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
| Поле | Значение |
|---|---|
| ID | UC-NWS-005 |
| Контекст | news (см. docs/06-services/news/) |
| Тип | основной |
| Статус | draft |
Актор
| Роль | Permissions / scope | Tenant | Record-level |
|---|---|---|---|
Anonymous (гость) | нет — публичное чтение опубликованного (news.posts.view — публичный); rate limit по IP | null (ADR-0052) | не применяется — только Status = Published |
User | нет — то же; авторизация не влияет на выдачу глобальной ленты | null (чтение вне tenant, ADR-0052 п.3) | не применяется |
Запрос выполняется на платформенном уровне (без tenant-фильтра): опубликованный контент читается публично;
scope-фильтр навешивает публичное ограничение (Status = Published ∧ не suspended-компания) до выполнения.
Предусловие
- Существуют посты со
Status = Published, проиндексированные в Elasticsearch-проекцииPostSearchDocument(aliasnews-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-дельтой.
- Ввод. Пользователь вводит
searchTermв строку поиска ленты; опционально задаёт фильтры (categoryId/authorType/projectId/authorCompanyId) иsort. - Запрос фронта.
GET /api/v1/posts?searchTerm=...&categoryId&authorType&projectId&sort&cursor&limit(тот же endpoint, что глобальная лента — поиск = лента с непустымsearchTerm). - Приём и валидация.
SearchTerm—1..256после trim;Limit1..30;Sort ∈ PostFeedSort;AuthorType— валидное значение enum.searchTerm— untrusted, идёт только параметром полнотекстового запроса, не raw-DSL и не raw SQL. Нарушение →VALIDATION_ERROR. - Search-запрос (Elasticsearch-проекция). Полнотекстовый запрос к проекции
PostSearchDocument(aliasnews-posts): матчsearchTermпо полюSearchText(анализаторru_translit_fuzzy— морфология + транслитерация + typo-tolerance). Публичный scope-фильтр навешиваетStatus = Published∧NOT IsAuthorCompanySuspended(INV-REP-003) до выполнения запроса. Фильтры (categoryId/authorType/projectId/authorCompanyId) добавляются как term/terms-фильтры (A2). - Ранжирование/сортировка. Порядок — по
Sortконтракта ленты:dateиrecommended(deferred, решение №10) →PublishedAt desc, Id desc;popularity→ViewsCount desc, PublishedAt desc, Id desc; постранично —search_after(Limit + 1). Relevance-ранжирование (_score) — целевое, отложено (см. «Отложено»), не смешивается с курсором. - Батч-подписи и маппинг. Из страницы результатов берутся
PostId; актуальные счётчики/блоки постов и подписи (AuthorReplica/CompanyReplica/PostCategory) добираются батчем — join-free; маппингPostFeedItem. ВнутреннийAuthorUserIdнаружу не отдаётся; компания — через ref-модельAuthorCompany. - Выдача. Страница
PostFeedItem→ HTTP200(Items,NextCursor,HasMore). Подсветка совпадений — фронтом поsearchTermповерхTitle/Subtitle/BlocksPreview.FirstTextExcerpt; серверный Elasticsearch-highlighter — additive-расширение, отложено (не расширяет контракт ленты). - Рендер. Фронт показывает страницу результатов; пустой
Items→ A1; «показать ещё» — поNextCursor.
Диаграмма
Альтернативные Потоки
A1. Пустой результат
- Search-запрос (+ фильтры) не совпал ни с одним
Published-постом в проекции. - Возвращается пустой
Items,HasMore = false,NextCursor = null— HTTP200(не ошибка; отсутствие совпадений — валидный исход).
Завершается: фронт рендерит пустое состояние поиска.
A2. Фильтры сужают выдачу (categoryId/authorType/projectId/authorCompanyId)
- Вместе с
searchTermзаданы один или несколько фильтров. - Каждый фильтр добавляется как term/terms-фильтр к search-запросу:
CategoryId;AuthorType;AuthorCompanyId;ProjectId→termsпоProjectIds. - Существование id фильтров синхронно не проверяется (ADR-0056) — несуществующий фильтр даёт пустую страницу (см. A1), а не ошибку.
Возвращается к основному потоку на шаг 5 (сортировка/выдача суженного набора).
A3. Свежеопубликованный пост ещё не в индексе (лаг проекции)
- Пост только что опубликован/переопубликован; upsert Elasticsearch-проекции (
PostVersionCapturedEvent/PostContentChangedEvent→ posts.md) ещё не применён (eventual, секунды). - Такой пост может временно не находиться по
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_ERROR | 400 | SearchTerm > 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 — только пересечение |
| AC6 | searchTerm идёт только параметром полнотекстового запроса (не raw-DSL/SQL); спецсимволы безопасны | Тест: ввод со спецсимволами (', &, :*) не роняет запрос и не инъектирует DSL |
| AC7 | SearchTerm > 256 → VALIDATION_ERROR (400) | Тест валидации на границе длины |
| AC8 | Fuzzy/транслитерация: опечатка и латиница находят кириллический пост (ru_translit_fuzzy) | Тест: searchTerm с опечаткой/латиницей матчит целевой пост (ADR-0059 п.1) |
| AC9 | Лаг проекции (A3): свежеопубликованный пост может временно не находиться поиском, но уже виден в лентах/по ссылке; после применения проекции — находится | Тест: publish → немедленный searchTerm (может отсутствовать) → после лага пост в выдаче |
Обратные ссылки
Автоссылки: где используется этот документ.
- API (1): GET /api/v1/posts — Глобальная лента + поиск
- Поиск (1): PostSearchDocument
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- Дом endpoint + полный алгоритм (валидация,
search_after, батч-подписи, маппинг): list-global-feed.md. - Search-проекция: posts.md (
PostSearchDocument, поля, source events, rebuild). - Домены: post.md (
SearchText, INV-NWS-008, Elasticsearch-проекция), post-block.md (TextBlock.PlainText— вклад вSearchText), identity-replicas.md (INV-REP-002/003), post-ranking-model.md (recommended=deferred,_score— отложено, решение №10). - Событие публикации (наполняет проекцию через доменный обработчик): post-published.md; карта областей — README.md.
- ADR: 0052 tenancy, 0059 Elasticsearch-проекции, 0056 simplicity-first, 0054 user-activity (view-трекинг).
Отложено (deferred)
- Relevance-ранжирование по Elasticsearch
_score— целевой порядок search-выдачи (additive-значениеrelevanceвPostFeedSort), но отложено: (1) общее ранжирование отложено (решение №10,news.ranking.enabled=false, post-ranking-model.md); (2)_scoreплохо совмещается сsearch_after-курсором ленты. До реализации выдача сортируется поSort(по умолчаниюdate;recommended→date). - Серверная подсветка через Elasticsearch-highlighter — additive-расширение ответа; на старте подсветка
выполняется фронтом по
searchTerm, контрактPostFeedItemне расширяется.