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