From 5270fee2b7fd37f67ee5b86c5437a35b36dc4197 Mon Sep 17 00:00:00 2001
From: freefire
Date: Fri, 25 Sep 2026 23:41:45 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AF=84=E8=AE=BA/=E5=B8=96=E5=AD=90?=
=?UTF-8?q?=E7=BC=96=E8=BE=91=E5=8E=86=E5=8F=B2=E4=B8=8E=E7=BC=96=E8=BE=91?=
=?UTF-8?q?=E6=A0=87=E8=AE=B0=EF=BC=8C=E5=86=85=E5=AE=B9=E9=94=81=E5=AE=9A?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
---
backend/handler/comment.go | 62 ++-
backend/handler/moderation.go | 35 --
backend/handler/post.go | 92 ++-
backend/handler/setting.go | 37 +-
backend/model/db.go | 2 +-
backend/model/models.go | 13 +
backend/router/router.go | 5 +-
backend/service/admin_content.go | 20 +-
backend/service/announcement.go | 4 +
backend/service/comment.go | 133 +++--
backend/service/content_lock_test.go | 74 +++
backend/service/moderation.go | 12 +-
backend/service/notification.go | 25 +
backend/service/post.go | 186 ++++++-
backend/service/setting.go | 121 ++++
backend/service/site_page.go | 3 +
.../AnnouncementComposeClient.tsx | 6 +-
frontend/app/admin/badges/BadgesClient.tsx | 6 +-
frontend/app/admin/content/ContentAdmin.tsx | 29 +-
.../app/admin/pages/PageComposeClient.tsx | 6 +-
frontend/app/admin/points/PointsTabs.tsx | 2 +-
frontend/app/admin/points/UserPointsTab.tsx | 41 +-
.../app/admin/settings/AccessSettings.tsx | 103 +++-
.../app/admin/settings/EconomySettings.tsx | 2 +-
frontend/app/globals.css | 93 ++++
.../app/notifications/NotificationsClient.tsx | 32 +-
frontend/app/post/[id]/page.tsx | 20 +-
.../components/CommentEditHistoryModal.tsx | 173 ------
frontend/components/CommentSection.tsx | 119 +++-
frontend/components/EditHistoryModal.tsx | 433 ++++++++++++++
frontend/components/MarkdownEditor.tsx | 8 +-
frontend/components/Modal.tsx | 6 +-
frontend/components/NotificationBell.tsx | 9 +-
frontend/components/PostActions.tsx | 73 ++-
frontend/components/PostComposer.tsx | 5 +-
frontend/components/PostEditedMark.tsx | 37 ++
frontend/lib/api.ts | 101 +++-
frontend/lib/smartDiff.test.ts | 214 +++++++
frontend/lib/smartDiff.ts | 527 ++++++++++++++++++
frontend/package.json | 2 +-
40 files changed, 2521 insertions(+), 350 deletions(-)
create mode 100644 backend/service/content_lock_test.go
delete mode 100644 frontend/components/CommentEditHistoryModal.tsx
create mode 100644 frontend/components/EditHistoryModal.tsx
create mode 100644 frontend/components/PostEditedMark.tsx
create mode 100644 frontend/lib/smartDiff.test.ts
create mode 100644 frontend/lib/smartDiff.ts
diff --git a/backend/handler/comment.go b/backend/handler/comment.go
index 12c33de..8bdfcf2 100644
--- a/backend/handler/comment.go
+++ b/backend/handler/comment.go
@@ -2,6 +2,7 @@ package handler
import (
"errors"
+ "fmt"
"net/http"
"strconv"
@@ -83,7 +84,7 @@ func (h *Handlers) PostComments(c *gin.Context) {
// CreateCommentRequest 评论请求
type CreateCommentRequest struct {
- Content string `json:"content" binding:"required,min=1"`
+ Content string `json:"content" binding:"required,min=1,max=5000"`
ParentID *uint `json:"parent_id"` // 非空 = 回复该评论(发为其子评论)
}
@@ -109,11 +110,24 @@ func (h *Handlers) CreateComment(c *gin.Context) {
}
// staff 直发;已过审用户在站点开启免审时可直发;其余进待审队列(通过时才发业务通知)
status := h.resolvePublishStatus(claims.ID, claims.Role)
- comment, parent, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status)
+ comment, parent, necroPoints, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status, model.Role(claims.Role))
if err != nil {
- c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ switch {
+ case errors.Is(err, service.ErrPostReplyLocked):
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrInsufficientPoints):
+ // 旧帖回复扣分余额不足(文案含具体规则)
+ c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ }
return
}
+ // 旧帖回复扣分:系统提醒本人(ActorID=0,不经过自我通知过滤)
+ if necroPoints > 0 {
+ h.Notification.CreateSystem(claims.ID, model.NotificationTypeNecroReply, uint(id), comment.ID,
+ fmt.Sprintf("您回复了长期没有新回复的旧帖,已扣除 %d 积分", necroPoints))
+ }
if comment.Status == model.ContentStatusPublished {
if parent == nil {
// 主评论:通知帖子作者(排除自己评论自己的帖子)
@@ -146,7 +160,7 @@ func (h *Handlers) UpdateComment(c *gin.Context) {
return
}
var body struct {
- Content string `json:"content"`
+ Content string `json:"content" binding:"omitempty,min=1,max=5000"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -157,7 +171,9 @@ func (h *Handlers) UpdateComment(c *gin.Context) {
switch {
case errors.Is(err, service.ErrCommentNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
- case errors.Is(err, service.ErrCommentForbidden):
+ case errors.Is(err, service.ErrCommentForbidden),
+ errors.Is(err, service.ErrCommentPostLocked),
+ errors.Is(err, service.ErrCommentEditLocked):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrCommentDeleted),
errors.Is(err, service.ErrCommentNotChanged):
@@ -256,3 +272,39 @@ func (h *Handlers) PurgeComment(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{"message": "已彻底删除"})
}
+
+// CommentEditHistory 评论编辑历史(登录即可见;分页)
+func (h *Handlers) CommentEditHistory(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
+ if err != nil || cid == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
+ return
+ }
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
+ items, total, err := h.Comment.ListEditHistory(h.loadActor(claims.ID), uint(cid), page, size)
+ if err != nil {
+ switch {
+ case errors.Is(err, service.ErrCommentNotFound):
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrCommentForbidden):
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"})
+ }
+ return
+ }
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 || size > 50 {
+ size = 10
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "items": items,
+ "total": total,
+ "page": page,
+ "size": size,
+ })
+}
diff --git a/backend/handler/moderation.go b/backend/handler/moderation.go
index 4fb8dc7..e32aa05 100644
--- a/backend/handler/moderation.go
+++ b/backend/handler/moderation.go
@@ -193,41 +193,6 @@ func (h *Handlers) AdminPurgeComment(c *gin.Context) {
h.execContentAction(c, h.Moderation.PurgeComment)
}
-// AdminCommentHistory 评论编辑历史(板域 scope;分页)
-func (h *Handlers) AdminCommentHistory(c *gin.Context) {
- id, err := strconv.ParseUint(c.Param("id"), 10, 64)
- if err != nil || id == 0 {
- c.JSON(http.StatusBadRequest, gin.H{"error": "无效的 ID"})
- return
- }
- page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
- size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
- items, total, err := h.Comment.ListEditHistory(middleware.CurrentActor(c), uint(id), page, size)
- if err != nil {
- switch {
- case errors.Is(err, service.ErrCommentNotFound):
- c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
- case errors.Is(err, service.ErrCommentForbidden):
- c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
- default:
- c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"})
- }
- return
- }
- if page < 1 {
- page = 1
- }
- if size < 1 || size > 50 {
- size = 10
- }
- c.JSON(http.StatusOK, gin.H{
- "items": items,
- "total": total,
- "page": page,
- "size": size,
- })
-}
-
func (h *Handlers) execContentAction(c *gin.Context, fn func(*service.Actor, uint) error) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil || id == 0 {
diff --git a/backend/handler/post.go b/backend/handler/post.go
index 05c71b0..1e8bdf1 100644
--- a/backend/handler/post.go
+++ b/backend/handler/post.go
@@ -89,6 +89,7 @@ func (h *Handlers) PostDetail(c *gin.Context) {
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
}
+ h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post})
}
@@ -96,7 +97,7 @@ func (h *Handlers) PostDetail(c *gin.Context) {
type CreatePostRequest struct {
BoardID uint `json:"board_id" binding:"required"`
Title string `json:"title" binding:"required,min=1,max=256"`
- Content string `json:"content" binding:"required,min=1"`
+ Content string `json:"content" binding:"required,min=1,max=20000"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
TypeMeta string `json:"type_meta"`
@@ -138,7 +139,7 @@ func (h *Handlers) CreatePost(c *gin.Context) {
// UpdatePostRequest 更新帖子请求
type UpdatePostRequest struct {
Title string `json:"title" binding:"omitempty,min=1,max=256"`
- Content string `json:"content" binding:"omitempty,min=1"`
+ Content string `json:"content" binding:"omitempty,min=1,max=20000"`
Tags string `json:"tags"`
TypeMeta *string `json:"type_meta"`
AttachmentIDs *[]uint `json:"attachment_ids"`
@@ -172,6 +173,42 @@ func (h *Handlers) UpdatePost(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"post": post})
}
+// PostEditHistory 帖子编辑历史(登录即可见;分页)
+func (h *Handlers) PostEditHistory(c *gin.Context) {
+ claims := middleware.CurrentUser(c)
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil || id == 0 {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
+ return
+ }
+ page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
+ size, _ := strconv.Atoi(c.DefaultQuery("size", "10"))
+ items, total, err := h.Post.ListEditHistory(h.loadActor(claims.ID), uint(id), page, size)
+ if err != nil {
+ switch {
+ case errors.Is(err, service.ErrPostNotFound):
+ c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
+ case errors.Is(err, service.ErrPostForbidden):
+ c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
+ default:
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "获取编辑历史失败"})
+ }
+ return
+ }
+ if page < 1 {
+ page = 1
+ }
+ if size < 1 || size > 50 {
+ size = 10
+ }
+ c.JSON(http.StatusOK, gin.H{
+ "items": items,
+ "total": total,
+ "page": page,
+ "size": size,
+ })
+}
+
// UnlockPostContent 积分解锁正文
func (h *Handlers) UnlockPostContent(c *gin.Context) {
claims := middleware.CurrentUser(c)
@@ -239,6 +276,7 @@ func (h *Handlers) UnlockPostPassword(c *gin.Context) {
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
}
+ h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post})
}
@@ -539,6 +577,52 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"recommended": rec})
}
+// TogglePostLock 切换帖子手动锁定(管理员及以上;锁定后普通用户不可编辑/回复,staff 豁免)
+func (h *Handlers) TogglePostLock(c *gin.Context) {
+ if !h.requireAdminOrAbove(c) {
+ return
+ }
+ id, err := strconv.ParseUint(c.Param("id"), 10, 64)
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
+ return
+ }
+ locked, err := h.Post.ToggleLock(uint(id))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"locked": locked})
+}
+
+// attachNecroReplyHint 旧帖回复确认提示:仅当 viewer 已登录、非 staff、规则开启、
+// 帖子判定为旧帖、且其评论将直发(待审不扣分不提醒,不弹窗)时返回
+func (h *Handlers) attachNecroReplyHint(claims *service.UserClaims, detail *service.PostDetail) {
+ if claims == nil || detail == nil || detail.Tombstone || detail.NecroReply != nil {
+ return
+ }
+ if model.IsStaff(model.Role(claims.Role)) {
+ return
+ }
+ afterHours, err1 := h.Setting.NecroReplyAfterHours()
+ penalty, err2 := h.Setting.NecroReplyPenalty()
+ if err1 != nil || err2 != nil || afterHours <= 0 || penalty <= 0 {
+ return
+ }
+ // 旧帖基准:最后回复时间;无回复(null)回落发帖时间
+ baseline := detail.CreatedAt
+ if detail.LastReplyAt != nil {
+ baseline = *detail.LastReplyAt
+ }
+ if !service.IsNecroReply(baseline, afterHours, time.Now()) {
+ return
+ }
+ if h.resolvePublishStatus(claims.ID, claims.Role) != model.ContentStatusPublished {
+ return
+ }
+ detail.NecroReply = &service.NecroReplyHint{AfterHours: afterHours, Penalty: penalty}
+}
+
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;以 DB Actor 为准
func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool {
claims := middleware.CurrentUser(c)
@@ -556,7 +640,9 @@ func respondPostModError(c *gin.Context, err error) {
case errors.Is(err, service.ErrPostNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPostForbidden),
- errors.Is(err, service.ErrAuthorProtected):
+ errors.Is(err, service.ErrAuthorProtected),
+ errors.Is(err, service.ErrPostLocked),
+ errors.Is(err, service.ErrPostEditLocked):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPollOptionsLocked),
errors.Is(err, service.ErrLotteryPrizesLocked):
diff --git a/backend/handler/setting.go b/backend/handler/setting.go
index 50fbc80..fb80d78 100644
--- a/backend/handler/setting.go
+++ b/backend/handler/setting.go
@@ -68,6 +68,11 @@ type updateSettingsRequest struct {
PointsReplyDailyCap *int `json:"points_reply_daily_cap"`
PointsRecommendReward *int `json:"points_recommend_reward"`
+ 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"`
+
Levels *[]model.LevelDef `json:"levels"`
LevelsFx *bool `json:"levels_fx"`
}
@@ -120,6 +125,11 @@ func settingsPayload(saved service.PublicSiteSettings) gin.H {
"points_reply_daily_cap": saved.PointsReplyDailyCap,
"points_recommend_reward": saved.PointsRecommendReward,
+ "post_edit_lock_hours": saved.PostEditLockHours,
+ "comment_edit_lock_hours": saved.CommentEditLockHours,
+ "necro_reply_after_hours": saved.NecroReplyAfterHours,
+ "necro_reply_penalty": saved.NecroReplyPenalty,
+
"levels": saved.Levels,
"levels_fx": saved.LevelsFx,
}
@@ -142,7 +152,9 @@ func (req *updateSettingsRequest) hasAny() bool {
req.PointsCheckinBase != nil || req.PointsStreakEveryDays != nil || req.PointsStreakBonus != nil ||
req.PointsPostReward != nil || req.PointsPostDailyCap != nil ||
req.PointsReplyReward != nil || req.PointsReplyDailyCap != nil ||
- req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil
+ req.PointsRecommendReward != nil || req.Levels != nil || req.LevelsFx != nil ||
+ req.PostEditLockHours != nil || req.CommentEditLockHours != nil ||
+ req.NecroReplyAfterHours != nil || req.NecroReplyPenalty != nil
}
// AdminGetSettings 超管读取站点设置(与公开 payload 字段一致)
@@ -247,6 +259,29 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
return
}
}
+ lockRules := []struct {
+ req *int
+ set func(int) error
+ hint string
+ }{
+ {req.PostEditLockHours, h.Setting.SetPostEditLockHours, "帖子可编辑时长须为 0–8760 小时"},
+ {req.CommentEditLockHours, h.Setting.SetCommentEditLockHours, "评论可编辑时长须为 0–8760 小时"},
+ {req.NecroReplyAfterHours, h.Setting.SetNecroReplyAfterHours, "旧帖回复阈值须为 0–8760 小时"},
+ {req.NecroReplyPenalty, h.Setting.SetNecroReplyPenalty, "旧帖回复扣分须为 0–100"},
+ }
+ for _, r := range lockRules {
+ if r.req == nil {
+ continue
+ }
+ if err := r.set(*r.req); err != nil {
+ if errors.Is(err, service.ErrInvalidSiteSetting) {
+ c.JSON(http.StatusBadRequest, gin.H{"error": r.hint})
+ return
+ }
+ c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
+ return
+ }
+ }
pointsRules := []struct {
req *int
set func(int) error
diff --git a/backend/model/db.go b/backend/model/db.go
index 8fb4a3f..b3bf353 100644
--- a/backend/model/db.go
+++ b/backend/model/db.go
@@ -57,7 +57,7 @@ func InitDB(dsn string) error {
if err := db.AutoMigrate(
&TemporaryUpload{}, &ModuleConfig{}, &SettingsAudit{}, &ActionCounter{}, &MailTask{}, &EmailChallenge{}, &StoredObject{},
- &User{}, &Board{}, &Post{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
+ &User{}, &Board{}, &Post{}, &PostEditHistory{}, &Comment{}, &CommentEditHistory{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
&Announcement{}, &SitePage{}, &SiteSetting{}, &SiteDailyStats{}, &SiteDailyVisitor{}, &VisitEvent{}, &Attachment{}, &UserBoard{}, &LoginLog{},
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
diff --git a/backend/model/models.go b/backend/model/models.go
index 5c27f03..2db9dde 100644
--- a/backend/model/models.go
+++ b/backend/model/models.go
@@ -220,6 +220,7 @@ type Post struct {
TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳)
Pinned int `gorm:"default:0" json:"pinned"`
Recommended bool `gorm:"default:false;index" json:"recommended"`
+ Locked bool `gorm:"default:false;index" json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `gorm:"size:16;default:published;index" json:"status"`
LikeCount int `gorm:"default:0" json:"like_count"`
ViewCount int `gorm:"default:0" json:"view_count"`
@@ -327,6 +328,7 @@ const (
PointReasonStreakBonus = "streak_bonus" // 连续签到里程碑加成
PointReasonRecommend = "recommend_reward" // 帖子被推荐奖励(可配置,0=关闭,每帖仅一次)
PointReasonAdminAdjust = "admin_adjust" // 管理员手动调整(加分计累计可升级;扣分仅扣余额)
+ PointReasonNecroReply = "necro_reply" // 旧帖回复扣分(可配置,0=关闭)
)
// PostPollVote 投票记录(多选时同一用户多行)
@@ -412,6 +414,16 @@ type CommentEditHistory struct {
CreatedAt time.Time `gorm:"index" json:"created_at"`
}
+// PostEditHistory 帖子修订快照(每次编辑前的旧标题/旧正文;登录用户可见)
+type PostEditHistory struct {
+ ID uint `gorm:"primaryKey" json:"id"`
+ PostID uint `gorm:"index;not null" json:"post_id"`
+ EditorID uint `gorm:"index;not null" json:"editor_id"`
+ OldTitle string `gorm:"size:256;not null;default:''" json:"old_title"`
+ OldContent string `gorm:"type:text;not null" json:"old_content"`
+ CreatedAt time.Time `gorm:"index" json:"created_at"`
+}
+
// Like 点赞记录(联合唯一索引防止重复点赞)
type Like struct {
ID uint `gorm:"primaryKey" json:"id"`
@@ -440,6 +452,7 @@ const (
NotificationTypePendingReview = "pending_review" // 有新内容待审核(发给可审者)
NotificationTypeBadge = "badge" // 获得管理员颁发的徽章
NotificationTypeDeleted = "deleted" // 帖子被管理员删除
+ NotificationTypeNecroReply = "necro_reply" // 回复旧帖提醒(系统,ActorID=0)
)
// Notification 站内通知
diff --git a/backend/router/router.go b/backend/router/router.go
index 31a16a2..2d83b02 100644
--- a/backend/router/router.go
+++ b/backend/router/router.go
@@ -204,6 +204,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.PUT("/profile", h.UpdateProfile)
api.POST("/posts", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RatePost)), h.CreatePost)
api.PUT("/posts/:id", h.UpdatePost)
+ api.GET("/posts/:id/history", h.PostEditHistory)
+ // 评论编辑历史(登录即可见;顶层路由:管理后台无 post_id 上下文)
+ api.GET("/comments/:cid/history", h.CommentEditHistory)
api.POST("/posts/:id/unlock", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.UnlockPostContent)
api.POST("/posts/:id/poll/vote", middleware.StaffExempt(middleware.RateLimitUserMiddleware(limiter, service.RateInteract)), h.VotePoll)
api.POST("/posts/:id/poll/close", h.ClosePoll)
@@ -215,6 +218,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.DELETE("/posts/:id", h.DeletePost)
api.PUT("/posts/:id/pin", h.TogglePin)
api.PUT("/posts/:id/recommend", h.ToggleRecommend)
+ api.PUT("/posts/:id/lock", h.TogglePostLock)
api.POST("/posts/:id/like", h.ToggleLike)
api.POST("/posts/:id/comments", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.CreateComment)
api.PUT("/posts/:id/comments/:cid", middleware.StaffExempt(middleware.RateLimitMiddleware(limiter, service.RateComment)), h.UpdateComment)
@@ -293,7 +297,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
staffAPI.DELETE("/content/comments/:id", h.AdminSoftDeleteComment)
staffAPI.PUT("/content/comments/:id/restore", h.AdminRestoreComment)
staffAPI.DELETE("/content/comments/:id/purge", h.AdminPurgeComment)
- staffAPI.GET("/content/comments/:id/history", h.AdminCommentHistory)
// 积分与类型帖概览(管理员及以上)
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
diff --git a/backend/service/admin_content.go b/backend/service/admin_content.go
index a05d147..4ba50b1 100644
--- a/backend/service/admin_content.go
+++ b/backend/service/admin_content.go
@@ -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 {
diff --git a/backend/service/announcement.go b/backend/service/announcement.go
index b9987e6..9693b0d 100644
--- a/backend/service/announcement.go
+++ b/backend/service/announcement.go
@@ -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
}
diff --git a/backend/service/comment.go b/backend/service/comment.go
index d47769c..2ca4b4b 100644
--- a/backend/service/comment.go
+++ b/backend/service/comment.go
@@ -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
}
diff --git a/backend/service/content_lock_test.go b/backend/service/content_lock_test.go
new file mode 100644
index 0000000..9c5a7c2
--- /dev/null
+++ b/backend/service/content_lock_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/backend/service/moderation.go b/backend/service/moderation.go
index 75368cb..468c6a2 100644
--- a/backend/service/moderation.go
+++ b/backend/service/moderation.go
@@ -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,
diff --git a/backend/service/notification.go b/backend/service/notification.go
index 8660c60..5c1dbdf 100644
--- a/backend/service/notification.go
+++ b/backend/service/notification.go
@@ -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 {
diff --git a/backend/service/post.go b/backend/service/post.go
index 55e8f9b..a786b48 100644
--- a/backend/service/post.go
+++ b/backend/service/post.go
@@ -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 {
diff --git a/backend/service/setting.go b/backend/service/setting.go
index 6bc2f88..0289a2e 100644
--- a/backend/service/setting.go
+++ b/backend/service/setting.go
@@ -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)
diff --git a/backend/service/site_page.go b/backend/service/site_page.go
index acf6362..6131830 100644
--- a/backend/service/site_page.go
+++ b/backend/service/site_page.go
@@ -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
}
diff --git a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx
index 62cbffa..02fd8c6 100644
--- a/frontend/app/admin/announcements/AnnouncementComposeClient.tsx
+++ b/frontend/app/admin/announcements/AnnouncementComposeClient.tsx
@@ -21,6 +21,9 @@ import { ANNOUNCEMENT_COLORS, announcementPublicPath } from "./announcementColor
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
+/** 公告正文字符上限(与后端 service/announcement.go 校验一致) */
+const CONTENT_MAX = 20000;
+
type FormState = {
title: string;
content: string;
@@ -328,8 +331,9 @@ export default function AnnouncementComposeClient({
value={form.content}
onChange={(content) => {
setErrors((prev) => ({ ...prev, content: undefined }));
- patchForm({ content });
+ patchForm({ content: content.slice(0, CONTENT_MAX) });
}}
+ maxChars={CONTENT_MAX}
allowHide
allowReplyHide={false}
placeholder="请输入公告正文,支持 Markdown…"
diff --git a/frontend/app/admin/badges/BadgesClient.tsx b/frontend/app/admin/badges/BadgesClient.tsx
index 8ee27e9..172395b 100644
--- a/frontend/app/admin/badges/BadgesClient.tsx
+++ b/frontend/app/admin/badges/BadgesClient.tsx
@@ -200,7 +200,7 @@ export default function BadgesClient() {
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
-