fix(files): DELETE ставит expires_at только объектам папок своего файла

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-06 00:35:43 +04:00
parent 583edf2a8d
commit 2ac19c6953
4 changed files with 55 additions and 9 deletions

View File

@ -12,12 +12,12 @@ import uuid
from collections.abc import Sequence from collections.abc import Sequence
from typing import Any 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 sqlalchemy.ext.asyncio import AsyncSession
from src.domain.policy import VisibilityScope, legal_entities_visible from src.domain.policy import VisibilityScope, legal_entities_visible
from src.models.directories import LegalEntity 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.files import Document, RecognitionJob, S3Object, StoredFile
from src.models.osv import OsvEntry 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)) 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: async def expire_s3_objects(
"""`s3_object.expires_at = now` (ретеншн бакета заберёт объект; физически объект не удаляется — OQ-22).""" session: AsyncSession,
if object_ids: object_ids: Sequence[uuid.UUID],
await session.execute(update(S3Object).where(S3Object.id.in_(object_ids)).values(expires_at=now)) *,
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))

View File

@ -50,6 +50,7 @@ from src.schemas.common import PageParams
from src.schemas.files import FileOut from src.schemas.files import FileOut
from src.services.documents import file_out from src.services.documents import file_out
from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND from src.services.legal_entities import LEGAL_ENTITY_NOT_FOUND
from src.services.result_mapping import RESULTS_PREFIX
OBJECT_TYPE = "stored_file" OBJECT_TYPE = "stored_file"
UPLOADS_PREFIX = "uploads" UPLOADS_PREFIX = "uploads"
@ -174,7 +175,8 @@ class PrimaryFilesService:
исчезнувший, пока ждали блокировку, — 404. исчезнувший, пока ждали блокировку, — 404.
Удаляются `osv_entry`, `document`, все задания и `stored_file`; `s3_object` файла и результатов заданий Удаляются `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` аудита — карточка файла.""" ретеншн бакета. `before_value` аудита — карточка файла."""
visible = await files_repo.get_file(self._session, self._scope, file_id) visible = await files_repo.get_file(self._session, self._scope, file_id)
if visible is None: if visible is None:
@ -197,7 +199,12 @@ class PrimaryFilesService:
before = file_out(file, current, documents).model_dump(mode="json") 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)] 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.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( await self._audit.record(
action=AuditAction.FILE_DELETE, action=AuditAction.FILE_DELETE,
section=AuditSection.PRIMARY_DOCUMENTS, section=AuditSection.PRIMARY_DOCUMENTS,

View File

@ -191,15 +191,17 @@ async def make_stored_file( # noqa: PLR0913 - сигнатура фабрики
сессии (`flush`). сессии (`flush`).
""" """
now = _now() now = _now()
file_id = uuid.uuid4()
s3 = S3Object( s3 = S3Object(
bucket_role=S3BucketRole.uploads, bucket_role=S3BucketRole.uploads,
bucket=FACTORY_BUCKET, 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, created_at=now,
) )
session.add(s3) session.add(s3)
await session.flush() await session.flush()
file = StoredFile( file = StoredFile(
id=file_id,
legal_entity_id=entity.id, legal_entity_id=entity.id,
file_type=file_type, file_type=file_type,
original_name=original_name, original_name=original_name,

View File

@ -102,6 +102,28 @@ async def test_delete_error_file(seeded: AsyncSession, auth_client: AuthClientFa
assert event.after_value is None 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: async def test_delete_processed_file_removes_rows(seeded: AsyncSession, auth_client: AuthClientFactory) -> None:
"""Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл.""" """Файл `PROCESSED` без подтверждения: удаляются строки ОСВ, документы, задания и файл."""
entity = await _rostov_entity(seeded) entity = await _rostov_entity(seeded)