Перейти к основному содержимому

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 areapublic
Feature groupdocuments
Статусdraft
Документdocs/06-services/profile/api/public/add-person-document.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (Bearer JWT), UserType = Investor
Auth policy / Permissionsauthenticated; permission profile.documents.manage-self
Scope (RBAC)self
Record-levelPersonEntity.OwnerUserId = UserId; чужой personId → 404
Tenant isolationне применяется (ADR-0052)
Auditcreate: CreatedByUserId = UserId, PersonDocumentAddedEvent (PersonId, DocumentId, Type, NumberMask)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/profile/persons/{personId}/documents
Success status201
Idempotency headerIdempotency-Key (опционально; двойной сабмит дополнительно гасится INV-DOC-4)
CorrelationOpenTelemetry trace (traceparent)

Входные данные / параметры запроса (body AddPersonDocumentForm)

Payload — полиморфная typed-модель: дискриминатор type выбирает модель полей; все модели и их ограничения (regex серий/номеров, диапазоны дат, контрольные цифры ИНН) — дом person-document.md, здесь не переописываются.

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity claims (sub)GuidДавалидный JWT401 без токена
personIdrouteGuidДаNotEmpty; активная персона владельцачужая → 404
typebodyPersonDocumentTypeДаenum (7 значений); иммутабелен после создания (INV-DOC-7)
countryIdbodyshortДассылка на справочник стран handbook; резолв через internal resolve (resolve-references.md), неизвестная/архивная → PROFILE_COUNTRY_UNKNOWN (INV-DOC-9); IsoCode2 резолвленной страны обязан соответствовать типу: RfPassport/InnRU, RbPassportBY, KzPassportKZ, CisPassport → AM/AZ/UZ/KG/TJ/MD/TM (INV-DOC-2); для ForeignPassport/Custom — любая страна
titlebodystring?Условнообязателен (1–256) для type = Custom (INV-DOC-3); для прочих игнорируется — генерируется
payloadbodytyped по typeДапо typed-модели дома (например RfPassportModel: Series ^\d{4}$, Number ^\d{6}$, DepartmentCode ^\d{3}-\d{3}$, IssueDate ≤ сегодня) — до шифрования (INV-DOC-1)PII → шифруется целиком
scansbodyList<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).

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (UserId = sub) с permission profile.documents.manage-self. Tenant не применяется (ADR-0052).
  2. Проверка входа. Валидный enum type; payload проверяется полиморфно по type (модели дома, INV-DOC-1); title обязателен для Custom (INV-DOC-3); scans — лимиты и whitelist contentType (INV-DOC-5) — иначе ValidationError. Всё — на plaintext до шифрования. Страна: countryId резолвится через internal resolve справочника handbook — страна не найдена или архивна → PROFILE_COUNTRY_UNKNOWN (422, INV-DOC-9); справочник недоступен → fail-closed PROFILE_HANDBOOK_UNAVAILABLE (503); IsoCode2 резолвленной страны сверяется с типом документа и CountryId внутри payload (INV-DOC-2) — иначе PROFILE_DOCUMENT_COUNTRY_MISMATCH; IsoCode2 запоминается для денормализации CountryIsoCode2.
  3. Проверка профиля и персоны. Профиль должен быть доступен 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).
  4. Вычисления и шифрование. 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).
  5. Инварианты добавления. Дубль по PersonId+Type+NumberHash среди активных → PROFILE_DOCUMENT_ALREADY_EXISTS (INV-DOC-4); ≥ 20 активных → PROFILE_DOCUMENTS_LIMIT_EXCEEDED (INV-DOC-8). Документ создаётся со Status = Active, Version = 1; у персоны DocumentsCount++.
  6. События. PersonUpsertedKafkaEvent (топик pin.profile.persons.v1, key PersonId): Id, OwnerUserId, Relation, Status, новый DocumentsCount, Versionбез PII (содержимое документов Kafka не покидает). Контракт — ../events/person-upserted.md.
  7. Согласованность. Персона, документ, публикация события и idempotency-запись фиксируются атомарно; гонку дублей страхует уникальный partial-индекс ux_person_documents_dedupe.
  8. Результат. PersonDocumentModel (эхо plaintext payload + открытые поля). HTTP 201 + Location: .../documents/{id}.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsPersonUpsertedKafkaEvent / pin.profile.persons.v1 / key = PersonId (без PII)
External callsstorage — проверка файлов сканов (timeout 5 с, без retry; ошибка → PROFILE_STORAGE_UNAVAILABLE); криптосервис — шифрование payload; справочник handbook (internal resolve) — резолв countryId (INV-DOC-2/9), недоступность → PROFILE_HANDBOOK_UNAVAILABLE
Cache / projections / searchnone
Notificationsnone

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Нет/просрочен JWTбез телаre-login
ValidationError400Payload не соответствует type / regex / сканы (INV-DOC-1/3/5)RestApiValidationErrorResponseno retry
PROFILE_NOT_FOUND404Профиль не создан«Профиль ещё создаётся»retry
PROFILE_USER_BLOCKED400Status != Active«Учётная запись заблокирована»no retry
PROFILE_MANAGED_EXTERNALLY403ContactsManagedBy = TalentSync«Данные управляются внешней системой»no retry
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE403не Investor«Доступно только инвесторам»no retry
PROFILE_PERSON_NOT_FOUND404Нет персоны / чужая«Персона не найдена»no retry
PROFILE_PERSON_ARCHIVED400Персона в архиве«Персона в архиве»no retry
PROFILE_DOCUMENT_COUNTRY_MISMATCH400IsoCode2 резолвленной страны не соответствует типу / CountryId payload ≠ открытому (INV-DOC-2)«Страна не соответствует типу документа»no retry
PROFILE_COUNTRY_UNKNOWN422countryId не найден в справочнике стран или архивен (INV-DOC-9)«Страна не найдена в справочнике»no retry (исправить страну)
PROFILE_DOCUMENT_ALREADY_EXISTS409Дубль по NumberHash (INV-DOC-4)«Такой документ уже добавлен»no retry
PROFILE_DOCUMENTS_LIMIT_EXCEEDED400≥ 20 активных (INV-DOC-8)«Достигнут лимит документов (20)»no retry
PROFILE_SCAN_FILE_NOT_FOUND400fileId не найден в storage / метаданные не совпали«Файл скана не найден»повторная загрузка файла
PROFILE_STORAGE_UNAVAILABLE502storage недоступен«Хранилище временно недоступно»retry
PROFILE_HANDBOOK_UNAVAILABLE503Справочник недоступен при резолве countryId (fail-closed)«Сервис временно недоступен»retry
PROFILE_CRYPTO_UNAVAILABLE503Ключи недоступны«Сервис временно недоступен»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; Then 201 + Location, документ Active/Version = 1, payload зашифрован (открыто — CountryId/CountryIsoCode2/NumberMask/NumberHash/даты/сканы), CountryIsoCode2 = IsoCode2 резолвленной страны, у персоны DocumentsCount++, опубликован PersonUpsertedKafkaEvent без PII.
  • Дубль. Given документ с тем же PersonId + Type + NumberHash уже активен; When add; Then 409 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; Then 400 PROFILE_DOCUMENT_COUNTRY_MISMATCH (INV-DOC-2).
  • Страна неизвестна. Given countryId, которого нет в справочнике стран (или страна архивна); When add; Then 422 PROFILE_COUNTRY_UNKNOWN (INV-DOC-9); документ не создан.
  • Справочник недоступен. Given internal resolve handbook недоступен; When add; Then 503 PROFILE_HANDBOOK_UNAVAILABLE, fail-closed; документ не создан, retry.
  • Custom без названия. Given type = Custom без title; When add; Then 400 ValidationError (INV-DOC-3).
  • Сканы вне лимитов. Given scans > 10, либо contentType вне whitelist, либо sizeBytes вне 1..20 971 520; When add; Then 400 ValidationError (INV-DOC-5).
  • Файл скана отсутствует. Given fileId нет в storage либо метаданные (sizeBytes/contentType) не совпали; When add; Then 400 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; Then 403 PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE (INV-PRF-4); архивная персона → 400 PROFILE_PERSON_ARCHIVED; заблокированный профиль → 400 PROFILE_USER_BLOCKED; ContactsManagedBy = TalentSync403 PROFILE_MANAGED_EXTERNALLY.

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы