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