POST /api/investors/me/phone/change-confirm — Подтверждение смены телефона (confirmPhoneChange)
Назначение
Второй шаг смены номера: проверить СМС-код, отправленный на новый номер
(request-phone-change.md), и применить новый номер (PhoneNumber = NewPhoneNumber, PhoneNumberConfirmedAt = now). Вызывается аутентифицированным пользователем. Меняется
основной логин СМС-входа — новое значение уходит снапшот-событием доверенным потребителям.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | contacts |
| Статус | approved |
| Документ | docs/06-services/identity/api/investor/confirm-phone-change.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Bearer JWT) |
| Auth policy / Permissions | authenticated; permission identity.security.manage-self |
| Scope (RBAC) | self |
| Record-level | Pending-запрос ищется строго по UserId актора + сверка newPhoneNumber |
| Tenant isolation | не применяется (ADR-0052) |
| Audit | update логина: событие UserContactChangedEvent (оба номера маскируются в логах) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/investors/me/phone/change-confirm |
| Success status | 200 |
| Idempotency header | не применяется; одноразовость — статус запроса Confirmed + оптимистическая блокировка |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса (body ConfirmPhoneChangeForm)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
newPhoneNumber | body | string | Да | min 1, max 32; E.164; должен совпасть с request.NewPhoneNumber | PII, маскирование |
code | body | string | Да | min 1, max 16 | никогда не логируется |
Модель ответа ConfirmPhoneChangeCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
investorId | string (Guid) | Да | UserEntity.Id | — | Идентификатор пользователя. |
Алгоритм
- Контекст и доступ. Операцию выполняет аутентифицированный пользователь над собственной записью
(
UserIdиз токена). - Проверка входа.
newPhoneNumberнепустой ≤ 32 (нормализация E.164),codeнепустой ≤ 16 — иначеValidationError. - Проверки состояния. Получить пользователя; он
Active— иначеIDENTITY_USER_BLOCKED; контакты управляются самим пользователем (ContactsManagedBy == SelfService, INV-4) — иначеIDENTITY_CONTACTS_MANAGED_EXTERNALLY. Найти его активный (Pending) запрос с цельюPhoneChange— нет запроса ⇒IDENTITY_CONFIRMATION_INVALID; запрос не истёк (INV-S3); номер во входе совпадает сrequest.NewPhoneNumber— иначеIDENTITY_CONFIRMATION_INVALID(код от другого номера неприменим). - Правила и переходы. Подтверждение кода: счётчик попыток увеличивается до сравнения (INV-S2, 5-я
неверная ⇒
Exhausted); сравнение с хэшем в постоянном времени (INV-S5); при успехе запросConfirmed,ConfirmedAt = now. Повторная проверка уникальности номера (INV-1, гонка): номер занят ⇒IDENTITY_USER_PHONE_TAKEN(паттерн DUPLICATE_PHONE). Применение:PhoneNumber = NewPhoneNumber,PhoneNumberConfirmedAt = now; фиксируется доменное событиеUserContactChangedEvent(видPhoneNumber, старое/новое — маскированные). Отзыв всех активных сессий пользователя (смена логина, INV-S7). - Что меняется. В одном согласованном изменении обновляются запрос и пользователь; неверные попытки
сохраняются до возврата ошибки; гонка двойного confirm разрешается оптимистической блокировкой;
уникальность номера окончательно гарантируется ограничением уникальности в хранилище (INV-1, нарушение
→
IDENTITY_USER_PHONE_TAKEN). - События.
UserUpsertedKafkaEvent→pin.identity.users.v1(ключ =UserId; снапшот с новымPhoneNumberв E.164 — только доверенные потребители crm/referral/messenger, а такжеName,Type,CompanyId,Status,Version). ДоменноеUserContactChangedEvent— аудит. - Результат.
investorId; следующий вход — уже по новому номеру; HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | UserUpsertedKafkaEvent / pin.identity.users.v1 / ключ = UserId (новый PhoneNumber для реплик crm/referral/messenger) |
| Внешние вызовы | none |
| Проекции / поиск | none |
| Уведомления | СМС на старый номер «номер изменён» (security-алерт) через сервис notifications |
Смена номера — смена логина: отзываются все активные сессии пользователя (INV-S7), включая текущую,
поэтому клиент разлогинивается и следующий вход выполняет уже по новому номеру (в отличие от смены
пароля, где текущая сессия сохраняется). Новый PhoneNumber в E.164 уходит снапшотом
UserUpsertedKafkaEvent; доверенные потребители (crm/referral/messenger) обновят свои реплики через
секунды — до этого их поиск/контакты по номеру возвращают прежнее значение, что для пользователя
выражается лишь в кратком лаге отображения нового номера у контрагентов.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен JWT | клиент выполняет re-login | re-login |
ValidationError | 400 | Формат/длины полей | Список ошибок валидации | no retry |
IDENTITY_CONFIRMATION_INVALID | 400 | Нет Pending-запроса / номер не совпал с запросом / неверный код | «Неверный код подтверждения» | ввести заново |
IDENTITY_CONFIRMATION_EXPIRED | 400 | INV-S3 | «Код истёк» | новый request-шаг |
IDENTITY_CONFIRMATION_EXHAUSTED | 400 | INV-S2 | «Превышено число попыток» | новый request-шаг |
IDENTITY_USER_PHONE_TAKEN | 400 | Номер занят (гонка, INV-1; паттерн DUPLICATE_PHONE) | «Номер уже используется» | новый request с другим номером |
IDENTITY_CONTACTS_MANAGED_EXTERNALLY | 400 | INV-4 | «Контакты управляются внешней системой» | no retry |
IDENTITY_USER_BLOCKED | 400 | Status != Active | «Учётная запись заблокирована» | no retry |
Acceptance Criteria
- Given активный self-service пользователь и непросроченный
Pending-запросPhoneChange, вход сnewPhoneNumber == request.NewPhoneNumberи вернымcode, свободный номер, When confirm, Then запрос →Confirmed,PhoneNumber = NewPhoneNumber,PhoneNumberConfirmedAt = now, все сессии отзываются, уходитUserUpsertedKafkaEvent(снапшот нового номера) + доменноеUserContactChangedEvent, ответ200; следующий вход — по новому номеру. - Given
newPhoneNumberво входе не совпадает сrequest.NewPhoneNumber, When confirm, ThenIDENTITY_CONFIRMATION_INVALID(400) — код от другого номера неприменим. - Given между request и confirm номер занял другой пользователь (INV-1), When confirm с верным
code, ThenIDENTITY_USER_PHONE_TAKEN(400) — требуется новый request с другим номером. - Given
codeневерный, When confirm, Then счётчик попыток растёт; на 5-й неверной — запрос →ExhaustedиIDENTITY_CONFIRMATION_EXHAUSTED; до этого —IDENTITY_CONFIRMATION_INVALID(400). - Given запрос истёк (INV-S3) / нет
Pending-запросаPhoneChange, When confirm, ThenIDENTITY_CONFIRMATION_EXPIRED/IDENTITY_CONFIRMATION_INVALID(400). - Given контакты управляются извне (INV-4), When confirm, Then
IDENTITY_CONTACTS_MANAGED_EXTERNALLY(400); GivenStatus != Active, ThenIDENTITY_USER_BLOCKED(400).
Совместимость и наблюдаемость
- Logs:
UserId, результат, маскированные номера; metrics:identity_phone_change_confirms_total{result}; traces — OTel.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): Identity API — карта областей и реестр investor-области, POST /api/investors/me/phone/change-request — Запрос смены телефона (requestPhoneChange)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: user.md (INV-1/4,
PhoneNumber/PhoneNumberConfirmedAt), user-session.md (NewPhoneNumber, INV-S2/S3/S5/S7/S8). - Пред-шаг: request-phone-change.md; событие — user.md «События».