feat(moderation): 帖评删除占位感知与硬删清场

管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 07:01:04 +08:00
parent 2b27703e6c
commit bcb4f6a603
15 changed files with 825 additions and 116 deletions

View File

@@ -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
}