From 2ac19c6953804b53db7f7ec3cbba0d6e3920c6ce Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 6 Oct 2026 00:35:43 +0400 Subject: [PATCH] =?UTF-8?q?fix(files):=20DELETE=20=D1=81=D1=82=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=82=20expires=5Fat=20=D1=82=D0=BE=D0=BB=D1=8C=D0=BA?= =?UTF-8?q?=D0=BE=20=D0=BE=D0=B1=D1=8A=D0=B5=D0=BA=D1=82=D0=B0=D0=BC=20?= =?UTF-8?q?=D0=BF=D0=B0=D0=BF=D0=BE=D0=BA=20=D1=81=D0=B2=D0=BE=D0=B5=D0=B3?= =?UTF-8?q?=D0=BE=20=D1=84=D0=B0=D0=B9=D0=BB=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit M-3: s3_object.expires_at = now ставится только объектам с ролью uploads и ключом uploads/{file_id}/… или с ролью results и ключом results/{file_id}/…; ссылка задания на объект другого файла (inbox переиспользует s3_object при конфликте (bucket, key)) не трогается и ошибкой не считается. Фабрика make_stored_file кладёт загрузку в папку файла uploads/{file_id}/…, как upload_key в проде. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2 --- api/src/repositories/files.py | 27 +++++++++++++++++++++------ api/src/services/primary_files.py | 11 +++++++++-- api/tests/factories.py | 4 +++- api/tests/test_api_files_delete.py | 22 ++++++++++++++++++++++ 4 files changed, 55 insertions(+), 9 deletions(-) diff --git a/api/src/repositories/files.py b/api/src/repositories/files.py index aa538f2..eaf81a1 100644 --- a/api/src/repositories/files.py +++ b/api/src/repositories/files.py @@ -12,12 +12,12 @@ import uuid from collections.abc import Sequence from typing import Any -from sqlalchemy import Select, delete, func, select, update +from sqlalchemy import Select, and_, delete, func, or_, select, update from sqlalchemy.ext.asyncio import AsyncSession from src.domain.policy import VisibilityScope, legal_entities_visible from src.models.directories import LegalEntity -from src.models.enums import FileStatus +from src.models.enums import FileStatus, S3BucketRole from src.models.files import Document, RecognitionJob, S3Object, StoredFile from src.models.osv import OsvEntry @@ -174,7 +174,22 @@ async def delete_file_rows(session: AsyncSession, file: StoredFile) -> None: await session.execute(delete(StoredFile).where(StoredFile.id == file_id)) -async def expire_s3_objects(session: AsyncSession, object_ids: Sequence[uuid.UUID], *, now: datetime.datetime) -> None: - """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22).""" - if object_ids: - await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now)) +async def expire_s3_objects( + session: AsyncSession, + object_ids: Sequence[uuid.UUID], + *, + owned_prefixes: Sequence[tuple[S3BucketRole, str]], + now: datetime.datetime, +) -> None: + """`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22) — только + объектам из `object_ids`, чья пара (роль бакета, начало ключа) входит в `owned_prefixes`. Остальные (например, + ссылка задания на объект другого файла) не трогаются и ошибкой не считаются (M-3).""" + if not object_ids or not owned_prefixes: + return + owned = or_( + *( + and_(S3Object.bucket_role == role, S3Object.key.startswith(prefix, autoescape=True)) + for role, prefix in owned_prefixes + ) + ) + await session.execute(update(S3Object).where(S3Object.id.in_(object_ids), owned).values(expires_at=now)) diff --git a/api/src/services/primary_files.py b/api/src/services/primary_files.py index 3ede2d3..4ed1bcd 100644 --- a/api/src/services/primary_files.py +++ b/api/src/services/primary_files.py @@ -50,6 +50,7 @@ from src.schemas.common import PageParams from src.schemas.files import FileOut 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 OBJECT_TYPE = "stored_file" UPLOADS_PREFIX = "uploads" @@ -174,7 +175,8 @@ class PrimaryFilesService: исчезнувший, пока ждали блокировку, — 404. Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий - остаются, им ставится `expires_at = now`. ASSUMPTION(OQ-22): физически объекты S3 не удаляются — их убирает + остаются, им ставится `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: @@ -197,7 +199,12 @@ class PrimaryFilesService: before = file_out(file, current, documents).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) - await files_repo.expire_s3_objects(self._session, object_ids, now=utc_now()) + # 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, diff --git a/api/tests/factories.py b/api/tests/factories.py index f8b5007..dfa1cae 100644 --- a/api/tests/factories.py +++ b/api/tests/factories.py @@ -191,15 +191,17 @@ async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики сессии (`flush`). """ now = _now() + file_id = uuid.uuid4() s3 = S3Object( bucket_role=S3BucketRole.uploads, bucket=FACTORY_BUCKET, - key=s3_key or f"uploads/{uuid.uuid4()}/{original_name}", + key=s3_key or f"uploads/{file_id}/{uuid.uuid4()}/{original_name}", # как `upload_key`: папка файла created_at=now, ) session.add(s3) await session.flush() file = StoredFile( + id=file_id, legal_entity_id=entity.id, file_type=file_type, original_name=original_name, diff --git a/api/tests/test_api_files_delete.py b/api/tests/test_api_files_delete.py index 5d39bdb..b45e4bf 100644 --- a/api/tests/test_api_files_delete.py +++ b/api/tests/test_api_files_delete.py @@ -102,6 +102,28 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa assert event.after_value is None +async def test_delete_expires_only_own_objects(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: + """M-3: `expires_at` ставится только объектам папок файла (`uploads/{file_id}/`, `results/{file_id}/`); ссылка + задания на объект другого файла (inbox переиспользует `s3_object` при конфликте `(bucket, key)`) его не трогает.""" + entity = await _rostov_entity(seeded) + file, job = await make_stored_file( + seeded, entity=entity, status=FileStatus.ERROR, job_status=RecognitionStatus.error + ) + other_file, _ = await make_stored_file(seeded, entity=entity, status=FileStatus.ERROR, original_name="other.xls") + job.result_s3_object_id = other_file.s3_object_id # результат «указывает» на загрузку другого файла + await seeded.flush() + file_id, own_id, foreign_id = file.id, file.s3_object_id, other_file.s3_object_id + client = await auth_client(KA_RF) + + response = await client.delete(f"/files/{file_id}") + + assert response.status_code == HTTP_NO_CONTENT, response.text + own = await seeded.get(S3Object, own_id, populate_existing=True) + foreign = await seeded.get(S3Object, foreign_id, populate_existing=True) + assert own is not None and own.expires_at is not None + assert foreign is not None and foreign.expires_at is None + + async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None: """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" entity = await _rostov_entity(seeded)