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

Entity / Aggregate: UserEntity

Дом полей агрегата «Пользователь» контекста identity. Все проекции (модели ответов API, payload событий, реплики UserId в других контекстах) ссылаются сюда.

Назначение

UserEntity — единая учётная запись платформы PIN: инвестор без родительской компании, менеджер застройщика, агент/менеджер агентства (брокера, партнёра) или сотрудник ПИН (Менеджер ПИН, Служба заботы). Надстройка над UserEntity из talent.microservices.identity, но без жёсткой связи «User per Tenant» — принадлежность компании выражается nullable CompanyId.

Учётная запись поддерживает СМС-вход, 2FA, пароль и пин-код; производные флаги isTwoFactorEnabled/hasPassword/hasPinCode и контактные поля (name, phoneNumber, email, createdAt) отдаются в карточке текущего пользователя.

Классификация

ПолеЗначение
Контекстidentity
KindAggregateRoot (для менеджеров застройщика — гибрид с Replica по профильным полям)
Source of truthidentity (инвесторы, агенты, сотрудники ПИН); профильные поля менеджеров застройщика — talent.microservices.identity
Таблицаidentity.users
Tenant isolationTenantId = CompanyId (ADR-0052): инвестор — null, вне tenant; изоляция инвесторских данных — record-level по UserId

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKapp / Talent (UserId)Идентификатор пользователя. Он же investorId в публичном контракте.
TypeUserTypeДаvarchar(32), index ix_users_typeapp / consumer синкаТип пользователя (см. enum ниже). Согласован с CompanyId (инвариант INV-2).
CompanyIdGuid?Нет (обязателен для DeveloperManager/PartnerManager/PinEmployee; для Agent — nullable, ADR-0061/ADR-0070; всегда null у Investor)FK fk_users_company_id → identity.companies(id), index ix_users_company_idapp / consumer синкаРодительская компания. null — инвестор или независимый агент (ADR-0061). Для Agent при мульти-членстве (ADR-0070) — денормализованное зеркало ТЕКУЩЕГО ВЫБРАННОГО активного агентства (зеркало RefreshSession.ActiveCompanyId), а не «единственное» и не источник истины членства: источник истины — набор активных AgentMembership (agent-membership.md); null у независимого агента (нет активных членств). Синхронизируется при POST /api/v1/me/active-agency.
StatusUserStatusДаvarchar(32), index ix_users_statusappInvited / Active / Blocked / Archived.
NameFullName (owned)Да (LastName может быть пустой строкой при lazy-регистрации)см. VO нижеapp (СМС-flow/updateInvestor) / Talent syncФИО — VO FullName {firstName, lastName, middleName?} (maxLength 128 каждое).
PhoneNumberstringДаvarchar(32), нормализация E.164, unique ux_users_phone_number where deleted_at is nullapp (signUpOrSignIn) / Talent syncОсновной логин СМС-входа. Смена — только через flow requestPhoneChange/confirmPhoneChange (СМС на новый номер).
PhoneNumberConfirmedAtDateTimeOffset?НетappМомент подтверждения номера (первый успешный confirmSignIn по СМС или confirmPhoneChange).
Emailstring?Нетvarchar(256), формат email, unique ux_users_email where not null and deleted_at is nullapp (requestEmailChange/confirmEmailChange) / Talent syncEmail (nullable).
EmailConfirmedAtDateTimeOffset?НетappМомент подтверждения email кодом (confirmEmailChange, code ≤ 16 симв.).
ContactsManagedByContactsManagementModeДаvarchar(32)appSelfService — пользователь сам управляет ФИО/контактами (инвестор, агент, сотрудник ПИН); TalentSync — источник знаний Talent Identity, self-service Update/EmailChange/PhoneChange запрещены (менеджеры застройщика).
PasswordHashstring?Нетvarchar(512); Argon2id (parameters версионируются в префиксе хэша)app (changePassword; min 8 / max 128 симв. пароля)Хэш пароля. Производный флаг hasPassword = PasswordHash != null. Значение никогда не отдаётся наружу.
PasswordChangedAtDateTimeOffset?НетappМомент последней смены пароля (инвалидация сессий).
PinCodeHashstring?Нетvarchar(512); Argon2idapp (setPinCode: ^\d{4,12}$ / clearPinCode)Хэш пин-кода. Производный флаг hasPinCode = PinCodeHash != null.
IsTwoFactorEnabledboolДаdefault falseapp (confirmEnableTwoFactor / confirmDisableTwoFactor)Флаг 2FA: при true вход по паролю/пин-коду дополнительно требует СМС-код.
TwoFactorChangedAtDateTimeOffset?НетappМомент последнего включения/выключения 2FA.
FailedAccessAttemptsintДа>= 0, default 0appСчётчик подряд неуспешных проверок пароля/пин-кода; сбрасывается успешным входом; при достижении 10 — LockoutUntil = now + 15 мин.
LockoutUntilDateTimeOffset?НетappВременная блокировка входа (не меняет Status).
LastSignInAtDateTimeOffset?Нетindex ix_users_last_sign_in_atappПоследний успешный вход (аналитика активности, admin-API).
RegisteredViaRegistrationSourceДаvarchar(32)appИсточник появления учётки (см. enum ниже). Иммутабельно.
ReferrerUrlstring?Нетvarchar(64)app (signUpOrSignIn/confirmSignIn referrerUrl)Slug реферальной ссылки, зафиксированный при регистрации; передаётся событием в referral (правила привязки — там).
ExternalTalentUserIdGuid?Нетunique ux_users_external_talent_user_id where not nullTalent syncId пользователя в Talent Identity; null — пользователь создан в PIN.
SyncSourceUserSyncSourceДаvarchar(32)app / consumerPin / TalentIdentity (какие поля владеет синк — talent-replica.md).
ReplicaVersionlong?Нет (обязателен при SyncSource = TalentIdentity)>= 0Talent syncВерсия события Talent (out-of-order guard).
LastSyncedAtDateTimeOffset?НетTalent syncВремя последнего применённого события синка.
CreatedAtDateTimeOffsetДаindex ix_users_created_atapp«Дата регистрации».
CreatedByUserIdGuid?Нетappnull при самостоятельной регистрации; заполнен при lazy-регистрации оператором/агентом и при создании через admin-API.
UpdatedAtDateTimeOffsetДаappШтамп изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший.
DeletedAtDateTimeOffset?НетappSoft-delete (архив).
DeletedByUserIdGuid?НетappКто архивировал.
VersionlongДа>= 1appДоменная версия — идёт в payload событий.

Enum UserType

ЗначениеКомпания (CompanyId)Описание
Investornull (обязательно)Самостоятельный пользователь-покупатель. Весь СМС-flow — про него.
DeveloperManagerType = DeveloperМенеджер застройщика; профиль/контакты управляются Talent Identity (ContactsManagedBy = TalentSync).
Agentnull (независимый) или зеркало выбранного активного членства (Agency/Broker/Partner)Агент/риэлтор. Членство в агентстве — отдельный агрегат AgentMembership, не поле пользователя; при мульти-членстве (ADR-0070) активных членств может быть несколько, а CompanyId отражает текущее выбранное активное агентство (ADR-0061/ADR-0070). Независимый агент — с ролью agent в scope Platform, без активных членств; сохраняет UserId и все метрики/достижения.
PartnerManagerType = PartnerМенеджер компании-партнёра.
PinEmployeeType = PinСотрудник ПИН: Менеджер ПИН, Служба заботы. Конкретные полномочия — ролями (role.md), не типом.
Сотрудники ПИН как агенты

Сотрудники ПИН, проводящие сделки как риэлторы, регистрируются пользователями типа Agent в отдельной компании Agency, принадлежащей ПИН — тип пользователя не дублируется.

Enum UserStatus

ЗначениеОписание
InvitedУчётка создана компанией/синком, пользователь ещё ни разу не входил.
ActiveРабочее состояние.
BlockedЗаблокирован (админ/забота/синк Talent): любой вход отклоняется c IDENTITY_USER_BLOCKED.
ArchivedАрхив (soft-delete): вход невозможен, исторические ссылки (сделки, фиксации) живы.

Enum ContactsManagementMode

ЗначениеОписание
SelfServiceФИО/email/телефон меняет сам пользователь.
TalentSyncПоля владеет синк Talent; self-service операции updateInvestor, request/confirmEmailChange, request/confirmPhoneChange возвращают IDENTITY_CONTACTS_MANAGED_EXTERNALLY.

Enum RegistrationSource

ЗначениеОписание
SmsSignUpСамостоятельная регистрация через signUpOrSignIn (СМС).
LazyRegistrationАвтосоздание по имени+телефону из заявки (консультация/бронь/фиксация); Имя обновляется при следующем входе, если пустое (глоссарий «Lazy-регистрация»).
TalentSyncСоздан consumer-ом синка Talent Identity.
AdminApiСоздан через admin-API (ADR-0053) или приглашением компании.

Enum UserSyncSource — аналогично CompanySyncSource: Pin / TalentIdentity.

Value objects

FullName (owned)

ПолеТипОбязательностьОграниченияКолонка
FirstNamestringДаvarchar(128), min 1name_first_name
LastNamestringДа (пустая строка допустима при lazy-регистрации до заполнения)varchar(128)name_last_name
MiddleNamestring?Нетvarchar(128)name_middle_name

Поиск по ФИО — не PostgreSQL-индекс, а Elasticsearch-проекция UserSearchDocument (../search/users.md, ADR-0059): fuzzy-поиск пользователей компании по ФИО/телефону/ email для экрана «Команда». Btree-индексы ux_users_phone_number/ux_users_email обслуживают точечный lookup; поиск дублей инвестора по ФИО для фиксаций CRM — отдельная реплика/проекция контекста crm.

Модели JSONB

JSONB-модели не используются: расширенный профиль, документы и реквизиты — контекст profile, не identity.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
CompanyEntityreferenceCompanyId (FK)GetById(CompanyId) при проверке статуса компании во входеrestrict
RoleAssignmentreference (обратная)role_assignments.user_id → users.idFilter по UserId при построении permissionscascade (отзыв при Archived)
SignInConfirmationRequestreference (обратная)sign_in_confirmation_requests.user_idFindBy(Id) в confirm-шагеcascade по TTL
RefreshSessionreference (обратная)refresh_sessions.user_idFilter по UserIdcascade (revoke)
Talent userreplicaExternalTalentUserIdconsumer FindBy(ExternalTalentUserId)

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-1 UniquePhonePhoneNumber уникален среди не архивныхunique index + FindBy(PhoneNumber) в signUpOrSignIn (существующий → вход, нет → регистрация)IDENTITY_USER_PHONE_TAKEN (только в phone-change flow; в signUpOrSignIn дубль = вход)
INV-2 TypeCompanyConsistencyInvestor ⇔ CompanyId = null (строго); DeveloperManager/PartnerManager/PinEmployeeCompanyId != null и Company.Type соответствует таблице UserType; AgentCompanyId nullable (ADR-0061/ADR-0070): при непустом — зеркало выбранного активного членства среди возможно нескольких активных AgentMembership (тип совпадает с Agency/Broker/Partner), при null — независимый агент без активных членств. Agent не обязан иметь ровно одно активное членство: активных членств может быть несколько (мульти-агентство, ADR-0070); CompanyId при непустом обязан соответствовать одному из активных членств агента. Членство не выражается наличием CompanyId — источник истины и история в agent-membership.mdфабрики UserEntity.CreateInvestor / CreateCompanyUser; переход PromoteToAgent/DemoteToInvestor; синхронизация зеркала — join/leave membership и смена активного агентства (POST /api/v1/me/active-agency)IDENTITY_USER_TYPE_COMPANY_MISMATCH
INV-3 UniqueEmailEmail уникален (где задан)unique partial index + FindBy(Email) в requestEmailChangeIDENTITY_USER_EMAIL_TAKEN
INV-4 ManagedContactsПри ContactsManagedBy = TalentSync self-service смена ФИО/email/телефона запрещенаguard в методах Rename, RequestEmailChange, RequestPhoneChangeIDENTITY_CONTACTS_MANAGED_EXTERNALLY
INV-5 PinCodeFormatПин-код при установке соответствует ^\d{4,12}$метод SetPinCodeValidationError
INV-6 PasswordPolicyПароль 8–128 символов; currentPassword обязателен, если PasswordHash != nullметод ChangePasswordIDENTITY_INVALID_CURRENT_PASSWORD
INV-7 TwoFactorConfirmВключение/выключение 2FA — только после подтверждения СМС-кодом (ConfirmationPurpose = EnableTwoFactor/DisableTwoFactor)flow в user-session.mdIDENTITY_CONFIRMATION_INVALID
INV-8 LockoutВход отклоняется при LockoutUntil > nowшаг валидации confirm-операцийIDENTITY_USER_LOCKED_OUT
INV-9 CompanyActiveВход пользователя компании допустим только при Company.Status = Activeconfirm-шаг: GetById(CompanyId)IDENTITY_COMPANY_SUSPENDED
INV-10 MonotonicReplicaСобытие синка применяется при event.Version > ReplicaVersionconsumerидемпотентный пропуск
INV-11 LazyNameFillfirstName в confirmSignIn применяется только если текущее имя пустоеметод FillNameIfEmptyнет ошибки — молча игнорируется

Переходы типа Investor ↔ Agent (ADR-0061) ортогональны Status и не создают новую учётку — один UserId на человека. PromoteToAgent (become-agent/accept приглашения) добавляет роль agent и, при вступлении в агентство, Active membership + зеркалит CompanyId; независимый агент — роль agent в scope Platform, CompanyId = null. DemoteToInvestor (become-investor) снимает роль agent и закрывает активное membership. Личные метрики/достижения агента — user-centric (агрегация по AgentUserId сквозь все компании, ADR-0061 п.5) и переживают смену/выход из агентства; исторические сделки/лиды/фиксации остаются у компании-владельца через иммутабельные снапшоты AgencyCompanyId/ AgentUserId в crm (back-fill запрещён; reference only — role.md, agent-membership.md).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
UserRegisteredEventдоменноеСоздание учётки любым источникомId, Type, RegisteredVia, ReferrerUrl
UserSignedInEventдоменноеУспешный confirm любого способа входаId, способ (Sms/Password/PinCode), SessionId
UserContactChangedEventдоменноеУспешный confirmEmailChange / confirmPhoneChangeId, вид контакта, старое/новое значение (маскированные в логах)
UserSecurityChangedEventдоменноесмена пароля / установка/сброс пин-кода / 2FA on/offId, вид изменения
UserUpsertedKafkaEventинтеграционное (ContractVersion = 1, topic pin.identity.users.v1, key = Id)Регистрация и изменение публичных полей (Name, Type, CompanyId, Status)Id, Type, CompanyId, Status, Name, PhoneNumber (E.164, только для доверенных consumer-ов: crm/referral/messenger/notifications — ACL топика), RegisteredVia, ReferrerUrl, Version
UserBlockedKafkaEventинтеграционное (тот же topic)Active → BlockedId, BlockedAt, причина

Потребители pin.identity.users.v1: referral (реферальная привязка при регистрации), crm (лиды, lookup инвесторов), profile (создание профиля), messenger (контакты), user-activity (связывание anonymous-сессии), notifications.

Конкурентность и согласованность

  • Зеркало CompanyId для агента. Поле — денормализованная копия текущего выбранного активного агентства (зеркало RefreshSession.ActiveCompanyId, ADR-0070), а не «единственного» членства: источник истины — набор активных AgentMembership (agent-membership.md). Обновляется тем же изменением, что фиксирует join/leave, и при смене активного агентства (POST /api/v1/me/active-agency); downstream-контексты, читающие членство, ориентируются на события членства, а не на зеркало. Расхождение зеркала и агрегата членства внутри identity невозможно (одно изменение), но реплики в других сервисах — eventual (окно — секунды).
  • Out-of-order синк Talent. События синка применяются только при event.Version > ReplicaVersion (INV-10); устаревшее/повторное событие — идемпотентный пропуск, локальные (PIN-owned) поля синком не затираются (talent-replica.md).
  • Конкурентные изменения учётки. Оптимистическая блокировка по Version: параллельные смена контакта и блокировка/архивация сериализуются — проигравший повторяет операцию поверх нового состояния. Блокировка/архивация в момент активной сессии её не ждёт: сессии отзываются (INV-S7 user-session.md), последующая авторизация отклоняется.
  • Idempotent-регистрация по номеру. Повторный signUpOrSignIn на существующий номер — это вход, а не создание дубля (INV-1); гонка двух регистраций одного номера страхуется уникальностью телефона (проигравший переходит в сценарий входа).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK (Id); ux_users_phone_number unique where deleted_at is null; ux_users_email unique where not null and deleted_at is null; ux_users_external_talent_user_id unique where not null; ix_users_company_id; ix_users_type; ix_users_status; ix_users_created_at; ix_users_last_sign_in_at. Поиск по ФИО — Elasticsearch-проекция UserSearchDocument (../search/users.md), не GIN-индекс PostgreSQL
Партиционирование / retentionnone; учётки — бессрочно (soft-delete); полное удаление PII — регламент администратора платформы (ADR-0053)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую); confirm-flows дополнительно сериализуются уникальностью SignInConfirmationRequest
Permissions / PIIPhoneNumber, Email, FullName — PII: в логах маскируются (+7***1234, d***@mail); PasswordHash/PinCodeHash — никогда не покидают домен (нет в проекциях/событиях); self-операции — permission identity.users.view-self / identity.security.manage-self; чужие пользователи компании — identity.users.view/manage в scope организации; кросс-компания — только платформенные роли ПИН
AuditVersion + actor-stamping; каждое security-изменение — UserSecurityChangedEvent (лента для админ-аудита)

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

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

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

  • company.md, role.md, permission.md, user-session.md, talent-replica.md.
  • Членство агента и приглашения (ADR-0061): agent-membership.md, company-invitation.md; переходы — become-agent/become-investor/leave-agency.
  • Мульти-агентство и активное агентство (ADR-0070): несколько активных членств, RefreshSession.ActiveCompanyId, POST /api/v1/me/active-agency, nextAction = SelectActiveAgencyuser-session.md, agent-membership.md.
  • Операции входа и безопасности: identity/api/.
  • ЛК/документы/верификация — контекст profile.
  • ADR-0052, ADR-0053, ADR-0061, ADR-0070.