From 92f8a3a416dd8629be0b3e335245a4fa36f8f5eb Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Fri, 25 Jul 2025 21:28:43 +0800 Subject: [PATCH] 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