fix(leaderboard): fix failed & duplicated scores in leaderboard

This commit is contained in:
MingxuanGame
2025-07-27 05:10:33 +00:00
parent ec241ac200
commit a8b05f1574
2 changed files with 93 additions and 55 deletions

View File

@@ -14,10 +14,10 @@ from app.models.score import (
) )
from .beatmap import Beatmap, BeatmapResp from .beatmap import Beatmap, BeatmapResp
from .beatmapset import BeatmapsetResp from .beatmapset import Beatmapset, BeatmapsetResp
from sqlalchemy import Column, DateTime from sqlalchemy import Column, ColumnExpressionArgument, DateTime
from sqlalchemy.orm import joinedload from sqlalchemy.orm import aliased, joinedload
from sqlmodel import ( from sqlmodel import (
JSON, JSON,
BigInteger, BigInteger,
@@ -31,6 +31,7 @@ from sqlmodel import (
select, select,
) )
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
from sqlmodel.sql._expression_select_cls import SelectOfScalar
class ScoreBase(SQLModel): class ScoreBase(SQLModel):
@@ -97,6 +98,43 @@ class Score(ScoreBase, table=True):
def is_perfect_combo(self) -> bool: def is_perfect_combo(self) -> bool:
return self.max_combo == self.beatmap.max_combo 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): class ScoreResp(ScoreBase):
id: int id: int
@@ -300,6 +338,7 @@ async def get_score_position_by_id(
Score.map_md5 == beatmap_md5, Score.map_md5 == beatmap_md5,
Score.id == score_id, Score.id == score_id,
Score.gamemode == mode, Score.gamemode == mode,
col(Score.passed).is_(True),
col(Beatmap.beatmap_status).in_( col(Beatmap.beatmap_status).in_(
[ [
BeatmapRankStatus.RANKED, BeatmapRankStatus.RANKED,
@@ -316,13 +355,29 @@ async def get_score_position_by_id(
rownum = ( rownum = (
func.row_number() func.row_number()
.over( .over(
partition_by=Score.map_md5, partition_by=[col(Score.user_id), col(Score.map_md5)],
order_by=col(Score.total_score).desc(), order_by=col(Score.total_score).desc(),
) )
.label("row_number") .label("rownum")
) )
subq = select(Score, rownum).join(Beatmap).where(*where_clause).subquery() subq = (
stmt = select(subq.c.row_number).where(subq.c.id == score_id) 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) result = await session.exec(stmt)
s = result.one_or_none() s = result.one_or_none()
return s if s else 0 return s if s else 0

View File

@@ -3,23 +3,26 @@ from __future__ import annotations
import datetime import datetime
from app.database import ( from app.database import (
Beatmap,
User as DBUser, User as DBUser,
) )
from app.database.beatmapset import Beatmapset
from app.database.score import Score, ScoreResp from app.database.score import Score, ScoreResp
from app.database.score_token import ScoreToken, ScoreTokenResp from app.database.score_token import ScoreToken, ScoreTokenResp
from app.database.user import User
from app.dependencies.database import get_db from app.dependencies.database import get_db
from app.dependencies.user import get_current_user 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 .api_router import router
from fastapi import Depends, Form, HTTPException, Query from fastapi import Depends, Form, HTTPException, Query
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.orm import joinedload from sqlalchemy.orm import joinedload
from sqlmodel import col, select from sqlmodel import col, select, true
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
@@ -34,7 +37,7 @@ class BeatmapScores(BaseModel):
async def get_beatmap_scores( async def get_beatmap_scores(
beatmap: int, beatmap: int,
legacy_only: bool = Query(None), # TODO:加入对这个参数的查询 legacy_only: bool = Query(None), # TODO:加入对这个参数的查询
mode: str = Query(None), mode: GameMode | None = Query(None),
# mods: List[APIMod] = Query(None), # TODO:加入指定MOD的查询 # mods: List[APIMod] = Query(None), # TODO:加入指定MOD的查询
type: str = Query(None), type: str = Query(None),
current_user: DBUser = Depends(get_current_user), current_user: DBUser = Depends(get_current_user),
@@ -47,23 +50,22 @@ async def get_beatmap_scores(
all_scores = ( all_scores = (
await db.exec( await db.exec(
select(Score).where(Score.beatmap_id == beatmap) Score.select_clause_unique(
# .where(Score.mods == mods if mods else True) Score.beatmap_id == beatmap,
col(Score.passed).is_(True),
Score.gamemode == mode if mode is not None else true(),
)
) )
).all() ).all()
user_score = ( user_score = (
await db.exec( await db.exec(
select(Score) Score.select_clause_unique(
.options( Score.beatmap_id == beatmap,
joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] Score.user_id == current_user.id,
.joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] col(Score.passed).is_(True),
.selectinload( Score.gamemode == mode if mode is not None else true(),
Beatmapset.beatmaps # pyright: ignore[reportArgumentType]
)
) )
.where(Score.beatmap_id == beatmap)
.where(Score.user_id == current_user.id)
) )
).first() ).first()
@@ -98,18 +100,13 @@ async def get_user_beatmap_score(
) )
user_score = ( user_score = (
await db.exec( await db.exec(
select(Score) Score.select_clause()
.options( .where(
joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] Score.gamemode == mode if mode is not None else True,
.joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] Score.beatmap_id == beatmap,
.selectinload( Score.user_id == user,
Beatmapset.beatmaps # pyright: ignore[reportArgumentType]
)
) )
.where(Score.gamemode == mode if mode is not None else True) .order_by(col(Score.total_score).desc())
.where(Score.beatmap_id == beatmap)
.where(Score.user_id == user)
.order_by(col(Score.classic_total_score).desc())
) )
).first() ).first()
@@ -143,17 +140,12 @@ async def get_user_all_beatmap_scores(
) )
all_user_scores = ( all_user_scores = (
await db.exec( await db.exec(
select(Score) Score.select_clause()
.options( .where(
joinedload(Score.beatmap) # pyright: ignore[reportArgumentType] Score.gamemode == ruleset if ruleset is not None else True,
.joinedload(Beatmap.beatmapset) # pyright: ignore[reportArgumentType] Score.beatmap_id == beatmap,
.selectinload( Score.user_id == user,
Beatmapset.beatmaps # pyright: ignore[reportArgumentType]
)
) )
.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()) .order_by(col(Score.classic_total_score).desc())
) )
).all() ).all()
@@ -255,16 +247,7 @@ async def submit_solo_score(
score_token.score_id = score_id score_token.score_id = score_id
await db.commit() await db.commit()
score = ( score = (
await db.exec( await db.exec(Score.select_clause().where(Score.id == score_id))
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)
)
).first() ).first()
assert score is not None assert score is not None
return await ScoreResp.from_db(db, score) return await ScoreResp.from_db(db, score)