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
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"photowall/internal/model"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AdminService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewAdminService(db *gorm.DB) *AdminService {
|
||||
return &AdminService{db: db}
|
||||
}
|
||||
|
||||
// AuditQueueReq 审核队列查询
|
||||
type AuditQueueReq struct {
|
||||
Status model.ClassStatus `form:"status"`
|
||||
Keyword string `form:"keyword"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
// ListAuditQueue 平台管理员查看审核队列
|
||||
func (s *AdminService) ListAuditQueue(req *AuditQueueReq) (*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.ClassDeleted)
|
||||
if req.Status != "" {
|
||||
db = db.Where("status = ?", req.Status)
|
||||
}
|
||||
if req.Keyword != "" {
|
||||
db = db.Where("name LIKE ?", "%"+req.Keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
var list []model.Class
|
||||
err := db.Order("audit_deadline asc, 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
|
||||
}
|
||||
|
||||
// Approve 审核通过
|
||||
func (s *AdminService) Approve(classID, auditorID uint, reason string) error {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return errors.New("班级已删除")
|
||||
}
|
||||
class.Status = model.ClassActive
|
||||
class.RejectReason = ""
|
||||
if err := s.db.Save(&class).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
s.log(classID, auditorID, model.AuditApprove, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 打回补齐
|
||||
func (s *AdminService) Reject(classID, auditorID uint, reason string) error {
|
||||
if reason == "" {
|
||||
return errors.New("请填写打回原因")
|
||||
}
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
if class.Status == model.ClassDeleted {
|
||||
return errors.New("班级已删除")
|
||||
}
|
||||
class.Status = model.ClassRejected
|
||||
class.RejectReason = reason
|
||||
if err := s.db.Save(&class).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
s.log(classID, auditorID, model.AuditReject, reason)
|
||||
return nil
|
||||
}
|
||||
|
||||
// ManualDelete 平台管理员手动删除班级
|
||||
func (s *AdminService) ManualDelete(classID, auditorID uint, reason string) error {
|
||||
var class model.Class
|
||||
if err := s.db.First(&class, classID).Error; err != nil {
|
||||
return errors.New("班级不存在")
|
||||
}
|
||||
return s.deleteClass(class, auditorID, model.AuditManualDelete, reason)
|
||||
}
|
||||
|
||||
// CleanupExpired 清理超过审核截止时间仍未通过的班级(30天未补齐自动删除)
|
||||
// 返回被删除的班级数量
|
||||
func (s *AdminService) CleanupExpired() (int, error) {
|
||||
var expired []model.Class
|
||||
// 状态为 pending 或 rejected,且已过 audit_deadline
|
||||
err := s.db.Where("status IN ? AND audit_deadline < ?",
|
||||
[]model.ClassStatus{model.ClassPending, model.ClassRejected},
|
||||
time.Now()).Find(&expired).Error
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
count := 0
|
||||
for _, class := range expired {
|
||||
if err := s.deleteClass(class, 0, model.AuditAutoDelete,
|
||||
fmt.Sprintf("超过 %d 天补齐期限未完成审核,系统自动删除", 30)); err == nil {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// deleteClass 事务删除班级及其关联数据(成员、照片、邀请码)
|
||||
func (s *AdminService) deleteClass(class model.Class, auditorID uint, action model.AuditAction, reason string) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 标记删除
|
||||
if err := tx.Model(&model.Class{}).Where("id = ?", class.ID).
|
||||
Updates(map[string]interface{}{
|
||||
"status": model.ClassDeleted,
|
||||
"reject_reason": reason,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 删除成员关系
|
||||
tx.Where("class_id = ?", class.ID).Delete(&model.ClassMember{})
|
||||
// 删除照片记录(文件保留在磁盘,避免误删;如需清理可加定时任务)
|
||||
tx.Where("class_id = ?", class.ID).Delete(&model.ClassPhoto{})
|
||||
// 禁用邀请码
|
||||
tx.Model(&model.Invitation{}).Where("class_id = ?", class.ID).Update("status", "disabled")
|
||||
// 审核日志
|
||||
log := &model.AuditLog{
|
||||
ClassID: class.ID,
|
||||
AuditorID: auditorID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
}
|
||||
return tx.Create(log).Error
|
||||
})
|
||||
}
|
||||
|
||||
func (s *AdminService) log(classID, auditorID uint, action model.AuditAction, reason string) {
|
||||
log := &model.AuditLog{
|
||||
ClassID: classID,
|
||||
AuditorID: auditorID,
|
||||
Action: action,
|
||||
Reason: reason,
|
||||
}
|
||||
s.db.Create(log)
|
||||
}
|
||||
|
||||
// AuditLogs 查看班级审核日志
|
||||
func (s *AdminService) AuditLogs(classID uint) ([]model.AuditLog, error) {
|
||||
var list []model.AuditLog
|
||||
err := s.db.Where("class_id = ?", classID).Order("id desc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// Stats 平台数据概览
|
||||
type AdminStats struct {
|
||||
TotalUsers int64 `json:"total_users"`
|
||||
TotalClasses int64 `json:"total_classes"`
|
||||
ActiveClasses int64 `json:"active_classes"`
|
||||
PendingClasses int64 `json:"pending_classes"`
|
||||
RejectedClasses int64 `json:"rejected_classes"`
|
||||
TotalSchools int64 `json:"total_schools"`
|
||||
TotalPhotos int64 `json:"total_photos"`
|
||||
}
|
||||
|
||||
func (s *AdminService) Stats() (*AdminStats, error) {
|
||||
stats := &AdminStats{}
|
||||
s.db.Model(&model.User{}).Where("role = ?", "user").Count(&stats.TotalUsers)
|
||||
s.db.Model(&model.Class{}).Where("status != ?", model.ClassDeleted).Count(&stats.TotalClasses)
|
||||
s.db.Model(&model.Class{}).Where("status = ?", model.ClassActive).Count(&stats.ActiveClasses)
|
||||
s.db.Model(&model.Class{}).Where("status = ?", model.ClassPending).Count(&stats.PendingClasses)
|
||||
s.db.Model(&model.Class{}).Where("status = ?", model.ClassRejected).Count(&stats.RejectedClasses)
|
||||
s.db.Model(&model.School{}).Count(&stats.TotalSchools)
|
||||
s.db.Model(&model.ClassPhoto{}).Count(&stats.TotalPhotos)
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ============ 详细数据统计 ============
|
||||
|
||||
type TrendItem struct {
|
||||
Date string `json:"date"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type NameCountItem struct {
|
||||
Name string `json:"name"`
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type DetailedStats struct {
|
||||
Core *AdminStats `json:"core"`
|
||||
UserTrend7d []TrendItem `json:"user_trend_7d"`
|
||||
ClassTrend7d []TrendItem `json:"class_trend_7d"`
|
||||
ClassStatus []NameCountItem `json:"class_status"`
|
||||
SchoolTypes []NameCountItem `json:"school_types"`
|
||||
TopRegions []NameCountItem `json:"top_regions"`
|
||||
GraduateYears []NameCountItem `json:"graduate_years"`
|
||||
PhotoStats map[string]int64 `json:"photo_stats"`
|
||||
}
|
||||
|
||||
func (s *AdminService) DetailedStats() (*DetailedStats, error) {
|
||||
core, _ := s.Stats()
|
||||
ds := &DetailedStats{Core: core}
|
||||
|
||||
// 近7天用户增长
|
||||
ds.UserTrend7d = s.dailyTrend("yz_pw_users", 7)
|
||||
// 近7天班级增长
|
||||
ds.ClassTrend7d = s.dailyTrend("yz_pw_classes", 7)
|
||||
|
||||
// 班级状态分布
|
||||
ds.ClassStatus = []NameCountItem{
|
||||
{Name: "待审核", Count: core.PendingClasses},
|
||||
{Name: "已通过", Count: core.ActiveClasses},
|
||||
{Name: "被打回", Count: core.RejectedClasses},
|
||||
}
|
||||
|
||||
// 学校类型分布
|
||||
type schoolTypeRow struct {
|
||||
SchoolType string `gorm:"column:school_type"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var stRows []schoolTypeRow
|
||||
s.db.Model(&model.School{}).Select("school_type, COUNT(*) as cnt").Group("school_type").Scan(&stRows)
|
||||
typeMap := map[string]string{"university": "大学", "vocational": "职业学校", "senior_high": "高中", "junior_high": "初中", "primary": "小学", "kindergarten": "幼儿园", "other": "其他"}
|
||||
for _, r := range stRows {
|
||||
name := typeMap[r.SchoolType]
|
||||
if name == "" {
|
||||
name = r.SchoolType
|
||||
}
|
||||
ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: name, Count: r.Cnt})
|
||||
}
|
||||
|
||||
// 地区TOP10(按省级行政区统计班级数)
|
||||
type regionRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
var rRows []regionRow
|
||||
s.db.Raw(`
|
||||
SELECT r.name, COUNT(c.id) as cnt
|
||||
FROM yz_pw_classes c
|
||||
JOIN yz_pw_schools s ON c.school_id = s.id
|
||||
JOIN yz_pw_regions r ON s.region_id = r.id
|
||||
WHERE c.status != 'deleted'
|
||||
GROUP BY r.name
|
||||
ORDER BY cnt DESC
|
||||
LIMIT 10
|
||||
`).Scan(&rRows)
|
||||
for _, r := range rRows {
|
||||
ds.TopRegions = append(ds.TopRegions, NameCountItem{Name: r.Name, Count: r.Cnt})
|
||||
}
|
||||
|
||||
// 毕业年份分布
|
||||
var gyRows []struct {
|
||||
GraduateYear int `gorm:"column:graduate_year"`
|
||||
Cnt int64 `gorm:"column:cnt"`
|
||||
}
|
||||
s.db.Model(&model.Class{}).Select("graduate_year, COUNT(*) as cnt").
|
||||
Where("status != ?", "deleted").Group("graduate_year").Order("graduate_year DESC").Limit(10).Scan(&gyRows)
|
||||
for _, r := range gyRows {
|
||||
ds.GraduateYears = append(ds.GraduateYears, NameCountItem{Name: fmt.Sprintf("%d届", r.GraduateYear), Count: r.Cnt})
|
||||
}
|
||||
|
||||
// 照片统计
|
||||
var gradPhotos int64
|
||||
s.db.Model(&model.Class{}).Where("graduation_photo != ? AND status != ?", "", "deleted").Count(&gradPhotos)
|
||||
ds.PhotoStats = map[string]int64{
|
||||
"graduation_photos": gradPhotos,
|
||||
"personal_photos": core.TotalPhotos,
|
||||
"total_photos": gradPhotos + core.TotalPhotos,
|
||||
}
|
||||
|
||||
return ds, nil
|
||||
}
|
||||
|
||||
// dailyTrend 查询近N天每日新增数量
|
||||
func (s *AdminService) dailyTrend(table string, days int) []TrendItem {
|
||||
var items []TrendItem
|
||||
s.db.Raw(fmt.Sprintf(`
|
||||
SELECT DATE(created_at) as date, COUNT(*) as count
|
||||
FROM %s
|
||||
WHERE created_at >= DATE_SUB(CURDATE(), INTERVAL %d DAY)
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date ASC
|
||||
`, table, days)).Scan(&items)
|
||||
// 补全缺失的日期
|
||||
result := make([]TrendItem, 0, days)
|
||||
now := time.Now()
|
||||
for i := days - 1; i >= 0; i-- {
|
||||
date := now.AddDate(0, 0, -i).Format("2006-01-02")
|
||||
count := int64(0)
|
||||
for _, item := range items {
|
||||
if item.Date == date {
|
||||
count = item.Count
|
||||
break
|
||||
}
|
||||
}
|
||||
result = append(result, TrendItem{Date: date, Count: count})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// ============ 账号封禁管理 ============
|
||||
|
||||
type BanUserReq struct {
|
||||
UserID uint `json:"user_id" binding:"required"`
|
||||
Reason string `json:"reason" binding:"required"`
|
||||
}
|
||||
|
||||
// BanUser 封禁用户
|
||||
func (s *AdminService) BanUser(req *BanUserReq, adminID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, req.UserID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return errors.New("不能封禁管理员账号")
|
||||
}
|
||||
now := time.Now()
|
||||
user.Status = "banned"
|
||||
user.BanReason = req.Reason
|
||||
user.BannedAt = &now
|
||||
return s.db.Save(&user).Error
|
||||
}
|
||||
|
||||
// UnbanUser 解封用户
|
||||
func (s *AdminService) UnbanUser(userID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
user.Status = "active"
|
||||
user.BanReason = ""
|
||||
user.BannedAt = nil
|
||||
return s.db.Save(&user).Error
|
||||
}
|
||||
|
||||
// ListBannedUsers 获取封禁用户列表
|
||||
func (s *AdminService) ListBannedUsers() ([]model.User, error) {
|
||||
var list []model.User
|
||||
err := s.db.Where("status = ?", "banned").Order("banned_at desc").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ============ 敏感词管理 ============
|
||||
|
||||
// ListSensitiveWords 获取敏感词列表
|
||||
func (s *AdminService) ListSensitiveWords() ([]model.SensitiveWord, error) {
|
||||
var list []model.SensitiveWord
|
||||
err := s.db.Order("category, id").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// AddSensitiveWord 添加敏感词
|
||||
func (s *AdminService) AddSensitiveWord(word, category string) (*model.SensitiveWord, error) {
|
||||
var count int64
|
||||
s.db.Model(&model.SensitiveWord{}).Where("word = ?", word).Count(&count)
|
||||
if count > 0 {
|
||||
return nil, errors.New("该敏感词已存在")
|
||||
}
|
||||
sw := &model.SensitiveWord{Word: word, Category: category, Enabled: true}
|
||||
if err := s.db.Create(sw).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return sw, nil
|
||||
}
|
||||
|
||||
// DeleteSensitiveWord 删除敏感词
|
||||
func (s *AdminService) DeleteSensitiveWord(id uint) error {
|
||||
return s.db.Delete(&model.SensitiveWord{}, id).Error
|
||||
}
|
||||
|
||||
// ToggleSensitiveWord 启用/禁用敏感词
|
||||
func (s *AdminService) ToggleSensitiveWord(id uint, enabled bool) error {
|
||||
return s.db.Model(&model.SensitiveWord{}).Where("id = ?", id).Update("enabled", enabled).Error
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"photowall/internal/config"
|
||||
"photowall/internal/model"
|
||||
"photowall/pkg/captcha"
|
||||
"photowall/pkg/hash"
|
||||
"photowall/pkg/jwt"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type AuthService struct {
|
||||
db *gorm.DB
|
||||
jm *jwt.Manager
|
||||
}
|
||||
|
||||
func NewAuthService(db *gorm.DB, jm *jwt.Manager) *AuthService {
|
||||
return &AuthService{db: db, jm: jm}
|
||||
}
|
||||
|
||||
// ============ 请求结构 ============
|
||||
|
||||
type RegisterReq struct {
|
||||
Username string `json:"username" binding:"required,min=3,max=32"`
|
||||
Password string `json:"password" binding:"required,min=6,max=64"`
|
||||
Nickname string `json:"nickname"`
|
||||
Email string `json:"email"`
|
||||
CaptchaID string `json:"captcha_id" binding:"required"`
|
||||
CaptchaCode string `json:"captcha_code" binding:"required"`
|
||||
}
|
||||
|
||||
type LoginReq struct {
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
LoginType string `json:"login_type"` // password / sms
|
||||
Phone string `json:"phone"`
|
||||
SmsCode string `json:"sms_code"`
|
||||
CaptchaID string `json:"captcha_id"`
|
||||
CaptchaCode string `json:"captcha_code"`
|
||||
}
|
||||
|
||||
type LoginResp struct {
|
||||
Token string `json:"token"`
|
||||
User model.User `json:"user"`
|
||||
ExpireIn int `json:"expire_in"`
|
||||
}
|
||||
|
||||
type ForgotPasswordReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
Email string `json:"email" binding:"required"`
|
||||
}
|
||||
|
||||
type ResetPasswordReq struct {
|
||||
Username string `json:"username" binding:"required"`
|
||||
ResetCode string `json:"reset_code" binding:"required"`
|
||||
NewPassword string `json:"new_password" binding:"required,min=6,max=64"`
|
||||
}
|
||||
|
||||
type SendSmsReq struct {
|
||||
Phone string `json:"phone" binding:"required"`
|
||||
Scene string `json:"scene" binding:"required"` // login / register / reset_password
|
||||
}
|
||||
|
||||
// ============ 注册 ============
|
||||
|
||||
func (s *AuthService) Register(req *RegisterReq) (*model.User, error) {
|
||||
// 校验图形验证码
|
||||
if !captcha.Verify(req.CaptchaID, req.CaptchaCode) {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
var count int64
|
||||
if err := s.db.Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil, errors.New("用户名已被注册")
|
||||
}
|
||||
salt, pwdHash := hash.Password(req.Password)
|
||||
nickname := req.Nickname
|
||||
if nickname == "" {
|
||||
nickname = req.Username
|
||||
}
|
||||
user := &model.User{
|
||||
Username: req.Username,
|
||||
PasswordHash: pwdHash,
|
||||
Salt: salt,
|
||||
Email: req.Email,
|
||||
Nickname: nickname,
|
||||
Role: "user",
|
||||
}
|
||||
if err := s.db.Create(user).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return user, nil
|
||||
}
|
||||
|
||||
// ============ 登录 ============
|
||||
|
||||
func (s *AuthService) Login(req *LoginReq) (*LoginResp, error) {
|
||||
// 图形验证码校验(密码登录必须,短信登录可选)
|
||||
if req.LoginType != "sms" {
|
||||
if !captcha.Verify(req.CaptchaID, req.CaptchaCode) {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
}
|
||||
|
||||
var user model.User
|
||||
if req.LoginType == "sms" {
|
||||
// 短信验证码登录
|
||||
if req.Phone == "" || req.SmsCode == "" {
|
||||
return nil, errors.New("手机号和验证码不能为空")
|
||||
}
|
||||
// 校验短信验证码
|
||||
var sms model.SmsCode
|
||||
if err := s.db.Where("phone = ? AND scene = ? AND used = ?", req.Phone, "login", false).
|
||||
Order("id DESC").First(&sms).Error; err != nil {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
if sms.Code != req.SmsCode || time.Now().After(sms.ExpireAt) {
|
||||
return nil, errors.New("验证码错误或已过期")
|
||||
}
|
||||
sms.Used = true
|
||||
s.db.Save(&sms)
|
||||
// 查找或创建用户
|
||||
if err := s.db.Where("username = ?", req.Phone).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
// 自动注册
|
||||
salt, pwdHash := hash.Password(randomCode(8))
|
||||
user = model.User{
|
||||
Username: req.Phone,
|
||||
PasswordHash: pwdHash,
|
||||
Salt: salt,
|
||||
Nickname: "手机用户" + req.Phone[len(req.Phone)-4:],
|
||||
Role: "user",
|
||||
}
|
||||
s.db.Create(&user)
|
||||
} else {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 账号密码登录
|
||||
if req.Username == "" || req.Password == "" {
|
||||
return nil, errors.New("用户名和密码不能为空")
|
||||
}
|
||||
if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !hash.Verify(user.PasswordHash, user.Salt, req.Password) {
|
||||
return nil, errors.New("用户名或密码错误")
|
||||
}
|
||||
}
|
||||
|
||||
// 检查账号状态
|
||||
if user.Status == "banned" {
|
||||
return nil, fmt.Errorf("账号已被封禁:%s", user.BanReason)
|
||||
}
|
||||
|
||||
token, err := s.jm.Generate(user.ID, user.Username, user.Role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &LoginResp{Token: token, User: user, ExpireIn: s.jm.ExpireHrs}, nil
|
||||
}
|
||||
|
||||
// ============ 忘记密码 ============
|
||||
|
||||
func (s *AuthService) ForgotPassword(req *ForgotPasswordReq) (string, error) {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ? AND email = ?", req.Username, req.Email).First(&user).Error; err != nil {
|
||||
return "", errors.New("用户名与邮箱不匹配")
|
||||
}
|
||||
// 生成6位重置码
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
reset := &model.PasswordReset{
|
||||
UserID: user.ID,
|
||||
Code: code,
|
||||
ExpireAt: time.Now().Add(30 * time.Minute),
|
||||
}
|
||||
if err := s.db.Create(reset).Error; err != nil {
|
||||
return "", err
|
||||
}
|
||||
// 开发阶段:重置码直接返回(生产环境应发送邮件)
|
||||
// TODO: 接入邮件服务后,通过邮箱发送重置码
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ============ 重置密码 ============
|
||||
|
||||
func (s *AuthService) ResetPassword(req *ResetPasswordReq) error {
|
||||
var user model.User
|
||||
if err := s.db.Where("username = ?", req.Username).First(&user).Error; err != nil {
|
||||
return errors.New("用户不存在")
|
||||
}
|
||||
var reset model.PasswordReset
|
||||
if err := s.db.Where("user_id = ? AND code = ? AND used = ?", user.ID, req.ResetCode, false).
|
||||
Order("id DESC").First(&reset).Error; err != nil {
|
||||
return errors.New("重置码错误或已过期")
|
||||
}
|
||||
if time.Now().After(reset.ExpireAt) {
|
||||
return errors.New("重置码已过期")
|
||||
}
|
||||
salt, pwdHash := hash.Password(req.NewPassword)
|
||||
user.PasswordHash = pwdHash
|
||||
user.Salt = salt
|
||||
reset.Used = true
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Save(&user).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Save(&reset).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ============ 发送短信验证码(预留) ============
|
||||
|
||||
func (s *AuthService) SendSmsCode(req *SendSmsReq) (string, error) {
|
||||
cfg := config.C
|
||||
// 参数未配置时返回提示(开发阶段直接返回验证码)
|
||||
if cfg.SMSAccessKey == "" || cfg.SMSSecretKey == "" {
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
sms := &model.SmsCode{
|
||||
Phone: req.Phone,
|
||||
Code: code,
|
||||
Scene: req.Scene,
|
||||
ExpireAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
s.db.Create(sms)
|
||||
// 开发阶段直接返回验证码(生产环境应通过短信服务商发送)
|
||||
return code, nil
|
||||
}
|
||||
// TODO: 接入阿里云/腾讯云短信SDK后发送真实短信
|
||||
code := fmt.Sprintf("%06d", rand.Intn(1000000))
|
||||
sms := &model.SmsCode{
|
||||
Phone: req.Phone,
|
||||
Code: code,
|
||||
Scene: req.Scene,
|
||||
ExpireAt: time.Now().Add(5 * time.Minute),
|
||||
}
|
||||
s.db.Create(sms)
|
||||
return code, nil
|
||||
}
|
||||
|
||||
// ============ 微信扫码登录(预留) ============
|
||||
|
||||
type WechatLoginReq struct {
|
||||
Code string `json:"code"` // 微信授权code
|
||||
}
|
||||
|
||||
func (s *AuthService) WechatLogin(req *WechatLoginReq) (*LoginResp, error) {
|
||||
cfg := config.C
|
||||
if cfg.WechatAppID == "" || cfg.WechatAppSecret == "" {
|
||||
return nil, errors.New("微信登录未配置,请联系管理员")
|
||||
}
|
||||
// TODO: 接入微信开放平台API
|
||||
// 1. 用 code 换取 access_token 和 openid
|
||||
// 2. 用 openid 查找或创建用户
|
||||
// 3. 签发 JWT
|
||||
return nil, errors.New("微信登录功能开发中")
|
||||
}
|
||||
|
||||
// ============ 工具 ============
|
||||
|
||||
func randomCode(length int) string {
|
||||
const chars = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||
b := make([]byte, length)
|
||||
for i := range b {
|
||||
b[i] = chars[rand.Intn(len(chars))]
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
@@ -0,0 +1,628 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type ConfigService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewConfigService(db *gorm.DB) *ConfigService {
|
||||
return &ConfigService{db: db}
|
||||
}
|
||||
|
||||
// defaultConfigs 所有可配置项及说明
|
||||
var defaultConfigs = []model.SystemConfig{
|
||||
// ===== 基础配置 =====
|
||||
{Key: "site_name", Value: "PhotoWall 毕业照墙", Label: "站点名称", Description: "显示在浏览器标题和登录页的站点名称", Category: "basic", Placeholder: "PhotoWall 毕业照墙", Sort: 1},
|
||||
{Key: "jwt_secret", Value: "photowall-dev-secret-change-in-prod", Label: "JWT 密钥", Description: "用于签发和验证登录Token,生产环境务必修改为随机字符串", Category: "basic", Placeholder: "随机字符串至少32位", Sort: 2},
|
||||
{Key: "jwt_expire_hours", Value: "3", Label: "登录有效期(小时)", Description: "用户登录后Token的有效时长,超时需重新登录", Category: "basic", Placeholder: "3", Sort: 3},
|
||||
{Key: "audit_grace_days", Value: "30", Label: "审核补齐期限(天)", Description: "班级被打回后,超过该天数未补齐则自动删除", Category: "basic", Placeholder: "30", Sort: 4},
|
||||
|
||||
// ===== 极验行为验证 =====
|
||||
{Key: "geetest_id", Value: "", Label: "极验 ID", Description: "极验行为验证的Captcha ID,用于防机器人和暴力破解", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 1},
|
||||
{Key: "geetest_key", Value: "", Label: "极验 Key", Description: "极验行为验证的私钥,与ID配对使用", Category: "geetest", Placeholder: "如:64a####################", DocLink: "https://www.geetest.com 注册后在应用管理获取", Sort: 2},
|
||||
|
||||
// ===== 短信验证码 =====
|
||||
{Key: "sms_provider", Value: "aliyun", Label: "短信服务商", Description: "选择短信服务商,目前支持阿里云和腾讯云", Category: "sms", Placeholder: "aliyun 或 tencent", DocLink: "阿里云: https://dysms.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/sms", Sort: 1},
|
||||
{Key: "sms_access_key", Value: "", Label: "短信 AccessKey", Description: "短信服务商的访问密钥ID", Category: "sms", Placeholder: "如:LTAI5t################", DocLink: "阿里云: https://ram.console.aliyun.com | 腾讯云: https://console.cloud.tencent.com/cam", Sort: 2},
|
||||
{Key: "sms_secret_key", Value: "", Label: "短信 SecretKey", Description: "短信服务商的访问密钥密码", Category: "sms", Placeholder: "如:########################", DocLink: "与AccessKey在同一页面获取", Sort: 3},
|
||||
{Key: "sms_sign_name", Value: "", Label: "短信签名", Description: "短信签名,需在短信服务商审核通过,如:PhotoWall", Category: "sms", Placeholder: "如:PhotoWall", DocLink: "在短信服务商控制台的国内消息-签名管理中申请", Sort: 4},
|
||||
{Key: "sms_template_code", Value: "", Label: "短信模板CODE", Description: "短信模板编码,需在短信服务商审核通过,模板内容需包含验证码变量", Category: "sms", Placeholder: "如:SMS_123456789", DocLink: "在短信服务商控制台的国内消息-模板管理中申请", Sort: 5},
|
||||
|
||||
// ===== 微信扫码登录 =====
|
||||
{Key: "wechat_app_id", Value: "", Label: "微信 AppID", Description: "微信开放平台应用的AppID,用于扫码登录", Category: "wechat", Placeholder: "如:wx################", DocLink: "https://open.weixin.qq.com 注册开发者账号并创建网站应用", Sort: 1},
|
||||
{Key: "wechat_app_secret", Value: "", Label: "微信 AppSecret", Description: "微信开放平台应用的密钥,与AppID配对", Category: "wechat", Placeholder: "如:########################", DocLink: "在微信开放平台应用详情中查看", Sort: 2},
|
||||
{Key: "wechat_redirect_uri", Value: "", Label: "微信回调地址", Description: "微信授权后的回调URL,需与开放平台配置一致,需HTTPS", Category: "wechat", Placeholder: "如:https://yourdomain.com/api/auth/wechat/callback", DocLink: "在微信开放平台应用的网站应用-授权回调域中配置", Sort: 3},
|
||||
|
||||
// ===== 存储配置 =====
|
||||
{Key: "upload_dir", Value: "uploads", Label: "上传目录", Description: "图片文件存储的相对路径", Category: "storage", Placeholder: "uploads", Sort: 1},
|
||||
{Key: "max_file_size_mb", Value: "10", Label: "单文件大小上限(MB)", Description: "上传图片的最大大小限制", Category: "storage", Placeholder: "10", Sort: 2},
|
||||
}
|
||||
|
||||
// InitDefaults 初始化默认配置(不存在则创建)
|
||||
func (s *ConfigService) InitDefaults() error {
|
||||
for _, c := range defaultConfigs {
|
||||
var count int64
|
||||
s.db.Model(&model.SystemConfig{}).Where("`key` = ?", c.Key).Count(&count)
|
||||
if count == 0 {
|
||||
if err := s.db.Create(&c).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有配置(按分类和排序)
|
||||
func (s *ConfigService) GetAll() ([]model.SystemConfig, error) {
|
||||
var list []model.SystemConfig
|
||||
err := s.db.Order("category, sort").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetByCategory 按分类获取配置
|
||||
func (s *ConfigService) GetByCategory(category string) ([]model.SystemConfig, error) {
|
||||
var list []model.SystemConfig
|
||||
err := s.db.Where("category = ?", category).Order("sort").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// UpdateReq 更新配置请求
|
||||
type UpdateConfigReq struct {
|
||||
Configs map[string]string `json:"configs" binding:"required"`
|
||||
}
|
||||
|
||||
// Update 批量更新配置
|
||||
func (s *ConfigService) Update(req *UpdateConfigReq) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for key, value := range req.Configs {
|
||||
if err := tx.Model(&model.SystemConfig{}).Where("`key` = ?", key).Update("value", value).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// GetValue 获取单个配置值
|
||||
func (s *ConfigService) GetValue(key string) string {
|
||||
var c model.SystemConfig
|
||||
if err := s.db.Where("`key` = ?", key).First(&c).Error; err != nil {
|
||||
return ""
|
||||
}
|
||||
return c.Value
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type MenuService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewMenuService(db *gorm.DB) *MenuService {
|
||||
return &MenuService{db: db}
|
||||
}
|
||||
|
||||
// defaultMenus 默认菜单数据
|
||||
var defaultMenus = []model.Menu{
|
||||
// 首页
|
||||
{ID: 1, ParentID: 0, Name: "Home", Path: "/", Component: "home/index", Title: "首页", Icon: "fas fa-home", Sort: 1, Visible: true, RequireAuth: false, RequireAdmin: false},
|
||||
// 班级管理
|
||||
{ID: 2, ParentID: 0, Name: "Class", Path: "/class", Component: "", Title: "班级管理", Icon: "fas fa-users", Sort: 2, Visible: true, RequireAuth: false, RequireAdmin: false, Redirect: "/class/list"},
|
||||
{ID: 3, ParentID: 2, Name: "ClassList", Path: "/class/list", Component: "class/list/index", Title: "班级列表", Icon: "fas fa-list", Sort: 1, Visible: true, RequireAuth: false, RequireAdmin: false},
|
||||
{ID: 4, ParentID: 2, Name: "ClassEdit", Path: "/class/edit", Component: "class/edit/index", Title: "创建班级", Icon: "fas fa-plus", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: false},
|
||||
{ID: 5, ParentID: 2, Name: "ClassJoin", Path: "/class/join", Component: "class/join/index", Title: "加入班级", Icon: "fas fa-sign-in-alt", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: false},
|
||||
{ID: 6, ParentID: 2, Name: "ClassDetail", Path: "/class/detail/:id", Component: "class/detail/index", Title: "班级详情", Icon: "", Sort: 4, Visible: false, RequireAuth: false, RequireAdmin: false},
|
||||
// 个人中心
|
||||
{ID: 7, ParentID: 0, Name: "Profile", Path: "/profile", Component: "normalsettings/usersettings/detail/index", Title: "个人中心", Icon: "fas fa-user", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: false},
|
||||
// 系统管理(管理员)
|
||||
{ID: 8, ParentID: 0, Name: "Admin", Path: "/admin", Component: "", Title: "系统管理", Icon: "fas fa-cog", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/admin/dashboard"},
|
||||
{ID: 18, ParentID: 8, Name: "Dashboard", Path: "/admin/dashboard", Component: "admin/dashboard/index", Title: "数据统计", Icon: "fas fa-chart-line", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 9, ParentID: 8, Name: "AdminAudit", Path: "/admin/audit", Component: "admin/audit/list/index", Title: "审核管理", Icon: "fas fa-clipboard-check", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 10, ParentID: 8, Name: "AdminAuditDetail", Path: "/admin/audit/detail/:id", Component: "admin/audit/detail/index", Title: "审核详情", Icon: "", Sort: 3, Visible: false, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 11, ParentID: 8, Name: "AdminConfig", Path: "/admin/config", Component: "admin/config/index", Title: "系统配置", Icon: "fas fa-sliders-h", Sort: 4, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 12, ParentID: 8, Name: "AdminMenu", Path: "/admin/menu", Component: "admin/menu/index", Title: "菜单管理", Icon: "fas fa-bars", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 19, ParentID: 8, Name: "UserManage", Path: "/admin/users", Component: "admin/users/index", Title: "用户管理", Icon: "fas fa-user-shield", Sort: 6, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 20, ParentID: 8, Name: "SensitiveWord", Path: "/admin/sensitive", Component: "admin/sensitive/index", Title: "敏感词管理", Icon: "fas fa-shield-alt", Sort: 7, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
// 基本设置(管理员)
|
||||
{ID: 13, ParentID: 0, Name: "NormalSettings", Path: "/normalsettings", Component: "", Title: "基本设置", Icon: "fas fa-wrench", Sort: 5, Visible: true, RequireAuth: true, RequireAdmin: true, Redirect: "/normalsettings/usersettings"},
|
||||
{ID: 14, ParentID: 13, Name: "UserSettings", Path: "/normalsettings/usersettings", Component: "normalsettings/usersettings/list/index", Title: "用户设置", Icon: "fas fa-user-cog", Sort: 1, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 15, ParentID: 13, Name: "RoleSettings", Path: "/normalsettings/rolesettings", Component: "normalsettings/rolesettings/list/index", Title: "角色设置", Icon: "fas fa-user-tag", Sort: 2, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
{ID: 16, ParentID: 13, Name: "LogManagement", Path: "/normalsettings/logmanagement", Component: "normalsettings/logmanagement/list/index", Title: "日志管理", Icon: "fas fa-file-alt", Sort: 3, Visible: true, RequireAuth: true, RequireAdmin: true},
|
||||
// 登录页(不显示在菜单)
|
||||
{ID: 17, ParentID: 0, Name: "Login", Path: "/login", Component: "auth/login/index", Title: "登录", Icon: "", Sort: 99, Visible: false, RequireAuth: false, RequireAdmin: false},
|
||||
}
|
||||
|
||||
// InitDefaults 初始化默认菜单(不存在则创建)
|
||||
func (s *MenuService) InitDefaults() error {
|
||||
for _, m := range defaultMenus {
|
||||
var count int64
|
||||
s.db.Model(&model.Menu{}).Where("id = ? OR name = ?", m.ID, m.Name).Count(&count)
|
||||
if count == 0 {
|
||||
if err := s.db.Create(&m).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetMenuTree 获取菜单树(isAdmin 过滤管理员菜单)
|
||||
func (s *MenuService) GetMenuTree(isAdmin bool) ([]*model.MenuTree, error) {
|
||||
var all []model.Menu
|
||||
query := s.db.Order("parent_id, sort")
|
||||
if !isAdmin {
|
||||
query = query.Where("require_admin = ?", false)
|
||||
}
|
||||
if err := query.Find(&all).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return buildTree(all, 0), nil
|
||||
}
|
||||
|
||||
// GetAll 获取所有菜单(管理用,含隐藏)
|
||||
func (s *MenuService) GetAll() ([]model.Menu, error) {
|
||||
var list []model.Menu
|
||||
err := s.db.Order("parent_id, sort").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetByID 获取单个菜单
|
||||
func (s *MenuService) GetByID(id uint) (*model.Menu, error) {
|
||||
var m model.Menu
|
||||
if err := s.db.First(&m, id).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &m, nil
|
||||
}
|
||||
|
||||
// Create 创建菜单
|
||||
func (s *MenuService) Create(m *model.Menu) error {
|
||||
return s.db.Create(m).Error
|
||||
}
|
||||
|
||||
// Update 更新菜单
|
||||
func (s *MenuService) Update(m *model.Menu) error {
|
||||
return s.db.Save(m).Error
|
||||
}
|
||||
|
||||
// Delete 删除菜单(同时删除子菜单)
|
||||
func (s *MenuService) Delete(id uint) error {
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 递归删除子菜单
|
||||
var children []model.Menu
|
||||
tx.Where("parent_id = ?", id).Find(&children)
|
||||
for _, child := range children {
|
||||
if err := tx.Delete(&child).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Delete(&model.Menu{}, id).Error
|
||||
})
|
||||
}
|
||||
|
||||
// buildTree 构建菜单树
|
||||
func buildTree(list []model.Menu, parentID uint) []*model.MenuTree {
|
||||
var tree []*model.MenuTree
|
||||
for i := range list {
|
||||
if list[i].ParentID == parentID {
|
||||
node := &model.MenuTree{
|
||||
Menu: list[i],
|
||||
Children: buildTree(list, list[i].ID),
|
||||
}
|
||||
tree = append(tree, node)
|
||||
}
|
||||
}
|
||||
return tree
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type RegionService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewRegionService(db *gorm.DB) *RegionService {
|
||||
return &RegionService{db: db}
|
||||
}
|
||||
|
||||
// ListByParent 根据父级ID查子地区;parentID=0 查国家
|
||||
func (s *RegionService) ListByParent(parentID uint) ([]model.Region, error) {
|
||||
var list []model.Region
|
||||
err := s.db.Where("parent_id = ?", parentID).Order("sort_order, name").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// ListCountries 查所有国家
|
||||
func (s *RegionService) ListCountries() ([]model.Region, error) {
|
||||
var list []model.Region
|
||||
err := s.db.Where("level = ?", model.LevelCountry).Order("sort_order, name").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// GetPath 获取某地区的完整路径(国家->省->县)
|
||||
func (s *RegionService) GetPath(regionID uint) ([]model.Region, error) {
|
||||
var r model.Region
|
||||
if err := s.db.First(&r, regionID).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
path := []model.Region{r}
|
||||
cur := r
|
||||
for cur.ParentID != 0 {
|
||||
var parent model.Region
|
||||
if err := s.db.First(&parent, cur.ParentID).Error; err != nil {
|
||||
break
|
||||
}
|
||||
// 插到前面
|
||||
path = append([]model.Region{parent}, path...)
|
||||
cur = parent
|
||||
}
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Search 按名称搜索地区(用于快速定位)
|
||||
func (s *RegionService) Search(keyword string, limit int) ([]model.Region, error) {
|
||||
if limit <= 0 {
|
||||
limit = 20
|
||||
}
|
||||
var list []model.Region
|
||||
err := s.db.Where("name LIKE ? OR name_en LIKE ?", "%"+keyword+"%", "%"+keyword+"%").
|
||||
Order("level, sort_order, name").Limit(limit).Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// BatchCreate 平台管理员批量录入地区(导入完整行政区划数据用)
|
||||
func (s *RegionService) BatchCreate(regions []model.Region) error {
|
||||
if len(regions) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.CreateInBatches(regions, 100).Error
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type SchoolService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewSchoolService(db *gorm.DB) *SchoolService {
|
||||
return &SchoolService{db: db}
|
||||
}
|
||||
|
||||
type SchoolQuery struct {
|
||||
RegionID uint `form:"region_id"`
|
||||
SchoolType model.SchoolType `form:"school_type"`
|
||||
Keyword string `form:"keyword"`
|
||||
Page int `form:"page"`
|
||||
PageSize int `form:"page_size"`
|
||||
}
|
||||
|
||||
// PageResp 通用分页响应
|
||||
type PageResp struct {
|
||||
List interface{} `json:"list"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
}
|
||||
|
||||
type SchoolListResp = PageResp // 别名兼容
|
||||
|
||||
func (s *SchoolService) List(q *SchoolQuery) (*SchoolListResp, error) {
|
||||
if q.Page <= 0 {
|
||||
q.Page = 1
|
||||
}
|
||||
if q.PageSize <= 0 || q.PageSize > 100 {
|
||||
q.PageSize = 20
|
||||
}
|
||||
db := s.db.Model(&model.School{})
|
||||
if q.RegionID > 0 {
|
||||
db = db.Where("region_id = ?", q.RegionID)
|
||||
}
|
||||
if q.SchoolType != "" {
|
||||
db = db.Where("school_type = ?", q.SchoolType)
|
||||
}
|
||||
if q.Keyword != "" {
|
||||
db = db.Where("name LIKE ? OR name_en LIKE ?", "%"+q.Keyword+"%", "%"+q.Keyword+"%")
|
||||
}
|
||||
var total int64
|
||||
db.Count(&total)
|
||||
var list []model.School
|
||||
err := db.Order("id desc").Offset((q.Page - 1) * q.PageSize).Limit(q.PageSize).Find(&list).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &SchoolListResp{List: list, Total: total, Page: q.Page}, nil
|
||||
}
|
||||
|
||||
// Create 创建学校(必须挂在 county 级地区下)
|
||||
func (s *SchoolService) Create(school *model.School) error {
|
||||
// 校验 region 必须是 county 级
|
||||
var region model.Region
|
||||
if err := s.db.First(®ion, school.RegionID).Error; err != nil {
|
||||
return errors.New("所属地区不存在")
|
||||
}
|
||||
if region.Level != model.LevelCounty {
|
||||
return errors.New("学校必须挂载在区/县级地区下")
|
||||
}
|
||||
if school.Name == "" {
|
||||
return errors.New("学校名称不能为空")
|
||||
}
|
||||
if school.SchoolType == "" {
|
||||
return errors.New("请选择学校类型")
|
||||
}
|
||||
return s.db.Create(school).Error
|
||||
}
|
||||
|
||||
func (s *SchoolService) GetByID(id uint) (*model.School, error) {
|
||||
var school model.School
|
||||
if err := s.db.First(&school, id).Error; err != nil {
|
||||
return nil, errors.New("学校不存在")
|
||||
}
|
||||
return &school, nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) Update(id uint, updates map[string]interface{}) error {
|
||||
res := s.db.Model(&model.School{}).Where("id = ?", id).Updates(updates)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("学校不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) Delete(id uint) error {
|
||||
// 检查是否有班级引用
|
||||
var count int64
|
||||
s.db.Model(&model.Class{}).Where("school_id = ?", id).Count(&count)
|
||||
if count > 0 {
|
||||
return errors.New("该学校下已有班级,无法删除")
|
||||
}
|
||||
return s.db.Delete(&model.School{}, id).Error
|
||||
}
|
||||
|
||||
// ---- 学院 ----
|
||||
|
||||
func (s *SchoolService) ListColleges(schoolID uint) ([]model.College, error) {
|
||||
var list []model.College
|
||||
err := s.db.Where("school_id = ?", schoolID).Order("name").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
func (s *SchoolService) CreateCollege(schoolID uint, name string) (*model.College, error) {
|
||||
if name == "" {
|
||||
return nil, errors.New("学院名称不能为空")
|
||||
}
|
||||
// 校验学校是否支持学院
|
||||
var school model.School
|
||||
if err := s.db.First(&school, schoolID).Error; err != nil {
|
||||
return nil, errors.New("学校不存在")
|
||||
}
|
||||
if !school.SchoolType.HasCollege() {
|
||||
return nil, errors.New("该学校类型不支持学院层级")
|
||||
}
|
||||
c := &model.College{SchoolID: schoolID, Name: name}
|
||||
if err := s.db.Create(c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) UpdateCollege(id uint, name string) error {
|
||||
res := s.db.Model(&model.College{}).Where("id = ?", id).Update("name", name)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("学院不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SchoolService) DeleteCollege(id uint) error {
|
||||
var count int64
|
||||
s.db.Model(&model.Class{}).Where("college_id = ?", id).Count(&count)
|
||||
if count > 0 {
|
||||
return errors.New("该学院下已有班级,无法删除")
|
||||
}
|
||||
return s.db.Delete(&model.College{}, id).Error
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"photowall/internal/model"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
type UserService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewUserService(db *gorm.DB) *UserService {
|
||||
return &UserService{db: db}
|
||||
}
|
||||
|
||||
// ProfileResp 用户完整资料
|
||||
type ProfileResp struct {
|
||||
User model.User `json:"user"`
|
||||
Contacts []model.UserContact `json:"contacts"`
|
||||
Education []model.EducationHistory `json:"education"`
|
||||
}
|
||||
|
||||
func (s *UserService) GetProfile(userID uint) (*ProfileResp, error) {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
var contacts []model.UserContact
|
||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc, id").Find(&contacts)
|
||||
var edu []model.EducationHistory
|
||||
s.db.Where("user_id = ?", userID).Order("start_year desc, id desc").Find(&edu)
|
||||
return &ProfileResp{User: user, Contacts: contacts, Education: edu}, nil
|
||||
}
|
||||
|
||||
type UpdateProfileReq struct {
|
||||
Nickname string `json:"nickname"`
|
||||
Bio string `json:"bio"`
|
||||
Avatar string `json:"avatar"`
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateProfile(userID uint, req *UpdateProfileReq) error {
|
||||
updates := map[string]interface{}{}
|
||||
if req.Nickname != "" {
|
||||
updates["nickname"] = req.Nickname
|
||||
}
|
||||
if req.Bio != "" {
|
||||
updates["bio"] = req.Bio
|
||||
}
|
||||
if req.Avatar != "" {
|
||||
updates["avatar"] = req.Avatar
|
||||
}
|
||||
if len(updates) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Model(&model.User{}).Where("id = ?", userID).Updates(updates).Error
|
||||
}
|
||||
|
||||
// AddContact 添加联系方式,校验各类型数量上限
|
||||
func (s *UserService) AddContact(userID uint, contactType model.ContactType, value string, isPrimary bool) (*model.UserContact, error) {
|
||||
if value == "" {
|
||||
return nil, errors.New("联系方式不能为空")
|
||||
}
|
||||
max, ok := model.ContactMaxCount[contactType]
|
||||
if !ok {
|
||||
return nil, errors.New("不支持的联系方式类型")
|
||||
}
|
||||
var count int64
|
||||
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, contactType).Count(&count)
|
||||
if int(count) >= max {
|
||||
return nil, fmt.Errorf("%s 最多只能添加 %d 个", contactType, max)
|
||||
}
|
||||
// 如果设为主,先取消同类型其他主
|
||||
if isPrimary {
|
||||
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, contactType).Update("is_primary", false)
|
||||
}
|
||||
c := &model.UserContact{
|
||||
UserID: userID,
|
||||
Type: contactType,
|
||||
Value: value,
|
||||
IsPrimary: isPrimary,
|
||||
}
|
||||
if err := s.db.Create(c).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return c, nil
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateContact(userID, contactID uint, value string, isPrimary *bool) error {
|
||||
var c model.UserContact
|
||||
if err := s.db.Where("id = ? AND user_id = ?", contactID, userID).First(&c).Error; err != nil {
|
||||
return errors.New("联系方式不存在")
|
||||
}
|
||||
if value != "" {
|
||||
c.Value = value
|
||||
}
|
||||
if isPrimary != nil && *isPrimary {
|
||||
s.db.Model(&model.UserContact{}).Where("user_id = ? AND type = ?", userID, c.Type).Update("is_primary", false)
|
||||
c.IsPrimary = true
|
||||
}
|
||||
return s.db.Save(&c).Error
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteContact(userID, contactID uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", contactID, userID).Delete(&model.UserContact{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("联系方式不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddEducation 添加学习履历
|
||||
func (s *UserService) AddEducation(userID uint, e *model.EducationHistory) error {
|
||||
e.UserID = userID
|
||||
e.ID = 0
|
||||
return s.db.Create(e).Error
|
||||
}
|
||||
|
||||
func (s *UserService) UpdateEducation(userID, id uint, e *model.EducationHistory) error {
|
||||
res := s.db.Model(&model.EducationHistory{}).
|
||||
Where("id = ? AND user_id = ?", id, userID).
|
||||
Updates(map[string]interface{}{
|
||||
"school_id": e.SchoolID,
|
||||
"college_id": e.CollegeID,
|
||||
"class_id": e.ClassID,
|
||||
"school_name": e.SchoolName,
|
||||
"college_name": e.CollegeName,
|
||||
"major": e.Major,
|
||||
"degree": e.Degree,
|
||||
"start_year": e.StartYear,
|
||||
"end_year": e.EndYear,
|
||||
"description": e.Description,
|
||||
})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("履历不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *UserService) DeleteEducation(userID, id uint) error {
|
||||
res := s.db.Where("id = ? AND user_id = ?", id, userID).Delete(&model.EducationHistory{})
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return errors.New("履历不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HasEducation 检查用户是否有至少一条学习履历(创建班级前置条件)
|
||||
func (s *UserService) HasEducation(userID uint) bool {
|
||||
var count int64
|
||||
s.db.Model(&model.EducationHistory{}).Where("user_id = ?", userID).Count(&count)
|
||||
return count > 0
|
||||
}
|
||||
|
||||
// GetUserByID 公开用户信息(班级成员查看)
|
||||
func (s *UserService) GetUserByID(userID uint) (*model.User, error) {
|
||||
var u model.User
|
||||
if err := s.db.First(&u, userID).Error; err != nil {
|
||||
return nil, errors.New("用户不存在")
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
// GetUserContacts 公开用户联系方式(同班同学可查看)
|
||||
func (s *UserService) GetUserContacts(userID uint) []model.UserContact {
|
||||
var list []model.UserContact
|
||||
s.db.Where("user_id = ?", userID).Order("type, is_primary desc").Find(&list)
|
||||
return list
|
||||
}
|
||||
Reference in New Issue
Block a user