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:
452
backend-go/internal/service/admin_service.go
Normal file
452
backend-go/internal/service/admin_service.go
Normal 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()
|
||||
}
|
||||
Reference in New Issue
Block a user