feat(posts): 类型帖截止时间、抽奖奖品表与悬赏发帖体验

统一投票/悬赏/抽奖截止与状态展示,抽奖支持多奖品行;发帖页悬赏积分对齐表格输入,并在余额不足时于底部就地提示。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 10:21:05 +08:00
parent 6b772380c9
commit b2d3d6dd26
23 changed files with 2711 additions and 1007 deletions

View File

@@ -25,7 +25,7 @@ func main() {
// 过期/已吊销 refresh token 定期清理
service.StartRefreshTokenCleanup(model.DB)
// 悬赏过期退回 / 抽奖到期开奖
service.StartTypedPostSettler(service.NewPostService(model.DB))
service.StartTypedPostSettler(service.NewPostService(model.DB).WithDevMode(cfg.DevMode))
r, err := router.Setup(cfg)
if err != nil {

View File

@@ -91,6 +91,8 @@ func (h *Handlers) CreateComment(c *gin.Context) {
if authorID, e := h.Post.GetAuthorID(uint(id)); e == nil {
h.Notification.Create(authorID, claims.ID, model.NotificationTypeComment, uint(id), comment.ID, req.Content)
}
// 抽奖帖:主评入池并记录客户端 IP(同 IP 去重)
h.Post.EnsureLotteryEntry(uint(id), claims.ID, comment.ID, c.ClientIP())
} else {
// 子回复:通知父评论作者(Notification.Create 内部排除自我通知)
h.Notification.Create(parent.UserID, claims.ID, model.NotificationTypeReply, uint(id), comment.ID, req.Content)

View File

@@ -78,6 +78,12 @@ func (h *Handlers) AdminApproveComment(c *gin.Context) {
h.writeModerationError(c, err)
return
}
// 抽奖帖主评通过审核后入池(无评论者 IP,回退其最近登录 IP)
var cm model.Comment
if e := model.DB.Select("id", "post_id", "user_id", "parent_id").First(&cm, uint(id)).Error; e == nil && cm.ParentID == nil {
ip := h.Post.LatestSuccessLoginIP(cm.UserID)
h.Post.EnsureLotteryEntry(cm.PostID, cm.UserID, cm.ID, ip)
}
h.broadcastFeedChanged("comment", postID, boardID, actor.ID)
h.broadcastModerationChanged()
c.JSON(http.StatusOK, gin.H{"message": "操作成功"})

View File

@@ -449,6 +449,9 @@ func respondPostModError(c *gin.Context, err error) {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPostForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()})
case errors.Is(err, service.ErrPollOptionsLocked),
errors.Is(err, service.ErrLotteryPrizesLocked):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}

View File

@@ -22,9 +22,7 @@ func (h *Handlers) respondInteractError(c *gin.Context, err error) {
errors.Is(err, service.ErrPollClosed),
errors.Is(err, service.ErrPollOptionsLocked),
errors.Is(err, service.ErrLotteryDrawn),
errors.Is(err, service.ErrLotteryClosed),
errors.Is(err, service.ErrBountySettled),
errors.Is(err, service.ErrBountyExpired):
errors.Is(err, service.ErrBountySettled):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
@@ -148,38 +146,6 @@ func (h *Handlers) AcceptBounty(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"post": post})
}
// RefundBounty 退回悬赏
func (h *Handlers) RefundBounty(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
post, err := h.Post.RefundBounty(h.loadActor(claims.ID), claims.ID, uint(id))
if err != nil {
h.respondInteractError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// CloseLottery 截止抽奖
func (h *Handlers) CloseLottery(c *gin.Context) {
claims := middleware.CurrentUser(c)
id, err := strconv.ParseUint(c.Param("id"), 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "无效的帖子 ID"})
return
}
post, err := h.Post.CloseLotteryEntries(h.loadActor(claims.ID), claims.ID, uint(id))
if err != nil {
h.respondInteractError(c, err)
return
}
c.JSON(http.StatusOK, gin.H{"post": post})
}
// DrawLottery 开奖
func (h *Handlers) DrawLottery(c *gin.Context) {
claims := middleware.CurrentUser(c)

View File

@@ -259,11 +259,13 @@ type PostPollVote struct {
CreatedAt time.Time `json:"created_at"`
}
// PostLotteryEntry 抽奖报名
// PostLotteryEntry 抽奖报名(主评入池时登记;同 IP 去重用 ClientIP)
type PostLotteryEntry struct {
ID uint `gorm:"primaryKey" json:"id"`
PostID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"post_id"`
UserID uint `gorm:"uniqueIndex:idx_lottery_entry;not null" json:"user_id"`
CommentID uint `gorm:"not null;default:0" json:"comment_id"` // 入池时的主评 ID
ClientIP string `gorm:"size:45;not null;default:''" json:"-"`
CreatedAt time.Time `json:"created_at"`
}

View File

@@ -42,7 +42,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
authSvc := service.NewAuthService(model.DB, cfg.JWTSecret)
boardSvc := service.NewBoardService(model.DB)
settingSvc := service.NewSettingService(model.DB)
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc)
postSvc := service.NewPostService(model.DB).WithSetting(settingSvc).WithDevMode(cfg.DevMode)
commentSvc := service.NewCommentService(model.DB)
likeSvc := service.NewLikeService(model.DB)
notifSvc := service.NewNotificationService(model.DB)
@@ -144,8 +144,6 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
api.POST("/posts/:id/question/solve", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.SolveQuestion)
api.POST("/posts/:id/question/reopen", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.ReopenQuestion)
api.POST("/posts/:id/bounty/accept", middleware.RateLimitUserMiddleware(limiter, service.RateInteract), h.AcceptBounty)
api.POST("/posts/:id/bounty/refund", h.RefundBounty)
api.POST("/posts/:id/lottery/close", h.CloseLottery)
api.POST("/posts/:id/lottery/draw", h.DrawLottery)
api.DELETE("/posts/:id", h.DeletePost)
api.PUT("/posts/:id/pin", h.TogglePin)

View File

@@ -22,6 +22,7 @@ var (
type PostService struct {
db *gorm.DB
setting *SettingService
devMode bool
}
func NewPostService(db *gorm.DB) *PostService {
@@ -34,6 +35,12 @@ func (s *PostService) WithSetting(setting *SettingService) *PostService {
return s
}
// WithDevMode 开发态:抽奖同 IP 去重对回环地址放宽
func (s *PostService) WithDevMode(dev bool) *PostService {
s.devMode = dev
return s
}
// PostListQuery 帖子列表查询参数
type PostListQuery struct {
BoardID uint
@@ -445,6 +452,7 @@ func (s *PostService) GetByIDForViewer(id, viewerID uint, loadActor func() *Acto
// 惰性结算:悬赏过期退回 / 抽奖到期开奖
_ = s.settleExpiredBountyIfNeeded(&post)
_ = s.settleDueLotteryIfNeeded(&post)
_ = s.settleDuePollIfNeeded(&post)
if post.TypeMeta != "" {
var fresh model.Post
if err := s.db.Select("type_meta").First(&fresh, post.ID).Error; err == nil {
@@ -729,9 +737,8 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
bm.Expired = false
bm.AcceptedCommentID = 0
if status == model.ContentStatusPublished {
days := normalizeExpireDays(bm.ExpireDays, 7, []int{3, 7, 14, 30})
bm.EndsAt = resolveEndsAtOnPublish(bm.EndsAt, bm.ExpireDays, 7, []int{3, 7, 14, 30}, now)
bm.ExpireDays = 0
bm.EndsAt = endsAtFromDays(days, now)
}
typeMeta, _ = encodeMeta(bm)
}
@@ -741,14 +748,26 @@ func (s *PostService) Create(in CreatePostInput) (*PostDetail, error) {
if lm.WinnerIDs == nil {
lm.WinnerIDs = []uint{}
}
if lm.Prizes == nil {
lm.Prizes = []LotteryPrize{}
}
lm.Slots = lotterySlotsFromPrizes(lm.Prizes)
if status == model.ContentStatusPublished {
days := normalizeExpireDays(lm.ExpireDays, 7, []int{1, 3, 7, 14})
lm.EndsAt = resolveEndsAtOnPublish(lm.EndsAt, lm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
lm.ExpireDays = 0
lm.EndsAt = endsAtFromDays(days, now)
}
typeMeta, _ = encodeMeta(lm)
}
if postType == model.PostTypePoll {
pm, _ := parsePollMeta(typeMeta)
if status == model.ContentStatusPublished {
pm.EndsAt = resolveEndsAtOnPublish(pm.EndsAt, pm.ExpireDays, 7, []int{1, 3, 7, 14}, now)
pm.ExpireDays = 0
}
typeMeta, _ = encodeMeta(pm)
}
post := &model.Post{
BoardID: in.BoardID, UserID: in.UserID,
Title: title, Content: content, Tags: in.Tags,
@@ -892,9 +911,15 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
neu.Multi = old.Multi
neu.Anonymous = old.Anonymous
neu.Closed = old.Closed
neu.EndsAt = old.EndsAt
neu.ClosedAt = old.ClosedAt
neu.ExpireDays = 0
meta, _ = encodeMeta(neu)
} else {
neu.Closed = old.Closed
neu.EndsAt = old.EndsAt
neu.ClosedAt = old.ClosedAt
neu.ExpireDays = 0
meta, _ = encodeMeta(neu)
}
}
@@ -907,8 +932,16 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
neu.WinnerIDs = old.WinnerIDs
neu.Closed = old.Closed
neu.EndsAt = old.EndsAt
if old.Drawn {
// 已有评论或已开奖:冻结奖品(名额由奖品合计推导)
locked := old.Drawn || post.CommentCount > 0
if locked {
if !lotteryPrizesEqual(neu.Prizes, old.Prizes) || neu.Slots != old.Slots {
return nil, ErrLotteryPrizesLocked
}
neu.Prizes = old.Prizes
neu.Slots = old.Slots
} else {
neu.Slots = lotterySlotsFromPrizes(neu.Prizes)
}
meta, _ = encodeMeta(neu)
}
@@ -920,6 +953,7 @@ func (s *PostService) Update(actor *Actor, postID, userID uint, in UpdatePostInp
// 解题态走专用 API,编辑帖子不覆盖
neu.Solved = old.Solved
neu.AcceptedCommentID = old.AcceptedCommentID
neu.SolvedAt = old.SolvedAt
meta, _ = encodeMeta(neu)
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"errors"
"math/big"
"net"
"strings"
"time"
"unicode/utf8"
@@ -16,15 +17,19 @@ import (
// ---- TypeMeta 结构 ----
type QuestionMeta struct {
Solved bool `json:"solved"`
AcceptedCommentID uint `json:"accepted_comment_id"`
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
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 {
@@ -37,13 +42,19 @@ type BountyMeta struct {
ExpireDays int `json:"expire_days,omitempty"` // 发帖入参,规范化后可清
}
type LotteryPrize struct {
Name string `json:"name"`
Count int `json:"count"`
}
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"`
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 (
@@ -53,10 +64,9 @@ var (
ErrPollOptionsLocked = errors.New("已有人投票,无法修改选项或匿名设置")
ErrBountySettled = errors.New("悬赏已结算")
ErrBountyNotEscrow = errors.New("悬赏未托管")
ErrBountyExpired = errors.New("悬赏已过期")
ErrLotteryDrawn = errors.New("已开奖")
ErrLotteryClosed = errors.New("抽奖已截止")
ErrLotteryNoEntries = errors.New("暂无回帖用户可抽奖")
ErrLotteryPrizesLocked = errors.New("已有评论,无法修改奖品设置")
ErrQuestionNotType = errors.New("本文不是问答帖")
)
@@ -73,10 +83,13 @@ func parseQuestionMeta(raw string) (*QuestionMeta, error) {
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"`
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("投票配置无效")
@@ -86,10 +99,13 @@ func parsePollMeta(raw string) (*PollMeta, error) {
anon = *wire.Anonymous
}
return &PollMeta{
Options: wire.Options,
Multi: wire.Multi,
Closed: wire.Closed,
Anonymous: anon,
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
}
@@ -106,9 +122,49 @@ func parseLotteryMeta(raw string) (*LotteryMeta, error) {
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 {
@@ -145,6 +201,35 @@ func parseEndsAt(s string) (time.Time, bool) {
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)
@@ -175,7 +260,10 @@ func ComputeTypeStatus(postType, typeMeta string) string {
if m.AcceptedCommentID > 0 {
return "solved"
}
if m.Expired || m.Refunded {
if m.Expired {
return "expired"
}
if m.Refunded {
return "expired"
}
return "open"
@@ -240,6 +328,14 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
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 == "" {
@@ -255,50 +351,102 @@ func NormalizeAndValidateTypeMeta(postType, meta string) (string, error) {
if m.Points > 100000 {
return "", errors.New("悬赏积分过高")
}
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
m.ExpireDays = days
// ends_at 在 Create/Approve 时按发布时间写入;编辑保留旧值
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("请设置中奖名额")
return "", errors.New("请设置奖品")
}
m, err := parseLotteryMeta(meta)
if err != nil {
return "", err
}
if m.Slots < 1 {
return "", errors.New("中奖名额至少为 1")
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 m.Slots > 100 {
return "", errors.New("中奖名额过多")
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{}
}
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
m.ExpireDays = days
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(悬赏/抽奖)
// 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 m.EndsAt != "" {
if _, ok := parseEndsAt(m.EndsAt); ok {
return typeMeta, false
}
days := normalizeExpireDays(m.ExpireDays, 7, []int{3, 7, 14, 30})
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{3, 7, 14, 30}, publishedAt)
m.ExpireDays = 0
m.EndsAt = endsAtFromDays(days, publishedAt)
raw, err := encodeMeta(m)
if err != nil {
return typeMeta, false
@@ -309,12 +457,11 @@ func EnsureDeadlineOnPublish(typeMeta, postType string, publishedAt time.Time) (
if err != nil {
return typeMeta, false
}
if m.EndsAt != "" {
if _, ok := parseEndsAt(m.EndsAt); ok {
return typeMeta, false
}
days := normalizeExpireDays(m.ExpireDays, 7, []int{1, 3, 7, 14})
m.EndsAt = resolveEndsAtOnPublish("", m.ExpireDays, 7, []int{1, 3, 7, 14}, publishedAt)
m.ExpireDays = 0
m.EndsAt = endsAtFromDays(days, publishedAt)
raw, err := encodeMeta(m)
if err != nil {
return typeMeta, false
@@ -347,6 +494,7 @@ type QuestionState struct {
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"`
@@ -372,6 +520,8 @@ type PollState struct {
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"`
@@ -391,7 +541,6 @@ type BountyState struct {
EndsAt string `json:"ends_at,omitempty"`
Settled bool `json:"settled"`
CanAccept bool `json:"can_accept"`
CanRefund bool `json:"can_refund"`
}
type LotteryEntrant struct {
@@ -399,20 +548,23 @@ type LotteryEntrant struct {
Nickname string `json:"nickname"`
Username string `json:"username"`
Avatar string `json:"avatar"`
CommentID uint `json:"comment_id"` // 中奖楼层(该用户最早主评),每人仅一条
CommentID uint `json:"comment_id"`
Floor int `json:"floor"`
PrizeName string `json:"prize_name,omitempty"`
}
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"`
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) {
@@ -444,18 +596,28 @@ func (s *PostService) buildQuestionState(post *model.Post, canManage bool) *Ques
m = &QuestionMeta{}
}
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
answer := s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor)
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: s.loadAcceptedAnswer(post.ID, m.AcceptedCommentID, floor),
AcceptedAnswer: answer,
SolvedAt: solvedAt,
CanAccept: canManage && !m.Solved,
CanSolve: canManage && !m.Solved,
CanReopen: 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
@@ -531,11 +693,17 @@ func (s *PostService) buildPollState(post *model.Post, viewerID uint, canClose b
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,
@@ -550,7 +718,7 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
if err != nil {
return nil
}
settled := m.AcceptedCommentID > 0 || m.Refunded || m.Expired
accepted := m.AcceptedCommentID > 0
floor := FloorNumber(s.db, post.ID, m.AcceptedCommentID)
return &BountyState{
Points: m.Points,
@@ -561,9 +729,9 @@ func (s *PostService) buildBountyState(post *model.Post, canManage bool) *Bounty
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,
Settled: accepted,
// 托管中可采纳;到期自动退回后仍可从余额再扣支付(不可撤回)
CanAccept: canManage && !accepted && (m.Escrowed || m.Refunded),
}
}
@@ -601,14 +769,197 @@ func (s *PostService) loadAcceptedAnswer(postID, commentID uint, floor int) *Acc
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) {
var ids []uint
err := s.db.Model(&model.Comment{}).
Where("post_id = ? AND status = ? AND deleted_at IS NULL AND user_id <> ?",
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).
Distinct("user_id").
Pluck("user_id", &ids).Error
return ids, err
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 中奖用户的中奖楼层:其在本帖最早一条已发布主评论(每人仅一楼)。
@@ -633,6 +984,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
if err != nil {
return nil
}
cooldownHours := s.lotteryCooldownHours()
ids, _ := s.lotteryEligibleIDs(post.ID, post.UserID)
eligible := false
for _, id := range ids {
@@ -642,6 +994,7 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
}
}
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)
@@ -649,9 +1002,13 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
for _, u := range users {
umap[u.ID] = u
}
for _, id := range m.WinnerIDs {
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,
@@ -659,20 +1016,27 @@ func (s *PostService) buildLotteryState(post *model.Post, viewerID uint, canMana
Avatar: u.Avatar,
CommentID: cid,
Floor: floor,
PrizeName: prize,
})
}
}
}
prizes := m.Prizes
if prizes == nil {
prizes = []LotteryPrize{}
}
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,
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,
}
}
@@ -688,6 +1052,8 @@ func (s *PostService) VotePoll(userID, postID uint, optionIndexes []int) (*PostD
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
@@ -755,6 +1121,9 @@ func (s *PostService) ClosePoll(actor *Actor, userID, postID uint) (*PostDetail,
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
@@ -794,6 +1163,7 @@ func (s *PostService) AcceptQuestion(actor *Actor, userID, postID, commentID uin
}
m.AcceptedCommentID = commentID
m.Solved = true
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
raw, err := encodeMeta(m)
if err != nil {
return nil, err
@@ -819,6 +1189,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
return nil, err
}
m.Solved = true
m.SolvedAt = time.Now().UTC().Format(time.RFC3339)
raw, err := encodeMeta(m)
if err != nil {
return nil, err
@@ -830,7 +1201,7 @@ func (s *PostService) SolveQuestion(actor *Actor, userID, postID uint) (*PostDet
return s.detailAfterInteract(post, userID)
}
// ReopenQuestion 重新打开
// ReopenQuestion 重新打开(仅允许撤回「手动标已解决」;已采纳答案不可重开)
func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDetail, error) {
post, err := s.loadManageablePost(actor, userID, postID)
if err != nil {
@@ -839,7 +1210,14 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
if model.NormalizePostType(post.PostType) != model.PostTypeQuestion {
return nil, ErrQuestionNotType
}
m := &QuestionMeta{Solved: false, AcceptedCommentID: 0}
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
@@ -851,7 +1229,8 @@ func (s *PostService) ReopenQuestion(actor *Actor, userID, postID uint) (*PostDe
return s.detailAfterInteract(post, userID)
}
// AcceptBounty 采纳评论并发放悬赏
// AcceptBounty 采纳评论并发放悬赏。
// 托管中:从托管直接发给答主;到期已退回后:从作者余额再扣再发放(不可撤回)。
func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint) (*PostDetail, error) {
post, err := s.loadManageablePost(actor, userID, postID)
if err != nil {
@@ -867,15 +1246,17 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
if err != nil {
return nil, err
}
if m.Expired {
return nil, ErrBountyExpired
}
if !m.Escrowed {
return nil, ErrBountyNotEscrow
}
if m.AcceptedCommentID > 0 || m.Refunded {
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("评论不存在")
@@ -888,56 +1269,18 @@ func (s *PostService) AcceptBounty(actor *Actor, userID, postID, commentID uint)
}
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
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
// 截止后退回再采纳:保留 Expired/Refunded 痕迹便于对账,但 Settled 以采纳为准
raw, err := encodeMeta(m)
if err != nil {
return err
@@ -996,36 +1339,12 @@ func (s *PostService) settleExpiredBountyIfNeeded(post *model.Post) error {
})
}
// 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
}
m.Slots = lotterySlotsFromPrizes(m.Prizes)
nWin := m.Slots
if nWin > len(ids) {
nWin = len(ids)
@@ -1078,6 +1397,34 @@ func (s *PostService) DrawLottery(actor *Actor, userID, postID uint) (*PostDetai
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
@@ -1093,15 +1440,29 @@ func (s *PostService) settleDueLotteryIfNeeded(post *model.Post) error {
return s.drawLotteryCore(post, m)
}
// SettleDueTypedPosts 定时任务:悬赏过期退回 + 抽奖到期开奖
// 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)
n := 0
for i := range bounties {
if n >= limit {
break
@@ -1116,7 +1477,6 @@ func (s *PostService) SettleDueTypedPosts(limit int) {
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