feat(banner): per-campaign colour overrides and urgent alerts
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).
This commit is contained in:
Ilia Denisov
2026-07-05 15:36:35 +02:00
parent ac383880b7
commit 6db9178449
32 changed files with 1297 additions and 152 deletions
+42 -13
View File
@@ -22,10 +22,20 @@ type bannerDTO struct {
// 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.
// 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"`
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.
@@ -83,22 +93,27 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
if s.ads == nil {
return nil
}
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
}
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
return nil
}
set, timings, err := s.ads.ActiveSet(ctx, bannerLang(acc))
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 normal eligibility
// gate applies (a paid account, a non-empty hint wallet or the no_banner role
// hides 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
}
if !ads.Eligible(acc.PaidAccount, acc.HintBalance, hasNoBanner) {
return nil
}
}
campaigns := make([]bannerCampaignDTO, 0, len(set))
for _, c := range set {
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
campaigns = append(campaigns, bannerCampaignFromActive(c))
}
return &bannerDTO{
Campaigns: campaigns,
@@ -113,6 +128,20 @@ func (s *Server) bannerFor(ctx context.Context, acc account.Account) *bannerDTO
}
}
// 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 {