Files
jiang13-bbs/backend/service/post_interact.go
freefire b9ba8cb8c9 feat: 类型帖互动、积分经济与发帖体验
补齐问答采纳/重开、投票匿名与有票禁编、悬赏过期退回、抽奖回帖开奖,并接入积分账本与发帖附件可见性。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 03:17:07 +08:00

1083 lines
29 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 (
"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"`
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"`
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,
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,
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
}