926 lines
28 KiB
Go
926 lines
28 KiB
Go
package service
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"photowall/internal/model"
|
|
"photowall/pkg/sensitive"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
type ClassService struct {
|
|
db *gorm.DB
|
|
configSvc *ConfigService
|
|
}
|
|
|
|
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"`
|
|
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 清洗名字:去空格、去空、去重,保持顺序
|
|
func cleanNames(names []string) []string {
|
|
seen := map[string]bool{}
|
|
result := []string{}
|
|
for _, n := range names {
|
|
n = strings.TrimSpace(n)
|
|
if n == "" {
|
|
continue
|
|
}
|
|
if seen[n] {
|
|
continue
|
|
}
|
|
seen[n] = true
|
|
result = append(result, n)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// Create 创建班级
|
|
func (s *ClassService) Create(userID uint, req *CreateClassReq) (*model.Class, error) {
|
|
// 1. 校验用户有学习履历
|
|
var eduCount int64
|
|
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&eduCount)
|
|
if eduCount == 0 {
|
|
return nil, errors.New("请先完善个人学习履历,有学习履历的用户才能创建班级")
|
|
}
|
|
|
|
// 2. 校验学校
|
|
var school model.School
|
|
if err := s.db.First(&school, req.SchoolID).Error; err != nil {
|
|
return nil, errors.New("学校不存在")
|
|
}
|
|
|
|
// 3. 校验学院(如果传了)
|
|
var collegeName string
|
|
if req.CollegeID > 0 {
|
|
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("学院不存在或不属于该学校")
|
|
}
|
|
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 != ?",
|
|
req.SchoolID, req.Grade, req.ClassNo, req.GraduateYear, model.ClassDeleted)
|
|
if req.CollegeID > 0 {
|
|
q = q.Where("college_id = ?", req.CollegeID)
|
|
} else {
|
|
q = q.Where("college_id = 0 OR college_id IS NULL")
|
|
}
|
|
q.Count(&existCount)
|
|
if existCount > 0 {
|
|
return nil, errors.New("该班级已存在,请勿重复创建")
|
|
}
|
|
|
|
// 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
|
|
if s.configSvc != nil {
|
|
graceDays = s.configSvc.GetInt("audit_grace_days", 30)
|
|
}
|
|
class := &model.Class{
|
|
SchoolID: req.SchoolID,
|
|
CollegeID: req.CollegeID,
|
|
Name: req.Name,
|
|
Grade: req.Grade,
|
|
ClassNo: req.ClassNo,
|
|
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个直接打回
|
|
totalPeople := len(students) + len(teachers)
|
|
if totalPeople < model.MinStudentCount {
|
|
class.Status = model.ClassRejected
|
|
class.RejectReason = fmt.Sprintf("班级录入人员仅 %d 人,需至少录入 %d 人(毕业照上通常有全部人员姓名,请补齐后重新提交)", totalPeople, model.MinStudentCount)
|
|
} else {
|
|
class.Status = model.ClassPending
|
|
}
|
|
|
|
// 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,
|
|
Role: model.MemberRoleAdmin,
|
|
JoinMethod: model.JoinByInvite, // 创建者视同邀请
|
|
JoinedAt: time.Now(),
|
|
}
|
|
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 位人员(学生 %d 人,老师 %d 人)", class.RowCount, len(rosters), len(students), len(teachers)),
|
|
}
|
|
return tx.Create(log).Error
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return class, nil
|
|
}
|
|
|
|
// ClassDetail 班级详情
|
|
type ClassDetail struct {
|
|
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"`
|
|
Contacts []model.UserContact `json:"contacts,omitempty"`
|
|
}
|
|
|
|
func (s *ClassService) GetDetail(classID, currentUserID uint, isSuperAdmin bool) (*ClassDetail, error) {
|
|
var class model.Class
|
|
if err := s.db.First(&class, classID).Error; err != nil {
|
|
return nil, errors.New("班级不存在")
|
|
}
|
|
if class.Status == model.ClassDeleted {
|
|
return nil, errors.New("班级已删除")
|
|
}
|
|
|
|
// 数据隔离:只有班级成员或超级管理员可查看完整信息(照片+联系方式)
|
|
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,
|
|
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 {
|
|
detail.School = &school
|
|
}
|
|
// 学院
|
|
if class.CollegeID > 0 {
|
|
var college model.College
|
|
if s.db.First(&college, class.CollegeID).Error == nil {
|
|
detail.College = &college
|
|
}
|
|
}
|
|
// 管理员
|
|
var admin model.User
|
|
if s.db.First(&admin, class.AdminUserID).Error == nil {
|
|
detail.Admin = &admin
|
|
}
|
|
|
|
// 成员列表
|
|
var members []model.ClassMember
|
|
s.db.Where("class_id = ?", classID).Order("role desc, joined_at").Find(&members)
|
|
for _, m := range members {
|
|
var u model.User
|
|
s.db.First(&u, 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)
|
|
}
|
|
}
|
|
|
|
// 照片墙:数据隔离,只有成员或超级管理员可查看
|
|
if canViewSensitive {
|
|
var photos []model.ClassPhoto
|
|
s.db.Where("class_id = ?", classID).Order("user_id, id desc").Find(&photos)
|
|
detail.Photos = photos
|
|
} else {
|
|
detail.Photos = []model.ClassPhoto{}
|
|
}
|
|
|
|
// 当前用户角色
|
|
if currentUserID > 0 {
|
|
var myMember model.ClassMember
|
|
if s.db.Where("class_id = ? AND user_id = ?", classID, currentUserID).First(&myMember).Error == nil {
|
|
detail.MyRole = string(myMember.Role)
|
|
detail.IsMember = true
|
|
}
|
|
}
|
|
return detail, nil
|
|
}
|
|
|
|
func (s *ClassService) isMember(classID, userID uint) bool {
|
|
var count int64
|
|
s.db.Model(&model.ClassMember{}).Where("class_id = ? AND user_id = ?", classID, userID).Count(&count)
|
|
return count > 0
|
|
}
|
|
|
|
func (s *ClassService) getUserContacts(userID uint) []model.UserContact {
|
|
var list []model.UserContact
|
|
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
|
|
}
|
|
|
|
// SearchClassReq 搜索班级
|
|
type SearchClassReq struct {
|
|
Keyword string `form:"keyword"`
|
|
SchoolID uint `form:"school_id"`
|
|
Grade string `form:"grade"`
|
|
GraduateYear int `form:"graduate_year"`
|
|
Page int `form:"page"`
|
|
PageSize int `form:"page_size"`
|
|
}
|
|
|
|
// Search 搜索可加入的班级(仅 active 状态)
|
|
func (s *ClassService) Search(req *SearchClassReq) (*SchoolListResp, error) {
|
|
if req.Page <= 0 {
|
|
req.Page = 1
|
|
}
|
|
if req.PageSize <= 0 || req.PageSize > 100 {
|
|
req.PageSize = 20
|
|
}
|
|
db := s.db.Model(&model.Class{}).Where("status = ?", model.ClassActive)
|
|
if req.SchoolID > 0 {
|
|
db = db.Where("school_id = ?", req.SchoolID)
|
|
}
|
|
if req.Grade != "" {
|
|
db = db.Where("grade = ?", req.Grade)
|
|
}
|
|
if req.GraduateYear > 0 {
|
|
db = db.Where("graduate_year = ?", req.GraduateYear)
|
|
}
|
|
if req.Keyword != "" {
|
|
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
|
|
}
|
|
var total int64
|
|
db.Count(&total)
|
|
var list []model.Class
|
|
err := db.Order("graduate_year desc, id desc").Offset((req.Page - 1) * req.PageSize).Limit(req.PageSize).Find(&list).Error
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &SchoolListResp{List: list, Total: total, Page: req.Page}, nil
|
|
}
|
|
|
|
// JoinBySearchReq 搜索加入验证
|
|
type JoinBySearchReq struct {
|
|
ClassID uint `json:"class_id" binding:"required"`
|
|
StudentNames []string `json:"student_names" binding:"required,min=8"`
|
|
}
|
|
|
|
// 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("班级不存在")
|
|
}
|
|
if class.Status != model.ClassActive {
|
|
return errors.New("该班级尚未通过审核,暂不能加入")
|
|
}
|
|
// 已加入?
|
|
if s.isMember(class.ID, userID) {
|
|
return errors.New("你已经是该班级成员")
|
|
}
|
|
// 验证8个名字
|
|
classStudents := class.StudentNames()
|
|
classSet := map[string]bool{}
|
|
for _, n := range classStudents {
|
|
classSet[strings.TrimSpace(n)] = true
|
|
}
|
|
input := cleanNames(req.StudentNames)
|
|
if len(input) < model.MinStudentCount {
|
|
return fmt.Errorf("请输入至少 %d 位同学的名字", model.MinStudentCount)
|
|
}
|
|
for _, n := range input {
|
|
if !classSet[n] {
|
|
return fmt.Errorf("同学「%s」不在该班级名单中,验证失败,请确认名字与毕业照一致", n)
|
|
}
|
|
}
|
|
// 全部匹配,加入
|
|
member := &model.ClassMember{
|
|
ClassID: class.ID,
|
|
UserID: userID,
|
|
Role: model.MemberRoleMember,
|
|
JoinMethod: model.JoinBySearch,
|
|
JoinedAt: time.Now(),
|
|
}
|
|
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
|
|
}
|
|
|
|
// ---- 邀请码 ----
|
|
|
|
// CreateInvitation 管理员生成邀请码
|
|
func (s *ClassService) CreateInvitation(classID, userID uint, maxUses int, expireDays int) (*model.Invitation, error) {
|
|
if !s.isAdmin(classID, userID) {
|
|
return nil, errors.New("只有班级管理员可以生成邀请码")
|
|
}
|
|
var class model.Class
|
|
if err := s.db.First(&class, classID).Error; err != nil {
|
|
return nil, errors.New("班级不存在")
|
|
}
|
|
if class.Status == model.ClassDeleted {
|
|
return nil, errors.New("班级已删除")
|
|
}
|
|
if maxUses <= 0 {
|
|
maxUses = 50
|
|
}
|
|
if expireDays <= 0 {
|
|
expireDays = 30
|
|
}
|
|
inv := &model.Invitation{
|
|
ClassID: classID,
|
|
InviterID: userID,
|
|
Code: strings.ToUpper(strings.ReplaceAll(uuid.New().String()[:8], "-", "")),
|
|
MaxUses: maxUses,
|
|
ExpiresAt: time.Now().AddDate(0, 0, expireDays),
|
|
Status: "active",
|
|
}
|
|
if err := s.db.Create(inv).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return inv, nil
|
|
}
|
|
|
|
// ListInvitations 管理员查看邀请码列表
|
|
func (s *ClassService) ListInvitations(classID, userID uint) ([]model.Invitation, error) {
|
|
if !s.isAdmin(classID, userID) {
|
|
return nil, errors.New("只有班级管理员可以查看邀请码")
|
|
}
|
|
var list []model.Invitation
|
|
err := s.db.Where("class_id = ?", classID).Order("id desc").Find(&list).Error
|
|
return list, err
|
|
}
|
|
|
|
// JoinByInvitationReq 邀请码加入
|
|
type JoinByInvitationReq struct {
|
|
Code string `json:"code" binding:"required"`
|
|
}
|
|
|
|
// 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 {
|
|
return nil, errors.New("邀请码无效")
|
|
}
|
|
if inv.Status != "active" {
|
|
return nil, errors.New("邀请码已失效")
|
|
}
|
|
if inv.UsedCount >= inv.MaxUses {
|
|
return nil, errors.New("邀请码使用次数已达上限")
|
|
}
|
|
if time.Now().After(inv.ExpiresAt) {
|
|
return nil, errors.New("邀请码已过期")
|
|
}
|
|
var class model.Class
|
|
if err := s.db.First(&class, inv.ClassID).Error; err != nil {
|
|
return nil, errors.New("班级不存在")
|
|
}
|
|
if class.Status == model.ClassDeleted {
|
|
return nil, errors.New("班级已删除")
|
|
}
|
|
if s.isMember(class.ID, userID) {
|
|
return nil, errors.New("你已经是该班级成员")
|
|
}
|
|
// 事务:加成员 + 邀请码使用数+1
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
member := &model.ClassMember{
|
|
ClassID: class.ID,
|
|
UserID: userID,
|
|
Role: model.MemberRoleMember,
|
|
JoinMethod: model.JoinByInvite,
|
|
JoinedAt: time.Now(),
|
|
}
|
|
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
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return &class, nil
|
|
}
|
|
|
|
// DisableInvitation 管理员禁用邀请码
|
|
func (s *ClassService) DisableInvitation(classID, invID, userID uint) error {
|
|
if !s.isAdmin(classID, userID) {
|
|
return errors.New("只有班级管理员可以操作")
|
|
}
|
|
res := s.db.Model(&model.Invitation{}).Where("id = ? AND class_id = ?", invID, classID).Update("status", "disabled")
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("邀请码不存在")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// isAdmin 检查用户是否为班级管理员
|
|
func (s *ClassService) isAdmin(classID, userID uint) bool {
|
|
var count int64
|
|
s.db.Model(&model.ClassMember{}).Where("class_id = ? AND user_id = ? AND role = ?", classID, userID, model.MemberRoleAdmin).Count(&count)
|
|
return count > 0
|
|
}
|
|
|
|
// TransferAdmin 管理员转移
|
|
func (s *ClassService) TransferAdmin(classID, fromUserID, toUserID uint) error {
|
|
if !s.isAdmin(classID, fromUserID) {
|
|
return errors.New("只有班级管理员可以转移管理员身份")
|
|
}
|
|
if fromUserID == toUserID {
|
|
return errors.New("不能转移给自己")
|
|
}
|
|
// 目标必须是同班成员
|
|
var target model.ClassMember
|
|
if err := s.db.Where("class_id = ? AND user_id = ?", classID, toUserID).First(&target).Error; err != nil {
|
|
return errors.New("目标用户不是该班级成员")
|
|
}
|
|
err := s.db.Transaction(func(tx *gorm.DB) error {
|
|
// 原管理员降为成员
|
|
if err := tx.Model(&model.ClassMember{}).
|
|
Where("class_id = ? AND user_id = ?", classID, fromUserID).
|
|
Update("role", model.MemberRoleMember).Error; err != nil {
|
|
return err
|
|
}
|
|
// 目标升为管理员
|
|
if err := tx.Model(&model.ClassMember{}).
|
|
Where("class_id = ? AND user_id = ?", classID, toUserID).
|
|
Update("role", model.MemberRoleAdmin).Error; err != nil {
|
|
return err
|
|
}
|
|
// 更新班级 admin_user_id
|
|
return tx.Model(&model.Class{}).Where("id = ?", classID).Update("admin_user_id", toUserID).Error
|
|
})
|
|
return err
|
|
}
|
|
|
|
// RemoveMember 管理员移除成员(不能移除自己)
|
|
func (s *ClassService) RemoveMember(classID, adminID, targetUserID uint) error {
|
|
if !s.isAdmin(classID, adminID) {
|
|
return errors.New("只有班级管理员可以移除成员")
|
|
}
|
|
if adminID == targetUserID {
|
|
return errors.New("不能移除自己,请先转移管理员")
|
|
}
|
|
res := s.db.Where("class_id = ? AND user_id = ?", classID, targetUserID).Delete(&model.ClassMember{})
|
|
if res.RowsAffected == 0 {
|
|
return errors.New("成员不存在")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ---- 个人照片墙 ----
|
|
|
|
// UploadPhoto 上传个人当期照片(每人每班级最多10张)
|
|
func (s *ClassService) UploadPhoto(classID, userID uint, photoURL, caption string) (*model.ClassPhoto, error) {
|
|
if !s.isMember(classID, userID) {
|
|
return nil, errors.New("只有班级成员可以上传照片")
|
|
}
|
|
var class model.Class
|
|
if err := s.db.First(&class, classID).Error; err != nil {
|
|
return nil, errors.New("班级不存在")
|
|
}
|
|
if class.Status == model.ClassDeleted {
|
|
return nil, errors.New("班级已删除")
|
|
}
|
|
var count int64
|
|
s.db.Model(&model.ClassPhoto{}).Where("class_id = ? AND user_id = ?", classID, userID).Count(&count)
|
|
if count >= int64(model.MaxPhotosPerUserPerClass) {
|
|
return nil, fmt.Errorf("每人在每个班级最多上传 %d 张照片", model.MaxPhotosPerUserPerClass)
|
|
}
|
|
photo := &model.ClassPhoto{
|
|
ClassID: classID,
|
|
UserID: userID,
|
|
PhotoURL: photoURL,
|
|
Caption: caption,
|
|
}
|
|
if err := s.db.Create(photo).Error; err != nil {
|
|
return nil, err
|
|
}
|
|
return photo, nil
|
|
}
|
|
|
|
// DeletePhoto 删除照片(本人或班级管理员或超级管理员)
|
|
func (s *ClassService) DeletePhoto(classID, photoID, userID uint, isSuperAdmin bool) error {
|
|
var photo model.ClassPhoto
|
|
if err := s.db.Where("id = ? AND class_id = ?", photoID, classID).First(&photo).Error; err != nil {
|
|
return errors.New("照片不存在")
|
|
}
|
|
// 权限校验:本人、班级管理员、超级管理员
|
|
canDelete := isSuperAdmin || photo.UserID == userID || s.isAdmin(classID, userID)
|
|
if !canDelete {
|
|
return errors.New("无权删除此照片")
|
|
}
|
|
s.db.Delete(&photo)
|
|
return nil
|
|
}
|
|
|
|
// ListMyClasses 我的班级列表
|
|
func (s *ClassService) ListMyClasses(userID uint) ([]ClassDetail, error) {
|
|
var members []model.ClassMember
|
|
s.db.Where("user_id = ?", userID).Order("id desc").Find(&members)
|
|
result := []ClassDetail{}
|
|
for _, m := range members {
|
|
var class model.Class
|
|
if err := s.db.First(&class, m.ClassID).Error; err != nil {
|
|
continue
|
|
}
|
|
if class.Status == model.ClassDeleted {
|
|
continue
|
|
}
|
|
d := ClassDetail{
|
|
Class: class,
|
|
MyRole: string(m.Role),
|
|
IsMember: true,
|
|
}
|
|
var school model.School
|
|
if s.db.First(&school, class.SchoolID).Error == nil {
|
|
d.School = &school
|
|
}
|
|
result = append(result, d)
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// ResubmitReq 重新提交审核(补齐名单/照片)
|
|
type ResubmitReq struct {
|
|
GraduationPhoto string `json:"graduation_photo"`
|
|
StudentNames []string `json:"student_names"`
|
|
TeacherNames []string `json:"teacher_names"`
|
|
}
|
|
|
|
// Resubmit 被打回后补齐重新提交
|
|
func (s *ClassService) Resubmit(classID, userID uint, req *ResubmitReq) error {
|
|
var class model.Class
|
|
if err := s.db.First(&class, classID).Error; err != nil {
|
|
return errors.New("班级不存在")
|
|
}
|
|
if class.AdminUserID != userID {
|
|
return errors.New("只有班级管理员可以重新提交审核")
|
|
}
|
|
if class.Status != model.ClassRejected && class.Status != model.ClassPending {
|
|
return errors.New("当前状态不支持重新提交")
|
|
}
|
|
students := cleanNames(req.StudentNames)
|
|
teachers := cleanNames(req.TeacherNames)
|
|
|
|
if len(students) < model.MinStudentCount {
|
|
return fmt.Errorf("同学名单至少需要 %d 人,当前仅 %d 人", model.MinStudentCount, len(students))
|
|
}
|
|
if req.GraduationPhoto != "" {
|
|
class.GraduationPhoto = req.GraduationPhoto
|
|
}
|
|
class.SetStudentNames(students)
|
|
class.SetTeacherNames(teachers)
|
|
class.Status = model.ClassPending
|
|
class.RejectReason = ""
|
|
// 重新提交后审核截止时间顺延?保持原截止时间,30天从创建算起
|
|
if err := s.db.Save(&class).Error; err != nil {
|
|
return err
|
|
}
|
|
// 审核日志
|
|
log := &model.AuditLog{
|
|
ClassID: class.ID,
|
|
AuditorID: userID,
|
|
Action: model.AuditSubmit,
|
|
Reason: fmt.Sprintf("重新提交,补齐同学 %d 人、老师 %d 人", len(students), len(teachers)),
|
|
}
|
|
s.db.Create(log)
|
|
return nil
|
|
}
|