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

@@ -17,8 +17,27 @@ var (
ErrPostForbidden = errors.New("无权限操作此帖子")
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
ErrPostInvalidType = errors.New("无效的删除类型")
ErrPostLocked = errors.New("帖子已被锁定,无法编辑")
ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑")
)
// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用
func editLocked(createdAt time.Time, isStaff bool, hours int, now time.Time) bool {
return !isStaff && hours > 0 && now.Sub(createdAt) > time.Duration(hours)*time.Hour
}
// postEditLockHours 帖子可编辑时长(setting 未注入或读取出错时=0 关闭)
func postEditLockHours(setting *SettingService) int {
if setting == nil {
return 0
}
hours, err := setting.PostEditLockHours()
if err != nil {
return 0
}
return hours
}
// PostService 帖子服务
type PostService struct {
db *gorm.DB
@@ -323,13 +342,30 @@ func (s *PostService) TogglePin(id uint) (int, error) {
} else {
newPinned = 1
}
result := s.db.Model(&post).Update("pinned", newPinned)
// UpdateColumn:置顶非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("pinned", newPinned)
if result.Error != nil {
return 0, result.Error
}
return newPinned, nil
}
// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。
// 锁定后普通用户不可编辑帖子、不可回复;staff 豁免。
func (s *PostService) ToggleLock(id uint) (bool, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return false, err
}
newVal := !post.Locked
// UpdateColumn:锁定非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("locked", newVal)
if result.Error != nil {
return false, result.Error
}
return newVal, nil
}
// ToggleRecommend 切换帖子加精;加精时向作者发放可配置的推荐奖励(每帖仅一次,自荐不发)
func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) {
var post model.Post
@@ -337,7 +373,8 @@ func (s *PostService) ToggleRecommend(id, operatorID uint) (bool, error) {
return false, err
}
newVal := !post.Recommended
result := s.db.Model(&post).Update("recommended", newVal)
// UpdateColumn:加精非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("recommended", newVal)
if result.Error != nil {
return false, result.Error
}
@@ -414,17 +451,23 @@ type PostDetail struct {
TypeStatus string `json:"type_status,omitempty"`
Pinned int `json:"pinned"`
Recommended bool `json:"recommended"`
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `json:"status"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
ContentLocked bool `json:"content_locked"`
AccessHint string `json:"access_hint,omitempty"`
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
// 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Board model.Board `json:"board"`
User model.User `json:"user"`
ContentLocked bool `json:"content_locked"`
AccessHint string `json:"access_hint,omitempty"`
Attachments []PostAttachmentDTO `json:"attachments"`
Question *QuestionState `json:"question,omitempty"`
Poll *PollState `json:"poll,omitempty"`
@@ -439,6 +482,12 @@ type PostDetail struct {
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// NecroReplyHint 旧帖回复确认提示(详情接口按查看者实时计算;命中才返回)
type NecroReplyHint struct {
AfterHours int `json:"after_hours"`
Penalty int `json:"penalty"`
}
// CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生)
type CreatePostInput struct {
UserID uint
@@ -498,6 +547,12 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
post.ViewCount++
detail := buildPostDetail(&post)
detail.Edited = s.hasEditHistory(post.ID)
// 最后一条已发布评论时间(旧帖判定基准;无回复为 nil,调用方回落 created_at)
var lastReplyAt *time.Time
if err := s.db.Raw(`SELECT MAX(created_at) FROM comments WHERE post_id = ? AND status = 'published' AND deleted_at IS NULL`, post.ID).Scan(&lastReplyAt).Error; err == nil {
detail.LastReplyAt = lastReplyAt
}
sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor, pwdUnlocked)
detail.Content = sanitized
detail.ContentLocked = fullyLocked
@@ -545,7 +600,7 @@ func buildPostDetail(post *model.Post) *PostDetail {
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
Pinned: post.Pinned, Recommended: post.Recommended, Locked: post.Locked, Status: post.Status,
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
Board: post.Board, User: post.User,
@@ -919,6 +974,15 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
return nil, ErrPostForbidden
}
editorIsStaff := model.IsStaff(actor.Role)
// 手动锁定:仅管理团队可编辑
if post.Locked && !editorIsStaff {
return nil, ErrPostLocked
}
// 自动编辑锁定:非管理团队的作者超时不可编辑;管理成员(版主代编等)豁免
if post.UserID == userID && editLocked(post.CreatedAt, editorIsStaff, postEditLockHours(s.setting), time.Now()) {
return nil, ErrPostEditLocked
}
updates := map[string]interface{}{}
if in.Title != "" {
@@ -1042,6 +1106,26 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
}
err := s.db.Transaction(func(tx *gorm.DB) error {
// 仅标题/正文实际变化时留修订快照(tags/type_meta/附件变更不留痕)
newTitle, _ := updates["title"].(string)
if newTitle == "" {
newTitle = post.Title
}
newContent, _ := updates["content"].(string)
if newContent == "" {
newContent = post.Content
}
if newTitle != post.Title || newContent != post.Content {
hist := model.PostEditHistory{
PostID: post.ID,
EditorID: userID, // 执行编辑者(可能是版主代编)
OldTitle: post.Title,
OldContent: post.Content,
}
if err := tx.Create(&hist).Error; err != nil {
return err
}
}
if err := tx.Model(&post).Updates(updates).Error; err != nil {
return err
}
@@ -1085,6 +1169,87 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
return detail, nil
}
// PostEditHistoryItem 登录可见的帖子修订记录
type PostEditHistoryItem struct {
ID uint `json:"id"`
Editor CommentActorBrief `json:"editor"`
OldTitle string `json:"old_title"`
OldContent string `json:"old_content"`
CreatedAt time.Time `json:"created_at"`
}
// hasEditHistory 帖子是否存在修订快照;updated_at 会被置顶/计数等写操作推动,不可作为编辑依据
func (s *PostService) hasEditHistory(postID uint) bool {
var n int64
s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID).Limit(1).Count(&n)
return n > 0
}
// ListEditHistory 帖子编辑历史(登录即可见;含软删帖子;按创建时间倒序分页)
func (s *PostService) ListEditHistory(actor *Actor, postID uint, page, size int) ([]PostEditHistoryItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 10
}
var post model.Post
if err := s.db.Unscoped().Select("id").First(&post, postID).Error; err != nil {
return nil, 0, ErrPostNotFound
}
if actor == nil {
return nil, 0, ErrPostForbidden
}
q := s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.PostEditHistory
if err := s.db.Where("post_id = ?", postID).
Order("created_at DESC").
Offset((page - 1) * size).Limit(size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
editorIDs := make([]uint, 0, len(rows))
seen := map[uint]struct{}{}
for _, r := range rows {
if _, ok := seen[r.EditorID]; ok {
continue
}
seen[r.EditorID] = struct{}{}
editorIDs = append(editorIDs, r.EditorID)
}
editors := map[uint]CommentActorBrief{}
if len(editorIDs) > 0 {
var users []model.User
s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", editorIDs).Find(&users)
for _, u := range users {
editors[u.ID] = CommentActorBrief{
ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar,
}
}
}
items := make([]PostEditHistoryItem, 0, len(rows))
for _, r := range rows {
ed := editors[r.EditorID]
if ed.ID == 0 {
ed = CommentActorBrief{ID: r.EditorID, Username: "已注销", Nickname: "已注销"}
}
items = append(items, PostEditHistoryItem{
ID: r.ID,
Editor: ed,
OldTitle: r.OldTitle,
OldContent: r.OldContent,
CreatedAt: r.CreatedAt,
})
}
return items, total, nil
}
// EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error {
var post model.Post
@@ -1184,7 +1349,8 @@ func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostO
"delete_reason": deleteReason,
"deleted_by": userID,
}
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
// UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导)
if err := s.db.Model(&post).UpdateColumns(updates).Error; err != nil {
return false, authorID, err
}
if err := s.db.Delete(&post).Error; err != nil {