Files
jiang13-bbs/backend/service/post.go
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

1195 lines
38 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"
"fmt"
"strings"
"time"
"github.com/freefire/jiang13-bbs/markdown"
"github.com/freefire/jiang13-bbs/model"
"gorm.io/gorm"
)
// 帖子操作错误
var (
ErrPostNotFound = errors.New("帖子不存在")
ErrPostForbidden = errors.New("无权限操作此帖子")
ErrPostDeleteMeta = errors.New("选「其他」时须填写删除理由")
ErrPostInvalidType = errors.New("无效的删除类型")
)
// PostService 帖子服务
type PostService struct {
db *gorm.DB
setting *SettingService
board *BoardService
devMode bool
}
func NewPostService(db *gorm.DB) *PostService {
return &PostService{db: db}
}
// WithSetting 注入站点设置(发帖冷静期可读配置);可链式调用
func (s *PostService) WithSetting(setting *SettingService) *PostService {
s.setting = setting
return s
}
// WithBoard 注入板块服务(发帖策略校验用);可链式调用
func (s *PostService) WithBoard(board *BoardService) *PostService {
s.board = board
return s
}
// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽
func (s *PostService) WithDevMode(dev bool) *PostService {
s.devMode = dev
return s
}
// 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").Preload("User.Badges.Badge").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").Preload("User.Badges.Badge").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 切换帖子加精;加精时向作者发放可配置的推荐奖励(每帖仅一次,自荐不发)
func (s *PostService) ToggleRecommend(id, operatorID 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
}
if newVal && operatorID != post.UserID {
s.grantRecommendReward(post)
}
return newVal, nil
}
// grantRecommendReward 发放推荐奖励(非关键路径,失败仅记日志不阻断)
func (s *PostService) grantRecommendReward(post model.Post) {
if s.setting == nil {
return
}
rules, err := s.setting.PointsRules()
if err != nil || rules.RecommendReward <= 0 {
return
}
_ = s.db.Transaction(func(tx *gorm.DB) error {
// 每帖仅一次:已存在同 reason/ref 的正入账则跳过(防止反复 toggle 重复加分)
var used int64
if err := tx.Model(&model.PointLedger{}).
Where("user_id = ? AND reason = ? AND ref_type = ? AND ref_id = ? AND delta > 0",
post.UserID, model.PointReasonRecommend, "post", post.ID).
Count(&used).Error; err != nil {
return err
}
if used > 0 {
return nil
}
RewardIfConfigured(tx, post.UserID, rules.RecommendReward, 0, model.PointReasonRecommend, "post", post.ID, "帖子被推荐")
return 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"`
// 软删 tombstone:列表不可见,详情专用页可读
Tombstone bool `json:"tombstone,omitempty"`
DeleteType string `json:"delete_type,omitempty"`
DeleteReason string `json:"delete_reason,omitempty"`
DeletedBy uint `json:"deleted_by,omitempty"`
StaffDeleted bool `json:"staff_deleted,omitempty"`
DeletedAt *time.Time `json:"deleted_at,omitempty"`
}
// CreatePostInput 发帖入参(content_access / access_points 由正文 [hide] 派生)
type CreatePostInput struct {
UserID uint
BoardID uint
Title string
Content string
Tags string
PostType string
TypeMeta string
Status string
AttachmentIDs []uint
}
// UpdatePostInput 编辑入参
type UpdatePostInput struct {
Title string
Content string
Tags string
TypeMeta *string
AttachmentIDs *[]uint // nil=不改附件;非 nil=替换列表
}
// GetByIDForViewer 获取帖子详情(带状态可见性 + 正文访问控制)。
// pwdUnlocked 为密码隐藏块已解锁下标(来自签名 cookie);可为 nil。
// 已软删帖返回 tombstone(无正文/附件);已硬删或不存在返回 ErrPostNotFound。
func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (*PostDetail, error) {
var post model.Post
err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, id).Error
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, ErrPostNotFound
}
// 默认 scope 找不到:尝试软删行 → tombstone
if err := s.db.Unscoped().Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, id).Error; err != nil {
return nil, ErrPostNotFound
}
if !post.DeletedAt.Valid {
return nil, ErrPostNotFound
}
return buildPostTombstone(&post), nil
}
if !visibleToPost(&post, viewerID, loadActor) {
return nil, ErrPostNotFound
}
// 惰性结算:悬赏过期退回 / 抽奖到期开奖
_ = s.settleExpiredBountyIfNeeded(&post)
_ = s.settleDueLotteryIfNeeded(&post)
_ = s.settleDuePollIfNeeded(&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)
sanitized, fullyLocked, hint := s.sanitizePostContent(&post, viewerID, loadActor, pwdUnlocked)
detail.Content = sanitized
detail.ContentLocked = fullyLocked
detail.AccessHint = hint
atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID)
detail.Attachments = atts
s.fillInteractState(detail, &post, viewerID, loadActor)
return detail, nil
}
func buildPostTombstone(post *model.Post) *PostDetail {
var deletedAt *time.Time
if post.DeletedAt.Valid {
t := post.DeletedAt.Time
deletedAt = &t
}
staffDeleted := post.DeletedBy != 0 && post.DeletedBy != post.UserID
return &PostDetail{
ID: post.ID,
BoardID: post.BoardID,
UserID: post.UserID,
Title: post.Title,
PostType: model.NormalizePostType(post.PostType),
Status: post.Status,
CreatedAt: post.CreatedAt,
UpdatedAt: post.UpdatedAt,
Board: post.Board,
User: post.User,
Attachments: []PostAttachmentDTO{},
Tombstone: true,
DeleteType: post.DeleteType,
DeleteReason: post.DeleteReason,
DeletedBy: post.DeletedBy,
StaffDeleted: staffDeleted,
DeletedAt: deletedAt,
}
}
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{},
}
}
// sanitizePostContent 按读者能力对正文 [hide] 块脱敏;fullyLocked 表示无可见正文。
func (s *PostService) sanitizePostContent(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) (content string, fullyLocked bool, hint string) {
caps := s.buildHideViewerCaps(post, viewerID, loadActor, pwdUnlocked)
sanitized, fully := markdown.SanitizeForViewer(post.Content, caps)
if !fully {
return sanitized, false, ""
}
access := model.NormalizeContentAccess(post.ContentAccess)
switch access {
case model.ContentAccessPoints:
return sanitized, true, "支付积分后可见隐藏内容"
case model.ContentAccessPassword:
return sanitized, true, "输入密码后可见隐藏内容"
case model.ContentAccessMixed:
return sanitized, true, "满足条件后可见隐藏内容"
case model.ContentAccessReply:
return sanitized, true, "回复本帖后可见隐藏内容"
case model.ContentAccessLogin:
return sanitized, true, "登录后可见隐藏内容"
default:
return sanitized, true, "正文暂不可见"
}
}
func (s *PostService) buildHideViewerCaps(post *model.Post, viewerID uint, loadActor func() *Actor, pwdUnlocked map[int]bool) markdown.ViewerCaps {
caps := markdown.ViewerCaps{PasswordUnlocked: pwdUnlocked}
if viewerID > 0 && post.UserID == viewerID {
caps.Bypass = true
caps.LoggedIn = true
return caps
}
if loadActor != nil && loadActor().CanModerateBoard(post.BoardID) {
caps.Bypass = true
caps.LoggedIn = true
return caps
}
if viewerID == 0 {
return caps
}
caps.LoggedIn = true
needReply := false
needPoints := false
blocks, err := markdown.ParseHideBlocks(post.Content)
if err == nil {
for _, b := range blocks {
if b.Kind == markdown.HideKindReply {
needReply = true
}
if b.Kind == markdown.HideKindPoints {
needPoints = true
}
}
} else {
access := model.NormalizeContentAccess(post.ContentAccess)
needReply = access == model.ContentAccessReply || access == model.ContentAccessMixed
needPoints = post.AccessPoints > 0 || access == model.ContentAccessPoints || access == model.ContentAccessMixed
}
if needReply {
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)
caps.HasReplied = n > 0
}
if needPoints {
var n int64
s.db.Model(&model.PostContentUnlock{}).
Where("post_id = ? AND user_id = ?", post.ID, viewerID).Count(&n)
caps.PointsPaid = n > 0
}
return caps
}
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
}
// UnlockByPassword 校验密码,返回匹配的隐藏块下标(调用方写 cookie)
func (s *PostService) UnlockByPassword(postID uint, password string) (matched []int, err error) {
var post model.Post
if err := s.db.Select("id", "content", "status").First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound
}
password = strings.TrimSpace(password)
if password == "" {
return nil, errors.New("请输入密码")
}
hit, err := markdown.MatchPasswordBlocks(post.Content, password)
if err != nil {
return nil, err
}
if len(hit) == 0 {
return nil, errors.New("密码错误")
}
return hit, nil
}
// UnlockContent 积分解锁正文隐藏块(一次支付解锁本帖全部积分块)
func (s *PostService) UnlockContent(userID, postID uint) (*PostDetail, error) {
var post model.Post
if err := s.db.Preload("Board").Preload("User").Preload("User.Badges.Badge").First(&post, postID).Error; err != nil {
return nil, ErrPostNotFound
}
derived, derr := markdown.DeriveAccessFromContent(post.Content)
if derr != nil {
return nil, derr
}
need := derived.Points
if need <= 0 {
need = post.AccessPoints
}
if need <= 0 {
return nil, errors.New("本文无需积分解锁")
}
if post.UserID == userID {
return s.detailAfterInteract(&post, userID)
}
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
}
return s.detailAfterInteract(&post, userID)
}
// Create 创建帖子。status 由 handler 按角色计算
func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
if err := NewOperations(s.db, nil).Filter(scope, text, in.UserID); err != nil {
return nil, err
}
}
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("无效的帖子类型")
}
if err := markdown.ValidateHideContent(content); err != nil {
return nil, err
}
derived, err := markdown.DeriveAccessFromContent(content)
if err != nil {
return nil, err
}
access := model.NormalizeContentAccess(derived.Access)
accessPts := derived.Points
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
if !s.isStaffUser(in.UserID) {
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 {
bm.EndsAt = resolveEndsAtOnPublish(bm.EndsAt, bm.ExpireDays, 7, []int{3, 7, 14, 30}, now)
bm.ExpireDays = 0
}
typeMeta, _ = encodeMeta(bm)
}
if postType == model.PostTypeLottery {
lm, _ := parseLotteryMeta(typeMeta)
if lm.WinnerIDs == nil {
lm.WinnerIDs = []uint{}
}
if lm.Prizes == nil {
lm.Prizes = []LotteryPrize{}
}
lm.Slots = lotterySlotsFromPrizes(lm.Prizes)
if status == model.ContentStatusPublished {
lm.EndsAt = resolveEndsAtOnPublish(lm.EndsAt, lm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
lm.ExpireDays = 0
}
typeMeta, _ = encodeMeta(lm)
}
if postType == model.PostTypePoll {
pm, _ := parsePollMeta(typeMeta)
if status == model.ContentStatusPublished {
pm.EndsAt = resolveEndsAtOnPublish(pm.EndsAt, pm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
pm.ExpireDays = 0
}
typeMeta, _ = encodeMeta(pm)
}
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 status == model.ContentStatusPublished && s.setting != nil {
rules, err := s.setting.PointsRules()
if err == nil {
RewardIfConfigured(tx, in.UserID, rules.PostReward, rules.PostDailyCap,
model.PointReasonPostReward, "post", post.ID, "发帖奖励:"+title)
}
}
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").Preload("User.Badges.Badge").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) {
for scope, text := range map[string]string{"title": in.Title, "body": in.Content} {
if err := NewOperations(s.db, nil).Filter(scope, text, userID); err != nil {
return nil, err
}
}
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("内容不能为空")
}
if err := markdown.ValidateHideContent(c); err != nil {
return nil, err
}
derived, err := markdown.DeriveAccessFromContent(c)
if err != nil {
return nil, err
}
updates["content"] = c
updates["content_access"] = model.NormalizeContentAccess(derived.Access)
updates["access_points"] = derived.Points
}
updates["tags"] = in.Tags
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
neu.EndsAt = old.EndsAt
neu.ClosedAt = old.ClosedAt
neu.ExpireDays = 0
meta, _ = encodeMeta(neu)
} else {
neu.Closed = old.Closed
neu.EndsAt = old.EndsAt
neu.ClosedAt = old.ClosedAt
neu.ExpireDays = 0
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
// 已有评论或已开奖:冻结奖品(名额由奖品合计推导)
locked := old.Drawn || post.CommentCount > 0
if locked {
if !lotteryPrizesEqual(neu.Prizes, old.Prizes) || neu.Slots != old.Slots {
return nil, ErrLotteryPrizesLocked
}
neu.Prizes = old.Prizes
neu.Slots = old.Slots
} else {
neu.Slots = lotterySlotsFromPrizes(neu.Prizes)
}
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
neu.SolvedAt = old.SolvedAt
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").Preload("User.Badges.Badge").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 新用户发帖冷静期(小时数取自站点设置,0=关闭);管理角色豁免
func (s *PostService) checkNewUserCooldown(userID uint) error {
hours := DefaultCooldownHours
if s.setting != nil {
if h, err := s.setting.PostCooldownHours(); err == nil {
hours = h
}
}
if hours <= 0 {
return nil
}
var user model.User
if err := s.db.First(&user, userID).Error; err != nil {
return err
}
if model.IsStaff(user.Role) { // 管理角色不受发帖冷静期限制
return nil
}
if time.Since(user.CreatedAt) < time.Duration(hours)*time.Hour {
return fmt.Errorf("新用户注册 %d 小时后才能发帖", hours)
}
return nil
}
// isStaffUser 作者是否管理角色(悬赏防刷等限制豁免用)
func (s *PostService) isStaffUser(userID uint) bool {
var role model.Role
if err := s.db.Model(&model.User{}).Where("id = ?", userID).
Select("role").Scan(&role).Error; err != nil {
return false
}
return model.IsStaff(role)
}
// DeletePostOpts 删帖选项:staff 软删必填类型与理由;作者自删传零值
type DeletePostOpts struct {
DeleteType string
DeleteReason string
}
// Delete 删除帖子(作者本人,或对该板块有审核权的管理成员)。
// staff 删除他人帖必须提供合法类型与非空理由;作者自删不写类型/理由。
// 返回 staffDeleted 与作者 ID,供 handler 发通知。
func (s *PostService) Delete(actor *Actor, postID, userID uint, opts DeletePostOpts) (staffDeleted bool, authorID uint, err error) {
var post model.Post
if err := s.db.First(&post, postID).Error; err != nil {
return false, 0, ErrPostNotFound
}
authorID = post.UserID
deleteType := ""
deleteReason := ""
if post.UserID == userID {
staffDeleted = false
} else if actor != nil && actor.CanModerateBoard(post.BoardID) {
var author model.User
if err := s.db.Select("role").First(&author, post.UserID).Error; err != nil {
return false, authorID, ErrPostNotFound
}
if !actor.CanModerateAuthor(author.Role) {
return false, authorID, ErrAuthorProtected
}
deleteType = strings.TrimSpace(opts.DeleteType)
deleteReason = strings.TrimSpace(opts.DeleteReason)
if !model.ValidPostDeleteType(deleteType) {
return false, authorID, ErrPostInvalidType
}
if deleteType == model.PostDeleteTypeOther {
if deleteReason == "" {
return false, authorID, ErrPostDeleteMeta
}
if len([]rune(deleteReason)) > 200 {
return false, authorID, errors.New("删除理由不能超过 200 字")
}
} else {
deleteReason = ""
}
staffDeleted = true
} else {
return false, authorID, ErrPostForbidden
}
updates := map[string]interface{}{
"delete_type": deleteType,
"delete_reason": deleteReason,
"deleted_by": userID,
}
if err := s.db.Model(&post).Updates(updates).Error; err != nil {
return false, authorID, err
}
if err := s.db.Delete(&post).Error; err != nil {
return false, authorID, err
}
return staffDeleted, authorID, nil
}