Entity (Replica): MessengerUserEntity
Дом полей реплики identity (ФИО/телефон) контекста messenger — справочник пользователей для
глобального поиска контактов по телефону, валидации адресата и display карточки контакта. Все проекции
(результат поиска, маскированная/полная карточка профиля контакта, display участника чата) ссылаются
сюда и перечисляют только дельту, а не переописывают поля.
Назначение
MessengerUserEntity — read-only проекция учётной записи платформы (source of truth —
identity/domain/user.md) в контекст messenger. Держит ФИО и нормализованный
телефон, чтобы мессенджер работал без синхронного запроса в identity:
- глобальный поиск контакта по телефону — «Имя Отчество + первая буква Фамилии» до подтверждения
контакта, операция
search-users-by-phone; - валидация адресата заявки в контакты / личного сообщения (адресат существует и
IsActive); - display контактов — полное ФИО + телефон раскрываются обеим сторонам только при
Accepted(contact.mdINV-CT-5), до этого фамилия маскируется.
Реплика наполняется по событию pin.identity.users.v1 с монотонным version-guard (алгоритм —
consumers/identity-user-upserted.md; контракт события —
identity/events/user-upserted.md). Собственных данных сущность не
владеет и интеграционных событий не публикует (внутренняя реплика).
Классификация
| Поле | Значение |
|---|---|
| Контекст | messenger |
| Kind | Replica (проекция из identity; наполняется только по событию, для пользовательских операций read-only) |
| Source of truth | identity (user.md) — owner ФИО/телефона |
| Таблица | messenger.users |
| Tenant isolation | нет — справочник person-level (глобальный поиск сквозной, ADR-0052). Id = UserId совпадает с identity.users.id (прямая адресация). |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
UserId | Guid | Да | PK (user_id) | identity (= users.id) | Ключ реплики и key события identity. |
FirstName | string | Да | varchar(128), не пустое | identity snapshot | Имя (в поиске/маске отдаётся полностью). |
MiddleName | string? | Нет | varchar(128) | identity snapshot | Отчество (в поиске/маске — полностью, если есть). |
LastName | string | Да | varchar(128), допускается пустая строка при lazy-регистрации | identity snapshot | Фамилия. В поиске до Accepted — только первая буква (INV-MU-3). |
PhoneNumber | string | Да | varchar(32), E.164; unique ux_users_phone (phone_number) | identity snapshot | Ключ поиска контактов и display; нормализуется (INV-MU-2). |
IdentityUserVersion | long? | Нет | — | identity (payload.Version) | Монотонный guard применённых snapshot-ов (INV-MU-1); null — snapshot ещё не применялся. |
IsActive | bool | Да | default true | identity snapshot | false, когда identity-статус Blocked/Archived — адресат недоступен для новых заявок/сообщений. |
CreatedAt | DateTimeOffset | Да | — | consumer | Первое появление реплики. |
UpdatedAt | DateTimeOffset | Да | — | consumer | Момент последнего применённого snapshot-а. |
Value objects
ФИО зеркалит VO FullName из identity/domain/user.md, но в реплике хранится
плоскими колонками FirstName/MiddleName/LastName — так работают проекция ФИО в Elasticsearch
(по отдельным лексемам, search/contacts.md) и маскирование по LastName[0]. Единый
дом концепта «ФИО» остаётся в identity — здесь только проекция.
FTS-поиск по ФИО/телефону (операция list-contacts) вынесен в
Elasticsearch-проекцию MessengerContactSearchDocument (search/contacts.md,
ADR-0059): в самой реплике полнотекстового индекса нет, PostgreSQL остаётся source of truth проекции.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
UserEntity (identity) | replica source | UserId = identity.users.id (логическая) | по событию pin.identity.users.v1 | — (в identity) |
ContactEntity | reference (обе стороны пары) | RequesterUserId/AddresseeUserId → users.user_id (логическая) | при display карточки контакта | реплика не удаляется (IsActive=false) |
ParticipantEntity | reference | participant.user_id → users.user_id (логическая) | display участника чата | — |
| Адресат заявки/сообщения | validation | UserId | guard IsActive при создании заявки/сообщения | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
INV-MU-1 | Snapshot применяется только при payload.Version > IdentityUserVersion (last-write-wins по key = UserId; защита от out-of-order / replay) | version-guard консюмера до применения | skip + метрика messenger_identity_stale_events_total |
INV-MU-2 | PhoneNumber нормализуется к E.164 и уникален (ux_users_phone); конфликт — лог + тикет заботы, реплика не мутируется | нормализация в консюмере + unique-индекс | MSG_USER_PHONE_CONFLICT |
INV-MU-3 | Поиск по телефону отдаёт маску фамилии (FirstName MiddleName LastName[0]+'.'), пока запрашивающий не в Accepted-контакте (или общем чате); при снятии маски раскрывается полное ФИО + телефон (INV-CT-5) | проекция результата поиска / карточки профиля | — |
INV-MU-4 | Реплика read-only для пользовательских операций — запись выполняет только консюмер identity | отсутствие операций записи; доступны только чтение/поиск | — |
Доменные и интеграционные события
Сущность не является агрегатом: доменных событий не поднимает, интеграционных Kafka-событий не публикует
(внутренняя реплика; downstream реагирует на события identity).
Потребляет (наполнение реплики):
| Событие | Топик | Когда | Эффект |
|---|---|---|---|
UserUpsertedKafkaEvent | pin.identity.users.v1 | регистрация/обновление публичных полей пользователя | upsert реплики с guard INV-MU-1; тот же коммит реиндексирует search-документ контактов (Elasticsearch, search/contacts.md) |
Контракт события — identity/events/user-upserted.md; алгоритм консюмера — consumers/identity-user-upserted.md.
Окно консистентности и ожидания от identity. Реплика eventual: наполнение гарантирует at-least-once
доставку user-upserted с ключом UserId, монотонным payload.Version и нормализованным E.164-телефоном.
Только что зарегистрированный или переименованный пользователь становится находимым по телефону и адресуемым
(заявка/сообщение) лишь после применения snapshot-а — обычно секунды, при лаге консюмера до минут. Пока snapshot
не применён, операции контакта видят пользователя как несуществующего (MSG_CONTACT_USER_NOT_FOUND,
contact.md INV-CT-6) — допустимая гонка, сходится после доставки. Смена IsActive тоже
eventual: заявка/сообщение, начатые до применения блокировки в identity, могут пройти по ещё-активной реплике —
guard IsActive есть точка на момент операции, а не распределённая транзакция.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK btree (user_id); ux_users_phone unique btree (phone_number). FTS по ФИО/телефону — Elasticsearch-проекция search/contacts.md (в PostgreSQL полнотекстового индекса нет, ADR-0059) |
| Партиционирование / retention | none (справочник до ~1 млн пользователей, ADR-0056); строка живёт, пока жив identity-пользователь; архивация/блокировка в identity → IsActive=false (не hard-delete) |
| Concurrency | оптимистической блокировки нет; порядок применения — монотонный IdentityUserVersion + key = UserId (last-write-wins, INV-MU-1) |
| Permissions / PII | messenger.contacts.search (поиск по телефону). PII: ФИО/телефон — класс PII; поиск отдаёт маску фамилии до Accepted (INV-MU-3); топик identity — m2m ACL Kafka (доверенные consumer-ы); в логах маскируется (+7***1234 / И***) |
| Audit | actor-штампов нет (реплика); аудит источника — в identity (user.md) |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (3): Entity (Replica): UserDisplayReplicaEntity, Entity / Aggregate: CallSessionEntity — сигналинг и лог звонка (media отложен), Entity / Aggregate: ContactEntity
- API (13): GET /api/messenger/admin/chats/{chatId}/transcript — Выгрузка переписки для арбитража, GET /api/messenger/blacklist — Мой чёрный список, GET /api/messenger/calls — Журнал звонков (входящие / исходящие / пропущенные), GET /api/messenger/chats — Список чатов пользователя, GET /api/messenger/chats/{chatId} — Карточка чата (участники + read-state), GET /api/messenger/contacts — Список подтверждённых контактов, GET /api/messenger/contacts/requests — Заявки в контакты (входящие/исходящие), GET /api/messenger/contacts/search — Глобальный поиск пользователя по телефону, POST /api/messenger/blacklist — Заблокировать пользователя, POST /api/messenger/calls — Начать звонок, POST /api/messenger/chats/direct — Открыть/создать личный 1:1 чат, POST /api/messenger/contacts/requests — Отправить заявку в контакты (+1)
- Use Cases (4): UC-MSG-001. Открыть личный чат по поиску пользователя по телефону, UC-MSG-002. Авто-провизия чата сделки по событию CRM, UC-MSG-004. Заблокировать пользователя (чёрный список), UC-MSG-008. Добавленный участник появляется в чате сделки/лида
- События (2): Event Contract: pin.messenger.contact-request-accepted (ContactRequestAcceptedEvent), Event Contract: pin.messenger.contact-request-created (ContactRequestCreatedEvent)
- Консюмеры (8): Consumer: Booking Expired Card (MessengerBookingExpiredConsumer), Consumer: Booking Paid Card (MessengerBookingPaidConsumer), Consumer: Deal Chat Members Sync (MessengerDealMembersChangedConsumer), Consumer: Deal Chat Provisioning (MessengerDealCreatedConsumer), Consumer: Document Request Card (MessengerDocumentRequestedConsumer), Consumer: Lead Chat Participants Sync (MessengerLeadParticipantsChangedConsumer), Consumer: Messenger User Replica Sync (MessengerUserUpsertedConsumer), Consumer: Wallet Identity Display Sync (wallet-identity-users)
- Поиск (1): MessengerContactSearchDocument
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
Связанные документы
- Source of truth: identity/domain/user.md; контракт события — identity/events/user-upserted.md; консюмер — consumers/identity-user-upserted.md.
- Потребители реплики: contact.md (
INV-CT-5, раскрытие профиля), participant.md (display участника), README контекста. - Search-проекция: search/contacts.md (
MessengerContactSearchDocument, FTS по ФИО/телефону через Elasticsearch, ADR-0059). - Tenancy — ADR-0052; envelope-scale — ADR-0056; поиск — ADR-0059.