feat(v1-api): support api v1
This commit is contained in:
@@ -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__:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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")
|
||||
|
||||
6
app/router/v1/__init__.py
Normal file
6
app/router/v1/__init__.py
Normal file
@@ -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"]
|
||||
226
app/router/v1/beatmap.py
Normal file
226
app/router/v1/beatmap.py
Normal file
@@ -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
|
||||
100
app/router/v1/replay.py
Normal file
100
app/router/v1/replay.py
Normal file
@@ -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"
|
||||
)
|
||||
25
app/router/v1/router.py
Normal file
25
app/router/v1/router.py
Normal file
@@ -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)
|
||||
171
app/router/v1/score.py
Normal file
171
app/router/v1/score.py
Normal file
@@ -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]
|
||||
118
app/router/v1/user.py
Normal file
118
app/router/v1/user.py
Normal file
@@ -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
|
||||
)
|
||||
]
|
||||
@@ -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
|
||||
|
||||
9
main.py
9
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)
|
||||
|
||||
51
migrations/versions/7e9d5e012d37_auth_add_v1_keys_table.py
Normal file
51
migrations/versions/7e9d5e012d37_auth_add_v1_keys_table.py
Normal file
@@ -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 ###
|
||||
Reference in New Issue
Block a user