feat(comments): 软删占位、理由与恢复,管理删仅对访客打码
自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,8 +11,11 @@ import (
|
||||
|
||||
// 评论操作错误
|
||||
var (
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrCommentForbidden = errors.New("无权限删除此评论")
|
||||
ErrCommentNotFound = errors.New("评论不存在")
|
||||
ErrCommentForbidden = errors.New("无权限删除此评论")
|
||||
ErrCommentDeleteMeta = errors.New("请填写删除理由")
|
||||
ErrCommentInvalidType = errors.New("无效的删除类型")
|
||||
ErrCommentNotDeleted = errors.New("评论未被删除")
|
||||
)
|
||||
|
||||
// CommentService 评论服务
|
||||
@@ -27,8 +30,18 @@ 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"`
|
||||
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 层),防止恶意无限嵌套
|
||||
@@ -92,19 +105,25 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
||||
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)))
|
||||
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))
|
||||
result[i] = toCommentNode(roots[i], build(roots[i].ID), revealAudit, deleters)
|
||||
placed[roots[i].ID] = true
|
||||
}
|
||||
for _, r := range replies {
|
||||
@@ -113,7 +132,7 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
||||
}
|
||||
for i := range result {
|
||||
if result[i].ID == *r.RootID {
|
||||
result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID)))
|
||||
result[i].Replies = append(result[i].Replies, toCommentNode(r, build(r.ID), revealAudit, deleters))
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -121,14 +140,70 @@ func (s *CommentService) ListFloorPaged(postID, boardID, viewerID uint, actor *A
|
||||
return result, floors, totalComments, nil
|
||||
}
|
||||
|
||||
// toCommentNode 软删行清空正文与用户展示信息,标 deleted
|
||||
func toCommentNode(c model.Comment, replies []CommentNode) CommentNode {
|
||||
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}
|
||||
}
|
||||
c.Content = ""
|
||||
c.User = model.User{}
|
||||
return CommentNode{Comment: c, Deleted: true, 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 且(作者 / 该板可审);
|
||||
@@ -265,20 +340,60 @@ func (s *CommentService) CountByUser(userID uint) (int64, error) {
|
||||
return total, err
|
||||
}
|
||||
|
||||
// Delete 软删单条评论(作者本人或板块审核权),不级联子树;占位保留楼层。
|
||||
func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
|
||||
// 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
|
||||
}
|
||||
if comment.UserID != userID {
|
||||
|
||||
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.CanModerateBoard(post.BoardID) {
|
||||
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
|
||||
@@ -290,6 +405,49 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint) error {
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user