feat: 多班级版班级管理系统 v2.0

技术栈:Go (Gin + GORM) + PHP + MySQL 5.7 + Redis

主要功能:
- 多班级完全隔离(class_id 贯穿全系统)
- 后端 Go Gin(端口 56789),Nginx 反代
- 超级管理员独立登录(env 配置,默认账密 admin/Admin123)
- bcrypt 密码加密(无 PASSWORD_SALT)
- 科任老师/课代表新角色
- 课代表作业管理页面
- 排行榜分项排行(操行分/考勤/作业)
- 角色加减分上下限由班主任配置
- 家长改密功能(可开关)
- 班级角色按需开关
- 宿舍号格式:南0-000
- 周度/月度重置功能
- MySQL 5.7 兼容
- 43 轮代码审查 + 全部修复

开发者: Canglan
版权归属: Sea Network Technology Studio
许可证: Apache License 2.0
This commit is contained in:
2026-06-22 10:21:52 +08:00
commit c6db68a9f4
135 changed files with 19933 additions and 0 deletions

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