Consumer: Calendar Identity User Sync (calendar-identity-users)
Назначение
Консюмер поддерживает в контексте calendar display-реплику ФИО пользователей: имя агента на
публичной витрине свободных слотов, ФИО инвестора и агента в списках записей и в календаре команды —
без синхронного запроса в identity. Идентити — source of truth ФИО; календарь хранит копию только
отображаемого имени. Реплика ведётся по всем пользователям платформы (агент/консультант/инвестор —
любой может стать участником записи), обновляется last-write-wins по версии. Собственных интеграционных
событий консюмер не публикует.
Owning-сервис и триггер
| Поле | Значение |
|---|---|
| Сервис/контекст | calendar |
| Consumer | Calendar Identity User Sync |
| Обрабатываемое событие | pin.identity.user-upserted |
| Topic | pin.identity.users.v1 (общий с событием блокировки пользователя; key = UserId — порядок per-user; дом — user-upserted.md) |
| ContractVersion | 1; при несовпадении — warn-and-proceed (метрика calendar_identity_sync_contract_mismatch_total) |
| Consumer group | calendar-identity-users |
| Producer | identity (регистрация — в т.ч. lazy-регистрация анонима при самозаписи, обновление профиля, синк Talent, admin API) |
| Эффект (событие → что меняется) | Upsert строки display-реплики UserDisplayReplicaEntity |
Границы scope. Календарь хранит только ФИО. Телефон, Type, CompanyId, Status, RegisteredVia,
ReferrerUrl не читаются и не реплицируются (data minimization: спискам и витрине нужно только
отображаемое имя; контактные данные — забота identity/profile). Событие блокировки пользователя из
общего топика игнорируется: блокировка на display-имя не влияет, доступ заблокированного пользователя
отсекает identity (JWT). Права и permissions календарь тоже не реплицирует — они проверяются по токену.
Проекция UserDisplayReplicaEntity (calendar.user_display_replicas)
Consumer-owned проекция; дом полей — этот документ. Читатели — выборки витрины слотов (display-имя агента), списки записей и событий (ФИО инвестора/агента), календарь команды.
| Поле | Тип | Обязательность | Ограничения | Источник | Описание |
|---|---|---|---|---|---|
UserId | Guid | Да | PK | payload | Пользователь identity. |
FirstName | string | Да | varchar(128) | Name.FirstName | Имя. |
LastName | string | Да | varchar(128); пустая строка допустима (lazy-регистрация) | Name.LastName | Фамилия. |
MiddleName | string? | Нет | varchar(128) | Name.MiddleName | Отчество. |
IdentityUserVersion | long | Да | монотонный guard | Version | Последняя применённая доменная версия identity (business-идемпотентность). |
CreatedAt | DateTimeOffset | Да | — | consumer | Создание строки реплики. |
UpdatedAt | DateTimeOffset | Да | — | consumer | Последнее применённое событие. |
Входные данные / payload события
Контракт — дом user-upserted.md (payload
UserUpsertedKafkaEvent + вложенная FullNameSnapshot). Ниже — только поля, которые консюмер реально
читает.
| Поле payload | Тип | Обязательность | Как используется (маппинг) |
|---|---|---|---|
UserId | Guid | Да | Ключ upsert-а строки реплики. |
Name | FullNameSnapshot | Да | FirstName/LastName/MiddleName поэлементно (LastName допускает пустую строку). |
Version | long | Да | Монотонный guard → IdentityUserVersion. |
Алгоритм
- Контекст события. Восстановить метаданные (идентификатор сообщения,
UserId, время, версия контракта); при несовпадении версии — warn-and-proceed. Идемпотентность по ключуcalendar-identity-users:{MessageId}: уже обработано → пропустить как дубль (ack). - Проверка входа.
UserIdзадан;Name.FirstNameнепустое (≤ 128; при превышении — truncate + warn, событие не теряется);Version ≥ 1. Нарушение →CAL_IDENTITY_SYNC_INVALID_PAYLOAD(non-retriable → DLQ + алерт). - Version-guard. Строка реплики существует и
Version ≤ IdentityUserVersion→ устаревшее/дубль: логskipped-stale, метрика, ack. - Upsert. First-seen
UserId→ строка создаётся; известный → display-поля замещаются целиком (полный snapshot, не мёрж),IdentityUserVersion←Version. Анонимизированный snapshot (admin purge в identity) применяется тем же путём — PII-очистка распространяется на реплику. - Согласованность. Upsert и отметка идемпотентности — в одном согласованном изменении; offset подтверждается после фиксации.
- Результат. Успех → ack (включая
skipped-stale); retriable-ошибка → retry по политике; non-retriable → DLQ + алерт. Freshness: реплика eventually consistent, SLA < 1 мин (README, SLO лага consumer-ов).
Идемпотентность и replay
- На уровне консюмера: ключ
calendar-identity-users:{MessageId}— повторная доставка не меняет состояние. - На бизнес-уровне: монотонный
IdentityUserVersion-guard — защита от out-of-order (после retry) и replay (last-write-wins по key =UserId). - Replay: да. Полный повторный проход топика с offset 0 безопасен: per-key порядок + version-guard
сходятся к последнему snapshot-у. Runbook — «пересинхронизация display-реплики пользователей календаря»
(
docs/06-services/calendar/runbooks/, будущий путь по конвенции).
Retry и DLQ
| Сбой | Код | Retry | DLQ | Ручное действие |
|---|---|---|---|---|
| Инфраструктурный (PostgreSQL/Kafka недоступны, timeout) | — (exception) | Бесконечный exponential backoff 1s→60s с паузой партиции | Нет | Runbook: восстановить зависимость |
| Отсутствуют обязательные поля / нераспознаваемый payload | CAL_IDENTITY_SYNC_INVALID_PAYLOAD | Нет | Да + алерт | Исправление данных в identity → повторное событие |
Конкурентная правка строки (два события одного пользователя на разных партициях невозможны — key = UserId; конфликт только при replay параллельно с live) | — | 3 попытки немедленно (перечитать строку) | После исчерпания | Расследование |
| ContractVersion mismatch | — | warn-and-proceed | Нет | Проверить версию контракта producer-а |
Наблюдаемость и безопасность
- Logs (structured):
consumed/skipped-duplicate/skipped-stale/upserted/failed— сMessageId,UserId,payload.Version. ФИО в логах маскируется (инициалы), телефон в payload есть, но консюмером не читается и в логи не попадает. - Metrics:
calendar_identity_sync_consume_duration_ms,calendar_identity_sync_upserted_total,calendar_identity_sync_stale_total,calendar_identity_sync_dlq_total,calendar_identity_sync_contract_mismatch_total,calendar_identity_sync_lag_seconds. - Traces: span приёма → span обработки → span записи; корреляция из метаданных события.
- Alerts: лаг консюмера > 5 мин; DLQ > 0.
- Безопасность: system-actor; реплика — platform-scope справочник без tenant-принадлежности (ФИО пользователя не является tenant-данными; выборки календаря применяют собственные tenant/record-level правила поверх). Топик доступен по m2m ACL Kafka (доверенный consumer). Наружу (публичная витрина слотов) отдаётся только имя агента-владельца опубликованных слотов; ФИО инвесторов в публичных ответах не отдаются (availability-slot.md, «Permissions / PII»).
Acceptance Criteria
- AC-1 (first-seen). Дано: реплики пользователя нет. Когда:
user-upserted(Version = 1, регистрация). Тогда: строка создана с ФИО иIdentityUserVersion = 1, ack. - AC-2 (обновление). Дано: строка с
IdentityUserVersion = 3. Когда: событие сVersion = 5и новым ФИО. Тогда: display-поля замещены целиком,IdentityUserVersion = 5. - AC-3 (устаревшая версия). Дано: строка с
IdentityUserVersion = 5. Когда: событие сVersion ≤ 5. Тогда:skipped-stale, строка без изменений, ack. - AC-4 (анонимизация). Дано: admin purge в identity опубликовал анонимизированный snapshot с наибольшей версией. Когда: обработка. Тогда: ФИО в реплике замещено анонимизированным значением — PII в календаре не переживает удаление в identity.
- AC-5 (дубль). Дано: уже обработанный
MessageId. Когда: повторная доставка. Тогда:skipped-duplicate, без изменений, ack. - AC-6 (игнор события блокировки). Дано: в общем топике приходит событие блокировки пользователя. Когда: доставка. Тогда: консюмер его игнорирует, реплика не меняется.
- AC-7 (невалидный payload). Дано: событие без
Name.FirstName. Когда: обработка. Тогда:CAL_IDENTITY_SYNC_INVALID_PAYLOAD→ DLQ + алерт, остальные пользователи не блокируются.
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity (Replica): UserDisplayReplicaEntity
- События (6): 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)
- Консюмеры (3): Consumer: Calendar CRM Booking Sync (calendar-crm-booking-events), Consumer: Calendar CRM Deal Sync (calendar-crm-deal-events), Consumer: Wallet Identity Display Sync (wallet-identity-users)
- index.md (1): Calendar Service
- registries (1): Реестр сущностей PIN
Связанные документы
- Контракт входного события (дом): user-upserted.md.
- Читатели реплики: availability-slot.md (публичная витрина),
appointment.md (списки записей),
calendar-event.md (display
ClientUserId). - Эталон display-реплики: crm/consumers/identity-user-upserted.md.
- Шаблон consumer: consumer-template.md; README контекста calendar; ADR-0052, ADR-0056.