package service import ( "errors" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 管理员用户操作的业务护栏(handler 层映射为 400,前端只展示消息) var ( // ErrAdminSelfAction 不能对自己的账号执行管理操作(自锁/误操作保护) ErrAdminSelfAction = errors.New("不能对自己的账号执行该操作") // ErrLastAdmin 至少保留一名未封禁的管理员,防止站点失去管理入口 ErrLastAdmin = errors.New("至少保留一名未封禁的管理员") // ErrInvalidUserRole 角色入参非法 ErrInvalidUserRole = errors.New("角色参数无效") ) // AdminUserService 后台用户管理 type AdminUserService struct { db *gorm.DB } func NewAdminUserService(db *gorm.DB) *AdminUserService { return &AdminUserService{db: db} } // AdminUserItem 后台用户列表项:email / last_seen 在 User 模型上 json:"-", // 仅管理员接口通过此 DTO 显式带出 type AdminUserItem struct { ID uint `json:"id"` Username string `json:"username"` Nickname string `json:"nickname"` Email string `json:"email"` Avatar string `json:"avatar"` Signature string `json:"signature"` Role string `json:"role"` Banned bool `json:"banned"` PostCount int64 `json:"post_count"` CommentCount int64 `json:"comment_count"` CreatedAt time.Time `json:"created_at"` LastSeenAt *time.Time `json:"last_seen_at"` } // AdminUserSummary 列表顶部汇总(总数/管理员/封禁/今日新增) type AdminUserSummary struct { Total int64 `json:"total"` Admins int64 `json:"admins"` Banned int64 `json:"banned"` TodayNew int64 `json:"today_new"` } // AdminUserListQuery 用户列表查询 type AdminUserListQuery struct { Page int Size int Keyword string // 用户名 / 昵称 / 邮箱模糊匹配 Role string // "" 全部 | "admin" 仅管理员 Status string // "" 全部 | "banned" 已封禁 | "normal" 正常 } // AdminUserListResult 分页结果 + 汇总 type AdminUserListResult struct { Users []AdminUserItem `json:"users"` Total int64 `json:"total"` Page int `json:"page"` Size int `json:"size"` Summary AdminUserSummary `json:"summary"` } // List 分页查询用户并批量填充发帖/回复计数,避免 N+1 func (s *AdminUserService) List(q AdminUserListQuery) (*AdminUserListResult, error) { if q.Page < 1 { q.Page = 1 } if q.Size < 1 || q.Size > 50 { q.Size = 20 } query := s.db.Model(&model.User{}) if kw := q.Keyword; kw != "" { like := "%" + kw + "%" query = query.Where("username ILIKE ? OR nickname ILIKE ? OR email ILIKE ?", like, like, like) } if q.Role == string(model.RoleAdmin) { query = query.Where("role = ?", model.RoleAdmin) } if q.Status == "banned" { query = query.Where("banned = ?", true) } else if q.Status == "normal" { query = query.Where("banned = ?", false) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, err } var users []model.User if err := query.Order("id DESC"). Offset((q.Page - 1) * q.Size).Limit(q.Size). Find(&users).Error; err != nil { return nil, err } items := s.toItems(users) // 汇总数据(不受筛选条件影响,始终反映全站) now := time.Now() dayStart := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) summary := AdminUserSummary{} s.db.Model(&model.User{}).Count(&summary.Total) s.db.Model(&model.User{}).Where("role = ?", model.RoleAdmin).Count(&summary.Admins) s.db.Model(&model.User{}).Where("banned = ?", true).Count(&summary.Banned) s.db.Model(&model.User{}).Where("created_at >= ?", dayStart).Count(&summary.TodayNew) return &AdminUserListResult{ Users: items, Total: total, Page: q.Page, Size: q.Size, Summary: summary, }, nil } // toItems 批量把 User 模型转为 DTO,并用两条 GROUP BY 填充计数 func (s *AdminUserService) toItems(users []model.User) []AdminUserItem { items := make([]AdminUserItem, 0, len(users)) ids := make([]uint, 0, len(users)) for _, u := range users { ids = append(ids, u.ID) items = append(items, AdminUserItem{ ID: u.ID, Username: u.Username, Nickname: u.Nickname, Email: u.Email, Avatar: u.Avatar, Signature: u.Signature, Role: string(u.Role), Banned: u.Banned, CreatedAt: u.CreatedAt, LastSeenAt: u.LastSeenAt, }) } if len(ids) == 0 { return items } type countRow struct { UserID uint Cnt int64 } postCounts := map[uint]int64{} commentCounts := map[uint]int64{} var postRows []countRow s.db.Model(&model.Post{}). Select("user_id, COUNT(*) AS cnt"). Where("user_id IN ?", ids). Group("user_id").Scan(&postRows) for _, r := range postRows { postCounts[r.UserID] = r.Cnt } var commentRows []countRow s.db.Model(&model.Comment{}). Select("user_id, COUNT(*) AS cnt"). Where("user_id IN ? AND status = ?", ids, model.ContentStatusPublished). Group("user_id").Scan(&commentRows) for _, r := range commentRows { commentCounts[r.UserID] = r.Cnt } for i := range items { items[i].PostCount = postCounts[items[i].ID] items[i].CommentCount = commentCounts[items[i].ID] } return items } // getItem 读取单个用户 DTO(供变更后回传最新状态) func (s *AdminUserService) getItem(tx *gorm.DB, id uint) (*AdminUserItem, error) { var u model.User if err := tx.First(&u, id).Error; err != nil { return nil, err } items := s.toItems([]model.User{u}) return &items[0], nil } // SetRole 修改用户角色。管理员降级时递增 token_version 并撤销 refresh token, // 使其旧 JWT(claims 中仍带 admin)立即失效,需重新登录获得新角色身份 func (s *AdminUserService) SetRole(operatorID, targetID uint, role string) (*AdminUserItem, error) { if role != string(model.RoleAdmin) && role != string(model.RoleUser) { return nil, ErrInvalidUserRole } if operatorID == targetID { return nil, ErrAdminSelfAction } var item *AdminUserItem err := s.db.Transaction(func(tx *gorm.DB) error { var u model.User if err := tx.First(&u, targetID).Error; err != nil { return err } if string(u.Role) == role { return nil } // 降级管理员:确保还存在另一名未封禁管理员 if u.Role == model.RoleAdmin { var otherAdmins int64 if err := tx.Model(&model.User{}). Where("role = ? AND banned = ? AND id <> ?", model.RoleAdmin, false, targetID). Count(&otherAdmins).Error; err != nil { return err } if otherAdmins == 0 { return ErrLastAdmin } } if err := tx.Model(&u).Update("role", model.Role(role)).Error; err != nil { return err } // 管理员被降级:强制下线,避免 15 分钟 JWT 窗口内仍保有管理权限 if role == string(model.RoleUser) { if err := invalidateUserSessions(tx, targetID); err != nil { return err } } return nil }) if err != nil { return nil, err } item, err = s.getItem(s.db, targetID) return item, err } // SetBanned 封禁/解封用户。封禁时同事务使该用户全部登录态立即失效 func (s *AdminUserService) SetBanned(operatorID, targetID uint, banned bool) (*AdminUserItem, error) { if operatorID == targetID { return nil, ErrAdminSelfAction } err := s.db.Transaction(func(tx *gorm.DB) error { var u model.User if err := tx.First(&u, targetID).Error; err != nil { return err } if u.Banned == banned { return nil } // 封禁管理员:确保还存在另一名未封禁管理员,避免站点失去管理入口 if banned && u.Role == model.RoleAdmin { var otherAdmins int64 if err := tx.Model(&model.User{}). Where("role = ? AND banned = ? AND id <> ?", model.RoleAdmin, false, targetID). Count(&otherAdmins).Error; err != nil { return err } if otherAdmins == 0 { return ErrLastAdmin } } if err := tx.Model(&u).Update("banned", banned).Error; err != nil { return err } if banned { if err := invalidateUserSessions(tx, targetID); err != nil { return err } } return nil }) if err != nil { return nil, err } return s.getItem(s.db, targetID) } // invalidateUserSessions 在事务内递增 token_version 并撤销全部 refresh token, // 与 AuthService 的强制下线逻辑等价(封禁/管理员降级时调用) func invalidateUserSessions(tx *gorm.DB, userID uint) error { if err := tx.Model(&model.User{}).Where("id = ?", userID). UpdateColumn("token_version", gorm.Expr("token_version + 1")).Error; err != nil { return err } return tx.Model(&model.RefreshToken{}).Where("user_id = ? AND revoked = ?", userID, false). Updates(map[string]any{"revoked": true, "token_cipher": ""}).Error }