Files
jiang13-bbs/backend/service/comment.go
freefire 8c5de13899 fix: 空评论列表 500,并完善撤回审计与角色说明
空切片 JSON 统一为数组;撤回保留正文供站长阅读,成员侧居中提示与重新编辑;头像支持拖放;角色弹窗补全职责说明并汇入封禁。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 00:23:55 +08:00

312 lines
10 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 (
ErrCommentNotFound = errors.New("评论不存在")
ErrCommentForbidden = errors.New("无权限删除此评论")
)
// CommentService 评论服务
type CommentService struct {
db *gorm.DB
}
func NewCommentService(db *gorm.DB) *CommentService {
return &CommentService{db: db}
}
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
type CommentNode struct {
model.Comment
Replies []CommentNode `json:"replies,omitempty"`
}
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
const maxReplyDepth = 10
// ListFloorPaged 楼层分页获取帖子评论树:
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
// viewer 可为作者本人或该板可审者时并入 pending;游客仅 published。
// 返回:树根列表、楼层数、全部评论数(含回复)。
func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *Actor, page, size int) ([]CommentNode, int64, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 100 {
size = 20
}
base := applyCommentListVisibility(s.db.Model(&model.Comment{}).Where("post_id = ?", postID), boardID, viewerID, actor)
var floors, totalComments int64
if err := base.Session(&gorm.Session{}).
Where("parent_id IS NULL").Count(&floors).Error; err != nil {
return nil, 0, 0, err
}
if err := base.Session(&gorm.Session{}).Count(&totalComments).Error; err != nil {
return nil, 0, 0, err
}
// 1. 本页楼层(主评论)
var roots []model.Comment
if err := base.Session(&gorm.Session{}).
Where("parent_id IS NULL").
Order("created_at ASC").
Preload("User").
Offset((page - 1) * size).
Limit(size).
Find(&roots).Error; err != nil {
return nil, 0, 0, err
}
if len(roots) == 0 {
return []CommentNode{}, floors, totalComments, nil
}
// 2. 一次拉齐本页全部楼层的所有回复(root_id 冗余,免递归查询)
rootIDs := make([]uint, len(roots))
for i, r := range roots {
rootIDs[i] = r.ID
}
replyQ := applyCommentListVisibility(s.db.Where("root_id IN (?)", rootIDs), boardID, viewerID, actor)
var replies []model.Comment
if err := replyQ.Order("created_at ASC").Preload("User").Find(&replies).Error; err != nil {
return nil, 0, 0, err
}
// 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部
children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序)
for _, r := range replies {
if r.ParentID != nil {
children[*r.ParentID] = append(children[*r.ParentID], r)
}
}
placed := make(map[uint]bool, len(roots)+len(replies))
var build func(pid uint) []CommentNode
build = func(pid uint) []CommentNode {
out := []CommentNode{}
for _, c := range children[pid] {
out = append(out, CommentNode{Comment: c, Replies: build(c.ID)})
placed[c.ID] = true
}
return out
}
result := make([]CommentNode, len(roots))
for i := range roots {
result[i] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)}
placed[roots[i].ID] = true
}
// 防御性兜底:父评论不在树中的回复 → 挂到所属楼层根部,保证不丢内容
for _, r := range replies {
if placed[r.ID] || r.RootID == nil {
continue
}
for i := range result {
if result[i].ID == *r.RootID {
result[i].Replies = append(result[i].Replies, CommentNode{Comment: r, Replies: build(r.ID)})
break
}
}
}
return result, floors, totalComments, nil
}
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审)
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
if viewerID == 0 || actor == nil {
return db.Where("status = ?", model.ContentStatusPublished)
}
if actor.CanModerateBoard(boardID) {
return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
}
return db.Where(
"status = ? OR (status = ? AND user_id = ?)",
model.ContentStatusPublished, model.ContentStatusPending, viewerID,
)
}
// Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)。
// status 由 handler 按角色计算:管理团队直发 published,普通用户进入 pending;
// pending 评论不计入 comment_count,审核通过时才 +1。
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) {
content = strings.TrimSpace(content)
if content == "" {
return nil, nil, errors.New("评论内容不能为空")
}
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
status = model.ContentStatusPending
}
// 检查帖子存在且已发布(待审/被拒帖子不接受评论)
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return nil, nil, errors.New("帖子不存在")
}
if post.Status != model.ContentStatusPublished {
return nil, nil, errors.New("帖子不存在")
}
comment := &model.Comment{
PostID: postID,
UserID: userID,
Content: content,
Status: status,
}
// 子回复:校验父评论(同帖、已发布),继承楼层根与层级
var parent *model.Comment
if parentID != nil {
parent = &model.Comment{}
if err := s.db.
Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished).
First(parent).Error; err != nil {
return nil, nil, errors.New("回复的评论不存在")
}
if parent.Depth >= maxReplyDepth {
return nil, nil, errors.New("回复层级过深")
}
rootID := parent.ID
if parent.RootID != nil {
rootID = *parent.RootID
}
comment.ParentID = parentID
comment.RootID = &rootID
comment.Depth = parent.Depth + 1
}
if err := s.db.Create(comment).Error; err != nil {
return nil, nil, err
}
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
if comment.Status == model.ContentStatusPublished {
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
}
// 预加载用户
s.db.Preload("User").First(comment, comment.ID)
return comment, parent, nil
}
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
type UserCommentItem struct {
ID uint `json:"id"`
PostID uint `json:"post_id"`
PostTitle string `json:"post_title"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
}
// ListByUser 获取指定用户发表的评论(含所属帖子标题)
func (s *CommentService) ListByUser(userID uint, page, size int) ([]UserCommentItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Table("comments").
Select("comments.id, comments.post_id, posts.title AS post_title, comments.content, comments.created_at").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []UserCommentItem
offset := (page - 1) * size
if err := query.Order("comments.created_at DESC").Offset(offset).Limit(size).Scan(&items).Error; err != nil {
return nil, 0, err
}
if items == nil {
items = []UserCommentItem{}
}
return items, total, nil
}
// CountByUser 统计用户评论数(仅统计未删除帖子上的评论,与列表保持一致)
func (s *CommentService) CountByUser(userID uint) (int64, error) {
var total int64
err := s.db.Table("comments").
Joins("JOIN posts ON posts.id = comments.post_id").
Where("comments.user_id = ? AND comments.status = ? AND posts.deleted_at IS NULL", userID, model.ContentStatusPublished).
Count(&total).Error
return total, err
}
// Delete 删除评论(作者本人,或对帖子所属板块有审核权的管理成员),
// 级联软删整棵子树
func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
var comment model.Comment
if err := s.db.First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
// 权限校验:作者本人或板块审核权
if comment.UserID != userID {
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return ErrCommentNotFound
}
if !actor.CanModerateBoard(post.BoardID) {
return ErrCommentForbidden
}
}
// 收集子树:取同楼层全部评论,多轮标记出以目标为祖先的集合(含自身,深度有限必然收敛)
var all []model.Comment
if comment.RootID != nil {
s.db.Where("root_id = ?", *comment.RootID).Find(&all)
} else {
s.db.Where("root_id = ?", comment.ID).Find(&all)
}
descendants := map[uint]bool{comment.ID: true}
for i := 0; i <= maxReplyDepth; i++ {
added := false
for _, c := range all {
if c.ParentID == nil || descendants[c.ID] {
continue
}
if descendants[*c.ParentID] {
descendants[c.ID] = true
added = true
}
}
if !added {
break
}
}
ids := make([]uint, 0, len(descendants))
for id := range descendants {
ids = append(ids, id)
}
// 只有已发布评论曾计入 comment_count,递减时排除待审/被拒评论。
// all 含同楼层全部子评论(删除主评论时目标自身不在 all 内,需单独计入)
publishedCnt := 0
if comment.Status == model.ContentStatusPublished {
publishedCnt++
}
for _, c := range all {
if c.ID == comment.ID {
continue
}
if descendants[c.ID] && c.Status == model.ContentStatusPublished {
publishedCnt++
}
}
// 软删子树并按已发布条数递减帖子评论数
if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil {
return err
}
if publishedCnt > 0 {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", publishedCnt))
}
return nil
}