Simplify: единый источник времени, версии партии и запросов

Проход /simplify по backend/app:

- Три источника «текущего времени» (_utcnow в models.py и match_service.py при
  живом timeutil.utcnow) сведены к одному — это прямо инвариант из CLAUDE.md.
- Бамп версии партии из двух независимых мест собран в match_service.touch:
  следующая точка мутации, не трогающая строку matches, теперь имеет очевидный
  способ сделать правильно.
- membership_service переиспользует group_service.get_membership вместо трёх
  копий одного запроса; защита последнего владельца — один хелпер на удаление
  и смену роли вместо двух похожих блоков.
- Убраны N+1: участники страницы партий и ники пригласивших берутся одним
  запросом вместо запроса на строку (20 партий = 20 лишних запросов с двумя
  join каждый).
- Счётчики партий считает СУБД (COUNT/MAX) вместо выгрузки всех строк ради
  len() и max() в Python.

#8

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf
This commit is contained in:
2026-09-09 18:45:50 +03:00
co-authored by Claude Opus 5
parent 1831a3e033
commit 8ac2757cd6
6 changed files with 129 additions and 132 deletions
+16 -13
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
import random
from dataclasses import dataclass
from datetime import date, datetime, timezone
from datetime import date
from sqlmodel import Session, select
@@ -16,7 +16,7 @@ from app.core.errors import (
NotFoundError,
ValidationError,
)
from app.core.timeutil import app_today, iso_utc
from app.core.timeutil import app_today, iso_utc, utcnow
from app.models import Faction, GroupMember, Match, MatchParticipant, User
from app.services import group_service
@@ -56,10 +56,6 @@ class ParticipantInput:
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)
@@ -77,6 +73,17 @@ def match_version(match: Match) -> str:
return iso_utc(match.updated_at)
def touch(session: Session, match: Match) -> None:
"""Пометить партию изменённой (без commit).
Версия для оптимистичной блокировки — это updated_at, а onupdate у SQLAlchemy
срабатывает только при реальном UPDATE строки matches. Правки, меняющие лишь
связанные сущности (участники, вложения), строку не трогают, поэтому каждая
такая точка обязана позвать touch — иначе конкурентная запись пройдёт молча."""
match.updated_at = utcnow()
session.add(match)
def assert_version(match: Match, expected: str | None) -> None:
"""Если клиент прислал версию и она устарела — отказываем (кто-то изменил партию)."""
if expected is not None and expected != match_version(match):
@@ -188,7 +195,7 @@ def create_match(
session, group_id, [r.user_id for r in roster], [r.faction_id for r in roster]
)
now = _utcnow()
now = utcnow()
match = Match(
group_id=group_id,
status="in_progress",
@@ -264,7 +271,7 @@ def finish_match(
p.faction_id = f.faction_id
session.add(p)
now = _utcnow()
now = utcnow()
match.finished_at = now
started = match.started_at
if started is not None:
@@ -351,11 +358,7 @@ def update_match(
)
match.player_count = len(participants)
# Версия партии = updated_at, а onupdate срабатывает только при реальном UPDATE
# строки matches. Правка одних участников его не вызывает, и тогда оптимистичная
# блокировка молча пропускала бы конкурентную запись — поэтому бампаем явно.
match.updated_at = _utcnow()
session.add(match)
touch(session, match)
session.commit()
session.refresh(match)
return match