feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user