Skip to content

ТЗ — Лендинги (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_landingsid, slug, title, template, status, site_id, locale, city_id, published_at, expires_at, replacement_landing_id, lock_versionпосадочная страница со своим шаблоном
cms_landing_statslanding_id, date, views, conversionsпосуточная статистика конверсии

FK landing_idconstrained() + 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_idrules движка полей ядра, 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-пагинация
EventBusLandingPublished, LandingConversionRecorded, LandingExpiredpayload JSON (см. «События и обмен»)
cms/ab-testing (опционально, если включён)landing_id, кандидаты вариантоввызов сервиса резолва варианта
Очередь landingslanding_id, datejob-payload (aggregate-stats, purge-stats, авто-снятие)
Отчёт import-legacyпрочитано/создано/обновлено/пропущено + построчные ошибкиCSV/JSON, скачиваемый

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

КлючТипДефолтaffectsPageCacheОписание
landings.enabledbooltrueдаВключение публикации лендингов
landings.url_prefixstring/lдаПрефикс пути slug-неймспейса лендингов
landings.stats_enabledbooltrueнетСбор посуточной статистики конверсии
landings.stats_retention_daysint400нетРетеншн cms_landing_stats; старше — удаляется job'ой
landings.max_activeint500нетЛимит published-лендингов на сайт; достижение — 422 при попытке опубликовать ещё один, не тихий отказ
landings.auto_unpublish_enabledbooltrueдаKill-switch: авто-снятие по expires_at; выключение останавливает только эту автодействие, ручная публикация/снятие продолжают работать
landings.expired_responseenum(410,301)410даОтвет для истёкшего лендинга без replacement_landing_id (301 требует явно указанной замены — иначе принудительно 410)

Секретов нет (провайдеры A/B-тестов/попапов конфигурируются в своих модулях, не здесь).

API

МетодПутьДоступНазначение
GET/api/v1/admin/landingsadmin (landings.view)Список лендингов со статусами (keyset-пагинация)
POST/api/v1/admin/landingsadmin (landings.manage)Создание/правка лендинга
POST/api/v1/admin/landings/{id}/copyadmin (landings.manage)Копирование лендинга со всеми блоками (черновик)
DELETE/api/v1/admin/landings/{id}admin (landings.delete)Удаление (подтверждение в UI, необратимо)
GET/api/v1/admin/landings/{id}/statsadmin (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; входящих подписок модуль не имеет.

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

Сущность/модульКаналНаправлениеЧто происходит
Ядро: PageRepositoryrequires-сервис-вызовlandings → ядропроверка конфликта slug лендинга с деревом страниц, если url_prefix пуст/пересекается
Ядро: формы / LeadServicerequires-сервис-вызовлендинг → ядровстроенная форма ядра создаёт лид, привязывает landing_id
Ядро: BlockRegistry/WidgetRegistry/CacheTagsрегистрация на bootstraplandings → ядрошаблоны-лендинги как компоненты темы, тег кеша 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-statscms: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. Разрешение, применённое в этом ТЗ: добавлены nullable site_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 запрещён

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