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

@@ -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,
})
}

View File

@@ -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 {

View File

@@ -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):

View File

@@ -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