From e0f1ed5fee2f1249d35195483134d939e13b3715 Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Wed, 9 Sep 2026 20:38:29 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A1=D1=82=D0=B0=D1=82=D0=B8=D1=81=D1=82?= =?UTF-8?q?=D0=B8=D0=BA=D0=B0:=20=D0=BE=D0=B4=D0=B8=D0=BD=20=D0=BF=D1=80?= =?UTF-8?q?=D0=BE=D1=85=D0=BE=D0=B4=20=D0=BF=D0=BE=20=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=BE=D0=BA=D0=B0=D0=BC=20=D0=B8=D0=B3=D1=80=D0=BE=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=B2=D0=BC=D0=B5=D1=81=D1=82=D0=BE=20=D1=87=D0=B5=D1=82?= =?UTF-8?q?=D1=8B=D1=80=D1=91=D1=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Главная гоняла тяжёлый SCORED_CTE пять раз: лидерборд, три запроса профиля (итог, разбивка по фракциям, форма последних партий) и ещё раз итог для активной группы. Четыре последних выбирали одни и те же строки одного игрока и отличались только агрегацией, а стоимость CTE растёт с числом партий во всём приложении, а не в группе игрока. Теперь строки игрока тянутся одним запросом, а итог, разбивка и форма считаются из них в Python. Фильтр по группе — фильтрация того же набора, поэтому блок активной группы не стоит отдельного прохода. На главной осталось два прохода вместо пяти, у профиля — один вместо трёх. Формула сглаженного рейтинга получила Python-версию рядом с SQL-версией, на тех же константах: в SQL она нужна лидерборду, где агрегация идёт по всем игрокам. Чтобы две реализации не разъехались (как однажды вышло с кэш-бастером аватара), добавлен тест, сверяющий цифры профиля с цифрами того же игрока в лидерборде. Второй тест считает запросы с SCORED_CTE на главной: без него оптимизация тихо отъедет назад при следующей правке витрин. #32 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BoiJK9ux8peeyjLb8TYjFf --- backend/app/services/scoring.py | 11 ++ backend/app/services/stats_service.py | 157 ++++++++++++++------------ backend/tests/test_stats_passes.py | 80 +++++++++++++ 3 files changed, 173 insertions(+), 75 deletions(-) create mode 100644 backend/tests/test_stats_passes.py diff --git a/backend/app/services/scoring.py b/backend/app/services/scoring.py index ec7f93c..705bf86 100644 --- a/backend/app/services/scoring.py +++ b/backend/app/services/scoring.py @@ -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: """Ключ сортировки топа: счёт ↓, winrate ↓, игры ↓, среднее место ↑, ник ↑.""" return ( diff --git a/backend/app/services/stats_service.py b/backend/app/services/stats_service.py index d90575b..d77987e 100644 --- a/backend/app/services/stats_service.py +++ b/backend/app/services/stats_service.py @@ -15,6 +15,7 @@ from app.services.scoring import ( MIN_GAMES, SMOOTHED_SCORE_SQL, leaderboard_sort_key, + smoothed_score, ) # Базовый блок: одна строка на участие с 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: - cond = "WHERE s.user_id = :uid" + (" AND s.group_id = :gid" if group_id is not None else "") +def _user_scored_rows(session: Session, user_id: int) -> list[dict]: + """Строки участия игрока со всеми полями, нужными витринам профиля. + + Один проход по SCORED_CTE вместо трёх: общий итог, разбивка по фракциям и форма + последних партий считаются из одного и того же набора строк. CTE джойнит участия + со всеми партиями приложения, поэтому каждый лишний проход дорожает вместе с + общим числом партий, а не с числом партий игрока.""" 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) + SELECT s.group_id AS group_id, s.match_id AS match_id, s.played_at AS played_at, + s.place AS place, s.player_count AS player_count, + s.points AS points, s.is_win AS is_win, + f.id AS faction_id, f.code AS code, f.name_ru AS name_ru, + e.code AS expansion_code 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 + WHERE s.user_id = :uid """ - 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() + rows = session.execute(text(sql), {"uid": user_id}).mappings().all() + return [dict(r) for r in rows] + + +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 = [] - 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( { - "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), + "faction_id": fid, + "code": meta["code"], + "name_ru": meta["name_ru"], + "expansion_code": meta["expansion_code"], + "games": games, + "wins": wins, + "win_rate": _round(wins / games, 4), + "avg_place": _round(sum(r["place"] for r in group) / games, 2), + # Фракции: чистое среднее (служебная метрика «лучшая/худшая», + # сглаживание задавило бы её к 50). + "score": _round(sum(float(r["points"]) for r in group) / games * 100, 1), "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 -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() +def _recent_form_from_rows(rows: list[dict], limit: int = 5) -> list[dict]: + recent = sorted(rows, key=lambda r: (str(r["played_at"]), r["match_id"]), reverse=True) 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: - overall = _overall_for_user(session, user_id, group_id) - factions = _faction_breakdown(session, user_id, group_id) +def profile_stats( + session: Session, + 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] 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 @@ -228,7 +233,7 @@ def profile_stats(session: Session, user_id: int, group_id: int | None = None) - "worst_faction": worst, "favorite_faction": _favorite_faction(session, user_id), "main_faction": main, - "recent_form": _recent_form(session, user_id, group_id), + "recent_form": _recent_form_from_rows(scoped), "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: 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 if active_group_id is not None: 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 = { "id": group.id, "name": group.name, - **_overall_for_user(session, user_id, active_group_id), + **_overall_from_rows(_for_group(rows, active_group_id)), } return { "leaderboard": board["entries"][:leaderboard_limit], diff --git a/backend/tests/test_stats_passes.py b/backend/tests/test_stats_passes.py new file mode 100644 index 0000000..6839358 --- /dev/null +++ b/backend/tests/test_stats_passes.py @@ -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 -- 2.54.0