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:
470
backend/app/api/v1/endpoints/progress.py
Normal file
470
backend/app/api/v1/endpoints/progress.py
Normal 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()
|
||||
Reference in New Issue
Block a user