Skip to content

ТЗ — 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.content before_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) и inline itemprop
  • Хлебные крошки + ItemList/ListItem для прайс-листов
  • Интеграция с sitemap: генерация всех комбинаций кросс-продукта в тиринговый sitemap
  • 301 при переименовании slug — через RedirectService ядра (ревизия 14.07.2026, п. 12), собственной таблицы редиректов у модуля нет

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

requires: cms/services

Поведение при выключении: услуги отображаются с дефолтными meta ядра (без падежей и кросс-продукта), посадочные кросс-осей перестают генерироваться и отдают 404, ранее проиндексированные URL требуют ручного 301 при повторном включении — данные (шаблоны, склонения) не удаляются. По каскаду выключения (граф зависимостей) ядро не даст выключить cms/services, пока включён этот модуль — сначала выключается он сам (см. «Крайние случаи»: «выключенный cms/services»).

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

ТаблицаКлючевые поляПримечание
cms_seo_blocksid, page_type, position, format, template, variables (json), city_id (nullable), locale, lock_versionшаблон SEO-текста с переменными
cms_seo_meta_overridesid, entity_type, entity_id, title, description, canonicalручной оверрайд поверх шаблона
cms_declensionsid, 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/PagePublishedprevious_slug) создаёт 301 через RedirectService ядра.

ПДн-паспорт: модуль ПДн не хранит — все таблицы содержат публичные SEO-артефакты (шаблоны, мета, падежи названий), персональных данных посетителя или клиента нет; участие в «выгрузить всё по субъекту»/«забыть по запросу» не требуется.

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

Входы (whitelist-принцип: всё, что не перечислено, — отвергается 422):

ИсточникПоляЧем валидируется
Filament — редактор шаблонов SEO-блоковpage_type, position, format, template, variables(json), city_id, localeFormRequest whitelist движка полей; template — plain-text с плейсхолдерами {var} из whitelist движка склонений, не Blade/PHP-выражение (исполняемый шаблон запрещён — риск инъекции)
Filament — оверрайд метаentity_type, entity_id, title, description, canonicalFormRequest 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.orgHTML <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/DeclensionMissingentity_type, entity_id, page_type/localepayload EventBus

Настройки (группа services-seo)

КлючТипДефолтaffectsPageCacheОписание
services-seo.declension_enabledbooltrueдаВключение движка падежей в шаблонах
services-seo.schema_dual_markupbooltrueдаДвойная микроразметка (JSON-LD + inline itemprop)
services-seo.template_fallbackstringdefaultдаШаблон, если для типа страницы нет своего
services-seo.sitemap_cross_productbooltrueнетВключение кросс-продукта в sitemap (kill-switch: при аномальном разрастании комбинаций — выключить без остановки модуля)
services-seo.declension_regen_batch_sizeint500нетРазмер чанка батч-пересчёта склонений/мета при смене шаблона (нагрузка на очередь)

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/servicesrequires (модуль зависит от него)cms/services-seocms/servicesчитает title/slug/breadcrumbs через публичный сервис для рендера шаблонов и склонений
cms/servicesсобытие ServiceSavedcms/servicescms/services-seoтриггерит пересчёт склонений изменённой сущности
cms/services-seoсобытие DeclensionMissingcms/services-seocms/health/логсигнал: у сущности нет падежей, шаблон рендерится с фолбэком (см. «Крайние случаи»)
cms/services-axesrequires (потребитель этого модуля)cms/services-axescms/services-seoвызывает DeclensionService::decline() для текста доп. осей (район/бренд/тип объекта)
ядро (SEO-резолвер страницы)FilterBus seo.metacms/services-seo → ядродописывает/переопределяет мета в резолвере ядра (ревизия 14.07.2026, п. 11)
ядро (sitemap)контракт SitemapRegistrycms/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 или у сущности нет declensioncms: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.viewservices-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
Интеграция с sitemapuniversal/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 запрещены

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