Consumer: Support Identity User Sync
Назначение
Consumer «Support Identity User Sync» слушает событие pin.identity.user-upserted и поддерживает в контексте
support display-реплику ФИО и телефона заявителя на карточках обращений (TicketEntity) и арбитражных
дел (ArbitrageCaseEntity), чтобы списки/карточки и поиск тикетов (Elasticsearch-проекция support-tickets
по номеру/теме/ФИО/телефону — ../search/tickets.md) работали без синхронного запроса
в identity. Один identity-пользователь может быть заявителем нескольких тикетов и/или дел — консюмер
обновляет все карточки, где RequesterUserId = payload.UserId (тикеты) и ComplainantUserId = payload.UserId (дела), fan-out в едином tenant платформы. Интеграционных событий консюмер не публикует
(внутренние данные support, ADR-0056).
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | support |
| Consumer | Support Identity User Sync |
| Обрабатываемое событие | pin.identity.user-upserted (UserUpsertedKafkaEvent) |
| Topic | pin.identity.users.v1 (общий с UserBlockedKafkaEvent; key = UserId, 6 партиций — дом user-upserted.md) |
| ContractVersion | ожидается 1; при несовпадении версии — warn-and-proceed (метрика support_identity_sync_contract_mismatch_total) |
| Consumer group | support-identity-user-sync |
| Producer | identity (регистрация / updateInvestor / confirmPhoneChange / синк Talent / admin-API) |
| Эффект (событие → что меняется) | обновляется display-реплика ФИО/телефона заявителя: RequesterName/RequesterPhone на тикетах и ComplainantName/ComplainantPhone на арбитражных делах (+ переиндексация тикетов в support-tickets) |
Границы scope. Топик общий с UserBlockedKafkaEvent, но display заявителя хранит только ФИО/телефон —
статус/блокировка на карточку тикета не влияют (ticket.md/arbitrage-case.md не моделируют UserStatus
заявителя), поэтому UserBlockedKafkaEvent этот consumer игнорирует. Email не синхронизируется:
pin.identity.users.v1 email в payload не несёт (user-upserted.md — «Email не входит»);
RequesterEmail/RequesterName.email фиксируются при создании тикета из ввода заявителя и этим консюмером не
обновляются (задокументированное расхождение дома ticket.md, поле RequesterEmail).
Контекст события
- Из метаданных события доступны:
MessageId(Guid v7),CreatedAt,Version(=ContractVersionproducer-а),AggregateId = UserId,AggregateType = "User". - Актор — system (не пользовательский контекст); scope — единый tenant платформы (
supportsingle-tenant,TenantId = PIN CompanyId, ADR-0052), fan-out идёт в пределах одного tenant (кросс-tenant записи нет). - Корреляция сквозного трейса восстанавливается из метаданных события.
- Идемпотентность: consumer-level — ключ
support-identity-user-upserted:{MessageId}; business-level — version-guardpayload.Versionпротив сохранённой версии реплики на каждой карточке (IdentityRequesterVersion/IdentityComplainantVersion, дома ticket.md/arbitrage-case.md).
Входные данные / payload события
Контракт — дом event-contract: pin.identity.user-upserted
(payload UserUpsertedKafkaEvent + вложенная FullNameSnapshot). Ниже — только поля, которые consumer
реально читает, и их маппинг.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid | Да | Ключ fan-out: карточки заявителя — тикеты по RequesterUserId + дела по ComplainantUserId |
Name | FullNameSnapshot | Да | Перезапись display-ФИО RequesterName/ComplainantName: FirstName/LastName/MiddleName поэлементно (LastName допускает пустую строку) |
PhoneNumber | string | Да | Нормализация E.164 → перезапись RequesterPhone/ComplainantPhone |
Version | long | Да | Монотонный guard → IdentityRequesterVersion/IdentityComplainantVersion (business-идемпотентность) |
Поля Type, CompanyId, Status, RegisteredVia, ReferrerUrl consumer не читает (на display
заявителя не влияют); Email в payload отсутствует (см. «Границы scope»).
Алгоритм
- Контекст события. Восстановить метаданные (
MessageId,AggregateId = UserId, время, версия); сверить версию сContractVersion = 1— при несовпадении warn-and-proceed (метрикаsupport_identity_sync_contract_mismatch_total). Актор — system, единый tenant платформы. Проверить consumer-идемпотентность по ключуsupport-identity-user-upserted:{MessageId}— событие уже обработано → пропустить как дубль (ack, no-op), логskipped-duplicate. - Проверка входа.
UserIdзадан;Name.FirstNameнепустое (≤ 128, при превышении truncate + warn — событие не теряется);PhoneNumberнепустой;Version >= 1. НормализацияPhoneNumberв E.164 — невалидный номер (аномалия producer-а, гарантирующего E.164) →SUPPORT_IDENTITY_SYNC_INVALID_PHONE(non-retriable → DLQ + алерт). Проверка существования by-id не выполняется — это upsert-проекция, пустой набор карточек допустим (шаг 3). - Что читаем (fan-out по двум агрегатам). В едином tenant платформы найти все карточки заявителя:
тикеты, где
RequesterUserId = payload.UserIdи не скрыты (индексix_tickets_requester_user_id), и арбитражные дела, гдеComplainantUserId = payload.UserIdи не скрыты (индексix_arbitrage_complainant_user_id). Оба набора малы (десятки строк). Оба пусты → пользователь ещё не заявитель ни обращения, ни дела → логno-match, только отметка идемпотентности, ack. - Бизнес-правила и переходы (по каждой карточке из обоих наборов).
- Version-guard: если
payload.Version <= card.IdentityRequesterVersion(илиcard.IdentityComplainantVersion) → устаревшее/дубль: метрикаsupport_identity_sync_stale_total, логskipped-stale, карточка пропускается. - Маппинг display-реплики: ФИО из
payload.Name(FirstName/LastName/MiddleName), телефон — нормализованный E.164 изpayload.PhoneNumber, версия → guard-поле. Snapshot-семантика: перезаписываются только display-поля (RequesterName/RequesterPhoneдля тикета,ComplainantName/ComplainantPhoneдля дела); статусы/очередь/assignee/решение не трогаются. Для тикетов обновление реплики переиндексирует документsupport-tickets(../search/tickets.md); арбитраж Elasticsearch-индекса не имеет (ILIKEпоNumber/Title). Карточка без изменений (guard/no-op) исключается из записи.
- Version-guard: если
- Что меняется. Обновлённые тикеты и дела + отметка идемпотентности фиксируются в одном согласованном изменении; смещение (offset) подтверждается только после успешной фиксации. Гонка с командой оператора/арбитра разводится оптимистической блокировкой → повтор с перечитыванием (см. «Retry и DLQ»).
- Результат. Успех → ack (в т.ч.
no-match/skipped-*). Retriable-код → retry по политике; non-retriable → DLQ + алерт. Freshness: display-реплика eventually consistent, SLA < 5 мин (README контекста, «Alerts»: лаг > 5 мин).
Диаграмма
Идемпотентность и replay
- Consumer-level: по
MessageId(support-identity-user-upserted:{MessageId}) — повторная доставка after-commit-crash не мутирует состояние. - Business-level: version-guard
payload.VersionпротивIdentityRequesterVersion/IdentityComplainantVersionпо каждой карточке — защита от out-of-order и от replay целого топика (last-write-wins по key=UserId). - Replay: да. Полный re-read топика с offset 0 безопасен: per-key (
UserId) порядок + version-guard дают сходимость к последнему snapshot-у. Runbook — «пересинхронизация display-реплики заявителей» (docs/06-services/support/runbooks/, будущий путь).
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
| Concurrency (конфликт версии) — гонка с командой оператора/арбитра | — | 3 попытки немедленно (перечитать карточку) | После исчерпания | Расследование гонки |
| Невалидный телефон (аномалия producer-а) | SUPPORT_IDENTITY_SYNC_INVALID_PHONE | Нет | Да + алерт | Исправление данных в identity → повторное событие |
| ContractVersion mismatch | — | warn-and-proceed | Нет | Проверить версию контракта producer-а |
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-stale/no-match/updated(tickets+cases count) /failedc полямиMessageId,UserId,payload.Version, затронутыхTicketId/CaseId. PII — маскирование: телефон+7***1234, ФИОИ***. - Metrics:
support_identity_sync_consume_duration_ms,support_identity_sync_updated_tickets_total,support_identity_sync_updated_cases_total,support_identity_sync_stale_total,support_identity_sync_dlq_total,support_identity_sync_contract_mismatch_total,support_identity_sync_lag_seconds(now − время события). - Traces: span приёма события → span обработки → spans обращений к хранилищу; correlation из метаданных события.
- Alerts: лаг консюмера > 5 мин (README контекста, «Alerts»); DLQ > 0.
- Dashboard / Runbook: Grafana
pin-support; runbooksdocs/06-services/support/runbooks/(будущий путь). - Безопасность: system-актор; единый tenant платформы (кросс-tenant записи нет, ADR-0052);
PhoneNumber/Name— PII, топик доступен по m2m ACL Kafka (доверенный consumer), в логах маскируются; каждая карточка несёт собственныйTenantId = PIN CompanyId.
Acceptance Criteria (Given / When / Then)
| # | Given | When | Then |
|---|---|---|---|
| AC1 | Пользователь — заявитель N тикетов и M арбитражных дел; payload.Version больше сохранённого на карточках | доставлен UserUpsertedKafkaEvent | Все N тикетов и M дел получают новые RequesterName/RequesterPhone и ComplainantName/ComplainantPhone; guard-версии обновлены; тикеты переиндексируются в support-tickets; ack после фиксации |
| AC2 | На карточке уже стоит версия >= payload.Version | доставка события | Карточка пропускается (skipped-stale, метрика support_identity_sync_stale_total); last-write-wins по UserId |
| AC3 | UserId ещё не заявитель ни тикета, ни дела | доставка события | no-match: только отметка идемпотентности, ack; мутаций нет |
| AC4 | Событие с тем же MessageId уже обработано (after-commit-crash / повтор) | повторная доставка | skipped-duplicate (consumer-level ключ), состояние не мутируется, ack |
| AC5 | PhoneNumber не приводится к E.164 (аномалия producer-а) | доставка события | SUPPORT_IDENTITY_SYNC_INVALID_PHONE → non-retriable → DLQ + алерт; исправление данных в identity → повторное событие |
| AC6 | ContractVersion события != 1 | доставка события | warn-and-proceed (метрика support_identity_sync_contract_mismatch_total), обработка продолжается |
| AC7 | Гонка с командой оператора/арбитра (карточка изменена между выборкой и записью) | конкурентная фиксация | Конфликт версии → 3 немедленных ретрая с перечиткой; после исчерпания — DLQ + расследование; ложной перезаписи чужих полей нет |
| AC8 | Полный re-read топика с offset 0 (replay) | пересинхронизация | Сходимость к последнему snapshot-у на каждый UserId (per-key порядок + version-guard); повторных мутаций уже актуальных карточек нет |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (2): Entity / Aggregate: ArbitrageCaseEntity (+ ArbitrageNoteEntity), Entity / Aggregate: TicketEntity
- API (4): GET /api/support/arbitrage-cases/{id} — Карточка арбитражного дела, GET /api/support/tickets/{id} — Карточка обращения (GetTicket), POST /api/public/support/tickets — Подать обращение, POST /api/support/arbitrage-cases — Регистрация арбитражного дела
- Use Cases (1): UC-SUP-001. Приём обращения и назначение оператора
- События (7): События сервиса support: карта топиков и решения об интеграциях, Event Contract: pin.crm.lead-created (LeadCreatedEvent), Event Contract: pin.identity.user-blocked (UserBlockedKafkaEvent), Event Contract: pin.identity.user-upserted (UserUpsertedKafkaEvent), Event Contract: pin.support.ticket-created (TicketCreatedKafkaEvent), Event Contract: pin.support.ticket-message-posted (TicketMessagePostedKafkaEvent), Event Contract: pin.support.ticket-status-changed (TicketStatusChangedKafkaEvent)
- Поиск (1): TicketSearchDocument
- index.md (1): Support Service
Связанные документы
- Целевые агрегаты (дома): domain/ticket.md (
RequesterName,RequesterPhone,RequesterUserId,IdentityRequesterVersion; поиск — ../search/tickets.md), domain/arbitrage-case.md (ComplainantName,ComplainantPhone,ComplainantUserId,IdentityComplainantVersion). - Контракт входного события (дом): identity/events/user-upserted.md; карта событий контекста: ../events/README.md; соседний консюмер — ../../crm/consumers/identity-user-upserted.md.
- README контекста support; tenancy — ADR-0052, ADR-0056.