POST /api/investors/auth/sign-up-or-sign-in — Войти или зарегистрироваться (signUpOrSignIn)
Назначение
Основная точка входа формы входа: семантика AddOrGet — существующий инвестор получает шаги входа,
новый регистрируется по телефону (+ опциональное имя) и получает СМС-код. Вызывается анонимным клиентом.
Принимает referrerUrl (slug реферальной ссылки из cookie 30 дней) — фиксируется в учётке и уходит
событием в контекст referral.
| Поле | Значение |
|---|
| Сервис/контекст | identity |
| API area | public |
| Feature group | auth |
| Статус | approved |
| Документ | docs/06-services/identity/api/investor/sign-up-or-sign-in.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | Anonymous |
| Auth policy / Permissions | none |
| Scope (RBAC) | не применяется |
| Record-level | связка по PhoneNumber |
| Tenant isolation | не применяется — создаётся/находится инвестор вне tenant (CompanyId = null, ADR-0052) |
| Audit | create (регистрация) — actor-stamping CreatedByUserId = null (самостоятельная) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/investors/auth/sign-up-or-sign-in |
| Success status | 200 |
| Idempotency header | не применяется — операция идемпотентна по смыслу (повтор для существующего номера = вход) |
| Correlation | сквозной trace через traceparent |
Входные данные / параметры запроса (body SignUpOrSignInForm)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
phoneNumber | body | string | Да | min 1, max 32; нормализация E.164 | PII: маскирование в логах |
firstName | body | string? | Нет | max 128; используется только при регистрации нового инвестора | PII |
sendSmsCode | body | bool? | Нет | true — отправить СМС-код даже при наличии пароля/пин-кода; для нового инвестора и при отсутствии пароля/пин-кода код отправляется в любом случае | — |
referrerUrl | body | string? | Нет | max 64; slug реферальной ссылки (из реф-cookie) | не логируется вместе с номером (связка гость→реферал) |
IpAddress / UserAgent | middleware | string? | Нет | ≤ 45 / ≤ 512 | rate-limit / анти-фрод |
Модель ответа SignUpOrSignInCommandResult
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
outcome | SignUpOrSignInOutcome | Да | computed: Registered — создан новый, SignedIn — найден существующий | — | Итог AddOrGet. |
nextStep | SignInNextStep | Да | computed (как в request-sign-in-code.md; для нового всегда RequiresSmsCode) | — | Следующий шаг входа. |
nextAction | AuthNextAction? | Нет | computed по флагам пользователя (FillName → FillPinCode → FillEmail → FillPassword, README.md) | — | Подсказка UI, не блокирует. |
signInConfirmationRequestId | string (Guid)? | Нет | SignInConfirmationRequest.Id (если код отправлялся) | — | Для confirmSignIn. |
realConfirmationCodeDispatched | bool? | Нет | verifier | — | false — код реально не отправлялся (тестовый контур). |
Алгоритм
- Контекст и доступ. Актор анонимный; из middleware известны
IpAddress/UserAgent.
- Проверка входа и лимиты.
phoneNumber непустой ≤ 32, firstName ≤ 128, referrerUrl ≤ 64 — иначе
ValidationError; нормализация E.164. Rate-limit (INV-S4, по номеру/IP) при отправке кода —
превышение ⇒ IDENTITY_CONFIRMATION_RATE_LIMITED (429).
- Проверки сущностей. Найти пользователя по нормализованному номеру (отсутствие → ветка регистрации).
Для существующего:
Status ∈ {Active, Invited} — иначе IDENTITY_USER_BLOCKED; блокировка входа снята
(INV-8) — иначе IDENTITY_USER_LOCKED_OUT.
- Правила и маппинг.
- Ветка Registered (пользователь не найден): создаётся инвестор —
Type = Investor,
CompanyId = null, Status = Active, Name = FullName(firstName ?? "", lastName: ""),
PhoneNumber = нормализованный, RegisteredVia = SmsSignUp, ReferrerUrl = referrerUrl,
ContactsManagedBy = SelfService, SyncSource = Pin (INV-1: уникальность номера; INV-2); фиксируется
доменное событие UserRegisteredEvent; outcome = Registered, nextStep = RequiresSmsCode,
отправка кода обязательна.
- Ветка SignedIn (пользователь найден):
outcome = SignedIn; nextStep по флагам (пароль →
RequiresPassword, иначе пин-код → RequiresPinCode, иначе RequiresSmsCode); код отправляется
если nextStep = RequiresSmsCode или sendSmsCode == true; firstName/referrerUrl
существующего пользователя не меняются (INV-11 — имя дозаполняется только в confirmSignIn и
только если пустое).
- При отправке кода: сгенерировать код (CSPRNG); создать запрос подтверждения (цель
SignIn, UserId,
PhoneNumber = нормализованный, хэш кода, ExpiresAt = now + 5 мин, IpAddress, UserAgent) +
доменное ConfirmationCodeRequestedEvent.
nextAction — первая применимая подсказка по данным пользователя (после создания — как правило
FillName при пустом имени).
- Что меняется. В одном согласованном изменении: ветка Registered создаёт нового пользователя;
при отправке кода прежний активный (
Pending) запрос той же пары (номер, SignIn) гасится в
Superseded (INV-S1) и создаётся новый запрос. Гонка одновременной регистрации одного номера
отсекается ограничением уникальности номера → повторное выполнение по ветке SignedIn.
- События. Registered →
UserUpsertedKafkaEvent → pin.identity.users.v1 (ключ = UserId; Id,
Type = Investor, CompanyId = null, Status, Name, PhoneNumber, RegisteredVia = SmsSignUp,
ReferrerUrl — по нему referral выполняет привязку реферала при регистрации, Version). SignedIn →
интеграционных событий нет.
- Результат.
outcome, nextStep, nextAction?, signInConfirmationRequestId?,
realConfirmationCodeDispatched? (маппинг в таблице ответа); токен не выдаётся — вход завершает
confirmSignIn/verifySignInPassword; HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Интеграционные события | UserUpsertedKafkaEvent / topic pin.identity.users.v1 / ключ = UserId — только в ветке Registered; потребители: referral (привязка по ReferrerUrl), profile, crm, messenger, user-activity, notifications |
| Внешние вызовы | СМС-verifier (timeout 5 с, 1 retry) |
| Проекции / поиск | rate-limit-счётчики |
| Уведомления | СМС с кодом |
Кросс-сервисный контракт и окно согласованности
- Предоставляет (только ветка
Registered): UserUpsertedKafkaEvent в топик pin.identity.users.v1,
ContractVersion = 1, ключ партиционирования = UserId (порядок событий по одному пользователю
сохраняется). Снапшот: Id, Type = Investor, CompanyId = null, Status, Name, PhoneNumber
(E.164 — только доверенным consumer-ам), RegisteredVia = SmsSignUp, ReferrerUrl, Version.
Потребители: referral (привязка реферала по ReferrerUrl), profile (создание профиля), crm (лиды/
lookup), messenger (контакт), user-activity (связывание anonymous-сессии), notifications.
- Ожидает от других: ничего синхронно, кроме внешнего СМС-verifier (синхронный адаптер, timeout 5 с,
1 retry). Реферальная привязка — забота контекста
referral; identity лишь переносит ReferrerUrl.
- Окно согласованности: учётка и её
ReferrerUrl доступны в identity сразу после коммита; привязка
реферала в referral, профиль, контакт и связывание anonymous-сессии происходят асинхронно (лаг —
секунды) по мере обработки события consumer-ами. До этого пользователь уже вошёл (после confirmSignIn),
но реферальная атрибуция/профиль могут быть ещё не видны. Ветка SignedIn интеграционных событий не
порождает.
Предусловия и постусловия
- Пред: актор анонимный;
phoneNumber нормализуется в E.164; поля формы в пределах длин; лимиты INV-S4
не превышены при отправке кода.
- Пост (
Registered): создан инвестор (Type = Investor, CompanyId = null, Status = Active,
Name = FullName(firstName ?? "", ""), ReferrerUrl, ContactsManagedBy = SelfService,
SyncSource = Pin); зафиксирован UserRegisteredEvent; опубликован UserUpsertedKafkaEvent; создан
Pending-запрос SignIn; код отправлен. Токен не выдаётся.
- Пост (
SignedIn): новый пользователь не создаётся; firstName/referrerUrl существующего не
меняются (INV-11); при отправке кода прежний Pending пары (номер, SignIn) → Superseded (INV-S1) и
создан новый. Интеграционных событий нет.
Acceptance Criteria
- Given незанятый номер, When
signUpOrSignIn {phoneNumber, firstName?, referrerUrl?}, Then
outcome = Registered, nextStep = RequiresSmsCode, создан инвестор с ReferrerUrl, опубликован
UserUpsertedKafkaEvent (key = UserId), отправлен СМС-код; nextAction = FillName при пустом имени.
- Given существующий номер с паролем,
sendSmsCode не задан, When signUpOrSignIn, Then
outcome = SignedIn, nextStep = RequiresPassword, СМС не отправляется, интеграционных событий нет,
firstName/referrerUrl существующего не изменены.
- Given существующий номер только с пин-кодом, When
signUpOrSignIn, Then nextStep = RequiresPinCode, без СМС.
- Given существующий номер с паролем и
sendSmsCode = true, When signUpOrSignIn, Then код
всё равно отправляется, возвращается signInConfirmationRequestId; прежний Pending → Superseded.
- Given существующий номер без пароля и пин-кода, When
signUpOrSignIn, Then nextStep = RequiresSmsCode, создан Pending-запрос SignIn, код отправлен.
- Given существующая учётка
Blocked/Archived, When signUpOrSignIn, Then
IDENTITY_USER_BLOCKED (400).
- Given
LockoutUntil > now (INV-8), When signUpOrSignIn, Then IDENTITY_USER_LOCKED_OUT (400).
- Given два параллельных запроса на один незанятый номер, When оба доходят до создания, Then
уникальность номера пропускает одного (
Registered), второй повторно выполняется по ветке SignedIn.
- Given превышен лимит СМС (INV-S4), When отправка кода, Then
IDENTITY_CONFIRMATION_RATE_LIMITED (429).
- Given verifier отказал, When отправка кода, Then
IDENTITY_SMS_DISPATCH_FAILED (400). Токен
не выдаётся ни в одной ветке — вход завершает confirmSignIn/verifySignInPassword.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
ValidationError | 400 | Формат/длины полей формы | Список ошибок валидации | no retry |
IDENTITY_USER_BLOCKED | 400 | Существующий Status ∈ {Blocked, Archived} | «Учётная запись заблокирована» | no retry |
IDENTITY_USER_LOCKED_OUT | 400 | LockoutUntil > now | «Вход временно заблокирован» | retry позже |
IDENTITY_CONFIRMATION_RATE_LIMITED | 429 | INV-S4 | «Слишком много запросов кода» | retry ≥ 60 сек |
IDENTITY_SMS_DISPATCH_FAILED | 400 | Отказ verifier | «Не удалось отправить код» | retry |
Совместимость и наблюдаемость
- Неизвестное значение
outcome/nextStep — клиент трактует как RequiresSmsCode (safe default). Новые
optional поля — additive.
- Logs:
outcome, nextStep, маскированный номер, наличие referrerUrl (bool); metrics:
identity_sign_up_or_sign_in_total{outcome}; traces — OTel.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы