from datetime import UTC from typing import Annotated, Literal from app.database.beatmap import ( Beatmap, BeatmapModel, ) from app.database.beatmapset import BeatmapsetModel from app.database.item_attempts_count import ItemAttemptsCount, ItemAttemptsCountModel from app.database.multiplayer_event import MultiplayerEvent, MultiplayerEventResp from app.database.playlists import Playlist, PlaylistModel from app.database.room import APIUploadedRoom, Room, RoomModel from app.database.room_participated_user import RoomParticipatedUser from app.database.score import Score from app.database.user import User, UserModel from app.dependencies.database import Database, Redis from app.dependencies.user import ClientUser, get_current_user from app.models.room import MatchType, RoomCategory, RoomStatus from app.service.room import create_playlist_room_from_api from app.utils import api_doc, utcnow from .router import router from fastapi import HTTPException, Path, Query, Security from sqlalchemy.sql.elements import ColumnElement from sqlmodel import col, exists, select from sqlmodel.ext.asyncio.session import AsyncSession @router.get( "/rooms", tags=["房间"], responses={ 200: api_doc( "房间列表", list[RoomModel], [ "current_playlist_item.beatmap.beatmapset", "difficulty_range", "host.country", "playlist_item_stats", "recent_participants", ], ) }, name="获取房间列表", description="获取房间列表。支持按状态/模式筛选", ) async def get_all_rooms( db: Database, current_user: Annotated[User, Security(get_current_user, scopes=["public"])], mode: Annotated[ Literal["open", "ended", "participated", "owned"] | None, Query( description=("房间模式:open 当前开放 / ended 已经结束 / participated 参与过 / owned 自己创建的房间"), ), ] = "open", category: Annotated[ RoomCategory, Query( description=("房间分类:NORMAL 普通歌单模式房间 / REALTIME 多人游戏房间 / DAILY_CHALLENGE 每日挑战"), ), ] = RoomCategory.NORMAL, status: Annotated[RoomStatus | None, Query(description="房间状态(可选)")] = None, ): resp_list = [] where_clauses: list[ColumnElement[bool]] = [col(Room.category) == category, col(Room.type) != MatchType.MATCHMAKING] now = utcnow() if status is not None: where_clauses.append(col(Room.status) == status) if mode == "open": where_clauses.extend( [ col(Room.status).in_([RoomStatus.IDLE, RoomStatus.PLAYING]), col(Room.starts_at).is_not(None), col(Room.ends_at).is_(None) if category == RoomCategory.REALTIME else col(Room.ends_at) > now, ] ) if mode == "participated": where_clauses.append( exists().where( col(RoomParticipatedUser.room_id) == Room.id, col(RoomParticipatedUser.user_id) == current_user.id, ) ) if mode == "owned": where_clauses.append(col(Room.host_id) == current_user.id) if mode == "ended": where_clauses.append((col(Room.ends_at).is_not(None)) & (col(Room.ends_at) < now.replace(tzinfo=UTC))) db_rooms = ( ( await db.exec( select(Room).where( *where_clauses, ) ) ) .unique() .all() ) for room in db_rooms: resp = await RoomModel.transform( room, includes=[ "current_playlist_item.beatmap.beatmapset", "difficulty_range", "host.country", "playlist_item_stats", "recent_participants", ], ) if category == RoomCategory.REALTIME: resp["category"] = RoomCategory.NORMAL resp_list.append(resp) return resp_list async def _participate_room(room_id: int, user_id: int, db_room: Room, session: AsyncSession, redis: Redis): participated_user = ( await session.exec( select(RoomParticipatedUser).where( RoomParticipatedUser.room_id == room_id, RoomParticipatedUser.user_id == user_id, ) ) ).first() if participated_user is None: participated_user = RoomParticipatedUser( room_id=room_id, user_id=user_id, joined_at=utcnow(), ) session.add(participated_user) else: participated_user.left_at = None participated_user.joined_at = utcnow() db_room.participant_count += 1 await redis.publish("chat:room:joined", f"{db_room.channel_id}:{user_id}") @router.post( "/rooms", tags=["房间"], name="创建房间", description="\n创建一个新的房间。", responses={ 200: api_doc( "创建的房间信息", RoomModel, Room.SHOW_RESPONSE_INCLUDES, ) }, ) async def create_room( db: Database, room: APIUploadedRoom, current_user: ClientUser, redis: Redis, ): if await current_user.is_restricted(db): raise HTTPException(status_code=403, detail="Your account is restricted from multiplayer.") user_id = current_user.id db_room = await create_playlist_room_from_api(db, room, user_id) await _participate_room(db_room.id, user_id, db_room, db, redis) await db.commit() await db.refresh(db_room) created_room = await RoomModel.transform(db_room, includes=Room.SHOW_RESPONSE_INCLUDES) return created_room @router.get( "/rooms/{room_id}", tags=["房间"], responses={ 200: api_doc( "房间详细信息", RoomModel, Room.SHOW_RESPONSE_INCLUDES, ) }, name="获取房间详情", description="获取指定房间详情。", ) async def get_room( db: Database, room_id: Annotated[int, Path(..., description="房间 ID")], current_user: Annotated[User, Security(get_current_user, scopes=["public"])], category: Annotated[ str, Query( description=("房间分类:NORMAL 普通歌单模式房间 / REALTIME 多人游戏房间 / DAILY_CHALLENGE 每日挑战 (可选)"), ), ] = "", ): db_room = (await db.exec(select(Room).where(Room.id == room_id))).first() if db_room is None: raise HTTPException(404, "Room not found") resp = await RoomModel.transform(db_room, includes=Room.SHOW_RESPONSE_INCLUDES, user=current_user) return resp @router.delete( "/rooms/{room_id}", tags=["房间"], name="结束房间", description="\n结束歌单模式房间。", ) async def delete_room( db: Database, room_id: Annotated[int, Path(..., description="房间 ID")], current_user: ClientUser, ): if await current_user.is_restricted(db): raise HTTPException(status_code=403, detail="Your account is restricted from multiplayer.") db_room = (await db.exec(select(Room).where(Room.id == room_id))).first() if db_room is None: raise HTTPException(404, "Room not found") else: db_room.ends_at = utcnow() await db.commit() return None @router.put( "/rooms/{room_id}/users/{user_id}", tags=["房间"], name="加入房间", description="\n加入指定歌单模式房间。", ) async def add_user_to_room( db: Database, room_id: Annotated[int, Path(..., description="房间 ID")], user_id: Annotated[int, Path(..., description="用户 ID")], redis: Redis, current_user: ClientUser, ): if await current_user.is_restricted(db): raise HTTPException(status_code=403, detail="Your account is restricted from multiplayer.") db_room = (await db.exec(select(Room).where(Room.id == room_id))).first() if db_room is not None: await _participate_room(room_id, user_id, db_room, db, redis) await db.commit() await db.refresh(db_room) resp = await RoomModel.transform(db_room, includes=Room.SHOW_RESPONSE_INCLUDES) return resp else: raise HTTPException(404, "room not found") @router.delete( "/rooms/{room_id}/users/{user_id}", tags=["房间"], name="离开房间", description="\n离开指定歌单模式房间。", ) async def remove_user_from_room( db: Database, room_id: Annotated[int, Path(..., description="房间 ID")], user_id: Annotated[int, Path(..., description="用户 ID")], current_user: ClientUser, redis: Redis, ): if await current_user.is_restricted(db): raise HTTPException(status_code=403, detail="Your account is restricted from multiplayer.") db_room = (await db.exec(select(Room).where(Room.id == room_id))).first() if db_room is not None: participated_user = ( await db.exec( select(RoomParticipatedUser).where( RoomParticipatedUser.room_id == room_id, RoomParticipatedUser.user_id == user_id, ) ) ).first() if participated_user is not None: participated_user.left_at = utcnow() if db_room.participant_count > 0: db_room.participant_count -= 1 await redis.publish("chat:room:left", f"{db_room.channel_id}:{user_id}") await db.commit() return None else: raise HTTPException(404, "Room not found") @router.get( "/rooms/{room_id}/leaderboard", tags=["房间"], name="获取房间排行榜", description="获取房间内累计得分排行榜。", responses={ 200: api_doc( "房间排行榜", { "leaderboard": list[ItemAttemptsCountModel], "user_score": ItemAttemptsCountModel | None, }, ["user.country", "position"], name="RoomLeaderboardResponse", ) }, ) async def get_room_leaderboard( db: Database, room_id: Annotated[int, Path(..., description="房间 ID")], current_user: Annotated[User, Security(get_current_user, scopes=["public"])], ): db_room = (await db.exec(select(Room).where(Room.id == room_id))).first() if db_room is None: raise HTTPException(404, "Room not found") aggs = await db.exec( select(ItemAttemptsCount) .where(ItemAttemptsCount.room_id == room_id) .order_by(col(ItemAttemptsCount.total_score).desc()) ) aggs_resp = [] user_agg = None for i, agg in enumerate(aggs): includes = ["user.country"] if agg.user_id == current_user.id: includes.append("position") resp = await ItemAttemptsCountModel.transform(agg, includes=includes) aggs_resp.append(resp) if agg.user_id == current_user.id: user_agg = resp return { "leaderboard": aggs_resp, "user_score": user_agg, } @router.get( "/rooms/{room_id}/events", tags=["房间"], name="获取房间事件", description="获取房间事件列表 (倒序,可按 after / before 进行范围截取)。", responses={ 200: api_doc( "房间事件", { "beatmaps": list[BeatmapModel], "beatmapsets": list[BeatmapsetModel], "current_playlist_item_id": int, "events": list[MultiplayerEventResp], "first_event_id": int, "last_event_id": int, "playlist_items": list[PlaylistModel], "room": RoomModel, "user": list[UserModel], }, ["country", "details", "scores"], name="RoomEventsResponse", ) }, ) async def get_room_events( db: Database, room_id: Annotated[int, Path(..., description="房间 ID")], current_user: Annotated[User, Security(get_current_user, scopes=["public"])], limit: Annotated[int, Query(ge=1, le=1000, description="返回条数 (1-1000)")] = 100, after: Annotated[int | None, Query(ge=0, description="仅包含大于该事件 ID 的事件")] = None, before: Annotated[int | None, Query(ge=0, description="仅包含小于该事件 ID 的事件")] = None, ): events = ( await db.exec( select(MultiplayerEvent) .where( MultiplayerEvent.room_id == room_id, col(MultiplayerEvent.id) > after if after is not None else True, col(MultiplayerEvent.id) < before if before is not None else True, ) .order_by(col(MultiplayerEvent.id).desc()) .limit(limit) ) ).all() user_ids = set() playlist_items = {} beatmap_ids = set() event_resps = [] first_event_id = 0 last_event_id = 0 current_playlist_item_id = 0 for event in events: event_resps.append(MultiplayerEventResp.from_db(event)) if event.user_id: user_ids.add(event.user_id) if event.playlist_item_id is not None and ( playitem := ( await db.exec( select(Playlist).where( Playlist.id == event.playlist_item_id, Playlist.room_id == room_id, ) ) ).first() ): current_playlist_item_id = playitem.id playlist_items[event.playlist_item_id] = playitem beatmap_ids.add(playitem.beatmap_id) scores = await db.exec( select(Score).where( Score.playlist_item_id == event.playlist_item_id, Score.room_id == room_id, ) ) for score in scores: user_ids.add(score.user_id) beatmap_ids.add(score.beatmap_id) first_event_id = min(first_event_id, event.id) last_event_id = max(last_event_id, event.id) room = (await db.exec(select(Room).where(Room.id == room_id))).first() if room is None: raise HTTPException(404, "Room not found") room_resp = await RoomModel.transform(room, includes=["current_playlist_item"]) if room.category == RoomCategory.REALTIME: current_playlist_item_id = (await Room.current_playlist_item(db, room))["id"] users = await db.exec(select(User).where(col(User.id).in_(user_ids))) user_resps = [await UserModel.transform(user, includes=["country"]) for user in users] beatmaps = await db.exec(select(Beatmap).where(col(Beatmap.id).in_(beatmap_ids))) beatmap_resps = [ await BeatmapModel.transform( beatmap, ) for beatmap in beatmaps ] beatmapsets = [] for beatmap in beatmaps: if beatmap.beatmapset_id not in beatmapsets: beatmapsets.append(beatmap.beatmapset) beatmapset_resps = [ await BeatmapsetModel.transform( beatmapset, ) for beatmapset in beatmapsets ] playlist_items_resps = [ await PlaylistModel.transform(item, includes=["details", "scores"]) for item in playlist_items.values() ] return { "beatmaps": beatmap_resps, "beatmapsets": beatmapset_resps, "current_playlist_item_id": current_playlist_item_id, "events": event_resps, "first_event_id": first_event_id, "last_event_id": last_event_id, "playlist_items": playlist_items_resps, "room": room_resp, "user": user_resps, }