feat(v1-api): support api v1

This commit is contained in:
MingxuanGame
2025-08-14 09:11:53 +00:00
parent c98ea9c723
commit a8906b8194
15 changed files with 802 additions and 18 deletions

View File

@@ -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__:

View File

@@ -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)
)

View File

@@ -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

View File

@@ -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)],

View File

@@ -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",
]

View File

@@ -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")

View 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
View 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
View 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
View 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
View 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
View 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
)
]

View File

@@ -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_
return await calculate_beatmap_attributes(
beatmap_id, ruleset, mods_, redis, fetcher
)
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
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

View File

@@ -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)

View 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 ###