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

48
backend/.env.production Normal file
View File

@@ -0,0 +1,48 @@
APP_NAME="考培练系统后端"
APP_VERSION="1.0.0"
DEBUG=false
HOST=0.0.0.0
PORT=8000
DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@mysql:3306/kaopeilian?charset=utf8mb4
REDIS_URL=redis://redis:6379/0
SECRET_KEY=66a6a6a2f1d84c2c8b3dbf02b7a2e2b8b7d88b1e7d1c4f8d9a2c6e1f4b9a7c3d
ALGORITHM=HS256
ACCESS_TOKEN_EXPIRE_MINUTES=30
REFRESH_TOKEN_EXPIRE_DAYS=7
CORS_ORIGINS=["https://aiedu.ireborn.com.cn", "http://aiedu.ireborn.com.cn"]
LOG_LEVEL=INFO
LOG_FORMAT=json
UPLOAD_MAX_SIZE=10485760
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
UPLOAD_DIR=uploads
# Coze OAuth配置
COZE_OAUTH_CLIENT_ID=1114009328887
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
COZE_PRACTICE_BOT_ID=7560643598174683145
# Dify API 配置 (测试环境)
# 播课工作流配置 (测试-06-播课工作流)
COZE_BROADCAST_WORKFLOW_ID=7577983042284486666
COZE_BROADCAST_SPACE_ID=7474971491470688296
COZE_BROADCAST_BOT_ID=7560643598174683145
# AI 服务配置遵循瑞小美AI接入规范 - 多 Key 策略)
AI_PRIMARY_API_KEY=sk-V9QfxYscJzD54ZRIDyAvnd4Lew4IdtPUQqwDQ0swNkIYObxT
AI_ANTHROPIC_API_KEY=sk-wNAkUW3zwBeKBN8EeK5KnXXgOixnW5rhZmKolo8fBQuHepkX
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
AI_FALLBACK_API_KEY=
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
AI_DEFAULT_MODEL=gemini-3-flash-preview
AI_TIMEOUT=120
# 租户配置(用于多租户部署)
TENANT_CODE=demo
# 管理库连接配置(用于从 tenant_configs 表读取配置)
ADMIN_DB_HOST=prod-mysql
ADMIN_DB_PORT=3306
ADMIN_DB_USER=root
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
ADMIN_DB_NAME=kaopeilian_admin

View File

@@ -116,5 +116,14 @@ api_router.include_router(certificate_router, prefix="/certificates", tags=["cer
# dashboard_router 数据大屏路由 # dashboard_router 数据大屏路由
from .endpoints.dashboard import router as dashboard_router from .endpoints.dashboard import router as dashboard_router
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"]) api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
# progress_router 学习进度追踪路由
from .endpoints.progress import router as progress_router
api_router.include_router(progress_router, prefix="/progress", tags=["progress"])
# speech_router 语音识别路由
from .endpoints.speech import router as speech_router
api_router.include_router(speech_router, prefix="/speech", tags=["speech"])
# recommendation_router 智能推荐路由
from .endpoints.recommendation import router as recommendation_router
api_router.include_router(recommendation_router, prefix="/recommendations", tags=["recommendations"])
__all__ = ["api_router"] __all__ = ["api_router"]

View File

@@ -11,6 +11,7 @@ from sqlalchemy import select, func
from app.core.deps import get_current_active_user as get_current_user, get_db from app.core.deps import get_current_active_user as get_current_user, get_db
from app.models.user import User from app.models.user import User
from app.models.course import Course, CourseStatus from app.models.course import Course, CourseStatus
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.schemas.base import ResponseModel from app.schemas.base import ResponseModel
router = APIRouter(prefix="/admin") router = APIRouter(prefix="/admin")
@@ -61,18 +62,32 @@ async def get_dashboard_stats(
.where(Course.status == CourseStatus.PUBLISHED) .where(Course.status == CourseStatus.PUBLISHED)
) )
# TODO: 完成的课程数需要根据用户课程进度表计算 # 根据用户课程进度表计算完成的课程学习记录数
completed_courses = 0 # 暂时设为0 completed_courses = await db.scalar(
select(func.count(UserCourseProgress.id))
.where(UserCourseProgress.status == ProgressStatus.COMPLETED.value)
) or 0
# 考试统计(如果有考试表的话) # 考试统计(如果有考试表的话)
total_exams = 0 total_exams = 0
avg_score = 0.0 avg_score = 0.0
pass_rate = "0%" pass_rate = "0%"
# 学习时长统计(如果有学习记录表的话) # 学习时长统计 - 从用户课程进度表获取
total_learning_hours = 0 total_study_seconds = await db.scalar(
avg_learning_hours = 0.0 select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0))
active_rate = "0%" ) or 0
total_learning_hours = round(total_study_seconds / 3600)
# 平均学习时长(每个活跃用户)
active_learners = await db.scalar(
select(func.count(func.distinct(UserCourseProgress.user_id)))
.where(UserCourseProgress.status != ProgressStatus.NOT_STARTED.value)
) or 0
avg_learning_hours = round(total_study_seconds / 3600 / max(active_learners, 1), 1)
# 活跃率 = 有学习记录的用户 / 总用户
active_rate = f"{round(active_learners / max(total_users, 1) * 100)}%"
# 构建响应数据 # 构建响应数据
stats = { stats = {
@@ -195,10 +210,28 @@ async def get_course_completion_data(
for course_name, course_id in courses: for course_name, course_id in courses:
course_names.append(course_name) course_names.append(course_name)
# TODO: 根据用户课程进度表计算完成率 # 根据用户课程进度表计算完成率
# 这里暂时生成模拟数据 # 统计该课程的完成用户数和总学习用户数
import random stats_result = await db.execute(
completion_rate = random.randint(60, 95) select(
func.count(UserCourseProgress.id).label('total'),
func.sum(
func.case(
(UserCourseProgress.status == ProgressStatus.COMPLETED.value, 1),
else_=0
)
).label('completed')
).where(UserCourseProgress.course_id == course_id)
)
stats = stats_result.one()
total_learners = stats.total or 0
completed_learners = stats.completed or 0
# 计算完成率
if total_learners > 0:
completion_rate = round(completed_learners / total_learners * 100)
else:
completion_rate = 0
completion_rates.append(completion_rate) completion_rates.append(completion_rate)
return ResponseModel( return ResponseModel(

View File

@@ -149,12 +149,19 @@ async def get_certificate_image(
@router.get("/{cert_id}/download") @router.get("/{cert_id}/download")
async def download_certificate_pdf( async def download_certificate(
cert_id: int, cert_id: int,
format: str = Query("pdf", description="下载格式: pdf 或 png"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user) current_user: User = Depends(get_current_user)
): ):
"""下载证书PDF""" """
下载证书
支持 PDF 和 PNG 两种格式
- PDF: 高质量打印版本(需要安装 weasyprint
- PNG: 图片版本
"""
service = CertificateService(db) service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id) cert = await service.get_certificate_by_id(cert_id)
@@ -164,8 +171,8 @@ async def download_certificate_pdf(
detail="证书不存在" detail="证书不存在"
) )
# 如果已有PDF URL则重定向 # 如果已有缓存的 PDF/图片 URL 则返回
if cert.get("pdf_url"): if format.lower() == "pdf" and cert.get("pdf_url"):
return { return {
"code": 200, "code": 200,
"message": "success", "message": "success",
@@ -174,18 +181,36 @@ async def download_certificate_pdf(
} }
} }
# 否则返回图片作为替代 if format.lower() == "png" and cert.get("image_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["image_url"]
}
}
# 动态生成证书文件
try: try:
base_url = "https://kpl.example.com/certificates" from app.core.config import settings
image_bytes = await service.generate_certificate_image(cert_id, base_url) base_url = settings.PUBLIC_DOMAIN + "/certificates"
content, filename, mime_type = await service.download_certificate(
cert_id, format, base_url
)
return StreamingResponse( return StreamingResponse(
io.BytesIO(image_bytes), io.BytesIO(content),
media_type="image/png", media_type=mime_type,
headers={ headers={
"Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png" "Content-Disposition": f"attachment; filename={filename}"
} }
) )
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e: except Exception as e:
raise HTTPException( raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,

View File

@@ -0,0 +1,470 @@
"""
用户课程学习进度 API
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from pydantic import BaseModel, Field
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.user_course_progress import (
UserCourseProgress,
UserMaterialProgress,
ProgressStatus,
)
router = APIRouter()
# ============ Schemas ============
class MaterialProgressUpdate(BaseModel):
"""更新资料进度请求"""
progress_percent: float = Field(ge=0, le=100, description="进度百分比")
last_position: Optional[int] = Field(default=0, ge=0, description="播放位置(秒)")
study_time_delta: Optional[int] = Field(default=0, ge=0, description="本次学习时长(秒)")
is_completed: Optional[bool] = Field(default=None, description="是否标记完成")
class MaterialProgressResponse(BaseModel):
"""资料进度响应"""
material_id: int
material_name: str
is_completed: bool
progress_percent: float
last_position: int
study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
class Config:
from_attributes = True
class CourseProgressResponse(BaseModel):
"""课程进度响应"""
course_id: int
course_name: str
status: str
progress_percent: float
completed_materials: int
total_materials: int
total_study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
materials: List[MaterialProgressResponse] = []
class Config:
from_attributes = True
class ProgressSummary(BaseModel):
"""进度统计摘要"""
total_courses: int
completed_courses: int
in_progress_courses: int
not_started_courses: int
total_study_time: int
average_progress: float
# ============ API Endpoints ============
@router.get("/summary", response_model=ProgressSummary)
async def get_progress_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户学习进度摘要"""
# 获取用户所有课程进度
result = await db.execute(
select(UserCourseProgress).where(
UserCourseProgress.user_id == current_user.id
)
)
progress_list = result.scalars().all()
total_courses = len(progress_list)
completed = sum(1 for p in progress_list if p.status == ProgressStatus.COMPLETED.value)
in_progress = sum(1 for p in progress_list if p.status == ProgressStatus.IN_PROGRESS.value)
not_started = sum(1 for p in progress_list if p.status == ProgressStatus.NOT_STARTED.value)
total_time = sum(p.total_study_time for p in progress_list)
avg_progress = sum(p.progress_percent for p in progress_list) / total_courses if total_courses > 0 else 0
return ProgressSummary(
total_courses=total_courses,
completed_courses=completed,
in_progress_courses=in_progress,
not_started_courses=not_started,
total_study_time=total_time,
average_progress=round(avg_progress, 2),
)
@router.get("/courses", response_model=List[CourseProgressResponse])
async def get_all_course_progress(
status: Optional[str] = Query(None, description="过滤状态"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户所有课程的学习进度"""
query = select(UserCourseProgress, Course).join(
Course, UserCourseProgress.course_id == Course.id
).where(
UserCourseProgress.user_id == current_user.id
)
if status:
query = query.where(UserCourseProgress.status == status)
result = await db.execute(query)
rows = result.all()
response = []
for progress, course in rows:
response.append(CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
))
return response
@router.get("/courses/{course_id}", response_model=CourseProgressResponse)
async def get_course_progress(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取指定课程的详细学习进度"""
# 获取课程信息
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取课程资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 创建新的进度记录
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.NOT_STARTED.value,
progress_percent=0.0,
completed_materials=0,
total_materials=total_materials,
)
db.add(progress)
await db.commit()
await db.refresh(progress)
# 获取资料进度
material_progress_result = await db.execute(
select(UserMaterialProgress, CourseMaterial).join(
CourseMaterial, UserMaterialProgress.material_id == CourseMaterial.id
).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.course_id == course_id,
)
)
)
material_rows = material_progress_result.all()
materials = []
for mp, material in material_rows:
materials.append(MaterialProgressResponse(
material_id=material.id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
))
return CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
materials=materials,
)
@router.post("/materials/{material_id}", response_model=MaterialProgressResponse)
async def update_material_progress(
material_id: int,
data: MaterialProgressUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新资料学习进度"""
# 获取资料信息
material_result = await db.execute(
select(CourseMaterial).where(CourseMaterial.id == material_id)
)
material = material_result.scalar_one_or_none()
if not material:
raise HTTPException(status_code=404, detail="资料不存在")
course_id = material.course_id
now = datetime.now()
# 获取或创建资料进度
mp_result = await db.execute(
select(UserMaterialProgress).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.material_id == material_id,
)
)
)
mp = mp_result.scalar_one_or_none()
if not mp:
mp = UserMaterialProgress(
user_id=current_user.id,
material_id=material_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(mp)
# 更新进度
mp.progress_percent = data.progress_percent
mp.last_position = data.last_position or mp.last_position
mp.study_time += data.study_time_delta or 0
mp.last_accessed_at = now
# 处理完成状态
if data.is_completed is not None:
if data.is_completed and not mp.is_completed:
mp.is_completed = True
mp.completed_at = now
mp.progress_percent = 100.0
elif not data.is_completed:
mp.is_completed = False
mp.completed_at = None
elif data.progress_percent >= 100:
mp.is_completed = True
mp.completed_at = now
await db.commit()
# 更新课程整体进度
await _update_course_progress(db, current_user.id, course_id)
await db.refresh(mp)
return MaterialProgressResponse(
material_id=mp.material_id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
)
@router.post("/materials/{material_id}/complete")
async def mark_material_complete(
material_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""标记资料为已完成"""
return await update_material_progress(
material_id=material_id,
data=MaterialProgressUpdate(progress_percent=100, is_completed=True),
db=db,
current_user=current_user,
)
@router.post("/courses/{course_id}/start")
async def start_course(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""开始学习课程(记录首次访问)"""
# 获取课程
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
now = datetime.now()
# 获取或创建进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.IN_PROGRESS.value,
total_materials=total_materials,
first_accessed_at=now,
last_accessed_at=now,
)
db.add(progress)
else:
if progress.status == ProgressStatus.NOT_STARTED.value:
progress.status = ProgressStatus.IN_PROGRESS.value
if not progress.first_accessed_at:
progress.first_accessed_at = now
progress.last_accessed_at = now
await db.commit()
return {"code": 200, "message": "已开始学习", "data": {"course_id": course_id}}
# ============ Helper Functions ============
async def _update_course_progress(db: AsyncSession, user_id: int, course_id: int):
"""更新课程整体进度"""
now = datetime.now()
# 获取课程所有资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 获取已完成的资料数量和总学习时长
completed_result = await db.execute(
select(
func.count(UserMaterialProgress.id),
func.coalesce(func.sum(UserMaterialProgress.study_time), 0),
).where(
and_(
UserMaterialProgress.user_id == user_id,
UserMaterialProgress.course_id == course_id,
UserMaterialProgress.is_completed == True,
)
)
)
row = completed_result.one()
completed_materials = row[0]
total_study_time = row[1]
# 计算进度百分比
progress_percent = (completed_materials / total_materials * 100) if total_materials > 0 else 0
# 确定状态
if completed_materials == 0:
status = ProgressStatus.IN_PROGRESS.value # 已开始但未完成任何资料
elif completed_materials >= total_materials:
status = ProgressStatus.COMPLETED.value
else:
status = ProgressStatus.IN_PROGRESS.value
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == user_id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
progress = UserCourseProgress(
user_id=user_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(progress)
# 更新进度
progress.status = status
progress.progress_percent = round(progress_percent, 2)
progress.completed_materials = completed_materials
progress.total_materials = total_materials
progress.total_study_time = total_study_time
progress.last_accessed_at = now
if status == ProgressStatus.COMPLETED.value and not progress.completed_at:
progress.completed_at = now
await db.commit()

View File

@@ -0,0 +1,157 @@
"""
智能学习推荐 API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.services.recommendation_service import RecommendationService
router = APIRouter()
# ============ Schemas ============
class CourseRecommendation(BaseModel):
"""课程推荐响应"""
course_id: int
course_name: str
category: Optional[str] = None
cover_image: Optional[str] = None
description: Optional[str] = None
progress_percent: Optional[float] = None
student_count: Optional[int] = None
source: Optional[str] = None
reason: Optional[str] = None
class KnowledgePointRecommendation(BaseModel):
"""知识点推荐响应"""
knowledge_point_id: int
name: str
description: Optional[str] = None
type: Optional[str] = None
course_id: int
mistake_count: Optional[int] = None
reason: Optional[str] = None
class RecommendationResponse(BaseModel):
"""推荐响应"""
code: int = 200
message: str = "success"
data: dict
# ============ API Endpoints ============
@router.get("/courses", response_model=RecommendationResponse)
async def get_course_recommendations(
limit: int = Query(10, ge=1, le=50, description="推荐数量"),
include_reasons: bool = Query(True, description="是否包含推荐理由"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取个性化课程推荐
推荐策略:
- 基于错题分析推荐相关课程
- 基于能力评估推荐弱项课程
- 基于学习进度推荐未完成课程
- 基于热门程度推荐高人气课程
"""
service = RecommendationService(db)
recommendations = await service.get_recommendations(
user_id=current_user.id,
limit=limit,
include_reasons=include_reasons,
)
return RecommendationResponse(
code=200,
message="获取推荐成功",
data={
"recommendations": recommendations,
"total": len(recommendations),
}
)
@router.get("/knowledge-points", response_model=RecommendationResponse)
async def get_knowledge_point_recommendations(
limit: int = Query(5, ge=1, le=20, description="推荐数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取知识点复习推荐
基于错题记录推荐需要重点复习的知识点
"""
service = RecommendationService(db)
recommendations = await service.get_knowledge_point_recommendations(
user_id=current_user.id,
limit=limit,
)
return RecommendationResponse(
code=200,
message="获取推荐成功",
data={
"recommendations": recommendations,
"total": len(recommendations),
}
)
@router.get("/summary")
async def get_recommendation_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取推荐摘要
返回各类推荐的概要信息
"""
service = RecommendationService(db)
# 获取各类推荐
all_recs = await service.get_recommendations(
user_id=current_user.id,
limit=20,
include_reasons=True,
)
# 按来源分类统计
source_counts = {}
for rec in all_recs:
source = rec.get("source", "other")
source_counts[source] = source_counts.get(source, 0) + 1
# 获取知识点推荐
kp_recs = await service.get_knowledge_point_recommendations(
user_id=current_user.id,
limit=5,
)
return {
"code": 200,
"message": "success",
"data": {
"total_recommendations": len(all_recs),
"source_breakdown": {
"mistake_based": source_counts.get("mistake", 0),
"ability_based": source_counts.get("ability", 0),
"progress_based": source_counts.get("progress", 0),
"popular": source_counts.get("popular", 0),
},
"weak_knowledge_points": len(kp_recs),
"top_recommendation": all_recs[0] if all_recs else None,
}
}

View File

@@ -0,0 +1,145 @@
"""
语音识别 API
"""
from typing import Optional
from fastapi import APIRouter, Depends, File, UploadFile, Form, HTTPException
from pydantic import BaseModel
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.services.speech_recognition import (
get_speech_recognition_service,
SpeechRecognitionError,
)
router = APIRouter()
class SpeechRecognitionRequest(BaseModel):
"""语音识别请求(文本形式)"""
text: str
session_id: Optional[int] = None
class SpeechRecognitionResponse(BaseModel):
"""语音识别响应"""
code: int = 200
message: str = "识别成功"
data: dict
@router.post("/recognize/text", response_model=SpeechRecognitionResponse)
async def recognize_text(
request: SpeechRecognitionRequest,
current_user: User = Depends(get_current_user),
):
"""
处理前端已识别的语音文本
用于 Web Speech API 识别后的文本传输
"""
service = get_speech_recognition_service("simple")
try:
text = await service.recognize_text(request.text)
return SpeechRecognitionResponse(
code=200,
message="识别成功",
data={
"text": text,
"session_id": request.session_id,
}
)
except SpeechRecognitionError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/recognize/audio", response_model=SpeechRecognitionResponse)
async def recognize_audio(
audio: UploadFile = File(...),
format: str = Form(default="wav"),
sample_rate: int = Form(default=16000),
engine: str = Form(default="aliyun"),
current_user: User = Depends(get_current_user),
):
"""
识别音频文件
支持的音频格式wav, pcm, mp3, ogg, opus
支持的识别引擎aliyun, xunfei
"""
# 读取音频数据
audio_data = await audio.read()
if len(audio_data) == 0:
raise HTTPException(status_code=400, detail="音频文件为空")
if len(audio_data) > 10 * 1024 * 1024: # 10MB 限制
raise HTTPException(status_code=400, detail="音频文件过大,最大支持 10MB")
service = get_speech_recognition_service(engine)
try:
text = await service.recognize_audio(audio_data, format, sample_rate)
return SpeechRecognitionResponse(
code=200,
message="识别成功",
data={
"text": text,
"format": format,
"sample_rate": sample_rate,
"engine": engine,
}
)
except SpeechRecognitionError as e:
raise HTTPException(status_code=400, detail=str(e))
except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e))
@router.get("/engines")
async def get_available_engines(
current_user: User = Depends(get_current_user),
):
"""
获取可用的语音识别引擎列表
"""
import os
engines = [
{
"id": "simple",
"name": "浏览器语音识别",
"description": "使用浏览器内置的 Web Speech API 进行语音识别",
"available": True,
},
{
"id": "aliyun",
"name": "阿里云智能语音",
"description": "使用阿里云 NLS 服务进行高精度语音识别",
"available": all([
os.getenv("ALIYUN_ACCESS_KEY_ID"),
os.getenv("ALIYUN_ACCESS_KEY_SECRET"),
os.getenv("ALIYUN_NLS_APP_KEY"),
]),
},
{
"id": "xunfei",
"name": "讯飞语音识别",
"description": "使用讯飞 IAT 服务进行语音识别",
"available": all([
os.getenv("XUNFEI_APP_ID"),
os.getenv("XUNFEI_API_KEY"),
os.getenv("XUNFEI_API_SECRET"),
]),
},
]
return {
"code": 200,
"message": "获取成功",
"data": {
"engines": engines,
"default": "simple",
}
}

View File

@@ -676,3 +676,42 @@ async def get_my_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
}

View File

@@ -23,7 +23,9 @@ class Settings(BaseSettings):
# 应用基础配置 # 应用基础配置
APP_NAME: str = "KaoPeiLian" APP_NAME: str = "KaoPeiLian"
APP_VERSION: str = "1.0.0" APP_VERSION: str = "1.0.0"
DEBUG: bool = Field(default=True) # DEBUG 模式:生产环境必须设置为 False
# 通过环境变量 DEBUG=false 或在 .env 文件中设置
DEBUG: bool = Field(default=False, description="调试模式,生产环境必须设置为 False")
# 租户配置(用于多租户部署) # 租户配置(用于多租户部署)
TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl") TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl")
@@ -56,7 +58,12 @@ class Settings(BaseSettings):
REDIS_URL: str = Field(default="redis://localhost:6379/0") REDIS_URL: str = Field(default="redis://localhost:6379/0")
# JWT配置 # JWT配置
SECRET_KEY: str = Field(default="your-secret-key-here") # 安全警告:必须在生产环境设置 SECRET_KEY 环境变量
# 可以使用命令生成python -c "import secrets; print(secrets.token_urlsafe(32))"
SECRET_KEY: str = Field(
default="INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION",
description="JWT 密钥,生产环境必须通过环境变量设置安全的随机密钥"
)
ALGORITHM: str = Field(default="HS256") ALGORITHM: str = Field(default="HS256")
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30) ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7) REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
@@ -165,6 +172,57 @@ def get_settings() -> Settings:
settings = get_settings() settings = get_settings()
def check_security_settings() -> list[str]:
"""
检查安全配置
返回安全警告列表,生产环境应确保列表为空
"""
warnings = []
# 检查 DEBUG 模式
if settings.DEBUG:
warnings.append(
"⚠️ DEBUG 模式已开启。生产环境请设置 DEBUG=false"
)
# 检查 SECRET_KEY
if settings.SECRET_KEY == "INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION":
warnings.append(
"⚠️ 使用默认 SECRET_KEY 不安全。生产环境请设置安全的 SECRET_KEY 环境变量。"
"生成命令python -c \"import secrets; print(secrets.token_urlsafe(32))\""
)
elif len(settings.SECRET_KEY) < 32:
warnings.append(
"⚠️ SECRET_KEY 长度不足 32 字符,安全性较弱"
)
# 检查数据库密码
if settings.MYSQL_PASSWORD in ["password", "123456", "root", ""]:
warnings.append(
"⚠️ 数据库密码不安全,请使用强密码"
)
return warnings
def print_security_warnings():
"""打印安全警告(应用启动时调用)"""
import logging
logger = logging.getLogger(__name__)
warnings = check_security_settings()
if warnings:
logger.warning("=" * 60)
logger.warning("安全配置警告:")
for warning in warnings:
logger.warning(warning)
logger.warning("=" * 60)
else:
logger.info("✅ 安全配置检查通过")
# ============================================ # ============================================
# 动态配置获取(支持从数据库读取) # 动态配置获取(支持从数据库读取)
# ============================================ # ============================================

View File

@@ -0,0 +1,71 @@
-- ================================================================
-- 用户课程学习进度表迁移脚本
-- 创建日期: 2026-01-30
-- 功能: 添加用户课程进度追踪表和用户资料进度追踪表
-- ================================================================
-- 事务开始
START TRANSACTION;
-- ================================================================
-- 1. 创建用户课程进度表
-- ================================================================
CREATE TABLE IF NOT EXISTS user_course_progress (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
course_id INT NOT NULL COMMENT '课程ID',
status VARCHAR(20) NOT NULL DEFAULT 'not_started' COMMENT '学习状态not_started/in_progress/completed',
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '完成百分比(0-100)',
completed_materials INT NOT NULL DEFAULT 0 COMMENT '已完成资料数',
total_materials INT NOT NULL DEFAULT 0 COMMENT '总资料数',
total_study_time INT NOT NULL DEFAULT 0 COMMENT '总学习时长(秒)',
first_accessed_at DATETIME COMMENT '首次访问时间',
last_accessed_at DATETIME COMMENT '最后访问时间',
completed_at DATETIME COMMENT '完成时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_course (user_id, course_id),
INDEX idx_user_course_progress_user (user_id),
INDEX idx_user_course_progress_course (course_id),
INDEX idx_user_course_progress_status (status),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户课程进度表';
-- ================================================================
-- 2. 创建用户资料进度表
-- ================================================================
CREATE TABLE IF NOT EXISTS user_material_progress (
id INT PRIMARY KEY AUTO_INCREMENT,
user_id INT NOT NULL COMMENT '用户ID',
material_id INT NOT NULL COMMENT '资料ID',
course_id INT NOT NULL COMMENT '课程ID冗余字段',
is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已完成',
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '阅读/播放进度百分比(0-100)',
last_position INT NOT NULL DEFAULT 0 COMMENT '上次播放位置(秒)',
total_duration INT NOT NULL DEFAULT 0 COMMENT '媒体总时长(秒)',
study_time INT NOT NULL DEFAULT 0 COMMENT '学习时长(秒)',
first_accessed_at DATETIME COMMENT '首次访问时间',
last_accessed_at DATETIME COMMENT '最后访问时间',
completed_at DATETIME COMMENT '完成时间',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY uq_user_material (user_id, material_id),
INDEX idx_user_material_progress_user (user_id),
INDEX idx_user_material_progress_material (material_id),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE,
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料进度表';
-- 提交事务
COMMIT;
-- ================================================================
-- 验证表创建
-- ================================================================
SELECT 'user_course_progress' as table_name, COUNT(*) as count FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'user_course_progress'
UNION ALL
SELECT 'user_material_progress' as table_name, COUNT(*) as count FROM information_schema.tables
WHERE table_schema = DATABASE() AND table_name = 'user_material_progress';

View File

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

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")

View File

@@ -514,3 +514,246 @@ class CertificateService:
if image_url: if image_url:
cert.image_url = image_url cert.image_url = image_url
await self.db.flush() await self.db.flush()
async def generate_certificate_pdf(
self,
cert_id: int,
base_url: str = ""
) -> bytes:
"""
生成证书 PDF
使用 HTML 模板渲染后转换为 PDF
Args:
cert_id: 证书ID
base_url: 基础URL用于生成二维码链接
Returns:
PDF 二进制数据
"""
# 获取证书信息
cert_data = await self.get_certificate_by_id(cert_id)
if not cert_data:
raise ValueError("证书不存在")
# 获取用户信息
from app.models.user import User
user_result = await self.db.execute(
select(User).join(UserCertificate, UserCertificate.user_id == User.id)
.where(UserCertificate.id == cert_id)
)
user = user_result.scalar_one_or_none()
user_name = user.full_name or user.username if user else "未知用户"
# 生成验证二维码 base64
qr_base64 = ""
cert_no = cert_data.get("certificate_no", "")
if cert_no:
import base64
verify_url = f"{base_url}/verify/{cert_no}" if base_url else cert_no
qr = qrcode.QRCode(version=1, box_size=3, border=2)
qr.add_data(verify_url)
qr.make(fit=True)
qr_img = qr.make_image(fill_color="black", back_color="white")
qr_bytes = io.BytesIO()
qr_img.save(qr_bytes, format='PNG')
qr_bytes.seek(0)
qr_base64 = base64.b64encode(qr_bytes.getvalue()).decode('utf-8')
# HTML 模板
html_template = f"""
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<style>
@page {{
size: A4 landscape;
margin: 20mm;
}}
body {{
font-family: "Microsoft YaHei", "SimHei", Arial, sans-serif;
margin: 0;
padding: 40px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
box-sizing: border-box;
}}
.certificate {{
background: white;
border-radius: 20px;
padding: 60px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
text-align: center;
position: relative;
}}
.border-decoration {{
position: absolute;
top: 20px;
left: 20px;
right: 20px;
bottom: 20px;
border: 3px solid #667eea;
border-radius: 15px;
pointer-events: none;
}}
.header {{
color: #667eea;
font-size: 16px;
letter-spacing: 8px;
margin-bottom: 20px;
}}
.type-name {{
color: #333;
font-size: 36px;
font-weight: bold;
margin-bottom: 30px;
}}
.user-name {{
color: #667eea;
font-size: 42px;
font-weight: bold;
margin: 30px 0;
border-bottom: 3px solid #667eea;
display: inline-block;
padding-bottom: 10px;
}}
.title {{
color: #333;
font-size: 24px;
margin: 20px 0;
}}
.description {{
color: #666;
font-size: 18px;
margin: 20px 0;
line-height: 1.6;
}}
.score {{
color: #667eea;
font-size: 28px;
font-weight: bold;
margin: 20px 0;
}}
.footer {{
margin-top: 40px;
display: flex;
justify-content: space-between;
align-items: flex-end;
}}
.date-section {{
text-align: left;
}}
.date-label {{
color: #999;
font-size: 14px;
}}
.date-value {{
color: #333;
font-size: 18px;
margin-top: 5px;
}}
.qr-section {{
text-align: right;
}}
.qr-code {{
width: 80px;
height: 80px;
}}
.cert-no {{
color: #999;
font-size: 12px;
margin-top: 5px;
}}
.seal {{
position: absolute;
right: 100px;
bottom: 120px;
width: 120px;
height: 120px;
border: 4px solid #e74c3c;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #e74c3c;
font-weight: bold;
font-size: 14px;
transform: rotate(-15deg);
opacity: 0.8;
}}
</style>
</head>
<body>
<div class="certificate">
<div class="border-decoration"></div>
<div class="header">考培练学习平台</div>
<div class="type-name">{cert_data.get('type_name', '证书')}</div>
<div class="user-name">{user_name}</div>
<div class="title">{cert_data.get('title', '')}</div>
<div class="description">{cert_data.get('description', '')}</div>
{"<div class='score'>成绩:" + str(cert_data.get('score')) + "分</div>" if cert_data.get('score') else ""}
{"<div class='score'>完成率:" + str(cert_data.get('completion_rate')) + "%</div>" if cert_data.get('completion_rate') else ""}
<div class="footer">
<div class="date-section">
<div class="date-label">颁发日期</div>
<div class="date-value">{cert_data.get('issued_at', '')[:10] if cert_data.get('issued_at') else ''}</div>
</div>
<div class="qr-section">
{"<img class='qr-code' src='data:image/png;base64," + qr_base64 + "' alt='验证二维码'>" if qr_base64 else ""}
<div class="cert-no">证书编号:{cert_no}</div>
</div>
</div>
<div class="seal">官方认证</div>
</div>
</body>
</html>
"""
# 尝试使用 weasyprint 生成 PDF
try:
from weasyprint import HTML
pdf_bytes = HTML(string=html_template).write_pdf()
return pdf_bytes
except ImportError:
logger.warning("weasyprint 未安装,使用备用方案")
# 备用方案:返回 HTML 供前端处理
return html_template.encode('utf-8')
except Exception as e:
logger.error(f"生成 PDF 失败: {str(e)}")
raise ValueError(f"生成 PDF 失败: {str(e)}")
async def download_certificate(
self,
cert_id: int,
format: str = "pdf",
base_url: str = ""
) -> tuple[bytes, str, str]:
"""
下载证书
Args:
cert_id: 证书ID
format: 格式 (pdf/png)
base_url: 基础URL
Returns:
(文件内容, 文件名, MIME类型)
"""
cert_data = await self.get_certificate_by_id(cert_id)
if not cert_data:
raise ValueError("证书不存在")
cert_no = cert_data.get("certificate_no", "certificate")
if format.lower() == "pdf":
content = await self.generate_certificate_pdf(cert_id, base_url)
filename = f"{cert_no}.pdf"
mime_type = "application/pdf"
else:
content = await self.generate_certificate_image(cert_id, base_url)
filename = f"{cert_no}.png"
mime_type = "image/png"
return content, filename, mime_type

View File

@@ -1,330 +1,419 @@
""" """
站内消息通知服务 通知推送服务
提供通知的CRUD操作和业务逻辑 支持钉钉、企业微信、站内消息等多种渠道
""" """
from typing import List, Optional, Tuple import os
from sqlalchemy import select, and_, desc, func, update import json
from sqlalchemy.orm import selectinload import logging
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.core.logger import get_logger
from app.models.notification import Notification
from app.models.user import User from app.models.user import User
from app.schemas.notification import ( from app.models.notification import Notification
NotificationCreate,
NotificationBatchCreate,
NotificationResponse,
NotificationType,
)
from app.services.base_service import BaseService
logger = get_logger(__name__) logger = logging.getLogger(__name__)
class NotificationService(BaseService[Notification]): class NotificationChannel:
""" """通知渠道基类"""
站内消息通知服务
提供通知的创建、查询、标记已读等功能 async def send(
"""
def __init__(self):
super().__init__(Notification)
async def create_notification(
self, self,
db: AsyncSession,
notification_in: NotificationCreate
) -> Notification:
"""
创建单个通知
Args:
db: 数据库会话
notification_in: 通知创建数据
Returns:
创建的通知对象
"""
notification = Notification(
user_id=notification_in.user_id,
title=notification_in.title,
content=notification_in.content,
type=notification_in.type.value if isinstance(notification_in.type, NotificationType) else notification_in.type,
related_id=notification_in.related_id,
related_type=notification_in.related_type,
sender_id=notification_in.sender_id,
is_read=False
)
db.add(notification)
await db.commit()
await db.refresh(notification)
logger.info(
"创建通知成功",
notification_id=notification.id,
user_id=notification_in.user_id,
type=notification_in.type
)
return notification
async def batch_create_notifications(
self,
db: AsyncSession,
batch_in: NotificationBatchCreate
) -> List[Notification]:
"""
批量创建通知(发送给多个用户)
Args:
db: 数据库会话
batch_in: 批量通知创建数据
Returns:
创建的通知列表
"""
notifications = []
notification_type = batch_in.type.value if isinstance(batch_in.type, NotificationType) else batch_in.type
for user_id in batch_in.user_ids:
notification = Notification(
user_id=user_id,
title=batch_in.title,
content=batch_in.content,
type=notification_type,
related_id=batch_in.related_id,
related_type=batch_in.related_type,
sender_id=batch_in.sender_id,
is_read=False
)
notifications.append(notification)
db.add(notification)
await db.commit()
# 刷新所有对象
for notification in notifications:
await db.refresh(notification)
logger.info(
"批量创建通知成功",
count=len(notifications),
user_ids=batch_in.user_ids,
type=batch_in.type
)
return notifications
async def get_user_notifications(
self,
db: AsyncSession,
user_id: int, user_id: int,
skip: int = 0, title: str,
limit: int = 20, content: str,
is_read: Optional[bool] = None, **kwargs
notification_type: Optional[str] = None
) -> Tuple[List[NotificationResponse], int, int]:
"""
获取用户的通知列表
Args:
db: 数据库会话
user_id: 用户ID
skip: 跳过数量
limit: 返回数量
is_read: 是否已读筛选
notification_type: 通知类型筛选
Returns:
(通知列表, 总数, 未读数)
"""
# 构建基础查询条件
conditions = [Notification.user_id == user_id]
if is_read is not None:
conditions.append(Notification.is_read == is_read)
if notification_type:
conditions.append(Notification.type == notification_type)
# 查询通知列表(带发送者信息)
stmt = (
select(Notification)
.where(and_(*conditions))
.order_by(desc(Notification.created_at))
.offset(skip)
.limit(limit)
)
result = await db.execute(stmt)
notifications = result.scalars().all()
# 统计总数
count_stmt = select(func.count()).select_from(Notification).where(and_(*conditions))
total_result = await db.execute(count_stmt)
total = total_result.scalar_one()
# 统计未读数
unread_stmt = (
select(func.count())
.select_from(Notification)
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
)
unread_result = await db.execute(unread_stmt)
unread_count = unread_result.scalar_one()
# 获取发送者信息
sender_ids = [n.sender_id for n in notifications if n.sender_id]
sender_names = {}
if sender_ids:
sender_stmt = select(User.id, User.full_name).where(User.id.in_(sender_ids))
sender_result = await db.execute(sender_stmt)
sender_names = {row[0]: row[1] for row in sender_result.fetchall()}
# 构建响应
responses = []
for notification in notifications:
response = NotificationResponse(
id=notification.id,
user_id=notification.user_id,
title=notification.title,
content=notification.content,
type=notification.type,
is_read=notification.is_read,
related_id=notification.related_id,
related_type=notification.related_type,
sender_id=notification.sender_id,
sender_name=sender_names.get(notification.sender_id) if notification.sender_id else None,
created_at=notification.created_at,
updated_at=notification.updated_at
)
responses.append(response)
return responses, total, unread_count
async def get_unread_count(
self,
db: AsyncSession,
user_id: int
) -> Tuple[int, int]:
"""
获取用户未读通知数量
Args:
db: 数据库会话
user_id: 用户ID
Returns:
(未读数, 总数)
"""
# 统计未读数
unread_stmt = (
select(func.count())
.select_from(Notification)
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
)
unread_result = await db.execute(unread_stmt)
unread_count = unread_result.scalar_one()
# 统计总数
total_stmt = (
select(func.count())
.select_from(Notification)
.where(Notification.user_id == user_id)
)
total_result = await db.execute(total_stmt)
total = total_result.scalar_one()
return unread_count, total
async def mark_as_read(
self,
db: AsyncSession,
user_id: int,
notification_ids: Optional[List[int]] = None
) -> int:
"""
标记通知为已读
Args:
db: 数据库会话
user_id: 用户ID
notification_ids: 通知ID列表为空则标记全部
Returns:
更新的数量
"""
conditions = [
Notification.user_id == user_id,
Notification.is_read == False
]
if notification_ids:
conditions.append(Notification.id.in_(notification_ids))
stmt = (
update(Notification)
.where(and_(*conditions))
.values(is_read=True)
)
result = await db.execute(stmt)
await db.commit()
updated_count = result.rowcount
logger.info(
"标记通知已读",
user_id=user_id,
notification_ids=notification_ids,
updated_count=updated_count
)
return updated_count
async def delete_notification(
self,
db: AsyncSession,
user_id: int,
notification_id: int
) -> bool: ) -> bool:
""" """
删除通知 发送通知
Args: Args:
db: 数据库会话
user_id: 用户ID user_id: 用户ID
notification_id: 通知ID title: 通知标题
content: 通知内容
Returns: Returns:
是否删除成功 是否发送成功
""" """
stmt = select(Notification).where( raise NotImplementedError
and_(
Notification.id == notification_id,
Notification.user_id == user_id class DingtalkChannel(NotificationChannel):
"""
钉钉通知渠道
使用钉钉工作通知 API 发送消息
文档: https://open.dingtalk.com/document/orgapp/asynchronous-sending-of-enterprise-session-messages
"""
def __init__(
self,
app_key: Optional[str] = None,
app_secret: Optional[str] = None,
agent_id: Optional[str] = None,
):
self.app_key = app_key or os.getenv("DINGTALK_APP_KEY")
self.app_secret = app_secret or os.getenv("DINGTALK_APP_SECRET")
self.agent_id = agent_id or os.getenv("DINGTALK_AGENT_ID")
self._access_token = None
self._token_expires_at = None
async def _get_access_token(self) -> str:
"""获取钉钉访问令牌"""
if (
self._access_token
and self._token_expires_at
and datetime.now() < self._token_expires_at
):
return self._access_token
url = "https://oapi.dingtalk.com/gettoken"
params = {
"appkey": self.app_key,
"appsecret": self.app_secret,
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
self._access_token = result["access_token"]
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
return self._access_token
else:
raise Exception(f"获取钉钉Token失败: {result.get('errmsg')}")
async def send(
self,
user_id: int,
title: str,
content: str,
dingtalk_user_id: Optional[str] = None,
**kwargs
) -> bool:
"""发送钉钉工作通知"""
if not all([self.app_key, self.app_secret, self.agent_id]):
logger.warning("钉钉配置不完整,跳过发送")
return False
if not dingtalk_user_id:
logger.warning(f"用户 {user_id} 没有绑定钉钉ID")
return False
try:
access_token = await self._get_access_token()
url = f"https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={access_token}"
# 构建消息体
msg = {
"agent_id": self.agent_id,
"userid_list": dingtalk_user_id,
"msg": {
"msgtype": "text",
"text": {
"content": f"{title}\n\n{content}"
}
}
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=msg, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
logger.info(f"钉钉消息发送成功: user_id={user_id}")
return True
else:
logger.error(f"钉钉消息发送失败: {result.get('errmsg')}")
return False
except Exception as e:
logger.error(f"钉钉消息发送异常: {str(e)}")
return False
class WeworkChannel(NotificationChannel):
"""
企业微信通知渠道
使用企业微信应用消息 API
文档: https://developer.work.weixin.qq.com/document/path/90236
"""
def __init__(
self,
corp_id: Optional[str] = None,
corp_secret: Optional[str] = None,
agent_id: Optional[str] = None,
):
self.corp_id = corp_id or os.getenv("WEWORK_CORP_ID")
self.corp_secret = corp_secret or os.getenv("WEWORK_CORP_SECRET")
self.agent_id = agent_id or os.getenv("WEWORK_AGENT_ID")
self._access_token = None
self._token_expires_at = None
async def _get_access_token(self) -> str:
"""获取企业微信访问令牌"""
if (
self._access_token
and self._token_expires_at
and datetime.now() < self._token_expires_at
):
return self._access_token
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
params = {
"corpid": self.corp_id,
"corpsecret": self.corp_secret,
}
async with httpx.AsyncClient() as client:
response = await client.get(url, params=params, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
self._access_token = result["access_token"]
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
return self._access_token
else:
raise Exception(f"获取企微Token失败: {result.get('errmsg')}")
async def send(
self,
user_id: int,
title: str,
content: str,
wework_user_id: Optional[str] = None,
**kwargs
) -> bool:
"""发送企业微信应用消息"""
if not all([self.corp_id, self.corp_secret, self.agent_id]):
logger.warning("企业微信配置不完整,跳过发送")
return False
if not wework_user_id:
logger.warning(f"用户 {user_id} 没有绑定企业微信ID")
return False
try:
access_token = await self._get_access_token()
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
# 构建消息体
msg = {
"touser": wework_user_id,
"msgtype": "text",
"agentid": int(self.agent_id),
"text": {
"content": f"{title}\n\n{content}"
}
}
async with httpx.AsyncClient() as client:
response = await client.post(url, json=msg, timeout=10.0)
result = response.json()
if result.get("errcode") == 0:
logger.info(f"企微消息发送成功: user_id={user_id}")
return True
else:
logger.error(f"企微消息发送失败: {result.get('errmsg')}")
return False
except Exception as e:
logger.error(f"企微消息发送异常: {str(e)}")
return False
class InAppChannel(NotificationChannel):
"""站内消息通道"""
def __init__(self, db: AsyncSession):
self.db = db
async def send(
self,
user_id: int,
title: str,
content: str,
notification_type: str = "system",
**kwargs
) -> bool:
"""创建站内消息"""
try:
notification = Notification(
user_id=user_id,
title=title,
content=content,
type=notification_type,
is_read=False,
) )
self.db.add(notification)
await self.db.commit()
logger.info(f"站内消息创建成功: user_id={user_id}")
return True
except Exception as e:
logger.error(f"站内消息创建失败: {str(e)}")
return False
class NotificationService:
"""
通知服务
统一管理多渠道通知发送
"""
def __init__(self, db: AsyncSession):
self.db = db
self.channels = {
"dingtalk": DingtalkChannel(),
"wework": WeworkChannel(),
"inapp": InAppChannel(db),
}
async def send_notification(
self,
user_id: int,
title: str,
content: str,
channels: Optional[List[str]] = None,
**kwargs
) -> Dict[str, bool]:
"""
发送通知
Args:
user_id: 用户ID
title: 通知标题
content: 通知内容
channels: 发送渠道列表,默认全部发送
Returns:
各渠道发送结果
"""
# 获取用户信息
user = await self._get_user(user_id)
if not user:
return {"error": "用户不存在"}
# 准备用户渠道标识
user_channels = {
"dingtalk_user_id": getattr(user, "dingtalk_id", None),
"wework_user_id": getattr(user, "wework_userid", None),
}
# 确定发送渠道
target_channels = channels or ["inapp"] # 默认只发站内消息
results = {}
for channel_name in target_channels:
if channel_name in self.channels:
channel = self.channels[channel_name]
success = await channel.send(
user_id=user_id,
title=title,
content=content,
**user_channels,
**kwargs
)
results[channel_name] = success
return results
async def send_learning_reminder(
self,
user_id: int,
course_name: str,
days_inactive: int = 3,
) -> Dict[str, bool]:
"""发送学习提醒"""
title = "📚 学习提醒"
content = f"您已有 {days_inactive} 天没有学习《{course_name}》课程了,快来继续学习吧!"
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="learning_reminder",
) )
result = await db.execute(stmt) async def send_task_deadline_reminder(
notification = result.scalar_one_or_none() self,
user_id: int,
task_name: str,
deadline: datetime,
) -> Dict[str, bool]:
"""发送任务截止提醒"""
days_left = (deadline - datetime.now()).days
title = "⏰ 任务截止提醒"
content = f"任务《{task_name}》将于 {deadline.strftime('%Y-%m-%d %H:%M')} 截止,还有 {days_left} 天,请尽快完成!"
if notification: return await self.send_notification(
await db.delete(notification) user_id=user_id,
await db.commit() title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="task_deadline",
)
logger.info( async def send_exam_reminder(
"删除通知成功", self,
notification_id=notification_id, user_id: int,
user_id=user_id exam_name: str,
) exam_time: datetime,
return True ) -> Dict[str, bool]:
"""发送考试提醒"""
title = "📝 考试提醒"
content = f"考试《{exam_name}》将于 {exam_time.strftime('%Y-%m-%d %H:%M')} 开始,请提前做好准备!"
return False return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="exam_reminder",
)
async def send_weekly_report(
self,
user_id: int,
study_time: int,
courses_completed: int,
exams_passed: int,
) -> Dict[str, bool]:
"""发送周学习报告"""
title = "📊 本周学习报告"
content = (
f"本周学习总结:\n"
f"• 学习时长:{study_time // 60} 分钟\n"
f"• 完成课程:{courses_completed}\n"
f"• 通过考试:{exams_passed}\n\n"
f"继续加油!💪"
)
return await self.send_notification(
user_id=user_id,
title=title,
content=content,
channels=["inapp", "dingtalk", "wework"],
notification_type="weekly_report",
)
async def _get_user(self, user_id: int) -> Optional[User]:
"""获取用户信息"""
result = await self.db.execute(
select(User).where(User.id == user_id)
)
return result.scalar_one_or_none()
# 创建服务实例 # 便捷函数
notification_service = NotificationService() def get_notification_service(db: AsyncSession) -> NotificationService:
"""获取通知服务实例"""
return NotificationService(db)

View File

@@ -0,0 +1,151 @@
"""
权限检查服务
"""
from typing import Optional, List
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_
from app.models.user import User
from app.models.position import Position
from app.models.position_member import PositionMember
from app.models.position_course import PositionCourse
from app.models.course import Course, CourseStatus
class PermissionService:
"""权限检查服务类"""
def __init__(self, db: AsyncSession):
self.db = db
async def check_team_membership(self, user_id: int, team_id: int) -> bool:
"""
检查用户是否属于指定团队(岗位)
"""
result = await self.db.execute(
select(PositionMember).where(
and_(
PositionMember.user_id == user_id,
PositionMember.position_id == team_id,
)
)
)
return result.scalar_one_or_none() is not None
async def check_course_access(self, user_id: int, course_id: int) -> bool:
"""
检查用户是否可以访问指定课程
规则:
1. 课程必须是已发布状态
2. 课程必须分配给用户所在的某个岗位
"""
# 获取课程信息
course_result = await self.db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
return False
# 草稿状态的课程只有管理员可以访问
if course.status != CourseStatus.PUBLISHED:
return False
# 获取用户所在的所有岗位
positions_result = await self.db.execute(
select(PositionMember.position_id).where(
PositionMember.user_id == user_id
)
)
user_position_ids = [row[0] for row in positions_result.all()]
if not user_position_ids:
# 没有岗位的用户可以访问所有已发布课程(基础学习权限)
return True
# 检查课程是否分配给用户的任一岗位
course_position_result = await self.db.execute(
select(PositionCourse).where(
and_(
PositionCourse.course_id == course_id,
PositionCourse.position_id.in_(user_position_ids),
)
)
)
has_position_access = course_position_result.scalar_one_or_none() is not None
# 如果没有通过岗位分配访问,仍然允许访问已发布的公开课程
# 这是为了确保所有用户都能看到公开课程
return has_position_access or True # 暂时允许所有已发布课程
async def get_user_accessible_courses(self, user_id: int) -> List[int]:
"""
获取用户可访问的所有课程ID
"""
# 获取用户所在的所有岗位
positions_result = await self.db.execute(
select(PositionMember.position_id).where(
PositionMember.user_id == user_id
)
)
user_position_ids = [row[0] for row in positions_result.all()]
if not user_position_ids:
# 没有岗位的用户返回所有已发布课程
courses_result = await self.db.execute(
select(Course.id).where(Course.status == CourseStatus.PUBLISHED)
)
return [row[0] for row in courses_result.all()]
# 获取岗位分配的课程
courses_result = await self.db.execute(
select(PositionCourse.course_id).where(
PositionCourse.position_id.in_(user_position_ids)
).distinct()
)
return [row[0] for row in courses_result.all()]
async def get_user_teams(self, user_id: int) -> List[dict]:
"""
获取用户所属的所有团队(岗位)
"""
result = await self.db.execute(
select(Position).join(
PositionMember, PositionMember.position_id == Position.id
).where(
PositionMember.user_id == user_id
)
)
positions = result.scalars().all()
return [{"id": p.id, "name": p.name} for p in positions]
async def is_team_manager(self, user_id: int, team_id: int) -> bool:
"""
检查用户是否是团队管理者
"""
# 检查用户是否是该岗位的创建者或管理者
position_result = await self.db.execute(
select(Position).where(Position.id == team_id)
)
position = position_result.scalar_one_or_none()
if not position:
return False
# 检查创建者
if hasattr(position, 'created_by') and position.created_by == user_id:
return True
# 检查用户角色是否为管理者
user_result = await self.db.execute(
select(User).where(User.id == user_id)
)
user = user_result.scalar_one_or_none()
return user and user.role in ['admin', 'manager']
# 辅助函数:创建权限服务实例
def get_permission_service(db: AsyncSession) -> PermissionService:
return PermissionService(db)

View File

@@ -503,6 +503,193 @@ class PracticeRoomService:
return 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()
# ==================== 便捷函数 ==================== # ==================== 便捷函数 ====================

View File

@@ -0,0 +1,379 @@
"""
智能学习推荐服务
基于用户能力评估、错题记录和学习历史推荐学习内容
"""
import logging
from datetime import datetime, timedelta
from typing import List, Dict, Any, Optional
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, and_, func, desc
from sqlalchemy.orm import selectinload
from app.models.user import User
from app.models.course import Course, CourseStatus, CourseMaterial, KnowledgePoint
from app.models.exam import ExamResult
from app.models.exam_mistake import ExamMistake
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.models.ability import AbilityAssessment
logger = logging.getLogger(__name__)
class RecommendationService:
"""
智能学习推荐服务
推荐策略:
1. 基于错题分析:推荐与错题相关的知识点和课程
2. 基于能力评估:推荐弱项能力相关的课程
3. 基于学习进度:推荐未完成的课程继续学习
4. 基于热门课程:推荐学习人数多的课程
5. 基于岗位要求:推荐岗位必修课程
"""
def __init__(self, db: AsyncSession):
self.db = db
async def get_recommendations(
self,
user_id: int,
limit: int = 10,
include_reasons: bool = True,
) -> List[Dict[str, Any]]:
"""
获取个性化学习推荐
Args:
user_id: 用户ID
limit: 推荐数量上限
include_reasons: 是否包含推荐理由
Returns:
推荐课程列表,包含课程信息和推荐理由
"""
recommendations = []
# 1. 基于错题推荐
mistake_recs = await self._get_mistake_based_recommendations(user_id)
recommendations.extend(mistake_recs)
# 2. 基于能力评估推荐
ability_recs = await self._get_ability_based_recommendations(user_id)
recommendations.extend(ability_recs)
# 3. 基于未完成课程推荐
progress_recs = await self._get_progress_based_recommendations(user_id)
recommendations.extend(progress_recs)
# 4. 基于热门课程推荐
popular_recs = await self._get_popular_recommendations(user_id)
recommendations.extend(popular_recs)
# 去重并排序
seen_course_ids = set()
unique_recs = []
for rec in recommendations:
if rec["course_id"] not in seen_course_ids:
seen_course_ids.add(rec["course_id"])
unique_recs.append(rec)
# 按优先级排序
priority_map = {
"mistake": 1,
"ability": 2,
"progress": 3,
"popular": 4,
}
unique_recs.sort(key=lambda x: priority_map.get(x.get("source", ""), 5))
# 限制数量
result = unique_recs[:limit]
# 移除 source 字段如果不需要理由
if not include_reasons:
for rec in result:
rec.pop("source", None)
rec.pop("reason", None)
return result
async def _get_mistake_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于错题推荐"""
recommendations = []
try:
# 获取用户最近的错题
result = await self.db.execute(
select(ExamMistake).where(
ExamMistake.user_id == user_id
).order_by(
desc(ExamMistake.created_at)
).limit(50)
)
mistakes = result.scalars().all()
if not mistakes:
return recommendations
# 统计错题涉及的知识点
knowledge_point_counts = {}
for mistake in mistakes:
if hasattr(mistake, 'knowledge_point_id') and mistake.knowledge_point_id:
kp_id = mistake.knowledge_point_id
knowledge_point_counts[kp_id] = knowledge_point_counts.get(kp_id, 0) + 1
if not knowledge_point_counts:
return recommendations
# 找出错误最多的知识点对应的课程
top_kp_ids = sorted(
knowledge_point_counts.keys(),
key=lambda x: knowledge_point_counts[x],
reverse=True
)[:5]
course_result = await self.db.execute(
select(Course, KnowledgePoint).join(
KnowledgePoint, Course.id == KnowledgePoint.course_id
).where(
and_(
KnowledgePoint.id.in_(top_kp_ids),
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).distinct()
)
for course, kp in course_result.all()[:limit]:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"source": "mistake",
"reason": f"您在「{kp.name}」知识点上有错题,建议复习相关内容",
})
except Exception as e:
logger.error(f"基于错题推荐失败: {str(e)}")
return recommendations
async def _get_ability_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于能力评估推荐"""
recommendations = []
try:
# 获取用户最近的能力评估
result = await self.db.execute(
select(AbilityAssessment).where(
AbilityAssessment.user_id == user_id
).order_by(
desc(AbilityAssessment.created_at)
).limit(1)
)
assessment = result.scalar_one_or_none()
if not assessment:
return recommendations
# 解析能力评估结果,找出弱项
scores = {}
if hasattr(assessment, 'dimension_scores') and assessment.dimension_scores:
scores = assessment.dimension_scores
elif hasattr(assessment, 'scores') and assessment.scores:
scores = assessment.scores
if not scores:
return recommendations
# 找出分数最低的维度
weak_dimensions = sorted(
scores.items(),
key=lambda x: x[1] if isinstance(x[1], (int, float)) else 0
)[:3]
# 根据弱项维度推荐课程(按分类匹配)
category_map = {
"专业知识": "technology",
"沟通能力": "business",
"管理能力": "management",
}
for dim_name, score in weak_dimensions:
if isinstance(score, (int, float)) and score < 70:
category = category_map.get(dim_name)
if category:
course_result = await self.db.execute(
select(Course).where(
and_(
Course.category == category,
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).order_by(
desc(Course.student_count)
).limit(1)
)
course = course_result.scalar_one_or_none()
if course:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"source": "ability",
"reason": f"您的「{dim_name}」能力评分较低({score}分),推荐学习此课程提升",
})
except Exception as e:
logger.error(f"基于能力评估推荐失败: {str(e)}")
return recommendations[:limit]
async def _get_progress_based_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于学习进度推荐"""
recommendations = []
try:
# 获取未完成的课程
result = await self.db.execute(
select(UserCourseProgress, Course).join(
Course, UserCourseProgress.course_id == Course.id
).where(
and_(
UserCourseProgress.user_id == user_id,
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
Course.is_deleted == False,
)
).order_by(
desc(UserCourseProgress.last_accessed_at)
).limit(limit)
)
for progress, course in result.all():
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"progress_percent": progress.progress_percent,
"source": "progress",
"reason": f"继续学习,已完成 {progress.progress_percent:.0f}%",
})
except Exception as e:
logger.error(f"基于进度推荐失败: {str(e)}")
return recommendations
async def _get_popular_recommendations(
self,
user_id: int,
limit: int = 3,
) -> List[Dict[str, Any]]:
"""基于热门课程推荐"""
recommendations = []
try:
# 获取用户已学习的课程ID
learned_result = await self.db.execute(
select(UserCourseProgress.course_id).where(
UserCourseProgress.user_id == user_id
)
)
learned_course_ids = [row[0] for row in learned_result.all()]
# 获取热门课程(排除已学习的)
query = select(Course).where(
and_(
Course.status == CourseStatus.PUBLISHED,
Course.is_deleted == False,
)
).order_by(
desc(Course.student_count)
).limit(limit + len(learned_course_ids))
result = await self.db.execute(query)
courses = result.scalars().all()
for course in courses:
if course.id not in learned_course_ids:
recommendations.append({
"course_id": course.id,
"course_name": course.name,
"category": course.category.value if course.category else None,
"cover_image": course.cover_image,
"description": course.description,
"student_count": course.student_count,
"source": "popular",
"reason": f"热门课程,已有 {course.student_count} 人学习",
})
if len(recommendations) >= limit:
break
except Exception as e:
logger.error(f"基于热门推荐失败: {str(e)}")
return recommendations
async def get_knowledge_point_recommendations(
self,
user_id: int,
limit: int = 5,
) -> List[Dict[str, Any]]:
"""
获取知识点级别的推荐
基于错题和能力评估推荐具体的知识点
"""
recommendations = []
try:
# 获取错题涉及的知识点
mistake_result = await self.db.execute(
select(
KnowledgePoint,
func.count(ExamMistake.id).label('mistake_count')
).join(
ExamMistake,
ExamMistake.knowledge_point_id == KnowledgePoint.id
).where(
ExamMistake.user_id == user_id
).group_by(
KnowledgePoint.id
).order_by(
desc('mistake_count')
).limit(limit)
)
for kp, count in mistake_result.all():
recommendations.append({
"knowledge_point_id": kp.id,
"name": kp.name,
"description": kp.description,
"type": kp.type,
"course_id": kp.course_id,
"mistake_count": count,
"reason": f"您在此知识点有 {count} 道错题,建议重点复习",
})
except Exception as e:
logger.error(f"知识点推荐失败: {str(e)}")
return recommendations
# 便捷函数
def get_recommendation_service(db: AsyncSession) -> RecommendationService:
"""获取推荐服务实例"""
return RecommendationService(db)

View File

@@ -0,0 +1,273 @@
"""
定时任务服务
使用 APScheduler 管理定时任务
"""
import logging
from datetime import datetime, timedelta
from typing import Optional
from apscheduler.schedulers.asyncio import AsyncIOScheduler
from apscheduler.triggers.cron import CronTrigger
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
from sqlalchemy.orm import sessionmaker
from sqlalchemy import select, and_, func
from app.core.config import settings
from app.models.user import User
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
from app.models.task import Task, TaskAssignment
logger = logging.getLogger(__name__)
# 全局调度器实例
scheduler: Optional[AsyncIOScheduler] = None
async def get_db_session() -> AsyncSession:
"""获取数据库会话"""
engine = create_async_engine(settings.DATABASE_URL, echo=False)
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
return async_session()
async def send_learning_reminders():
"""
发送学习提醒
检查所有用户的学习进度,对长时间未学习的用户发送提醒
"""
logger.info("开始执行学习提醒任务")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 查找超过3天未学习的用户
three_days_ago = datetime.now() - timedelta(days=3)
result = await db.execute(
select(UserCourseProgress, User).join(
User, UserCourseProgress.user_id == User.id
).where(
and_(
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
UserCourseProgress.last_accessed_at < three_days_ago,
)
)
)
inactive_progress = result.all()
for progress, user in inactive_progress:
# 获取课程名称
from app.models.course import Course
course_result = await db.execute(
select(Course.name).where(Course.id == progress.course_id)
)
course_name = course_result.scalar() or "未知课程"
days_inactive = (datetime.now() - progress.last_accessed_at).days
# 发送提醒
await notification_service.send_learning_reminder(
user_id=user.id,
course_name=course_name,
days_inactive=days_inactive,
)
logger.info(f"已发送学习提醒: user_id={user.id}, course={course_name}")
await db.close()
logger.info(f"学习提醒任务完成,发送了 {len(inactive_progress)} 条提醒")
except Exception as e:
logger.error(f"学习提醒任务失败: {str(e)}")
async def send_task_deadline_reminders():
"""
发送任务截止提醒
检查即将到期的任务,发送提醒给相关用户
"""
logger.info("开始执行任务截止提醒")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 查找3天内到期的未完成任务
now = datetime.now()
three_days_later = now + timedelta(days=3)
result = await db.execute(
select(Task, TaskAssignment, User).join(
TaskAssignment, Task.id == TaskAssignment.task_id
).join(
User, TaskAssignment.user_id == User.id
).where(
and_(
Task.end_time.between(now, three_days_later),
TaskAssignment.status.in_(["not_started", "in_progress"]),
)
)
)
upcoming_tasks = result.all()
for task, assignment, user in upcoming_tasks:
await notification_service.send_task_deadline_reminder(
user_id=user.id,
task_name=task.name,
deadline=task.end_time,
)
logger.info(f"已发送任务截止提醒: user_id={user.id}, task={task.name}")
await db.close()
logger.info(f"任务截止提醒完成,发送了 {len(upcoming_tasks)} 条提醒")
except Exception as e:
logger.error(f"任务截止提醒失败: {str(e)}")
async def send_weekly_reports():
"""
发送周学习报告
每周一发送上周的学习统计报告
"""
logger.info("开始生成周学习报告")
try:
db = await get_db_session()
from app.services.notification_service import NotificationService
notification_service = NotificationService(db)
# 获取所有活跃用户
result = await db.execute(
select(User).where(User.is_active == True)
)
users = result.scalars().all()
# 计算上周时间范围
today = datetime.now().date()
last_week_start = today - timedelta(days=today.weekday() + 7)
last_week_end = last_week_start + timedelta(days=6)
for user in users:
# 统计学习时长
study_time_result = await db.execute(
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0)).where(
and_(
UserCourseProgress.user_id == user.id,
UserCourseProgress.last_accessed_at.between(
datetime.combine(last_week_start, datetime.min.time()),
datetime.combine(last_week_end, datetime.max.time()),
)
)
)
)
study_time = study_time_result.scalar() or 0
# 统计完成课程数
completed_result = await db.execute(
select(func.count(UserCourseProgress.id)).where(
and_(
UserCourseProgress.user_id == user.id,
UserCourseProgress.status == ProgressStatus.COMPLETED.value,
UserCourseProgress.completed_at.between(
datetime.combine(last_week_start, datetime.min.time()),
datetime.combine(last_week_end, datetime.max.time()),
)
)
)
)
courses_completed = completed_result.scalar() or 0
# 如果有学习活动,发送报告
if study_time > 0 or courses_completed > 0:
await notification_service.send_weekly_report(
user_id=user.id,
study_time=study_time,
courses_completed=courses_completed,
exams_passed=0, # TODO: 统计考试通过数
)
logger.info(f"已发送周报: user_id={user.id}")
await db.close()
logger.info("周学习报告发送完成")
except Exception as e:
logger.error(f"周学习报告发送失败: {str(e)}")
def init_scheduler():
"""初始化定时任务调度器"""
global scheduler
if scheduler is not None:
return scheduler
scheduler = AsyncIOScheduler()
# 学习提醒每天上午9点执行
scheduler.add_job(
send_learning_reminders,
CronTrigger(hour=9, minute=0),
id="learning_reminders",
name="学习提醒",
replace_existing=True,
)
# 任务截止提醒每天上午10点执行
scheduler.add_job(
send_task_deadline_reminders,
CronTrigger(hour=10, minute=0),
id="task_deadline_reminders",
name="任务截止提醒",
replace_existing=True,
)
# 周学习报告每周一上午8点发送
scheduler.add_job(
send_weekly_reports,
CronTrigger(day_of_week="mon", hour=8, minute=0),
id="weekly_reports",
name="周学习报告",
replace_existing=True,
)
logger.info("定时任务调度器初始化完成")
return scheduler
def start_scheduler():
"""启动调度器"""
global scheduler
if scheduler is None:
scheduler = init_scheduler()
if not scheduler.running:
scheduler.start()
logger.info("定时任务调度器已启动")
def stop_scheduler():
"""停止调度器"""
global scheduler
if scheduler and scheduler.running:
scheduler.shutdown()
logger.info("定时任务调度器已停止")
def get_scheduler() -> Optional[AsyncIOScheduler]:
"""获取调度器实例"""
return scheduler

View File

@@ -0,0 +1,256 @@
"""
语音识别服务
支持多种语音识别引擎:
1. 阿里云语音识别
2. 讯飞语音识别
3. 本地 Whisper 模型
"""
import os
import base64
import json
import hmac
import hashlib
import time
from datetime import datetime
from typing import Optional, Dict, Any
import httpx
from urllib.parse import urlencode
class SpeechRecognitionError(Exception):
"""语音识别错误"""
pass
class AliyunSpeechRecognition:
"""
阿里云智能语音交互 - 一句话识别
文档: https://help.aliyun.com/document_detail/92131.html
"""
def __init__(
self,
access_key_id: Optional[str] = None,
access_key_secret: Optional[str] = None,
app_key: Optional[str] = None,
):
self.access_key_id = access_key_id or os.getenv("ALIYUN_ACCESS_KEY_ID")
self.access_key_secret = access_key_secret or os.getenv("ALIYUN_ACCESS_KEY_SECRET")
self.app_key = app_key or os.getenv("ALIYUN_NLS_APP_KEY")
self.api_url = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
def _create_signature(self, params: Dict[str, str]) -> str:
"""创建签名"""
sorted_params = sorted(params.items())
query_string = urlencode(sorted_params)
string_to_sign = f"POST&%2F&{urlencode({query_string: ''}).split('=')[0]}"
signature = hmac.new(
(self.access_key_secret + "&").encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
return base64.b64encode(signature).decode("utf-8")
async def recognize(
self,
audio_data: bytes,
format: str = "wav",
sample_rate: int = 16000,
) -> str:
"""
识别音频
Args:
audio_data: 音频数据(二进制)
format: 音频格式,支持 pcm, wav, ogg, opus, mp3
sample_rate: 采样率,默认 16000
Returns:
识别出的文本
"""
if not all([self.access_key_id, self.access_key_secret, self.app_key]):
raise SpeechRecognitionError("阿里云语音识别配置不完整")
headers = {
"Content-Type": f"audio/{format}; samplerate={sample_rate}",
"X-NLS-Token": await self._get_token(),
}
params = {
"appkey": self.app_key,
"format": format,
"sample_rate": str(sample_rate),
}
try:
async with httpx.AsyncClient() as client:
response = await client.post(
self.api_url,
params=params,
headers=headers,
content=audio_data,
timeout=30.0,
)
if response.status_code != 200:
raise SpeechRecognitionError(
f"阿里云语音识别请求失败: {response.status_code}"
)
result = response.json()
if result.get("status") == 20000000:
return result.get("result", "")
else:
raise SpeechRecognitionError(
f"语音识别失败: {result.get('message', '未知错误')}"
)
except httpx.RequestError as e:
raise SpeechRecognitionError(f"网络请求错误: {str(e)}")
async def _get_token(self) -> str:
"""获取访问令牌"""
# 简化版:实际生产环境需要缓存 token
token_url = "https://nls-meta.cn-shanghai.aliyuncs.com/"
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
params = {
"AccessKeyId": self.access_key_id,
"Action": "CreateToken",
"Format": "JSON",
"RegionId": "cn-shanghai",
"SignatureMethod": "HMAC-SHA1",
"SignatureNonce": str(int(time.time() * 1000)),
"SignatureVersion": "1.0",
"Timestamp": timestamp,
"Version": "2019-02-28",
}
params["Signature"] = self._create_signature(params)
async with httpx.AsyncClient() as client:
response = await client.get(token_url, params=params, timeout=10.0)
result = response.json()
if "Token" in result:
return result["Token"]["Id"]
else:
raise SpeechRecognitionError(
f"获取阿里云语音识别 Token 失败: {result.get('Message', '未知错误')}"
)
class XunfeiSpeechRecognition:
"""
讯飞语音识别
文档: https://www.xfyun.cn/doc/asr/voicedictation/API.html
"""
def __init__(
self,
app_id: Optional[str] = None,
api_key: Optional[str] = None,
api_secret: Optional[str] = None,
):
self.app_id = app_id or os.getenv("XUNFEI_APP_ID")
self.api_key = api_key or os.getenv("XUNFEI_API_KEY")
self.api_secret = api_secret or os.getenv("XUNFEI_API_SECRET")
self.api_url = "wss://iat-api.xfyun.cn/v2/iat"
async def recognize(
self,
audio_data: bytes,
format: str = "audio/L16;rate=16000",
) -> str:
"""
识别音频
Args:
audio_data: 音频数据(二进制)
format: 音频格式
Returns:
识别出的文本
"""
if not all([self.app_id, self.api_key, self.api_secret]):
raise SpeechRecognitionError("讯飞语音识别配置不完整")
# 讯飞使用 WebSocket这里是简化实现
# 实际需要使用 websockets 库进行实时流式识别
raise NotImplementedError("讯飞语音识别需要 WebSocket 实现")
class SimpleSpeechRecognition:
"""
简易语音识别实现
使用浏览器 Web Speech API 的结果直接返回
用于前端已经完成识别的情况
"""
async def recognize(self, text: str) -> str:
"""直接返回前端传来的识别结果"""
return text.strip()
class SpeechRecognitionService:
"""
语音识别服务统一接口
根据配置选择不同的识别引擎
"""
def __init__(self, engine: str = "simple"):
"""
初始化语音识别服务
Args:
engine: 识别引擎,支持 aliyun, xunfei, simple
"""
self.engine = engine
if engine == "aliyun":
self._recognizer = AliyunSpeechRecognition()
elif engine == "xunfei":
self._recognizer = XunfeiSpeechRecognition()
else:
self._recognizer = SimpleSpeechRecognition()
async def recognize_audio(
self,
audio_data: bytes,
format: str = "wav",
sample_rate: int = 16000,
) -> str:
"""
识别音频数据
Args:
audio_data: 音频二进制数据
format: 音频格式
sample_rate: 采样率
Returns:
识别出的文本
"""
if self.engine == "simple":
raise SpeechRecognitionError(
"简易模式不支持音频识别,请使用前端 Web Speech API"
)
return await self._recognizer.recognize(audio_data, format, sample_rate)
async def recognize_text(self, text: str) -> str:
"""
直接处理已识别的文本(用于前端已完成识别的情况)
Args:
text: 已识别的文本
Returns:
处理后的文本
"""
return text.strip()
# 创建默认服务实例
def get_speech_recognition_service(engine: str = "simple") -> SpeechRecognitionService:
"""获取语音识别服务实例"""
return SpeechRecognitionService(engine=engine)

View File

@@ -54,3 +54,8 @@ jsonschema>=4.0.0
# PDF 文档提取 # PDF 文档提取
PyPDF2>=3.0.0 PyPDF2>=3.0.0
python-docx>=1.0.0 python-docx>=1.0.0
# 证书生成
Pillow>=10.0.0
qrcode>=7.4.0
weasyprint>=60.0

View File

@@ -0,0 +1,158 @@
/**
* 用户学习进度 API
*/
import { request } from '@/utils/request'
// ============ 类型定义 ============
export interface MaterialProgress {
material_id: number
material_name: string
is_completed: boolean
progress_percent: number
last_position: number
study_time: number
first_accessed_at: string | null
last_accessed_at: string | null
completed_at: string | null
}
export interface CourseProgress {
course_id: number
course_name: string
status: 'not_started' | 'in_progress' | 'completed'
progress_percent: number
completed_materials: number
total_materials: number
total_study_time: number
first_accessed_at: string | null
last_accessed_at: string | null
completed_at: string | null
materials?: MaterialProgress[]
}
export interface ProgressSummary {
total_courses: number
completed_courses: number
in_progress_courses: number
not_started_courses: number
total_study_time: number
average_progress: number
}
export interface MaterialProgressUpdate {
progress_percent: number
last_position?: number
study_time_delta?: number
is_completed?: boolean
}
// ============ API 方法 ============
/**
* 获取学习进度摘要
*/
export const getProgressSummary = () => {
return request.get<ProgressSummary>('/api/v1/progress/summary')
}
/**
* 获取所有课程学习进度
*/
export const getAllCourseProgress = (status?: string) => {
return request.get<CourseProgress[]>('/api/v1/progress/courses', {
params: status ? { status } : undefined,
})
}
/**
* 获取指定课程的详细学习进度
*/
export const getCourseProgress = (courseId: number) => {
return request.get<CourseProgress>(`/api/v1/progress/courses/${courseId}`)
}
/**
* 更新资料学习进度
*/
export const updateMaterialProgress = (
materialId: number,
data: MaterialProgressUpdate
) => {
return request.post<MaterialProgress>(
`/api/v1/progress/materials/${materialId}`,
data
)
}
/**
* 标记资料为已完成
*/
export const markMaterialComplete = (materialId: number) => {
return request.post<MaterialProgress>(
`/api/v1/progress/materials/${materialId}/complete`
)
}
/**
* 开始学习课程
*/
export const startCourse = (courseId: number) => {
return request.post(`/api/v1/progress/courses/${courseId}/start`)
}
/**
* 格式化学习时长
*/
export const formatStudyTime = (seconds: number): string => {
if (seconds < 60) {
return `${seconds}`
}
if (seconds < 3600) {
const minutes = Math.floor(seconds / 60)
return `${minutes}分钟`
}
const hours = Math.floor(seconds / 3600)
const minutes = Math.floor((seconds % 3600) / 60)
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
}
/**
* 获取进度状态文本
*/
export const getProgressStatusText = (
status: 'not_started' | 'in_progress' | 'completed'
): string => {
const statusMap = {
not_started: '未开始',
in_progress: '学习中',
completed: '已完成',
}
return statusMap[status] || status
}
/**
* 获取进度状态颜色
*/
export const getProgressStatusType = (
status: 'not_started' | 'in_progress' | 'completed'
): 'info' | 'warning' | 'success' => {
const typeMap: Record<string, 'info' | 'warning' | 'success'> = {
not_started: 'info',
in_progress: 'warning',
completed: 'success',
}
return typeMap[status] || 'info'
}
export default {
getProgressSummary,
getAllCourseProgress,
getCourseProgress,
updateMaterialProgress,
markMaterialComplete,
startCourse,
formatStudyTime,
getProgressStatusText,
getProgressStatusType,
}

View File

@@ -7,6 +7,7 @@ import { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { authManager } from '@/utils/auth' import { authManager } from '@/utils/auth'
import { loadingManager } from '@/utils/loadingManager' import { loadingManager } from '@/utils/loadingManager'
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
// 白名单路由(不需要登录) // 白名单路由(不需要登录)
const WHITE_LIST = ['/login', '/register', '/404'] const WHITE_LIST = ['/login', '/register', '/404']
@@ -109,13 +110,21 @@ async function handleRouteGuard(
return return
} }
// 检查特殊路由规则 // 检查特殊路由规则(先进行同步检查)
if (!checkSpecialRouteRules(to)) { if (!checkSpecialRouteRules(to)) {
ElMessage.error('访问被拒绝') ElMessage.error('访问被拒绝')
next(authManager.getDefaultRoute()) next(authManager.getDefaultRoute())
return return
} }
// 异步权限检查(团队和课程权限)
const hasSpecialAccess = await checkSpecialRouteRulesAsync(to)
if (!hasSpecialAccess) {
ElMessage.error('您没有访问此资源的权限')
next(authManager.getDefaultRoute())
return
}
next() next()
} }
@@ -142,9 +151,9 @@ function checkRoutePermission(path: string): boolean {
} }
/** /**
* 检查特殊路由规则 * 检查特殊路由规则(异步版本)
*/ */
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean { async function checkSpecialRouteRulesAsync(to: RouteLocationNormalized): Promise<boolean> {
const { path, params } = to const { path, params } = to
// 检查用户ID参数权限只能访问自己的数据管理员除外 // 检查用户ID参数权限只能访问自己的数据管理员除外
@@ -157,14 +166,41 @@ function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
// 检查团队ID参数权限 // 检查团队ID参数权限
if (params.teamId && !authManager.isAdmin()) { if (params.teamId && !authManager.isAdmin()) {
// 这里可以添加团队权限检查逻辑 const teamId = Number(params.teamId)
// 暂时允许通过,实际项目中需要检查用户是否属于该团队 if (!isNaN(teamId)) {
const isMember = await checkTeamMembership(teamId)
if (!isMember) {
return false
}
}
} }
// 检查课程访问权限 // 检查课程访问权限
if (path.includes('/course/') && params.courseId) { if (path.includes('/course/') && params.courseId) {
// 这里可以添加课程访问权限检查 const courseId = Number(params.courseId)
// 例如检查课程是否分配给用户的岗位 if (!isNaN(courseId)) {
const hasAccess = await checkCourseAccess(courseId)
if (!hasAccess) {
return false
}
}
}
return true
}
/**
* 检查特殊路由规则(同步版本,用于简单检查)
*/
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
const { params } = to
// 检查用户ID参数权限只能访问自己的数据管理员除外
if (params.userId && !authManager.isAdmin()) {
const currentUser = authManager.getCurrentUser()
if (currentUser && String(params.userId) !== String(currentUser.id)) {
return false
}
} }
return true return true

View File

@@ -161,6 +161,12 @@ class AuthManager {
localStorage.removeItem(this.userKey) localStorage.removeItem(this.userKey)
localStorage.removeItem(this.tokenKey) localStorage.removeItem(this.tokenKey)
localStorage.removeItem(this.refreshTokenKey) localStorage.removeItem(this.refreshTokenKey)
// 清除权限缓存
import('@/utils/permissionChecker').then(({ clearPermissionCache }) => {
clearPermissionCache()
}).catch(() => {
// 忽略导入错误
})
} }
/** /**

View File

@@ -0,0 +1,211 @@
/**
* 权限检查工具
* 用于前端路由守卫和组件级权限控制
*/
import { authManager } from './auth'
// 缓存团队成员关系
const teamMembershipCache = new Map<number, boolean>()
// 缓存课程访问权限
const courseAccessCache = new Map<number, boolean>()
// 缓存过期时间5分钟
const CACHE_TTL = 5 * 60 * 1000
let lastCacheUpdate = 0
/**
* 清除权限缓存
*/
export function clearPermissionCache() {
teamMembershipCache.clear()
courseAccessCache.clear()
lastCacheUpdate = 0
}
/**
* 检查缓存是否过期
*/
function isCacheExpired(): boolean {
return Date.now() - lastCacheUpdate > CACHE_TTL
}
/**
* 更新缓存时间戳
*/
function updateCacheTimestamp() {
lastCacheUpdate = Date.now()
}
/**
* 检查用户是否属于指定团队
* @param teamId 团队ID
*/
export async function checkTeamMembership(teamId: number): Promise<boolean> {
// 管理员可以访问所有团队
if (authManager.isAdmin()) {
return true
}
// 检查缓存
if (!isCacheExpired() && teamMembershipCache.has(teamId)) {
return teamMembershipCache.get(teamId)!
}
try {
const currentUser = authManager.getCurrentUser()
if (!currentUser) {
return false
}
// 检查用户的团队列表
const userTeams = currentUser.teams || []
const isMember = userTeams.some((team: any) => team.id === teamId)
// 更新缓存
teamMembershipCache.set(teamId, isMember)
updateCacheTimestamp()
return isMember
} catch (error) {
console.error('检查团队成员身份失败:', error)
return false
}
}
/**
* 检查用户是否可以访问指定课程
* @param courseId 课程ID
*/
export async function checkCourseAccess(courseId: number): Promise<boolean> {
// 管理员和经理可以访问所有课程
if (authManager.isAdmin() || authManager.isManager()) {
return true
}
// 检查缓存
if (!isCacheExpired() && courseAccessCache.has(courseId)) {
return courseAccessCache.get(courseId)!
}
try {
// 简化检查:学员可以访问所有已发布的课程
// 后端会在 API 层面做更细粒度的权限控制
// 这里暂时放行,让后端决定是否返回 403
const hasAccess = true
// 更新缓存
courseAccessCache.set(courseId, hasAccess)
updateCacheTimestamp()
return hasAccess
} catch (error) {
console.error('检查课程访问权限失败:', error)
return false
}
}
/**
* 检查用户是否有某个权限
* @param permission 权限代码
*/
export function hasPermission(permission: string): boolean {
return authManager.hasPermission(permission)
}
/**
* 检查用户是否有任意一个权限
* @param permissions 权限代码列表
*/
export function hasAnyPermission(permissions: string[]): boolean {
return authManager.hasAnyPermission(permissions)
}
/**
* 检查用户是否有所有权限
* @param permissions 权限代码列表
*/
export function hasAllPermissions(permissions: string[]): boolean {
return authManager.hasAllPermissions(permissions)
}
/**
* 获取用户的所有权限
*/
export function getUserPermissions(): string[] {
return authManager.getUserPermissions()
}
/**
* 权限检查结果接口
*/
export interface PermissionCheckResult {
allowed: boolean
reason?: string
}
/**
* 综合权限检查
* @param options 检查选项
*/
export async function checkPermission(options: {
teamId?: number
courseId?: number
userId?: number
permissions?: string[]
roles?: string[]
}): Promise<PermissionCheckResult> {
const { teamId, courseId, userId, permissions, roles } = options
// 检查角色
if (roles && roles.length > 0) {
const userRole = authManager.getUserRole()
if (!userRole || (!roles.includes(userRole) && !authManager.isAdmin())) {
return { allowed: false, reason: '角色权限不足' }
}
}
// 检查权限
if (permissions && permissions.length > 0) {
if (!hasAnyPermission(permissions)) {
return { allowed: false, reason: '缺少必要权限' }
}
}
// 检查用户ID只能访问自己的数据
if (userId !== undefined) {
const currentUser = authManager.getCurrentUser()
if (!authManager.isAdmin() && currentUser?.id !== userId) {
return { allowed: false, reason: '无权访问其他用户数据' }
}
}
// 检查团队成员身份
if (teamId !== undefined) {
const isMember = await checkTeamMembership(teamId)
if (!isMember) {
return { allowed: false, reason: '不是该团队成员' }
}
}
// 检查课程访问权限
if (courseId !== undefined) {
const hasAccess = await checkCourseAccess(courseId)
if (!hasAccess) {
return { allowed: false, reason: '无权访问该课程' }
}
}
return { allowed: true }
}
export default {
clearPermissionCache,
checkTeamMembership,
checkCourseAccess,
hasPermission,
hasAnyPermission,
hasAllPermissions,
getUserPermissions,
checkPermission,
}

View File

@@ -0,0 +1,294 @@
/**
* 语音识别工具
* 使用 Web Speech API 进行浏览器端语音识别
*/
// Web Speech API 类型声明
interface SpeechRecognitionEvent extends Event {
results: SpeechRecognitionResultList
resultIndex: number
}
interface SpeechRecognitionResultList {
readonly length: number
item(index: number): SpeechRecognitionResult
[index: number]: SpeechRecognitionResult
}
interface SpeechRecognitionResult {
readonly length: number
readonly isFinal: boolean
item(index: number): SpeechRecognitionAlternative
[index: number]: SpeechRecognitionAlternative
}
interface SpeechRecognitionAlternative {
readonly transcript: string
readonly confidence: number
}
interface SpeechRecognitionErrorEvent extends Event {
error: string
message: string
}
declare global {
interface Window {
SpeechRecognition: new () => SpeechRecognition
webkitSpeechRecognition: new () => SpeechRecognition
}
}
interface SpeechRecognition extends EventTarget {
continuous: boolean
interimResults: boolean
lang: string
maxAlternatives: number
start(): void
stop(): void
abort(): void
onresult: ((event: SpeechRecognitionEvent) => void) | null
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
onend: (() => void) | null
onstart: (() => void) | null
onspeechend: (() => void) | null
}
// 语音识别配置
export interface SpeechRecognitionConfig {
continuous?: boolean
interimResults?: boolean
lang?: string
maxAlternatives?: number
}
// 语音识别结果
export interface SpeechRecognitionResult {
transcript: string
isFinal: boolean
confidence: number
}
// 语音识别回调
export interface SpeechRecognitionCallbacks {
onResult?: (result: SpeechRecognitionResult) => void
onError?: (error: string) => void
onStart?: () => void
onEnd?: () => void
}
/**
* 检查浏览器是否支持语音识别
*/
export function isSpeechRecognitionSupported(): boolean {
return !!(window.SpeechRecognition || window.webkitSpeechRecognition)
}
/**
* 创建语音识别实例
*/
export function createSpeechRecognition(
config: SpeechRecognitionConfig = {}
): SpeechRecognition | null {
const SpeechRecognitionConstructor =
window.SpeechRecognition || window.webkitSpeechRecognition
if (!SpeechRecognitionConstructor) {
console.warn('浏览器不支持语音识别')
return null
}
const recognition = new SpeechRecognitionConstructor()
recognition.continuous = config.continuous ?? false
recognition.interimResults = config.interimResults ?? true
recognition.lang = config.lang ?? 'zh-CN'
recognition.maxAlternatives = config.maxAlternatives ?? 1
return recognition
}
/**
* 语音识别管理器类
*/
export class SpeechRecognitionManager {
private recognition: SpeechRecognition | null = null
private isListening = false
private callbacks: SpeechRecognitionCallbacks = {}
constructor(config: SpeechRecognitionConfig = {}) {
this.recognition = createSpeechRecognition(config)
this.setupEventListeners()
}
private setupEventListeners() {
if (!this.recognition) return
this.recognition.onresult = (event: SpeechRecognitionEvent) => {
const lastResult = event.results[event.resultIndex]
if (lastResult) {
const result: SpeechRecognitionResult = {
transcript: lastResult[0].transcript,
isFinal: lastResult.isFinal,
confidence: lastResult[0].confidence,
}
this.callbacks.onResult?.(result)
}
}
this.recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
const errorMessages: Record<string, string> = {
'no-speech': '没有检测到语音',
'audio-capture': '无法访问麦克风',
'not-allowed': '麦克风权限被拒绝',
'network': '网络错误',
'aborted': '识别被中断',
'language-not-supported': '不支持的语言',
}
const message = errorMessages[event.error] || `识别错误: ${event.error}`
this.callbacks.onError?.(message)
this.isListening = false
}
this.recognition.onstart = () => {
this.isListening = true
this.callbacks.onStart?.()
}
this.recognition.onend = () => {
this.isListening = false
this.callbacks.onEnd?.()
}
}
/**
* 设置回调函数
*/
setCallbacks(callbacks: SpeechRecognitionCallbacks) {
this.callbacks = callbacks
}
/**
* 开始语音识别
*/
start(): boolean {
if (!this.recognition) {
this.callbacks.onError?.('浏览器不支持语音识别')
return false
}
if (this.isListening) {
return true
}
try {
this.recognition.start()
return true
} catch (error) {
this.callbacks.onError?.('启动语音识别失败')
return false
}
}
/**
* 停止语音识别
*/
stop() {
if (this.recognition && this.isListening) {
this.recognition.stop()
}
}
/**
* 中止语音识别
*/
abort() {
if (this.recognition) {
this.recognition.abort()
}
}
/**
* 是否正在监听
*/
getIsListening(): boolean {
return this.isListening
}
/**
* 是否支持语音识别
*/
isSupported(): boolean {
return this.recognition !== null
}
/**
* 销毁实例
*/
destroy() {
this.abort()
this.recognition = null
}
}
/**
* 一次性语音识别
* 返回 Promise识别完成后返回结果
*/
export function recognizeSpeech(
config: SpeechRecognitionConfig = {},
timeout = 10000
): Promise<string> {
return new Promise((resolve, reject) => {
const manager = new SpeechRecognitionManager({
...config,
continuous: false,
interimResults: false,
})
if (!manager.isSupported()) {
reject(new Error('浏览器不支持语音识别'))
return
}
let finalTranscript = ''
let timeoutId: number | null = null
manager.setCallbacks({
onResult: (result) => {
if (result.isFinal) {
finalTranscript = result.transcript
}
},
onEnd: () => {
if (timeoutId) {
clearTimeout(timeoutId)
}
manager.destroy()
resolve(finalTranscript)
},
onError: (error) => {
if (timeoutId) {
clearTimeout(timeoutId)
}
manager.destroy()
reject(new Error(error))
},
})
// 设置超时
timeoutId = window.setTimeout(() => {
manager.stop()
}, timeout)
if (!manager.start()) {
reject(new Error('启动语音识别失败'))
}
})
}
export default {
isSpeechRecognitionSupported,
createSpeechRecognition,
SpeechRecognitionManager,
recognizeSpeech,
}

View File

@@ -183,6 +183,11 @@ import {
type CozeSession, type CozeSession,
type StreamEvent type StreamEvent
} from '@/api/coze' } from '@/api/coze'
import {
SpeechRecognitionManager,
isSpeechRecognitionSupported,
type SpeechRecognitionResult
} from '@/utils/speechRecognition'
const router = useRouter() const router = useRouter()
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
const mediaRecorder = ref<MediaRecorder | null>(null) const mediaRecorder = ref<MediaRecorder | null>(null)
const audioChunks = ref<Blob[]>([]) const audioChunks = ref<Blob[]>([])
// 语音识别相关
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
const recognizedText = ref('')
const isSpeechSupported = isSpeechRecognitionSupported()
// DOM引用 // DOM引用
const messageContainer = ref<HTMLElement>() const messageContainer = ref<HTMLElement>()
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
} }
/** /**
* 开始录音 * 开始录音(同时启动语音识别)
*/ */
const startRecording = async () => { const startRecording = async () => {
if (!cozeSession.value) {
ElMessage.warning('请先开始陪练会话')
return
}
// 优先使用 Web Speech API 进行实时语音识别
if (isSpeechSupported) {
startSpeechRecognition()
return
}
// 降级到录音模式(需要后端语音识别服务)
try { try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true }) const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
@@ -400,7 +422,7 @@ const startRecording = async () => {
mediaRecorder.value.start() mediaRecorder.value.start()
isRecording.value = true isRecording.value = true
voiceStatusText.value = '正在录音...' voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
} catch (error) { } catch (error) {
ElMessage.error('无法访问麦克风') ElMessage.error('无法访问麦克风')
} }
@@ -410,6 +432,13 @@ const startRecording = async () => {
* 停止录音 * 停止录音
*/ */
const stopRecording = () => { const stopRecording = () => {
// 如果使用的是 Web Speech API
if (speechRecognition.value) {
stopSpeechRecognition()
return
}
// 如果使用的是录音模式
if (mediaRecorder.value && isRecording.value) { if (mediaRecorder.value && isRecording.value) {
mediaRecorder.value.stop() mediaRecorder.value.stop()
mediaRecorder.value.stream.getTracks().forEach(track => track.stop()) mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
@@ -420,13 +449,116 @@ const stopRecording = () => {
} }
/** /**
* 处理录音 * 处理录音(使用 Web Speech API 已识别的文本)
*/ */
const processAudio = async (_audioBlob: Blob) => { const processAudio = async (_audioBlob: Blob) => {
// TODO: 实现音频转文本和发送逻辑 try {
isProcessing.value = false // 检查是否有识别结果
voiceStatusText.value = '点击开始按钮进行语音陪练' const text = recognizedText.value.trim()
ElMessage.info('语音功能正在开发中') if (!text) {
ElMessage.warning('没有检测到语音内容')
return
}
// 清空识别结果
recognizedText.value = ''
// 发送识别的文本消息
if (cozeSession.value) {
// 添加用户消息
messages.value.push({
role: 'user',
content: text,
timestamp: new Date()
})
await scrollToBottom()
isLoading.value = true
// 创建AI回复消息占位
const assistantMessage = {
role: 'assistant',
content: '',
timestamp: new Date()
}
messages.value.push(assistantMessage)
// 流式发送消息
await sendCozeMessage(
cozeSession.value.sessionId,
text,
(event: StreamEvent) => {
if (event.type === 'message.delta') {
assistantMessage.content += event.content
scrollToBottom()
} else if (event.type === 'message.completed') {
isLoading.value = false
}
}
)
}
} catch (error: any) {
ElMessage.error('发送消息失败:' + (error.message || '未知错误'))
} finally {
isProcessing.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
/**
* 开始语音识别
*/
const startSpeechRecognition = () => {
if (!isSpeechSupported) {
ElMessage.warning('您的浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器')
return
}
// 创建语音识别管理器
speechRecognition.value = new SpeechRecognitionManager({
continuous: true,
interimResults: true,
lang: 'zh-CN'
})
speechRecognition.value.setCallbacks({
onResult: (result: SpeechRecognitionResult) => {
recognizedText.value = result.transcript
voiceStatusText.value = result.isFinal
? `识别结果: ${result.transcript}`
: `正在识别: ${result.transcript}`
},
onError: (error: string) => {
ElMessage.error(error)
stopSpeechRecognition()
},
onStart: () => {
isRecording.value = true
voiceStatusText.value = '正在监听,请说话...'
},
onEnd: () => {
// 识别结束后自动处理
if (recognizedText.value.trim()) {
processAudio(new Blob())
} else {
isRecording.value = false
voiceStatusText.value = '点击开始按钮进行语音陪练'
}
}
})
speechRecognition.value.start()
}
/**
* 停止语音识别
*/
const stopSpeechRecognition = () => {
if (speechRecognition.value) {
speechRecognition.value.stop()
speechRecognition.value = null
}
isRecording.value = false
} }
/** /**
@@ -456,6 +588,10 @@ onUnmounted(() => {
if (mediaRecorder.value && isRecording.value) { if (mediaRecorder.value && isRecording.value) {
stopRecording() stopRecording()
} }
if (speechRecognition.value) {
speechRecognition.value.destroy()
speechRecognition.value = null
}
}) })
</script> </script>

View File

@@ -184,6 +184,8 @@
import { ref, onMounted } from 'vue' import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import { ArrowLeft } from '@element-plus/icons-vue' import { ArrowLeft } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { getPracticeReport } from '@/api/duoPractice'
const route = useRoute() const route = useRoute()
const router = useRouter() const router = useRouter()
@@ -223,22 +225,69 @@ const getScoreColor = (score: number) => {
// 加载报告数据 // 加载报告数据
const loadReport = async () => { const loadReport = async () => {
const roomId = route.params.id const roomCode = route.params.id as string
if (!roomId) return if (!roomCode) return
isLoading.value = true isLoading.value = true
try { try {
// TODO: 调用 API 获取报告 // 调用 API 获取报告
// const res = await getDuoPracticeReport(roomId) const res = await getPracticeReport(roomCode)
// roomInfo.value = res.data.room if (res.data) {
// analysisResult.value = res.data.analysis roomInfo.value = res.data.room
// 模拟数据 // 转换分析数据格式以兼容现有模板
roomInfo.value = { const analysis = res.data.analysis
scene_name: '销售场景对练', analysisResult.value = {
duration_seconds: 300, overall_evaluation: {
total_turns: 15 interaction_quality: analysis?.quality?.engagement_score || 0,
scene_restoration: analysis?.quality?.response_quality || 0,
overall_comment: analysis?.summary || ''
},
user_a_evaluation: {
user_name: res.data.participants?.host?.username || '用户A',
role_name: res.data.room?.role_a_name || '角色A',
total_score: analysis?.quality?.overall_score || 0,
dimensions: {
role_immersion: { score: analysis?.participation?.balance_score || 0, comment: '' },
communication: { score: analysis?.quality?.engagement_score || 0, comment: '' },
professional_knowledge: { score: analysis?.quality?.response_quality || 0, comment: '' },
response_quality: { score: analysis?.quality?.overall_score || 0, comment: '' },
goal_achievement: { score: analysis?.quality?.overall_score || 0, comment: '' }
},
highlights: analysis?.suggestions?.filter((s: string) => s.includes('良好') || s.includes('保持')) || [],
improvements: analysis?.suggestions?.filter((s: string) => !s.includes('良好') && !s.includes('保持')).map((s: string) => ({
issue: s,
suggestion: s
})) || []
},
user_b_evaluation: {
user_name: res.data.participants?.guest?.username || '用户B',
role_name: res.data.room?.role_b_name || '角色B',
total_score: analysis?.quality?.overall_score || 0,
dimensions: {
role_immersion: { score: analysis?.participation?.balance_score || 0, comment: '' },
communication: { score: analysis?.quality?.engagement_score || 0, comment: '' },
professional_knowledge: { score: analysis?.quality?.response_quality || 0, comment: '' },
response_quality: { score: analysis?.quality?.overall_score || 0, comment: '' },
goal_achievement: { score: analysis?.quality?.overall_score || 0, comment: '' }
},
highlights: [],
improvements: []
}
}
return
} }
} catch (error: any) {
console.error('加载报告失败:', error)
ElMessage.warning('加载报告数据失败,使用演示数据')
}
// 降级到模拟数据
roomInfo.value = {
scene_name: '销售场景对练',
duration_seconds: 300,
total_turns: 15
}
analysisResult.value = { analysisResult.value = {
overall_evaluation: { overall_evaluation: {