feat(api): 添加测试,小修小补

- **未经测试**
This commit is contained in:
jimmy-sketch
2025-07-26 10:28:48 +08:00
parent 3b697785fc
commit 7ea4570c17
10 changed files with 266 additions and 59 deletions

View File

@@ -13,10 +13,11 @@ RUN apt-get update && apt-get install -y \
# 复制依赖文件 # 复制依赖文件
COPY uv.lock . COPY uv.lock .
COPY pyproject.toml . COPY pyproject.toml .
COPY requirements.txt .
# 安装Python依赖 # 安装Python依赖
RUN uv sync --locked RUN uv sync
RUN pip install uvicorn RUN pip install -r requirements.txt
# 复制应用代码 # 复制应用代码
COPY . . COPY . .

View File

@@ -10,9 +10,9 @@ load_dotenv()
class Settings: class Settings:
# 数据库设置 # 数据库设置
DATABASE_URL: str = os.getenv( DATABASE_URL: str = os.getenv(
"DATABASE_URL", "mysql+aiomysql://root:password@localhost:3306/osu_api" "DATABASE_URL", "mysql+aiomysql://root:password@127.0.0.1:3306/osu_api"
) )
REDIS_URL: str = os.getenv("REDIS_URL", "redis://localhost:6379/0") REDIS_URL: str = os.getenv("REDIS_URL", "redis://127.0.0.1:6379/0")
# JWT 设置 # JWT 设置
SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here") SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here")

View File

@@ -87,7 +87,7 @@ class BeatmapsetBase(SQLModel):
# Beatmapset # Beatmapset
artist: str = Field(index=True) artist: str = Field(index=True)
artist_unicode: str = Field(index=True) artist_unicode: str = Field(index=True)
covers: BeatmapCovers = Field(sa_column=Column(JSON)) covers: BeatmapCovers | None = Field(sa_column=Column(JSON))
creator: str creator: str
favourite_count: int favourite_count: int
nsfw: bool = Field(default=False) nsfw: bool = Field(default=False)

View File

@@ -4,6 +4,7 @@ from typing import TYPE_CHECKING
from sqlalchemy import JSON, Column, DateTime from sqlalchemy import JSON, Column, DateTime
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
from sqlalchemy.orm import Mapped
if TYPE_CHECKING: if TYPE_CHECKING:
from .user import User from .user import User
@@ -70,7 +71,7 @@ class LegacyUserStatistics(SQLModel, table=True):
) )
# 关联关系 # 关联关系
user: "User" = Relationship(back_populates="statistics") user: Mapped["User"] = Relationship(back_populates="statistics")
class LegacyOAuthToken(SQLModel, table=True): class LegacyOAuthToken(SQLModel, table=True):

View File

@@ -4,7 +4,7 @@ from datetime import datetime
import math import math
from typing import Literal, TYPE_CHECKING, List from typing import Literal, TYPE_CHECKING, List
from app.models.score import Rank, APIMod, GameMode from app.models.score import Rank, APIMod, GameMode, MODE_TO_INT
from .beatmap import Beatmap, BeatmapResp from .beatmap import Beatmap, BeatmapResp
from .beatmapset import Beatmapset, BeatmapsetResp from .beatmapset import Beatmapset, BeatmapsetResp
@@ -84,6 +84,7 @@ class ScoreResp(ScoreBase):
legacy_total_score: int = 0 # FIXME legacy_total_score: int = 0 # FIXME
processed: bool = False # solo_score processed: bool = False # solo_score
weight: float = 0.0 weight: float = 0.0
ruleset_id: int | None
beatmap: BeatmapResp | None = None beatmap: BeatmapResp | None = None
beatmapset: BeatmapsetResp | None = None beatmapset: BeatmapsetResp | None = None
# FIXME: user: APIUser | None = None # FIXME: user: APIUser | None = None
@@ -96,6 +97,7 @@ class ScoreResp(ScoreBase):
s.beatmapset = BeatmapsetResp.from_db(score.beatmap.beatmapset) s.beatmapset = BeatmapsetResp.from_db(score.beatmap.beatmapset)
s.is_perfect_combo = s.max_combo == s.beatmap.max_combo s.is_perfect_combo = s.max_combo == s.beatmap.max_combo
s.legacy_perfect = s.max_combo == s.beatmap.max_combo s.legacy_perfect = s.max_combo == s.beatmap.max_combo
s.ruleset_id=MODE_TO_INT[score.ruleset_id]
if score.best_id: if score.best_id:
# https://osu.ppy.sh/wiki/Performance_points/Weighting_system # https://osu.ppy.sh/wiki/Performance_points/Weighting_system
s.weight = math.pow(0.95, score.best_id) s.weight = math.pow(0.95, score.best_id)

View File

@@ -3,6 +3,7 @@ from datetime import datetime
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from sqlalchemy import Column, DateTime from sqlalchemy import Column, DateTime
from sqlalchemy.orm import Mapped
from sqlmodel import Field, Relationship, SQLModel from sqlmodel import Field, Relationship, SQLModel
if TYPE_CHECKING: if TYPE_CHECKING:
@@ -20,7 +21,7 @@ class Team(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
members: list["TeamMember"] = Relationship(back_populates="team") members: Mapped[list["TeamMember"]] = Relationship(back_populates="team")
class TeamMember(SQLModel, table=True): class TeamMember(SQLModel, table=True):
@@ -33,5 +34,5 @@ class TeamMember(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="team_membership") user: Mapped["User"] = Relationship(back_populates="team_membership")
team: "Team" = Relationship(back_populates="members") team: Mapped["Team"] = Relationship(back_populates="members")

View File

@@ -10,6 +10,7 @@ from .team import TeamMember
from sqlalchemy import DECIMAL, JSON, Column, Date, DateTime, Text from sqlalchemy import DECIMAL, JSON, Column, Date, DateTime, Text
from sqlalchemy.dialects.mysql import VARCHAR from sqlalchemy.dialects.mysql import VARCHAR
from sqlalchemy.orm import Mapped
from sqlmodel import BigInteger, Field, Relationship, SQLModel from sqlmodel import BigInteger, Field, Relationship, SQLModel
@@ -69,31 +70,31 @@ class User(SQLModel, table=True):
return datetime.fromtimestamp(latest_activity) if latest_activity > 0 else None return datetime.fromtimestamp(latest_activity) if latest_activity > 0 else None
# 关联关系 # 关联关系
lazer_profile: Optional["LazerUserProfile"] = Relationship(back_populates="user") lazer_profile: Mapped[Optional["LazerUserProfile"]] = Relationship(back_populates="user")
lazer_statistics: list["LazerUserStatistics"] = Relationship(back_populates="user") lazer_statistics: Mapped[list["LazerUserStatistics"]] = Relationship(back_populates="user")
lazer_counts: Optional["LazerUserCounts"] = Relationship(back_populates="user") lazer_counts: Mapped[Optional["LazerUserCounts"]] = Relationship(back_populates="user")
lazer_achievements: list["LazerUserAchievement"] = Relationship( lazer_achievements: Mapped[list["LazerUserAchievement"]] = Relationship(
back_populates="user" back_populates="user"
) )
lazer_profile_sections: list["LazerUserProfileSections"] = Relationship( lazer_profile_sections: Mapped[list["LazerUserProfileSections"]] = Relationship(
back_populates="user" back_populates="user"
) )
statistics: list[LegacyUserStatistics] = Relationship(back_populates="user") statistics: list["LegacyUserStatistics"] = Relationship(back_populates="user")
team_membership: list[TeamMember] = Relationship(back_populates="user") team_membership: Mapped[list["TeamMember"]] = Relationship(back_populates="user")
daily_challenge_stats: Optional["DailyChallengeStats"] = Relationship( daily_challenge_stats: Mapped[Optional["DailyChallengeStats"]] = Relationship(
back_populates="user" back_populates="user"
) )
rank_history: list["RankHistory"] = Relationship(back_populates="user") rank_history: Mapped[list["RankHistory"]] = Relationship(back_populates="user")
avatar: Optional["UserAvatar"] = Relationship(back_populates="user") avatar: Mapped[Optional["UserAvatar"]] = Relationship(back_populates="user")
active_banners: list["LazerUserBanners"] = Relationship(back_populates="user") active_banners: Mapped[list["LazerUserBanners"]] = Relationship(back_populates="user")
lazer_badges: list["LazerUserBadge"] = Relationship(back_populates="user") lazer_badges: Mapped[list["LazerUserBadge"]] = Relationship(back_populates="user")
lazer_monthly_playcounts: list["LazerUserMonthlyPlaycounts"] = Relationship( lazer_monthly_playcounts: Mapped[list["LazerUserMonthlyPlaycounts"]] = Relationship(
back_populates="user" back_populates="user"
) )
lazer_previous_usernames: list["LazerUserPreviousUsername"] = Relationship( lazer_previous_usernames: Mapped[list["LazerUserPreviousUsername"]] = Relationship(
back_populates="user" back_populates="user"
) )
lazer_replays_watched: list["LazerUserReplaysWatched"] = Relationship( lazer_replays_watched: Mapped[list["LazerUserReplaysWatched"]] = Relationship(
back_populates="user" back_populates="user"
) )
@@ -154,7 +155,7 @@ class LazerUserProfile(SQLModel, table=True):
) )
# 关联关系 # 关联关系
user: "User" = Relationship(back_populates="lazer_profile") user: Mapped["User"] = Relationship(back_populates="lazer_profile")
class LazerUserProfileSections(SQLModel, table=True): class LazerUserProfileSections(SQLModel, table=True):
@@ -172,7 +173,7 @@ class LazerUserProfileSections(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="lazer_profile_sections") user: Mapped["User"] = Relationship(back_populates="lazer_profile_sections")
class LazerUserCountry(SQLModel, table=True): class LazerUserCountry(SQLModel, table=True):
@@ -237,7 +238,7 @@ class LazerUserCounts(SQLModel, table=True):
) )
# 关联关系 # 关联关系
user: "User" = Relationship(back_populates="lazer_counts") user: Mapped["User"] = Relationship(back_populates="lazer_counts")
class LazerUserStatistics(SQLModel, table=True): class LazerUserStatistics(SQLModel, table=True):
@@ -297,7 +298,7 @@ class LazerUserStatistics(SQLModel, table=True):
) )
# 关联关系 # 关联关系
user: "User" = Relationship(back_populates="lazer_statistics") user: Mapped["User"] = Relationship(back_populates="lazer_statistics")
class LazerUserBanners(SQLModel, table=True): class LazerUserBanners(SQLModel, table=True):
@@ -310,7 +311,7 @@ class LazerUserBanners(SQLModel, table=True):
is_active: bool | None = Field(default=None) is_active: bool | None = Field(default=None)
# 修正user关系的back_populates值 # 修正user关系的back_populates值
user: "User" = Relationship(back_populates="active_banners") user: Mapped["User"] = Relationship(back_populates="active_banners")
class LazerUserAchievement(SQLModel, table=True): class LazerUserAchievement(SQLModel, table=True):
@@ -323,7 +324,7 @@ class LazerUserAchievement(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="lazer_achievements") user: Mapped["User"] = Relationship(back_populates="lazer_achievements")
class LazerUserBadge(SQLModel, table=True): class LazerUserBadge(SQLModel, table=True):
@@ -344,7 +345,7 @@ class LazerUserBadge(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="lazer_badges") user: Mapped["User"] = Relationship(back_populates="lazer_badges")
class LazerUserMonthlyPlaycounts(SQLModel, table=True): class LazerUserMonthlyPlaycounts(SQLModel, table=True):
@@ -362,7 +363,7 @@ class LazerUserMonthlyPlaycounts(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="lazer_monthly_playcounts") user: Mapped["User"] = Relationship(back_populates="lazer_monthly_playcounts")
class LazerUserPreviousUsername(SQLModel, table=True): class LazerUserPreviousUsername(SQLModel, table=True):
@@ -380,7 +381,7 @@ class LazerUserPreviousUsername(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="lazer_previous_usernames") user: Mapped["User"] = Relationship(back_populates="lazer_previous_usernames")
class LazerUserReplaysWatched(SQLModel, table=True): class LazerUserReplaysWatched(SQLModel, table=True):
@@ -398,7 +399,7 @@ class LazerUserReplaysWatched(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="lazer_replays_watched") user: Mapped["User"] = Relationship(back_populates="lazer_replays_watched")
# 类型转换用的 UserAchievement不是 SQLAlchemy 模型) # 类型转换用的 UserAchievement不是 SQLAlchemy 模型)
@@ -426,7 +427,7 @@ class DailyChallengeStats(SQLModel, table=True):
weekly_streak_best: int = Field(default=0) weekly_streak_best: int = Field(default=0)
weekly_streak_current: int = Field(default=0) weekly_streak_current: int = Field(default=0)
user: "User" = Relationship(back_populates="daily_challenge_stats") user: Mapped["User"] = Relationship(back_populates="daily_challenge_stats")
class RankHistory(SQLModel, table=True): class RankHistory(SQLModel, table=True):
@@ -440,7 +441,7 @@ class RankHistory(SQLModel, table=True):
default_factory=datetime.utcnow, sa_column=Column(DateTime) default_factory=datetime.utcnow, sa_column=Column(DateTime)
) )
user: "User" = Relationship(back_populates="rank_history") user: Mapped["User"] = Relationship(back_populates="rank_history")
class UserAvatar(SQLModel, table=True): class UserAvatar(SQLModel, table=True):
@@ -458,4 +459,4 @@ class UserAvatar(SQLModel, table=True):
r2_original_url: str | None = Field(default=None, max_length=500) r2_original_url: str | None = Field(default=None, max_length=500)
r2_game_url: str | None = Field(default=None, max_length=500) r2_game_url: str | None = Field(default=None, max_length=500)
user: "User" = Relationship(back_populates="avatar") user: Mapped["User"] = Relationship(back_populates="avatar")

View File

@@ -12,6 +12,11 @@ from app.auth import get_password_hash
from app.database import ( from app.database import (
User, User,
) )
from app.database.beatmapset import Beatmapset, BeatmapsetResp
from app.database.beatmap import Beatmap, BeatmapResp
from app.database.score import Score
from app.models.score import GameMode, Rank, APIMod
from app.models.beatmap import BeatmapRankStatus, Genre, Language
from app.dependencies.database import create_tables, engine from app.dependencies.database import create_tables, engine
from sqlmodel import select from sqlmodel import select
@@ -72,10 +77,138 @@ async def create_sample_user():
return user return user
async def create_sample_beatmap_data(user: User):
"""创建示例谱面数据"""
async with AsyncSession(engine) as session:
async with session.begin():
# 检查谱面集是否已存在
statement = select(Beatmapset).where(Beatmapset.id == 1)
result = await session.execute(statement)
existing_beatmapset = result.scalars().first()
if existing_beatmapset:
print("示例谱面集已存在,跳过创建")
return existing_beatmapset
# 创建谱面集
beatmapset = Beatmapset(
id=1,
artist="Example Artist",
artist_unicode="Example Artist",
covers=None,
creator="Googujiang",
favourite_count=0,
hype_current=0,
hype_required=0,
nsfw=False,
play_count=0,
preview_url="",
source="",
spotlight=False,
title="Example Song",
title_unicode="Example Song",
user_id=user.id,
video=False,
availability_info=None,
download_disabled=False,
bpm=180.0,
can_be_hyped=False,
discussion_locked=False,
last_updated=datetime.now(),
ranked_date=datetime.now(),
storyboard=False,
submitted_date=datetime.now(),
current_nominations=[],
beatmap_status=BeatmapRankStatus.RANKED,
beatmap_genre=Genre.ANY, # 使用整数表示Genre枚举
beatmap_language=Language.ANY, # 使用整数表示Language枚举
nominations_required=0,
nominations_current=0,
pack_tags=[],
ratings=[],
)
session.add(beatmapset)
await session.flush()
# 创建谱面
beatmap = Beatmap(
id=1,
url="",
mode=GameMode.OSU,
beatmapset_id=1,
difficulty_rating=5.5,
beatmap_status=BeatmapRankStatus.RANKED,
total_length=195,
user_id=user.id,
version="Example Difficulty",
checksum="example_checksum",
current_user_playcount=0,
max_combo=1200,
ar=9.0,
cs=4.0,
drain=5.0,
accuracy=8.0,
bpm=180.0,
count_circles=1000,
count_sliders=200,
count_spinners=1,
deleted_at=None,
hit_length=180,
last_updated=datetime.now(),
passcount=10,
playcount=50,
)
session.add(beatmap)
await session.flush()
# 创建成绩
score = Score(
id=1,
accuracy=0.9876,
map_md5="example_checksum",
best_id=1,
build_id=None,
classic_total_score=1234567,
ended_at=datetime.now(),
has_replay=True,
max_combo=1100,
mods=[APIMod(acronym="HD"), APIMod(acronym="DT")],
passed=True,
playlist_item_id=None,
pp=250.5,
preserve=True,
rank=Rank.S,
room_id=None,
ruleset_id=GameMode.OSU,
started_at=datetime.now(),
total_score=1234567,
type="solo_score",
position=None,
beatmap_id=1,
user_id=user.id,
n300=950,
n100=30,
n50=20,
nmiss=5,
ngeki=150,
nkatu=50,
nlarge_tick_miss=None,
nslider_tail_hit=None,
)
session.add(score)
await session.commit()
await session.refresh(beatmapset)
print(f"成功创建示例谱面集: {beatmapset.title} (ID: {beatmapset.id})")
print(f"成功创建示例谱面: {beatmap.version} (ID: {beatmap.id})")
print(f"成功创建示例成绩: ID {score.id}")
return beatmapset
async def main(): async def main():
print("开始创建示例数据...") print("开始创建示例数据...")
await create_tables() await create_tables()
user = await create_sample_user() user = await create_sample_user()
await create_sample_beatmap_data(user)
print("示例数据创建完成!") print("示例数据创建完成!")
print(f"用户名: {user.name}") print(f"用户名: {user.name}")
print("密码: password123") print("密码: password123")

View File

@@ -1,17 +1,54 @@
fastapi~=0.116.1
uvicorn[standard]==0.24.0
sqlalchemy~=2.0.41
alembic==1.12.1
pymysql~=1.1.1
cryptography==41.0.7
redis~=6.2.0
python-jose[cryptography]~=3.5.0
passlib[bcrypt]==1.7.4
python-multipart==0.0.6
pydantic[email]~=2.11.7
python-dotenv~=1.1.1
bcrypt~=4.3.0
msgpack~=1.1.1
sqlmodel~=0.0.24
starlette~=0.47.2
aiomysql==0.2.0 aiomysql==0.2.0
alembic==1.16.4
annotated-types==0.7.0
anyio==4.9.0
async-timeout==5.0.1
bcrypt==4.3.0
cffi==1.17.1
cfgv==3.4.0
click==8.2.1
colorama==0.4.6
cryptography==45.0.5
distlib==0.4.0
dnspython==2.7.0
ecdsa==0.19.1
email-validator==2.2.0
fastapi==0.116.1
filelock==3.18.0
greenlet==3.2.3
h11==0.16.0
httptools==0.6.4
identify==2.6.12
idna==3.10
mako==1.3.10
markupsafe==3.0.2
msgpack==1.1.1
msgpack-types==0.5.0
nodeenv==1.9.1
passlib==1.7.4
platformdirs==4.3.8
pre-commit==4.2.0
pyasn1==0.6.1
pycparser==2.22
pydantic==2.11.7
pydantic-core==2.33.2
pymysql==1.1.1
python-dotenv==1.1.1
python-jose==3.5.0
python-multipart==0.0.20
pyyaml==6.0.2
redis==6.2.0
rsa==4.9.1
ruff==0.12.4
six==1.17.0
sniffio==1.3.1
sqlalchemy==2.0.41
sqlmodel==0.0.24
starlette==0.47.2
typing-extensions==4.14.1
typing-inspection==0.4.1
uvicorn==0.35.0
uvloop==0.21.0
virtualenv==20.32.0
watchfiles==1.1.0
websockets==15.0.1

View File

@@ -105,6 +105,25 @@ def get_current_user(access_token: str, ruleset: str = "osu"):
return None return None
def get_beatmap_scores(access_token: str, beatmap_id: int):
"""获取谱面成绩数据"""
url = f"{API_URL}/api/v2/beatmaps/{beatmap_id}/scores"
headers = {"Authorization": f"Bearer {access_token}"}
try:
response = requests.get(url, headers=headers)
if response.status_code == 200:
print(f"✅ 成功获取谱面 {beatmap_id} 的成绩数据")
return response.json()
else:
print(f"❌ 获取谱面成绩失败: {response.status_code}")
print(f"响应内容: {response.text}")
return None
except Exception as e:
print(f"❌ 获取谱面成绩请求失败: {e}")
return None
def main(): def main():
"""主测试函数""" """主测试函数"""
print("=== osu! API 模拟服务器测试 ===\n") print("=== osu! API 模拟服务器测试 ===\n")
@@ -149,14 +168,26 @@ def main():
print(f"游戏次数: {user_data['statistics']['play_count']}") print(f"游戏次数: {user_data['statistics']['play_count']}")
print(f"命中精度: {user_data['statistics']['hit_accuracy']:.2f}%") print(f"命中精度: {user_data['statistics']['hit_accuracy']:.2f}%")
# 5. 测试令牌刷新 # 5. 测试获取谱面成绩
print("\n5. 测试令牌刷新...") print("\n5. 测试获取谱面成绩...")
scores_data = get_beatmap_scores(token_data["access_token"], 1)
if scores_data:
print(f"谱面成绩总数: {len(scores_data['scores'])}")
if scores_data['userScore']:
print("用户在该谱面有成绩记录")
print(f"用户成绩 ID: {scores_data['userScore']['id']}")
print(f"用户成绩分数: {scores_data['userScore']['total_score']}")
else:
print("用户在该谱面没有成绩记录")
# 6. 测试令牌刷新
print("\n6. 测试令牌刷新...")
new_token_data = refresh_token(token_data["refresh_token"]) new_token_data = refresh_token(token_data["refresh_token"])
if new_token_data: if new_token_data:
print(f"新访问令牌: {new_token_data['access_token']}") print(f"新访问令牌: {new_token_data['access_token']}")
# 使用新令牌获取用户数据 # 使用新令牌获取用户数据
print("\n6. 使用新令牌获取用户数据...") print("\n7. 使用新令牌获取用户数据...")
user_data = get_current_user(new_token_data["access_token"]) user_data = get_current_user(new_token_data["access_token"])
if user_data: if user_data:
print(f"✅ 新令牌有效,用户: {user_data['username']}") print(f"✅ 新令牌有效,用户: {user_data['username']}")