Files
jiang13-bbs/backend/service/admin_user.go
freefire 6da4309453 feat: 新增管理员用户管理功能与账号封禁体系
此提交完成了全站管理员后台核心功能开发:
1.  新增AdminUserService与管理接口,支持用户列表查询、角色变更与封禁解封
2.  重构认证链路,统一账号封禁错误码与处理逻辑
3.  新增全局强制下线机制,账号被封禁时自动弹出告知弹窗并清理登录态
4.  重构管理员后台路由与布局,统一权限校验逻辑
5.  优化通知轮询逻辑,页面切回前台时立即刷新未读计数
6.  合并原公告、外观管理页到统一后台面板
2026-09-15 02:52:26 +08:00

294 lines
8.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}