Files
ForbiddenStarsApp/backend/app/services/stats_service.py
T
NotBigGhostandClaude Opus 5 8ac2757cd6 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
2026-09-09 18:45:50 +03:00

481 lines
18 KiB
Python

"""Статистика и рейтинги. Считается «вживую» (объём данных мал, кэш не нужен)."""
from __future__ import annotations
from typing import Any
from sqlalchemy import func, text
from sqlmodel import Session, select
from app.core.timeutil import iso_utc
from app.models import Faction, Group, GroupMember, Match, MatchParticipant, User
from app.services import faction_service, group_service, membership_service, user_service
from app.services.scoring import (
FACTION_MIN_GAMES,
MATCH_POINTS_SQL,
MIN_GAMES,
SMOOTHED_SCORE_SQL,
leaderboard_sort_key,
)
# Базовый блок: одна строка на участие с tie-aware очками.
# Учитываются только ЗАВЕРШЁННЫЕ партии (in_progress без мест в статистику не входят).
SCORED_CTE = f"""
WITH tie AS (
SELECT mp.match_id AS match_id, mp.place AS place, COUNT(*) AS tie_size
FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
WHERE m.status = 'finished' AND mp.place IS NOT NULL
GROUP BY mp.match_id, mp.place
),
scored AS (
SELECT mp.user_id AS user_id,
mp.faction_id AS faction_id,
m.id AS match_id,
m.group_id AS group_id,
m.played_at AS played_at,
mp.place AS place,
mp.was_random AS was_random,
m.player_count AS player_count,
({MATCH_POINTS_SQL}) AS points,
CASE WHEN mp.place = 1 THEN 1 ELSE 0 END AS is_win
FROM match_participants mp
JOIN matches m ON m.id = mp.match_id
JOIN tie t ON t.match_id = mp.match_id AND t.place = mp.place
WHERE m.status = 'finished'
)
"""
def _round(value: Any, ndigits: int) -> float | None:
return None if value is None else round(float(value), ndigits)
def _normalize(row: dict) -> dict:
# avatar_url мирроринг user_service.avatar_url_for: версия = epoch(updated_at) из SQL.
avatar_url = None
if row.get("avatar_path"):
avatar_url = f"/api/users/{row['user_id']}/avatar?v={int(row.get('avatar_version') or 0)}"
return {
"user_id": row["user_id"],
"nickname": row["nickname"],
"games": int(row["games"] or 0),
"wins": int(row["wins"] or 0),
"win_rate": _round(row["win_rate"] or 0.0, 4),
"avg_place": _round(row["avg_place"], 2),
"score": _round(row["score"], 1),
"avatar_url": avatar_url,
}
def _leaderboard_rows(session: Session, group_id: int | None) -> list[dict]:
where = "WHERE s.group_id = :gid" if group_id is not None else ""
sql = f"""
{SCORED_CTE}
SELECT u.id AS user_id, u.nickname AS nickname,
u.avatar_path AS avatar_path,
CAST(strftime('%s', u.updated_at) AS INTEGER) AS avatar_version,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
{SMOOTHED_SCORE_SQL} AS score
FROM scored s
JOIN users u ON u.id = s.user_id
{where}
GROUP BY u.id, u.nickname, u.avatar_path, u.updated_at
"""
params = {"gid": group_id} if group_id is not None else {}
result = session.execute(text(sql), params).mappings().all()
return [_normalize(dict(r)) for r in result]
def leaderboard(session: Session, group_id: int | None = None) -> dict:
rows = _leaderboard_rows(session, group_id)
qualified = [r for r in rows if r["games"] >= MIN_GAMES]
provisional = [r for r in rows if r["games"] < MIN_GAMES]
qualified.sort(key=leaderboard_sort_key)
provisional.sort(key=leaderboard_sort_key)
for i, r in enumerate(qualified, start=1):
r["rank"] = i
for r in provisional:
r["rank"] = None
return {
"entries": qualified,
"provisional": provisional,
"min_games": MIN_GAMES,
}
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
{SMOOTHED_SCORE_SQL} AS score
FROM scored s
{cond}
"""
params: dict[str, Any] = {"uid": user_id}
if group_id is not None:
params["gid"] = group_id
r = session.execute(text(sql), params).mappings().first() or {}
return {
"games": int(r.get("games") or 0),
"wins": int(r.get("wins") or 0),
"win_rate": _round(r.get("win_rate") or 0.0, 4),
"avg_place": _round(r.get("avg_place"), 2),
"score": _round(r.get("score"), 1),
}
def _faction_breakdown(session: Session, user_id: int, group_id: int | None) -> list[dict]:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
e.code AS expansion_code,
COUNT(*) AS games,
SUM(s.is_win) AS wins,
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
AVG(s.place) AS avg_place,
AVG(s.points) * 100 AS score -- фракции: чистое среднее (служебная метрика
-- «лучшая/худшая», сглаживание задавило бы её к 50)
FROM scored s
JOIN factions f ON f.id = s.faction_id
JOIN expansions e ON e.id = f.expansion_id
{cond}
GROUP BY f.id, f.code, f.name_ru, e.code
ORDER BY games DESC, score DESC
"""
params: dict[str, Any] = {"uid": user_id}
if group_id is not None:
params["gid"] = group_id
result = session.execute(text(sql), params).mappings().all()
out = []
for r in result:
out.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"expansion_code": r["expansion_code"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"win_rate": _round(r["win_rate"] or 0.0, 4),
"avg_place": _round(r["avg_place"], 2),
"score": _round(r["score"], 1),
"name_ru_prepositional": faction_service.prepositional(
r["code"], r["name_ru"]
),
}
)
return out
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
sql = f"""
{SCORED_CTE}
SELECT s.place AS place, s.player_count AS player_count, s.played_at AS played_at
FROM scored s
{cond}
ORDER BY s.played_at DESC, s.match_id DESC
LIMIT :lim
"""
params: dict[str, Any] = {"uid": user_id, "lim": limit}
if group_id is not None:
params["gid"] = group_id
result = session.execute(text(sql), params).mappings().all()
return [
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
for r in result
]
def _favorite_faction(session: Session, user_id: int) -> dict | None:
"""Любимая фракция игрока: личный выбор в профиле, а не вывод из статистики."""
user = session.get(User, user_id)
if user is None or user.favorite_faction_id is None:
return None
faction = session.get(Faction, user.favorite_faction_id)
if faction is None:
return None
return {
"id": faction.id,
"code": faction.code,
"name_ru": faction.name_ru,
"expansion_id": faction.expansion_id,
}
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
overall = _overall_for_user(session, user_id, group_id)
factions = _faction_breakdown(session, user_id, group_id)
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
best = max(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
# «Чаще всего играет на» — самая игранная фракция по всей истории, включая
# рандомные раздачи.
main = max(factions, key=lambda f: f["games"]) if factions else None
return {
"user_id": user_id,
"overall": overall,
"factions": factions,
"best_faction": best,
"worst_faction": worst,
"favorite_faction": _favorite_faction(session, user_id),
"main_faction": main,
"recent_form": _recent_form(session, user_id, group_id),
"min_games": MIN_GAMES,
}
def group_stats(session: Session, group_id: int) -> dict:
board = leaderboard(session, group_id=group_id)
# Нужны только счётчик и дата последней партии — тянуть строки целиком незачем.
games_count, last_played = session.exec(
select(func.count(), func.max(Match.played_at)).where(
Match.group_id == group_id, Match.status == "finished"
)
).one()
last_at = str(last_played) if last_played else None
available_ids = group_service.available_faction_ids(session, group_id)
faction_meta = []
sql = f"""
{SCORED_CTE}
SELECT f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
COUNT(s.user_id) AS games, SUM(s.is_win) AS wins
FROM factions f
LEFT JOIN scored s ON s.faction_id = f.id AND s.group_id = :gid
GROUP BY f.id, f.code, f.name_ru
ORDER BY games DESC, f.sort_order
"""
rows = session.execute(text(sql), {"gid": group_id}).mappings().all()
for r in rows:
faction_meta.append(
{
"faction_id": r["faction_id"],
"code": r["code"],
"name_ru": r["name_ru"],
"games": int(r["games"] or 0),
"wins": int(r["wins"] or 0),
"available": r["faction_id"] in available_ids,
}
)
# Участники без завершённых партий — отдельным блоком (нули, rank=null).
played_ids = {e["user_id"] for e in board["entries"]} | {
e["user_id"] for e in board["provisional"]
}
inactive = []
for _m, u in membership_service.list_members(session, group_id):
if u.id in played_ids:
continue
inactive.append(
{
"user_id": u.id,
"nickname": u.nickname,
"games": 0,
"wins": 0,
"win_rate": 0.0,
"avg_place": None,
"score": None,
"rank": None,
"avatar_url": user_service.avatar_url_for(u.id, u.avatar_path, u.updated_at),
}
)
return {
"group_id": group_id,
"total_matches": games_count,
"last_match_at": last_at,
"leaderboard": board["entries"],
"provisional": board["provisional"],
"inactive": inactive,
"faction_meta": faction_meta,
"min_games": MIN_GAMES,
}
def _participant_row(p: MatchParticipant, u: User, f: Faction) -> dict:
return {
"user_id": u.id,
"nickname": u.nickname,
"faction_id": f.id,
"faction_name": f.name_ru,
"place": p.place,
"eliminated": p.eliminated,
"was_random": p.was_random,
"comment": p.comment,
}
def _participants_by_match(session: Session, match_ids: list[int]) -> dict[int, list[dict]]:
"""Участники сразу всей страницы партий: иначе запрос на каждую партию (N+1)."""
if not match_ids:
return {}
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.in_(match_ids))
# place может быть NULL (партия идёт) — NULL уходит в конец сортировки.
.order_by(MatchParticipant.place.is_(None), MatchParticipant.place, User.nickname)
).all()
out: dict[int, list[dict]] = {}
for p, u, f in rows:
out.setdefault(p.match_id, []).append(_participant_row(p, u, f))
return out
def _match_items(session: Session, matches) -> list[dict]:
"""Элементы списка партий (общее для списка группы и истории игрока)."""
by_match = _participants_by_match(session, [m.id for m in matches])
items = []
for m in matches:
parts = by_match.get(m.id, [])
items.append(
{
"id": m.id,
"status": m.status,
"played_at": str(m.played_at),
"started_at": iso_utc(m.started_at),
"finished_at": iso_utc(m.finished_at),
"duration_minutes": m.duration_minutes,
"win_reason": m.win_reason,
"player_count": m.player_count,
"overall_comment": m.overall_comment,
"created_by": m.created_by,
"participants": parts,
}
)
return items
def group_match_list(session: Session, group_id: int, limit: int = 20, offset: int = 0) -> dict:
total = session.exec(
select(func.count()).select_from(Match).where(Match.group_id == group_id)
).one()
matches = session.exec(
select(Match)
.where(Match.group_id == group_id)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"total": total,
"limit": limit,
"offset": offset,
}
def user_match_list(
session: Session,
user_id: int,
limit: int = 20,
offset: int = 0,
best_only: bool = False,
) -> dict:
"""История партий игрока: только ЗАВЕРШЁННЫЕ, свежие сверху.
best_only — одна лучшая партия по League Points (s.points из SCORED_CTE учитывает
место и размер стола); при равных очках берём более свежую."""
if best_only:
sql = f"""
{SCORED_CTE}
SELECT s.match_id AS match_id
FROM scored s
WHERE s.user_id = :uid
ORDER BY s.points DESC, s.played_at DESC, s.match_id DESC
LIMIT 1
"""
row = session.execute(text(sql), {"uid": user_id}).mappings().first()
matches = [session.get(Match, row["match_id"])] if row else []
return {
"items": _match_items(session, matches),
"total": len(matches),
"limit": 1,
"offset": 0,
}
where = (MatchParticipant.user_id == user_id, Match.status == "finished")
total = session.exec(
select(func.count())
.select_from(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
).one()
matches = session.exec(
select(Match)
.join(MatchParticipant, MatchParticipant.match_id == Match.id)
.where(*where)
.order_by(Match.played_at.desc(), Match.id.desc())
.offset(offset)
.limit(limit)
).all()
return {
"items": _match_items(session, matches),
"total": total,
"limit": limit,
"offset": offset,
}
def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
"""Незавершённые партии во всех группах, где состоит пользователь (новые сверху)."""
group_ids = list(
session.exec(select(GroupMember.group_id).where(GroupMember.user_id == user_id)).all()
)
if not group_ids:
return []
rows = session.exec(
select(Match, Group.name)
.join(Group, Group.id == Match.group_id)
.where(Match.status == "in_progress", Match.group_id.in_(group_ids))
.order_by(Match.started_at.desc(), Match.id.desc())
).all()
by_match = _participants_by_match(session, [m.id for m, _ in rows])
out = []
for m, gname in rows:
parts = by_match.get(m.id, [])
out.append(
{
"id": m.id,
"group_id": m.group_id,
"group_name": gname,
"started_at": iso_utc(m.started_at),
"player_count": m.player_count,
"participants": parts,
}
)
return out
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
board = leaderboard(session, group_id=None)
profile = profile_stats(session, user_id, group_id=None)
active_group_brief = None
if active_group_id is not None:
group = session.get(Group, active_group_id)
if group is not None:
active_group_brief = {
"id": group.id,
"name": group.name,
**_overall_for_user(session, user_id, active_group_id),
}
return {
"leaderboard": board["entries"][:leaderboard_limit],
"provisional": board["provisional"][:leaderboard_limit],
"profile": profile,
"active_group": active_group_brief,
"in_progress": user_in_progress_matches(session, user_id),
"min_games": MIN_GAMES,
}