Merge pull request #5 from GooGuTeam/score-database-model

Score database model and /beatmap/score API endpoint
This commit is contained in:
陈晋瑭
2025-07-25 22:01:47 +08:00
committed by GitHub
6 changed files with 138 additions and 10 deletions

2
.idea/misc.xml generated
View File

@@ -3,7 +3,7 @@
<component name="Black">
<option name="sdkName" value="osu_lazer_api" />
</component>
<component name="ProjectRootManager" version="2" project-jdk-name="osu_lazer_api" project-jdk-type="Python SDK" />
<component name="ProjectRootManager" version="2" project-jdk-name="uv (osu_lazer_api)" project-jdk-type="Python SDK" />
<component name="PythonCompatibilityInspectionAdvertiser">
<option name="version" value="3" />
</component>

View File

@@ -4,7 +4,7 @@
<content url="file://$MODULE_DIR$">
<excludeFolder url="file://$MODULE_DIR$/.venv" />
</content>
<orderEntry type="jdk" jdkName="osu_lazer_api" jdkType="Python SDK" />
<orderEntry type="jdk" jdkName="uv (osu_lazer_api)" jdkType="Python SDK" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
<component name="PyDocumentationSettings">

110
app/database/score.py Normal file
View File

@@ -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

View File

@@ -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 专用表模型
# ============================================

View File

@@ -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):

View File

@@ -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