From bdd07e8103d2672620286cb6586fe1a3eae34b5d Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 20:47:32 +0300 Subject: [PATCH] =?UTF-8?q?SSE:=20=D1=81=D0=BE=D0=B1=D1=8B=D1=82=D0=B8?= =?UTF-8?q?=D0=B5=20=D0=BF=D0=B0=D1=80=D1=82=D0=B8=D0=B8=20=D0=BD=D0=B5?= =?UTF-8?q?=D1=81=D1=91=D1=82=20=D1=83=D1=87=D0=B0=D1=81=D1=82=D0=BD=D0=B8?= =?UTF-8?q?=D0=BA=D0=BE=D0=B2,=20=D0=BA=D0=BB=D0=B8=D0=B5=D0=BD=D1=82=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B4=D1=91=D1=80=D0=B3=D0=B0=D0=B5=D1=82=20?= =?UTF-8?q?=D0=BB=D0=B8=D1=88=D0=BD=D0=B8=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Событие о партии рассылается всей группе, и клиент по нему инвалидировал историю игр, публичные профили и личную статистику получателя. В группе из шести человек любая партия двоих заставляла остальные четыре вкладки перезапрашивать свою историю и открытый профиль, хотя у них ничего не изменилось. Теперь событие несёт participant_ids. Общие витрины (карточка партии, списки и статистика группы, топ и главная) обновляются у всех — рейтинг глобальный, чужая партия действительно двигает топ. История, публичный профиль и личная статистика обновляются только у тех, кто играл, и у зрителей их профилей. При удалении партии участники собираются ДО удаления: каскад уносит их строки вместе с партией, и собранный после список всегда был бы пустым. Это же поведение закреплено тестом. Событие без participant_ids (вкладка открыта до обновления сервера) обрабатывается по-старому, широко: обновление бэкенда не ломает уже открытые страницы. #33 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- backend/app/routers/admin.py | 4 +- backend/app/routers/matches.py | 5 +- backend/app/services/notify.py | 40 +++++++++++--- backend/tests/test_events_payload.py | 77 +++++++++++++++++++++++++++ frontend/src/hooks/useServerEvents.ts | 30 +++++++++-- 5 files changed, 145 insertions(+), 11 deletions(-) create mode 100644 backend/tests/test_events_payload.py diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 4e858b9..5d58805 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -293,13 +293,15 @@ def delete_match( 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) 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) + notify.match_removed(session, match_id, group_id, participant_ids) return s.OkResponse() diff --git a/backend/app/routers/matches.py b/backend/app/routers/matches.py index eb16f01..8778b7c 100644 --- a/backend/app/routers/matches.py +++ b/backend/app/routers/matches.py @@ -335,6 +335,9 @@ 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] match_service.delete_match(session, match, expected_version=expected_version) audit_service.record( session, @@ -347,5 +350,5 @@ def delete_match( user_agent=request.headers.get("user-agent"), ) session.commit() - notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type] + notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type] return s.OkResponse() diff --git a/backend/app/services/notify.py b/backend/app/services/notify.py index b31482a..849c3de 100644 --- a/backend/app/services/notify.py +++ b/backend/app/services/notify.py @@ -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 +from app.models import GroupMember, Match, MatchParticipant def _group_member_ids(session: Session, group_id: int) -> list[int]: @@ -17,11 +17,29 @@ 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) + ).all() + ) + + 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}, + { + "type": "match", + "match_id": match.id, + "group_id": match.group_id, + "participant_ids": match_participant_ids(session, match.id), # type: ignore[arg-type] + }, ) @@ -35,11 +53,21 @@ 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) -> None: - """Партия удалена — уведомить участников группы (обновить списки).""" +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}, + { + "type": "match", + "match_id": match_id, + "group_id": group_id, + "participant_ids": participant_ids or [], + }, ) diff --git a/backend/tests/test_events_payload.py b/backend/tests/test_events_payload.py new file mode 100644 index 0000000..63bd3db --- /dev/null +++ b/backend/tests/test_events_payload.py @@ -0,0 +1,77 @@ +"""Событие партии несёт список участников: по нему клиент решает, чьи витрины протухли.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from tests.conftest import add_group_member, csrf_headers, finish_match, login, start_match + + +def _capture_events(monkeypatch) -> list[tuple[list[int], dict]]: + from app.core import events + + published: list[tuple[list[int], dict]] = [] + monkeypatch.setattr( + events.hub, "publish", lambda ids, event: published.append((list(ids), event)) + ) + 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 test_match_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") + # Третий в группе, но НЕ в партии: его история от этой партии не меняется. + p3 = add_group_member(engine, gid, "Зритель") + fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()] + + published = _capture_events(monkeypatch) + 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 + 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"] + + published.clear() + fin = finish_match( + client, mid, [{"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]) + + +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()] + mid = start_match( + client, gid, + [{"user_id": me["id"], "faction_id": fids[0]}, {"user_id": p2, "faction_id": fids[1]}], + ).json()["id"] + + published = _capture_events(monkeypatch) + version = client.get(f"/api/matches/{mid}").json()["version"] + r = client.delete( + 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]) diff --git a/frontend/src/hooks/useServerEvents.ts b/frontend/src/hooks/useServerEvents.ts index 90a4258..d18b957 100644 --- a/frontend/src/hooks/useServerEvents.ts +++ b/frontend/src/hooks/useServerEvents.ts @@ -1,12 +1,15 @@ import { useQueryClient } from "@tanstack/react-query"; -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { matchAffectedKeys, qk } from "../api/queryKeys"; +import { useMe } from "./auth"; interface ServerEvent { type: "match" | "match_draft" | "group" | "invitations" | "notifications"; match_id?: number; group_id?: number; + /** Кто играл в партии: их история и профили протухли, чужие — нет. */ + participant_ids?: number[]; } /** @@ -16,6 +19,11 @@ interface ServerEvent { */ export function useServerEvents(enabled: boolean) { const qc = useQueryClient(); + const { data: me } = useMe(); + // Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали + // SSE-соединение каждый раз, когда профиль перезапрашивается. + const myId = useRef(null); + myId.current = me?.id ?? null; useEffect(() => { if (!enabled) return; const base = import.meta.env.VITE_API_BASE_URL || ""; @@ -42,8 +50,24 @@ export function useServerEvents(enabled: boolean) { qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); } - // История игр и публичные профили тоже меняются от чужой партии. - for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key }); + // Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ. + 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 }); + } } else if (ev.type === "group") { if (ev.group_id != null) { qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); -- 2.54.0