feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user