feat: 评论/帖子编辑历史与编辑标记,内容锁定

- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
This commit is contained in:
2026-09-25 23:41:45 +08:00
parent cab8230803
commit 5270fee2b7
40 changed files with 2521 additions and 350 deletions

View File

@@ -2,6 +2,7 @@ package service
import (
"errors"
"fmt"
"strings"
"time"
@@ -18,8 +19,29 @@ var (
ErrCommentNotDeleted = errors.New("评论未被删除")
ErrCommentNotChanged = errors.New("评论内容未变更")
ErrCommentDeleted = errors.New("已删除的评论不可编辑")
ErrPostReplyLocked = errors.New("帖子已被锁定,无法回复")
ErrCommentPostLocked = errors.New("帖子已被锁定,评论无法编辑")
ErrCommentEditLocked = errors.New("评论已超过可编辑时限,无法编辑")
)
// commentEditLockHours 评论可编辑时长(setting 未注入或读取出错时=0 关闭)
func commentEditLockHours(setting *SettingService) int {
if setting == nil {
return 0
}
hours, err := setting.CommentEditLockHours()
if err != nil {
return 0
}
return hours
}
// IsNecroReply 旧帖判定:lastReplyAt(最后回复时间,无回复回落发帖时间)距 now 超过 afterHours。
// staff 豁免、规则开关与回落基准由调用方负责。
func IsNecroReply(lastReplyAt time.Time, afterHours int, now time.Time) bool {
return afterHours > 0 && now.Sub(lastReplyAt) > time.Duration(afterHours)*time.Hour
}
// CommentService 评论服务
type CommentService struct {
db *gorm.DB
@@ -303,17 +325,18 @@ func applyCommentListVisibility(db *gorm.DB, boardID, viewerID uint, actor *Acto
// Create 创建评论(parentID 为 nil 时发主评论/楼层,否则发为对应评论的子回复)。
// status 由 handler 按角色计算:管理团队直发 published,普通用户进入 pending;
// pending 评论不计入 comment_count,审核通过时才 +1。
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string) (*model.Comment, *model.Comment, error) {
// actorRole 用于锁帖与旧帖扣分规则的 staff 豁免判断。
// 返回:新评论、父评论(子回复时非 nil,供通知定位被回复人)、旧帖扣分值(0=未触发)
func (s *CommentService) Create(userID, postID uint, content string, parentID *uint, status string, actorRole model.Role) (*model.Comment, *model.Comment, int, error) {
if err := NewOperations(s.db, nil).Filter("comment", content, userID); err != nil {
return nil, nil, err
return nil, nil, 0, err
}
content = strings.TrimSpace(content)
if content == "" {
return nil, nil, errors.New("评论内容不能为空")
return nil, nil, 0, errors.New("评论内容不能为空")
}
if err := rejectHideBlocks(content, ErrCommentHideNotAllowed); err != nil {
return nil, nil, err
return nil, nil, 0, err
}
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
status = model.ContentStatusPending
@@ -322,10 +345,14 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
// 检查帖子存在且已发布(待审/被拒帖子不接受评论)
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return nil, nil, errors.New("帖子不存在")
return nil, nil, 0, errors.New("帖子不存在")
}
if post.Status != model.ContentStatusPublished {
return nil, nil, errors.New("帖子不存在")
return nil, nil, 0, errors.New("帖子不存在")
}
// 管理员手动锁帖:普通用户禁止回复,staff 豁免
if post.Locked && !model.IsStaff(actorRole) {
return nil, nil, 0, ErrPostReplyLocked
}
comment := &model.Comment{
@@ -342,10 +369,10 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
if err := s.db.
Where("id = ? AND post_id = ? AND status = ?", *parentID, postID, model.ContentStatusPublished).
First(parent).Error; err != nil {
return nil, nil, errors.New("回复的评论不存在")
return nil, nil, 0, errors.New("回复的评论不存在")
}
if parent.Depth >= maxReplyDepth {
return nil, nil, errors.New("回复层级过深")
return nil, nil, 0, errors.New("回复层级过深")
}
rootID := parent.ID
if parent.RootID != nil {
@@ -356,26 +383,57 @@ func (s *CommentService) Create(userID, postID uint, content string, parentID *u
comment.Depth = parent.Depth + 1
}
if err := s.db.Create(comment).Error; err != nil {
return nil, nil, err
// 旧帖回复扣分(staff 豁免):仅已发布评论生效,待审不发不提醒。
// 旧帖判定:最后一条已发布评论时间距现在超过阈值;无回复(MAX 为 NULL)回落发帖时间。
necroAfterHours, necroPoints := 0, 0
if comment.Status == model.ContentStatusPublished && s.setting != nil && !model.IsStaff(actorRole) {
afterHours, afterErr := s.setting.NecroReplyAfterHours()
penalty, penaltyErr := s.setting.NecroReplyPenalty()
var lastAt *time.Time
if err := s.db.Raw(`SELECT MAX(created_at) FROM comments WHERE post_id = ? AND status = 'published' AND deleted_at IS NULL`, postID).Scan(&lastAt).Error; err != nil || lastAt == nil {
lastAt = &post.CreatedAt
}
if afterErr == nil && penaltyErr == nil &&
IsNecroReply(*lastAt, afterHours, time.Now()) {
necroAfterHours, necroPoints = afterHours, penalty
}
}
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
if comment.Status == model.ContentStatusPublished {
s.db.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1"))
// 原子事务:评论落库 + 计数 + 旧帖扣分 + 回复奖励(扣分余额不足整体回滚,禁止回复)
if err := s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(comment).Error; err != nil {
return err
}
if comment.Status != model.ContentStatusPublished {
return nil
}
// 仅已发布评论立即计入评论数;待审评论通过审核时才 +1
if err := tx.Model(&post).UpdateColumn("comment_count", gorm.Expr("comment_count + 1")).Error; err != nil {
return err
}
if necroPoints > 0 {
if _, err := DebitTx(tx, userID, necroPoints, model.PointReasonNecroReply, "post", post.ID,
fmt.Sprintf("回复超过 %d 小时无新回复的旧帖", necroAfterHours)); err != nil {
return err
}
}
// 回复奖励(可配置;待审不发且过审不补发,防拒审刷分)
if s.setting != nil {
if rules, err := s.setting.PointsRules(); err == nil {
s.db.Transaction(func(tx *gorm.DB) error {
RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap,
model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title)
return nil
})
RewardIfConfigured(tx, userID, rules.ReplyReward, rules.ReplyDailyCap,
model.PointReasonReplyReward, "comment", comment.ID, "回复奖励:"+post.Title)
}
}
return nil
}); err != nil {
if necroPoints > 0 && errors.Is(err, ErrInsufficientPoints) {
return nil, nil, 0, fmt.Errorf("%w:该帖已超过 %d 小时没有新回复,回复需扣除 %d 积分", ErrInsufficientPoints, necroAfterHours, necroPoints)
}
return nil, nil, 0, err
}
// 预加载用户
s.db.Preload("User").Preload("User.Badges.Badge").First(comment, comment.ID)
return comment, parent, nil
return comment, parent, necroPoints, nil
}
// UserCommentItem 用户评论列表项(含帖子标题便于跳转)
@@ -484,7 +542,8 @@ func (s *CommentService) Delete(actor *Actor, commentID, userID uint, opts Delet
"delete_reason": deleteReason,
"deleted_by": userID,
}
if err := s.db.Model(&comment).Updates(updates).Error; err != nil {
// UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Model(&comment).UpdateColumns(updates).Error; err != nil {
return err
}
if err := s.db.Delete(&comment).Error; err != nil {
@@ -537,7 +596,8 @@ func (s *CommentService) Restore(actor *Actor, commentID, userID uint) error {
"delete_reason": "",
"deleted_by": 0,
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).Updates(updates).Error; err != nil {
// UpdateColumns:恢复非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", commentID).UpdateColumns(updates).Error; err != nil {
return err
}
if comment.Status == model.ContentStatusPublished {
@@ -637,14 +697,23 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st
if comment.DeletedAt.Valid {
return nil, ErrCommentDeleted
}
var post model.Post
if err := s.db.Select("id", "board_id", "created_at", "locked").First(&post, comment.PostID).Error; err != nil {
return nil, ErrCommentNotFound
}
if comment.UserID != userID {
var post model.Post
if err := s.db.Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return nil, ErrCommentNotFound
}
// 版主编辑他人评论不受锁帖/时限约束(本就为 staff)
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
return nil, ErrCommentForbidden
}
} else if !model.IsStaff(actor.Role) {
// 作者本人编辑:锁帖禁止、超过可编辑时限禁止,staff 豁免
if post.Locked {
return nil, ErrCommentPostLocked
}
if editLocked(comment.CreatedAt, false, commentEditLockHours(s.setting), time.Now()) {
return nil, ErrCommentEditLocked
}
}
if comment.Content == content {
return nil, ErrCommentNotChanged
@@ -672,7 +741,7 @@ func (s *CommentService) Update(actor *Actor, commentID, userID uint, content st
return &node, nil
}
// CommentEditHistoryItem 管理可见的评论修订记录
// CommentEditHistoryItem 登录可见的评论修订记录
type CommentEditHistoryItem struct {
ID uint `json:"id"`
Editor CommentActorBrief `json:"editor"`
@@ -680,7 +749,7 @@ type CommentEditHistoryItem struct {
CreatedAt time.Time `json:"created_at"`
}
// ListEditHistory 评论编辑历史(仅版主;含软删评论;按创建时间倒序分页)
// ListEditHistory 评论编辑历史(登录即可见;含软删评论;按创建时间倒序分页)
func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, size int) ([]CommentEditHistoryItem, int64, error) {
if page < 1 {
page = 1
@@ -689,14 +758,10 @@ func (s *CommentService) ListEditHistory(actor *Actor, commentID uint, page, siz
size = 10
}
var comment model.Comment
if err := s.db.Unscoped().Select("id", "post_id").First(&comment, commentID).Error; err != nil {
if err := s.db.Unscoped().Select("id").First(&comment, commentID).Error; err != nil {
return nil, 0, ErrCommentNotFound
}
var post model.Post
if err := s.db.Unscoped().Select("id", "board_id").First(&post, comment.PostID).Error; err != nil {
return nil, 0, ErrCommentNotFound
}
if actor == nil || !actor.CanModerateBoard(post.BoardID) {
if actor == nil {
return nil, 0, ErrCommentForbidden
}