补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
13 KiB
Go
439 lines
13 KiB
Go
package handler
|
|
|
|
import (
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"path/filepath"
|
|
"strconv"
|
|
|
|
"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)
|
|
}
|
|
post, err := h.Post.GetByIDForViewer(uint(id), viewerID, loadActor)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
|
return
|
|
}
|
|
if claims != nil {
|
|
post.Liked = h.Like.HasLiked(post.ID, claims.ID)
|
|
}
|
|
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"`
|
|
Tags string `json:"tags"`
|
|
PostType string `json:"post_type"`
|
|
ContentAccess string `json:"content_access"`
|
|
AccessPoints int `json:"access_points"`
|
|
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 := model.ContentStatusPending
|
|
if model.IsStaff(model.Role(claims.Role)) {
|
|
status = model.ContentStatusPublished
|
|
}
|
|
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,
|
|
ContentAccess: req.ContentAccess,
|
|
AccessPoints: req.AccessPoints,
|
|
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"`
|
|
Tags string `json:"tags"`
|
|
ContentAccess *string `json:"content_access"`
|
|
AccessPoints *int `json:"access_points"`
|
|
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,
|
|
ContentAccess: req.ContentAccess,
|
|
AccessPoints: req.AccessPoints,
|
|
TypeMeta: req.TypeMeta,
|
|
AttachmentIDs: req.AttachmentIDs,
|
|
})
|
|
if err != nil {
|
|
respondPostModError(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"post": post})
|
|
}
|
|
|
|
// 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})
|
|
}
|
|
|
|
// 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)
|
|
file, err := c.FormFile("file")
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请选择文件"})
|
|
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()
|
|
data, err := io.ReadAll(io.LimitReader(f, service.FileMaxBytes+1))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "读取文件失败"})
|
|
return
|
|
}
|
|
att, err := h.PostFile.SaveDraftFile(claims.ID, file.Filename, data, price)
|
|
if err != nil {
|
|
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("Content-Type", att.MIME)
|
|
c.File(path)
|
|
_ = filepath.Base(path)
|
|
}
|
|
|
|
// DeletePost 删除帖子
|
|
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
|
|
}
|
|
if err := h.Post.Delete(h.loadActor(claims.ID), uint(id), claims.ID); err != nil {
|
|
respondPostModError(c, err)
|
|
return
|
|
}
|
|
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
|
|
}
|
|
rec, err := h.Post.ToggleRecommend(uint(id))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, gin.H{"recommended": rec})
|
|
}
|
|
|
|
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false
|
|
func (h *Handlers) requireAdminOrAbove(c *gin.Context) bool {
|
|
claims := middleware.CurrentUser(c)
|
|
if model.RoleLevel(model.Role(claims.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):
|
|
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
|
|
default:
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
}
|
|
}
|