Skip to content

ТЗ — Баннеры/слайдеры (cms/banners)

Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: несколько Статус: ТЗ к разработке

🔄 Ревизия (корпоративный MVP, 15.07.2026, решение №13 сессии «порог по переиспользованию»): переиспользуемые управляемые наборы (слайдеры/баннеры) — пресет типа контента на движке типов контента (слайд = запись со свойствами/связями), а разовые списки баннеров в одной секции — репитер-поля блока. Слайдер-блок этого ТЗ выводит набор из движка. При ревизии решить, что остаётся модулю (зоны показа, ротация, статистика показов) — эти части движок не покрывает.

⚠️ Статус тела ТЗ: раздел «Модель данных» и всё тело ТЗ ниже (модель cms_banners*/ зоны, роуты /api/v1/banners/*, Filament-ресурс, права banners.manage, тесты) — исторический слой, написанный до пивота 15.07.2026: описывают целевые правила ротации/таргетинга через историческую реализацию собственными таблицами. При реализации переиспользуемый набор становится пресетом движка — хранение cms_content_entries, свойства/связи по карте пресетов (engine §13); админку/API даёт движок (ContentEntryResource, /api/v1/content/{slug}). Волной F (15.07.2026) пресет реализован тонким модулем: программная регистрация типа slides (slider_key filterable → partial-индекс, sort sortable; схема после создания принадлежит инсталляции — sync не перетирает), блоки slider (CSS-карусель scroll-snap без JS) и banner, записи ведутся генерик-ресурсом движка. Зоны показа/ ротация/статистика — по-прежнему вне пресета (исторический слой ниже). Полная ревизия ТЗ — отдельная задача непосредственно перед реализацией пресета (content-types-engine §16). Этот файл — эталонный 16-разделный ТЗ; правка баннера не меняет его роль образца.

Назначение и возможности

Баннер-места (зоны показа) с ротацией, таргетингом по странице/городу/локали, периодом показа и учётом кликов. Слайдер как блок для вставки в контент, баннер-место — как виджет для областей темы.

  • Зоны показа (баннер-места): главная, каталог, sidebar и т.д. — конфигурируемый список
  • Ротация нескольких баннеров в одной зоне (случайная/по весу/по порядку)
  • Таргетинг: страница/раздел, город, локаль, устройство (desktop/mobile)
  • Период показа: starts_at/ends_at, автоматическое скрытие по истечении
  • Учёт показов и кликов (счётчики, без полноценной BI-аналитики)
  • Слайдер-блок (BlockRegistry) с несколькими баннерами и автопрокруткой
  • Виджет «баннер-место» для областей темы вне блочного контента

Зависимости и выключение

requires: ядро (cms/core-contracts) · suggests: cms/multicity (таргетинг по городу из RequestContext) · suggests: cms/audit (запись CRUD-действий в аудит-лог, если включён).

Без cms/multicity измерение city_id — просто nullable-поле (правило измерений §4 стандарта): баннер с city_id = null показывается во всех городах, деградация без ветвления кода и без ошибки.

Поведение при выключении модуля: зоны баннеров и слайдер-блок отдают fallback-заглушку (пусто), статистика показов/кликов сохраняется, но не пополняется до включения.

Модель данных

ТаблицаКлючевые поляПримечание
cms_banner_zonesid, slug, title, external_id (nullable)зона показа (баннер-место)
cms_bannersid, zone_id, media_id, url, starts_at, ends_at, weight, city_id (nullable), locale (nullable), lock_version, external_id (nullable)сам баннер с таргетингом
cms_banner_statsbanner_id, date, impressions, clicksсуточные агрегированные счётчики показов/кликов

zone_idconstrained()->cascadeOnDelete()->index(); banner_id+date — составной уникальный индекс на cms_banner_stats (upsert-цель для атомарного инкремента, BRIN по date при большом объёме); starts_at/ends_at — составной индекс под выборку активных баннеров; external_id — уникален в пределах таблицы, используется только legacy-импортом (идемпотентность повторного прогона, §16 стандарта); lock_version — optimistic lock (§4 стандарта) на конкурентное редактирование баннера в админке.

ПДн-паспорт: модуль не хранит персональных данных. cms_banner_stats — обезличенные суточные агрегаты (impressions/clicks по баннеру и дате), без IP, cookie или привязки к пользователю/сессии. Хуки «выгрузить/забыть по субъекту» не применимы — декларация «ПДн не храню».

Входные и выходные данные

Вход:

ИсточникПоляВалидация
Публичный клик (POST /api/v1/banners/{id}/click)banner_id (путь)баннер обязан входить в набор, уже отданный публичным GET этой зоне (активен, не истёк); rate-limit по IP/сессии
Admin CRUD зоны (/api/v1/admin/banner-zones)slug, titleFormRequest-whitelist, slug уникален
Admin CRUD баннера (/api/v1/admin/banners)zone_id, media_id, url, starts_at, ends_at, weight, city_id, locale, lock_versionFormRequest-whitelist; media_id существует в медиатеке; url — только http(s) или относительный путь; starts_at < ends_at; lock_version сверяется — иначе 409
Рендер зоны (блок/виджет → сервис)zone.slug, RequestContext (city/locale)внутренний вызов BannerService, не форма — блок/виджет не читает БД напрямую
Legacy-импорт (cms:banners:import-legacy)строки донорской таблицы: зона, изображение, ссылка, период, городмаппинг профиля источника; идемпотентность по external_id; --dry-run без записи

Выход:

ПотребительДанныеФормат
Слайдер-блок (BlockRegistry)активные баннеры зоны: media_url, alt, url, weightprops блока, схема версии _v, demo-props в галерее
Виджет «баннер-место»один/несколько баннеров зоны области темырендер темы через BannerService
Публичный API (GET /api/v1/banners/{zone})активные баннеры зоны с учётом таргетингаконверт {data, meta}
Filament-отчётпоказы/клики по баннеру за периодтаблица/график в ресурсе
Шина событийBannerClicked / BannerExpiredpayload → подписчики (инвалидатор page-cache, cms/audit)
Legacy-импортотчёт прогона--json: создано/обновлено/пропущено/ошибки построчно

Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта) — в частности, публичный клик не принимает произвольный banner_id, а сверяет его с уже показанным зоне набором.

Настройки (группа banners)

КлючТипДефолтaffectsPageCacheОписание
banners.rotation_strategystringweightedдаСтратегия ротации (random/weighted/sequential)
banners.slider_autoplay_msint5000нетИнтервал автопрокрутки слайдера
banners.track_clicksbooltrueнетУчёт кликов (счётчик, без внешней аналитики)
banners.max_image_size_kbint2048нетМаксимальный размер загружаемого изображения баннера
banners.kill_switchboolfalseдаАварийное отключение показа всех баннеров (зоны/слайдер отдают fallback) без выключения модуля

API

МетодПутьДоступНазначение
GET/api/v1/banners/{zone}publicАктивные баннеры зоны с учётом таргетинга
POST/api/v1/banners/{id}/clickpublic (rate-limit)Фиксация клика по баннеру
GET/POST/PUT/DELETE/api/v1/admin/banners…admin (banners.manage)CRUD зон и баннеров

Мутации CRUD баннера принимают lock_version — рассинхрон отдаёт 409 (конвенции ядра §7 стандарта). Списки в админке — по общим правилам API ядра (конверт, whitelist фильтров).

Компоненты

Блоки (BlockRegistry): «Слайдер» — версия _v, demo-props, lazy-load автопрокрутки вне первого экрана, srcset/адаптивные изображения, зарезервированные размеры под изображение (без CLS), клавиатурная навигация (стрелки/точки — tabindex, aria-label). Виджеты: «Баннер-место». Filament: ресурсы зон и баннеров с превью периода показа, afterSave() → инвалидация тега banners, отчёт по показам/кликам. Демо-сидер баннеров и зон — для галереи блоков и playground (модуль показывается без ручного ввода). Команды: cms:banners:expire-check --json, cms:banners:import-legacy --source=<профиль> --json.

События и обмен

СобытиеКогдаPayload
BannerClickedзафиксирован клик по баннеруbanner_id, zone_id
BannerExpiredбаннер вышел из периода показаbanner_id, zone_id

Слушает: —. Provides-контрактов не реализует, FilterBus не использует.

Таблица взаимодействий:

Сущность/модульКаналНаправлениеЧто происходит
BannerClicked1 — шина событийbanners → подписчикиучёт клика (батч/атомарный инкремент), при включённом cms/audit — запись в аудит
BannerExpired1 — шина событийbanners → подписчикиинвалидация тега banners (page-cache зоны), при включённом cms/audit — запись
RequestContext (city/locale)сервис-вызов cms/core-contractsядро → bannersвыборка активных баннеров зоны учитывает текущий контекст запроса
MediaServiceсервис-вызов cms/core-contractsbanners → ядрозагрузка/конверсии/привязка изображения баннера
cms/multicitysuggestsbannersmulticityвключён — таргетинг по city_id активен; не установлен — измерение nullable, показ всем городам без ошибки
Настройка banners.kill_switchsettings-storebanners → рендер зонвключена — зоны/слайдер отдают fallback, публичный API отдаёт пустой набор

Фоновая работа

Джоба cms:banners:expire-check (очередь banners) — по расписанию через ScheduleRegistrar ядра проверяет ends_at и публикует BannerExpired; идемпотентна — повторный/пропущенный прогон (в т.ч. после выключения модуля посреди работы) не публикует событие повторно для уже помеченного баннера.

Производительность и кеш

Ожидаемые объёмы на активном сайте: десятки зон, сотни баннеров, тысячи показов/кликов в сутки. Горячий путь — выборка активных баннеров зоны на каждом рендере страницы: бюджет — 1 закешированный запрос на зону, не на баннер (->with('media'), без N+1). Ключ кеша включает zone_id + измерения RequestContext (city_id, locale).

Критичные индексы: zone_id (FK+index), составной starts_at/ends_at под выборку активных, уникальный banner_id+date на cms_banner_stats (upsert-цель атомарного инкремента, BRIN по date).

Тег кеша banners объявляется через CacheTags (§10 стандарта); инвалидируется модулем при создании/правке/удалении баннера или зоны, при BannerExpired, при изменении настроек группы banners (rotation_strategy, kill_switch). Счётчики показов/кликов кешу не подлежат — пишутся напрямую атомарным UPDATE, не через выборку-изменение-запись.

Безопасность

Границы входа: FormRequest-whitelist на CRUD зон/баннеров (админка); публичный клик — rate-limit (защита от накрутки статистики) и сверка banner_id с реально показанным набором (см. «Входные и выходные данные» — произвольный ID не принимается). Медиа баннера — только через медиатеку ядра, с проверкой лимита banners.max_image_size_kb и человеческим сообщением об ошибке при превышении/неверном формате.

URL перехода — валидируется границей ядра: разрешены только http(s) и относительные пути (/...), любые другие схемы (javascript:, data:, vbscript:) отклоняются на входе.

Защита от накрутки касается не только кликов, но и показов: показ фиксируется на сервере в момент выборки зоны (не отдельным публичным POST), поэтому у показов нет отдельной атакуемой конечной точки; при батч-агрегации показов в очереди применяется дедупликация по zone_id + IP/сессия в окне агрегации, чтобы один визит не раздувал impressions.

Матрица ролей:

Действиеadminменеджерредакторstudio
Просмотр статистики показов/кликов
CRUD зоны показа
CRUD баннера✅ (без изменения периода показа)
Удаление баннера с активной статистикой кликов✅ (с подтверждением)
banners.kill_switch (аварийное отключение)

Права: banners.view, banners.manage.

UX-требования

Админ:

  • Пустая зона без баннеров — не пустая таблица, а подсказка «в зоне нет баннеров» с кнопкой «Добавить баннер».
  • Массовое действие в списке баннеров: массовая деактивация истёкших (ends_at в прошлом).
  • Ошибка загрузки изображения — конкретный текст («Изображение превышает 2 МБ», «Формат не поддерживается — допустимы JPG, PNG, WebP»), не generic «Error».
  • Удаление баннера с ненулевой статистикой кликов — подтверждение с цифрами («У баннера N показов и M кликов — удалить безвозвратно?»).
  • Конфликт редактирования (lock_version) — человеческое сообщение «Баннер изменён другим пользователем, обновите страницу», не молчаливая перезапись.

Посетитель:

  • Слайдер не создаёт CLS: размеры под изображение зарезервированы (width/height или aspect-ratio) до загрузки.
  • Клик по баннеру не блокирует переход: навигация выполняется сразу, фиксация клика — асинхронно (fetch keepalive/sendBeacon); сбой фиксации не отменяет и не задерживает переход.
  • Слайдер управляется с клавиатуры (стрелки между слайдами, точки-навигация — фокусируемы).

Крайние случаи и типовые баги

  • Счётчики показов/кликов при высокой конкурентности — не read-modify-write из PHP (гонка теряет инкременты), а атомарный UPDATE cms_banner_stats SET clicks = clicks + 1 WHERE banner_id = ? AND date = ? (upsert при отсутствии строки дня) либо очередь с батч-агрегацией (кладём факт клика в очередь, воркер раз в интервал схлопывает пачку в один UPDATE на баннер).
  • Баннер с истёкшим ends_at уже лежит в page-cache — отдаётся до следующей инвалидации. Окно рассинхрона ограничено интервалом expire-check (не TTL общего page-cache): джоба публикует BannerExpired → тег banners инвалидируется сразу, поэтому фактическое окно — периодичность расписания джобы, а не cache.ttl.
  • Вес картинки баннера и CLS: обязательные width/height (или aspect-ratio) + srcset; баннеры вне первого экрана зоны — loading="lazy", hero-слайдер на главной — eager/preload.
  • Таргетинг по городу при отсутствии cms/multicitycity_id nullable, баннер с пустым городом показывается всем (контрактный тест гоняется в режимах «с модулем» и «без»).
  • Конкурентное редактирование баннера двумя админами — lock_version, конфликт → 409, не «последний победил» молча.
  • Выключение модуля посреди expire-check — джоба идемпотентна: при следующем прогоне (после включения) баннеры, уже отмеченные истёкшими, повторно событие не получают.
  • Зона без активных баннеров — виджет/слайдер отдают пустой fallback (ничего не рендерят либо демо-заглушку в dev), не 404/500 у остальной страницы.
  • Накрутка кликов ботом сверх лимита — 429, счётчик клика не увеличивается (проверка rate-limit выполняется до инкремента, не после).
  • Баннер без locale — трактуется как «показывать на всех локалях сайта», не как ошибка конфигурации; отсутствие явного значения — не повод скрывать баннер.
  • ⚠️ Противоречие: rotation_strategy = sequential подразумевает состояние «какой баннер показан последним» между запросами, а бюджет производительности требует 1 закешированный запрос на зону (статичный набор до инвалидации тега). Серверная пораундовая ротация конфликтует с кешированием выборки. Предложение: «sequential» — это фиксированный порядок элементов в закешированном массиве (по weight/created_at), а фактическую прокрутку между баннерами делает клиентский слайдер; сервер не хранит курсор ротации между запросами.

Донорский код

Что взятьПуть
Ротация и таргетинг баннеровнесколько проектов (пути не выданы)

Legacy-импорт: cms:banners:import-legacy --source=<профиль> — маппинг старых баннерных таблиц донорских проектов (зона/картинка/ссылка/период/город) на cms_banner_zones/cms_banners; идемпотентен по external_id (повторный прогон обновляет, не дублирует); --dry-run выводит отчёт расхождений без записи. Зоны сопоставляются по slug, баннеры — по external_id. Прогон на копии боевых данных донора — часть приёмки модуля.

Тесты и приёмка

  • [ ] Контрактный тест /api/v1/banners/{zone} учитывает таргетинг (город/локаль/период)
  • [ ] При выключении модуля зоны и слайдер отдают пустой fallback без 500
  • [ ] Клик фиксируется не чаще лимита rate-limit (защита от накрутки статистики)
  • [ ] Клик по banner_id вне текущего показанного набора зоны отклоняется
  • [ ] Права banners.manage разграничены от публичного показа; матрица ролей покрыта тестом
  • [ ] Нет N+1 при выборке активных баннеров зоны (->with('media'))
  • [ ] Инвалидация page-cache зоны по тегу banners при изменении баннера и при BannerExpired
  • [ ] Атомарный инкремент impressions/clicks не теряет обновления при параллельных запросах
  • [ ] Конкурентное редактирование баннера двумя админами → 409 по lock_version
  • [ ] banners.kill_switch отключает показ во всех зонах без выключения модуля
  • [ ] Превышение banners.max_image_size_kb или неверный формат — человеческая ошибка, не 500
  • [ ] Legacy-импорт идемпотентен по external_id, --dry-run не пишет в БД
  • [ ] Контрактный набор cms-testing зелёный, пакет тестируется в testbench-изоляции
  • [ ] Feature-тест на каждый роут (зона, клик, admin CRUD)
  • [ ] Тестовая БД только banners_test; migrate:fresh/refresh/reset/db:wipe запрещены

Внутренняя база знаний студии