""" 用户课程学习进度 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.deps import get_db, 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()