// =========================================== // 多班级版班级管理系统 - 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 } }