Merge pull request 'Главная страница четырежды пересчитывает тяжёлый CTE статистики (#32)' (#43) from issue-32-stats-single-pass into dev
Reviewed-on: #43
This commit was merged in pull request #43.
This commit is contained in:
@@ -38,6 +38,17 @@ MATCH_POINTS_SQL = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def smoothed_score(points_sum: float, games: int) -> float | None:
|
||||||
|
"""Тот же сглаженный рейтинг, что и SMOOTHED_SCORE_SQL, но в Python.
|
||||||
|
|
||||||
|
Нужен там, где строки уже вытащены и агрегировать в SQL нечего (профиль игрока).
|
||||||
|
Держим рядом с SQL-версией и на одних константах: разъехавшиеся реализации одной
|
||||||
|
формулы — источник расхождений, который потом ловится только глазами."""
|
||||||
|
if games <= 0:
|
||||||
|
return None # рейтинга без игр нет — как SUM(...) = NULL в SQL
|
||||||
|
return (PRIOR_GAMES * PRIOR_MEAN + points_sum) / (PRIOR_GAMES + games) * 100
|
||||||
|
|
||||||
|
|
||||||
def leaderboard_sort_key(row: dict) -> tuple:
|
def leaderboard_sort_key(row: dict) -> tuple:
|
||||||
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
"""Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑."""
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from app.services.scoring import (
|
|||||||
MIN_GAMES,
|
MIN_GAMES,
|
||||||
SMOOTHED_SCORE_SQL,
|
SMOOTHED_SCORE_SQL,
|
||||||
leaderboard_sort_key,
|
leaderboard_sort_key,
|
||||||
|
smoothed_score,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Базовый блок: одна строка на участие с tie-aware очками.
|
# Базовый блок: одна строка на участие с tie-aware очками.
|
||||||
@@ -106,92 +107,88 @@ def leaderboard(session: Session, group_id: int | None = None) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _overall_for_user(session: Session, user_id: int, group_id: int | None) -> dict:
|
def _user_scored_rows(session: Session, user_id: int) -> list[dict]:
|
||||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
"""Строки участия игрока со всеми полями, нужными витринам профиля.
|
||||||
|
|
||||||
|
Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма
|
||||||
|
последних партий считаются из одного и того же набора строк. CTE джойнит участия
|
||||||
|
со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с
|
||||||
|
общим числом партий, а не с числом партий игрока."""
|
||||||
sql = f"""
|
sql = f"""
|
||||||
{SCORED_CTE}
|
{SCORED_CTE}
|
||||||
SELECT COUNT(*) AS games,
|
SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at,
|
||||||
SUM(s.is_win) AS wins,
|
s.place AS place, s.player_count AS player_count,
|
||||||
AVG(CAST(s.is_win AS FLOAT)) AS win_rate,
|
s.points AS points, s.is_win AS is_win,
|
||||||
AVG(s.place) AS avg_place,
|
f.id AS faction_id, f.code AS code, f.name_ru AS name_ru,
|
||||||
{SMOOTHED_SCORE_SQL} AS score
|
e.code AS expansion_code
|
||||||
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
|
FROM scored s
|
||||||
JOIN factions f ON f.id = s.faction_id
|
JOIN factions f ON f.id = s.faction_id
|
||||||
JOIN expansions e ON e.id = f.expansion_id
|
JOIN expansions e ON e.id = f.expansion_id
|
||||||
{cond}
|
WHERE s.user_id = :uid
|
||||||
GROUP BY f.id, f.code, f.name_ru, e.code
|
|
||||||
ORDER BY games DESC, score DESC
|
|
||||||
"""
|
"""
|
||||||
params: dict[str, Any] = {"uid": user_id}
|
rows = session.execute(text(sql), {"uid": user_id}).mappings().all()
|
||||||
if group_id is not None:
|
return [dict(r) for r in rows]
|
||||||
params["gid"] = group_id
|
|
||||||
result = session.execute(text(sql), params).mappings().all()
|
|
||||||
|
def _for_group(rows: list[dict], group_id: int | None) -> list[dict]:
|
||||||
|
return rows if group_id is None else [r for r in rows if r["group_id"] == group_id]
|
||||||
|
|
||||||
|
|
||||||
|
def _overall_from_rows(rows: list[dict]) -> dict:
|
||||||
|
"""Тот же итог, что раньше считал SQL: COUNT/SUM/AVG плюс сглаженный рейтинг."""
|
||||||
|
games = len(rows)
|
||||||
|
if games == 0:
|
||||||
|
return {"games": 0, "wins": 0, "win_rate": 0.0, "avg_place": None, "score": None}
|
||||||
|
wins = sum(int(r["is_win"]) for r in rows)
|
||||||
|
return {
|
||||||
|
"games": games,
|
||||||
|
"wins": wins,
|
||||||
|
"win_rate": _round(wins / games, 4),
|
||||||
|
"avg_place": _round(sum(r["place"] for r in rows) / games, 2),
|
||||||
|
"score": _round(smoothed_score(sum(float(r["points"]) for r in rows), games), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _faction_breakdown_from_rows(rows: list[dict]) -> list[dict]:
|
||||||
|
by_faction: dict[int, list[dict]] = {}
|
||||||
|
for r in rows:
|
||||||
|
by_faction.setdefault(r["faction_id"], []).append(r)
|
||||||
out = []
|
out = []
|
||||||
for r in result:
|
for fid, group in by_faction.items():
|
||||||
|
games = len(group)
|
||||||
|
wins = sum(int(r["is_win"]) for r in group)
|
||||||
|
meta = group[0]
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"faction_id": r["faction_id"],
|
"faction_id": fid,
|
||||||
"code": r["code"],
|
"code": meta["code"],
|
||||||
"name_ru": r["name_ru"],
|
"name_ru": meta["name_ru"],
|
||||||
"expansion_code": r["expansion_code"],
|
"expansion_code": meta["expansion_code"],
|
||||||
"games": int(r["games"] or 0),
|
"games": games,
|
||||||
"wins": int(r["wins"] or 0),
|
"wins": wins,
|
||||||
"win_rate": _round(r["win_rate"] or 0.0, 4),
|
"win_rate": _round(wins / games, 4),
|
||||||
"avg_place": _round(r["avg_place"], 2),
|
"avg_place": _round(sum(r["place"] for r in group) / games, 2),
|
||||||
"score": _round(r["score"], 1),
|
# Фракции: чистое среднее (служебная метрика «лучшая/худшая»,
|
||||||
|
# сглаживание задавило бы её к 50).
|
||||||
|
"score": _round(sum(float(r["points"]) for r in group) / games * 100, 1),
|
||||||
"name_ru_prepositional": faction_service.prepositional(
|
"name_ru_prepositional": faction_service.prepositional(
|
||||||
r["code"], r["name_ru"]
|
meta["code"], meta["name_ru"]
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
out.sort(key=lambda f: (-f["games"], -(f["score"] or 0.0)))
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _recent_form(session: Session, user_id: int, group_id: int | None, limit: int = 5) -> list[dict]:
|
def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]:
|
||||||
cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "")
|
recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True)
|
||||||
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 [
|
return [
|
||||||
{"place": r["place"], "player_count": r["player_count"], "played_at": str(r["played_at"])}
|
{
|
||||||
for r in result
|
"place": r["place"],
|
||||||
|
"player_count": r["player_count"],
|
||||||
|
"played_at": str(r["played_at"]),
|
||||||
|
}
|
||||||
|
for r in recent[:limit]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@@ -211,9 +208,17 @@ def _favorite_faction(session: Session, user_id: int) -> dict | None:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def profile_stats(session: Session, user_id: int, group_id: int | None = None) -> dict:
|
def profile_stats(
|
||||||
overall = _overall_for_user(session, user_id, group_id)
|
session: Session,
|
||||||
factions = _faction_breakdown(session, user_id, group_id)
|
user_id: int,
|
||||||
|
group_id: int | None = None,
|
||||||
|
*,
|
||||||
|
rows: list[dict] | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Витрина профиля. rows — уже вытащенные строки игрока (home() их переиспользует)."""
|
||||||
|
scoped = _for_group(rows if rows is not None else _user_scored_rows(session, user_id), group_id)
|
||||||
|
overall = _overall_from_rows(scoped)
|
||||||
|
factions = _faction_breakdown_from_rows(scoped)
|
||||||
qualified = [f for f in factions if f["games"] >= FACTION_MIN_GAMES]
|
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
|
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
|
worst = min(qualified, key=lambda f: (f["score"] or 0)) if qualified else None
|
||||||
@@ -228,7 +233,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) -
|
|||||||
"worst_faction": worst,
|
"worst_faction": worst,
|
||||||
"favorite_faction": _favorite_faction(session, user_id),
|
"favorite_faction": _favorite_faction(session, user_id),
|
||||||
"main_faction": main,
|
"main_faction": main,
|
||||||
"recent_form": _recent_form(session, user_id, group_id),
|
"recent_form": _recent_form_from_rows(scoped),
|
||||||
"min_games": MIN_GAMES,
|
"min_games": MIN_GAMES,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -460,7 +465,9 @@ def user_in_progress_matches(session: Session, user_id: int) -> list[dict]:
|
|||||||
|
|
||||||
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
def home(session: Session, user_id: int, active_group_id: int | None, leaderboard_limit: int = 10) -> dict:
|
||||||
board = leaderboard(session, group_id=None)
|
board = leaderboard(session, group_id=None)
|
||||||
profile = profile_stats(session, user_id, group_id=None)
|
# Строки игрока тянем один раз: из них считается и профиль, и итог по активной группе.
|
||||||
|
rows = _user_scored_rows(session, user_id)
|
||||||
|
profile = profile_stats(session, user_id, group_id=None, rows=rows)
|
||||||
active_group_brief = None
|
active_group_brief = None
|
||||||
if active_group_id is not None:
|
if active_group_id is not None:
|
||||||
group = session.get(Group, active_group_id)
|
group = session.get(Group, active_group_id)
|
||||||
@@ -468,7 +475,7 @@ def home(session: Session, user_id: int, active_group_id: int | None, leaderboar
|
|||||||
active_group_brief = {
|
active_group_brief = {
|
||||||
"id": group.id,
|
"id": group.id,
|
||||||
"name": group.name,
|
"name": group.name,
|
||||||
**_overall_for_user(session, user_id, active_group_id),
|
**_overall_from_rows(_for_group(rows, active_group_id)),
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
"leaderboard": board["entries"][:leaderboard_limit],
|
"leaderboard": board["entries"][:leaderboard_limit],
|
||||||
|
|||||||
@@ -0,0 +1,80 @@
|
|||||||
|
"""Статистика профиля: цифры сходятся с лидербордом, а главная не гоняет CTE лишний раз."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from sqlalchemy import event
|
||||||
|
|
||||||
|
from tests.conftest import add_group_member, create_finished_match, csrf_headers, login
|
||||||
|
|
||||||
|
|
||||||
|
def _group_with_matches(client: TestClient, engine, games: int = 3) -> tuple[dict, int, int]:
|
||||||
|
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, "Соперник")
|
||||||
|
fids = [f["id"] for f in client.get(f"/api/groups/{gid}/factions").json()]
|
||||||
|
for i in range(games):
|
||||||
|
# Чередуем победителя и берём разные фракции: средние и разбивка по фракциям
|
||||||
|
# должны получиться нетривиальными, а фракции в партии не повторяться.
|
||||||
|
winner_first = i % 2 == 0
|
||||||
|
mine, theirs = fids[(2 * i) % len(fids)], fids[(2 * i + 1) % len(fids)]
|
||||||
|
create_finished_match(
|
||||||
|
client, gid,
|
||||||
|
[
|
||||||
|
{"user_id": me["id"], "faction_id": mine, "place": 1 if winner_first else 2},
|
||||||
|
{"user_id": p2, "faction_id": theirs, "place": 2 if winner_first else 1},
|
||||||
|
],
|
||||||
|
)
|
||||||
|
return me, gid, p2
|
||||||
|
|
||||||
|
|
||||||
|
def test_profile_numbers_match_leaderboard(client: TestClient, engine):
|
||||||
|
"""Профиль считает в Python, лидерборд — в SQL: цифры обязаны совпадать.
|
||||||
|
|
||||||
|
Формула сглаженного рейтинга живёт в двух видах (SMOOTHED_SCORE_SQL и
|
||||||
|
scoring.smoothed_score); этот тест ловит их расхождение."""
|
||||||
|
me, gid, p2 = _group_with_matches(client, engine, games=4)
|
||||||
|
|
||||||
|
profile = client.get("/api/users/me/stats").json()["overall"]
|
||||||
|
board = client.get("/api/stats/leaderboard").json()
|
||||||
|
entry = next(
|
||||||
|
e for e in board["entries"] + board["provisional"] if e["user_id"] == me["id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
for field in ("games", "wins", "win_rate", "avg_place", "score"):
|
||||||
|
assert profile[field] == entry[field], field
|
||||||
|
|
||||||
|
|
||||||
|
def test_home_does_not_repeat_scored_cte(client: TestClient, engine):
|
||||||
|
"""Главная делает не больше двух проходов по SCORED_CTE.
|
||||||
|
|
||||||
|
Было пять: лидерборд, три запроса профиля и итог по активной группе. Без этой
|
||||||
|
проверки оптимизация тихо отъедет назад при следующей правке витрин."""
|
||||||
|
me, gid, p2 = _group_with_matches(client, engine, games=2)
|
||||||
|
client.put(
|
||||||
|
"/api/users/me/active-group", json={"group_id": gid}, headers=csrf_headers(client)
|
||||||
|
)
|
||||||
|
|
||||||
|
seen: list[str] = []
|
||||||
|
|
||||||
|
def before_execute(conn, cursor, statement, params, context, executemany):
|
||||||
|
if "WITH tie AS" in statement:
|
||||||
|
seen.append(statement)
|
||||||
|
|
||||||
|
event.listen(engine, "before_cursor_execute", before_execute)
|
||||||
|
try:
|
||||||
|
r = client.get("/api/home")
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
finally:
|
||||||
|
event.remove(engine, "before_cursor_execute", before_execute)
|
||||||
|
|
||||||
|
# Сейчас ровно два: лидерборд и один проход по строкам игрока. Нижняя граница не
|
||||||
|
# для красоты — без неё тест пройдёт и когда счётчик молча перестанет что-либо
|
||||||
|
# ловить (сменился путь, переименован CTE).
|
||||||
|
assert 1 <= len(seen) <= 2, f"ожидали 1–2 прохода, получили {len(seen)}"
|
||||||
|
# Главная всё ещё показывает и профиль, и блок активной группы.
|
||||||
|
body = r.json()
|
||||||
|
assert body["profile"]["overall"]["games"] == 2
|
||||||
|
assert body["active_group"]["games"] == 2
|
||||||
Reference in New Issue
Block a user