""" 双人对练房间服务 功能: - 房间创建、加入、退出 - 房间状态管理 - 消息广播 - 对练结束处理 """ import logging import random import string from datetime import datetime from typing import Optional, List, Dict, Any from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select, update, and_ from sqlalchemy.orm import selectinload from app.models.practice_room import PracticeRoom, PracticeRoomMessage from app.models.practice import PracticeDialogue, PracticeSession from app.models.user import User logger = logging.getLogger(__name__) class PracticeRoomService: """双人对练房间服务""" # 房间状态常量 STATUS_WAITING = "waiting" # 等待加入 STATUS_READY = "ready" # 准备就绪 STATUS_PRACTICING = "practicing" # 对练中 STATUS_COMPLETED = "completed" # 已完成 STATUS_CANCELED = "canceled" # 已取消 # 消息类型常量 MSG_TYPE_CHAT = "chat" # 聊天消息 MSG_TYPE_SYSTEM = "system" # 系统消息 MSG_TYPE_JOIN = "join" # 加入消息 MSG_TYPE_LEAVE = "leave" # 离开消息 MSG_TYPE_START = "start" # 开始消息 MSG_TYPE_END = "end" # 结束消息 def __init__(self, db: AsyncSession): self.db = db # ==================== 房间管理 ==================== async def create_room( self, host_user_id: int, scene_id: Optional[int] = None, scene_name: Optional[str] = None, scene_type: Optional[str] = None, scene_background: Optional[str] = None, role_a_name: str = "销售顾问", role_b_name: str = "顾客", role_a_description: Optional[str] = None, role_b_description: Optional[str] = None, host_role: str = "A", room_name: Optional[str] = None ) -> PracticeRoom: """ 创建对练房间 Args: host_user_id: 房主用户ID scene_id: 场景ID(可选) scene_name: 场景名称 scene_type: 场景类型 scene_background: 场景背景 role_a_name: 角色A名称 role_b_name: 角色B名称 role_a_description: 角色A描述 role_b_description: 角色B描述 host_role: 房主选择的角色(A或B) room_name: 房间名称 Returns: PracticeRoom: 创建的房间对象 """ # 生成唯一的6位房间码 room_code = await self._generate_unique_room_code() # 创建房间 room = PracticeRoom( room_code=room_code, room_name=room_name or f"{scene_name or '双人对练'}房间", scene_id=scene_id, scene_name=scene_name, scene_type=scene_type, scene_background=scene_background, role_a_name=role_a_name, role_b_name=role_b_name, role_a_description=role_a_description, role_b_description=role_b_description, host_user_id=host_user_id, host_role=host_role, status=self.STATUS_WAITING ) self.db.add(room) await self.db.commit() await self.db.refresh(room) logger.info(f"创建房间成功: room_code={room_code}, host_user_id={host_user_id}") return room async def join_room( self, room_code: str, user_id: int ) -> PracticeRoom: """ 加入房间 Args: room_code: 房间码 user_id: 用户ID Returns: PracticeRoom: 房间对象 Raises: ValueError: 房间不存在、已满或状态不允许加入 """ # 查询房间 room = await self.get_room_by_code(room_code) if not room: raise ValueError("房间不存在或已过期") # 检查是否是房主(房主重新进入) if room.host_user_id == user_id: return room # 检查房间状态 if room.status not in [self.STATUS_WAITING, self.STATUS_READY]: raise ValueError("房间已开始对练或已结束,无法加入") # 检查是否已满 if room.guest_user_id and room.guest_user_id != user_id: raise ValueError("房间已满") # 加入房间 room.guest_user_id = user_id room.status = self.STATUS_READY await self.db.commit() await self.db.refresh(room) # 发送系统消息 await self._add_system_message(room.id, f"用户已加入房间", self.MSG_TYPE_JOIN, user_id) logger.info(f"用户加入房间: room_code={room_code}, user_id={user_id}") return room async def leave_room( self, room_code: str, user_id: int ) -> bool: """ 离开房间 Args: room_code: 房间码 user_id: 用户ID Returns: bool: 是否成功离开 """ room = await self.get_room_by_code(room_code) if not room: return False # 如果是房主离开,取消房间 if room.host_user_id == user_id: room.status = self.STATUS_CANCELED await self._add_system_message(room.id, "房主离开,房间已关闭", self.MSG_TYPE_LEAVE, user_id) # 如果是嘉宾离开 elif room.guest_user_id == user_id: room.guest_user_id = None room.status = self.STATUS_WAITING await self._add_system_message(room.id, "对方已离开房间", self.MSG_TYPE_LEAVE, user_id) else: return False await self.db.commit() logger.info(f"用户离开房间: room_code={room_code}, user_id={user_id}") return True async def start_practice( self, room_code: str, user_id: int ) -> PracticeRoom: """ 开始对练(仅房主可操作) Args: room_code: 房间码 user_id: 用户ID(必须是房主) Returns: PracticeRoom: 房间对象 """ room = await self.get_room_by_code(room_code) if not room: raise ValueError("房间不存在") if room.host_user_id != user_id: raise ValueError("只有房主可以开始对练") if room.status != self.STATUS_READY: raise ValueError("房间未就绪,请等待对方加入") room.status = self.STATUS_PRACTICING room.started_at = datetime.now() await self.db.commit() await self.db.refresh(room) # 发送开始消息 await self._add_system_message(room.id, "对练开始!", self.MSG_TYPE_START) logger.info(f"对练开始: room_code={room_code}") return room async def end_practice( self, room_code: str, user_id: int ) -> PracticeRoom: """ 结束对练 Args: room_code: 房间码 user_id: 用户ID Returns: PracticeRoom: 房间对象 """ room = await self.get_room_by_code(room_code) if not room: raise ValueError("房间不存在") if room.status != self.STATUS_PRACTICING: raise ValueError("对练未在进行中") # 计算时长 if room.started_at: duration = (datetime.now() - room.started_at).total_seconds() room.duration_seconds = int(duration) room.status = self.STATUS_COMPLETED room.ended_at = datetime.now() await self.db.commit() await self.db.refresh(room) # 发送结束消息 await self._add_system_message(room.id, "对练结束!", self.MSG_TYPE_END) logger.info(f"对练结束: room_code={room_code}, duration={room.duration_seconds}s") return room # ==================== 消息管理 ==================== async def send_message( self, room_id: int, user_id: int, content: Optional[str], role_name: Optional[str] = None, message_type: Optional[str] = None, extra_data: Optional[dict] = None ) -> PracticeRoomMessage: """ 发送聊天消息或信令消息 Args: room_id: 房间ID user_id: 发送者ID content: 消息内容 role_name: 角色名称 message_type: 消息类型(默认为 chat) extra_data: 额外数据(用于 WebRTC 信令等) Returns: PracticeRoomMessage: 消息对象 """ import json # 获取当前消息序号 sequence = await self._get_next_sequence(room_id) # 如果是信令消息,将 extra_data 序列化到 content 中 actual_content = content if extra_data and not content: actual_content = json.dumps(extra_data) message = PracticeRoomMessage( room_id=room_id, user_id=user_id, message_type=message_type or self.MSG_TYPE_CHAT, content=actual_content, role_name=role_name, sequence=sequence ) self.db.add(message) # 只有聊天消息才更新房间统计 if (message_type or self.MSG_TYPE_CHAT) == self.MSG_TYPE_CHAT: room = await self.get_room_by_id(room_id) if room: room.total_turns += 1 user_role = room.get_user_role(user_id) if user_role == "A": room.role_a_turns += 1 elif user_role == "B": room.role_b_turns += 1 await self.db.commit() await self.db.refresh(message) return message async def get_messages( self, room_id: int, since_sequence: int = 0, limit: int = 100 ) -> List[PracticeRoomMessage]: """ 获取房间消息(用于SSE轮询) Args: room_id: 房间ID since_sequence: 从该序号之后开始获取 limit: 最大数量 Returns: List[PracticeRoomMessage]: 消息列表 """ result = await self.db.execute( select(PracticeRoomMessage) .where( and_( PracticeRoomMessage.room_id == room_id, PracticeRoomMessage.sequence > since_sequence ) ) .order_by(PracticeRoomMessage.sequence) .limit(limit) ) return list(result.scalars().all()) async def get_all_messages(self, room_id: int) -> List[PracticeRoomMessage]: """ 获取房间所有消息 Args: room_id: 房间ID Returns: List[PracticeRoomMessage]: 消息列表 """ result = await self.db.execute( select(PracticeRoomMessage) .where(PracticeRoomMessage.room_id == room_id) .order_by(PracticeRoomMessage.sequence) ) return list(result.scalars().all()) # ==================== 查询方法 ==================== async def get_room_by_code(self, room_code: str) -> Optional[PracticeRoom]: """根据房间码获取房间""" result = await self.db.execute( select(PracticeRoom).where( and_( PracticeRoom.room_code == room_code, PracticeRoom.is_deleted == False ) ) ) return result.scalar_one_or_none() async def get_room_by_id(self, room_id: int) -> Optional[PracticeRoom]: """根据ID获取房间""" result = await self.db.execute( select(PracticeRoom).where( and_( PracticeRoom.id == room_id, PracticeRoom.is_deleted == False ) ) ) return result.scalar_one_or_none() async def get_user_rooms( self, user_id: int, status: Optional[str] = None, limit: int = 20 ) -> List[PracticeRoom]: """获取用户的房间列表""" query = select(PracticeRoom).where( and_( (PracticeRoom.host_user_id == user_id) | (PracticeRoom.guest_user_id == user_id), PracticeRoom.is_deleted == False ) ) if status: query = query.where(PracticeRoom.status == status) query = query.order_by(PracticeRoom.created_at.desc()).limit(limit) result = await self.db.execute(query) return list(result.scalars().all()) async def get_room_with_users(self, room_code: str) -> Optional[Dict[str, Any]]: """获取房间详情(包含用户信息)""" room = await self.get_room_by_code(room_code) if not room: return None # 获取用户信息 host_user = None guest_user = None if room.host_user_id: result = await self.db.execute( select(User).where(User.id == room.host_user_id) ) host_user = result.scalar_one_or_none() if room.guest_user_id: result = await self.db.execute( select(User).where(User.id == room.guest_user_id) ) guest_user = result.scalar_one_or_none() return { "room": room, "host_user": host_user, "guest_user": guest_user, "host_role_name": room.get_role_name(room.host_role), "guest_role_name": room.get_role_name("B" if room.host_role == "A" else "A") if guest_user else None } # ==================== 辅助方法 ==================== async def _generate_unique_room_code(self) -> str: """生成唯一的6位房间码""" for _ in range(10): # 最多尝试10次 code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) # 排除容易混淆的字符 code = code.replace('0', 'X').replace('O', 'Y').replace('I', 'Z').replace('1', 'W') # 检查是否已存在 existing = await self.get_room_by_code(code) if not existing: return code raise ValueError("无法生成唯一房间码,请稍后重试") async def _get_next_sequence(self, room_id: int) -> int: """获取下一个消息序号""" result = await self.db.execute( select(PracticeRoomMessage.sequence) .where(PracticeRoomMessage.room_id == room_id) .order_by(PracticeRoomMessage.sequence.desc()) .limit(1) ) last_seq = result.scalar_one_or_none() return (last_seq or 0) + 1 async def _add_system_message( self, room_id: int, content: str, msg_type: str, user_id: Optional[int] = None ) -> PracticeRoomMessage: """添加系统消息""" sequence = await self._get_next_sequence(room_id) message = PracticeRoomMessage( room_id=room_id, user_id=user_id, message_type=msg_type, content=content, sequence=sequence ) self.db.add(message) await self.db.commit() await self.db.refresh(message) return message # ==================== 报告生成 ==================== async def generate_report(self, room_id: int) -> Dict[str, Any]: """ 生成对练报告 Args: room_id: 房间ID Returns: 包含房间信息、对话分析、表现评估的完整报告 """ # 获取房间信息 room = await self.get_room(room_id) if not room: return None # 获取房间消息 messages = await self.get_messages(room_id) chat_messages = [m for m in messages if m.message_type == self.MSG_TYPE_CHAT] # 获取用户信息 host_user = await self._get_user(room.host_user_id) guest_user = await self._get_user(room.guest_user_id) if room.guest_user_id else None # 分析对话 analysis = self._analyze_conversation(room, chat_messages) # 构建报告 report = { "room": { "id": room.id, "room_code": room.room_code, "scene_name": room.scene_name or "自由对练", "scene_type": room.scene_type, "scene_background": room.scene_background, "role_a_name": room.role_a_name, "role_b_name": room.role_b_name, "status": room.status, "duration_seconds": room.duration_seconds or 0, "total_turns": room.total_turns or 0, "started_at": room.started_at.isoformat() if room.started_at else None, "ended_at": room.ended_at.isoformat() if room.ended_at else None, }, "participants": { "host": { "user_id": room.host_user_id, "username": host_user.username if host_user else "未知用户", "role": room.host_role, "role_name": room.role_a_name if room.host_role == "A" else room.role_b_name, }, "guest": { "user_id": room.guest_user_id, "username": guest_user.username if guest_user else "未加入", "role": "B" if room.host_role == "A" else "A", "role_name": room.role_b_name if room.host_role == "A" else room.role_a_name, } if room.guest_user_id else None, }, "analysis": analysis, "messages": [ { "id": m.id, "user_id": m.user_id, "content": m.content, "role_name": m.role_name, "sequence": m.sequence, "created_at": m.created_at.isoformat() if m.created_at else None, } for m in chat_messages ], } return report def _analyze_conversation( self, room: PracticeRoom, messages: List[PracticeRoomMessage] ) -> Dict[str, Any]: """ 分析对话内容 返回对话分析结果,包括: - 对话统计 - 参与度分析 - 对话质量评估 - 改进建议 """ if not messages: return { "summary": "暂无对话记录", "statistics": { "total_messages": 0, "role_a_messages": 0, "role_b_messages": 0, "avg_message_length": 0, "conversation_duration": room.duration_seconds or 0, }, "participation": { "role_a_ratio": 0, "role_b_ratio": 0, "balance_score": 0, }, "quality": { "overall_score": 0, "engagement_score": 0, "response_quality": 0, }, "suggestions": ["尚无足够的对话数据进行分析"], } # 统计消息 role_a_messages = [m for m in messages if m.role_name == room.role_a_name] role_b_messages = [m for m in messages if m.role_name == room.role_b_name] total_messages = len(messages) role_a_count = len(role_a_messages) role_b_count = len(role_b_messages) # 计算平均消息长度 total_length = sum(len(m.content or "") for m in messages) avg_length = round(total_length / total_messages) if total_messages > 0 else 0 # 计算参与度 role_a_ratio = round(role_a_count / total_messages * 100, 1) if total_messages > 0 else 0 role_b_ratio = round(role_b_count / total_messages * 100, 1) if total_messages > 0 else 0 # 平衡度评分(越接近50:50越高) balance_score = round(100 - abs(role_a_ratio - 50) * 2, 1) balance_score = max(0, min(100, balance_score)) # 质量评估(基于简单规则) engagement_score = min(100, total_messages * 5) # 每条消息5分,最高100 # 响应质量(基于平均消息长度) response_quality = min(100, avg_length * 2) # 每字2分,最高100 # 综合评分 overall_score = round((balance_score + engagement_score + response_quality) / 3, 1) # 生成建议 suggestions = [] if balance_score < 70: suggestions.append(f"对话参与度不均衡,建议{room.role_a_name if role_a_ratio < 50 else room.role_b_name}增加互动") if avg_length < 20: suggestions.append("平均消息较短,建议增加更详细的表达") if total_messages < 10: suggestions.append("对话轮次较少,建议增加更多交流") if overall_score >= 80: suggestions.append("对话质量良好,继续保持!") elif overall_score < 60: suggestions.append("建议增加对话深度和互动频率") if not suggestions: suggestions.append("表现正常,可以尝试更复杂的场景练习") return { "summary": f"本次对练共进行 {total_messages} 轮对话,时长 {room.duration_seconds or 0} 秒", "statistics": { "total_messages": total_messages, "role_a_messages": role_a_count, "role_b_messages": role_b_count, "avg_message_length": avg_length, "conversation_duration": room.duration_seconds or 0, }, "participation": { "role_a_ratio": role_a_ratio, "role_b_ratio": role_b_ratio, "balance_score": balance_score, }, "quality": { "overall_score": overall_score, "engagement_score": engagement_score, "response_quality": response_quality, }, "suggestions": suggestions, } async def _get_user(self, user_id: Optional[int]) -> Optional[User]: """获取用户信息""" if not user_id: return None result = await self.db.execute( select(User).where(User.id == user_id) ) return result.scalar_one_or_none() # ==================== 便捷函数 ==================== async def create_practice_room( db: AsyncSession, host_user_id: int, **kwargs ) -> PracticeRoom: """便捷函数:创建房间""" service = PracticeRoomService(db) return await service.create_room(host_user_id, **kwargs) async def join_practice_room( db: AsyncSession, room_code: str, user_id: int ) -> PracticeRoom: """便捷函数:加入房间""" service = PracticeRoomService(db) return await service.join_room(room_code, user_id)