From 38671cd4711cec984bd6f8fb994fe17bd3279873 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 4 Oct 2025 09:52:15 +0000 Subject: [PATCH] feat(user): implement `/users/{user_id}/beatmaps-passed` close #9 --- app/models/score.py | 9 ++++ app/router/v2/user.py | 104 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+) diff --git a/app/models/score.py b/app/models/score.py index ed8811e..4102d54 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -84,6 +84,15 @@ class GameMode(str, Enum): GameMode.FRUITSRX: "catch relax", }[self] + def to_base_ruleset(self) -> "GameMode": + gamemode = { + GameMode.OSURX: GameMode.OSU, + GameMode.OSUAP: GameMode.OSU, + GameMode.TAIKORX: GameMode.TAIKO, + GameMode.FRUITSRX: GameMode.FRUITS, + }.get(self) + return gamemode if gamemode else self + def to_special_mode(self, mods: list[APIMod] | list[str]) -> "GameMode": if self not in (GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS): return self diff --git a/app/router/v2/user.py b/app/router/v2/user.py index 6782c83..923d9e1 100644 --- a/app/router/v2/user.py +++ b/app/router/v2/user.py @@ -4,8 +4,10 @@ from typing import Annotated, Literal from app.config import settings from app.const import BANCHOBOT_ID from app.database import ( + Beatmap, BeatmapPlaycounts, BeatmapPlaycountsResp, + BeatmapResp, BeatmapsetResp, User, UserResp, @@ -19,6 +21,7 @@ from app.dependencies.database import Database, get_redis from app.dependencies.user import get_current_user from app.helpers.asset_proxy_helper import asset_proxy_response from app.log import log +from app.models.mods import API_MODS from app.models.score import GameMode from app.models.user import BeatmapsetType from app.service.user_cache_service import get_user_cache_service @@ -36,6 +39,19 @@ class BatchUserResponse(BaseModel): users: list[UserResp] +class BeatmapsPassedResponse(BaseModel): + beatmaps_passed: list[BeatmapResp] + + +def _get_difficulty_reduction_mods() -> set[str]: + mods: set[str] = set() + for ruleset_mods in API_MODS.values(): + for mod_acronym, mod_meta in ruleset_mods.items(): + if mod_meta.get("Type") == "DifficultyReduction": + mods.add(mod_acronym) + return mods + + @router.get( "/users/", response_model=BatchUserResponse, @@ -165,6 +181,94 @@ async def get_user_kudosu( return [] +@router.get( + "/users/{user_id}/beatmaps-passed", + response_model=BeatmapsPassedResponse, + name="获取用户已通过谱面", + description="获取指定用户在给定谱面集中的已通过谱面列表。", + tags=["用户"], +) +@asset_proxy_response +async def get_user_beatmaps_passed( + session: Database, + user_id: Annotated[int, Path(description="用户 ID")], + current_user: Annotated[User, Security(get_current_user, scopes=["public"])], + beatmapset_ids: Annotated[ + list[int], + Query( + alias="beatmapset_ids[]", + description="要查询的谱面集 ID 列表 (最多 50 个)", + ), + ] = [], + ruleset_id: Annotated[ + int | None, + Query(description="指定 ruleset ID"), + ] = None, + exclude_converts: Annotated[bool, Query(description="是否排除转谱成绩")] = False, + is_legacy: Annotated[bool | None, Query(description="是否仅返回 Stable 成绩")] = None, + no_diff_reduction: Annotated[bool, Query(description="是否排除减难 MOD 成绩")] = True, +): + if not beatmapset_ids: + return BeatmapsPassedResponse(beatmaps_passed=[]) + if len(beatmapset_ids) > 50: + raise HTTPException(status_code=413, detail="beatmapset_ids cannot exceed 50 items") + + user = await session.get(User, user_id) + if not user or user.id == BANCHOBOT_ID: + raise HTTPException(404, detail="User not found") + + allowed_mode: GameMode | None = None + if ruleset_id is not None: + try: + allowed_mode = GameMode.from_int_extra(ruleset_id) + except KeyError as exc: + raise HTTPException(status_code=422, detail="Invalid ruleset_id") from exc + + score_query = ( + select(Score.beatmap_id, Score.mods, Score.gamemode, Beatmap.mode) + .where( + Score.user_id == user.id, + col(Score.beatmap_id).in_(select(Beatmap.id).where(col(Beatmap.beatmapset_id).in_(beatmapset_ids))), + col(Score.passed).is_(True), + ) + .join(Beatmap, col(Beatmap.id) == Score.beatmap_id) + ) + if allowed_mode: + score_query = score_query.where(Score.gamemode == allowed_mode) + + scores = (await session.exec(score_query)).all() + if not scores: + return BeatmapsPassedResponse(beatmaps_passed=[]) + + difficulty_reduction_mods = _get_difficulty_reduction_mods() if no_diff_reduction else set() + passed_beatmap_ids: set[int] = set() + for beatmap_id, mods, _mode, _beatmap_mode in scores: + gamemode = GameMode(_mode) + beatmap_mode = GameMode(_beatmap_mode) + + if exclude_converts and gamemode.to_base_ruleset() != beatmap_mode: + continue + if difficulty_reduction_mods and any(mod["acronym"] in difficulty_reduction_mods for mod in mods): + continue + passed_beatmap_ids.add(beatmap_id) + if not passed_beatmap_ids: + return BeatmapsPassedResponse(beatmaps_passed=[]) + + beatmaps = ( + await session.exec( + select(Beatmap) + .where(col(Beatmap.id).in_(passed_beatmap_ids)) + .order_by(col(Beatmap.difficulty_rating).desc()) + ) + ).all() + + return BeatmapsPassedResponse( + beatmaps_passed=[ + await BeatmapResp.from_db(beatmap, allowed_mode, session=session, user=user) for beatmap in beatmaps + ] + ) + + @router.get( "/users/{user_id}/{ruleset}", response_model=UserResp,