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

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