Entity (Replica): UserDisplayReplicaEntity
Дом полей display-реплики холдеров контекста wallet — справочник отображаемых имён получателей
вознаграждений (ФИО агента/партнёра, название агентства), чтобы read-path балансов рисовал человекочитаемые
подписи без синхронного запроса в identity. Проекции ответов API (HolderDisplayName в реестре счетов,
балансах агентов агентства, «моём кошельке») ссылаются сюда и перечисляют только дельту.
Назначение
UserDisplayReplicaEntity — read-only проекция учётной записи платформы (source of truth —
identity/domain/user.md; название компании — identity/domain/company.md)
в контекст wallet. Держит отображаемое имя холдера и, для агента, его текущее агентство (id + название),
чтобы кошелёк:
- подписывал счета именем холдера — ФИО агента/партнёра в реестре счетов оператора ПИН (../api/platform/list-wallet-accounts.md) и в балансах агентов руководителю агентства (../api/agency/list-agency-agent-balances.md);
- определял текущий состав агентства для выборки балансов агентов — ростер резолвится по реплике
(
CompanyId = агентство актора), а не синхронным вызовомidentityна read-path; - подписывал счёт агентства его названием (
CompanyName).
Реплика наполняется двумя событиями identity с монотонными version-guard-ами (алгоритм —
consumers/identity-users.md): pin.identity.users.v1 (ФИО + текущее
агентство) и pin.identity.companies.v1 (название агентства). Собственных данных сущность не владеет и
интеграционных событий не публикует (внутренняя реплика).
Классификация
| Поле | Значение |
|---|---|
| Контекст | wallet |
| Kind | Replica (проекция из identity; наполняется только по событиям, для операций кошелька read-only) |
| Source of truth | identity: ФИО — user.md; название агентства — company.md |
| Таблица | wallet.user_display_replicas |
| Tenant isolation | вне tenant-модели — справочник person-level (Id = UserId совпадает с identity.users.id, прямая адресация); read-path кошелька накладывает собственные record-level / scope-правила поверх (wallet-account.md) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
UserId | Guid | Да | PK (user_id) | identity (= users.id) | Ключ реплики и key события pin.identity.user-upserted. Совпадает с HolderUserId холдерских счетов. |
DisplayName | string | Да | varchar(384); допускается пустая строка при lazy-регистрации | identity snapshot (Name) | Отображаемое ФИО холдера, схлопнутое из FullNameSnapshot (INV-WLT-UDR-3). Единственная проекция ФИО (поиск/маскирование кошельку не нужны). |
CompanyId | Guid? | Нет | index ix_user_display_replicas_company (company_id) where company_id is not null | identity snapshot (CompanyId) | Текущее агентство агента (активная компания, ADR-0070); null — партнёр/инвестор/агент вне агентства. Ключ резолва ростера агентства. |
CompanyName | string? | Нет | varchar(256) | company snapshot (Name) | Название текущего агентства (денормализовано на строку агента для подписи счёта агентства без второго чтения). null — компания ещё не реплицирована / холдер вне агентства. |
IdentityUserVersion | long? | Нет | — | identity (user-upserted.Version) | Монотонный guard применённых snapshot-ов пользователя (INV-WLT-UDR-1); null — snapshot ещё не применялся. |
IdentityCompanyVersion | long? | Нет | — | identity (company-upserted.Version) | Монотонный guard применённого snapshot-а названия текущей компании (INV-WLT-UDR-2); null — название ещё не применялось. |
CreatedAt | DateTimeOffset | Да | — | consumer | Первое появление строки реплики. |
UpdatedAt | DateTimeOffset | Да | — | consumer | Момент последнего применённого snapshot-а (любого из двух). |
Value objects
VO не используются: ФИО хранится схлопнутой строкой DisplayName (кошелёк только отображает, не ищет и не
маскирует — в отличие от messenger/domain/messenger-user.md, где
ФИО разложено на лексемы под поиск/маску). Единый дом концепта «ФИО» остаётся в identity — здесь только
плоская проекция для подписи.
Модели JSONB
JSONB-модели не используются.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
UserEntity (identity) | replica source | UserId = identity.users.id (логическая) | по событию pin.identity.users.v1 | — (в identity) |
CompanyEntity (identity) | replica source | CompanyId = identity.companies.id (логическая) | по событию pin.identity.company-upserted.v1 | — (в identity) |
WalletAccountEntity (AgentCommission/ReferralCommission) | reference | HolderUserId → user_display_replicas.user_id (логическая) | при проекции HolderDisplayName счёта | реплика не удаляется |
WalletAccountEntity (AgencyCommission) | reference | подпись по CompanyName строк агентов агентства (CompanyId = HolderCompanyId) | при проекции HolderDisplayName счёта агентства | — |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
INV-WLT-UDR-1 | Snapshot пользователя применяется только при user-upserted.Version > IdentityUserVersion (last-write-wins по key = UserId; защита от out-of-order/replay) | version-guard консюмера до применения | skip + метрика wallet_identity_sync_stale_total{source="user"} |
INV-WLT-UDR-2 | Snapshot названия компании применяется к строке только при company-upserted.Version > IdentityCompanyVersion и CompanyId строки = компании события | version-guard консюмера до применения | skip + метрика wallet_identity_sync_stale_total{source="company"} |
INV-WLT-UDR-3 | DisplayName — детерминированное схлопывание FullNameSnapshot в «LastName FirstName MiddleName» (пустые части опускаются, лишние пробелы схлопываются); пустой snapshot (lazy-регистрация без ФИО) → пустая строка | проекция ФИО в консюмере | — |
INV-WLT-UDR-4 | При смене CompanyId (агент сменил агентство) CompanyName берётся из уже известной строки того же нового агентства; если ни одной нет — CompanyName = null, IdentityCompanyVersion = null до ближайшего company-upserted | обработка user-upserted | — |
INV-WLT-UDR-5 | Реплика read-only для операций кошелька — запись выполняет только консюмер identity; отсутствие строки на read-path не ошибка (подпись = null, клиент показывает id) | отсутствие операций записи вне консюмера | — |
Доменные и интеграционные события
Сущность не является агрегатом: доменных событий не поднимает, интеграционных Kafka-событий не публикует
(внутренняя реплика; downstream реагирует на события identity).
Потребляет (наполнение реплики):
| Событие | Топик | Когда | Эффект |
|---|---|---|---|
UserUpsertedKafkaEvent | pin.identity.users.v1 | регистрация/обновление публичных полей пользователя, смена агентства | upsert DisplayName + CompanyId с guard INV-WLT-UDR-1; CompanyName — по INV-WLT-UDR-4 |
CompanyUpsertedKafkaEvent | pin.identity.companies.v1 | создание/переименование компании | обновление CompanyName строк агентов этой компании с guard INV-WLT-UDR-2 |
Событие блокировки пользователя (identity/events/user-blocked.md) и
приостановки компании из тех же топиков игнорируются: блокировка на отображаемую подпись не влияет,
доступ отсекает identity (JWT), а фактический учёт балансов ведёт журнал.
Контракты событий — identity/events/user-upserted.md, identity/events/company-upserted.md; алгоритм консюмера — consumers/identity-users.md.
Окно консистентности. Реплика eventual: наполнение гарантирует at-least-once доставку с ключами
UserId/CompanyId и монотонными версиями. Только что зарегистрированный или переименованный холдер
(агентство) получает корректную подпись через секунды (при лаге консюмера — минуты). Пока snapshot не
применён, подпись = null (клиент показывает id) — это косметическая гонка на read-path, деньги и балансы
она не затрагивает (баланс ведёт журнал, независимо от реплики). Смена CompanyId тоже eventual: ростер
агентства сходится к текущему составу после применения user-upserted.
Индексы, хранение, безопасность
| Аспект | Значение |
|---|---|
| Индексы | PK btree (user_id); ix_user_display_replicas_company btree (company_id) where company_id is not null — резолв ростера агентства |
| Партиционирование / retention | none (справочник до ~1 млн пользователей, ADR-0056); строка живёт, пока жив identity-пользователь; блокировка/архивация в identity реплику не удаляет (подпись остаётся для исторических счетов) |
| Concurrency | оптимистической блокировки нет; порядок применения — раздельные монотонные guard-ы IdentityUserVersion (key UserId) и IdentityCompanyVersion (key CompanyId), last-write-wins |
| Permissions / PII | реплика читается только на read-path балансов (wallet.accounts.view-all, wallet.team.view); ФИО — класс PII, в логах маскируется (инициалы); топики identity — m2m ACL Kafka (доверенный consumer). PII не переживает admin purge в identity: анонимизированный snapshot затирает DisplayName (INV-WLT-UDR-1) |
| Audit | actor-штампов нет (реплика); аудит источника — в identity |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: WalletAccountEntity
- API (4): GET /api/v1/agency/wallet/agent-balances — Балансы агентов агентства, GET /api/v1/my/wallet — Мой кошелёк (балансы счетов), GET /api/v1/platform/wallet/accounts — Реестр счетов кошелька, Wallet API — реестр операций и общие контрактные модели
- Use Cases (1): UC-WLT-004. Холдер смотрит баланс и историю операций
- Консюмеры (1): Consumer: Wallet Identity Display Sync (wallet-identity-users)
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Wallet Service
Связанные документы
- Source of truth: identity/domain/user.md, identity/domain/company.md; контракты событий — identity/events/user-upserted.md, identity/events/company-upserted.md; консюмер — consumers/identity-users.md.
- Потребители реплики: ../api/platform/list-wallet-accounts.md, ../api/agency/list-agency-agent-balances.md, ../api/user/get-my-wallet.md, README контекста.
- Эталоны display-реплики: messenger/domain/messenger-user.md, calendar/consumers/identity-user-upserted.md.
- Tenancy — ADR-0052; envelope-scale — ADR-0056; активная компания — ADR-0070.