From 19f94fffbbef8be6728c0fe86556564c035f1a33 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sun, 14 Sep 2025 14:09:53 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=94=AF=E6=8C=81=20`x-api-versio?= =?UTF-8?q?n`=20(#29)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(relationship): support legacy-compatible response format * feat(score): add support for legacy score response format in API * fix(score): avoid missing greenlet * fix(score): fix missing field for model validation * feat(user): apply legacy score format for user * feat(api): use `int` to hint `APIVersion` --- app/database/score.py | 91 ++++++++++++++++++++++++++++++- app/dependencies/api_version.py | 16 ++++++ app/router/v2/relationship.py | 49 ++++++++++++++++- app/router/v2/score.py | 53 ++++++++++++------ app/router/v2/user.py | 24 +++++--- app/service/user_cache_service.py | 20 +++++-- 6 files changed, 218 insertions(+), 35 deletions(-) create mode 100644 app/dependencies/api_version.py diff --git a/app/database/score.py b/app/database/score.py index fe2b9f9..3d3f6d2 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -47,7 +47,7 @@ from .relationship import ( ) from .score_token import ScoreToken -from pydantic import field_serializer, field_validator +from pydantic import BaseModel, field_serializer, field_validator from redis.asyncio import Redis from sqlalchemy import Boolean, Column, DateTime, TextClause from sqlalchemy.ext.asyncio import AsyncAttrs @@ -194,6 +194,11 @@ class Score(ScoreBase, table=True): def is_perfect_combo(self) -> bool: return self.max_combo == self.beatmap.max_combo + async def to_resp(self, session: AsyncSession, api_version: int) -> "ScoreResp | LegacyScoreResp": + if api_version >= 20220705: + return await ScoreResp.from_db(session, self) + return await LegacyScoreResp.from_db(session, self) + class ScoreResp(ScoreBase): id: int @@ -334,6 +339,90 @@ class ScoreResp(ScoreBase): return s +class LegacyStatistics(BaseModel): + count_300: int + count_100: int + count_50: int + count_miss: int + count_geki: int | None = None + count_katu: int | None = None + + +class LegacyScoreResp(UTCBaseModel): + accuracy: float + best_id: int + created_at: datetime + id: int + max_combo: int + mode: GameMode + mode_int: int + mods: list[str] # acronym + passed: bool + perfect: bool = False + pp: float + rank: Rank + replay: bool + score: int + statistics: LegacyStatistics + type: str + user_id: int + current_user_attributes: CurrentUserAttributes + user: UserResp + beatmap: BeatmapResp + rank_global: int | None = Field(default=None, exclude=True) + + @classmethod + async def from_db(cls, session: AsyncSession, score: Score) -> "LegacyScoreResp": + await session.refresh(score) + await score.awaitable_attrs.beatmap + return cls( + accuracy=score.accuracy, + best_id=await get_best_id(session, score.id) or 0, + created_at=score.started_at, + id=score.id, + max_combo=score.max_combo, + mode=score.gamemode, + mode_int=int(score.gamemode), + mods=[m["acronym"] for m in score.mods], + passed=score.passed, + pp=score.pp, + rank=score.rank, + replay=score.has_replay, + score=score.total_score, + statistics=LegacyStatistics( + count_300=score.n300, + count_100=score.n100, + count_50=score.n50, + count_miss=score.nmiss, + count_geki=score.ngeki or 0, + count_katu=score.nkatu or 0, + ), + type=score.type, + user_id=score.user_id, + current_user_attributes=CurrentUserAttributes( + pin=PinAttributes(is_pinned=bool(score.pinned_order), score_id=score.id) + ), + user=await UserResp.from_db( + score.user, + session, + include=["statistics", "team", "daily_challenge_user_stats"], + ruleset=score.gamemode, + ), + beatmap=await BeatmapResp.from_db(score.beatmap), + perfect=score.is_perfect_combo, + rank_global=( + await get_score_position_by_id( + session, + score.beatmap_id, + score.id, + mode=score.gamemode, + user=score.user, + ) + or None + ), + ) + + class MultiplayerScores(RespWithCursor): scores: list[ScoreResp] = Field(default_factory=list) params: dict[str, Any] = Field(default_factory=dict) diff --git a/app/dependencies/api_version.py b/app/dependencies/api_version.py new file mode 100644 index 0000000..7cfa1c7 --- /dev/null +++ b/app/dependencies/api_version.py @@ -0,0 +1,16 @@ +from __future__ import annotations + +from typing import Annotated + +from fastapi import Depends, Header + + +def get_api_version(version: int | None = Header(None, alias="x-api-version")) -> int: + if version is None: + return 0 + if version < 1: + raise ValueError + return version + + +APIVersion = Annotated[int, Depends(get_api_version)] diff --git a/app/router/v2/relationship.py b/app/router/v2/relationship.py index 69a9ea9..431098e 100644 --- a/app/router/v2/relationship.py +++ b/app/router/v2/relationship.py @@ -1,6 +1,8 @@ from __future__ import annotations from app.database import Relationship, RelationshipResp, RelationshipType, User +from app.database.lazer_user import UserResp +from app.dependencies.api_version import APIVersion from app.dependencies.database import Database from app.dependencies.user import get_client_user, get_current_user @@ -14,9 +16,34 @@ from sqlmodel import exists, select @router.get( "/friends", tags=["用户关系"], - response_model=list[RelationshipResp], + responses={ + 200: { + "description": "好友列表", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "type": "array", + "items": {"$ref": "#/components/schemas/RelationshipResp"}, + "description": "好友列表", + }, + { + "type": "array", + "items": {"$ref": "#/components/schemas/UserResp"}, + "description": "好友列表 (`x-api-version < 20241022`)", + }, + ] + } + } + }, + } + }, name="获取好友列表", - description="获取当前用户的好友列表。", + description=( + "获取当前用户的好友列表。\n\n" + "如果 `x-api-version < 20241022`,返回值为 `UserResp` 列表,否则为 `RelationshipResp` 列表。" + ), ) @router.get( "/blocks", @@ -28,6 +55,7 @@ from sqlmodel import exists, select async def get_relationship( db: Database, request: Request, + api_version: APIVersion, current_user: User = Security(get_current_user, scopes=["friends.read"]), ): relationship_type = RelationshipType.FOLLOW if request.url.path.endswith("/friends") else RelationshipType.BLOCK @@ -37,7 +65,22 @@ async def get_relationship( Relationship.type == relationship_type, ) ) - return [await RelationshipResp.from_db(db, rel) for rel in relationships.unique()] + if api_version >= 20241022 or relationship_type == RelationshipType.BLOCK: + return [await RelationshipResp.from_db(db, rel) for rel in relationships.unique()] + else: + return [ + await UserResp.from_db( + rel.target, + db, + include=[ + "team", + "daily_challenge_user_stats", + "statistics", + "statistics_rulesets", + ], + ) + for rel in relationships.unique() + ] class AddFriendResp(BaseModel): diff --git a/app/router/v2/score.py b/app/router/v2/score.py index a53f21f..99697a1 100644 --- a/app/router/v2/score.py +++ b/app/router/v2/score.py @@ -29,12 +29,14 @@ from app.database.playlist_best_score import ( ) from app.database.relationship import Relationship, RelationshipType from app.database.score import ( + LegacyScoreResp, MultiplayerScores, ScoreAround, get_leaderboard, process_score, process_user, ) +from app.dependencies.api_version import APIVersion from app.dependencies.database import Database, get_redis, with_db from app.dependencies.fetcher import get_fetcher from app.dependencies.storage import get_storage_service @@ -264,21 +266,31 @@ async def _preload_beatmap_for_pp_calculation(beatmap_id: int) -> None: logger.warning(f"Failed to preload beatmap {beatmap_id}: {e}") -class BeatmapScores(BaseModel): - scores: list[ScoreResp] - user_score: BeatmapUserScore | None = None +class BeatmapUserScore[T: ScoreResp | LegacyScoreResp](BaseModel): + position: int + score: T + + +class BeatmapScores[T: ScoreResp | LegacyScoreResp](BaseModel): + scores: list[T] + user_score: BeatmapUserScore[T] | None = None score_count: int = 0 @router.get( "/beatmaps/{beatmap_id}/scores", tags=["成绩"], - response_model=BeatmapScores, + response_model=BeatmapScores[ScoreResp] | BeatmapScores[LegacyScoreResp], name="获取谱面排行榜", - description="获取指定谱面在特定条件下的排行榜及当前用户成绩。", + description=( + "获取指定谱面在特定条件下的排行榜及当前用户成绩。\n\n" + "如果 `x-api-version >= 20220705`,返回值为 `BeatmapScores[ScoreResp]`," + "否则为 `BeatmapScores[LegacyScoreResp]`。" + ), ) async def get_beatmap_scores( db: Database, + api_version: APIVersion, beatmap_id: int = Path(description="谱面 ID"), mode: GameMode = Query(description="指定 auleset"), legacy_only: bool = Query(None, description="是否只查询 Stable 分数"), @@ -303,9 +315,9 @@ async def get_beatmap_scores( mods=sorted(mods), ) - user_score_resp = await ScoreResp.from_db(db, user_score) if user_score else None + user_score_resp = await user_score.to_resp(db, api_version) if user_score else None resp = BeatmapScores( - scores=[await ScoreResp.from_db(db, score) for score in all_scores], + scores=[await score.to_resp(db, api_version) for score in all_scores], user_score=BeatmapUserScore(score=user_score_resp, position=user_score_resp.rank_global or 0) if user_score_resp else None, @@ -314,20 +326,20 @@ async def get_beatmap_scores( return resp -class BeatmapUserScore(BaseModel): - position: int - score: ScoreResp - - @router.get( "/beatmaps/{beatmap_id}/scores/users/{user_id}", tags=["成绩"], - response_model=BeatmapUserScore, + response_model=BeatmapUserScore[ScoreResp] | BeatmapUserScore[LegacyScoreResp], name="获取用户谱面最高成绩", - description="获取指定用户在指定谱面上的最高成绩。", + description=( + "获取指定用户在指定谱面上的最高成绩。\n\n" + "如果 `x-api-version >= 20220705`,返回值为 `BeatmapUserScore[ScoreResp]`," + "否则为 `BeatmapUserScore[LegacyScoreResp]`。" + ), ) async def get_user_beatmap_score( db: Database, + api_version: APIVersion, beatmap_id: int = Path(description="谱面 ID"), user_id: int = Path(description="用户 ID"), legacy_only: bool = Query(None, description="是否只查询 Stable 分数"), @@ -355,7 +367,7 @@ async def get_user_beatmap_score( detail=f"Cannot find user {user_id}'s score on this beatmap", ) else: - resp = await ScoreResp.from_db(db, user_score) + resp = await user_score.to_resp(db, api_version=api_version) return BeatmapUserScore( position=resp.rank_global or 0, score=resp, @@ -365,12 +377,17 @@ async def get_user_beatmap_score( @router.get( "/beatmaps/{beatmap_id}/scores/users/{user_id}/all", tags=["成绩"], - response_model=list[ScoreResp], + response_model=list[ScoreResp] | list[LegacyScoreResp], name="获取用户谱面全部成绩", - description="获取指定用户在指定谱面上的全部成绩列表。", + description=( + "获取指定用户在指定谱面上的全部成绩列表。\n\n" + "如果 `x-api-version >= 20220705`,返回值为 `ScoreResp`列表," + "否则为 `LegacyScoreResp`列表。" + ), ) async def get_user_all_beatmap_scores( db: Database, + api_version: APIVersion, beatmap_id: int = Path(description="谱面 ID"), user_id: int = Path(description="用户 ID"), legacy_only: bool = Query(None, description="是否只查询 Stable 分数"), @@ -390,7 +407,7 @@ async def get_user_all_beatmap_scores( ) ).all() - return [await ScoreResp.from_db(db, score) for score in all_user_scores] + return [await score.to_resp(db, api_version) for score in all_user_scores] @router.post( diff --git a/app/router/v2/user.py b/app/router/v2/user.py index 67ca97a..463a8be 100644 --- a/app/router/v2/user.py +++ b/app/router/v2/user.py @@ -15,7 +15,8 @@ from app.database import ( from app.database.events import Event from app.database.lazer_user import SEARCH_INCLUDED from app.database.pp_best_score import PPBestScore -from app.database.score import Score, ScoreResp, get_user_first_scores +from app.database.score import LegacyScoreResp, Score, ScoreResp, get_user_first_scores +from app.dependencies.api_version import APIVersion from app.dependencies.database import Database, get_redis from app.dependencies.user import get_current_user from app.log import logger @@ -313,13 +314,18 @@ async def get_user_beatmapsets( @router.get( "/users/{user_id}/scores/{type}", - response_model=list[ScoreResp], + response_model=list[ScoreResp] | list[LegacyScoreResp], name="获取用户成绩列表", - description="获取用户特定类型的成绩列表,如最好成绩、最近成绩等。", + description=( + "获取用户特定类型的成绩列表,如最好成绩、最近成绩等。\n\n" + "如果 `x-api-version >= 20220705`,返回值为 `ScoreResp`列表," + "否则为 `LegacyScoreResp`列表。" + ), tags=["用户"], ) async def get_user_scores( session: Database, + api_version: APIVersion, background_task: BackgroundTasks, user_id: int = Path(description="用户 ID"), type: Literal["best", "recent", "firsts", "pinned"] = Path( @@ -332,12 +338,15 @@ async def get_user_scores( offset: int = Query(0, ge=0, description="偏移量"), current_user: User = Security(get_current_user, scopes=["public"]), ): + is_legacy_api = api_version < 20220705 redis = get_redis() cache_service = get_user_cache_service(redis) # 先尝试从缓存获取(对于recent类型使用较短的缓存时间) cache_expire = 30 if type == "recent" else settings.user_scores_cache_expire_seconds - cached_scores = await cache_service.get_user_scores_from_cache(user_id, type, include_fails, mode, limit, offset) + cached_scores = await cache_service.get_user_scores_from_cache( + user_id, type, include_fails, mode, limit, offset, is_legacy_api + ) if cached_scores is not None: return cached_scores @@ -373,9 +382,9 @@ async def get_user_scores( scores = [best_score.score for best_score in best_scores] score_responses = [ - await ScoreResp.from_db( + await score.to_resp( session, - score, + api_version, ) for score in scores ] @@ -385,12 +394,13 @@ async def get_user_scores( cache_service.cache_user_scores, user_id, type, - score_responses, + score_responses, # pyright: ignore[reportArgumentType] include_fails, mode, limit, offset, cache_expire, + is_legacy_api, ) return score_responses diff --git a/app/service/user_cache_service.py b/app/service/user_cache_service.py index a7c2817..b204438 100644 --- a/app/service/user_cache_service.py +++ b/app/service/user_cache_service.py @@ -13,7 +13,7 @@ from app.config import settings from app.const import BANCHOBOT_ID from app.database import User, UserResp from app.database.lazer_user import SEARCH_INCLUDED -from app.database.score import ScoreResp +from app.database.score import LegacyScoreResp, ScoreResp from app.log import logger from app.models.score import GameMode from app.service.asset_proxy_service import get_asset_proxy_service @@ -111,11 +111,13 @@ class UserCacheService: mode: GameMode | None = None, limit: int = 100, offset: int = 0, + is_legacy: bool = False, ) -> str: """生成用户成绩缓存键""" mode_part = f":{mode}" if mode else "" return ( - f"user:{user_id}:scores:{score_type}{mode_part}:limit:{limit}:offset:{offset}:include_fail:{include_fail}" + f"user:{user_id}:scores:{score_type}{mode_part}:limit:{limit}:offset:" + f"{offset}:include_fail:{include_fail}:is_legacy:{is_legacy}" ) def _get_user_beatmapsets_cache_key( @@ -166,10 +168,13 @@ class UserCacheService: mode: GameMode | None = None, limit: int = 100, offset: int = 0, - ) -> list[ScoreResp] | None: + is_legacy: bool = False, + ) -> list[ScoreResp] | list[LegacyScoreResp] | None: """从缓存获取用户成绩""" try: - cache_key = self._get_user_scores_cache_key(user_id, score_type, include_fail, mode, limit, offset) + cache_key = self._get_user_scores_cache_key( + user_id, score_type, include_fail, mode, limit, offset, is_legacy + ) cached_data = await self.redis.get(cache_key) if cached_data: logger.debug(f"User scores cache hit for user {user_id}, type {score_type}") @@ -184,18 +189,21 @@ class UserCacheService: self, user_id: int, score_type: str, - scores: list[ScoreResp], + scores: list[ScoreResp] | list[LegacyScoreResp], include_fail: bool, mode: GameMode | None = None, limit: int = 100, offset: int = 0, expire_seconds: int | None = None, + is_legacy: bool = False, ): """缓存用户成绩""" try: if expire_seconds is None: expire_seconds = settings.user_scores_cache_expire_seconds - cache_key = self._get_user_scores_cache_key(user_id, score_type, include_fail, mode, limit, offset) + cache_key = self._get_user_scores_cache_key( + user_id, score_type, include_fail, mode, limit, offset, is_legacy + ) # 使用 model_dump_json() 而不是 model_dump() + json.dumps() scores_json_list = [score.model_dump_json() for score in scores] cached_data = f"[{','.join(scores_json_list)}]"