DZ Hub — полный гид по проекту¶
Состояние на 2026-10-03: бэкенд — коммит
e18a21d, фронтенд — коммитe3cc09a. Если код поменялся, а гид нет, — верить коду.
Этот документ объясняет каждую часть проекта: что это, зачем, как устроено и как связано с остальным. Читать можно подряд или по разделам.
Содержание¶
- Общая картина
- Как запустить всё целиком
- Бэкенд
- Фронтенд
- Как фронтенд и бэкенд общаются
- Шпаргалка команд
- Известные ограничения и несостыковки
- Словарь терминов
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 → контейнер │
└────────────────────────┘ └──────────────────────────────┘
- Браузер открывает фронтенд, который отдаёт Vite.
- Код фронтенда (React) отправляет HTTP-запросы на бэкенд и получает JSON.
- Бэкенд читает и пишет данные в 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) читает значения в таком порядке (верхнее важнее):
- Переменные окружения процесса (например, заданные в docker-compose).
- Файл
.envв папке, откуда запущено приложение. - Значения по умолчанию в коде
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() по шагам:
setup_logging(...)— настраивает логи первым делом, чтобы всё дальше логировалось в нашем формате.FastAPI(...):title— название в документации;openapi_url="/api/v1/openapi.json"— машиночитаемое описание API (OpenAPI). Из него в будущем фронтенд будет генерировать TypeScript-типы;docs_url="/api/docs"— интерактивная документация Swagger UI;redoc_url=None— альтернативная документация ReDoc отключена (хватает одной);lifespan— код, который выполняется при старте и остановке.- CORS — подключается, только если
CORS_ORIGINSне пустой (подробнее в 5.1): allow_origins— список разрешённых сайтов;allow_credentials=True— разрешить отправку куки/авторизации;allow_methods=["*"],allow_headers=["*"]— любые методы и заголовки (в том числеAuthorization).RequestLoggingMiddleware— логирование запросов.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через SQLAlchemyupdated_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"
- Alembic подключается к базе и сравнивает текущую схему базы с моделями в коде
(
Base.metadata). Это называется autogenerate. - Записывает разницу в новый файл
alembic/versions/<дата>-<id>_add_loads.py. - Post-write hooks прогоняют файл через
ruff check --fixиruff format. - Файл нужно прочитать глазами. Autogenerate не всё понимает: например, переименование столбца он видит как «удалить старый + создать новый», что уничтожит данные.
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)¶
- Создаёт один handler в
stdoutс нужным formatter и фильтромrequest_id. - Ставит его на корневой логгер — туда приходят записи всех логгеров: приложения, SQLAlchemy, uvicorn.
- Логгеры uvicorn (
uvicorn,uvicorn.error) uvicorn настраивает ещё до импорта приложения, со своим форматом. Мы убираем их handlers и включаемpropagate, чтобы их записи шли через наш формат. 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)¶
- Загружает
env(с проверкой). - Если включены моки — подменяет
window.fetch(см. 4.9). - Загружает переводы.
- Рендерит
<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. Известные ограничения и несостыковки¶
Сознательные упрощения:
- Выход не отзывает токен на сервере. Токен удаляется только во фронтенде; украденный токен действует до истечения срока (7 дней). Решение в будущем — короткий access-токен
- refresh-токен с отзывом.
- Токен хранится в
localStorage. Если на сайт удастся внедрить чужой JavaScript (XSS), он сможет прочитать токен. Альтернатива — httpOnly-cookie; переход затронет толькоtoken-storage.tsи бэкенд. - Нет подтверждения email и сброса пароля.
- Нет CI для бэкенда и удалённых репозиториев.
docs/не под git — документы нигде не сохраняются, кроме твоего диска.- Тесты не прогоняют миграции — схема в тестах строится из моделей.
Несостыковки, которые стоит поправить:
- 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 | Уникальный номер запроса для поиска его строк в логах |