技术栈: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
453 lines
15 KiB
Go
453 lines
15 KiB
Go
// ===========================================
|
||
// 多班级版班级管理系统 - 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()
|
||
}
|