feat(ads): server-driven ad-banner backend, wire & admin console (PR1)
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
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).
This commit is contained in:
@@ -0,0 +1,188 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user