package service import ( "crypto/rand" "encoding/json" "errors" "math/big" "strings" "time" "unicode/utf8" "github.com/freefire/jiang13-bbs/model" "gorm.io/gorm" ) // ---- TypeMeta 结构 ---- type QuestionMeta struct { Solved bool `json:"solved"` AcceptedCommentID uint `json:"accepted_comment_id"` } type PollMeta struct { Options []string `json:"options"` Multi bool `json:"multi"` Closed bool `json:"closed"` Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true } type BountyMeta struct { Points int `json:"points"` AcceptedCommentID uint `json:"accepted_comment_id"` Escrowed bool `json:"escrowed"` Refunded bool `json:"refunded"` Expired bool `json:"expired"` EndsAt string `json:"ends_at"` // RFC3339;空表示待发布时补写 ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清 } type LotteryMeta struct { Slots int `json:"slots"` Drawn bool `json:"drawn"` WinnerIDs []uint `json:"winner_ids"` Closed bool `json:"closed"` EndsAt string `json:"ends_at"` ExpireDays int `json:"expire_days,omitempty"` } var ( ErrPollClosed = errors.New("投票已结束") ErrAlreadyVoted = errors.New("你已投过票") ErrInvalidPollOpt = errors.New("无效的投票选项") ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置") ErrBountySettled = errors.New("悬赏已结算") ErrBountyNotEscrow = errors.New("悬赏未托管") ErrBountyExpired = errors.New("悬赏已过期") ErrLotteryDrawn = errors.New("已开奖") ErrLotteryClosed = errors.New("抽奖已截止") ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖") ErrQuestionNotType = errors.New("本文不是问答帖") ) func parseQuestionMeta(raw string) (*QuestionMeta, error) { if strings.TrimSpace(raw) == "" { return &QuestionMeta{}, nil } var m QuestionMeta if err := json.Unmarshal([]byte(raw), &m); err != nil { return nil, errors.New("问答配置无效") } return &m, nil } func parsePollMeta(raw string) (*PollMeta, error) { var wire struct { Options []string `json:"options"` Multi bool `json:"multi"` Closed bool `json:"closed"` Anonymous *bool `json:"anonymous"` } if err := json.Unmarshal([]byte(raw), &wire); err != nil { return nil, errors.New("投票配置无效") } anon := true // 缺省 / null → 匿名 if wire.Anonymous != nil { anon = *wire.Anonymous } return &PollMeta{ Options: wire.Options, Multi: wire.Multi, Closed: wire.Closed, Anonymous: anon, }, nil } func parseBountyMeta(raw string) (*BountyMeta, error) { var m BountyMeta if err := json.Unmarshal([]byte(raw), &m); err != nil { return nil, errors.New("悬赏配置无效") } return &m, nil } func parseLotteryMeta(raw string) (*LotteryMeta, error) { var m LotteryMeta if err := json.Unmarshal([]byte(raw), &m); err != nil { return nil, errors.New("抽奖配置无效") } return &m, nil } func encodeMeta(v any) (string, error) { b, err := json.Marshal(v) if err != nil { return "", err } return string(b), nil } func normalizeExpireDays(d, def int, allowed []int) int { if d <= 0 { return def } for _, a := range allowed { if d == a { return d } } return def } func endsAtFromDays(days int, from time.Time) string { return from.UTC().Add(time.Duration(days) * 24 * time.Hour).Format(time.RFC3339) } func parseEndsAt(s string) (time.Time, bool) { s = strings.TrimSpace(s) if s == "" { return time.Time{}, false } t, err := time.Parse(time.RFC3339, s) if err != nil { return time.Time{}, false } return t, true } // ComputeTypeStatus 列表徽章状态 func ComputeTypeStatus(postType, typeMeta string) string { pt := model.NormalizePostType(postType) switch pt { case model.PostTypeQuestion: m, err := parseQuestionMeta(typeMeta) if err != nil { return "unsolved" } if m.Solved { return "solved" } return "unsolved" case model.PostTypePoll: m, err := parsePollMeta(typeMeta) if err != nil { return "open" } if m.Closed { return "closed" } return "open" case model.PostTypeBounty: m, err := parseBountyMeta(typeMeta) if err != nil { return "open" } if m.AcceptedCommentID > 0 { return "solved" } if m.Expired || m.Refunded { return "expired" } return "open" case model.PostTypeLottery: m, err := parseLotteryMeta(typeMeta) if err != nil { return "open" } if m.Drawn { return "drawn" } if m.Closed { return "closed" } return "open" default: return "" } } // NormalizeAndValidateTypeMeta 校验并规范化 type_meta(发帖/编辑) func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) { postType = model.NormalizePostType(postType) meta = strings.TrimSpace(meta) switch postType { case model.PostTypeQuestion: m, err := parseQuestionMeta(meta) if err != nil { return "", err } // 新建默认未解决 return encodeMeta(m) case model.PostTypePoll: if meta == "" { return "", errors.New("请填写至少 2 个投票选项") } m, err := parsePollMeta(meta) if err != nil { return "", err } opts := make([]string, 0, len(m.Options)) seen := map[string]bool{} for _, o := range m.Options { o = strings.TrimSpace(o) if o == "" { continue } if utf8.RuneCountInString(o) > 64 { return "", errors.New("选项最多 64 字") } key := strings.ToLower(o) if seen[key] { continue } seen[key] = true opts = append(opts, o) } if len(opts) < 2 { return "", errors.New("至少需要 2 个投票选项") } if len(opts) > 10 { return "", errors.New("最多 10 个投票选项") } m.Options = opts return encodeMeta(m) case model.PostTypeBounty: if meta == "" { return "", errors.New("请设置悬赏积分") } m, err := parseBountyMeta(meta) if err != nil { return "", err } if m.Points < 1 { return "", errors.New("悬赏积分至少为 1") } if m.Points > 100000 { return "", errors.New("悬赏积分过高") } days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30}) m.ExpireDays = days // ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值 return encodeMeta(m) case model.PostTypeLottery: if meta == "" { return "", errors.New("请设置中奖名额") } m, err := parseLotteryMeta(meta) if err != nil { return "", err } if m.Slots < 1 { return "", errors.New("中奖名额至少为 1") } if m.Slots > 100 { return "", errors.New("中奖名额过多") } if m.WinnerIDs == nil { m.WinnerIDs = []uint{} } days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14}) m.ExpireDays = days return encodeMeta(m) default: return "", nil } } // EnsureDeadlineOnPublish 首次公开发布时补写 ends_at(悬赏/抽奖) func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (string, bool) { pt := model.NormalizePostType(postType) switch pt { case model.PostTypeBounty: m, err := parseBountyMeta(typeMeta) if err != nil { return typeMeta, false } if m.EndsAt != "" { return typeMeta, false } days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30}) m.ExpireDays = 0 m.EndsAt = endsAtFromDays(days, publishedAt) raw, err := encodeMeta(m) if err != nil { return typeMeta, false } return raw, true case model.PostTypeLottery: m, err := parseLotteryMeta(typeMeta) if err != nil { return typeMeta, false } if m.EndsAt != "" { return typeMeta, false } days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14}) m.ExpireDays = 0 m.EndsAt = endsAtFromDays(days, publishedAt) raw, err := encodeMeta(m) if err != nil { return typeMeta, false } return raw, true default: return typeMeta, false } } // ---- 详情附加状态 ---- type QuestionState struct { Solved bool `json:"solved"` AcceptedCommentID uint `json:"accepted_comment_id"` AcceptedFloor int `json:"accepted_floor"` CanAccept bool `json:"can_accept"` CanSolve bool `json:"can_solve"` CanReopen bool `json:"can_reopen"` } type PollVoter struct { ID uint `json:"id"` Nickname string `json:"nickname"` Username string `json:"username"` Avatar string `json:"avatar"` } type PollOptionStat struct { Index int `json:"index"` Text string `json:"text"` Votes int `json:"votes"` Percent int `json:"percent"` Voters []PollVoter `json:"voters,omitempty"` // 非匿名且可看结果时填充 } type PollState struct { Options []PollOptionStat `json:"options"` Multi bool `json:"multi"` Closed bool `json:"closed"` Anonymous bool `json:"anonymous"` TotalVotes int `json:"total_votes"` Voters int `json:"voters"` MyOptions []int `json:"my_options"` CanVote bool `json:"can_vote"` CanClose bool `json:"can_close"` CanEditOptions bool `json:"can_edit_options"` } type BountyState struct { Points int `json:"points"` AcceptedCommentID uint `json:"accepted_comment_id"` AcceptedFloor int `json:"accepted_floor"` Escrowed bool `json:"escrowed"` Refunded bool `json:"refunded"` Expired bool `json:"expired"` EndsAt string `json:"ends_at,omitempty"` Settled bool `json:"settled"` CanAccept bool `json:"can_accept"` CanRefund bool `json:"can_refund"` } type LotteryEntrant struct { ID uint `json:"id"` Nickname string `json:"nickname"` Username string `json:"username"` Avatar string `json:"avatar"` } type LotteryState struct { Slots int `json:"slots"` Drawn bool `json:"drawn"` Closed bool `json:"closed"` EndsAt string `json:"ends_at,omitempty"` EntryCount int `json:"entry_count"` // 回帖参与人数 Eligible bool `json:"eligible"` // 当前用户是否在奖池 CanDraw bool `json:"can_draw"` CanClose bool `json:"can_close"` Winners []LotteryEntrant `json:"winners"` } func (s *PostService) fillInteractState(detail *PostDetail, post *model.Post, viewerID uint, loadActor func() *Actor) { pt := model.NormalizePostType(post.PostType) isAuthor := viewerID > 0 && viewerID == post.UserID isMod := false if loadActor != nil { if a := loadActor(); a != nil { isMod = a.CanModerateBoard(post.BoardID) } } canManage := isAuthor || isMod switch pt { case model.PostTypeQuestion: detail.Question = s.buildQuestionState(post, canManage) case model.PostTypePoll: detail.Poll = s.buildPollState(post, viewerID, canManage) case model.PostTypeBounty: detail.Bounty = s.buildBountyState(post, canManage) case model.PostTypeLottery: detail.Lottery = s.buildLotteryState(post, viewerID, canManage) } } func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *QuestionState { m, err := parseQuestionMeta(post.TypeMeta) if err != nil || m == nil { m = &QuestionMeta{} } return &QuestionState{ Solved: m.Solved, AcceptedCommentID: m.AcceptedCommentID, AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID), CanAccept: canManage && !m.Solved, CanSolve: canManage && !m.Solved, CanReopen: canManage && m.Solved, } } func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState { m, err := parsePollMeta(post.TypeMeta) if err != nil || len(m.Options) == 0 { return nil } var votes []model.PostPollVote s.db.Where("post_id = ?", post.ID).Find(&votes) counts := make([]int, len(m.Options)) voterSet := map[uint]struct{}{} my := []int{} // optionIndex → userIDs(去重保序) optVoters := make([][]uint, len(m.Options)) seenOptUser := make([]map[uint]struct{}, len(m.Options)) for i := range seenOptUser { seenOptUser[i] = map[uint]struct{}{} } for _, v := range votes { if v.OptionIndex >= 0 && v.OptionIndex < len(counts) { counts[v.OptionIndex]++ if _, ok := seenOptUser[v.OptionIndex][v.UserID]; !ok { seenOptUser[v.OptionIndex][v.UserID] = struct{}{} optVoters[v.OptionIndex] = append(optVoters[v.OptionIndex], v.UserID) } } voterSet[v.UserID] = struct{}{} if viewerID > 0 && v.UserID == viewerID { my = append(my, v.OptionIndex) } } total := 0 for _, c := range counts { total += c } // 非匿名:已投票或已结束(或作者/版主)才下发投票人,避免未投票先窥名单 revealVoters := !m.Anonymous && (m.Closed || len(my) > 0 || canClose) userMap := map[uint]model.User{} if revealVoters { uidSet := map[uint]struct{}{} for _, ids := range optVoters { for _, id := range ids { uidSet[id] = struct{}{} } } if len(uidSet) > 0 { uids := make([]uint, 0, len(uidSet)) for id := range uidSet { uids = append(uids, id) } var users []model.User s.db.Select("id, username, nickname, avatar").Where("id IN ?", uids).Find(&users) for _, u := range users { userMap[u.ID] = u } } } opts := make([]PollOptionStat, len(m.Options)) for i, text := range m.Options { pct := 0 if total > 0 { pct = counts[i] * 100 / total } stat := PollOptionStat{Index: i, Text: text, Votes: counts[i], Percent: pct} if revealVoters { vs := make([]PollVoter, 0, len(optVoters[i])) for _, id := range optVoters[i] { if u, ok := userMap[id]; ok { vs = append(vs, PollVoter{ ID: u.ID, Nickname: u.Nickname, Username: u.Username, Avatar: u.Avatar, }) } } stat.Voters = vs } opts[i] = stat } voters := len(voterSet) return &PollState{ Options: opts, Multi: m.Multi, Closed: m.Closed, Anonymous: m.Anonymous, TotalVotes: total, Voters: voters, MyOptions: my, CanVote: viewerID > 0 && !m.Closed && len(my) == 0, CanClose: canClose && !m.Closed, CanEditOptions: voters == 0 && !m.Closed, } } func (s *PostService) buildBountyState(post *model.Post, canManage bool) *BountyState { m, err := parseBountyMeta(post.TypeMeta) if err != nil { return nil } settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired return &BountyState{ Points: m.Points, AcceptedCommentID: m.AcceptedCommentID, AcceptedFloor: FloorNumber(s.db, post.ID, m.AcceptedCommentID), Escrowed: m.Escrowed, Refunded: m.Refunded, Expired: m.Expired, EndsAt: m.EndsAt, Settled: settled, CanAccept: canManage && m.Escrowed && !settled && !m.Expired, CanRefund: canManage && m.Escrowed && !settled && !m.Expired, } } func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) { var ids []uint err := s.db.Model(&model.Comment{}). Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?", postID, model.ContentStatusPublished, authorID). Distinct("user_id"). Pluck("user_id", &ids).Error return ids, err } func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canManage bool) *LotteryState { m, err := parseLotteryMeta(post.TypeMeta) if err != nil { return nil } ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID) eligible := false for _, id := range ids { if viewerID > 0 && id == viewerID { eligible = true break } } winners := []LotteryEntrant{} if len(m.WinnerIDs) > 0 { var users []model.User s.db.Select("id, username, nickname, avatar").Where("id IN ?", m.WinnerIDs).Find(&users) umap := map[uint]model.User{} for _, u := range users { umap[u.ID] = u } for _, id := range m.WinnerIDs { if u, ok := umap[id]; ok { winners = append(winners, LotteryEntrant{ ID: u.ID, Nickname: u.Nickname, Username: u.Username, Avatar: u.Avatar, }) } } } return &LotteryState{ Slots: m.Slots, Drawn: m.Drawn, Closed: m.Closed, EndsAt: m.EndsAt, EntryCount: len(ids), Eligible: eligible, CanDraw: canManage && !m.Drawn, CanClose: canManage && !m.Drawn && !m.Closed, Winners: winners, } } // VotePoll 投票 func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostDetail, error) { var post model.Post if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil { return nil, ErrPostNotFound } if model.NormalizePostType(post.PostType) != model.PostTypePoll { return nil, errors.New("本文不是投票帖") } if post.Status != model.ContentStatusPublished { return nil, errors.New("帖子未公开,无法投票") } m, err := parsePollMeta(post.TypeMeta) if err != nil { return nil, err } if m.Closed { return nil, ErrPollClosed } if len(optionIndexes) == 0 { return nil, ErrInvalidPollOpt } if !m.Multi && len(optionIndexes) != 1 { return nil, errors.New("单选只能选一项") } if m.Multi && len(optionIndexes) > len(m.Options) { return nil, ErrInvalidPollOpt } seen := map[int]bool{} clean := make([]int, 0, len(optionIndexes)) for _, idx := range optionIndexes { if idx < 0 || idx >= len(m.Options) { return nil, ErrInvalidPollOpt } if seen[idx] { continue } seen[idx] = true clean = append(clean, idx) } err = s.db.Transaction(func(tx *gorm.DB) error { var n int64 if err := tx.Model(&model.PostPollVote{}). Where("post_id = ? AND user_id = ?", postID, userID).Count(&n).Error; err != nil { return err } if n > 0 { return ErrAlreadyVoted } for _, idx := range clean { if err := tx.Create(&model.PostPollVote{ PostID: postID, UserID: userID, OptionIndex: idx, }).Error; err != nil { return err } } return nil }) if err != nil { return nil, err } return s.detailAfterInteract(&post, userID) } // ClosePoll 结束投票 func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } if model.NormalizePostType(post.PostType) != model.PostTypePoll { return nil, errors.New("本文不是投票帖") } m, err := parsePollMeta(post.TypeMeta) if err != nil { return nil, err } m.Closed = true raw, err := encodeMeta(m) if err != nil { return nil, err } if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { return nil, err } post.TypeMeta = raw return s.detailAfterInteract(post, userID) } // AcceptQuestion 采纳问答答案 func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { return nil, ErrQuestionNotType } m, err := parseQuestionMeta(post.TypeMeta) if err != nil { return nil, err } if m.Solved { return nil, errors.New("问题已解决") } var cm model.Comment if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID { return nil, errors.New("评论不存在") } if cm.Status != model.ContentStatusPublished { return nil, errors.New("只能采纳已公开的评论") } if cm.UserID == post.UserID { return nil, errors.New("不能采纳自己的评论") } m.AcceptedCommentID = commentID m.Solved = true raw, err := encodeMeta(m) if err != nil { return nil, err } if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { return nil, err } post.TypeMeta = raw return s.detailAfterInteract(post, userID) } // SolveQuestion 手动标为已解决 func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { return nil, ErrQuestionNotType } m, err := parseQuestionMeta(post.TypeMeta) if err != nil { return nil, err } m.Solved = true raw, err := encodeMeta(m) if err != nil { return nil, err } if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { return nil, err } post.TypeMeta = raw return s.detailAfterInteract(post, userID) } // ReopenQuestion 重新打开 func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } if model.NormalizePostType(post.PostType) != model.PostTypeQuestion { return nil, ErrQuestionNotType } m := &QuestionMeta{Solved: false, AcceptedCommentID: 0} raw, err := encodeMeta(m) if err != nil { return nil, err } if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { return nil, err } post.TypeMeta = raw return s.detailAfterInteract(post, userID) } // AcceptBounty 采纳评论并发放悬赏 func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } _ = s.settleExpiredBountyIfNeeded(post) s.db.First(post, post.ID) if model.NormalizePostType(post.PostType) != model.PostTypeBounty { return nil, errors.New("本文不是悬赏帖") } m, err := parseBountyMeta(post.TypeMeta) if err != nil { return nil, err } if m.Expired { return nil, ErrBountyExpired } if !m.Escrowed { return nil, ErrBountyNotEscrow } if m.AcceptedCommentID > 0 || m.Refunded { return nil, ErrBountySettled } var cm model.Comment if err := s.db.First(&cm, commentID).Error; err != nil || cm.PostID != postID { return nil, errors.New("评论不存在") } if cm.Status != model.ContentStatusPublished { return nil, errors.New("只能采纳已公开的评论") } if cm.UserID == post.UserID { return nil, errors.New("不能采纳自己的评论") } err = s.db.Transaction(func(tx *gorm.DB) error { if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil { return err } m.AcceptedCommentID = commentID m.Escrowed = false raw, err := encodeMeta(m) if err != nil { return err } return tx.Model(post).Update("type_meta", raw).Error }) if err != nil { return nil, err } s.db.Preload("Board").Preload("User").First(post, post.ID) return s.detailAfterInteract(post, userID) } // RefundBounty 退回悬赏积分(未采纳时) func (s *PostService) RefundBounty(actor *Actor, userID, postID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } _ = s.settleExpiredBountyIfNeeded(post) s.db.First(post, post.ID) if model.NormalizePostType(post.PostType) != model.PostTypeBounty { return nil, errors.New("本文不是悬赏帖") } m, err := parseBountyMeta(post.TypeMeta) if err != nil { return nil, err } if m.Expired { return nil, ErrBountyExpired } if !m.Escrowed { return nil, ErrBountyNotEscrow } if m.AcceptedCommentID > 0 || m.Refunded { return nil, ErrBountySettled } err = s.db.Transaction(func(tx *gorm.DB) error { if _, err := CreditTx(tx, post.UserID, m.Points, model.PointReasonBountyRefund, "post", postID, "悬赏退回:"+post.Title); err != nil { return err } m.Refunded = true m.Escrowed = false raw, err := encodeMeta(m) if err != nil { return err } return tx.Model(post).Update("type_meta", raw).Error }) if err != nil { return nil, err } s.db.Preload("Board").Preload("User").First(post, post.ID) return s.detailAfterInteract(post, userID) } func (s *PostService) settleExpiredBountyIfNeeded(post *model.Post) error { if model.NormalizePostType(post.PostType) != model.PostTypeBounty { return nil } m, err := parseBountyMeta(post.TypeMeta) if err != nil { return nil } if !m.Escrowed || m.AcceptedCommentID > 0 || m.Refunded || m.Expired { return nil } ends, ok := parseEndsAt(m.EndsAt) if !ok || time.Now().UTC().Before(ends) { return nil } return s.db.Transaction(func(tx *gorm.DB) error { var fresh model.Post if err := tx.First(&fresh, post.ID).Error; err != nil { return err } fm, err := parseBountyMeta(fresh.TypeMeta) if err != nil { return err } if !fm.Escrowed || fm.AcceptedCommentID > 0 || fm.Refunded || fm.Expired { return nil } if _, err := CreditTx(tx, fresh.UserID, fm.Points, model.PointReasonBountyRefund, "post", fresh.ID, "悬赏过期退回:"+fresh.Title); err != nil { return err } fm.Expired = true fm.Refunded = true fm.Escrowed = false raw, err := encodeMeta(fm) if err != nil { return err } if err := tx.Model(&fresh).Update("type_meta", raw).Error; err != nil { return err } post.TypeMeta = raw return nil }) } // CloseLotteryEntries 截止抽奖(仍可手动开奖) func (s *PostService) CloseLotteryEntries(actor *Actor, userID, postID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } m, err := parseLotteryMeta(post.TypeMeta) if err != nil { return nil, err } if m.Drawn { return nil, ErrLotteryDrawn } m.Closed = true raw, err := encodeMeta(m) if err != nil { return nil, err } if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { return nil, err } post.TypeMeta = raw return s.detailAfterInteract(post, userID) } func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error { ids, err := s.lotteryEligibleIDs(post.ID, post.UserID) if err != nil { return err } nWin := m.Slots if nWin > len(ids) { nWin = len(ids) } if nWin > 0 { for i := len(ids) - 1; i > 0; i-- { jBig, err := rand.Int(rand.Reader, big.NewInt(int64(i+1))) if err != nil { return err } j := int(jBig.Int64()) ids[i], ids[j] = ids[j], ids[i] } m.WinnerIDs = ids[:nWin] } else { m.WinnerIDs = []uint{} } m.Drawn = true m.Closed = true raw, err := encodeMeta(m) if err != nil { return err } if err := s.db.Model(post).Update("type_meta", raw).Error; err != nil { return err } post.TypeMeta = raw return nil } // DrawLottery 开奖(回帖用户奖池) func (s *PostService) DrawLottery(actor *Actor, userID, postID uint) (*PostDetail, error) { post, err := s.loadManageablePost(actor, userID, postID) if err != nil { return nil, err } if model.NormalizePostType(post.PostType) != model.PostTypeLottery { return nil, errors.New("本文不是抽奖帖") } m, err := parseLotteryMeta(post.TypeMeta) if err != nil { return nil, err } if m.Drawn { return nil, ErrLotteryDrawn } if err := s.drawLotteryCore(post, m); err != nil { return nil, err } return s.detailAfterInteract(post, userID) } func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error { if model.NormalizePostType(post.PostType) != model.PostTypeLottery { return nil } m, err := parseLotteryMeta(post.TypeMeta) if err != nil || m.Drawn { return nil } ends, ok := parseEndsAt(m.EndsAt) if !ok || time.Now().UTC().Before(ends) { return nil } return s.drawLotteryCore(post, m) } // SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖 func (s *PostService) SettleDueTypedPosts(limit int) { if limit <= 0 { limit = 50 } var bounties []model.Post s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty). Order("id ASC").Limit(limit * 3).Find(&bounties) n := 0 for i := range bounties { if n >= limit { break } before := bounties[i].TypeMeta _ = s.settleExpiredBountyIfNeeded(&bounties[i]) if bounties[i].TypeMeta != before { n++ } } var lotteries []model.Post s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeLottery). Order("id ASC").Limit(limit * 3).Find(&lotteries) n = 0 for i := range lotteries { if n >= limit { break } before := lotteries[i].TypeMeta _ = s.settleDueLotteryIfNeeded(&lotteries[i]) if lotteries[i].TypeMeta != before { n++ } } } // StartTypedPostSettler 启动定时结算 func StartTypedPostSettler(s *PostService) { go func() { t := time.NewTicker(2 * time.Minute) defer t.Stop() for range t.C { s.SettleDueTypedPosts(50) } }() } func (s *PostService) loadManageablePost(actor *Actor, userID, postID uint) (*model.Post, error) { var post model.Post if err := s.db.Preload("Board").Preload("User").First(&post, postID).Error; err != nil { return nil, ErrPostNotFound } if post.UserID != userID && (actor == nil || !actor.CanModerateBoard(post.BoardID)) { return nil, ErrPostForbidden } return &post, nil } func (s *PostService) detailAfterInteract(post *model.Post, viewerID uint) (*PostDetail, error) { detail := buildPostDetail(post) locked, hint := s.evalContentAccess(post, viewerID, nil) detail.ContentLocked = locked detail.AccessHint = hint if locked { detail.Content = "" } atts, _ := s.listAttachmentDTOs(post.ID, viewerID, post.UserID) detail.Attachments = atts s.fillInteractState(detail, post, viewerID, nil) return detail, nil }