Тема
ТЗ — Лендинги (cms/landings)
Слой: 🟡 функц-модуль · Зрелость доноров: ★ · Донор: studio-core (панель студии; отдельного пути нет) Статус: ТЗ к разработке
Назначение и возможности
Отдельные посадочные страницы вне основного дерева сайта (рекламные акции, промо-кампании) со своим неймспейсом slug'ов, независимым набором шаблонов темы и статистикой конверсии. Поддерживает A/B-крючья, формы захвата лидов и жизненный цикл акции целиком (публикация → истечение срока → замена).
- Отдельный slug-неймспейс лендингов (
url_prefix), не пересекающийся с деревом основного сайта - Шаблоны-лендинги темы (упрощённая вёрстка без общего меню/футера при необходимости)
- Крючья для подключения
cms/ab-testing(вариант выбирается до формирования ключа кеша) - Встроенные формы ядра для захвата лидов на лендинге
- Статистика конверсии лендинга (просмотры → заявки) в админке
- Публикация/снятие с публикации лендинга независимо от основного контента
- Копирование лендинга со всеми блоками одним действием (черновик-дубликат)
- Публикация по сроку акции: авто-снятие по
expires_at, снятый лендинг отдаёт 410 либо 301 наreplacement_landing_id, если замена указана
Зависимости и выключение
requires: ядро (формы, темизация, PageRepository для проверки конфликтов slug) · suggests: cms/ab-testing (выбор варианта), cms/popups (поп-апы поверх лендинга)
Поведение при выключении: опубликованные лендинги перестают открываться (404), формы захвата лидов на них не принимают заявки, фоновые job'ы (агрегация статистики, авто-снятие по сроку) не выполняются — основной сайт не затрагивается, деградация ограничена лендингами. При повторном включении лендинги и статистика не теряются (свои таблицы никогда не чистятся при disable — только явный uninstall).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_landings | id, slug, title, template, status, site_id, locale, city_id, published_at, expires_at, replacement_landing_id, lock_version | посадочная страница со своим шаблоном |
cms_landing_stats | landing_id, date, views, conversions | посуточная статистика конверсии |
FK landing_id — constrained() + index(); replacement_landing_id — self-FK nullOnDelete(); status — PHP Enum (draft, published, archived); уникальный индекс (site_id, slug) под неймспейс лендингов (мультисайт-измерение — §4 стандарта, nullable); индекс на expires_at (выборка job'ой авто-снятия) и на status (фильтры админки); cms_landing_stats — журнальная посуточная таблица, кандидат на BRIN по date, уникальный индекс (landing_id, date) от дублей агрегации (upsert). lock_version — optimistic lock (§4 стандарта): конкурентная правка двух админов отдаёт 409, а не «последний победил».
ПДн-паспорт. Модуль не хранит ПДн напрямую: cms_landing_stats — обезличенные агрегаты (счётчики), заявки с формы лендинга сохраняются в cms_leads ядра (владелец — подсистема форм/лидов, см. ТЗ ядра); в cms_landings ПДн нет. Хуки «выгрузить всё по субъекту» / «забыть по запросу» (152-ФЗ) реализует владелец cms_leads, не этот модуль. Событие LandingConversionRecorded несёт только form_submission_id (ссылку), не содержимое формы. Ретеншн cms_landing_stats — не про ПДн, а про объём диска: landings.stats_retention_days (см. «Настройки»).
Входные и выходные данные
Входы
| Источник | Поля | Чем валидируется |
|---|---|---|
| Admin-форма создания/правки лендинга | slug, title, template, status, site_id, locale, city_id, published_at, expires_at, replacement_landing_id, blocks[] | FormRequest (whitelist) + схемы полей блоков (FieldTypeRegistry) |
| Admin bulk-действие «копировать» | landing_id[] | FormRequest, id только из cms_landings |
| Admin bulk-действие «архивировать истёкшие» | landing_id[] (либо все с expires_at в прошлом) | FormRequest, permission landings.delete |
| Публичная форма захвата лида на лендинге | поля по декларации конструктора форм ядра + скрытый landing_id | rules движка полей ядра, rate-limit, honeypot/капча (см. API ядра) |
cms:landings:import-legacy --source=<профиль> | старые id/slug/title/template/status/published_at, external_id | маппер профиля + те же FormRequest-правила, что и в админке |
Всё, что не перечислено выше, модуль обязан отвергать (whitelist-принцип §11 стандарта).
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Посетитель (публичный рендер) | HTML лендинга (блоки шаблона) либо 410/301 при истечении | HTML через BlockRegistry, тема |
| Admin API | список/детали лендинга, статистика конверсии | JSON {data, meta}, keyset-пагинация |
| EventBus | LandingPublished, LandingConversionRecorded, LandingExpired | payload JSON (см. «События и обмен») |
cms/ab-testing (опционально, если включён) | landing_id, кандидаты вариантов | вызов сервиса резолва варианта |
Очередь landings | landing_id, date | job-payload (aggregate-stats, purge-stats, авто-снятие) |
Отчёт import-legacy | прочитано/создано/обновлено/пропущено + построчные ошибки | CSV/JSON, скачиваемый |
Настройки (группа landings)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
landings.enabled | bool | true | да | Включение публикации лендингов |
landings.url_prefix | string | /l | да | Префикс пути slug-неймспейса лендингов |
landings.stats_enabled | bool | true | нет | Сбор посуточной статистики конверсии |
landings.stats_retention_days | int | 400 | нет | Ретеншн cms_landing_stats; старше — удаляется job'ой |
landings.max_active | int | 500 | нет | Лимит published-лендингов на сайт; достижение — 422 при попытке опубликовать ещё один, не тихий отказ |
landings.auto_unpublish_enabled | bool | true | да | Kill-switch: авто-снятие по expires_at; выключение останавливает только эту автодействие, ручная публикация/снятие продолжают работать |
landings.expired_response | enum(410,301) | 410 | да | Ответ для истёкшего лендинга без replacement_landing_id (301 требует явно указанной замены — иначе принудительно 410) |
Секретов нет (провайдеры A/B-тестов/попапов конфигурируются в своих модулях, не здесь).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/landings | admin (landings.view) | Список лендингов со статусами (keyset-пагинация) |
| POST | /api/v1/admin/landings | admin (landings.manage) | Создание/правка лендинга |
| POST | /api/v1/admin/landings/{id}/copy | admin (landings.manage) | Копирование лендинга со всеми блоками (черновик) |
| DELETE | /api/v1/admin/landings/{id} | admin (landings.delete) | Удаление (подтверждение в UI, необратимо) |
| GET | /api/v1/admin/landings/{id}/stats | admin (landings.view) | Статистика конверсии лендинга |
Компоненты
Filament: редактор лендинга (шаблон, публикация, expires_at/replacement_landing_id), массовые действия на списке (копировать, архивировать истёкшие — с подтверждением), дашборд статистики конверсии. Команды: cms:landings:aggregate-stats --json, cms:landings:purge-stats --dry-run --json, cms:landings:expire-check --dry-run --json (ручной прогон авто-снятия) — все с --json, восстановительные идемпотентны.
Фронтенд-бюджет и a11y. Лендинг рендерится теми же блоками темы, что и обычная страница — собственных тяжёлых ассетов модуль не добавляет; блок-виджет обратного отсчёта акции (если используется темой) — lazy-init по видимости, резерв высоты (без CLS), таймер доступен с клавиатуры и озвучивается скринридером (aria-live="polite", без агрессивных объявлений).
Демо-контент. Сидер создаёт 1–2 демо-лендинга (блоки hero/CTA/форма, expires_at в будущем) для галереи /_gallery и playground — без ручного ввода.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
LandingPublished | лендинг опубликован | landing_id, slug |
LandingConversionRecorded | зафиксирована заявка с лендинга | landing_id, form_submission_id |
LandingExpired | авто-снятие по expires_at (или ручное архивирование истёкшего) | landing_id, slug, replacement_landing_id |
Слушателей у модуля нет — весь исходящий обмен идёт через сервис-вызовы (ядро) и опциональный вызов cms/ab-testing; входящих подписок модуль не имеет.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: PageRepository | requires-сервис-вызов | landings → ядро | проверка конфликта slug лендинга с деревом страниц, если url_prefix пуст/пересекается |
Ядро: формы / LeadService | requires-сервис-вызов | лендинг → ядро | встроенная форма ядра создаёт лид, привязывает landing_id |
Ядро: BlockRegistry/WidgetRegistry/CacheTags | регистрация на bootstrap | landings → ядро | шаблоны-лендинги как компоненты темы, тег кеша landings:<id> |
cms/ab-testing (suggests) | requires-сервис-вызов, опциональный (guarded) | landings → ab-testing | резолв варианта лендинга до формирования ключа кеша (см. «Производительность и кеш») |
cms/popups (suggests) | нет прямого канала — попапы читают RequestContext/область показа | косвенно, через ядро | поп-ап показывается поверх лендинга по своим условиям, лендинг о попапе не знает |
Очередь landings | очередь | landings → воркер | aggregate-stats, purge-stats, авто-снятие по expires_at |
Подписчики LandingConversionRecorded (любые) | событие | landings → все | например cms/loyalty может начислить бонус за конверсию — landings об этом не знает |
Фоновая работа
Очередь landings:
aggregate-stats— плановая (черезScheduleRegistrar) агрегация посуточных просмотров/конверсий, upsert по(landing_id, date)— идемпотентен при ретрае;purge-stats— плановая очисткаcms_landing_statsстаршеlandings.stats_retention_days;- авто-снятие по
expires_at— периодический прогон (ScheduleRegistrar, например раз в 5 минут), батчами черезBus::batch(утилизация ядер на большом объёме лендингов), гейтитсяlandings.auto_unpublish_enabled(kill-switch) иlandings.enabled(при выключенном модуле — no-op, а не ошибка).
Публикация/снятие вручную — синхронные (админ видит результат сразу).
Метрики и алерты. Счётчики: активные/истёкшие лендинги, длительность aggregate-stats, отставание очереди landings, число просроченных (expires_at в прошлом, но статус ещё published) лендингов — backlog авто-снятия. Алерты: backlog авто-снятия больше N минут (cms/health); aggregate-stats не выполнялась > 1 суток.
Мини-ранбук.
| Симптом | Что проверить | Чем чинить |
|---|---|---|
| Статистика конверсии не обновляется днями | лог очереди landings, cms:landings:aggregate-stats --json | ручной прогон --date=<пропущенная> (backfill) |
| Истёкший лендинг всё ещё отдаёт 200 | не выключен ли landings.auto_unpublish_enabled (kill-switch), лог планировщика | включить настройку либо cms:landings:expire-check вручную |
Рост cms_landing_stats не останавливается | landings.stats_retention_days, лог purge-stats | cms:landings:purge-stats --dry-run, затем без флага |
| Посетители видят разные версии лендинга без A/B | ключ page-cache (варианта в нём быть не должно, если ab-testing выключен) | cms:cache:inspect + принудительная инвалидация тега landings:<id> |
| Массовое 404 после отката/сбоя модуля | статус в cms:doctor --json, включён ли landings.enabled | включить модуль/настройку — данные не теряются (§3 деградация) |
Бэкап/рестор. В бэкап — cms_landings и cms_landing_stats целиком (сырые события просмотров не хранятся отдельно, восстановить агрегаты после потери неоткуда). После рестора пересчёта не требуется; aggregate-stats продолжает копить дальнейшие дни как обычно.
Производительность и кеш
Ожидаемый объём: сотни–низкие тысячи лендингов на сайт, cms_landing_stats растёт линейно (лендинги × дни активности), ограничена stats_retention_days. Горячий путь — публичный рендер опубликованного лендинга: page-cache hit = 0 запросов к БД (бюджет §6/§10 стандарта); запись просмотра — лёгкий инкремент вне транзакции рендера (не блокирует ответ, переживает сбой очереди — см. «Крайние случаи»).
Критичные индексы: уникальный (site_id, slug) на cms_landings, индекс на expires_at (выборка job'ой авто-снятия) и на status (фильтры админки), уникальный (landing_id, date) + BRIN по date на cms_landing_stats.
Собственный тег кеша — landings:<id> (объявляется в CacheTags), инвалидируется событиями LandingPublished/LandingExpired/правкой блоков — не «flush всего кеша». url_prefix, enabled, auto_unpublish_enabled, expired_response помечены affectsPageCache. Если включён cms/ab-testing — вариант резолвится до формирования ключа кеша, и ключ включает id варианта (несколько закешированных версий одного лендинга); без ab-testing — один ключ, дефолтный вариант. Статистика просмотров не участвует в ключе и не инвалидирует кеш при каждом просмотре.
Безопасность
Публикация/правка лендинга — только под landings.manage (удаление и массовые разрушительные действия — landings.delete) через FormRequest-whitelist; формы захвата лидов используют стандартную санитизацию, rate-limit и капчу ядра — модуль не пишет собственных проверок. Slug-неймспейс проверяется на конфликт с деревом основного сайта через PageRepository при создании/правке (см. «События и обмен»); при штатном url_prefix конфликт с деревом ядра структурно маловероятен, но проверка выполняется всегда, а не только когда префикс пуст или изменён вручную. {!! !!}/доверенный HTML в блоках лендинга — только studio-роль, как в ядре.
Матрица ролей.
| Permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
landings.view | ✅ | ✅ | ✅ | ✅ |
landings.manage (создание/правка/публикация/копирование) | ✅ | — | ✅ | ✅ |
landings.delete (удаление, массовое архивирование истёкших) | ✅ | — | — | ✅ |
Менеджер видит список и статистику конверсии (для отчётности), но не публикует и не удаляет; разрушительные и массовые действия — только админ/studio (§11 стандарта: опасные действия только повышенным ролям).
ПДн — см. «Модель данных» (модуль своих ПДн не хранит). В логи и метрики модуля не попадают содержимое форм и данные посетителей — только landing_id и агрегаты.
Права: landings.view, landings.manage, landings.delete.
UX-требования
Админ. Пустой список лендингов — подсказка «Нет лендингов — создайте первый» с кнопкой создания, а не пустая таблица. Массовые действия в списке: копировать, архивировать выбранные/все истёкшие — с превью «затронет N лендингов» перед подтверждением (необратимые операции требуют явного подтверждения). Ошибки на человеческом языке: конфликт slug → «Такой адрес уже занят на сайте, выберите другой» (не техническая 500-строка); конкурентная правка → «Лендинг изменён другим пользователем, обновите страницу» (409, не молчаливая перезапись).
Посетитель. Форма захвата лида при ошибке валидации сохраняет введённые значения (кроме файлов) и переводит фокус на первое невалидное поле — повторный ввод с нуля недопустим. Опубликованный лендинг из page-cache отдаётся без обращений к БД — воспринимаемая скорость как у обычной страницы. Блоки лендинга (в т.ч. таймер акции) доступны с клавиатуры, интерактивные элементы — с label/aria-*. Истёкший лендинг — понятная страница (410) или корректный редирект (301), не белый экран/технический стектрейс.
Крайние случаи и типовые баги
- Смена slug опубликованного лендинга → 301 со старого URL через
RedirectServiceядра (ревизия 14.07.2026, п. 12) поprevious_slugиз события публикации; собственная таблица редиректов модулю запрещена. - Двойной сабмит формы захвата лида → идемпотентность обеспечивает форма/
LeadServiceядра (dedup-ключ по сессии/токену формы); повторный клик не создаёт вторую заявку и не учитывает вторую конверсию вcms_landing_stats. - Ретрай
aggregate-stats→ upsert по уникальному(landing_id, date), повторный прогон не удваивает счётчики. - Модуль выключен посреди активной акции → опубликованные лендинги отдают 404 (не 500), формы не принимают заявки, фоновые job'ы (агрегация, авто-снятие) не выполняются; при включении обратно всё восстанавливается без потери данных (§3 стандарта).
cms/ab-testingотсутствует/выключен → резолв варианта деградирует к дефолтному (control), кеш — один ключ на лендинг, ошибок на публичной стороне нет.cms/popupsотсутствует → поп-апы просто не показываются, лендинг не деградирует (модуль о попапах не знает — см. «События и обмен»).- Сбой очереди
landings→ просмотры продолжают считаться (пишутся мимо очереди, см. «Производительность и кеш»); агрегация конверсий откладывается до восстановления воркера, backlog виден в метриках, алерт черезcms/health. - Пустой лендинг (JSONB блоков пуст) → рендерится пустой шаблон без 500; список в админке помечает такой лендинг предупреждением «без контента».
- Огромный объём (тысячи лендингов, годы статистики) →
stats_retention_daysограничивает рост журнала, BRIN держит сканы дешёвыми, список лендингов — keyset. - ⚠️ Противоречие: исходная модель данных не несла измерений
locale/city_id/site_id, хотя §4 стандарта требует их на контентных сущностях (nullable, а не отдельная ветка кода) — на мультисайтовой инсталляции два сайта не могли бы завести акцию с одинаковым slug. Разрешение, применённое в этом ТЗ: добавлены nullablesite_id/locale/city_idвcms_landings, уникальность slug скоупится по(site_id, slug)(см. «Модель данных»). - Противоречивые настройки (
landings.enabled=false, ноlandings.auto_unpublish_enabled=true) → job авто-снятия проверяетlandings.enabledпервым и завершается no-op, а не падает и не пытается снять с публикации уже недоступные (404) лендинги. - Конкурентное редактирование двух админов одного лендинга →
lock_version(optimistic lock), конфликт — 409 и понятное сообщение, не «последний победил». expires_atв прошлом при создании (акция уже кончилась) → лендинг сохраняется сразуarchived, не публикуется; форма показывает предупреждение вместо тихой публикации мёртвого лендинга.
Донорский код
| Что взять | Путь |
|---|---|
| Практики отдельного неймспейса лендингов панели студии (без готового пути) | studio-core (панель студии) |
Миграция legacy-данных. cms:landings:import-legacy --source=<профиль> — маппинг старых лендингов (id/slug/title/template/status/published_at) на cms_landings по ключу external_id; идемпотентна (повторный прогон обновляет, не дублирует), поддерживает --dry-run с отчётом расхождений (сколько прочитано/создано/обновлено/ пропущено и почему, построчные ошибки скачиваемы — §16 стандарта). Прогон на копии донорских данных — часть приёмки, если у клиента есть боевой донор лендингов.
Тесты и приёмка
- [ ] Контрактный тест: slug лендинга не конфликтует с деревом основного сайта (в т.ч. при нестандартном
url_prefix) и скоупится по(site_id, slug) - [ ] Снятый с публикации лендинг отдаёт 404; истёкший по
expires_at— 410 либо 301 наreplacement_landing_id(оба сценария покрыты тестами) - [ ] При выключении модуля опубликованные ранее лендинги недоступны без 500, фоновые job'ы — no-op, данные не удаляются
- [ ] Статистика конверсии агрегируется по дням без дублей при повторных просмотрах и при ретрае
aggregate-stats(upsert) - [ ] Права
landings.view/landings.manage/landings.deleteразграничивают чтение, публикацию и удаление согласно матрице ролей - [ ] Совместимость с
cms/ab-testing: вариант резолвится до ключа кеша, отсутствие модуля не ломает рендер (деградация к дефолтному варианту) - [ ] Копирование лендинга переносит все блоки, сбрасывает статус в
draft, не копирует статистику - [ ] Двойной сабмит формы и двойной прогон авто-снятия/purge — идемпотентны
- [ ] Конкурентная правка двух админов — 409 по
lock_version, не «последний победил» - [ ]
cms:landings:import-legacy --dry-runдаёт отчёт расхождений; повторный прогон не дублирует записи (идемпотентность поexternal_id) - [ ]
landings.max_activeиlandings.auto_unpublish_enabled(kill-switch) покрыты тестами достижения лимита / выключения автодействия - [ ] Измерения
locale/city_id/site_idработают и при их отсутствии (nullable, оба режима в тестах — §4 стандарта) - [ ] Смена slug опубликованного лендинга регистрирует 301 в
RedirectServiceядра; старый URL не отдаёт 404 - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут API; тестовая БД только
landings_test,migrate:freshзапрещён