Тема
ТЗ — SEO услуг (generic-движок) (cms/services-seo)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★★ · Донор: artel-23ru Статус: ТЗ к разработке
🔄 Ревизия (волны C–D корп-MVP, 15.07.2026): мета/шаблоны/SEO-блоки страниц живут в ядре (
cms_seo_meta/cms_seo_templates/cms_seo_blocks,SeoResolver, шовseo.content) — этот модуль больше НЕ хранилище меты (егоcms_services_seo_meta_overridesперенесена в ядро data-миграцией волны C; таблица будет снята следующим срезом). Модулю остаются: движок склонений по сущностям услуг (DeclensionService+cms_services_seo_declensions),TemplateRendererплейсхолдеров услуг, JSON-LD (SchemaOrgBuilder+ServiceSchemaInjectorнаseo.contentbefore_footer карточки), авто-301 смены slug (RegisterServiceSlugRedirect), собственные SEO-блоки услуг (cms_services_seo_blocks) и sitemap-источник; позиции карточки услуги (after_hero/after_prices/before_footer) модуль дописывает в реестр ядраconfig('cms.seo.positions')при boot.
Назначение и возможности
Генератор посадочных страниц из шаблонов с переменными и русскими падежами (склонение города/услуги в тексте: «в Краснодаре», «электрика для квартиры»). Строит кросс-продукт ЧПУ по формуле услуга × город × ось (см. cms/services-axes) и снабжает каждую посадочную двойной микроразметкой Schema.org. Обобщение эталонного движка artel-23ru — генеричный, не завязан жёстко на модель «услуга».
- Шаблоны SEO-блоков с переменными (
{city_gen},{service_prep}) и позицией/форматом - Движок склонений — 6 русских падежей на сущность, хранение в JSON
declensions - Ручной оверрайд мета (title/description/canonical) поверх сгенерированного шаблона
- Кросс-продукт ЧПУ: услуга × город × дополнительная ось (район/бренд/тип объекта)
- Двойная микроразметка: JSON-LD (
Service+LocalBusiness+Offer) и inlineitemprop - Хлебные крошки +
ItemList/ListItemдля прайс-листов - Интеграция с sitemap: генерация всех комбинаций кросс-продукта в тиринговый sitemap
- 301 при переименовании slug — через
RedirectServiceядра (ревизия 14.07.2026, п. 12), собственной таблицы редиректов у модуля нет
Зависимости и выключение
requires: cms/services
Поведение при выключении: услуги отображаются с дефолтными meta ядра (без падежей и кросс-продукта), посадочные кросс-осей перестают генерироваться и отдают 404, ранее проиндексированные URL требуют ручного 301 при повторном включении — данные (шаблоны, склонения) не удаляются. По каскаду выключения (граф зависимостей) ядро не даст выключить cms/services, пока включён этот модуль — сначала выключается он сам (см. «Крайние случаи»: «выключенный cms/services»).
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_seo_blocks | id, page_type, position, format, template, variables (json), city_id (nullable), locale, lock_version | шаблон SEO-текста с переменными |
cms_seo_meta_overrides | id, entity_type, entity_id, title, description, canonical | ручной оверрайд поверх шаблона |
cms_declensions | id, entity_type, entity_id, declensions (json), locale, external_id (nullable, unique) | 6 падежей на сущность (город/услуга/категория) |
variables/declensions — JSONB с GIN-индексом под выборку по ключам; entity_type + entity_id — составной индекс на cms_seo_meta_overrides и cms_declensions; lock_version на cms_seo_blocks — optimistic lock при конкурентном редактировании шаблона. Редиректы модуль не хранит: слушатель ServiceSaved/PagePublished (с previous_slug) создаёт 301 через RedirectService ядра.
ПДн-паспорт: модуль ПДн не хранит — все таблицы содержат публичные SEO-артефакты (шаблоны, мета, падежи названий), персональных данных посетителя или клиента нет; участие в «выгрузить всё по субъекту»/«забыть по запросу» не требуется.
Входные и выходные данные
Входы (whitelist-принцип: всё, что не перечислено, — отвергается 422):
| Источник | Поля | Чем валидируется |
|---|---|---|
| Filament — редактор шаблонов SEO-блоков | page_type, position, format, template, variables(json), city_id, locale | FormRequest whitelist движка полей; template — plain-text с плейсхолдерами {var} из whitelist движка склонений, не Blade/PHP-выражение (исполняемый шаблон запрещён — риск инъекции) |
| Filament — оверрайд мета | entity_type, entity_id, title, description, canonical | FormRequest whitelist; canonical — валидный URL своего домена, title/description — plain-text (не rich-text, экранируются на выводе ) |
| Filament — справочник склонений | entity_type, entity_id, declensions (6 падежей json), locale | Валидатор проверяет заполненность всех 6 ключей либо явный флаг «нет данных»; неизвестный падеж-ключ отвергается |
Событие ServiceSaved/PagePublished (ядро/cms/services) | entity_id, previous_slug | не парсится — триггер регистрации 301 в RedirectService ядра |
Событие ServiceSaved (cms/services) | service_id, level, city_id | не парсится вручную — только триггер пересчёта склонений по entity_id |
Legacy-импорт cms:services-seo:import-legacy | донорские seo_texts/declensions/redirects | маппер + external_id, --dry-run без записи |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Публичные страницы услуг (SEO-резолвер ядра) | сгенерированные title/description/canonical, Schema.org | HTML <head> + <script type="application/ld+json"> |
| Блок «SEO-текст» | отрендеренный шаблон с подставленными падежами | Blade-partial из сервиса модуля |
| Sitemap ядра | URL кросс-продукта услуга×город×ось | XML sitemap-тир (лимит и авто-разбивка — на стороне ядра, см. «Производительность и кеш») |
cms/services-axes (requires этого модуля) | склонение текста для доп. осей | вызов DeclensionService::decline() (канал 4) |
RedirectService ядра (канал 4) | from_path → to_path при смене slug | регистрация 301; отдаёт редиректы само ядро |
Событие SeoTemplateRendered/DeclensionMissing | entity_type, entity_id, page_type/locale | payload EventBus |
Настройки (группа services-seo)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
services-seo.declension_enabled | bool | true | да | Включение движка падежей в шаблонах |
services-seo.schema_dual_markup | bool | true | да | Двойная микроразметка (JSON-LD + inline itemprop) |
services-seo.template_fallback | string | default | да | Шаблон, если для типа страницы нет своего |
services-seo.sitemap_cross_product | bool | true | нет | Включение кросс-продукта в sitemap (kill-switch: при аномальном разрастании комбинаций — выключить без остановки модуля) |
services-seo.declension_regen_batch_size | int | 500 | нет | Размер чанка батч-пересчёта склонений/мета при смене шаблона (нагрузка на очередь) |
API
Отдельного API нет — SEO-блоки рендерятся серверно в составе страниц ядра; управление шаблонами и склонениями — через Filament.
Компоненты
Блоки (BlockRegistry): «SEO-текст» (рендерит шаблон с подстановкой падежей, демо-props из сидера для галереи /_gallery). Filament: редактор шаблонов SEO-блоков с превью подстановки, справочник склонений сущностей (редиректы — в админ-UI ядра). Команды: cms:services-seo:rebuild-declensions --json, cms:services-seo:sitemap:warm --json.
Фронтенд-бюджет: блок «SEO-текст» — серверный рендер без JS, CLS не создаёт (текст, не медиа); JSON-LD-скрипт — в <head>, не влияет на LCP видимой области; превью подстановки в редакторе шаблона — единственное место с клиентским JS модуля (debounce на инпуте, без блокировки сохранения).
Демо-контент: сидер демо-услуги с заполненными склонениями и демо-шаблоном на все позиции/форматы — обязателен для playground и галереи блоков без ручного ввода.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
SeoTemplateRendered | шаблон применён к странице | entity_type, entity_id, page_type |
DeclensionMissing | у сущности нет склонений для подстановки | entity_type, entity_id, locale |
Слушает: ServiceSaved из cms/services — пересчитывает склонения при изменении названия.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/services | requires (модуль зависит от него) | cms/services-seo → cms/services | читает title/slug/breadcrumbs через публичный сервис для рендера шаблонов и склонений |
cms/services | событие ServiceSaved | cms/services → cms/services-seo | триггерит пересчёт склонений изменённой сущности |
cms/services-seo | событие DeclensionMissing | cms/services-seo → cms/health/лог | сигнал: у сущности нет падежей, шаблон рендерится с фолбэком (см. «Крайние случаи») |
cms/services-axes | requires (потребитель этого модуля) | cms/services-axes → cms/services-seo | вызывает DeclensionService::decline() для текста доп. осей (район/бренд/тип объекта) |
| ядро (SEO-резолвер страницы) | FilterBus seo.meta | cms/services-seo → ядро | дописывает/переопределяет мета в резолвере ядра (ревизия 14.07.2026, п. 11) |
| ядро (sitemap) | контракт SitemapRegistry | cms/services-seo → ядро | декларативно регистрирует URL-источник услуг/осей (ревизия 14.07.2026, п. 10) |
Оба канала канонизированы ревизией ядра 14.07.2026:
seo.meta(п. 11). Модуль встраивается в SEO-резолвер ядра фильтром —cms_seo_meta_overridesявляется единственным источником истины для<head>страниц услуг; отдельный SEO-блок остаётся только для видимого текста, дублирование генерации title/description между ядром и модулем исключено.SitemapRegistry(п. 10).cms:services-seo:sitemap:warmрегистрирует провайдера URL через контракт; запись в таблицы sitemap ядра напрямую (raw SQL) запрещена §5 стандарта.
Фоновая работа
Джобы cms:services-seo:rebuild-declensions и cms:services-seo:sitemap:warm (очередь services-seo); пересчёт склонений запускается по событию ServiceSaved, sitemap-прогрев — по расписанию через ScheduleRegistrar ядра. Смена шаблона (page_type/template) не пересчитывает мета «на лету» при каждом запросе — только батч-командой rebuild-declensions/аналогом для меты, чанками по services-seo.declension_regen_batch_size (нагрузка на очередь предсказуема, страница не блокируется рендером шаблона всех сущностей разом).
Эксплуатация (мини-ранбук):
| Симптом | Что проверить | Команда/действие |
|---|---|---|
На карточке услуги вместо текста — сырой {city_gen} | Нет города в RequestContext или у сущности нет declension | cms:services-seo:rebuild-declensions --dry-run --json, проверить событие DeclensionMissing в логе |
| После смены шаблона старые страницы не обновились | Батч-регенерация не запущена (не автоматична по дизайну) | cms:services-seo:rebuild-declensions --json |
| 301-редирект не срабатывает после переименования slug | слушатель не отработал (очередь) | cms:services-seo:rebuild-declensions --json; сами редиректы и схлопывание цепочек — зона RedirectService ядра |
| Sitemap не растёт после массового импорта услуг | Очередь services-seo отстала | cms:services-seo:sitemap:warm --json |
Метрики: число DeclensionMissing за период (алерт при резком росте — признак сломанного импорта/сидера), длительность rebuild-declensions на 1000 сущностей, глубина цепочек редиректов (мониторинг цепочек — на стороне RedirectService ядра).
После рестора: таблицы cms_seo_blocks/cms_seo_meta_overrides/cms_declensions/ Отрендеренный кеш SEO-блоков и прогретый sitemap — не бэкапятся, пересоздаются командами rebuild-declensions/sitemap:warm; рестор без их прогона не считается завершённым (§15 стандарта).
Производительность и кеш
Ожидаемые объёмы: сотни шаблонов SEO-блоков, тысячи-десятки тысяч записей склонений (по числу услуг × городов), кросс-продукт с осями (cms/services-axes) — потенциально десятки-сотни тысяч комбинаций URL.
Горячие пути: рендер SEO-блока и мета на каждой публичной странице услуги/посадочной — почти всегда из page-cache; на промахе бюджет — подстановка шаблона ≤1 запрос за declension-запись сущности (без N+1 при массовом рендере списка услуг — предзагрузка declensions вместе с сущностями).
Критичные индексы: составной (entity_type, entity_id) на cms_seo_meta_overrides и cms_declensions (GIN на JSONB-поля variables/declensions); lookup редиректов и его индексы — зона RedirectService ядра.
Теги кеша: services-seo на отрендеренных SEO-блоках и шаблонах; инвалидация при сохранении шаблона или пересчёте склонений — батчевая одним тегом после rebuild-declensions, не по одной сущности. Влияет на page-cache карточек услуг и кросс-осевых посадочных.
Лимит sitemap: авто-разбивка sitemap-файлов ядра при >50 000 URL (gzip-чанкинг, SEO-база ядра) — кросс-продукт этого модуля подчиняется общему лимиту ядра, отдельного лимита в модуле не заводится; отсечение тонких/некачественных комбинаций (min_content_length) — зона ответственности cms/services-axes, не этого модуля.
Безопасность
Границы входа: FormRequest-whitelist на редактор шаблонов (переменные — только из whitelist движка, без произвольного PHP/Blade в тексте шаблона). Rich-текст оверрайдов мета — санитизация двойным барьером. Публичного API нет, rate-limit не требуется.
Векторы атак и защита:
- Инъекция в переменные шаблона —
templateне исполняется как Blade/PHP: движок подстановки работает по строгому whitelist плейсхолдеров ({city_gen},{service_prep}, …), любой текст вне известного плейсхолдера выводится буквально, экранируется на выводе— HTML/JS в шаблоне не исполняется; - Пользовательские regex — модуль их не вводит (шаблоны — plain-text с плейсхолдерами, редиректы — точное совпадение пути, не regex): ReDoS не применим;
- XSS через оверрайд title/description — поля не rich-text, санитизация как у plain-text полей + вывод только через
,canonicalдополнительно валидируется как URL своего домена (защита от open redirect через canonical на чужой хост); - Цикл редиректов как отказ в обслуживании — защита от циклов и длинных цепочек реализована в
RedirectServiceядра; модуль только регистрирует пары «старый → новый».
Матрица ролей (permissions services-seo.view, services-seo.manage):
| Роль | services-seo.view | services-seo.manage | Комментарий |
|---|---|---|---|
| studio (разработчик студии) | ✅ | ✅ | Плюс снятие kill-switch sitemap_cross_product, доступ к legacy-импорту |
| админ (владелец сайта) | ✅ | ✅ | Полный CRUD шаблонов/склонений/редиректов своего сайта |
| менеджер (контент-менеджер клиента) | ✅ | ✅ | Правка оверрайдов мета и склонений; без прав менять шаблон формата (структурные правки — только studio/админ) |
| редактор | ✅ | — | Только просмотр — правка шаблонов и редиректов затрагивает SEO всего сайта, не отдельного раздела |
UX-требования
Для админа: редактор шаблона показывает live-превью подстановки (что реально отрендерится вместо {city_gen} на конкретной демо-сущности), а не только сырой текст с плейсхолдерами; пустое состояние справочника склонений — подсказка «Заполните падежи или запустите автогенерацию» со ссылкой на rebuild-declensions; массовое действие — «пересчитать склонения для выбранных сущностей»; человеческая ошибка при 409 на редактировании шаблона — «Шаблон изменил другой пользователь, ваши правки не сохранены, обновите страницу»; подтверждение необратимой операции — удаление шаблона, на который завязаны опубликованные страницы, требует явного подтверждения с указанием, сколько страниц вернутся к template_fallback.
Для посетителя: отсутствие declension никогда не приводит к пустой/битой странице — фолбэк на именительный падеж или базовое название сущности (см. «Крайние случаи»); Schema.org не создаёт видимого контента, не влияет на воспринимаемую скорость; 301-редиректы выполняются за один прыжок (см. лимит цепочки) — без ощутимой задержки для посетителя, пришедшего по старой ссылке.
Крайние случаи и типовые баги
- Коллизия slug при кросс-продукте (услуга и ось дают одинаковый итоговый URL для разных комбинаций) → генерация кросс-продукта проверяет уникальность итогового
pathдо публикации; конфликт — комбинация не публикуется, событие/лог с указанием обеих исходных сущностей, а не молчаливая перезапись одной посадочной другой. - Регенерация мета при смене шаблона → не на лету при каждом запросе (кросс-продукт может быть десятками тысяч страниц — синхронный пересчёт на всех — недопустимая нагрузка); только батч-командой чанками по
declension_regen_batch_size, с прогрессом в Filament; до завершения батча старые страницы отдают старую версию мета (не 500 и не пустую). - Переменная шаблона без значения у сущности (нет данных для
{city_gen}) → фолбэк: плейсхолдер заменяется на именительный падеж названия сущности (не остаётся как сырой текст{city_gen}и не приводит к пустой строке); одновременно логируетсяDeclensionMissingдля докрутки данных. - Отсутствие declension для новой сущности → при создании услуги без последующего запуска
rebuild-declensionsпервый рендер её страницы уже отдаёт фолбэк (см. выше) и издаётDeclensionMissing {entity_type, entity_id, locale}; подписчик (лог/cms/health) агрегирует по частоте — резкий рост сигнализирует о забытом шаге импорта, а не о единичном случае. - Цепочка переименований slug (A→B→C→…→A) → модуль передаёт каждую смену в
RedirectServiceядра; схлопывание цепочек до одного прыжка и защита от циклов — контрактное поведение сервиса ядра, модуль его не дублирует. - Выключенный
cms/services(requires) → по каскаду выключения ядра (граф зависимостей) выключитьcms/services, покаcms/services-seoвключён, штатно нельзя — сначала выключается зависимый модуль. Если отключение произошло в обход штатного пути (ручнойuninstall, форс через CLI) — по инварианту §0.1 стандарта модуль обязан деградировать, а не падать: SEO-блоки отдают fallback, сохранённые declension/meta не читаются (нет владельца-сущности для проверки актуальности), 500 недопустим. - Огромный кросс-продукт в sitemap → отдельного лимита модуль не вводит — подчиняется общему авто-разбиению ядра при >50 000 URL на файл (см. «Производительность и кеш»); контроль качества (не публиковать тонкие комбинации) — зона
cms/services-axes, при его выключении кросс-продукт этого модуля ограничивается только услуга×город без дополнительных осей. - Конкурентное редактирование шаблона →
lock_versionнаcms_seo_blocks; несовпадение версии при сохранении — 409 «Шаблон изменил другой пользователь», не «последний победил».
Донорский код
| Что взять | Путь |
|---|---|
| Генерация SEO-блоков, рендер шаблонов | universal/src/app/Services/SeoService.php (свежая обобщённая версия: setting() из БД, SeoBlock с entity-scope) · первоисточник artel-23ru/src/app/Services/SeoService.php |
| Движок падежей (6 склонений) | universal/src/app/Services/DeclensionService.php · первоисточник artel-23ru/src/app/Services/DeclensionService.php |
| Интеграция с sitemap | universal/src/app/Services/SitemapService.php · первоисточник artel-23ru/src/app/Services/SitemapService.php |
| Трейт склонений | universal/src/app/Traits/HasDeclensions.php · первоисточник artel-23ru/src/app/Traits/HasDeclensions.php |
Legacy-импорт (cms:services-seo:import-legacy --source=artel-23ru --dry-run): маппинг донорских seo_texts/declensions (artel-23ru) на cms_seo_blocks/cms_declensions; связь по донорскому id — в колонке external_id на cms_declensions (повторный прогон обновляет, не дублирует). Донорские redirects импортируются регистрацией в RedirectService ядра (не в таблицы модуля); --dry-run печатает отчёт расхождений без записи, ошибки построчно — не молча.
Тесты и приёмка
- [ ] Контрактный тест рендера шаблона с подстановкой падежей на тестовой сущности
- [ ] При выключении модуля страницы услуг отдают дефолтные meta ядра без ошибок 500
- [ ] Schema.org валидируется Google Rich Results Test на карточке услуги
- [ ] Отсутствующее склонение логируется событием
DeclensionMissing, рендерится фолбэком (именительный падеж), не рвёт рендер - [ ] Инвалидация кеша шаблонов по тегу
services-seoпри сохранении шаблона - [ ] Sitemap кросс-продукта не создаёт дублей URL при пересечении осей; подчиняется авто-разбиению ядра на >50 000 URL
- [ ] Нет N+1 при массовом рендере SEO-блоков для списка услуг
- [ ] Смена шаблона не пересчитывает мета синхронно на запросе — только батч-командой чанками
- [ ] Переименование slug услуги регистрирует 301 в
RedirectServiceядра (поprevious_slugсобытия); собственных таблиц редиректов в пакете нет - [ ]
lock_versionнаcms_seo_blocksотдаёт 409 при конкурентном редактировании шаблона - [ ]
cms:services-seo:import-legacy --dry-runне пишет в БД; повторный прогон идемпотентен поexternal_id - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут админки (шаблоны, склонения)
- [ ] Тестовая БД только
services-seo_test;migrate:fresh/refresh/reset/db:wipeзапрещены