Files
012-kaopeilian/backend/app/api/v1/practice_room.py
yuliang_guo 64f5d567fa
Some checks failed
continuous-integration/drone/push Build is failing
feat: 实现 KPL 系统功能改进计划
1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
2026-01-30 14:22:35 +08:00

718 lines
22 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
]
}
}
@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)
):
"""
获取双人对练报告
包含:
- 房间基本信息
- 参与者信息
- 对话统计分析
- 表现评估
- 改进建议
"""
service = PracticeRoomService(db)
# 通过房间码获取房间
room = await service.get_room_by_code(room_code)
if not room:
raise HTTPException(status_code=404, detail="房间不存在")
# 验证用户权限
if current_user.id not in [room.host_user_id, room.guest_user_id]:
raise HTTPException(status_code=403, detail="无权查看此报告")
# 生成报告
report = await service.generate_report(room.id)
if not report:
raise HTTPException(status_code=404, detail="无法生成报告")
return {
"code": 200,
"message": "success",
"data": report
}