Тема
ТЗ — Кросс-оси услуг (cms/services-axes)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: artel-23ru Статус: ТЗ к разработке
Назначение и возможности
Дополнительные SEO-оси поверх услуг (район, бренд, тип объекта), дающие комбинации услуга × ось × город с контролем качества посадочных. Опциональная надстройка над cms/services и cms/services-seo — без неё услуги работают как обычный прайс-лист.
- Оси «район» (self-ref дерево: район → микрорайон → улица → ЖК), «бренд», «тип объекта»
- Кросс-продукт URL вида
{ось}/{категория}и{бренд}/{категория}/{услуга} - Оверрайд услуги под бренд (
unique(brand_id, service_id)) - Контроль качества посадочных: минимальный объём уникального контента перед публикацией
- Canonical на базовую услугу для комбинаций без достаточного контента
- Whitelist активных осей per-проект (не все проекты используют все три оси)
- Ленивое создание посадочных (не взрывной пересчёт всех комбинаций при сохранении услуги)
- Аварийное отключение генерации кросс-осевых страниц без выключения модуля (kill-switch)
Зависимости и выключение
requires: cms/services, cms/services-seo · suggests: cms/search — фасетный поиск по кросс-осевым комбинациям быстрее из индекса, без него — обычный роутинг ядра.
Оба requires — жёсткие и равнозначные: модуль недееспособен без любого из них. Health-чек проверяет доступность обоих сервисов при enable; если один из них в рантайме выключен платформой (managed-парк) или упал, модуль переходит в то же деградированное состояние, что и при собственном disable (§3 стандарта) — частичной деградации (например, «оси по району работают, бренд — нет») не бывает: риск рассинхрона canonical выше пользы.
Поведение при выключении: посадочные кросс-осей (район/бренд/тип объекта) перестают генерироваться и отдают 404 с 301 на базовую страницу услуги, оверрайды брендов скрываются — данные осей и оверрайдов сохраняются.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_service_locations | id, parent_id, slug, title, level, city_id | self-ref дерево район→микрорайон→улица→ЖК |
cms_service_brands | id, slug, title | справочник брендов |
cms_brand_category | brand_id, category_id | pivot бренд↔категория услуг |
cms_brand_services | id, brand_id, service_id, price_override, lock_version | оверрайд услуги под бренд |
cms_object_types | id, slug, title | справочник типов объектов (квартира/офис/дом) |
cms_service_axis_pages | id, axis_type, axis_id, service_id, city_id, quality_ok, canonical_target_id | денормализованный реестр созданных комбинаций (для ленивой генерации и quality-check) |
parent_id на cms_service_locations — constrained()->cascadeOnDelete() + index() под рекурсивный обход дерева; глубина дерева ограничена настройкой services-axes.max_location_depth (защита от неограниченной рекурсии/циклов). unique(brand_id, service_id) на cms_brand_services; lock_version — optimistic lock на конкурентное редактирование оверрайда (§4 стандарта). cms_service_axis_pages несёт индекс unique(axis_type, axis_id, service_id, city_id) и index(quality_ok) под батчевый обход в quality-check.
ПДн-паспорт: таблицы модуля не содержат персональных данных (справочники локаций/брендов/типов объектов и оверрайды услуг обезличены) — хуки «выгрузить всё по субъекту» и «забыть по запросу» не требуются, декларируется «ПДн не храню».
Входные и выходные данные
Входы
| Источник | Поля | Чем валидируется |
|---|---|---|
| Admin-форма (Filament) CRUD локации | title, slug, parent_id, city_id, level | LocationRequest: parent_id существует и не создаёт цикл (глубина ≤ max_location_depth), slug уникален в пределах города |
| Admin-форма CRUD бренда | title, slug | BrandRequest: slug уникален |
| Admin-форма CRUD типа объекта | title, slug | ObjectTypeRequest: slug уникален |
| Admin-форма оверрайда бренд-услуга | brand_id, service_id, price_override, content, lock_version | BrandServiceOverrideRequest: unique(brand_id, service_id), price_override — integer minor units, lock_version сверяется (409 при расхождении) |
Событие ServiceSaved (cms/services) | service_id, city_id | не валидируется повторно (доверенный внутренний канал), слушатель тонкий — только ставит job инвалидации |
| Публичный GET по кросс-URL (ленивая генерация) | axis_type, axis_id, service_slug, city_id | резолвится роутингом cms/services-seo; whitelist enabled_axes, лимит max_combinations_per_service |
Импорт cms:services-axes:import-legacy | донорские таблицы локаций/брендов/оверрайдов | маппинг по профилю + external_id, --dry-run |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
cms/services-seo (роутинг ядра) | посадочная кросс-продукта | HTML-страница через BlockRegistry услуги + Schema.org |
sitemap.xml (core SEO) | список опубликованных URL комбинаций (quality_ok=true) | XML urlset |
Подписчики события ServiceAxisCombinationPublished | axis_type, axis_id, service_id, city_id | payload события (канал 1) |
| Filament (отчёт quality-check) | список комбинаций ниже порога контента | таблица админки / --json команды |
cms/search (suggests, если включён) | те же данные события | индексация в поисковом провайдере |
Whitelist-принцип: параметры кросс-URL вне whitelist осей/города — 404, не игнорируются и не создают запись в cms_service_axis_pages.
Настройки (группа services-axes)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
services-axes.enabled_axes | array | ["location"] | да | Whitelist активных осей (location/brand/object_type) |
services-axes.min_content_length | int | 300 | нет | Минимум символов уникального текста для публикации посадочной |
services-axes.canonical_fallback | bool | true | да | Canonical на базовую услугу при недостатке контента |
services-axes.max_location_depth | int | 4 | да | Максимальная глубина дерева локаций (защита от циклов/неограниченной рекурсии) |
services-axes.max_combinations_per_service | int | 500 | нет | Жёсткий лимит комбинаций на одну услугу — достижение лимита логируется и не создаёт новые |
services-axes.lazy_generation | bool | true | нет | Комбинация создаётся при первом заходе, а не разом при сохранении услуги |
services-axes.quality_check_batch_size | int | 200 | нет | Размер чанка батча quality-check (Bus::batch) |
services-axes.kill_switch | bool | false | да | Аварийная остановка генерации новых комбинаций без выключения модуля; существующие качественные страницы продолжают отдаваться |
API
Отдельного API нет — админ-CRUD осей и оверрайдов через Filament, публичный вывод — через страницы кросс-продукта ядра (роутинг cms/services-seo).
Первый заход по URL кросс-продукта при lazy_generation=true синхронно проверяет существование услуги/оси и лимит max_combinations_per_service, создаёт запись в cms_service_axis_pages и рендерит страницу (таймаут проверки ≤200 мс, иначе — деградация на базовую страницу услуги); при lazy_generation=false записи создаёт только явный прогон команды/джобы.
Компоненты
Filament: ресурсы локаций (дерево с drag&drop, глубина ограничена max_location_depth), брендов, типов объектов, RelationManager оверрайдов бренд-услуга (список с массовым включением/выключением и bulk-удалением, подтверждение перед bulk-операцией). Команды: cms:services-axes:quality-check --json (находит посадочные ниже порога контента), cms:services-axes:recount --json (пересчёт агрегатов комбинаций после рестора), cms:services-axes:reindex-canonical --dry-run --json (перепривязка canonical при удалении базовой услуги).
Фронтенд-бюджет: кросс-осевые страницы используют блоки/шаблоны cms/services без собственных ассетов — нулевой дополнительный JS/CSS-бюджет; хлебные крошки и дерево локаций в шаблоне — семантический <nav> с aria-label, доступны с клавиатуры, изображения услуги наследуют резерв размеров базового шаблона (без CLS).
Демо-контент: отдельного сидера нет — модуль не регистрирует свои блоки/виджеты (использует чужие шаблоны рендера); playground демонстрируется демо-сидером cms/services + 2–3 демо-локациями/брендами для показа кросс-URL в галерее.
Эксплуатация: метрики — число опубликованных/скрытых комбинаций по осям, длительность прогона quality-check, доля комбинаций ниже порога контента; алерт — доля некачественных комбинаций > 30% от общего числа (признак проблемы генерации контента, не отдельных страниц). Ранбук:
| Симптом | Проверить | Команда |
|---|---|---|
| Комбинации не публикуются | лимит max_combinations_per_service, порог min_content_length | cms:services-axes:quality-check --json |
| 404 вместо кросс-страницы | доступность cms/services/cms/services-seo, enabled_axes, kill_switch | cms:doctor --json |
| Битый canonical после удаления услуги | цепочка canonical на удалённую/скрытую услугу | cms:services-axes:reindex-canonical --dry-run |
| Расхождение агрегатов после рестора | денормализованный quality_ok не пересчитан | cms:services-axes:recount --json |
Рестор из бэкапа: в бэкап попадают все таблицы модуля (локации, бренды, типы объектов, оверрайды, реестр комбинаций). Денормализованный флаг quality_ok и canonical-связи пересчитываются командой cms:services-axes:quality-check после рестора — рестор без этого шага не считается завершённым.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
ServiceAxisCombinationPublished | посадочная кросс-оси прошла контроль качества | axis_type, axis_id, service_id, city_id |
ServiceAxisCombinationHidden | посадочная снята с публикации (порог/лимит/удаление источника) | axis_type, axis_id, service_id, city_id, reason |
Слушает: ServiceSaved из cms/services — инвалидирует связанные комбинации осей (тонкий слушатель, тяжёлый пересчёт уходит в очередь services-axes). Provides-контрактов не реализует, FilterBus не использует.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/services | событие ServiceSaved | входящее | инвалидация/переоценка комбинаций осей затронутой услуги |
cms/services | requires (прямой вызов ServicesService) | исходящее | чтение базовых данных услуги для рендера комбинации и canonical fallback |
cms/services-seo | requires (прямой вызов роутинга/SEO-резолвера) | двустороннее | регистрация ЧПУ кросс-продукта, canonical на базовую услугу |
cms/search (suggests) | событие ServiceAxisCombinationPublished/Hidden | исходящее | индексация/деиндексация комбинации в поисковом провайдере |
ядро: EventBus | издаёт события выше | исходящее | уведомление всех подписчиков (в т.ч. sitemap) |
ядро: CacheTags | тег services-axes | — | инвалидация кросс-осевых страниц |
ядро: ScheduleRegistrar | регистрация quality-check | — | периодический батчевый прогон |
ядро: SettingsStore | чтение группы services-axes | входящее | лимиты, whitelist осей, kill_switch |
Фоновая работа
Джоба cms:services-axes:quality-check (очередь services-axes) — по расписанию через ScheduleRegistrar ядра проверяет посадочные ниже порога контента и снимает их с публикации; идёт чанками Bus::batch размером quality_check_batch_size (утилизация ядер, прогресс виден в админке). Отдельная джоба RegenerateAxisCombination (та же очередь) выполняет ленивое создание комбинации асинхронно, если синхронная проверка на публичном запросе превышает таймаут — запрос отдаёт базовую страницу услуги, комбинация дозревает в фоне и подхватывается следующим заходом.
Производительность и кеш
Ожидаемые объёмы. Порядок величины для среднего проекта: 50 услуг × 10 городов × 20 локаций ≈ 10 000 потенциальных комбинаций по оси «район» + до 5 000 по «бренду» и 2 500 по «типу объекта» — совокупно десятки тысяч. Для крупного мультигорода (200 услуг × 50 городов × 50 локаций) декартово произведение уходит за полмиллиона — без лимита max_combinations_per_service и ленивой генерации это неконтролируемый рост таблиц и sitemap; лимит и lazy_generation=true — обязательный дефолт, не опция «для больших проектов».
Горячие пути и бюджет запросов: рендер опубликованной кросс-страницы — 0 запросов на попадании в page-cache; промах кеша — не более 3 запросов (услуга, ось, оверрайд бренда при наличии); обход дерева локаций — ->with('children') с ограничением глубины (max_location_depth), не полный рекурсивный SELECT *. quality-check — батч по индексу quality_ok, не полное сканирование cms_service_axis_pages.
Критичные индексы: cms_service_locations(parent_id), cms_service_locations(city_id), unique(cms_brand_services.brand_id, service_id), unique(cms_service_axis_pages.axis_type, axis_id, service_id, city_id), index(cms_service_axis_pages.quality_ok).
Теги кеша и инвалидация: тег services-axes на кросс-осевых посадочных; инвалидация точечная по конкретной комбинации при изменении оверрайда бренда или ServiceSaved (не сброс всего тега при каждом чихе) — влияет на page-cache посадочных страниц кросс-продукта.
Безопасность
Границы входа: FormRequest-whitelist на CRUD локаций/брендов/типов объектов и оверрайдов (админка). Своего публичного API нет — вход только через страницы ядра, отдельный rate-limit не требуется (публичные GET кешируются page-cache). Санитизация rich-текста описаний осей и контента оверрайдов — двойным барьером (сохранение + вывод).
Конкретные векторы:
- цикл в дереве локаций (
parent_idуказывает на себя или потомка) — блокируетсяLocationRequestдо записи, не только UI-подсказкой; - массовое создание комбинаций через ленивую генерацию (скан всех возможных URL ботом) — ограничено
max_combinations_per_service+ таймаутом синхронной проверки; - IDOR через числовые
axis_id/service_idв служебных URL — публичные кросс-URL строятся поslug, не поid; - ReDoS в пользовательских паттернах склонений (донорский трейт
HasDeclensions) — только через ReDoS-валидатор ядра при кастомизации словоформ; - гонка при удалении бренда во время массового
quality-check— удаление бренда идёт транзакцией с блокировкой затронутых строкcms_brand_services, job пропускает удалённые записи без падения.
Матрица ролей:
| Роль | services-axes.view | services-axes.manage | Публикация без quality-check | Доверенный HTML описаний осей |
|---|---|---|---|---|
| studio | ✅ | ✅ | ✅ (осознанный форс) | ✅ |
| админ клиента | ✅ | ✅ | ❌ | ❌ |
| менеджер | ✅ | ❌ (только просмотр отчёта quality-check) | ❌ | ❌ |
| редактор | ✅ (свои оси/оверрайды контента) | частично (правка контента, не публикация) | ❌ | ❌ |
manage не позволяет публиковать посадочную без прохождения quality-check — правило проверяется Policy, не UI-подсказкой.
UX-требования
Админ: пустое состояние списка локаций/брендов — подсказка «добавьте первую локацию/бренд, чтобы включить ось» со ссылкой на настройку enabled_axes; массовые действия на списке оверрайдов (bulk включить/выключить, bulk удалить с подтверждением); ошибка quality-check формулируется по-человечески («не хватает 120 символов текста», не «validation failed»); удаление бренда/локации с дочерними записями или связанными оверрайдами — модальное подтверждение с числом затронутых сущностей (необратимая операция, §0 стандарта).
Посетитель: кросс-осевая страница отдаётся из page-cache без ощутимой задержки; при промахе (первый заход, ленивая генерация) — не более короткого ожидания, иначе fallback на базовую страницу услуги вместо белого экрана/таймаута; хлебные крошки и дерево локаций доступны с клавиатуры, изображения без CLS.
Крайние случаи и типовые баги
- Декартово произведение осей (услуга × город × ось) даёт десятки–сотни тысяч потенциальных комбинаций (см. «Производительность и кеш») → ожидаемое поведение: ничего не генерируется разом при сохранении услуги, работает
lazy_generation+ жёсткийmax_combinations_per_service; при достижении лимита — понятная метрика и запись в лог, не тихое обрезание. - Контент ниже порога, canonical на базовую услугу, а базовая услуга сама удалена/скрыта → ожидаемое поведение: canonical должен переключаться на ближайший живой уровень (категория услуги), а не указывать на 404. ⚠️ Противоречие: текущая модель не описывает цепочку разрешения canonical при удалении цели — предложение:
canonical_target_idвcms_service_axis_pagesпересчитывается слушателем удаления услуги (событие изcms/services) командойcms:services-axes:reindex-canonical, а не хранится статично. - Выключение
cms/servicesилиcms/services-seo(оба —requires) → ожидаемое поведение: модуль деградирует полностью (см. «Зависимости»), а не частично по осям — все кросс-URL отдают 404+301 на базовую услугу сразу, без попытки различить, какой из двух модулей отсутствует. - Гонка при
quality-checkво время правки оверрайда бренда → ожидаемое поведение: batch-job читаетlock_versionстроки на момент своей проверки; если админ сохранил правку между чтением и записью решения job, job перепроверяет качество на актуальной версии перед публикацией, а не публикует по устаревшим данным. - Self-ref дерево локаций:
parent_id = selfили цикл через потомка → ожидаемое поведение:LocationRequestотклоняет сохранение с 422 и понятной ошибкой «локация не может быть предком самой себя»; проверка — обход цепочки до корня с ограничениемmax_location_depth, не бесконечный цикл на сервере. - Удаление бренда со связанными оверрайдами услуг → ожидаемое поведение:
cascadeOnDeleteнаcms_brand_services, но только после явного подтверждения в Filament с указанием числа затронутых оверрайдов; связанные кросс-страницы снимаются с публикации событиемServiceAxisCombinationHidden, не остаются «осиротевшими». - Конкурентное редактирование оверрайда бренд-услуга двумя админами → ожидаемое поведение:
lock_version(optimistic lock, §4 стандарта) — второйPUTполучает 409 и человеческое сообщение «оверрайд изменён другим пользователем, перезагрузите форму», не молчаливую перезапись. - Whitelist
enabled_axesменяется в рантайме (ось выключена) → ожидаемое поведение: ранее опубликованные комбинации этой оси скрываются (404+301), а не удаляются — при повторном включении оси восстанавливаются без повторной генерации. - Пустые данные: услуга без единой активной локации/бренда/типа объекта → ноль комбинаций, ни одной ошибки, страница услуги работает как обычная (без кросс-осей).
- Огромный город (тысячи локаций) →
quality-checkи обход дерева идут батчами (quality_check_batch_size, ограничение глубины), не синхронным полным сканированием на публичном запросе.
Донорский код
| Что взять | Путь |
|---|---|
| Дерево локаций, бренды, кросс-продукт контроллеров | artel-23ru/src/app/ (Models/Location.php, Brand.php, ObjectType.php, контроллеры) · обобщённый порт моделей — universal/src/app/Models/ |
| Трейт склонений для текста осей | universal/src/app/Traits/HasDeclensions.php · первоисточник artel-23ru/src/app/Traits/HasDeclensions.php |
Легаси-импорт: cms:services-axes:import-legacy --source=artel-23ru --dry-run — маппинг донорских таблиц локаций/брендов/типов объектов/оверрайдов на новую схему, ключ идемпотентности — external_id (повторный прогон обновляет, не дублирует). Отчёт: прочитано/создано/обновлено/пропущено с построчными причинами; прогон на копии боевых данных — часть приёмки модуля (§16 стандарта).
Тесты и приёмка
- [ ] Контрактный тест: комбинация ниже порога контента отдаёт canonical на базовую услугу
- [ ] Удаление/скрытие базовой услуги пересчитывает canonical-цепочку, не оставляет 404-canonical
- [ ] При выключении модуля посадочные кросс-осей отдают 404+301, без 500
- [ ] Частичное выключение только одного из двух
requiresдеградирует модуль полностью, не частично - [ ] Whitelist
enabled_axesреально скрывает не включённые оси из роутинга и sitemap, без удаления данных - [ ] Права
services-axes.manageне позволяют публиковать без прохождения quality-check - [ ]
LocationRequestотклоняетparent_id = selfи циклы через потомка - [ ] Конкурентное редактирование оверрайда бренд-услуга отдаёт 409 по
lock_version - [ ]
lazy_generation=trueне создаёт комбинации при сохранении услуги, только по факту захода - [ ] Достижение
max_combinations_per_serviceлогируется и не создаёт новые записи - [ ] Удаление бренда с оверрайдами требует подтверждения и снимает связанные комбинации с публикации
- [ ]
kill_switch=trueостанавливает генерацию новых комбинаций, существующие качественные страницы живы - [ ] Нет N+1 при обходе дерева локаций (
->with('children')рекурсивно ограничен глубиной) - [ ] Инвалидация кеша по тегу
services-axesпри изменении оверрайда бренда — точечная, не полный сброс тега - [ ]
cms:services-axes:import-legacy --dry-runидемпотентен и не создаёт дублей поexternal_id - [ ] Контрактный набор
cms-testingзелёный, пакет тестируется в testbench-изоляции - [ ] Feature-тест на каждый роут админки (локации, бренды, типы объектов, оверрайды)
- [ ] Тестовая БД только
services-axes_test;migrate:fresh/refresh/reset/db:wipeзапрещены