feat: SSR 内容门控与积分钱包/签到抽奖
闭合积分解锁经济闭环:锁定壳与 unlock,以及 profile 钱包流水与每日签到/抽奖 PRG。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -100,6 +100,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) {
|
||||
Board: boardSvc, Post: postSvc, Comment: commentSvc,
|
||||
Message: messageSvc, Filter: filter,
|
||||
Limiter: limiter, EmailCode: emailCodeSvc, Store: uploadStore,
|
||||
Points: services.NewPointsService(),
|
||||
}, authMW)
|
||||
|
||||
r.GET("/media/thumb/*filepath", h.ServeImageThumb)
|
||||
|
||||
@@ -23,6 +23,7 @@ type Deps struct {
|
||||
Limiter *services.RateLimiter
|
||||
EmailCode *services.EmailCodeService
|
||||
Store *services.UploadStore
|
||||
Points *services.PointsService
|
||||
}
|
||||
|
||||
// BoardView 侧栏
|
||||
@@ -47,7 +48,8 @@ type PageChrome struct {
|
||||
CSRF string
|
||||
Flash string
|
||||
Error string
|
||||
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
|
||||
UnreadCount int64 // 登录用户未读私信/通知总数;未登录为 0
|
||||
ViewerPoints int // 登录用户当前积分;未登录为 0
|
||||
}
|
||||
|
||||
func (d Deps) ctx(c *gin.Context) *webctx.Context {
|
||||
@@ -78,20 +80,25 @@ func (d Deps) chrome(ctx *webctx.Context, title, desc, inner string) PageChrome
|
||||
if ctx.IsSigned() && d.Message != nil {
|
||||
unread, _ = d.Message.UnreadCount(ctx.UserID())
|
||||
}
|
||||
viewerPoints := 0
|
||||
if ctx.IsSigned() && ctx.Doer != nil {
|
||||
viewerPoints = ctx.Doer.Points
|
||||
}
|
||||
return PageChrome{
|
||||
Title: title,
|
||||
Description: desc,
|
||||
SiteName: brand.Name,
|
||||
Slogan: brand.Slogan,
|
||||
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
|
||||
LoggedIn: ctx.IsSigned(),
|
||||
IsAdmin: ctx.IsAdmin(),
|
||||
ViewerName: name,
|
||||
Boards: bv,
|
||||
Inner: inner,
|
||||
CSRF: ctx.EnsureCSRF(),
|
||||
Flash: ctx.TakeFlash(),
|
||||
UnreadCount: unread,
|
||||
Title: title,
|
||||
Description: desc,
|
||||
SiteName: brand.Name,
|
||||
Slogan: brand.Slogan,
|
||||
LogoMark: firstRuneOr(brand.LogoMark, "姜"),
|
||||
LoggedIn: ctx.IsSigned(),
|
||||
IsAdmin: ctx.IsAdmin(),
|
||||
ViewerName: name,
|
||||
Boards: bv,
|
||||
Inner: inner,
|
||||
CSRF: ctx.EnsureCSRF(),
|
||||
Flash: ctx.TakeFlash(),
|
||||
UnreadCount: unread,
|
||||
ViewerPoints: viewerPoints,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
||||
g.POST("/post/:id/comments", authMW.RequireAuth(), deps.PostComment)
|
||||
g.POST("/post/:id/like", authMW.RequireAuth(), deps.PostLike)
|
||||
g.POST("/post/:id/favorite", authMW.RequireAuth(), deps.PostFavorite)
|
||||
g.POST("/post/:id/unlock", authMW.RequireAuth(), deps.PostUnlock)
|
||||
g.GET("/login", deps.LoginGet)
|
||||
g.POST("/login", deps.LoginPost)
|
||||
g.POST("/logout", deps.LogoutPost)
|
||||
@@ -58,6 +59,8 @@ func Register(r *gin.Engine, deps Deps, authMW *auth.AuthMiddleware) {
|
||||
g.POST("/profile/signature", authMW.RequireAuth(), deps.ProfileSignaturePost)
|
||||
g.POST("/profile/password", authMW.RequireAuth(), deps.ProfilePasswordPost)
|
||||
g.POST("/profile/avatar", authMW.RequireAuth(), deps.ProfileAvatarPost)
|
||||
g.POST("/profile/checkin", authMW.RequireAuth(), deps.ProfileCheckInPost)
|
||||
g.POST("/profile/lottery", authMW.RequireAuth(), deps.ProfileLotteryPost)
|
||||
g.GET("/favorites", authMW.RequireAuth(), deps.FavoritesGet)
|
||||
g.GET("/messages", authMW.RequireAuth(), deps.MessagesList)
|
||||
g.GET("/messages/with/:peerId", authMW.RequireAuth(), deps.MessagesThread)
|
||||
|
||||
@@ -156,6 +156,40 @@ func (d Deps) PostComment(c *gin.Context) {
|
||||
ctx.Redirect(fmt.Sprintf("/post/%d#comments", id))
|
||||
}
|
||||
|
||||
// PostUnlock POST /post/:id/unlock — 积分解锁(JSON,供详情页渐进增强)
|
||||
func (d Deps) PostUnlock(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
if !ctx.CheckCSRF() {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "无效请求,请重试"})
|
||||
return
|
||||
}
|
||||
id, err := parsePostID(c, d)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "帖子不存在"})
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
BlockKey string `json:"block_key" form:"block_key"`
|
||||
}
|
||||
_ = c.ShouldBindJSON(&req)
|
||||
if req.BlockKey == "" {
|
||||
req.BlockKey = strings.TrimSpace(c.PostForm("block_key"))
|
||||
}
|
||||
if req.BlockKey == "" {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "缺少 block_key"})
|
||||
return
|
||||
}
|
||||
res, err := services.UnlockPointsBlock(ctx.UserID(), id, req.BlockKey)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"message": "解锁成功",
|
||||
"unlock": res,
|
||||
})
|
||||
}
|
||||
|
||||
// PostLike 赞
|
||||
func (d Deps) PostLike(c *gin.Context) {
|
||||
ctx := d.ctx(c)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user