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

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