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
|
||||
}
|
||||
Reference in New Issue
Block a user