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/app/database/score.py b/app/database/score.py
new file mode 100644
index 0000000..f68f4e8
--- /dev/null
+++ b/app/database/score.py
@@ -0,0 +1,110 @@
+# ruff: noqa: I002
+
+from datetime import datetime
+import math
+from typing import Literal
+
+from app.models.score import Rank
+
+from .beatmap import Beatmap, BeatmapResp
+from .beatmapset import Beatmapset, BeatmapsetResp
+
+from pydantic import BaseModel
+from sqlalchemy import Column, DateTime
+from sqlmodel import BigInteger, Field, Relationship, SQLModel
+
+
+class ScoreBase(SQLModel):
+ # 基本字段
+ accuracy: float
+ map_md5: str = Field(max_length=32, index=True)
+ 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)
+ 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
+
+ # optional
+ # TODO: current_user_attributes
+ position: int | None = Field(default=None) # multiplayer
+
+
+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)
+ 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):
+ 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
+ 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:
+ # 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/database/user.py b/app/database/user.py
index d1a24fb..d10e7dc 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 专用表模型
# ============================================
diff --git a/app/models/score.py b/app/models/score.py
index 736a32d..416b68d 100644
--- a/app/models/score.py
+++ b/app/models/score.py
@@ -19,6 +19,19 @@ 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):
+ X = "ss"
+ XH = "ssh"
+ S = "s"
+ SH = "sh"
+ A = "a"
+ B = "b"
+ C = "c"
+ D = "d"
+ F = "f"
class APIMod(BaseModel):
diff --git a/requirements.txt b/requirements.txt
index de329af..3e2146d 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.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
aiomysql==0.2.0