From 9d9e4a345acd074ebd6c87dd2faca42379d25507 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 15:24:52 +0300 Subject: [PATCH 1/7] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E:=20=D0=B1?= =?UTF-8?q?=D0=B5=D0=B7=D0=BE=D0=BF=D0=B0=D1=81=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D0=B8=20=D0=BA=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82?= =?UTF-8?q?=D0=BD=D0=BE=D1=81=D1=82=D1=8C=20=D0=B2=20=D1=81=D0=B5=D1=80?= =?UTF-8?q?=D0=B2=D0=B8=D1=81=D0=B0=D1=85=20=D0=B1=D1=8D=D0=BA=D0=B5=D0=BD?= =?UTF-8?q?=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Находки прохода /code-review high по backend/app: - achievement_service: slug из URL шёл в путь без проверки, из-за чего DELETE /api/admin/achievements/%2E%2E удалял rmtree'ом родительскую папку каталога ачивок (в проде это /data — БД, uploads, ачивки целиком). - match_service/attachment_service: версия партии = updated_at, но onupdate срабатывает лишь при реальном UPDATE строки matches. Правка одних участников и работа с вложениями его не вызывали, и оптимистичная блокировка молча пропускала конкурентную запись — бампаем updated_at явно. - admin_service: удаление группы с партиями упиралось в RESTRICT и уходило наружу голым 500; теперь понятная ошибка. Админское удаление партии не чистило файлы вложений с тома — они оставались навсегда. - user_service: при повторной загрузке аватара с тем же расширением avatar_path не менялся, updated_at не двигался, и кэш-бастер оставлял старую картинку до часа. Плюс версия считалась из наивного времени как из локального и разъезжалась с лидербордом, где то же поле считает SQL. - membership_service: единственный владелец мог разжаловать сам себя и группа оставалась без владельца навсегда. - notification_service: mark_read не слал SSE-сигнал, и бейдж непрочитанных на других устройствах висел до перезагрузки. - routers/admin: created_at после правки пользователя отдавался без смещения, и дата «создан» прыгала на часовой пояс до следующего обновления списка. #8 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD --- backend/app/routers/admin.py | 2 +- backend/app/services/achievement_service.py | 9 +++++++++ backend/app/services/admin_service.py | 10 ++++++++++ backend/app/services/attachment_service.py | 11 +++++++++++ backend/app/services/match_service.py | 4 ++++ backend/app/services/membership_service.py | 10 ++++++++++ backend/app/services/notification_service.py | 3 +++ backend/app/services/user_service.py | 16 ++++++++++++++-- 8 files changed, 62 insertions(+), 3 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 44b889f..ff006ff 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -114,7 +114,7 @@ def update_user( is_active=u.is_active, auth_provider=u.auth_provider, telegram_id=u.telegram_id, - created_at=u.created_at.isoformat(), + created_at=iso_utc(u.created_at), ) diff --git a/backend/app/services/achievement_service.py b/backend/app/services/achievement_service.py index c01ec48..3bc1839 100644 --- a/backend/app/services/achievement_service.py +++ b/backend/app/services/achievement_service.py @@ -42,6 +42,10 @@ def _root() -> Path: return Path(settings.achievements_dir) +# Формат slug — то, что выдаёт _slugify: только латиница, цифры и дефис. +_SLUG_RE = re.compile(r"[a-z0-9][a-z0-9-]{0,63}") + + def _slugify(name: str) -> str: text = "".join(_TRANSLIT.get(ch, ch) for ch in (name or "").strip().lower()) slug = re.sub(r"[^a-z0-9]+", "-", text).strip("-") @@ -49,6 +53,11 @@ def _slugify(name: str) -> str: def _dir(slug: str) -> Path: + """Папка ачивки. Slug приходит из URL, поэтому формат проверяем здесь: без этого + `..` или `a/b` увели бы файловые операции (вплоть до rmtree в delete) за пределы + каталога ачивок.""" + if not _SLUG_RE.fullmatch(slug or ""): + raise NotFoundError("Ачивка не найдена.") return _root() / slug diff --git a/backend/app/services/admin_service.py b/backend/app/services/admin_service.py index 4616ea1..552f21d 100644 --- a/backend/app/services/admin_service.py +++ b/backend/app/services/admin_service.py @@ -6,6 +6,7 @@ from typing import Any from sqlmodel import Session, select from app.core.errors import ( + ConflictError, InvalidCredentialsError, NicknameTakenError, NotFoundError, @@ -75,6 +76,10 @@ def delete_group(session: Session, group_id: int) -> None: group = session.get(Group, group_id) if group is None: raise NotFoundError("Группа не найдена.") + # matches.group_id — ON DELETE RESTRICT, поэтому группу с партиями БД не отдаст. + # Проверяем сами, иначе IntegrityError уходит наружу голым 500 без AppError-конверта. + if session.exec(select(Match.id).where(Match.group_id == group_id)).first() is not None: + raise ConflictError("Нельзя удалить группу, в которой есть партии. Сначала удалите их.") session.delete(group) session.commit() @@ -134,11 +139,16 @@ def rename_faction(session: Session, faction_id: int, name_ru: str) -> Faction: def delete_match(session: Session, match_id: int) -> None: + from app.services import attachment_service # избегаем цикла импорта + match = session.get(Match, match_id) if match is None: raise NotFoundError("Партия не найдена.") session.delete(match) session.commit() + # Как и в игроцком пути (match_service.delete_match): строки вложений уходят + # каскадом, а файлы с тома нужно убрать руками, иначе они остаются навсегда. + attachment_service.delete_match_files(match_id) # ─── Журнал аудита ─────────────────────────────────────────────────────────── diff --git a/backend/app/services/attachment_service.py b/backend/app/services/attachment_service.py index 5491e33..eabfe2e 100644 --- a/backend/app/services/attachment_service.py +++ b/backend/app/services/attachment_service.py @@ -11,6 +11,7 @@ from sqlmodel import Session, select from app.core.config import settings from app.core.errors import ConflictError, NotFoundError +from app.core.timeutil import utcnow from app.models import Match, MatchAttachment, User MAX_ATTACHMENTS = 10 @@ -39,6 +40,14 @@ def file_path(att: MatchAttachment) -> Path: return Path(settings.upload_dir) / att.storage_path +def _touch(session: Session, match: Match) -> None: + """Двинуть версию партии: набор вложений виден в MatchRead, а сама строка matches + при работе с ними не меняется — без этого оптимистичная блокировка проспала бы + конкурентную правку.""" + match.updated_at = utcnow() + session.add(match) + + def add_photo( session: Session, match: Match, user: User, content: bytes, ext: str, mime: str ) -> MatchAttachment: @@ -60,6 +69,7 @@ def add_photo( abs_path.write_bytes(content) att.storage_path = rel session.add(att) + _touch(session, match) session.commit() session.refresh(att) return att @@ -81,6 +91,7 @@ def delete(session: Session, match: Match, att_id: int) -> None: except OSError: pass session.delete(att) + _touch(session, match) session.commit() diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index 00ead8e..e3d7ecb 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -351,6 +351,10 @@ def update_match( ) match.player_count = len(participants) + # Версия партии = updated_at, а onupdate срабатывает только при реальном UPDATE + # строки matches. Правка одних участников его не вызывает, и тогда оптимистичная + # блокировка молча пропускала бы конкурентную запись — поэтому бампаем явно. + match.updated_at = _utcnow() session.add(match) session.commit() session.refresh(match) diff --git a/backend/app/services/membership_service.py b/backend/app/services/membership_service.py index d4a83b6..12b4dd4 100644 --- a/backend/app/services/membership_service.py +++ b/backend/app/services/membership_service.py @@ -85,6 +85,16 @@ def change_role(session: Session, group: Group, user_id: int, role: str) -> Grou ).first() if member is None: raise NotFoundError("Игрок не состоит в группе.") + if member.role == "owner" and role != "owner": + # Без этого единственный владелец мог разжаловать сам себя, и группа + # оставалась без владельца навсегда: назначить нового уже некому. + owners = session.exec( + select(GroupMember).where( + GroupMember.group_id == group.id, GroupMember.role == "owner" + ) + ).all() + if len(owners) <= 1: + raise ForbiddenError("Нельзя снять роль с последнего владельца группы.") member.role = role session.add(member) session.commit() diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index cd3c80d..c7e33b2 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -137,6 +137,9 @@ def mark_read(session: Session, user_id: int, ids: list[int] | None = None) -> i session.add(row) if rows: session.commit() + # Счётчик непрочитанных изменился — толкаем тот же сигнал, что и create_for, + # иначе вкладка на другом устройстве держит устаревший бейдж до перезагрузки. + notify.notifications_changed(user_id) return len(rows) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py index ea6d552..1be90f3 100644 --- a/backend/app/services/user_service.py +++ b/backend/app/services/user_service.py @@ -3,7 +3,7 @@ from __future__ import annotations import os import re -from datetime import datetime +from datetime import datetime, timezone from pathlib import Path from sqlmodel import Session, select @@ -11,6 +11,7 @@ from sqlmodel import Session, select from app.auth.provider import ExternalIdentity from app.core.config import settings from app.core.errors import NicknameTakenError, NotFoundError, ValidationError +from app.core.timeutil import utcnow from app.models import AuthIdentity, Faction, GroupMember, User _NICK_RE = re.compile(r"^[\w .\-]{2,64}$", re.UNICODE) @@ -170,7 +171,13 @@ def avatar_url_for(user_id: int, avatar_path: str | None, updated_at: datetime | подтягивал новую картинку после смены (файл перезаписывается по тому же пути).""" if not avatar_path: return None - version = int(updated_at.timestamp()) if updated_at else 0 + # В БД время наивное и хранится в UTC. .timestamp() у наивного значения считает + # его локальным, и версия разъезжалась с лидербордом, где то же поле считает SQL + # (strftime('%s') читает его как UTC) — один аватар качался браузером дважды. + version = 0 + if updated_at is not None: + aware = updated_at if updated_at.tzinfo else updated_at.replace(tzinfo=timezone.utc) + version = int(aware.timestamp()) return f"/api/users/{user_id}/avatar?v={version}" @@ -253,6 +260,10 @@ def set_avatar(session: Session, user: User, content: bytes, ext: str) -> User: rel = f"{_AVATAR_SUBDIR}/{user.id}.{ext}" (Path(settings.upload_dir) / rel).write_bytes(content) user.avatar_path = rel + # Файл перезаписывается по тому же пути, поэтому при том же расширении avatar_path + # не меняется, UPDATE не эмитится и onupdate не срабатывает. Без явного бампа + # кэш-бастер остаётся прежним, и браузер час показывает старую картинку. + user.updated_at = utcnow() session.add(user) session.commit() session.refresh(user) @@ -267,6 +278,7 @@ def clear_avatar(session: Session, user: User) -> User: except OSError: pass user.avatar_path = None + user.updated_at = utcnow() session.add(user) session.commit() session.refresh(user) From 62d75ea176c0d5e03efcc9dab489751bc5dbc360 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 15:26:42 +0300 Subject: [PATCH 2/7] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E:=20=D1=82?= =?UTF-8?q?=D0=B5=D1=81=D1=82=D1=8B-=D1=80=D0=B5=D0=B3=D1=80=D0=B5=D1=81?= =?UTF-8?q?=D1=81=D0=B8=D0=B8=20=D0=BD=D0=B0=20=D0=BD=D0=B0=D0=B9=D0=B4?= =?UTF-8?q?=D0=B5=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=B5=D1=84=D0=B5=D0=BA?= =?UTF-8?q?=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Четыре теста закрывают то, что чинил предыдущий коммит: обход каталога ачивок через slug, неподвижная версия партии при правке участников, удаление группы с партиями (409 вместо 500) и разжалование последнего владельца. #8 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD --- backend/tests/test_achievements.py | 16 +++++++++++ backend/tests/test_admin_extra.py | 46 ++++++++++++++++++++++++++++++ backend/tests/test_concurrency.py | 36 +++++++++++++++++++++++ 3 files changed, 98 insertions(+) diff --git a/backend/tests/test_achievements.py b/backend/tests/test_achievements.py index d940ded..a32dd23 100644 --- a/backend/tests/test_achievements.py +++ b/backend/tests/test_achievements.py @@ -111,3 +111,19 @@ def test_update_and_delete(client: TestClient, make_admin, monkeypatch, tmp_path f"/api/admin/achievements/{slug}", headers=csrf_headers(client) ).status_code == 200 assert all(a["slug"] != slug for a in client.get("/api/admin/achievements").json()) + + +def test_delete_rejects_traversal_slug(client: TestClient, make_admin, monkeypatch, tmp_path): + """Slug из URL не должен уводить файловые операции за каталог ачивок. + + Регрессия: `DELETE /api/admin/achievements/%2E%2E` снимал rmtree'ом родительскую + папку каталога (в проде это /data — БД, uploads и ачивки разом).""" + root = _use_tmp_achievements(monkeypatch, tmp_path / "achievements") + root.mkdir(parents=True, exist_ok=True) + sibling = tmp_path / "db.sqlite3" + sibling.write_bytes(b"data") + _admin(client, make_admin) + + r = client.delete("/api/admin/achievements/..", headers=csrf_headers(client)) + assert r.status_code == 404, r.text + assert sibling.exists() and root.is_dir() diff --git a/backend/tests/test_admin_extra.py b/backend/tests/test_admin_extra.py index 604e142..467aa0a 100644 --- a/backend/tests/test_admin_extra.py +++ b/backend/tests/test_admin_extra.py @@ -203,3 +203,49 @@ def test_admin_rename_faction_system_wide(client: TestClient, make_admin, engine detail = client.get(f"/api/admin/matches/{mid}").json() ap = next(p for p in detail["participants"] if p["user_id"] == me["id"]) assert ap["faction_name"] == "Орки WAAAGH" + + +def test_admin_delete_group_with_matches_is_conflict(client: TestClient, make_admin, engine): + """Группу с партиями удалять нельзя — но ответ должен быть внятным 409. + + Регрессия: matches.group_id — ON DELETE RESTRICT, и голый session.delete ронял + IntegrityError наружу пятисоткой без конверта ошибки.""" + me = login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, 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()] + create_finished_match( + client, gid, + [ + {"user_id": me["id"], "faction_id": fids[0], "place": 1}, + {"user_id": p2, "faction_id": fids[1], "place": 2}, + ], + ) + + _admin_login(client, make_admin) + r = client.delete(f"/api/admin/groups/{gid}", headers=csrf_headers(client)) + assert r.status_code == 409, r.text + assert client.get("/api/admin/groups").status_code == 200 + + +def test_last_owner_cannot_demote_self(client: TestClient, engine): + """Единственный владелец не может разжаловать сам себя. + + Регрессия: change_role не проверял последнего владельца (в отличие от удаления), + и группа оставалась без владельца навсегда — назначить нового было некому.""" + me = login(client, "Owner") + gid = client.post( + "/api/groups", json={"name": "Группа", "expansion_ids": []}, headers=csrf_headers(client) + ).json()["id"] + add_group_member(engine, gid, "Игрок2") + + r = client.patch( + f"/api/groups/{gid}/members/{me['id']}", + json={"role": "member"}, + headers=csrf_headers(client), + ) + assert r.status_code == 403, r.text + members = client.get(f"/api/groups/{gid}/members").json() + assert any(m["user_id"] == me["id"] and m["role"] == "owner" for m in members) diff --git a/backend/tests/test_concurrency.py b/backend/tests/test_concurrency.py index 516621f..fb7673d 100644 --- a/backend/tests/test_concurrency.py +++ b/backend/tests/test_concurrency.py @@ -75,3 +75,39 @@ def test_stale_finish_rejected(client: TestClient, engine): headers=csrf_headers(client), ) assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text + + +def test_participant_edit_moves_version(client: TestClient, engine): + """Правка одних участников тоже двигает версию партии. + + Регрессия: updated_at менялся только при UPDATE строки matches, поэтому после + правки участников версия оставалась прежней и вторая правка со старой версией + проходила вместо 409 — ровно то, от чего защищает блокировка.""" + me, p2, mid = _start(client, engine) + fin = finish_match( + client, mid, [{"user_id": me["id"], "place": 1}, {"user_id": p2, "place": 2}], + win_reason="objectives", + ) + assert fin.status_code == 200, fin.text + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + parts = {p["user_id"]: p["faction_id"] for p in client.get(f"/api/matches/{mid}").json()["participants"]} + swap = [ + {"user_id": me["id"], "faction_id": parts[me["id"]], "place": 2}, + {"user_id": p2, "faction_id": parts[p2], "place": 1}, + ] + r = client.patch( + f"/api/matches/{mid}", + json={"participants": swap, "expected_version": v1}, + headers=csrf_headers(client), + ) + assert r.status_code == 200, r.text + assert client.get(f"/api/matches/{mid}").json()["version"] != v1 + + stale = client.patch( + f"/api/matches/{mid}", + json={"participants": swap, "expected_version": v1}, + headers=csrf_headers(client), + ) + assert stale.status_code == 409, stale.text + assert stale.json()["error"]["code"] == "STALE_WRITE" From c4b72cca39a48089e2424f2911edf8e438c4c1c7 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 15:30:56 +0300 Subject: [PATCH 3/7] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E:=20=D0=BA?= =?UTF-8?q?=D0=BE=D1=80=D1=80=D0=B5=D0=BA=D1=82=D0=BD=D0=BE=D1=81=D1=82?= =?UTF-8?q?=D1=8C=20=D1=84=D1=80=D0=BE=D0=BD=D1=82=D0=B5=D0=BD=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Находки прохода /code-review high по frontend/src: - CreateMatchPage: барабан рандома, таймеры и строки адресовались по индексу, а удаление строки индексы сдвигает — фракция могла записаться соседу или потеряться. Строки получили стабильный id. - AdminAccountsPage: переключение «активен» глотало ошибку и всё равно показывало «Сохранено». - useServerEvents/useFinishMatch: завершение чужой партии не инвалидировало историю игр и публичные профили — открытый профиль показывал состав до завершения. - guards/useMe: обрыв связи не отличался от «нет сессии», и мигание сети выкидывало авторизованного пользователя на /login. Транспортные ошибки повторяем, гварды показывают сообщение вместо редиректа. - format: fallback на нераспознанную дату не работал (new Date не бросает исключение), и в интерфейс попадало «NaN.NaN NaN:NaN». - PlaceEditor: без onPointerCancel прерванный перенос оставлял блок с классом dragging и сдвигом, которые React не снимает — они выставлены в обход него. - AdminFactionsPage: refetch после сохранения одной фракции затирал несохранённый ввод в остальных. - MatchDetailPage: nav(-1) после удаления уводил из приложения при открытии партии по прямой ссылке; id из URL мог быть NaN и уходил в запрос. #8 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD --- frontend/src/auth/guards.tsx | 11 ++- frontend/src/components/PlaceEditor.tsx | 4 + frontend/src/domain/format.ts | 18 ++-- frontend/src/hooks/auth.ts | 3 + frontend/src/hooks/matches.ts | 7 +- frontend/src/hooks/useServerEvents.ts | 5 ++ frontend/src/pages/CreateMatchPage.tsx | 85 +++++++++++-------- frontend/src/pages/MatchDetailPage.tsx | 8 +- .../src/pages/admin/AdminAccountsPage.tsx | 8 +- .../src/pages/admin/AdminFactionsPage.tsx | 13 ++- 10 files changed, 105 insertions(+), 57 deletions(-) diff --git a/frontend/src/auth/guards.tsx b/frontend/src/auth/guards.tsx index a468f42..79bb2c9 100644 --- a/frontend/src/auth/guards.tsx +++ b/frontend/src/auth/guards.tsx @@ -5,17 +5,24 @@ import { Spinner } from "../components/Spinner"; import { useMe } from "../hooks/auth"; import { useAdminMe } from "../hooks/admin"; +/** Запрос упал, а не ответил «не авторизован»: связи нет — это не повод разлогинивать. */ +function OfflineNotice() { + return
Нет связи с сервером. Проверьте подключение и обновите страницу.
; +} + export function RequireAuth({ children }: PropsWithChildren) { - const { data: me, isLoading } = useMe(); + const { data: me, isLoading, isError } = useMe(); const location = useLocation(); if (isLoading) return ; + if (isError) return ; if (!me) return ; return <>{children}; } export function RequireGroup({ children }: PropsWithChildren) { - const { data: me, isLoading } = useMe(); + const { data: me, isLoading, isError } = useMe(); if (isLoading) return ; + if (isError) return ; if (!me) return ; if (me.active_group_id == null) return ; return <>{children}; diff --git a/frontend/src/components/PlaceEditor.tsx b/frontend/src/components/PlaceEditor.tsx index 7deb09d..aa7a9cd 100644 --- a/frontend/src/components/PlaceEditor.tsx +++ b/frontend/src/components/PlaceEditor.tsx @@ -182,6 +182,10 @@ export function PlaceEditor({ onPointerDown: (e: React.PointerEvent) => startDrag(e, opts), onPointerMove: moveDrag, onPointerUp: endDrag, + // Браузер отменяет указатель (перехват прокрутки, входящий звонок) — без этого + // блок остаётся с классом dragging и сдвигом: они выставлены в обход React, + // и перерендер их не снимет. + onPointerCancel: endDrag, }); // ✂ — вынуть игрока из ничьей отдельным блоком сразу после неё. diff --git a/frontend/src/domain/format.ts b/frontend/src/domain/format.ts index 5885c61..f8cecd9 100644 --- a/frontend/src/domain/format.ts +++ b/frontend/src/domain/format.ts @@ -29,12 +29,9 @@ export function formatDuration(minutes: number | null | undefined): string { export function formatTime(iso: string | null | undefined): string { if (!iso) return "—"; - try { - const d = shifted(iso); - return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; - } catch { - return iso; - } + const d = shifted(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; } // «Дата игры» приходит date-only строкой (YYYY-MM-DD) — просто переставляем части, @@ -47,10 +44,7 @@ export function formatDate(iso: string | null | undefined): string { export function formatDateTime(iso: string | null | undefined): string { if (!iso) return "—"; - try { - const d = shifted(iso); - return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; - } catch { - return iso; - } + const d = shifted(iso); + if (Number.isNaN(d.getTime())) return iso; + return `${p2(d.getUTCDate())}.${p2(d.getUTCMonth() + 1)}.${d.getUTCFullYear()} ${p2(d.getUTCHours())}:${p2(d.getUTCMinutes())}`; } diff --git a/frontend/src/hooks/auth.ts b/frontend/src/hooks/auth.ts index 2982779..f7e03ed 100644 --- a/frontend/src/hooks/auth.ts +++ b/frontend/src/hooks/auth.ts @@ -12,6 +12,9 @@ export function useMe() { if (r.response.status === 401) return null; return unwrap(r); }, + // «Нет сессии» — это только 401 (выше, null). Обрыв связи не ответ сервера: + // без повторов гварды принимали бы его за разлогин и уводили на /login. + retry: (count, err) => !(err instanceof ApiError) && count < 2, }); } diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 90e2b94..564b28c 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -12,7 +12,9 @@ function readCsrfToken(): string | null { export function useMatch(matchId: number | null) { return useQuery({ - queryKey: matchId ? qk.match(matchId) : ["match", "none"], + // Ключ и enabled должны сходиться: иначе запрос без валидного id кэшировал бы + // свою ошибку под общим ключом-заглушкой. + queryKey: matchId != null ? qk.match(matchId) : ["match", "none"], enabled: matchId != null, queryFn: async () => unwrap( @@ -63,6 +65,9 @@ export function useFinishMatch() { qc.invalidateQueries({ queryKey: qk.leaderboard }); qc.invalidateQueries({ queryKey: qk.home }); qc.invalidateQueries({ queryKey: qk.myStats }); + // Завершённая партия попадает в историю игр и в публичные профили участников. + qc.invalidateQueries({ queryKey: ["userMatches"] }); + qc.invalidateQueries({ queryKey: ["publicProfile"] }); }, }); } diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index 8efddda..163c34e 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -40,6 +40,11 @@ export function useServerEvents(enabled: boolean) { } qc.invalidateQueries({ queryKey: qk.home }); qc.invalidateQueries({ queryKey: qk.leaderboard }); + // История игр и публичный профиль тоже меняются от чужой партии: без этих + // двух ключей открытый профиль показывал бы состав до завершения. + qc.invalidateQueries({ queryKey: ["userMatches"] }); + qc.invalidateQueries({ queryKey: ["publicProfile"] }); + qc.invalidateQueries({ queryKey: qk.myStats }); } else if (ev.type === "group") { if (ev.group_id != null) { qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); diff --git a/frontend/src/pages/CreateMatchPage.tsx b/frontend/src/pages/CreateMatchPage.tsx index e6aa1e9..f897e86 100644 --- a/frontend/src/pages/CreateMatchPage.tsx +++ b/frontend/src/pages/CreateMatchPage.tsx @@ -15,13 +15,19 @@ import { useGroupMembers } from "../hooks/groups"; import { useRandomizeFaction, useStartMatch } from "../hooks/matches"; interface Draft { + // Стабильный ключ строки: индексы сдвигаются при удалении, а барабан рандома + // отложенно дописывает результат в свою строку и промахнулся бы по соседа. + id: number; user_id: number | ""; faction_id: number | ""; was_random: boolean; } +let draftSeq = 0; + function emptyDraft(): Draft { - return { user_id: "", faction_id: "", was_random: false }; + draftSeq += 1; + return { id: draftSeq, user_id: "", faction_id: "", was_random: false }; } export function CreateMatchPage() { @@ -51,30 +57,39 @@ export function CreateMatchPage() { if (lm || lf) return ; if (!groupId) return
Нет активной группы.
; - const takenFactionIds = (exceptIdx: number) => + const takenFactionIds = (exceptId: number) => rows - .filter((_, i) => i !== exceptIdx) + .filter((r) => r.id !== exceptId) .map((r) => r.faction_id) .filter((x): x is number => typeof x === "number"); // Игроки, уже выбранные в других строках, — в списке не предлагаются. - const takenUserIds = (exceptIdx: number) => + const takenUserIds = (exceptId: number) => rows - .filter((_, i) => i !== exceptIdx) + .filter((r) => r.id !== exceptId) .map((r) => r.user_id) .filter((x): x is number => typeof x === "number"); - const update = (idx: number, patch: Partial) => - setRows((rs) => rs.map((r, i) => (i === idx ? { ...r, ...patch } : r))); + const update = (id: number, patch: Partial) => + setRows((rs) => rs.map((r) => (r.id === id ? { ...r, ...patch } : r))); const addRow = () => setRows((rs) => [...rs, emptyDraft()]); - const removeRow = (idx: number) => setRows((rs) => rs.filter((_, i) => i !== idx)); + const removeRow = (id: number) => { + const timer = reelTimers.current[id]; + if (timer) clearTimeout(timer); + delete reelTimers.current[id]; + setSpin((sp) => { + const { [id]: _dropped, ...rest } = sp; + return rest; + }); + setRows((rs) => rs.filter((r) => r.id !== id)); + }; // «Барабан»: мелькаем названиями с нарастающим интервалом (плавное замедление), // затем рулетка останавливается на выбранной фракции, держит её 0.5с — и только // после этого фракция «выбирается» (onReveal: показываем select с результатом). const animateReel = ( - idx: number, + id: number, names: string[], finalName: string, onReveal: () => void, @@ -82,16 +97,16 @@ export function CreateMatchPage() { new Promise((resolve) => { let delay = 50; const tick = () => { - setSpin((s) => ({ ...s, [idx]: names[Math.floor(Math.random() * names.length)] })); + setSpin((s) => ({ ...s, [id]: names[Math.floor(Math.random() * names.length)] })); delay *= 1.18; if (delay < 300) { - reelTimers.current[idx] = setTimeout(tick, delay); + reelTimers.current[id] = setTimeout(tick, delay); } else { - reelTimers.current[idx] = setTimeout(() => { - setSpin((s) => ({ ...s, [idx]: finalName })); // остановка на выбранной - reelTimers.current[idx] = setTimeout(() => { + reelTimers.current[id] = setTimeout(() => { + setSpin((s) => ({ ...s, [id]: finalName })); // остановка на выбранной + reelTimers.current[id] = setTimeout(() => { onReveal(); - setSpin((s) => ({ ...s, [idx]: null })); + setSpin((s) => ({ ...s, [id]: null })); resolve(); }, 500); // пауза перед «выбором» }, delay); @@ -100,20 +115,20 @@ export function CreateMatchPage() { tick(); }); - const randomizeRow = async (idx: number) => { + const randomizeRow = async (id: number) => { const pool = factions ?? []; - if (pool.length === 0 || spin[idx] != null) return; + if (pool.length === 0 || spin[id] != null) return; try { const faction = await randomize.mutateAsync({ group_id: groupId, - exclude_faction_ids: takenFactionIds(idx), + exclude_faction_ids: takenFactionIds(id), }); - await animateReel(idx, pool.map((f) => f.name_ru), faction.name_ru, () => - update(idx, { faction_id: faction.id, was_random: true }), + await animateReel(id, pool.map((f) => f.name_ru), faction.name_ru, () => + update(id, { faction_id: faction.id, was_random: true }), ); } catch (e) { - if (reelTimers.current[idx]) clearTimeout(reelTimers.current[idx]); - setSpin((s) => ({ ...s, [idx]: null })); + if (reelTimers.current[id]) clearTimeout(reelTimers.current[id]); + setSpin((s) => ({ ...s, [id]: null })); toast.show(e instanceof ApiError ? e.message : "Ошибка рандома"); } }; @@ -130,10 +145,10 @@ export function CreateMatchPage() { const names = all.map((f) => f.name_ru); // Барабан крутится во всех строках одновременно, каждая садится на свою фракцию. await Promise.all( - rows.map((_, idx) => { + rows.map((row, idx) => { const f = pool[idx % pool.length]; - return animateReel(idx, names, f.name_ru, () => - update(idx, { faction_id: f.id, was_random: true }), + return animateReel(row.id, names, f.name_ru, () => + update(row.id, { faction_id: f.id, was_random: true }), ); }), ); @@ -194,11 +209,11 @@ export function CreateMatchPage() { {rows.map((row, idx) => ( -
+
Игрок {idx + 1} {rows.length > 2 && ( - )} @@ -206,29 +221,29 @@ export function CreateMatchPage() { m.id === row.user_id) ?? null} - options={playerOptions.filter((m) => !takenUserIds(idx).includes(m.id))} + options={playerOptions.filter((m) => !takenUserIds(row.id).includes(m.id))} placeholder="— игрок —" renderOption={(m) => playerLabel(m, 28)} renderValue={(m) => playerLabel(m, 24)} - onPick={(m) => update(idx, { user_id: m.id })} + onPick={(m) => update(row.id, { user_id: m.id })} />
- {spin[idx] != null ? ( -
{spin[idx]}
+ {spin[row.id] != null ? ( +
{spin[row.id]}
) : ( f.id === row.faction_id) ?? null} - options={(factions ?? []).filter((f) => !takenFactionIds(idx).includes(f.id))} + options={(factions ?? []).filter((f) => !takenFactionIds(row.id).includes(f.id))} placeholder="— фракция —" renderOption={(f) => f.name_ru} - onPick={(f) => update(idx, { faction_id: f.id, was_random: false })} + onPick={(f) => update(row.id, { faction_id: f.id, was_random: false })} /> )}