Files
jiang13-bbs/backend/service/admin_user.go
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

783 lines
24 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"
"fmt"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// 管理员用户操作的业务护栏(handler 层映射为 4xx,前端只展示消息)
var (
// ErrAdminSelfAction 不能对自己的账号执行管理操作(自锁/误操作保护)
ErrAdminSelfAction = errors.New("不能对自己的账号执行该操作")
// ErrInvalidRole 角色入参非法
ErrInvalidRole = errors.New("角色参数无效")
// ErrProtectedOwner 站长账号受保护,任何人不可改角色/封禁
ErrProtectedOwner = errors.New("站长账号受保护,不可操作")
// ErrCannotAssignRole 当前操作者无权授予该角色
ErrCannotAssignRole = errors.New("无权授予该角色")
// ErrBoardRequired 板块管理员至少要授权一个板块
ErrBoardRequired = errors.New("板块管理员至少需要授权一个板块")
// ErrBoardNotFound 授权的板块不存在
ErrBoardNotFound = errors.New("部分板块不存在")
// ErrOwnerOnly 仅站长可访问用户内容档案
ErrOwnerOnly = errors.New("仅站长可查看用户内容档案")
)
// onlineThreshold last_seen_at 在该窗口内视为在线
const onlineThreshold = 5 * time.Minute
// AdminUserService 后台用户管理
type AdminUserService struct {
db *gorm.DB
}
func NewAdminUserService(db *gorm.DB) *AdminUserService {
return &AdminUserService{db: db}
}
// AdminUserItem 后台用户列表项:email / last_seen / 登录 IP 在 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"`
BoardIDs []uint `json:"board_ids"`
Banned bool `json:"banned"`
CanManageMessages bool `json:"can_manage_messages"`
PostCount int64 `json:"post_count"`
CommentCount int64 `json:"comment_count"`
Points int `json:"points"`
TotalPoints int `json:"total_points"`
Level int `json:"level"` // AfterFind 按 TotalPoints 计算
CreatedAt time.Time `json:"created_at"`
LastSeenAt *time.Time `json:"last_seen_at"`
Online bool `json:"online"`
LastLoginIP string `json:"last_login_ip"`
LastLoginAt *time.Time `json:"last_login_at"`
}
// AdminUserSummary 列表顶部汇总
// - online / in_cooldown / active_7d / failed_logins_24h:用户页独有运营指标
// - total / admins / banned:筛选 Tab 角标(不在顶部卡片展示,避免与仪表盘重复)
type AdminUserSummary struct {
Online int64 `json:"online"`
InCooldown int64 `json:"in_cooldown"`
Active7d int64 `json:"active_7d"`
FailedLogins24h int64 `json:"failed_logins_24h"`
Total int64 `json:"total"`
Admins int64 `json:"admins"`
Banned int64 `json:"banned"`
}
// staffRoles 管理团队角色集合
var staffRoles = []string{
string(model.RoleBoardAdmin), string(model.RoleAdmin),
string(model.RoleSuperAdmin), string(model.RoleOwner),
}
// AdminUserListQuery 用户列表查询
type AdminUserListQuery struct {
Page int
Size int
Keyword string // 用户名 / 昵称 / 邮箱模糊匹配
Role string // "" 全部 | "staff" 管理团队 | 具体角色枚举
Status string // "" 全部 | "banned" 已封禁 | "normal" 正常
Sort string // 白名单列:id / role / status / points / total_points / last_login / counts / created_at / online
Dir string // asc | desc;非法值按 desc
}
// adminUserOrderSQL 生成 ORDER BY 表达式。sort/dir 均走白名单,默认 id DESC(与历史行为一致)。
func adminUserOrderSQL(sort, dir string) string {
ord := "DESC"
if strings.EqualFold(strings.TrimSpace(dir), "asc") {
ord = "ASC"
}
switch strings.TrimSpace(sort) {
case "role":
return fmt.Sprintf(
"CASE users.role WHEN 'owner' THEN 100 WHEN 'super_admin' THEN 80 WHEN 'admin' THEN 50 WHEN 'board_admin' THEN 30 ELSE 0 END %s, users.id DESC",
ord,
)
case "status":
return fmt.Sprintf("users.banned %s, users.id DESC", ord)
case "points":
return fmt.Sprintf("users.points %s, users.id DESC", ord)
case "total_points":
return fmt.Sprintf("users.total_points %s, users.id DESC", ord)
case "created_at":
return fmt.Sprintf("users.created_at %s, users.id DESC", ord)
case "online":
return fmt.Sprintf("users.last_seen_at %s NULLS LAST, users.id DESC", ord)
case "last_login":
return fmt.Sprintf(
"(SELECT MAX(created_at) FROM login_logs WHERE login_logs.user_id = users.id AND login_logs.success = true) %s NULLS LAST, users.id DESC",
ord,
)
case "counts":
return fmt.Sprintf(
"(SELECT COUNT(*) FROM posts WHERE posts.user_id = users.id AND posts.status = '%s' AND posts.deleted_at IS NULL) %s, users.id DESC",
model.ContentStatusPublished, ord,
)
default:
return "users.id " + ord
}
}
// 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)
}
switch {
case q.Role == "staff":
query = query.Where("role IN ?", staffRoles)
case q.Role != "" && model.ValidRole(model.Role(q.Role)):
query = query.Where("role = ?", q.Role)
}
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.Clauses(clause.OrderBy{
Expression: clause.Expr{SQL: adminUserOrderSQL(q.Sort, q.Dir)},
}).
Offset((q.Page - 1) * q.Size).Limit(q.Size).
Find(&users).Error; err != nil {
return nil, err
}
items := s.toItems(users)
// 汇总:用户页独有指标(避免与仪表盘站点总量重复)
now := time.Now()
onlineSince := now.Add(-onlineThreshold)
hours, _ := (&SettingService{db: s.db}).PostCooldownHours()
activeSince := now.Add(-7 * 24 * time.Hour)
failSince := now.Add(-24 * time.Hour)
summary := AdminUserSummary{}
s.db.Model(&model.User{}).
Where("banned = ? AND last_seen_at IS NOT NULL AND last_seen_at >= ?", false, onlineSince).
Count(&summary.Online)
if hours > 0 {
cooldownSince := now.Add(-time.Duration(hours) * time.Hour)
s.db.Model(&model.User{}).
Where("banned = ? AND role = ? AND created_at >= ?", false, model.RoleUser, cooldownSince).
Count(&summary.InCooldown)
}
s.db.Model(&model.User{}).
Where("last_seen_at IS NOT NULL AND last_seen_at >= ?", activeSince).
Count(&summary.Active7d)
s.db.Model(&model.LoginLog{}).
Where("success = ? AND created_at >= ?", false, failSince).
Count(&summary.FailedLogins24h)
s.db.Model(&model.User{}).Count(&summary.Total)
s.db.Model(&model.User{}).Where("role IN ?", staffRoles).Count(&summary.Admins)
s.db.Model(&model.User{}).Where("banned = ?", true).Count(&summary.Banned)
return &AdminUserListResult{
Users: items,
Total: total,
Page: q.Page,
Size: q.Size,
Summary: summary,
}, nil
}
// toItems 批量把 User 模型转为 DTO,并用聚合查询填充计数/授权板块/最近登录
func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
items := make([]AdminUserItem, 0, len(users))
ids := make([]uint, 0, len(users))
now := time.Now()
for _, u := range users {
ids = append(ids, u.ID)
item := AdminUserItem{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Email: u.Email,
Avatar: u.Avatar,
Signature: u.Signature,
Role: string(u.Role),
BoardIDs: []uint{},
Banned: u.Banned,
CanManageMessages: u.CanManageMessages,
Points: u.Points,
TotalPoints: u.TotalPoints,
Level: u.Level,
CreatedAt: u.CreatedAt,
LastSeenAt: u.LastSeenAt,
}
if u.LastSeenAt != nil && now.Sub(*u.LastSeenAt) <= onlineThreshold {
item.Online = true
}
items = append(items, item)
}
if len(ids) == 0 {
return items
}
// 板块授权:一次查出本页全部映射
type ubRow struct {
UserID uint
BoardID uint
}
var ubRows []ubRow
s.db.Model(&model.UserBoard{}).Where("user_id IN ?", ids).
Order("board_id ASC").Scan(&ubRows)
boardsMap := map[uint][]uint{}
for _, r := range ubRows {
boardsMap[r.UserID] = append(boardsMap[r.UserID], r.BoardID)
}
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 ? AND status = ?", ids, model.ContentStatusPublished).
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
}
// 每个用户最近一次成功登录(DISTINCT ON 走 user_id+created_at 索引)
type loginRow struct {
UserID uint
IP string
CreatedAt time.Time
}
var loginRows []loginRow
s.db.Raw(`SELECT DISTINCT ON (user_id) user_id, ip, created_at
FROM login_logs WHERE user_id IN ? AND success = true
ORDER BY user_id, created_at DESC`, ids).Scan(&loginRows)
lastLogin := map[uint]loginRow{}
for _, r := range loginRows {
lastLogin[r.UserID] = r
}
for i := range items {
items[i].PostCount = postCounts[items[i].ID]
items[i].CommentCount = commentCounts[items[i].ID]
if b := boardsMap[items[i].ID]; len(b) > 0 {
items[i].BoardIDs = b
}
if l, ok := lastLogin[items[i].ID]; ok {
t := l.CreatedAt
items[i].LastLoginIP = l.IP
items[i].LastLoginAt = &t
}
}
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
}
// SetStaff 修改用户管理角色与板块授权。角色变更后强制下线,
// 使其旧 JWT(claims 中带旧角色)立即失效,需重新登录获得新身份。
// 站长账号任何时候都不可被操作;只有站长可授予超管;站长身份不可被授予。
func (s *AdminUserService) SetStaff(operator *Actor, targetID uint, role model.Role, boardIDs []uint) (*AdminUserItem, error) {
if operator == nil {
return nil, ErrCannotAssignRole
}
if operator.ID == targetID {
return nil, ErrAdminSelfAction
}
if !model.ValidRole(role) || role == model.RoleOwner {
return nil, ErrInvalidRole
}
if !operator.CanAssignRole(role) {
return nil, ErrCannotAssignRole
}
// 板块管理员:规整、去重并校验板块存在
var normBoards []uint
if role == model.RoleBoardAdmin {
seen := map[uint]bool{}
for _, b := range boardIDs {
if b > 0 && !seen[b] {
seen[b] = true
normBoards = append(normBoards, b)
}
}
if len(normBoards) == 0 {
return nil, ErrBoardRequired
}
var boardCnt int64
if err := s.db.Model(&model.Board{}).Where("id IN ?", normBoards).Count(&boardCnt).Error; err != nil {
return nil, err
}
if int(boardCnt) != len(normBoards) {
return nil, ErrBoardNotFound
}
}
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.Role == model.RoleOwner {
return ErrProtectedOwner
}
roleChanged := u.Role != role
if roleChanged {
if err := tx.Model(&u).Update("role", role).Error; err != nil {
return err
}
}
// 同步板块授权:整体替换;非板块管理员清空
if err := tx.Where("user_id = ?", targetID).Delete(&model.UserBoard{}).Error; err != nil {
return err
}
if role == model.RoleBoardAdmin {
rows := make([]model.UserBoard, 0, len(normBoards))
for _, b := range normBoards {
rows = append(rows, model.UserBoard{UserID: targetID, BoardID: b})
}
if err := tx.Create(&rows).Error; err != nil {
return err
}
}
// 任何角色变化都强制下线(板块授权变化因 Actor 每次现查 DB,无需下线)
if roleChanged {
if err := invalidateUserSessions(tx, targetID); err != nil {
return err
}
}
return nil
})
if err != nil {
return nil, err
}
return s.getItem(s.db, targetID)
}
// SetCanManageMessages 站长授予/撤销站点消息管理权限(不可操作站长账号与自己)
func (s *AdminUserService) SetCanManageMessages(operator *Actor, targetID uint, enabled bool) (*AdminUserItem, error) {
if operator == nil || operator.Role != model.RoleOwner {
return nil, ErrOwnerOnly
}
if operator.ID == 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.Role == model.RoleOwner {
return ErrProtectedOwner
}
// 超管固有全站监管,flag 无意义;强制保持 false 以免误导
if u.Role == model.RoleSuperAdmin {
enabled = false
}
if u.CanManageMessages == enabled {
return nil
}
// GORM bool 零值省略:强制 Select 写入
return tx.Model(&u).Select("can_manage_messages").Update("can_manage_messages", enabled).Error
})
if err != nil {
return nil, err
}
return s.getItem(s.db, targetID)
}
// SetBanned 封禁/解封用户。站长账号不可封禁;封禁时同事务使登录态立即失效
func (s *AdminUserService) SetBanned(operator *Actor, targetID uint, banned bool) (*AdminUserItem, error) {
if operator == nil {
return nil, ErrProtectedOwner
}
if operator.ID == 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.Role == model.RoleOwner {
return ErrProtectedOwner
}
if u.Banned == banned {
return nil
}
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)
}
// RecordLogin 记录一次登录尝试(成功/失败都写)。失败时用户可能不存在,
// userID 传 0,username 仍落库便于异常登录排查
func (s *AdminUserService) RecordLogin(userID uint, username, ip, ua string, success bool) {
if len([]rune(ua)) > 500 {
ua = string([]rune(ua)[:500])
}
log := model.LoginLog{
UserID: userID,
Username: truncateStr(username, 128),
IP: truncateStr(ip, 45),
UserAgent: ua,
Success: success,
}
// Select 强制写入全部字段:Success=false 是 Go 零值,
// 否则 GORM 会因 default 标签省略该列,导致失败记录被写成默认值
_ = s.db.Select("user_id", "username", "ip", "user_agent", "success", "created_at").
Create(&log).Error // 审计写失败不应阻断登录
}
// ListLoginLogs 查询某用户的登录历史(最近在前)
func (s *AdminUserService) ListLoginLogs(targetID uint, page, size int) ([]model.LoginLog, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 15
}
q := s.db.Model(&model.LoginLog{}).Where("user_id = ?", targetID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var logs []model.LoginLog
if err := q.Order("created_at DESC").
Offset((page - 1) * size).Limit(size).
Find(&logs).Error; err != nil {
return nil, 0, err
}
return logs, total, nil
}
func truncateStr(s string, n int) string {
r := []rune(s)
if len(r) > n {
return string(r[:n])
}
return s
}
// 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
}
// ===== 站长用户内容档案(仅 owner) =====
func requireOwnerActor(actor *Actor) error {
if actor == nil || actor.Role != model.RoleOwner {
return ErrOwnerOnly
}
return nil
}
func normalizeAuditPage(page, size int) (int, int) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
return page, size
}
// AdminUserAuditProfile 站长查看用户档案摘要
type AdminUserAuditProfile struct {
User AdminUserItem `json:"user"`
PostsTotal int64 `json:"posts_total"` // 含各状态与软删
CommentsTotal int64 `json:"comments_total"` // 含各状态与软删
MessagesTotal int64 `json:"messages_total"` // 含撤回与软删
PublishedPosts int64 `json:"published_posts"`
PublishedComments int64 `json:"published_comments"`
}
// AdminAuditPostItem 审计帖子行
type AdminAuditPostItem struct {
ID uint `json:"id"`
Title string `json:"title"`
Content string `json:"content"`
Status string `json:"status"`
Deleted bool `json:"deleted"`
BoardID uint `json:"board_id"`
BoardName string `json:"board_name"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// AdminAuditCommentItem 审计评论行
type AdminAuditCommentItem struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
Content string `json:"content"`
Status string `json:"status"`
Deleted bool `json:"deleted"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// AdminAuditMessageItem 审计聊天消息行
type AdminAuditMessageItem struct {
ID uint `json:"id"`
RoomID uint `json:"room_id"`
RoomName string `json:"room_name"`
RoomType string `json:"room_type"`
Content string `json:"content"`
Recalled bool `json:"recalled"`
Deleted bool `json:"deleted"`
CreatedAt time.Time `json:"created_at"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// GetAuditProfile 站长:用户摘要 + 全量计数
func (s *AdminUserService) GetAuditProfile(actor *Actor, userID uint) (*AdminUserAuditProfile, error) {
if err := requireOwnerActor(actor); err != nil {
return nil, err
}
item, err := s.getItem(s.db, userID)
if err != nil {
return nil, err
}
out := &AdminUserAuditProfile{User: *item}
s.db.Unscoped().Model(&model.Post{}).Where("user_id = ?", userID).Count(&out.PostsTotal)
s.db.Unscoped().Model(&model.Comment{}).Where("user_id = ?", userID).Count(&out.CommentsTotal)
s.db.Unscoped().Model(&model.ChatMessage{}).Where("sender_id = ?", userID).Count(&out.MessagesTotal)
s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&out.PublishedPosts)
s.db.Model(&model.Comment{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&out.PublishedComments)
return out, nil
}
// ListAuditPosts 站长:用户全部帖子(含软删)
func (s *AdminUserService) ListAuditPosts(actor *Actor, userID uint, page, size int) ([]AdminAuditPostItem, int64, int, error) {
if err := requireOwnerActor(actor); err != nil {
return nil, 0, page, err
}
page, size = normalizeAuditPage(page, size)
if err := s.db.First(&model.User{}, userID).Error; err != nil {
return nil, 0, page, err
}
q := s.db.Unscoped().Model(&model.Post{}).Where("user_id = ?", userID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, page, err
}
var posts []model.Post
if err := q.Preload("Board").Order("id DESC").
Offset((page - 1) * size).Limit(size).Find(&posts).Error; err != nil {
return nil, 0, page, err
}
items := make([]AdminAuditPostItem, 0, len(posts))
for _, p := range posts {
it := AdminAuditPostItem{
ID: p.ID,
Title: p.Title,
Content: truncateStr(p.Content, 240),
Status: p.Status,
BoardID: p.BoardID,
BoardName: p.Board.Name,
CreatedAt: p.CreatedAt,
}
if p.DeletedAt.Valid {
it.Deleted = true
t := p.DeletedAt.Time
it.DeletedAt = &t
}
items = append(items, it)
}
return items, total, page, nil
}
// ListAuditComments 站长:用户全部评论(含软删)
func (s *AdminUserService) ListAuditComments(actor *Actor, userID uint, page, size int) ([]AdminAuditCommentItem, int64, int, error) {
if err := requireOwnerActor(actor); err != nil {
return nil, 0, page, err
}
page, size = normalizeAuditPage(page, size)
if err := s.db.First(&model.User{}, userID).Error; err != nil {
return nil, 0, page, err
}
q := s.db.Unscoped().Model(&model.Comment{}).Where("user_id = ?", userID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, page, err
}
var comments []model.Comment
if err := q.Order("id DESC").
Offset((page - 1) * size).Limit(size).Find(&comments).Error; err != nil {
return nil, 0, page, err
}
items := make([]AdminAuditCommentItem, 0, len(comments))
for _, c := range comments {
it := AdminAuditCommentItem{
ID: c.ID,
PostID: c.PostID,
Content: truncateStr(c.Content, 240),
Status: c.Status,
CreatedAt: c.CreatedAt,
}
if c.DeletedAt.Valid {
it.Deleted = true
t := c.DeletedAt.Time
it.DeletedAt = &t
}
items = append(items, it)
}
return items, total, page, nil
}
// ListAuditMessages 站长:用户发出的全部聊天消息(含撤回/软删)
func (s *AdminUserService) ListAuditMessages(actor *Actor, userID uint, page, size int) ([]AdminAuditMessageItem, int64, int, error) {
if err := requireOwnerActor(actor); err != nil {
return nil, 0, page, err
}
page, size = normalizeAuditPage(page, size)
if err := s.db.First(&model.User{}, userID).Error; err != nil {
return nil, 0, page, err
}
q := s.db.Unscoped().Model(&model.ChatMessage{}).Where("sender_id = ?", userID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, page, err
}
var msgs []model.ChatMessage
if err := q.Order("id DESC").
Offset((page - 1) * size).Limit(size).Find(&msgs).Error; err != nil {
return nil, 0, page, err
}
roomIDs := make([]uint, 0, len(msgs))
seen := map[uint]struct{}{}
for _, m := range msgs {
if _, ok := seen[m.RoomID]; ok {
continue
}
seen[m.RoomID] = struct{}{}
roomIDs = append(roomIDs, m.RoomID)
}
roomMap := map[uint]model.ChatRoom{}
if len(roomIDs) > 0 {
var rooms []model.ChatRoom
_ = s.db.Unscoped().Where("id IN ?", roomIDs).Find(&rooms)
for _, r := range rooms {
roomMap[r.ID] = r
}
}
items := make([]AdminAuditMessageItem, 0, len(msgs))
for _, m := range msgs {
room := roomMap[m.RoomID]
name := room.Name
if name == "" {
if room.RoomType == model.ChatRoomTypeDirect {
name = "私聊"
} else if room.IsDefault {
name = "全站大厅"
} else {
name = "群聊"
}
}
content := m.Content
recalled := m.RecalledAt != nil
// 历史数据:早期撤回曾清空正文,无法还原
if recalled && content == "" {
content = "(撤回时未保留正文)"
}
it := AdminAuditMessageItem{
ID: m.ID,
RoomID: m.RoomID,
RoomName: name,
RoomType: room.RoomType,
Content: truncateStr(content, 240),
Recalled: recalled,
CreatedAt: m.CreatedAt,
}
if m.DeletedAt.Valid {
it.Deleted = true
t := m.DeletedAt.Time
it.DeletedAt = &t
}
items = append(items, it)
}
return items, total, page, nil
}