Files
scrabble-game/backend/internal/ads/ads.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

245 lines
8.4 KiB
Go

// Package ads owns the server-driven advertising banner ("advertising network"):
// operator-managed campaigns, their bilingual messages and the global display
// timings the client's one-line announcement strip rotates through.
//
// A campaign is one placement order with a show weight (an integer percent).
// Simultaneously active campaigns compete for display slots in proportion to
// their weights; the single perpetual default campaign fills the unsold
// remainder up to 100%. The actual rotation runs client-side (a smooth weighted
// round-robin over campaigns, round-robin over a campaign's messages); the server
// only computes the effective weighted, language-resolved set a viewer should
// rotate, via ActiveSet. Who sees a banner at all is decided by Eligible.
package ads
import (
"errors"
"time"
"github.com/google/uuid"
)
// ErrNotFound is returned when a campaign or message does not exist.
var ErrNotFound = errors.New("ads: not found")
// ErrDefaultImmutable is returned when an operation is refused on the perpetual
// default campaign (it cannot be deleted, disabled, windowed, or have its
// weight or default flag changed).
var ErrDefaultImmutable = errors.New("ads: the default campaign cannot be modified that way")
// ErrValidation wraps an operator-facing validation failure (a bad weight,
// window or message body).
var ErrValidation = errors.New("ads: validation")
// ColorSet is an optional per-campaign colour override for the banner strip:
// background, foreground (text) and link, each a "#rrggbb" hex string. The three
// are set together or the whole set is absent (a nil *ColorSet).
type ColorSet struct {
Bg string
Fg string
Link string
}
// Campaign is one advertising placement order with its messages.
type Campaign struct {
ID uuid.UUID // uuid.Nil on create
Name string
Weight int // show percent, 1..100; nominal (ignored) for the default
IsDefault bool
Enabled bool
StartsAt *time.Time // nil = open-ended start; always nil for the default
EndsAt *time.Time // nil = open-ended end; always nil for the default
Messages []Message
// OverrideAll paints the strip on every theme; OverrideDark, when set, further
// overrides the dark theme (the client resolves dark ← dark ?? all ?? token,
// light ← all ?? token). Both are nil for the default campaign and for a
// campaign that keeps the neutral theme tokens. Non-default only.
OverrideAll *ColorSet
OverrideDark *ColorSet
// Urgent forces the banner on every viewer (bypassing eligibility) and, while
// any urgent campaign is active, suppresses every non-urgent campaign and the
// default remainder. Non-default only; always false for the default campaign.
Urgent bool
CreatedAt time.Time
UpdatedAt time.Time
}
// Message is one bilingual creative of a campaign. Both bodies are mandatory;
// the client shows the variant for the viewer's bot (service) language. Position
// orders the messages within a campaign (the round-robin order).
type Message struct {
ID uuid.UUID // uuid.Nil on create
CampaignID uuid.UUID
Position int
BodyEn string
BodyRu string
}
// Timings are the global banner display timings, in milliseconds except
// ScrollPxPerSec. HoldMs is how long one message shows; EdgePauseMs and
// ScrollPxPerSec drive the scroll of a message wider than the strip; a
// transition between messages is FadeOutMs then GapMs then FadeInMs.
type Timings struct {
HoldMs int
EdgePauseMs int
ScrollPxPerSec int
FadeOutMs int
GapMs int
FadeInMs int
}
// ActiveCampaign is one campaign in the resolved rotation feed sent to a client:
// its GCD-reduced show weight and its messages, already resolved to the viewer's
// language and in display (round-robin) order, plus the optional colour overrides
// the client applies to the strip (nil = the neutral theme tokens). Urgency is not
// carried here: it is resolved server-side into the set's membership and the
// eligibility bypass, so the client only ever renders what it is sent.
type ActiveCampaign struct {
Weight int
Messages []string
OverrideAll *ColorSet
OverrideDark *ColorSet
}
// computeActiveSet builds the resolved rotation feed from the enabled campaigns
// at time now, in language lang, and reports whether the feed is an urgent one.
// Campaigns outside their validity window, and campaigns with no messages, are
// dropped.
//
// While any active campaign is urgent, the feed is the urgent campaigns alone —
// every non-urgent timed campaign and the default remainder are suppressed for
// the duration (and the caller shows the feed to every viewer, bypassing
// eligibility). Otherwise the default campaign's effective weight is the
// remainder up to 100% — max(0, 100 - sum of active timed weights) — so it fills
// unsold inventory and is dropped entirely when timed campaigns already reach
// 100%. Weights are then reduced by their GCD so the fair rotation cycle stays
// short. The input is expected to be the enabled campaigns (ActiveCampaigns);
// disabled ones must already be excluded.
func computeActiveSet(campaigns []Campaign, now time.Time, lang string) ([]ActiveCampaign, bool) {
var timed []Campaign
var def *Campaign
for i := range campaigns {
c := campaigns[i]
if len(c.Messages) == 0 {
continue // a campaign with no creative cannot fill a slot
}
if c.IsDefault {
def = &campaigns[i]
continue
}
if withinWindow(c, now) {
timed = append(timed, c)
}
}
if urgent := filterUrgent(timed); len(urgent) > 0 {
out := make([]ActiveCampaign, 0, len(urgent))
for _, c := range urgent {
out = append(out, activeFrom(c, lang))
}
reduceByGCD(out)
return out, true
}
sumTimed := 0
for _, c := range timed {
sumTimed += c.Weight
}
out := make([]ActiveCampaign, 0, len(timed)+1)
for _, c := range timed {
out = append(out, activeFrom(c, lang))
}
if def != nil {
if dw := 100 - sumTimed; dw > 0 {
a := activeFrom(*def, lang)
a.Weight = dw // the default's stored weight is nominal; it fills the remainder
out = append(out, a)
}
}
reduceByGCD(out)
return out, false
}
// activeFrom projects a campaign to its rotation-feed entry: its show weight, its
// language-resolved messages and its colour overrides (carried through by
// reference, they are read-only).
func activeFrom(c Campaign, lang string) ActiveCampaign {
return ActiveCampaign{
Weight: c.Weight,
Messages: resolveBodies(c.Messages, lang),
OverrideAll: c.OverrideAll,
OverrideDark: c.OverrideDark,
}
}
// filterUrgent returns the urgent campaigns among cs, preserving order. It is the
// preempt selector: a non-empty result makes the whole feed urgent.
func filterUrgent(cs []Campaign) []Campaign {
var out []Campaign
for _, c := range cs {
if c.Urgent {
out = append(out, c)
}
}
return out
}
// ActiveAt reports whether the campaign would rotate at time now: enabled, and
// either the perpetual default or within its validity window. It mirrors the
// filter computeActiveSet applies (a campaign with no messages still reports
// active here — that is a content gap the console surfaces, not an inactive
// campaign).
func (c Campaign) ActiveAt(now time.Time) bool {
return c.Enabled && (c.IsDefault || withinWindow(c, now))
}
// withinWindow reports whether now lies inside the campaign's validity window. A
// nil bound is open-ended on that side.
func withinWindow(c Campaign, now time.Time) bool {
if c.StartsAt != nil && now.Before(*c.StartsAt) {
return false
}
if c.EndsAt != nil && now.After(*c.EndsAt) {
return false
}
return true
}
// resolveBodies projects each message to the body for lang ("ru" picks the
// Russian body; anything else picks English), preserving display order.
func resolveBodies(msgs []Message, lang string) []string {
out := make([]string, len(msgs))
for i, m := range msgs {
if lang == "ru" {
out[i] = m.BodyRu
} else {
out[i] = m.BodyEn
}
}
return out
}
// reduceByGCD divides every weight by the greatest common divisor of all weights
// (in place), shrinking a {50,30,20} set to {5,3,2} and a lone {100} to {1}. A
// no-op when the set is empty or already coprime.
func reduceByGCD(cs []ActiveCampaign) {
g := 0
for _, c := range cs {
g = gcd(g, c.Weight)
}
if g <= 1 {
return
}
for i := range cs {
cs[i].Weight /= g
}
}
// gcd returns the greatest common divisor of a and b (gcd(0, n) == n).
func gcd(a, b int) int {
for b != 0 {
a, b = b, a%b
}
if a < 0 {
return -a
}
return a
}