diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index dd5e639..78631f4 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -229,6 +229,7 @@ def update_match( win_reason=body.win_reason, win_reason_set=("win_reason" in body.model_fields_set), participants=participants, + expected_version=body.expected_version, ) audit_service.record( session, diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index ce79e28..0767213 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -1,7 +1,7 @@ """Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление.""" from __future__ import annotations -from fastapi import APIRouter, Depends, File, Request, UploadFile +from fastapi import APIRouter, Depends, File, Query, Request, UploadFile from fastapi.responses import FileResponse from sqlmodel import Session @@ -69,6 +69,7 @@ def build_match_read(session: Session, match: Match, *, can_modify: bool = False overall_comment=match.overall_comment, created_by=match.created_by, can_modify=can_modify, + version=match_service.match_version(match), participants=parts, attachments=[ attachment_read(a, f"/api/matches/{match.id}") @@ -143,6 +144,7 @@ def finish_match( win_reason=body.win_reason, overall_comment=body.overall_comment, overall_comment_set=("overall_comment" in body.model_fields_set), + expected_version=body.expected_version, ) audit_service.record( session, @@ -200,6 +202,7 @@ def update_match( win_reason=body.win_reason, win_reason_set=("win_reason" in body.model_fields_set), participants=participants, + expected_version=body.expected_version, ) audit_service.record( session, @@ -278,6 +281,7 @@ def get_attachment( def delete_match( match_id: int, request: Request, + expected_version: str | None = Query(None), session: Session = Depends(get_session), user: User = Depends(get_current_user), ) -> s.OkResponse: @@ -285,7 +289,7 @@ def delete_match( match_service.assert_can_modify(session, match, user) match_id_val = match.id group_id_val = match.group_id - match_service.delete_match(session, match) + match_service.delete_match(session, match, expected_version=expected_version) audit_service.record( session, actor_id=user.id, diff --git a/backend/app/schemas/api.py b/backend/app/schemas/api.py index cc6d9c0..c3b28db 100644 --- a/backend/app/schemas/api.py +++ b/backend/app/schemas/api.py @@ -192,6 +192,8 @@ class MatchFinish(BaseModel): participants: list[MatchFinishParticipant] win_reason: WinReason overall_comment: str | None = None + # Оптимистичная блокировка: версия партии, которую видел клиент (см. MatchRead.version). + expected_version: str | None = None # Полный участник (правка завершённой партии админом). @@ -208,6 +210,7 @@ class MatchUpdate(BaseModel): overall_comment: str | None = None win_reason: WinReason | None = None participants: list[ParticipantInput] | None = None + expected_version: str | None = None # оптимистичная блокировка class MatchParticipantRead(BaseModel): @@ -242,6 +245,7 @@ class MatchRead(BaseModel): overall_comment: str | None = None created_by: int can_modify: bool = False # может ли текущий зритель править/завершать партию + version: str # для оптимистичной блокировки (iso updated_at); клиент шлёт обратно participants: list[MatchParticipantRead] = [] attachments: list[AttachmentRead] = [] diff --git a/backend/app/services/match_service.py b/backend/app/services/match_service.py index 93abd29..f522670 100644 --- a/backend/app/services/match_service.py +++ b/backend/app/services/match_service.py @@ -16,7 +16,7 @@ from app.core.errors import ( NotFoundError, ValidationError, ) -from app.core.timeutil import app_today +from app.core.timeutil import app_today, iso_utc from app.models import Faction, GroupMember, Match, MatchParticipant, User from app.services import group_service @@ -70,6 +70,20 @@ def get_match(session: Session, match_id: int) -> Match: return match +def match_version(match: Match) -> str: + """Версия партии для оптимистичной блокировки (меняется при любом изменении).""" + return iso_utc(match.updated_at) + + +def assert_version(match: Match, expected: str | None) -> None: + """Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию).""" + if expected is not None and expected != match_version(match): + raise ConflictError( + "Партия уже изменена на другом устройстве — обновите страницу.", + code="STALE_WRITE", + ) + + def participants_detail( session: Session, match_id: int ) -> list[tuple[MatchParticipant, User, Faction]]: @@ -193,7 +207,9 @@ def finish_match( win_reason: str, overall_comment: str | None = None, overall_comment_set: bool = False, + expected_version: str | None = None, ) -> Match: + assert_version(match, expected_version) if match.status != "in_progress": raise ConflictError("Партия уже завершена.") if win_reason not in WIN_REASONS: @@ -274,8 +290,10 @@ def update_match( win_reason: str | None = None, win_reason_set: bool = False, participants: list[ParticipantInput] | None = None, + expected_version: str | None = None, ) -> Match: """Правка завершённой партии (админ): полный список участников с местами.""" + assert_version(match, expected_version) if played_at is not None: match.played_at = played_at if overall_comment_set: @@ -317,9 +335,10 @@ def update_match( return match -def delete_match(session: Session, match: Match) -> None: +def delete_match(session: Session, match: Match, expected_version: str | None = None) -> None: from app.services import attachment_service # избегаем цикла импорта + assert_version(match, expected_version) match_id = match.id session.delete(match) # участники и вложения (БД) удалятся каскадом (FK ON DELETE CASCADE) session.commit() diff --git a/backend/tests/test_concurrency.py b/backend/tests/test_concurrency.py new file mode 100644 index 0000000..516621f --- /dev/null +++ b/backend/tests/test_concurrency.py @@ -0,0 +1,77 @@ +"""Оптимистичная блокировка партии: устаревшие правки/удаление отклоняются (STALE_WRITE).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + + +def _start(client: TestClient, engine) -> tuple[dict, int, int]: + 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()] + started = start_match( + client, gid, + [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], + ) + assert started.status_code == 200, started.text + return me, p2, started.json()["id"] + + +def test_stale_delete_rejected(client: TestClient, engine): + """Сценарий бага: ПК завершил, телефон со старой версией жмёт «Отменить».""" + me, p2, mid = _start(client, engine) + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + # «ПК» завершает партию — версия меняется. + 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 + + # «Телефон» со старой версией пытается отменить → 409 STALE_WRITE, партия НЕ удаляется. + stale = client.delete( + f"/api/matches/{mid}", params={"expected_version": v1}, headers=csrf_headers(client) + ) + assert stale.status_code == 409, stale.text + assert stale.json()["error"]["code"] == "STALE_WRITE" + assert client.get(f"/api/matches/{mid}").status_code == 200 # жива + + # С актуальной версией удаление проходит. + v2 = client.get(f"/api/matches/{mid}").json()["version"] + ok = client.delete( + f"/api/matches/{mid}", params={"expected_version": v2}, headers=csrf_headers(client) + ) + assert ok.status_code == 200, ok.text + assert client.get(f"/api/matches/{mid}").status_code == 404 + + +def test_stale_finish_rejected(client: TestClient, engine): + me, p2, mid = _start(client, engine) + v1 = client.get(f"/api/matches/{mid}").json()["version"] + + # Партию изменили (правка комментария) — версия устарела. + bump = client.patch( + f"/api/matches/{mid}", json={"overall_comment": "правка"}, headers=csrf_headers(client) + ) + assert bump.status_code == 200, bump.text + + # Завершение со старой версией → 409 STALE_WRITE. + r = client.post( + f"/api/matches/{mid}/finish", + json={ + "participants": [ + {"user_id": me["id"], "place": 1}, + {"user_id": p2, "place": 2}, + ], + "win_reason": "objectives", + "expected_version": v1, + }, + headers=csrf_headers(client), + ) + assert r.status_code == 409 and r.json()["error"]["code"] == "STALE_WRITE", r.text diff --git a/frontend/src/api/schema.d.ts b/frontend/src/api/schema.d.ts index a309b3c..ac111cd 100644 --- a/frontend/src/api/schema.d.ts +++ b/frontend/src/api/schema.d.ts @@ -1507,6 +1507,8 @@ export interface components { win_reason: "objectives" | "worlds" | "plastic" | "resources"; /** Overall Comment */ overall_comment?: string | null; + /** Expected Version */ + expected_version?: string | null; }; /** MatchFinishParticipant */ MatchFinishParticipant: { @@ -1627,6 +1629,8 @@ export interface components { * @default false */ can_modify: boolean; + /** Version */ + version: string; /** * Participants * @default [] @@ -1648,6 +1652,8 @@ export interface components { win_reason?: ("objectives" | "worlds" | "plastic" | "resources") | null; /** Participants */ participants?: components["schemas"]["ParticipantInput"][] | null; + /** Expected Version */ + expected_version?: string | null; }; /** MeRead */ MeRead: { @@ -2882,7 +2888,9 @@ export interface operations { }; delete_match_api_matches__match_id__delete: { parameters: { - query?: never; + query?: { + expected_version?: string | null; + }; header?: never; path: { match_id: number; diff --git a/frontend/src/hooks/matches.ts b/frontend/src/hooks/matches.ts index 75f9949..90e2b94 100644 --- a/frontend/src/hooks/matches.ts +++ b/frontend/src/hooks/matches.ts @@ -70,10 +70,14 @@ export function useFinishMatch() { export function useDeleteMatch() { const qc = useQueryClient(); return useMutation({ - mutationFn: async (matchId: number) => + // expectedVersion — оптимистичная блокировка: отмена устаревшей версии вернёт 409 STALE_WRITE. + mutationFn: async (args: { matchId: number; expectedVersion?: string }) => unwrap( await api.DELETE("/api/matches/{match_id}", { - params: { path: { match_id: matchId } }, + params: { + path: { match_id: args.matchId }, + query: args.expectedVersion ? { expected_version: args.expectedVersion } : {}, + }, }), ), onSuccess: () => qc.invalidateQueries(), diff --git a/frontend/src/pages/MatchDetailPage.tsx b/frontend/src/pages/MatchDetailPage.tsx index 3232152..e72d97d 100644 --- a/frontend/src/pages/MatchDetailPage.tsx +++ b/frontend/src/pages/MatchDetailPage.tsx @@ -26,7 +26,7 @@ interface FinishRow { export function MatchDetailPage() { const { matchId } = useParams(); const id = matchId ? Number(matchId) : null; - const { data: match, isLoading } = useMatch(id); + const { data: match, isLoading, refetch } = useMatch(id); const finish = useFinishMatch(); const del = useDeleteMatch(); const uploadAtt = useUploadMatchAttachment(id ?? 0); @@ -59,8 +59,11 @@ export function MatchDetailPage() { const upd = (i: number, patch: Partial) => setRows(finishRows.map((r, idx) => (idx === i ? { ...r, ...patch } : r))); + // Конфликт версий (кто-то изменил партию с другого устройства) → сообщаем и обновляем. + const isStale = (e: unknown) => e instanceof ApiError && e.code === "STALE_WRITE"; + const submitFinish = async () => { - if (!id) return; + if (!id || !match) return; setError(null); try { await finish.mutateAsync({ @@ -73,19 +76,34 @@ export function MatchDetailPage() { })), win_reason: winReason, overall_comment: overall.trim() || null, + expected_version: match.version, }, }); toast.show("Партия завершена"); } catch (e) { + if (isStale(e)) { + toast.show("Партия изменилась на другом устройстве — обновлено"); + refetch(); + return; + } setError(e instanceof ApiError ? e.message : "Не удалось завершить партию"); } }; const remove = async () => { - if (!id) return; - await del.mutateAsync(id).catch(() => {}); - toast.show(inProgress ? "Партия отменена" : "Партия удалена"); - navigate(-1); + if (!id || !match) return; + try { + await del.mutateAsync({ matchId: id, expectedVersion: match.version }); + toast.show(inProgress ? "Партия отменена" : "Партия удалена"); + navigate(-1); + } catch (e) { + if (isStale(e)) { + toast.show("Партия изменилась на другом устройстве — обновлено"); + refetch(); + return; + } + toast.show(e instanceof ApiError ? e.message : "Не удалось выполнить"); + } }; const sorted = [...match.participants].sort(