Files
SharedClassManager/backend-go/internal/service/semester_service.go
canglan 124d7f645e 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
2026-06-22 10:21:52 +08:00

666 lines
20 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// ===========================================
// 多班级版班级管理系统 - 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
}
}