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:
226
backend-go/internal/service/attendance_service.go
Normal file
226
backend-go/internal/service/attendance_service.go
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user