feat: 多班级版班级管理系统 v2.0

技术栈:Go (Gin + GORM) + PHP + MySQL 5.7 + Redis

主要功能:
- 多班级完全隔离(class_id 贯穿全系统)
- 后端从 Python FastAPI 重写为 Go Gin(端口 56789)
- 超级管理员独立登录(env 配置路径,默认账密 admin/Admin123)
- 科任老师/课代表新角色
- 课代表作业管理页面
- 排行榜分项排行(操行分/考勤/作业)
- 角色加减分上下限由班主任配置
- 家长改密功能(可开关)
- 班级角色按需开关
- 宿舍号格式:南0-000
- 周度/月度重置功能
- MySQL 5.7 兼容
- Nginx 反向代理部署

开发者: Canglan
版权归属: Sea Network Technology Studio
许可证: Apache License 2.0
This commit is contained in:
2026-06-22 10:21:52 +08:00
commit 124d7f645e
140 changed files with 21103 additions and 0 deletions

View 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 "/"
}
}