// =========================================== // 多班级版班级管理系统 - 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 "/" } }