feat(moderation): 帖评删除占位感知与硬删清场
管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -303,16 +303,38 @@ func (s *ModerationService) loadCommentUnscoped(actor *Actor, id uint) (*model.C
|
||||
return &cm, post.BoardID, nil
|
||||
}
|
||||
|
||||
// SoftDeletePost 软删帖子(未删除时)
|
||||
func (s *ModerationService) SoftDeletePost(actor *Actor, id uint) error {
|
||||
// SoftDeletePost 软删帖子(未删除时);须填写删除类型与理由,并通知作者
|
||||
func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, deleteReason string) (authorID uint, err error) {
|
||||
post, err := s.loadPostUnscoped(actor, id)
|
||||
if err != nil {
|
||||
return err
|
||||
return 0, err
|
||||
}
|
||||
if post.DeletedAt.Valid {
|
||||
return errors.New("帖子已在回收站")
|
||||
return 0, errors.New("帖子已在回收站")
|
||||
}
|
||||
return s.db.Delete(post).Error
|
||||
deleteType = strings.TrimSpace(deleteType)
|
||||
deleteReason = strings.TrimSpace(deleteReason)
|
||||
if !model.ValidPostDeleteType(deleteType) {
|
||||
return 0, errors.New("无效的删除类型")
|
||||
}
|
||||
if deleteReason == "" {
|
||||
return 0, errors.New("请填写删除理由")
|
||||
}
|
||||
if len([]rune(deleteReason)) > 200 {
|
||||
return 0, errors.New("删除理由不能超过 200 字")
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"delete_type": deleteType,
|
||||
"delete_reason": deleteReason,
|
||||
"deleted_by": actor.ID,
|
||||
}
|
||||
if err := s.db.Model(post).Updates(updates).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := s.db.Delete(post).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return post.UserID, nil
|
||||
}
|
||||
|
||||
// RestorePost 从回收站恢复帖子
|
||||
|
||||
@@ -27,6 +27,7 @@ func NewCommentService(db *gorm.DB) *CommentService {
|
||||
// CommentNode 评论树节点:嵌入 model.Comment 使 JSON 字段拍平,replies 为空时省略
|
||||
type CommentNode struct {
|
||||
model.Comment
|
||||
Deleted bool `json:"deleted,omitempty"` // 软删占位
|
||||
Replies []CommentNode `json:"replies,omitempty"`
|
||||
}
|
||||
|
||||
@@ -35,8 +36,8 @@ const maxReplyDepth = 10
|
||||
|
||||
// ListFloorPaged 楼层分页获取帖子评论树:
|
||||
// 分页与楼层号只针对主评论(parent_id IS NULL),每楼附带全部子评论(时间正序)。
|
||||
// viewer 可为作者本人或该板可审者时并入 pending;游客仅 published。
|
||||
// 返回:树根列表、楼层数、全部评论数(含回复)。
|
||||
// 已软删评论以 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
|
||||
@@ -44,18 +45,23 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
base := applyCommentListVisibility(s.db.Model(&model.Comment{}).Where("post_id = ?", postID), boardID, viewerID, actor)
|
||||
// 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{}).Count(&totalComments).Error; err != nil {
|
||||
// 徽章:仅未软删
|
||||
if err := base.Session(&gorm.Session{}).
|
||||
Where("deleted_at IS NULL").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").
|
||||
@@ -70,19 +76,17 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
||||
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)
|
||||
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
|
||||
}
|
||||
|
||||
// 3. 组装树:按 parent_id 建索引递归构造;父缺失(已删/脏数据)时降级挂到所属楼层根部
|
||||
children := make(map[uint][]model.Comment, len(replies)) // parentID -> 直接子评论(时间正序)
|
||||
children := make(map[uint][]model.Comment, len(replies))
|
||||
for _, r := range replies {
|
||||
if r.ParentID != nil {
|
||||
children[*r.ParentID] = append(children[*r.ParentID], r)
|
||||
@@ -93,24 +97,23 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
||||
build = func(pid uint) []CommentNode {
|
||||
out := []CommentNode{}
|
||||
for _, c := range children[pid] {
|
||||
out = append(out, CommentNode{Comment: c, Replies: build(c.ID)})
|
||||
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] = CommentNode{Comment: roots[i], Replies: build(roots[i].ID)}
|
||||
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, CommentNode{Comment: r, Replies: build(r.ID)})
|
||||
result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID)))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -118,17 +121,38 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
||||
return result, floors, totalComments, nil
|
||||
}
|
||||
|
||||
// applyCommentListVisibility 评论流可见性:published,或 pending 且(作者 / 该板可审)
|
||||
// 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("status = ?", model.ContentStatusPublished)
|
||||
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("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
|
||||
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(
|
||||
"status = ? OR (status = ? AND user_id = ?)",
|
||||
model.ContentStatusPublished, model.ContentStatusPending, viewerID,
|
||||
`(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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -241,14 +265,12 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
// Delete 删除评论(作者本人,或对帖子所属板块有审核权的管理成员),
|
||||
// 级联软删整棵子树
|
||||
// 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 {
|
||||
@@ -258,12 +280,23 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
|
||||
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.Where("root_id = ?", *comment.RootID).Find(&all)
|
||||
s.db.Unscoped().Where("root_id = ?", *comment.RootID).Find(&all)
|
||||
} else {
|
||||
s.db.Where("root_id = ?", comment.ID).Find(&all)
|
||||
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++ {
|
||||
@@ -285,27 +318,37 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
|
||||
for id := range descendants {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
// 只有已发布评论曾计入 comment_count,递减时排除待审/被拒评论。
|
||||
// all 含同楼层全部子评论(删除主评论时目标自身不在 all 内,需单独计入)
|
||||
publishedCnt := 0
|
||||
if comment.Status == model.ContentStatusPublished {
|
||||
publishedCnt++
|
||||
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
|
||||
}
|
||||
for _, c := range all {
|
||||
if c.ID == comment.ID {
|
||||
continue
|
||||
}
|
||||
if descendants[c.ID] && c.Status == model.ContentStatusPublished {
|
||||
publishedCnt++
|
||||
}
|
||||
var post model.Post
|
||||
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
|
||||
return ErrCommentNotFound
|
||||
}
|
||||
// 软删子树并按已发布条数递减帖子评论数
|
||||
if err := s.db.Delete(&model.Comment{}, ids).Error; err != nil {
|
||||
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 publishedCnt > 0 {
|
||||
if livePublished > 0 {
|
||||
s.db.Model(&model.Post{}).Where("id = ?", comment.PostID).
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", publishedCnt))
|
||||
UpdateColumn("comment_count", gorm.Expr("GREATEST(comment_count - ?, 0)", livePublished))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -12,8 +12,10 @@ import (
|
||||
|
||||
// 帖子操作错误
|
||||
var (
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostForbidden = errors.New("无权限操作此帖子")
|
||||
ErrPostNotFound = errors.New("帖子不存在")
|
||||
ErrPostForbidden = errors.New("无权限操作此帖子")
|
||||
ErrPostDeleteMeta = errors.New("管理删除须填写类型与理由")
|
||||
ErrPostInvalidType = errors.New("无效的删除类型")
|
||||
)
|
||||
|
||||
// PostService 帖子服务
|
||||
@@ -384,6 +386,13 @@ type PostDetail struct {
|
||||
Poll *PollState `json:"poll,omitempty"`
|
||||
Bounty *BountyState `json:"bounty,omitempty"`
|
||||
Lottery *LotteryState `json:"lottery,omitempty"`
|
||||
// 软删 tombstone:列表不可见,详情专用页可读
|
||||
Tombstone bool `json:"tombstone,omitempty"`
|
||||
DeleteType string `json:"delete_type,omitempty"`
|
||||
DeleteReason string `json:"delete_reason,omitempty"`
|
||||
DeletedBy uint `json:"deleted_by,omitempty"`
|
||||
StaffDeleted bool `json:"staff_deleted,omitempty"`
|
||||
DeletedAt *time.Time `json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePostInput 发帖入参
|
||||
@@ -412,11 +421,23 @@ type UpdatePostInput struct {
|
||||
AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
|
||||
}
|
||||
|
||||
// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)
|
||||
// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)。
|
||||
// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
err := s.db.Preload("Board").Preload("User").First(&post, id).Error
|
||||
if err != nil {
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
// 默认 scope 找不到:尝试软删行 → tombstone
|
||||
if err := s.db.Unscoped().Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
if !post.DeletedAt.Valid {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
return buildPostTombstone(&post), nil
|
||||
}
|
||||
if !visibleToPost(&post, viewerID, loadActor) {
|
||||
return nil, ErrPostNotFound
|
||||
@@ -448,6 +469,34 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func buildPostTombstone(post *model.Post) *PostDetail {
|
||||
var deletedAt *time.Time
|
||||
if post.DeletedAt.Valid {
|
||||
t := post.DeletedAt.Time
|
||||
deletedAt = &t
|
||||
}
|
||||
staffDeleted := post.DeletedBy != 0 && post.DeletedBy != post.UserID
|
||||
return &PostDetail{
|
||||
ID: post.ID,
|
||||
BoardID: post.BoardID,
|
||||
UserID: post.UserID,
|
||||
Title: post.Title,
|
||||
PostType: model.NormalizePostType(post.PostType),
|
||||
Status: post.Status,
|
||||
CreatedAt: post.CreatedAt,
|
||||
UpdatedAt: post.UpdatedAt,
|
||||
Board: post.Board,
|
||||
User: post.User,
|
||||
Attachments: []PostAttachmentDTO{},
|
||||
Tombstone: true,
|
||||
DeleteType: post.DeleteType,
|
||||
DeleteReason: post.DeleteReason,
|
||||
DeletedBy: post.DeletedBy,
|
||||
StaffDeleted: staffDeleted,
|
||||
DeletedAt: deletedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func buildPostDetail(post *model.Post) *PostDetail {
|
||||
return &PostDetail{
|
||||
ID: post.ID, BoardID: post.BoardID, UserID: post.UserID,
|
||||
@@ -954,17 +1003,53 @@ func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Delete(actor *Actor, postID, userID uint) error {
|
||||
// DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值
|
||||
type DeletePostOpts struct {
|
||||
DeleteType string
|
||||
DeleteReason string
|
||||
}
|
||||
|
||||
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)。
|
||||
// staff 删除他人帖必须提供合法类型与非空理由;作者自删不写类型/理由。
|
||||
// 返回 staffDeleted 与作者 ID,供 handler 发通知。
|
||||
func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostOpts) (staffDeleted bool, authorID uint, err error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
return false, 0, ErrPostNotFound
|
||||
}
|
||||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||||
return ErrPostForbidden
|
||||
authorID = post.UserID
|
||||
|
||||
deleteType := ""
|
||||
deleteReason := ""
|
||||
if post.UserID == userID {
|
||||
staffDeleted = false
|
||||
} else if actor != nil && actor.CanModerateBoard(post.BoardID) {
|
||||
deleteType = strings.TrimSpace(opts.DeleteType)
|
||||
deleteReason = strings.TrimSpace(opts.DeleteReason)
|
||||
if !model.ValidPostDeleteType(deleteType) {
|
||||
return false, authorID, ErrPostInvalidType
|
||||
}
|
||||
if deleteReason == "" {
|
||||
return false, authorID, ErrPostDeleteMeta
|
||||
}
|
||||
if len([]rune(deleteReason)) > 200 {
|
||||
return false, authorID, errors.New("删除理由不能超过 200 字")
|
||||
}
|
||||
staffDeleted = true
|
||||
} else {
|
||||
return false, authorID, ErrPostForbidden
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{
|
||||
"delete_type": deleteType,
|
||||
"delete_reason": deleteReason,
|
||||
"deleted_by": userID,
|
||||
}
|
||||
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
|
||||
return false, authorID, err
|
||||
}
|
||||
if err := s.db.Delete(&post).Error; err != nil {
|
||||
return err
|
||||
return false, authorID, err
|
||||
}
|
||||
return nil
|
||||
return staffDeleted, authorID, nil
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user