Files
SharedClassManager/backend-go/internal/service/admin_service.go
canglan 4a82eff3c6 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 04:41:49 +08:00

463 lines
15 KiB
Go
Raw 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"
"regexp"
"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
}
// hashPassword 对密码进行 bcrypt 哈希,失败时 panic不应发生
func hashPasswordOrPanic(password string) string {
hash, err := crypto.HashPassword(password)
if err != nil {
logger.Sugared.Fatalf("密码哈希失败: %v", err)
}
return hash
}
// AddStudent 新增学生
func (s *AdminService) AddStudent(studentNo, name string, parentAccount *string, classID int, dormitoryNumber *string) (map[string]interface{}, error) {
// 校验宿舍号格式
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 := hashPasswordOrPanic(defaultPassword)
_, 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 := hashPasswordOrPanic(defaultPassword)
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) {
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 := hashPasswordOrPanic(password)
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 := hashPasswordOrPanic(password)
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)
}
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, err := crypto.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("密码加密失败")
}
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) {
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, err := crypto.HashPassword(password)
if err != nil {
return nil, fmt.Errorf("密码加密失败: %w", err)
}
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)
}
passwordHash, err := crypto.HashPassword(newPassword)
if err != nil {
return fmt.Errorf("密码加密失败")
}
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()
}