feat: 多班级版班级管理系统 v2.0
技术栈:Go (Gin + GORM) + PHP + MySQL 5.7 + Redis 主要功能: - 多班级完全隔离(class_id 贯穿全系统) - 后端 Go Gin(端口 56789),Nginx 反代 - 超级管理员独立登录(env 配置,默认账密 admin/Admin123) - bcrypt 密码加密(无 PASSWORD_SALT) - 科任老师/课代表新角色 - 课代表作业管理页面 - 排行榜分项排行(操行分/考勤/作业) - 角色加减分上下限由班主任配置 - 家长改密功能(可开关) - 班级角色按需开关 - 宿舍号格式:南0-000 - 周度/月度重置功能 - MySQL 5.7 兼容 - 43 轮代码审查 + 全部修复 开发者: Canglan 版权归属: Sea Network Technology Studio 许可证: Apache License 2.0
This commit is contained in:
459
backend-go/internal/service/auth_service.go
Normal file
459
backend-go/internal/service/auth_service.go
Normal file
@@ -0,0 +1,459 @@
|
||||
// ===========================================
|
||||
// 多班级版班级管理系统 - 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/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"
|
||||
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
|
||||
)
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
// 验证密码(bcrypt)
|
||||
if !crypto.VerifyPassword(password, user.PasswordHash) {
|
||||
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) {
|
||||
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) {
|
||||
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 {
|
||||
user, err := s.userRepo.GetByUserID(userID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("用户不存在")
|
||||
}
|
||||
|
||||
// 验证原密码(强制改密时跳过)
|
||||
if !force {
|
||||
if !crypto.VerifyPassword(oldPassword, user.PasswordHash) {
|
||||
return fmt.Errorf("原密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 验证新密码强度
|
||||
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
|
||||
return fmt.Errorf("%s", msg)
|
||||
}
|
||||
|
||||
// 更新密码
|
||||
newHash, err := crypto.HashPassword(newPassword)
|
||||
if err != nil {
|
||||
return fmt.Errorf("密码加密失败")
|
||||
}
|
||||
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 "/"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user