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:
48
backend/.env.production
Normal file
48
backend/.env.production
Normal file
@@ -0,0 +1,48 @@
|
||||
APP_NAME="考培练系统后端"
|
||||
APP_VERSION="1.0.0"
|
||||
DEBUG=false
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@mysql:3306/kaopeilian?charset=utf8mb4
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
SECRET_KEY=66a6a6a2f1d84c2c8b3dbf02b7a2e2b8b7d88b1e7d1c4f8d9a2c6e1f4b9a7c3d
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
CORS_ORIGINS=["https://aiedu.ireborn.com.cn", "http://aiedu.ireborn.com.cn"]
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
UPLOAD_MAX_SIZE=10485760
|
||||
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||
UPLOAD_DIR=uploads
|
||||
|
||||
# Coze OAuth配置
|
||||
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||
|
||||
# Dify API 配置 (测试环境)
|
||||
# 播课工作流配置 (测试-06-播课工作流)
|
||||
COZE_BROADCAST_WORKFLOW_ID=7577983042284486666
|
||||
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||
|
||||
# AI 服务配置(遵循瑞小美AI接入规范 - 多 Key 策略)
|
||||
AI_PRIMARY_API_KEY=sk-V9QfxYscJzD54ZRIDyAvnd4Lew4IdtPUQqwDQ0swNkIYObxT
|
||||
AI_ANTHROPIC_API_KEY=sk-wNAkUW3zwBeKBN8EeK5KnXXgOixnW5rhZmKolo8fBQuHepkX
|
||||
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||
AI_FALLBACK_API_KEY=
|
||||
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||
AI_TIMEOUT=120
|
||||
|
||||
# 租户配置(用于多租户部署)
|
||||
TENANT_CODE=demo
|
||||
|
||||
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||
ADMIN_DB_HOST=prod-mysql
|
||||
ADMIN_DB_PORT=3306
|
||||
ADMIN_DB_USER=root
|
||||
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||
ADMIN_DB_NAME=kaopeilian_admin
|
||||
@@ -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), # 是否已配置
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -23,7 +23,9 @@ class Settings(BaseSettings):
|
||||
# 应用基础配置
|
||||
APP_NAME: str = "KaoPeiLian"
|
||||
APP_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = Field(default=True)
|
||||
# DEBUG 模式:生产环境必须设置为 False
|
||||
# 通过环境变量 DEBUG=false 或在 .env 文件中设置
|
||||
DEBUG: bool = Field(default=False, description="调试模式,生产环境必须设置为 False")
|
||||
|
||||
# 租户配置(用于多租户部署)
|
||||
TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl")
|
||||
@@ -56,7 +58,12 @@ class Settings(BaseSettings):
|
||||
REDIS_URL: str = Field(default="redis://localhost:6379/0")
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY: str = Field(default="your-secret-key-here")
|
||||
# 安全警告:必须在生产环境设置 SECRET_KEY 环境变量
|
||||
# 可以使用命令生成:python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
SECRET_KEY: str = Field(
|
||||
default="INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION",
|
||||
description="JWT 密钥,生产环境必须通过环境变量设置安全的随机密钥"
|
||||
)
|
||||
ALGORITHM: str = Field(default="HS256")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
|
||||
@@ -165,6 +172,57 @@ def get_settings() -> Settings:
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def check_security_settings() -> list[str]:
|
||||
"""
|
||||
检查安全配置
|
||||
|
||||
返回安全警告列表,生产环境应确保列表为空
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
# 检查 DEBUG 模式
|
||||
if settings.DEBUG:
|
||||
warnings.append(
|
||||
"⚠️ DEBUG 模式已开启。生产环境请设置 DEBUG=false"
|
||||
)
|
||||
|
||||
# 检查 SECRET_KEY
|
||||
if settings.SECRET_KEY == "INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION":
|
||||
warnings.append(
|
||||
"⚠️ 使用默认 SECRET_KEY 不安全。生产环境请设置安全的 SECRET_KEY 环境变量。"
|
||||
"生成命令:python -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
||||
)
|
||||
elif len(settings.SECRET_KEY) < 32:
|
||||
warnings.append(
|
||||
"⚠️ SECRET_KEY 长度不足 32 字符,安全性较弱"
|
||||
)
|
||||
|
||||
# 检查数据库密码
|
||||
if settings.MYSQL_PASSWORD in ["password", "123456", "root", ""]:
|
||||
warnings.append(
|
||||
"⚠️ 数据库密码不安全,请使用强密码"
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def print_security_warnings():
|
||||
"""打印安全警告(应用启动时调用)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
warnings = check_security_settings()
|
||||
|
||||
if warnings:
|
||||
logger.warning("=" * 60)
|
||||
logger.warning("安全配置警告:")
|
||||
for warning in warnings:
|
||||
logger.warning(warning)
|
||||
logger.warning("=" * 60)
|
||||
else:
|
||||
logger.info("✅ 安全配置检查通过")
|
||||
|
||||
|
||||
# ============================================
|
||||
# 动态配置获取(支持从数据库读取)
|
||||
# ============================================
|
||||
|
||||
@@ -1,242 +1,242 @@
|
||||
"""
|
||||
定时任务调度模块
|
||||
|
||||
使用 APScheduler 实现定时任务:
|
||||
- 通讯录增量同步(每30分钟)
|
||||
- 通讯录完整同步(每天凌晨2点)
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
||||
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
class SchedulerManager:
|
||||
"""
|
||||
定时任务调度管理器
|
||||
|
||||
单例模式,统一管理所有定时任务
|
||||
"""
|
||||
|
||||
_instance: Optional['SchedulerManager'] = None
|
||||
_scheduler: Optional[AsyncIOScheduler] = None
|
||||
_initialized: bool = False
|
||||
|
||||
# 配置(可通过环境变量覆盖)
|
||||
AUTO_SYNC_ENABLED: bool = True
|
||||
INCREMENTAL_SYNC_INTERVAL_MINUTES: int = 30 # 增量同步间隔(分钟)
|
||||
FULL_SYNC_HOUR: int = 2 # 完整同步执行时间(小时,24小时制)
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'SchedulerManager':
|
||||
"""获取调度管理器实例"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def _load_config(cls):
|
||||
"""从环境变量加载配置"""
|
||||
cls.AUTO_SYNC_ENABLED = os.getenv('AUTO_SYNC_ENABLED', 'true').lower() == 'true'
|
||||
cls.INCREMENTAL_SYNC_INTERVAL_MINUTES = int(os.getenv('AUTO_SYNC_INTERVAL_MINUTES', '30'))
|
||||
cls.FULL_SYNC_HOUR = int(os.getenv('FULL_SYNC_HOUR', '2'))
|
||||
|
||||
async def init(self, db_session_factory):
|
||||
"""
|
||||
初始化调度器
|
||||
|
||||
Args:
|
||||
db_session_factory: 数据库会话工厂(async_sessionmaker)
|
||||
"""
|
||||
if self._initialized:
|
||||
logger.info("调度器已初始化,跳过")
|
||||
return
|
||||
|
||||
self._load_config()
|
||||
|
||||
if not self.AUTO_SYNC_ENABLED:
|
||||
logger.info("自动同步已禁用,调度器不启动")
|
||||
return
|
||||
|
||||
self._db_session_factory = db_session_factory
|
||||
self._scheduler = AsyncIOScheduler(timezone='Asia/Shanghai')
|
||||
|
||||
# 添加任务执行监听器
|
||||
self._scheduler.add_listener(self._job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
|
||||
|
||||
# 注册定时任务
|
||||
self._register_jobs()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("调度器初始化完成")
|
||||
|
||||
def _register_jobs(self):
|
||||
"""注册所有定时任务"""
|
||||
if not self._scheduler:
|
||||
return
|
||||
|
||||
# 1. 增量同步任务(每30分钟)
|
||||
self._scheduler.add_job(
|
||||
self._run_incremental_sync,
|
||||
IntervalTrigger(minutes=self.INCREMENTAL_SYNC_INTERVAL_MINUTES),
|
||||
id='employee_incremental_sync',
|
||||
name='员工增量同步',
|
||||
replace_existing=True,
|
||||
max_instances=1, # 防止任务堆积
|
||||
)
|
||||
logger.info(f"已注册任务: 员工增量同步(每{self.INCREMENTAL_SYNC_INTERVAL_MINUTES}分钟)")
|
||||
|
||||
# 2. 完整同步任务(每天凌晨2点)
|
||||
self._scheduler.add_job(
|
||||
self._run_full_sync,
|
||||
CronTrigger(hour=self.FULL_SYNC_HOUR, minute=0),
|
||||
id='employee_full_sync',
|
||||
name='员工完整同步',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
logger.info(f"已注册任务: 员工完整同步(每天{self.FULL_SYNC_HOUR}:00)")
|
||||
|
||||
def _job_listener(self, event):
|
||||
"""任务执行监听器"""
|
||||
job_id = event.job_id
|
||||
|
||||
if event.exception:
|
||||
logger.error(
|
||||
f"定时任务执行失败",
|
||||
job_id=job_id,
|
||||
error=str(event.exception),
|
||||
traceback=event.traceback
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"定时任务执行完成",
|
||||
job_id=job_id,
|
||||
return_value=str(event.retval) if event.retval else None
|
||||
)
|
||||
|
||||
async def _run_incremental_sync(self):
|
||||
"""执行增量同步"""
|
||||
from app.services.employee_sync_service import EmployeeSyncService
|
||||
|
||||
logger.info("开始执行定时增量同步任务")
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
async with self._db_session_factory() as db:
|
||||
async with EmployeeSyncService(db) as sync_service:
|
||||
stats = await sync_service.incremental_sync_employees()
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"定时增量同步完成",
|
||||
duration_seconds=duration,
|
||||
stats=stats
|
||||
)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"定时增量同步失败: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _run_full_sync(self):
|
||||
"""执行完整同步"""
|
||||
from app.services.employee_sync_service import EmployeeSyncService
|
||||
|
||||
logger.info("开始执行定时完整同步任务")
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
async with self._db_session_factory() as db:
|
||||
async with EmployeeSyncService(db) as sync_service:
|
||||
stats = await sync_service.sync_employees()
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"定时完整同步完成",
|
||||
duration_seconds=duration,
|
||||
stats=stats
|
||||
)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"定时完整同步失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def start(self):
|
||||
"""启动调度器"""
|
||||
if not self._scheduler:
|
||||
logger.warning("调度器未初始化,无法启动")
|
||||
return
|
||||
|
||||
if self._scheduler.running:
|
||||
logger.info("调度器已在运行")
|
||||
return
|
||||
|
||||
self._scheduler.start()
|
||||
logger.info("调度器已启动")
|
||||
|
||||
# 打印已注册的任务
|
||||
jobs = self._scheduler.get_jobs()
|
||||
for job in jobs:
|
||||
logger.info(f" - {job.name} (ID: {job.id}, 下次执行: {job.next_run_time})")
|
||||
|
||||
def stop(self):
|
||||
"""停止调度器"""
|
||||
if self._scheduler and self._scheduler.running:
|
||||
self._scheduler.shutdown(wait=True)
|
||||
logger.info("调度器已停止")
|
||||
|
||||
def get_jobs(self):
|
||||
"""获取所有任务列表"""
|
||||
if not self._scheduler:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'pending': job.pending,
|
||||
}
|
||||
for job in self._scheduler.get_jobs()
|
||||
]
|
||||
|
||||
async def trigger_job(self, job_id: str):
|
||||
"""
|
||||
手动触发任务
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
"""
|
||||
if not self._scheduler:
|
||||
raise RuntimeError("调度器未初始化")
|
||||
|
||||
job = self._scheduler.get_job(job_id)
|
||||
if not job:
|
||||
raise ValueError(f"任务不存在: {job_id}")
|
||||
|
||||
# 立即执行
|
||||
if job_id == 'employee_incremental_sync':
|
||||
return await self._run_incremental_sync()
|
||||
elif job_id == 'employee_full_sync':
|
||||
return await self._run_full_sync()
|
||||
else:
|
||||
raise ValueError(f"未知任务: {job_id}")
|
||||
|
||||
|
||||
# 全局调度管理器实例
|
||||
scheduler_manager = SchedulerManager.get_instance()
|
||||
"""
|
||||
定时任务调度模块
|
||||
|
||||
使用 APScheduler 实现定时任务:
|
||||
- 通讯录增量同步(每30分钟)
|
||||
- 通讯录完整同步(每天凌晨2点)
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.interval import IntervalTrigger
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED
|
||||
|
||||
from app.core.logger import logger
|
||||
|
||||
|
||||
class SchedulerManager:
|
||||
"""
|
||||
定时任务调度管理器
|
||||
|
||||
单例模式,统一管理所有定时任务
|
||||
"""
|
||||
|
||||
_instance: Optional['SchedulerManager'] = None
|
||||
_scheduler: Optional[AsyncIOScheduler] = None
|
||||
_initialized: bool = False
|
||||
|
||||
# 配置(可通过环境变量覆盖)
|
||||
AUTO_SYNC_ENABLED: bool = True
|
||||
INCREMENTAL_SYNC_INTERVAL_MINUTES: int = 30 # 增量同步间隔(分钟)
|
||||
FULL_SYNC_HOUR: int = 2 # 完整同步执行时间(小时,24小时制)
|
||||
|
||||
def __new__(cls):
|
||||
if cls._instance is None:
|
||||
cls._instance = super().__new__(cls)
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def get_instance(cls) -> 'SchedulerManager':
|
||||
"""获取调度管理器实例"""
|
||||
if cls._instance is None:
|
||||
cls._instance = cls()
|
||||
return cls._instance
|
||||
|
||||
@classmethod
|
||||
def _load_config(cls):
|
||||
"""从环境变量加载配置"""
|
||||
cls.AUTO_SYNC_ENABLED = os.getenv('AUTO_SYNC_ENABLED', 'true').lower() == 'true'
|
||||
cls.INCREMENTAL_SYNC_INTERVAL_MINUTES = int(os.getenv('AUTO_SYNC_INTERVAL_MINUTES', '30'))
|
||||
cls.FULL_SYNC_HOUR = int(os.getenv('FULL_SYNC_HOUR', '2'))
|
||||
|
||||
async def init(self, db_session_factory):
|
||||
"""
|
||||
初始化调度器
|
||||
|
||||
Args:
|
||||
db_session_factory: 数据库会话工厂(async_sessionmaker)
|
||||
"""
|
||||
if self._initialized:
|
||||
logger.info("调度器已初始化,跳过")
|
||||
return
|
||||
|
||||
self._load_config()
|
||||
|
||||
if not self.AUTO_SYNC_ENABLED:
|
||||
logger.info("自动同步已禁用,调度器不启动")
|
||||
return
|
||||
|
||||
self._db_session_factory = db_session_factory
|
||||
self._scheduler = AsyncIOScheduler(timezone='Asia/Shanghai')
|
||||
|
||||
# 添加任务执行监听器
|
||||
self._scheduler.add_listener(self._job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR)
|
||||
|
||||
# 注册定时任务
|
||||
self._register_jobs()
|
||||
|
||||
self._initialized = True
|
||||
logger.info("调度器初始化完成")
|
||||
|
||||
def _register_jobs(self):
|
||||
"""注册所有定时任务"""
|
||||
if not self._scheduler:
|
||||
return
|
||||
|
||||
# 1. 增量同步任务(每30分钟)
|
||||
self._scheduler.add_job(
|
||||
self._run_incremental_sync,
|
||||
IntervalTrigger(minutes=self.INCREMENTAL_SYNC_INTERVAL_MINUTES),
|
||||
id='employee_incremental_sync',
|
||||
name='员工增量同步',
|
||||
replace_existing=True,
|
||||
max_instances=1, # 防止任务堆积
|
||||
)
|
||||
logger.info(f"已注册任务: 员工增量同步(每{self.INCREMENTAL_SYNC_INTERVAL_MINUTES}分钟)")
|
||||
|
||||
# 2. 完整同步任务(每天凌晨2点)
|
||||
self._scheduler.add_job(
|
||||
self._run_full_sync,
|
||||
CronTrigger(hour=self.FULL_SYNC_HOUR, minute=0),
|
||||
id='employee_full_sync',
|
||||
name='员工完整同步',
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
)
|
||||
logger.info(f"已注册任务: 员工完整同步(每天{self.FULL_SYNC_HOUR}:00)")
|
||||
|
||||
def _job_listener(self, event):
|
||||
"""任务执行监听器"""
|
||||
job_id = event.job_id
|
||||
|
||||
if event.exception:
|
||||
logger.error(
|
||||
f"定时任务执行失败",
|
||||
job_id=job_id,
|
||||
error=str(event.exception),
|
||||
traceback=event.traceback
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
f"定时任务执行完成",
|
||||
job_id=job_id,
|
||||
return_value=str(event.retval) if event.retval else None
|
||||
)
|
||||
|
||||
async def _run_incremental_sync(self):
|
||||
"""执行增量同步"""
|
||||
from app.services.employee_sync_service import EmployeeSyncService
|
||||
|
||||
logger.info("开始执行定时增量同步任务")
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
async with self._db_session_factory() as db:
|
||||
async with EmployeeSyncService(db) as sync_service:
|
||||
stats = await sync_service.incremental_sync_employees()
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"定时增量同步完成",
|
||||
duration_seconds=duration,
|
||||
stats=stats
|
||||
)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"定时增量同步失败: {str(e)}")
|
||||
raise
|
||||
|
||||
async def _run_full_sync(self):
|
||||
"""执行完整同步"""
|
||||
from app.services.employee_sync_service import EmployeeSyncService
|
||||
|
||||
logger.info("开始执行定时完整同步任务")
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
async with self._db_session_factory() as db:
|
||||
async with EmployeeSyncService(db) as sync_service:
|
||||
stats = await sync_service.sync_employees()
|
||||
|
||||
duration = (datetime.now() - start_time).total_seconds()
|
||||
logger.info(
|
||||
"定时完整同步完成",
|
||||
duration_seconds=duration,
|
||||
stats=stats
|
||||
)
|
||||
return stats
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"定时完整同步失败: {str(e)}")
|
||||
raise
|
||||
|
||||
def start(self):
|
||||
"""启动调度器"""
|
||||
if not self._scheduler:
|
||||
logger.warning("调度器未初始化,无法启动")
|
||||
return
|
||||
|
||||
if self._scheduler.running:
|
||||
logger.info("调度器已在运行")
|
||||
return
|
||||
|
||||
self._scheduler.start()
|
||||
logger.info("调度器已启动")
|
||||
|
||||
# 打印已注册的任务
|
||||
jobs = self._scheduler.get_jobs()
|
||||
for job in jobs:
|
||||
logger.info(f" - {job.name} (ID: {job.id}, 下次执行: {job.next_run_time})")
|
||||
|
||||
def stop(self):
|
||||
"""停止调度器"""
|
||||
if self._scheduler and self._scheduler.running:
|
||||
self._scheduler.shutdown(wait=True)
|
||||
logger.info("调度器已停止")
|
||||
|
||||
def get_jobs(self):
|
||||
"""获取所有任务列表"""
|
||||
if not self._scheduler:
|
||||
return []
|
||||
|
||||
return [
|
||||
{
|
||||
'id': job.id,
|
||||
'name': job.name,
|
||||
'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None,
|
||||
'pending': job.pending,
|
||||
}
|
||||
for job in self._scheduler.get_jobs()
|
||||
]
|
||||
|
||||
async def trigger_job(self, job_id: str):
|
||||
"""
|
||||
手动触发任务
|
||||
|
||||
Args:
|
||||
job_id: 任务ID
|
||||
"""
|
||||
if not self._scheduler:
|
||||
raise RuntimeError("调度器未初始化")
|
||||
|
||||
job = self._scheduler.get_job(job_id)
|
||||
if not job:
|
||||
raise ValueError(f"任务不存在: {job_id}")
|
||||
|
||||
# 立即执行
|
||||
if job_id == 'employee_incremental_sync':
|
||||
return await self._run_incremental_sync()
|
||||
elif job_id == 'employee_full_sync':
|
||||
return await self._run_full_sync()
|
||||
else:
|
||||
raise ValueError(f"未知任务: {job_id}")
|
||||
|
||||
|
||||
# 全局调度管理器实例
|
||||
scheduler_manager = SchedulerManager.get_instance()
|
||||
|
||||
71
backend/app/migrations/add_user_course_progress.sql
Normal file
71
backend/app/migrations/add_user_course_progress.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- ================================================================
|
||||
-- 用户课程学习进度表迁移脚本
|
||||
-- 创建日期: 2026-01-30
|
||||
-- 功能: 添加用户课程进度追踪表和用户资料进度追踪表
|
||||
-- ================================================================
|
||||
|
||||
-- 事务开始
|
||||
START TRANSACTION;
|
||||
|
||||
-- ================================================================
|
||||
-- 1. 创建用户课程进度表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS user_course_progress (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
course_id INT NOT NULL COMMENT '课程ID',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'not_started' COMMENT '学习状态:not_started/in_progress/completed',
|
||||
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '完成百分比(0-100)',
|
||||
completed_materials INT NOT NULL DEFAULT 0 COMMENT '已完成资料数',
|
||||
total_materials INT NOT NULL DEFAULT 0 COMMENT '总资料数',
|
||||
total_study_time INT NOT NULL DEFAULT 0 COMMENT '总学习时长(秒)',
|
||||
first_accessed_at DATETIME COMMENT '首次访问时间',
|
||||
last_accessed_at DATETIME COMMENT '最后访问时间',
|
||||
completed_at DATETIME COMMENT '完成时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_user_course (user_id, course_id),
|
||||
INDEX idx_user_course_progress_user (user_id),
|
||||
INDEX idx_user_course_progress_course (course_id),
|
||||
INDEX idx_user_course_progress_status (status),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户课程进度表';
|
||||
|
||||
-- ================================================================
|
||||
-- 2. 创建用户资料进度表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS user_material_progress (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
material_id INT NOT NULL COMMENT '资料ID',
|
||||
course_id INT NOT NULL COMMENT '课程ID(冗余字段)',
|
||||
is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已完成',
|
||||
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '阅读/播放进度百分比(0-100)',
|
||||
last_position INT NOT NULL DEFAULT 0 COMMENT '上次播放位置(秒)',
|
||||
total_duration INT NOT NULL DEFAULT 0 COMMENT '媒体总时长(秒)',
|
||||
study_time INT NOT NULL DEFAULT 0 COMMENT '学习时长(秒)',
|
||||
first_accessed_at DATETIME COMMENT '首次访问时间',
|
||||
last_accessed_at DATETIME COMMENT '最后访问时间',
|
||||
completed_at DATETIME COMMENT '完成时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_user_material (user_id, material_id),
|
||||
INDEX idx_user_material_progress_user (user_id),
|
||||
INDEX idx_user_material_progress_material (material_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料进度表';
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
|
||||
-- ================================================================
|
||||
-- 验证表创建
|
||||
-- ================================================================
|
||||
SELECT 'user_course_progress' as table_name, COUNT(*) as count FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'user_course_progress'
|
||||
UNION ALL
|
||||
SELECT 'user_material_progress' as table_name, COUNT(*) as count FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'user_material_progress';
|
||||
@@ -32,6 +32,11 @@ from app.models.certificate import (
|
||||
UserCertificate,
|
||||
CertificateType,
|
||||
)
|
||||
from app.models.user_course_progress import (
|
||||
UserCourseProgress,
|
||||
UserMaterialProgress,
|
||||
ProgressStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -72,4 +77,7 @@ __all__ = [
|
||||
"CertificateTemplate",
|
||||
"UserCertificate",
|
||||
"CertificateType",
|
||||
"UserCourseProgress",
|
||||
"UserMaterialProgress",
|
||||
"ProgressStatus",
|
||||
]
|
||||
|
||||
@@ -1,76 +1,76 @@
|
||||
"""
|
||||
证书系统数据模型
|
||||
|
||||
定义证书模板和用户证书的数据结构
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class CertificateType(str, Enum):
|
||||
"""证书类型枚举"""
|
||||
COURSE = "course" # 课程结业证书
|
||||
EXAM = "exam" # 考试合格证书
|
||||
ACHIEVEMENT = "achievement" # 成就证书
|
||||
|
||||
|
||||
class CertificateTemplate(Base):
|
||||
"""证书模板表"""
|
||||
__tablename__ = "certificate_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="模板名称")
|
||||
type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型")
|
||||
background_url = Column(String(500), comment="证书背景图URL")
|
||||
template_html = Column(Text, comment="HTML模板内容")
|
||||
template_style = Column(Text, comment="CSS样式")
|
||||
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||
sort_order = Column(Integer, default=0, comment="排序顺序")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
certificates = relationship("UserCertificate", back_populates="template")
|
||||
|
||||
|
||||
class UserCertificate(Base):
|
||||
"""用户证书表"""
|
||||
__tablename__ = "user_certificates"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID")
|
||||
template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID")
|
||||
certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号")
|
||||
title = Column(String(200), nullable=False, comment="证书标题")
|
||||
description = Column(Text, comment="证书描述")
|
||||
issued_at = Column(DateTime, default=datetime.now, comment="颁发时间")
|
||||
valid_until = Column(DateTime, comment="有效期至")
|
||||
|
||||
# 关联信息
|
||||
course_id = Column(Integer, comment="关联课程ID")
|
||||
exam_id = Column(Integer, comment="关联考试ID")
|
||||
badge_id = Column(Integer, comment="关联奖章ID")
|
||||
|
||||
# 成绩信息
|
||||
score = Column(DECIMAL(5, 2), comment="考试分数")
|
||||
completion_rate = Column(DECIMAL(5, 2), comment="完成率")
|
||||
|
||||
# 生成的文件
|
||||
pdf_url = Column(String(500), comment="PDF文件URL")
|
||||
image_url = Column(String(500), comment="分享图片URL")
|
||||
|
||||
# 元数据
|
||||
meta_data = Column(JSON, comment="扩展元数据")
|
||||
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
template = relationship("CertificateTemplate", back_populates="certificates")
|
||||
user = relationship("User", backref="certificates")
|
||||
"""
|
||||
证书系统数据模型
|
||||
|
||||
定义证书模板和用户证书的数据结构
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class CertificateType(str, Enum):
|
||||
"""证书类型枚举"""
|
||||
COURSE = "course" # 课程结业证书
|
||||
EXAM = "exam" # 考试合格证书
|
||||
ACHIEVEMENT = "achievement" # 成就证书
|
||||
|
||||
|
||||
class CertificateTemplate(Base):
|
||||
"""证书模板表"""
|
||||
__tablename__ = "certificate_templates"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
name = Column(String(100), nullable=False, comment="模板名称")
|
||||
type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型")
|
||||
background_url = Column(String(500), comment="证书背景图URL")
|
||||
template_html = Column(Text, comment="HTML模板内容")
|
||||
template_style = Column(Text, comment="CSS样式")
|
||||
is_active = Column(Boolean, default=True, comment="是否启用")
|
||||
sort_order = Column(Integer, default=0, comment="排序顺序")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
certificates = relationship("UserCertificate", back_populates="template")
|
||||
|
||||
|
||||
class UserCertificate(Base):
|
||||
"""用户证书表"""
|
||||
__tablename__ = "user_certificates"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID")
|
||||
template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID")
|
||||
certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号")
|
||||
title = Column(String(200), nullable=False, comment="证书标题")
|
||||
description = Column(Text, comment="证书描述")
|
||||
issued_at = Column(DateTime, default=datetime.now, comment="颁发时间")
|
||||
valid_until = Column(DateTime, comment="有效期至")
|
||||
|
||||
# 关联信息
|
||||
course_id = Column(Integer, comment="关联课程ID")
|
||||
exam_id = Column(Integer, comment="关联考试ID")
|
||||
badge_id = Column(Integer, comment="关联奖章ID")
|
||||
|
||||
# 成绩信息
|
||||
score = Column(DECIMAL(5, 2), comment="考试分数")
|
||||
completion_rate = Column(DECIMAL(5, 2), comment="完成率")
|
||||
|
||||
# 生成的文件
|
||||
pdf_url = Column(String(500), comment="PDF文件URL")
|
||||
image_url = Column(String(500), comment="分享图片URL")
|
||||
|
||||
# 元数据
|
||||
meta_data = Column(JSON, comment="扩展元数据")
|
||||
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
template = relationship("CertificateTemplate", back_populates="certificates")
|
||||
user = relationship("User", backref="certificates")
|
||||
|
||||
@@ -1,140 +1,140 @@
|
||||
"""
|
||||
等级与奖章系统模型
|
||||
|
||||
包含:
|
||||
- UserLevel: 用户等级信息
|
||||
- ExpHistory: 经验值变化历史
|
||||
- BadgeDefinition: 奖章定义
|
||||
- UserBadge: 用户已获得的奖章
|
||||
- LevelConfig: 等级配置
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.base import Base, BaseModel
|
||||
|
||||
|
||||
class UserLevel(Base):
|
||||
"""用户等级表"""
|
||||
__tablename__ = "user_levels"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
level = Column(Integer, nullable=False, default=1, comment="当前等级")
|
||||
exp = Column(Integer, nullable=False, default=0, comment="当前经验值")
|
||||
total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值")
|
||||
login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数")
|
||||
max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数")
|
||||
last_login_date = Column(Date, nullable=True, comment="最后登录日期")
|
||||
last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="user_level", uselist=False)
|
||||
|
||||
|
||||
class ExpHistory(Base):
|
||||
"""经验值历史表"""
|
||||
__tablename__ = "exp_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
exp_change = Column(Integer, nullable=False, comment="经验值变化")
|
||||
exp_type = Column(String(50), nullable=False, index=True, comment="类型:exam/practice/training/task/login/badge/other")
|
||||
source_id = Column(Integer, nullable=True, comment="来源记录ID")
|
||||
description = Column(String(255), nullable=False, comment="描述")
|
||||
level_before = Column(Integer, nullable=True, comment="变化前等级")
|
||||
level_after = Column(Integer, nullable=True, comment="变化后等级")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="exp_histories")
|
||||
|
||||
|
||||
class BadgeDefinition(Base):
|
||||
"""奖章定义表"""
|
||||
__tablename__ = "badge_definitions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String(50), nullable=False, unique=True, comment="奖章编码")
|
||||
name = Column(String(100), nullable=False, comment="奖章名称")
|
||||
description = Column(String(255), nullable=False, comment="奖章描述")
|
||||
icon = Column(String(100), nullable=False, default="Medal", comment="图标名称")
|
||||
category = Column(String(50), nullable=False, index=True, comment="分类")
|
||||
condition_type = Column(String(50), nullable=False, comment="条件类型")
|
||||
condition_field = Column(String(100), nullable=True, comment="条件字段")
|
||||
condition_value = Column(Integer, nullable=False, default=1, comment="条件数值")
|
||||
exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值")
|
||||
sort_order = Column(Integer, nullable=False, default=0, comment="排序")
|
||||
is_active = Column(Boolean, nullable=False, default=True, comment="是否启用")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
user_badges = relationship("UserBadge", back_populates="badge")
|
||||
|
||||
|
||||
class UserBadge(Base):
|
||||
"""用户奖章表"""
|
||||
__tablename__ = "user_badges"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间")
|
||||
is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知")
|
||||
notified_at = Column(DateTime, nullable=True, comment="通知时间")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="badges")
|
||||
badge = relationship("BadgeDefinition", back_populates="user_badges")
|
||||
|
||||
|
||||
class LevelConfig(Base):
|
||||
"""等级配置表"""
|
||||
__tablename__ = "level_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
level = Column(Integer, nullable=False, unique=True, comment="等级")
|
||||
exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值")
|
||||
total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值")
|
||||
title = Column(String(50), nullable=False, comment="等级称号")
|
||||
color = Column(String(20), nullable=True, comment="等级颜色")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
|
||||
|
||||
# 经验值类型枚举
|
||||
class ExpType:
|
||||
"""经验值类型"""
|
||||
EXAM = "exam" # 考试
|
||||
PRACTICE = "practice" # 练习
|
||||
TRAINING = "training" # 陪练
|
||||
TASK = "task" # 任务
|
||||
LOGIN = "login" # 登录/签到
|
||||
BADGE = "badge" # 奖章奖励
|
||||
OTHER = "other" # 其他
|
||||
|
||||
|
||||
# 奖章分类枚举
|
||||
class BadgeCategory:
|
||||
"""奖章分类"""
|
||||
LEARNING = "learning" # 学习进度
|
||||
EXAM = "exam" # 考试成绩
|
||||
PRACTICE = "practice" # 练习时长
|
||||
STREAK = "streak" # 连续打卡
|
||||
SPECIAL = "special" # 特殊成就
|
||||
|
||||
|
||||
# 条件类型枚举
|
||||
class ConditionType:
|
||||
"""解锁条件类型"""
|
||||
COUNT = "count" # 次数
|
||||
SCORE = "score" # 分数
|
||||
STREAK = "streak" # 连续天数
|
||||
LEVEL = "level" # 等级
|
||||
DURATION = "duration" # 时长
|
||||
"""
|
||||
等级与奖章系统模型
|
||||
|
||||
包含:
|
||||
- UserLevel: 用户等级信息
|
||||
- ExpHistory: 经验值变化历史
|
||||
- BadgeDefinition: 奖章定义
|
||||
- UserBadge: 用户已获得的奖章
|
||||
- LevelConfig: 等级配置
|
||||
"""
|
||||
|
||||
from datetime import datetime, date
|
||||
from typing import Optional, List
|
||||
from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text
|
||||
from sqlalchemy.orm import relationship
|
||||
|
||||
from app.models.base import Base, BaseModel
|
||||
|
||||
|
||||
class UserLevel(Base):
|
||||
"""用户等级表"""
|
||||
__tablename__ = "user_levels"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
|
||||
level = Column(Integer, nullable=False, default=1, comment="当前等级")
|
||||
exp = Column(Integer, nullable=False, default=0, comment="当前经验值")
|
||||
total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值")
|
||||
login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数")
|
||||
max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数")
|
||||
last_login_date = Column(Date, nullable=True, comment="最后登录日期")
|
||||
last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="user_level", uselist=False)
|
||||
|
||||
|
||||
class ExpHistory(Base):
|
||||
"""经验值历史表"""
|
||||
__tablename__ = "exp_history"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
exp_change = Column(Integer, nullable=False, comment="经验值变化")
|
||||
exp_type = Column(String(50), nullable=False, index=True, comment="类型:exam/practice/training/task/login/badge/other")
|
||||
source_id = Column(Integer, nullable=True, comment="来源记录ID")
|
||||
description = Column(String(255), nullable=False, comment="描述")
|
||||
level_before = Column(Integer, nullable=True, comment="变化前等级")
|
||||
level_after = Column(Integer, nullable=True, comment="变化后等级")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="exp_histories")
|
||||
|
||||
|
||||
class BadgeDefinition(Base):
|
||||
"""奖章定义表"""
|
||||
__tablename__ = "badge_definitions"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
code = Column(String(50), nullable=False, unique=True, comment="奖章编码")
|
||||
name = Column(String(100), nullable=False, comment="奖章名称")
|
||||
description = Column(String(255), nullable=False, comment="奖章描述")
|
||||
icon = Column(String(100), nullable=False, default="Medal", comment="图标名称")
|
||||
category = Column(String(50), nullable=False, index=True, comment="分类")
|
||||
condition_type = Column(String(50), nullable=False, comment="条件类型")
|
||||
condition_field = Column(String(100), nullable=True, comment="条件字段")
|
||||
condition_value = Column(Integer, nullable=False, default=1, comment="条件数值")
|
||||
exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值")
|
||||
sort_order = Column(Integer, nullable=False, default=0, comment="排序")
|
||||
is_active = Column(Boolean, nullable=False, default=True, comment="是否启用")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)
|
||||
|
||||
# 关联
|
||||
user_badges = relationship("UserBadge", back_populates="badge")
|
||||
|
||||
|
||||
class UserBadge(Base):
|
||||
"""用户奖章表"""
|
||||
__tablename__ = "user_badges"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间")
|
||||
is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知")
|
||||
notified_at = Column(DateTime, nullable=True, comment="通知时间")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
|
||||
# 关联
|
||||
user = relationship("User", backref="badges")
|
||||
badge = relationship("BadgeDefinition", back_populates="user_badges")
|
||||
|
||||
|
||||
class LevelConfig(Base):
|
||||
"""等级配置表"""
|
||||
__tablename__ = "level_configs"
|
||||
|
||||
id = Column(Integer, primary_key=True, autoincrement=True)
|
||||
level = Column(Integer, nullable=False, unique=True, comment="等级")
|
||||
exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值")
|
||||
total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值")
|
||||
title = Column(String(50), nullable=False, comment="等级称号")
|
||||
color = Column(String(20), nullable=True, comment="等级颜色")
|
||||
created_at = Column(DateTime, nullable=False, default=datetime.now)
|
||||
|
||||
|
||||
# 经验值类型枚举
|
||||
class ExpType:
|
||||
"""经验值类型"""
|
||||
EXAM = "exam" # 考试
|
||||
PRACTICE = "practice" # 练习
|
||||
TRAINING = "training" # 陪练
|
||||
TASK = "task" # 任务
|
||||
LOGIN = "login" # 登录/签到
|
||||
BADGE = "badge" # 奖章奖励
|
||||
OTHER = "other" # 其他
|
||||
|
||||
|
||||
# 奖章分类枚举
|
||||
class BadgeCategory:
|
||||
"""奖章分类"""
|
||||
LEARNING = "learning" # 学习进度
|
||||
EXAM = "exam" # 考试成绩
|
||||
PRACTICE = "practice" # 练习时长
|
||||
STREAK = "streak" # 连续打卡
|
||||
SPECIAL = "special" # 特殊成就
|
||||
|
||||
|
||||
# 条件类型枚举
|
||||
class ConditionType:
|
||||
"""解锁条件类型"""
|
||||
COUNT = "count" # 次数
|
||||
SCORE = "score" # 分数
|
||||
STREAK = "streak" # 连续天数
|
||||
LEVEL = "level" # 等级
|
||||
DURATION = "duration" # 时长
|
||||
|
||||
@@ -1,122 +1,122 @@
|
||||
"""
|
||||
双人对练房间模型
|
||||
|
||||
功能:
|
||||
- 房间管理(创建、加入、状态)
|
||||
- 参与者管理
|
||||
- 实时消息同步
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class PracticeRoom(Base):
|
||||
"""双人对练房间模型"""
|
||||
__tablename__ = "practice_rooms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="房间ID")
|
||||
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
|
||||
room_name = Column(String(200), comment="房间名称")
|
||||
|
||||
# 场景信息
|
||||
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
|
||||
scene_name = Column(String(200), comment="场景名称")
|
||||
scene_type = Column(String(50), comment="场景类型")
|
||||
scene_background = Column(Text, comment="场景背景")
|
||||
|
||||
# 角色设置
|
||||
role_a_name = Column(String(50), default="角色A", comment="角色A名称(如销售顾问)")
|
||||
role_b_name = Column(String(50), default="角色B", comment="角色B名称(如顾客)")
|
||||
role_a_description = Column(Text, comment="角色A描述")
|
||||
role_b_description = Column(Text, comment="角色B描述")
|
||||
|
||||
# 参与者信息
|
||||
host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID")
|
||||
guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID")
|
||||
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
|
||||
max_participants = Column(Integer, default=2, comment="最大参与人数")
|
||||
|
||||
# 状态和时间
|
||||
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
started_at = Column(DateTime, comment="开始时间")
|
||||
ended_at = Column(DateTime, comment="结束时间")
|
||||
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
|
||||
|
||||
# 对话统计
|
||||
total_turns = Column(Integer, default=0, comment="总对话轮次")
|
||||
role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
|
||||
role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
|
||||
|
||||
# 软删除
|
||||
is_deleted = Column(Boolean, default=False, comment="是否删除")
|
||||
deleted_at = Column(DateTime, comment="删除时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""房间是否已满"""
|
||||
return self.guest_user_id is not None
|
||||
|
||||
@property
|
||||
def participant_count(self) -> int:
|
||||
"""当前参与人数"""
|
||||
count = 1 # 房主
|
||||
if self.guest_user_id:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def get_user_role(self, user_id: int) -> str:
|
||||
"""获取用户在房间中的角色"""
|
||||
if user_id == self.host_user_id:
|
||||
return self.host_role
|
||||
elif user_id == self.guest_user_id:
|
||||
return "B" if self.host_role == "A" else "A"
|
||||
return None
|
||||
|
||||
def get_role_name(self, role: str) -> str:
|
||||
"""获取角色名称"""
|
||||
if role == "A":
|
||||
return self.role_a_name
|
||||
elif role == "B":
|
||||
return self.role_b_name
|
||||
return None
|
||||
|
||||
def get_user_role_name(self, user_id: int) -> str:
|
||||
"""获取用户的角色名称"""
|
||||
role = self.get_user_role(user_id)
|
||||
return self.get_role_name(role) if role else None
|
||||
|
||||
|
||||
class PracticeRoomMessage(Base):
|
||||
"""房间实时消息模型"""
|
||||
__tablename__ = "practice_room_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="消息ID")
|
||||
room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID")
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID")
|
||||
message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end")
|
||||
content = Column(Text, comment="消息内容")
|
||||
role_name = Column(String(50), comment="角色名称")
|
||||
sequence = Column(Integer, nullable=False, comment="消息序号")
|
||||
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典(用于SSE推送)"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"room_id": self.room_id,
|
||||
"user_id": self.user_id,
|
||||
"message_type": self.message_type,
|
||||
"content": self.content,
|
||||
"role_name": self.role_name,
|
||||
"sequence": self.sequence,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
"""
|
||||
双人对练房间模型
|
||||
|
||||
功能:
|
||||
- 房间管理(创建、加入、状态)
|
||||
- 参与者管理
|
||||
- 实时消息同步
|
||||
"""
|
||||
from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON
|
||||
from sqlalchemy.sql import func
|
||||
from sqlalchemy.orm import relationship
|
||||
from app.models.base import Base
|
||||
|
||||
|
||||
class PracticeRoom(Base):
|
||||
"""双人对练房间模型"""
|
||||
__tablename__ = "practice_rooms"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="房间ID")
|
||||
room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码")
|
||||
room_name = Column(String(200), comment="房间名称")
|
||||
|
||||
# 场景信息
|
||||
scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID")
|
||||
scene_name = Column(String(200), comment="场景名称")
|
||||
scene_type = Column(String(50), comment="场景类型")
|
||||
scene_background = Column(Text, comment="场景背景")
|
||||
|
||||
# 角色设置
|
||||
role_a_name = Column(String(50), default="角色A", comment="角色A名称(如销售顾问)")
|
||||
role_b_name = Column(String(50), default="角色B", comment="角色B名称(如顾客)")
|
||||
role_a_description = Column(Text, comment="角色A描述")
|
||||
role_b_description = Column(Text, comment="角色B描述")
|
||||
|
||||
# 参与者信息
|
||||
host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID")
|
||||
guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID")
|
||||
host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)")
|
||||
max_participants = Column(Integer, default=2, comment="最大参与人数")
|
||||
|
||||
# 状态和时间
|
||||
status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled")
|
||||
created_at = Column(DateTime, server_default=func.now(), comment="创建时间")
|
||||
started_at = Column(DateTime, comment="开始时间")
|
||||
ended_at = Column(DateTime, comment="结束时间")
|
||||
duration_seconds = Column(Integer, default=0, comment="对练时长(秒)")
|
||||
|
||||
# 对话统计
|
||||
total_turns = Column(Integer, default=0, comment="总对话轮次")
|
||||
role_a_turns = Column(Integer, default=0, comment="角色A发言次数")
|
||||
role_b_turns = Column(Integer, default=0, comment="角色B发言次数")
|
||||
|
||||
# 软删除
|
||||
is_deleted = Column(Boolean, default=False, comment="是否删除")
|
||||
deleted_at = Column(DateTime, comment="删除时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PracticeRoom(code='{self.room_code}', status='{self.status}')>"
|
||||
|
||||
@property
|
||||
def is_full(self) -> bool:
|
||||
"""房间是否已满"""
|
||||
return self.guest_user_id is not None
|
||||
|
||||
@property
|
||||
def participant_count(self) -> int:
|
||||
"""当前参与人数"""
|
||||
count = 1 # 房主
|
||||
if self.guest_user_id:
|
||||
count += 1
|
||||
return count
|
||||
|
||||
def get_user_role(self, user_id: int) -> str:
|
||||
"""获取用户在房间中的角色"""
|
||||
if user_id == self.host_user_id:
|
||||
return self.host_role
|
||||
elif user_id == self.guest_user_id:
|
||||
return "B" if self.host_role == "A" else "A"
|
||||
return None
|
||||
|
||||
def get_role_name(self, role: str) -> str:
|
||||
"""获取角色名称"""
|
||||
if role == "A":
|
||||
return self.role_a_name
|
||||
elif role == "B":
|
||||
return self.role_b_name
|
||||
return None
|
||||
|
||||
def get_user_role_name(self, user_id: int) -> str:
|
||||
"""获取用户的角色名称"""
|
||||
role = self.get_user_role(user_id)
|
||||
return self.get_role_name(role) if role else None
|
||||
|
||||
|
||||
class PracticeRoomMessage(Base):
|
||||
"""房间实时消息模型"""
|
||||
__tablename__ = "practice_room_messages"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True, comment="消息ID")
|
||||
room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID")
|
||||
user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID")
|
||||
message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end")
|
||||
content = Column(Text, comment="消息内容")
|
||||
role_name = Column(String(50), comment="角色名称")
|
||||
sequence = Column(Integer, nullable=False, comment="消息序号")
|
||||
created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间")
|
||||
|
||||
def __repr__(self):
|
||||
return f"<PracticeRoomMessage(room_id={self.room_id}, type='{self.message_type}', seq={self.sequence})>"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""转换为字典(用于SSE推送)"""
|
||||
return {
|
||||
"id": self.id,
|
||||
"room_id": self.room_id,
|
||||
"user_id": self.user_id,
|
||||
"message_type": self.message_type,
|
||||
"content": self.content,
|
||||
"role_name": self.role_name,
|
||||
"sequence": self.sequence,
|
||||
"created_at": self.created_at.isoformat() if self.created_at else None
|
||||
}
|
||||
|
||||
201
backend/app/models/user_course_progress.py
Normal file
201
backend/app/models/user_course_progress.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""
|
||||
用户课程学习进度数据库模型
|
||||
"""
|
||||
from enum import Enum
|
||||
from typing import Optional
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import (
|
||||
String,
|
||||
Integer,
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
Float,
|
||||
DateTime,
|
||||
UniqueConstraint,
|
||||
Index,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.models.base import BaseModel
|
||||
|
||||
|
||||
class ProgressStatus(str, Enum):
|
||||
"""学习进度状态枚举"""
|
||||
NOT_STARTED = "not_started" # 未开始
|
||||
IN_PROGRESS = "in_progress" # 学习中
|
||||
COMPLETED = "completed" # 已完成
|
||||
|
||||
|
||||
class UserCourseProgress(BaseModel):
|
||||
"""
|
||||
用户课程进度表
|
||||
记录用户对每门课程的整体学习进度
|
||||
"""
|
||||
|
||||
__tablename__ = "user_course_progress"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "course_id", name="uq_user_course"),
|
||||
Index("idx_user_course_progress_user", "user_id"),
|
||||
Index("idx_user_course_progress_course", "course_id"),
|
||||
Index("idx_user_course_progress_status", "status"),
|
||||
)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID",
|
||||
)
|
||||
course_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("courses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="课程ID",
|
||||
)
|
||||
|
||||
# 进度信息
|
||||
status: Mapped[ProgressStatus] = mapped_column(
|
||||
String(20),
|
||||
default=ProgressStatus.NOT_STARTED.value,
|
||||
nullable=False,
|
||||
comment="学习状态:not_started/in_progress/completed",
|
||||
)
|
||||
progress_percent: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
default=0.0,
|
||||
nullable=False,
|
||||
comment="完成百分比(0-100)",
|
||||
)
|
||||
completed_materials: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="已完成资料数",
|
||||
)
|
||||
total_materials: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="总资料数",
|
||||
)
|
||||
|
||||
# 学习时长统计
|
||||
total_study_time: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="总学习时长(秒)",
|
||||
)
|
||||
|
||||
# 时间记录
|
||||
first_accessed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="首次访问时间",
|
||||
)
|
||||
last_accessed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="最后访问时间",
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="完成时间",
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user = relationship("User", backref="course_progress")
|
||||
course = relationship("Course", backref="user_progress")
|
||||
|
||||
|
||||
class UserMaterialProgress(BaseModel):
|
||||
"""
|
||||
用户资料进度表
|
||||
记录用户对每个课程资料的学习进度
|
||||
"""
|
||||
|
||||
__tablename__ = "user_material_progress"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "material_id", name="uq_user_material"),
|
||||
Index("idx_user_material_progress_user", "user_id"),
|
||||
Index("idx_user_material_progress_material", "material_id"),
|
||||
)
|
||||
|
||||
user_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("users.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="用户ID",
|
||||
)
|
||||
material_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("course_materials.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="资料ID",
|
||||
)
|
||||
course_id: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
ForeignKey("courses.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
comment="课程ID(冗余字段,便于查询)",
|
||||
)
|
||||
|
||||
# 进度信息
|
||||
is_completed: Mapped[bool] = mapped_column(
|
||||
Boolean,
|
||||
default=False,
|
||||
nullable=False,
|
||||
comment="是否已完成",
|
||||
)
|
||||
progress_percent: Mapped[float] = mapped_column(
|
||||
Float,
|
||||
default=0.0,
|
||||
nullable=False,
|
||||
comment="阅读/播放进度百分比(0-100)",
|
||||
)
|
||||
|
||||
# 视频/音频特有字段
|
||||
last_position: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="上次播放位置(秒)",
|
||||
)
|
||||
total_duration: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="媒体总时长(秒)",
|
||||
)
|
||||
|
||||
# 学习时长
|
||||
study_time: Mapped[int] = mapped_column(
|
||||
Integer,
|
||||
default=0,
|
||||
nullable=False,
|
||||
comment="学习时长(秒)",
|
||||
)
|
||||
|
||||
# 时间记录
|
||||
first_accessed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="首次访问时间",
|
||||
)
|
||||
last_accessed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="最后访问时间",
|
||||
)
|
||||
completed_at: Mapped[Optional[datetime]] = mapped_column(
|
||||
DateTime,
|
||||
nullable=True,
|
||||
comment="完成时间",
|
||||
)
|
||||
|
||||
# 关联关系
|
||||
user = relationship("User", backref="material_progress")
|
||||
material = relationship("CourseMaterial", backref="user_progress")
|
||||
course = relationship("Course", backref="material_user_progress")
|
||||
@@ -1,323 +1,323 @@
|
||||
"""
|
||||
双人对练分析服务
|
||||
|
||||
功能:
|
||||
- 分析双人对练对话
|
||||
- 生成双方评估报告
|
||||
- 对话标注和建议
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.ai.ai_service import AIService
|
||||
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserEvaluation:
|
||||
"""用户评估结果"""
|
||||
user_name: str
|
||||
role_name: str
|
||||
total_score: int
|
||||
dimensions: Dict[str, Dict[str, Any]]
|
||||
highlights: List[str]
|
||||
improvements: List[Dict[str, str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuoPracticeAnalysisResult:
|
||||
"""双人对练分析结果"""
|
||||
# 整体评估
|
||||
interaction_quality: int = 0
|
||||
scene_restoration: int = 0
|
||||
overall_comment: str = ""
|
||||
|
||||
# 用户A评估
|
||||
user_a_evaluation: Optional[UserEvaluation] = None
|
||||
|
||||
# 用户B评估
|
||||
user_b_evaluation: Optional[UserEvaluation] = None
|
||||
|
||||
# 对话标注
|
||||
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# AI 元数据
|
||||
raw_response: str = ""
|
||||
ai_provider: str = ""
|
||||
ai_model: str = ""
|
||||
ai_latency_ms: int = 0
|
||||
|
||||
|
||||
class DuoPracticeAnalysisService:
|
||||
"""
|
||||
双人对练分析服务
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
service = DuoPracticeAnalysisService()
|
||||
result = await service.analyze(
|
||||
scene_name="销售场景",
|
||||
scene_background="客户咨询产品",
|
||||
role_a_name="销售顾问",
|
||||
role_b_name="顾客",
|
||||
user_a_name="张三",
|
||||
user_b_name="李四",
|
||||
dialogue_history=dialogue_list,
|
||||
duration_seconds=300,
|
||||
total_turns=20
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
MODULE_CODE = "duo_practice_analysis"
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
scene_name: str,
|
||||
scene_background: str,
|
||||
role_a_name: str,
|
||||
role_b_name: str,
|
||||
role_a_description: str,
|
||||
role_b_description: str,
|
||||
user_a_name: str,
|
||||
user_b_name: str,
|
||||
dialogue_history: List[Dict[str, Any]],
|
||||
duration_seconds: int,
|
||||
total_turns: int,
|
||||
db: Any = None
|
||||
) -> DuoPracticeAnalysisResult:
|
||||
"""
|
||||
分析双人对练
|
||||
|
||||
Args:
|
||||
scene_name: 场景名称
|
||||
scene_background: 场景背景
|
||||
role_a_name: 角色A名称
|
||||
role_b_name: 角色B名称
|
||||
role_a_description: 角色A描述
|
||||
role_b_description: 角色B描述
|
||||
user_a_name: 用户A名称
|
||||
user_b_name: 用户B名称
|
||||
dialogue_history: 对话历史列表
|
||||
duration_seconds: 对练时长(秒)
|
||||
total_turns: 总对话轮次
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
DuoPracticeAnalysisResult: 分析结果
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
|
||||
|
||||
# 格式化对话历史
|
||||
dialogue_text = self._format_dialogue_history(dialogue_history)
|
||||
|
||||
# 创建 AI 服务
|
||||
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
|
||||
|
||||
# 构建用户提示词
|
||||
user_prompt = USER_PROMPT.format(
|
||||
scene_name=scene_name,
|
||||
scene_background=scene_background or "未设置",
|
||||
role_a_name=role_a_name,
|
||||
role_b_name=role_b_name,
|
||||
role_a_description=role_a_description or f"扮演{role_a_name}角色",
|
||||
role_b_description=role_b_description or f"扮演{role_b_name}角色",
|
||||
user_a_name=user_a_name,
|
||||
user_b_name=user_b_name,
|
||||
dialogue_history=dialogue_text,
|
||||
duration_seconds=duration_seconds,
|
||||
total_turns=total_turns
|
||||
)
|
||||
|
||||
# 调用 AI
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
|
||||
ai_response = await ai_service.chat(
|
||||
messages=messages,
|
||||
model="gemini-3-flash-preview", # 使用快速模型
|
||||
temperature=0.3,
|
||||
prompt_name="duo_practice_analysis"
|
||||
)
|
||||
|
||||
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
|
||||
|
||||
# 解析 AI 输出
|
||||
result = self._parse_analysis_result(
|
||||
ai_response.content,
|
||||
user_a_name=user_a_name,
|
||||
user_b_name=user_b_name,
|
||||
role_a_name=role_a_name,
|
||||
role_b_name=role_b_name
|
||||
)
|
||||
|
||||
# 填充 AI 元数据
|
||||
result.raw_response = ai_response.content
|
||||
result.ai_provider = ai_response.provider
|
||||
result.ai_model = ai_response.model
|
||||
result.ai_latency_ms = ai_response.latency_ms
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"双人对练分析失败: {e}", exc_info=True)
|
||||
# 返回空结果
|
||||
return DuoPracticeAnalysisResult(
|
||||
overall_comment=f"分析失败: {str(e)}"
|
||||
)
|
||||
|
||||
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
|
||||
"""格式化对话历史"""
|
||||
lines = []
|
||||
for d in dialogues:
|
||||
speaker = d.get("role_name") or d.get("speaker", "未知")
|
||||
content = d.get("content", "")
|
||||
seq = d.get("sequence", 0)
|
||||
lines.append(f"[{seq}] {speaker}:{content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _parse_analysis_result(
|
||||
self,
|
||||
ai_output: str,
|
||||
user_a_name: str,
|
||||
user_b_name: str,
|
||||
role_a_name: str,
|
||||
role_b_name: str
|
||||
) -> DuoPracticeAnalysisResult:
|
||||
"""解析 AI 输出"""
|
||||
result = DuoPracticeAnalysisResult()
|
||||
|
||||
try:
|
||||
# 尝试提取 JSON
|
||||
json_str = ai_output
|
||||
|
||||
# 如果输出包含 markdown 代码块,提取其中的 JSON
|
||||
if "```json" in ai_output:
|
||||
start = ai_output.find("```json") + 7
|
||||
end = ai_output.find("```", start)
|
||||
json_str = ai_output[start:end].strip()
|
||||
elif "```" in ai_output:
|
||||
start = ai_output.find("```") + 3
|
||||
end = ai_output.find("```", start)
|
||||
json_str = ai_output[start:end].strip()
|
||||
|
||||
data = json.loads(json_str)
|
||||
|
||||
# 解析整体评估
|
||||
overall = data.get("overall_evaluation", {})
|
||||
result.interaction_quality = overall.get("interaction_quality", 0)
|
||||
result.scene_restoration = overall.get("scene_restoration", 0)
|
||||
result.overall_comment = overall.get("overall_comment", "")
|
||||
|
||||
# 解析用户A评估
|
||||
user_a_data = data.get("user_a_evaluation", {})
|
||||
if user_a_data:
|
||||
result.user_a_evaluation = UserEvaluation(
|
||||
user_name=user_a_data.get("user_name", user_a_name),
|
||||
role_name=user_a_data.get("role_name", role_a_name),
|
||||
total_score=user_a_data.get("total_score", 0),
|
||||
dimensions=user_a_data.get("dimensions", {}),
|
||||
highlights=user_a_data.get("highlights", []),
|
||||
improvements=user_a_data.get("improvements", [])
|
||||
)
|
||||
|
||||
# 解析用户B评估
|
||||
user_b_data = data.get("user_b_evaluation", {})
|
||||
if user_b_data:
|
||||
result.user_b_evaluation = UserEvaluation(
|
||||
user_name=user_b_data.get("user_name", user_b_name),
|
||||
role_name=user_b_data.get("role_name", role_b_name),
|
||||
total_score=user_b_data.get("total_score", 0),
|
||||
dimensions=user_b_data.get("dimensions", {}),
|
||||
highlights=user_b_data.get("highlights", []),
|
||||
improvements=user_b_data.get("improvements", [])
|
||||
)
|
||||
|
||||
# 解析对话标注
|
||||
result.dialogue_annotations = data.get("dialogue_annotations", [])
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"JSON 解析失败: {e}")
|
||||
result.overall_comment = "AI 输出格式异常,请重试"
|
||||
except Exception as e:
|
||||
logger.error(f"解析分析结果失败: {e}")
|
||||
result.overall_comment = f"解析失败: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
|
||||
"""将结果转换为字典(用于 API 响应)"""
|
||||
return {
|
||||
"overall_evaluation": {
|
||||
"interaction_quality": result.interaction_quality,
|
||||
"scene_restoration": result.scene_restoration,
|
||||
"overall_comment": result.overall_comment
|
||||
},
|
||||
"user_a_evaluation": {
|
||||
"user_name": result.user_a_evaluation.user_name,
|
||||
"role_name": result.user_a_evaluation.role_name,
|
||||
"total_score": result.user_a_evaluation.total_score,
|
||||
"dimensions": result.user_a_evaluation.dimensions,
|
||||
"highlights": result.user_a_evaluation.highlights,
|
||||
"improvements": result.user_a_evaluation.improvements
|
||||
} if result.user_a_evaluation else None,
|
||||
"user_b_evaluation": {
|
||||
"user_name": result.user_b_evaluation.user_name,
|
||||
"role_name": result.user_b_evaluation.role_name,
|
||||
"total_score": result.user_b_evaluation.total_score,
|
||||
"dimensions": result.user_b_evaluation.dimensions,
|
||||
"highlights": result.user_b_evaluation.highlights,
|
||||
"improvements": result.user_b_evaluation.improvements
|
||||
} if result.user_b_evaluation else None,
|
||||
"dialogue_annotations": result.dialogue_annotations,
|
||||
"ai_metadata": {
|
||||
"provider": result.ai_provider,
|
||||
"model": result.ai_model,
|
||||
"latency_ms": result.ai_latency_ms
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ==================== 全局实例 ====================
|
||||
|
||||
duo_practice_analysis_service = DuoPracticeAnalysisService()
|
||||
|
||||
|
||||
# ==================== 便捷函数 ====================
|
||||
|
||||
async def analyze_duo_practice(
|
||||
scene_name: str,
|
||||
scene_background: str,
|
||||
role_a_name: str,
|
||||
role_b_name: str,
|
||||
role_a_description: str,
|
||||
role_b_description: str,
|
||||
user_a_name: str,
|
||||
user_b_name: str,
|
||||
dialogue_history: List[Dict[str, Any]],
|
||||
duration_seconds: int,
|
||||
total_turns: int,
|
||||
db: Any = None
|
||||
) -> DuoPracticeAnalysisResult:
|
||||
"""便捷函数:分析双人对练"""
|
||||
return await duo_practice_analysis_service.analyze(
|
||||
scene_name=scene_name,
|
||||
scene_background=scene_background,
|
||||
role_a_name=role_a_name,
|
||||
role_b_name=role_b_name,
|
||||
role_a_description=role_a_description,
|
||||
role_b_description=role_b_description,
|
||||
user_a_name=user_a_name,
|
||||
user_b_name=user_b_name,
|
||||
dialogue_history=dialogue_history,
|
||||
duration_seconds=duration_seconds,
|
||||
total_turns=total_turns,
|
||||
db=db
|
||||
)
|
||||
"""
|
||||
双人对练分析服务
|
||||
|
||||
功能:
|
||||
- 分析双人对练对话
|
||||
- 生成双方评估报告
|
||||
- 对话标注和建议
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from app.services.ai.ai_service import AIService
|
||||
from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class UserEvaluation:
|
||||
"""用户评估结果"""
|
||||
user_name: str
|
||||
role_name: str
|
||||
total_score: int
|
||||
dimensions: Dict[str, Dict[str, Any]]
|
||||
highlights: List[str]
|
||||
improvements: List[Dict[str, str]]
|
||||
|
||||
|
||||
@dataclass
|
||||
class DuoPracticeAnalysisResult:
|
||||
"""双人对练分析结果"""
|
||||
# 整体评估
|
||||
interaction_quality: int = 0
|
||||
scene_restoration: int = 0
|
||||
overall_comment: str = ""
|
||||
|
||||
# 用户A评估
|
||||
user_a_evaluation: Optional[UserEvaluation] = None
|
||||
|
||||
# 用户B评估
|
||||
user_b_evaluation: Optional[UserEvaluation] = None
|
||||
|
||||
# 对话标注
|
||||
dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list)
|
||||
|
||||
# AI 元数据
|
||||
raw_response: str = ""
|
||||
ai_provider: str = ""
|
||||
ai_model: str = ""
|
||||
ai_latency_ms: int = 0
|
||||
|
||||
|
||||
class DuoPracticeAnalysisService:
|
||||
"""
|
||||
双人对练分析服务
|
||||
|
||||
使用示例:
|
||||
```python
|
||||
service = DuoPracticeAnalysisService()
|
||||
result = await service.analyze(
|
||||
scene_name="销售场景",
|
||||
scene_background="客户咨询产品",
|
||||
role_a_name="销售顾问",
|
||||
role_b_name="顾客",
|
||||
user_a_name="张三",
|
||||
user_b_name="李四",
|
||||
dialogue_history=dialogue_list,
|
||||
duration_seconds=300,
|
||||
total_turns=20
|
||||
)
|
||||
```
|
||||
"""
|
||||
|
||||
MODULE_CODE = "duo_practice_analysis"
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
scene_name: str,
|
||||
scene_background: str,
|
||||
role_a_name: str,
|
||||
role_b_name: str,
|
||||
role_a_description: str,
|
||||
role_b_description: str,
|
||||
user_a_name: str,
|
||||
user_b_name: str,
|
||||
dialogue_history: List[Dict[str, Any]],
|
||||
duration_seconds: int,
|
||||
total_turns: int,
|
||||
db: Any = None
|
||||
) -> DuoPracticeAnalysisResult:
|
||||
"""
|
||||
分析双人对练
|
||||
|
||||
Args:
|
||||
scene_name: 场景名称
|
||||
scene_background: 场景背景
|
||||
role_a_name: 角色A名称
|
||||
role_b_name: 角色B名称
|
||||
role_a_description: 角色A描述
|
||||
role_b_description: 角色B描述
|
||||
user_a_name: 用户A名称
|
||||
user_b_name: 用户B名称
|
||||
dialogue_history: 对话历史列表
|
||||
duration_seconds: 对练时长(秒)
|
||||
total_turns: 总对话轮次
|
||||
db: 数据库会话
|
||||
|
||||
Returns:
|
||||
DuoPracticeAnalysisResult: 分析结果
|
||||
"""
|
||||
try:
|
||||
logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}")
|
||||
|
||||
# 格式化对话历史
|
||||
dialogue_text = self._format_dialogue_history(dialogue_history)
|
||||
|
||||
# 创建 AI 服务
|
||||
ai_service = AIService(module_code=self.MODULE_CODE, db_session=db)
|
||||
|
||||
# 构建用户提示词
|
||||
user_prompt = USER_PROMPT.format(
|
||||
scene_name=scene_name,
|
||||
scene_background=scene_background or "未设置",
|
||||
role_a_name=role_a_name,
|
||||
role_b_name=role_b_name,
|
||||
role_a_description=role_a_description or f"扮演{role_a_name}角色",
|
||||
role_b_description=role_b_description or f"扮演{role_b_name}角色",
|
||||
user_a_name=user_a_name,
|
||||
user_b_name=user_b_name,
|
||||
dialogue_history=dialogue_text,
|
||||
duration_seconds=duration_seconds,
|
||||
total_turns=total_turns
|
||||
)
|
||||
|
||||
# 调用 AI
|
||||
messages = [
|
||||
{"role": "system", "content": SYSTEM_PROMPT},
|
||||
{"role": "user", "content": user_prompt}
|
||||
]
|
||||
|
||||
ai_response = await ai_service.chat(
|
||||
messages=messages,
|
||||
model="gemini-3-flash-preview", # 使用快速模型
|
||||
temperature=0.3,
|
||||
prompt_name="duo_practice_analysis"
|
||||
)
|
||||
|
||||
logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms")
|
||||
|
||||
# 解析 AI 输出
|
||||
result = self._parse_analysis_result(
|
||||
ai_response.content,
|
||||
user_a_name=user_a_name,
|
||||
user_b_name=user_b_name,
|
||||
role_a_name=role_a_name,
|
||||
role_b_name=role_b_name
|
||||
)
|
||||
|
||||
# 填充 AI 元数据
|
||||
result.raw_response = ai_response.content
|
||||
result.ai_provider = ai_response.provider
|
||||
result.ai_model = ai_response.model
|
||||
result.ai_latency_ms = ai_response.latency_ms
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"双人对练分析失败: {e}", exc_info=True)
|
||||
# 返回空结果
|
||||
return DuoPracticeAnalysisResult(
|
||||
overall_comment=f"分析失败: {str(e)}"
|
||||
)
|
||||
|
||||
def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str:
|
||||
"""格式化对话历史"""
|
||||
lines = []
|
||||
for d in dialogues:
|
||||
speaker = d.get("role_name") or d.get("speaker", "未知")
|
||||
content = d.get("content", "")
|
||||
seq = d.get("sequence", 0)
|
||||
lines.append(f"[{seq}] {speaker}:{content}")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _parse_analysis_result(
|
||||
self,
|
||||
ai_output: str,
|
||||
user_a_name: str,
|
||||
user_b_name: str,
|
||||
role_a_name: str,
|
||||
role_b_name: str
|
||||
) -> DuoPracticeAnalysisResult:
|
||||
"""解析 AI 输出"""
|
||||
result = DuoPracticeAnalysisResult()
|
||||
|
||||
try:
|
||||
# 尝试提取 JSON
|
||||
json_str = ai_output
|
||||
|
||||
# 如果输出包含 markdown 代码块,提取其中的 JSON
|
||||
if "```json" in ai_output:
|
||||
start = ai_output.find("```json") + 7
|
||||
end = ai_output.find("```", start)
|
||||
json_str = ai_output[start:end].strip()
|
||||
elif "```" in ai_output:
|
||||
start = ai_output.find("```") + 3
|
||||
end = ai_output.find("```", start)
|
||||
json_str = ai_output[start:end].strip()
|
||||
|
||||
data = json.loads(json_str)
|
||||
|
||||
# 解析整体评估
|
||||
overall = data.get("overall_evaluation", {})
|
||||
result.interaction_quality = overall.get("interaction_quality", 0)
|
||||
result.scene_restoration = overall.get("scene_restoration", 0)
|
||||
result.overall_comment = overall.get("overall_comment", "")
|
||||
|
||||
# 解析用户A评估
|
||||
user_a_data = data.get("user_a_evaluation", {})
|
||||
if user_a_data:
|
||||
result.user_a_evaluation = UserEvaluation(
|
||||
user_name=user_a_data.get("user_name", user_a_name),
|
||||
role_name=user_a_data.get("role_name", role_a_name),
|
||||
total_score=user_a_data.get("total_score", 0),
|
||||
dimensions=user_a_data.get("dimensions", {}),
|
||||
highlights=user_a_data.get("highlights", []),
|
||||
improvements=user_a_data.get("improvements", [])
|
||||
)
|
||||
|
||||
# 解析用户B评估
|
||||
user_b_data = data.get("user_b_evaluation", {})
|
||||
if user_b_data:
|
||||
result.user_b_evaluation = UserEvaluation(
|
||||
user_name=user_b_data.get("user_name", user_b_name),
|
||||
role_name=user_b_data.get("role_name", role_b_name),
|
||||
total_score=user_b_data.get("total_score", 0),
|
||||
dimensions=user_b_data.get("dimensions", {}),
|
||||
highlights=user_b_data.get("highlights", []),
|
||||
improvements=user_b_data.get("improvements", [])
|
||||
)
|
||||
|
||||
# 解析对话标注
|
||||
result.dialogue_annotations = data.get("dialogue_annotations", [])
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
logger.warning(f"JSON 解析失败: {e}")
|
||||
result.overall_comment = "AI 输出格式异常,请重试"
|
||||
except Exception as e:
|
||||
logger.error(f"解析分析结果失败: {e}")
|
||||
result.overall_comment = f"解析失败: {str(e)}"
|
||||
|
||||
return result
|
||||
|
||||
def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]:
|
||||
"""将结果转换为字典(用于 API 响应)"""
|
||||
return {
|
||||
"overall_evaluation": {
|
||||
"interaction_quality": result.interaction_quality,
|
||||
"scene_restoration": result.scene_restoration,
|
||||
"overall_comment": result.overall_comment
|
||||
},
|
||||
"user_a_evaluation": {
|
||||
"user_name": result.user_a_evaluation.user_name,
|
||||
"role_name": result.user_a_evaluation.role_name,
|
||||
"total_score": result.user_a_evaluation.total_score,
|
||||
"dimensions": result.user_a_evaluation.dimensions,
|
||||
"highlights": result.user_a_evaluation.highlights,
|
||||
"improvements": result.user_a_evaluation.improvements
|
||||
} if result.user_a_evaluation else None,
|
||||
"user_b_evaluation": {
|
||||
"user_name": result.user_b_evaluation.user_name,
|
||||
"role_name": result.user_b_evaluation.role_name,
|
||||
"total_score": result.user_b_evaluation.total_score,
|
||||
"dimensions": result.user_b_evaluation.dimensions,
|
||||
"highlights": result.user_b_evaluation.highlights,
|
||||
"improvements": result.user_b_evaluation.improvements
|
||||
} if result.user_b_evaluation else None,
|
||||
"dialogue_annotations": result.dialogue_annotations,
|
||||
"ai_metadata": {
|
||||
"provider": result.ai_provider,
|
||||
"model": result.ai_model,
|
||||
"latency_ms": result.ai_latency_ms
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# ==================== 全局实例 ====================
|
||||
|
||||
duo_practice_analysis_service = DuoPracticeAnalysisService()
|
||||
|
||||
|
||||
# ==================== 便捷函数 ====================
|
||||
|
||||
async def analyze_duo_practice(
|
||||
scene_name: str,
|
||||
scene_background: str,
|
||||
role_a_name: str,
|
||||
role_b_name: str,
|
||||
role_a_description: str,
|
||||
role_b_description: str,
|
||||
user_a_name: str,
|
||||
user_b_name: str,
|
||||
dialogue_history: List[Dict[str, Any]],
|
||||
duration_seconds: int,
|
||||
total_turns: int,
|
||||
db: Any = None
|
||||
) -> DuoPracticeAnalysisResult:
|
||||
"""便捷函数:分析双人对练"""
|
||||
return await duo_practice_analysis_service.analyze(
|
||||
scene_name=scene_name,
|
||||
scene_background=scene_background,
|
||||
role_a_name=role_a_name,
|
||||
role_b_name=role_b_name,
|
||||
role_a_description=role_a_description,
|
||||
role_b_description=role_b_description,
|
||||
user_a_name=user_a_name,
|
||||
user_b_name=user_b_name,
|
||||
dialogue_history=dialogue_history,
|
||||
duration_seconds=duration_seconds,
|
||||
total_turns=total_turns,
|
||||
db=db
|
||||
)
|
||||
|
||||
@@ -1,207 +1,207 @@
|
||||
"""
|
||||
双人对练评估提示词模板
|
||||
|
||||
功能:评估双人角色扮演对练的表现
|
||||
"""
|
||||
|
||||
# ==================== 元数据 ====================
|
||||
|
||||
PROMPT_META = {
|
||||
"name": "duo_practice_analysis",
|
||||
"display_name": "双人对练评估",
|
||||
"description": "评估双人角色扮演对练中双方的表现",
|
||||
"module": "kaopeilian",
|
||||
"variables": [
|
||||
"scene_name", "scene_background",
|
||||
"role_a_name", "role_b_name",
|
||||
"role_a_description", "role_b_description",
|
||||
"user_a_name", "user_b_name",
|
||||
"dialogue_history",
|
||||
"duration_seconds", "total_turns"
|
||||
],
|
||||
"version": "1.0.0",
|
||||
"author": "kaopeilian-team",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 系统提示词 ====================
|
||||
|
||||
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
|
||||
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
|
||||
|
||||
评估原则:
|
||||
1. 客观公正,基于对话内容给出评价
|
||||
2. 突出亮点,指出不足
|
||||
3. 给出具体、可操作的改进建议
|
||||
4. 考虑角色特点,评估角色代入度
|
||||
|
||||
输出格式要求:
|
||||
- 必须返回有效的 JSON 格式
|
||||
- 分数范围 0-100
|
||||
- 建议具体可行"""
|
||||
|
||||
|
||||
# ==================== 用户提示词模板 ====================
|
||||
|
||||
USER_PROMPT = """# 双人对练评估任务
|
||||
|
||||
## 场景信息
|
||||
- **场景名称**:{scene_name}
|
||||
- **场景背景**:{scene_background}
|
||||
|
||||
## 角色设置
|
||||
### {role_a_name}
|
||||
- **扮演者**:{user_a_name}
|
||||
- **角色描述**:{role_a_description}
|
||||
|
||||
### {role_b_name}
|
||||
- **扮演者**:{user_b_name}
|
||||
- **角色描述**:{role_b_description}
|
||||
|
||||
## 对练数据
|
||||
- **对练时长**:{duration_seconds} 秒
|
||||
- **总对话轮次**:{total_turns} 轮
|
||||
|
||||
## 对话记录
|
||||
{dialogue_history}
|
||||
|
||||
---
|
||||
|
||||
## 评估要求
|
||||
|
||||
请按以下 JSON 格式输出评估结果:
|
||||
|
||||
```json
|
||||
{{
|
||||
"overall_evaluation": {{
|
||||
"interaction_quality": 85,
|
||||
"scene_restoration": 80,
|
||||
"overall_comment": "整体评价..."
|
||||
}},
|
||||
"user_a_evaluation": {{
|
||||
"user_name": "{user_a_name}",
|
||||
"role_name": "{role_a_name}",
|
||||
"total_score": 85,
|
||||
"dimensions": {{
|
||||
"role_immersion": {{
|
||||
"score": 85,
|
||||
"comment": "角色代入度评价..."
|
||||
}},
|
||||
"communication": {{
|
||||
"score": 80,
|
||||
"comment": "沟通表达能力评价..."
|
||||
}},
|
||||
"professional_knowledge": {{
|
||||
"score": 75,
|
||||
"comment": "专业知识运用评价..."
|
||||
}},
|
||||
"response_quality": {{
|
||||
"score": 82,
|
||||
"comment": "回应质量评价..."
|
||||
}},
|
||||
"goal_achievement": {{
|
||||
"score": 78,
|
||||
"comment": "目标达成度评价..."
|
||||
}}
|
||||
}},
|
||||
"highlights": [
|
||||
"亮点1...",
|
||||
"亮点2..."
|
||||
],
|
||||
"improvements": [
|
||||
{{
|
||||
"issue": "问题描述",
|
||||
"suggestion": "改进建议",
|
||||
"example": "示例话术"
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"user_b_evaluation": {{
|
||||
"user_name": "{user_b_name}",
|
||||
"role_name": "{role_b_name}",
|
||||
"total_score": 82,
|
||||
"dimensions": {{
|
||||
"role_immersion": {{
|
||||
"score": 80,
|
||||
"comment": "角色代入度评价..."
|
||||
}},
|
||||
"communication": {{
|
||||
"score": 85,
|
||||
"comment": "沟通表达能力评价..."
|
||||
}},
|
||||
"professional_knowledge": {{
|
||||
"score": 78,
|
||||
"comment": "专业知识运用评价..."
|
||||
}},
|
||||
"response_quality": {{
|
||||
"score": 80,
|
||||
"comment": "回应质量评价..."
|
||||
}},
|
||||
"goal_achievement": {{
|
||||
"score": 75,
|
||||
"comment": "目标达成度评价..."
|
||||
}}
|
||||
}},
|
||||
"highlights": [
|
||||
"亮点1...",
|
||||
"亮点2..."
|
||||
],
|
||||
"improvements": [
|
||||
{{
|
||||
"issue": "问题描述",
|
||||
"suggestion": "改进建议",
|
||||
"example": "示例话术"
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"dialogue_annotations": [
|
||||
{{
|
||||
"sequence": 1,
|
||||
"speaker": "{role_a_name}",
|
||||
"tags": ["good_opening"],
|
||||
"comment": "开场白自然得体"
|
||||
}},
|
||||
{{
|
||||
"sequence": 3,
|
||||
"speaker": "{role_b_name}",
|
||||
"tags": ["needs_improvement"],
|
||||
"comment": "可以更主动表达需求"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
请基于对话内容,给出客观、专业的评估。"""
|
||||
|
||||
|
||||
# ==================== 维度说明 ====================
|
||||
|
||||
DIMENSION_DESCRIPTIONS = {
|
||||
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
|
||||
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
|
||||
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
|
||||
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
|
||||
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
|
||||
}
|
||||
|
||||
|
||||
# ==================== 对话标签 ====================
|
||||
|
||||
DIALOGUE_TAGS = {
|
||||
# 正面标签
|
||||
"good_opening": "开场良好",
|
||||
"active_listening": "积极倾听",
|
||||
"empathy": "共情表达",
|
||||
"professional": "专业表现",
|
||||
"good_closing": "结束得体",
|
||||
"creative_response": "创意回应",
|
||||
"problem_solving": "问题解决",
|
||||
|
||||
# 需改进标签
|
||||
"needs_improvement": "需要改进",
|
||||
"off_topic": "偏离主题",
|
||||
"too_passive": "过于被动",
|
||||
"lack_detail": "缺乏细节",
|
||||
"missed_opportunity": "错失机会",
|
||||
"unclear_expression": "表达不清"
|
||||
}
|
||||
"""
|
||||
双人对练评估提示词模板
|
||||
|
||||
功能:评估双人角色扮演对练的表现
|
||||
"""
|
||||
|
||||
# ==================== 元数据 ====================
|
||||
|
||||
PROMPT_META = {
|
||||
"name": "duo_practice_analysis",
|
||||
"display_name": "双人对练评估",
|
||||
"description": "评估双人角色扮演对练中双方的表现",
|
||||
"module": "kaopeilian",
|
||||
"variables": [
|
||||
"scene_name", "scene_background",
|
||||
"role_a_name", "role_b_name",
|
||||
"role_a_description", "role_b_description",
|
||||
"user_a_name", "user_b_name",
|
||||
"dialogue_history",
|
||||
"duration_seconds", "total_turns"
|
||||
],
|
||||
"version": "1.0.0",
|
||||
"author": "kaopeilian-team",
|
||||
}
|
||||
|
||||
|
||||
# ==================== 系统提示词 ====================
|
||||
|
||||
SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。
|
||||
你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。
|
||||
|
||||
评估原则:
|
||||
1. 客观公正,基于对话内容给出评价
|
||||
2. 突出亮点,指出不足
|
||||
3. 给出具体、可操作的改进建议
|
||||
4. 考虑角色特点,评估角色代入度
|
||||
|
||||
输出格式要求:
|
||||
- 必须返回有效的 JSON 格式
|
||||
- 分数范围 0-100
|
||||
- 建议具体可行"""
|
||||
|
||||
|
||||
# ==================== 用户提示词模板 ====================
|
||||
|
||||
USER_PROMPT = """# 双人对练评估任务
|
||||
|
||||
## 场景信息
|
||||
- **场景名称**:{scene_name}
|
||||
- **场景背景**:{scene_background}
|
||||
|
||||
## 角色设置
|
||||
### {role_a_name}
|
||||
- **扮演者**:{user_a_name}
|
||||
- **角色描述**:{role_a_description}
|
||||
|
||||
### {role_b_name}
|
||||
- **扮演者**:{user_b_name}
|
||||
- **角色描述**:{role_b_description}
|
||||
|
||||
## 对练数据
|
||||
- **对练时长**:{duration_seconds} 秒
|
||||
- **总对话轮次**:{total_turns} 轮
|
||||
|
||||
## 对话记录
|
||||
{dialogue_history}
|
||||
|
||||
---
|
||||
|
||||
## 评估要求
|
||||
|
||||
请按以下 JSON 格式输出评估结果:
|
||||
|
||||
```json
|
||||
{{
|
||||
"overall_evaluation": {{
|
||||
"interaction_quality": 85,
|
||||
"scene_restoration": 80,
|
||||
"overall_comment": "整体评价..."
|
||||
}},
|
||||
"user_a_evaluation": {{
|
||||
"user_name": "{user_a_name}",
|
||||
"role_name": "{role_a_name}",
|
||||
"total_score": 85,
|
||||
"dimensions": {{
|
||||
"role_immersion": {{
|
||||
"score": 85,
|
||||
"comment": "角色代入度评价..."
|
||||
}},
|
||||
"communication": {{
|
||||
"score": 80,
|
||||
"comment": "沟通表达能力评价..."
|
||||
}},
|
||||
"professional_knowledge": {{
|
||||
"score": 75,
|
||||
"comment": "专业知识运用评价..."
|
||||
}},
|
||||
"response_quality": {{
|
||||
"score": 82,
|
||||
"comment": "回应质量评价..."
|
||||
}},
|
||||
"goal_achievement": {{
|
||||
"score": 78,
|
||||
"comment": "目标达成度评价..."
|
||||
}}
|
||||
}},
|
||||
"highlights": [
|
||||
"亮点1...",
|
||||
"亮点2..."
|
||||
],
|
||||
"improvements": [
|
||||
{{
|
||||
"issue": "问题描述",
|
||||
"suggestion": "改进建议",
|
||||
"example": "示例话术"
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"user_b_evaluation": {{
|
||||
"user_name": "{user_b_name}",
|
||||
"role_name": "{role_b_name}",
|
||||
"total_score": 82,
|
||||
"dimensions": {{
|
||||
"role_immersion": {{
|
||||
"score": 80,
|
||||
"comment": "角色代入度评价..."
|
||||
}},
|
||||
"communication": {{
|
||||
"score": 85,
|
||||
"comment": "沟通表达能力评价..."
|
||||
}},
|
||||
"professional_knowledge": {{
|
||||
"score": 78,
|
||||
"comment": "专业知识运用评价..."
|
||||
}},
|
||||
"response_quality": {{
|
||||
"score": 80,
|
||||
"comment": "回应质量评价..."
|
||||
}},
|
||||
"goal_achievement": {{
|
||||
"score": 75,
|
||||
"comment": "目标达成度评价..."
|
||||
}}
|
||||
}},
|
||||
"highlights": [
|
||||
"亮点1...",
|
||||
"亮点2..."
|
||||
],
|
||||
"improvements": [
|
||||
{{
|
||||
"issue": "问题描述",
|
||||
"suggestion": "改进建议",
|
||||
"example": "示例话术"
|
||||
}}
|
||||
]
|
||||
}},
|
||||
"dialogue_annotations": [
|
||||
{{
|
||||
"sequence": 1,
|
||||
"speaker": "{role_a_name}",
|
||||
"tags": ["good_opening"],
|
||||
"comment": "开场白自然得体"
|
||||
}},
|
||||
{{
|
||||
"sequence": 3,
|
||||
"speaker": "{role_b_name}",
|
||||
"tags": ["needs_improvement"],
|
||||
"comment": "可以更主动表达需求"
|
||||
}}
|
||||
]
|
||||
}}
|
||||
```
|
||||
|
||||
请基于对话内容,给出客观、专业的评估。"""
|
||||
|
||||
|
||||
# ==================== 维度说明 ====================
|
||||
|
||||
DIMENSION_DESCRIPTIONS = {
|
||||
"role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定",
|
||||
"communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体",
|
||||
"professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备",
|
||||
"response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性",
|
||||
"goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果"
|
||||
}
|
||||
|
||||
|
||||
# ==================== 对话标签 ====================
|
||||
|
||||
DIALOGUE_TAGS = {
|
||||
# 正面标签
|
||||
"good_opening": "开场良好",
|
||||
"active_listening": "积极倾听",
|
||||
"empathy": "共情表达",
|
||||
"professional": "专业表现",
|
||||
"good_closing": "结束得体",
|
||||
"creative_response": "创意回应",
|
||||
"problem_solving": "问题解决",
|
||||
|
||||
# 需改进标签
|
||||
"needs_improvement": "需要改进",
|
||||
"off_topic": "偏离主题",
|
||||
"too_passive": "过于被动",
|
||||
"lack_detail": "缺乏细节",
|
||||
"missed_opportunity": "错失机会",
|
||||
"unclear_expression": "表达不清"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,489 +1,489 @@
|
||||
"""
|
||||
数据大屏服务
|
||||
|
||||
提供企业级和团队级数据大屏功能:
|
||||
- 学习数据概览
|
||||
- 部门/团队对比
|
||||
- 趋势分析
|
||||
- 实时动态
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy import select, func, and_, or_, desc, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.models.user import User
|
||||
from app.models.course import Course, CourseMaterial
|
||||
from app.models.exam import Exam
|
||||
from app.models.practice import PracticeSession
|
||||
from app.models.training import TrainingSession, TrainingReport
|
||||
from app.models.level import UserLevel, ExpHistory, UserBadge
|
||||
from app.models.position import Position
|
||||
from app.models.position_member import PositionMember
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""数据大屏服务"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
获取企业级数据概览
|
||||
|
||||
Args:
|
||||
enterprise_id: 企业ID(可选,用于多租户)
|
||||
|
||||
Returns:
|
||||
企业级数据概览
|
||||
"""
|
||||
today = date.today()
|
||||
week_ago = today - timedelta(days=7)
|
||||
month_ago = today - timedelta(days=30)
|
||||
|
||||
# 基础统计
|
||||
# 1. 总学员数
|
||||
result = await self.db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.is_deleted == False, User.role == 'trainee')
|
||||
)
|
||||
total_users = result.scalar() or 0
|
||||
|
||||
# 2. 今日活跃用户(有经验值记录)
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(func.date(ExpHistory.created_at) == today)
|
||||
)
|
||||
today_active = result.scalar() or 0
|
||||
|
||||
# 3. 本周活跃用户
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
|
||||
)
|
||||
week_active = result.scalar() or 0
|
||||
|
||||
# 4. 本月活跃用户
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
|
||||
)
|
||||
month_active = result.scalar() or 0
|
||||
|
||||
# 5. 总学习时长(小时)
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||
.where(PracticeSession.status == 'completed')
|
||||
)
|
||||
practice_hours = (result.scalar() or 0) / 3600
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
|
||||
.where(TrainingSession.status == 'COMPLETED')
|
||||
)
|
||||
training_hours = (result.scalar() or 0) / 3600
|
||||
|
||||
total_hours = round(practice_hours + training_hours, 1)
|
||||
|
||||
# 6. 考试统计
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
func.count(Exam.id),
|
||||
func.count(case((Exam.is_passed == True, 1))),
|
||||
func.avg(Exam.score)
|
||||
)
|
||||
.where(Exam.status == 'submitted')
|
||||
)
|
||||
exam_row = result.first()
|
||||
exam_count = exam_row[0] or 0
|
||||
exam_passed = exam_row[1] or 0
|
||||
exam_avg_score = round(exam_row[2] or 0, 1)
|
||||
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
|
||||
|
||||
# 7. 满分人数
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(Exam.user_id)))
|
||||
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
|
||||
)
|
||||
perfect_users = result.scalar() or 0
|
||||
|
||||
# 8. 签到率(今日签到人数/总用户数)
|
||||
result = await self.db.execute(
|
||||
select(func.count(UserLevel.id))
|
||||
.where(func.date(UserLevel.last_login_date) == today)
|
||||
)
|
||||
today_checkin = result.scalar() or 0
|
||||
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
|
||||
|
||||
return {
|
||||
"overview": {
|
||||
"total_users": total_users,
|
||||
"today_active": today_active,
|
||||
"week_active": week_active,
|
||||
"month_active": month_active,
|
||||
"total_hours": total_hours,
|
||||
"checkin_rate": checkin_rate,
|
||||
},
|
||||
"exam": {
|
||||
"total_count": exam_count,
|
||||
"pass_rate": exam_pass_rate,
|
||||
"avg_score": exam_avg_score,
|
||||
"perfect_users": perfect_users,
|
||||
},
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
async def get_department_comparison(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取部门/团队学习对比数据
|
||||
|
||||
Returns:
|
||||
部门对比列表
|
||||
"""
|
||||
# 获取所有岗位及其成员的学习数据
|
||||
result = await self.db.execute(
|
||||
select(Position)
|
||||
.where(Position.is_deleted == False)
|
||||
.order_by(Position.name)
|
||||
)
|
||||
positions = result.scalars().all()
|
||||
|
||||
departments = []
|
||||
for pos in positions:
|
||||
# 获取该岗位的成员数
|
||||
result = await self.db.execute(
|
||||
select(func.count(PositionMember.id))
|
||||
.where(PositionMember.position_id == pos.id)
|
||||
)
|
||||
member_count = result.scalar() or 0
|
||||
|
||||
if member_count == 0:
|
||||
continue
|
||||
|
||||
# 获取成员ID列表
|
||||
result = await self.db.execute(
|
||||
select(PositionMember.user_id)
|
||||
.where(PositionMember.position_id == pos.id)
|
||||
)
|
||||
member_ids = [row[0] for row in result.all()]
|
||||
|
||||
# 统计该岗位成员的学习数据
|
||||
# 考试通过率
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
func.count(Exam.id),
|
||||
func.count(case((Exam.is_passed == True, 1)))
|
||||
)
|
||||
.where(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.status == 'submitted'
|
||||
)
|
||||
)
|
||||
exam_row = result.first()
|
||||
exam_total = exam_row[0] or 0
|
||||
exam_passed = exam_row[1] or 0
|
||||
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
|
||||
|
||||
# 平均学习时长
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||
.where(
|
||||
PracticeSession.user_id.in_(member_ids),
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
total_seconds = result.scalar() or 0
|
||||
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
|
||||
|
||||
# 平均等级
|
||||
result = await self.db.execute(
|
||||
select(func.avg(UserLevel.level))
|
||||
.where(UserLevel.user_id.in_(member_ids))
|
||||
)
|
||||
avg_level = round(result.scalar() or 1, 1)
|
||||
|
||||
departments.append({
|
||||
"id": pos.id,
|
||||
"name": pos.name,
|
||||
"member_count": member_count,
|
||||
"pass_rate": pass_rate,
|
||||
"avg_hours": avg_hours,
|
||||
"avg_level": avg_level,
|
||||
})
|
||||
|
||||
# 按通过率排序
|
||||
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
|
||||
|
||||
return departments
|
||||
|
||||
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
|
||||
"""
|
||||
获取学习趋势数据
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
趋势数据
|
||||
"""
|
||||
today = date.today()
|
||||
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
|
||||
|
||||
trend_data = []
|
||||
for d in dates:
|
||||
# 当日活跃用户
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(func.date(ExpHistory.created_at) == d)
|
||||
)
|
||||
active_users = result.scalar() or 0
|
||||
|
||||
# 当日新增学习时长
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||
.where(
|
||||
func.date(PracticeSession.created_at) == d,
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
hours = round((result.scalar() or 0) / 3600, 1)
|
||||
|
||||
# 当日考试次数
|
||||
result = await self.db.execute(
|
||||
select(func.count(Exam.id))
|
||||
.where(
|
||||
func.date(Exam.created_at) == d,
|
||||
Exam.status == 'submitted'
|
||||
)
|
||||
)
|
||||
exams = result.scalar() or 0
|
||||
|
||||
trend_data.append({
|
||||
"date": d.isoformat(),
|
||||
"active_users": active_users,
|
||||
"learning_hours": hours,
|
||||
"exam_count": exams,
|
||||
})
|
||||
|
||||
return {
|
||||
"dates": [d.isoformat() for d in dates],
|
||||
"trend": trend_data
|
||||
}
|
||||
|
||||
async def get_level_distribution(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取等级分布数据
|
||||
|
||||
Returns:
|
||||
等级分布
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(UserLevel.level, func.count(UserLevel.id))
|
||||
.group_by(UserLevel.level)
|
||||
.order_by(UserLevel.level)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
distribution = {row[0]: row[1] for row in rows}
|
||||
|
||||
# 补全1-10级
|
||||
for i in range(1, 11):
|
||||
if i not in distribution:
|
||||
distribution[i] = 0
|
||||
|
||||
return {
|
||||
"levels": list(range(1, 11)),
|
||||
"counts": [distribution.get(i, 0) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取实时动态
|
||||
|
||||
Args:
|
||||
limit: 数量限制
|
||||
|
||||
Returns:
|
||||
实时动态列表
|
||||
"""
|
||||
activities = []
|
||||
|
||||
# 获取最近的经验值记录
|
||||
result = await self.db.execute(
|
||||
select(ExpHistory, User)
|
||||
.join(User, ExpHistory.user_id == User.id)
|
||||
.order_by(ExpHistory.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
for exp, user in rows:
|
||||
activity_type = "学习"
|
||||
if "考试" in (exp.description or ""):
|
||||
activity_type = "考试"
|
||||
elif "签到" in (exp.description or ""):
|
||||
activity_type = "签到"
|
||||
elif "陪练" in (exp.description or ""):
|
||||
activity_type = "陪练"
|
||||
elif "奖章" in (exp.description or ""):
|
||||
activity_type = "奖章"
|
||||
|
||||
activities.append({
|
||||
"id": exp.id,
|
||||
"user_id": user.id,
|
||||
"user_name": user.full_name or user.username,
|
||||
"type": activity_type,
|
||||
"description": exp.description,
|
||||
"exp_amount": exp.exp_amount,
|
||||
"created_at": exp.created_at.isoformat() if exp.created_at else None,
|
||||
})
|
||||
|
||||
return activities
|
||||
|
||||
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
获取团队级数据大屏
|
||||
|
||||
Args:
|
||||
team_leader_id: 团队负责人ID
|
||||
|
||||
Returns:
|
||||
团队数据
|
||||
"""
|
||||
# 获取团队负责人管理的岗位
|
||||
result = await self.db.execute(
|
||||
select(Position)
|
||||
.where(
|
||||
Position.is_deleted == False,
|
||||
or_(
|
||||
Position.manager_id == team_leader_id,
|
||||
Position.created_by == team_leader_id
|
||||
)
|
||||
)
|
||||
)
|
||||
positions = result.scalars().all()
|
||||
position_ids = [p.id for p in positions]
|
||||
|
||||
if not position_ids:
|
||||
return {
|
||||
"members": [],
|
||||
"overview": {
|
||||
"total_members": 0,
|
||||
"avg_level": 0,
|
||||
"avg_exp": 0,
|
||||
"total_badges": 0,
|
||||
},
|
||||
"pending_tasks": []
|
||||
}
|
||||
|
||||
# 获取团队成员
|
||||
result = await self.db.execute(
|
||||
select(PositionMember.user_id)
|
||||
.where(PositionMember.position_id.in_(position_ids))
|
||||
)
|
||||
member_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not member_ids:
|
||||
return {
|
||||
"members": [],
|
||||
"overview": {
|
||||
"total_members": 0,
|
||||
"avg_level": 0,
|
||||
"avg_exp": 0,
|
||||
"total_badges": 0,
|
||||
},
|
||||
"pending_tasks": []
|
||||
}
|
||||
|
||||
# 获取成员详细信息
|
||||
result = await self.db.execute(
|
||||
select(User, UserLevel)
|
||||
.outerjoin(UserLevel, User.id == UserLevel.user_id)
|
||||
.where(User.id.in_(member_ids))
|
||||
.order_by(UserLevel.total_exp.desc().nullslast())
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
members = []
|
||||
total_exp = 0
|
||||
total_level = 0
|
||||
|
||||
for user, level in rows:
|
||||
user_level = level.level if level else 1
|
||||
user_exp = level.total_exp if level else 0
|
||||
total_level += user_level
|
||||
total_exp += user_exp
|
||||
|
||||
# 获取用户奖章数
|
||||
result = await self.db.execute(
|
||||
select(func.count(UserBadge.id))
|
||||
.where(UserBadge.user_id == user.id)
|
||||
)
|
||||
badge_count = result.scalar() or 0
|
||||
|
||||
members.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"full_name": user.full_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"level": user_level,
|
||||
"total_exp": user_exp,
|
||||
"badge_count": badge_count,
|
||||
})
|
||||
|
||||
total_members = len(members)
|
||||
|
||||
# 获取团队总奖章数
|
||||
result = await self.db.execute(
|
||||
select(func.count(UserBadge.id))
|
||||
.where(UserBadge.user_id.in_(member_ids))
|
||||
)
|
||||
total_badges = result.scalar() or 0
|
||||
|
||||
return {
|
||||
"members": members,
|
||||
"overview": {
|
||||
"total_members": total_members,
|
||||
"avg_level": round(total_level / total_members, 1) if total_members > 0 else 0,
|
||||
"avg_exp": round(total_exp / total_members) if total_members > 0 else 0,
|
||||
"total_badges": total_badges,
|
||||
},
|
||||
"positions": [{"id": p.id, "name": p.name} for p in positions]
|
||||
}
|
||||
|
||||
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取课程热度排行
|
||||
|
||||
Args:
|
||||
limit: 数量限制
|
||||
|
||||
Returns:
|
||||
课程排行列表
|
||||
"""
|
||||
# 这里简化实现,实际应该统计课程学习次数
|
||||
result = await self.db.execute(
|
||||
select(Course)
|
||||
.where(Course.is_deleted == False, Course.is_published == True)
|
||||
.order_by(Course.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
courses = result.scalars().all()
|
||||
|
||||
ranking = []
|
||||
for i, course in enumerate(courses, 1):
|
||||
ranking.append({
|
||||
"rank": i,
|
||||
"id": course.id,
|
||||
"name": course.name,
|
||||
"description": course.description,
|
||||
# 这里可以添加实际的学习人数统计
|
||||
"learners": 0,
|
||||
})
|
||||
|
||||
return ranking
|
||||
"""
|
||||
数据大屏服务
|
||||
|
||||
提供企业级和团队级数据大屏功能:
|
||||
- 学习数据概览
|
||||
- 部门/团队对比
|
||||
- 趋势分析
|
||||
- 实时动态
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta, date
|
||||
from typing import Optional, List, Dict, Any
|
||||
from sqlalchemy import select, func, and_, or_, desc, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.models.user import User
|
||||
from app.models.course import Course, CourseMaterial
|
||||
from app.models.exam import Exam
|
||||
from app.models.practice import PracticeSession
|
||||
from app.models.training import TrainingSession, TrainingReport
|
||||
from app.models.level import UserLevel, ExpHistory, UserBadge
|
||||
from app.models.position import Position
|
||||
from app.models.position_member import PositionMember
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
|
||||
class DashboardService:
|
||||
"""数据大屏服务"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
获取企业级数据概览
|
||||
|
||||
Args:
|
||||
enterprise_id: 企业ID(可选,用于多租户)
|
||||
|
||||
Returns:
|
||||
企业级数据概览
|
||||
"""
|
||||
today = date.today()
|
||||
week_ago = today - timedelta(days=7)
|
||||
month_ago = today - timedelta(days=30)
|
||||
|
||||
# 基础统计
|
||||
# 1. 总学员数
|
||||
result = await self.db.execute(
|
||||
select(func.count(User.id))
|
||||
.where(User.is_deleted == False, User.role == 'trainee')
|
||||
)
|
||||
total_users = result.scalar() or 0
|
||||
|
||||
# 2. 今日活跃用户(有经验值记录)
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(func.date(ExpHistory.created_at) == today)
|
||||
)
|
||||
today_active = result.scalar() or 0
|
||||
|
||||
# 3. 本周活跃用户
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time()))
|
||||
)
|
||||
week_active = result.scalar() or 0
|
||||
|
||||
# 4. 本月活跃用户
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time()))
|
||||
)
|
||||
month_active = result.scalar() or 0
|
||||
|
||||
# 5. 总学习时长(小时)
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||
.where(PracticeSession.status == 'completed')
|
||||
)
|
||||
practice_hours = (result.scalar() or 0) / 3600
|
||||
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0))
|
||||
.where(TrainingSession.status == 'COMPLETED')
|
||||
)
|
||||
training_hours = (result.scalar() or 0) / 3600
|
||||
|
||||
total_hours = round(practice_hours + training_hours, 1)
|
||||
|
||||
# 6. 考试统计
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
func.count(Exam.id),
|
||||
func.count(case((Exam.is_passed == True, 1))),
|
||||
func.avg(Exam.score)
|
||||
)
|
||||
.where(Exam.status == 'submitted')
|
||||
)
|
||||
exam_row = result.first()
|
||||
exam_count = exam_row[0] or 0
|
||||
exam_passed = exam_row[1] or 0
|
||||
exam_avg_score = round(exam_row[2] or 0, 1)
|
||||
exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0
|
||||
|
||||
# 7. 满分人数
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(Exam.user_id)))
|
||||
.where(Exam.status == 'submitted', Exam.score >= Exam.total_score)
|
||||
)
|
||||
perfect_users = result.scalar() or 0
|
||||
|
||||
# 8. 签到率(今日签到人数/总用户数)
|
||||
result = await self.db.execute(
|
||||
select(func.count(UserLevel.id))
|
||||
.where(func.date(UserLevel.last_login_date) == today)
|
||||
)
|
||||
today_checkin = result.scalar() or 0
|
||||
checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0
|
||||
|
||||
return {
|
||||
"overview": {
|
||||
"total_users": total_users,
|
||||
"today_active": today_active,
|
||||
"week_active": week_active,
|
||||
"month_active": month_active,
|
||||
"total_hours": total_hours,
|
||||
"checkin_rate": checkin_rate,
|
||||
},
|
||||
"exam": {
|
||||
"total_count": exam_count,
|
||||
"pass_rate": exam_pass_rate,
|
||||
"avg_score": exam_avg_score,
|
||||
"perfect_users": perfect_users,
|
||||
},
|
||||
"updated_at": datetime.now().isoformat()
|
||||
}
|
||||
|
||||
async def get_department_comparison(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取部门/团队学习对比数据
|
||||
|
||||
Returns:
|
||||
部门对比列表
|
||||
"""
|
||||
# 获取所有岗位及其成员的学习数据
|
||||
result = await self.db.execute(
|
||||
select(Position)
|
||||
.where(Position.is_deleted == False)
|
||||
.order_by(Position.name)
|
||||
)
|
||||
positions = result.scalars().all()
|
||||
|
||||
departments = []
|
||||
for pos in positions:
|
||||
# 获取该岗位的成员数
|
||||
result = await self.db.execute(
|
||||
select(func.count(PositionMember.id))
|
||||
.where(PositionMember.position_id == pos.id)
|
||||
)
|
||||
member_count = result.scalar() or 0
|
||||
|
||||
if member_count == 0:
|
||||
continue
|
||||
|
||||
# 获取成员ID列表
|
||||
result = await self.db.execute(
|
||||
select(PositionMember.user_id)
|
||||
.where(PositionMember.position_id == pos.id)
|
||||
)
|
||||
member_ids = [row[0] for row in result.all()]
|
||||
|
||||
# 统计该岗位成员的学习数据
|
||||
# 考试通过率
|
||||
result = await self.db.execute(
|
||||
select(
|
||||
func.count(Exam.id),
|
||||
func.count(case((Exam.is_passed == True, 1)))
|
||||
)
|
||||
.where(
|
||||
Exam.user_id.in_(member_ids),
|
||||
Exam.status == 'submitted'
|
||||
)
|
||||
)
|
||||
exam_row = result.first()
|
||||
exam_total = exam_row[0] or 0
|
||||
exam_passed = exam_row[1] or 0
|
||||
pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0
|
||||
|
||||
# 平均学习时长
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||
.where(
|
||||
PracticeSession.user_id.in_(member_ids),
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
total_seconds = result.scalar() or 0
|
||||
avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0
|
||||
|
||||
# 平均等级
|
||||
result = await self.db.execute(
|
||||
select(func.avg(UserLevel.level))
|
||||
.where(UserLevel.user_id.in_(member_ids))
|
||||
)
|
||||
avg_level = round(result.scalar() or 1, 1)
|
||||
|
||||
departments.append({
|
||||
"id": pos.id,
|
||||
"name": pos.name,
|
||||
"member_count": member_count,
|
||||
"pass_rate": pass_rate,
|
||||
"avg_hours": avg_hours,
|
||||
"avg_level": avg_level,
|
||||
})
|
||||
|
||||
# 按通过率排序
|
||||
departments.sort(key=lambda x: x["pass_rate"], reverse=True)
|
||||
|
||||
return departments
|
||||
|
||||
async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]:
|
||||
"""
|
||||
获取学习趋势数据
|
||||
|
||||
Args:
|
||||
days: 统计天数
|
||||
|
||||
Returns:
|
||||
趋势数据
|
||||
"""
|
||||
today = date.today()
|
||||
dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)]
|
||||
|
||||
trend_data = []
|
||||
for d in dates:
|
||||
# 当日活跃用户
|
||||
result = await self.db.execute(
|
||||
select(func.count(func.distinct(ExpHistory.user_id)))
|
||||
.where(func.date(ExpHistory.created_at) == d)
|
||||
)
|
||||
active_users = result.scalar() or 0
|
||||
|
||||
# 当日新增学习时长
|
||||
result = await self.db.execute(
|
||||
select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0))
|
||||
.where(
|
||||
func.date(PracticeSession.created_at) == d,
|
||||
PracticeSession.status == 'completed'
|
||||
)
|
||||
)
|
||||
hours = round((result.scalar() or 0) / 3600, 1)
|
||||
|
||||
# 当日考试次数
|
||||
result = await self.db.execute(
|
||||
select(func.count(Exam.id))
|
||||
.where(
|
||||
func.date(Exam.created_at) == d,
|
||||
Exam.status == 'submitted'
|
||||
)
|
||||
)
|
||||
exams = result.scalar() or 0
|
||||
|
||||
trend_data.append({
|
||||
"date": d.isoformat(),
|
||||
"active_users": active_users,
|
||||
"learning_hours": hours,
|
||||
"exam_count": exams,
|
||||
})
|
||||
|
||||
return {
|
||||
"dates": [d.isoformat() for d in dates],
|
||||
"trend": trend_data
|
||||
}
|
||||
|
||||
async def get_level_distribution(self) -> Dict[str, Any]:
|
||||
"""
|
||||
获取等级分布数据
|
||||
|
||||
Returns:
|
||||
等级分布
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(UserLevel.level, func.count(UserLevel.id))
|
||||
.group_by(UserLevel.level)
|
||||
.order_by(UserLevel.level)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
distribution = {row[0]: row[1] for row in rows}
|
||||
|
||||
# 补全1-10级
|
||||
for i in range(1, 11):
|
||||
if i not in distribution:
|
||||
distribution[i] = 0
|
||||
|
||||
return {
|
||||
"levels": list(range(1, 11)),
|
||||
"counts": [distribution.get(i, 0) for i in range(1, 11)]
|
||||
}
|
||||
|
||||
async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取实时动态
|
||||
|
||||
Args:
|
||||
limit: 数量限制
|
||||
|
||||
Returns:
|
||||
实时动态列表
|
||||
"""
|
||||
activities = []
|
||||
|
||||
# 获取最近的经验值记录
|
||||
result = await self.db.execute(
|
||||
select(ExpHistory, User)
|
||||
.join(User, ExpHistory.user_id == User.id)
|
||||
.order_by(ExpHistory.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
for exp, user in rows:
|
||||
activity_type = "学习"
|
||||
if "考试" in (exp.description or ""):
|
||||
activity_type = "考试"
|
||||
elif "签到" in (exp.description or ""):
|
||||
activity_type = "签到"
|
||||
elif "陪练" in (exp.description or ""):
|
||||
activity_type = "陪练"
|
||||
elif "奖章" in (exp.description or ""):
|
||||
activity_type = "奖章"
|
||||
|
||||
activities.append({
|
||||
"id": exp.id,
|
||||
"user_id": user.id,
|
||||
"user_name": user.full_name or user.username,
|
||||
"type": activity_type,
|
||||
"description": exp.description,
|
||||
"exp_amount": exp.exp_amount,
|
||||
"created_at": exp.created_at.isoformat() if exp.created_at else None,
|
||||
})
|
||||
|
||||
return activities
|
||||
|
||||
async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
获取团队级数据大屏
|
||||
|
||||
Args:
|
||||
team_leader_id: 团队负责人ID
|
||||
|
||||
Returns:
|
||||
团队数据
|
||||
"""
|
||||
# 获取团队负责人管理的岗位
|
||||
result = await self.db.execute(
|
||||
select(Position)
|
||||
.where(
|
||||
Position.is_deleted == False,
|
||||
or_(
|
||||
Position.manager_id == team_leader_id,
|
||||
Position.created_by == team_leader_id
|
||||
)
|
||||
)
|
||||
)
|
||||
positions = result.scalars().all()
|
||||
position_ids = [p.id for p in positions]
|
||||
|
||||
if not position_ids:
|
||||
return {
|
||||
"members": [],
|
||||
"overview": {
|
||||
"total_members": 0,
|
||||
"avg_level": 0,
|
||||
"avg_exp": 0,
|
||||
"total_badges": 0,
|
||||
},
|
||||
"pending_tasks": []
|
||||
}
|
||||
|
||||
# 获取团队成员
|
||||
result = await self.db.execute(
|
||||
select(PositionMember.user_id)
|
||||
.where(PositionMember.position_id.in_(position_ids))
|
||||
)
|
||||
member_ids = [row[0] for row in result.all()]
|
||||
|
||||
if not member_ids:
|
||||
return {
|
||||
"members": [],
|
||||
"overview": {
|
||||
"total_members": 0,
|
||||
"avg_level": 0,
|
||||
"avg_exp": 0,
|
||||
"total_badges": 0,
|
||||
},
|
||||
"pending_tasks": []
|
||||
}
|
||||
|
||||
# 获取成员详细信息
|
||||
result = await self.db.execute(
|
||||
select(User, UserLevel)
|
||||
.outerjoin(UserLevel, User.id == UserLevel.user_id)
|
||||
.where(User.id.in_(member_ids))
|
||||
.order_by(UserLevel.total_exp.desc().nullslast())
|
||||
)
|
||||
rows = result.all()
|
||||
|
||||
members = []
|
||||
total_exp = 0
|
||||
total_level = 0
|
||||
|
||||
for user, level in rows:
|
||||
user_level = level.level if level else 1
|
||||
user_exp = level.total_exp if level else 0
|
||||
total_level += user_level
|
||||
total_exp += user_exp
|
||||
|
||||
# 获取用户奖章数
|
||||
result = await self.db.execute(
|
||||
select(func.count(UserBadge.id))
|
||||
.where(UserBadge.user_id == user.id)
|
||||
)
|
||||
badge_count = result.scalar() or 0
|
||||
|
||||
members.append({
|
||||
"id": user.id,
|
||||
"username": user.username,
|
||||
"full_name": user.full_name,
|
||||
"avatar_url": user.avatar_url,
|
||||
"level": user_level,
|
||||
"total_exp": user_exp,
|
||||
"badge_count": badge_count,
|
||||
})
|
||||
|
||||
total_members = len(members)
|
||||
|
||||
# 获取团队总奖章数
|
||||
result = await self.db.execute(
|
||||
select(func.count(UserBadge.id))
|
||||
.where(UserBadge.user_id.in_(member_ids))
|
||||
)
|
||||
total_badges = result.scalar() or 0
|
||||
|
||||
return {
|
||||
"members": members,
|
||||
"overview": {
|
||||
"total_members": total_members,
|
||||
"avg_level": round(total_level / total_members, 1) if total_members > 0 else 0,
|
||||
"avg_exp": round(total_exp / total_members) if total_members > 0 else 0,
|
||||
"total_badges": total_badges,
|
||||
},
|
||||
"positions": [{"id": p.id, "name": p.name} for p in positions]
|
||||
}
|
||||
|
||||
async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取课程热度排行
|
||||
|
||||
Args:
|
||||
limit: 数量限制
|
||||
|
||||
Returns:
|
||||
课程排行列表
|
||||
"""
|
||||
# 这里简化实现,实际应该统计课程学习次数
|
||||
result = await self.db.execute(
|
||||
select(Course)
|
||||
.where(Course.is_deleted == False, Course.is_published == True)
|
||||
.order_by(Course.created_at.desc())
|
||||
.limit(limit)
|
||||
)
|
||||
courses = result.scalars().all()
|
||||
|
||||
ranking = []
|
||||
for i, course in enumerate(courses, 1):
|
||||
ranking.append({
|
||||
"rank": i,
|
||||
"id": course.id,
|
||||
"name": course.name,
|
||||
"description": course.description,
|
||||
# 这里可以添加实际的学习人数统计
|
||||
"learners": 0,
|
||||
})
|
||||
|
||||
return ranking
|
||||
|
||||
@@ -1,302 +1,302 @@
|
||||
"""
|
||||
钉钉认证服务
|
||||
|
||||
提供钉钉免密登录功能,从数据库读取配置
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import Token
|
||||
from app.services.user_service import UserService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 钉钉API地址
|
||||
DINGTALK_API_BASE = "https://oapi.dingtalk.com"
|
||||
|
||||
|
||||
class DingtalkAuthService:
|
||||
"""钉钉认证服务"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.user_service = UserService(db)
|
||||
self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time)
|
||||
|
||||
async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]:
|
||||
"""
|
||||
从数据库获取钉钉配置
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
配置字典 {app_key, app_secret, agent_id, corp_id}
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value
|
||||
FROM tenant_configs
|
||||
WHERE tenant_id = :tenant_id AND config_group = 'dingtalk'
|
||||
"""),
|
||||
{"tenant_id": tenant_id}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
config = {}
|
||||
key_mapping = {
|
||||
"DINGTALK_APP_KEY": "app_key",
|
||||
"DINGTALK_APP_SECRET": "app_secret",
|
||||
"DINGTALK_AGENT_ID": "agent_id",
|
||||
"DINGTALK_CORP_ID": "corp_id",
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
if row[0] in key_mapping:
|
||||
config[key_mapping[row[0]]] = row[1]
|
||||
|
||||
return config
|
||||
|
||||
async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool:
|
||||
"""
|
||||
检查钉钉免密登录功能是否启用
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
是否启用
|
||||
"""
|
||||
# 先查租户级别的配置
|
||||
result = await self.db.execute(
|
||||
text("""
|
||||
SELECT is_enabled FROM feature_switches
|
||||
WHERE feature_code = 'dingtalk_login'
|
||||
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
|
||||
ORDER BY tenant_id DESC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"tenant_id": tenant_id}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row:
|
||||
return bool(row[0])
|
||||
|
||||
return False
|
||||
|
||||
async def get_access_token(self, tenant_id: int) -> str:
|
||||
"""
|
||||
获取钉钉访问令牌(带内存缓存)
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
access_token
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败时抛出异常
|
||||
"""
|
||||
# 检查缓存
|
||||
if tenant_id in self._access_token_cache:
|
||||
token, expire_time = self._access_token_cache[tenant_id]
|
||||
if time.time() < expire_time - 300: # 提前5分钟刷新
|
||||
return token
|
||||
|
||||
# 获取配置
|
||||
config = await self.get_dingtalk_config(tenant_id)
|
||||
if not config.get("app_key") or not config.get("app_secret"):
|
||||
raise ValueError("钉钉配置不完整,请在管理后台配置AppKey和AppSecret")
|
||||
|
||||
# 调用钉钉API获取token
|
||||
url = f"{DINGTALK_API_BASE}/gettoken"
|
||||
params = {
|
||||
"appkey": config["app_key"],
|
||||
"appsecret": config["app_secret"],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
error_msg = data.get("errmsg", "未知错误")
|
||||
logger.error(f"获取钉钉access_token失败: {error_msg}")
|
||||
raise Exception(f"获取钉钉access_token失败: {error_msg}")
|
||||
|
||||
access_token = data["access_token"]
|
||||
expires_in = data.get("expires_in", 7200)
|
||||
|
||||
# 缓存token
|
||||
self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in)
|
||||
|
||||
logger.info(f"获取钉钉access_token成功,有效期: {expires_in}秒")
|
||||
return access_token
|
||||
|
||||
async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
通过免登码获取钉钉用户信息
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
code: 免登授权码
|
||||
|
||||
Returns:
|
||||
用户信息 {userid, name, ...}
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败时抛出异常
|
||||
"""
|
||||
access_token = await self.get_access_token(tenant_id)
|
||||
|
||||
url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo"
|
||||
params = {"access_token": access_token}
|
||||
payload = {"code": code}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, params=params, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
error_msg = data.get("errmsg", "未知错误")
|
||||
logger.error(f"通过code获取钉钉用户信息失败: {error_msg}")
|
||||
raise Exception(f"获取钉钉用户信息失败: {error_msg}")
|
||||
|
||||
result = data.get("result", {})
|
||||
logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}")
|
||||
|
||||
return result
|
||||
|
||||
async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取钉钉用户详细信息
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
userid: 钉钉用户ID
|
||||
|
||||
Returns:
|
||||
用户详细信息
|
||||
"""
|
||||
access_token = await self.get_access_token(tenant_id)
|
||||
|
||||
url = f"{DINGTALK_API_BASE}/topapi/v2/user/get"
|
||||
params = {"access_token": access_token}
|
||||
payload = {"userid": userid}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, params=params, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
error_msg = data.get("errmsg", "未知错误")
|
||||
logger.warning(f"获取钉钉用户详情失败: {error_msg}")
|
||||
return {}
|
||||
|
||||
return data.get("result", {})
|
||||
|
||||
async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]:
|
||||
"""
|
||||
钉钉免密登录主流程
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
code: 免登授权码
|
||||
|
||||
Returns:
|
||||
(用户对象, Token对象)
|
||||
|
||||
Raises:
|
||||
Exception: 登录失败时抛出异常
|
||||
"""
|
||||
# 1. 检查功能是否启用
|
||||
if not await self.is_dingtalk_login_enabled(tenant_id):
|
||||
raise Exception("钉钉免密登录功能未启用")
|
||||
|
||||
# 2. 通过code获取钉钉用户信息
|
||||
dingtalk_user = await self.get_user_info_by_code(tenant_id, code)
|
||||
dingtalk_userid = dingtalk_user.get("userid")
|
||||
|
||||
if not dingtalk_userid:
|
||||
raise Exception("无法获取钉钉用户ID")
|
||||
|
||||
# 3. 根据dingtalk_id查找系统用户
|
||||
logger.info(f"开始查找用户,钉钉userid: {dingtalk_userid}")
|
||||
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
|
||||
|
||||
if not user:
|
||||
logger.info(f"通过dingtalk_id未找到用户,尝试手机号匹配")
|
||||
# 尝试通过手机号匹配
|
||||
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
|
||||
mobile = user_detail.get("mobile")
|
||||
logger.info(f"获取到钉钉用户手机号: {mobile}")
|
||||
|
||||
if mobile:
|
||||
user = await self.user_service.get_by_phone(mobile)
|
||||
if user:
|
||||
# 绑定dingtalk_id
|
||||
user.dingtalk_id = dingtalk_userid
|
||||
await self.db.commit()
|
||||
logger.info(f"通过手机号匹配成功,已绑定dingtalk_id: {dingtalk_userid}")
|
||||
else:
|
||||
logger.warning(f"通过手机号 {mobile} 也未找到用户")
|
||||
else:
|
||||
logger.warning("无法获取钉钉用户手机号")
|
||||
|
||||
if not user:
|
||||
logger.error(f"钉钉登录失败:dingtalk_userid={dingtalk_userid}, 未找到对应用户")
|
||||
raise Exception("未找到对应的系统用户,请联系管理员")
|
||||
|
||||
if not user.is_active:
|
||||
raise Exception("用户已被禁用")
|
||||
|
||||
# 4. 生成JWT Token
|
||||
access_token = create_access_token(subject=user.id)
|
||||
refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
# 5. 更新最后登录时间
|
||||
await self.user_service.update_last_login(user.id)
|
||||
|
||||
logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}")
|
||||
|
||||
return user, Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
async def get_public_config(self, tenant_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
获取钉钉公开配置(前端需要用于初始化JSDK)
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
{corp_id, agent_id, enabled}
|
||||
"""
|
||||
enabled = await self.is_dingtalk_login_enabled(tenant_id)
|
||||
|
||||
if not enabled:
|
||||
return {
|
||||
"enabled": False,
|
||||
"corp_id": None,
|
||||
"agent_id": None,
|
||||
}
|
||||
|
||||
config = await self.get_dingtalk_config(tenant_id)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"corp_id": config.get("corp_id"),
|
||||
"agent_id": config.get("agent_id"),
|
||||
}
|
||||
"""
|
||||
钉钉认证服务
|
||||
|
||||
提供钉钉免密登录功能,从数据库读取配置
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from typing import Optional, Dict, Any, Tuple
|
||||
|
||||
import httpx
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.core.security import create_access_token, create_refresh_token
|
||||
from app.models.user import User
|
||||
from app.schemas.auth import Token
|
||||
from app.services.user_service import UserService
|
||||
|
||||
logger = get_logger(__name__)
|
||||
|
||||
# 钉钉API地址
|
||||
DINGTALK_API_BASE = "https://oapi.dingtalk.com"
|
||||
|
||||
|
||||
class DingtalkAuthService:
|
||||
"""钉钉认证服务"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.user_service = UserService(db)
|
||||
self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time)
|
||||
|
||||
async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]:
|
||||
"""
|
||||
从数据库获取钉钉配置
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
配置字典 {app_key, app_secret, agent_id, corp_id}
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
text("""
|
||||
SELECT config_key, config_value
|
||||
FROM tenant_configs
|
||||
WHERE tenant_id = :tenant_id AND config_group = 'dingtalk'
|
||||
"""),
|
||||
{"tenant_id": tenant_id}
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
config = {}
|
||||
key_mapping = {
|
||||
"DINGTALK_APP_KEY": "app_key",
|
||||
"DINGTALK_APP_SECRET": "app_secret",
|
||||
"DINGTALK_AGENT_ID": "agent_id",
|
||||
"DINGTALK_CORP_ID": "corp_id",
|
||||
}
|
||||
|
||||
for row in rows:
|
||||
if row[0] in key_mapping:
|
||||
config[key_mapping[row[0]]] = row[1]
|
||||
|
||||
return config
|
||||
|
||||
async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool:
|
||||
"""
|
||||
检查钉钉免密登录功能是否启用
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
是否启用
|
||||
"""
|
||||
# 先查租户级别的配置
|
||||
result = await self.db.execute(
|
||||
text("""
|
||||
SELECT is_enabled FROM feature_switches
|
||||
WHERE feature_code = 'dingtalk_login'
|
||||
AND (tenant_id = :tenant_id OR tenant_id IS NULL)
|
||||
ORDER BY tenant_id DESC
|
||||
LIMIT 1
|
||||
"""),
|
||||
{"tenant_id": tenant_id}
|
||||
)
|
||||
row = result.fetchone()
|
||||
|
||||
if row:
|
||||
return bool(row[0])
|
||||
|
||||
return False
|
||||
|
||||
async def get_access_token(self, tenant_id: int) -> str:
|
||||
"""
|
||||
获取钉钉访问令牌(带内存缓存)
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
access_token
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败时抛出异常
|
||||
"""
|
||||
# 检查缓存
|
||||
if tenant_id in self._access_token_cache:
|
||||
token, expire_time = self._access_token_cache[tenant_id]
|
||||
if time.time() < expire_time - 300: # 提前5分钟刷新
|
||||
return token
|
||||
|
||||
# 获取配置
|
||||
config = await self.get_dingtalk_config(tenant_id)
|
||||
if not config.get("app_key") or not config.get("app_secret"):
|
||||
raise ValueError("钉钉配置不完整,请在管理后台配置AppKey和AppSecret")
|
||||
|
||||
# 调用钉钉API获取token
|
||||
url = f"{DINGTALK_API_BASE}/gettoken"
|
||||
params = {
|
||||
"appkey": config["app_key"],
|
||||
"appsecret": config["app_secret"],
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.get(url, params=params)
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
error_msg = data.get("errmsg", "未知错误")
|
||||
logger.error(f"获取钉钉access_token失败: {error_msg}")
|
||||
raise Exception(f"获取钉钉access_token失败: {error_msg}")
|
||||
|
||||
access_token = data["access_token"]
|
||||
expires_in = data.get("expires_in", 7200)
|
||||
|
||||
# 缓存token
|
||||
self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in)
|
||||
|
||||
logger.info(f"获取钉钉access_token成功,有效期: {expires_in}秒")
|
||||
return access_token
|
||||
|
||||
async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]:
|
||||
"""
|
||||
通过免登码获取钉钉用户信息
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
code: 免登授权码
|
||||
|
||||
Returns:
|
||||
用户信息 {userid, name, ...}
|
||||
|
||||
Raises:
|
||||
Exception: 获取失败时抛出异常
|
||||
"""
|
||||
access_token = await self.get_access_token(tenant_id)
|
||||
|
||||
url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo"
|
||||
params = {"access_token": access_token}
|
||||
payload = {"code": code}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, params=params, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
error_msg = data.get("errmsg", "未知错误")
|
||||
logger.error(f"通过code获取钉钉用户信息失败: {error_msg}")
|
||||
raise Exception(f"获取钉钉用户信息失败: {error_msg}")
|
||||
|
||||
result = data.get("result", {})
|
||||
logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}")
|
||||
|
||||
return result
|
||||
|
||||
async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]:
|
||||
"""
|
||||
获取钉钉用户详细信息
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
userid: 钉钉用户ID
|
||||
|
||||
Returns:
|
||||
用户详细信息
|
||||
"""
|
||||
access_token = await self.get_access_token(tenant_id)
|
||||
|
||||
url = f"{DINGTALK_API_BASE}/topapi/v2/user/get"
|
||||
params = {"access_token": access_token}
|
||||
payload = {"userid": userid}
|
||||
|
||||
async with httpx.AsyncClient(timeout=30.0) as client:
|
||||
response = await client.post(url, params=params, json=payload)
|
||||
data = response.json()
|
||||
|
||||
if data.get("errcode") != 0:
|
||||
error_msg = data.get("errmsg", "未知错误")
|
||||
logger.warning(f"获取钉钉用户详情失败: {error_msg}")
|
||||
return {}
|
||||
|
||||
return data.get("result", {})
|
||||
|
||||
async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]:
|
||||
"""
|
||||
钉钉免密登录主流程
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
code: 免登授权码
|
||||
|
||||
Returns:
|
||||
(用户对象, Token对象)
|
||||
|
||||
Raises:
|
||||
Exception: 登录失败时抛出异常
|
||||
"""
|
||||
# 1. 检查功能是否启用
|
||||
if not await self.is_dingtalk_login_enabled(tenant_id):
|
||||
raise Exception("钉钉免密登录功能未启用")
|
||||
|
||||
# 2. 通过code获取钉钉用户信息
|
||||
dingtalk_user = await self.get_user_info_by_code(tenant_id, code)
|
||||
dingtalk_userid = dingtalk_user.get("userid")
|
||||
|
||||
if not dingtalk_userid:
|
||||
raise Exception("无法获取钉钉用户ID")
|
||||
|
||||
# 3. 根据dingtalk_id查找系统用户
|
||||
logger.info(f"开始查找用户,钉钉userid: {dingtalk_userid}")
|
||||
user = await self.user_service.get_by_dingtalk_id(dingtalk_userid)
|
||||
|
||||
if not user:
|
||||
logger.info(f"通过dingtalk_id未找到用户,尝试手机号匹配")
|
||||
# 尝试通过手机号匹配
|
||||
user_detail = await self.get_user_detail(tenant_id, dingtalk_userid)
|
||||
mobile = user_detail.get("mobile")
|
||||
logger.info(f"获取到钉钉用户手机号: {mobile}")
|
||||
|
||||
if mobile:
|
||||
user = await self.user_service.get_by_phone(mobile)
|
||||
if user:
|
||||
# 绑定dingtalk_id
|
||||
user.dingtalk_id = dingtalk_userid
|
||||
await self.db.commit()
|
||||
logger.info(f"通过手机号匹配成功,已绑定dingtalk_id: {dingtalk_userid}")
|
||||
else:
|
||||
logger.warning(f"通过手机号 {mobile} 也未找到用户")
|
||||
else:
|
||||
logger.warning("无法获取钉钉用户手机号")
|
||||
|
||||
if not user:
|
||||
logger.error(f"钉钉登录失败:dingtalk_userid={dingtalk_userid}, 未找到对应用户")
|
||||
raise Exception("未找到对应的系统用户,请联系管理员")
|
||||
|
||||
if not user.is_active:
|
||||
raise Exception("用户已被禁用")
|
||||
|
||||
# 4. 生成JWT Token
|
||||
access_token = create_access_token(subject=user.id)
|
||||
refresh_token = create_refresh_token(subject=user.id)
|
||||
|
||||
# 5. 更新最后登录时间
|
||||
await self.user_service.update_last_login(user.id)
|
||||
|
||||
logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}")
|
||||
|
||||
return user, Token(
|
||||
access_token=access_token,
|
||||
refresh_token=refresh_token,
|
||||
)
|
||||
|
||||
async def get_public_config(self, tenant_id: int) -> Dict[str, Any]:
|
||||
"""
|
||||
获取钉钉公开配置(前端需要用于初始化JSDK)
|
||||
|
||||
Args:
|
||||
tenant_id: 租户ID
|
||||
|
||||
Returns:
|
||||
{corp_id, agent_id, enabled}
|
||||
"""
|
||||
enabled = await self.is_dingtalk_login_enabled(tenant_id)
|
||||
|
||||
if not enabled:
|
||||
return {
|
||||
"enabled": False,
|
||||
"corp_id": None,
|
||||
"agent_id": None,
|
||||
}
|
||||
|
||||
config = await self.get_dingtalk_config(tenant_id)
|
||||
|
||||
return {
|
||||
"enabled": True,
|
||||
"corp_id": config.get("corp_id"),
|
||||
"agent_id": config.get("agent_id"),
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,330 +1,419 @@
|
||||
"""
|
||||
站内消息通知服务
|
||||
提供通知的CRUD操作和业务逻辑
|
||||
通知推送服务
|
||||
支持钉钉、企业微信、站内消息等多种渠道
|
||||
"""
|
||||
from typing import List, Optional, Tuple
|
||||
from sqlalchemy import select, and_, desc, func, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.models.notification import Notification
|
||||
from app.models.user import User
|
||||
from app.schemas.notification import (
|
||||
NotificationCreate,
|
||||
NotificationBatchCreate,
|
||||
NotificationResponse,
|
||||
NotificationType,
|
||||
)
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.notification import Notification
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService(BaseService[Notification]):
|
||||
"""
|
||||
站内消息通知服务
|
||||
class NotificationChannel:
|
||||
"""通知渠道基类"""
|
||||
|
||||
提供通知的创建、查询、标记已读等功能
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Notification)
|
||||
|
||||
async def create_notification(
|
||||
async def send(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
notification_in: NotificationCreate
|
||||
) -> Notification:
|
||||
"""
|
||||
创建单个通知
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
notification_in: 通知创建数据
|
||||
|
||||
Returns:
|
||||
创建的通知对象
|
||||
"""
|
||||
notification = Notification(
|
||||
user_id=notification_in.user_id,
|
||||
title=notification_in.title,
|
||||
content=notification_in.content,
|
||||
type=notification_in.type.value if isinstance(notification_in.type, NotificationType) else notification_in.type,
|
||||
related_id=notification_in.related_id,
|
||||
related_type=notification_in.related_type,
|
||||
sender_id=notification_in.sender_id,
|
||||
is_read=False
|
||||
)
|
||||
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
await db.refresh(notification)
|
||||
|
||||
logger.info(
|
||||
"创建通知成功",
|
||||
notification_id=notification.id,
|
||||
user_id=notification_in.user_id,
|
||||
type=notification_in.type
|
||||
)
|
||||
|
||||
return notification
|
||||
|
||||
async def batch_create_notifications(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
batch_in: NotificationBatchCreate
|
||||
) -> List[Notification]:
|
||||
"""
|
||||
批量创建通知(发送给多个用户)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
batch_in: 批量通知创建数据
|
||||
|
||||
Returns:
|
||||
创建的通知列表
|
||||
"""
|
||||
notifications = []
|
||||
notification_type = batch_in.type.value if isinstance(batch_in.type, NotificationType) else batch_in.type
|
||||
|
||||
for user_id in batch_in.user_ids:
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
title=batch_in.title,
|
||||
content=batch_in.content,
|
||||
type=notification_type,
|
||||
related_id=batch_in.related_id,
|
||||
related_type=batch_in.related_type,
|
||||
sender_id=batch_in.sender_id,
|
||||
is_read=False
|
||||
)
|
||||
notifications.append(notification)
|
||||
db.add(notification)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 刷新所有对象
|
||||
for notification in notifications:
|
||||
await db.refresh(notification)
|
||||
|
||||
logger.info(
|
||||
"批量创建通知成功",
|
||||
count=len(notifications),
|
||||
user_ids=batch_in.user_ids,
|
||||
type=batch_in.type
|
||||
)
|
||||
|
||||
return notifications
|
||||
|
||||
async def get_user_notifications(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
is_read: Optional[bool] = None,
|
||||
notification_type: Optional[str] = None
|
||||
) -> Tuple[List[NotificationResponse], int, int]:
|
||||
"""
|
||||
获取用户的通知列表
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
skip: 跳过数量
|
||||
limit: 返回数量
|
||||
is_read: 是否已读筛选
|
||||
notification_type: 通知类型筛选
|
||||
|
||||
Returns:
|
||||
(通知列表, 总数, 未读数)
|
||||
"""
|
||||
# 构建基础查询条件
|
||||
conditions = [Notification.user_id == user_id]
|
||||
|
||||
if is_read is not None:
|
||||
conditions.append(Notification.is_read == is_read)
|
||||
|
||||
if notification_type:
|
||||
conditions.append(Notification.type == notification_type)
|
||||
|
||||
# 查询通知列表(带发送者信息)
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Notification.created_at))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
notifications = result.scalars().all()
|
||||
|
||||
# 统计总数
|
||||
count_stmt = select(func.count()).select_from(Notification).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# 统计未读数
|
||||
unread_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
|
||||
)
|
||||
unread_result = await db.execute(unread_stmt)
|
||||
unread_count = unread_result.scalar_one()
|
||||
|
||||
# 获取发送者信息
|
||||
sender_ids = [n.sender_id for n in notifications if n.sender_id]
|
||||
sender_names = {}
|
||||
if sender_ids:
|
||||
sender_stmt = select(User.id, User.full_name).where(User.id.in_(sender_ids))
|
||||
sender_result = await db.execute(sender_stmt)
|
||||
sender_names = {row[0]: row[1] for row in sender_result.fetchall()}
|
||||
|
||||
# 构建响应
|
||||
responses = []
|
||||
for notification in notifications:
|
||||
response = NotificationResponse(
|
||||
id=notification.id,
|
||||
user_id=notification.user_id,
|
||||
title=notification.title,
|
||||
content=notification.content,
|
||||
type=notification.type,
|
||||
is_read=notification.is_read,
|
||||
related_id=notification.related_id,
|
||||
related_type=notification.related_type,
|
||||
sender_id=notification.sender_id,
|
||||
sender_name=sender_names.get(notification.sender_id) if notification.sender_id else None,
|
||||
created_at=notification.created_at,
|
||||
updated_at=notification.updated_at
|
||||
)
|
||||
responses.append(response)
|
||||
|
||||
return responses, total, unread_count
|
||||
|
||||
async def get_unread_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
获取用户未读通知数量
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
(未读数, 总数)
|
||||
"""
|
||||
# 统计未读数
|
||||
unread_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
|
||||
)
|
||||
unread_result = await db.execute(unread_stmt)
|
||||
unread_count = unread_result.scalar_one()
|
||||
|
||||
# 统计总数
|
||||
total_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
)
|
||||
total_result = await db.execute(total_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
return unread_count, total
|
||||
|
||||
async def mark_as_read(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
notification_ids: Optional[List[int]] = None
|
||||
) -> int:
|
||||
"""
|
||||
标记通知为已读
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
notification_ids: 通知ID列表,为空则标记全部
|
||||
|
||||
Returns:
|
||||
更新的数量
|
||||
"""
|
||||
conditions = [
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False
|
||||
]
|
||||
|
||||
if notification_ids:
|
||||
conditions.append(Notification.id.in_(notification_ids))
|
||||
|
||||
stmt = (
|
||||
update(Notification)
|
||||
.where(and_(*conditions))
|
||||
.values(is_read=True)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
updated_count = result.rowcount
|
||||
|
||||
logger.info(
|
||||
"标记通知已读",
|
||||
user_id=user_id,
|
||||
notification_ids=notification_ids,
|
||||
updated_count=updated_count
|
||||
)
|
||||
|
||||
return updated_count
|
||||
|
||||
async def delete_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
notification_id: int
|
||||
title: str,
|
||||
content: str,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
删除通知
|
||||
发送通知
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
notification_id: 通知ID
|
||||
title: 通知标题
|
||||
content: 通知内容
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
是否发送成功
|
||||
"""
|
||||
stmt = select(Notification).where(
|
||||
and_(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user_id
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DingtalkChannel(NotificationChannel):
|
||||
"""
|
||||
钉钉通知渠道
|
||||
|
||||
使用钉钉工作通知 API 发送消息
|
||||
文档: https://open.dingtalk.com/document/orgapp/asynchronous-sending-of-enterprise-session-messages
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_key: Optional[str] = None,
|
||||
app_secret: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
):
|
||||
self.app_key = app_key or os.getenv("DINGTALK_APP_KEY")
|
||||
self.app_secret = app_secret or os.getenv("DINGTALK_APP_SECRET")
|
||||
self.agent_id = agent_id or os.getenv("DINGTALK_AGENT_ID")
|
||||
self._access_token = None
|
||||
self._token_expires_at = None
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""获取钉钉访问令牌"""
|
||||
if (
|
||||
self._access_token
|
||||
and self._token_expires_at
|
||||
and datetime.now() < self._token_expires_at
|
||||
):
|
||||
return self._access_token
|
||||
|
||||
url = "https://oapi.dingtalk.com/gettoken"
|
||||
params = {
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params=params, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
self._access_token = result["access_token"]
|
||||
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
|
||||
return self._access_token
|
||||
else:
|
||||
raise Exception(f"获取钉钉Token失败: {result.get('errmsg')}")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
dingtalk_user_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""发送钉钉工作通知"""
|
||||
if not all([self.app_key, self.app_secret, self.agent_id]):
|
||||
logger.warning("钉钉配置不完整,跳过发送")
|
||||
return False
|
||||
|
||||
if not dingtalk_user_id:
|
||||
logger.warning(f"用户 {user_id} 没有绑定钉钉ID")
|
||||
return False
|
||||
|
||||
try:
|
||||
access_token = await self._get_access_token()
|
||||
|
||||
url = f"https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={access_token}"
|
||||
|
||||
# 构建消息体
|
||||
msg = {
|
||||
"agent_id": self.agent_id,
|
||||
"userid_list": dingtalk_user_id,
|
||||
"msg": {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": f"{title}\n\n{content}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=msg, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
logger.info(f"钉钉消息发送成功: user_id={user_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"钉钉消息发送失败: {result.get('errmsg')}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉消息发送异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
class WeworkChannel(NotificationChannel):
|
||||
"""
|
||||
企业微信通知渠道
|
||||
|
||||
使用企业微信应用消息 API
|
||||
文档: https://developer.work.weixin.qq.com/document/path/90236
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
corp_id: Optional[str] = None,
|
||||
corp_secret: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
):
|
||||
self.corp_id = corp_id or os.getenv("WEWORK_CORP_ID")
|
||||
self.corp_secret = corp_secret or os.getenv("WEWORK_CORP_SECRET")
|
||||
self.agent_id = agent_id or os.getenv("WEWORK_AGENT_ID")
|
||||
self._access_token = None
|
||||
self._token_expires_at = None
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""获取企业微信访问令牌"""
|
||||
if (
|
||||
self._access_token
|
||||
and self._token_expires_at
|
||||
and datetime.now() < self._token_expires_at
|
||||
):
|
||||
return self._access_token
|
||||
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
params = {
|
||||
"corpid": self.corp_id,
|
||||
"corpsecret": self.corp_secret,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params=params, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
self._access_token = result["access_token"]
|
||||
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
|
||||
return self._access_token
|
||||
else:
|
||||
raise Exception(f"获取企微Token失败: {result.get('errmsg')}")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
wework_user_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""发送企业微信应用消息"""
|
||||
if not all([self.corp_id, self.corp_secret, self.agent_id]):
|
||||
logger.warning("企业微信配置不完整,跳过发送")
|
||||
return False
|
||||
|
||||
if not wework_user_id:
|
||||
logger.warning(f"用户 {user_id} 没有绑定企业微信ID")
|
||||
return False
|
||||
|
||||
try:
|
||||
access_token = await self._get_access_token()
|
||||
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
# 构建消息体
|
||||
msg = {
|
||||
"touser": wework_user_id,
|
||||
"msgtype": "text",
|
||||
"agentid": int(self.agent_id),
|
||||
"text": {
|
||||
"content": f"{title}\n\n{content}"
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=msg, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
logger.info(f"企微消息发送成功: user_id={user_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"企微消息发送失败: {result.get('errmsg')}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"企微消息发送异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
class InAppChannel(NotificationChannel):
|
||||
"""站内消息通道"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
notification_type: str = "system",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""创建站内消息"""
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
type=notification_type,
|
||||
is_read=False,
|
||||
)
|
||||
self.db.add(notification)
|
||||
await self.db.commit()
|
||||
logger.info(f"站内消息创建成功: user_id={user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"站内消息创建失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""
|
||||
通知服务
|
||||
|
||||
统一管理多渠道通知发送
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.channels = {
|
||||
"dingtalk": DingtalkChannel(),
|
||||
"wework": WeworkChannel(),
|
||||
"inapp": InAppChannel(db),
|
||||
}
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
channels: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
发送通知
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
title: 通知标题
|
||||
content: 通知内容
|
||||
channels: 发送渠道列表,默认全部发送
|
||||
|
||||
Returns:
|
||||
各渠道发送结果
|
||||
"""
|
||||
# 获取用户信息
|
||||
user = await self._get_user(user_id)
|
||||
if not user:
|
||||
return {"error": "用户不存在"}
|
||||
|
||||
# 准备用户渠道标识
|
||||
user_channels = {
|
||||
"dingtalk_user_id": getattr(user, "dingtalk_id", None),
|
||||
"wework_user_id": getattr(user, "wework_userid", None),
|
||||
}
|
||||
|
||||
# 确定发送渠道
|
||||
target_channels = channels or ["inapp"] # 默认只发站内消息
|
||||
|
||||
results = {}
|
||||
for channel_name in target_channels:
|
||||
if channel_name in self.channels:
|
||||
channel = self.channels[channel_name]
|
||||
success = await channel.send(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
**user_channels,
|
||||
**kwargs
|
||||
)
|
||||
results[channel_name] = success
|
||||
|
||||
return results
|
||||
|
||||
async def send_learning_reminder(
|
||||
self,
|
||||
user_id: int,
|
||||
course_name: str,
|
||||
days_inactive: int = 3,
|
||||
) -> Dict[str, bool]:
|
||||
"""发送学习提醒"""
|
||||
title = "📚 学习提醒"
|
||||
content = f"您已有 {days_inactive} 天没有学习《{course_name}》课程了,快来继续学习吧!"
|
||||
|
||||
return await self.send_notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
channels=["inapp", "dingtalk", "wework"],
|
||||
notification_type="learning_reminder",
|
||||
)
|
||||
|
||||
async def send_task_deadline_reminder(
|
||||
self,
|
||||
user_id: int,
|
||||
task_name: str,
|
||||
deadline: datetime,
|
||||
) -> Dict[str, bool]:
|
||||
"""发送任务截止提醒"""
|
||||
days_left = (deadline - datetime.now()).days
|
||||
title = "⏰ 任务截止提醒"
|
||||
content = f"任务《{task_name}》将于 {deadline.strftime('%Y-%m-%d %H:%M')} 截止,还有 {days_left} 天,请尽快完成!"
|
||||
|
||||
return await self.send_notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
channels=["inapp", "dingtalk", "wework"],
|
||||
notification_type="task_deadline",
|
||||
)
|
||||
|
||||
async def send_exam_reminder(
|
||||
self,
|
||||
user_id: int,
|
||||
exam_name: str,
|
||||
exam_time: datetime,
|
||||
) -> Dict[str, bool]:
|
||||
"""发送考试提醒"""
|
||||
title = "📝 考试提醒"
|
||||
content = f"考试《{exam_name}》将于 {exam_time.strftime('%Y-%m-%d %H:%M')} 开始,请提前做好准备!"
|
||||
|
||||
return await self.send_notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
channels=["inapp", "dingtalk", "wework"],
|
||||
notification_type="exam_reminder",
|
||||
)
|
||||
|
||||
async def send_weekly_report(
|
||||
self,
|
||||
user_id: int,
|
||||
study_time: int,
|
||||
courses_completed: int,
|
||||
exams_passed: int,
|
||||
) -> Dict[str, bool]:
|
||||
"""发送周学习报告"""
|
||||
title = "📊 本周学习报告"
|
||||
content = (
|
||||
f"本周学习总结:\n"
|
||||
f"• 学习时长:{study_time // 60} 分钟\n"
|
||||
f"• 完成课程:{courses_completed} 门\n"
|
||||
f"• 通过考试:{exams_passed} 次\n\n"
|
||||
f"继续加油!💪"
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
notification = result.scalar_one_or_none()
|
||||
|
||||
if notification:
|
||||
await db.delete(notification)
|
||||
await db.commit()
|
||||
|
||||
logger.info(
|
||||
"删除通知成功",
|
||||
notification_id=notification_id,
|
||||
user_id=user_id
|
||||
)
|
||||
return True
|
||||
|
||||
return False
|
||||
return await self.send_notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
channels=["inapp", "dingtalk", "wework"],
|
||||
notification_type="weekly_report",
|
||||
)
|
||||
|
||||
async def _get_user(self, user_id: int) -> Optional[User]:
|
||||
"""获取用户信息"""
|
||||
result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
# 创建服务实例
|
||||
notification_service = NotificationService()
|
||||
|
||||
# 便捷函数
|
||||
def get_notification_service(db: AsyncSession) -> NotificationService:
|
||||
"""获取通知服务实例"""
|
||||
return NotificationService(db)
|
||||
|
||||
151
backend/app/services/permission_service.py
Normal file
151
backend/app/services/permission_service.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
权限检查服务
|
||||
"""
|
||||
from typing import Optional, List
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.position import Position
|
||||
from app.models.position_member import PositionMember
|
||||
from app.models.position_course import PositionCourse
|
||||
from app.models.course import Course, CourseStatus
|
||||
|
||||
|
||||
class PermissionService:
|
||||
"""权限检查服务类"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def check_team_membership(self, user_id: int, team_id: int) -> bool:
|
||||
"""
|
||||
检查用户是否属于指定团队(岗位)
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(PositionMember).where(
|
||||
and_(
|
||||
PositionMember.user_id == user_id,
|
||||
PositionMember.position_id == team_id,
|
||||
)
|
||||
)
|
||||
)
|
||||
return result.scalar_one_or_none() is not None
|
||||
|
||||
async def check_course_access(self, user_id: int, course_id: int) -> bool:
|
||||
"""
|
||||
检查用户是否可以访问指定课程
|
||||
规则:
|
||||
1. 课程必须是已发布状态
|
||||
2. 课程必须分配给用户所在的某个岗位
|
||||
"""
|
||||
# 获取课程信息
|
||||
course_result = await self.db.execute(
|
||||
select(Course).where(Course.id == course_id)
|
||||
)
|
||||
course = course_result.scalar_one_or_none()
|
||||
|
||||
if not course:
|
||||
return False
|
||||
|
||||
# 草稿状态的课程只有管理员可以访问
|
||||
if course.status != CourseStatus.PUBLISHED:
|
||||
return False
|
||||
|
||||
# 获取用户所在的所有岗位
|
||||
positions_result = await self.db.execute(
|
||||
select(PositionMember.position_id).where(
|
||||
PositionMember.user_id == user_id
|
||||
)
|
||||
)
|
||||
user_position_ids = [row[0] for row in positions_result.all()]
|
||||
|
||||
if not user_position_ids:
|
||||
# 没有岗位的用户可以访问所有已发布课程(基础学习权限)
|
||||
return True
|
||||
|
||||
# 检查课程是否分配给用户的任一岗位
|
||||
course_position_result = await self.db.execute(
|
||||
select(PositionCourse).where(
|
||||
and_(
|
||||
PositionCourse.course_id == course_id,
|
||||
PositionCourse.position_id.in_(user_position_ids),
|
||||
)
|
||||
)
|
||||
)
|
||||
has_position_access = course_position_result.scalar_one_or_none() is not None
|
||||
|
||||
# 如果没有通过岗位分配访问,仍然允许访问已发布的公开课程
|
||||
# 这是为了确保所有用户都能看到公开课程
|
||||
return has_position_access or True # 暂时允许所有已发布课程
|
||||
|
||||
async def get_user_accessible_courses(self, user_id: int) -> List[int]:
|
||||
"""
|
||||
获取用户可访问的所有课程ID
|
||||
"""
|
||||
# 获取用户所在的所有岗位
|
||||
positions_result = await self.db.execute(
|
||||
select(PositionMember.position_id).where(
|
||||
PositionMember.user_id == user_id
|
||||
)
|
||||
)
|
||||
user_position_ids = [row[0] for row in positions_result.all()]
|
||||
|
||||
if not user_position_ids:
|
||||
# 没有岗位的用户返回所有已发布课程
|
||||
courses_result = await self.db.execute(
|
||||
select(Course.id).where(Course.status == CourseStatus.PUBLISHED)
|
||||
)
|
||||
return [row[0] for row in courses_result.all()]
|
||||
|
||||
# 获取岗位分配的课程
|
||||
courses_result = await self.db.execute(
|
||||
select(PositionCourse.course_id).where(
|
||||
PositionCourse.position_id.in_(user_position_ids)
|
||||
).distinct()
|
||||
)
|
||||
return [row[0] for row in courses_result.all()]
|
||||
|
||||
async def get_user_teams(self, user_id: int) -> List[dict]:
|
||||
"""
|
||||
获取用户所属的所有团队(岗位)
|
||||
"""
|
||||
result = await self.db.execute(
|
||||
select(Position).join(
|
||||
PositionMember, PositionMember.position_id == Position.id
|
||||
).where(
|
||||
PositionMember.user_id == user_id
|
||||
)
|
||||
)
|
||||
positions = result.scalars().all()
|
||||
return [{"id": p.id, "name": p.name} for p in positions]
|
||||
|
||||
async def is_team_manager(self, user_id: int, team_id: int) -> bool:
|
||||
"""
|
||||
检查用户是否是团队管理者
|
||||
"""
|
||||
# 检查用户是否是该岗位的创建者或管理者
|
||||
position_result = await self.db.execute(
|
||||
select(Position).where(Position.id == team_id)
|
||||
)
|
||||
position = position_result.scalar_one_or_none()
|
||||
|
||||
if not position:
|
||||
return False
|
||||
|
||||
# 检查创建者
|
||||
if hasattr(position, 'created_by') and position.created_by == user_id:
|
||||
return True
|
||||
|
||||
# 检查用户角色是否为管理者
|
||||
user_result = await self.db.execute(
|
||||
select(User).where(User.id == user_id)
|
||||
)
|
||||
user = user_result.scalar_one_or_none()
|
||||
|
||||
return user and user.role in ['admin', 'manager']
|
||||
|
||||
|
||||
# 辅助函数:创建权限服务实例
|
||||
def get_permission_service(db: AsyncSession) -> PermissionService:
|
||||
return PermissionService(db)
|
||||
File diff suppressed because it is too large
Load Diff
379
backend/app/services/recommendation_service.py
Normal file
379
backend/app/services/recommendation_service.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""
|
||||
智能学习推荐服务
|
||||
基于用户能力评估、错题记录和学习历史推荐学习内容
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import List, Dict, Any, Optional
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_, func, desc
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from app.models.user import User
|
||||
from app.models.course import Course, CourseStatus, CourseMaterial, KnowledgePoint
|
||||
from app.models.exam import ExamResult
|
||||
from app.models.exam_mistake import ExamMistake
|
||||
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
|
||||
from app.models.ability import AbilityAssessment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class RecommendationService:
|
||||
"""
|
||||
智能学习推荐服务
|
||||
|
||||
推荐策略:
|
||||
1. 基于错题分析:推荐与错题相关的知识点和课程
|
||||
2. 基于能力评估:推荐弱项能力相关的课程
|
||||
3. 基于学习进度:推荐未完成的课程继续学习
|
||||
4. 基于热门课程:推荐学习人数多的课程
|
||||
5. 基于岗位要求:推荐岗位必修课程
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def get_recommendations(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 10,
|
||||
include_reasons: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取个性化学习推荐
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
limit: 推荐数量上限
|
||||
include_reasons: 是否包含推荐理由
|
||||
|
||||
Returns:
|
||||
推荐课程列表,包含课程信息和推荐理由
|
||||
"""
|
||||
recommendations = []
|
||||
|
||||
# 1. 基于错题推荐
|
||||
mistake_recs = await self._get_mistake_based_recommendations(user_id)
|
||||
recommendations.extend(mistake_recs)
|
||||
|
||||
# 2. 基于能力评估推荐
|
||||
ability_recs = await self._get_ability_based_recommendations(user_id)
|
||||
recommendations.extend(ability_recs)
|
||||
|
||||
# 3. 基于未完成课程推荐
|
||||
progress_recs = await self._get_progress_based_recommendations(user_id)
|
||||
recommendations.extend(progress_recs)
|
||||
|
||||
# 4. 基于热门课程推荐
|
||||
popular_recs = await self._get_popular_recommendations(user_id)
|
||||
recommendations.extend(popular_recs)
|
||||
|
||||
# 去重并排序
|
||||
seen_course_ids = set()
|
||||
unique_recs = []
|
||||
for rec in recommendations:
|
||||
if rec["course_id"] not in seen_course_ids:
|
||||
seen_course_ids.add(rec["course_id"])
|
||||
unique_recs.append(rec)
|
||||
|
||||
# 按优先级排序
|
||||
priority_map = {
|
||||
"mistake": 1,
|
||||
"ability": 2,
|
||||
"progress": 3,
|
||||
"popular": 4,
|
||||
}
|
||||
unique_recs.sort(key=lambda x: priority_map.get(x.get("source", ""), 5))
|
||||
|
||||
# 限制数量
|
||||
result = unique_recs[:limit]
|
||||
|
||||
# 移除 source 字段如果不需要理由
|
||||
if not include_reasons:
|
||||
for rec in result:
|
||||
rec.pop("source", None)
|
||||
rec.pop("reason", None)
|
||||
|
||||
return result
|
||||
|
||||
async def _get_mistake_based_recommendations(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""基于错题推荐"""
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
# 获取用户最近的错题
|
||||
result = await self.db.execute(
|
||||
select(ExamMistake).where(
|
||||
ExamMistake.user_id == user_id
|
||||
).order_by(
|
||||
desc(ExamMistake.created_at)
|
||||
).limit(50)
|
||||
)
|
||||
mistakes = result.scalars().all()
|
||||
|
||||
if not mistakes:
|
||||
return recommendations
|
||||
|
||||
# 统计错题涉及的知识点
|
||||
knowledge_point_counts = {}
|
||||
for mistake in mistakes:
|
||||
if hasattr(mistake, 'knowledge_point_id') and mistake.knowledge_point_id:
|
||||
kp_id = mistake.knowledge_point_id
|
||||
knowledge_point_counts[kp_id] = knowledge_point_counts.get(kp_id, 0) + 1
|
||||
|
||||
if not knowledge_point_counts:
|
||||
return recommendations
|
||||
|
||||
# 找出错误最多的知识点对应的课程
|
||||
top_kp_ids = sorted(
|
||||
knowledge_point_counts.keys(),
|
||||
key=lambda x: knowledge_point_counts[x],
|
||||
reverse=True
|
||||
)[:5]
|
||||
|
||||
course_result = await self.db.execute(
|
||||
select(Course, KnowledgePoint).join(
|
||||
KnowledgePoint, Course.id == KnowledgePoint.course_id
|
||||
).where(
|
||||
and_(
|
||||
KnowledgePoint.id.in_(top_kp_ids),
|
||||
Course.status == CourseStatus.PUBLISHED,
|
||||
Course.is_deleted == False,
|
||||
)
|
||||
).distinct()
|
||||
)
|
||||
|
||||
for course, kp in course_result.all()[:limit]:
|
||||
recommendations.append({
|
||||
"course_id": course.id,
|
||||
"course_name": course.name,
|
||||
"category": course.category.value if course.category else None,
|
||||
"cover_image": course.cover_image,
|
||||
"description": course.description,
|
||||
"source": "mistake",
|
||||
"reason": f"您在「{kp.name}」知识点上有错题,建议复习相关内容",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"基于错题推荐失败: {str(e)}")
|
||||
|
||||
return recommendations
|
||||
|
||||
async def _get_ability_based_recommendations(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""基于能力评估推荐"""
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
# 获取用户最近的能力评估
|
||||
result = await self.db.execute(
|
||||
select(AbilityAssessment).where(
|
||||
AbilityAssessment.user_id == user_id
|
||||
).order_by(
|
||||
desc(AbilityAssessment.created_at)
|
||||
).limit(1)
|
||||
)
|
||||
assessment = result.scalar_one_or_none()
|
||||
|
||||
if not assessment:
|
||||
return recommendations
|
||||
|
||||
# 解析能力评估结果,找出弱项
|
||||
scores = {}
|
||||
if hasattr(assessment, 'dimension_scores') and assessment.dimension_scores:
|
||||
scores = assessment.dimension_scores
|
||||
elif hasattr(assessment, 'scores') and assessment.scores:
|
||||
scores = assessment.scores
|
||||
|
||||
if not scores:
|
||||
return recommendations
|
||||
|
||||
# 找出分数最低的维度
|
||||
weak_dimensions = sorted(
|
||||
scores.items(),
|
||||
key=lambda x: x[1] if isinstance(x[1], (int, float)) else 0
|
||||
)[:3]
|
||||
|
||||
# 根据弱项维度推荐课程(按分类匹配)
|
||||
category_map = {
|
||||
"专业知识": "technology",
|
||||
"沟通能力": "business",
|
||||
"管理能力": "management",
|
||||
}
|
||||
|
||||
for dim_name, score in weak_dimensions:
|
||||
if isinstance(score, (int, float)) and score < 70:
|
||||
category = category_map.get(dim_name)
|
||||
if category:
|
||||
course_result = await self.db.execute(
|
||||
select(Course).where(
|
||||
and_(
|
||||
Course.category == category,
|
||||
Course.status == CourseStatus.PUBLISHED,
|
||||
Course.is_deleted == False,
|
||||
)
|
||||
).order_by(
|
||||
desc(Course.student_count)
|
||||
).limit(1)
|
||||
)
|
||||
course = course_result.scalar_one_or_none()
|
||||
if course:
|
||||
recommendations.append({
|
||||
"course_id": course.id,
|
||||
"course_name": course.name,
|
||||
"category": course.category.value if course.category else None,
|
||||
"cover_image": course.cover_image,
|
||||
"description": course.description,
|
||||
"source": "ability",
|
||||
"reason": f"您的「{dim_name}」能力评分较低({score}分),推荐学习此课程提升",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"基于能力评估推荐失败: {str(e)}")
|
||||
|
||||
return recommendations[:limit]
|
||||
|
||||
async def _get_progress_based_recommendations(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""基于学习进度推荐"""
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
# 获取未完成的课程
|
||||
result = await self.db.execute(
|
||||
select(UserCourseProgress, Course).join(
|
||||
Course, UserCourseProgress.course_id == Course.id
|
||||
).where(
|
||||
and_(
|
||||
UserCourseProgress.user_id == user_id,
|
||||
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
|
||||
Course.is_deleted == False,
|
||||
)
|
||||
).order_by(
|
||||
desc(UserCourseProgress.last_accessed_at)
|
||||
).limit(limit)
|
||||
)
|
||||
|
||||
for progress, course in result.all():
|
||||
recommendations.append({
|
||||
"course_id": course.id,
|
||||
"course_name": course.name,
|
||||
"category": course.category.value if course.category else None,
|
||||
"cover_image": course.cover_image,
|
||||
"description": course.description,
|
||||
"progress_percent": progress.progress_percent,
|
||||
"source": "progress",
|
||||
"reason": f"继续学习,已完成 {progress.progress_percent:.0f}%",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"基于进度推荐失败: {str(e)}")
|
||||
|
||||
return recommendations
|
||||
|
||||
async def _get_popular_recommendations(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 3,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""基于热门课程推荐"""
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
# 获取用户已学习的课程ID
|
||||
learned_result = await self.db.execute(
|
||||
select(UserCourseProgress.course_id).where(
|
||||
UserCourseProgress.user_id == user_id
|
||||
)
|
||||
)
|
||||
learned_course_ids = [row[0] for row in learned_result.all()]
|
||||
|
||||
# 获取热门课程(排除已学习的)
|
||||
query = select(Course).where(
|
||||
and_(
|
||||
Course.status == CourseStatus.PUBLISHED,
|
||||
Course.is_deleted == False,
|
||||
)
|
||||
).order_by(
|
||||
desc(Course.student_count)
|
||||
).limit(limit + len(learned_course_ids))
|
||||
|
||||
result = await self.db.execute(query)
|
||||
courses = result.scalars().all()
|
||||
|
||||
for course in courses:
|
||||
if course.id not in learned_course_ids:
|
||||
recommendations.append({
|
||||
"course_id": course.id,
|
||||
"course_name": course.name,
|
||||
"category": course.category.value if course.category else None,
|
||||
"cover_image": course.cover_image,
|
||||
"description": course.description,
|
||||
"student_count": course.student_count,
|
||||
"source": "popular",
|
||||
"reason": f"热门课程,已有 {course.student_count} 人学习",
|
||||
})
|
||||
if len(recommendations) >= limit:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.error(f"基于热门推荐失败: {str(e)}")
|
||||
|
||||
return recommendations
|
||||
|
||||
async def get_knowledge_point_recommendations(
|
||||
self,
|
||||
user_id: int,
|
||||
limit: int = 5,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
获取知识点级别的推荐
|
||||
基于错题和能力评估推荐具体的知识点
|
||||
"""
|
||||
recommendations = []
|
||||
|
||||
try:
|
||||
# 获取错题涉及的知识点
|
||||
mistake_result = await self.db.execute(
|
||||
select(
|
||||
KnowledgePoint,
|
||||
func.count(ExamMistake.id).label('mistake_count')
|
||||
).join(
|
||||
ExamMistake,
|
||||
ExamMistake.knowledge_point_id == KnowledgePoint.id
|
||||
).where(
|
||||
ExamMistake.user_id == user_id
|
||||
).group_by(
|
||||
KnowledgePoint.id
|
||||
).order_by(
|
||||
desc('mistake_count')
|
||||
).limit(limit)
|
||||
)
|
||||
|
||||
for kp, count in mistake_result.all():
|
||||
recommendations.append({
|
||||
"knowledge_point_id": kp.id,
|
||||
"name": kp.name,
|
||||
"description": kp.description,
|
||||
"type": kp.type,
|
||||
"course_id": kp.course_id,
|
||||
"mistake_count": count,
|
||||
"reason": f"您在此知识点有 {count} 道错题,建议重点复习",
|
||||
})
|
||||
except Exception as e:
|
||||
logger.error(f"知识点推荐失败: {str(e)}")
|
||||
|
||||
return recommendations
|
||||
|
||||
|
||||
# 便捷函数
|
||||
def get_recommendation_service(db: AsyncSession) -> RecommendationService:
|
||||
"""获取推荐服务实例"""
|
||||
return RecommendationService(db)
|
||||
273
backend/app/services/scheduler_service.py
Normal file
273
backend/app/services/scheduler_service.py
Normal file
@@ -0,0 +1,273 @@
|
||||
"""
|
||||
定时任务服务
|
||||
使用 APScheduler 管理定时任务
|
||||
"""
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy import select, and_, func
|
||||
|
||||
from app.core.config import settings
|
||||
from app.models.user import User
|
||||
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
|
||||
from app.models.task import Task, TaskAssignment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 全局调度器实例
|
||||
scheduler: Optional[AsyncIOScheduler] = None
|
||||
|
||||
|
||||
async def get_db_session() -> AsyncSession:
|
||||
"""获取数据库会话"""
|
||||
engine = create_async_engine(settings.DATABASE_URL, echo=False)
|
||||
async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
|
||||
return async_session()
|
||||
|
||||
|
||||
async def send_learning_reminders():
|
||||
"""
|
||||
发送学习提醒
|
||||
|
||||
检查所有用户的学习进度,对长时间未学习的用户发送提醒
|
||||
"""
|
||||
logger.info("开始执行学习提醒任务")
|
||||
|
||||
try:
|
||||
db = await get_db_session()
|
||||
|
||||
from app.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(db)
|
||||
|
||||
# 查找超过3天未学习的用户
|
||||
three_days_ago = datetime.now() - timedelta(days=3)
|
||||
|
||||
result = await db.execute(
|
||||
select(UserCourseProgress, User).join(
|
||||
User, UserCourseProgress.user_id == User.id
|
||||
).where(
|
||||
and_(
|
||||
UserCourseProgress.status == ProgressStatus.IN_PROGRESS.value,
|
||||
UserCourseProgress.last_accessed_at < three_days_ago,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
inactive_progress = result.all()
|
||||
|
||||
for progress, user in inactive_progress:
|
||||
# 获取课程名称
|
||||
from app.models.course import Course
|
||||
course_result = await db.execute(
|
||||
select(Course.name).where(Course.id == progress.course_id)
|
||||
)
|
||||
course_name = course_result.scalar() or "未知课程"
|
||||
|
||||
days_inactive = (datetime.now() - progress.last_accessed_at).days
|
||||
|
||||
# 发送提醒
|
||||
await notification_service.send_learning_reminder(
|
||||
user_id=user.id,
|
||||
course_name=course_name,
|
||||
days_inactive=days_inactive,
|
||||
)
|
||||
|
||||
logger.info(f"已发送学习提醒: user_id={user.id}, course={course_name}")
|
||||
|
||||
await db.close()
|
||||
logger.info(f"学习提醒任务完成,发送了 {len(inactive_progress)} 条提醒")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"学习提醒任务失败: {str(e)}")
|
||||
|
||||
|
||||
async def send_task_deadline_reminders():
|
||||
"""
|
||||
发送任务截止提醒
|
||||
|
||||
检查即将到期的任务,发送提醒给相关用户
|
||||
"""
|
||||
logger.info("开始执行任务截止提醒")
|
||||
|
||||
try:
|
||||
db = await get_db_session()
|
||||
|
||||
from app.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(db)
|
||||
|
||||
# 查找3天内到期的未完成任务
|
||||
now = datetime.now()
|
||||
three_days_later = now + timedelta(days=3)
|
||||
|
||||
result = await db.execute(
|
||||
select(Task, TaskAssignment, User).join(
|
||||
TaskAssignment, Task.id == TaskAssignment.task_id
|
||||
).join(
|
||||
User, TaskAssignment.user_id == User.id
|
||||
).where(
|
||||
and_(
|
||||
Task.end_time.between(now, three_days_later),
|
||||
TaskAssignment.status.in_(["not_started", "in_progress"]),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
upcoming_tasks = result.all()
|
||||
|
||||
for task, assignment, user in upcoming_tasks:
|
||||
await notification_service.send_task_deadline_reminder(
|
||||
user_id=user.id,
|
||||
task_name=task.name,
|
||||
deadline=task.end_time,
|
||||
)
|
||||
|
||||
logger.info(f"已发送任务截止提醒: user_id={user.id}, task={task.name}")
|
||||
|
||||
await db.close()
|
||||
logger.info(f"任务截止提醒完成,发送了 {len(upcoming_tasks)} 条提醒")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"任务截止提醒失败: {str(e)}")
|
||||
|
||||
|
||||
async def send_weekly_reports():
|
||||
"""
|
||||
发送周学习报告
|
||||
|
||||
每周一发送上周的学习统计报告
|
||||
"""
|
||||
logger.info("开始生成周学习报告")
|
||||
|
||||
try:
|
||||
db = await get_db_session()
|
||||
|
||||
from app.services.notification_service import NotificationService
|
||||
notification_service = NotificationService(db)
|
||||
|
||||
# 获取所有活跃用户
|
||||
result = await db.execute(
|
||||
select(User).where(User.is_active == True)
|
||||
)
|
||||
users = result.scalars().all()
|
||||
|
||||
# 计算上周时间范围
|
||||
today = datetime.now().date()
|
||||
last_week_start = today - timedelta(days=today.weekday() + 7)
|
||||
last_week_end = last_week_start + timedelta(days=6)
|
||||
|
||||
for user in users:
|
||||
# 统计学习时长
|
||||
study_time_result = await db.execute(
|
||||
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0)).where(
|
||||
and_(
|
||||
UserCourseProgress.user_id == user.id,
|
||||
UserCourseProgress.last_accessed_at.between(
|
||||
datetime.combine(last_week_start, datetime.min.time()),
|
||||
datetime.combine(last_week_end, datetime.max.time()),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
study_time = study_time_result.scalar() or 0
|
||||
|
||||
# 统计完成课程数
|
||||
completed_result = await db.execute(
|
||||
select(func.count(UserCourseProgress.id)).where(
|
||||
and_(
|
||||
UserCourseProgress.user_id == user.id,
|
||||
UserCourseProgress.status == ProgressStatus.COMPLETED.value,
|
||||
UserCourseProgress.completed_at.between(
|
||||
datetime.combine(last_week_start, datetime.min.time()),
|
||||
datetime.combine(last_week_end, datetime.max.time()),
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
courses_completed = completed_result.scalar() or 0
|
||||
|
||||
# 如果有学习活动,发送报告
|
||||
if study_time > 0 or courses_completed > 0:
|
||||
await notification_service.send_weekly_report(
|
||||
user_id=user.id,
|
||||
study_time=study_time,
|
||||
courses_completed=courses_completed,
|
||||
exams_passed=0, # TODO: 统计考试通过数
|
||||
)
|
||||
|
||||
logger.info(f"已发送周报: user_id={user.id}")
|
||||
|
||||
await db.close()
|
||||
logger.info("周学习报告发送完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"周学习报告发送失败: {str(e)}")
|
||||
|
||||
|
||||
def init_scheduler():
|
||||
"""初始化定时任务调度器"""
|
||||
global scheduler
|
||||
|
||||
if scheduler is not None:
|
||||
return scheduler
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
|
||||
# 学习提醒:每天上午9点执行
|
||||
scheduler.add_job(
|
||||
send_learning_reminders,
|
||||
CronTrigger(hour=9, minute=0),
|
||||
id="learning_reminders",
|
||||
name="学习提醒",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# 任务截止提醒:每天上午10点执行
|
||||
scheduler.add_job(
|
||||
send_task_deadline_reminders,
|
||||
CronTrigger(hour=10, minute=0),
|
||||
id="task_deadline_reminders",
|
||||
name="任务截止提醒",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# 周学习报告:每周一上午8点发送
|
||||
scheduler.add_job(
|
||||
send_weekly_reports,
|
||||
CronTrigger(day_of_week="mon", hour=8, minute=0),
|
||||
id="weekly_reports",
|
||||
name="周学习报告",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
logger.info("定时任务调度器初始化完成")
|
||||
return scheduler
|
||||
|
||||
|
||||
def start_scheduler():
|
||||
"""启动调度器"""
|
||||
global scheduler
|
||||
|
||||
if scheduler is None:
|
||||
scheduler = init_scheduler()
|
||||
|
||||
if not scheduler.running:
|
||||
scheduler.start()
|
||||
logger.info("定时任务调度器已启动")
|
||||
|
||||
|
||||
def stop_scheduler():
|
||||
"""停止调度器"""
|
||||
global scheduler
|
||||
|
||||
if scheduler and scheduler.running:
|
||||
scheduler.shutdown()
|
||||
logger.info("定时任务调度器已停止")
|
||||
|
||||
|
||||
def get_scheduler() -> Optional[AsyncIOScheduler]:
|
||||
"""获取调度器实例"""
|
||||
return scheduler
|
||||
256
backend/app/services/speech_recognition.py
Normal file
256
backend/app/services/speech_recognition.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""
|
||||
语音识别服务
|
||||
支持多种语音识别引擎:
|
||||
1. 阿里云语音识别
|
||||
2. 讯飞语音识别
|
||||
3. 本地 Whisper 模型
|
||||
"""
|
||||
import os
|
||||
import base64
|
||||
import json
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
import httpx
|
||||
from urllib.parse import urlencode
|
||||
|
||||
|
||||
class SpeechRecognitionError(Exception):
|
||||
"""语音识别错误"""
|
||||
pass
|
||||
|
||||
|
||||
class AliyunSpeechRecognition:
|
||||
"""
|
||||
阿里云智能语音交互 - 一句话识别
|
||||
文档: https://help.aliyun.com/document_detail/92131.html
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
access_key_id: Optional[str] = None,
|
||||
access_key_secret: Optional[str] = None,
|
||||
app_key: Optional[str] = None,
|
||||
):
|
||||
self.access_key_id = access_key_id or os.getenv("ALIYUN_ACCESS_KEY_ID")
|
||||
self.access_key_secret = access_key_secret or os.getenv("ALIYUN_ACCESS_KEY_SECRET")
|
||||
self.app_key = app_key or os.getenv("ALIYUN_NLS_APP_KEY")
|
||||
self.api_url = "https://nls-gateway-cn-shanghai.aliyuncs.com/stream/v1/asr"
|
||||
|
||||
def _create_signature(self, params: Dict[str, str]) -> str:
|
||||
"""创建签名"""
|
||||
sorted_params = sorted(params.items())
|
||||
query_string = urlencode(sorted_params)
|
||||
string_to_sign = f"POST&%2F&{urlencode({query_string: ''}).split('=')[0]}"
|
||||
signature = hmac.new(
|
||||
(self.access_key_secret + "&").encode("utf-8"),
|
||||
string_to_sign.encode("utf-8"),
|
||||
hashlib.sha1,
|
||||
).digest()
|
||||
return base64.b64encode(signature).decode("utf-8")
|
||||
|
||||
async def recognize(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "wav",
|
||||
sample_rate: int = 16000,
|
||||
) -> str:
|
||||
"""
|
||||
识别音频
|
||||
|
||||
Args:
|
||||
audio_data: 音频数据(二进制)
|
||||
format: 音频格式,支持 pcm, wav, ogg, opus, mp3
|
||||
sample_rate: 采样率,默认 16000
|
||||
|
||||
Returns:
|
||||
识别出的文本
|
||||
"""
|
||||
if not all([self.access_key_id, self.access_key_secret, self.app_key]):
|
||||
raise SpeechRecognitionError("阿里云语音识别配置不完整")
|
||||
|
||||
headers = {
|
||||
"Content-Type": f"audio/{format}; samplerate={sample_rate}",
|
||||
"X-NLS-Token": await self._get_token(),
|
||||
}
|
||||
|
||||
params = {
|
||||
"appkey": self.app_key,
|
||||
"format": format,
|
||||
"sample_rate": str(sample_rate),
|
||||
}
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
self.api_url,
|
||||
params=params,
|
||||
headers=headers,
|
||||
content=audio_data,
|
||||
timeout=30.0,
|
||||
)
|
||||
|
||||
if response.status_code != 200:
|
||||
raise SpeechRecognitionError(
|
||||
f"阿里云语音识别请求失败: {response.status_code}"
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
if result.get("status") == 20000000:
|
||||
return result.get("result", "")
|
||||
else:
|
||||
raise SpeechRecognitionError(
|
||||
f"语音识别失败: {result.get('message', '未知错误')}"
|
||||
)
|
||||
except httpx.RequestError as e:
|
||||
raise SpeechRecognitionError(f"网络请求错误: {str(e)}")
|
||||
|
||||
async def _get_token(self) -> str:
|
||||
"""获取访问令牌"""
|
||||
# 简化版:实际生产环境需要缓存 token
|
||||
token_url = "https://nls-meta.cn-shanghai.aliyuncs.com/"
|
||||
|
||||
timestamp = datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
params = {
|
||||
"AccessKeyId": self.access_key_id,
|
||||
"Action": "CreateToken",
|
||||
"Format": "JSON",
|
||||
"RegionId": "cn-shanghai",
|
||||
"SignatureMethod": "HMAC-SHA1",
|
||||
"SignatureNonce": str(int(time.time() * 1000)),
|
||||
"SignatureVersion": "1.0",
|
||||
"Timestamp": timestamp,
|
||||
"Version": "2019-02-28",
|
||||
}
|
||||
|
||||
params["Signature"] = self._create_signature(params)
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(token_url, params=params, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if "Token" in result:
|
||||
return result["Token"]["Id"]
|
||||
else:
|
||||
raise SpeechRecognitionError(
|
||||
f"获取阿里云语音识别 Token 失败: {result.get('Message', '未知错误')}"
|
||||
)
|
||||
|
||||
|
||||
class XunfeiSpeechRecognition:
|
||||
"""
|
||||
讯飞语音识别
|
||||
文档: https://www.xfyun.cn/doc/asr/voicedictation/API.html
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_id: Optional[str] = None,
|
||||
api_key: Optional[str] = None,
|
||||
api_secret: Optional[str] = None,
|
||||
):
|
||||
self.app_id = app_id or os.getenv("XUNFEI_APP_ID")
|
||||
self.api_key = api_key or os.getenv("XUNFEI_API_KEY")
|
||||
self.api_secret = api_secret or os.getenv("XUNFEI_API_SECRET")
|
||||
self.api_url = "wss://iat-api.xfyun.cn/v2/iat"
|
||||
|
||||
async def recognize(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "audio/L16;rate=16000",
|
||||
) -> str:
|
||||
"""
|
||||
识别音频
|
||||
|
||||
Args:
|
||||
audio_data: 音频数据(二进制)
|
||||
format: 音频格式
|
||||
|
||||
Returns:
|
||||
识别出的文本
|
||||
"""
|
||||
if not all([self.app_id, self.api_key, self.api_secret]):
|
||||
raise SpeechRecognitionError("讯飞语音识别配置不完整")
|
||||
|
||||
# 讯飞使用 WebSocket,这里是简化实现
|
||||
# 实际需要使用 websockets 库进行实时流式识别
|
||||
raise NotImplementedError("讯飞语音识别需要 WebSocket 实现")
|
||||
|
||||
|
||||
class SimpleSpeechRecognition:
|
||||
"""
|
||||
简易语音识别实现
|
||||
使用浏览器 Web Speech API 的结果直接返回
|
||||
用于前端已经完成识别的情况
|
||||
"""
|
||||
|
||||
async def recognize(self, text: str) -> str:
|
||||
"""直接返回前端传来的识别结果"""
|
||||
return text.strip()
|
||||
|
||||
|
||||
class SpeechRecognitionService:
|
||||
"""
|
||||
语音识别服务统一接口
|
||||
根据配置选择不同的识别引擎
|
||||
"""
|
||||
|
||||
def __init__(self, engine: str = "simple"):
|
||||
"""
|
||||
初始化语音识别服务
|
||||
|
||||
Args:
|
||||
engine: 识别引擎,支持 aliyun, xunfei, simple
|
||||
"""
|
||||
self.engine = engine
|
||||
|
||||
if engine == "aliyun":
|
||||
self._recognizer = AliyunSpeechRecognition()
|
||||
elif engine == "xunfei":
|
||||
self._recognizer = XunfeiSpeechRecognition()
|
||||
else:
|
||||
self._recognizer = SimpleSpeechRecognition()
|
||||
|
||||
async def recognize_audio(
|
||||
self,
|
||||
audio_data: bytes,
|
||||
format: str = "wav",
|
||||
sample_rate: int = 16000,
|
||||
) -> str:
|
||||
"""
|
||||
识别音频数据
|
||||
|
||||
Args:
|
||||
audio_data: 音频二进制数据
|
||||
format: 音频格式
|
||||
sample_rate: 采样率
|
||||
|
||||
Returns:
|
||||
识别出的文本
|
||||
"""
|
||||
if self.engine == "simple":
|
||||
raise SpeechRecognitionError(
|
||||
"简易模式不支持音频识别,请使用前端 Web Speech API"
|
||||
)
|
||||
|
||||
return await self._recognizer.recognize(audio_data, format, sample_rate)
|
||||
|
||||
async def recognize_text(self, text: str) -> str:
|
||||
"""
|
||||
直接处理已识别的文本(用于前端已完成识别的情况)
|
||||
|
||||
Args:
|
||||
text: 已识别的文本
|
||||
|
||||
Returns:
|
||||
处理后的文本
|
||||
"""
|
||||
return text.strip()
|
||||
|
||||
|
||||
# 创建默认服务实例
|
||||
def get_speech_recognition_service(engine: str = "simple") -> SpeechRecognitionService:
|
||||
"""获取语音识别服务实例"""
|
||||
return SpeechRecognitionService(engine=engine)
|
||||
@@ -1,82 +1,82 @@
|
||||
# 数据库迁移说明
|
||||
|
||||
本目录包含 KPL 考培练系统的数据库迁移脚本。
|
||||
|
||||
## 迁移脚本列表
|
||||
|
||||
| 脚本 | 说明 | 创建时间 |
|
||||
|------|------|----------|
|
||||
| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 |
|
||||
|
||||
## 执行迁移
|
||||
|
||||
### 测试环境(Docker)
|
||||
|
||||
KPL 测试环境数据库在服务器 Docker 容器中运行:
|
||||
|
||||
```bash
|
||||
# 1. SSH 登录 KPL 服务器
|
||||
ssh root@<KPL服务器IP>
|
||||
|
||||
# 2. 进入项目目录
|
||||
cd /www/wwwroot/kpl.ireborn.com.cn
|
||||
|
||||
# 3. 执行迁移(方法一:直接执行)
|
||||
docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql
|
||||
|
||||
# 或者(方法二:交互式执行)
|
||||
docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev
|
||||
# 然后复制粘贴 SQL 脚本内容执行
|
||||
|
||||
# 方法三:从本地执行(需要先上传SQL文件到服务器)
|
||||
# scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/
|
||||
# ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql"
|
||||
```
|
||||
|
||||
**注意**:MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认)
|
||||
|
||||
### 生产环境
|
||||
|
||||
生产环境迁移前请确保:
|
||||
1. 已备份数据库
|
||||
2. 在低峰期执行
|
||||
3. 测试环境验证通过
|
||||
|
||||
```bash
|
||||
# 执行迁移(替换为实际的生产数据库配置)
|
||||
mysql -h<host> -u<user> -p<password> <database> < backend/migrations/add_level_badge_system.sql
|
||||
```
|
||||
|
||||
## 回滚方法
|
||||
|
||||
如需回滚,执行以下 SQL:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS user_badges;
|
||||
DROP TABLE IF EXISTS badge_definitions;
|
||||
DROP TABLE IF EXISTS exp_history;
|
||||
DROP TABLE IF EXISTS level_configs;
|
||||
DROP TABLE IF EXISTS user_levels;
|
||||
```
|
||||
|
||||
## 验证迁移
|
||||
|
||||
执行以下查询验证表是否创建成功:
|
||||
|
||||
```sql
|
||||
SHOW TABLES LIKE '%level%';
|
||||
SHOW TABLES LIKE '%badge%';
|
||||
SHOW TABLES LIKE '%exp%';
|
||||
|
||||
-- 查看表结构
|
||||
DESCRIBE user_levels;
|
||||
DESCRIBE exp_history;
|
||||
DESCRIBE badge_definitions;
|
||||
DESCRIBE user_badges;
|
||||
DESCRIBE level_configs;
|
||||
|
||||
-- 验证初始数据
|
||||
SELECT COUNT(*) FROM level_configs; -- 应该是 10 条
|
||||
SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条
|
||||
SELECT COUNT(*) FROM user_levels; -- 应该等于用户数
|
||||
```
|
||||
# 数据库迁移说明
|
||||
|
||||
本目录包含 KPL 考培练系统的数据库迁移脚本。
|
||||
|
||||
## 迁移脚本列表
|
||||
|
||||
| 脚本 | 说明 | 创建时间 |
|
||||
|------|------|----------|
|
||||
| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 |
|
||||
|
||||
## 执行迁移
|
||||
|
||||
### 测试环境(Docker)
|
||||
|
||||
KPL 测试环境数据库在服务器 Docker 容器中运行:
|
||||
|
||||
```bash
|
||||
# 1. SSH 登录 KPL 服务器
|
||||
ssh root@<KPL服务器IP>
|
||||
|
||||
# 2. 进入项目目录
|
||||
cd /www/wwwroot/kpl.ireborn.com.cn
|
||||
|
||||
# 3. 执行迁移(方法一:直接执行)
|
||||
docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql
|
||||
|
||||
# 或者(方法二:交互式执行)
|
||||
docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev
|
||||
# 然后复制粘贴 SQL 脚本内容执行
|
||||
|
||||
# 方法三:从本地执行(需要先上传SQL文件到服务器)
|
||||
# scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/
|
||||
# ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql"
|
||||
```
|
||||
|
||||
**注意**:MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认)
|
||||
|
||||
### 生产环境
|
||||
|
||||
生产环境迁移前请确保:
|
||||
1. 已备份数据库
|
||||
2. 在低峰期执行
|
||||
3. 测试环境验证通过
|
||||
|
||||
```bash
|
||||
# 执行迁移(替换为实际的生产数据库配置)
|
||||
mysql -h<host> -u<user> -p<password> <database> < backend/migrations/add_level_badge_system.sql
|
||||
```
|
||||
|
||||
## 回滚方法
|
||||
|
||||
如需回滚,执行以下 SQL:
|
||||
|
||||
```sql
|
||||
DROP TABLE IF EXISTS user_badges;
|
||||
DROP TABLE IF EXISTS badge_definitions;
|
||||
DROP TABLE IF EXISTS exp_history;
|
||||
DROP TABLE IF EXISTS level_configs;
|
||||
DROP TABLE IF EXISTS user_levels;
|
||||
```
|
||||
|
||||
## 验证迁移
|
||||
|
||||
执行以下查询验证表是否创建成功:
|
||||
|
||||
```sql
|
||||
SHOW TABLES LIKE '%level%';
|
||||
SHOW TABLES LIKE '%badge%';
|
||||
SHOW TABLES LIKE '%exp%';
|
||||
|
||||
-- 查看表结构
|
||||
DESCRIBE user_levels;
|
||||
DESCRIBE exp_history;
|
||||
DESCRIBE badge_definitions;
|
||||
DESCRIBE user_badges;
|
||||
DESCRIBE level_configs;
|
||||
|
||||
-- 验证初始数据
|
||||
SELECT COUNT(*) FROM level_configs; -- 应该是 10 条
|
||||
SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条
|
||||
SELECT COUNT(*) FROM user_levels; -- 应该等于用户数
|
||||
```
|
||||
|
||||
@@ -1,166 +1,166 @@
|
||||
-- ================================================================
|
||||
-- 证书系统数据库迁移脚本
|
||||
-- 创建日期: 2026-01-29
|
||||
-- 功能: 添加证书模板表和用户证书表
|
||||
-- ================================================================
|
||||
|
||||
-- 事务开始
|
||||
START TRANSACTION;
|
||||
|
||||
-- ================================================================
|
||||
-- 1. 创建证书模板表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS certificate_templates (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL COMMENT '模板名称',
|
||||
type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书',
|
||||
background_url VARCHAR(500) COMMENT '证书背景图URL',
|
||||
template_html TEXT COMMENT 'HTML模板内容',
|
||||
template_style TEXT COMMENT 'CSS样式',
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序顺序',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_type (type),
|
||||
INDEX idx_is_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表';
|
||||
|
||||
-- ================================================================
|
||||
-- 2. 创建用户证书表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS user_certificates (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
template_id INT NOT NULL COMMENT '模板ID',
|
||||
certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号',
|
||||
title VARCHAR(200) NOT NULL COMMENT '证书标题',
|
||||
description TEXT COMMENT '证书描述/成就说明',
|
||||
issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间',
|
||||
valid_until DATETIME COMMENT '有效期至(NULL表示永久)',
|
||||
|
||||
-- 关联信息
|
||||
course_id INT COMMENT '关联课程ID',
|
||||
exam_id INT COMMENT '关联考试ID',
|
||||
badge_id INT COMMENT '关联奖章ID',
|
||||
|
||||
-- 成绩信息
|
||||
score DECIMAL(5,2) COMMENT '考试分数',
|
||||
completion_rate DECIMAL(5,2) COMMENT '完成率',
|
||||
|
||||
-- 生成的文件
|
||||
pdf_url VARCHAR(500) COMMENT 'PDF文件URL',
|
||||
image_url VARCHAR(500) COMMENT '分享图片URL',
|
||||
|
||||
-- 元数据
|
||||
meta_data JSON COMMENT '扩展元数据',
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (template_id) REFERENCES certificate_templates(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_certificate_no (certificate_no),
|
||||
INDEX idx_course_id (course_id),
|
||||
INDEX idx_exam_id (exam_id),
|
||||
INDEX idx_issued_at (issued_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表';
|
||||
|
||||
-- ================================================================
|
||||
-- 3. 插入默认证书模板
|
||||
-- ================================================================
|
||||
INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES
|
||||
-- 课程结业证书模板
|
||||
('课程结业证书', 'course',
|
||||
'<div class="certificate">
|
||||
<div class="header">
|
||||
<div class="logo">考培练系统</div>
|
||||
<h1>结业证书</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||
<p class="content">已完成课程《{{course_name}}》的全部学习内容</p>
|
||||
<p class="completion">完成率:{{completion_rate}}%</p>
|
||||
<p class="date">颁发日期:{{issue_date}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||
<div class="qrcode">{{qrcode}}</div>
|
||||
</div>
|
||||
</div>',
|
||||
'.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||
.header { text-align: center; margin-bottom: 30px; }
|
||||
.header .logo { font-size: 24px; color: #667eea; font-weight: bold; }
|
||||
.header h1 { font-size: 36px; color: #333; margin: 20px 0; }
|
||||
.body { text-align: center; padding: 30px 60px; }
|
||||
.body .recipient { font-size: 20px; margin-bottom: 20px; }
|
||||
.body .content { font-size: 18px; color: #555; margin-bottom: 15px; }
|
||||
.body .completion { font-size: 16px; color: #667eea; }
|
||||
.body .date { font-size: 14px; color: #888; margin-top: 30px; }
|
||||
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; }
|
||||
.cert-no { font-size: 12px; color: #999; }
|
||||
.qrcode { width: 80px; height: 80px; }',
|
||||
TRUE, 1),
|
||||
|
||||
-- 考试合格证书模板
|
||||
('考试合格证书', 'exam',
|
||||
'<div class="certificate exam-cert">
|
||||
<div class="header">
|
||||
<div class="logo">考培练系统</div>
|
||||
<h1>考试合格证书</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||
<p class="content">在《{{exam_name}}》考试中成绩合格</p>
|
||||
<div class="score-badge">
|
||||
<span class="score">{{score}}</span>
|
||||
<span class="unit">分</span>
|
||||
</div>
|
||||
<p class="date">考试日期:{{exam_date}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||
<div class="qrcode">{{qrcode}}</div>
|
||||
</div>
|
||||
</div>',
|
||||
'.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||
.exam-cert .header h1 { color: #2e7d32; }
|
||||
.score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; }
|
||||
.score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; }
|
||||
.score-badge .unit { font-size: 18px; color: #666; }',
|
||||
TRUE, 2),
|
||||
|
||||
-- 成就证书模板
|
||||
('成就证书', 'achievement',
|
||||
'<div class="certificate achievement-cert">
|
||||
<div class="header">
|
||||
<div class="logo">考培练系统</div>
|
||||
<h1>成就证书</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||
<div class="achievement-icon">{{badge_icon}}</div>
|
||||
<p class="achievement-name">{{badge_name}}</p>
|
||||
<p class="achievement-desc">{{badge_description}}</p>
|
||||
<p class="date">获得日期:{{achieve_date}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||
<div class="qrcode">{{qrcode}}</div>
|
||||
</div>
|
||||
</div>',
|
||||
'.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||
.achievement-cert .header h1 { color: #e65100; }
|
||||
.achievement-icon { font-size: 64px; margin: 20px 0; }
|
||||
.achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; }
|
||||
.achievement-desc { font-size: 16px; color: #666; }',
|
||||
TRUE, 3);
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
|
||||
-- ================================================================
|
||||
-- 验证脚本
|
||||
-- ================================================================
|
||||
-- SELECT * FROM certificate_templates;
|
||||
-- SELECT COUNT(*) AS template_count FROM certificate_templates;
|
||||
-- ================================================================
|
||||
-- 证书系统数据库迁移脚本
|
||||
-- 创建日期: 2026-01-29
|
||||
-- 功能: 添加证书模板表和用户证书表
|
||||
-- ================================================================
|
||||
|
||||
-- 事务开始
|
||||
START TRANSACTION;
|
||||
|
||||
-- ================================================================
|
||||
-- 1. 创建证书模板表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS certificate_templates (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
name VARCHAR(100) NOT NULL COMMENT '模板名称',
|
||||
type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书',
|
||||
background_url VARCHAR(500) COMMENT '证书背景图URL',
|
||||
template_html TEXT COMMENT 'HTML模板内容',
|
||||
template_style TEXT COMMENT 'CSS样式',
|
||||
is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用',
|
||||
sort_order INT DEFAULT 0 COMMENT '排序顺序',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_type (type),
|
||||
INDEX idx_is_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表';
|
||||
|
||||
-- ================================================================
|
||||
-- 2. 创建用户证书表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS user_certificates (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
template_id INT NOT NULL COMMENT '模板ID',
|
||||
certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号',
|
||||
title VARCHAR(200) NOT NULL COMMENT '证书标题',
|
||||
description TEXT COMMENT '证书描述/成就说明',
|
||||
issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间',
|
||||
valid_until DATETIME COMMENT '有效期至(NULL表示永久)',
|
||||
|
||||
-- 关联信息
|
||||
course_id INT COMMENT '关联课程ID',
|
||||
exam_id INT COMMENT '关联考试ID',
|
||||
badge_id INT COMMENT '关联奖章ID',
|
||||
|
||||
-- 成绩信息
|
||||
score DECIMAL(5,2) COMMENT '考试分数',
|
||||
completion_rate DECIMAL(5,2) COMMENT '完成率',
|
||||
|
||||
-- 生成的文件
|
||||
pdf_url VARCHAR(500) COMMENT 'PDF文件URL',
|
||||
image_url VARCHAR(500) COMMENT '分享图片URL',
|
||||
|
||||
-- 元数据
|
||||
meta_data JSON COMMENT '扩展元数据',
|
||||
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (template_id) REFERENCES certificate_templates(id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_certificate_no (certificate_no),
|
||||
INDEX idx_course_id (course_id),
|
||||
INDEX idx_exam_id (exam_id),
|
||||
INDEX idx_issued_at (issued_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表';
|
||||
|
||||
-- ================================================================
|
||||
-- 3. 插入默认证书模板
|
||||
-- ================================================================
|
||||
INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES
|
||||
-- 课程结业证书模板
|
||||
('课程结业证书', 'course',
|
||||
'<div class="certificate">
|
||||
<div class="header">
|
||||
<div class="logo">考培练系统</div>
|
||||
<h1>结业证书</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||
<p class="content">已完成课程《{{course_name}}》的全部学习内容</p>
|
||||
<p class="completion">完成率:{{completion_rate}}%</p>
|
||||
<p class="date">颁发日期:{{issue_date}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||
<div class="qrcode">{{qrcode}}</div>
|
||||
</div>
|
||||
</div>',
|
||||
'.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||
.header { text-align: center; margin-bottom: 30px; }
|
||||
.header .logo { font-size: 24px; color: #667eea; font-weight: bold; }
|
||||
.header h1 { font-size: 36px; color: #333; margin: 20px 0; }
|
||||
.body { text-align: center; padding: 30px 60px; }
|
||||
.body .recipient { font-size: 20px; margin-bottom: 20px; }
|
||||
.body .content { font-size: 18px; color: #555; margin-bottom: 15px; }
|
||||
.body .completion { font-size: 16px; color: #667eea; }
|
||||
.body .date { font-size: 14px; color: #888; margin-top: 30px; }
|
||||
.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; }
|
||||
.cert-no { font-size: 12px; color: #999; }
|
||||
.qrcode { width: 80px; height: 80px; }',
|
||||
TRUE, 1),
|
||||
|
||||
-- 考试合格证书模板
|
||||
('考试合格证书', 'exam',
|
||||
'<div class="certificate exam-cert">
|
||||
<div class="header">
|
||||
<div class="logo">考培练系统</div>
|
||||
<h1>考试合格证书</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||
<p class="content">在《{{exam_name}}》考试中成绩合格</p>
|
||||
<div class="score-badge">
|
||||
<span class="score">{{score}}</span>
|
||||
<span class="unit">分</span>
|
||||
</div>
|
||||
<p class="date">考试日期:{{exam_date}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||
<div class="qrcode">{{qrcode}}</div>
|
||||
</div>
|
||||
</div>',
|
||||
'.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||
.exam-cert .header h1 { color: #2e7d32; }
|
||||
.score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; }
|
||||
.score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; }
|
||||
.score-badge .unit { font-size: 18px; color: #666; }',
|
||||
TRUE, 2),
|
||||
|
||||
-- 成就证书模板
|
||||
('成就证书', 'achievement',
|
||||
'<div class="certificate achievement-cert">
|
||||
<div class="header">
|
||||
<div class="logo">考培练系统</div>
|
||||
<h1>成就证书</h1>
|
||||
</div>
|
||||
<div class="body">
|
||||
<p class="recipient">兹证明 <strong>{{user_name}}</strong></p>
|
||||
<div class="achievement-icon">{{badge_icon}}</div>
|
||||
<p class="achievement-name">{{badge_name}}</p>
|
||||
<p class="achievement-desc">{{badge_description}}</p>
|
||||
<p class="date">获得日期:{{achieve_date}}</p>
|
||||
</div>
|
||||
<div class="footer">
|
||||
<div class="cert-no">证书编号:{{certificate_no}}</div>
|
||||
<div class="qrcode">{{qrcode}}</div>
|
||||
</div>
|
||||
</div>',
|
||||
'.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; }
|
||||
.achievement-cert .header h1 { color: #e65100; }
|
||||
.achievement-icon { font-size: 64px; margin: 20px 0; }
|
||||
.achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; }
|
||||
.achievement-desc { font-size: 16px; color: #666; }',
|
||||
TRUE, 3);
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
|
||||
-- ================================================================
|
||||
-- 验证脚本
|
||||
-- ================================================================
|
||||
-- SELECT * FROM certificate_templates;
|
||||
-- SELECT COUNT(*) AS template_count FROM certificate_templates;
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
-- =====================================================
|
||||
-- 钉钉免密登录功能 - 数据库迁移脚本
|
||||
-- 创建时间: 2026-01-28
|
||||
-- 说明: 为考培练系统添加钉钉免密登录支持
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 用户表添加 dingtalk_id 字段
|
||||
-- -----------------------------------------------------
|
||||
ALTER TABLE users ADD COLUMN dingtalk_id VARCHAR(64) UNIQUE COMMENT '钉钉用户ID';
|
||||
CREATE INDEX idx_users_dingtalk_id ON users(dingtalk_id);
|
||||
|
||||
|
||||
-- 2. 配置模板表添加钉钉配置项
|
||||
-- -----------------------------------------------------
|
||||
INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, is_required, is_secret, sort_order) VALUES
|
||||
('dingtalk', 'DINGTALK_APP_KEY', 'AppKey', '钉钉应用的AppKey(从钉钉开放平台获取)', 'string', 1, 0, 1),
|
||||
('dingtalk', 'DINGTALK_APP_SECRET', 'AppSecret', '钉钉应用的AppSecret(敏感信息)', 'string', 1, 1, 2),
|
||||
('dingtalk', 'DINGTALK_AGENT_ID', 'AgentId', '钉钉应用的AgentId', 'string', 1, 0, 3),
|
||||
('dingtalk', 'DINGTALK_CORP_ID', 'CorpId', '钉钉企业的CorpId', 'string', 1, 0, 4);
|
||||
|
||||
|
||||
-- 3. 功能开关表添加钉钉免密登录开关(默认禁用)
|
||||
-- -----------------------------------------------------
|
||||
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES
|
||||
(NULL, 'dingtalk_login', '钉钉免密登录', 'auth', 0, '启用后,用户可通过钉钉免密登录系统');
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- 回滚脚本(如需回滚,执行以下SQL)
|
||||
-- =====================================================
|
||||
/*
|
||||
-- 回滚步骤1: 删除功能开关
|
||||
DELETE FROM feature_switches WHERE feature_code = 'dingtalk_login';
|
||||
|
||||
-- 回滚步骤2: 删除配置模板
|
||||
DELETE FROM config_templates WHERE config_group = 'dingtalk';
|
||||
|
||||
-- 回滚步骤3: 删除用户表字段
|
||||
ALTER TABLE users DROP INDEX idx_users_dingtalk_id;
|
||||
ALTER TABLE users DROP COLUMN dingtalk_id;
|
||||
*/
|
||||
-- =====================================================
|
||||
-- 钉钉免密登录功能 - 数据库迁移脚本
|
||||
-- 创建时间: 2026-01-28
|
||||
-- 说明: 为考培练系统添加钉钉免密登录支持
|
||||
-- =====================================================
|
||||
|
||||
-- 1. 用户表添加 dingtalk_id 字段
|
||||
-- -----------------------------------------------------
|
||||
ALTER TABLE users ADD COLUMN dingtalk_id VARCHAR(64) UNIQUE COMMENT '钉钉用户ID';
|
||||
CREATE INDEX idx_users_dingtalk_id ON users(dingtalk_id);
|
||||
|
||||
|
||||
-- 2. 配置模板表添加钉钉配置项
|
||||
-- -----------------------------------------------------
|
||||
INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, is_required, is_secret, sort_order) VALUES
|
||||
('dingtalk', 'DINGTALK_APP_KEY', 'AppKey', '钉钉应用的AppKey(从钉钉开放平台获取)', 'string', 1, 0, 1),
|
||||
('dingtalk', 'DINGTALK_APP_SECRET', 'AppSecret', '钉钉应用的AppSecret(敏感信息)', 'string', 1, 1, 2),
|
||||
('dingtalk', 'DINGTALK_AGENT_ID', 'AgentId', '钉钉应用的AgentId', 'string', 1, 0, 3),
|
||||
('dingtalk', 'DINGTALK_CORP_ID', 'CorpId', '钉钉企业的CorpId', 'string', 1, 0, 4);
|
||||
|
||||
|
||||
-- 3. 功能开关表添加钉钉免密登录开关(默认禁用)
|
||||
-- -----------------------------------------------------
|
||||
INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES
|
||||
(NULL, 'dingtalk_login', '钉钉免密登录', 'auth', 0, '启用后,用户可通过钉钉免密登录系统');
|
||||
|
||||
|
||||
-- =====================================================
|
||||
-- 回滚脚本(如需回滚,执行以下SQL)
|
||||
-- =====================================================
|
||||
/*
|
||||
-- 回滚步骤1: 删除功能开关
|
||||
DELETE FROM feature_switches WHERE feature_code = 'dingtalk_login';
|
||||
|
||||
-- 回滚步骤2: 删除配置模板
|
||||
DELETE FROM config_templates WHERE config_group = 'dingtalk';
|
||||
|
||||
-- 回滚步骤3: 删除用户表字段
|
||||
ALTER TABLE users DROP INDEX idx_users_dingtalk_id;
|
||||
ALTER TABLE users DROP COLUMN dingtalk_id;
|
||||
*/
|
||||
|
||||
@@ -1,192 +1,192 @@
|
||||
-- =====================================================
|
||||
-- 等级与奖章系统数据库迁移脚本
|
||||
-- 版本: 1.0.0
|
||||
-- 创建时间: 2026-01-29
|
||||
-- 说明: 添加用户等级系统和奖章系统相关表
|
||||
-- =====================================================
|
||||
|
||||
-- 使用事务确保原子性
|
||||
START TRANSACTION;
|
||||
|
||||
-- =====================================================
|
||||
-- 1. 用户等级表 (user_levels)
|
||||
-- 存储用户的等级和经验值信息
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_levels (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
level INT NOT NULL DEFAULT 1 COMMENT '当前等级',
|
||||
exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值',
|
||||
total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值',
|
||||
login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数',
|
||||
max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数',
|
||||
last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)',
|
||||
last_checkin_at DATETIME NULL COMMENT '最后签到时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_id (user_id),
|
||||
INDEX idx_level (level),
|
||||
INDEX idx_total_exp (total_exp),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表';
|
||||
|
||||
-- =====================================================
|
||||
-- 2. 经验值历史表 (exp_history)
|
||||
-- 记录每次经验值变化的详细信息
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS exp_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)',
|
||||
exp_type VARCHAR(50) NOT NULL COMMENT '类型:exam/practice/training/task/login/badge/other',
|
||||
source_id INT NULL COMMENT '来源记录ID(如考试ID、练习ID等)',
|
||||
description VARCHAR(255) NOT NULL COMMENT '描述',
|
||||
level_before INT NULL COMMENT '变化前等级',
|
||||
level_after INT NULL COMMENT '变化后等级',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_exp_type (exp_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表';
|
||||
|
||||
-- =====================================================
|
||||
-- 3. 奖章定义表 (badge_definitions)
|
||||
-- 定义所有可获得的奖章及其解锁条件
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS badge_definitions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)',
|
||||
name VARCHAR(100) NOT NULL COMMENT '奖章名称',
|
||||
description VARCHAR(255) NOT NULL COMMENT '奖章描述',
|
||||
icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称(Element Plus 图标)',
|
||||
category VARCHAR(50) NOT NULL COMMENT '分类:learning/exam/practice/streak/special',
|
||||
condition_type VARCHAR(50) NOT NULL COMMENT '条件类型:count/score/streak/level/duration',
|
||||
condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)',
|
||||
condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值',
|
||||
exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_code (code),
|
||||
INDEX idx_category (category),
|
||||
INDEX idx_is_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表';
|
||||
|
||||
-- =====================================================
|
||||
-- 4. 用户奖章表 (user_badges)
|
||||
-- 记录用户已解锁的奖章
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_badges (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
badge_id INT NOT NULL COMMENT '奖章ID',
|
||||
unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间',
|
||||
is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户',
|
||||
notified_at DATETIME NULL COMMENT '通知时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_badge (user_id, badge_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_badge_id (badge_id),
|
||||
INDEX idx_unlocked_at (unlocked_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表';
|
||||
|
||||
-- =====================================================
|
||||
-- 5. 等级配置表 (level_configs)
|
||||
-- 定义每个等级所需的经验值和称号
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS level_configs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
level INT NOT NULL COMMENT '等级',
|
||||
exp_required INT NOT NULL COMMENT '升到此级所需经验值',
|
||||
total_exp_required INT NOT NULL COMMENT '累计所需经验值',
|
||||
title VARCHAR(50) NOT NULL COMMENT '等级称号',
|
||||
color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_level (level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表';
|
||||
|
||||
-- =====================================================
|
||||
-- 6. 插入等级配置数据
|
||||
-- =====================================================
|
||||
INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES
|
||||
(1, 0, 0, '初学者', '#909399'),
|
||||
(2, 100, 100, '入门学徒', '#67C23A'),
|
||||
(3, 200, 300, '勤奋学员', '#67C23A'),
|
||||
(4, 400, 700, '进阶学员', '#409EFF'),
|
||||
(5, 600, 1300, '优秀学员', '#409EFF'),
|
||||
(6, 1000, 2300, '精英学员', '#E6A23C'),
|
||||
(7, 1500, 3800, '资深学员', '#E6A23C'),
|
||||
(8, 2000, 5800, '学习达人', '#F56C6C'),
|
||||
(9, 3000, 8800, '学霸', '#F56C6C'),
|
||||
(10, 5000, 13800, '大师', '#9B59B6');
|
||||
|
||||
-- =====================================================
|
||||
-- 7. 插入奖章定义数据
|
||||
-- =====================================================
|
||||
|
||||
-- 7.1 学习进度类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101),
|
||||
('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102),
|
||||
('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103),
|
||||
('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104);
|
||||
|
||||
-- 7.2 考试成绩类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201),
|
||||
('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202),
|
||||
('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203),
|
||||
('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204),
|
||||
('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205);
|
||||
|
||||
-- 7.3 练习时长类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301),
|
||||
('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302),
|
||||
('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303),
|
||||
('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304);
|
||||
|
||||
-- 7.4 连续打卡类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401),
|
||||
('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402),
|
||||
('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403),
|
||||
('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404);
|
||||
|
||||
-- 7.5 特殊成就类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501),
|
||||
('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502),
|
||||
('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503),
|
||||
('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504);
|
||||
|
||||
-- =====================================================
|
||||
-- 8. 为现有用户初始化等级数据
|
||||
-- =====================================================
|
||||
INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date)
|
||||
SELECT
|
||||
id as user_id,
|
||||
1 as level,
|
||||
0 as exp,
|
||||
0 as total_exp,
|
||||
0 as login_streak,
|
||||
NULL as last_login_date
|
||||
FROM users
|
||||
WHERE is_deleted = 0
|
||||
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
|
||||
-- =====================================================
|
||||
-- 回滚脚本(如需回滚,执行以下语句)
|
||||
-- =====================================================
|
||||
-- DROP TABLE IF EXISTS user_badges;
|
||||
-- DROP TABLE IF EXISTS badge_definitions;
|
||||
-- DROP TABLE IF EXISTS exp_history;
|
||||
-- DROP TABLE IF EXISTS level_configs;
|
||||
-- DROP TABLE IF EXISTS user_levels;
|
||||
-- =====================================================
|
||||
-- 等级与奖章系统数据库迁移脚本
|
||||
-- 版本: 1.0.0
|
||||
-- 创建时间: 2026-01-29
|
||||
-- 说明: 添加用户等级系统和奖章系统相关表
|
||||
-- =====================================================
|
||||
|
||||
-- 使用事务确保原子性
|
||||
START TRANSACTION;
|
||||
|
||||
-- =====================================================
|
||||
-- 1. 用户等级表 (user_levels)
|
||||
-- 存储用户的等级和经验值信息
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_levels (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
level INT NOT NULL DEFAULT 1 COMMENT '当前等级',
|
||||
exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值',
|
||||
total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值',
|
||||
login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数',
|
||||
max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数',
|
||||
last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)',
|
||||
last_checkin_at DATETIME NULL COMMENT '最后签到时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_id (user_id),
|
||||
INDEX idx_level (level),
|
||||
INDEX idx_total_exp (total_exp),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表';
|
||||
|
||||
-- =====================================================
|
||||
-- 2. 经验值历史表 (exp_history)
|
||||
-- 记录每次经验值变化的详细信息
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS exp_history (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)',
|
||||
exp_type VARCHAR(50) NOT NULL COMMENT '类型:exam/practice/training/task/login/badge/other',
|
||||
source_id INT NULL COMMENT '来源记录ID(如考试ID、练习ID等)',
|
||||
description VARCHAR(255) NOT NULL COMMENT '描述',
|
||||
level_before INT NULL COMMENT '变化前等级',
|
||||
level_after INT NULL COMMENT '变化后等级',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_exp_type (exp_type),
|
||||
INDEX idx_created_at (created_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表';
|
||||
|
||||
-- =====================================================
|
||||
-- 3. 奖章定义表 (badge_definitions)
|
||||
-- 定义所有可获得的奖章及其解锁条件
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS badge_definitions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)',
|
||||
name VARCHAR(100) NOT NULL COMMENT '奖章名称',
|
||||
description VARCHAR(255) NOT NULL COMMENT '奖章描述',
|
||||
icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称(Element Plus 图标)',
|
||||
category VARCHAR(50) NOT NULL COMMENT '分类:learning/exam/practice/streak/special',
|
||||
condition_type VARCHAR(50) NOT NULL COMMENT '条件类型:count/score/streak/level/duration',
|
||||
condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)',
|
||||
condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值',
|
||||
exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值',
|
||||
sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序',
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_code (code),
|
||||
INDEX idx_category (category),
|
||||
INDEX idx_is_active (is_active)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表';
|
||||
|
||||
-- =====================================================
|
||||
-- 4. 用户奖章表 (user_badges)
|
||||
-- 记录用户已解锁的奖章
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS user_badges (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
badge_id INT NOT NULL COMMENT '奖章ID',
|
||||
unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间',
|
||||
is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户',
|
||||
notified_at DATETIME NULL COMMENT '通知时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_user_badge (user_id, badge_id),
|
||||
INDEX idx_user_id (user_id),
|
||||
INDEX idx_badge_id (badge_id),
|
||||
INDEX idx_unlocked_at (unlocked_at),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表';
|
||||
|
||||
-- =====================================================
|
||||
-- 5. 等级配置表 (level_configs)
|
||||
-- 定义每个等级所需的经验值和称号
|
||||
-- =====================================================
|
||||
CREATE TABLE IF NOT EXISTS level_configs (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
level INT NOT NULL COMMENT '等级',
|
||||
exp_required INT NOT NULL COMMENT '升到此级所需经验值',
|
||||
total_exp_required INT NOT NULL COMMENT '累计所需经验值',
|
||||
title VARCHAR(50) NOT NULL COMMENT '等级称号',
|
||||
color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uk_level (level)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表';
|
||||
|
||||
-- =====================================================
|
||||
-- 6. 插入等级配置数据
|
||||
-- =====================================================
|
||||
INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES
|
||||
(1, 0, 0, '初学者', '#909399'),
|
||||
(2, 100, 100, '入门学徒', '#67C23A'),
|
||||
(3, 200, 300, '勤奋学员', '#67C23A'),
|
||||
(4, 400, 700, '进阶学员', '#409EFF'),
|
||||
(5, 600, 1300, '优秀学员', '#409EFF'),
|
||||
(6, 1000, 2300, '精英学员', '#E6A23C'),
|
||||
(7, 1500, 3800, '资深学员', '#E6A23C'),
|
||||
(8, 2000, 5800, '学习达人', '#F56C6C'),
|
||||
(9, 3000, 8800, '学霸', '#F56C6C'),
|
||||
(10, 5000, 13800, '大师', '#9B59B6');
|
||||
|
||||
-- =====================================================
|
||||
-- 7. 插入奖章定义数据
|
||||
-- =====================================================
|
||||
|
||||
-- 7.1 学习进度类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101),
|
||||
('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102),
|
||||
('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103),
|
||||
('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104);
|
||||
|
||||
-- 7.2 考试成绩类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201),
|
||||
('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202),
|
||||
('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203),
|
||||
('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204),
|
||||
('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205);
|
||||
|
||||
-- 7.3 练习时长类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301),
|
||||
('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302),
|
||||
('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303),
|
||||
('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304);
|
||||
|
||||
-- 7.4 连续打卡类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401),
|
||||
('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402),
|
||||
('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403),
|
||||
('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404);
|
||||
|
||||
-- 7.5 特殊成就类奖章
|
||||
INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES
|
||||
('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501),
|
||||
('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502),
|
||||
('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503),
|
||||
('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504);
|
||||
|
||||
-- =====================================================
|
||||
-- 8. 为现有用户初始化等级数据
|
||||
-- =====================================================
|
||||
INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date)
|
||||
SELECT
|
||||
id as user_id,
|
||||
1 as level,
|
||||
0 as exp,
|
||||
0 as total_exp,
|
||||
0 as login_streak,
|
||||
NULL as last_login_date
|
||||
FROM users
|
||||
WHERE is_deleted = 0
|
||||
ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP;
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
|
||||
-- =====================================================
|
||||
-- 回滚脚本(如需回滚,执行以下语句)
|
||||
-- =====================================================
|
||||
-- DROP TABLE IF EXISTS user_badges;
|
||||
-- DROP TABLE IF EXISTS badge_definitions;
|
||||
-- DROP TABLE IF EXISTS exp_history;
|
||||
-- DROP TABLE IF EXISTS level_configs;
|
||||
-- DROP TABLE IF EXISTS user_levels;
|
||||
|
||||
@@ -1,186 +1,186 @@
|
||||
-- ============================================================================
|
||||
-- 双人对练功能数据库迁移脚本
|
||||
-- 版本: 2026-01-28
|
||||
-- 功能: 新增对练房间表,扩展现有表支持多人对练
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. 创建对练房间表 practice_rooms
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `practice_rooms` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '房间ID',
|
||||
`room_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '6位邀请码',
|
||||
`room_name` VARCHAR(200) COMMENT '房间名称',
|
||||
|
||||
-- 场景信息
|
||||
`scene_id` INT COMMENT '关联场景ID',
|
||||
`scene_name` VARCHAR(200) COMMENT '场景名称',
|
||||
`scene_type` VARCHAR(50) COMMENT '场景类型',
|
||||
`scene_background` TEXT COMMENT '场景背景',
|
||||
|
||||
-- 角色设置
|
||||
`role_a_name` VARCHAR(50) DEFAULT '角色A' COMMENT '角色A名称(如销售顾问)',
|
||||
`role_b_name` VARCHAR(50) DEFAULT '角色B' COMMENT '角色B名称(如顾客)',
|
||||
`role_a_description` TEXT COMMENT '角色A描述',
|
||||
`role_b_description` TEXT COMMENT '角色B描述',
|
||||
|
||||
-- 参与者信息
|
||||
`host_user_id` INT NOT NULL COMMENT '房主用户ID',
|
||||
`guest_user_id` INT COMMENT '加入者用户ID',
|
||||
`host_role` VARCHAR(10) DEFAULT 'A' COMMENT '房主选择的角色(A/B)',
|
||||
`max_participants` INT DEFAULT 2 COMMENT '最大参与人数',
|
||||
|
||||
-- 状态和时间
|
||||
`status` VARCHAR(20) DEFAULT 'waiting' COMMENT '状态: waiting/ready/practicing/completed/canceled',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`started_at` DATETIME COMMENT '开始时间',
|
||||
`ended_at` DATETIME COMMENT '结束时间',
|
||||
`duration_seconds` INT DEFAULT 0 COMMENT '对练时长(秒)',
|
||||
|
||||
-- 对话统计
|
||||
`total_turns` INT DEFAULT 0 COMMENT '总对话轮次',
|
||||
`role_a_turns` INT DEFAULT 0 COMMENT '角色A发言次数',
|
||||
`role_b_turns` INT DEFAULT 0 COMMENT '角色B发言次数',
|
||||
|
||||
-- 软删除
|
||||
`is_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除',
|
||||
`deleted_at` DATETIME COMMENT '删除时间',
|
||||
|
||||
-- 索引
|
||||
INDEX `idx_room_code` (`room_code`),
|
||||
INDEX `idx_host_user` (`host_user_id`),
|
||||
INDEX `idx_guest_user` (`guest_user_id`),
|
||||
INDEX `idx_status` (`status`),
|
||||
INDEX `idx_created_at` (`created_at`),
|
||||
|
||||
-- 外键(可选,根据实际需求决定是否启用)
|
||||
-- FOREIGN KEY (`scene_id`) REFERENCES `practice_scenes`(`id`) ON DELETE SET NULL,
|
||||
-- FOREIGN KEY (`host_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
-- FOREIGN KEY (`guest_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
|
||||
CONSTRAINT `chk_host_role` CHECK (`host_role` IN ('A', 'B'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='双人对练房间表';
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. 扩展对话记录表 practice_dialogues
|
||||
-- ============================================================================
|
||||
|
||||
-- 添加用户ID字段(区分说话人)
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '说话人用户ID' AFTER `speaker`;
|
||||
|
||||
-- 添加角色名称字段
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `role_name` VARCHAR(50) COMMENT '角色名称(如销售顾问/顾客)' AFTER `user_id`;
|
||||
|
||||
-- 添加房间ID字段
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`;
|
||||
|
||||
-- 添加消息类型字段
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text/voice/system' AFTER `content`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD INDEX IF NOT EXISTS `idx_user_id` (`user_id`),
|
||||
ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. 扩展会话表 practice_sessions
|
||||
-- ============================================================================
|
||||
|
||||
-- 添加房间ID字段
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `scene_type`;
|
||||
|
||||
-- 添加参与者角色字段
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD COLUMN IF NOT EXISTS `participant_role` VARCHAR(50) COMMENT '用户在房间中的角色' AFTER `room_id`;
|
||||
|
||||
-- 添加会话类型字段
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD COLUMN IF NOT EXISTS `session_type` VARCHAR(20) DEFAULT 'solo' COMMENT '会话类型: solo/duo' AFTER `participant_role`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. 扩展报告表 practice_reports(支持双人报告)
|
||||
-- ============================================================================
|
||||
|
||||
-- 添加房间ID字段
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`;
|
||||
|
||||
-- 添加用户ID字段(双人模式下每人一份报告)
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '用户ID' AFTER `room_id`;
|
||||
|
||||
-- 添加报告类型字段
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `report_type` VARCHAR(20) DEFAULT 'solo' COMMENT '报告类型: solo/duo' AFTER `user_id`;
|
||||
|
||||
-- 添加对方评价字段(双人模式)
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `partner_feedback` JSON COMMENT '对方表现评价' AFTER `suggestions`;
|
||||
|
||||
-- 添加互动质量评分
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `interaction_score` INT COMMENT '互动质量评分(0-100)' AFTER `partner_feedback`;
|
||||
|
||||
-- 修改唯一索引(允许同一session有多个报告)
|
||||
-- 注意:需要先删除旧的唯一索引
|
||||
-- ALTER TABLE `practice_reports` DROP INDEX `session_id`;
|
||||
-- ALTER TABLE `practice_reports` ADD UNIQUE INDEX `idx_session_user` (`session_id`, `user_id`);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. 创建房间消息表(用于实时同步)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `practice_room_messages` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
|
||||
`room_id` INT NOT NULL COMMENT '房间ID',
|
||||
`user_id` INT COMMENT '发送者用户ID(系统消息为NULL)',
|
||||
`message_type` VARCHAR(20) NOT NULL COMMENT '消息类型: chat/system/join/leave/start/end',
|
||||
`content` TEXT COMMENT '消息内容',
|
||||
`role_name` VARCHAR(50) COMMENT '角色名称',
|
||||
`sequence` INT NOT NULL COMMENT '消息序号',
|
||||
`created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒精度)',
|
||||
|
||||
INDEX `idx_room_id` (`room_id`),
|
||||
INDEX `idx_room_sequence` (`room_id`, `sequence`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间实时消息表';
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 回滚脚本(如需回滚,执行以下语句)
|
||||
-- ============================================================================
|
||||
/*
|
||||
-- 删除新增的表
|
||||
DROP TABLE IF EXISTS `practice_room_messages`;
|
||||
DROP TABLE IF EXISTS `practice_rooms`;
|
||||
|
||||
-- 删除 practice_dialogues 新增的列
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `user_id`;
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `role_name`;
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `room_id`;
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `message_type`;
|
||||
|
||||
-- 删除 practice_sessions 新增的列
|
||||
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `room_id`;
|
||||
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `participant_role`;
|
||||
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `session_type`;
|
||||
|
||||
-- 删除 practice_reports 新增的列
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `room_id`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `user_id`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `report_type`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `partner_feedback`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `interaction_score`;
|
||||
*/
|
||||
-- ============================================================================
|
||||
-- 双人对练功能数据库迁移脚本
|
||||
-- 版本: 2026-01-28
|
||||
-- 功能: 新增对练房间表,扩展现有表支持多人对练
|
||||
-- ============================================================================
|
||||
|
||||
-- ============================================================================
|
||||
-- 1. 创建对练房间表 practice_rooms
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `practice_rooms` (
|
||||
`id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '房间ID',
|
||||
`room_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '6位邀请码',
|
||||
`room_name` VARCHAR(200) COMMENT '房间名称',
|
||||
|
||||
-- 场景信息
|
||||
`scene_id` INT COMMENT '关联场景ID',
|
||||
`scene_name` VARCHAR(200) COMMENT '场景名称',
|
||||
`scene_type` VARCHAR(50) COMMENT '场景类型',
|
||||
`scene_background` TEXT COMMENT '场景背景',
|
||||
|
||||
-- 角色设置
|
||||
`role_a_name` VARCHAR(50) DEFAULT '角色A' COMMENT '角色A名称(如销售顾问)',
|
||||
`role_b_name` VARCHAR(50) DEFAULT '角色B' COMMENT '角色B名称(如顾客)',
|
||||
`role_a_description` TEXT COMMENT '角色A描述',
|
||||
`role_b_description` TEXT COMMENT '角色B描述',
|
||||
|
||||
-- 参与者信息
|
||||
`host_user_id` INT NOT NULL COMMENT '房主用户ID',
|
||||
`guest_user_id` INT COMMENT '加入者用户ID',
|
||||
`host_role` VARCHAR(10) DEFAULT 'A' COMMENT '房主选择的角色(A/B)',
|
||||
`max_participants` INT DEFAULT 2 COMMENT '最大参与人数',
|
||||
|
||||
-- 状态和时间
|
||||
`status` VARCHAR(20) DEFAULT 'waiting' COMMENT '状态: waiting/ready/practicing/completed/canceled',
|
||||
`created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||
`started_at` DATETIME COMMENT '开始时间',
|
||||
`ended_at` DATETIME COMMENT '结束时间',
|
||||
`duration_seconds` INT DEFAULT 0 COMMENT '对练时长(秒)',
|
||||
|
||||
-- 对话统计
|
||||
`total_turns` INT DEFAULT 0 COMMENT '总对话轮次',
|
||||
`role_a_turns` INT DEFAULT 0 COMMENT '角色A发言次数',
|
||||
`role_b_turns` INT DEFAULT 0 COMMENT '角色B发言次数',
|
||||
|
||||
-- 软删除
|
||||
`is_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除',
|
||||
`deleted_at` DATETIME COMMENT '删除时间',
|
||||
|
||||
-- 索引
|
||||
INDEX `idx_room_code` (`room_code`),
|
||||
INDEX `idx_host_user` (`host_user_id`),
|
||||
INDEX `idx_guest_user` (`guest_user_id`),
|
||||
INDEX `idx_status` (`status`),
|
||||
INDEX `idx_created_at` (`created_at`),
|
||||
|
||||
-- 外键(可选,根据实际需求决定是否启用)
|
||||
-- FOREIGN KEY (`scene_id`) REFERENCES `practice_scenes`(`id`) ON DELETE SET NULL,
|
||||
-- FOREIGN KEY (`host_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE,
|
||||
-- FOREIGN KEY (`guest_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL
|
||||
|
||||
CONSTRAINT `chk_host_role` CHECK (`host_role` IN ('A', 'B'))
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='双人对练房间表';
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 2. 扩展对话记录表 practice_dialogues
|
||||
-- ============================================================================
|
||||
|
||||
-- 添加用户ID字段(区分说话人)
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '说话人用户ID' AFTER `speaker`;
|
||||
|
||||
-- 添加角色名称字段
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `role_name` VARCHAR(50) COMMENT '角色名称(如销售顾问/顾客)' AFTER `user_id`;
|
||||
|
||||
-- 添加房间ID字段
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`;
|
||||
|
||||
-- 添加消息类型字段
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD COLUMN IF NOT EXISTS `message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text/voice/system' AFTER `content`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `practice_dialogues`
|
||||
ADD INDEX IF NOT EXISTS `idx_user_id` (`user_id`),
|
||||
ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 3. 扩展会话表 practice_sessions
|
||||
-- ============================================================================
|
||||
|
||||
-- 添加房间ID字段
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `scene_type`;
|
||||
|
||||
-- 添加参与者角色字段
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD COLUMN IF NOT EXISTS `participant_role` VARCHAR(50) COMMENT '用户在房间中的角色' AFTER `room_id`;
|
||||
|
||||
-- 添加会话类型字段
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD COLUMN IF NOT EXISTS `session_type` VARCHAR(20) DEFAULT 'solo' COMMENT '会话类型: solo/duo' AFTER `participant_role`;
|
||||
|
||||
-- 添加索引
|
||||
ALTER TABLE `practice_sessions`
|
||||
ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 4. 扩展报告表 practice_reports(支持双人报告)
|
||||
-- ============================================================================
|
||||
|
||||
-- 添加房间ID字段
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`;
|
||||
|
||||
-- 添加用户ID字段(双人模式下每人一份报告)
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '用户ID' AFTER `room_id`;
|
||||
|
||||
-- 添加报告类型字段
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `report_type` VARCHAR(20) DEFAULT 'solo' COMMENT '报告类型: solo/duo' AFTER `user_id`;
|
||||
|
||||
-- 添加对方评价字段(双人模式)
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `partner_feedback` JSON COMMENT '对方表现评价' AFTER `suggestions`;
|
||||
|
||||
-- 添加互动质量评分
|
||||
ALTER TABLE `practice_reports`
|
||||
ADD COLUMN IF NOT EXISTS `interaction_score` INT COMMENT '互动质量评分(0-100)' AFTER `partner_feedback`;
|
||||
|
||||
-- 修改唯一索引(允许同一session有多个报告)
|
||||
-- 注意:需要先删除旧的唯一索引
|
||||
-- ALTER TABLE `practice_reports` DROP INDEX `session_id`;
|
||||
-- ALTER TABLE `practice_reports` ADD UNIQUE INDEX `idx_session_user` (`session_id`, `user_id`);
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 5. 创建房间消息表(用于实时同步)
|
||||
-- ============================================================================
|
||||
CREATE TABLE IF NOT EXISTS `practice_room_messages` (
|
||||
`id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID',
|
||||
`room_id` INT NOT NULL COMMENT '房间ID',
|
||||
`user_id` INT COMMENT '发送者用户ID(系统消息为NULL)',
|
||||
`message_type` VARCHAR(20) NOT NULL COMMENT '消息类型: chat/system/join/leave/start/end',
|
||||
`content` TEXT COMMENT '消息内容',
|
||||
`role_name` VARCHAR(50) COMMENT '角色名称',
|
||||
`sequence` INT NOT NULL COMMENT '消息序号',
|
||||
`created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒精度)',
|
||||
|
||||
INDEX `idx_room_id` (`room_id`),
|
||||
INDEX `idx_room_sequence` (`room_id`, `sequence`),
|
||||
INDEX `idx_created_at` (`created_at`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间实时消息表';
|
||||
|
||||
|
||||
-- ============================================================================
|
||||
-- 回滚脚本(如需回滚,执行以下语句)
|
||||
-- ============================================================================
|
||||
/*
|
||||
-- 删除新增的表
|
||||
DROP TABLE IF EXISTS `practice_room_messages`;
|
||||
DROP TABLE IF EXISTS `practice_rooms`;
|
||||
|
||||
-- 删除 practice_dialogues 新增的列
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `user_id`;
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `role_name`;
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `room_id`;
|
||||
ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `message_type`;
|
||||
|
||||
-- 删除 practice_sessions 新增的列
|
||||
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `room_id`;
|
||||
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `participant_role`;
|
||||
ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `session_type`;
|
||||
|
||||
-- 删除 practice_reports 新增的列
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `room_id`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `user_id`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `report_type`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `partner_feedback`;
|
||||
ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `interaction_score`;
|
||||
*/
|
||||
|
||||
@@ -53,4 +53,9 @@ jsonschema>=4.0.0
|
||||
|
||||
# PDF 文档提取
|
||||
PyPDF2>=3.0.0
|
||||
python-docx>=1.0.0
|
||||
python-docx>=1.0.0
|
||||
|
||||
# 证书生成
|
||||
Pillow>=10.0.0
|
||||
qrcode>=7.4.0
|
||||
weasyprint>=60.0
|
||||
Reference in New Issue
Block a user