feat(ads): rewarded video (VK) — client-attested credit + daily/hourly caps
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
CI / changes (pull_request) Successful in 4s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m11s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
The first ads slice: a voluntary rewarded video credits chips. VK Mini App ads (VKWebAppShowNativeAds) expose only a client-side watch result — no server-to-server verify — so the credit is client-attested, guarded by a server daily + hourly cap (config reward_daily_cap / reward_hourly_cap, default 50 / 10). The caps are both anti-abuse (bounding a forger who skips the ad and calls the endpoint directly) and an economic conversion lever (limiting free chips so a player who wants more buys). D29 amended to VK's reality. Backend: CreditReward (VK-only, order-less, idempotent on a client nonce, floored by the caps; payout from config rewarded_payout_chips, default 0 = off) + the wallet.reward edge op returning the updated wallet (reward_chips gates the "watch for chips" CTA). Additive migration (two config columns). Client: the ads-network abstraction (lib/ads.ts, VK impl) + the VK bridge (vkRewardedReady / vkShowRewarded) + the Wallet CTA + i18n. A contour test stub (VITE_ADS_STUB -> a toast instead of a real ad; prod always real) and a temporary diagnostic that logs the raw VK data, so we confirm on the contour exactly what VK returns (harden to signature-verify if it carries one). Tests: backend integration (credit, nonce idempotency, hourly cap, disabled, non-VK refusal) + codec unit (reward wire). Docs: PAYMENTS(+ru) §10, D29 amend, PLAN E6. Bundle: shared budget 30->31 (reward i18n strings).
This commit is contained in:
@@ -72,6 +72,8 @@ func (s *Server) registerRoutes() {
|
||||
u.GET("/wallet", s.handleWallet)
|
||||
u.GET("/wallet/catalog", s.handleWalletCatalog)
|
||||
u.POST("/wallet/buy", s.handleWalletBuy)
|
||||
// A rewarded-video credit (VK ads): client-attested + a config daily cap.
|
||||
u.POST("/wallet/reward", s.handleWalletReward)
|
||||
}
|
||||
if s.payments != nil {
|
||||
// The money order endpoint dispatches by rail (direct → Robokassa, vk → VK); an
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"scrabble/backend/internal/payments"
|
||||
)
|
||||
@@ -20,11 +21,15 @@ type walletSegmentDTO struct {
|
||||
|
||||
// walletDTO is the user-facing wallet: the context-visible chip segments and the
|
||||
// context-applicable benefits (the no-ads term or forever flag, and the available hints).
|
||||
// RewardChips is the chips a rewarded-video view earns in the current context (0 when rewarded is
|
||||
// unavailable here — outside VK, or unconfigured); the client shows the "watch for chips" button
|
||||
// only when it is positive.
|
||||
type walletDTO struct {
|
||||
Segments []walletSegmentDTO `json:"segments"`
|
||||
AdsForever bool `json:"ads_forever"`
|
||||
AdsPaidUntil int64 `json:"ads_paid_until_ms"` // unix millis; 0 = no active term
|
||||
Hints int `json:"hints"`
|
||||
RewardChips int `json:"reward_chips"`
|
||||
}
|
||||
|
||||
// walletBuyRequest is the POST body of a chip spend: the product to buy with chips.
|
||||
@@ -108,7 +113,8 @@ func (s *Server) handleWalletCatalog(c *gin.Context) {
|
||||
}
|
||||
|
||||
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
||||
// trusted execution context.
|
||||
// trusted execution context, plus the rewarded-video payout available here (0 outside VK or when
|
||||
// unconfigured), which gates the client's "watch for chips" button.
|
||||
func (s *Server) handleWallet(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
@@ -125,7 +131,13 @@ func (s *Server) handleWallet(c *gin.Context) {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
dto := walletDTOFrom(view)
|
||||
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr != nil {
|
||||
s.log.Warn("wallet: reward payout read failed", zap.String("account", uid.String()), zap.Error(perr))
|
||||
} else {
|
||||
dto.RewardChips = payout
|
||||
}
|
||||
c.JSON(http.StatusOK, dto)
|
||||
}
|
||||
|
||||
// handleWalletBuy spends chips on a chip-priced value and returns the updated wallet. It is
|
||||
@@ -162,3 +174,65 @@ func (s *Server) handleWalletBuy(c *gin.Context) {
|
||||
}
|
||||
c.JSON(http.StatusOK, walletDTOFrom(view))
|
||||
}
|
||||
|
||||
// walletRewardRequest is the POST body of a rewarded-video credit: a client nonce (the idempotency
|
||||
// key for a single watched view — a retry credits once) and Diag, the raw VK ad result forwarded for
|
||||
// a temporary diagnostic log while we confirm exactly what VK returns on the contour.
|
||||
type walletRewardRequest struct {
|
||||
Nonce string `json:"nonce"`
|
||||
Diag string `json:"diag"`
|
||||
}
|
||||
|
||||
// handleWalletReward credits a rewarded-video view's chips to the VK segment, client-attested and
|
||||
// bounded by the config daily cap. It is VK-only and idempotent on the nonce; a reached cap answers
|
||||
// reward_capped, and an unconfigured payout answers reward_unavailable. On success it returns the
|
||||
// updated wallet (like a spend).
|
||||
func (s *Server) handleWalletReward(c *gin.Context) {
|
||||
uid, ok := userID(c)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
var req walletRewardRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil || req.Nonce == "" {
|
||||
abortBadRequest(c, "nonce is required")
|
||||
return
|
||||
}
|
||||
ctx := c.Request.Context()
|
||||
cxt, present, err := s.walletGate(ctx, uid)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
// Temporary diagnostic: log the raw VK ad result (bounded — untrusted client input) to learn its
|
||||
// exact shape on the contour; if it carries a verifiable token we harden past client-attested.
|
||||
if req.Diag != "" {
|
||||
diag := req.Diag
|
||||
if len(diag) > 2000 {
|
||||
diag = diag[:2000]
|
||||
}
|
||||
s.log.Info("rewarded ad diagnostic", zap.String("account", uid.String()), zap.String("vk_data", diag))
|
||||
}
|
||||
outcome, err := s.payments.CreditReward(ctx, uid, cxt, present, req.Nonce)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
if outcome.Capped {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_capped", Message: "daily reward limit reached"}})
|
||||
return
|
||||
}
|
||||
if outcome.Chips == 0 && !outcome.AlreadyCredited {
|
||||
c.AbortWithStatusJSON(http.StatusConflict, errorResponse{Error: errorBody{Code: "reward_unavailable", Message: "rewarded video is not available"}})
|
||||
return
|
||||
}
|
||||
view, err := s.payments.Wallet(ctx, uid, cxt, present)
|
||||
if err != nil {
|
||||
s.abortErr(c, err)
|
||||
return
|
||||
}
|
||||
view2 := walletDTOFrom(view)
|
||||
if payout, perr := s.payments.RewardPayout(ctx, cxt, present); perr == nil {
|
||||
view2.RewardChips = payout
|
||||
}
|
||||
c.JSON(http.StatusOK, view2)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user