GET /api/investor/crm/deals/{dealId}/documents/{documentId}/download — Скачать документ сделки
Инвестор скачивает договор или иной документ своей сделки (US-INV-13: «скачать файл, у файла — дата»):
операция возвращает короткоживущую пресайн-ссылку storage/CDN на бинарь по FileId документа. CRM
хранит только метаданные и FileId; сам файл живёт во внешнем storage. Дом полей документа —
deal-document.md.
Назначение
Дать участнику-инвестору по паре {dealId, documentId} временный URL для скачивания загруженного файла
(договор бронирования, договор ДДУ/ДКП, чек и т. п.) без раскрытия постоянной ссылки на storage. Ссылка
пресайнится на короткий срок и в логи не пишется. PII/Sensitive типы (ClientPassport,
ContractDraftDocument) выдаются только владельцу-инвестору сделки (и Менеджеру ПИН), согласно
deal-document.md.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | investor (JWT инвестора) |
| Feature group | deal-documents |
| Статус | draft |
| Документ | docs/06-services/crm/api/investor/download-deal-document.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Инвестор — участник-владелец сделки); Operator (Менеджер ПИН — platform-scope) |
| Auth policy / Permissions | crm.deal-documents.view (JWT) |
| Scope (RBAC) | запрос не tenanted (Deal.TenantId = DeveloperCompanyId ≠ актор): чтение инвесторской стороны по явному record-level предикату (ADR-0052) |
| Record-level | актор — активный InvestorPrimary-участник сделки (deal.PrimaryInvestorUserId == ActorUserId, RemovedAt is null); для sensitive-типов — доп. правило INV-DOC |
| Tenant isolation | TenantId пресайна = document.TenantId (DeveloperCompanyId), а не актора; передаётся в storage-адаптер |
| Audit | read-sensitive (скачивание документа: DocumentId, DealId, ActorUserId, Type); DownloadUrl/FileName в логи не пишутся, только Id |
Запрос не tenanted — доступ проверяется record-level (ADR-0052): актор должен быть активным
InvestorPrimary-участником сделки (deal.PrimaryInvestorUserId == ActorUserId, RemovedAt is null) либо
PinManager (platform-scope, record-level фильтр снят). Для sensitive-типов документов (ClientPassport,
ContractDraftDocument) действует дополнительное правило INV-DOC — доступ только
{InvestorPrimary(владелец), Executor, SellManager, PinManager}.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | GET |
| Route | /api/investor/crm/deals/{dealId}/documents/{documentId}/download |
| Success status | 200 (тело — DownloadLinkModel; редирект не используется, ссылку применяет клиент) |
| Idempotency header | не применяется (GET — безопасный, семантически идемпотентный) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
DealId | route | Guid | Да | != Guid.Empty | — |
DocumentId | route | Guid | Да | != Guid.Empty; документ должен принадлежать DealId | — |
ActorUserId | identity claims | Guid | Да | JWT инвестора | record-level (участник-владелец) |
IsPinManager | identity claims (scope permission) | bool | Да | вычисляется из permission; снимает фильтр PrimaryInvestorUserId = actor (platform-scope) | — |
Модель ответа DownloadLinkModel
Проекция storage-ответа + метаданные документа (дом полей — deal-document.md);
дельта ниже. DownloadUrl — секрет короткого действия, не кэшируется и не логируется.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
DocumentId | Guid | Да | DealDocumentEntity.Id | — | Идентификатор документа. |
Type | string (enum DealDocumentType) | Да | Type | — | Тип документа (enum строкой). |
Status | string (enum DealDocumentStatus) | Да | Status | — | Uploaded/UnderReview/Accepted/Rejected. |
FileName | string | Да | FileName | не логируется | Оригинальное имя файла (US-INV-13). |
ContentType | string | Да | ContentType | — | MIME (application/pdf/image/jpeg/image/png). |
SizeBytes | long | Да | SizeBytes | — | Размер файла. |
UploadedAt | DateTimeOffset | Да | UploadedAt | — | Дата загрузки (US-INV-13: «дата у файла»). |
DownloadUrl | string | Да | пресайн storage | не логируется | Пресайн-ссылка storage/CDN на скачивание. |
ExpiresAt | DateTimeOffset | Да | пресайн storage | — | Момент истечения пресайн-ссылки (короткоживущая). |
DownloadUrl и FileName — секреты/PII короткого действия: не логируются, не кэшируются; пресайн-ссылка
короткоживущая (ExpiresAt). Скачивание не создаёт новых записей и не меняет статус документа (INV-DOC-4 —
новую версию порождает только повторная загрузка).
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (permission
crm.deal-documents.view) либо Менеджер ПИН (platform-scope);ActorUserId,IsPinManager— из токена. Запрос не tenanted (у актора нетTenantId = DeveloperCompanyIdсделки) — доступ режется record-level. - Проверка входа и предусловий.
DealId,DocumentIdне пустые, иначеVALIDATION_FAILED. Существование, принадлежность, состояние и участие проверяются ниже. - Что читаем. Документ по
DocumentId; если его нет или удалён →CRM_DOCUMENT_NOT_FOUND. Принадлежность маршруту:document.DealId == DealId, иначеCRM_DOCUMENT_NOT_FOUND(документ чужой сделки не раскрывается тем же кодом). Сделку (с активными участниками,RemovedAt is null) — для проверки участия; если её нет или удалена →CRM_DEAL_NOT_FOUND. - Бизнес-правила.
- участие (record-level):
IsPinManagerилиdeal.PrimaryInvestorUserId == ActorUserId(актор — активныйInvestorPrimary-участник); иначеFORBIDDEN(403) — чужая сделка не раскрывается; - состояние документа (Uploaded+):
document.Status ∈ {Uploaded, UnderReview, Accepted, Rejected}(у всех естьFileId); статусRequested(файла нет) →CRM_DOCUMENT_NOT_UPLOADED(409); - PII/Sensitive (INV-DOC): если
document.Type ∈ {ClientPassport, ContractDraftDocument}— доступ только ролям{InvestorPrimary(владелец), Executor, SellManager, PinManager}(deal-document.md); на этом endpoint актор — владелец-InvestorPrimaryлибоPinManager, оба проходят; иначе →CRM_DOCUMENT_SENSITIVE_FORBIDDEN(403); - маппинг метаданных
DownloadLinkModel(DocumentId/Type/Status/FileName/ContentType/SizeBytes/UploadedAt) — 1:1 из документа.
- участие (record-level):
- Результат. Пресайн-ссылка запрашивается у storage/CDN по (
document.TenantId,document.FileId) →{DownloadUrl, ExpiresAt}; сбой/таймаут storage →CRM_STORAGE_UNAVAILABLE(503). Ответ —DownloadLinkModel(метаданные шага 4 +DownloadUrl/ExpiresAt),200. Файл и статусы не меняются (скачивание новой записи не создаёт, INV-DOC-4 — новую версию порождает только повторная загрузка).
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (read-only GET; скачивание событий не публикует) |
| External calls | storage/CDN пресайн (tenantId + fileId → presignedDownloadUrl); timeout 3 c, retry 1; сбой → CRM_STORAGE_UNAVAILABLE |
| Cache / projections / search | none (пресайн-ссылка короткоживущая, не кэшируется) |
| Notifications | none |
Ошибки и failure modes
Чтобы не раскрывать чужую сделку: отсутствие участия у актора отвечает 403 FORBIDDEN, а документ другой
сделки (или несуществующий/удалённый) — 404 CRM_DOCUMENT_NOT_FOUND тем же кодом, без различия между
«не существует» и «принадлежит другому dealId».
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | DealId/DocumentId = Guid.Empty | «Некорректный идентификатор» | no retry |
FORBIDDEN | 403 | актор — не участник-владелец сделки | «Нет доступа к документу» | no retry |
CRM_DOCUMENT_SENSITIVE_FORBIDDEN | 403 | sensitive-тип (ClientPassport/ContractDraftDocument), роль вне допустимого набора (INV-DOC) | «Нет доступа к документу» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделка не найдена / удалена | «Сделка не найдена» | no retry |
CRM_DOCUMENT_NOT_FOUND | 404 | документ не найден, удалён или не принадлежит DealId | «Документ не найден» | no retry |
CRM_DOCUMENT_NOT_UPLOADED | 409 | Status = Requested (файл ещё не загружен) | «Файл ещё не загружен» | no retry (дождаться загрузки) |
CRM_STORAGE_UNAVAILABLE | 503 | storage/CDN недоступен | «Сервис файлов временно недоступен» | retry |
Acceptance Criteria
- Given инвестор — активный
InvestorPrimary-участник сделки, документ статусаUploaded+принадлежитDealId, When download, Then возвращаетсяDownloadLinkModelс короткоживущей пресайн-ссылкой иExpiresAt, статусы и записи документа не меняются (INV-DOC-4), ответ200,DownloadUrl/FileNameне логируются. - Given
document.Status = Requested(файла нет), When download, ThenCRM_DOCUMENT_NOT_UPLOADED(409). - Given актор не владелец сделки и не
PinManager, When download, ThenFORBIDDEN(403) — чужая сделка не раскрывается. - Given
Type ∈ {ClientPassport, ContractDraftDocument}и роль актора вне допустимого набора, When download, ThenCRM_DOCUMENT_SENSITIVE_FORBIDDEN(403) по INV-DOC. - Given
document.DealId != DealIdлибо документ удалён/не существует, When download, ThenCRM_DOCUMENT_NOT_FOUND(404); сделка отсутствует/удалена →CRM_DEAL_NOT_FOUND(404). - Given пресайн у storage/CDN не получен после 1 retry (таймаут 3 c), When download, Then
CRM_STORAGE_UNAVAILABLE(503); клиент повторяет. - Given актор —
PinManager(platform-scope), When download, Then record-level фильтрPrimaryInvestorUserId = actorснят, ссылка выдаётся при валидном документе.
Совместимость и наблюдаемость
- Новые optional поля
DownloadLinkModel— additive (без breaking change); удаление/переименование/смена типа — breaking (migration + rollout). Enum-поля (DealDocumentType/DealDocumentStatus) сериализуются строкой; клиент обязан толерантно обрабатывать неизвестное значение. - Logs:
deal-document download requested(DocumentId,DealId,ActorUserId,Type;DownloadUrl/FileNameне пишутся — read-sensitive); metrics:crm_deal_document_download_total{result}, latency p95 (включая storage-вызов); traces — OTel, correlation черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: deal-document.md (типы, статусы, INV-DOC-1..6, правило PII/Sensitive и пресайн-скачивания), deal.md (участники, record-level).
- Загрузка файла застройщиком (обратный поток): upload-booking-contract.md; смена статуса документа — событие document-status-changed.md.
- Карта областей — README контекста. Tenancy/record-level: ADR-0052.