Compare commits
42
Commits
7829a49e9d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f0a5cf4dff | ||
|
|
0b33b442e9 | ||
|
|
d12afe62d0 | ||
|
|
30270916a4 | ||
|
|
2f0b22ccd4 | ||
|
|
89cbb81be7 | ||
|
|
f5f8dc03ce | ||
|
|
ec1e94119c | ||
|
|
8d4f7a95df | ||
|
|
5b89dc9d2f | ||
|
|
00bd3c72b6 | ||
|
|
f7f74fee8c | ||
|
|
643a9f1795 | ||
|
|
709b864c60 | ||
|
|
03834c6855 | ||
|
|
0ee7cd1274 | ||
|
|
1bc3940ee8 | ||
|
|
164db495b4 | ||
|
|
cff48f7cc7 | ||
|
|
bb5f2a1121 | ||
|
|
63a2cddd5e | ||
|
|
5035ee41e6 | ||
|
|
6c35cb04d4 | ||
|
|
63f541d61f | ||
|
|
551032d66e | ||
|
|
d31e998f87 | ||
|
|
976b8fc622 | ||
|
|
07a14ecc9b | ||
|
|
9d77971b0f | ||
|
|
d3ecbca731 | ||
|
|
a3984638c3 | ||
|
|
96c32d99c5 | ||
|
|
30e9669361 | ||
|
|
867b1f868c | ||
|
|
74b9b6698f | ||
|
|
05e1f79c25 | ||
|
|
98cd82f045 | ||
|
|
e44a62f9cc | ||
|
|
9f599f18d7 | ||
|
|
90d0eb2546 | ||
|
|
64ac0efb36 | ||
|
|
5fad9dbea4 |
+14
-5
@@ -1,13 +1,23 @@
|
|||||||
|
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
|
||||||
|
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
|
||||||
|
|
||||||
# Python
|
# Python
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
**/*.pyc
|
**/*.pyc
|
||||||
|
**/*.egg-info/
|
||||||
backend/.venv/
|
backend/.venv/
|
||||||
backend/.pytest_cache/
|
backend/.pytest_cache/
|
||||||
backend/*.db
|
|
||||||
backend/*.db-wal
|
|
||||||
backend/*.db-shm
|
|
||||||
backend/openapi.json
|
backend/openapi.json
|
||||||
|
|
||||||
|
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
|
||||||
|
# локальные копии бэкапов — ни в образ, ни в контекст сборки
|
||||||
|
backend/data/
|
||||||
|
data/
|
||||||
|
backups/
|
||||||
|
**/*.db
|
||||||
|
**/*.db-wal
|
||||||
|
**/*.db-shm
|
||||||
|
|
||||||
# Node / сборка фронта
|
# Node / сборка фронта
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
@@ -23,8 +33,7 @@ backend/tests/
|
|||||||
backend/pyproject.toml
|
backend/pyproject.toml
|
||||||
|
|
||||||
# Прочее
|
# Прочее
|
||||||
.env
|
**/.env
|
||||||
data/
|
|
||||||
**/.DS_Store
|
**/.DS_Store
|
||||||
*.md
|
*.md
|
||||||
.claude/
|
.claude/
|
||||||
|
|||||||
+18
-6
@@ -6,7 +6,7 @@
|
|||||||
|
|
||||||
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
|
||||||
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
# Этот параметр читает ЛАУНЧЕР (run.ps1 / run.sh) и решает, что запускать:
|
||||||
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, вход TG+ник
|
# development — нативно: uvicorn --reload + vite, БД в ./data/dev/, есть вход по нику (stub)
|
||||||
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
# production — НЕ запускается лаунчером; деплой на Pi отдельно (docker compose up -d).
|
||||||
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
# Прод-контейнер ИГНОРИРУЕТ это значение и всегда production.
|
||||||
APP_ENV=development
|
APP_ENV=development
|
||||||
@@ -14,6 +14,9 @@ APP_ENV=development
|
|||||||
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
|
||||||
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
|
||||||
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
|
||||||
|
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
|
||||||
|
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
|
||||||
|
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
|
||||||
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
|
||||||
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
|
||||||
LOCAL_PUBLIC=local
|
LOCAL_PUBLIC=local
|
||||||
@@ -41,8 +44,8 @@ ADMIN_NICKNAME=Администратор
|
|||||||
ADMIN_BOOTSTRAP_ENABLED=true
|
ADMIN_BOOTSTRAP_ENABLED=true
|
||||||
|
|
||||||
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
|
||||||
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
|
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
|
||||||
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
|
||||||
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
|
||||||
# и forbidden-stars.ru (dev).
|
# и forbidden-stars.ru (dev).
|
||||||
TELEGRAM_BOT_TOKEN=
|
TELEGRAM_BOT_TOKEN=
|
||||||
@@ -54,6 +57,7 @@ PUBLIC_BASE_URL=
|
|||||||
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
|
||||||
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
|
||||||
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
|
||||||
|
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
|
||||||
SECRET_KEY=change-me-dev-secret-not-for-production
|
SECRET_KEY=change-me-dev-secret-not-for-production
|
||||||
JWT_ALGORITHM=HS256
|
JWT_ALGORITHM=HS256
|
||||||
JWT_USER_TTL_MINUTES=10080
|
JWT_USER_TTL_MINUTES=10080
|
||||||
@@ -77,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
|
|||||||
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
PROD_ACHIEVEMENTS_DIR=/data/achievements
|
||||||
|
|
||||||
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
|
||||||
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр,
|
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
|
||||||
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
|
||||||
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
# Перед пушем/пуллом: docker login gitea.arseniev.info
|
||||||
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
|
||||||
@@ -85,7 +89,8 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
|
||||||
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
# Лимиты под Raspberry Pi. Не заданы → дефолты compose (512m / 1.5 CPU).
|
||||||
# Подними, если у Pi больше RAM/ядер.
|
# Подними, если у Pi больше RAM/ядер. Лимит памяти работает, только если на Pi включён
|
||||||
|
# memory cgroup (cgroup_enable=memory в cmdline.txt) — см. deploy/pi/README.md, раздел 1.
|
||||||
#APP_MEM_LIMIT=512m
|
#APP_MEM_LIMIT=512m
|
||||||
#APP_CPUS=1.5
|
#APP_CPUS=1.5
|
||||||
|
|
||||||
@@ -119,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic
|
|||||||
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
|
||||||
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
|
||||||
BACKUP_SSH_KEY_B64=
|
BACKUP_SSH_KEY_B64=
|
||||||
|
# Отчёты о бэкапах в Telegram и команды /backups, /status (deploy/backup/README.md, раздел 10).
|
||||||
|
# id своего чата: напишите боту /start → `docker compose exec backup fs-backup telegram chats`. Несколько — через
|
||||||
|
# запятую. Пусто = отчёты выключены. Токен по умолчанию — TELEGRAM_BOT_TOKEN приложения.
|
||||||
|
BACKUP_TELEGRAM_CHAT_ID=
|
||||||
|
#BACKUP_TELEGRAM_BOT_TOKEN=
|
||||||
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
# Только для ПК (scripts/fs-backup.ps1 / .sh): как зайти на Pi по SSH и где там лежит
|
||||||
# docker-compose.yml прода.
|
# docker-compose.yml прода.
|
||||||
BACKUP_PI_SSH=pi@192.168.1.10
|
BACKUP_PI_SSH=pi@192.168.1.10
|
||||||
BACKUP_PI_DIR=~/forbidden-stars
|
BACKUP_PI_DIR=~/forbidden-stars
|
||||||
|
|
||||||
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
|
||||||
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
|
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
|
||||||
|
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
|
||||||
|
# пояс устройства игрока не учитывается.
|
||||||
APP_TZ_OFFSET_HOURS=3
|
APP_TZ_OFFSET_HOURS=3
|
||||||
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
|
||||||
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
|
||||||
|
|||||||
@@ -3,16 +3,3 @@
|
|||||||
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
|
||||||
*.sh text eol=lf
|
*.sh text eol=lf
|
||||||
backend/entrypoint.sh text eol=lf
|
backend/entrypoint.sh text eol=lf
|
||||||
|
|
||||||
# ── export-ignore: НЕ попадает в `git archive` (чистая выгрузка прода) ─────────
|
|
||||||
# В git эти файлы есть и доступны на всех ветках (нужны для разработки),
|
|
||||||
# но в архив деплоя (scripts/export-prod.sh) не идут. На Docker-сборку НЕ влияет —
|
|
||||||
# там чистоту образа обеспечивает .dockerignore.
|
|
||||||
backend/tests/ export-ignore
|
|
||||||
backend/app/auth/dev_stub.py export-ignore
|
|
||||||
backend/app/routers/dev_auth.py export-ignore
|
|
||||||
backend/pyproject.toml export-ignore
|
|
||||||
README.md export-ignore
|
|
||||||
.gitignore export-ignore
|
|
||||||
.gitattributes export-ignore
|
|
||||||
.dockerignore export-ignore
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
|
||||||
frontend/ React + Vite SPA
|
frontend/ React + Vite SPA
|
||||||
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
|
||||||
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh
|
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
|
||||||
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
|
||||||
docker-compose.yml прод на Pi: app + tunnel + backup
|
docker-compose.yml прод на Pi: app + tunnel + backup
|
||||||
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
|
||||||
@@ -125,8 +125,10 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
|
||||||
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
|
||||||
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
|
||||||
дефолтным `ADMIN_PASSWORD` приложение не стартует. Учтите: `ADMIN_PASSWORD` применяется только
|
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
|
||||||
при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73).
|
при первом создании админа; сменить его потом — правкой `.env`, `docker compose up -d` и
|
||||||
|
`docker compose exec app python -m app.bootstrap --reset-admin-password` (все админские
|
||||||
|
сессии завершатся; подробно — в `deploy/pi/README.md`).
|
||||||
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
Пошагово — [`deploy/pi/README.md`](deploy/pi/README.md), бэкапы — [`deploy/backup/README.md`](deploy/backup/README.md).
|
||||||
|
|
||||||
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
|
||||||
@@ -200,9 +202,8 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
|
||||||
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
|
||||||
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
|
||||||
исключены из образа (`.dockerignore`).
|
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
|
||||||
- **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило
|
и локальные бэкапы (`backups/`) в образ тоже не попадают.
|
||||||
`data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71).
|
|
||||||
|
|
||||||
## Git и деплой
|
## Git и деплой
|
||||||
|
|
||||||
@@ -213,10 +214,6 @@ docker compose up -d # pull_policy: always — тянет свежие
|
|||||||
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
|
||||||
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
|
||||||
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
именно PS-версия скрипта (`build-push.sh` из PowerShell уходит в WSL).
|
||||||
- **Чистая выгрузка в папку без git** (опц., к деплою на Pi не относится):
|
|
||||||
`scripts/export-prod.sh <dir> [ref]` — через `git archive` + `export-ignore` из
|
|
||||||
`.gitattributes` (без тестов, stub-входа, `pyproject.toml`, README-файлов и лаунчера).
|
|
||||||
`dev_admin.py` в `export-ignore` пока не внесён (задача #70).
|
|
||||||
|
|
||||||
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
Секреты (`.env`) и данные (`data/`, `*.db`) в git не идут — см. `.gitignore`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
|
||||||
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
|
||||||
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
|
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
|
||||||
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
|
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
|
||||||
|
включая IP.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
|
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Общий вход: внешняя личность → пользователь → сессия.
|
"""Общий вход: внешняя личность → пользователь → сессия.
|
||||||
|
|
||||||
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
|
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
|
||||||
и dev-входом.
|
Telegram, /auth/login, /auth/register и dev-вход.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
|
||||||
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
|
||||||
Доступен и в dev, и в prod (в prod — единственный метод входа).
|
Доступен во всех окружениях — наряду со входом по логину и паролю.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
|
||||||
|
|
||||||
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
|
||||||
|
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
@@ -11,6 +14,7 @@ from app.core.security import hash_password, verify_password
|
|||||||
from app.db.session import engine
|
from app.db.session import engine
|
||||||
from app.models import User
|
from app.models import User
|
||||||
from app.seed.reference_data import seed_reference_data
|
from app.seed.reference_data import seed_reference_data
|
||||||
|
from app.services import user_service
|
||||||
|
|
||||||
|
|
||||||
def _ensure_admin(session: Session) -> None:
|
def _ensure_admin(session: Session) -> None:
|
||||||
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
|
|
||||||
# Администратор уже существует.
|
# Администратор уже существует.
|
||||||
if not settings.is_development:
|
if not settings.is_development:
|
||||||
# В prod пароль НЕ перезаписываем (мог быть изменён через панель).
|
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
|
||||||
|
# по себе ничего не делает — применить её можно только осознанно, командой
|
||||||
|
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
|
||||||
return
|
return
|
||||||
|
|
||||||
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
|
||||||
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
|
|||||||
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
print(f"[bootstrap] DEV: администратор обновлён из .env ({', '.join(changed)})")
|
||||||
|
|
||||||
|
|
||||||
|
def reset_admin_password(session: Session) -> User:
|
||||||
|
"""Записать существующему админу пароль из ADMIN_PASSWORD (#73) — в любом окружении.
|
||||||
|
|
||||||
|
Через панель пароль админа не меняется, а обычный старт в prod его из .env не берёт:
|
||||||
|
это единственный штатный способ, и он требует доступа к серверу. set_password
|
||||||
|
проверяет пароль и увеличивает token_version — все выданные админские сессии
|
||||||
|
(в том числе чужая, если пароль утёк) перестают действовать. Логин не трогаем."""
|
||||||
|
if not settings.admin_bootstrap_enabled:
|
||||||
|
raise RuntimeError(
|
||||||
|
"ADMIN_BOOTSTRAP_ENABLED=false — администратором управляют вручную, ротация отключена."
|
||||||
|
)
|
||||||
|
password = (settings.admin_password or "").strip()
|
||||||
|
if not password:
|
||||||
|
raise RuntimeError("ADMIN_PASSWORD пуст — нечего применять.")
|
||||||
|
admin = session.exec(select(User).where(User.role == "admin")).first()
|
||||||
|
if admin is None:
|
||||||
|
raise RuntimeError("Администратора ещё нет — запустите обычный bootstrap, он его создаст.")
|
||||||
|
return user_service.set_password(session, admin, password)
|
||||||
|
|
||||||
|
|
||||||
def bootstrap() -> None:
|
def bootstrap() -> None:
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
seed_reference_data(session) # идемпотентно
|
seed_reference_data(session) # идемпотентно
|
||||||
_ensure_admin(session)
|
_ensure_admin(session)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
ap = argparse.ArgumentParser(prog="python -m app.bootstrap", description=__doc__)
|
||||||
|
ap.add_argument(
|
||||||
|
"--reset-admin-password",
|
||||||
|
action="store_true",
|
||||||
|
help="применить ADMIN_PASSWORD из .env к существующему админу и завершить его сессии",
|
||||||
|
)
|
||||||
|
args = ap.parse_args(argv)
|
||||||
|
if not args.reset_admin_password:
|
||||||
|
bootstrap()
|
||||||
|
return
|
||||||
|
with Session(engine) as session:
|
||||||
|
admin = reset_admin_password(session)
|
||||||
|
print(f"[bootstrap] Пароль администратора {admin.nickname} обновлён из .env; "
|
||||||
|
"все его сессии завершены.")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
bootstrap()
|
main()
|
||||||
|
|||||||
+42
-19
@@ -12,8 +12,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
# (исключён из образа) — там настройки приходят переменными от docker compose.
|
||||||
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
|
||||||
|
|
||||||
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
|
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
|
||||||
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
|
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
|
||||||
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
|
||||||
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
|
||||||
_MIN_SECRET_KEY_LENGTH = 32
|
_MIN_SECRET_KEY_LENGTH = 32
|
||||||
@@ -31,18 +31,20 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# ── Главный переключатель окружения: development | production ─────────────
|
# ── Главный переключатель окружения: development | production ─────────────
|
||||||
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
|
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
|
||||||
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
|
||||||
app_env: str = "development"
|
app_env: str = "development"
|
||||||
log_level: str = "INFO"
|
log_level: str = "INFO"
|
||||||
|
|
||||||
# Публикация локального dev-окружения наружу через VPS-туннель.
|
# Публикация локального dev-окружения наружу через VPS-туннель.
|
||||||
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
# Читает ЛАУНЧЕР (run.ps1/run.sh): local — только localhost; vps — плюс SSH-туннель
|
||||||
# на forbidden-stars.ru. Влияет на cookie_secure (vps ⇒ снаружи HTTPS ⇒ Secure-cookie).
|
# на forbidden-stars.ru. Приложению значение говорит, опубликовано ли оно (is_published):
|
||||||
|
# от этого зависят Secure-cookie и проверка секретов при старте.
|
||||||
local_public: str = "local"
|
local_public: str = "local"
|
||||||
|
|
||||||
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
|
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
|
||||||
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
|
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
|
||||||
|
# на фронте — оно приходит туда в GET /api/auth/config (#68).
|
||||||
app_tz_offset_hours: int = 3
|
app_tz_offset_hours: int = 3
|
||||||
|
|
||||||
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
|
||||||
@@ -66,8 +68,8 @@ class Settings(BaseSettings):
|
|||||||
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
|
||||||
cookie_domain: str | None = None
|
cookie_domain: str | None = None
|
||||||
|
|
||||||
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
|
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
|
||||||
# prod: только telegram) — отдельного переключателя провайдера нет.
|
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
|
||||||
telegram_bot_token: str | None = None
|
telegram_bot_token: str | None = None
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
public_base_url: str | None = None
|
public_base_url: str | None = None
|
||||||
@@ -111,11 +113,18 @@ class Settings(BaseSettings):
|
|||||||
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
return self.dev_achievements_dir if self.is_development else self.prod_achievements_dir
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_secure(self) -> bool:
|
def is_published(self) -> bool:
|
||||||
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
|
"""Приложение доступно снаружи по домену: production или dev, выставленный через
|
||||||
нативный dev на localhost по HTTP (development + local_public=local)."""
|
VPS-туннель. Не опубликован только нативный dev на localhost
|
||||||
|
(development + local_public=local)."""
|
||||||
return not (self.is_development and self.local_public.lower() == "local")
|
return not (self.is_development and self.local_public.lower() == "local")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def cookie_secure(self) -> bool:
|
||||||
|
"""Secure-cookie нужен везде, где снаружи HTTPS (домен), — у опубликованного
|
||||||
|
приложения. На localhost по HTTP браузер Secure-cookie не вернул бы."""
|
||||||
|
return self.is_published
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def cookie_domain_value(self) -> str | None:
|
def cookie_domain_value(self) -> str | None:
|
||||||
return self.cookie_domain or None
|
return self.cookie_domain or None
|
||||||
@@ -131,14 +140,23 @@ class Settings(BaseSettings):
|
|||||||
)
|
)
|
||||||
return value
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@field_validator("app_tz_offset_hours")
|
||||||
def _forbid_default_secrets_in_prod(self) -> "Settings":
|
@classmethod
|
||||||
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
|
def _known_tz_offset(cls, value: int) -> int:
|
||||||
|
"""Реальные пояса — от −12 до +14: опечатка в .env — ошибка старта, а не время,
|
||||||
|
сдвинутое на сутки."""
|
||||||
|
if not -12 <= value <= 14:
|
||||||
|
raise ValueError(f"APP_TZ_OFFSET_HOURS={value} вне диапазона −12..14")
|
||||||
|
return value
|
||||||
|
|
||||||
Деплой, скопировавший .env.example дословно (или забывший поле), иначе поднялся бы
|
@model_validator(mode="after")
|
||||||
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
|
def _forbid_default_secrets_when_published(self) -> "Settings":
|
||||||
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
|
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
|
||||||
if self.app_env.lower() != "production":
|
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
|
||||||
|
|
||||||
|
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
|
||||||
|
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
|
||||||
|
if not self.is_published:
|
||||||
return self
|
return self
|
||||||
problems: list[str] = []
|
problems: list[str] = []
|
||||||
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
if self.secret_key == _DEFAULT_SECRET_KEY or len(self.secret_key) < _MIN_SECRET_KEY_LENGTH:
|
||||||
@@ -150,8 +168,13 @@ class Settings(BaseSettings):
|
|||||||
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
if not password or password == _DEFAULT_ADMIN_PASSWORD:
|
||||||
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
problems.append("ADMIN_PASSWORD не задан или дефолтный")
|
||||||
if problems:
|
if problems:
|
||||||
|
where = (
|
||||||
|
"production"
|
||||||
|
if self.is_production
|
||||||
|
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
|
||||||
|
)
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Небезопасная конфигурация production — задайте секреты в .env: "
|
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
|
||||||
+ "; ".join(problems)
|
+ "; ".join(problems)
|
||||||
)
|
)
|
||||||
return self
|
return self
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
||||||
перезапуска, но окно короткое, а рестарты редки;
|
перезапуска, но окно короткое, а рестарты редки;
|
||||||
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
||||||
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
|
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
|
||||||
про SSE-шину), общий для всех воркеров.
|
про SSE-шину), общий для всех воркеров.
|
||||||
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
||||||
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
||||||
|
|||||||
@@ -175,6 +175,6 @@ def is_session_revoked(payload: dict) -> bool:
|
|||||||
def client_ip(request: Request) -> str | None:
|
def client_ip(request: Request) -> str | None:
|
||||||
"""IP клиента для журнала аудита.
|
"""IP клиента для журнала аудита.
|
||||||
|
|
||||||
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
|
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
|
||||||
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
|
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
|
||||||
return request.client.host if request.client else None
|
return request.client.host if request.client else None
|
||||||
|
|||||||
@@ -1,21 +0,0 @@
|
|||||||
"""Инициализация схемы и справочников (для тестов и локального быстрого старта).
|
|
||||||
|
|
||||||
В production схема создаётся миграциями Alembic; этот модуль удобен для тестов,
|
|
||||||
где БД поднимается из чистого состояния.
|
|
||||||
"""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlmodel import SQLModel
|
|
||||||
|
|
||||||
from app.db.session import engine
|
|
||||||
from app.seed.reference_data import seed_reference_data
|
|
||||||
from sqlmodel import Session
|
|
||||||
|
|
||||||
# Импорт моделей обязателен, чтобы они зарегистрировались в SQLModel.metadata.
|
|
||||||
import app.models # noqa: F401
|
|
||||||
|
|
||||||
|
|
||||||
def create_db_and_seed() -> None:
|
|
||||||
SQLModel.metadata.create_all(engine)
|
|
||||||
with Session(engine) as session:
|
|
||||||
seed_reference_data(session)
|
|
||||||
@@ -132,6 +132,16 @@ async def _lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
hub.bind_loop(asyncio.get_running_loop())
|
hub.bind_loop(asyncio.get_running_loop())
|
||||||
|
|
||||||
|
if settings.is_development and settings.is_published:
|
||||||
|
# Решение владельца (#69): dev-инструменты остаются и на опубликованном dev —
|
||||||
|
# но о том, что они открыты любому посетителю домена, нужно сказать громко.
|
||||||
|
logging.getLogger("fs").warning(
|
||||||
|
"DEV ОПУБЛИКОВАН НАРУЖУ (LOCAL_PUBLIC=%s): любому посетителю домена открыты "
|
||||||
|
"вход по нику без пароля, список и создание игроков, жёсткое удаление аккаунтов "
|
||||||
|
"и Swagger. Не держите в dev-базе копию прод-данных.",
|
||||||
|
settings.local_public,
|
||||||
|
)
|
||||||
|
|
||||||
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
# В DEV приложение само подтягивает справочники и админа из .env при старте
|
||||||
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
|
||||||
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
|
||||||
|
|||||||
@@ -315,16 +315,15 @@ def delete_match(
|
|||||||
session: Session = Depends(get_session),
|
session: Session = Depends(get_session),
|
||||||
admin: User = Depends(get_current_admin),
|
admin: User = Depends(get_current_admin),
|
||||||
) -> s.OkResponse:
|
) -> s.OkResponse:
|
||||||
group_id = match_service.get_match(session, match_id).group_id # для уведомления
|
match = match_service.get_match(session, match_id)
|
||||||
# До удаления: каскад унесёт участников вместе с партией.
|
group_id, finished = match.group_id, match.status == "finished" # для уведомления
|
||||||
participant_ids = notify.match_participant_ids(session, match_id)
|
|
||||||
admin_service.delete_match(session, match_id)
|
admin_service.delete_match(session, match_id)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
|
||||||
ip=client_ip(request),
|
ip=client_ip(request),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id, group_id, participant_ids)
|
notify.match_removed(session, match_id, group_id, finished=finished)
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
|
|||||||
return s.AuthConfig(
|
return s.AuthConfig(
|
||||||
methods=enabled_methods(),
|
methods=enabled_methods(),
|
||||||
telegram_bot_username=settings.telegram_bot_username,
|
telegram_bot_username=settings.telegram_bot_username,
|
||||||
|
tz_offset_hours=settings.app_tz_offset_hours,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
|
||||||
|
|
||||||
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
|
||||||
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
|
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
|
||||||
входа по логину остаётся только на деве.
|
без пароля остаётся только на деве (по логину и паролю входят везде).
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -352,9 +352,7 @@ def delete_match(
|
|||||||
match_service.assert_can_modify(session, match, user)
|
match_service.assert_can_modify(session, match, user)
|
||||||
match_id_val = match.id
|
match_id_val = match.id
|
||||||
group_id_val = match.group_id
|
group_id_val = match.group_id
|
||||||
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
|
finished = match.status == "finished" # после удаления статус уже не прочитать
|
||||||
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
|
|
||||||
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
|
|
||||||
match_service.delete_match(session, match, expected_version=expected_version)
|
match_service.delete_match(session, match, expected_version=expected_version)
|
||||||
audit_service.record(
|
audit_service.record(
|
||||||
session,
|
session,
|
||||||
@@ -367,5 +365,5 @@ def delete_match(
|
|||||||
user_agent=request.headers.get("user-agent"),
|
user_agent=request.headers.get("user-agent"),
|
||||||
)
|
)
|
||||||
session.commit()
|
session.commit()
|
||||||
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
|
notify.match_removed(session, match_id_val, group_id_val, finished=finished) # type: ignore[arg-type]
|
||||||
return s.OkResponse()
|
return s.OkResponse()
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ class AuthConfig(BaseModel):
|
|||||||
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
|
||||||
methods: list[str] = []
|
methods: list[str] = []
|
||||||
telegram_bot_username: str | None = None
|
telegram_bot_username: str | None = None
|
||||||
|
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
|
||||||
|
# показывает время всем игрокам — независимо от пояса устройства (#68).
|
||||||
|
tz_offset_hours: int
|
||||||
|
|
||||||
|
|
||||||
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
|
||||||
|
|||||||
@@ -33,7 +33,13 @@ FACTIONS: list[tuple[str, str, str, int]] = [
|
|||||||
|
|
||||||
|
|
||||||
def seed_reference_data(session: Session) -> None:
|
def seed_reference_data(session: Session) -> None:
|
||||||
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
|
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
|
||||||
|
|
||||||
|
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
|
||||||
|
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
|
||||||
|
и после создания записи источник правды для него — БД (#72). Имя из кода получает
|
||||||
|
только новая фракция; поправить имя существующей — админкой или миграцией.
|
||||||
|
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
|
||||||
code_to_expansion: dict[str, Expansion] = {}
|
code_to_expansion: dict[str, Expansion] = {}
|
||||||
|
|
||||||
for code, name_ru, is_base, order in EXPANSIONS:
|
for code, name_ru, is_base, order in EXPANSIONS:
|
||||||
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
|
|||||||
)
|
)
|
||||||
session.add(fac)
|
session.add(fac)
|
||||||
else:
|
else:
|
||||||
fac.name_ru = name_ru
|
|
||||||
fac.expansion_id = expansion.id # type: ignore[assignment]
|
fac.expansion_id = expansion.id # type: ignore[assignment]
|
||||||
fac.sort_order = order
|
fac.sort_order = order
|
||||||
|
|
||||||
|
|||||||
@@ -63,8 +63,9 @@ def update_user(session: Session, user_id: int, *, nickname: str | None = None,
|
|||||||
|
|
||||||
|
|
||||||
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
def set_player_password(session: Session, user_id: int, new_password: str) -> User:
|
||||||
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
|
"""Новый пароль игроку (восстановление забытого). Пароль админа через панель не
|
||||||
он задаётся ADMIN_PASSWORD в .env."""
|
меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
|
||||||
|
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
|
||||||
user = session.get(User, user_id)
|
user = session.get(User, user_id)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise NotFoundError("Пользователь не найден.")
|
raise NotFoundError("Пользователь не найден.")
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from __future__ import annotations
|
|||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
from app.core.events import hub
|
from app.core.events import hub
|
||||||
from app.models import GroupMember, Match, MatchParticipant, User
|
from app.models import GroupMember, Match, User
|
||||||
|
|
||||||
|
|
||||||
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
||||||
@@ -17,30 +17,28 @@ def _group_member_ids(session: Session, group_id: int) -> list[int]:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def match_participant_ids(session: Session, match_id: int) -> list[int]:
|
def _ratings_changed(session: Session, notified: list[int]) -> None:
|
||||||
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
|
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
|
||||||
(история игр, публичный профиль, личная статистика) реально протухли."""
|
главную, историю и профили всех, кто играл после неё, и страницы других групп.
|
||||||
return list(
|
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
|
||||||
session.exec(
|
skip = set(notified)
|
||||||
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
|
ids = [
|
||||||
|
uid
|
||||||
|
for uid in session.exec(
|
||||||
|
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
|
||||||
).all()
|
).all()
|
||||||
)
|
if uid not in skip
|
||||||
|
]
|
||||||
|
hub.publish(ids, {"type": "ratings"})
|
||||||
|
|
||||||
|
|
||||||
def match_changed(session: Session, match: Match) -> None:
|
def match_changed(session: Session, match: Match) -> None:
|
||||||
"""Партия изменилась — уведомить всех участников её группы.
|
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
|
||||||
|
и остальных игроков (_ratings_changed)."""
|
||||||
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
|
members = _group_member_ids(session, match.group_id)
|
||||||
история и профили протухают только у игравших, поэтому их id едут в событии."""
|
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
|
||||||
hub.publish(
|
if match.status == "finished":
|
||||||
_group_member_ids(session, match.group_id),
|
_ratings_changed(session, members)
|
||||||
{
|
|
||||||
"type": "match",
|
|
||||||
"match_id": match.id,
|
|
||||||
"group_id": match.group_id,
|
|
||||||
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
||||||
@@ -53,22 +51,13 @@ def match_draft_changed(session: Session, match: Match, actor_id: int) -> None:
|
|||||||
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
hub.publish(ids, {"type": "match_draft", "match_id": match.id, "group_id": match.group_id})
|
||||||
|
|
||||||
|
|
||||||
def match_removed(
|
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
|
||||||
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
|
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
|
||||||
) -> None:
|
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
|
||||||
"""Партия удалена — уведомить участников группы (обновить списки).
|
members = _group_member_ids(session, group_id)
|
||||||
|
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
|
||||||
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
|
if finished:
|
||||||
ушли каскадом, и собрать их из базы невозможно."""
|
_ratings_changed(session, members)
|
||||||
hub.publish(
|
|
||||||
_group_member_ids(session, group_id),
|
|
||||||
{
|
|
||||||
"type": "match",
|
|
||||||
"match_id": match_id,
|
|
||||||
"group_id": group_id,
|
|
||||||
"participant_ids": participant_ids or [],
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
|
||||||
|
|||||||
@@ -7,9 +7,10 @@
|
|||||||
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
|
||||||
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
|
||||||
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
ΔR_i = K_i · G(N) / (N − 1) · Σ_j M_ij · (S_ij − E_ij)
|
||||||
K_i спускается от K_MAX у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола,
|
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
|
||||||
M — множитель отрыва (темп, цели, миры; близость по типу победы — только у пар
|
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
|
||||||
с победителем). Недостающий признак партии подставляется типичным и не влияет на M.
|
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
|
||||||
|
признак партии подставляется типичным и не влияет на M.
|
||||||
|
|
||||||
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
|
||||||
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
|
||||||
@@ -168,6 +169,10 @@ def rate_match(
|
|||||||
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
r = {s.user_id: ratings.get(s.user_id, R0) for s in m.seats}
|
||||||
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
|
k = {s.user_id: k_factor(games.get(s.user_id, 0)) for s in m.seats}
|
||||||
for a, b in combinations(m.seats, 2):
|
for a, b in combinations(m.seats, 2):
|
||||||
|
if a.eliminated and b.eliminated:
|
||||||
|
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
|
||||||
|
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
|
||||||
|
continue
|
||||||
if a.place > b.place:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
|
|||||||
@@ -2,7 +2,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
from sqlmodel import Session, SQLModel, create_engine, select
|
||||||
|
|
||||||
|
from app.models import Faction
|
||||||
|
from app.seed.reference_data import FACTIONS, seed_reference_data
|
||||||
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login, start_match
|
||||||
|
|
||||||
|
|
||||||
@@ -205,6 +209,37 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
|
|||||||
assert ap["faction_name"] == "Орки WAAAGH"
|
assert ap["faction_name"] == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_faction_rename_survives_restart_seeding(client: TestClient, make_admin, engine):
|
||||||
|
"""Сидинг идёт при каждом старте (entrypoint.sh, lifespan) и не должен откатывать
|
||||||
|
имя, заданное админом (#72)."""
|
||||||
|
login(client, "Кто-то")
|
||||||
|
orks = next(f for f in client.get("/api/factions").json() if f["code"] == "orks")
|
||||||
|
_admin_login(client, make_admin)
|
||||||
|
r = client.patch(
|
||||||
|
f"/api/admin/factions/{orks['id']}",
|
||||||
|
json={"name_ru": "Орки WAAAGH"},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
seed_reference_data(s) # то же, что делает рестарт
|
||||||
|
assert s.get(Faction, orks["id"]).name_ru == "Орки WAAAGH"
|
||||||
|
|
||||||
|
|
||||||
|
def test_seeding_is_complete_and_idempotent():
|
||||||
|
"""Пустая БД: фракции из кода создаются со своими именами, повтор ничего не ломает."""
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool
|
||||||
|
)
|
||||||
|
SQLModel.metadata.create_all(engine)
|
||||||
|
with Session(engine) as s:
|
||||||
|
seed_reference_data(s)
|
||||||
|
seed_reference_data(s)
|
||||||
|
names = {f.code: f.name_ru for f in s.exec(select(Faction)).all()}
|
||||||
|
assert names == {code: name for code, name, _exp, _order in FACTIONS}
|
||||||
|
|
||||||
|
|
||||||
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine):
|
||||||
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 409.
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"""Ротация пароля администратора из .env (#73): `python -m app.bootstrap --reset-admin-password`."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
|
from app import bootstrap
|
||||||
|
from app.core.security import verify_password
|
||||||
|
from app.models import User
|
||||||
|
from tests.conftest import csrf_headers
|
||||||
|
|
||||||
|
OLD, NEW = "secret123", "brand-new-admin-pw"
|
||||||
|
|
||||||
|
|
||||||
|
def _admin(engine) -> User:
|
||||||
|
with Session(engine) as s:
|
||||||
|
return s.exec(select(User).where(User.role == "admin")).one()
|
||||||
|
|
||||||
|
|
||||||
|
def _login(client: TestClient, password: str):
|
||||||
|
return client.post(
|
||||||
|
"/api/admin/auth/login",
|
||||||
|
json={"username": "admin", "password": password},
|
||||||
|
headers=csrf_headers(client),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_applies_env_password_and_revokes_sessions(
|
||||||
|
client: TestClient, engine, make_admin, monkeypatch
|
||||||
|
):
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
assert _login(client, OLD).status_code == 200
|
||||||
|
assert client.get("/api/admin/me").status_code == 200
|
||||||
|
version = _admin(engine).token_version
|
||||||
|
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s:
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
|
|
||||||
|
admin = _admin(engine)
|
||||||
|
assert verify_password(NEW, admin.password_hash)
|
||||||
|
assert admin.token_version == version + 1
|
||||||
|
# Прежняя админская cookie больше не действует (в т.ч. у того, кто украл пароль).
|
||||||
|
assert client.get("/api/admin/me").status_code == 401
|
||||||
|
assert _login(client, OLD).status_code == 401
|
||||||
|
assert _login(client, NEW).status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
def test_regular_bootstrap_keeps_admin_password_outside_dev(engine, make_admin, monkeypatch):
|
||||||
|
"""Обычный старт в prod по-прежнему не берёт пароль из .env — только ротация."""
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "app_env", "production")
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s:
|
||||||
|
bootstrap._ensure_admin(s)
|
||||||
|
assert verify_password(OLD, _admin(engine).password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_refuses_without_admin_or_password(engine, make_admin, monkeypatch):
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s, pytest.raises(RuntimeError, match="Администратора ещё нет"):
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
|
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", " ")
|
||||||
|
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_PASSWORD пуст"):
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
|
assert verify_password(OLD, _admin(engine).password_hash)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_refuses_when_bootstrap_disabled(engine, make_admin, monkeypatch):
|
||||||
|
make_admin("admin", OLD)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_bootstrap_enabled", False)
|
||||||
|
monkeypatch.setattr(bootstrap.settings, "admin_password", NEW)
|
||||||
|
with Session(engine) as s, pytest.raises(RuntimeError, match="ADMIN_BOOTSTRAP_ENABLED"):
|
||||||
|
bootstrap.reset_admin_password(s)
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
|
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
|
||||||
|
стартует с дефолтными секретами (#59, F4, #69)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic import ValidationError
|
from pydantic import ValidationError
|
||||||
|
|
||||||
@@ -61,10 +64,70 @@ def test_production_skips_admin_check_when_bootstrap_disabled():
|
|||||||
def test_development_allows_defaults():
|
def test_development_allows_defaults():
|
||||||
s = config.Settings(
|
s = config.Settings(
|
||||||
app_env="development",
|
app_env="development",
|
||||||
|
local_public="local",
|
||||||
secret_key=config._DEFAULT_SECRET_KEY,
|
secret_key=config._DEFAULT_SECRET_KEY,
|
||||||
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
admin_password=config._DEFAULT_ADMIN_PASSWORD,
|
||||||
)
|
)
|
||||||
assert s.is_development
|
assert s.is_development
|
||||||
|
assert not s.is_published and not s.cookie_secure
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Dev, опубликованный на домен (LOCAL_PUBLIC=vps, #69) ─────────────────────
|
||||||
|
# Снаружи он так же доступен, как прод: общеизвестный ключ JWT и пароль админа там
|
||||||
|
# открывают админку и подделку любого токена.
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"secret_key,admin_password",
|
||||||
|
[
|
||||||
|
(config._DEFAULT_SECRET_KEY, _STRONG_ADMIN_PW),
|
||||||
|
("too-short", _STRONG_ADMIN_PW),
|
||||||
|
(_STRONG_SECRET, config._DEFAULT_ADMIN_PASSWORD),
|
||||||
|
],
|
||||||
|
ids=["default-secret", "short-secret", "default-admin-password"],
|
||||||
|
)
|
||||||
|
def test_published_dev_rejects_weak_secrets(secret_key, admin_password):
|
||||||
|
with pytest.raises(ValidationError, match="LOCAL_PUBLIC=vps"):
|
||||||
|
config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
local_public="vps",
|
||||||
|
secret_key=secret_key,
|
||||||
|
admin_password=admin_password,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_dev_accepts_strong_secrets():
|
||||||
|
s = config.Settings(
|
||||||
|
app_env="development",
|
||||||
|
local_public="vps",
|
||||||
|
secret_key=_STRONG_SECRET,
|
||||||
|
admin_password=_STRONG_ADMIN_PW,
|
||||||
|
)
|
||||||
|
assert s.is_development and s.is_published and s.cookie_secure
|
||||||
|
|
||||||
|
|
||||||
|
def test_published_dev_warns_on_startup(monkeypatch, caplog):
|
||||||
|
"""Dev-инструменты на опубликованном dev остаются (решение владельца) — но старт
|
||||||
|
громко перечисляет, что открыто любому посетителю домена."""
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
monkeypatch.setattr(main.settings, "local_public", "vps")
|
||||||
|
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
|
||||||
|
pass
|
||||||
|
assert "DEV ОПУБЛИКОВАН НАРУЖУ" in caplog.text
|
||||||
|
assert "вход по нику без пароля" in caplog.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_local_dev_starts_quietly(caplog):
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from app import main
|
||||||
|
|
||||||
|
with caplog.at_level(logging.WARNING, logger="fs"), TestClient(main.create_app()):
|
||||||
|
pass
|
||||||
|
assert "DEV ОПУБЛИКОВАН НАРУЖУ" not in caplog.text
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
@pytest.mark.parametrize("app_env", ["test", "staging", ""])
|
||||||
@@ -77,3 +140,20 @@ def test_unknown_app_env_rejected(app_env):
|
|||||||
def test_app_env_case_insensitive():
|
def test_app_env_case_insensitive():
|
||||||
s = config.Settings(app_env="Development")
|
s = config.Settings(app_env="Development")
|
||||||
assert s.is_development
|
assert s.is_development
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Пояс приложения (#68) ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("offset", [-13, 15])
|
||||||
|
def test_tz_offset_out_of_range_rejected(offset):
|
||||||
|
with pytest.raises(ValidationError, match="APP_TZ_OFFSET_HOURS"):
|
||||||
|
config.Settings(app_env="development", app_tz_offset_hours=offset)
|
||||||
|
|
||||||
|
|
||||||
|
def test_auth_config_exposes_app_tz_offset(client, monkeypatch):
|
||||||
|
"""Фронт показывает время в поясе приложения — смещение приходит из настроек."""
|
||||||
|
from app.routers import auth
|
||||||
|
|
||||||
|
monkeypatch.setattr(auth.settings, "app_tz_offset_hours", 5)
|
||||||
|
assert client.get("/api/auth/config").json()["tz_offset_hours"] == 5
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
|
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
|
||||||
|
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
@@ -16,20 +17,29 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
|
|||||||
return published
|
return published
|
||||||
|
|
||||||
|
|
||||||
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
|
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
|
||||||
return [e for _ids, e in published if e.get("type") == "match"]
|
return {uid for ids, e in published if e.get("type") == kind for uid in ids}
|
||||||
|
|
||||||
|
|
||||||
def test_match_event_carries_participants(client: TestClient, engine, monkeypatch):
|
def _two_groups(client: TestClient, engine) -> tuple[dict, int, int, int, list[int]]:
|
||||||
|
"""Хост и Игрок2 в группе партии, Чужой — только в другой группе хоста."""
|
||||||
me = login(client, "Хост")
|
me = login(client, "Хост")
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
||||||
gid = client.post(
|
|
||||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
def group(name: str) -> int:
|
||||||
).json()["id"]
|
return client.post(
|
||||||
|
"/api/groups", json={"name": name, "expansion_ids": exps}, headers=csrf_headers(client)
|
||||||
|
).json()["id"]
|
||||||
|
|
||||||
|
gid, other_gid = group("Группа"), group("Другая")
|
||||||
p2 = add_group_member(engine, gid, "Игрок2")
|
p2 = add_group_member(engine, gid, "Игрок2")
|
||||||
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
|
outsider = add_group_member(engine, other_gid, "Чужой")
|
||||||
p3 = add_group_member(engine, gid, "Зритель")
|
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
return me, gid, p2, outsider, fids
|
||||||
|
|
||||||
|
|
||||||
|
def test_finished_match_reaches_players_outside_group(client: TestClient, engine, monkeypatch):
|
||||||
|
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
started = start_match(
|
started = start_match(
|
||||||
@@ -37,36 +47,31 @@ def test_match_event_carries_participants(client: TestClient, engine, monkeypatc
|
|||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
)
|
)
|
||||||
assert started.status_code == 200, started.text
|
assert started.status_code == 200, started.text
|
||||||
mid = started.json()["id"]
|
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
|
||||||
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
ev = _match_events(published)[-1]
|
assert _recipients(published, "ratings") == set()
|
||||||
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
|
|
||||||
assert p3 not in ev["participant_ids"]
|
|
||||||
|
|
||||||
published.clear()
|
published.clear()
|
||||||
fin = finish_match(
|
fin = finish_match(
|
||||||
client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
client, started.json()["id"], [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}]
|
||||||
)
|
)
|
||||||
assert fin.status_code == 200, fin.text
|
assert fin.status_code == 200, fin.text
|
||||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
|
ratings = _recipients(published, "ratings")
|
||||||
|
assert outsider in ratings
|
||||||
|
assert not ratings & {me["id"], p2} # группа уже получила подробное событие
|
||||||
|
|
||||||
|
|
||||||
def test_delete_event_carries_participants(client: TestClient, engine, monkeypatch):
|
def test_deleting_finished_match_reaches_players_outside_group(
|
||||||
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
|
client: TestClient, engine, monkeypatch
|
||||||
|
):
|
||||||
Если собирать их после удаления, список всегда окажется пустым, и клиент не
|
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
|
||||||
обновит историю тем, кто в этой партии играл."""
|
me, gid, p2, outsider, fids = _two_groups(client, engine)
|
||||||
me = login(client, "Хост")
|
|
||||||
exps = [e["id"] for e in client.get("/api/expansions").json()]
|
|
||||||
gid = client.post(
|
|
||||||
"/api/groups", json={"name": "Группа", "expansion_ids": exps}, headers=csrf_headers(client)
|
|
||||||
).json()["id"]
|
|
||||||
p2 = add_group_member(engine, gid, "Игрок2")
|
|
||||||
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
|
||||||
mid = start_match(
|
mid = start_match(
|
||||||
client, gid,
|
client, gid,
|
||||||
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
|
||||||
).json()["id"]
|
).json()["id"]
|
||||||
|
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
|
||||||
|
|
||||||
published = _capture_events(monkeypatch)
|
published = _capture_events(monkeypatch)
|
||||||
version = client.get(f"/api/matches/{mid}").json()["version"]
|
version = client.get(f"/api/matches/{mid}").json()["version"]
|
||||||
@@ -74,4 +79,5 @@ def test_delete_event_carries_participants(client: TestClient, engine, monkeypat
|
|||||||
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
f"/api/matches/{mid}", params={"expected_version": version}, headers=csrf_headers(client)
|
||||||
)
|
)
|
||||||
assert r.status_code == 200, r.text
|
assert r.status_code == 200, r.text
|
||||||
assert sorted(_match_events(published)[-1]["participant_ids"]) == sorted([me["id"], p2])
|
assert _recipients(published, "match") == {me["id"], p2}
|
||||||
|
assert outsider in _recipients(published, "ratings")
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ EXAMPLES = [
|
|||||||
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
|
||||||
"objectives", end_round=7,
|
"objectives", end_round=7,
|
||||||
),
|
),
|
||||||
{A: 9.27, B: 5.85, C: -7.78, D: -7.35}),
|
{A: 9.27, B: 5.85, C: -7.6, D: -7.53}),
|
||||||
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
("6a", dict.fromkeys(range(A, F + 1), 1500), _vets(*range(A, F + 1)),
|
||||||
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
|
RatedMatch(SIX, "objectives", end_round=8, nine_rounds_rule=True),
|
||||||
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
|
{A: 12.0, B: 7.2, C: 2.4, D: -2.4, E: -7.2, F: -12.0}),
|
||||||
@@ -101,6 +101,23 @@ def test_last_standing_counts_full_objective_gap():
|
|||||||
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
|
assert standing - ordinary == pytest.approx(16 * 0.5 * 0.5)
|
||||||
|
|
||||||
|
|
||||||
|
def test_eliminated_are_not_compared_with_each_other():
|
||||||
|
"""Выбывшие между собой не сравниваются (#91): слабый выбывший среди сильных не
|
||||||
|
получает рейтинг, а рейтинги прочих выбывших на его изменение не влияют."""
|
||||||
|
six = range(A, F + 1)
|
||||||
|
seats = (_seat(A, 1),) + tuple(_seat(u, 2, eliminated=True) for u in six if u != A)
|
||||||
|
match = RatedMatch(seats, "last_standing")
|
||||||
|
strong = {**dict.fromkeys(six, 1800), F: 1200}
|
||||||
|
delta = rate_match(strong, _vets(*six), match)[0]
|
||||||
|
assert delta[F] < 0
|
||||||
|
# Сильные выбывшие → слабые: у F и у победителя ничего не меняется от этого.
|
||||||
|
weak = {**dict.fromkeys(six, 1200), A: 1800}
|
||||||
|
again = rate_match(weak, _vets(*six), match)[0]
|
||||||
|
assert again[F] == pytest.approx(delta[F])
|
||||||
|
# Победителю сила соперников по-прежнему важна: против слабых он получает меньше.
|
||||||
|
assert again[A] < delta[A]
|
||||||
|
|
||||||
|
|
||||||
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
|
# ─── Сверка с эталоном ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
|
SIMULATE = Path(__file__).resolve().parents[2] / "docs" / "rating" / "simulate.py"
|
||||||
@@ -147,6 +164,7 @@ def test_constants_match_reference(sim):
|
|||||||
assert dict(p.closeness) == scoring.CLOSENESS
|
assert dict(p.closeness) == scoring.CLOSENESS
|
||||||
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
assert sim.BOARD_TILES == scoring.BOARD_TILES
|
||||||
assert not p.autocorr
|
assert not p.autocorr
|
||||||
|
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
|
||||||
@@ -174,8 +192,8 @@ def test_replay_matches_reference_season(sim, scenario, stripped):
|
|||||||
|
|
||||||
|
|
||||||
def test_monotone_and_zero_sum(sim):
|
def test_monotone_and_zero_sum(sim):
|
||||||
"""Победитель без ничьей не теряет, последний без ничьей не получает; при равных K
|
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
|
||||||
сумма изменений за партию — ноль."""
|
при равных K сумма изменений за партию — ноль."""
|
||||||
cfg = sim.SCENARIOS["сигнал"]
|
cfg = sim.SCENARIOS["сигнал"]
|
||||||
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
|
||||||
ids: dict[str, int] = {}
|
ids: dict[str, int] = {}
|
||||||
@@ -188,6 +206,8 @@ def test_monotone_and_zero_sum(sim):
|
|||||||
delta, _ = rate_match({}, veterans, rm)
|
delta, _ = rate_match({}, veterans, rm)
|
||||||
places = [s.place for s in rm.seats]
|
places = [s.place for s in rm.seats]
|
||||||
for s in rm.seats:
|
for s in rm.seats:
|
||||||
|
if s.eliminated:
|
||||||
|
assert delta[s.user_id] < 0
|
||||||
if places.count(s.place) > 1:
|
if places.count(s.place) > 1:
|
||||||
continue
|
continue
|
||||||
if s.place == 1:
|
if s.place == 1:
|
||||||
|
|||||||
@@ -4,8 +4,9 @@
|
|||||||
FROM restic/restic:0.19.1
|
FROM restic/restic:0.19.1
|
||||||
|
|
||||||
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
|
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
|
||||||
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
# tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
|
||||||
RUN apk add --no-cache sqlite supercronic tini
|
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
|
||||||
|
RUN apk add --no-cache sqlite supercronic tini curl
|
||||||
|
|
||||||
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
|
||||||
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
|
||||||
|
|||||||
+55
-5
@@ -737,16 +737,23 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
|
||||||
Контейнер `backup` стартует одновременно с `app` и сразу пробует сделать первый бэкап. В его
|
Контейнер `backup` стартует одновременно с `app`, ждёт, пока приложение ответит (до
|
||||||
журнале (`docker compose logs backup`) нормально увидеть одно из двух:
|
10 минут), и пробует сделать первый бэкап. В его журнале (`docker compose logs backup`)
|
||||||
|
нормально увидеть одно из трёх:
|
||||||
|
|
||||||
- если `backup` успел раньше, чем `app` создал БД:
|
- если приложение так и не поднялось и БД нет:
|
||||||
|
|
||||||
```
|
```
|
||||||
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
|
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
|
||||||
Первый бэкап не удался — следующая попытка по расписанию.
|
Первый бэкап не удался — следующая попытка по расписанию.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
- если БД есть, но без таблиц (миграции ещё не прошли):
|
||||||
|
|
||||||
|
```
|
||||||
|
Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches.
|
||||||
|
```
|
||||||
|
|
||||||
- если БД уже была:
|
- если БД уже была:
|
||||||
|
|
||||||
```
|
```
|
||||||
@@ -763,8 +770,8 @@
|
|||||||
```
|
```
|
||||||
|
|
||||||
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
|
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
|
||||||
Защита выше не распознаёт БД без таблиц: если первый бэкап попал ровно в момент создания
|
Снимки без данных защита больше не создаёт, но снимок с `?` мог остаться от старой версии
|
||||||
БД, в списке может появиться свежий снимок с `?` (задача #74). Такой снимок не выбирайте.
|
бэкапов — такой не выбирайте.
|
||||||
|
|
||||||
5. Восстановите, подставив ID из `list vps`:
|
5. Восстановите, подставив ID из `list vps`:
|
||||||
|
|
||||||
@@ -831,6 +838,42 @@ ID одного и того же снимка в `local` и `vps` разные
|
|||||||
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
**Обновить образ бэкапа** (после изменений в `deploy/backup/`). На ПК —
|
||||||
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
`.\scripts\build-push.ps1`, на Pi — `docker compose up -d backup`.
|
||||||
|
|
||||||
|
### Отчёты в Telegram
|
||||||
|
|
||||||
|
Бот приложения (тот же, что для входа через Telegram) может присылать вам отчёты о бэкапах
|
||||||
|
и отвечать на команды. Пока `BACKUP_TELEGRAM_CHAT_ID` пуст, отчёты выключены.
|
||||||
|
|
||||||
|
**Что приходит само:**
|
||||||
|
- после каждого бэкапа — «✅ Бэкап»: сколько игроков и партий в БД, по каждому репозиторию
|
||||||
|
снимок, число снимков и размер;
|
||||||
|
- если бэкап не удался, в том числе не начавшись (нет пароля, занята другая операция, БД
|
||||||
|
не прошла проверку) — «❌ Бэкап не удался» с причиной и упавшими репозиториями;
|
||||||
|
- после еженедельной проверки — «🔍 Проверка данных: OK» или «❌».
|
||||||
|
|
||||||
|
**Команды боту** (отвечает только чатам из `BACKUP_TELEGRAM_CHAT_ID`):
|
||||||
|
- `/backups` — хранящиеся снимки: по репозиторию число и размер, 10 последних с временем,
|
||||||
|
игроками и партиями, 📌 у именованных;
|
||||||
|
- `/status` — то же, что `fs-backup status`;
|
||||||
|
- `/help` — список команд.
|
||||||
|
|
||||||
|
**Настройка:**
|
||||||
|
1. В Telegram найдите бота приложения и напишите ему `/start`.
|
||||||
|
2. Узнайте id своего чата:
|
||||||
|
```bash
|
||||||
|
docker compose exec backup fs-backup telegram chats
|
||||||
|
# 123456789 @you /start
|
||||||
|
```
|
||||||
|
Когда бот уже работает (id вписан), он сам забирает сообщения. Тогда id нового чата
|
||||||
|
ищите в журнале: `docker compose logs backup | grep "чужого чата"`.
|
||||||
|
3. Впишите id в `.env` — `BACKUP_TELEGRAM_CHAT_ID=123456789`, несколько через запятую —
|
||||||
|
и пересоздайте контейнер: `docker compose up -d backup`.
|
||||||
|
4. Проверьте связь: `docker compose exec backup fs-backup telegram test` — в чат придёт
|
||||||
|
пробное сообщение.
|
||||||
|
|
||||||
|
Токен бота по умолчанию берётся из `TELEGRAM_BOT_TOKEN` приложения. Чтобы слать отчёты от
|
||||||
|
другого бота, задайте `BACKUP_TELEGRAM_BOT_TOKEN`. Если Telegram недоступен, бэкапы работают
|
||||||
|
как обычно — в журнале будет только строка «Telegram: … не отправлено».
|
||||||
|
|
||||||
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
> **Никогда не выполняйте на проде `docker compose down -v`.** Флаг `-v` удаляет тома —
|
||||||
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
> данные приложения **и** локальную копию бэкапов. Обычный `docker compose down` данные
|
||||||
> не трогает.
|
> не трогает.
|
||||||
@@ -852,6 +895,8 @@ docker compose logs --tail 100 backup
|
|||||||
| Симптом | Причина | Что сделать |
|
| Симптом | Причина | Что сделать |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
| `BACKUP_PASSWORD не задан в .env — бэкапы ОТКЛЮЧЕНЫ` | нет пароля в `.env` | добавить `BACKUP_PASSWORD` (шаг 5), затем `docker compose up -d backup` |
|
||||||
|
| отчёты в Telegram не приходят, `telegram test` пишет «отправить не удалось» | неверный chat id или токен, боту не писали `/start` | раздел 10, «Отчёты в Telegram»: написать боту, взять id из журнала, `docker compose up -d backup` |
|
||||||
|
| бот не отвечает на `/backups`, в журнале «сообщение из чужого чата» | ваш id не в `BACKUP_TELEGRAM_CHAT_ID` | вписать id из этой строки журнала, `docker compose up -d backup` |
|
||||||
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
| `неверный BACKUP_PASSWORD для репозитория …` | пароль в `.env` не тот, с которым создан репозиторий | вернуть правильный пароль из менеджера паролей; `docker compose up -d backup` |
|
||||||
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
| `BACKUP_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
|
||||||
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
|
||||||
@@ -920,6 +965,9 @@ docker volume rm <имя тома>
|
|||||||
| `recover` | разбор прерванного восстановления |
|
| `recover` | разбор прерванного восстановления |
|
||||||
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
|
||||||
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
| `restic <local\|vps> <аргументы>` | любая команда restic с настройками контейнера, например `restic vps stats` |
|
||||||
|
| `telegram chats` | id чатов, писавших боту, — для `BACKUP_TELEGRAM_CHAT_ID` |
|
||||||
|
| `telegram test` | пробное сообщение в чаты `BACKUP_TELEGRAM_CHAT_ID` |
|
||||||
|
| `telegram bot` | бот-слушатель команд `/backups`, `/status` (контейнер запускает его сам) |
|
||||||
| `help` | краткая справка |
|
| `help` | краткая справка |
|
||||||
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
|
||||||
|
|
||||||
@@ -955,6 +1003,8 @@ docker volume rm <имя тома>
|
|||||||
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
|
||||||
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
|
||||||
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
| `BACKUP_SSH_KEY_B64` | Pi | — | приватный ключ для VPS, base64 |
|
||||||
|
| `BACKUP_TELEGRAM_CHAT_ID` | Pi | — | id чатов для отчётов и команд, через запятую; пусто = без Telegram |
|
||||||
|
| `BACKUP_TELEGRAM_BOT_TOKEN` | Pi | `TELEGRAM_BOT_TOKEN` | токен бота, если отчёты должен слать другой бот |
|
||||||
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
| `BACKUP_MEM_LIMIT` | Pi | `384m` | лимит памяти контейнера |
|
||||||
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
| `BACKUP_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
|
||||||
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
|
||||||
|
|||||||
@@ -5,6 +5,14 @@ set -eu
|
|||||||
|
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*"; }
|
||||||
|
|
||||||
|
# Telegram-бот (#83): отвечает владельцу на /backups и /status. Стартует раньше проверки
|
||||||
|
# пароля — при выключенных бэкапах скажет, что они выключены. Упал — перезапуск через минуту.
|
||||||
|
if [ -n "${BACKUP_TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${BACKUP_TELEGRAM_CHAT_ID:-}" ]; then
|
||||||
|
( while :; do fs-backup telegram bot || true; sleep 60; done ) &
|
||||||
|
else
|
||||||
|
log "Telegram не настроен (BACKUP_TELEGRAM_CHAT_ID/токен бота) — отчёты в Telegram выключены."
|
||||||
|
fi
|
||||||
|
|
||||||
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
# Без пароля бэкапы невозможны. Не падаем (иначе restart-петля и спам в логах) — ждём,
|
||||||
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
|
||||||
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
if [ -z "${BACKUP_PASSWORD:-}" ]; then
|
||||||
@@ -29,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
if ! fs-backup has-snapshots; then
|
if ! fs-backup has-snapshots; then
|
||||||
log "Снимков в локальном репозитории ещё нет — делаю первый бэкап сразу."
|
# Новый сервер: app и backup стартуют разом, и БД может быть ещё без таблиц. /api/health
|
||||||
|
# отвечает только после миграций и bootstrap — ждём его (#74). Не дождались — пробуем всё
|
||||||
|
# равно: guard_empty не сохранит БД без таблиц.
|
||||||
|
_app="${BACKUP_APP_HOST:-app}"
|
||||||
|
_wait="${BACKUP_APP_WAIT_SECONDS:-600}"
|
||||||
|
log "Снимков в локальном репозитории ещё нет — жду приложение (до ${_wait} с) и делаю первый бэкап."
|
||||||
|
_waited=0
|
||||||
|
until wget -q -T 3 -O /dev/null "http://$_app:8000/api/health" 2>/dev/null; do
|
||||||
|
if [ "$_waited" -ge "$_wait" ]; then
|
||||||
|
log "Приложение не ответило за ${_wait} с — пробую бэкап без него."
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 10
|
||||||
|
_waited=$((_waited + 10))
|
||||||
|
done
|
||||||
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
fs-backup run --scheduled || log "Первый бэкап не удался — следующая попытка по расписанию."
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
+243
-4
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
|
|||||||
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
|
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
|
||||||
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
|
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
|
||||||
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
|
export RESTIC_CACHE_DIR="${RESTIC_CACHE_DIR:-/backup/cache}"
|
||||||
|
TG_TOKEN="${BACKUP_TELEGRAM_BOT_TOKEN:-}"
|
||||||
|
TG_CHATS="${BACKUP_TELEGRAM_CHAT_ID:-}"
|
||||||
|
TG_API="${BACKUP_TELEGRAM_API:-https://api.telegram.org}"
|
||||||
|
LAST_ERROR="" # текст последнего die — причина сбоя в отчёте Telegram
|
||||||
|
|
||||||
# ─── Общие функции ────────────────────────────────────────────────────────────
|
# ─── Общие функции ────────────────────────────────────────────────────────────
|
||||||
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
# Весь служебный вывод — в stderr: stdout у export занят tar-потоком.
|
||||||
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
log() { printf '[backup %s] %s\n' "$(date '+%Y-%m-%d %H:%M:%S')" "$*" >&2; }
|
||||||
die() { log "ОШИБКА: $*"; exit 1; }
|
die() { LAST_ERROR="$*"; log "ОШИБКА: $*"; exit 1; }
|
||||||
|
|
||||||
require_password() {
|
require_password() {
|
||||||
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
[ -n "$RESTIC_PASSWORD" ] || die "BACKUP_PASSWORD не задан в .env (см. deploy/backup/README.md, шаг 1)."
|
||||||
@@ -118,6 +122,99 @@ human() { # байты → «12.3 MB»
|
|||||||
|
|
||||||
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
fmt_epoch() { date -d "@$1" '+%Y-%m-%d %H:%M' 2>/dev/null || echo "$1"; }
|
||||||
|
|
||||||
|
# ─── Telegram (#83) ───────────────────────────────────────────────────────────
|
||||||
|
# Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота
|
||||||
|
# BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не
|
||||||
|
# страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст.
|
||||||
|
tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; }
|
||||||
|
|
||||||
|
tg_escape() { sed -e 's/&/\&/g' -e 's/</\</g' -e 's/>/\>/g'; }
|
||||||
|
|
||||||
|
tg_chat_ids() { printf '%s' "$TG_CHATS" | tr ',;' ' '; }
|
||||||
|
|
||||||
|
tg_api() { # tg_api <метод> [аргументы curl…] → JSON ответа
|
||||||
|
_method="$1"
|
||||||
|
shift
|
||||||
|
curl -fsS --max-time 70 "$@" "$TG_API/bot$TG_TOKEN/$_method" 2>/dev/null
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_post() { # tg_post <chat_id> <html> — одно сообщение в один чат
|
||||||
|
tg_api sendMessage -o /dev/null \
|
||||||
|
--data-urlencode "chat_id=$1" \
|
||||||
|
--data-urlencode "text=$2" \
|
||||||
|
--data-urlencode "parse_mode=HTML" \
|
||||||
|
--data-urlencode "disable_web_page_preview=true"
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_send() { # tg_send <html> — во все чаты владельца
|
||||||
|
tg_enabled || return 0
|
||||||
|
for _chat in $(tg_chat_ids); do
|
||||||
|
tg_post "$_chat" "$1" || log "Telegram: сообщение в чат $_chat не отправлено."
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
repo_summary() { # repo_summary <repo> → «снимков 30 · 45.2 MB»
|
||||||
|
if _st="$(r "$1" stats --mode raw-data --json 2>/dev/null)"; then
|
||||||
|
printf '%s' "$_st" | jq -r '"\(.snapshots_count)\t\(.total_size)"' | {
|
||||||
|
IFS="$(printf '\t')" read -r _cnt _size
|
||||||
|
echo "снимков $_cnt · $(human "$_size")"
|
||||||
|
}
|
||||||
|
else
|
||||||
|
echo "размер недоступен"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
|
||||||
|
# Итог run/verify одним сообщением. Списки — «репо:поле:поле» через пробел; их собирают
|
||||||
|
# cmd_run/cmd_verify, а вызывает обработчик EXIT — так отчёт уходит и при раннем отказе.
|
||||||
|
notify_run() { # notify_run <код выхода>
|
||||||
|
tg_enabled || return 0
|
||||||
|
_when="$(date '+%d.%m %H:%M')"
|
||||||
|
_db="БД: игроков $_players, партий $_matches"
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
_text="✅ <b>Бэкап</b> $_when
|
||||||
|
$_db"
|
||||||
|
else
|
||||||
|
_text="❌ <b>Бэкап не удался</b> $_when
|
||||||
|
$(printf '%s' "${LAST_ERROR:-прервался с кодом $1}" | tg_escape)"
|
||||||
|
[ "$_players" = "?" ] || _text="$_text
|
||||||
|
$_db"
|
||||||
|
fi
|
||||||
|
for _item in $_ok_repos; do
|
||||||
|
_text="$_text
|
||||||
|
• ${_item%%:*}: снимок <code>${_item#*:}</code> · $(repo_summary "${_item%%:*}")"
|
||||||
|
done
|
||||||
|
for _repo in $_failed; do
|
||||||
|
_text="$_text
|
||||||
|
• $_repo: ошибка"
|
||||||
|
done
|
||||||
|
[ "$1" -eq 0 ] || _text="$_text
|
||||||
|
Подробности: <code>docker compose logs backup</code>"
|
||||||
|
tg_send "$_text"
|
||||||
|
}
|
||||||
|
|
||||||
|
notify_verify() { # notify_verify <код выхода>
|
||||||
|
tg_enabled || return 0
|
||||||
|
_when="$(date '+%d.%m %H:%M')"
|
||||||
|
if [ "$1" -eq 0 ]; then
|
||||||
|
_text="🔍 <b>Проверка данных: OK</b> $_when"
|
||||||
|
else
|
||||||
|
_text="❌ <b>Проверка данных не прошла</b> $_when
|
||||||
|
$(printf '%s' "${LAST_ERROR:-прервалась с кодом $1}" | tg_escape)"
|
||||||
|
fi
|
||||||
|
for _item in $_verified; do
|
||||||
|
_rest="${_item#*:}"
|
||||||
|
_text="$_text
|
||||||
|
• ${_item%%:*}: данные целы, в последнем снимке игроков ${_rest%%:*}, партий ${_rest#*:}"
|
||||||
|
done
|
||||||
|
for _repo in $_failed; do
|
||||||
|
_text="$_text
|
||||||
|
• $_repo: ошибка"
|
||||||
|
done
|
||||||
|
[ "$1" -eq 0 ] || _text="$_text
|
||||||
|
Подробности: <code>docker compose logs backup</code>"
|
||||||
|
tg_send "$_text"
|
||||||
|
}
|
||||||
|
|
||||||
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
# Проверка SQLite без записи рядом с файлом (immutable: ни -wal, ни -shm не создаются).
|
||||||
db_ok() {
|
db_ok() {
|
||||||
[ -s "$1" ] || return 1
|
[ -s "$1" ] || return 1
|
||||||
@@ -178,6 +275,17 @@ forget_repo() {
|
|||||||
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
|
||||||
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
|
||||||
guard_empty() { # guard_empty <игроков> <партий>
|
guard_empty() { # guard_empty <игроков> <партий>
|
||||||
|
# «?» — счётчики не прочитались: в БД нет таблиц (миграции ещё не прошли). Такой снимок
|
||||||
|
# стал бы latest и сломал бы restore latest (#74) — отказ, как и для пустой БД.
|
||||||
|
case "$1$2" in
|
||||||
|
*'?'*)
|
||||||
|
for _r in $(repos); do mark "run-$_r" err "в БД нет таблиц users/matches — бэкап не сделан"; done
|
||||||
|
log "Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches."
|
||||||
|
log "Похоже, приложение ещё не применило миграции. Бэкап НЕ сделан, чтобы снимок без данных"
|
||||||
|
log "не стал последним. Дождитесь запуска приложения; если так и задумано:"
|
||||||
|
die "fs-backup run --allow-empty"
|
||||||
|
;;
|
||||||
|
esac
|
||||||
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
|
if [ "$1" != 0 ] || [ "$2" != 0 ]; then return 0; fi
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
|
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
|
||||||
@@ -236,11 +344,18 @@ cmd_run() {
|
|||||||
esac
|
esac
|
||||||
shift
|
shift
|
||||||
done
|
done
|
||||||
|
_players="?"
|
||||||
|
_matches="?"
|
||||||
|
_ok_repos=""
|
||||||
|
_failed=""
|
||||||
|
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
|
||||||
|
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
|
||||||
|
trap '_rc=$?; notify_run "$_rc"' EXIT
|
||||||
require_password
|
require_password
|
||||||
setup_ssh
|
setup_ssh
|
||||||
take_lock
|
take_lock
|
||||||
warn_leftovers
|
warn_leftovers
|
||||||
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
|
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
|
||||||
|
|
||||||
log "Снимок данных: консистентная копия БД…"
|
log "Снимок данных: консистентная копия БД…"
|
||||||
stage_db
|
stage_db
|
||||||
@@ -252,7 +367,6 @@ cmd_run() {
|
|||||||
log "БД в порядке: игроков $_players, партий $_matches."
|
log "БД в порядке: игроков $_players, партий $_matches."
|
||||||
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
|
||||||
|
|
||||||
_failed=""
|
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
|
||||||
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
if backup_to "$_repo" --tag "$_kind" --tag "players:$_players" --tag "matches:$_matches" ${_tag:+--tag keep --tag "$_tag"} \
|
||||||
@@ -260,6 +374,7 @@ cmd_run() {
|
|||||||
&& r "$_repo" check >&2; then
|
&& r "$_repo" check >&2; then
|
||||||
_sid="$(latest_short_id "$_repo")"
|
_sid="$(latest_short_id "$_repo")"
|
||||||
mark "run-$_repo" ok "снимок $_sid"
|
mark "run-$_repo" ok "снимок $_sid"
|
||||||
|
_ok_repos="$_ok_repos $_repo:$_sid"
|
||||||
log "OK: репозиторий $_repo, снимок $_sid."
|
log "OK: репозиторий $_repo, снимок $_sid."
|
||||||
else
|
else
|
||||||
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
|
||||||
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
|
|||||||
|
|
||||||
# ─── verify ───────────────────────────────────────────────────────────────────
|
# ─── verify ───────────────────────────────────────────────────────────────────
|
||||||
cmd_verify() {
|
cmd_verify() {
|
||||||
|
_verified=""
|
||||||
|
_failed=""
|
||||||
|
trap '_rc=$?; notify_verify "$_rc"' EXIT
|
||||||
require_password
|
require_password
|
||||||
setup_ssh
|
setup_ssh
|
||||||
take_lock
|
take_lock
|
||||||
_failed=""
|
|
||||||
for _repo in $(repos); do
|
for _repo in $(repos); do
|
||||||
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
|
||||||
_tmp="$RUNTIME_DIR/verify.db"
|
_tmp="$RUNTIME_DIR/verify.db"
|
||||||
@@ -378,6 +495,7 @@ cmd_verify() {
|
|||||||
&& db_ok "$_tmp"; then
|
&& db_ok "$_tmp"; then
|
||||||
_counts="$(db_counts "$_tmp")"
|
_counts="$(db_counts "$_tmp")"
|
||||||
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
|
||||||
|
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
|
||||||
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
|
||||||
else
|
else
|
||||||
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
mark "verify-$_repo" err "проверка не прошла — см. docker compose logs backup"
|
||||||
@@ -779,6 +897,121 @@ cmd_init() {
|
|||||||
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
for _repo in $(repos); do ensure_repo "$_repo" && log "Репозиторий $_repo готов."; done
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ─── telegram ─────────────────────────────────────────────────────────────────
|
||||||
|
tg_help_text() {
|
||||||
|
cat <<'EOF'
|
||||||
|
<b>Бэкапы Forbidden Stars</b>
|
||||||
|
/backups — хранящиеся снимки по репозиториям
|
||||||
|
/status — последние бэкапы и проверки, размеры
|
||||||
|
Отчёт о каждом бэкапе и проверке приходит сюда сам.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_backups_text() { # хранящиеся снимки: по репозиторию итог и 10 последних
|
||||||
|
[ -n "$RESTIC_PASSWORD" ] || { echo "Бэкапы отключены: BACKUP_PASSWORD не задан."; return 0; }
|
||||||
|
setup_ssh
|
||||||
|
for _repo in $(repos); do
|
||||||
|
echo "<b>$_repo</b> · $(repo_summary "$_repo")"
|
||||||
|
if _json="$(r "$_repo" snapshots --json 2>/dev/null)"; then
|
||||||
|
printf '%s' "$_json" | jq -r '
|
||||||
|
def tagval($p): ([.tags[]? | select(startswith($p)) | ltrimstr($p)] | first) // "?";
|
||||||
|
def names: [.tags[]? | select(test("^(players:|matches:|scheduled$|manual$|keep$)") | not)];
|
||||||
|
sort_by(.time) | .[-10:] | reverse | .[] |
|
||||||
|
"<code>\(.time[5:16] | sub("T"; " "))</code> игроков \(tagval("players:")), партий \(tagval("matches:"))"
|
||||||
|
+ (if (names | length) > 0 then " · 📌 " + (names | join(", ")) else "" end)'
|
||||||
|
else
|
||||||
|
echo "репозиторий недоступен или занят — повторите позже"
|
||||||
|
fi
|
||||||
|
echo
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_allowed() { # tg_allowed <chat_id> — чат из белого списка владельца
|
||||||
|
for _allowed in $(tg_chat_ids); do [ "$_allowed" = "$1" ] && return 0; done
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
tg_reply() { # tg_reply <chat_id> <текст команды>
|
||||||
|
# Каждая выборка — в подоболочке $(…): die внутри не роняет цикл бота.
|
||||||
|
case "$2" in
|
||||||
|
/backups*|/list*) _reply="$(tg_backups_text 2>/dev/null)" ;;
|
||||||
|
/status*) _reply="<pre>$(cmd_status 2>&1 | head -n 60 | tg_escape)</pre>" ;;
|
||||||
|
*) _reply="$(tg_help_text)" ;;
|
||||||
|
esac
|
||||||
|
[ -n "$_reply" ] || _reply="Не удалось получить данные — см. docker compose logs backup."
|
||||||
|
tg_post "$1" "$_reply" || log "Telegram: ответ в чат $1 не отправлен."
|
||||||
|
}
|
||||||
|
|
||||||
|
# Бот: long polling getUpdates. Отвечает только чатам из BACKUP_TELEGRAM_CHAT_ID; про чужие
|
||||||
|
# пишет chat id в лог — так владелец узнаёт свой при настройке. Lock не берёт: чтение
|
||||||
|
# restic совместимо с идущим бэкапом.
|
||||||
|
cmd_telegram_bot() {
|
||||||
|
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота."
|
||||||
|
_offset_file="$STATE_DIR/telegram.offset"
|
||||||
|
_updates="$RUNTIME_DIR/telegram-updates"
|
||||||
|
log "Telegram-бот: слушаю команды /backups, /status, /help."
|
||||||
|
while :; do
|
||||||
|
_offset="$(cat "$_offset_file" 2>/dev/null || echo 0)"
|
||||||
|
if ! _resp="$(tg_api getUpdates --data-urlencode "offset=$_offset" \
|
||||||
|
--data-urlencode "timeout=50" --data-urlencode 'allowed_updates=["message"]')"; then
|
||||||
|
sleep 30
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
# Разделитель — \037 (не пробельный): пустые поля (нет username/текста) не схлопываются.
|
||||||
|
printf '%s' "$_resp" | jq -r '.result[]? |
|
||||||
|
[.update_id, (.message.chat.id // ""), (.message.from.username // ""),
|
||||||
|
((.message.text // "") | gsub("[\n]"; " "))] | map(tostring) | join("")' \
|
||||||
|
> "$_updates" 2>/dev/null || { sleep 30; continue; }
|
||||||
|
while IFS="$(printf '\037')" read -r _uid _chat _from _text; do
|
||||||
|
echo $((_uid + 1)) > "$_offset_file"
|
||||||
|
[ -n "$_chat" ] || continue
|
||||||
|
if tg_allowed "$_chat"; then
|
||||||
|
tg_reply "$_chat" "$_text"
|
||||||
|
else
|
||||||
|
log "Telegram: сообщение из чужого чата $_chat (@${_from:-?}) — без ответа. Если это вы, впишите $_chat в BACKUP_TELEGRAM_CHAT_ID."
|
||||||
|
fi
|
||||||
|
done < "$_updates"
|
||||||
|
done
|
||||||
|
}
|
||||||
|
|
||||||
|
# Кто писал боту: id чатов для BACKUP_TELEGRAM_CHAT_ID. Нужен только токен; апдейты не
|
||||||
|
# подтверждаются (без offset), так что ничего не теряется. Пока бот-слушатель запущен, он
|
||||||
|
# забирает сообщения сам — тогда id смотрите в журнале («сообщение из чужого чата …»).
|
||||||
|
cmd_telegram_chats() {
|
||||||
|
[ -n "$TG_TOKEN" ] || die "нет токена бота: задайте TELEGRAM_BOT_TOKEN или BACKUP_TELEGRAM_BOT_TOKEN."
|
||||||
|
_resp="$(tg_api getUpdates --data-urlencode "timeout=0")" \
|
||||||
|
|| die "Telegram не ответил (неверный токен или нет сети)."
|
||||||
|
_chats="$(printf '%s' "$_resp" | jq -r '[.result[]? | .message | select(. != null)
|
||||||
|
| "\(.chat.id)\t@\(.from.username // "?")\t\(.text // "")"] | unique | .[]')"
|
||||||
|
if [ -z "$_chats" ]; then
|
||||||
|
echo "Сообщений боту нет: напишите ему /start в Telegram и повторите."
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
echo "Чаты, писавшие боту (id — в BACKUP_TELEGRAM_CHAT_ID):"
|
||||||
|
printf '%s\n' "$_chats"
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd_telegram() {
|
||||||
|
case "${1:-}" in
|
||||||
|
bot) cmd_telegram_bot ;;
|
||||||
|
chats) cmd_telegram_chats ;;
|
||||||
|
test)
|
||||||
|
tg_enabled || die "Telegram не настроен: нужны BACKUP_TELEGRAM_CHAT_ID и токен бота (BACKUP_TELEGRAM_BOT_TOKEN или TELEGRAM_BOT_TOKEN)."
|
||||||
|
_sent=0
|
||||||
|
for _chat in $(tg_chat_ids); do
|
||||||
|
if tg_post "$_chat" "🔔 Проверка связи: отчёты о бэкапах Forbidden Stars будут приходить сюда."; then
|
||||||
|
_sent=$((_sent + 1))
|
||||||
|
log "Telegram: тестовое сообщение в чат $_chat отправлено."
|
||||||
|
else
|
||||||
|
log "Telegram: в чат $_chat отправить не удалось (неверный токен/chat id или боту не писали /start)."
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
[ "$_sent" -gt 0 ] || die "ни одно тестовое сообщение не дошло."
|
||||||
|
;;
|
||||||
|
*) die "telegram: укажите chats, test или bot" ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
usage() {
|
usage() {
|
||||||
cat <<'EOF'
|
cat <<'EOF'
|
||||||
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из папки с docker-compose.yml:
|
||||||
@@ -806,6 +1039,11 @@ fs-backup — бэкапы Forbidden Stars (restic). Запуск на Pi из
|
|||||||
export <id|latest> [--repo local|vps] > fs.tar
|
export <id|latest> [--repo local|vps] > fs.tar
|
||||||
снимок в tar без сжатия (нужен exec -T)
|
снимок в tar без сжатия (нужен exec -T)
|
||||||
|
|
||||||
|
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
|
||||||
|
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
|
||||||
|
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
|
||||||
|
telegram bot бот-слушатель команд (контейнер запускает его сам)
|
||||||
|
|
||||||
Прочее:
|
Прочее:
|
||||||
init создать репозитории (делается автоматически)
|
init создать репозитории (делается автоматически)
|
||||||
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
|
||||||
@@ -833,6 +1071,7 @@ case "$_cmd" in
|
|||||||
restic) cmd_restic "$@" ;;
|
restic) cmd_restic "$@" ;;
|
||||||
health) cmd_health "$@" ;;
|
health) cmd_health "$@" ;;
|
||||||
has-snapshots) cmd_has_snapshots "$@" ;;
|
has-snapshots) cmd_has_snapshots "$@" ;;
|
||||||
|
telegram) cmd_telegram "$@" ;;
|
||||||
help|-h|--help) usage ;;
|
help|-h|--help) usage ;;
|
||||||
*) usage >&2; exit 2 ;;
|
*) usage >&2; exit 2 ;;
|
||||||
esac
|
esac
|
||||||
|
|||||||
+41
-3
@@ -35,6 +35,32 @@ newgrp docker # применить группу
|
|||||||
docker version && docker compose version # проверка
|
docker version && docker compose version # проверка
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Лимиты памяти (memory cgroup)
|
||||||
|
Прошивка Raspberry Pi сама добавляет ядру `cgroup_disable=memory`. Без контроллера `memory`
|
||||||
|
Docker **молча игнорирует** `mem_limit` из `docker-compose.yml` (app 512m, tunnel 64m, backup
|
||||||
|
384m) и на каждый контейнер пишет «Your kernel does not support memory limit capabilities or
|
||||||
|
the cgroup is not mounted. Limitation discarded.». Тогда утечка или тяжёлый бэкап могут
|
||||||
|
занять всю RAM Pi, и OOM-killer прибьёт что попало.
|
||||||
|
|
||||||
|
Проверка — если есть вывод, лимиты не работают:
|
||||||
|
```bash
|
||||||
|
docker info 2>&1 | grep -i "no memory limit"
|
||||||
|
cat /sys/fs/cgroup/cgroup.controllers # в списке должно быть слово memory
|
||||||
|
```
|
||||||
|
Включить — дописать параметр **в ту же единственную строку** `cmdline.txt` (перевод строки
|
||||||
|
в этом файле ломает загрузку) и перезагрузить Pi. Прод на время перезагрузки недоступен,
|
||||||
|
контейнеры поднимутся сами:
|
||||||
|
```bash
|
||||||
|
sudo cp /boot/firmware/cmdline.txt /boot/firmware/cmdline.txt.bak
|
||||||
|
grep -q "cgroup_enable=memory" /boot/firmware/cmdline.txt || \
|
||||||
|
sudo sed -i '1 s/$/ cgroup_enable=memory/' /boot/firmware/cmdline.txt
|
||||||
|
cat /boot/firmware/cmdline.txt # одна строка, в конце cgroup_enable=memory
|
||||||
|
sudo reboot
|
||||||
|
```
|
||||||
|
После перезагрузки `cgroup.controllers` содержит `memory`, а `docker info` не пишет
|
||||||
|
`No memory limit support`. Если Pi не загрузился, верните бэкап: вставьте карту в ПК и на
|
||||||
|
разделе `bootfs` замените `cmdline.txt` содержимым `cmdline.txt.bak`.
|
||||||
|
|
||||||
## 2. SSH-ключ для туннеля
|
## 2. SSH-ключ для туннеля
|
||||||
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
|
||||||
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
|
||||||
@@ -92,9 +118,18 @@ IMAGE_TAG=latest
|
|||||||
|
|
||||||
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
|
||||||
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
|
||||||
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются **только при первом создании** админа: если
|
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
|
||||||
> потом поменять их в `.env`, у существующего админа ничего не изменится, а штатной смены
|
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
|
||||||
> пароля админа на проде пока нет (задача #73).
|
>
|
||||||
|
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
|
||||||
|
> его не сменить:
|
||||||
|
> 1. поменять `ADMIN_PASSWORD` в `.env` на Pi;
|
||||||
|
> 2. `docker compose up -d` — пересоздаст `app` с новым `.env` (контейнер читает `.env`
|
||||||
|
> только при создании; без этого шага команда ниже увидит старый пароль);
|
||||||
|
> 3. `docker compose exec app python -m app.bootstrap --reset-admin-password`.
|
||||||
|
>
|
||||||
|
> Все админские сессии, в том числе чужие, если пароль утёк, после этого завершаются.
|
||||||
|
> Логин (`ADMIN_USERNAME`) команда не меняет.
|
||||||
|
|
||||||
## 4. Запуск
|
## 4. Запуск
|
||||||
```bash
|
```bash
|
||||||
@@ -145,3 +180,6 @@ docker compose exec backup fs-backup list # хронология снимк
|
|||||||
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
|
||||||
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
|
||||||
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
сертификатом (иначе хост в `/etc/docker/daemon.json` → `insecure-registries`, `systemctl restart docker`).
|
||||||
|
- `docker compose up` пишет «Your kernel does not support memory limit capabilities…
|
||||||
|
Limitation discarded.» → лимиты памяти не работают: включите memory cgroup, раздел 1,
|
||||||
|
«Лимиты памяти».
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
|
||||||
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
# Два домена, ОБА с твоими сертификатами; проксируют в SSH-туннели:
|
||||||
#
|
#
|
||||||
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (autossh, постоянно) PROD
|
# forbiddenstars.ru → 127.0.0.1:9000 ← Pi (туннель-контейнер, ssh + restart) PROD
|
||||||
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
# forbidden-stars.ru → 127.0.0.1:9001 ← ПК (по требованию) DEV
|
||||||
#
|
#
|
||||||
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
|
||||||
|
|||||||
+11
-5
@@ -21,8 +21,8 @@
|
|||||||
services:
|
services:
|
||||||
app:
|
app:
|
||||||
build: .
|
build: .
|
||||||
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется
|
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
|
||||||
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi).
|
# .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
|
||||||
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
@@ -42,7 +42,9 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 3
|
retries: 3
|
||||||
start_period: 40s
|
start_period: 40s
|
||||||
mem_limit: ${APP_MEM_LIMIT:-512m} # лимиты под Pi; переопределяются в .env
|
# Лимиты под Pi; переопределяются в .env. mem_limit действует, только если на Pi включён
|
||||||
|
# memory cgroup — иначе Docker его молча игнорирует (deploy/pi/README.md, раздел 1).
|
||||||
|
mem_limit: ${APP_MEM_LIMIT:-512m}
|
||||||
cpus: ${APP_CPUS:-1.5}
|
cpus: ${APP_CPUS:-1.5}
|
||||||
security_opt:
|
security_opt:
|
||||||
- no-new-privileges:true
|
- no-new-privileges:true
|
||||||
@@ -75,8 +77,8 @@ services:
|
|||||||
|
|
||||||
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
|
||||||
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
|
||||||
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
|
# только BACKUP_* (секреты приложения сюда не передаются; исключение — токен бота для
|
||||||
# восстановление — deploy/backup/README.md.
|
# отчётов в Telegram). Всё про настройку и восстановление — deploy/backup/README.md.
|
||||||
backup:
|
backup:
|
||||||
build: ./deploy/backup
|
build: ./deploy/backup
|
||||||
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars-backup:${IMAGE_TAG:-latest}
|
||||||
@@ -97,6 +99,10 @@ services:
|
|||||||
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
|
BACKUP_VPS_PORT: ${BACKUP_VPS_PORT:-22}
|
||||||
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
|
BACKUP_VPS_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
|
||||||
BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-}
|
BACKUP_SSH_KEY_B64: ${BACKUP_SSH_KEY_B64:-}
|
||||||
|
# Отчёты и бот в Telegram (#83). Единственное исключение из «секреты приложения сюда
|
||||||
|
# не передаются»: по умолчанию — токен бота приложения (тот же ForbidenStarsBot).
|
||||||
|
BACKUP_TELEGRAM_BOT_TOKEN: ${BACKUP_TELEGRAM_BOT_TOKEN:-${TELEGRAM_BOT_TOKEN:-}}
|
||||||
|
BACKUP_TELEGRAM_CHAT_ID: ${BACKUP_TELEGRAM_CHAT_ID:-}
|
||||||
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
TZ: ${BACKUP_TZ:-Europe/Moscow}
|
||||||
volumes:
|
volumes:
|
||||||
- db-data:/fs-db # живая БД (снимается консистентно)
|
- db-data:/fs-db # живая БД (снимается консистентно)
|
||||||
|
|||||||
@@ -26,8 +26,11 @@
|
|||||||
влияет, но слабее темпа.
|
влияет, но слабее темпа.
|
||||||
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
|
||||||
задачи «60 против 40» в ней — 1600 против 1400.
|
задачи «60 против 40» в ней — 1600 против 1400.
|
||||||
- **Монотонность.** Победитель никогда не теряет рейтинг, последнее место никогда не
|
- **Выбывшие между собой не сравниваются.** Все они проиграли, а миров у них нет: пара двух
|
||||||
приносит.
|
выбывших в расчёт не входит. Сила соперников при этом важна, как и прежде (4.4).
|
||||||
|
- **Монотонность.** Единоличный победитель и выбывшие всегда движутся в свою сторону: первый
|
||||||
|
не теряет рейтинг, вторые его не получают. Внутри ничьей невыбывших — за 1-е или
|
||||||
|
последнее место — пара может сдвинуть рейтинг в любую сторону (4.9).
|
||||||
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
- **Старая история.** Пересчитывается по тем же формулам: у партий без новых полей
|
||||||
признаки берутся нейтральными.
|
признаки берутся нейтральными.
|
||||||
|
|
||||||
@@ -159,7 +162,8 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
|||||||
### 4.3. Фактический результат пары
|
### 4.3. Фактический результат пары
|
||||||
|
|
||||||
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
|
||||||
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`).
|
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`),
|
||||||
|
но между собой не сравниваются (4.4).
|
||||||
|
|
||||||
### 4.4. Изменение рейтинга
|
### 4.4. Изменение рейтинга
|
||||||
|
|
||||||
@@ -170,6 +174,14 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
|
|||||||
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
|
||||||
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
|
||||||
|
|
||||||
|
**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба
|
||||||
|
выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они
|
||||||
|
все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего
|
||||||
|
с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна.
|
||||||
|
Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая
|
||||||
|
пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший
|
||||||
|
получал рейтинг за счёт сильных выбывших.
|
||||||
|
|
||||||
### 4.5. Коэффициент K — скорость изменения
|
### 4.5. Коэффициент K — скорость изменения
|
||||||
|
|
||||||
```math
|
```math
|
||||||
@@ -236,7 +248,10 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 4.9. Свойства
|
### 4.9. Свойства
|
||||||
|
|
||||||
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
|
||||||
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг.
|
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже:
|
||||||
|
его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей
|
||||||
|
невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший
|
||||||
|
1-е место со слабым, может потерять.
|
||||||
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
|
||||||
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
|
||||||
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
это сделано намеренно (пример 7). В симуляции среднее по лиге за 300 партий сдвигается
|
||||||
@@ -352,16 +367,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
| A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 |
|
| A–D | 1 | 0.640 | 1 | 1 | 1 + 0.25 + 0.25 = **1.500** | 0.540 |
|
||||||
| B–C | 1 | 0.529 | 2/4 | 1 | 1 + 0 + 0.25 = **1.250** | 0.589 |
|
| B–C | 1 | 0.529 | 2/4 | 1 | 1 + 0 + 0.25 = **1.250** | 0.589 |
|
||||||
| B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 |
|
| B–D | 1 | 0.571 | 3/4 | 1 | **1.375** | 0.589 |
|
||||||
| C–D | 0.5 | 0.543 | 1/4 (по модулю) | 0 | 1 − 0.125 − 0.25 = **0.625** | −0.027 |
|
| C–D | — | — | — | — | оба выбыли — пара не считается (4.4) | 0 |
|
||||||
|
|
||||||
- `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
|
- `ΔR_A = 6.67 · (0.300 + 0.551 + 0.540)` = **+9.27**
|
||||||
- `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
|
- `ΔR_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
|
||||||
- `ΔR_C = 6.67 · (−0.551 − 0.589 − 0.027)` = **−7.78**
|
- `ΔR_C = 6.67 · (−0.551 − 0.589)` = **−7.60**
|
||||||
- `ΔR_D = 6.67 · (−0.540 − 0.589 + 0.027)` = **−7.35**
|
- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53**
|
||||||
|
|
||||||
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
|
||||||
от выбывших огромный — эти пары весят 1.25–1.5. C и D поделили место, но C сильнее по
|
от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба
|
||||||
рейтингу, поэтому немного уступает D.
|
проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего.
|
||||||
|
|
||||||
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
### Пример 6. Стол на 6 и хоумрул 9 раундов
|
||||||
|
|
||||||
@@ -435,7 +450,9 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
### 7.3. Результаты: 200 сезонов на сценарий
|
### 7.3. Результаты: 200 сезонов на сценарий
|
||||||
|
|
||||||
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
|
||||||
так будет считаться история, накопленная до #23.
|
так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны
|
||||||
|
с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь
|
||||||
|
в третьем знаке, у остальных систем цифры прежние.
|
||||||
|
|
||||||
**Сигнал** (потолок точности 0.6843)
|
**Сигнал** (потолок точности 0.6843)
|
||||||
|
|
||||||
@@ -443,8 +460,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 |
|
| League Points (сейчас) | 0.6683 | — | 0.913 | 0.660 | 0.769 | 0.854 | — | — | 6.91 |
|
||||||
| Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 47.4 | 0.81 | 5.24 |
|
| Elo, чистый | 0.6691 | 0.2089 | 0.911 | 0.656 | 0.770 | 0.852 | 47.4 | 0.81 | 5.24 |
|
||||||
| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.711** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 |
|
| **Предложенная** | **0.6721** | **0.2077** | **0.929** | **0.712** | **0.807** | **0.881** | **41.9** | **0.95** | 5.95 |
|
||||||
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.782 | 0.864 | 47.4 | 0.79 | 5.90 |
|
| Предложенная, без новых полей | 0.6701 | 0.2085 | 0.916 | 0.677 | 0.783 | 0.864 | 47.2 | 0.79 | 5.90 |
|
||||||
|
|
||||||
**Клубы** (потолок 0.6308)
|
**Клубы** (потолок 0.6308)
|
||||||
|
|
||||||
@@ -452,8 +469,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 |
|
| League Points (сейчас) | 0.6045 | — | 0.525 | 0.303 | 0.391 | 0.463 | — | — | 8.06 |
|
||||||
| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 |
|
| Elo, чистый | 0.6063 | 0.2341 | 0.632 | 0.326 | 0.440 | 0.535 | 102.6 | 0.40 | 5.71 |
|
||||||
| **Предложенная** | **0.6102** | 0.2336 | **0.644** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
|
| **Предложенная** | **0.6101** | 0.2336 | **0.645** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
|
||||||
| Предложенная, без новых полей | 0.6074 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.534 | 103.3 | 0.38 | 6.41 |
|
| Предложенная, без новых полей | 0.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 |
|
||||||
|
|
||||||
**Рост** (потолок 0.6864)
|
**Рост** (потолок 0.6864)
|
||||||
|
|
||||||
@@ -461,8 +478,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 |
|
| League Points (сейчас) | 0.6688 | — | 0.915 | 0.631 | 0.732 | 0.824 | — | — | 6.91 |
|
||||||
| Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 48.0 | 0.81 | 5.25 |
|
| Elo, чистый | 0.6687 | 0.2086 | 0.916 | 0.632 | 0.733 | 0.825 | 48.0 | 0.81 | 5.25 |
|
||||||
| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.774** | **0.851** | **42.7** | **0.95** | 5.96 |
|
| **Предложенная** | **0.6726** | **0.2076** | **0.929** | **0.685** | **0.775** | **0.850** | **42.7** | **0.95** | 5.96 |
|
||||||
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.6 | 0.79 | 5.91 |
|
| Предложенная, без новых полей | 0.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.5 | 0.79 | 5.91 |
|
||||||
|
|
||||||
**Шум** (потолок 0.6894)
|
**Шум** (потолок 0.6894)
|
||||||
|
|
||||||
@@ -470,16 +487,16 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|---|---|---|---|---|---|---|---|---|---|
|
|---|---|---|---|---|---|---|---|---|---|
|
||||||
| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 |
|
| League Points (сейчас) | 0.6737 | — | 0.916 | 0.656 | 0.761 | 0.848 | — | — | 6.89 |
|
||||||
| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 |
|
| Elo, чистый | 0.6739 | **0.2070** | 0.917 | 0.659 | 0.765 | 0.851 | **46.1** | **0.82** | 5.23 |
|
||||||
| Предложенная | 0.6727 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.6 | 0.81 | 5.91 |
|
| Предложенная | 0.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 |
|
||||||
| Предложенная, без новых полей | **0.6743** | **0.2070** | **0.919** | **0.667** | **0.775** | **0.856** | 47.4 | 0.78 | 5.88 |
|
| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 |
|
||||||
|
|
||||||
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
|
||||||
|
|
||||||
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.37 ± 0.07 п.п. |
|
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. |
|
||||||
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.57 ± 0.10 п.п. |
|
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. |
|
||||||
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.07 п.п. | +0.37 ± 0.06 п.п. |
|
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. |
|
||||||
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
| шум | +0.0006 ± 0.0001 | −0.12 ± 0.06 п.п. | −0.10 ± 0.07 п.п. |
|
||||||
|
|
||||||
Выводы:
|
Выводы:
|
||||||
@@ -490,7 +507,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
Рейтинг меньше сжат к середине (наклон 0.95 против 0.81): сильные игроки быстрее
|
||||||
отрываются от середняков.
|
отрываются от середняков.
|
||||||
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
|
||||||
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.644): чемпион слабой группы
|
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы
|
||||||
у неё стоит рядом с чемпионом сильной.
|
у неё стоит рядом с чемпионом сильной.
|
||||||
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
3. **Если отрыв — шум**, предложенная система теряет 0.1 п.п. точности и 0.0006 Brier —
|
||||||
цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях
|
цена лишней волатильности. Это худший из рассмотренных случаев: в остальных сценариях
|
||||||
@@ -506,6 +523,8 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
|
|
||||||
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
`simulate.py --grid` перебирает коэффициенты на **других** 40 сезонах каждого сценария.
|
||||||
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
|
||||||
|
Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не
|
||||||
|
переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3).
|
||||||
|
|
||||||
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
|
||||||
Выгоден высокий K новичка и низкий K ветерана.
|
Выгоден высокий K новичка и низкий K ветерана.
|
||||||
@@ -732,6 +751,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
|
|||||||
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
|
||||||
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
|
||||||
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
|
| 7 | Групповой рейтинг отдельной цепочкой (после внедрения, #80, 2026-09-15) | **Убрать.** Рейтинг единый; на странице группы — показатели по партиям группы, на главной и в профиле — общие | раздел 8, «Расчёт» |
|
||||||
|
| 8 | Сравнивать ли выбывших между собой (после ревью, #87 → #91, 2026-09-18) | **Нет.** Все они проиграли и одинаково слабы в этой партии, миров у них нет; при расчёте победителя сила соперников по-прежнему важна | 4.3, 4.4, 4.9, пример 5, итоги 7.3 (подбор 7.4 не переигрывался) |
|
||||||
|
|
||||||
Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных —
|
Открытых вопросов по предложению не осталось. Калибровка коэффициентов на реальных данных —
|
||||||
после внедрения #23 (раздел 8, «Калибровка»).
|
после внедрения #23 (раздел 8, «Калибровка»).
|
||||||
|
|||||||
@@ -93,6 +93,9 @@ class Params:
|
|||||||
m_min: float = 0.5
|
m_min: float = 0.5
|
||||||
m_max: float = 2.0
|
m_max: float = 2.0
|
||||||
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
autocorr: bool = False # поправка на автокорреляцию (см. документ)
|
||||||
|
# Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму
|
||||||
|
# (решение владельца, #91). У систем для сравнения — как было.
|
||||||
|
skip_eliminated_pairs: bool = False
|
||||||
|
|
||||||
def closeness_for(self, reason: str | None) -> float:
|
def closeness_for(self, reason: str | None) -> float:
|
||||||
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
return dict(self.closeness).get(reason, 1.0) if reason else 1.0
|
||||||
@@ -181,6 +184,8 @@ def rate_match(
|
|||||||
k = {s.player: k_factor(games.get(s.player, 0), p) for s in m.seats}
|
k = {s.player: k_factor(games.get(s.player, 0), p) for s in m.seats}
|
||||||
delta = {s.player: 0.0 for s in m.seats}
|
delta = {s.player: 0.0 for s in m.seats}
|
||||||
for a, b in combinations(m.seats, 2):
|
for a, b in combinations(m.seats, 2):
|
||||||
|
if p.skip_eliminated_pairs and a.eliminated and b.eliminated:
|
||||||
|
continue
|
||||||
if a.place > b.place:
|
if a.place > b.place:
|
||||||
a, b = b, a
|
a, b = b, a
|
||||||
s_ab = 0.5 if a.place == b.place else 1.0
|
s_ab = 0.5 if a.place == b.place else 1.0
|
||||||
@@ -550,6 +555,7 @@ PROPOSED = Params(
|
|||||||
w_obj=0.5,
|
w_obj=0.5,
|
||||||
w_worlds=0.5,
|
w_worlds=0.5,
|
||||||
closeness=CLOSENESS,
|
closeness=CLOSENESS,
|
||||||
|
skip_eliminated_pairs=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -594,7 +600,7 @@ def examples() -> list[tuple[str, str, dict, dict, Match]]:
|
|||||||
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
("4e", "Разгром: 3-й раунд, 2:0 цели, 8:2 миров",
|
||||||
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
{"A": 1500, "B": 1500}, _vets("A", "B"),
|
||||||
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
Match([Seat("A", 1, 2, 8), Seat("B", 2, 0, 2)], "objectives", round=3)),
|
||||||
("5", "Стол на 4: ничья выбывших, раунд 7",
|
("5", "Стол на 4: двое выбывших, раунд 7",
|
||||||
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
{"A": 1550, "B": 1500, "C": 1480, "D": 1450}, _vets(*"ABCD"),
|
||||||
Match(
|
Match(
|
||||||
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
[Seat("A", 1, 4, 8), Seat("B", 2, 3, 7),
|
||||||
@@ -625,7 +631,7 @@ EXPECTED: dict[str, dict[str, float]] = {
|
|||||||
"4c": {"A": 4.8, "B": -4.8},
|
"4c": {"A": 4.8, "B": -4.8},
|
||||||
"4d": {"A": 3.4, "B": -3.4},
|
"4d": {"A": 3.4, "B": -3.4},
|
||||||
"4e": {"A": 16.0, "B": -16.0},
|
"4e": {"A": 16.0, "B": -16.0},
|
||||||
"5": {"A": 9.27, "B": 5.85, "C": -7.78, "D": -7.35},
|
"5": {"A": 9.27, "B": 5.85, "C": -7.6, "D": -7.53},
|
||||||
"6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0},
|
"6a": {"A": 12.0, "B": 7.2, "C": 2.4, "D": -2.4, "E": -7.2, "F": -12.0},
|
||||||
"6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66},
|
"6b": {"A": 10.29, "B": 7.54, "C": 2.74, "D": -2.06, "E": -6.86, "F": -11.66},
|
||||||
"7": {"A": 32.0, "B": -8.0},
|
"7": {"A": 32.0, "B": -8.0},
|
||||||
|
|||||||
+19
-1
@@ -1,15 +1,33 @@
|
|||||||
import { QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import type { ReactNode } from "react";
|
||||||
import { RouterProvider } from "react-router-dom";
|
import { RouterProvider } from "react-router-dom";
|
||||||
|
|
||||||
import { queryClient } from "./app/queryClient";
|
import { queryClient } from "./app/queryClient";
|
||||||
import { router } from "./app/router";
|
import { router } from "./app/router";
|
||||||
|
import { Spinner } from "./components/Spinner";
|
||||||
import { ToastProvider } from "./context/ToastContext";
|
import { ToastProvider } from "./context/ToastContext";
|
||||||
|
import { setAppTzOffsetHours } from "./domain/format";
|
||||||
|
import { useAuthConfig } from "./hooks/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Пояс приложения (APP_TZ_OFFSET_HOURS) приходит с сервера и нужен до первой отрисовки:
|
||||||
|
* функции format.ts читают его синхронно (#68). Пока конфиг грузится — спиннер; если он
|
||||||
|
* недоступен (нет сети), рендерим с запасным поясом, чтобы не запереть приложение.
|
||||||
|
*/
|
||||||
|
function AppTimeZone({ children }: { children: ReactNode }) {
|
||||||
|
const { data, isPending } = useAuthConfig();
|
||||||
|
if (data) setAppTzOffsetHours(data.tz_offset_hours);
|
||||||
|
if (isPending) return <Spinner />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
return (
|
return (
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<RouterProvider router={router} />
|
<AppTimeZone>
|
||||||
|
<RouterProvider router={router} />
|
||||||
|
</AppTimeZone>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
export const qk = {
|
export const qk = {
|
||||||
me: ["me"] as const,
|
me: ["me"] as const,
|
||||||
adminMe: ["adminMe"] as const,
|
adminMe: ["adminMe"] as const,
|
||||||
@@ -30,10 +32,11 @@ export const qk = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
|
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
|
||||||
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
|
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
|
||||||
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
|
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
|
||||||
* в этом файле тихо разойдётся с местами, где он написан строкой.
|
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
|
||||||
|
* написан строкой.
|
||||||
*/
|
*/
|
||||||
export const matchAffectedKeys = [
|
export const matchAffectedKeys = [
|
||||||
qk.home,
|
qk.home,
|
||||||
@@ -42,3 +45,15 @@ export const matchAffectedKeys = [
|
|||||||
["userMatches"],
|
["userMatches"],
|
||||||
["publicProfile"],
|
["publicProfile"],
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все рейтинговые витрины, включая статистику любой группы: рейтинг игроков в ней
|
||||||
|
* общий, так что его двигает и партия другой группы (#88). Перезапрашиваются только
|
||||||
|
* открытые на экране запросы, остальные лишь помечаются устаревшими.
|
||||||
|
*/
|
||||||
|
export function invalidateRatingViews(qc: QueryClient) {
|
||||||
|
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
||||||
|
qc.invalidateQueries({
|
||||||
|
predicate: (q) => q.queryKey[0] === "group" && q.queryKey[2] === "stats",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Vendored
+2
@@ -1610,6 +1610,8 @@ export interface components {
|
|||||||
methods: string[];
|
methods: string[];
|
||||||
/** Telegram Bot Username */
|
/** Telegram Bot Username */
|
||||||
telegram_bot_username?: string | null;
|
telegram_bot_username?: string | null;
|
||||||
|
/** Tz Offset Hours */
|
||||||
|
tz_offset_hours: number;
|
||||||
};
|
};
|
||||||
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
/** Body_add_attachment_api_matches__match_id__attachments_post */
|
||||||
Body_add_attachment_api_matches__match_id__attachments_post: {
|
Body_add_attachment_api_matches__match_id__attachments_post: {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
|
|||||||
import { useRef } from "react";
|
import { useRef } from "react";
|
||||||
|
|
||||||
import { Avatar } from "./Avatar";
|
import { Avatar } from "./Avatar";
|
||||||
|
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
|
||||||
|
|
||||||
export interface PlacePlayer {
|
export interface PlacePlayer {
|
||||||
user_id: number;
|
user_id: number;
|
||||||
@@ -241,19 +242,11 @@ export function PlaceEditor({
|
|||||||
type="number"
|
type="number"
|
||||||
inputMode="numeric"
|
inputMode="numeric"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="—"
|
placeholder="—"
|
||||||
disabled={locked}
|
disabled={locked}
|
||||||
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
|
||||||
onChange={(e) => {
|
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
|
||||||
const raw = e.target.value.trim();
|
|
||||||
const num = Number(raw);
|
|
||||||
onCount?.(
|
|
||||||
userId,
|
|
||||||
field,
|
|
||||||
raw === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num))),
|
|
||||||
);
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,19 @@
|
|||||||
// Отображаем время в фиксированном поясе +3 (МСК) независимо от пояса браузера.
|
// Время показываем в поясе приложения — одном для всех, независимо от пояса браузера.
|
||||||
const APP_TZ_OFFSET_MIN = 3 * 60;
|
// Смещение задаёт APP_TZ_OFFSET_HOURS на бэкенде (в нём же считается «дата игры»);
|
||||||
|
// App.tsx получает его из /api/auth/config до первой отрисовки страниц (#68).
|
||||||
|
// 3 ч (МСК) — запасное значение, если конфиг недоступен.
|
||||||
|
let appTzOffsetMin = 3 * 60;
|
||||||
|
|
||||||
|
export function setAppTzOffsetHours(hours: number): void {
|
||||||
|
appTzOffsetMin = hours * 60;
|
||||||
|
}
|
||||||
|
|
||||||
const p2 = (n: number) => String(n).padStart(2, "0");
|
const p2 = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
|
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
|
||||||
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
|
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
|
||||||
function shifted(iso: string): Date {
|
function shifted(iso: string): Date {
|
||||||
return new Date(new Date(iso).getTime() + APP_TZ_OFFSET_MIN * 60_000);
|
return new Date(new Date(iso).getTime() + appTzOffsetMin * 60_000);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
// Склонение существительного при числе: plural(3, "игрок", "игрока", "игроков").
|
||||||
@@ -50,18 +57,19 @@ export function formatDate(iso: string | null | undefined): string {
|
|||||||
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Значение <input type="datetime-local"> — «настенное» время в том же поясе +3, что и всё
|
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
|
||||||
// отображение: админ вводит время показа объявления по МСК, где бы ни был его браузер.
|
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
|
||||||
export function toAppLocalInput(iso: string): string {
|
export function toAppLocalInput(iso: string): string {
|
||||||
const d = shifted(iso);
|
const d = shifted(iso);
|
||||||
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Обратно: «настенное» время +3 из datetime-local → момент ISO (UTC). null — поле пустое.
|
// Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
|
||||||
|
// null — поле пустое.
|
||||||
export function fromAppLocalInput(value: string): string | null {
|
export function fromAppLocalInput(value: string): string | null {
|
||||||
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
|
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
|
||||||
if (!m) return null;
|
if (!m) return null;
|
||||||
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - APP_TZ_OFFSET_MIN * 60_000;
|
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
|
||||||
return new Date(ms).toISOString();
|
return new Date(ms).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
/** Верхняя граница целей и миров — отсечка мусора, та же, что в схеме API (Count). */
|
||||||
|
export const MAX_COUNT = 99;
|
||||||
|
|
||||||
|
/** Поле «цели»/«миры» формы: пустое — не указано (null), иначе целое 0..MAX_COUNT. */
|
||||||
|
export function parseCount(raw: string): number | null {
|
||||||
|
const text = raw.trim();
|
||||||
|
const num = Number(text);
|
||||||
|
if (text === "" || !Number.isFinite(num)) return null;
|
||||||
|
return Math.min(MAX_COUNT, Math.max(0, Math.trunc(num)));
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
|
||||||
import { api, unwrap } from "../api/client";
|
import { api, unwrap } from "../api/client";
|
||||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||||
import type {
|
import type {
|
||||||
FactionRead,
|
FactionRead,
|
||||||
MatchCreate,
|
MatchCreate,
|
||||||
@@ -83,8 +83,7 @@ export function useFinishMatch() {
|
|||||||
onSuccess: (m) => {
|
onSuccess: (m) => {
|
||||||
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
qc.invalidateQueries({ queryKey: qk.match(m.id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
invalidateRatingViews(qc);
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -103,9 +102,8 @@ export function useUpdateMatch() {
|
|||||||
onSuccess: (m) => {
|
onSuccess: (m) => {
|
||||||
qc.setQueryData(qk.match(m.id), m);
|
qc.setQueryData(qk.match(m.id), m);
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
|
// Места изменились — значит изменились рейтинги, топ, истории и профили.
|
||||||
// Места изменились — значит изменились лидерборд, история и профили.
|
invalidateRatingViews(qc);
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect } from "react";
|
||||||
|
|
||||||
import { matchAffectedKeys, qk } from "../api/queryKeys";
|
import { invalidateRatingViews, qk } from "../api/queryKeys";
|
||||||
import { useMe } from "./auth";
|
|
||||||
|
|
||||||
interface ServerEvent {
|
interface ServerEvent {
|
||||||
type: "match" | "match_draft" | "group" | "invitations" | "notifications" | "announcements";
|
/** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
|
||||||
|
type:
|
||||||
|
| "match"
|
||||||
|
| "match_draft"
|
||||||
|
| "ratings"
|
||||||
|
| "group"
|
||||||
|
| "invitations"
|
||||||
|
| "notifications"
|
||||||
|
| "announcements";
|
||||||
match_id?: number;
|
match_id?: number;
|
||||||
group_id?: number;
|
group_id?: number;
|
||||||
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
|
|
||||||
participant_ids?: number[];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -19,11 +24,6 @@ interface ServerEvent {
|
|||||||
*/
|
*/
|
||||||
export function useServerEvents(enabled: boolean) {
|
export function useServerEvents(enabled: boolean) {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const { data: me } = useMe();
|
|
||||||
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
|
|
||||||
// SSE-соединение каждый раз, когда профиль перезапрашивается.
|
|
||||||
const myId = useRef<number | null>(null);
|
|
||||||
myId.current = me?.id ?? null;
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!enabled) return;
|
if (!enabled) return;
|
||||||
const base = import.meta.env.VITE_API_BASE_URL || "";
|
const base = import.meta.env.VITE_API_BASE_URL || "";
|
||||||
@@ -50,26 +50,12 @@ export function useServerEvents(enabled: boolean) {
|
|||||||
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
|
||||||
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) });
|
|
||||||
}
|
|
||||||
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
|
||||||
qc.invalidateQueries({ queryKey: qk.home });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.leaderboard });
|
|
||||||
if (ev.participant_ids) {
|
|
||||||
// Личные витрины — только у игравших: иначе каждая партия в группе
|
|
||||||
// заставляла бы всех остальных перезапрашивать свою историю.
|
|
||||||
for (const pid of ev.participant_ids) {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.userMatches(pid) });
|
|
||||||
qc.invalidateQueries({ queryKey: qk.publicProfile(pid) });
|
|
||||||
}
|
|
||||||
if (myId.current != null && ev.participant_ids.includes(myId.current)) {
|
|
||||||
qc.invalidateQueries({ queryKey: qk.myStats });
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Событие от бэкенда без списка участников (вкладка открыта до обновления
|
|
||||||
// сервера) — ведём себя как раньше, широко.
|
|
||||||
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
|
|
||||||
}
|
}
|
||||||
|
// Рейтинг общий и считается по всей истории: партия двигает топ, историю
|
||||||
|
// и профили всех, кто играл после неё, и страницы других групп.
|
||||||
|
invalidateRatingViews(qc);
|
||||||
|
} else if (ev.type === "ratings") {
|
||||||
|
invalidateRatingViews(qc);
|
||||||
} else if (ev.type === "group") {
|
} else if (ev.type === "group") {
|
||||||
if (ev.group_id != null) {
|
if (ev.group_id != null) {
|
||||||
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ export function HelpPage() {
|
|||||||
<p className="small" style={{ marginTop: 10 }}>
|
<p className="small" style={{ marginTop: 10 }}>
|
||||||
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
|
||||||
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
|
||||||
делят последнее место.
|
делят последнее место, но между собой не сравниваются: в этой партии все они
|
||||||
|
проиграли, так что пара двух выбывших рейтинг не двигает.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -93,7 +94,9 @@ export function HelpPage() {
|
|||||||
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
с правилом группы «9 раундов» при 5–6 игроках — 9.
|
||||||
</p>
|
</p>
|
||||||
<p className="small" style={{ marginBottom: 0 }}>
|
<p className="small" style={{ marginBottom: 0 }}>
|
||||||
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
|
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
|
||||||
|
и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются
|
||||||
|
между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -52,6 +52,18 @@ const countDict = (counts: Counts, field: CountField): Record<string, number> =>
|
|||||||
|
|
||||||
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
const survivorsIn = (blocks: number[][]) => blocks.reduce((sum, ids) => sum + ids.length, 0);
|
||||||
|
|
||||||
|
// Места по раскладке: блоки сверху вниз (competition ranking — ничья съедает следующие
|
||||||
|
// места), затем выбывшие с общим последним местом.
|
||||||
|
const placeRows = (blocks: number[][], eliminated: number[]) => {
|
||||||
|
let place = 1;
|
||||||
|
const survivors = blocks.flatMap((ids) => {
|
||||||
|
const rows = ids.map((uid) => ({ uid, place, eliminated: false }));
|
||||||
|
place += ids.length;
|
||||||
|
return rows;
|
||||||
|
});
|
||||||
|
return [...survivors, ...eliminated.map((uid) => ({ uid, place, eliminated: true }))];
|
||||||
|
};
|
||||||
|
|
||||||
export function MatchDetailPage() {
|
export function MatchDetailPage() {
|
||||||
const { matchId } = useParams();
|
const { matchId } = useParams();
|
||||||
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
|
||||||
@@ -193,21 +205,14 @@ export function MatchDetailPage() {
|
|||||||
|
|
||||||
const outcomeSeats = (): OutcomeSeat[] => {
|
const outcomeSeats = (): OutcomeSeat[] => {
|
||||||
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
const byId = new Map(match.participants.map((p) => [p.user_id, p]));
|
||||||
const seat = (uid: number, place: number, eliminated: boolean): OutcomeSeat => ({
|
return placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||||
userId: uid,
|
userId: uid,
|
||||||
nickname: byId.get(uid)?.nickname ?? "",
|
nickname: byId.get(uid)?.nickname ?? "",
|
||||||
place,
|
place,
|
||||||
eliminated,
|
eliminated,
|
||||||
objectives: finishCounts[uid]?.objectives ?? null,
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
worlds: eliminated ? 0 : (finishCounts[uid]?.worlds ?? null),
|
||||||
});
|
}));
|
||||||
let place = 1;
|
|
||||||
const rows = finishBlocks.flatMap((ids) => {
|
|
||||||
const out = ids.map((uid) => seat(uid, place, false));
|
|
||||||
place += ids.length;
|
|
||||||
return out;
|
|
||||||
});
|
|
||||||
return [...rows, ...elim.map((uid) => seat(uid, place, true))];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const warnings = () =>
|
const warnings = () =>
|
||||||
@@ -218,35 +223,16 @@ export function MatchDetailPage() {
|
|||||||
maxRounds: match.max_rounds,
|
maxRounds: match.max_rounds,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
|
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
|
||||||
const resultRows = () => {
|
const resultRows = () =>
|
||||||
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
|
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
|
||||||
const countsFor = (uid: number, eliminated: boolean) => ({
|
|
||||||
objectives: finishCounts[uid]?.objectives ?? null,
|
|
||||||
// У выбывшего миров нет — сервер сам запишет 0.
|
|
||||||
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
|
||||||
});
|
|
||||||
let place = 1;
|
|
||||||
const survivors = finishBlocks.flatMap((ids) => {
|
|
||||||
const rows = ids.map((uid) => ({
|
|
||||||
user_id: uid,
|
|
||||||
place,
|
|
||||||
eliminated: false,
|
|
||||||
comment: commentOf(uid),
|
|
||||||
...countsFor(uid, false),
|
|
||||||
}));
|
|
||||||
place += ids.length; // competition ranking: ничья съедает следующие места
|
|
||||||
return rows;
|
|
||||||
});
|
|
||||||
const eliminated = elim.map((uid) => ({
|
|
||||||
user_id: uid,
|
user_id: uid,
|
||||||
place: null,
|
place: eliminated ? null : place,
|
||||||
eliminated: true,
|
eliminated,
|
||||||
comment: commentOf(uid),
|
comment: (finishComments[uid] ?? "").trim() || null,
|
||||||
...countsFor(uid, true),
|
objectives: finishCounts[uid]?.objectives ?? null,
|
||||||
|
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
|
||||||
}));
|
}));
|
||||||
return [...survivors, ...eliminated];
|
|
||||||
};
|
|
||||||
|
|
||||||
const submitFinish = async () => {
|
const submitFinish = async () => {
|
||||||
if (!id || !match) return;
|
if (!id || !match) return;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { MatchMedia } from "../../components/MatchMedia";
|
|||||||
import { Spinner } from "../../components/Spinner";
|
import { Spinner } from "../../components/Spinner";
|
||||||
import { Switch } from "../../components/Switch";
|
import { Switch } from "../../components/Switch";
|
||||||
import { finishWarnings } from "../../domain/finishWarnings";
|
import { finishWarnings } from "../../domain/finishWarnings";
|
||||||
|
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
|
||||||
import {
|
import {
|
||||||
LAST_STANDING,
|
LAST_STANDING,
|
||||||
reasonForSurvivors,
|
reasonForSurvivors,
|
||||||
@@ -34,12 +35,6 @@ interface Row {
|
|||||||
worlds: number | null;
|
worlds: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Пустое поле — «не указано» (null); иначе целое 0..99.
|
|
||||||
const parseCount = (raw: string): number | null => {
|
|
||||||
const num = Number(raw.trim());
|
|
||||||
return raw.trim() === "" || !Number.isFinite(num) ? null : Math.min(99, Math.max(0, Math.trunc(num)));
|
|
||||||
};
|
|
||||||
|
|
||||||
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose: () => void }) {
|
||||||
const { data: match, isLoading } = useAdminMatch(matchId);
|
const { data: match, isLoading } = useAdminMatch(matchId);
|
||||||
const { data: factions } = useAdminFactions();
|
const { data: factions } = useAdminFactions();
|
||||||
@@ -218,7 +213,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="цели"
|
placeholder="цели"
|
||||||
title="Цели на конец партии"
|
title="Цели на конец партии"
|
||||||
value={r.objectives ?? ""}
|
value={r.objectives ?? ""}
|
||||||
@@ -228,7 +223,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
|
|||||||
style={{ flex: "0 0 70px" }}
|
style={{ flex: "0 0 70px" }}
|
||||||
type="number"
|
type="number"
|
||||||
min={0}
|
min={0}
|
||||||
max={99}
|
max={MAX_COUNT}
|
||||||
placeholder="миры"
|
placeholder="миры"
|
||||||
title="Миры на конец партии"
|
title="Миры на конец партии"
|
||||||
disabled={r.eliminated}
|
disabled={r.eliminated}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
[33mcommit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6[m[33m ([m[1;36mHEAD[m[33m -> [m[1;32mdev[m[33m)[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Wed Jun 17 04:45:33 2026 +0300
|
|
||||||
|
|
||||||
Добавление ssh-ключа к контейнеру tunnel
|
|
||||||
|
|
||||||
[33mcommit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Wed Jun 17 04:27:11 2026 +0300
|
|
||||||
|
|
||||||
Правки локального размещения дева, перенос тунеля в контейнер
|
|
||||||
|
|
||||||
[33mcommit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 19:15:54 2026 +0300
|
|
||||||
|
|
||||||
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
|
|
||||||
|
|
||||||
[33mcommit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f[m[33m ([m[1;31morigin/main[m[33m, [m[1;32mmain[m[33m)[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 17:41:06 2026 +0300
|
|
||||||
|
|
||||||
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
|
|
||||||
|
|
||||||
[33mcommit 6ab74f01aaf1ac042a269c7660b3e564b93350c7[m
|
|
||||||
Author: NotBigGhost <ivan@arseniev.info>
|
|
||||||
Date: Tue Jun 16 16:53:41 2026 +0300
|
|
||||||
|
|
||||||
first commit
|
|
||||||
@@ -128,11 +128,14 @@ switch ($appEnv) {
|
|||||||
if ($localPublic -eq "vps") {
|
if ($localPublic -eq "vps") {
|
||||||
Start-VpsTunnel 5173
|
Start-VpsTunnel 5173
|
||||||
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
Write-Host " Public: https://forbidden-stars.ru" -ForegroundColor Green
|
||||||
|
Write-Host " WARNING: dev is public. Anyone can log in by nickname without a password," -ForegroundColor Yellow
|
||||||
|
Write-Host " list/create players, hard-delete accounts and read Swagger." -ForegroundColor Yellow
|
||||||
|
Write-Host " Do not keep a copy of production data in the dev database." -ForegroundColor Yellow
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"production" {
|
"production" {
|
||||||
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
Write-Host "production is not started by the launcher - prod is separate." -ForegroundColor Yellow
|
||||||
Write-Host "Deploy on Pi (from main branch): docker compose up -d --build"
|
Write-Host "Deploy: PC (main branch) scripts\build-push.ps1, then on Pi: docker compose up -d"
|
||||||
exit 1
|
exit 1
|
||||||
}
|
}
|
||||||
default {
|
default {
|
||||||
|
|||||||
@@ -75,12 +75,17 @@ case "$app_env" in
|
|||||||
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
trap 'kill "$back" 2>/dev/null || true' EXIT INT TERM
|
||||||
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
echo " Бэк: http://127.0.0.1:8000 (Swagger: /api/docs)"
|
||||||
echo " Фронт: http://127.0.0.1:5173"
|
echo " Фронт: http://127.0.0.1:5173"
|
||||||
[ "$local_public" = "vps" ] && start_tunnel 5173
|
if [ "$local_public" = "vps" ]; then
|
||||||
|
start_tunnel 5173
|
||||||
|
echo " ВНИМАНИЕ: dev опубликован. Любой посетитель может войти по нику без пароля,"
|
||||||
|
echo " смотреть и создавать игроков, жёстко удалять аккаунты и читать Swagger."
|
||||||
|
echo " Не держите в dev-базе копию прод-данных."
|
||||||
|
fi
|
||||||
( cd "$root/frontend" && npm run dev )
|
( cd "$root/frontend" && npm run dev )
|
||||||
;;
|
;;
|
||||||
production)
|
production)
|
||||||
echo "production лаунчером не запускается — прод обособлен."
|
echo "production лаунчером не запускается — прод обособлен."
|
||||||
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
|
echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
|
||||||
exit 1
|
exit 1
|
||||||
;;
|
;;
|
||||||
*)
|
*)
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# Выгрузка ПРОДА: в целевую папку попадают только файлы, нужные для запуска
|
|
||||||
# прод-контейнера (без тестов, dev-входа и dev-лаунчера).
|
|
||||||
#
|
|
||||||
# Использование: scripts/export-prod.sh <целевая-папка> [git-ref]
|
|
||||||
# git-ref по умолчанию HEAD; для прод-ветки: scripts/export-prod.sh /srv/fs prod
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
DEST="${1:?Укажите целевую папку: scripts/export-prod.sh <dir> [ref]}"
|
|
||||||
REF="${2:-HEAD}"
|
|
||||||
|
|
||||||
mkdir -p "$DEST"
|
|
||||||
# git archive уважает export-ignore из .gitattributes (тесты, dev-вход и т.п. отсеяны)
|
|
||||||
git archive --format=tar "$REF" | tar -x -C "$DEST"
|
|
||||||
|
|
||||||
# dev-специфика в проде не нужна (лаунчер)
|
|
||||||
rm -f "$DEST/run.ps1" "$DEST/run.sh"
|
|
||||||
|
|
||||||
echo "[export-prod] Прод выгружен в: $DEST"
|
|
||||||
echo " дальше: cp .env.example .env && docker compose up -d --build"
|
|
||||||
Reference in New Issue
Block a user