stats/ (Flet) генерировал графики через matplotlib в PNG и отдавал их через Flutter web с WebSocket — та же архитектура, что подводила основной фронтенд (см. известные ошибки в README). Плавающая версия flet>=0.82.2 недавно подтянула 0.86.0 с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере. stats-react/ — SPA по тому же паттерну, что и web-react (Vite+React+TS, свой Dockerfile+nginx, same-origin прокси /api вместо WebSocket). Функциональность 1:1: 4 summary-карточки, общие фильтры (тест/ организация/группа/год), два таба (прохождения / результаты тестов), 8 графиков, топ-10 таблица, фильтр по оси + сравнение по организациям/ группам. Круговая диаграмма заменена на 100%-stacked bar (dataviz: "part-to-whole rides on the stacked bar chart; donut stays deprioritized") с фолдингом длинного хвоста категорий в "Другое" — у оригинала было 32 категории на 8 цветов, из-за чего сегменты становились неразличимы. Категориальная палитра графиков провалидирована на CVD-безопасность (scripts/validate_palette.js, light+dark) — 8-цветная палитра из исходного designer.py не проходила проверку (hard fail по normal-vision floor). Бренд-оранжевый сохранён в палитре, остальные 7 слотов — из референсного набора skill'а. stats/ и Dockerfile.stats удалены, docker-compose.yml переключён на stats-react. Проверено сборкой всех трёх образов, полным compose up и визуально (headless Chrome, light+dark, обе вкладки, реальные данные с прод-сервера). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
633 lines
36 KiB
Markdown
633 lines
36 KiB
Markdown
# api-copp
|
||
|
||
Платформа ЦОПП Челябинской области для проведения профориентационных тестирований. REST API на FastAPI + веб-интерфейс на React (SPA). Поддерживает тесты Голланда, Климова и Гломштока с паутинными диаграммами результатов. Тест можно пройти анонимно — достаточно выбрать школу и класс, аккаунт не нужен.
|
||
|
||
---
|
||
|
||
## Содержание
|
||
|
||
- [Архитектура](#архитектура)
|
||
- [Быстрый старт](#быстрый-старт)
|
||
- [Переменные окружения](#переменные-окружения)
|
||
- [Структура проекта](#структура-проекта)
|
||
- [База данных](#база-данных)
|
||
- [API](#api)
|
||
- [Перенос данных между серверами](#перенос-данных-между-серверами)
|
||
- [Паутинная диаграмма](#паутинная-диаграмма)
|
||
- [Redis и кэширование](#redis-и-кэширование)
|
||
- [Деплой с внешним прокси](#деплой-с-внешним-прокси)
|
||
- [Известные ошибки и решения](#известные-ошибки-и-решения)
|
||
|
||
---
|
||
|
||
## Архитектура
|
||
|
||
```
|
||
Браузер пользователя
|
||
│ :80
|
||
[ web-react — nginx (SPA + reverse-proxy /api) ] ← единственный публичный сервис
|
||
│ http://api:8000 (только Docker-сеть)
|
||
[ api — FastAPI :8000 ]
|
||
│ │
|
||
[ PostgreSQL :5432 ] [ Redis :6379 ]
|
||
```
|
||
|
||
Сервис `api` **не публикует порты наружу** — доступен только контейнеру `web-react` внутри Docker-сети. Браузер обращается к `/api/*`, nginx внутри `web-react` перенаписывает и проксирует запрос на `api:8000` (см. `web-react/nginx.conf`). Пользователь никогда не обращается к API напрямую.
|
||
|
||
PostgreSQL и Redis могут запускаться как:
|
||
- сервисами `postgres`/`redis` в том же `docker-compose.yml`, за профилем `local-db` (локально в Docker)
|
||
- внешними сервисами (отдельный сервер, managed service) — достаточно прописать нужные `REDIS_HOST`/`DB_HOST` в `.env`, профиль `local-db` не поднимать
|
||
|
||
---
|
||
|
||
## Быстрый старт
|
||
|
||
Один файл `docker-compose.yml` на всё. По умолчанию поднимает только приложение (`api` + `web-react` + `stats`) — БД и Redis ожидаются внешними (`DB_HOST`/`REDIS_HOST` в `.env`, см. «Переменные окружения»).
|
||
|
||
```bash
|
||
# Только приложение (внешние БД/Redis из .env)
|
||
docker compose up -d --build
|
||
|
||
# + локальные PostgreSQL и Redis в Docker (профиль local-db)
|
||
docker compose --profile local-db up -d --build
|
||
```
|
||
|
||
| Адрес | Что |
|
||
|---|---|
|
||
| `http://localhost` | Веб-интерфейс (React SPA) |
|
||
| `http://localhost:8000/docs` | Swagger UI (HTTP Basic Auth) |
|
||
| `http://localhost:8000/redoc` | ReDoc |
|
||
| `http://localhost:8081` | Дашборд статистики |
|
||
|
||
> Реальные значения переменных окружения (пароли, ключи) держите в `.env` в корне репозитория — `docker compose` подставляет их автоматически. Шаблон без секретов — в `.env.example`. Файл `.env` в `.gitignore`, в репозиторий не попадает.
|
||
|
||
### 3. Инициализация БД (первый запуск)
|
||
|
||
```bash
|
||
# Создать таблицы
|
||
curl -X POST http://localhost:8000/db/create-tables \
|
||
-H "X-Admin-Key: <ADMIN_KEY>"
|
||
|
||
# Применить все миграции (безопасно запускать повторно — IF NOT EXISTS)
|
||
curl -X POST http://localhost:8000/db/migrate \
|
||
-H "X-Admin-Key: <ADMIN_KEY>"
|
||
|
||
# Миграция FK: responses.user_id → users.id ON DELETE CASCADE
|
||
curl -X POST http://localhost:8000/db/migrate-cascade-user \
|
||
-H "X-Admin-Key: <ADMIN_KEY>"
|
||
```
|
||
|
||
> **Важно:** `db/migrate-cascade-user` нужно выполнить **один раз** на каждом сервере с уже существующей БД (созданной до апреля 2026). На новых базах — уже включено автоматически через `create-tables`.
|
||
|
||
### 4. Загрузка тестов
|
||
|
||
```bash
|
||
# Голланд (42 пары профессий)
|
||
curl -X POST http://localhost:8000/polls/holland -H "X-Admin-Key: <ADMIN_KEY>"
|
||
curl -X POST http://localhost:8000/polls/holland/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
|
||
|
||
# Климов (ДДО)
|
||
curl -X POST http://localhost:8000/polls/klimov -H "X-Admin-Key: <ADMIN_KEY>"
|
||
curl -X POST http://localhost:8000/polls/klimov/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
|
||
|
||
# Гломшток (Карта интересов)
|
||
curl -X POST http://localhost:8000/polls/glomshtok -H "X-Admin-Key: <ADMIN_KEY>"
|
||
curl -X POST http://localhost:8000/polls/glomshtok/<poll_id>/seed-scores -H "X-Admin-Key: <ADMIN_KEY>"
|
||
```
|
||
|
||
---
|
||
|
||
## Переменные окружения
|
||
|
||
> `docker-compose.yml` читает значения из `.env` (не коммитится, см. `.env.example` за шаблоном). Перед продакшн-деплоем обязательно смените все пароли и ключи в своём `.env`.
|
||
|
||
| Переменная | Описание | Пример |
|
||
|---|---|---|
|
||
| `APP_PATH` | Путь к директории приложения внутри контейнера | `/app` |
|
||
| `DEBUG` | Режим отладки API | `False` |
|
||
| `DB_HOST` | Хост PostgreSQL | `postgres` |
|
||
| `DB_PORT` | Порт PostgreSQL | `5432` |
|
||
| `DB_USER` | Пользователь БД | `postgres` |
|
||
| `DB_PASS` | Пароль БД | — |
|
||
| `DB_NAME` | Имя базы данных | `profi` |
|
||
| `REDIS_HOST` | Хост Redis | `redis` |
|
||
| `REDIS_PORT` | Порт Redis | `6379` |
|
||
| `REDIS_DB` | Номер БД Redis | `0` |
|
||
| `REDIS_PASSWORD` | Пароль Redis | — |
|
||
| `SECRET_KEY` | Секрет JWT (min 32 символа) | `python -c "import secrets; print(secrets.token_hex(32))"` |
|
||
| `ADMIN_KEY` | Ключ для admin-эндпоинтов (`X-Admin-Key`) | — |
|
||
| `DOCS_USERNAME` | Логин для `/docs` и `/redoc` | `admin` |
|
||
| `DOCS_PASSWORD` | Пароль для `/docs` и `/redoc` | — |
|
||
| `API_URL` | URL API для фронтенда (только Docker-сеть) | `http://api:8000` |
|
||
|
||
---
|
||
|
||
## Структура проекта
|
||
|
||
```
|
||
api-copp/
|
||
├── main.py # точка входа FastAPI, регистрация роутеров
|
||
├── pyproject.toml / uv.lock # зависимости API (единственный источник — читает Dockerfile.api через uv)
|
||
├── .dockerignore # что не попадает в build context образов
|
||
├── .env.example # шаблон переменных окружения без секретов
|
||
├── Dockerfile.api # образ API
|
||
├── docker-compose.yml # api + web-react + stats-react (+ postgres/redis за профилем local-db)
|
||
│
|
||
├── bd/
|
||
│ ├── __init__.py # Settings (pydantic), make_engine()
|
||
│ └── tables/
|
||
│ ├── simple_base.py
|
||
│ ├── users.py # User (username, hashed_password, group_id, org_id)
|
||
│ ├── organization.py # Organization
|
||
│ ├── group.py # Group
|
||
│ ├── poll.py # Poll (title, description, is_active)
|
||
│ ├── question.py # Question (type: single/multiple)
|
||
│ ├── choice.py # Choice (варианты ответов)
|
||
│ ├── response.py # Response, Answer
|
||
│ ├── scale.py # ScaleDimension, ChoiceScore
|
||
│ └── radar_result.py # RadarResult, RadarResultItem
|
||
│
|
||
├── route/
|
||
│ ├── auth_utils.py # JWT, bcrypt, require_admin_key
|
||
│ ├── auth.py # /auth/register, /auth/login, /auth/logout
|
||
│ ├── base.py # /health, /version
|
||
│ ├── init_data_base.py # /db/create-tables, /db/migrate, /db/migrate-cascade-user
|
||
│ ├── users_crud.py # CRUD пользователей
|
||
│ ├── groups_crud.py # CRUD групп
|
||
│ ├── organizations_crud.py # CRUD организаций
|
||
│ ├── poll_crud.py # CRUD опросов
|
||
│ ├── question_crud.py # CRUD вопросов
|
||
│ ├── choice_crud.py # CRUD вариантов ответов
|
||
│ ├── response_crud.py # приём ответов + авто-расчёт радара
|
||
│ ├── holland_crud.py # тест Голланда
|
||
│ ├── klimov_crud.py # тест Климова (ДДО)
|
||
│ ├── glomshtok_crud.py # тест Гломштока (Карта интересов)
|
||
│ ├── scale_crud.py # CRUD осей и расшифровок
|
||
│ ├── radar_crud.py # расчёт и чтение радарных результатов
|
||
│ ├── radar_svg_gen.py # серверная генерация SVG-диаграмм
|
||
│ ├── stats_crud.py # статистика (кэшируется в Redis, TTL 5 мин)
|
||
│ └── transfer_crud.py # экспорт/импорт/бэкап/восстановление БД
|
||
│
|
||
├── web-react/ # React SPA-фронтенд (порт 80, свой Dockerfile+nginx)
|
||
│ ├── src/api/client.ts # единая точка HTTP-запросов к API
|
||
│ ├── src/auth/AuthContext.tsx # токен-логин + анонимная сессия (школа+класс без аккаунта)
|
||
│ ├── src/pages/ # страницы (Login, AnonymousEntry, Poll, MyTests, ...)
|
||
│ └── nginx.conf # раздаёт статику SPA, проксирует /api → api:8000
|
||
│
|
||
├── stats-react/ # дашборд статистики, React SPA (порт 8081, свой Dockerfile+nginx)
|
||
│ ├── src/api/client.ts # HTTP-клиент к /stats/*, /polls/, /organizations/, /groups/
|
||
│ ├── src/components/charts/ # BarChart, LineChart, PartToWholeBar — свои SVG/HTML, без сторонних libs
|
||
│ └── src/App.tsx # фильтры + два таба (прохождения / результаты тестов)
|
||
│
|
||
└── data/ # том Docker (./data:/app/data)
|
||
├── postgres/ # данные PostgreSQL
|
||
└── radar/ # SVG-файлы диаграмм (структура: <тест>/<группа>/<user_id>.svg)
|
||
```
|
||
|
||
---
|
||
|
||
## База данных
|
||
|
||
### Схема связей
|
||
|
||
```
|
||
organizations ──< groups ──< users
|
||
│ CASCADE DELETE
|
||
polls ──< questions ──< choices ──< choice_scores
|
||
│
|
||
└──< responses ──< answers (CASCADE: poll→responses, responses→answers)
|
||
│
|
||
└──< radar_results ──< radar_result_items (CASCADE)
|
||
│
|
||
image_path → data/radar/...svg
|
||
|
||
polls ──< scale_dimensions ──< choice_scores
|
||
│
|
||
└── radar_result_items.dimension_id (SET NULL при удалении оси)
|
||
```
|
||
|
||
### Каскадное удаление
|
||
|
||
При удалении **пользователя** автоматически удаляется:
|
||
- все его `responses` → все `answers` внутри → все `radar_results` → все `radar_result_items`
|
||
|
||
`organization` и `group`, к которым был привязан пользователь, **не удаляются** — только обнуляются ссылки (`SET NULL`).
|
||
|
||
При удалении **теста (poll)**:
|
||
- все `questions` → все `choices` → все `choice_scores`
|
||
- все `responses` → все `answers` → все `radar_results` → все `radar_result_items`
|
||
- все `scale_dimensions` → все `choice_scores`
|
||
|
||
### Миграции
|
||
|
||
Применяются через admin-эндпоинты (безопасно запускать повторно — используют `IF NOT EXISTS`/`IF EXISTS`):
|
||
|
||
| Эндпоинт | Что делает |
|
||
|---|---|
|
||
| `POST /db/create-tables` | Создаёт все таблицы по моделям SQLAlchemy |
|
||
| `POST /db/migrate` | Добавляет колонки `username`/`hashed_password` в users, `image_path` в radar_results |
|
||
| `POST /db/migrate-cascade-user` | Добавляет FK `responses.user_id → users(id) ON DELETE CASCADE` |
|
||
|
||
---
|
||
|
||
## API
|
||
|
||
### Аутентификация
|
||
|
||
```
|
||
POST /auth/register — регистрация (username ≥ 3 симв., password ≥ 8 симв.)
|
||
POST /auth/login — JWT-токен (OAuth2 password flow)
|
||
POST /auth/logout — отзыв токена (добавляет в Redis-блэклист)
|
||
```
|
||
|
||
Токен: `Authorization: Bearer <token>`
|
||
Admin-операции: `X-Admin-Key: <ADMIN_KEY>`
|
||
|
||
### Тесты и вопросы
|
||
|
||
```
|
||
GET /polls — список тестов
|
||
POST /polls — создать тест [admin]
|
||
GET /polls/{id} — тест с вопросами и вариантами
|
||
DELETE /polls/{id} — удалить тест [admin]
|
||
POST /polls/{id}/questions — добавить вопрос [admin]
|
||
POST /questions/{id}/choices — добавить вариант ответа [admin]
|
||
```
|
||
|
||
### Прохождение теста
|
||
|
||
```
|
||
POST /polls/{poll_id}/responses — сдать тест (автоматически считает радар + генерирует SVG)
|
||
GET /polls/users/{user_id}/responses — история пользователя
|
||
GET /responses/{id} — конкретный результат с ответами
|
||
```
|
||
|
||
`POST /polls/{poll_id}/responses` не требует авторизации: `user_id` необязателен. `web-react` использует это для анонимного прохождения — школьник на `/anonymous` выбирает только школу и класс (без пароля и аккаунта), выбор кладётся в `group_id`/`organization_id` тела запроса вместо `user_id`. Результат сохраняется в базе как снимок школы/класса без привязки к пользователю; истории результатов после закрытия вкладки у анонимной сессии нет — только у настоящих аккаунтов (`/my-tests`).
|
||
|
||
### Результаты (паутинная диаграмма)
|
||
|
||
```
|
||
GET /responses/{id}/radar — данные диаграммы (оси + баллы)
|
||
GET /responses/{id}/radar/image — SVG-файл диаграммы
|
||
POST /responses/{id}/radar/compute — пересчитать диаграмму [admin]
|
||
GET /users/{user_id}/radar-results — история всех радарных результатов пользователя
|
||
```
|
||
|
||
### Оси и расшифровки
|
||
|
||
```
|
||
POST /polls/{poll_id}/dimensions — создать ось [admin]
|
||
GET /polls/{poll_id}/dimensions — список осей
|
||
PUT /dimensions/{id} [admin]
|
||
DELETE /dimensions/{id} [admin]
|
||
POST /dimensions/{id}/scores — задать расшифровку варианта ответа [admin]
|
||
GET /choices/{choice_id}/scores
|
||
```
|
||
|
||
### Готовые тесты
|
||
|
||
```
|
||
POST /polls/holland — создать тест Голланда (42 пары) [admin]
|
||
POST /polls/holland/{poll_id}/seed-scores — загрузить ключ расшифровки [admin]
|
||
POST /polls/klimov — создать тест Климова (ДДО) [admin]
|
||
POST /polls/klimov/{poll_id}/seed-scores [admin]
|
||
POST /polls/glomshtok — создать тест Гломштока [admin]
|
||
POST /polls/glomshtok/{poll_id}/seed-scores [admin]
|
||
```
|
||
|
||
### Пользователи / Группы / Организации
|
||
|
||
```
|
||
POST /users/ — создать пользователя [admin]
|
||
GET /users/ — список пользователей [admin]
|
||
DELETE /users/{id} — удалить пользователя (каскадно удаляет все данные)
|
||
GET /users/me — текущий пользователь
|
||
PUT /users/me — обновить профиль
|
||
|
||
GET /groups/ — список групп
|
||
POST /groups/ [admin]
|
||
...
|
||
|
||
GET /organizations/ — список организаций
|
||
POST /organizations/ [admin]
|
||
...
|
||
```
|
||
|
||
### Статистика
|
||
|
||
```
|
||
GET /stats/summary — общая сводка (total_responses, total_polls, ...)
|
||
GET /stats/responses?group_by=poll — ответы по тестам/организациям/группам/месяцам
|
||
GET /stats/results/avg-by-dimension — средний балл по осям
|
||
GET /stats/results/distribution — распределение баллов
|
||
GET /stats/years — список лет с активностью
|
||
```
|
||
|
||
Фильтры: `poll_id`, `org_id`, `group_id`, `year`. Все ответы кэшируются в Redis (TTL 5 минут). Кэш автоматически сбрасывается при удалении пользователя.
|
||
|
||
### База данных
|
||
|
||
```
|
||
POST /db/create-tables [admin]
|
||
POST /db/migrate [admin]
|
||
POST /db/migrate-cascade-user [admin]
|
||
```
|
||
|
||
### Служебные
|
||
|
||
```
|
||
GET /health
|
||
GET /version
|
||
```
|
||
|
||
---
|
||
|
||
## Перенос данных между серверами
|
||
|
||
Два сценария использования:
|
||
|
||
### Сценарий 1: Дополнение (transfer)
|
||
|
||
Ученики прошли тест на сервере A — нужно перенести их результаты на сервер B, не удаляя существующие данные на B.
|
||
|
||
```bash
|
||
# Сервер A — скачать архив (все таблицы + SVG-диаграммы)
|
||
curl -H "X-Admin-Key: <KEY_A>" \
|
||
http://server-a:8000/transfer/export -o export.zip
|
||
|
||
# Сервер B — на новой БД сначала создать схему (один раз)
|
||
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/create-tables
|
||
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate
|
||
curl -X POST -H "X-Admin-Key: <KEY_B>" http://server-b:8000/db/migrate-cascade-user
|
||
|
||
# Сервер B — импортировать (добавляет только новое, дубли по UUID пропускаются)
|
||
curl -X POST -H "X-Admin-Key: <KEY_B>" \
|
||
-F "file=@export.zip" \
|
||
http://server-b:8000/transfer/import
|
||
```
|
||
|
||
**Ответ импорта:**
|
||
```json
|
||
{
|
||
"status": "ok",
|
||
"result": {
|
||
"inserted": {"organizations": 107, "users": 4, "responses": 34, "radar_results": 34, "svg_files": 23, ...},
|
||
"skipped": {"organizations": 0, "users": 0, "responses": 0, "radar_results": 0, ...}
|
||
}
|
||
}
|
||
```
|
||
|
||
При повторном импорте того же архива все записи попадают в `skipped` — **дублей не создаётся**.
|
||
|
||
### Сценарий 2: Резервное копирование и восстановление (backup)
|
||
|
||
Полный снимок БД с возможностью полного восстановления.
|
||
|
||
```bash
|
||
# Создать резервную копию (имя файла начинается с backup_)
|
||
curl -H "X-Admin-Key: <KEY>" \
|
||
http://localhost:8000/backup/export -o backup_20260410.zip
|
||
|
||
# Восстановить из резервной копии (УДАЛЯЕТ все текущие данные, вставляет из архива)
|
||
curl -X POST -H "X-Admin-Key: <KEY>" \
|
||
-F "file=@backup_20260410.zip" \
|
||
http://localhost:8000/backup/restore
|
||
```
|
||
|
||
> **Внимание:** `/backup/restore` полностью очищает БД (в обратном FK-порядке) перед вставкой данных. SVG-папка тоже очищается и восстанавливается. Используйте только для полного восстановления.
|
||
|
||
**Ответ восстановления:**
|
||
```json
|
||
{
|
||
"status": "ok",
|
||
"deleted": {"radar_result_items": 332, "users": 4, "organizations": 107, ...},
|
||
"inserted": {"organizations": 107, "users": 4, "radar_result_items": 332, "svg_files": 23, ...}
|
||
}
|
||
```
|
||
|
||
### Что входит в архив
|
||
|
||
Оба архива содержат одинаковый набор данных (12 таблиц в FK-порядке + SVG):
|
||
|
||
```
|
||
manifest.json ← версия формата, дата, количество записей
|
||
organizations.json
|
||
groups.json
|
||
polls.json
|
||
questions.json
|
||
choices.json
|
||
scale_dimensions.json
|
||
choice_scores.json
|
||
users.json
|
||
responses.json
|
||
answers.json
|
||
radar_results.json
|
||
radar_result_items.json
|
||
radar_svgs/ ← все SVG-файлы (структура папок сохраняется)
|
||
```
|
||
|
||
Все UUID сохраняются **без изменений** — FK-связи не рвутся.
|
||
|
||
---
|
||
|
||
## Паутинная диаграмма
|
||
|
||
### Как работает
|
||
|
||
При сдаче теста (`POST /polls/{id}/responses`) сервер автоматически:
|
||
|
||
1. Суммирует баллы по каждой оси (`ChoiceScore.score`) для выбранных вариантов
|
||
2. Сохраняет `radar_result` + `radar_result_items`
|
||
3. Генерирует SVG и сохраняет в `{APP_PATH}/data/radar/<тест>/<группа>/<user_id>.svg`
|
||
4. Записывает путь в `image_path` — всё в **одном** `session.commit()`
|
||
|
||
### SVG-файл
|
||
|
||
Генерируется чистым Python (`route/radar_svg_gen.py`):
|
||
- Фон `#2F184B`, полигон `rgba(155,114,207,0.55)`
|
||
- 5 концентрических сеток
|
||
- Подписи с именем типа и баллом
|
||
- Размер: 520×520 пикселей
|
||
|
||
### Скачивание из браузера
|
||
|
||
`web-react` отдаёт SVG через тот же same-origin прокси, что и остальной API: `<img src="/api/responses/{id}/radar/image">`, nginx внутри контейнера `web-react` перенаправляет запрос на `api:8000` (см. `web-react/nginx.conf`). Браузер по-прежнему не обращается к `api` напрямую — `api` не публикует порты.
|
||
|
||
---
|
||
|
||
## Redis и кэширование
|
||
|
||
### Настройка Redis
|
||
|
||
Используется connection pool (`max_connections=20`). При Redis с отключённой командой `CONFIG` (например, Debian `redis-server` по умолчанию) — настройки меняются только через `/etc/redis/redis.conf` + `systemctl restart redis`.
|
||
|
||
Рекомендуемые настройки для продакшна:
|
||
```
|
||
maxmemory 2048mb
|
||
maxmemory-policy volatile-lru
|
||
appendfsync everysec
|
||
timeout 300
|
||
tcp-keepalive 60
|
||
```
|
||
|
||
### Назначение ключей
|
||
|
||
| Префикс | Что хранит | TTL |
|
||
|---|---|---|
|
||
| `token_blacklist:<jti>` | Отозванные JWT-токены | До истечения токена |
|
||
| `cache:<md5>` | Кэш ответов `/stats/*` | 5 минут |
|
||
|
||
### Сброс кэша
|
||
|
||
Кэш статистики (`cache:*`) автоматически сбрасывается при удалении пользователя — чтобы счётчики обновились немедленно, а не через 5 минут.
|
||
|
||
---
|
||
|
||
## Тест Голланда
|
||
|
||
Модификация Г.В. Резапкиной теста профессиональной ориентации Дж. Голланда. 42 пары профессий, испытуемый выбирает предпочтительную в каждой паре.
|
||
|
||
| Тип | Название | Цвет |
|
||
|---|---|---|
|
||
| Р | Реалистичный | `#FF8C00` |
|
||
| И | Интеллектуальный | `#4169E1` |
|
||
| С | Социальный | `#32CD32` |
|
||
| К | Конвенциональный | `#9370DB` |
|
||
| П | Предприимчивый | `#DC143C` |
|
||
| А | Артистический | `#FF69B4` |
|
||
|
||
Каждый тип — 14 вопросов (14 максимальных баллов). `seed-scores` создаёт 6 осей (`ScaleDimension`) и 84 записи расшифровки (`ChoiceScore`).
|
||
|
||
---
|
||
|
||
## Деплой с внешним прокси
|
||
|
||
`web-react` — статический SPA + reverse-proxy внутри своего же контейнера (`web-react/nginx.conf`), долгоживущих WebSocket-соединений (как было у Flet) больше нет. Минимальная конфигурация внешнего nginx перед контейнером:
|
||
|
||
```nginx
|
||
location / {
|
||
proxy_pass http://<server-ip>:80;
|
||
proxy_set_header Host $host;
|
||
proxy_set_header X-Real-IP $remote_addr;
|
||
proxy_http_version 1.1;
|
||
}
|
||
```
|
||
|
||
> **⚠ `nginx.conf` и `nginx.production.conf` в репозитории всё ещё написаны под старый Flet-фронтенд** (`upstream flet_app`, отдельный `location /ws` для WebSocket, `location /radar-image/`). После перехода на `web-react` эти файлы не актуальны и не обновлялись — `nginx.production.conf`, судя по всему, конфиг боевого внешнего nginx для `test.cloud-copp74.ru`, поэтому его правка не входит в эту уборку без отдельного подтверждения (ошибка здесь ломает публичный домен). Если используете внешний прокси в проде — сверьте его с примером выше перед следующим деплоем.
|
||
|
||
---
|
||
|
||
## Известные ошибки и решения
|
||
|
||
### 1. `api` недоступен из браузера ✅ (относилось к старому Flet-фронтенду)
|
||
|
||
**Проблема:** Docker-имя `api` не резолвится у браузера пользователя. Любой URL вида `http://api:8000/...` в `page.launch_url()` (Flet) давал ошибку DNS.
|
||
|
||
**Решение в `web-react`:** same-origin прокси `/api/*` в `web-react/nginx.conf` — браузер всегда обращается к тому же origin, что и отдал SPA, прямых обращений к `api` из браузера нет в принципе (см. «Скачивание из браузера» выше).
|
||
|
||
---
|
||
|
||
### 2. SVG записывался без `image_path` при сбое ✅
|
||
|
||
**Проблема:** `image_path` обновлялся вторым `session.commit()` — при падении между первым и вторым записи оставались без пути к файлу.
|
||
|
||
**Решение:** SVG генерируется до коммита, `image_path` устанавливается до коммита. Один атомарный `commit()`.
|
||
|
||
---
|
||
|
||
### 3. `/docs` был открыт без авторизации ✅
|
||
|
||
**Решение:** `docs_url=None, openapi_url=None, redoc_url=None` — стандартные маршруты отключены. Вместо них собственные маршруты с HTTP Basic Auth (`secrets.compare_digest` против timing-атак).
|
||
|
||
---
|
||
|
||
### 4. Варианты ответов обрезались ✅ (относилось к старому Flet-фронтенду)
|
||
|
||
**Проблема:** `ft.Radio(label=...)` в Flet не переносит текст метки — длинные варианты обрезались.
|
||
|
||
**Решение:** актуально не для этого проекта — в `web-react` варианты ответа рендерятся обычной HTML-разметкой (`PollPage.tsx`), перенос текста работает из коробки.
|
||
|
||
---
|
||
|
||
### 5. `image_path` требует миграции на старых БД ✅
|
||
|
||
**Решение:**
|
||
```sql
|
||
ALTER TABLE radar_results ADD COLUMN IF NOT EXISTS image_path VARCHAR(512);
|
||
```
|
||
Применяется через `POST /db/migrate`.
|
||
|
||
---
|
||
|
||
### 6. При удалении пользователя оставались его ответы ✅
|
||
|
||
**Проблема:** `responses.user_id` был простым UUID без FK-ограничения. При удалении пользователя его ответы, answers и radar_results оставались в БД (висящие строки).
|
||
|
||
**Решение:** Добавлен FK `responses.user_id → users(id) ON DELETE CASCADE`. Теперь удаление пользователя каскадно удаляет все его данные.
|
||
|
||
Применяется через `POST /db/migrate-cascade-user`.
|
||
|
||
---
|
||
|
||
### 7. Статистика не обновлялась после удаления пользователя ✅
|
||
|
||
**Проблема:** После удаления пользователя кэш `/stats/*` в Redis ещё 5 минут отдавал старые данные (включая удалённые ответы).
|
||
|
||
**Решение:** `delete_user` вызывает `cache_flush()` — удаляет все ключи `cache:*` через `scan_iter`. Следующий запрос к статистике сразу идёт в БД.
|
||
|
||
---
|
||
|
||
### 8. Redis на Debian отключает команду `CONFIG` ✅
|
||
|
||
**Проблема:** `/etc/redis/redis.conf` содержал `rename-command CONFIG ""` — команда `CONFIG SET` возвращала ошибку `ERR unknown command`.
|
||
|
||
**Решение:** Настройки меняются напрямую в `/etc/redis/redis.conf` + `systemctl restart redis`. Строку `rename-command CONFIG ""` при необходимости удалить или закомментировать.
|
||
|
||
---
|
||
|
||
### 9. `pg8000` вместо `psycopg` ✅
|
||
|
||
**Проблема:** `pyproject.toml` указывал `psycopg>=3.3.3`, реально использовался `pg8000`.
|
||
|
||
**Решение:** Зависимость заменена на `pg8000>=1.29.0`. Все запросы к БД синхронные.
|
||
|
||
---
|
||
|
||
### 10. Дублирование SVG-генератора ✅
|
||
|
||
**Было:** `route/radar_svg_gen.py` (сервер) и `web/radar_svg.py` (клиент, Flet) — два разных генератора, второй нигде не вызывался.
|
||
|
||
**Решение:** старый Flet-фронтенд `web/` целиком удалён вместе с `Dockerfile.web` (заменён на `web-react` в коммите `eac6192`). Единственный генератор SVG теперь — `route/radar_svg_gen.py`.
|
||
|
||
---
|
||
|
||
### 11. Реальные секреты в `docker-compose.yml` ✅
|
||
|
||
**Проблема:** пароли БД/Redis, `SECRET_KEY`, `ADMIN_KEY` были прописаны в открытом виде прямо в `docker-compose.yml` (тогда ещё вместе с отдельным `docker-compose_db.yml`), закоммичены в git.
|
||
|
||
**Решение:** значения вынесены в `.env` (в `.gitignore`, не коммитится), compose-файлы читают их через `${VAR}`. Шаблон без секретов — `.env.example`. **Важно:** старые значения уже есть в истории git (коммит `71887b0`) — сама по себе эта правка их не отзывает, реальные пароли на серверах БД/Redis нужно ротировать отдельно.
|
||
|
||
---
|
||
|
||
### 12. Зависимости API расходились между `pyproject.toml` и `requirements.txt` ✅
|
||
|
||
**Проблема:** `Dockerfile.api` ставил зависимости из `requirements.txt`, а не из `pyproject.toml`/`uv.lock` — версии успели разойтись (`redis==5.0.0` vs `>=7.1.0`, `sqlalchemy==2.0.32` vs `>=2.0.47`).
|
||
|
||
**Решение:** `Dockerfile.api` теперь ставит зависимости через `uv sync --frozen` напрямую из `pyproject.toml`/`uv.lock`. `requirements.txt` удалён — единственный источник правды. При переходе обнаружилась ещё одна расхождение: `python-multipart` (нужен FastAPI для `UploadFile`/`Form`, используется в `organizations_crud.py` при импорте .docx) был в `requirements.txt`, но отсутствовал в `pyproject.toml` — без него API падал при старте с `RuntimeError`. Добавлен в зависимости.
|
||
|
||
---
|
||
|
||
### 13. Деплой требовал двух отдельных compose-файлов ✅
|
||
|
||
**Было:** `docker-compose.yml` (api + web-react + stats) и `docker-compose_db.yml` (postgres + redis) — два отдельных файла и две команды на разворачивание.
|
||
|
||
**Решение:** `postgres`/`redis` перенесены в тот же `docker-compose.yml`, за профилем `local-db` — не поднимаются командой `docker compose up -d` по умолчанию (приложение по умолчанию ходит на внешние `DB_HOST`/`REDIS_HOST` из `.env`), но доступны через `docker compose --profile local-db up -d`, когда нужна локальная БД. `docker-compose_db.yml` удалён.
|
||
|
||
---
|
||
|
||
### 14. Дашборд статистики был на Flet — переписан на React ✅
|
||
|
||
**Проблема:** `stats/` (Flet, `Dockerfile.stats`) генерировал графики через matplotlib в PNG на сервере и отдавал их клиенту через Flutter web c долгоживущим WebSocket-соединением — та же архитектура, что подвела основной фронтенд (см. записи №1–4 выше). Плавающая версия `flet>=0.82.2` в какой-то момент подтянула мажорный `0.86.0` с несовместимым протоколом фреймирования, и дашборд перестал открываться в браузере.
|
||
|
||
**Решение:** `stats/` и `Dockerfile.stats` удалены, дашборд переписан на React SPA (`stats-react/`, тот же паттерн, что и `web-react/`) — same-origin прокси `/api/*` вместо WebSocket, графики — свой SVG/HTML без сторонних библиотек (bar/line/part-to-whole с hover-тултипами), функциональность 1:1 с оригиналом (2 таба, 4+4 графика, топ-10 таблица, все фильтры). Категориальная палитра графиков провалидирована на CVD-безопасность (`node scripts/validate_palette.js`) — у исходной 8-цветной палитры из `designer.py` эта проверка проваливалась по нескольким пунктам.
|