feat(files): GET /files/{id}/content — исходный файл потоком из S3, RFC 5987 (D-10)

Поток из S3 порциями без чтения в память; нет объекта — 404 FILE_CONTENT_NOT_FOUND до заголовков;
при обрыве потребителя ответ S3 закрывается и отпускается. Content-Disposition вынесен в common/filenames.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-07 12:55:00 +04:00
parent 68b8a3d2f7
commit 310e63224d
11 changed files with 334 additions and 16 deletions

View File

@ -755,6 +755,7 @@ email без учёта регистра проверяет только сер
| `FILE_SUPERSEDED` | 409 | подтверждение: документ файла, заменённого новой версией |
| `DOCUMENT_NOT_CONFIRMABLE` | 409 | подтверждение: статус документа не `RECOGNIZED`/`NEEDS_CHECK` (например, `ERROR`) |
| `FILE_HAS_CONFIRMED_DOCUMENTS` | 409 | удаление файла с `CONFIRMED` документом |
| `FILE_CONTENT_NOT_FOUND` | 404 | `GET /files/{id}/content`: файл есть, объекта нет в S3 (истёк или пропал) |
| `FILE_SUPERSEDES_OTHERS` | 409 | удаление файла, на который ссылаются `superseded_by_file_id` других файлов |
| `CONSOLIDATION_EXISTS` | 409 | консолидация этой ГК за год и квартал уже создана (в т. ч. гонка двух `POST /consolidations`) |
| `PERIMETER_TOO_SMALL` | 422 | в ГСК (при создании) или в новом периметре (`PUT …/members`) меньше двух ЮЛ; ничего не создаётся и не меняется |

View File

@ -5,19 +5,20 @@
import uuid
from typing import Annotated, Any
from urllib.parse import quote
from fastapi import APIRouter, Depends, Query, Response, status
from src.api.dependencies.audit import RequestAudit, get_request_audit
from src.api.dependencies.auth import require
from src.api.dependencies.consolidations import ConsolidatedOsvServiceDep
from src.common.filenames import content_disposition
from src.schemas.common import ErrorDetail
from src.schemas.consolidations import OsvFilters
router = APIRouter(prefix="/consolidations/versions")
XLSX_MEDIA_TYPE = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
EXPORT_ASCII_NAME = "svod-osv.xlsx"
VIEW = [Depends(require("CONSOLIDATION_VIEW"))]
RESPONSES: dict[int | str, dict[str, Any]] = {
status.HTTP_200_OK: {
@ -28,12 +29,6 @@ RESPONSES: dict[int | str, dict[str, Any]] = {
}
def content_disposition(filename: str) -> str:
"""Заголовок вложения: ASCII-имя по умолчанию и `filename*` (RFC 5987) с полным percent-кодированием имени —
пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются, заголовок остаётся чистым ASCII."""
return f"attachment; filename=\"svod-osv.xlsx\"; filename*=UTF-8''{quote(filename, safe='')}"
@router.get(
"/{version_id}/osv/export",
response_class=Response,
@ -50,6 +45,5 @@ async def export_osv(
"""Файл `svod-osv_<ГК>_<год>-<квартал>_v<n>.xlsx`: строки свода после фильтров (как у `GET …/osv`, без
пагинации, в том же порядке) и лист пробелов по своду целиком. Пишет `audit_event(EXPORT)`."""
content, filename = await service.export(version_id, filters, audit)
return Response(
content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": content_disposition(filename)}
)
header = content_disposition(filename, ascii_fallback=EXPORT_ASCII_NAME)
return Response(content=content, media_type=XLSX_MEDIA_TYPE, headers={"Content-Disposition": header})

View File

@ -1,6 +1,7 @@
"""`GET /legal-entities/{legal_entity_id}/files`, `GET /files/{file_id}` — файлы организации и карточка файла
(spec documents-api «Файлы организации и карточка файла»; design D-11); `DELETE /files/{file_id}` — удаление файла
(spec primary-files «Удаление файла»; design D-10).
(spec primary-files «Удаление файла»; design D-10); `GET /files/{file_id}/content` — исходный файл потоком из S3
(spec documents-api «Исходный файл»; design D-10).
Право `DOCUMENT_VIEW` (удаление — `DOCUMENT_EDIT`) проверяется до видимости; ЮЛ или файл вне видимости — тот же 404,
что несуществующий.
@ -8,9 +9,10 @@
"""
import uuid
from typing import Annotated, Any
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, Query, status
from fastapi.responses import StreamingResponse
from src.api.dependencies.auth import require
from src.api.dependencies.files import PrimaryFilesServiceDep
@ -66,6 +68,36 @@ async def get_file(file_id: uuid.UUID, service: PrimaryFilesServiceDep) -> FileO
return await service.get_file(file_id)
CONTENT_RESPONSES: dict[int | str, dict[str, Any]] = {
status.HTTP_200_OK: {
"description": "содержимое исходного файла (тип — по `file_type`)",
"content": {"application/octet-stream": {"schema": {"type": "string", "format": "binary"}}},
},
status.HTTP_404_NOT_FOUND: {
"model": ErrorDetail,
"description": "файл нет или вне видимости; FILE_CONTENT_NOT_FOUND — объекта нет в хранилище",
},
status.HTTP_503_SERVICE_UNAVAILABLE: {"model": ErrorDetail, "description": "STORAGE_UNAVAILABLE: S3 не подключено"},
}
@router.get(
"/files/{file_id}/content",
response_class=StreamingResponse,
dependencies=VIEW,
responses=CONTENT_RESPONSES,
summary="Исходный файл",
)
async def get_file_content(
file_id: uuid.UUID,
service: PrimaryFilesServiceDep,
disposition: Annotated[Literal["attachment", "inline"], Query()] = "attachment",
) -> StreamingResponse:
"""Содержимое исходного файла из S3 потоком (в память целиком не читается); `inline` — для просмотра в браузере."""
stream, media_type, header = await service.content(file_id, disposition)
return StreamingResponse(stream, media_type=media_type, headers={"Content-Disposition": header})
@router.delete(
"/files/{file_id}",
status_code=status.HTTP_204_NO_CONTENT,

View File

@ -6,6 +6,7 @@
import unicodedata
from pathlib import PurePosixPath
from urllib.parse import quote
from fastapi import HTTPException, UploadFile, status
@ -50,6 +51,13 @@ def safe_filename(filename: str | None, *, default: str = DEFAULT_FILENAME) -> s
return name
def content_disposition(filename: str, *, disposition: str = "attachment", ascii_fallback: str) -> str:
"""Заголовок `Content-Disposition`: ASCII-имя `ascii_fallback` и `filename*` (RFC 5987) с полным
percent-кодированием `filename` — пробел, кавычки, `;`, `/`, переводы строк и кириллица экранируются,
заголовок остаётся чистым ASCII."""
return f"{disposition}; filename=\"{ascii_fallback}\"; filename*=UTF-8''{quote(filename, safe='')}"
def extension(name: str) -> str:
"""Расширение имени в нижнем регистре без точки (`ОСВ.XLS` → `xls`; без расширения — пустая строка)."""
return PurePosixPath(name).suffix.lstrip(".").lower()

View File

@ -2,6 +2,7 @@
по docs/context/03-architecture.md (референс — reference-old-arm/api/src/core/clients.py).
"""
from collections.abc import AsyncIterator
from io import BytesIO
from miniopy_async import Minio
@ -45,3 +46,18 @@ class S3Storage:
finally:
response.close()
await response.release()
async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]:
"""Объект порциями; `NoSuchKey` → `ObjectNotFoundError` до первой порции; ответ отпускается и при обрыве."""
try:
response = await self.client.get_object(self.bucket, obj_name)
except S3Error as error:
if error.code == NO_SUCH_KEY:
raise ObjectNotFoundError(obj_name) from error
raise
try:
async for chunk in response.content.iter_chunked(chunk_size):
yield chunk
finally:
response.close()
await response.release()

View File

@ -4,6 +4,7 @@
`services/directory_import.py`: тот же интерфейс нужен загрузке файлов и маппингу результатов (Срез 2c, D-12).
"""
from collections.abc import AsyncIterator
from typing import Protocol
@ -12,7 +13,7 @@ class ObjectNotFoundError(Exception):
class ObjectStorage(Protocol):
"""Что ядру нужно от S3: бакет, запись и чтение объекта целиком."""
"""Что ядру нужно от S3: бакет, запись, чтение объекта целиком и порциями."""
bucket: str
@ -21,3 +22,9 @@ class ObjectStorage(Protocol):
async def get_bytes(self, obj_name: str) -> bytes:
"""Содержимое объекта; отсутствие объекта — `ObjectNotFoundError`."""
...
def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]:
"""Объект порциями по `chunk_size` байт; отсутствие объекта — `ObjectNotFoundError` до первой порции.
Закрытие итератора (`aclose`) освобождает соединение с S3."""
...

View File

@ -18,7 +18,9 @@ audit-trail «События файлов и документов»; design D-4)
import hashlib
import uuid
from collections.abc import AsyncIterator
from dataclasses import dataclass
from typing import Literal
from loguru import logger
from sqlalchemy import func, select
@ -28,10 +30,10 @@ from starlette import status as http_status
from src.api.dependencies.audit import RequestAudit
from src.common.clock import utc_now
from src.common.exceptions import ConflictError, DomainError, DomainValidationError, NotFoundError
from src.common.filenames import safe_filename
from src.common.filenames import CONTENT_TYPES, content_disposition, extension, safe_filename
from src.contract import CONTRACT_VERSION, request_subject
from src.core.config import Settings
from src.core.storage import ObjectStorage
from src.core.storage import ObjectNotFoundError, ObjectStorage
from src.domain.file_types import MAX_ATTEMPTS, DetectedFile, detect_file
from src.domain.policy import IN_WORK_STATUSES, VisibilityScope
from src.models.consolidation import ConsolidationVersion
@ -65,6 +67,7 @@ FILE_TOO_LARGE = "FILE_TOO_LARGE"
STORAGE_UNAVAILABLE = "STORAGE_UNAVAILABLE"
STORAGE_UNAVAILABLE_MESSAGE = "хранилище файлов недоступно"
FILE_NOT_FOUND = "файл не найден" # одинаково для чужого и несуществующего
FILE_CONTENT_NOT_FOUND = "FILE_CONTENT_NOT_FOUND"
FILE_HAS_CONFIRMED_DOCUMENTS = "FILE_HAS_CONFIRMED_DOCUMENTS"
FILE_SUPERSEDES_OTHERS = "FILE_SUPERSEDES_OTHERS"
@ -76,6 +79,13 @@ class StorageUnavailableError(DomainError):
super().__init__(http_status.HTTP_503_SERVICE_UNAVAILABLE, STORAGE_UNAVAILABLE, message)
class FileContentNotFoundError(DomainError):
"""404 `FILE_CONTENT_NOT_FOUND`: файл есть в БД, но объекта нет в S3 (истёк или пропал)."""
def __init__(self) -> None:
super().__init__(http_status.HTTP_404_NOT_FOUND, FILE_CONTENT_NOT_FOUND, "исходный файл недоступен в хранилище")
@dataclass(frozen=True)
class AcceptedFile:
"""Принятый файл: `stored_file.id`, `recognition_job.id`, очищенное имя."""
@ -106,6 +116,18 @@ class RejectedFile:
)
async def _chain(first: bytes, rest: AsyncIterator[bytes]) -> AsyncIterator[bytes]:
"""Первая (уже прочитанная) порция и остальной поток; при завершении, ошибке и обрыве потребителя поток S3
закрывается (`aclose` — там `close` + `release` ответа), соединение не остаётся занятым."""
try:
if first:
yield first
async for chunk in rest:
yield chunk
finally:
await rest.aclose() # type: ignore[attr-defined]
def upload_key(file_id: uuid.UUID, job_id: uuid.UUID, name: str) -> str:
"""Ключ исходного файла в S3: `uploads/{file_id}/{job_id}/{имя}` (`03-architecture.md`, схема ключей)."""
return f"{UPLOADS_PREFIX}/{file_id}/{job_id}/{name}"
@ -231,6 +253,36 @@ class PrimaryFilesService:
(card,) = await self._cards([file])
return card
async def content(
self, file_id: uuid.UUID, disposition: Literal["attachment", "inline"] = "attachment"
) -> tuple[AsyncIterator[bytes], str, str]:
"""Исходный файл потоком: `(поток, Content-Type, Content-Disposition)` (spec documents-api, D-10).
Файл любого статуса (в т. ч. `SUPERSEDED`) в области видимости; чужой и несуществующий — 404. Ключ и имя
читаются здесь, тело потока сессию не держит (ответ уходит после коммита запроса). Первая порция берётся до
отправки заголовков: отсутствие объекта в S3 — 404 `FILE_CONTENT_NOT_FOUND`, а не оборванный ответ.
Аудит не пишется."""
file = await files_repo.get_file(self._session, self._scope, file_id)
if file is None:
raise NotFoundError(FILE_NOT_FOUND)
s3_object = await self._session.get(S3Object, file.s3_object_id)
if s3_object is None:
raise FileContentNotFoundError()
storage = self.require_storage()
iterator = storage.stream(s3_object.key)
try:
first = await anext(iterator, b"")
except ObjectNotFoundError:
raise FileContentNotFoundError() from None
except BaseException:
await iterator.aclose() # type: ignore[attr-defined]
raise
name = file.original_name or ""
suffix = extension(name) or file.file_type.value.lower()
header = content_disposition(name or f"file.{suffix}", disposition=disposition, ascii_fallback=f"file.{suffix}")
media_type = CONTENT_TYPES.get(file.file_type.value.lower(), "application/octet-stream")
return _chain(first, iterator), media_type, header
async def delete(self, file_id: uuid.UUID) -> None:
"""Удалить видимый файл (spec primary-files «Удаление файла»; audit-trail `FILE_DELETE`; design D-10).

View File

@ -207,7 +207,7 @@ async def auth_client(app: FastAPI, seeded: AsyncSession) -> AsyncIterator[AuthC
class FakeStorage:
"""Заглушка `S3Storage` (протокол `ObjectStorage`): `bucket`/`put_object`/`get_bytes`, объекты — в словаре."""
"""Заглушка `S3Storage` (протокол `ObjectStorage`): `put_object`/`get_bytes`/`stream`, объекты — в словаре."""
def __init__(self, bucket: str = "arm-ka") -> None:
self.bucket = bucket
@ -222,6 +222,14 @@ class FakeStorage:
except KeyError:
raise ObjectNotFoundError(obj_name) from None
async def stream(self, obj_name: str, chunk_size: int = 1 << 20) -> AsyncIterator[bytes]:
try:
data = self.objects[obj_name]
except KeyError:
raise ObjectNotFoundError(obj_name) from None
for start in range(0, len(data), chunk_size):
yield data[start : start + chunk_size]
@pytest_asyncio.fixture
async def committed_sessionmaker() -> AsyncIterator[async_sessionmaker[AsyncSession]]:

View File

@ -0,0 +1,167 @@
"""Исходный файл потоком (spec documents-api «Исходный файл»; design D-10)."""
from collections.abc import AsyncIterator
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from src.core.storage import ObjectNotFoundError
from src.models.enums import FileStatus, FileType, RecognitionStatus
from src.models.files import S3Object, StoredFile
from src.services.primary_files import _chain
from tests.conftest import AuthClientFactory, FakeStorage
from tests.factories import make_stored_file
from tests.test_api_documents import CLIENT_MANAGER, CURATOR_GO, KA_RF, RKA, _rostov_entity
pytestmark = pytest.mark.integration
PDF = b"%PDF-1.4\n" + bytes(range(256)) * 4096 # ~1 МиБ: проверка порционной отдачи
HTTP_OK = 200
HTTP_NOT_FOUND = 404
HTTP_FORBIDDEN = 403
async def _file(
session: AsyncSession, storage: FakeStorage, name: str, file_type: FileType, *, entity_name: str = "ООО Файлы"
) -> StoredFile:
entity = await _rostov_entity(session, name=entity_name)
file, _ = await make_stored_file(
session, entity=entity, status=FileStatus.PROCESSED, job_status=RecognitionStatus.done, original_name=name
)
file.file_type = file_type
key = (await session.execute(select(S3Object.key).where(S3Object.id == file.s3_object_id))).scalar_one()
storage.objects[key] = PDF
await session.flush()
return file
async def test_inline_pdf(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001
storage = FakeStorage()
app.state.storage = storage
file = await _file(seeded, storage, "aep_6.pdf", FileType.PDF)
client: AsyncClient = await auth_client(KA_RF)
response = await client.get(f"/files/{file.id}/content", params={"disposition": "inline"})
assert response.status_code == HTTP_OK
assert response.headers["content-type"] == "application/pdf"
assert response.headers["content-disposition"] == "inline; filename=\"file.pdf\"; filename*=UTF-8''aep_6.pdf"
assert response.content == PDF
async def test_cyrillic_name_and_missing(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001
storage = FakeStorage()
app.state.storage = storage
file = await _file(seeded, storage, "ОСВ_66 за 2024.xls", FileType.XLS)
client = await auth_client(KA_RF)
response = await client.get(f"/files/{file.id}/content")
assert response.headers["content-type"] == "application/vnd.ms-excel"
assert response.headers["content-disposition"].startswith(
"attachment; filename=\"file.xls\"; filename*=UTF-8''%D0%9E"
)
storage.objects.clear()
missing = await client.get(f"/files/{file.id}/content")
assert missing.status_code == HTTP_NOT_FOUND and missing.json()["detail"]["code"] == "FILE_CONTENT_NOT_FOUND"
async def test_superseded_file_is_served(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001
storage = FakeStorage()
app.state.storage = storage
file = await _file(seeded, storage, "old.xlsx", FileType.XLSX)
file.status = FileStatus.SUPERSEDED
await seeded.flush()
client = await auth_client(KA_RF)
response = await client.get(f"/files/{file.id}/content")
assert response.status_code == HTTP_OK and response.content == PDF
assert response.headers["content-type"] == ("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
async def test_foreign_unknown_and_permission(app, seeded: AsyncSession, auth_client: AuthClientFactory) -> None: # noqa: ANN001
storage = FakeStorage()
app.state.storage = storage
file = await _file(seeded, storage, "a.pdf", FileType.PDF)
# RKA — только DOCUMENT_VIEW: доступ есть (тот же филиал); без права — 403 до видимости
assert (await (await auth_client(RKA)).get(f"/files/{file.id}/content")).status_code == HTTP_OK
assert (await (await auth_client(CURATOR_GO)).get(f"/files/{file.id}/content")).status_code == HTTP_FORBIDDEN
foreign = await (await auth_client(CLIENT_MANAGER)).get(f"/files/{file.id}/content") # другой филиал
assert foreign.status_code == HTTP_NOT_FOUND and foreign.json()["detail"]["code"] == "NOT_FOUND"
client = await auth_client(KA_RF)
unknown = await client.get("/files/00000000-0000-0000-0000-000000000000/content")
assert unknown.status_code == HTTP_NOT_FOUND
async def test_stream_error_before_first_chunk_is_object_not_found() -> None:
storage = FakeStorage()
with pytest.raises(ObjectNotFoundError):
await storage.stream("missing").__anext__()
async def test_stream_releases_on_error() -> None:
"""Review Focus 4: при обрыве потребителя ответ S3 закрывается и отпускается."""
from src.core.clients import S3Storage # noqa: PLC0415
class Content:
async def iter_chunked(self, size: int): # noqa: ANN202
yield b"a" * size
yield b"b" * size
class Response:
def __init__(self) -> None:
self.content = Content()
self.closed = self.released = False
def close(self) -> None:
self.closed = True
async def release(self) -> None:
self.released = True
response = Response()
class Client:
async def get_object(self, bucket: str, name: str) -> Response:
return response
storage = S3Storage(Client(), "arm-ka") # type: ignore[arg-type]
stream = storage.stream("uploads/x", chunk_size=4)
assert await stream.__anext__() == b"aaaa"
await stream.aclose() # потребитель оборвал чтение
assert response.closed and response.released
class _Source:
"""Источник порций с признаком закрытия (`finally` асинхронного генератора)."""
def __init__(self, fail_after: int | None = None) -> None:
self.closed = False
self._fail_after = fail_after
self.iterator = self._run()
async def _run(self) -> AsyncIterator[bytes]:
try:
for index in range(3):
if self._fail_after == index:
raise ConnectionError("обрыв S3")
yield b"x"
finally:
self.closed = True
async def test_chain_closes_source_when_consumer_stops() -> None:
"""Обрыв клиента после первой порции: `aclose` обёртки закрывает исходный поток."""
source = _Source()
first = await anext(source.iterator, b"")
chain = _chain(first, source.iterator)
assert await chain.__anext__() == b"x"
await chain.aclose()
assert source.closed
async def test_chain_closes_source_on_error_and_yields_all() -> None:
source = _Source(fail_after=2)
chain = _chain(await anext(source.iterator, b""), source.iterator)
with pytest.raises(ConnectionError):
_ = [chunk async for chunk in chain]
assert source.closed
complete = _Source()
assert b"".join([chunk async for chunk in _chain(await anext(complete.iterator, b""), complete.iterator)]) == b"xxx"
assert complete.closed

View File

@ -1,6 +1,7 @@
"""`S3Storage.get_bytes` на заглушке Minio (без сети):
чтение, закрытие response, `NoSuchKey` → `ObjectNotFoundError`."""
from collections.abc import AsyncIterator
from typing import Any
from unittest.mock import MagicMock
@ -11,11 +12,21 @@ from src.core.clients import S3Storage
from src.core.storage import ObjectNotFoundError
class StubContent:
def __init__(self, data: bytes) -> None:
self._data = data
async def iter_chunked(self, size: int) -> AsyncIterator[bytes]:
for start in range(0, len(self._data), size):
yield self._data[start : start + size]
class StubResponse:
"""Ответ `aiohttp` с флагами закрытия."""
def __init__(self, data: bytes, *, fail_read: bool = False) -> None:
self._data = data
self.content = StubContent(data)
self._fail_read = fail_read
self.closed = False
self.released = False
@ -83,3 +94,24 @@ async def test_get_bytes_other_s3_error_is_not_swallowed() -> None:
with pytest.raises(S3Error) as caught:
await _storage(StubMinio(error=_s3_error("AccessDenied"))).get_bytes("k")
assert caught.value.code == "AccessDenied"
async def test_stream_yields_chunks_and_releases_response() -> None:
response = StubResponse(b"abcdefghij")
chunks = [chunk async for chunk in _storage(StubMinio(response)).stream("k", chunk_size=4)]
assert chunks == [b"abcd", b"efgh", b"ij"]
assert response.closed is True
assert response.released is True
async def test_stream_no_such_key_is_object_not_found_before_first_chunk() -> None:
with pytest.raises(ObjectNotFoundError):
await _storage(StubMinio(error=_s3_error("NoSuchKey"))).stream("missing").__anext__()
async def test_stream_other_s3_error_is_not_swallowed() -> None:
with pytest.raises(S3Error) as caught:
await _storage(StubMinio(error=_s3_error("AccessDenied"))).stream("k").__anext__()
assert caught.value.code == "AccessDenied"

View File

@ -48,6 +48,7 @@ EXPECTED_ROUTES: dict[str, set[str]] = {
# primary-files, documents-api (Срез 2c)
"/api/v1/legal-entities/{legal_entity_id}/files": {"get", "post"},
"/api/v1/files/{file_id}": {"get", "delete"},
"/api/v1/files/{file_id}/content": {"get"},
"/api/v1/documents/{document_id}": {"get"},
"/api/v1/documents/{document_id}/entries": {"get"},
"/api/v1/documents/{document_id}/confirm": {"post"},