fix(score): rewrite DB model & API model

This commit is contained in:
MingxuanGame
2025-07-25 21:28:43 +08:00
parent 75e7350649
commit 92f8a3a416
2 changed files with 101 additions and 88 deletions

View File

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

View File

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