feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -24,6 +24,8 @@ func main() {
|
||||
service.ConfigureCookieNames(!cfg.DevMode)
|
||||
// 过期/已吊销 refresh token 定期清理
|
||||
service.StartRefreshTokenCleanup(model.DB)
|
||||
// 悬赏过期退回 / 抽奖到期开奖
|
||||
service.StartTypedPostSettler(service.NewPostService(model.DB))
|
||||
|
||||
r, err := router.Setup(cfg)
|
||||
if err != nil {
|
||||
|
||||
@@ -22,6 +22,8 @@ type Handlers struct {
|
||||
Checkin *service.CheckinService
|
||||
Announcement *service.AnnouncementService
|
||||
Upload *service.UploadService
|
||||
PostFile *service.PostFileService
|
||||
Points *service.PointsService
|
||||
Setting *service.SettingService
|
||||
AdminUser *service.AdminUserService
|
||||
Moderation *service.ModerationService
|
||||
|
||||
@@ -2,7 +2,10 @@ package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
@@ -43,7 +46,6 @@ func (h *Handlers) Posts(c *gin.Context) {
|
||||
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 {
|
||||
@@ -74,14 +76,13 @@ func (h *Handlers) PostDetail(c *gin.Context) {
|
||||
var loadActor func() *service.Actor
|
||||
if claims != nil {
|
||||
viewerID = claims.ID
|
||||
loadActor = h.actorLoader(claims.ID) // 懒加载:仅非已发布帖才查 DB
|
||||
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)
|
||||
}
|
||||
@@ -90,11 +91,15 @@ func (h *Handlers) PostDetail(c *gin.Context) {
|
||||
|
||||
// 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"`
|
||||
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 创建帖子
|
||||
@@ -105,17 +110,23 @@ func (h *Handlers) CreatePost(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
postType := req.PostType
|
||||
if postType == "" {
|
||||
postType = "normal"
|
||||
}
|
||||
// 管理团队成员发帖免审直发;普通用户进入待审核队列。
|
||||
// 角色变更会强制 JWT 失效(token_version 递增),claims.Role 可视为实时值
|
||||
status := model.ContentStatusPending
|
||||
if model.IsStaff(model.Role(claims.Role)) {
|
||||
status = model.ContentStatusPublished
|
||||
}
|
||||
post, err := h.Post.Create(claims.ID, req.BoardID, req.Title, req.Content, req.Tags, postType, status)
|
||||
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
|
||||
@@ -130,9 +141,13 @@ 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"`
|
||||
Tags string `json:"tags"`
|
||||
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 编辑帖子
|
||||
@@ -149,7 +164,15 @@ func (h *Handlers) UpdatePost(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
actor := h.loadActor(claims.ID)
|
||||
post, err := h.Post.Update(actor, uint(id), claims.ID, req.Title, req.Content, req.Tags)
|
||||
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
|
||||
@@ -157,6 +180,190 @@ func (h *Handlers) UpdatePost(c *gin.Context) {
|
||||
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)
|
||||
@@ -200,12 +407,12 @@ func (h *Handlers) ToggleRecommend(c *gin.Context) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
|
||||
return
|
||||
}
|
||||
recommended, err := h.Post.ToggleRecommend(uint(id))
|
||||
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": recommended})
|
||||
c.JSON(http.StatusOK, gin.H{"recommended": rec})
|
||||
}
|
||||
|
||||
// requireAdminOrAbove 置顶/加精仅管理员及以上可用;校验失败已写响应,返回 false
|
||||
|
||||
197
backend/handler/post_interact.go
Normal file
197
backend/handler/post_interact.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/middleware"
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *Handlers) respondInteractError(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()})
|
||||
case errors.Is(err, service.ErrInsufficientPoints):
|
||||
c.JSON(http.StatusPaymentRequired, gin.H{"error": err.Error()})
|
||||
case errors.Is(err, service.ErrAlreadyVoted),
|
||||
errors.Is(err, service.ErrPollClosed),
|
||||
errors.Is(err, service.ErrPollOptionsLocked),
|
||||
errors.Is(err, service.ErrLotteryDrawn),
|
||||
errors.Is(err, service.ErrLotteryClosed),
|
||||
errors.Is(err, service.ErrBountySettled),
|
||||
errors.Is(err, service.ErrBountyExpired):
|
||||
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
|
||||
default:
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
}
|
||||
}
|
||||
|
||||
// VotePoll 投票
|
||||
func (h *Handlers) VotePoll(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 {
|
||||
Options []int `json:"options"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "参数错误"})
|
||||
return
|
||||
}
|
||||
post, err := h.Post.VotePoll(claims.ID, uint(id), req.Options)
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// ClosePoll 结束投票
|
||||
func (h *Handlers) ClosePoll(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.ClosePoll(h.loadActor(claims.ID), claims.ID, uint(id))
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// AcceptQuestion 采纳问答答案
|
||||
func (h *Handlers) AcceptQuestion(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 {
|
||||
CommentID uint `json:"comment_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定评论"})
|
||||
return
|
||||
}
|
||||
post, err := h.Post.AcceptQuestion(h.loadActor(claims.ID), claims.ID, uint(id), req.CommentID)
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// SolveQuestion 手动标已解决
|
||||
func (h *Handlers) SolveQuestion(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.SolveQuestion(h.loadActor(claims.ID), claims.ID, uint(id))
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// ReopenQuestion 重新打开问答
|
||||
func (h *Handlers) ReopenQuestion(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.ReopenQuestion(h.loadActor(claims.ID), claims.ID, uint(id))
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// AcceptBounty 采纳评论并发放悬赏
|
||||
func (h *Handlers) AcceptBounty(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 {
|
||||
CommentID uint `json:"comment_id" binding:"required"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "请指定评论"})
|
||||
return
|
||||
}
|
||||
post, err := h.Post.AcceptBounty(h.loadActor(claims.ID), claims.ID, uint(id), req.CommentID)
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// RefundBounty 退回悬赏
|
||||
func (h *Handlers) RefundBounty(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.RefundBounty(h.loadActor(claims.ID), claims.ID, uint(id))
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// CloseLottery 截止抽奖
|
||||
func (h *Handlers) CloseLottery(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.CloseLotteryEntries(h.loadActor(claims.ID), claims.ID, uint(id))
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
|
||||
// DrawLottery 开奖
|
||||
func (h *Handlers) DrawLottery(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.DrawLottery(h.loadActor(claims.ID), claims.ID, uint(id))
|
||||
if err != nil {
|
||||
h.respondInteractError(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"post": post})
|
||||
}
|
||||
@@ -49,6 +49,46 @@ func (h *Handlers) UploadAvatar(c *gin.Context) {
|
||||
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
|
||||
}
|
||||
|
||||
// UploadImage 上传帖子插图(multipart 字段 file:JPEG / PNG / WebP)
|
||||
func (h *Handlers) UploadImage(c *gin.Context) {
|
||||
claims := middleware.CurrentUser(c)
|
||||
|
||||
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, service.ImageMaxBytes+4096)
|
||||
|
||||
fh, err := c.FormFile("file")
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片过大(不能超过 5MB)或格式不正确"})
|
||||
return
|
||||
}
|
||||
if fh.Size > service.ImageMaxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
f, err := fh.Open()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
data, err := io.ReadAll(io.LimitReader(f, service.ImageMaxBytes+1))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "读取图片失败"})
|
||||
return
|
||||
}
|
||||
if len(data) > service.ImageMaxBytes {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "图片不能超过 5MB"})
|
||||
return
|
||||
}
|
||||
|
||||
att, err := h.Upload.SaveImage(claims.ID, data)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"url": att.URL, "attachment": att})
|
||||
}
|
||||
|
||||
// UseAvatarRequest 选用历史头像请求
|
||||
type UseAvatarRequest struct {
|
||||
URL string `json:"url"`
|
||||
|
||||
@@ -2,12 +2,13 @@ package middleware
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// RateLimitMiddleware 速率限制中间件
|
||||
// RateLimitMiddleware 速率限制中间件(按 IP)
|
||||
func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
key := rateType + ":" + c.ClientIP()
|
||||
@@ -18,3 +19,20 @@ func RateLimitMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFu
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
// RateLimitUserMiddleware 按登录用户限流(需挂在 RequireAuth 之后)
|
||||
func RateLimitUserMiddleware(rl *service.RateLimiter, rateType string) gin.HandlerFunc {
|
||||
return func(c *gin.Context) {
|
||||
claims := CurrentUser(c)
|
||||
id := "anon"
|
||||
if claims != nil {
|
||||
id = strconv.FormatUint(uint64(claims.ID), 10)
|
||||
}
|
||||
key := rateType + ":u:" + id
|
||||
if !rl.Allow(key) {
|
||||
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{"error": "操作过于频繁,请稍后再试"})
|
||||
return
|
||||
}
|
||||
c.Next()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,10 +39,23 @@ func InitDB(dsn string) error {
|
||||
&User{}, &Board{}, &Post{}, &Comment{}, &RefreshToken{}, &Like{}, &Notification{}, &Checkin{},
|
||||
&Announcement{}, &SiteSetting{}, &Attachment{}, &UserBoard{}, &LoginLog{},
|
||||
&ChatRoom{}, &ChatRoomMember{}, &ChatMessage{},
|
||||
&PointLedger{}, &PostContentUnlock{}, &PostAttachment{}, &PostAttachmentUnlock{},
|
||||
&PostPollVote{}, &PostLotteryEntry{},
|
||||
); err != nil {
|
||||
return fmt.Errorf("自动迁移失败: %w", err)
|
||||
}
|
||||
|
||||
// 旧 post_type=normal → discussion
|
||||
if err := db.Exec(`UPDATE posts SET post_type = ? WHERE post_type = ? OR post_type = '' OR post_type IS NULL`,
|
||||
PostTypeDiscussion, PostTypeNormal).Error; err != nil {
|
||||
return fmt.Errorf("帖子类型归一失败: %w", err)
|
||||
}
|
||||
|
||||
// 一次性:用签到累计回填 User.Points(仅余额仍为 0 且有签到积分的用户)
|
||||
if err := backfillPointsFromCheckin(db); err != nil {
|
||||
return fmt.Errorf("积分余额回填失败: %w", err)
|
||||
}
|
||||
|
||||
// RBAC:把初始管理员(id 最小的 admin,通常即首个注册账号)升级为站长;
|
||||
// 已存在 owner 时不动数据,保证幂等
|
||||
if err := ensureOwnerRole(db); err != nil {
|
||||
@@ -213,6 +226,46 @@ func ensureOwnerRole(db *gorm.DB) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// backfillPointsFromCheckin 把历史签到积分写入 User.Points(幂等:仅 points=0 且有签到)
|
||||
func backfillPointsFromCheckin(db *gorm.DB) error {
|
||||
type row struct {
|
||||
UserID uint
|
||||
Total int
|
||||
}
|
||||
var rows []row
|
||||
if err := db.Raw(`
|
||||
SELECT c.user_id, COALESCE(SUM(c.points), 0)::int AS total
|
||||
FROM checkins c
|
||||
INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL AND u.points = 0
|
||||
GROUP BY c.user_id
|
||||
HAVING COALESCE(SUM(c.points), 0) > 0
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
for _, r := range rows {
|
||||
err := db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&User{}).Where("id = ? AND points = 0", r.UserID).
|
||||
Update("points", r.Total).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Create(&PointLedger{
|
||||
UserID: r.UserID,
|
||||
Delta: r.Total,
|
||||
Balance: r.Total,
|
||||
Reason: PointReasonMigrateCheckin,
|
||||
Note: "历史签到积分回填",
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
log.Printf("[model] 已回填 %d 名用户的签到积分余额", len(rows))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// seedDefaultBoards 写入默认板块
|
||||
func seedDefaultBoards(db *gorm.DB) {
|
||||
defaults := []Board{
|
||||
|
||||
@@ -54,12 +54,64 @@ const (
|
||||
ContentStatusRejected = "rejected"
|
||||
)
|
||||
|
||||
// 帖子类型
|
||||
// 帖子类型(discussion 为默认;兼容旧值 normal → discussion)
|
||||
const (
|
||||
PostTypeNormal = "normal"
|
||||
PostTypeQuestion = "question"
|
||||
PostTypeDiscussion = "discussion" // 讨论(默认)
|
||||
PostTypeQuestion = "question" // 问答
|
||||
PostTypePoll = "poll" // 投票
|
||||
PostTypeBounty = "bounty" // 悬赏
|
||||
PostTypeLottery = "lottery" // 抽奖
|
||||
PostTypeNormal = "normal" // 旧值,读取时归一为 discussion
|
||||
)
|
||||
|
||||
// ValidPostType 发帖类型白名单
|
||||
func ValidPostType(t string) bool {
|
||||
switch t {
|
||||
case PostTypeDiscussion, PostTypeQuestion, PostTypePoll, PostTypeBounty, PostTypeLottery:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizePostType 归一化类型(空/旧 normal → discussion)
|
||||
func NormalizePostType(t string) string {
|
||||
if t == "" || t == PostTypeNormal {
|
||||
return PostTypeDiscussion
|
||||
}
|
||||
if ValidPostType(t) {
|
||||
return t
|
||||
}
|
||||
return PostTypeDiscussion
|
||||
}
|
||||
|
||||
// 正文可见性
|
||||
const (
|
||||
ContentAccessPublic = "public" // 公开
|
||||
ContentAccessLogin = "login" // 登录可见
|
||||
ContentAccessReply = "reply" // 回复可见
|
||||
ContentAccessPoints = "points" // 积分购买可见
|
||||
)
|
||||
|
||||
// ValidContentAccess 可见性白名单
|
||||
func ValidContentAccess(a string) bool {
|
||||
switch a {
|
||||
case ContentAccessPublic, ContentAccessLogin, ContentAccessReply, ContentAccessPoints:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// NormalizeContentAccess 归一化可见性
|
||||
func NormalizeContentAccess(a string) string {
|
||||
if a == "" {
|
||||
return ContentAccessPublic
|
||||
}
|
||||
if ValidContentAccess(a) {
|
||||
return a
|
||||
}
|
||||
return ContentAccessPublic
|
||||
}
|
||||
|
||||
// User 用户表
|
||||
type User struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
@@ -71,6 +123,7 @@ type User struct {
|
||||
Signature string `gorm:"size:255;default:''" json:"signature"` // 个性签名
|
||||
Role Role `gorm:"size:16;default:user" json:"role"`
|
||||
Banned bool `gorm:"default:false" json:"banned"`
|
||||
Points int `gorm:"not null;default:0" json:"points"` // 可用积分余额
|
||||
TokenVersion int `gorm:"default:0" json:"-"` // token 版本号,改密码/封禁时递增使旧 JWT 失效
|
||||
LastSeenAt *time.Time `gorm:"index" json:"-"` // 最近活跃时间(在线统计,限频更新)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -111,28 +164,105 @@ type Board struct {
|
||||
|
||||
// Post 帖子
|
||||
type Post struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
BoardID uint `gorm:"index;not null" json:"board_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Title string `gorm:"size:256;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Tags string `gorm:"size:256" json:"tags"`
|
||||
PostType string `gorm:"size:16;default:normal;index" json:"post_type"`
|
||||
Pinned int `gorm:"default:0" json:"pinned"`
|
||||
Recommended bool `gorm:"default:false;index" json:"recommended"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
CommentCount int `gorm:"default:0" json:"comment_count"`
|
||||
Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
BoardID uint `gorm:"index;not null" json:"board_id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Title string `gorm:"size:256;not null" json:"title"`
|
||||
Content string `gorm:"type:text;not null" json:"content"`
|
||||
Tags string `gorm:"size:256" json:"tags"`
|
||||
PostType string `gorm:"size:16;default:discussion;index" json:"post_type"`
|
||||
ContentAccess string `gorm:"size:16;default:public;index" json:"content_access"` // public|login|reply|points
|
||||
AccessPoints int `gorm:"not null;default:0" json:"access_points"` // points 可见时所需积分
|
||||
TypeMeta string `gorm:"type:text;default:''" json:"type_meta"` // 类型扩展 JSON(投票/悬赏/抽奖壳)
|
||||
Pinned int `gorm:"default:0" json:"pinned"`
|
||||
Recommended bool `gorm:"default:false;index" json:"recommended"`
|
||||
Status string `gorm:"size:16;default:published;index" json:"status"`
|
||||
LikeCount int `gorm:"default:0" json:"like_count"`
|
||||
ViewCount int `gorm:"default:0" json:"view_count"`
|
||||
CommentCount int `gorm:"default:0" json:"comment_count"`
|
||||
Liked bool `gorm:"-" json:"liked"` // 当前用户是否已点赞(展示字段,不入库)
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
|
||||
Board Board `gorm:"foreignKey:BoardID" json:"board,omitempty"`
|
||||
User User `gorm:"foreignKey:UserID" json:"user,omitempty"`
|
||||
}
|
||||
|
||||
// PointLedger 积分流水(余额以 User.Points 为准,本表可审计)
|
||||
type PointLedger struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Delta int `gorm:"not null" json:"delta"` // 正入负出
|
||||
Balance int `gorm:"not null" json:"balance"` // 变动后余额
|
||||
Reason string `gorm:"size:32;not null;index" json:"reason"`
|
||||
RefType string `gorm:"size:32;not null;default:''" json:"ref_type"`
|
||||
RefID uint `gorm:"not null;default:0" json:"ref_id"`
|
||||
Note string `gorm:"size:256;not null;default:''" json:"note"`
|
||||
CreatedAt time.Time `gorm:"index" json:"created_at"`
|
||||
}
|
||||
|
||||
// 积分流水原因
|
||||
const (
|
||||
PointReasonCheckin = "checkin"
|
||||
PointReasonUnlockPost = "unlock_post"
|
||||
PointReasonDownloadFile = "download_file"
|
||||
PointReasonMigrateCheckin = "migrate_checkin"
|
||||
PointReasonBountyEscrow = "bounty_escrow"
|
||||
PointReasonBountyRefund = "bounty_refund"
|
||||
PointReasonBountyAward = "bounty_award"
|
||||
)
|
||||
|
||||
// PostPollVote 投票记录(多选时同一用户多行)
|
||||
type PostPollVote struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_poll_vote;not null" json:"user_id"`
|
||||
OptionIndex int `gorm:"uniqueIndex:idx_poll_vote;not null" json:"option_index"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PostLotteryEntry 抽奖报名
|
||||
type PostLotteryEntry struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PostContentUnlock 积分购买正文解锁记录
|
||||
type PostContentUnlock struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"uniqueIndex:idx_post_unlock_user;not null" json:"post_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_post_unlock_user;not null" json:"user_id"`
|
||||
Points int `gorm:"not null;default:0" json:"points"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// PostAttachment 帖子文件附件(不走公开静态目录,经 API 鉴权下载)
|
||||
type PostAttachment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
PostID uint `gorm:"index;not null;default:0" json:"post_id"` // 0=草稿未绑定
|
||||
UserID uint `gorm:"index;not null" json:"user_id"`
|
||||
Name string `gorm:"size:256;not null" json:"name"` // 原始文件名
|
||||
StoredName string `gorm:"size:64;not null" json:"-"` // 磁盘文件名
|
||||
MIME string `gorm:"size:128;not null;default:application/octet-stream" json:"mime"`
|
||||
Size int `gorm:"not null;default:0" json:"size"`
|
||||
PricePoints int `gorm:"not null;default:0" json:"price_points"` // 0=免费
|
||||
DownloadCount int `gorm:"not null;default:0" json:"download_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
|
||||
}
|
||||
|
||||
// PostAttachmentUnlock 积分附件下载解锁(按附件计费一次)
|
||||
type PostAttachmentUnlock struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
AttachmentID uint `gorm:"uniqueIndex:idx_att_unlock_user;not null" json:"attachment_id"`
|
||||
UserID uint `gorm:"uniqueIndex:idx_att_unlock_user;not null" json:"user_id"`
|
||||
Points int `gorm:"not null;default:0" json:"points"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Comment 评论(主评论 = 楼层,ParentID 为空;子评论挂 root_id 对应楼层下)
|
||||
type Comment struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
|
||||
@@ -49,6 +49,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
checkinSvc := service.NewCheckinService(model.DB)
|
||||
announcementSvc := service.NewAnnouncementService(model.DB)
|
||||
uploadSvc := service.NewUploadService(model.DB, filepath.Join(cfg.DataDir, "uploads"))
|
||||
postFileSvc := service.NewPostFileService(model.DB, filepath.Join(cfg.DataDir, "private"))
|
||||
pointsSvc := service.NewPointsService(model.DB)
|
||||
settingSvc := service.NewSettingService(model.DB)
|
||||
adminUserSvc := service.NewAdminUserService(model.DB)
|
||||
moderationSvc := service.NewModerationService(model.DB, notifSvc)
|
||||
@@ -56,6 +58,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
if err := uploadSvc.EnsureDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := postFileSvc.EnsureDir(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
limiter := service.DefaultRateLimiter()
|
||||
|
||||
h := &handler.Handlers{
|
||||
@@ -71,6 +76,8 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
Checkin: checkinSvc,
|
||||
Announcement: announcementSvc,
|
||||
Upload: uploadSvc,
|
||||
PostFile: postFileSvc,
|
||||
Points: pointsSvc,
|
||||
Setting: settingSvc,
|
||||
AdminUser: adminUserSvc,
|
||||
Moderation: moderationSvc,
|
||||
@@ -107,6 +114,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
pubAPI.GET("/overview", h.Overview)
|
||||
pubAPI.GET("/posts", h.Posts)
|
||||
pubAPI.GET("/posts/:id", h.PostDetail)
|
||||
pubAPI.GET("/posts/:id/attachments/:aid/download", h.DownloadPostAttachment)
|
||||
pubAPI.GET("/posts/:id/comments", h.PostComments)
|
||||
pubAPI.GET("/users/:id", h.UserProfile)
|
||||
pubAPI.GET("/users/:id/comments", h.UserComments)
|
||||
@@ -128,6 +136,16 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.PUT("/profile", h.UpdateProfile)
|
||||
api.POST("/posts", middleware.RateLimitMiddleware(limiter, service.RatePost), h.CreatePost)
|
||||
api.PUT("/posts/:id", h.UpdatePost)
|
||||
api.POST("/posts/:id/unlock", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.UnlockPostContent)
|
||||
api.POST("/posts/:id/poll/vote", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.VotePoll)
|
||||
api.POST("/posts/:id/poll/close", h.ClosePoll)
|
||||
api.POST("/posts/:id/question/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptQuestion)
|
||||
api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion)
|
||||
api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion)
|
||||
api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty)
|
||||
api.POST("/posts/:id/bounty/refund", h.RefundBounty)
|
||||
api.POST("/posts/:id/lottery/close", h.CloseLottery)
|
||||
api.POST("/posts/:id/lottery/draw", h.DrawLottery)
|
||||
api.DELETE("/posts/:id", h.DeletePost)
|
||||
api.PUT("/posts/:id/pin", h.TogglePin)
|
||||
api.PUT("/posts/:id/recommend", h.ToggleRecommend)
|
||||
@@ -139,11 +157,17 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
api.GET("/notifications/unread-count", h.UnreadCount)
|
||||
api.PUT("/notifications/:id/read", h.MarkRead)
|
||||
api.PUT("/notifications/read-all", h.MarkAllRead)
|
||||
// 每日签到
|
||||
// 每日签到 / 积分
|
||||
api.GET("/checkin", h.GetCheckin)
|
||||
api.POST("/checkin", middleware.RateLimitMiddleware(limiter, service.RateComment), h.DoCheckin)
|
||||
// 头像上传(裁剪后的 WebP)/ 媒体库 / 附件删除
|
||||
api.GET("/points", h.GetPointsBalance)
|
||||
api.GET("/points/ledger", h.GetPointsLedger)
|
||||
// 头像上传(裁剪后的 WebP)/ 帖子插图 / 媒体库 / 附件删除
|
||||
api.POST("/upload/avatar", h.UploadAvatar)
|
||||
api.POST("/upload/image", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadImage)
|
||||
api.POST("/upload/file", middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadPostFile)
|
||||
api.PUT("/upload/file/:id/price", h.UpdatePostFilePrice)
|
||||
api.DELETE("/upload/file/:id", h.DeletePostFile)
|
||||
api.PUT("/avatar/use", h.UseAvatar)
|
||||
api.GET("/my/media", h.MyMedia)
|
||||
api.DELETE("/my/attachments/:id", h.DeleteAttachment)
|
||||
@@ -186,6 +210,9 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
staffAPI.PUT("/moderation/comments/:id/approve", h.AdminApproveComment)
|
||||
staffAPI.PUT("/moderation/comments/:id/reject", h.AdminRejectComment)
|
||||
|
||||
// 积分与类型帖概览(管理员及以上)
|
||||
staffAPI.GET("/economy", authMW.RequirePerm(service.PermAnnouncements), h.AdminPointsStats)
|
||||
|
||||
// 站点公告文章管理(管理员及以上)
|
||||
announceAPI := staffAPI.Group("", authMW.RequirePerm(service.PermAnnouncements))
|
||||
announceAPI.GET("/announcements", h.AdminListAnnouncements)
|
||||
|
||||
@@ -21,7 +21,7 @@ var ErrAlreadyCheckedIn = errors.New("今日已签到")
|
||||
type CheckinStatus struct {
|
||||
CheckedToday bool `json:"checked_today"`
|
||||
Streak int `json:"streak"` // 连续签到天数
|
||||
TotalPoints int `json:"total_points"` // 累计积分(签到所得)
|
||||
TotalPoints int `json:"total_points"` // 累计积分(可用余额)
|
||||
TodayPoints int `json:"today_points"` // 今日签到可得积分
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.db.Model(&model.Checkin{}).
|
||||
Where("user_id = ?", userID).
|
||||
Select("COALESCE(SUM(points), 0)").Scan(&total).Error; err != nil {
|
||||
if err := s.db.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Select("points").Scan(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 同步入账到用户积分余额
|
||||
if _, err := CreditTx(tx, userID, DailyCheckinPoints, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -171,7 +171,14 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, er
|
||||
if post.Status != model.ContentStatusPending {
|
||||
return ErrNotPending
|
||||
}
|
||||
if err := tx.Model(&post).Update("status", model.ContentStatusPublished).Error; err != nil {
|
||||
updates := map[string]interface{}{
|
||||
"status": model.ContentStatusPublished,
|
||||
}
|
||||
// 悬赏/抽奖:首次公开发布时补写 ends_at
|
||||
if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok {
|
||||
updates["type_meta"] = raw
|
||||
}
|
||||
if err := tx.Model(&post).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
boardID = post.BoardID
|
||||
|
||||
231
backend/service/points.go
Normal file
231
backend/service/points.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientPoints = errors.New("积分不足")
|
||||
ErrInvalidPoints = errors.New("积分数量无效")
|
||||
)
|
||||
|
||||
// PointsService 用户积分账户
|
||||
type PointsService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPointsService(db *gorm.DB) *PointsService {
|
||||
return &PointsService{db: db}
|
||||
}
|
||||
|
||||
// Balance 查询可用积分
|
||||
func (s *PointsService) Balance(userID uint) (int, error) {
|
||||
var u model.User
|
||||
if err := s.db.Select("id", "points").First(&u, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.Points, nil
|
||||
}
|
||||
|
||||
// CreditTx 在事务内入账
|
||||
func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, ErrInvalidPoints
|
||||
}
|
||||
var u model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("id", "points").First(&u, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bal := u.Points + delta
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Create(&model.PointLedger{
|
||||
UserID: userID,
|
||||
Delta: delta,
|
||||
Balance: bal,
|
||||
Reason: reason,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
Note: note,
|
||||
}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal, nil
|
||||
}
|
||||
|
||||
// DebitTx 在事务内扣款
|
||||
func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, ErrInvalidPoints
|
||||
}
|
||||
var u model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("id", "points").First(&u, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if u.Points < delta {
|
||||
return 0, ErrInsufficientPoints
|
||||
}
|
||||
bal := u.Points - delta
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Create(&model.PointLedger{
|
||||
UserID: userID,
|
||||
Delta: -delta,
|
||||
Balance: bal,
|
||||
Reason: reason,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
Note: note,
|
||||
}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal, nil
|
||||
}
|
||||
|
||||
// Credit 入账
|
||||
func (s *PointsService) Credit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
var bal int
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var e error
|
||||
bal, e = CreditTx(tx, userID, delta, reason, refType, refID, note)
|
||||
return e
|
||||
})
|
||||
return bal, err
|
||||
}
|
||||
|
||||
// Debit 扣款
|
||||
func (s *PointsService) Debit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
var bal int
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var e error
|
||||
bal, e = DebitTx(tx, userID, delta, reason, refType, refID, note)
|
||||
return e
|
||||
})
|
||||
return bal, err
|
||||
}
|
||||
|
||||
// LedgerItem 流水展示项
|
||||
type LedgerItem struct {
|
||||
ID uint `json:"id"`
|
||||
Delta int `json:"delta"`
|
||||
Balance int `json:"balance"`
|
||||
Reason string `json:"reason"`
|
||||
RefType string `json:"ref_type"`
|
||||
RefID uint `json:"ref_id"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Ledger 分页查询本人积分流水
|
||||
func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
var total int64
|
||||
q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.PointLedger
|
||||
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]LedgerItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, LedgerItem{
|
||||
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
|
||||
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// PointsEconomyStats 全站积分与类型帖概览(管理端)
|
||||
type PointsEconomyStats struct {
|
||||
TotalBalance int64 `json:"total_balance"` // 用户余额合计
|
||||
LedgerCount int64 `json:"ledger_count"` // 流水条数
|
||||
CheckinToday int64 `json:"checkin_today"` // 今日签到人数
|
||||
OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖
|
||||
EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算)
|
||||
PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量
|
||||
RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note)
|
||||
}
|
||||
|
||||
// AdminEconomyStats 管理端经济看板
|
||||
func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
||||
st := &PointsEconomyStats{PostsByType: map[string]int64{}}
|
||||
if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
|
||||
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
// 用日期字符串更稳妥(与 checkin 一致用 date)
|
||||
if err := s.db.Model(&model.Checkin{}).
|
||||
Where("checkin_date::date = CURRENT_DATE").
|
||||
Count(&st.CheckinToday).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = today
|
||||
|
||||
type row struct {
|
||||
PostType string
|
||||
Cnt int64
|
||||
}
|
||||
var rows []row
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Select("post_type, count(*) as cnt").
|
||||
Where("deleted_at IS NULL").
|
||||
Group("post_type").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
pt := model.NormalizePostType(r.PostType)
|
||||
st.PostsByType[pt] += r.Cnt
|
||||
}
|
||||
|
||||
// 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算
|
||||
var bountyPosts []model.Post
|
||||
if err := s.db.Select("id, type_meta").
|
||||
Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
||||
Find(&bountyPosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range bountyPosts {
|
||||
m, err := parseBountyMeta(p.TypeMeta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
|
||||
st.OpenBounties++
|
||||
st.EscrowedPoints += int64(m.Points)
|
||||
}
|
||||
}
|
||||
|
||||
var recent []model.PointLedger
|
||||
if err := s.db.Order("id DESC").Limit(15).Find(&recent).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.RecentLedger = make([]LedgerItem, 0, len(recent))
|
||||
for _, r := range recent {
|
||||
st.RecentLedger = append(st.RecentLedger, LedgerItem{
|
||||
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
|
||||
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
@@ -40,9 +40,13 @@ type PostListQuery struct {
|
||||
func toPostListItems(posts []model.Post) []PostListItem {
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
pt := model.NormalizePostType(p.PostType)
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Title: p.Title, Tags: p.Tags, PostType: pt,
|
||||
TypeStatus: ComputeTypeStatus(pt, p.TypeMeta),
|
||||
ContentAccess: model.NormalizeContentAccess(p.ContentAccess),
|
||||
AccessPoints: p.AccessPoints,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
@@ -67,23 +71,26 @@ type LastReplyInfo struct {
|
||||
|
||||
// PostListItem 帖子列表项(不含正文)
|
||||
type PostListItem struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Status string `json:"status"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastReply *LastReplyInfo `json:"last_reply,omitempty"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
TypeStatus string `json:"type_status,omitempty"` // unsolved|solved|open|closed|expired|drawn
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Status string `json:"status"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastReply *LastReplyInfo `json:"last_reply,omitempty"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
// fillLastReply 批量填充每帖最后一条已发布评论(发帖人+时间),
|
||||
@@ -327,9 +334,78 @@ func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) boo
|
||||
return loadActor().CanModerateBoard(post.BoardID)
|
||||
}
|
||||
|
||||
// GetByIDForViewer 获取帖子详情(带可见性校验);通过校验才计入浏览量。
|
||||
// viewerID 为当前登录用户(未登录传 0),loadActor 可传 nil
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*model.Post, error) {
|
||||
// PostAttachmentDTO 附件对外字段
|
||||
type PostAttachmentDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
MIME string `json:"mime"`
|
||||
PricePoints int `json:"price_points"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
Unlocked bool `json:"unlocked"` // 当前用户是否可直接下载(免费/已购/作者)
|
||||
}
|
||||
|
||||
// PostDetail 帖子详情(含可见性裁剪与附件)
|
||||
type PostDetail struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
TypeMeta string `json:"type_meta"`
|
||||
TypeStatus string `json:"type_status,omitempty"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Status string `json:"status"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ContentLocked bool `json:"content_locked"`
|
||||
AccessHint string `json:"access_hint,omitempty"`
|
||||
Attachments []PostAttachmentDTO `json:"attachments"`
|
||||
Question *QuestionState `json:"question,omitempty"`
|
||||
Poll *PollState `json:"poll,omitempty"`
|
||||
Bounty *BountyState `json:"bounty,omitempty"`
|
||||
Lottery *LotteryState `json:"lottery,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePostInput 发帖入参
|
||||
type CreatePostInput struct {
|
||||
UserID uint
|
||||
BoardID uint
|
||||
Title string
|
||||
Content string
|
||||
Tags string
|
||||
PostType string
|
||||
ContentAccess string
|
||||
AccessPoints int
|
||||
TypeMeta string
|
||||
Status string
|
||||
AttachmentIDs []uint
|
||||
}
|
||||
|
||||
// UpdatePostInput 编辑入参
|
||||
type UpdatePostInput struct {
|
||||
Title string
|
||||
Content string
|
||||
Tags string
|
||||
ContentAccess *string
|
||||
AccessPoints *int
|
||||
TypeMeta *string
|
||||
AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
|
||||
}
|
||||
|
||||
// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
@@ -337,9 +413,504 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
|
||||
if !visibleToPost(&post, viewerID, loadActor) {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
// 增加浏览量(待审/被拒内容不计)
|
||||
// 惰性结算:悬赏过期退回 / 抽奖到期开奖
|
||||
_ = s.settleExpiredBountyIfNeeded(&post)
|
||||
_ = s.settleDueLotteryIfNeeded(&post)
|
||||
if post.TypeMeta != "" {
|
||||
var fresh model.Post
|
||||
if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil {
|
||||
post.TypeMeta = fresh.TypeMeta
|
||||
}
|
||||
}
|
||||
|
||||
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
return &post, nil
|
||||
post.ViewCount++
|
||||
|
||||
detail := buildPostDetail(&post)
|
||||
locked, hint := s.evalContentAccess(&post, viewerID, loadActor)
|
||||
detail.ContentLocked = locked
|
||||
detail.AccessHint = hint
|
||||
if locked {
|
||||
detail.Content = ""
|
||||
}
|
||||
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, &post, viewerID, loadActor)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func buildPostDetail(post *model.Post) *PostDetail {
|
||||
return &PostDetail{
|
||||
ID: post.ID, BoardID: post.BoardID, UserID: post.UserID,
|
||||
Title: post.Title, Content: post.Content, Tags: post.Tags,
|
||||
PostType: model.NormalizePostType(post.PostType),
|
||||
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
|
||||
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
|
||||
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
|
||||
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
|
||||
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
|
||||
Board: post.Board, User: post.User,
|
||||
Attachments: []PostAttachmentDTO{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) evalContentAccess(post *model.Post, viewerID uint, loadActor func() *Actor) (locked bool, hint string) {
|
||||
access := model.NormalizeContentAccess(post.ContentAccess)
|
||||
if access == model.ContentAccessPublic {
|
||||
return false, ""
|
||||
}
|
||||
// 作者与版主始终可见
|
||||
if viewerID > 0 && post.UserID == viewerID {
|
||||
return false, ""
|
||||
}
|
||||
if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
switch access {
|
||||
case model.ContentAccessLogin:
|
||||
if viewerID == 0 {
|
||||
return true, "登录后可见全文"
|
||||
}
|
||||
return false, ""
|
||||
case model.ContentAccessReply:
|
||||
if viewerID == 0 {
|
||||
return true, "回复本帖后可见全文"
|
||||
}
|
||||
var n int64
|
||||
s.db.Model(&model.Comment{}).
|
||||
Where("post_id = ? AND user_id = ? AND status = ? AND deleted_at IS NULL",
|
||||
post.ID, viewerID, model.ContentStatusPublished).
|
||||
Count(&n)
|
||||
if n == 0 {
|
||||
return true, "回复本帖后可见全文"
|
||||
}
|
||||
return false, ""
|
||||
case model.ContentAccessPoints:
|
||||
need := post.AccessPoints
|
||||
if need <= 0 {
|
||||
need = 1
|
||||
}
|
||||
if viewerID == 0 {
|
||||
return true, "支付积分后可见全文"
|
||||
}
|
||||
var n int64
|
||||
s.db.Model(&model.PostContentUnlock{}).
|
||||
Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n)
|
||||
if n > 0 {
|
||||
return false, ""
|
||||
}
|
||||
return true, "支付积分后可见全文"
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]PostAttachmentDTO, error) {
|
||||
var rows []model.PostAttachment
|
||||
if err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PostAttachmentDTO, 0, len(rows))
|
||||
unlockedIDs := map[uint]bool{}
|
||||
if viewerID > 0 {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.PricePoints > 0 {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
var unlocks []model.PostAttachmentUnlock
|
||||
s.db.Where("attachment_id IN ? AND user_id = ?", ids, viewerID).Find(&unlocks)
|
||||
for _, u := range unlocks {
|
||||
unlockedIDs[u.AttachmentID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
ok := r.PricePoints <= 0 || viewerID == authorID || unlockedIDs[r.ID]
|
||||
out = append(out, PostAttachmentDTO{
|
||||
ID: r.ID, Name: r.Name, Size: r.Size, MIME: r.MIME,
|
||||
PricePoints: r.PricePoints, DownloadCount: r.DownloadCount, Unlocked: ok,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnlockContent 积分解锁正文
|
||||
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
if model.NormalizeContentAccess(post.ContentAccess) != model.ContentAccessPoints {
|
||||
return nil, errors.New("本文无需积分解锁")
|
||||
}
|
||||
if post.UserID == userID {
|
||||
return buildPostDetail(&post), nil
|
||||
}
|
||||
need := post.AccessPoints
|
||||
if need <= 0 {
|
||||
need = 1
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var n int64
|
||||
if err := tx.Model(&model.PostContentUnlock{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, userID).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := DebitTx(tx, userID, need, model.PointReasonUnlockPost, "post", postID, "解锁帖子:"+post.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if post.UserID > 0 {
|
||||
if _, err := CreditTx(tx, post.UserID, need, model.PointReasonUnlockPost, "post_earn", postID, "正文解锁收益"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.PostContentUnlock{
|
||||
PostID: postID, UserID: userID, Points: need,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail := buildPostDetail(&post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, &post, userID, nil)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// Create 创建帖子。status 由 handler 按角色计算
|
||||
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
title := strings.TrimSpace(in.Title)
|
||||
content := strings.TrimSpace(in.Content)
|
||||
if title == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
if content == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
if in.BoardID == 0 {
|
||||
return nil, errors.New("请选择板块")
|
||||
}
|
||||
status := in.Status
|
||||
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
|
||||
status = model.ContentStatusPending
|
||||
}
|
||||
postType := model.NormalizePostType(in.PostType)
|
||||
if !model.ValidPostType(postType) {
|
||||
return nil, errors.New("无效的帖子类型")
|
||||
}
|
||||
access := model.NormalizeContentAccess(in.ContentAccess)
|
||||
accessPts := in.AccessPoints
|
||||
if access == model.ContentAccessPoints {
|
||||
if accessPts <= 0 {
|
||||
return nil, errors.New("请设置解锁所需积分")
|
||||
}
|
||||
if accessPts > 100000 {
|
||||
return nil, errors.New("解锁积分过高")
|
||||
}
|
||||
} else {
|
||||
accessPts = 0
|
||||
}
|
||||
typeMeta, err := NormalizeAndValidateTypeMeta(postType, in.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.checkNewUserCooldown(in.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if postType == model.PostTypeQuestion {
|
||||
qm, _ := parseQuestionMeta(typeMeta)
|
||||
if qm == nil {
|
||||
qm = &QuestionMeta{}
|
||||
}
|
||||
typeMeta, _ = encodeMeta(qm)
|
||||
}
|
||||
|
||||
// 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限)
|
||||
var bountyPts int
|
||||
if postType == model.PostTypeBounty {
|
||||
bm, _ := parseBountyMeta(typeMeta)
|
||||
bountyPts = bm.Points
|
||||
var bountyPosts []model.Post
|
||||
s.db.Select("type_meta").
|
||||
Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty).
|
||||
Find(&bountyPosts)
|
||||
open := 0
|
||||
for _, bp := range bountyPosts {
|
||||
m, err := parseBountyMeta(bp.TypeMeta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
|
||||
open++
|
||||
}
|
||||
}
|
||||
if open >= 3 {
|
||||
return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回")
|
||||
}
|
||||
var todayN int64
|
||||
s.db.Model(&model.Post{}).
|
||||
Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL",
|
||||
in.UserID, model.PostTypeBounty).
|
||||
Count(&todayN)
|
||||
if todayN >= 5 {
|
||||
return nil, errors.New("今日悬赏发帖已达上限(5)")
|
||||
}
|
||||
bm.Escrowed = true
|
||||
bm.Refunded = false
|
||||
bm.Expired = false
|
||||
bm.AcceptedCommentID = 0
|
||||
if status == model.ContentStatusPublished {
|
||||
days := normalizeExpireDays(bm.ExpireDays, 7, []int{3, 7, 14, 30})
|
||||
bm.ExpireDays = 0
|
||||
bm.EndsAt = endsAtFromDays(days, now)
|
||||
}
|
||||
typeMeta, _ = encodeMeta(bm)
|
||||
}
|
||||
|
||||
if postType == model.PostTypeLottery {
|
||||
lm, _ := parseLotteryMeta(typeMeta)
|
||||
if lm.WinnerIDs == nil {
|
||||
lm.WinnerIDs = []uint{}
|
||||
}
|
||||
if status == model.ContentStatusPublished {
|
||||
days := normalizeExpireDays(lm.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||
lm.ExpireDays = 0
|
||||
lm.EndsAt = endsAtFromDays(days, now)
|
||||
}
|
||||
typeMeta, _ = encodeMeta(lm)
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
BoardID: in.BoardID, UserID: in.UserID,
|
||||
Title: title, Content: content, Tags: in.Tags,
|
||||
PostType: postType, ContentAccess: access, AccessPoints: accessPts,
|
||||
TypeMeta: typeMeta, Status: status,
|
||||
}
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(post).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if bountyPts > 0 {
|
||||
if _, err := DebitTx(tx, in.UserID, bountyPts, model.PointReasonBountyEscrow, "post", post.ID, "悬赏托管:"+title); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(in.AttachmentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(in.AttachmentIDs) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
var atts []model.PostAttachment
|
||||
if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID).
|
||||
Find(&atts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(atts) != len(in.AttachmentIDs) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID).
|
||||
Update("post_id", post.ID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
detail := buildPostDetail(post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, in.UserID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, post, in.UserID, nil)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func validateTypeMeta(postType, meta string) error {
|
||||
_, err := NormalizeAndValidateTypeMeta(postType, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||||
return nil, ErrPostForbidden
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if in.Title != "" {
|
||||
t := strings.TrimSpace(in.Title)
|
||||
if t == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
updates["title"] = t
|
||||
}
|
||||
if in.Content != "" {
|
||||
c := strings.TrimSpace(in.Content)
|
||||
if c == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
updates["content"] = c
|
||||
}
|
||||
updates["tags"] = in.Tags
|
||||
|
||||
if in.ContentAccess != nil {
|
||||
access := model.NormalizeContentAccess(*in.ContentAccess)
|
||||
updates["content_access"] = access
|
||||
if access == model.ContentAccessPoints {
|
||||
pts := post.AccessPoints
|
||||
if in.AccessPoints != nil {
|
||||
pts = *in.AccessPoints
|
||||
}
|
||||
if pts <= 0 {
|
||||
return nil, errors.New("请设置解锁所需积分")
|
||||
}
|
||||
updates["access_points"] = pts
|
||||
} else {
|
||||
updates["access_points"] = 0
|
||||
}
|
||||
} else if in.AccessPoints != nil && model.NormalizeContentAccess(post.ContentAccess) == model.ContentAccessPoints {
|
||||
if *in.AccessPoints <= 0 {
|
||||
return nil, errors.New("请设置解锁所需积分")
|
||||
}
|
||||
updates["access_points"] = *in.AccessPoints
|
||||
}
|
||||
if in.TypeMeta != nil {
|
||||
pt := model.NormalizePostType(post.PostType)
|
||||
meta, err := NormalizeAndValidateTypeMeta(pt, *in.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 悬赏已托管:禁止改积分,保留结算/截止字段
|
||||
if pt == model.PostTypeBounty {
|
||||
old, _ := parseBountyMeta(post.TypeMeta)
|
||||
neu, _ := parseBountyMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
neu.Escrowed = old.Escrowed
|
||||
neu.Refunded = old.Refunded
|
||||
neu.Expired = old.Expired
|
||||
neu.AcceptedCommentID = old.AcceptedCommentID
|
||||
neu.EndsAt = old.EndsAt
|
||||
if old.Escrowed || old.AcceptedCommentID > 0 || old.Refunded || old.Expired {
|
||||
neu.Points = old.Points
|
||||
}
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
// 投票已有票:明确报错,禁止改选项/单多选/匿名
|
||||
if pt == model.PostTypePoll {
|
||||
old, _ := parsePollMeta(post.TypeMeta)
|
||||
neu, _ := parsePollMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
var n int64
|
||||
s.db.Model(&model.PostPollVote{}).Where("post_id = ?", postID).Count(&n)
|
||||
if n > 0 {
|
||||
optsChanged := len(neu.Options) != len(old.Options)
|
||||
if !optsChanged {
|
||||
for i := range old.Options {
|
||||
if neu.Options[i] != old.Options[i] {
|
||||
optsChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if optsChanged || neu.Multi != old.Multi || neu.Anonymous != old.Anonymous {
|
||||
return nil, ErrPollOptionsLocked
|
||||
}
|
||||
neu.Options = old.Options
|
||||
neu.Multi = old.Multi
|
||||
neu.Anonymous = old.Anonymous
|
||||
neu.Closed = old.Closed
|
||||
meta, _ = encodeMeta(neu)
|
||||
} else {
|
||||
neu.Closed = old.Closed
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
}
|
||||
if pt == model.PostTypeLottery {
|
||||
old, _ := parseLotteryMeta(post.TypeMeta)
|
||||
neu, _ := parseLotteryMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
neu.Drawn = old.Drawn
|
||||
neu.WinnerIDs = old.WinnerIDs
|
||||
neu.Closed = old.Closed
|
||||
neu.EndsAt = old.EndsAt
|
||||
if old.Drawn {
|
||||
neu.Slots = old.Slots
|
||||
}
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
if pt == model.PostTypeQuestion {
|
||||
old, _ := parseQuestionMeta(post.TypeMeta)
|
||||
neu, _ := parseQuestionMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
// 解题态走专用 API,编辑帖子不覆盖
|
||||
neu.Solved = old.Solved
|
||||
neu.AcceptedCommentID = old.AcceptedCommentID
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
updates["type_meta"] = meta
|
||||
}
|
||||
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&post).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if in.AttachmentIDs == nil {
|
||||
return nil
|
||||
}
|
||||
ids := *in.AttachmentIDs
|
||||
if len(ids) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
if err := tx.Model(&model.PostAttachment{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, post.UserID).
|
||||
Update("post_id", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var atts []model.PostAttachment
|
||||
if err := tx.Where(
|
||||
"id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
|
||||
ids, post.UserID, postID,
|
||||
).Find(&atts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(atts) != len(ids) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ?", ids, post.UserID).
|
||||
Update("post_id", postID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||||
detail := buildPostDetail(&post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, &post, userID, nil)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
|
||||
@@ -354,105 +925,27 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create 创建帖子。status 由 handler 按角色计算:
|
||||
// 管理团队成员直发 published,普通用户进入 pending 等待审核
|
||||
func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType, status string) (*model.Post, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
content = strings.TrimSpace(content)
|
||||
if title == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
if content == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
if boardID == 0 {
|
||||
return nil, errors.New("请选择板块")
|
||||
}
|
||||
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
|
||||
status = model.ContentStatusPending
|
||||
}
|
||||
|
||||
// 新用户 24h 冷静期校验
|
||||
if err := s.checkNewUserCooldown(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
PostType: postType,
|
||||
Status: status,
|
||||
}
|
||||
if err := s.db.Create(post).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 预加载关联
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// checkNewUserCooldown 新用户发帖 24h 冷静期
|
||||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注册不足 24 小时的新用户不能发帖
|
||||
if time.Since(user.CreatedAt) < 24*time.Hour {
|
||||
return errors.New("新用户注册 24 小时后才能发帖")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Update(actor *Actor, postID, userID uint, title, content, tags string) (*model.Post, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
// 权限校验:作者本人或板块审核权
|
||||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||||
return nil, ErrPostForbidden
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if title != "" {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
updates["title"] = t
|
||||
}
|
||||
if content != "" {
|
||||
c := strings.TrimSpace(content)
|
||||
if c == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
updates["content"] = c
|
||||
}
|
||||
updates["tags"] = tags
|
||||
|
||||
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Delete(actor *Actor, postID, userID uint) error {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
// 权限校验:作者本人或板块审核权
|
||||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||||
return ErrPostForbidden
|
||||
}
|
||||
// 软删除(gorm DeletedAt)
|
||||
if err := s.db.Delete(&post).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
283
backend/service/post_file.go
Normal file
283
backend/service/post_file.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
FileMaxBytes = 20 << 20 // 20 MiB
|
||||
MaxPostAttachments = 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||||
ErrAttachmentForbidden = errors.New("无权操作此附件")
|
||||
ErrTooManyAttachments = errors.New("附件数量超过上限")
|
||||
)
|
||||
|
||||
// PostFileService 帖子文件附件(私有目录 + API 下载)
|
||||
type PostFileService struct {
|
||||
db *gorm.DB
|
||||
dir string // data/private/files
|
||||
}
|
||||
|
||||
func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService {
|
||||
return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")}
|
||||
}
|
||||
|
||||
func (s *PostFileService) EnsureDir() error {
|
||||
return os.MkdirAll(s.dir, 0o755)
|
||||
}
|
||||
|
||||
func absPath(dir, stored string) string {
|
||||
return filepath.Join(dir, stored)
|
||||
}
|
||||
|
||||
// SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定)
|
||||
func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if len(data) > FileMaxBytes {
|
||||
return nil, errors.New("附件不能超过 20MB")
|
||||
}
|
||||
if pricePoints < 0 {
|
||||
pricePoints = 0
|
||||
}
|
||||
if pricePoints > 100000 {
|
||||
return nil, errors.New("积分定价过高")
|
||||
}
|
||||
name := sanitizeFilename(originalName)
|
||||
if name == "" {
|
||||
name = "file"
|
||||
}
|
||||
|
||||
var orphan int64
|
||||
if err := s.db.Model(&model.PostAttachment{}).
|
||||
Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orphan >= MaxPostAttachments {
|
||||
return nil, ErrTooManyAttachments
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
if utf8.RuneCountInString(ext) > 16 {
|
||||
ext = ""
|
||||
}
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := hex.EncodeToString(raw) + ext
|
||||
full := absPath(s.dir, stored)
|
||||
if err := os.WriteFile(full, data, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
PostID: 0,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
StoredName: stored,
|
||||
MIME: mimeType,
|
||||
Size: len(data),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, name)
|
||||
if utf8.RuneCountInString(name) > 200 {
|
||||
runes := []rune(name)
|
||||
name = string(runes[:200])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// BindToPost 将草稿附件绑定到帖子(仅本人、未绑定)
|
||||
func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(ids) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var atts []model.PostAttachment
|
||||
if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).Find(&atts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(atts) != len(ids) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).
|
||||
Update("post_id", postID).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的)
|
||||
func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error {
|
||||
if len(ids) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var keep []model.PostAttachment
|
||||
if len(ids) > 0 {
|
||||
if err := tx.Where(
|
||||
"id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
|
||||
ids, userID, postID,
|
||||
).Find(&keep).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keep) != len(ids) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
}
|
||||
// 解绑本帖旧附件(软删物理文件可选:P1 仅解绑)
|
||||
if err := tx.Model(&model.PostAttachment{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, userID).
|
||||
Update("post_id", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ?", ids, userID).
|
||||
Update("post_id", postID).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListByPost 帖子附件列表
|
||||
func (s *PostFileService) ListByPost(postID uint) ([]model.PostAttachment, error) {
|
||||
var list []model.PostAttachment
|
||||
err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// UpdatePrice 更新附件积分定价(作者)
|
||||
func (s *PostFileService) UpdatePrice(userID, attID uint, price int) error {
|
||||
if price < 0 {
|
||||
price = 0
|
||||
}
|
||||
res := s.db.Model(&model.PostAttachment{}).
|
||||
Where("id = ? AND user_id = ?", attID, userID).
|
||||
Update("price_points", price)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrAttachmentNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOwn 删除本人未绑定或本帖附件
|
||||
func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||||
var att model.PostAttachment
|
||||
if err := s.db.First(&att, attID).Error; err != nil {
|
||||
return ErrAttachmentNotFound
|
||||
}
|
||||
if att.UserID != userID {
|
||||
return ErrAttachmentForbidden
|
||||
}
|
||||
path := absPath(s.dir, att.StoredName)
|
||||
if err := s.db.Delete(&att).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分
|
||||
func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) {
|
||||
var att model.PostAttachment
|
||||
if err := s.db.First(&att, attID).Error; err != nil {
|
||||
return nil, ErrAttachmentNotFound
|
||||
}
|
||||
return &att, nil
|
||||
}
|
||||
|
||||
func (s *PostFileService) FilePath(att *model.PostAttachment) string {
|
||||
return absPath(s.dir, att.StoredName)
|
||||
}
|
||||
|
||||
func (s *PostFileService) IncDownload(attID uint) {
|
||||
s.db.Model(&model.PostAttachment{}).Where("id = ?", attID).
|
||||
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
|
||||
}
|
||||
|
||||
// EnsureAttachmentUnlocked 免费或已购/作者;积分附件扣费一次
|
||||
func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostAttachment) error {
|
||||
if att.PricePoints <= 0 {
|
||||
return nil
|
||||
}
|
||||
if att.UserID == userID {
|
||||
return nil
|
||||
}
|
||||
var n int64
|
||||
if err := s.db.Model(&model.PostAttachmentUnlock{}).
|
||||
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
|
||||
Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var again int64
|
||||
if err := tx.Model(&model.PostAttachmentUnlock{}).
|
||||
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
|
||||
Count(&again).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if again > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
// 积分转给作者
|
||||
if att.UserID > 0 && att.UserID != userID {
|
||||
if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.PostAttachmentUnlock{
|
||||
AttachmentID: att.ID,
|
||||
UserID: userID,
|
||||
Points: att.PricePoints,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
1082
backend/service/post_interact.go
Normal file
1082
backend/service/post_interact.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,8 @@ const (
|
||||
RatePost = "post"
|
||||
RateComment = "comment"
|
||||
RateChat = "chat" // 群聊发消息
|
||||
RateUpload = "upload" // 帖子插图等上传
|
||||
RateInteract = "interact" // 投票/抽奖/解锁等互动
|
||||
)
|
||||
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
@@ -77,5 +79,7 @@ func DefaultRateLimiter() *RateLimiter {
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
|
||||
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
|
||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||
return rl
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -20,6 +23,10 @@ const (
|
||||
AvatarMaxBytes = 2 << 20 // 2 MiB
|
||||
AvatarMinDim = 64
|
||||
AvatarMaxDim = 512
|
||||
|
||||
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
|
||||
ImageMaxBytes = 5 << 20 // 5 MiB
|
||||
ImageMaxDim = 4096
|
||||
)
|
||||
|
||||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||||
@@ -34,7 +41,10 @@ func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
|
||||
|
||||
// EnsureDir 启动时确保上传目录存在
|
||||
func (s *UploadService) EnsureDir() error {
|
||||
return os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755)
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(s.dir, "images"), 0o755)
|
||||
}
|
||||
|
||||
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
|
||||
@@ -96,6 +106,89 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
||||
return att, nil
|
||||
}
|
||||
|
||||
// imageFormat 由魔数识别的插图格式
|
||||
type imageFormat struct {
|
||||
ext string
|
||||
mime string
|
||||
}
|
||||
|
||||
func detectImageFormat(data []byte) (imageFormat, error) {
|
||||
if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
|
||||
return imageFormat{ext: ".jpg", mime: "image/jpeg"}, nil
|
||||
}
|
||||
if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" {
|
||||
return imageFormat{ext: ".png", mime: "image/png"}, nil
|
||||
}
|
||||
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||
return imageFormat{ext: ".webp", mime: "image/webp"}, nil
|
||||
}
|
||||
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
|
||||
}
|
||||
|
||||
func decodeImageSize(data []byte, mime string) (w, h int, err error) {
|
||||
r := bytes.NewReader(data)
|
||||
var cfg image.Config
|
||||
switch mime {
|
||||
case "image/webp":
|
||||
cfg, err = webp.DecodeConfig(r)
|
||||
default:
|
||||
cfg, _, err = image.DecodeConfig(r)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, errors.New("无法解析图片")
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if len(data) > ImageMaxBytes {
|
||||
return nil, errors.New("图片不能超过 5MB")
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, h, err := decodeImageSize(data, format.mime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w < 1 || h < 1 {
|
||||
return nil, errors.New("无效的图片尺寸")
|
||||
}
|
||||
if w > ImageMaxDim || h > ImageMaxDim {
|
||||
return nil, errors.New("图片边长不能超过 4096px")
|
||||
}
|
||||
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
fullPath := filepath.Join(s.dir, "images", filename)
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/" + filename,
|
||||
MIME: format.mime,
|
||||
Size: len(data),
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
|
||||
// UseAvatar 选用一张【本人历史上传】的头像
|
||||
func (s *UploadService) UseAvatar(userID uint, url string) error {
|
||||
url = strings.TrimSpace(url)
|
||||
|
||||
Reference in New Issue
Block a user