POST /api/v1/profile/persons — Создание персоны (createPerson)
Назначение
Создать новый набор персональных данных (персону) на аккаунте инвестора: аккаунт может принадлежать
мужу/жене, а сделка оформляется на супруга/ребёнка/иное лицо — поэтому персон несколько. ФИО и дата рождения
шифруются на уровне приложения (AES-256-GCM) до записи в БД (person.md). Персона
Relation = Self создаётся автоматически консюмером identity
(../consumers/identity-users-sync.md); повторная активная Self
запрещена (INV-PRS-1).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | profile |
| API area | public |
| Feature group | persons |
| Статус | draft |
| Документ | docs/06-services/profile/api/public/create-person.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Bearer JWT), UserType = Investor (INV-PRS-2) |
| Auth policy / Permissions | authenticated; permission profile.personal.manage-self |
| Scope (RBAC) | self |
| Record-level | OwnerUserId = UserId из sub (проставляется handler-ом, из body не принимается) |
| Tenant isolation | не применяется (ADR-0052) |
| Audit | create: CreatedByUserId = UserId, Version = 1, доменное событие PersonCreatedEvent |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/profile/persons |
| Success status | 201 |
| Idempotency header | Idempotency-Key (опционально; защита от двойного сабмита формы) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса (body CreatePersonForm)
Проверка входа выполняется до шифрования на plaintext (правила полей — person.md).
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
relation | body | PersonRelationType | Да | enum Self/Spouse/Child/Parent/Other; Self — только при отсутствии активной Self (INV-PRS-1) | — |
firstName | body | string | Да | 1–128 символов | PII → шифруется, не логируется |
lastName | body | string | Да | 1–128 символов | PII → шифруется |
middleName | body | string? | Нет | 1–128 символов (опциональное отчество — прототип) | PII → шифруется |
birthDate | body | DateOnly? | Нет | ISO yyyy-MM-dd; ≤ сегодня; ≥ 1900-01-01 | PII → шифруется |
citizenshipCountryId | body | short? | Нет | ссылка на справочник стран handbook; резолв через internal resolve (resolve-references.md), неизвестная/архивная → PROFILE_COUNTRY_UNKNOWN | открытое поле |
gender | body | PersonGender? | Нет | enum Male/Female | открытое поле |
Модель ответа PersonModel
Дом модели — list-persons.md; возвращается созданная персона (documentsCount = 0,
version = 1), значения ФИО/даты — из входного plaintext (повторная расшифровка не выполняется).
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (
UserId=sub, INV-PRS-2) с permissionprofile.personal.manage-self; владелец берётся из токена, из body не принимается. Tenant не применяется (ADR-0052). При повторе с тем жеIdempotency-Keyвозвращается сохранённый результат без создания дубля. - Проверка входа. enum
relation; длины ФИО (1–128);birthDate≤ сегодня и ≥1900-01-01;gender— enum — иначеValidationError(на plaintext до шифрования). Если заданcitizenshipCountryId— резолв страны через internal resolve справочникаhandbook; страна не найдена или архивна →PROFILE_COUNTRY_UNKNOWN(422); справочник недоступен → fail-closedPROFILE_HANDBOOK_UNAVAILABLE(503). - Проверка профиля и лимитов. Профиль self-service: отсутствие →
PROFILE_NOT_FOUND;Status = Active(INV-PRF-3 →PROFILE_USER_BLOCKED);ContactsManagedBy = SelfService(INV-PRF-2/INV-PRS-3 →PROFILE_MANAGED_EXTERNALLY; менеджеры застройщика — источник identity);UserType = Investor(INV-PRS-2 →PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE). Лимит: ≥ 20 активных персон →PROFILE_PERSONS_LIMIT_EXCEEDED(INV-PRS-7). Приrelation = Self: активнаяSelfуже есть →PROFILE_PERSON_SELF_ALREADY_EXISTS(INV-PRS-1). - Шифрование и создание. ФИО (first/last/middle) и
birthDateшифруются единымKeyVersionзаписи (INV-PRS-8); недоступность ключей →PROFILE_CRYPTO_UNAVAILABLE(503). Создаётся персона:Status = Active,DocumentsCount = 0,CreatedByUserId = UserId,Version = 1;citizenshipCountryId/gender— открытые поля. - События.
PersonUpsertedKafkaEvent(топикpin.profile.persons.v1, keyPersonId; payload без PII:Id,OwnerUserId,Relation,Status = Active,DocumentsCount = 0,Version = 1). Контракт — ../events/person-upserted.md. - Согласованность. Персона, публикация события и idempotency-запись фиксируются атомарно; гонку двойного
Selfстрахует уникальный partial-индексux_persons_owner_self(конфликт →PROFILE_PERSON_SELF_ALREADY_EXISTS). - Результат.
PersonModel(документов 0, версия 1; значения ФИО/даты — из входного plaintext). HTTP 201 +Location: /api/v1/profile/persons/{id}.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | PersonUpsertedKafkaEvent / pin.profile.persons.v1 / key = PersonId (без PII) |
| External calls | криптосервис (ключи Vault) — шифрование ФИО/даты рождения; недоступность — fail-closed PROFILE_CRYPTO_UNAVAILABLE. Справочник handbook (internal resolve) — валидация citizenshipCountryId, только если поле задано; недоступность — fail-closed PROFILE_HANDBOOK_UNAVAILABLE |
| Cache / projections / search | none (поиск по шифрованным полям не ведётся) |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
ValidationError | 400 | Формат/длины/enum/даты | RestApiValidationErrorResponse | no retry |
PROFILE_NOT_FOUND | 404 | Профиль не создан консюмером | «Профиль ещё создаётся» | retry с backoff |
PROFILE_USER_BLOCKED | 400 | Status != Active (INV-PRF-3) | «Учётная запись заблокирована» | no retry |
PROFILE_MANAGED_EXTERNALLY | 403 | ContactsManagedBy = TalentSync (INV-PRS-3) | «Данные управляются внешней системой» | no retry |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | UserType != Investor (INV-PRS-2) | «Доступно только инвесторам» | no retry |
PROFILE_PERSONS_LIMIT_EXCEEDED | 400 | ≥ 20 активных персон (INV-PRS-7) | «Достигнут лимит персон (20)» | no retry |
PROFILE_PERSON_SELF_ALREADY_EXISTS | 409 | Активная Self уже есть (INV-PRS-1) | «Основная персона уже существует» | no retry |
PROFILE_COUNTRY_UNKNOWN | 422 | citizenshipCountryId не найден в справочнике стран или архивен | «Страна не найдена в справочнике» | no retry (исправить страну) |
PROFILE_HANDBOOK_UNAVAILABLE | 503 | Справочник недоступен при валидации citizenshipCountryId (fail-closed) | «Сервис временно недоступен» | retry |
PROFILE_CRYPTO_UNAVAILABLE | 503 | Ключи шифрования недоступны | «Сервис временно недоступен» | retry |
Совместимость и наблюдаемость
- Форма расширяется только optional-полями (additive); plaintext-значения PII никогда не появляются в логах/метриках/событиях.
- Logs:
UserId,PersonId,Relation(без значений ФИО/дат); metrics:profile_api_operations_total{operation="createPerson",result},profile_persons_created_total{relation},profile_crypto_errors_total; traces — OTel.
Acceptance Criteria
- Given инвестор с активным self-service-профилем и без активной
Self, WhenPOSTrelation = Selfс корректными ФИО иbirthDate, Then201+Location; персонаActive,DocumentsCount = 0,Version = 1; ФИО и дата рождения зашифрованы единымKeyVersion; опубликованPersonUpsertedKafkaEvent(pin.profile.persons.v1, keyPersonId, без PII). - Given уже есть активная
Self, WhenPOSTrelation = Self, ThenPROFILE_PERSON_SELF_ALREADY_EXISTS(409) — INV-PRS-1; персона не создана, событие не публикуется. - Given корректный
relation = Spouse, WhenPOST, Then201; вторая персона создана (несколько персон на аккаунт допустимо). - Given 20 активных персон, When
POST, ThenPROFILE_PERSONS_LIMIT_EXCEEDED(400) — INV-PRS-7. - Given
firstNameдлиной 0 или > 128 /birthDateвне[1900-01-01 .. сегодня]/ неизвестное значениеrelation, WhenPOST, ThenValidationError(400) — проверка на plaintext до шифрования; шифротекст и PII в ответ/лог не попадают. - Given
citizenshipCountryId, которого нет в справочнике стран (или страна архивна), WhenPOST, ThenPROFILE_COUNTRY_UNKNOWN(422); персона не создана. - Given справочник
handbookнедоступен иcitizenshipCountryIdзадан, WhenPOST, ThenPROFILE_HANDBOOK_UNAVAILABLE(503), fail-closed; безcitizenshipCountryIdоперация резолв не вызывает и проходит. - Given профиль под управлением внешней системы (
ContactsManagedBy = TalentSync), WhenPOST, ThenPROFILE_MANAGED_EXTERNALLY(403) — INV-PRS-3. - Given
UserType != Investor, WhenPOST, ThenPROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE(403) — INV-PRS-2. - Given профиль заблокирован (
Status != Active), WhenPOST, ThenPROFILE_USER_BLOCKED(400) — INV-PRF-3. - Given профиль ещё не создан консюмером identity, When
POST, ThenPROFILE_NOT_FOUND(404); клиент повторяет с backoff. - Given ключи шифрования недоступны, When
POST, ThenPROFILE_CRYPTO_UNAVAILABLE(503), fail-closed; персона не создана, retry. - Given повтор запроса с тем же
Idempotency-Key, WhenPOST, Then возвращается ранее сохранённый результат без создания дубля. - Given два конкурентных создания
Self, When обаPOST, Then одно201, второе —PROFILE_PERSON_SELF_ALREADY_EXISTS(409) (страховка уникальным partial-индексомux_persons_owner_self).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (3): GET /api/v1/profile/persons — Список персон аккаунта (listPersons), Profile API — реестр операций областей public / internal / admin, PUT /api/v1/profile/persons/{personId} — Обновление персоны (updatePerson)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность: person.md (INV-PRS-1..8,
EncryptedPersonName). - Guard-ы: profile.md (INV-PRF-2..4); событие — ../events/person-upserted.md.