""" 双人对练房间 API 功能: - 房间创建、加入、退出 - 房间状态查询 - 实时消息推送(SSE) - 消息发送 """ import asyncio import logging from datetime import datetime from typing import Optional, List from fastapi import APIRouter, Depends, HTTPException, Query from fastapi.responses import StreamingResponse from sqlalchemy.ext.asyncio import AsyncSession from pydantic import BaseModel, Field from app.core.deps import get_db, get_current_user from app.models.user import User from app.services.practice_room_service import PracticeRoomService logger = logging.getLogger(__name__) router = APIRouter(prefix="/practice/rooms", tags=["双人对练房间"]) # ==================== Schema 定义 ==================== class CreateRoomRequest(BaseModel): """创建房间请求""" scene_id: Optional[int] = Field(None, description="场景ID") scene_name: Optional[str] = Field(None, description="场景名称") scene_type: Optional[str] = Field(None, description="场景类型") scene_background: Optional[str] = Field(None, description="场景背景") role_a_name: str = Field("销售顾问", description="角色A名称") role_b_name: str = Field("顾客", description="角色B名称") role_a_description: Optional[str] = Field(None, description="角色A描述") role_b_description: Optional[str] = Field(None, description="角色B描述") host_role: str = Field("A", description="房主选择的角色(A/B)") room_name: Optional[str] = Field(None, description="房间名称") class JoinRoomRequest(BaseModel): """加入房间请求""" room_code: str = Field(..., description="房间码") class SendMessageRequest(BaseModel): """发送消息请求""" content: str = Field(..., description="消息内容") class RoomResponse(BaseModel): """房间响应""" id: int room_code: str room_name: Optional[str] scene_id: Optional[int] scene_name: Optional[str] scene_type: Optional[str] role_a_name: str role_b_name: str host_user_id: int guest_user_id: Optional[int] host_role: str status: str created_at: datetime started_at: Optional[datetime] ended_at: Optional[datetime] duration_seconds: int total_turns: int class Config: from_attributes = True class RoomDetailResponse(BaseModel): """房间详情响应(包含用户信息)""" room: RoomResponse host_user: Optional[dict] guest_user: Optional[dict] host_role_name: Optional[str] guest_role_name: Optional[str] my_role: Optional[str] my_role_name: Optional[str] class MessageResponse(BaseModel): """消息响应""" id: int room_id: int user_id: Optional[int] message_type: str content: Optional[str] role_name: Optional[str] sequence: int created_at: datetime class Config: from_attributes = True # ==================== API 端点 ==================== @router.post("", summary="创建房间") async def create_room( request: CreateRoomRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 创建双人对练房间 - 返回房间码,可分享给对方加入 - 房主可选择扮演角色A或B """ service = PracticeRoomService(db) try: room = await service.create_room( host_user_id=current_user.id, scene_id=request.scene_id, scene_name=request.scene_name, scene_type=request.scene_type, scene_background=request.scene_background, role_a_name=request.role_a_name, role_b_name=request.role_b_name, role_a_description=request.role_a_description, role_b_description=request.role_b_description, host_role=request.host_role, room_name=request.room_name ) return { "code": 200, "message": "房间创建成功", "data": { "room_code": room.room_code, "room_id": room.id, "room_name": room.room_name, "my_role": room.host_role, "my_role_name": room.get_role_name(room.host_role) } } except Exception as e: logger.error(f"创建房间失败: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/join", summary="加入房间") async def join_room( request: JoinRoomRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 通过房间码加入房间 - 房间最多容纳2人 - 加入后自动分配对方角色 """ service = PracticeRoomService(db) try: room = await service.join_room( room_code=request.room_code.upper(), user_id=current_user.id ) my_role = room.get_user_role(current_user.id) return { "code": 200, "message": "加入房间成功", "data": { "room_code": room.room_code, "room_id": room.id, "room_name": room.room_name, "status": room.status, "my_role": my_role, "my_role_name": room.get_role_name(my_role) } } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"加入房间失败: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.get("/{room_code}", summary="获取房间详情") async def get_room( room_code: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 获取房间详情,包含参与者信息 """ service = PracticeRoomService(db) room_data = await service.get_room_with_users(room_code.upper()) if not room_data: raise HTTPException(status_code=404, detail="房间不存在") room = room_data["room"] host_user = room_data["host_user"] guest_user = room_data["guest_user"] my_role = room.get_user_role(current_user.id) return { "code": 200, "message": "success", "data": { "room": { "id": room.id, "room_code": room.room_code, "room_name": room.room_name, "scene_id": room.scene_id, "scene_name": room.scene_name, "scene_type": room.scene_type, "scene_background": room.scene_background, "role_a_name": room.role_a_name, "role_b_name": room.role_b_name, "role_a_description": room.role_a_description, "role_b_description": room.role_b_description, "host_role": room.host_role, "status": room.status, "created_at": room.created_at.isoformat() if room.created_at else None, "started_at": room.started_at.isoformat() if room.started_at else None, "ended_at": room.ended_at.isoformat() if room.ended_at else None, "duration_seconds": room.duration_seconds, "total_turns": room.total_turns, "role_a_turns": room.role_a_turns, "role_b_turns": room.role_b_turns }, "host_user": { "id": host_user.id, "username": host_user.username, "full_name": host_user.full_name, "avatar_url": host_user.avatar_url } if host_user else None, "guest_user": { "id": guest_user.id, "username": guest_user.username, "full_name": guest_user.full_name, "avatar_url": guest_user.avatar_url } if guest_user else None, "host_role_name": room_data["host_role_name"], "guest_role_name": room_data["guest_role_name"], "my_role": my_role, "my_role_name": room.get_role_name(my_role) if my_role else None, "is_host": current_user.id == room.host_user_id } } @router.post("/{room_code}/start", summary="开始对练") async def start_practice( room_code: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 开始对练(仅房主可操作) - 需要房间状态为 ready(双方都已加入) """ service = PracticeRoomService(db) try: room = await service.start_practice( room_code=room_code.upper(), user_id=current_user.id ) return { "code": 200, "message": "对练已开始", "data": { "room_code": room.room_code, "status": room.status, "started_at": room.started_at.isoformat() if room.started_at else None } } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"开始对练失败: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/{room_code}/end", summary="结束对练") async def end_practice( room_code: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 结束对练 - 任意参与者都可结束 - 结束后可查看分析报告 """ service = PracticeRoomService(db) try: room = await service.end_practice( room_code=room_code.upper(), user_id=current_user.id ) return { "code": 200, "message": "对练已结束", "data": { "room_code": room.room_code, "room_id": room.id, "status": room.status, "duration_seconds": room.duration_seconds, "total_turns": room.total_turns } } except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) except Exception as e: logger.error(f"结束对练失败: {e}") raise HTTPException(status_code=500, detail=str(e)) @router.post("/{room_code}/leave", summary="离开房间") async def leave_room( room_code: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 离开房间 - 房主离开则关闭房间 - 嘉宾离开则房间回到等待状态 """ service = PracticeRoomService(db) success = await service.leave_room( room_code=room_code.upper(), user_id=current_user.id ) if not success: raise HTTPException(status_code=400, detail="离开房间失败") return { "code": 200, "message": "已离开房间" } @router.post("/{room_code}/message", summary="发送消息") async def send_message( room_code: str, request: SendMessageRequest, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 发送聊天消息 - 仅对练中状态可发送 """ service = PracticeRoomService(db) # 获取房间 room = await service.get_room_by_code(room_code.upper()) if not room: raise HTTPException(status_code=404, detail="房间不存在") if room.status != "practicing": raise HTTPException(status_code=400, detail="对练未在进行中") # 获取用户角色 role_name = room.get_user_role_name(current_user.id) if not role_name: raise HTTPException(status_code=403, detail="您不是房间参与者") # 发送消息 message = await service.send_message( room_id=room.id, user_id=current_user.id, content=request.content, role_name=role_name ) return { "code": 200, "message": "发送成功", "data": message.to_dict() } @router.get("/{room_code}/messages", summary="获取消息列表") async def get_messages( room_code: str, since_sequence: int = Query(0, description="从该序号之后开始获取"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 获取房间消息列表 - 用于轮询获取新消息 - 传入 since_sequence 只获取新消息 """ service = PracticeRoomService(db) room = await service.get_room_by_code(room_code.upper()) if not room: raise HTTPException(status_code=404, detail="房间不存在") messages = await service.get_messages( room_id=room.id, since_sequence=since_sequence ) return { "code": 200, "message": "success", "data": { "messages": [msg.to_dict() for msg in messages], "room_status": room.status, "last_sequence": messages[-1].sequence if messages else since_sequence } } @router.get("/{room_code}/stream", summary="消息流(SSE)") async def message_stream( room_code: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 实时消息流(Server-Sent Events) - 用于实时接收房间消息 - 前端使用 EventSource 连接 """ service = PracticeRoomService(db) room = await service.get_room_by_code(room_code.upper()) if not room: raise HTTPException(status_code=404, detail="房间不存在") async def event_generator(): last_sequence = 0 while True: # 获取新消息 messages = await service.get_messages( room_id=room.id, since_sequence=last_sequence ) for msg in messages: yield f"event: message\ndata: {msg.to_dict()}\n\n" last_sequence = msg.sequence # 检查房间状态 room_status = await service.get_room_by_id(room.id) if room_status and room_status.status in ["completed", "canceled"]: yield f"event: room_closed\ndata: {{\"status\": \"{room_status.status}\"}}\n\n" break # 等待一段时间再轮询 await asyncio.sleep(0.5) return StreamingResponse( event_generator(), media_type="text/event-stream", headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Accel-Buffering": "no" } ) @router.get("/{room_code}/report", summary="获取或生成对练报告") async def get_practice_report( room_code: str, db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 获取双人对练报告 - 如果报告不存在,则调用 AI 生成 - 返回双方表现评估 """ from app.services.ai.duo_practice_analysis_service import DuoPracticeAnalysisService service = PracticeRoomService(db) # 获取房间详情 room_data = await service.get_room_with_users(room_code.upper()) if not room_data: raise HTTPException(status_code=404, detail="房间不存在") room = room_data["room"] # 检查房间是否已完成 if room.status != "completed": raise HTTPException(status_code=400, detail="对练尚未结束,无法生成报告") # 获取所有消息 messages = await service.get_all_messages(room.id) chat_messages = [m for m in messages if m.message_type == "chat"] if not chat_messages: raise HTTPException(status_code=400, detail="暂无对话记录") # 准备对话历史 dialogue_history = [ { "sequence": m.sequence, "role_name": m.role_name, "content": m.content, "user_id": m.user_id } for m in chat_messages ] # 获取用户名称 host_name = room_data["host_user"].full_name if room_data["host_user"] else "用户A" guest_name = room_data["guest_user"].full_name if room_data["guest_user"] else "用户B" # 确定角色对应的用户名 if room.host_role == "A": user_a_name = host_name user_b_name = guest_name else: user_a_name = guest_name user_b_name = host_name # 调用 AI 分析 analysis_service = DuoPracticeAnalysisService() result = await analysis_service.analyze( scene_name=room.scene_name or "双人对练", scene_background=room.scene_background or "", role_a_name=room.role_a_name, role_b_name=room.role_b_name, role_a_description=room.role_a_description or f"扮演{room.role_a_name}", role_b_description=room.role_b_description or f"扮演{room.role_b_name}", user_a_name=user_a_name, user_b_name=user_b_name, dialogue_history=dialogue_history, duration_seconds=room.duration_seconds, total_turns=room.total_turns, db=db ) return { "code": 200, "message": "success", "data": { "room": { "id": room.id, "room_code": room.room_code, "room_name": room.room_name, "scene_name": room.scene_name, "duration_seconds": room.duration_seconds, "total_turns": room.total_turns }, "analysis": analysis_service.result_to_dict(result) } } @router.get("", summary="获取我的房间列表") async def get_my_rooms( status: Optional[str] = Query(None, description="按状态筛选"), limit: int = Query(20, description="数量限制"), db: AsyncSession = Depends(get_db), current_user: User = Depends(get_current_user) ): """ 获取当前用户的房间列表 """ service = PracticeRoomService(db) rooms = await service.get_user_rooms( user_id=current_user.id, status=status, limit=limit ) return { "code": 200, "message": "success", "data": { "rooms": [ { "id": room.id, "room_code": room.room_code, "room_name": room.room_name, "scene_name": room.scene_name, "status": room.status, "is_host": room.host_user_id == current_user.id, "created_at": room.created_at.isoformat() if room.created_at else None, "duration_seconds": room.duration_seconds, "total_turns": room.total_turns } for room in rooms ] } }