Files
jiang13-bbs/backend/service/post_interact.go

1167 lines
32 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
}
}
// ---- 详情附加状态 ----
// AcceptedAnswer 问答/悬赏已采纳答案(详情页正文下展示;与评论分页无关)
type AcceptedAnswer struct {
ID uint `json:"id"`
Floor int `json:"floor"`
Content string `json:"content"`
CreatedAt time.Time `json:"created_at"`
Deleted bool `json:"deleted,omitempty"`
User struct {
ID uint `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Avatar string `json:"avatar"`
} `json:"user"`
}
type QuestionState struct {
Solved bool `json:"solved"`
AcceptedCommentID uint `json:"accepted_comment_id"`
AcceptedFloor int `json:"accepted_floor"`
AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"`
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"`
AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"`
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"`
CommentID uint `json:"comment_id"` // 中奖楼层(该用户最早主评),每人仅一条
Floor int `json:"floor"`
}
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{}
}
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
return &QuestionState{
Solved: m.Solved,
AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: floor,
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
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
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
return &BountyState{
Points: m.Points,
AcceptedCommentID: m.AcceptedCommentID,
AcceptedFloor: floor,
AcceptedAnswer: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
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,
}
}
// loadAcceptedAnswer 加载已采纳评论快照(公开可见正文);软删则清空正文并标记 deleted。
func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *AcceptedAnswer {
if postID == 0 || commentID == 0 {
return nil
}
var c model.Comment
err := s.db.Unscoped().Preload("User").
Where("id = ? AND post_id = ?", commentID, postID).
First(&c).Error
if err != nil {
return nil
}
// 待审评论不当作公开答案资料
if c.Status != model.ContentStatusPublished && !c.DeletedAt.Valid {
return nil
}
ans := &AcceptedAnswer{
ID: c.ID,
Floor: floor,
CreatedAt: c.CreatedAt,
}
ans.User.ID = c.User.ID
ans.User.Username = c.User.Username
ans.User.Nickname = c.User.Nickname
ans.User.Avatar = c.User.Avatar
if c.DeletedAt.Valid {
ans.Deleted = true
ans.Content = ""
return ans
}
ans.Content = c.Content
return ans
}
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
}
// lotteryWinningFloor 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。
func (s *PostService) lotteryWinningFloor(postID, userID uint) (commentID uint, floor int) {
if postID == 0 || userID == 0 {
return 0, 0
}
var c model.Comment
err := s.db.Select("id").
Where("post_id = ? AND user_id = ? AND parent_id IS NULL AND status = ? AND deleted_at IS NULL",
postID, userID, model.ContentStatusPublished).
Order("created_at ASC, id ASC").
First(&c).Error
if err != nil {
return 0, 0
}
return c.ID, FloorNumber(s.db, postID, c.ID)
}
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 {
cid, floor := s.lotteryWinningFloor(post.ID, id)
winners = append(winners, LotteryEntrant{
ID: u.ID,
Nickname: u.Nickname,
Username: u.Username,
Avatar: u.Avatar,
CommentID: cid,
Floor: floor,
})
}
}
}
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
}