feat: SSR 抽奖帖发帖与开奖

compose 支持 lottery;详情抽奖卡可开奖并展示中奖名单。同步勾选已落地的 health/robots/sitemap/uploads/限流清单项。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 03:30:57 +08:00
parent b5aee606fb
commit e06c54870c
9 changed files with 176 additions and 13 deletions

View File

@@ -27,6 +27,7 @@ type composeData struct {
PollEndsAt string
PollOptions string
BountyPoints int
LotteryWinners int
Boards []BoardView
TitleMax int
TagsMax int
@@ -46,6 +47,8 @@ func (d Deps) ComposeGet(c *gin.Context) {
BoardID: uint(boardID),
PostType: models.PostTypeNormal,
PollMaxChoices: 1,
BountyPoints: 1,
LotteryWinners: 1,
}, false, 0)
}
@@ -73,9 +76,10 @@ func (d Deps) ComposePost(c *gin.Context) {
d.renderCompose(ctx, err.Error(), form, false, 0)
return
}
if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty {
if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty || post.PostType == models.PostTypeLottery {
var extras services.PostCreateExtras
if post.PostType == models.PostTypePoll {
switch post.PostType {
case models.PostTypePoll:
pollJSON, err := services.BuildPollOptionsJSON(form.PollMulti, form.PollMaxChoices, form.PollEndsAt, form.PollOptions)
if err != nil {
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
@@ -83,8 +87,10 @@ func (d Deps) ComposePost(c *gin.Context) {
return
}
extras = services.ParsePostExtrasFromForm(pollJSON, "", "")
} else {
case models.PostTypeBounty:
extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "")
case models.PostTypeLottery:
extras = services.ParsePostExtrasFromForm("", "", strconv.Itoa(form.LotteryWinners))
}
if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil {
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
@@ -192,11 +198,12 @@ type composeForm struct {
PollEndsAt string
PollOptions string
BountyPoints int
LotteryWinners int
}
func normalizeComposePostType(t string) string {
switch t {
case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty:
case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty, models.PostTypeLottery:
return t
default:
return models.PostTypeNormal
@@ -210,6 +217,10 @@ func composeFormFrom(c *gin.Context) composeForm {
maxChoices = 1
}
bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points"))
lotteryWinners, _ := strconv.Atoi(c.PostForm("lottery_winner_count"))
if lotteryWinners < 1 {
lotteryWinners = 1
}
return composeForm{
BoardID: uint(bid),
Title: strings.TrimSpace(c.PostForm("title")),
@@ -221,6 +232,7 @@ func composeFormFrom(c *gin.Context) composeForm {
PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")),
PollOptions: c.PostForm("poll_options"),
BountyPoints: bountyPoints,
LotteryWinners: lotteryWinners,
}
}
@@ -240,6 +252,9 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
if form.BountyPoints < 1 {
form.BountyPoints = 1
}
if form.LotteryWinners < 1 {
form.LotteryWinners = 1
}
chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
chrome.ActiveBoard = form.BoardID
@@ -258,6 +273,7 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
PollEndsAt: form.PollEndsAt,
PollOptions: form.PollOptions,
BountyPoints: form.BountyPoints,
LotteryWinners: form.LotteryWinners,
Boards: chrome.Boards,
TitleMax: d.Settings.PostTitleMax(),
TagsMax: d.Settings.PostTagsMax(),

View File

@@ -36,6 +36,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.POST("/post/:id/question/resolve", authMW.RequireAuth(), deps.PostQuestionResolvePost)
g.POST("/post/:id/bounty/award", authMW.RequireAuth(), deps.PostBountyAwardPost)
g.POST("/post/:id/bounty/refund", authMW.RequireAuth(), deps.PostBountyRefundPost)
g.POST("/post/:id/lottery/draw", authMW.RequireAuth(), deps.PostLotteryDrawPost)
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)
g.POST("/post/:id/report", authMW.RequireAuth(), deps.PostReportPost)
g.POST("/post/:id/comments/:cid/report", authMW.RequireAuth(), deps.CommentReportPost)

View File

@@ -50,6 +50,7 @@ type PostPageData struct {
QuestionResolved bool
CanToggleQuestion bool
Bounty *postBountyView
Lottery *postLotteryView
Poll *postPollView
}
@@ -62,6 +63,21 @@ type postBountyView struct {
AwardedCommentID uint
}
type postLotteryWinnerView struct {
UserID uint
Name string
FloorHint string
}
type postLotteryView struct {
WinnerCount int
Status string
StatusLabel string
ParticipantCount int
CanDraw bool
Winners []postLotteryWinnerView
}
type postPollOptionView struct {
ID uint
Text string
@@ -219,6 +235,29 @@ func (d Deps) PostView(c *gin.Context) {
bountyView = bv
}
var lotteryView *postLotteryView
if post.PostType == models.PostTypeLottery {
if lv, err := services.GetPostLotteryView(post); err == nil && lv != nil {
isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID()
winners := make([]postLotteryWinnerView, 0, len(lv.Winners))
for _, w := range lv.Winners {
name := strings.TrimSpace(w.Nickname)
if name == "" {
name = w.Username
}
winners = append(winners, postLotteryWinnerView{UserID: w.UserID, Name: name})
}
lotteryView = &postLotteryView{
WinnerCount: lv.WinnerCount,
Status: lv.Status,
StatusLabel: lotteryStatusLabel(lv.Status),
ParticipantCount: lv.ParticipantCount,
CanDraw: isOperator && lv.Status != models.PostLotteryStatusDrawn,
Winners: winners,
}
}
}
ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
@@ -242,6 +281,7 @@ func (d Deps) PostView(c *gin.Context) {
QuestionResolved: post.QuestionResolved,
CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()),
Bounty: bountyView,
Lottery: lotteryView,
Poll: pollView,
})
}
@@ -284,6 +324,17 @@ func contentStatusLabel(status string) string {
}
}
func lotteryStatusLabel(status string) string {
switch status {
case models.PostLotteryStatusDrawn:
return "已开奖"
case models.PostLotteryStatusOpen:
return "待开奖"
default:
return status
}
}
func bountyStatusLabel(status string) string {
switch status {
case models.BountyStatusOpen:
@@ -596,6 +647,28 @@ func (d Deps) PostBountyRefundPost(c *gin.Context) {
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
// PostLotteryDrawPost 抽奖帖开奖
func (d Deps) PostLotteryDrawPost(c *gin.Context) {
ctx := d.ctx(c)
id, err := parsePostID(c, d)
if err != nil || id == 0 {
d.render404(ctx)
return
}
if !ctx.CheckCSRF() {
ctx.SetFlash("无效请求,请重试")
ctx.Redirect(fmt.Sprintf("/post/%d", id))
return
}
if _, err := services.DrawPostLottery(id, ctx.UserID(), ctx.IsAdmin()); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d", id))
return
}
ctx.SetFlash("开奖完成")
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
type commentEditData struct {
PageChrome
PostID uint