Files
jiang13-bbs/backend/service/post.go
freefire 833bcd33a1 chore: 去掉未上线前的旧兼容垫片,板块流收口到首页
鉴权只认 cookie、SEO 只走 Next、sort 与推荐对齐;删除 Bearer、Go sitemap、post_type 回填等冗余路径。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 05:04:47 +08:00

954 lines
29 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"errors"
"strings"
"time"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 帖子操作错误
var (
ErrPostNotFound = errors.New("帖子不存在")
ErrPostForbidden = errors.New("无权限操作此帖子")
)
// PostService 帖子服务
type PostService struct {
db *gorm.DB
}
func NewPostService(db *gorm.DB) *PostService {
return &PostService{db: db}
}
// PostListQuery 帖子列表查询参数
type PostListQuery struct {
BoardID uint
Page int
Size int
Sort string // latest | recommended | new
Keyword string // 搜索关键词
Recommended bool // 仅精华帖
ViewerID uint // 当前查看者(0=游客)
Actor *Actor // 查看者权限快照;nil 仅 published
}
// toPostListItems 将 Post 模型批量转为不含正文的列表项
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: 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,
})
}
return items
}
// LastReplyUser 最后回复人(仅列表展示所需字段)
type LastReplyUser struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
}
// LastReplyInfo 帖子最后一条已发布评论的摘要
type LastReplyInfo struct {
User LastReplyUser `json:"user"`
CreatedAt time.Time `json:"created_at"`
}
// 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"`
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 批量填充每帖最后一条已发布评论(发帖人+时间),
// 单条 SQL 取每帖最新评论,再批量取用户,避免 N+1
func (s *PostService) fillLastReply(items []PostListItem) {
if len(items) == 0 {
return
}
postIDs := make([]uint, 0, len(items))
for _, it := range items {
postIDs = append(postIDs, it.ID)
}
type latestRow struct {
PostID uint
UserID uint
CreatedAt time.Time
}
var rows []latestRow
if err := s.db.Raw(`
SELECT DISTINCT ON (post_id) post_id, user_id, created_at
FROM comments
WHERE post_id IN ? AND status = 'published' AND deleted_at IS NULL
ORDER BY post_id, created_at DESC`, postIDs).Scan(&rows).Error; err != nil || len(rows) == 0 {
return
}
userIDs := make([]uint, 0, len(rows))
seen := map[uint]bool{}
for _, r := range rows {
if !seen[r.UserID] {
seen[r.UserID] = true
userIDs = append(userIDs, r.UserID)
}
}
var users []model.User
if err := s.db.Select("id, username, nickname, avatar").Where("id IN ?", userIDs).Find(&users).Error; err != nil {
return
}
userMap := make(map[uint]model.User, len(users))
for _, u := range users {
userMap[u.ID] = u
}
replyMap := make(map[uint]latestRow, len(rows))
for _, r := range rows {
replyMap[r.PostID] = r
}
for i := range items {
r, ok := replyMap[items[i].ID]
if !ok {
continue
}
u, ok := userMap[r.UserID]
if !ok {
continue
}
items[i].LastReply = &LastReplyInfo{
User: LastReplyUser{ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar},
CreatedAt: r.CreatedAt,
}
}
}
// applyListVisibility 公开流可见性:published,或 pending 且(作者本人 / 该板可审者)
func applyListVisibility(db *gorm.DB, viewerID uint, actor *Actor) *gorm.DB {
if viewerID == 0 || actor == nil {
return db.Where("status = ?", model.ContentStatusPublished)
}
if model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin) {
return db.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
}
if actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 {
return db.Where(
"status = ? OR (status = ? AND (user_id = ? OR board_id IN ?))",
model.ContentStatusPublished, model.ContentStatusPending, viewerID, actor.BoardIDs,
)
}
return db.Where(
"status = ? OR (status = ? AND user_id = ?)",
model.ContentStatusPublished, model.ContentStatusPending, viewerID,
)
}
// List 获取帖子列表
func (s *PostService) List(q PostListQuery) ([]PostListItem, int64, error) {
if q.Page < 1 {
q.Page = 1
}
if q.Size < 1 || q.Size > 50 {
q.Size = 20
}
query := applyListVisibility(s.db.Model(&model.Post{}), q.ViewerID, q.Actor)
if q.BoardID > 0 {
query = query.Where("board_id = ?", q.BoardID)
}
if q.Keyword != "" {
kw := "%" + q.Keyword + "%"
query = query.Where("title ILIKE ? OR content ILIKE ? OR tags ILIKE ?", kw, kw, kw)
}
if q.Recommended {
query = query.Where("recommended = ?", true)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
offset := (q.Page - 1) * q.Size
// latest:按"最后活跃时间"排序——新帖与新评论都会浮到顶部。
// 取发帖时间与该帖最新一条已发布评论时间的较大值,相关子查询走 comments.post_id 索引
order := `pinned DESC, GREATEST(created_at, COALESCE((
SELECT MAX(c.created_at) FROM comments c
WHERE c.post_id = posts.id AND c.status = 'published' AND c.deleted_at IS NULL
), created_at)) DESC`
if q.Sort == "recommended" {
order = "pinned DESC, (like_count + comment_count * 2) DESC, created_at DESC"
}
// new:纯按发帖时间(新帖子)
if q.Sort == "new" {
order = "pinned DESC, created_at DESC"
}
if err := query.Order(order).Offset(offset).Limit(q.Size).
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
return nil, 0, err
}
items := toPostListItems(posts)
s.fillLastReply(items)
return items, total, nil
}
// ListByUser 获取指定用户发布的帖子。
// viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。
func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int) ([]PostListItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 20
}
query := s.db.Model(&model.Post{}).Where("user_id = ?", userID)
if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) {
query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending})
} else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 {
query = query.Where(
"status = ? OR (status = ? AND board_id IN ?)",
model.ContentStatusPublished, model.ContentStatusPending, actor.BoardIDs,
)
} else {
query = query.Where("status = ?", model.ContentStatusPublished)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var posts []model.Post
offset := (page - 1) * size
if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size).
Preload("Board").Preload("User").Find(&posts).Error; err != nil {
return nil, 0, err
}
items := toPostListItems(posts)
s.fillLastReply(items)
return items, total, nil
}
// GetAuthorID 获取帖子作者 ID(不增加浏览量)
func (s *PostService) GetAuthorID(postID uint) (uint, error) {
var post model.Post
if err := s.db.Select("user_id").First(&post, postID).Error; err != nil {
return 0, err
}
return post.UserID, nil
}
// GetBoardID 获取帖子所属板块 ID(不增加浏览量)
func (s *PostService) GetBoardID(postID uint) (uint, error) {
var post model.Post
if err := s.db.Select("board_id").First(&post, postID).Error; err != nil {
return 0, err
}
return post.BoardID, nil
}
// CountByUser 统计用户发帖数
func (s *PostService) CountByUser(userID uint) (int64, error) {
var total int64
err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error
return total, err
}
// TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) TogglePin(id uint) (int, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return 0, err
}
var newPinned int
if post.Pinned > 0 {
newPinned = 0
} else {
newPinned = 1
}
result := s.db.Model(&post).Update("pinned", newPinned)
if result.Error != nil {
return 0, result.Error
}
return newPinned, nil
}
// ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限)
func (s *PostService) ToggleRecommend(id uint) (bool, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return false, err
}
newVal := !post.Recommended
result := s.db.Model(&post).Update("recommended", newVal)
if result.Error != nil {
return false, result.Error
}
return newVal, nil
}
// visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。
// loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB
func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool {
if post.Status == model.ContentStatusPublished {
return true
}
if viewerID > 0 && post.UserID == viewerID {
return true
}
if loadActor == nil {
return false
}
return loadActor().CanModerateBoard(post.BoardID)
}
// 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
}
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"))
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 校验帖子对当前访问者可见(评论列表等场景复用,不增加浏览量)
func (s *PostService) EnsurePostVisible(postID, viewerID uint, loadActor func() *Actor) error {
var post model.Post
if err := s.db.Select("id", "user_id", "board_id", "status").First(&post, postID).Error; err != nil {
return ErrPostNotFound
}
if !visibleToPost(&post, viewerID, loadActor) {
return ErrPostNotFound
}
return 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
}
if time.Since(user.CreatedAt) < 24*time.Hour {
return errors.New("新用户注册 24 小时后才能发帖")
}
return 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
}
if err := s.db.Delete(&post).Error; err != nil {
return err
}
return nil
}