6db9178449
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 10s
CI / integration (pull_request) Successful in 15s
CI / ui (pull_request) Successful in 1m8s
CI / conformance (pull_request) Successful in 9s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m50s
Non-default campaigns gain an optional colour override (background / text / link) in two sets — one for every theme, one for the dark theme only — and an "urgent" flag. - Colours ride profile.get as six trailing FlatBuffers strings on BannerCampaign (backward-compatible). The client resolves the cascade (dark <- dark ?? all, light <- all) per rendered theme and derives the strip border from the background in JS (no CSS color-mix, for the old Android WebView floor); AdBanner applies them as inline vars scoped to the strip. - Urgent is resolved entirely server-side: while any enabled, in-window urgent campaign exists, computeActiveSet returns only the urgent campaigns and bannerFor skips the eligibility gate — so a system notice reaches every viewer (paid / hint-holding / no_banner included) and preempts the ordinary feed. No wire field; it appears on each viewer's next profile.get. - Admin console (/_gm/banners): native colour pickers + a live light/dark preview of the strip, and an urgent toggle. The default campaign stays plain, enforced by the service and a DB CHECK. Migration 00009 is additive (nullable colour columns + a bool default + all-or-nothing / hex / default-plain CHECKs) — expand-contract, rollback-safe. Docs: ARCHITECTURE §10, UI_DESIGN, FUNCTIONAL (+ru). Tests: ads unit (urgent preempt + colour validation), codec + resolver unit, gateway transcode, and integration (colour round-trip + urgent bypass against real Postgres).
253 lines
8.8 KiB
Go
253 lines
8.8 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
|
|
}
|
|
|
|
// Eligible reports whether an account should be shown the advertising banner: a
|
|
// free account (not paid) with an empty hint wallet and without the no_banner
|
|
// role. The no_banner role suppresses the banner unconditionally; buying a paid
|
|
// account or any hints also removes it.
|
|
func Eligible(paidAccount bool, hintBalance int, hasNoBanner bool) bool {
|
|
return !paidAccount && hintBalance <= 0 && !hasNoBanner
|
|
}
|
|
|
|
// 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
|
|
}
|