From e589e68881a6da11150b76b45ff814b4ae8d2b95 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=92=95=E8=B0=B7=E9=85=B1?= <74496778+GooGuJiang@users.noreply.github.com> Date: Thu, 11 Sep 2025 02:34:01 +0800 Subject: [PATCH] Add public API for player statistics and information queries --- app/models/v1_user.py | 121 ++++++++++++ app/router/v1/__init__.py | 5 +- app/router/v1/public_router.py | 26 +++ app/router/v1/public_user.py | 344 +++++++++++++++++++++++++++++++++ app/router/v1/user.py | 166 +++++++++++++++- main.py | 2 + 6 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 app/models/v1_user.py create mode 100644 app/router/v1/public_router.py create mode 100644 app/router/v1/public_user.py diff --git a/app/models/v1_user.py b/app/models/v1_user.py new file mode 100644 index 0000000..a12ca6f --- /dev/null +++ b/app/models/v1_user.py @@ -0,0 +1,121 @@ +"""V1 API 用户相关模型""" +from __future__ import annotations + +from pydantic import BaseModel, Field + + +class PlayerStatsHistory(BaseModel): + """玩家 PP 历史数据""" + pp: list[float] = Field(default_factory=list) + + +class PlayerModeStats(BaseModel): + """单个模式的玩家统计数据""" + id: int + mode: int + tscore: int # total_score + rscore: int # ranked_score + pp: float + plays: int # play_count + playtime: int # play_time + acc: float # accuracy + max_combo: int # maximum_combo + total_hits: int + replay_views: int # replays_watched_by_others + xh_count: int # grade_ssh + x_count: int # grade_ss + sh_count: int # grade_sh + s_count: int # grade_s + a_count: int # grade_a + level: int + level_progress: int + rank: int + country_rank: int + history: PlayerStatsHistory + + +class PlayerStatsResponse(BaseModel): + """玩家统计信息响应 - 包含所有模式""" + stats: dict[str, PlayerModeStats] = Field(default_factory=dict) + + +class PlayerEventItem(BaseModel): + """玩家事件项目""" + userId: int + name: str + mapId: int | None = None + setId: int | None = None + artist: str | None = None + title: str | None = None + version: str | None = None + mode: int | None = None + rank: int | None = None + grade: str | None = None + event: str | None = None + time: str | None = None + + +class PlayerEventsResponse(BaseModel): + """玩家事件响应""" + events: list[PlayerEventItem] = Field(default_factory=list) + + +class PlayerInfo(BaseModel): + """玩家基本信息""" + id: int + name: str + safe_name: str + priv: int + country: str + silence_end: int + donor_end: int + creation_time: int + latest_activity: int + clan_id: int + clan_priv: int + preferred_mode: int + preferred_type: int + play_style: int + custom_badge_enabled: int + custom_badge_name: str + custom_badge_icon: str + custom_badge_color: str + userpage_content: str + recentFailed: int + social_discord: str | None = None + social_youtube: str | None = None + social_twitter: str | None = None + social_twitch: str | None = None + social_github: str | None = None + social_osu: str | None = None + username_history: list[str] = Field(default_factory=list) + + +class PlayerInfoResponse(BaseModel): + """玩家信息响应""" + info: PlayerInfo + + +class PlayerAllResponse(BaseModel): + """玩家完整信息响应 - 包含所有数据""" + info: PlayerInfo + stats: dict[str, PlayerModeStats] = Field(default_factory=dict) + events: list[PlayerEventItem] = Field(default_factory=list) + + +class GetPlayerInfoResponse(BaseModel): + """get_player_info 接口响应""" + status: str = "success" + player: PlayerStatsResponse | PlayerEventsResponse | PlayerInfoResponse | PlayerAllResponse + + +class PlayerCountData(BaseModel): + """玩家数量数据""" + online: int + total: int + + +class GetPlayerCountResponse(BaseModel): + """get_player_count 接口响应""" + status: str = "success" + counts: PlayerCountData diff --git a/app/router/v1/__init__.py b/app/router/v1/__init__.py index 2772528..0935339 100644 --- a/app/router/v1/__init__.py +++ b/app/router/v1/__init__.py @@ -1,6 +1,7 @@ from __future__ import annotations -from . import beatmap, replay, score, user # noqa: F401 +from . import beatmap, replay, score, user, public_user # noqa: F401 from .router import router as api_v1_router +from .public_router import public_router as api_v1_public_router -__all__ = ["api_v1_router"] +__all__ = ["api_v1_router", "api_v1_public_router"] diff --git a/app/router/v1/public_router.py b/app/router/v1/public_router.py new file mode 100644 index 0000000..4d2c240 --- /dev/null +++ b/app/router/v1/public_router.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import datetime +from enum import Enum + +from app.dependencies.rate_limit import LIMITERS + +from fastapi import APIRouter +from pydantic import BaseModel, field_serializer + +# 公共V1 API路由器 - 不需要认证 +public_router = APIRouter(prefix="/api/v1", dependencies=LIMITERS, tags=["V1 Public API"]) + + +class AllStrModel(BaseModel): + @field_serializer("*", when_used="json") + def serialize_datetime(self, v, _info): + if isinstance(v, Enum): + return str(v.value) + elif isinstance(v, datetime): + return v.strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(v, bool): + return "1" if v else "0" + elif isinstance(v, list): + return [self.serialize_datetime(item, _info) for item in v] + return str(v) diff --git a/app/router/v1/public_user.py b/app/router/v1/public_user.py new file mode 100644 index 0000000..406ffd8 --- /dev/null +++ b/app/router/v1/public_user.py @@ -0,0 +1,344 @@ +from __future__ import annotations + +from typing import Literal + +from app.database.lazer_user import User +from app.database.statistics import UserStatistics +from app.dependencies.database import Database, get_redis +from app.models.v1_user import ( + GetPlayerInfoResponse, + PlayerStatsResponse, + PlayerEventsResponse, + PlayerInfoResponse, + PlayerAllResponse, + PlayerStatsHistory, + GetPlayerCountResponse, + PlayerCountData +) +from app.models.score import GameMode +from app.log import logger +from app.router.v1.public_router import public_router, AllStrModel + +from fastapi import HTTPException, Query +from sqlmodel import select + + +async def _create_player_mode_stats(session: Database, user: User, mode: GameMode, user_statistics: list[UserStatistics]): + """创建指定模式的玩家统计数据""" + from app.models.v1_user import PlayerModeStats + + # 查找对应模式的统计数据 + statistics = None + for stats in user_statistics: + if stats.mode == mode: + statistics = stats + break + + if not statistics: + # 如果没有统计数据,返回默认值 + return PlayerModeStats( + id=user.id, + mode=int(mode), + tscore=0, + rscore=0, + pp=0.0, + plays=0, + playtime=0, + acc=0.0, + max_combo=0, + total_hits=0, + replay_views=0, + xh_count=0, + x_count=0, + sh_count=0, + s_count=0, + a_count=0, + level=1, + level_progress=0, + rank=0, + country_rank=0, + history=PlayerStatsHistory() + ) + + return PlayerModeStats( + id=user.id, + mode=int(mode), + tscore=statistics.total_score if statistics.total_score else 0, + rscore=statistics.ranked_score if statistics.ranked_score else 0, + pp=float(statistics.pp) if statistics.pp else 0.0, + plays=statistics.play_count if statistics.play_count else 0, + playtime=statistics.play_time if statistics.play_time else 0, + acc=float(statistics.hit_accuracy) if statistics.hit_accuracy else 0.0, + max_combo=statistics.maximum_combo if statistics.maximum_combo else 0, + total_hits=statistics.total_hits if statistics.total_hits else 0, + replay_views=statistics.replays_watched_by_others if statistics.replays_watched_by_others else 0, + xh_count=statistics.grade_ssh if statistics.grade_ssh else 0, + x_count=statistics.grade_ss if statistics.grade_ss else 0, + sh_count=statistics.grade_sh if statistics.grade_sh else 0, + s_count=statistics.grade_s if statistics.grade_s else 0, + a_count=statistics.grade_a if statistics.grade_a else 0, + level=int(statistics.level_current) if statistics.level_current else 1, + level_progress=0, # TODO: 计算等级进度 + rank=0, # global_rank需要从RankHistory获取 + country_rank=0, # country_rank需要从其他地方获取 + history=PlayerStatsHistory() # TODO: 获取PP历史数据 + ) + + +async def _create_player_info(user: User): + """创建玩家基本信息""" + from app.models.v1_user import PlayerInfo + + return PlayerInfo( + id=user.id, + name=user.username, + safe_name=user.username, # 使用 username 作为 safe_name + priv=user.priv if user.priv else 1, + country=user.country_code if user.country_code else "", + silence_end=int(user.silence_end_at.timestamp()) if user.silence_end_at else 0, + donor_end=int(user.donor_end_at.timestamp()) if user.donor_end_at else 0, + creation_time=int(user.join_date.timestamp()) if user.join_date else 0, + latest_activity=int(user.last_visit.timestamp()) if user.last_visit else 0, + clan_id=0, # TODO: 从 user 获取战队信息 + clan_priv=0, + preferred_mode=int(user.playmode) if user.playmode else 0, + preferred_type=0, + play_style=0, # TODO: 从 user.playstyle 获取游戏风格 + custom_badge_enabled=0, + custom_badge_name="", + custom_badge_icon="", + custom_badge_color="", + userpage_content=user.page["html"] if user.page and "html" in user.page else "", + recentFailed=0, # TODO: 获取最近失败次数 + social_discord=user.discord, + social_youtube=None, + social_twitter=user.twitter, + social_twitch=None, + social_github=None, + social_osu=None, + username_history=user.previous_usernames if user.previous_usernames else [] + ) + + +async def _get_player_events(session: Database, user_id: int): + """获取玩家事件列表""" + # TODO: 实现事件查询逻辑 + # 这里应该查询 app.database.events 表 + return [] + + +async def _count_online_users_optimized(redis): + """ + 优化的在线用户计数函数 + 首先尝试使用HyperLogLog近似计数,失败则回退到SCAN + """ + try: + # 方案1: 尝试使用更高效的方法 - 如果项目维护了在线用户集合 + # 检查是否存在在线用户集合键 + online_set_key = "metadata:online_users_set" + if await redis.exists(online_set_key): + # 如果存在维护好的在线用户集合,直接获取数量 + count = await redis.scard(online_set_key) + logger.debug(f"Using online users set, count: {count}") + return count + + except Exception as e: + logger.debug(f"Online users set not available: {e}") + + # 方案2: 回退到优化的SCAN操作 + online_count = 0 + cursor = 0 + scan_iterations = 0 + max_iterations = 50 # 进一步减少最大迭代次数 + batch_size = 10000 # 增加批次大小 + + try: + while cursor != 0 or scan_iterations == 0: + if scan_iterations >= max_iterations: + logger.warning(f"Redis SCAN reached max iterations ({max_iterations}), breaking") + break + + cursor, keys = await redis.scan( + cursor, + match="metadata:online:*", + count=batch_size + ) + online_count += len(keys) + scan_iterations += 1 + + # 如果连续几次没有找到键,可能已经扫描完成 + if len(keys) == 0 and scan_iterations > 2: + break + + logger.debug(f"Found {online_count} online users after {scan_iterations} scan iterations") + return online_count + + except Exception as e: + logger.error(f"Error counting online users: {e}") + # 如果SCAN失败,返回0而不是让整个API失败 + return 0 + + +@public_router.get( + "/get_player_info", + response_model=GetPlayerInfoResponse, + name="获取玩家信息", + description="返回指定玩家的信息。", +) +async def api_get_player_info( + session: Database, + scope: Literal["stats", "events", "info", "all"] = Query(..., description="信息范围"), + id: int | None = Query(None, ge=3, le=2147483647, description="用户 ID"), + name: str | None = Query(None, regex=r"^[\w \[\]-]{2,32}$", description="用户名"), +): + """ + 获取指定玩家的信息 + + Args: + scope: 信息范围 - stats(统计), events(事件), info(基本信息), all(全部) + id: 用户 ID (可选) + name: 用户名 (可选) + """ + # 验证参数 + if not id and not name: + raise HTTPException(400, "Must provide either id or name") + + # 查询用户 + if id: + user = await session.get(User, id) + else: + user = (await session.exec(select(User).where(User.username == name))).first() + + if not user: + raise HTTPException(404, "User not found") + + try: + if scope == "stats": + # 获取所有模式的统计数据 + user_statistics = list(( + await session.exec( + select(UserStatistics).where(UserStatistics.user_id == user.id) + ) + ).all()) + + stats_dict = {} + # 获取所有游戏模式的统计数据 + all_modes = [GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS, GameMode.MANIA, + GameMode.OSURX, GameMode.OSUAP] + + for mode in all_modes: + mode_stats = await _create_player_mode_stats(session, user, mode, user_statistics) + stats_dict[str(int(mode))] = mode_stats + + return GetPlayerInfoResponse( + player=PlayerStatsResponse(stats=stats_dict) + ) + + elif scope == "events": + # 获取事件数据 + events = await _get_player_events(session, user.id) + return GetPlayerInfoResponse( + player=PlayerEventsResponse(events=events) + ) + + elif scope == "info": + # 获取基本信息 + info = await _create_player_info(user) + return GetPlayerInfoResponse( + player=PlayerInfoResponse(info=info) + ) + + elif scope == "all": + # 获取所有信息 + # 统计数据 + user_statistics = list(( + await session.exec( + select(UserStatistics).where(UserStatistics.user_id == user.id) + ) + ).all()) + + stats_dict = {} + all_modes = [GameMode.OSU, GameMode.TAIKO, GameMode.FRUITS, GameMode.MANIA, + GameMode.OSURX, GameMode.OSUAP] + + for mode in all_modes: + mode_stats = await _create_player_mode_stats(session, user, mode, user_statistics) + stats_dict[str(int(mode))] = mode_stats + + # 基本信息 + info = await _create_player_info(user) + + # 事件 + events = await _get_player_events(session, user.id) + + return GetPlayerInfoResponse( + player=PlayerAllResponse( + info=info, + stats=stats_dict, + events=events + ) + ) + + except Exception as e: + logger.error(f"Error processing get_player_info for user {user.id}: {e}") + raise HTTPException(500, "Internal server error") + + +@public_router.get( + "/get_player_count", + response_model=GetPlayerCountResponse, + name="获取玩家数量", + description="返回在线和总用户数量。", +) +async def api_get_player_count( + session: Database, +): + """ + 获取玩家数量统计 + + Returns: + 包含在线用户数和总用户数的响应 + """ + try: + redis = get_redis() + + online_cache_key = "stats:online_users_count" + cached_online = await redis.get(online_cache_key) + + if cached_online is not None: + online_count = int(cached_online) + logger.debug(f"Using cached online user count: {online_count}") + else: + logger.debug("Cache miss, scanning Redis for online users") + online_count = await _count_online_users_optimized(redis) + + await redis.setex(online_cache_key, 30, str(online_count)) + logger.debug(f"Cached online user count: {online_count} for 30 seconds") + + cache_key = "stats:total_users" + cached_total = await redis.get(cache_key) + + if cached_total is not None: + total_count = int(cached_total) + logger.debug(f"Using cached total user count: {total_count}") + else: + logger.debug("Cache miss, querying database for total user count") + from sqlmodel import func, select + total_count_result = await session.exec( + select(func.count()).select_from(User) + ) + total_count = total_count_result.one() + + await redis.setex(cache_key, 3600, str(total_count)) + logger.debug(f"Cached total user count: {total_count} for 1 hour") + + return GetPlayerCountResponse( + counts=PlayerCountData( + online=online_count, + total=max(0, total_count - 1) # 减去1个机器人账户,确保不为负数 + ) + ) + + except Exception as e: + logger.error(f"Error getting player count: {e}") + raise HTTPException(500, "Internal server error") diff --git a/app/router/v1/user.py b/app/router/v1/user.py index 84bb9b0..4cb835c 100644 --- a/app/router/v1/user.py +++ b/app/router/v1/user.py @@ -3,17 +3,31 @@ from __future__ import annotations from datetime import datetime from typing import Literal +from app.database.events import Event, EventType from app.database.lazer_user import User +from app.database.rank_history import RankHistory, RankHistoryResp from app.database.statistics import UserStatistics, UserStatisticsResp from app.dependencies.database import Database, get_redis from app.log import logger from app.models.score import GameMode +from app.models.v1_user import ( + GetPlayerInfoResponse, + PlayerAllResponse, + PlayerEventItem, + PlayerEventsResponse, + PlayerInfo, + PlayerInfoResponse, + PlayerModeStats, + PlayerStatsHistory, + PlayerStatsResponse, +) from app.service.user_cache_service import get_user_cache_service from .router import AllStrModel, router from fastapi import BackgroundTasks, HTTPException, Query -from sqlmodel import select +from pydantic import BaseModel, Field +from sqlmodel import col, select class V1User(AllStrModel): @@ -153,3 +167,153 @@ async def get_user( except ValueError as e: logger.error(f"Error processing V1 user data: {e}") raise HTTPException(500, "Internal server error") + + +# 以下为 get_player_info 接口相关的实现函数 + +async def _get_pp_history_for_mode(session: Database, user_id: int, mode: GameMode, days: int = 30) -> list[float]: + """获取指定模式的 PP 历史数据""" + try: + # 获取最近 30 天的排名历史(由于没有 PP 历史,我们使用当前的 PP 填充) + stats = ( + await session.exec( + select(UserStatistics).where( + UserStatistics.user_id == user_id, + UserStatistics.mode == mode + ) + ) + ).first() + + current_pp = stats.pp if stats else 0.0 + # 创建 30 天的 PP 历史(使用当前 PP 值填充) + return [current_pp] * days + except Exception as e: + logger.error(f"Error getting PP history for user {user_id}, mode {mode}: {e}") + return [0.0] * days + + +async def _create_player_mode_stats( + session: Database, + user: User, + mode: GameMode, + user_statistics: list[UserStatistics] +) -> PlayerModeStats: + """创建单个模式的玩家统计数据""" + # 查找对应模式的统计数据 + stats = None + for stat in user_statistics: + if stat.mode == mode: + stats = stat + break + + if not stats: + # 如果没有统计数据,创建默认数据 + pp_history = [0.0] * 30 + return PlayerModeStats( + id=user.id, + mode=int(mode), + tscore=0, + rscore=0, + pp=0.0, + plays=0, + playtime=0, + acc=0.0, + max_combo=0, + total_hits=0, + replay_views=0, + xh_count=0, + x_count=0, + sh_count=0, + s_count=0, + a_count=0, + level=1, + level_progress=0, + rank=0, + country_rank=0, + history=PlayerStatsHistory(pp=pp_history) + ) + + # 获取排名信息 + try: + from app.database.statistics import get_rank + global_rank = await get_rank(session, stats) or 0 + country_rank = await get_rank(session, stats, user.country_code) or 0 + except Exception as e: + logger.error(f"Error getting rank for user {user.id}: {e}") + global_rank = 0 + country_rank = 0 + + # 获取 PP 历史 + pp_history = await _get_pp_history_for_mode(session, user.id, mode) + + # 计算等级进度 + level_current = int(stats.level_current) + level_progress = int((stats.level_current - level_current) * 100) + + return PlayerModeStats( + id=user.id, + mode=int(mode), + tscore=stats.total_score, + rscore=stats.ranked_score, + pp=stats.pp, + plays=stats.play_count, + playtime=stats.play_time, + acc=stats.hit_accuracy, + max_combo=stats.maximum_combo, + total_hits=stats.total_hits, + replay_views=stats.replays_watched_by_others, + xh_count=stats.grade_ssh, + x_count=stats.grade_ss, + sh_count=stats.grade_sh, + s_count=stats.grade_s, + a_count=stats.grade_a, + level=level_current, + level_progress=level_progress, + rank=global_rank, + country_rank=country_rank, + history=PlayerStatsHistory(pp=pp_history) + ) + + +async def _get_player_events(session: Database, user_id: int, event_days: int = 1) -> list[PlayerEventItem]: + """获取玩家事件""" + try: + # 这里暂时返回空列表,因为事件系统需要更多的实现 + # TODO: 实现真正的事件查询 + return [] + except Exception as e: + logger.error(f"Error getting events for user {user_id}: {e}") + return [] + + +async def _create_player_info(user: User) -> PlayerInfo: + """创建玩家基本信息""" + return PlayerInfo( + id=user.id, + name=user.username, + safe_name=user.username.lower(), # 使用 username 转小写作为 safe_name + priv=user.priv, + country=user.country_code, + silence_end=int(user.silence_end_at.timestamp()) if user.silence_end_at else 0, + donor_end=int(user.donor_end_at.timestamp()) if user.donor_end_at else 0, + creation_time=int(user.join_date.timestamp()), + latest_activity=int(user.last_visit.timestamp()) if user.last_visit else 0, + clan_id=0, # TODO: 实现战队系统 + clan_priv=0, + preferred_mode=int(user.playmode), + preferred_type=0, + play_style=0, + custom_badge_enabled=0, + custom_badge_name="", + custom_badge_icon="", + custom_badge_color="white", + userpage_content=user.page.get("html", "") if user.page else "", + recentFailed=0, + social_discord=user.discord, + social_youtube=None, + social_twitter=user.twitter, + social_twitch=None, + social_github=None, + social_osu=None, + username_history=user.previous_usernames or [] + ) diff --git a/main.py b/main.py index 72bef7a..5f7b337 100644 --- a/main.py +++ b/main.py @@ -19,6 +19,7 @@ from app.router import ( private_router, redirect_api_router, ) +from app.router.v1 import api_v1_public_router from app.router.redirect import redirect_router from app.scheduler.cache_scheduler import start_cache_scheduler, stop_cache_scheduler from app.scheduler.database_cleanup_scheduler import ( @@ -157,6 +158,7 @@ app = FastAPI( app.include_router(api_v2_router) app.include_router(api_v1_router) +app.include_router(api_v1_public_router) app.include_router(chat_router) app.include_router(redirect_api_router) app.include_router(fetcher_router)