Files
jiang13-bbs/backend/service/comment.go
freefire c6fdbb7193 feat(comments): 软删占位、理由与恢复,管理删仅对访客打码
自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。

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

513 lines
16 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("无权限删除此评论")
ErrCommentDeleteMeta = errors.New("请填写删除理由")
ErrCommentInvalidType = errors.New("无效的删除类型")
ErrCommentNotDeleted = 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"` // 软删占位
StaffDeleted bool `json:"staff_deleted,omitempty"` // DeletedBy≠评论者
DeletedByUser *CommentActorBrief `json:"deleted_by_user,omitempty"` // 管理删执行者(公开)
Replies []CommentNode `json:"replies,omitempty"`
}
// CommentActorBrief 评论相关公开用户摘要(删评管理员链接等)
type CommentActorBrief struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// 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)
}
}
allRows := make([]model.Comment, 0, len(roots)+len(replies))
allRows = append(allRows, roots...)
allRows = append(allRows, replies...)
deleters := s.loadCommentActorsByIDs(collectStaffDeleterIDs(allRows))
placed := make(map[uint]bool, len(roots)+len(replies))
revealAudit := actor != nil && actor.CanModerateBoard(boardID)
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), revealAudit, deleters))
placed[c.ID] = true
}
return out
}
result := make([]CommentNode, len(roots))
for i := range roots {
result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, deleters)
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), revealAudit, deleters))
break
}
}
}
return result, floors, totalComments, nil
}
func collectStaffDeleterIDs(rows []model.Comment) []uint {
seen := map[uint]bool{}
var ids []uint
for _, c := range rows {
if !c.DeletedAt.Valid || c.DeletedBy == 0 || c.DeletedBy == c.UserID {
continue
}
if !seen[c.DeletedBy] {
seen[c.DeletedBy] = true
ids = append(ids, c.DeletedBy)
}
}
return ids
}
func (s *CommentService) loadCommentActorsByIDs(ids []uint) map[uint]CommentActorBrief {
out := map[uint]CommentActorBrief{}
if len(ids) == 0 {
return out
}
var users []model.User
if err := s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", ids).Find(&users).Error; err != nil {
return out
}
for _, u := range users {
out[u.ID] = CommentActorBrief{
ID: u.ID,
Username: u.Username,
Nickname: u.Nickname,
Avatar: u.Avatar,
}
}
return out
}
// toCommentNode 软删占位:清空公开正文;版主保留原文;自删理由仅版主可见。
func toCommentNode(c model.Comment, replies []CommentNode, revealAudit bool, deleters map[uint]CommentActorBrief) CommentNode {
if !c.DeletedAt.Valid {
return CommentNode{Comment: c, Replies: replies}
}
staff := c.DeletedBy != 0 && c.DeletedBy != c.UserID
if !revealAudit {
c.Content = ""
}
if !staff {
c.DeleteType = ""
if !revealAudit {
c.DeleteReason = ""
}
}
var deleter *CommentActorBrief
if staff {
if u, ok := deleters[c.DeletedBy]; ok {
cp := u
deleter = &cp
}
}
return CommentNode{
Comment: c,
Deleted: true,
StaffDeleted: staff,
DeletedByUser: deleter,
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
}
// DeleteCommentOpts 软删选项:自删须填理由;staff 须选类型,「其他」才须填理由
type DeleteCommentOpts struct {
DeleteType string
DeleteReason string
}
// Delete 软删单条评论(评论者本人或板块审核权),不级联子树;占位保留楼层。
func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts DeleteCommentOpts) error {
var comment model.Comment
if err := s.db.First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
deleteType := ""
deleteReason := strings.TrimSpace(opts.DeleteReason)
if comment.UserID == userID {
if deleteReason == "" {
return ErrCommentDeleteMeta
}
if len([]rune(deleteReason)) > 200 {
return errors.New("删除理由不能超过 200 字")
}
} else {
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
}
deleteType = strings.TrimSpace(opts.DeleteType)
if !model.ValidPostDeleteType(deleteType) {
return ErrCommentInvalidType
}
if deleteType == model.PostDeleteTypeOther {
if deleteReason == "" {
return ErrCommentDeleteMeta
}
if len([]rune(deleteReason)) > 200 {
return errors.New("删除理由不能超过 200 字")
}
} else {
// 非「其他」仅展示类型标签,不落库自由文本
deleteReason = ""
}
}
updates := map[string]interface{}{
"delete_type": deleteType,
"delete_reason": deleteReason,
"deleted_by": userID,
}
if err := s.db.Model(&comment).Updates(updates).Error; err != nil {
return err
}
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
}
// Restore 恢复软删评论:作者可恢复自己的自删;版主可恢复本板任意软删(含管理删除)。
func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error {
var comment model.Comment
if err := s.db.Unscoped().First(&comment, commentID).Error; err != nil {
return ErrCommentNotFound
}
if !comment.DeletedAt.Valid {
return ErrCommentNotDeleted
}
staffDeleted := comment.DeletedBy != 0 && comment.DeletedBy != comment.UserID
allowed := false
if comment.UserID == userID && !staffDeleted {
allowed = true
} else {
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) {
allowed = true
}
}
if !allowed {
return ErrCommentForbidden
}
updates := map[string]interface{}{
"deleted_at": nil,
"delete_type": "",
"delete_reason": "",
"deleted_by": 0,
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil {
return err
}
if comment.Status == model.ContentStatusPublished {
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
}
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
}