- 新增 AdminSessionRecover:后台离线/后端重启时自动重试恢复会话,明确失效才回跳登录 - 认证 cookie 契约调整(SameSite=Lax 与刷新轮转适配),路由与守卫适配 - 访问来源统计查询优化与测试、管理端 analytics sources 页面适配 - 移动端布局修正:覆盖 body min-h-screen 避免内容区高度异常
664 lines
20 KiB
Go
664 lines
20 KiB
Go
package handler
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"io"
|
||
"net/http"
|
||
"net/url"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/freefire/jiang13-bbs/middleware"
|
||
"github.com/freefire/jiang13-bbs/model"
|
||
"github.com/freefire/jiang13-bbs/service"
|
||
"github.com/gin-gonic/gin"
|
||
)
|
||
|
||
// maxSearchKeywordRunes 帖子搜索关键词长度上限(字符数,与前端搜索框 maxLength 一致)
|
||
const maxSearchKeywordRunes = 100
|
||
|
||
// 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 := strings.TrimSpace(c.Query("keyword"))
|
||
// 搜索词上限:前端输入框同限;超长词 ILIKE 必然无意义结果,静默截断
|
||
if runes := []rune(keyword); len(runes) > maxSearchKeywordRunes {
|
||
keyword = string(runes[:maxSearchKeywordRunes])
|
||
}
|
||
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)
|
||
post.Favorited = h.Favorite.HasFavorited(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)
|
||
post.Favorited = h.Favorite.HasFavorited(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()})
|
||
}
|
||
}
|