feat: 多班级版 v2.0 - Go后端重写 + 43轮代码审查

- 后端从 Python FastAPI 重写为 Go Gin(端口 56789)
- 多班级完全隔离
- 超级管理员独立登录
- 课代表作业管理、排行榜分项排行
- 角色加减分上下限可配置
- 家长改密功能(可开关)
- 周度/月度重置功能
- MySQL 5.7 兼容
- 43轮代码审查+全部修复
- Apache 2.0 许可证
This commit is contained in:
2026-06-22 10:06:10 +08:00
parent 4084afc53c
commit d6dec878bd
214 changed files with 12622 additions and 9725 deletions

View 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()
}