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 数据大屏路由
|
# dashboard_router 数据大屏路由
|
||||||
from .endpoints.dashboard import router as dashboard_router
|
from .endpoints.dashboard import router as dashboard_router
|
||||||
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
|
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"]
|
__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.core.deps import get_current_active_user as get_current_user, get_db
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
from app.models.course import Course, CourseStatus
|
from app.models.course import Course, CourseStatus
|
||||||
|
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
|
||||||
from app.schemas.base import ResponseModel
|
from app.schemas.base import ResponseModel
|
||||||
|
|
||||||
router = APIRouter(prefix="/admin")
|
router = APIRouter(prefix="/admin")
|
||||||
@@ -61,18 +62,32 @@ async def get_dashboard_stats(
|
|||||||
.where(Course.status == CourseStatus.PUBLISHED)
|
.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
|
total_exams = 0
|
||||||
avg_score = 0.0
|
avg_score = 0.0
|
||||||
pass_rate = "0%"
|
pass_rate = "0%"
|
||||||
|
|
||||||
# 学习时长统计(如果有学习记录表的话)
|
# 学习时长统计 - 从用户课程进度表获取
|
||||||
total_learning_hours = 0
|
total_study_seconds = await db.scalar(
|
||||||
avg_learning_hours = 0.0
|
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0))
|
||||||
active_rate = "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 = {
|
stats = {
|
||||||
@@ -195,10 +210,28 @@ async def get_course_completion_data(
|
|||||||
for course_name, course_id in courses:
|
for course_name, course_id in courses:
|
||||||
course_names.append(course_name)
|
course_names.append(course_name)
|
||||||
|
|
||||||
# TODO: 根据用户课程进度表计算完成率
|
# 根据用户课程进度表计算完成率
|
||||||
# 这里暂时生成模拟数据
|
# 统计该课程的完成用户数和总学习用户数
|
||||||
import random
|
stats_result = await db.execute(
|
||||||
completion_rate = random.randint(60, 95)
|
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)
|
completion_rates.append(completion_rate)
|
||||||
|
|
||||||
return ResponseModel(
|
return ResponseModel(
|
||||||
|
|||||||
@@ -149,12 +149,19 @@ async def get_certificate_image(
|
|||||||
|
|
||||||
|
|
||||||
@router.get("/{cert_id}/download")
|
@router.get("/{cert_id}/download")
|
||||||
async def download_certificate_pdf(
|
async def download_certificate(
|
||||||
cert_id: int,
|
cert_id: int,
|
||||||
|
format: str = Query("pdf", description="下载格式: pdf 或 png"),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
current_user: User = Depends(get_current_user)
|
current_user: User = Depends(get_current_user)
|
||||||
):
|
):
|
||||||
"""下载证书PDF"""
|
"""
|
||||||
|
下载证书
|
||||||
|
|
||||||
|
支持 PDF 和 PNG 两种格式
|
||||||
|
- PDF: 高质量打印版本(需要安装 weasyprint)
|
||||||
|
- PNG: 图片版本
|
||||||
|
"""
|
||||||
service = CertificateService(db)
|
service = CertificateService(db)
|
||||||
cert = await service.get_certificate_by_id(cert_id)
|
cert = await service.get_certificate_by_id(cert_id)
|
||||||
|
|
||||||
@@ -164,8 +171,8 @@ async def download_certificate_pdf(
|
|||||||
detail="证书不存在"
|
detail="证书不存在"
|
||||||
)
|
)
|
||||||
|
|
||||||
# 如果已有PDF URL则重定向
|
# 如果已有缓存的 PDF/图片 URL 则返回
|
||||||
if cert.get("pdf_url"):
|
if format.lower() == "pdf" and cert.get("pdf_url"):
|
||||||
return {
|
return {
|
||||||
"code": 200,
|
"code": 200,
|
||||||
"message": "success",
|
"message": "success",
|
||||||
@@ -174,18 +181,36 @@ async def download_certificate_pdf(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# 否则返回图片作为替代
|
if format.lower() == "png" and cert.get("image_url"):
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"download_url": cert["image_url"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# 动态生成证书文件
|
||||||
try:
|
try:
|
||||||
base_url = "https://kpl.example.com/certificates"
|
from app.core.config import settings
|
||||||
image_bytes = await service.generate_certificate_image(cert_id, base_url)
|
base_url = settings.PUBLIC_DOMAIN + "/certificates"
|
||||||
|
|
||||||
|
content, filename, mime_type = await service.download_certificate(
|
||||||
|
cert_id, format, base_url
|
||||||
|
)
|
||||||
|
|
||||||
return StreamingResponse(
|
return StreamingResponse(
|
||||||
io.BytesIO(image_bytes),
|
io.BytesIO(content),
|
||||||
media_type="image/png",
|
media_type=mime_type,
|
||||||
headers={
|
headers={
|
||||||
"Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png"
|
"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:
|
except Exception as e:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||||
|
|||||||
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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -676,3 +676,42 @@ async def get_my_rooms(
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{room_code}/report", summary="获取对练报告")
|
||||||
|
async def get_practice_report(
|
||||||
|
room_code: str,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
current_user: User = Depends(get_current_user)
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取双人对练报告
|
||||||
|
|
||||||
|
包含:
|
||||||
|
- 房间基本信息
|
||||||
|
- 参与者信息
|
||||||
|
- 对话统计分析
|
||||||
|
- 表现评估
|
||||||
|
- 改进建议
|
||||||
|
"""
|
||||||
|
service = PracticeRoomService(db)
|
||||||
|
|
||||||
|
# 通过房间码获取房间
|
||||||
|
room = await service.get_room_by_code(room_code)
|
||||||
|
if not room:
|
||||||
|
raise HTTPException(status_code=404, detail="房间不存在")
|
||||||
|
|
||||||
|
# 验证用户权限
|
||||||
|
if current_user.id not in [room.host_user_id, room.guest_user_id]:
|
||||||
|
raise HTTPException(status_code=403, detail="无权查看此报告")
|
||||||
|
|
||||||
|
# 生成报告
|
||||||
|
report = await service.generate_report(room.id)
|
||||||
|
if not report:
|
||||||
|
raise HTTPException(status_code=404, detail="无法生成报告")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"code": 200,
|
||||||
|
"message": "success",
|
||||||
|
"data": report
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,7 +23,9 @@ class Settings(BaseSettings):
|
|||||||
# 应用基础配置
|
# 应用基础配置
|
||||||
APP_NAME: str = "KaoPeiLian"
|
APP_NAME: str = "KaoPeiLian"
|
||||||
APP_VERSION: str = "1.0.0"
|
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")
|
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")
|
REDIS_URL: str = Field(default="redis://localhost:6379/0")
|
||||||
|
|
||||||
# JWT配置
|
# 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")
|
ALGORITHM: str = Field(default="HS256")
|
||||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
|
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
|
||||||
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
|
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
|
||||||
@@ -165,6 +172,57 @@ def get_settings() -> Settings:
|
|||||||
settings = get_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("✅ 安全配置检查通过")
|
||||||
|
|
||||||
|
|
||||||
# ============================================
|
# ============================================
|
||||||
# 动态配置获取(支持从数据库读取)
|
# 动态配置获取(支持从数据库读取)
|
||||||
# ============================================
|
# ============================================
|
||||||
|
|||||||
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,
|
UserCertificate,
|
||||||
CertificateType,
|
CertificateType,
|
||||||
)
|
)
|
||||||
|
from app.models.user_course_progress import (
|
||||||
|
UserCourseProgress,
|
||||||
|
UserMaterialProgress,
|
||||||
|
ProgressStatus,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"Base",
|
"Base",
|
||||||
@@ -72,4 +77,7 @@ __all__ = [
|
|||||||
"CertificateTemplate",
|
"CertificateTemplate",
|
||||||
"UserCertificate",
|
"UserCertificate",
|
||||||
"CertificateType",
|
"CertificateType",
|
||||||
|
"UserCourseProgress",
|
||||||
|
"UserMaterialProgress",
|
||||||
|
"ProgressStatus",
|
||||||
]
|
]
|
||||||
|
|||||||
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")
|
||||||
@@ -514,3 +514,246 @@ class CertificateService:
|
|||||||
if image_url:
|
if image_url:
|
||||||
cert.image_url = image_url
|
cert.image_url = image_url
|
||||||
await self.db.flush()
|
await self.db.flush()
|
||||||
|
|
||||||
|
async def generate_certificate_pdf(
|
||||||
|
self,
|
||||||
|
cert_id: int,
|
||||||
|
base_url: str = ""
|
||||||
|
) -> bytes:
|
||||||
|
"""
|
||||||
|
生成证书 PDF
|
||||||
|
|
||||||
|
使用 HTML 模板渲染后转换为 PDF
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_id: 证书ID
|
||||||
|
base_url: 基础URL(用于生成二维码链接)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
PDF 二进制数据
|
||||||
|
"""
|
||||||
|
# 获取证书信息
|
||||||
|
cert_data = await self.get_certificate_by_id(cert_id)
|
||||||
|
if not cert_data:
|
||||||
|
raise ValueError("证书不存在")
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
from app.models.user import User
|
||||||
|
user_result = await self.db.execute(
|
||||||
|
select(User).join(UserCertificate, UserCertificate.user_id == User.id)
|
||||||
|
.where(UserCertificate.id == cert_id)
|
||||||
|
)
|
||||||
|
user = user_result.scalar_one_or_none()
|
||||||
|
user_name = user.full_name or user.username if user else "未知用户"
|
||||||
|
|
||||||
|
# 生成验证二维码 base64
|
||||||
|
qr_base64 = ""
|
||||||
|
cert_no = cert_data.get("certificate_no", "")
|
||||||
|
if cert_no:
|
||||||
|
import base64
|
||||||
|
verify_url = f"{base_url}/verify/{cert_no}" if base_url else cert_no
|
||||||
|
qr = qrcode.QRCode(version=1, box_size=3, border=2)
|
||||||
|
qr.add_data(verify_url)
|
||||||
|
qr.make(fit=True)
|
||||||
|
qr_img = qr.make_image(fill_color="black", back_color="white")
|
||||||
|
qr_bytes = io.BytesIO()
|
||||||
|
qr_img.save(qr_bytes, format='PNG')
|
||||||
|
qr_bytes.seek(0)
|
||||||
|
qr_base64 = base64.b64encode(qr_bytes.getvalue()).decode('utf-8')
|
||||||
|
|
||||||
|
# HTML 模板
|
||||||
|
html_template = f"""
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
@page {{
|
||||||
|
size: A4 landscape;
|
||||||
|
margin: 20mm;
|
||||||
|
}}
|
||||||
|
body {{
|
||||||
|
font-family: "Microsoft YaHei", "SimHei", Arial, sans-serif;
|
||||||
|
margin: 0;
|
||||||
|
padding: 40px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}}
|
||||||
|
.certificate {{
|
||||||
|
background: white;
|
||||||
|
border-radius: 20px;
|
||||||
|
padding: 60px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
}}
|
||||||
|
.border-decoration {{
|
||||||
|
position: absolute;
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
border: 3px solid #667eea;
|
||||||
|
border-radius: 15px;
|
||||||
|
pointer-events: none;
|
||||||
|
}}
|
||||||
|
.header {{
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 16px;
|
||||||
|
letter-spacing: 8px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}}
|
||||||
|
.type-name {{
|
||||||
|
color: #333;
|
||||||
|
font-size: 36px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}}
|
||||||
|
.user-name {{
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 42px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 30px 0;
|
||||||
|
border-bottom: 3px solid #667eea;
|
||||||
|
display: inline-block;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}}
|
||||||
|
.title {{
|
||||||
|
color: #333;
|
||||||
|
font-size: 24px;
|
||||||
|
margin: 20px 0;
|
||||||
|
}}
|
||||||
|
.description {{
|
||||||
|
color: #666;
|
||||||
|
font-size: 18px;
|
||||||
|
margin: 20px 0;
|
||||||
|
line-height: 1.6;
|
||||||
|
}}
|
||||||
|
.score {{
|
||||||
|
color: #667eea;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: bold;
|
||||||
|
margin: 20px 0;
|
||||||
|
}}
|
||||||
|
.footer {{
|
||||||
|
margin-top: 40px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-end;
|
||||||
|
}}
|
||||||
|
.date-section {{
|
||||||
|
text-align: left;
|
||||||
|
}}
|
||||||
|
.date-label {{
|
||||||
|
color: #999;
|
||||||
|
font-size: 14px;
|
||||||
|
}}
|
||||||
|
.date-value {{
|
||||||
|
color: #333;
|
||||||
|
font-size: 18px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}}
|
||||||
|
.qr-section {{
|
||||||
|
text-align: right;
|
||||||
|
}}
|
||||||
|
.qr-code {{
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
}}
|
||||||
|
.cert-no {{
|
||||||
|
color: #999;
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 5px;
|
||||||
|
}}
|
||||||
|
.seal {{
|
||||||
|
position: absolute;
|
||||||
|
right: 100px;
|
||||||
|
bottom: 120px;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border: 4px solid #e74c3c;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: #e74c3c;
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: 14px;
|
||||||
|
transform: rotate(-15deg);
|
||||||
|
opacity: 0.8;
|
||||||
|
}}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="certificate">
|
||||||
|
<div class="border-decoration"></div>
|
||||||
|
<div class="header">考培练学习平台</div>
|
||||||
|
<div class="type-name">{cert_data.get('type_name', '证书')}</div>
|
||||||
|
<div class="user-name">{user_name}</div>
|
||||||
|
<div class="title">{cert_data.get('title', '')}</div>
|
||||||
|
<div class="description">{cert_data.get('description', '')}</div>
|
||||||
|
{"<div class='score'>成绩:" + str(cert_data.get('score')) + "分</div>" if cert_data.get('score') else ""}
|
||||||
|
{"<div class='score'>完成率:" + str(cert_data.get('completion_rate')) + "%</div>" if cert_data.get('completion_rate') else ""}
|
||||||
|
<div class="footer">
|
||||||
|
<div class="date-section">
|
||||||
|
<div class="date-label">颁发日期</div>
|
||||||
|
<div class="date-value">{cert_data.get('issued_at', '')[:10] if cert_data.get('issued_at') else ''}</div>
|
||||||
|
</div>
|
||||||
|
<div class="qr-section">
|
||||||
|
{"<img class='qr-code' src='data:image/png;base64," + qr_base64 + "' alt='验证二维码'>" if qr_base64 else ""}
|
||||||
|
<div class="cert-no">证书编号:{cert_no}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="seal">官方认证</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
|
|
||||||
|
# 尝试使用 weasyprint 生成 PDF
|
||||||
|
try:
|
||||||
|
from weasyprint import HTML
|
||||||
|
pdf_bytes = HTML(string=html_template).write_pdf()
|
||||||
|
return pdf_bytes
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("weasyprint 未安装,使用备用方案")
|
||||||
|
# 备用方案:返回 HTML 供前端处理
|
||||||
|
return html_template.encode('utf-8')
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"生成 PDF 失败: {str(e)}")
|
||||||
|
raise ValueError(f"生成 PDF 失败: {str(e)}")
|
||||||
|
|
||||||
|
async def download_certificate(
|
||||||
|
self,
|
||||||
|
cert_id: int,
|
||||||
|
format: str = "pdf",
|
||||||
|
base_url: str = ""
|
||||||
|
) -> tuple[bytes, str, str]:
|
||||||
|
"""
|
||||||
|
下载证书
|
||||||
|
|
||||||
|
Args:
|
||||||
|
cert_id: 证书ID
|
||||||
|
format: 格式 (pdf/png)
|
||||||
|
base_url: 基础URL
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
(文件内容, 文件名, MIME类型)
|
||||||
|
"""
|
||||||
|
cert_data = await self.get_certificate_by_id(cert_id)
|
||||||
|
if not cert_data:
|
||||||
|
raise ValueError("证书不存在")
|
||||||
|
|
||||||
|
cert_no = cert_data.get("certificate_no", "certificate")
|
||||||
|
|
||||||
|
if format.lower() == "pdf":
|
||||||
|
content = await self.generate_certificate_pdf(cert_id, base_url)
|
||||||
|
filename = f"{cert_no}.pdf"
|
||||||
|
mime_type = "application/pdf"
|
||||||
|
else:
|
||||||
|
content = await self.generate_certificate_image(cert_id, base_url)
|
||||||
|
filename = f"{cert_no}.png"
|
||||||
|
mime_type = "image/png"
|
||||||
|
|
||||||
|
return content, filename, mime_type
|
||||||
@@ -1,330 +1,419 @@
|
|||||||
"""
|
"""
|
||||||
站内消息通知服务
|
通知推送服务
|
||||||
提供通知的CRUD操作和业务逻辑
|
支持钉钉、企业微信、站内消息等多种渠道
|
||||||
"""
|
"""
|
||||||
from typing import List, Optional, Tuple
|
import os
|
||||||
from sqlalchemy import select, and_, desc, func, update
|
import json
|
||||||
from sqlalchemy.orm import selectinload
|
import logging
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from typing import Optional, List, Dict, Any
|
||||||
|
import httpx
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.models.user import User
|
||||||
from app.schemas.notification import (
|
from app.models.notification import Notification
|
||||||
NotificationCreate,
|
|
||||||
NotificationBatchCreate,
|
|
||||||
NotificationResponse,
|
|
||||||
NotificationType,
|
|
||||||
)
|
|
||||||
from app.services.base_service import BaseService
|
|
||||||
|
|
||||||
logger = get_logger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class NotificationService(BaseService[Notification]):
|
class NotificationChannel:
|
||||||
"""
|
"""通知渠道基类"""
|
||||||
站内消息通知服务
|
|
||||||
|
|
||||||
提供通知的创建、查询、标记已读等功能
|
async def send(
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self):
|
|
||||||
super().__init__(Notification)
|
|
||||||
|
|
||||||
async def create_notification(
|
|
||||||
self,
|
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,
|
user_id: int,
|
||||||
skip: int = 0,
|
title: str,
|
||||||
limit: int = 20,
|
content: str,
|
||||||
is_read: Optional[bool] = None,
|
**kwargs
|
||||||
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
|
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
删除通知
|
发送通知
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
db: 数据库会话
|
|
||||||
user_id: 用户ID
|
user_id: 用户ID
|
||||||
notification_id: 通知ID
|
title: 通知标题
|
||||||
|
content: 通知内容
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
是否删除成功
|
是否发送成功
|
||||||
"""
|
"""
|
||||||
stmt = select(Notification).where(
|
raise NotImplementedError
|
||||||
and_(
|
|
||||||
Notification.id == notification_id,
|
|
||||||
Notification.user_id == user_id
|
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",
|
||||||
)
|
)
|
||||||
|
|
||||||
result = await db.execute(stmt)
|
async def send_task_deadline_reminder(
|
||||||
notification = result.scalar_one_or_none()
|
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} 天,请尽快完成!"
|
||||||
|
|
||||||
if notification:
|
return await self.send_notification(
|
||||||
await db.delete(notification)
|
user_id=user_id,
|
||||||
await db.commit()
|
title=title,
|
||||||
|
content=content,
|
||||||
|
channels=["inapp", "dingtalk", "wework"],
|
||||||
|
notification_type="task_deadline",
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(
|
async def send_exam_reminder(
|
||||||
"删除通知成功",
|
self,
|
||||||
notification_id=notification_id,
|
user_id: int,
|
||||||
user_id=user_id
|
exam_name: str,
|
||||||
)
|
exam_time: datetime,
|
||||||
return True
|
) -> Dict[str, bool]:
|
||||||
|
"""发送考试提醒"""
|
||||||
|
title = "📝 考试提醒"
|
||||||
|
content = f"考试《{exam_name}》将于 {exam_time.strftime('%Y-%m-%d %H:%M')} 开始,请提前做好准备!"
|
||||||
|
|
||||||
return False
|
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"继续加油!💪"
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -503,6 +503,193 @@ class PracticeRoomService:
|
|||||||
|
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
# ==================== 报告生成 ====================
|
||||||
|
|
||||||
|
async def generate_report(self, room_id: int) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
生成对练报告
|
||||||
|
|
||||||
|
Args:
|
||||||
|
room_id: 房间ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
包含房间信息、对话分析、表现评估的完整报告
|
||||||
|
"""
|
||||||
|
# 获取房间信息
|
||||||
|
room = await self.get_room(room_id)
|
||||||
|
if not room:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# 获取房间消息
|
||||||
|
messages = await self.get_messages(room_id)
|
||||||
|
chat_messages = [m for m in messages if m.message_type == self.MSG_TYPE_CHAT]
|
||||||
|
|
||||||
|
# 获取用户信息
|
||||||
|
host_user = await self._get_user(room.host_user_id)
|
||||||
|
guest_user = await self._get_user(room.guest_user_id) if room.guest_user_id else None
|
||||||
|
|
||||||
|
# 分析对话
|
||||||
|
analysis = self._analyze_conversation(room, chat_messages)
|
||||||
|
|
||||||
|
# 构建报告
|
||||||
|
report = {
|
||||||
|
"room": {
|
||||||
|
"id": room.id,
|
||||||
|
"room_code": room.room_code,
|
||||||
|
"scene_name": room.scene_name or "自由对练",
|
||||||
|
"scene_type": room.scene_type,
|
||||||
|
"scene_background": room.scene_background,
|
||||||
|
"role_a_name": room.role_a_name,
|
||||||
|
"role_b_name": room.role_b_name,
|
||||||
|
"status": room.status,
|
||||||
|
"duration_seconds": room.duration_seconds or 0,
|
||||||
|
"total_turns": room.total_turns or 0,
|
||||||
|
"started_at": room.started_at.isoformat() if room.started_at else None,
|
||||||
|
"ended_at": room.ended_at.isoformat() if room.ended_at else None,
|
||||||
|
},
|
||||||
|
"participants": {
|
||||||
|
"host": {
|
||||||
|
"user_id": room.host_user_id,
|
||||||
|
"username": host_user.username if host_user else "未知用户",
|
||||||
|
"role": room.host_role,
|
||||||
|
"role_name": room.role_a_name if room.host_role == "A" else room.role_b_name,
|
||||||
|
},
|
||||||
|
"guest": {
|
||||||
|
"user_id": room.guest_user_id,
|
||||||
|
"username": guest_user.username if guest_user else "未加入",
|
||||||
|
"role": "B" if room.host_role == "A" else "A",
|
||||||
|
"role_name": room.role_b_name if room.host_role == "A" else room.role_a_name,
|
||||||
|
} if room.guest_user_id else None,
|
||||||
|
},
|
||||||
|
"analysis": analysis,
|
||||||
|
"messages": [
|
||||||
|
{
|
||||||
|
"id": m.id,
|
||||||
|
"user_id": m.user_id,
|
||||||
|
"content": m.content,
|
||||||
|
"role_name": m.role_name,
|
||||||
|
"sequence": m.sequence,
|
||||||
|
"created_at": m.created_at.isoformat() if m.created_at else None,
|
||||||
|
}
|
||||||
|
for m in chat_messages
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
return report
|
||||||
|
|
||||||
|
def _analyze_conversation(
|
||||||
|
self,
|
||||||
|
room: PracticeRoom,
|
||||||
|
messages: List[PracticeRoomMessage]
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
分析对话内容
|
||||||
|
|
||||||
|
返回对话分析结果,包括:
|
||||||
|
- 对话统计
|
||||||
|
- 参与度分析
|
||||||
|
- 对话质量评估
|
||||||
|
- 改进建议
|
||||||
|
"""
|
||||||
|
if not messages:
|
||||||
|
return {
|
||||||
|
"summary": "暂无对话记录",
|
||||||
|
"statistics": {
|
||||||
|
"total_messages": 0,
|
||||||
|
"role_a_messages": 0,
|
||||||
|
"role_b_messages": 0,
|
||||||
|
"avg_message_length": 0,
|
||||||
|
"conversation_duration": room.duration_seconds or 0,
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"role_a_ratio": 0,
|
||||||
|
"role_b_ratio": 0,
|
||||||
|
"balance_score": 0,
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"overall_score": 0,
|
||||||
|
"engagement_score": 0,
|
||||||
|
"response_quality": 0,
|
||||||
|
},
|
||||||
|
"suggestions": ["尚无足够的对话数据进行分析"],
|
||||||
|
}
|
||||||
|
|
||||||
|
# 统计消息
|
||||||
|
role_a_messages = [m for m in messages if m.role_name == room.role_a_name]
|
||||||
|
role_b_messages = [m for m in messages if m.role_name == room.role_b_name]
|
||||||
|
|
||||||
|
total_messages = len(messages)
|
||||||
|
role_a_count = len(role_a_messages)
|
||||||
|
role_b_count = len(role_b_messages)
|
||||||
|
|
||||||
|
# 计算平均消息长度
|
||||||
|
total_length = sum(len(m.content or "") for m in messages)
|
||||||
|
avg_length = round(total_length / total_messages) if total_messages > 0 else 0
|
||||||
|
|
||||||
|
# 计算参与度
|
||||||
|
role_a_ratio = round(role_a_count / total_messages * 100, 1) if total_messages > 0 else 0
|
||||||
|
role_b_ratio = round(role_b_count / total_messages * 100, 1) if total_messages > 0 else 0
|
||||||
|
|
||||||
|
# 平衡度评分(越接近50:50越高)
|
||||||
|
balance_score = round(100 - abs(role_a_ratio - 50) * 2, 1)
|
||||||
|
balance_score = max(0, min(100, balance_score))
|
||||||
|
|
||||||
|
# 质量评估(基于简单规则)
|
||||||
|
engagement_score = min(100, total_messages * 5) # 每条消息5分,最高100
|
||||||
|
|
||||||
|
# 响应质量(基于平均消息长度)
|
||||||
|
response_quality = min(100, avg_length * 2) # 每字2分,最高100
|
||||||
|
|
||||||
|
# 综合评分
|
||||||
|
overall_score = round((balance_score + engagement_score + response_quality) / 3, 1)
|
||||||
|
|
||||||
|
# 生成建议
|
||||||
|
suggestions = []
|
||||||
|
if balance_score < 70:
|
||||||
|
suggestions.append(f"对话参与度不均衡,建议{room.role_a_name if role_a_ratio < 50 else room.role_b_name}增加互动")
|
||||||
|
if avg_length < 20:
|
||||||
|
suggestions.append("平均消息较短,建议增加更详细的表达")
|
||||||
|
if total_messages < 10:
|
||||||
|
suggestions.append("对话轮次较少,建议增加更多交流")
|
||||||
|
if overall_score >= 80:
|
||||||
|
suggestions.append("对话质量良好,继续保持!")
|
||||||
|
elif overall_score < 60:
|
||||||
|
suggestions.append("建议增加对话深度和互动频率")
|
||||||
|
|
||||||
|
if not suggestions:
|
||||||
|
suggestions.append("表现正常,可以尝试更复杂的场景练习")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"summary": f"本次对练共进行 {total_messages} 轮对话,时长 {room.duration_seconds or 0} 秒",
|
||||||
|
"statistics": {
|
||||||
|
"total_messages": total_messages,
|
||||||
|
"role_a_messages": role_a_count,
|
||||||
|
"role_b_messages": role_b_count,
|
||||||
|
"avg_message_length": avg_length,
|
||||||
|
"conversation_duration": room.duration_seconds or 0,
|
||||||
|
},
|
||||||
|
"participation": {
|
||||||
|
"role_a_ratio": role_a_ratio,
|
||||||
|
"role_b_ratio": role_b_ratio,
|
||||||
|
"balance_score": balance_score,
|
||||||
|
},
|
||||||
|
"quality": {
|
||||||
|
"overall_score": overall_score,
|
||||||
|
"engagement_score": engagement_score,
|
||||||
|
"response_quality": response_quality,
|
||||||
|
},
|
||||||
|
"suggestions": suggestions,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def _get_user(self, user_id: Optional[int]) -> Optional[User]:
|
||||||
|
"""获取用户信息"""
|
||||||
|
if not user_id:
|
||||||
|
return None
|
||||||
|
result = await self.db.execute(
|
||||||
|
select(User).where(User.id == user_id)
|
||||||
|
)
|
||||||
|
return result.scalar_one_or_none()
|
||||||
|
|
||||||
|
|
||||||
# ==================== 便捷函数 ====================
|
# ==================== 便捷函数 ====================
|
||||||
|
|
||||||
|
|||||||
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)
|
||||||
@@ -54,3 +54,8 @@ jsonschema>=4.0.0
|
|||||||
# PDF 文档提取
|
# PDF 文档提取
|
||||||
PyPDF2>=3.0.0
|
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
|
||||||
158
frontend/src/api/progress.ts
Normal file
158
frontend/src/api/progress.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
/**
|
||||||
|
* 用户学习进度 API
|
||||||
|
*/
|
||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
// ============ 类型定义 ============
|
||||||
|
|
||||||
|
export interface MaterialProgress {
|
||||||
|
material_id: number
|
||||||
|
material_name: string
|
||||||
|
is_completed: boolean
|
||||||
|
progress_percent: number
|
||||||
|
last_position: number
|
||||||
|
study_time: number
|
||||||
|
first_accessed_at: string | null
|
||||||
|
last_accessed_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CourseProgress {
|
||||||
|
course_id: number
|
||||||
|
course_name: string
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed'
|
||||||
|
progress_percent: number
|
||||||
|
completed_materials: number
|
||||||
|
total_materials: number
|
||||||
|
total_study_time: number
|
||||||
|
first_accessed_at: string | null
|
||||||
|
last_accessed_at: string | null
|
||||||
|
completed_at: string | null
|
||||||
|
materials?: MaterialProgress[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProgressSummary {
|
||||||
|
total_courses: number
|
||||||
|
completed_courses: number
|
||||||
|
in_progress_courses: number
|
||||||
|
not_started_courses: number
|
||||||
|
total_study_time: number
|
||||||
|
average_progress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MaterialProgressUpdate {
|
||||||
|
progress_percent: number
|
||||||
|
last_position?: number
|
||||||
|
study_time_delta?: number
|
||||||
|
is_completed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ API 方法 ============
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取学习进度摘要
|
||||||
|
*/
|
||||||
|
export const getProgressSummary = () => {
|
||||||
|
return request.get<ProgressSummary>('/api/v1/progress/summary')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有课程学习进度
|
||||||
|
*/
|
||||||
|
export const getAllCourseProgress = (status?: string) => {
|
||||||
|
return request.get<CourseProgress[]>('/api/v1/progress/courses', {
|
||||||
|
params: status ? { status } : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取指定课程的详细学习进度
|
||||||
|
*/
|
||||||
|
export const getCourseProgress = (courseId: number) => {
|
||||||
|
return request.get<CourseProgress>(`/api/v1/progress/courses/${courseId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新资料学习进度
|
||||||
|
*/
|
||||||
|
export const updateMaterialProgress = (
|
||||||
|
materialId: number,
|
||||||
|
data: MaterialProgressUpdate
|
||||||
|
) => {
|
||||||
|
return request.post<MaterialProgress>(
|
||||||
|
`/api/v1/progress/materials/${materialId}`,
|
||||||
|
data
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 标记资料为已完成
|
||||||
|
*/
|
||||||
|
export const markMaterialComplete = (materialId: number) => {
|
||||||
|
return request.post<MaterialProgress>(
|
||||||
|
`/api/v1/progress/materials/${materialId}/complete`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始学习课程
|
||||||
|
*/
|
||||||
|
export const startCourse = (courseId: number) => {
|
||||||
|
return request.post(`/api/v1/progress/courses/${courseId}/start`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 格式化学习时长
|
||||||
|
*/
|
||||||
|
export const formatStudyTime = (seconds: number): string => {
|
||||||
|
if (seconds < 60) {
|
||||||
|
return `${seconds}秒`
|
||||||
|
}
|
||||||
|
if (seconds < 3600) {
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
return `${minutes}分钟`
|
||||||
|
}
|
||||||
|
const hours = Math.floor(seconds / 3600)
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60)
|
||||||
|
return minutes > 0 ? `${hours}小时${minutes}分钟` : `${hours}小时`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取进度状态文本
|
||||||
|
*/
|
||||||
|
export const getProgressStatusText = (
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed'
|
||||||
|
): string => {
|
||||||
|
const statusMap = {
|
||||||
|
not_started: '未开始',
|
||||||
|
in_progress: '学习中',
|
||||||
|
completed: '已完成',
|
||||||
|
}
|
||||||
|
return statusMap[status] || status
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取进度状态颜色
|
||||||
|
*/
|
||||||
|
export const getProgressStatusType = (
|
||||||
|
status: 'not_started' | 'in_progress' | 'completed'
|
||||||
|
): 'info' | 'warning' | 'success' => {
|
||||||
|
const typeMap: Record<string, 'info' | 'warning' | 'success'> = {
|
||||||
|
not_started: 'info',
|
||||||
|
in_progress: 'warning',
|
||||||
|
completed: 'success',
|
||||||
|
}
|
||||||
|
return typeMap[status] || 'info'
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
getProgressSummary,
|
||||||
|
getAllCourseProgress,
|
||||||
|
getCourseProgress,
|
||||||
|
updateMaterialProgress,
|
||||||
|
markMaterialComplete,
|
||||||
|
startCourse,
|
||||||
|
formatStudyTime,
|
||||||
|
getProgressStatusText,
|
||||||
|
getProgressStatusType,
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import { Router, RouteLocationNormalized, NavigationGuardNext } from 'vue-router
|
|||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { authManager } from '@/utils/auth'
|
import { authManager } from '@/utils/auth'
|
||||||
import { loadingManager } from '@/utils/loadingManager'
|
import { loadingManager } from '@/utils/loadingManager'
|
||||||
|
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
|
||||||
|
|
||||||
// 白名单路由(不需要登录)
|
// 白名单路由(不需要登录)
|
||||||
const WHITE_LIST = ['/login', '/register', '/404']
|
const WHITE_LIST = ['/login', '/register', '/404']
|
||||||
@@ -109,13 +110,21 @@ async function handleRouteGuard(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查特殊路由规则
|
// 检查特殊路由规则(先进行同步检查)
|
||||||
if (!checkSpecialRouteRules(to)) {
|
if (!checkSpecialRouteRules(to)) {
|
||||||
ElMessage.error('访问被拒绝')
|
ElMessage.error('访问被拒绝')
|
||||||
next(authManager.getDefaultRoute())
|
next(authManager.getDefaultRoute())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 异步权限检查(团队和课程权限)
|
||||||
|
const hasSpecialAccess = await checkSpecialRouteRulesAsync(to)
|
||||||
|
if (!hasSpecialAccess) {
|
||||||
|
ElMessage.error('您没有访问此资源的权限')
|
||||||
|
next(authManager.getDefaultRoute())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
next()
|
next()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,9 +151,9 @@ function checkRoutePermission(path: string): boolean {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 检查特殊路由规则
|
* 检查特殊路由规则(异步版本)
|
||||||
*/
|
*/
|
||||||
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
|
async function checkSpecialRouteRulesAsync(to: RouteLocationNormalized): Promise<boolean> {
|
||||||
const { path, params } = to
|
const { path, params } = to
|
||||||
|
|
||||||
// 检查用户ID参数权限(只能访问自己的数据,管理员除外)
|
// 检查用户ID参数权限(只能访问自己的数据,管理员除外)
|
||||||
@@ -157,14 +166,41 @@ function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
|
|||||||
|
|
||||||
// 检查团队ID参数权限
|
// 检查团队ID参数权限
|
||||||
if (params.teamId && !authManager.isAdmin()) {
|
if (params.teamId && !authManager.isAdmin()) {
|
||||||
// 这里可以添加团队权限检查逻辑
|
const teamId = Number(params.teamId)
|
||||||
// 暂时允许通过,实际项目中需要检查用户是否属于该团队
|
if (!isNaN(teamId)) {
|
||||||
|
const isMember = await checkTeamMembership(teamId)
|
||||||
|
if (!isMember) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查课程访问权限
|
// 检查课程访问权限
|
||||||
if (path.includes('/course/') && params.courseId) {
|
if (path.includes('/course/') && params.courseId) {
|
||||||
// 这里可以添加课程访问权限检查
|
const courseId = Number(params.courseId)
|
||||||
// 例如检查课程是否分配给用户的岗位
|
if (!isNaN(courseId)) {
|
||||||
|
const hasAccess = await checkCourseAccess(courseId)
|
||||||
|
if (!hasAccess) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查特殊路由规则(同步版本,用于简单检查)
|
||||||
|
*/
|
||||||
|
function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
|
||||||
|
const { params } = to
|
||||||
|
|
||||||
|
// 检查用户ID参数权限(只能访问自己的数据,管理员除外)
|
||||||
|
if (params.userId && !authManager.isAdmin()) {
|
||||||
|
const currentUser = authManager.getCurrentUser()
|
||||||
|
if (currentUser && String(params.userId) !== String(currentUser.id)) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return true
|
return true
|
||||||
|
|||||||
@@ -161,6 +161,12 @@ class AuthManager {
|
|||||||
localStorage.removeItem(this.userKey)
|
localStorage.removeItem(this.userKey)
|
||||||
localStorage.removeItem(this.tokenKey)
|
localStorage.removeItem(this.tokenKey)
|
||||||
localStorage.removeItem(this.refreshTokenKey)
|
localStorage.removeItem(this.refreshTokenKey)
|
||||||
|
// 清除权限缓存
|
||||||
|
import('@/utils/permissionChecker').then(({ clearPermissionCache }) => {
|
||||||
|
clearPermissionCache()
|
||||||
|
}).catch(() => {
|
||||||
|
// 忽略导入错误
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
211
frontend/src/utils/permissionChecker.ts
Normal file
211
frontend/src/utils/permissionChecker.ts
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
/**
|
||||||
|
* 权限检查工具
|
||||||
|
* 用于前端路由守卫和组件级权限控制
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { authManager } from './auth'
|
||||||
|
|
||||||
|
// 缓存团队成员关系
|
||||||
|
const teamMembershipCache = new Map<number, boolean>()
|
||||||
|
// 缓存课程访问权限
|
||||||
|
const courseAccessCache = new Map<number, boolean>()
|
||||||
|
|
||||||
|
// 缓存过期时间(5分钟)
|
||||||
|
const CACHE_TTL = 5 * 60 * 1000
|
||||||
|
let lastCacheUpdate = 0
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清除权限缓存
|
||||||
|
*/
|
||||||
|
export function clearPermissionCache() {
|
||||||
|
teamMembershipCache.clear()
|
||||||
|
courseAccessCache.clear()
|
||||||
|
lastCacheUpdate = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查缓存是否过期
|
||||||
|
*/
|
||||||
|
function isCacheExpired(): boolean {
|
||||||
|
return Date.now() - lastCacheUpdate > CACHE_TTL
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新缓存时间戳
|
||||||
|
*/
|
||||||
|
function updateCacheTimestamp() {
|
||||||
|
lastCacheUpdate = Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否属于指定团队
|
||||||
|
* @param teamId 团队ID
|
||||||
|
*/
|
||||||
|
export async function checkTeamMembership(teamId: number): Promise<boolean> {
|
||||||
|
// 管理员可以访问所有团队
|
||||||
|
if (authManager.isAdmin()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (!isCacheExpired() && teamMembershipCache.has(teamId)) {
|
||||||
|
return teamMembershipCache.get(teamId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const currentUser = authManager.getCurrentUser()
|
||||||
|
if (!currentUser) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户的团队列表
|
||||||
|
const userTeams = currentUser.teams || []
|
||||||
|
const isMember = userTeams.some((team: any) => team.id === teamId)
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
teamMembershipCache.set(teamId, isMember)
|
||||||
|
updateCacheTimestamp()
|
||||||
|
|
||||||
|
return isMember
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查团队成员身份失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否可以访问指定课程
|
||||||
|
* @param courseId 课程ID
|
||||||
|
*/
|
||||||
|
export async function checkCourseAccess(courseId: number): Promise<boolean> {
|
||||||
|
// 管理员和经理可以访问所有课程
|
||||||
|
if (authManager.isAdmin() || authManager.isManager()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查缓存
|
||||||
|
if (!isCacheExpired() && courseAccessCache.has(courseId)) {
|
||||||
|
return courseAccessCache.get(courseId)!
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 简化检查:学员可以访问所有已发布的课程
|
||||||
|
// 后端会在 API 层面做更细粒度的权限控制
|
||||||
|
// 这里暂时放行,让后端决定是否返回 403
|
||||||
|
const hasAccess = true
|
||||||
|
|
||||||
|
// 更新缓存
|
||||||
|
courseAccessCache.set(courseId, hasAccess)
|
||||||
|
updateCacheTimestamp()
|
||||||
|
|
||||||
|
return hasAccess
|
||||||
|
} catch (error) {
|
||||||
|
console.error('检查课程访问权限失败:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有某个权限
|
||||||
|
* @param permission 权限代码
|
||||||
|
*/
|
||||||
|
export function hasPermission(permission: string): boolean {
|
||||||
|
return authManager.hasPermission(permission)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有任意一个权限
|
||||||
|
* @param permissions 权限代码列表
|
||||||
|
*/
|
||||||
|
export function hasAnyPermission(permissions: string[]): boolean {
|
||||||
|
return authManager.hasAnyPermission(permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查用户是否有所有权限
|
||||||
|
* @param permissions 权限代码列表
|
||||||
|
*/
|
||||||
|
export function hasAllPermissions(permissions: string[]): boolean {
|
||||||
|
return authManager.hasAllPermissions(permissions)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取用户的所有权限
|
||||||
|
*/
|
||||||
|
export function getUserPermissions(): string[] {
|
||||||
|
return authManager.getUserPermissions()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限检查结果接口
|
||||||
|
*/
|
||||||
|
export interface PermissionCheckResult {
|
||||||
|
allowed: boolean
|
||||||
|
reason?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 综合权限检查
|
||||||
|
* @param options 检查选项
|
||||||
|
*/
|
||||||
|
export async function checkPermission(options: {
|
||||||
|
teamId?: number
|
||||||
|
courseId?: number
|
||||||
|
userId?: number
|
||||||
|
permissions?: string[]
|
||||||
|
roles?: string[]
|
||||||
|
}): Promise<PermissionCheckResult> {
|
||||||
|
const { teamId, courseId, userId, permissions, roles } = options
|
||||||
|
|
||||||
|
// 检查角色
|
||||||
|
if (roles && roles.length > 0) {
|
||||||
|
const userRole = authManager.getUserRole()
|
||||||
|
if (!userRole || (!roles.includes(userRole) && !authManager.isAdmin())) {
|
||||||
|
return { allowed: false, reason: '角色权限不足' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
if (permissions && permissions.length > 0) {
|
||||||
|
if (!hasAnyPermission(permissions)) {
|
||||||
|
return { allowed: false, reason: '缺少必要权限' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户ID(只能访问自己的数据)
|
||||||
|
if (userId !== undefined) {
|
||||||
|
const currentUser = authManager.getCurrentUser()
|
||||||
|
if (!authManager.isAdmin() && currentUser?.id !== userId) {
|
||||||
|
return { allowed: false, reason: '无权访问其他用户数据' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查团队成员身份
|
||||||
|
if (teamId !== undefined) {
|
||||||
|
const isMember = await checkTeamMembership(teamId)
|
||||||
|
if (!isMember) {
|
||||||
|
return { allowed: false, reason: '不是该团队成员' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查课程访问权限
|
||||||
|
if (courseId !== undefined) {
|
||||||
|
const hasAccess = await checkCourseAccess(courseId)
|
||||||
|
if (!hasAccess) {
|
||||||
|
return { allowed: false, reason: '无权访问该课程' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { allowed: true }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
clearPermissionCache,
|
||||||
|
checkTeamMembership,
|
||||||
|
checkCourseAccess,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyPermission,
|
||||||
|
hasAllPermissions,
|
||||||
|
getUserPermissions,
|
||||||
|
checkPermission,
|
||||||
|
}
|
||||||
294
frontend/src/utils/speechRecognition.ts
Normal file
294
frontend/src/utils/speechRecognition.ts
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
/**
|
||||||
|
* 语音识别工具
|
||||||
|
* 使用 Web Speech API 进行浏览器端语音识别
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Web Speech API 类型声明
|
||||||
|
interface SpeechRecognitionEvent extends Event {
|
||||||
|
results: SpeechRecognitionResultList
|
||||||
|
resultIndex: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionResultList {
|
||||||
|
readonly length: number
|
||||||
|
item(index: number): SpeechRecognitionResult
|
||||||
|
[index: number]: SpeechRecognitionResult
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionResult {
|
||||||
|
readonly length: number
|
||||||
|
readonly isFinal: boolean
|
||||||
|
item(index: number): SpeechRecognitionAlternative
|
||||||
|
[index: number]: SpeechRecognitionAlternative
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionAlternative {
|
||||||
|
readonly transcript: string
|
||||||
|
readonly confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognitionErrorEvent extends Event {
|
||||||
|
error: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
SpeechRecognition: new () => SpeechRecognition
|
||||||
|
webkitSpeechRecognition: new () => SpeechRecognition
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SpeechRecognition extends EventTarget {
|
||||||
|
continuous: boolean
|
||||||
|
interimResults: boolean
|
||||||
|
lang: string
|
||||||
|
maxAlternatives: number
|
||||||
|
start(): void
|
||||||
|
stop(): void
|
||||||
|
abort(): void
|
||||||
|
onresult: ((event: SpeechRecognitionEvent) => void) | null
|
||||||
|
onerror: ((event: SpeechRecognitionErrorEvent) => void) | null
|
||||||
|
onend: (() => void) | null
|
||||||
|
onstart: (() => void) | null
|
||||||
|
onspeechend: (() => void) | null
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音识别配置
|
||||||
|
export interface SpeechRecognitionConfig {
|
||||||
|
continuous?: boolean
|
||||||
|
interimResults?: boolean
|
||||||
|
lang?: string
|
||||||
|
maxAlternatives?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音识别结果
|
||||||
|
export interface SpeechRecognitionResult {
|
||||||
|
transcript: string
|
||||||
|
isFinal: boolean
|
||||||
|
confidence: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// 语音识别回调
|
||||||
|
export interface SpeechRecognitionCallbacks {
|
||||||
|
onResult?: (result: SpeechRecognitionResult) => void
|
||||||
|
onError?: (error: string) => void
|
||||||
|
onStart?: () => void
|
||||||
|
onEnd?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查浏览器是否支持语音识别
|
||||||
|
*/
|
||||||
|
export function isSpeechRecognitionSupported(): boolean {
|
||||||
|
return !!(window.SpeechRecognition || window.webkitSpeechRecognition)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建语音识别实例
|
||||||
|
*/
|
||||||
|
export function createSpeechRecognition(
|
||||||
|
config: SpeechRecognitionConfig = {}
|
||||||
|
): SpeechRecognition | null {
|
||||||
|
const SpeechRecognitionConstructor =
|
||||||
|
window.SpeechRecognition || window.webkitSpeechRecognition
|
||||||
|
|
||||||
|
if (!SpeechRecognitionConstructor) {
|
||||||
|
console.warn('浏览器不支持语音识别')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const recognition = new SpeechRecognitionConstructor()
|
||||||
|
recognition.continuous = config.continuous ?? false
|
||||||
|
recognition.interimResults = config.interimResults ?? true
|
||||||
|
recognition.lang = config.lang ?? 'zh-CN'
|
||||||
|
recognition.maxAlternatives = config.maxAlternatives ?? 1
|
||||||
|
|
||||||
|
return recognition
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 语音识别管理器类
|
||||||
|
*/
|
||||||
|
export class SpeechRecognitionManager {
|
||||||
|
private recognition: SpeechRecognition | null = null
|
||||||
|
private isListening = false
|
||||||
|
private callbacks: SpeechRecognitionCallbacks = {}
|
||||||
|
|
||||||
|
constructor(config: SpeechRecognitionConfig = {}) {
|
||||||
|
this.recognition = createSpeechRecognition(config)
|
||||||
|
this.setupEventListeners()
|
||||||
|
}
|
||||||
|
|
||||||
|
private setupEventListeners() {
|
||||||
|
if (!this.recognition) return
|
||||||
|
|
||||||
|
this.recognition.onresult = (event: SpeechRecognitionEvent) => {
|
||||||
|
const lastResult = event.results[event.resultIndex]
|
||||||
|
if (lastResult) {
|
||||||
|
const result: SpeechRecognitionResult = {
|
||||||
|
transcript: lastResult[0].transcript,
|
||||||
|
isFinal: lastResult.isFinal,
|
||||||
|
confidence: lastResult[0].confidence,
|
||||||
|
}
|
||||||
|
this.callbacks.onResult?.(result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recognition.onerror = (event: SpeechRecognitionErrorEvent) => {
|
||||||
|
const errorMessages: Record<string, string> = {
|
||||||
|
'no-speech': '没有检测到语音',
|
||||||
|
'audio-capture': '无法访问麦克风',
|
||||||
|
'not-allowed': '麦克风权限被拒绝',
|
||||||
|
'network': '网络错误',
|
||||||
|
'aborted': '识别被中断',
|
||||||
|
'language-not-supported': '不支持的语言',
|
||||||
|
}
|
||||||
|
const message = errorMessages[event.error] || `识别错误: ${event.error}`
|
||||||
|
this.callbacks.onError?.(message)
|
||||||
|
this.isListening = false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recognition.onstart = () => {
|
||||||
|
this.isListening = true
|
||||||
|
this.callbacks.onStart?.()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.recognition.onend = () => {
|
||||||
|
this.isListening = false
|
||||||
|
this.callbacks.onEnd?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置回调函数
|
||||||
|
*/
|
||||||
|
setCallbacks(callbacks: SpeechRecognitionCallbacks) {
|
||||||
|
this.callbacks = callbacks
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始语音识别
|
||||||
|
*/
|
||||||
|
start(): boolean {
|
||||||
|
if (!this.recognition) {
|
||||||
|
this.callbacks.onError?.('浏览器不支持语音识别')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.isListening) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.recognition.start()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
this.callbacks.onError?.('启动语音识别失败')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止语音识别
|
||||||
|
*/
|
||||||
|
stop() {
|
||||||
|
if (this.recognition && this.isListening) {
|
||||||
|
this.recognition.stop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 中止语音识别
|
||||||
|
*/
|
||||||
|
abort() {
|
||||||
|
if (this.recognition) {
|
||||||
|
this.recognition.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否正在监听
|
||||||
|
*/
|
||||||
|
getIsListening(): boolean {
|
||||||
|
return this.isListening
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否支持语音识别
|
||||||
|
*/
|
||||||
|
isSupported(): boolean {
|
||||||
|
return this.recognition !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 销毁实例
|
||||||
|
*/
|
||||||
|
destroy() {
|
||||||
|
this.abort()
|
||||||
|
this.recognition = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 一次性语音识别
|
||||||
|
* 返回 Promise,识别完成后返回结果
|
||||||
|
*/
|
||||||
|
export function recognizeSpeech(
|
||||||
|
config: SpeechRecognitionConfig = {},
|
||||||
|
timeout = 10000
|
||||||
|
): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const manager = new SpeechRecognitionManager({
|
||||||
|
...config,
|
||||||
|
continuous: false,
|
||||||
|
interimResults: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!manager.isSupported()) {
|
||||||
|
reject(new Error('浏览器不支持语音识别'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let finalTranscript = ''
|
||||||
|
let timeoutId: number | null = null
|
||||||
|
|
||||||
|
manager.setCallbacks({
|
||||||
|
onResult: (result) => {
|
||||||
|
if (result.isFinal) {
|
||||||
|
finalTranscript = result.transcript
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onEnd: () => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
manager.destroy()
|
||||||
|
resolve(finalTranscript)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
if (timeoutId) {
|
||||||
|
clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
manager.destroy()
|
||||||
|
reject(new Error(error))
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置超时
|
||||||
|
timeoutId = window.setTimeout(() => {
|
||||||
|
manager.stop()
|
||||||
|
}, timeout)
|
||||||
|
|
||||||
|
if (!manager.start()) {
|
||||||
|
reject(new Error('启动语音识别失败'))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
isSpeechRecognitionSupported,
|
||||||
|
createSpeechRecognition,
|
||||||
|
SpeechRecognitionManager,
|
||||||
|
recognizeSpeech,
|
||||||
|
}
|
||||||
@@ -183,6 +183,11 @@ import {
|
|||||||
type CozeSession,
|
type CozeSession,
|
||||||
type StreamEvent
|
type StreamEvent
|
||||||
} from '@/api/coze'
|
} from '@/api/coze'
|
||||||
|
import {
|
||||||
|
SpeechRecognitionManager,
|
||||||
|
isSpeechRecognitionSupported,
|
||||||
|
type SpeechRecognitionResult
|
||||||
|
} from '@/utils/speechRecognition'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
|
|||||||
const mediaRecorder = ref<MediaRecorder | null>(null)
|
const mediaRecorder = ref<MediaRecorder | null>(null)
|
||||||
const audioChunks = ref<Blob[]>([])
|
const audioChunks = ref<Blob[]>([])
|
||||||
|
|
||||||
|
// 语音识别相关
|
||||||
|
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
|
||||||
|
const recognizedText = ref('')
|
||||||
|
const isSpeechSupported = isSpeechRecognitionSupported()
|
||||||
|
|
||||||
// DOM引用
|
// DOM引用
|
||||||
const messageContainer = ref<HTMLElement>()
|
const messageContainer = ref<HTMLElement>()
|
||||||
|
|
||||||
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 开始录音
|
* 开始录音(同时启动语音识别)
|
||||||
*/
|
*/
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
|
if (!cozeSession.value) {
|
||||||
|
ElMessage.warning('请先开始陪练会话')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 优先使用 Web Speech API 进行实时语音识别
|
||||||
|
if (isSpeechSupported) {
|
||||||
|
startSpeechRecognition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到录音模式(需要后端语音识别服务)
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
|
|
||||||
@@ -400,7 +422,7 @@ const startRecording = async () => {
|
|||||||
|
|
||||||
mediaRecorder.value.start()
|
mediaRecorder.value.start()
|
||||||
isRecording.value = true
|
isRecording.value = true
|
||||||
voiceStatusText.value = '正在录音...'
|
voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
ElMessage.error('无法访问麦克风')
|
ElMessage.error('无法访问麦克风')
|
||||||
}
|
}
|
||||||
@@ -410,6 +432,13 @@ const startRecording = async () => {
|
|||||||
* 停止录音
|
* 停止录音
|
||||||
*/
|
*/
|
||||||
const stopRecording = () => {
|
const stopRecording = () => {
|
||||||
|
// 如果使用的是 Web Speech API
|
||||||
|
if (speechRecognition.value) {
|
||||||
|
stopSpeechRecognition()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果使用的是录音模式
|
||||||
if (mediaRecorder.value && isRecording.value) {
|
if (mediaRecorder.value && isRecording.value) {
|
||||||
mediaRecorder.value.stop()
|
mediaRecorder.value.stop()
|
||||||
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
|
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
|
||||||
@@ -420,13 +449,116 @@ const stopRecording = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 处理录音
|
* 处理录音(使用 Web Speech API 已识别的文本)
|
||||||
*/
|
*/
|
||||||
const processAudio = async (_audioBlob: Blob) => {
|
const processAudio = async (_audioBlob: Blob) => {
|
||||||
// TODO: 实现音频转文本和发送逻辑
|
try {
|
||||||
isProcessing.value = false
|
// 检查是否有识别结果
|
||||||
voiceStatusText.value = '点击开始按钮进行语音陪练'
|
const text = recognizedText.value.trim()
|
||||||
ElMessage.info('语音功能正在开发中')
|
if (!text) {
|
||||||
|
ElMessage.warning('没有检测到语音内容')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清空识别结果
|
||||||
|
recognizedText.value = ''
|
||||||
|
|
||||||
|
// 发送识别的文本消息
|
||||||
|
if (cozeSession.value) {
|
||||||
|
// 添加用户消息
|
||||||
|
messages.value.push({
|
||||||
|
role: 'user',
|
||||||
|
content: text,
|
||||||
|
timestamp: new Date()
|
||||||
|
})
|
||||||
|
|
||||||
|
await scrollToBottom()
|
||||||
|
isLoading.value = true
|
||||||
|
|
||||||
|
// 创建AI回复消息占位
|
||||||
|
const assistantMessage = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: '',
|
||||||
|
timestamp: new Date()
|
||||||
|
}
|
||||||
|
messages.value.push(assistantMessage)
|
||||||
|
|
||||||
|
// 流式发送消息
|
||||||
|
await sendCozeMessage(
|
||||||
|
cozeSession.value.sessionId,
|
||||||
|
text,
|
||||||
|
(event: StreamEvent) => {
|
||||||
|
if (event.type === 'message.delta') {
|
||||||
|
assistantMessage.content += event.content
|
||||||
|
scrollToBottom()
|
||||||
|
} else if (event.type === 'message.completed') {
|
||||||
|
isLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
ElMessage.error('发送消息失败:' + (error.message || '未知错误'))
|
||||||
|
} finally {
|
||||||
|
isProcessing.value = false
|
||||||
|
voiceStatusText.value = '点击开始按钮进行语音陪练'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 开始语音识别
|
||||||
|
*/
|
||||||
|
const startSpeechRecognition = () => {
|
||||||
|
if (!isSpeechSupported) {
|
||||||
|
ElMessage.warning('您的浏览器不支持语音识别,请使用 Chrome 或 Edge 浏览器')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建语音识别管理器
|
||||||
|
speechRecognition.value = new SpeechRecognitionManager({
|
||||||
|
continuous: true,
|
||||||
|
interimResults: true,
|
||||||
|
lang: 'zh-CN'
|
||||||
|
})
|
||||||
|
|
||||||
|
speechRecognition.value.setCallbacks({
|
||||||
|
onResult: (result: SpeechRecognitionResult) => {
|
||||||
|
recognizedText.value = result.transcript
|
||||||
|
voiceStatusText.value = result.isFinal
|
||||||
|
? `识别结果: ${result.transcript}`
|
||||||
|
: `正在识别: ${result.transcript}`
|
||||||
|
},
|
||||||
|
onError: (error: string) => {
|
||||||
|
ElMessage.error(error)
|
||||||
|
stopSpeechRecognition()
|
||||||
|
},
|
||||||
|
onStart: () => {
|
||||||
|
isRecording.value = true
|
||||||
|
voiceStatusText.value = '正在监听,请说话...'
|
||||||
|
},
|
||||||
|
onEnd: () => {
|
||||||
|
// 识别结束后自动处理
|
||||||
|
if (recognizedText.value.trim()) {
|
||||||
|
processAudio(new Blob())
|
||||||
|
} else {
|
||||||
|
isRecording.value = false
|
||||||
|
voiceStatusText.value = '点击开始按钮进行语音陪练'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
speechRecognition.value.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 停止语音识别
|
||||||
|
*/
|
||||||
|
const stopSpeechRecognition = () => {
|
||||||
|
if (speechRecognition.value) {
|
||||||
|
speechRecognition.value.stop()
|
||||||
|
speechRecognition.value = null
|
||||||
|
}
|
||||||
|
isRecording.value = false
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -456,6 +588,10 @@ onUnmounted(() => {
|
|||||||
if (mediaRecorder.value && isRecording.value) {
|
if (mediaRecorder.value && isRecording.value) {
|
||||||
stopRecording()
|
stopRecording()
|
||||||
}
|
}
|
||||||
|
if (speechRecognition.value) {
|
||||||
|
speechRecognition.value.destroy()
|
||||||
|
speechRecognition.value = null
|
||||||
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,8 @@
|
|||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getPracticeReport } from '@/api/duoPractice'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -223,22 +225,69 @@ const getScoreColor = (score: number) => {
|
|||||||
|
|
||||||
// 加载报告数据
|
// 加载报告数据
|
||||||
const loadReport = async () => {
|
const loadReport = async () => {
|
||||||
const roomId = route.params.id
|
const roomCode = route.params.id as string
|
||||||
if (!roomId) return
|
if (!roomCode) return
|
||||||
|
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
// TODO: 调用 API 获取报告
|
// 调用 API 获取报告
|
||||||
// const res = await getDuoPracticeReport(roomId)
|
const res = await getPracticeReport(roomCode)
|
||||||
// roomInfo.value = res.data.room
|
if (res.data) {
|
||||||
// analysisResult.value = res.data.analysis
|
roomInfo.value = res.data.room
|
||||||
|
|
||||||
// 模拟数据
|
// 转换分析数据格式以兼容现有模板
|
||||||
roomInfo.value = {
|
const analysis = res.data.analysis
|
||||||
scene_name: '销售场景对练',
|
analysisResult.value = {
|
||||||
duration_seconds: 300,
|
overall_evaluation: {
|
||||||
total_turns: 15
|
interaction_quality: analysis?.quality?.engagement_score || 0,
|
||||||
|
scene_restoration: analysis?.quality?.response_quality || 0,
|
||||||
|
overall_comment: analysis?.summary || ''
|
||||||
|
},
|
||||||
|
user_a_evaluation: {
|
||||||
|
user_name: res.data.participants?.host?.username || '用户A',
|
||||||
|
role_name: res.data.room?.role_a_name || '角色A',
|
||||||
|
total_score: analysis?.quality?.overall_score || 0,
|
||||||
|
dimensions: {
|
||||||
|
role_immersion: { score: analysis?.participation?.balance_score || 0, comment: '' },
|
||||||
|
communication: { score: analysis?.quality?.engagement_score || 0, comment: '' },
|
||||||
|
professional_knowledge: { score: analysis?.quality?.response_quality || 0, comment: '' },
|
||||||
|
response_quality: { score: analysis?.quality?.overall_score || 0, comment: '' },
|
||||||
|
goal_achievement: { score: analysis?.quality?.overall_score || 0, comment: '' }
|
||||||
|
},
|
||||||
|
highlights: analysis?.suggestions?.filter((s: string) => s.includes('良好') || s.includes('保持')) || [],
|
||||||
|
improvements: analysis?.suggestions?.filter((s: string) => !s.includes('良好') && !s.includes('保持')).map((s: string) => ({
|
||||||
|
issue: s,
|
||||||
|
suggestion: s
|
||||||
|
})) || []
|
||||||
|
},
|
||||||
|
user_b_evaluation: {
|
||||||
|
user_name: res.data.participants?.guest?.username || '用户B',
|
||||||
|
role_name: res.data.room?.role_b_name || '角色B',
|
||||||
|
total_score: analysis?.quality?.overall_score || 0,
|
||||||
|
dimensions: {
|
||||||
|
role_immersion: { score: analysis?.participation?.balance_score || 0, comment: '' },
|
||||||
|
communication: { score: analysis?.quality?.engagement_score || 0, comment: '' },
|
||||||
|
professional_knowledge: { score: analysis?.quality?.response_quality || 0, comment: '' },
|
||||||
|
response_quality: { score: analysis?.quality?.overall_score || 0, comment: '' },
|
||||||
|
goal_achievement: { score: analysis?.quality?.overall_score || 0, comment: '' }
|
||||||
|
},
|
||||||
|
highlights: [],
|
||||||
|
improvements: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('加载报告失败:', error)
|
||||||
|
ElMessage.warning('加载报告数据失败,使用演示数据')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 降级到模拟数据
|
||||||
|
roomInfo.value = {
|
||||||
|
scene_name: '销售场景对练',
|
||||||
|
duration_seconds: 300,
|
||||||
|
total_turns: 15
|
||||||
|
}
|
||||||
|
|
||||||
analysisResult.value = {
|
analysisResult.value = {
|
||||||
overall_evaluation: {
|
overall_evaluation: {
|
||||||
|
|||||||
Reference in New Issue
Block a user