feat: SSR 悬赏帖托管、采纳与退款

compose 支持 bounty;详情悬赏条可采纳评论或按规则退回。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-30 03:16:33 +08:00
parent 303755b47a
commit b5aee606fb
9 changed files with 198 additions and 21 deletions

View File

@@ -26,6 +26,7 @@ type composeData struct {
PollMaxChoices int
PollEndsAt string
PollOptions string
BountyPoints int
Boards []BoardView
TitleMax int
TagsMax int
@@ -66,23 +67,25 @@ func (d Deps) ComposePost(c *gin.Context) {
}
form := composeFormFrom(c)
htmlBody := services.ComposeBodyToHTML(form.Content)
postType := form.PostType
if postType != models.PostTypePoll && postType != models.PostTypeQuestion {
postType = models.PostTypeNormal
}
postType := normalizeComposePostType(form.PostType)
post, err := d.Post.Create(ctx.UserID(), form.BoardID, form.Title, htmlBody, form.Tags, postType, ctx.SkipsModeration())
if err != nil {
d.renderCompose(ctx, err.Error(), form, false, 0)
return
}
if post.PostType == 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)
d.renderCompose(ctx, err.Error(), form, false, 0)
return
if post.PostType == models.PostTypePoll || post.PostType == models.PostTypeBounty {
var extras services.PostCreateExtras
if post.PostType == 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)
d.renderCompose(ctx, err.Error(), form, false, 0)
return
}
extras = services.ParsePostExtrasFromForm(pollJSON, "", "")
} else {
extras = services.ParsePostExtrasFromForm("", strconv.Itoa(form.BountyPoints), "")
}
extras := services.ParsePostExtrasFromForm(pollJSON, "", "")
if err := services.FinalizeSpecialPostCreate(post, ctx.UserID(), extras); err != nil {
_ = d.Post.Delete(ctx.UserID(), post.ID, true)
d.renderCompose(ctx, err.Error(), form, false, 0)
@@ -188,6 +191,16 @@ type composeForm struct {
PollMaxChoices int
PollEndsAt string
PollOptions string
BountyPoints int
}
func normalizeComposePostType(t string) string {
switch t {
case models.PostTypePoll, models.PostTypeQuestion, models.PostTypeBounty:
return t
default:
return models.PostTypeNormal
}
}
func composeFormFrom(c *gin.Context) composeForm {
@@ -196,20 +209,18 @@ func composeFormFrom(c *gin.Context) composeForm {
if maxChoices < 1 {
maxChoices = 1
}
postType := strings.TrimSpace(c.PostForm("post_type"))
if postType != models.PostTypePoll && postType != models.PostTypeQuestion {
postType = models.PostTypeNormal
}
bountyPoints, _ := strconv.Atoi(c.PostForm("bounty_points"))
return composeForm{
BoardID: uint(bid),
Title: strings.TrimSpace(c.PostForm("title")),
Tags: strings.TrimSpace(c.PostForm("tags")),
Content: c.PostForm("content"),
PostType: postType,
PostType: normalizeComposePostType(strings.TrimSpace(c.PostForm("post_type"))),
PollMulti: c.PostForm("poll_multi") == "1" || c.PostForm("poll_multi") == "on",
PollMaxChoices: maxChoices,
PollEndsAt: strings.TrimSpace(c.PostForm("poll_ends_at")),
PollOptions: c.PostForm("poll_options"),
BountyPoints: bountyPoints,
}
}
@@ -226,6 +237,9 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
if form.PollMaxChoices < 1 {
form.PollMaxChoices = 1
}
if form.BountyPoints < 1 {
form.BountyPoints = 1
}
chrome := d.chrome(ctx, title+" · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
chrome.ActiveBoard = form.BoardID
@@ -243,6 +257,7 @@ func (d Deps) renderCompose(ctx *webctx.Context, errMsg string, form composeForm
PollMaxChoices: form.PollMaxChoices,
PollEndsAt: form.PollEndsAt,
PollOptions: form.PollOptions,
BountyPoints: form.BountyPoints,
Boards: chrome.Boards,
TitleMax: d.Settings.PostTitleMax(),
TagsMax: d.Settings.PostTagsMax(),

View File

@@ -34,6 +34,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
g.POST("/post/:id/poll/vote", authMW.RequireAuth(), deps.PostPollVote)
g.POST("/post/:id/poll/close", authMW.RequireAuth(), deps.PostPollClose)
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/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

@@ -49,9 +49,19 @@ type PostPageData struct {
IsQuestion bool
QuestionResolved bool
CanToggleQuestion bool
Bounty *postBountyView
Poll *postPollView
}
type postBountyView struct {
Points int
Status string
StatusLabel string
CanRefund bool
RefundBlockReason string
AwardedCommentID uint
}
type postPollOptionView struct {
ID uint
Text string
@@ -93,6 +103,8 @@ type CommentView struct {
CanDelete bool
Status string
StatusLabel string
CanAwardBounty bool
IsBountyAward bool
}
// PostView GET /post/:id
@@ -116,6 +128,10 @@ func (d Deps) PostView(c *gin.Context) {
hasReplied := ctx.UserID() > 0 && d.Comment.HasUserReplied(uint(id), ctx.UserID())
body := services.ApplyPostContentGates(post.Content, post, ctx.UserID(), ctx.IsAdmin(), hasReplied)
canAwardBounty := post.PostType == models.PostTypeBounty &&
post.BountyStatus == models.BountyStatusOpen &&
(ctx.IsAdmin() || post.UserID == ctx.UserID())
comments, _ := d.Comment.ListByPost(uint(id), ctx.UserID(), ctx.IsAdmin(), post.UserID, nil)
cv := make([]CommentView, 0, len(comments))
for _, cm := range comments {
@@ -132,6 +148,9 @@ func (d Deps) PostView(c *gin.Context) {
CanEdit: !cm.ContentHidden && d.Comment.CanUserEditComment(&cm, ctx.UserID(), ctx.IsAdmin()),
CanDelete: !cm.ContentHidden && d.Comment.CanUserDeleteComment(&cm, ctx.UserID(), ctx.IsAdmin()),
Status: cm.Status,
CanAwardBounty: canAwardBounty && !cm.ContentHidden &&
cm.Status == models.ContentStatusPublished && cm.UserID != post.UserID && cm.UserID > 0,
IsBountyAward: post.BountyCommentID > 0 && cm.ID == post.BountyCommentID,
}
if cm.Status == models.ContentStatusPending || cm.Status == models.ContentStatusRejected {
if ctx.IsAdmin() || (cm.UserID > 0 && cm.UserID == ctx.UserID()) {
@@ -183,6 +202,23 @@ func (d Deps) PostView(c *gin.Context) {
}
}
var bountyView *postBountyView
if post.PostType == models.PostTypeBounty {
isOperator := ctx.IsAdmin() || post.UserID == ctx.UserID()
canRefund, blockReason := services.CanRefundBounty(post, ctx.IsAdmin())
bv := &postBountyView{
Points: post.BountyPoints,
Status: post.BountyStatus,
StatusLabel: bountyStatusLabel(post.BountyStatus),
CanRefund: isOperator && canRefund,
AwardedCommentID: post.BountyCommentID,
}
if isOperator && !canRefund {
bv.RefundBlockReason = blockReason
}
bountyView = bv
}
ctx.HTML(http.StatusOK, "post", PostPageData{
PageChrome: chrome, PostID: post.ID,
PostPath: url.QueryEscape(fmt.Sprintf("/post/%d", post.ID)),
@@ -205,6 +241,7 @@ func (d Deps) PostView(c *gin.Context) {
IsQuestion: post.PostType == models.PostTypeQuestion,
QuestionResolved: post.QuestionResolved,
CanToggleQuestion: post.PostType == models.PostTypeQuestion && (ctx.IsAdmin() || post.UserID == ctx.UserID()),
Bounty: bountyView,
Poll: pollView,
})
}
@@ -247,6 +284,19 @@ func contentStatusLabel(status string) string {
}
}
func bountyStatusLabel(status string) string {
switch status {
case models.BountyStatusOpen:
return "进行中"
case models.BountyStatusAwarded:
return "已采纳"
case models.BountyStatusRefunded:
return "已退回"
default:
return status
}
}
func postTypeLabel(t string) string {
switch t {
case models.PostTypeQuestion:
@@ -501,6 +551,51 @@ func (d Deps) PostQuestionResolvePost(c *gin.Context) {
ctx.Redirect(fmt.Sprintf("/post/%d", id))
}
// PostBountyAwardPost 采纳评论并发放悬赏
func (d Deps) PostBountyAwardPost(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
}
cid, _ := strconv.ParseUint(c.PostForm("comment_id"), 10, 64)
if err := services.AwardBounty(id, ctx.UserID(), ctx.IsAdmin(), uint(cid)); err != nil {
ctx.SetFlash(err.Error())
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
return
}
ctx.SetFlash("已采纳并发放悬赏")
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
}
// PostBountyRefundPost 取消悬赏并退回积分
func (d Deps) PostBountyRefundPost(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.RefundBounty(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