b6c2598710
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 26s
CI / ui (pull_request) Successful in 1m13s
CI / conformance (pull_request) Successful in 10s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Failing after 16m19s
Robokassa moderation requires the public offer to list every digital good with its price. Move /offer/ off the static landing container to the render sidecar: it splices the live catalog price list (§4.4) into the owner-edited ui/legal/offer_ru.md and renders it with the shared ui/src/lib/offer.ts — one renderer, no drift, always matching the current catalog with no redeploy. - backend: /api/v1/internal/offer/pricing (internal, off the edge allow-list) projects the active catalog into two markdown tables — chip packs priced per rail (roubles / VK votes / Telegram Stars) and chip-priced values — through payments.Money so no float reaches the page. Cached in memory: warmed at boot, marked stale on every catalog mutation, so a served render issues no query. - renderer: GET /offer/ fetches the tables and substitutes them at the <#pricing_template#> marker, then renders; offer_ru.md is baked into the image and marked is bundled from ui. GET /offer -> 301. Only /offer/ is edge-exposed. - caddy: route /offer/ to the sidecar; drop the now-dead landing /offer/ handlers and the vite emit-offer plugin. - offer: fill §4.3 (the chip-payment wording) and drop the in-page back link. - landing footer: a feedback link (the offer's Telegram contact) beside the offer link. - docs (ARCHITECTURE, FUNCTIONAL +_ru, renderer README), CI /offer/ probe, unit + integration + node tests.
244 lines
8.5 KiB
Go
244 lines
8.5 KiB
Go
package server
|
|
|
|
import (
|
|
"net/http"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/payments"
|
|
)
|
|
|
|
// walletSegmentDTO is one chip balance in the wallet view: the funding source, the chip count,
|
|
// and whether it can be spent in the current execution context (false for a frozen VK-iOS
|
|
// balance or an untrusted platform).
|
|
type walletSegmentDTO struct {
|
|
Source string `json:"source"`
|
|
Chips int `json:"chips"`
|
|
Spendable bool `json:"spendable"`
|
|
}
|
|
|
|
// 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.
|
|
type walletBuyRequest struct {
|
|
ProductID string `json:"product_id"`
|
|
}
|
|
|
|
// walletDTOFrom projects a payments wallet view into the wire DTO.
|
|
func walletDTOFrom(v payments.WalletView) walletDTO {
|
|
out := walletDTO{AdsForever: v.Benefits.AdsForever, Hints: v.Benefits.Hints}
|
|
if v.Benefits.AdsPaidUntil != nil {
|
|
out.AdsPaidUntil = v.Benefits.AdsPaidUntil.UnixMilli()
|
|
}
|
|
out.Segments = make([]walletSegmentDTO, 0, len(v.Segments))
|
|
for _, seg := range v.Segments {
|
|
out.Segments = append(out.Segments, walletSegmentDTO{Source: string(seg.Source), Chips: seg.Chips, Spendable: seg.Spendable})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// catalogAtomDTO is one atom line of a storefront product: the base value type it grants and how
|
|
// many of it the product carries.
|
|
type catalogAtomDTO struct {
|
|
AtomType string `json:"atom_type"`
|
|
Quantity int `json:"quantity"`
|
|
}
|
|
|
|
// catalogProductDTO is one storefront product for the caller's context: a chip-priced value
|
|
// (chips set, no money price) or a chip pack priced in the context's payment method
|
|
// (money_amount minor units + money_currency, no chips), plus what it grants.
|
|
type catalogProductDTO struct {
|
|
Kind string `json:"kind"`
|
|
ProductID string `json:"product_id"`
|
|
Title string `json:"title"`
|
|
Chips int `json:"chips"`
|
|
MoneyAmount int64 `json:"money_amount"`
|
|
MoneyCurrency string `json:"money_currency"`
|
|
Atoms []catalogAtomDTO `json:"atoms"`
|
|
}
|
|
|
|
// catalogDTO is the storefront: the products visible and purchasable in the caller's context.
|
|
type catalogDTO struct {
|
|
Products []catalogProductDTO `json:"products"`
|
|
}
|
|
|
|
// catalogDTOFrom projects a payments catalog view into the wire DTO.
|
|
func catalogDTOFrom(v payments.CatalogView) catalogDTO {
|
|
out := catalogDTO{Products: make([]catalogProductDTO, 0, len(v.Products))}
|
|
for _, p := range v.Products {
|
|
atoms := make([]catalogAtomDTO, 0, len(p.Atoms))
|
|
for _, a := range p.Atoms {
|
|
atoms = append(atoms, catalogAtomDTO{AtomType: a.AtomType, Quantity: a.Quantity})
|
|
}
|
|
out.Products = append(out.Products, catalogProductDTO{
|
|
Kind: p.Kind, ProductID: p.ProductID, Title: p.Title,
|
|
Chips: p.Chips, MoneyAmount: p.MoneyAmount, MoneyCurrency: p.MoneyCurrency, Atoms: atoms,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// handleWalletCatalog returns the storefront for the caller's trusted execution context: the
|
|
// chip-priced values and the chip packs priced in the context's payment method.
|
|
func (s *Server) handleWalletCatalog(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
cxt, _, err := s.walletGate(ctx, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
view, err := s.payments.Catalog(ctx, cxt)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
c.JSON(http.StatusOK, catalogDTOFrom(view))
|
|
}
|
|
|
|
// handleOfferPricing serves the public-offer price list (§4.4) as markdown — the two catalog tables
|
|
// projected from the active products. The render sidecar fetches it and splices it into the offer
|
|
// markdown before rendering the /offer/ page. Internal, non-public: the /api/v1/internal group is
|
|
// off the edge allow-list, and the value is served from the payments cache (no per-request query in
|
|
// the steady state). Called by the renderer, not the gateway.
|
|
func (s *Server) handleOfferPricing(c *gin.Context) {
|
|
md, err := s.payments.OfferPricing(c.Request.Context())
|
|
if err != nil {
|
|
s.log.Error("offer pricing projection failed", zap.Error(err))
|
|
c.String(http.StatusInternalServerError, "offer pricing unavailable")
|
|
return
|
|
}
|
|
c.Header("Content-Type", "text/markdown; charset=utf-8")
|
|
c.String(http.StatusOK, md)
|
|
}
|
|
|
|
// handleWallet returns the caller's wallet — the segments and benefits visible in the current
|
|
// 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 {
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
cxt, present, err := s.walletGate(ctx, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
view, err := s.payments.Wallet(ctx, uid, cxt, present)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
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
|
|
// fail-closed: an untrusted or frozen context, or an insufficient balance, is refused.
|
|
func (s *Server) handleWalletBuy(c *gin.Context) {
|
|
uid, ok := userID(c)
|
|
if !ok {
|
|
return
|
|
}
|
|
var req walletBuyRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid request body"}})
|
|
return
|
|
}
|
|
productID, err := uuid.Parse(req.ProductID)
|
|
if err != nil {
|
|
c.AbortWithStatusJSON(http.StatusBadRequest, errorResponse{Error: errorBody{Code: "invalid_request", Message: "invalid product id"}})
|
|
return
|
|
}
|
|
ctx := c.Request.Context()
|
|
cxt, present, err := s.walletGate(ctx, uid)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
if err := s.payments.Spend(ctx, uid, cxt, present, productID); err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
view, err := s.payments.Wallet(ctx, uid, cxt, present)
|
|
if err != nil {
|
|
s.abortErr(c, err)
|
|
return
|
|
}
|
|
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).
|
|
type walletRewardRequest struct {
|
|
Nonce string `json:"nonce"`
|
|
}
|
|
|
|
// 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
|
|
}
|
|
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)
|
|
}
|