Files
jiang13-bbs/backend/service/post.go
freefire 5270fee2b7 feat: 评论/帖子编辑历史与编辑标记,内容锁定
- 新增 smartDiff 差异计算与 EditHistoryModal 编辑历史弹窗,替换原 CommentEditHistoryModal
- 新增 PostEditedMark 编辑标记展示
- 新增内容锁定机制(content_lock)防止并发编辑冲突
- 审核与通知服务适配
2026-09-25 23:41:45 +08:00

1361 lines
44 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("无效的删除类型")
ErrPostLocked = errors.New("帖子已被锁定,无法编辑")
ErrPostEditLocked = errors.New("帖子已超过可编辑时限,无法编辑")
)
// editLocked 内容是否已被自动编辑锁定;管理团队豁免;hours<=0 表示不启用
func editLocked(createdAt time.Time, isStaff bool, hours int, now time.Time) bool {
return !isStaff && hours > 0 && now.Sub(createdAt) > time.Duration(hours)*time.Hour
}
// postEditLockHours 帖子可编辑时长(setting 未注入或读取出错时=0 关闭)
func postEditLockHours(setting *SettingService) int {
if setting == nil {
return 0
}
hours, err := setting.PostEditLockHours()
if err != nil {
return 0
}
return hours
}
// 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
}
// UpdateColumn:置顶非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("pinned", newPinned)
if result.Error != nil {
return 0, result.Error
}
return newPinned, nil
}
// ToggleLock 切换帖子手动锁定状态(仅管理员可操作,由 handler 校验权限)。
// 锁定后普通用户不可编辑帖子、不可回复;staff 豁免。
func (s *PostService) ToggleLock(id uint) (bool, error) {
var post model.Post
if err := s.db.First(&post, id).Error; err != nil {
return false, err
}
newVal := !post.Locked
// UpdateColumn:锁定非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("locked", newVal)
if result.Error != nil {
return false, result.Error
}
return newVal, 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
// UpdateColumn:加精非内容编辑,不刷新 updated_at
result := s.db.Model(&post).UpdateColumn("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"`
Locked bool `json:"locked"` // 管理员手动锁定:普通用户不可编辑/回复(staff 豁免)
Status string `json:"status"`
LikeCount int `json:"like_count"`
ViewCount int `json:"view_count"`
CommentCount int `json:"comment_count"`
Liked bool `json:"liked"`
Edited bool `json:"edited"` // 是否存在编辑历史快照(决定"更新于/编辑历史"入口展示)
// 最后一条已发布评论时间;null=无回复(旧帖判定回落 created_at)
LastReplyAt *time.Time `json:"last_reply_at,omitempty"`
// 旧帖回复确认提示:按查看者实时计算,命中才返回;前端存在即弹确认框
NecroReply *NecroReplyHint `json:"necro_reply,omitempty"`
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"`
}
// NecroReplyHint 旧帖回复确认提示(详情接口按查看者实时计算;命中才返回)
type NecroReplyHint struct {
AfterHours int `json:"after_hours"`
Penalty int `json:"penalty"`
}
// 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)
detail.Edited = s.hasEditHistory(post.ID)
// 最后一条已发布评论时间(旧帖判定基准;无回复为 nil,调用方回落 created_at)
var lastReplyAt *time.Time
if err := s.db.Raw(`SELECT MAX(created_at) FROM comments WHERE post_id = ? AND status = 'published' AND deleted_at IS NULL`, post.ID).Scan(&lastReplyAt).Error; err == nil {
detail.LastReplyAt = lastReplyAt
}
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, Locked: post.Locked, 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
}
editorIsStaff := model.IsStaff(actor.Role)
// 手动锁定:仅管理团队可编辑
if post.Locked && !editorIsStaff {
return nil, ErrPostLocked
}
// 自动编辑锁定:非管理团队的作者超时不可编辑;管理成员(版主代编等)豁免
if post.UserID == userID && editLocked(post.CreatedAt, editorIsStaff, postEditLockHours(s.setting), time.Now()) {
return nil, ErrPostEditLocked
}
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 {
// 仅标题/正文实际变化时留修订快照(tags/type_meta/附件变更不留痕)
newTitle, _ := updates["title"].(string)
if newTitle == "" {
newTitle = post.Title
}
newContent, _ := updates["content"].(string)
if newContent == "" {
newContent = post.Content
}
if newTitle != post.Title || newContent != post.Content {
hist := model.PostEditHistory{
PostID: post.ID,
EditorID: userID, // 执行编辑者(可能是版主代编)
OldTitle: post.Title,
OldContent: post.Content,
}
if err := tx.Create(&hist).Error; err != nil {
return err
}
}
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
}
// PostEditHistoryItem 登录可见的帖子修订记录
type PostEditHistoryItem struct {
ID uint `json:"id"`
Editor CommentActorBrief `json:"editor"`
OldTitle string `json:"old_title"`
OldContent string `json:"old_content"`
CreatedAt time.Time `json:"created_at"`
}
// hasEditHistory 帖子是否存在修订快照;updated_at 会被置顶/计数等写操作推动,不可作为编辑依据
func (s *PostService) hasEditHistory(postID uint) bool {
var n int64
s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID).Limit(1).Count(&n)
return n > 0
}
// ListEditHistory 帖子编辑历史(登录即可见;含软删帖子;按创建时间倒序分页)
func (s *PostService) ListEditHistory(actor *Actor, postID uint, page, size int) ([]PostEditHistoryItem, int64, error) {
if page < 1 {
page = 1
}
if size < 1 || size > 50 {
size = 10
}
var post model.Post
if err := s.db.Unscoped().Select("id").First(&post, postID).Error; err != nil {
return nil, 0, ErrPostNotFound
}
if actor == nil {
return nil, 0, ErrPostForbidden
}
q := s.db.Model(&model.PostEditHistory{}).Where("post_id = ?", postID)
var total int64
if err := q.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.PostEditHistory
if err := s.db.Where("post_id = ?", postID).
Order("created_at DESC").
Offset((page - 1) * size).Limit(size).
Find(&rows).Error; err != nil {
return nil, 0, err
}
editorIDs := make([]uint, 0, len(rows))
seen := map[uint]struct{}{}
for _, r := range rows {
if _, ok := seen[r.EditorID]; ok {
continue
}
seen[r.EditorID] = struct{}{}
editorIDs = append(editorIDs, r.EditorID)
}
editors := map[uint]CommentActorBrief{}
if len(editorIDs) > 0 {
var users []model.User
s.db.Select("id", "username", "nickname", "avatar").Where("id IN ?", editorIDs).Find(&users)
for _, u := range users {
editors[u.ID] = CommentActorBrief{
ID: u.ID, Username: u.Username, Nickname: u.Nickname, Avatar: u.Avatar,
}
}
}
items := make([]PostEditHistoryItem, 0, len(rows))
for _, r := range rows {
ed := editors[r.EditorID]
if ed.ID == 0 {
ed = CommentActorBrief{ID: r.EditorID, Username: "已注销", Nickname: "已注销"}
}
items = append(items, PostEditHistoryItem{
ID: r.ID,
Editor: ed,
OldTitle: r.OldTitle,
OldContent: r.OldContent,
CreatedAt: r.CreatedAt,
})
}
return items, total, 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,
}
// UpdateColumns:删除元数据非内容编辑,不刷新 updated_at(详情页「更新于」由该字段推导)
if err := s.db.Model(&post).UpdateColumns(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
}