This commit is contained in:
chenjintang-shrimp
2025-08-12 14:38:26 +00:00
9 changed files with 395 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

@@ -4,12 +4,13 @@ from typing import TYPE_CHECKING, NotRequired, TypedDict
from app.database.events import Event
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
@@ -152,6 +153,10 @@ class User(AsyncAttrs, UserBase, table=True):
favourite_beatmapsets: list["FavouriteBeatmapset"] = Relationship(
back_populates="user"
)
rank_history: list[RankHistory] = Relationship(
back_populates="user",
)
events: list["Event"] = Relationship(back_populates="user")
email: str = Field(max_length=254, unique=True, index=True, exclude=True)
priv: int = Field(default=1, exclude=True)
@@ -191,8 +196,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)
@@ -290,14 +295,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
}
@@ -318,6 +327,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(
@@ -384,6 +414,7 @@ ALL_INCLUDED = [
"achievements",
"monthly_playcounts",
"replays_watched_counts",
"rank_history",
]
@@ -395,6 +426,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

@@ -848,6 +848,8 @@ class MultiplayerHub(Hub[MultiplayerClientState]):
played_user,
ex=3600,
)
else:
await room.queue.finish_current_item()
async def send_match_event(
self, room: ServerMultiplayerRoom, event: MatchServerEvent

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