Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
19 changes: 17 additions & 2 deletions .env.dev
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,22 @@ POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_DB=test
POSTGRES_HOST=backend-db
PGPORT=5433
POSTGRES_PORT=5432
PGPORT=5432

# Celery / Redis
CELERY_BROKER_URL=redis://backend-redis:6379/0
CELERY_BROKER_URL=redis://backend-redis:6379/0
REDIS_URL=redis://backend-redis:6379/0

# App
STORAGE_DIR=/backend/storage/files
MAX_UPLOAD_BYTES=52428800
LOG_LEVEL=INFO
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000

# Container resource overrides (optional)
BACKEND_MEM_LIMIT=768M
WORKER_MEM_LIMIT=768M
POSTGRES_MEM_LIMIT=512M
REDIS_MEM_LIMIT=128M
FRONTEND_MEM_LIMIT=512M
12 changes: 11 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,19 @@ build/
dist/
wheels/
*.egg-info
.pytest_cache/

# Virtual environments
.venv
.idea
**/.DS_Store
backend/storage/*
backend/storage/*
!backend/storage/.gitkeep

# Local helpers
_write_batch*.py
uv.lock.bak

# Local docker diagnostics (never commit)
.docker_*.txt
.docker_*.log
183 changes: 183 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
# Архитектура

## Цель

Рефакторинг baseline-MVP файлообменника **без изменения бизнес-правил и публичных API-путей**.
Дополнительно: одна фоновая pipeline-задача, безопасный stream-upload, лимиты Docker, слои frontend, live-обновление статусов в UI, интеграционные/unit-тесты backend.

---

## Backend: слои

```text
api/ HTTP-роуты, валидация входа, коды ответов
use_cases/ оркестрация (CRUD, pipeline)
data/ SQLAlchemy-запросы с projection-колонками
entities/ ORM-таблицы
dto/ Pydantic request/response
storage/ диск (stream, path safety)
scan/ pure-правила проверки (без I/O)
worker/ Celery app + одна job
db/ engine / sessions
settings.py конфигурация из env
errors.py доменные исключения
http_errors.py domain → HTTP (в т.ч. безопасная сериализация 422)
```

### Оптимизация pipeline

**Было (baseline):** 3 Celery-задачи `scan → metadata → alert`
= 3 hop в Redis + 3 DB session + 3 load по PK.

**Стало:** одна задача `jobs.process_uploaded_file`
([worker/jobs.py](backend/src/worker/jobs.py) → [use_cases/pipeline.py](backend/src/use_cases/pipeline.py)):

1. `processing`
2. scan (эвристики)
3. metadata (с лимитом чтения)
4. alert (`info` / `warning` / `critical`)
5. retries с exponential backoff; после исчерпания — `failed` + critical alert

**Бизнес-исходы не менялись:**

| Область | Значения |
|---|---|
| `processing_status` | `uploaded` → `processing` → `processed` / `failed` |
| `scan_status` | `clean` / `suspicious` / `failed` (`null` до обработки) |
| scan rules | extension ∈ `{.exe,.bat,.cmd,.sh,.js}`, size > 10 MiB, pdf mime mismatch |
| alerts | `info` (успех), `warning` (suspicious), `critical` (failed) |

Идемпотентность: если уже `processed` и alert есть → noop.

### Доступ к данным

- List/get API выбирают только колонки `FileItem` / `AlertItem` (**без** `stored_name`)
- Download / delete / worker — узкие projection с `stored_name` при необходимости
- Индексы: `files.created_at`, `alerts.created_at`, `alerts.file_id`
- `alerts.file_id` FK: **ON DELETE CASCADE**

### Upload / защита от DoS

- Потоковая запись (chunk 64 KiB), лимит `MAX_UPLOAD_BYTES` (по умолчанию 50 MiB)
- Oversize → **413** + cleanup partial
- Empty → **400**
- Ошибка записи → **500** (без internals) + unlink partial
- Path resolve через basename — anti path-traversal
- Resumable protocol нет; обрыв соединения чистит partial

### HTTP-ошибки

| Domain | HTTP |
|---|---|
| ResourceNotFoundError | 404 |
| EmptyUploadError | 400 |
| PayloadTooLargeError | 413 |
| ValidationDomainError | 422 |
| RequestValidationError | 422 (JSON-safe: Exception в `ctx` → string) |
| StorageIOError / AppError / unhandled | 500, detail без internals |

### Auth

Без токенов / JWT. Demo открыт в LAN; hardening: path safety, лимиты, CORS, log hygiene.

---

## Frontend: слои

```text
config/ API_BASE_URL
types/ DTO (FileItem, AlertItem)
api/ HTTP-клиент + resource methods
hooks/ useDashboardData (load + polling), useFileUpload
components/ presentational UI
app/page только composition
```

### UX: live-статусы (polling)

После upload worker обновляет БД за сотни мс, а UI раньше делал **один** `loadData` → «залипал» на `uploaded` / UI-`pending`.

**Сейчас** ([useDashboardData.ts](frontend/src/hooks/useDashboardData.ts)):

- silent refresh после upload (без spinner таблиц)
- polling **1.5 s**, пока есть файлы в `uploaded` | `processing`
- kickoff **400 ms**, safety stop **120 s**
- coalesce concurrent loads (очередь silent refresh)
- индикатор в header: «Идёт фоновая обработка…»

### basePath

- `basePath: '/test'` — контракт задания, **сохранён**
- redirect `/` → `/test` (`basePath: false`) — удобный вход с корня
([next.config.ts](frontend/next.config.ts))

---

## Docker / ops

- Код **копируется в образ** (`COPY`) — основной reproducible-запуск
- Dev compose **bind-mount** `./backend` для uvicorn `--reload` (DX, не «логика снаружи»)
- Бинарники upload — named volume **`file-storage`**, общий API ↔ worker
- `deploy.resources` limits (memory/CPU) на сервисах
- Postgres host `127.0.0.1:5433→5432`, healthchecks, alpine images
- Entrypoint:
1. wait Postgres + Redis (`nc`)
2. `alembic upgrade head` **только API**
3. start uvicorn / celery
- `restart: always` — подъём после reboot Docker Desktop

---

## Тесты backend

```text
tests/conftest.py fixtures: temp storage, truncate DB, ASGI client, mock enqueue
tests/test_api_endpoints.py все HTTP-эндпоинты, валидация, 400/413/404/422/204
tests/test_pipeline.py clean / suspicious / missing disk / idempotent
tests/test_storage.py stream, empty, oversize, sanitize name, path safety
tests/test_scan_rules.py границы порогов и комбинации rules
tests/test_use_cases_files.py create/update/delete, soft-fail enqueue
tests/test_http_errors.py матрица domain → HTTP
tests/test_dto_validation.py FileUpdate / title normalize
tests/test_columns.py projection ≡ DTO, stored_name только где нужен
```

Запуск (в контейнере API или локально с dev-extra):

```bash
cd backend
uv sync --extra dev
# Postgres доступен (compose: backend-db)
POSTGRES_HOST=127.0.0.1 POSTGRES_PORT=5433 PGPORT=5433 uv run pytest
```

---

## Запуск стека

```bash
docker compose -f docker-compose.dev.yml up --build
```

| URL | Назначение |
|---|---|
| http://localhost:3000/ | redirect → `/test` |
| http://localhost:3000/test | UI |
| http://localhost:8000/docs | OpenAPI |
| http://localhost:8000/health | liveness |

---

## Что сознательно не меняли

- Публичные path API и wire-format статусов (строки)
- `basePath: '/test'`
- Бизнес-правила scan / alert levels
- Auth (нет в ТЗ)

## Возможные следующие шаги

- StrEnum для статусов (БД остаётся `String`)
- UI rename/delete (API уже есть)
- reprocess endpoint для «застрявших» `uploaded`
- non-root Celery user
43 changes: 38 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,44 @@
2. (Дополнительно) На бэкенде есть возможность неочевидной оптимизации - выполните ее;
3. (Дополнительно) Разбейте логику фронтенда на слои;

**Запуск:**
1. ```docker compose -f docker-compose.dev.yml up```
2. ```docker exec -it backend alembic upgrade head```
## Что сделано в этой ветке

- Backend разбит на слои: `api` / `use_cases` / `data` / `entities` / `storage` / `scan` / `worker`
- Неочевидная оптимизация: 3 Celery-задачи → 1 pipeline job
- Stream upload, лимит размера, cleanup partial, информативные логи, retries
- Projection-запросы (API не читает `stored_name`), индексы, CASCADE delete
- Frontend: types / api / hooks / components
- Docker: healthchecks, ports, resource limits, shared storage volume
- Подробности: [ARCHITECTURE.md](./ARCHITECTURE.md)

**Открыть фронт:** ```http://localhost:3000/test```
**Запуск (одной командой):**
1. `docker compose -f docker-compose.dev.yml up --build`

**Открыть бэк:** ```http://localhost:8000/docs```
При старте backend entrypoint:
- ждёт PostgreSQL и Redis;
- накатывает `alembic upgrade head` (только API-контейнер, без гонки с worker);
- поднимает uvicorn / celery.

`restart: always` — контейнеры поднимаются снова при рестарте Docker Desktop (если стек уже был запущен).

Ручной `docker exec … alembic upgrade head` **не нужен** (оставлен только для отладки).

**Открыть фронт:** `http://localhost:3000/test`

**Открыть бэк:** `http://localhost:8000/docs`

**Health:** `http://localhost:8000/health`

**Тесты backend (локально):**
```bash
cd backend
uv sync --extra dev
uv run pytest
```

### Docker: код в контейнере или снаружи?

- **В образ** копируется исходный код (`COPY`) — основной способ запуска.
- В dev compose `./backend` **примонтирован** для hot-reload; это не «вынос логики наружу», а DX.
- Бинарные загрузки — в volume `file-storage`, общий для API и worker.
- Старт: wait DB/Redis → migrate → app.
7 changes: 7 additions & 0 deletions backend/.dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
__pycache__
*.pyc
.pytest_cache
.venv
storage
tests
*.md
21 changes: 18 additions & 3 deletions backend/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,26 @@ FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
UV_NO_DEV=1 \
UV_PROJECT_ENVIRONMENT=/usr/local
UV_PROJECT_ENVIRONMENT=/usr/local \
PYTHONPATH=/backend

WORKDIR /backend

COPY pyproject.toml uv.lock ./
RUN apt-get update \
&& apt-get install -y --no-install-recommends netcat-openbsd \
&& rm -rf /var/lib/apt/lists/*

COPY pyproject.toml ./
COPY --from=ghcr.io/astral-sh/uv:latest /uv /uvx /bin/
RUN uv sync --locked
RUN uv lock && uv sync --no-dev

COPY src ./src
COPY migrations ./migrations
COPY alembic.ini ./
COPY entrypoint.sh /backend/entrypoint.sh
RUN chmod +x /backend/entrypoint.sh \
&& mkdir -p /backend/storage/files

EXPOSE 8000

ENTRYPOINT ["sh", "/backend/entrypoint.sh"]
34 changes: 34 additions & 0 deletions backend/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/sh
set -e

DB_HOST="${POSTGRES_HOST:-backend-db}"
DB_PORT="${POSTGRES_PORT:-${PGPORT:-5432}}"
REDIS_HOST="${REDIS_HOST:-backend-redis}"
REDIS_PORT="${REDIS_PORT:-6379}"

echo ">>> Waiting for PostgreSQL at ${DB_HOST}:${DB_PORT}..."
until nc -z "$DB_HOST" "$DB_PORT" 2>/dev/null; do
echo " Database not ready, retrying in 2s..."
sleep 2
done
echo ">>> PostgreSQL is ready"

echo ">>> Waiting for Redis at ${REDIS_HOST}:${REDIS_PORT}..."
until nc -z "$REDIS_HOST" "$REDIS_PORT" 2>/dev/null; do
echo " Redis not ready, retrying in 2s..."
sleep 2
done
echo ">>> Redis is ready"

# Migrations only in the API process to avoid races with celery workers
# Run migrations only for the API process (not for the Celery worker).
if [ "$1" = "uvicorn" ] || [ "${RUN_MIGRATIONS:-0}" = "1" ]; then
echo ">>> Applying database migrations (alembic upgrade head)..."
alembic upgrade head
echo ">>> Migrations applied"
else
echo ">>> Skipping migrations (not API process)"
fi

echo ">>> Starting: $*"
exec "$@"
Loading