412 lines
13 KiB
Go
412 lines
13 KiB
Go
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},
|
|
}
|
|
|
|
// 学校类型分布(一所学校可涵盖多个学段,按学段分别计数)
|
|
typeMap := map[model.SchoolType]string{
|
|
model.SchoolUniversity: "大学",
|
|
model.SchoolVocational: "职业学校",
|
|
model.SchoolSeniorHigh: "高中",
|
|
model.SchoolJuniorHigh: "初中",
|
|
model.SchoolPrimary: "小学",
|
|
model.SchoolKindergarten: "幼儿园",
|
|
model.SchoolOther: "其他",
|
|
}
|
|
orderedTypes := []model.SchoolType{
|
|
model.SchoolUniversity, model.SchoolVocational, model.SchoolSeniorHigh,
|
|
model.SchoolJuniorHigh, model.SchoolPrimary, model.SchoolKindergarten, model.SchoolOther,
|
|
}
|
|
for _, t := range orderedTypes {
|
|
var cnt int64
|
|
s.db.Model(&model.School{}).
|
|
Where("FIND_IN_SET(?, school_types) > 0 OR ((school_types IS NULL OR school_types = '') AND school_type = ?)", t, t).
|
|
Count(&cnt)
|
|
if cnt > 0 {
|
|
ds.SchoolTypes = append(ds.SchoolTypes, NameCountItem{Name: typeMap[t], Count: 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
|
|
}
|
|
|
|
// ListUsers 获取用户列表(支持状态与关键词检索)
|
|
func (s *AdminService) ListUsers(status, keyword string) ([]model.User, error) {
|
|
var list []model.User
|
|
query := s.db.Model(&model.User{})
|
|
if status != "" && status != "all" {
|
|
query = query.Where("status = ?", status)
|
|
}
|
|
if keyword != "" {
|
|
k := "%" + keyword + "%"
|
|
query = query.Where("username LIKE ? OR real_name LIKE ? OR nickname LIKE ? OR email LIKE ? OR phone LIKE ?", k, k, k, k, k)
|
|
}
|
|
err := query.Order("id desc").Find(&list).Error
|
|
return list, err
|
|
}
|
|
|
|
// 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
|
|
}
|