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