feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,7 +21,7 @@ var ErrAlreadyCheckedIn = errors.New("今日已签到")
|
||||
type CheckinStatus struct {
|
||||
CheckedToday bool `json:"checked_today"`
|
||||
Streak int `json:"streak"` // 连续签到天数
|
||||
TotalPoints int `json:"total_points"` // 累计积分(签到所得)
|
||||
TotalPoints int `json:"total_points"` // 累计积分(可用余额)
|
||||
TodayPoints int `json:"today_points"` // 今日签到可得积分
|
||||
}
|
||||
|
||||
@@ -85,9 +85,9 @@ func (s *CheckinService) Status(userID uint) (*CheckinStatus, error) {
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := s.db.Model(&model.Checkin{}).
|
||||
Where("user_id = ?", userID).
|
||||
Select("COALESCE(SUM(points), 0)").Scan(&total).Error; err != nil {
|
||||
if err := s.db.Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
Select("points").Scan(&total).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -122,6 +122,10 @@ func (s *CheckinService) CheckIn(userID uint) (*CheckinStatus, error) {
|
||||
if err := tx.Create(&row).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 同步入账到用户积分余额
|
||||
if _, err := CreditTx(tx, userID, DailyCheckinPoints, model.PointReasonCheckin, "checkin", row.ID, "每日签到"); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -171,7 +171,14 @@ func (s *ModerationService) ApprovePost(actor *Actor, id uint) (boardID uint, er
|
||||
if post.Status != model.ContentStatusPending {
|
||||
return ErrNotPending
|
||||
}
|
||||
if err := tx.Model(&post).Update("status", model.ContentStatusPublished).Error; err != nil {
|
||||
updates := map[string]interface{}{
|
||||
"status": model.ContentStatusPublished,
|
||||
}
|
||||
// 悬赏/抽奖:首次公开发布时补写 ends_at
|
||||
if raw, ok := EnsureDeadlineOnPublish(post.TypeMeta, post.PostType, time.Now().UTC()); ok {
|
||||
updates["type_meta"] = raw
|
||||
}
|
||||
if err := tx.Model(&post).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
boardID = post.BoardID
|
||||
|
||||
231
backend/service/points.go
Normal file
231
backend/service/points.go
Normal file
@@ -0,0 +1,231 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"time"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInsufficientPoints = errors.New("积分不足")
|
||||
ErrInvalidPoints = errors.New("积分数量无效")
|
||||
)
|
||||
|
||||
// PointsService 用户积分账户
|
||||
type PointsService struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPointsService(db *gorm.DB) *PointsService {
|
||||
return &PointsService{db: db}
|
||||
}
|
||||
|
||||
// Balance 查询可用积分
|
||||
func (s *PointsService) Balance(userID uint) (int, error) {
|
||||
var u model.User
|
||||
if err := s.db.Select("id", "points").First(&u, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return u.Points, nil
|
||||
}
|
||||
|
||||
// CreditTx 在事务内入账
|
||||
func CreditTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, ErrInvalidPoints
|
||||
}
|
||||
var u model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("id", "points").First(&u, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
bal := u.Points + delta
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Create(&model.PointLedger{
|
||||
UserID: userID,
|
||||
Delta: delta,
|
||||
Balance: bal,
|
||||
Reason: reason,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
Note: note,
|
||||
}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal, nil
|
||||
}
|
||||
|
||||
// DebitTx 在事务内扣款
|
||||
func DebitTx(tx *gorm.DB, userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
if delta <= 0 {
|
||||
return 0, ErrInvalidPoints
|
||||
}
|
||||
var u model.User
|
||||
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Select("id", "points").First(&u, userID).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if u.Points < delta {
|
||||
return 0, ErrInsufficientPoints
|
||||
}
|
||||
bal := u.Points - delta
|
||||
if err := tx.Model(&model.User{}).Where("id = ?", userID).Update("points", bal).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if err := tx.Create(&model.PointLedger{
|
||||
UserID: userID,
|
||||
Delta: -delta,
|
||||
Balance: bal,
|
||||
Reason: reason,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
Note: note,
|
||||
}).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return bal, nil
|
||||
}
|
||||
|
||||
// Credit 入账
|
||||
func (s *PointsService) Credit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
var bal int
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var e error
|
||||
bal, e = CreditTx(tx, userID, delta, reason, refType, refID, note)
|
||||
return e
|
||||
})
|
||||
return bal, err
|
||||
}
|
||||
|
||||
// Debit 扣款
|
||||
func (s *PointsService) Debit(userID uint, delta int, reason, refType string, refID uint, note string) (int, error) {
|
||||
var bal int
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var e error
|
||||
bal, e = DebitTx(tx, userID, delta, reason, refType, refID, note)
|
||||
return e
|
||||
})
|
||||
return bal, err
|
||||
}
|
||||
|
||||
// LedgerItem 流水展示项
|
||||
type LedgerItem struct {
|
||||
ID uint `json:"id"`
|
||||
Delta int `json:"delta"`
|
||||
Balance int `json:"balance"`
|
||||
Reason string `json:"reason"`
|
||||
RefType string `json:"ref_type"`
|
||||
RefID uint `json:"ref_id"`
|
||||
Note string `json:"note"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
// Ledger 分页查询本人积分流水
|
||||
func (s *PointsService) Ledger(userID uint, page, size int) ([]LedgerItem, int64, error) {
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 50 {
|
||||
size = 20
|
||||
}
|
||||
var total int64
|
||||
q := s.db.Model(&model.PointLedger{}).Where("user_id = ?", userID)
|
||||
if err := q.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
var rows []model.PointLedger
|
||||
if err := q.Order("id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := make([]LedgerItem, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
out = append(out, LedgerItem{
|
||||
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
|
||||
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
// PointsEconomyStats 全站积分与类型帖概览(管理端)
|
||||
type PointsEconomyStats struct {
|
||||
TotalBalance int64 `json:"total_balance"` // 用户余额合计
|
||||
LedgerCount int64 `json:"ledger_count"` // 流水条数
|
||||
CheckinToday int64 `json:"checkin_today"` // 今日签到人数
|
||||
OpenBounties int64 `json:"open_bounties"` // 未结算悬赏帖
|
||||
EscrowedPoints int64 `json:"escrowed_points"` // 托管中悬赏积分(估算)
|
||||
PostsByType map[string]int64 `json:"posts_by_type"` // 各类型帖数量
|
||||
RecentLedger []LedgerItem `json:"recent_ledger"` // 最近全局流水(脱敏 note)
|
||||
}
|
||||
|
||||
// AdminEconomyStats 管理端经济看板
|
||||
func (s *PointsService) AdminEconomyStats() (*PointsEconomyStats, error) {
|
||||
st := &PointsEconomyStats{PostsByType: map[string]int64{}}
|
||||
if err := s.db.Model(&model.User{}).Where("deleted_at IS NULL").
|
||||
Select("COALESCE(SUM(points),0)").Scan(&st.TotalBalance).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.db.Model(&model.PointLedger{}).Count(&st.LedgerCount).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
today := time.Now().Truncate(24 * time.Hour)
|
||||
// 用日期字符串更稳妥(与 checkin 一致用 date)
|
||||
if err := s.db.Model(&model.Checkin{}).
|
||||
Where("checkin_date::date = CURRENT_DATE").
|
||||
Count(&st.CheckinToday).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_ = today
|
||||
|
||||
type row struct {
|
||||
PostType string
|
||||
Cnt int64
|
||||
}
|
||||
var rows []row
|
||||
if err := s.db.Model(&model.Post{}).
|
||||
Select("post_type, count(*) as cnt").
|
||||
Where("deleted_at IS NULL").
|
||||
Group("post_type").Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
pt := model.NormalizePostType(r.PostType)
|
||||
st.PostsByType[pt] += r.Cnt
|
||||
}
|
||||
|
||||
// 未结算悬赏:type_meta 含 escrowed true 且未 accepted/refunded —— 用简易扫描估算
|
||||
var bountyPosts []model.Post
|
||||
if err := s.db.Select("id, type_meta").
|
||||
Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
||||
Find(&bountyPosts).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, p := range bountyPosts {
|
||||
m, err := parseBountyMeta(p.TypeMeta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
|
||||
st.OpenBounties++
|
||||
st.EscrowedPoints += int64(m.Points)
|
||||
}
|
||||
}
|
||||
|
||||
var recent []model.PointLedger
|
||||
if err := s.db.Order("id DESC").Limit(15).Find(&recent).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.RecentLedger = make([]LedgerItem, 0, len(recent))
|
||||
for _, r := range recent {
|
||||
st.RecentLedger = append(st.RecentLedger, LedgerItem{
|
||||
ID: r.ID, Delta: r.Delta, Balance: r.Balance, Reason: r.Reason,
|
||||
RefType: r.RefType, RefID: r.RefID, Note: r.Note, CreatedAt: r.CreatedAt,
|
||||
})
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
@@ -40,9 +40,13 @@ type PostListQuery struct {
|
||||
func toPostListItems(posts []model.Post) []PostListItem {
|
||||
items := make([]PostListItem, 0, len(posts))
|
||||
for _, p := range posts {
|
||||
pt := model.NormalizePostType(p.PostType)
|
||||
items = append(items, PostListItem{
|
||||
ID: p.ID, BoardID: p.BoardID, UserID: p.UserID,
|
||||
Title: p.Title, Tags: p.Tags, PostType: p.PostType,
|
||||
Title: p.Title, Tags: p.Tags, PostType: pt,
|
||||
TypeStatus: ComputeTypeStatus(pt, p.TypeMeta),
|
||||
ContentAccess: model.NormalizeContentAccess(p.ContentAccess),
|
||||
AccessPoints: p.AccessPoints,
|
||||
Pinned: p.Pinned, Recommended: p.Recommended, LikeCount: p.LikeCount, ViewCount: p.ViewCount,
|
||||
CommentCount: p.CommentCount, Status: p.Status, CreatedAt: p.CreatedAt,
|
||||
Board: p.Board, User: p.User,
|
||||
@@ -67,23 +71,26 @@ type LastReplyInfo struct {
|
||||
|
||||
// PostListItem 帖子列表项(不含正文)
|
||||
type PostListItem struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Status string `json:"status"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastReply *LastReplyInfo `json:"last_reply,omitempty"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
TypeStatus string `json:"type_status,omitempty"` // unsolved|solved|open|closed|expired|drawn
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Status string `json:"status"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
LastReply *LastReplyInfo `json:"last_reply,omitempty"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
}
|
||||
|
||||
// fillLastReply 批量填充每帖最后一条已发布评论(发帖人+时间),
|
||||
@@ -327,9 +334,78 @@ func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) boo
|
||||
return loadActor().CanModerateBoard(post.BoardID)
|
||||
}
|
||||
|
||||
// GetByIDForViewer 获取帖子详情(带可见性校验);通过校验才计入浏览量。
|
||||
// viewerID 为当前登录用户(未登录传 0),loadActor 可传 nil
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*model.Post, error) {
|
||||
// PostAttachmentDTO 附件对外字段
|
||||
type PostAttachmentDTO struct {
|
||||
ID uint `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Size int `json:"size"`
|
||||
MIME string `json:"mime"`
|
||||
PricePoints int `json:"price_points"`
|
||||
DownloadCount int `json:"download_count"`
|
||||
Unlocked bool `json:"unlocked"` // 当前用户是否可直接下载(免费/已购/作者)
|
||||
}
|
||||
|
||||
// PostDetail 帖子详情(含可见性裁剪与附件)
|
||||
type PostDetail struct {
|
||||
ID uint `json:"id"`
|
||||
BoardID uint `json:"board_id"`
|
||||
UserID uint `json:"user_id"`
|
||||
Title string `json:"title"`
|
||||
Content string `json:"content"`
|
||||
Tags string `json:"tags"`
|
||||
PostType string `json:"post_type"`
|
||||
ContentAccess string `json:"content_access"`
|
||||
AccessPoints int `json:"access_points"`
|
||||
TypeMeta string `json:"type_meta"`
|
||||
TypeStatus string `json:"type_status,omitempty"`
|
||||
Pinned int `json:"pinned"`
|
||||
Recommended bool `json:"recommended"`
|
||||
Status string `json:"status"`
|
||||
LikeCount int `json:"like_count"`
|
||||
ViewCount int `json:"view_count"`
|
||||
CommentCount int `json:"comment_count"`
|
||||
Liked bool `json:"liked"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
Board model.Board `json:"board"`
|
||||
User model.User `json:"user"`
|
||||
ContentLocked bool `json:"content_locked"`
|
||||
AccessHint string `json:"access_hint,omitempty"`
|
||||
Attachments []PostAttachmentDTO `json:"attachments"`
|
||||
Question *QuestionState `json:"question,omitempty"`
|
||||
Poll *PollState `json:"poll,omitempty"`
|
||||
Bounty *BountyState `json:"bounty,omitempty"`
|
||||
Lottery *LotteryState `json:"lottery,omitempty"`
|
||||
}
|
||||
|
||||
// CreatePostInput 发帖入参
|
||||
type CreatePostInput struct {
|
||||
UserID uint
|
||||
BoardID uint
|
||||
Title string
|
||||
Content string
|
||||
Tags string
|
||||
PostType string
|
||||
ContentAccess string
|
||||
AccessPoints int
|
||||
TypeMeta string
|
||||
Status string
|
||||
AttachmentIDs []uint
|
||||
}
|
||||
|
||||
// UpdatePostInput 编辑入参
|
||||
type UpdatePostInput struct {
|
||||
Title string
|
||||
Content string
|
||||
Tags string
|
||||
ContentAccess *string
|
||||
AccessPoints *int
|
||||
TypeMeta *string
|
||||
AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
|
||||
}
|
||||
|
||||
// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)
|
||||
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, id).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
@@ -337,9 +413,504 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
|
||||
if !visibleToPost(&post, viewerID, loadActor) {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
// 增加浏览量(待审/被拒内容不计)
|
||||
// 惰性结算:悬赏过期退回 / 抽奖到期开奖
|
||||
_ = s.settleExpiredBountyIfNeeded(&post)
|
||||
_ = s.settleDueLotteryIfNeeded(&post)
|
||||
if post.TypeMeta != "" {
|
||||
var fresh model.Post
|
||||
if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil {
|
||||
post.TypeMeta = fresh.TypeMeta
|
||||
}
|
||||
}
|
||||
|
||||
s.db.Model(&post).UpdateColumn("view_count", gorm.Expr("view_count + 1"))
|
||||
return &post, nil
|
||||
post.ViewCount++
|
||||
|
||||
detail := buildPostDetail(&post)
|
||||
locked, hint := s.evalContentAccess(&post, viewerID, loadActor)
|
||||
detail.ContentLocked = locked
|
||||
detail.AccessHint = hint
|
||||
if locked {
|
||||
detail.Content = ""
|
||||
}
|
||||
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, &post, viewerID, loadActor)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func buildPostDetail(post *model.Post) *PostDetail {
|
||||
return &PostDetail{
|
||||
ID: post.ID, BoardID: post.BoardID, UserID: post.UserID,
|
||||
Title: post.Title, Content: post.Content, Tags: post.Tags,
|
||||
PostType: model.NormalizePostType(post.PostType),
|
||||
ContentAccess: model.NormalizeContentAccess(post.ContentAccess),
|
||||
AccessPoints: post.AccessPoints, TypeMeta: post.TypeMeta,
|
||||
TypeStatus: ComputeTypeStatus(post.PostType, post.TypeMeta),
|
||||
Pinned: post.Pinned, Recommended: post.Recommended, Status: post.Status,
|
||||
LikeCount: post.LikeCount, ViewCount: post.ViewCount, CommentCount: post.CommentCount,
|
||||
Liked: post.Liked, CreatedAt: post.CreatedAt, UpdatedAt: post.UpdatedAt,
|
||||
Board: post.Board, User: post.User,
|
||||
Attachments: []PostAttachmentDTO{},
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) evalContentAccess(post *model.Post, viewerID uint, loadActor func() *Actor) (locked bool, hint string) {
|
||||
access := model.NormalizeContentAccess(post.ContentAccess)
|
||||
if access == model.ContentAccessPublic {
|
||||
return false, ""
|
||||
}
|
||||
// 作者与版主始终可见
|
||||
if viewerID > 0 && post.UserID == viewerID {
|
||||
return false, ""
|
||||
}
|
||||
if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
switch access {
|
||||
case model.ContentAccessLogin:
|
||||
if viewerID == 0 {
|
||||
return true, "登录后可见全文"
|
||||
}
|
||||
return false, ""
|
||||
case model.ContentAccessReply:
|
||||
if viewerID == 0 {
|
||||
return true, "回复本帖后可见全文"
|
||||
}
|
||||
var n int64
|
||||
s.db.Model(&model.Comment{}).
|
||||
Where("post_id = ? AND user_id = ? AND status = ? AND deleted_at IS NULL",
|
||||
post.ID, viewerID, model.ContentStatusPublished).
|
||||
Count(&n)
|
||||
if n == 0 {
|
||||
return true, "回复本帖后可见全文"
|
||||
}
|
||||
return false, ""
|
||||
case model.ContentAccessPoints:
|
||||
need := post.AccessPoints
|
||||
if need <= 0 {
|
||||
need = 1
|
||||
}
|
||||
if viewerID == 0 {
|
||||
return true, "支付积分后可见全文"
|
||||
}
|
||||
var n int64
|
||||
s.db.Model(&model.PostContentUnlock{}).
|
||||
Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n)
|
||||
if n > 0 {
|
||||
return false, ""
|
||||
}
|
||||
return true, "支付积分后可见全文"
|
||||
default:
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
func (s *PostService) listAttachmentDTOs(postID, viewerID, authorID uint) ([]PostAttachmentDTO, error) {
|
||||
var rows []model.PostAttachment
|
||||
if err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]PostAttachmentDTO, 0, len(rows))
|
||||
unlockedIDs := map[uint]bool{}
|
||||
if viewerID > 0 {
|
||||
ids := make([]uint, 0, len(rows))
|
||||
for _, r := range rows {
|
||||
if r.PricePoints > 0 {
|
||||
ids = append(ids, r.ID)
|
||||
}
|
||||
}
|
||||
if len(ids) > 0 {
|
||||
var unlocks []model.PostAttachmentUnlock
|
||||
s.db.Where("attachment_id IN ? AND user_id = ?", ids, viewerID).Find(&unlocks)
|
||||
for _, u := range unlocks {
|
||||
unlockedIDs[u.AttachmentID] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, r := range rows {
|
||||
ok := r.PricePoints <= 0 || viewerID == authorID || unlockedIDs[r.ID]
|
||||
out = append(out, PostAttachmentDTO{
|
||||
ID: r.ID, Name: r.Name, Size: r.Size, MIME: r.MIME,
|
||||
PricePoints: r.PricePoints, DownloadCount: r.DownloadCount, Unlocked: ok,
|
||||
})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// UnlockContent 积分解锁正文
|
||||
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
if model.NormalizeContentAccess(post.ContentAccess) != model.ContentAccessPoints {
|
||||
return nil, errors.New("本文无需积分解锁")
|
||||
}
|
||||
if post.UserID == userID {
|
||||
return buildPostDetail(&post), nil
|
||||
}
|
||||
need := post.AccessPoints
|
||||
if need <= 0 {
|
||||
need = 1
|
||||
}
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var n int64
|
||||
if err := tx.Model(&model.PostContentUnlock{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, userID).Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := DebitTx(tx, userID, need, model.PointReasonUnlockPost, "post", postID, "解锁帖子:"+post.Title); err != nil {
|
||||
return err
|
||||
}
|
||||
if post.UserID > 0 {
|
||||
if _, err := CreditTx(tx, post.UserID, need, model.PointReasonUnlockPost, "post_earn", postID, "正文解锁收益"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.PostContentUnlock{
|
||||
PostID: postID, UserID: userID, Points: need,
|
||||
}).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
detail := buildPostDetail(&post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, &post, userID, nil)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// Create 创建帖子。status 由 handler 按角色计算
|
||||
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
|
||||
title := strings.TrimSpace(in.Title)
|
||||
content := strings.TrimSpace(in.Content)
|
||||
if title == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
if content == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
if in.BoardID == 0 {
|
||||
return nil, errors.New("请选择板块")
|
||||
}
|
||||
status := in.Status
|
||||
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
|
||||
status = model.ContentStatusPending
|
||||
}
|
||||
postType := model.NormalizePostType(in.PostType)
|
||||
if !model.ValidPostType(postType) {
|
||||
return nil, errors.New("无效的帖子类型")
|
||||
}
|
||||
access := model.NormalizeContentAccess(in.ContentAccess)
|
||||
accessPts := in.AccessPoints
|
||||
if access == model.ContentAccessPoints {
|
||||
if accessPts <= 0 {
|
||||
return nil, errors.New("请设置解锁所需积分")
|
||||
}
|
||||
if accessPts > 100000 {
|
||||
return nil, errors.New("解锁积分过高")
|
||||
}
|
||||
} else {
|
||||
accessPts = 0
|
||||
}
|
||||
typeMeta, err := NormalizeAndValidateTypeMeta(postType, in.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.checkNewUserCooldown(in.UserID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
if postType == model.PostTypeQuestion {
|
||||
qm, _ := parseQuestionMeta(typeMeta)
|
||||
if qm == nil {
|
||||
qm = &QuestionMeta{}
|
||||
}
|
||||
typeMeta, _ = encodeMeta(qm)
|
||||
}
|
||||
|
||||
// 悬赏:创建时托管积分 + 防刷(未结算上限 / 每日上限)
|
||||
var bountyPts int
|
||||
if postType == model.PostTypeBounty {
|
||||
bm, _ := parseBountyMeta(typeMeta)
|
||||
bountyPts = bm.Points
|
||||
var bountyPosts []model.Post
|
||||
s.db.Select("type_meta").
|
||||
Where("user_id = ? AND post_type = ? AND deleted_at IS NULL", in.UserID, model.PostTypeBounty).
|
||||
Find(&bountyPosts)
|
||||
open := 0
|
||||
for _, bp := range bountyPosts {
|
||||
m, err := parseBountyMeta(bp.TypeMeta)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if m.Escrowed && m.AcceptedCommentID == 0 && !m.Refunded && !m.Expired {
|
||||
open++
|
||||
}
|
||||
}
|
||||
if open >= 3 {
|
||||
return nil, errors.New("未结算悬赏最多同时 3 个,请先采纳或退回")
|
||||
}
|
||||
var todayN int64
|
||||
s.db.Model(&model.Post{}).
|
||||
Where("user_id = ? AND post_type = ? AND created_at >= CURRENT_DATE AND deleted_at IS NULL",
|
||||
in.UserID, model.PostTypeBounty).
|
||||
Count(&todayN)
|
||||
if todayN >= 5 {
|
||||
return nil, errors.New("今日悬赏发帖已达上限(5)")
|
||||
}
|
||||
bm.Escrowed = true
|
||||
bm.Refunded = false
|
||||
bm.Expired = false
|
||||
bm.AcceptedCommentID = 0
|
||||
if status == model.ContentStatusPublished {
|
||||
days := normalizeExpireDays(bm.ExpireDays, 7, []int{3, 7, 14, 30})
|
||||
bm.ExpireDays = 0
|
||||
bm.EndsAt = endsAtFromDays(days, now)
|
||||
}
|
||||
typeMeta, _ = encodeMeta(bm)
|
||||
}
|
||||
|
||||
if postType == model.PostTypeLottery {
|
||||
lm, _ := parseLotteryMeta(typeMeta)
|
||||
if lm.WinnerIDs == nil {
|
||||
lm.WinnerIDs = []uint{}
|
||||
}
|
||||
if status == model.ContentStatusPublished {
|
||||
days := normalizeExpireDays(lm.ExpireDays, 7, []int{1, 3, 7, 14})
|
||||
lm.ExpireDays = 0
|
||||
lm.EndsAt = endsAtFromDays(days, now)
|
||||
}
|
||||
typeMeta, _ = encodeMeta(lm)
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
BoardID: in.BoardID, UserID: in.UserID,
|
||||
Title: title, Content: content, Tags: in.Tags,
|
||||
PostType: postType, ContentAccess: access, AccessPoints: accessPts,
|
||||
TypeMeta: typeMeta, Status: status,
|
||||
}
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Create(post).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if bountyPts > 0 {
|
||||
if _, err := DebitTx(tx, in.UserID, bountyPts, model.PointReasonBountyEscrow, "post", post.ID, "悬赏托管:"+title); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(in.AttachmentIDs) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(in.AttachmentIDs) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
var atts []model.PostAttachment
|
||||
if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID).
|
||||
Find(&atts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(atts) != len(in.AttachmentIDs) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ? AND post_id = 0", in.AttachmentIDs, in.UserID).
|
||||
Update("post_id", post.ID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
detail := buildPostDetail(post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, in.UserID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, post, in.UserID, nil)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
func validateTypeMeta(postType, meta string) error {
|
||||
_, err := NormalizeAndValidateTypeMeta(postType, meta)
|
||||
return err
|
||||
}
|
||||
|
||||
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInput) (*PostDetail, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||||
return nil, ErrPostForbidden
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if in.Title != "" {
|
||||
t := strings.TrimSpace(in.Title)
|
||||
if t == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
updates["title"] = t
|
||||
}
|
||||
if in.Content != "" {
|
||||
c := strings.TrimSpace(in.Content)
|
||||
if c == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
updates["content"] = c
|
||||
}
|
||||
updates["tags"] = in.Tags
|
||||
|
||||
if in.ContentAccess != nil {
|
||||
access := model.NormalizeContentAccess(*in.ContentAccess)
|
||||
updates["content_access"] = access
|
||||
if access == model.ContentAccessPoints {
|
||||
pts := post.AccessPoints
|
||||
if in.AccessPoints != nil {
|
||||
pts = *in.AccessPoints
|
||||
}
|
||||
if pts <= 0 {
|
||||
return nil, errors.New("请设置解锁所需积分")
|
||||
}
|
||||
updates["access_points"] = pts
|
||||
} else {
|
||||
updates["access_points"] = 0
|
||||
}
|
||||
} else if in.AccessPoints != nil && model.NormalizeContentAccess(post.ContentAccess) == model.ContentAccessPoints {
|
||||
if *in.AccessPoints <= 0 {
|
||||
return nil, errors.New("请设置解锁所需积分")
|
||||
}
|
||||
updates["access_points"] = *in.AccessPoints
|
||||
}
|
||||
if in.TypeMeta != nil {
|
||||
pt := model.NormalizePostType(post.PostType)
|
||||
meta, err := NormalizeAndValidateTypeMeta(pt, *in.TypeMeta)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 悬赏已托管:禁止改积分,保留结算/截止字段
|
||||
if pt == model.PostTypeBounty {
|
||||
old, _ := parseBountyMeta(post.TypeMeta)
|
||||
neu, _ := parseBountyMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
neu.Escrowed = old.Escrowed
|
||||
neu.Refunded = old.Refunded
|
||||
neu.Expired = old.Expired
|
||||
neu.AcceptedCommentID = old.AcceptedCommentID
|
||||
neu.EndsAt = old.EndsAt
|
||||
if old.Escrowed || old.AcceptedCommentID > 0 || old.Refunded || old.Expired {
|
||||
neu.Points = old.Points
|
||||
}
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
// 投票已有票:明确报错,禁止改选项/单多选/匿名
|
||||
if pt == model.PostTypePoll {
|
||||
old, _ := parsePollMeta(post.TypeMeta)
|
||||
neu, _ := parsePollMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
var n int64
|
||||
s.db.Model(&model.PostPollVote{}).Where("post_id = ?", postID).Count(&n)
|
||||
if n > 0 {
|
||||
optsChanged := len(neu.Options) != len(old.Options)
|
||||
if !optsChanged {
|
||||
for i := range old.Options {
|
||||
if neu.Options[i] != old.Options[i] {
|
||||
optsChanged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if optsChanged || neu.Multi != old.Multi || neu.Anonymous != old.Anonymous {
|
||||
return nil, ErrPollOptionsLocked
|
||||
}
|
||||
neu.Options = old.Options
|
||||
neu.Multi = old.Multi
|
||||
neu.Anonymous = old.Anonymous
|
||||
neu.Closed = old.Closed
|
||||
meta, _ = encodeMeta(neu)
|
||||
} else {
|
||||
neu.Closed = old.Closed
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
}
|
||||
if pt == model.PostTypeLottery {
|
||||
old, _ := parseLotteryMeta(post.TypeMeta)
|
||||
neu, _ := parseLotteryMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
neu.Drawn = old.Drawn
|
||||
neu.WinnerIDs = old.WinnerIDs
|
||||
neu.Closed = old.Closed
|
||||
neu.EndsAt = old.EndsAt
|
||||
if old.Drawn {
|
||||
neu.Slots = old.Slots
|
||||
}
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
if pt == model.PostTypeQuestion {
|
||||
old, _ := parseQuestionMeta(post.TypeMeta)
|
||||
neu, _ := parseQuestionMeta(meta)
|
||||
if old != nil && neu != nil {
|
||||
// 解题态走专用 API,编辑帖子不覆盖
|
||||
neu.Solved = old.Solved
|
||||
neu.AcceptedCommentID = old.AcceptedCommentID
|
||||
meta, _ = encodeMeta(neu)
|
||||
}
|
||||
}
|
||||
updates["type_meta"] = meta
|
||||
}
|
||||
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&post).Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if in.AttachmentIDs == nil {
|
||||
return nil
|
||||
}
|
||||
ids := *in.AttachmentIDs
|
||||
if len(ids) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
if err := tx.Model(&model.PostAttachment{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, post.UserID).
|
||||
Update("post_id", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
var atts []model.PostAttachment
|
||||
if err := tx.Where(
|
||||
"id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
|
||||
ids, post.UserID, postID,
|
||||
).Find(&atts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(atts) != len(ids) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ?", ids, post.UserID).
|
||||
Update("post_id", postID).Error
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||||
detail := buildPostDetail(&post)
|
||||
atts, _ := s.listAttachmentDTOs(post.ID, userID, post.UserID)
|
||||
detail.Attachments = atts
|
||||
s.fillInteractState(detail, &post, userID, nil)
|
||||
return detail, nil
|
||||
}
|
||||
|
||||
// EnsurePostVisible 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
|
||||
@@ -354,105 +925,27 @@ func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func()
|
||||
return nil
|
||||
}
|
||||
|
||||
// Create 创建帖子。status 由 handler 按角色计算:
|
||||
// 管理团队成员直发 published,普通用户进入 pending 等待审核
|
||||
func (s *PostService) Create(userID uint, boardID uint, title, content, tags, postType, status string) (*model.Post, error) {
|
||||
title = strings.TrimSpace(title)
|
||||
content = strings.TrimSpace(content)
|
||||
if title == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
if content == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
if boardID == 0 {
|
||||
return nil, errors.New("请选择板块")
|
||||
}
|
||||
if status != model.ContentStatusPending && status != model.ContentStatusPublished {
|
||||
status = model.ContentStatusPending
|
||||
}
|
||||
|
||||
// 新用户 24h 冷静期校验
|
||||
if err := s.checkNewUserCooldown(userID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
post := &model.Post{
|
||||
BoardID: boardID,
|
||||
UserID: userID,
|
||||
Title: title,
|
||||
Content: content,
|
||||
Tags: tags,
|
||||
PostType: postType,
|
||||
Status: status,
|
||||
}
|
||||
if err := s.db.Create(post).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 预加载关联
|
||||
s.db.Preload("Board").Preload("User").First(post, post.ID)
|
||||
return post, nil
|
||||
}
|
||||
|
||||
// checkNewUserCooldown 新用户发帖 24h 冷静期
|
||||
func (s *PostService) checkNewUserCooldown(userID uint) error {
|
||||
var user model.User
|
||||
if err := s.db.First(&user, userID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 注册不足 24 小时的新用户不能发帖
|
||||
if time.Since(user.CreatedAt) < 24*time.Hour {
|
||||
return errors.New("新用户注册 24 小时后才能发帖")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update 更新帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Update(actor *Actor, postID, userID uint, title, content, tags string) (*model.Post, error) {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return nil, ErrPostNotFound
|
||||
}
|
||||
// 权限校验:作者本人或板块审核权
|
||||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||||
return nil, ErrPostForbidden
|
||||
}
|
||||
|
||||
updates := map[string]interface{}{}
|
||||
if title != "" {
|
||||
t := strings.TrimSpace(title)
|
||||
if t == "" {
|
||||
return nil, errors.New("标题不能为空")
|
||||
}
|
||||
updates["title"] = t
|
||||
}
|
||||
if content != "" {
|
||||
c := strings.TrimSpace(content)
|
||||
if c == "" {
|
||||
return nil, errors.New("内容不能为空")
|
||||
}
|
||||
updates["content"] = c
|
||||
}
|
||||
updates["tags"] = tags
|
||||
|
||||
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.db.Preload("Board").Preload("User").First(&post, post.ID)
|
||||
return &post, nil
|
||||
}
|
||||
|
||||
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)
|
||||
func (s *PostService) Delete(actor *Actor, postID, userID uint) error {
|
||||
var post model.Post
|
||||
if err := s.db.First(&post, postID).Error; err != nil {
|
||||
return ErrPostNotFound
|
||||
}
|
||||
// 权限校验:作者本人或板块审核权
|
||||
if post.UserID != userID && !actor.CanModerateBoard(post.BoardID) {
|
||||
return ErrPostForbidden
|
||||
}
|
||||
// 软删除(gorm DeletedAt)
|
||||
if err := s.db.Delete(&post).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
283
backend/service/post_file.go
Normal file
283
backend/service/post_file.go
Normal file
@@ -0,0 +1,283 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"mime"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/freefire/jiang13-bbs/model"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
const (
|
||||
FileMaxBytes = 20 << 20 // 20 MiB
|
||||
MaxPostAttachments = 10
|
||||
)
|
||||
|
||||
var (
|
||||
ErrAttachmentNotFound = errors.New("附件不存在")
|
||||
ErrAttachmentForbidden = errors.New("无权操作此附件")
|
||||
ErrTooManyAttachments = errors.New("附件数量超过上限")
|
||||
)
|
||||
|
||||
// PostFileService 帖子文件附件(私有目录 + API 下载)
|
||||
type PostFileService struct {
|
||||
db *gorm.DB
|
||||
dir string // data/private/files
|
||||
}
|
||||
|
||||
func NewPostFileService(db *gorm.DB, privateDir string) *PostFileService {
|
||||
return &PostFileService{db: db, dir: filepath.Join(privateDir, "files")}
|
||||
}
|
||||
|
||||
func (s *PostFileService) EnsureDir() error {
|
||||
return os.MkdirAll(s.dir, 0o755)
|
||||
}
|
||||
|
||||
func absPath(dir, stored string) string {
|
||||
return filepath.Join(dir, stored)
|
||||
}
|
||||
|
||||
// SaveDraftFile 上传附件(先挂 post_id=0,发帖时绑定)
|
||||
func (s *PostFileService) SaveDraftFile(userID uint, originalName string, data []byte, pricePoints int) (*model.PostAttachment, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if len(data) > FileMaxBytes {
|
||||
return nil, errors.New("附件不能超过 20MB")
|
||||
}
|
||||
if pricePoints < 0 {
|
||||
pricePoints = 0
|
||||
}
|
||||
if pricePoints > 100000 {
|
||||
return nil, errors.New("积分定价过高")
|
||||
}
|
||||
name := sanitizeFilename(originalName)
|
||||
if name == "" {
|
||||
name = "file"
|
||||
}
|
||||
|
||||
var orphan int64
|
||||
if err := s.db.Model(&model.PostAttachment{}).
|
||||
Where("user_id = ? AND post_id = 0", userID).Count(&orphan).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if orphan >= MaxPostAttachments {
|
||||
return nil, ErrTooManyAttachments
|
||||
}
|
||||
|
||||
ext := filepath.Ext(name)
|
||||
if utf8.RuneCountInString(ext) > 16 {
|
||||
ext = ""
|
||||
}
|
||||
raw := make([]byte, 16)
|
||||
if _, err := rand.Read(raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stored := hex.EncodeToString(raw) + ext
|
||||
full := absPath(s.dir, stored)
|
||||
if err := os.WriteFile(full, data, 0o600); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mimeType := mime.TypeByExtension(ext)
|
||||
if mimeType == "" {
|
||||
mimeType = "application/octet-stream"
|
||||
}
|
||||
|
||||
att := &model.PostAttachment{
|
||||
PostID: 0,
|
||||
UserID: userID,
|
||||
Name: name,
|
||||
StoredName: stored,
|
||||
MIME: mimeType,
|
||||
Size: len(data),
|
||||
PricePoints: pricePoints,
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(full)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
|
||||
func sanitizeFilename(name string) string {
|
||||
name = filepath.Base(strings.ReplaceAll(name, "\\", "/"))
|
||||
name = strings.TrimSpace(name)
|
||||
name = strings.Map(func(r rune) rune {
|
||||
switch r {
|
||||
case '/', '\\', '\x00', ':', '*', '?', '"', '<', '>', '|':
|
||||
return '_'
|
||||
default:
|
||||
return r
|
||||
}
|
||||
}, name)
|
||||
if utf8.RuneCountInString(name) > 200 {
|
||||
runes := []rune(name)
|
||||
name = string(runes[:200])
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// BindToPost 将草稿附件绑定到帖子(仅本人、未绑定)
|
||||
func (s *PostFileService) BindToPost(userID, postID uint, ids []uint) error {
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
if len(ids) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var atts []model.PostAttachment
|
||||
if err := tx.Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).Find(&atts).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(atts) != len(ids) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ? AND post_id = 0", ids, userID).
|
||||
Update("post_id", postID).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ReplacePostAttachments 编辑时重绑附件列表(ids 为最终列表;可含已绑定本帖的)
|
||||
func (s *PostFileService) ReplacePostAttachments(userID, postID uint, ids []uint) error {
|
||||
if len(ids) > MaxPostAttachments {
|
||||
return ErrTooManyAttachments
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var keep []model.PostAttachment
|
||||
if len(ids) > 0 {
|
||||
if err := tx.Where(
|
||||
"id IN ? AND user_id = ? AND (post_id = 0 OR post_id = ?)",
|
||||
ids, userID, postID,
|
||||
).Find(&keep).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(keep) != len(ids) {
|
||||
return errors.New("部分附件无效或无权使用")
|
||||
}
|
||||
}
|
||||
// 解绑本帖旧附件(软删物理文件可选:P1 仅解绑)
|
||||
if err := tx.Model(&model.PostAttachment{}).
|
||||
Where("post_id = ? AND user_id = ?", postID, userID).
|
||||
Update("post_id", 0).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.PostAttachment{}).
|
||||
Where("id IN ? AND user_id = ?", ids, userID).
|
||||
Update("post_id", postID).Error
|
||||
})
|
||||
}
|
||||
|
||||
// ListByPost 帖子附件列表
|
||||
func (s *PostFileService) ListByPost(postID uint) ([]model.PostAttachment, error) {
|
||||
var list []model.PostAttachment
|
||||
err := s.db.Where("post_id = ?", postID).Order("id ASC").Find(&list).Error
|
||||
return list, err
|
||||
}
|
||||
|
||||
// UpdatePrice 更新附件积分定价(作者)
|
||||
func (s *PostFileService) UpdatePrice(userID, attID uint, price int) error {
|
||||
if price < 0 {
|
||||
price = 0
|
||||
}
|
||||
res := s.db.Model(&model.PostAttachment{}).
|
||||
Where("id = ? AND user_id = ?", attID, userID).
|
||||
Update("price_points", price)
|
||||
if res.Error != nil {
|
||||
return res.Error
|
||||
}
|
||||
if res.RowsAffected == 0 {
|
||||
return ErrAttachmentNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteOwn 删除本人未绑定或本帖附件
|
||||
func (s *PostFileService) DeleteOwn(userID, attID uint) error {
|
||||
var att model.PostAttachment
|
||||
if err := s.db.First(&att, attID).Error; err != nil {
|
||||
return ErrAttachmentNotFound
|
||||
}
|
||||
if att.UserID != userID {
|
||||
return ErrAttachmentForbidden
|
||||
}
|
||||
path := absPath(s.dir, att.StoredName)
|
||||
if err := s.db.Delete(&att).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
_ = os.Remove(path)
|
||||
return nil
|
||||
}
|
||||
|
||||
// OpenForDownload 鉴权后打开文件;需先确认帖子可见与积分
|
||||
func (s *PostFileService) Get(attID uint) (*model.PostAttachment, error) {
|
||||
var att model.PostAttachment
|
||||
if err := s.db.First(&att, attID).Error; err != nil {
|
||||
return nil, ErrAttachmentNotFound
|
||||
}
|
||||
return &att, nil
|
||||
}
|
||||
|
||||
func (s *PostFileService) FilePath(att *model.PostAttachment) string {
|
||||
return absPath(s.dir, att.StoredName)
|
||||
}
|
||||
|
||||
func (s *PostFileService) IncDownload(attID uint) {
|
||||
s.db.Model(&model.PostAttachment{}).Where("id = ?", attID).
|
||||
UpdateColumn("download_count", gorm.Expr("download_count + 1"))
|
||||
}
|
||||
|
||||
// EnsureAttachmentUnlocked 免费或已购/作者;积分附件扣费一次
|
||||
func (s *PostFileService) EnsureAttachmentUnlocked(userID uint, att *model.PostAttachment) error {
|
||||
if att.PricePoints <= 0 {
|
||||
return nil
|
||||
}
|
||||
if att.UserID == userID {
|
||||
return nil
|
||||
}
|
||||
var n int64
|
||||
if err := s.db.Model(&model.PostAttachmentUnlock{}).
|
||||
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
|
||||
Count(&n).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if n > 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var again int64
|
||||
if err := tx.Model(&model.PostAttachmentUnlock{}).
|
||||
Where("attachment_id = ? AND user_id = ?", att.ID, userID).
|
||||
Count(&again).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if again > 0 {
|
||||
return nil
|
||||
}
|
||||
if _, err := DebitTx(tx, userID, att.PricePoints, model.PointReasonDownloadFile, "attachment", att.ID, "下载附件:"+att.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
// 积分转给作者
|
||||
if att.UserID > 0 && att.UserID != userID {
|
||||
if _, err := CreditTx(tx, att.UserID, att.PricePoints, model.PointReasonDownloadFile, "attachment_earn", att.ID, "附件收益:"+att.Name); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Create(&model.PostAttachmentUnlock{
|
||||
AttachmentID: att.ID,
|
||||
UserID: userID,
|
||||
Points: att.PricePoints,
|
||||
}).Error
|
||||
})
|
||||
}
|
||||
1082
backend/service/post_interact.go
Normal file
1082
backend/service/post_interact.go
Normal file
File diff suppressed because it is too large
Load Diff
@@ -67,6 +67,8 @@ const (
|
||||
RatePost = "post"
|
||||
RateComment = "comment"
|
||||
RateChat = "chat" // 群聊发消息
|
||||
RateUpload = "upload" // 帖子插图等上传
|
||||
RateInteract = "interact" // 投票/抽奖/解锁等互动
|
||||
)
|
||||
|
||||
// DefaultRateLimiter 创建默认速率限制器
|
||||
@@ -77,5 +79,7 @@ func DefaultRateLimiter() *RateLimiter {
|
||||
rl.SetLimit(RatePost, 10) // 发帖 10/分钟
|
||||
rl.SetLimit(RateComment, 30) // 评论 30/分钟
|
||||
rl.SetLimit(RateChat, 30) // 群聊消息 30/分钟
|
||||
rl.SetLimit(RateUpload, 20) // 图片上传 20/分钟
|
||||
rl.SetLimit(RateInteract, 40) // 互动 40/分钟
|
||||
return rl
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@ import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"image"
|
||||
_ "image/jpeg"
|
||||
_ "image/png"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -20,6 +23,10 @@ const (
|
||||
AvatarMaxBytes = 2 << 20 // 2 MiB
|
||||
AvatarMinDim = 64
|
||||
AvatarMaxDim = 512
|
||||
|
||||
// 帖子插图:允许 JPEG/PNG/WebP,不强制转码
|
||||
ImageMaxBytes = 5 << 20 // 5 MiB
|
||||
ImageMaxDim = 4096
|
||||
)
|
||||
|
||||
// UploadService 附件上传:落盘到 data/uploads,元信息入库 attachments
|
||||
@@ -34,7 +41,10 @@ func NewUploadService(db *gorm.DB, uploadDir string) *UploadService {
|
||||
|
||||
// EnsureDir 启动时确保上传目录存在
|
||||
func (s *UploadService) EnsureDir() error {
|
||||
return os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755)
|
||||
if err := os.MkdirAll(filepath.Join(s.dir, "avatars"), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
return os.MkdirAll(filepath.Join(s.dir, "images"), 0o755)
|
||||
}
|
||||
|
||||
// SaveAvatar 保存裁剪后的 WebP 头像:校验魔数/大小/尺寸 → 落盘 → 写附件记录 → 更新用户头像
|
||||
@@ -96,6 +106,89 @@ func (s *UploadService) SaveAvatar(userID uint, data []byte) (*model.Attachment,
|
||||
return att, nil
|
||||
}
|
||||
|
||||
// imageFormat 由魔数识别的插图格式
|
||||
type imageFormat struct {
|
||||
ext string
|
||||
mime string
|
||||
}
|
||||
|
||||
func detectImageFormat(data []byte) (imageFormat, error) {
|
||||
if len(data) >= 3 && data[0] == 0xff && data[1] == 0xd8 && data[2] == 0xff {
|
||||
return imageFormat{ext: ".jpg", mime: "image/jpeg"}, nil
|
||||
}
|
||||
if len(data) >= 8 && string(data[0:8]) == "\x89PNG\r\n\x1a\n" {
|
||||
return imageFormat{ext: ".png", mime: "image/png"}, nil
|
||||
}
|
||||
if len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WEBP" {
|
||||
return imageFormat{ext: ".webp", mime: "image/webp"}, nil
|
||||
}
|
||||
return imageFormat{}, errors.New("仅支持 JPEG / PNG / WebP")
|
||||
}
|
||||
|
||||
func decodeImageSize(data []byte, mime string) (w, h int, err error) {
|
||||
r := bytes.NewReader(data)
|
||||
var cfg image.Config
|
||||
switch mime {
|
||||
case "image/webp":
|
||||
cfg, err = webp.DecodeConfig(r)
|
||||
default:
|
||||
cfg, _, err = image.DecodeConfig(r)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, 0, errors.New("无法解析图片")
|
||||
}
|
||||
return cfg.Width, cfg.Height, nil
|
||||
}
|
||||
|
||||
// SaveImage 保存帖子插图:校验格式/大小/尺寸 → 落盘 → 写 attachments(kind=image)
|
||||
func (s *UploadService) SaveImage(userID uint, data []byte) (*model.Attachment, error) {
|
||||
if len(data) == 0 {
|
||||
return nil, errors.New("文件为空")
|
||||
}
|
||||
if len(data) > ImageMaxBytes {
|
||||
return nil, errors.New("图片不能超过 5MB")
|
||||
}
|
||||
format, err := detectImageFormat(data)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
w, h, err := decodeImageSize(data, format.mime)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if w < 1 || h < 1 {
|
||||
return nil, errors.New("无效的图片尺寸")
|
||||
}
|
||||
if w > ImageMaxDim || h > ImageMaxDim {
|
||||
return nil, errors.New("图片边长不能超过 4096px")
|
||||
}
|
||||
|
||||
nameBytes := make([]byte, 16)
|
||||
if _, err := rand.Read(nameBytes); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filename := hex.EncodeToString(nameBytes) + format.ext
|
||||
fullPath := filepath.Join(s.dir, "images", filename)
|
||||
if err := os.WriteFile(fullPath, data, 0o644); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
att := &model.Attachment{
|
||||
UserID: userID,
|
||||
Kind: model.AttachmentKindImage,
|
||||
URL: "/uploads/images/" + filename,
|
||||
MIME: format.mime,
|
||||
Size: len(data),
|
||||
Width: w,
|
||||
Height: h,
|
||||
}
|
||||
if err := s.db.Create(att).Error; err != nil {
|
||||
_ = os.Remove(fullPath)
|
||||
return nil, err
|
||||
}
|
||||
return att, nil
|
||||
}
|
||||
|
||||
// UseAvatar 选用一张【本人历史上传】的头像
|
||||
func (s *UploadService) UseAvatar(userID uint, url string) error {
|
||||
url = strings.TrimSpace(url)
|
||||
|
||||
Reference in New Issue
Block a user