feat(moderation): 帖评删除占位感知与硬删清场
管理删帖须填类型与理由并展示专用页;评论软删占位、版主可硬删不占楼。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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