SSE-событие партии заставляет перезапрашивать данные всю группу (#33) #44
@@ -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()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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 [],
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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])
|
||||||
@@ -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) });
|
||||||
}
|
}
|
||||||
// История игр и публичные профили тоже меняются от чужой партии.
|
// Общее меняется от любой партии: рейтинг глобальный, и чужая игра двигает топ.
|
||||||
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") {
|
} 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) });
|
||||||
|
|||||||
Reference in New Issue
Block a user