TicketSearchDocument
Elasticsearch document TicketSearchDocument — rebuildable search projection внутреннего поиска
обращений Службы заботы (рабочий стол оператора: ../api/support/list-tickets.md,
поиск по номеру/теме/заявителю + фасетные фильтры статуса/очереди/оператора/приоритета/категории/дат/SLA).
Переводит FTS с PostgreSQL-tsvector/GIN на Elasticsearch-проекцию (ADR-0059).
Назначение
Source of truth — TicketEntity в PostgreSQL; проекция хранит только
индексируемые/фильтруемые/сортируемые/возвращаемые в список поля. Поиск идёт через
Talent.Common.Domain.Search (ElasticsearchDocument, IElasticsearchIndexRepository<TicketSearchDocument>,
SearchQuery) — прямого клиента Elasticsearch в handlers/domain нет (ADR-0059).
Драйвер перехода — typo-tolerance (fuzzy), транслитерация кириллица↔латиница и морфология для тем/ФИО/номеров
(SUP-NNN). Индекс — только внутренний (back-office); публичного поиска тикетов нет (заявитель видит свои
обращения списком ../api/public/list-my-tickets.md, без Elasticsearch).
Index
| Поле | Значение |
|---|---|
| Index alias | support-tickets |
| Versioned index | support-tickets-v1 (переключение alias — IElasticsearchAliasManager, ADR-0029) |
| Owner service | support (Pin.Support) |
| Source of truth | TicketEntity (../domain/ticket.md) в PostgreSQL |
| Rebuild job | TicketSearchRebuildJob : ISearchRebuildJob |
Документы — в src/services/support/Infrastructure/Search/Documents/; наполнение — инкрементально
in-service проектором по доменным событиям TicketEntity + периодический/ручной rebuild (ADR-0059 п.2).
Document Id И Routing
- Document id:
<TenantId>:<TicketId>(TenantId = PIN CompanyId, единый tenant платформы, ADR-0052) — детерминирован, upsert идемпотентен. - Routing key:
QueueId— тикеты одной очереди колокализуются (кластер малый, ADR-0056). - Delete/tombstone: soft-delete/спам (
DeletedAt != null) → удаление документа из индекса (fail-closed — скрытый тикет не находится). СтатусClosedне удаляет документ (закрытые обращения ищутся в back-office), лишь меняет фильтруемое полеStatus.
Поля
| Поле | Тип | Indexed | Stored | Analyzer | Source | Security |
|---|---|---|---|---|---|---|
DocumentId | keyword | да | да | — | TenantId:TicketId | include |
TicketId | keyword | да | да | — | TicketEntity.Id | include |
TenantId | keyword | да | нет | — | TicketEntity.TenantId | scope-only (не выдаётся) |
Number | keyword + text | да | да | ru_translit_fuzzy (text-подполе) | TicketEntity.Number (SUP-NNN) | include |
Subject | text | да | да | ru_translit_fuzzy | TicketEntity.Subject | include |
RequesterName | text | да | да | ru_translit_fuzzy | TicketEntity.RequesterName (FullName-реплика) | PII (маска в логах; back-office only) |
RequesterPhone | keyword + text | да | да | ru_translit_fuzzy (text-подполе) | TicketEntity.RequesterPhone | PII (маска в логах; back-office only) |
SearchText | text | да | нет | ru_translit_fuzzy | Number ⊕ Subject ⊕ RequesterName ⊕ RequesterPhone (copy_to) | основной матч Q |
Status | keyword | да | да | — | TicketEntity.Status (TicketStatus) | фильтр |
Priority | keyword | да | да | — | TicketEntity.Priority (TicketPriority) | фильтр/сортировка |
Category | keyword | да | да | — | TicketEntity.Category (TicketCategory) | фильтр |
QueueId | keyword | да | да | — | TicketEntity.QueueId | routing/фильтр |
QueueName | keyword | нет | да | — | TicketQueueEntity.Name (денорм.) | include (без join на выдаче) |
AssigneeUserId | keyword | да | да | — | TicketEntity.AssigneeUserId | фильтр (me/user/unassigned = exists) |
RequesterUserId | keyword | да | да | — | TicketEntity.RequesterUserId | фильтр |
Channel | keyword | да | да | — | TicketEntity.Channel (TicketChannel) | include |
MessagesCount | integer | нет | да | — | TicketEntity.MessagesCount | include |
SlaBreached | boolean | да | да | — | TicketEntity.SlaBreached | фильтр |
LastMessageAt | date | да | да | — | TicketEntity.LastMessageAt | сортировка |
FirstResponseDueAt | date | да | да | — | TicketEntity.FirstResponseDueAt | сортировка (SLA) |
CreatedAt | date | да | да | — | TicketEntity.CreatedAt | фильтр-диапазон + сортировка (default) |
SchemaVersion | integer | да | да | — | ElasticsearchDocument | версия схемы проекции |
UpdatedAt | date | да | да | — | TicketEntity.UpdatedAt | freshness watermark |
IndexedAt | date | да | да | — | индексатор | freshness |
Внешний ввод (Q) идёт только параметром SearchQuery (multi-match по SearchText с fuzziness),
не в raw-DSL. Фасеты (Status/Priority/Category/QueueId/AssigneeUserId/RequesterUserId/
SlaBreached/диапазон CreatedAt) и сортировки (CreatedAt/LastMessageAt/Priority/FirstResponseDueAt)
— типизированными полями SearchQuery. Анализатор ru_translit_fuzzy — русская морфология +
ICU-транслитерация (кириллица↔латиница) + edge-ngram для typo-tolerance (ADR-0059 п.1).
Source Events
| Event | Version | Transform |
|---|---|---|
TicketCreated (IDomainEvent) | 1 | upsert документа из состояния TicketEntity (маппинг полей выше) |
TicketStatusChanged / TicketAssigned / TicketUpdated (IDomainEvent) | 1 | partial-upsert изменённых полей (Status/AssigneeUserId/Priority/Category/QueueId/QueueName) |
TicketMessageAdded (IDomainEvent) | 1 | обновление MessagesCount/LastMessageAt |
SLA-worker (SlaBreached/*DueAt) | 1 | partial-upsert SlaBreached/FirstResponseDueAt |
pin.identity.users.v1 (через SupportUserUpsertedConsumer) | 1 | обновление RequesterName/RequesterPhone (реплика заявителя) |
soft-delete/спам (DeletedAt) | 1 | delete документа по DocumentId |
Проекция наполняется in-service проектором TicketSearchProjector (реагирует на доменные события агрегата;
non-event-bearing мутации ассайна/приоритета/очереди/SLA покрыты доменными событиями выше), плюс
периодический/ручной TicketSearchRebuildJob для bootstrap и восстановления консистентности из PostgreSQL.
Отказоустойчивость индексации
- Транзиентный сбой записи в индекс — ретраи проектора; исчерпание попыток → карантин события с алертом,
без блокировки остальной проекции. Пропущенный документ дорисовывается ближайшим
TicketSearchRebuildJob(rebuild — safety net против расхождения с PostgreSQL). - Out-of-order обновления. Partial-upsert не откатывает более свежее состояние: watermark
UpdatedAtдокумента отражает версию тикета; запоздавшее событие с меньшимUpdatedAtне перетирает уже проиндексированные свежие поля. Реплика заявителя (RequesterName/RequesterPhone) обновляется тем же version-guard, что и в источнике (../domain/ticket.md). - Отставание проекции от source of truth — eventual (< 1 мин): оператор видит в поиске прежнее состояние до синхронизации; актуальные статус/переписку отдаёт карточка на PostgreSQL (Freshness ниже). Индекс — ускоритель поиска, не источник истины: расхождение всегда сходится rebuild-ом.
Freshness
- SLA:
search(near-real-time, < 1 мин от мутации до появления в поиске; eventual относительно PostgreSQL,FreshnessViewдля операторских выборок — ADR-0019). - Watermark:
IndexedAt+UpdatedAtтикета в документе. - Stale behavior: показать результаты с freshness-маркером; актуальный статус/переписку оператор видит в карточке ../api/support/get-ticket.md, читающей source-of-truth PostgreSQL.
Security
- Tenant/scope trimming выполняется через
SearchScopeFilterдо query execution (ADR-0059 п.3, ADR-0014):support.tickets.view@ platform-scope видит все тикеты платформы (TenantId = PIN CompanyId); record-level ограничения нет —AssigneeUserId(вкл.me) — фильтр, не scope. - PII fields:
RequesterName,RequesterPhone(индексируются для поиска по заявителю — матрица 4.5); индекс back-office-only, публичной выдачи тикетов нет, в логах — маска (+7***1234,И***). - Полнотекстовый индекс не хранит тела сообщений/внутренние заметки, secrets, tokens, платёжные/KYC-данные;
TenantId— только scope-фильтр, наружу не выдаётся.
Rebuild
- Создать версионный индекс
support-tickets-vN(маппинг +ru_translit_fuzzy). - Backfill
TicketSearchRebuildJobиз PostgreSQL:Filter(DeletedAt == null)поTicketEntityстримом (SearchAsStream), map вTicketSearchDocument(QueueName— joinsupport.ticket_queues). - Валидация counts/checksums против источника.
- Атомарно переключить alias
support-ticketsна новую версию (IElasticsearchAliasManager). - Держать предыдущий индекс до конца rollback-окна.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: TicketEntity
- API (10): GET /api/support/tickets — Список обращений (фильтры), PATCH /api/support/tickets/{id} — Приоритет/категория/очередь, POST /api/public/support/tickets — Подать обращение, POST /api/public/support/tickets/{id}/messages — Ответ заявителя, POST /api/support/tickets — Создать обращение от имени пользователя, POST /api/support/tickets/{id}/assign — Назначить/переназначить оператора, POST /api/support/tickets/{id}/escalate — Эскалация администратору, POST /api/support/tickets/{id}/messages — Ответ оператора / внутренняя заметка, POST /api/support/tickets/{id}/status — Сменить статус (решено/закрыто/реопен), POST /api/support/tickets/{id}/take — Взять тикет в работу
- События (1): События сервиса support: карта топиков и решения об интеграциях
- Консюмеры (1): Consumer: Support Identity User Sync
- index.md (1): Support Service
Связанные документы
- Сущность-источник: ../domain/ticket.md; потребитель поиска: ../api/support/list-tickets.md; реплика заявителя — ../consumers/identity-user-upserted.md.
- ADR: 0059 Elasticsearch-проекции, 0008 Elasticsearch, 0014 scope-filters, 0052 tenancy, 0056 simplicity-first.
- SDK:
Talent.Common.Domain.Search(ElasticsearchDocument,IElasticsearchIndexRepository,SearchQuery,ISearchRebuildJob,SearchScopeFilter); шаблон: search-document-template.md.