feat(daily-challenge): simple implement

This commit is contained in:
MingxuanGame
2025-08-09 11:18:29 +00:00
parent 076b9d901b
commit 832a6fc95d
14 changed files with 323 additions and 42 deletions

View File

@@ -25,7 +25,7 @@ from .playlist_best_score import PlaylistBestScore
from .playlists import Playlist, PlaylistResp from .playlists import Playlist, PlaylistResp
from .pp_best_score import PPBestScore from .pp_best_score import PPBestScore
from .relationship import Relationship, RelationshipResp, RelationshipType from .relationship import Relationship, RelationshipResp, RelationshipType
from .room import Room, RoomResp from .room import APIUploadedRoom, Room, RoomResp
from .room_participated_user import RoomParticipatedUser from .room_participated_user import RoomParticipatedUser
from .score import ( from .score import (
MultiplayerScores, MultiplayerScores,
@@ -48,6 +48,7 @@ from .user_account_history import (
) )
__all__ = [ __all__ = [
"APIUploadedRoom",
"Beatmap", "Beatmap",
"Beatmapset", "Beatmapset",
"BeatmapsetResp", "BeatmapsetResp",

View File

@@ -138,6 +138,6 @@ class PlaylistResp(PlaylistBase):
) -> "PlaylistResp": ) -> "PlaylistResp":
data = playlist.model_dump() data = playlist.model_dump()
if "beatmap" in include: 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) resp = cls.model_validate(data)
return resp return resp

View File

@@ -160,3 +160,18 @@ class RoomResp(RoomBase):
participant_count=len(room.users), participant_count=len(room.users),
) )
return resp 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)

View File

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

View File

@@ -123,3 +123,7 @@ class OnlineStatus(IntEnum):
OFFLINE = 0 # 隐身 OFFLINE = 0 # 隐身
DO_NOT_DISTURB = 1 DO_NOT_DISTURB = 1
ONLINE = 2 ONLINE = 2
class DailyChallengeInfo(BaseModel):
room_id: int

View File

@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime
from typing import Literal from typing import Literal
from app.database.beatmap import Beatmap, BeatmapResp 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.lazer_user import User, UserResp
from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp
from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp from app.database.playlist_attempts import ItemAttemptsCount, ItemAttemptsResp
from app.database.playlists import Playlist, PlaylistBase, PlaylistResp from app.database.playlists import Playlist, PlaylistResp
from app.database.room import Room, RoomBase, RoomResp from app.database.room import APIUploadedRoom, Room, RoomResp
from app.database.room_participated_user import RoomParticipatedUser from app.database.room_participated_user import RoomParticipatedUser
from app.database.score import Score from app.database.score import Score
from app.dependencies.database import get_db, get_redis from app.dependencies.database import get_db, get_redis
from app.dependencies.user import get_current_user from app.dependencies.user import get_current_user
from app.models.room import RoomCategory, RoomStatus from app.models.room import RoomCategory, RoomStatus
from app.service.room import create_playlist_room_from_api
from app.signalr.hub import MultiplayerHubs from app.signalr.hub import MultiplayerHubs
from .api_router import router from .api_router import router
@@ -92,21 +93,6 @@ class APICreatedRoom(RoomResp):
error: str = "" 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( async def _participate_room(
room_id: int, user_id: int, db_room: Room, session: AsyncSession room_id: int, user_id: int, db_room: Room, session: AsyncSession
): ):
@@ -137,27 +123,11 @@ async def create_room(
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
): ):
# db_room = Room.from_resp(room)
await db.refresh(current_user)
user_id = current_user.id user_id = current_user.id
db_room = room.to_room() db_room = await create_playlist_room_from_api(db, room, user_id)
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)
await _participate_room(db_room.id, user_id, db_room, db) await _participate_room(db_room.id, user_id, db_room, db)
# await db.commit()
for item in room.playlist: # await db.refresh(db_room)
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)
created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db)) created_room = APICreatedRoom.model_validate(await RoomResp.from_db(db_room, db))
created_room.error = "" created_room.error = ""
return created_room return created_room

View File

@@ -32,6 +32,7 @@ from app.dependencies.fetcher import get_fetcher
from app.dependencies.user import get_current_user from app.dependencies.user import get_current_user
from app.fetcher import Fetcher from app.fetcher import Fetcher
from app.models.beatmap import BeatmapRankStatus from app.models.beatmap import BeatmapRankStatus
from app.models.room import RoomCategory
from app.models.score import ( from app.models.score import (
INT_TO_MODE, INT_TO_MODE,
GameMode, GameMode,
@@ -402,6 +403,10 @@ async def index_playlist_scores(
current_user: User = Depends(get_current_user), current_user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_db), 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) limit = clamp(limit, 1, 50)
scores = ( scores = (
@@ -426,6 +431,12 @@ async def index_playlist_scores(
score.position = await get_position(room_id, playlist_id, score.id, session) score.position = await get_position(room_id, playlist_id, score.id, session)
if score.user_id == current_user.id: if score.user_id == current_user.id:
user_score = score 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( resp = IndexedScoreResp(
scores=score_resp, scores=score_resp,
user_score=user_score, user_score=user_score,

10
app/service/__init__.py Normal file
View File

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

View File

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

78
app/service/room.py Normal file
View File

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

View File

@@ -6,12 +6,19 @@ from datetime import UTC, datetime
from typing import override from typing import override
from app.database import Relationship, RelationshipType, User from app.database import Relationship, RelationshipType, User
from app.database.room import Room
from app.dependencies.database import engine, get_redis 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 .hub import Client, Hub
from sqlmodel import select from sqlmodel import col, select
from sqlmodel.ext.asyncio.session import AsyncSession from sqlmodel.ext.asyncio.session import AsyncSession
ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers" ONLINE_PRESENCE_WATCHERS_GROUP = "metadata:online-presence-watchers"
@@ -107,6 +114,23 @@ class MetadataHub(Hub[MetadataClientState]):
) )
) )
await asyncio.gather(*tasks) 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() redis = get_redis()
await redis.set(f"metadata:online:{user_id}", "") await redis.set(f"metadata:online:{user_id}", "")

View File

@@ -6,6 +6,7 @@ from datetime import datetime
from app.config import settings from app.config import settings
from app.dependencies.database import create_tables, engine, redis_client from app.dependencies.database import create_tables, engine, redis_client
from app.dependencies.fetcher import get_fetcher from app.dependencies.fetcher import get_fetcher
from app.dependencies.scheduler import init_scheduler, stop_scheduler
from app.router import ( from app.router import (
api_router, api_router,
auth_router, auth_router,
@@ -21,8 +22,10 @@ async def lifespan(app: FastAPI):
# on startup # on startup
await create_tables() await create_tables()
await get_fetcher() # 初始化 fetcher await get_fetcher() # 初始化 fetcher
init_scheduler()
# on shutdown # on shutdown
yield yield
stop_scheduler()
await engine.dispose() await engine.dispose()
await redis_client.aclose() await redis_client.aclose()

View File

@@ -7,6 +7,7 @@ requires-python = ">=3.12"
dependencies = [ dependencies = [
"aiomysql>=0.2.0", "aiomysql>=0.2.0",
"alembic>=1.12.1", "alembic>=1.12.1",
"apscheduler>=3.11.0",
"bcrypt>=4.1.2", "bcrypt>=4.1.2",
"cryptography>=41.0.7", "cryptography>=41.0.7",
"fastapi>=0.104.1", "fastapi>=0.104.1",

37
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1 version = 1
revision = 2 revision = 3
requires-python = ">=3.12" requires-python = ">=3.12"
[manifest] [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" }, { 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]] [[package]]
name = "bcrypt" name = "bcrypt"
version = "4.3.0" version = "4.3.0"
@@ -493,6 +505,7 @@ source = { virtual = "." }
dependencies = [ dependencies = [
{ name = "aiomysql" }, { name = "aiomysql" },
{ name = "alembic" }, { name = "alembic" },
{ name = "apscheduler" },
{ name = "bcrypt" }, { name = "bcrypt" },
{ name = "cryptography" }, { name = "cryptography" },
{ name = "fastapi" }, { name = "fastapi" },
@@ -522,6 +535,7 @@ dev = [
requires-dist = [ requires-dist = [
{ name = "aiomysql", specifier = ">=0.2.0" }, { name = "aiomysql", specifier = ">=0.2.0" },
{ name = "alembic", specifier = ">=1.12.1" }, { name = "alembic", specifier = ">=1.12.1" },
{ name = "apscheduler", specifier = ">=3.11.0" },
{ name = "bcrypt", specifier = ">=4.1.2" }, { name = "bcrypt", specifier = ">=4.1.2" },
{ name = "cryptography", specifier = ">=41.0.7" }, { name = "cryptography", specifier = ">=41.0.7" },
{ name = "fastapi", specifier = ">=0.104.1" }, { 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" }, { 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]] [[package]]
name = "uvicorn" name = "uvicorn"
version = "0.35.0" version = "0.35.0"