Перейти к основному содержимому

UC-NWS-005. Поиск постов (Elasticsearch)

Назначение

Полнотекстовый поиск опубликованных постов ленты новостей: гость или авторизованный пользователь вводит searchTerm, система ищет по Elasticsearch-проекции PostSearchDocument и отдаёт релевантные Published-посты. Тот же endpoint, что глобальная лента (поиск = лента с непустым searchTerm); дом операции и полного алгоритма — list-global-feed.md, дом проекции — posts.md, дом полей — post.md. Этот use-case описывает search-дельту и правила выдачи. Поиск переведён с PostgreSQL-FTS на Elasticsearch (ADR-0059).

Metadata

ПолеЗначение
IDUC-NWS-005
Контекстnews (см. docs/06-services/news/)
Типосновной
Статусdraft

Актор

РольPermissions / scopeTenantRecord-level
Anonymous (гость)нет — публичное чтение опубликованного (news.posts.view — публичный); rate limit по IPnull (ADR-0052)не применяется — только Status = Published
Userнет — то же; авторизация не влияет на выдачу глобальной лентыnull (чтение вне tenant, ADR-0052 п.3)не применяется

Запрос выполняется на платформенном уровне (без tenant-фильтра): опубликованный контент читается публично; scope-фильтр навешивает публичное ограничение (Status = Published ∧ не suspended-компания) до выполнения.

Предусловие

  • Существуют посты со Status = Published, проиндексированные в Elasticsearch-проекции PostSearchDocument (alias news-posts): SearchText = Name ⊕ Title ⊕ Subtitle ⊕ BlocksText, posts.md.
  • Проекция наполнена доменными событиями Post (PostVersionCapturedEvent/PostContentChangedEvent → upsert; unpublish/archive → delete) + PostSearchRebuildJob; лаг проекции допустим (eventual, freshness-маркер).
  • Реплики подписей наполнены потребителями identity (лаг допустим — деградация подписи, не ошибка).

Триггер

ПолеЗначение
ИсточникUI (строка поиска ленты новостей) → API call
Триггерящий элементстраница /news (компонент поиска ленты) → GET /api/v1/posts?searchTerm=...

Основной Поток

Дом контракта и полного алгоритма (валидация, search_after, батч-подписи, маппинг) — list-global-feed.md; ниже — сценарий с search-дельтой.

  1. Ввод. Пользователь вводит searchTerm в строку поиска ленты; опционально задаёт фильтры (categoryId/authorType/projectId/authorCompanyId) и sort.
  2. Запрос фронта. GET /api/v1/posts?searchTerm=...&categoryId&authorType&projectId&sort&cursor&limit (тот же endpoint, что глобальная лента — поиск = лента с непустым searchTerm).
  3. Приём и валидация. SearchTerm1..256 после trim; Limit 1..30; Sort ∈ PostFeedSort; AuthorType — валидное значение enum. searchTerm — untrusted, идёт только параметром полнотекстового запроса, не raw-DSL и не raw SQL. Нарушение → VALIDATION_ERROR.
  4. Search-запрос (Elasticsearch-проекция). Полнотекстовый запрос к проекции PostSearchDocument (alias news-posts): матч searchTerm по полю SearchText (анализатор ru_translit_fuzzy — морфология + транслитерация + typo-tolerance). Публичный scope-фильтр навешивает Status = PublishedNOT IsAuthorCompanySuspended (INV-REP-003) до выполнения запроса. Фильтры (categoryId/authorType/projectId/authorCompanyId) добавляются как term/terms-фильтры (A2).
  5. Ранжирование/сортировка. Порядок — по Sort контракта ленты: date и recommended (deferred, решение №10) → PublishedAt desc, Id desc; popularityViewsCount desc, PublishedAt desc, Id desc; постранично — search_after (Limit + 1). Relevance-ранжирование (_score) — целевое, отложено (см. «Отложено»), не смешивается с курсором.
  6. Батч-подписи и маппинг. Из страницы результатов берутся PostId; актуальные счётчики/блоки постов и подписи (AuthorReplica/CompanyReplica/PostCategory) добираются батчем — join-free; маппинг PostFeedItem. Внутренний AuthorUserId наружу не отдаётся; компания — через ref-модель AuthorCompany.
  7. Выдача. Страница PostFeedItem → HTTP 200 (Items, NextCursor, HasMore). Подсветка совпадений — фронтом по searchTerm поверх Title/Subtitle/BlocksPreview.FirstTextExcerpt; серверный Elasticsearch-highlighter — additive-расширение, отложено (не расширяет контракт ленты).
  8. Рендер. Фронт показывает страницу результатов; пустой ItemsA1; «показать ещё» — по NextCursor.

Диаграмма

Альтернативные Потоки

A1. Пустой результат

  1. Search-запрос (+ фильтры) не совпал ни с одним Published-постом в проекции.
  2. Возвращается пустой Items, HasMore = false, NextCursor = null — HTTP 200 (не ошибка; отсутствие совпадений — валидный исход).

Завершается: фронт рендерит пустое состояние поиска.

A2. Фильтры сужают выдачу (categoryId/authorType/projectId/authorCompanyId)

  1. Вместе с searchTerm заданы один или несколько фильтров.
  2. Каждый фильтр добавляется как term/terms-фильтр к search-запросу: CategoryId; AuthorType; AuthorCompanyId; ProjectIdterms по ProjectIds.
  3. Существование id фильтров синхронно не проверяется (ADR-0056) — несуществующий фильтр даёт пустую страницу (см. A1), а не ошибку.

Возвращается к основному потоку на шаг 5 (сортировка/выдача суженного набора).

A3. Свежеопубликованный пост ещё не в индексе (лаг проекции)

  1. Пост только что опубликован/переопубликован; upsert Elasticsearch-проекции (PostVersionCapturedEvent/ PostContentChangedEventposts.md) ещё не применён (eventual, секунды).
  2. Такой пост может временно не находиться по searchTerm, хотя уже виден в лентах global/subscription (PostgreSQL-keyset — без лага) и по прямой ссылке. Это не ошибка: после применения проекции пост появляется в поиске. Симметрично только что снятый с публикации/архивированный пост может краткое время ещё находиться, пока не применён delete проекции.

Завершается: клиент показывает freshness-маркер; критичных для решения проверок поиск не несёт (чтение поста — из источника).

Постусловие

  • Мутаций нет (read-only): сущности не создаются/не изменяются/не удаляются, события не публикуются.
  • Клиент получил страницу PostFeedItem с релевантными Published-постами (черновики/Unpublished/ Archived и посты suspended-компаний в проекцию/выдачу не попадают).
  • Побочные эффекты: чтение Elasticsearch-проекции news-posts + гидрация PostgreSQL; трекинг просмотров здесь не пишется (view — фронт → user-activity, ADR-0054).

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
VALIDATION_ERROR400SearchTerm > 256; Limit вне 1..30; неизвестный Sort/AuthorType; повреждённый CursorПоказать ошибку параметров, не ретраить как есть
— (не ошибка)200Нет совпадений под запрос/фильтрыПустое состояние поиска (A1)
5xx (ProblemDetails)500/503Отказ Elasticsearch/гидрации PostgreSQLИнфраструктурная ошибка (не бизнес-ошибка домена); ретрай позже

Acceptance Criteria

#КритерийПроверка
AC1Поиск ищет по заголовку + подзаголовку + имени + тексту блоков (SearchText = Name ⊕ Title ⊕ Subtitle ⊕ BlocksText)Integration-тест: пост с термином только в TextBlock.PlainText находится по searchTerm
AC2Черновики/Unpublished/Archived не находятся (в проекции только Published)Тест: Draft и Unpublished посты с совпадающим термином отсутствуют в выдаче
AC3Посты suspended-компаний исключены (INV-REP-003, scope-фильтр)Тест: CompanyReplica.Status = Suspended → пост компании не в выдаче поиска
AC4Нет совпадений → 200 с пустым Items (не ошибка)Тест A1: заведомо отсутствующий терм → пустая страница, HasMore=false
AC5Фильтры сужают набор (AND с searchTerm)Тест A2: searchTerm + categoryId/authorType/projectId — только пересечение
AC6searchTerm идёт только параметром полнотекстового запроса (не raw-DSL/SQL); спецсимволы безопасныТест: ввод со спецсимволами (', &, :*) не роняет запрос и не инъектирует DSL
AC7SearchTerm > 256VALIDATION_ERROR (400)Тест валидации на границе длины
AC8Fuzzy/транслитерация: опечатка и латиница находят кириллический пост (ru_translit_fuzzy)Тест: searchTerm с опечаткой/латиницей матчит целевой пост (ADR-0059 п.1)
AC9Лаг проекции (A3): свежеопубликованный пост может временно не находиться поиском, но уже виден в лентах/по ссылке; после применения проекции — находитсяТест: publish → немедленный searchTerm (может отсутствовать) → после лага пост в выдаче

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные Документы

Отложено (deferred)

  • Relevance-ранжирование по Elasticsearch _score — целевой порядок search-выдачи (additive-значение relevance в PostFeedSort), но отложено: (1) общее ранжирование отложено (решение №10, news.ranking.enabled=false, post-ranking-model.md); (2) _score плохо совмещается с search_after-курсором ленты. До реализации выдача сортируется по Sort (по умолчанию date; recommendeddate).
  • Серверная подсветка через Elasticsearch-highlighter — additive-расширение ответа; на старте подсветка выполняется фронтом по searchTerm, контракт PostFeedItem не расширяется.