47 Commits
Author SHA1 Message Date
NotBigGhost f0a5cf4dff v1.5 — Обновление системы рейтинга, переход на elo с модифицирующими коэффициентами. 2026-09-30 13:51:44 +03:00
NotBigGhost 0b33b442e9 Merge pull request 'Устаревшие комментарии и подсказки в коде, export-скрипты; мусорный файл в репозитории (#70)' (#99) from issue-70-stale-comments into dev
Reviewed-on: #99
2026-09-30 13:05:53 +03:00
NotBigGhost d12afe62d0 Merge pull request 'Защита бэкапов от пустой БД пропускает БД без таблиц (счётчики «?») (#74)' (#100) from issue-74-backup-guard into dev
Reviewed-on: #100
2026-09-30 13:04:37 +03:00
NotBigGhost 30270916a4 Merge pull request 'Часовой пояс отображения захардкожен на фронте и не зависит от APP_TZ_OFFSET_HOURS (#68)' (#98) from issue-68-tz-offset into dev
Reviewed-on: #98
2026-09-30 13:03:39 +03:00
NotBigGhost 2f0b22ccd4 Merge pull request 'Уведомление о бэкапах в тг боте (#83)' (#97) from issue-83-backup-telegram into dev
Reviewed-on: #97
2026-09-30 13:02:05 +03:00
NotBigGhost 89cbb81be7 Merge pull request 'Баг ограничения ресурсов пая (#67)' (#96) from issue-67-pi-memory-cgroup into dev
Reviewed-on: #96
2026-09-30 13:00:43 +03:00
NotBigGhostandClaude Opus 5 f5f8dc03ce Бэкапы: БД без таблиц не становится снимком, первый бэкап ждёт приложение
guard_empty считал неизвестные счётчики («?» — нет таблиц users/matches)
непустыми, и снимок без данных уходил в оба репозитория и становился latest —
restore latest в сценарии «Pi умер» падал на verify_staging. Теперь это отказ,
как для пустой БД (обход — --allow-empty).

entrypoint перед самым первым бэкапом ждёт /api/health приложения (до
BACKUP_APP_WAIT_SECONDS, 600 с): оно отвечает только после миграций. Не дождались —
пробуем всё равно, guard не пропустит БД без таблиц. README, раздел 9: новые
строки журнала, снимки с «?» больше не создаются. #74

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:15:49 +03:00
NotBigGhostandClaude Opus 5 ec1e94119c Чистка: устаревшие комментарии и подсказки, export-скрипт, мусорный файл
Комментарии про методы входа приведены к факту (логин/пароль и Telegram везде,
stub только в development): config.py, .env.example, telegram.py, dev_stub.py,
dev_auth.py, login.py. admin_login.py больше не обещает «строже игроцкого» —
описаны реальные лимиты; client_ip — адрес за прокси уже даёт uvicorn.

Подсказки деплоя: run.ps1/run.sh (build-push на ПК, `up -d` на Pi), compose,
.env.example (build-push.ps1), Caddyfile (туннель-контейнер вместо autossh);
ratelimit.py ссылается на core/events.py вместо CLAUDE.md.

Удалены неиспользуемые scripts/export-prod.sh (с блоком export-ignore в
.gitattributes и упоминаниями в README), db/init_db.py и случайно закоммиченный
файл «h -u origin dev…». #70

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:12:16 +03:00
NotBigGhostandClaude Opus 5 8d4f7a95df Время: один пояс приложения из .env и на сервере, и на фронте
APP_TZ_OFFSET_HOURS влиял только на «дату игры», а фронт показывал время в
захардкоженных +3 — при другом значении дата и время партии противоречили друг
другу. Теперь /api/auth/config отдаёт tz_offset_hours, App.tsx выставляет его в
format.ts до первой отрисовки страниц, и в нём же показывается всё время и
вводятся даты объявлений (запасное значение +3, если конфиг недоступен). Пояс
устройства не учитывается — решение владельца. Валидатор ограничивает смещение
−12..14. #68

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 03:05:30 +03:00
NotBigGhostandClaude Opus 5 5b89dc9d2f Бэкапы: отчёты и команды в Telegram-боте
Каждый run и verify шлёт в чаты BACKUP_TELEGRAM_CHAT_ID одно сообщение: успех
со сводкой (игроки/партии, по репозиторию снимок, число снимков, размер) или
сбой с причиной — в том числе ранний отказ (нет пароля, занят lock, битая БД):
обработчик EXIT берёт текст последнего die. Недоступный Telegram бэкап не ломает.

Бот-слушатель (fs-backup telegram bot, long polling, стартует из entrypoint в
фоне) отвечает на /backups (хранящиеся снимки), /status и /help только чатам из
белого списка; чужие chat id пишет в журнал. telegram chats — узнать свой id,
telegram test — проверить связь. Токен по умолчанию — TELEGRAM_BOT_TOKEN
приложения (тот же бот), BACKUP_TELEGRAM_BOT_TOKEN переопределяет. В образ
добавлен curl. Настройка — deploy/backup/README.md, раздел 10. #83

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 02:38:49 +03:00
NotBigGhostandClaude Opus 5 00bd3c72b6 Деплой: как включить memory cgroup на Pi, иначе mem_limit не действует
На Pi при каждом `docker compose up` контейнеры пишут «Your kernel does not
support memory limit capabilities…». Причина — прошивка Raspberry Pi добавляет
ядру cgroup_disable=memory: контроллера memory нет, и Docker молча игнорирует
mem_limit (app 512m, tunnel 64m, backup 384m).

deploy/pi/README.md: проверка, дописывание cgroup_enable=memory в cmdline.txt с
бэкапом, перезагрузка и контроль; строка в «Если что-то не так». Пояснения
у mem_limit в compose и в .env.example. #67

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 02:21:19 +03:00
NotBigGhost f7f74fee8c Merge pull request 'Dev-база и загрузки попадают в Docker-образ: .dockerignore не исключает backend/data/ (#71)' (#93) from issue-71-dockerignore-data into dev
Reviewed-on: #93
2026-09-19 00:11:09 +03:00
NotBigGhost 643a9f1795 Merge pull request 'Переименование фракции в админке откатывается при каждом перезапуске приложения (#72)' (#94) from issue-72-faction-rename into dev
Reviewed-on: #94
2026-09-19 00:10:56 +03:00
NotBigGhost 709b864c60 Merge pull request 'В test/prod нет штатного способа сменить пароль администратора (#73)' (#95) from issue-73-admin-password into dev
Reviewed-on: #95
2026-09-19 00:10:36 +03:00
NotBigGhost 03834c6855 Merge pull request 'Dev, выставленный на forbidden-stars.ru (LOCAL_PUBLIC=vps), публичен с stub-входом и без проверки секретов (#69)' (#92) from issue-69-public-dev into dev
Reviewed-on: #92
2026-09-19 00:10:19 +03:00
NotBigGhostandClaude Opus 5 0ee7cd1274 Безопасность: команда ротации пароля администратора из .env
В prod пароль админа задавался только при первом создании: обычный bootstrap
его из .env не берёт, панель не меняет — комментарии отсылали друг к другу.
Теперь `python -m app.bootstrap --reset-admin-password` применяет ADMIN_PASSWORD
к существующему админу через user_service.set_password: пароль проверяется,
token_version растёт, и все админские сессии (в том числе чужие) отзываются.
Обычный старт по-прежнему пароль не трогает.

Процедура на Pi — в deploy/pi/README.md (правка .env → docker compose up -d →
exec команды), кратко — в README. #73

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 00:08:04 +03:00
NotBigGhostandClaude Opus 5 1bc3940ee8 Справочники: сидинг не откатывает имя фракции, заданное админом
seed_reference_data идёт при каждом старте (entrypoint.sh, lifespan) и
перезаписывал name_ru существующих фракций значением из кода — переименование
из админки жило до ближайшего рестарта. Теперь имя из кода получает только
новая фракция; дополнение и порядок синхронизируются как раньше. #72

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-19 00:01:19 +03:00
NotBigGhostandClaude Opus 5 164db495b4 Безопасность: dev-БД и загрузки не попадают в Docker-образ
Шаблоны .dockerignore отсчитываются от корня контекста: `data/` ловил только
корневую папку, `backend/*.db` — только файлы прямо в backend/. Dockerfile
копирует backend/ целиком, и в каждый прод-образ уходили backend/data/dev/
(dev-БД с хешами паролей и аудитом, uploads) и egg-info от pip install -e.

Теперь исключены backend/data/, любые *.db/-wal/-shm, *.egg-info, .env в любой
папке и backups/ (копии прод-БД не уходят и в контекст сборки). README больше
не говорит о дыре. #71

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:55:12 +03:00
NotBigGhostandClaude Opus 5 cff48f7cc7 Безопасность: опубликованный dev не стартует с дефолтными секретами
При LOCAL_PUBLIC=vps dev доступен на forbidden-stars.ru, а fail-fast по
SECRET_KEY/ADMIN_PASSWORD работал только в production: снаружи оставались
общеизвестный ключ JWT (подделка любого токена, включая админский) и пароль
админки. Теперь проверка срабатывает при is_published — у прода и у dev на
домене; на нём же cookie_secure.

Dev-инструменты и Swagger на опубликованном dev остаются (решение владельца):
лаунчеры и лог старта перечисляют, что открыто любому посетителю. В .env.example —
что открывает vps и что у dev и prod должны быть разные SECRET_KEY. #69

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:52:25 +03:00
NotBigGhost bb5f2a1121 Merge pull request 'Ревью кода (#82)' (#90) from issue-82-code-review into dev
Reviewed-on: #90
2026-09-18 23:42:36 +03:00
NotBigGhostandClaude Opus 5 63a2cddd5e Рейтинг: выбывшие между собой не сравниваются
Решение владельца: в партии все выбывшие проиграли и одинаково слабы, миров у них
нет, поэтому пара двух выбывших в сумму не входит — ни S − E, ни множитель отрыва.
Пары с невыбывшими считаются как прежде, нормировка на N − 1 тоже: при равных
рейтингах результат не меняется. Слабый выбывший больше не получает рейтинг за счёт
сильных выбывших.

Эталон — флаг skip_eliminated_pairs у предложенной системы; документ: 4.3, 4.4,
4.9, пример 5, итоги 7.3 пересчитаны (сдвиг в третьем знаке), решение 8 в разделе 9;
справка. #91

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:41:29 +03:00
NotBigGhostandClaude Opus 5 5035ee41e6 События: общий рейтинг обновляется у всех, а не только в группе партии
Рейтинг с #80 один на всё приложение: завершённая или удалённая партия двигает
топ, историю и профили всех, кто играл после неё, и страницы других групп.
Событие match по-прежнему идёт группе партии, остальным активным игрокам —
ratings без подробностей о партии. Фронт по обоим сбрасывает все рейтинговые
витрины (invalidateRatingViews); participant_ids больше не нужен. #88

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:29:37 +03:00
NotBigGhostandClaude Opus 5 6c35cb04d4 Итоги партии: общий разбор целей/миров и один расчёт мест
parseCount и граница 99 — в domain/matchCounts.ts вместо двух копий в PlaceEditor
и AdminMatchEdit. В MatchDetailPage места по раскладке считает placeRows, из него
строятся и предупреждения, и строки для API. Поведение не меняется. #89

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:23:02 +03:00
NotBigGhostandClaude Opus 5 63f541d61f Рейтинг: монотонность — только без ничьей, в справке и документе
Справка и краткое изложение документа обещали, что победитель не теряет рейтинг,
а последнее место его не приносит. Внутри общего места (ничья за 1-е, несколько
выбывших) это не так — раздел 4.9 уже говорил «без ничьей». #87

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
2026-09-18 23:21:31 +03:00
NotBigGhost 551032d66e Merge pull request 'Система объявлений в main без рейтинга (#84)' (#86) from issue-84-announcements-main into main
Reviewed-on: #86
2026-09-18 22:06:49 +03:00
NotBigGhost 7829a49e9d Merge pull request 'Создание системы объявлений (#84)' (#85) from issue-84-announcements into dev
Reviewed-on: #85
2026-09-18 22:06:42 +03:00
NotBigGhostandClaude Opus 5 d31e998f87 Объявления: окно у игрока и редактор в админке
Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди
и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу
уходит из кэша, следующее открывается без ожидания сети. У повторно
показанного объявления под заголовком — красная пометка «обновлено».
AppShell показывает окно на любой странице и только когда пароль задан:
обязательное окно пароля всегда первое. Новые и изменённые объявления
приходят SSE-событием announcements, отложенное начало показа — перезапросом
раз в 5 минут.

Админка — вкладка «Объявления» с редактором R1: contenteditable и панель
(жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления),
вставка и перетаскивание только простым текстом, счётчик символов, период
показа по МСК, переключатели «показывать новым игрокам» и «показать заново».
Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком
«закрыли N из M» и действиями: изменить, снять с показа, дублировать,
удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего.

schema.d.ts пересобран из OpenAPI. #84

Перенесено в main без рейтинга из ab1fc3f; schema.d.ts сверен с OpenAPI этой ветки.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:59:32 +03:00
NotBigGhostandClaude Opus 5 976b8fc622 Объявления: модель, API игрока и админки, очистка HTML
Объявление администрации показывается игроку окном в свой период, пока игрок
не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером
версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю
увидят объявление снова — ответ помечен updated («обновлено»). Флаг
show_to_new_players=false прячет объявление от зарегистрировавшихся после
начала показа. Пересекающиеся объявления идут от старого к новому.

Текст приходит HTML-ом из редактора админки и сохраняется только после
очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты
отбрасываются, script/style/svg — вместе с содержимым, текст экранируется
заново. Фронт вставляет только этот HTML.

API: GET /api/announcements/pending, POST /api/announcements/{id}/ack;
админка — список со статусом и счётчиком «закрыли N из M», создание, правка,
«снять с показа», удаление, всё в аудит. SSE-событие announcements активным
игрокам. Миграция 0015 идемпотентная.

Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок,
«новые игроки», повторный показ, снятие, удаление, валидация. #84

Перенесено в main без рейтинга из a0a0e52; миграция 0015 — сразу от 0013
(как в ec0445f).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:57:46 +03:00
NotBigGhostandClaude Opus 5 07a14ecc9b Гитигнор: локальный AGENTS.md не идёт в репозиторий
AGENTS.md — такой же локальный гайд для ассистента, как CLAUDE.md, только
для другого инструмента. Он лежал в рабочем дереве незакоммиченным и висел
в git status; теперь он в блоке «AI-ассистенты» рядом с CLAUDE.md
и .claude/, так что случайный `git add .` его не подхватит. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:55:53 +03:00
NotBigGhostandClaude Opus 5 ec0445fbcb Миграции: объявления отходят от 0013, слияние с рейтингом в 0016
Объявления выпускаются в main раньше рейтинга, а в main последняя миграция —
0013. Если бы 0015 оставалась после 0014, прод, получивший объявления,
встал бы на 0015, и при будущем переходе на dev alembic счёл бы базу
актуальной — рейтинговая 0014 не применилась бы никогда.

Теперь 0015 отходит от 0013 (как и 0014), а пустая 0016 сводит обе ветки
в одну голову: прод на 0015 докатит 0014 и 0016, база на 0014 — 0015 и 0016.
Базы, уже прогнанные прежней версией (0015 после 0014), повторно получат
идемпотентную 0014 — проверено на копии dev-БД, на пустой базе и на такой
базе. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:55:43 +03:00
NotBigGhostandClaude Opus 5 ab1fc3f00f Объявления: окно у игрока и редактор в админке
Окно — вариант E из макетов: заголовок в плашке-шапке, текст, точки очереди
и «Понятно», крестика нет. Очередь идёт от старого к новому; закрытое сразу
уходит из кэша, следующее открывается без ожидания сети. У повторно
показанного объявления под заголовком — красная пометка «обновлено».
AppShell показывает окно на любой странице и только когда пароль задан:
обязательное окно пароля всегда первое. Новые и изменённые объявления
приходят SSE-событием announcements, отложенное начало показа — перезапросом
раз в 5 минут.

Админка — вкладка «Объявления» с редактором R1: contenteditable и панель
(жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления),
вставка и перетаскивание только простым текстом, счётчик символов, период
показа по МСК, переключатели «показывать новым игрокам» и «показать заново».
Предпросмотр — тем же окном, что у игрока. Список со статусом, счётчиком
«закрыли N из M» и действиями: изменить, снять с показа, дублировать,
удалить. Выделение текста в форме — полупрозрачный оранжевый вместо синего.

schema.d.ts пересобран из OpenAPI. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:26:38 +03:00
NotBigGhostandClaude Opus 5 a0a0e522ef Объявления: модель, API игрока и админки, очистка HTML
Объявление администрации показывается игроку окном в свой период, пока игрок
не закроет его («Понятно»). Отметка о закрытии хранится на сервере с номером
версии: правка с «показать заново» поднимает версию, и закрывшие прежнюю
увидят объявление снова — ответ помечен updated («обновлено»). Флаг
show_to_new_players=false прячет объявление от зарегистрировавшихся после
начала показа. Пересекающиеся объявления идут от старого к новому.

Текст приходит HTML-ом из редактора админки и сохраняется только после
очистки по белому списку (b, em, mark и mark.red, p, br): атрибуты
отбрасываются, script/style/svg — вместе с содержимым, текст экранируется
заново. Фронт вставляет только этот HTML.

API: GET /api/announcements/pending, POST /api/announcements/{id}/ack;
админка — список со статусом и счётчиком «закрыли N из M», создание, правка,
«снять с показа», удаление, всё в аудит. SSE-событие announcements активным
игрокам. Миграция 0015 идемпотентная.

Тесты: очистка (XSS-попытки, вложенные div), права, период и порядок,
«новые игроки», повторный показ, снятие, удаление, валидация. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 21:20:55 +03:00
NotBigGhostandClaude Opus 5 2e496a3f21 Гитигнор: локальный AGENTS.md не идёт в репозиторий
AGENTS.md — такой же локальный гайд для ассистента, как CLAUDE.md, только
для другого инструмента. Он лежал в рабочем дереве незакоммиченным и висел
в git status; теперь он в блоке «AI-ассистенты» рядом с CLAUDE.md
и .claude/, так что случайный `git add .` его не подхватит. #84

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 02:51:15 +03:00
NotBigGhost 9d77971b0f v1.4 — Настрйока бэкапа БД и хранимых медиа. Третьим контейнером с restic. Бэкап на VPS 2026-09-14 17:55:49 +03:00
NotBigGhost d3ecbca731 v1.37 — аутентификация через логин и пароль 2026-09-13 18:48:46 +03:00
NotBigGhost a3984638c3 v1.351 — пересмотр логики выдачи CSRF-токена 2026-09-13 13:17:37 +03:00
NotBigGhost 96c32d99c5 v1.35 — полировка мелких ошибок вёрстки, оптимизация сетевого кода, унификация и доработка элементов интерфейса 2026-09-13 12:31:04 +03:00
NotBigGhost 30e9669361 v1.3 — Глобальное изменение интерфейса завершения партии. Для выбора места теперь используется интуитивное перетаскивание тайлов 2026-07-19 15:31:30 +03:00
NotBigGhost 867b1f868c v1.21 — Полировка выпадающих списков в меню начала партии 2026-07-18 03:48:36 +03:00
NotBigGhost 74b9b6698f v1.2 — Новая логика подсчёта и отображения рейтинга (Байесовская оценка решения) 2026-07-17 03:12:46 +03:00
NotBigGhost 05e1f79c25 v1.11 — QoL улучшения интерфейса, унификация его эллементов 2026-07-17 02:46:13 +03:00
NotBigGhost 98cd82f045 v1.1 - Пересмотр деплоя прода 2026-06-29 22:26:26 +03:00
NotBigGhost e44a62f9cc v1.0 - Финальная проверка изоляции продакшна. Настройка бэкапа на VPS, подготовка образа 2026-06-27 01:58:34 +03:00
NotBigGhost 9f599f18d7 v0.9 - Справка и надёжная офлайн-страница
- Справка: новая страница /help с описанием подсчёта очков игроков (формула League
  Points, ничьи, выбывшие = последнее место, порог 3 партии для топа); кнопка
  «Справка» добавлена в боковое меню. Задел под будущие правила и подсказки.
- index.html больше не кэшируется (no-store на Caddy и в FastAPI): при недоступности
  хоста надёжно показывается страница-заглушка вместо зависшей «Загрузки».
2026-06-26 23:15:06 +03:00
NotBigGhost 90d0eb2546 v0.8 - Пересмотр элементов интерфейса, улучшены навигация по приложению, улучшена читаемость некоторых эллементов, добавлены анимации 2026-06-26 18:19:13 +03:00
NotBigGhost 64ac0efb36 v0.7 - Реализованы уведомления 2026-06-26 15:52:15 +03:00
NotBigGhost 5fad9dbea4 v0.6 - Настройка SSE-соединения для пуша данных с сервера 2026-06-17 23:22:09 +03:00
71 changed files with 3251 additions and 375 deletions
+14 -5
View File
@@ -1,13 +1,23 @@
# Шаблоны отсчитываются от корня контекста: без «**/» правило ловит только корень
# (так `data/` пропускал backend/data/dev/ с dev-БД и загрузками в образ, #71).
# Python
**/__pycache__/
**/*.pyc
**/*.egg-info/
backend/.venv/
backend/.pytest_cache/
backend/*.db
backend/*.db-wal
backend/*.db-shm
backend/openapi.json
# Данные: dev-БД, загрузки и ачивки дева (backend/data/dev/), любые файлы SQLite и
# локальные копии бэкапов — ни в образ, ни в контекст сборки
backend/data/
data/
backups/
**/*.db
**/*.db-wal
**/*.db-shm
# Node / сборка фронта
frontend/node_modules/
frontend/dist/
@@ -23,8 +33,7 @@ backend/tests/
backend/pyproject.toml
# Прочее
.env
data/
**/.env
**/.DS_Store
*.md
.claude/
+18 -6
View File
@@ -6,7 +6,7 @@
# ─── ГЛАВНЫЙ ПЕРЕКЛЮЧАТЕЛЬ ────────────────────────────────────────────────────
# Этот параметр читает ЛАУНЧЕР (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.
APP_ENV=development
@@ -14,6 +14,9 @@ APP_ENV=development
# ─── ПУБЛИКАЦИЯ ЧЕРЕЗ ДОМЕН (VPS-туннель) ─────────────────────────────────────
# LOCAL_PUBLIC — только для DEV на твоём ПК: local = приложение лишь на localhost;
# vps = лаунчер (run.ps1) дополнительно поднимает SSH-туннель → дев на forbidden-stars.ru.
# Тогда любому посетителю домена открыты dev-инструменты: вход по нику без пароля,
# список/создание игроков, жёсткое удаление аккаунтов, Swagger. Дефолтные SECRET_KEY
# и ADMIN_PASSWORD при vps не дают стартовать — задайте свои (#69).
# PROD выставляет себя сам через туннель-КОНТЕЙНЕР (docker-compose*.yml) — ему
# LOCAL_PUBLIC не нужен, но VPS_TUNNEL_HOST/USER ниже он тоже читает.
LOCAL_PUBLIC=local
@@ -41,8 +44,8 @@ ADMIN_NICKNAME=Администратор
ADMIN_BOOTSTRAP_ENABLED=true
# ─── АУТЕНТИФИКАЦИЯ ИГРОКОВ ───────────────────────────────────────────────────
# Методы входа задаёт APP_ENV: dev → Telegram + stub (вход по нику), prod → только
# Telegram. Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
# Вход везде — логин/пароль и Telegram; в development ещё stub (по нику без пароля).
# Для Telegram нужны токен и юзернейм бота (@BotFather). /setdomain у
# BotFather укажи на ОБА домена, где открывается виджет: forbiddenstars.ru (prod)
# и forbidden-stars.ru (dev).
TELEGRAM_BOT_TOKEN=
@@ -54,6 +57,7 @@ PUBLIC_BASE_URL=
# Сгенерировать: python -c "import secrets;print(secrets.token_urlsafe(48))"
# ВАЖНО: в секретах НЕ используйте символ '$' — docker compose трактует его как
# подстановку переменной (token_urlsafe даёт только [A-Za-z0-9_-], это безопасно).
# У dev и prod ключи должны быть РАЗНЫМИ: иначе токен, подписанный на dev, примет прод.
SECRET_KEY=change-me-dev-secret-not-for-production
JWT_ALGORITHM=HS256
JWT_USER_TTL_MINUTES=10080
@@ -77,7 +81,7 @@ DEV_ACHIEVEMENTS_DIR=./data/dev/achievements
PROD_ACHIEVEMENTS_DIR=/data/achievements
# ─── ОБРАЗЫ ПРОДА (реестр для docker compose pull на Pi) ──────────────────────
# Образы собираются под arm64 на ПК (scripts/build-push.sh) и пушатся в Gitea-реестр,
# Образы собираются под arm64 на ПК (scripts/build-push.ps1; в Linux — .sh) и пушатся в Gitea-реестр,
# а Pi их тянет (docker compose pull). Owner в пути — строчными. Тег можно версионировать.
# Перед пушем/пуллом: docker login gitea.arseniev.info
IMAGE_REGISTRY=gitea.arseniev.info/notbigghost
@@ -85,7 +89,8 @@ IMAGE_TAG=latest
# ─── РЕСУРСЫ ПРОД-КОНТЕЙНЕРА (docker-compose.yml) ─────────────────────────────
# Лимиты под 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_CPUS=1.5
@@ -119,13 +124,20 @@ BACKUP_VPS_DIR=/srv/fs-backups/restic
# в deploy/backup/id_backup (в git НЕ идёт), pubkey — в authorized_keys у fsbackup@VPS.
# PowerShell: [Convert]::ToBase64String([IO.File]::ReadAllBytes((Resolve-Path "deploy\backup\id_backup")))
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 и где там лежит
# docker-compose.yml прода.
BACKUP_PI_SSH=pi@192.168.1.10
BACKUP_PI_DIR=~/forbidden-stars
# ─── ПРОЧЕЕ ───────────────────────────────────────────────────────────────────
# Часовой пояс приложения (фикс. смещение в часах; МСК = 3)
# Часовой пояс приложения (фикс. смещение в часах, −12..14; МСК = 3). Один на всех: в нём
# сервер ставит «дату игры», а фронт показывает время и принимает даты объявлений —
# пояс устройства игрока не учитывается.
APP_TZ_OFFSET_HOURS=3
# CORS нужен только в dev (фронт и API на разных портах); в prod single-origin
CORS_ORIGINS=http://localhost:5173,http://127.0.0.1:5173
-13
View File
@@ -3,16 +3,3 @@
# Скрипты образов (entrypoint.sh, deploy/*/…) контейнер дополнительно чинит sed-ом при сборке.
*.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
+1
View File
@@ -43,6 +43,7 @@ mockups/
# AI-ассистенты (локальные, в репозиторий не идут)
CLAUDE.md
AGENTS.md
.claude/
# Редактор / ОС
+7 -10
View File
@@ -16,7 +16,7 @@
backend/ FastAPI: ядро, REST API, БД, миграции Alembic, seed, тесты
frontend/ React + Vite SPA
deploy/ публикация и эксплуатация: vps/ (Caddy), pi/ (прод), tunnel/ и backup/ (образы)
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК), export-prod.sh
scripts/ build-push.* (сборка и пуш образов), fs-backup.* (бэкапы с ПК)
Dockerfile multi-stage сборка (фронт собирается node, отдаётся FastAPI)
docker-compose.yml прод на Pi: app + tunnel + backup
docker-compose.temp.yml временный прод на ПК вместо Pi: app + tunnel
@@ -125,8 +125,10 @@ docker compose up -d # pull_policy: always — тянет свежие
туннель-контейнер. FastAPI отдаёт собранный SPA и API с одного origin. Миграции, сидинг
справочников и создание админа выполняются автоматически при старте (`entrypoint.sh`).
В production закрыты OpenAPI/Swagger, а с дефолтным или коротким `SECRET_KEY` либо
дефолтным `ADMIN_PASSWORD` приложение не стартует. Учтите: `ADMIN_PASSWORD` применяется только
при первом создании админа, дальше его смена в `.env` ни на что не влияет (задача #73).
дефолтным `ADMIN_PASSWORD` приложение не стартует. `ADMIN_PASSWORD` применяется сам только
при первом создании админа; сменить его потом — правкой `.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).
- **Не используйте `$` в секретах.** Единый `.env` читают и pydantic (dev — `$` дословно),
@@ -200,9 +202,8 @@ docker compose up -d # pull_policy: always — тянет свежие
(`backend/data/dev/`), prod → `PROD_DATABASE_URL` (том `/data`). Так же раздельно лежат
загрузки (`*_UPLOAD_DIR`) и ачивки (`*_ACHIEVEMENTS_DIR`).
- **В Docker идёт только прод-код:** dev-вход (stub), dev-удаление аккаунтов и тесты физически
исключены из образа (`.dockerignore`).
- **Внимание:** данные дева (`backend/data/dev/`) сейчас **попадают** в образ — правило
`data/` в `.dockerignore` исключает только корневую папку `data/` (задача #71).
исключены из образа (`.dockerignore`). Данные дева (`backend/data/`), любые файлы SQLite
и локальные бэкапы (`backups/`) в образ тоже не попадают.
## Git и деплой
@@ -213,10 +214,6 @@ docker compose up -d # pull_policy: always — тянет свежие
- **Деплой на Pi:** на ПК с ветки `main` — `.\scripts\build-push.ps1` (собирает и пушит
образы app + tunnel + backup под arm64), на Pi — `docker compose up -d`. На Windows нужна
именно 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`.
@@ -0,0 +1,80 @@
"""Объявления администрации (#84): сами объявления и отметки «игрок закрыл».
Идемпотентна: на свежей БД таблицы создаёт 0001 (create_all из актуальных моделей) -> no-op;
на существующей БД создаёт недостающие таблицы.
Отходит от 0013, а не от 0014: объявления выпущены в main раньше рейтинга. Ветки рейтинга
(0014) и объявлений (0015) сводит пустая миграция 0016.
Revision ID: 0015_announcements
Revises: 0013_user_token_version
Create Date: 2026-09-18
"""
from typing import Sequence, Union
import sqlalchemy as sa
from sqlalchemy import inspect
from alembic import op
revision: str = "0015_announcements"
down_revision: Union[str, None] = "0013_user_token_version"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
bind = op.get_bind()
tables = set(inspect(bind).get_table_names())
if "announcements" not in tables:
op.create_table(
"announcements",
sa.Column("id", sa.Integer(), primary_key=True),
sa.Column("title", sa.String(64), nullable=False),
sa.Column("body_html", sa.Text(), nullable=False),
sa.Column("starts_at", sa.DateTime(), nullable=False),
sa.Column("ends_at", sa.DateTime(), nullable=False),
sa.Column("show_to_new_players", sa.Boolean(), nullable=False, server_default="1"),
sa.Column("revision", sa.Integer(), nullable=False, server_default="1"),
sa.Column(
"created_by",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column("created_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
)
op.create_index("ix_announcements_period", "announcements", ["starts_at", "ends_at"])
if "announcement_views" not in tables:
op.create_table(
"announcement_views",
sa.Column(
"announcement_id",
sa.Integer(),
sa.ForeignKey("announcements.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column(
"user_id",
sa.Integer(),
sa.ForeignKey("users.id", ondelete="CASCADE"),
primary_key=True,
),
sa.Column("revision", sa.Integer(), nullable=False),
sa.Column("closed_at", sa.DateTime(), nullable=False),
)
op.create_index(
"ix_announcement_views_user_id", "announcement_views", ["user_id"]
)
def downgrade() -> None:
tables = set(inspect(op.get_bind()).get_table_names())
if "announcement_views" in tables:
op.drop_table("announcement_views")
if "announcements" in tables:
op.drop_table("announcements")
@@ -0,0 +1,25 @@
"""Слияние веток миграций: рейтинг (0014) и объявления (0015).
Объявления (#84) вышли в main раньше рейтинга, поэтому 0015 отходит от 0013, а не от 0014,
и у графа миграций две ветки. Эта миграция сводит их в одну голову и сама ничего не меняет:
прод, стоящий на 0015 (релиз без рейтинга), при переходе на эту версию получит 0014 и её;
база на 0014 (dev) — 0015 и её.
Revision ID: 0016_merge_rating_announcements
Revises: 0014_rating_inputs, 0015_announcements
Create Date: 2026-09-18
"""
from typing import Sequence, Union
revision: str = "0016_merge_rating_announcements"
down_revision: Union[str, Sequence[str], None] = ("0014_rating_inputs", "0015_announcements")
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
+3 -2
View File
@@ -2,8 +2,9 @@
Тонкий слой поверх `admin_service.authenticate_admin`: throttle по IP, по паре «IP + логин»
и по самому аккаунту через тот же `LoginThrottle`, что и вход игрока (`core/ratelimit`).
Сервис остаётся чистым от инфраструктуры лимитов. Пароль администратора — единственный
барьер к полному контролю приложения, поэтому перебор здесь ограничиваем строже игроцкого.
Сервис остаётся чистым от инфраструктуры лимитов. Лимиты те же, что у игрока (5 на пару,
20 на IP, 50 на аккаунт за 15 минут); отличие — при успешном входе снимаются все счётчики,
включая IP.
"""
from __future__ import annotations
+1 -1
View File
@@ -1,4 +1,4 @@
"""Dev-провайдер: вход без секрета по нику/идентификатору (только не-production)."""
"""Dev-провайдер: вход без секрета по нику/идентификатору (только development)."""
from __future__ import annotations
from typing import Any
+2 -2
View File
@@ -1,7 +1,7 @@
"""Общий вход: внешняя личность → пользователь → сессия.
Прод-безопасный модуль (без импортов dev-провайдера). Используется и Telegram-входом,
и dev-входом.
Прод-безопасный модуль (без импортов dev-провайдера). establish_session зовут вход через
Telegram, /auth/login, /auth/register и dev-вход.
"""
from __future__ import annotations
+1 -1
View File
@@ -2,7 +2,7 @@
Проверяет подпись данных виджета (HMAC-SHA256 ключом SHA256(BOT_TOKEN)) и свежесть
auth_date. Нужны TELEGRAM_BOT_TOKEN (+ TELEGRAM_BOT_USERNAME для виджета на фронте).
Доступен и в dev, и в prod (в prod — единственный метод входа).
Доступен во всех окружениях — наряду со входом по логину и паролю.
"""
from __future__ import annotations
+45 -2
View File
@@ -1,9 +1,12 @@
"""Идемпотентный бутстрап: справочники + учётная запись администратора.
Запуск: `python -m app.bootstrap` (вызывается из entrypoint.sh после миграций).
Ротация пароля админа из .env: `python -m app.bootstrap --reset-admin-password` (#73).
"""
from __future__ import annotations
import argparse
from sqlmodel import Session, select
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.models import User
from app.seed.reference_data import seed_reference_data
from app.services import user_service
def _ensure_admin(session: Session) -> None:
@@ -38,7 +42,9 @@ def _ensure_admin(session: Session) -> None:
# Администратор уже существует.
if not settings.is_development:
# В prod пароль НЕ перезаписываем (мог быть изменён через панель).
# В prod обычный старт пароль НЕ перезаписывает: смена ADMIN_PASSWORD в .env сама
# по себе ничего не делает — применить её можно только осознанно, командой
# ротации (reset_admin_password), которая заодно отзывает админские сессии.
return
# DEV: подтягиваем логин/пароль из .env (env — источник истины в деве).
@@ -55,11 +61,48 @@ def _ensure_admin(session: Session) -> None:
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:
with Session(engine) as session:
seed_reference_data(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__":
bootstrap()
main()
+42 -19
View File
@@ -12,8 +12,8 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
# (исключён из образа) — там настройки приходят переменными от docker compose.
_ROOT_ENV = str(Path(__file__).resolve().parents[3] / ".env")
# Небезопасные значения по умолчанию (годятся только для dev). В production приложение
# с ними не стартует — см. валидатор _forbid_default_secrets_in_prod (#59).
# Небезопасные значения по умолчанию (годятся только для dev на localhost). Опубликованное
# приложение с ними не стартует — см. валидатор _forbid_default_secrets_when_published.
_DEFAULT_SECRET_KEY = "change-me-dev-secret-not-for-production"
_DEFAULT_ADMIN_PASSWORD = "change-me-admin-password"
_MIN_SECRET_KEY_LENGTH = 32
@@ -31,18 +31,20 @@ class Settings(BaseSettings):
)
# ── Главный переключатель окружения: development | production ─────────────
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, вход Telegram+ник.
# development — нативный dev (uvicorn + vite), БД в ./data/dev/, есть stub-вход по нику.
# production — Docker на Pi; контейнер форсит это значение, игнорируя .env.
app_env: str = "development"
log_level: str = "INFO"
# Публикация локального dev-окружения наружу через VPS-туннель.
# Читает ЛАУНЧЕР (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"
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3).
# Хранение всегда в UTC; смещение применяется к «дате игры» и отображению.
# Часовой пояс приложения (фиксированное смещение, по умолчанию МСК +3). Хранение всегда
# в UTC; смещение применяется к «дате игры» (timeutil.app_today) и к отображению времени
# на фронте — оно приходит туда в GET /api/auth/config (#68).
app_tz_offset_hours: int = 3
# ── БД: структура общая, файлы РАЗНЫЕ для dev и prod; выбор по app_env ─────
@@ -66,8 +68,8 @@ class Settings(BaseSettings):
# cookie_secure НЕ задаётся вручную — выводится из окружения (см. property ниже).
cookie_domain: str | None = None
# Аутентификация. Методы входа определяются окружением (dev: telegram+stub,
# prod: только telegram) — отдельного переключателя провайдера нет.
# Аутентификация. Методы входа определяются окружением (auth/registry.py): везде
# логин/пароль + Telegram, в development ещё stub — отдельного переключателя нет.
telegram_bot_token: str | None = None
telegram_bot_username: 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
@property
def cookie_secure(self) -> bool:
"""Secure-cookie нужен везде, где снаружи HTTPS (домен). Исключение —
нативный dev на localhost по HTTP (development + local_public=local)."""
def is_published(self) -> bool:
"""Приложение доступно снаружи по домену: production или dev, выставленный через
VPS-туннель. Не опубликован только нативный dev на localhost
(development + local_public=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
def cookie_domain_value(self) -> str | None:
return self.cookie_domain or None
@@ -131,14 +140,23 @@ class Settings(BaseSettings):
)
return value
@model_validator(mode="after")
def _forbid_default_secrets_in_prod(self) -> "Settings":
"""Fail-fast: в production не стартуем с дефолтными/слабыми секретами (#59).
@field_validator("app_tz_offset_hours")
@classmethod
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 дословно (или забывший поле), иначе поднялся бы
с общеизвестным ключом подписи JWT (подделка любого токена, включая админский) и
известным паролем администратора. В dev проверка не мешает — там дефолты норма."""
if self.app_env.lower() != "production":
@model_validator(mode="after")
def _forbid_default_secrets_when_published(self) -> "Settings":
"""Fail-fast: опубликованное приложение не стартует с дефолтными/слабыми
секретами (#59, #69) — и прод, и dev, выставленный на домен (LOCAL_PUBLIC=vps).
Иначе снаружи оказались бы общеизвестный ключ подписи JWT (подделка любого токена,
включая админский) и известный пароль администратора. На localhost дефолты — норма."""
if not self.is_published:
return self
problems: list[str] = []
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:
problems.append("ADMIN_PASSWORD не задан или дефолтный")
if problems:
where = (
"production"
if self.is_production
else f"dev, опубликованного наружу (LOCAL_PUBLIC={self.local_public})"
)
raise ValueError(
"Небезопасная конфигурация production — задайте секреты в .env: "
f"Небезопасная конфигурация {where} — задайте секреты в .env: "
+ "; ".join(problems)
)
return self
+1 -1
View File
@@ -6,7 +6,7 @@
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
перезапуска, но окно короткое, а рестарты редки;
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в core/events.py
про SSE-шину), общий для всех воркеров.
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
+2 -2
View File
@@ -175,6 +175,6 @@ def is_session_revoked(payload: dict) -> bool:
def client_ip(request: Request) -> str | None:
"""IP клиента для журнала аудита.
Одна точка на всё приложение: за VPS-привратником адрес придётся брать из
X-Forwarded-For, и менять это в двух десятках роутеров — не вариант."""
Одна точка на всё приложение. Реальный адрес за VPS-привратником уже подставляет
uvicorn (--proxy-headers + --forwarded-allow-ips в entrypoint.sh) — отсюда он и берётся."""
return request.client.host if request.client else None
-21
View File
@@ -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)
+13 -1
View File
@@ -17,6 +17,7 @@ from app.core.errors import AppError, app_error_handler
from app.routers import (
achievements,
admin,
announcements,
auth,
events,
groups,
@@ -131,6 +132,16 @@ async def _lifespan(_app: FastAPI):
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 при старте
# (в prod это делает entrypoint.sh; в pytest отключено FS_STARTUP_BOOTSTRAP=0).
if settings.is_development and os.getenv("FS_STARTUP_BOOTSTRAP", "1") != "0":
@@ -199,7 +210,8 @@ def create_app() -> FastAPI:
# API-роутеры под /api.
api_routers = [auth.router, users.router, groups.router, invitations.router,
matches.router, reference.router, stats.router, achievements.router,
events.router, notifications.router, admin.router]
events.router, notifications.router, announcements.router,
admin.router]
for r in api_routers:
app.include_router(r, prefix="/api")
+63
View File
@@ -447,6 +447,69 @@ class Notification(SQLModel, table=True):
created_at: datetime = Field(default_factory=utcnow, nullable=False, index=True)
# ─── Объявления администрации ────────────────────────────────────────────────
class Announcement(SQLModel, table=True):
"""Объявление администрации (#84): окно поверх приложения в период показа, каждому
игроку — пока он его не закроет.
body_html — уже очищенный сервером HTML (announcement_service.sanitize_body), фронт
вставляет его как есть. revision растёт, когда админ сохраняет правку с «показать
заново»: закрывшие прежнюю версию увидят объявление ещё раз с пометкой «обновлено».
show_to_new_players=False — не показывать зарегистрировавшимся после starts_at."""
__tablename__ = "announcements"
__table_args__ = (
CheckConstraint("ends_at > starts_at", name="ck_announcement_period"),
Index("ix_announcements_period", "starts_at", "ends_at"),
)
id: int | None = Field(default=None, primary_key=True)
title: str = Field(sa_column=Column(String(64), nullable=False))
body_html: str = Field(sa_column=Column(Text, nullable=False))
starts_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
ends_at: datetime = Field(sa_column=Column(DateTime, nullable=False))
show_to_new_players: bool = Field(
default=True,
sa_column=Column(Boolean, nullable=False, server_default="1"),
)
revision: int = Field(default=1, sa_column=Column(Integer, nullable=False, server_default="1"))
created_by: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
),
)
created_at: datetime = Field(default_factory=utcnow, nullable=False)
updated_at: datetime = Field(
default_factory=utcnow,
sa_column_kwargs={"onupdate": utcnow},
nullable=False,
)
class AnnouncementView(SQLModel, table=True):
"""Игрок закрыл объявление. revision — какую версию он видел: если админ выпустил
новую, объявление покажется снова."""
__tablename__ = "announcement_views"
announcement_id: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("announcements.id", ondelete="CASCADE"), primary_key=True
),
)
user_id: int | None = Field(
default=None,
sa_column=Column(
Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True, index=True
),
)
revision: int = Field(sa_column=Column(Integer, nullable=False))
closed_at: datetime = Field(default_factory=utcnow, nullable=False)
# ─── Журнал аудита ───────────────────────────────────────────────────────────
class AuditLog(SQLModel, table=True):
+125 -4
View File
@@ -18,6 +18,7 @@ from app.schemas import api as s
from app.services import (
achievement_service,
admin_service,
announcement_service,
attachment_service,
audit_service,
faction_service,
@@ -314,16 +315,15 @@ def delete_match(
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
group_id = match_service.get_match(session, match_id).group_id # для уведомления
# До удаления: каскад унесёт участников вместе с партией.
participant_ids = notify.match_participant_ids(session, match_id)
match = match_service.get_match(session, match_id)
group_id, finished = match.group_id, match.status == "finished" # для уведомления
admin_service.delete_match(session, match_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=client_ip(request),
)
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()
@@ -463,6 +463,127 @@ def delete_achievement(
return s.OkResponse()
# ─── Объявления ──────────────────────────────────────────────────────────────
def _announcement_read(item: dict) -> s.AdminAnnouncementRead:
a = item["a"]
return s.AdminAnnouncementRead(
id=a.id,
title=a.title,
body_html=a.body_html,
starts_at=iso_utc(a.starts_at), # type: ignore[arg-type]
ends_at=iso_utc(a.ends_at), # type: ignore[arg-type]
show_to_new_players=a.show_to_new_players,
revision=a.revision,
status=item["status"],
seen_count=item["seen"],
audience_count=item["audience"],
created_at=iso_utc(a.created_at), # type: ignore[arg-type]
updated_at=iso_utc(a.updated_at), # type: ignore[arg-type]
)
def _announcement_by_id(session: Session, announcement_id: int) -> s.AdminAnnouncementRead:
return _announcement_read(announcement_service.admin_item(session, announcement_id))
@router.get("/announcements", response_model=list[s.AdminAnnouncementRead])
def list_announcements(
session: Session = Depends(get_session),
_admin: User = Depends(get_current_admin),
) -> list[s.AdminAnnouncementRead]:
return [_announcement_read(i) for i in announcement_service.list_admin(session)]
@router.post("/announcements", response_model=s.AdminAnnouncementRead)
def create_announcement(
body: s.AnnouncementWrite,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
a = announcement_service.create(
session,
title=body.title,
body_html=body.body_html,
starts_at=body.starts_at,
ends_at=body.ends_at,
show_to_new_players=body.show_to_new_players,
actor_id=admin.id,
)
audit_service.record(
session, actor_id=admin.id, action="create", entity_type="announcement",
entity_id=a.id, payload={"title": a.title}, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, a.id) # type: ignore[arg-type]
@router.put("/announcements/{announcement_id}", response_model=s.AdminAnnouncementRead)
def update_announcement(
announcement_id: int,
body: s.AnnouncementUpdate,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
a = announcement_service.update(
session,
announcement_id,
title=body.title,
body_html=body.body_html,
starts_at=body.starts_at,
ends_at=body.ends_at,
show_to_new_players=body.show_to_new_players,
reshow=body.reshow,
)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="announcement",
entity_id=announcement_id,
payload={"title": a.title, "reshow": body.reshow, "revision": a.revision},
ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, announcement_id)
@router.post("/announcements/{announcement_id}/stop", response_model=s.AdminAnnouncementRead)
def stop_announcement(
announcement_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.AdminAnnouncementRead:
"""«Снять с показа»: период идущего объявления заканчивается сейчас."""
announcement_service.stop(session, announcement_id)
audit_service.record(
session, actor_id=admin.id, action="update", entity_type="announcement",
entity_id=announcement_id, payload={"stopped": True}, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return _announcement_by_id(session, announcement_id)
@router.delete("/announcements/{announcement_id}", response_model=s.OkResponse)
def delete_announcement(
announcement_id: int,
request: Request,
session: Session = Depends(get_session),
admin: User = Depends(get_current_admin),
) -> s.OkResponse:
announcement_service.delete(session, announcement_id)
audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="announcement",
entity_id=announcement_id, ip=client_ip(request),
)
session.commit()
notify.announcements_changed(session)
return s.OkResponse()
# ─── Журнал аудита ───────────────────────────────────────────────────────────
@router.get("/audit-logs", response_model=s.AuditLogList)
+46
View File
@@ -0,0 +1,46 @@
"""Объявления администрации для игрока: что показать сейчас и «Понятно».
Появление нового объявления у открытой вкладки обеспечивает SSE-сигнал
`{type:"announcements"}`; объявление с отложенным началом клиент подхватывает
периодическим перезапросом."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.db.session import get_session
from app.models import User
from app.schemas import api as s
from app.services import announcement_service
router = APIRouter(prefix="/announcements", tags=["announcements"])
@router.get("/pending", response_model=list[s.AnnouncementRead])
def pending(
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> list[s.AnnouncementRead]:
return [
s.AnnouncementRead(
id=a.id, # type: ignore[arg-type]
title=a.title,
body_html=a.body_html,
revision=a.revision,
updated=updated,
)
for a, updated in announcement_service.pending_for_user(session, user)
]
@router.post("/{announcement_id}/ack", response_model=s.OkResponse)
def acknowledge(
announcement_id: int,
body: s.AnnouncementAck,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
announcement_service.acknowledge(session, user.id, announcement_id, body.revision) # type: ignore[arg-type]
session.commit()
return s.OkResponse()
+1
View File
@@ -28,6 +28,7 @@ def auth_config() -> s.AuthConfig:
return s.AuthConfig(
methods=enabled_methods(),
telegram_bot_username=settings.telegram_bot_username,
tz_offset_hours=settings.app_tz_offset_hours,
)
+2 -2
View File
@@ -1,8 +1,8 @@
"""DEV-ТОЛЬКО роутер: вход по нику (stub) + тестовые пользователи.
Этот файл и app/auth/dev_stub.py ФИЗИЧЕСКИ исключены из прод-образа (.dockerignore),
а подключается роутер лишь когда APP_ENV != production (см. app/main.py). Так код
входа по логину остаётся только на деве.
а подключается роутер лишь при APP_ENV=development (см. app/main.py). Так вход
без пароля остаётся только на деве (по логину и паролю входят везде).
"""
from __future__ import annotations
+2 -4
View File
@@ -352,9 +352,7 @@ def delete_match(
match_service.assert_can_modify(session, match, user)
match_id_val = match.id
group_id_val = match.group_id
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
finished = match.status == "finished" # после удаления статус уже не прочитать
match_service.delete_match(session, match, expected_version=expected_version)
audit_service.record(
session,
@@ -367,5 +365,5 @@ def delete_match(
user_agent=request.headers.get("user-agent"),
)
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()
+57 -1
View File
@@ -1,7 +1,7 @@
"""Pydantic-схемы (граница HTTP). Из них генерируется OpenAPI → типы фронта."""
from __future__ import annotations
from datetime import date
from datetime import date, datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
@@ -22,6 +22,9 @@ class AuthConfig(BaseModel):
# Доступные методы входа: ["password","telegram"] в проде, плюс "stub" в деве.
methods: list[str] = []
telegram_bot_username: str | None = None
# Пояс приложения (APP_TZ_OFFSET_HOURS): в нём сервер считает «дату игры», а фронт
# показывает время всем игрокам — независимо от пояса устройства (#68).
tz_offset_hours: int
# Верхняя граница длины пароля на входе API: отсекает мегабайтные тела до bcrypt.
@@ -233,6 +236,23 @@ class NotificationMarkRead(BaseModel):
ids: list[int] | None = None
# ─── Объявления (игрок) ──────────────────────────────────────────────────────
class AnnouncementRead(BaseModel):
id: int
title: str
# HTML, уже очищенный сервером по белому списку — фронт вставляет как есть.
body_html: str
revision: int
# Игрок закрывал прежнюю версию — окно показывает пометку «обновлено».
updated: bool = False
class AnnouncementAck(BaseModel):
# Версия, которую игрок видел и закрыл (AnnouncementRead.revision).
revision: int = Field(ge=1)
# ─── Партии ──────────────────────────────────────────────────────────────────
class RandomizeRequest(BaseModel):
@@ -627,3 +647,39 @@ class AuditLogList(BaseModel):
items: list[AuditLogItem] = []
limit: int
offset: int
# ─── Объявления (админ) ──────────────────────────────────────────────────────
AnnouncementStatus = Literal["planned", "live", "finished"]
class AnnouncementWrite(BaseModel):
# Точные правила (заголовок до 60 символов, текст до 600 видимых) — в
# announcement_service; здесь только отсечка мегабайтных тел.
title: str = Field(max_length=200)
body_html: str = Field(max_length=20_000)
starts_at: datetime
ends_at: datetime
show_to_new_players: bool = True
class AnnouncementUpdate(AnnouncementWrite):
# Показать заново тем, кто уже закрыл (с пометкой «обновлено»).
reshow: bool = False
class AdminAnnouncementRead(BaseModel):
id: int
title: str
body_html: str
starts_at: str
ends_at: str
show_to_new_players: bool
revision: int
status: AnnouncementStatus
# Закрыли текущую версию / сколько активных игроков в адресатах.
seen_count: int
audience_count: int
created_at: str
updated_at: str
+7 -2
View File
@@ -33,7 +33,13 @@ FACTIONS: list[tuple[str, str, str, int]] = [
def seed_reference_data(session: Session) -> None:
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно."""
"""Создаёт/обновляет дополнения и фракции. Безопасно вызывать многократно.
Идёт при каждом старте (entrypoint.sh, lifespan dev), поэтому не перетирает то, что
правят руками: имя существующей фракции меняет админка (admin_service.rename_faction),
и после создания записи источник правды для него — БД (#72). Имя из кода получает
только новая фракция; поправить имя существующей — админкой или миграцией.
Служебные поля (дополнение, порядок) и всё у дополнений синхронизируются с кодом."""
code_to_expansion: dict[str, Expansion] = {}
for code, name_ru, is_base, order in EXPANSIONS:
@@ -61,7 +67,6 @@ def seed_reference_data(session: Session) -> None:
)
session.add(fac)
else:
fac.name_ru = name_ru
fac.expansion_id = expansion.id # type: ignore[assignment]
fac.sort_order = order
+3 -2
View File
@@ -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:
"""Новый пароль игроку (восстановление забытого). Пароль админа так не меняется —
он задаётся ADMIN_PASSWORD в .env."""
"""Новый пароль игроку (восстановление забытого). Пароль админа через панель не
меняется: он задаётся ADMIN_PASSWORD в .env и применяется к существующему админу
командой `python -m app.bootstrap --reset-admin-password` (#73)."""
user = session.get(User, user_id)
if user is None:
raise NotFoundError("Пользователь не найден.")
@@ -0,0 +1,336 @@
"""Объявления администрации (#84): очистка текста, период показа, кому и что показать.
Объявление видно игроку, пока идёт его период и игрок не закрыл текущую версию. Закрытие
(«Понятно») пишет отметку с номером версии; правка с «показать заново» поднимает версию —
и закрывшие прежнюю увидят объявление снова, с пометкой «обновлено».
Текст хранится HTML-ом из редактора админки, но только после очистки по белому списку
(sanitize_body): фронт вставляет его без экранирования, так что это единственный барьер
между полем редактора и страницей игрока.
"""
from __future__ import annotations
import html
from datetime import datetime, timezone
from html.parser import HTMLParser
from sqlalchemy import func
from sqlmodel import Session, select
from app.core.errors import NotFoundError, ValidationError
from app.core.timeutil import utcnow
from app.models import Announcement, AnnouncementView, User
TITLE_MAX = 60
TEXT_MAX = 600 # видимых символов, без разметки
# ─── Очистка HTML ────────────────────────────────────────────────────────────
# Что оставляем и во что превращаем. Редактор (contenteditable + execCommand) в разных
# браузерах пишет то <b>, то <strong>, абзацы — <div> или <p>; приводим к одному виду.
_TAGS = {
"b": "b",
"strong": "b",
"i": "em",
"em": "em",
"mark": "mark",
"p": "p",
"div": "p",
"br": "br",
}
# Теги, которые выбрасываются вместе с содержимым: их текст не предназначен для показа.
_DROP_WITH_CONTENT = {
"script", "style", "template", "noscript", "iframe", "object", "embed",
"svg", "math", "head", "title", "textarea", "select",
}
_VOID = {"br"}
class _Sanitizer(HTMLParser):
"""Пересобирает HTML из разобранных токенов: теги — только из белого списка и без
атрибутов (кроме class="red" у <mark>), весь текст экранируется заново. Всё, что
парсер не распознал как тег из списка, становится текстом или пропадает."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.out: list[str] = []
self.text: list[str] = []
self.stack: list[str] = []
self.drop_depth = 0
def _pop(self) -> str:
top = self.stack.pop()
last = self.out[-1] if self.out else ""
# Точное сравнение: startswith("<b") поймал бы и <br>.
if last == f"<{top}>" or last.startswith(f"<{top} "):
self.out.pop() # пустая пара (<p></p> от вложенных <div>) — выбрасываем
else:
self.out.append(f"</{top}>")
return top
def _close_to(self, tag: str) -> None:
while self.stack:
if self._pop() == tag:
return
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
if tag in _DROP_WITH_CONTENT:
self.drop_depth += 1
return
if self.drop_depth or tag not in _TAGS:
return
name = _TAGS[tag]
if name in _VOID:
self.out.append(f"<{name}>")
return
if name == "p" and "p" in self.stack:
# Абзац внутри абзаца (вложенные <div> из contenteditable) — закрываем прежний.
self._close_to("p")
opening = f"<{name}>"
if name == "mark":
classes = next((v or "" for k, v in attrs if k == "class"), "").split()
if "red" in classes:
opening = '<mark class="red">'
self.stack.append(name)
self.out.append(opening)
def handle_startendtag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
# <br/> и прочие самозакрытые: содержимого нет, так что drop-теги тут ни при чём.
if not self.drop_depth and _TAGS.get(tag) in _VOID:
self.out.append(f"<{_TAGS[tag]}>")
def handle_endtag(self, tag: str) -> None:
if tag in _DROP_WITH_CONTENT:
self.drop_depth = max(0, self.drop_depth - 1)
return
if self.drop_depth:
return
name = _TAGS.get(tag)
if name and name not in _VOID and name in self.stack:
self._close_to(name)
def handle_data(self, data: str) -> None:
if self.drop_depth:
return
self.text.append(data)
self.out.append(html.escape(data, quote=False))
def result(self) -> tuple[str, str]:
self.close()
while self.stack:
self._pop()
return "".join(self.out), "".join(self.text)
def sanitize_body(raw: str) -> tuple[str, str]:
"""(очищенный HTML, видимый текст). Видимый текст нужен для проверки длины."""
parser = _Sanitizer()
parser.feed(raw)
return parser.result()
# ─── Проверки и время ─────────────────────────────────────────────────────────
def _now() -> datetime:
# В SQLite моменты лежат наивными (UTC) — сравниваем с наивным же «сейчас».
return utcnow().replace(tzinfo=None)
def _naive_utc(dt: datetime) -> datetime:
"""Момент из запроса → наивный UTC. Время без смещения считаем UTC."""
if dt.tzinfo is not None:
dt = dt.astimezone(timezone.utc).replace(tzinfo=None)
return dt.replace(second=0, microsecond=0)
def _clean_fields(
title: str, body_html: str, starts_at: datetime, ends_at: datetime
) -> tuple[str, str, datetime, datetime]:
title = title.strip()
if not title:
raise ValidationError("Заголовок не может быть пустым.")
if len(title) > TITLE_MAX:
raise ValidationError(f"Заголовок длиннее {TITLE_MAX} символов.")
clean_html, text = sanitize_body(body_html)
text = text.strip()
if not text:
raise ValidationError("Текст объявления не может быть пустым.")
if len(text) > TEXT_MAX:
raise ValidationError(f"Текст длиннее {TEXT_MAX} символов ({len(text)}).")
start, end = _naive_utc(starts_at), _naive_utc(ends_at)
if end <= start:
raise ValidationError("Конец показа должен быть позже начала.")
return title, clean_html, start, end
def status_of(a: Announcement, now: datetime | None = None) -> str:
now = now or _now()
if now < a.starts_at:
return "planned"
if now >= a.ends_at:
return "finished"
return "live"
def get(session: Session, announcement_id: int) -> Announcement:
a = session.get(Announcement, announcement_id)
if a is None:
raise NotFoundError("Объявление не найдено.")
return a
# ─── Админка ─────────────────────────────────────────────────────────────────
def create(
session: Session,
*,
title: str,
body_html: str,
starts_at: datetime,
ends_at: datetime,
show_to_new_players: bool,
actor_id: int | None,
) -> Announcement:
title, body_html, start, end = _clean_fields(title, body_html, starts_at, ends_at)
if end <= _now():
raise ValidationError("Период показа уже закончился.")
a = Announcement(
title=title,
body_html=body_html,
starts_at=start,
ends_at=end,
show_to_new_players=show_to_new_players,
created_by=actor_id,
)
session.add(a)
session.flush()
return a
def update(
session: Session,
announcement_id: int,
*,
title: str,
body_html: str,
starts_at: datetime,
ends_at: datetime,
show_to_new_players: bool,
reshow: bool,
) -> Announcement:
a = get(session, announcement_id)
a.title, a.body_html, a.starts_at, a.ends_at = _clean_fields(
title, body_html, starts_at, ends_at
)
a.show_to_new_players = show_to_new_players
if reshow:
# Новая версия: отметки о закрытии прежней больше не прячут объявление.
a.revision += 1
a.updated_at = utcnow()
session.add(a)
session.flush()
return a
def stop(session: Session, announcement_id: int) -> Announcement:
"""«Снять с показа»: период заканчивается сейчас. Только у идущего объявления —
у запланированного конец раньше начала нарушил бы период; его просто удаляют."""
a = get(session, announcement_id)
now = _now()
if status_of(a, now) != "live" or now <= a.starts_at:
raise ValidationError("Снять с показа можно только идущее объявление.")
a.ends_at = now
a.updated_at = utcnow()
session.add(a)
session.flush()
return a
def delete(session: Session, announcement_id: int) -> None:
session.delete(get(session, announcement_id))
session.flush()
def _audience_filter(a: Announcement):
"""Условия «игрок — адресат объявления» (для счётчиков и для показа)."""
conds = [User.role == "player", User.is_active.is_(True)] # type: ignore[union-attr]
if not a.show_to_new_players:
conds.append(User.created_at <= a.starts_at)
return conds
def _admin_item(session: Session, a: Announcement, now: datetime) -> dict:
"""Объявление со статусом и счётчиком «закрыли N из M»: N — закрывшие текущую
версию, M — сколько активных игроков сейчас в адресатах."""
audience = _audience_filter(a)
audience_count = session.exec(select(func.count()).select_from(User).where(*audience)).one()
seen_count = session.exec(
select(func.count())
.select_from(AnnouncementView)
.join(User, User.id == AnnouncementView.user_id)
.where(
AnnouncementView.announcement_id == a.id,
AnnouncementView.revision >= a.revision,
*audience,
)
).one()
return {"a": a, "status": status_of(a, now), "seen": seen_count, "audience": audience_count}
def admin_item(session: Session, announcement_id: int) -> dict:
return _admin_item(session, get(session, announcement_id), _now())
def list_admin(session: Session) -> list[dict]:
"""Все объявления, новые сверху. Объявлений единицы, поэтому счётчики — по паре
запросов на объявление."""
now = _now()
rows = session.exec(
select(Announcement).order_by(Announcement.starts_at.desc(), Announcement.id.desc()) # type: ignore[union-attr]
).all()
return [_admin_item(session, a, now) for a in rows]
# ─── Игрок ───────────────────────────────────────────────────────────────────
def pending_for_user(session: Session, user: User) -> list[tuple[Announcement, bool]]:
"""Что показать игроку сейчас — от старого к новому. Второй элемент — «обновлено»:
игрок закрывал прежнюю версию этого объявления."""
now = _now()
registered = user.created_at.replace(tzinfo=None) # только что созданный — aware UTC
rows = session.exec(
select(Announcement, AnnouncementView.revision)
.join(
AnnouncementView,
(AnnouncementView.announcement_id == Announcement.id)
& (AnnouncementView.user_id == user.id),
isouter=True,
)
.where(Announcement.starts_at <= now, Announcement.ends_at > now)
.order_by(Announcement.starts_at, Announcement.id)
).all()
result = []
for a, seen_revision in rows:
if seen_revision is not None and seen_revision >= a.revision:
continue
if not a.show_to_new_players and registered > a.starts_at:
continue
result.append((a, seen_revision is not None))
return result
def acknowledge(session: Session, user_id: int, announcement_id: int, revision: int) -> None:
"""Игрок закрыл объявление в версии revision. Версию берём с клиента: если админ
выпустил новую, пока окно было открыто, игрок закрыл старую и новую ещё увидит."""
a = get(session, announcement_id)
revision = min(revision, a.revision)
view = session.get(AnnouncementView, (announcement_id, user_id))
if view is None:
session.add(
AnnouncementView(announcement_id=announcement_id, user_id=user_id, revision=revision)
)
elif revision > view.revision:
view.revision = revision
view.closed_at = utcnow()
session.add(view)
session.flush()
+36 -37
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from sqlmodel import Session, select
from app.core.events import hub
from app.models import GroupMember, Match, MatchParticipant
from app.models import GroupMember, Match, User
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]:
"""Кто играл в партии. Нужен в событии, чтобы клиент понимал, чьи витрины
(история игр, публичный профиль, личная статистика) реально протухли."""
return list(
session.exec(
select(MatchParticipant.user_id).where(MatchParticipant.match_id == match_id)
def _ratings_changed(session: Session, notified: list[int]) -> None:
"""Рейтинг общий и считается по всей истории (#80): завершённая партия двигает топ,
главную, историю и профили всех, кто играл после неё, и страницы других групп.
Игрокам вне группы (notified уже знают) — событие без подробностей о партии."""
skip = set(notified)
ids = [
uid
for uid in session.exec(
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
).all()
)
if uid not in skip
]
hub.publish(ids, {"type": "ratings"})
def match_changed(session: Session, match: Match) -> None:
"""Партия изменилась — уведомить всех участников её группы.
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
история и профили протухают только у игравших, поэтому их id едут в событии."""
hub.publish(
_group_member_ids(session, match.group_id),
{
"type": "match",
"match_id": match.id,
"group_id": match.group_id,
"participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type]
},
)
"""Партия изменилась — уведомить всех участников её группы, а если она завершена —
и остальных игроков (_ratings_changed)."""
members = _group_member_ids(session, match.group_id)
hub.publish(members, {"type": "match", "match_id": match.id, "group_id": match.group_id})
if match.status == "finished":
_ratings_changed(session, members)
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})
def match_removed(
session: Session, match_id: int, group_id: int, participant_ids: list[int] | None = None
) -> None:
"""Партия удалена — уведомить участников группы (обновить списки).
participant_ids передаются снаружи: к этому моменту партии уже нет, а её участники
ушли каскадом, и собрать их из базы невозможно."""
hub.publish(
_group_member_ids(session, group_id),
{
"type": "match",
"match_id": match_id,
"group_id": group_id,
"participant_ids": participant_ids or [],
},
)
def match_removed(session: Session, match_id: int, group_id: int, *, finished: bool) -> None:
"""Партия удалена — уведомить участников группы (обновить списки), а если она была
завершена — и остальных игроков. Статус передаётся снаружи: партии уже нет."""
members = _group_member_ids(session, group_id)
hub.publish(members, {"type": "match", "match_id": match_id, "group_id": group_id})
if finished:
_ratings_changed(session, members)
def group_changed(session: Session, group_id: int, extra_user_ids: list[int] | None = None) -> None:
@@ -87,3 +76,13 @@ def invitations_changed(user_id: int) -> None:
def notifications_changed(user_id: int) -> None:
"""У пользователя появилось/изменилось уведомление — пусть подтянет список."""
hub.publish([user_id], {"type": "notifications"})
def announcements_changed(session: Session) -> None:
"""Админ создал, поправил, снял или удалил объявление — всем активным игрокам:
открытые вкладки перезапросят, что показать. Объявление, чей период начнётся
позже, клиент подхватит сам — периодическим перезапросом."""
ids = session.exec(
select(User.id).where(User.role == "player", User.is_active.is_(True)) # type: ignore[union-attr]
).all()
hub.publish(ids, {"type": "announcements"})
+8 -3
View File
@@ -7,9 +7,10 @@
E_ab = 1 / (1 + 10^((R_b − R_a) / D)) ожидание по рейтингам ДО партии
S_ab = 1 / 0.5 / 0 выше / поровну / ниже
Δ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) — вес размера стола,
M — множитель отрыва (темп, цели, миры; близость по типу победы — только у пар
с победителем). Недостающий признак партии подставляется типичным и не влияет на M.
Пары двух выбывших в сумму не входят (#91), остальные — все. K_i спускается от K_MAX
у новичка до K_MIN за K_GAMES партий, G(N) — вес размера стола, M — множитель отрыва
(темп, цели, миры; близость по типу победы — только у пар с победителем). Недостающий
признак партии подставляется типичным и не влияет на M.
Модуль — только константы и чистые функции без БД: калибровка на реальных данных —
правка констант, пересчёт выполняется сам (рейтинг — функция упорядоченной истории).
@@ -168,6 +169,10 @@ def rate_match(
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}
for a, b in combinations(m.seats, 2):
if a.eliminated and b.eliminated:
# Выбывшие между собой не сравниваются: в этой партии все они проиграли, а миров
# у них нет (решение владельца, #91). Нормировка на N − 1 остаётся прежней.
continue
if a.place > b.place:
a, b = b, a
s_ab = 0.5 if a.place == b.place else 1.0
+35
View File
@@ -2,7 +2,11 @@
from __future__ import annotations
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
@@ -205,6 +209,37 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine
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):
"""Группу с партиями удалять нельзя — но ответ должен быть внятным 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)
+286
View File
@@ -0,0 +1,286 @@
"""Объявления администрации (#84): очистка HTML, права, период показа, «новые игроки»,
повторный показ с пометкой «обновлено», снятие с показа и удаление."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import pytest
from fastapi.testclient import TestClient
from sqlmodel import Session, select
from app.models import AnnouncementView, User
from app.services import notify
from app.services.announcement_service import sanitize_body
from tests.conftest import csrf_headers, login
def _iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).isoformat()
def _now() -> datetime:
return datetime.now(timezone.utc)
def _admin_login(client: TestClient, make_admin) -> None:
make_admin("admin", "secret123")
r = client.post(
"/api/admin/auth/login",
json={"username": "admin", "password": "secret123"},
headers=csrf_headers(client),
)
assert r.status_code == 200, r.text
def _create(client: TestClient, **over) -> dict:
body = {
"title": "Турнир",
"body_html": "<p>Суббота, <b>11:00</b></p>",
"starts_at": _iso(_now() - timedelta(hours=1)),
"ends_at": _iso(_now() + timedelta(days=1)),
"show_to_new_players": True,
}
body.update(over)
r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client))
assert r.status_code == 200, r.text
return r.json()
def _update(client: TestClient, ann: dict, **over) -> dict:
body = {
"title": ann["title"],
"body_html": ann["body_html"],
"starts_at": ann["starts_at"],
"ends_at": ann["ends_at"],
"show_to_new_players": ann["show_to_new_players"],
"reshow": False,
}
body.update(over)
r = client.put(
f"/api/admin/announcements/{ann['id']}", json=body, headers=csrf_headers(client)
)
assert r.status_code == 200, r.text
return r.json()
def _pending(client: TestClient) -> list[dict]:
r = client.get("/api/announcements/pending")
assert r.status_code == 200, r.text
return r.json()
def _ack(client: TestClient, ann_id: int, revision: int):
return client.post(
f"/api/announcements/{ann_id}/ack",
json={"revision": revision},
headers=csrf_headers(client),
)
def _admin_item(client: TestClient, ann_id: int) -> dict:
return next(a for a in client.get("/api/admin/announcements").json() if a["id"] == ann_id)
# ─── Очистка HTML ────────────────────────────────────────────────────────────
def test_sanitize_keeps_allowed_and_strips_everything_else():
raw = (
'<div>Привет <strong onclick="steal()">мир</strong></div>'
'<p style="color:red"><i>курсив</i> <mark class="red big">красный</mark> '
'<mark class="gold" onmouseover="x()">золотой</mark></p>'
"<script>alert(1)</script><style>p{display:none}</style>"
'<img src=x onerror="alert(1)"><a href="javascript:alert(1)">ссылка</a>'
"<svg><text>svg-текст</text></svg>&lt;b&gt;"
)
html, text = sanitize_body(raw)
assert html == (
"<p>Привет <b>мир</b></p>"
'<p><em>курсив</em> <mark class="red">красный</mark> <mark>золотой</mark></p>'
"ссылка&lt;b&gt;"
)
# Содержимое script/style/svg в видимый текст (и в счётчик длины) не попадает.
assert text == "Привет миркурсив красный золотойссылка<b>"
def test_sanitize_fixes_nesting_and_keeps_line_breaks():
html, _ = sanitize_body("<div><div>один</div><div>два<br></div></div><b>жирный<br/>хвост")
# Вложенные <div> из contenteditable → плоские абзацы без пустых <p></p>;
# <br> внутри <b> не путается с самим <b>; незакрытое закрывается.
assert html == "<p>один</p><p>два<br></p><b>жирный<br>хвост</b>"
# ─── Права ───────────────────────────────────────────────────────────────────
def test_admin_endpoints_are_closed_to_players(client: TestClient):
assert client.get("/api/admin/announcements").status_code == 401
login(client, "Игрок")
assert client.get("/api/admin/announcements").status_code == 401
r = client.post(
"/api/admin/announcements",
json={
"title": "x",
"body_html": "y",
"starts_at": _iso(_now()),
"ends_at": _iso(_now() + timedelta(days=1)),
},
headers=csrf_headers(client),
)
assert r.status_code == 401
def test_pending_requires_player_session(client: TestClient):
assert client.get("/api/announcements/pending").status_code == 401
# ─── Показ ───────────────────────────────────────────────────────────────────
def test_created_announcement_is_shown_once(client: TestClient, make_admin, monkeypatch):
events: list[dict] = []
monkeypatch.setattr(notify.hub, "publish", lambda ids, ev: events.append(ev))
login(client, "Игрок")
_admin_login(client, make_admin)
ann = _create(client, body_html="<p>Сбор <b>в 11:00</b></p><script>alert(1)</script>")
assert ann["body_html"] == "<p>Сбор <b>в 11:00</b></p>"
assert ann["status"] == "live"
assert (ann["seen_count"], ann["audience_count"]) == (0, 1)
assert {"type": "announcements"} in events
items = _pending(client)
assert [(a["id"], a["revision"], a["updated"]) for a in items] == [(ann["id"], 1, False)]
assert _ack(client, ann["id"], 1).status_code == 200
assert _pending(client) == []
assert _admin_item(client, ann["id"])["seen_count"] == 1
logs = client.get("/api/admin/audit-logs?entity_type=announcement").json()["items"]
assert [(l["action"], l["entity_id"]) for l in logs] == [("create", ann["id"])]
def test_pending_respects_period_and_order(client: TestClient, make_admin):
login(client, "Игрок")
_admin_login(client, make_admin)
later = _create(client, title="Позже", starts_at=_iso(_now() - timedelta(minutes=30)))
earlier = _create(client, title="Раньше", starts_at=_iso(_now() - timedelta(hours=3)))
planned = _create(
client,
title="Завтра",
starts_at=_iso(_now() + timedelta(days=1)),
ends_at=_iso(_now() + timedelta(days=2)),
)
assert planned["status"] == "planned"
# Пересекающиеся периоды — от старого к новому; запланированного пока нет.
assert [a["title"] for a in _pending(client)] == ["Раньше", "Позже"]
assert earlier["id"] != later["id"]
# Период, который уже закончился, создать нельзя.
r = client.post(
"/api/admin/announcements",
json={
"title": "Прошлое",
"body_html": "текст",
"starts_at": _iso(_now() - timedelta(days=2)),
"ends_at": _iso(_now() - timedelta(days=1)),
},
headers=csrf_headers(client),
)
assert r.status_code == 422
def test_hidden_from_players_registered_after_start(client: TestClient, make_admin, engine):
login(client, "Старожил")
with Session(engine) as s:
old = s.exec(select(User).where(User.nickname == "Старожил")).one()
old.created_at = datetime.now(timezone.utc) - timedelta(days=2)
s.add(old)
s.commit()
_admin_login(client, make_admin)
ann = _create(client, show_to_new_players=False)
assert ann["audience_count"] == 1
assert [a["id"] for a in _pending(client)] == [ann["id"]]
login(client, "Новичок") # зарегистрирован уже после начала показа
assert _pending(client) == []
# Адресаты — по-прежнему только старожил.
assert _admin_item(client, ann["id"])["audience_count"] == 1
def test_reshow_brings_announcement_back_marked_updated(client: TestClient, make_admin):
login(client, "Игрок")
_admin_login(client, make_admin)
ann = _create(client)
assert _ack(client, ann["id"], 1).status_code == 200
# Правка без «показать заново» — закрывшие её не видят.
ann = _update(client, ann, body_html="<p>Сбор в 12:00</p>")
assert ann["revision"] == 1
assert _pending(client) == []
ann = _update(client, ann, body_html="<p>Сбор в 13:00</p>", reshow=True)
assert ann["revision"] == 2
assert ann["seen_count"] == 0 # закрывших новую версию ещё нет
items = _pending(client)
assert [(a["revision"], a["updated"], a["body_html"]) for a in items] == [
(2, True, "<p>Сбор в 13:00</p>")
]
# Пока окно висело, админ выпустил третью версию: закрыв вторую, игрок увидит третью.
ann = _update(client, ann, reshow=True)
assert _ack(client, ann["id"], 2).status_code == 200
assert [(a["revision"], a["updated"]) for a in _pending(client)] == [(3, True)]
assert _ack(client, ann["id"], 3).status_code == 200
assert _pending(client) == []
def test_stop_and_delete(client: TestClient, make_admin, engine):
login(client, "Игрок")
_admin_login(client, make_admin)
live = _create(client)
planned = _create(
client,
starts_at=_iso(_now() + timedelta(days=1)),
ends_at=_iso(_now() + timedelta(days=2)),
)
r = client.post(f"/api/admin/announcements/{planned['id']}/stop", headers=csrf_headers(client))
assert r.status_code == 422 # снять можно только идущее
r = client.post(f"/api/admin/announcements/{live['id']}/stop", headers=csrf_headers(client))
assert r.status_code == 200, r.text
assert r.json()["status"] == "finished"
assert _pending(client) == []
assert _ack(client, live["id"], 1).status_code == 200 # закрыть можно и снятое
r = client.delete(f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client))
assert r.status_code == 200
with Session(engine) as s:
assert s.exec(select(AnnouncementView)).all() == [] # отметки ушли каскадом
assert client.delete(
f"/api/admin/announcements/{live['id']}", headers=csrf_headers(client)
).status_code == 404
assert _ack(client, live["id"], 1).status_code == 404
@pytest.mark.parametrize(
("over", "message_part"),
[
({"title": " "}, "Заголовок"),
({"title": "Я" * 61}, "Заголовок"),
({"body_html": "<p> </p><script>текст</script>"}, "пустым"),
({"body_html": "<p>" + "а" * 601 + "</p>"}, "600"),
({"ends_at": _iso(_now() - timedelta(hours=2))}, "позже начала"),
],
)
def test_validation(client: TestClient, make_admin, over: dict, message_part: str):
_admin_login(client, make_admin)
body = {
"title": "Заголовок",
"body_html": "<p>текст</p>",
"starts_at": _iso(_now() - timedelta(hours=1)),
"ends_at": _iso(_now() + timedelta(days=1)),
}
body.update(over)
r = client.post("/api/admin/announcements", json=body, headers=csrf_headers(client))
assert r.status_code == 422, r.text
err = r.json()["error"]
assert err["code"] == "VALIDATION_ERROR"
assert message_part in err["message"]
+81 -1
View File
@@ -1,6 +1,9 @@
"""Fail-fast конфигурации: production не стартует с дефолтными секретами (#59, F4)."""
"""Fail-fast конфигурации: опубликованное приложение (production и dev на домене) не
стартует с дефолтными секретами (#59, F4, #69)."""
from __future__ import annotations
import logging
import pytest
from pydantic import ValidationError
@@ -61,10 +64,70 @@ def test_production_skips_admin_check_when_bootstrap_disabled():
def test_development_allows_defaults():
s = config.Settings(
app_env="development",
local_public="local",
secret_key=config._DEFAULT_SECRET_KEY,
admin_password=config._DEFAULT_ADMIN_PASSWORD,
)
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", ""])
@@ -77,3 +140,20 @@ def test_unknown_app_env_rejected(app_env):
def test_app_env_case_insensitive():
s = config.Settings(app_env="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
+35 -29
View File
@@ -1,4 +1,5 @@
"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли."""
"""Адресаты событий партии. Рейтинг общий (#80): завершённая партия двигает витрины
всех игроков, поэтому игроки вне группы получают событие ratings (#88)."""
from __future__ import annotations
from fastapi.testclient import TestClient
@@ -16,20 +17,29 @@ def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]:
return published
def _match_events(published: list[tuple[list[int], dict]]) -> list[dict]:
return [e for _ids, e in published if e.get("type") == "match"]
def _recipients(published: list[tuple[list[int], dict]], kind: str) -> set[int]:
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, "Хост")
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"]
def group(name: str) -> int:
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")
# Третий в группе, но НЕ в партии: его история от этой партии не меняется.
p3 = add_group_member(engine, gid, "Зритель")
outsider = add_group_member(engine, other_gid, "Чужой")
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)
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]}],
)
assert started.status_code == 200, started.text
mid = started.json()["id"]
ev = _match_events(published)[-1]
assert sorted(ev["participant_ids"]) == sorted([me["id"], p2])
assert p3 not in ev["participant_ids"]
# Незавершённая партия рейтинг не двигает — знать о ней нужно только группе.
assert _recipients(published, "match") == {me["id"], p2}
assert _recipients(published, "ratings") == set()
published.clear()
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 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):
"""Удаление — главный случай: строки участников уже уничтожены каскадом.
Если собирать их после удаления, список всегда окажется пустым, и клиент не
обновит историю тем, кто в этой партии играл."""
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()]
def test_deleting_finished_match_reaches_players_outside_group(
client: TestClient, engine, monkeypatch
):
"""Удаление завершённой партии пересчитывает рейтинг всех, кто играл после неё."""
me, gid, p2, outsider, fids = _two_groups(client, engine)
mid = start_match(
client, gid,
[{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}],
).json()["id"]
finish_match(client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}])
published = _capture_events(monkeypatch)
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)
)
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")
+23 -3
View File
@@ -68,7 +68,7 @@ EXAMPLES = [
_seat(C, 3, 1, 0, eliminated=True), _seat(D, 3, 0, 0, eliminated=True)),
"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)),
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}),
@@ -101,6 +101,23 @@ def test_last_standing_counts_full_objective_gap():
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"
@@ -147,6 +164,7 @@ def test_constants_match_reference(sim):
assert dict(p.closeness) == scoring.CLOSENESS
assert sim.BOARD_TILES == scoring.BOARD_TILES
assert not p.autocorr
assert p.skip_eliminated_pairs # выбывшие между собой не сравниваются (#91)
@pytest.mark.parametrize("scenario", ["сигнал", "клубы", "рост"])
@@ -174,8 +192,8 @@ def test_replay_matches_reference_season(sim, scenario, stripped):
def test_monotone_and_zero_sum(sim):
"""Победитель без ничьей не теряет, последний без ничьей не получает; при равных K
сумма изменений за партию — ноль."""
"""Победитель без ничьей не теряет, последний без ничьей и любой выбывший не получают;
при равных K сумма изменений за партию — ноль."""
cfg = sim.SCENARIOS["сигнал"]
_skill, matches = sim.generate_season(cfg["seed"] + 7, 200, True)
ids: dict[str, int] = {}
@@ -188,6 +206,8 @@ def test_monotone_and_zero_sum(sim):
delta, _ = rate_match({}, veterans, rm)
places = [s.place 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:
continue
if s.place == 1:
+3 -2
View File
@@ -4,8 +4,9 @@
FROM restic/restic:0.19.1
# sqlite — консистентная копия и проверка БД; supercronic — cron для контейнера без root;
# tini — корректные сигналы. jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini
# tini — корректные сигналы; curl — отчёты и бот в Telegram (#83).
# jq, openssh-client, busybox (wget, flock, tar) уже есть в базе.
RUN apk add --no-cache sqlite supercronic tini curl
# Тот же uid, что у appuser в образе приложения (10001): файлы после restore получают
# правильного владельца, а -wal/-shm SQLite никогда не достаются root.
+55 -5
View File
@@ -737,16 +737,23 @@
```
3. Дождитесь, пока приложение создаст пустую БД (`docker compose ps` → `app` `(healthy)`).
Контейнер `backup` стартует одновременно с `app` и сразу пробует сделать первый бэкап. В его
журнале (`docker compose logs backup`) нормально увидеть одно из двух:
Контейнер `backup` стартует одновременно с `app`, ждёт, пока приложение ответит (до
10 минут), и пробует сделать первый бэкап. В его журнале (`docker compose logs backup`)
нормально увидеть одно из трёх:
- если `backup` успел раньше, чем `app` создал БД:
- если приложение так и не поднялось и БД нет:
```
ОШИБКА: БД /fs-db/forbidden_stars.db не найдена — приложение ещё ни разу не запускалось?
Первый бэкап не удался — следующая попытка по расписанию.
```
- если БД есть, но без таблиц (миграции ещё не прошли):
```
Не удалось прочитать число игроков и партий: в БД нет таблиц users/matches.
```
- если БД уже была:
```
@@ -763,8 +770,8 @@
```
Проверьте, что у выбранного снимка в столбцах `Игроков`/`Партий` числа, а не `0` и не `?`.
Защита выше не распознаёт БД без таблиц: если первый бэкап попал ровно в момент создания
БД, в списке может появиться свежий снимок с `?` (задача #74). Такой снимок не выбирайте.
Снимки без данных защита больше не создаёт, но снимок с `?` мог остаться от старой версии
бэкапов — такой не выбирайте.
5. Восстановите, подставив ID из `list vps`:
@@ -831,6 +838,42 @@ ID одного и того же снимка в `local` и `vps` разные
**Обновить образ бэкапа** (после изменений в `deploy/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` данные
> не трогает.
@@ -852,6 +895,8 @@ docker compose logs --tail 100 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_SSH_KEY_B64 не декодируется из base64` / `— не приватный SSH-ключ` | строка ключа обрезана, с пробелами или от `.pub` | заново скопировать base64 **приватного** ключа (шаг 5, пункт 5), одной строкой |
| `Репозиторий vps недоступен` и выше `Permission denied (publickey)` | на VPS нет публичного ключа или ключ другой | шаг 3, пункты 4 и 9: проверить `authorized_keys` и вход `sftp` с ПК этим ключом |
@@ -920,6 +965,9 @@ docker volume rm <имя тома>
| `recover` | разбор прерванного восстановления |
| `export <ID\|latest> [--repo …]` | снимок в tar в stdout: `docker compose exec -T backup fs-backup export latest > fs.tar` |
| `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` | краткая справка |
| `init`, `info`, `health`, `has-snapshots` | служебные: создать репозитории, данные снимка для скриптов ПК, healthcheck, проверка «есть ли снимки» при старте |
@@ -955,6 +1003,8 @@ docker volume rm <имя тома>
| `BACKUP_VPS_PORT` | Pi | `22` | SSH-порт VPS |
| `BACKUP_VPS_DIR` | Pi | `/srv/fs-backups/restic` | папка репозитория на VPS |
| `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_PI_SSH` | ПК | — | как зайти на Pi: `pi@<IP>` |
| `BACKUP_PI_DIR` | ПК | `~/forbidden-stars` | папка прода на Pi |
+23 -1
View File
@@ -5,6 +5,14 @@ set -eu
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-петля и спам в логах) — ждём,
# пока пароль появится в .env; healthcheck при этом показывает unhealthy.
if [ -z "${BACKUP_PASSWORD:-}" ]; then
@@ -29,7 +37,21 @@ if [ ! -s "$CRONTAB" ]; then
fi
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 "Первый бэкап не удался — следующая попытка по расписанию."
fi
+243 -4
View File
@@ -34,11 +34,15 @@ APP_HOST="${BACKUP_APP_HOST:-app}"
export RESTIC_PASSWORD="${BACKUP_PASSWORD:-}"
export RESTIC_COMPRESSION="${BACKUP_COMPRESSION:-max}"
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-потоком.
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() {
[ -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"; }
# ─── Telegram (#83) ───────────────────────────────────────────────────────────
# Отчёты о бэкапах и проверках — в чаты BACKUP_TELEGRAM_CHAT_ID (через запятую) от бота
# BACKUP_TELEGRAM_BOT_TOKEN. Не настроено — молчим. Telegram недоступен — бэкап от этого не
# страдает: неудача отправки только пишется в лог. Токен не попадает ни в лог, ни в текст.
tg_enabled() { [ -n "$TG_TOKEN" ] && [ -n "$TG_CHATS" ]; }
tg_escape() { sed -e 's/&/\&amp;/g' -e 's/</\&lt;/g' -e 's/>/\&gt;/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 не создаются).
db_ok() {
[ -s "$1" ] || return 1
@@ -178,6 +275,17 @@ forget_repo() {
# Защита истории от пустых данных: новый Pi до восстановления или случайно очищенная БД не
# должны становиться «последним снимком» (restore latest вернул бы пустоту).
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
for _repo in $(repos); do
_prev="$(r "$_repo" snapshots latest --host "$SNAP_HOST" --json 2>/dev/null | jq -r \
@@ -236,11 +344,18 @@ cmd_run() {
esac
shift
done
_players="?"
_matches="?"
_ok_repos=""
_failed=""
# Отчёт в Telegram уходит при любом исходе, в том числе при раннем отказе (занят lock,
# нет пароля). Копию БД убираем только после take_lock: до него она может быть чужой.
trap '_rc=$?; notify_run "$_rc"' EXIT
require_password
setup_ssh
take_lock
warn_leftovers
trap 'rm -f "$SNAP_ROOT/$DB_NAME"' EXIT
trap '_rc=$?; rm -f "$SNAP_ROOT/$DB_NAME"; notify_run "$_rc"' EXIT
log "Снимок данных: консистентная копия БД…"
stage_db
@@ -252,7 +367,6 @@ cmd_run() {
log "БД в порядке: игроков $_players, партий $_matches."
if [ "$_allow_empty" = no ]; then guard_empty "$_players" "$_matches"; fi
_failed=""
for _repo in $(repos); do
log "=== Репозиторий $_repo ($(repo_url "$_repo")) ==="
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
_sid="$(latest_short_id "$_repo")"
mark "run-$_repo" ok "снимок $_sid"
_ok_repos="$_ok_repos $_repo:$_sid"
log "OK: репозиторий $_repo, снимок $_sid."
else
mark "run-$_repo" err "бэкап/очистка/проверка не удались — см. docker compose logs backup"
@@ -365,10 +480,12 @@ cmd_has_snapshots() {
# ─── verify ───────────────────────────────────────────────────────────────────
cmd_verify() {
_verified=""
_failed=""
trap '_rc=$?; notify_verify "$_rc"' EXIT
require_password
setup_ssh
take_lock
_failed=""
for _repo in $(repos); do
log "=== Проверка репозитория $_repo: структура + $VERIFY_SUBSET данных ==="
_tmp="$RUNTIME_DIR/verify.db"
@@ -378,6 +495,7 @@ cmd_verify() {
&& db_ok "$_tmp"; then
_counts="$(db_counts "$_tmp")"
mark "verify-$_repo" ok "данные целы; последний снимок: игроков ${_counts%%|*}, партий ${_counts##*|}"
_verified="$_verified $_repo:${_counts%%|*}:${_counts##*|}"
log "OK: $_repo — данные целы, БД последнего снимка открывается (игроков ${_counts%%|*}, партий ${_counts##*|})."
else
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
}
# ─── 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() {
cat <<'EOF'
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
снимок в tar без сжатия (нужен exec -T)
Telegram (отчёты о каждом бэкапе и проверке, команды /backups и /status):
telegram chats id чатов, писавших боту (для BACKUP_TELEGRAM_CHAT_ID)
telegram test пробное сообщение в чаты BACKUP_TELEGRAM_CHAT_ID
telegram bot бот-слушатель команд (контейнер запускает его сам)
Прочее:
init создать репозитории (делается автоматически)
restic <local|vps> <аргументы> произвольная команда restic с настройками контейнера
@@ -833,6 +1071,7 @@ case "$_cmd" in
restic) cmd_restic "$@" ;;
health) cmd_health "$@" ;;
has-snapshots) cmd_has_snapshots "$@" ;;
telegram) cmd_telegram "$@" ;;
help|-h|--help) usage ;;
*) usage >&2; exit 2 ;;
esac
+41 -3
View File
@@ -35,6 +35,32 @@ newgrp docker # применить группу
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-ключ для туннеля
**Что это.** Отдельная пара ключей **только для туннеля** — ею контейнер `tunnel` логинится на
`tunnel@VPS`, чтобы открыть `ssh -R`. Это не системный ключ Pi, ты создаёшь его сам. Распределение:
@@ -92,9 +118,18 @@ IMAGE_TAG=latest
> В production приложение **не стартует**, если `SECRET_KEY` дефолтный или короче 32
> символов, а `ADMIN_PASSWORD` пустой или дефолтный (при `ADMIN_BOOTSTRAP_ENABLED=true`).
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются **только при первом создании** админа: если
> потом поменять их в `.env`, у существующего админа ничего не изменится, а штатной смены
> пароля админа на проде пока нет (задача #73).
> `ADMIN_USERNAME`/`ADMIN_PASSWORD` применяются автоматически **только при первом создании**
> админа: если потом поменять их в `.env`, у существующего админа само ничего не изменится.
>
> **Смена пароля админа** (плановая или при утечке) — осознанной командой, через панель
> его не сменить:
> 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. Запуск
```bash
@@ -145,3 +180,6 @@ docker compose exec backup fs-backup list # хронология снимк
слот 9000 → на VPS `sudo fuser -k 9000/tcp`, затем `docker compose restart tunnel`).
- `pull` не проходит → проверь `docker login gitea.arseniev.info` и что реестр по HTTPS с валидным
сертификатом (иначе хост в `/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 -1
View File
@@ -1,7 +1,7 @@
# Caddy на VPS (186.246.51.17) — единственная публичная точка входа.
# Два домена, ОБА с твоими сертификатами; проксируют в 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
#
# Caddy сам терминирует TLS (он и есть edge: видит реального клиента), а вниз к
+11 -5
View File
@@ -21,8 +21,8 @@
services:
app:
build: .
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.sh) и тянется
# на Pi через `docker compose pull`. build: оставлен как локальный фолбэк (сборка на Pi).
# Образ из реестра (Gitea): собирается под arm64 на ПК (scripts/build-push.ps1, в Linux —
# .sh) и тянется на Pi. build: нужен только этой сборке на ПК — на Pi не используется.
image: ${IMAGE_REGISTRY:-gitea.arseniev.info/notbigghost}/forbidden-stars:${IMAGE_TAG:-latest}
pull_policy: always # на Pi всегда тянем образ из реестра (без сборки)
restart: unless-stopped
@@ -42,7 +42,9 @@ services:
timeout: 5s
retries: 3
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}
security_opt:
- no-new-privileges:true
@@ -75,8 +77,8 @@ services:
# Бэкапы (restic): снимки по расписанию в локальный репозиторий (том backup-data) и на VPS
# (SFTP, если задан BACKUP_VPS_HOST). От app не зависит и app не мешает. Переменные —
# только BACKUP_* (секреты приложения сюда не передаются). Всё про настройку и
# восстановление — deploy/backup/README.md.
# только BACKUP_* (секреты приложения сюда не передаются; исключение — токен бота для
# отчётов в Telegram). Всё про настройку и восстановление — deploy/backup/README.md.
backup:
build: ./deploy/backup
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_DIR: ${BACKUP_VPS_DIR:-/srv/fs-backups/restic}
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}
volumes:
- db-data:/fs-db # живая БД (снимается консистентно)
+42 -22
View File
@@ -26,8 +26,11 @@
влияет, но слабее темпа.
- **Шкала — классический Elo.** Старт 1500, разница 400 пунктов — шансы 10:1. Пример из
задачи «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. Фактический результат пары
`S_ab = 1`, если `a` занял место выше `b`; `0.5`, если места равны; `0` — если ниже.
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`).
Выбывшие делят последнее место, как и сейчас (`match_service._resolve_finish_places`),
но между собой не сравниваются (4.4).
### 4.4. Изменение рейтинга
@@ -170,6 +174,14 @@ E_{ab} = \frac{1}{1 + 10^{(R_b - R_a)/D}}
Все рейтинги в формуле — **до** партии. Деление на `N − 1` приводит сумму по соперникам
к «средней дуэли», размер стола затем добавляется явно через `G(N)`.
**Выбывшие между собой не сравниваются** (решение владельца, #91): если `i` и `j` оба
выбыли, их пара в сумму не входит — ни `S − E`, ни множитель отрыва. В этой партии они
все проиграли, то есть оказались одинаково слабы, а миров у выбывших нет. Пары выбывшего
с невыбывшими, в том числе с победителем, считаются как обычно: сила соперников важна.
Нормировка на `N − 1` не меняется, поэтому при равных рейтингах результат прежний — такая
пара и раньше давала `S − E = 0`. Уходит только перекос, при котором слабый выбывший
получал рейтинг за счёт сильных выбывших.
### 4.5. Коэффициент K — скорость изменения
```math
@@ -236,7 +248,10 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
### 4.9. Свойства
- **Монотонность.** 1-е место без ничьей даёт `S − E > 0` во всех парах, а `M > 0` — значит,
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг.
рейтинг растёт. Последнее место без ничьей всегда уменьшает рейтинг. Выбывший — тоже:
его пары с выбывшими не считаются, а каждому невыбывшему он проиграл. Внутри ничьей
невыбывших `S = 0.5`, и знак `S − E` зависит от рейтингов: сильный игрок, поделивший
1-е место со слабым, может потерять.
- **Сумма-ноль.** `M_ab = M_ba`, поэтому при равных K сумма изменений за партию равна нулю
и рейтинг не раздувается. Когда K разные (новичок и ветеран), сумма не нулевая —
это сделано намеренно (пример 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 |
| 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 |
| 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_B = 6.67 · (−0.300 + 0.589 + 0.589)` = **+5.85**
- `ΔR_C = 6.67 · (−0.551 − 0.589 − 0.027)` = **−7.78**
- `ΔR_D = 6.67 · (−0.540 − 0.589 + 0.027)` = **−7.35**
- `ΔR_C = 6.67 · (−0.551 − 0.589)` = **−7.60**
- `ΔR_D = 6.67 · (−0.540 − 0.589)` = **−7.53**
Итого: A и B близки друг к другу по целям и мирам, поэтому эта пара весит 0.7. Отрыв обоих
от выбывших огромный — эти пары весят 1.25–1.5. C и D поделили место, но C сильнее по
рейтингу, поэтому немного уступает D.
от выбывших огромный — эти пары весят 1.25–1.5. C и D между собой не сравниваются: оба
проиграли всем невыбывшим. C теряет чуть больше, потому что от более сильного ждали большего.
### Пример 6. Стол на 6 и хоумрул 9 раундов
@@ -435,7 +450,9 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
### 7.3. Результаты: 200 сезонов на сценарий
«Без новых полей» — предложенная система на той же истории, но без раунда, целей и миров:
так будет считаться история, накопленная до #23.
так будет считаться история, накопленная до #23. Строки предложенной системы пересчитаны
с правилом «выбывшие между собой не сравниваются» (#91). Оно сдвинуло метрики лишь
в третьем знаке, у остальных систем цифры прежние.
**Сигнал** (потолок точности 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 |
| 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.6701 | 0.2085 | 0.916 | 0.677 | 0.782 | 0.864 | 47.4 | 0.79 | 5.90 |
| **Предложенная** | **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.783 | 0.864 | 47.2 | 0.79 | 5.90 |
**Клубы** (потолок 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 |
| 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.6074 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.534 | 103.3 | 0.38 | 6.41 |
| **Предложенная** | **0.6101** | 0.2336 | **0.645** | **0.356** | **0.463** | **0.555** | **100.3** | **0.47** | 6.53 |
| Предложенная, без новых полей | 0.6073 | **0.2332** | 0.633 | 0.336 | 0.440 | 0.535 | 103.3 | 0.38 | 6.41 |
**Рост** (потолок 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 |
| 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.6697 | 0.2081 | 0.922 | 0.651 | 0.750 | 0.835 | 47.6 | 0.79 | 5.91 |
| **Предложенная** | **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.5 | 0.79 | 5.91 |
**Шум** (потолок 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 |
| 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.6743** | **0.2070** | **0.919** | **0.667** | **0.775** | **0.856** | 47.4 | 0.78 | 5.88 |
| Предложенная | 0.6728 | 0.2076 | 0.908 | 0.643 | 0.750 | 0.844 | 48.5 | 0.81 | 5.91 |
| Предложенная, без новых полей | **0.6744** | **0.2070** | **0.919** | **0.667** | **0.776** | **0.856** | 47.3 | 0.78 | 5.88 |
Парные разности (среднее ± стандартная ошибка по 200 сезонам):
| Сценарий | Brier: предложенная − чистый Elo | Точность: предложенная − чистый Elo | Точность: предложенная − сейчас |
|---|---|---|---|
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.37 ± 0.07 п.п. |
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.57 ± 0.10 п.п. |
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.07 п.п. | +0.37 ± 0.06 п.п. |
| сигнал | −0.0011 ± 0.0001 | +0.30 ± 0.06 п.п. | +0.38 ± 0.07 п.п. |
| клубы | −0.0005 ± 0.0002 | +0.38 ± 0.08 п.п. | +0.56 ± 0.10 п.п. |
| рост | −0.0010 ± 0.0001 | +0.39 ± 0.06 п.п. | +0.38 ± 0.06 п.п. |
| шум | +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): сильные игроки быстрее
отрываются от середняков.
2. **Сила соперников — главное преимущество Elo над текущей системой.** В «клубах» текущая
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.644): чемпион слабой группы
система упорядочивает игроков заметно хуже (ρ 0.525 против 0.645): чемпион слабой группы
у неё стоит рядом с чемпионом сильной.
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 сезонах каждого сценария.
Критерий — средний Brier, меньше — лучше. Разница в 0.0001 — примерно граница шума.
Перебор выполнен до правила «выбывшие между собой не сравниваются» (#91) и не
переигрывался: правило сдвигает метрики лишь в третьем знаке (7.3).
**Этап 1. K чистого Elo** (все четыре сценария). Спуск K за 20 партий лучше, чем за 10.
Выгоден высокий K новичка и низкий K ветерана.
@@ -732,6 +751,7 @@ M_{ab} = \operatorname{clamp}(A_{ab},\; 0.5,\; 2.0) \cdot c_{ab}
| 5 | Затухание за неактивность | **Не добавлять** | без изменений |
| 6 | Минимум партий для топа | **Оставить 10** | без изменений: `MIN_GAMES = 10` |
| 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, «Калибровка»).
+8 -2
View File
@@ -93,6 +93,9 @@ class Params:
m_min: float = 0.5
m_max: float = 2.0
autocorr: bool = False # поправка на автокорреляцию (см. документ)
# Выбывшие между собой не сравниваются: пара двух выбывших не входит в сумму
# (решение владельца, #91). У систем для сравнения — как было.
skip_eliminated_pairs: bool = False
def closeness_for(self, reason: str | None) -> float:
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}
delta = {s.player: 0.0 for s in m.seats}
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:
a, b = b, a
s_ab = 0.5 if a.place == b.place else 1.0
@@ -550,6 +555,7 @@ PROPOSED = Params(
w_obj=0.5,
w_worlds=0.5,
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 миров",
{"A": 1500, "B": 1500}, _vets("A", "B"),
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"),
Match(
[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},
"4d": {"A": 3.4, "B": -3.4},
"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},
"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},
+19 -1
View File
@@ -1,15 +1,33 @@
import { QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { RouterProvider } from "react-router-dom";
import { queryClient } from "./app/queryClient";
import { router } from "./app/router";
import { Spinner } from "./components/Spinner";
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() {
return (
<QueryClientProvider client={queryClient}>
<ToastProvider>
<RouterProvider router={router} />
<AppTimeZone>
<RouterProvider router={router} />
</AppTimeZone>
</ToastProvider>
</QueryClientProvider>
);
+21 -4
View File
@@ -1,3 +1,5 @@
import type { QueryClient } from "@tanstack/react-query";
export const qk = {
me: ["me"] as const,
adminMe: ["adminMe"] as const,
@@ -19,19 +21,22 @@ export const qk = {
authConfig: ["authConfig"] as const,
invitations: ["invitations"] as const,
notifications: ["notifications"] as const,
announcements: ["announcements"] as const,
devUsers: ["devUsers"] as const,
adminUsers: ["adminUsers"] as const,
adminGroups: ["adminGroups"] as const,
adminMatches: ["adminMatches"] as const,
adminLogs: ["adminLogs"] as const,
adminAchievements: ["adminAchievements"] as const,
adminAnnouncements: ["adminAnnouncements"] as const,
};
/**
* Ключи, которые протухают от любой партии: конкретных участников мы не знаем
* (событие приходит на всю группу), поэтому инвалидируем по префиксу. Один
* список на SSE-обработчик и на завершение партии — иначе переименование ключа
* в этом файле тихо разойдётся с местами, где он написан строкой.
* Ключи, которые протухают от любой завершённой партии: рейтинг общий и считается по
* всей истории (#80), так что партия двигает топ, историю и профили всех, кто играл
* после неё. Поэтому инвалидируем по префиксу. Один список на SSE-обработчик и на
* мутации партии — иначе переименование ключа тихо разойдётся с местами, где он
* написан строкой.
*/
export const matchAffectedKeys = [
qk.home,
@@ -40,3 +45,15 @@ export const matchAffectedKeys = [
["userMatches"],
["publicProfile"],
] 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",
});
}
+397
View File
@@ -842,6 +842,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/announcements/pending": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Pending */
get: operations["pending_api_announcements_pending_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/announcements/{announcement_id}/ack": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/** Acknowledge */
post: operations["acknowledge_api_announcements__announcement_id__ack_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/auth/login": {
parameters: {
query?: never;
@@ -1140,6 +1174,62 @@ export interface paths {
patch?: never;
trace?: never;
};
"/api/admin/announcements": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** List Announcements */
get: operations["list_announcements_api_admin_announcements_get"];
put?: never;
/** Create Announcement */
post: operations["create_announcement_api_admin_announcements_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/announcements/{announcement_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Update Announcement */
put: operations["update_announcement_api_admin_announcements__announcement_id__put"];
post?: never;
/** Delete Announcement */
delete: operations["delete_announcement_api_admin_announcements__announcement_id__delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/announcements/{announcement_id}/stop": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Stop Announcement
* @description «Снять с показа»: период идущего объявления заканчивается сейчас.
*/
post: operations["stop_announcement_api_admin_announcements__announcement_id__stop_post"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/admin/audit-logs": {
parameters: {
query?: never;
@@ -1277,6 +1367,36 @@ export interface components {
/** Group Id */
group_id?: number | null;
};
/** AdminAnnouncementRead */
AdminAnnouncementRead: {
/** Id */
id: number;
/** Title */
title: string;
/** Body Html */
body_html: string;
/** Starts At */
starts_at: string;
/** Ends At */
ends_at: string;
/** Show To New Players */
show_to_new_players: boolean;
/** Revision */
revision: number;
/**
* Status
* @enum {string}
*/
status: "planned" | "live" | "finished";
/** Seen Count */
seen_count: number;
/** Audience Count */
audience_count: number;
/** Created At */
created_at: string;
/** Updated At */
updated_at: string;
};
/** AdminGroupRead */
AdminGroupRead: {
/** Id */
@@ -1361,6 +1481,76 @@ export interface components {
/** Is Active */
is_active?: boolean | null;
};
/** AnnouncementAck */
AnnouncementAck: {
/** Revision */
revision: number;
};
/** AnnouncementRead */
AnnouncementRead: {
/** Id */
id: number;
/** Title */
title: string;
/** Body Html */
body_html: string;
/** Revision */
revision: number;
/**
* Updated
* @default false
*/
updated: boolean;
};
/** AnnouncementUpdate */
AnnouncementUpdate: {
/** Title */
title: string;
/** Body Html */
body_html: string;
/**
* Starts At
* Format: date-time
*/
starts_at: string;
/**
* Ends At
* Format: date-time
*/
ends_at: string;
/**
* Show To New Players
* @default true
*/
show_to_new_players: boolean;
/**
* Reshow
* @default false
*/
reshow: boolean;
};
/** AnnouncementWrite */
AnnouncementWrite: {
/** Title */
title: string;
/** Body Html */
body_html: string;
/**
* Starts At
* Format: date-time
*/
starts_at: string;
/**
* Ends At
* Format: date-time
*/
ends_at: string;
/**
* Show To New Players
* @default true
*/
show_to_new_players: boolean;
};
/** AttachmentRead */
AttachmentRead: {
/** Id */
@@ -1420,6 +1610,8 @@ export interface components {
methods: string[];
/** Telegram Bot Username */
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: {
@@ -3980,6 +4172,61 @@ export interface operations {
};
};
};
pending_api_announcements_pending_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AnnouncementRead"][];
};
};
};
};
acknowledge_api_announcements__announcement_id__ack_post: {
parameters: {
query?: never;
header?: never;
path: {
announcement_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AnnouncementAck"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
admin_login_api_admin_auth_login_post: {
parameters: {
query?: never;
@@ -4676,6 +4923,156 @@ export interface operations {
};
};
};
list_announcements_api_admin_announcements_get: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminAnnouncementRead"][];
};
};
};
};
create_announcement_api_admin_announcements_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AnnouncementWrite"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminAnnouncementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_announcement_api_admin_announcements__announcement_id__put: {
parameters: {
query?: never;
header?: never;
path: {
announcement_id: number;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["AnnouncementUpdate"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminAnnouncementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_announcement_api_admin_announcements__announcement_id__delete: {
parameters: {
query?: never;
header?: never;
path: {
announcement_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["OkResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
stop_announcement_api_admin_announcements__announcement_id__stop_post: {
parameters: {
query?: never;
header?: never;
path: {
announcement_id: number;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AdminAnnouncementRead"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
audit_logs_api_admin_audit_logs_get: {
parameters: {
query?: {
+2
View File
@@ -14,6 +14,7 @@ import { OverallStatsPage } from "../pages/OverallStatsPage";
import { PublicProfilePage } from "../pages/PublicProfilePage";
import { AdminAccountsPage } from "../pages/admin/AdminAccountsPage";
import { AdminAchievementsPage } from "../pages/admin/AdminAchievementsPage";
import { AdminAnnouncementsPage } from "../pages/admin/AdminAnnouncementsPage";
import { AdminFactionsPage } from "../pages/admin/AdminFactionsPage";
import { AdminLayout } from "../pages/admin/AdminLayout";
import { AdminLogsPage } from "../pages/admin/AdminLogsPage";
@@ -70,6 +71,7 @@ export const router = createBrowserRouter([
{ path: "factions", element: <AdminFactionsPage /> },
{ path: "achievements", element: <AdminAchievementsPage /> },
{ path: "logs", element: <AdminLogsPage /> },
{ path: "announcements", element: <AdminAnnouncementsPage /> },
],
},
{ path: "*", element: <Navigate to="/" replace /> },
@@ -0,0 +1,40 @@
import { useEffect, useState } from "react";
import { useAckAnnouncement, usePendingAnnouncements } from "../hooks/announcements";
import { AnnouncementWindow } from "./AnnouncementWindow";
/**
* Очередь объявлений администрации поверх любой страницы: по одному, от старого
* к новому, следующее — после «Понятно» на предыдущем. AppShell монтирует её только
* когда пароль задан: обязательное окно пароля всегда первое.
*/
export function AnnouncementDialog() {
const { data } = usePendingAnnouncements(true);
const ack = useAckAnnouncement();
// Сколько уже закрыто в этой очереди — для точек «2 из 3». Закрытые сразу уходят
// из данных запроса, так что без счётчика точки показывали бы только остаток.
const [closed, setClosed] = useState(0);
const items = data ?? [];
useEffect(() => {
if (items.length === 0) setClosed(0);
}, [items.length]);
if (items.length === 0) return null;
const current = items[0];
return (
<AnnouncementWindow
key={current.id}
title={current.title}
bodyHtml={current.body_html}
updated={current.updated}
index={closed}
total={closed + items.length}
onOk={() => {
setClosed((c) => c + 1);
ack.mutate({ id: current.id, revision: current.revision });
}}
/>
);
}
@@ -0,0 +1,50 @@
import { useId } from "react";
/**
* Окно объявления администрации (вариант E из #84): заголовок — в плашке-шапке, ниже текст,
* точки очереди и «Понятно». Крестика нет намеренно: закрытие — только осознанное.
*
* bodyHtml у игрока — HTML, очищенный сервером по белому списку (announcement_service),
* поэтому вставляется как есть. В предпросмотре админки — содержимое его же редактора.
*/
export function AnnouncementWindow({
title,
bodyHtml,
updated = false,
index = 0,
total = 1,
onOk,
}: {
title: string;
bodyHtml: string;
updated?: boolean;
index?: number;
total?: number;
onOk: () => void;
}) {
const titleId = useId();
return (
<div className="modal-overlay">
<div className="modal ann-modal" role="dialog" aria-modal="true" aria-labelledby={titleId}>
<div className="ann-ribbon">
<div className="ann-ribbon-title" id={titleId}>
{title}
</div>
{updated && <div className="ann-updated">обновлено</div>}
</div>
<div className="ann-body" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
<div className="modal-actions ann-actions">
<div className="ann-dots" aria-hidden="true">
{total > 1 &&
Array.from({ length: total }, (_, k) => (
<i key={k} className={k <= index ? "on" : undefined} />
))}
</div>
<button className="btn btn-primary" onClick={onOk} autoFocus>
Понятно
</button>
</div>
</div>
</div>
);
}
+3
View File
@@ -3,6 +3,7 @@ import { Outlet, useLocation, useNavigate } from "react-router-dom";
import { useMe } from "../hooks/auth";
import { useServerEvents } from "../hooks/useServerEvents";
import { AnnouncementDialog } from "./AnnouncementDialog";
import { BottomBar } from "./BottomBar";
import { NotificationBell } from "./NotificationBell";
import { NotificationToaster } from "./NotificationToaster";
@@ -55,6 +56,8 @@ export function AppShell() {
{/* Без пароля дальше не пускаем: логин = ник, пароль нужен для входа. */}
{me && !me.has_password && <SetPasswordDialog nickname={me.nickname} />}
{/* Объявления администрации — только после окна пароля, на любой странице. */}
{me?.has_password && <AnnouncementDialog />}
</div>
);
}
+3 -10
View File
@@ -2,6 +2,7 @@ import { Scissors } from "lucide-react";
import { useRef } from "react";
import { Avatar } from "./Avatar";
import { MAX_COUNT, parseCount } from "../domain/matchCounts";
export interface PlacePlayer {
user_id: number;
@@ -241,19 +242,11 @@ export function PlaceEditor({
type="number"
inputMode="numeric"
min={0}
max={99}
max={MAX_COUNT}
placeholder="—"
disabled={locked}
value={locked ? 0 : (counts?.[userId]?.[field] ?? "")}
onChange={(e) => {
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))),
);
}}
onChange={(e) => onCount?.(userId, field, parseCount(e.target.value))}
/>
</label>
);
+28 -5
View File
@@ -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");
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в +3
// и форматируем по UTC-частям, чтобы получить «настенное» время МСК.
// iso — корректный момент (бэкенд отдаёт UTC со смещением). Сдвигаем в пояс приложения
// и форматируем по UTC-частям, чтобы получить «настенное» время этого пояса.
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, "игрок", "игрока", "игроков").
@@ -50,6 +57,22 @@ export function formatDate(iso: string | null | undefined): string {
return m ? `${m[3]}.${m[2]}.${m[1]}` : iso;
}
// Значение <input type="datetime-local"> — «настенное» время в том же поясе приложения, что
// и всё отображение: админ вводит время показа объявления в нём, где бы ни был браузер.
export function toAppLocalInput(iso: string): string {
const d = shifted(iso);
return `${d.getUTCFullYear()}-${p2(d.getUTCMonth() + 1)}-${p2(d.getUTCDate())}T${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`;
}
// Обратно: «настенное» время пояса приложения из datetime-local → момент ISO (UTC).
// null — поле пустое.
export function fromAppLocalInput(value: string): string | null {
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})/.exec(value);
if (!m) return null;
const ms = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]) - appTzOffsetMin * 60_000;
return new Date(ms).toISOString();
}
export function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "—";
const d = shifted(iso);
+10
View File
@@ -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)));
}
+4
View File
@@ -34,3 +34,7 @@ export type MatchUpdate = S["MatchUpdate"];
export type MatchCreate = S["MatchCreate"];
export type MatchFinish = S["MatchFinish"];
export type MatchFinishDraftData = S["MatchFinishDraftData"];
export type AnnouncementRead = S["AnnouncementRead"];
export type AdminAnnouncementRead = S["AdminAnnouncementRead"];
export type AnnouncementWrite = S["AnnouncementWrite"];
export type AnnouncementUpdate = S["AnnouncementUpdate"];
+60 -1
View File
@@ -2,7 +2,12 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, authProbeRetry, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AdminMe, MatchUpdate } from "../domain/types";
import type {
AdminMe,
AnnouncementUpdate,
AnnouncementWrite,
MatchUpdate,
} from "../domain/types";
import { uploadFile } from "../lib/upload";
export function useAdminMe() {
@@ -282,3 +287,57 @@ export function useUploadAchievementIcon() {
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAchievements }),
});
}
// ─── Объявления ──────────────────────────────────────────────────────────────
export function useAdminAnnouncements() {
return useQuery({
queryKey: qk.adminAnnouncements,
queryFn: async () => unwrap(await api.GET("/api/admin/announcements")),
});
}
function useAnnouncementMutation<A, R>(fn: (args: A) => Promise<R>) {
const qc = useQueryClient();
return useMutation({
mutationFn: fn,
onSuccess: () => qc.invalidateQueries({ queryKey: qk.adminAnnouncements }),
});
}
export function useAdminCreateAnnouncement() {
return useAnnouncementMutation(async (body: AnnouncementWrite) =>
unwrap(await api.POST("/api/admin/announcements", { body })),
);
}
export function useAdminUpdateAnnouncement() {
return useAnnouncementMutation(async (args: { id: number; body: AnnouncementUpdate }) =>
unwrap(
await api.PUT("/api/admin/announcements/{announcement_id}", {
params: { path: { announcement_id: args.id } },
body: args.body,
}),
),
);
}
export function useAdminStopAnnouncement() {
return useAnnouncementMutation(async (id: number) =>
unwrap(
await api.POST("/api/admin/announcements/{announcement_id}/stop", {
params: { path: { announcement_id: id } },
}),
),
);
}
export function useAdminDeleteAnnouncement() {
return useAnnouncementMutation(async (id: number) =>
unwrap(
await api.DELETE("/api/admin/announcements/{announcement_id}", {
params: { path: { announcement_id: id } },
}),
),
);
}
+40
View File
@@ -0,0 +1,40 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { qk } from "../api/queryKeys";
import type { AnnouncementRead } from "../domain/types";
// Объявления администрации, которые игрок ещё не закрыл, — от старого к новому.
export function usePendingAnnouncements(enabled: boolean) {
return useQuery({
queryKey: qk.announcements,
queryFn: async () => unwrap(await api.GET("/api/announcements/pending")),
enabled,
// Новое и изменённое объявление приходит SSE-событием, а вот наступление отложенного
// начала показа — не событие в данных. Его подхватываем периодическим перезапросом.
refetchInterval: 5 * 60_000,
});
}
// «Понятно»: объявление помечается закрытым в той версии, которую игрок видел.
export function useAckAnnouncement() {
const qc = useQueryClient();
return useMutation({
mutationFn: async (a: { id: number; revision: number }) =>
unwrap(
await api.POST("/api/announcements/{announcement_id}/ack", {
params: { path: { announcement_id: a.id } },
body: { revision: a.revision },
}),
),
// Закрытое убираем из очереди сразу — следующее открывается, не дожидаясь сети.
onMutate: async (a) => {
await qc.cancelQueries({ queryKey: qk.announcements });
qc.setQueryData<AnnouncementRead[]>(qk.announcements, (old) =>
old?.filter((x) => x.id !== a.id),
);
},
// Если отметка не записалась, объявление вернётся с перезапросом, а не пропадёт молча.
onSettled: () => qc.invalidateQueries({ queryKey: qk.announcements }),
});
}
+4 -6
View File
@@ -1,7 +1,7 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { api, unwrap } from "../api/client";
import { matchAffectedKeys, qk } from "../api/queryKeys";
import { invalidateRatingViews, qk } from "../api/queryKeys";
import type {
FactionRead,
MatchCreate,
@@ -83,8 +83,7 @@ export function useFinishMatch() {
onSuccess: (m) => {
qc.invalidateQueries({ queryKey: qk.match(m.id) });
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
invalidateRatingViews(qc);
},
});
}
@@ -103,9 +102,8 @@ export function useUpdateMatch() {
onSuccess: (m) => {
qc.setQueryData(qk.match(m.id), m);
qc.invalidateQueries({ queryKey: qk.groupMatches(m.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(m.group_id) });
// Места изменились — значит изменились лидерборд, история и профили.
for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
// Места изменились — значит изменились рейтинги, топ, истории и профили.
invalidateRatingViews(qc);
},
});
}
+18 -30
View File
@@ -1,15 +1,20 @@
import { useQueryClient } from "@tanstack/react-query";
import { useEffect, useRef } from "react";
import { useEffect } from "react";
import { matchAffectedKeys, qk } from "../api/queryKeys";
import { useMe } from "./auth";
import { invalidateRatingViews, qk } from "../api/queryKeys";
interface ServerEvent {
type: "match" | "match_draft" | "group" | "invitations" | "notifications";
/** ratings — завершённая партия чужой группы сдвинула общий рейтинг (#88). */
type:
| "match"
| "match_draft"
| "ratings"
| "group"
| "invitations"
| "notifications"
| "announcements";
match_id?: number;
group_id?: number;
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
participant_ids?: number[];
}
/**
@@ -19,11 +24,6 @@ interface ServerEvent {
*/
export function useServerEvents(enabled: boolean) {
const qc = useQueryClient();
const { data: me } = useMe();
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
// SSE-соединение каждый раз, когда профиль перезапрашивается.
const myId = useRef<number | null>(null);
myId.current = me?.id ?? null;
useEffect(() => {
if (!enabled) return;
const base = import.meta.env.VITE_API_BASE_URL || "";
@@ -40,6 +40,8 @@ export function useServerEvents(enabled: boolean) {
qc.invalidateQueries({ queryKey: qk.invitations });
} else if (ev.type === "notifications") {
qc.invalidateQueries({ queryKey: qk.notifications });
} else if (ev.type === "announcements") {
qc.invalidateQueries({ queryKey: qk.announcements });
} else if (ev.type === "match_draft") {
// Черновик меняется на каждое движение тайла: трогаем только саму партию,
// без лидерборда и историй — иначе группа захлебнётся перезапросами.
@@ -48,26 +50,12 @@ export function useServerEvents(enabled: boolean) {
if (ev.match_id != null) qc.invalidateQueries({ queryKey: qk.match(ev.match_id) });
if (ev.group_id != null) {
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") {
if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });
+5 -2
View File
@@ -54,7 +54,8 @@ export function HelpPage() {
<p className="small" style={{ marginTop: 10 }}>
где <b>N</b> — число игроков, сумма — по всем соперникам. Поэтому победа над сильным
приносит больше, чем над слабым, а поражение от слабого отнимает больше. Выбывшие
делят последнее место.
делят последнее место, но между собой не сравниваются: в этой партии все они
проиграли, так что пара двух выбывших рейтинг не двигает.
</p>
</div>
@@ -93,7 +94,9 @@ export function HelpPage() {
с правилом группы «9 раундов» при 5–6 игроках — 9.
</p>
<p className="small" style={{ marginBottom: 0 }}>
Победитель никогда не теряет рейтинг, а последнее место никогда его не приносит.
Единоличный победитель никогда не теряет рейтинг, а единоличное последнее место
и выбывание никогда его не приносят. Невыбывшие, поделившие место, сравниваются
между собой как в ничьей: слабый может получить рейтинг, сильный — потерять.
</p>
</div>
+22 -36
View File
@@ -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);
// Места по раскладке: блоки сверху вниз (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() {
const { matchId } = useParams();
// Number("abc") — NaN, а не null: без проверки запрос уходил бы на /api/matches/NaN.
@@ -193,21 +205,14 @@ export function MatchDetailPage() {
const outcomeSeats = (): OutcomeSeat[] => {
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,
nickname: byId.get(uid)?.nickname ?? "",
place,
eliminated,
objectives: finishCounts[uid]?.objectives ?? 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 = () =>
@@ -218,35 +223,16 @@ export function MatchDetailPage() {
maxRounds: match.max_rounds,
});
// Строки результатов для API: места по блокам (competition ranking), затем выбывшие.
const resultRows = () => {
const commentOf = (uid: number) => (finishComments[uid] ?? "").trim() || null;
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) => ({
// Строки результатов для API. Место выбывшего и его миры (0) проставит сервер.
const resultRows = () =>
placeRows(finishBlocks, elim).map(({ uid, place, eliminated }) => ({
user_id: uid,
place: null,
eliminated: true,
comment: commentOf(uid),
...countsFor(uid, true),
place: eliminated ? null : place,
eliminated,
comment: (finishComments[uid] ?? "").trim() || null,
objectives: finishCounts[uid]?.objectives ?? null,
worlds: eliminated ? null : (finishCounts[uid]?.worlds ?? null),
}));
return [...survivors, ...eliminated];
};
const submitFinish = async () => {
if (!id || !match) return;
@@ -0,0 +1,329 @@
import { useRef, useState } from "react";
import { ApiError } from "../../api/client";
import { AnnouncementWindow } from "../../components/AnnouncementWindow";
import { ConfirmDialog } from "../../components/ConfirmDialog";
import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch";
import { useToast } from "../../context/ToastContext";
import { formatDateTime, fromAppLocalInput, toAppLocalInput } from "../../domain/format";
import type { AdminAnnouncementRead } from "../../domain/types";
import {
useAdminAnnouncements,
useAdminCreateAnnouncement,
useAdminDeleteAnnouncement,
useAdminStopAnnouncement,
useAdminUpdateAnnouncement,
} from "../../hooks/admin";
import { RichTextEditor } from "./RichTextEditor";
// Те же пределы, что проверяет сервер (announcement_service).
const TITLE_MAX = 60;
const TEXT_MAX = 600;
const WEEK_MS = 7 * 24 * 3600_000;
const STATUS: Record<AdminAnnouncementRead["status"], [string, string]> = {
planned: ["Запланировано", "badge"],
live: ["Идёт", "badge badge-live"],
finished: ["Завершено", "badge muted"],
};
interface Draft {
id: number | null; // null — новое объявление
key: number; // смена key пересоздаёт редактор с новым начальным текстом
title: string;
html: string;
length: number;
starts: string; // datetime-local, МСК
ends: string;
showToNew: boolean;
reshow: boolean;
}
let draftSeq = 0;
// Новое объявление: показ с текущей минуты на неделю. from — «Дублировать».
function newDraft(from?: AdminAnnouncementRead): Draft {
const now = Date.now();
return {
id: null,
key: ++draftSeq,
title: from?.title ?? "",
html: from?.body_html ?? "",
length: 0,
starts: toAppLocalInput(new Date(now).toISOString()),
ends: toAppLocalInput(new Date(now + WEEK_MS).toISOString()),
showToNew: from?.show_to_new_players ?? true,
reshow: false,
};
}
function editDraft(a: AdminAnnouncementRead): Draft {
return {
id: a.id,
key: ++draftSeq,
title: a.title,
html: a.body_html,
length: 0,
starts: toAppLocalInput(a.starts_at),
ends: toAppLocalInput(a.ends_at),
showToNew: a.show_to_new_players,
reshow: false,
};
}
export function AdminAnnouncementsPage() {
const { data: items, isLoading } = useAdminAnnouncements();
const create = useAdminCreateAnnouncement();
const update = useAdminUpdateAnnouncement();
const stop = useAdminStopAnnouncement();
const remove = useAdminDeleteAnnouncement();
const toast = useToast();
const formRef = useRef<HTMLDivElement>(null);
const [draft, setDraft] = useState<Draft>(() => newDraft());
const [error, setError] = useState<string | null>(null);
const [preview, setPreview] = useState(false);
const [toDelete, setToDelete] = useState<AdminAnnouncementRead | null>(null);
const patch = (p: Partial<Draft>) => setDraft((d) => ({ ...d, ...p }));
const openDraft = (d: Draft) => {
setDraft(d);
setError(null);
formRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const validate = (): { starts: string; ends: string } | string => {
if (!draft.title.trim()) return "Введите заголовок.";
if (draft.length === 0) return "Введите текст объявления.";
if (draft.length > TEXT_MAX) return `Текст длиннее ${TEXT_MAX} символов.`;
const starts = fromAppLocalInput(draft.starts);
const ends = fromAppLocalInput(draft.ends);
if (!starts || !ends) return "Укажите начало и конец показа.";
if (ends <= starts) return "Конец показа должен быть позже начала.";
return { starts, ends };
};
const save = async () => {
const period = validate();
if (typeof period === "string") {
setError(period);
return;
}
setError(null);
const body = {
title: draft.title.trim(),
body_html: draft.html,
starts_at: period.starts,
ends_at: period.ends,
show_to_new_players: draft.showToNew,
};
try {
if (draft.id == null) {
const a = await create.mutateAsync(body);
toast.show(a.status === "planned" ? "Объявление запланировано" : "Объявление опубликовано");
} else {
await update.mutateAsync({ id: draft.id, body: { ...body, reshow: draft.reshow } });
toast.show(draft.reshow ? "Сохранено — игроки увидят обновление" : "Сохранено");
}
setDraft(newDraft());
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const doStop = async (a: AdminAnnouncementRead) => {
try {
await stop.mutateAsync(a.id);
toast.show("Снято с показа");
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const doDelete = async () => {
const a = toDelete;
setToDelete(null);
if (!a) return;
try {
await remove.mutateAsync(a.id);
if (draft.id === a.id) setDraft(newDraft());
toast.show("Объявление удалено");
} catch (e) {
toast.error(e instanceof ApiError ? e.message : "Ошибка");
}
};
const saving = create.isPending || update.isPending;
return (
<div>
<div className="card ann-form" ref={formRef}>
{draft.id != null && (
<div className="row-between" style={{ marginBottom: 8 }}>
<span className="muted small">Правка объявления</span>
<button className="btn btn-ghost small" onClick={() => openDraft(newDraft())}>
Отменить правку
</button>
</div>
)}
<div className="field">
<label className="label" htmlFor="ann-title">
Заголовок
</label>
<input
id="ann-title"
maxLength={TITLE_MAX}
value={draft.title}
onChange={(e) => patch({ title: e.target.value })}
/>
</div>
<div className="field">
<span className="label">Текст объявления</span>
<RichTextEditor
key={draft.key}
initialHtml={draft.html}
onChange={(html, length) => patch({ html, length })}
/>
<div className={"rte-counter" + (draft.length > TEXT_MAX ? " over" : "")}>
{draft.length} / {TEXT_MAX}
</div>
</div>
<div className="row" style={{ alignItems: "flex-start" }}>
<div className="field" style={{ flex: 1, minWidth: 0 }}>
<label className="label" htmlFor="ann-starts">
Показывать с (МСК)
</label>
<input
id="ann-starts"
type="datetime-local"
value={draft.starts}
onChange={(e) => patch({ starts: e.target.value })}
/>
</div>
<div className="field" style={{ flex: 1, minWidth: 0 }}>
<label className="label" htmlFor="ann-ends">
по (МСК)
</label>
<input
id="ann-ends"
type="datetime-local"
value={draft.ends}
onChange={(e) => patch({ ends: e.target.value })}
/>
</div>
</div>
<label className="row-between">
<span>Показывать новым игрокам</span>
<Switch checked={draft.showToNew} onChange={(v) => patch({ showToNew: v })} />
</label>
<p className="muted small" style={{ margin: "4px 0 12px" }}>
Выключите, если объявление ни к чему тем, кто зарегистрируется после начала показа.
</p>
{draft.id != null && (
<>
<label className="row-between">
<span>Показать заново тем, кто уже закрыл</span>
<Switch checked={draft.reshow} onChange={(v) => patch({ reshow: v })} />
</label>
<p className="muted small" style={{ margin: "4px 0 12px" }}>
Они увидят объявление ещё раз — с красной пометкой «обновлено» под заголовком.
</p>
</>
)}
{error && <p className="error-text">{error}</p>}
<div className="row">
<button className="btn btn-ghost" onClick={() => setPreview(true)}>
Предпросмотр
</button>
<button className="btn btn-primary" style={{ flex: 1 }} disabled={saving} onClick={save}>
{draft.id == null ? "Опубликовать" : "Сохранить"}
</button>
</div>
</div>
<h3>Созданные объявления</h3>
{isLoading ? (
<Spinner />
) : !items?.length ? (
<p className="muted small">Объявлений пока нет.</p>
) : (
<div className="stack">
{items.map((a) => {
const [label, badge] = STATUS[a.status];
const meta = [
`${formatDateTime(a.starts_at)} — ${formatDateTime(a.ends_at)}`,
`закрыли ${a.seen_count} из ${a.audience_count}`,
...(a.show_to_new_players ? [] : ["без новых игроков"]),
...(a.revision > 1 ? [`версия ${a.revision}`] : []),
];
return (
<div key={a.id} className="card" style={{ margin: 0 }}>
<div className="row-between" style={{ alignItems: "flex-start" }}>
<div style={{ minWidth: 0 }}>
<div style={{ fontWeight: 600, overflowWrap: "anywhere" }}>{a.title}</div>
<div className="muted small" style={{ marginTop: 2 }}>
{meta.join(" · ")}
</div>
</div>
<span className={badge}>{label}</span>
</div>
<div className="row" style={{ marginTop: 10, flexWrap: "wrap", gap: 8 }}>
<button className="btn btn-ghost small" onClick={() => openDraft(editDraft(a))}>
Изменить
</button>
{a.status === "live" ? (
<button
className="btn btn-ghost small"
disabled={stop.isPending}
onClick={() => doStop(a)}
>
Снять с показа
</button>
) : (
<button className="btn btn-ghost small" onClick={() => openDraft(newDraft(a))}>
Дублировать
</button>
)}
<button className="btn btn-danger small" onClick={() => setToDelete(a)}>
Удалить
</button>
</div>
</div>
);
})}
</div>
)}
{preview && (
<AnnouncementWindow
title={draft.title.trim() || "Без заголовка"}
bodyHtml={draft.html}
onOk={() => setPreview(false)}
/>
)}
<ConfirmDialog
open={toDelete != null}
title="Удалить объявление?"
message={
toDelete
? `«${toDelete.title}» пропадёт у всех, кто его ещё не закрыл, а отметки о прочтении удалятся.`
: undefined
}
confirmLabel="Удалить"
danger
onConfirm={doDelete}
onCancel={() => setToDelete(null)}
/>
</div>
);
}
+3
View File
@@ -38,6 +38,9 @@ export function AdminLayout() {
<NavLink to="/admin/logs" className={tab}>
Логи
</NavLink>
<NavLink to="/admin/announcements" className={tab}>
Объявления
</NavLink>
</div>
<main className="app-main" style={{ paddingBottom: 24 }}>
<Outlet />
+1 -1
View File
@@ -5,7 +5,7 @@ import { formatDateTime } from "../../domain/format";
import { useAdminLogs } from "../../hooks/admin";
const ACTIONS = ["", "login", "create", "update", "delete"];
const ENTITIES = ["", "user", "group", "group_member", "match", "admin"];
const ENTITIES = ["", "user", "group", "group_member", "match", "admin", "announcement"];
export function AdminLogsPage() {
const [action, setAction] = useState("");
+3 -8
View File
@@ -6,6 +6,7 @@ import { MatchMedia } from "../../components/MatchMedia";
import { Spinner } from "../../components/Spinner";
import { Switch } from "../../components/Switch";
import { finishWarnings } from "../../domain/finishWarnings";
import { MAX_COUNT, parseCount } from "../../domain/matchCounts";
import {
LAST_STANDING,
reasonForSurvivors,
@@ -34,12 +35,6 @@ interface Row {
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 }) {
const { data: match, isLoading } = useAdminMatch(matchId);
const { data: factions } = useAdminFactions();
@@ -218,7 +213,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={99}
max={MAX_COUNT}
placeholder="цели"
title="Цели на конец партии"
value={r.objectives ?? ""}
@@ -228,7 +223,7 @@ export function AdminMatchEdit({ matchId, onClose }: { matchId: number; onClose:
style={{ flex: "0 0 70px" }}
type="number"
min={0}
max={99}
max={MAX_COUNT}
placeholder="миры"
title="Миры на конец партии"
disabled={r.eliminated}
+183
View File
@@ -0,0 +1,183 @@
import { type ReactNode, useEffect, useRef, useState } from "react";
const EMOJI = ["🏆", "📈", "🛠️", "🍂", "⚔️", "💀", "🔥", "🎲", "📅", "❗"];
const escapeHtml = (s: string) =>
s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Кнопка панели. mousedown гасим, чтобы фокус и выделение остались в поле текста.
function Tool({
title,
className,
onClick,
children,
}: {
title: string;
className?: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
className={"rte-tool" + (className ? ` ${className}` : "")}
title={title}
aria-label={title}
onMouseDown={(e) => e.preventDefault()}
onClick={onClick}
>
{children}
</button>
);
}
/**
* Редактор текста объявления (вариант R1 из #84): поле contenteditable и панель —
* жирный, курсив, золотой и красный акцент, эмодзи, снятие оформления. Набор узкий
* намеренно: сервер всё равно оставит только b / em / mark / p / br
* (announcement_service.sanitize_body). Вставка и перетаскивание — только простым текстом.
*
* Поле неуправляемое: начальный HTML ставится при монтировании (для другого объявления
* родитель пересоздаёт редактор через key), дальше каждое изменение уходит в onChange
* вместе с длиной видимого текста в символах — так же её считает сервер.
*/
export function RichTextEditor({
initialHtml,
onChange,
}: {
initialHtml: string;
onChange: (html: string, length: number) => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const [emojiOpen, setEmojiOpen] = useState(false);
const emit = () => {
const el = ref.current;
if (el) onChange(el.innerHTML, [...(el.textContent ?? "").trim()].length);
};
// Только при монтировании: дальше содержимым владеет сам contenteditable.
useEffect(() => {
if (ref.current) ref.current.innerHTML = initialHtml;
emit();
}, []);
// Выделение внутри поля — кнопки панели работают только с ним.
const rangeInEditor = (): Range | null => {
const sel = document.getSelection();
if (!sel || sel.rangeCount === 0) return null;
const range = sel.getRangeAt(0);
return ref.current?.contains(range.commonAncestorContainer) ? range : null;
};
// Курсор в конец поля, если в нём ещё не кликали (эмодзи до первого клика в текст).
const ensureCaret = () => {
const el = ref.current;
if (!el) return;
el.focus();
if (rangeInEditor()) return;
const range = document.createRange();
range.selectNodeContents(el);
range.collapse(false);
const sel = document.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
};
const run = (fn: () => void) => {
fn();
emit();
};
const format = (cmd: "bold" | "italic") =>
run(() => {
if (rangeInEditor()) document.execCommand(cmd);
});
// Акцент: выделенный текст целиком оборачивается в <mark> (золотой) или
// <mark class="red">. Оформление внутри выделения при этом снимается — для коротких
// акцентов это проще, чем вложенные теги.
const accent = (red: boolean) =>
run(() => {
const range = rangeInEditor();
if (!range || range.collapsed) return;
const inner = range.toString().split("\n").map(escapeHtml).join("<br>");
document.execCommand("insertHTML", false, `<mark${red ? ' class="red"' : ""}>${inner}</mark>`);
});
const clearFormat = () =>
run(() => {
if (!rangeInEditor()) return;
document.execCommand("removeFormat");
// removeFormat не знает про <mark> — акценты в выделении снимаем сами.
const range = rangeInEditor();
ref.current?.querySelectorAll("mark").forEach((m) => {
if (range?.intersectsNode(m)) m.replaceWith(...Array.from(m.childNodes));
});
});
const insertEmoji = (emoji: string) =>
run(() => {
ensureCaret();
document.execCommand("insertText", false, emoji);
});
return (
<div>
<div className="rte-toolbar">
<Tool title="Жирный" onClick={() => format("bold")}>
<b>Ж</b>
</Tool>
<Tool title="Курсив" onClick={() => format("italic")}>
<em>К</em>
</Tool>
<Tool title="Золотой акцент" className="rte-gold" onClick={() => accent(false)}>
A
</Tool>
<Tool title="Красный акцент" className="rte-red" onClick={() => accent(true)}>
A
</Tool>
<span className="rte-sep" />
<Tool title="Эмодзи" onClick={() => setEmojiOpen((v) => !v)}>
😀
</Tool>
<span className="rte-sep" />
<Tool title="Убрать оформление" onClick={clearFormat}>
✕&nbsp;формат
</Tool>
</div>
<div
ref={ref}
className="rte-editor ann-body"
contentEditable
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label="Текст объявления"
data-placeholder="Короткий текст объявления…"
onInput={emit}
// Enter создаёт <p>, а не <div> — абзацы сразу в том виде, что хранит сервер.
onFocus={() => document.execCommand("defaultParagraphSeparator", false, "p")}
onPaste={(e) => {
e.preventDefault();
document.execCommand("insertText", false, e.clipboardData.getData("text/plain"));
}}
onDrop={(e) => e.preventDefault()}
/>
{emojiOpen && (
<div className="rte-emoji">
{EMOJI.map((emoji) => (
<button
key={emoji}
type="button"
onMouseDown={(e) => e.preventDefault()}
onClick={() => insertEmoji(emoji)}
>
{emoji}
</button>
))}
</div>
)}
</div>
);
}
+90
View File
@@ -487,3 +487,93 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.toast-top-error { border-left-color: var(--danger); }
.toast-top-title { font-weight: 700; }
.toast-top-body { margin-top: 2px; font-size: 13px; color: var(--text-muted); }
/* ─── Объявления администрации (#84) ─── */
/* Окно (вариант E): заголовок — в плашке-шапке на всю ширину модалки. */
.ann-modal { max-height: calc(100dvh - 48px); overflow-y: auto; }
.ann-ribbon {
margin: -18px -18px 14px;
padding: 12px 18px;
background: var(--surface-2);
border-bottom: 1px solid var(--border);
border-radius: var(--radius) var(--radius) 0 0;
}
.ann-ribbon-title { font-weight: 700; font-size: 16px; line-height: 1.3; overflow-wrap: anywhere; }
/* Пометка повторного показа — мелкой строкой под заголовком, красным. */
.ann-updated { margin-top: 2px; font-size: 12px; color: var(--danger); }
.ann-actions { margin-top: 16px; justify-content: space-between; align-items: center; }
.ann-dots { display: flex; gap: 6px; align-items: center; }
.ann-dots i { display: block; width: 6px; height: 6px; border-radius: 50%; background: var(--border); }
.ann-dots i.on { background: var(--accent-2); }
/* Текст объявления — и в окне, и в редакторе админки (там он выглядит так же). */
.ann-body { font-size: 14px; line-height: 1.5; color: var(--text); overflow-wrap: anywhere; }
.ann-body p { margin: 0; }
.ann-body p + p { margin-top: 10px; }
/* Акценты: золотой — цвет ссылок и бейджа «идёт», красный — для предупреждений. */
.ann-body mark { background: none; color: var(--accent-2); font-weight: 600; }
.ann-body mark.red { color: var(--danger); }
/* Редактор (вариант R1) */
.rte-toolbar {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
padding: 8px;
background: var(--surface-2);
border: 1px solid var(--border);
border-bottom: none;
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.rte-tool {
min-width: 32px;
height: 32px;
padding: 0 8px;
border: 1px solid var(--border);
border-radius: 8px;
background: var(--surface);
color: var(--text);
font-size: 14px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.rte-tool:hover { border-color: var(--accent-2); }
.rte-gold { color: var(--accent-2); font-weight: 700; }
.rte-red { color: var(--danger); font-weight: 700; }
.rte-sep { width: 1px; height: 22px; background: var(--border); }
.rte-editor {
min-height: 130px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
padding: 10px 12px;
outline: none;
}
.rte-editor:focus { border-color: var(--accent); }
.rte-editor:empty::before { content: attr(data-placeholder); color: var(--text-muted); }
.rte-emoji {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
padding: 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
}
.rte-emoji button {
width: 34px;
height: 34px;
border: none;
background: none;
font-size: 18px;
border-radius: 8px;
}
.rte-emoji button:hover { background: var(--surface-2); }
.rte-counter { font-size: 12px; color: var(--text-muted); margin-top: 4px; text-align: right; }
.rte-counter.over { color: var(--danger); }
/* Выделение текста в форме объявления — полупрозрачный --accent-2 вместо системного синего. */
.ann-form ::selection { background: rgba(240, 160, 75, 0.35); }
-29
View File
@@ -1,29 +0,0 @@
commit 65bebf8b85fcbf921cbbc4f2599953a9fa3ca9c6 (HEAD -> dev)
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:45:33 2026 +0300
Добавление ssh-ключа к контейнеру tunnel
commit 7e8b748caf5d9f4ab3d2d7f76d6a4b850b9b1366
Author: NotBigGhost <ivan@arseniev.info>
Date: Wed Jun 17 04:27:11 2026 +0300
Правки локального размещения дева, перенос тунеля в контейнер
commit b04fbb2e171a7fe3376f3633dfee9e29e4e4bb90
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 19:15:54 2026 +0300
Завершена настройка домена для дева, теста и прода. Соединение через ssh-туннель
commit 56b5d09a4dc5e6f5d06e32c2635c84a66597e71f (origin/main, main)
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 17:41:06 2026 +0300
v0.1 - макет интерфейса, аутентификация через логин, аккаунт админа, создание партии в 2 этапа, базовые настройки профиля и группы, переключение между группами, статистика
commit 6ab74f01aaf1ac042a269c7660b3e564b93350c7
Author: NotBigGhost <ivan@arseniev.info>
Date: Tue Jun 16 16:53:41 2026 +0300
first commit
+4 -1
View File
@@ -128,11 +128,14 @@ switch ($appEnv) {
if ($localPublic -eq "vps") {
Start-VpsTunnel 5173
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" {
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
}
default {
+7 -2
View File
@@ -75,12 +75,17 @@ case "$app_env" in
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: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 )
;;
production)
echo "production лаунчером не запускается — прод обособлен."
echo "Деплой на Pi (из ветки main): docker compose up -d --build"
echo "Деплой: на ПК (ветка main) scripts/build-push.sh, затем на Pi: docker compose up -d"
exit 1
;;
*)
-20
View File
@@ -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"