Документ описывает, к чему приводится бэкенд и почему. Реализуется инкрементально; бизнес-логика и публичный контракт API сохраняются (см. раздел «Что сохранено 1:1»). Ссылки вида A1, P1 — на пункты AUDIT.md.
- Тонкие контроллеры, толстые сервисы, изолированный доступ к данным. HTTP-слой только валидирует и оркеструет; бизнес-логика — в сервисах; SQL — в репозиториях.
- Один источник правды для инфраструктуры. Настройки, engine и сессия создаются в одном месте и внедряются через DI.
- Ничего лишнего. Структура соответствует размеру проекта; новые зависимости — только с обоснованием.
- Совместимость превыше красоты. Любое отклонение от исходного поведения — явное и задокументированное.
backend/src/
├── core/
│ ├── config.py # Settings (pydantic-settings) — единый типизированный конфиг
│ └── database.py # engine, session factory, get_session (DI), session_scope (воркер)
├── storage.py # FileStorage — абстракция файлового хранилища, неблокирующий I/O
├── exceptions.py # доменные исключения (FileNotFound, EmptyFile, StoredFileNotFound)
├── models.py # ORM-модели (StoredFile, Alert)
├── schemas.py # Pydantic-DTO (FileItem, AlertItem, FileUpdate)
├── repositories/
│ ├── file.py # FileRepository — доступ к данным files
│ └── alert.py # AlertRepository — доступ к данным alerts
├── services/
│ ├── file.py # FileService — загрузка, чтение, обновление, удаление
│ └── processing.py # ProcessingService — конвейер scan → metadata → alert
├── api/
│ ├── deps.py # DI-зависимости (сессия → репозитории → сервисы)
│ ├── files.py # роутер /files
│ └── alerts.py # роутер /alerts
├── tasks.py # Celery-приложение + одна задача process_file
└── app.py # create_app() — сборка приложения
Разбиение по пакетам repositories/, services/, api/ выбрано намеренно: слои видны из дерева каталогов. storage, exceptions, config, database — одиночные модули: сущностей мало, отдельные пакеты были бы раздуванием.
Класс Settings на pydantic-settings читает окружение с типизацией и валидацией, собирает database_url и celery_broker_url как свойства. Голый os.environ, разбросанный по service.py/tasks.py, убирается. Рассинхрон брокера (CELERY_BROKER_URL в .env.dev ↔ REDIS_URL в коде) устраняется: имя переменной становится единым.
Единственный create_async_engine и async_sessionmaker. Две точки входа:
get_session()— FastAPI-зависимость (генератор), даёт сессию на запрос и гарантированно закрывает её;session_scope()— асинхронный контекст-менеджер для Celery-задач.
Дублирование engine (было в service.py и tasks.py) исчезает.
Класс FileStorage инкапсулирует каталог хранения и операции над файлами. Запись — потоковая и неблокирующая: UploadFile читается чанками и пишется через anyio.open_file, event loop не блокируется, весь файл не держится в памяти. Чтение/удаление/проверка существования — через anyio.to_thread. Роутер и сервисы больше не трогают pathlib/диск напрямую.
FileRepository и AlertRepository получают сессию в конструкторе и инкапсулируют все запросы (get, list_ordered, add, delete). Сервисы не пишут SQL. Повторные session.get(...) заменяются переиспользованием уже загруженного объекта.
FileService: загрузка (черезFileStorage+FileRepository), получение, список, обновление, удаление. Дублирующая логикаdownloadиз роутера и мёртвыйget_file_pathзаменяются одним методом.ProcessingService: конвейерscan → metadata → alert(перенесён изtasks.py). Работает с уже загруженной записью; порядок вычислений и все значения (scan_status,scan_details,requires_attention,metadata_json, уровни алертов) — без изменений.
Сервисы не знают про HTTP: вместо HTTPException бросают доменные исключения из exceptions.py.
Роутеры files.py и alerts.py содержат только объявления эндпоинтов и вызовы сервисов через DI (deps.py). Доменные исключения транслируются в HTTP через обработчики исключений, зарегистрированные в create_app(). Так коды ответов (404/400/201/204) сохраняются, но HTTP-детали уходят из сервисов.
Celery-приложение и одна задача process_file, выполняющая весь конвейер (см. «Неочевидная оптимизация»). Event loop и engine воркера инициализируются корректно (см. «Надёжность воркера»).
create_app() собирает FastAPI: middleware, роутеры, обработчики исключений. Модульных сайд-эффектов на импорте нет.
Загрузка запускала цепочку из трёх Celery-задач, каждая из которых передавала эстафету следующей через .delay():
scan_file_for_threats → extract_file_metadata → send_file_alert
На обработку одного файла приходилось:
- 3 постановки в брокер и 3 доставки (сериализация/десериализация, round-trip Redis);
- 3 отдельные сессии БД (checkout соединения из пула ×3);
- 3 выборки одной и той же записи по первичному ключу (
session.get(StoredFile, id)в каждой задаче); - 3 независимых event loop-прогонки в воркере.
Подтверждено логом воркера: на каждый файл — три пары received/succeeded.
Разбиение выглядит как «правильное разделение ответственности»: сканирование, метаданные и алерты — разные шаги. Но конвейер строго линейный: нет ветвления, нет параллелизма, каждый шаг всегда следует за предыдущим для той же записи. В таком случае дробление на задачи не даёт ни изоляции сбоев, ни параллелизма — только тройные накладные расходы на брокер, сессии и выборки.
Одна задача process_file(file_id): одна постановка в брокер, одна сессия, одна выборка записи, переиспользуемая всеми тремя шагами. Логика шагов (значения scan/metadata/alert и наблюдаемый переход processing → processed/failed) сохраняется.
| Ресурс | Было | Стало |
|---|---|---|
| Задач в брокере (round-trip) | 3 | 1 |
| Сессий БД (checkout) | 3 | 1 |
SELECT записи по PK |
3 | 1 |
| Прогонов event loop | 3 | 1 |
При потоке в N файлов экономия линейна: −2N обращений к брокеру, −2N сессий, −2N выборок. Латентность обработки падает за счёт устранённых очередей между задачами.
Отдельной Alembic-ревизией добавляются индексы:
alerts.file_id— FK не индексируется Postgres автоматически; нужен для выборок алертов по файлу и для каскадного удаления;files.created_atиalerts.created_at— обе выборки списков сортируют поcreated_at DESC.
Engine больше не создаётся на уровне модуля до появления event loop. Sessionmaker воркера инициализируется лениво внутри рабочего цикла, поэтому asyncpg-пул привязан к тому же loop, в котором исполняется, — исключаются ошибки «Future attached to a different loop».
Было: DELETE /files/{id} для обработанного файла падал с HTTP 500 (ForeignKeyViolationError), потому что FK alerts.file_id не имел ON DELETE CASCADE, а сервис не удалял алерты. Обработанный файл (у него всегда есть алерт) удалить было невозможно.
Стало: отдельной ревизией FK пересоздаётся с ON DELETE CASCADE; удаление файла каскадно удаляет его алерты и возвращает 204 — как и задумано контрактом DELETE.
Обоснование: 500 не был частью контракта — это необработанная ошибка целостности. Исправление приводит поведение в соответствие с объявленным 204, а не ломает контракт. Помечено как осознанное изменение согласно требованию «любое изменение поведения — только явное и задокументированное».
После ревью закрыты названные замечания:
- Метаданные читаются потоково, не целиком.
read_text/read_bytesзаменены на подсчёт по мере чтения: строки/символы — построчной итерацией (universal newlines, результат идентиченlen(text.splitlines())/len(text)), вхождения/Type /Pageв PDF — чанками с overlap на границе. Пиковая память больше не растёт с размером файла. - Пагинация
/filesи/alerts. Добавлены query-параметрыlimit(1–200, по умолчанию 50) иoffset(≥0). Ответ остаётсяlist[...], запрос без параметров совместим — возвращается первая страница. - Retry фоновых задач.
process_fileповторяется при сбоях (autoretry_for, экспоненциальныйretry_backoff,max_retries=3). Конвейер идемпотентен: повтор пересчитывает scan/metadata к тем же значениям, алерт фиксируется только при успешном проходе. - Согласованность файла и БД.
delete: файл удаляется после успешного commit — сбой commit больше не оставляет запись без файла.create: при ошибке commit уже записанный файл удаляется — не остаётся файла-сироты.
Изменение контракта: GET /files и GET /alerts теперь отдают страницу (по умолчанию до 50 записей), а не весь список. Сделано осознанно по прямому замечанию ревьюера.
- + pydantic-settings — обоснованно. В Pydantic v2
BaseSettingsвынесен в отдельный пакет; это стандартный способ типизированной конфигурации в экосистеме FastAPI. Заменяет ручной разборos.environс валидацией и единым источником настроек. Альтернатива (свой парсер env) — больше кода и багов при меньшей надёжности. - anyio — без добавления в зависимости. Неблокирующий файловый I/O реализуется на
anyio, который уже присутствует транзитивно (через Starlette/FastAPI). Специальная библиотекаaiofilesне добавляется — она дала бы то же самое ценой лишней прямой зависимости.
- Публичный контракт API: пути, методы, коды (201/204/400/404), формы запроса/ответа, набор и имена полей DTO (в т.ч. скрытый
stored_name), сортировкиcreated_at DESC. - Бизнес-логика конвейера: правила
scan_status/scan_details/requires_attention, извлечениеmetadata_json(line/char count дляtext/*, approx page count для PDF), уровни и тексты алертов, переходы статусовuploaded → processing → processed/failed. - Команды запуска:
docker compose -f docker-compose.dev.yml upиdocker exec -it backend alembic upgrade head; фронт на/test, бэк на/docs. - Стек: FastAPI + SQLAlchemy (async) + Celery + Next.js; миграции применяются с нуля.
Намеренные отклонения (оба задокументированы выше): DELETE (500 → 204) и пагинация списков GET /files / GET /alerts.