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").Find(&posts).Error; err != nil { return nil, 0, err } items := toPostListItems(posts) s.fillLastReply(items) return items, total, nil } // ListByUser 获取指定用户发布的帖子。 // viewer 为本人或全站可审者时并入 pending;板块管理员仅并入其授权板的 pending。 func (s *PostService) ListByUser(userID, viewerID uint, actor *Actor, page, size int) ([]PostListItem, int64, error) { if page < 1 { page = 1 } if size < 1 || size > 50 { size = 20 } query := s.db.Model(&model.Post{}).Where("user_id = ?", userID) if viewerID == userID || (actor != nil && model.RoleLevel(actor.Role) >= model.RoleLevel(model.RoleAdmin)) { query = query.Where("status IN ?", []string{model.ContentStatusPublished, model.ContentStatusPending}) } else if actor != nil && actor.Role == model.RoleBoardAdmin && len(actor.BoardIDs) > 0 { query = query.Where( "status = ? OR (status = ? AND board_id IN ?)", model.ContentStatusPublished, model.ContentStatusPending, actor.BoardIDs, ) } else { query = query.Where("status = ?", model.ContentStatusPublished) } var total int64 if err := query.Count(&total).Error; err != nil { return nil, 0, err } var posts []model.Post offset := (page - 1) * size if err := query.Order("pinned DESC, created_at DESC").Offset(offset).Limit(size). Preload("Board").Preload("User").Find(&posts).Error; err != nil { return nil, 0, err } items := toPostListItems(posts) s.fillLastReply(items) return items, total, nil } // GetAuthorID 获取帖子作者 ID(不增加浏览量) func (s *PostService) GetAuthorID(postID uint) (uint, error) { var post model.Post if err := s.db.Select("user_id").First(&post, postID).Error; err != nil { return 0, err } return post.UserID, nil } // GetBoardID 获取帖子所属板块 ID(不增加浏览量) func (s *PostService) GetBoardID(postID uint) (uint, error) { var post model.Post if err := s.db.Select("board_id").First(&post, postID).Error; err != nil { return 0, err } return post.BoardID, nil } // CountByUser 统计用户发帖数 func (s *PostService) CountByUser(userID uint) (int64, error) { var total int64 err := s.db.Model(&model.Post{}).Where("user_id = ? AND status = ?", userID, model.ContentStatusPublished).Count(&total).Error return total, err } // TogglePin 切换置顶状态(仅管理员可操作,由 handler 校验权限) func (s *PostService) TogglePin(id uint) (int, error) { var post model.Post if err := s.db.First(&post, id).Error; err != nil { return 0, err } var newPinned int if post.Pinned > 0 { newPinned = 0 } else { newPinned = 1 } result := s.db.Model(&post).Update("pinned", newPinned) if result.Error != nil { return 0, result.Error } return newPinned, nil } // ToggleRecommend 切换推荐状态(仅管理员可操作,由 handler 校验权限) func (s *PostService) ToggleRecommend(id uint) (bool, error) { var post model.Post if err := s.db.First(&post, id).Error; err != nil { return false, err } newVal := !post.Recommended result := s.db.Model(&post).Update("recommended", newVal) if result.Error != nil { return false, result.Error } return newVal, nil } // visibleTo 非已发布帖子仅作者本人或对该板块有审核权的管理成员可见。 // loadActor 为懒加载回调:仅访问非已发布帖且访问者非作者时才触发,避免常规浏览多查 DB func visibleToPost(post *model.Post, viewerID uint, loadActor func() *Actor) bool { if post.Status == model.ContentStatusPublished { return true } if viewerID > 0 && post.UserID == viewerID { return true } if loadActor == nil { return false } return loadActor().CanModerateBoard(post.BoardID) } // PostAttachmentDTO 附件对外字段 type PostAttachmentDTO struct { ID uint `json:"id"` Name string `json:"name"` Size int `json:"size"` MIME string `json:"mime"` PricePoints int `json:"price_points"` DownloadCount int `json:"download_count"` Unlocked bool `json:"unlocked"` // 当前用户是否可直接下载(免费/已购/作者) } // PostDetail 帖子详情(含可见性裁剪与附件) type PostDetail struct { ID uint `json:"id"` BoardID uint `json:"board_id"` UserID uint `json:"user_id"` Title string `json:"title"` Content string `json:"content"` Tags string `json:"tags"` PostType string `json:"post_type"` ContentAccess string `json:"content_access"` AccessPoints int `json:"access_points"` TypeMeta string `json:"type_meta"` TypeStatus string `json:"type_status,omitempty"` Pinned int `json:"pinned"` Recommended bool `json:"recommended"` Status string `json:"status"` LikeCount int `json:"like_count"` ViewCount int `json:"view_count"` CommentCount int `json:"comment_count"` Liked bool `json:"liked"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Board model.Board `json:"board"` User model.User `json:"user"` ContentLocked bool `json:"content_locked"` AccessHint string `json:"access_hint,omitempty"` Attachments []PostAttachmentDTO `json:"attachments"` Question *QuestionState `json:"question,omitempty"` Poll *PollState `json:"poll,omitempty"` Bounty *BountyState `json:"bounty,omitempty"` Lottery *LotteryState `json:"lottery,omitempty"` // 软删 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").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").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").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 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) { 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").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 }