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:
@@ -116,5 +116,14 @@ api_router.include_router(certificate_router, prefix="/certificates", tags=["cer
|
||||
# dashboard_router 数据大屏路由
|
||||
from .endpoints.dashboard import router as dashboard_router
|
||||
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
|
||||
# progress_router 学习进度追踪路由
|
||||
from .endpoints.progress import router as progress_router
|
||||
api_router.include_router(progress_router, prefix="/progress", tags=["progress"])
|
||||
# speech_router 语音识别路由
|
||||
from .endpoints.speech import router as speech_router
|
||||
api_router.include_router(speech_router, prefix="/speech", tags=["speech"])
|
||||
# recommendation_router 智能推荐路由
|
||||
from .endpoints.recommendation import router as recommendation_router
|
||||
api_router.include_router(recommendation_router, prefix="/recommendations", tags=["recommendations"])
|
||||
|
||||
__all__ = ["api_router"]
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import select, func
|
||||
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.models.course import Course, CourseStatus
|
||||
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
|
||||
from app.schemas.base import ResponseModel
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
@@ -61,18 +62,32 @@ async def get_dashboard_stats(
|
||||
.where(Course.status == CourseStatus.PUBLISHED)
|
||||
)
|
||||
|
||||
# TODO: 完成的课程数需要根据用户课程进度表计算
|
||||
completed_courses = 0 # 暂时设为0
|
||||
# 根据用户课程进度表计算完成的课程学习记录数
|
||||
completed_courses = await db.scalar(
|
||||
select(func.count(UserCourseProgress.id))
|
||||
.where(UserCourseProgress.status == ProgressStatus.COMPLETED.value)
|
||||
) or 0
|
||||
|
||||
# 考试统计(如果有考试表的话)
|
||||
total_exams = 0
|
||||
avg_score = 0.0
|
||||
pass_rate = "0%"
|
||||
|
||||
# 学习时长统计(如果有学习记录表的话)
|
||||
total_learning_hours = 0
|
||||
avg_learning_hours = 0.0
|
||||
active_rate = "0%"
|
||||
# 学习时长统计 - 从用户课程进度表获取
|
||||
total_study_seconds = await db.scalar(
|
||||
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0))
|
||||
) or 0
|
||||
total_learning_hours = round(total_study_seconds / 3600)
|
||||
|
||||
# 平均学习时长(每个活跃用户)
|
||||
active_learners = await db.scalar(
|
||||
select(func.count(func.distinct(UserCourseProgress.user_id)))
|
||||
.where(UserCourseProgress.status != ProgressStatus.NOT_STARTED.value)
|
||||
) or 0
|
||||
avg_learning_hours = round(total_study_seconds / 3600 / max(active_learners, 1), 1)
|
||||
|
||||
# 活跃率 = 有学习记录的用户 / 总用户
|
||||
active_rate = f"{round(active_learners / max(total_users, 1) * 100)}%"
|
||||
|
||||
# 构建响应数据
|
||||
stats = {
|
||||
@@ -195,10 +210,28 @@ async def get_course_completion_data(
|
||||
for course_name, course_id in courses:
|
||||
course_names.append(course_name)
|
||||
|
||||
# TODO: 根据用户课程进度表计算完成率
|
||||
# 这里暂时生成模拟数据
|
||||
import random
|
||||
completion_rate = random.randint(60, 95)
|
||||
# 根据用户课程进度表计算完成率
|
||||
# 统计该课程的完成用户数和总学习用户数
|
||||
stats_result = await db.execute(
|
||||
select(
|
||||
func.count(UserCourseProgress.id).label('total'),
|
||||
func.sum(
|
||||
func.case(
|
||||
(UserCourseProgress.status == ProgressStatus.COMPLETED.value, 1),
|
||||
else_=0
|
||||
)
|
||||
).label('completed')
|
||||
).where(UserCourseProgress.course_id == course_id)
|
||||
)
|
||||
stats = stats_result.one()
|
||||
total_learners = stats.total or 0
|
||||
completed_learners = stats.completed or 0
|
||||
|
||||
# 计算完成率
|
||||
if total_learners > 0:
|
||||
completion_rate = round(completed_learners / total_learners * 100)
|
||||
else:
|
||||
completion_rate = 0
|
||||
completion_rates.append(completion_rate)
|
||||
|
||||
return ResponseModel(
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
)
|
||||
|
||||
470
backend/app/api/v1/endpoints/progress.py
Normal file
470
backend/app/api/v1/endpoints/progress.py
Normal file
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
用户课程学习进度 API
|
||||
"""
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, func, and_
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.api.deps import get_current_user
|
||||
from app.models.user import User
|
||||
from app.models.course import Course, CourseMaterial
|
||||
from app.models.user_course_progress import (
|
||||
UserCourseProgress,
|
||||
UserMaterialProgress,
|
||||
ProgressStatus,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============ Schemas ============
|
||||
|
||||
class MaterialProgressUpdate(BaseModel):
|
||||
"""更新资料进度请求"""
|
||||
progress_percent: float = Field(ge=0, le=100, description="进度百分比")
|
||||
last_position: Optional[int] = Field(default=0, ge=0, description="播放位置(秒)")
|
||||
study_time_delta: Optional[int] = Field(default=0, ge=0, description="本次学习时长(秒)")
|
||||
is_completed: Optional[bool] = Field(default=None, description="是否标记完成")
|
||||
|
||||
|
||||
class MaterialProgressResponse(BaseModel):
|
||||
"""资料进度响应"""
|
||||
material_id: int
|
||||
material_name: str
|
||||
is_completed: bool
|
||||
progress_percent: float
|
||||
last_position: int
|
||||
study_time: int
|
||||
first_accessed_at: Optional[datetime]
|
||||
last_accessed_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class CourseProgressResponse(BaseModel):
|
||||
"""课程进度响应"""
|
||||
course_id: int
|
||||
course_name: str
|
||||
status: str
|
||||
progress_percent: float
|
||||
completed_materials: int
|
||||
total_materials: int
|
||||
total_study_time: int
|
||||
first_accessed_at: Optional[datetime]
|
||||
last_accessed_at: Optional[datetime]
|
||||
completed_at: Optional[datetime]
|
||||
materials: List[MaterialProgressResponse] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
|
||||
class ProgressSummary(BaseModel):
|
||||
"""进度统计摘要"""
|
||||
total_courses: int
|
||||
completed_courses: int
|
||||
in_progress_courses: int
|
||||
not_started_courses: int
|
||||
total_study_time: int
|
||||
average_progress: float
|
||||
|
||||
|
||||
# ============ API Endpoints ============
|
||||
|
||||
@router.get("/summary", response_model=ProgressSummary)
|
||||
async def get_progress_summary(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取用户学习进度摘要"""
|
||||
# 获取用户所有课程进度
|
||||
result = await db.execute(
|
||||
select(UserCourseProgress).where(
|
||||
UserCourseProgress.user_id == current_user.id
|
||||
)
|
||||
)
|
||||
progress_list = result.scalars().all()
|
||||
|
||||
total_courses = len(progress_list)
|
||||
completed = sum(1 for p in progress_list if p.status == ProgressStatus.COMPLETED.value)
|
||||
in_progress = sum(1 for p in progress_list if p.status == ProgressStatus.IN_PROGRESS.value)
|
||||
not_started = sum(1 for p in progress_list if p.status == ProgressStatus.NOT_STARTED.value)
|
||||
total_time = sum(p.total_study_time for p in progress_list)
|
||||
avg_progress = sum(p.progress_percent for p in progress_list) / total_courses if total_courses > 0 else 0
|
||||
|
||||
return ProgressSummary(
|
||||
total_courses=total_courses,
|
||||
completed_courses=completed,
|
||||
in_progress_courses=in_progress,
|
||||
not_started_courses=not_started,
|
||||
total_study_time=total_time,
|
||||
average_progress=round(avg_progress, 2),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/courses", response_model=List[CourseProgressResponse])
|
||||
async def get_all_course_progress(
|
||||
status: Optional[str] = Query(None, description="过滤状态"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取用户所有课程的学习进度"""
|
||||
query = select(UserCourseProgress, Course).join(
|
||||
Course, UserCourseProgress.course_id == Course.id
|
||||
).where(
|
||||
UserCourseProgress.user_id == current_user.id
|
||||
)
|
||||
|
||||
if status:
|
||||
query = query.where(UserCourseProgress.status == status)
|
||||
|
||||
result = await db.execute(query)
|
||||
rows = result.all()
|
||||
|
||||
response = []
|
||||
for progress, course in rows:
|
||||
response.append(CourseProgressResponse(
|
||||
course_id=course.id,
|
||||
course_name=course.name,
|
||||
status=progress.status,
|
||||
progress_percent=progress.progress_percent,
|
||||
completed_materials=progress.completed_materials,
|
||||
total_materials=progress.total_materials,
|
||||
total_study_time=progress.total_study_time,
|
||||
first_accessed_at=progress.first_accessed_at,
|
||||
last_accessed_at=progress.last_accessed_at,
|
||||
completed_at=progress.completed_at,
|
||||
))
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/courses/{course_id}", response_model=CourseProgressResponse)
|
||||
async def get_course_progress(
|
||||
course_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""获取指定课程的详细学习进度"""
|
||||
# 获取课程信息
|
||||
course_result = await db.execute(
|
||||
select(Course).where(Course.id == course_id)
|
||||
)
|
||||
course = course_result.scalar_one_or_none()
|
||||
if not course:
|
||||
raise HTTPException(status_code=404, detail="课程不存在")
|
||||
|
||||
# 获取或创建课程进度
|
||||
progress_result = await db.execute(
|
||||
select(UserCourseProgress).where(
|
||||
and_(
|
||||
UserCourseProgress.user_id == current_user.id,
|
||||
UserCourseProgress.course_id == course_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
progress = progress_result.scalar_one_or_none()
|
||||
|
||||
if not progress:
|
||||
# 获取课程资料数量
|
||||
materials_result = await db.execute(
|
||||
select(func.count(CourseMaterial.id)).where(
|
||||
and_(
|
||||
CourseMaterial.course_id == course_id,
|
||||
CourseMaterial.is_deleted == False,
|
||||
)
|
||||
)
|
||||
)
|
||||
total_materials = materials_result.scalar() or 0
|
||||
|
||||
# 创建新的进度记录
|
||||
progress = UserCourseProgress(
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
status=ProgressStatus.NOT_STARTED.value,
|
||||
progress_percent=0.0,
|
||||
completed_materials=0,
|
||||
total_materials=total_materials,
|
||||
)
|
||||
db.add(progress)
|
||||
await db.commit()
|
||||
await db.refresh(progress)
|
||||
|
||||
# 获取资料进度
|
||||
material_progress_result = await db.execute(
|
||||
select(UserMaterialProgress, CourseMaterial).join(
|
||||
CourseMaterial, UserMaterialProgress.material_id == CourseMaterial.id
|
||||
).where(
|
||||
and_(
|
||||
UserMaterialProgress.user_id == current_user.id,
|
||||
UserMaterialProgress.course_id == course_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
material_rows = material_progress_result.all()
|
||||
|
||||
materials = []
|
||||
for mp, material in material_rows:
|
||||
materials.append(MaterialProgressResponse(
|
||||
material_id=material.id,
|
||||
material_name=material.name,
|
||||
is_completed=mp.is_completed,
|
||||
progress_percent=mp.progress_percent,
|
||||
last_position=mp.last_position,
|
||||
study_time=mp.study_time,
|
||||
first_accessed_at=mp.first_accessed_at,
|
||||
last_accessed_at=mp.last_accessed_at,
|
||||
completed_at=mp.completed_at,
|
||||
))
|
||||
|
||||
return CourseProgressResponse(
|
||||
course_id=course.id,
|
||||
course_name=course.name,
|
||||
status=progress.status,
|
||||
progress_percent=progress.progress_percent,
|
||||
completed_materials=progress.completed_materials,
|
||||
total_materials=progress.total_materials,
|
||||
total_study_time=progress.total_study_time,
|
||||
first_accessed_at=progress.first_accessed_at,
|
||||
last_accessed_at=progress.last_accessed_at,
|
||||
completed_at=progress.completed_at,
|
||||
materials=materials,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/materials/{material_id}", response_model=MaterialProgressResponse)
|
||||
async def update_material_progress(
|
||||
material_id: int,
|
||||
data: MaterialProgressUpdate,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""更新资料学习进度"""
|
||||
# 获取资料信息
|
||||
material_result = await db.execute(
|
||||
select(CourseMaterial).where(CourseMaterial.id == material_id)
|
||||
)
|
||||
material = material_result.scalar_one_or_none()
|
||||
if not material:
|
||||
raise HTTPException(status_code=404, detail="资料不存在")
|
||||
|
||||
course_id = material.course_id
|
||||
now = datetime.now()
|
||||
|
||||
# 获取或创建资料进度
|
||||
mp_result = await db.execute(
|
||||
select(UserMaterialProgress).where(
|
||||
and_(
|
||||
UserMaterialProgress.user_id == current_user.id,
|
||||
UserMaterialProgress.material_id == material_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
mp = mp_result.scalar_one_or_none()
|
||||
|
||||
if not mp:
|
||||
mp = UserMaterialProgress(
|
||||
user_id=current_user.id,
|
||||
material_id=material_id,
|
||||
course_id=course_id,
|
||||
first_accessed_at=now,
|
||||
)
|
||||
db.add(mp)
|
||||
|
||||
# 更新进度
|
||||
mp.progress_percent = data.progress_percent
|
||||
mp.last_position = data.last_position or mp.last_position
|
||||
mp.study_time += data.study_time_delta or 0
|
||||
mp.last_accessed_at = now
|
||||
|
||||
# 处理完成状态
|
||||
if data.is_completed is not None:
|
||||
if data.is_completed and not mp.is_completed:
|
||||
mp.is_completed = True
|
||||
mp.completed_at = now
|
||||
mp.progress_percent = 100.0
|
||||
elif not data.is_completed:
|
||||
mp.is_completed = False
|
||||
mp.completed_at = None
|
||||
elif data.progress_percent >= 100:
|
||||
mp.is_completed = True
|
||||
mp.completed_at = now
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 更新课程整体进度
|
||||
await _update_course_progress(db, current_user.id, course_id)
|
||||
|
||||
await db.refresh(mp)
|
||||
|
||||
return MaterialProgressResponse(
|
||||
material_id=mp.material_id,
|
||||
material_name=material.name,
|
||||
is_completed=mp.is_completed,
|
||||
progress_percent=mp.progress_percent,
|
||||
last_position=mp.last_position,
|
||||
study_time=mp.study_time,
|
||||
first_accessed_at=mp.first_accessed_at,
|
||||
last_accessed_at=mp.last_accessed_at,
|
||||
completed_at=mp.completed_at,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/materials/{material_id}/complete")
|
||||
async def mark_material_complete(
|
||||
material_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""标记资料为已完成"""
|
||||
return await update_material_progress(
|
||||
material_id=material_id,
|
||||
data=MaterialProgressUpdate(progress_percent=100, is_completed=True),
|
||||
db=db,
|
||||
current_user=current_user,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/courses/{course_id}/start")
|
||||
async def start_course(
|
||||
course_id: int,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user),
|
||||
):
|
||||
"""开始学习课程(记录首次访问)"""
|
||||
# 获取课程
|
||||
course_result = await db.execute(
|
||||
select(Course).where(Course.id == course_id)
|
||||
)
|
||||
course = course_result.scalar_one_or_none()
|
||||
if not course:
|
||||
raise HTTPException(status_code=404, detail="课程不存在")
|
||||
|
||||
now = datetime.now()
|
||||
|
||||
# 获取或创建进度
|
||||
progress_result = await db.execute(
|
||||
select(UserCourseProgress).where(
|
||||
and_(
|
||||
UserCourseProgress.user_id == current_user.id,
|
||||
UserCourseProgress.course_id == course_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
progress = progress_result.scalar_one_or_none()
|
||||
|
||||
if not progress:
|
||||
# 获取资料数量
|
||||
materials_result = await db.execute(
|
||||
select(func.count(CourseMaterial.id)).where(
|
||||
and_(
|
||||
CourseMaterial.course_id == course_id,
|
||||
CourseMaterial.is_deleted == False,
|
||||
)
|
||||
)
|
||||
)
|
||||
total_materials = materials_result.scalar() or 0
|
||||
|
||||
progress = UserCourseProgress(
|
||||
user_id=current_user.id,
|
||||
course_id=course_id,
|
||||
status=ProgressStatus.IN_PROGRESS.value,
|
||||
total_materials=total_materials,
|
||||
first_accessed_at=now,
|
||||
last_accessed_at=now,
|
||||
)
|
||||
db.add(progress)
|
||||
else:
|
||||
if progress.status == ProgressStatus.NOT_STARTED.value:
|
||||
progress.status = ProgressStatus.IN_PROGRESS.value
|
||||
if not progress.first_accessed_at:
|
||||
progress.first_accessed_at = now
|
||||
progress.last_accessed_at = now
|
||||
|
||||
await db.commit()
|
||||
|
||||
return {"code": 200, "message": "已开始学习", "data": {"course_id": course_id}}
|
||||
|
||||
|
||||
# ============ Helper Functions ============
|
||||
|
||||
async def _update_course_progress(db: AsyncSession, user_id: int, course_id: int):
|
||||
"""更新课程整体进度"""
|
||||
now = datetime.now()
|
||||
|
||||
# 获取课程所有资料数量
|
||||
materials_result = await db.execute(
|
||||
select(func.count(CourseMaterial.id)).where(
|
||||
and_(
|
||||
CourseMaterial.course_id == course_id,
|
||||
CourseMaterial.is_deleted == False,
|
||||
)
|
||||
)
|
||||
)
|
||||
total_materials = materials_result.scalar() or 0
|
||||
|
||||
# 获取已完成的资料数量和总学习时长
|
||||
completed_result = await db.execute(
|
||||
select(
|
||||
func.count(UserMaterialProgress.id),
|
||||
func.coalesce(func.sum(UserMaterialProgress.study_time), 0),
|
||||
).where(
|
||||
and_(
|
||||
UserMaterialProgress.user_id == user_id,
|
||||
UserMaterialProgress.course_id == course_id,
|
||||
UserMaterialProgress.is_completed == True,
|
||||
)
|
||||
)
|
||||
)
|
||||
row = completed_result.one()
|
||||
completed_materials = row[0]
|
||||
total_study_time = row[1]
|
||||
|
||||
# 计算进度百分比
|
||||
progress_percent = (completed_materials / total_materials * 100) if total_materials > 0 else 0
|
||||
|
||||
# 确定状态
|
||||
if completed_materials == 0:
|
||||
status = ProgressStatus.IN_PROGRESS.value # 已开始但未完成任何资料
|
||||
elif completed_materials >= total_materials:
|
||||
status = ProgressStatus.COMPLETED.value
|
||||
else:
|
||||
status = ProgressStatus.IN_PROGRESS.value
|
||||
|
||||
# 获取或创建课程进度
|
||||
progress_result = await db.execute(
|
||||
select(UserCourseProgress).where(
|
||||
and_(
|
||||
UserCourseProgress.user_id == user_id,
|
||||
UserCourseProgress.course_id == course_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
progress = progress_result.scalar_one_or_none()
|
||||
|
||||
if not progress:
|
||||
progress = UserCourseProgress(
|
||||
user_id=user_id,
|
||||
course_id=course_id,
|
||||
first_accessed_at=now,
|
||||
)
|
||||
db.add(progress)
|
||||
|
||||
# 更新进度
|
||||
progress.status = status
|
||||
progress.progress_percent = round(progress_percent, 2)
|
||||
progress.completed_materials = completed_materials
|
||||
progress.total_materials = total_materials
|
||||
progress.total_study_time = total_study_time
|
||||
progress.last_accessed_at = now
|
||||
|
||||
if status == ProgressStatus.COMPLETED.value and not progress.completed_at:
|
||||
progress.completed_at = now
|
||||
|
||||
await db.commit()
|
||||
157
backend/app/api/v1/endpoints/recommendation.py
Normal file
157
backend/app/api/v1/endpoints/recommendation.py
Normal 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,
|
||||
}
|
||||
}
|
||||
145
backend/app/api/v1/endpoints/speech.py
Normal file
145
backend/app/api/v1/endpoints/speech.py
Normal 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",
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,306 +1,306 @@
|
||||
"""
|
||||
系统设置 API
|
||||
|
||||
供企业管理员(admin角色)配置系统级别的设置,如钉钉免密登录等
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================
|
||||
# Schema 定义
|
||||
# ============================================
|
||||
|
||||
class DingtalkConfigUpdate(BaseModel):
|
||||
"""钉钉配置更新请求"""
|
||||
app_key: Optional[str] = Field(None, description="钉钉AppKey")
|
||||
app_secret: Optional[str] = Field(None, description="钉钉AppSecret")
|
||||
agent_id: Optional[str] = Field(None, description="钉钉AgentId")
|
||||
corp_id: Optional[str] = Field(None, description="钉钉CorpId")
|
||||
enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录")
|
||||
|
||||
|
||||
class DingtalkConfigResponse(BaseModel):
|
||||
"""钉钉配置响应"""
|
||||
app_key: Optional[str] = None
|
||||
app_secret_masked: Optional[str] = None # 脱敏显示
|
||||
agent_id: Optional[str] = None
|
||||
corp_id: Optional[str] = None
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
def check_admin_permission(user: User):
|
||||
"""检查是否为管理员"""
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限"
|
||||
)
|
||||
|
||||
|
||||
async def get_or_create_tenant_id(db: AsyncSession) -> int:
|
||||
"""获取或创建默认租户ID(简化版,假设单租户)"""
|
||||
# 对于考培练系统,简化处理,使用固定的租户ID=1
|
||||
return 1
|
||||
|
||||
|
||||
async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]:
|
||||
"""获取系统配置值"""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT config_value FROM tenant_configs
|
||||
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key}
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def set_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str, config_value: str):
|
||||
"""设置系统配置值"""
|
||||
# 检查是否已存在
|
||||
existing = await get_system_config(db, tenant_id, config_group, config_key)
|
||||
|
||||
if existing is not None:
|
||||
# 更新
|
||||
await db.execute(
|
||||
text("""
|
||||
UPDATE tenant_configs
|
||||
SET config_value = :config_value
|
||||
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key, "config_value": config_value}
|
||||
)
|
||||
else:
|
||||
# 插入
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
|
||||
VALUES (:tenant_id, :config_group, :config_key, :config_value, 'string', :is_encrypted)
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"config_group": config_group,
|
||||
"config_key": config_key,
|
||||
"config_value": config_value,
|
||||
"is_encrypted": 1 if config_key == 'DINGTALK_APP_SECRET' else 0
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def get_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str) -> bool:
|
||||
"""获取功能开关状态"""
|
||||
# 先查租户级别
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT is_enabled FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "feature_code": feature_code}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
return bool(row[0])
|
||||
|
||||
# 再查默认值
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT is_enabled FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id IS NULL
|
||||
"""),
|
||||
{"feature_code": feature_code}
|
||||
)
|
||||
row = result.fetchone()
|
||||
return bool(row[0]) if row else False
|
||||
|
||||
|
||||
async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool):
|
||||
"""设置功能开关状态"""
|
||||
# 检查是否已存在租户级配置
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "feature_code": feature_code}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row:
|
||||
# 更新
|
||||
await db.execute(
|
||||
text("""
|
||||
UPDATE feature_switches
|
||||
SET is_enabled = :is_enabled
|
||||
WHERE tenant_id = :tenant_id AND feature_code = :feature_code
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "feature_code": feature_code, "is_enabled": 1 if is_enabled else 0}
|
||||
)
|
||||
else:
|
||||
# 获取默认配置信息
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT feature_name, feature_group, description FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id IS NULL
|
||||
"""),
|
||||
{"feature_code": feature_code}
|
||||
)
|
||||
default_row = result.fetchone()
|
||||
|
||||
if default_row:
|
||||
# 插入租户级配置
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description)
|
||||
VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description)
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"feature_code": feature_code,
|
||||
"feature_name": default_row[0],
|
||||
"feature_group": default_row[1],
|
||||
"is_enabled": 1 if is_enabled else 0,
|
||||
"description": default_row[2]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# API 端点
|
||||
# ============================================
|
||||
|
||||
@router.get("/dingtalk", response_model=ResponseModel)
|
||||
async def get_dingtalk_config(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取钉钉配置
|
||||
|
||||
仅限管理员访问
|
||||
"""
|
||||
check_admin_permission(current_user)
|
||||
|
||||
tenant_id = await get_or_create_tenant_id(db)
|
||||
|
||||
# 获取配置
|
||||
app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
|
||||
app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
|
||||
agent_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID')
|
||||
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
||||
enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
|
||||
|
||||
# 脱敏处理 app_secret
|
||||
app_secret_masked = None
|
||||
if app_secret:
|
||||
if len(app_secret) > 8:
|
||||
app_secret_masked = app_secret[:4] + '****' + app_secret[-4:]
|
||||
else:
|
||||
app_secret_masked = '****'
|
||||
|
||||
return ResponseModel(
|
||||
message="获取成功",
|
||||
data={
|
||||
"app_key": app_key,
|
||||
"app_secret_masked": app_secret_masked,
|
||||
"agent_id": agent_id,
|
||||
"corp_id": corp_id,
|
||||
"enabled": enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/dingtalk", response_model=ResponseModel)
|
||||
async def update_dingtalk_config(
|
||||
config: DingtalkConfigUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
更新钉钉配置
|
||||
|
||||
仅限管理员访问
|
||||
"""
|
||||
check_admin_permission(current_user)
|
||||
|
||||
tenant_id = await get_or_create_tenant_id(db)
|
||||
|
||||
try:
|
||||
# 更新配置
|
||||
if config.app_key is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key)
|
||||
|
||||
if config.app_secret is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret)
|
||||
|
||||
if config.agent_id is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id)
|
||||
|
||||
if config.corp_id is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id)
|
||||
|
||||
if config.enabled is not None:
|
||||
await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"钉钉配置已更新",
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
)
|
||||
|
||||
return ResponseModel(message="配置已保存")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"更新钉钉配置失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="保存配置失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/all", response_model=ResponseModel)
|
||||
async def get_all_settings(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取所有系统设置概览
|
||||
|
||||
仅限管理员访问
|
||||
"""
|
||||
check_admin_permission(current_user)
|
||||
|
||||
tenant_id = await get_or_create_tenant_id(db)
|
||||
|
||||
# 钉钉配置状态
|
||||
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
|
||||
dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
||||
|
||||
return ResponseModel(
|
||||
message="获取成功",
|
||||
data={
|
||||
"dingtalk": {
|
||||
"enabled": dingtalk_enabled,
|
||||
"configured": bool(dingtalk_corp_id), # 是否已配置
|
||||
}
|
||||
}
|
||||
)
|
||||
"""
|
||||
系统设置 API
|
||||
|
||||
供企业管理员(admin角色)配置系统级别的设置,如钉钉免密登录等
|
||||
"""
|
||||
|
||||
from typing import Optional, Dict, Any
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.core.deps import get_current_active_user, get_db
|
||||
from app.core.logger import logger
|
||||
from app.models.user import User
|
||||
from app.schemas.base import ResponseModel
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ============================================
|
||||
# Schema 定义
|
||||
# ============================================
|
||||
|
||||
class DingtalkConfigUpdate(BaseModel):
|
||||
"""钉钉配置更新请求"""
|
||||
app_key: Optional[str] = Field(None, description="钉钉AppKey")
|
||||
app_secret: Optional[str] = Field(None, description="钉钉AppSecret")
|
||||
agent_id: Optional[str] = Field(None, description="钉钉AgentId")
|
||||
corp_id: Optional[str] = Field(None, description="钉钉CorpId")
|
||||
enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录")
|
||||
|
||||
|
||||
class DingtalkConfigResponse(BaseModel):
|
||||
"""钉钉配置响应"""
|
||||
app_key: Optional[str] = None
|
||||
app_secret_masked: Optional[str] = None # 脱敏显示
|
||||
agent_id: Optional[str] = None
|
||||
corp_id: Optional[str] = None
|
||||
enabled: bool = False
|
||||
|
||||
|
||||
# ============================================
|
||||
# 辅助函数
|
||||
# ============================================
|
||||
|
||||
def check_admin_permission(user: User):
|
||||
"""检查是否为管理员"""
|
||||
if user.role != 'admin':
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="需要管理员权限"
|
||||
)
|
||||
|
||||
|
||||
async def get_or_create_tenant_id(db: AsyncSession) -> int:
|
||||
"""获取或创建默认租户ID(简化版,假设单租户)"""
|
||||
# 对于考培练系统,简化处理,使用固定的租户ID=1
|
||||
return 1
|
||||
|
||||
|
||||
async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]:
|
||||
"""获取系统配置值"""
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT config_value FROM tenant_configs
|
||||
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key}
|
||||
)
|
||||
row = result.fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
|
||||
async def set_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str, config_value: str):
|
||||
"""设置系统配置值"""
|
||||
# 检查是否已存在
|
||||
existing = await get_system_config(db, tenant_id, config_group, config_key)
|
||||
|
||||
if existing is not None:
|
||||
# 更新
|
||||
await db.execute(
|
||||
text("""
|
||||
UPDATE tenant_configs
|
||||
SET config_value = :config_value
|
||||
WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key, "config_value": config_value}
|
||||
)
|
||||
else:
|
||||
# 插入
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted)
|
||||
VALUES (:tenant_id, :config_group, :config_key, :config_value, 'string', :is_encrypted)
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"config_group": config_group,
|
||||
"config_key": config_key,
|
||||
"config_value": config_value,
|
||||
"is_encrypted": 1 if config_key == 'DINGTALK_APP_SECRET' else 0
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
async def get_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str) -> bool:
|
||||
"""获取功能开关状态"""
|
||||
# 先查租户级别
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT is_enabled FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "feature_code": feature_code}
|
||||
)
|
||||
row = result.fetchone()
|
||||
if row:
|
||||
return bool(row[0])
|
||||
|
||||
# 再查默认值
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT is_enabled FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id IS NULL
|
||||
"""),
|
||||
{"feature_code": feature_code}
|
||||
)
|
||||
row = result.fetchone()
|
||||
return bool(row[0]) if row else False
|
||||
|
||||
|
||||
async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool):
|
||||
"""设置功能开关状态"""
|
||||
# 检查是否已存在租户级配置
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT id FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id = :tenant_id
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "feature_code": feature_code}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row:
|
||||
# 更新
|
||||
await db.execute(
|
||||
text("""
|
||||
UPDATE feature_switches
|
||||
SET is_enabled = :is_enabled
|
||||
WHERE tenant_id = :tenant_id AND feature_code = :feature_code
|
||||
"""),
|
||||
{"tenant_id": tenant_id, "feature_code": feature_code, "is_enabled": 1 if is_enabled else 0}
|
||||
)
|
||||
else:
|
||||
# 获取默认配置信息
|
||||
result = await db.execute(
|
||||
text("""
|
||||
SELECT feature_name, feature_group, description FROM feature_switches
|
||||
WHERE feature_code = :feature_code AND tenant_id IS NULL
|
||||
"""),
|
||||
{"feature_code": feature_code}
|
||||
)
|
||||
default_row = result.fetchone()
|
||||
|
||||
if default_row:
|
||||
# 插入租户级配置
|
||||
await db.execute(
|
||||
text("""
|
||||
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description)
|
||||
VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description)
|
||||
"""),
|
||||
{
|
||||
"tenant_id": tenant_id,
|
||||
"feature_code": feature_code,
|
||||
"feature_name": default_row[0],
|
||||
"feature_group": default_row[1],
|
||||
"is_enabled": 1 if is_enabled else 0,
|
||||
"description": default_row[2]
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ============================================
|
||||
# API 端点
|
||||
# ============================================
|
||||
|
||||
@router.get("/dingtalk", response_model=ResponseModel)
|
||||
async def get_dingtalk_config(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取钉钉配置
|
||||
|
||||
仅限管理员访问
|
||||
"""
|
||||
check_admin_permission(current_user)
|
||||
|
||||
tenant_id = await get_or_create_tenant_id(db)
|
||||
|
||||
# 获取配置
|
||||
app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY')
|
||||
app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET')
|
||||
agent_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID')
|
||||
corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
||||
enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
|
||||
|
||||
# 脱敏处理 app_secret
|
||||
app_secret_masked = None
|
||||
if app_secret:
|
||||
if len(app_secret) > 8:
|
||||
app_secret_masked = app_secret[:4] + '****' + app_secret[-4:]
|
||||
else:
|
||||
app_secret_masked = '****'
|
||||
|
||||
return ResponseModel(
|
||||
message="获取成功",
|
||||
data={
|
||||
"app_key": app_key,
|
||||
"app_secret_masked": app_secret_masked,
|
||||
"agent_id": agent_id,
|
||||
"corp_id": corp_id,
|
||||
"enabled": enabled,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.put("/dingtalk", response_model=ResponseModel)
|
||||
async def update_dingtalk_config(
|
||||
config: DingtalkConfigUpdate,
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
更新钉钉配置
|
||||
|
||||
仅限管理员访问
|
||||
"""
|
||||
check_admin_permission(current_user)
|
||||
|
||||
tenant_id = await get_or_create_tenant_id(db)
|
||||
|
||||
try:
|
||||
# 更新配置
|
||||
if config.app_key is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key)
|
||||
|
||||
if config.app_secret is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret)
|
||||
|
||||
if config.agent_id is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id)
|
||||
|
||||
if config.corp_id is not None:
|
||||
await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id)
|
||||
|
||||
if config.enabled is not None:
|
||||
await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled)
|
||||
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"钉钉配置已更新",
|
||||
user_id=current_user.id,
|
||||
username=current_user.username,
|
||||
)
|
||||
|
||||
return ResponseModel(message="配置已保存")
|
||||
|
||||
except Exception as e:
|
||||
await db.rollback()
|
||||
logger.error(f"更新钉钉配置失败: {str(e)}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="保存配置失败"
|
||||
)
|
||||
|
||||
|
||||
@router.get("/all", response_model=ResponseModel)
|
||||
async def get_all_settings(
|
||||
current_user: User = Depends(get_current_active_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
) -> ResponseModel:
|
||||
"""
|
||||
获取所有系统设置概览
|
||||
|
||||
仅限管理员访问
|
||||
"""
|
||||
check_admin_permission(current_user)
|
||||
|
||||
tenant_id = await get_or_create_tenant_id(db)
|
||||
|
||||
# 钉钉配置状态
|
||||
dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login')
|
||||
dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID')
|
||||
|
||||
return ResponseModel(
|
||||
message="获取成功",
|
||||
data={
|
||||
"dingtalk": {
|
||||
"enabled": dingtalk_enabled,
|
||||
"configured": bool(dingtalk_corp_id), # 是否已配置
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user