From 52df05648c679e3e1e25b28e03196a7087448493 Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Tue, 12 Aug 2025 13:36:15 +0000 Subject: [PATCH] feat(user): support global rank & country rank --- app/database/__init__.py | 13 +- app/database/lazer_user.py | 41 ++++++- app/database/rank_history.py | 78 ++++++++++++ app/database/statistics.py | 72 ++++++++++- app/router/v2/user.py | 2 +- app/service/calculate_all_user_rank.py | 87 ++++++++++++++ main.py | 2 + .../b6a304d96a2d_user_support_rank.py | 113 ++++++++++++++++++ 8 files changed, 392 insertions(+), 16 deletions(-) create mode 100644 app/database/rank_history.py create mode 100644 app/service/calculate_all_user_rank.py create mode 100644 migrations/versions/b6a304d96a2d_user_support_rank.py diff --git a/app/database/__init__.py b/app/database/__init__.py index 5304b34..9497ad1 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -1,13 +1,13 @@ from .achievement import UserAchievement, UserAchievementResp from .auth import OAuthClient, OAuthToken from .beatmap import ( - Beatmap as Beatmap, - BeatmapResp as BeatmapResp, + Beatmap, + BeatmapResp, ) from .beatmap_playcounts import BeatmapPlaycounts, BeatmapPlaycountsResp from .beatmapset import ( - Beatmapset as Beatmapset, - BeatmapsetResp as BeatmapsetResp, + Beatmapset, + BeatmapsetResp, ) from .best_score import BestScore from .counts import ( @@ -30,6 +30,7 @@ from .playlist_attempts import ( from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore +from .rank_history import RankHistory, RankHistoryResp, RankTop from .relationship import Relationship, RelationshipResp, RelationshipType from .room import APIUploadedRoom, Room, RoomResp from .room_participated_user import RoomParticipatedUser @@ -58,6 +59,7 @@ __all__ = [ "Beatmap", "BeatmapPlaycounts", "BeatmapPlaycountsResp", + "BeatmapResp", "Beatmapset", "BeatmapsetResp", "BestScore", @@ -78,6 +80,9 @@ __all__ = [ "PlaylistAggregateScore", "PlaylistBestScore", "PlaylistResp", + "RankHistory", + "RankHistoryResp", + "RankTop", "Relationship", "RelationshipResp", "RelationshipType", diff --git a/app/database/lazer_user.py b/app/database/lazer_user.py index 05998a7..21d4a18 100644 --- a/app/database/lazer_user.py +++ b/app/database/lazer_user.py @@ -3,12 +3,13 @@ from typing import TYPE_CHECKING, NotRequired, TypedDict from app.models.model import UTCBaseModel from app.models.score import GameMode -from app.models.user import Country, Page, RankHistory +from app.models.user import Country, Page from .achievement import UserAchievement, UserAchievementResp from .beatmap_playcounts import BeatmapPlaycounts from .counts import CountResp, MonthlyPlaycounts, ReplayWatchedCount from .daily_challenge import DailyChallengeStats, DailyChallengeStatsResp +from .rank_history import RankHistory, RankHistoryResp, RankTop from .statistics import UserStatistics, UserStatisticsResp from .team import Team, TeamMember from .user_account_history import UserAccountHistory, UserAccountHistoryResp @@ -151,6 +152,9 @@ class User(AsyncAttrs, UserBase, table=True): favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship( back_populates="user" ) + rank_history: list[RankHistory] = Relationship( + back_populates="user", + ) email: str = Field(max_length=254, unique=True, index=True, exclude=True) priv: int = Field(default=1, exclude=True) @@ -190,8 +194,8 @@ class UserResp(UserBase): monthly_playcounts: list[CountResp] = Field(default_factory=list) replay_watched_counts: list[CountResp] = Field(default_factory=list) unread_pm_count: int = 0 # TODO - rank_history: RankHistory | None = None # TODO - rank_highest: RankHighest | None = None # TODO + rank_history: RankHistoryResp | None = None + rank_highest: RankHighest | None = None statistics: UserStatisticsResp | None = None statistics_rulesets: dict[str, UserStatisticsResp] | None = None user_achievements: list[UserAchievementResp] = Field(default_factory=list) @@ -289,14 +293,18 @@ class UserResp(UserBase): current_stattistics = i break u.statistics = ( - UserStatisticsResp.from_db(current_stattistics) + await UserStatisticsResp.from_db( + current_stattistics, session, obj.country_code + ) if current_stattistics else None ) if "statistics_rulesets" in include: u.statistics_rulesets = { - i.mode.value: UserStatisticsResp.from_db(i) + i.mode.value: await UserStatisticsResp.from_db( + i, session, obj.country_code + ) for i in await obj.awaitable_attrs.statistics } @@ -317,6 +325,27 @@ class UserResp(UserBase): UserAchievementResp.from_db(ua) for ua in await obj.awaitable_attrs.achievement ] + if "rank_history" in include: + rank_history = await RankHistoryResp.from_db(session, obj.id, ruleset) + if len(rank_history.data) != 0: + u.rank_history = rank_history + + rank_top = ( + await session.exec( + select(RankTop).where( + RankTop.user_id == obj.id, RankTop.mode == ruleset + ) + ) + ).first() + if rank_top: + u.rank_highest = ( + RankHighest( + rank=rank_top.rank, + updated_at=datetime.combine(rank_top.date, datetime.min.time()), + ) + if rank_top + else None + ) u.favourite_beatmapset_count = ( await session.exec( @@ -383,6 +412,7 @@ ALL_INCLUDED = [ "achievements", "monthly_playcounts", "replays_watched_counts", + "rank_history", ] @@ -394,6 +424,7 @@ SEARCH_INCLUDED = [ "achievements", "monthly_playcounts", "replays_watched_counts", + "rank_history", ] BASE_INCLUDES = [ diff --git a/app/database/rank_history.py b/app/database/rank_history.py new file mode 100644 index 0000000..45d9fa3 --- /dev/null +++ b/app/database/rank_history.py @@ -0,0 +1,78 @@ +from datetime import ( + UTC, + date as dt, + datetime, +) +from typing import TYPE_CHECKING, Optional + +from app.models.score import GameMode + +from pydantic import BaseModel +from sqlmodel import ( + BigInteger, + Column, + Date, + Field, + ForeignKey, + Relationship, + SQLModel, + col, + select, +) +from sqlmodel.ext.asyncio.session import AsyncSession + +if TYPE_CHECKING: + from .lazer_user import User + + +class RankHistory(SQLModel, table=True): + __tablename__ = "rank_history" # pyright: ignore[reportAssignmentType] + + id: int | None = Field(default=None, sa_column=Column(BigInteger, primary_key=True)) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + mode: GameMode + rank: int + date: dt = Field( + default_factory=lambda: datetime.now(UTC).date(), + sa_column=Column(Date, index=True), + ) + + user: Optional["User"] = Relationship(back_populates="rank_history") + + +class RankTop(SQLModel, table=True): + __tablename__ = "rank_top" # pyright: ignore[reportAssignmentType] + + id: int | None = Field(default=None, sa_column=Column(BigInteger, primary_key=True)) + user_id: int = Field( + sa_column=Column(BigInteger, ForeignKey("lazer_users.id"), index=True) + ) + mode: GameMode + rank: int + date: dt = Field( + default_factory=lambda: datetime.now(UTC).date(), + sa_column=Column(Date, index=True), + ) + + +class RankHistoryResp(BaseModel): + mode: GameMode + data: list[int] + + @classmethod + async def from_db( + cls, session: AsyncSession, user_id: int, mode: GameMode + ) -> "RankHistoryResp": + results = ( + await session.exec( + select(RankHistory) + .where(RankHistory.user_id == user_id, RankHistory.mode == mode) + .order_by(col(RankHistory.date).desc()) + .limit(90) + ) + ).all() + data = [result.rank for result in results] + data.reverse() + return cls(mode=mode, data=data) diff --git a/app/database/statistics.py b/app/database/statistics.py index cac2971..ca32e32 100644 --- a/app/database/statistics.py +++ b/app/database/statistics.py @@ -1,7 +1,10 @@ +from datetime import UTC, datetime from typing import TYPE_CHECKING from app.models.score import GameMode +from .rank_history import RankHistory + from sqlmodel import ( BigInteger, Column, @@ -9,23 +12,24 @@ from sqlmodel import ( ForeignKey, Relationship, SQLModel, + col, + func, + select, ) +from sqlmodel.ext.asyncio.session import AsyncSession if TYPE_CHECKING: from .lazer_user import User class UserStatisticsBase(SQLModel): - mode: GameMode + mode: GameMode = Field(index=True) count_100: int = Field(default=0, sa_column=Column(BigInteger)) count_300: int = Field(default=0, sa_column=Column(BigInteger)) count_50: int = Field(default=0, sa_column=Column(BigInteger)) count_miss: int = Field(default=0, sa_column=Column(BigInteger)) - global_rank: int | None = Field(default=None) - country_rank: int | None = Field(default=None) - - pp: float = Field(default=0.0) + pp: float = Field(default=0.0, index=True) ranked_score: int = Field(default=0) hit_accuracy: float = Field(default=0.00) total_score: int = Field(default=0, sa_column=Column(BigInteger)) @@ -62,6 +66,8 @@ class UserStatistics(UserStatisticsBase, table=True): class UserStatisticsResp(UserStatisticsBase): + global_rank: int | None = Field(default=None) + country_rank: int | None = Field(default=None) grade_counts: dict[str, int] = Field( default_factory=lambda: { "ss": 0, @@ -79,7 +85,9 @@ class UserStatisticsResp(UserStatisticsBase): ) @classmethod - def from_db(cls, obj: UserStatistics) -> "UserStatisticsResp": + async def from_db( + cls, obj: UserStatistics, session: AsyncSession, user_country: str + ) -> "UserStatisticsResp": s = cls.model_validate(obj) s.grade_counts = { "ss": obj.grade_ss, @@ -92,4 +100,56 @@ class UserStatisticsResp(UserStatisticsBase): "current": obj.level_current, "progress": obj.level_progress, } + s.global_rank = await get_rank(session, obj) + s.country_rank = await get_rank(session, obj, user_country) return s + + +async def get_rank( + session: AsyncSession, statistics: UserStatistics, country: str | None = None +) -> int | None: + from .lazer_user import User + + query = select( + UserStatistics.user_id, + func.row_number().over(order_by=col(UserStatistics.pp).desc()).label("rank"), + ).where( + UserStatistics.mode == statistics.mode, + UserStatistics.pp > 0, + col(UserStatistics.is_ranked).is_(True), + ) + + if country is not None: + query = query.join(User).where(User.country_code == country) + + subq = query.subquery() + + result = await session.exec( + select(subq.c.rank).where(subq.c.user_id == statistics.user_id) + ) + + rank = result.first() + if rank is None: + return None + + today = datetime.now(UTC).date() + rank_history = ( + await session.exec( + select(RankHistory).where( + RankHistory.user_id == statistics.user_id, + RankHistory.mode == statistics.mode, + RankHistory.date == today, + ) + ) + ).first() + if rank_history is None: + rank_history = RankHistory( + user_id=statistics.user_id, + mode=statistics.mode, + date=today, + rank=rank, + ) + session.add(rank_history) + else: + rank_history.rank = rank + return rank diff --git a/app/router/v2/user.py b/app/router/v2/user.py index bcbb8f5..303c258 100644 --- a/app/router/v2/user.py +++ b/app/router/v2/user.py @@ -32,7 +32,7 @@ class BatchUserResponse(BaseModel): @router.get( - "/users", + "/users/", response_model=BatchUserResponse, name="批量获取用户信息", description="通过用户 ID 列表批量获取用户信息。", diff --git a/app/service/calculate_all_user_rank.py b/app/service/calculate_all_user_rank.py new file mode 100644 index 0000000..55ae61e --- /dev/null +++ b/app/service/calculate_all_user_rank.py @@ -0,0 +1,87 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from app.database import RankHistory, UserStatistics +from app.database.rank_history import RankTop +from app.dependencies.database import engine +from app.dependencies.scheduler import get_scheduler +from app.models.score import GameMode + +from sqlmodel import col, exists, select, update +from sqlmodel.ext.asyncio.session import AsyncSession + + +@get_scheduler().scheduled_job( + "cron", hour=0, minute=0, second=0, id="calculate_user_rank" +) +async def calculate_user_rank(is_today: bool = False): + today = datetime.now(UTC).date() + target_date = today if is_today else today - timedelta(days=1) + async with AsyncSession(engine) as session: + for gamemode in GameMode: + users = await session.exec( + select(UserStatistics) + .where( + UserStatistics.mode == gamemode, + UserStatistics.pp > 0, + col(UserStatistics.is_ranked).is_(True), + ) + .order_by( + col(UserStatistics.pp).desc(), + col(UserStatistics.total_score).desc(), + ) + ) + rank = 1 + for user in users: + is_exist = ( + await session.exec( + select(exists()).where( + RankHistory.user_id == user.user_id, + RankHistory.mode == gamemode, + RankHistory.date == target_date, + ) + ) + ).first() + if not is_exist: + rank_history = RankHistory( + user_id=user.user_id, + mode=gamemode, + rank=rank, + date=today, + ) + session.add(rank_history) + else: + await session.execute( + update(RankHistory) + .where( + col(RankHistory.user_id) == user.user_id, + col(RankHistory.mode) == gamemode, + col(RankHistory.date) == target_date, + ) + .values(rank=rank) + ) + + rank_top = ( + await session.exec( + select(RankTop).where( + RankTop.user_id == user.user_id, + RankTop.mode == gamemode, + ) + ) + ).first() + if not rank_top: + rank_top = RankTop( + user_id=user.user_id, + mode=gamemode, + rank=rank, + date=today, + ) + session.add(rank_top) + else: + if rank_top.rank > rank: + rank_top.rank = rank + rank_top.date = today + + rank += 1 + await session.commit() diff --git a/main.py b/main.py index d279318..c606d41 100644 --- a/main.py +++ b/main.py @@ -17,6 +17,7 @@ from app.router import ( signalr_router, ) from app.router.redirect import redirect_router +from app.service.calculate_all_user_rank import calculate_user_rank from app.service.daily_challenge import daily_challenge_job from app.service.osu_rx_statistics import create_rx_statistics @@ -29,6 +30,7 @@ from fastapi.responses import JSONResponse async def lifespan(app: FastAPI): # on startup await create_rx_statistics() + await calculate_user_rank(True) await get_fetcher() # 初始化 fetcher init_scheduler() await daily_challenge_job() diff --git a/migrations/versions/b6a304d96a2d_user_support_rank.py b/migrations/versions/b6a304d96a2d_user_support_rank.py new file mode 100644 index 0000000..9a93489 --- /dev/null +++ b/migrations/versions/b6a304d96a2d_user_support_rank.py @@ -0,0 +1,113 @@ +"""user: support rank + +Revision ID: b6a304d96a2d +Revises: 749bb2c2c33a +Create Date: 2025-08-12 13:31:45.315844 + +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = "b6a304d96a2d" +down_revision: str | Sequence[str] | None = "749bb2c2c33a" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + """Upgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.create_table( + "rank_history", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column( + "mode", + sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + nullable=False, + ), + sa.Column("rank", sa.Integer(), nullable=False), + sa.Column("date", sa.Date(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index( + op.f("ix_rank_history_date"), "rank_history", ["date"], unique=False + ) + op.create_index( + op.f("ix_rank_history_user_id"), "rank_history", ["user_id"], unique=False + ) + op.create_table( + "rank_top", + sa.Column("id", sa.BigInteger(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=True), + sa.Column( + "mode", + sa.Enum( + "OSU", "TAIKO", "FRUITS", "MANIA", "OSURX", "OSUAP", name="gamemode" + ), + nullable=False, + ), + sa.Column("rank", sa.Integer(), nullable=False), + sa.Column("date", sa.Date(), nullable=True), + sa.ForeignKeyConstraint( + ["user_id"], + ["lazer_users.id"], + ), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_rank_top_date"), "rank_top", ["date"], unique=False) + op.create_index(op.f("ix_rank_top_user_id"), "rank_top", ["user_id"], unique=False) + op.create_index( + op.f("ix_lazer_user_statistics_mode"), + "lazer_user_statistics", + ["mode"], + unique=False, + ) + op.create_index( + op.f("ix_lazer_user_statistics_pp"), + "lazer_user_statistics", + ["pp"], + unique=False, + ) + op.drop_column("lazer_user_statistics", "country_rank") + op.drop_column("lazer_user_statistics", "global_rank") + op.create_index( + op.f("ix_oauth_clients_name"), "oauth_clients", ["name"], unique=False + ) + # ### end Alembic commands ### + + +def downgrade() -> None: + """Downgrade schema.""" + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f("ix_oauth_clients_name"), table_name="oauth_clients") + op.add_column( + "lazer_user_statistics", + sa.Column("global_rank", mysql.INTEGER(), autoincrement=False, nullable=True), + ) + op.add_column( + "lazer_user_statistics", + sa.Column("country_rank", mysql.INTEGER(), autoincrement=False, nullable=True), + ) + op.drop_index( + op.f("ix_lazer_user_statistics_pp"), table_name="lazer_user_statistics" + ) + op.drop_index( + op.f("ix_lazer_user_statistics_mode"), table_name="lazer_user_statistics" + ) + op.drop_table("rank_top") + op.drop_table("rank_history") + # ### end Alembic commands ###