SSE-событие партии заставляет перезапрашивать данные всю группу (#33) #44

Merged
NotBigGhost merged 1 commits from issue-33-sse-participants into dev 2026-09-09 20:49:04 +03:00
5 changed files with 145 additions and 11 deletions
Showing only changes of commit bdd07e8103 - Show all commits
+3 -1
View File
@@ -293,13 +293,15 @@ def delete_match(
admin: User = Depends(get_current_admin), admin: User = Depends(get_current_admin),
) -> s.OkResponse: ) -> s.OkResponse:
group_id = match_service.get_match(session, match_id).group_id # для уведомления 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) admin_service.delete_match(session, match_id)
audit_service.record( audit_service.record(
session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id, session, actor_id=admin.id, action="delete", entity_type="match", entity_id=match_id,
ip=client_ip(request), ip=client_ip(request),
) )
session.commit() session.commit()
notify.match_removed(session, match_id, group_id) notify.match_removed(session, match_id, group_id, participant_ids)
return s.OkResponse() return s.OkResponse()
+4 -1
View File
@@ -335,6 +335,9 @@ def delete_match(
match_service.assert_can_modify(session, match, user) match_service.assert_can_modify(session, match, user)
match_id_val = match.id match_id_val = match.id
group_id_val = match.group_id group_id_val = match.group_id
# Участников читаем до удаления: каскад унесёт их строки вместе с партией,
# а событию они нужны, чтобы клиент знал, чьи витрины протухли.
participant_ids = notify.match_participant_ids(session, match_id_val) # type: ignore[arg-type]
match_service.delete_match(session, match, expected_version=expected_version) match_service.delete_match(session, match, expected_version=expected_version)
audit_service.record( audit_service.record(
session, session,
@@ -347,5 +350,5 @@ def delete_match(
user_agent=request.headers.get("user-agent"), user_agent=request.headers.get("user-agent"),
) )
session.commit() session.commit()
notify.match_removed(session, match_id_val, group_id_val) # type: ignore[arg-type] notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
return s.OkResponse() return s.OkResponse()
+34 -6
View File
@@ -8,7 +8,7 @@ from __future__ import annotations
from sqlmodel import Session, select from sqlmodel import Session, select
from app.core.events import hub from app.core.events import hub
from app.models import GroupMember, Match from app.models import GroupMember, Match, MatchParticipant
def _group_member_ids(session: Session, group_id: int) -> list[int]: 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: def match_changed(session: Session, match: Match) -> None:
"""Партия изменилась — уведомить всех участников её группы.""" """Партия изменилась — уведомить всех участников её группы.
Адресат — вся группа: списки партий и статистика группы меняются у всех. А вот
история и профили протухают только у игравших, поэтому их id едут в событии."""
hub.publish( hub.publish(
_group_member_ids(session, match.group_id), _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}) 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( hub.publish(
_group_member_ids(session, group_id), _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 [],
},
) )
+77
View File
@@ -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])
+26 -2
View File
@@ -1,12 +1,15 @@
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { useEffect } from "react"; import { useEffect, useRef } from "react";
import { matchAffectedKeys, qk } from "../api/queryKeys"; import { matchAffectedKeys, qk } from "../api/queryKeys";
import { useMe } from "./auth";
interface ServerEvent { interface ServerEvent {
type: "match" | "match_draft" | "group" | "invitations" | "notifications"; type: "match" | "match_draft" | "group" | "invitations" | "notifications";
match_id?: number; match_id?: number;
group_id?: number; group_id?: number;
/** Кто играл в партии: их история и профили протухли, чужие — нет. */
participant_ids?: number[];
} }
/** /**
@@ -16,6 +19,11 @@ interface ServerEvent {
*/ */
export function useServerEvents(enabled: boolean) { export function useServerEvents(enabled: boolean) {
const qc = useQueryClient(); const qc = useQueryClient();
const { data: me } = useMe();
// Свой id — в ref: положив его в зависимости эффекта, мы бы пересоздавали
// SSE-соединение каждый раз, когда профиль перезапрашивается.
const myId = useRef<number | null>(null);
myId.current = me?.id ?? null;
useEffect(() => { useEffect(() => {
if (!enabled) return; if (!enabled) return;
const base = import.meta.env.VITE_API_BASE_URL || ""; const base = import.meta.env.VITE_API_BASE_URL || "";
@@ -42,8 +50,24 @@ export function useServerEvents(enabled: boolean) {
qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.groupMatches(ev.group_id) });
qc.invalidateQueries({ queryKey: qk.groupStats(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.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 }); for (const key of matchAffectedKeys) qc.invalidateQueries({ queryKey: key });
}
} else if (ev.type === "group") { } else if (ev.type === "group") {
if (ev.group_id != null) { if (ev.group_id != null) {
qc.invalidateQueries({ queryKey: qk.group(ev.group_id) }); qc.invalidateQueries({ queryKey: qk.group(ev.group_id) });