feat: 实现 KPL 系统功能改进计划
Some checks failed
continuous-integration/drone/push Build is failing

1. 课程学习进度追踪
   - 新增 UserCourseProgress 和 UserMaterialProgress 模型
   - 新增 /api/v1/progress/* 进度追踪 API
   - 更新 admin.py 使用真实课程完成率数据

2. 路由权限检查完善
   - 新增前端 permissionChecker.ts 权限检查工具
   - 更新 router/guard.ts 实现团队和课程权限验证
   - 新增后端 permission_service.py

3. AI 陪练音频转文本
   - 新增 speech_recognition.py 语音识别服务
   - 新增 /api/v1/speech/* API
   - 更新 ai-practice-coze.vue 支持语音输入

4. 双人对练报告生成
   - 更新 practice_room_service.py 添加报告生成功能
   - 新增 /rooms/{room_code}/report API
   - 更新 duo-practice-report.vue 调用真实 API

5. 学习提醒推送
   - 新增 notification_service.py 通知服务
   - 新增 scheduler_service.py 定时任务服务
   - 支持钉钉、企微、站内消息推送

6. 智能学习推荐
   - 新增 recommendation_service.py 推荐服务
   - 新增 /api/v1/recommendations/* API
   - 支持错题、能力、进度、热门多维度推荐

7. 安全问题修复
   - DEBUG 默认值改为 False
   - 添加 SECRET_KEY 安全警告
   - 新增 check_security_settings() 检查函数

8. 证书 PDF 生成
   - 更新 certificate_service.py 添加 PDF 生成
   - 添加 weasyprint、Pillow、qrcode 依赖
   - 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
yuliang_guo
2026-01-30 14:22:35 +08:00
parent 9793013a56
commit 64f5d567fa
66 changed files with 18067 additions and 14330 deletions

View File

@@ -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"]

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,470 @@
"""
用户课程学习进度 API
"""
from datetime import datetime
from typing import List, Optional
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, func, and_
from pydantic import BaseModel, Field
from app.core.database import get_db
from app.api.deps import get_current_user
from app.models.user import User
from app.models.course import Course, CourseMaterial
from app.models.user_course_progress import (
UserCourseProgress,
UserMaterialProgress,
ProgressStatus,
)
router = APIRouter()
# ============ Schemas ============
class MaterialProgressUpdate(BaseModel):
"""更新资料进度请求"""
progress_percent: float = Field(ge=0, le=100, description="进度百分比")
last_position: Optional[int] = Field(default=0, ge=0, description="播放位置(秒)")
study_time_delta: Optional[int] = Field(default=0, ge=0, description="本次学习时长(秒)")
is_completed: Optional[bool] = Field(default=None, description="是否标记完成")
class MaterialProgressResponse(BaseModel):
"""资料进度响应"""
material_id: int
material_name: str
is_completed: bool
progress_percent: float
last_position: int
study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
class Config:
from_attributes = True
class CourseProgressResponse(BaseModel):
"""课程进度响应"""
course_id: int
course_name: str
status: str
progress_percent: float
completed_materials: int
total_materials: int
total_study_time: int
first_accessed_at: Optional[datetime]
last_accessed_at: Optional[datetime]
completed_at: Optional[datetime]
materials: List[MaterialProgressResponse] = []
class Config:
from_attributes = True
class ProgressSummary(BaseModel):
"""进度统计摘要"""
total_courses: int
completed_courses: int
in_progress_courses: int
not_started_courses: int
total_study_time: int
average_progress: float
# ============ API Endpoints ============
@router.get("/summary", response_model=ProgressSummary)
async def get_progress_summary(
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户学习进度摘要"""
# 获取用户所有课程进度
result = await db.execute(
select(UserCourseProgress).where(
UserCourseProgress.user_id == current_user.id
)
)
progress_list = result.scalars().all()
total_courses = len(progress_list)
completed = sum(1 for p in progress_list if p.status == ProgressStatus.COMPLETED.value)
in_progress = sum(1 for p in progress_list if p.status == ProgressStatus.IN_PROGRESS.value)
not_started = sum(1 for p in progress_list if p.status == ProgressStatus.NOT_STARTED.value)
total_time = sum(p.total_study_time for p in progress_list)
avg_progress = sum(p.progress_percent for p in progress_list) / total_courses if total_courses > 0 else 0
return ProgressSummary(
total_courses=total_courses,
completed_courses=completed,
in_progress_courses=in_progress,
not_started_courses=not_started,
total_study_time=total_time,
average_progress=round(avg_progress, 2),
)
@router.get("/courses", response_model=List[CourseProgressResponse])
async def get_all_course_progress(
status: Optional[str] = Query(None, description="过滤状态"),
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取用户所有课程的学习进度"""
query = select(UserCourseProgress, Course).join(
Course, UserCourseProgress.course_id == Course.id
).where(
UserCourseProgress.user_id == current_user.id
)
if status:
query = query.where(UserCourseProgress.status == status)
result = await db.execute(query)
rows = result.all()
response = []
for progress, course in rows:
response.append(CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
))
return response
@router.get("/courses/{course_id}", response_model=CourseProgressResponse)
async def get_course_progress(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""获取指定课程的详细学习进度"""
# 获取课程信息
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取课程资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 创建新的进度记录
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.NOT_STARTED.value,
progress_percent=0.0,
completed_materials=0,
total_materials=total_materials,
)
db.add(progress)
await db.commit()
await db.refresh(progress)
# 获取资料进度
material_progress_result = await db.execute(
select(UserMaterialProgress, CourseMaterial).join(
CourseMaterial, UserMaterialProgress.material_id == CourseMaterial.id
).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.course_id == course_id,
)
)
)
material_rows = material_progress_result.all()
materials = []
for mp, material in material_rows:
materials.append(MaterialProgressResponse(
material_id=material.id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
))
return CourseProgressResponse(
course_id=course.id,
course_name=course.name,
status=progress.status,
progress_percent=progress.progress_percent,
completed_materials=progress.completed_materials,
total_materials=progress.total_materials,
total_study_time=progress.total_study_time,
first_accessed_at=progress.first_accessed_at,
last_accessed_at=progress.last_accessed_at,
completed_at=progress.completed_at,
materials=materials,
)
@router.post("/materials/{material_id}", response_model=MaterialProgressResponse)
async def update_material_progress(
material_id: int,
data: MaterialProgressUpdate,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""更新资料学习进度"""
# 获取资料信息
material_result = await db.execute(
select(CourseMaterial).where(CourseMaterial.id == material_id)
)
material = material_result.scalar_one_or_none()
if not material:
raise HTTPException(status_code=404, detail="资料不存在")
course_id = material.course_id
now = datetime.now()
# 获取或创建资料进度
mp_result = await db.execute(
select(UserMaterialProgress).where(
and_(
UserMaterialProgress.user_id == current_user.id,
UserMaterialProgress.material_id == material_id,
)
)
)
mp = mp_result.scalar_one_or_none()
if not mp:
mp = UserMaterialProgress(
user_id=current_user.id,
material_id=material_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(mp)
# 更新进度
mp.progress_percent = data.progress_percent
mp.last_position = data.last_position or mp.last_position
mp.study_time += data.study_time_delta or 0
mp.last_accessed_at = now
# 处理完成状态
if data.is_completed is not None:
if data.is_completed and not mp.is_completed:
mp.is_completed = True
mp.completed_at = now
mp.progress_percent = 100.0
elif not data.is_completed:
mp.is_completed = False
mp.completed_at = None
elif data.progress_percent >= 100:
mp.is_completed = True
mp.completed_at = now
await db.commit()
# 更新课程整体进度
await _update_course_progress(db, current_user.id, course_id)
await db.refresh(mp)
return MaterialProgressResponse(
material_id=mp.material_id,
material_name=material.name,
is_completed=mp.is_completed,
progress_percent=mp.progress_percent,
last_position=mp.last_position,
study_time=mp.study_time,
first_accessed_at=mp.first_accessed_at,
last_accessed_at=mp.last_accessed_at,
completed_at=mp.completed_at,
)
@router.post("/materials/{material_id}/complete")
async def mark_material_complete(
material_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""标记资料为已完成"""
return await update_material_progress(
material_id=material_id,
data=MaterialProgressUpdate(progress_percent=100, is_completed=True),
db=db,
current_user=current_user,
)
@router.post("/courses/{course_id}/start")
async def start_course(
course_id: int,
db: AsyncSession = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""开始学习课程(记录首次访问)"""
# 获取课程
course_result = await db.execute(
select(Course).where(Course.id == course_id)
)
course = course_result.scalar_one_or_none()
if not course:
raise HTTPException(status_code=404, detail="课程不存在")
now = datetime.now()
# 获取或创建进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == current_user.id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
# 获取资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
progress = UserCourseProgress(
user_id=current_user.id,
course_id=course_id,
status=ProgressStatus.IN_PROGRESS.value,
total_materials=total_materials,
first_accessed_at=now,
last_accessed_at=now,
)
db.add(progress)
else:
if progress.status == ProgressStatus.NOT_STARTED.value:
progress.status = ProgressStatus.IN_PROGRESS.value
if not progress.first_accessed_at:
progress.first_accessed_at = now
progress.last_accessed_at = now
await db.commit()
return {"code": 200, "message": "已开始学习", "data": {"course_id": course_id}}
# ============ Helper Functions ============
async def _update_course_progress(db: AsyncSession, user_id: int, course_id: int):
"""更新课程整体进度"""
now = datetime.now()
# 获取课程所有资料数量
materials_result = await db.execute(
select(func.count(CourseMaterial.id)).where(
and_(
CourseMaterial.course_id == course_id,
CourseMaterial.is_deleted == False,
)
)
)
total_materials = materials_result.scalar() or 0
# 获取已完成的资料数量和总学习时长
completed_result = await db.execute(
select(
func.count(UserMaterialProgress.id),
func.coalesce(func.sum(UserMaterialProgress.study_time), 0),
).where(
and_(
UserMaterialProgress.user_id == user_id,
UserMaterialProgress.course_id == course_id,
UserMaterialProgress.is_completed == True,
)
)
)
row = completed_result.one()
completed_materials = row[0]
total_study_time = row[1]
# 计算进度百分比
progress_percent = (completed_materials / total_materials * 100) if total_materials > 0 else 0
# 确定状态
if completed_materials == 0:
status = ProgressStatus.IN_PROGRESS.value # 已开始但未完成任何资料
elif completed_materials >= total_materials:
status = ProgressStatus.COMPLETED.value
else:
status = ProgressStatus.IN_PROGRESS.value
# 获取或创建课程进度
progress_result = await db.execute(
select(UserCourseProgress).where(
and_(
UserCourseProgress.user_id == user_id,
UserCourseProgress.course_id == course_id,
)
)
)
progress = progress_result.scalar_one_or_none()
if not progress:
progress = UserCourseProgress(
user_id=user_id,
course_id=course_id,
first_accessed_at=now,
)
db.add(progress)
# 更新进度
progress.status = status
progress.progress_percent = round(progress_percent, 2)
progress.completed_materials = completed_materials
progress.total_materials = total_materials
progress.total_study_time = total_study_time
progress.last_accessed_at = now
if status == ProgressStatus.COMPLETED.value and not progress.completed_at:
progress.completed_at = now
await db.commit()

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -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), # 是否已配置
}
}
)