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

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:
Ilia Denisov
2026-06-15 23:00:19 +02:00
parent f59c8dcd43
commit 0946a3f66c
45 changed files with 2993 additions and 28 deletions
+100
View File
@@ -0,0 +1,100 @@
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))
}
+4
View File
@@ -49,6 +49,10 @@ type profileResponse struct {
BlockFriendRequests bool `json:"block_friend_requests"`
IsGuest bool `json:"is_guest"`
NotificationsInAppOnly bool `json:"notifications_in_app_only"`
// Banner is the advertising-banner block: present only for a viewer eligible to
// see the banner (a free account with an empty hint wallet and without the
// no_banner role), absent otherwise. See banner.go.
Banner *bannerDTO `json:"banner,omitempty"`
}
// tileDTO is one placed (or to-place) tile.
@@ -0,0 +1,323 @@
package server
import (
"errors"
"strconv"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/ads"
)
// bannersBack is the campaign-list path the banner console actions return to.
const bannersBack = "/_gm/banners"
// localTimeLayout is the datetime-local input/round-trip form of a validity-window
// bound; the operator's entry is interpreted as UTC.
const localTimeLayout = "2006-01-02T15:04"
// consoleBanners lists the advertising campaigns (default first), each with its
// weight, validity window, enabled flag, message count and live-now status.
func (s *Server) consoleBanners(c *gin.Context) {
campaigns, err := s.ads.ListCampaigns(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
now := time.Now().UTC()
var view adminconsole.BannersView
for _, cm := range campaigns {
view.Items = append(view.Items, adminconsole.BannerCampaignRow{
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
Window: windowLabel(cm),
Messages: len(cm.Messages),
ActiveNow: cm.ActiveAt(now),
})
}
s.renderConsole(c, "banners", "banners", "Banners", view)
}
// consoleBannerDetail renders one campaign's edit form and its messages.
func (s *Server) consoleBannerDetail(c *gin.Context) {
id, ok := s.consoleUUID(c, bannersBack)
if !ok {
return
}
cm, err := s.ads.Campaign(c.Request.Context(), id)
if err != nil {
s.bannerError(c, err, bannersBack)
return
}
view := adminconsole.BannerDetailView{
ID: cm.ID.String(),
Name: cm.Name,
Weight: cm.Weight,
IsDefault: cm.IsDefault,
Enabled: cm.Enabled,
StartsAt: localInput(cm.StartsAt),
EndsAt: localInput(cm.EndsAt),
}
for i, m := range cm.Messages {
view.Messages = append(view.Messages, adminconsole.BannerMessageRow{
ID: m.ID.String(),
BodyEn: m.BodyEn,
BodyRu: m.BodyRu,
First: i == 0,
Last: i == len(cm.Messages)-1,
})
}
s.renderConsole(c, "banner_detail", "banners", cm.Name, view)
}
// consoleCreateBanner creates a new time-limited campaign.
func (s *Server) consoleCreateBanner(c *gin.Context) {
starts, ends, err := parseWindow(c)
if err != nil {
s.renderConsoleMessage(c, "Invalid window", err.Error(), bannersBack)
return
}
id, err := s.ads.CreateCampaign(c.Request.Context(), ads.Campaign{
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
})
if err != nil {
s.bannerError(c, err, bannersBack)
return
}
s.renderConsoleMessage(c, "Created", "campaign created", "/_gm/banners/"+id.String())
}
// consoleUpdateBanner saves a campaign's editable fields. For the default
// campaign the service ignores everything but the name.
func (s *Server) consoleUpdateBanner(c *gin.Context) {
id, ok := s.consoleUUID(c, bannersBack)
if !ok {
return
}
back := "/_gm/banners/" + id.String()
starts, ends, err := parseWindow(c)
if err != nil {
s.renderConsoleMessage(c, "Invalid window", err.Error(), back)
return
}
err = s.ads.UpdateCampaign(c.Request.Context(), ads.Campaign{
ID: id,
Name: trimForm(c, "name"),
Weight: atoiForm(c, "weight"),
Enabled: c.PostForm("enabled") != "",
StartsAt: starts,
EndsAt: ends,
})
if err != nil {
s.bannerError(c, err, back)
return
}
s.renderConsoleMessage(c, "Saved", "campaign updated", back)
}
// consoleDeleteBanner removes a campaign (the service refuses the default).
func (s *Server) consoleDeleteBanner(c *gin.Context) {
id, ok := s.consoleUUID(c, bannersBack)
if !ok {
return
}
if err := s.ads.DeleteCampaign(c.Request.Context(), id); err != nil {
s.bannerError(c, err, "/_gm/banners/"+id.String())
return
}
s.renderConsoleMessage(c, "Deleted", "campaign deleted", bannersBack)
}
// consoleAddBannerMessage appends a bilingual message to a campaign.
func (s *Server) consoleAddBannerMessage(c *gin.Context) {
id, ok := s.consoleUUID(c, bannersBack)
if !ok {
return
}
back := "/_gm/banners/" + id.String()
if _, err := s.ads.AddMessage(c.Request.Context(), id, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil {
s.bannerError(c, err, back)
return
}
s.renderConsoleMessage(c, "Added", "message added", back)
}
// consoleEditBannerMessage rewrites a message's bilingual bodies.
func (s *Server) consoleEditBannerMessage(c *gin.Context) {
cid, mid, ok := s.bannerMessageIDs(c)
if !ok {
return
}
back := "/_gm/banners/" + cid.String()
if err := s.ads.EditMessage(c.Request.Context(), mid, trimForm(c, "body_en"), trimForm(c, "body_ru")); err != nil {
s.bannerError(c, err, back)
return
}
s.renderConsoleMessage(c, "Saved", "message updated", back)
}
// consoleDeleteBannerMessage removes a message (the service keeps the default's
// last one).
func (s *Server) consoleDeleteBannerMessage(c *gin.Context) {
cid, mid, ok := s.bannerMessageIDs(c)
if !ok {
return
}
back := "/_gm/banners/" + cid.String()
if err := s.ads.DeleteMessage(c.Request.Context(), cid, mid); err != nil {
s.bannerError(c, err, back)
return
}
s.renderConsoleMessage(c, "Deleted", "message deleted", back)
}
// consoleMoveBannerMessage reorders a message up or down within its campaign.
func (s *Server) consoleMoveBannerMessage(c *gin.Context) {
cid, mid, ok := s.bannerMessageIDs(c)
if !ok {
return
}
back := "/_gm/banners/" + cid.String()
dir := 1
if trimForm(c, "dir") == "up" {
dir = -1
}
if err := s.ads.MoveMessage(c.Request.Context(), cid, mid, dir); err != nil {
s.bannerError(c, err, back)
return
}
s.renderConsoleMessage(c, "Moved", "message reordered", back)
}
// consoleBannerSettings renders the global display-timings form.
func (s *Server) consoleBannerSettings(c *gin.Context) {
t, err := s.ads.Settings(c.Request.Context())
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsole(c, "banner_settings", "banners", "Banner settings", adminconsole.BannerSettingsView{
HoldMs: t.HoldMs,
EdgePauseMs: t.EdgePauseMs,
ScrollPxPerSec: t.ScrollPxPerSec,
FadeOutMs: t.FadeOutMs,
GapMs: t.GapMs,
FadeInMs: t.FadeInMs,
})
}
// consoleUpdateBannerSettings saves the global display timings (clamped by the
// service into their safe ranges).
func (s *Server) consoleUpdateBannerSettings(c *gin.Context) {
err := s.ads.UpdateSettings(c.Request.Context(), ads.Timings{
HoldMs: atoiForm(c, "hold_ms"),
EdgePauseMs: atoiForm(c, "edge_pause_ms"),
ScrollPxPerSec: atoiForm(c, "scroll_px_per_sec"),
FadeOutMs: atoiForm(c, "fade_out_ms"),
GapMs: atoiForm(c, "gap_ms"),
FadeInMs: atoiForm(c, "fade_in_ms"),
})
if err != nil {
s.consoleError(c, err)
return
}
s.renderConsoleMessage(c, "Saved", "display timings updated", "/_gm/banner-settings")
}
// bannerMessageIDs parses the campaign :id and message :mid path parameters,
// rendering an invalid-id message and returning false when either is not a UUID.
func (s *Server) bannerMessageIDs(c *gin.Context) (campaignID, messageID uuid.UUID, ok bool) {
cid, err := uuid.Parse(c.Param("id"))
if err != nil {
s.renderConsoleMessage(c, "Invalid id", "the campaign id in the URL is not valid", bannersBack)
return uuid.Nil, uuid.Nil, false
}
mid, err := uuid.Parse(c.Param("mid"))
if err != nil {
s.renderConsoleMessage(c, "Invalid id", "the message id in the URL is not valid", "/_gm/banners/"+cid.String())
return uuid.Nil, uuid.Nil, false
}
return cid, mid, true
}
// bannerError maps an ads validation/immutability/not-found error to a friendly
// console message, and anything else to the generic error page.
func (s *Server) bannerError(c *gin.Context, err error, back string) {
switch {
case errors.Is(err, ads.ErrValidation), errors.Is(err, ads.ErrDefaultImmutable):
s.renderConsoleMessage(c, "Not saved", err.Error(), back)
case errors.Is(err, ads.ErrNotFound):
s.renderConsoleMessage(c, "Not found", "no such campaign or message", back)
default:
s.consoleError(c, err)
}
}
// parseWindow reads the optional starts_at/ends_at datetime-local form fields,
// interpreting each as UTC; an empty field is an open-ended bound (nil).
func parseWindow(c *gin.Context) (starts, ends *time.Time, err error) {
if starts, err = parseLocalTime(trimForm(c, "starts_at")); err != nil {
return nil, nil, errors.New("the start time is not a valid date/time")
}
if ends, err = parseLocalTime(trimForm(c, "ends_at")); err != nil {
return nil, nil, errors.New("the end time is not a valid date/time")
}
return starts, ends, nil
}
// parseLocalTime parses a datetime-local value as UTC, or returns nil for an
// empty string.
func parseLocalTime(s string) (*time.Time, error) {
if s == "" {
return nil, nil
}
t, err := time.Parse(localTimeLayout, s)
if err != nil {
return nil, err
}
t = t.UTC()
return &t, nil
}
// localInput formats a validity-window bound for a datetime-local input, or ""
// when open-ended.
func localInput(t *time.Time) string {
if t == nil {
return ""
}
return t.UTC().Format(localTimeLayout)
}
// windowLabel builds the human-readable validity window for the campaign list.
func windowLabel(c ads.Campaign) string {
if c.IsDefault {
return "perpetual"
}
switch {
case c.StartsAt == nil && c.EndsAt == nil:
return "always"
case c.StartsAt == nil:
return "until " + fmtTimePtr(c.EndsAt)
case c.EndsAt == nil:
return "from " + fmtTimePtr(c.StartsAt)
default:
return fmtTimePtr(c.StartsAt) + " " + fmtTimePtr(c.EndsAt)
}
}
// atoiForm returns the integer value of a posted form field, or 0 when missing
// or non-numeric (the ads service clamps timings and validates weights).
func atoiForm(c *gin.Context, name string) int {
n, _ := strconv.Atoi(trimForm(c, name))
return n
}
@@ -88,6 +88,19 @@ func (s *Server) registerConsole(router *gin.Engine) {
gm.POST("/dictionary/changes/apply", s.consoleApplyChanges)
gm.GET("/broadcast", s.consoleBroadcast)
gm.POST("/broadcast", s.consolePostBroadcast)
if s.ads != nil {
gm.GET("/banners", s.consoleBanners)
gm.POST("/banners", s.consoleCreateBanner)
gm.GET("/banners/:id", s.consoleBannerDetail)
gm.POST("/banners/:id", s.consoleUpdateBanner)
gm.POST("/banners/:id/delete", s.consoleDeleteBanner)
gm.POST("/banners/:id/messages", s.consoleAddBannerMessage)
gm.POST("/banners/:id/messages/:mid", s.consoleEditBannerMessage)
gm.POST("/banners/:id/messages/:mid/delete", s.consoleDeleteBannerMessage)
gm.POST("/banners/:id/messages/:mid/move", s.consoleMoveBannerMessage)
gm.GET("/banner-settings", s.consoleBannerSettings)
gm.POST("/banner-settings", s.consoleUpdateBannerSettings)
}
}
// consoleDashboard renders the landing page: the top-line counts and the resident
@@ -817,6 +830,8 @@ func (s *Server) consoleGrantHints(c *gin.Context) {
s.consoleError(c, err)
return
}
// A non-empty hint wallet removes the banner: nudge an open client to re-check.
s.publishBannerChange(id)
s.renderConsoleMessage(c, "Granted", fmt.Sprintf("added %d hint(s); the wallet is now %d", n, balance), back)
}
@@ -245,6 +245,9 @@ func (s *Server) consoleGrantRole(c *gin.Context) {
s.consoleError(c, err)
return
}
if role == account.RoleNoBanner {
s.publishBannerChange(id)
}
s.renderConsoleMessage(c, "Role granted", "granted "+role, back)
}
@@ -264,6 +267,9 @@ func (s *Server) consoleRevokeRole(c *gin.Context) {
s.consoleError(c, err)
return
}
if role == account.RoleNoBanner {
s.publishBannerChange(id)
}
s.renderConsoleMessage(c, "Role revoked", "revoked "+role, back)
}
+3 -1
View File
@@ -23,7 +23,9 @@ func (s *Server) handleProfile(c *gin.Context) {
s.abortErr(c, err)
return
}
c.JSON(http.StatusOK, profileResponseFor(acc))
resp := profileResponseFor(acc)
resp.Banner = s.bannerFor(c.Request.Context(), acc)
c.JSON(http.StatusOK, resp)
}
// submitPlayRequest places tiles on the player's turn; the engine infers the
+19
View File
@@ -19,12 +19,14 @@ import (
"scrabble/backend/internal/account"
"scrabble/backend/internal/adminconsole"
"scrabble/backend/internal/ads"
"scrabble/backend/internal/connector"
"scrabble/backend/internal/engine"
"scrabble/backend/internal/feedback"
"scrabble/backend/internal/game"
"scrabble/backend/internal/link"
"scrabble/backend/internal/lobby"
"scrabble/backend/internal/notify"
"scrabble/backend/internal/ratewatch"
"scrabble/backend/internal/session"
"scrabble/backend/internal/social"
@@ -81,6 +83,14 @@ type Deps struct {
// admin console's throttled view + the high-rate auto-flag. A nil RateWatch
// disables the internal report endpoint and the console view.
RateWatch *ratewatch.Watch
// Ads is the advertising-banner domain service: campaign rotation feeding the
// profile.get banner block, plus the banner admin console section. A nil Ads
// omits the banner block and disables the banner console.
Ads *ads.Service
// Notifier publishes live-event intents — here the banner-eligibility re-poll
// signal the banner/hint/role console actions emit. A nil Notifier discards
// them (notify.Nop).
Notifier notify.Publisher
}
// Server owns the gin engine, the underlying HTTP server and the readiness
@@ -105,6 +115,8 @@ type Server struct {
dictDir string
connector *connector.Client
ratewatch *ratewatch.Watch
ads *ads.Service
notifier notify.Publisher
console *adminconsole.Renderer
public *gin.RouterGroup
@@ -129,6 +141,11 @@ func New(addr string, deps Deps) *Server {
engine.Use(gin.Recovery())
engine.Use(telemetry.Middleware(log))
notifier := deps.Notifier
if notifier == nil {
notifier = notify.Nop{}
}
s := &Server{
log: log,
db: deps.DB,
@@ -147,6 +164,8 @@ func New(addr string, deps Deps) *Server {
dictDir: deps.DictDir,
connector: deps.Connector,
ratewatch: deps.RateWatch,
ads: deps.Ads,
notifier: notifier,
http: &http.Server{Addr: addr, Handler: engine},
}
s.registerProbes(engine)