Files
jiang13-bbs/backend/service/post_interact.go
freefire 4ae57d9392 feat: 用户徽章与等级体系,积分经济管理重构
- 新增徽章系统:badge 服务与 handler、管理端配置页、BadgeChip/LevelBadge 展示组件
- 新增等级模型:model/level 及前端 levels 元数据
- 管理端经济模块拆分为 points/badges/levels 独立页面,EconomySettings 迁入设置
- 积分、签到、评论等服务适配等级与徽章逻辑
2026-09-25 16:38:29 +08:00

1521 lines
42 KiB
Go
Raw Permalink 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"
"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").Preload("User.Badges.Badge").
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 <= ? OR u.role IN ?", cutoff, []string{
string(model.RoleBoardAdmin), string(model.RoleAdmin),
string(model.RoleSuperAdmin), string(model.RoleOwner),
})
}
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").Preload("User.Badges.Badge").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").Preload("User.Badges.Badge").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").Preload("User.Badges.Badge").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
}