diff --git a/Dockerfile b/Dockerfile index 17a33c9..f301c1d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,10 +13,11 @@ RUN apt-get update && apt-get install -y \ # 复制依赖文件 COPY uv.lock . COPY pyproject.toml . +COPY requirements.txt . # 安装Python依赖 -RUN uv sync --locked -RUN pip install uvicorn +RUN uv sync +RUN pip install -r requirements.txt # 复制应用代码 COPY . . diff --git a/app/config.py b/app/config.py index 6f3aa25..aab79b8 100644 --- a/app/config.py +++ b/app/config.py @@ -10,9 +10,9 @@ load_dotenv() class Settings: # 数据库设置 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 设置 SECRET_KEY: str = os.getenv("SECRET_KEY", "your-secret-key-here") diff --git a/app/database/beatmapset.py b/app/database/beatmapset.py index bd53398..781fa9f 100644 --- a/app/database/beatmapset.py +++ b/app/database/beatmapset.py @@ -87,7 +87,7 @@ class BeatmapsetBase(SQLModel): # Beatmapset artist: 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 favourite_count: int nsfw: bool = Field(default=False) diff --git a/app/database/legacy.py b/app/database/legacy.py index 978bbc5..245e267 100644 --- a/app/database/legacy.py +++ b/app/database/legacy.py @@ -4,6 +4,7 @@ from typing import TYPE_CHECKING from sqlalchemy import JSON, Column, DateTime from sqlmodel import Field, Relationship, SQLModel +from sqlalchemy.orm import Mapped if TYPE_CHECKING: 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): diff --git a/app/database/score.py b/app/database/score.py index 477695c..34eb327 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -4,7 +4,7 @@ from datetime import datetime import math 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 .beatmapset import Beatmapset, BeatmapsetResp @@ -84,6 +84,7 @@ class ScoreResp(ScoreBase): legacy_total_score: int = 0 # FIXME processed: bool = False # solo_score weight: float = 0.0 + ruleset_id: int | None beatmap: BeatmapResp | None = None beatmapset: BeatmapsetResp | None = None # FIXME: user: APIUser | None = None @@ -96,6 +97,7 @@ class ScoreResp(ScoreBase): s.beatmapset = BeatmapsetResp.from_db(score.beatmap.beatmapset) s.is_perfect_combo = 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: # https://osu.ppy.sh/wiki/Performance_points/Weighting_system s.weight = math.pow(0.95, score.best_id) diff --git a/app/database/team.py b/app/database/team.py index 0c1ea07..e7e277d 100644 --- a/app/database/team.py +++ b/app/database/team.py @@ -3,6 +3,7 @@ from datetime import datetime from typing import TYPE_CHECKING from sqlalchemy import Column, DateTime +from sqlalchemy.orm import Mapped from sqlmodel import Field, Relationship, SQLModel if TYPE_CHECKING: @@ -20,7 +21,7 @@ class Team(SQLModel, table=True): 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): @@ -33,5 +34,5 @@ class TeamMember(SQLModel, table=True): default_factory=datetime.utcnow, sa_column=Column(DateTime) ) - user: "User" = Relationship(back_populates="team_membership") - team: "Team" = Relationship(back_populates="members") + user: Mapped["User"] = Relationship(back_populates="team_membership") + team: Mapped["Team"] = Relationship(back_populates="members") \ No newline at end of file diff --git a/app/database/user.py b/app/database/user.py index d10e7dc..160f27b 100644 --- a/app/database/user.py +++ b/app/database/user.py @@ -10,6 +10,7 @@ from .team import TeamMember from sqlalchemy import DECIMAL, JSON, Column, Date, DateTime, Text from sqlalchemy.dialects.mysql import VARCHAR +from sqlalchemy.orm import Mapped 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 # 关联关系 - lazer_profile: Optional["LazerUserProfile"] = Relationship(back_populates="user") - lazer_statistics: list["LazerUserStatistics"] = Relationship(back_populates="user") - lazer_counts: Optional["LazerUserCounts"] = Relationship(back_populates="user") - lazer_achievements: list["LazerUserAchievement"] = Relationship( + lazer_profile: Mapped[Optional["LazerUserProfile"]] = Relationship(back_populates="user") + lazer_statistics: Mapped[list["LazerUserStatistics"]] = Relationship(back_populates="user") + lazer_counts: Mapped[Optional["LazerUserCounts"]] = Relationship(back_populates="user") + lazer_achievements: Mapped[list["LazerUserAchievement"]] = Relationship( back_populates="user" ) - lazer_profile_sections: list["LazerUserProfileSections"] = Relationship( + lazer_profile_sections: Mapped[list["LazerUserProfileSections"]] = Relationship( back_populates="user" ) - statistics: list[LegacyUserStatistics] = Relationship(back_populates="user") - team_membership: list[TeamMember] = Relationship(back_populates="user") - daily_challenge_stats: Optional["DailyChallengeStats"] = Relationship( + statistics: list["LegacyUserStatistics"] = Relationship(back_populates="user") + team_membership: Mapped[list["TeamMember"]] = Relationship(back_populates="user") + daily_challenge_stats: Mapped[Optional["DailyChallengeStats"]] = Relationship( back_populates="user" ) - rank_history: list["RankHistory"] = Relationship(back_populates="user") - avatar: Optional["UserAvatar"] = Relationship(back_populates="user") - active_banners: list["LazerUserBanners"] = Relationship(back_populates="user") - lazer_badges: list["LazerUserBadge"] = Relationship(back_populates="user") - lazer_monthly_playcounts: list["LazerUserMonthlyPlaycounts"] = Relationship( + rank_history: Mapped[list["RankHistory"]] = Relationship(back_populates="user") + avatar: Mapped[Optional["UserAvatar"]] = Relationship(back_populates="user") + active_banners: Mapped[list["LazerUserBanners"]] = Relationship(back_populates="user") + lazer_badges: Mapped[list["LazerUserBadge"]] = Relationship(back_populates="user") + lazer_monthly_playcounts: Mapped[list["LazerUserMonthlyPlaycounts"]] = Relationship( back_populates="user" ) - lazer_previous_usernames: list["LazerUserPreviousUsername"] = Relationship( + lazer_previous_usernames: Mapped[list["LazerUserPreviousUsername"]] = Relationship( back_populates="user" ) - lazer_replays_watched: list["LazerUserReplaysWatched"] = Relationship( + lazer_replays_watched: Mapped[list["LazerUserReplaysWatched"]] = Relationship( 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): @@ -172,7 +173,7 @@ class LazerUserProfileSections(SQLModel, table=True): 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): @@ -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): @@ -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): @@ -310,7 +311,7 @@ class LazerUserBanners(SQLModel, table=True): is_active: bool | None = Field(default=None) # 修正user关系的back_populates值 - user: "User" = Relationship(back_populates="active_banners") + user: Mapped["User"] = Relationship(back_populates="active_banners") class LazerUserAchievement(SQLModel, table=True): @@ -323,7 +324,7 @@ class LazerUserAchievement(SQLModel, table=True): 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): @@ -344,7 +345,7 @@ class LazerUserBadge(SQLModel, table=True): 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): @@ -362,7 +363,7 @@ class LazerUserMonthlyPlaycounts(SQLModel, table=True): 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): @@ -380,7 +381,7 @@ class LazerUserPreviousUsername(SQLModel, table=True): 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): @@ -398,7 +399,7 @@ class LazerUserReplaysWatched(SQLModel, table=True): 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 模型) @@ -426,7 +427,7 @@ class DailyChallengeStats(SQLModel, table=True): weekly_streak_best: 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): @@ -440,7 +441,7 @@ class RankHistory(SQLModel, table=True): 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): @@ -458,4 +459,4 @@ class UserAvatar(SQLModel, table=True): r2_original_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") diff --git a/create_sample_data.py b/create_sample_data.py index 4e5098e..b91223e 100644 --- a/create_sample_data.py +++ b/create_sample_data.py @@ -12,6 +12,11 @@ from app.auth import get_password_hash from app.database import ( 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 sqlmodel import select @@ -72,10 +77,138 @@ async def create_sample_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(): print("开始创建示例数据...") await create_tables() user = await create_sample_user() + await create_sample_beatmap_data(user) print("示例数据创建完成!") print(f"用户名: {user.name}") print("密码: password123") @@ -83,4 +216,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) + asyncio.run(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 3e2146d..d842a40 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 +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 \ No newline at end of file diff --git a/test_api.py b/test_api.py index b3956e1..5749621 100644 --- a/test_api.py +++ b/test_api.py @@ -105,6 +105,25 @@ def get_current_user(access_token: str, ruleset: str = "osu"): 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(): """主测试函数""" print("=== osu! API 模拟服务器测试 ===\n") @@ -149,14 +168,26 @@ def main(): print(f"游戏次数: {user_data['statistics']['play_count']}") print(f"命中精度: {user_data['statistics']['hit_accuracy']:.2f}%") - # 5. 测试令牌刷新 - print("\n5. 测试令牌刷新...") + # 5. 测试获取谱面成绩 + 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"]) if new_token_data: print(f"新访问令牌: {new_token_data['access_token']}") # 使用新令牌获取用户数据 - print("\n6. 使用新令牌获取用户数据...") + print("\n7. 使用新令牌获取用户数据...") user_data = get_current_user(new_token_data["access_token"]) if user_data: print(f"✅ 新令牌有效,用户: {user_data['username']}") @@ -165,4 +196,4 @@ def main(): if __name__ == "__main__": - main() + main() \ No newline at end of file