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

@@ -32,6 +32,11 @@ from app.models.certificate import (
UserCertificate,
CertificateType,
)
from app.models.user_course_progress import (
UserCourseProgress,
UserMaterialProgress,
ProgressStatus,
)
__all__ = [
"Base",
@@ -72,4 +77,7 @@ __all__ = [
"CertificateTemplate",
"UserCertificate",
"CertificateType",
"UserCourseProgress",
"UserMaterialProgress",
"ProgressStatus",
]

View File

@@ -1,76 +1,76 @@
"""
证书系统数据模型
定义证书模板和用户证书的数据结构
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON
from sqlalchemy.orm import relationship
from app.models.base import Base
class CertificateType(str, Enum):
"""证书类型枚举"""
COURSE = "course" # 课程结业证书
EXAM = "exam" # 考试合格证书
ACHIEVEMENT = "achievement" # 成就证书
class CertificateTemplate(Base):
"""证书模板表"""
__tablename__ = "certificate_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, comment="模板名称")
type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型")
background_url = Column(String(500), comment="证书背景图URL")
template_html = Column(Text, comment="HTML模板内容")
template_style = Column(Text, comment="CSS样式")
is_active = Column(Boolean, default=True, comment="是否启用")
sort_order = Column(Integer, default=0, comment="排序顺序")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
certificates = relationship("UserCertificate", back_populates="template")
class UserCertificate(Base):
"""用户证书表"""
__tablename__ = "user_certificates"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID")
template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID")
certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号")
title = Column(String(200), nullable=False, comment="证书标题")
description = Column(Text, comment="证书描述")
issued_at = Column(DateTime, default=datetime.now, comment="颁发时间")
valid_until = Column(DateTime, comment="有效期至")
# 关联信息
course_id = Column(Integer, comment="关联课程ID")
exam_id = Column(Integer, comment="关联考试ID")
badge_id = Column(Integer, comment="关联奖章ID")
# 成绩信息
score = Column(DECIMAL(5, 2), comment="考试分数")
completion_rate = Column(DECIMAL(5, 2), comment="完成率")
# 生成的文件
pdf_url = Column(String(500), comment="PDF文件URL")
image_url = Column(String(500), comment="分享图片URL")
# 元数据
meta_data = Column(JSON, comment="扩展元数据")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
template = relationship("CertificateTemplate", back_populates="certificates")
user = relationship("User", backref="certificates")
"""
证书系统数据模型
定义证书模板和用户证书的数据结构
"""
from datetime import datetime
from enum import Enum
from typing import Optional
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON
from sqlalchemy.orm import relationship
from app.models.base import Base
class CertificateType(str, Enum):
"""证书类型枚举"""
COURSE = "course" # 课程结业证书
EXAM = "exam" # 考试合格证书
ACHIEVEMENT = "achievement" # 成就证书
class CertificateTemplate(Base):
"""证书模板表"""
__tablename__ = "certificate_templates"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String(100), nullable=False, comment="模板名称")
type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型")
background_url = Column(String(500), comment="证书背景图URL")
template_html = Column(Text, comment="HTML模板内容")
template_style = Column(Text, comment="CSS样式")
is_active = Column(Boolean, default=True, comment="是否启用")
sort_order = Column(Integer, default=0, comment="排序顺序")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
certificates = relationship("UserCertificate", back_populates="template")
class UserCertificate(Base):
"""用户证书表"""
__tablename__ = "user_certificates"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID")
template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID")
certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号")
title = Column(String(200), nullable=False, comment="证书标题")
description = Column(Text, comment="证书描述")
issued_at = Column(DateTime, default=datetime.now, comment="颁发时间")
valid_until = Column(DateTime, comment="有效期至")
# 关联信息
course_id = Column(Integer, comment="关联课程ID")
exam_id = Column(Integer, comment="关联考试ID")
badge_id = Column(Integer, comment="关联奖章ID")
# 成绩信息
score = Column(DECIMAL(5, 2), comment="考试分数")
completion_rate = Column(DECIMAL(5, 2), comment="完成率")
# 生成的文件
pdf_url = Column(String(500), comment="PDF文件URL")
image_url = Column(String(500), comment="分享图片URL")
# 元数据
meta_data = Column(JSON, comment="扩展元数据")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
template = relationship("CertificateTemplate", back_populates="certificates")
user = relationship("User", backref="certificates")

View File

@@ -1,140 +1,140 @@
"""
等级与奖章系统模型
包含:
- UserLevel: 用户等级信息
- ExpHistory: 经验值变化历史
- BadgeDefinition: 奖章定义
- UserBadge: 用户已获得的奖章
- LevelConfig: 等级配置
"""
from datetime import datetime, date
from typing import Optional, List
from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.models.base import Base, BaseModel
class UserLevel(Base):
"""用户等级表"""
__tablename__ = "user_levels"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
level = Column(Integer, nullable=False, default=1, comment="当前等级")
exp = Column(Integer, nullable=False, default=0, comment="当前经验值")
total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值")
login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数")
max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数")
last_login_date = Column(Date, nullable=True, comment="最后登录日期")
last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
user = relationship("User", backref="user_level", uselist=False)
class ExpHistory(Base):
"""经验值历史表"""
__tablename__ = "exp_history"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
exp_change = Column(Integer, nullable=False, comment="经验值变化")
exp_type = Column(String(50), nullable=False, index=True, comment="类型exam/practice/training/task/login/badge/other")
source_id = Column(Integer, nullable=True, comment="来源记录ID")
description = Column(String(255), nullable=False, comment="描述")
level_before = Column(Integer, nullable=True, comment="变化前等级")
level_after = Column(Integer, nullable=True, comment="变化后等级")
created_at = Column(DateTime, nullable=False, default=datetime.now)
# 关联
user = relationship("User", backref="exp_histories")
class BadgeDefinition(Base):
"""奖章定义表"""
__tablename__ = "badge_definitions"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(50), nullable=False, unique=True, comment="奖章编码")
name = Column(String(100), nullable=False, comment="奖章名称")
description = Column(String(255), nullable=False, comment="奖章描述")
icon = Column(String(100), nullable=False, default="Medal", comment="图标名称")
category = Column(String(50), nullable=False, index=True, comment="分类")
condition_type = Column(String(50), nullable=False, comment="条件类型")
condition_field = Column(String(100), nullable=True, comment="条件字段")
condition_value = Column(Integer, nullable=False, default=1, comment="条件数值")
exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值")
sort_order = Column(Integer, nullable=False, default=0, comment="排序")
is_active = Column(Boolean, nullable=False, default=True, comment="是否启用")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
user_badges = relationship("UserBadge", back_populates="badge")
class UserBadge(Base):
"""用户奖章表"""
__tablename__ = "user_badges"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True)
unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间")
is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知")
notified_at = Column(DateTime, nullable=True, comment="通知时间")
created_at = Column(DateTime, nullable=False, default=datetime.now)
# 关联
user = relationship("User", backref="badges")
badge = relationship("BadgeDefinition", back_populates="user_badges")
class LevelConfig(Base):
"""等级配置表"""
__tablename__ = "level_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
level = Column(Integer, nullable=False, unique=True, comment="等级")
exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值")
total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值")
title = Column(String(50), nullable=False, comment="等级称号")
color = Column(String(20), nullable=True, comment="等级颜色")
created_at = Column(DateTime, nullable=False, default=datetime.now)
# 经验值类型枚举
class ExpType:
"""经验值类型"""
EXAM = "exam" # 考试
PRACTICE = "practice" # 练习
TRAINING = "training" # 陪练
TASK = "task" # 任务
LOGIN = "login" # 登录/签到
BADGE = "badge" # 奖章奖励
OTHER = "other" # 其他
# 奖章分类枚举
class BadgeCategory:
"""奖章分类"""
LEARNING = "learning" # 学习进度
EXAM = "exam" # 考试成绩
PRACTICE = "practice" # 练习时长
STREAK = "streak" # 连续打卡
SPECIAL = "special" # 特殊成就
# 条件类型枚举
class ConditionType:
"""解锁条件类型"""
COUNT = "count" # 次数
SCORE = "score" # 分数
STREAK = "streak" # 连续天数
LEVEL = "level" # 等级
DURATION = "duration" # 时长
"""
等级与奖章系统模型
包含:
- UserLevel: 用户等级信息
- ExpHistory: 经验值变化历史
- BadgeDefinition: 奖章定义
- UserBadge: 用户已获得的奖章
- LevelConfig: 等级配置
"""
from datetime import datetime, date
from typing import Optional, List
from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text
from sqlalchemy.orm import relationship
from app.models.base import Base, BaseModel
class UserLevel(Base):
"""用户等级表"""
__tablename__ = "user_levels"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
level = Column(Integer, nullable=False, default=1, comment="当前等级")
exp = Column(Integer, nullable=False, default=0, comment="当前经验值")
total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值")
login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数")
max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数")
last_login_date = Column(Date, nullable=True, comment="最后登录日期")
last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
user = relationship("User", backref="user_level", uselist=False)
class ExpHistory(Base):
"""经验值历史表"""
__tablename__ = "exp_history"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
exp_change = Column(Integer, nullable=False, comment="经验值变化")
exp_type = Column(String(50), nullable=False, index=True, comment="类型exam/practice/training/task/login/badge/other")
source_id = Column(Integer, nullable=True, comment="来源记录ID")
description = Column(String(255), nullable=False, comment="描述")
level_before = Column(Integer, nullable=True, comment="变化前等级")
level_after = Column(Integer, nullable=True, comment="变化后等级")
created_at = Column(DateTime, nullable=False, default=datetime.now)
# 关联
user = relationship("User", backref="exp_histories")
class BadgeDefinition(Base):
"""奖章定义表"""
__tablename__ = "badge_definitions"
id = Column(Integer, primary_key=True, autoincrement=True)
code = Column(String(50), nullable=False, unique=True, comment="奖章编码")
name = Column(String(100), nullable=False, comment="奖章名称")
description = Column(String(255), nullable=False, comment="奖章描述")
icon = Column(String(100), nullable=False, default="Medal", comment="图标名称")
category = Column(String(50), nullable=False, index=True, comment="分类")
condition_type = Column(String(50), nullable=False, comment="条件类型")
condition_field = Column(String(100), nullable=True, comment="条件字段")
condition_value = Column(Integer, nullable=False, default=1, comment="条件数值")
exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值")
sort_order = Column(Integer, nullable=False, default=0, comment="排序")
is_active = Column(Boolean, nullable=False, default=True, comment="是否启用")
created_at = Column(DateTime, nullable=False, default=datetime.now)
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
# 关联
user_badges = relationship("UserBadge", back_populates="badge")
class UserBadge(Base):
"""用户奖章表"""
__tablename__ = "user_badges"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True)
unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间")
is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知")
notified_at = Column(DateTime, nullable=True, comment="通知时间")
created_at = Column(DateTime, nullable=False, default=datetime.now)
# 关联
user = relationship("User", backref="badges")
badge = relationship("BadgeDefinition", back_populates="user_badges")
class LevelConfig(Base):
"""等级配置表"""
__tablename__ = "level_configs"
id = Column(Integer, primary_key=True, autoincrement=True)
level = Column(Integer, nullable=False, unique=True, comment="等级")
exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值")
total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值")
title = Column(String(50), nullable=False, comment="等级称号")
color = Column(String(20), nullable=True, comment="等级颜色")
created_at = Column(DateTime, nullable=False, default=datetime.now)
# 经验值类型枚举
class ExpType:
"""经验值类型"""
EXAM = "exam" # 考试
PRACTICE = "practice" # 练习
TRAINING = "training" # 陪练
TASK = "task" # 任务
LOGIN = "login" # 登录/签到
BADGE = "badge" # 奖章奖励
OTHER = "other" # 其他
# 奖章分类枚举
class BadgeCategory:
"""奖章分类"""
LEARNING = "learning" # 学习进度
EXAM = "exam" # 考试成绩
PRACTICE = "practice" # 练习时长
STREAK = "streak" # 连续打卡
SPECIAL = "special" # 特殊成就
# 条件类型枚举
class ConditionType:
"""解锁条件类型"""
COUNT = "count" # 次数
SCORE = "score" # 分数
STREAK = "streak" # 连续天数
LEVEL = "level" # 等级
DURATION = "duration" # 时长

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
}

View File

@@ -0,0 +1,201 @@
"""
用户课程学习进度数据库模型
"""
from enum import Enum
from typing import Optional
from datetime import datetime
from sqlalchemy import (
String,
Integer,
Boolean,
ForeignKey,
Float,
DateTime,
UniqueConstraint,
Index,
)
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.models.base import BaseModel
class ProgressStatus(str, Enum):
"""学习进度状态枚举"""
NOT_STARTED = "not_started" # 未开始
IN_PROGRESS = "in_progress" # 学习中
COMPLETED = "completed" # 已完成
class UserCourseProgress(BaseModel):
"""
用户课程进度表
记录用户对每门课程的整体学习进度
"""
__tablename__ = "user_course_progress"
__table_args__ = (
UniqueConstraint("user_id", "course_id", name="uq_user_course"),
Index("idx_user_course_progress_user", "user_id"),
Index("idx_user_course_progress_course", "course_id"),
Index("idx_user_course_progress_status", "status"),
)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
comment="用户ID",
)
course_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("courses.id", ondelete="CASCADE"),
nullable=False,
comment="课程ID",
)
# 进度信息
status: Mapped[ProgressStatus] = mapped_column(
String(20),
default=ProgressStatus.NOT_STARTED.value,
nullable=False,
comment="学习状态not_started/in_progress/completed",
)
progress_percent: Mapped[float] = mapped_column(
Float,
default=0.0,
nullable=False,
comment="完成百分比(0-100)",
)
completed_materials: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="已完成资料数",
)
total_materials: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="总资料数",
)
# 学习时长统计
total_study_time: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="总学习时长(秒)",
)
# 时间记录
first_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="首次访问时间",
)
last_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="最后访问时间",
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="完成时间",
)
# 关联关系
user = relationship("User", backref="course_progress")
course = relationship("Course", backref="user_progress")
class UserMaterialProgress(BaseModel):
"""
用户资料进度表
记录用户对每个课程资料的学习进度
"""
__tablename__ = "user_material_progress"
__table_args__ = (
UniqueConstraint("user_id", "material_id", name="uq_user_material"),
Index("idx_user_material_progress_user", "user_id"),
Index("idx_user_material_progress_material", "material_id"),
)
user_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("users.id", ondelete="CASCADE"),
nullable=False,
comment="用户ID",
)
material_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("course_materials.id", ondelete="CASCADE"),
nullable=False,
comment="资料ID",
)
course_id: Mapped[int] = mapped_column(
Integer,
ForeignKey("courses.id", ondelete="CASCADE"),
nullable=False,
comment="课程ID冗余字段便于查询",
)
# 进度信息
is_completed: Mapped[bool] = mapped_column(
Boolean,
default=False,
nullable=False,
comment="是否已完成",
)
progress_percent: Mapped[float] = mapped_column(
Float,
default=0.0,
nullable=False,
comment="阅读/播放进度百分比(0-100)",
)
# 视频/音频特有字段
last_position: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="上次播放位置(秒)",
)
total_duration: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="媒体总时长(秒)",
)
# 学习时长
study_time: Mapped[int] = mapped_column(
Integer,
default=0,
nullable=False,
comment="学习时长(秒)",
)
# 时间记录
first_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="首次访问时间",
)
last_accessed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="最后访问时间",
)
completed_at: Mapped[Optional[datetime]] = mapped_column(
DateTime,
nullable=True,
comment="完成时间",
)
# 关联关系
user = relationship("User", backref="material_progress")
material = relationship("CourseMaterial", backref="user_progress")
course = relationship("Course", backref="material_user_progress")