feat(comments): 软删占位、理由与恢复,管理删仅对访客打码
自删/管理删差异文案与理由可见性,评论者与管理员免马赛克,并支持恢复与处理人链接。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 硬删评论及子树(仅版主;不占位)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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