Files
canglan c6db68a9f4 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
2026-06-23 16:02:28 +08:00

460 lines
14 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===========================================
// 多班级版班级管理系统 - 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 "/"
}
}