This commit is contained in:
2026-09-03 17:55:42 +08:00
parent 7c38e84e30
commit c80a3d4a2d
35 changed files with 6795 additions and 597 deletions
+340 -47
View File
@@ -21,17 +21,27 @@ func NewClassService(db *gorm.DB, configSvc *ConfigService) *ClassService {
return &ClassService{db: db, configSvc: configSvc}
}
// RosterItemReq 毕业照人员录入项
type RosterItemReq struct {
RowNo int `json:"row_no"`
ColNo int `json:"col_no"`
Name string `json:"name"`
Role string `json:"role"` // "student" | "teacher"
}
// CreateClassReq 创建班级请求
type CreateClassReq struct {
SchoolID uint `json:"school_id" binding:"required"`
CollegeID uint `json:"college_id"`
Name string `json:"name" binding:"required"`
Grade string `json:"grade" binding:"required"` // 如"2018级"
ClassNo string `json:"class_no" binding:"required"`
GraduateYear int `json:"graduate_year" binding:"required"`
GraduationPhoto string `json:"graduation_photo"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
SchoolID uint `json:"school_id" binding:"required"`
CollegeID uint `json:"college_id"`
Name string `json:"name"`
Grade string `json:"grade" binding:"required"` // 如"2018级"
ClassNo string `json:"class_no" binding:"required"`
GraduateYear int `json:"graduate_year"`
GraduationPhoto string `json:"graduation_photo"`
RowCount int `json:"row_count"`
Roster []RosterItemReq `json:"roster"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
}
// cleanNames 清洗名字:去空格、去空、去重,保持顺序
@@ -54,10 +64,6 @@ func cleanNames(names []string) []string {
// Create 创建班级
func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, error) {
// 0. 敏感词检测
if found, word := sensitive.Check(req.Name); found {
return nil, fmt.Errorf("班级名称包含敏感词:%s", word)
}
// 1. 校验用户有学习履历
var eduCount int64
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
@@ -72,19 +78,60 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
}
// 3. 校验学院(如果传了)
var collegeName string
if req.CollegeID > 0 {
if !school.SchoolType.HasCollege() {
if !school.HasCollegeType() {
return nil, errors.New("该学校类型不支持学院层级")
}
var college model.College
if err := s.db.Where("id = ? AND school_id = ?", req.CollegeID, req.SchoolID).First(&college).Error; err != nil {
return nil, errors.New("学院不存在或不属于该学校")
}
} else if school.SchoolType.HasCollege() {
// 大学/职业学校必须选学院
collegeName = college.Name
} else if school.RequiresCollege() {
// 纯大学/职业学校必须选学院;同时涵盖中小学学段的学校可不选
return nil, errors.New("该学校需要选择所属学院")
}
// 规范化年级与班级,自动补齐班级名称与推导毕业年份
req.Grade = strings.TrimSpace(req.Grade)
req.ClassNo = strings.TrimSpace(req.ClassNo)
if req.Name == "" {
req.Name = school.Name + collegeName + req.Grade + req.ClassNo
}
// 0. 敏感词检测
if found, word := sensitive.Check(req.Name); found {
return nil, fmt.Errorf("班级名称包含敏感词:%s", word)
}
if req.GraduateYear <= 0 {
startYear := 0
var numStr string
for _, ch := range req.Grade {
if ch >= '0' && ch <= '9' {
numStr += string(ch)
if len(numStr) == 4 {
fmt.Sscanf(numStr, "%d", &startYear)
break
}
} else {
numStr = ""
}
}
duration := 3
if strings.Contains(school.SchoolTypes, "primary") || school.SchoolType == model.SchoolPrimary {
duration = 6
} else if strings.Contains(school.SchoolTypes, "university") || school.SchoolType == model.SchoolUniversity {
duration = 4
}
if startYear > 0 {
req.GraduateYear = startYear + duration
} else {
req.GraduateYear = time.Now().Year()
}
}
// 4. 唯一性校验
var existCount int64
q := s.db.Model(&model.Class{}).Where("school_id = ? AND grade = ? AND class_no = ? AND graduate_year = ? AND status != ?",
@@ -99,9 +146,91 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
return nil, errors.New("该班级已存在,请勿重复创建")
}
// 5. 清洗名单
students := cleanNames(req.StudentNames)
teachers := cleanNames(req.TeacherNames)
// 5. 清洗名单与构建排位花名册(预录入每个同学与老师,未注册账号预留)
var rosters []model.ClassRoster
var students []string
var teachers []string
now := time.Now()
var creator model.User
s.db.Select("id, username, nickname").First(&creator, userID)
if len(req.Roster) > 0 {
maxRow := 1
for _, item := range req.Roster {
name := strings.TrimSpace(item.Name)
if name == "" {
continue
}
rowNo := item.RowNo
if rowNo <= 0 {
rowNo = 1
}
if rowNo > maxRow {
maxRow = rowNo
}
role := item.Role
if role == "" {
role = "student"
}
if role == "teacher" {
teachers = append(teachers, name)
} else {
students = append(students, name)
}
r := model.ClassRoster{
RowNo: rowNo,
ColNo: item.ColNo,
Name: name,
Role: role,
}
// 若当前录入的名字与创建者本人姓名/昵称一致,且尚未绑定任何一条,自动认领绑定
if creator.ID > 0 && (name == creator.Nickname || name == creator.Username) {
r.UserID = creator.ID
r.IsClaimed = true
r.ClaimedAt = &now
}
rosters = append(rosters, r)
}
if req.RowCount < maxRow {
req.RowCount = maxRow
}
students = cleanNames(students)
teachers = cleanNames(teachers)
} else {
// 兼容旧格式调用
students = cleanNames(req.StudentNames)
teachers = cleanNames(req.TeacherNames)
col := 1
for _, name := range students {
r := model.ClassRoster{
RowNo: 1,
ColNo: col,
Name: name,
Role: "student",
}
if creator.ID > 0 && (name == creator.Nickname || name == creator.Username) {
r.UserID = creator.ID
r.IsClaimed = true
r.ClaimedAt = &now
}
rosters = append(rosters, r)
col++
}
for _, name := range teachers {
rosters = append(rosters, model.ClassRoster{
RowNo: 1,
ColNo: col,
Name: name,
Role: "teacher",
})
col++
}
if req.RowCount <= 0 {
req.RowCount = 1
}
}
// 6. 构建班级
graceDays := 30
@@ -117,24 +246,35 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
GraduateYear: req.GraduateYear,
AdminUserID: userID,
GraduationPhoto: req.GraduationPhoto,
RowCount: req.RowCount,
AuditDeadline: time.Now().AddDate(0, 0, graceDays),
}
class.SetStudentNames(students)
class.SetTeacherNames(teachers)
// 7. 名字不足8个直接打回
if len(students) < model.MinStudentCount {
// 7. 总人数不足8个直接打回
totalPeople := len(students) + len(teachers)
if totalPeople < model.MinStudentCount {
class.Status = model.ClassRejected
class.RejectReason = fmt.Sprintf("班级同学名单仅 %d 人,需至少录入 %d 人(毕业照上通常有全部同学姓名,请补齐后重新提交)", len(students), model.MinStudentCount)
class.RejectReason = fmt.Sprintf("班级录入人员仅 %d 人,需至少录入 %d 人(毕业照上通常有全部人员姓名,请补齐后重新提交)", totalPeople, model.MinStudentCount)
} else {
class.Status = model.ClassPending
}
// 8. 事务:创建班级 + 创建者成为管理员成员 + 审核日志
// 8. 事务:创建班级 + 保存分开的花名册预录入记录 + 创建者成为管理员成员 + 审核日志
err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(class).Error; err != nil {
return err
}
// 写入花名册:每个同学/老师作为独立记录存储(未绑定具体账号)
if len(rosters) > 0 {
for i := range rosters {
rosters[i].ClassID = class.ID
}
if err := tx.Create(&rosters).Error; err != nil {
return err
}
}
member := &model.ClassMember{
ClassID: class.ID,
UserID: userID,
@@ -145,11 +285,28 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
if err := tx.Create(member).Error; err != nil {
return err
}
// 检查创建者是否在花名册中,若是则自动认领绑定对应站位
var creator model.User
if tx.Select("id, nickname, username, real_name").First(&creator, userID).Error == nil {
for i := range rosters {
name := rosters[i].Name
if (creator.RealName != "" && name == creator.RealName) || (creator.Nickname != "" && name == creator.Nickname) || name == creator.Username {
tx.Model(&model.ClassRoster{}).Where("class_id = ? AND row_no = ? AND col_no = ?", class.ID, rosters[i].RowNo, rosters[i].ColNo).Updates(map[string]interface{}{
"user_id": userID,
"is_claimed": true,
"claimed_at": time.Now(),
})
break
}
}
}
log := &model.AuditLog{
ClassID: class.ID,
AuditorID: userID,
Action: model.AuditSubmit,
Reason: fmt.Sprintf("创建班级,录入同学 %d 人、老师 %d 人", len(students), len(teachers)),
Reason: fmt.Sprintf("创建班级,按 %d 排录入 %d 位人员(学生 %d 人,老师 %d 人)", class.RowCount, len(rosters), len(students), len(teachers)),
}
return tx.Create(log).Error
})
@@ -161,22 +318,24 @@ func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, e
// ClassDetail 班级详情
type ClassDetail struct {
Class model.Class `json:"class"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
School *model.School `json:"school,omitempty"`
College *model.College `json:"college,omitempty"`
Admin *model.User `json:"admin,omitempty"`
Members []ClassMemberInfo `json:"members"`
Photos []model.ClassPhoto `json:"photos"`
MyRole string `json:"my_role"` // 当前用户角色,未加入为空
IsMember bool `json:"is_member"`
CanViewSensitive bool `json:"can_view_sensitive"` // 是否可查看照片和联系方式(成员/管理员)
Class model.Class `json:"class"`
RowCount int `json:"row_count"`
Roster []model.ClassRoster `json:"roster"`
StudentNames []string `json:"student_names"`
TeacherNames []string `json:"teacher_names"`
School *model.School `json:"school,omitempty"`
College *model.College `json:"college,omitempty"`
Admin *model.User `json:"admin,omitempty"`
Members []ClassMemberInfo `json:"members"`
Photos []model.ClassPhoto `json:"photos"`
MyRole string `json:"my_role"` // 当前用户角色,未加入为空
IsMember bool `json:"is_member"`
CanViewSensitive bool `json:"can_view_sensitive"` // 是否可查看照片和联系方式(成员/管理员)
}
type ClassMemberInfo struct {
Member model.ClassMember `json:"member"`
User model.User `json:"user"`
Member model.ClassMember `json:"member"`
User model.User `json:"user"`
Contacts []model.UserContact `json:"contacts,omitempty"`
}
@@ -192,13 +351,51 @@ func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool)
// 数据隔离:只有班级成员或超级管理员可查看完整信息(照片+联系方式)
canViewSensitive := isSuperAdmin || (currentUserID > 0 && s.isMember(classID, currentUserID))
var rosters []model.ClassRoster
s.db.Where("class_id = ?", classID).Order("row_no asc, col_no asc, id asc").Find(&rosters)
detail := &ClassDetail{
Class: class,
StudentNames: class.StudentNames(),
TeacherNames: class.TeacherNames(),
Class: class,
RowCount: class.RowCount,
Roster: rosters,
StudentNames: class.StudentNames(),
TeacherNames: class.TeacherNames(),
CanViewSensitive: canViewSensitive,
}
// 兼容老数据:如果花名册表中暂无记录,则按 StudentNames/TeacherNames 构造第1排返回
if len(rosters) == 0 && (len(detail.StudentNames) > 0 || len(detail.TeacherNames) > 0) {
col := 1
for _, name := range detail.StudentNames {
detail.Roster = append(detail.Roster, model.ClassRoster{
ClassID: classID,
RowNo: 1,
ColNo: col,
Name: name,
Role: "student",
})
col++
}
for _, name := range detail.TeacherNames {
detail.Roster = append(detail.Roster, model.ClassRoster{
ClassID: classID,
RowNo: 1,
ColNo: col,
Name: name,
Role: "teacher",
})
col++
}
detail.RowCount = 1
}
if detail.RowCount <= 0 && len(detail.Roster) > 0 {
for _, r := range detail.Roster {
if r.RowNo > detail.RowCount {
detail.RowCount = r.RowNo
}
}
}
// 学校
var school model.School
if s.db.First(&school, class.SchoolID).Error == nil {
@@ -223,12 +420,26 @@ func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool)
for _, m := range members {
var u model.User
s.db.First(&u, m.UserID)
info := ClassMemberInfo{Member: m, User: u}
// 数据隔离:同班成员或超级管理员可查看联系方式
if canViewSensitive {
info.Contacts = s.getUserContacts(m.UserID)
// 敏感信息脱敏:非本人且非平台管理员时脱敏身份证号
if !isSuperAdmin && m.UserID != currentUserID {
if len(u.IdCard) == 18 {
u.IdCard = u.IdCard[:6] + "********" + u.IdCard[14:]
} else if u.IdCard != "" {
u.IdCard = "****************"
}
}
// 数据隔离:同班成员或超级管理员可查看联系方式;非成员清空敏感联系信息
if canViewSensitive {
info := ClassMemberInfo{Member: m, User: u}
info.Contacts = s.getUserContacts(m.UserID)
detail.Members = append(detail.Members, info)
} else {
u.Phone = ""
u.Email = ""
u.Address = ""
info := ClassMemberInfo{Member: m, User: u}
detail.Members = append(detail.Members, info)
}
detail.Members = append(detail.Members, info)
}
// 照片墙:数据隔离,只有成员或超级管理员可查看
@@ -259,7 +470,33 @@ func (s *ClassService) isMember(classID, userID uint) bool {
func (s *ClassService) getUserContacts(userID uint) []model.UserContact {
var list []model.UserContact
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&list)
var u model.User
if s.db.Select("id, phone, email, address").First(&u, userID).Error == nil {
hasPhone := false
hasEmail := false
hasAddress := false
for _, c := range list {
if c.Type == model.ContactPhone {
hasPhone = true
}
if c.Type == model.ContactEmail {
hasEmail = true
}
if c.Type == model.ContactAddress {
hasAddress = true
}
}
if !hasPhone && u.Phone != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactPhone, Value: u.Phone, IsPrimary: true})
}
if !hasEmail && u.Email != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactEmail, Value: u.Email, IsPrimary: true})
}
if !hasAddress && u.Address != "" {
list = append(list, model.UserContact{UserID: userID, Type: model.ContactAddress, Value: u.Address, IsPrimary: true})
}
}
return list
}
@@ -312,6 +549,13 @@ type JoinBySearchReq struct {
// JoinBySearch 通过搜索+8人验证加入班级
func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
// 0. 校验用户有学习履历
var eduCount int64
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
if eduCount == 0 {
return errors.New("请先完善个人学习履历,有学习履历的用户才能加入班级")
}
var class model.Class
if err := s.db.First(&class, req.ClassID).Error; err != nil {
return errors.New("班级不存在")
@@ -346,7 +590,29 @@ func (s *ClassService) JoinBySearch(userID uint, req *JoinBySearchReq) error {
JoinMethod: model.JoinBySearch,
JoinedAt: time.Now(),
}
return s.db.Create(member).Error
if err := s.db.Create(member).Error; err != nil {
return err
}
// 联查班级花名册:若当前用户姓名与花名册中未绑定人员匹配,自动绑定该坑位
var user model.User
if s.db.Select("id, nickname, username, real_name").First(&user, userID).Error == nil {
var targetRoster model.ClassRoster
q := s.db.Where("class_id = ? AND is_claimed = ? AND user_id = 0", class.ID, false)
if user.RealName != "" {
q = q.Where("name = ? OR name = ? OR name = ?", user.RealName, user.Nickname, user.Username)
} else {
q = q.Where("name = ? OR name = ?", user.Nickname, user.Username)
}
if err := q.First(&targetRoster).Error; err == nil {
s.db.Model(&targetRoster).Updates(map[string]interface{}{
"user_id": userID,
"is_claimed": true,
"claimed_at": time.Now(),
})
}
}
return nil
}
// ---- 邀请码 ----
@@ -400,6 +666,13 @@ type JoinByInvitationReq struct {
// JoinByInvitation 通过邀请码加入
func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (*model.Class, error) {
// 0. 校验用户有学习履历
var eduCount int64
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
if eduCount == 0 {
return nil, errors.New("请先完善个人学习履历,有学习履历的用户才能加入班级")
}
code := strings.ToUpper(strings.TrimSpace(req.Code))
var inv model.Invitation
if err := s.db.Where("code = ?", code).First(&inv).Error; err != nil {
@@ -436,6 +709,26 @@ func (s *ClassService) JoinByInvitation(userID uint, req *JoinByInvitationReq) (
if err := tx.Create(member).Error; err != nil {
return err
}
// 联查花名册自动绑定坑位
var u model.User
if tx.Select("id, nickname, username, real_name").First(&u, userID).Error == nil {
var targetRoster model.ClassRoster
q := tx.Where("class_id = ? AND is_claimed = ? AND user_id = 0", class.ID, false)
if u.RealName != "" {
q = q.Where("name = ? OR name = ? OR name = ?", u.RealName, u.Nickname, u.Username)
} else {
q = q.Where("name = ? OR name = ?", u.Nickname, u.Username)
}
if err := q.First(&targetRoster).Error; err == nil {
tx.Model(&targetRoster).Updates(map[string]interface{}{
"user_id": userID,
"is_claimed": true,
"claimed_at": time.Now(),
})
}
}
return tx.Model(&model.Invitation{}).Where("id = ?", inv.ID).
Update("used_count", gorm.Expr("used_count + 1")).Error
})