GET /api/v1/profile/persons/{personId}/documents/{documentId}/scans/{fileId}/download-link — Presigned-ссылка на скан (getDocumentScanLink)
Назначение
Выдать короткоживущую presigned-ссылку на скачивание скана документа из сервиса storage. Файлы сканов
содержат PII и никогда не отдаются через profile напрямую — доступ гейтится этим методом (record-level
владельца) и TTL ссылки. Вызывает инвестор из ЛК для скачивания собственного скана.
| Поле | Значение |
|---|
| Сервис/контекст | profile |
| API area | public |
| Feature group | documents |
| Статус | draft |
| Документ | docs/06-services/profile/api/public/get-document-scan-link.md |
Актор и доступ
| Проверка | Значение |
|---|
| Actor type | User (Bearer JWT), UserType = Investor |
| Auth policy / Permissions | authenticated; permission profile.documents.view-self |
| Scope (RBAC) | self |
| Record-level | OwnerUserId = UserId; чужие id → 404 |
| Tenant isolation | не применяется (ADR-0052) |
| Audit | read-sensitive: каждая выдача ссылки логируется (UserId, DocumentId, FileId, TTL) — ссылка даёт доступ к PII-файлу |
HTTP-контракт
| Поле | Значение |
|---|
| Method | GET |
| Route | /api/v1/profile/persons/{personId}/documents/{documentId}/scans/{fileId}/download-link |
| Success status | 200 |
| Idempotency header | не применяется |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
personId | route | Guid | Да | NotEmpty | чужая → 404 |
documentId | route | Guid | Да | NotEmpty; документ персоны (включая архивный — просмотр истории допустим) | чужой → 404 |
fileId | route | Guid | Да | NotEmpty; присутствует в ScanList документа | иначе 404 |
Модель ответа DocumentScanLinkModel
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|
url | string | Да | storage GetDownloadFileLink | не логируется (содержит подпись) | Presigned-ссылка на скачивание. |
expiresAt | DateTimeOffset | Да | storage | — | Истечение ссылки (TTL 5 минут). |
fileName | string | Да | DocumentScan.FileName | — | Для сохранения файла клиентом. |
contentType | string | Да | DocumentScan.ContentType | — | MIME из whitelist. |
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (актор
User, UserId из токена); tenant не
применяется. Требуется право profile.documents.view-self.
- Проверка входа.
personId/documentId/fileId заданы. Нарушение → ValidationError.
- Проверка предусловий. Профиль владельца существует; иначе
PROFILE_NOT_FOUND. Тип пользователя —
Investor (иначе PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE, INV-PRF-4; чтение допустимо и для
TalentSync-инвестора, и для заблокированного — только просмотр собственных данных, мутаций нет).
Персона personId принадлежит владельцу; иначе PROFILE_PERSON_NOT_FOUND. Документ documentId
принадлежит персоне (включая архивный — история читается); иначе PROFILE_DOCUMENT_NOT_FOUND. Скан
fileId присутствует в ScanList документа; иначе PROFILE_SCAN_NOT_FOUND.
- Формирование ссылки. У storage запрашивается presigned-ссылка на файл
fileId с TTL 5 минут
(timeout 5 с, без retry); недоступность storage → PROFILE_STORAGE_UNAVAILABLE. В ответ маппятся
url, expiresAt (из storage), fileName, contentType (из метаданных скана).
- Результат. Модель
DocumentScanLinkModel; HTTP 200. Ссылка одноразового характера по TTL;
повторный запрос выдаёт новую. Значение url наружу отдаётся, но в логи не пишется.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|
| Внешние вызовы | storage: presigned-ссылка на файл fileId с TTL — timeout 5 с, без retry |
Ссылки не кэшируются (короткий TTL); интеграционных событий и уведомлений нет.
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
ValidationError | 400 | Пустые route-id | перечень невалидных полей | no retry |
PROFILE_NOT_FOUND | 404 | Профиль не создан | «Профиль ещё создаётся» | 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_SCAN_NOT_FOUND | 404 | fileId не в ScanList | «Скан не найден» | no retry |
PROFILE_STORAGE_UNAVAILABLE | 502 | storage недоступен / файл утрачен | «Хранилище временно недоступно» | retry |
Совместимость и наблюдаемость
- Контракт storage-границы — внешний сервис; изменение TTL — конфигурация, не контракт.
- Logs:
UserId, DocumentId, FileId, ttl (сам url — никогда); metrics:
profile_api_operations_total{operation="getDocumentScanLink",result},
profile_storage_calls_total; traces — OTel.
Acceptance Criteria
- Given активный документ владельца со сканом
fileId, When GET download-link, Then 200;
url (presigned, TTL 5 мин), expiresAt, fileName, contentType; факт выдачи логируется (UserId,
DocumentId, FileId, TTL), значение url в лог не пишется.
- Given архивный документ владельца со сканом (просмотр истории), When
GET, Then 200 —
чтение допустимо.
- Given
fileId отсутствует в ScanList документа, When GET, Then PROFILE_SCAN_NOT_FOUND
(404).
- Given
documentId чужой, When GET, Then PROFILE_DOCUMENT_NOT_FOUND (404); чужой
personId → PROFILE_PERSON_NOT_FOUND (404) — существование чужих id не раскрывается.
- Given storage не отвечает за 5 с или файл утрачен, When
GET, Then
PROFILE_STORAGE_UNAVAILABLE (502), retry.
- Given заблокированный или
TalentSync-инвестор запрашивает собственный скан, When GET, Then
200 (чтение своих данных допустимо, мутаций нет); UserType != Investor →
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE (403).
- Given повторный запрос ссылки, When два
GET, Then каждый раз выдаётся новая короткоживущая
ссылка (TTL 5 мин); ссылки не кэшируются.
Обратные ссылки
Автоссылки: где используется этот документ.
Связанные документы