Тема
ТЗ — Фасетный фильтр (cms/commerce-facets)
Слой: 🟡 функц-модуль · Зрелость доноров: ★★ · Донор: catalog (имя проекта, путь не выдан) Статус: ТЗ к разработке
Назначение и возможности
Фасетные агрегации листинга (свойства, цена, наличие, бренд) с выдачей пар «значение → количество» и поддержкой зависимых фасетов. HIGHLOAD: агрегации строятся из поискового индекса cms/search, не COUNT(*)/GROUP BY по живым таблицам каталога (см. /cms-v2/commerce-model, раздел «Highload-требования каталога»).
- Фасеты по фильтруемым свойствам (
cms/commerce-attributes), цене, наличию, бренду - Агрегации из поискового индекса Meilisearch/Elasticsearch (
cms/search), если он установлен - Выдача пар «значение → количество совпадений» для каждого фасета
- Зависимые фасеты: выбор одного сужает счётчики остальных
- ЧПУ-представление выбранных фильтров — интеграция с
cms/seo-filter(умный SEO-фильтр) - Кеш агрегаций листинга с инвалидацией по тегу раздела/категории
- Дебаунс-окно перед пересчётом кеша: массовое изменение цен/остатков не бьёт по каждому событию отдельно (см. «Фоновая работа»)
- PG-fallback (tsvector) — рабочий путь без
cms/search, но только доpg_fallback_thresholdтоваров в категории (см. «Зависимости и выключение»)
Зависимости и выключение
requires: cms/commerce-attributes · suggests: cms/search (агрегации из индекса — единственный путь выше pg_fallback_threshold), cms/seo-filter
⚠️ Противоречие: исходная версия ТЗ объявляла cms/search как requires (жёсткая зависимость), но одновременно описывала встроенный PG tsvector-fallback агрегаций (pg_fallback_threshold), который по определению работает без cms/search. Жёсткая requires-зависимость логически несовместима с наличием автономного пути работы модуля. Разрешение: cms/search фиксируется как suggests — PG-fallback является полноценным (хоть и ограниченным по объёму) путём работы без поискового индекса; cms/commerce-attributes остаётся requires, так как конфигурация фасета ссылается на attribute_id напрямую (FK) и без модуля атрибутов конфигурировать нечего.
Поведение при отсутствии/выключении cms/search: категории с числом товаров ≤ pg_fallback_threshold продолжают показывать блок фасетных фильтров через PG tsvector-агрегацию (заметно медленнее полнотекстового индекса, но рабочую и корректную); категории выше порога — блок фасетных фильтров скрывается целиком (не показывать агрегацию, которая будет тормозить или таймаутить на живом трафике), листинг продолжает работать через обычную категорийную навигацию без фильтров.
Поведение при выключении самого cms/commerce-facets: листинг категории отображается без блока фильтров — навигация по каталогу продолжает работать через категории и поиск, деградация без поломки витрины.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
commerce_facet_configs | id, category_id, attribute_id, sort, is_active, lock_version | какие фасеты показывать в категории; FK — constrained()->index(); lock_version — optimistic lock на конкурентную правку в Filament |
commerce_facet_cache | cache_key, category_id, city_id (nullable), payload (jsonb), expires_at | кеш агрегаций листинга по ключу нормализованного набора фильтров; индекс по (category_id, expires_at) |
cache_key строится из отсортированного набора активных filter[…] + измерений city_id/site_id (nullable, §4 стандарта) — одинаковый набор фильтров в другом порядке даёт тот же ключ, не размножает кеш.
ПДн-паспорт. Модуль ПДн не хранит: обе таблицы содержат обезличенную конфигурацию фасетов и кеш агрегированных счётчиков по категории, к субъекту персональных данных не привязаны. Ретеншн: commerce_facet_cache живёт до expires_at, просроченные строки прибираются плановой джобой (см. «Фоновая работа»).
Входные и выходные данные
Входы (whitelist — всё не перечисленное отклоняется 422):
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Публичный GET /categories/{slug}/facets | filter[attribute_code][]=value, filter[price][min|max], filter[in_stock]=1, sort | FormRequest сверяет каждый ключ filter[…] со списком активных commerce_facet_configs категории; неизвестный/неактивный фасет отклоняется, не молча игнорируется |
| Filament: конфигурация фасета | category_id, attribute_id, sort, is_active, lock_version | FormRequest, attribute_id — реальный существующий атрибут из cms/commerce-attributes, lock_version совпадает с текущим (иначе 409) |
Событие AttributeValueChanged (cms/commerce-attributes) | attribute_id, category_ids[] | внутренний источник, не пользовательский ввод — используется только как триггер дебаунс-окна |
Событие PricesBulkImported (cms/commerce-pricing, без requires — событие не требует манифестной связи) | imported_count, price_type_id | внутренний источник, триггер дебаунс-окна для ценовых фасетов |
Событие SearchIndexRebuilt (cms/search, если установлен) | model_type, indexed_count, duration_ms | внутренний, полный прогрев кеша агрегаций категории |
cms:commerce-facets:warm-cache | параметры (категория — опционально) | whitelist аргументов команды |
Выходы:
| Потребитель | Данные | Формат |
|---|---|---|
| Посетитель / блок листинга | фасеты с парами «значение → count» | {data:{facets:[{code,title,type,values:[{value,label,count}]}]}, meta:{degraded, degraded_reason?}} |
cms/seo-filter (requires со стороны seo-filter) | список активных значений фасетов категории | сервис-вызов (канал 4 со стороны seo-filter) |
| Filament | конфигурация фасетов по категориям | таблица |
Событие FacetCacheInvalidated | см. «События и обмен» | category_id |
meta.degraded: true выставляется, когда категория выше pg_fallback_threshold осталась без cms/search — ответ всё равно 200, data.facets: [], не 5xx (конвенция ядра, core.md).
Настройки (группа commerce-facets)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
commerce-facets.enabled | bool | true | да | Включение блока фасетных фильтров |
commerce-facets.max_values_per_facet | int | 20 | нет | Лимит значений, показываемых в одном фасете (сверх — «показать ещё» по count desc) |
commerce-facets.cache_ttl | int | 600 | нет | TTL кеша агрегаций листинга |
commerce-facets.pg_fallback_threshold | int | 100000 | да | Порог товаров в категории, выше которого PG tsvector-fallback запрещён и блок фильтров скрывается |
commerce-facets.debounce_window_seconds | int | 15 | нет | Окно агрегации входящих событий перед батчевым прогревом кеша (см. «Фоновая работа») |
commerce-facets.pause_recalculation | bool | false | нет | Kill-switch: аварийная пауза фонового пересчёта агрегаций без выключения модуля — уже прогретый кеш продолжает отдаваться до истечения cache_ttl |
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/catalog/categories/{slug}/facets | public | Агрегации фасетов для текущего набора фильтров |
| GET | /api/v1/admin/commerce-facets/configs | admin (commerce-facets.view) | Конфигурация фасетов по категориям |
| POST | /api/v1/admin/commerce-facets/configs | admin (commerce-facets.manage) | Правка состава и порядка фасетов категории (lock_version в теле — 409 при конфликте) |
Компоненты
Блоки (BlockRegistry): виджет блока фильтров листинга — данные из сервиса модуля, при meta.degraded рендерит fallback (см. «UX-требования»), собственный JS ограничен раскрытием списков значений (без CLS, резерв высоты блока). Filament: конструктор фасетов категории с drag-n-drop порядком, индикатор «фасет неактивен — скрыт на витрине».
Демо-контент: CommerceFacetsDemoSeeder создаёт 2 демо-категории с 3–4 сконфигурированными фасетами (свойство, цена, наличие) и прогретым кешом агрегаций — галерея /_gallery и playground показывают блок фильтров без ручного ввода.
Команды: cms:commerce-facets:warm-cache --json, cms:commerce-facets:prune-expired-cache --json.
Эксплуатация (ранбук): метрики — доля cache-miss по агрегациям, длительность PG-fallback запроса (алерт при приближении к таймауту), глубина очереди commerce-facets. Алерт — доля категорий в meta.degraded=true растёт (признак недоступности cms/search).
| Симптом | Что проверить / команда |
|---|---|
| Блок фильтров пуст на всей витрине | cms/search доступен? health-чек search; для PG-режима — не превышен ли pg_fallback_threshold |
| Счётчики устарели после массового импорта | глубина очереди commerce-facets, pause_recalculation, cms:commerce-facets:warm-cache --json |
| PG-fallback медленный/таймаутит | число товаров в категории относительно pg_fallback_threshold — снизить порог или включить cms/search |
Бэкап/рестор: commerce_facet_configs попадает в обычный бэкап БД (конфигурация — не пересоздаётся автоматически). commerce_facet_cache в бэкап не входит осознанно — после рестора прогревается заново cms:commerce-facets:warm-cache --json; рестор без этого шага считается незавершённым.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
FacetCacheInvalidated | завершён батчевый прогрев кеша агрегаций категории | category_id |
Слушает: SearchIndexRebuilt из cms/search (полный прогрев), AttributeValueChanged из cms/commerce-attributes (структурные изменения фильтруемых значений), PricesBulkImported из cms/commerce-pricing (массовое изменение цен — без requires, слабая связь событием). Единичные PriceChanged/StockMoved не слушаются напрямую — точечные изменения одного ТП покрываются истечением cache_ttl, а не реактивной инвалидацией (см. «Фоновая работа»).
Provides-контракты: не предоставляет. FilterBus: не использует; работает поверх индекса cms/search (или PG tsvector), а не через фильтр-цепочку.
Таблица взаимодействий:
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
cms/search (suggests) | provides-контракт search-provider (косвенно, через сервис поиска) + событие SearchIndexRebuilt | in | агрегации при наличии индекса; полный прогрев после переиндексации |
cms/commerce-attributes (requires) | событие AttributeValueChanged | in | попадает в дебаунс-окно, не пересчитывает синхронно |
cms/commerce-pricing | событие PricesBulkImported | in | попадает в дебаунс-окно; единичный PriceChanged игнорируется намеренно |
cms/seo-filter (requires со стороны seo-filter) | сервис-вызов (канал 4) | out | seo-filter запрашивает активные значения фасетов для whitelist-комбинаций посадочных страниц |
Очередь commerce-facets | очередь (канал 5) | внутри модуля | флаш дебаунс-окна, прогрев кеша, прополка просроченных записей |
Фоновая работа
Дебаунс-механизм (окно агрегации событий, а не пересчёт на каждое отдельное событие — обязателен при массовом изменении цен/остатков): каждое входящее событие-триггер (AttributeValueChanged, PricesBulkImported) добавляет category_id в короткоживущий Redis-сет commerce-facets:pending:{category_id} с TTL debounce_window_seconds. Первое попадание в пустой сет ставит одну отложенную job (FlushFacetDebounce, dispatch()->delay()) на конец окна; повторные события той же категории в пределах окна не создают новых job — они лишь продлевают/наполняют тот же сет. По истечении окна job делает один проход инвалидации + прогрева кеша по категории, независимо от того, сколько событий пришло за это время (N событий → 1 job). pause_recalculation проверяется перед постановкой job — при паузе события накапливаются в сете, но флаш не запускается; старый кеш продолжает отдаваться до cache_ttl.
Очередь commerce-facets: FlushFacetDebounce (батчевый прогрев по дебаунс-окну), prune-expired-cache по расписанию через ScheduleRegistrar (удаление строк commerce_facet_cache старше expires_at, не полагаться только на пассивное истечение TTL при чтении — иначе таблица растёт).
Производительность и кеш
Ожидаемые объёмы: категории от десятков до сотен тысяч товаров; при cms/search — агрегации не зависят от объёма линейно (движок агрегирует по индексу); в PG-режиме объём ограничен pg_fallback_threshold (дефолт 100k) — выше него блок фильтров скрывается, а не деградирует по времени ответа незаметно для пользователя.
Горячий путь — публичный запрос агрегаций листинга: бюджет 0 запросов к БД при cache-hit (Redis по cache_key); при cache-miss — 1 вызов к cms/search (через search-provider) либо 1 запрос к PG (tsvector + GROUP BY по индексируемым столбцам, только если категория ≤ pg_fallback_threshold) на весь набор фасетов категории разом, не по одному запросу на фасет.
Критичные индексы: (category_id, is_active, sort) на commerce_facet_configs; (category_id, expires_at) на commerce_facet_cache под прогрев/прополку; PG-fallback требует покрывающий индекс/tsvector-GIN на фильтруемые атрибуты и цену в таблицах каталога — заводится только на сайтах без cms/search, чтобы не дублировать индекс, который в норме не используется.
Теги и инвалидация: commerce-facets:category:{id}. Инвалидация — батчево по дебаунс-окну (см. «Фоновая работа»), не поштучно на каждое изменение одного товара; ручной триггер — cms:commerce-facets:warm-cache --json.
Безопасность
Границы входа: вход — whitelist допустимых filter[…]/sort строго по активной конфигурации фасетов категории; попытка запроса неактивного/несуществующего фасета отклоняется на границе FormRequest (422), не пробрасывается в PG/поисковый движок как есть — та же логика, что закрывает риск скрейпинга произвольных внутренних полей через перебор параметров. Публичный эндпоинт — под rate-limit (агрегация — не бесплатная операция, особенно в PG-режиме).
Матрица ролей:
| Действие / permission | Админ | Менеджер | Редактор | Studio |
|---|---|---|---|---|
| Просмотр агрегаций на витрине (публично) | ✅ | ✅ | ✅ | ✅ |
Просмотр конфигурации фасетов (commerce-facets.view) | ✅ | ✅ | ✅ | ✅ |
Правка состава/порядка фасетов (commerce-facets.manage) | ✅ | ✅ | — | ✅ |
Изменение pg_fallback_threshold, kill-switch (настройки) | ✅ | — | — | ✅ |
Права: commerce-facets.view, commerce-facets.manage.
UX-требования
Админ:
- пустое состояние конструктора — «Фасеты для этой категории не настроены» со ссылкой «Добавить фасет», не тихая пустая таблица;
- массовое действие: «деактивировать выбранные фасеты» на списке конфигураций категории;
- человеческая ошибка — «Атрибут уже добавлен как фасет в эту категорию» вместо generic 500 при дубле; конфликт
lock_version— «Конфигурацию изменил другой администратор, обновите и повторите»; - подтверждение перед изменением
pg_fallback_threshold— эффект «часть категорий потеряет блок фильтров» показывается заранее (сколько категорий затронет), не постфактум.
Посетитель:
- комбинация фильтров даёт 0 результатов — «Ничего не найдено с выбранными фильтрами» и кнопка «Сбросить фильтры»; уже выбранные значения не сбрасываются автоматически без действия пользователя (сохранение состояния формы при ошибке — §UX стандарта);
- категория выше
pg_fallback_thresholdбезcms/search— блок фильтров не рендерится вовсе (не «пустой» блок с заголовком без содержимого) — листинг выглядит целостно, без визуального намёка на сломанную функциональность; - раскрытие «показать ещё» в фасете с большим числом значений — без перезагрузки страницы, доступно с клавиатуры.
Крайние случаи и типовые баги
- Массовое изменение цен/остатков (импорт тысяч позиций) → дебаунс-окно
debounce_window_secondsсобирает все триггеры категории в одну батчевую job вместо job на каждое событие — см. «Фоновая работа». ЕдиничныйPriceChangedвне импорта не триггерит пересчёт вовсе, полагается наcache_ttl. - Комбинация фильтров даёт 0 результатов → не белый экран и не пустой блок без объяснения: сообщение «Ничего не найдено» + кнопка сброса фильтров, выбранные значения остаются видимыми до явного сброса пользователем.
- Деградация без
cms/search→ категория ≤pg_fallback_thresholdработает на PG tsvector-агрегации (медленнее, но корректно); выше порога — блок фильтров скрывается целиком (см. ⚠️ Противоречие выше и «Зависимости и выключение»). PG-fallback — не бесплатная замена индекса, а осознанно ограниченный по объёму режим. - Индексируемость фасетных URL → сам модуль не принимает решений об индексации: любая комбинация фильтров, попавшая в whitelist-правило
cms/seo-filter, получает собственный индексируемый ЧПУ; всё остальное отдаётся как обычный листинг с?filter[…]=в query-string без собственного индексируемого URL — canonical на базовый листинг категории проставляет SEO-база ядра,commerce-facetsтолько поставляет исходные значения фасетов через сервис-вызовseo-filter. - Гонка: два разных триггера дебаунса одной категории почти одновременно (
AttributeValueChangedиPricesBulkImportedв один момент) → оба попадания идут в один и тот же Redis-сетpending:{category_id}, флаш-job — одна на категорию за окно, не дублируется; прогрев кеша идемпотентен (upsert поcache_key), повторный прогон не создаёт дублирующих строкcommerce_facet_cache. - Выключение модуля посреди прогрева кеша → уже стартовавшая batch-джоба довершает взятые в работу чанки (не обрывается на середине), новые job не ставятся; листинг без блока фильтров отдаётся немедленно для новых запросов (деградация, не ожидание завершения прогрева).
- Огромное число уникальных значений одного фасета (например бренд с тысячами значений) →
max_values_per_facetограничивает вывод поcount desc, не рендерит все значения разом; «показать ещё» — отдельная подгрузка, не увеличивает вес первого ответа. - Пустая категория (0 товаров) →
GET /facetsвозвращаетdata.facets: [], не404/500; блок фильтров на витрине скрывается (пустой список — не полезная информация для посетителя). - Измерение
city/site— при включённомcms/multicitycity_idвходит вcache_keyкак nullable-измерение: счётчики наличия/цены не должны утечь из одного города в другой через общий кеш; на сайте без городов измерение простоnullи не создаёт отдельной ветки кода. - Конкурентное редактирование конфигурации фасета — два админа правят порядок/состав фасетов одной категории одновременно →
lock_versionнаcommerce_facet_configs, второйPOSTбез актуальной версии получает409, не «последний победил» молча. - Отсутствие suggests-модуля
cms/seo-filter→ фасетный фильтр работает как обычный?filter[…]=без генерации отдельных индексируемых посадочных страниц — деградация предусмотрена, не поломка блока фильтров.
Донорский код
| Что взять | Путь |
|---|---|
| Практики фасетной агрегации и зависимых фильтров | catalog (имя проекта, путь не выдан) |
Legacy-импорт: у модуля нет собственных первичных данных для переноса — конфигурация фасетов (commerce_facet_configs) настраивается заново в Filament при разработке категорий, кеш агрегаций всегда строится прогревом (warm-cache), не переносом. Если у донора была конфигурация фасетов по категориям в структурированном виде — маппинг на commerce_facet_configs по category_id/attribute_id выполняется вручную при разработке категории, отдельная команда import-legacy для этого модуля не требуется (в отличие от модулей с собственными первичными данными).
Тесты и приёмка
- [ ] Контрактный тест: агрегации фасетов читаются из индекса
cms/search, не черезCOUNT(*)по живым таблицам - [ ] PG-fallback агрегаций работает ≤
pg_fallback_threshold, выше — блок фильтров скрыт, не выдаёт таймаут/500 - [ ] Выбор значения одного фасета пересчитывает счётчики зависимых фасетов корректно
- [ ] Дебаунс-окно собирает серию событий массового импорта в одну batch-job, не в job на событие
- [ ] Единичный
PriceChangedне триггерит немедленный пересчёт (полагается наcache_ttl) - [ ] Комбинация фильтров без результатов возвращает понятное сообщение, а не пустой блок/белый экран
- [ ] Кеш агрегаций инвалидируется батчево по тегу категории, не поштучно при каждом товаре
- [ ]
cache_keyучитывает измерениеcity_id— счётчики одного города не утекают в другой - [ ] Конкурентная правка конфигурации фасета двумя админами отдаёт
409, не «последний победил» - [ ]
pause_recalculation(kill-switch) не блокирует уже прогретый кеш, только новые прогревы - [ ] При выключении модуля листинг отдаётся без блока фильтров, без ошибок 500
- [ ] Права
commerce-facets.view/.manageразграничивают чтение и конфигурацию фасетов - [ ] Контрактный набор
cms-testingи testbench-изоляция зелёные, feature-тест на каждый роут - [ ] Тестовая БД только
commerce-facets_test;migrate:fresh/refresh/resetзапрещены