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("抽奖配置无效") } // 旧数据只有 slots:合成单一奖品项 if len(m.Prizes) == 0 && m.Slots > 0 { m.Prizes = []LotteryPrize{{Name: "奖品", Count: m.Slots}} } if len(m.Prizes) > 0 { 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) solvedAt := m.SolvedAt if m.Solved && solvedAt == "" && answer != nil && !answer.CreatedAt.IsZero() { solvedAt = answer.CreatedAt.UTC().Format(time.RFC3339) // 旧数据:无 solved_at 时用采纳评论时间 } return &QuestionState{ Solved: m.Solved, AcceptedCommentID: m.AcceptedCommentID, AcceptedFloor: floor, AcceptedAnswer: answer, SolvedAt: 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) closedAt := m.ClosedAt if m.Closed && closedAt == "" { closedAt = m.EndsAt // 旧数据:无 closed_at 时用截止时刻兜底 } return &PollState{ Options: opts, Multi: m.Multi, Closed: m.Closed, Anonymous: m.Anonymous, EndsAt: m.EndsAt, ClosedAt: 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) 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 }