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,384 @@
// ===========================================
// 多班级版班级管理系统 - Go 后端
//
// 开发者: Canglan
// 联系方式: admin@sea-studio.top
// 版权归属: Sea Network Technology Studio
// 许可证: Apache License 2.0
//
// 版权所有 © Sea Network Technology Studio
// ===========================================
package service
import (
"fmt"
"strconv"
"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"
)
// ConductService 操行分服务
type ConductService struct {
conductRepo *repository.ConductRepo
studentRepo *repository.StudentRepo
adminRoleRepo *repository.AdminRoleRepo
semesterRepo *repository.SemesterRepo
classRepo *repository.ClassRepo
}
// NewConductService 创建操行分服务
func NewConductService(
conductRepo *repository.ConductRepo,
studentRepo *repository.StudentRepo,
adminRoleRepo *repository.AdminRoleRepo,
semesterRepo *repository.SemesterRepo,
classRepo *repository.ClassRepo,
) *ConductService {
return &ConductService{
conductRepo: conductRepo,
studentRepo: studentRepo,
adminRoleRepo: adminRoleRepo,
semesterRepo: semesterRepo,
classRepo: classRepo,
}
}
// AddPoints 批量加减分
func (s *ConductService) AddPoints(studentIDs []int, pointsChange int, reason string,
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
// 输入校验
if len(studentIDs) == 0 || len(studentIDs) > 200 {
return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil
}
if reason == "" || len(reason) > 255 {
return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil
}
if pointsChange == 0 || absInt(pointsChange) > 100 {
return map[string]interface{}{"success": false, "message": "分值无效"}, nil
}
// 获取操作人角色
role, _, err := s.adminRoleRepo.GetUserRoleAndClassID(recorderID)
if err != nil {
return map[string]interface{}{"success": false, "message": "获取操作人角色失败"}, nil
}
// 权限验证(从 class_settings 读取限制,这里使用默认值)
if err := s.validatePointsPermission(role, pointsChange, classID); err != nil {
return map[string]interface{}{"success": false, "message": err.Error()}, nil
}
return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType)
}
// CadreAddPoints 课代表专用加减分(跳过角色权限验证,仅限作业相关扣分)
func (s *ConductService) CadreAddPoints(studentIDs []int, pointsChange int, reason string,
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
// 输入校验
if len(studentIDs) == 0 || len(studentIDs) > 200 {
return map[string]interface{}{"success": false, "message": "学生数量需在1-200之间"}, nil
}
if reason == "" || len(reason) > 255 {
return map[string]interface{}{"success": false, "message": "原因不能为空且不超过255字符"}, nil
}
if pointsChange >= 0 || absInt(pointsChange) > 100 {
return map[string]interface{}{"success": false, "message": "课代表只能进行扣分操作"}, nil
}
// 强制设置为作业类型
relatedType = "homework"
return s.addPointsInternal(studentIDs, pointsChange, reason, recorderID, recorderName, classID, relatedType)
}
// addPointsInternal 批量加减分内部实现
func (s *ConductService) addPointsInternal(studentIDs []int, pointsChange int, reason string,
recorderID int, recorderName string, classID int, relatedType string) (map[string]interface{}, error) {
// 自动获取当前活跃学期
activeSemester, semErr := s.semesterRepo.GetActive()
if semErr != nil {
logger.Sugared.Warnf("获取活跃学期失败,操行分将不关联学期: %v", semErr)
}
var semesterID *int
if activeSemester != nil {
semesterID = &activeSemester.SemesterID
}
if relatedType == "" {
relatedType = "manual"
}
successCount := 0
failCount := 0
var details []map[string]interface{}
db := s.semesterRepo.GetDB()
for _, studentID := range studentIDs {
// 检查学生是否存在
student, err := s.studentRepo.GetByID(studentID)
if err != nil || student == nil {
failCount++
details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不存在"})
continue
}
// 校验学生是否属于当前班级
if student.ClassID != classID {
failCount++
details = append(details, map[string]interface{}{"student_id": studentID, "error": "学生不属于当前班级"})
continue
}
// 使用事务确保记录创建和总分更新的原子性(#3
recordID, txErr := func() (int64, error) {
var rid int64
txErr := db.Transaction(func(tx *gorm.DB) error {
record := &model.ConductRecord{
StudentID: studentID,
PointsChange: pointsChange,
Reason: reason,
RecorderID: recorderID,
RecorderName: &recorderName,
RelatedType: relatedType,
SemesterID: semesterID,
}
if err := tx.Create(record).Error; err != nil {
return err
}
rid = record.RecordID
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
}
return nil
})
return rid, txErr
}()
if txErr != nil {
failCount++
details = append(details, map[string]interface{}{"student_id": studentID, "error": txErr.Error()})
continue
}
successCount++
details = append(details, map[string]interface{}{"student_id": studentID, "success": true, "record_id": recordID})
logger.Sugared.Infof("用户[%d] 对学生[%d] 进行 %d 分操作", recorderID, studentID, pointsChange)
}
return map[string]interface{}{
"success": failCount == 0,
"success_count": successCount,
"fail_count": failCount,
"details": details,
}, nil
}
// RevokeRecord 撤销记录(事务保护,避免并发重复撤销)
func (s *ConductService) RevokeRecord(recordID int64, revokerID int, classID int) (map[string]interface{}, error) {
record, err := s.conductRepo.GetRecordByID(recordID)
if err != nil || record == nil {
return map[string]interface{}{"success": false, "message": "记录不存在"}, nil
}
// 校验记录所属学生是否在当前操作者的班级中
student, _ := s.studentRepo.GetByID(record.StudentID)
if student == nil || student.ClassID != classID {
return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil
}
if record.IsRevoked == 1 {
return map[string]interface{}{"success": false, "message": "该记录已被撤销"}, nil
}
db := s.semesterRepo.GetDB()
txErr := db.Transaction(func(tx *gorm.DB) error {
// 撤销记录
if err := tx.Model(&model.ConductRecord{}).
Where("record_id = ? AND is_revoked = 0", recordID).
Updates(map[string]interface{}{
"is_revoked": 1,
"revoked_by": revokerID,
}).Error; err != nil {
return err
}
// 反向恢复学生总分(下限保护)
return tx.Model(&model.Student{}).
Where("student_id = ?", record.StudentID).
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", -record.PointsChange)).Error
})
if txErr != nil {
return map[string]interface{}{"success": false, "message": "撤销失败"}, nil
}
return map[string]interface{}{
"success": true,
"message": "撤销成功",
"record": map[string]interface{}{
"student_id": record.StudentID,
"recorder_name": derefStr(record.RecorderName),
"points_change": record.PointsChange,
},
}, nil
}
// RestoreRecord 反撤销记录(事务保护,避免并发重复恢复)
func (s *ConductService) RestoreRecord(recordID int64, restorerID int, classID int) (map[string]interface{}, error) {
record, err := s.conductRepo.GetRecordByID(recordID)
if err != nil || record == nil {
return map[string]interface{}{"success": false, "message": "记录不存在"}, nil
}
// 校验记录所属学生是否在当前操作者的班级中
student, _ := s.studentRepo.GetByID(record.StudentID)
if student == nil || student.ClassID != classID {
return map[string]interface{}{"success": false, "message": "无权操作该记录"}, nil
}
if record.IsRevoked == 0 {
return map[string]interface{}{"success": false, "message": "该记录未被撤销,无需恢复"}, nil
}
db := s.semesterRepo.GetDB()
txErr := db.Transaction(func(tx *gorm.DB) error {
// 反撤销
if err := tx.Model(&model.ConductRecord{}).
Where("record_id = ? AND is_revoked = 1", recordID).
Updates(map[string]interface{}{
"is_revoked": 0,
"revoked_by": nil,
"revoked_at": nil,
}).Error; err != nil {
return err
}
// 恢复学生总分(下限保护)
return tx.Model(&model.Student{}).
Where("student_id = ?", record.StudentID).
Update("total_points", gorm.Expr("GREATEST(total_points + ?, 0)", record.PointsChange)).Error
})
if txErr != nil {
return map[string]interface{}{"success": false, "message": "反撤销失败"}, nil
}
return map[string]interface{}{
"success": true,
"message": "反撤销成功",
}, nil
}
// GetHistory 获取操行分历史记录
func (s *ConductService) GetHistory(classID int, studentID *int, page, pageSize int,
startDate, endDate, relatedType, reasonPrefix string, isRevoked *int, reasonSearch string) (map[string]interface{}, error) {
includeRevoked := false
if isRevoked != nil && *isRevoked == 1 {
includeRevoked = true
}
offset := (page - 1) * pageSize
records, err := s.conductRepo.GetAllRecords(classID, pageSize, offset, startDate, endDate,
derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch)
if err != nil {
return nil, err
}
total, err := s.conductRepo.CountAllRecords(classID, startDate, endDate,
derefInt(studentID), includeRevoked, relatedType, reasonPrefix, isRevoked, reasonSearch)
if err != nil {
return nil, err
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return map[string]interface{}{
"records": records,
"total": total,
"page": page,
"page_size": pageSize,
"total_pages": totalPages,
}, nil
}
// validatePointsPermission 验证角色加减分权限
func (s *ConductService) validatePointsPermission(role string, pointsChange, classID int) error {
// 从 class_settings 读取配置,若无则使用默认值
maxPoints := func(key string, defaultVal int) int {
if classID > 0 {
setting, err := s.classRepo.GetSetting(classID, key)
if err == nil && setting != nil {
if v, e := strconv.Atoi(setting.SettingValue); e == nil {
return v
}
}
}
return defaultVal
}
switch role {
case "班主任":
return nil // 无限制
case "班长":
maxAdd := maxPoints("point_limit_班长_max", 5)
maxSub := maxPoints("point_limit_班长_min", -5)
if pointsChange > maxAdd || pointsChange < maxSub {
return fmt.Errorf("班长单次只能加%d至减%d分以内", maxAdd, absInt(maxSub))
}
case "学习委员":
limit := maxPoints("point_limit_学习委员_max", 5)
if absInt(pointsChange) > limit {
return fmt.Errorf("学习委员单次只能加减%d分以内", limit)
}
case "科任老师":
limit := maxPoints("point_limit_科任老师_max", 5)
if absInt(pointsChange) > limit {
return fmt.Errorf("科任老师单次只能加减%d分以内", limit)
}
case "考勤委员":
if pointsChange > 0 {
return fmt.Errorf("考勤委员只能进行扣分操作")
}
limit := maxPoints("point_limit_考勤委员_max", 8)
if absInt(pointsChange) > limit {
return fmt.Errorf("考勤委员单次最多扣%d分", limit)
}
case "劳动委员":
limit := maxPoints("point_limit_劳动委员_max", 1)
if absInt(pointsChange) > limit {
return fmt.Errorf("劳动委员单次只能加减%d分以内", limit)
}
case "志愿委员":
if pointsChange < 0 {
return fmt.Errorf("志愿委员只能加分")
}
limit := maxPoints("point_limit_志愿委员_max", 5)
if pointsChange > limit {
return fmt.Errorf("志愿委员单次最多加%d分", limit)
}
case "课代表":
return fmt.Errorf("课代表无权进行此操作")
default:
return fmt.Errorf("无权进行此操作")
}
return nil
}
// absInt 取绝对值
func absInt(x int) int {
if x < 0 {
return -x
}
return x
}