Files
server-core/app/api/routers/rbac_training.py
T

653 lines
25 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""培训与技能认证体系 API 路由。
覆盖:课程浏览、报名、学习进度、结业证书、运营端课程管理。
完整闭环:参加培训 → 完成学习 → 通过考核 → 获得证书 → 授予徽章 → 信用加分。
"""
from __future__ import annotations
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:
await db.close()
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)}