Тема
ТЗ — Поиск (Scout) (cms/search)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★★ · Донор: catalog, freelance (пути не выданы — только имена проектов) Статус: ТЗ к разработке
Назначение и возможности
Полнотекстовый поиск на Laravel Scout поверх Meilisearch (дефолтный движок) с возможностью переключения на Elasticsearch. Заменяет собой fallback на PostgreSQL tsvector из ядра, когда включён. Индексация и переиндексация выполняются фоново, без блокировки основного потока.
- Драйверы Meilisearch (дефолт) и Elasticsearch через единый контракт Scout
- Фоновая индексация чанками через очереди (без блокирующих команд на весь каталог)
- Полная переиндексация как шаг
cms:postupgrade, без даунтайма публичного поиска (alias-swap — теневой индекс собирается под временным именем, атомарная подмена алиаса по завершении) - Фасетные агрегации для витрин каталога (используется
cms/commerce-facets) - Синонимы и словарь опечаток (typo tolerance) с настройкой чувствительности; словоформы русского языка (морфология: падежи, число) — через встроенный анализатор движка (Meilisearch/ES), не собственный стеммер модуля
- Веса полей (boost) для тонкой настройки релевантности
- Автоматическое исключение из индекса при снятии с публикации
- Диагностика расхождения индекса с БД (drift-отчёт)
Зависимости и выключение
requires: ядро · provides: search-provider
Интерфейс search-provider живёт в cms/core-contracts; потребители (cms/commerce-facets и другие) резолвят реализацию через DI, не зная о Meilisearch/Elasticsearch напрямую (канал 3, data-exchange.md). При отсутствии модуля потребители, объявившие search.suggests, деградируют до собственной упрощённой логики (например фасеты без предагрегации).
Поведение при выключении: поиск автоматически откатывается на встроенный PostgreSQL tsvector-fallback ядра (более простая релевантность, без фасетов и синонимов) — деградация качества поиска, не поломка.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_search_index_jobs | id, model_type, chunk_from, chunk_to, status, attempts | учёт фоновых чанков индексации |
cms_search_synonyms | id, term, synonyms (json), locale | словарь синонимов по языкам |
cms_search_drift_log | id, model_type, missing_count, checked_at | результаты drift-проверки индекса |
Заметки: status — PHP Enum, индекс (model_type, status); synonyms — JSONB c касом 'array' (B-tree по term+locale); drift_log растёт линейно — BRIN по checked_at. locale в cms_search_synonyms — nullable по правилу измерений §4 стандарта: null означает «применимо ко всем языкам» (fallback-словарь), а не отдельную ветку кода для одноязычных сайтов. Сам поисковый индекс (документы) физически не хранится в БД CMS — это внешнее хранилище движка (Meilisearch/ Elasticsearch), таблицы модуля хранят только служебные метаданные индексации.
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
Событие PageSaved/PagePublished (ядро) | model_type, model_id, published | внутренний, из EventBus, не пользовательский вход |
Событие ContentEntrySaved (ядро) | model_type, model_id | внутренний |
Событие публикации/снятия из модулей-каталогов (cms/commerce-catalog и др.) | model_type, model_id, published | внутренний, по конвенции фактов ядра |
API GET /api/v1/search | q, filter[…], sort, cursor | FormRequest whitelist индексируемых моделей и полей фильтра/сортировки, search.max_query_length на q |
API POST /api/v1/admin/search/reindex | model_type (опционально) | FormRequest whitelist зарегистрированных searchable-моделей, Idempotency-Key обязателен |
API POST/PUT/DELETE /api/v1/admin/search/synonyms… | term, synonyms[], locale | FormRequest: длина term, whitelist locale из зарегистрированных языков сайта, символьный whitelist (не regex/код) |
toSearchableArray() моделей-владельцев (внутренний контракт Scout) | поля модели, помеченные модулем-владельцем | ответственность модуля-владельца данных, cms/search только потребляет |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Публичный фронт / headless-клиент | ответ GET /api/v1/search | {data: […], meta: {facets, next_cursor, degraded}} |
cms/commerce-facets (через search-provider) | результаты поиска и фасетных агрегаций | вызов интерфейса SearchProviderContract |
| Filament (админ) | статус индексов, drift-отчёт | таблица в UI |
Событие SearchIndexRebuilt | факт завершения переиндексации | model_type, indexed_count, duration_ms — подписчики (health, аудит) |
Событие SearchDriftDetected | факт расхождения | model_type, missing_count — подписчики (cms/health) |
| Блок «Строка поиска» | автодополнение/подсказки | тот же контракт поиска с укороченным limit |
Whitelist-принцип: любой параметр запроса вне описанных «Входов» (нефильтруемое поле, не зарегистрированная модель в reindex, символы вне разрешённых в term синонима) отвергается — 422, не молчаливый игнор и не проброс в движок как есть.
Настройки (группа search)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
search.driver | string | meilisearch | нет | Активный движок (meilisearch/elasticsearch) |
search.enabled | bool | true | да | Включение поискового провайдера вместо fallback ядра |
search.chunk_size | int | 500 | нет | Размер чанка при фоновой индексации |
search.typo_tolerance | bool | true | нет | Допуск опечаток при поиске |
search.facets_enabled | bool | true | нет | Включение фасетных агрегаций |
search.field_weights | array | {} | нет | Веса полей для релевантности по моделям |
search.engine_timeout_ms | int | 2000 | нет | Таймаут синхронного запроса к движку в моменте поиска |
search.fallback_on_engine_down | bool | true | нет | Автоматический откат на tsvector ядра при недоступности движка (kill-switch, см. ниже) |
search.reindex_chunk_timeout_seconds | int | 30 | нет | Таймаут обработки одного чанка индексации, после которого — retry |
search.max_query_length | int | 200 | нет | Максимальная длина поискового запроса q (защита от abuse) |
search.drift_alert_threshold | int | 50 | нет | Порог missing_count, после которого drift-проверка алертит |
search.fallback_on_engine_down — kill-switch модуля (матрица v2.2): позволяет мгновенно вернуться на tsvector ядра при проблемах с движком, не выключая модуль целиком (синонимы, веса, настройки индексации сохраняются для следующего восстановления связи).
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/search | public | Полнотекстовый поиск с фасетами |
| GET | /api/v1/admin/search/status | admin (search.view) | Статус индексов и drift-отчёт |
| POST | /api/v1/admin/search/reindex | admin (search.manage) | Постановка полной переиндексации в очередь |
| GET/POST/PUT/DELETE | /api/v1/admin/search/synonyms… | admin (search.manage; GET — search.view) | CRUD словаря синонимов |
Выдача /api/v1/search — keyset-пагинация (не OFFSET). POST /reindex требует Idempotency-Key: повторный вызов с тем же ключом возвращает статус уже идущей задачи вместо постановки второй параллельной полной переиндексации (см. «Крайние случаи»). Ответ /search всегда 200 с meta.degraded: true|false — публичный поиск не возвращает инфраструктурные коды вроде 503 (обоснование — см. «Крайние случаи», раздел про недоступность движка).
Компоненты
Блоки: виджет «Строка поиска» с автодополнением — лёгкий JS-бандл, автодополнение подгружается лениво (не в критическом пути первой отрисовки), контейнер результатов резервирует высоту заранее (без CLS), навигация по подсказкам — клавиатурой (стрелки + Enter). Filament: страница статуса индексов, редактор синонимов с массовым импортом/экспортом (CSV/JSON) и массовым удалением выбранных записей; пустое состояние словаря синонимов — подсказка «Синонимов пока нет, добавьте первый или импортируйте список». Демо-сидер: набор тестовых документов и синонимов для playground/галереи блоков (матрица v2.2, «Демо-контент»).
Команды: cms:search:reindex --json, cms:search:drift-check --json.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
SearchIndexRebuilt | завершена полная переиндексация модели | model_type, indexed_count, duration_ms |
SearchDriftDetected | drift-проверка нашла расхождение | model_type, missing_count |
Слушает: события публикации/снятия с публикации из ядра и модулей-каталогов. Provides: search-provider — контракт для cms/commerce-facets и других потребителей вместо прямого обращения к Meilisearch/Elasticsearch.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
Ядро: PageSaved/PagePublished | событие | in | Постановка job переиндексации страницы в очередь search |
Ядро: ContentEntrySaved | событие | in | То же для записей типов контента |
Модули-каталоги (cms/commerce-catalog и др.) | событие публикации/снятия | in | Индекс товара/сущности обновляется или удаляется по факту |
cms/commerce-facets | provides-контракт search-provider | out | Резолвит поиск/фасетные агрегации через интерфейс, не зная о движке |
| Другие потенциальные потребители (виджеты «похожее», рекомендации) | provides-контракт search-provider | out | Тот же контракт, множественные независимые потребители |
cms/health | health-чек модуля + событие SearchDriftDetected | out | Алерт при недоступности движка или превышении drift_alert_threshold |
Ядро: SettingsStore | сервис-вызов | in | Чтение группы search из кеша (driver, enabled, chunk_size…) |
Очередь search | очередь (канал 5) | out | Индексация чанками, drift-проверка, полная переиндексация |
| Блок «Строка поиска» | сервис модуля (SearchService) | out | Рендер получает данные из сервиса модуля, не из Meilisearch/ES напрямую из шаблона |
Фоновая работа
Очередь search — индексация чанками (Bus::batch) и drift-проверка; полная переиндексация — шаг cms:postupgrade, расписание drift-проверки — через ScheduleRegistrar ядра. Внешние вызовы к движку — только из очереди, кроме синхронного чтения при самом поисковом запросе. Обработка чанка ограничена search.reindex_chunk_timeout_seconds; при превышении — повтор с backoff (attempts в cms_search_index_jobs), исчерпание попыток — failed job + алерт через cms/health, не тихий пропуск документов.
Эксплуатация (матрица v2.2): метрики в Pulse — задержка ответа движка, глубина очереди search, missing_count из последнего drift-отчёта.
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Команда |
|---|---|---|
| Движок недоступен, публичный поиск деградировал | сеть/креды движка, search.engine_timeout_ms | cms:doctor --json (секция search), health-чек |
| Индекс разошёлся с БД | missing_count последнего drift-отчёта | cms:search:drift-check --json |
| Индекс пуст/повреждён после инцидента | версия схемы документов, отставание очереди search | cms:search:reindex --json |
| Синонимы/веса полей не применяются | кеш настроек группы search актуален | сверка search.field_weights в settings-store |
⚠️ После восстановления из бэкапа переиндексация обязательна. Сам поисковый индекс НЕ входит в бэкап БД (внешнее хранилище движка, см. ТЗ бэкапа) — сразу после cms:backup:restore-test/реального восстановления БД индекс пуст или содержит устаревшие данные относительно восстановленной БД. Рестор не считается завершённым без cms:search:reindex --json (§15 стандарта, «что пересоздаётся после рестора») — это шаг ранбука самого cms/backup, но именно cms/search обязан явно документировать его здесь как потребитель.
Производительность и кеш
Ожидаемые объёмы: от сотен документов (сайт-визитка) до нескольких миллионов на крупном коммерческом каталоге (десятки категорий × тысячи SKU, с учётом вариаций). При search.chunk_size=500 полная переиндексация каталога на 1 млн товаров — порядка 2000 job'ов в очереди search.
Горячий путь — публичный поиск: синхронный HTTP-вызов к движку в моменте запроса, ограниченный search.engine_timeout_ms; бюджет — 1 внешний вызов к движку + при необходимости минимальная гидратация недостающих полей по id из ответа (не N+1 на список результатов, обязателен ->with() там, где гидратация нужна). БД CMS не участвует в резолве поискового запроса как таковом — только в фоновой индексации.
Индексы: (model_type, status) на cms_search_index_jobs под мониторинг очереди индексации; B-tree (term, locale) на cms_search_synonyms под резолв словаря при построении запроса; BRIN (checked_at) на cms_search_drift_log под линейно растущий журнал drift-проверок.
Что кешируется: сам индекс движка — это и есть фактический «кеш» поисковых данных, отдельного CacheTags-слоя над результатами поиска модуль не объявляет — не кешируется, потому что результаты сильно варьируются по фильтрам/фасетам (низкий hit rate), а типовой ответ движка уже укладывается в десятки миллисекунд — кеширование добавило бы риск устаревания без ощутимой выгоды. Инвалидация индекса — по событиям (публикация/снятие), не по таймеру: задержка применения равна времени обработки job в очереди search при штатной нагрузке (секунды).
search.enabled — affectsPageCache: да: переключение между провайдером и tsvector-fallback меняет разметку блока «Строка поиска» (наличие автодополнения, фасетов) для всех посетителей одинаково (не персонализированно) — инвалидация штатным механизмом page-cache ядра через SettingChanged, без собственного тега модуля.
Безопасность
Публичный /api/v1/search — whitelist параметров и фасетных фильтров (filter[…]/sort), rate-limit против злоупотребления. Переиндексация и статус — только под permissions: search.view, search.manage.
Векторы атак, специфичные для модуля:
- инъекция через
q: запрос экранируется на уровне Scout/драйвера движка, не строится raw SQL/DSL конкатенацией;search.max_query_lengthотсекает аномально длинные запросы (422); - скрейпинг каталога через перебор фасетов: rate-limit на публичный
/search, whitelist допустимых фасетных полей не даёт итерировать произвольные внутренние поля модели; - утечка неопубликованного через индекс: индексация обязана уважать те же правила видимости, что и публичный API (черновики/неопубликованное не индексируются) — контрактный тест на этот инвариант обязателен;
- синонимы как вектор инъекции:
term/synonyms— не regex и не код, whitelist символов на уровне FormRequest (в отличие от полей, идущих через ReDoS-валидатор пользовательских regex в других модулях — здесь этот класс риска исключён самой природой поля); - повторная тяжёлая операция:
POST /reindexбезIdempotency-Keyмог бы быть использован для DoS через параллельный запуск множества полных переиндексаций — заголовок обязателен; - утечка инфраструктуры в ошибках: адрес/версия/трассировка Meilisearch/Elasticsearch не попадают в тело публичного ответа при сбое — посетитель видит только
meta.degraded/честное сообщение, разработчик — доменное исключение в логе (три аудитории, §12 стандарта).
ПДн-паспорт (матрица v2.2): собственные таблицы модуля (cms_search_index_jobs, cms_search_synonyms, cms_search_drift_log) ПДн не хранят. Индексируемые документы могут содержать ПДн, если модуль-владелец включает такие поля в toSearchableArray() (например текст отзыва с именем автора) — ответственность за отсутствие лишних ПДн в индексе лежит на модуле-владельце данных, cms/search документирует это правило, но не валидирует чужие поля.
Матрица ролей:
| Роль | Публичный поиск | Просмотр статуса/drift | Переиндексация | Синонимы |
|---|---|---|---|---|
| посетитель | ✅ | — | — | — |
| studio | ✅ | ✅ | ✅ | ✅ |
| админ | ✅ | ✅ | ✅ | ✅ |
| менеджер | ✅ | ✅ | ❌ | ✅ (только просмотр/правка, без reindex) |
UX-требования
Для админа:
- пустое состояние: индекс пуст (0 документов) — подсказка «Запустите первую индексацию» с кнопкой действия, а не тихая пустая таблица; drift-отчёт без расхождений — «Расхождений не найдено, последняя проверка: …»;
- массовые действия: массовый импорт/экспорт синонимов (CSV/JSON), массовое удаление выбранных записей словаря;
- ошибки на человеческом языке: «Индексация уже выполняется, дождитесь завершения» вместо технической ошибки конфликта job'ов; «Meilisearch недоступен — проверьте подключение» вместо сырого исключения драйвера;
- подтверждение ресурсоёмких операций: запуск полной переиндексации на боевом трафике — предупреждение «Переиндексация займёт ориентировочно N минут, поиск продолжит работать по текущему индексу» перед стартом.
Для посетителя:
- поведение поисковой строки при недоступном движке: автодополнение молча отключается (без JS-ошибок в консоли, без блокирующего UI), а сама форма поиска по
submitпродолжает работать — либо через автоматический tsvector-fallback (fallback_on_engine_down=true), либо честным сообщением «Поиск временно недоступен, попробуйте позже» (приfalse); белый экран или необработанное исключение исключены в обоих случаях; - поведение при пустой выдаче: «Ничего не найдено по запросу «…»» с предложениями (ослабленный запрос без typo-tolerance ограничений либо по синонимам), не пустая страница без объяснения;
- деградация на tsvector-fallback не сигнализируется посетителю как ошибка — для него это обычный результат поиска, отличие в качестве релевантности не должно выглядеть как сбой.
Крайние случаи и типовые баги
- Рассинхрон индекса с БД между обнаружением и устранением дрифта → drift-проверка находит расхождение по расписанию (не мгновенно), в промежутке между фактическим изменением данных и либо штатной обработкой события, либо следующим drift-fix пользователь может увидеть устаревший результат (обновлённая запись — до переиндексации, удалённая — до исключения из индекса). Это осознанный компромисс eventual consistency: основной путь синхронизации — события (задержка — время обработки job в очереди
search, секунды при штатной нагрузке), drift-проверка — страховка от пропущенных/недоставленных событий, не основной канал. - Модуль включён, движок недоступен по сети/таймауту в моменте запроса → при
search.fallback_on_engine_down=true— автоматический откат на tsvector ядра для конкретного запроса без даунтайма для посетителя, параллельно health-чек модуля фиксирует красный статус и алертит черезcms/health; таймаут (engine_timeout_ms) не ждёт следующего планового health-цикла — решение принимается в моменте самого запроса. Приfalse— честный ответ сmeta.degraded: trueи понятным сообщением вместо белого экрана. - Переиндексация без даунтайма → полная переиндексация строится в теневой индекс/алиас (alias-swap): новый индекс собирается под временным именем, атомарная подмена алиаса происходит только после завершения всех чанков; публичный поиск на боевом трафике читает прежний индекс вплоть до момента подмены, никогда не видит частично построенный.
- Двойной клик «Переиндексировать» →
Idempotency-KeyнаPOST /reindexпредотвращает постановку второй параллельной полной переиндексации; повторный запрос с тем же ключом возвращает статус уже идущей задачи, а не создаёт дублирующийBus::batch. - Параллельные джобы индексации гонятся за одним документом (ручной reindex запущен во время штатной обработки события) → идемпотентность на уровне upsert по documentID средствами Scout: повторная индексация того же документа безопасна, порядок применения последней записи не важен для корректности конечного состояния.
- Выключение модуля посреди полной переиндексации → незавершённые чанки в
cms_search_index_jobsостаются в статусеpending/in_progress; новые чанки не ставятся, воркер довершает уже взятые в работу (не обрывает на середине чанка), далее публичный поиск переключается на tsvector-fallback; при повторном включении модуль сообщает о незавершённой переиндексации и предлагает перезапустить. - Отсутствие suggests-модуля
cms/commerce-facets→ provides-контрактsearch-providerпросто не имеет потребителей, ядро/cms/searchработает как есть; фасетные агрегации по товарам никем не запрашиваются — не ошибка, штатная деградация канала 3. - Синонимы без
locale(null— «для всех языков») → трактуются как fallback-словарь, применяются при отсутствии отдельной записи для конкретной локали; на одноязычном сайте (правило измерений §4) все синонимы имеютlocale = nullи работают без отдельной ветки кода. - Пустой поисковый индекс на новом сайте →
GET /api/v1/searchвозвращает корректный конверт сdata: [], не404/500; UI показывает «ничего не найдено», не белый экран. - Огромный запрос / abuse (
qаномальной длины, целенаправленный перебор фасетных комбинаций) →search.max_query_lengthотклоняет запрос422, rate-limit на публичном эндпоинте ограничивает частоту перебора. - Конкурентное редактирование одного синонима двумя админами → optimistic lock по
updated_at: второйPUTбез актуальной версии получает409с текущим значением записи, UI предлагает подтвердить поверх (матрица v2.2, «Конкурентное редактирование»). - Код ответа при недоступном движке — зафиксированное решение (ревизия ядра 14.07.2026, п.2). Исходная версия ТЗ рассматривала
503 Service Unavailableкак более честный код для недоступного внешнего движка приsearch.fallback_on_engine_down=false, чем подмена данных фиктивным200— конвенция API ядра перечисляла только401/403/404/409/422/429, без инфраструктурных5xx. Разрешение: ядро приняло общую конвенцию деградации публичного чтения для всех provides-провайдеров —200сmeta.degraded: true(+meta.degraded_reasonдля логов) вместо5xx,5xxдопустим только при отказе самого ядра.cms/api-tokens,cms/dadata,cms/cbr-ratesи другие provides-провайдеры следуют тому же правилу — не частное решениеcms/search. Коды4xxиз общей конвенции остаются применимы к ошибкам самого запроса (422на невалидныйq/фильтр), не к состоянию движка.
Донорский код
Практики фасетного поиска/индексации — catalog; батчевая индексация фриланс-каталога — freelance (имена проектов, готовые пути не выданы). Миграция легаси-данных (матрица v2.2): у модуля нет собственных первичных данных для переноса — индекс всегда строится заново полной переиндексацией из БД CMS (cms:search:reindex), которая идемпотентна по построению (upsert по documentID); переносить с донора имеет смысл только конфигурацию — синонимы и веса полей, если они были явно выделены в донорских проектах, — импорт таких настроек не автоматизирован этим ТЗ и выполняется вручную через редактор синонимов при необходимости.
Тесты и приёмка
- [ ] Контрактный тест
/api/v1/searchс фасетами и пагинацией - [ ] Health-чек модуля проверяет доступность движка (Meilisearch/Elasticsearch)
- [ ] При выключении модуля поиск автоматически откатывается на tsvector-fallback без ошибок 500
- [ ] Индексация выполняется чанками через очереди, не блокирует
cms:postupgradeцеликом - [ ] Права
search.view/search.manageразграничивают чтение статуса и переиндексацию - [ ] Drift-отчёт находит и логирует расхождение индекса с БД на тестовых данных
- [ ] Нет N+1 при построении документов индекса (используется
->with()) - [ ] Модуль включён, движок недоступен в моменте запроса →
fallback_on_engine_down=trueоткатывает на tsvector без даунтайма;=falseвозвращает честноеmeta.degraded: true - [ ] Полная переиндексация не создаёт даунтайм публичного поиска (alias-swap проверен тестом)
- [ ] Повторный
POST /reindexс тем жеIdempotency-Keyне запускает вторую переиндексацию - [ ] Параллельная индексация одного документа двумя источниками не дублирует запись (upsert)
- [ ] Выключение модуля посреди переиндексации не обрывает взятый в работу чанк
- [ ] Синонимы с
locale = nullприменяются как fallback для всех языков - [ ] Пустой индекс на новом сайте возвращает корректный конверт, не
404/500 - [ ]
search.max_query_lengthи rate-limit отклоняют аномальные запросы - [ ] Конкурентное редактирование одного синонима двумя админами даёт
409, не «последний победил» - [ ] Индексация не включает неопубликованные/черновые сущности (проверка прав видимости)
- [ ] Поиск словоформы (падеж/число) находит документ с исходной формой слова (морфология RU)
- [ ] После восстановления БД из бэкапа
cms:search:reindex --jsonпересоздаёт индекс полностью - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
search_test,migrate:freshзапрещён