1. 课程学习进度追踪
- 新增 UserCourseProgress 和 UserMaterialProgress 模型
- 新增 /api/v1/progress/* 进度追踪 API
- 更新 admin.py 使用真实课程完成率数据
2. 路由权限检查完善
- 新增前端 permissionChecker.ts 权限检查工具
- 更新 router/guard.ts 实现团队和课程权限验证
- 新增后端 permission_service.py
3. AI 陪练音频转文本
- 新增 speech_recognition.py 语音识别服务
- 新增 /api/v1/speech/* API
- 更新 ai-practice-coze.vue 支持语音输入
4. 双人对练报告生成
- 更新 practice_room_service.py 添加报告生成功能
- 新增 /rooms/{room_code}/report API
- 更新 duo-practice-report.vue 调用真实 API
5. 学习提醒推送
- 新增 notification_service.py 通知服务
- 新增 scheduler_service.py 定时任务服务
- 支持钉钉、企微、站内消息推送
6. 智能学习推荐
- 新增 recommendation_service.py 推荐服务
- 新增 /api/v1/recommendations/* API
- 支持错题、能力、进度、热门多维度推荐
7. 安全问题修复
- DEBUG 默认值改为 False
- 添加 SECRET_KEY 安全警告
- 新增 check_security_settings() 检查函数
8. 证书 PDF 生成
- 更新 certificate_service.py 添加 PDF 生成
- 添加 weasyprint、Pillow、qrcode 依赖
- 更新下载 API 支持 PDF 和 PNG 格式
This commit is contained in:
48
backend/.env.production
Normal file
48
backend/.env.production
Normal file
@@ -0,0 +1,48 @@
|
||||
APP_NAME="考培练系统后端"
|
||||
APP_VERSION="1.0.0"
|
||||
DEBUG=false
|
||||
HOST=0.0.0.0
|
||||
PORT=8000
|
||||
DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@mysql:3306/kaopeilian?charset=utf8mb4
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
SECRET_KEY=66a6a6a2f1d84c2c8b3dbf02b7a2e2b8b7d88b1e7d1c4f8d9a2c6e1f4b9a7c3d
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
REFRESH_TOKEN_EXPIRE_DAYS=7
|
||||
CORS_ORIGINS=["https://aiedu.ireborn.com.cn", "http://aiedu.ireborn.com.cn"]
|
||||
LOG_LEVEL=INFO
|
||||
LOG_FORMAT=json
|
||||
UPLOAD_MAX_SIZE=10485760
|
||||
UPLOAD_ALLOWED_TYPES=["image/jpeg", "image/png", "application/pdf", "audio/mpeg", "audio/wav", "audio/webm"]
|
||||
UPLOAD_DIR=uploads
|
||||
|
||||
# Coze OAuth配置
|
||||
COZE_OAUTH_CLIENT_ID=1114009328887
|
||||
COZE_OAUTH_PUBLIC_KEY_ID=GGs9pw0BDHx2k9vGGehUyRgKV-PyUWLBncDs-YNNN_I
|
||||
COZE_OAUTH_PRIVATE_KEY_PATH=/app/secrets/coze_private_key.pem
|
||||
COZE_PRACTICE_BOT_ID=7560643598174683145
|
||||
|
||||
# Dify API 配置 (测试环境)
|
||||
# 播课工作流配置 (测试-06-播课工作流)
|
||||
COZE_BROADCAST_WORKFLOW_ID=7577983042284486666
|
||||
COZE_BROADCAST_SPACE_ID=7474971491470688296
|
||||
COZE_BROADCAST_BOT_ID=7560643598174683145
|
||||
|
||||
# AI 服务配置(遵循瑞小美AI接入规范 - 多 Key 策略)
|
||||
AI_PRIMARY_API_KEY=sk-V9QfxYscJzD54ZRIDyAvnd4Lew4IdtPUQqwDQ0swNkIYObxT
|
||||
AI_ANTHROPIC_API_KEY=sk-wNAkUW3zwBeKBN8EeK5KnXXgOixnW5rhZmKolo8fBQuHepkX
|
||||
AI_PRIMARY_BASE_URL=https://4sapi.com/v1
|
||||
AI_FALLBACK_API_KEY=
|
||||
AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1
|
||||
AI_DEFAULT_MODEL=gemini-3-flash-preview
|
||||
AI_TIMEOUT=120
|
||||
|
||||
# 租户配置(用于多租户部署)
|
||||
TENANT_CODE=demo
|
||||
|
||||
# 管理库连接配置(用于从 tenant_configs 表读取配置)
|
||||
ADMIN_DB_HOST=prod-mysql
|
||||
ADMIN_DB_PORT=3306
|
||||
ADMIN_DB_USER=root
|
||||
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
|
||||
ADMIN_DB_NAME=kaopeilian_admin
|
||||
@@ -116,5 +116,14 @@ api_router.include_router(certificate_router, prefix="/certificates", tags=["cer
|
||||
# dashboard_router 数据大屏路由
|
||||
from .endpoints.dashboard import router as dashboard_router
|
||||
api_router.include_router(dashboard_router, prefix="/dashboard", tags=["dashboard"])
|
||||
# progress_router 学习进度追踪路由
|
||||
from .endpoints.progress import router as progress_router
|
||||
api_router.include_router(progress_router, prefix="/progress", tags=["progress"])
|
||||
# speech_router 语音识别路由
|
||||
from .endpoints.speech import router as speech_router
|
||||
api_router.include_router(speech_router, prefix="/speech", tags=["speech"])
|
||||
# recommendation_router 智能推荐路由
|
||||
from .endpoints.recommendation import router as recommendation_router
|
||||
api_router.include_router(recommendation_router, prefix="/recommendations", tags=["recommendations"])
|
||||
|
||||
__all__ = ["api_router"]
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import select, func
|
||||
from app.core.deps import get_current_active_user as get_current_user, get_db
|
||||
from app.models.user import User
|
||||
from app.models.course import Course, CourseStatus
|
||||
from app.models.user_course_progress import UserCourseProgress, ProgressStatus
|
||||
from app.schemas.base import ResponseModel
|
||||
|
||||
router = APIRouter(prefix="/admin")
|
||||
@@ -61,18 +62,32 @@ async def get_dashboard_stats(
|
||||
.where(Course.status == CourseStatus.PUBLISHED)
|
||||
)
|
||||
|
||||
# TODO: 完成的课程数需要根据用户课程进度表计算
|
||||
completed_courses = 0 # 暂时设为0
|
||||
# 根据用户课程进度表计算完成的课程学习记录数
|
||||
completed_courses = await db.scalar(
|
||||
select(func.count(UserCourseProgress.id))
|
||||
.where(UserCourseProgress.status == ProgressStatus.COMPLETED.value)
|
||||
) or 0
|
||||
|
||||
# 考试统计(如果有考试表的话)
|
||||
total_exams = 0
|
||||
avg_score = 0.0
|
||||
pass_rate = "0%"
|
||||
|
||||
# 学习时长统计(如果有学习记录表的话)
|
||||
total_learning_hours = 0
|
||||
avg_learning_hours = 0.0
|
||||
active_rate = "0%"
|
||||
# 学习时长统计 - 从用户课程进度表获取
|
||||
total_study_seconds = await db.scalar(
|
||||
select(func.coalesce(func.sum(UserCourseProgress.total_study_time), 0))
|
||||
) or 0
|
||||
total_learning_hours = round(total_study_seconds / 3600)
|
||||
|
||||
# 平均学习时长(每个活跃用户)
|
||||
active_learners = await db.scalar(
|
||||
select(func.count(func.distinct(UserCourseProgress.user_id)))
|
||||
.where(UserCourseProgress.status != ProgressStatus.NOT_STARTED.value)
|
||||
) or 0
|
||||
avg_learning_hours = round(total_study_seconds / 3600 / max(active_learners, 1), 1)
|
||||
|
||||
# 活跃率 = 有学习记录的用户 / 总用户
|
||||
active_rate = f"{round(active_learners / max(total_users, 1) * 100)}%"
|
||||
|
||||
# 构建响应数据
|
||||
stats = {
|
||||
@@ -195,10 +210,28 @@ async def get_course_completion_data(
|
||||
for course_name, course_id in courses:
|
||||
course_names.append(course_name)
|
||||
|
||||
# TODO: 根据用户课程进度表计算完成率
|
||||
# 这里暂时生成模拟数据
|
||||
import random
|
||||
completion_rate = random.randint(60, 95)
|
||||
# 根据用户课程进度表计算完成率
|
||||
# 统计该课程的完成用户数和总学习用户数
|
||||
stats_result = await db.execute(
|
||||
select(
|
||||
func.count(UserCourseProgress.id).label('total'),
|
||||
func.sum(
|
||||
func.case(
|
||||
(UserCourseProgress.status == ProgressStatus.COMPLETED.value, 1),
|
||||
else_=0
|
||||
)
|
||||
).label('completed')
|
||||
).where(UserCourseProgress.course_id == course_id)
|
||||
)
|
||||
stats = stats_result.one()
|
||||
total_learners = stats.total or 0
|
||||
completed_learners = stats.completed or 0
|
||||
|
||||
# 计算完成率
|
||||
if total_learners > 0:
|
||||
completion_rate = round(completed_learners / total_learners * 100)
|
||||
else:
|
||||
completion_rate = 0
|
||||
completion_rates.append(completion_rate)
|
||||
|
||||
return ResponseModel(
|
||||
|
||||
@@ -149,12 +149,19 @@ async def get_certificate_image(
|
||||
|
||||
|
||||
@router.get("/{cert_id}/download")
|
||||
async def download_certificate_pdf(
|
||||
async def download_certificate(
|
||||
cert_id: int,
|
||||
format: str = Query("pdf", description="下载格式: pdf 或 png"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user)
|
||||
):
|
||||
"""下载证书PDF"""
|
||||
"""
|
||||
下载证书
|
||||
|
||||
支持 PDF 和 PNG 两种格式
|
||||
- PDF: 高质量打印版本(需要安装 weasyprint)
|
||||
- PNG: 图片版本
|
||||
"""
|
||||
service = CertificateService(db)
|
||||
cert = await service.get_certificate_by_id(cert_id)
|
||||
|
||||
@@ -164,8 +171,8 @@ async def download_certificate_pdf(
|
||||
detail="证书不存在"
|
||||
)
|
||||
|
||||
# 如果已有PDF URL则重定向
|
||||
if cert.get("pdf_url"):
|
||||
# 如果已有缓存的 PDF/图片 URL 则返回
|
||||
if format.lower() == "pdf" and cert.get("pdf_url"):
|
||||
return {
|
||||
"code": 200,
|
||||
"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:
|
||||
base_url = "https://kpl.example.com/certificates"
|
||||
image_bytes = await service.generate_certificate_image(cert_id, base_url)
|
||||
from app.core.config import settings
|
||||
base_url = settings.PUBLIC_DOMAIN + "/certificates"
|
||||
|
||||
content, filename, mime_type = await service.download_certificate(
|
||||
cert_id, format, base_url
|
||||
)
|
||||
|
||||
return StreamingResponse(
|
||||
io.BytesIO(image_bytes),
|
||||
media_type="image/png",
|
||||
io.BytesIO(content),
|
||||
media_type=mime_type,
|
||||
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:
|
||||
raise HTTPException(
|
||||
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_VERSION: str = "1.0.0"
|
||||
DEBUG: bool = Field(default=True)
|
||||
# DEBUG 模式:生产环境必须设置为 False
|
||||
# 通过环境变量 DEBUG=false 或在 .env 文件中设置
|
||||
DEBUG: bool = Field(default=False, description="调试模式,生产环境必须设置为 False")
|
||||
|
||||
# 租户配置(用于多租户部署)
|
||||
TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl")
|
||||
@@ -56,7 +58,12 @@ class Settings(BaseSettings):
|
||||
REDIS_URL: str = Field(default="redis://localhost:6379/0")
|
||||
|
||||
# JWT配置
|
||||
SECRET_KEY: str = Field(default="your-secret-key-here")
|
||||
# 安全警告:必须在生产环境设置 SECRET_KEY 环境变量
|
||||
# 可以使用命令生成:python -c "import secrets; print(secrets.token_urlsafe(32))"
|
||||
SECRET_KEY: str = Field(
|
||||
default="INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION",
|
||||
description="JWT 密钥,生产环境必须通过环境变量设置安全的随机密钥"
|
||||
)
|
||||
ALGORITHM: str = Field(default="HS256")
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30)
|
||||
REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7)
|
||||
@@ -165,6 +172,57 @@ def get_settings() -> Settings:
|
||||
settings = get_settings()
|
||||
|
||||
|
||||
def check_security_settings() -> list[str]:
|
||||
"""
|
||||
检查安全配置
|
||||
|
||||
返回安全警告列表,生产环境应确保列表为空
|
||||
"""
|
||||
warnings = []
|
||||
|
||||
# 检查 DEBUG 模式
|
||||
if settings.DEBUG:
|
||||
warnings.append(
|
||||
"⚠️ DEBUG 模式已开启。生产环境请设置 DEBUG=false"
|
||||
)
|
||||
|
||||
# 检查 SECRET_KEY
|
||||
if settings.SECRET_KEY == "INSECURE-DEFAULT-KEY-CHANGE-IN-PRODUCTION":
|
||||
warnings.append(
|
||||
"⚠️ 使用默认 SECRET_KEY 不安全。生产环境请设置安全的 SECRET_KEY 环境变量。"
|
||||
"生成命令:python -c \"import secrets; print(secrets.token_urlsafe(32))\""
|
||||
)
|
||||
elif len(settings.SECRET_KEY) < 32:
|
||||
warnings.append(
|
||||
"⚠️ SECRET_KEY 长度不足 32 字符,安全性较弱"
|
||||
)
|
||||
|
||||
# 检查数据库密码
|
||||
if settings.MYSQL_PASSWORD in ["password", "123456", "root", ""]:
|
||||
warnings.append(
|
||||
"⚠️ 数据库密码不安全,请使用强密码"
|
||||
)
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
def print_security_warnings():
|
||||
"""打印安全警告(应用启动时调用)"""
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
warnings = check_security_settings()
|
||||
|
||||
if warnings:
|
||||
logger.warning("=" * 60)
|
||||
logger.warning("安全配置警告:")
|
||||
for warning in warnings:
|
||||
logger.warning(warning)
|
||||
logger.warning("=" * 60)
|
||||
else:
|
||||
logger.info("✅ 安全配置检查通过")
|
||||
|
||||
|
||||
# ============================================
|
||||
# 动态配置获取(支持从数据库读取)
|
||||
# ============================================
|
||||
|
||||
71
backend/app/migrations/add_user_course_progress.sql
Normal file
71
backend/app/migrations/add_user_course_progress.sql
Normal file
@@ -0,0 +1,71 @@
|
||||
-- ================================================================
|
||||
-- 用户课程学习进度表迁移脚本
|
||||
-- 创建日期: 2026-01-30
|
||||
-- 功能: 添加用户课程进度追踪表和用户资料进度追踪表
|
||||
-- ================================================================
|
||||
|
||||
-- 事务开始
|
||||
START TRANSACTION;
|
||||
|
||||
-- ================================================================
|
||||
-- 1. 创建用户课程进度表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS user_course_progress (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
course_id INT NOT NULL COMMENT '课程ID',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'not_started' COMMENT '学习状态:not_started/in_progress/completed',
|
||||
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '完成百分比(0-100)',
|
||||
completed_materials INT NOT NULL DEFAULT 0 COMMENT '已完成资料数',
|
||||
total_materials INT NOT NULL DEFAULT 0 COMMENT '总资料数',
|
||||
total_study_time INT NOT NULL DEFAULT 0 COMMENT '总学习时长(秒)',
|
||||
first_accessed_at DATETIME COMMENT '首次访问时间',
|
||||
last_accessed_at DATETIME COMMENT '最后访问时间',
|
||||
completed_at DATETIME COMMENT '完成时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_user_course (user_id, course_id),
|
||||
INDEX idx_user_course_progress_user (user_id),
|
||||
INDEX idx_user_course_progress_course (course_id),
|
||||
INDEX idx_user_course_progress_status (status),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户课程进度表';
|
||||
|
||||
-- ================================================================
|
||||
-- 2. 创建用户资料进度表
|
||||
-- ================================================================
|
||||
CREATE TABLE IF NOT EXISTS user_material_progress (
|
||||
id INT PRIMARY KEY AUTO_INCREMENT,
|
||||
user_id INT NOT NULL COMMENT '用户ID',
|
||||
material_id INT NOT NULL COMMENT '资料ID',
|
||||
course_id INT NOT NULL COMMENT '课程ID(冗余字段)',
|
||||
is_completed BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否已完成',
|
||||
progress_percent FLOAT NOT NULL DEFAULT 0 COMMENT '阅读/播放进度百分比(0-100)',
|
||||
last_position INT NOT NULL DEFAULT 0 COMMENT '上次播放位置(秒)',
|
||||
total_duration INT NOT NULL DEFAULT 0 COMMENT '媒体总时长(秒)',
|
||||
study_time INT NOT NULL DEFAULT 0 COMMENT '学习时长(秒)',
|
||||
first_accessed_at DATETIME COMMENT '首次访问时间',
|
||||
last_accessed_at DATETIME COMMENT '最后访问时间',
|
||||
completed_at DATETIME COMMENT '完成时间',
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uq_user_material (user_id, material_id),
|
||||
INDEX idx_user_material_progress_user (user_id),
|
||||
INDEX idx_user_material_progress_material (material_id),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户资料进度表';
|
||||
|
||||
-- 提交事务
|
||||
COMMIT;
|
||||
|
||||
-- ================================================================
|
||||
-- 验证表创建
|
||||
-- ================================================================
|
||||
SELECT 'user_course_progress' as table_name, COUNT(*) as count FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'user_course_progress'
|
||||
UNION ALL
|
||||
SELECT 'user_material_progress' as table_name, COUNT(*) as count FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_name = 'user_material_progress';
|
||||
@@ -32,6 +32,11 @@ from app.models.certificate import (
|
||||
UserCertificate,
|
||||
CertificateType,
|
||||
)
|
||||
from app.models.user_course_progress import (
|
||||
UserCourseProgress,
|
||||
UserMaterialProgress,
|
||||
ProgressStatus,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"Base",
|
||||
@@ -72,4 +77,7 @@ __all__ = [
|
||||
"CertificateTemplate",
|
||||
"UserCertificate",
|
||||
"CertificateType",
|
||||
"UserCourseProgress",
|
||||
"UserMaterialProgress",
|
||||
"ProgressStatus",
|
||||
]
|
||||
|
||||
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:
|
||||
cert.image_url = image_url
|
||||
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
|
||||
from sqlalchemy import select, and_, desc, func, update
|
||||
from sqlalchemy.orm import selectinload
|
||||
import os
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional, List, Dict, Any
|
||||
import httpx
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select, and_
|
||||
|
||||
from app.core.logger import get_logger
|
||||
from app.models.notification import Notification
|
||||
from app.models.user import User
|
||||
from app.schemas.notification import (
|
||||
NotificationCreate,
|
||||
NotificationBatchCreate,
|
||||
NotificationResponse,
|
||||
NotificationType,
|
||||
)
|
||||
from app.services.base_service import BaseService
|
||||
from app.models.notification import Notification
|
||||
|
||||
logger = get_logger(__name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NotificationService(BaseService[Notification]):
|
||||
"""
|
||||
站内消息通知服务
|
||||
class NotificationChannel:
|
||||
"""通知渠道基类"""
|
||||
|
||||
提供通知的创建、查询、标记已读等功能
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
super().__init__(Notification)
|
||||
|
||||
async def create_notification(
|
||||
async def send(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
notification_in: NotificationCreate
|
||||
) -> Notification:
|
||||
"""
|
||||
创建单个通知
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
notification_in: 通知创建数据
|
||||
|
||||
Returns:
|
||||
创建的通知对象
|
||||
"""
|
||||
notification = Notification(
|
||||
user_id=notification_in.user_id,
|
||||
title=notification_in.title,
|
||||
content=notification_in.content,
|
||||
type=notification_in.type.value if isinstance(notification_in.type, NotificationType) else notification_in.type,
|
||||
related_id=notification_in.related_id,
|
||||
related_type=notification_in.related_type,
|
||||
sender_id=notification_in.sender_id,
|
||||
is_read=False
|
||||
)
|
||||
|
||||
db.add(notification)
|
||||
await db.commit()
|
||||
await db.refresh(notification)
|
||||
|
||||
logger.info(
|
||||
"创建通知成功",
|
||||
notification_id=notification.id,
|
||||
user_id=notification_in.user_id,
|
||||
type=notification_in.type
|
||||
)
|
||||
|
||||
return notification
|
||||
|
||||
async def batch_create_notifications(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
batch_in: NotificationBatchCreate
|
||||
) -> List[Notification]:
|
||||
"""
|
||||
批量创建通知(发送给多个用户)
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
batch_in: 批量通知创建数据
|
||||
|
||||
Returns:
|
||||
创建的通知列表
|
||||
"""
|
||||
notifications = []
|
||||
notification_type = batch_in.type.value if isinstance(batch_in.type, NotificationType) else batch_in.type
|
||||
|
||||
for user_id in batch_in.user_ids:
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
title=batch_in.title,
|
||||
content=batch_in.content,
|
||||
type=notification_type,
|
||||
related_id=batch_in.related_id,
|
||||
related_type=batch_in.related_type,
|
||||
sender_id=batch_in.sender_id,
|
||||
is_read=False
|
||||
)
|
||||
notifications.append(notification)
|
||||
db.add(notification)
|
||||
|
||||
await db.commit()
|
||||
|
||||
# 刷新所有对象
|
||||
for notification in notifications:
|
||||
await db.refresh(notification)
|
||||
|
||||
logger.info(
|
||||
"批量创建通知成功",
|
||||
count=len(notifications),
|
||||
user_ids=batch_in.user_ids,
|
||||
type=batch_in.type
|
||||
)
|
||||
|
||||
return notifications
|
||||
|
||||
async def get_user_notifications(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
skip: int = 0,
|
||||
limit: int = 20,
|
||||
is_read: Optional[bool] = None,
|
||||
notification_type: Optional[str] = None
|
||||
) -> Tuple[List[NotificationResponse], int, int]:
|
||||
"""
|
||||
获取用户的通知列表
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
skip: 跳过数量
|
||||
limit: 返回数量
|
||||
is_read: 是否已读筛选
|
||||
notification_type: 通知类型筛选
|
||||
|
||||
Returns:
|
||||
(通知列表, 总数, 未读数)
|
||||
"""
|
||||
# 构建基础查询条件
|
||||
conditions = [Notification.user_id == user_id]
|
||||
|
||||
if is_read is not None:
|
||||
conditions.append(Notification.is_read == is_read)
|
||||
|
||||
if notification_type:
|
||||
conditions.append(Notification.type == notification_type)
|
||||
|
||||
# 查询通知列表(带发送者信息)
|
||||
stmt = (
|
||||
select(Notification)
|
||||
.where(and_(*conditions))
|
||||
.order_by(desc(Notification.created_at))
|
||||
.offset(skip)
|
||||
.limit(limit)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
notifications = result.scalars().all()
|
||||
|
||||
# 统计总数
|
||||
count_stmt = select(func.count()).select_from(Notification).where(and_(*conditions))
|
||||
total_result = await db.execute(count_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
# 统计未读数
|
||||
unread_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
|
||||
)
|
||||
unread_result = await db.execute(unread_stmt)
|
||||
unread_count = unread_result.scalar_one()
|
||||
|
||||
# 获取发送者信息
|
||||
sender_ids = [n.sender_id for n in notifications if n.sender_id]
|
||||
sender_names = {}
|
||||
if sender_ids:
|
||||
sender_stmt = select(User.id, User.full_name).where(User.id.in_(sender_ids))
|
||||
sender_result = await db.execute(sender_stmt)
|
||||
sender_names = {row[0]: row[1] for row in sender_result.fetchall()}
|
||||
|
||||
# 构建响应
|
||||
responses = []
|
||||
for notification in notifications:
|
||||
response = NotificationResponse(
|
||||
id=notification.id,
|
||||
user_id=notification.user_id,
|
||||
title=notification.title,
|
||||
content=notification.content,
|
||||
type=notification.type,
|
||||
is_read=notification.is_read,
|
||||
related_id=notification.related_id,
|
||||
related_type=notification.related_type,
|
||||
sender_id=notification.sender_id,
|
||||
sender_name=sender_names.get(notification.sender_id) if notification.sender_id else None,
|
||||
created_at=notification.created_at,
|
||||
updated_at=notification.updated_at
|
||||
)
|
||||
responses.append(response)
|
||||
|
||||
return responses, total, unread_count
|
||||
|
||||
async def get_unread_count(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int
|
||||
) -> Tuple[int, int]:
|
||||
"""
|
||||
获取用户未读通知数量
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
|
||||
Returns:
|
||||
(未读数, 总数)
|
||||
"""
|
||||
# 统计未读数
|
||||
unread_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(and_(Notification.user_id == user_id, Notification.is_read == False))
|
||||
)
|
||||
unread_result = await db.execute(unread_stmt)
|
||||
unread_count = unread_result.scalar_one()
|
||||
|
||||
# 统计总数
|
||||
total_stmt = (
|
||||
select(func.count())
|
||||
.select_from(Notification)
|
||||
.where(Notification.user_id == user_id)
|
||||
)
|
||||
total_result = await db.execute(total_stmt)
|
||||
total = total_result.scalar_one()
|
||||
|
||||
return unread_count, total
|
||||
|
||||
async def mark_as_read(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
notification_ids: Optional[List[int]] = None
|
||||
) -> int:
|
||||
"""
|
||||
标记通知为已读
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
notification_ids: 通知ID列表,为空则标记全部
|
||||
|
||||
Returns:
|
||||
更新的数量
|
||||
"""
|
||||
conditions = [
|
||||
Notification.user_id == user_id,
|
||||
Notification.is_read == False
|
||||
]
|
||||
|
||||
if notification_ids:
|
||||
conditions.append(Notification.id.in_(notification_ids))
|
||||
|
||||
stmt = (
|
||||
update(Notification)
|
||||
.where(and_(*conditions))
|
||||
.values(is_read=True)
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
await db.commit()
|
||||
|
||||
updated_count = result.rowcount
|
||||
|
||||
logger.info(
|
||||
"标记通知已读",
|
||||
user_id=user_id,
|
||||
notification_ids=notification_ids,
|
||||
updated_count=updated_count
|
||||
)
|
||||
|
||||
return updated_count
|
||||
|
||||
async def delete_notification(
|
||||
self,
|
||||
db: AsyncSession,
|
||||
user_id: int,
|
||||
notification_id: int
|
||||
title: str,
|
||||
content: str,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""
|
||||
删除通知
|
||||
发送通知
|
||||
|
||||
Args:
|
||||
db: 数据库会话
|
||||
user_id: 用户ID
|
||||
notification_id: 通知ID
|
||||
title: 通知标题
|
||||
content: 通知内容
|
||||
|
||||
Returns:
|
||||
是否删除成功
|
||||
是否发送成功
|
||||
"""
|
||||
stmt = select(Notification).where(
|
||||
and_(
|
||||
Notification.id == notification_id,
|
||||
Notification.user_id == user_id
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class DingtalkChannel(NotificationChannel):
|
||||
"""
|
||||
钉钉通知渠道
|
||||
|
||||
使用钉钉工作通知 API 发送消息
|
||||
文档: https://open.dingtalk.com/document/orgapp/asynchronous-sending-of-enterprise-session-messages
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
app_key: Optional[str] = None,
|
||||
app_secret: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
):
|
||||
self.app_key = app_key or os.getenv("DINGTALK_APP_KEY")
|
||||
self.app_secret = app_secret or os.getenv("DINGTALK_APP_SECRET")
|
||||
self.agent_id = agent_id or os.getenv("DINGTALK_AGENT_ID")
|
||||
self._access_token = None
|
||||
self._token_expires_at = None
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""获取钉钉访问令牌"""
|
||||
if (
|
||||
self._access_token
|
||||
and self._token_expires_at
|
||||
and datetime.now() < self._token_expires_at
|
||||
):
|
||||
return self._access_token
|
||||
|
||||
url = "https://oapi.dingtalk.com/gettoken"
|
||||
params = {
|
||||
"appkey": self.app_key,
|
||||
"appsecret": self.app_secret,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params=params, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
self._access_token = result["access_token"]
|
||||
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
|
||||
return self._access_token
|
||||
else:
|
||||
raise Exception(f"获取钉钉Token失败: {result.get('errmsg')}")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
dingtalk_user_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""发送钉钉工作通知"""
|
||||
if not all([self.app_key, self.app_secret, self.agent_id]):
|
||||
logger.warning("钉钉配置不完整,跳过发送")
|
||||
return False
|
||||
|
||||
if not dingtalk_user_id:
|
||||
logger.warning(f"用户 {user_id} 没有绑定钉钉ID")
|
||||
return False
|
||||
|
||||
try:
|
||||
access_token = await self._get_access_token()
|
||||
|
||||
url = f"https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={access_token}"
|
||||
|
||||
# 构建消息体
|
||||
msg = {
|
||||
"agent_id": self.agent_id,
|
||||
"userid_list": dingtalk_user_id,
|
||||
"msg": {
|
||||
"msgtype": "text",
|
||||
"text": {
|
||||
"content": f"{title}\n\n{content}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=msg, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
logger.info(f"钉钉消息发送成功: user_id={user_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"钉钉消息发送失败: {result.get('errmsg')}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"钉钉消息发送异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
class WeworkChannel(NotificationChannel):
|
||||
"""
|
||||
企业微信通知渠道
|
||||
|
||||
使用企业微信应用消息 API
|
||||
文档: https://developer.work.weixin.qq.com/document/path/90236
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
corp_id: Optional[str] = None,
|
||||
corp_secret: Optional[str] = None,
|
||||
agent_id: Optional[str] = None,
|
||||
):
|
||||
self.corp_id = corp_id or os.getenv("WEWORK_CORP_ID")
|
||||
self.corp_secret = corp_secret or os.getenv("WEWORK_CORP_SECRET")
|
||||
self.agent_id = agent_id or os.getenv("WEWORK_AGENT_ID")
|
||||
self._access_token = None
|
||||
self._token_expires_at = None
|
||||
|
||||
async def _get_access_token(self) -> str:
|
||||
"""获取企业微信访问令牌"""
|
||||
if (
|
||||
self._access_token
|
||||
and self._token_expires_at
|
||||
and datetime.now() < self._token_expires_at
|
||||
):
|
||||
return self._access_token
|
||||
|
||||
url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken"
|
||||
params = {
|
||||
"corpid": self.corp_id,
|
||||
"corpsecret": self.corp_secret,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.get(url, params=params, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
self._access_token = result["access_token"]
|
||||
self._token_expires_at = datetime.now() + timedelta(seconds=7000)
|
||||
return self._access_token
|
||||
else:
|
||||
raise Exception(f"获取企微Token失败: {result.get('errmsg')}")
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
wework_user_id: Optional[str] = None,
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""发送企业微信应用消息"""
|
||||
if not all([self.corp_id, self.corp_secret, self.agent_id]):
|
||||
logger.warning("企业微信配置不完整,跳过发送")
|
||||
return False
|
||||
|
||||
if not wework_user_id:
|
||||
logger.warning(f"用户 {user_id} 没有绑定企业微信ID")
|
||||
return False
|
||||
|
||||
try:
|
||||
access_token = await self._get_access_token()
|
||||
|
||||
url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}"
|
||||
|
||||
# 构建消息体
|
||||
msg = {
|
||||
"touser": wework_user_id,
|
||||
"msgtype": "text",
|
||||
"agentid": int(self.agent_id),
|
||||
"text": {
|
||||
"content": f"{title}\n\n{content}"
|
||||
}
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(url, json=msg, timeout=10.0)
|
||||
result = response.json()
|
||||
|
||||
if result.get("errcode") == 0:
|
||||
logger.info(f"企微消息发送成功: user_id={user_id}")
|
||||
return True
|
||||
else:
|
||||
logger.error(f"企微消息发送失败: {result.get('errmsg')}")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"企微消息发送异常: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
class InAppChannel(NotificationChannel):
|
||||
"""站内消息通道"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
|
||||
async def send(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
notification_type: str = "system",
|
||||
**kwargs
|
||||
) -> bool:
|
||||
"""创建站内消息"""
|
||||
try:
|
||||
notification = Notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
type=notification_type,
|
||||
is_read=False,
|
||||
)
|
||||
self.db.add(notification)
|
||||
await self.db.commit()
|
||||
logger.info(f"站内消息创建成功: user_id={user_id}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"站内消息创建失败: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
class NotificationService:
|
||||
"""
|
||||
通知服务
|
||||
|
||||
统一管理多渠道通知发送
|
||||
"""
|
||||
|
||||
def __init__(self, db: AsyncSession):
|
||||
self.db = db
|
||||
self.channels = {
|
||||
"dingtalk": DingtalkChannel(),
|
||||
"wework": WeworkChannel(),
|
||||
"inapp": InAppChannel(db),
|
||||
}
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
user_id: int,
|
||||
title: str,
|
||||
content: str,
|
||||
channels: Optional[List[str]] = None,
|
||||
**kwargs
|
||||
) -> Dict[str, bool]:
|
||||
"""
|
||||
发送通知
|
||||
|
||||
Args:
|
||||
user_id: 用户ID
|
||||
title: 通知标题
|
||||
content: 通知内容
|
||||
channels: 发送渠道列表,默认全部发送
|
||||
|
||||
Returns:
|
||||
各渠道发送结果
|
||||
"""
|
||||
# 获取用户信息
|
||||
user = await self._get_user(user_id)
|
||||
if not user:
|
||||
return {"error": "用户不存在"}
|
||||
|
||||
# 准备用户渠道标识
|
||||
user_channels = {
|
||||
"dingtalk_user_id": getattr(user, "dingtalk_id", None),
|
||||
"wework_user_id": getattr(user, "wework_userid", None),
|
||||
}
|
||||
|
||||
# 确定发送渠道
|
||||
target_channels = channels or ["inapp"] # 默认只发站内消息
|
||||
|
||||
results = {}
|
||||
for channel_name in target_channels:
|
||||
if channel_name in self.channels:
|
||||
channel = self.channels[channel_name]
|
||||
success = await channel.send(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
**user_channels,
|
||||
**kwargs
|
||||
)
|
||||
results[channel_name] = success
|
||||
|
||||
return results
|
||||
|
||||
async def send_learning_reminder(
|
||||
self,
|
||||
user_id: int,
|
||||
course_name: str,
|
||||
days_inactive: int = 3,
|
||||
) -> Dict[str, bool]:
|
||||
"""发送学习提醒"""
|
||||
title = "📚 学习提醒"
|
||||
content = f"您已有 {days_inactive} 天没有学习《{course_name}》课程了,快来继续学习吧!"
|
||||
|
||||
return await self.send_notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
channels=["inapp", "dingtalk", "wework"],
|
||||
notification_type="learning_reminder",
|
||||
)
|
||||
|
||||
result = await db.execute(stmt)
|
||||
notification = result.scalar_one_or_none()
|
||||
async def send_task_deadline_reminder(
|
||||
self,
|
||||
user_id: int,
|
||||
task_name: str,
|
||||
deadline: datetime,
|
||||
) -> Dict[str, bool]:
|
||||
"""发送任务截止提醒"""
|
||||
days_left = (deadline - datetime.now()).days
|
||||
title = "⏰ 任务截止提醒"
|
||||
content = f"任务《{task_name}》将于 {deadline.strftime('%Y-%m-%d %H:%M')} 截止,还有 {days_left} 天,请尽快完成!"
|
||||
|
||||
if notification:
|
||||
await db.delete(notification)
|
||||
await db.commit()
|
||||
return await self.send_notification(
|
||||
user_id=user_id,
|
||||
title=title,
|
||||
content=content,
|
||||
channels=["inapp", "dingtalk", "wework"],
|
||||
notification_type="task_deadline",
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"删除通知成功",
|
||||
notification_id=notification_id,
|
||||
user_id=user_id
|
||||
)
|
||||
return True
|
||||
async def send_exam_reminder(
|
||||
self,
|
||||
user_id: int,
|
||||
exam_name: str,
|
||||
exam_time: datetime,
|
||||
) -> Dict[str, bool]:
|
||||
"""发送考试提醒"""
|
||||
title = "📝 考试提醒"
|
||||
content = f"考试《{exam_name}》将于 {exam_time.strftime('%Y-%m-%d %H:%M')} 开始,请提前做好准备!"
|
||||
|
||||
return 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
|
||||
|
||||
# ==================== 报告生成 ====================
|
||||
|
||||
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 文档提取
|
||||
PyPDF2>=3.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 { authManager } from '@/utils/auth'
|
||||
import { loadingManager } from '@/utils/loadingManager'
|
||||
import { checkTeamMembership, checkCourseAccess, clearPermissionCache } from '@/utils/permissionChecker'
|
||||
|
||||
// 白名单路由(不需要登录)
|
||||
const WHITE_LIST = ['/login', '/register', '/404']
|
||||
@@ -109,13 +110,21 @@ async function handleRouteGuard(
|
||||
return
|
||||
}
|
||||
|
||||
// 检查特殊路由规则
|
||||
// 检查特殊路由规则(先进行同步检查)
|
||||
if (!checkSpecialRouteRules(to)) {
|
||||
ElMessage.error('访问被拒绝')
|
||||
next(authManager.getDefaultRoute())
|
||||
return
|
||||
}
|
||||
|
||||
// 异步权限检查(团队和课程权限)
|
||||
const hasSpecialAccess = await checkSpecialRouteRulesAsync(to)
|
||||
if (!hasSpecialAccess) {
|
||||
ElMessage.error('您没有访问此资源的权限')
|
||||
next(authManager.getDefaultRoute())
|
||||
return
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// 检查用户ID参数权限(只能访问自己的数据,管理员除外)
|
||||
@@ -157,14 +166,41 @@ function checkSpecialRouteRules(to: RouteLocationNormalized): boolean {
|
||||
|
||||
// 检查团队ID参数权限
|
||||
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) {
|
||||
// 这里可以添加课程访问权限检查
|
||||
// 例如检查课程是否分配给用户的岗位
|
||||
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
|
||||
|
||||
@@ -161,6 +161,12 @@ class AuthManager {
|
||||
localStorage.removeItem(this.userKey)
|
||||
localStorage.removeItem(this.tokenKey)
|
||||
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 StreamEvent
|
||||
} from '@/api/coze'
|
||||
import {
|
||||
SpeechRecognitionManager,
|
||||
isSpeechRecognitionSupported,
|
||||
type SpeechRecognitionResult
|
||||
} from '@/utils/speechRecognition'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
@@ -205,6 +210,11 @@ const voiceStatusText = ref('点击开始按钮进行语音陪练')
|
||||
const mediaRecorder = ref<MediaRecorder | null>(null)
|
||||
const audioChunks = ref<Blob[]>([])
|
||||
|
||||
// 语音识别相关
|
||||
const speechRecognition = ref<SpeechRecognitionManager | null>(null)
|
||||
const recognizedText = ref('')
|
||||
const isSpeechSupported = isSpeechRecognitionSupported()
|
||||
|
||||
// DOM引用
|
||||
const messageContainer = ref<HTMLElement>()
|
||||
|
||||
@@ -380,9 +390,21 @@ const toggleRecording = async () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 开始录音
|
||||
* 开始录音(同时启动语音识别)
|
||||
*/
|
||||
const startRecording = async () => {
|
||||
if (!cozeSession.value) {
|
||||
ElMessage.warning('请先开始陪练会话')
|
||||
return
|
||||
}
|
||||
|
||||
// 优先使用 Web Speech API 进行实时语音识别
|
||||
if (isSpeechSupported) {
|
||||
startSpeechRecognition()
|
||||
return
|
||||
}
|
||||
|
||||
// 降级到录音模式(需要后端语音识别服务)
|
||||
try {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||
|
||||
@@ -400,7 +422,7 @@ const startRecording = async () => {
|
||||
|
||||
mediaRecorder.value.start()
|
||||
isRecording.value = true
|
||||
voiceStatusText.value = '正在录音...'
|
||||
voiceStatusText.value = '正在录音(浏览器不支持实时识别,录音结束后将发送到服务器识别)...'
|
||||
} catch (error) {
|
||||
ElMessage.error('无法访问麦克风')
|
||||
}
|
||||
@@ -410,6 +432,13 @@ const startRecording = async () => {
|
||||
* 停止录音
|
||||
*/
|
||||
const stopRecording = () => {
|
||||
// 如果使用的是 Web Speech API
|
||||
if (speechRecognition.value) {
|
||||
stopSpeechRecognition()
|
||||
return
|
||||
}
|
||||
|
||||
// 如果使用的是录音模式
|
||||
if (mediaRecorder.value && isRecording.value) {
|
||||
mediaRecorder.value.stop()
|
||||
mediaRecorder.value.stream.getTracks().forEach(track => track.stop())
|
||||
@@ -420,13 +449,116 @@ const stopRecording = () => {
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理录音
|
||||
* 处理录音(使用 Web Speech API 已识别的文本)
|
||||
*/
|
||||
const processAudio = async (_audioBlob: Blob) => {
|
||||
// TODO: 实现音频转文本和发送逻辑
|
||||
isProcessing.value = false
|
||||
voiceStatusText.value = '点击开始按钮进行语音陪练'
|
||||
ElMessage.info('语音功能正在开发中')
|
||||
try {
|
||||
// 检查是否有识别结果
|
||||
const text = recognizedText.value.trim()
|
||||
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) {
|
||||
stopRecording()
|
||||
}
|
||||
if (speechRecognition.value) {
|
||||
speechRecognition.value.destroy()
|
||||
speechRecognition.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -184,6 +184,8 @@
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { ArrowLeft } from '@element-plus/icons-vue'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { getPracticeReport } from '@/api/duoPractice'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -223,22 +225,69 @@ const getScoreColor = (score: number) => {
|
||||
|
||||
// 加载报告数据
|
||||
const loadReport = async () => {
|
||||
const roomId = route.params.id
|
||||
if (!roomId) return
|
||||
const roomCode = route.params.id as string
|
||||
if (!roomCode) return
|
||||
|
||||
isLoading.value = true
|
||||
try {
|
||||
// TODO: 调用 API 获取报告
|
||||
// const res = await getDuoPracticeReport(roomId)
|
||||
// roomInfo.value = res.data.room
|
||||
// analysisResult.value = res.data.analysis
|
||||
// 调用 API 获取报告
|
||||
const res = await getPracticeReport(roomCode)
|
||||
if (res.data) {
|
||||
roomInfo.value = res.data.room
|
||||
|
||||
// 模拟数据
|
||||
roomInfo.value = {
|
||||
scene_name: '销售场景对练',
|
||||
duration_seconds: 300,
|
||||
total_turns: 15
|
||||
// 转换分析数据格式以兼容现有模板
|
||||
const analysis = res.data.analysis
|
||||
analysisResult.value = {
|
||||
overall_evaluation: {
|
||||
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 = {
|
||||
overall_evaluation: {
|
||||
|
||||
Reference in New Issue
Block a user