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

@@ -1,304 +1,329 @@
"""
证书管理 API 端点
提供证书相关的 RESTful API
- 获取证书列表
- 获取证书详情
- 下载证书
- 验证证书
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import io
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.certificate_service import CertificateService
router = APIRouter()
@router.get("/templates")
async def get_certificate_templates(
cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书模板列表"""
service = CertificateService(db)
templates = await service.get_templates(cert_type)
return {
"code": 200,
"message": "success",
"data": templates
}
@router.get("/me")
async def get_my_certificates(
cert_type: Optional[str] = Query(None, description="证书类型过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取当前用户的证书列表"""
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=current_user.id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/user/{user_id}")
async def get_user_certificates(
user_id: int,
cert_type: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取指定用户的证书列表(需要管理员权限)"""
# 只允许查看自己的证书或管理员查看
if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看其他用户的证书"
)
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=user_id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/{cert_id}")
async def get_certificate_detail(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书详情"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
return {
"code": 200,
"message": "success",
"data": cert
}
@router.get("/{cert_id}/image")
async def get_certificate_image(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书分享图片"""
service = CertificateService(db)
try:
# 获取基础URL
base_url = "https://kpl.example.com/certificates" # 可从配置读取
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"inline; filename=certificate_{cert_id}.png"
}
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成证书图片失败: {str(e)}"
)
@router.get("/{cert_id}/download")
async def download_certificate_pdf(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""下载证书PDF"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
# 如果已有PDF URL则重定向
if cert.get("pdf_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["pdf_url"]
}
}
# 否则返回图片作为替代
try:
base_url = "https://kpl.example.com/certificates"
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png"
}
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"下载失败: {str(e)}"
)
@router.get("/verify/{cert_no}")
async def verify_certificate(
cert_no: str,
db: AsyncSession = Depends(get_db)
):
"""
验证证书真伪
此接口无需登录,可用于公开验证证书
"""
service = CertificateService(db)
cert = await service.get_certificate_by_no(cert_no)
if not cert:
return {
"code": 404,
"message": "证书不存在或编号错误",
"data": {
"valid": False,
"certificate_no": cert_no
}
}
return {
"code": 200,
"message": "证书验证通过",
"data": {
"valid": True,
"certificate_no": cert_no,
"title": cert.get("title"),
"type_name": cert.get("type_name"),
"issued_at": cert.get("issued_at"),
"user": cert.get("user", {}),
}
}
@router.post("/issue/course")
async def issue_course_certificate(
course_id: int,
course_name: str,
completion_rate: float = 100.0,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发课程结业证书
通常由系统在用户完成课程时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_course_certificate(
user_id=current_user.id,
course_id=course_id,
course_name=course_name,
completion_rate=completion_rate,
user_name=current_user.full_name or current_user.username
)
await db.commit()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/issue/exam")
async def issue_exam_certificate(
exam_id: int,
exam_name: str,
score: float,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发考试合格证书
通常由系统在用户考试通过时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_exam_certificate(
user_id=current_user.id,
exam_id=exam_id,
exam_name=exam_name,
score=score,
user_name=current_user.full_name or current_user.username
)
await db.commit()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
"""
证书管理 API 端点
提供证书相关的 RESTful API
- 获取证书列表
- 获取证书详情
- 下载证书
- 验证证书
"""
from typing import Optional, List
from fastapi import APIRouter, Depends, HTTPException, status, Response, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.ext.asyncio import AsyncSession
import io
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.certificate_service import CertificateService
router = APIRouter()
@router.get("/templates")
async def get_certificate_templates(
cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书模板列表"""
service = CertificateService(db)
templates = await service.get_templates(cert_type)
return {
"code": 200,
"message": "success",
"data": templates
}
@router.get("/me")
async def get_my_certificates(
cert_type: Optional[str] = Query(None, description="证书类型过滤"),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取当前用户的证书列表"""
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=current_user.id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/user/{user_id}")
async def get_user_certificates(
user_id: int,
cert_type: Optional[str] = Query(None),
offset: int = Query(0, ge=0),
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取指定用户的证书列表(需要管理员权限)"""
# 只允许查看自己的证书或管理员查看
if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="无权查看其他用户的证书"
)
service = CertificateService(db)
result = await service.get_user_certificates(
user_id=user_id,
cert_type=cert_type,
offset=offset,
limit=limit
)
return {
"code": 200,
"message": "success",
"data": result
}
@router.get("/{cert_id}")
async def get_certificate_detail(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书详情"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
return {
"code": 200,
"message": "success",
"data": cert
}
@router.get("/{cert_id}/image")
async def get_certificate_image(
cert_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""获取证书分享图片"""
service = CertificateService(db)
try:
# 获取基础URL
base_url = "https://kpl.example.com/certificates" # 可从配置读取
image_bytes = await service.generate_certificate_image(cert_id, base_url)
return StreamingResponse(
io.BytesIO(image_bytes),
media_type="image/png",
headers={
"Content-Disposition": f"inline; filename=certificate_{cert_id}.png"
}
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"生成证书图片失败: {str(e)}"
)
@router.get("/{cert_id}/download")
async def download_certificate(
cert_id: int,
format: str = Query("pdf", description="下载格式: pdf 或 png"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
下载证书
支持 PDF 和 PNG 两种格式
- PDF: 高质量打印版本(需要安装 weasyprint
- PNG: 图片版本
"""
service = CertificateService(db)
cert = await service.get_certificate_by_id(cert_id)
if not cert:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="证书不存在"
)
# 如果已有缓存的 PDF/图片 URL 则返回
if format.lower() == "pdf" and cert.get("pdf_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["pdf_url"]
}
}
if format.lower() == "png" and cert.get("image_url"):
return {
"code": 200,
"message": "success",
"data": {
"download_url": cert["image_url"]
}
}
# 动态生成证书文件
try:
from app.core.config import settings
base_url = settings.PUBLIC_DOMAIN + "/certificates"
content, filename, mime_type = await service.download_certificate(
cert_id, format, base_url
)
return StreamingResponse(
io.BytesIO(content),
media_type=mime_type,
headers={
"Content-Disposition": f"attachment; filename={filename}"
}
)
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
except Exception as e:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"下载失败: {str(e)}"
)
@router.get("/verify/{cert_no}")
async def verify_certificate(
cert_no: str,
db: AsyncSession = Depends(get_db)
):
"""
验证证书真伪
此接口无需登录,可用于公开验证证书
"""
service = CertificateService(db)
cert = await service.get_certificate_by_no(cert_no)
if not cert:
return {
"code": 404,
"message": "证书不存在或编号错误",
"data": {
"valid": False,
"certificate_no": cert_no
}
}
return {
"code": 200,
"message": "证书验证通过",
"data": {
"valid": True,
"certificate_no": cert_no,
"title": cert.get("title"),
"type_name": cert.get("type_name"),
"issued_at": cert.get("issued_at"),
"user": cert.get("user", {}),
}
}
@router.post("/issue/course")
async def issue_course_certificate(
course_id: int,
course_name: str,
completion_rate: float = 100.0,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发课程结业证书
通常由系统在用户完成课程时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_course_certificate(
user_id=current_user.id,
course_id=course_id,
course_name=course_name,
completion_rate=completion_rate,
user_name=current_user.full_name or current_user.username
)
await db.commit()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)
@router.post("/issue/exam")
async def issue_exam_certificate(
exam_id: int,
exam_name: str,
score: float,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
颁发考试合格证书
通常由系统在用户考试通过时自动调用
"""
service = CertificateService(db)
try:
cert = await service.issue_exam_certificate(
user_id=current_user.id,
exam_id=exam_id,
exam_name=exam_name,
score=score,
user_name=current_user.full_name or current_user.username
)
await db.commit()
return {
"code": 200,
"message": "证书颁发成功",
"data": cert
}
except ValueError as e:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=str(e)
)

View File

@@ -1,230 +1,230 @@
"""
数据大屏 API 端点
提供企业级和团队级数据大屏接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.dashboard_service import DashboardService
router = APIRouter()
@router.get("/enterprise/overview")
async def get_enterprise_overview(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取企业级数据概览
需要管理员或企业管理员权限
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_enterprise_overview()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/departments")
async def get_department_comparison(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取部门/团队学习对比数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_department_comparison()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/trend")
async def get_learning_trend(
days: int = Query(7, ge=1, le=30),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取学习趋势数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_learning_trend(days)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/level-distribution")
async def get_level_distribution(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级分布数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_level_distribution()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/activities")
async def get_realtime_activities(
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取实时动态
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_realtime_activities(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/course-ranking")
async def get_course_ranking(
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取课程热度排行
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_course_ranking(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/team")
async def get_team_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取团队级数据大屏
面向团队负责人,显示其管理团队的数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要团队负责人权限"
)
service = DashboardService(db)
data = await service.get_team_dashboard(current_user.id)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/all")
async def get_all_dashboard_data(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取完整的大屏数据(一次性获取所有数据)
用于大屏初始化加载
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
# 并行获取所有数据
overview = await service.get_enterprise_overview()
departments = await service.get_department_comparison()
trend = await service.get_learning_trend(7)
level_dist = await service.get_level_distribution()
activities = await service.get_realtime_activities(20)
course_ranking = await service.get_course_ranking(10)
return {
"code": 200,
"message": "success",
"data": {
"overview": overview,
"departments": departments,
"trend": trend,
"level_distribution": level_dist,
"activities": activities,
"course_ranking": course_ranking,
}
}
"""
数据大屏 API 端点
提供企业级和团队级数据大屏接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.models.user import User
from app.services.dashboard_service import DashboardService
router = APIRouter()
@router.get("/enterprise/overview")
async def get_enterprise_overview(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取企业级数据概览
需要管理员或企业管理员权限
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_enterprise_overview()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/departments")
async def get_department_comparison(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取部门/团队学习对比数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_department_comparison()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/trend")
async def get_learning_trend(
days: int = Query(7, ge=1, le=30),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取学习趋势数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_learning_trend(days)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/level-distribution")
async def get_level_distribution(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级分布数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_level_distribution()
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/activities")
async def get_realtime_activities(
limit: int = Query(20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取实时动态
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_realtime_activities(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/enterprise/course-ranking")
async def get_course_ranking(
limit: int = Query(10, ge=1, le=50),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取课程热度排行
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
data = await service.get_course_ranking(limit)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/team")
async def get_team_dashboard(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取团队级数据大屏
面向团队负责人,显示其管理团队的数据
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要团队负责人权限"
)
service = DashboardService(db)
data = await service.get_team_dashboard(current_user.id)
return {
"code": 200,
"message": "success",
"data": data
}
@router.get("/all")
async def get_all_dashboard_data(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取完整的大屏数据(一次性获取所有数据)
用于大屏初始化加载
"""
if current_user.role not in ["admin", "enterprise_admin", "manager"]:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
service = DashboardService(db)
# 并行获取所有数据
overview = await service.get_enterprise_overview()
departments = await service.get_department_comparison()
trend = await service.get_learning_trend(7)
level_dist = await service.get_level_distribution()
activities = await service.get_realtime_activities(20)
course_ranking = await service.get_course_ranking(10)
return {
"code": 200,
"message": "success",
"data": {
"overview": overview,
"departments": departments,
"trend": trend,
"level_distribution": level_dist,
"activities": activities,
"course_ranking": course_ranking,
}
}

View File

@@ -1,277 +1,277 @@
"""
等级与奖章 API
提供等级查询、奖章查询、排行榜、签到等接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
from app.models.user import User
router = APIRouter()
# ============================================
# 等级相关接口
# ============================================
@router.get("/me", response_model=ResponseModel)
async def get_my_level(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户等级信息
返回用户的等级、经验值、称号、连续登录天数等信息
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.get("/user/{user_id}", response_model=ResponseModel)
async def get_user_level(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取指定用户等级信息
Args:
user_id: 用户ID
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(user_id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.post("/checkin", response_model=ResponseModel)
async def daily_checkin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
每日签到
每天首次签到获得经验值,连续签到有额外奖励
"""
level_service = LevelService(db)
badge_service = BadgeService(db)
# 执行签到
checkin_result = await level_service.daily_checkin(current_user.id)
# 检查是否解锁新奖章
new_badges = []
if checkin_result["success"]:
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message=checkin_result["message"],
data={
**checkin_result,
"new_badges": new_badges
}
)
@router.get("/exp-history", response_model=ResponseModel)
async def get_exp_history(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取经验值变化历史
Args:
limit: 每页数量默认50最大100
offset: 偏移量
exp_type: 类型筛选exam/practice/training/task/login/badge/other
"""
level_service = LevelService(db)
history, total = await level_service.get_exp_history(
user_id=current_user.id,
limit=limit,
offset=offset,
exp_type=exp_type
)
return ResponseModel(
message="获取成功",
data={
"items": history,
"total": total,
"limit": limit,
"offset": offset
}
)
@router.get("/leaderboard", response_model=ResponseModel)
async def get_leaderboard(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级排行榜
Args:
limit: 每页数量默认50最大100
offset: 偏移量
"""
level_service = LevelService(db)
# 获取排行榜
leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset)
# 获取当前用户排名
my_rank = await level_service.get_user_rank(current_user.id)
# 获取当前用户等级信息
my_level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data={
"items": leaderboard,
"total": total,
"limit": limit,
"offset": offset,
"my_rank": my_rank,
"my_level_info": my_level_info
}
)
# ============================================
# 奖章相关接口
# ============================================
@router.get("/badges/all", response_model=ResponseModel)
async def get_all_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有奖章定义
返回所有可获得的奖章列表
"""
badge_service = BadgeService(db)
badges = await badge_service.get_all_badges()
return ResponseModel(
message="获取成功",
data=badges
)
@router.get("/badges/me", response_model=ResponseModel)
async def get_my_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的奖章(含解锁状态)
返回所有奖章及用户是否已解锁
"""
badge_service = BadgeService(db)
badges = await badge_service.get_user_badges_with_status(current_user.id)
# 统计已解锁数量
unlocked_count = sum(1 for b in badges if b["unlocked"])
return ResponseModel(
message="获取成功",
data={
"badges": badges,
"total": len(badges),
"unlocked_count": unlocked_count
}
)
@router.get("/badges/unnotified", response_model=ResponseModel)
async def get_unnotified_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取未通知的新奖章
用于前端显示新获得奖章的弹窗提示
"""
badge_service = BadgeService(db)
badges = await badge_service.get_unnotified_badges(current_user.id)
return ResponseModel(
message="获取成功",
data=badges
)
@router.post("/badges/mark-notified", response_model=ResponseModel)
async def mark_badges_notified(
badge_ids: Optional[list[int]] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
标记奖章为已通知
Args:
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
badge_service = BadgeService(db)
await badge_service.mark_badges_notified(current_user.id, badge_ids)
await db.commit()
return ResponseModel(
message="标记成功"
)
@router.post("/check-badges", response_model=ResponseModel)
async def check_and_award_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
检查并授予符合条件的奖章
手动触发奖章检查,返回新获得的奖章
"""
badge_service = BadgeService(db)
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message="检查完成",
data={
"new_badges": new_badges,
"count": len(new_badges)
}
)
"""
等级与奖章 API
提供等级查询、奖章查询、排行榜、签到等接口
"""
from typing import Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel
from app.services.level_service import LevelService
from app.services.badge_service import BadgeService
from app.models.user import User
router = APIRouter()
# ============================================
# 等级相关接口
# ============================================
@router.get("/me", response_model=ResponseModel)
async def get_my_level(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户等级信息
返回用户的等级、经验值、称号、连续登录天数等信息
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.get("/user/{user_id}", response_model=ResponseModel)
async def get_user_level(
user_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取指定用户等级信息
Args:
user_id: 用户ID
"""
level_service = LevelService(db)
level_info = await level_service.get_user_level_info(user_id)
return ResponseModel(
message="获取成功",
data=level_info
)
@router.post("/checkin", response_model=ResponseModel)
async def daily_checkin(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
每日签到
每天首次签到获得经验值,连续签到有额外奖励
"""
level_service = LevelService(db)
badge_service = BadgeService(db)
# 执行签到
checkin_result = await level_service.daily_checkin(current_user.id)
# 检查是否解锁新奖章
new_badges = []
if checkin_result["success"]:
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message=checkin_result["message"],
data={
**checkin_result,
"new_badges": new_badges
}
)
@router.get("/exp-history", response_model=ResponseModel)
async def get_exp_history(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取经验值变化历史
Args:
limit: 每页数量默认50最大100
offset: 偏移量
exp_type: 类型筛选exam/practice/training/task/login/badge/other
"""
level_service = LevelService(db)
history, total = await level_service.get_exp_history(
user_id=current_user.id,
limit=limit,
offset=offset,
exp_type=exp_type
)
return ResponseModel(
message="获取成功",
data={
"items": history,
"total": total,
"limit": limit,
"offset": offset
}
)
@router.get("/leaderboard", response_model=ResponseModel)
async def get_leaderboard(
limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取等级排行榜
Args:
limit: 每页数量默认50最大100
offset: 偏移量
"""
level_service = LevelService(db)
# 获取排行榜
leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset)
# 获取当前用户排名
my_rank = await level_service.get_user_rank(current_user.id)
# 获取当前用户等级信息
my_level_info = await level_service.get_user_level_info(current_user.id)
return ResponseModel(
message="获取成功",
data={
"items": leaderboard,
"total": total,
"limit": limit,
"offset": offset,
"my_rank": my_rank,
"my_level_info": my_level_info
}
)
# ============================================
# 奖章相关接口
# ============================================
@router.get("/badges/all", response_model=ResponseModel)
async def get_all_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取所有奖章定义
返回所有可获得的奖章列表
"""
badge_service = BadgeService(db)
badges = await badge_service.get_all_badges()
return ResponseModel(
message="获取成功",
data=badges
)
@router.get("/badges/me", response_model=ResponseModel)
async def get_my_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取当前用户的奖章(含解锁状态)
返回所有奖章及用户是否已解锁
"""
badge_service = BadgeService(db)
badges = await badge_service.get_user_badges_with_status(current_user.id)
# 统计已解锁数量
unlocked_count = sum(1 for b in badges if b["unlocked"])
return ResponseModel(
message="获取成功",
data={
"badges": badges,
"total": len(badges),
"unlocked_count": unlocked_count
}
)
@router.get("/badges/unnotified", response_model=ResponseModel)
async def get_unnotified_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
获取未通知的新奖章
用于前端显示新获得奖章的弹窗提示
"""
badge_service = BadgeService(db)
badges = await badge_service.get_unnotified_badges(current_user.id)
return ResponseModel(
message="获取成功",
data=badges
)
@router.post("/badges/mark-notified", response_model=ResponseModel)
async def mark_badges_notified(
badge_ids: Optional[list[int]] = None,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
标记奖章为已通知
Args:
badge_ids: 要标记的奖章ID列表为空则标记全部
"""
badge_service = BadgeService(db)
await badge_service.mark_badges_notified(current_user.id, badge_ids)
await db.commit()
return ResponseModel(
message="标记成功"
)
@router.post("/check-badges", response_model=ResponseModel)
async def check_and_award_badges(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user)
):
"""
检查并授予符合条件的奖章
手动触发奖章检查,返回新获得的奖章
"""
badge_service = BadgeService(db)
new_badges = await badge_service.check_and_award_badges(current_user.id)
await db.commit()
return ResponseModel(
message="检查完成",
data={
"new_badges": new_badges,
"count": len(new_badges)
}
)

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

View File

@@ -0,0 +1,157 @@
"""
智能学习推荐 API
"""
from typing import List, Optional
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from pydantic import BaseModel
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.services.recommendation_service import RecommendationService
router = APIRouter()
# ============ Schemas ============
class CourseRecommendation(BaseModel):
"""课程推荐响应"""
course_id: int
course_name: str
category: Optional[str] = None
cover_image: Optional[str] = None
description: Optional[str] = None
progress_percent: Optional[float] = None
student_count: Optional[int] = None
source: Optional[str] = None
reason: Optional[str] = None
class KnowledgePointRecommendation(BaseModel):
"""知识点推荐响应"""
knowledge_point_id: int
name: str
description: Optional[str] = None
type: Optional[str] = None
course_id: int
mistake_count: Optional[int] = None
reason: Optional[str] = None
class RecommendationResponse(BaseModel):
"""推荐响应"""
code: int = 200
message: str = "success"
data: dict
# ============ API Endpoints ============
@router.get("/courses", response_model=RecommendationResponse)
async def get_course_recommendations(
limit: int = Query(10, ge=1, le=50, description="推荐数量"),
include_reasons: bool = Query(True, description="是否包含推荐理由"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取个性化课程推荐
推荐策略:
- 基于错题分析推荐相关课程
- 基于能力评估推荐弱项课程
- 基于学习进度推荐未完成课程
- 基于热门程度推荐高人气课程
"""
service = RecommendationService(db)
recommendations = await service.get_recommendations(
user_id=current_user.id,
limit=limit,
include_reasons=include_reasons,
)
return RecommendationResponse(
code=200,
message="获取推荐成功",
data={
"recommendations": recommendations,
"total": len(recommendations),
}
)
@router.get("/knowledge-points", response_model=RecommendationResponse)
async def get_knowledge_point_recommendations(
limit: int = Query(5, ge=1, le=20, description="推荐数量"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取知识点复习推荐
基于错题记录推荐需要重点复习的知识点
"""
service = RecommendationService(db)
recommendations = await service.get_knowledge_point_recommendations(
user_id=current_user.id,
limit=limit,
)
return RecommendationResponse(
code=200,
message="获取推荐成功",
data={
"recommendations": recommendations,
"total": len(recommendations),
}
)
@router.get("/summary")
async def get_recommendation_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""
获取推荐摘要
返回各类推荐的概要信息
"""
service = RecommendationService(db)
# 获取各类推荐
all_recs = await service.get_recommendations(
user_id=current_user.id,
limit=20,
include_reasons=True,
)
# 按来源分类统计
source_counts = {}
for rec in all_recs:
source = rec.get("source", "other")
source_counts[source] = source_counts.get(source, 0) + 1
# 获取知识点推荐
kp_recs = await service.get_knowledge_point_recommendations(
user_id=current_user.id,
limit=5,
)
return {
"code": 200,
"message": "success",
"data": {
"total_recommendations": len(all_recs),
"source_breakdown": {
"mistake_based": source_counts.get("mistake", 0),
"ability_based": source_counts.get("ability", 0),
"progress_based": source_counts.get("progress", 0),
"popular": source_counts.get("popular", 0),
},
"weak_knowledge_points": len(kp_recs),
"top_recommendation": all_recs[0] if all_recs else None,
}
}

View File

@@ -0,0 +1,145 @@
"""
语音识别 API
"""
from typing import Optional
from fastapi import APIRouter, Depends, File, UploadFile, Form, HTTPException
from pydantic import BaseModel
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.services.speech_recognition import (
get_speech_recognition_service,
SpeechRecognitionError,
)
router = APIRouter()
class SpeechRecognitionRequest(BaseModel):
"""语音识别请求(文本形式)"""
text: str
session_id: Optional[int] = None
class SpeechRecognitionResponse(BaseModel):
"""语音识别响应"""
code: int = 200
message: str = "识别成功"
data: dict
@router.post("/recognize/text", response_model=SpeechRecognitionResponse)
async def recognize_text(
request: SpeechRecognitionRequest,
current_user: User = Depends(get_current_user),
):
"""
处理前端已识别的语音文本
用于 Web Speech API 识别后的文本传输
"""
service = get_speech_recognition_service("simple")
try:
text = await service.recognize_text(request.text)
return SpeechRecognitionResponse(
code=200,
message="识别成功",
data={
"text": text,
"session_id": request.session_id,
}
)
except SpeechRecognitionError as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/recognize/audio", response_model=SpeechRecognitionResponse)
async def recognize_audio(
audio: UploadFile = File(...),
format: str = Form(default="wav"),
sample_rate: int = Form(default=16000),
engine: str = Form(default="aliyun"),
current_user: User = Depends(get_current_user),
):
"""
识别音频文件
支持的音频格式wav, pcm, mp3, ogg, opus
支持的识别引擎aliyun, xunfei
"""
# 读取音频数据
audio_data = await audio.read()
if len(audio_data) == 0:
raise HTTPException(status_code=400, detail="音频文件为空")
if len(audio_data) > 10 * 1024 * 1024: # 10MB 限制
raise HTTPException(status_code=400, detail="音频文件过大,最大支持 10MB")
service = get_speech_recognition_service(engine)
try:
text = await service.recognize_audio(audio_data, format, sample_rate)
return SpeechRecognitionResponse(
code=200,
message="识别成功",
data={
"text": text,
"format": format,
"sample_rate": sample_rate,
"engine": engine,
}
)
except SpeechRecognitionError as e:
raise HTTPException(status_code=400, detail=str(e))
except NotImplementedError as e:
raise HTTPException(status_code=501, detail=str(e))
@router.get("/engines")
async def get_available_engines(
current_user: User = Depends(get_current_user),
):
"""
获取可用的语音识别引擎列表
"""
import os
engines = [
{
"id": "simple",
"name": "浏览器语音识别",
"description": "使用浏览器内置的 Web Speech API 进行语音识别",
"available": True,
},
{
"id": "aliyun",
"name": "阿里云智能语音",
"description": "使用阿里云 NLS 服务进行高精度语音识别",
"available": all([
os.getenv("ALIYUN_ACCESS_KEY_ID"),
os.getenv("ALIYUN_ACCESS_KEY_SECRET"),
os.getenv("ALIYUN_NLS_APP_KEY"),
]),
},
{
"id": "xunfei",
"name": "讯飞语音识别",
"description": "使用讯飞 IAT 服务进行语音识别",
"available": all([
os.getenv("XUNFEI_APP_ID"),
os.getenv("XUNFEI_API_KEY"),
os.getenv("XUNFEI_API_SECRET"),
]),
},
]
return {
"code": 200,
"message": "获取成功",
"data": {
"engines": engines,
"default": "simple",
}
}