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

1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

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

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

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

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

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

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

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

View File

@@ -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()