- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal - 新增 PostEditedMark 编辑标记展示 - 新增内容锁定机制(content_lock)防止并发编辑冲突 - 审核与通知服务适配
311 lines
11 KiB
Go
311 lines
11 KiB
Go
package handler
|
||
|
||
import (
|
||
"errors"
|
||
"fmt"
|
||
"net/http"
|
||
"strconv"
|
||
|
||
"github.com/freefire/jiang13-bbs/middleware"
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"github.com/freefire/jiang13-bbs/service"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// PostComments 获取帖子评论树(楼层分页:主评论算楼层,每楼附带全部子回复,时间正序)
|
||
func (h *Handlers) PostComments(c *gin.Context) {
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||
return
|
||
}
|
||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
|
||
|
||
// 待审/被拒帖子的评论不对公众开放
|
||
var viewerID uint
|
||
var actor *service.Actor
|
||
var loadActor func() *service.Actor
|
||
if claims := middleware.CurrentUser(c); claims != nil {
|
||
viewerID = claims.ID
|
||
loadActor = h.actorLoader(claims.ID)
|
||
if a, err := h.Auth.LoadActor(claims.ID); err == nil {
|
||
actor = a
|
||
}
|
||
}
|
||
if err := h.Post.EnsurePostVisible(uint(id), viewerID, loadActor); err != nil {
|
||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||
return
|
||
}
|
||
|
||
if allow, err := h.Setting.AllowComments(); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||
return
|
||
} else if !allow {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"comments": []any{},
|
||
"total": 0,
|
||
"total_comments": 0,
|
||
"page": page,
|
||
"size": size,
|
||
})
|
||
return
|
||
}
|
||
if requireLogin, err := h.Setting.CommentsRequireLogin(); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||
return
|
||
} else if requireLogin && viewerID == 0 {
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"comments": []any{},
|
||
"total": 0,
|
||
"total_comments": 0,
|
||
"page": page,
|
||
"size": size,
|
||
"require_login": true,
|
||
})
|
||
return
|
||
}
|
||
|
||
boardID, _ := h.Post.GetBoardID(uint(id))
|
||
|
||
comments, floors, totalComments, err := h.Comment.ListFloorPaged(uint(id), boardID, viewerID, actor, page, size)
|
||
if err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{
|
||
"comments": nonNilSlice(comments),
|
||
"total": floors, // 楼层数(主评论数)→ 前端分页与楼层号计算
|
||
"total_comments": totalComments, // 全部评论数(含回复)→ 展示徽标
|
||
"page": page,
|
||
"size": size,
|
||
})
|
||
}
|
||
|
||
// CreateCommentRequest 评论请求
|
||
type CreateCommentRequest struct {
|
||
Content string `json:"content" binding:"required,min=1,max=5000"`
|
||
ParentID *uint `json:"parent_id"` // 非空 = 回复该评论(发为其子评论)
|
||
}
|
||
|
||
// CreateComment 创建评论(主评论或子回复)
|
||
func (h *Handlers) CreateComment(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||
return
|
||
}
|
||
if allow, err := h.Setting.AllowComments(); err != nil {
|
||
c.JSON(http.StatusInternalServerError, gin.H{"error": "评论暂时不可用"})
|
||
return
|
||
} else if !allow {
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "本站不支持评论"})
|
||
return
|
||
}
|
||
var req CreateCommentRequest
|
||
if err := c.ShouldBindJSON(&req); err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
return
|
||
}
|
||
// staff 直发;已过审用户在站点开启免审时可直发;其余进待审队列(通过时才发业务通知)
|
||
status := h.resolvePublishStatus(claims.ID, claims.Role)
|
||
comment, parent, necroPoints, err := h.Comment.Create(claims.ID, uint(id), req.Content, req.ParentID, status, model.Role(claims.Role))
|
||
if err != nil {
|
||
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 {
|
||
// 主评论:通知帖子作者(排除自己评论自己的帖子)
|
||
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
|
||
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
|
||
}
|
||
// 抽奖帖:主评入池并记录客户端 IP(同 IP 去重)
|
||
h.Post.EnsureLotteryEntry(uint(id), claims.ID, comment.ID, c.ClientIP())
|
||
} else {
|
||
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
|
||
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)
|
||
}
|
||
if boardID, e := h.Post.GetBoardID(uint(id)); e == nil {
|
||
h.broadcastFeedChanged("comment", uint(id), boardID, claims.ID)
|
||
}
|
||
} else if comment.Status == model.ContentStatusPending {
|
||
if boardID, e := h.Post.GetBoardID(uint(id)); e == nil {
|
||
h.notifyPendingReview(boardID, claims.ID, uint(id), comment.ID, "有新评论待审核")
|
||
}
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"comment": comment})
|
||
}
|
||
|
||
// UpdateComment 编辑评论(作者或版主)
|
||
func (h *Handlers) UpdateComment(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||
return
|
||
}
|
||
var body struct {
|
||
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()})
|
||
return
|
||
}
|
||
node, err := h.Comment.Update(h.loadActor(claims.ID), uint(cid), claims.ID, body.Content)
|
||
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),
|
||
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):
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"comment": node})
|
||
}
|
||
|
||
// DeleteComment 软删评论(占位;自删与管理删均须填理由,管理删另须类型)
|
||
func (h *Handlers) DeleteComment(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||
return
|
||
}
|
||
var body struct {
|
||
DeleteType string `json:"delete_type"`
|
||
DeleteReason string `json:"delete_reason"`
|
||
}
|
||
_ = c.ShouldBindJSON(&body)
|
||
if err := h.Comment.Delete(h.loadActor(claims.ID), uint(cid), claims.ID, service.DeleteCommentOpts{
|
||
DeleteType: body.DeleteType,
|
||
DeleteReason: body.DeleteReason,
|
||
}); err != nil {
|
||
switch {
|
||
case errors.Is(err, service.ErrCommentNotFound):
|
||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||
case errors.Is(err, service.ErrCommentForbidden),
|
||
errors.Is(err, service.ErrAuthorProtected):
|
||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||
case errors.Is(err, service.ErrCommentDeleteMeta),
|
||
errors.Is(err, service.ErrCommentInvalidType):
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
default:
|
||
msg := err.Error()
|
||
if msg == "删除理由不能超过 200 字" {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||
return
|
||
}
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": msg})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
|
||
}
|
||
|
||
// RestoreComment 恢复软删评论(自删作者或版主)
|
||
func (h *Handlers) RestoreComment(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||
return
|
||
}
|
||
if err := h.Comment.Restore(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil {
|
||
switch {
|
||
case errors.Is(err, service.ErrCommentNotFound):
|
||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||
case errors.Is(err, service.ErrCommentForbidden),
|
||
errors.Is(err, service.ErrAuthorProtected):
|
||
c.JSON(http.StatusForbidden, gin.H{"error": "无权限恢复此评论"})
|
||
case errors.Is(err, service.ErrCommentNotDeleted):
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
}
|
||
return
|
||
}
|
||
c.JSON(http.StatusOK, gin.H{"message": "已恢复"})
|
||
}
|
||
|
||
// PurgeComment 硬删评论及子树(仅站长;不占位)
|
||
func (h *Handlers) PurgeComment(c *gin.Context) {
|
||
claims := middleware.CurrentUser(c)
|
||
cid, err := strconv.ParseUint(c.Param("cid"), 10, 64)
|
||
if err != nil {
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的评论 ID"})
|
||
return
|
||
}
|
||
if err := h.Comment.Purge(h.loadActor(claims.ID), uint(cid), claims.ID); err != nil {
|
||
switch {
|
||
case errors.Is(err, service.ErrCommentNotFound):
|
||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||
case errors.Is(err, service.ErrCommentForbidden),
|
||
errors.Is(err, service.ErrPurgeOwnerOnly):
|
||
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
||
default:
|
||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||
}
|
||
return
|
||
}
|
||
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,
|
||
})
|
||
}
|