diff --git a/app/database/__init__.py b/app/database/__init__.py index 9497ad1..0283923 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -1,5 +1,5 @@ from .achievement import UserAchievement, UserAchievementResp -from .auth import OAuthClient, OAuthToken +from .auth import OAuthClient, OAuthToken, V1APIKeys from .beatmap import ( Beatmap, BeatmapResp, @@ -109,6 +109,7 @@ __all__ = [ "UserResp", "UserStatistics", "UserStatisticsResp", + "V1APIKeys", ] for i in __all__: diff --git a/app/database/auth.py b/app/database/auth.py index 39ac549..632be23 100644 --- a/app/database/auth.py +++ b/app/database/auth.py @@ -5,7 +5,15 @@ from typing import TYPE_CHECKING from app.models.model import UTCBaseModel from sqlalchemy import Column, DateTime -from sqlmodel import JSON, BigInteger, Field, ForeignKey, Relationship, SQLModel, Text +from sqlmodel import ( + JSON, + BigInteger, + Field, + ForeignKey, + Relationship, + SQLModel, + Text, +) if TYPE_CHECKING: from .lazer_user import User @@ -41,3 +49,13 @@ class OAuthClient(SQLModel, table=True): owner_id: int = Field( sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) ) + + +class V1APIKeys(SQLModel, table=True): + __tablename__ = "v1_api_keys" # pyright: ignore[reportAssignmentType] + id: int | None = Field(default=None, primary_key=True) + name: str = Field(max_length=100, index=True) + key: str = Field(default_factory=secrets.token_hex, index=True) + owner_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) diff --git a/app/database/beatmap.py b/app/database/beatmap.py index f9d8aab..fac6d03 100644 --- a/app/database/beatmap.py +++ b/app/database/beatmap.py @@ -1,13 +1,18 @@ +import asyncio from datetime import datetime +import hashlib from typing import TYPE_CHECKING +from app.calculator import calculate_beatmap_attribute from app.config import settings -from app.models.beatmap import BeatmapRankStatus +from app.models.beatmap import BeatmapAttributes, BeatmapRankStatus +from app.models.mods import APIMod from app.models.score import MODE_TO_INT, GameMode from .beatmap_playcounts import BeatmapPlaycounts from .beatmapset import Beatmapset, BeatmapsetResp +from redis.asyncio import Redis from sqlalchemy import Column, DateTime from sqlmodel import VARCHAR, Field, Relationship, SQLModel, col, func, select from sqlmodel.ext.asyncio.session import AsyncSession @@ -195,3 +200,24 @@ class BeatmapResp(BeatmapBase): ) ).one() return cls.model_validate(beatmap_) + + +async def calculate_beatmap_attributes( + beatmap_id: int, + ruleset: GameMode, + mods_: list[APIMod], + redis: Redis, + fetcher: "Fetcher", +): + key = ( + f"beatmap:{beatmap_id}:{ruleset}:" + f"{hashlib.md5(str(mods_).encode()).hexdigest()}:attributes" + ) + if await redis.exists(key): + return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType] + resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id) + attr = await asyncio.get_event_loop().run_in_executor( + None, calculate_beatmap_attribute, resp, ruleset, mods_ + ) + await redis.set(key, attr.model_dump_json()) + return attr diff --git a/app/dependencies/user.py b/app/dependencies/user.py index 284e5d2..084323f 100644 --- a/app/dependencies/user.py +++ b/app/dependencies/user.py @@ -5,12 +5,14 @@ from typing import Annotated from app.auth import get_token_by_access_token from app.config import settings from app.database import User +from app.database.auth import V1APIKeys from app.models.oauth import OAuth2ClientCredentialsBearer from .database import get_db from fastapi import Depends, HTTPException from fastapi.security import ( + APIKeyQuery, HTTPBearer, OAuth2AuthorizationCodeBearer, OAuth2PasswordBearer, @@ -58,6 +60,23 @@ oauth2_client_credentials = OAuth2ClientCredentialsBearer( scheme_name="Client Credentials Grant", ) +v1_api_key = APIKeyQuery(name="k", scheme_name="V1 API Key", description="v1 API 密钥") + + +async def v1_authorize( + db: Annotated[AsyncSession, Depends(get_db)], + api_key: Annotated[str, Depends(v1_api_key)], +): + """V1 API Key 授权""" + if not api_key: + raise HTTPException(status_code=401, detail="Missing API key") + + api_key_record = ( + await db.exec(select(V1APIKeys).where(V1APIKeys.key == api_key)) + ).first() + if not api_key_record: + raise HTTPException(status_code=401, detail="Invalid API key") + async def get_client_user( token: Annotated[str, Depends(oauth2_password)], diff --git a/app/router/__init__.py b/app/router/__init__.py index 32248cb..57fc949 100644 --- a/app/router/__init__.py +++ b/app/router/__init__.py @@ -6,15 +6,21 @@ from .auth import router as auth_router from .fetcher import fetcher_router as fetcher_router from .file import file_router as file_router from .private import private_router as private_router -from .redirect import redirect_router as redirect_router +from .redirect import ( + redirect_api_router as redirect_api_router, + redirect_router as redirect_router, +) +from .v1.router import router as api_v1_router from .v2.router import router as api_v2_router __all__ = [ + "api_v1_router", "api_v2_router", "auth_router", "fetcher_router", "file_router", "private_router", + "redirect_api_router", "redirect_router", "signalr_router", ] diff --git a/app/router/redirect.py b/app/router/redirect.py index 61cb509..8df9fe0 100644 --- a/app/router/redirect.py +++ b/app/router/redirect.py @@ -4,7 +4,7 @@ import urllib.parse from app.config import settings -from fastapi import APIRouter, Request +from fastapi import APIRouter, HTTPException, Request from fastapi.responses import RedirectResponse redirect_router = APIRouter(include_in_schema=False) @@ -28,3 +28,20 @@ async def redirect(request: Request): redirect_url, status_code=301, ) + + +redirect_api_router = APIRouter(prefix="/api", include_in_schema=False) + + +@redirect_api_router.get("/{path}") +async def redirect_to_api_root(request: Request, path: str): + if path in { + "get_beatmaps", + "get_user", + "get_scores", + "get_user_best", + "get_user_recent", + "get_replay", + }: + return RedirectResponse(f"/api/v1/{path}?{request.url.query}", status_code=302) + raise HTTPException(404, detail="Not Found") diff --git a/app/router/v1/__init__.py b/app/router/v1/__init__.py new file mode 100644 index 0000000..2772528 --- /dev/null +++ b/app/router/v1/__init__.py @@ -0,0 +1,6 @@ +from __future__ import annotations + +from . import beatmap, replay, score, user # noqa: F401 +from .router import router as api_v1_router + +__all__ = ["api_v1_router"] diff --git a/app/router/v1/beatmap.py b/app/router/v1/beatmap.py new file mode 100644 index 0000000..d9b9532 --- /dev/null +++ b/app/router/v1/beatmap.py @@ -0,0 +1,226 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from app.database.beatmap import Beatmap, calculate_beatmap_attributes +from app.database.beatmap_playcounts import BeatmapPlaycounts +from app.database.beatmapset import Beatmapset +from app.database.favourite_beatmapset import FavouriteBeatmapset +from app.database.score import Score +from app.dependencies.database import get_db, get_redis +from app.dependencies.fetcher import get_fetcher +from app.fetcher import Fetcher +from app.models.beatmap import BeatmapRankStatus, Genre, Language +from app.models.mods import int_to_mods +from app.models.score import MODE_TO_INT, GameMode + +from .router import AllStrModel, router + +from fastapi import Depends, Query +from redis.asyncio import Redis +from sqlmodel import col, func, select +from sqlmodel.ext.asyncio.session import AsyncSession + + +class V1Beatmap(AllStrModel): + approved: BeatmapRankStatus + submit_date: datetime + approved_date: datetime | None = None + last_update: datetime + artist: str + artist_unicode: str + beatmap_id: int + beatmapset_id: int + bpm: float + creator: str + creator_id: int + difficultyrating: float + diff_aim: float | None = None + diff_speed: float | None = None + diff_size: float # CS + diff_overall: float # OD + diff_approach: float # AR + diff_drain: float # HP + hit_length: int + source: str + genre_id: Genre + language_id: Language + title: str + title_unicode: str + total_length: int + version: str + file_md5: str + mode: int + tags: str + favourite_count: int + rating: float + playcount: int + passcount: int + count_normal: int + count_slider: int + count_spinner: int + max_combo: int | None = None + storyboard: bool + video: bool + download_unavailable: bool + audio_unavailable: bool + + @classmethod + async def from_db( + cls, + session: AsyncSession, + db_beatmap: Beatmap, + diff_aim: float | None = None, + diff_speed: float | None = None, + ) -> "V1Beatmap": + return cls( + approved=db_beatmap.beatmap_status, + submit_date=db_beatmap.beatmapset.submitted_date, + approved_date=db_beatmap.beatmapset.ranked_date, + last_update=db_beatmap.last_updated, + artist=db_beatmap.beatmapset.artist, + beatmap_id=db_beatmap.id, + beatmapset_id=db_beatmap.beatmapset.id, + bpm=db_beatmap.bpm, + creator=db_beatmap.beatmapset.creator, + creator_id=db_beatmap.beatmapset.user_id, + difficultyrating=db_beatmap.difficulty_rating, + diff_aim=diff_aim, + diff_speed=diff_speed, + diff_size=db_beatmap.cs, + diff_overall=db_beatmap.accuracy, + diff_approach=db_beatmap.ar, + diff_drain=db_beatmap.drain, + hit_length=db_beatmap.hit_length, + source=db_beatmap.beatmapset.source, + genre_id=db_beatmap.beatmapset.beatmap_genre, + language_id=db_beatmap.beatmapset.beatmap_language, + title=db_beatmap.beatmapset.title, + total_length=db_beatmap.total_length, + version=db_beatmap.version, + file_md5=db_beatmap.checksum, + mode=MODE_TO_INT[db_beatmap.mode], + tags=db_beatmap.beatmapset.tags, + favourite_count=( + await session.exec( + select(func.count()) + .select_from(FavouriteBeatmapset) + .where( + FavouriteBeatmapset.beatmapset_id == db_beatmap.beatmapset.id + ) + ) + ).one(), + rating=0, # TODO + playcount=( + await session.exec( + select(func.count()) + .select_from(BeatmapPlaycounts) + .where(BeatmapPlaycounts.beatmap_id == db_beatmap.id) + ) + ).one(), + passcount=( + await session.exec( + select(func.count()) + .select_from(Score) + .where( + Score.beatmap_id == db_beatmap.id, + col(Score.passed).is_(True), + ) + ) + ).one(), + count_normal=db_beatmap.count_circles, + count_slider=db_beatmap.count_sliders, + count_spinner=db_beatmap.count_spinners, + max_combo=db_beatmap.max_combo, + storyboard=db_beatmap.beatmapset.storyboard, + video=db_beatmap.beatmapset.video, + download_unavailable=db_beatmap.beatmapset.download_disabled, + audio_unavailable=db_beatmap.beatmapset.download_disabled, + artist_unicode=db_beatmap.beatmapset.artist_unicode, + title_unicode=db_beatmap.beatmapset.title_unicode, + ) + + +@router.get( + "/get_beatmaps", + name="获取谱面", + response_model=list[V1Beatmap], + description="根据指定条件搜索谱面。", +) +async def get_beatmaps( + since: datetime | None = Query(None, description="自指定时间后拥有排行榜的谱面"), + beatmapset_id: int | None = Query(None, alias="s", description="谱面集 ID"), + beatmap_id: int | None = Query(None, alias="b", description="谱面 ID"), + user: str | None = Query(None, alias="u", description="谱师"), + type: Literal["string", "id"] | None = Query( + None, description="用户类型:string 用户名称 / id 用户 ID" + ), + ruleset_id: int | None = Query( + None, alias="m", description="Ruleset ID", ge=0, le=3 + ), # TODO + convert: bool = Query(False, alias="a", description="转谱"), # TODO + checksum: str | None = Query(None, alias="h", description="谱面文件 MD5"), + limit: int = Query(500, ge=1, le=500, description="返回结果数量限制"), + mods: int = Query(0, description="应用到谱面属性的 MOD"), + session: AsyncSession = Depends(get_db), + redis: Redis = Depends(get_redis), + fetcher: Fetcher = Depends(get_fetcher), +): + beatmaps: list[Beatmap] = [] + results = [] + if beatmap_id is not None: + beatmaps.append(await Beatmap.get_or_fetch(session, fetcher, beatmap_id)) + elif checksum is not None: + beatmaps.append(await Beatmap.get_or_fetch(session, fetcher, md5=checksum)) + elif beatmapset_id is not None: + beatmapset = await Beatmapset.get_or_fetch(session, fetcher, beatmapset_id) + await beatmapset.awaitable_attrs.beatmaps + if len(beatmapset.beatmaps) > limit: + beatmaps = beatmapset.beatmaps[:limit] + else: + beatmaps = beatmapset.beatmaps + elif user is not None: + where = ( + Beatmapset.user_id == user + if type == "id" or user.isdigit() + else Beatmapset.creator == user + ) + beatmapsets = (await session.exec(select(Beatmapset).where(where))).all() + for beatmapset in beatmapsets: + if len(beatmaps) >= limit: + break + beatmaps.extend(beatmapset.beatmaps) + elif since is not None: + beatmapsets = ( + await session.exec( + select(Beatmapset) + .where(col(Beatmapset.ranked_date) > since) + .limit(limit) + ) + ).all() + for beatmapset in beatmapsets: + if len(beatmaps) >= limit: + break + beatmaps.extend(beatmapset.beatmaps) + + for beatmap in beatmaps: + if beatmap.mode == GameMode.OSU: + try: + attrs = await calculate_beatmap_attributes( + beatmap.id, + beatmap.mode, + sorted(int_to_mods(mods), key=lambda m: m["acronym"]), + redis, + fetcher, + ) + results.append( + await V1Beatmap.from_db( + session, beatmap, attrs.aim_difficulty, attrs.speed_difficulty + ) + ) + continue + except Exception: + ... + results.append(await V1Beatmap.from_db(session, beatmap, None, None)) + return results diff --git a/app/router/v1/replay.py b/app/router/v1/replay.py new file mode 100644 index 0000000..a9fdb97 --- /dev/null +++ b/app/router/v1/replay.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import base64 +from datetime import date +from typing import Literal + +from app.database.counts import ReplayWatchedCount +from app.database.score import Score +from app.dependencies.database import get_db +from app.dependencies.storage import get_storage_service +from app.models.mods import int_to_mods +from app.models.score import INT_TO_MODE +from app.storage import StorageService + +from .router import router + +from fastapi import Depends, HTTPException, Query +from pydantic import BaseModel +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + + +class ReplayModel(BaseModel): + content: str + encoding: Literal["base64"] = "base64" + + +@router.get( + "/get_replay", + response_model=ReplayModel, + name="获取回放文件", + description="获取指定谱面的回放文件。", +) +async def download_replay( + beatmap: int = Query(..., alias="b", description="谱面 ID"), + user: str = Query(..., alias="u", description="用户"), + ruleset_id: int | None = Query( + None, alias="m", description="Ruleset ID", ge=0, le=3 + ), + score_id: int | None = Query(None, alias="s", description="成绩 ID"), + type: Literal["string", "id"] | None = Query( + None, description="用户类型:string 用户名称 / id 用户 ID" + ), + mods: int = Query(0, description="成绩的 MOD"), + session: AsyncSession = Depends(get_db), + storage_service: StorageService = Depends(get_storage_service), +): + mods_ = int_to_mods(mods) + if score_id is not None: + score_record = await session.get(Score, score_id) + if score_record is None: + raise HTTPException(status_code=404, detail="Score not found") + else: + score_record = ( + await session.exec( + select(Score).where( + Score.beatmap_id == beatmap, + Score.user_id == user + if type == "id" or user.isdigit() + else Score.user.username == user, + Score.mods == mods_, + Score.gamemode == INT_TO_MODE[ruleset_id] + if ruleset_id is not None + else True, + ) + ) + ).first() + if score_record is None: + raise HTTPException(status_code=404, detail="Score not found") + + filepath = ( + f"replays/{score_record.id}_{score_record.beatmap_id}" + f"_{score_record.user_id}_lazer_replay.osr" + ) + if not await storage_service.is_exists(filepath): + raise HTTPException(status_code=404, detail="Replay file not found") + + replay_watched_count = ( + await session.exec( + select(ReplayWatchedCount).where( + ReplayWatchedCount.user_id == score_record.user_id, + ReplayWatchedCount.year == date.today().year, + ReplayWatchedCount.month == date.today().month, + ) + ) + ).first() + if replay_watched_count is None: + replay_watched_count = ReplayWatchedCount( + user_id=score_record.user_id, + year=date.today().year, + month=date.today().month, + ) + session.add(replay_watched_count) + replay_watched_count.count += 1 + await session.commit() + + data = await storage_service.read_file(filepath) + return ReplayModel( + content=base64.b64encode(data).decode("utf-8"), encoding="base64" + ) diff --git a/app/router/v1/router.py b/app/router/v1/router.py new file mode 100644 index 0000000..b5587ff --- /dev/null +++ b/app/router/v1/router.py @@ -0,0 +1,25 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from app.dependencies.user import v1_api_key + +from fastapi import APIRouter, Depends +from pydantic import BaseModel, field_serializer + +router = APIRouter( + prefix="/api/v1", dependencies=[Depends(v1_api_key)], tags=["V1 API"] +) + + +class AllStrModel(BaseModel): + @field_serializer("*", when_used="json") + def serialize_datetime(self, v, _info): + if isinstance(v, Enum): + return str(v.value) + elif isinstance(v, datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(v, bool): + return "1" if v else "0" + return str(v) diff --git a/app/router/v1/score.py b/app/router/v1/score.py new file mode 100644 index 0000000..ba993df --- /dev/null +++ b/app/router/v1/score.py @@ -0,0 +1,171 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +from typing import Literal + +from app.database.pp_best_score import PPBestScore +from app.database.score import Score, get_leaderboard +from app.dependencies.database import get_db +from app.models.mods import int_to_mods, mods_to_int +from app.models.score import INT_TO_MODE, LeaderboardType + +from .router import AllStrModel, router + +from fastapi import Depends, Query +from sqlalchemy.orm import joinedload +from sqlmodel import col, exists, select +from sqlmodel.ext.asyncio.session import AsyncSession + + +class V1Score(AllStrModel): + beatmap_id: int | None = None + username: str | None = None + score_id: int + score: int + maxcombo: int | None = None + count50: int + count100: int + count300: int + countmiss: int + countkatu: int + countgeki: int + perfect: bool + enabled_mods: int + user_id: int + date: datetime + rank: str + pp: float + replay_available: bool + + @classmethod + async def from_db(cls, score: Score): + return cls( + beatmap_id=score.beatmap_id, + username=score.user.username, + score_id=score.id, + score=score.total_score, + maxcombo=score.max_combo, + count50=score.n50, + count100=score.n100, + count300=score.n300, + countmiss=score.nmiss, + countkatu=score.nkatu, + countgeki=score.ngeki, + perfect=score.is_perfect_combo, + enabled_mods=mods_to_int(score.mods), + user_id=score.user_id, + date=score.ended_at, + rank=score.rank, + pp=score.pp, + replay_available=score.has_replay, + ) + + +@router.get( + "/get_user_best", + response_model=list[V1Score], + name="获取用户最好成绩", + description="获取指定用户的最好成绩。", +) +async def get_user_best( + user: str = Query(..., alias="u", description="用户"), + ruleset_id: int = Query(0, alias="m", description="Ruleset ID", ge=0, le=3), + type: Literal["string", "id"] | None = Query( + None, description="用户类型:string 用户名称 / id 用户 ID" + ), + limit: int = Query(10, ge=1, le=100, description="返回的成绩数量"), + session: AsyncSession = Depends(get_db), +): + scores = ( + await session.exec( + select(Score) + .where( + Score.user_id == user + if type == "id" or user.isdigit() + else Score.user.username == user, + Score.gamemode == INT_TO_MODE[ruleset_id], + exists().where(col(PPBestScore.score_id) == Score.id), + ) + .order_by(col(Score.pp).desc()) + .options(joinedload(Score.beatmap)) + .limit(limit) + ) + ).all() + return [await V1Score.from_db(score) for score in scores] + + +@router.get( + "/get_user_recent", + response_model=list[V1Score], + name="获取用户最近成绩", + description="获取指定用户的最近成绩。", +) +async def get_user_recent( + user: str = Query(..., alias="u", description="用户"), + ruleset_id: int = Query(0, alias="m", description="Ruleset ID", ge=0, le=3), + type: Literal["string", "id"] | None = Query( + None, description="用户类型:string 用户名称 / id 用户 ID" + ), + limit: int = Query(10, ge=1, le=100, description="返回的成绩数量"), + session: AsyncSession = Depends(get_db), +): + scores = ( + await session.exec( + select(Score) + .where( + Score.user_id == user + if type == "id" or user.isdigit() + else Score.user.username == user, + Score.gamemode == INT_TO_MODE[ruleset_id], + Score.ended_at > datetime.now(UTC) - timedelta(hours=24), + ) + .order_by(col(Score.pp).desc()) + .options(joinedload(Score.beatmap)) + .limit(limit) + ) + ).all() + return [await V1Score.from_db(score) for score in scores] + + +@router.get( + "/get_scores", + response_model=list[V1Score], + name="获取成绩", + description="获取指定谱面的成绩。", +) +async def get_scores( + user: str | None = Query(None, alias="u", description="用户"), + beatmap_id: int = Query(alias="b", description="谱面 ID"), + ruleset_id: int = Query(0, alias="m", description="Ruleset ID", ge=0, le=3), + type: Literal["string", "id"] | None = Query( + None, description="用户类型:string 用户名称 / id 用户 ID" + ), + limit: int = Query(10, ge=1, le=100, description="返回的成绩数量"), + mods: int = Query(0, description="成绩的 MOD"), + session: AsyncSession = Depends(get_db), +): + if user is not None: + scores = ( + await session.exec( + select(Score) + .where( + Score.gamemode == INT_TO_MODE[ruleset_id], + Score.beatmap_id == beatmap_id, + Score.user_id == user + if type == "id" or user.isdigit() + else Score.user.username == user, + ) + .options(joinedload(Score.beatmap)) + .order_by(col(Score.classic_total_score).desc()) + ) + ).all() + else: + scores, _ = await get_leaderboard( + session, + beatmap_id, + INT_TO_MODE[ruleset_id], + LeaderboardType.GLOBAL, + [mod["acronym"] for mod in int_to_mods(mods)], + limit=limit, + ) + return [await V1Score.from_db(score) for score in scores] diff --git a/app/router/v1/user.py b/app/router/v1/user.py new file mode 100644 index 0000000..c425730 --- /dev/null +++ b/app/router/v1/user.py @@ -0,0 +1,118 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Literal + +from app.database.lazer_user import User +from app.database.statistics import UserStatistics, UserStatisticsResp +from app.dependencies.database import get_db +from app.models.score import INT_TO_MODE, GameMode + +from .router import AllStrModel, router + +from fastapi import Depends, Query +from sqlmodel import select +from sqlmodel.ext.asyncio.session import AsyncSession + + +class V1User(AllStrModel): + user_id: int + username: str + join_date: datetime + count300: int + count100: int + count50: int + playcount: int + ranked_score: int + total_score: int + pp_rank: int + level: float + pp_raw: float + accuracy: float + count_rank_ss: int + count_rank_ssh: int + count_rank_s: int + count_rank_sh: int + count_rank_a: int + country: str + total_seconds_played: int + pp_country_rank: int + events: list[dict] + + @classmethod + async def from_db( + cls, session: AsyncSession, db_user: User, ruleset: GameMode | None = None + ) -> "V1User": + ruleset = ruleset or db_user.playmode + current_statistics: UserStatistics | None = None + for i in await db_user.awaitable_attrs.statistics: + if i.mode == ruleset: + current_statistics = i + break + if current_statistics: + statistics = await UserStatisticsResp.from_db( + current_statistics, session, db_user.country_code + ) + else: + statistics = None + return cls( + user_id=db_user.id, + username=db_user.username, + join_date=db_user.join_date, + count300=statistics.count_300 if statistics else 0, + count100=statistics.count_100 if statistics else 0, + count50=statistics.count_50 if statistics else 0, + playcount=statistics.play_count if statistics else 0, + ranked_score=statistics.ranked_score if statistics else 0, + total_score=statistics.total_score if statistics else 0, + pp_rank=statistics.global_rank if statistics else 0, + level=current_statistics.level_current if current_statistics else 0, + pp_raw=statistics.pp if statistics else 0.0, + accuracy=statistics.hit_accuracy if statistics else 0, + count_rank_ss=current_statistics.grade_ss if current_statistics else 0, + count_rank_ssh=current_statistics.grade_ssh if current_statistics else 0, + count_rank_s=current_statistics.grade_s if current_statistics else 0, + count_rank_sh=current_statistics.grade_sh if current_statistics else 0, + count_rank_a=current_statistics.grade_a if current_statistics else 0, + country=db_user.country_code, + total_seconds_played=statistics.play_time if statistics else 0, + pp_country_rank=statistics.country_rank if statistics else 0, + events=[], # TODO + ) + + +@router.get( + "/get_user", + response_model=list[V1User], + name="获取用户信息", + description="获取指定用户的信息。", +) +async def get_user( + user: str = Query(..., alias="u", description="用户"), + ruleset_id: int | None = Query( + None, alias="m", description="Ruleset ID", ge=0, le=3 + ), + type: Literal["string", "id"] | None = Query( + None, description="用户类型:string 用户名称 / id 用户 ID" + ), + event_days: int = Query( + default=1, ge=1, le=31, description="从现在起所有事件的最大天数" + ), + session: AsyncSession = Depends(get_db), +): + db_user = ( + await session.exec( + select(User).where( + User.id == user + if type == "id" or user.isdigit() + else User.username == user, + ) + ) + ).first() + if not db_user: + return [] + return [ + await V1User.from_db( + session, db_user, INT_TO_MODE[ruleset_id] if ruleset_id else None + ) + ] diff --git a/app/router/v2/beatmap.py b/app/router/v2/beatmap.py index 460fc42..d0c582e 100644 --- a/app/router/v2/beatmap.py +++ b/app/router/v2/beatmap.py @@ -4,8 +4,8 @@ import asyncio import hashlib import json -from app.calculator import calculate_beatmap_attribute from app.database import Beatmap, BeatmapResp, User +from app.database.beatmap import calculate_beatmap_attributes from app.dependencies.database import get_db, get_redis from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user @@ -195,16 +195,11 @@ async def get_beatmap_attributes( ) if await redis.exists(key): return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType] - try: - resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id) - try: - attr = await asyncio.get_event_loop().run_in_executor( - None, calculate_beatmap_attribute, resp, ruleset, mods_ - ) - except rosu.ConvertError as e: # pyright: ignore[reportAttributeAccessIssue] - raise HTTPException(status_code=400, detail=str(e)) - await redis.set(key, attr.model_dump_json()) - return attr + return await calculate_beatmap_attributes( + beatmap_id, ruleset, mods_, redis, fetcher + ) except HTTPStatusError: raise HTTPException(status_code=404, detail="Beatmap not found") + except rosu.ConvertError as e: # pyright: ignore[reportAttributeAccessIssue] + raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/main.py b/main.py index 5cde52a..c2739c4 100644 --- a/main.py +++ b/main.py @@ -10,11 +10,13 @@ from app.dependencies.fetcher import get_fetcher from app.dependencies.scheduler import init_scheduler, stop_scheduler from app.log import logger from app.router import ( + api_v1_router, api_v2_router, auth_router, fetcher_router, file_router, private_router, + redirect_api_router, signalr_router, ) from app.router.redirect import redirect_router @@ -48,8 +50,9 @@ async def lifespan(app: FastAPI): desc = ( - "osu! API 模拟服务器,支持 osu! API v2 和 osu!lazer 的绝大部分功能。\n\n" - "官方文档:[osu!web 文档](https://osu.ppy.sh/docs/index.html)" + "osu! API 模拟服务器,支持 osu! API v1, v2 和 osu!lazer 的绝大部分功能。\n\n" + "官方文档:[osu!web 文档](https://osu.ppy.sh/docs/index.html)\n\n" + "V1 API 文档:[osu-api](https://github.com/ppy/osu-api/wiki)" ) if settings.sentry_dsn is not None: @@ -67,6 +70,8 @@ app = FastAPI( ) app.include_router(api_v2_router) +app.include_router(api_v1_router) +app.include_router(redirect_api_router) app.include_router(signalr_router) app.include_router(fetcher_router) app.include_router(file_router) diff --git a/migrations/versions/7e9d5e012d37_auth_add_v1_keys_table.py b/migrations/versions/7e9d5e012d37_auth_add_v1_keys_table.py new file mode 100644 index 0000000..b410ee7 --- /dev/null +++ b/migrations/versions/7e9d5e012d37_auth_add_v1_keys_table.py @@ -0,0 +1,51 @@ +"""auth: add v1 keys table + +Revision ID: 7e9d5e012d37 +Revises: ce29ef0a5674 +Create Date: 2025-08-14 08:39:51.725121 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +import sqlmodel + +# revision identifiers, used by Alembic. +revision: str = "7e9d5e012d37" +down_revision: str | Sequence[str] | None = "ce29ef0a5674" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "v1_api_keys", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sqlmodel.sql.sqltypes.AutoString(length=100), nullable=False), + sa.Column("key", sqlmodel.sql.sqltypes.AutoString(), nullable=False), + sa.Column("owner_id", sa.BigInteger(), nullable=True), + sa.ForeignKeyConstraint( + ["owner_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_v1_api_keys_key"), "v1_api_keys", ["key"], unique=False) + op.create_index(op.f("ix_v1_api_keys_name"), "v1_api_keys", ["name"], unique=False) + op.create_index( + op.f("ix_v1_api_keys_owner_id"), "v1_api_keys", ["owner_id"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table("v1_api_keys") + # ### end Alembic commands ###