feat(v1-api): support api v1
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
from .achievement import UserAchievement, UserAchievementResp
|
from .achievement import UserAchievement, UserAchievementResp
|
||||||
from .auth import OAuthClient, OAuthToken
|
from .auth import OAuthClient, OAuthToken, V1APIKeys
|
||||||
from .beatmap import (
|
from .beatmap import (
|
||||||
Beatmap,
|
Beatmap,
|
||||||
BeatmapResp,
|
BeatmapResp,
|
||||||
@@ -109,6 +109,7 @@ __all__ = [
|
|||||||
"UserResp",
|
"UserResp",
|
||||||
"UserStatistics",
|
"UserStatistics",
|
||||||
"UserStatisticsResp",
|
"UserStatisticsResp",
|
||||||
|
"V1APIKeys",
|
||||||
]
|
]
|
||||||
|
|
||||||
for i in __all__:
|
for i in __all__:
|
||||||
|
|||||||
@@ -5,7 +5,15 @@ from typing import TYPE_CHECKING
|
|||||||
from app.models.model import UTCBaseModel
|
from app.models.model import UTCBaseModel
|
||||||
|
|
||||||
from sqlalchemy import Column, DateTime
|
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:
|
if TYPE_CHECKING:
|
||||||
from .lazer_user import User
|
from .lazer_user import User
|
||||||
@@ -41,3 +49,13 @@ class OAuthClient(SQLModel, table=True):
|
|||||||
owner_id: int = Field(
|
owner_id: int = Field(
|
||||||
sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True)
|
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
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from app.calculator import calculate_beatmap_attribute
|
||||||
from app.config import settings
|
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 app.models.score import MODE_TO_INT, GameMode
|
||||||
|
|
||||||
from .beatmap_playcounts import BeatmapPlaycounts
|
from .beatmap_playcounts import BeatmapPlaycounts
|
||||||
from .beatmapset import Beatmapset, BeatmapsetResp
|
from .beatmapset import Beatmapset, BeatmapsetResp
|
||||||
|
|
||||||
|
from redis.asyncio import Redis
|
||||||
from sqlalchemy import Column, DateTime
|
from sqlalchemy import Column, DateTime
|
||||||
from sqlmodel import VARCHAR, Field, Relationship, SQLModel, col, func, select
|
from sqlmodel import VARCHAR, Field, Relationship, SQLModel, col, func, select
|
||||||
from sqlmodel.ext.asyncio.session import AsyncSession
|
from sqlmodel.ext.asyncio.session import AsyncSession
|
||||||
@@ -195,3 +200,24 @@ class BeatmapResp(BeatmapBase):
|
|||||||
)
|
)
|
||||||
).one()
|
).one()
|
||||||
return cls.model_validate(beatmap_)
|
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.auth import get_token_by_access_token
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.database import User
|
from app.database import User
|
||||||
|
from app.database.auth import V1APIKeys
|
||||||
from app.models.oauth import OAuth2ClientCredentialsBearer
|
from app.models.oauth import OAuth2ClientCredentialsBearer
|
||||||
|
|
||||||
from .database import get_db
|
from .database import get_db
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException
|
from fastapi import Depends, HTTPException
|
||||||
from fastapi.security import (
|
from fastapi.security import (
|
||||||
|
APIKeyQuery,
|
||||||
HTTPBearer,
|
HTTPBearer,
|
||||||
OAuth2AuthorizationCodeBearer,
|
OAuth2AuthorizationCodeBearer,
|
||||||
OAuth2PasswordBearer,
|
OAuth2PasswordBearer,
|
||||||
@@ -58,6 +60,23 @@ oauth2_client_credentials = OAuth2ClientCredentialsBearer(
|
|||||||
scheme_name="Client Credentials Grant",
|
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(
|
async def get_client_user(
|
||||||
token: Annotated[str, Depends(oauth2_password)],
|
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 .fetcher import fetcher_router as fetcher_router
|
||||||
from .file import file_router as file_router
|
from .file import file_router as file_router
|
||||||
from .private import private_router as private_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
|
from .v2.router import router as api_v2_router
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
"api_v1_router",
|
||||||
"api_v2_router",
|
"api_v2_router",
|
||||||
"auth_router",
|
"auth_router",
|
||||||
"fetcher_router",
|
"fetcher_router",
|
||||||
"file_router",
|
"file_router",
|
||||||
"private_router",
|
"private_router",
|
||||||
|
"redirect_api_router",
|
||||||
"redirect_router",
|
"redirect_router",
|
||||||
"signalr_router",
|
"signalr_router",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import urllib.parse
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
|
||||||
from fastapi import APIRouter, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from fastapi.responses import RedirectResponse
|
from fastapi.responses import RedirectResponse
|
||||||
|
|
||||||
redirect_router = APIRouter(include_in_schema=False)
|
redirect_router = APIRouter(include_in_schema=False)
|
||||||
@@ -28,3 +28,20 @@ async def redirect(request: Request):
|
|||||||
redirect_url,
|
redirect_url,
|
||||||
status_code=301,
|
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 hashlib
|
||||||
import json
|
import json
|
||||||
|
|
||||||
from app.calculator import calculate_beatmap_attribute
|
|
||||||
from app.database import Beatmap, BeatmapResp, User
|
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.database import get_db, get_redis
|
||||||
from app.dependencies.fetcher import get_fetcher
|
from app.dependencies.fetcher import get_fetcher
|
||||||
from app.dependencies.user import get_current_user
|
from app.dependencies.user import get_current_user
|
||||||
@@ -195,16 +195,11 @@ async def get_beatmap_attributes(
|
|||||||
)
|
)
|
||||||
if await redis.exists(key):
|
if await redis.exists(key):
|
||||||
return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType]
|
return BeatmapAttributes.model_validate_json(await redis.get(key)) # pyright: ignore[reportArgumentType]
|
||||||
|
|
||||||
try:
|
try:
|
||||||
resp = await fetcher.get_or_fetch_beatmap_raw(redis, beatmap_id)
|
return await calculate_beatmap_attributes(
|
||||||
try:
|
beatmap_id, ruleset, mods_, redis, fetcher
|
||||||
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
|
|
||||||
except HTTPStatusError:
|
except HTTPStatusError:
|
||||||
raise HTTPException(status_code=404, detail="Beatmap not found")
|
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.dependencies.scheduler import init_scheduler, stop_scheduler
|
||||||
from app.log import logger
|
from app.log import logger
|
||||||
from app.router import (
|
from app.router import (
|
||||||
|
api_v1_router,
|
||||||
api_v2_router,
|
api_v2_router,
|
||||||
auth_router,
|
auth_router,
|
||||||
fetcher_router,
|
fetcher_router,
|
||||||
file_router,
|
file_router,
|
||||||
private_router,
|
private_router,
|
||||||
|
redirect_api_router,
|
||||||
signalr_router,
|
signalr_router,
|
||||||
)
|
)
|
||||||
from app.router.redirect import redirect_router
|
from app.router.redirect import redirect_router
|
||||||
@@ -48,8 +50,9 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
desc = (
|
desc = (
|
||||||
"osu! API 模拟服务器,支持 osu! API v2 和 osu!lazer 的绝大部分功能。\n\n"
|
"osu! API 模拟服务器,支持 osu! API v1, v2 和 osu!lazer 的绝大部分功能。\n\n"
|
||||||
"官方文档:[osu!web 文档](https://osu.ppy.sh/docs/index.html)"
|
"官方文档:[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:
|
if settings.sentry_dsn is not None:
|
||||||
@@ -67,6 +70,8 @@ app = FastAPI(
|
|||||||
)
|
)
|
||||||
|
|
||||||
app.include_router(api_v2_router)
|
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(signalr_router)
|
||||||
app.include_router(fetcher_router)
|
app.include_router(fetcher_router)
|
||||||
app.include_router(file_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