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