feat(comments): 软删占位、理由与恢复,管理删仅对访客打码

自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 07:43:48 +08:00
parent 5052bcd805
commit c6fdbb7193
11 changed files with 941 additions and 141 deletions

View File

@@ -106,7 +106,7 @@ func (h *Handlers) CreateComment(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"comment": comment})
}
// DeleteComment 软删评论(占位)
// DeleteComment 软删评论(占位;自删与管理删均须填理由,管理删另须类型)
func (h *Handlers) DeleteComment(c *gin.Context) {
claims := middleware.CurrentUser(c)
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
@@ -114,18 +114,58 @@ func (h *Handlers) DeleteComment(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
if err := h.Comment.Delete(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil {
var body struct {
DeleteType string `json:"delete_type"`
DeleteReason string `json:"delete_reason"`
}
_ = c.ShouldBindJSON(&body)
if err := h.Comment.Delete(h.loadActor(claims.ID), uint(cid), claims.ID, service.DeleteCommentOpts{
DeleteType: body.DeleteType,
DeleteReason: body.DeleteReason,
}); err != nil {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentDeleteMeta),
errors.Is(err, service.ErrCommentInvalidType):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
msg := err.Error()
if msg == "删除理由不能超过 200 字" {
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// RestoreComment 恢复软删评论(自删作者或版主)
func (h *Handlers) RestoreComment(c *gin.Context) {
claims := middleware.CurrentUser(c)
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
return
}
if err := h.Comment.Restore(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": "无权限恢复此评论"})
case errors.Is(err, service.ErrCommentNotDeleted):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
c.JSON(http.StatusOK, gin.H{"message": "已恢复"})
}
// PurgeComment 硬删评论及子树(仅版主;不占位)

View File

@@ -157,7 +157,26 @@ func (h *Handlers) AdminPurgePost(c *gin.Context) {
}
func (h *Handlers) AdminSoftDeleteComment(c *gin.Context) {
h.execContentAction(c, h.Moderation.SoftDeleteComment)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
return
}
var body struct {
DeleteType string `json:"delete_type"`
DeleteReason string `json:"delete_reason"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请提交删除类型与理由"})
return
}
actor := middleware.CurrentActor(c)
if err := h.Moderation.SoftDeleteComment(actor, uint(id), body.DeleteType, body.DeleteReason); err != nil {
h.writeModerationError(c, err)
return
}
h.broadcastModerationChanged()
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})
}
func (h *Handlers) AdminRestoreComment(c *gin.Context) {

View File

@@ -310,9 +310,13 @@ type Comment struct {
Depth int `gorm:"not null;default:0" json:"depth"` // 层级:主评论 0,子评论 = 父 + 1
Content string `gorm:"type:text;not null" json:"content"`
Status string `gorm:"size:16;default:published;index" json:"status"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
// 软删元数据:列表 Unscoped 作 tombstone;DeletedBy≠UserID 为管理删除
DeleteType string `gorm:"size:32;default:''" json:"delete_type,omitempty"`
DeleteReason string `gorm:"size:256;default:''" json:"delete_reason,omitempty"`
DeletedBy uint `gorm:"not null;default:0" json:"deleted_by,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
}

View File

@@ -151,6 +151,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/posts/:id/like", h.ToggleLike)
api.POST("/posts/:id/comments", middleware.RateLimitMiddleware(limiter, service.RateComment), h.CreateComment)
api.DELETE("/posts/:id/comments/:cid", h.DeleteComment)
api.PUT("/posts/:id/comments/:cid/restore", h.RestoreComment)
api.DELETE("/posts/:id/comments/:cid/purge", h.PurgeComment)
// 通知
api.GET("/notifications", h.Notifications)

View File

@@ -366,8 +366,8 @@ func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
})
}
// SoftDeleteComment 软删单条评论(不级联子树,后台点对点清理)
func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error {
// SoftDeleteComment 软删单条评论(不级联子树);须选类型,「其他」才须填理由
func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType, deleteReason string) error {
cm, _, err := s.loadCommentUnscoped(actor, id)
if err != nil {
return err
@@ -375,6 +375,29 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error {
if cm.DeletedAt.Valid {
return errors.New("评论已在回收站")
}
deleteType = strings.TrimSpace(deleteType)
deleteReason = strings.TrimSpace(deleteReason)
if !model.ValidPostDeleteType(deleteType) {
return errors.New("无效的删除类型")
}
if deleteType == model.PostDeleteTypeOther {
if deleteReason == "" {
return errors.New("请填写删除理由")
}
if len([]rune(deleteReason)) > 200 {
return errors.New("删除理由不能超过 200 字")
}
} else {
deleteReason = ""
}
updates := map[string]interface{}{
"delete_type": deleteType,
"delete_reason": deleteReason,
"deleted_by": actor.ID,
}
if err := s.db.Model(cm).Updates(updates).Error; err != nil {
return err
}
if err := s.db.Delete(cm).Error; err != nil {
return err
}
@@ -385,7 +408,7 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint) error {
return nil
}
// RestoreComment 恢复单条评论
// RestoreComment 恢复单条评论,并清空删除元数据
func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
cm, _, err := s.loadCommentUnscoped(actor, id)
if err != nil {
@@ -394,7 +417,13 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
if !cm.DeletedAt.Valid {
return errors.New("评论未被删除")
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Update("deleted_at", nil).Error; err != nil {
updates := map[string]interface{}{
"deleted_at": nil,
"delete_type": "",
"delete_reason": "",
"deleted_by": 0,
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Updates(updates).Error; err != nil {
return err
}
if cm.Status == model.ContentStatusPublished {

View File

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