feat(user): support global rank & country rank

This commit is contained in:
MingxuanGame
2025-08-12 13:36:15 +00:00
parent 147509a93c
commit 52df05648c
8 changed files with 392 additions and 16 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -32,7 +32,7 @@ class BatchUserResponse(BaseModel):
@router.get(
"/users",
"/users/",
response_model=BatchUserResponse,
name="批量获取用户信息",
description="通过用户 ID 列表批量获取用户信息。",

View File

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

View File

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

View File

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