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

@@ -81,6 +81,7 @@ type AdminContentComment struct {
BoardID uint `json:"board_id"`
Content string `json:"content"`
Status string `json:"status"`
Edited bool `json:"edited"` // 相对创建已编辑(同评论侧规则),用于隐藏无修订的历史入口
Deleted bool `json:"deleted"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -223,13 +224,14 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
Content string
Status string
CreatedAt time.Time
UpdatedAt time.Time
DeletedAt gorm.DeletedAt
BoardID uint
PostTitle string
UserID uint
}
var rows []row
if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id").
if err := q.Select("comments.id, comments.post_id, comments.content, comments.status, comments.created_at, comments.updated_at, comments.deleted_at, posts.board_id, posts.title AS post_title, comments.user_id").
Order("comments.id DESC").
Offset((page - 1) * adminContentPageSize).Limit(adminContentPageSize).
Scan(&rows).Error; err != nil {
@@ -276,6 +278,7 @@ func (s *ModerationService) ListAdminComments(actor *Actor, status, keyword stri
BoardID: r.BoardID,
Content: r.Content,
Status: r.Status,
Edited: r.UpdatedAt.Sub(r.CreatedAt) > time.Minute,
CreatedAt: r.CreatedAt,
Board: boards[r.BoardID],
User: users[r.UserID],
@@ -354,7 +357,8 @@ func (s *ModerationService) SoftDeletePost(actor *Actor, id uint, deleteType, de
"delete_reason": deleteReason,
"deleted_by": actor.ID,
}
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 0, err
}
if err := s.db.Delete(post).Error; err != nil {
@@ -375,7 +379,8 @@ func (s *ModerationService) RestorePost(actor *Actor, id uint) error {
if !post.DeletedAt.Valid {
return errors.New("帖子未被删除")
}
return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).Update("deleted_at", nil).Error
// UpdateColumn:恢复非内容编辑,不刷新 updated_at
return s.db.Unscoped().Model(&model.Post{}).Where("id = ?", id).UpdateColumn("deleted_at", nil).Error
}
// PurgePost 彻底删除已软删帖子及其评论(仅站长)
@@ -391,6 +396,9 @@ func (s *ModerationService) PurgePost(actor *Actor, id uint) error {
return ErrContentNotDeleted
}
return s.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.PostEditHistory{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("post_id = ?", id).Delete(&model.Comment{}).Error; err != nil {
return err
}
@@ -430,7 +438,8 @@ func (s *ModerationService) SoftDeleteComment(actor *Actor, id uint, deleteType,
"delete_reason": deleteReason,
"deleted_by": actor.ID,
}
if err := s.db.Model(cm).Updates(updates).Error; err != nil {
// UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Model(cm).UpdateColumns(updates).Error; err != nil {
return err
}
if err := s.db.Delete(cm).Error; err != nil {
@@ -461,7 +470,8 @@ func (s *ModerationService) RestoreComment(actor *Actor, id uint) error {
"delete_reason": "",
"deleted_by": 0,
}
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).Updates(updates).Error; err != nil {
// UpdateColumns:恢复非内容编辑,不刷新 updated_at(该字段用于 edited 判定)
if err := s.db.Unscoped().Model(&model.Comment{}).Where("id = ?", id).UpdateColumns(updates).Error; err != nil {
return err
}
if cm.Status == model.ContentStatusPublished {

View File

@@ -3,6 +3,7 @@ package service
import (
"errors"
"strings"
"unicode/utf8"
"github.com/freefire/jiang13-bbs/markdown"
"github.com/freefire/jiang13-bbs/model"
@@ -49,6 +50,9 @@ func (in *AnnouncementInput) normalize(existing *model.Announcement) error {
if in.Content == "" {
return errors.New("内容不能为空")
}
if utf8.RuneCountInString(in.Content) > 20000 {
return errors.New("内容不能超过 20000 字")
}
if err := markdown.ValidateHideContent(in.Content); err != nil {
return err
}

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
}

View File

@@ -0,0 +1,74 @@
package service
import (
"testing"
"time"
"github.com/freefire/jiang13-bbs/model"
)
// TestEditLocked 内容自动编辑锁定规则(帖子与评论共用):
// staff 豁免、hours<=0 关闭、以创建时间为基准计时
func TestEditLocked(t *testing.T) {
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.Local)
createdAt := now.Add(-48 * time.Hour) // 两天前创建
cases := []struct {
name string
createdAt time.Time
isStaff bool
hours int
now time.Time
want bool
}{
{"未启用", createdAt, false, 0, now, false},
{"负值视为关闭", createdAt, false, -1, now, false},
{"超过时限锁定", createdAt, false, 24, now, true},
{"恰好到期未锁(严格大于)", createdAt, false, 48, now, false},
{"未到期不锁", createdAt, false, 72, now, false},
{"staff 超时也豁免", createdAt, true, 1, now, false},
{"staff 管理员角色豁免", createdAt, model.IsStaff(model.RoleAdmin), 1, now, false},
{"普通用户非豁免", createdAt, model.IsStaff(model.RoleUser), 1, now, true},
{"板块管理员豁免", createdAt, model.IsStaff(model.RoleBoardAdmin), 1, now, false},
{"刚创建且时限极短不锁", now, false, 1, now, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := editLocked(c.createdAt, c.isStaff, c.hours, c.now); got != c.want {
t.Fatalf("editLocked() = %v, want %v", got, c.want)
}
})
}
}
// TestIsNecroReply 旧帖判定:最后回复时间(无回复回落发帖时间)距 now 超过阈值。
// staff 豁免、规则开关、回落基准由调用方负责,不在本 helper 内。
func TestIsNecroReply(t *testing.T) {
now := time.Date(2026, 9, 25, 12, 0, 0, 0, time.Local)
lastReplyAt := now.Add(-48 * time.Hour) // 最后回复在两天前
cases := []struct {
name string
lastReplyAt time.Time
afterHours int
now time.Time
want bool
}{
{"阈值关闭", lastReplyAt, 0, now, false},
{"阈值负值视为关闭", lastReplyAt, -1, now, false},
{"超过阈值触发", lastReplyAt, 24, now, true},
{"恰好到期不触发(严格大于)", lastReplyAt, 48, now, false},
{"未到期不触发", lastReplyAt, 72, now, false},
{"无回复回落发帖时间同理(基准即发帖时间)", now.Add(-72 * time.Hour), 48, now, true},
{"刚回复不久不触发", now.Add(-time.Hour), 2, now, false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
if got := IsNecroReply(c.lastReplyAt, c.afterHours, c.now); got != c.want {
t.Fatalf("IsNecroReply() = %v, want %v", got, c.want)
}
})
}
}

View File

@@ -180,7 +180,8 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, er
if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok {
updates["type_meta"] = raw
}
if err := tx.Model(&post).Updates(updates).Error; err != nil {
// UpdateColumns:审核非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导)
if err := tx.Model(&post).UpdateColumns(updates).Error; err != nil {
return err
}
boardID = post.BoardID
@@ -204,7 +205,8 @@ func (s *ModerationService) RejectPost(actor *Actor, id uint) error {
if post.Status != model.ContentStatusPending {
return ErrNotPending
}
if err := tx.Model(&post).Update("status", model.ContentStatusRejected).Error; err != nil {
// UpdateColumn:审核非内容编辑,不刷新 updated_at
if err := tx.Model(&post).UpdateColumn("status", model.ContentStatusRejected).Error; err != nil {
return err
}
s.notif.Create(post.UserID, actor.ID, model.NotificationTypeRejected, post.ID, 0,
@@ -230,7 +232,8 @@ func (s *ModerationService) ApproveComment(actor *Actor, id uint) (postID, board
if cm.Status != model.ContentStatusPending {
return ErrNotPending
}
if err := tx.Model(&cm).Update("status", model.ContentStatusPublished).Error; err != nil {
// UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定)
if err := tx.Model(&cm).UpdateColumn("status", model.ContentStatusPublished).Error; err != nil {
return err
}
if err := tx.Model(&model.Post{}).Where("id = ?", cm.PostID).
@@ -273,7 +276,8 @@ func (s *ModerationService) RejectComment(actor *Actor, id uint) error {
if cm.Status != model.ContentStatusPending {
return ErrNotPending
}
if err := tx.Model(&cm).Update("status", model.ContentStatusRejected).Error; err != nil {
// UpdateColumn:审核非内容编辑,不刷新评论 updated_at(该字段用于 edited 判定)
if err := tx.Model(&cm).UpdateColumn("status", model.ContentStatusRejected).Error; err != nil {
return err
}
s.notif.Create(cm.UserID, actor.ID, model.NotificationTypeRejected, cm.PostID, cm.ID,

View File

@@ -100,6 +100,31 @@ func (s *NotificationService) Create(userID, actorID uint, notifType string, pos
}
}
// CreateSystem 系统通知(ActorID=0,提醒对象即本人,绕过自我通知检查;失败静默)
func (s *NotificationService) CreateSystem(userID uint, notifType string, postID, commentID uint, content string) {
if userID == 0 {
return
}
if len(content) > 200 {
content = content[:200]
}
n := &model.Notification{
UserID: userID,
ActorID: 0,
Type: notifType,
PostID: postID,
CommentID: commentID,
Content: content,
IsRead: false,
}
if err := s.db.Create(n).Error; err != nil {
return
}
if s.OnNotifyNew != nil {
s.OnNotifyNew(userID)
}
}
// CreateMention 创建群聊 @ 提醒(PostID=0,用 RoomID/MessageID 关联)。
// 返回创建出的通知 ID(0 表示未创建),供 handler 实时推送。
func (s *NotificationService) CreateMention(userID, actorID, roomID, messageID uint, content string) uint {

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 {

View File

@@ -59,6 +59,12 @@ const (
// SettingKeyAnalyticsRetentionDays 访问明细保留天数;缺行=90
SettingKeyAnalyticsRetentionDays = "analytics_retention_days"
// 内容锁定与旧帖回复(全部缺行=0 关闭)
SettingKeyPostEditLockHours = "post_edit_lock_hours" // 帖子可编辑时长;0=不锁定
SettingKeyCommentEditLockHours = "comment_edit_lock_hours" // 评论可编辑时长;0=不锁定
SettingKeyNecroReplyAfterHours = "necro_reply_after_hours" // 超过该时长回复视为旧帖回复;0=关闭
SettingKeyNecroReplyPenalty = "necro_reply_penalty" // 旧帖回复扣除积分;0=仅提醒不扣分
// 积分增长规则(正整数奖励;0=关闭/不限,缺行=默认值)
SettingKeyPointsCheckinBase = "points_checkin_base" // 签到基础分;缺行=5
SettingKeyPointsStreakEveryDays = "points_streak_every_days" // 每满 N 天连续签到触发加成;0=关闭;缺行=7
@@ -100,6 +106,9 @@ const (
MinAnalyticsRetentionDays = 7
MaxAnalyticsRetentionDays = 365
// MaxContentLockHours 内容锁定/旧帖阈值小时数上限(一年)
MaxContentLockHours = 8760
DefaultPointsCheckinBase = 5
DefaultPointsStreakEveryDays = 7
MaxPointsRule = 100 // 各积分规则值/上限的统一上界
@@ -150,6 +159,12 @@ type PublicSiteSettings struct {
AttachmentMaxMB int `json:"attachment_max_mb"`
AttachmentMaxCount int `json:"attachment_max_count"`
ImageMaxMB int `json:"image_max_mb"`
// 内容锁定与旧帖回复(0=关闭;公开保持规则透明)
PostEditLockHours int `json:"post_edit_lock_hours"`
CommentEditLockHours int `json:"comment_edit_lock_hours"`
NecroReplyAfterHours int `json:"necro_reply_after_hours"`
NecroReplyPenalty int `json:"necro_reply_penalty"`
BgSiteURL string `json:"bg_site_url"`
BgSiteMode string `json:"bg_site_mode"`
BgAdminURL string `json:"bg_admin_url"`
@@ -438,6 +453,27 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
}
out.ImageMaxMB = imgMB
postLock, err := s.PostEditLockHours()
if err != nil {
return out, err
}
out.PostEditLockHours = postLock
commentLock, err := s.CommentEditLockHours()
if err != nil {
return out, err
}
out.CommentEditLockHours = commentLock
necroAfter, err := s.NecroReplyAfterHours()
if err != nil {
return out, err
}
out.NecroReplyAfterHours = necroAfter
necroPenalty, err := s.NecroReplyPenalty()
if err != nil {
return out, err
}
out.NecroReplyPenalty = necroPenalty
siteURL, err := s.BgSiteURL()
if err != nil {
return out, err
@@ -941,6 +977,91 @@ func (s *SettingService) ImageMaxBytes() (int64, error) {
return int64(mb) << 20, nil
}
// getLockHours 读取小时数配置(缺行/非法=0 关闭,超上限钳制)
func (s *SettingService) getLockHours(key string) (int, error) {
v, found, err := s.getValue(key)
if err != nil {
return 0, err
}
if !found {
return 0, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return 0, nil
}
if n > MaxContentLockHours {
return MaxContentLockHours, nil
}
return n, nil
}
func (s *SettingService) setLockHours(key string, hours int) error {
if hours < 0 || hours > MaxContentLockHours {
return ErrInvalidSiteSetting
}
if hours == 0 {
return s.deleteKey(key)
}
return s.putValue(key, strconv.Itoa(hours))
}
// PostEditLockHours 帖子可编辑时长(小时)。缺行=0(不锁定)。
func (s *SettingService) PostEditLockHours() (int, error) {
return s.getLockHours(SettingKeyPostEditLockHours)
}
func (s *SettingService) SetPostEditLockHours(hours int) error {
return s.setLockHours(SettingKeyPostEditLockHours, hours)
}
// CommentEditLockHours 评论可编辑时长(小时)。缺行=0(不锁定)。
func (s *SettingService) CommentEditLockHours() (int, error) {
return s.getLockHours(SettingKeyCommentEditLockHours)
}
func (s *SettingService) SetCommentEditLockHours(hours int) error {
return s.setLockHours(SettingKeyCommentEditLockHours, hours)
}
// NecroReplyAfterHours 旧帖回复阈值(小时)。缺行=0(关闭)。
func (s *SettingService) NecroReplyAfterHours() (int, error) {
return s.getLockHours(SettingKeyNecroReplyAfterHours)
}
func (s *SettingService) SetNecroReplyAfterHours(hours int) error {
return s.setLockHours(SettingKeyNecroReplyAfterHours, hours)
}
// NecroReplyPenalty 旧帖回复扣分。缺行=0(仅提醒不扣分)。
func (s *SettingService) NecroReplyPenalty() (int, error) {
v, found, err := s.getValue(SettingKeyNecroReplyPenalty)
if err != nil {
return 0, err
}
if !found {
return 0, nil
}
n, convErr := strconv.Atoi(strings.TrimSpace(v))
if convErr != nil || n < 0 {
return 0, nil
}
if n > MaxPointsRule {
return MaxPointsRule, nil
}
return n, nil
}
func (s *SettingService) SetNecroReplyPenalty(n int) error {
if n < 0 || n > MaxPointsRule {
return ErrInvalidSiteSetting
}
if n == 0 {
return s.deleteKey(SettingKeyNecroReplyPenalty)
}
return s.putValue(SettingKeyNecroReplyPenalty, strconv.Itoa(n))
}
// AnalyticsEnabled 访问统计采集开关。缺行视为开启。
func (s *SettingService) AnalyticsEnabled() (bool, error) {
v, found, err := s.getValue(SettingKeyAnalyticsEnabled)

View File

@@ -82,6 +82,9 @@ func (in *SitePageInput) normalize(existing *model.SitePage) error {
if in.Content == "" {
return errors.New("内容不能为空")
}
if utf8.RuneCountInString(in.Content) > 20000 {
return errors.New("内容不能超过 20000 字")
}
if err := rejectHideBlocks(in.Content, ErrSitePageHideNotAllowed); err != nil {
return err
}