Files
jiang13-bbs/backend/handler/post.go
freefire 5270fee2b7 feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
2026-09-25 23:41:45 +08:00

654 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package handler
import (
"context"
"errors"
"io"
"net/http"
"net/url"
"path/filepath"
"strconv"
"time"
"github.com/freefire/jiang13-bbs/middleware"
"github.com/freefire/jiang13-bbs/model"
"github.com/freefire/jiang13-bbs/service"
"github.com/gin-gonic/gin"
)
// Posts 获取帖子列表
func (h *Handlers) Posts(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
boardID, _ := strconv.ParseUint(c.Query("board_id"), 10, 64)
sort := c.DefaultQuery("sort", "latest")
keyword := c.Query("keyword")
recommended := c.Query("recommended") == "true"
var viewerID uint
var actor *service.Actor
if claims := middleware.CurrentUser(c); claims != nil {
viewerID = claims.ID
if a, err := h.Auth.LoadActor(claims.ID); err == nil {
actor = a
}
}
items, total, err := h.Post.List(service.PostListQuery{
BoardID: uint(boardID),
Page: page,
Size: size,
Sort: sort,
Keyword: keyword,
Recommended: recommended,
ViewerID: viewerID,
Actor: actor,
})
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if claims := middleware.CurrentUser(c); claims != nil {
ids := make([]uint, 0, len(items))
for _, p := range items {
ids = append(ids, p.ID)
}
likedMap := h.Like.BatchHasLiked(ids, claims.ID)
for i := range items {
items[i].Liked = likedMap[items[i].ID]
}
}
c.JSON(http.StatusOK, gin.H{
"posts": items,
"total": total,
"page": page,
"size": size,
})
}
// PostDetail 获取帖子详情
func (h *Handlers) PostDetail(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
claims := middleware.CurrentUser(c)
var viewerID uint
var loadActor func() *service.Actor
if claims != nil {
viewerID = claims.ID
loadActor = h.actorLoader(claims.ID)
}
pwdUnlocked := h.HidePwd.ReadUnlocked(c, uint(id))
post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor, pwdUnlocked)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
}
h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post})
}
// CreatePostRequest 发帖请求
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,max=20000"`
Tags string `json:"tags"`
PostType string `json:"post_type"`
TypeMeta string `json:"type_meta"`
AttachmentIDs []uint `json:"attachment_ids"`
}
// CreatePost 创建帖子
func (h *Handlers) CreatePost(c *gin.Context) {
claims := middleware.CurrentUser(c)
var req CreatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
status := h.resolvePublishStatus(claims.ID, claims.Role)
post, err := h.Post.Create(service.CreatePostInput{
UserID: claims.ID,
BoardID: req.BoardID,
Title: req.Title,
Content: req.Content,
Tags: req.Tags,
PostType: req.PostType,
TypeMeta: req.TypeMeta,
Status: status,
AttachmentIDs: req.AttachmentIDs,
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if post.Status == model.ContentStatusPublished {
h.broadcastFeedChanged("post", post.ID, post.BoardID, claims.ID)
} else if post.Status == model.ContentStatusPending {
h.notifyPendingReview(post.BoardID, claims.ID, post.ID, 0, "有新帖待审核:"+post.Title)
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// UpdatePostRequest 更新帖子请求
type UpdatePostRequest struct {
Title string `json:"title" binding:"omitempty,min=1,max=256"`
Content string `json:"content" binding:"omitempty,min=1,max=20000"`
Tags string `json:"tags"`
TypeMeta *string `json:"type_meta"`
AttachmentIDs *[]uint `json:"attachment_ids"`
}
// UpdatePost 编辑帖子
func (h *Handlers) UpdatePost(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
}
var req UpdatePostRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
actor := h.loadActor(claims.ID)
post, err := h.Post.Update(actor, uint(id), claims.ID, service.UpdatePostInput{
Title: req.Title,
Content: req.Content,
Tags: req.Tags,
TypeMeta: req.TypeMeta,
AttachmentIDs: req.AttachmentIDs,
})
if err != nil {
respondPostModError(c, err)
return
}
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)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
post, err := h.Post.UnlockContent(claims.ID, uint(id))
if err != nil {
if errors.Is(err, service.ErrInsufficientPoints) {
c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// UnlockPostPasswordRequest 密码解锁隐藏块
type UnlockPostPasswordRequest struct {
Password string `json:"password" binding:"required,min=1,max=64"`
}
// UnlockPostPassword 输入密码解锁正文密码隐藏块(游客可用,签名 cookie 记住)
func (h *Handlers) UnlockPostPassword(c *gin.Context) {
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
var req UnlockPostPasswordRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请输入密码"})
return
}
matched, err := h.Post.UnlockByPassword(uint(id), req.Password)
if err != nil {
if errors.Is(err, service.ErrPostNotFound) {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
h.HidePwd.WriteUnlocked(c, uint(id), matched)
claims := middleware.CurrentUser(c)
var viewerID uint
var loadActor func() *service.Actor
if claims != nil {
viewerID = claims.ID
loadActor = h.actorLoader(claims.ID)
}
pwdUnlocked := h.HidePwd.ReadUnlocked(c, uint(id))
for _, i := range matched {
pwdUnlocked[i] = true
}
post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor, pwdUnlocked)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
if claims != nil {
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
}
h.attachNecroReplyHint(claims, post)
c.JSON(http.StatusOK, gin.H{"post": post})
}
// GetPointsBalance 当前用户积分余额
func (h *Handlers) GetPointsBalance(c *gin.Context) {
claims := middleware.CurrentUser(c)
bal, err := h.Points.Balance(claims.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"points": bal})
}
// GetPointsLedger 本人积分流水
func (h *Handlers) GetPointsLedger(c *gin.Context) {
claims := middleware.CurrentUser(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
size, _ := strconv.Atoi(c.DefaultQuery("size", "20"))
items, total, err := h.Points.Ledger(claims.ID, page, size)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"items": nonNilSlice(items),
"total": total,
"page": page,
"size": size,
})
}
// AdminPointsStats 管理端积分与类型帖看板
func (h *Handlers) AdminPointsStats(c *gin.Context) {
st, err := h.Points.AdminEconomyStats()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, st)
}
// UploadPostFile 上传帖子附件(草稿态)
func (h *Handlers) UploadPostFile(c *gin.Context) {
claims := middleware.CurrentUser(c)
maxBytes, err := h.Setting.AttachmentMaxBytes()
if err != nil || maxBytes < 1 {
maxBytes = service.FileMaxBytes
}
overhead := int64(64 << 10) // multipart 边界开销
limit := maxBytes + overhead
if c.Request.ContentLength > limit {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, limit)
file, err := c.FormFile("file")
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
return
}
if file.Size > maxBytes {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
price, _ := strconv.Atoi(c.DefaultPostForm("price_points", "0"))
f, err := file.Open()
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无法读取文件"})
return
}
defer f.Close()
att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, f, price)
if err != nil {
var maxErr *http.MaxBytesError
if errors.As(err, &maxErr) || errors.Is(err, service.ErrAttachmentTooLarge) {
mb := int(maxBytes >> 20)
if mb < 1 {
mb = 1
}
c.JSON(http.StatusRequestEntityTooLarge, gin.H{"error": "附件不能超过 " + strconv.Itoa(mb) + "MB"})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"attachment": gin.H{
"id": att.ID,
"name": att.Name,
"size": att.Size,
"mime": att.MIME,
"price_points": att.PricePoints,
"download_count": att.DownloadCount,
"unlocked": true,
},
})
}
// DeletePostFile 删除本人附件草稿
func (h *Handlers) DeletePostFile(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 err := h.PostFile.DeleteOwn(claims.ID, uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// UpdatePostFilePrice 更新附件积分定价
func (h *Handlers) UpdatePostFilePrice(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
}
var req struct {
PricePoints int `json:"price_points"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
return
}
if err := h.PostFile.UpdatePrice(claims.ID, uint(id), req.PricePoints); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"ok": true})
}
// DownloadPostAttachment 下载帖子附件(鉴权 + 积分)
func (h *Handlers) DownloadPostAttachment(c *gin.Context) {
postID, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
attID, err := strconv.ParseUint(c.Param("aid"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的附件 ID"})
return
}
claims := middleware.CurrentUser(c)
var viewerID uint
var loadActor func() *service.Actor
if claims != nil {
viewerID = claims.ID
loadActor = h.actorLoader(claims.ID)
}
if err := h.Post.EnsurePostVisible(uint(postID), viewerID, loadActor); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
return
}
att, err := h.PostFile.Get(uint(attID))
if err != nil || att.PostID != uint(postID) {
c.JSON(http.StatusNotFound, gin.H{"error": "附件不存在"})
return
}
if att.PricePoints > 0 {
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"})
return
}
if err := h.PostFile.EnsureAttachmentUnlocked(claims.ID, att); err != nil {
if errors.Is(err, service.ErrInsufficientPoints) {
c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
}
path := h.PostFile.FilePath(att)
h.PostFile.IncDownload(att.ID)
c.Header("Content-Disposition", "attachment; filename*=UTF-8''"+url.PathEscape(att.Name))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Security-Policy", "sandbox")
ct := att.MIME
ext := service.ExtOfFilename(att.Name)
if service.IsActiveContentExt(ext) || ct == "" {
ct = "application/octet-stream"
}
c.Header("Content-Type", ct)
if att.ObjectID != "" {
ctx, cancel := context.WithTimeout(c.Request.Context(), 60*time.Second)
defer cancel()
r, _, err := h.Ops.OpenObject(ctx, att.ObjectID, false)
if err != nil {
c.JSON(503, gin.H{"error": "文件暂不可用"})
return
}
defer r.Close()
c.Header("Cache-Control", "private, no-store")
c.Status(200)
_, _ = io.Copy(c.Writer, r)
return
}
c.File(path)
_ = filepath.Base(path)
}
// DeletePost 删除帖子(作者自删或 staff 软删;staff 须提交类型与理由)
func (h *Handlers) DeletePost(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
}
var body struct {
DeleteType string `json:"delete_type"`
DeleteReason string `json:"delete_reason"`
}
_ = c.ShouldBindJSON(&body)
staffDeleted, authorID, err := h.Post.Delete(
h.loadActor(claims.ID),
uint(id),
claims.ID,
service.DeletePostOpts{DeleteType: body.DeleteType, DeleteReason: body.DeleteReason},
)
if err != nil {
respondPostModError(c, err)
return
}
if staffDeleted {
label := model.PostDeleteTypeLabel(body.DeleteType)
preview := label
if body.DeleteReason != "" {
preview = label + ":" + body.DeleteReason
}
h.Notification.Create(authorID, claims.ID, model.NotificationTypeDeleted, uint(id), 0, preview)
}
c.JSON(http.StatusOK, gin.H{"message": "已删除"})
}
// TogglePin 切换帖子置顶(管理员及以上,板块管理员无此权限)
func (h *Handlers) TogglePin(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
}
pinned, err := h.Post.TogglePin(uint(id))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"pinned": pinned})
}
// ToggleRecommend 切换帖子加精(管理员及以上,板块管理员无此权限)
func (h *Handlers) ToggleRecommend(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
}
claims := middleware.CurrentUser(c)
rec, err := h.Post.ToggleRecommend(uint(id), claims.ID)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
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)
actor, err := h.Auth.LoadActor(claims.ID)
if err != nil || model.RoleLevel(actor.Role) < model.RoleLevel(model.RoleAdmin) {
c.JSON(http.StatusForbidden, gin.H{"error": "仅管理员可操作"})
return false
}
return true
}
// respondPostModError 帖子编辑/删除业务错误 → HTTP 状态码
func respondPostModError(c *gin.Context, err error) {
switch {
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.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):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
}