0946a3f66c
CI / changes (pull_request) Successful in 2s
CI / unit (pull_request) Successful in 9s
CI / integration (pull_request) Successful in 14s
CI / ui (pull_request) Successful in 49s
CI / gate (pull_request) Successful in 0s
CI / deploy (pull_request) Successful in 1m12s
Turn the gated-off mock banner into a real advertising subsystem (backend + admin half; the UI rotation lands in PR2). - internal/ads: campaigns (percent weight + validity window; a perpetual, undeletable default that fills the remainder up to 100%), 1..N bilingual messages (en+ru), global display timings; ActiveSet computes the window-filtered, default-remainder, GCD-reduced, language-resolved rotation feed. Smooth-weighted-round-robin math is unit-tested. - migration 00006 (+ jetgen): ad_campaigns / ad_messages / ad_settings, seeded default campaign + house message + default timings. - eligibility = !paid_account && hint_balance==0 && !no_banner role (new role; guests qualify). The resolved feed rides the profile.get response (no new RPC, works for guests, nothing distinct to filter); language by service_language. - live update: a notify `banner` sub-kind (re-poll signal) published when an operator grants hints or grants/revokes no_banner, so the client shows/hides in place. - admin console /_gm/banners (+ /_gm/banner-settings): campaign + message CRUD with reorder, default protection, clamped timings. - wire: fbs BannerInfo/BannerCampaign on Profile; gateway transcode forwards it. - docs: ARCHITECTURE §10, FUNCTIONAL (+ _ru), backend README, PRERELEASE tracker (incl. the deferred app.load aggregator note).
189 lines
6.3 KiB
Go
189 lines
6.3 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")
|
|
|
|
// 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
|
|
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.
|
|
type ActiveCampaign struct {
|
|
Weight int
|
|
Messages []string
|
|
}
|
|
|
|
// 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. Campaigns outside their validity window, and
|
|
// campaigns with no messages, are dropped. 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 {
|
|
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)
|
|
}
|
|
}
|
|
sumTimed := 0
|
|
for _, c := range timed {
|
|
sumTimed += c.Weight
|
|
}
|
|
out := make([]ActiveCampaign, 0, len(timed)+1)
|
|
for _, c := range timed {
|
|
out = append(out, ActiveCampaign{Weight: c.Weight, Messages: resolveBodies(c.Messages, lang)})
|
|
}
|
|
if def != nil {
|
|
if dw := 100 - sumTimed; dw > 0 {
|
|
out = append(out, ActiveCampaign{Weight: dw, Messages: resolveBodies(def.Messages, lang)})
|
|
}
|
|
}
|
|
reduceByGCD(out)
|
|
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
|
|
}
|