"""培训与技能认证体系 API 路由。 覆盖:课程浏览、报名、学习进度、结业证书、运营端课程管理。 完整闭环:参加培训 → 完成学习 → 通过考核 → 获得证书 → 授予徽章 → 信用加分。 """ from __future__ import annotations import asyncio import contextlib from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from typing import Optional from ...infrastructure.repositories import Database, new_id, utcnow_iso from ...infrastructure.models import ( TrainingCourse, TrainingChapter, TrainingLesson, TrainingCourseEnrollment, TrainingLessonProgress, TrainingCertificate, ) import json router = APIRouter() # ── 依赖 ────────────────────────────────────────────────────────────── async def get_db() -> Database: db = Database() try: yield db finally: try: await db.close() except asyncio.CancelledError: # 请求任务被取消:让 close 在独立任务中完成,确保连接归还连接池。 with contextlib.suppress(BaseException): await asyncio.shield(db.close()) raise def get_current_user(authorization: str = Query(default="")) -> dict: """简化的用户获取(实际项目中从 JWT 解析)。""" # 这里返回一个模拟用户,实际应从 auth 依赖获取 return {"id": "demo_user", "username": "demo"} # ── 请求模型 ────────────────────────────────────────────────────────── class EnrollRequest(BaseModel): pass class LessonProgressRequest(BaseModel): watch_seconds: int = 0 class LessonCompleteRequest(BaseModel): quiz_score: Optional[int] = None class CourseCreateRequest(BaseModel): title: str subtitle: str = "" description: str = "" cover_url: str = "" category: str = "" level: str = "beginner" price: int = 0 duration_minutes: int = 0 instructor_name: str = "" instructor_title: str = "" instructor_avatar: str = "" tags: list = [] prerequisites: str = "" learning_outcomes: str = "" status: str = "draft" is_featured: int = 0 sort_order: int = 0 class ChapterCreateRequest(BaseModel): course_id: str title: str description: str = "" sort_order: int = 0 class LessonCreateRequest(BaseModel): chapter_id: str course_id: str title: str type: str = "video" content: str = "" duration_minutes: int = 0 sort_order: int = 0 is_free: int = 0 is_exam: int = 0 # ── 课程分类常量 ─────────────────────────────────────────────────────── COURSE_CATEGORIES = [ {"code": "ui_design", "name": "UI设计", "icon": "PaletteFilled"}, {"code": "graphic_design", "name": "平面设计", "icon": "ImageFilled"}, {"code": "frontend_dev", "name": "前端开发", "icon": "CodeFilled"}, {"code": "backend_dev", "name": "后端开发", "icon": "ServerFilled"}, {"code": "copywriting", "name": "文案写作", "icon": "PenFilled"}, {"code": "translation", "name": "翻译", "icon": "LanguagesFilled"}, {"code": "data_analysis", "name": "数据分析", "icon": "ChartBarFilled"}, {"code": "project_management", "name": "项目管理", "icon": "TrendingUpFilled"}, ] LEVEL_LABELS = { "beginner": "入门", "intermediate": "进阶", "advanced": "高级", "expert": "专家", } # ══════════════════════════════════════════════════════════════════════ # 公开:课程浏览 # ══════════════════════════════════════════════════════════════════════ @router.get("/courses") async def list_courses( category: str = "", level: str = "", search: str = "", featured_only: bool = False, db: Database = Depends(get_db), ): """课程列表(公开,仅返回已发布课程)。""" courses = await db.courses.list( category=category, level=level, status="published", search=search, featured_only=featured_only, ) # 附加分类名称和等级名称 for c in courses: cat = next((x for x in COURSE_CATEGORIES if x["code"] == c["category"]), None) c["category_name"] = cat["name"] if cat else c["category"] c["level_name"] = LEVEL_LABELS.get(c["level"], c["level"]) return {"items": courses, "total": len(courses)} @router.get("/courses/categories") async def list_course_categories(db: Database = Depends(get_db)): """课程分类列表(含课程数统计)。""" courses = await db.courses.list(status="published") cat_counts = {} for c in courses: cat_counts[c["category"]] = cat_counts.get(c["category"], 0) + 1 result = [] for cat in COURSE_CATEGORIES: result.append({ **cat, "course_count": cat_counts.get(cat["code"], 0), }) return {"items": result} @router.get("/courses/{course_id}") async def get_course_detail(course_id: str, db: Database = Depends(get_db)): """课程详情(含章节和课时列表)。""" course = await db.courses.get(course_id) if not course: raise HTTPException(status_code=404, detail="课程不存在") cat = next((x for x in COURSE_CATEGORIES if x["code"] == course["category"]), None) course["category_name"] = cat["name"] if cat else course["category"] course["level_name"] = LEVEL_LABELS.get(course["level"], course["level"]) # 获取章节和课时 chapters = await db.course_chapters.list_by_course(course_id) for ch in chapters: ch["lessons"] = await db.course_lessons.list_by_chapter(ch["id"]) course["chapters"] = chapters course["total_lessons"] = sum(len(ch["lessons"]) for ch in chapters) return course # ══════════════════════════════════════════════════════════════════════ # 我的课程:报名、学习进度 # ══════════════════════════════════════════════════════════════════════ @router.get("/me/courses") async def list_my_courses( status: str = "", db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """我报名的课程列表。""" enrollments = await db.course_enrollments.list_by_user(user["id"], status=status) # 附加课程信息 for e in enrollments: course = await db.courses.get(e["course_id"]) if course: e["course"] = course cat = next((x for x in COURSE_CATEGORIES if x["code"] == course["category"]), None) e["course"]["category_name"] = cat["name"] if cat else course["category"] return {"items": enrollments, "total": len(enrollments)} @router.post("/courses/{course_id}/enroll") async def enroll_course( course_id: str, req: EnrollRequest, db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """报名课程(免费课程直接报名,付费课程需先支付)。""" course = await db.courses.get(course_id) if not course: raise HTTPException(status_code=404, detail="课程不存在") if course["status"] != "published": raise HTTPException(status_code=400, detail="课程未发布") # 检查是否已报名 existing = await db.course_enrollments.get(user["id"], course_id) if existing: return {"ok": True, "item": existing, "already_enrolled": True} # 付费课程需要支付(这里简化,实际应对接支付) if course["price"] > 0: # TODO: 对接微信支付,返回支付参数 return {"ok": False, "message": "付费课程需先完成支付", "price": course["price"]} # 统计课程总课时数 total_lessons = await db.course_lessons.count_by_course(course_id) enrollment = await db.course_enrollments.create( user_id=user["id"], course_id=course_id, status="enrolled", progress_percent=0, completed_lessons=0, total_lessons=total_lessons, price_paid=0, ) # 增加课程报名数 await db.courses.increment_enroll(course_id) # 实时通知:报名成功 from ...services.notification_service import notify as _n await _n(db, user["id"], "training", "课程报名成功", f"你已报名课程「{course['title']}」,开始学习吧", event_code="course.enrolled", level="success", link="/opc/training", ref_type="course", ref_id=course_id) return {"ok": True, "item": enrollment, "already_enrolled": False} @router.get("/me/courses/{course_id}/progress") async def get_course_progress( course_id: str, db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """课程学习进度详情(含每个课时的完成状态)。""" enrollment = await db.course_enrollments.get(user["id"], course_id) if not enrollment: raise HTTPException(status_code=404, detail="未报名该课程") # 获取所有课时的进度 lessons = await db.course_lessons.list_by_course(course_id) progress_list = await db.course_lesson_progress.list_by_enrollment(enrollment["id"]) progress_map = {p["lesson_id"]: p for p in progress_list} lesson_statuses = [] for lesson in lessons: p = progress_map.get(lesson["id"]) lesson_statuses.append({ "lesson_id": lesson["id"], "title": lesson["title"], "type": lesson["type"], "status": p["status"] if p else "not_started", "watch_seconds": p["watch_seconds"] if p else 0, "completed_at": p["completed_at"] if p else None, }) return { "enrollment": enrollment, "lessons": lesson_statuses, "completed_count": enrollment["completed_lessons"], "total_count": enrollment["total_lessons"], "progress_percent": enrollment["progress_percent"], } @router.post("/courses/lessons/{lesson_id}/start") async def start_lesson( lesson_id: str, db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """开始学习课时。""" lesson = await db.course_lessons.get(lesson_id) if not lesson: raise HTTPException(status_code=404, detail="课时不存在") enrollment = await db.course_enrollments.get(user["id"], lesson["course_id"]) if not enrollment: raise HTTPException(status_code=403, detail="请先报名课程") progress = await db.course_lesson_progress.get_or_create( enrollment["id"], user["id"], lesson_id, lesson["course_id"], ) if progress["status"] == "not_started": progress = await db.course_lesson_progress.update( progress["id"], {"status": "in_progress", "started_at": utcnow_iso()}, ) # 更新报名记录的上次学习 await db.course_enrollments.update( enrollment["id"], {"last_lesson_id": lesson_id, "last_studied_at": utcnow_iso(), "status": "in_progress"}, ) return {"ok": True, "item": progress, "lesson": lesson} @router.post("/courses/lessons/{lesson_id}/progress") async def update_lesson_progress( lesson_id: str, req: LessonProgressRequest, db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """更新课时学习进度(watch_seconds 心跳上报)。""" lesson = await db.course_lessons.get(lesson_id) if not lesson: raise HTTPException(status_code=404, detail="课时不存在") enrollment = await db.course_enrollments.get(user["id"], lesson["course_id"]) if not enrollment: raise HTTPException(status_code=403, detail="请先报名课程") progress = await db.course_lesson_progress.get_or_create( enrollment["id"], user["id"], lesson_id, lesson["course_id"], ) progress = await db.course_lesson_progress.update( progress["id"], {"watch_seconds": req.watch_seconds, "status": "in_progress"}, ) # 更新总学习时长 total_watch = enrollment["total_watch_seconds"] + 30 # 每次心跳+30秒 await db.course_enrollments.update(enrollment["id"], {"total_watch_seconds": total_watch}) return {"ok": True, "item": progress} @router.post("/courses/lessons/{lesson_id}/complete") async def complete_lesson( lesson_id: str, req: LessonCompleteRequest, db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """完成课时(自动更新课程进度)。""" lesson = await db.course_lessons.get(lesson_id) if not lesson: raise HTTPException(status_code=404, detail="课时不存在") enrollment = await db.course_enrollments.get(user["id"], lesson["course_id"]) if not enrollment: raise HTTPException(status_code=403, detail="请先报名课程") progress = await db.course_lesson_progress.get_or_create( enrollment["id"], user["id"], lesson_id, lesson["course_id"], ) if progress["status"] != "completed": progress = await db.course_lesson_progress.update( progress["id"], {"status": "completed", "completed_at": utcnow_iso(), "quiz_score": req.quiz_score or 0}, ) # 重新计算课程进度 completed = await db.course_lesson_progress.count_completed(enrollment["id"]) total = enrollment["total_lessons"] or 1 progress_percent = int((completed / total) * 100) status = "completed" if progress_percent >= 100 else "in_progress" await db.course_enrollments.update( enrollment["id"], {"completed_lessons": completed, "progress_percent": progress_percent, "status": status}, ) return {"ok": True, "item": progress, "course_progress": progress_percent, "completed": completed, "total": total} @router.post("/courses/{course_id}/complete") async def complete_course( course_id: str, db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """完成课程(检查全部课时完成,颁发结业证书)。""" course = await db.courses.get(course_id) if not course: raise HTTPException(status_code=404, detail="课程不存在") enrollment = await db.course_enrollments.get(user["id"], course_id) if not enrollment: raise HTTPException(status_code=403, detail="请先报名课程") # 检查是否所有课时都完成 completed = await db.course_lesson_progress.count_completed(enrollment["id"]) total = enrollment["total_lessons"] or 0 if completed < total: raise HTTPException(status_code=400, detail=f"还有 {total - completed} 个课时未完成") # 检查是否已有证书 existing_cert = await db.course_certificates.get_by_user_course(user["id"], course_id) if existing_cert: return {"ok": True, "certificate": existing_cert, "already_issued": True} # 生成证书编号 now = utcnow_iso() date_str = now[:10].replace("-", "") cert_no = f"OPC-CC-{date_str}-{new_id('')[:4].upper()}" certificate = await db.course_certificates.create( certificate_no=cert_no, user_id=user["id"], course_id=course_id, course_title=course["title"], instructor_name=course["instructor_name"], duration_minutes=enrollment["total_watch_seconds"] // 60, final_score=0, issued_at=now, is_verified=1, ) # 更新报名状态 await db.course_enrollments.update( enrollment["id"], {"status": "completed", "completed_at": now, "progress_percent": 100}, ) # 信用事件:课程结业(+30,按课程去重)+ 结业证书(+10) from ...services.credit_engine import on_credit_event await on_credit_event( db, user_id=user["id"], event_code="training.completed", ref_type="course", ref_id=course_id, reason=f"完成培训课程「{course['title']}」", ) await on_credit_event( db, user_id=user["id"], event_code="training.cert_issued", ref_type="training_cert", ref_id=certificate["id"], reason=f"获得结业证书 {cert_no}", ) # 实时通知:结业 + 证书 + 徽章 from ...services.notification_service import notify as _n await _n(db, user["id"], "training", "课程结业", f"你已完成「{course['title']}」全部课时,结业证书已颁发({cert_no})", event_code="course.graduated", level="success", link="/opc/training", ref_type="course", ref_id=course_id) await _n(db, user["id"], "training", "获得结业证书", f"证书编号 {cert_no},可在培训中心查看", event_code="training.cert_issued", level="success", link="/opc/training", ref_type="training_cert", ref_id=certificate["id"]) return {"ok": True, "certificate": certificate, "already_issued": False} # ══════════════════════════════════════════════════════════════════════ # 结业证书 # ══════════════════════════════════════════════════════════════════════ @router.get("/me/certificates") async def list_my_certificates( db: Database = Depends(get_db), user: dict = Depends(get_current_user), ): """我的结业证书列表。""" certs = await db.course_certificates.list_by_user(user["id"]) return {"items": certs, "total": len(certs)} @router.get("/certificates/{certificate_id}") async def get_certificate(certificate_id: str, db: Database = Depends(get_db)): """证书详情(公开可验证)。""" cert = await db.course_certificates.get(certificate_id) if not cert: raise HTTPException(status_code=404, detail="证书不存在") return cert @router.get("/certificates/verify/{certificate_no}") async def verify_certificate(certificate_no: str, db: Database = Depends(get_db)): """公开验证证书。""" cert = await db.course_certificates.get_by_no(certificate_no) if not cert: return {"valid": False, "message": "证书不存在"} return {"valid": True, "certificate": cert} # ══════════════════════════════════════════════════════════════════════ # 运营端:课程管理 # ══════════════════════════════════════════════════════════════════════ @router.get("/admin/courses") async def admin_list_courses( status: str = "", category: str = "", db: Database = Depends(get_db), ): """运营端:课程列表(含草稿)。""" courses = await db.courses.list(category=category, status=status if status else "") return {"items": courses, "total": len(courses)} @router.post("/admin/courses") async def admin_create_course( req: CourseCreateRequest, db: Database = Depends(get_db), ): """运营端:创建课程。""" course = await db.courses.create(**req.model_dump()) return {"ok": True, "item": course} @router.put("/admin/courses/{course_id}") async def admin_update_course( course_id: str, req: CourseCreateRequest, db: Database = Depends(get_db), ): """运营端:编辑课程。""" course = await db.courses.update(course_id, req.model_dump()) if not course: raise HTTPException(status_code=404, detail="课程不存在") return {"ok": True, "item": course} @router.delete("/admin/courses/{course_id}") async def admin_delete_course(course_id: str, db: Database = Depends(get_db)): """运营端:删除课程(软删除,改为 archived)。""" course = await db.courses.update(course_id, {"status": "archived"}) if not course: raise HTTPException(status_code=404, detail="课程不存在") return {"ok": True, "item": course} @router.post("/admin/courses/{course_id}/publish") async def admin_publish_course(course_id: str, db: Database = Depends(get_db)): """运营端:发布课程。""" course = await db.courses.update(course_id, {"status": "published"}) if not course: raise HTTPException(status_code=404, detail="课程不存在") return {"ok": True, "item": course} # ── 章节管理 ────────────────────────────────────────────────────────── @router.post("/admin/courses/{course_id}/chapters") async def admin_create_chapter( course_id: str, req: ChapterCreateRequest, db: Database = Depends(get_db), ): """运营端:添加章节。""" chapter = await db.course_chapters.create(course_id=course_id, **req.model_dump(exclude={"course_id"})) # 更新课程章节数 course = await db.courses.get(course_id) if course: chapters = await db.course_chapters.list_by_course(course_id) await db.courses.update(course_id, {"chapter_count": len(chapters)}) return {"ok": True, "item": chapter} @router.put("/admin/chapters/{chapter_id}") async def admin_update_chapter( chapter_id: str, req: ChapterCreateRequest, db: Database = Depends(get_db), ): """运营端:编辑章节。""" chapter = await db.course_chapters.update(chapter_id, req.model_dump()) if not chapter: raise HTTPException(status_code=404, detail="章节不存在") return {"ok": True, "item": chapter} @router.delete("/admin/chapters/{chapter_id}") async def admin_delete_chapter(chapter_id: str, db: Database = Depends(get_db)): """运营端:删除章节。""" chapter = await db.course_chapters.get(chapter_id) if not chapter: raise HTTPException(status_code=404, detail="章节不存在") course_id = chapter["course_id"] await db.course_chapters.delete(chapter_id) # 更新课程章节数 chapters = await db.course_chapters.list_by_course(course_id) await db.courses.update(course_id, {"chapter_count": len(chapters)}) return {"ok": True} # ── 课时管理 ────────────────────────────────────────────────────────── @router.post("/admin/chapters/{chapter_id}/lessons") async def admin_create_lesson( chapter_id: str, req: LessonCreateRequest, db: Database = Depends(get_db), ): """运营端:添加课时。""" lesson = await db.course_lessons.create(chapter_id=chapter_id, **req.model_dump(exclude={"chapter_id"})) # 更新课程课时数 course = await db.courses.get(req.course_id) if course: total = await db.course_lessons.count_by_course(req.course_id) await db.courses.update(req.course_id, {"lesson_count": total}) return {"ok": True, "item": lesson} @router.put("/admin/lessons/{lesson_id}") async def admin_update_lesson( lesson_id: str, req: LessonCreateRequest, db: Database = Depends(get_db), ): """运营端:编辑课时。""" lesson = await db.course_lessons.update(lesson_id, req.model_dump()) if not lesson: raise HTTPException(status_code=404, detail="课时不存在") return {"ok": True, "item": lesson} @router.delete("/admin/lessons/{lesson_id}") async def admin_delete_lesson(lesson_id: str, db: Database = Depends(get_db)): """运营端:删除课时。""" lesson = await db.course_lessons.get(lesson_id) if not lesson: raise HTTPException(status_code=404, detail="课时不存在") course_id = lesson["course_id"] await db.course_lessons.delete(lesson_id) # 更新课程课时数 total = await db.course_lessons.count_by_course(course_id) await db.courses.update(course_id, {"lesson_count": total}) return {"ok": True} # ── 运营端统计 ──────────────────────────────────────────────────────── @router.get("/admin/courses/{course_id}/enrollments") async def admin_course_enrollments( course_id: str, db: Database = Depends(get_db), ): """运营端:课程报名统计。""" enrollments = await db.course_enrollments.list_by_course(course_id) completed = sum(1 for e in enrollments if e["status"] == "completed") in_progress = sum(1 for e in enrollments if e["status"] == "in_progress") return { "items": enrollments, "total": len(enrollments), "completed": completed, "in_progress": in_progress, "enrolled": len(enrollments) - completed - in_progress, } @router.get("/admin/certificates") async def admin_list_certificates(db: Database = Depends(get_db)): """运营端:证书列表。""" certs = await db.course_certificates.list_all() return {"items": certs, "total": len(certs)}