arm-core/api/src/services/primary_files.py

516 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Первичные файлы организации: загрузка (spec primary-files «Загрузка файлов организации», «Дедуп по содержимому»;
audit-trail «События файлов и документов»; design D-4) и чтение — список файлов ЮЛ и карточка файла (spec documents-api
«Файлы организации и карточка файла»; design D-11): файл, текущее задание и документы, пакетно без N+1.
Загрузка одного файла — SAVEPOINT (`begin_nested()`) внутри транзакции запроса (`SessionDep`): отказ файла откатывает
только его записи, принятые раньше файлы того же запроса остаются. Порядок внутри SAVEPOINT:
1. рекомендательная блокировка `(ЮЛ, sha256)` до конца транзакции запроса и проверка дубля — до записи в S3;
2. `s3_object` + `flush` (конфликт ключа или ошибка БД — до загрузки объекта, бакет не засоряется);
3. `put_object`; ошибка S3 — `STORAGE_UNAVAILABLE`, SAVEPOINT откатывается (строки `s3_object` не остаётся);
4. `stored_file` (`UPLOADED`, `version_no = 1`, `consolidation_id` при загрузке в консолидацию) и `recognition_job`
(`queued`) + `flush`, `stored_file.current_recognition_job_id`, аудит `FILE_UPLOAD` (с `consolidation_id`).
Если после шага 3 упадёт БД (вставка файла/задания, аудит или commit запроса), объект в S3 останется без записи
`s3_object` — допустимо: ключ содержит свежие `file_id`/`job_id`, ни на что не ссылается и не будет переиспользован;
уборка таких объектов — ретеншн бакета (OQ-22).
"""
import hashlib
import uuid
from collections.abc import AsyncGenerator
from dataclasses import dataclass
from typing import Literal
from loguru import logger
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
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 CONTENT_TYPES, content_disposition, safe_filename
from src.common.streaming import ChainedStream
from src.contract import CONTRACT_VERSION, request_subject
from src.core.config import Settings
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 Consolidation
from src.models.directories import LegalEntity
from src.models.enums import (
AuditAction,
AuditSection,
ConsolidationStatus,
DocumentStatus,
FileStatus,
RecognitionStatus,
S3BucketRole,
)
from src.models.files import RecognitionJob, S3Object, StoredFile
from src.repositories import consolidations as consolidations_repo
from src.repositories import directories as directories_repo
from src.repositories import files as files_repo
from src.repositories import statements as statements_repo
from src.schemas.common import PageParams
from src.schemas.files import FileOut
from src.services import consolidation_documents
from src.services.audit import AuditService
from src.services.consolidation_guard import CONSOLIDATION_NOT_FOUND
from src.services.documents import file_out
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
from src.services.result_mapping import RESULTS_PREFIX
from src.services.session_audit import TransactionFactory
OBJECT_TYPE = "stored_file"
UPLOADS_PREFIX = "uploads"
DUPLICATE_FILE = "DUPLICATE_FILE"
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"
class StorageUnavailableError(DomainError):
"""503 `STORAGE_UNAVAILABLE`: S3 не подключено (dev без S3) или не приняло объект."""
def __init__(self, message: str = STORAGE_UNAVAILABLE_MESSAGE) -> None:
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`, очищенное имя."""
file_id: uuid.UUID
job_id: uuid.UUID
name: str
@dataclass(frozen=True)
class RejectedFile:
"""Отклонённый файл; `file_id` — существующий файл при `DUPLICATE_FILE`."""
name: str
code: str
message: str
file_id: uuid.UUID | None = None
@classmethod
def from_error(cls, name: str, error: DomainError) -> "RejectedFile":
"""Отказ по доменной ошибке загрузки файла `name`."""
raw_file_id = error.extra.get("file_id")
return cls(
name=name,
code=error.code,
message=error.message,
file_id=uuid.UUID(str(raw_file_id)) if raw_file_id is not None else None,
)
def _chain(first: bytes, rest: AsyncGenerator[bytes, None]) -> ChainedStream:
"""Первая (уже прочитанная) порция и остальной поток; `aclose` закрывает поток S3 (там `close` + `release`
ответа) — соединение не остаётся занятым при завершении, ошибке и обрыве потребителя (в т. ч. не начатом)."""
return ChainedStream(first, rest)
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}"
class PrimaryFilesService:
"""Загрузка первичных файлов ЮЛ от имени пользователя запроса (право `DOCUMENT_EDIT` — на маршруте)."""
def __init__( # noqa: PLR0913, PLR0917 - зависимости сервиса; `transaction` — отдельная транзакция (D-6)
self,
session: AsyncSession,
audit: RequestAudit,
scope: VisibilityScope,
storage: ObjectStorage | None,
settings: Settings,
transaction: TransactionFactory | None = None,
) -> None:
self._session = session
self._audit = audit
self._scope = scope
self._storage = storage
self._settings = settings
self._transaction = transaction
@property
def max_upload_bytes(self) -> int:
"""Предел размера одного файла (`UPLOAD_MAX_BYTES`)."""
return self._settings.UPLOAD_MAX_BYTES
async def get_entity(self, entity_id: uuid.UUID) -> LegalEntity:
"""ЮЛ в области видимости пользователя; чужое и несуществующее — одинаковый 404 (§10.1)."""
entity = await directories_repo.get_legal_entity(self._session, self._scope, entity_id)
if entity is None:
raise NotFoundError(LEGAL_ENTITY_NOT_FOUND)
return entity
async def check_consolidation_for_upload(self, consolidation_id: uuid.UUID, entity: LegalEntity) -> Consolidation:
"""Загрузка в консолидацию (spec primary-files «Загрузка файлов в консолидацию», §9.5 п. 8, D-3): ДО приёма —
консолидация видима (404 `CONSOLIDATION_NOT_FOUND`) → есть версия «в работе» (409 `CONSOLIDATION_NOT_EDITABLE`)
→ ЮЛ в периметре хотя бы одной из них (422 `ENTITY_NOT_IN_PERIMETER`). Блокировки не нужны: статус
перечитывается под `FOR UPDATE` в `after_upload_to_consolidation`."""
consolidation = await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id)
if consolidation is None:
raise NotFoundError(CONSOLIDATION_NOT_FOUND)
in_work = [
version
for version in await consolidations_repo.versions_of(self._session, consolidation.id)
if version.status in IN_WORK_STATUSES
]
if not in_work:
raise ConflictError("CONSOLIDATION_NOT_EDITABLE", "У консолидации нет версии в работе")
for version in in_work:
members = await consolidations_repo.members(self._session, version.id)
if any(member.legal_entity_id == entity.id for member, _ in members):
return consolidation
raise DomainValidationError("ENTITY_NOT_IN_PERIMETER", "ЮЛ не входит в периметр версий консолидации в работе")
async def after_upload_to_consolidation(self, consolidation_id: uuid.UUID, entity_id: uuid.UUID) -> None:
"""После приёма хотя бы одного файла: каждая версия консолидации «в работе» с ЮЛ в периметре и статусом
`INITIATED` → `DOCUMENTS_PROCESSING` (`changed_by` — загрузивший; §9.1, D-4).
В ОТДЕЛЬНОЙ транзакции (`session_transaction`): версии под `FOR UPDATE ORDER BY id`, статус перечитан —
попадают только ещё `INITIATED`. Приём файлов важнее побочного статуса (R-10 Среза 4): любая ошибка
перехода логируется и НЕ меняет ответ. `BaseException` не перехватывается."""
# импорт внутри функции — модули консолидации импортируют друг друга
from src.services.consolidation_status import transition # noqa: PLC0415
factory = self._transaction
if factory is None:
from src.db.db import session_transaction # noqa: PLC0415
factory = session_transaction
try:
async with factory() as session:
versions = await consolidations_repo.lock_versions_of_consolidation(
session, consolidation_id, entity_id, {ConsolidationStatus.INITIATED}
)
audit = AuditService(session)
now = utc_now()
for version in versions:
await transition(
session,
audit,
version=version,
to=ConsolidationStatus.DOCUMENTS_PROCESSING,
changed_by=self._audit.user_id,
comment=None,
ip_address=self._audit.ip_address,
user_agent=self._audit.user_agent,
now=now,
)
except Exception:
logger.exception(
"Не удалось перевести версии консолидации в DOCUMENTS_PROCESSING после загрузки, файлы приняты. "
"error_category=version_transition_error, consolidation_id={consolidation_id}",
consolidation_id=consolidation_id,
)
def require_storage(self) -> ObjectStorage:
"""S3 приложения; без него загрузка невозможна — 503 `STORAGE_UNAVAILABLE`."""
if self._storage is None:
raise StorageUnavailableError()
return self._storage
async def page_files(
self, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams
) -> tuple[list[FileOut], int]:
"""Карточки файлов видимого ЮЛ по убыванию `uploaded_at` и их общее число; ЮЛ вне видимости — 404.
Три запроса на страницу независимо от числа файлов: файлы (+ `count`), текущие задания, документы."""
entity = await self.get_entity(entity_id)
files, total = await files_repo.list_files(
self._session,
self._scope,
entity.id,
include_superseded=include_superseded,
limit=page.limit,
offset=page.offset,
)
return await self._cards(files), total
async def page_desk(
self, consolidation_id: uuid.UUID, entity_id: uuid.UUID, *, include_superseded: bool, page: PageParams
) -> tuple[list[FileOut], int]:
"""Рабочий стол консолидации для ЮЛ (spec documents-api «Рабочий стол консолидации для ЮЛ»; §9.5 п. 11; D-6):
консолидация вне видимости — 404 `CONSOLIDATION_NOT_FOUND`, ЮЛ вне видимости — 404; принадлежность ЮЛ к ГК
консолидации не проверяется (чужому ЮЛ — пустой стол). Карточки — как в `page_files`."""
if await consolidations_repo.get_consolidation(self._session, self._scope, consolidation_id) is None:
raise NotFoundError(CONSOLIDATION_NOT_FOUND)
entity = await self.get_entity(entity_id)
files, total = await files_repo.list_desk_files(
self._session,
self._scope,
entity.id,
consolidation_id,
include_superseded=include_superseded,
limit=page.limit,
offset=page.offset,
)
return await self._cards(files), total
async def get_file(self, file_id: uuid.UUID) -> FileOut:
"""Карточка видимого файла; чужой и несуществующий — одинаковый 404."""
file = await files_repo.get_file(self._session, self._scope, file_id)
if file is None:
raise NotFoundError(FILE_NOT_FOUND)
(card,) = await self._cards([file])
return card
async def content(
self, file_id: uuid.UUID, disposition: Literal["attachment", "inline"] = "attachment"
) -> tuple[ChainedStream, 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()
raise
name = file.original_name or ""
ascii_name = f"file.{file.file_type.value.lower()}"
header = content_disposition(name or ascii_name, disposition=disposition, ascii_fallback=ascii_name)
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).
Порядок блокировок — как в обмене и маппинге: задания файла `FOR UPDATE`, затем `stored_file`, затем документы
(confirm берёт «файл → документ», автозамена — advisory → старые файлы по id). Состояние перечитывается
под блокировками, и 409 проверяются после них, в порядке: `FILE_HAS_CONFIRMED_DOCUMENTS` — есть документ,
подтверждённый пользователем; `FILE_SUPERSEDES_OTHERS` — на файл ссылаются `superseded_by_file_id` (цепочка
версий не рвётся); `FILE_HAS_CONFIRMED_DOCUMENTS` — автоподтверждённый документ привязан к версии вне «в
работе» (OQ-157). Привязки автоподтверждённых документов снимаются (после блокировки advisory ГК → версии),
id версий — в `after_value.unbound_version_ids`. Файл, исчезнувший, пока ждали блокировку, — 404.
Удаляются формы ББ/ОФР (`financial_statement_*`), комментарии документов, `osv_entry`, `document`, все задания
и `stored_file`; `s3_object` файла и результатов заданий
остаются, им ставится `expires_at = now` — только объектам из папок файла `uploads/{file_id}/` и
`results/{file_id}/` (M-3). ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает
ретеншн бакета. `before_value` аудита — карточка файла."""
visible = await files_repo.get_file(self._session, self._scope, file_id)
if visible is None:
raise NotFoundError(FILE_NOT_FOUND)
jobs = await files_repo.lock_jobs(self._session, file_id)
file = await files_repo.lock_file(self._session, file_id)
if file is None: # удалён параллельно, пока ждали блокировку
raise NotFoundError(FILE_NOT_FOUND)
documents = await files_repo.lock_file_documents(self._session, file_id)
confirmed = [document for document in documents if document.status is DocumentStatus.CONFIRMED]
if any(document.confirmed_by is not None for document in confirmed):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS, "файл нельзя удалить: в нём есть документы, подтверждённые пользователем"
)
if await files_repo.supersedes_others(self._session, file_id):
raise ConflictError(
FILE_SUPERSEDES_OTHERS,
"файл нельзя удалить: он заменил более ранние версии, цепочка версий сохраняется",
)
# OQ-157, design D-4: автоподтверждённые документы удаляются, пока их версии «в работе»; привязки снимаются
bound = await consolidation_documents.bound_versions(self._session, documents=confirmed)
if any(status not in IN_WORK_STATUSES for _, status in bound):
raise ConflictError(
FILE_HAS_CONFIRMED_DOCUMENTS,
"файл нельзя удалить: документ привязан к версии консолидации вне корректировки данных",
)
unbound_version_ids = sorted({version_id for version_id, _ in bound})
await consolidation_documents.unbind_documents(
self._session, document_ids=[document.id for document in confirmed]
)
current = next((job for job in jobs if job.id == file.current_recognition_job_id), None)
counts = await files_repo.comments_count_by_document(self._session, [document.id for document in documents])
issue_counts = await statements_repo.issue_counts_by_document(
self._session, [document.id for document in documents]
)
before = file_out(file, current, documents, counts, issue_counts).model_dump(mode="json")
object_ids = [file.s3_object_id, *(job.result_s3_object_id for job in jobs if job.result_s3_object_id)]
await files_repo.delete_file_rows(self._session, file)
# M-3: только объекты папок этого файла — ссылка задания может указывать на объект другого файла
owned_prefixes = [
(S3BucketRole.uploads, f"{UPLOADS_PREFIX}/{file_id}/"),
(S3BucketRole.results, f"{RESULTS_PREFIX}/{file_id}/"),
]
await files_repo.expire_s3_objects(self._session, object_ids, owned_prefixes=owned_prefixes, now=utc_now())
await self._audit.record(
action=AuditAction.FILE_DELETE,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=OBJECT_TYPE,
object_id=file_id,
before_value=before,
after_value={"unbound_version_ids": unbound_version_ids},
)
async def _cards(self, files: list[StoredFile]) -> list[FileOut]:
jobs = await files_repo.current_jobs(self._session, files)
documents = await files_repo.documents_by_file(self._session, [file.id for file in files])
document_ids = [document.id for group in documents.values() for document in group]
counts = await files_repo.comments_count_by_document(self._session, document_ids)
issue_counts = await statements_repo.issue_counts_by_document(self._session, document_ids)
return [file_out(file, jobs.get(file.id), documents.get(file.id, []), counts, issue_counts) for file in files]
async def find_duplicate(self, entity_id: uuid.UUID, sha256: str) -> StoredFile | None:
"""Файл ЮЛ с тем же содержимым, кроме заменённых (`SUPERSEDED`): дубль по spec «Дедуп по содержимому»."""
stmt = (
select(StoredFile)
.where(
StoredFile.legal_entity_id == entity_id,
StoredFile.content_hash == sha256,
StoredFile.status != FileStatus.SUPERSEDED,
)
.order_by(StoredFile.uploaded_at, StoredFile.id)
.limit(1)
)
return (await self._session.execute(stmt)).scalar_one_or_none()
async def upload_one(
self, entity: LegalEntity, name: str, content: bytes, *, consolidation_id: uuid.UUID | None = None
) -> AcceptedFile:
"""Принять один файл (см. docstring модуля); отказ — `DomainError` (`UNSUPPORTED_FILE`, `DUPLICATE_FILE`,
`STORAGE_UNAVAILABLE`), записи этого файла откатываются вместе с его SAVEPOINT."""
storage = self.require_storage()
safe_name = safe_filename(name, default="")
detected = detect_file(safe_name, content)
sha256 = hashlib.sha256(content).hexdigest()
async with self._session.begin_nested():
await self._lock_content(entity.id, sha256)
duplicate = await self.find_duplicate(entity.id, sha256)
if duplicate is not None:
raise ConflictError(
DUPLICATE_FILE,
f"файл «{safe_name}» уже загружен для этого ЮЛ",
file_id=str(duplicate.id),
)
return await self._store(
entity,
name=safe_name,
content=content,
sha256=sha256,
storage=storage,
detected=detected,
consolidation_id=consolidation_id,
)
async def _lock_content(self, entity_id: uuid.UUID, sha256: str) -> None:
"""Сериализует загрузки одного содержимого в одно ЮЛ до конца транзакции запроса (двойной клик): второй
запрос дождётся commit первого и увидит дубль. Блокировка снимается с транзакцией, а не с SAVEPOINT."""
lock_key = func.hashtextextended(f"{OBJECT_TYPE}:{entity_id}:{sha256}", 0)
await self._session.execute(select(func.pg_advisory_xact_lock(lock_key)))
async def _store( # noqa: PLR0913 - шаги одной загрузки: всё, что уже вычислено в upload_one
self,
entity: LegalEntity,
*,
name: str,
content: bytes,
sha256: str,
storage: ObjectStorage,
detected: DetectedFile,
consolidation_id: uuid.UUID | None,
) -> AcceptedFile:
now = utc_now()
user_id = self._audit.user_id
file_id, job_id = uuid.uuid4(), uuid.uuid4()
key = upload_key(file_id, job_id, name)
s3_object = S3Object(
id=uuid.uuid4(),
bucket_role=S3BucketRole.uploads,
bucket=storage.bucket,
key=key,
content_type=detected.content_type,
size_bytes=len(content),
sha256=sha256,
created_by=f"user:{user_id}",
created_at=now,
)
self._session.add(s3_object)
await self._session.flush()
try:
await storage.put_object(key, content)
except Exception as error:
logger.warning(
"S3 не приняло загружаемый файл, файл отклонён. error_category=storage_error, key={key}, error={error}",
key=key,
error=f"{type(error).__name__}: {error}",
)
raise StorageUnavailableError(f"{STORAGE_UNAVAILABLE_MESSAGE}: файл «{name}» не сохранён") from error
stored_file = StoredFile(
id=file_id,
legal_entity_id=entity.id,
consolidation_id=consolidation_id,
file_type=detected.file_type,
original_name=name,
s3_object_id=s3_object.id,
content_hash=sha256,
size_bytes=len(content),
status=FileStatus.UPLOADED,
version_no=1,
uploaded_by=user_id,
uploaded_at=now,
)
job = RecognitionJob(
id=job_id,
file_id=file_id,
pipeline=detected.pipeline,
request_subject=request_subject(detected.pipeline.value, self._settings.OPENBAO.NATS.request_subjects()),
schema_version=CONTRACT_VERSION,
status=RecognitionStatus.queued,
attempt=1,
max_attempts=MAX_ATTEMPTS[detected.pipeline],
requested_at=now,
)
# Связей `relationship()` в моделях нет — порядок INSERT задаём сами: файл, затем задание (FK `file_id`);
# FK `current_recognition_job_id` не отложенный (use_alter): ссылку ставим, когда задание уже вставлено
self._session.add(stored_file)
await self._session.flush()
self._session.add(job)
await self._session.flush()
stored_file.current_recognition_job_id = job.id
await self._session.flush()
await self._audit.record(
action=AuditAction.FILE_UPLOAD,
section=AuditSection.PRIMARY_DOCUMENTS,
object_type=OBJECT_TYPE,
object_id=file_id,
after_value={
"original_name": name,
"content_hash": sha256,
"job_id": str(job_id),
"file_type": detected.file_type.value,
"consolidation_id": str(consolidation_id) if consolidation_id is not None else None,
},
)
return AcceptedFile(file_id=file_id, job_id=job_id, name=name)