1517 lines
42 KiB
Go
1517 lines
42 KiB
Go
package service
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"encoding/json"
|
||
"errors"
|
||
"math/big"
|
||
"net"
|
||
"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"`
|
||
SolvedAt string `json:"solved_at,omitempty"` // 解决时刻 RFC3339
|
||
}
|
||
|
||
type PollMeta struct {
|
||
Options []string `json:"options"`
|
||
Multi bool `json:"multi"`
|
||
Closed bool `json:"closed"`
|
||
Anonymous bool `json:"anonymous"` // 默认 true;缺省字段在 parse 时补 true
|
||
EndsAt string `json:"ends_at,omitempty"`
|
||
ClosedAt string `json:"closed_at,omitempty"` // 实际结束时刻;提前结束时与 ends_at 可能不同
|
||
ExpireDays int `json:"expire_days,omitempty"`
|
||
}
|
||
|
||
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 LotteryPrize struct {
|
||
Name string `json:"name"`
|
||
Count int `json:"count"`
|
||
}
|
||
|
||
type LotteryMeta struct {
|
||
Prizes []LotteryPrize `json:"prizes"`
|
||
Slots int `json:"slots"` // 由 prizes 数量合计,仅便于展示/校验
|
||
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("悬赏未托管")
|
||
ErrLotteryDrawn = errors.New("已开奖")
|
||
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
|
||
ErrLotteryPrizesLocked = 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"`
|
||
EndsAt string `json:"ends_at"`
|
||
ClosedAt string `json:"closed_at"`
|
||
ExpireDays int `json:"expire_days"`
|
||
}
|
||
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,
|
||
EndsAt: strings.TrimSpace(wire.EndsAt),
|
||
ClosedAt: strings.TrimSpace(wire.ClosedAt),
|
||
ExpireDays: wire.ExpireDays,
|
||
}, 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("抽奖配置无效")
|
||
}
|
||
m.Slots = lotterySlotsFromPrizes(m.Prizes)
|
||
return &m, nil
|
||
}
|
||
|
||
func lotterySlotsFromPrizes(prizes []LotteryPrize) int {
|
||
n := 0
|
||
for _, p := range prizes {
|
||
if p.Count > 0 {
|
||
n += p.Count
|
||
}
|
||
}
|
||
return n
|
||
}
|
||
|
||
func lotteryPrizesEqual(a, b []LotteryPrize) bool {
|
||
if len(a) != len(b) {
|
||
return false
|
||
}
|
||
for i := range a {
|
||
if a[i].Name != b[i].Name || a[i].Count != b[i].Count {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// expandLotteryPrizeNames 按奖项顺序展开为与名额等长的名称列表(开奖结果按序对应)
|
||
func expandLotteryPrizeNames(prizes []LotteryPrize) []string {
|
||
out := make([]string, 0, lotterySlotsFromPrizes(prizes))
|
||
for _, p := range prizes {
|
||
for i := 0; i < p.Count; i++ {
|
||
out = append(out, p.Name)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
// normalizeOptionalEndsAt 校验自定义截止时刻:至少晚于现在 1 小时,最远 90 天。空串表示未自定义。
|
||
func normalizeOptionalEndsAt(endsAt string) (string, error) {
|
||
endsAt = strings.TrimSpace(endsAt)
|
||
if endsAt == "" {
|
||
return "", nil
|
||
}
|
||
t, ok := parseEndsAt(endsAt)
|
||
if !ok {
|
||
return "", errors.New("截止时间格式无效")
|
||
}
|
||
now := time.Now().UTC()
|
||
if !t.After(now.Add(time.Hour)) {
|
||
return "", errors.New("截止时间至少晚于当前 1 小时")
|
||
}
|
||
if t.After(now.Add(90 * 24 * time.Hour)) {
|
||
return "", errors.New("截止时间最远 90 天")
|
||
}
|
||
return t.UTC().Format(time.RFC3339), nil
|
||
}
|
||
|
||
// resolveEndsAtOnPublish 发布时确定 ends_at:已有自定义时刻则保留,否则按天数推算。
|
||
func resolveEndsAtOnPublish(existing string, expireDays, def int, allowed []int, from time.Time) string {
|
||
if t, ok := parseEndsAt(existing); ok {
|
||
return t.UTC().Format(time.RFC3339)
|
||
}
|
||
days := normalizeExpireDays(expireDays, def, allowed)
|
||
return endsAtFromDays(days, from)
|
||
}
|
||
|
||
// 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 {
|
||
return "expired"
|
||
}
|
||
if 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
|
||
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||
return "", err
|
||
} else if ends != "" {
|
||
m.EndsAt = ends
|
||
m.ExpireDays = 0
|
||
} else {
|
||
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||
}
|
||
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("悬赏积分过高")
|
||
}
|
||
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||
return "", err
|
||
} else if ends != "" {
|
||
m.EndsAt = ends
|
||
m.ExpireDays = 0
|
||
} else {
|
||
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
|
||
}
|
||
// ends_at 若仍空,在 Create/Approve 时按发布时间写入
|
||
return encodeMeta(m)
|
||
case model.PostTypeLottery:
|
||
if meta == "" {
|
||
return "", errors.New("请设置奖品")
|
||
}
|
||
m, err := parseLotteryMeta(meta)
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
prizes := make([]LotteryPrize, 0, len(m.Prizes))
|
||
for _, p := range m.Prizes {
|
||
name := strings.TrimSpace(p.Name)
|
||
if name == "" {
|
||
return "", errors.New("请填写奖品名称")
|
||
}
|
||
if utf8.RuneCountInString(name) > 32 {
|
||
return "", errors.New("奖品名称最多 32 字")
|
||
}
|
||
if p.Count < 1 {
|
||
return "", errors.New("请填写每个奖品的数量")
|
||
}
|
||
if p.Count > 100 {
|
||
return "", errors.New("单个奖品数量过多")
|
||
}
|
||
prizes = append(prizes, LotteryPrize{Name: name, Count: p.Count})
|
||
}
|
||
if len(prizes) < 1 {
|
||
return "", errors.New("请至少添加一种奖品")
|
||
}
|
||
if len(prizes) > 10 {
|
||
return "", errors.New("最多 10 种奖品")
|
||
}
|
||
total := lotterySlotsFromPrizes(prizes)
|
||
if total < 1 {
|
||
return "", errors.New("奖品总名额至少为 1")
|
||
}
|
||
if total > 100 {
|
||
return "", errors.New("奖品总名额过多")
|
||
}
|
||
m.Prizes = prizes
|
||
m.Slots = total
|
||
if m.WinnerIDs == nil {
|
||
m.WinnerIDs = []uint{}
|
||
}
|
||
if ends, err := normalizeOptionalEndsAt(m.EndsAt); err != nil {
|
||
return "", err
|
||
} else if ends != "" {
|
||
m.EndsAt = ends
|
||
m.ExpireDays = 0
|
||
} else {
|
||
m.ExpireDays = normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
|
||
}
|
||
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.PostTypePoll:
|
||
m, err := parsePollMeta(typeMeta)
|
||
if err != nil {
|
||
return typeMeta, false
|
||
}
|
||
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||
return typeMeta, false
|
||
}
|
||
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
|
||
m.ExpireDays = 0
|
||
raw, err := encodeMeta(m)
|
||
if err != nil {
|
||
return typeMeta, false
|
||
}
|
||
return raw, true
|
||
case model.PostTypeBounty:
|
||
m, err := parseBountyMeta(typeMeta)
|
||
if err != nil {
|
||
return typeMeta, false
|
||
}
|
||
if _, ok := parseEndsAt(m.EndsAt); ok {
|
||
return typeMeta, false
|
||
}
|
||
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{3, 7, 14, 30}, publishedAt)
|
||
m.ExpireDays = 0
|
||
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 _, ok := parseEndsAt(m.EndsAt); ok {
|
||
return typeMeta, false
|
||
}
|
||
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
|
||
m.ExpireDays = 0
|
||
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 *CommentActorBrief `json:"user,omitempty"` // 管理删对公众省略
|
||
}
|
||
|
||
type QuestionState struct {
|
||
Solved bool `json:"solved"`
|
||
AcceptedCommentID uint `json:"accepted_comment_id"`
|
||
AcceptedFloor int `json:"accepted_floor"`
|
||
AcceptedAnswer *AcceptedAnswer `json:"accepted_answer,omitempty"`
|
||
SolvedAt string `json:"solved_at,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"`
|
||
EndsAt string `json:"ends_at,omitempty"`
|
||
ClosedAt string `json:"closed_at,omitempty"`
|
||
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"`
|
||
}
|
||
|
||
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"`
|
||
PrizeName string `json:"prize_name,omitempty"`
|
||
}
|
||
|
||
type LotteryState struct {
|
||
Slots int `json:"slots"`
|
||
Prizes []LotteryPrize `json:"prizes"`
|
||
Drawn bool `json:"drawn"`
|
||
Closed bool `json:"closed"`
|
||
EndsAt string `json:"ends_at,omitempty"`
|
||
EntryCount int `json:"entry_count"`
|
||
Eligible bool `json:"eligible"`
|
||
CooldownHours int `json:"cooldown_hours"`
|
||
CanDraw bool `json:"can_draw"`
|
||
CanEditPrizes bool `json:"can_edit_prizes"`
|
||
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, viewerID, canManage, isMod)
|
||
case model.PostTypePoll:
|
||
detail.Poll = s.buildPollState(post, viewerID, canManage)
|
||
case model.PostTypeBounty:
|
||
detail.Bounty = s.buildBountyState(post, viewerID, canManage, isMod)
|
||
case model.PostTypeLottery:
|
||
detail.Lottery = s.buildLotteryState(post, viewerID, canManage)
|
||
}
|
||
}
|
||
|
||
func (s *PostService) buildQuestionState(post *model.Post, viewerID uint, canManage, isMod bool) *QuestionState {
|
||
m, err := parseQuestionMeta(post.TypeMeta)
|
||
if err != nil || m == nil {
|
||
m = &QuestionMeta{}
|
||
}
|
||
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
|
||
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor, viewerID, isMod)
|
||
return &QuestionState{
|
||
Solved: m.Solved,
|
||
AcceptedCommentID: m.AcceptedCommentID,
|
||
AcceptedFloor: floor,
|
||
AcceptedAnswer: answer,
|
||
SolvedAt: m.SolvedAt,
|
||
CanAccept: canManage && !m.Solved,
|
||
CanSolve: canManage && !m.Solved,
|
||
// 已采纳答案后不可重新打开;仅「手动标已解决」可撤回
|
||
CanReopen: canManage && m.Solved && m.AcceptedCommentID == 0,
|
||
}
|
||
}
|
||
|
||
func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose bool) *PollState {
|
||
_ = s.settleDuePollIfNeeded(post)
|
||
s.db.Select("id", "type_meta", "post_type").First(post, post.ID)
|
||
|
||
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,
|
||
EndsAt: m.EndsAt,
|
||
ClosedAt: m.ClosedAt,
|
||
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, viewerID uint, canManage, isMod bool) *BountyState {
|
||
m, err := parseBountyMeta(post.TypeMeta)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
accepted := m.AcceptedCommentID > 0
|
||
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, viewerID, isMod),
|
||
Escrowed: m.Escrowed,
|
||
Refunded: m.Refunded,
|
||
Expired: m.Expired,
|
||
EndsAt: m.EndsAt,
|
||
Settled: accepted,
|
||
// 托管中可采纳;到期自动退回后仍可从余额再扣支付(不可撤回)
|
||
CanAccept: canManage && !accepted && (m.Escrowed || m.Refunded),
|
||
}
|
||
}
|
||
|
||
// loadAcceptedAnswer 加载已采纳评论快照(公开可见正文);软删则清空正文并标记 deleted。
|
||
// 管理删:公众不返回评论者身份(与评论树 toCommentNode 一致)。
|
||
func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int, viewerID uint, isMod bool) *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,
|
||
}
|
||
staff := c.DeletedAt.Valid && c.DeletedBy != 0 && c.DeletedBy != c.UserID
|
||
hideAuthor := staff && !isMod && viewerID != c.UserID
|
||
if !hideAuthor && c.User.ID != 0 {
|
||
ans.User = &CommentActorBrief{
|
||
ID: c.User.ID,
|
||
Username: c.User.Username,
|
||
Nickname: c.User.Nickname,
|
||
Avatar: c.User.Avatar,
|
||
}
|
||
}
|
||
if c.DeletedAt.Valid {
|
||
ans.Deleted = true
|
||
if isMod || viewerID == c.UserID {
|
||
ans.Content = c.Content
|
||
} else {
|
||
ans.Content = ""
|
||
}
|
||
return ans
|
||
}
|
||
ans.Content = c.Content
|
||
return ans
|
||
}
|
||
|
||
func (s *PostService) lotteryCooldownHours() int {
|
||
hours := DefaultCooldownHours
|
||
if s.setting != nil {
|
||
if h, err := s.setting.PostCooldownHours(); err == nil {
|
||
hours = h
|
||
}
|
||
}
|
||
if hours < 0 {
|
||
return 0
|
||
}
|
||
return hours
|
||
}
|
||
|
||
// EnsureLotteryEntry 主评公开发布后登记抽奖报名(幂等;楼主不入池)。
|
||
func (s *PostService) EnsureLotteryEntry(postID, userID, commentID uint, clientIP string) {
|
||
if postID == 0 || userID == 0 {
|
||
return
|
||
}
|
||
var post model.Post
|
||
if err := s.db.Select("id", "user_id", "post_type").First(&post, postID).Error; err != nil {
|
||
return
|
||
}
|
||
if model.NormalizePostType(post.PostType) != model.PostTypeLottery {
|
||
return
|
||
}
|
||
if post.UserID == userID {
|
||
return
|
||
}
|
||
clientIP = normalizeLotteryIP(clientIP)
|
||
var n int64
|
||
s.db.Model(&model.PostLotteryEntry{}).
|
||
Where("post_id = ? AND user_id = ?", postID, userID).
|
||
Count(&n)
|
||
if n > 0 {
|
||
return
|
||
}
|
||
_ = s.db.Create(&model.PostLotteryEntry{
|
||
PostID: postID,
|
||
UserID: userID,
|
||
CommentID: commentID,
|
||
ClientIP: clientIP,
|
||
}).Error
|
||
}
|
||
|
||
func normalizeLotteryIP(ip string) string {
|
||
ip = strings.TrimSpace(ip)
|
||
if ip == "" {
|
||
return ""
|
||
}
|
||
// 去掉 IPv6 zone(如 fe80::1%eth0)
|
||
if i := strings.IndexByte(ip, '%'); i >= 0 {
|
||
ip = ip[:i]
|
||
}
|
||
return ip
|
||
}
|
||
|
||
// lotteryEligibleIDs 奖池:已发布主评用户去重 − 楼主 − 新号冷静期 − 同 IP 仅留一人。
|
||
func (s *PostService) lotteryEligibleIDs(postID, authorID uint) ([]uint, error) {
|
||
hours := s.lotteryCooldownHours()
|
||
q := s.db.Table("comments AS c").
|
||
Select("c.user_id").
|
||
Joins("INNER JOIN users u ON u.id = c.user_id AND u.deleted_at IS NULL").
|
||
Where("c.post_id = ? AND c.parent_id IS NULL AND c.status = ? AND c.deleted_at IS NULL AND c.user_id <> ?",
|
||
postID, model.ContentStatusPublished, authorID).
|
||
Group("c.user_id")
|
||
if hours > 0 {
|
||
cutoff := time.Now().UTC().Add(-time.Duration(hours) * time.Hour)
|
||
q = q.Where("u.created_at <= ?", cutoff)
|
||
}
|
||
var ids []uint
|
||
if err := q.Pluck("c.user_id", &ids).Error; err != nil {
|
||
return nil, err
|
||
}
|
||
return s.lotteryDedupByIP(postID, ids), nil
|
||
}
|
||
|
||
// latestSuccessLoginIPs 各用户最近一次成功登录 IP(作报名 IP 回退)
|
||
func (s *PostService) latestSuccessLoginIPs(userIDs []uint) map[uint]string {
|
||
out := map[uint]string{}
|
||
if len(userIDs) == 0 {
|
||
return out
|
||
}
|
||
type row struct {
|
||
UserID uint
|
||
IP string
|
||
}
|
||
var rows []row
|
||
_ = s.db.Raw(`
|
||
SELECT DISTINCT ON (user_id) user_id, ip
|
||
FROM login_logs
|
||
WHERE user_id IN ? AND success = TRUE AND ip <> ''
|
||
ORDER BY user_id, created_at DESC
|
||
`, userIDs).Scan(&rows).Error
|
||
for _, r := range rows {
|
||
out[r.UserID] = normalizeLotteryIP(r.IP)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// LatestSuccessLoginIP 单用户最近成功登录 IP
|
||
func (s *PostService) LatestSuccessLoginIP(userID uint) string {
|
||
if userID == 0 {
|
||
return ""
|
||
}
|
||
return s.latestSuccessLoginIPs([]uint{userID})[userID]
|
||
}
|
||
|
||
// lotteryIPIgnoredForDedup 不参与同 IP 互斥:空地址;开发态下的回环地址(::1 / 127.0.0.1)
|
||
func (s *PostService) lotteryIPIgnoredForDedup(ip string) bool {
|
||
ip = normalizeLotteryIP(ip)
|
||
if ip == "" {
|
||
return true
|
||
}
|
||
if !s.devMode {
|
||
return false
|
||
}
|
||
parsed := net.ParseIP(ip)
|
||
return parsed != nil && parsed.IsLoopback()
|
||
}
|
||
|
||
// lotteryDedupByIP 同一非空 IP 只保留注册最早的一名;空 IP / 开发态回环不互斥。
|
||
func (s *PostService) lotteryDedupByIP(postID uint, ids []uint) []uint {
|
||
if len(ids) <= 1 {
|
||
return ids
|
||
}
|
||
var entries []model.PostLotteryEntry
|
||
_ = s.db.Where("post_id = ? AND user_id IN ?", postID, ids).Find(&entries).Error
|
||
ipByUser := map[uint]string{}
|
||
for _, e := range entries {
|
||
if ip := normalizeLotteryIP(e.ClientIP); ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
|
||
ipByUser[e.UserID] = ip
|
||
}
|
||
}
|
||
missing := make([]uint, 0)
|
||
for _, id := range ids {
|
||
if ipByUser[id] == "" {
|
||
missing = append(missing, id)
|
||
}
|
||
}
|
||
for id, ip := range s.latestSuccessLoginIPs(missing) {
|
||
if ip != "" && !s.lotteryIPIgnoredForDedup(ip) {
|
||
ipByUser[id] = ip
|
||
}
|
||
}
|
||
|
||
type userRow struct {
|
||
ID uint
|
||
CreatedAt time.Time
|
||
}
|
||
var users []userRow
|
||
_ = s.db.Model(&model.User{}).Select("id", "created_at").
|
||
Where("id IN ?", ids).Find(&users).Error
|
||
created := map[uint]time.Time{}
|
||
for _, u := range users {
|
||
created[u.ID] = u.CreatedAt
|
||
}
|
||
|
||
// ip -> 胜出 user(注册最早;同刻取更小 id)
|
||
bestByIP := map[string]uint{}
|
||
for _, id := range ids {
|
||
ip := ipByUser[id]
|
||
if ip == "" {
|
||
continue
|
||
}
|
||
cur, ok := bestByIP[ip]
|
||
if !ok {
|
||
bestByIP[ip] = id
|
||
continue
|
||
}
|
||
ct, okC := created[id]
|
||
cc, okCur := created[cur]
|
||
if !okC {
|
||
continue
|
||
}
|
||
if !okCur || ct.Before(cc) || (ct.Equal(cc) && id < cur) {
|
||
bestByIP[ip] = id
|
||
}
|
||
}
|
||
chosenIP := map[uint]bool{}
|
||
for _, id := range bestByIP {
|
||
chosenIP[id] = true
|
||
}
|
||
|
||
out := make([]uint, 0, len(ids))
|
||
for _, id := range ids {
|
||
ip := ipByUser[id]
|
||
if ip == "" || chosenIP[id] {
|
||
out = append(out, id)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
// 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
|
||
}
|
||
cooldownHours := s.lotteryCooldownHours()
|
||
ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID)
|
||
eligible := false
|
||
for _, id := range ids {
|
||
if viewerID > 0 && id == viewerID {
|
||
eligible = true
|
||
break
|
||
}
|
||
}
|
||
winners := []LotteryEntrant{}
|
||
prizeNames := expandLotteryPrizeNames(m.Prizes)
|
||
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 i, id := range m.WinnerIDs {
|
||
if u, ok := umap[id]; ok {
|
||
cid, floor := s.lotteryWinningFloor(post.ID, id)
|
||
prize := ""
|
||
if i < len(prizeNames) {
|
||
prize = prizeNames[i]
|
||
}
|
||
winners = append(winners, LotteryEntrant{
|
||
ID: u.ID,
|
||
Nickname: u.Nickname,
|
||
Username: u.Username,
|
||
Avatar: u.Avatar,
|
||
CommentID: cid,
|
||
Floor: floor,
|
||
PrizeName: prize,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
prizes := m.Prizes
|
||
if prizes == nil {
|
||
prizes = []LotteryPrize{}
|
||
}
|
||
return &LotteryState{
|
||
Slots: m.Slots,
|
||
Prizes: prizes,
|
||
Drawn: m.Drawn,
|
||
Closed: m.Closed,
|
||
EndsAt: m.EndsAt,
|
||
EntryCount: len(ids),
|
||
Eligible: eligible,
|
||
CooldownHours: cooldownHours,
|
||
CanDraw: canManage && !m.Drawn,
|
||
CanEditPrizes: !m.Drawn && post.CommentCount == 0,
|
||
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("帖子未公开,无法投票")
|
||
}
|
||
_ = s.settleDuePollIfNeeded(&post)
|
||
s.db.First(&post, post.ID)
|
||
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
|
||
if strings.TrimSpace(m.ClosedAt) == "" {
|
||
m.ClosedAt = time.Now().UTC().Format(time.RFC3339)
|
||
}
|
||
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
|
||
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
|
||
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
|
||
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
|
||
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
|
||
}
|
||
old, err := parseQuestionMeta(post.TypeMeta)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
if old != nil && old.AcceptedCommentID > 0 {
|
||
return nil, errors.New("已采纳答案,无法重新打开")
|
||
}
|
||
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0, SolvedAt: ""}
|
||
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.AcceptedCommentID > 0 {
|
||
return nil, ErrBountySettled
|
||
}
|
||
payFromBalance := false
|
||
if m.Escrowed {
|
||
payFromBalance = false
|
||
} else if m.Refunded {
|
||
payFromBalance = true
|
||
} else {
|
||
return nil, ErrBountyNotEscrow
|
||
}
|
||
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 payFromBalance {
|
||
note := "悬赏截止后采纳扣款:" + post.Title
|
||
if _, err := DebitTx(tx, post.UserID, m.Points, model.PointReasonBountyEscrow, "post", postID, note); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if _, err := CreditTx(tx, cm.UserID, m.Points, model.PointReasonBountyAward, "post", postID, "悬赏采纳:"+post.Title); err != nil {
|
||
return err
|
||
}
|
||
m.AcceptedCommentID = commentID
|
||
m.Escrowed = false
|
||
// 截止后退回再采纳:保留 Expired/Refunded 痕迹便于对账,但 Settled 以采纳为准
|
||
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
|
||
})
|
||
}
|
||
|
||
func (s *PostService) drawLotteryCore(post *model.Post, m *LotteryMeta) error {
|
||
ids, err := s.lotteryEligibleIDs(post.ID, post.UserID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
m.Slots = lotterySlotsFromPrizes(m.Prizes)
|
||
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) settleDuePollIfNeeded(post *model.Post) error {
|
||
if model.NormalizePostType(post.PostType) != model.PostTypePoll {
|
||
return nil
|
||
}
|
||
m, err := parsePollMeta(post.TypeMeta)
|
||
if err != nil || m.Closed {
|
||
return nil
|
||
}
|
||
ends, ok := parseEndsAt(m.EndsAt)
|
||
if !ok || time.Now().UTC().Before(ends) {
|
||
return nil
|
||
}
|
||
m.Closed = true
|
||
if strings.TrimSpace(m.ClosedAt) == "" {
|
||
// 到期自动结束:结束时刻取截止时刻
|
||
m.ClosedAt = ends.UTC().Format(time.RFC3339)
|
||
}
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
n := 0
|
||
var polls []model.Post
|
||
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypePoll).
|
||
Order("id ASC").Limit(limit * 3).Find(&polls)
|
||
for i := range polls {
|
||
if n >= limit {
|
||
break
|
||
}
|
||
before := polls[i].TypeMeta
|
||
_ = s.settleDuePollIfNeeded(&polls[i])
|
||
if polls[i].TypeMeta != before {
|
||
n++
|
||
}
|
||
}
|
||
|
||
var bounties []model.Post
|
||
s.db.Where("post_type = ? AND deleted_at IS NULL", model.PostTypeBounty).
|
||
Order("id ASC").Limit(limit * 3).Find(&bounties)
|
||
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)
|
||
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)
|
||
sanitized, fullyLocked, hint := s.sanitizePostContent(post, viewerID, nil, nil)
|
||
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, nil)
|
||
return detail, nil
|
||
}
|