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"` 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 / 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 "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, 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 }