Skip to content

ТЗ — Кросс-оси услуг (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_locationsid, parent_id, slug, title, level, city_idself-ref дерево район→микрорайон→улица→ЖК
cms_service_brandsid, slug, titleсправочник брендов
cms_brand_categorybrand_id, category_idpivot бренд↔категория услуг
cms_brand_servicesid, brand_id, service_id, price_override, lock_versionоверрайд услуги под бренд
cms_object_typesid, slug, titleсправочник типов объектов (квартира/офис/дом)
cms_service_axis_pagesid, axis_type, axis_id, service_id, city_id, quality_ok, canonical_target_idденормализованный реестр созданных комбинаций (для ленивой генерации и quality-check)

parent_id на cms_service_locationsconstrained()->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, levelLocationRequest: parent_id существует и не создаёт цикл (глубина ≤ max_location_depth), slug уникален в пределах города
Admin-форма CRUD брендаtitle, slugBrandRequest: slug уникален
Admin-форма CRUD типа объектаtitle, slugObjectTypeRequest: slug уникален
Admin-форма оверрайда бренд-услугаbrand_id, service_id, price_override, content, lock_versionBrandServiceOverrideRequest: 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
Подписчики события ServiceAxisCombinationPublishedaxis_type, axis_id, service_id, city_idpayload события (канал 1)
Filament (отчёт quality-check)список комбинаций ниже порога контентатаблица админки / --json команды
cms/search (suggests, если включён)те же данные событияиндексация в поисковом провайдере

Whitelist-принцип: параметры кросс-URL вне whitelist осей/города — 404, не игнорируются и не создают запись в cms_service_axis_pages.

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

КлючТипДефолтaffectsPageCacheОписание
services-axes.enabled_axesarray["location"]даWhitelist активных осей (location/brand/object_type)
services-axes.min_content_lengthint300нетМинимум символов уникального текста для публикации посадочной
services-axes.canonical_fallbackbooltrueдаCanonical на базовую услугу при недостатке контента
services-axes.max_location_depthint4даМаксимальная глубина дерева локаций (защита от циклов/неограниченной рекурсии)
services-axes.max_combinations_per_serviceint500нетЖёсткий лимит комбинаций на одну услугу — достижение лимита логируется и не создаёт новые
services-axes.lazy_generationbooltrueнетКомбинация создаётся при первом заходе, а не разом при сохранении услуги
services-axes.quality_check_batch_sizeint200нетРазмер чанка батча quality-check (Bus::batch)
services-axes.kill_switchboolfalseдаАварийная остановка генерации новых комбинаций без выключения модуля; существующие качественные страницы продолжают отдаваться

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_lengthcms:services-axes:quality-check --json
404 вместо кросс-страницыдоступность cms/services/cms/services-seo, enabled_axes, kill_switchcms: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/servicesrequires (прямой вызов ServicesService)исходящеечтение базовых данных услуги для рендера комбинации и canonical fallback
cms/services-seorequires (прямой вызов роутинга/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.viewservices-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 запрещены

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