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).
266 lines
8.0 KiB
Go
266 lines
8.0 KiB
Go
package ads
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// Operator-input bounds and the display-timing clamps. The timings are clamped
|
|
// (not rejected) on update so the client rotator can never be driven into a
|
|
// broken state by a typo in the console.
|
|
const (
|
|
maxCampaignName = 80
|
|
maxMessageBody = 500
|
|
|
|
minHoldMs = 3000 // 3s — below this the fade transition would dominate
|
|
maxHoldMs = 600000 // 10 min
|
|
|
|
maxEdgePauseMs = 60000
|
|
|
|
minScrollPxPerSec = 5
|
|
maxScrollPxPerSec = 1000
|
|
|
|
maxFadeMs = 5000
|
|
)
|
|
|
|
// Service is the domain layer over Store: it validates operator input, enforces
|
|
// the default campaign's invariants, clamps the display timings, and assembles
|
|
// the resolved rotation feed (ActiveSet) for a viewer.
|
|
type Service struct {
|
|
store *Store
|
|
}
|
|
|
|
// NewService constructs a Service over store.
|
|
func NewService(store *Store) *Service { return &Service{store: store} }
|
|
|
|
// ActiveSet returns the resolved rotation feed for a viewer in language lang
|
|
// (en/ru) together with the global display timings: the currently-active
|
|
// campaigns, each with its GCD-reduced show weight and its messages resolved to
|
|
// lang, ready for the client's weighted round-robin.
|
|
func (s *Service) ActiveSet(ctx context.Context, lang string) ([]ActiveCampaign, Timings, error) {
|
|
campaigns, err := s.store.ActiveCampaigns(ctx)
|
|
if err != nil {
|
|
return nil, Timings{}, err
|
|
}
|
|
timings, err := s.store.Settings(ctx)
|
|
if err != nil {
|
|
return nil, Timings{}, err
|
|
}
|
|
return computeActiveSet(campaigns, time.Now().UTC(), lang), timings, nil
|
|
}
|
|
|
|
// ListCampaigns returns every campaign with its messages, for the admin console.
|
|
func (s *Service) ListCampaigns(ctx context.Context) ([]Campaign, error) {
|
|
return s.store.ListCampaigns(ctx)
|
|
}
|
|
|
|
// Campaign returns one campaign with its messages, or ErrNotFound.
|
|
func (s *Service) Campaign(ctx context.Context, id uuid.UUID) (Campaign, error) {
|
|
return s.store.Campaign(ctx, id)
|
|
}
|
|
|
|
// CreateCampaign validates and creates a new time-limited campaign (never the
|
|
// default) and returns its id.
|
|
func (s *Service) CreateCampaign(ctx context.Context, c Campaign) (uuid.UUID, error) {
|
|
name, err := validName(c.Name)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
if err := validWeight(c.Weight); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
return s.store.CreateCampaign(ctx, Campaign{
|
|
Name: name, Weight: c.Weight, Enabled: c.Enabled, StartsAt: c.StartsAt, EndsAt: c.EndsAt,
|
|
})
|
|
}
|
|
|
|
// UpdateCampaign validates and updates a campaign. The default campaign keeps its
|
|
// weight (nominal), enabled flag and (empty) window — only its name is editable;
|
|
// any submitted weight/window/enabled are ignored for it.
|
|
func (s *Service) UpdateCampaign(ctx context.Context, c Campaign) error {
|
|
existing, err := s.store.Campaign(ctx, c.ID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
name, err := validName(c.Name)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
upd := Campaign{ID: existing.ID, Name: name}
|
|
if existing.IsDefault {
|
|
upd.Weight = existing.Weight
|
|
upd.Enabled = true
|
|
upd.StartsAt = nil
|
|
upd.EndsAt = nil
|
|
} else {
|
|
if err := validWeight(c.Weight); err != nil {
|
|
return err
|
|
}
|
|
if err := validWindow(c.StartsAt, c.EndsAt); err != nil {
|
|
return err
|
|
}
|
|
upd.Weight = c.Weight
|
|
upd.Enabled = c.Enabled
|
|
upd.StartsAt = c.StartsAt
|
|
upd.EndsAt = c.EndsAt
|
|
}
|
|
return s.store.UpdateCampaign(ctx, upd)
|
|
}
|
|
|
|
// DeleteCampaign removes a campaign, refusing the perpetual default.
|
|
func (s *Service) DeleteCampaign(ctx context.Context, id uuid.UUID) error {
|
|
existing, err := s.store.Campaign(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if existing.IsDefault {
|
|
return ErrDefaultImmutable
|
|
}
|
|
return s.store.DeleteCampaign(ctx, id)
|
|
}
|
|
|
|
// AddMessage validates a bilingual message and appends it to a campaign.
|
|
func (s *Service) AddMessage(ctx context.Context, campaignID uuid.UUID, bodyEn, bodyRu string) (uuid.UUID, error) {
|
|
en, ru, err := validBodies(bodyEn, bodyRu)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
c, err := s.store.Campaign(ctx, campaignID)
|
|
if err != nil {
|
|
return uuid.Nil, err
|
|
}
|
|
return s.store.AddMessage(ctx, Message{CampaignID: campaignID, Position: len(c.Messages), BodyEn: en, BodyRu: ru})
|
|
}
|
|
|
|
// EditMessage validates and updates a message's bilingual bodies (its position is
|
|
// unchanged).
|
|
func (s *Service) EditMessage(ctx context.Context, id uuid.UUID, bodyEn, bodyRu string) error {
|
|
en, ru, err := validBodies(bodyEn, bodyRu)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return s.store.UpdateMessageBodies(ctx, id, en, ru)
|
|
}
|
|
|
|
// MoveMessage reorders a message within its campaign by swapping its position
|
|
// with the adjacent message in direction dir (-1 up, +1 down). A move past an
|
|
// edge is a no-op.
|
|
func (s *Service) MoveMessage(ctx context.Context, campaignID, messageID uuid.UUID, dir int) error {
|
|
c, err := s.store.Campaign(ctx, campaignID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
idx := -1
|
|
for i, m := range c.Messages {
|
|
if m.ID == messageID {
|
|
idx = i
|
|
break
|
|
}
|
|
}
|
|
if idx < 0 {
|
|
return ErrNotFound
|
|
}
|
|
j := idx + dir
|
|
if j < 0 || j >= len(c.Messages) {
|
|
return nil // already at the edge
|
|
}
|
|
a, b := c.Messages[idx], c.Messages[j]
|
|
if err := s.store.SetMessagePosition(ctx, a.ID, j); err != nil {
|
|
return err
|
|
}
|
|
return s.store.SetMessagePosition(ctx, b.ID, idx)
|
|
}
|
|
|
|
// DeleteMessage removes a message, refusing to remove the default campaign's last
|
|
// remaining message (the default must always have a creative to show).
|
|
func (s *Service) DeleteMessage(ctx context.Context, campaignID, messageID uuid.UUID) error {
|
|
c, err := s.store.Campaign(ctx, campaignID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if c.IsDefault && len(c.Messages) <= 1 {
|
|
return fmt.Errorf("%w: the default campaign must keep at least one message", ErrValidation)
|
|
}
|
|
return s.store.DeleteMessage(ctx, messageID)
|
|
}
|
|
|
|
// Settings returns the global display timings.
|
|
func (s *Service) Settings(ctx context.Context) (Timings, error) {
|
|
return s.store.Settings(ctx)
|
|
}
|
|
|
|
// UpdateSettings clamps every timing into its safe range and stores them.
|
|
func (s *Service) UpdateSettings(ctx context.Context, t Timings) error {
|
|
return s.store.UpdateSettings(ctx, clampTimings(t))
|
|
}
|
|
|
|
// validName trims and bounds a campaign name.
|
|
func validName(name string) (string, error) {
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", fmt.Errorf("%w: the campaign name is required", ErrValidation)
|
|
}
|
|
if len([]rune(name)) > maxCampaignName {
|
|
return "", fmt.Errorf("%w: the campaign name must be at most %d characters", ErrValidation, maxCampaignName)
|
|
}
|
|
return name, nil
|
|
}
|
|
|
|
// validWeight bounds a campaign show weight to the 1..100 percent range.
|
|
func validWeight(w int) error {
|
|
if w < 1 || w > 100 {
|
|
return fmt.Errorf("%w: the weight must be a percent between 1 and 100", ErrValidation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validWindow rejects an inverted validity window.
|
|
func validWindow(starts, ends *time.Time) error {
|
|
if starts != nil && ends != nil && ends.Before(*starts) {
|
|
return fmt.Errorf("%w: the end must not precede the start", ErrValidation)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// validBodies trims and bounds both mandatory language bodies of a message.
|
|
func validBodies(bodyEn, bodyRu string) (string, string, error) {
|
|
en := strings.TrimSpace(bodyEn)
|
|
ru := strings.TrimSpace(bodyRu)
|
|
if en == "" || ru == "" {
|
|
return "", "", fmt.Errorf("%w: both the English and Russian message bodies are required", ErrValidation)
|
|
}
|
|
if len([]rune(en)) > maxMessageBody || len([]rune(ru)) > maxMessageBody {
|
|
return "", "", fmt.Errorf("%w: a message body must be at most %d characters", ErrValidation, maxMessageBody)
|
|
}
|
|
return en, ru, nil
|
|
}
|
|
|
|
// clampTimings forces every display timing into its safe range.
|
|
func clampTimings(t Timings) Timings {
|
|
return Timings{
|
|
HoldMs: clampInt(t.HoldMs, minHoldMs, maxHoldMs),
|
|
EdgePauseMs: clampInt(t.EdgePauseMs, 0, maxEdgePauseMs),
|
|
ScrollPxPerSec: clampInt(t.ScrollPxPerSec, minScrollPxPerSec, maxScrollPxPerSec),
|
|
FadeOutMs: clampInt(t.FadeOutMs, 0, maxFadeMs),
|
|
GapMs: clampInt(t.GapMs, 0, maxFadeMs),
|
|
FadeInMs: clampInt(t.FadeInMs, 0, maxFadeMs),
|
|
}
|
|
}
|
|
|
|
func clampInt(v, lo, hi int) int {
|
|
if v < lo {
|
|
return lo
|
|
}
|
|
if v > hi {
|
|
return hi
|
|
}
|
|
return v
|
|
}
|