From 832a6fc95dcd5a2d21dd48b51e18feb7b3540d4a Mon Sep 17 00:00:00 2001 From: MingxuanGame Date: Sat, 9 Aug 2025 11:18:29 +0000 Subject: [PATCH] feat(daily-challenge): simple implement --- app/database/__init__.py | 3 +- app/database/playlists.py | 2 +- app/database/room.py | 15 +++++ app/dependencies/scheduler.py | 26 +++++++++ app/models/metadata_hub.py | 4 ++ app/router/room.py | 44 +++----------- app/router/score.py | 11 ++++ app/service/__init__.py | 10 ++++ app/service/daily_challenge.py | 103 +++++++++++++++++++++++++++++++++ app/service/room.py | 78 +++++++++++++++++++++++++ app/signalr/hub/metadata.py | 28 ++++++++- main.py | 3 + pyproject.toml | 1 + uv.lock | 37 +++++++++++- 14 files changed, 323 insertions(+), 42 deletions(-) create mode 100644 app/dependencies/scheduler.py create mode 100644 app/service/__init__.py create mode 100644 app/service/daily_challenge.py create mode 100644 app/service/room.py diff --git a/app/database/__init__.py b/app/database/__init__.py index f401e56..7030ae7 100644 --- a/app/database/__init__.py +++ b/app/database/__init__.py @@ -25,7 +25,7 @@ from .playlist_best_score import PlaylistBestScore from .playlists import Playlist, PlaylistResp from .pp_best_score import PPBestScore from .relationship import Relationship, RelationshipResp, RelationshipType -from .room import Room, RoomResp +from .room import APIUploadedRoom, Room, RoomResp from .room_participated_user import RoomParticipatedUser from .score import ( MultiplayerScores, @@ -48,6 +48,7 @@ from .user_account_history import ( ) __all__ = [ + "APIUploadedRoom", "Beatmap", "Beatmapset", "BeatmapsetResp", diff --git a/app/database/playlists.py b/app/database/playlists.py index 3f7ae40..c177432 100644 --- a/app/database/playlists.py +++ b/app/database/playlists.py @@ -138,6 +138,6 @@ class PlaylistResp(PlaylistBase): ) -> "PlaylistResp": data = playlist.model_dump() if "beatmap" in include: - data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap, from_set=True) + data["beatmap"] = await BeatmapResp.from_db(playlist.beatmap) resp = cls.model_validate(data) return resp diff --git a/app/database/room.py b/app/database/room.py index 3b59ab0..368a04a 100644 --- a/app/database/room.py +++ b/app/database/room.py @@ -160,3 +160,18 @@ class RoomResp(RoomBase): participant_count=len(room.users), ) return resp + + +class APIUploadedRoom(RoomBase): + def to_room(self) -> Room: + """ + 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 + """ + room_dict = self.model_dump() + room_dict.pop("playlist", None) + # host_id 已在字段中 + return Room(**room_dict) + + id: int | None + host_id: int | None = None + playlist: list[Playlist] = Field(default_factory=list) diff --git a/app/dependencies/scheduler.py b/app/dependencies/scheduler.py new file mode 100644 index 0000000..fa20396 --- /dev/null +++ b/app/dependencies/scheduler.py @@ -0,0 +1,26 @@ +from __future__ import annotations + +from datetime import UTC + +from apscheduler.schedulers.asyncio import AsyncIOScheduler + +scheduler: AsyncIOScheduler | None = None + + +def init_scheduler(): + global scheduler + scheduler = AsyncIOScheduler(timezone=UTC) + scheduler.start() + + +def get_scheduler() -> AsyncIOScheduler: + global scheduler + if scheduler is None: + init_scheduler() + return scheduler # pyright: ignore[reportReturnType] + + +def stop_scheduler(): + global scheduler + if scheduler: + scheduler.shutdown() diff --git a/app/models/metadata_hub.py b/app/models/metadata_hub.py index 684ab54..7ef2b7a 100644 --- a/app/models/metadata_hub.py +++ b/app/models/metadata_hub.py @@ -123,3 +123,7 @@ class OnlineStatus(IntEnum): OFFLINE = 0 # 隐身 DO_NOT_DISTURB = 1 ONLINE = 2 + + +class DailyChallengeInfo(BaseModel): + room_id: int diff --git a/app/router/room.py b/app/router/room.py index 78e08dd..6918364 100644 --- a/app/router/room.py +++ b/app/router/room.py @@ -1,6 +1,6 @@ from __future__ import annotations -from datetime import UTC, datetime, timedelta +from datetime import UTC, datetime from typing import Literal from app.database.beatmap import Beatmap, BeatmapResp @@ -8,13 +8,14 @@ from app.database.beatmapset import BeatmapsetResp from app.database.lazer_user import User, UserResp from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp -from app.database.playlists import Playlist, PlaylistBase, PlaylistResp -from app.database.room import Room, RoomBase, RoomResp +from app.database.playlists import Playlist, PlaylistResp +from app.database.room import APIUploadedRoom, Room, RoomResp from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.dependencies.database import get_db, get_redis from app.dependencies.user import get_current_user from app.models.room import RoomCategory, RoomStatus +from app.service.room import create_playlist_room_from_api from app.signalr.hub import MultiplayerHubs from .api_router import router @@ -92,21 +93,6 @@ class APICreatedRoom(RoomResp): error: str = "" -class APIUploadedRoom(RoomBase): - def to_room(self) -> Room: - """ - 将 APIUploadedRoom 转换为 Room 对象,playlist 字段需单独处理。 - """ - room_dict = self.model_dump() - room_dict.pop("playlist", None) - # host_id 已在字段中 - return Room(**room_dict) - - id: int | None - host_id: int | None = None - playlist: list[PlaylistBase] = Field(default_factory=list) - - async def _participate_room( room_id: int, user_id: int, db_room: Room, session: AsyncSession ): @@ -137,27 +123,11 @@ async def create_room( db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user), ): - # db_room = Room.from_resp(room) - await db.refresh(current_user) user_id = current_user.id - db_room = room.to_room() - db_room.host_id = current_user.id if current_user.id else 1 - db_room.starts_at = datetime.now(UTC) - db_room.ends_at = db_room.starts_at + timedelta( - minutes=db_room.duration if db_room.duration is not None else 0 - ) - db.add(db_room) - await db.commit() - await db.refresh(db_room) + db_room = await create_playlist_room_from_api(db, room, user_id) await _participate_room(db_room.id, user_id, db_room, db) - - for item in room.playlist: - item.id = await Playlist.get_next_id_for_room(db_room.id, db) - item.room_id = db_room.id - item.owner_id = user_id if user_id else 1 - db.add(item) - await db.commit() - await db.refresh(db_room) + # await db.commit() + # await db.refresh(db_room) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room.error = "" return created_room diff --git a/app/router/score.py b/app/router/score.py index 70664a5..506ebac 100644 --- a/app/router/score.py +++ b/app/router/score.py @@ -32,6 +32,7 @@ from app.dependencies.fetcher import get_fetcher from app.dependencies.user import get_current_user from app.fetcher import Fetcher from app.models.beatmap import BeatmapRankStatus +from app.models.room import RoomCategory from app.models.score import ( INT_TO_MODE, GameMode, @@ -402,6 +403,10 @@ async def index_playlist_scores( current_user: User = Depends(get_current_user), session: AsyncSession = Depends(get_db), ): + room = await session.get(Room, room_id) + if not room: + raise HTTPException(status_code=404, detail="Room not found") + limit = clamp(limit, 1, 50) scores = ( @@ -426,6 +431,12 @@ async def index_playlist_scores( score.position = await get_position(room_id, playlist_id, score.id, session) if score.user_id == current_user.id: user_score = score + + if room.category == RoomCategory.DAILY_CHALLENGE: + score_resp = [s for s in score_resp if s.passed] + if user_score and not user_score.passed: + user_score = None + resp = IndexedScoreResp( scores=score_resp, user_score=user_score, diff --git a/app/service/__init__.py b/app/service/__init__.py new file mode 100644 index 0000000..cbb83a2 --- /dev/null +++ b/app/service/__init__.py @@ -0,0 +1,10 @@ +from __future__ import annotations + +from .daily_challenge import create_daily_challenge_room +from .room import create_playlist_room, create_playlist_room_from_api + +__all__ = [ + "create_daily_challenge_room", + "create_playlist_room", + "create_playlist_room_from_api", +] diff --git a/app/service/daily_challenge.py b/app/service/daily_challenge.py new file mode 100644 index 0000000..1f92034 --- /dev/null +++ b/app/service/daily_challenge.py @@ -0,0 +1,103 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta +import json + +from app.database.playlists import Playlist +from app.database.room import Room +from app.dependencies.database import engine, get_redis +from app.dependencies.scheduler import get_scheduler +from app.log import logger +from app.models.metadata_hub import DailyChallengeInfo +from app.models.mods import APIMod +from app.models.room import RoomCategory +from app.signalr.hub import MetadataHubs + +from .room import create_playlist_room + +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def create_daily_challenge_room( + beatmap: int, ruleset_id: int, required_mods: list[APIMod] = [] +) -> Room: + async with AsyncSession(engine) as session: + today = datetime.now(UTC).date() + return await create_playlist_room( + session=session, + name=str(today), + host_id=3, + playlist=[ + Playlist( + id=0, + room_id=0, + owner_id=3, + ruleset_id=ruleset_id, + beatmap_id=beatmap, + required_mods=required_mods, + ) + ], + category=RoomCategory.DAILY_CHALLENGE, + duration=24 * 60 - 2, # remain 2 minute to apply new daily challenge + ) + + +@get_scheduler().scheduled_job("cron", hour=0, minute=0, second=0, id="daily_challenge") +async def daily_challenge_job(): + today = datetime.now(UTC).date() + redis = get_redis() + key = f"daily_challenge:{today.year}-{today.month}-{today.day}" + if not await redis.exists(key): + return + try: + beatmap = await redis.hget(key, "beatmap") # pyright: ignore[reportGeneralTypeIssues] + ruleset_id = await redis.hget(key, "ruleset_id") # pyright: ignore[reportGeneralTypeIssues] + required_mods = await redis.hget(key, "required_mods") # pyright: ignore[reportGeneralTypeIssues] + + if beatmap is None or ruleset_id is None: + logger.warning( + f"[DailyChallenge] Missing required data for daily challenge {today}." + " Will try again in 5 minutes." + ) + get_scheduler().add_job( + daily_challenge_job, + "date", + run_date=datetime.now(UTC) + timedelta(minutes=5), + ) + return + + beatmap_int = int(beatmap) + ruleset_id_int = int(ruleset_id) + + mods_list = [] + if required_mods: + mods_list = json.loads(required_mods) + + room = await create_daily_challenge_room( + beatmap=beatmap_int, + ruleset_id=ruleset_id_int, + required_mods=mods_list, + ) + await MetadataHubs.broadcast_call( + "DailyChallengeUpdated", DailyChallengeInfo(room_id=room.id) + ) + logger.success( + "[DailyChallenge] Added today's daily challenge: " + f"{beatmap=}, {ruleset_id=}, {required_mods=}" + ) + return + except (ValueError, json.JSONDecodeError) as e: + logger.warning( + f"[DailyChallenge] Error processing daily challenge data: {e}" + " Will try again in 5 minutes." + ) + except Exception as e: + logger.exception( + f"[DailyChallenge] Unexpected error in daily challenge job: {e}" + " Will try again in 5 minutes." + ) + get_scheduler().add_job( + daily_challenge_job, + "date", + run_date=datetime.now(UTC) + timedelta(minutes=5), + ) diff --git a/app/service/room.py b/app/service/room.py new file mode 100644 index 0000000..d11dced --- /dev/null +++ b/app/service/room.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +from datetime import UTC, datetime, timedelta + +from app.database.beatmap import Beatmap +from app.database.playlists import Playlist +from app.database.room import APIUploadedRoom, Room +from app.dependencies.fetcher import get_fetcher +from app.models.room import MatchType, QueueMode, RoomCategory, RoomStatus + +from sqlalchemy import exists +from sqlmodel import col, select +from sqlmodel.ext.asyncio.session import AsyncSession + + +async def create_playlist_room_from_api( + session: AsyncSession, room: APIUploadedRoom, host_id: int +) -> Room: + db_room = room.to_room() + db_room.host_id = host_id + db_room.starts_at = datetime.now(UTC) + db_room.ends_at = db_room.starts_at + timedelta( + minutes=db_room.duration if db_room.duration is not None else 0 + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + await add_playlists_to_room(session, db_room.id, room.playlist, host_id) + await session.refresh(db_room) + return db_room + + +async def create_playlist_room( + session: AsyncSession, + name: str, + host_id: int, + category: RoomCategory = RoomCategory.NORMAL, + duration: int = 30, + max_attempts: int | None = None, + playlist: list[Playlist] = [], +) -> Room: + db_room = Room( + name=name, + category=category, + duration=duration, + starts_at=datetime.now(UTC), + ends_at=datetime.now(UTC) + timedelta(minutes=duration), + participant_count=0, + max_attempts=max_attempts, + type=MatchType.PLAYLISTS, + queue_mode=QueueMode.HOST_ONLY, + auto_skip=False, + auto_start_duration=0, + status=RoomStatus.IDLE, + host_id=host_id, + ) + session.add(db_room) + await session.commit() + await session.refresh(db_room) + await add_playlists_to_room(session, db_room.id, playlist, host_id) + await session.refresh(db_room) + return db_room + + +async def add_playlists_to_room( + session: AsyncSession, room_id: int, playlist: list[Playlist], owner_id: int +): + for item in playlist: + if not ( + await session.exec(select(exists().where(col(Beatmap.id) == item.beatmap))) + ).first(): + fetcher = await get_fetcher() + await Beatmap.get_or_fetch(session, fetcher, item.beatmap_id) + item.id = await Playlist.get_next_id_for_room(room_id, session) + item.room_id = room_id + item.owner_id = owner_id + session.add(item) + await session.commit() diff --git a/app/signalr/hub/metadata.py b/app/signalr/hub/metadata.py index 08ee035..ef21c93 100644 --- a/app/signalr/hub/metadata.py +++ b/app/signalr/hub/metadata.py @@ -6,12 +6,19 @@ from datetime import UTC, datetime from typing import override from app.database import Relationship, RelationshipType, User +from app.database.room import Room from app.dependencies.database import engine, get_redis -from app.models.metadata_hub import MetadataClientState, OnlineStatus, UserActivity +from app.models.metadata_hub import ( + DailyChallengeInfo, + MetadataClientState, + OnlineStatus, + UserActivity, +) +from app.models.room import RoomCategory from .hub import Client, Hub -from sqlmodel import select +from sqlmodel import col, select from sqlmodel.ext.asyncio.session import AsyncSession ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" @@ -107,6 +114,23 @@ class MetadataHub(Hub[MetadataClientState]): ) ) await asyncio.gather(*tasks) + + daily_challenge_room = ( + await session.exec( + select(Room).where( + col(Room.ends_at) > datetime.now(UTC), + Room.category == RoomCategory.DAILY_CHALLENGE, + ) + ) + ).first() + if daily_challenge_room: + await self.call_noblock( + client, + "DailyChallengeUpdated", + DailyChallengeInfo( + room_id=daily_challenge_room.id, + ), + ) redis = get_redis() await redis.set(f"metadata:online:{user_id}", "") diff --git a/main.py b/main.py index b12f543..31cdd44 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from datetime import datetime from app.config import settings from app.dependencies.database import create_tables, engine, redis_client from app.dependencies.fetcher import get_fetcher +from app.dependencies.scheduler import init_scheduler, stop_scheduler from app.router import ( api_router, auth_router, @@ -21,8 +22,10 @@ async def lifespan(app: FastAPI): # on startup await create_tables() await get_fetcher() # 初始化 fetcher + init_scheduler() # on shutdown yield + stop_scheduler() await engine.dispose() await redis_client.aclose() diff --git a/pyproject.toml b/pyproject.toml index cd90947..3ab61c6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,6 +7,7 @@ requires-python = ">=3.12" dependencies = [ "aiomysql>=0.2.0", "alembic>=1.12.1", + "apscheduler>=3.11.0", "bcrypt>=4.1.2", "cryptography>=41.0.7", "fastapi>=0.104.1", diff --git a/uv.lock b/uv.lock index 3fc7d3c..22a4f1d 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.12" [manifest] @@ -57,6 +57,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a1/ee/48ca1a7c89ffec8b6a0c5d02b89c305671d5ffd8d3c94acf8b8c408575bb/anyio-4.9.0-py3-none-any.whl", hash = "sha256:9f76d541cad6e36af7beb62e978876f3b41e3e04f2c1fbf0884604c0a9c4d93c", size = 100916, upload-time = "2025-03-17T00:02:52.713Z" }, ] +[[package]] +name = "apscheduler" +version = "3.11.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzlocal" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/4e/00/6d6814ddc19be2df62c8c898c4df6b5b1914f3bd024b780028caa392d186/apscheduler-3.11.0.tar.gz", hash = "sha256:4c622d250b0955a65d5d0eb91c33e6d43fd879834bf541e0a18661ae60460133", size = 107347, upload-time = "2024-11-24T19:39:26.463Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/ae/9a053dd9229c0fde6b1f1f33f609ccff1ee79ddda364c756a924c6d8563b/APScheduler-3.11.0-py3-none-any.whl", hash = "sha256:fc134ca32e50f5eadcc4938e3a4545ab19131435e851abb40b34d63d5141c6da", size = 64004, upload-time = "2024-11-24T19:39:24.442Z" }, +] + [[package]] name = "bcrypt" version = "4.3.0" @@ -493,6 +505,7 @@ source = { virtual = "." } dependencies = [ { name = "aiomysql" }, { name = "alembic" }, + { name = "apscheduler" }, { name = "bcrypt" }, { name = "cryptography" }, { name = "fastapi" }, @@ -522,6 +535,7 @@ dev = [ requires-dist = [ { name = "aiomysql", specifier = ">=0.2.0" }, { name = "alembic", specifier = ">=1.12.1" }, + { name = "apscheduler", specifier = ">=3.11.0" }, { name = "bcrypt", specifier = ">=4.1.2" }, { name = "cryptography", specifier = ">=41.0.7" }, { name = "fastapi", specifier = ">=0.104.1" }, @@ -904,6 +918,27 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/17/69/cd203477f944c353c31bade965f880aa1061fd6bf05ded0726ca845b6ff7/typing_inspection-0.4.1-py3-none-any.whl", hash = "sha256:389055682238f53b04f7badcb49b989835495a96700ced5dab2d8feae4b26f51", size = 14552, upload-time = "2025-05-21T18:55:22.152Z" }, ] +[[package]] +name = "tzdata" +version = "2025.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/95/32/1a225d6164441be760d75c2c42e2780dc0873fe382da3e98a2e1e48361e5/tzdata-2025.2.tar.gz", hash = "sha256:b60a638fcc0daffadf82fe0f57e53d06bdec2f36c4df66280ae79bce6bd6f2b9", size = 196380, upload-time = "2025-03-23T13:54:43.652Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/23/c7abc0ca0a1526a0774eca151daeb8de62ec457e77262b66b359c3c7679e/tzdata-2025.2-py2.py3-none-any.whl", hash = "sha256:1a403fada01ff9221ca8044d701868fa132215d84beb92242d9acd2147f667a8", size = 347839, upload-time = "2025-03-23T13:54:41.845Z" }, +] + +[[package]] +name = "tzlocal" +version = "5.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, +] + [[package]] name = "uvicorn" version = "0.35.0"