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

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 aliassupport-tickets
Versioned indexsupport-tickets-v1 (переключение alias — IElasticsearchAliasManager, ADR-0029)
Owner servicesupport (Pin.Support)
Source of truthTicketEntity (../domain/ticket.md) в PostgreSQL
Rebuild jobTicketSearchRebuildJob : 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.

Поля

ПолеТипIndexedStoredAnalyzerSourceSecurity
DocumentIdkeywordдадаTenantId:TicketIdinclude
TicketIdkeywordдадаTicketEntity.Idinclude
TenantIdkeywordданетTicketEntity.TenantIdscope-only (не выдаётся)
Numberkeyword + textдадаru_translit_fuzzy (text-подполе)TicketEntity.Number (SUP-NNN)include
Subjecttextдадаru_translit_fuzzyTicketEntity.Subjectinclude
RequesterNametextдадаru_translit_fuzzyTicketEntity.RequesterName (FullName-реплика)PII (маска в логах; back-office only)
RequesterPhonekeyword + textдадаru_translit_fuzzy (text-подполе)TicketEntity.RequesterPhonePII (маска в логах; back-office only)
SearchTexttextданетru_translit_fuzzyNumber ⊕ Subject ⊕ RequesterName ⊕ RequesterPhone (copy_to)основной матч Q
StatuskeywordдадаTicketEntity.Status (TicketStatus)фильтр
PrioritykeywordдадаTicketEntity.Priority (TicketPriority)фильтр/сортировка
CategorykeywordдадаTicketEntity.Category (TicketCategory)фильтр
QueueIdkeywordдадаTicketEntity.QueueIdrouting/фильтр
QueueNamekeywordнетдаTicketQueueEntity.Name (денорм.)include (без join на выдаче)
AssigneeUserIdkeywordдадаTicketEntity.AssigneeUserIdфильтр (me/user/unassigned = exists)
RequesterUserIdkeywordдадаTicketEntity.RequesterUserIdфильтр
ChannelkeywordдадаTicketEntity.Channel (TicketChannel)include
MessagesCountintegerнетдаTicketEntity.MessagesCountinclude
SlaBreachedbooleanдадаTicketEntity.SlaBreachedфильтр
LastMessageAtdateдадаTicketEntity.LastMessageAtсортировка
FirstResponseDueAtdateдадаTicketEntity.FirstResponseDueAtсортировка (SLA)
CreatedAtdateдадаTicketEntity.CreatedAtфильтр-диапазон + сортировка (default)
SchemaVersionintegerдадаElasticsearchDocumentверсия схемы проекции
UpdatedAtdateдадаTicketEntity.UpdatedAtfreshness watermark
IndexedAtdateдадаиндексатор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

EventVersionTransform
TicketCreated (IDomainEvent)1upsert документа из состояния TicketEntity (маппинг полей выше)
TicketStatusChanged / TicketAssigned / TicketUpdated (IDomainEvent)1partial-upsert изменённых полей (Status/AssigneeUserId/Priority/Category/QueueId/QueueName)
TicketMessageAdded (IDomainEvent)1обновление MessagesCount/LastMessageAt
SLA-worker (SlaBreached/*DueAt)1partial-upsert SlaBreached/FirstResponseDueAt
pin.identity.users.v1 (через SupportUserUpsertedConsumer)1обновление RequesterName/RequesterPhone (реплика заявителя)
soft-delete/спам (DeletedAt)1delete документа по 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

  1. Создать версионный индекс support-tickets-vN (маппинг + ru_translit_fuzzy).
  2. Backfill TicketSearchRebuildJob из PostgreSQL: Filter(DeletedAt == null) по TicketEntity стримом (SearchAsStream), map в TicketSearchDocument (QueueName — join support.ticket_queues).
  3. Валидация counts/checksums против источника.
  4. Атомарно переключить alias support-tickets на новую версию (IElasticsearchAliasManager).
  5. Держать предыдущий индекс до конца rollback-окна.

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

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

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