Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BJKMLHdxMwSUeYcdkwqX4W
516 lines
29 KiB
Python
516 lines
29 KiB
Python
"""Первичные файлы организации: загрузка (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)
|