Перейти к содержанию

DZ Hub — полный гид по проекту

Состояние на 2026-10-03: бэкенд — коммит e18a21d, фронтенд — коммит e3cc09a. Если код поменялся, а гид нет, — верить коду.

Этот документ объясняет каждую часть проекта: что это, зачем, как устроено и как связано с остальным. Читать можно подряд или по разделам.

Содержание

  1. Общая картина
  2. Как запустить всё целиком
  3. Бэкенд
  4. Фронтенд
  5. Как фронтенд и бэкенд общаются
  6. Шпаргалка команд
  7. Известные ограничения и несостыковки
  8. Словарь терминов

1. Общая картина

1.1 Что это

DZ Hub — портал для парашютных аэродромов: прыжковые дни, взлёты, запись на них, кабинет парашютиста и панель организатора. Что именно входит в первую версию — в MVP.md. Сейчас реализованы регистрация и вход по email.

1.2 Папки и репозитории

~/Documents/Projects/DZHub/        ← рабочая папка, НЕ git-репозиторий
├── README.md                      ← короткое описание и быстрый старт фронта
├── docs/                          ← документы (не в git!)
│   ├── MVP.md                     ← о чём договорились по продукту
│   └── PROJECT_GUIDE.md           ← этот файл
├── backend/                       ← git-репозиторий: Python, FastAPI, PostgreSQL
└── frontend/                      ← git-репозиторий: React, TypeScript, Vite

Бэкенд и фронтенд — два независимых git-репозитория. У каждого своя история, свои команды и свой Docker. Папка docs/ пока не под git, поэтому её изменения нигде не сохраняются.

1.3 Как части связаны

 Браузер (http://localhost:5173)
   │  1. Загружает страницу и JS-код фронтенда с dev-сервера Vite
   ▼
 ┌────────────────────────┐
 │ frontend (Vite, React) │
 └──────────┬─────────────┘
            │  2. Запросы к API идут напрямую на http://localhost:8000/api/v1/...
            │     (другой порт = другой «источник», поэтому нужен CORS, см. 5.1)
            ▼
 ┌────────────────────────┐          ┌──────────────────────────────┐
 │ backend (FastAPI)      │ ───────► │ PostgreSQL 18 (Docker)       │
 │ :8000                  │  3. SQL  │ localhost:5433 → контейнер   │
 └────────────────────────┘          └──────────────────────────────┘
  1. Браузер открывает фронтенд, который отдаёт Vite.
  2. Код фронтенда (React) отправляет HTTP-запросы на бэкенд и получает JSON.
  3. Бэкенд читает и пишет данные в PostgreSQL.

2. Как запустить всё целиком

Нужны: uv (менеджер Python), Docker, Node.js 22+.

# Терминал 1 — бэкенд
cd ~/Documents/Projects/DZHub/backend
make install      # первый раз: зависимости + .env
make up           # база + API в Docker, миграции применяются сами
# API:          http://localhost:8000
# Документация: http://localhost:8000/api/docs

# Терминал 2 — фронтенд
cd ~/Documents/Projects/DZHub/frontend
npm install       # первый раз
npm run dev       # http://localhost:5173

Открой http://localhost:5173, зарегистрируйся — пользователь появится в базе.

Посмотреть данные: cd backend && make psql, затем select * from users;, выход — \q.


3. Бэкенд

3.1 Стек: что и зачем

Библиотека Версия Зачем
Python 3.13 Язык
FastAPI 0.142 Веб-фреймворк: маршруты, валидация, документация API
uvicorn 0.54 Веб-сервер, который запускает FastAPI-приложение (ASGI-сервер)
Pydantic 2.13 Описание и проверка данных (схемы запросов и ответов)
pydantic-settings 2.15 Чтение настроек из переменных окружения и .env
SQLAlchemy 2.1 Работа с базой: модели-классы вместо голого SQL (ORM)
asyncpg 0.31 Асинхронный драйвер PostgreSQL (через него SQLAlchemy общается с базой)
Alembic 1.20 Миграции: версии схемы базы данных
pwdlib[argon2] 0.3 Хеширование паролей алгоритмом argon2id
PyJWT 2.15 Создание и проверка токенов доступа (JWT)
email-validator 2.3 Проверка формата email (через pydantic[email])

Только для разработки (группа dev, в Docker-образ не попадают):

Библиотека Зачем
pytest Запуск тестов
pytest-asyncio Поддержка async-тестов
httpx HTTP-клиент: тесты обращаются к приложению как настоящий клиент
ruff Линтер и форматтер (стиль кода, типичные ошибки, сортировка импортов)
mypy Проверка типов

Почему асинхронность (async). Пока один запрос ждёт ответа базы, сервер может обрабатывать другие. Поэтому вся цепочка асинхронная: FastAPI → SQLAlchemy (async) → asyncpg.

3.2 Структура файлов

backend/
├── .python-version       версия Python для uv (3.13)
├── pyproject.toml        описание проекта, зависимости, настройки ruff/mypy/pytest
├── uv.lock               точные версии ВСЕХ пакетов (генерируется uv, в git)
├── .venv/                виртуальное окружение с установленными пакетами (не в git)
├── .env.example          шаблон настроек (в git)
├── .env                  твои настройки (НЕ в git: там секреты)
├── .gitignore            что git не отслеживает
├── .dockerignore         что не копировать в Docker-образ
├── Dockerfile            как собрать образ API
├── docker-compose.yml    как запустить базу и API вместе
├── Makefile              короткие команды: make up, make test, ...
├── alembic.ini           настройки Alembic
├── alembic/
│   ├── env.py            как Alembic подключается к базе и находит модели
│   ├── script.py.mako    шаблон файла новой миграции
│   └── versions/         сами миграции (по файлу на изменение схемы)
│       └── 2026_10_02_2152-b81f8147cc9f_create_users.py
├── app/                  код приложения
│   ├── main.py           сборка приложения: create_app()
│   ├── models.py         реестр всех моделей (для Alembic)
│   ├── api/
│   │   ├── router.py     общий роутер /api/v1, сюда подключаются модули
│   │   └── health.py     /health и /health/ready
│   ├── core/             инфраструктура, общая для всех модулей
│   │   ├── config.py     настройки
│   │   ├── db.py         база данных
│   │   ├── logging.py    логирование
│   │   ├── middleware.py request_id + лог запросов
│   │   └── schemas.py    CamelModel
│   └── modules/          бизнес-модули
│       ├── users/        пользователи: модель, схема ответа, поиск
│       └── auth/         регистрация, вход, токены
└── tests/
    ├── conftest.py       общие фикстуры: тестовая база, клиент
    ├── test_health.py
    └── test_auth.py

Файлы __init__.py (пустые) превращают папки в Python-пакеты, чтобы работали импорты вида from app.core.db import Base.

3.3 pyproject.toml — построчно

[project]
name = "dz-hub-backend"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [ ... ]          # библиотеки, нужные для работы приложения
  • dependencies — то, без чего приложение не запустится (попадает в Docker).
  • >=1.20.0 — «эта версия или новее». Точные версии фиксирует uv.lock.
[tool.uv]
package = false

Говорит uv: это приложение, а не библиотека. Его не нужно упаковывать и публиковать, нужно только установить зависимости.

[dependency-groups]
dev = [ "httpx", "mypy", "pytest", "pytest-asyncio", "ruff" ]

Зависимости только для разработки. uv sync ставит их локально, uv sync --no-dev (в Docker) — нет.

[tool.ruff]
line-length = 100             # максимальная длина строки
target-version = "py313"      # можно использовать синтаксис Python 3.13

[tool.ruff.lint]
select = ["E", "W", "F", "I", "B", "UP", "SIM", "ASYNC", "RUF"]
ignore = ["RUF001", "RUF002", "RUF003"]
Код Набор правил
E, W Стиль по PEP 8 (ошибки и предупреждения)
F Pyflakes: неиспользуемые импорты и переменные, неопределённые имена
I Сортировка импортов (isort)
B Bugbear: частые ловушки (изменяемые значения по умолчанию и т. п.)
UP pyupgrade: современный синтаксис (list[str] вместо List[str])
SIM Упрощения кода
ASYNC Ошибки в async-коде (блокирующие вызовы и т. п.)
RUF Правила самого ruff

RUF001–003 отключены: они ругаются на кириллицу в строках и комментариях, путая её с похожими латинскими буквами. У нас комментарии на русском, так что это ложные срабатывания.

[tool.mypy]
strict = true                 # самый строгий режим: у всего должны быть типы
plugins = ["pydantic.mypy"]   # mypy понимает модели Pydantic
exclude = ["^alembic/versions/"]  # файлы миграций генерируются, их не проверяем
[tool.pytest.ini_options]
asyncio_mode = "auto"                         # async-тесты работают без декоратора
asyncio_default_fixture_loop_scope = "session"
asyncio_default_test_loop_scope = "session"   # один event loop на все тесты
testpaths = ["tests"]

Один общий event loop нужен потому, что движок тестовой базы создаётся один раз на всю сессию тестов, а соединения asyncpg привязаны к loop, в котором созданы.

3.4 uv — менеджер пакетов и окружения

  • .python-version — uv использует Python 3.13 (при необходимости скачает сам).
  • .venv/ — изолированное окружение проекта: пакеты ставятся сюда, а не в систему.
  • uv.lock — «снимок» точных версий всех пакетов, включая зависимости зависимостей. Благодаря ему у всех (у тебя, в Docker, в CI) стоят одинаковые версии. Коммитится в git.
  • uv sync — привести .venv в соответствие с uv.lock.
  • uv add <пакет> — добавить зависимость (обновит pyproject.toml и uv.lock). uv add --dev <пакет> — в группу dev.
  • uv run <команда> — выполнить команду внутри .venv (например, uv run pytest). Активировать окружение вручную не нужно.

3.5 Конфигурация: .env и app/core/config.py

Откуда берутся настройки

Класс Settings (pydantic-settings) читает значения в таком порядке (верхнее важнее):

  1. Переменные окружения процесса (например, заданные в docker-compose).
  2. Файл .env в папке, откуда запущено приложение.
  3. Значения по умолчанию в коде Settings.

Имена не зависят от регистра: поле postgres_host читается из POSTGRES_HOST. extra="ignore" — лишние переменные в .env не вызывают ошибку.

.env.example лежит в git как шаблон, .env — твоя локальная копия (в .gitignore), потому что в нём могут быть секреты. make install / make env создают .env из шаблона, если его ещё нет.

Все параметры

Переменная По умолчанию Что делает
ENVIRONMENT local local / test / production. В production включаются строгие проверки секретов
LOG_LEVEL INFO Минимальный уровень логов: DEBUG < INFO < WARNING < ERROR
LOG_FORMAT text text — читать глазами, json — для систем сбора логов
POSTGRES_HOST localhost Адрес базы. В Docker переопределяется на db
POSTGRES_PORT 5433 Порт базы. В Docker переопределяется на 5432
POSTGRES_USER dzhub Пользователь базы
POSTGRES_PASSWORD dzhub Пароль базы (SecretStr, см. ниже)
POSTGRES_DB dzhub Имя базы. Тесты используют dzhub_test
DB_ECHO false true — печатать в лог каждый SQL-запрос
CORS_ORIGINS [] JSON-список сайтов, которым браузер разрешит обращаться к API. Сейчас ["http://localhost:5173"] — фронтенд
JWT_SECRET_KEY dev-значение Секрет подписи токенов. В проде обязателен свой (32+ символа)
ACCESS_TOKEN_EXPIRE_MINUTES 10080 Срок жизни токена: 10080 минут = 7 дней

Эти же POSTGRES_* читает контейнер PostgreSQL в docker-compose: из них он создаёт пользователя и базу при первом запуске. Поэтому настройки приложения и базы всегда совпадают.

Внутри Settings также есть app_name = "DZ Hub API" (заголовок в документации) и jwt_algorithm = "HS256" — их можно переопределить через APP_NAME / JWT_ALGORITHM, но обычно не нужно.

Детали кода

  • SecretStr — тип для секретов. При печати показывает **********, а не значение, поэтому пароль не утечёт в лог случайно. Настоящее значение — .get_secret_value().
  • database_url — собирает строку подключения: postgresql+asyncpg://dzhub:dzhub@localhost:5433/dzhub. Часть +asyncpg говорит SQLAlchemy, каким драйвером пользоваться.
  • _check_production_secrets — валидатор: если ENVIRONMENT=production, а секрет JWT стандартный или короче 32 символов, приложение не запустится. Это защита от выкладки в прод с dev-секретом, которым любой мог бы подделать токен.
  • get_settings() с @lru_cache — настройки читаются один раз и кэшируются. Везде в коде используем get_settings(), а не os.environ.

3.6 Путь одного запроса от начала до конца

Пример: POST /api/v1/auth/register с JSON-телом.

 1. uvicorn принимает HTTP-соединение и передаёт запрос приложению FastAPI
 2. CORSMiddleware: если запрос из браузера с другого сайта — проверяет, разрешён ли он
 3. RequestLoggingMiddleware: назначает request_id, засекает время
 4. Роутер находит обработчик по пути и методу: auth/router.py → register()
 5. FastAPI готовит аргументы обработчика:
      data: RegisterRequest   ← JSON проверяется Pydantic; ошибка → 422 автоматически
      session: SessionDep     ← вызывается get_session(), открывается сессия к базе
 6. Обработчик вызывает сервис: auth/service.py → register(session, data)
 7. Сервис работает с базой через SQLAlchemy → asyncpg → PostgreSQL
 8. Обработчик возвращает TokenResponse → FastAPI превращает его в JSON (camelCase)
 9. get_session() закрывает сессию
10. RequestLoggingMiddleware пишет строку лога и добавляет заголовок X-Request-ID
11. uvicorn отправляет ответ клиенту

3.7 app/main.py — сборка приложения

app = create_app()

Uvicorn запускается командой uvicorn app.main:app: «в модуле app/main.py возьми переменную app».

create_app() по шагам:

  1. setup_logging(...) — настраивает логи первым делом, чтобы всё дальше логировалось в нашем формате.
  2. FastAPI(...):
  3. title — название в документации;
  4. openapi_url="/api/v1/openapi.json" — машиночитаемое описание API (OpenAPI). Из него в будущем фронтенд будет генерировать TypeScript-типы;
  5. docs_url="/api/docs" — интерактивная документация Swagger UI;
  6. redoc_url=None — альтернативная документация ReDoc отключена (хватает одной);
  7. lifespan — код, который выполняется при старте и остановке.
  8. CORS — подключается, только если CORS_ORIGINS не пустой (подробнее в 5.1):
  9. allow_origins — список разрешённых сайтов;
  10. allow_credentials=True — разрешить отправку куки/авторизации;
  11. allow_methods=["*"], allow_headers=["*"] — любые методы и заголовки (в том числе Authorization).
  12. RequestLoggingMiddleware — логирование запросов.
  13. include_router(api_router) — подключает все маршруты /api/v1/....

Порядок middleware. В Starlette последний добавленный middleware оказывается внешним. Поэтому запрос сначала проходит логирование, потом CORS. Так в лог попадают даже запросы, отклонённые CORS.

lifespan. Код до yield выполняется при старте (пишем «Запуск DZ Hub API»), после yield — при остановке: engine.dispose() аккуратно закрывает все соединения с базой.

3.8 База данных: app/core/db.py

Движок и сессии

engine = create_async_engine(database_url, echo=db_echo, pool_pre_ping=True)
SessionFactory = async_sessionmaker(engine, expire_on_commit=False)
  • Engine (движок) — один на всё приложение. Держит пул соединений: открытые соединения с базой переиспользуются, а не открываются заново на каждый запрос (это дорого).
  • pool_pre_ping=True — перед выдачей соединения из пула проверяет, живо ли оно. Если база перезапускалась, «мёртвое» соединение будет заменено, а не вызовет ошибку.
  • echo — печать SQL в лог (DB_ECHO).
  • Session (сессия) — «рабочий сеанс» с базой на время одного запроса: накапливает изменения объектов и отправляет их при commit().
  • expire_on_commit=False — после commit() объекты не «протухают». Без этого обращение к user.email после коммита потребовало бы нового запроса к базе, а в async-коде такие скрытые запросы вызывают ошибки.
async def get_session():
    async with SessionFactory() as session:
        yield session

SessionDep = Annotated[AsyncSession, Depends(get_session)]
  • get_session — зависимость FastAPI (dependency). FastAPI вызывает её на каждый запрос, передаёт сессию в обработчик, а после ответа выполняет код после yield — async with закрывает сессию (и откатывает незакоммиченное, если была ошибка).
  • SessionDep — сокращение. В обработчике достаточно написать session: SessionDep.
  • Правило проекта: коммит делает сервис, а не зависимость. Так сервис сам решает, когда изменения готовы.

Базовый класс моделей

class Base(DeclarativeBase):
    metadata = MetaData(naming_convention=NAMING_CONVENTION)

Все модели наследуются от Base. metadata — это «каталог» всех таблиц; по нему Alembic понимает, какой должна быть схема.

Naming convention — правила имён для ограничений в базе:

Тип Шаблон Пример
Первичный ключ pk_<таблица> pk_users
Уникальность uq_<таблица>_<столбец> uq_users_email
Проверка ck_<таблица>_<имя> ck_users_email_lowercase
Внешний ключ fk_<таблица>_<столбец>_<куда> fk_loads_aircraft_id_aircraft
Индекс ix_<столбец> ix_users_email

Без правил PostgreSQL придумывает имена сам, и потом миграция не может надёжно найти ограничение, чтобы изменить или удалить его.

TimestampMixin

created_at = mapped_column(DateTime(timezone=True), server_default=func.now())
updated_at = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
  • Mixin — класс-«примесь»: модель наследует от него готовые поля.
  • DateTime(timezone=True) → тип timestamptz в PostgreSQL: время хранится с часовым поясом (фактически в UTC), без путаницы с летним временем.
  • server_default=func.now() — значение ставит сама база при вставке (DEFAULT now()).
  • onupdate=func.now() — при каждом UPDATE через SQLAlchemy updated_at обновляется.

3.9 Таблица users (app/modules/users/models.py)

Столбец Тип в PostgreSQL Особенности
id uuid Первичный ключ. DEFAULT uuidv7() — генерирует база
email varchar(320) UNIQUE; CHECK (email = lower(email))
password_hash varchar(255) Хеш argon2id, сам пароль не хранится
first_name varchar(100)
last_name varchar(100)
is_active boolean DEFAULT true. false — аккаунт заблокирован
created_at timestamptz DEFAULT now()
updated_at timestamptz DEFAULT now(), обновляется при изменении
  • UUIDv7 — уникальный идентификатор вида 01a0fdf6-fb77-7142-bbd2-75a5d7f351d4. Версия 7 начинается с метки времени, поэтому новые id идут по порядку — это хорошо для индексов. По id нельзя угадать количество пользователей (в отличие от 1, 2, 3). Функция uuidv7() встроена в PostgreSQL начиная с версии 18.
  • 320 — максимальная длина email по стандарту.
  • CHECK (email = lower(email)) — база не даст записать email с заглавными буквами. Код приводит email к нижнему регистру перед сохранением, а ограничение — страховка от ошибки в коде. Благодаря этому Ion@Mail.md и ion@mail.md — один и тот же пользователь.
  • full_name — не столбец, а свойство Python: first_name + " " + last_name.
  • В модели Mapped[str] без | None означает NOT NULL — значение обязательно.

3.10 Миграции (Alembic)

Что это и зачем

Миграция — файл с инструкцией, как изменить схему базы (upgrade) и как отменить это изменение (downgrade). Миграции выстраиваются в цепочку версий. Alembic хранит в базе таблицу alembic_version с номером текущей версии и знает, какие миграции ещё не применены.

Зачем: схема базы меняется вместе с кодом. Миграции позволяют одинаково обновить базу у тебя, в тестах и в продакшене, не теряя данных.

Как создаётся миграция

make makemigration m="add loads"
  1. Alembic подключается к базе и сравнивает текущую схему базы с моделями в коде (Base.metadata). Это называется autogenerate.
  2. Записывает разницу в новый файл alembic/versions/<дата>-<id>_add_loads.py.
  3. Post-write hooks прогоняют файл через ruff check --fix и ruff format.
  4. Файл нужно прочитать глазами. Autogenerate не всё понимает: например, переименование столбца он видит как «удалить старый + создать новый», что уничтожит данные.
  5. make migrate — применить.

make downgrade откатывает последнюю миграцию, make history показывает цепочку.

alembic.ini

Параметр Значение Смысл
script_location %(here)s/alembic Где лежат env.py и versions/
file_template %%(year)d_%%(month).2d_... Имена файлов начинаются с даты: 2026_10_02_2152-<id>_<описание>.py, поэтому сортируются по времени
prepend_sys_path . Добавить папку проекта в путь импорта, чтобы работал import app
sqlalchemy.url — Не задан: адрес берётся из .env в env.py
[post_write_hooks] ruff_fix, ruff_format Автоформатирование новых миграций
[loggers] и далее Логи самого Alembic при запуске из консоли

alembic/env.py

Сгенерирован официальным async-шаблоном Alembic, изменено три вещи:

config.set_main_option("sqlalchemy.url", get_settings().database_url)  # адрес из .env
target_metadata = Base.metadata          # модели для autogenerate (из app/models.py)
compare_type=True                        # замечать смену типа столбца

Режимы: online (обычный) — подключается к базе и применяет миграции; offline (alembic upgrade head --sql) — только печатает SQL, не подключаясь.

app/models.py — реестр моделей

Alembic видит только те модели, чей модуль был импортирован. Поэтому все модели импортируются в одном файле, а env.py импортирует его. Новая модель → добавь импорт сюда.

Где применяются миграции

  • Локально: make migrate.
  • В Docker: при каждом старте контейнера API выполняется alembic upgrade head (см. command в docker-compose).
  • В тестах миграции не используются: тестовая база создаётся напрямую из моделей (create_all), это быстрее. Поэтому сами миграции проверяются отдельно — make migrate.

3.11 Модули и их устройство

Каждый бизнес-модуль в app/modules/<имя>/ устроен одинаково:

Файл Что внутри Знает про HTTP?
models.py Таблицы SQLAlchemy нет
schemas.py Pydantic-схемы запросов и ответов нет
service.py Бизнес-логика, работа с базой нет — ошибки как свои исключения
router.py Эндпоинты: принимают запрос, зовут сервис, переводят исключения в HTTP-коды да
dependencies.py Зависимости FastAPI (если нужны) да

Почему сервис не знает про HTTP: ту же логику можно вызвать из теста, из фоновой задачи или из Telegram-бота, где нет HTTP-кодов.

app/core/schemas.py — CamelModel

class CamelModel(BaseModel):
    model_config = ConfigDict(alias_generator=to_camel, populate_by_name=True)

В Python принято first_name, в JavaScript — firstName. CamelModel автоматически создаёт для каждого поля псевдоним в camelCase: в JSON уходит firstName, в Python остаётся first_name. populate_by_name=True позволяет создавать объект и по Python-именам. Все схемы API наследуются от CamelModel.

Модуль users

  • models.py — модель User (раздел 3.9).
  • schemas.py — UserPublic: что о пользователе видит клиент — id, email, firstName, lastName, fullName. Пароля и служебных полей там нет. from_attributes=True позволяет построить схему прямо из объекта модели: UserPublic.model_validate(user).
  • service.py:
  • normalize_email — убирает пробелы по краям и приводит к нижнему регистру;
  • get_by_id — найти по id;
  • get_by_email — найти по email (с нормализацией).

Модуль auth

schemas.py — что принимает и отдаёт API:

Схема Поля и правила
RegisterRequest email — валидный email; password — 8–128 символов, пробелы не обрезаются (могут быть частью пароля); firstName, lastName — 1–100 символов, пробелы по краям обрезаются
LoginRequest email — валидный email; password — 1–128 символов
TokenResponse accessToken, tokenType (всегда "bearer"), user (UserPublic)

Если данные не проходят проверку, FastAPI сам отвечает 422 со списком ошибок по полям.

security.py — пароли и токены:

  • Хеширование паролей (argon2id). Пароль не хранится и не может быть восстановлен из хеша. При входе введённый пароль хешируется тем же способом и сравнивается. Argon2id — современный алгоритм, который специально сделан медленным и требовательным к памяти, чтобы перебор паролей был дорогим. Хеш выглядит так: $argon2id$v=19$m=65536,t=3,p=4$... — в нём записаны алгоритм, параметры и случайная «соль».
  • Защита от подбора email по времени (_DUMMY_HASH). Если пользователя с таким email нет, мы всё равно проверяем пароль против «пустышки». Иначе ответ «email не найден» приходил бы мгновенно, а «неверный пароль» — с задержкой на хеширование, и по времени можно было бы понять, зарегистрирован ли email.
  • JWT (JSON Web Token) — токен доступа. Состоит из трёх частей через точку: заголовок.данные.подпись.
  • Данные (payload): sub — id пользователя, iat — когда выдан, exp — когда истекает.
  • Подпись — HMAC-SHA256 (HS256) от первых двух частей с секретом JWT_SECRET_KEY. Кто не знает секрета, не может создать или изменить токен: подпись не сойдётся.
  • Данные токена не зашифрованы, их может прочитать любой (например, на jwt.io). Поэтому в токен не кладём ничего секретного — только id.
  • decode_access_token возвращает id пользователя или None, если подпись неверна, срок истёк или токен повреждён. Поля sub и exp обязательны.

service.py — логика:

  • register:
  • нормализует email;
  • если такой уже есть → EmailAlreadyRegisteredError;
  • создаёт User с хешем пароля, commit();
  • если два запроса регистрируют один email одновременно, второй упадёт на UNIQUE в базе (IntegrityError) — это тоже превращается в EmailAlreadyRegisteredError;
  • refresh(user) подгружает значения, которые поставила база (id, created_at);
  • пишет в лог «Пользователь зарегистрирован» с user_id.
  • authenticate:
  • ищет пользователя по email;
  • проверяет пароль (или «пустышку», если пользователя нет) → иначе InvalidCredentialsError;
  • если is_active = false → UserInactiveError.

dependencies.py — кто сейчас вошёл:

  • HTTPBearer(auto_error=False) — читает заголовок Authorization: Bearer <токен>. auto_error=False — если заголовка нет, не падать сразу, а дать нам самим вернуть понятный 401.
  • get_current_user — достаёт токен → расшифровывает id → загружает пользователя → проверяет is_active. Любая проблема → 401 с заголовком WWW-Authenticate: Bearer.
  • CurrentUser — сокращение. Чтобы закрыть эндпоинт от анонимов, достаточно добавить параметр user: CurrentUser.

router.py — эндпоинты: см. таблицу в следующем разделе. Ошибки сервиса переводятся в HTTP-коды. from None убирает из логов лишнюю цепочку исключений.

3.12 API: все эндпоинты

Базовый адрес: http://localhost:8000/api/v1. Интерактивно — http://localhost:8000/api/docs.

Метод и путь Нужен токен Ответы
GET /health нет 200 {"status":"ok"} — сервер жив
GET /health/ready нет 200 — база доступна; 503 — нет
POST /auth/register нет 201 токен; 409 email занят; 422 неверные данные
POST /auth/login нет 200 токен; 401 неверный email или пароль; 403 аккаунт заблокирован; 422
GET /auth/me да 200 пользователь; 401
POST /auth/logout да 204; 401

Пример регистрации:

POST /api/v1/auth/register
Content-Type: application/json

{"email": "Ion@Mail.md", "password": "parasutist123", "firstName": "Ion", "lastName": "Popescu"}
HTTP/1.1 201 Created
X-Request-ID: b1bdb36ae291

{
  "accessToken": "eyJhbGciOiJIUzI1NiIs...",
  "tokenType": "bearer",
  "user": {
    "id": "01a0fdf6-fb77-7142-bbd2-75a5d7f351d4",
    "email": "ion@mail.md",
    "firstName": "Ion",
    "lastName": "Popescu",
    "fullName": "Ion Popescu"
  }
}

Формат ошибок — стандартный для FastAPI:

{"detail": "Email already registered"}

Ошибки валидации (422) — списком, loc указывает на поле:

{"detail": [{"loc": ["body", "password"], "msg": "String should have at least 8 characters", "type": "string_too_short"}]}

operation_id у каждого эндпоинта (auth_register, auth_login, ...) — стабильное имя операции в OpenAPI. Из него будут генерироваться имена функций в TypeScript-клиенте.

3.13 Логирование

Основные понятия модуля logging

Понятие Что это
Logger Объект, через который пишут: logger = logging.getLogger(__name__). Имена образуют дерево: app.modules.auth.service → app.modules.auth → app → корень
Уровень Важность: DEBUG < INFO < WARNING < ERROR. Записи ниже LOG_LEVEL отбрасываются
Handler Куда писать. У нас один — stdout (стандартный вывод). Docker собирает его в docker compose logs
Formatter Как превратить запись в строку (text или json)
Filter Может изменить или отбросить запись. Наш RequestIdFilter добавляет в каждую запись request_id
propagate Передавать ли запись родительскому логгеру. Все записи «всплывают» к корню, где стоит наш handler

setup_logging(level, fmt)

  1. Создаёт один handler в stdout с нужным formatter и фильтром request_id.
  2. Ставит его на корневой логгер — туда приходят записи всех логгеров: приложения, SQLAlchemy, uvicorn.
  3. Логгеры uvicorn (uvicorn, uvicorn.error) uvicorn настраивает ещё до импорта приложения, со своим форматом. Мы убираем их handlers и включаем propagate, чтобы их записи шли через наш формат.
  4. uvicorn.access (стандартный лог запросов uvicorn) выключаем: запросы логирует наш middleware, с request_id и длительностью.

Форматы

text (LOG_FORMAT=text) — для чтения глазами:

18:53:37 INFO  app.modules.auth.service [b1bdb36ae291] Пользователь зарегистрирован user_id=01a0fdf6-...
18:53:37 INFO  app.request [b1bdb36ae291] POST /api/v1/auth/register 201 duration_ms=118.6

время уровень логгер [request_id] сообщение дополнительные_поля

json (LOG_FORMAT=json) — одна JSON-строка на запись, для систем сбора логов (Loki, ELK и т. п.), где по полям можно искать и строить графики:

{"time": "2026-10-02T17:01:29+00:00", "level": "INFO", "logger": "app.demo", "request_id": "abc123", "message": "Взлёт создан", "load_id": 7}

Если в записи есть исключение, добавляется поле exception с traceback.

Дополнительные поля (extra)

logger.info("Взлёт создан", extra={"load_id": load.id})

Всё, что передано в extra, попадает в лог отдельными полями (load_id=7 в text, "load_id": 7 в json). Так лучше, чем вклеивать значения в текст сообщения: по полям удобно искать.

request_id

  • Зачем. Когда пользователь пишет «у меня ошибка», по request_id можно найти все строки лога именно этого запроса среди тысяч других.
  • Как работает:
  • Middleware берёт заголовок X-Request-ID из запроса (если клиент прислал) или генерирует новый — 12 символов.
  • Кладёт его в contextvar — переменную, у которой своё значение в каждом асинхронном запросе. Параллельные запросы не перепутают свои id.
  • RequestIdFilter подставляет его в каждую запись лога.
  • Возвращает тот же id в заголовке ответа X-Request-ID.
  • Вне запроса (запуск, остановка) request_id равен -.

Middleware логирования (app/core/middleware.py)

Для каждого запроса пишет одну строку: метод, путь, статус, duration_ms.

Ситуация Уровень
Ответ 5xx WARNING
Успешный /health, /health/ready DEBUG — Docker дёргает их каждые 10 секунд, иначе засорили бы лог
Всё остальное INFO
Необработанное исключение ERROR с traceback, затем исключение пробрасывается дальше (клиент получит 500)

Что намеренно не логируется: пароли, токены и email при неудачном входе (это персональные данные). При регистрации логируется только user_id.

3.14 Health-эндпоинты (app/api/health.py)

  • /health (liveness) — «процесс жив и отвечает». Базу не трогает. Его проверяет Docker healthcheck: если он перестанет отвечать, контейнер помечается unhealthy.
  • /health/ready (readiness) — «готов обслуживать запросы»: выполняет SELECT 1 в базе. Если база недоступна → 503. Пригодится для мониторинга и балансировщиков.

Почему два: если база временно недоступна, перезапускать сам API бессмысленно — он жив, просто не готов.

3.15 Docker

Понятия

Понятие Что это
Образ (image) Готовый «слепок» системы с приложением. Собирается по Dockerfile
Контейнер Запущенный экземпляр образа: изолированный процесс со своей файловой системой и сетью
Том (volume) Хранилище данных, которое переживает удаление контейнера. Там лежат данные PostgreSQL
Порт A:B Порт A на твоём компьютере ведёт в порт B внутри контейнера
Сеть compose Контейнеры одного compose видят друг друга по имени сервиса (db, api)

Dockerfile — построчно

Сборка в два этапа (multi-stage): на первом ставим зависимости, во второй (итоговый) образ копируем только результат. Так итоговый образ меньше и в нём нет инструментов сборки.

# syntax=docker/dockerfile:1

Включает современный синтаксис Dockerfile (нужен для --mount).

Этап 1 — builder:

FROM python:3.13-slim AS builder

Базовый образ: минимальный Debian с Python 3.13.

COPY --from=ghcr.io/astral-sh/uv:0.12 /uv /bin/uv

Берём бинарник uv из официального образа uv.

ENV UV_COMPILE_BYTECODE=1 UV_LINK_MODE=copy UV_PYTHON_DOWNLOADS=0
  • UV_COMPILE_BYTECODE=1 — заранее скомпилировать .py в байткод .pyc: контейнер стартует быстрее;
  • UV_LINK_MODE=copy — копировать файлы пакетов, а не делать ссылки на кэш (кэш на следующем этапе недоступен);
  • UV_PYTHON_DOWNLOADS=0 — не скачивать Python, использовать тот, что в образе.
WORKDIR /app
RUN --mount=type=cache,target=/root/.cache/uv \
    --mount=type=bind,source=uv.lock,target=uv.lock \
    --mount=type=bind,source=pyproject.toml,target=pyproject.toml \
    uv sync --locked --no-dev --no-install-project

Устанавливаем только зависимости, ещё без нашего кода: - --mount=type=cache — кэш скачанных пакетов между сборками (быстрее пересборка); - --mount=type=bind — временно подкладываем uv.lock и pyproject.toml; - --locked — версии строго из uv.lock; если он не совпадает с pyproject.toml — ошибка; - --no-dev — без pytest, ruff и т. п.

Зачем отдельный шаг. Docker кэширует каждый шаг. Пока uv.lock не менялся, этот медленный шаг берётся из кэша, даже если код поменялся.

COPY . .
RUN --mount=type=cache,target=/root/.cache/uv uv sync --locked --no-dev

Копируем весь код (кроме исключённого в .dockerignore) и завершаем установку.

Этап 2 — итоговый образ:

FROM python:3.13-slim
RUN groupadd --system app && useradd --system --gid app --no-create-home app

Чистый образ и системный пользователь app без прав администратора.

WORKDIR /app
COPY --from=builder --chown=app:app /app /app

Копируем из builder готовую папку (код + .venv), владелец — app.

ENV PATH="/app/.venv/bin:$PATH" PYTHONUNBUFFERED=1
  • PATH — команды uvicorn, alembic берутся из .venv;
  • PYTHONUNBUFFERED=1 — вывод сразу уходит в лог, без буферизации (иначе логи могут появляться с задержкой).
USER app

Дальше всё выполняется не от root. Если приложение взломают, у злоумышленника не будет прав администратора внутри контейнера.

EXPOSE 8000
HEALTHCHECK --interval=10s --timeout=3s --start-period=10s --retries=3 \
    CMD ["python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/api/v1/health')"]
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
  • EXPOSE — документирует, что приложение слушает 8000;
  • HEALTHCHECK — каждые 10 секунд дёргать /health; 3 неудачи подряд → unhealthy. Используется стандартная библиотека Python, потому что curl в slim-образе нет;
  • CMD — команда запуска по умолчанию. --host 0.0.0.0 — слушать на всех интерфейсах, иначе снаружи контейнера сервер недоступен.

.dockerignore

Не копировать в образ: .venv (собирается внутри), .env (секреты передаются при запуске), .git, кэши, tests. Образ меньше, секреты в него не попадают.

docker-compose.yml — построчно

name: dzhub

Имя проекта: контейнеры называются dzhub-db-1, dzhub-api-1, том — dzhub_pgdata.

Сервис db — PostgreSQL:

  db:
    image: postgres:18-alpine          # официальный образ, Alpine — маленький
    restart: unless-stopped            # перезапускать при сбое, пока не остановили вручную
    environment:
      POSTGRES_USER: ${POSTGRES_USER:-dzhub}       # из .env, иначе dzhub
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dzhub}
      POSTGRES_DB: ${POSTGRES_DB:-dzhub}
    ports:
      - "${POSTGRES_PORT:-5433}:5432"  # localhost:5433 → 5432 в контейнере
    volumes:
      - pgdata:/var/lib/postgresql     # данные в томе, переживают перезапуск
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
      interval: 5s
      timeout: 3s
      retries: 10
  • ${VAR:-default} — compose подставляет значение из .env (или из окружения), иначе значение после :-.
  • Почему 5433. На твоём Mac уже установлен свой PostgreSQL, и он занимает 5432. Если бы контейнер тоже был на 5432, запросы на localhost:5432 уходили бы в локальную базу.
  • Путь тома. В PostgreSQL 18 образ хранит данные в /var/lib/postgresql/18/docker, поэтому монтируется родительская папка /var/lib/postgresql.
  • pg_isready — утилита PostgreSQL: «готова ли база принимать подключения». $$ — экранирование $ для compose: переменная раскрывается внутри контейнера.

Сервис api — наше приложение:

  api:
    build: .                     # собрать образ по Dockerfile из текущей папки
    env_file: .env               # передать в контейнер все переменные из .env
    environment:
      POSTGRES_HOST: db          # внутри сети compose база доступна по имени сервиса
      POSTGRES_PORT: 5432        # и на своём внутреннем порту
    ports:
      - "8000:8000"
    depends_on:
      db:
        condition: service_healthy   # ждать, пока база станет healthy
    command: sh -c "alembic upgrade head && uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload"
    volumes:
      - ./app:/app/app           # код с твоего диска «подменяет» код в образе
      - ./alembic:/app/alembic
  • environment важнее env_file, поэтому localhost:5433 из .env заменяется на db:5432.
  • command заменяет CMD из Dockerfile: сначала миграции, потом сервер. --reload — перезапуск при изменении файлов (это режим разработки).
  • Bind mounts (./app:/app/app) — правишь код на компьютере, контейнер сразу видит изменения и перезапускается. Но: новые зависимости (uv add) в образ так не попадут — нужна пересборка make up (в ней есть --build).
volumes:
  pgdata:

Объявление именованного тома.

3.16 Makefile

make <команда> — сокращения для длинных команд. Список: make help.

Команда Что выполняет Когда использовать
make help Печатает список команд (по комментариям ##) Забыл команду
make install make env + uv sync Первый запуск, после git pull
make env Создаёт .env из .env.example, если его нет Обычно вызывается автоматически
make dev uv run uvicorn app.main:app --reload --port 8000 Разработка: API локально, база в Docker
make db docker compose up -d --wait db Поднять только базу
make up docker compose up -d --build --wait Всё в Docker; пересобирает образ
make down docker compose down Остановить (данные сохраняются)
make restart down + up Перезапуск
make logs docker compose logs -f Смотреть логи (Ctrl+C — выход)
make ps docker compose ps Статус контейнеров
make psql Консоль PostgreSQL в контейнере Посмотреть данные
make migrate alembic upgrade head Применить миграции
make makemigration m="..." alembic revision --autogenerate -m "..." Изменил модели
make downgrade alembic downgrade -1 Откатить последнюю миграцию
make history alembic history --verbose Посмотреть цепочку миграций
make test pytest Тесты (нужна make db)
make lint ruff check + ruff format --check Проверить стиль
make format ruff check --fix + ruff format Исправить стиль
make typecheck mypy . Проверить типы
make check lint + typecheck + test Перед коммитом
make clean docker compose down -v Удаляет данные базы!
  • -d — запустить в фоне; --wait — дождаться, пока контейнеры станут healthy.
  • .PHONY — эти имена — команды, а не файлы.
  • $$ в Makefile — экранирование $.

Два режима работы:

make up (всё в Docker) make db + make dev
Где API в контейнере на твоём компьютере
Миграции автоматически при старте вручную make migrate
Отладчик VS Code неудобно работает
Новые зависимости нужна пересборка (make up) сразу после uv add

Одновременно оба режима не запустить: оба используют порт 8000.

3.17 Тесты

make db     # база должна работать
make test

Отдельная тестовая база

Тесты не трогают твою базу dzhub: они работают в dzhub_test.

tests/conftest.py содержит фикстуры — функции, которые pytest вызывает, чтобы подготовить окружение для теста:

Фикстура Область Что делает
engine вся сессия тестов Создаёт базу dzhub_test, если её нет (через AUTOCOMMIT: CREATE DATABASE нельзя выполнить в транзакции). Удаляет и заново создаёт все таблицы из моделей
session каждый тест Открывает соединение и внешнюю транзакцию. Сессия работает в режиме create_savepoint: commit() в коде приложения становится точкой сохранения (SAVEPOINT) внутри этой транзакции. В конце теста — rollback(): всё, что тест записал, исчезает
client каждый тест HTTP-клиент, который ходит прямо в приложение (без сети, через ASGITransport). Подменяет зависимость get_session на тестовую сессию

В итоге каждый тест начинает с пустой базы и не влияет на другие.

app.dependency_overrides — механизм FastAPI: на время теста подменить зависимость другой функцией. Это главный инструмент тестирования в FastAPI.

Что покрыто (16 тестов)

  • test_health.py: /health; генерация X-Request-ID; передача своего X-Request-ID; /health/ready с базой.
  • test_auth.py:
  • регистрация возвращает токен и пользователя; email приведён к нижнему регистру; имя без лишних пробелов; хеша пароля нет в ответе;
  • повторный email (в другом регистре) → 409;
  • ошибки валидации по всем четырём полям → 422;
  • пароль в базе — хеш argon2id;
  • вход (email в другом регистре работает), неверный пароль → 401, неизвестный email → такой же 401, заблокированный → 403;
  • /me с токеном, без токена (401 + WWW-Authenticate), с поддельным токеном → 401;
  • выход → 204.

3.18 Качество кода

  • ruff — make lint проверяет, make format исправляет.
  • mypy strict — make typecheck. Каждая функция с типами аргументов и результата.
  • make check — всё сразу. Запускай перед коммитом.

CI (автоматических проверок на сервере) для бэкенда пока нет.

3.19 Git

  • Ветка main, коммиты a52feee и e18a21d.
  • .gitignore исключает .env (секреты), .venv, __pycache__, кэши ruff/mypy/pytest.
  • alembic/versions/.gitkeep — пустой файл, чтобы git сохранил папку versions/ (git не хранит пустые папки, а без неё Alembic не работает).
  • Удалённого репозитория (GitHub) пока нет.

3.20 Безопасность — сводка

Мера Где
Пароли — argon2id, не восстанавливаются auth/security.py
Одинаковый ответ и время для «нет email» и «неверный пароль» auth/service.py, security.py
Подписанные JWT со сроком жизни auth/security.py
Прод не запустится с dev-секретом core/config.py
Секреты не в git и не в образе .gitignore, .dockerignore
SecretStr — пароли не попадают в логи core/config.py
Контейнер работает не от root Dockerfile
Email уникален без учёта регистра на уровне базы users/models.py
Пароли и токены не логируются middleware.py, auth/service.py
CORS разрешает только фронтенд .env → CORS_ORIGINS

4. Фронтенд

4.1 Стек

Библиотека Зачем
React 19 Интерфейс из компонентов
TypeScript 6 JavaScript с типами
Vite 8 Dev-сервер с мгновенной перезагрузкой и сборка для продакшена
React Router 8 Страницы и переходы без перезагрузки
TanStack Query 5 Загрузка данных с сервера: кэш, повторы, состояния загрузки
react-hook-form + zod Формы и проверка введённых данных
i18next / react-i18next Переводы: румынский и русский
Tailwind CSS 4 Стили через классы (px-4 text-sm)
Radix UI, class-variance-authority, clsx, tailwind-merge, lucide-react Основа UI-компонентов в стиле shadcn/ui и иконки
MSW Фейковый бэкенд (моки) для тестов и работы без сервера
Vitest + Testing Library Тесты
ESLint, Prettier, Husky, lint-staged Качество кода, автоформат, проверки перед коммитом

4.2 Структура

frontend/
├── index.html              HTML-страница; <title> берётся из VITE_APP_NAME
├── vite.config.ts          настройки Vite: плагины, алиас @, порт
├── vitest.config.ts        настройки тестов
├── tsconfig*.json          настройки TypeScript
├── eslint.config.js        правила линтера, в том числе архитектурные
├── .prettierrc.json        стиль форматирования
├── .env*                   настройки окружения (см. 4.3)
├── Dockerfile, nginx/      сборка и раздача в продакшене
├── .github/workflows/ci.yml проверки на GitHub
├── public/                 статика как есть (favicon, mockServiceWorker.js)
└── src/
    ├── main.tsx            запуск приложения
    ├── index.css           Tailwind и дизайн-токены (цвета, радиусы)
    ├── app/                сборка: провайдеры, роутер, layouts, системные страницы
    ├── config/             env.ts (все настройки) и backend.ts (адреса бэкенда)
    ├── features/           бизнес-модули (сейчас только auth)
    ├── shared/             переиспользуемое без бизнес-логики: api, ui, lib, config
    ├── i18n/               переводы
    ├── mocks/              фейковый бэкенд (MSW)
    └── test/               настройка тестов

4.3 Настройки окружения

Vite загружает файлы по порядку (последующие перекрывают предыдущие): .env → .env.[режим] → .env.local → .env.[режим].local. Режим: development для npm run dev, production для npm run build, test для тестов. Файлы *.local не коммитятся — это твои личные переопределения.

Переменная Значение сейчас Что делает
VITE_APP_NAME DZ Hub Название в шапке и <title>
VITE_APP_ENV development Окружение → адрес бэкенда из src/config/backend.ts
VITE_API_TIMEOUT_MS 15000 Таймаут запроса (15 секунд)
VITE_ENABLE_MOCKS false true — работать на фейковом бэкенде, без сервера
VITE_DEFAULT_LOCALE ro Язык по умолчанию
VITE_SUPPORTED_LOCALES ro,ru Доступные языки
VITE_ENABLE_QUERY_DEVTOOLS true в dev Панель отладки TanStack Query
DEV_SERVER_PORT 5173 Порт dev-сервера (без VITE_ — в браузер не попадает)

Важно: всё с префиксом VITE_ вшивается в JavaScript, который скачивает браузер. Никаких секретов туда.

src/config/backend.ts — таблица адресов бэкенда по окружениям. Сейчас одно: development → http://localhost:8000. Префикс API /api/v1 общий.

src/config/env.ts — единая точка доступа к настройкам. Проверяет переменные zod-схемой (тип, обязательность, что язык по умолчанию входит в список). Если что-то не так, вместо белого экрана показывается понятная ошибка. В коде используется env.apiUrl и т. п., а не import.meta.env напрямую.

4.4 Запуск приложения (src/main.tsx)

  1. Загружает env (с проверкой).
  2. Если включены моки — подменяет window.fetch (см. 4.9).
  3. Загружает переводы.
  4. Рендерит <App /> в <div id="root">.

Модули грузятся динамически (await import(...)), чтобы ошибка конфигурации отобразилась на экране. При VITE_ENABLE_MOCKS=false код моков полностью вырезается из сборки.

<App /> = AppProviders (TanStack Query, наблюдатель сессии, devtools) + RouterProvider.

4.5 Маршруты и защита страниц

Путь Страница Доступ
/login Вход только гостям
/register Регистрация только гостям
/ Главная (заглушка) только вошедшим
* 404 всем
  • RequireAuth — пока сессия грузится, показывает спиннер; гостя отправляет на /login и запоминает, откуда он пришёл, чтобы после входа вернуть туда же.
  • RedirectIfAuthenticated — вошедшего пользователя со страниц входа/регистрации отправляет на главную.
  • AppLayout — шапка (название, имя пользователя, переключатель языка, выход); PublicLayout — обёртка для страниц входа и регистрации.

4.6 Работа с API (src/shared/api/)

  • http-client.ts — обёртка над fetch:
  • добавляет Accept: application/json и Content-Type для тела;
  • добавляет Authorization: Bearer <токен>, если токен есть (кроме запросов с auth: false);
  • таймаут через AbortSignal.timeout;
  • при ошибке HTTP бросает ApiError; при 401 на запросе с токеном вызывает обработчик, который сбрасывает сессию;
  • ответ 204 → undefined.
  • api-error.ts — ApiError: тип (http / network / timeout), статус, сообщение, ошибки валидации из detail. Понимает оба формата ошибок FastAPI.
  • clients/auth.ts — authClient и типы (User, RegisterRequest, TokenResponse...), повторяющие схемы бэкенда. Позже будут генерироваться из OpenAPI автоматически.
  • token-storage.ts — хранит токен в localStorage под ключом dz.accessToken. Вся работа с токеном только через этот модуль.
  • query-client.ts — настройки TanStack Query: данные «свежие» 30 секунд, без перезапроса при возврате на вкладку, ошибки 4xx не повторяются, остальные — до 2 раз.

4.7 Сессия пользователя (src/features/auth/api/session.ts)

  • useSession() — единственный источник правды «вошёл ли пользователь»: loading / anonymous / authenticated + user. Если токен есть, делает GET /auth/me; при 401 удаляет токен.
  • useLogin() / useRegister() — отправляют форму; при успехе сохраняют токен и сразу кладут пользователя в кэш (лишний запрос /me не нужен).
  • useLogout() — POST /auth/logout, затем удаляет токен и очищает весь кэш.
  • useUnauthorizedRedirect() — подключён в корне: любой 401 от API сбрасывает сессию, и RequireAuth отправляет на страницу входа.

4.8 Формы (LoginForm, RegisterForm)

  • react-hook-form управляет полями, zod (model/schemas.ts) проверяет их до отправки. Правила повторяют бэкенд: имя 1–100, пароль 8–128, валидный email.
  • Сообщения ошибок в схеме — это ключи переводов, переводятся при показе.
  • Ошибки сервера:
  • вход: 401 → «неверный email или пароль», 403 → «аккаунт отключён»;
  • регистрация: 409 → ошибка прямо у поля email, 422 → «некорректные данные».
  • Доступность: aria-invalid, aria-describedby, role="alert" для ошибок.

4.9 Моки (MSW)

  • src/mocks/handlers/auth.ts — фейковые /auth/login, /auth/register, /auth/me, /auth/logout, ведут себя как настоящий бэкенд (те же коды и формат ответов).
  • src/mocks/data.ts — «база» в памяти; демо-пользователь demo@example.md / password123.
  • В браузере (VITE_ENABLE_MOCKS=true) подменяется window.fetch: запросы к API получают ответы моков и на сервер не уходят. Service Worker не используется: его обходят блокировщики и жёсткая перезагрузка.
  • В тестах (src/test/setup.ts) моки работают через msw/node. Неожиданный запрос без мока = ошибка теста.

4.10 Переводы (i18n)

  • Файлы src/i18n/locales/{ro,ru}/{common,auth}.json. Румынский — эталон: TypeScript берёт типы ключей из него, поэтому опечатка в ключе — ошибка компиляции.
  • Язык определяется по сохранённому выбору (localStorage, ключ dz.locale), иначе по языку браузера. <html lang> обновляется при смене языка.

4.11 Стили

Tailwind CSS 4. В src/index.css заданы дизайн-токены: цвета (primary, muted, destructive...), радиусы, шрифт. Компоненты используют только токены (bg-primary), поэтому фирменные цвета или тёмную тему можно поменять в одном месте. UI-компоненты (Button, Card, Input, Label, FormField, Spinner, LanguageSwitcher) — в src/shared/ui. cn() склеивает классы и убирает конфликтующие.

4.12 Архитектурные правила (проверяет ESLint)

app ──► features ──► shared
  • shared не импортирует app и features.
  • Чужой модуль — только через его публичный index.ts: @/features/auth, а не @/features/auth/components/LoginForm.
  • Между слоями — алиас @/, внутри модуля — относительные пути.
  • consistent-type-imports — типы импортируются через import type.

4.13 TypeScript, Prettier, Husky, CI

  • TypeScript в строгом режиме, плюс noUncheckedIndexedAccess (элемент массива может быть undefined), noUnusedLocals/Parameters и др. Алиас @/* → src/*.
  • Prettier: без точек с запятой, одинарные кавычки, ширина 100, сортировка Tailwind-классов.
  • .editorconfig: UTF-8, LF, отступ 2 пробела (4 для Python).
  • Husky + lint-staged: перед каждым коммитом ESLint и Prettier прогоняются по изменённым файлам.
  • GitHub Actions (.github/workflows/ci.yml): на push в main и pull request — typecheck, lint, format:check, тесты, сборка. Заработает, когда репозиторий будет на GitHub.
  • VS Code: рекомендованные расширения (ESLint, Prettier, Tailwind, i18n Ally, Vitest, EditorConfig) и настройки в корневой .vscode/.

4.14 Команды фронтенда

Команда Что делает
npm run dev Dev-сервер http://localhost:5173
npm run build Проверка типов + сборка в dist/
npm run preview Посмотреть собранную версию
npm test / npm run test:watch Тесты
npm run typecheck Типы
npm run lint / lint:fix ESLint
npm run format / format:check Prettier
npm run check Всё сразу, как в CI

4.15 Docker фронтенда

  • Этап build: node:22-alpine, npm ci, npm run build. VITE_* передаются как build args, потому что вшиваются при сборке.
  • Этап runtime: nginx раздаёт собранные файлы:
  • /assets/ кэшируются на год (в именах файлов хэш, при изменении меняется имя);
  • любой другой путь → index.html, чтобы работали адреса React Router;
  • /api/ проксируется на API_UPSTREAM (по умолчанию http://backend:8000);
  • gzip-сжатие.
  • См. несостыковку в разделе 7.

5. Как фронтенд и бэкенд общаются

5.1 CORS

Браузер считает «источником» (origin) комбинацию протокол + домен + порт. http://localhost:5173 (фронтенд) и http://localhost:8000 (API) — разные источники. По умолчанию браузер не даёт JavaScript читать ответы с чужого источника — это защита от сайтов, которые пытаются от твоего имени обращаться к другим сервисам.

CORS — способ серверу сказать браузеру «этому источнику можно». Перед запросом с Authorization или JSON-телом браузер отправляет предварительный запрос OPTIONS (preflight). Бэкенд отвечает заголовками Access-Control-Allow-Origin и т. п., и только тогда браузер отправляет настоящий запрос.

Поэтому: - в backend/.env: CORS_ORIGINS=["http://localhost:5173"]; - в vite.config.ts: strictPort: true — если порт 5173 занят, Vite не перейдёт молча на 5174 (с которого CORS бы не пустил), а выдаст ошибку.

5.2 Контракт данных

  • JSON в camelCase (на бэкенде CamelModel).
  • Ошибки — { "detail": "..." } или список для 422 (на фронтенде их разбирает ApiError).
  • Типы на фронтенде (clients/auth.ts) пока написаны вручную по схемам бэкенда. Цель — генерировать их из http://localhost:8000/api/v1/openapi.json.

5.3 Сценарии

Регистрация:

Форма → zod-проверка → POST /auth/register
  → бэкенд: проверка → хеш пароля → INSERT users → JWT
  ← 201 { accessToken, user }
Фронтенд: токен → localStorage, user → кэш сессии → переход в приложение

Открытие приложения, когда токен уже есть:

useSession → GET /auth/me (Authorization: Bearer ...)
  → бэкенд: проверка подписи и срока → SELECT user → is_active?
  ← 200 user           → показываем приложение
  ← 401 (токен истёк)  → удаляем токен → страница входа

Выход:

POST /auth/logout ← 204 → удаляем токен и очищаем кэш

6. Шпаргалка команд

# Бэкенд
cd backend
make up              # запустить всё в Docker
make down            # остановить
make logs            # логи
make psql            # консоль базы
make makemigration m="описание" && make migrate   # изменить схему
make check           # проверки перед коммитом

# Фронтенд
cd frontend
npm run dev          # запустить
npm run check        # проверки перед коммитом

7. Известные ограничения и несостыковки

Сознательные упрощения:

  1. Выход не отзывает токен на сервере. Токен удаляется только во фронтенде; украденный токен действует до истечения срока (7 дней). Решение в будущем — короткий access-токен
  2. refresh-токен с отзывом.
  3. Токен хранится в localStorage. Если на сайт удастся внедрить чужой JavaScript (XSS), он сможет прочитать токен. Альтернатива — httpOnly-cookie; переход затронет только token-storage.ts и бэкенд.
  4. Нет подтверждения email и сброса пароля.
  5. Нет CI для бэкенда и удалённых репозиториев.
  6. docs/ не под git — документы нигде не сохраняются, кроме твоего диска.
  7. Тесты не прогоняют миграции — схема в тестах строится из моделей.

Несостыковки, которые стоит поправить:

  1. Docker-сборка фронтенда. Фронтенд теперь обращается к бэкенду по абсолютному адресу из backend.ts (http://localhost:8000), а nginx-конфиг рассчитан на прокси /api/ на том же домене (и комментарий там говорит «CORS не нужен»). В контейнере прокси nginx фактически не используется. Нужно решить для продакшена: либо фронт ходит на относительный /api/v1 через nginx (без CORS), либо на отдельный домен API (с CORS), и тогда убрать прокси из nginx.

8. Словарь терминов

Термин Объяснение
API Набор адресов, по которым программы обмениваются данными
Эндпоинт Один адрес API с методом: POST /auth/login
HTTP-метод GET — получить, POST — создать/выполнить, PATCH — изменить, DELETE — удалить
HTTP-статус Код результата: 2xx успех, 4xx ошибка клиента, 5xx ошибка сервера
JSON Текстовый формат данных: {"email": "a@b.md"}
ORM Работа с таблицами как с классами Python (SQLAlchemy)
Модель Класс, описывающий таблицу (User → users)
Схема (Pydantic) Описание формы данных запроса или ответа с проверками
Миграция Версионированное изменение схемы базы
Зависимость (FastAPI) Функция, результат которой FastAPI подставляет в обработчик (SessionDep, CurrentUser)
Middleware Код, через который проходит каждый запрос до и после обработчика
ASGI Стандарт связи асинхронного Python-приложения с веб-сервером (uvicorn ↔ FastAPI)
Пул соединений Набор открытых соединений с базой, которые переиспользуются
Транзакция Группа изменений, которая применяется целиком или не применяется вовсе
SAVEPOINT Точка сохранения внутри транзакции, к которой можно откатиться
Хеш Необратимый «отпечаток» данных фиксированной длины
Соль Случайная добавка к паролю перед хешированием: одинаковые пароли дают разные хеши
JWT Подписанный токен с данными (id пользователя, срок действия)
Bearer-токен Токен в заголовке Authorization: Bearer <токен>: «кто предъявил, тот и владелец»
CORS Механизм, разрешающий браузеру запросы между разными источниками
Origin (источник) Протокол + домен + порт: http://localhost:5173
Docker-образ / контейнер / том Слепок системы / запущенный экземпляр / постоянное хранилище данных
Healthcheck Регулярная проверка «жив ли сервис»
Liveness / readiness «Процесс жив» / «готов обслуживать запросы»
Фикстура (pytest) Функция, готовящая окружение для теста
Линтер Программа, которая ищет ошибки и проблемы стиля в коде
Мок Подделка внешней системы (бэкенда) для тестов и разработки
contextvar Переменная со своим значением в каждой асинхронной задаче
request_id Уникальный номер запроса для поиска его строк в логах