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

View File

@@ -0,0 +1,226 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"fmt"
"strconv"
"time"
"gorm.io/gorm"
"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/logger"
)
// AttendanceService 考勤服务
type AttendanceService struct {
attendanceRepo *repository.AttendanceRepo
studentRepo *repository.StudentRepo
userRepo *repository.UserRepo
conductRepo *repository.ConductRepo
semesterRepo *repository.SemesterRepo
settingRepo *repository.SystemSettingRepo
classRepo *repository.ClassRepo
}
// NewAttendanceService 创建考勤服务
func NewAttendanceService(
attendanceRepo *repository.AttendanceRepo,
studentRepo *repository.StudentRepo,
userRepo *repository.UserRepo,
conductRepo *repository.ConductRepo,
semesterRepo *repository.SemesterRepo,
settingRepo *repository.SystemSettingRepo,
classRepo *repository.ClassRepo,
) *AttendanceService {
return &AttendanceService{
attendanceRepo: attendanceRepo,
studentRepo: studentRepo,
userRepo: userRepo,
conductRepo: conductRepo,
semesterRepo: semesterRepo,
settingRepo: settingRepo,
classRepo: classRepo,
}
}
// CreateRecord 创建考勤记录
func (s *AttendanceService) CreateRecord(studentID int, dateStr, slot, status string, reason *string,
applyDeduction bool, customDeduction *int, recorderID int, classID int) (map[string]interface{}, error) {
// 校验学生是否属于当前班级(#7
student, err := s.studentRepo.GetByID(studentID)
if err != nil || student == nil || student.ClassID != classID {
return map[string]interface{}{"success": false, "message": "学生不属于当前班级"}, nil
}
// 解析日期
parsedDate, err := time.Parse("2006-01-02", dateStr)
if err != nil {
return map[string]interface{}{"success": false, "message": "日期格式错误"}, nil
}
// 获取活跃学期
var semesterID *int
activeSemester, _ := s.semesterRepo.GetActive()
if activeSemester != nil {
semesterID = &activeSemester.SemesterID
}
record := &model.AttendanceRecord{
StudentID: studentID,
Date: parsedDate,
Slot: slot,
Status: status,
Reason: reason,
RecorderID: recorderID,
SemesterID: semesterID,
}
createResult, err := s.attendanceRepo.CreateRecord(record)
if err != nil {
return map[string]interface{}{"success": false, "message": "添加考勤记录失败"}, nil
}
attendanceID := createResult.AttendanceID
// 更新已有记录时,先撤销旧扣分再应用新扣分
if createResult.IsUpdate && createResult.OldDeductionApplied == 1 && createResult.OldDeductionRecordID != nil {
if err := s.conductRepo.RevokeRecord(*createResult.OldDeductionRecordID, recorderID); err != nil {
logger.Sugared.Errorf("撤销旧考勤扣分失败: attendance_id=%d, old_record_id=%d, err=%v",
attendanceID, *createResult.OldDeductionRecordID, err)
return nil, fmt.Errorf("撤销旧扣分失败,操作已中止以避免双重扣分: %w", err)
}
}
// 应用扣分(事务保护,避免数据不一致)
if applyDeduction && (status == "absent" || status == "late" || status == "leave") {
// 校验自定义扣分值必须为非负数
if customDeduction != nil && *customDeduction < 0 {
return map[string]interface{}{"success": false, "message": "自定义扣分值不能为负数"}, nil
}
var pointsChange int
if customDeduction != nil {
pointsChange = -*customDeduction
} else {
pointsChange = s.getDeductionPoints(classID, status)
}
if pointsChange == 0 {
return map[string]interface{}{"success": true, "message": "考勤记录添加成功(不扣分)"}, nil
}
// 获取操作人姓名
recorderName := "班主任"
user, err := s.userRepo.GetByUserID(recorderID)
if err == nil && user != nil {
recorderName = user.RealName
}
statusText := map[string]string{
"absent": "缺勤", "late": "迟到", "leave": "请假",
}[status]
// 使用事务确保操行分记录创建、总分更新、考勤标记的原子性
db := s.semesterRepo.GetDB()
txErr := db.Transaction(func(tx *gorm.DB) error {
conductRecord := &model.ConductRecord{
StudentID: studentID,
PointsChange: pointsChange,
Reason: fmt.Sprintf("考勤:%s", statusText),
RecorderID: recorderID,
RecorderName: &recorderName,
RelatedType: "attendance",
RelatedID: &attendanceID,
SemesterID: semesterID,
}
if err := tx.Create(conductRecord).Error; err != nil {
return err
}
if err := tx.Model(&model.Student{}).
Where("student_id = ?", studentID).
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil {
return err
}
if err := tx.Model(&model.AttendanceRecord{}).
Where("attendance_id = ?", attendanceID).
Updates(map[string]interface{}{
"deduction_applied": 1,
"deduction_record_id": conductRecord.RecordID,
}).Error; err != nil {
return err
}
return nil
})
if txErr != nil {
logger.Sugared.Errorf("考勤扣分事务失败: attendance_id=%d, student_id=%d, err=%v", attendanceID, studentID, txErr)
return map[string]interface{}{
"success": false,
"message": "考勤记录添加成功,但扣分失败,请手动处理",
"attendance_id": attendanceID,
"deduction_failed": true,
}, nil
}
logger.Sugared.Infof("用户[%d] 添加考勤记录[%d] -> %s (扣%d分)", recorderID, attendanceID, status, -pointsChange)
}
return map[string]interface{}{"success": true, "message": "考勤记录添加成功"}, nil
}
// GetRecords 获取考勤记录
func (s *AttendanceService) GetRecords(classID int, date string, studentID *int, slot string) (map[string]interface{}, error) {
records, err := s.attendanceRepo.GetClassRecords(classID, date, derefInt(studentID), slot)
if err != nil {
return nil, err
}
return map[string]interface{}{"records": records}, nil
}
// getClassSettingValue 从 class_settings 读取设置值,若无则返回默认值
func (s *AttendanceService) getClassSettingValue(classID int, key, defaultVal string) string {
if classID > 0 && s.classRepo != nil {
setting, err := s.classRepo.GetSetting(classID, key)
if err == nil && setting != nil && setting.SettingValue != "" {
return setting.SettingValue
}
}
return defaultVal
}
// getDeductionPoints 获取考勤扣分数值(优先从 class_settings 读取班级级配置)
func (s *AttendanceService) getDeductionPoints(classID int, status string) int {
switch status {
case "absent":
val := s.getClassSettingValue(classID, "deduction_attendance_absent", "3")
if v, err := strconv.Atoi(val); err == nil {
return -v
}
return -3
case "late":
val := s.getClassSettingValue(classID, "deduction_attendance_late", "1")
if v, err := strconv.Atoi(val); err == nil {
return -v
}
return -1
case "leave":
val := s.getClassSettingValue(classID, "deduction_attendance_leave", "0")
if v, err := strconv.Atoi(val); err == nil {
return -v
}
return 0
default:
return 0
}
}

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

View File

@@ -0,0 +1,224 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"context"
"fmt"
"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"
appJwt "hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/jwt"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/pkg/database"
)
// ClassService 班级服务
type ClassService struct {
classRepo *repository.ClassRepo
userRepo *repository.UserRepo
adminRoleRepo *repository.AdminRoleRepo
}
// NewClassService 创建班级服务
func NewClassService(
classRepo *repository.ClassRepo,
userRepo *repository.UserRepo,
adminRoleRepo *repository.AdminRoleRepo,
) *ClassService {
return &ClassService{
classRepo: classRepo,
userRepo: userRepo,
adminRoleRepo: adminRoleRepo,
}
}
// ListClasses 获取班级列表
func (s *ClassService) ListClasses(includeDisabled bool) (map[string]interface{}, error) {
classes, err := s.classRepo.GetAll(includeDisabled)
if err != nil {
return nil, err
}
for i := range classes {
count, _ := s.classRepo.GetStudentCount(classes[i].ClassID)
classes[i].StudentCount = count
}
return map[string]interface{}{
"classes": classes,
"total": len(classes),
}, nil
}
// GetClassDetail 获取班级详情
func (s *ClassService) GetClassDetail(classID int) (map[string]interface{}, error) {
cls, err := s.classRepo.GetByID(classID)
if err != nil {
return nil, err
}
cls.StudentCount, _ = s.classRepo.GetStudentCount(classID)
return map[string]interface{}{"class": cls}, nil
}
// CreateClass 创建班级
func (s *ClassService) CreateClass(className string, grade, description *string) (map[string]interface{}, error) {
existing, _ := s.classRepo.GetByName(className)
if existing != nil {
return map[string]interface{}{"success": false, "message": "班级名称已存在"}, nil
}
cls := &model.Class{
ClassName: className,
Grade: grade,
Description: description,
Status: 1,
}
classID, err := s.classRepo.Create(cls)
if err != nil {
return nil, err
}
return map[string]interface{}{
"success": true,
"class_id": classID,
"message": "班级创建成功",
}, nil
}
// UpdateClass 更新班级
func (s *ClassService) UpdateClass(classID int, className, grade, description *string, status *int8) error {
existing, err := s.classRepo.GetByID(classID)
if err != nil {
return fmt.Errorf("班级不存在")
}
updates := make(map[string]interface{})
if className != nil && *className != existing.ClassName {
nameExists, _ := s.classRepo.GetByName(*className)
if nameExists != nil {
return fmt.Errorf("班级名称已存在")
}
updates["class_name"] = *className
}
if grade != nil {
updates["grade"] = *grade
}
if description != nil {
updates["description"] = *description
}
if status != nil {
updates["status"] = *status
}
return s.classRepo.Update(classID, updates)
}
// DeleteClass 删除班级
func (s *ClassService) DeleteClass(classID int) error {
hasStudents, _ := s.classRepo.HasActiveStudents(classID)
if hasStudents {
return fmt.Errorf("该班级下还有学生,无法删除")
}
return s.classRepo.Delete(classID)
}
// SwitchClass 切换班级上下文(超级管理员)
func (s *ClassService) SwitchClass(userID int, classID int) (map[string]interface{}, error) {
cfg := config.AppConfig
cls, err := s.classRepo.GetByID(classID)
if err != nil {
return nil, fmt.Errorf("班级不存在")
}
user, err := s.userRepo.GetByUserID(userID)
if err != nil {
return nil, fmt.Errorf("用户不存在")
}
// 查询目标班级中该用户的角色
var role string
if user.UserType == "super_admin" {
role = "系统管理员"
} else {
adminRole, _ := s.adminRoleRepo.GetByUserIDAndClass(userID, classID)
if adminRole != nil {
role = adminRole.RoleType
}
}
// 生成新 Token更新 class_id
token, err := appJwt.CreateToken(
user.UserID, user.Username, user.UserType,
user.StudentID, role, user.RealName, &classID,
user.NeedChangePassword == 1,
)
if err != nil {
return nil, fmt.Errorf("生成令牌失败")
}
ctx := context.Background()
_ = database.SetUserToken(ctx, userID, token, cfg.JWTIdleTimeoutMinutes)
return map[string]interface{}{
"token": token,
"class_id": classID,
"class_name": cls.ClassName,
}, nil
}
// GetSettings 获取班级设置
func (s *ClassService) GetSettings(classID int) (map[string]interface{}, error) {
settings, err := s.classRepo.GetSettings(classID)
if err != nil {
return nil, err
}
result := make(map[string]string)
for _, setting := range settings {
result[setting.SettingKey] = setting.SettingValue
}
return map[string]interface{}{"settings": result}, nil
}
// SaveSetting 保存班级设置
func (s *ClassService) SaveSetting(classID int, key, value string) error {
return s.classRepo.SaveSetting(classID, key, value)
}
// GetFeatures 获取班级功能开关
func (s *ClassService) GetFeatures(classID int) (map[string]interface{}, error) {
features, err := s.classRepo.GetFeatures(classID)
if err != nil {
return nil, err
}
result := make(map[string]int8)
for _, f := range features {
result[f.FeatureKey] = f.Enabled
}
return map[string]interface{}{"features": result}, nil
}
// SaveFeature 保存班级功能开关
func (s *ClassService) SaveFeature(classID int, featureKey string, enabled int8) error {
return s.classRepo.SaveFeature(classID, featureKey, enabled)
}
// IsFeatureEnabled 检查功能开关是否启用
func (s *ClassService) IsFeatureEnabled(classID int, featureKey string) bool {
feature, err := s.classRepo.GetFeature(classID, featureKey)
if err != nil || feature == nil {
return true // 默认启用
}
return feature.Enabled == 1
}

View File

@@ -0,0 +1,384 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"fmt"
"strconv"
"gorm.io/gorm"
"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/logger"
)
// ConductService 操行分服务
type ConductService struct {
conductRepo *repository.ConductRepo
studentRepo *repository.StudentRepo
adminRoleRepo *repository.AdminRoleRepo
semesterRepo *repository.SemesterRepo
classRepo *repository.ClassRepo
}
// NewConductService 创建操行分服务
func NewConductService(
conductRepo *repository.ConductRepo,
studentRepo *repository.StudentRepo,
adminRoleRepo *repository.AdminRoleRepo,
semesterRepo *repository.SemesterRepo,
classRepo *repository.ClassRepo,
) *ConductService {
return &ConductService{
conductRepo: conductRepo,
studentRepo: studentRepo,
adminRoleRepo: adminRoleRepo,
semesterRepo: semesterRepo,
classRepo: classRepo,
}
}
// AddPoints 批量加减分
func (s *ConductService) AddPoints(studentIDs []int, pointsChange int, reason string,
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
// 输入校验
if len(studentIDs) == 0 || len(studentIDs) > 200 {
return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil
}
if reason == "" || len(reason) > 255 {
return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil
}
if pointsChange == 0 || absInt(pointsChange) > 100 {
return map[string]interface{}{"success": false, "message": "分值无效"}, nil
}
// 获取操作人角色
role, _, err := s.adminRoleRepo.GetUserRoleAndClassID(recorderID)
if err != nil {
return map[string]interface{}{"success": false, "message": "获取操作人角色失败"}, nil
}
// 权限验证(从 class_settings 读取限制,这里使用默认值)
if err := s.validatePointsPermission(role, pointsChange, classID); err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}, nil
}
return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType)
}
// CadreAddPoints 课代表专用加减分(跳过角色权限验证,仅限作业相关扣分)
func (s *ConductService) CadreAddPoints(studentIDs []int, pointsChange int, reason string,
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
// 输入校验
if len(studentIDs) == 0 || len(studentIDs) > 200 {
return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil
}
if reason == "" || len(reason) > 255 {
return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil
}
if pointsChange >= 0 || absInt(pointsChange) > 100 {
return map[string]interface{}{"success": false, "message": "课代表只能进行扣分操作"}, nil
}
// 强制设置为作业类型
relatedType = "homework"
return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType)
}
// addPointsInternal 批量加减分内部实现
func (s *ConductService) addPointsInternal(studentIDs []int, pointsChange int, reason string,
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
// 自动获取当前活跃学期
activeSemester, semErr := s.semesterRepo.GetActive()
if semErr != nil {
logger.Sugared.Warnf("获取活跃学期失败,操行分将不关联学期: %v", semErr)
}
var semesterID *int
if activeSemester != nil {
semesterID = &activeSemester.SemesterID
}
if relatedType == "" {
relatedType = "manual"
}
successCount := 0
failCount := 0
var details []map[string]interface{}
db := s.semesterRepo.GetDB()
for _, studentID := range studentIDs {
// 检查学生是否存在
student, err := s.studentRepo.GetByID(studentID)
if err != nil || student == nil {
failCount++
details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不存在"})
continue
}
// 校验学生是否属于当前班级
if student.ClassID != classID {
failCount++
details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不属于当前班级"})
continue
}
// 使用事务确保记录创建和总分更新的原子性(#3
recordID, txErr := func() (int64, error) {
var rid int64
txErr := db.Transaction(func(tx *gorm.DB) error {
record := &model.ConductRecord{
StudentID: studentID,
PointsChange: pointsChange,
Reason: reason,
RecorderID: recorderID,
RecorderName: &recorderName,
RelatedType: relatedType,
SemesterID: semesterID,
}
if err := tx.Create(record).Error; err != nil {
return err
}
rid = record.RecordID
if err := tx.Model(&model.Student{}).
Where("student_id = ?", studentID).
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", pointsChange)).Error; err != nil {
return err
}
return nil
})
return rid, txErr
}()
if txErr != nil {
failCount++
details = append(details, map[string]interface{}{"student_id": studentID, "error": txErr.Error()})
continue
}
successCount++
details = append(details, map[string]interface{}{"student_id": studentID, "success": true, "record_id": recordID})
logger.Sugared.Infof("用户[%d] 对学生[%d] 进行 %d 分操作", recorderID, studentID, pointsChange)
}
return map[string]interface{}{
"success": failCount == 0,
"success_count": successCount,
"fail_count": failCount,
"details": details,
}, nil
}
// RevokeRecord 撤销记录(事务保护,避免并发重复撤销)
func (s *ConductService) RevokeRecord(recordID int64, revokerID int, classID int) (map[string]interface{}, error) {
record, err := s.conductRepo.GetRecordByID(recordID)
if err != nil || record == nil {
return map[string]interface{}{"success": false, "message": "记录不存在"}, nil
}
// 校验记录所属学生是否在当前操作者的班级中
student, _ := s.studentRepo.GetByID(record.StudentID)
if student == nil || student.ClassID != classID {
return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil
}
if record.IsRevoked == 1 {
return map[string]interface{}{"success": false, "message": "该记录已被撤销"}, nil
}
db := s.semesterRepo.GetDB()
txErr := db.Transaction(func(tx *gorm.DB) error {
// 撤销记录
if err := tx.Model(&model.ConductRecord{}).
Where("record_id = ? AND is_revoked = 0", recordID).
Updates(map[string]interface{}{
"is_revoked": 1,
"revoked_by": revokerID,
}).Error; err != nil {
return err
}
// 反向恢复学生总分(下限保护)
return tx.Model(&model.Student{}).
Where("student_id = ?", record.StudentID).
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", -record.PointsChange)).Error
})
if txErr != nil {
return map[string]interface{}{"success": false, "message": "撤销失败"}, nil
}
return map[string]interface{}{
"success": true,
"message": "撤销成功",
"record": map[string]interface{}{
"student_id": record.StudentID,
"recorder_name": derefStr(record.RecorderName),
"points_change": record.PointsChange,
},
}, nil
}
// RestoreRecord 反撤销记录(事务保护,避免并发重复恢复)
func (s *ConductService) RestoreRecord(recordID int64, restorerID int, classID int) (map[string]interface{}, error) {
record, err := s.conductRepo.GetRecordByID(recordID)
if err != nil || record == nil {
return map[string]interface{}{"success": false, "message": "记录不存在"}, nil
}
// 校验记录所属学生是否在当前操作者的班级中
student, _ := s.studentRepo.GetByID(record.StudentID)
if student == nil || student.ClassID != classID {
return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil
}
if record.IsRevoked == 0 {
return map[string]interface{}{"success": false, "message": "该记录未被撤销,无需恢复"}, nil
}
db := s.semesterRepo.GetDB()
txErr := db.Transaction(func(tx *gorm.DB) error {
// 反撤销
if err := tx.Model(&model.ConductRecord{}).
Where("record_id = ? AND is_revoked = 1", recordID).
Updates(map[string]interface{}{
"is_revoked": 0,
"revoked_by": nil,
"revoked_at": nil,
}).Error; err != nil {
return err
}
// 恢复学生总分(下限保护)
return tx.Model(&model.Student{}).
Where("student_id = ?", record.StudentID).
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", record.PointsChange)).Error
})
if txErr != nil {
return map[string]interface{}{"success": false, "message": "反撤销失败"}, nil
}
return map[string]interface{}{
"success": true,
"message": "反撤销成功",
}, nil
}
// GetHistory 获取操行分历史记录
func (s *ConductService) GetHistory(classID int, studentID *int, page, pageSize int,
startDate, endDate, relatedType, reasonPrefix string, isRevoked *int, reasonSearch string) (map[string]interface{}, error) {
includeRevoked := false
if isRevoked != nil && *isRevoked == 1 {
includeRevoked = true
}
offset := (page - 1) * pageSize
records, err := s.conductRepo.GetAllRecords(classID, pageSize, offset, startDate, endDate,
derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch)
if err != nil {
return nil, err
}
total, err := s.conductRepo.CountAllRecords(classID, startDate, endDate,
derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch)
if err != nil {
return nil, err
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return map[string]interface{}{
"records": records,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": totalPages,
}, nil
}
// validatePointsPermission 验证角色加减分权限
func (s *ConductService) validatePointsPermission(role string, pointsChange, classID int) error {
// 从 class_settings 读取配置,若无则使用默认值
maxPoints := func(key string, defaultVal int) int {
if classID > 0 {
setting, err := s.classRepo.GetSetting(classID, key)
if err == nil && setting != nil {
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
return v
}
}
}
return defaultVal
}
switch role {
case "班主任":
return nil // 无限制
case "班长":
maxAdd := maxPoints("point_limit_班长_max", 5)
maxSub := maxPoints("point_limit_班长_min", -5)
if pointsChange > maxAdd || pointsChange < maxSub {
return fmt.Errorf("班长单次只能加%d至减%d分以内", maxAdd, absInt(maxSub))
}
case "学习委员":
limit := maxPoints("point_limit_学习委员_max", 5)
if absInt(pointsChange) > limit {
return fmt.Errorf("学习委员单次只能加减%d分以内", limit)
}
case "科任老师":
limit := maxPoints("point_limit_科任老师_max", 5)
if absInt(pointsChange) > limit {
return fmt.Errorf("科任老师单次只能加减%d分以内", limit)
}
case "考勤委员":
if pointsChange > 0 {
return fmt.Errorf("考勤委员只能进行扣分操作")
}
limit := maxPoints("point_limit_考勤委员_max", 8)
if absInt(pointsChange) > limit {
return fmt.Errorf("考勤委员单次最多扣%d分", limit)
}
case "劳动委员":
limit := maxPoints("point_limit_劳动委员_max", 1)
if absInt(pointsChange) > limit {
return fmt.Errorf("劳动委员单次只能加减%d分以内", limit)
}
case "志愿委员":
if pointsChange < 0 {
return fmt.Errorf("志愿委员只能加分")
}
limit := maxPoints("point_limit_志愿委员_max", 5)
if pointsChange > limit {
return fmt.Errorf("志愿委员单次最多加%d分", limit)
}
case "课代表":
return fmt.Errorf("课代表无权进行此操作")
default:
return fmt.Errorf("无权进行此操作")
}
return nil
}
// absInt 取绝对值
func absInt(x int) int {
if x < 0 {
return -x
}
return x
}

View File

@@ -0,0 +1,49 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
)
// ConfigService 配置服务
type ConfigService struct {
classRepo *repository.ClassRepo
}
// NewConfigService 创建配置服务
func NewConfigService(classRepo *repository.ClassRepo) *ConfigService {
return &ConfigService{classRepo: classRepo}
}
// GetClassSettingValue 从 class_settings 读取设置值,若无则返回默认值
func (s *ConfigService) GetClassSettingValue(classID int, key, defaultVal string) string {
if classID > 0 && s.classRepo != nil {
setting, err := s.classRepo.GetSetting(classID, key)
if err == nil && setting != nil && setting.SettingValue != "" {
return setting.SettingValue
}
}
return defaultVal
}
// GetDeductionRules 获取扣分规则(优先从 class_settings 读取班级级配置)
func (s *ConfigService) GetDeductionRules(classID int) map[string]string {
return map[string]string{
"DEDUCTION_ATTENDANCE_ABSENT": s.GetClassSettingValue(classID, "deduction_attendance_absent", "3"),
"DEDUCTION_ATTENDANCE_LATE": s.GetClassSettingValue(classID, "deduction_attendance_late", "1"),
"DEDUCTION_ATTENDANCE_LEAVE": s.GetClassSettingValue(classID, "deduction_attendance_leave", "0"),
"STUDENT_INITIAL_POINTS": s.GetClassSettingValue(classID, "initial_points", "60"),
"DEDUCTION_HOMEWORK_NOT_SUBMIT": s.GetClassSettingValue(classID, "deduction_homework_not_submit", "2"),
"DEDUCTION_HOMEWORK_LATE": s.GetClassSettingValue(classID, "deduction_homework_late", "1"),
}
}

View File

@@ -0,0 +1,70 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"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/logger"
)
// LogService 日志服务
type LogService struct {
logRepo *repository.LogRepo
}
// NewLogService 创建日志服务
func NewLogService(logRepo *repository.LogRepo) *LogService {
return &LogService{logRepo: logRepo}
}
// WriteLoginLog 写入登录日志
func (s *LogService) WriteLoginLog(username string, loginResult int8, ip, userAgent, failReason string) {
log := &model.LoginLog{
Username: username,
LoginResult: loginResult,
IPAddress: stringPtr(ip),
UserAgent: stringPtr(userAgent),
FailReason: stringPtr(failReason),
}
if _, err := s.logRepo.CreateLoginLog(log); err != nil {
logger.Sugared.Errorf("写入登录日志失败: %v", err)
}
}
// WriteOperationLog 写入操作日志
func (s *LogService) WriteOperationLog(operatorID int, operatorName, operatorRole, operationType string,
targetType *string, targetID *int, details *string, ip *string, classID *int) {
log := &model.OperationLog{
OperatorID: operatorID,
OperatorName: stringPtr(operatorName),
OperatorRole: stringPtr(operatorRole),
OperationType: operationType,
TargetType: targetType,
TargetID: targetID,
Details: details,
IPAddress: ip,
ClassID: classID,
}
if _, err := s.logRepo.CreateOperationLog(log); err != nil {
logger.Sugared.Errorf("写入操作日志失败: %v", err)
}
}
// stringPtr 辅助函数:字符串转指针(空字符串返回 nil
func stringPtr(s string) *string {
if s == "" {
return nil
}
return &s
}

View File

@@ -0,0 +1,80 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
)
// RankingService 排行榜服务
type RankingService struct {
studentRepo *repository.StudentRepo
conductRepo *repository.ConductRepo
}
// NewRankingService 创建排行榜服务
func NewRankingService(
studentRepo *repository.StudentRepo,
conductRepo *repository.ConductRepo,
) *RankingService {
return &RankingService{
studentRepo: studentRepo,
conductRepo: conductRepo,
}
}
// GetRankings 获取排行榜
func (s *RankingService) GetRankings(classID int, rankType string, limit int) (map[string]interface{}, error) {
switch rankType {
case "attendance", "homework", "conduct":
return s.getTypedRanking(classID, rankType, limit)
default:
// 默认按操行分总分排行
ranking, err := s.studentRepo.GetRanking(classID, limit)
if err != nil {
return nil, err
}
totalStudents, _ := s.studentRepo.GetTotalCount(classID)
return map[string]interface{}{
"ranking": ranking,
"total_students": totalStudents,
"type": "all",
}, nil
}
}
// getTypedRanking 获取分项排行榜(使用 SQL 层聚合,避免全量加载)
func (s *RankingService) getTypedRanking(classID int, relatedType string, limit int) (map[string]interface{}, error) {
dbType := relatedType
if relatedType == "conduct" {
dbType = "manual"
}
results, err := s.conductRepo.GetStudentPointsByType(classID, dbType, limit)
if err != nil {
return nil, err
}
var rankings []map[string]interface{}
for _, r := range results {
rankings = append(rankings, map[string]interface{}{
"student_id": r.StudentID,
"student_no": r.StudentNo,
"name": r.Name,
"points": r.TotalPoints,
})
}
return map[string]interface{}{
"ranking": rankings,
"type": relatedType,
}, nil
}

View File

@@ -0,0 +1,665 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"fmt"
"strconv"
"time"
"gorm.io/gorm"
"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/logger"
)
// SemesterService 学期服务
type SemesterService struct {
semesterRepo *repository.SemesterRepo
studentRepo *repository.StudentRepo
classRepo *repository.ClassRepo
attendanceRepo *repository.AttendanceRepo
assignmentRepo *repository.AssignmentRepo
logService *LogService
}
// NewSemesterService 创建学期服务
func NewSemesterService(
semesterRepo *repository.SemesterRepo,
studentRepo *repository.StudentRepo,
classRepo *repository.ClassRepo,
attendanceRepo *repository.AttendanceRepo,
assignmentRepo *repository.AssignmentRepo,
logService *LogService,
) *SemesterService {
return &SemesterService{
semesterRepo: semesterRepo,
studentRepo: studentRepo,
classRepo: classRepo,
attendanceRepo: attendanceRepo,
assignmentRepo: assignmentRepo,
logService: logService,
}
}
// ListSemesters 获取学期列表
func (s *SemesterService) ListSemesters() (map[string]interface{}, error) {
semesters, err := s.semesterRepo.GetAll()
if err != nil {
return nil, err
}
today := time.Now()
for i := range semesters {
conductCount, attendanceCount, _ := s.semesterRepo.CountRecordsBySemester(semesters[i].SemesterID)
semesters[i].ConductCount = conductCount
semesters[i].AttendanceCount = attendanceCount
// 计算当前周数
if semesters[i].IsActive == 1 && semesters[i].StartDate != nil {
delta := today.Sub(*semesters[i].StartDate).Hours() / (24 * 7)
if delta >= 0 {
week := int(delta) + 1
semesters[i].CurrentWeek = &week
}
}
}
return map[string]interface{}{
"semesters": semesters,
}, nil
}
// GetActiveSemester 获取当前活跃学期
func (s *SemesterService) GetActiveSemester() (*model.Semester, error) {
return s.semesterRepo.GetActive()
}
// CreateSemester 创建学期
func (s *SemesterService) CreateSemester(semesterName string, startDate, endDate *string) (map[string]interface{}, error) {
semester := &model.Semester{
SemesterName: semesterName,
IsActive: 0,
IsArchived: 0,
}
if startDate != nil && *startDate != "" {
t, err := time.Parse("2006-01-02", *startDate)
if err == nil {
semester.StartDate = &t
}
}
if endDate != nil && *endDate != "" {
t, err := time.Parse("2006-01-02", *endDate)
if err == nil {
semester.EndDate = &t
}
}
semesterID, err := s.semesterRepo.Create(semester)
if err != nil {
return map[string]interface{}{"success": false, "message": "创建学期失败"}, nil
}
// 如果日期范围包含今天,自动激活
if semester.StartDate != nil {
today := time.Now()
if semester.StartDate.Before(today) || sameDay(*semester.StartDate, today) {
if semester.EndDate == nil || semester.EndDate.After(today) || sameDay(*semester.EndDate, today) {
_ = s.semesterRepo.DeactivateAll()
_ = s.semesterRepo.Activate(semesterID)
}
}
}
return map[string]interface{}{
"success": true,
"message": "学期创建成功",
"semester_id": semesterID,
}, nil
}
// ActivateSemester 激活学期
func (s *SemesterService) ActivateSemester(semesterID int) error {
semester, err := s.semesterRepo.GetByID(semesterID)
if err != nil || semester == nil {
return fmt.Errorf("学期不存在")
}
if semester.IsArchived == 1 {
return fmt.Errorf("已归档的学期不能设为当前学期")
}
_ = s.semesterRepo.DeactivateAll()
return s.semesterRepo.Activate(semesterID)
}
// UpdateSemester 更新学期
func (s *SemesterService) UpdateSemester(semesterID int, semesterName, startDate, endDate *string) error {
semester, err := s.semesterRepo.GetByID(semesterID)
if err != nil || semester == nil {
return fmt.Errorf("学期不存在")
}
if semester.IsArchived == 1 {
return fmt.Errorf("已归档的学期不能编辑")
}
updates := make(map[string]interface{})
if semesterName != nil {
updates["semester_name"] = *semesterName
}
if startDate != nil {
t, err := time.Parse("2006-01-02", *startDate)
if err == nil {
updates["start_date"] = t
}
}
if endDate != nil {
t, err := time.Parse("2006-01-02", *endDate)
if err == nil {
updates["end_date"] = t
}
}
return s.semesterRepo.Update(semesterID, updates)
}
// DeleteSemester 删除学期
func (s *SemesterService) DeleteSemester(semesterID int) error {
archiveCount, err := s.semesterRepo.CountArchives(semesterID)
if err != nil {
return err
}
if archiveCount > 0 {
return fmt.Errorf("该学期有 %d 条归档数据,无法删除", archiveCount)
}
return s.semesterRepo.Delete(semesterID)
}
// AssociateRecords 关联记录到学期
func (s *SemesterService) AssociateRecords(semesterID int) (map[string]interface{}, error) {
semester, err := s.semesterRepo.GetByID(semesterID)
if err != nil || semester == nil {
return map[string]interface{}{"success": false, "message": "学期不存在"}, nil
}
if semester.IsArchived == 1 {
return map[string]interface{}{"success": false, "message": "已归档的学期不能关联数据"}, nil
}
if semester.StartDate == nil {
return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法关联数据"}, nil
}
startDate := semester.StartDate.Format("2006-01-02")
endDate := time.Now().Format("2006-01-02")
if semester.EndDate != nil {
endDate = semester.EndDate.Format("2006-01-02")
}
conductCount, attendanceCount, err := s.semesterRepo.AssociateRecordsByDateRange(semesterID, startDate, endDate)
if err != nil {
return map[string]interface{}{"success": false, "message": "关联记录失败"}, nil
}
return map[string]interface{}{
"success": true,
"message": fmt.Sprintf("关联完成:操行分 %d 条,考勤 %d 条", conductCount, attendanceCount),
"data": map[string]interface{}{
"conduct": conductCount,
"attendance": attendanceCount,
},
}, nil
}
// ArchiveSemester 归档学期
func (s *SemesterService) ArchiveSemester(semesterID, classID int, resetScores bool) (map[string]interface{}, error) {
semester, err := s.semesterRepo.GetByID(semesterID)
if err != nil || semester == nil {
return map[string]interface{}{"success": false, "message": "学期不存在"}, nil
}
if semester.IsArchived == 1 {
return map[string]interface{}{"success": false, "message": "该学期已归档"}, nil
}
if semester.StartDate == nil {
return map[string]interface{}{"success": false, "message": "学期未设置开始日期,无法进行归档"}, nil
}
if classID == 0 {
return map[string]interface{}{"success": false, "message": "未指定班级"}, nil
}
// 获取班级活跃学生
students, err := s.studentRepo.GetStudentsByClassID(classID)
if err != nil || len(students) == 0 {
return map[string]interface{}{"success": false, "message": "没有可归档的学生数据"}, nil
}
totalStudents := len(students)
// 查询考勤统计
startDate := semester.StartDate.Format("2006-01-02")
endDate := time.Now().Format("2006-01-02")
if semester.EndDate != nil {
endDate = semester.EndDate.Format("2006-01-02")
}
attendanceStats, _ := s.attendanceRepo.GetAttendanceStatsBySemester(semesterID, startDate, endDate)
attendanceMap := make(map[int]map[string]int64)
for _, stat := range attendanceStats {
if attendanceMap[stat.StudentID] == nil {
attendanceMap[stat.StudentID] = make(map[string]int64)
}
attendanceMap[stat.StudentID][stat.Status] = stat.Count
}
// 查询作业统计
homeworkStats, err := s.assignmentRepo.GetHomeworkStatsByDateRange(*semester.StartDate, time.Now())
if err != nil {
logger.Sugared.Warnf("查询作业统计失败,归档快照中作业数据可能不完整: %v", err)
}
homeworkMap := make(map[int]map[string]int64)
for _, stat := range homeworkStats {
if homeworkMap[stat.StudentID] == nil {
homeworkMap[stat.StudentID] = make(map[string]int64)
}
homeworkMap[stat.StudentID][stat.Status] = stat.Count
}
// 使用事务确保归档操作的原子性,并通过行锁防止并发归档
db := s.semesterRepo.GetDB()
txErr := db.Transaction(func(tx *gorm.DB) error {
// 使用 SELECT ... FOR UPDATE 锁定学期记录,防止并发归档
var lockedSemester model.Semester
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("semester_id = ?", semesterID).First(&lockedSemester).Error; err != nil {
return fmt.Errorf("锁定学期记录失败: %w", err)
}
if lockedSemester.IsArchived == 1 {
return fmt.Errorf("该学期已被其他操作归档")
}
// 删除旧的归档数据
if err := tx.Where("semester_id = ?", semesterID).Delete(&model.SemesterArchive{}).Error; err != nil {
return fmt.Errorf("删除旧归档数据失败: %w", err)
}
// 创建归档快照(填充考勤和作业统计)
var archives []model.SemesterArchive
for rank, stu := range students {
stuAttendance := attendanceMap[stu.StudentID]
stuHomework := homeworkMap[stu.StudentID]
archive := model.SemesterArchive{
SemesterID: semesterID,
ClassID: classID,
StudentID: stu.StudentID,
StudentNo: stu.StudentNo,
StudentName: stu.Name,
FinalPoints: stu.TotalPoints,
RankPosition: intPtr(rank + 1),
TotalStudents: &totalStudents,
AttendancePresent: int(stuAttendance["present"]),
AttendanceAbsent: int(stuAttendance["absent"]),
AttendanceLate: int(stuAttendance["late"]),
AttendanceLeave: int(stuAttendance["leave"]),
HomeworkSubmitted: int(stuHomework["submitted"]),
HomeworkNotSubmitted: int(stuHomework["not_submitted"]),
HomeworkLate: int(stuHomework["late"]),
}
archives = append(archives, archive)
}
if len(archives) > 0 {
if err := tx.Create(&archives).Error; err != nil {
return fmt.Errorf("创建归档快照失败: %w", err)
}
}
// 归档学期
if err := tx.Model(&model.Semester{}).
Where("semester_id = ? AND is_archived = 0", semesterID).
Updates(map[string]interface{}{"is_archived": 1, "is_active": 0}).Error; err != nil {
return fmt.Errorf("归档学期失败: %w", err)
}
// 重置分数(从 class_settings 读取初始分,若无则默认 60
if resetScores {
initialPoints := 60
var setting model.ClassSetting
if err := tx.Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil {
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
initialPoints = v
}
}
if err := tx.Model(&model.Student{}).
Where("class_id = ? AND status = 1", classID).
Update("total_points", initialPoints).Error; err != nil {
return fmt.Errorf("重置分数失败: %w", err)
}
}
return nil
})
if txErr != nil {
logger.Sugared.Errorf("归档事务失败: %v", txErr)
return map[string]interface{}{"success": false, "message": "归档失败: " + txErr.Error()}, nil
}
return map[string]interface{}{
"success": true,
"message": "归档成功",
}, nil
}
// GetArchiveRecords 获取归档数据
func (s *SemesterService) GetArchiveRecords(semesterID, classID, page, pageSize int) (map[string]interface{}, error) {
archives, total, err := s.semesterRepo.GetArchivesBySemester(semesterID, classID, page, pageSize)
if err != nil {
return nil, err
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return map[string]interface{}{
"items": archives,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": totalPages,
}, nil
}
// sameDay 判断两个时间是否同一天
func sameDay(a, b time.Time) bool {
return a.Year() == b.Year() && a.YearDay() == b.YearDay()
}
// ========== 周期重置功能 ==========
// PeriodReset 周度/月度重置
// 1. 创建当前操行分快照
// 2. 将所有学生操行分重置为 class_settings.initial_points
// 3. 记录操作日志
func (s *SemesterService) PeriodReset(classID int, period string, operatorID int, operatorName string, ip string) error {
periodLabel := generatePeriodLabel(period, time.Now())
// 读取初始分
initialPoints := 60
var setting model.ClassSetting
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil {
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
initialPoints = v
}
}
// 获取班级活跃学生
students, err := s.studentRepo.GetStudentsByClassID(classID)
if err != nil || len(students) == 0 {
return fmt.Errorf("没有可重置的学生数据")
}
totalStudents := len(students)
var archives []model.PeriodArchive
for rank, stu := range students {
archive := model.PeriodArchive{
ClassID: classID,
PeriodType: period,
PeriodLabel: periodLabel,
StudentID: stu.StudentID,
StudentNo: stu.StudentNo,
StudentName: stu.Name,
FinalPoints: stu.TotalPoints,
RankPosition: intPtr(rank + 1),
TotalStudents: &totalStudents,
ResetBy: "manual",
OperatorID: &operatorID,
}
archives = append(archives, archive)
}
// 使用事务确保原子性,并将存在检查移入事务内防止竞态条件
db := s.semesterRepo.GetDB()
txErr := db.Transaction(func(tx *gorm.DB) error {
// 在事务内检查本期是否已有归档数据(防并发重复重置)
var existCount int64
if err := tx.Model(&model.PeriodArchive{}).
Where("class_id = ? AND period_type = ? AND period_label = ?", classID, period, periodLabel).
Count(&existCount).Error; err != nil {
return fmt.Errorf("检查归档数据失败: %w", err)
}
if existCount > 0 {
return fmt.Errorf("当前周期(%s)已有归档数据,请勿重复重置", periodLabel)
}
// 创建归档快照
if len(archives) > 0 {
if err := tx.Create(&archives).Error; err != nil {
return fmt.Errorf("创建周期归档快照失败: %w", err)
}
}
// 重置分数
if err := tx.Model(&model.Student{}).
Where("class_id = ? AND status = 1", classID).
Update("total_points", initialPoints).Error; err != nil {
return fmt.Errorf("重置分数失败: %w", err)
}
return nil
})
if txErr != nil {
logger.Sugared.Errorf("周期重置事务失败: %v", txErr)
return txErr
}
// 记录操作日志
details := fmt.Sprintf("手动执行%s重置周期标签: %s影响学生数: %d", periodCN(period), periodLabel, totalStudents)
s.logService.WriteOperationLog(
operatorID, operatorName, "班主任", "period_reset",
nil, nil, &details, &ip, &classID,
)
return nil
}
// AutoPeriodReset 自动周期重置检查(由定时任务调用)
func (s *SemesterService) AutoPeriodReset() {
logger.Sugared.Info("开始检查自动周期重置...")
// 获取所有启用的班级
classes, err := s.classRepo.GetAll(false)
if err != nil {
logger.Sugared.Errorf("获取班级列表失败: %v", err)
return
}
now := time.Now()
for _, cls := range classes {
// 读取 reset_frequency
var freqSetting model.ClassSetting
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_frequency").First(&freqSetting).Error; err != nil {
continue // 无配置,跳过
}
freq := freqSetting.SettingValue
if freq == "none" || freq == "" {
continue
}
shouldReset := false
switch freq {
case "weekly":
// 读取 reset_day_of_week默认1=周一)
resetDay := 1
var daySetting model.ClassSetting
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_week").First(&daySetting).Error; err == nil {
if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 7 {
resetDay = v
}
}
// Go的Weekday: 0=Sunday, 1=Monday, ..., 6=Saturday
// 映射: 1=周一(1) ... 6=周六(6), 7=周日(0)
var targetWeekday time.Weekday
if resetDay == 7 {
targetWeekday = time.Sunday
} else {
targetWeekday = time.Weekday(resetDay)
}
if now.Weekday() == targetWeekday {
shouldReset = true
}
case "monthly":
// 读取 reset_day_of_month默认1
resetDay := 1
var daySetting model.ClassSetting
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", cls.ClassID, "reset_day_of_month").First(&daySetting).Error; err == nil {
if v, e := strconv.Atoi(daySetting.SettingValue); e == nil && v >= 1 && v <= 28 {
resetDay = v
}
}
if now.Day() == resetDay {
shouldReset = true
}
}
if !shouldReset {
continue
}
// 检查今天是否已经重置过
periodLabel := generatePeriodLabel(freq, now)
var existCount int64
if err := s.classRepo.GetDB().Model(&model.PeriodArchive{}).
Where("class_id = ? AND period_type = ? AND period_label = ? AND reset_by = ?",
cls.ClassID, freq, periodLabel, "auto").
Count(&existCount).Error; err != nil {
logger.Sugared.Errorf("检查班级 %d 周期归档失败: %v", cls.ClassID, err)
continue
}
if existCount > 0 {
logger.Sugared.Infof("班级 %d 本期(%s)已自动重置,跳过", cls.ClassID, periodLabel)
continue
}
// 执行自动重置
logger.Sugared.Infof("自动重置班级 %d (%s, %s)", cls.ClassID, cls.ClassName, periodLabel)
if err := s.autoPeriodResetClass(cls.ClassID, freq, periodLabel); err != nil {
logger.Sugared.Errorf("自动重置班级 %d 失败: %v", cls.ClassID, err)
}
}
}
// autoPeriodResetClass 单个班级的自动周期重置
func (s *SemesterService) autoPeriodResetClass(classID int, period, periodLabel string) error {
initialPoints := 60
var setting model.ClassSetting
if err := s.classRepo.GetDB().Where("class_id = ? AND setting_key = ?", classID, "initial_points").First(&setting).Error; err == nil {
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
initialPoints = v
}
}
students, err := s.studentRepo.GetStudentsByClassID(classID)
if err != nil || len(students) == 0 {
return fmt.Errorf("没有可重置的学生数据")
}
totalStudents := len(students)
var archives []model.PeriodArchive
for rank, stu := range students {
archive := model.PeriodArchive{
ClassID: classID,
PeriodType: period,
PeriodLabel: periodLabel,
StudentID: stu.StudentID,
StudentNo: stu.StudentNo,
StudentName: stu.Name,
FinalPoints: stu.TotalPoints,
RankPosition: intPtr(rank + 1),
TotalStudents: &totalStudents,
ResetBy: "auto",
}
archives = append(archives, archive)
}
db := s.semesterRepo.GetDB()
return db.Transaction(func(tx *gorm.DB) error {
if len(archives) > 0 {
if err := tx.Create(&archives).Error; err != nil {
return fmt.Errorf("创建周期归档快照失败: %w", err)
}
}
if err := tx.Model(&model.Student{}).
Where("class_id = ? AND status = 1", classID).
Update("total_points", initialPoints).Error; err != nil {
return fmt.Errorf("重置分数失败: %w", err)
}
return nil
})
}
// GetPeriodArchives 获取周期归档列表
func (s *SemesterService) GetPeriodArchives(classID int, period string, page, pageSize int) (map[string]interface{}, error) {
archives, total, err := s.semesterRepo.GetPeriodArchives(classID, period, page, pageSize)
if err != nil {
return nil, err
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return map[string]interface{}{
"items": archives,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": totalPages,
}, nil
}
// generatePeriodLabel 生成周期标签
func generatePeriodLabel(period string, t time.Time) string {
switch period {
case "weekly":
year, week := t.ISOWeek()
return fmt.Sprintf("%d-W%02d", year, week)
case "monthly":
return t.Format("2006-01")
default:
return t.Format("2006-01-02")
}
}
// periodCN 周期类型的中文描述
func periodCN(period string) string {
switch period {
case "weekly":
return "每周"
case "monthly":
return "每月"
default:
return period
}
}
// PeriodLabelCN 周期类型的中文标签(当前周期)
func PeriodLabelCN(period string) string {
switch period {
case "weekly":
return "本周"
case "monthly":
return "本月"
default:
return period
}
}

View File

@@ -0,0 +1,171 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/repository"
)
// StudentService 学生端服务
type StudentService struct {
studentRepo *repository.StudentRepo
conductRepo *repository.ConductRepo
attendanceRepo *repository.AttendanceRepo
semesterRepo *repository.SemesterRepo
}
// NewStudentService 创建学生端服务
func NewStudentService(
studentRepo *repository.StudentRepo,
conductRepo *repository.ConductRepo,
attendanceRepo *repository.AttendanceRepo,
semesterRepo *repository.SemesterRepo,
) *StudentService {
return &StudentService{
studentRepo: studentRepo,
conductRepo: conductRepo,
attendanceRepo: attendanceRepo,
semesterRepo: semesterRepo,
}
}
// GetStudentInfo 获取学生个人信息
func (s *StudentService) GetStudentInfo(studentID int) (map[string]interface{}, error) {
student, err := s.studentRepo.GetByID(studentID)
if err != nil {
return nil, err
}
return map[string]interface{}{
"student": student,
}, nil
}
// GetConductHistory 获取学生操行分历史
func (s *StudentService) GetConductHistory(studentID int, limit, offset int) (map[string]interface{}, error) {
student, err := s.studentRepo.GetByID(studentID)
if err != nil {
return nil, err
}
records, err := s.conductRepo.GetStudentRecords(studentID, limit, offset, false, "", "", 0)
if err != nil {
return nil, err
}
// 扣分项的操作人统一显示为"班主任"
for i := range records {
if records[i].PointsChange < 0 {
name := "班主任"
records[i].RecorderReal = &name
}
}
return map[string]interface{}{
"student_id": studentID,
"student_name": student.Name,
"total_points": student.TotalPoints,
"records": records,
}, nil
}
// GetHomeworkStatus 获取学生作业情况
func (s *StudentService) GetHomeworkStatus(studentID int) (map[string]interface{}, error) {
student, err := s.studentRepo.GetByID(studentID)
if err != nil {
return nil, err
}
records, err := s.conductRepo.GetStudentRecords(studentID, 1000, 0, false, "", "", 0)
if err != nil {
return nil, err
}
// 过滤出作业相关记录
var homeworkRecords []interface{}
for _, r := range records {
if r.RelatedType == "homework" {
homeworkRecords = append(homeworkRecords, r)
}
}
return map[string]interface{}{
"student_id": studentID,
"student_name": student.Name,
"homework": homeworkRecords,
}, nil
}
// GetAttendanceRecords 获取学生考勤记录
func (s *StudentService) GetAttendanceRecords(studentID int, month string) (map[string]interface{}, error) {
student, err := s.studentRepo.GetByID(studentID)
if err != nil {
return nil, err
}
records, err := s.attendanceRepo.GetStudentRecords(studentID, month)
if err != nil {
return nil, err
}
// 统计
present, absent, late, leave := 0, 0, 0, 0
for _, r := range records {
switch r.Status {
case "present":
present++
case "absent":
absent++
case "late":
late++
case "leave":
leave++
}
}
return map[string]interface{}{
"student_id": studentID,
"student_name": student.Name,
"statistics": map[string]interface{}{
"present": present,
"absent": absent,
"late": late,
"leave": leave,
"total": len(records),
},
"records": records,
}, nil
}
// GetRanking 获取排行榜
func (s *StudentService) GetRanking(classID int, limit int) (map[string]interface{}, error) {
ranking, err := s.studentRepo.GetRanking(classID, limit)
if err != nil {
return nil, err
}
totalStudents, _ := s.studentRepo.GetTotalCount(classID)
return map[string]interface{}{
"ranking": ranking,
"total_students": totalStudents,
}, nil
}
// GetSemesterRecords 获取学生学期归档记录
func (s *StudentService) GetSemesterRecords(studentID int) (map[string]interface{}, error) {
archives, err := s.semesterRepo.GetArchivesByStudent(studentID)
if err != nil {
return nil, err
}
return map[string]interface{}{
"records": archives,
}, nil
}

View File

@@ -0,0 +1,92 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"fmt"
"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/logger"
)
// SubjectService 科目服务
type SubjectService struct {
subjectRepo *repository.SubjectRepo
}
// NewSubjectService 创建科目服务
func NewSubjectService(subjectRepo *repository.SubjectRepo) *SubjectService {
return &SubjectService{subjectRepo: subjectRepo}
}
// GetSubjects 获取科目列表
func (s *SubjectService) GetSubjects(isActive *bool) (map[string]interface{}, error) {
subjects, err := s.subjectRepo.GetAll(isActive)
if err != nil {
return nil, err
}
return map[string]interface{}{
"subjects": subjects,
"total": len(subjects),
}, nil
}
// CreateSubject 创建科目
func (s *SubjectService) CreateSubject(subjectName string, subjectCode *string, sortOrder int) (map[string]interface{}, error) {
existing, _ := s.subjectRepo.GetByName(subjectName)
if existing != nil {
return map[string]interface{}{"success": false, "message": "科目名称已存在"}, nil
}
subject := &model.Subject{
SubjectName: subjectName,
SubjectCode: subjectCode,
SortOrder: sortOrder,
IsActive: 1,
}
subjectID, err := s.subjectRepo.Create(subject)
if err != nil {
return nil, err
}
logger.Sugared.Infof("创建科目: %s", subjectName)
return map[string]interface{}{
"success": true,
"subject_id": subjectID,
}, nil
}
// UpdateSubject 更新科目
func (s *SubjectService) UpdateSubject(subjectID int, updates map[string]interface{}) error {
return s.subjectRepo.Update(subjectID, updates)
}
// DisableSubject 禁用科目(将 is_active 设为 0保留数据
func (s *SubjectService) DisableSubject(subjectID int) error {
return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 0})
}
// EnableSubject 启用科目(将 is_active 设为 1
func (s *SubjectService) EnableSubject(subjectID int) error {
return s.subjectRepo.Update(subjectID, map[string]interface{}{"is_active": 1})
}
// DeleteSubject 物理删除科目(需先检查关联数据)
func (s *SubjectService) DeleteSubject(subjectID int) error {
hasData, _ := s.subjectRepo.HasRelatedData(subjectID)
if hasData {
return fmt.Errorf("该科目下已有作业数据,无法删除")
}
return s.subjectRepo.Delete(subjectID)
}

View File

@@ -0,0 +1,158 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"context"
"fmt"
"hz-gitea.sea-studio.top/canglan/SharedClassManager/internal/config"
"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"
)
// SuperAdminService 超级管理员服务
type SuperAdminService struct {
superAdminRepo *repository.SuperAdminRepo
logService *LogService
}
// NewSuperAdminService 创建超级管理员服务
func NewSuperAdminService(superAdminRepo *repository.SuperAdminRepo, logService *LogService) *SuperAdminService {
return &SuperAdminService{superAdminRepo: superAdminRepo, logService: logService}
}
// EnsureDefaultAdmin 确保默认超级管理员存在
func (s *SuperAdminService) EnsureDefaultAdmin() error {
cfg := config.AppConfig
logger.Sugared.Warnf("⚠️ 当前使用默认超级管理员密码,部署环境请务必修改 SUPER_ADMIN_DEFAULT_PASSWORD 并重启服务")
// 为超级管理员生成独立的随机 Salt
salt, err := crypto.GenerateRandomPassword(16)
if err != nil {
return fmt.Errorf("生成随机盐值失败: %w", err)
}
passwordHash := crypto.HashPassword(cfg.SuperAdminDefaultPass, salt)
if err := s.superAdminRepo.EnsureDefaultAdmin(
cfg.SuperAdminDefaultUser,
passwordHash,
salt,
"系统管理员",
); err != nil {
return fmt.Errorf("创建默认超级管理员失败: %w", err)
}
return nil
}
// Login 超级管理员登录
func (s *SuperAdminService) Login(username, password, ip, userAgent string) (map[string]interface{}, error) {
ctx := context.Background()
cfg := config.AppConfig
// 检查登录失败次数(用户名级 + IP 级双重限流,使用原子 Incr 防止 TOCTOU 竞态)
attemptsKey := fmt.Sprintf("login_attempts:sa:%s", username)
ipAttemptsKey := fmt.Sprintf("login_attempts:ip:super_admin:%s", ip)
count, _ := incrWithExpireAtomic(ctx, attemptsKey, 300)
if count > 5 {
return map[string]interface{}{"success": false, "message": "登录失败次数过多请5分钟后重试"}, nil
}
// IP 级限流
ipCount, _ := incrWithExpireAtomic(ctx, ipAttemptsKey, 300)
if ipCount > 20 {
return map[string]interface{}{"success": false, "message": "登录失败次数过多请5分钟后重试"}, nil
}
admin, err := s.superAdminRepo.GetByUsername(username)
if err != nil {
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
}
if !crypto.VerifyPassword(password, admin.PasswordHash, admin.Salt) {
s.logService.WriteLoginLog(username, 0, ip, userAgent, "用户名或密码错误")
return map[string]interface{}{"success": false, "message": "用户名或密码错误"}, nil
}
// 清除用户名级登录失败记录IP 级计数由 TTL 自然过期(与普通用户策略一致,防止同 IP 其他用户限流被重置)
database.RDB.Del(ctx, attemptsKey)
s.logService.WriteLoginLog(username, 1, ip, userAgent, "")
// 生成 Token
token, err := appJwt.CreateToken(
admin.ID, admin.Username, "super_admin",
nil, "系统管理员", admin.RealName, nil, false,
)
if err != nil {
return map[string]interface{}{"success": false, "message": "生成令牌失败"}, nil
}
_ = database.SetUserToken(ctx, admin.ID, token, cfg.JWTIdleTimeoutMinutes)
needChangePassword := admin.NeedChangePassword == 1
redirect := "/admin/dashboard.php"
if needChangePassword {
redirect = "/admin/password.php"
}
return map[string]interface{}{
"success": true,
"token": token,
"user_id": admin.ID,
"username": admin.Username,
"real_name": admin.RealName,
"user_type": "super_admin",
"need_change_password": needChangePassword,
"redirect": redirect,
}, nil
}
// ChangePassword 超级管理员修改密码(操作 super_admins 表,使用独立 salt
func (s *SuperAdminService) ChangePassword(adminID int, oldPassword, newPassword string, force bool) error {
admin, err := s.superAdminRepo.GetByID(adminID)
if err != nil {
return fmt.Errorf("超级管理员不存在")
}
// 验证原密码(强制改密时跳过)
if !force {
if !crypto.VerifyPassword(oldPassword, admin.PasswordHash, admin.Salt) {
return fmt.Errorf("原密码错误")
}
}
// 验证新密码强度
if valid, msg := crypto.ValidatePasswordStrength(newPassword); !valid {
return fmt.Errorf("%s", msg)
}
// 生成新的独立 salt
newSalt, err := crypto.GenerateRandomPassword(16)
if err != nil {
return fmt.Errorf("生成随机盐值失败: %w", err)
}
newHash := crypto.HashPassword(newPassword, newSalt)
if err := s.superAdminRepo.UpdatePasswordWithSalt(adminID, newHash, newSalt); err != nil {
return fmt.Errorf("密码修改失败")
}
// 清除旧 Token强制重新登录
ctx := context.Background()
_ = database.DeleteUserToken(ctx, adminID)
return nil
}

View File

@@ -0,0 +1,25 @@
package service
// derefInt 安全解引用 int 指针
func derefInt(i *int) int {
if i == nil {
return 0
}
return *i
}
// derefStr 安全解引用字符串指针
func derefStr(s *string) string {
if s == nil {
return ""
}
return *s
}
// intPtr 辅助函数int 转指针0 返回 nil
func intPtr(i int) *int {
if i == 0 {
return nil
}
return &i
}