Search Document: ProjectSearchDocument
Elasticsearch document ProjectSearchDocument — rebuildable search projection для поиска проектов
(ЖК/апарт-отелей) в ленте каталога: полнотекстовый поиск по названию проекта и адресу с
typo-tolerance (fuzzy), транслитерацией (кириллица ↔ латиница) и морфологией русского языка.
Назначение
Фасетные фильтры ленты — тип/класс/регион/город/статус строительства/цена; гео-фасеты — по ссылкам
справочника RegionId/CityId, display — RegionName/CityName. Source of truth остаётся
ProjectEntity в PostgreSQL (../domain/project.md); поисковый индекс — вторичная
проекция (ADR-0059), scope-фильтр применяется до выполнения запроса.
Index
| Поле | Значение |
|---|---|
| Index alias | pin-chessboard-projects |
| Versioned index | pin-chessboard-projects-v1 (переключение alias, ADR-0029) |
| Owner service | chessboard |
| Source of truth | ProjectEntity (../domain/project.md) в PostgreSQL |
| Rebuild job | ProjectSearchRebuildJob |
Наполнение — инкрементально по событию pin.chessboard.project-upserted.v1 + периодический/ручной rebuild
(ADR-0059 п.2).
Document Id И Routing
- Document id:
<TenantId>:<ProjectId>(tenant-scoped, детерминирован — upsert идемпотентен). - Routing key:
TenantId(=DeveloperId) — документы застройщика в одном шарде (кластер малый, ADR-0056). - Delete/tombstone: документ индексируется только при
Status = Published; переход вDraft/Hidden/Archived(или soft-delete проекта) удаляет документ из индекса (fail-closed — скрытый проект не находится поиском).Developer.Status = Hiddenкаскадно удаляет документы всех его проектов.
Поля
| Поле | Тип | Indexed | Stored | Analyzer | Source | Security |
|---|---|---|---|---|---|---|
DocumentId | keyword | да | да | — | TenantId:ProjectId | include |
ProjectId | keyword | да | да | — | ProjectEntity.Id | include |
TenantId | keyword | да | нет | — | ProjectEntity.TenantId | scope-only (не выдаётся) |
DeveloperId | keyword | да | да | — | ProjectEntity.DeveloperId | include |
Name | text | да | да | ru_name (russian morphology + lowercase + edge-ngram) | ProjectEntity.Name | include |
NameTranslit | text | да | нет | translit (ICU кириллица↔латиница) | ProjectEntity.Name | include |
Address | text | да | да | ru_text | ProjectEntity.CityName + Location.District + Location.AddressLine (jsonb) | include |
RegionId | keyword | да | да | — | ProjectEntity.RegionId (ссылка справочника — ключ фасета «регион») | include |
RegionName | text | да | да | ru_text | ProjectEntity.RegionName (display + полнотекстовый матч) | include |
CityId | keyword | да | да | — | ProjectEntity.CityId (ссылка справочника — ключ фасета «город») | include |
CityName | text | да | да | ru_text | ProjectEntity.CityName (display + полнотекстовый матч) | include |
Type | keyword | да | да | — | ProjectEntity.Type (ProjectType) | include |
Class | keyword | да | да | — | ProjectEntity.Class (ProjectClass) | include |
ConstructionStatus | keyword | да | да | — | ProjectEntity.ConstructionStatus | include |
Stars | short | да | да | — | ProjectEntity.Stars | include |
Price.Min | scaled_float | да | да | — | ProjectEntity.Price.Min (NullableRange<decimal>) | include |
Price.Max | scaled_float | да | да | — | ProjectEntity.Price.Max | include |
CurrencyId | short | нет | да | — | ProjectEntity.CurrencyId (ссылка справочника; RUB-only, ADR-0048) | include |
PaybackYears | scaled_float | да | да | — | ProjectEntity.PaybackYears | include |
CoverImageFileId | keyword | нет | да | — | ProjectEntity.CoverImageFileId | include |
CreatedAt | date | да | да | — | ProjectEntity.CreatedAt (сортировка «по дате») | include |
SchemaVersion | int | да | да | — | версия схемы документа индекса | include |
UpdatedAt | date | да | да | — | ProjectEntity.UpdatedAt | include |
IndexedAt | date | да | да | — | момент индексации (watermark) | include |
Внешний ввод (searchTerm) идёт только типизированным параметром поиска (multi-match по Name/NameTranslit/
Address/RegionName с fuzziness), не в raw-DSL. Фасеты (Type/Class/RegionId/CityId/
ConstructionStatus/диапазон Price — гео-фасеты фильтруются по ссылкам справочника, display-имена — из
RegionName/CityName) и сортировки (relevance / Price.Min / CreatedAt) — типизированными параметрами
запроса. Точные цены/статусы для неавторизованных урезаются потребителем витрины (catalog) — сам индекс
держит только Published-срез без PII.
Source Events
| Event | Version | Transform |
|---|---|---|
pin.chessboard.project-upserted.v1 (ProjectUpsertedKafkaEvent) | 1 | При Status = Published — upsert документа (map скалярных полей + Price.Min/Max; гео — RegionId/CityId события, display-имена RegionName/CityName — из RegionSnapshot/CitySnapshot payload-а); при Draft/Hidden/Archived — delete по DocumentId. Refresh display-имён при переименовании в справочнике приходит этим же событием (re-publish HR-6, ../domain/handbook-replica.md). |
pin.chessboard.developer-upserted.v1 (DeveloperUpsertedKafkaEvent) | 1 | Developer.Status = Hidden → delete всех документов застройщика (TenantId = Id); Published → повторная индексация из source (через rebuild). |
Полный адрес (Address) собирается из display-имени города (CityName) и адресных деталей jsonb Location
(District, AddressLine); content jsonb-вкладок в индекс не попадает. Наполнение — консюмером проекции, плюс bootstrap/восстановление через rebuild из PostgreSQL.
- Порядок и защита от переупорядочивания. События проекта упорядочены ключом (
ProjectId); индексатор применяет upsert/delete, только если версия события больше уже проиндексированной (watermark поVersion/IndexedAt), иначе — skip. Переупорядоченное старое событие не перезатирает свежий документ. - Идемпотентность.
DocumentIdдетерминирован (TenantId:ProjectId) — повторная доставка upsert/delete повторяема без побочных эффектов; дубликат сообщения не создаёт второй документ. - Гонка publish ↔ hide/archive. Разрешается порядком per-key: конечное состояние документа (проиндексирован / удалён) соответствует последнему по версии событию; промежуточно допустим лаг в пределах freshness-SLA.
- Ретраи и DLQ. Временная ошибка индексации → повтор с backoff; стабильно неиндексируемое событие → DLQ индексатора с алертом (партиция не блокируется). Любое накопленное расхождение устраняется периодическим или ручным rebuild из PostgreSQL — источника истины.
Freshness
- SLA: near-real-time (< 1 мин от
project-upsertedдо появления в поиске); для admin/decision-запросов —FreshnessViewс watermark (ADR-0019). - Watermark:
IndexedAt+ offset топикаpin.chessboard.project-upserted.v1. - Stale behavior: при расхождении/лаге — показывать marker свежести; критичные решения принимаются по
source-of-truth (
chessboardAPI), не по проекции.
Security
- Tenant/brand trimming выполняется до выполнения запроса через scope-фильтр (ADR-0014, ADR-0052):
публичная лента видит только
Status = Publishedпроекты published-застройщиков; developer/admin-запросы — свой tenant/полный доступ (ADR-0053). - PII fields: нет (проект — не персональные данные; юрлицо/декларации
LegalInfoпо 214-ФЗ публичны и в индекс не кладутся). - Полнотекстовый индекс не хранит secrets, tokens, платёжные/KYC-данные;
TenantId— только scope-фильтр, наружу не выдаётся.
Поведение запроса (edge-cases)
- Пустой
searchTerm→ фасетная лента без relevance-ранжирования: результат сортируется заданной сортировкой (по умолчанию —CreatedAtdesc) и фильтруется фасетами. searchTermбез совпадений (даже с fuzzy/транслитерацией) → пустая страница (0 hits); фасетные счётчики пересчитываются по текущему scope.- Неизвестное значение фасета (
Type/Class/ConstructionStatus) → пустой результат по этому фильтру (fail-closed), а не ошибка запроса; клиент валидирует значения по reference-data.md. Неизвестный/архивныйRegionId/CityId— так же fail-closed: пустой результат, не ошибка (актуальные значения справочника — ../domain/handbook-replica.md). - Инвертированный диапазон цены (
min > max) → пустой результат; границы диапазона — типизированные параметры, не raw-DSL. - Границы пагинации — стандартные лимиты
size/offset; превышение усекается до максимума (глубокая пагинация ограничена, кластер малый — ADR-0056). - Scope-trim применяется до запроса. Гость видит только
Status = Publishedпроекты published-застройщиков; смена доступа/публичности между индексацией и запросом отражается scope-фильтром запроса и не требует переиндексации.
Rebuild
- Создать версионный индекс
pin-chessboard-projects-vNс analyzers (ru_name,ru_text,translit). - Backfill из PostgreSQL: выборка
Status = PublishedпоProjectEntityстримом, map вProjectSearchDocument. - Валидация counts/checksums против источника.
- Атомарно переключить alias
pin-chessboard-projectsна новую версию. - Держать предыдущий индекс до конца rollback-окна.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: ProjectEntity
- API (5): GET /api/v1/developers/projects — Поиск/список проектов застройщика, GET /api/v1/public/projects — Лента published-проектов (поиск + фильтры + сортировки), POST /api/v1/developers/projects/{projectId}/publish — Публикация проекта, POST /api/v1/developers/projects/{projectId}/unpublish — Снятие проекта с публикации, PUT /api/v1/developers/projects/{projectId} — Обновление проекта
- index.md (1): Chessboard Service (Pin.Chessboard)
Связанные документы
- Сущность-источник: ../domain/project.md; событие наполнения: ../events/project-upserted.md.
- Публичный поиск проектов (API): ../api/public/projects/search-projects.md.
- Решения: ADR-0059 (Elasticsearch-проекции), ADR-0008 (search projection), ADR-0029 (alias strategy), ADR-0014 (scope-filters), ADR-0052 (tenancy), ADR-0056 (simplicity-first).