Files
ForbiddenStarsApp/backend/app/services/match_service.py
T
NotBigGhostandClaude Opus 5 9d9e4a345a Ревью: безопасность и корректность в сервисах бэкенда
Находки прохода /code-review high по backend/app:

- achievement_service: slug из URL шёл в путь без проверки, из-за чего
  DELETE /api/admin/achievements/%2E%2E удалял rmtree'ом родительскую папку
  каталога ачивок (в проде это /data — БД, uploads, ачивки целиком).
- match_service/attachment_service: версия партии = updated_at, но onupdate
  срабатывает лишь при реальном UPDATE строки matches. Правка одних участников
  и работа с вложениями его не вызывали, и оптимистичная блокировка молча
  пропускала конкурентную запись — бампаем updated_at явно.
- admin_service: удаление группы с партиями упиралось в RESTRICT и уходило
  наружу голым 500; теперь понятная ошибка. Админское удаление партии не
  чистило файлы вложений с тома — они оставались навсегда.
- user_service: при повторной загрузке аватара с тем же расширением avatar_path
  не менялся, updated_at не двигался, и кэш-бастер оставлял старую картинку до
  часа. Плюс версия считалась из наивного времени как из локального и
  разъезжалась с лидербордом, где то же поле считает SQL.
- membership_service: единственный владелец мог разжаловать сам себя и группа
  оставалась без владельца навсегда.
- notification_service: mark_read не слал SSE-сигнал, и бейдж непрочитанных на
  других устройствах висел до перезагрузки.
- routers/admin: created_at после правки пользователя отдавался без смещения,
  и дата «создан» прыгала на часовой пояс до следующего обновления списка.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0186Fk74jkkszahEHSjBzTjD
2026-09-09 15:24:52 +03:00

372 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
import random
from dataclasses import dataclass
from datetime import date, datetime, timezone
from sqlmodel import Session, select
from app.core.errors import (
ConflictError,
DuplicateParticipantError,
FactionNotAvailableError,
ForbiddenError,
InvalidRankingError,
NotFoundError,
ValidationError,
)
from app.core.timeutil import app_today, iso_utc
from app.models import Faction, GroupMember, Match, MatchParticipant, User
from app.services import group_service
MAX_MATCH_PLAYERS = 6
WIN_REASONS = ("objectives", "worlds", "plastic", "resources")
@dataclass
class RosterInput:
"""Участник на этапе старта (мест ещё нет)."""
user_id: int
faction_id: int
was_random: bool = False
@dataclass
class FinishInput:
"""Результат участника на этапе завершения."""
user_id: int
place: int | None = None # None у выбывшего (eliminated) — место проставится авто
eliminated: bool = False
comment: str | None = None
faction_id: int | None = None # опц. смена фракции при завершении
@dataclass
class ParticipantInput:
"""Полный участник (для правки завершённой партии админом)."""
user_id: int
faction_id: int
place: int | None = None
eliminated: bool = False
was_random: bool = False
comment: str | None = None
def _utcnow() -> datetime:
return datetime.now(timezone.utc)
def round_to_30(minutes: float) -> int:
"""Округление длительности до получаса, минимум 30 минут."""
return max(30, int(round(minutes / 30.0)) * 30)
def get_match(session: Session, match_id: int) -> Match:
match = session.get(Match, match_id)
if match is None:
raise NotFoundError("Партия не найдена.")
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]]:
rows = session.exec(
select(MatchParticipant, User, Faction)
.join(User, User.id == MatchParticipant.user_id)
.join(Faction, Faction.id == MatchParticipant.faction_id)
.where(MatchParticipant.match_id == match_id)
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
).all()
return [(p, u, f) for p, u, f in rows]
def randomize_faction(
session: Session, group_id: int, exclude_faction_ids: list[int] | None = None
) -> Faction:
exclude = set(exclude_faction_ids or [])
pool = [f for f in group_service.available_factions(session, group_id) if f.id not in exclude]
if not pool:
raise ValidationError("Нет доступных фракций для рандома.")
return random.choice(pool)
def _validate_ranking(places: list[int]) -> None:
"""Проверяет competition ranking (1,2,2,4) с допуском ничьих."""
if any(p < 1 for p in places):
raise InvalidRankingError("Место должно быть ≥ 1.")
ordered = sorted(places)
expected = 1
i = 0
n = len(ordered)
while i < n:
current = ordered[i]
if current != expected:
raise InvalidRankingError(
"Места должны идти по правилу 1,2,2,4 (без пропусков перед группой ничьих)."
)
tie = 0
while i < n and ordered[i] == current:
tie += 1
i += 1
expected = current + tie
def _resolve_finish_places(rows: list[tuple[int, int | None, bool]]) -> dict[int, int]:
"""rows: (user_id, place, eliminated) → {user_id: место}.
Выжившим (не eliminated) валидируем competition ranking (1,2,2,4). Выбывшим
проставляем общее последнее место = len(survivors)+1 (ничья последнего ранга),
чтобы подсчёт очков считал их как последнее место без отдельных правок SQL."""
survivors = [(uid, place) for uid, place, elim in rows if not elim]
if not survivors:
raise InvalidRankingError("Хотя бы один игрок должен остаться в партии (с местом).")
if any(place is None for _uid, place in survivors):
raise InvalidRankingError("Укажите место каждому не выбывшему игроку.")
_validate_ranking([place for _uid, place in survivors]) # type: ignore[misc]
elim_place = len(survivors) + 1
return {uid: (elim_place if elim else place) for uid, place, elim in rows} # type: ignore[misc]
def _group_member_ids(session: Session, group_id: int) -> set[int]:
return {
m.user_id
for m in session.exec(
select(GroupMember).where(GroupMember.group_id == group_id)
).all()
}
def _validate_roster_basics(
session: Session,
group_id: int,
user_ids: list[int],
faction_ids: list[int],
) -> None:
if len(user_ids) < 2:
raise ValidationError("В партии должно быть не менее 2 участников.")
if len(user_ids) > MAX_MATCH_PLAYERS:
raise ValidationError(f"В партии не может быть больше {MAX_MATCH_PLAYERS} игроков.")
if len(set(user_ids)) != len(user_ids) or len(set(faction_ids)) != len(faction_ids):
raise DuplicateParticipantError()
if not set(user_ids).issubset(_group_member_ids(session, group_id)):
raise ValidationError("Все участники должны состоять в группе.")
if not set(faction_ids).issubset(group_service.available_faction_ids(session, group_id)):
raise FactionNotAvailableError()
# ─── Этап 1: старт партии ─────────────────────────────────────────────────────
def create_match(
session: Session,
creator: User,
*,
group_id: int,
roster: list[RosterInput],
) -> Match:
group_service.assert_member(session, group_id, creator.id) # type: ignore[arg-type]
_validate_roster_basics(
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
)
now = _utcnow()
match = Match(
group_id=group_id,
status="in_progress",
played_at=app_today(), # дата игры — в поясе приложения (+3)
started_at=now,
player_count=len(roster),
created_by=creator.id, # type: ignore[arg-type]
)
session.add(match)
session.flush()
for r in roster:
session.add(
MatchParticipant(
match_id=match.id, # type: ignore[arg-type]
user_id=r.user_id,
faction_id=r.faction_id,
place=None,
was_random=r.was_random,
)
)
session.commit()
session.refresh(match)
return match
# ─── Этап 2: завершение партии ────────────────────────────────────────────────
def finish_match(
session: Session,
match: Match,
*,
finish: list[FinishInput],
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:
raise ValidationError("Укажите корректную причину победы.")
existing = {
p.user_id: p
for p in session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all()
}
if {f.user_id for f in finish} != set(existing.keys()):
raise ValidationError("Нужно указать результат по всем участникам партии.")
# Эффективные фракции (с учётом возможной замены при завершении).
eff_factions = {
f.user_id: (f.faction_id if f.faction_id is not None else existing[f.user_id].faction_id)
for f in finish
}
fids = list(eff_factions.values())
if len(set(fids)) != len(fids):
raise DuplicateParticipantError()
if not set(fids).issubset(group_service.available_faction_ids(session, match.group_id)):
raise FactionNotAvailableError()
places = _resolve_finish_places([(f.user_id, f.place, f.eliminated) for f in finish])
for f in finish:
p = existing[f.user_id]
p.place = places[f.user_id]
p.eliminated = f.eliminated
p.comment = f.comment or None
if f.faction_id is not None:
p.faction_id = f.faction_id
session.add(p)
now = _utcnow()
match.finished_at = now
started = match.started_at
if started is not None:
if started.tzinfo is not None:
started = started.replace(tzinfo=None)
# max(0, ...) на случай перекоса часов — не уходим в отрицательную длительность.
elapsed_min = max(0.0, (now.replace(tzinfo=None) - started).total_seconds() / 60.0)
match.duration_minutes = round_to_30(elapsed_min)
match.status = "finished"
match.win_reason = win_reason
if overall_comment_set:
match.overall_comment = overall_comment or None
session.add(match)
session.commit()
session.refresh(match)
return match
# ─── Права / правка / удаление ────────────────────────────────────────────────
def can_modify(session: Session, match: Match, user: User) -> bool:
# Управление партиями доступно любому участнику группы (а также админу).
if user.role == "admin":
return True
member = group_service.get_membership(session, match.group_id, user.id) # type: ignore[arg-type]
return member is not None
def assert_can_modify(session: Session, match: Match, user: User) -> None:
if not can_modify(session, match, user):
raise ForbiddenError("Недостаточно прав для изменения партии.")
def update_match(
session: Session,
match: Match,
*,
played_at: date | None = None,
overall_comment: str | None = None,
overall_comment_set: bool = False,
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:
match.overall_comment = overall_comment or None
if win_reason_set:
if win_reason is not None and win_reason not in WIN_REASONS:
raise ValidationError("Некорректная причина победы.")
match.win_reason = win_reason
if participants is not None:
_validate_roster_basics(
session,
match.group_id,
[p.user_id for p in participants],
[p.faction_id for p in participants],
)
places = _resolve_finish_places(
[(p.user_id, p.place, p.eliminated) for p in participants]
)
for old in session.exec(
select(MatchParticipant).where(MatchParticipant.match_id == match.id)
).all():
session.delete(old)
session.flush()
for p in participants:
session.add(
MatchParticipant(
match_id=match.id, # type: ignore[arg-type]
user_id=p.user_id,
faction_id=p.faction_id,
place=places[p.user_id],
eliminated=p.eliminated,
was_random=p.was_random,
comment=p.comment or None,
)
)
match.player_count = len(participants)
# Версия партии = updated_at, а onupdate срабатывает только при реальном UPDATE
# строки matches. Правка одних участников его не вызывает, и тогда оптимистичная
# блокировка молча пропускала бы конкурентную запись — поэтому бампаем явно.
match.updated_at = _utcnow()
session.add(match)
session.commit()
session.refresh(match)
return match
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()
attachment_service.delete_match_files(match_id) # type: ignore[arg-type] # файлы с диска