feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -22,6 +22,8 @@ var (
|
||||
ErrBoardRequired = errors.New("板块管理员至少需要授权一个板块")
|
||||
// ErrBoardNotFound 授权的板块不存在
|
||||
ErrBoardNotFound = errors.New("部分板块不存在")
|
||||
// ErrOwnerOnly 仅站长可访问用户内容档案
|
||||
ErrOwnerOnly = errors.New("仅站长可查看用户内容档案")
|
||||
)
|
||||
|
||||
// onlineThreshold last_seen_at 在该窗口内视为在线
|
||||
@@ -197,9 +199,10 @@ func (s *AdminUserService) toItems(users []model.User) []AdminUserItem {
|
||||
commentCounts := map[uint]int64{}
|
||||
|
||||
var postRows []countRow
|
||||
// 列表计数统一为「已发布」口径,与评论一致;全量含驳回/软删见内容档案
|
||||
s.db.Model(&model.Post{}).
|
||||
Select("user_id, COUNT(*) AS cnt").
|
||||
Where("user_id IN ?", ids).
|
||||
Where("user_id IN ? AND status = ?", ids, model.ContentStatusPublished).
|
||||
Group("user_id").Scan(&postRows)
|
||||
for _, r := range postRows {
|
||||
postCounts[r.UserID] = r.Cnt
|
||||
@@ -433,3 +436,240 @@ func invalidateUserSessions(tx *gorm.DB, userID uint) error {
|
||||
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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user