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

View File

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

View File

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

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 # 隐身
DO_NOT_DISTURB = 1
ONLINE = 2
class DailyChallengeInfo(BaseModel):
room_id: int

View File

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

View File

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

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 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}", "")

View File

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

View File

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

37
uv.lock generated
View File

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