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).
101 lines
3.4 KiB
Go
101 lines
3.4 KiB
Go
package server
|
|
|
|
import (
|
|
"context"
|
|
|
|
"github.com/google/uuid"
|
|
"go.uber.org/zap"
|
|
|
|
"scrabble/backend/internal/account"
|
|
"scrabble/backend/internal/ads"
|
|
"scrabble/backend/internal/notify"
|
|
)
|
|
|
|
// bannerDTO is the advertising-banner block attached to an eligible viewer's
|
|
// profile: the campaigns to rotate (each already resolved to the viewer's bot
|
|
// language) and the global display timings the client rotator reads. It is
|
|
// absent for a viewer who should see no banner.
|
|
type bannerDTO struct {
|
|
Campaigns []bannerCampaignDTO `json:"campaigns"`
|
|
Timings bannerTimingsDTO `json:"timings"`
|
|
}
|
|
|
|
// 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.
|
|
type bannerCampaignDTO struct {
|
|
Weight int `json:"weight"`
|
|
Messages []string `json:"messages"`
|
|
}
|
|
|
|
// bannerTimingsDTO mirrors ads.Timings on the wire.
|
|
type bannerTimingsDTO struct {
|
|
HoldMs int `json:"hold_ms"`
|
|
EdgePauseMs int `json:"edge_pause_ms"`
|
|
ScrollPxPerSec int `json:"scroll_px_per_sec"`
|
|
FadeOutMs int `json:"fade_out_ms"`
|
|
GapMs int `json:"gap_ms"`
|
|
FadeInMs int `json:"fade_in_ms"`
|
|
}
|
|
|
|
// bannerFor builds the advertising-banner block for the account's profile, or
|
|
// nil when the ads service is not configured or the viewer is not eligible to
|
|
// see a banner. The message language follows the account's bot (service)
|
|
// language, falling back to its interface language and then English. A failure
|
|
// reading roles or campaigns is logged and treated as "no banner" so the profile
|
|
// response still succeeds.
|
|
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))
|
|
if err != nil {
|
|
s.log.Warn("banner: active set failed", zap.String("account", acc.ID.String()), zap.Error(err))
|
|
return nil
|
|
}
|
|
campaigns := make([]bannerCampaignDTO, 0, len(set))
|
|
for _, c := range set {
|
|
campaigns = append(campaigns, bannerCampaignDTO{Weight: c.Weight, Messages: c.Messages})
|
|
}
|
|
return &bannerDTO{
|
|
Campaigns: campaigns,
|
|
Timings: bannerTimingsDTO{
|
|
HoldMs: timings.HoldMs,
|
|
EdgePauseMs: timings.EdgePauseMs,
|
|
ScrollPxPerSec: timings.ScrollPxPerSec,
|
|
FadeOutMs: timings.FadeOutMs,
|
|
GapMs: timings.GapMs,
|
|
FadeInMs: timings.FadeInMs,
|
|
},
|
|
}
|
|
}
|
|
|
|
// bannerLang resolves the message language for a viewer: the bot (service)
|
|
// language they last signed in through, falling back to the interface language
|
|
// and then English.
|
|
func bannerLang(acc account.Account) string {
|
|
if acc.ServiceLanguage == "ru" || acc.ServiceLanguage == "en" {
|
|
return acc.ServiceLanguage
|
|
}
|
|
if acc.PreferredLanguage == "ru" {
|
|
return "ru"
|
|
}
|
|
return "en"
|
|
}
|
|
|
|
// publishBannerChange emits the in-app "banner eligibility may have changed"
|
|
// re-poll signal to the account, so an open client re-fetches profile.get and
|
|
// shows or hides the banner without a reload. Best-effort (notify.Nop when no
|
|
// notifier is wired).
|
|
func (s *Server) publishBannerChange(id uuid.UUID) {
|
|
s.notifier.Publish(notify.BannerChanged(id))
|
|
}
|