Files
photowall/go/internal/service/class_service.go
T
hero920103 57d9866dab feat: PhotoWall 毕业照存储系统初始版本
后端: Go(Gin+GORM+MySQL+Redis)
- 17张业务表(yz_pw_前缀), 自动迁移
- JWT认证(3小时) + 盐+MD5密码 + 图形验证码
- 班级CRUD/加入(8人姓名验证/邀请码)/审核/30天自动清理
- 系统配置(16项)/菜单管理(动态路由)/数据统计
- 文件MD5去重/数据隔离/账号封禁/敏感词DFA检测

前端: Vue3+Vite+Element Plus+Less+ECharts+FontAwesome
- 动态路由(数据库菜单驱动)
- 蓝白配色, H5响应式
- 登录/注册/忘记密码/个人中心
- 班级创建向导/详情/加入/列表
- 管理后台: 审核/配置/菜单/统计/用户/敏感词

数据库: MySQL 10.31.100.3:3306/photowall
管理员: hero920103 / 920103
2026-09-03 05:55:23 +08:00

629 lines
20 KiB
Go

package service
import (
"errors"
"fmt"
"photowall/internal/config"
"photowall/internal/model"
"photowall/pkg/sensitive"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
)
type ClassService struct {
db *gorm.DB
}
func NewClassService(db *gorm.DB) *ClassService {
return &ClassService{db: db}
}
// 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"`
}
// 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) {
// 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)
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. 校验学院(如果传了)
if req.CollegeID > 0 {
if !school.SchoolType.HasCollege() {
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() {
// 大学/职业学校必须选学院
return nil, errors.New("该学校需要选择所属学院")
}
// 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. 清洗名单
students := cleanNames(req.StudentNames)
teachers := cleanNames(req.TeacherNames)
// 6. 构建班级
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,
AuditDeadline: time.Now().AddDate(0, 0, config.C.AuditGraceDays),
}
class.SetStudentNames(students)
class.SetTeacherNames(teachers)
// 7. 名字不足8个直接打回
if len(students) < model.MinStudentCount {
class.Status = model.ClassRejected
class.RejectReason = fmt.Sprintf("班级同学名单仅 %d 人,需至少录入 %d 人(毕业照上通常有全部同学姓名,请补齐后重新提交)", len(students), 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
}
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
}
log := &model.AuditLog{
ClassID: class.ID,
AuditorID: userID,
Action: model.AuditSubmit,
Reason: fmt.Sprintf("创建班级,录入同学 %d 人、老师 %d 人", 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"`
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))
detail := &ClassDetail{
Class: class,
StudentNames: class.StudentNames(),
TeacherNames: class.TeacherNames(),
CanViewSensitive: canViewSensitive,
}
// 学校
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)
info := ClassMemberInfo{Member: m, User: u}
// 数据隔离:同班成员或超级管理员可查看联系方式
if canViewSensitive {
info.Contacts = s.getUserContacts(m.UserID)
}
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").Find(&list)
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 {
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(),
}
return s.db.Create(member).Error
}
// ---- 邀请码 ----
// 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) {
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
}
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
}