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 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))

View File

@ -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,

View File

@ -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,

View File

@ -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)