UC-PRF-006. Смена email/phone из ЛК (делегирование в identity + обновление реплики)
Назначение
Раздельная смена контактной информации (email, phone) из личного кабинета: сами операции выполняет
identity (requestEmailChange/confirmEmailChange, requestPhoneChange/confirmPhoneChange —
UC-IDN-004); роль контекста profile —
отображение read-only реплики контактов и её актуализация консюмером identity-users-sync по событию
pin.identity.users.v1. profile эти операции не дублирует (граница —
../domain/profile.md). Связан с контекстами profile и identity.
| Поле | Значение |
|---|
| ID | UC-PRF-006 |
| Контекст | profile (реплика/consumer); операции — identity (см. docs/06-services/identity/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|
| Роль | Инвестор / Агент / Агентство / МенеджерПИН / СлужбаЗаботы (self-service, ContactsManagedBy = SelfService); Девелопер — запрещено (UC-PRF-005, UC-IDN-004 A3). Consumer-шаг — System |
| Permissions / scope | identity-шаги: identity.security.manage-self; profile-отображение: profile.personal.view-self, profile.contacts.manage-self (право видеть кнопки «Изменить») |
| Tenant | self-операция, субъект = актор (ADR-0052); consumer — system-actor без tenant |
| Record-level | только собственная учётка/профиль (Id = UserId) |
Предусловие
- Пользователь аутентифицирован,
Profile.Status = Active (INV-PRF-3).
ContactsManagedBy = SelfService (иначе A3 → UC-PRF-005).
- Ограничения identity: новый контакт не занят (INV-1/INV-3 identity), rate-limit кодов не исчерпан.
Триггер
| Поле | Значение |
|---|
| Источник | UI button; затем Kafka event (consumer-шаг) |
| Триггерящий элемент | ЛК /profile/personal → блок «Контакты» → «Изменить телефон» / «Изменить email»; consumer identity-users-sync (Pin.Profile.Background) |
Основной Поток
- Пользователь в ЛК видит контакты — реплику из
getMyProfile (ContactPhone, ContactEmail
полей ProfileEntity; read-only, дом — ../domain/profile.md) — и нажимает
«Изменить телефон» (email — аналогично, по отдельности).
- Фронт выполняет процедуру смены контакта в identity — полные алгоритмы (шаги request/confirm,
отправка кода на НОВЫЙ контакт, отзыв сессий при смене телефона) документированы в
UC-IDN-004 и здесь не дублируются:
POST /investors/request-phone-change { newPhoneNumber } → код на новый номер;
POST /investors/confirm-phone-change { newPhoneNumber, code } → смена номера учётки, отзыв прочих
Active-сессий.
Контекст profile в этих шагах не участвует.
identity при подтверждении публикует UserUpsertedKafkaEvent (топик pin.identity.users.v1,
снапшот учётки с новым PhoneNumber/Email и инкрементированным Version).
- Consumer
identity-users-sync (Pin.Profile.Background,
../consumers/identity-users-sync.md) обрабатывает событие
(бизнес-проекция; полный алгоритм — в доке консюмера):
- actor = system; бизнес-идемпотентность — по
event.Version против ReplicaVersion.
- payload доверенный (внутренний контур); структурная проверка контракта; unknown enum
UserType →
DLQ (правило из ../domain/profile.md).
- профиль ищется по
Id = event.UserId; отсутствие допускается (первое событие → создание профиля,
ветка A1 доки консюмера).
- INV-PRF-1 MonotonicReplica: событие применяется только при
event.Version > profile.ReplicaVersion,
иначе идемпотентный пропуск (out-of-order/повтор); маппинг реплика-полей:
ContactPhone ← event.PhoneNumber, ContactEmail ← event.Email, Name ← event.Name,
UserType/CompanyId/ContactsManagedBy/Status ← event; ReplicaVersion = event.Version;
LastSyncedAt = now. Собственные поля profile (Locale, Timezone, VerificationStatus,
DefaultRequisiteTemplateId) не затрагиваются — consumer и self-операции меняют непересекающиеся
наборы полей.
- собственных Kafka-событий profile не публикует (контакты наружу транслирует их владелец
identity —
../domain/profile.md, раздел событий).
- offset коммитится после успешного сохранения (at-least-once + идемпотентность по
ReplicaVersion);
ошибка обработки → retry, затем DLQ (alert «DLQ identity-sync > 0»).
- Фронт после успешного подтверждения инвалидирует
getCurrentInvestor (identity) и getMyProfile
(profile); ЛК показывает новый контакт. Если consumer ещё не догнал, фронт
показывает значение из ответа identity — реплика profile используется для последующих заходов.
- Смена email — тот же поток с
requestEmailChange/confirmEmailChange
(UC-IDN-004, поток A1): код письмом на новый адрес; сессии не отзываются.
Альтернативные Потоки
A1. Смена email
- Шаги 1–6 с парой
requestEmailChange/confirmEmailChange (UC-IDN-004 A1): код на новый email,
смена email учётки; сессии не отзываются. Consumer-шаг идентичен (ContactEmail ← event.Email).
A2. Событие пришло не по порядку / повторно
event.Version <= profile.ReplicaVersion → идемпотентный пропуск без мутаций (INV-PRF-1);
offset коммитится. Более новое событие уже применено — реплика не откатывается.
A3. Менеджер застройщика
- Кнопки скрыты (
canEditContacts = false из getMyProfile); прямой вызов identity отклоняется
IDENTITY_CONTACTS_MANAGED_EXTERNALLY (UC-IDN-004 A3, UC-PRF-005). Актуализация контактов —
только синк Talent. Сценарий завершается.
A4. Ошибки identity-flow (занятый контакт, неверный код, rate-limit)
IDENTITY_USER_PHONE_TAKEN / IDENTITY_USER_EMAIL_TAKEN / IDENTITY_CONFIRMATION_INVALID/ EXPIRED/EXHAUSTED / IDENTITY_CONFIRMATION_RATE_LIMITED — полностью на стороне identity
(UC-IDN-004, A2/A4). Контакты и реплика не меняются, событие не публикуется. Возврат к шагу 1.
A5. Consumer упал / отстаёт
- Реплика в profile временно показывает старый контакт; фактический вход/СМС уже работают по
новому (source of truth — identity). Retry → DLQ → replay по runbook (README profile).
- Ни одна операция profile не принимает решений по значению контакта — только отображение,
поэтому рассинхронизация не нарушает инварианты. После догона — A2/шаг 4.
A6. Быстрая последовательная смена телефона и email
- Два confirm-шага в identity порождают два
UserUpsertedKafkaEvent с растущим Version; порядок
сохраняется key = UserId (одна партиция per user — событие каждой смены применяется в порядке
публикации).
- Consumer применяет события по возрастанию
Version (INV-PRF-1); финальная реплика отражает оба
изменения. Переставленное/задвоенное доставкой событие с Version <= ReplicaVersion идемпотентно
пропускается — откат к промежуточному состоянию исключён (A2).
Постусловие
- В identity:
PhoneNumber/Email учётки изменены, подтверждены (постусловия UC-IDN-004).
- В profile: реплика
ContactPhone/ContactEmail обновлена; ReplicaVersion = event.Version,
LastSyncedAt заполнен; собственные поля ЛК не изменились.
- ЛК отображает новый контакт; персоны/документы/шаблоны/верификация не затронуты
(контакты учётки и контакты-оверрайды шаблонов — независимые данные, UC-PRF-004).
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|
IDENTITY_CONTACTS_MANAGED_EXTERNALLY | 403 | TalentSync (identity INV-4; A3) | Скрыть кнопки, плашка |
IDENTITY_USER_PHONE_TAKEN / IDENTITY_USER_EMAIL_TAKEN | 409 | Контакт занят (identity INV-1/INV-3) | «Уже используется» |
IDENTITY_CONFIRMATION_INVALID/EXPIRED/EXHAUSTED | 400 | Код неверен/истёк/исчерпан | Как в UC-IDN-001/004 |
IDENTITY_CONFIRMATION_RATE_LIMITED | 429 | Rate-limit кодов | Таймер повтора |
PROFILE_NOT_FOUND | 404 | Реплика ещё не создана (первый вход) | Retry (UC-PRF-001 A1) |
401 UNAUTHORIZED | 401 | Нет JWT | Auth-модалка |
Ошибки consumer-шага (десериализация, unknown enum, сбой БД) — не HTTP: retry → DLQ + alert
(наблюдаемость — README profile; runbook «replay DLQ identity-sync»).
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|
UserContactChangedEvent (identity) | Confirm-шаг identity — дом UC-IDN-004 | identity in-proc аудит |
ProfileCreatedEvent (profile) | Только если событие оказалось первым для UserId (ветка создания реплики) | in-proc |
Собственных доменных событий на обновление реплика-полей profile не поднимает (техническая
синхронизация; аудит — ReplicaVersion/LastSyncedAt).
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|
Consumed: UserUpsertedKafkaEvent.v1 | pin.identity.users.v1 | Успешный confirm-шаг смены телефона/email в identity | profile (identity-users-sync), а также crm, referral, messenger, notifications |
| Produced | — | profile в этом сценарии ничего не публикует (владелец контактов — identity) | — |
Acceptance Criteria
| # | Критерий | Проверка |
|---|
| AC1 | В profile нет endpoint-ов смены email/phone; ЛК ведёт на операции identity | ревью API-карты profile (README) |
| AC2 | После confirm-шага identity реплика profile обновляется событием без ручных действий | сквозной интеграционный тест |
| AC3 | Out-of-order/повторное событие не откатывает реплику (INV-PRF-1) | тест consumer-а с перестановкой Version |
| AC4 | Consumer не затирает собственные поля profile (Locale/Timezone/VerificationStatus/DefaultRequisiteTemplateId) | тест непересекающихся полей |
| AC5 | Email и телефон меняются независимо (по отдельности) | два раздельных теста |
| AC6 | Для TalentSync-пользователя кнопки скрыты и identity возвращает 403 | тест A3 |
| AC7 | Сбой consumer-а не блокирует identity-flow; после replay DLQ реплика догоняет | тест A5 + runbook |
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные Документы
- Identity (дом операций): UC-IDN-004;
API —
identity/api/investor/request-phone-change.md, identity/api/investor/confirm-phone-change.md,
identity/api/investor/request-email-change.md, identity/api/investor/confirm-email-change.md;
событие — ../../identity/events/user-upserted.md.
- Consumer: ../consumers/identity-users-sync.md (полный
алгоритм консюмера — дом; здесь — проекция на сценарий).
- Domain entity: ../domain/profile.md (реплика-поля, INV-PRF-1,
граница «identity владеет контактами»).
- Смежные операции identity (та же граница): пароль/2FA/пин —
UC-IDN-002,
UC-IDN-003.
- Связанные UC: UC-PRF-001 (отображение профиля), UC-PRF-005 (запрет для менеджера застройщика).