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

@@ -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