feat: 类型帖互动、积分经济与发帖体验

补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 03:17:07 +08:00
parent 1ef3d8e299
commit b9ba8cb8c9
58 changed files with 6913 additions and 506 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View 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})
}

View File

@@ -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"`

View File

@@ -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()
}
}

View File

@@ -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{

View File

@@ -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"`

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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
View 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
}

View File

@@ -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
}

View 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
})
}

File diff suppressed because it is too large Load Diff

View File

@@ -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
}

View File

@@ -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)