From a8b05f157448721f1b2fee2877b73f77f00ed878 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 27 Jul 2025 05:10:33 +0000 Subject: [PATCH] fix(leaderboard): fix failed & duplicated scores in leaderboard --- app/database/score.py | 69 +++++++++++++++++++++++++++++++++---- app/router/score.py | 79 +++++++++++++++++-------------------------- 2 files changed, 93 insertions(+), 55 deletions(-) diff --git a/app/database/score.py b/app/database/score.py index 1bc2e58..180096d 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -14,10 +14,10 @@ from app.models.score import ( ) from .beatmap import Beatmap, BeatmapResp -from .beatmapset import BeatmapsetResp +from .beatmapset import Beatmapset, BeatmapsetResp -from sqlalchemy import Column, DateTime -from sqlalchemy.orm import joinedload +from sqlalchemy import Column, ColumnExpressionArgument, DateTime +from sqlalchemy.orm import aliased, joinedload from sqlmodel import ( JSON, BigInteger, @@ -31,6 +31,7 @@ from sqlmodel import ( select, ) from sqlmodel.ext.asyncio.session import AsyncSession +from sqlmodel.sql._expression_select_cls import SelectOfScalar class ScoreBase(SQLModel): @@ -97,6 +98,43 @@ class Score(ScoreBase, table=True): def is_perfect_combo(self) -> bool: return self.max_combo == self.beatmap.max_combo + @staticmethod + def select_clause() -> SelectOfScalar["Score"]: + return select(Score).options( + joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] + .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] + .selectinload( + Beatmapset.beatmaps # pyright: ignore[reportArgumentType] + ), + joinedload(Score.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] + ) + + @staticmethod + def select_clause_unique( + *where_clauses: ColumnExpressionArgument[bool] | bool, + ) -> SelectOfScalar["Score"]: + rownum = ( + func.row_number() + .over( + partition_by=col(Score.user_id), order_by=col(Score.total_score).desc() + ) + .label("rn") + ) + subq = select(Score, rownum).where(*where_clauses).subquery() + best = aliased(Score, subq, adapt_on_names=True) + return ( + select(best) + .where(subq.c.rn == 1) + .options( + joinedload(best.beatmap) # pyright: ignore[reportArgumentType] + .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] + .selectinload( + Beatmapset.beatmaps # pyright: ignore[reportArgumentType] + ), + joinedload(best.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] + ) + ) + class ScoreResp(ScoreBase): id: int @@ -300,6 +338,7 @@ async def get_score_position_by_id( Score.map_md5 == beatmap_md5, Score.id == score_id, Score.gamemode == mode, + col(Score.passed).is_(True), col(Beatmap.beatmap_status).in_( [ BeatmapRankStatus.RANKED, @@ -316,13 +355,29 @@ async def get_score_position_by_id( rownum = ( func.row_number() .over( - partition_by=Score.map_md5, + partition_by=[col(Score.user_id), col(Score.map_md5)], order_by=col(Score.total_score).desc(), ) - .label("row_number") + .label("rownum") ) - subq = select(Score, rownum).join(Beatmap).where(*where_clause).subquery() - stmt = select(subq.c.row_number).where(subq.c.id == score_id) + subq = ( + select(Score.user_id, Score.id, Score.total_score, rownum) + .join(Beatmap) + .where(*where_clause) + .subquery() + ) + best_scores = aliased(subq) + overall_rank = ( + func.rank().over(order_by=best_scores.c.total_score.desc()).label("global_rank") + ) + final_q = ( + select(best_scores.c.id, overall_rank) + .select_from(best_scores) + .where(best_scores.c.rownum == 1) + .subquery() + ) + + stmt = select(final_q.c.global_rank).where(final_q.c.id == score_id) result = await session.exec(stmt) s = result.one_or_none() return s if s else 0 diff --git a/app/router/score.py b/app/router/score.py index 2bf9519..cc1629a 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -3,23 +3,26 @@ from __future__ import annotations import datetime from app.database import ( - Beatmap, User as DBUser, ) -from app.database.beatmapset import Beatmapset from app.database.score import Score, ScoreResp from app.database.score_token import ScoreToken, ScoreTokenResp -from app.database.user import User from app.dependencies.database import get_db from app.dependencies.user import get_current_user -from app.models.score import INT_TO_MODE, HitResult, Rank, SoloScoreSubmissionInfo +from app.models.score import ( + INT_TO_MODE, + GameMode, + HitResult, + Rank, + SoloScoreSubmissionInfo, +) from .api_router import router from fastapi import Depends, Form, HTTPException, Query from pydantic import BaseModel from sqlalchemy.orm import joinedload -from sqlmodel import col, select +from sqlmodel import col, select, true from sqlmodel.ext.asyncio.session import AsyncSession @@ -34,7 +37,7 @@ class BeatmapScores(BaseModel): async def get_beatmap_scores( beatmap: int, legacy_only: bool = Query(None), # TODO:加入对这个参数的查询 - mode: str = Query(None), + mode: GameMode | None = Query(None), # mods: List[APIMod] = Query(None), # TODO:加入指定MOD的查询 type: str = Query(None), current_user: DBUser = Depends(get_current_user), @@ -47,23 +50,22 @@ async def get_beatmap_scores( all_scores = ( await db.exec( - select(Score).where(Score.beatmap_id == beatmap) - # .where(Score.mods == mods if mods else True) + Score.select_clause_unique( + Score.beatmap_id == beatmap, + col(Score.passed).is_(True), + Score.gamemode == mode if mode is not None else true(), + ) ) ).all() user_score = ( await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload( - Beatmapset.beatmaps # pyright: ignore[reportArgumentType] - ) + Score.select_clause_unique( + Score.beatmap_id == beatmap, + Score.user_id == current_user.id, + col(Score.passed).is_(True), + Score.gamemode == mode if mode is not None else true(), ) - .where(Score.beatmap_id == beatmap) - .where(Score.user_id == current_user.id) ) ).first() @@ -98,18 +100,13 @@ async def get_user_beatmap_score( ) user_score = ( await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload( - Beatmapset.beatmaps # pyright: ignore[reportArgumentType] - ) + Score.select_clause() + .where( + Score.gamemode == mode if mode is not None else True, + Score.beatmap_id == beatmap, + Score.user_id == user, ) - .where(Score.gamemode == mode if mode is not None else True) - .where(Score.beatmap_id == beatmap) - .where(Score.user_id == user) - .order_by(col(Score.classic_total_score).desc()) + .order_by(col(Score.total_score).desc()) ) ).first() @@ -143,17 +140,12 @@ async def get_user_all_beatmap_scores( ) all_user_scores = ( await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload( - Beatmapset.beatmaps # pyright: ignore[reportArgumentType] - ) + Score.select_clause() + .where( + Score.gamemode == ruleset if ruleset is not None else True, + Score.beatmap_id == beatmap, + Score.user_id == user, ) - .where(Score.gamemode == ruleset if ruleset is not None else True) - .where(Score.beatmap_id == beatmap) - .where(Score.user_id == user) .order_by(col(Score.classic_total_score).desc()) ) ).all() @@ -255,16 +247,7 @@ async def submit_solo_score( score_token.score_id = score_id await db.commit() score = ( - await db.exec( - select(Score) - .options( - joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] - .joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] - .selectinload(Beatmapset.beatmaps), # pyright: ignore[reportArgumentType] - joinedload(Score.user).joinedload(User.lazer_profile), # pyright: ignore[reportArgumentType] - ) - .where(Score.id == score_id) - ) + await db.exec(Score.select_clause().where(Score.id == score_id)) ).first() assert score is not None return await ScoreResp.from_db(db, score)