Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
fd4066f
docs(audit): аудит проблем бэкенда, фронтенда и инфраструктуры
Jul 10, 2026
ba4ca34
docs(architecture): целевая архитектура и разбор оптимизации
Jul 10, 2026
77a0624
refactor(core): единый конфиг (pydantic-settings) и engine с DI
Jul 10, 2026
b78e60e
refactor(storage): неблокирующее потоковое файловое хранилище
Jul 10, 2026
279ba1b
refactor(api): послойное разделение request-path
Jul 10, 2026
30d6d8e
perf(tasks): объединить конвейер scan/metadata/alert в одну задачу
Jul 10, 2026
ea15032
perf(db): индексы под сортировки и внешний ключ
Jul 10, 2026
79faf21
fix(db): ON DELETE CASCADE для алертов — DELETE файла больше не падает
Jul 10, 2026
509b26c
fix(infra): backend и worker явно зависят от backend-redis
Jul 10, 2026
d74b214
chore(frontend): убрать лишние артефакты, починить сборку и иконку
Jul 10, 2026
f8bc8c6
refactor(frontend): разнести page.tsx по слоям
Jul 10, 2026
3429897
test(backend): тесты конвейера обработки и контракта API
Jul 10, 2026
751ab99
docs(readme): карта изменений, структура слоёв и запуск тестов
Jul 10, 2026
bd71479
docs(audit): статус обработки пунктов (исправлено / оставлено осознанно)
Jul 10, 2026
783304f
feat(app): жизненный цикл БД, кэш настроек и health-эндпоинт
Jul 11, 2026
7d77400
refactor(services): вынести scan-правила в чистый модуль
Jul 11, 2026
d2d27ef
chore(backend): линтер и форматтер ruff
Jul 11, 2026
6dbf8f9
test(frontend): юнит-тесты на Vitest
Jul 11, 2026
2789860
ci: GitHub Actions и pre-commit хуки
Jul 11, 2026
c7d2c18
docs(readme): health, линтинг, тесты фронтенда и CI
Jul 11, 2026
c5967bb
chore: убрать .env.dev из репозитория, добавить .env.dev.example
Jul 11, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 0 additions & 9 deletions .env.dev

This file was deleted.

14 changes: 14 additions & 0 deletions .env.dev.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Скопируйте этот файл в .env.dev перед запуском:
# cp .env.dev.example .env.dev
# Значения ниже — для локальной разработки. Для любого не-локального
# окружения задайте собственные секреты и не коммитьте .env.dev.

# Postgres
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changeme
POSTGRES_DB=test
POSTGRES_HOST=backend-db
PGPORT=5433

# Celery / Redis
CELERY_BROKER_URL=redis://backend-redis:6379/0
56 changes: 56 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
name: CI

on:
push:
branches: [master, main]
pull_request:

jobs:
backend:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: postgres
POSTGRES_DB: test
PGPORT: 5433
ports:
- 5433:5433
options: >-
--health-cmd "pg_isready -U postgres -p 5433"
--health-interval 10s
--health-timeout 5s
--health-retries 10
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: astral-sh/setup-uv@v3
- run: uv sync --group dev
- name: Lint
run: |
uv run ruff check src tests
uv run ruff format --check src tests
- name: Tests
env:
POSTGRES_HOST: localhost
run: uv run pytest -q

frontend:
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- run: npm ci
- name: Unit tests
run: npm test
- name: Build
run: npm run build
5 changes: 4 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,7 @@ wheels/
.venv
.idea
**/.DS_Store
backend/storage/*
backend/storage/*

# Local secrets — use .env.dev.example as the template
.env.dev
9 changes: 9 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.8.6
hooks:
- id: ruff
args: [--fix]
files: ^backend/
- id: ruff-format
files: ^backend/
136 changes: 136 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# ARCHITECTURE — целевая архитектура бэкенда

Документ описывает, **к чему** приводится бэкенд и **почему**. Реализуется инкрементально; бизнес-логика и публичный контракт API сохраняются (см. раздел «Что сохранено 1:1»). Ссылки вида `A1`, `P1` — на пункты `AUDIT.md`.

## Принципы

1. **Тонкие контроллеры, толстые сервисы, изолированный доступ к данным.** HTTP-слой только валидирует и оркеструет; бизнес-логика — в сервисах; SQL — в репозиториях.
2. **Один источник правды для инфраструктуры.** Настройки, engine и сессия создаются в одном месте и внедряются через DI.
3. **Ничего лишнего.** Структура соответствует размеру проекта; новые зависимости — только с обоснованием.
4. **Совместимость превыше красоты.** Любое отклонение от исходного поведения — явное и задокументированное.

## Целевая структура

```
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` — одиночные модули: сущностей мало, отдельные пакеты были бы раздуванием.

## Слои и ответственность

### core/config.py — конфигурация (решает `C1`, `C2`)
Класс `Settings` на **pydantic-settings** читает окружение с типизацией и валидацией, собирает `database_url` и `celery_broker_url` как свойства. Голый `os.environ`, разбросанный по `service.py`/`tasks.py`, убирается. Рассинхрон брокера (`CELERY_BROKER_URL` в `.env.dev` ↔ `REDIS_URL` в коде) устраняется: имя переменной становится единым.

### core/database.py — доступ к БД (решает `A1`, `A2`)
Единственный `create_async_engine` и `async_sessionmaker`. Две точки входа:
- `get_session()` — FastAPI-зависимость (генератор), даёт сессию на запрос и гарантированно закрывает её;
- `session_scope()` — асинхронный контекст-менеджер для Celery-задач.

Дублирование engine (было в `service.py` и `tasks.py`) исчезает.

### storage.py — файловое хранилище (решает `I1`, `I2`, `A4`)
Класс `FileStorage` инкапсулирует каталог хранения и операции над файлами. Запись — **потоковая и неблокирующая**: `UploadFile` читается чанками и пишется через `anyio.open_file`, event loop не блокируется, весь файл не держится в памяти. Чтение/удаление/проверка существования — через `anyio.to_thread`. Роутер и сервисы больше не трогают `pathlib`/диск напрямую.

### repositories/ — доступ к данным (решает `A2`, `A3`, `D3`)
`FileRepository` и `AlertRepository` получают сессию в конструкторе и инкапсулируют все запросы (`get`, `list_ordered`, `add`, `delete`). Сервисы не пишут SQL. Повторные `session.get(...)` заменяются переиспользованием уже загруженного объекта.

### services/ — бизнес-логика (решает `A3`, `A5`, `M1`)
- `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`.

### api/ — контроллеры (решает `A4`, `A6`)
Роутеры `files.py` и `alerts.py` содержат только объявления эндпоинтов и вызовы сервисов через DI (`deps.py`). Доменные исключения транслируются в HTTP через обработчики исключений, зарегистрированные в `create_app()`. Так коды ответов (404/400/201/204) сохраняются, но HTTP-детали уходят из сервисов.

### tasks.py — фоновые задачи (решает `P1`, `P2`, `D3`)
Celery-приложение и **одна** задача `process_file`, выполняющая весь конвейер (см. «Неочевидная оптимизация»). Event loop и engine воркера инициализируются корректно (см. «Надёжность воркера»).

### app.py — сборка (решает `A6`)
`create_app()` собирает FastAPI: middleware, роутеры, обработчики исключений. Модульных сайд-эффектов на импорте нет.

## Неочевидная оптимизация (Этап 3): конвейер из трёх задач → одна

### Что было
Загрузка запускала цепочку из трёх 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 выборок. Латентность обработки падает за счёт устранённых очередей между задачами.

## Прочие улучшения производительности

### Индексы под реальные запросы (решает `D1`, `D2`)
Отдельной Alembic-ревизией добавляются индексы:
- `alerts.file_id` — FK не индексируется Postgres автоматически; нужен для выборок алертов по файлу и для каскадного удаления;
- `files.created_at` и `alerts.created_at` — обе выборки списков сортируют по `created_at DESC`.

### Надёжность воркера (решает `P2`)
Engine больше не создаётся на уровне модуля до появления event loop. Sessionmaker воркера инициализируется лениво внутри рабочего цикла, поэтому asyncpg-пул привязан к тому же loop, в котором исполняется, — исключаются ошибки «Future attached to a different loop».

## Осознанные изменения поведения

### DELETE файла с алертами: 500 → 204 (`B1`)
**Было:** `DELETE /files/{id}` для обработанного файла падал с HTTP 500 (`ForeignKeyViolationError`), потому что FK `alerts.file_id` не имел `ON DELETE CASCADE`, а сервис не удалял алерты. Обработанный файл (у него всегда есть алерт) удалить было невозможно.

**Стало:** отдельной ревизией FK пересоздаётся с `ON DELETE CASCADE`; удаление файла каскадно удаляет его алерты и возвращает **204** — как и задумано контрактом `DELETE`.

**Обоснование:** 500 не был частью контракта — это необработанная ошибка целостности. Исправление приводит поведение в соответствие с объявленным `204`, а не ломает контракт. Помечено как осознанное изменение согласно требованию «любое изменение поведения — только явное и задокументированное».

## Зависимости

- **+ pydantic-settings** — обоснованно. В Pydantic v2 `BaseSettings` вынесен в отдельный пакет; это стандартный способ типизированной конфигурации в экосистеме FastAPI. Заменяет ручной разбор `os.environ` с валидацией и единым источником настроек. Альтернатива (свой парсер env) — больше кода и багов при меньшей надёжности.
- **anyio — без добавления в зависимости.** Неблокирующий файловый I/O реализуется на `anyio`, который уже присутствует транзитивно (через Starlette/FastAPI). Специальная библиотека `aiofiles` не добавляется — она дала бы то же самое ценой лишней прямой зависимости.

## Что сохранено 1:1

- **Публичный контракт 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` (см. выше).
Loading