From 3ef6397428c188efcf43ee79ccadcfe4fdc6f49f Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Fri, 25 Jul 2025 12:34:44 +0800 Subject: [PATCH 1/6] =?UTF-8?q?build(deps):=20=E6=9B=B4=E6=96=B0=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E4=BE=9D=E8=B5=96=E5=B9=B6=E5=8D=87=E7=BA=A7=20Python?= =?UTF-8?q?=20SDK=20-=20=E5=B0=86uv.lock=E4=B8=8Erequirements.txt=E5=90=8C?= =?UTF-8?q?=E6=AD=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .idea/misc.xml | 2 +- .idea/osu_lazer_api.iml | 2 +- requirements.txt | 20 ++++++++++++-------- 3 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.idea/misc.xml b/.idea/misc.xml index e5787b9..d7ee3e6 100644 --- a/.idea/misc.xml +++ b/.idea/misc.xml @@ -3,7 +3,7 @@ - + diff --git a/.idea/osu_lazer_api.iml b/.idea/osu_lazer_api.iml index e2e520d..5e25259 100644 --- a/.idea/osu_lazer_api.iml +++ b/.idea/osu_lazer_api.iml @@ -4,7 +4,7 @@ - + diff --git a/requirements.txt b/requirements.txt index 2aa8b01..cc06c1a 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,13 +1,17 @@ -fastapi==0.104.1 +fastapi~=0.116.1 uvicorn[standard]==0.24.0 -sqlalchemy==2.0.23 +sqlalchemy~=2.0.41 alembic==1.12.1 -pymysql==1.1.0 +pymysql~=1.1.1 cryptography==41.0.7 -redis==5.0.1 -python-jose[cryptography]==3.3.0 +redis~=6.2.0 +python-jose[cryptography]~=3.5.0 passlib[bcrypt]==1.7.4 python-multipart==0.0.6 -pydantic[email]==2.5.0 -python-dotenv==1.0.0 -bcrypt==4.1.2 +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 \ No newline at end of file From 86e815b4e248d6c82b48d7692c89ebd6d40ec240 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Fri, 25 Jul 2025 12:34:56 +0800 Subject: [PATCH 2/6] =?UTF-8?q?feat(database):=20=E6=96=B0=E5=A2=9E=20Scor?= =?UTF-8?q?e=20=E6=95=B0=E6=8D=AE=E5=BA=93=E6=A8=A1=E5=9E=8B=E5=B9=B6?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20User=20=E5=85=B3=E7=B3=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 创建 Score 类作为成绩数据库模型,对应 osu! API 中的 Score 对象 - 在 User 模型中添加与 Score 的关系- 优化了数据库表结构,增加了索引和字段约束 --- app/database/score.py | 41 +++++++++++++++++++++++++++++++++++++++++ app/database/user.py | 3 ++- 2 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 app/database/score.py diff --git a/app/database/score.py b/app/database/score.py new file mode 100644 index 0000000..3919ce5 --- /dev/null +++ b/app/database/score.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from datetime import datetime +from . import User +from sqlalchemy import Column, DateTime +from sqlmodel import Field, Relationship, SQLModel + +class Score(SQLModel, table=True): + """ + 成绩数据库模型,对应osu! API中的Score对象 + 参考: https://osu.ppy.sh/docs/index.html#score + 数据库表结构参考: migrations/base.sql + """ + __tablename__ = "scores" + + # 基本字段 + id: int = Field(primary_key=True) + map_md5: str = Field(max_length=32, index=True) + score: int + pp: float + acc: float + max_combo: int + mods: int = Field(index=True) + n300: int + n100: int + n50: int + nmiss: int + ngeki: int + nkatu: int + grade: str = Field(default="N", max_length=2) + status: int = Field(index=True) + mode: int = Field(index=True) + play_time: datetime = Field(sa_column=Column(DateTime, index=True)) + time_elapsed: int + client_flags: int + userid: int = Field(index=True) + perfect: bool + online_checksum: str = Field(max_length=32, index=True) + + # 关联关系 + user: "User" = Relationship(back_populates="scores") diff --git a/app/database/user.py b/app/database/user.py index a25e3e1..6534758 100644 --- a/app/database/user.py +++ b/app/database/user.py @@ -1,4 +1,6 @@ # ruff: noqa: I002 +from __future__ import annotations + from dataclasses import dataclass from datetime import datetime from typing import Optional @@ -95,7 +97,6 @@ class User(SQLModel, table=True): back_populates="user" ) - # ============================================ # Lazer API 专用表模型 # ============================================ From 94d53b2a205a3c623459cd156e273b660a8424a9 Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Fri, 25 Jul 2025 14:18:02 +0800 Subject: [PATCH 3/6] =?UTF-8?q?feat(models):=20=E6=B7=BB=E5=8A=A0=20Score?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E5=AE=9A=E4=B9=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在 score.py 中定义了 Score 类,用于表示游戏分数信息 --- app/models/score.py | 62 ++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/app/models/score.py b/app/models/score.py index 29f1f18..653c75a 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,6 +1,8 @@ from enum import Enum, IntEnum -from typing import Any +from typing import Any, Optional from pydantic import BaseModel +from datetime import datetime +from .user import User class GameMode(str, Enum): OSU = "osu" @@ -38,3 +40,61 @@ class HitResult(IntEnum): COMBO_BREAK = 16 # [Order(16)] LEGACY_COMBO_INCREASE = 99 # [Order(99)] @deprecated + +class Score(BaseModel): + # 基本信息 + id: int + user_id: int + mode: GameMode + mode_int: int + beatmap_id: int + best_id: int + build_id: int + + # 分数和准确度 + score: int + accuracy: float + mods: list[APIMod] + total_score: int + + # 命中统计 + statistics: dict[HitResult, int] + maximum_statistics: dict[HitResult, int] + + # 排名相关 + rank: str # 等级 (SS, S, A, B, C, D, F) + ranked: bool + rank_country: Optional[int] = None + rank_global: Optional[int] = None + + # PP值 + pp: Optional[float] = None + pp_exp: Optional[float] = None + + # 连击 + maximum_combo: int + combo: int + + # 游戏设置 + is_perfect_combo: bool + passed: bool # 是否通过谱面 + + # 时间信息 + started_at: datetime + ended_at: datetime + + # 最佳成绩相关 + best_id: Optional[int] = None + is_best: bool = False + + # 额外信息 + has_replay: bool # 是否有回放 + preserve: bool # 是否保留 + processed: bool # 是否已处理 + + # Legacy字段 + legacy_score_id: Optional[int] = None + legacy_total_score: int + legacy_perfect: bool + + # mp字段 \ No newline at end of file From 9a19027cc17ad219fcffb5beb040f54975aac5ee Mon Sep 17 00:00:00 2001 From: jimmy-sketch Date: Fri, 25 Jul 2025 20:02:21 +0800 Subject: [PATCH 4/6] =?UTF-8?q?feat(models):=20=E4=B8=BAscores=E7=B1=BB?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0mp=E7=9B=B8=E5=85=B3=E7=9A=84=E4=B8=A4?= =?UTF-8?q?=E4=B8=AA=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/models/score.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/models/score.py b/app/models/score.py index 653c75a..81db1f4 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -97,4 +97,6 @@ class Score(BaseModel): legacy_total_score: int legacy_perfect: bool - # mp字段 \ No newline at end of file + # mp字段 + playlist_item_id: Optional[int] = None + room_id: Optional[int] = None \ No newline at end of file From 92f8a3a416dd8629be0b3e335245a4fa36f8f5eb Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 25 Jul 2025 21:28:43 +0800 Subject: [PATCH 5/6] fix(score): rewrite DB model & API model --- app/database/score.py | 113 ++++++++++++++++++++++++++++++++---------- app/models/score.py | 76 +++++----------------------- 2 files changed, 101 insertions(+), 88 deletions(-) diff --git a/app/database/score.py b/app/database/score.py index 3919ce5..e926202 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -1,41 +1,104 @@ -from __future__ import annotations +# ruff: noqa: I002 from datetime import datetime -from . import User +import math +from typing import Literal + +from app.models.score import Rank + +from .beatmap import Beatmap +from .beatmapset import Beatmapset +from .user import User + +from pydantic import BaseModel from sqlalchemy import Column, DateTime -from sqlmodel import Field, Relationship, SQLModel +from sqlmodel import BigInteger, Field, Relationship, SQLModel -class Score(SQLModel, table=True): - """ - 成绩数据库模型,对应osu! API中的Score对象 - 参考: https://osu.ppy.sh/docs/index.html#score - 数据库表结构参考: migrations/base.sql - """ - __tablename__ = "scores" +class ScoreBase(SQLModel): # 基本字段 - id: int = Field(primary_key=True) + accuracy: float + beatmap_id: int = Field(index=True, foreign_key="_beatmap.id") map_md5: str = Field(max_length=32, index=True) - score: int - pp: float - acc: float + best_id: int | None = Field(default=None) + build_id: int | None = Field(default=None) + classic_total_score: int | None = Field( + default=0, sa_column=Column(BigInteger) + ) # solo_score + ended_at: datetime = Field(sa_column=Column(DateTime)) + has_replay: bool max_combo: int mods: int = Field(index=True) - n300: int + passed: bool + playlist_item_id: int | None = Field(default=None) # multiplayer + pp: float + preserve: bool = Field(default=True) + rank: Rank + room_id: int | None = Field(default=None) # multiplayer + ruleset_id: Literal[0, 1, 2, 3] = Field(index=True) + started_at: datetime = Field(sa_column=Column(DateTime)) + total_score: int = Field(default=0, sa_column=Column(BigInteger)) + type: str + user_id: int = Field(foreign_key="user.id", index=True) + # ScoreStatistics + n300: int = Field(default=0, exclude=True) n100: int n50: int nmiss: int ngeki: int nkatu: int - grade: str = Field(default="N", max_length=2) - status: int = Field(index=True) - mode: int = Field(index=True) - play_time: datetime = Field(sa_column=Column(DateTime, index=True)) - time_elapsed: int - client_flags: int - userid: int = Field(index=True) - perfect: bool - online_checksum: str = Field(max_length=32, index=True) + nlarge_tick_miss: int | None = Field(default=None, exclude=True) + nslider_tail_hit: int | None = Field(default=None, exclude=True) - # 关联关系 + # optional + beatmap: "Beatmap" = Relationship(back_populates="scores") + beatmapset: "Beatmapset" = Relationship(back_populates="scores") + # TODO: current_user_attributes + position: int | None = Field(default=None) # multiplayer user: "User" = Relationship(back_populates="scores") + + +class ScoreStatistics(BaseModel): + count_miss: int + count_50: int + count_100: int + count_300: int + count_geki: int + count_katu: int + count_large_tick_miss: int | None = None + count_slider_tail_hit: int | None = None + + +class Score(ScoreBase, table=True): + __tablename__ = "scores" # pyright: ignore[reportAssignmentType] + id: int = Field(primary_key=True) + + +class ScoreResp(ScoreBase): + id: int + is_perfect_combo: bool = False + legacy_perfect: bool = False + legacy_total_score: int = 0 # FIXME + processed: bool = False # solo_score + weight: float = 0.0 + statistics: ScoreStatistics | None = None + + @classmethod + def from_db(cls, score: Score) -> "ScoreResp": + s = cls.model_validate(score) + s.is_perfect_combo = s.max_combo == s.beatmap.max_combo + s.legacy_perfect = s.max_combo == s.beatmap.max_combo + if score.best_id: + # https://osu.ppy.sh/wiki/Performance_points/Weighting_system + s.weight = math.pow(0.95, score.best_id) + s.statistics = ScoreStatistics( + count_miss=score.nmiss, + count_50=score.n50, + count_100=score.n100, + count_300=score.n300, + count_geki=score.ngeki, + count_katu=score.nkatu, + count_large_tick_miss=score.nlarge_tick_miss, + count_slider_tail_hit=score.nslider_tail_hit, + ) + return s diff --git a/app/models/score.py b/app/models/score.py index 0cae065..76aa0ac 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -1,11 +1,9 @@ from __future__ import annotations from enum import Enum, IntEnum -from typing import Any, Optional +from typing import Any from pydantic import BaseModel -from datetime import datetime -from .user import User class GameMode(str, Enum): @@ -23,6 +21,18 @@ MODE_TO_INT = { } +class Rank(str, Enum): + X = "ss" + XH = "ssh" + S = "s" + SH = "sh" + A = "a" + B = "b" + C = "c" + D = "d" + F = "f" + + class APIMod(BaseModel): acronym: str settings: dict[str, Any] = {} @@ -54,63 +64,3 @@ class HitResult(IntEnum): COMBO_BREAK = 16 # [Order(16)] LEGACY_COMBO_INCREASE = 99 # [Order(99)] @deprecated - -class Score(BaseModel): - # 基本信息 - id: int - user_id: int - mode: GameMode - mode_int: int - beatmap_id: int - best_id: int - build_id: int - - # 分数和准确度 - score: int - accuracy: float - mods: list[APIMod] - total_score: int - - # 命中统计 - statistics: dict[HitResult, int] - maximum_statistics: dict[HitResult, int] - - # 排名相关 - rank: str # 等级 (SS, S, A, B, C, D, F) - ranked: bool - rank_country: Optional[int] = None - rank_global: Optional[int] = None - - # PP值 - pp: Optional[float] = None - pp_exp: Optional[float] = None - - # 连击 - maximum_combo: int - combo: int - - # 游戏设置 - is_perfect_combo: bool - passed: bool # 是否通过谱面 - - # 时间信息 - started_at: datetime - ended_at: datetime - - # 最佳成绩相关 - best_id: Optional[int] = None - is_best: bool = False - - # 额外信息 - has_replay: bool # 是否有回放 - preserve: bool # 是否保留 - processed: bool # 是否已处理 - - # Legacy字段 - legacy_score_id: Optional[int] = None - legacy_total_score: int - legacy_perfect: bool - - # mp字段 - playlist_item_id: Optional[int] = None - room_id: Optional[int] = None \ No newline at end of file From 2260d9265e7754d6c035eab5e07e34ee7856e9ce Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 25 Jul 2025 21:38:51 +0800 Subject: [PATCH 6/6] fix(score): move db only fields to DB model --- app/database/score.py | 40 +++++++++++++++++++++++----------------- app/models/score.py | 1 + 2 files changed, 24 insertions(+), 17 deletions(-) diff --git a/app/database/score.py b/app/database/score.py index e926202..f68f4e8 100644 --- a/app/database/score.py +++ b/app/database/score.py @@ -6,9 +6,8 @@ from typing import Literal from app.models.score import Rank -from .beatmap import Beatmap -from .beatmapset import Beatmapset -from .user import User +from .beatmap import Beatmap, BeatmapResp +from .beatmapset import Beatmapset, BeatmapsetResp from pydantic import BaseModel from sqlalchemy import Column, DateTime @@ -18,7 +17,6 @@ from sqlmodel import BigInteger, Field, Relationship, SQLModel class ScoreBase(SQLModel): # 基本字段 accuracy: float - beatmap_id: int = Field(index=True, foreign_key="_beatmap.id") map_md5: str = Field(max_length=32, index=True) best_id: int | None = Field(default=None) build_id: int | None = Field(default=None) @@ -39,23 +37,10 @@ class ScoreBase(SQLModel): started_at: datetime = Field(sa_column=Column(DateTime)) total_score: int = Field(default=0, sa_column=Column(BigInteger)) type: str - user_id: int = Field(foreign_key="user.id", index=True) - # ScoreStatistics - n300: int = Field(default=0, exclude=True) - n100: int - n50: int - nmiss: int - ngeki: int - nkatu: int - nlarge_tick_miss: int | None = Field(default=None, exclude=True) - nslider_tail_hit: int | None = Field(default=None, exclude=True) # optional - beatmap: "Beatmap" = Relationship(back_populates="scores") - beatmapset: "Beatmapset" = Relationship(back_populates="scores") # TODO: current_user_attributes position: int | None = Field(default=None) # multiplayer - user: "User" = Relationship(back_populates="scores") class ScoreStatistics(BaseModel): @@ -72,6 +57,22 @@ class ScoreStatistics(BaseModel): class Score(ScoreBase, table=True): __tablename__ = "scores" # pyright: ignore[reportAssignmentType] id: int = Field(primary_key=True) + beatmap_id: int = Field(index=True, foreign_key="beatmap.id") + user_id: int = Field(foreign_key="user.id", index=True) + # ScoreStatistics + n300: int = Field(exclude=True) + n100: int = Field(exclude=True) + n50: int = Field(exclude=True) + nmiss: int = Field(exclude=True) + ngeki: int = Field(exclude=True) + nkatu: int = Field(exclude=True) + nlarge_tick_miss: int | None = Field(default=None, exclude=True) + nslider_tail_hit: int | None = Field(default=None, exclude=True) + + # optional + beatmap: "Beatmap" = Relationship(back_populates="scores") + beatmapset: "Beatmapset" = Relationship(back_populates="scores") + # FIXME: user: "User" = Relationship(back_populates="scores") class ScoreResp(ScoreBase): @@ -81,11 +82,16 @@ class ScoreResp(ScoreBase): legacy_total_score: int = 0 # FIXME processed: bool = False # solo_score weight: float = 0.0 + beatmap: BeatmapResp | None = None + beatmapset: BeatmapsetResp | None = None + # FIXME: user: APIUser | None = None statistics: ScoreStatistics | None = None @classmethod def from_db(cls, score: Score) -> "ScoreResp": s = cls.model_validate(score) + s.beatmap = BeatmapResp.from_db(score.beatmap) + 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 if score.best_id: diff --git a/app/models/score.py b/app/models/score.py index 76aa0ac..416b68d 100644 --- a/app/models/score.py +++ b/app/models/score.py @@ -19,6 +19,7 @@ MODE_TO_INT = { GameMode.FRUITS: 2, GameMode.MANIA: 3, } +INT_TO_MODE = {v: k for k, v in MODE_TO_INT.items()} class Rank(str, Enum):