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

POST /api/v1/profile/persons/{personId}/documents/{documentId}/scans — Прикрепление скана документа (attachDocumentScan)

Назначение

Прикрепить к документу персоны скан/фото. Файл предварительно загружается клиентом в сервис storage (двухфазная схема: storage выдаёт upload-ссылку и FileId); profile хранит только метаданные DocumentScan в plaintext jsonb ScanList (person-document.md) и проверяет файл в storage перед привязкой. Лимиты: ≤ 10 сканов на документ, ≤ 20 МБ, whitelist ContentType (INV-DOC-5).

Metadata

ПолеЗначение
Сервис/контекстprofile
API areapublic
Feature groupdocuments
Статусdraft
Документdocs/06-services/profile/api/public/attach-document-scan.md

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

ПроверкаЗначение
Actor typeUser (Bearer JWT), UserType = Investor
Auth policy / Permissionsauthenticated; permission profile.documents.manage-self
Scope (RBAC)self
Record-levelOwnerUserId = UserId; чужие id → 404
Tenant isolationне применяется (ADR-0052)
Auditupdate: UpdatedByUserId = UserId, Version++; файл — untrusted input (валидация типа/размера на storage + whitelist здесь)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/profile/persons/{personId}/documents/{documentId}/scans
Success status200
Idempotency headerне применяется (повтор с тем же fileId — идемпотентный no-op)
CorrelationOpenTelemetry trace (traceparent)

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
UserIdidentity claims (sub)GuidДавалидный JWT401 без токена
personIdrouteGuidДаNotEmptyчужая → 404
documentIdrouteGuidДаNotEmpty; активный документ персонычужой → 404
fileIdbodyGuidДаNotEmpty; файл существует в storage и загружен этим пользователемсверка владельца в storage
fileNamebodystringДа1–256 символов; без path-компонентов (/, \)untrusted — санитизация
contentTypebodystringДаиз whitelist: image/jpeg, image/png, image/webp, application/pdf (INV-DOC-5)сверяется с storage
sizeBytesbodylongДа1 – 20 971 520 (20 МБ)сверяется с storage

Модель ответа PersonDocumentModel

Дом модели — list-person-documents.md; возвращается документ с обновлённым scans (payload расшифровывается как в list — форма остаётся заполненной).

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (UserId = sub) с permission profile.documents.manage-self. Tenant не применяется (ADR-0052).
  2. Проверка входа. whitelist contentType, диапазон sizeBytes (1–20 971 520), санитизация fileName (без path-компонентов) — иначе ValidationError.
  3. Проверка профиля, персоны, документа и файла. Профиль self-service (guard-ы INV-PRF-2..4; отсутствие → PROFILE_NOT_FOUND). Персона должна принадлежать владельцу — иначе PROFILE_PERSON_NOT_FOUND. Документ существует у персоны — иначе PROFILE_DOCUMENT_NOT_FOUND; активен — иначе PROFILE_DOCUMENT_ARCHIVED; fileId уже в списке сканов → идемпотентный успех. Файл должен существовать в storage, принадлежать пользователю, а sizeBytes/contentType — совпадать с метаданными storage (подмена клиентом отклоняется) — иначе PROFILE_SCAN_FILE_NOT_FOUND (storage недоступен → PROFILE_STORAGE_UNAVAILABLE).
  4. Добавление скана. INV-DOC-5: ≥ 10 сканов на документ → PROFILE_SCANS_LIMIT_EXCEEDED. В ScanList (ContractVersion = 1) добавляется элемент DocumentScan(fileId, fileName, contentType, sizeBytes, uploadedAt = now). PayloadCipher не трогается (сканы — plaintext jsonb, PII — в файлах storage за presigned-ссылками).
  5. События. PersonUpsertedKafkaEvent (топик pin.profile.persons.v1, key PersonId; без PII).
  6. Согласованность. Обновление ScanList, персона и публикация события фиксируются атомарно; гонка версии → PROFILE_CONCURRENCY_CONFLICT (409).
  7. Результат. PersonDocumentModel (расшифрованный payload + обновлённый scans). HTTP 200.

Диаграмма

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

ТипДетали
Integration eventsPersonUpsertedKafkaEvent / pin.profile.persons.v1 / key = PersonId
External callsstorage — проверка файла скана (timeout 5 с, без retry; недоступность → PROFILE_STORAGE_UNAVAILABLE)
Cache / projections / searchnone
Notificationsnone

После коммита событие персоны потребляется асинхронно контекстом crm (SLA < 5 мин, person-upserted.md): незавершённые брони/сделки, чей снапшот реквизитов ссылается на эту персону, помечаются «реквизиты могли устареть», и на следующем шаге сделки CRM предлагает повторный запрос снапшота; иммутабельные снапшоты и завершённые сделки не изменяются. В окне до догона консюмера пользователь CRM ещё видит прежнее состояние.

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Нет/просрочен JWTбез телаre-login
ValidationError400contentType вне whitelist / размер / fileNameRestApiValidationErrorResponseno retry
PROFILE_NOT_FOUND404Профиль не создан«Профиль ещё создаётся»retry
PROFILE_USER_BLOCKED400Status != Active«Учётная запись заблокирована»no retry
PROFILE_MANAGED_EXTERNALLY403TalentSync«Данные управляются внешней системой»no retry
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE403не Investor«Доступно только инвесторам»no retry
PROFILE_PERSON_NOT_FOUND404Нет персоны / чужая«Персона не найдена»no retry
PROFILE_DOCUMENT_NOT_FOUND404Нет документа«Документ не найден»no retry
PROFILE_DOCUMENT_ARCHIVED400Документ в архиве«Документ в архиве»no retry
PROFILE_SCAN_FILE_NOT_FOUND400Файл не найден в storage / чужой / метаданные не совпали«Файл не найден — загрузите заново»повторная загрузка
PROFILE_SCANS_LIMIT_EXCEEDED400≥ 10 сканов (INV-DOC-5)«Достигнут лимит сканов (10)»no retry
PROFILE_STORAGE_UNAVAILABLE502storage недоступен«Хранилище временно недоступно»retry
PROFILE_CONCURRENCY_CONFLICT409Гонка (xmin)«Данные изменены, повторите»retry

Совместимость и наблюдаемость

  • Whitelist contentType расширяется только additive; модель DocumentScan — versioned (DocumentScanList.ContractVersion, дом person-document.md).
  • Logs: UserId, DocumentId, FileId, sizeBytes (имя файла — не логируется: может содержать PII); metrics: profile_api_operations_total{operation="attachDocumentScan",result}, profile_storage_calls_total{operation,result}; traces — OTel.

Acceptance Criteria

  • Given активный документ персоны с 2 сканами, When POST с валидным новым fileId (существует в storage, загружен этим пользователем, sizeBytes/contentType совпадают с метаданными storage), Then 200; элемент добавлен в ScanList (uploadedAt = now), сканов стало 3; опубликован PersonUpsertedKafkaEvent (pin.profile.persons.v1, без PII).
  • Given fileId уже присутствует в ScanList, When повторный POST, Then 200 идемпотентный no-op; ScanList не изменён, нового события нет.
  • Given contentType вне whitelist (напр. image/gif) / sizeBytes > 20 971 520 / fileName с path-компонентами, When POST, Then ValidationError (400) — INV-DOC-5.
  • Given документ уже содержит 10 сканов, When POST 11-го, Then PROFILE_SCANS_LIMIT_EXCEEDED (400) — INV-DOC-5.
  • Given fileId отсутствует в storage / принадлежит другому пользователю / метаданные клиента не совпали с storage (подмена размера или типа), When POST, Then PROFILE_SCAN_FILE_NOT_FOUND (400); повторная загрузка.
  • Given storage не отвечает за 5 с, When POST, Then PROFILE_STORAGE_UNAVAILABLE (502), retry; скан не привязан.
  • Given документ в архиве, When POST, Then PROFILE_DOCUMENT_ARCHIVED (400).
  • Given personId/documentId принадлежат другому пользователю, When POST, Then PROFILE_PERSON_NOT_FOUND / PROFILE_DOCUMENT_NOT_FOUND (404) — существование чужих id не раскрывается.
  • Given UserType != Investor, When POST, Then PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE (403).
  • Given конкурентная правка ScanList (конфликт версии), When POST, Then PROFILE_CONCURRENCY_CONFLICT (409), retry.

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

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

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