Skip to content

ТЗ — Фасетный фильтр (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_configsid, category_id, attribute_id, sort, is_active, lock_versionкакие фасеты показывать в категории; FK — constrained()->index(); lock_version — optimistic lock на конкурентную правку в Filament
commerce_facet_cachecache_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}/facetsfilter[attribute_code][]=value, filter[price][min|max], filter[in_stock]=1, sortFormRequest сверяет каждый ключ filter[…] со списком активных commerce_facet_configs категории; неизвестный/неактивный фасет отклоняется, не молча игнорируется
Filament: конфигурация фасетаcategory_id, attribute_id, sort, is_active, lock_versionFormRequest, 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.enabledbooltrueдаВключение блока фасетных фильтров
commerce-facets.max_values_per_facetint20нетЛимит значений, показываемых в одном фасете (сверх — «показать ещё» по count desc)
commerce-facets.cache_ttlint600нетTTL кеша агрегаций листинга
commerce-facets.pg_fallback_thresholdint100000даПорог товаров в категории, выше которого PG tsvector-fallback запрещён и блок фильтров скрывается
commerce-facets.debounce_window_secondsint15нетОкно агрегации входящих событий перед батчевым прогревом кеша (см. «Фоновая работа»)
commerce-facets.pause_recalculationboolfalseнетKill-switch: аварийная пауза фонового пересчёта агрегаций без выключения модуля — уже прогретый кеш продолжает отдаваться до истечения cache_ttl

API

МетодПутьДоступНазначение
GET/api/v1/catalog/categories/{slug}/facetspublicАгрегации фасетов для текущего набора фильтров
GET/api/v1/admin/commerce-facets/configsadmin (commerce-facets.view)Конфигурация фасетов по категориям
POST/api/v1/admin/commerce-facets/configsadmin (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 (косвенно, через сервис поиска) + событие SearchIndexRebuiltinагрегации при наличии индекса; полный прогрев после переиндексации
cms/commerce-attributes (requires)событие AttributeValueChangedinпопадает в дебаунс-окно, не пересчитывает синхронно
cms/commerce-pricingсобытие PricesBulkImportedinпопадает в дебаунс-окно; единичный PriceChanged игнорируется намеренно
cms/seo-filter (requires со стороны seo-filter)сервис-вызов (канал 4)outseo-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-miss1 вызов к 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 — блок фильтров не рендерится вовсе (не «пустой» блок с заголовком без содержимого) — листинг выглядит целостно, без визуального намёка на сломанную функциональность;
  • раскрытие «показать ещё» в фасете с большим числом значений — без перезагрузки страницы, доступно с клавиатуры.

Крайние случаи и типовые баги

  1. Массовое изменение цен/остатков (импорт тысяч позиций) → дебаунс-окно debounce_window_seconds собирает все триггеры категории в одну батчевую job вместо job на каждое событие — см. «Фоновая работа». Единичный PriceChanged вне импорта не триггерит пересчёт вовсе, полагается на cache_ttl.
  2. Комбинация фильтров даёт 0 результатов → не белый экран и не пустой блок без объяснения: сообщение «Ничего не найдено» + кнопка сброса фильтров, выбранные значения остаются видимыми до явного сброса пользователем.
  3. Деградация без cms/search → категория ≤ pg_fallback_threshold работает на PG tsvector-агрегации (медленнее, но корректно); выше порога — блок фильтров скрывается целиком (см. ⚠️ Противоречие выше и «Зависимости и выключение»). PG-fallback — не бесплатная замена индекса, а осознанно ограниченный по объёму режим.
  4. Индексируемость фасетных URL → сам модуль не принимает решений об индексации: любая комбинация фильтров, попавшая в whitelist-правило cms/seo-filter, получает собственный индексируемый ЧПУ; всё остальное отдаётся как обычный листинг с ?filter[…]= в query-string без собственного индексируемого URL — canonical на базовый листинг категории проставляет SEO-база ядра, commerce-facets только поставляет исходные значения фасетов через сервис-вызов seo-filter.
  5. Гонка: два разных триггера дебаунса одной категории почти одновременно (AttributeValueChanged и PricesBulkImported в один момент) → оба попадания идут в один и тот же Redis-сет pending:{category_id}, флаш-job — одна на категорию за окно, не дублируется; прогрев кеша идемпотентен (upsert по cache_key), повторный прогон не создаёт дублирующих строк commerce_facet_cache.
  6. Выключение модуля посреди прогрева кеша → уже стартовавшая batch-джоба довершает взятые в работу чанки (не обрывается на середине), новые job не ставятся; листинг без блока фильтров отдаётся немедленно для новых запросов (деградация, не ожидание завершения прогрева).
  7. Огромное число уникальных значений одного фасета (например бренд с тысячами значений) → max_values_per_facet ограничивает вывод по count desc, не рендерит все значения разом; «показать ещё» — отдельная подгрузка, не увеличивает вес первого ответа.
  8. Пустая категория (0 товаров) → GET /facets возвращает data.facets: [], не 404/500; блок фильтров на витрине скрывается (пустой список — не полезная информация для посетителя).
  9. Измерение city/site — при включённом cms/multicity city_id входит в cache_key как nullable-измерение: счётчики наличия/цены не должны утечь из одного города в другой через общий кеш; на сайте без городов измерение просто null и не создаёт отдельной ветки кода.
  10. Конкурентное редактирование конфигурации фасета — два админа правят порядок/состав фасетов одной категории одновременно → lock_version на commerce_facet_configs, второй POST без актуальной версии получает 409, не «последний победил» молча.
  11. Отсутствие 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 запрещены

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