PostSearchDocument
Elasticsearch document PostSearchDocument — rebuildable search projection полнотекстового поиска
опубликованных постов ленты новостей (GET /api/v1/posts с непустым searchTerm — ТЗ.md:47).
Переводит поиск постов с PostgreSQL-tsvector/GIN на Elasticsearch (ADR-0059): заменяет SearchVector +
partial GIN ix_posts_search_vector where status='Published' (ранее UC-NWS-005).
Назначение
Source of truth — агрегат Post; проекция хранит только индексируемые/фильтруемые/
сортируемые поля опубликованного поста (заголовок, подзаголовок, «название», денормализованный plaintext
блоков SearchText, attribution). Поиск идёт через Talent.Common.Domain.Search (ElasticsearchDocument,
IElasticsearchIndexRepository<PostSearchDocument>, SearchQuery) — прямого клиента Elasticsearch в
handlers/domain нет. Драйвер перехода — fuzzy (typo-tolerance), транслитерация кириллица↔латиница и русская
морфология для заголовков/текста постов и имён авторов/компаний (ADR-0059 п.1). Только searchTerm-поиск
использует проекцию; лента без searchTerm читается keyset-ом из PostgreSQL (list-global-feed.md).
Index
| Поле | Значение |
|---|---|
| Index alias | news-posts |
| Versioned index | news-posts-v1 |
| Owner service | news |
| Source of truth | Post (news.posts, только Status = Published) |
| Rebuild job | PostSearchRebuildJob (ISearchRebuildJob) |
Document Id И Routing
- Document id:
Post.Id(Guid v7). Опубликованный контент — платформенного уровня (ADR-0052 п.3), tenant-префикс к id не применяется. - Routing key:
AuthorCompanyId(при наличии) — посты одной компании колокализуются; для постов инвестора/ сотрудника (AuthorCompanyId = null) — routing поId. - Delete/tombstone:
StatusпокидаетPublished(Unpublished/Archived) или soft-delete поста → удаление документа из индекса. Пост компании соCompanyReplica.Status = Suspended→ документ удаляется консюмеромidentity-companiesи восстанавливается при снятии suspension (INV-REP-003).
Поля
| Поле | Тип | Indexed | Stored | Analyzer | Source | Security |
|---|---|---|---|---|---|---|
Id | keyword | да | да | — | Post.Id | — |
AuthorUserId | keyword | да | да | — | Post.AuthorUserId | scope-filter (авторские выборки) |
AuthorType | keyword | да | да | — | Post.AuthorType | фильтр «тип автора» |
AuthorCompanyId | keyword | да | да | — | Post.AuthorCompanyId | routing/фильтр «компания-автор» |
CategoryId | keyword | да | да | — | Post.CategoryId | фильтр «категория» |
ProjectIds | keyword[] | да | да | — | Post.ProjectRefs.ProjectIds | фильтр «проект» |
Name | text | да | нет | ru_translit_fuzzy | Post.Name | — |
Title | text | да | нет | ru_translit_fuzzy | Post.Title | — |
Subtitle | text | да | нет | ru_translit_fuzzy | Post.Subtitle | — |
BlocksText | text | да | нет | ru_translit_fuzzy | Post.SearchText (plaintext блоков TextBlock.PlainText) | — |
SearchText | text | да | нет | ru_translit_fuzzy | Name ⊕ Title ⊕ Subtitle ⊕ BlocksText (copy_to) | основной матч searchTerm |
PublishedAt | date | да | да | — | Post.PublishedAt | сортировка «по дате» |
ViewsCount | long | да | да | — | Post.ViewsCount | сортировка «по популярности» |
Status | keyword | да | да | — | Post.Status (Published) | security-trimming |
IsAuthorCompanySuspended | boolean | да | да | — | CompanyReplica.Status = Suspended (денорм.) | security-trimming |
SchemaVersion | integer | да | да | — | документ | версия схемы проекции |
UpdatedAt | date | да | да | — | Post.UpdatedAt | freshness watermark |
IndexedAt | date | да | да | — | индексатор | freshness |
Анализатор ru_translit_fuzzy: русская морфология + ICU-транслитерация (кириллица↔латиница) + edge-ngram
для typo-tolerance (ADR-0059 п.1). Тело блоков-подборок (SelectionEmbed), display-данные объектов
недвижимости, галереи и обложки в индекс не попадают — ищется только текст (Name/Title/Subtitle/
SearchText). Ранжирование/PromotionBoost в проекции не участвуют — recommended отложен (решение №10).
Source Events
| Event | Version | Transform |
|---|---|---|
PostVersionCapturedEvent (IDomainEvent, publish/republish → Published) | v1 | upsert документа из текущего состояния Post (маппинг полей выше) |
PostContentChangedEvent (IDomainEvent, пост уже Published) | v1 | upsert документа (пересчёт BlocksText/SearchText) |
PostUnpublishedEvent / PostArchivedDomainEvent (Unpublished/Archived) | v1 | delete документа |
CompanySuspended / CompanyUpserted (консюмер identity-companies) | v1 | пересчёт IsAuthorCompanySuspended / delete-restore документов компании |
Проекция наполняется инкрементально доменными событиями агрегата Post (тот же коммит, что мутация —
через диспетчер доменных событий SDK) плюс периодический/ручной PostSearchRebuildJob для bootstrap и
восстановления консистентности. Черновики (Draft) в индекс не попадают — публикация обязательна.
Идемпотентность и порядок индексации. Upsert по Id идемпотентен (повтор события даёт тот же документ).
Конкурентные/переставленные события не должны откатывать проекцию назад: более раннее по Post.UpdatedAt
событие не перезаписывает уже проиндексированное более свежее состояние (watermark UpdatedAt), а delete
(Unpublished/Archived/soft-delete/suspend) не воскрешается последующим устаревшим upsert. Неизвестное
значение AuthorType индексируется как есть (keyword): фильтр «тип автора» по нему просто не сматчит, но
документ остаётся находимым по тексту (деградация фильтра, не потеря документа). Rebuild-джоб перекрывает
пропуски инкрементальных событий (потеря/сбой доставки) полным backfill из источника.
Freshness
- SLA:
search(секунды–минуты; eventual относительно PostgreSQL,FreshnessView). - Watermark:
Post.UpdatedAtв документе. - Stale behavior: показать результаты с freshness-маркером; критичных для решения проверок поиск не несёт — чтение поста идёт из источника (get-post.md).
Security
- Tenant/brand/scope trimming выполняется через
SearchScopeFilterдо query execution (ADR-0059 п.3): публичный поиск — толькоStatus = Published∧NOT IsAuthorCompanySuspended(INV-REP-003). Автор- подписьAuthorReplica.Status = Blockedпост не скрывает (INV-REP-002) — помечается на маппинге ленты. - PII fields: none — ФИО/телефон/реквизиты в проекцию не кладутся; подпись автора (
DisplayName) хранится в репликеidentityи добирается лентой на маппинге, не индексируется. - Полнотекстовый поиск не индексирует secrets, tokens, payment/KYC data, тело блоков-подборок.
Rebuild
- Create versioned index
news-posts-v{N+1}(маппинг + анализаторru_translit_fuzzy). - Backfill из
news.posts(Status = Published, компания неSuspended) черезISearchRebuildJob. - Validate counts/checksums против source.
- Atomically switch alias
news-postsна новую версию (IElasticsearchAliasManager, ADR-0029). - Keep previous index до окончания rollback-окна.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: Post, JSONB document: PostContentDocument (typed-блоки поста)
- API (7): Admin: модерация постов и комментариев (m2m), GET /api/v1/posts — Глобальная лента + поиск, POST /api/v1/posts — Создать черновик поста, POST /api/v1/posts/{id}/publish — публикация поста (snapshot + событие), POST /api/v1/posts/{id}/unpublish — снять пост с публикации, PUT /api/v1/posts/{id} — обновить метаданные поста, PUT /api/v1/posts/{id}/blocks — Редактирование блоков поста (embed через selections)
- Use Cases (2): UC-NWS-001. Создать черновик и опубликовать пост, UC-NWS-005. Поиск постов (Elasticsearch)
- Консюмеры (1): Consumer: Promotion Boost (pin-news-promotion-boost)
- index.md (1): News Service (Pin.News)
Связанные документы
- post.md — source of truth (
Post,SearchText, INV-NWS-008), post-block.md (TextBlock.PlainText→Post.SearchText), identity-replicas.md (INV-REP-002/003). - Дом операции поиска: list-global-feed.md; сценарий — UC-NWS-005-post-search.md.
- ADR-0059 (Elasticsearch-проекции), ADR-0008 (search projection), ADR-0029 (alias strategy), ADR-0056 (масштаб). Аналог поверхности: catalog/search/property-objects.md.
- Шаблон: search-document-template.md.