Files
jiang13-bbs/backend/service/post.go
freefire 42c395a864 feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-15 22:58:16 +08:00

461 lines
14 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"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 帖子操作错误
var (
ErrPostNotFound = errors.New("帖子不存在")
ErrPostForbidden = errors.New("无权限操作此帖子")
)
// PostService 帖子服务
type PostService struct {
db *gorm.DB
}
func NewPostService(db *gorm.DB) *PostService {
return &PostService{db: db}
}
// PostListQuery 帖子列表查询参数
type PostListQuery struct {
BoardID uint
Page int
Size int
Sort string // latest | hot | new
Keyword string // 搜索关键词
Recommended bool // 仅精华帖
ViewerID uint // 当前查看者(0=游客)
Actor *Actor // 查看者权限快照;nil 仅 published
}
// toPostListItems 将 Post 模型批量转为不含正文的列表项
func toPostListItems(posts []model.Post) []PostListItem {
items := make([]PostListItem, 0, len(posts))
for _, p := range posts {
items = append(items, PostListItem{
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt,
Board: p.Board, User: p.User,
})
}
return items
}
// LastReplyUser 最后回复人(仅列表展示所需字段)
type LastReplyUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// LastReplyInfo 帖子最后一条已发布评论的摘要
type LastReplyInfo struct {
User LastReplyUser `json:"user"`
CreatedAt time.Time `json:"created_at"`
}
// PostListItem 帖子列表项(不含正文)
type PostListItem struct {
ID uint `json:"id"`
BoardID uint `json:"board_id"`
UserID uint `json:"user_id"`
Title string `json:"title"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
Pinned int `json:"pinned"`
Recommended bool `json:"recommended"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Status string `json:"status"`
Liked bool `json:"liked"`
CreatedAt time.Time `json:"created_at"`
LastReply *LastReplyInfo `json:"last_reply,omitempty"`
Board model.Board `json:"board"`
User model.User `json:"user"`
}
// fillLastReply 批量填充每帖最后一条已发布评论(发帖人+时间),
// 单条 SQL 取每帖最新评论,再批量取用户,避免 N+1
func (s *PostService) fillLastReply(items []PostListItem) {
if len(items) == 0 {
return
}
postIDs := make([]uint, 0, len(items))
for _, it := range items {
postIDs = append(postIDs, it.ID)
}
type latestRow struct {
PostID uint
UserID uint
CreatedAt time.Time
}
var rows []latestRow
if err := s.db.Raw(`
SELECT DISTINCT ON (post_id) post_id, user_id, created_at
FROM comments
WHERE post_id IN ? AND status = 'published' AND deleted_at IS NULL
ORDER BY post_id, created_at DESC`, postIDs).Scan(&rows).Error; err != nil || len(rows) == 0 {
return
}
userIDs := make([]uint, 0, len(rows))
seen := map[uint]bool{}
for _, r := range rows {
if !seen[r.UserID] {
seen[r.UserID] = true
userIDs = append(userIDs, r.UserID)
}
}
var users []model.User
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", userIDs).Find(&users).Error; err != nil {
return
}
userMap := make(map[uint]model.User, len(users))
for _, u := range users {
userMap[u.ID] = u
}
replyMap := make(map[uint]latestRow, len(rows))
for _, r := range rows {
replyMap[r.PostID] = r
}
for i := range items {
r, ok := replyMap[items[i].ID]
if !ok {
continue
}
u, ok := userMap[r.UserID]
if !ok {
continue
}
items[i].LastReply = &LastReplyInfo{
User: LastReplyUser{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar},
CreatedAt: r.CreatedAt,
}
}
}
// applyListVisibility 公开流可见性:published,或 pending 且(作者本人 / 该板可审者)
func applyListVisibility(db *gorm.DB, viewerID uint, actor *Actor) *gorm.DB {
if viewerID == 0 || actor == nil {
return db.Where("status = ?", model.ContentStatusPublished)
}
if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) {
return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
}
if actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 {
return db.Where(
"status = ? OR (status = ? AND (user_id = ? OR board_id IN ?))",
model.ContentStatusPublished, model.ContentStatusPending, viewerID, actor.BoardIDs,
)
}
return db.Where(
"status = ? OR (status = ? AND user_id = ?)",
model.ContentStatusPublished, model.ContentStatusPending, viewerID,
)
}
// List 获取帖子列表
func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 || q.Size > 50 {
q.Size = 20
}
query := applyListVisibility(s.db.Model(&model.Post{}), q.ViewerID, q.Actor)
if q.BoardID > 0 {
query = query.Where("board_id = ?", q.BoardID)
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
}
if q.Recommended {
query = query.Where("recommended = ?", true)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
offset := (q.Page - 1) * q.Size
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
order := `pinned DESC, GREATEST(created_at, COALESCE((
SELECT MAX(c.created_at) FROM comments c
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
), created_at)) DESC`
if q.Sort == "hot" {
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
}
// new:纯按发帖时间(新帖子)
if q.Sort == "new" {
order = "pinned DESC, created_at DESC"
}
if err := query.Order(order).Offset(offset).Limit(q.Size).
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
return nil, 0, err
}
items := toPostListItems(posts)
s.fillLastReply(items)
return items, total, nil
}
// ListByUser 获取指定用户发布的帖子。
// viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。
func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int) ([]PostListItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Model(&model.Post{}).Where("user_id = ?", userID)
if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) {
query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
} else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 {
query = query.Where(
"status = ? OR (status = ? AND board_id IN ?)",
model.ContentStatusPublished, model.ContentStatusPending, actor.BoardIDs,
)
} else {
query = query.Where("status = ?", model.ContentStatusPublished)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
offset := (page - 1) * size
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
return nil, 0, err
}
items := toPostListItems(posts)
s.fillLastReply(items)
return items, total, nil
}
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
func (s *PostService) GetAuthorID(postID uint) (uint, error) {
var post model.Post
if err := s.db.Select("user_id").First(&post, postID).Error; err != nil {
return 0, err
}
return post.UserID, nil
}
// GetBoardID 获取帖子所属板块 ID(不增加浏览量)
func (s *PostService) GetBoardID(postID uint) (uint, error) {
var post model.Post
if err := s.db.Select("board_id").First(&post, postID).Error; err != nil {
return 0, err
}
return post.BoardID, nil
}
// CountByUser 统计用户发帖数
func (s *PostService) CountByUser(userID uint) (int64, error) {
var total int64
err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error
return total, err
}
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) TogglePin(id uint) (int, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return 0, err
}
var newPinned int
if post.Pinned > 0 {
newPinned = 0
} else {
newPinned = 1
}
result := s.db.Model(&post).Update("pinned", newPinned)
if result.Error != nil {
return 0, result.Error
}
return newPinned, nil
}
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return false, err
}
newVal := !post.Recommended
result := s.db.Model(&post).Update("recommended", newVal)
if result.Error != nil {
return false, result.Error
}
return newVal, nil
}
// visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。
// loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB
func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool {
if post.Status == model.ContentStatusPublished {
return true
}
if viewerID > 0 && post.UserID == viewerID {
return true
}
if loadActor == nil {
return false
}
return loadActor().CanModerateBoard(post.BoardID)
}
// GetByIDForViewer 获取帖子详情(带可见性校验);通过校验才计入浏览量。
// viewerID 为当前登录用户(未登录传 0),loadActor 可传 nil
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*model.Post, error) {
var post model.Post
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
return nil, ErrPostNotFound
}
if !visibleToPost(&post, viewerID, loadActor) {
return nil, ErrPostNotFound
}
// 增加浏览量(待审/被拒内容不计)
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
return &post, nil
}
// EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error {
var post model.Post
if err := s.db.Select("id", "user_id", "board_id", "status").First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if !visibleToPost(&post, viewerID, loadActor) {
return ErrPostNotFound
}
return nil
}
// Create 创建帖子。status 由 handler 按角色计算:
// 管理团队成员直发 published,普通用户进入 pending 等待审核
func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType, status string) (*model.Post, error) {
title = strings.TrimSpace(title)
content = strings.TrimSpace(content)
if title == "" {
return nil, errors.New("标题不能为空")
}
if content == "" {
return nil, errors.New("内容不能为空")
}
if boardID == 0 {
return nil, errors.New("请选择板块")
}
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
status = model.ContentStatusPending
}
// 新用户 24h 冷静期校验
if err := s.checkNewUserCooldown(userID); err != nil {
return nil, err
}
post := &model.Post{
BoardID: boardID,
UserID: userID,
Title: title,
Content: content,
Tags: tags,
PostType: postType,
Status: status,
}
if err := s.db.Create(post).Error; err != nil {
return nil, err
}
// 预加载关联
s.db.Preload("Board").Preload("User").First(post, post.ID)
return post, nil
}
// checkNewUserCooldown 新用户发帖 24h 冷静期
func (s *PostService) checkNewUserCooldown(userID uint) error {
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return err
}
// 注册不足 24 小时的新用户不能发帖
if time.Since(user.CreatedAt) < 24*time.Hour {
return errors.New("新用户注册 24 小时后才能发帖")
}
return nil
}
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
func (s *PostService) Update(actor *Actor, postID, userID uint, title, content, tags string) (*model.Post, error) {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound
}
// 权限校验:作者本人或板块审核权
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
return nil, ErrPostForbidden
}
updates := map[string]interface{}{}
if title != "" {
t := strings.TrimSpace(title)
if t == "" {
return nil, errors.New("标题不能为空")
}
updates["title"] = t
}
if content != "" {
c := strings.TrimSpace(content)
if c == "" {
return nil, errors.New("内容不能为空")
}
updates["content"] = c
}
updates["tags"] = tags
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
return nil, err
}
s.db.Preload("Board").Preload("User").First(&post, post.ID)
return &post, nil
}
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)
func (s *PostService) Delete(actor *Actor, postID, userID uint) error {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
// 权限校验:作者本人或板块审核权
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
return ErrPostForbidden
}
// 软删除(gorm DeletedAt)
if err := s.db.Delete(&post).Error; err != nil {
return err
}
return nil
}