feat: 实现 KPL 系统功能改进计划
Some checks failed
continuous-integration/drone/push Build is failing

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 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

View File

@@ -1,122 +1,122 @@
"""
双人对练房间模型
功能:
- 房间管理(创建、加入、状态)
- 参与者管理
- 实时消息同步
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.models.base import Base
class PracticeRoom(Base):
"""双人对练房间模型"""
__tablename__ = "practice_rooms"
id = Column(Integer, primary_key=True, index=True, comment="房间ID")
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
room_name = Column(String(200), comment="房间名称")
# 场景信息
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
scene_name = Column(String(200), comment="场景名称")
scene_type = Column(String(50), comment="场景类型")
scene_background = Column(Text, comment="场景背景")
# 角色设置
role_a_name = Column(String(50), default="角色A", comment="角色A名称如销售顾问")
role_b_name = Column(String(50), default="角色B", comment="角色B名称如顾客")
role_a_description = Column(Text, comment="角色A描述")
role_b_description = Column(Text, comment="角色B描述")
# 参与者信息
host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID")
guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID")
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
max_participants = Column(Integer, default=2, comment="最大参与人数")
# 状态和时间
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
started_at = Column(DateTime, comment="开始时间")
ended_at = Column(DateTime, comment="结束时间")
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
# 对话统计
total_turns = Column(Integer, default=0, comment="总对话轮次")
role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
# 软删除
is_deleted = Column(Boolean, default=False, comment="是否删除")
deleted_at = Column(DateTime, comment="删除时间")
def __repr__(self):
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
@property
def is_full(self) -> bool:
"""房间是否已满"""
return self.guest_user_id is not None
@property
def participant_count(self) -> int:
"""当前参与人数"""
count = 1 # 房主
if self.guest_user_id:
count += 1
return count
def get_user_role(self, user_id: int) -> str:
"""获取用户在房间中的角色"""
if user_id == self.host_user_id:
return self.host_role
elif user_id == self.guest_user_id:
return "B" if self.host_role == "A" else "A"
return None
def get_role_name(self, role: str) -> str:
"""获取角色名称"""
if role == "A":
return self.role_a_name
elif role == "B":
return self.role_b_name
return None
def get_user_role_name(self, user_id: int) -> str:
"""获取用户的角色名称"""
role = self.get_user_role(user_id)
return self.get_role_name(role) if role else None
class PracticeRoomMessage(Base):
"""房间实时消息模型"""
__tablename__ = "practice_room_messages"
id = Column(Integer, primary_key=True, index=True, comment="消息ID")
room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID")
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID")
message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end")
content = Column(Text, comment="消息内容")
role_name = Column(String(50), comment="角色名称")
sequence = Column(Integer, nullable=False, comment="消息序号")
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
def __repr__(self):
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
def to_dict(self) -> dict:
"""转换为字典用于SSE推送"""
return {
"id": self.id,
"room_id": self.room_id,
"user_id": self.user_id,
"message_type": self.message_type,
"content": self.content,
"role_name": self.role_name,
"sequence": self.sequence,
"created_at": self.created_at.isoformat() if self.created_at else None
}
"""
双人对练房间模型
功能:
- 房间管理(创建、加入、状态)
- 参与者管理
- 实时消息同步
"""
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
from sqlalchemy.sql import func
from sqlalchemy.orm import relationship
from app.models.base import Base
class PracticeRoom(Base):
"""双人对练房间模型"""
__tablename__ = "practice_rooms"
id = Column(Integer, primary_key=True, index=True, comment="房间ID")
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
room_name = Column(String(200), comment="房间名称")
# 场景信息
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
scene_name = Column(String(200), comment="场景名称")
scene_type = Column(String(50), comment="场景类型")
scene_background = Column(Text, comment="场景背景")
# 角色设置
role_a_name = Column(String(50), default="角色A", comment="角色A名称如销售顾问")
role_b_name = Column(String(50), default="角色B", comment="角色B名称如顾客")
role_a_description = Column(Text, comment="角色A描述")
role_b_description = Column(Text, comment="角色B描述")
# 参与者信息
host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID")
guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID")
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
max_participants = Column(Integer, default=2, comment="最大参与人数")
# 状态和时间
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
started_at = Column(DateTime, comment="开始时间")
ended_at = Column(DateTime, comment="结束时间")
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
# 对话统计
total_turns = Column(Integer, default=0, comment="总对话轮次")
role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
# 软删除
is_deleted = Column(Boolean, default=False, comment="是否删除")
deleted_at = Column(DateTime, comment="删除时间")
def __repr__(self):
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
@property
def is_full(self) -> bool:
"""房间是否已满"""
return self.guest_user_id is not None
@property
def participant_count(self) -> int:
"""当前参与人数"""
count = 1 # 房主
if self.guest_user_id:
count += 1
return count
def get_user_role(self, user_id: int) -> str:
"""获取用户在房间中的角色"""
if user_id == self.host_user_id:
return self.host_role
elif user_id == self.guest_user_id:
return "B" if self.host_role == "A" else "A"
return None
def get_role_name(self, role: str) -> str:
"""获取角色名称"""
if role == "A":
return self.role_a_name
elif role == "B":
return self.role_b_name
return None
def get_user_role_name(self, user_id: int) -> str:
"""获取用户的角色名称"""
role = self.get_user_role(user_id)
return self.get_role_name(role) if role else None
class PracticeRoomMessage(Base):
"""房间实时消息模型"""
__tablename__ = "practice_room_messages"
id = Column(Integer, primary_key=True, index=True, comment="消息ID")
room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID")
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID")
message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end")
content = Column(Text, comment="消息内容")
role_name = Column(String(50), comment="角色名称")
sequence = Column(Integer, nullable=False, comment="消息序号")
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
def __repr__(self):
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
def to_dict(self) -> dict:
"""转换为字典用于SSE推送"""
return {
"id": self.id,
"room_id": self.room_id,
"user_id": self.user_id,
"message_type": self.message_type,
"content": self.content,
"role_name": self.role_name,
"sequence": self.sequence,
"created_at": self.created_at.isoformat() if self.created_at else None
}