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).
| Поле | Значение |
|---|
| Сервис/контекст | profile |
| API area | public |
| Feature group | documents |
| Статус | draft |
| Документ | docs/06-services/profile/api/public/attach-document-scan.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT), UserType = Investor |
| Auth policy / Permissions | authenticated; permission profile.documents.manage-self |
| Scope (RBAC) | self |
| Record-level | OwnerUserId = UserId; чужие id → 404 |
| Tenant isolation | не применяется (ADR-0052) |
| Audit | update: UpdatedByUserId = UserId, Version++; файл — untrusted input (валидация типа/размера на storage + whitelist здесь) |
HTTP-контракт
| Поле | Значение |
|---|
| Method | POST |
| Route | /api/v1/profile/persons/{personId}/documents/{documentId}/scans |
| Success status | 200 |
| Idempotency header | не применяется (повтор с тем же fileId — идемпотентный no-op) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса (body AttachDocumentScanForm)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
personId | route | Guid | Да | NotEmpty | чужая → 404 |
documentId | route | Guid | Да | NotEmpty; активный документ персоны | чужой → 404 |
fileId | body | Guid | Да | NotEmpty; файл существует в storage и загружен этим пользователем | сверка владельца в storage |
fileName | body | string | Да | 1–256 символов; без path-компонентов (/, \) | untrusted — санитизация |
contentType | body | string | Да | из whitelist: image/jpeg, image/png, image/webp, application/pdf (INV-DOC-5) | сверяется с storage |
sizeBytes | body | long | Да | 1 – 20 971 520 (20 МБ) | сверяется с storage |
Модель ответа PersonDocumentModel
Дом модели — list-person-documents.md; возвращается документ с обновлённым
scans (payload расшифровывается как в list — форма остаётся заполненной).
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (
UserId = sub) с permission
profile.documents.manage-self. Tenant не применяется (ADR-0052).
- Проверка входа. whitelist
contentType, диапазон sizeBytes (1–20 971 520), санитизация fileName
(без path-компонентов) — иначе ValidationError.
- Проверка профиля, персоны, документа и файла. Профиль 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).
- Добавление скана. INV-DOC-5: ≥ 10 сканов на документ →
PROFILE_SCANS_LIMIT_EXCEEDED. В ScanList
(ContractVersion = 1) добавляется элемент DocumentScan(fileId, fileName, contentType, sizeBytes, uploadedAt = now). PayloadCipher не трогается (сканы — plaintext jsonb, PII — в файлах storage за
presigned-ссылками).
- События.
PersonUpsertedKafkaEvent (топик pin.profile.persons.v1, key PersonId; без PII).
- Согласованность. Обновление
ScanList, персона и публикация события фиксируются атомарно; гонка версии →
PROFILE_CONCURRENCY_CONFLICT (409).
- Результат.
PersonDocumentModel (расшифрованный payload + обновлённый scans). HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Integration events | PersonUpsertedKafkaEvent / pin.profile.persons.v1 / key = PersonId |
| External calls | storage — проверка файла скана (timeout 5 с, без retry; недоступность → PROFILE_STORAGE_UNAVAILABLE) |
| Cache / projections / search | none |
| Notifications | none |
После коммита событие персоны потребляется асинхронно контекстом crm (SLA < 5 мин,
person-upserted.md): незавершённые брони/сделки, чей снапшот реквизитов
ссылается на эту персону, помечаются «реквизиты могли устареть», и на следующем шаге сделки CRM предлагает
повторный запрос снапшота; иммутабельные снапшоты и завершённые сделки не изменяются. В окне до догона
консюмера пользователь CRM ещё видит прежнее состояние.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
ValidationError | 400 | contentType вне whitelist / размер / fileName | RestApiValidationErrorResponse | no retry |
PROFILE_NOT_FOUND | 404 | Профиль не создан | «Профиль ещё создаётся» | retry |
PROFILE_USER_BLOCKED | 400 | Status != Active | «Учётная запись заблокирована» | no retry |
PROFILE_MANAGED_EXTERNALLY | 403 | TalentSync | «Данные управляются внешней системой» | no retry |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | не Investor | «Доступно только инвесторам» | no retry |
PROFILE_PERSON_NOT_FOUND | 404 | Нет персоны / чужая | «Персона не найдена» | no retry |
PROFILE_DOCUMENT_NOT_FOUND | 404 | Нет документа | «Документ не найден» | no retry |
PROFILE_DOCUMENT_ARCHIVED | 400 | Документ в архиве | «Документ в архиве» | no retry |
PROFILE_SCAN_FILE_NOT_FOUND | 400 | Файл не найден в storage / чужой / метаданные не совпали | «Файл не найден — загрузите заново» | повторная загрузка |
PROFILE_SCANS_LIMIT_EXCEEDED | 400 | ≥ 10 сканов (INV-DOC-5) | «Достигнут лимит сканов (10)» | no retry |
PROFILE_STORAGE_UNAVAILABLE | 502 | storage недоступен | «Хранилище временно недоступно» | retry |
PROFILE_CONCURRENCY_CONFLICT | 409 | Гонка (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.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы