13be7c3d9a
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 23s
CI / ui (pull_request) Successful in 1m12s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m53s
Interstitial video after a confirmed play or a hint, VK-only, offline banner-only. The gate is client-mirrored: the backend puts the config cooldowns (global 5m / vs_ai 30m / hint 1m) and a suppressed flag (the no-ads / no_banner gate, same as the banner) on Profile.ads via adsFor; the client self-gates on a per-kind last-shown time in localStorage, with the VITE_ADS_STUB contour "ad fired" toast. Never fires after a pass, exchange or resign. maybeShowInterstitial + vkShowInterstitial; the codec and gateway transcode carry the ads block. Also retires the deprecated accounts.hint_balance / paid_account domain usage (expand-contract, code only — the columns stay for a later DROP so image rollback stays DB-safe): drop the Account fields and their scan, the dead account.SpendHint, account.GrantHints and the admin grant-hints action; the in-game hint display now comes wholly from the payments hint benefit. Banner eligibility and account merge no longer read the legacy flags. Tests: ads.test.ts (the client-mirrored gate), the codec ads round-trip, the gateway ads transcode test, and a profile ads-config integration test (cooldowns + suppressed under no-ads / no_banner). Docs: PAYMENTS §10 (+ru) interstitial, the decision amends, backend README, the plan.
249 lines
10 KiB
Go
249 lines
10 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 comes from the payments benefit
|
|
// (context-aware); the deprecated accounts.hint_balance column is no longer read, so on any
|
|
// failure the fallback from profileResponseFor is a plain 0.
|
|
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)
|
|
r.Ads = s.adsFor(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,
|
|
},
|
|
}
|
|
}
|
|
|
|
// adsDTO is the post-move interstitial config in the profile: the client-mirrored cooldowns
|
|
// (seconds) and whether ads are suppressed in the context (a no-ads benefit applicable here, or the
|
|
// no_banner role). The client shows a VK interstitial after a confirmed move / hint only when not
|
|
// suppressed and the mirrored cooldown has elapsed.
|
|
type adsDTO struct {
|
|
CooldownGlobalS int `json:"cooldown_global_s"`
|
|
CooldownVsAiS int `json:"cooldown_vs_ai_s"`
|
|
CooldownHintS int `json:"cooldown_hint_s"`
|
|
Suppressed bool `json:"suppressed"`
|
|
}
|
|
|
|
// adsFor builds the profile interstitial-ad config: the cooldowns and whether ads are suppressed
|
|
// here (the same no-ads / no_banner gate as the banner). A read failure logs and yields a suppressed
|
|
// block (fail-safe: no interstitial), so the profile still succeeds.
|
|
func (s *Server) adsFor(ctx context.Context, acc account.Account, cxt payments.Context, present []payments.Source) *adsDTO {
|
|
if s.payments == nil {
|
|
return nil
|
|
}
|
|
global, vsAi, hint, err := s.payments.InterstitialCooldowns(ctx)
|
|
if err != nil {
|
|
s.log.Warn("profile: ad cooldowns read failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
|
return &adsDTO{Suppressed: true}
|
|
}
|
|
suppressed := false
|
|
if adFree, aerr := s.payments.AdFree(ctx, acc.ID, cxt, present); aerr != nil {
|
|
s.log.Warn("profile: ad-free read failed", zap.String("account", acc.ID.String()), zap.Error(aerr))
|
|
suppressed = true // fail-safe: suppress the interstitial when eligibility is unknown
|
|
} else {
|
|
suppressed = adFree
|
|
}
|
|
if !suppressed {
|
|
if noBanner, berr := s.accounts.HasRole(ctx, acc.ID, account.RoleNoBanner); berr == nil {
|
|
suppressed = noBanner
|
|
}
|
|
}
|
|
return &adsDTO{CooldownGlobalS: global, CooldownVsAiS: vsAi, CooldownHintS: hint, Suppressed: suppressed}
|
|
}
|
|
|
|
// 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))
|
|
}
|