Files
jiang13-bbs/backend/service/comment.go
freefire bcb4f6a603 feat(moderation): 帖评删除占位感知与硬删清场
管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 07:01:04 +08:00

355 lines
11 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
Deleted bool `json:"deleted,omitempty"` // 软删占位
Replies []CommentNode `json:"replies,omitempty"`
}
// maxReplyDepth 回复层级上限(主评论为 0 层),防止恶意无限嵌套
const maxReplyDepth = 10
// ListFloorPaged 楼层分页获取帖子评论树:
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
// 已软删评论以 tombstone 占位(计入楼层);已硬删不存在。
// totalComments 仅计未删条数(头部徽章);floors 含 tombstone 主评。
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
}
// Unscoped:把软删行一并纳入,供占位展示
base := applyCommentListVisibility(
s.db.Unscoped().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{}).
Where("deleted_at IS NULL").Count(&totalComments).Error; err != nil {
return nil, 0, 0, err
}
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
}
rootIDs := make([]uint, len(roots))
for i, r := range roots {
rootIDs[i] = r.ID
}
replyQ := applyCommentListVisibility(s.db.Unscoped().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
}
children := make(map[uint][]model.Comment, len(replies))
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, toCommentNode(c, build(c.ID)))
placed[c.ID] = true
}
return out
}
result := make([]CommentNode, len(roots))
for i := range roots {
result[i] = toCommentNode(roots[i], 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, toCommentNode(r, build(r.ID)))
break
}
}
}
return result, floors, totalComments, nil
}
// toCommentNode 软删行清空正文与用户展示信息,标 deleted
func toCommentNode(c model.Comment, replies []CommentNode) CommentNode {
if !c.DeletedAt.Valid {
return CommentNode{Comment: c, Replies: replies}
}
c.Content = ""
c.User = model.User{}
return CommentNode{Comment: c, Deleted: true, Replies: replies}
}
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审);
// 软删行:已发布的对所有人占位;待审软删仅作者/可审者可见占位。
func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Actor) *gorm.DB {
pub := model.ContentStatusPublished
pend := model.ContentStatusPending
if viewerID == 0 || actor == nil {
return db.Where(
"(deleted_at IS NULL AND status = ?) OR (deleted_at IS NOT NULL AND status = ?)",
pub, pub,
)
}
if actor.CanModerateBoard(boardID) {
return db.Where(
"(deleted_at IS NULL AND status IN ?) OR (deleted_at IS NOT NULL AND status IN ?)",
[]string{pub, pend}, []string{pub, pend},
)
}
return db.Where(
`(deleted_at IS NULL AND (status = ? OR (status = ? AND user_id = ?)))
OR (deleted_at IS NOT NULL AND (status = ? OR (status = ? AND user_id = ?)))`,
pub, pend, viewerID,
pub, pend, 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
}
}
if err := s.db.Delete(&comment).Error; err != nil {
return err
}
if comment.Status == model.ContentStatusPublished {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - 1, 0)"))
}
return nil
}
// collectDescendantIDs 收集以 root 为根的子树 ID(含自身)
func (s *CommentService) collectDescendantIDs(comment model.Comment) []uint {
var all []model.Comment
if comment.RootID != nil {
s.db.Unscoped().Where("root_id = ?", *comment.RootID).Find(&all)
} else {
s.db.Unscoped().Where("root_id = ? OR id = ?", comment.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)
}
return ids
}
// Purge 硬删评论及子树(仅板块审核权);不占位;对尚未软删的已发布条递减 comment_count。
func (s *CommentService) Purge(actor *Actor, commentID, userID uint) error {
var comment model.Comment
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return ErrCommentNotFound
}
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return ErrCommentForbidden
}
_ = userID
ids := s.collectDescendantIDs(comment)
// 仅对「尚未软删」的已发布评论减计数(软删时已减过)
var livePublished int64
s.db.Model(&model.Comment{}).
Where("id IN ? AND status = ? AND deleted_at IS NULL", ids, model.ContentStatusPublished).
Count(&livePublished)
if err := s.db.Unscoped().Delete(&model.Comment{}, ids).Error; err != nil {
return err
}
if livePublished > 0 {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished))
}
return nil
}