Files
ForbiddenStarsApp/backend/app/routers/matches.py
T
NotBigGhostandClaude Opus 5 bdd07e8103 SSE: событие партии несёт участников, клиент не дёргает лишних
Событие о партии рассылается всей группе, и клиент по нему инвалидировал историю
игр, публичные профили и личную статистику получателя. В группе из шести человек
любая партия двоих заставляла остальные четыре вкладки перезапрашивать свою
историю и открытый профиль, хотя у них ничего не изменилось.

Теперь событие несёт participant_ids. Общие витрины (карточка партии, списки и
статистика группы, топ и главная) обновляются у всех — рейтинг глобальный, чужая
партия действительно двигает топ. История, публичный профиль и личная статистика
обновляются только у тех, кто играл, и у зрителей их профилей.

При удалении партии участники собираются ДО удаления: каскад уносит их строки
вместе с партией, и собранный после список всегда был бы пустым. Это же
поведение закреплено тестом.

Событие без participant_ids (вкладка открыта до обновления сервера) обрабатывается
по-старому, широко: обновление бэкенда не ломает уже открытые страницы.

#33

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
2026-09-09 20:47:32 +03:00

355 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Роутер партий: рандом фракции, старт, завершение, детали, правка, удаление."""
from __future__ import annotations
from fastapi import APIRouter, Depends, File, Query, Request, UploadFile
from fastapi.responses import FileResponse
from sqlmodel import Session
from app.auth.deps import get_current_user
from app.core.security import client_ip
from app.core.errors import ConflictError, NoGroupError, NotFoundError
from app.core.timeutil import iso_utc
from app.db.session import get_session
from app.models import Match, MatchAttachment, User
from app.schemas import api as s
from app.services import (
attachment_service,
audit_service,
group_service,
match_service,
notification_service,
notify,
user_service,
)
from app.services.match_service import FinishInput, ParticipantInput, RosterInput
router = APIRouter(prefix="/matches", tags=["matches"])
def attachment_read(att: MatchAttachment, base: str) -> s.AttachmentRead:
"""AttachmentRead с URL под нужным префиксом (base = '/api/matches/{id}' или
'/api/admin/matches/{id}'); cookie игрока/админа доходит до своего пути."""
return s.AttachmentRead(
id=att.id, # type: ignore[arg-type]
kind=att.kind,
url=f"{base}/attachments/{att.id}",
mime_type=att.mime_type,
size_bytes=att.size_bytes,
created_at=iso_utc(att.created_at),
)
def _ensure_has_any_group(session: Session, user: User) -> None:
if not group_service.list_user_groups(session, user.id): # type: ignore[arg-type]
raise NoGroupError()
def build_match_read(session: Session, match: Match, *, can_modify: bool = False) -> s.MatchRead:
parts = [
s.MatchParticipantRead(
user_id=u.id, # type: ignore[arg-type]
nickname=u.nickname,
faction_id=f.id, # type: ignore[arg-type]
faction_name=f.name_ru,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
avatar_url=user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at), # type: ignore[arg-type]
)
for p, u, f in match_service.participants_detail(session, match.id) # type: ignore[arg-type]
]
draft_row = match_service.get_finish_draft(session, match.id) # type: ignore[arg-type]
draft = None
if draft_row is not None:
author = session.get(User, draft_row.updated_by) if draft_row.updated_by else None
draft = s.MatchFinishDraftRead(
data=s.MatchFinishDraftData(**draft_row.data),
updated_by=draft_row.updated_by,
updated_by_nickname=author.nickname if author else None,
updated_at=iso_utc(draft_row.updated_at),
)
return s.MatchRead(
id=match.id, # type: ignore[arg-type]
group_id=match.group_id,
status=match.status,
played_at=match.played_at,
started_at=iso_utc(match.started_at),
finished_at=iso_utc(match.finished_at),
duration_minutes=match.duration_minutes,
win_reason=match.win_reason, # type: ignore[arg-type]
player_count=match.player_count,
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}")
for a in attachment_service.list_for_match(session, match.id) # type: ignore[arg-type]
],
finish_draft=draft,
)
@router.post("/randomize-faction", response_model=s.RandomizeResponse)
def randomize_faction(
body: s.RandomizeRequest,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.RandomizeResponse:
_ensure_has_any_group(session, user)
group_service.assert_member(session, body.group_id, user.id) # type: ignore[arg-type]
faction = match_service.randomize_faction(session, body.group_id, body.exclude_faction_ids)
return s.RandomizeResponse(
faction=s.FactionRead(
id=faction.id, code=faction.code, name_ru=faction.name_ru, expansion_id=faction.expansion_id # type: ignore[arg-type]
)
)
@router.post("", response_model=s.MatchRead)
def start_match(
body: s.MatchCreate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
"""Этап 1: старт партии (выбор игроков и фракций)."""
_ensure_has_any_group(session, user)
roster = [
RosterInput(user_id=p.user_id, faction_id=p.faction_id, was_random=p.was_random)
for p in body.participants
]
match = match_service.create_match(session, user, group_id=body.group_id, roster=roster)
audit_service.record(
session,
actor_id=user.id,
action="create",
entity_type="match",
entity_id=match.id,
payload={"group_id": match.group_id, "player_count": match.player_count, "status": "in_progress"},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_changed(session, match)
notification_service.match_started(session, match, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.post("/{match_id}/finish", response_model=s.MatchRead)
def finish_match(
match_id: int,
body: s.MatchFinish,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
"""Этап 2: завершение партии (места, причина победы, длительность)."""
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
finish = [
FinishInput(
user_id=p.user_id,
place=p.place,
eliminated=p.eliminated,
comment=p.comment,
faction_id=p.faction_id,
)
for p in body.participants
]
match = match_service.finish_match(
session,
match,
finish=finish,
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,
actor_id=user.id,
action="update",
entity_type="match",
entity_id=match.id,
payload={"event": "finish", "win_reason": match.win_reason, "duration_minutes": match.duration_minutes},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_changed(session, match)
notification_service.match_finished(session, match, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.get("/{match_id}", response_model=s.MatchRead)
def get_match(
match_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type]
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.patch("/{match_id}", response_model=s.MatchRead)
def update_match(
match_id: int,
body: s.MatchUpdate,
request: Request,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
participants = None
if body.participants is not None:
participants = [
ParticipantInput(
user_id=p.user_id,
faction_id=p.faction_id,
place=p.place,
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment,
)
for p in body.participants
]
match = match_service.update_match(
session,
match,
played_at=body.played_at,
overall_comment=body.overall_comment,
overall_comment_set=("overall_comment" in body.model_fields_set),
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,
actor_id=user.id,
action="update",
entity_type="match",
entity_id=match.id,
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_changed(session, match)
return build_match_read(session, match, can_modify=match_service.can_modify(session, match, user))
@router.put("/{match_id}/finish-draft", response_model=s.MatchRead)
def save_finish_draft(
match_id: int,
body: s.MatchFinishDraftData,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.MatchRead:
"""Общий черновик формы завершения: то, что видят все, кто заполняет партию.
Права те же, что у самой формы. Версию партии запись черновика не двигает —
иначе «Завершить» у второго участника ловил бы STALE_WRITE на каждую чужую правку."""
match = match_service.get_match(session, match_id)
match_service.assert_can_modify(session, match, user)
match_service.save_finish_draft(session, match, user, body.model_dump())
notify.match_draft_changed(session, match, actor_id=user.id) # type: ignore[arg-type]
return build_match_read(
session, match, can_modify=match_service.can_modify(session, match, user)
)
# ─── Медиа партии (фото) ──────────────────────────────────────────────────────
def _assert_can_attach(session: Session, match: Match, user: User) -> None:
match_service.assert_can_modify(session, match, user)
if match.status != "in_progress":
raise ConflictError("Медиа можно прикреплять только до завершения партии.")
@router.post("/{match_id}/attachments", response_model=s.AttachmentRead)
def add_attachment(
match_id: int,
file: UploadFile = File(...),
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.AttachmentRead:
match = match_service.get_match(session, match_id)
_assert_can_attach(session, match, user)
content, ext = user_service.read_capped_image(
file, attachment_service.MAX_ATTACHMENT_BYTES, "Файл слишком большой (макс. 10 МБ)."
)
att = attachment_service.add_photo(
session, match, user, content, ext, user_service.avatar_media_type(ext)
)
notify.match_changed(session, match)
return attachment_read(att, f"/api/matches/{match_id}")
@router.delete("/{match_id}/attachments/{attachment_id}", response_model=s.OkResponse)
def delete_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> s.OkResponse:
match = match_service.get_match(session, match_id)
_assert_can_attach(session, match, user)
attachment_service.delete(session, match, attachment_id)
notify.match_changed(session, match)
return s.OkResponse()
@router.get("/{match_id}/attachments/{attachment_id}")
def get_attachment(
match_id: int,
attachment_id: int,
session: Session = Depends(get_session),
user: User = Depends(get_current_user),
) -> FileResponse:
match = match_service.get_match(session, match_id)
group_service.assert_member(session, match.group_id, user.id) # type: ignore[arg-type]
att = attachment_service.get_for_match(session, match_id, attachment_id)
path = attachment_service.file_path(att)
if not path.exists():
raise NotFoundError("Файл не найден.")
return FileResponse(
path, media_type=att.mime_type, headers={"Cache-Control": "private, max-age=3600"}
)
@router.delete("/{match_id}", response_model=s.OkResponse)
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:
match = match_service.get_match(session, match_id)
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,
actor_id=user.id,
action="delete",
entity_type="match",
entity_id=match_id_val,
payload={"group_id": group_id_val},
ip=client_ip(request),
user_agent=request.headers.get("user-agent"),
)
session.commit()
notify.match_removed(session, match_id_val, group_id_val, participant_ids) # type: ignore[arg-type]
return s.OkResponse()