Files
scrabble-game/backend/internal/server/banner.go
T
Ilia Denisov 1c06d1d0d1
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 11s
CI / integration (pull_request) Successful in 22s
CI / ui (pull_request) Successful in 1m7s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m41s
feat(payments): chip wallet, store-compliance gate and benefit application
Stand up the internal chip/benefit mechanic behind the narrow payments interface:
context-aware balances and benefits, an atomic chip spend, admin grants as
zero-price value sales, the one-directional store-compliance gate (VK/TG same-
origin only, web draws direct→vk→tg, VK-iOS frozen, untrusted fail-closed), and
per-origin hint and no-ads application with term stacking. Reads are served from
an in-process, account-keyed write-through cache (mirroring the suspension gate),
so hot paths issue no query to the payments schema.

Flip the online-game hint wallet and the ad-banner suppression from the deprecated
accounts.hint_balance / paid_account columns to the payments benefit (a hint
balance no longer suppresses the banner — only a no-ads benefit does), and fold
chip segments and benefits by origin on account merge, inside the merge tx. Add
the GET/POST /api/v1/user/wallet edge chain (REST → Connect → FlatBuffers) plus
its codec unit test; no wallet UI yet.

Bring the frozen owner decisions log into the repo at
docs/PAYMENTS_DECISIONS_ru.md (it was untracked under .vscode) and reference it
from PLAN.md; record the read-cache design and the present-sources interface in
PLAN.md and docs/PAYMENTS.md (+ RU mirror).
2026-07-08 06:06:40 +02:00

210 lines
8.4 KiB
Go

package server
import (
"context"
"github.com/google/uuid"
"go.uber.org/zap"
"scrabble/backend/internal/account"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/payments"
)
// bannerDTO is the advertising-banner block attached to an eligible viewer's
// profile: the campaigns to rotate (each already resolved to the viewer's bot
// language) and the global display timings the client rotator reads. It is
// absent for a viewer who should see no banner.
type bannerDTO struct {
Campaigns []bannerCampaignDTO `json:"campaigns"`
Timings bannerTimingsDTO `json:"timings"`
}
// bannerCampaignDTO is one campaign in the rotation feed: its GCD-reduced show
// weight (for the client's smooth weighted round-robin) and its messages, in
// display order, already resolved to one language. The override_* colours are
// present only for a campaign that carries a colour override: override_bg/fg/link
// paint every theme, and the *_dark trio further overrides the dark theme (the
// client resolves the cascade). They are absent for a plain campaign, which keeps
// the neutral theme tokens.
type bannerCampaignDTO struct {
Weight int `json:"weight"`
Messages []string `json:"messages"`
OverrideBg string `json:"override_bg,omitempty"`
OverrideFg string `json:"override_fg,omitempty"`
OverrideLink string `json:"override_link,omitempty"`
OverrideBgDark string `json:"override_bg_dark,omitempty"`
OverrideFgDark string `json:"override_fg_dark,omitempty"`
OverrideLinkDark string `json:"override_link_dark,omitempty"`
}
// bannerTimingsDTO mirrors ads.Timings on the wire.
type bannerTimingsDTO struct {
HoldMs int `json:"hold_ms"`
EdgePauseMs int `json:"edge_pause_ms"`
ScrollPxPerSec int `json:"scroll_px_per_sec"`
FadeOutMs int `json:"fade_out_ms"`
GapMs int `json:"gap_ms"`
FadeInMs int `json:"fade_in_ms"`
}
// profileResponse builds the account's profile DTO with the advertising-banner block attached when
// the viewer is eligible. Every endpoint returning the caller's own profile (get, update, link)
// uses it, so the banner never drops off the client's profile after a non-get profile change (e.g.
// a language switch).
func (s *Server) profileResponse(ctx context.Context, acc account.Account) profileResponse {
r := profileResponseFor(acc)
// Resolve the payments gate once (execution context + present sources) and feed it to both
// the hint count and the banner. The profile hint balance now comes from the payments benefit
// (context-aware), not the deprecated accounts.hint_balance column; on any failure the legacy
// value from profileResponseFor (zeroed in production) stands.
cxt, present, err := s.walletGate(ctx, acc.ID)
if err != nil {
s.log.Warn("profile: wallet gate failed", zap.String("account", acc.ID.String()), zap.Error(err))
} else if s.payments != nil {
if hints, herr := s.payments.HintsAvailable(ctx, acc.ID, cxt, present); herr == nil {
r.HintBalance = hints
} else {
s.log.Warn("profile: hint balance read failed", zap.String("account", acc.ID.String()), zap.Error(herr))
}
}
r.Banner = s.bannerFor(ctx, acc, cxt, present)
s.fillLinkedIdentities(ctx, &r, acc.ID)
r.DictVersions = s.currentDictVersions()
return r
}
// currentDictVersions reports the current dictionary version of every game variant with a
// resident dictionary, as engine.Variant stable labels. It backs profileResponse.DictVersions
// so an offline-capable client preloads the matching dawg per variant off the cold-start
// profile. A variant without a loaded dictionary is omitted (Registry.Latest reports
// ErrUnknownVariant); the returned slice is nil only when no variant is resident.
func (s *Server) currentDictVersions() []dictVersion {
if s.registry == nil {
return nil // no dictionary registry wired (e.g. a minimal test server): advertise none
}
variants := []engine.Variant{engine.VariantEnglish, engine.VariantRussianScrabble, engine.VariantErudit}
out := make([]dictVersion, 0, len(variants))
for _, v := range variants {
version, _, err := s.registry.Latest(v)
if err != nil {
continue
}
out = append(out, dictVersion{Variant: v.String(), Version: version})
}
if len(out) == 0 {
return nil
}
return out
}
// fillLinkedIdentities sets the profile's confirmed email address and platform-linked
// flags from the account's identities, so the client offers the right link / unlink /
// change-email controls. A read failure leaves them zero (no controls), logged as a
// warning so the profile response still succeeds.
func (s *Server) fillLinkedIdentities(ctx context.Context, r *profileResponse, accountID uuid.UUID) {
ids, err := s.accounts.Identities(ctx, accountID)
if err != nil {
s.log.Warn("profile: identities read failed", zap.String("account", accountID.String()), zap.Error(err))
return
}
for _, id := range ids {
switch id.Kind {
case account.KindEmail:
if id.Confirmed {
r.Email = id.ExternalID
}
case account.KindTelegram:
r.TelegramLinked = true
case account.KindVK:
r.VkLinked = true
}
}
}
// bannerFor builds the advertising-banner block for the account's profile, or
// nil when the ads service is not configured or the viewer is not eligible to
// see a banner. The message language follows the account's bot (service)
// language, falling back to its interface language and then English. A failure
// reading roles or campaigns is logged and treated as "no banner" so the profile
// response still succeeds.
func (s *Server) bannerFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *bannerDTO {
if s.ads == nil {
return nil
}
set, timings, urgent, err := s.ads.ActiveSet(ctx, bannerLang(acc))
if err != nil {
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
// An urgent campaign is shown to every viewer; otherwise the banner is suppressed by the
// no_banner role or by an active no-ads benefit applicable in the viewer's context (the
// payments gate — a hint balance no longer suppresses the banner). An untrusted platform is
// fail-closed by the gate, so it does not suppress the banner.
if !urgent {
hasNoBanner, err := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner)
if err != nil {
s.log.Warn("banner: role check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
adFree := false
if s.payments != nil {
if adFree, err = s.payments.AdFree(ctx, acc.ID, cxt, present); err != nil {
s.log.Warn("banner: ad-free check failed", zap.String("account", acc.ID.String()), zap.Error(err))
return nil
}
}
if hasNoBanner || adFree {
return nil
}
}
campaigns := make([]bannerCampaignDTO, 0, len(set))
for _, c := range set {
campaigns = append(campaigns, bannerCampaignFromActive(c))
}
return &bannerDTO{
Campaigns: campaigns,
Timings: bannerTimingsDTO{
HoldMs: timings.HoldMs,
EdgePauseMs: timings.EdgePauseMs,
ScrollPxPerSec: timings.ScrollPxPerSec,
FadeOutMs: timings.FadeOutMs,
GapMs: timings.GapMs,
FadeInMs: timings.FadeInMs,
},
}
}
// bannerCampaignFromActive flattens a resolved campaign into its wire DTO,
// projecting each optional colour set into its three "#rrggbb" fields (empty when
// the set is absent, so JSON omitempty drops them).
func bannerCampaignFromActive(c ads.ActiveCampaign) bannerCampaignDTO {
d := bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages}
if c.OverrideAll != nil {
d.OverrideBg, d.OverrideFg, d.OverrideLink = c.OverrideAll.Bg, c.OverrideAll.Fg, c.OverrideAll.Link
}
if c.OverrideDark != nil {
d.OverrideBgDark, d.OverrideFgDark, d.OverrideLinkDark = c.OverrideDark.Bg, c.OverrideDark.Fg, c.OverrideDark.Link
}
return d
}
// bannerLang resolves the message language for a viewer: their interface language
// (Russian, or English by default).
func bannerLang(acc account.Account) string {
if acc.PreferredLanguage == "ru" {
return "ru"
}
return "en"
}
// publishBannerChange emits the in-app "banner eligibility may have changed"
// re-poll signal to the account, so an open client re-fetches profile.get and
// shows or hides the banner without a reload. Best-effort (notify.Nop when no
// notifier is wired).
func (s *Server) publishBannerChange(id uuid.UUID) {
s.notifier.Publish(notify.BannerChanged(id))
}