package service import ( "errors" "time" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // 管理员用户操作的业务护栏(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"` PostCount int64 `json:"post_count"` CommentCount int64 `json:"comment_count"` 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 列表顶部汇总(总数/管理团队/封禁/今日新增) type AdminUserSummary struct { Total int64 `json:"total"` Admins int64 `json:"admins"` Banned int64 `json:"banned"` TodayNew int64 `json:"today_new"` } // 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" 正常 } // 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.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 IN ?", staffRoles).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,并用聚合查询填充计数/授权板块/最近登录 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, 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) } // 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 }