POST /api/v1/profile/persons/{personId}/documents — Добавление документа персоны (addPersonDocument)
Назначение
Добавить персоне документ: паспорт РФ/РБ/Казахстана/прочих стран СНГ/иностранный (Грузия, Армения, ОАЭ и
др.), ИНН или кастомный документ с произвольными полями. Typed-модель полей (строго по type, дом —
person-document.md) проверяется на plaintext и шифруется целиком в
PayloadCipher (AES-256-GCM). Открыто хранятся только маска номера, HMAC-хэш (для поиска дублей), даты и
сканы (FileId storage). Документ добавляется в состав персоны PersonEntity
(person.md).
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | profile |
| API area | public |
| Feature group | documents |
| Статус | draft |
| Документ | docs/06-services/profile/api/public/add-person-document.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Bearer JWT), UserType = Investor |
| Auth policy / Permissions | authenticated; permission profile.documents.manage-self |
| Scope (RBAC) | self |
| Record-level | PersonEntity.OwnerUserId = UserId; чужой personId → 404 |
| Tenant isolation | не применяется (ADR-0052) |
| Audit | create: CreatedByUserId = UserId, PersonDocumentAddedEvent (PersonId, DocumentId, Type, NumberMask) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/profile/persons/{personId}/documents |
| Success status | 201 |
| Idempotency header | Idempotency-Key (опционально; двойной сабмит дополнительно гасится INV-DOC-4) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса (body AddPersonDocumentForm)
Payload — полиморфная typed-модель: дискриминатор type выбирает модель полей; все модели и их
ограничения (regex серий/номеров, диапазоны дат, контрольные цифры ИНН) — дом
person-document.md, здесь не переописываются.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
personId | route | Guid | Да | NotEmpty; активная персона владельца | чужая → 404 |
type | body | PersonDocumentType | Да | enum (7 значений); иммутабелен после создания (INV-DOC-7) | — |
countryId | body | short | Да | ссылка на справочник стран handbook; резолв через internal resolve (resolve-references.md), неизвестная/архивная → PROFILE_COUNTRY_UNKNOWN (INV-DOC-9); IsoCode2 резолвленной страны обязан соответствовать типу: RfPassport/Inn → RU, RbPassport → BY, KzPassport → KZ, CisPassport → AM/AZ/UZ/KG/TJ/MD/TM (INV-DOC-2); для ForeignPassport/Custom — любая страна | — |
title | body | string? | Условно | обязателен (1–256) для type = Custom (INV-DOC-3); для прочих игнорируется — генерируется | — |
payload | body | typed по type | Да | по typed-модели дома (например RfPassportModel: Series ^\d{4}$, Number ^\d{6}$, DepartmentCode ^\d{3}-\d{3}$, IssueDate ≤ сегодня) — до шифрования (INV-DOC-1) | PII → шифруется целиком |
scans | body | List<DocumentScanForm>? | Нет | ≤ 10 элементов (INV-DOC-5); элемент: fileId Guid NotEmpty, fileName 1–256, contentType из image/jpeg/image/png/image/webp/application/pdf, sizeBytes 1–20 971 520 | файлы — storage, метаданные открыто |
Модель ответа PersonDocumentModel
Дом модели — list-person-documents.md; возвращается созданный документ
(payload — эхо входного plaintext, version = 1).
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (
UserId=sub) с permissionprofile.documents.manage-self. Tenant не применяется (ADR-0052). - Проверка входа. Валидный enum
type; payload проверяется полиморфно поtype(модели дома, INV-DOC-1);titleобязателен дляCustom(INV-DOC-3);scans— лимиты и whitelistcontentType(INV-DOC-5) — иначеValidationError. Всё — на plaintext до шифрования. Страна:countryIdрезолвится через internal resolve справочникаhandbook— страна не найдена или архивна →PROFILE_COUNTRY_UNKNOWN(422, INV-DOC-9); справочник недоступен → fail-closedPROFILE_HANDBOOK_UNAVAILABLE(503);IsoCode2резолвленной страны сверяется с типом документа иCountryIdвнутри payload (INV-DOC-2) — иначеPROFILE_DOCUMENT_COUNTRY_MISMATCH;IsoCode2запоминается для денормализацииCountryIsoCode2. - Проверка профиля и персоны. Профиль должен быть доступен self-service:
Status = Active(INV-PRF-3 →PROFILE_USER_BLOCKED),ContactsManagedBy = SelfService(INV-PRF-2 →PROFILE_MANAGED_EXTERNALLY),UserType = Investor(INV-PRF-4 →PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE); отсутствие профиля →PROFILE_NOT_FOUND. Персона должна принадлежать владельцу — иначеPROFILE_PERSON_NOT_FOUND; активна — иначеPROFILE_PERSON_ARCHIVED. Если заданыscans— каждый файл существует в storage с совпадающимиsizeBytes/contentType, иначеPROFILE_SCAN_FILE_NOT_FOUND(storage недоступен →PROFILE_STORAGE_UNAVAILABLE). - Вычисления и шифрование.
normalizedNumber= серия+номер payload без пробелов, upper-case (дляCustomбез номера —Title);NumberHash = HMAC-SHA256(normalizedNumber)(выделенный ключ, не ключ шифрования);NumberMask= первые 2 + последние 3 символа, остальное*;Title= сгенерированный («Паспорт РФ •• 1234») либо пользовательский (Custom);IssueDate/ExpiresAt— открытые копии из payload;CountryIsoCode2=IsoCode2резолвленной страны (денормализация — юридический след, INV-DOC-9); payload сериализуется и шифруется целиком вPayloadCipher(fail-closed →PROFILE_CRYPTO_UNAVAILABLE). - Инварианты добавления. Дубль по
PersonId+Type+NumberHashсреди активных →PROFILE_DOCUMENT_ALREADY_EXISTS(INV-DOC-4); ≥ 20 активных →PROFILE_DOCUMENTS_LIMIT_EXCEEDED(INV-DOC-8). Документ создаётся соStatus = Active,Version = 1; у персоныDocumentsCount++. - События.
PersonUpsertedKafkaEvent(топикpin.profile.persons.v1, keyPersonId):Id,OwnerUserId,Relation,Status, новыйDocumentsCount,Version— без PII (содержимое документов Kafka не покидает). Контракт — ../events/person-upserted.md. - Согласованность. Персона, документ, публикация события и idempotency-запись фиксируются атомарно;
гонку дублей страхует уникальный partial-индекс
ux_person_documents_dedupe. - Результат.
PersonDocumentModel(эхо plaintext payload + открытые поля). HTTP 201 +Location: .../documents/{id}.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | PersonUpsertedKafkaEvent / pin.profile.persons.v1 / key = PersonId (без PII) |
| External calls | storage — проверка файлов сканов (timeout 5 с, без retry; ошибка → PROFILE_STORAGE_UNAVAILABLE); криптосервис — шифрование payload; справочник handbook (internal resolve) — резолв countryId (INV-DOC-2/9), недоступность → PROFILE_HANDBOOK_UNAVAILABLE |
| Cache / projections / search | none |
| Notifications | none |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
ValidationError | 400 | Payload не соответствует type / regex / сканы (INV-DOC-1/3/5) | RestApiValidationErrorResponse | no retry |
PROFILE_NOT_FOUND | 404 | Профиль не создан | «Профиль ещё создаётся» | retry |
PROFILE_USER_BLOCKED | 400 | Status != Active | «Учётная запись заблокирована» | no retry |
PROFILE_MANAGED_EXTERNALLY | 403 | ContactsManagedBy = TalentSync | «Данные управляются внешней системой» | no retry |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | не Investor | «Доступно только инвесторам» | no retry |
PROFILE_PERSON_NOT_FOUND | 404 | Нет персоны / чужая | «Персона не найдена» | no retry |
PROFILE_PERSON_ARCHIVED | 400 | Персона в архиве | «Персона в архиве» | no retry |
PROFILE_DOCUMENT_COUNTRY_MISMATCH | 400 | IsoCode2 резолвленной страны не соответствует типу / CountryId payload ≠ открытому (INV-DOC-2) | «Страна не соответствует типу документа» | no retry |
PROFILE_COUNTRY_UNKNOWN | 422 | countryId не найден в справочнике стран или архивен (INV-DOC-9) | «Страна не найдена в справочнике» | no retry (исправить страну) |
PROFILE_DOCUMENT_ALREADY_EXISTS | 409 | Дубль по NumberHash (INV-DOC-4) | «Такой документ уже добавлен» | no retry |
PROFILE_DOCUMENTS_LIMIT_EXCEEDED | 400 | ≥ 20 активных (INV-DOC-8) | «Достигнут лимит документов (20)» | no retry |
PROFILE_SCAN_FILE_NOT_FOUND | 400 | fileId не найден в storage / метаданные не совпали | «Файл скана не найден» | повторная загрузка файла |
PROFILE_STORAGE_UNAVAILABLE | 502 | storage недоступен | «Хранилище временно недоступно» | retry |
PROFILE_HANDBOOK_UNAVAILABLE | 503 | Справочник недоступен при резолве countryId (fail-closed) | «Сервис временно недоступен» | retry |
PROFILE_CRYPTO_UNAVAILABLE | 503 | Ключи недоступны | «Сервис временно недоступен» | retry |
Совместимость и наблюдаемость
- Новые типы документов — новый enum-элемент + typed-модель + миграция контракта (additive, дом);
breaking для клиентов старых версий отсутствует (unknown
typeне создаётся старым клиентом). - Logs:
UserId,PersonId,DocumentId,Type(значения payload и полный номер — никогда, толькоNumberMask); metrics:profile_api_operations_total{operation="addPersonDocument",result},profile_documents_created_total{type}; traces — OTel.
Acceptance Criteria
- Happy path. Given активная персона владельца,
UserType = Investor, self-service профиль, валидныйpayloadпоtype,countryIdрезолвится в справочнике; When add; Then201+Location, документActive/Version = 1, payload зашифрован (открыто —CountryId/CountryIsoCode2/NumberMask/NumberHash/даты/сканы),CountryIsoCode2=IsoCode2резолвленной страны, у персоныDocumentsCount++, опубликованPersonUpsertedKafkaEventбез PII. - Дубль. Given документ с тем же
PersonId + Type + NumberHashуже активен; When add; Then409 PROFILE_DOCUMENT_ALREADY_EXISTS(INV-DOC-4). - Лимит. Given у персоны уже 20 активных документов; When add; Then
400 PROFILE_DOCUMENTS_LIMIT_EXCEEDED(INV-DOC-8). - Страна не по типу. Given
type = RfPassportиcountryIdстраны сIsoCode2 ≠ 'RU'(аналогичноRbPassport/BY,KzPassport/KZ,Inn/RU,CisPassport/страны СНГ); When add; Then400 PROFILE_DOCUMENT_COUNTRY_MISMATCH(INV-DOC-2). - Страна неизвестна. Given
countryId, которого нет в справочнике стран (или страна архивна); When add; Then422 PROFILE_COUNTRY_UNKNOWN(INV-DOC-9); документ не создан. - Справочник недоступен. Given internal resolve
handbookнедоступен; When add; Then503 PROFILE_HANDBOOK_UNAVAILABLE, fail-closed; документ не создан, retry. - Custom без названия. Given
type = Customбезtitle; When add; Then400 ValidationError(INV-DOC-3). - Сканы вне лимитов. Given
scans> 10, либоcontentTypeвне whitelist, либоsizeBytesвне1..20 971 520; When add; Then400 ValidationError(INV-DOC-5). - Файл скана отсутствует. Given
fileIdнет в storage либо метаданные (sizeBytes/contentType) не совпали; When add; Then400 PROFILE_SCAN_FILE_NOT_FOUND; storage недоступен →502 PROFILE_STORAGE_UNAVAILABLE. - Ключи недоступны. Given хранилище ключей недоступно (fail-closed); When add; Then
503 PROFILE_CRYPTO_UNAVAILABLE, документ не создаётся. - Идемпотентный повтор. Given тот же
Idempotency-Key; When повторный сабмит; Then тот же результат, второй документ не создаётся (страховка — INV-DOC-4 + уникальный partial-индекс). - Гейт по типу пользователя. Given
UserType != Investor; When add; Then403 PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE(INV-PRF-4); архивная персона →400 PROFILE_PERSON_ARCHIVED; заблокированный профиль →400 PROFILE_USER_BLOCKED;ContactsManagedBy = TalentSync→403 PROFILE_MANAGED_EXTERNALLY.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (3): GET /api/v1/profile/persons/{personId}/documents — Список документов персоны (listPersonDocuments), Profile API — реестр операций областей public / internal / admin, PUT /api/v1/profile/persons/{personId}/documents/{documentId} — Обновление документа персоны (updatePersonDocument)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность: person-document.md (typed-модели, INV-DOC-1..8, маска/хэш).
- Root: person.md (
AddDocument,DocumentsCount). - Сканы: attach-document-scan.md; storage-граница — prototype-mapping.md.