feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

View File

@@ -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
}