Files
012-kaopeilian/backend/app/api/v1/practice_room.py
yuliang_guo c5d460b413
Some checks failed
continuous-integration/drone/push Build is failing
feat: 添加双人对练语音通话功能
- 后端:扩展 SSE 支持 WebRTC 信令消息转发
- 前端:创建 WebRTC 连接管理模块 (webrtc.ts)
- 前端:创建 useVoiceCall 组合式函数
- 前端:在对练房间添加语音通话 UI
- 集成 Web Speech API 实现语音转文字
2026-01-28 15:45:47 +08:00

679 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
双人对练房间 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="消息内容")
source: Optional[str] = Field("text", description="消息来源: text/voice")
class WebRTCSignalRequest(BaseModel):
"""WebRTC 信令请求"""
signal_type: str = Field(..., description="信令类型: voice_offer/voice_answer/ice_candidate/voice_start/voice_end")
payload: dict = Field(..., description="信令数据SDP/ICE候选等")
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.post("/{room_code}/signal", summary="发送WebRTC信令")
async def send_signal(
room_code: str,
request: WebRTCSignalRequest,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
发送 WebRTC 信令消息
信令类型:
- voice_start: 发起语音通话
- voice_offer: SDP Offer
- voice_answer: SDP Answer
- ice_candidate: ICE 候选
- voice_end: 结束语音通话
"""
service = PracticeRoomService(db)
# 获取房间
room = await service.get_room_by_code(room_code.upper())
if not room:
raise HTTPException(status_code=404, detail="房间不存在")
# 检查用户是否在房间中
user_role = room.get_user_role(current_user.id)
if not user_role:
raise HTTPException(status_code=403, detail="您不是房间参与者")
# 验证信令类型
valid_signal_types = ["voice_start", "voice_offer", "voice_answer", "ice_candidate", "voice_end"]
if request.signal_type not in valid_signal_types:
raise HTTPException(status_code=400, detail=f"无效的信令类型,必须是: {', '.join(valid_signal_types)}")
# 发送信令消息(作为系统消息存储,用于 SSE 推送)
message = await service.send_message(
room_id=room.id,
user_id=current_user.id,
content=None, # 信令消息不需要文本内容
role_name=None,
message_type=request.signal_type,
extra_data=request.payload
)
return {
"code": 200,
"message": "信令发送成功",
"data": {
"signal_type": request.signal_type,
"sequence": message.sequence
}
}
@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
]
}
}