Тема
ТЗ — Баннеры/слайдеры (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_zones | id, slug, title, external_id (nullable) | зона показа (баннер-место) |
cms_banners | id, zone_id, media_id, url, starts_at, ends_at, weight, city_id (nullable), locale (nullable), lock_version, external_id (nullable) | сам баннер с таргетингом |
cms_banner_stats | banner_id, date, impressions, clicks | суточные агрегированные счётчики показов/кликов |
zone_id — constrained()->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, title | FormRequest-whitelist, slug уникален |
Admin CRUD баннера (/api/v1/admin/banners) | zone_id, media_id, url, starts_at, ends_at, weight, city_id, locale, lock_version | FormRequest-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, weight | props блока, схема версии _v, demo-props в галерее |
| Виджет «баннер-место» | один/несколько баннеров зоны области темы | рендер темы через BannerService |
Публичный API (GET /api/v1/banners/{zone}) | активные баннеры зоны с учётом таргетинга | конверт {data, meta} |
| Filament-отчёт | показы/клики по баннеру за период | таблица/график в ресурсе |
| Шина событий | BannerClicked / BannerExpired | payload → подписчики (инвалидатор page-cache, cms/audit) |
| Legacy-импорт | отчёт прогона | --json: создано/обновлено/пропущено/ошибки построчно |
Всё, что не перечислено как вход, модуль отвергает (whitelist-принцип §11 стандарта) — в частности, публичный клик не принимает произвольный banner_id, а сверяет его с уже показанным зоне набором.
Настройки (группа banners)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
banners.rotation_strategy | string | weighted | да | Стратегия ротации (random/weighted/sequential) |
banners.slider_autoplay_ms | int | 5000 | нет | Интервал автопрокрутки слайдера |
banners.track_clicks | bool | true | нет | Учёт кликов (счётчик, без внешней аналитики) |
banners.max_image_size_kb | int | 2048 | нет | Максимальный размер загружаемого изображения баннера |
banners.kill_switch | bool | false | да | Аварийное отключение показа всех баннеров (зоны/слайдер отдают fallback) без выключения модуля |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/banners/{zone} | public | Активные баннеры зоны с учётом таргетинга |
| POST | /api/v1/banners/{id}/click | public (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 не использует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
BannerClicked | 1 — шина событий | banners → подписчики | учёт клика (батч/атомарный инкремент), при включённом cms/audit — запись в аудит |
BannerExpired | 1 — шина событий | banners → подписчики | инвалидация тега banners (page-cache зоны), при включённом cms/audit — запись |
RequestContext (city/locale) | сервис-вызов cms/core-contracts | ядро → banners | выборка активных баннеров зоны учитывает текущий контекст запроса |
MediaService | сервис-вызов cms/core-contracts | banners → ядро | загрузка/конверсии/привязка изображения баннера |
cms/multicity | suggests | banners ↔ multicity | включён — таргетинг по city_id активен; не установлен — измерение nullable, показ всем городам без ошибки |
Настройка banners.kill_switch | settings-store | banners → рендер зон | включена — зоны/слайдер отдают 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/multicity—city_idnullable, баннер с пустым городом показывается всем (контрактный тест гоняется в режимах «с модулем» и «без»). - Конкурентное редактирование баннера двумя админами —
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запрещены