feat: 多班级版 v2.0 - Go后端重写 + 43轮代码审查
- 后端从 Python FastAPI 重写为 Go Gin(端口 56789) - 多班级完全隔离 - 超级管理员独立登录 - 课代表作业管理、排行榜分项排行 - 角色加减分上下限可配置 - 家长改密功能(可开关) - 周度/月度重置功能 - MySQL 5.7 兼容 - 43轮代码审查+全部修复 - Apache 2.0 许可证
This commit is contained in:
163
backend-go/internal/config/config.go
Normal file
163
backend-go/internal/config/config.go
Normal file
@@ -0,0 +1,163 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
// Config 应用全局配置结构体
|
||||
type Config struct {
|
||||
// 应用基础配置
|
||||
AppName string
|
||||
AppEnv string
|
||||
Debug bool
|
||||
AppPort string
|
||||
|
||||
// MySQL 数据库配置
|
||||
DBHost string
|
||||
DBPort int
|
||||
DBUser string
|
||||
DBPassword string
|
||||
DBName string
|
||||
DBMaxOpenConns int
|
||||
DBMaxIdleConns int
|
||||
DBConnMaxLife int // 秒
|
||||
|
||||
// Redis 配置
|
||||
RedisHost string
|
||||
RedisPort int
|
||||
RedisPassword string
|
||||
RedisDB int
|
||||
RedisMaxConns int
|
||||
|
||||
// JWT 配置
|
||||
JWTSecretKey string
|
||||
JWTAlgorithm string
|
||||
JWTExpireMinutes int
|
||||
JWTIdleTimeoutMinutes int
|
||||
|
||||
// 密码加密(兼容 Python 版)
|
||||
PasswordSalt string
|
||||
|
||||
// 系统管理员配置
|
||||
SuperAdminLoginPath string
|
||||
SuperAdminDefaultUser string
|
||||
SuperAdminDefaultPass string
|
||||
|
||||
// 日志
|
||||
LogLevel string
|
||||
LogFile string
|
||||
}
|
||||
|
||||
// AppConfig 全局配置实例
|
||||
var AppConfig *Config
|
||||
|
||||
// Load 加载配置:先尝试加载 .env 文件,然后读取环境变量
|
||||
func Load() (*Config, error) {
|
||||
// 尝试加载 .env 文件(不存在不报错)
|
||||
_ = godotenv.Load()
|
||||
|
||||
cfg := &Config{
|
||||
AppName: getEnv("APP_NAME", "多班级版班级管理系统"),
|
||||
AppEnv: getEnv("APP_ENV", "production"),
|
||||
Debug: getEnvBool("DEBUG", false),
|
||||
AppPort: getEnv("APP_PORT", "56789"),
|
||||
|
||||
DBHost: getEnv("DB_HOST", "localhost"),
|
||||
DBPort: getEnvInt("DB_PORT", 3306),
|
||||
DBUser: getEnv("DB_USER", "class_admin"),
|
||||
DBPassword: getEnv("DB_PASSWORD", ""),
|
||||
DBName: getEnv("DB_NAME", "classmanagerdb"),
|
||||
DBMaxOpenConns: getEnvInt("DB_MAX_OPEN_CONNS", 25),
|
||||
DBMaxIdleConns: getEnvInt("DB_MAX_IDLE_CONNS", 10),
|
||||
DBConnMaxLife: getEnvInt("DB_CONN_MAX_LIFETIME", 300),
|
||||
|
||||
RedisHost: getEnv("REDIS_HOST", "localhost"),
|
||||
RedisPort: getEnvInt("REDIS_PORT", 6379),
|
||||
RedisPassword: getEnv("REDIS_PASSWORD", ""),
|
||||
RedisDB: getEnvInt("REDIS_DB", 0),
|
||||
RedisMaxConns: getEnvInt("REDIS_MAX_CONNECTIONS", 500),
|
||||
|
||||
JWTSecretKey: getEnv("JWT_SECRET_KEY", ""),
|
||||
JWTAlgorithm: getEnv("JWT_ALGORITHM", "HS256"),
|
||||
JWTExpireMinutes: getEnvInt("JWT_EXPIRE_MINUTES", 60),
|
||||
JWTIdleTimeoutMinutes: getEnvInt("JWT_IDLE_TIMEOUT_MINUTES", 10),
|
||||
|
||||
PasswordSalt: getEnv("PASSWORD_SALT", ""),
|
||||
|
||||
SuperAdminLoginPath: getEnv("SUPER_ADMIN_LOGIN_PATH", "/super-admin"),
|
||||
SuperAdminDefaultUser: getEnv("SUPER_ADMIN_DEFAULT_USERNAME", "admin"),
|
||||
// 安全警告:默认密码仅用于首次部署初始化,上线前必须在 .env 中修改 SUPER_ADMIN_DEFAULT_PASSWORD。
|
||||
// EnsureDefaultAdmin 通过 need_change_password=1 强制首次登录改密作为缓解措施。
|
||||
SuperAdminDefaultPass: getEnv("SUPER_ADMIN_DEFAULT_PASSWORD", "Admin123"),
|
||||
|
||||
LogLevel: getEnv("LOG_LEVEL", "info"),
|
||||
LogFile: getEnv("LOG_FILE", "logs/app.log"),
|
||||
}
|
||||
|
||||
// 校验必填项
|
||||
if cfg.JWTSecretKey == "" {
|
||||
return nil, fmt.Errorf("配置 JWT_SECRET_KEY 不能为空")
|
||||
}
|
||||
if cfg.PasswordSalt == "" {
|
||||
return nil, fmt.Errorf("配置 PASSWORD_SALT 不能为空")
|
||||
}
|
||||
|
||||
AppConfig = cfg
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// DSN 返回 MySQL 连接字符串
|
||||
func (c *Config) DSN() string {
|
||||
return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=True&loc=Local",
|
||||
c.DBUser, c.DBPassword, c.DBHost, c.DBPort, c.DBName)
|
||||
}
|
||||
|
||||
// RedisAddr 返回 Redis 地址
|
||||
func (c *Config) RedisAddr() string {
|
||||
return fmt.Sprintf("%s:%d", c.RedisHost, c.RedisPort)
|
||||
}
|
||||
|
||||
// IsProduction 判断是否为生产环境
|
||||
func (c *Config) IsProduction() bool {
|
||||
return c.AppEnv == "production"
|
||||
}
|
||||
|
||||
// --- 辅助函数 ---
|
||||
|
||||
func getEnv(key, fallback string) string {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
return val
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func getEnvInt(key string, fallback int) int {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
if i, err := strconv.Atoi(val); err == nil {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
func getEnvBool(key string, fallback bool) bool {
|
||||
if val, ok := os.LookupEnv(key); ok {
|
||||
return strings.ToLower(val) == "true"
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
602
backend-go/internal/handler/admin_handler.go
Normal file
602
backend-go/internal/handler/admin_handler.go
Normal file
@@ -0,0 +1,602 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// AdminHandler 管理端处理器
|
||||
type AdminHandler struct {
|
||||
adminService *service.AdminService
|
||||
conductService *service.ConductService
|
||||
attendanceSvc *service.AttendanceService
|
||||
rankingService *service.RankingService
|
||||
logService *service.LogService
|
||||
}
|
||||
|
||||
// NewAdminHandler 创建管理端处理器
|
||||
func NewAdminHandler(
|
||||
adminService *service.AdminService,
|
||||
conductService *service.ConductService,
|
||||
attendanceSvc *service.AttendanceService,
|
||||
rankingService *service.RankingService,
|
||||
logService *service.LogService,
|
||||
) *AdminHandler {
|
||||
return &AdminHandler{
|
||||
adminService: adminService,
|
||||
conductService: conductService,
|
||||
attendanceSvc: attendanceSvc,
|
||||
rankingService: rankingService,
|
||||
logService: logService,
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 学生管理 ==========
|
||||
|
||||
// GetDormitories 获取宿舍号列表
|
||||
func (h *AdminHandler) GetDormitories(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "请先选择班级")
|
||||
return
|
||||
}
|
||||
dormitories, err := h.adminService.GetDormitories(classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取宿舍号列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, gin.H{"dormitories": dormitories}, "操作成功")
|
||||
}
|
||||
|
||||
// StudentList 获取学生列表
|
||||
func (h *AdminHandler) StudentList(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "请先选择班级")
|
||||
return
|
||||
}
|
||||
|
||||
var query schema.StudentListQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.adminService.GetStudents(classID, query.Page, query.PageSize, query.Search, query.DormitoryNumber)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取学生列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// StudentImport 批量导入学生
|
||||
func (h *AdminHandler) StudentImport(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "请先选择班级")
|
||||
return
|
||||
}
|
||||
|
||||
file, _, err := c.Request.FormFile("file")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "请上传文件")
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
limitedReader := io.LimitReader(file, 5*1024*1024)
|
||||
content, err := io.ReadAll(limitedReader)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "读取文件失败")
|
||||
return
|
||||
}
|
||||
|
||||
var data struct {
|
||||
Students []map[string]interface{} `json:"students"`
|
||||
}
|
||||
if err := json.Unmarshal(content, &data); err != nil {
|
||||
response.BadRequest(c, "JSON格式错误")
|
||||
return
|
||||
}
|
||||
if len(data.Students) == 0 {
|
||||
response.BadRequest(c, "文件中没有学生数据")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.adminService.ImportStudents(data.Students, classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "导入失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// StudentCreate 新增学生
|
||||
func (h *AdminHandler) StudentCreate(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "请先选择班级")
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.StudentCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.adminService.AddStudent(req.StudentNo, req.Name, req.ParentAccount, classID, req.DormitoryNumber)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作失败"
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result, "学生添加成功")
|
||||
}
|
||||
|
||||
// StudentUpdate 编辑学生
|
||||
func (h *AdminHandler) StudentUpdate(c *gin.Context) {
|
||||
studentID, ok := parseID(c, "student_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
var req schema.StudentUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.UpdateStudent(studentID, req.Name, req.ParentAccount, req.DormitoryNumber, classID); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "更新成功")
|
||||
}
|
||||
|
||||
// StudentDelete 删除学生
|
||||
func (h *AdminHandler) StudentDelete(c *gin.Context) {
|
||||
studentID, ok := parseID(c, "student_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
if err := h.adminService.DeleteStudent(studentID, classID); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// ResetStudentPassword 重置学生密码
|
||||
func (h *AdminHandler) ResetStudentPassword(c *gin.Context) {
|
||||
studentID, ok := parseID(c, "student_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.ResetStudentPassword(studentID, req.NewPassword); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "密码重置成功")
|
||||
}
|
||||
|
||||
// ========== 操行分管理 ==========
|
||||
|
||||
// AddConductPoints 批量加减分
|
||||
func (h *AdminHandler) AddConductPoints(c *gin.Context) {
|
||||
var req schema.ConductAddRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
realName := middleware.GetRealName(c)
|
||||
|
||||
result, err := h.conductService.AddPoints(
|
||||
req.StudentIDs, req.PointsChange, req.Reason,
|
||||
userID, realName, classID, req.RelatedType,
|
||||
)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作失败"
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// RevokeConductRecord 撤销记录
|
||||
func (h *AdminHandler) RevokeConductRecord(c *gin.Context) {
|
||||
var req schema.RevokeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
classID := middleware.GetClassID(c)
|
||||
result, err := h.conductService.RevokeRecord(req.RecordID, userID, classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作失败"
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "撤销成功")
|
||||
}
|
||||
|
||||
// RestoreConductRecord 反撤销记录
|
||||
func (h *AdminHandler) RestoreConductRecord(c *gin.Context) {
|
||||
var req schema.RevokeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
classID := middleware.GetClassID(c)
|
||||
result, err := h.conductService.RestoreRecord(req.RecordID, userID, classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作失败"
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "反撤销成功")
|
||||
}
|
||||
|
||||
// GetConductHistory 操行分历史
|
||||
func (h *AdminHandler) GetConductHistory(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
var query schema.ConductHistoryQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.conductService.GetHistory(
|
||||
classID, query.StudentID, query.Page, query.PageSize,
|
||||
query.StartDate, query.EndDate, query.RelatedType,
|
||||
query.ReasonPrefix, query.IsRevoked, query.ReasonSearch,
|
||||
)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// BatchRevokeConductRecords 批量撤销
|
||||
func (h *AdminHandler) BatchRevokeConductRecords(c *gin.Context) {
|
||||
var req schema.BatchRevokeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
classID := middleware.GetClassID(c)
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
var errors []map[string]interface{}
|
||||
|
||||
for _, recordID := range req.RecordIDs {
|
||||
result, _ := h.conductService.RevokeRecord(recordID, userID, classID)
|
||||
if result != nil {
|
||||
if success, _ := result["success"].(bool); success {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
msg, _ := result["message"].(string)
|
||||
errors = append(errors, map[string]interface{}{"record_id": recordID, "error": msg})
|
||||
}
|
||||
} else {
|
||||
failCount++
|
||||
errors = append(errors, map[string]interface{}{"record_id": recordID, "error": "撤销失败"})
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"success_count": successCount,
|
||||
"fail_count": failCount,
|
||||
"errors": errors,
|
||||
}, "批量撤销完成")
|
||||
}
|
||||
|
||||
// BatchRestoreConductRecords 批量反撤销
|
||||
func (h *AdminHandler) BatchRestoreConductRecords(c *gin.Context) {
|
||||
var req schema.BatchRevokeRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
classID := middleware.GetClassID(c)
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
var errors []map[string]interface{}
|
||||
|
||||
for _, recordID := range req.RecordIDs {
|
||||
result, _ := h.conductService.RestoreRecord(recordID, userID, classID)
|
||||
if result != nil {
|
||||
if success, _ := result["success"].(bool); success {
|
||||
successCount++
|
||||
} else {
|
||||
failCount++
|
||||
msg, _ := result["message"].(string)
|
||||
errors = append(errors, map[string]interface{}{"record_id": recordID, "error": msg})
|
||||
}
|
||||
} else {
|
||||
failCount++
|
||||
errors = append(errors, map[string]interface{}{"record_id": recordID, "error": "反撤销失败"})
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"success_count": successCount,
|
||||
"fail_count": failCount,
|
||||
"errors": errors,
|
||||
}, "批量反撤销完成")
|
||||
}
|
||||
|
||||
// ========== 考勤管理 ==========
|
||||
|
||||
// CreateAttendanceRecord 添加考勤
|
||||
func (h *AdminHandler) CreateAttendanceRecord(c *gin.Context) {
|
||||
var req schema.AttendanceCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
classID := middleware.GetClassID(c)
|
||||
result, err := h.attendanceSvc.CreateRecord(
|
||||
req.StudentID, req.Date, req.Slot, req.Status,
|
||||
&req.Reason, req.ApplyDeduction, req.CustomDeduction, userID, classID,
|
||||
)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作失败"
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
return
|
||||
}
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作成功"
|
||||
}
|
||||
response.SuccessWithMessage(c, msg)
|
||||
}
|
||||
|
||||
// GetAttendanceRecords 获取考勤记录
|
||||
func (h *AdminHandler) GetAttendanceRecords(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
var query schema.AttendanceQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.attendanceSvc.GetRecords(classID, query.Date, query.StudentID, query.Slot)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// ========== 管理员管理 ==========
|
||||
|
||||
// AdminList 管理员列表
|
||||
func (h *AdminHandler) AdminList(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
result, err := h.adminService.GetAdmins(classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取管理员列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// AdminCreate 添加管理员
|
||||
func (h *AdminHandler) AdminCreate(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "请先选择班级")
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.AdminCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.adminService.AddAdmin(req.Username, req.RealName, req.Password, req.RoleType, classID, req.SubjectID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作失败"
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "管理员添加成功")
|
||||
}
|
||||
|
||||
// AdminUpdate 更新管理员
|
||||
func (h *AdminHandler) AdminUpdate(c *gin.Context) {
|
||||
userID, ok := parseID(c, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
var req schema.AdminUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.UpdateAdmin(userID, req.RealName, req.RoleType, classID, req.SubjectID); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "更新成功")
|
||||
}
|
||||
|
||||
// AdminDelete 删除管理员
|
||||
func (h *AdminHandler) AdminDelete(c *gin.Context) {
|
||||
userID, ok := parseID(c, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
if err := h.adminService.DeleteAdmin(userID, classID); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// AdminResetPassword 重置管理员密码
|
||||
func (h *AdminHandler) AdminResetPassword(c *gin.Context) {
|
||||
userID, ok := parseID(c, "user_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.ResetPasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.ResetAdminPassword(userID, req.NewPassword); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "密码重置成功")
|
||||
}
|
||||
|
||||
// UnlockAccount 解除登录锁定
|
||||
func (h *AdminHandler) UnlockAccount(c *gin.Context) {
|
||||
var req schema.UnlockUserRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.adminService.UnlockAccount(req.Username, c.ClientIP()); err != nil {
|
||||
response.InternalError(c, "解锁失败")
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "解锁成功")
|
||||
}
|
||||
|
||||
|
||||
// GetRankings 分项排行榜
|
||||
func (h *AdminHandler) GetRankings(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "请先选择班级")
|
||||
return
|
||||
}
|
||||
|
||||
rankType := c.DefaultQuery("type", "all")
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
result, err := h.rankingService.GetRankings(classID, rankType, limit)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
131
backend-go/internal/handler/auth_handler.go
Normal file
131
backend-go/internal/handler/auth_handler.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// AuthHandler 认证处理器
|
||||
type AuthHandler struct {
|
||||
authService *service.AuthService
|
||||
superAdminService *service.SuperAdminService
|
||||
}
|
||||
|
||||
// NewAuthHandler 创建认证处理器
|
||||
func NewAuthHandler(authService *service.AuthService, superAdminService *service.SuperAdminService) *AuthHandler {
|
||||
return &AuthHandler{authService: authService, superAdminService: superAdminService}
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (h *AuthHandler) Login(c *gin.Context) {
|
||||
var req schema.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
|
||||
result := h.authService.Login(req.Username, req.Password, ip, userAgent)
|
||||
if !result.Success {
|
||||
response.Unauthorized(c, result.Message)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result, "登录成功")
|
||||
}
|
||||
|
||||
// Logout 用户登出
|
||||
func (h *AuthHandler) Logout(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
if err := h.authService.Logout(userID); err != nil {
|
||||
response.InternalError(c, "登出失败")
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "登出成功")
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码(超级管理员操作 super_admins 表,普通用户操作 users 表)
|
||||
func (h *AuthHandler) ChangePassword(c *gin.Context) {
|
||||
var req schema.ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
userType := middleware.GetUserType(c)
|
||||
|
||||
// force 参数仅在用户确实需要强制改密时才允许使用
|
||||
if req.Force {
|
||||
if userType == "super_admin" {
|
||||
// 超级管理员的 need_change_password 由 super_admin_service 处理
|
||||
// force 改密时直接允许(登录时已验证 need_change_password 标记)
|
||||
} else {
|
||||
userInfo, err := h.authService.GetUserInfo(userID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
needChange, _ := userInfo["need_change_password"].(bool)
|
||||
if !needChange {
|
||||
response.BadRequest(c, "当前状态不允许强制修改密码")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if userType == "super_admin" {
|
||||
if err := h.superAdminService.ChangePassword(userID, req.OldPassword, req.NewPassword, req.Force); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
} else {
|
||||
if err := h.authService.ChangePassword(userID, req.OldPassword, req.NewPassword, req.Force); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.SuccessWithMessage(c, "密码修改成功,请重新登录")
|
||||
}
|
||||
|
||||
// GetUserInfo 获取当前用户信息
|
||||
func (h *AuthHandler) GetUserInfo(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
userInfo, err := h.authService.GetUserInfo(userID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, userInfo, "操作成功")
|
||||
}
|
||||
|
||||
// parseID 解析路径参数中的 ID
|
||||
func parseID(c *gin.Context, key string) (int, bool) {
|
||||
idStr := c.Param(key)
|
||||
id, err := strconv.Atoi(idStr)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "无效的ID参数")
|
||||
return 0, false
|
||||
}
|
||||
return id, true
|
||||
}
|
||||
143
backend-go/internal/handler/cadre_handler.go
Normal file
143
backend-go/internal/handler/cadre_handler.go
Normal file
@@ -0,0 +1,143 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// CadreHandler 课代表处理器
|
||||
type CadreHandler struct {
|
||||
assignmentRepo *repository.AssignmentRepo
|
||||
conductService *service.ConductService
|
||||
adminRoleRepo *repository.AdminRoleRepo
|
||||
}
|
||||
|
||||
// NewCadreHandler 创建课代表处理器
|
||||
func NewCadreHandler(assignmentRepo *repository.AssignmentRepo, conductService *service.ConductService, adminRoleRepo *repository.AdminRoleRepo) *CadreHandler {
|
||||
return &CadreHandler{assignmentRepo: assignmentRepo, conductService: conductService, adminRoleRepo: adminRoleRepo}
|
||||
}
|
||||
|
||||
// HomeworkList 课代表查看作业列表
|
||||
func (h *CadreHandler) HomeworkList(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
var query schema.CadreHomeworkQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
subjectID := 0
|
||||
if query.SubjectID != nil {
|
||||
subjectID = *query.SubjectID
|
||||
}
|
||||
|
||||
assignments, total, err := h.assignmentRepo.GetAssignmentsByClass(classID, subjectID, query.Page, query.PageSize)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取作业列表失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Paginated(c, assignments, total, query.Page, query.PageSize)
|
||||
}
|
||||
|
||||
// HomeworkSubmit 课代表发布作业
|
||||
func (h *CadreHandler) HomeworkSubmit(c *gin.Context) {
|
||||
var req schema.CadreHomeworkSubmitRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
|
||||
// 从管理员角色中获取课代表关联的科目 ID
|
||||
adminRole, err := h.adminRoleRepo.GetByUserID(userID)
|
||||
if err != nil || adminRole == nil || adminRole.SubjectID == nil {
|
||||
response.BadRequest(c, "无法获取课代表关联的科目信息")
|
||||
return
|
||||
}
|
||||
|
||||
deadline, err := time.Parse("2006-01-02", req.Deadline)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "日期格式错误")
|
||||
return
|
||||
}
|
||||
|
||||
assignment := &model.Assignment{
|
||||
ClassID: classID,
|
||||
SubjectID: *adminRole.SubjectID,
|
||||
Title: req.Title,
|
||||
Description: &req.Description,
|
||||
Deadline: deadline,
|
||||
CreatedBy: userID,
|
||||
}
|
||||
|
||||
assignmentID, err := h.assignmentRepo.CreateAssignment(assignment)
|
||||
if err != nil {
|
||||
response.InternalError(c, "发布作业失败")
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"assignment_id": assignmentID,
|
||||
}, "发布成功")
|
||||
}
|
||||
|
||||
// AddConductPoints 课代表登记缺交(仅允许作业相关的扣分操作)
|
||||
func (h *CadreHandler) AddConductPoints(c *gin.Context) {
|
||||
var req schema.ConductAddRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
// 课代表只允许扣分操作
|
||||
if req.PointsChange >= 0 {
|
||||
response.BadRequest(c, "课代表只能进行扣分操作")
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
userID := middleware.GetUserID(c)
|
||||
realName := middleware.GetRealName(c)
|
||||
|
||||
result, err := h.conductService.CadreAddPoints(
|
||||
req.StudentIDs, req.PointsChange, req.Reason,
|
||||
userID, realName, classID, "homework",
|
||||
)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
msg, _ := result["message"].(string)
|
||||
if msg == "" {
|
||||
msg = "操作失败"
|
||||
}
|
||||
response.BadRequest(c, msg)
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
271
backend-go/internal/handler/class_handler.go
Normal file
271
backend-go/internal/handler/class_handler.go
Normal file
@@ -0,0 +1,271 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// ClassHandler 班级管理处理器
|
||||
type ClassHandler struct {
|
||||
classService *service.ClassService
|
||||
}
|
||||
|
||||
// NewClassHandler 创建班级管理处理器
|
||||
func NewClassHandler(classService *service.ClassService) *ClassHandler {
|
||||
return &ClassHandler{classService: classService}
|
||||
}
|
||||
|
||||
// ClassList 班级列表
|
||||
func (h *ClassHandler) ClassList(c *gin.Context) {
|
||||
includeDisabled := c.Query("include_disabled") == "true"
|
||||
result, err := h.classService.ListClasses(includeDisabled)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取班级列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// ClassDetail 班级详情
|
||||
func (h *ClassHandler) ClassDetail(c *gin.Context) {
|
||||
classID, ok := parseID(c, "class_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.classService.GetClassDetail(classID)
|
||||
if err != nil {
|
||||
response.NotFound(c, "班级不存在")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// ClassCreate 创建班级
|
||||
func (h *ClassHandler) ClassCreate(c *gin.Context) {
|
||||
var req schema.ClassCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.classService.CreateClass(req.ClassName, req.Grade, req.Description)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
response.BadRequest(c, result["message"].(string))
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "班级创建成功")
|
||||
}
|
||||
|
||||
// ClassUpdate 更新班级
|
||||
func (h *ClassHandler) ClassUpdate(c *gin.Context) {
|
||||
classID, ok := parseID(c, "class_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.ClassUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.classService.UpdateClass(classID, req.ClassName, req.Grade, req.Description, req.Status); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "更新成功")
|
||||
}
|
||||
|
||||
// ClassDelete 删除班级
|
||||
func (h *ClassHandler) ClassDelete(c *gin.Context) {
|
||||
classID, ok := parseID(c, "class_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.classService.DeleteClass(classID); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// SwitchClass 切换班级上下文
|
||||
func (h *ClassHandler) SwitchClass(c *gin.Context) {
|
||||
var req schema.SwitchClassRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
result, err := h.classService.SwitchClass(userID, req.ClassID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "切换成功")
|
||||
}
|
||||
|
||||
// GetSettings 获取班级设置
|
||||
func (h *ClassHandler) GetSettings(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
result, err := h.classService.GetSettings(classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取设置失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// allowedSettingKeys 允许通过 SaveSetting 端点写入的配置键白名单
|
||||
var allowedSettingKeys = map[string]bool{
|
||||
"initial_password": true,
|
||||
"initial_points": true,
|
||||
"deduction_attendance_absent": true,
|
||||
"deduction_attendance_late": true,
|
||||
"deduction_attendance_leave": true,
|
||||
"deduction_homework_not_submit": true,
|
||||
"deduction_homework_late": true,
|
||||
"reset_frequency": true,
|
||||
"reset_day_of_week": true,
|
||||
"reset_day_of_month": true,
|
||||
}
|
||||
|
||||
// SaveSetting 保存班级设置
|
||||
func (h *ClassHandler) SaveSetting(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
var req schema.SettingRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if !allowedSettingKeys[req.SettingKey] {
|
||||
response.BadRequest(c, "不允许的配置项: "+req.SettingKey)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.classService.SaveSetting(classID, req.SettingKey, req.SettingValue); err != nil {
|
||||
response.InternalError(c, "保存设置失败")
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "保存成功")
|
||||
}
|
||||
|
||||
// GetPointLimits 获取角色加减分配置
|
||||
func (h *ClassHandler) GetPointLimits(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
result, err := h.classService.GetSettings(classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取配置失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// allowedPointLimitKeys 允许的操行分限制配置键白名单(与 conduct_service 读取 key 一致)
|
||||
var allowedPointLimitKeys = map[string]bool{
|
||||
"point_limit_班长_max": true,
|
||||
"point_limit_班长_min": true,
|
||||
"point_limit_学习委员_max": true,
|
||||
"point_limit_学习委员_min": true,
|
||||
"point_limit_考勤委员_max": true,
|
||||
"point_limit_考勤委员_min": true,
|
||||
"point_limit_劳动委员_max": true,
|
||||
"point_limit_劳动委员_min": true,
|
||||
"point_limit_志愿委员_max": true,
|
||||
"point_limit_志愿委员_min": true,
|
||||
"point_limit_科任老师_max": true,
|
||||
"point_limit_科任老师_min": true,
|
||||
}
|
||||
|
||||
// SavePointLimits 保存角色加减分配置
|
||||
func (h *ClassHandler) SavePointLimits(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
var req map[string]string
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
for key, value := range req {
|
||||
if !allowedPointLimitKeys[key] {
|
||||
response.BadRequest(c, "不允许的配置项: "+key)
|
||||
return
|
||||
}
|
||||
if err := h.classService.SaveSetting(classID, key, value); err != nil {
|
||||
response.InternalError(c, "保存配置失败")
|
||||
return
|
||||
}
|
||||
}
|
||||
response.SuccessWithMessage(c, "保存成功")
|
||||
}
|
||||
|
||||
// GetFeatures 获取功能开关
|
||||
func (h *ClassHandler) GetFeatures(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
result, err := h.classService.GetFeatures(classID)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取功能开关失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// allowedFeatureKeys 允许的功能开关键白名单
|
||||
var allowedFeatureKeys = map[string]bool{
|
||||
"parent_account_enabled": true,
|
||||
"parent_password_change_enabled": true,
|
||||
"parent_view_attendance": true,
|
||||
"parent_view_ranking": true,
|
||||
"student_view_ranking": true,
|
||||
"homework_management": true,
|
||||
"attendance_management": true,
|
||||
"cadre_homework": true,
|
||||
}
|
||||
|
||||
// SaveFeature 保存功能开关
|
||||
func (h *ClassHandler) SaveFeature(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
var req schema.FeatureToggleRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if !allowedFeatureKeys[req.FeatureKey] {
|
||||
response.BadRequest(c, "不允许的功能开关: "+req.FeatureKey)
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.classService.SaveFeature(classID, req.FeatureKey, req.Enabled); err != nil {
|
||||
response.InternalError(c, "保存功能开关失败")
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "保存成功")
|
||||
}
|
||||
44
backend-go/internal/handler/config_handler.go
Normal file
44
backend-go/internal/handler/config_handler.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// ConfigHandler 配置处理器
|
||||
type ConfigHandler struct {
|
||||
configService *service.ConfigService
|
||||
}
|
||||
|
||||
// NewConfigHandler 创建配置处理器
|
||||
func NewConfigHandler(configService *service.ConfigService) *ConfigHandler {
|
||||
return &ConfigHandler{configService: configService}
|
||||
}
|
||||
|
||||
// GetDeductionRules 获取扣分规则(优先从 class_settings 读取班级级配置)
|
||||
func (h *ConfigHandler) GetDeductionRules(c *gin.Context) {
|
||||
classID := 0
|
||||
if classIDStr := c.Query("class_id"); classIDStr != "" {
|
||||
if id, err := strconv.Atoi(classIDStr); err == nil {
|
||||
classID = id
|
||||
}
|
||||
}
|
||||
|
||||
rules := h.configService.GetDeductionRules(classID)
|
||||
response.Success(c, rules, "操作成功")
|
||||
}
|
||||
20
backend-go/internal/handler/handler_utils.go
Normal file
20
backend-go/internal/handler/handler_utils.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// parseQueryParamInt 解析查询参数为 int
|
||||
func parseQueryParamInt(c *gin.Context, key string, defaultVal int) int {
|
||||
val := c.Query(key)
|
||||
if val == "" {
|
||||
return defaultVal
|
||||
}
|
||||
i, err := strconv.Atoi(val)
|
||||
if err != nil {
|
||||
return defaultVal
|
||||
}
|
||||
return i
|
||||
}
|
||||
115
backend-go/internal/handler/parent_handler.go
Normal file
115
backend-go/internal/handler/parent_handler.go
Normal file
@@ -0,0 +1,115 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// ParentHandler 家长端处理器
|
||||
type ParentHandler struct {
|
||||
parentService *service.ParentService
|
||||
authService *service.AuthService
|
||||
classService *service.ClassService
|
||||
}
|
||||
|
||||
// NewParentHandler 创建家长端处理器
|
||||
func NewParentHandler(
|
||||
parentService *service.ParentService,
|
||||
authService *service.AuthService,
|
||||
classService *service.ClassService,
|
||||
) *ParentHandler {
|
||||
return &ParentHandler{
|
||||
parentService: parentService,
|
||||
authService: authService,
|
||||
classService: classService,
|
||||
}
|
||||
}
|
||||
|
||||
// Dashboard 子女操行分(家长仪表盘)
|
||||
func (h *ParentHandler) Dashboard(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
result, err := h.parentService.GetChildConduct(userID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// History 子女历史记录
|
||||
func (h *ParentHandler) History(c *gin.Context) {
|
||||
var query schema.ParentHistoryQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
result, err := h.parentService.GetChildHistory(userID, query.Page, query.PageSize)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// Attendance 子女考勤
|
||||
func (h *ParentHandler) Attendance(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
result, err := h.parentService.GetChildAttendance(userID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// Ranking 子女排名
|
||||
func (h *ParentHandler) Ranking(c *gin.Context) {
|
||||
userID := middleware.GetUserID(c)
|
||||
result, err := h.parentService.GetChildRanking(userID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// ChangePassword 家长修改密码(受功能开关控制)
|
||||
func (h *ParentHandler) ChangePassword(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
// 检查功能开关
|
||||
if !h.classService.IsFeatureEnabled(classID, "parent_password_change_enabled") {
|
||||
response.Forbidden(c, "该功能暂未开放")
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.ChangePasswordRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
if err := h.authService.ChangePassword(userID, req.OldPassword, req.NewPassword, false); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "密码修改成功")
|
||||
}
|
||||
230
backend-go/internal/handler/semester_handler.go
Normal file
230
backend-go/internal/handler/semester_handler.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// SemesterHandler 学期管理处理器
|
||||
type SemesterHandler struct {
|
||||
semesterService *service.SemesterService
|
||||
}
|
||||
|
||||
// NewSemesterHandler 创建学期管理处理器
|
||||
func NewSemesterHandler(semesterService *service.SemesterService) *SemesterHandler {
|
||||
return &SemesterHandler{semesterService: semesterService}
|
||||
}
|
||||
|
||||
// SemesterList 学期列表
|
||||
func (h *SemesterHandler) SemesterList(c *gin.Context) {
|
||||
result, err := h.semesterService.ListSemesters()
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取学期列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// ActiveSemester 当前学期
|
||||
func (h *SemesterHandler) ActiveSemester(c *gin.Context) {
|
||||
semester, err := h.semesterService.GetActiveSemester()
|
||||
if err != nil {
|
||||
response.Success(c, nil, "无活跃学期")
|
||||
return
|
||||
}
|
||||
response.Success(c, semester, "操作成功")
|
||||
}
|
||||
|
||||
// SemesterCreate 创建学期
|
||||
func (h *SemesterHandler) SemesterCreate(c *gin.Context) {
|
||||
var req schema.SemesterCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.semesterService.CreateSemester(req.SemesterName, req.StartDate, req.EndDate)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
response.BadRequest(c, result["message"].(string))
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// ActivateSemester 激活学期
|
||||
func (h *SemesterHandler) ActivateSemester(c *gin.Context) {
|
||||
semesterID, ok := parseID(c, "semester_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.semesterService.ActivateSemester(semesterID); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "已设为当前学期")
|
||||
}
|
||||
|
||||
// SemesterUpdate 编辑学期
|
||||
func (h *SemesterHandler) SemesterUpdate(c *gin.Context) {
|
||||
semesterID, ok := parseID(c, "semester_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.SemesterUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.semesterService.UpdateSemester(semesterID, req.SemesterName, req.StartDate, req.EndDate); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "更新成功")
|
||||
}
|
||||
|
||||
// SemesterDelete 删除学期
|
||||
func (h *SemesterHandler) SemesterDelete(c *gin.Context) {
|
||||
semesterID, ok := parseID(c, "semester_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.semesterService.DeleteSemester(semesterID); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// AssociateRecords 关联记录
|
||||
func (h *SemesterHandler) AssociateRecords(c *gin.Context) {
|
||||
semesterID, ok := parseID(c, "semester_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.semesterService.AssociateRecords(semesterID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
response.BadRequest(c, result["message"].(string))
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// ArchiveSemester 归档学期
|
||||
func (h *SemesterHandler) ArchiveSemester(c *gin.Context) {
|
||||
semesterID, ok := parseID(c, "semester_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
classID := parseQueryParamInt(c, "class_id", 0)
|
||||
resetScores := c.Query("reset_scores") == "true"
|
||||
|
||||
result, err := h.semesterService.ArchiveSemester(semesterID, classID, resetScores)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
response.BadRequest(c, result["message"].(string))
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// GetArchiveData 归档数据
|
||||
func (h *SemesterHandler) GetArchiveData(c *gin.Context) {
|
||||
semesterID, ok := parseID(c, "semester_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
classID := parseQueryParamInt(c, "class_id", 0)
|
||||
page := parseQueryParamInt(c, "page", 1)
|
||||
pageSize := parseQueryParamInt(c, "page_size", 20)
|
||||
|
||||
result, err := h.semesterService.GetArchiveRecords(semesterID, classID, page, pageSize)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// PeriodReset 手动触发周/月重置
|
||||
func (h *SemesterHandler) PeriodReset(c *gin.Context) {
|
||||
var req schema.PeriodResetRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "未指定班级")
|
||||
return
|
||||
}
|
||||
|
||||
userID := middleware.GetUserID(c)
|
||||
realName := middleware.GetRealName(c)
|
||||
ip := c.ClientIP()
|
||||
|
||||
if err := h.semesterService.PeriodReset(classID, req.Period, userID, realName, ip); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, service.PeriodLabelCN(req.Period)+"重置成功")
|
||||
}
|
||||
|
||||
// GetPeriodArchives 查看周期归档数据
|
||||
func (h *SemesterHandler) GetPeriodArchives(c *gin.Context) {
|
||||
var req schema.PeriodArchiveQuery
|
||||
if err := c.ShouldBindQuery(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
classID := middleware.GetClassID(c)
|
||||
if classID == 0 {
|
||||
response.BadRequest(c, "未指定班级")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.semesterService.GetPeriodArchives(classID, req.Period, req.Page, req.PageSize)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
192
backend-go/internal/handler/student_handler.go
Normal file
192
backend-go/internal/handler/student_handler.go
Normal file
@@ -0,0 +1,192 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// StudentHandler 学生端处理器
|
||||
type StudentHandler struct {
|
||||
studentService *service.StudentService
|
||||
classRepo *repository.ClassRepo
|
||||
}
|
||||
|
||||
// NewStudentHandler 创建学生端处理器
|
||||
func NewStudentHandler(studentService *service.StudentService, classRepo *repository.ClassRepo) *StudentHandler {
|
||||
return &StudentHandler{studentService: studentService, classRepo: classRepo}
|
||||
}
|
||||
|
||||
// Dashboard 学生个人信息(仪表盘)
|
||||
func (h *StudentHandler) Dashboard(c *gin.Context) {
|
||||
studentID := middleware.GetStudentID(c)
|
||||
if studentID == 0 {
|
||||
response.BadRequest(c, "非学生用户")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.studentService.GetStudentInfo(studentID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// resolveStudentID 校验学生归属:学生只能查看自己的数据,家长只能查看关联子女,管理员可查看指定学生
|
||||
func (h *StudentHandler) resolveStudentID(c *gin.Context) (int, bool) {
|
||||
userType := middleware.GetUserType(c)
|
||||
if userType == "student" {
|
||||
// 学生只能查看自己的数据,忽略 URL 参数中的 student_id
|
||||
studentID := middleware.GetStudentID(c)
|
||||
if studentID == 0 {
|
||||
response.BadRequest(c, "非学生用户")
|
||||
return 0, false
|
||||
}
|
||||
return studentID, true
|
||||
}
|
||||
|
||||
requestedID, ok := parseID(c, "student_id")
|
||||
if !ok {
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// 家长只能查看自己关联的子女数据
|
||||
if userType == "parent" {
|
||||
parentStudentID := middleware.GetStudentID(c)
|
||||
if parentStudentID == 0 || parentStudentID != requestedID {
|
||||
response.Forbidden(c, "无权访问该学生数据")
|
||||
return 0, false
|
||||
}
|
||||
return requestedID, true
|
||||
}
|
||||
|
||||
// 管理员/超级管理员允许查看(角色权限由路由中间件 RequireRole 控制)
|
||||
return requestedID, true
|
||||
}
|
||||
|
||||
// ConductHistory 学生操行分历史
|
||||
func (h *StudentHandler) ConductHistory(c *gin.Context) {
|
||||
studentID, ok := h.resolveStudentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var query schema.StudentConductQuery
|
||||
if err := c.ShouldBindQuery(&query); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.studentService.GetConductHistory(studentID, query.Limit, query.Offset)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// Homework 学生作业情况
|
||||
func (h *StudentHandler) Homework(c *gin.Context) {
|
||||
studentID, ok := h.resolveStudentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.studentService.GetHomeworkStatus(studentID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// Attendance 学生考勤记录
|
||||
func (h *StudentHandler) Attendance(c *gin.Context) {
|
||||
studentID, ok := h.resolveStudentID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
month := c.Query("month")
|
||||
result, err := h.studentService.GetAttendanceRecords(studentID, month)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// Ranking 操行分排行
|
||||
func (h *StudentHandler) Ranking(c *gin.Context) {
|
||||
classID := middleware.GetClassID(c)
|
||||
|
||||
// 检查班级功能开关:学生查看排行榜
|
||||
feature, err := h.classRepo.GetFeature(classID, "student_view_ranking")
|
||||
if err == nil && feature != nil && feature.Enabled == 0 {
|
||||
response.Forbidden(c, "该功能暂未开放")
|
||||
return
|
||||
}
|
||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 500 {
|
||||
limit = 500
|
||||
}
|
||||
|
||||
result, err := h.studentService.GetRanking(classID, limit)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// MyInfo 学生个人信息
|
||||
func (h *StudentHandler) MyInfo(c *gin.Context) {
|
||||
studentID := middleware.GetStudentID(c)
|
||||
if studentID == 0 {
|
||||
response.BadRequest(c, "非学生用户")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.studentService.GetStudentInfo(studentID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// SemesterRecords 学期归档记录
|
||||
func (h *StudentHandler) SemesterRecords(c *gin.Context) {
|
||||
studentID := middleware.GetStudentID(c)
|
||||
if studentID <= 0 {
|
||||
response.BadRequest(c, "非学生用户")
|
||||
return
|
||||
}
|
||||
result, err := h.studentService.GetSemesterRecords(studentID)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
152
backend-go/internal/handler/subject_handler.go
Normal file
152
backend-go/internal/handler/subject_handler.go
Normal file
@@ -0,0 +1,152 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// SubjectHandler 科目管理处理器
|
||||
type SubjectHandler struct {
|
||||
subjectService *service.SubjectService
|
||||
}
|
||||
|
||||
// NewSubjectHandler 创建科目管理处理器
|
||||
func NewSubjectHandler(subjectService *service.SubjectService) *SubjectHandler {
|
||||
return &SubjectHandler{subjectService: subjectService}
|
||||
}
|
||||
|
||||
// SubjectList 科目列表
|
||||
func (h *SubjectHandler) SubjectList(c *gin.Context) {
|
||||
var isActive *bool
|
||||
if v := c.Query("is_active"); v == "true" {
|
||||
b := true
|
||||
isActive = &b
|
||||
} else if v == "false" {
|
||||
b := false
|
||||
isActive = &b
|
||||
}
|
||||
|
||||
result, err := h.subjectService.GetSubjects(isActive)
|
||||
if err != nil {
|
||||
response.InternalError(c, "获取科目列表失败")
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// SubjectCreate 创建科目
|
||||
func (h *SubjectHandler) SubjectCreate(c *gin.Context) {
|
||||
var req schema.SubjectCreateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.subjectService.CreateSubject(req.SubjectName, req.SubjectCode, req.SortOrder)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if success, _ := result["success"].(bool); !success {
|
||||
response.BadRequest(c, result["message"].(string))
|
||||
return
|
||||
}
|
||||
response.Success(c, result, "操作成功")
|
||||
}
|
||||
|
||||
// SubjectUpdate 更新科目
|
||||
func (h *SubjectHandler) SubjectUpdate(c *gin.Context) {
|
||||
subjectID, ok := parseID(c, "subject_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req schema.SubjectUpdateRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if req.SubjectName != nil {
|
||||
updates["subject_name"] = *req.SubjectName
|
||||
}
|
||||
if req.SubjectCode != nil {
|
||||
updates["subject_code"] = *req.SubjectCode
|
||||
}
|
||||
if req.IsActive != nil {
|
||||
updates["is_active"] = *req.IsActive
|
||||
}
|
||||
if req.SortOrder != nil {
|
||||
updates["sort_order"] = *req.SortOrder
|
||||
}
|
||||
|
||||
if err := h.subjectService.UpdateSubject(subjectID, updates); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "更新成功")
|
||||
}
|
||||
|
||||
// SubjectDelete 删除科目
|
||||
func (h *SubjectHandler) SubjectDelete(c *gin.Context) {
|
||||
subjectID, ok := parseID(c, "subject_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
if err := h.subjectService.DeleteSubject(subjectID); err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.SuccessWithMessage(c, "删除成功")
|
||||
}
|
||||
|
||||
// SubjectToggle 切换科目启用/禁用状态
|
||||
func (h *SubjectHandler) SubjectToggle(c *gin.Context) {
|
||||
subjectID, ok := parseID(c, "subject_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
IsActive bool `json:"is_active"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
var err error
|
||||
if req.IsActive {
|
||||
err = h.subjectService.EnableSubject(subjectID)
|
||||
} else {
|
||||
err = h.subjectService.DisableSubject(subjectID)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if req.IsActive {
|
||||
response.SuccessWithMessage(c, "科目已启用")
|
||||
} else {
|
||||
response.SuccessWithMessage(c, "科目已禁用")
|
||||
}
|
||||
}
|
||||
56
backend-go/internal/handler/super_admin_handler.go
Normal file
56
backend-go/internal/handler/super_admin_handler.go
Normal file
@@ -0,0 +1,56 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package handler
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/schema"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/service"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// SuperAdminHandler 超级管理员处理器
|
||||
type SuperAdminHandler struct {
|
||||
superAdminService *service.SuperAdminService
|
||||
}
|
||||
|
||||
// NewSuperAdminHandler 创建超级管理员处理器
|
||||
func NewSuperAdminHandler(superAdminService *service.SuperAdminService) *SuperAdminHandler {
|
||||
return &SuperAdminHandler{superAdminService: superAdminService}
|
||||
}
|
||||
|
||||
// Login 超级管理员登录
|
||||
func (h *SuperAdminHandler) Login(c *gin.Context) {
|
||||
var req schema.LoginRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "参数错误")
|
||||
return
|
||||
}
|
||||
|
||||
ip := c.ClientIP()
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
result, err := h.superAdminService.Login(req.Username, req.Password, ip, userAgent)
|
||||
if err != nil {
|
||||
response.InternalError(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
success, ok := result["success"].(bool)
|
||||
if !ok || !success {
|
||||
msg, _ := result["message"].(string)
|
||||
response.Unauthorized(c, msg)
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, result, "登录成功")
|
||||
}
|
||||
57
backend-go/internal/middleware/access_log.go
Normal file
57
backend-go/internal/middleware/access_log.go
Normal file
@@ -0,0 +1,57 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// AccessLog 访问日志中间件
|
||||
func AccessLog() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
path := c.Request.URL.Path
|
||||
query := c.Request.URL.RawQuery
|
||||
|
||||
// 处理请求
|
||||
c.Next()
|
||||
|
||||
latency := time.Since(start)
|
||||
status := c.Writer.Status()
|
||||
clientIP := c.ClientIP()
|
||||
method := c.Request.Method
|
||||
userAgent := c.Request.UserAgent()
|
||||
|
||||
if query != "" {
|
||||
path = path + "?" + query
|
||||
}
|
||||
|
||||
// 获取用户信息(如已认证)
|
||||
userID, _ := c.Get(CtxUserID)
|
||||
username, _ := c.Get(CtxUsername)
|
||||
|
||||
logger.Sugared.Infow("请求日志",
|
||||
"status", status,
|
||||
"method", method,
|
||||
"path", path,
|
||||
"ip", clientIP,
|
||||
"latency", latency.String(),
|
||||
"user_agent", userAgent,
|
||||
"user_id", userID,
|
||||
"username", username,
|
||||
)
|
||||
}
|
||||
}
|
||||
227
backend-go/internal/middleware/auth.go
Normal file
227
backend-go/internal/middleware/auth.go
Normal file
@@ -0,0 +1,227 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||||
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// 上下文 Key 常量
|
||||
const (
|
||||
CtxUserID = "user_id"
|
||||
CtxUsername = "username"
|
||||
CtxUserType = "user_type"
|
||||
CtxStudentID = "student_id"
|
||||
CtxRole = "role"
|
||||
CtxRealName = "real_name"
|
||||
CtxClassID = "class_id"
|
||||
)
|
||||
|
||||
// 公开路径(不需要认证)
|
||||
var publicPaths = map[string]bool{
|
||||
"/": true,
|
||||
"/health": true,
|
||||
"/api/auth/login": true,
|
||||
}
|
||||
|
||||
// RegisterPublicPath 注册额外的公开路径(需在路由初始化阶段调用)
|
||||
func RegisterPublicPath(path string) {
|
||||
publicPaths[path] = true
|
||||
}
|
||||
|
||||
// AuthRequired JWT 认证中间件
|
||||
func AuthRequired() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
path := c.Request.URL.Path
|
||||
|
||||
// 公开路径跳过
|
||||
if publicPaths[path] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
cfg := config.AppConfig
|
||||
|
||||
// 获取 Authorization header
|
||||
authHeader := c.GetHeader("Authorization")
|
||||
if authHeader == "" {
|
||||
response.Unauthorized(c, "缺少认证令牌")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 解析 Bearer Token
|
||||
parts := strings.SplitN(authHeader, " ", 2)
|
||||
if len(parts) != 2 || strings.ToLower(parts[0]) != "bearer" {
|
||||
response.Unauthorized(c, "认证格式错误")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
tokenStr := parts[1]
|
||||
|
||||
// 验证 JWT
|
||||
claims, err := appJwt.VerifyToken(tokenStr)
|
||||
if err != nil {
|
||||
logger.Sugared.Warnf("JWT 验证失败: path=%s, err=%v", path, err)
|
||||
response.Unauthorized(c, "令牌无效或已过期")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
|
||||
// 验证 Redis 中的 Token
|
||||
ctx := context.Background()
|
||||
storedToken, err := database.GetUserToken(ctx, claims.UserID)
|
||||
if err != nil || storedToken != tokenStr {
|
||||
logger.Sugared.Warnf("Redis Token 不匹配: path=%s, user_id=%d", path, claims.UserID)
|
||||
// 主动清理 Redis 中的旧 Token,避免残留
|
||||
if err == nil && storedToken != "" && storedToken != tokenStr {
|
||||
_ = database.DeleteUserToken(ctx, claims.UserID)
|
||||
}
|
||||
response.Unauthorized(c, "令牌已失效,请重新登录")
|
||||
c.Abort()
|
||||
return
|
||||
}
|
||||
// 刷新 Token 过期时间(空闲超时)
|
||||
_ = database.ExpireToken(ctx, claims.UserID, cfg.JWTIdleTimeoutMinutes)
|
||||
|
||||
// 将用户信息写入 Gin 上下文
|
||||
c.Set(CtxUserID, claims.UserID)
|
||||
c.Set(CtxUsername, claims.Username)
|
||||
c.Set(CtxUserType, claims.UserType)
|
||||
c.Set(CtxRealName, claims.RealName)
|
||||
if claims.StudentID != nil {
|
||||
c.Set(CtxStudentID, *claims.StudentID)
|
||||
}
|
||||
c.Set(CtxRole, claims.Role)
|
||||
if claims.ClassID != nil {
|
||||
c.Set(CtxClassID, *claims.ClassID)
|
||||
}
|
||||
|
||||
logger.Sugared.Debugf("认证成功: %s %s, user_id=%d, username=%s",
|
||||
c.Request.Method, path, claims.UserID, claims.Username)
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RequireRole 角色权限中间件
|
||||
func RequireRole(roles ...string) gin.HandlerFunc {
|
||||
roleSet := make(map[string]bool, len(roles))
|
||||
for _, r := range roles {
|
||||
roleSet[r] = true
|
||||
}
|
||||
|
||||
return func(c *gin.Context) {
|
||||
userType, _ := c.Get(CtxUserType)
|
||||
role, _ := c.Get(CtxRole)
|
||||
|
||||
// 超级管理员直接通过
|
||||
if userType == "super_admin" {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 user_type
|
||||
if ut, ok := userType.(string); ok && roleSet[ut] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
// 检查 role(admin_roles.role_type)
|
||||
if r, ok := role.(string); ok && roleSet[r] {
|
||||
c.Next()
|
||||
return
|
||||
}
|
||||
|
||||
response.Forbidden(c, "权限不足")
|
||||
c.Abort()
|
||||
}
|
||||
}
|
||||
|
||||
// GetUserID 从上下文获取用户 ID
|
||||
func GetUserID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxUserID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetUsername 从上下文获取用户名
|
||||
func GetUsername(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxUsername); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetUserType 从上下文获取用户类型
|
||||
func GetUserType(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxUserType); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetRole 从上下文获取角色
|
||||
func GetRole(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxRole); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// GetClassID 从上下文获取班级 ID
|
||||
func GetClassID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxClassID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetStudentID 从上下文获取学生 ID
|
||||
func GetStudentID(c *gin.Context) int {
|
||||
if v, exists := c.Get(CtxStudentID); exists {
|
||||
if id, ok := v.(int); ok {
|
||||
return id
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// GetRealName 从上下文获取真实姓名
|
||||
func GetRealName(c *gin.Context) string {
|
||||
if v, exists := c.Get(CtxRealName); exists {
|
||||
if s, ok := v.(string); ok {
|
||||
return s
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
131
backend-go/internal/middleware/sanitize.go
Normal file
131
backend-go/internal/middleware/sanitize.go
Normal file
@@ -0,0 +1,131 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package middleware
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// Sanitize 输入清理中间件(路径遍历防护 + 长度限制)
|
||||
func Sanitize() gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
// 处理 POST、PUT、PATCH 请求体
|
||||
if c.Request.Method == "POST" || c.Request.Method == "PUT" || c.Request.Method == "PATCH" {
|
||||
body, err := io.ReadAll(c.Request.Body)
|
||||
if err == nil && len(body) > 0 {
|
||||
var data interface{}
|
||||
if json.Unmarshal(body, &data) == nil {
|
||||
cleaned := sanitizeData(data)
|
||||
newBody, _ := json.Marshal(cleaned)
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(newBody))
|
||||
c.Request.ContentLength = int64(len(newBody))
|
||||
} else {
|
||||
// 非 JSON 请求体,恢复原始 body
|
||||
c.Request.Body = io.NopCloser(bytes.NewBuffer(body))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 清理查询参数(GET 等请求的 URL query string)
|
||||
if c.Request.URL.RawQuery != "" {
|
||||
params := c.Request.URL.Query()
|
||||
dirty := false
|
||||
for key, values := range params {
|
||||
for i, v := range values {
|
||||
cleaned := sanitizeString(v)
|
||||
if cleaned != v {
|
||||
values[i] = cleaned
|
||||
dirty = true
|
||||
}
|
||||
}
|
||||
params[key] = values
|
||||
}
|
||||
if dirty {
|
||||
c.Request.URL.RawQuery = params.Encode()
|
||||
}
|
||||
}
|
||||
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeData 递归清理数据
|
||||
func sanitizeData(data interface{}) interface{} {
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
result := make(map[string]interface{}, len(v))
|
||||
for key, val := range v {
|
||||
result[key] = sanitizeData(val)
|
||||
}
|
||||
return result
|
||||
case []interface{}:
|
||||
result := make([]interface{}, len(v))
|
||||
for i, val := range v {
|
||||
result[i] = sanitizeData(val)
|
||||
}
|
||||
return result
|
||||
case string:
|
||||
return sanitizeString(v)
|
||||
default:
|
||||
return v
|
||||
}
|
||||
}
|
||||
|
||||
// sanitizeString 清理字符串
|
||||
func sanitizeString(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
value = strings.TrimSpace(value)
|
||||
|
||||
// 路径遍历防护(循环解码直到稳定,防止多层编码绕过)
|
||||
for {
|
||||
decoded, err := url.PathUnescape(value)
|
||||
if err != nil || decoded == value {
|
||||
break
|
||||
}
|
||||
value = decoded
|
||||
}
|
||||
// 大小写无关的路径遍历模式清理(循环移除直到无匹配)
|
||||
lower := strings.ToLower(value)
|
||||
for strings.Contains(lower, "../") || strings.Contains(lower, "..\\") {
|
||||
replaced := false
|
||||
for _, pattern := range []string{"../", "..\\"} {
|
||||
if idx := strings.Index(lower, pattern); idx >= 0 {
|
||||
value = value[:idx] + value[idx+len(pattern):]
|
||||
lower = lower[:idx] + lower[idx+len(pattern):]
|
||||
replaced = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 限制长度(按 rune 截断,避免切断多字节 UTF-8 字符)
|
||||
runes := []rune(value)
|
||||
if len(runes) > 1000 {
|
||||
value = string(runes[:1000])
|
||||
}
|
||||
|
||||
// SQL 注入由 GORM 参数化查询防护,无需正则替换(避免破坏合法输入)
|
||||
|
||||
return value
|
||||
}
|
||||
36
backend-go/internal/model/admin_role.go
Normal file
36
backend-go/internal/model/admin_role.go
Normal file
@@ -0,0 +1,36 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AdminRole 管理员角色模型,对应 admin_roles 表
|
||||
type AdminRole struct {
|
||||
AdminRoleID int `gorm:"column:admin_role_id;primaryKey;autoIncrement" json:"admin_role_id"`
|
||||
UserID int `gorm:"column:user_id;not null;uniqueIndex:uk_user_class" json:"user_id"`
|
||||
ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_user_class;index:idx_admin_role_class" json:"class_id"`
|
||||
RoleType string `gorm:"column:role_type;type:enum('班主任','班长','学习委员','考勤委员','劳动委员','志愿委员','科任老师','课代表');not null" json:"role_type"`
|
||||
SubjectID *int `gorm:"column:subject_id" json:"subject_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
|
||||
// 虚拟字段(JOIN 查询时使用)
|
||||
RealName *string `gorm:"-" json:"real_name,omitempty"`
|
||||
Username *string `gorm:"-" json:"username,omitempty"`
|
||||
UserStatus *int8 `gorm:"-" json:"user_status,omitempty"`
|
||||
SubjectName *string `gorm:"-" json:"subject_name,omitempty"`
|
||||
ClassName *string `gorm:"-" json:"class_name,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (AdminRole) TableName() string {
|
||||
return "admin_roles"
|
||||
}
|
||||
53
backend-go/internal/model/assignment.go
Normal file
53
backend-go/internal/model/assignment.go
Normal file
@@ -0,0 +1,53 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Assignment 作业模型,对应 assignments 表
|
||||
type Assignment struct {
|
||||
AssignmentID int `gorm:"column:assignment_id;primaryKey;autoIncrement" json:"assignment_id"`
|
||||
ClassID int `gorm:"column:class_id;not null;index:idx_assignment_class" json:"class_id"`
|
||||
SubjectID int `gorm:"column:subject_id;not null;index:idx_assignment_subject" json:"subject_id"`
|
||||
Title string `gorm:"column:title;type:varchar(100);not null" json:"title"`
|
||||
Description *string `gorm:"column:description;type:text" json:"description"`
|
||||
Deadline time.Time `gorm:"column:deadline;type:date;not null" json:"deadline"`
|
||||
CreatedBy int `gorm:"column:created_by;not null" json:"created_by"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
|
||||
// 虚拟字段
|
||||
SubjectName *string `gorm:"-" json:"subject_name,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Assignment) TableName() string {
|
||||
return "assignments"
|
||||
}
|
||||
|
||||
// AssignmentSubmission 作业提交记录模型,对应 homework_submissions 表
|
||||
type AssignmentSubmission struct {
|
||||
SubmissionID int `gorm:"column:submission_id;primaryKey;autoIncrement" json:"submission_id"`
|
||||
AssignmentID int `gorm:"column:assignment_id;not null;uniqueIndex:uk_assignment_student" json:"assignment_id"`
|
||||
StudentID int `gorm:"column:student_id;not null;uniqueIndex:uk_assignment_student" json:"student_id"`
|
||||
Status string `gorm:"column:status;type:enum('submitted','not_submitted','late');default:'not_submitted'" json:"status"`
|
||||
SubmitTime *time.Time `gorm:"column:submit_time" json:"submit_time"`
|
||||
Comments *string `gorm:"column:comments;type:text" json:"comments"`
|
||||
DeductionApplied int8 `gorm:"column:deduction_applied;default:0" json:"deduction_applied"`
|
||||
DeductionRecordID *int64 `gorm:"column:deduction_record_id" json:"deduction_record_id"`
|
||||
UpdatedBy *int `gorm:"column:updated_by" json:"updated_by"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (AssignmentSubmission) TableName() string {
|
||||
return "homework_submissions"
|
||||
}
|
||||
38
backend-go/internal/model/attendance.go
Normal file
38
backend-go/internal/model/attendance.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// AttendanceRecord 考勤记录模型,对应 attendance_records 表
|
||||
type AttendanceRecord struct {
|
||||
AttendanceID int `gorm:"column:attendance_id;primaryKey;autoIncrement" json:"attendance_id"`
|
||||
StudentID int `gorm:"column:student_id;not null" json:"student_id"`
|
||||
Date time.Time `gorm:"column:date;type:date;not null;index:idx_date" json:"date"`
|
||||
Slot string `gorm:"column:slot;type:enum('morning','afternoon','evening');default:'morning'" json:"slot"`
|
||||
Status string `gorm:"column:status;type:enum('present','absent','late','leave');default:'present'" json:"status"`
|
||||
Reason *string `gorm:"column:reason;type:varchar(255)" json:"reason"`
|
||||
RecorderID int `gorm:"column:recorder_id;not null" json:"recorder_id"`
|
||||
DeductionApplied int8 `gorm:"column:deduction_applied;default:0" json:"deduction_applied"`
|
||||
DeductionRecordID *int64 `gorm:"column:deduction_record_id" json:"deduction_record_id"`
|
||||
SemesterID *int `gorm:"column:semester_id;index:idx_attendance_semester" json:"semester_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
|
||||
// 虚拟字段(JOIN 查询时使用)
|
||||
StudentName *string `gorm:"-" json:"student_name,omitempty"`
|
||||
StudentNo *string `gorm:"-" json:"student_no,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (AttendanceRecord) TableName() string {
|
||||
return "attendance_records"
|
||||
}
|
||||
60
backend-go/internal/model/class_model.go
Normal file
60
backend-go/internal/model/class_model.go
Normal file
@@ -0,0 +1,60 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Class 班级模型,对应 classes 表
|
||||
type Class struct {
|
||||
ClassID int `gorm:"column:class_id;primaryKey;autoIncrement" json:"class_id"`
|
||||
ClassName string `gorm:"column:class_name;type:varchar(100);uniqueIndex:uk_class_name;not null" json:"class_name"`
|
||||
Grade *string `gorm:"column:grade;type:varchar(50)" json:"grade"`
|
||||
Description *string `gorm:"column:description;type:varchar(255)" json:"description"`
|
||||
Status int8 `gorm:"column:status;default:1" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
|
||||
// 虚拟字段
|
||||
StudentCount int64 `gorm:"-" json:"student_count,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Class) TableName() string {
|
||||
return "classes"
|
||||
}
|
||||
|
||||
// ClassSetting 班级设置模型,对应 class_settings 表
|
||||
type ClassSetting struct {
|
||||
SettingID int `gorm:"column:setting_id;primaryKey;autoIncrement" json:"setting_id"`
|
||||
ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_class_setting" json:"class_id"`
|
||||
SettingKey string `gorm:"column:setting_key;type:varchar(50);not null;uniqueIndex:uk_class_setting" json:"setting_key"`
|
||||
SettingValue string `gorm:"column:setting_value;type:varchar(255);not null" json:"setting_value"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ClassSetting) TableName() string {
|
||||
return "class_settings"
|
||||
}
|
||||
|
||||
// ClassFeature 班级功能开关模型,对应 class_features 表
|
||||
type ClassFeature struct {
|
||||
FeatureID int `gorm:"column:feature_id;primaryKey;autoIncrement" json:"feature_id"`
|
||||
ClassID int `gorm:"column:class_id;not null;uniqueIndex:uk_class_feature" json:"class_id"`
|
||||
FeatureKey string `gorm:"column:feature_key;type:varchar(50);not null;uniqueIndex:uk_class_feature" json:"feature_key"`
|
||||
Enabled int8 `gorm:"column:enabled;default:1" json:"enabled"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ClassFeature) TableName() string {
|
||||
return "class_features"
|
||||
}
|
||||
44
backend-go/internal/model/conduct.go
Normal file
44
backend-go/internal/model/conduct.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// ConductRecord 操行分记录模型,对应 conduct_records 表
|
||||
type ConductRecord struct {
|
||||
RecordID int64 `gorm:"column:record_id;primaryKey;autoIncrement" json:"record_id"`
|
||||
StudentID int `gorm:"column:student_id;not null;index:idx_conduct_student;index:idx_student_created" json:"student_id"`
|
||||
PointsChange int `gorm:"column:points_change;not null" json:"points_change"`
|
||||
Reason string `gorm:"column:reason;type:varchar(255);not null" json:"reason"`
|
||||
RecorderID int `gorm:"column:recorder_id;not null;index:idx_recorder_id" json:"recorder_id"`
|
||||
RecorderName *string `gorm:"column:recorder_name;type:varchar(50)" json:"recorder_name"`
|
||||
RelatedType string `gorm:"column:related_type;type:enum('manual','homework','attendance');default:'manual'" json:"related_type"`
|
||||
RelatedID *int `gorm:"column:related_id" json:"related_id"`
|
||||
IsRevoked int8 `gorm:"column:is_revoked;default:0" json:"is_revoked"`
|
||||
RevokedBy *int `gorm:"column:revoked_by" json:"revoked_by"`
|
||||
RevokedAt *time.Time `gorm:"column:revoked_at" json:"revoked_at"`
|
||||
SemesterID *int `gorm:"column:semester_id;index:idx_conduct_semester;index:idx_conduct_type_semester" json:"semester_id"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_student_created" json:"created_at"`
|
||||
|
||||
// 虚拟字段(JOIN 查询时使用)
|
||||
StudentName *string `gorm:"-" json:"student_name,omitempty"`
|
||||
StudentNo *string `gorm:"-" json:"student_no,omitempty"`
|
||||
RecorderReal *string `gorm:"-" json:"recorder_real,omitempty"`
|
||||
RevokerName *string `gorm:"-" json:"revoker_name,omitempty"`
|
||||
TotalPoints *int `gorm:"-" json:"total_points,omitempty"`
|
||||
ClassID *int `gorm:"-" json:"class_id,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (ConductRecord) TableName() string {
|
||||
return "conduct_records"
|
||||
}
|
||||
50
backend-go/internal/model/log.go
Normal file
50
backend-go/internal/model/log.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// OperationLog 操作日志模型,对应 operation_logs 表
|
||||
type OperationLog struct {
|
||||
LogID int64 `gorm:"column:log_id;primaryKey;autoIncrement" json:"log_id"`
|
||||
OperatorID int `gorm:"column:operator_id;not null;index:idx_operator_created" json:"operator_id"`
|
||||
OperatorName *string `gorm:"column:operator_name;type:varchar(50)" json:"operator_name"`
|
||||
OperatorRole *string `gorm:"column:operator_role;type:varchar(50)" json:"operator_role"`
|
||||
ClassID *int `gorm:"column:class_id;index:idx_operation_class" json:"class_id"`
|
||||
OperationType string `gorm:"column:operation_type;type:varchar(50);not null" json:"operation_type"`
|
||||
TargetType *string `gorm:"column:target_type;type:varchar(50)" json:"target_type"`
|
||||
TargetID *int `gorm:"column:target_id" json:"target_id"`
|
||||
Details *string `gorm:"column:details;type:text" json:"details"`
|
||||
IPAddress *string `gorm:"column:ip_address;type:varchar(45)" json:"ip_address"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_operator_created" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (OperationLog) TableName() string {
|
||||
return "operation_logs"
|
||||
}
|
||||
|
||||
// LoginLog 登录日志模型,对应 login_logs 表
|
||||
type LoginLog struct {
|
||||
LogID int64 `gorm:"column:log_id;primaryKey;autoIncrement" json:"log_id"`
|
||||
Username string `gorm:"column:username;type:varchar(50);not null;index:idx_username_created" json:"username"`
|
||||
LoginResult int8 `gorm:"column:login_result;not null" json:"login_result"`
|
||||
FailReason *string `gorm:"column:fail_reason;type:varchar(100)" json:"fail_reason"`
|
||||
IPAddress *string `gorm:"column:ip_address;type:varchar(45)" json:"ip_address"`
|
||||
UserAgent *string `gorm:"column:user_agent;type:varchar(255)" json:"user_agent"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;index:idx_username_created" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (LoginLog) TableName() string {
|
||||
return "login_logs"
|
||||
}
|
||||
88
backend-go/internal/model/semester.go
Normal file
88
backend-go/internal/model/semester.go
Normal file
@@ -0,0 +1,88 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Semester 学期模型,对应 semesters 表
|
||||
type Semester struct {
|
||||
SemesterID int `gorm:"column:semester_id;primaryKey;autoIncrement" json:"semester_id"`
|
||||
SemesterName string `gorm:"column:semester_name;type:varchar(100);not null" json:"semester_name"`
|
||||
StartDate *time.Time `gorm:"column:start_date;type:date" json:"start_date"`
|
||||
EndDate *time.Time `gorm:"column:end_date;type:date" json:"end_date"`
|
||||
IsActive int8 `gorm:"column:is_active;default:0" json:"is_active"`
|
||||
IsArchived int8 `gorm:"column:is_archived;default:0" json:"is_archived"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
|
||||
// 虚拟字段
|
||||
ConductCount int64 `gorm:"-" json:"conduct_count,omitempty"`
|
||||
AttendanceCount int64 `gorm:"-" json:"attendance_count,omitempty"`
|
||||
CurrentWeek *int `gorm:"-" json:"current_week,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Semester) TableName() string {
|
||||
return "semesters"
|
||||
}
|
||||
|
||||
// SemesterArchive 学期归档快照模型,对应 semester_archives 表
|
||||
type SemesterArchive struct {
|
||||
ArchiveID int `gorm:"column:archive_id;primaryKey;autoIncrement" json:"archive_id"`
|
||||
SemesterID int `gorm:"column:semester_id;not null;index:idx_semester_id" json:"semester_id"`
|
||||
ClassID int `gorm:"column:class_id;not null;index:idx_archive_class" json:"class_id"`
|
||||
StudentID int `gorm:"column:student_id;not null" json:"student_id"`
|
||||
StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"`
|
||||
StudentName string `gorm:"column:student_name;type:varchar(50);not null" json:"student_name"`
|
||||
FinalPoints int `gorm:"column:final_points;not null" json:"final_points"`
|
||||
RankPosition *int `gorm:"column:rank_position" json:"rank_position"`
|
||||
TotalStudents *int `gorm:"column:total_students" json:"total_students"`
|
||||
AttendancePresent int `gorm:"column:attendance_present;default:0" json:"attendance_present"`
|
||||
AttendanceAbsent int `gorm:"column:attendance_absent;default:0" json:"attendance_absent"`
|
||||
AttendanceLate int `gorm:"column:attendance_late;default:0" json:"attendance_late"`
|
||||
AttendanceLeave int `gorm:"column:attendance_leave;default:0" json:"attendance_leave"`
|
||||
HomeworkSubmitted int `gorm:"column:homework_submitted;default:0" json:"homework_submitted"`
|
||||
HomeworkNotSubmitted int `gorm:"column:homework_not_submitted;default:0" json:"homework_not_submitted"`
|
||||
HomeworkLate int `gorm:"column:homework_late;default:0" json:"homework_late"`
|
||||
ArchivedAt time.Time `gorm:"column:archived_at;autoCreateTime" json:"archived_at"`
|
||||
|
||||
// 虚拟字段
|
||||
SemesterName *string `gorm:"-" json:"semester_name,omitempty"`
|
||||
SStartDate *time.Time `gorm:"-" json:"start_date,omitempty"`
|
||||
SEndDate *time.Time `gorm:"-" json:"end_date,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SemesterArchive) TableName() string {
|
||||
return "semester_archives"
|
||||
}
|
||||
|
||||
// PeriodArchive 周期归档快照模型,对应 period_archives 表
|
||||
type PeriodArchive struct {
|
||||
ArchiveID int `gorm:"column:archive_id;primaryKey;autoIncrement" json:"archive_id"`
|
||||
ClassID int `gorm:"column:class_id;not null;index:idx_period_archive_class" json:"class_id"`
|
||||
PeriodType string `gorm:"column:period_type;type:enum('weekly','monthly');not null;index:idx_period_archive_type" json:"period_type"`
|
||||
PeriodLabel string `gorm:"column:period_label;type:varchar(50);not null;index:idx_period_archive_type" json:"period_label"`
|
||||
StudentID int `gorm:"column:student_id;not null" json:"student_id"`
|
||||
StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"`
|
||||
StudentName string `gorm:"column:student_name;type:varchar(50);not null" json:"student_name"`
|
||||
FinalPoints int `gorm:"column:final_points;not null" json:"final_points"`
|
||||
RankPosition *int `gorm:"column:rank_position" json:"rank_position"`
|
||||
TotalStudents *int `gorm:"column:total_students" json:"total_students"`
|
||||
ArchivedAt time.Time `gorm:"column:archived_at;autoCreateTime" json:"archived_at"`
|
||||
ResetBy string `gorm:"column:reset_by;type:varchar(20);default:auto" json:"reset_by"`
|
||||
OperatorID *int `gorm:"column:operator_id" json:"operator_id"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (PeriodArchive) TableName() string {
|
||||
return "period_archives"
|
||||
}
|
||||
37
backend-go/internal/model/student.go
Normal file
37
backend-go/internal/model/student.go
Normal file
@@ -0,0 +1,37 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Student 学生模型,对应 students 表
|
||||
type Student struct {
|
||||
StudentID int `gorm:"column:student_id;primaryKey;autoIncrement" json:"student_id"`
|
||||
StudentNo string `gorm:"column:student_no;type:varchar(20);not null" json:"student_no"`
|
||||
ClassID int `gorm:"column:class_id;not null;index:idx_student_class" json:"class_id"`
|
||||
Name string `gorm:"column:name;type:varchar(50);not null" json:"name"`
|
||||
TotalPoints int `gorm:"column:total_points;default:60" json:"total_points"`
|
||||
ParentAccount *string `gorm:"column:parent_account;type:varchar(50)" json:"parent_account"`
|
||||
DormitoryNumber *string `gorm:"column:dormitory_number;type:varchar(20)" json:"dormitory_number"` // 格式:南0-000
|
||||
Status int8 `gorm:"column:status;default:1" json:"status"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
PointsUpdatedAt time.Time `gorm:"column:points_updated_at;autoCreateTime" json:"points_updated_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
|
||||
// 虚拟字段(JOIN 查询时使用,不映射到数据库)
|
||||
ClassName *string `gorm:"-" json:"class_name,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Student) TableName() string {
|
||||
return "students"
|
||||
}
|
||||
29
backend-go/internal/model/subject.go
Normal file
29
backend-go/internal/model/subject.go
Normal file
@@ -0,0 +1,29 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// Subject 科目模型,对应 subjects 表
|
||||
type Subject struct {
|
||||
SubjectID int `gorm:"column:subject_id;primaryKey;autoIncrement" json:"subject_id"`
|
||||
SubjectName string `gorm:"column:subject_name;type:varchar(50);uniqueIndex:uk_subject_name;not null" json:"subject_name"`
|
||||
SubjectCode *string `gorm:"column:subject_code;type:varchar(20)" json:"subject_code"`
|
||||
IsActive int8 `gorm:"column:is_active;default:1" json:"is_active"`
|
||||
SortOrder int `gorm:"column:sort_order;default:0" json:"sort_order"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (Subject) TableName() string {
|
||||
return "subjects"
|
||||
}
|
||||
32
backend-go/internal/model/super_admin.go
Normal file
32
backend-go/internal/model/super_admin.go
Normal file
@@ -0,0 +1,32 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// SuperAdmin 超级管理员模型,对应 super_admins 表
|
||||
type SuperAdmin struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"`
|
||||
Salt string `gorm:"column:salt;type:varchar(64);not null" json:"-"`
|
||||
RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"`
|
||||
Status int8 `gorm:"column:status;default:1" json:"status"`
|
||||
NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SuperAdmin) TableName() string {
|
||||
return "super_admins"
|
||||
}
|
||||
26
backend-go/internal/model/system_setting.go
Normal file
26
backend-go/internal/model/system_setting.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// SystemSetting 系统设置模型,对应 system_settings 表
|
||||
type SystemSetting struct {
|
||||
SettingKey string `gorm:"column:setting_key;type:varchar(50);primaryKey;not null" json:"setting_key"`
|
||||
SettingValue string `gorm:"column:setting_value;type:varchar(255);not null" json:"setting_value"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (SystemSetting) TableName() string {
|
||||
return "system_settings"
|
||||
}
|
||||
34
backend-go/internal/model/user.go
Normal file
34
backend-go/internal/model/user.go
Normal file
@@ -0,0 +1,34 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// User 用户模型,对应 users 表
|
||||
type User struct {
|
||||
UserID int `gorm:"column:user_id;primaryKey;autoIncrement" json:"user_id"`
|
||||
Username string `gorm:"column:username;type:varchar(50);uniqueIndex;not null" json:"username"`
|
||||
PasswordHash string `gorm:"column:password_hash;type:varchar(255);not null" json:"-"`
|
||||
RealName string `gorm:"column:real_name;type:varchar(50);not null" json:"real_name"`
|
||||
UserType string `gorm:"column:user_type;type:enum('student','parent','admin','super_admin');not null" json:"user_type"`
|
||||
StudentID *int `gorm:"column:student_id" json:"student_id"`
|
||||
Status int8 `gorm:"column:status;default:1" json:"status"`
|
||||
NeedChangePassword int8 `gorm:"column:need_change_password;default:1" json:"need_change_password"`
|
||||
LastLoginTime *time.Time `gorm:"column:last_login_time" json:"last_login_time"`
|
||||
LastLoginIP *string `gorm:"column:last_login_ip;type:varchar(45)" json:"last_login_ip"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (User) TableName() string {
|
||||
return "users"
|
||||
}
|
||||
112
backend-go/internal/repository/admin_role_repo.go
Normal file
112
backend-go/internal/repository/admin_role_repo.go
Normal file
@@ -0,0 +1,112 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// AdminRoleRepo 管理员角色数据访问层
|
||||
type AdminRoleRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAdminRoleRepo 创建管理员角色 Repository
|
||||
func NewAdminRoleRepo(db *gorm.DB) *AdminRoleRepo {
|
||||
return &AdminRoleRepo{db: db}
|
||||
}
|
||||
|
||||
// GetByUserID 获取用户的管理员角色(取第一个,含科目名称)
|
||||
func (r *AdminRoleRepo) GetByUserID(userID int) (*model.AdminRole, error) {
|
||||
var role model.AdminRole
|
||||
if err := r.db.Table("admin_roles ar").
|
||||
Select("ar.*, s.subject_name").
|
||||
Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id").
|
||||
Where("ar.user_id = ?", userID).
|
||||
Order("ar.admin_role_id ASC").
|
||||
Limit(1).
|
||||
First(&role).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// GetByUserIDAndClass 获取用户在指定班级的管理员角色
|
||||
func (r *AdminRoleRepo) GetByUserIDAndClass(userID int, classID int) (*model.AdminRole, error) {
|
||||
var role model.AdminRole
|
||||
if err := r.db.Table("admin_roles ar").
|
||||
Select("ar.*, s.subject_name").
|
||||
Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id").
|
||||
Where("ar.user_id = ? AND ar.class_id = ?", userID, classID).
|
||||
Limit(1).
|
||||
First(&role).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &role, nil
|
||||
}
|
||||
|
||||
// GetAllByClass 获取指定班级的所有管理员列表(含用户和科目信息)
|
||||
func (r *AdminRoleRepo) GetAllByClass(classID int) ([]model.AdminRole, error) {
|
||||
var roles []model.AdminRole
|
||||
if err := r.db.Table("admin_roles ar").
|
||||
Select("ar.*, u.real_name, u.username, u.status as user_status, s.subject_name").
|
||||
Joins("JOIN users u ON ar.user_id = u.user_id AND u.status = 1").
|
||||
Joins("LEFT JOIN subjects s ON ar.subject_id = s.subject_id").
|
||||
Where("ar.class_id = ?", classID).
|
||||
Order("ar.role_type").
|
||||
Find(&roles).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return roles, nil
|
||||
}
|
||||
|
||||
// Create 创建管理员角色
|
||||
func (r *AdminRoleRepo) Create(role *model.AdminRole) (int, error) {
|
||||
if err := r.db.Create(role).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return role.AdminRoleID, nil
|
||||
}
|
||||
|
||||
// Delete 删除管理员角色(可指定班级)
|
||||
func (r *AdminRoleRepo) Delete(userID int, classID int) error {
|
||||
query := r.db.Where("user_id = ?", userID)
|
||||
if classID > 0 {
|
||||
query = query.Where("class_id = ?", classID)
|
||||
}
|
||||
return query.Delete(&model.AdminRole{}).Error
|
||||
}
|
||||
|
||||
// UpdateRole 更新管理员角色类型和关联科目
|
||||
func (r *AdminRoleRepo) UpdateRole(userID int, roleType string, classID int, subjectID *int) error {
|
||||
query := r.db.Model(&model.AdminRole{}).Where("user_id = ?", userID)
|
||||
if classID > 0 {
|
||||
query = query.Where("class_id = ?", classID)
|
||||
}
|
||||
return query.Updates(map[string]interface{}{
|
||||
"role_type": roleType,
|
||||
"subject_id": subjectID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// GetUserRoleAndClassID 获取用户的角色类型和所属班级ID
|
||||
func (r *AdminRoleRepo) GetUserRoleAndClassID(userID int) (string, int, error) {
|
||||
var role model.AdminRole
|
||||
if err := r.db.Where("user_id = ?", userID).
|
||||
Limit(1).
|
||||
First(&role).Error; err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
return role.RoleType, role.ClassID, nil
|
||||
}
|
||||
168
backend-go/internal/repository/assignment_repo.go
Normal file
168
backend-go/internal/repository/assignment_repo.go
Normal file
@@ -0,0 +1,168 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// AssignmentRepo 作业数据访问层
|
||||
type AssignmentRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAssignmentRepo 创建作业 Repository
|
||||
func NewAssignmentRepo(db *gorm.DB) *AssignmentRepo {
|
||||
return &AssignmentRepo{db: db}
|
||||
}
|
||||
|
||||
// ========== Assignment 操作 ==========
|
||||
|
||||
// CreateAssignment 创建作业
|
||||
func (r *AssignmentRepo) CreateAssignment(assignment *model.Assignment) (int, error) {
|
||||
if err := r.db.Create(assignment).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return assignment.AssignmentID, nil
|
||||
}
|
||||
|
||||
// GetAssignmentByID 根据ID获取作业
|
||||
func (r *AssignmentRepo) GetAssignmentByID(assignmentID int) (*model.Assignment, error) {
|
||||
var assignment model.Assignment
|
||||
if err := r.db.Where("assignment_id = ?", assignmentID).First(&assignment).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &assignment, nil
|
||||
}
|
||||
|
||||
// GetAssignmentsByClass 获取班级作业列表
|
||||
func (r *AssignmentRepo) GetAssignmentsByClass(classID int, subjectID int, page, pageSize int) ([]model.Assignment, int64, error) {
|
||||
var assignments []model.Assignment
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&model.Assignment{}).Where("class_id = ?", classID)
|
||||
if subjectID > 0 {
|
||||
query = query.Where("subject_id = ?", subjectID)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&assignments).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return assignments, total, nil
|
||||
}
|
||||
|
||||
// GetAssignmentsBySubject 获取科目关联的作业列表
|
||||
func (r *AssignmentRepo) GetAssignmentsBySubject(subjectID int) ([]model.Assignment, error) {
|
||||
var assignments []model.Assignment
|
||||
if err := r.db.Where("subject_id = ?", subjectID).
|
||||
Order("created_at DESC").
|
||||
Find(&assignments).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return assignments, nil
|
||||
}
|
||||
|
||||
// DeleteAssignment 删除作业
|
||||
func (r *AssignmentRepo) DeleteAssignment(assignmentID int) error {
|
||||
return r.db.Where("assignment_id = ?", assignmentID).Delete(&model.Assignment{}).Error
|
||||
}
|
||||
|
||||
// GetHomeworkStatsByDateRange 通过作业截止日期范围查询学生作业提交统计
|
||||
func (r *AssignmentRepo) GetHomeworkStatsByDateRange(startDate, endDate time.Time) ([]struct {
|
||||
StudentID int
|
||||
Status string
|
||||
Count int64
|
||||
}, error) {
|
||||
var stats []struct {
|
||||
StudentID int
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
err := r.db.Table("homework_submissions hs").
|
||||
Select("hs.student_id, hs.status, COUNT(*) as count").
|
||||
Joins("JOIN assignments a ON hs.assignment_id = a.assignment_id").
|
||||
Where("a.deadline BETWEEN ? AND ?", startDate, endDate).
|
||||
Group("hs.student_id, hs.status").
|
||||
Find(&stats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ========== AssignmentSubmission 操作 ==========
|
||||
|
||||
// CreateSubmission 创建作业提交记录
|
||||
func (r *AssignmentRepo) CreateSubmission(submission *model.AssignmentSubmission) (int, error) {
|
||||
if err := r.db.Create(submission).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return submission.SubmissionID, nil
|
||||
}
|
||||
|
||||
// GetSubmissionByAssignmentAndStudent 获取指定作业和学生的提交记录
|
||||
func (r *AssignmentRepo) GetSubmissionByAssignmentAndStudent(assignmentID, studentID int) (*model.AssignmentSubmission, error) {
|
||||
var submission model.AssignmentSubmission
|
||||
if err := r.db.Where("assignment_id = ? AND student_id = ?", assignmentID, studentID).
|
||||
First(&submission).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &submission, nil
|
||||
}
|
||||
|
||||
// GetSubmissionsByAssignment 获取作业的所有提交记录
|
||||
func (r *AssignmentRepo) GetSubmissionsByAssignment(assignmentID int) ([]model.AssignmentSubmission, error) {
|
||||
var submissions []model.AssignmentSubmission
|
||||
if err := r.db.Where("assignment_id = ?", assignmentID).
|
||||
Find(&submissions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return submissions, nil
|
||||
}
|
||||
|
||||
// GetSubmissionsByStudent 获取学生的所有提交记录
|
||||
func (r *AssignmentRepo) GetSubmissionsByStudent(studentID int) ([]model.AssignmentSubmission, error) {
|
||||
var submissions []model.AssignmentSubmission
|
||||
if err := r.db.Where("student_id = ?", studentID).
|
||||
Find(&submissions).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return submissions, nil
|
||||
}
|
||||
|
||||
// UpdateSubmission 更新提交记录
|
||||
func (r *AssignmentRepo) UpdateSubmission(submissionID int, updates map[string]interface{}) error {
|
||||
return r.db.Model(&model.AssignmentSubmission{}).
|
||||
Where("submission_id = ?", submissionID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// BatchCreateSubmissions 批量创建提交记录
|
||||
func (r *AssignmentRepo) BatchCreateSubmissions(submissions []model.AssignmentSubmission) error {
|
||||
if len(submissions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(&submissions).Error
|
||||
}
|
||||
184
backend-go/internal/repository/attendance_repo.go
Normal file
184
backend-go/internal/repository/attendance_repo.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// AttendanceRepo 考勤数据访问层
|
||||
type AttendanceRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewAttendanceRepo 创建考勤 Repository
|
||||
func NewAttendanceRepo(db *gorm.DB) *AttendanceRepo {
|
||||
return &AttendanceRepo{db: db}
|
||||
}
|
||||
|
||||
// GetStudentRecords 获取学生考勤记录
|
||||
func (r *AttendanceRepo) GetStudentRecords(studentID int, month string) ([]model.AttendanceRecord, error) {
|
||||
var records []model.AttendanceRecord
|
||||
query := r.db.Where("student_id = ?", studentID)
|
||||
|
||||
if month != "" {
|
||||
query = query.Where("DATE_FORMAT(date, '%Y-%m') = ?", month)
|
||||
}
|
||||
|
||||
if err := query.Order("date DESC").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetClassRecords 获取班级考勤记录(支持多种过滤条件)
|
||||
func (r *AttendanceRepo) GetClassRecords(classID int, date string, studentID int, slot string) ([]model.AttendanceRecord, error) {
|
||||
var records []model.AttendanceRecord
|
||||
query := r.db.Table("attendance_records ar").
|
||||
Select("ar.*, s.name as student_name, s.student_no").
|
||||
Joins("JOIN students s ON ar.student_id = s.student_id").
|
||||
Where("1 = 1")
|
||||
|
||||
if classID > 0 {
|
||||
query = query.Where("s.class_id = ?", classID)
|
||||
}
|
||||
if date != "" {
|
||||
query = query.Where("ar.date = ?", date)
|
||||
}
|
||||
if studentID > 0 {
|
||||
query = query.Where("ar.student_id = ?", studentID)
|
||||
}
|
||||
if slot != "" {
|
||||
query = query.Where("ar.slot = ?", slot)
|
||||
}
|
||||
|
||||
if err := query.Order("ar.date DESC, s.student_no").Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// CreateRecordResult 创建或更新考勤记录的结果
|
||||
type CreateRecordResult struct {
|
||||
AttendanceID int
|
||||
IsUpdate bool
|
||||
OldDeductionApplied int8
|
||||
OldDeductionRecordID *int64
|
||||
}
|
||||
|
||||
// CreateRecord 创建或更新考勤记录(存在则更新),使用事务+行锁防止并发竞态
|
||||
func (r *AttendanceRepo) CreateRecord(record *model.AttendanceRecord) (*CreateRecordResult, error) {
|
||||
var result CreateRecordResult
|
||||
err := r.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 使用 SELECT ... FOR UPDATE 锁定记录,防止并发请求同时判定为"不存在"
|
||||
var existing model.AttendanceRecord
|
||||
findErr := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("student_id = ? AND date = ? AND slot = ?",
|
||||
record.StudentID, record.Date, record.Slot).
|
||||
First(&existing).Error
|
||||
|
||||
if findErr == nil {
|
||||
// 更新已有记录
|
||||
if updateErr := tx.Model(&existing).Updates(map[string]interface{}{
|
||||
"status": record.Status,
|
||||
"reason": record.Reason,
|
||||
"recorder_id": record.RecorderID,
|
||||
}).Error; updateErr != nil {
|
||||
return updateErr
|
||||
}
|
||||
result = CreateRecordResult{
|
||||
AttendanceID: existing.AttendanceID,
|
||||
IsUpdate: true,
|
||||
OldDeductionApplied: existing.DeductionApplied,
|
||||
OldDeductionRecordID: existing.DeductionRecordID,
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if findErr != gorm.ErrRecordNotFound {
|
||||
return findErr
|
||||
}
|
||||
|
||||
// 插入新记录
|
||||
if createErr := tx.Create(record).Error; createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
result = CreateRecordResult{
|
||||
AttendanceID: record.AttendanceID,
|
||||
IsUpdate: false,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
}
|
||||
|
||||
// GetAttendanceStatsBySemester 批量查询学期内所有学生的考勤统计
|
||||
func (r *AttendanceRepo) GetAttendanceStatsBySemester(semesterID int, startDate, endDate string) ([]struct {
|
||||
StudentID int
|
||||
Status string
|
||||
Count int64
|
||||
}, error) {
|
||||
var stats []struct {
|
||||
StudentID int
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
err := r.db.Model(&model.AttendanceRecord{}).
|
||||
Select("student_id, status, COUNT(*) as count").
|
||||
Where("semester_id = ? OR (semester_id IS NULL AND `date` BETWEEN ? AND ?)", semesterID, startDate, endDate).
|
||||
Group("student_id, status").
|
||||
Find(&stats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetAttendanceStatsByDateRange 通过日期范围查询学生考勤统计
|
||||
func (r *AttendanceRepo) GetAttendanceStatsByDateRange(startDate, endDate time.Time, classID int) ([]struct {
|
||||
StudentID int
|
||||
Status string
|
||||
Count int64
|
||||
}, error) {
|
||||
var stats []struct {
|
||||
StudentID int
|
||||
Status string
|
||||
Count int64
|
||||
}
|
||||
query := r.db.Model(&model.AttendanceRecord{}).
|
||||
Select("student_id, status, COUNT(*) as count").
|
||||
Where("date BETWEEN ? AND ?", startDate, endDate)
|
||||
|
||||
if classID > 0 {
|
||||
query = query.Where("student_id IN (SELECT student_id FROM students WHERE class_id = ?)", classID)
|
||||
}
|
||||
|
||||
err := query.Group("student_id, status").Find(&stats).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// AssociateSemester 将考勤记录关联到学期
|
||||
func (r *AttendanceRepo) AssociateSemester(attendanceID int, semesterID int) error {
|
||||
return r.db.Model(&model.AttendanceRecord{}).
|
||||
Where("attendance_id = ? AND semester_id IS NULL", attendanceID).
|
||||
Update("semester_id", semesterID).Error
|
||||
}
|
||||
184
backend-go/internal/repository/class_repo.go
Normal file
184
backend-go/internal/repository/class_repo.go
Normal file
@@ -0,0 +1,184 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// ClassRepo 班级数据访问层
|
||||
type ClassRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewClassRepo 创建班级 Repository
|
||||
func NewClassRepo(db *gorm.DB) *ClassRepo {
|
||||
return &ClassRepo{db: db}
|
||||
}
|
||||
|
||||
// GetDB 获取底层数据库连接
|
||||
func (r *ClassRepo) GetDB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取班级信息
|
||||
func (r *ClassRepo) GetByID(classID int) (*model.Class, error) {
|
||||
var class model.Class
|
||||
if err := r.db.Where("class_id = ?", classID).First(&class).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &class, nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有班级列表
|
||||
func (r *ClassRepo) GetAll(includeDisabled bool) ([]model.Class, error) {
|
||||
var classes []model.Class
|
||||
query := r.db.Where("1 = 1")
|
||||
if !includeDisabled {
|
||||
query = query.Where("status = 1")
|
||||
}
|
||||
if err := query.Order("class_id").Find(&classes).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return classes, nil
|
||||
}
|
||||
|
||||
// GetByName 根据班级名称获取班级
|
||||
func (r *ClassRepo) GetByName(className string) (*model.Class, error) {
|
||||
var class model.Class
|
||||
if err := r.db.Where("class_name = ?", className).First(&class).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &class, nil
|
||||
}
|
||||
|
||||
// Create 创建班级
|
||||
func (r *ClassRepo) Create(class *model.Class) (int, error) {
|
||||
if err := r.db.Create(class).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return class.ClassID, nil
|
||||
}
|
||||
|
||||
// Update 更新班级信息(仅更新非零值字段)
|
||||
func (r *ClassRepo) Update(classID int, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Model(&model.Class{}).
|
||||
Where("class_id = ?", classID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// Delete 删除班级(硬删除,需先确认无学生)
|
||||
func (r *ClassRepo) Delete(classID int) error {
|
||||
return r.db.Where("class_id = ?", classID).Delete(&model.Class{}).Error
|
||||
}
|
||||
|
||||
// GetStudentCount 获取班级活跃学生数量
|
||||
func (r *ClassRepo) GetStudentCount(classID int) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.Student{}).
|
||||
Where("class_id = ? AND status = 1", classID).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// HasActiveStudents 检查班级是否有活跃学生
|
||||
func (r *ClassRepo) HasActiveStudents(classID int) (bool, error) {
|
||||
count, err := r.GetStudentCount(classID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// ========== 班级设置操作 ==========
|
||||
|
||||
// GetSettings 获取班级的所有设置
|
||||
func (r *ClassRepo) GetSettings(classID int) ([]model.ClassSetting, error) {
|
||||
var settings []model.ClassSetting
|
||||
if err := r.db.Where("class_id = ?", classID).Find(&settings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetSetting 获取班级单个设置项
|
||||
func (r *ClassRepo) GetSetting(classID int, key string) (*model.ClassSetting, error) {
|
||||
var setting model.ClassSetting
|
||||
if err := r.db.Where("class_id = ? AND setting_key = ?", classID, key).First(&setting).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
// SaveSetting 保存班级设置项(upsert)
|
||||
func (r *ClassRepo) SaveSetting(classID int, key, value string) error {
|
||||
setting := model.ClassSetting{
|
||||
ClassID: classID,
|
||||
SettingKey: key,
|
||||
SettingValue: value,
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "class_id"}, {Name: "setting_key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"setting_value"}),
|
||||
}).Create(&setting).Error
|
||||
}
|
||||
|
||||
// BatchSaveSettings 批量保存班级设置项
|
||||
func (r *ClassRepo) BatchSaveSettings(classID int, settings map[string]string) error {
|
||||
for key, value := range settings {
|
||||
if err := r.SaveSetting(classID, key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ========== 班级功能开关操作 ==========
|
||||
|
||||
// GetFeatures 获取班级的所有功能开关
|
||||
func (r *ClassRepo) GetFeatures(classID int) ([]model.ClassFeature, error) {
|
||||
var features []model.ClassFeature
|
||||
if err := r.db.Where("class_id = ?", classID).Find(&features).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return features, nil
|
||||
}
|
||||
|
||||
// GetFeature 获取班级单个功能开关
|
||||
func (r *ClassRepo) GetFeature(classID int, featureKey string) (*model.ClassFeature, error) {
|
||||
var feature model.ClassFeature
|
||||
if err := r.db.Where("class_id = ? AND feature_key = ?", classID, featureKey).First(&feature).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &feature, nil
|
||||
}
|
||||
|
||||
// SaveFeature 保存班级功能开关(upsert)
|
||||
func (r *ClassRepo) SaveFeature(classID int, featureKey string, enabled int8) error {
|
||||
feature := model.ClassFeature{
|
||||
ClassID: classID,
|
||||
FeatureKey: featureKey,
|
||||
Enabled: enabled,
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "class_id"}, {Name: "feature_key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"enabled"}),
|
||||
}).Create(&feature).Error
|
||||
}
|
||||
294
backend-go/internal/repository/conduct_repo.go
Normal file
294
backend-go/internal/repository/conduct_repo.go
Normal file
@@ -0,0 +1,294 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// ConductRepo 操行分记录数据访问层
|
||||
type ConductRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewConductRepo 创建操行分 Repository
|
||||
func NewConductRepo(db *gorm.DB) *ConductRepo {
|
||||
return &ConductRepo{db: db}
|
||||
}
|
||||
|
||||
// CreateRecord 创建操行分记录
|
||||
func (r *ConductRepo) CreateRecord(record *model.ConductRecord) (int64, error) {
|
||||
if err := r.db.Create(record).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return record.RecordID, nil
|
||||
}
|
||||
|
||||
// GetRecordByID 根据ID获取记录(含学生信息)
|
||||
func (r *ConductRepo) GetRecordByID(recordID int64) (*model.ConductRecord, error) {
|
||||
var record model.ConductRecord
|
||||
if err := r.db.Table("conduct_records cr").
|
||||
Select("cr.*, s.name as student_name, s.total_points").
|
||||
Joins("JOIN students s ON cr.student_id = s.student_id").
|
||||
Where("cr.record_id = ?", recordID).
|
||||
First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &record, nil
|
||||
}
|
||||
|
||||
// CountStudentRecords 统计学生操行分记录总数
|
||||
func (r *ConductRepo) CountStudentRecords(studentID int, includeRevoked bool, startDate, endDate string, recorderID int) (int64, error) {
|
||||
var count int64
|
||||
query := r.db.Model(&model.ConductRecord{}).Where("student_id = ?", studentID)
|
||||
|
||||
if !includeRevoked {
|
||||
query = query.Where("is_revoked = 0")
|
||||
}
|
||||
if startDate != "" {
|
||||
query = query.Where("DATE(created_at) >= ?", startDate)
|
||||
}
|
||||
if endDate != "" {
|
||||
query = query.Where("DATE(created_at) <= ?", endDate)
|
||||
}
|
||||
if recorderID > 0 {
|
||||
query = query.Where("recorder_id = ?", recorderID)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetStudentRecords 获取学生操行分记录
|
||||
func (r *ConductRepo) GetStudentRecords(studentID int, limit, offset int, includeRevoked bool, startDate, endDate string, recorderID int) ([]model.ConductRecord, error) {
|
||||
var records []model.ConductRecord
|
||||
query := r.db.Table("conduct_records cr").
|
||||
Select("cr.*, u.real_name as recorder_real").
|
||||
Joins("LEFT JOIN users u ON cr.recorder_id = u.user_id").
|
||||
Where("cr.student_id = ?", studentID)
|
||||
|
||||
if !includeRevoked {
|
||||
query = query.Where("cr.is_revoked = 0")
|
||||
}
|
||||
if startDate != "" {
|
||||
query = query.Where("DATE(cr.created_at) >= ?", startDate)
|
||||
}
|
||||
if endDate != "" {
|
||||
query = query.Where("DATE(cr.created_at) <= ?", endDate)
|
||||
}
|
||||
if recorderID > 0 {
|
||||
query = query.Where("cr.recorder_id = ?", recorderID)
|
||||
}
|
||||
|
||||
if err := query.Order("cr.created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetAllRecords 获取所有记录(管理员用,支持多种过滤条件)
|
||||
func (r *ConductRepo) GetAllRecords(classID int, limit, offset int, startDate, endDate string,
|
||||
studentID int, includeRevoked bool, relatedType, reasonPrefix string,
|
||||
isRevoked *int, reasonSearch string) ([]model.ConductRecord, error) {
|
||||
|
||||
var records []model.ConductRecord
|
||||
query := r.db.Table("conduct_records cr").
|
||||
Select("cr.*, s.name as student_name, s.student_no, s.class_id, u.real_name as recorder_real, ru.real_name as revoker_name").
|
||||
Joins("JOIN students s ON cr.student_id = s.student_id").
|
||||
Joins("JOIN users u ON cr.recorder_id = u.user_id").
|
||||
Joins("LEFT JOIN users ru ON cr.revoked_by = ru.user_id").
|
||||
Where("1 = 1")
|
||||
|
||||
if !includeRevoked {
|
||||
query = query.Where("cr.is_revoked = 0")
|
||||
}
|
||||
if classID > 0 {
|
||||
query = query.Where("s.class_id = ?", classID)
|
||||
}
|
||||
if studentID > 0 {
|
||||
query = query.Where("cr.student_id = ?", studentID)
|
||||
}
|
||||
if startDate != "" {
|
||||
query = query.Where("DATE(cr.created_at) >= ?", startDate)
|
||||
}
|
||||
if endDate != "" {
|
||||
query = query.Where("DATE(cr.created_at) <= ?", endDate)
|
||||
}
|
||||
if relatedType != "" {
|
||||
query = query.Where("cr.related_type = ?", relatedType)
|
||||
}
|
||||
if reasonPrefix != "" {
|
||||
query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%s%%", reasonPrefix))
|
||||
}
|
||||
if reasonSearch != "" {
|
||||
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(reasonSearch)
|
||||
query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%%%s%%", escaped))
|
||||
}
|
||||
if isRevoked != nil {
|
||||
query = query.Where("cr.is_revoked = ?", *isRevoked)
|
||||
}
|
||||
|
||||
if err := query.Order("cr.created_at DESC").
|
||||
Limit(limit).
|
||||
Offset(offset).
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// CountAllRecords 统计记录总数(与 GetAllRecords 使用相同过滤条件)
|
||||
func (r *ConductRepo) CountAllRecords(classID int, startDate, endDate string,
|
||||
studentID int, includeRevoked bool, relatedType, reasonPrefix string,
|
||||
isRevoked *int, reasonSearch string) (int64, error) {
|
||||
|
||||
var count int64
|
||||
query := r.db.Table("conduct_records cr").
|
||||
Joins("JOIN students s ON cr.student_id = s.student_id").
|
||||
Where("1 = 1")
|
||||
|
||||
if !includeRevoked {
|
||||
query = query.Where("cr.is_revoked = 0")
|
||||
}
|
||||
if classID > 0 {
|
||||
query = query.Where("s.class_id = ?", classID)
|
||||
}
|
||||
if studentID > 0 {
|
||||
query = query.Where("cr.student_id = ?", studentID)
|
||||
}
|
||||
if startDate != "" {
|
||||
query = query.Where("DATE(cr.created_at) >= ?", startDate)
|
||||
}
|
||||
if endDate != "" {
|
||||
query = query.Where("DATE(cr.created_at) <= ?", endDate)
|
||||
}
|
||||
if relatedType != "" {
|
||||
query = query.Where("cr.related_type = ?", relatedType)
|
||||
}
|
||||
if reasonPrefix != "" {
|
||||
query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%s%%", reasonPrefix))
|
||||
}
|
||||
if reasonSearch != "" {
|
||||
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(reasonSearch)
|
||||
query = query.Where("cr.reason LIKE ?", fmt.Sprintf("%%%s%%", escaped))
|
||||
}
|
||||
if isRevoked != nil {
|
||||
query = query.Where("cr.is_revoked = ?", *isRevoked)
|
||||
}
|
||||
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// RevokeRecord 撤销单条操行分记录
|
||||
func (r *ConductRepo) RevokeRecord(recordID int64, revokerID int) error {
|
||||
return r.db.Model(&model.ConductRecord{}).
|
||||
Where("record_id = ? AND is_revoked = 0", recordID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_revoked": 1,
|
||||
"revoked_by": revokerID,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// BatchRevokeRecords 批量撤销记录
|
||||
func (r *ConductRepo) BatchRevokeRecords(recordIDs []int64, revokerID int) (int64, error) {
|
||||
result := r.db.Model(&model.ConductRecord{}).
|
||||
Where("record_id IN ? AND is_revoked = 0", recordIDs).
|
||||
Updates(map[string]interface{}{
|
||||
"is_revoked": 1,
|
||||
"revoked_by": revokerID,
|
||||
"revoked_at": time.Now(),
|
||||
})
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// BatchRestoreRecords 批量反撤销记录
|
||||
func (r *ConductRepo) BatchRestoreRecords(recordIDs []int64) (int64, error) {
|
||||
result := r.db.Model(&model.ConductRecord{}).
|
||||
Where("record_id IN ? AND is_revoked = 1", recordIDs).
|
||||
Updates(map[string]interface{}{
|
||||
"is_revoked": 0,
|
||||
"revoked_by": nil,
|
||||
"revoked_at": nil,
|
||||
})
|
||||
if result.Error != nil {
|
||||
return 0, result.Error
|
||||
}
|
||||
return result.RowsAffected, nil
|
||||
}
|
||||
|
||||
// AssociateSemester 将记录关联到学期
|
||||
func (r *ConductRepo) AssociateSemester(recordID int64, semesterID int) error {
|
||||
return r.db.Model(&model.ConductRecord{}).
|
||||
Where("record_id = ? AND semester_id IS NULL", recordID).
|
||||
Update("semester_id", semesterID).Error
|
||||
}
|
||||
|
||||
// GetHomeworkRecords 获取学生作业相关的操行分记录
|
||||
func (r *ConductRepo) GetHomeworkRecords(studentID int) ([]model.ConductRecord, error) {
|
||||
var records []model.ConductRecord
|
||||
if err := r.db.Where("student_id = ? AND related_type = 'homework' AND is_revoked = 0", studentID).
|
||||
Order("created_at DESC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// GetStudentPointsByType 按 related_type 在 SQL 层聚合学生分数(避免全量加载),支持 limit 限制返回数量
|
||||
func (r *ConductRepo) GetStudentPointsByType(classID int, relatedType string, limit int) ([]struct {
|
||||
StudentID int
|
||||
StudentNo string
|
||||
Name string
|
||||
TotalPoints int
|
||||
}, error) {
|
||||
var results []struct {
|
||||
StudentID int
|
||||
StudentNo string
|
||||
Name string
|
||||
TotalPoints int
|
||||
}
|
||||
err := r.db.Table("conduct_records cr").
|
||||
Select("cr.student_id, s.student_no, s.name, SUM(cr.points_change) as total_points").
|
||||
Joins("JOIN students s ON cr.student_id = s.student_id").
|
||||
Where("s.class_id = ? AND s.status = 1 AND cr.related_type = ? AND cr.is_revoked = 0", classID, relatedType).
|
||||
Group("cr.student_id, s.student_no, s.name").
|
||||
Order("total_points DESC").
|
||||
Limit(limit).
|
||||
Find(&results).Error
|
||||
return results, err
|
||||
}
|
||||
|
||||
// GetStudentTotalPoints 获取学生当前总分
|
||||
func (r *ConductRepo) GetStudentTotalPoints(studentID int) (int, error) {
|
||||
var student model.Student
|
||||
if err := r.db.Where("student_id = ?", studentID).First(&student).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return student.TotalPoints, nil
|
||||
}
|
||||
91
backend-go/internal/repository/log_repo.go
Normal file
91
backend-go/internal/repository/log_repo.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// LogRepo 日志数据访问层
|
||||
type LogRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewLogRepo 创建日志 Repository
|
||||
func NewLogRepo(db *gorm.DB) *LogRepo {
|
||||
return &LogRepo{db: db}
|
||||
}
|
||||
|
||||
// ========== 操作日志 ==========
|
||||
|
||||
// CreateOperationLog 写入操作日志
|
||||
func (r *LogRepo) CreateOperationLog(log *model.OperationLog) (int64, error) {
|
||||
if err := r.db.Create(log).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return log.LogID, nil
|
||||
}
|
||||
|
||||
// GetOperationLogs 查询操作日志(支持按操作者和班级过滤)
|
||||
func (r *LogRepo) GetOperationLogs(operatorID int, classID int, operationType string, page, pageSize int) ([]model.OperationLog, int64, error) {
|
||||
var logs []model.OperationLog
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&model.OperationLog{}).Where("1 = 1")
|
||||
|
||||
if operatorID > 0 {
|
||||
query = query.Where("operator_id = ?", operatorID)
|
||||
}
|
||||
if classID > 0 {
|
||||
query = query.Where("class_id = ?", classID)
|
||||
}
|
||||
if operationType != "" {
|
||||
query = query.Where("operation_type = ?", operationType)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("created_at DESC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&logs).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return logs, total, nil
|
||||
}
|
||||
|
||||
// ========== 登录日志 ==========
|
||||
|
||||
// CreateLoginLog 写入登录日志
|
||||
func (r *LogRepo) CreateLoginLog(log *model.LoginLog) (int64, error) {
|
||||
if err := r.db.Create(log).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return log.LogID, nil
|
||||
}
|
||||
|
||||
// GetRecentLoginFailCount 获取最近 5 分钟内的登录失败次数
|
||||
func (r *LogRepo) GetRecentLoginFailCount(username string) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.LoginLog{}).
|
||||
Where("username = ? AND login_result = 0 AND created_at > DATE_SUB(NOW(), INTERVAL 5 MINUTE)", username).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
291
backend-go/internal/repository/semester_repo.go
Normal file
291
backend-go/internal/repository/semester_repo.go
Normal file
@@ -0,0 +1,291 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// SemesterRepo 学期数据访问层
|
||||
type SemesterRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSemesterRepo 创建学期 Repository
|
||||
func NewSemesterRepo(db *gorm.DB) *SemesterRepo {
|
||||
return &SemesterRepo{db: db}
|
||||
}
|
||||
|
||||
// GetDB 获取底层数据库连接(用于事务操作)
|
||||
func (r *SemesterRepo) GetDB() *gorm.DB {
|
||||
return r.db
|
||||
}
|
||||
|
||||
// Create 创建学期
|
||||
func (r *SemesterRepo) Create(semester *model.Semester) (int, error) {
|
||||
if err := r.db.Create(semester).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return semester.SemesterID, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取学期信息
|
||||
func (r *SemesterRepo) GetByID(semesterID int) (*model.Semester, error) {
|
||||
var semester model.Semester
|
||||
if err := r.db.Where("semester_id = ?", semesterID).First(&semester).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &semester, nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有学期列表
|
||||
func (r *SemesterRepo) GetAll() ([]model.Semester, error) {
|
||||
var semesters []model.Semester
|
||||
if err := r.db.Order("created_at DESC").Find(&semesters).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return semesters, nil
|
||||
}
|
||||
|
||||
// GetActive 获取当前活跃学期(优先 is_active 标记,降级为日期范围匹配)
|
||||
func (r *SemesterRepo) GetActive() (*model.Semester, error) {
|
||||
var semester model.Semester
|
||||
|
||||
// 第一优先级:is_active 标记
|
||||
if err := r.db.Where("is_active = 1 AND is_archived = 0").
|
||||
Limit(1).First(&semester).Error; err == nil {
|
||||
return &semester, nil
|
||||
}
|
||||
|
||||
// 第二优先级:日期范围匹配
|
||||
today := time.Now().Format("2006-01-02")
|
||||
if err := r.db.Where("is_archived = 0 AND start_date <= ? AND (end_date IS NULL OR end_date >= ?)", today, today).
|
||||
Limit(1).First(&semester).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &semester, nil
|
||||
}
|
||||
|
||||
// DeactivateAll 将所有学期设为非活跃
|
||||
func (r *SemesterRepo) DeactivateAll() error {
|
||||
return r.db.Model(&model.Semester{}).
|
||||
Where("is_active = 1").
|
||||
Update("is_active", 0).Error
|
||||
}
|
||||
|
||||
// Activate 设为当前活跃学期
|
||||
func (r *SemesterRepo) Activate(semesterID int) error {
|
||||
return r.db.Model(&model.Semester{}).
|
||||
Where("semester_id = ? AND is_archived = 0", semesterID).
|
||||
Update("is_active", 1).Error
|
||||
}
|
||||
|
||||
// Archive 归档学期
|
||||
func (r *SemesterRepo) Archive(semesterID int) error {
|
||||
return r.db.Model(&model.Semester{}).
|
||||
Where("semester_id = ? AND is_archived = 0", semesterID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_archived": 1,
|
||||
"is_active": 0,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// Update 编辑学期信息(仅未归档)
|
||||
func (r *SemesterRepo) Update(semesterID int, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Model(&model.Semester{}).
|
||||
Where("semester_id = ? AND is_archived = 0", semesterID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// Delete 删除学期
|
||||
func (r *SemesterRepo) Delete(semesterID int) error {
|
||||
return r.db.Where("semester_id = ?", semesterID).Delete(&model.Semester{}).Error
|
||||
}
|
||||
|
||||
// CountArchives 统计学期归档数据数量
|
||||
func (r *SemesterRepo) CountArchives(semesterID int) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.SemesterArchive{}).
|
||||
Where("semester_id = ?", semesterID).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// CountRecordsBySemester 统计学期关联的记录数
|
||||
func (r *SemesterRepo) CountRecordsBySemester(semesterID int) (conductCount, attendanceCount int64, err error) {
|
||||
if err = r.db.Model(&model.ConductRecord{}).
|
||||
Where("semester_id = ?", semesterID).
|
||||
Count(&conductCount).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
if err = r.db.Model(&model.AttendanceRecord{}).
|
||||
Where("semester_id = ?", semesterID).
|
||||
Count(&attendanceCount).Error; err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return conductCount, attendanceCount, nil
|
||||
}
|
||||
|
||||
// AssociateRecordsByDateRange 按日期范围关联记录到学期
|
||||
func (r *SemesterRepo) AssociateRecordsByDateRange(semesterID int, startDate, endDate string) (conductCount, attendanceCount int64, err error) {
|
||||
if startDate == "" || endDate == "" {
|
||||
return 0, 0, fmt.Errorf("日期范围不能为空")
|
||||
}
|
||||
|
||||
// 关联操行分记录
|
||||
result := r.db.Model(&model.ConductRecord{}).
|
||||
Where("semester_id IS NULL AND created_at BETWEEN ? AND CONCAT(?, ' 23:59:59')", startDate, endDate).
|
||||
Update("semester_id", semesterID)
|
||||
if result.Error != nil {
|
||||
return 0, 0, result.Error
|
||||
}
|
||||
conductCount = result.RowsAffected
|
||||
|
||||
// 关联考勤记录
|
||||
result = r.db.Model(&model.AttendanceRecord{}).
|
||||
Where("semester_id IS NULL AND `date` BETWEEN ? AND ?", startDate, endDate).
|
||||
Update("semester_id", semesterID)
|
||||
if result.Error != nil {
|
||||
return conductCount, 0, result.Error
|
||||
}
|
||||
attendanceCount = result.RowsAffected
|
||||
|
||||
return conductCount, attendanceCount, nil
|
||||
}
|
||||
|
||||
// GetConductRecordSemesterID 获取操行分记录所属的学期ID
|
||||
func (r *SemesterRepo) GetConductRecordSemesterID(recordID int64) (*int, error) {
|
||||
var record model.ConductRecord
|
||||
if err := r.db.Where("record_id = ?", recordID).First(&record).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return record.SemesterID, nil
|
||||
}
|
||||
|
||||
// ========== 学期归档操作 ==========
|
||||
|
||||
// BatchCreateArchives 批量创建归档快照
|
||||
func (r *SemesterRepo) BatchCreateArchives(archives []model.SemesterArchive) error {
|
||||
if len(archives) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Create(&archives).Error
|
||||
}
|
||||
|
||||
// DeleteArchivesBySemester 删除指定学期的所有归档数据
|
||||
func (r *SemesterRepo) DeleteArchivesBySemester(semesterID int) error {
|
||||
return r.db.Where("semester_id = ?", semesterID).Delete(&model.SemesterArchive{}).Error
|
||||
}
|
||||
|
||||
// GetArchivesBySemester 获取学期的归档数据
|
||||
func (r *SemesterRepo) GetArchivesBySemester(semesterID int, classID int, page, pageSize int) ([]model.SemesterArchive, int64, error) {
|
||||
var archives []model.SemesterArchive
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&model.SemesterArchive{}).Where("semester_id = ?", semesterID)
|
||||
if classID > 0 {
|
||||
query = query.Where("class_id = ?", classID)
|
||||
}
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("rank_position ASC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&archives).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return archives, total, nil
|
||||
}
|
||||
|
||||
// GetArchivesByStudent 获取学生在所有已归档学期的数据
|
||||
func (r *SemesterRepo) GetArchivesByStudent(studentID int) ([]model.SemesterArchive, error) {
|
||||
var archives []model.SemesterArchive
|
||||
if err := r.db.Table("semester_archives sa").
|
||||
Select("sa.archive_id, sa.semester_id, sa.student_id, sa.student_no, "+
|
||||
"sa.student_name, sa.final_points, sa.rank_position, "+
|
||||
"sa.total_students, sa.attendance_present, sa.attendance_absent, "+
|
||||
"sa.attendance_late, sa.attendance_leave, "+
|
||||
"sa.homework_submitted, sa.homework_not_submitted, sa.homework_late, "+
|
||||
"sa.archived_at, s.semester_name, s.start_date, s.end_date").
|
||||
Joins("JOIN semesters s ON sa.semester_id = s.semester_id").
|
||||
Where("sa.student_id = ?", studentID).
|
||||
Order("sa.archived_at DESC").
|
||||
Find(&archives).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return archives, nil
|
||||
}
|
||||
|
||||
// ========== 周期归档操作 ==========
|
||||
|
||||
// GetPeriodArchives 获取周期归档列表
|
||||
func (r *SemesterRepo) GetPeriodArchives(classID int, periodType string, page, pageSize int) ([]model.PeriodArchive, int64, error) {
|
||||
var archives []model.PeriodArchive
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&model.PeriodArchive{}).
|
||||
Where("class_id = ? AND period_type = ?", classID, periodType)
|
||||
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("archived_at DESC, period_label DESC, rank_position ASC").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&archives).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return archives, total, nil
|
||||
}
|
||||
|
||||
// GetPeriodArchiveLabels 获取班级的所有周期归档标签(按时间倒序去重)
|
||||
func (r *SemesterRepo) GetPeriodArchiveLabels(classID int, periodType string) ([]string, error) {
|
||||
var labels []string
|
||||
if err := r.db.Model(&model.PeriodArchive{}).
|
||||
Where("class_id = ? AND period_type = ?", classID, periodType).
|
||||
Distinct("period_label").
|
||||
Order("period_label DESC").
|
||||
Pluck("period_label", &labels).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return labels, nil
|
||||
}
|
||||
|
||||
// GetLatestPeriodArchiveLabel 获取指定班级最近一次周期归档的标签
|
||||
func (r *SemesterRepo) GetLatestPeriodArchiveLabel(classID int, periodType string) (string, error) {
|
||||
var archive model.PeriodArchive
|
||||
if err := r.db.Where("class_id = ? AND period_type = ?", classID, periodType).
|
||||
Order("archived_at DESC").
|
||||
Limit(1).
|
||||
First(&archive).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
return archive.PeriodLabel, nil
|
||||
}
|
||||
230
backend-go/internal/repository/student_repo.go
Normal file
230
backend-go/internal/repository/student_repo.go
Normal file
@@ -0,0 +1,230 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// StudentRepo 学生数据访问层
|
||||
type StudentRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewStudentRepo 创建学生 Repository
|
||||
func NewStudentRepo(db *gorm.DB) *StudentRepo {
|
||||
return &StudentRepo{db: db}
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取学生信息(含班级名称)
|
||||
func (r *StudentRepo) GetByID(studentID int) (*model.Student, error) {
|
||||
var student model.Student
|
||||
if err := r.db.Table("students s").
|
||||
Select("s.*, c.class_name").
|
||||
Joins("LEFT JOIN classes c ON s.class_id = c.class_id").
|
||||
Where("s.student_id = ?", studentID).
|
||||
First(&student).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &student, nil
|
||||
}
|
||||
|
||||
// GetByStudentNo 根据学号获取学生(可指定班级)
|
||||
func (r *StudentRepo) GetByStudentNo(studentNo string, classID int) (*model.Student, error) {
|
||||
var student model.Student
|
||||
query := r.db.Where("student_no = ?", studentNo)
|
||||
if classID > 0 {
|
||||
query = query.Where("class_id = ?", classID)
|
||||
}
|
||||
if err := query.First(&student).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &student, nil
|
||||
}
|
||||
|
||||
// GetAll 获取指定班级的学生列表
|
||||
func (r *StudentRepo) GetAll(classID int, includeDisabled bool) ([]model.Student, error) {
|
||||
var students []model.Student
|
||||
query := r.db.Where("class_id = ?", classID)
|
||||
if !includeDisabled {
|
||||
query = query.Where("status = 1")
|
||||
}
|
||||
if err := query.Order("student_no").Find(&students).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return students, nil
|
||||
}
|
||||
|
||||
// GetDormitoryList 获取班级内所有不重复的宿舍号列表
|
||||
func (r *StudentRepo) GetDormitoryList(classID int) ([]string, error) {
|
||||
var dormitories []string
|
||||
err := r.db.Model(&model.Student{}).
|
||||
Where("class_id = ? AND status = 1 AND dormitory_number IS NOT NULL AND dormitory_number != ''", classID).
|
||||
Distinct("dormitory_number").
|
||||
Order("dormitory_number").
|
||||
Pluck("dormitory_number", &dormitories).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return dormitories, nil
|
||||
}
|
||||
|
||||
// Create 创建学生记录
|
||||
func (r *StudentRepo) Create(student *model.Student) (int, error) {
|
||||
if err := r.db.Create(student).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return student.StudentID, nil
|
||||
}
|
||||
|
||||
// Update 更新学生信息(仅更新非零值字段)
|
||||
func (r *StudentRepo) Update(studentID int, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Model(&model.Student{}).
|
||||
Where("student_id = ?", studentID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// SoftDelete 软删除学生
|
||||
func (r *StudentRepo) SoftDelete(studentID int) error {
|
||||
return r.db.Model(&model.Student{}).
|
||||
Where("student_id = ?", studentID).
|
||||
Update("status", 0).Error
|
||||
}
|
||||
|
||||
// UpdateTotalPoints 更新学生总分(增量更新,下限保护为 0)
|
||||
func (r *StudentRepo) UpdateTotalPoints(studentID int, pointsChange int) error {
|
||||
return r.db.Model(&model.Student{}).
|
||||
Where("student_id = ?", studentID).
|
||||
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error
|
||||
}
|
||||
|
||||
// GetRanking 获取班级内学生排行
|
||||
func (r *StudentRepo) GetRanking(classID int, limit int) ([]model.Student, error) {
|
||||
var students []model.Student
|
||||
if err := r.db.Where("status = 1 AND class_id = ?", classID).
|
||||
Order("total_points DESC, student_id ASC").
|
||||
Limit(limit).
|
||||
Find(&students).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return students, nil
|
||||
}
|
||||
|
||||
// GetTotalCount 获取班级内活跃学生总数
|
||||
func (r *StudentRepo) GetTotalCount(classID int) (int64, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.Student{}).
|
||||
Where("status = 1 AND class_id = ?", classID).
|
||||
Count(&count).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListByClass 分页获取班级学生列表(支持搜索和宿舍号过滤)
|
||||
func (r *StudentRepo) ListByClass(classID int, page, pageSize int, search, dormitoryNumber string) ([]model.Student, int64, error) {
|
||||
var students []model.Student
|
||||
var total int64
|
||||
|
||||
query := r.db.Model(&model.Student{}).Where("status = 1 AND class_id = ?", classID)
|
||||
|
||||
if search != "" {
|
||||
escaped := strings.NewReplacer("\\", "\\\\", "%", "\\%", "_", "\\_").Replace(search)
|
||||
searchPattern := fmt.Sprintf("%%%s%%", escaped)
|
||||
query = query.Where("student_no LIKE ? OR name LIKE ?", searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
if dormitoryNumber != "" {
|
||||
query = query.Where("dormitory_number = ?", dormitoryNumber)
|
||||
}
|
||||
|
||||
// 获取总数
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
// 分页查询
|
||||
offset := (page - 1) * pageSize
|
||||
if err := query.Order("student_no").
|
||||
Limit(pageSize).
|
||||
Offset(offset).
|
||||
Find(&students).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
return students, total, nil
|
||||
}
|
||||
|
||||
// BatchCreate 批量创建学生
|
||||
func (r *StudentRepo) BatchCreate(students []model.Student) error {
|
||||
return r.db.Create(&students).Error
|
||||
}
|
||||
|
||||
// GetStudentNosByClass 获取指定班级所有学生学号(用于批量导入去重)
|
||||
func (r *StudentRepo) GetStudentNosByClass(classID int) ([]string, error) {
|
||||
var studentNos []string
|
||||
if err := r.db.Model(&model.Student{}).
|
||||
Where("class_id = ?", classID).
|
||||
Pluck("student_no", &studentNos).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return studentNos, nil
|
||||
}
|
||||
|
||||
// ResetPoints 重置班级内所有学生的操行分为初始值
|
||||
func (r *StudentRepo) ResetPoints(classID int, initialPoints int) error {
|
||||
return r.db.Model(&model.Student{}).
|
||||
Where("class_id = ? AND status = 1", classID).
|
||||
Update("total_points", initialPoints).Error
|
||||
}
|
||||
|
||||
// GetByParentAccount 根据家长账号查找学生
|
||||
func (r *StudentRepo) GetByParentAccount(parentAccount string) (*model.Student, error) {
|
||||
var student model.Student
|
||||
if err := r.db.Where("parent_account = ? AND status = 1", parentAccount).First(&student).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &student, nil
|
||||
}
|
||||
|
||||
// GetRankByStudentID 使用密集排名(dense rank)计算学生排名:相同分数同名次,后续名次不跳过
|
||||
func (r *StudentRepo) GetRankByStudentID(classID, studentID int) (int, error) {
|
||||
var student model.Student
|
||||
if err := r.db.Select("total_points").Where("student_id = ?", studentID).First(&student).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var distinctHigherCount int64
|
||||
if err := r.db.Raw("SELECT COUNT(DISTINCT total_points) FROM students WHERE status = 1 AND class_id = ? AND total_points > ?",
|
||||
classID, student.TotalPoints).Scan(&distinctHigherCount).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return int(distinctHigherCount) + 1, nil
|
||||
}
|
||||
|
||||
// GetStudentsByClassID 获取班级内所有活跃学生(用于归档等批量操作)
|
||||
func (r *StudentRepo) GetStudentsByClassID(classID int) ([]model.Student, error) {
|
||||
var students []model.Student
|
||||
if err := r.db.Where("class_id = ? AND status = 1", classID).
|
||||
Order("total_points DESC, student_id ASC").
|
||||
Find(&students).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return students, nil
|
||||
}
|
||||
104
backend-go/internal/repository/subject_repo.go
Normal file
104
backend-go/internal/repository/subject_repo.go
Normal file
@@ -0,0 +1,104 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// SubjectRepo 科目数据访问层
|
||||
type SubjectRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSubjectRepo 创建科目 Repository
|
||||
func NewSubjectRepo(db *gorm.DB) *SubjectRepo {
|
||||
return &SubjectRepo{db: db}
|
||||
}
|
||||
|
||||
// GetAll 获取所有科目列表
|
||||
func (r *SubjectRepo) GetAll(isActive *bool) ([]model.Subject, error) {
|
||||
var subjects []model.Subject
|
||||
query := r.db.Where("1 = 1")
|
||||
if isActive != nil {
|
||||
if *isActive {
|
||||
query = query.Where("is_active = 1")
|
||||
} else {
|
||||
query = query.Where("is_active = 0")
|
||||
}
|
||||
}
|
||||
if err := query.Order("sort_order, subject_id").Find(&subjects).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return subjects, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取科目
|
||||
func (r *SubjectRepo) GetByID(subjectID int) (*model.Subject, error) {
|
||||
var subject model.Subject
|
||||
if err := r.db.Where("subject_id = ?", subjectID).First(&subject).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &subject, nil
|
||||
}
|
||||
|
||||
// GetByName 根据科目名称获取科目
|
||||
func (r *SubjectRepo) GetByName(subjectName string) (*model.Subject, error) {
|
||||
var subject model.Subject
|
||||
if err := r.db.Where("subject_name = ?", subjectName).First(&subject).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &subject, nil
|
||||
}
|
||||
|
||||
// Create 创建科目
|
||||
func (r *SubjectRepo) Create(subject *model.Subject) (int, error) {
|
||||
if err := r.db.Create(subject).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return subject.SubjectID, nil
|
||||
}
|
||||
|
||||
// Update 更新科目信息
|
||||
func (r *SubjectRepo) Update(subjectID int, updates map[string]interface{}) error {
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.Model(&model.Subject{}).
|
||||
Where("subject_id = ?", subjectID).
|
||||
Updates(updates).Error
|
||||
}
|
||||
|
||||
// Delete 删除科目
|
||||
func (r *SubjectRepo) Delete(subjectID int) error {
|
||||
return r.db.Where("subject_id = ?", subjectID).Delete(&model.Subject{}).Error
|
||||
}
|
||||
|
||||
// HasRelatedData 检查科目是否有关联的作业数据
|
||||
func (r *SubjectRepo) HasRelatedData(subjectID int) (bool, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.Assignment{}).
|
||||
Where("subject_id = ?", subjectID).
|
||||
Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// Activate 激活科目
|
||||
func (r *SubjectRepo) Activate(subjectID int) error {
|
||||
return r.db.Model(&model.Subject{}).
|
||||
Where("subject_id = ?", subjectID).
|
||||
Update("is_active", 1).Error
|
||||
}
|
||||
110
backend-go/internal/repository/super_admin_repo.go
Normal file
110
backend-go/internal/repository/super_admin_repo.go
Normal file
@@ -0,0 +1,110 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// SuperAdminRepo 超级管理员数据访问层
|
||||
type SuperAdminRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSuperAdminRepo 创建超级管理员 Repository
|
||||
func NewSuperAdminRepo(db *gorm.DB) *SuperAdminRepo {
|
||||
return &SuperAdminRepo{db: db}
|
||||
}
|
||||
|
||||
// GetByUsername 根据用户名获取超级管理员
|
||||
func (r *SuperAdminRepo) GetByUsername(username string) (*model.SuperAdmin, error) {
|
||||
var admin model.SuperAdmin
|
||||
if err := r.db.Where("username = ? AND status = 1", username).First(&admin).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID获取超级管理员
|
||||
func (r *SuperAdminRepo) GetByID(id int) (*model.SuperAdmin, error) {
|
||||
var admin model.SuperAdmin
|
||||
if err := r.db.Where("id = ?", id).First(&admin).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &admin, nil
|
||||
}
|
||||
|
||||
// Create 创建超级管理员
|
||||
func (r *SuperAdminRepo) Create(admin *model.SuperAdmin) (int, error) {
|
||||
if err := r.db.Create(admin).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return admin.ID, nil
|
||||
}
|
||||
|
||||
// UpdatePassword 更新超级管理员密码
|
||||
func (r *SuperAdminRepo) UpdatePassword(id int, passwordHash string) error {
|
||||
return r.db.Model(&model.SuperAdmin{}).
|
||||
Where("id = ?", id).
|
||||
Update("password_hash", passwordHash).Error
|
||||
}
|
||||
|
||||
// UpdatePasswordWithSalt 更新超级管理员密码和盐值,并清除强制改密标记
|
||||
func (r *SuperAdminRepo) UpdatePasswordWithSalt(id int, passwordHash, salt string) error {
|
||||
return r.db.Model(&model.SuperAdmin{}).
|
||||
Where("id = ?", id).
|
||||
Updates(map[string]interface{}{
|
||||
"password_hash": passwordHash,
|
||||
"salt": salt,
|
||||
"need_change_password": 0,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// CheckUsernameExists 检查用户名是否存在
|
||||
func (r *SuperAdminRepo) CheckUsernameExists(username string) (bool, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.SuperAdmin{}).Where("username = ?", username).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// List 获取所有超级管理员
|
||||
func (r *SuperAdminRepo) List() ([]model.SuperAdmin, error) {
|
||||
var admins []model.SuperAdmin
|
||||
if err := r.db.Order("id").Find(&admins).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return admins, nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新超级管理员状态
|
||||
func (r *SuperAdminRepo) UpdateStatus(id int, status int8) error {
|
||||
return r.db.Model(&model.SuperAdmin{}).
|
||||
Where("id = ?", id).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
// EnsureDefaultAdmin 确保默认超级管理员存在(使用 INSERT IGNORE 避免并发竞态)
|
||||
func (r *SuperAdminRepo) EnsureDefaultAdmin(username, passwordHash, salt, realName string) error {
|
||||
admin := model.SuperAdmin{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
Salt: salt,
|
||||
RealName: realName,
|
||||
Status: 1,
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{DoNothing: true}).Create(&admin).Error
|
||||
}
|
||||
100
backend-go/internal/repository/system_setting_repo.go
Normal file
100
backend-go/internal/repository/system_setting_repo.go
Normal file
@@ -0,0 +1,100 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// SystemSettingRepo 系统设置数据访问层
|
||||
type SystemSettingRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewSystemSettingRepo 创建系统设置 Repository
|
||||
func NewSystemSettingRepo(db *gorm.DB) *SystemSettingRepo {
|
||||
return &SystemSettingRepo{db: db}
|
||||
}
|
||||
|
||||
// GetByKey 根据键名获取系统设置
|
||||
func (r *SystemSettingRepo) GetByKey(key string) (*model.SystemSetting, error) {
|
||||
var setting model.SystemSetting
|
||||
if err := r.db.Where("setting_key = ?", key).First(&setting).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &setting, nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有系统设置
|
||||
func (r *SystemSettingRepo) GetAll() ([]model.SystemSetting, error) {
|
||||
var settings []model.SystemSetting
|
||||
if err := r.db.Find(&settings).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return settings, nil
|
||||
}
|
||||
|
||||
// GetByKeyMap 获取所有系统设置并转为 map
|
||||
func (r *SystemSettingRepo) GetByKeyMap() (map[string]string, error) {
|
||||
settings, err := r.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := make(map[string]string, len(settings))
|
||||
for _, s := range settings {
|
||||
result[s.SettingKey] = s.SettingValue
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Save 保存系统设置(upsert)
|
||||
func (r *SystemSettingRepo) Save(key, value string) error {
|
||||
setting := model.SystemSetting{
|
||||
SettingKey: key,
|
||||
SettingValue: value,
|
||||
}
|
||||
return r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "setting_key"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"setting_value"}),
|
||||
}).Create(&setting).Error
|
||||
}
|
||||
|
||||
// BatchSave 批量保存系统设置
|
||||
func (r *SystemSettingRepo) BatchSave(settings map[string]string) error {
|
||||
for key, value := range settings {
|
||||
if err := r.Save(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetValue 根据键名获取设置值
|
||||
func (r *SystemSettingRepo) GetValue(key string) (string, error) {
|
||||
setting, err := r.GetByKey(key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return setting.SettingValue, nil
|
||||
}
|
||||
|
||||
// GetValueWithDefault 根据键名获取设置值,不存在则返回默认值
|
||||
func (r *SystemSettingRepo) GetValueWithDefault(key, defaultValue string) string {
|
||||
setting, err := r.GetByKey(key)
|
||||
if err != nil {
|
||||
return defaultValue
|
||||
}
|
||||
return setting.SettingValue
|
||||
}
|
||||
166
backend-go/internal/repository/user_repo.go
Normal file
166
backend-go/internal/repository/user_repo.go
Normal file
@@ -0,0 +1,166 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
)
|
||||
|
||||
// UserRepo 用户数据访问层
|
||||
type UserRepo struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
// NewUserRepo 创建用户 Repository
|
||||
func NewUserRepo(db *gorm.DB) *UserRepo {
|
||||
return &UserRepo{db: db}
|
||||
}
|
||||
|
||||
// GetByUsername 根据用户名获取用户(含状态过滤)
|
||||
func (r *UserRepo) GetByUsername(username string) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := r.db.Where("username = ? AND status = 1", username).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// GetByUserID 根据用户ID获取用户
|
||||
func (r *UserRepo) GetByUserID(userID int) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := r.db.Where("user_id = ?", userID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// CreateStudent 创建学生账号
|
||||
func (r *UserRepo) CreateStudent(username, passwordHash, realName string, studentID int) (int, error) {
|
||||
user := model.User{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
RealName: realName,
|
||||
UserType: "student",
|
||||
StudentID: &studentID,
|
||||
Status: 1,
|
||||
NeedChangePassword: 1,
|
||||
}
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.UserID, nil
|
||||
}
|
||||
|
||||
// CreateParent 创建家长账号
|
||||
func (r *UserRepo) CreateParent(username, passwordHash, realName string, studentID int) (int, error) {
|
||||
user := model.User{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
RealName: realName,
|
||||
UserType: "parent",
|
||||
StudentID: &studentID,
|
||||
Status: 1,
|
||||
NeedChangePassword: 0,
|
||||
}
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.UserID, nil
|
||||
}
|
||||
|
||||
// CreateAdmin 创建管理员账号
|
||||
func (r *UserRepo) CreateAdmin(username, passwordHash, realName string) (int, error) {
|
||||
user := model.User{
|
||||
Username: username,
|
||||
PasswordHash: passwordHash,
|
||||
RealName: realName,
|
||||
UserType: "admin",
|
||||
Status: 1,
|
||||
NeedChangePassword: 1,
|
||||
}
|
||||
if err := r.db.Create(&user).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return user.UserID, nil
|
||||
}
|
||||
|
||||
// UpdatePassword 更新密码并清除强制改密标记
|
||||
func (r *UserRepo) UpdatePassword(userID int, passwordHash string) error {
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("user_id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"password_hash": passwordHash,
|
||||
"need_change_password": 0,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// UpdateLastLogin 更新最后登录信息
|
||||
func (r *UserRepo) UpdateLastLogin(userID int, ip string) error {
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("user_id = ?", userID).
|
||||
Updates(map[string]interface{}{
|
||||
"last_login_time": time.Now(),
|
||||
"last_login_ip": ip,
|
||||
}).Error
|
||||
}
|
||||
|
||||
// CheckUsernameExists 检查用户名是否存在
|
||||
func (r *UserRepo) CheckUsernameExists(username string) (bool, error) {
|
||||
var count int64
|
||||
if err := r.db.Model(&model.User{}).Where("username = ?", username).Count(&count).Error; err != nil {
|
||||
return false, err
|
||||
}
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// UpdateStatus 更新用户状态
|
||||
func (r *UserRepo) UpdateStatus(userID int, status int8) error {
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("status", status).Error
|
||||
}
|
||||
|
||||
// UpdateRealName 更新用户真实姓名
|
||||
func (r *UserRepo) UpdateRealName(userID int, realName string) error {
|
||||
return r.db.Model(&model.User{}).
|
||||
Where("user_id = ?", userID).
|
||||
Update("real_name", realName).Error
|
||||
}
|
||||
|
||||
// GetByStudentID 根据学生ID获取关联的用户账号
|
||||
func (r *UserRepo) GetByStudentID(studentID int) (*model.User, error) {
|
||||
var user model.User
|
||||
if err := r.db.Where("student_id = ? AND status = 1", studentID).First(&user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &user, nil
|
||||
}
|
||||
|
||||
// DeleteUser 硬删除用户记录
|
||||
func (r *UserRepo) DeleteUser(userID int) error {
|
||||
return r.db.Unscoped().Where("user_id = ?", userID).Delete(&model.User{}).Error
|
||||
}
|
||||
|
||||
// GetActiveUsernames 获取所有活跃用户的用户名列表(用于批量导入去重)
|
||||
func (r *UserRepo) GetActiveUsernames() ([]string, error) {
|
||||
var usernames []string
|
||||
if err := r.db.Model(&model.User{}).
|
||||
Where("status = 1").
|
||||
Pluck("username", &usernames).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return usernames, nil
|
||||
}
|
||||
206
backend-go/internal/router/router.go
Normal file
206
backend-go/internal/router/router.go
Normal file
@@ -0,0 +1,206 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package router
|
||||
|
||||
import (
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/handler"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/middleware"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/response"
|
||||
)
|
||||
|
||||
// Handlers 聚合所有 HTTP 处理器
|
||||
type Handlers struct {
|
||||
Auth *handler.AuthHandler
|
||||
Admin *handler.AdminHandler
|
||||
Student *handler.StudentHandler
|
||||
Parent *handler.ParentHandler
|
||||
Subject *handler.SubjectHandler
|
||||
Semester *handler.SemesterHandler
|
||||
Class *handler.ClassHandler
|
||||
Config *handler.ConfigHandler
|
||||
SuperAdmin *handler.SuperAdminHandler
|
||||
Cadre *handler.CadreHandler
|
||||
}
|
||||
|
||||
// SetupRouter 注册所有路由,返回 Gin 引擎
|
||||
func SetupRouter(cfg *config.Config, h *Handlers) *gin.Engine {
|
||||
if cfg.IsProduction() {
|
||||
gin.SetMode(gin.ReleaseMode)
|
||||
}
|
||||
|
||||
r := gin.New()
|
||||
|
||||
// ========== 全局中间件 ==========
|
||||
// CORS 说明:生产环境通过 Nginx 反代实现同源策略,API 与前端同域,无需额外 CORS 配置。
|
||||
// 若需要直接访问 API(绕过 Nginx),需在此添加 CORS 中间件。
|
||||
r.Use(middleware.AccessLog())
|
||||
r.Use(gin.Recovery())
|
||||
r.Use(middleware.Sanitize())
|
||||
|
||||
// ========== 公开路由组(不需要认证) ==========
|
||||
public := r.Group("/api")
|
||||
{
|
||||
public.POST("/auth/login", h.Auth.Login)
|
||||
}
|
||||
|
||||
// ========== 超级管理员独立登录(路径可配置) ==========
|
||||
superAdminPath := "/api" + cfg.SuperAdminLoginPath
|
||||
middleware.RegisterPublicPath(superAdminPath + "/login")
|
||||
superAdmin := r.Group(superAdminPath)
|
||||
{
|
||||
superAdmin.POST("/login", h.SuperAdmin.Login)
|
||||
}
|
||||
|
||||
// ========== 需认证的路由组 ==========
|
||||
authRequired := r.Group("/api")
|
||||
authRequired.Use(middleware.AuthRequired())
|
||||
{
|
||||
// 扣分规则(需认证)
|
||||
authRequired.GET("/config/deduction-rules", h.Config.GetDeductionRules)
|
||||
|
||||
// 认证相关
|
||||
authRequired.POST("/auth/logout", h.Auth.Logout)
|
||||
authRequired.POST("/auth/change-password", h.Auth.ChangePassword)
|
||||
authRequired.GET("/auth/me", h.Auth.GetUserInfo)
|
||||
|
||||
// 学生端
|
||||
student := authRequired.Group("/student")
|
||||
{
|
||||
student.GET("/conduct/:student_id", h.Student.ConductHistory)
|
||||
student.GET("/homework/:student_id", h.Student.Homework)
|
||||
student.GET("/attendance/:student_id", h.Student.Attendance)
|
||||
student.GET("/ranking", h.Student.Ranking)
|
||||
student.GET("/my-info", h.Student.MyInfo)
|
||||
student.GET("/semester-records", h.Student.SemesterRecords)
|
||||
}
|
||||
|
||||
// 家长端
|
||||
parent := authRequired.Group("/parent")
|
||||
{
|
||||
parent.GET("/child/conduct", h.Parent.Dashboard)
|
||||
parent.GET("/child/attendance", h.Parent.Attendance)
|
||||
parent.GET("/child/ranking", h.Parent.Ranking)
|
||||
parent.GET("/child/history", h.Parent.History)
|
||||
parent.POST("/password", h.Parent.ChangePassword)
|
||||
}
|
||||
|
||||
// 管理端
|
||||
admin := authRequired.Group("/admin")
|
||||
admin.Use(middleware.RequireRole("admin", "super_admin"))
|
||||
{
|
||||
// 学生管理
|
||||
admin.GET("/students/dormitories", h.Admin.GetDormitories)
|
||||
admin.GET("/students", h.Admin.StudentList)
|
||||
admin.POST("/students/import", h.Admin.StudentImport)
|
||||
admin.POST("/students", h.Admin.StudentCreate)
|
||||
admin.PUT("/students/:student_id", h.Admin.StudentUpdate)
|
||||
admin.DELETE("/students/:student_id", h.Admin.StudentDelete)
|
||||
admin.POST("/students/reset-password/:student_id", h.Admin.ResetStudentPassword)
|
||||
|
||||
// 操行分管理
|
||||
admin.POST("/conduct/add", h.Admin.AddConductPoints)
|
||||
admin.POST("/conduct/revoke", h.Admin.RevokeConductRecord)
|
||||
admin.POST("/conduct/restore", h.Admin.RestoreConductRecord)
|
||||
admin.GET("/conduct/history", h.Admin.GetConductHistory)
|
||||
admin.POST("/conduct/batch-revoke", h.Admin.BatchRevokeConductRecords)
|
||||
admin.POST("/conduct/batch-restore", h.Admin.BatchRestoreConductRecords)
|
||||
|
||||
// 考勤管理
|
||||
admin.POST("/attendance", h.Admin.CreateAttendanceRecord)
|
||||
admin.GET("/attendance/records", h.Admin.GetAttendanceRecords)
|
||||
|
||||
// 管理员管理
|
||||
admin.POST("/add", h.Admin.AdminCreate)
|
||||
admin.GET("/list", h.Admin.AdminList)
|
||||
admin.PUT("/update/:user_id", h.Admin.AdminUpdate)
|
||||
admin.DELETE("/delete/:user_id", h.Admin.AdminDelete)
|
||||
admin.POST("/reset-password/:user_id", h.Admin.AdminResetPassword)
|
||||
admin.POST("/unlock-user", h.Admin.UnlockAccount)
|
||||
|
||||
// 排行榜分项(新增)
|
||||
admin.GET("/rankings", h.Admin.GetRankings)
|
||||
}
|
||||
|
||||
// 科目管理
|
||||
subject := authRequired.Group("/subject")
|
||||
subject.Use(middleware.RequireRole("admin", "super_admin"))
|
||||
{
|
||||
subject.GET("/list", h.Subject.SubjectList)
|
||||
subject.POST("/create", h.Subject.SubjectCreate)
|
||||
subject.PUT("/update/:subject_id", h.Subject.SubjectUpdate)
|
||||
subject.PUT("/toggle/:subject_id", h.Subject.SubjectToggle)
|
||||
subject.DELETE("/delete/:subject_id", h.Subject.SubjectDelete)
|
||||
}
|
||||
|
||||
// 学期管理
|
||||
semester := authRequired.Group("/semester")
|
||||
semester.Use(middleware.RequireRole("admin", "super_admin"))
|
||||
{
|
||||
semester.GET("/list", h.Semester.SemesterList)
|
||||
semester.GET("/active", h.Semester.ActiveSemester)
|
||||
semester.POST("/create", h.Semester.SemesterCreate)
|
||||
semester.PUT("/activate/:semester_id", h.Semester.ActivateSemester)
|
||||
semester.PUT("/update/:semester_id", h.Semester.SemesterUpdate)
|
||||
semester.DELETE("/delete/:semester_id", h.Semester.SemesterDelete)
|
||||
semester.POST("/:semester_id/associate", h.Semester.AssociateRecords)
|
||||
semester.POST("/archive/:semester_id", h.Semester.ArchiveSemester)
|
||||
semester.GET("/archive/:semester_id/records", h.Semester.GetArchiveData)
|
||||
semester.POST("/period-reset", h.Semester.PeriodReset)
|
||||
semester.GET("/period-archives", h.Semester.GetPeriodArchives)
|
||||
}
|
||||
|
||||
// 班级管理
|
||||
classGroup := authRequired.Group("/class")
|
||||
classGroup.Use(middleware.RequireRole("admin", "super_admin"))
|
||||
{
|
||||
classGroup.GET("/list", h.Class.ClassList)
|
||||
classGroup.GET("/:class_id", h.Class.ClassDetail)
|
||||
classGroup.POST("/create", h.Class.ClassCreate)
|
||||
classGroup.PUT("/update/:class_id", h.Class.ClassUpdate)
|
||||
classGroup.DELETE("/delete/:class_id", h.Class.ClassDelete)
|
||||
classGroup.POST("/switch", h.Class.SwitchClass)
|
||||
classGroup.POST("/settings", h.Class.SaveSetting)
|
||||
classGroup.GET("/settings", h.Class.GetSettings)
|
||||
classGroup.GET("/point-limits", h.Class.GetPointLimits)
|
||||
classGroup.POST("/point-limits", h.Class.SavePointLimits)
|
||||
classGroup.GET("/features", h.Class.GetFeatures)
|
||||
classGroup.POST("/features", h.Class.SaveFeature)
|
||||
}
|
||||
|
||||
// 课代表路由(新增)
|
||||
cadre := authRequired.Group("/cadre")
|
||||
cadre.Use(middleware.RequireRole("课代表"))
|
||||
{
|
||||
cadre.GET("/homework", h.Cadre.HomeworkList)
|
||||
cadre.POST("/homework", h.Cadre.HomeworkSubmit)
|
||||
cadre.POST("/conduct/add", h.Cadre.AddConductPoints)
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 系统路由 ==========
|
||||
r.GET("/", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{
|
||||
"app": cfg.AppName,
|
||||
"version": "2.0",
|
||||
"status": "running",
|
||||
}, "服务运行中")
|
||||
})
|
||||
|
||||
r.GET("/health", func(c *gin.Context) {
|
||||
response.Success(c, gin.H{"status": "ok"}, "健康检查通过")
|
||||
})
|
||||
|
||||
return r
|
||||
}
|
||||
33
backend-go/internal/schema/admin.go
Normal file
33
backend-go/internal/schema/admin.go
Normal file
@@ -0,0 +1,33 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// AdminCreateRequest 添加管理员请求
|
||||
type AdminCreateRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
RealName string `json:"real_name" binding:"required"`
|
||||
Password string `json:"password"`
|
||||
RoleType string `json:"role_type" binding:"required"`
|
||||
SubjectID *int `json:"subject_id"`
|
||||
}
|
||||
|
||||
// AdminUpdateRequest 更新管理员请求
|
||||
type AdminUpdateRequest struct {
|
||||
RealName string `json:"real_name" binding:"required"`
|
||||
RoleType string `json:"role_type" binding:"required"`
|
||||
SubjectID *int `json:"subject_id"`
|
||||
}
|
||||
|
||||
// UnlockUserRequest 解锁用户请求
|
||||
type UnlockUserRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
}
|
||||
30
backend-go/internal/schema/attendance.go
Normal file
30
backend-go/internal/schema/attendance.go
Normal file
@@ -0,0 +1,30 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// AttendanceCreateRequest 创建考勤记录请求
|
||||
type AttendanceCreateRequest struct {
|
||||
StudentID int `json:"student_id" binding:"required"`
|
||||
Date string `json:"date" binding:"required"`
|
||||
Slot string `json:"slot" binding:"required,oneof=morning afternoon evening"`
|
||||
Status string `json:"status" binding:"required,oneof=present absent late leave"`
|
||||
Reason string `json:"reason"`
|
||||
ApplyDeduction bool `json:"apply_deduction"`
|
||||
CustomDeduction *int `json:"custom_deduction"`
|
||||
}
|
||||
|
||||
// AttendanceQuery 考勤查询参数
|
||||
type AttendanceQuery struct {
|
||||
Date string `form:"date"`
|
||||
StudentID *int `form:"student_id"`
|
||||
Slot string `form:"slot"`
|
||||
}
|
||||
26
backend-go/internal/schema/auth.go
Normal file
26
backend-go/internal/schema/auth.go
Normal file
@@ -0,0 +1,26 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// LoginRequest 登录请求
|
||||
type LoginRequest struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Password string `json:"password" binding:"required"`
|
||||
}
|
||||
|
||||
// ChangePasswordRequest 修改密码请求
|
||||
type ChangePasswordRequest struct {
|
||||
OldPassword string `json:"old_password" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
Force bool `json:"force"`
|
||||
}
|
||||
|
||||
44
backend-go/internal/schema/class.go
Normal file
44
backend-go/internal/schema/class.go
Normal file
@@ -0,0 +1,44 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// ClassCreateRequest 创建班级请求
|
||||
type ClassCreateRequest struct {
|
||||
ClassName string `json:"class_name" binding:"required"`
|
||||
Grade *string `json:"grade"`
|
||||
Description *string `json:"description"`
|
||||
}
|
||||
|
||||
// ClassUpdateRequest 更新班级请求
|
||||
type ClassUpdateRequest struct {
|
||||
ClassName *string `json:"class_name"`
|
||||
Grade *string `json:"grade"`
|
||||
Description *string `json:"description"`
|
||||
Status *int8 `json:"status"`
|
||||
}
|
||||
|
||||
// SwitchClassRequest 切换班级上下文请求
|
||||
type SwitchClassRequest struct {
|
||||
ClassID int `json:"class_id" binding:"required"`
|
||||
}
|
||||
|
||||
// SettingRequest 保存班级设置请求
|
||||
type SettingRequest struct {
|
||||
SettingKey string `json:"setting_key" binding:"required"`
|
||||
SettingValue string `json:"setting_value" binding:"required"`
|
||||
}
|
||||
|
||||
// FeatureToggleRequest 功能开关请求
|
||||
type FeatureToggleRequest struct {
|
||||
FeatureKey string `json:"feature_key" binding:"required"`
|
||||
Enabled int8 `json:"enabled" binding:"oneof=0 1"`
|
||||
}
|
||||
43
backend-go/internal/schema/conduct.go
Normal file
43
backend-go/internal/schema/conduct.go
Normal file
@@ -0,0 +1,43 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// ConductAddRequest 批量加减分请求
|
||||
type ConductAddRequest struct {
|
||||
StudentIDs []int `json:"student_ids" binding:"required,min=1"`
|
||||
PointsChange int `json:"points_change" binding:"required,ne=0"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
RelatedType string `json:"related_type"`
|
||||
}
|
||||
|
||||
// RevokeRequest 撤销/反撤销请求
|
||||
type RevokeRequest struct {
|
||||
RecordID int64 `json:"record_id" binding:"required"`
|
||||
}
|
||||
|
||||
// BatchRevokeRequest 批量撤销/反撤销请求
|
||||
type BatchRevokeRequest struct {
|
||||
RecordIDs []int64 `json:"record_ids" binding:"required,min=1,max=100"`
|
||||
}
|
||||
|
||||
// ConductHistoryQuery 操行分历史查询参数
|
||||
type ConductHistoryQuery struct {
|
||||
StudentID *int `form:"student_id"`
|
||||
Page int `form:"page,default=1" binding:"min=1"`
|
||||
PageSize int `form:"page_size,default=20" binding:"min=1,max=1000"`
|
||||
StartDate string `form:"start_date"`
|
||||
EndDate string `form:"end_date"`
|
||||
RelatedType string `form:"related_type"`
|
||||
ReasonPrefix string `form:"reason_prefix"`
|
||||
IsRevoked *int `form:"is_revoked"`
|
||||
ReasonSearch string `form:"reason_search"`
|
||||
}
|
||||
50
backend-go/internal/schema/ranking.go
Normal file
50
backend-go/internal/schema/ranking.go
Normal file
@@ -0,0 +1,50 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// RankingQuery 排行榜查询参数
|
||||
type RankingQuery struct {
|
||||
Type string `form:"type" binding:"omitempty,oneof=attendance homework conduct all"`
|
||||
Limit int `form:"limit,default=50" binding:"min=1,max=1000"`
|
||||
}
|
||||
|
||||
// ParentHistoryQuery 家长历史记录查询参数
|
||||
type ParentHistoryQuery struct {
|
||||
Page int `form:"page,default=1" binding:"min=1"`
|
||||
PageSize int `form:"page_size,default=20" binding:"min=1,max=100"`
|
||||
}
|
||||
|
||||
// StudentConductQuery 学生操行分查询参数
|
||||
type StudentConductQuery struct {
|
||||
Limit int `form:"limit,default=50" binding:"min=1"`
|
||||
Offset int `form:"offset,default=0" binding:"min=0"`
|
||||
}
|
||||
|
||||
// StudentAttendanceQuery 学生考勤查询参数
|
||||
type StudentAttendanceQuery struct {
|
||||
Month string `form:"month"`
|
||||
}
|
||||
|
||||
// CadreHomeworkQuery 课代表作业查询参数
|
||||
type CadreHomeworkQuery struct {
|
||||
SubjectID *int `form:"subject_id"`
|
||||
Page int `form:"page,default=1" binding:"min=1"`
|
||||
PageSize int `form:"page_size,default=20" binding:"min=1,max=100"`
|
||||
}
|
||||
|
||||
// CadreHomeworkSubmitRequest 课代表发布作业请求
|
||||
// SubjectID 由后端从管理员角色中自动获取,无需前端传递
|
||||
type CadreHomeworkSubmitRequest struct {
|
||||
Title string `json:"title" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Deadline string `json:"deadline" binding:"required"`
|
||||
}
|
||||
38
backend-go/internal/schema/semester.go
Normal file
38
backend-go/internal/schema/semester.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// SemesterCreateRequest 创建学期请求
|
||||
type SemesterCreateRequest struct {
|
||||
SemesterName string `json:"semester_name" binding:"required"`
|
||||
StartDate *string `json:"start_date"`
|
||||
EndDate *string `json:"end_date"`
|
||||
}
|
||||
|
||||
// SemesterUpdateRequest 更新学期请求
|
||||
type SemesterUpdateRequest struct {
|
||||
SemesterName *string `json:"semester_name"`
|
||||
StartDate *string `json:"start_date"`
|
||||
EndDate *string `json:"end_date"`
|
||||
}
|
||||
|
||||
// PeriodResetRequest 周期重置请求
|
||||
type PeriodResetRequest struct {
|
||||
Period string `json:"period" binding:"required,oneof=weekly monthly"`
|
||||
}
|
||||
|
||||
// PeriodArchiveQuery 周期归档查询参数
|
||||
type PeriodArchiveQuery struct {
|
||||
Period string `form:"period" binding:"required,oneof=weekly monthly"`
|
||||
Page int `form:"page,default=1"`
|
||||
PageSize int `form:"page_size,default=20"`
|
||||
}
|
||||
54
backend-go/internal/schema/student.go
Normal file
54
backend-go/internal/schema/student.go
Normal file
@@ -0,0 +1,54 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// StudentCreateRequest 新增学生请求
|
||||
type StudentCreateRequest struct {
|
||||
StudentNo string `json:"student_no" binding:"required"`
|
||||
Name string `json:"name" binding:"required"`
|
||||
ParentAccount *string `json:"parent_account"`
|
||||
DormitoryNumber *string `json:"dormitory_number"`
|
||||
}
|
||||
|
||||
// StudentImportSingle 导入的单个学生数据
|
||||
type StudentImportSingle struct {
|
||||
StudentNo string `json:"student_no"`
|
||||
Name string `json:"name"`
|
||||
ParentAccount string `json:"parent_account"`
|
||||
DormitoryNumber string `json:"dormitory_number"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// StudentImportRequest 批量导入学生请求
|
||||
type StudentImportRequest struct {
|
||||
Students []StudentImportSingle `json:"students" binding:"required"`
|
||||
}
|
||||
|
||||
// StudentUpdateRequest 编辑学生请求
|
||||
type StudentUpdateRequest struct {
|
||||
Name *string `json:"name"`
|
||||
ParentAccount *string `json:"parent_account"`
|
||||
DormitoryNumber *string `json:"dormitory_number"`
|
||||
}
|
||||
|
||||
// StudentListQuery 学生列表查询参数
|
||||
type StudentListQuery struct {
|
||||
Page int `form:"page,default=1" binding:"min=1"`
|
||||
PageSize int `form:"page_size,default=20" binding:"min=1,max=1000"`
|
||||
Search string `form:"search"`
|
||||
DormitoryNumber string `form:"dormitory_number"`
|
||||
}
|
||||
|
||||
// ResetPasswordRequest 重置密码请求
|
||||
type ResetPasswordRequest struct {
|
||||
NewPassword string `json:"new_password" binding:"required"`
|
||||
}
|
||||
27
backend-go/internal/schema/subject.go
Normal file
27
backend-go/internal/schema/subject.go
Normal file
@@ -0,0 +1,27 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package schema
|
||||
|
||||
// SubjectCreateRequest 创建科目请求
|
||||
type SubjectCreateRequest struct {
|
||||
SubjectName string `json:"subject_name" binding:"required"`
|
||||
SubjectCode *string `json:"subject_code"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// SubjectUpdateRequest 更新科目请求
|
||||
type SubjectUpdateRequest struct {
|
||||
SubjectName *string `json:"subject_name"`
|
||||
SubjectCode *string `json:"subject_code"`
|
||||
IsActive *int8 `json:"is_active"`
|
||||
SortOrder *int `json:"sort_order"`
|
||||
}
|
||||
452
backend-go/internal/service/admin_service.go
Normal file
452
backend-go/internal/service/admin_service.go
Normal file
@@ -0,0 +1,452 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"regexp"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// AdminService 管理员服务
|
||||
type AdminService struct {
|
||||
userRepo *repository.UserRepo
|
||||
studentRepo *repository.StudentRepo
|
||||
adminRoleRepo *repository.AdminRoleRepo
|
||||
classRepo *repository.ClassRepo
|
||||
}
|
||||
|
||||
// NewAdminService 创建管理员服务
|
||||
func NewAdminService(
|
||||
userRepo *repository.UserRepo,
|
||||
studentRepo *repository.StudentRepo,
|
||||
adminRoleRepo *repository.AdminRoleRepo,
|
||||
classRepo *repository.ClassRepo,
|
||||
) *AdminService {
|
||||
return &AdminService{
|
||||
userRepo: userRepo,
|
||||
studentRepo: studentRepo,
|
||||
adminRoleRepo: adminRoleRepo,
|
||||
classRepo: classRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// dormitoryRegex 宿舍号格式校验:东南北西 + 1-2位楼号 - 3位房号
|
||||
var dormitoryRegex = regexp.MustCompile(`^[东南北西]\d{1,2}-\d{3}$`)
|
||||
|
||||
// validateDormitoryNumber 校验宿舍号格式(允许空值或合法格式)
|
||||
func validateDormitoryNumber(dn *string) bool {
|
||||
if dn == nil || *dn == "" {
|
||||
return true
|
||||
}
|
||||
return dormitoryRegex.MatchString(*dn)
|
||||
}
|
||||
|
||||
// GetStudents 获取指定班级的学生列表
|
||||
func (s *AdminService) GetStudents(classID int, page, pageSize int, search, dormitoryNumber string) (map[string]interface{}, error) {
|
||||
students, total, err := s.studentRepo.ListByClass(classID, page, pageSize, search, dormitoryNumber)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"students": students,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetDormitories 获取宿舍号列表
|
||||
func (s *AdminService) GetDormitories(classID int) ([]string, error) {
|
||||
return s.studentRepo.GetDormitoryList(classID)
|
||||
}
|
||||
|
||||
// getInitialPassword 从 class_settings 读取初始密码,若无则使用随机密码
|
||||
func (s *AdminService) getInitialPassword(classID int) (string, error) {
|
||||
if s.classRepo != nil {
|
||||
setting, err := s.classRepo.GetSetting(classID, "initial_password")
|
||||
if err == nil && setting != nil && setting.SettingValue != "" {
|
||||
return setting.SettingValue, nil
|
||||
}
|
||||
}
|
||||
pwd, err := crypto.GenerateRandomPassword(8)
|
||||
if err != nil {
|
||||
logger.Sugared.Errorf("生成随机密码失败: %v", err)
|
||||
return "", fmt.Errorf("生成随机密码失败: %w", err)
|
||||
}
|
||||
return pwd, nil
|
||||
}
|
||||
|
||||
// AddStudent 新增学生
|
||||
func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) {
|
||||
cfg := config.AppConfig
|
||||
|
||||
// 校验宿舍号格式
|
||||
if !validateDormitoryNumber(dormitoryNumber) {
|
||||
return map[string]interface{}{"success": false, "message": "宿舍号格式不正确,应为如 东1-101 的格式"}, nil
|
||||
}
|
||||
|
||||
// 检查学号是否已存在
|
||||
existing, err := s.studentRepo.GetByStudentNo(studentNo, classID)
|
||||
if err == nil && existing != nil {
|
||||
return map[string]interface{}{"success": false, "message": "该班级中已存在此学号"}, nil
|
||||
}
|
||||
|
||||
// 创建学生记录
|
||||
student := &model.Student{
|
||||
StudentNo: studentNo,
|
||||
ClassID: classID,
|
||||
Name: name,
|
||||
TotalPoints: 60,
|
||||
ParentAccount: parentAccount,
|
||||
DormitoryNumber: dormitoryNumber,
|
||||
Status: 1,
|
||||
}
|
||||
studentID, err := s.studentRepo.Create(student)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 创建学生登录账号(从 class_settings 读取初始密码或使用随机密码)
|
||||
defaultPassword, err := s.getInitialPassword(classID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passwordHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt)
|
||||
_, err = s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID)
|
||||
if err != nil {
|
||||
logger.Sugared.Errorf("创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err)
|
||||
// 回滚学生记录,避免存在无账号的孤儿学生
|
||||
_ = s.studentRepo.SoftDelete(studentID)
|
||||
return nil, fmt.Errorf("创建学生登录账号失败")
|
||||
}
|
||||
|
||||
// 创建家长账号(失败时不回滚学生记录,仅记录日志;管理员可手动补建家长账号)
|
||||
if parentAccount != nil && *parentAccount != "" {
|
||||
exists, _ := s.userRepo.CheckUsernameExists(*parentAccount)
|
||||
if !exists {
|
||||
parentHash := crypto.HashPassword(defaultPassword, cfg.PasswordSalt)
|
||||
parentRealName := fmt.Sprintf("%s家长", name)
|
||||
if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil {
|
||||
logger.Sugared.Warnf("创建家长账号失败(学生记录已保留): parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"student_id": studentID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ImportStudents 批量导入学生
|
||||
// 注意:当前实现为逐条创建,单条失败时回滚该条记录(SoftDelete),不影响其他记录。
|
||||
// 这是设计上有意为之——允许部分成功,避免一条失败导致整个导入作废。
|
||||
// 未使用数据库事务的原因:Repository 层未暴露事务接口,全量事务包裹需要较大重构;
|
||||
// 且批量导入场景下允许部分成功是合理的业务权衡(用户可修正失败记录后重新导入)。
|
||||
func (s *AdminService) ImportStudents(students []map[string]interface{}, classID int) (map[string]interface{}, error) {
|
||||
cfg := config.AppConfig
|
||||
successCount := 0
|
||||
failedCount := 0
|
||||
var details []map[string]interface{}
|
||||
|
||||
// 预查重
|
||||
existingNos, _ := s.studentRepo.GetStudentNosByClass(classID)
|
||||
existingSet := make(map[string]bool, len(existingNos))
|
||||
for _, no := range existingNos {
|
||||
existingSet[no] = true
|
||||
}
|
||||
|
||||
existingUsernames, _ := s.userRepo.GetActiveUsernames()
|
||||
usernameSet := make(map[string]bool, len(existingUsernames))
|
||||
for _, u := range existingUsernames {
|
||||
usernameSet[u] = true
|
||||
}
|
||||
|
||||
for _, stu := range students {
|
||||
studentNo, _ := stu["student_no"].(string)
|
||||
name, _ := stu["name"].(string)
|
||||
|
||||
if studentNo == "" || name == "" {
|
||||
failedCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": false, "error": "学号或姓名不能为空",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
if existingSet[studentNo] {
|
||||
failedCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": false, "error": "学号已存在",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
var parentAccount *string
|
||||
if pa, ok := stu["parent_account"].(string); ok && pa != "" {
|
||||
parentAccount = &pa
|
||||
}
|
||||
var dormitoryNumber *string
|
||||
if dn, ok := stu["dormitory_number"].(string); ok && dn != "" {
|
||||
dormitoryNumber = &dn
|
||||
}
|
||||
// 校验宿舍号格式
|
||||
if !validateDormitoryNumber(dormitoryNumber) {
|
||||
failedCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": false, "error": "宿舍号格式不正确,应为如 东1-101 的格式",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
password, pwdErr := s.getInitialPassword(classID)
|
||||
if pwdErr != nil {
|
||||
failedCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": false, "error": "生成初始密码失败",
|
||||
})
|
||||
continue
|
||||
}
|
||||
if pw, ok := stu["password"].(string); ok && pw != "" {
|
||||
if valid, msg := crypto.ValidatePasswordStrength(pw); !valid {
|
||||
failedCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": false, "error": msg,
|
||||
})
|
||||
continue
|
||||
}
|
||||
password = pw
|
||||
}
|
||||
|
||||
// 创建学生记录
|
||||
student := &model.Student{
|
||||
StudentNo: studentNo,
|
||||
ClassID: classID,
|
||||
Name: name,
|
||||
TotalPoints: 60,
|
||||
ParentAccount: parentAccount,
|
||||
DormitoryNumber: dormitoryNumber,
|
||||
Status: 1,
|
||||
}
|
||||
studentID, err := s.studentRepo.Create(student)
|
||||
if err != nil {
|
||||
failedCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": false, "error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
existingSet[studentNo] = true
|
||||
|
||||
// 创建学生登录账号
|
||||
passwordHash := crypto.HashPassword(password, cfg.PasswordSalt)
|
||||
if _, err := s.userRepo.CreateStudent(studentNo, passwordHash, name, studentID); err != nil {
|
||||
logger.Sugared.Errorf("批量导入-创建学生登录账号失败: student_no=%s, student_id=%d, err=%v", studentNo, studentID, err)
|
||||
// 回滚学生记录
|
||||
_ = s.studentRepo.SoftDelete(studentID)
|
||||
failedCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": false, "error": "创建登录账号失败",
|
||||
})
|
||||
continue
|
||||
}
|
||||
usernameSet[studentNo] = true
|
||||
|
||||
// 创建家长账号
|
||||
if parentAccount != nil && *parentAccount != "" && !usernameSet[*parentAccount] {
|
||||
parentHash := crypto.HashPassword(password, cfg.PasswordSalt)
|
||||
parentRealName := fmt.Sprintf("%s家长", name)
|
||||
if _, err := s.userRepo.CreateParent(*parentAccount, parentHash, parentRealName, studentID); err != nil {
|
||||
logger.Sugared.Errorf("批量导入-创建家长账号失败: parent_account=%s, student_id=%d, err=%v", *parentAccount, studentID, err)
|
||||
}
|
||||
usernameSet[*parentAccount] = true
|
||||
}
|
||||
|
||||
successCount++
|
||||
details = append(details, map[string]interface{}{
|
||||
"student_no": studentNo, "success": true, "student_id": studentID,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"total": len(students),
|
||||
"success_count": successCount,
|
||||
"failed_count": failedCount,
|
||||
"details": details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateStudent 编辑学生信息
|
||||
func (s *AdminService) UpdateStudent(studentID int, name, parentAccount, dormitoryNumber *string, classID int) error {
|
||||
// 校验学生是否属于当前班级
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil || student == nil {
|
||||
return fmt.Errorf("学生不存在")
|
||||
}
|
||||
if student.ClassID != classID {
|
||||
return fmt.Errorf("无权操作该学生")
|
||||
}
|
||||
// 校验宿舍号格式
|
||||
if !validateDormitoryNumber(dormitoryNumber) {
|
||||
return fmt.Errorf("宿舍号格式不正确,应为如 东1-101 的格式")
|
||||
}
|
||||
updates := make(map[string]interface{})
|
||||
if name != nil {
|
||||
updates["name"] = *name
|
||||
}
|
||||
if parentAccount != nil {
|
||||
updates["parent_account"] = *parentAccount
|
||||
}
|
||||
if dormitoryNumber != nil {
|
||||
updates["dormitory_number"] = *dormitoryNumber
|
||||
}
|
||||
return s.studentRepo.Update(studentID, updates)
|
||||
}
|
||||
|
||||
// DeleteStudent 删除学生
|
||||
func (s *AdminService) DeleteStudent(studentID int, classID int) error {
|
||||
// 校验学生是否属于当前班级
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil || student == nil {
|
||||
return fmt.Errorf("学生不存在")
|
||||
}
|
||||
if student.ClassID != classID {
|
||||
return fmt.Errorf("无权操作该学生")
|
||||
}
|
||||
return s.studentRepo.SoftDelete(studentID)
|
||||
}
|
||||
|
||||
// ResetStudentPassword 重置学生密码
|
||||
func (s *AdminService) ResetStudentPassword(studentID int, newPassword string) error {
|
||||
// 验证新密码强度(#11)
|
||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
cfg := config.AppConfig
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("学生不存在")
|
||||
}
|
||||
// 通过学号查找关联的用户账号
|
||||
user, err := s.userRepo.GetByUsername(student.StudentNo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("学生登录账号不存在")
|
||||
}
|
||||
passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt)
|
||||
return s.userRepo.UpdatePassword(user.UserID, passwordHash)
|
||||
}
|
||||
|
||||
// AddAdmin 添加管理员
|
||||
func (s *AdminService) AddAdmin(username, realName, password, roleType string, classID int, subjectID *int) (map[string]interface{}, error) {
|
||||
cfg := config.AppConfig
|
||||
|
||||
exists, _ := s.userRepo.CheckUsernameExists(username)
|
||||
if exists {
|
||||
return map[string]interface{}{"success": false, "message": "用户名已存在"}, nil
|
||||
}
|
||||
|
||||
if password == "" {
|
||||
pwd, err := crypto.GenerateRandomPassword(8)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成随机密码失败: %w", err)
|
||||
}
|
||||
password = pwd
|
||||
}
|
||||
|
||||
passwordHash := crypto.HashPassword(password, cfg.PasswordSalt)
|
||||
userID, err := s.userRepo.CreateAdmin(username, passwordHash, realName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
role := &model.AdminRole{
|
||||
UserID: userID,
|
||||
ClassID: classID,
|
||||
RoleType: roleType,
|
||||
SubjectID: subjectID,
|
||||
}
|
||||
_, err = s.adminRoleRepo.Create(role)
|
||||
if err != nil {
|
||||
// 角色创建失败,回滚用户记录,避免孤儿数据
|
||||
_ = s.userRepo.DeleteUser(userID)
|
||||
return nil, fmt.Errorf("创建管理员角色失败,已回滚用户记录: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"user_id": userID,
|
||||
"username": username,
|
||||
"role_type": roleType,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAdmins 获取管理员列表
|
||||
func (s *AdminService) GetAdmins(classID int) (map[string]interface{}, error) {
|
||||
admins, err := s.adminRoleRepo.GetAllByClass(classID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"admins": admins}, nil
|
||||
}
|
||||
|
||||
// UpdateAdmin 更新管理员
|
||||
func (s *AdminService) UpdateAdmin(userID int, realName, roleType string, classID int, subjectID *int) error {
|
||||
if err := s.userRepo.UpdateRealName(userID, realName); err != nil {
|
||||
return err
|
||||
}
|
||||
return s.adminRoleRepo.UpdateRole(userID, roleType, classID, subjectID)
|
||||
}
|
||||
|
||||
// DeleteAdmin 硬删除管理员(同时删除 users 和 admin_roles 记录)
|
||||
func (s *AdminService) DeleteAdmin(userID int, classID int) error {
|
||||
// 先删除关联的 admin_roles 记录
|
||||
if err := s.adminRoleRepo.Delete(userID, classID); err != nil {
|
||||
return err
|
||||
}
|
||||
// 硬删除 users 表记录
|
||||
return s.userRepo.DeleteUser(userID)
|
||||
}
|
||||
|
||||
// ResetAdminPassword 重置管理员密码
|
||||
func (s *AdminService) ResetAdminPassword(userID int, newPassword string) error {
|
||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
cfg := config.AppConfig
|
||||
passwordHash := crypto.HashPassword(newPassword, cfg.PasswordSalt)
|
||||
return s.userRepo.UpdatePassword(userID, passwordHash)
|
||||
}
|
||||
|
||||
// UnlockAccount 解锁账号(清除用户名级 + IP 级登录失败计数)
|
||||
func (s *AdminService) UnlockAccount(username, ip string) error {
|
||||
ctx := context.Background()
|
||||
keys := []string{fmt.Sprintf("login_attempts:%s", username)}
|
||||
if ip != "" {
|
||||
keys = append(keys, fmt.Sprintf("login_attempts:ip:%s", ip))
|
||||
}
|
||||
return database.RDB.Del(ctx, keys...).Err()
|
||||
}
|
||||
226
backend-go/internal/service/attendance_service.go
Normal file
226
backend-go/internal/service/attendance_service.go
Normal file
@@ -0,0 +1,226 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// AttendanceService 考勤服务
|
||||
type AttendanceService struct {
|
||||
attendanceRepo *repository.AttendanceRepo
|
||||
studentRepo *repository.StudentRepo
|
||||
userRepo *repository.UserRepo
|
||||
conductRepo *repository.ConductRepo
|
||||
semesterRepo *repository.SemesterRepo
|
||||
settingRepo *repository.SystemSettingRepo
|
||||
classRepo *repository.ClassRepo
|
||||
}
|
||||
|
||||
// NewAttendanceService 创建考勤服务
|
||||
func NewAttendanceService(
|
||||
attendanceRepo *repository.AttendanceRepo,
|
||||
studentRepo *repository.StudentRepo,
|
||||
userRepo *repository.UserRepo,
|
||||
conductRepo *repository.ConductRepo,
|
||||
semesterRepo *repository.SemesterRepo,
|
||||
settingRepo *repository.SystemSettingRepo,
|
||||
classRepo *repository.ClassRepo,
|
||||
) *AttendanceService {
|
||||
return &AttendanceService{
|
||||
attendanceRepo: attendanceRepo,
|
||||
studentRepo: studentRepo,
|
||||
userRepo: userRepo,
|
||||
conductRepo: conductRepo,
|
||||
semesterRepo: semesterRepo,
|
||||
settingRepo: settingRepo,
|
||||
classRepo: classRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// CreateRecord 创建考勤记录
|
||||
func (s *AttendanceService) CreateRecord(studentID int, dateStr, slot, status string, reason *string,
|
||||
applyDeduction bool, customDeduction *int, recorderID int, classID int) (map[string]interface{}, error) {
|
||||
|
||||
// 校验学生是否属于当前班级(#7)
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil || student == nil || student.ClassID != classID {
|
||||
return map[string]interface{}{"success": false, "message": "学生不属于当前班级"}, nil
|
||||
}
|
||||
|
||||
// 解析日期
|
||||
parsedDate, err := time.Parse("2006-01-02", dateStr)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": "日期格式错误"}, nil
|
||||
}
|
||||
|
||||
// 获取活跃学期
|
||||
var semesterID *int
|
||||
activeSemester, _ := s.semesterRepo.GetActive()
|
||||
if activeSemester != nil {
|
||||
semesterID = &activeSemester.SemesterID
|
||||
}
|
||||
|
||||
record := &model.AttendanceRecord{
|
||||
StudentID: studentID,
|
||||
Date: parsedDate,
|
||||
Slot: slot,
|
||||
Status: status,
|
||||
Reason: reason,
|
||||
RecorderID: recorderID,
|
||||
SemesterID: semesterID,
|
||||
}
|
||||
|
||||
createResult, err := s.attendanceRepo.CreateRecord(record)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": "添加考勤记录失败"}, nil
|
||||
}
|
||||
attendanceID := createResult.AttendanceID
|
||||
|
||||
// 更新已有记录时,先撤销旧扣分再应用新扣分
|
||||
if createResult.IsUpdate && createResult.OldDeductionApplied == 1 && createResult.OldDeductionRecordID != nil {
|
||||
if err := s.conductRepo.RevokeRecord(*createResult.OldDeductionRecordID, recorderID); err != nil {
|
||||
logger.Sugared.Errorf("撤销旧考勤扣分失败: attendance_id=%d, old_record_id=%d, err=%v",
|
||||
attendanceID, *createResult.OldDeductionRecordID, err)
|
||||
return nil, fmt.Errorf("撤销旧扣分失败,操作已中止以避免双重扣分: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 应用扣分(事务保护,避免数据不一致)
|
||||
if applyDeduction && (status == "absent" || status == "late" || status == "leave") {
|
||||
// 校验自定义扣分值必须为非负数
|
||||
if customDeduction != nil && *customDeduction < 0 {
|
||||
return map[string]interface{}{"success": false, "message": "自定义扣分值不能为负数"}, nil
|
||||
}
|
||||
|
||||
var pointsChange int
|
||||
if customDeduction != nil {
|
||||
pointsChange = -*customDeduction
|
||||
} else {
|
||||
pointsChange = s.getDeductionPoints(classID, status)
|
||||
}
|
||||
|
||||
if pointsChange == 0 {
|
||||
return map[string]interface{}{"success": true, "message": "考勤记录添加成功(不扣分)"}, nil
|
||||
}
|
||||
|
||||
// 获取操作人姓名
|
||||
recorderName := "班主任"
|
||||
user, err := s.userRepo.GetByUserID(recorderID)
|
||||
if err == nil && user != nil {
|
||||
recorderName = user.RealName
|
||||
}
|
||||
|
||||
statusText := map[string]string{
|
||||
"absent": "缺勤", "late": "迟到", "leave": "请假",
|
||||
}[status]
|
||||
|
||||
// 使用事务确保操行分记录创建、总分更新、考勤标记的原子性
|
||||
db := s.semesterRepo.GetDB()
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
conductRecord := &model.ConductRecord{
|
||||
StudentID: studentID,
|
||||
PointsChange: pointsChange,
|
||||
Reason: fmt.Sprintf("考勤:%s", statusText),
|
||||
RecorderID: recorderID,
|
||||
RecorderName: &recorderName,
|
||||
RelatedType: "attendance",
|
||||
RelatedID: &attendanceID,
|
||||
SemesterID: semesterID,
|
||||
}
|
||||
if err := tx.Create(conductRecord).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.Student{}).
|
||||
Where("student_id = ?", studentID).
|
||||
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Model(&model.AttendanceRecord{}).
|
||||
Where("attendance_id = ?", attendanceID).
|
||||
Updates(map[string]interface{}{
|
||||
"deduction_applied": 1,
|
||||
"deduction_record_id": conductRecord.RecordID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if txErr != nil {
|
||||
logger.Sugared.Errorf("考勤扣分事务失败: attendance_id=%d, student_id=%d, err=%v", attendanceID, studentID, txErr)
|
||||
return map[string]interface{}{
|
||||
"success": false,
|
||||
"message": "考勤记录添加成功,但扣分失败,请手动处理",
|
||||
"attendance_id": attendanceID,
|
||||
"deduction_failed": true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
logger.Sugared.Infof("用户[%d] 添加考勤记录[%d] -> %s (扣%d分)", recorderID, attendanceID, status, -pointsChange)
|
||||
}
|
||||
|
||||
return map[string]interface{}{"success": true, "message": "考勤记录添加成功"}, nil
|
||||
}
|
||||
|
||||
// GetRecords 获取考勤记录
|
||||
func (s *AttendanceService) GetRecords(classID int, date string, studentID *int, slot string) (map[string]interface{}, error) {
|
||||
records, err := s.attendanceRepo.GetClassRecords(classID, date, derefInt(studentID), slot)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"records": records}, nil
|
||||
}
|
||||
|
||||
// getClassSettingValue 从 class_settings 读取设置值,若无则返回默认值
|
||||
func (s *AttendanceService) getClassSettingValue(classID int, key, defaultVal string) string {
|
||||
if classID > 0 && s.classRepo != nil {
|
||||
setting, err := s.classRepo.GetSetting(classID, key)
|
||||
if err == nil && setting != nil && setting.SettingValue != "" {
|
||||
return setting.SettingValue
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// getDeductionPoints 获取考勤扣分数值(优先从 class_settings 读取班级级配置)
|
||||
func (s *AttendanceService) getDeductionPoints(classID int, status string) int {
|
||||
switch status {
|
||||
case "absent":
|
||||
val := s.getClassSettingValue(classID, "deduction_attendance_absent", "3")
|
||||
if v, err := strconv.Atoi(val); err == nil {
|
||||
return -v
|
||||
}
|
||||
return -3
|
||||
case "late":
|
||||
val := s.getClassSettingValue(classID, "deduction_attendance_late", "1")
|
||||
if v, err := strconv.Atoi(val); err == nil {
|
||||
return -v
|
||||
}
|
||||
return -1
|
||||
case "leave":
|
||||
val := s.getClassSettingValue(classID, "deduction_attendance_leave", "0")
|
||||
if v, err := strconv.Atoi(val); err == nil {
|
||||
return -v
|
||||
}
|
||||
return 0
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
461
backend-go/internal/service/auth_service.go
Normal file
461
backend-go/internal/service/auth_service.go
Normal file
@@ -0,0 +1,461 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||||
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// AuthService 认证服务
|
||||
type AuthService struct {
|
||||
userRepo *repository.UserRepo
|
||||
studentRepo *repository.StudentRepo
|
||||
adminRoleRepo *repository.AdminRoleRepo
|
||||
classRepo *repository.ClassRepo
|
||||
logService *LogService
|
||||
}
|
||||
|
||||
// NewAuthService 创建认证服务
|
||||
func NewAuthService(
|
||||
userRepo *repository.UserRepo,
|
||||
studentRepo *repository.StudentRepo,
|
||||
adminRoleRepo *repository.AdminRoleRepo,
|
||||
classRepo *repository.ClassRepo,
|
||||
logService *LogService,
|
||||
) *AuthService {
|
||||
return &AuthService{
|
||||
userRepo: userRepo,
|
||||
studentRepo: studentRepo,
|
||||
adminRoleRepo: adminRoleRepo,
|
||||
classRepo: classRepo,
|
||||
logService: logService,
|
||||
}
|
||||
}
|
||||
|
||||
// LoginResult 登录结果
|
||||
type LoginResult struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Token string `json:"token,omitempty"`
|
||||
UserID int `json:"user_id,omitempty"`
|
||||
Username string `json:"username,omitempty"`
|
||||
RealName string `json:"real_name,omitempty"`
|
||||
UserType string `json:"user_type,omitempty"`
|
||||
StudentID *int `json:"student_id,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
ClassID *int `json:"class_id,omitempty"`
|
||||
ClassName *string `json:"class_name,omitempty"`
|
||||
NeedChangePassword bool `json:"need_change_password,omitempty"`
|
||||
Redirect string `json:"redirect,omitempty"`
|
||||
}
|
||||
|
||||
// incrWithExpireAtomic 原子递增并在首次设置过期时间(Lua 脚本保证原子性)
|
||||
func incrWithExpireAtomic(ctx context.Context, key string, ttlSeconds int) (int64, error) {
|
||||
script := redis.NewScript(`
|
||||
local current = redis.call('INCR', KEYS[1])
|
||||
if current == 1 then
|
||||
redis.call('EXPIRE', KEYS[1], ARGV[1])
|
||||
end
|
||||
return current
|
||||
`)
|
||||
result, err := script.Run(ctx, database.RDB, []string{key}, ttlSeconds).Int64()
|
||||
return result, err
|
||||
}
|
||||
|
||||
// Login 用户登录
|
||||
func (s *AuthService) Login(username, password, ip, userAgent string) *LoginResult {
|
||||
ctx := context.Background()
|
||||
cfg := config.AppConfig
|
||||
|
||||
// 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态)
|
||||
attemptsKey := fmt.Sprintf("login_attempts:%s", username)
|
||||
ipAttemptsKey := fmt.Sprintf("login_attempts:ip:%s", ip)
|
||||
|
||||
// 用户名级限流:原子递增后检查
|
||||
userCount, err := incrWithExpireAtomic(ctx, attemptsKey, 300)
|
||||
if err != nil {
|
||||
logger.Sugared.Errorf("Redis 限流检查失败 (用户名级): %v", err)
|
||||
return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"}
|
||||
}
|
||||
if userCount > 5 {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "登录失败次数过多")
|
||||
return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"}
|
||||
}
|
||||
// IP 级限流:原子递增后检查
|
||||
ipCount, err := incrWithExpireAtomic(ctx, ipAttemptsKey, 300)
|
||||
if err != nil {
|
||||
logger.Sugared.Errorf("Redis 限流检查失败 (IP级): %v", err)
|
||||
return &LoginResult{Success: false, Message: "系统繁忙,请稍后重试"}
|
||||
}
|
||||
if ipCount > 20 {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "IP登录失败次数过多")
|
||||
return &LoginResult{Success: false, Message: "登录失败次数过多,请5分钟后重试"}
|
||||
}
|
||||
|
||||
// 获取用户
|
||||
user, err := s.userRepo.GetByUsername(username)
|
||||
if err != nil {
|
||||
// 尝试学生登录:username 匹配 student_no
|
||||
student, stuErr := s.studentRepo.GetByStudentNo(username, 0)
|
||||
if stuErr == nil && student != nil {
|
||||
return s.loginAsStudent(student, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey)
|
||||
}
|
||||
// 尝试家长登录:username 匹配 parent_account
|
||||
return s.tryParentLogin(username, password, ip, userAgent, cfg, attemptsKey, ipAttemptsKey)
|
||||
}
|
||||
|
||||
// 验证密码(使用全局 PASSWORD_SALT,与 Python 版兼容。
|
||||
// 已知设计局限:全局共享盐值,若泄露则所有普通用户密码面临风险。
|
||||
// 后续迁移计划:为每个用户生成独立盐值,存储在 users 表中。)
|
||||
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||
}
|
||||
|
||||
// 检查账号状态
|
||||
if user.Status != 1 {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "账号已被禁用")
|
||||
return &LoginResult{Success: false, Message: "账号已被禁用"}
|
||||
}
|
||||
|
||||
// 清除用户名级登录失败记录,IP 级计数由 TTL 自然过期(防止同 IP 其他用户限流被重置)
|
||||
database.RDB.Del(ctx, attemptsKey)
|
||||
|
||||
// 更新最后登录信息
|
||||
_ = s.userRepo.UpdateLastLogin(user.UserID, ip)
|
||||
|
||||
// 获取角色和班级信息
|
||||
var role *string
|
||||
var classID *int
|
||||
var className *string
|
||||
|
||||
if user.UserType == "admin" {
|
||||
adminRole, err := s.adminRoleRepo.GetByUserID(user.UserID)
|
||||
if err == nil && adminRole != nil {
|
||||
role = &adminRole.RoleType
|
||||
classID = &adminRole.ClassID
|
||||
}
|
||||
} else if user.UserType == "super_admin" {
|
||||
r := "系统管理员"
|
||||
role = &r
|
||||
} else if user.StudentID != nil {
|
||||
student, err := s.studentRepo.GetByID(*user.StudentID)
|
||||
if err == nil && student != nil {
|
||||
cid := student.ClassID
|
||||
classID = &cid
|
||||
}
|
||||
}
|
||||
|
||||
// 获取班级名称
|
||||
if classID != nil {
|
||||
cls, err := s.classRepo.GetByID(*classID)
|
||||
if err == nil && cls != nil {
|
||||
className = &cls.ClassName
|
||||
}
|
||||
}
|
||||
|
||||
// 生成 Token
|
||||
token, err := appJwt.CreateToken(
|
||||
user.UserID, user.Username, user.UserType,
|
||||
user.StudentID, derefStr(role), user.RealName, classID,
|
||||
user.NeedChangePassword == 1,
|
||||
)
|
||||
if err != nil {
|
||||
return &LoginResult{Success: false, Message: "生成令牌失败"}
|
||||
}
|
||||
|
||||
// 存储 Token 到 Redis(使用 IdleTimeout 与中间件空闲超时一致,避免 Token 在 Redis 中残留过久)
|
||||
_ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes)
|
||||
// 确定跳转路径
|
||||
redirect := getRedirectPath(user.UserType, role)
|
||||
|
||||
// 需要强制改密时,跳转到密码修改页面
|
||||
needChangePassword := user.NeedChangePassword == 1
|
||||
if needChangePassword {
|
||||
redirect = getPasswordChangePath(user.UserType)
|
||||
}
|
||||
|
||||
s.logService.WriteLoginLog(username, 1, ip, userAgent, "")
|
||||
|
||||
return &LoginResult{
|
||||
Success: true,
|
||||
Token: token,
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
RealName: user.RealName,
|
||||
UserType: user.UserType,
|
||||
StudentID: user.StudentID,
|
||||
Role: role,
|
||||
ClassID: classID,
|
||||
ClassName: className,
|
||||
NeedChangePassword: needChangePassword,
|
||||
Redirect: redirect,
|
||||
}
|
||||
}
|
||||
|
||||
// loginAsStudent 学生登录(通过学号)
|
||||
func (s *AuthService) loginAsStudent(student *model.Student, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult {
|
||||
ctx := context.Background()
|
||||
|
||||
user, err := s.userRepo.GetByUsername(student.StudentNo)
|
||||
if err != nil {
|
||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||
}
|
||||
|
||||
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
|
||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||
}
|
||||
|
||||
if user.Status != 1 {
|
||||
return &LoginResult{Success: false, Message: "账号已被禁用"}
|
||||
}
|
||||
|
||||
// 清除用户名级登录失败记录
|
||||
database.RDB.Del(ctx, attemptsKey)
|
||||
_ = s.userRepo.UpdateLastLogin(user.UserID, ip)
|
||||
|
||||
classID := student.ClassID
|
||||
var className *string
|
||||
cls, err := s.classRepo.GetByID(classID)
|
||||
if err == nil && cls != nil {
|
||||
className = &cls.ClassName
|
||||
}
|
||||
|
||||
token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1)
|
||||
if err != nil {
|
||||
return &LoginResult{Success: false, Message: "生成令牌失败"}
|
||||
}
|
||||
|
||||
_ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes)
|
||||
s.logService.WriteLoginLog(user.Username, 1, ip, userAgent, "")
|
||||
|
||||
needChangePassword := user.NeedChangePassword == 1
|
||||
redirect := "/student/dashboard.php"
|
||||
if needChangePassword {
|
||||
redirect = "/student/password.php"
|
||||
}
|
||||
|
||||
return &LoginResult{
|
||||
Success: true,
|
||||
Token: token,
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
RealName: user.RealName,
|
||||
UserType: user.UserType,
|
||||
StudentID: user.StudentID,
|
||||
ClassID: &classID,
|
||||
ClassName: className,
|
||||
NeedChangePassword: needChangePassword,
|
||||
Redirect: redirect,
|
||||
}
|
||||
}
|
||||
|
||||
// tryParentLogin 尝试家长登录(通过 parent_account 查找学生,再获取关联的家长用户)
|
||||
func (s *AuthService) tryParentLogin(username, password, ip, userAgent string, cfg *config.Config, attemptsKey, ipAttemptsKey string) *LoginResult {
|
||||
ctx := context.Background()
|
||||
|
||||
// 根据 parent_account 字段查找学生
|
||||
student, err := s.studentRepo.GetByParentAccount(username)
|
||||
if err != nil || student == nil {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||
}
|
||||
|
||||
// 根据学生ID获取关联的家长用户账号
|
||||
user, err := s.userRepo.GetByStudentID(student.StudentID)
|
||||
if err != nil || user == nil || user.UserType != "parent" {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||
}
|
||||
|
||||
if !crypto.VerifyPassword(password, user.PasswordHash, cfg.PasswordSalt) {
|
||||
return &LoginResult{Success: false, Message: "用户名或密码错误"}
|
||||
}
|
||||
|
||||
// 清除用户名级登录失败记录
|
||||
database.RDB.Del(ctx, attemptsKey)
|
||||
_ = s.userRepo.UpdateLastLogin(user.UserID, ip)
|
||||
|
||||
classID := student.ClassID
|
||||
var className *string
|
||||
cls, err := s.classRepo.GetByID(classID)
|
||||
if err == nil && cls != nil {
|
||||
className = &cls.ClassName
|
||||
}
|
||||
|
||||
token, err := appJwt.CreateToken(user.UserID, user.Username, user.UserType, user.StudentID, "", user.RealName, &classID, user.NeedChangePassword == 1)
|
||||
if err != nil {
|
||||
return &LoginResult{Success: false, Message: "生成令牌失败"}
|
||||
}
|
||||
|
||||
_ = database.SetUserToken(ctx, user.UserID, token, cfg.JWTIdleTimeoutMinutes)
|
||||
s.logService.WriteLoginLog(username, 1, ip, userAgent, "")
|
||||
|
||||
needChangePassword := user.NeedChangePassword == 1
|
||||
redirect := "/parent/dashboard.php"
|
||||
if needChangePassword {
|
||||
redirect = "/parent/password.php"
|
||||
}
|
||||
|
||||
return &LoginResult{
|
||||
Success: true,
|
||||
Token: token,
|
||||
UserID: user.UserID,
|
||||
Username: user.Username,
|
||||
RealName: user.RealName,
|
||||
UserType: user.UserType,
|
||||
StudentID: user.StudentID,
|
||||
ClassID: &classID,
|
||||
ClassName: className,
|
||||
NeedChangePassword: needChangePassword,
|
||||
Redirect: redirect,
|
||||
}
|
||||
}
|
||||
|
||||
// Logout 用户登出
|
||||
func (s *AuthService) Logout(userID int) error {
|
||||
ctx := context.Background()
|
||||
return database.DeleteUserToken(ctx, userID)
|
||||
}
|
||||
|
||||
// ChangePassword 修改密码
|
||||
func (s *AuthService) ChangePassword(userID int, oldPassword, newPassword string, force bool) error {
|
||||
cfg := config.AppConfig
|
||||
|
||||
user, err := s.userRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("用户不存在")
|
||||
}
|
||||
|
||||
// 验证原密码(强制改密时跳过)
|
||||
if !force {
|
||||
if !crypto.VerifyPassword(oldPassword, user.PasswordHash, cfg.PasswordSalt) {
|
||||
return fmt.Errorf("原密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证新密码强度
|
||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
newHash := crypto.HashPassword(newPassword, cfg.PasswordSalt)
|
||||
if err := s.userRepo.UpdatePassword(userID, newHash); err != nil {
|
||||
return fmt.Errorf("密码修改失败")
|
||||
}
|
||||
|
||||
// 清除 Token
|
||||
ctx := context.Background()
|
||||
_ = database.DeleteUserToken(ctx, userID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetUserInfo 获取用户信息
|
||||
func (s *AuthService) GetUserInfo(userID int) (map[string]interface{}, error) {
|
||||
user, err := s.userRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"user_id": user.UserID,
|
||||
"username": user.Username,
|
||||
"real_name": user.RealName,
|
||||
"user_type": user.UserType,
|
||||
"need_change_password": user.NeedChangePassword == 1,
|
||||
}
|
||||
|
||||
var classID int
|
||||
|
||||
if user.StudentID != nil {
|
||||
student, err := s.studentRepo.GetByID(*user.StudentID)
|
||||
if err == nil && student != nil {
|
||||
result["student_no"] = student.StudentNo
|
||||
result["student_name"] = student.Name
|
||||
result["total_points"] = student.TotalPoints
|
||||
classID = student.ClassID
|
||||
}
|
||||
}
|
||||
|
||||
if user.UserType == "admin" {
|
||||
adminRole, err := s.adminRoleRepo.GetByUserID(userID)
|
||||
if err == nil && adminRole != nil {
|
||||
result["role"] = adminRole.RoleType
|
||||
classID = adminRole.ClassID
|
||||
}
|
||||
}
|
||||
|
||||
if classID > 0 {
|
||||
result["class_id"] = classID
|
||||
cls, err := s.classRepo.GetByID(classID)
|
||||
if err == nil && cls != nil {
|
||||
result["class_name"] = cls.ClassName
|
||||
}
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// UnlockAccount 解锁账号(清除用户名级 + IP 级登录失败计数)
|
||||
func (s *AuthService) UnlockAccount(username, ip string) error {
|
||||
ctx := context.Background()
|
||||
keys := []string{fmt.Sprintf("login_attempts:%s", username)}
|
||||
if ip != "" {
|
||||
keys = append(keys, fmt.Sprintf("login_attempts:ip:%s", ip))
|
||||
}
|
||||
return database.RDB.Del(ctx, keys...).Err()
|
||||
}
|
||||
|
||||
// getRedirectPath 根据用户类型和角色确定跳转路径
|
||||
func getRedirectPath(userType string, role *string) string {
|
||||
switch userType {
|
||||
case "super_admin":
|
||||
return "/admin/dashboard.php"
|
||||
case "admin":
|
||||
return "/admin/dashboard.php"
|
||||
case "student":
|
||||
return "/student/dashboard.php"
|
||||
case "parent":
|
||||
return "/parent/dashboard.php"
|
||||
default:
|
||||
return "/"
|
||||
}
|
||||
}
|
||||
|
||||
// getPasswordChangePath 根据用户类型返回密码修改页面路径
|
||||
func getPasswordChangePath(userType string) string {
|
||||
switch userType {
|
||||
case "super_admin":
|
||||
return "/admin/password.php"
|
||||
case "admin":
|
||||
return "/admin/password.php"
|
||||
case "student":
|
||||
return "/student/password.php"
|
||||
case "parent":
|
||||
return "/parent/password.php"
|
||||
default:
|
||||
return "/"
|
||||
}
|
||||
}
|
||||
|
||||
224
backend-go/internal/service/class_service.go
Normal file
224
backend-go/internal/service/class_service.go
Normal file
@@ -0,0 +1,224 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||||
)
|
||||
|
||||
// ClassService 班级服务
|
||||
type ClassService struct {
|
||||
classRepo *repository.ClassRepo
|
||||
userRepo *repository.UserRepo
|
||||
adminRoleRepo *repository.AdminRoleRepo
|
||||
}
|
||||
|
||||
// NewClassService 创建班级服务
|
||||
func NewClassService(
|
||||
classRepo *repository.ClassRepo,
|
||||
userRepo *repository.UserRepo,
|
||||
adminRoleRepo *repository.AdminRoleRepo,
|
||||
) *ClassService {
|
||||
return &ClassService{
|
||||
classRepo: classRepo,
|
||||
userRepo: userRepo,
|
||||
adminRoleRepo: adminRoleRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// ListClasses 获取班级列表
|
||||
func (s *ClassService) ListClasses(includeDisabled bool) (map[string]interface{}, error) {
|
||||
classes, err := s.classRepo.GetAll(includeDisabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for i := range classes {
|
||||
count, _ := s.classRepo.GetStudentCount(classes[i].ClassID)
|
||||
classes[i].StudentCount = count
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"classes": classes,
|
||||
"total": len(classes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetClassDetail 获取班级详情
|
||||
func (s *ClassService) GetClassDetail(classID int) (map[string]interface{}, error) {
|
||||
cls, err := s.classRepo.GetByID(classID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cls.StudentCount, _ = s.classRepo.GetStudentCount(classID)
|
||||
return map[string]interface{}{"class": cls}, nil
|
||||
}
|
||||
|
||||
// CreateClass 创建班级
|
||||
func (s *ClassService) CreateClass(className string, grade, description *string) (map[string]interface{}, error) {
|
||||
existing, _ := s.classRepo.GetByName(className)
|
||||
if existing != nil {
|
||||
return map[string]interface{}{"success": false, "message": "班级名称已存在"}, nil
|
||||
}
|
||||
|
||||
cls := &model.Class{
|
||||
ClassName: className,
|
||||
Grade: grade,
|
||||
Description: description,
|
||||
Status: 1,
|
||||
}
|
||||
classID, err := s.classRepo.Create(cls)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"class_id": classID,
|
||||
"message": "班级创建成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateClass 更新班级
|
||||
func (s *ClassService) UpdateClass(classID int, className, grade, description *string, status *int8) error {
|
||||
existing, err := s.classRepo.GetByID(classID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("班级不存在")
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if className != nil && *className != existing.ClassName {
|
||||
nameExists, _ := s.classRepo.GetByName(*className)
|
||||
if nameExists != nil {
|
||||
return fmt.Errorf("班级名称已存在")
|
||||
}
|
||||
updates["class_name"] = *className
|
||||
}
|
||||
if grade != nil {
|
||||
updates["grade"] = *grade
|
||||
}
|
||||
if description != nil {
|
||||
updates["description"] = *description
|
||||
}
|
||||
if status != nil {
|
||||
updates["status"] = *status
|
||||
}
|
||||
|
||||
return s.classRepo.Update(classID, updates)
|
||||
}
|
||||
|
||||
// DeleteClass 删除班级
|
||||
func (s *ClassService) DeleteClass(classID int) error {
|
||||
hasStudents, _ := s.classRepo.HasActiveStudents(classID)
|
||||
if hasStudents {
|
||||
return fmt.Errorf("该班级下还有学生,无法删除")
|
||||
}
|
||||
return s.classRepo.Delete(classID)
|
||||
}
|
||||
|
||||
// SwitchClass 切换班级上下文(超级管理员)
|
||||
func (s *ClassService) SwitchClass(userID int, classID int) (map[string]interface{}, error) {
|
||||
cfg := config.AppConfig
|
||||
cls, err := s.classRepo.GetByID(classID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("班级不存在")
|
||||
}
|
||||
|
||||
user, err := s.userRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("用户不存在")
|
||||
}
|
||||
|
||||
// 查询目标班级中该用户的角色
|
||||
var role string
|
||||
if user.UserType == "super_admin" {
|
||||
role = "系统管理员"
|
||||
} else {
|
||||
adminRole, _ := s.adminRoleRepo.GetByUserIDAndClass(userID, classID)
|
||||
if adminRole != nil {
|
||||
role = adminRole.RoleType
|
||||
}
|
||||
}
|
||||
|
||||
// 生成新 Token,更新 class_id
|
||||
token, err := appJwt.CreateToken(
|
||||
user.UserID, user.Username, user.UserType,
|
||||
user.StudentID, role, user.RealName, &classID,
|
||||
user.NeedChangePassword == 1,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("生成令牌失败")
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
_ = database.SetUserToken(ctx, userID, token, cfg.JWTIdleTimeoutMinutes)
|
||||
|
||||
return map[string]interface{}{
|
||||
"token": token,
|
||||
"class_id": classID,
|
||||
"class_name": cls.ClassName,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSettings 获取班级设置
|
||||
func (s *ClassService) GetSettings(classID int) (map[string]interface{}, error) {
|
||||
settings, err := s.classRepo.GetSettings(classID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]string)
|
||||
for _, setting := range settings {
|
||||
result[setting.SettingKey] = setting.SettingValue
|
||||
}
|
||||
return map[string]interface{}{"settings": result}, nil
|
||||
}
|
||||
|
||||
// SaveSetting 保存班级设置
|
||||
func (s *ClassService) SaveSetting(classID int, key, value string) error {
|
||||
return s.classRepo.SaveSetting(classID, key, value)
|
||||
}
|
||||
|
||||
// GetFeatures 获取班级功能开关
|
||||
func (s *ClassService) GetFeatures(classID int) (map[string]interface{}, error) {
|
||||
features, err := s.classRepo.GetFeatures(classID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
result := make(map[string]int8)
|
||||
for _, f := range features {
|
||||
result[f.FeatureKey] = f.Enabled
|
||||
}
|
||||
return map[string]interface{}{"features": result}, nil
|
||||
}
|
||||
|
||||
// SaveFeature 保存班级功能开关
|
||||
func (s *ClassService) SaveFeature(classID int, featureKey string, enabled int8) error {
|
||||
return s.classRepo.SaveFeature(classID, featureKey, enabled)
|
||||
}
|
||||
|
||||
// IsFeatureEnabled 检查功能开关是否启用
|
||||
func (s *ClassService) IsFeatureEnabled(classID int, featureKey string) bool {
|
||||
feature, err := s.classRepo.GetFeature(classID, featureKey)
|
||||
if err != nil || feature == nil {
|
||||
return true // 默认启用
|
||||
}
|
||||
return feature.Enabled == 1
|
||||
}
|
||||
384
backend-go/internal/service/conduct_service.go
Normal file
384
backend-go/internal/service/conduct_service.go
Normal file
@@ -0,0 +1,384 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// ConductService 操行分服务
|
||||
type ConductService struct {
|
||||
conductRepo *repository.ConductRepo
|
||||
studentRepo *repository.StudentRepo
|
||||
adminRoleRepo *repository.AdminRoleRepo
|
||||
semesterRepo *repository.SemesterRepo
|
||||
classRepo *repository.ClassRepo
|
||||
}
|
||||
|
||||
// NewConductService 创建操行分服务
|
||||
func NewConductService(
|
||||
conductRepo *repository.ConductRepo,
|
||||
studentRepo *repository.StudentRepo,
|
||||
adminRoleRepo *repository.AdminRoleRepo,
|
||||
semesterRepo *repository.SemesterRepo,
|
||||
classRepo *repository.ClassRepo,
|
||||
) *ConductService {
|
||||
return &ConductService{
|
||||
conductRepo: conductRepo,
|
||||
studentRepo: studentRepo,
|
||||
adminRoleRepo: adminRoleRepo,
|
||||
semesterRepo: semesterRepo,
|
||||
classRepo: classRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// AddPoints 批量加减分
|
||||
func (s *ConductService) AddPoints(studentIDs []int, pointsChange int, reason string,
|
||||
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
|
||||
|
||||
// 输入校验
|
||||
if len(studentIDs) == 0 || len(studentIDs) > 200 {
|
||||
return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil
|
||||
}
|
||||
if reason == "" || len(reason) > 255 {
|
||||
return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil
|
||||
}
|
||||
if pointsChange == 0 || absInt(pointsChange) > 100 {
|
||||
return map[string]interface{}{"success": false, "message": "分值无效"}, nil
|
||||
}
|
||||
|
||||
// 获取操作人角色
|
||||
role, _, err := s.adminRoleRepo.GetUserRoleAndClassID(recorderID)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": "获取操作人角色失败"}, nil
|
||||
}
|
||||
|
||||
// 权限验证(从 class_settings 读取限制,这里使用默认值)
|
||||
if err := s.validatePointsPermission(role, pointsChange, classID); err != nil {
|
||||
return map[string]interface{}{"success": false, "message": err.Error()}, nil
|
||||
}
|
||||
|
||||
return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType)
|
||||
}
|
||||
|
||||
// CadreAddPoints 课代表专用加减分(跳过角色权限验证,仅限作业相关扣分)
|
||||
func (s *ConductService) CadreAddPoints(studentIDs []int, pointsChange int, reason string,
|
||||
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
|
||||
|
||||
// 输入校验
|
||||
if len(studentIDs) == 0 || len(studentIDs) > 200 {
|
||||
return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil
|
||||
}
|
||||
if reason == "" || len(reason) > 255 {
|
||||
return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil
|
||||
}
|
||||
if pointsChange >= 0 || absInt(pointsChange) > 100 {
|
||||
return map[string]interface{}{"success": false, "message": "课代表只能进行扣分操作"}, nil
|
||||
}
|
||||
|
||||
// 强制设置为作业类型
|
||||
relatedType = "homework"
|
||||
|
||||
return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType)
|
||||
}
|
||||
|
||||
// addPointsInternal 批量加减分内部实现
|
||||
func (s *ConductService) addPointsInternal(studentIDs []int, pointsChange int, reason string,
|
||||
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
|
||||
|
||||
// 自动获取当前活跃学期
|
||||
activeSemester, semErr := s.semesterRepo.GetActive()
|
||||
if semErr != nil {
|
||||
logger.Sugared.Warnf("获取活跃学期失败,操行分将不关联学期: %v", semErr)
|
||||
}
|
||||
var semesterID *int
|
||||
if activeSemester != nil {
|
||||
semesterID = &activeSemester.SemesterID
|
||||
}
|
||||
|
||||
if relatedType == "" {
|
||||
relatedType = "manual"
|
||||
}
|
||||
|
||||
successCount := 0
|
||||
failCount := 0
|
||||
var details []map[string]interface{}
|
||||
db := s.semesterRepo.GetDB()
|
||||
|
||||
for _, studentID := range studentIDs {
|
||||
// 检查学生是否存在
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil || student == nil {
|
||||
failCount++
|
||||
details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不存在"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 校验学生是否属于当前班级
|
||||
if student.ClassID != classID {
|
||||
failCount++
|
||||
details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不属于当前班级"})
|
||||
continue
|
||||
}
|
||||
|
||||
// 使用事务确保记录创建和总分更新的原子性(#3)
|
||||
recordID, txErr := func() (int64, error) {
|
||||
var rid int64
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
record := &model.ConductRecord{
|
||||
StudentID: studentID,
|
||||
PointsChange: pointsChange,
|
||||
Reason: reason,
|
||||
RecorderID: recorderID,
|
||||
RecorderName: &recorderName,
|
||||
RelatedType: relatedType,
|
||||
SemesterID: semesterID,
|
||||
}
|
||||
if err := tx.Create(record).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
rid = record.RecordID
|
||||
if err := tx.Model(&model.Student{}).
|
||||
Where("student_id = ?", studentID).
|
||||
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return rid, txErr
|
||||
}()
|
||||
|
||||
if txErr != nil {
|
||||
failCount++
|
||||
details = append(details, map[string]interface{}{"student_id": studentID, "error": txErr.Error()})
|
||||
continue
|
||||
}
|
||||
|
||||
successCount++
|
||||
details = append(details, map[string]interface{}{"student_id": studentID, "success": true, "record_id": recordID})
|
||||
logger.Sugared.Infof("用户[%d] 对学生[%d] 进行 %d 分操作", recorderID, studentID, pointsChange)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": failCount == 0,
|
||||
"success_count": successCount,
|
||||
"fail_count": failCount,
|
||||
"details": details,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RevokeRecord 撤销记录(事务保护,避免并发重复撤销)
|
||||
func (s *ConductService) RevokeRecord(recordID int64, revokerID int, classID int) (map[string]interface{}, error) {
|
||||
record, err := s.conductRepo.GetRecordByID(recordID)
|
||||
if err != nil || record == nil {
|
||||
return map[string]interface{}{"success": false, "message": "记录不存在"}, nil
|
||||
}
|
||||
|
||||
// 校验记录所属学生是否在当前操作者的班级中
|
||||
student, _ := s.studentRepo.GetByID(record.StudentID)
|
||||
if student == nil || student.ClassID != classID {
|
||||
return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil
|
||||
}
|
||||
|
||||
if record.IsRevoked == 1 {
|
||||
return map[string]interface{}{"success": false, "message": "该记录已被撤销"}, nil
|
||||
}
|
||||
|
||||
db := s.semesterRepo.GetDB()
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
// 撤销记录
|
||||
if err := tx.Model(&model.ConductRecord{}).
|
||||
Where("record_id = ? AND is_revoked = 0", recordID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_revoked": 1,
|
||||
"revoked_by": revokerID,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 反向恢复学生总分(下限保护)
|
||||
return tx.Model(&model.Student{}).
|
||||
Where("student_id = ?", record.StudentID).
|
||||
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", -record.PointsChange)).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
return map[string]interface{}{"success": false, "message": "撤销失败"}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "撤销成功",
|
||||
"record": map[string]interface{}{
|
||||
"student_id": record.StudentID,
|
||||
"recorder_name": derefStr(record.RecorderName),
|
||||
"points_change": record.PointsChange,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// RestoreRecord 反撤销记录(事务保护,避免并发重复恢复)
|
||||
func (s *ConductService) RestoreRecord(recordID int64, restorerID int, classID int) (map[string]interface{}, error) {
|
||||
record, err := s.conductRepo.GetRecordByID(recordID)
|
||||
if err != nil || record == nil {
|
||||
return map[string]interface{}{"success": false, "message": "记录不存在"}, nil
|
||||
}
|
||||
|
||||
// 校验记录所属学生是否在当前操作者的班级中
|
||||
student, _ := s.studentRepo.GetByID(record.StudentID)
|
||||
if student == nil || student.ClassID != classID {
|
||||
return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil
|
||||
}
|
||||
|
||||
if record.IsRevoked == 0 {
|
||||
return map[string]interface{}{"success": false, "message": "该记录未被撤销,无需恢复"}, nil
|
||||
}
|
||||
|
||||
db := s.semesterRepo.GetDB()
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
// 反撤销
|
||||
if err := tx.Model(&model.ConductRecord{}).
|
||||
Where("record_id = ? AND is_revoked = 1", recordID).
|
||||
Updates(map[string]interface{}{
|
||||
"is_revoked": 0,
|
||||
"revoked_by": nil,
|
||||
"revoked_at": nil,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 恢复学生总分(下限保护)
|
||||
return tx.Model(&model.Student{}).
|
||||
Where("student_id = ?", record.StudentID).
|
||||
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", record.PointsChange)).Error
|
||||
})
|
||||
if txErr != nil {
|
||||
return map[string]interface{}{"success": false, "message": "反撤销失败"}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "反撤销成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetHistory 获取操行分历史记录
|
||||
func (s *ConductService) GetHistory(classID int, studentID *int, page, pageSize int,
|
||||
startDate, endDate, relatedType, reasonPrefix string, isRevoked *int, reasonSearch string) (map[string]interface{}, error) {
|
||||
|
||||
includeRevoked := false
|
||||
if isRevoked != nil && *isRevoked == 1 {
|
||||
includeRevoked = true
|
||||
}
|
||||
|
||||
offset := (page - 1) * pageSize
|
||||
|
||||
records, err := s.conductRepo.GetAllRecords(classID, pageSize, offset, startDate, endDate,
|
||||
derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
total, err := s.conductRepo.CountAllRecords(classID, startDate, endDate,
|
||||
derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"records": records,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// validatePointsPermission 验证角色加减分权限
|
||||
func (s *ConductService) validatePointsPermission(role string, pointsChange, classID int) error {
|
||||
// 从 class_settings 读取配置,若无则使用默认值
|
||||
maxPoints := func(key string, defaultVal int) int {
|
||||
if classID > 0 {
|
||||
setting, err := s.classRepo.GetSetting(classID, key)
|
||||
if err == nil && setting != nil {
|
||||
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
|
||||
return v
|
||||
}
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
switch role {
|
||||
case "班主任":
|
||||
return nil // 无限制
|
||||
case "班长":
|
||||
maxAdd := maxPoints("point_limit_班长_max", 5)
|
||||
maxSub := maxPoints("point_limit_班长_min", -5)
|
||||
if pointsChange > maxAdd || pointsChange < maxSub {
|
||||
return fmt.Errorf("班长单次只能加%d至减%d分以内", maxAdd, absInt(maxSub))
|
||||
}
|
||||
case "学习委员":
|
||||
limit := maxPoints("point_limit_学习委员_max", 5)
|
||||
if absInt(pointsChange) > limit {
|
||||
return fmt.Errorf("学习委员单次只能加减%d分以内", limit)
|
||||
}
|
||||
case "科任老师":
|
||||
limit := maxPoints("point_limit_科任老师_max", 5)
|
||||
if absInt(pointsChange) > limit {
|
||||
return fmt.Errorf("科任老师单次只能加减%d分以内", limit)
|
||||
}
|
||||
case "考勤委员":
|
||||
if pointsChange > 0 {
|
||||
return fmt.Errorf("考勤委员只能进行扣分操作")
|
||||
}
|
||||
limit := maxPoints("point_limit_考勤委员_max", 8)
|
||||
if absInt(pointsChange) > limit {
|
||||
return fmt.Errorf("考勤委员单次最多扣%d分", limit)
|
||||
}
|
||||
case "劳动委员":
|
||||
limit := maxPoints("point_limit_劳动委员_max", 1)
|
||||
if absInt(pointsChange) > limit {
|
||||
return fmt.Errorf("劳动委员单次只能加减%d分以内", limit)
|
||||
}
|
||||
case "志愿委员":
|
||||
if pointsChange < 0 {
|
||||
return fmt.Errorf("志愿委员只能加分")
|
||||
}
|
||||
limit := maxPoints("point_limit_志愿委员_max", 5)
|
||||
if pointsChange > limit {
|
||||
return fmt.Errorf("志愿委员单次最多加%d分", limit)
|
||||
}
|
||||
case "课代表":
|
||||
return fmt.Errorf("课代表无权进行此操作")
|
||||
default:
|
||||
return fmt.Errorf("无权进行此操作")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// absInt 取绝对值
|
||||
func absInt(x int) int {
|
||||
if x < 0 {
|
||||
return -x
|
||||
}
|
||||
return x
|
||||
}
|
||||
49
backend-go/internal/service/config_service.go
Normal file
49
backend-go/internal/service/config_service.go
Normal file
@@ -0,0 +1,49 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
)
|
||||
|
||||
// ConfigService 配置服务
|
||||
type ConfigService struct {
|
||||
classRepo *repository.ClassRepo
|
||||
}
|
||||
|
||||
// NewConfigService 创建配置服务
|
||||
func NewConfigService(classRepo *repository.ClassRepo) *ConfigService {
|
||||
return &ConfigService{classRepo: classRepo}
|
||||
}
|
||||
|
||||
// GetClassSettingValue 从 class_settings 读取设置值,若无则返回默认值
|
||||
func (s *ConfigService) GetClassSettingValue(classID int, key, defaultVal string) string {
|
||||
if classID > 0 && s.classRepo != nil {
|
||||
setting, err := s.classRepo.GetSetting(classID, key)
|
||||
if err == nil && setting != nil && setting.SettingValue != "" {
|
||||
return setting.SettingValue
|
||||
}
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// GetDeductionRules 获取扣分规则(优先从 class_settings 读取班级级配置)
|
||||
func (s *ConfigService) GetDeductionRules(classID int) map[string]string {
|
||||
return map[string]string{
|
||||
"DEDUCTION_ATTENDANCE_ABSENT": s.GetClassSettingValue(classID, "deduction_attendance_absent", "3"),
|
||||
"DEDUCTION_ATTENDANCE_LATE": s.GetClassSettingValue(classID, "deduction_attendance_late", "1"),
|
||||
"DEDUCTION_ATTENDANCE_LEAVE": s.GetClassSettingValue(classID, "deduction_attendance_leave", "0"),
|
||||
"STUDENT_INITIAL_POINTS": s.GetClassSettingValue(classID, "initial_points", "60"),
|
||||
"DEDUCTION_HOMEWORK_NOT_SUBMIT": s.GetClassSettingValue(classID, "deduction_homework_not_submit", "2"),
|
||||
"DEDUCTION_HOMEWORK_LATE": s.GetClassSettingValue(classID, "deduction_homework_late", "1"),
|
||||
}
|
||||
}
|
||||
70
backend-go/internal/service/log_service.go
Normal file
70
backend-go/internal/service/log_service.go
Normal file
@@ -0,0 +1,70 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// LogService 日志服务
|
||||
type LogService struct {
|
||||
logRepo *repository.LogRepo
|
||||
}
|
||||
|
||||
// NewLogService 创建日志服务
|
||||
func NewLogService(logRepo *repository.LogRepo) *LogService {
|
||||
return &LogService{logRepo: logRepo}
|
||||
}
|
||||
|
||||
// WriteLoginLog 写入登录日志
|
||||
func (s *LogService) WriteLoginLog(username string, loginResult int8, ip, userAgent, failReason string) {
|
||||
log := &model.LoginLog{
|
||||
Username: username,
|
||||
LoginResult: loginResult,
|
||||
IPAddress: stringPtr(ip),
|
||||
UserAgent: stringPtr(userAgent),
|
||||
FailReason: stringPtr(failReason),
|
||||
}
|
||||
if _, err := s.logRepo.CreateLoginLog(log); err != nil {
|
||||
logger.Sugared.Errorf("写入登录日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// WriteOperationLog 写入操作日志
|
||||
func (s *LogService) WriteOperationLog(operatorID int, operatorName, operatorRole, operationType string,
|
||||
targetType *string, targetID *int, details *string, ip *string, classID *int) {
|
||||
log := &model.OperationLog{
|
||||
OperatorID: operatorID,
|
||||
OperatorName: stringPtr(operatorName),
|
||||
OperatorRole: stringPtr(operatorRole),
|
||||
OperationType: operationType,
|
||||
TargetType: targetType,
|
||||
TargetID: targetID,
|
||||
Details: details,
|
||||
IPAddress: ip,
|
||||
ClassID: classID,
|
||||
}
|
||||
if _, err := s.logRepo.CreateOperationLog(log); err != nil {
|
||||
logger.Sugared.Errorf("写入操作日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// stringPtr 辅助函数:字符串转指针(空字符串返回 nil)
|
||||
func stringPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
|
||||
80
backend-go/internal/service/ranking_service.go
Normal file
80
backend-go/internal/service/ranking_service.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
)
|
||||
|
||||
// RankingService 排行榜服务
|
||||
type RankingService struct {
|
||||
studentRepo *repository.StudentRepo
|
||||
conductRepo *repository.ConductRepo
|
||||
}
|
||||
|
||||
// NewRankingService 创建排行榜服务
|
||||
func NewRankingService(
|
||||
studentRepo *repository.StudentRepo,
|
||||
conductRepo *repository.ConductRepo,
|
||||
) *RankingService {
|
||||
return &RankingService{
|
||||
studentRepo: studentRepo,
|
||||
conductRepo: conductRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetRankings 获取排行榜
|
||||
func (s *RankingService) GetRankings(classID int, rankType string, limit int) (map[string]interface{}, error) {
|
||||
switch rankType {
|
||||
case "attendance", "homework", "conduct":
|
||||
return s.getTypedRanking(classID, rankType, limit)
|
||||
default:
|
||||
// 默认按操行分总分排行
|
||||
ranking, err := s.studentRepo.GetRanking(classID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalStudents, _ := s.studentRepo.GetTotalCount(classID)
|
||||
return map[string]interface{}{
|
||||
"ranking": ranking,
|
||||
"total_students": totalStudents,
|
||||
"type": "all",
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
// getTypedRanking 获取分项排行榜(使用 SQL 层聚合,避免全量加载)
|
||||
func (s *RankingService) getTypedRanking(classID int, relatedType string, limit int) (map[string]interface{}, error) {
|
||||
dbType := relatedType
|
||||
if relatedType == "conduct" {
|
||||
dbType = "manual"
|
||||
}
|
||||
results, err := s.conductRepo.GetStudentPointsByType(classID, dbType, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var rankings []map[string]interface{}
|
||||
for _, r := range results {
|
||||
rankings = append(rankings, map[string]interface{}{
|
||||
"student_id": r.StudentID,
|
||||
"student_no": r.StudentNo,
|
||||
"name": r.Name,
|
||||
"points": r.TotalPoints,
|
||||
})
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"ranking": rankings,
|
||||
"type": relatedType,
|
||||
}, nil
|
||||
}
|
||||
665
backend-go/internal/service/semester_service.go
Normal file
665
backend-go/internal/service/semester_service.go
Normal file
@@ -0,0 +1,665 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// SemesterService 学期服务
|
||||
type SemesterService struct {
|
||||
semesterRepo *repository.SemesterRepo
|
||||
studentRepo *repository.StudentRepo
|
||||
classRepo *repository.ClassRepo
|
||||
attendanceRepo *repository.AttendanceRepo
|
||||
assignmentRepo *repository.AssignmentRepo
|
||||
logService *LogService
|
||||
}
|
||||
|
||||
// NewSemesterService 创建学期服务
|
||||
func NewSemesterService(
|
||||
semesterRepo *repository.SemesterRepo,
|
||||
studentRepo *repository.StudentRepo,
|
||||
classRepo *repository.ClassRepo,
|
||||
attendanceRepo *repository.AttendanceRepo,
|
||||
assignmentRepo *repository.AssignmentRepo,
|
||||
logService *LogService,
|
||||
) *SemesterService {
|
||||
return &SemesterService{
|
||||
semesterRepo: semesterRepo,
|
||||
studentRepo: studentRepo,
|
||||
classRepo: classRepo,
|
||||
attendanceRepo: attendanceRepo,
|
||||
assignmentRepo: assignmentRepo,
|
||||
logService: logService,
|
||||
}
|
||||
}
|
||||
|
||||
// ListSemesters 获取学期列表
|
||||
func (s *SemesterService) ListSemesters() (map[string]interface{}, error) {
|
||||
semesters, err := s.semesterRepo.GetAll()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
today := time.Now()
|
||||
for i := range semesters {
|
||||
conductCount, attendanceCount, _ := s.semesterRepo.CountRecordsBySemester(semesters[i].SemesterID)
|
||||
semesters[i].ConductCount = conductCount
|
||||
semesters[i].AttendanceCount = attendanceCount
|
||||
|
||||
// 计算当前周数
|
||||
if semesters[i].IsActive == 1 && semesters[i].StartDate != nil {
|
||||
delta := today.Sub(*semesters[i].StartDate).Hours() / (24 * 7)
|
||||
if delta >= 0 {
|
||||
week := int(delta) + 1
|
||||
semesters[i].CurrentWeek = &week
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"semesters": semesters,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetActiveSemester 获取当前活跃学期
|
||||
func (s *SemesterService) GetActiveSemester() (*model.Semester, error) {
|
||||
return s.semesterRepo.GetActive()
|
||||
}
|
||||
|
||||
// CreateSemester 创建学期
|
||||
func (s *SemesterService) CreateSemester(semesterName string, startDate, endDate *string) (map[string]interface{}, error) {
|
||||
semester := &model.Semester{
|
||||
SemesterName: semesterName,
|
||||
IsActive: 0,
|
||||
IsArchived: 0,
|
||||
}
|
||||
|
||||
if startDate != nil && *startDate != "" {
|
||||
t, err := time.Parse("2006-01-02", *startDate)
|
||||
if err == nil {
|
||||
semester.StartDate = &t
|
||||
}
|
||||
}
|
||||
if endDate != nil && *endDate != "" {
|
||||
t, err := time.Parse("2006-01-02", *endDate)
|
||||
if err == nil {
|
||||
semester.EndDate = &t
|
||||
}
|
||||
}
|
||||
|
||||
semesterID, err := s.semesterRepo.Create(semester)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": "创建学期失败"}, nil
|
||||
}
|
||||
|
||||
// 如果日期范围包含今天,自动激活
|
||||
if semester.StartDate != nil {
|
||||
today := time.Now()
|
||||
if semester.StartDate.Before(today) || sameDay(*semester.StartDate, today) {
|
||||
if semester.EndDate == nil || semester.EndDate.After(today) || sameDay(*semester.EndDate, today) {
|
||||
_ = s.semesterRepo.DeactivateAll()
|
||||
_ = s.semesterRepo.Activate(semesterID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "学期创建成功",
|
||||
"semester_id": semesterID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ActivateSemester 激活学期
|
||||
func (s *SemesterService) ActivateSemester(semesterID int) error {
|
||||
semester, err := s.semesterRepo.GetByID(semesterID)
|
||||
if err != nil || semester == nil {
|
||||
return fmt.Errorf("学期不存在")
|
||||
}
|
||||
if semester.IsArchived == 1 {
|
||||
return fmt.Errorf("已归档的学期不能设为当前学期")
|
||||
}
|
||||
|
||||
_ = s.semesterRepo.DeactivateAll()
|
||||
return s.semesterRepo.Activate(semesterID)
|
||||
}
|
||||
|
||||
// UpdateSemester 更新学期
|
||||
func (s *SemesterService) UpdateSemester(semesterID int, semesterName, startDate, endDate *string) error {
|
||||
semester, err := s.semesterRepo.GetByID(semesterID)
|
||||
if err != nil || semester == nil {
|
||||
return fmt.Errorf("学期不存在")
|
||||
}
|
||||
if semester.IsArchived == 1 {
|
||||
return fmt.Errorf("已归档的学期不能编辑")
|
||||
}
|
||||
|
||||
updates := make(map[string]interface{})
|
||||
if semesterName != nil {
|
||||
updates["semester_name"] = *semesterName
|
||||
}
|
||||
if startDate != nil {
|
||||
t, err := time.Parse("2006-01-02", *startDate)
|
||||
if err == nil {
|
||||
updates["start_date"] = t
|
||||
}
|
||||
}
|
||||
if endDate != nil {
|
||||
t, err := time.Parse("2006-01-02", *endDate)
|
||||
if err == nil {
|
||||
updates["end_date"] = t
|
||||
}
|
||||
}
|
||||
|
||||
return s.semesterRepo.Update(semesterID, updates)
|
||||
}
|
||||
|
||||
// DeleteSemester 删除学期
|
||||
func (s *SemesterService) DeleteSemester(semesterID int) error {
|
||||
archiveCount, err := s.semesterRepo.CountArchives(semesterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if archiveCount > 0 {
|
||||
return fmt.Errorf("该学期有 %d 条归档数据,无法删除", archiveCount)
|
||||
}
|
||||
return s.semesterRepo.Delete(semesterID)
|
||||
}
|
||||
|
||||
// AssociateRecords 关联记录到学期
|
||||
func (s *SemesterService) AssociateRecords(semesterID int) (map[string]interface{}, error) {
|
||||
semester, err := s.semesterRepo.GetByID(semesterID)
|
||||
if err != nil || semester == nil {
|
||||
return map[string]interface{}{"success": false, "message": "学期不存在"}, nil
|
||||
}
|
||||
if semester.IsArchived == 1 {
|
||||
return map[string]interface{}{"success": false, "message": "已归档的学期不能关联数据"}, nil
|
||||
}
|
||||
if semester.StartDate == nil {
|
||||
return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法关联数据"}, nil
|
||||
}
|
||||
|
||||
startDate := semester.StartDate.Format("2006-01-02")
|
||||
endDate := time.Now().Format("2006-01-02")
|
||||
if semester.EndDate != nil {
|
||||
endDate = semester.EndDate.Format("2006-01-02")
|
||||
}
|
||||
|
||||
conductCount, attendanceCount, err := s.semesterRepo.AssociateRecordsByDateRange(semesterID, startDate, endDate)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": "关联记录失败"}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": fmt.Sprintf("关联完成:操行分 %d 条,考勤 %d 条", conductCount, attendanceCount),
|
||||
"data": map[string]interface{}{
|
||||
"conduct": conductCount,
|
||||
"attendance": attendanceCount,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ArchiveSemester 归档学期
|
||||
func (s *SemesterService) ArchiveSemester(semesterID, classID int, resetScores bool) (map[string]interface{}, error) {
|
||||
semester, err := s.semesterRepo.GetByID(semesterID)
|
||||
if err != nil || semester == nil {
|
||||
return map[string]interface{}{"success": false, "message": "学期不存在"}, nil
|
||||
}
|
||||
if semester.IsArchived == 1 {
|
||||
return map[string]interface{}{"success": false, "message": "该学期已归档"}, nil
|
||||
}
|
||||
if semester.StartDate == nil {
|
||||
return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法进行归档"}, nil
|
||||
}
|
||||
if classID == 0 {
|
||||
return map[string]interface{}{"success": false, "message": "未指定班级"}, nil
|
||||
}
|
||||
|
||||
// 获取班级活跃学生
|
||||
students, err := s.studentRepo.GetStudentsByClassID(classID)
|
||||
if err != nil || len(students) == 0 {
|
||||
return map[string]interface{}{"success": false, "message": "没有可归档的学生数据"}, nil
|
||||
}
|
||||
totalStudents := len(students)
|
||||
|
||||
// 查询考勤统计
|
||||
startDate := semester.StartDate.Format("2006-01-02")
|
||||
endDate := time.Now().Format("2006-01-02")
|
||||
if semester.EndDate != nil {
|
||||
endDate = semester.EndDate.Format("2006-01-02")
|
||||
}
|
||||
attendanceStats, _ := s.attendanceRepo.GetAttendanceStatsBySemester(semesterID, startDate, endDate)
|
||||
attendanceMap := make(map[int]map[string]int64)
|
||||
for _, stat := range attendanceStats {
|
||||
if attendanceMap[stat.StudentID] == nil {
|
||||
attendanceMap[stat.StudentID] = make(map[string]int64)
|
||||
}
|
||||
attendanceMap[stat.StudentID][stat.Status] = stat.Count
|
||||
}
|
||||
|
||||
// 查询作业统计
|
||||
homeworkStats, err := s.assignmentRepo.GetHomeworkStatsByDateRange(*semester.StartDate, time.Now())
|
||||
if err != nil {
|
||||
logger.Sugared.Warnf("查询作业统计失败,归档快照中作业数据可能不完整: %v", err)
|
||||
}
|
||||
homeworkMap := make(map[int]map[string]int64)
|
||||
for _, stat := range homeworkStats {
|
||||
if homeworkMap[stat.StudentID] == nil {
|
||||
homeworkMap[stat.StudentID] = make(map[string]int64)
|
||||
}
|
||||
homeworkMap[stat.StudentID][stat.Status] = stat.Count
|
||||
}
|
||||
|
||||
// 使用事务确保归档操作的原子性,并通过行锁防止并发归档
|
||||
db := s.semesterRepo.GetDB()
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
// 使用 SELECT ... FOR UPDATE 锁定学期记录,防止并发归档
|
||||
var lockedSemester model.Semester
|
||||
if err := tx.Set("gorm:query_option", "FOR UPDATE").
|
||||
Where("semester_id = ?", semesterID).First(&lockedSemester).Error; err != nil {
|
||||
return fmt.Errorf("锁定学期记录失败: %w", err)
|
||||
}
|
||||
if lockedSemester.IsArchived == 1 {
|
||||
return fmt.Errorf("该学期已被其他操作归档")
|
||||
}
|
||||
|
||||
// 删除旧的归档数据
|
||||
if err := tx.Where("semester_id = ?", semesterID).Delete(&model.SemesterArchive{}).Error; err != nil {
|
||||
return fmt.Errorf("删除旧归档数据失败: %w", err)
|
||||
}
|
||||
|
||||
// 创建归档快照(填充考勤和作业统计)
|
||||
var archives []model.SemesterArchive
|
||||
for rank, stu := range students {
|
||||
stuAttendance := attendanceMap[stu.StudentID]
|
||||
stuHomework := homeworkMap[stu.StudentID]
|
||||
archive := model.SemesterArchive{
|
||||
SemesterID: semesterID,
|
||||
ClassID: classID,
|
||||
StudentID: stu.StudentID,
|
||||
StudentNo: stu.StudentNo,
|
||||
StudentName: stu.Name,
|
||||
FinalPoints: stu.TotalPoints,
|
||||
RankPosition: intPtr(rank + 1),
|
||||
TotalStudents: &totalStudents,
|
||||
AttendancePresent: int(stuAttendance["present"]),
|
||||
AttendanceAbsent: int(stuAttendance["absent"]),
|
||||
AttendanceLate: int(stuAttendance["late"]),
|
||||
AttendanceLeave: int(stuAttendance["leave"]),
|
||||
HomeworkSubmitted: int(stuHomework["submitted"]),
|
||||
HomeworkNotSubmitted: int(stuHomework["not_submitted"]),
|
||||
HomeworkLate: int(stuHomework["late"]),
|
||||
}
|
||||
archives = append(archives, archive)
|
||||
}
|
||||
|
||||
if len(archives) > 0 {
|
||||
if err := tx.Create(&archives).Error; err != nil {
|
||||
return fmt.Errorf("创建归档快照失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 归档学期
|
||||
if err := tx.Model(&model.Semester{}).
|
||||
Where("semester_id = ? AND is_archived = 0", semesterID).
|
||||
Updates(map[string]interface{}{"is_archived": 1, "is_active": 0}).Error; err != nil {
|
||||
return fmt.Errorf("归档学期失败: %w", err)
|
||||
}
|
||||
|
||||
// 重置分数(从 class_settings 读取初始分,若无则默认 60)
|
||||
if resetScores {
|
||||
initialPoints := 60
|
||||
var setting model.ClassSetting
|
||||
if err := tx.Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil {
|
||||
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
|
||||
initialPoints = v
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.Student{}).
|
||||
Where("class_id = ? AND status = 1", classID).
|
||||
Update("total_points", initialPoints).Error; err != nil {
|
||||
return fmt.Errorf("重置分数失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if txErr != nil {
|
||||
logger.Sugared.Errorf("归档事务失败: %v", txErr)
|
||||
return map[string]interface{}{"success": false, "message": "归档失败: " + txErr.Error()}, nil
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"message": "归档成功",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetArchiveRecords 获取归档数据
|
||||
func (s *SemesterService) GetArchiveRecords(semesterID, classID, page, pageSize int) (map[string]interface{}, error) {
|
||||
archives, total, err := s.semesterRepo.GetArchivesBySemester(semesterID, classID, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"items": archives,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// sameDay 判断两个时间是否同一天
|
||||
func sameDay(a, b time.Time) bool {
|
||||
return a.Year() == b.Year() && a.YearDay() == b.YearDay()
|
||||
}
|
||||
|
||||
// ========== 周期重置功能 ==========
|
||||
|
||||
// PeriodReset 周度/月度重置
|
||||
// 1. 创建当前操行分快照
|
||||
// 2. 将所有学生操行分重置为 class_settings.initial_points
|
||||
// 3. 记录操作日志
|
||||
func (s *SemesterService) PeriodReset(classID int, period string, operatorID int, operatorName string, ip string) error {
|
||||
periodLabel := generatePeriodLabel(period, time.Now())
|
||||
|
||||
// 读取初始分
|
||||
initialPoints := 60
|
||||
var setting model.ClassSetting
|
||||
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil {
|
||||
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
|
||||
initialPoints = v
|
||||
}
|
||||
}
|
||||
|
||||
// 获取班级活跃学生
|
||||
students, err := s.studentRepo.GetStudentsByClassID(classID)
|
||||
if err != nil || len(students) == 0 {
|
||||
return fmt.Errorf("没有可重置的学生数据")
|
||||
}
|
||||
|
||||
totalStudents := len(students)
|
||||
var archives []model.PeriodArchive
|
||||
for rank, stu := range students {
|
||||
archive := model.PeriodArchive{
|
||||
ClassID: classID,
|
||||
PeriodType: period,
|
||||
PeriodLabel: periodLabel,
|
||||
StudentID: stu.StudentID,
|
||||
StudentNo: stu.StudentNo,
|
||||
StudentName: stu.Name,
|
||||
FinalPoints: stu.TotalPoints,
|
||||
RankPosition: intPtr(rank + 1),
|
||||
TotalStudents: &totalStudents,
|
||||
ResetBy: "manual",
|
||||
OperatorID: &operatorID,
|
||||
}
|
||||
archives = append(archives, archive)
|
||||
}
|
||||
|
||||
// 使用事务确保原子性,并将存在检查移入事务内防止竞态条件
|
||||
db := s.semesterRepo.GetDB()
|
||||
txErr := db.Transaction(func(tx *gorm.DB) error {
|
||||
// 在事务内检查本期是否已有归档数据(防并发重复重置)
|
||||
var existCount int64
|
||||
if err := tx.Model(&model.PeriodArchive{}).
|
||||
Where("class_id = ? AND period_type = ? AND period_label = ?", classID, period, periodLabel).
|
||||
Count(&existCount).Error; err != nil {
|
||||
return fmt.Errorf("检查归档数据失败: %w", err)
|
||||
}
|
||||
if existCount > 0 {
|
||||
return fmt.Errorf("当前周期(%s)已有归档数据,请勿重复重置", periodLabel)
|
||||
}
|
||||
|
||||
// 创建归档快照
|
||||
if len(archives) > 0 {
|
||||
if err := tx.Create(&archives).Error; err != nil {
|
||||
return fmt.Errorf("创建周期归档快照失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 重置分数
|
||||
if err := tx.Model(&model.Student{}).
|
||||
Where("class_id = ? AND status = 1", classID).
|
||||
Update("total_points", initialPoints).Error; err != nil {
|
||||
return fmt.Errorf("重置分数失败: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
if txErr != nil {
|
||||
logger.Sugared.Errorf("周期重置事务失败: %v", txErr)
|
||||
return txErr
|
||||
}
|
||||
|
||||
// 记录操作日志
|
||||
details := fmt.Sprintf("手动执行%s重置,周期标签: %s,影响学生数: %d", periodCN(period), periodLabel, totalStudents)
|
||||
s.logService.WriteOperationLog(
|
||||
operatorID, operatorName, "班主任", "period_reset",
|
||||
nil, nil, &details, &ip, &classID,
|
||||
)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AutoPeriodReset 自动周期重置检查(由定时任务调用)
|
||||
func (s *SemesterService) AutoPeriodReset() {
|
||||
logger.Sugared.Info("开始检查自动周期重置...")
|
||||
|
||||
// 获取所有启用的班级
|
||||
classes, err := s.classRepo.GetAll(false)
|
||||
if err != nil {
|
||||
logger.Sugared.Errorf("获取班级列表失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for _, cls := range classes {
|
||||
// 读取 reset_frequency
|
||||
var freqSetting model.ClassSetting
|
||||
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_frequency").First(&freqSetting).Error; err != nil {
|
||||
continue // 无配置,跳过
|
||||
}
|
||||
freq := freqSetting.SettingValue
|
||||
if freq == "none" || freq == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
shouldReset := false
|
||||
switch freq {
|
||||
case "weekly":
|
||||
// 读取 reset_day_of_week(默认1=周一)
|
||||
resetDay := 1
|
||||
var daySetting model.ClassSetting
|
||||
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_week").First(&daySetting).Error; err == nil {
|
||||
if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 7 {
|
||||
resetDay = v
|
||||
}
|
||||
}
|
||||
// Go的Weekday: 0=Sunday, 1=Monday, ..., 6=Saturday
|
||||
// 映射: 1=周一(1) ... 6=周六(6), 7=周日(0)
|
||||
var targetWeekday time.Weekday
|
||||
if resetDay == 7 {
|
||||
targetWeekday = time.Sunday
|
||||
} else {
|
||||
targetWeekday = time.Weekday(resetDay)
|
||||
}
|
||||
if now.Weekday() == targetWeekday {
|
||||
shouldReset = true
|
||||
}
|
||||
case "monthly":
|
||||
// 读取 reset_day_of_month(默认1)
|
||||
resetDay := 1
|
||||
var daySetting model.ClassSetting
|
||||
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_month").First(&daySetting).Error; err == nil {
|
||||
if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 28 {
|
||||
resetDay = v
|
||||
}
|
||||
}
|
||||
if now.Day() == resetDay {
|
||||
shouldReset = true
|
||||
}
|
||||
}
|
||||
|
||||
if !shouldReset {
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查今天是否已经重置过
|
||||
periodLabel := generatePeriodLabel(freq, now)
|
||||
var existCount int64
|
||||
if err := s.classRepo.GetDB().Model(&model.PeriodArchive{}).
|
||||
Where("class_id = ? AND period_type = ? AND period_label = ? AND reset_by = ?",
|
||||
cls.ClassID, freq, periodLabel, "auto").
|
||||
Count(&existCount).Error; err != nil {
|
||||
logger.Sugared.Errorf("检查班级 %d 周期归档失败: %v", cls.ClassID, err)
|
||||
continue
|
||||
}
|
||||
if existCount > 0 {
|
||||
logger.Sugared.Infof("班级 %d 本期(%s)已自动重置,跳过", cls.ClassID, periodLabel)
|
||||
continue
|
||||
}
|
||||
|
||||
// 执行自动重置
|
||||
logger.Sugared.Infof("自动重置班级 %d (%s, %s)", cls.ClassID, cls.ClassName, periodLabel)
|
||||
if err := s.autoPeriodResetClass(cls.ClassID, freq, periodLabel); err != nil {
|
||||
logger.Sugared.Errorf("自动重置班级 %d 失败: %v", cls.ClassID, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// autoPeriodResetClass 单个班级的自动周期重置
|
||||
func (s *SemesterService) autoPeriodResetClass(classID int, period, periodLabel string) error {
|
||||
initialPoints := 60
|
||||
var setting model.ClassSetting
|
||||
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil {
|
||||
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
|
||||
initialPoints = v
|
||||
}
|
||||
}
|
||||
|
||||
students, err := s.studentRepo.GetStudentsByClassID(classID)
|
||||
if err != nil || len(students) == 0 {
|
||||
return fmt.Errorf("没有可重置的学生数据")
|
||||
}
|
||||
|
||||
totalStudents := len(students)
|
||||
var archives []model.PeriodArchive
|
||||
for rank, stu := range students {
|
||||
archive := model.PeriodArchive{
|
||||
ClassID: classID,
|
||||
PeriodType: period,
|
||||
PeriodLabel: periodLabel,
|
||||
StudentID: stu.StudentID,
|
||||
StudentNo: stu.StudentNo,
|
||||
StudentName: stu.Name,
|
||||
FinalPoints: stu.TotalPoints,
|
||||
RankPosition: intPtr(rank + 1),
|
||||
TotalStudents: &totalStudents,
|
||||
ResetBy: "auto",
|
||||
}
|
||||
archives = append(archives, archive)
|
||||
}
|
||||
|
||||
db := s.semesterRepo.GetDB()
|
||||
return db.Transaction(func(tx *gorm.DB) error {
|
||||
if len(archives) > 0 {
|
||||
if err := tx.Create(&archives).Error; err != nil {
|
||||
return fmt.Errorf("创建周期归档快照失败: %w", err)
|
||||
}
|
||||
}
|
||||
if err := tx.Model(&model.Student{}).
|
||||
Where("class_id = ? AND status = 1", classID).
|
||||
Update("total_points", initialPoints).Error; err != nil {
|
||||
return fmt.Errorf("重置分数失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetPeriodArchives 获取周期归档列表
|
||||
func (s *SemesterService) GetPeriodArchives(classID int, period string, page, pageSize int) (map[string]interface{}, error) {
|
||||
archives, total, err := s.semesterRepo.GetPeriodArchives(classID, period, page, pageSize)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
totalPages := int(total) / pageSize
|
||||
if int(total)%pageSize > 0 {
|
||||
totalPages++
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"items": archives,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"page_size": pageSize,
|
||||
"total_pages": totalPages,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// generatePeriodLabel 生成周期标签
|
||||
func generatePeriodLabel(period string, t time.Time) string {
|
||||
switch period {
|
||||
case "weekly":
|
||||
year, week := t.ISOWeek()
|
||||
return fmt.Sprintf("%d-W%02d", year, week)
|
||||
case "monthly":
|
||||
return t.Format("2006-01")
|
||||
default:
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
}
|
||||
|
||||
// periodCN 周期类型的中文描述
|
||||
func periodCN(period string) string {
|
||||
switch period {
|
||||
case "weekly":
|
||||
return "每周"
|
||||
case "monthly":
|
||||
return "每月"
|
||||
default:
|
||||
return period
|
||||
}
|
||||
}
|
||||
|
||||
// PeriodLabelCN 周期类型的中文标签(当前周期)
|
||||
func PeriodLabelCN(period string) string {
|
||||
switch period {
|
||||
case "weekly":
|
||||
return "本周"
|
||||
case "monthly":
|
||||
return "本月"
|
||||
default:
|
||||
return period
|
||||
}
|
||||
}
|
||||
171
backend-go/internal/service/student_service.go
Normal file
171
backend-go/internal/service/student_service.go
Normal file
@@ -0,0 +1,171 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
)
|
||||
|
||||
// StudentService 学生端服务
|
||||
type StudentService struct {
|
||||
studentRepo *repository.StudentRepo
|
||||
conductRepo *repository.ConductRepo
|
||||
attendanceRepo *repository.AttendanceRepo
|
||||
semesterRepo *repository.SemesterRepo
|
||||
}
|
||||
|
||||
// NewStudentService 创建学生端服务
|
||||
func NewStudentService(
|
||||
studentRepo *repository.StudentRepo,
|
||||
conductRepo *repository.ConductRepo,
|
||||
attendanceRepo *repository.AttendanceRepo,
|
||||
semesterRepo *repository.SemesterRepo,
|
||||
) *StudentService {
|
||||
return &StudentService{
|
||||
studentRepo: studentRepo,
|
||||
conductRepo: conductRepo,
|
||||
attendanceRepo: attendanceRepo,
|
||||
semesterRepo: semesterRepo,
|
||||
}
|
||||
}
|
||||
|
||||
// GetStudentInfo 获取学生个人信息
|
||||
func (s *StudentService) GetStudentInfo(studentID int) (map[string]interface{}, error) {
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"student": student,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetConductHistory 获取学生操行分历史
|
||||
func (s *StudentService) GetConductHistory(studentID int, limit, offset int) (map[string]interface{}, error) {
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records, err := s.conductRepo.GetStudentRecords(studentID, limit, offset, false, "", "", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 扣分项的操作人统一显示为"班主任"
|
||||
for i := range records {
|
||||
if records[i].PointsChange < 0 {
|
||||
name := "班主任"
|
||||
records[i].RecorderReal = &name
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"student_id": studentID,
|
||||
"student_name": student.Name,
|
||||
"total_points": student.TotalPoints,
|
||||
"records": records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetHomeworkStatus 获取学生作业情况
|
||||
func (s *StudentService) GetHomeworkStatus(studentID int) (map[string]interface{}, error) {
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records, err := s.conductRepo.GetStudentRecords(studentID, 1000, 0, false, "", "", 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 过滤出作业相关记录
|
||||
var homeworkRecords []interface{}
|
||||
for _, r := range records {
|
||||
if r.RelatedType == "homework" {
|
||||
homeworkRecords = append(homeworkRecords, r)
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"student_id": studentID,
|
||||
"student_name": student.Name,
|
||||
"homework": homeworkRecords,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetAttendanceRecords 获取学生考勤记录
|
||||
func (s *StudentService) GetAttendanceRecords(studentID int, month string) (map[string]interface{}, error) {
|
||||
student, err := s.studentRepo.GetByID(studentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
records, err := s.attendanceRepo.GetStudentRecords(studentID, month)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 统计
|
||||
present, absent, late, leave := 0, 0, 0, 0
|
||||
for _, r := range records {
|
||||
switch r.Status {
|
||||
case "present":
|
||||
present++
|
||||
case "absent":
|
||||
absent++
|
||||
case "late":
|
||||
late++
|
||||
case "leave":
|
||||
leave++
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"student_id": studentID,
|
||||
"student_name": student.Name,
|
||||
"statistics": map[string]interface{}{
|
||||
"present": present,
|
||||
"absent": absent,
|
||||
"late": late,
|
||||
"leave": leave,
|
||||
"total": len(records),
|
||||
},
|
||||
"records": records,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetRanking 获取排行榜
|
||||
func (s *StudentService) GetRanking(classID int, limit int) (map[string]interface{}, error) {
|
||||
ranking, err := s.studentRepo.GetRanking(classID, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
totalStudents, _ := s.studentRepo.GetTotalCount(classID)
|
||||
|
||||
return map[string]interface{}{
|
||||
"ranking": ranking,
|
||||
"total_students": totalStudents,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetSemesterRecords 获取学生学期归档记录
|
||||
func (s *StudentService) GetSemesterRecords(studentID int) (map[string]interface{}, error) {
|
||||
archives, err := s.semesterRepo.GetArchivesByStudent(studentID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"records": archives,
|
||||
}, nil
|
||||
}
|
||||
92
backend-go/internal/service/subject_service.go
Normal file
92
backend-go/internal/service/subject_service.go
Normal file
@@ -0,0 +1,92 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/model"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// SubjectService 科目服务
|
||||
type SubjectService struct {
|
||||
subjectRepo *repository.SubjectRepo
|
||||
}
|
||||
|
||||
// NewSubjectService 创建科目服务
|
||||
func NewSubjectService(subjectRepo *repository.SubjectRepo) *SubjectService {
|
||||
return &SubjectService{subjectRepo: subjectRepo}
|
||||
}
|
||||
|
||||
// GetSubjects 获取科目列表
|
||||
func (s *SubjectService) GetSubjects(isActive *bool) (map[string]interface{}, error) {
|
||||
subjects, err := s.subjectRepo.GetAll(isActive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{
|
||||
"subjects": subjects,
|
||||
"total": len(subjects),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CreateSubject 创建科目
|
||||
func (s *SubjectService) CreateSubject(subjectName string, subjectCode *string, sortOrder int) (map[string]interface{}, error) {
|
||||
existing, _ := s.subjectRepo.GetByName(subjectName)
|
||||
if existing != nil {
|
||||
return map[string]interface{}{"success": false, "message": "科目名称已存在"}, nil
|
||||
}
|
||||
|
||||
subject := &model.Subject{
|
||||
SubjectName: subjectName,
|
||||
SubjectCode: subjectCode,
|
||||
SortOrder: sortOrder,
|
||||
IsActive: 1,
|
||||
}
|
||||
|
||||
subjectID, err := s.subjectRepo.Create(subject)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
logger.Sugared.Infof("创建科目: %s", subjectName)
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"subject_id": subjectID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// UpdateSubject 更新科目
|
||||
func (s *SubjectService) UpdateSubject(subjectID int, updates map[string]interface{}) error {
|
||||
return s.subjectRepo.Update(subjectID, updates)
|
||||
}
|
||||
|
||||
// DisableSubject 禁用科目(将 is_active 设为 0,保留数据)
|
||||
func (s *SubjectService) DisableSubject(subjectID int) error {
|
||||
return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 0})
|
||||
}
|
||||
|
||||
// EnableSubject 启用科目(将 is_active 设为 1)
|
||||
func (s *SubjectService) EnableSubject(subjectID int) error {
|
||||
return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 1})
|
||||
}
|
||||
|
||||
// DeleteSubject 物理删除科目(需先检查关联数据)
|
||||
func (s *SubjectService) DeleteSubject(subjectID int) error {
|
||||
hasData, _ := s.subjectRepo.HasRelatedData(subjectID)
|
||||
if hasData {
|
||||
return fmt.Errorf("该科目下已有作业数据,无法删除")
|
||||
}
|
||||
return s.subjectRepo.Delete(subjectID)
|
||||
}
|
||||
158
backend-go/internal/service/super_admin_service.go
Normal file
158
backend-go/internal/service/super_admin_service.go
Normal file
@@ -0,0 +1,158 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - Go 后端
|
||||
//
|
||||
// 开发者: Canglan
|
||||
// 联系方式: admin@sea-studio.top
|
||||
// 版权归属: Sea Network Technology Studio
|
||||
// 许可证: Apache License 2.0
|
||||
//
|
||||
// 版权所有 © Sea Network Technology Studio
|
||||
// ===========================================
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/crypto"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
|
||||
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/logger"
|
||||
)
|
||||
|
||||
// SuperAdminService 超级管理员服务
|
||||
type SuperAdminService struct {
|
||||
superAdminRepo *repository.SuperAdminRepo
|
||||
logService *LogService
|
||||
}
|
||||
|
||||
// NewSuperAdminService 创建超级管理员服务
|
||||
func NewSuperAdminService(superAdminRepo *repository.SuperAdminRepo, logService *LogService) *SuperAdminService {
|
||||
return &SuperAdminService{superAdminRepo: superAdminRepo, logService: logService}
|
||||
}
|
||||
|
||||
// EnsureDefaultAdmin 确保默认超级管理员存在
|
||||
func (s *SuperAdminService) EnsureDefaultAdmin() error {
|
||||
cfg := config.AppConfig
|
||||
|
||||
logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务")
|
||||
|
||||
// 为超级管理员生成独立的随机 Salt
|
||||
salt, err := crypto.GenerateRandomPassword(16)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成随机盐值失败: %w", err)
|
||||
}
|
||||
passwordHash := crypto.HashPassword(cfg.SuperAdminDefaultPass, salt)
|
||||
if err := s.superAdminRepo.EnsureDefaultAdmin(
|
||||
cfg.SuperAdminDefaultUser,
|
||||
passwordHash,
|
||||
salt,
|
||||
"系统管理员",
|
||||
); err != nil {
|
||||
return fmt.Errorf("创建默认超级管理员失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Login 超级管理员登录
|
||||
func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map[string]interface{}, error) {
|
||||
ctx := context.Background()
|
||||
cfg := config.AppConfig
|
||||
|
||||
// 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态)
|
||||
attemptsKey := fmt.Sprintf("login_attempts:sa:%s", username)
|
||||
ipAttemptsKey := fmt.Sprintf("login_attempts:ip:super_admin:%s", ip)
|
||||
|
||||
count, _ := incrWithExpireAtomic(ctx, attemptsKey, 300)
|
||||
if count > 5 {
|
||||
return map[string]interface{}{"success": false, "message": "登录失败次数过多,请5分钟后重试"}, nil
|
||||
}
|
||||
// IP 级限流
|
||||
ipCount, _ := incrWithExpireAtomic(ctx, ipAttemptsKey, 300)
|
||||
if ipCount > 20 {
|
||||
return map[string]interface{}{"success": false, "message": "登录失败次数过多,请5分钟后重试"}, nil
|
||||
}
|
||||
|
||||
admin, err := s.superAdminRepo.GetByUsername(username)
|
||||
if err != nil {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||||
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
|
||||
}
|
||||
|
||||
if !crypto.VerifyPassword(password, admin.PasswordHash, admin.Salt) {
|
||||
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
|
||||
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
|
||||
}
|
||||
|
||||
// 清除用户名级登录失败记录,IP 级计数由 TTL 自然过期(与普通用户策略一致,防止同 IP 其他用户限流被重置)
|
||||
database.RDB.Del(ctx, attemptsKey)
|
||||
s.logService.WriteLoginLog(username, 1, ip, userAgent, "")
|
||||
|
||||
// 生成 Token
|
||||
token, err := appJwt.CreateToken(
|
||||
admin.ID, admin.Username, "super_admin",
|
||||
nil, "系统管理员", admin.RealName, nil, false,
|
||||
)
|
||||
if err != nil {
|
||||
return map[string]interface{}{"success": false, "message": "生成令牌失败"}, nil
|
||||
}
|
||||
|
||||
_ = database.SetUserToken(ctx, admin.ID, token, cfg.JWTIdleTimeoutMinutes)
|
||||
|
||||
needChangePassword := admin.NeedChangePassword == 1
|
||||
redirect := "/admin/dashboard.php"
|
||||
if needChangePassword {
|
||||
redirect = "/admin/password.php"
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"user_id": admin.ID,
|
||||
"username": admin.Username,
|
||||
"real_name": admin.RealName,
|
||||
"user_type": "super_admin",
|
||||
"need_change_password": needChangePassword,
|
||||
"redirect": redirect,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ChangePassword 超级管理员修改密码(操作 super_admins 表,使用独立 salt)
|
||||
func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error {
|
||||
admin, err := s.superAdminRepo.GetByID(adminID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("超级管理员不存在")
|
||||
}
|
||||
|
||||
// 验证原密码(强制改密时跳过)
|
||||
if !force {
|
||||
if !crypto.VerifyPassword(oldPassword, admin.PasswordHash, admin.Salt) {
|
||||
return fmt.Errorf("原密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证新密码强度
|
||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
// 生成新的独立 salt
|
||||
newSalt, err := crypto.GenerateRandomPassword(16)
|
||||
if err != nil {
|
||||
return fmt.Errorf("生成随机盐值失败: %w", err)
|
||||
}
|
||||
newHash := crypto.HashPassword(newPassword, newSalt)
|
||||
|
||||
if err := s.superAdminRepo.UpdatePasswordWithSalt(adminID, newHash, newSalt); err != nil {
|
||||
return fmt.Errorf("密码修改失败")
|
||||
}
|
||||
|
||||
// 清除旧 Token,强制重新登录
|
||||
ctx := context.Background()
|
||||
_ = database.DeleteUserToken(ctx, adminID)
|
||||
|
||||
return nil
|
||||
}
|
||||
25
backend-go/internal/service/utils.go
Normal file
25
backend-go/internal/service/utils.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package service
|
||||
|
||||
// derefInt 安全解引用 int 指针
|
||||
func derefInt(i *int) int {
|
||||
if i == nil {
|
||||
return 0
|
||||
}
|
||||
return *i
|
||||
}
|
||||
|
||||
// derefStr 安全解引用字符串指针
|
||||
func derefStr(s *string) string {
|
||||
if s == nil {
|
||||
return ""
|
||||
}
|
||||
return *s
|
||||
}
|
||||
|
||||
// intPtr 辅助函数:int 转指针(0 返回 nil)
|
||||
func intPtr(i int) *int {
|
||||
if i == 0 {
|
||||
return nil
|
||||
}
|
||||
return &i
|
||||
}
|
||||
Reference in New Issue
Block a user