feat: SSR 内容门控与积分钱包/签到抽奖

闭合积分解锁经济闭环:锁定壳与 unlock,以及 profile 钱包流水与每日签到/抽奖 PRG。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-29 06:20:12 +08:00
parent 40ec20b3df
commit 204e7fdb32
20 changed files with 818 additions and 107 deletions

View File

@@ -1,6 +1,7 @@
package web
import (
"errors"
"fmt"
"net/http"
"strings"
@@ -11,6 +12,16 @@ import (
"github.com/gin-gonic/gin"
)
const profileLedgerLimit = 15
type profileLedgerItem struct {
Delta int
Balance int
ReasonLabel string
Note string
CreatedAt string
}
type profileData struct {
PageChrome
UserID uint
@@ -29,6 +40,35 @@ type profileData struct {
PublicURL string
AvatarMaxMB int
SignatureMax int
CheckIn services.CheckInStatus
Lottery services.LotteryStatus
Ledger []profileLedgerItem
}
func pointReasonLabel(reason string) string {
switch reason {
case models.PointReasonCheckIn:
return "签到"
case models.PointReasonLottery:
return "每日抽奖"
case models.PointReasonUnlockSpend:
return "解锁内容"
case models.PointReasonCreatorIncome:
return "创作分成"
case models.PointReasonAdminAdjust:
return "站长调整"
case models.PointReasonBountyEscrow:
return "悬赏托管"
case models.PointReasonBountyAward:
return "悬赏奖励"
case models.PointReasonBountyRefund:
return "悬赏退款"
default:
if reason == "" {
return "其他"
}
return reason
}
}
// ProfileGet 个人中心
@@ -48,6 +88,7 @@ func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
st, _ := d.User.ActivityStats(uid)
chrome := d.chrome(ctx, "个人中心 · "+d.Settings.SiteBranding().Name, "", "")
chrome.Error = errMsg
chrome.ViewerPoints = user.Points
nick := strings.TrimSpace(user.Nickname)
if nick == "" {
nick = user.Username
@@ -71,6 +112,21 @@ func (d Deps) renderProfile(ctx *webctx.Context, errMsg string) {
AvatarMaxMB: d.Settings.AvatarMaxMB(),
SignatureMax: d.Settings.SignatureMax(),
}
if d.Points != nil {
data.CheckIn, _ = d.Points.GetCheckInStatus(uid)
data.Lottery, _ = d.Points.GetLotteryStatus(uid)
rows, _, _ := d.Points.ListLedger(uid, 1, profileLedgerLimit)
data.Ledger = make([]profileLedgerItem, 0, len(rows))
for _, row := range rows {
data.Ledger = append(data.Ledger, profileLedgerItem{
Delta: row.Delta,
Balance: row.Balance,
ReasonLabel: pointReasonLabel(row.Reason),
Note: row.Note,
CreatedAt: row.CreatedAt.Format("2006-01-02 15:04"),
})
}
}
// 导航显示最新昵称
data.ViewerName = nick
ctx.HTML(http.StatusOK, "profile/view", data)
@@ -166,3 +222,57 @@ func (d Deps) ProfileAvatarPost(c *gin.Context) {
ctx.SetFlash("头像已更新")
ctx.Redirect("/profile")
}
// ProfileCheckInPost 每日签到PRG
func (d Deps) ProfileCheckInPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
d.renderProfile(ctx, "积分服务未就绪")
return
}
st, err := d.Points.CheckIn(ctx.UserID())
if err != nil {
if errors.Is(err, services.ErrAlreadyCheckedIn) {
ctx.SetFlash("今日已签到")
ctx.Redirect("/profile")
return
}
d.renderProfile(ctx, err.Error())
return
}
ctx.SetFlash(fmt.Sprintf("签到成功:连续 %d 天,获得 %d 积分", st.Streak, st.TodayPoints))
ctx.Redirect("/profile")
}
// ProfileLotteryPost 每日抽奖PRG
func (d Deps) ProfileLotteryPost(c *gin.Context) {
ctx := d.ctx(c)
if !ctx.CheckCSRF() {
d.renderProfile(ctx, "无效请求,请重试")
return
}
if d.Points == nil {
d.renderProfile(ctx, "积分服务未就绪")
return
}
st, err := d.Points.DrawLottery(ctx.UserID())
if err != nil {
if errors.Is(err, services.ErrAlreadyLottery) {
ctx.SetFlash("今日已抽奖")
ctx.Redirect("/profile")
return
}
d.renderProfile(ctx, err.Error())
return
}
if st.Points > 0 {
ctx.SetFlash(fmt.Sprintf("抽奖结果:获得 %d 积分", st.Points))
} else {
ctx.SetFlash("抽奖结果:未中奖,明天再来")
}
ctx.Redirect("/profile")
}