From 64f5d567fa09845a723eefc9b480ec4ad7ab030b Mon Sep 17 00:00:00 2001 From: yuliang_guo Date: Fri, 30 Jan 2026 14:22:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=20KPL=20=E7=B3=BB?= =?UTF-8?q?=E7=BB=9F=E5=8A=9F=E8=83=BD=E6=94=B9=E8=BF=9B=E8=AE=A1=E5=88=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 格式 --- .gitignore | 144 +- CHANGELOG-2026-01-29.md | 522 ++--- backend/.env.production | 48 + backend/app/api/v1/__init__.py | 9 + backend/app/api/v1/admin.py | 53 +- backend/app/api/v1/endpoints/certificate.py | 633 +++--- backend/app/api/v1/endpoints/dashboard.py | 460 ++--- backend/app/api/v1/endpoints/level.py | 554 ++--- backend/app/api/v1/endpoints/progress.py | 470 +++++ .../app/api/v1/endpoints/recommendation.py | 157 ++ backend/app/api/v1/endpoints/speech.py | 145 ++ backend/app/api/v1/practice_room.py | 1395 +++++++------ backend/app/api/v1/system_settings.py | 612 +++--- backend/app/core/config.py | 62 +- backend/app/core/scheduler.py | 484 ++--- .../migrations/add_user_course_progress.sql | 71 + backend/app/models/__init__.py | 8 + backend/app/models/certificate.py | 152 +- backend/app/models/level.py | 280 +-- backend/app/models/practice_room.py | 244 +-- backend/app/models/user_course_progress.py | 201 ++ .../ai/duo_practice_analysis_service.py | 646 +++--- .../ai/prompts/duo_practice_prompts.py | 414 ++-- backend/app/services/badge_service.py | 1172 +++++------ backend/app/services/certificate_service.py | 1275 +++++++----- backend/app/services/dashboard_service.py | 978 ++++----- backend/app/services/dingtalk_auth_service.py | 604 +++--- backend/app/services/level_service.py | 1176 +++++------ backend/app/services/notification_service.py | 701 ++++--- backend/app/services/permission_service.py | 151 ++ backend/app/services/practice_room_service.py | 1239 ++++++----- .../app/services/recommendation_service.py | 379 ++++ backend/app/services/scheduler_service.py | 273 +++ backend/app/services/speech_recognition.py | 256 +++ backend/migrations/README.md | 164 +- backend/migrations/add_certificate_system.sql | 332 +-- backend/migrations/add_dingtalk_login.sql | 82 +- backend/migrations/add_level_badge_system.sql | 384 ++-- .../versions/add_practice_rooms_table.sql | 372 ++-- backend/requirements.txt | 7 +- docs/测试环境配置.md | 434 ++-- frontend/src/api/certificate.ts | 298 +-- frontend/src/api/dashboard.ts | 2 +- frontend/src/api/duoPractice.ts | 444 ++-- frontend/src/api/level.ts | 364 ++-- frontend/src/api/progress.ts | 158 ++ frontend/src/components/BadgeCard.vue | 348 ++-- frontend/src/components/ExpProgress.vue | 200 +- frontend/src/components/LevelBadge.vue | 170 +- frontend/src/components/LevelUpDialog.vue | 594 +++--- frontend/src/composables/useVoiceCall.ts | 924 ++++----- frontend/src/router/guard.ts | 50 +- frontend/src/stores/duoPracticeStore.ts | 826 ++++---- frontend/src/utils/auth.ts | 6 + frontend/src/utils/dingtalk.ts | 454 ++-- frontend/src/utils/permissionChecker.ts | 211 ++ frontend/src/utils/speechRecognition.ts | 294 +++ frontend/src/utils/webrtc.ts | 648 +++--- frontend/src/views/admin/data-dashboard.vue | 1536 +++++++------- frontend/src/views/admin/system-settings.vue | 502 ++--- .../src/views/trainee/ai-practice-coze.vue | 150 +- .../src/views/trainee/duo-practice-report.vue | 1137 +++++----- .../src/views/trainee/duo-practice-room.vue | 1826 ++++++++--------- frontend/src/views/trainee/duo-practice.vue | 802 ++++---- frontend/src/views/trainee/leaderboard.vue | 1242 +++++------ .../src/views/trainee/my-certificates.vue | 1468 ++++++------- 66 files changed, 18067 insertions(+), 14330 deletions(-) create mode 100644 backend/.env.production create mode 100644 backend/app/api/v1/endpoints/progress.py create mode 100644 backend/app/api/v1/endpoints/recommendation.py create mode 100644 backend/app/api/v1/endpoints/speech.py create mode 100644 backend/app/migrations/add_user_course_progress.sql create mode 100644 backend/app/models/user_course_progress.py create mode 100644 backend/app/services/permission_service.py create mode 100644 backend/app/services/recommendation_service.py create mode 100644 backend/app/services/scheduler_service.py create mode 100644 backend/app/services/speech_recognition.py create mode 100644 frontend/src/api/progress.ts create mode 100644 frontend/src/utils/permissionChecker.ts create mode 100644 frontend/src/utils/speechRecognition.ts diff --git a/.gitignore b/.gitignore index 21449d1..87e7268 100644 --- a/.gitignore +++ b/.gitignore @@ -1,72 +1,72 @@ -# ================================ -# AgentWD 项目 .gitignore -# ================================ - -# ---------------- -# 环境配置(敏感) -# ---------------- -.env -.env.local -.env.*.local -# 允许提交非敏感的环境配置(用于 Vite 构建) -# .env.production -# .env.staging - -# ---------------- -# 依赖目录 -# ---------------- -node_modules/ -.pnpm-store/ -__pycache__/ -*.pyc -.venv/ -venv/ - -# ---------------- -# 构建产物 -# ---------------- -dist/ -build/ -.output/ -*.egg-info/ - -# ---------------- -# IDE 配置 -# ---------------- -.vscode/ -.idea/ -*.swp -*.swo -.DS_Store - -# ---------------- -# 日志文件 -# ---------------- -logs/ -*.log -npm-debug.log* -pnpm-debug.log* - -# ---------------- -# 测试覆盖率 -# ---------------- -coverage/ -.nyc_output/ - -# ---------------- -# n8n 敏感信息 -# ---------------- -n8n-workflows/*-credentials.json -n8n-workflows/credentials.json - -# ---------------- -# 历史备份(.history插件) -# ---------------- -.history/ - -# ---------------- -# 临时文件 -# ---------------- -*.tmp -*.temp -.cache/ +# ================================ +# AgentWD 项目 .gitignore +# ================================ + +# ---------------- +# 环境配置(敏感) +# ---------------- +.env +.env.local +.env.*.local +# 允许提交非敏感的环境配置(用于 Vite 构建) +# .env.production +# .env.staging + +# ---------------- +# 依赖目录 +# ---------------- +node_modules/ +.pnpm-store/ +__pycache__/ +*.pyc +.venv/ +venv/ + +# ---------------- +# 构建产物 +# ---------------- +dist/ +build/ +.output/ +*.egg-info/ + +# ---------------- +# IDE 配置 +# ---------------- +.vscode/ +.idea/ +*.swp +*.swo +.DS_Store + +# ---------------- +# 日志文件 +# ---------------- +logs/ +*.log +npm-debug.log* +pnpm-debug.log* + +# ---------------- +# 测试覆盖率 +# ---------------- +coverage/ +.nyc_output/ + +# ---------------- +# n8n 敏感信息 +# ---------------- +n8n-workflows/*-credentials.json +n8n-workflows/credentials.json + +# ---------------- +# 历史备份(.history插件) +# ---------------- +.history/ + +# ---------------- +# 临时文件 +# ---------------- +*.tmp +*.temp +.cache/ diff --git a/CHANGELOG-2026-01-29.md b/CHANGELOG-2026-01-29.md index 28e362b..35d9545 100644 --- a/CHANGELOG-2026-01-29.md +++ b/CHANGELOG-2026-01-29.md @@ -1,261 +1,261 @@ -# KPL 考培练系统 功能迭代更新日志 - -**日期**: 2026-01-29 -**版本**: v1.5.0 - ---- - -## 一、奖章条件优化 - -### 修复内容 -- 修复 `badge_service.py` 中统计查询的 SQL 语法问题 -- 将非标准 `func.if_` 替换为 SQLAlchemy 标准 `case` 语句 -- 优化考试统计逻辑:通过数、满分数、优秀数分开查询 -- 添加 `func.coalesce` 处理空值 - -### 新增功能 -- `check_badges_by_category()` - 按类别检查奖章 -- `check_exam_badges()` - 考试后触发 -- `check_practice_badges()` - 练习后触发 -- `check_streak_badges()` - 签到后触发 -- `check_level_badges()` - 等级变化后触发 - -### 文件变更 -- `backend/app/services/badge_service.py` - ---- - -## 二、移动端适配 - -### 适配页面 -| 页面 | 文件 | 适配要点 | -|------|------|----------| -| 登录页 | `views/login/index.vue` | 表单全宽、背景动画隐藏、钉钉安全区域 | -| 课程中心 | `views/trainee/course-center.vue` | 单列卡片、分类横向滚动、操作按钮网格化 | -| 课程详情 | `views/trainee/course-detail.vue` | 侧边栏折叠、视频自适应、工具栏响应式 | -| 考试页面 | `views/exam/practice.vue` | 选项垂直排列、按钮放大、弹窗全屏 | -| 成长路径 | `views/trainee/growth-path.vue` | 雷达图缩放、卡片堆叠、统计区折叠 | -| 排行榜 | `views/trainee/leaderboard.vue` | 列表简化、签到按钮全宽 | - -### 技术方案 -- 使用 `@media (max-width: 768px)` 和 `@media (max-width: 480px)` 断点 -- 钉钉环境使用 `env(safe-area-inset-*)` 适配安全区域 - ---- - -## 三、证书系统 - -### 数据库设计 -```sql --- 证书模板表 -CREATE TABLE certificate_templates ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(100) NOT NULL, - type ENUM('course', 'exam', 'achievement') NOT NULL, - background_url VARCHAR(500), - template_html TEXT, - template_style TEXT, - is_active BOOLEAN DEFAULT TRUE, - ... -); - --- 用户证书表 -CREATE TABLE user_certificates ( - id INT PRIMARY KEY AUTO_INCREMENT, - user_id INT NOT NULL, - template_id INT NOT NULL, - certificate_no VARCHAR(50) UNIQUE NOT NULL, - title VARCHAR(200) NOT NULL, - ... -); -``` - -### 后端实现 -- **模型**: `CertificateTemplate`, `UserCertificate`, `CertificateType` -- **服务**: `CertificateService` - - `issue_course_certificate()` - 颁发课程证书 - - `issue_exam_certificate()` - 颁发考试证书 - - `issue_achievement_certificate()` - 颁发成就证书 - - `generate_certificate_image()` - 生成分享图片 - - `get_certificate_by_no()` - 验证证书 - -### API 端点 -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/certificates/me` | 我的证书列表 | -| GET | `/certificates/{id}` | 证书详情 | -| GET | `/certificates/{id}/image` | 获取分享图片 | -| GET | `/certificates/{id}/download` | 下载证书 | -| GET | `/certificates/verify/{no}` | 验证证书(无需登录) | -| POST | `/certificates/issue/course` | 颁发课程证书 | -| POST | `/certificates/issue/exam` | 颁发考试证书 | - -### 前端实现 -- **API**: `frontend/src/api/certificate.ts` -- **页面**: `frontend/src/views/trainee/my-certificates.vue` -- **功能**: 证书列表、分类筛选、预览、分享、下载 - -### 文件变更 -- `backend/migrations/add_certificate_system.sql` (新增) -- `backend/app/models/certificate.py` (新增) -- `backend/app/services/certificate_service.py` (新增) -- `backend/app/api/v1/endpoints/certificate.py` (新增) -- `backend/app/models/__init__.py` (修改) -- `backend/app/api/v1/__init__.py` (修改) -- `frontend/src/api/certificate.ts` (新增) -- `frontend/src/views/trainee/my-certificates.vue` (新增) -- `frontend/src/router/index.ts` (修改) - ---- - -## 四、数据大屏 - -### 数据指标 -| 类别 | 指标 | -|------|------| -| 概览 | 总学员数、今日活跃、周活跃、月活跃、总学习时长、签到率 | -| 考试 | 总次数、通过率、平均分、满分人数 | -| 部门 | 成员数、通过率、平均学习时长、平均等级 | -| 趋势 | 近7天活跃用户、学习时长、考试次数 | -| 分布 | 1-10级用户数量分布 | -| 动态 | 最新学习活动实时滚动 | - -### 后端实现 -- **服务**: `DashboardService` - - `get_enterprise_overview()` - 企业级概览 - - `get_department_comparison()` - 部门对比 - - `get_learning_trend()` - 学习趋势 - - `get_level_distribution()` - 等级分布 - - `get_realtime_activities()` - 实时动态 - - `get_team_dashboard()` - 团队级数据 - - `get_course_ranking()` - 课程热度排行 - -### API 端点 -| 方法 | 路径 | 说明 | -|------|------|------| -| GET | `/dashboard/enterprise/overview` | 企业概览 | -| GET | `/dashboard/enterprise/departments` | 部门对比 | -| GET | `/dashboard/enterprise/trend` | 学习趋势 | -| GET | `/dashboard/enterprise/level-distribution` | 等级分布 | -| GET | `/dashboard/enterprise/activities` | 实时动态 | -| GET | `/dashboard/enterprise/course-ranking` | 课程排行 | -| GET | `/dashboard/team` | 团队数据 | -| GET | `/dashboard/all` | 完整数据(一次性) | - -### 前端实现 -- **API**: `frontend/src/api/dashboard.ts` -- **页面**: `frontend/src/views/admin/data-dashboard.vue` -- **图表**: ECharts (横向柱状图、折线图、仪表盘、饼图) -- **功能**: 全屏模式、5分钟自动刷新、响应式布局 - -### 文件变更 -- `backend/app/services/dashboard_service.py` (新增) -- `backend/app/api/v1/endpoints/dashboard.py` (新增) -- `backend/app/api/v1/__init__.py` (修改) -- `frontend/src/api/dashboard.ts` (新增) -- `frontend/src/views/admin/data-dashboard.vue` (新增) -- `frontend/src/router/index.ts` (修改) - ---- - -## 部署说明 - -### 数据库迁移 -需执行以下 SQL 脚本: -```bash -# 证书系统迁移 -mysql -u root -p kaopeilian < backend/migrations/add_certificate_system.sql -``` - -### 依赖安装 -后端新增依赖(用于证书图片生成): -```bash -pip install Pillow qrcode -``` - -### 路由变更 -新增前端路由: -- `/trainee/my-certificates` - 我的证书 -- `/manager/data-dashboard` - 数据大屏 - ---- - -## 五、错误提示优化(下午更新) - -### 课程名重复错误优化 -**问题**:创建课程时名称重复返回 409 错误,前端提示不明确 - -**修复内容**: -1. **后端** `course_service.py`:课程名重复时返回 `existing_id` 和 `existing_name` -2. **前端** `edit-course.vue`:检测 409 错误后弹出确认框 - - "查看已有课程" → 跳转到已存在的课程 - - "修改名称" → 留在当前页面 - -### 通用错误处理增强 -- `errorHandler.ts` 新增 409 冲突错误处理 -- 新增工具函数:`isConflictError()`, `getConflictDetail()`, `getConflictMessage()` - -### 其他页面错误提示优化 -- `position-management.vue`:岗位创建/编辑错误提取详细信息 -- `user-management.vue`:用户编辑错误提取详细信息 - -### 文件变更 -- `backend/app/services/course_service.py` (修改) -- `frontend/src/utils/errorHandler.ts` (修改) -- `frontend/src/views/manager/edit-course.vue` (修改) -- `frontend/src/views/admin/position-management.vue` (修改) -- `frontend/src/views/admin/user-management.vue` (修改) - ---- - -## 六、部署问题修复(下午更新) - -### 后端导入路径修复 -1. `certificate.py` 模型:`from app.core.database import Base` → `from app.models.base import Base` -2. `certificate.py` API:`from app.core.database import get_db` → `from app.core.deps import get_db` -3. `dashboard.py` API:同上 -4. 合并 `get_current_user` 导入到 `app.core.deps` - -### 依赖安装 -```bash -docker exec kpl-backend-dev pip install Pillow qrcode -``` - -### 前端构建同步问题 -- 问题:构建输出到 `/root/aiedu/frontend/dist/`,但容器挂载的是 `/root/aiedu/dist-test/` -- 解决:构建后需手动同步 `cp -r /root/aiedu/frontend/dist/* /root/aiedu/dist-test/` - ---- - -## 七、钉钉免密登录问题修复(下午更新) - -### 问题现象 -- 钉钉环境打开应用后显示"没有访问此页面的权限" -- 后端日志显示登录实际成功 - -### 问题原因 -登录成功后读取 URL 中的 `redirect` 参数跳转,但该参数指向用户无权限的页面(如 /admin/*) - -### 修复内容 -`login/index.vue`:登录成功后检查 redirect 目标是否有权限 -```javascript -// 检查 redirect 目标是否有权限访问 -if ((redirect.startsWith('/admin') && userRole !== 'admin') || - (redirect.startsWith('/manager') && !['admin', 'manager'].includes(userRole))) { - redirect = defaultRoute // 改为跳转到默认页面 -} -``` - -### 调试工具 -- 钉钉环境自动启用 vConsole(`main.ts` 中根据 UA 判断) -- 依赖:`npm install vconsole` - ---- - -## 待办事项 - -- [ ] 证书 PDF 生成(需安装 weasyprint) -- [ ] 课程完成进度追踪(user_course_progress 表) -- [ ] 数据大屏数据缓存优化 -- [ ] 钉钉环境下底部导航适配 -- [ ] 移除 vConsole 调试代码(问题确认解决后) +# KPL 考培练系统 功能迭代更新日志 + +**日期**: 2026-01-29 +**版本**: v1.5.0 + +--- + +## 一、奖章条件优化 + +### 修复内容 +- 修复 `badge_service.py` 中统计查询的 SQL 语法问题 +- 将非标准 `func.if_` 替换为 SQLAlchemy 标准 `case` 语句 +- 优化考试统计逻辑:通过数、满分数、优秀数分开查询 +- 添加 `func.coalesce` 处理空值 + +### 新增功能 +- `check_badges_by_category()` - 按类别检查奖章 +- `check_exam_badges()` - 考试后触发 +- `check_practice_badges()` - 练习后触发 +- `check_streak_badges()` - 签到后触发 +- `check_level_badges()` - 等级变化后触发 + +### 文件变更 +- `backend/app/services/badge_service.py` + +--- + +## 二、移动端适配 + +### 适配页面 +| 页面 | 文件 | 适配要点 | +|------|------|----------| +| 登录页 | `views/login/index.vue` | 表单全宽、背景动画隐藏、钉钉安全区域 | +| 课程中心 | `views/trainee/course-center.vue` | 单列卡片、分类横向滚动、操作按钮网格化 | +| 课程详情 | `views/trainee/course-detail.vue` | 侧边栏折叠、视频自适应、工具栏响应式 | +| 考试页面 | `views/exam/practice.vue` | 选项垂直排列、按钮放大、弹窗全屏 | +| 成长路径 | `views/trainee/growth-path.vue` | 雷达图缩放、卡片堆叠、统计区折叠 | +| 排行榜 | `views/trainee/leaderboard.vue` | 列表简化、签到按钮全宽 | + +### 技术方案 +- 使用 `@media (max-width: 768px)` 和 `@media (max-width: 480px)` 断点 +- 钉钉环境使用 `env(safe-area-inset-*)` 适配安全区域 + +--- + +## 三、证书系统 + +### 数据库设计 +```sql +-- 证书模板表 +CREATE TABLE certificate_templates ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL, + type ENUM('course', 'exam', 'achievement') NOT NULL, + background_url VARCHAR(500), + template_html TEXT, + template_style TEXT, + is_active BOOLEAN DEFAULT TRUE, + ... +); + +-- 用户证书表 +CREATE TABLE user_certificates ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL, + template_id INT NOT NULL, + certificate_no VARCHAR(50) UNIQUE NOT NULL, + title VARCHAR(200) NOT NULL, + ... +); +``` + +### 后端实现 +- **模型**: `CertificateTemplate`, `UserCertificate`, `CertificateType` +- **服务**: `CertificateService` + - `issue_course_certificate()` - 颁发课程证书 + - `issue_exam_certificate()` - 颁发考试证书 + - `issue_achievement_certificate()` - 颁发成就证书 + - `generate_certificate_image()` - 生成分享图片 + - `get_certificate_by_no()` - 验证证书 + +### API 端点 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/certificates/me` | 我的证书列表 | +| GET | `/certificates/{id}` | 证书详情 | +| GET | `/certificates/{id}/image` | 获取分享图片 | +| GET | `/certificates/{id}/download` | 下载证书 | +| GET | `/certificates/verify/{no}` | 验证证书(无需登录) | +| POST | `/certificates/issue/course` | 颁发课程证书 | +| POST | `/certificates/issue/exam` | 颁发考试证书 | + +### 前端实现 +- **API**: `frontend/src/api/certificate.ts` +- **页面**: `frontend/src/views/trainee/my-certificates.vue` +- **功能**: 证书列表、分类筛选、预览、分享、下载 + +### 文件变更 +- `backend/migrations/add_certificate_system.sql` (新增) +- `backend/app/models/certificate.py` (新增) +- `backend/app/services/certificate_service.py` (新增) +- `backend/app/api/v1/endpoints/certificate.py` (新增) +- `backend/app/models/__init__.py` (修改) +- `backend/app/api/v1/__init__.py` (修改) +- `frontend/src/api/certificate.ts` (新增) +- `frontend/src/views/trainee/my-certificates.vue` (新增) +- `frontend/src/router/index.ts` (修改) + +--- + +## 四、数据大屏 + +### 数据指标 +| 类别 | 指标 | +|------|------| +| 概览 | 总学员数、今日活跃、周活跃、月活跃、总学习时长、签到率 | +| 考试 | 总次数、通过率、平均分、满分人数 | +| 部门 | 成员数、通过率、平均学习时长、平均等级 | +| 趋势 | 近7天活跃用户、学习时长、考试次数 | +| 分布 | 1-10级用户数量分布 | +| 动态 | 最新学习活动实时滚动 | + +### 后端实现 +- **服务**: `DashboardService` + - `get_enterprise_overview()` - 企业级概览 + - `get_department_comparison()` - 部门对比 + - `get_learning_trend()` - 学习趋势 + - `get_level_distribution()` - 等级分布 + - `get_realtime_activities()` - 实时动态 + - `get_team_dashboard()` - 团队级数据 + - `get_course_ranking()` - 课程热度排行 + +### API 端点 +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/dashboard/enterprise/overview` | 企业概览 | +| GET | `/dashboard/enterprise/departments` | 部门对比 | +| GET | `/dashboard/enterprise/trend` | 学习趋势 | +| GET | `/dashboard/enterprise/level-distribution` | 等级分布 | +| GET | `/dashboard/enterprise/activities` | 实时动态 | +| GET | `/dashboard/enterprise/course-ranking` | 课程排行 | +| GET | `/dashboard/team` | 团队数据 | +| GET | `/dashboard/all` | 完整数据(一次性) | + +### 前端实现 +- **API**: `frontend/src/api/dashboard.ts` +- **页面**: `frontend/src/views/admin/data-dashboard.vue` +- **图表**: ECharts (横向柱状图、折线图、仪表盘、饼图) +- **功能**: 全屏模式、5分钟自动刷新、响应式布局 + +### 文件变更 +- `backend/app/services/dashboard_service.py` (新增) +- `backend/app/api/v1/endpoints/dashboard.py` (新增) +- `backend/app/api/v1/__init__.py` (修改) +- `frontend/src/api/dashboard.ts` (新增) +- `frontend/src/views/admin/data-dashboard.vue` (新增) +- `frontend/src/router/index.ts` (修改) + +--- + +## 部署说明 + +### 数据库迁移 +需执行以下 SQL 脚本: +```bash +# 证书系统迁移 +mysql -u root -p kaopeilian < backend/migrations/add_certificate_system.sql +``` + +### 依赖安装 +后端新增依赖(用于证书图片生成): +```bash +pip install Pillow qrcode +``` + +### 路由变更 +新增前端路由: +- `/trainee/my-certificates` - 我的证书 +- `/manager/data-dashboard` - 数据大屏 + +--- + +## 五、错误提示优化(下午更新) + +### 课程名重复错误优化 +**问题**:创建课程时名称重复返回 409 错误,前端提示不明确 + +**修复内容**: +1. **后端** `course_service.py`:课程名重复时返回 `existing_id` 和 `existing_name` +2. **前端** `edit-course.vue`:检测 409 错误后弹出确认框 + - "查看已有课程" → 跳转到已存在的课程 + - "修改名称" → 留在当前页面 + +### 通用错误处理增强 +- `errorHandler.ts` 新增 409 冲突错误处理 +- 新增工具函数:`isConflictError()`, `getConflictDetail()`, `getConflictMessage()` + +### 其他页面错误提示优化 +- `position-management.vue`:岗位创建/编辑错误提取详细信息 +- `user-management.vue`:用户编辑错误提取详细信息 + +### 文件变更 +- `backend/app/services/course_service.py` (修改) +- `frontend/src/utils/errorHandler.ts` (修改) +- `frontend/src/views/manager/edit-course.vue` (修改) +- `frontend/src/views/admin/position-management.vue` (修改) +- `frontend/src/views/admin/user-management.vue` (修改) + +--- + +## 六、部署问题修复(下午更新) + +### 后端导入路径修复 +1. `certificate.py` 模型:`from app.core.database import Base` → `from app.models.base import Base` +2. `certificate.py` API:`from app.core.database import get_db` → `from app.core.deps import get_db` +3. `dashboard.py` API:同上 +4. 合并 `get_current_user` 导入到 `app.core.deps` + +### 依赖安装 +```bash +docker exec kpl-backend-dev pip install Pillow qrcode +``` + +### 前端构建同步问题 +- 问题:构建输出到 `/root/aiedu/frontend/dist/`,但容器挂载的是 `/root/aiedu/dist-test/` +- 解决:构建后需手动同步 `cp -r /root/aiedu/frontend/dist/* /root/aiedu/dist-test/` + +--- + +## 七、钉钉免密登录问题修复(下午更新) + +### 问题现象 +- 钉钉环境打开应用后显示"没有访问此页面的权限" +- 后端日志显示登录实际成功 + +### 问题原因 +登录成功后读取 URL 中的 `redirect` 参数跳转,但该参数指向用户无权限的页面(如 /admin/*) + +### 修复内容 +`login/index.vue`:登录成功后检查 redirect 目标是否有权限 +```javascript +// 检查 redirect 目标是否有权限访问 +if ((redirect.startsWith('/admin') && userRole !== 'admin') || + (redirect.startsWith('/manager') && !['admin', 'manager'].includes(userRole))) { + redirect = defaultRoute // 改为跳转到默认页面 +} +``` + +### 调试工具 +- 钉钉环境自动启用 vConsole(`main.ts` 中根据 UA 判断) +- 依赖:`npm install vconsole` + +--- + +## 待办事项 + +- [ ] 证书 PDF 生成(需安装 weasyprint) +- [ ] 课程完成进度追踪(user_course_progress 表) +- [ ] 数据大屏数据缓存优化 +- [ ] 钉钉环境下底部导航适配 +- [ ] 移除 vConsole 调试代码(问题确认解决后) diff --git a/backend/.env.production b/backend/.env.production new file mode 100644 index 0000000..bba4dc4 --- /dev/null +++ b/backend/.env.production @@ -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 diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py index 8a32741..6d39e38 100644 --- a/backend/app/api/v1/__init__.py +++ b/backend/app/api/v1/__init__.py @@ -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"] diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py index 2c58de4..bc037a6 100644 --- a/backend/app/api/v1/admin.py +++ b/backend/app/api/v1/admin.py @@ -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( diff --git a/backend/app/api/v1/endpoints/certificate.py b/backend/app/api/v1/endpoints/certificate.py index df8b1da..6b33193 100644 --- a/backend/app/api/v1/endpoints/certificate.py +++ b/backend/app/api/v1/endpoints/certificate.py @@ -1,304 +1,329 @@ -""" -证书管理 API 端点 - -提供证书相关的 RESTful API: -- 获取证书列表 -- 获取证书详情 -- 下载证书 -- 验证证书 -""" - -from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException, status, Response, Query -from fastapi.responses import StreamingResponse -from sqlalchemy.ext.asyncio import AsyncSession -import io - -from app.core.deps import get_db, get_current_user -from app.models.user import User -from app.services.certificate_service import CertificateService - -router = APIRouter() - - -@router.get("/templates") -async def get_certificate_templates( - cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """获取证书模板列表""" - service = CertificateService(db) - templates = await service.get_templates(cert_type) - return { - "code": 200, - "message": "success", - "data": templates - } - - -@router.get("/me") -async def get_my_certificates( - cert_type: Optional[str] = Query(None, description="证书类型过滤"), - offset: int = Query(0, ge=0), - limit: int = Query(20, ge=1, le=100), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """获取当前用户的证书列表""" - service = CertificateService(db) - result = await service.get_user_certificates( - user_id=current_user.id, - cert_type=cert_type, - offset=offset, - limit=limit - ) - return { - "code": 200, - "message": "success", - "data": result - } - - -@router.get("/user/{user_id}") -async def get_user_certificates( - user_id: int, - cert_type: Optional[str] = Query(None), - offset: int = Query(0, ge=0), - limit: int = Query(20, ge=1, le=100), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """获取指定用户的证书列表(需要管理员权限)""" - # 只允许查看自己的证书或管理员查看 - if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="无权查看其他用户的证书" - ) - - service = CertificateService(db) - result = await service.get_user_certificates( - user_id=user_id, - cert_type=cert_type, - offset=offset, - limit=limit - ) - return { - "code": 200, - "message": "success", - "data": result - } - - -@router.get("/{cert_id}") -async def get_certificate_detail( - cert_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """获取证书详情""" - service = CertificateService(db) - cert = await service.get_certificate_by_id(cert_id) - - if not cert: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="证书不存在" - ) - - return { - "code": 200, - "message": "success", - "data": cert - } - - -@router.get("/{cert_id}/image") -async def get_certificate_image( - cert_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """获取证书分享图片""" - service = CertificateService(db) - - try: - # 获取基础URL - base_url = "https://kpl.example.com/certificates" # 可从配置读取 - - image_bytes = await service.generate_certificate_image(cert_id, base_url) - - return StreamingResponse( - io.BytesIO(image_bytes), - media_type="image/png", - headers={ - "Content-Disposition": f"inline; filename=certificate_{cert_id}.png" - } - ) - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail=str(e) - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"生成证书图片失败: {str(e)}" - ) - - -@router.get("/{cert_id}/download") -async def download_certificate_pdf( - cert_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """下载证书PDF""" - service = CertificateService(db) - cert = await service.get_certificate_by_id(cert_id) - - if not cert: - raise HTTPException( - status_code=status.HTTP_404_NOT_FOUND, - detail="证书不存在" - ) - - # 如果已有PDF URL则重定向 - if cert.get("pdf_url"): - return { - "code": 200, - "message": "success", - "data": { - "download_url": cert["pdf_url"] - } - } - - # 否则返回图片作为替代 - try: - base_url = "https://kpl.example.com/certificates" - image_bytes = await service.generate_certificate_image(cert_id, base_url) - - return StreamingResponse( - io.BytesIO(image_bytes), - media_type="image/png", - headers={ - "Content-Disposition": f"attachment; filename=certificate_{cert['certificate_no']}.png" - } - ) - except Exception as e: - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail=f"下载失败: {str(e)}" - ) - - -@router.get("/verify/{cert_no}") -async def verify_certificate( - cert_no: str, - db: AsyncSession = Depends(get_db) -): - """ - 验证证书真伪 - - 此接口无需登录,可用于公开验证证书 - """ - service = CertificateService(db) - cert = await service.get_certificate_by_no(cert_no) - - if not cert: - return { - "code": 404, - "message": "证书不存在或编号错误", - "data": { - "valid": False, - "certificate_no": cert_no - } - } - - return { - "code": 200, - "message": "证书验证通过", - "data": { - "valid": True, - "certificate_no": cert_no, - "title": cert.get("title"), - "type_name": cert.get("type_name"), - "issued_at": cert.get("issued_at"), - "user": cert.get("user", {}), - } - } - - -@router.post("/issue/course") -async def issue_course_certificate( - course_id: int, - course_name: str, - completion_rate: float = 100.0, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 颁发课程结业证书 - - 通常由系统在用户完成课程时自动调用 - """ - service = CertificateService(db) - - try: - cert = await service.issue_course_certificate( - user_id=current_user.id, - course_id=course_id, - course_name=course_name, - completion_rate=completion_rate, - user_name=current_user.full_name or current_user.username - ) - await db.commit() - - return { - "code": 200, - "message": "证书颁发成功", - "data": cert - } - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) - - -@router.post("/issue/exam") -async def issue_exam_certificate( - exam_id: int, - exam_name: str, - score: float, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 颁发考试合格证书 - - 通常由系统在用户考试通过时自动调用 - """ - service = CertificateService(db) - - try: - cert = await service.issue_exam_certificate( - user_id=current_user.id, - exam_id=exam_id, - exam_name=exam_name, - score=score, - user_name=current_user.full_name or current_user.username - ) - await db.commit() - - return { - "code": 200, - "message": "证书颁发成功", - "data": cert - } - except ValueError as e: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail=str(e) - ) +""" +证书管理 API 端点 + +提供证书相关的 RESTful API: +- 获取证书列表 +- 获取证书详情 +- 下载证书 +- 验证证书 +""" + +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, status, Response, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +import io + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.services.certificate_service import CertificateService + +router = APIRouter() + + +@router.get("/templates") +async def get_certificate_templates( + cert_type: Optional[str] = Query(None, description="证书类型: course/exam/achievement"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取证书模板列表""" + service = CertificateService(db) + templates = await service.get_templates(cert_type) + return { + "code": 200, + "message": "success", + "data": templates + } + + +@router.get("/me") +async def get_my_certificates( + cert_type: Optional[str] = Query(None, description="证书类型过滤"), + offset: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取当前用户的证书列表""" + service = CertificateService(db) + result = await service.get_user_certificates( + user_id=current_user.id, + cert_type=cert_type, + offset=offset, + limit=limit + ) + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.get("/user/{user_id}") +async def get_user_certificates( + user_id: int, + cert_type: Optional[str] = Query(None), + offset: int = Query(0, ge=0), + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取指定用户的证书列表(需要管理员权限)""" + # 只允许查看自己的证书或管理员查看 + if current_user.id != user_id and current_user.role not in ["admin", "enterprise_admin"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权查看其他用户的证书" + ) + + service = CertificateService(db) + result = await service.get_user_certificates( + user_id=user_id, + cert_type=cert_type, + offset=offset, + limit=limit + ) + return { + "code": 200, + "message": "success", + "data": result + } + + +@router.get("/{cert_id}") +async def get_certificate_detail( + cert_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取证书详情""" + service = CertificateService(db) + cert = await service.get_certificate_by_id(cert_id) + + if not cert: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="证书不存在" + ) + + return { + "code": 200, + "message": "success", + "data": cert + } + + +@router.get("/{cert_id}/image") +async def get_certificate_image( + cert_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """获取证书分享图片""" + service = CertificateService(db) + + try: + # 获取基础URL + base_url = "https://kpl.example.com/certificates" # 可从配置读取 + + image_bytes = await service.generate_certificate_image(cert_id, base_url) + + return StreamingResponse( + io.BytesIO(image_bytes), + media_type="image/png", + headers={ + "Content-Disposition": f"inline; filename=certificate_{cert_id}.png" + } + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(e) + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"生成证书图片失败: {str(e)}" + ) + + +@router.get("/{cert_id}/download") +async def download_certificate( + cert_id: int, + format: str = Query("pdf", description="下载格式: pdf 或 png"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 下载证书 + + 支持 PDF 和 PNG 两种格式 + - PDF: 高质量打印版本(需要安装 weasyprint) + - PNG: 图片版本 + """ + service = CertificateService(db) + cert = await service.get_certificate_by_id(cert_id) + + if not cert: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="证书不存在" + ) + + # 如果已有缓存的 PDF/图片 URL 则返回 + if format.lower() == "pdf" and cert.get("pdf_url"): + return { + "code": 200, + "message": "success", + "data": { + "download_url": cert["pdf_url"] + } + } + + if format.lower() == "png" and cert.get("image_url"): + return { + "code": 200, + "message": "success", + "data": { + "download_url": cert["image_url"] + } + } + + # 动态生成证书文件 + try: + from app.core.config import settings + base_url = settings.PUBLIC_DOMAIN + "/certificates" + + content, filename, mime_type = await service.download_certificate( + cert_id, format, base_url + ) + + return StreamingResponse( + io.BytesIO(content), + media_type=mime_type, + headers={ + "Content-Disposition": f"attachment; filename={filename}" + } + ) + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"下载失败: {str(e)}" + ) + + +@router.get("/verify/{cert_no}") +async def verify_certificate( + cert_no: str, + db: AsyncSession = Depends(get_db) +): + """ + 验证证书真伪 + + 此接口无需登录,可用于公开验证证书 + """ + service = CertificateService(db) + cert = await service.get_certificate_by_no(cert_no) + + if not cert: + return { + "code": 404, + "message": "证书不存在或编号错误", + "data": { + "valid": False, + "certificate_no": cert_no + } + } + + return { + "code": 200, + "message": "证书验证通过", + "data": { + "valid": True, + "certificate_no": cert_no, + "title": cert.get("title"), + "type_name": cert.get("type_name"), + "issued_at": cert.get("issued_at"), + "user": cert.get("user", {}), + } + } + + +@router.post("/issue/course") +async def issue_course_certificate( + course_id: int, + course_name: str, + completion_rate: float = 100.0, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 颁发课程结业证书 + + 通常由系统在用户完成课程时自动调用 + """ + service = CertificateService(db) + + try: + cert = await service.issue_course_certificate( + user_id=current_user.id, + course_id=course_id, + course_name=course_name, + completion_rate=completion_rate, + user_name=current_user.full_name or current_user.username + ) + await db.commit() + + return { + "code": 200, + "message": "证书颁发成功", + "data": cert + } + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) + + +@router.post("/issue/exam") +async def issue_exam_certificate( + exam_id: int, + exam_name: str, + score: float, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 颁发考试合格证书 + + 通常由系统在用户考试通过时自动调用 + """ + service = CertificateService(db) + + try: + cert = await service.issue_exam_certificate( + user_id=current_user.id, + exam_id=exam_id, + exam_name=exam_name, + score=score, + user_name=current_user.full_name or current_user.username + ) + await db.commit() + + return { + "code": 200, + "message": "证书颁发成功", + "data": cert + } + except ValueError as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=str(e) + ) diff --git a/backend/app/api/v1/endpoints/dashboard.py b/backend/app/api/v1/endpoints/dashboard.py index 489b594..839db49 100644 --- a/backend/app/api/v1/endpoints/dashboard.py +++ b/backend/app/api/v1/endpoints/dashboard.py @@ -1,230 +1,230 @@ -""" -数据大屏 API 端点 - -提供企业级和团队级数据大屏接口 -""" - -from typing import Optional -from fastapi import APIRouter, Depends, HTTPException, status, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.deps import get_db, get_current_user -from app.models.user import User -from app.services.dashboard_service import DashboardService - -router = APIRouter() - - -@router.get("/enterprise/overview") -async def get_enterprise_overview( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取企业级数据概览 - - 需要管理员或企业管理员权限 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - service = DashboardService(db) - data = await service.get_enterprise_overview() - - return { - "code": 200, - "message": "success", - "data": data - } - - -@router.get("/enterprise/departments") -async def get_department_comparison( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取部门/团队学习对比数据 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - service = DashboardService(db) - data = await service.get_department_comparison() - - return { - "code": 200, - "message": "success", - "data": data - } - - -@router.get("/enterprise/trend") -async def get_learning_trend( - days: int = Query(7, ge=1, le=30), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取学习趋势数据 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - service = DashboardService(db) - data = await service.get_learning_trend(days) - - return { - "code": 200, - "message": "success", - "data": data - } - - -@router.get("/enterprise/level-distribution") -async def get_level_distribution( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取等级分布数据 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - service = DashboardService(db) - data = await service.get_level_distribution() - - return { - "code": 200, - "message": "success", - "data": data - } - - -@router.get("/enterprise/activities") -async def get_realtime_activities( - limit: int = Query(20, ge=1, le=100), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取实时动态 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - service = DashboardService(db) - data = await service.get_realtime_activities(limit) - - return { - "code": 200, - "message": "success", - "data": data - } - - -@router.get("/enterprise/course-ranking") -async def get_course_ranking( - limit: int = Query(10, ge=1, le=50), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取课程热度排行 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - service = DashboardService(db) - data = await service.get_course_ranking(limit) - - return { - "code": 200, - "message": "success", - "data": data - } - - -@router.get("/team") -async def get_team_dashboard( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取团队级数据大屏 - - 面向团队负责人,显示其管理团队的数据 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要团队负责人权限" - ) - - service = DashboardService(db) - data = await service.get_team_dashboard(current_user.id) - - return { - "code": 200, - "message": "success", - "data": data - } - - -@router.get("/all") -async def get_all_dashboard_data( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取完整的大屏数据(一次性获取所有数据) - - 用于大屏初始化加载 - """ - if current_user.role not in ["admin", "enterprise_admin", "manager"]: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - service = DashboardService(db) - - # 并行获取所有数据 - overview = await service.get_enterprise_overview() - departments = await service.get_department_comparison() - trend = await service.get_learning_trend(7) - level_dist = await service.get_level_distribution() - activities = await service.get_realtime_activities(20) - course_ranking = await service.get_course_ranking(10) - - return { - "code": 200, - "message": "success", - "data": { - "overview": overview, - "departments": departments, - "trend": trend, - "level_distribution": level_dist, - "activities": activities, - "course_ranking": course_ranking, - } - } +""" +数据大屏 API 端点 + +提供企业级和团队级数据大屏接口 +""" + +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.services.dashboard_service import DashboardService + +router = APIRouter() + + +@router.get("/enterprise/overview") +async def get_enterprise_overview( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取企业级数据概览 + + 需要管理员或企业管理员权限 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + service = DashboardService(db) + data = await service.get_enterprise_overview() + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/enterprise/departments") +async def get_department_comparison( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取部门/团队学习对比数据 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + service = DashboardService(db) + data = await service.get_department_comparison() + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/enterprise/trend") +async def get_learning_trend( + days: int = Query(7, ge=1, le=30), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取学习趋势数据 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + service = DashboardService(db) + data = await service.get_learning_trend(days) + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/enterprise/level-distribution") +async def get_level_distribution( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取等级分布数据 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + service = DashboardService(db) + data = await service.get_level_distribution() + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/enterprise/activities") +async def get_realtime_activities( + limit: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取实时动态 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + service = DashboardService(db) + data = await service.get_realtime_activities(limit) + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/enterprise/course-ranking") +async def get_course_ranking( + limit: int = Query(10, ge=1, le=50), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取课程热度排行 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + service = DashboardService(db) + data = await service.get_course_ranking(limit) + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/team") +async def get_team_dashboard( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取团队级数据大屏 + + 面向团队负责人,显示其管理团队的数据 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要团队负责人权限" + ) + + service = DashboardService(db) + data = await service.get_team_dashboard(current_user.id) + + return { + "code": 200, + "message": "success", + "data": data + } + + +@router.get("/all") +async def get_all_dashboard_data( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取完整的大屏数据(一次性获取所有数据) + + 用于大屏初始化加载 + """ + if current_user.role not in ["admin", "enterprise_admin", "manager"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + service = DashboardService(db) + + # 并行获取所有数据 + overview = await service.get_enterprise_overview() + departments = await service.get_department_comparison() + trend = await service.get_learning_trend(7) + level_dist = await service.get_level_distribution() + activities = await service.get_realtime_activities(20) + course_ranking = await service.get_course_ranking(10) + + return { + "code": 200, + "message": "success", + "data": { + "overview": overview, + "departments": departments, + "trend": trend, + "level_distribution": level_dist, + "activities": activities, + "course_ranking": course_ranking, + } + } diff --git a/backend/app/api/v1/endpoints/level.py b/backend/app/api/v1/endpoints/level.py index f00a499..8cc8d87 100644 --- a/backend/app/api/v1/endpoints/level.py +++ b/backend/app/api/v1/endpoints/level.py @@ -1,277 +1,277 @@ -""" -等级与奖章 API - -提供等级查询、奖章查询、排行榜、签到等接口 -""" - -from typing import Optional -from fastapi import APIRouter, Depends, Query -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.deps import get_db, get_current_user -from app.schemas.base import ResponseModel -from app.services.level_service import LevelService -from app.services.badge_service import BadgeService -from app.models.user import User - -router = APIRouter() - - -# ============================================ -# 等级相关接口 -# ============================================ - -@router.get("/me", response_model=ResponseModel) -async def get_my_level( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取当前用户等级信息 - - 返回用户的等级、经验值、称号、连续登录天数等信息 - """ - level_service = LevelService(db) - level_info = await level_service.get_user_level_info(current_user.id) - - return ResponseModel( - message="获取成功", - data=level_info - ) - - -@router.get("/user/{user_id}", response_model=ResponseModel) -async def get_user_level( - user_id: int, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取指定用户等级信息 - - Args: - user_id: 用户ID - """ - level_service = LevelService(db) - level_info = await level_service.get_user_level_info(user_id) - - return ResponseModel( - message="获取成功", - data=level_info - ) - - -@router.post("/checkin", response_model=ResponseModel) -async def daily_checkin( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 每日签到 - - 每天首次签到获得经验值,连续签到有额外奖励 - """ - level_service = LevelService(db) - badge_service = BadgeService(db) - - # 执行签到 - checkin_result = await level_service.daily_checkin(current_user.id) - - # 检查是否解锁新奖章 - new_badges = [] - if checkin_result["success"]: - new_badges = await badge_service.check_and_award_badges(current_user.id) - await db.commit() - - return ResponseModel( - message=checkin_result["message"], - data={ - **checkin_result, - "new_badges": new_badges - } - ) - - -@router.get("/exp-history", response_model=ResponseModel) -async def get_exp_history( - limit: int = Query(default=50, ge=1, le=100), - offset: int = Query(default=0, ge=0), - exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取经验值变化历史 - - Args: - limit: 每页数量(默认50,最大100) - offset: 偏移量 - exp_type: 类型筛选(exam/practice/training/task/login/badge/other) - """ - level_service = LevelService(db) - history, total = await level_service.get_exp_history( - user_id=current_user.id, - limit=limit, - offset=offset, - exp_type=exp_type - ) - - return ResponseModel( - message="获取成功", - data={ - "items": history, - "total": total, - "limit": limit, - "offset": offset - } - ) - - -@router.get("/leaderboard", response_model=ResponseModel) -async def get_leaderboard( - limit: int = Query(default=50, ge=1, le=100), - offset: int = Query(default=0, ge=0), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取等级排行榜 - - Args: - limit: 每页数量(默认50,最大100) - offset: 偏移量 - """ - level_service = LevelService(db) - - # 获取排行榜 - leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset) - - # 获取当前用户排名 - my_rank = await level_service.get_user_rank(current_user.id) - - # 获取当前用户等级信息 - my_level_info = await level_service.get_user_level_info(current_user.id) - - return ResponseModel( - message="获取成功", - data={ - "items": leaderboard, - "total": total, - "limit": limit, - "offset": offset, - "my_rank": my_rank, - "my_level_info": my_level_info - } - ) - - -# ============================================ -# 奖章相关接口 -# ============================================ - -@router.get("/badges/all", response_model=ResponseModel) -async def get_all_badges( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取所有奖章定义 - - 返回所有可获得的奖章列表 - """ - badge_service = BadgeService(db) - badges = await badge_service.get_all_badges() - - return ResponseModel( - message="获取成功", - data=badges - ) - - -@router.get("/badges/me", response_model=ResponseModel) -async def get_my_badges( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取当前用户的奖章(含解锁状态) - - 返回所有奖章及用户是否已解锁 - """ - badge_service = BadgeService(db) - badges = await badge_service.get_user_badges_with_status(current_user.id) - - # 统计已解锁数量 - unlocked_count = sum(1 for b in badges if b["unlocked"]) - - return ResponseModel( - message="获取成功", - data={ - "badges": badges, - "total": len(badges), - "unlocked_count": unlocked_count - } - ) - - -@router.get("/badges/unnotified", response_model=ResponseModel) -async def get_unnotified_badges( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取未通知的新奖章 - - 用于前端显示新获得奖章的弹窗提示 - """ - badge_service = BadgeService(db) - badges = await badge_service.get_unnotified_badges(current_user.id) - - return ResponseModel( - message="获取成功", - data=badges - ) - - -@router.post("/badges/mark-notified", response_model=ResponseModel) -async def mark_badges_notified( - badge_ids: Optional[list[int]] = None, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 标记奖章为已通知 - - Args: - badge_ids: 要标记的奖章ID列表(为空则标记全部) - """ - badge_service = BadgeService(db) - await badge_service.mark_badges_notified(current_user.id, badge_ids) - await db.commit() - - return ResponseModel( - message="标记成功" - ) - - -@router.post("/check-badges", response_model=ResponseModel) -async def check_and_award_badges( - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 检查并授予符合条件的奖章 - - 手动触发奖章检查,返回新获得的奖章 - """ - badge_service = BadgeService(db) - new_badges = await badge_service.check_and_award_badges(current_user.id) - await db.commit() - - return ResponseModel( - message="检查完成", - data={ - "new_badges": new_badges, - "count": len(new_badges) - } - ) +""" +等级与奖章 API + +提供等级查询、奖章查询、排行榜、签到等接口 +""" + +from typing import Optional +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.schemas.base import ResponseModel +from app.services.level_service import LevelService +from app.services.badge_service import BadgeService +from app.models.user import User + +router = APIRouter() + + +# ============================================ +# 等级相关接口 +# ============================================ + +@router.get("/me", response_model=ResponseModel) +async def get_my_level( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户等级信息 + + 返回用户的等级、经验值、称号、连续登录天数等信息 + """ + level_service = LevelService(db) + level_info = await level_service.get_user_level_info(current_user.id) + + return ResponseModel( + message="获取成功", + data=level_info + ) + + +@router.get("/user/{user_id}", response_model=ResponseModel) +async def get_user_level( + user_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取指定用户等级信息 + + Args: + user_id: 用户ID + """ + level_service = LevelService(db) + level_info = await level_service.get_user_level_info(user_id) + + return ResponseModel( + message="获取成功", + data=level_info + ) + + +@router.post("/checkin", response_model=ResponseModel) +async def daily_checkin( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 每日签到 + + 每天首次签到获得经验值,连续签到有额外奖励 + """ + level_service = LevelService(db) + badge_service = BadgeService(db) + + # 执行签到 + checkin_result = await level_service.daily_checkin(current_user.id) + + # 检查是否解锁新奖章 + new_badges = [] + if checkin_result["success"]: + new_badges = await badge_service.check_and_award_badges(current_user.id) + await db.commit() + + return ResponseModel( + message=checkin_result["message"], + data={ + **checkin_result, + "new_badges": new_badges + } + ) + + +@router.get("/exp-history", response_model=ResponseModel) +async def get_exp_history( + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + exp_type: Optional[str] = Query(default=None, description="经验值类型筛选"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取经验值变化历史 + + Args: + limit: 每页数量(默认50,最大100) + offset: 偏移量 + exp_type: 类型筛选(exam/practice/training/task/login/badge/other) + """ + level_service = LevelService(db) + history, total = await level_service.get_exp_history( + user_id=current_user.id, + limit=limit, + offset=offset, + exp_type=exp_type + ) + + return ResponseModel( + message="获取成功", + data={ + "items": history, + "total": total, + "limit": limit, + "offset": offset + } + ) + + +@router.get("/leaderboard", response_model=ResponseModel) +async def get_leaderboard( + limit: int = Query(default=50, ge=1, le=100), + offset: int = Query(default=0, ge=0), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取等级排行榜 + + Args: + limit: 每页数量(默认50,最大100) + offset: 偏移量 + """ + level_service = LevelService(db) + + # 获取排行榜 + leaderboard, total = await level_service.get_leaderboard(limit=limit, offset=offset) + + # 获取当前用户排名 + my_rank = await level_service.get_user_rank(current_user.id) + + # 获取当前用户等级信息 + my_level_info = await level_service.get_user_level_info(current_user.id) + + return ResponseModel( + message="获取成功", + data={ + "items": leaderboard, + "total": total, + "limit": limit, + "offset": offset, + "my_rank": my_rank, + "my_level_info": my_level_info + } + ) + + +# ============================================ +# 奖章相关接口 +# ============================================ + +@router.get("/badges/all", response_model=ResponseModel) +async def get_all_badges( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取所有奖章定义 + + 返回所有可获得的奖章列表 + """ + badge_service = BadgeService(db) + badges = await badge_service.get_all_badges() + + return ResponseModel( + message="获取成功", + data=badges + ) + + +@router.get("/badges/me", response_model=ResponseModel) +async def get_my_badges( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户的奖章(含解锁状态) + + 返回所有奖章及用户是否已解锁 + """ + badge_service = BadgeService(db) + badges = await badge_service.get_user_badges_with_status(current_user.id) + + # 统计已解锁数量 + unlocked_count = sum(1 for b in badges if b["unlocked"]) + + return ResponseModel( + message="获取成功", + data={ + "badges": badges, + "total": len(badges), + "unlocked_count": unlocked_count + } + ) + + +@router.get("/badges/unnotified", response_model=ResponseModel) +async def get_unnotified_badges( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取未通知的新奖章 + + 用于前端显示新获得奖章的弹窗提示 + """ + badge_service = BadgeService(db) + badges = await badge_service.get_unnotified_badges(current_user.id) + + return ResponseModel( + message="获取成功", + data=badges + ) + + +@router.post("/badges/mark-notified", response_model=ResponseModel) +async def mark_badges_notified( + badge_ids: Optional[list[int]] = None, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 标记奖章为已通知 + + Args: + badge_ids: 要标记的奖章ID列表(为空则标记全部) + """ + badge_service = BadgeService(db) + await badge_service.mark_badges_notified(current_user.id, badge_ids) + await db.commit() + + return ResponseModel( + message="标记成功" + ) + + +@router.post("/check-badges", response_model=ResponseModel) +async def check_and_award_badges( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 检查并授予符合条件的奖章 + + 手动触发奖章检查,返回新获得的奖章 + """ + badge_service = BadgeService(db) + new_badges = await badge_service.check_and_award_badges(current_user.id) + await db.commit() + + return ResponseModel( + message="检查完成", + data={ + "new_badges": new_badges, + "count": len(new_badges) + } + ) diff --git a/backend/app/api/v1/endpoints/progress.py b/backend/app/api/v1/endpoints/progress.py new file mode 100644 index 0000000..2876710 --- /dev/null +++ b/backend/app/api/v1/endpoints/progress.py @@ -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() diff --git a/backend/app/api/v1/endpoints/recommendation.py b/backend/app/api/v1/endpoints/recommendation.py new file mode 100644 index 0000000..096ff92 --- /dev/null +++ b/backend/app/api/v1/endpoints/recommendation.py @@ -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, + } + } diff --git a/backend/app/api/v1/endpoints/speech.py b/backend/app/api/v1/endpoints/speech.py new file mode 100644 index 0000000..9d4cbda --- /dev/null +++ b/backend/app/api/v1/endpoints/speech.py @@ -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", + } + } diff --git a/backend/app/api/v1/practice_room.py b/backend/app/api/v1/practice_room.py index 87275f1..58b8673 100644 --- a/backend/app/api/v1/practice_room.py +++ b/backend/app/api/v1/practice_room.py @@ -1,678 +1,717 @@ -""" -双人对练房间 API - -功能: -- 房间创建、加入、退出 -- 房间状态查询 -- 实时消息推送(SSE) -- 消息发送 -""" -import asyncio -import logging -from datetime import datetime -from typing import Optional, List -from fastapi import APIRouter, Depends, HTTPException, Query -from fastapi.responses import StreamingResponse -from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, Field - -from app.core.deps import get_db, get_current_user -from app.models.user import User -from app.services.practice_room_service import PracticeRoomService - -logger = logging.getLogger(__name__) -router = APIRouter(prefix="/practice/rooms", tags=["双人对练房间"]) - - -# ==================== Schema 定义 ==================== - -class CreateRoomRequest(BaseModel): - """创建房间请求""" - scene_id: Optional[int] = Field(None, description="场景ID") - scene_name: Optional[str] = Field(None, description="场景名称") - scene_type: Optional[str] = Field(None, description="场景类型") - scene_background: Optional[str] = Field(None, description="场景背景") - role_a_name: str = Field("销售顾问", description="角色A名称") - role_b_name: str = Field("顾客", description="角色B名称") - role_a_description: Optional[str] = Field(None, description="角色A描述") - role_b_description: Optional[str] = Field(None, description="角色B描述") - host_role: str = Field("A", description="房主选择的角色(A/B)") - room_name: Optional[str] = Field(None, description="房间名称") - - -class JoinRoomRequest(BaseModel): - """加入房间请求""" - room_code: str = Field(..., description="房间码") - - -class SendMessageRequest(BaseModel): - """发送消息请求""" - content: str = Field(..., description="消息内容") - source: Optional[str] = Field("text", description="消息来源: text/voice") - - -class WebRTCSignalRequest(BaseModel): - """WebRTC 信令请求""" - signal_type: str = Field(..., description="信令类型: voice_offer/voice_answer/ice_candidate/voice_start/voice_end") - payload: dict = Field(..., description="信令数据(SDP/ICE候选等)") - - -class RoomResponse(BaseModel): - """房间响应""" - id: int - room_code: str - room_name: Optional[str] - scene_id: Optional[int] - scene_name: Optional[str] - scene_type: Optional[str] - role_a_name: str - role_b_name: str - host_user_id: int - guest_user_id: Optional[int] - host_role: str - status: str - created_at: datetime - started_at: Optional[datetime] - ended_at: Optional[datetime] - duration_seconds: int - total_turns: int - - class Config: - from_attributes = True - - -class RoomDetailResponse(BaseModel): - """房间详情响应(包含用户信息)""" - room: RoomResponse - host_user: Optional[dict] - guest_user: Optional[dict] - host_role_name: Optional[str] - guest_role_name: Optional[str] - my_role: Optional[str] - my_role_name: Optional[str] - - -class MessageResponse(BaseModel): - """消息响应""" - id: int - room_id: int - user_id: Optional[int] - message_type: str - content: Optional[str] - role_name: Optional[str] - sequence: int - created_at: datetime - - class Config: - from_attributes = True - - -# ==================== API 端点 ==================== - -@router.post("", summary="创建房间") -async def create_room( - request: CreateRoomRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 创建双人对练房间 - - - 返回房间码,可分享给对方加入 - - 房主可选择扮演角色A或B - """ - service = PracticeRoomService(db) - - try: - room = await service.create_room( - host_user_id=current_user.id, - scene_id=request.scene_id, - scene_name=request.scene_name, - scene_type=request.scene_type, - scene_background=request.scene_background, - role_a_name=request.role_a_name, - role_b_name=request.role_b_name, - role_a_description=request.role_a_description, - role_b_description=request.role_b_description, - host_role=request.host_role, - room_name=request.room_name - ) - - return { - "code": 200, - "message": "房间创建成功", - "data": { - "room_code": room.room_code, - "room_id": room.id, - "room_name": room.room_name, - "my_role": room.host_role, - "my_role_name": room.get_role_name(room.host_role) - } - } - except Exception as e: - logger.error(f"创建房间失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/join", summary="加入房间") -async def join_room( - request: JoinRoomRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 通过房间码加入房间 - - - 房间最多容纳2人 - - 加入后自动分配对方角色 - """ - service = PracticeRoomService(db) - - try: - room = await service.join_room( - room_code=request.room_code.upper(), - user_id=current_user.id - ) - - my_role = room.get_user_role(current_user.id) - - return { - "code": 200, - "message": "加入房间成功", - "data": { - "room_code": room.room_code, - "room_id": room.id, - "room_name": room.room_name, - "status": room.status, - "my_role": my_role, - "my_role_name": room.get_role_name(my_role) - } - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"加入房间失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.get("/{room_code}", summary="获取房间详情") -async def get_room( - room_code: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取房间详情,包含参与者信息 - """ - service = PracticeRoomService(db) - - room_data = await service.get_room_with_users(room_code.upper()) - if not room_data: - raise HTTPException(status_code=404, detail="房间不存在") - - room = room_data["room"] - host_user = room_data["host_user"] - guest_user = room_data["guest_user"] - - my_role = room.get_user_role(current_user.id) - - return { - "code": 200, - "message": "success", - "data": { - "room": { - "id": room.id, - "room_code": room.room_code, - "room_name": room.room_name, - "scene_id": room.scene_id, - "scene_name": room.scene_name, - "scene_type": room.scene_type, - "scene_background": room.scene_background, - "role_a_name": room.role_a_name, - "role_b_name": room.role_b_name, - "role_a_description": room.role_a_description, - "role_b_description": room.role_b_description, - "host_role": room.host_role, - "status": room.status, - "created_at": room.created_at.isoformat() if room.created_at else None, - "started_at": room.started_at.isoformat() if room.started_at else None, - "ended_at": room.ended_at.isoformat() if room.ended_at else None, - "duration_seconds": room.duration_seconds, - "total_turns": room.total_turns, - "role_a_turns": room.role_a_turns, - "role_b_turns": room.role_b_turns - }, - "host_user": { - "id": host_user.id, - "username": host_user.username, - "full_name": host_user.full_name, - "avatar_url": host_user.avatar_url - } if host_user else None, - "guest_user": { - "id": guest_user.id, - "username": guest_user.username, - "full_name": guest_user.full_name, - "avatar_url": guest_user.avatar_url - } if guest_user else None, - "host_role_name": room_data["host_role_name"], - "guest_role_name": room_data["guest_role_name"], - "my_role": my_role, - "my_role_name": room.get_role_name(my_role) if my_role else None, - "is_host": current_user.id == room.host_user_id - } - } - - -@router.post("/{room_code}/start", summary="开始对练") -async def start_practice( - room_code: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 开始对练(仅房主可操作) - - - 需要房间状态为 ready(双方都已加入) - """ - service = PracticeRoomService(db) - - try: - room = await service.start_practice( - room_code=room_code.upper(), - user_id=current_user.id - ) - - return { - "code": 200, - "message": "对练已开始", - "data": { - "room_code": room.room_code, - "status": room.status, - "started_at": room.started_at.isoformat() if room.started_at else None - } - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"开始对练失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/{room_code}/end", summary="结束对练") -async def end_practice( - room_code: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 结束对练 - - - 任意参与者都可结束 - - 结束后可查看分析报告 - """ - service = PracticeRoomService(db) - - try: - room = await service.end_practice( - room_code=room_code.upper(), - user_id=current_user.id - ) - - return { - "code": 200, - "message": "对练已结束", - "data": { - "room_code": room.room_code, - "room_id": room.id, - "status": room.status, - "duration_seconds": room.duration_seconds, - "total_turns": room.total_turns - } - } - except ValueError as e: - raise HTTPException(status_code=400, detail=str(e)) - except Exception as e: - logger.error(f"结束对练失败: {e}") - raise HTTPException(status_code=500, detail=str(e)) - - -@router.post("/{room_code}/leave", summary="离开房间") -async def leave_room( - room_code: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 离开房间 - - - 房主离开则关闭房间 - - 嘉宾离开则房间回到等待状态 - """ - service = PracticeRoomService(db) - - success = await service.leave_room( - room_code=room_code.upper(), - user_id=current_user.id - ) - - if not success: - raise HTTPException(status_code=400, detail="离开房间失败") - - return { - "code": 200, - "message": "已离开房间" - } - - -@router.post("/{room_code}/message", summary="发送消息") -async def send_message( - room_code: str, - request: SendMessageRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 发送聊天消息 - - - 仅对练中状态可发送 - """ - service = PracticeRoomService(db) - - # 获取房间 - room = await service.get_room_by_code(room_code.upper()) - if not room: - raise HTTPException(status_code=404, detail="房间不存在") - - if room.status != "practicing": - raise HTTPException(status_code=400, detail="对练未在进行中") - - # 获取用户角色 - role_name = room.get_user_role_name(current_user.id) - if not role_name: - raise HTTPException(status_code=403, detail="您不是房间参与者") - - # 发送消息 - message = await service.send_message( - room_id=room.id, - user_id=current_user.id, - content=request.content, - role_name=role_name - ) - - return { - "code": 200, - "message": "发送成功", - "data": message.to_dict() - } - - -@router.post("/{room_code}/signal", summary="发送WebRTC信令") -async def send_signal( - room_code: str, - request: WebRTCSignalRequest, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 发送 WebRTC 信令消息 - - 信令类型: - - voice_start: 发起语音通话 - - voice_offer: SDP Offer - - voice_answer: SDP Answer - - ice_candidate: ICE 候选 - - voice_end: 结束语音通话 - """ - service = PracticeRoomService(db) - - # 获取房间 - room = await service.get_room_by_code(room_code.upper()) - if not room: - raise HTTPException(status_code=404, detail="房间不存在") - - # 检查用户是否在房间中 - user_role = room.get_user_role(current_user.id) - if not user_role: - raise HTTPException(status_code=403, detail="您不是房间参与者") - - # 验证信令类型 - valid_signal_types = ["voice_start", "voice_offer", "voice_answer", "ice_candidate", "voice_end"] - if request.signal_type not in valid_signal_types: - raise HTTPException(status_code=400, detail=f"无效的信令类型,必须是: {', '.join(valid_signal_types)}") - - # 发送信令消息(作为系统消息存储,用于 SSE 推送) - message = await service.send_message( - room_id=room.id, - user_id=current_user.id, - content=None, # 信令消息不需要文本内容 - role_name=None, - message_type=request.signal_type, - extra_data=request.payload - ) - - return { - "code": 200, - "message": "信令发送成功", - "data": { - "signal_type": request.signal_type, - "sequence": message.sequence - } - } - - -@router.get("/{room_code}/messages", summary="获取消息列表") -async def get_messages( - room_code: str, - since_sequence: int = Query(0, description="从该序号之后开始获取"), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取房间消息列表 - - - 用于轮询获取新消息 - - 传入 since_sequence 只获取新消息 - """ - service = PracticeRoomService(db) - - room = await service.get_room_by_code(room_code.upper()) - if not room: - raise HTTPException(status_code=404, detail="房间不存在") - - messages = await service.get_messages( - room_id=room.id, - since_sequence=since_sequence - ) - - return { - "code": 200, - "message": "success", - "data": { - "messages": [msg.to_dict() for msg in messages], - "room_status": room.status, - "last_sequence": messages[-1].sequence if messages else since_sequence - } - } - - -@router.get("/{room_code}/stream", summary="消息流(SSE)") -async def message_stream( - room_code: str, - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 实时消息流(Server-Sent Events) - - - 用于实时接收房间消息 - - 前端使用 EventSource 连接 - """ - service = PracticeRoomService(db) - - room = await service.get_room_by_code(room_code.upper()) - if not room: - raise HTTPException(status_code=404, detail="房间不存在") - - async def event_generator(): - last_sequence = 0 - - while True: - # 获取新消息 - messages = await service.get_messages( - room_id=room.id, - since_sequence=last_sequence - ) - - for msg in messages: - yield f"event: message\ndata: {msg.to_dict()}\n\n" - last_sequence = msg.sequence - - # 检查房间状态 - room_status = await service.get_room_by_id(room.id) - if room_status and room_status.status in ["completed", "canceled"]: - yield f"event: room_closed\ndata: {{\"status\": \"{room_status.status}\"}}\n\n" - break - - # 等待一段时间再轮询 - await asyncio.sleep(0.5) - - return StreamingResponse( - event_generator(), - media_type="text/event-stream", - headers={ - "Cache-Control": "no-cache", - "Connection": "keep-alive", - "X-Accel-Buffering": "no" - } - ) - - -@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) -): - """ - 获取双人对练报告 - - - 如果报告不存在,则调用 AI 生成 - - 返回双方表现评估 - """ - from app.services.ai.duo_practice_analysis_service import DuoPracticeAnalysisService - - service = PracticeRoomService(db) - - # 获取房间详情 - room_data = await service.get_room_with_users(room_code.upper()) - if not room_data: - raise HTTPException(status_code=404, detail="房间不存在") - - room = room_data["room"] - - # 检查房间是否已完成 - if room.status != "completed": - raise HTTPException(status_code=400, detail="对练尚未结束,无法生成报告") - - # 获取所有消息 - messages = await service.get_all_messages(room.id) - chat_messages = [m for m in messages if m.message_type == "chat"] - - if not chat_messages: - raise HTTPException(status_code=400, detail="暂无对话记录") - - # 准备对话历史 - dialogue_history = [ - { - "sequence": m.sequence, - "role_name": m.role_name, - "content": m.content, - "user_id": m.user_id - } - for m in chat_messages - ] - - # 获取用户名称 - host_name = room_data["host_user"].full_name if room_data["host_user"] else "用户A" - guest_name = room_data["guest_user"].full_name if room_data["guest_user"] else "用户B" - - # 确定角色对应的用户名 - if room.host_role == "A": - user_a_name = host_name - user_b_name = guest_name - else: - user_a_name = guest_name - user_b_name = host_name - - # 调用 AI 分析 - analysis_service = DuoPracticeAnalysisService() - result = await analysis_service.analyze( - scene_name=room.scene_name or "双人对练", - scene_background=room.scene_background or "", - role_a_name=room.role_a_name, - role_b_name=room.role_b_name, - role_a_description=room.role_a_description or f"扮演{room.role_a_name}", - role_b_description=room.role_b_description or f"扮演{room.role_b_name}", - user_a_name=user_a_name, - user_b_name=user_b_name, - dialogue_history=dialogue_history, - duration_seconds=room.duration_seconds, - total_turns=room.total_turns, - db=db - ) - - return { - "code": 200, - "message": "success", - "data": { - "room": { - "id": room.id, - "room_code": room.room_code, - "room_name": room.room_name, - "scene_name": room.scene_name, - "duration_seconds": room.duration_seconds, - "total_turns": room.total_turns - }, - "analysis": analysis_service.result_to_dict(result) - } - } - - -@router.get("", summary="获取我的房间列表") -async def get_my_rooms( - status: Optional[str] = Query(None, description="按状态筛选"), - limit: int = Query(20, description="数量限制"), - db: AsyncSession = Depends(get_db), - current_user: User = Depends(get_current_user) -): - """ - 获取当前用户的房间列表 - """ - service = PracticeRoomService(db) - - rooms = await service.get_user_rooms( - user_id=current_user.id, - status=status, - limit=limit - ) - - return { - "code": 200, - "message": "success", - "data": { - "rooms": [ - { - "id": room.id, - "room_code": room.room_code, - "room_name": room.room_name, - "scene_name": room.scene_name, - "status": room.status, - "is_host": room.host_user_id == current_user.id, - "created_at": room.created_at.isoformat() if room.created_at else None, - "duration_seconds": room.duration_seconds, - "total_turns": room.total_turns - } - for room in rooms - ] - } - } +""" +双人对练房间 API + +功能: +- 房间创建、加入、退出 +- 房间状态查询 +- 实时消息推送(SSE) +- 消息发送 +""" +import asyncio +import logging +from datetime import datetime +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.services.practice_room_service import PracticeRoomService + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/practice/rooms", tags=["双人对练房间"]) + + +# ==================== Schema 定义 ==================== + +class CreateRoomRequest(BaseModel): + """创建房间请求""" + scene_id: Optional[int] = Field(None, description="场景ID") + scene_name: Optional[str] = Field(None, description="场景名称") + scene_type: Optional[str] = Field(None, description="场景类型") + scene_background: Optional[str] = Field(None, description="场景背景") + role_a_name: str = Field("销售顾问", description="角色A名称") + role_b_name: str = Field("顾客", description="角色B名称") + role_a_description: Optional[str] = Field(None, description="角色A描述") + role_b_description: Optional[str] = Field(None, description="角色B描述") + host_role: str = Field("A", description="房主选择的角色(A/B)") + room_name: Optional[str] = Field(None, description="房间名称") + + +class JoinRoomRequest(BaseModel): + """加入房间请求""" + room_code: str = Field(..., description="房间码") + + +class SendMessageRequest(BaseModel): + """发送消息请求""" + content: str = Field(..., description="消息内容") + source: Optional[str] = Field("text", description="消息来源: text/voice") + + +class WebRTCSignalRequest(BaseModel): + """WebRTC 信令请求""" + signal_type: str = Field(..., description="信令类型: voice_offer/voice_answer/ice_candidate/voice_start/voice_end") + payload: dict = Field(..., description="信令数据(SDP/ICE候选等)") + + +class RoomResponse(BaseModel): + """房间响应""" + id: int + room_code: str + room_name: Optional[str] + scene_id: Optional[int] + scene_name: Optional[str] + scene_type: Optional[str] + role_a_name: str + role_b_name: str + host_user_id: int + guest_user_id: Optional[int] + host_role: str + status: str + created_at: datetime + started_at: Optional[datetime] + ended_at: Optional[datetime] + duration_seconds: int + total_turns: int + + class Config: + from_attributes = True + + +class RoomDetailResponse(BaseModel): + """房间详情响应(包含用户信息)""" + room: RoomResponse + host_user: Optional[dict] + guest_user: Optional[dict] + host_role_name: Optional[str] + guest_role_name: Optional[str] + my_role: Optional[str] + my_role_name: Optional[str] + + +class MessageResponse(BaseModel): + """消息响应""" + id: int + room_id: int + user_id: Optional[int] + message_type: str + content: Optional[str] + role_name: Optional[str] + sequence: int + created_at: datetime + + class Config: + from_attributes = True + + +# ==================== API 端点 ==================== + +@router.post("", summary="创建房间") +async def create_room( + request: CreateRoomRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 创建双人对练房间 + + - 返回房间码,可分享给对方加入 + - 房主可选择扮演角色A或B + """ + service = PracticeRoomService(db) + + try: + room = await service.create_room( + host_user_id=current_user.id, + scene_id=request.scene_id, + scene_name=request.scene_name, + scene_type=request.scene_type, + scene_background=request.scene_background, + role_a_name=request.role_a_name, + role_b_name=request.role_b_name, + role_a_description=request.role_a_description, + role_b_description=request.role_b_description, + host_role=request.host_role, + room_name=request.room_name + ) + + return { + "code": 200, + "message": "房间创建成功", + "data": { + "room_code": room.room_code, + "room_id": room.id, + "room_name": room.room_name, + "my_role": room.host_role, + "my_role_name": room.get_role_name(room.host_role) + } + } + except Exception as e: + logger.error(f"创建房间失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/join", summary="加入房间") +async def join_room( + request: JoinRoomRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 通过房间码加入房间 + + - 房间最多容纳2人 + - 加入后自动分配对方角色 + """ + service = PracticeRoomService(db) + + try: + room = await service.join_room( + room_code=request.room_code.upper(), + user_id=current_user.id + ) + + my_role = room.get_user_role(current_user.id) + + return { + "code": 200, + "message": "加入房间成功", + "data": { + "room_code": room.room_code, + "room_id": room.id, + "room_name": room.room_name, + "status": room.status, + "my_role": my_role, + "my_role_name": room.get_role_name(my_role) + } + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"加入房间失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.get("/{room_code}", summary="获取房间详情") +async def get_room( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取房间详情,包含参与者信息 + """ + service = PracticeRoomService(db) + + room_data = await service.get_room_with_users(room_code.upper()) + if not room_data: + raise HTTPException(status_code=404, detail="房间不存在") + + room = room_data["room"] + host_user = room_data["host_user"] + guest_user = room_data["guest_user"] + + my_role = room.get_user_role(current_user.id) + + return { + "code": 200, + "message": "success", + "data": { + "room": { + "id": room.id, + "room_code": room.room_code, + "room_name": room.room_name, + "scene_id": room.scene_id, + "scene_name": room.scene_name, + "scene_type": room.scene_type, + "scene_background": room.scene_background, + "role_a_name": room.role_a_name, + "role_b_name": room.role_b_name, + "role_a_description": room.role_a_description, + "role_b_description": room.role_b_description, + "host_role": room.host_role, + "status": room.status, + "created_at": room.created_at.isoformat() if room.created_at else None, + "started_at": room.started_at.isoformat() if room.started_at else None, + "ended_at": room.ended_at.isoformat() if room.ended_at else None, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns, + "role_a_turns": room.role_a_turns, + "role_b_turns": room.role_b_turns + }, + "host_user": { + "id": host_user.id, + "username": host_user.username, + "full_name": host_user.full_name, + "avatar_url": host_user.avatar_url + } if host_user else None, + "guest_user": { + "id": guest_user.id, + "username": guest_user.username, + "full_name": guest_user.full_name, + "avatar_url": guest_user.avatar_url + } if guest_user else None, + "host_role_name": room_data["host_role_name"], + "guest_role_name": room_data["guest_role_name"], + "my_role": my_role, + "my_role_name": room.get_role_name(my_role) if my_role else None, + "is_host": current_user.id == room.host_user_id + } + } + + +@router.post("/{room_code}/start", summary="开始对练") +async def start_practice( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 开始对练(仅房主可操作) + + - 需要房间状态为 ready(双方都已加入) + """ + service = PracticeRoomService(db) + + try: + room = await service.start_practice( + room_code=room_code.upper(), + user_id=current_user.id + ) + + return { + "code": 200, + "message": "对练已开始", + "data": { + "room_code": room.room_code, + "status": room.status, + "started_at": room.started_at.isoformat() if room.started_at else None + } + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"开始对练失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{room_code}/end", summary="结束对练") +async def end_practice( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 结束对练 + + - 任意参与者都可结束 + - 结束后可查看分析报告 + """ + service = PracticeRoomService(db) + + try: + room = await service.end_practice( + room_code=room_code.upper(), + user_id=current_user.id + ) + + return { + "code": 200, + "message": "对练已结束", + "data": { + "room_code": room.room_code, + "room_id": room.id, + "status": room.status, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns + } + } + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"结束对练失败: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + +@router.post("/{room_code}/leave", summary="离开房间") +async def leave_room( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 离开房间 + + - 房主离开则关闭房间 + - 嘉宾离开则房间回到等待状态 + """ + service = PracticeRoomService(db) + + success = await service.leave_room( + room_code=room_code.upper(), + user_id=current_user.id + ) + + if not success: + raise HTTPException(status_code=400, detail="离开房间失败") + + return { + "code": 200, + "message": "已离开房间" + } + + +@router.post("/{room_code}/message", summary="发送消息") +async def send_message( + room_code: str, + request: SendMessageRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 发送聊天消息 + + - 仅对练中状态可发送 + """ + service = PracticeRoomService(db) + + # 获取房间 + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + if room.status != "practicing": + raise HTTPException(status_code=400, detail="对练未在进行中") + + # 获取用户角色 + role_name = room.get_user_role_name(current_user.id) + if not role_name: + raise HTTPException(status_code=403, detail="您不是房间参与者") + + # 发送消息 + message = await service.send_message( + room_id=room.id, + user_id=current_user.id, + content=request.content, + role_name=role_name + ) + + return { + "code": 200, + "message": "发送成功", + "data": message.to_dict() + } + + +@router.post("/{room_code}/signal", summary="发送WebRTC信令") +async def send_signal( + room_code: str, + request: WebRTCSignalRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 发送 WebRTC 信令消息 + + 信令类型: + - voice_start: 发起语音通话 + - voice_offer: SDP Offer + - voice_answer: SDP Answer + - ice_candidate: ICE 候选 + - voice_end: 结束语音通话 + """ + service = PracticeRoomService(db) + + # 获取房间 + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + # 检查用户是否在房间中 + user_role = room.get_user_role(current_user.id) + if not user_role: + raise HTTPException(status_code=403, detail="您不是房间参与者") + + # 验证信令类型 + valid_signal_types = ["voice_start", "voice_offer", "voice_answer", "ice_candidate", "voice_end"] + if request.signal_type not in valid_signal_types: + raise HTTPException(status_code=400, detail=f"无效的信令类型,必须是: {', '.join(valid_signal_types)}") + + # 发送信令消息(作为系统消息存储,用于 SSE 推送) + message = await service.send_message( + room_id=room.id, + user_id=current_user.id, + content=None, # 信令消息不需要文本内容 + role_name=None, + message_type=request.signal_type, + extra_data=request.payload + ) + + return { + "code": 200, + "message": "信令发送成功", + "data": { + "signal_type": request.signal_type, + "sequence": message.sequence + } + } + + +@router.get("/{room_code}/messages", summary="获取消息列表") +async def get_messages( + room_code: str, + since_sequence: int = Query(0, description="从该序号之后开始获取"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取房间消息列表 + + - 用于轮询获取新消息 + - 传入 since_sequence 只获取新消息 + """ + service = PracticeRoomService(db) + + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + messages = await service.get_messages( + room_id=room.id, + since_sequence=since_sequence + ) + + return { + "code": 200, + "message": "success", + "data": { + "messages": [msg.to_dict() for msg in messages], + "room_status": room.status, + "last_sequence": messages[-1].sequence if messages else since_sequence + } + } + + +@router.get("/{room_code}/stream", summary="消息流(SSE)") +async def message_stream( + room_code: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 实时消息流(Server-Sent Events) + + - 用于实时接收房间消息 + - 前端使用 EventSource 连接 + """ + service = PracticeRoomService(db) + + room = await service.get_room_by_code(room_code.upper()) + if not room: + raise HTTPException(status_code=404, detail="房间不存在") + + async def event_generator(): + last_sequence = 0 + + while True: + # 获取新消息 + messages = await service.get_messages( + room_id=room.id, + since_sequence=last_sequence + ) + + for msg in messages: + yield f"event: message\ndata: {msg.to_dict()}\n\n" + last_sequence = msg.sequence + + # 检查房间状态 + room_status = await service.get_room_by_id(room.id) + if room_status and room_status.status in ["completed", "canceled"]: + yield f"event: room_closed\ndata: {{\"status\": \"{room_status.status}\"}}\n\n" + break + + # 等待一段时间再轮询 + await asyncio.sleep(0.5) + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +@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) +): + """ + 获取双人对练报告 + + - 如果报告不存在,则调用 AI 生成 + - 返回双方表现评估 + """ + from app.services.ai.duo_practice_analysis_service import DuoPracticeAnalysisService + + service = PracticeRoomService(db) + + # 获取房间详情 + room_data = await service.get_room_with_users(room_code.upper()) + if not room_data: + raise HTTPException(status_code=404, detail="房间不存在") + + room = room_data["room"] + + # 检查房间是否已完成 + if room.status != "completed": + raise HTTPException(status_code=400, detail="对练尚未结束,无法生成报告") + + # 获取所有消息 + messages = await service.get_all_messages(room.id) + chat_messages = [m for m in messages if m.message_type == "chat"] + + if not chat_messages: + raise HTTPException(status_code=400, detail="暂无对话记录") + + # 准备对话历史 + dialogue_history = [ + { + "sequence": m.sequence, + "role_name": m.role_name, + "content": m.content, + "user_id": m.user_id + } + for m in chat_messages + ] + + # 获取用户名称 + host_name = room_data["host_user"].full_name if room_data["host_user"] else "用户A" + guest_name = room_data["guest_user"].full_name if room_data["guest_user"] else "用户B" + + # 确定角色对应的用户名 + if room.host_role == "A": + user_a_name = host_name + user_b_name = guest_name + else: + user_a_name = guest_name + user_b_name = host_name + + # 调用 AI 分析 + analysis_service = DuoPracticeAnalysisService() + result = await analysis_service.analyze( + scene_name=room.scene_name or "双人对练", + scene_background=room.scene_background or "", + role_a_name=room.role_a_name, + role_b_name=room.role_b_name, + role_a_description=room.role_a_description or f"扮演{room.role_a_name}", + role_b_description=room.role_b_description or f"扮演{room.role_b_name}", + user_a_name=user_a_name, + user_b_name=user_b_name, + dialogue_history=dialogue_history, + duration_seconds=room.duration_seconds, + total_turns=room.total_turns, + db=db + ) + + return { + "code": 200, + "message": "success", + "data": { + "room": { + "id": room.id, + "room_code": room.room_code, + "room_name": room.room_name, + "scene_name": room.scene_name, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns + }, + "analysis": analysis_service.result_to_dict(result) + } + } + + +@router.get("", summary="获取我的房间列表") +async def get_my_rooms( + status: Optional[str] = Query(None, description="按状态筛选"), + limit: int = Query(20, description="数量限制"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户的房间列表 + """ + service = PracticeRoomService(db) + + rooms = await service.get_user_rooms( + user_id=current_user.id, + status=status, + limit=limit + ) + + return { + "code": 200, + "message": "success", + "data": { + "rooms": [ + { + "id": room.id, + "room_code": room.room_code, + "room_name": room.room_name, + "scene_name": room.scene_name, + "status": room.status, + "is_host": room.host_user_id == current_user.id, + "created_at": room.created_at.isoformat() if room.created_at else None, + "duration_seconds": room.duration_seconds, + "total_turns": room.total_turns + } + for room in 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 + } diff --git a/backend/app/api/v1/system_settings.py b/backend/app/api/v1/system_settings.py index 01dd460..e6ff96b 100644 --- a/backend/app/api/v1/system_settings.py +++ b/backend/app/api/v1/system_settings.py @@ -1,306 +1,306 @@ -""" -系统设置 API - -供企业管理员(admin角色)配置系统级别的设置,如钉钉免密登录等 -""" - -from typing import Optional, Dict, Any -from fastapi import APIRouter, Depends, HTTPException, status -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession -from pydantic import BaseModel, Field - -from app.core.deps import get_current_active_user, get_db -from app.core.logger import logger -from app.models.user import User -from app.schemas.base import ResponseModel - -router = APIRouter() - - -# ============================================ -# Schema 定义 -# ============================================ - -class DingtalkConfigUpdate(BaseModel): - """钉钉配置更新请求""" - app_key: Optional[str] = Field(None, description="钉钉AppKey") - app_secret: Optional[str] = Field(None, description="钉钉AppSecret") - agent_id: Optional[str] = Field(None, description="钉钉AgentId") - corp_id: Optional[str] = Field(None, description="钉钉CorpId") - enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录") - - -class DingtalkConfigResponse(BaseModel): - """钉钉配置响应""" - app_key: Optional[str] = None - app_secret_masked: Optional[str] = None # 脱敏显示 - agent_id: Optional[str] = None - corp_id: Optional[str] = None - enabled: bool = False - - -# ============================================ -# 辅助函数 -# ============================================ - -def check_admin_permission(user: User): - """检查是否为管理员""" - if user.role != 'admin': - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail="需要管理员权限" - ) - - -async def get_or_create_tenant_id(db: AsyncSession) -> int: - """获取或创建默认租户ID(简化版,假设单租户)""" - # 对于考培练系统,简化处理,使用固定的租户ID=1 - return 1 - - -async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]: - """获取系统配置值""" - result = await db.execute( - text(""" - SELECT config_value FROM tenant_configs - WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key - """), - {"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key} - ) - row = result.fetchone() - return row[0] if row else None - - -async def set_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str, config_value: str): - """设置系统配置值""" - # 检查是否已存在 - existing = await get_system_config(db, tenant_id, config_group, config_key) - - if existing is not None: - # 更新 - await db.execute( - text(""" - UPDATE tenant_configs - SET config_value = :config_value - WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key - """), - {"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key, "config_value": config_value} - ) - else: - # 插入 - await db.execute( - text(""" - INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) - VALUES (:tenant_id, :config_group, :config_key, :config_value, 'string', :is_encrypted) - """), - { - "tenant_id": tenant_id, - "config_group": config_group, - "config_key": config_key, - "config_value": config_value, - "is_encrypted": 1 if config_key == 'DINGTALK_APP_SECRET' else 0 - } - ) - - -async def get_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str) -> bool: - """获取功能开关状态""" - # 先查租户级别 - result = await db.execute( - text(""" - SELECT is_enabled FROM feature_switches - WHERE feature_code = :feature_code AND tenant_id = :tenant_id - """), - {"tenant_id": tenant_id, "feature_code": feature_code} - ) - row = result.fetchone() - if row: - return bool(row[0]) - - # 再查默认值 - result = await db.execute( - text(""" - SELECT is_enabled FROM feature_switches - WHERE feature_code = :feature_code AND tenant_id IS NULL - """), - {"feature_code": feature_code} - ) - row = result.fetchone() - return bool(row[0]) if row else False - - -async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool): - """设置功能开关状态""" - # 检查是否已存在租户级配置 - result = await db.execute( - text(""" - SELECT id FROM feature_switches - WHERE feature_code = :feature_code AND tenant_id = :tenant_id - """), - {"tenant_id": tenant_id, "feature_code": feature_code} - ) - row = result.fetchone() - - if row: - # 更新 - await db.execute( - text(""" - UPDATE feature_switches - SET is_enabled = :is_enabled - WHERE tenant_id = :tenant_id AND feature_code = :feature_code - """), - {"tenant_id": tenant_id, "feature_code": feature_code, "is_enabled": 1 if is_enabled else 0} - ) - else: - # 获取默认配置信息 - result = await db.execute( - text(""" - SELECT feature_name, feature_group, description FROM feature_switches - WHERE feature_code = :feature_code AND tenant_id IS NULL - """), - {"feature_code": feature_code} - ) - default_row = result.fetchone() - - if default_row: - # 插入租户级配置 - await db.execute( - text(""" - INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) - VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description) - """), - { - "tenant_id": tenant_id, - "feature_code": feature_code, - "feature_name": default_row[0], - "feature_group": default_row[1], - "is_enabled": 1 if is_enabled else 0, - "description": default_row[2] - } - ) - - -# ============================================ -# API 端点 -# ============================================ - -@router.get("/dingtalk", response_model=ResponseModel) -async def get_dingtalk_config( - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db), -) -> ResponseModel: - """ - 获取钉钉配置 - - 仅限管理员访问 - """ - check_admin_permission(current_user) - - tenant_id = await get_or_create_tenant_id(db) - - # 获取配置 - app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY') - app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET') - agent_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID') - corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID') - enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login') - - # 脱敏处理 app_secret - app_secret_masked = None - if app_secret: - if len(app_secret) > 8: - app_secret_masked = app_secret[:4] + '****' + app_secret[-4:] - else: - app_secret_masked = '****' - - return ResponseModel( - message="获取成功", - data={ - "app_key": app_key, - "app_secret_masked": app_secret_masked, - "agent_id": agent_id, - "corp_id": corp_id, - "enabled": enabled, - } - ) - - -@router.put("/dingtalk", response_model=ResponseModel) -async def update_dingtalk_config( - config: DingtalkConfigUpdate, - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db), -) -> ResponseModel: - """ - 更新钉钉配置 - - 仅限管理员访问 - """ - check_admin_permission(current_user) - - tenant_id = await get_or_create_tenant_id(db) - - try: - # 更新配置 - if config.app_key is not None: - await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key) - - if config.app_secret is not None: - await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret) - - if config.agent_id is not None: - await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id) - - if config.corp_id is not None: - await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id) - - if config.enabled is not None: - await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled) - - await db.commit() - - logger.info( - "钉钉配置已更新", - user_id=current_user.id, - username=current_user.username, - ) - - return ResponseModel(message="配置已保存") - - except Exception as e: - await db.rollback() - logger.error(f"更新钉钉配置失败: {str(e)}") - raise HTTPException( - status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, - detail="保存配置失败" - ) - - -@router.get("/all", response_model=ResponseModel) -async def get_all_settings( - current_user: User = Depends(get_current_active_user), - db: AsyncSession = Depends(get_db), -) -> ResponseModel: - """ - 获取所有系统设置概览 - - 仅限管理员访问 - """ - check_admin_permission(current_user) - - tenant_id = await get_or_create_tenant_id(db) - - # 钉钉配置状态 - dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login') - dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID') - - return ResponseModel( - message="获取成功", - data={ - "dingtalk": { - "enabled": dingtalk_enabled, - "configured": bool(dingtalk_corp_id), # 是否已配置 - } - } - ) +""" +系统设置 API + +供企业管理员(admin角色)配置系统级别的设置,如钉钉免密登录等 +""" + +from typing import Optional, Dict, Any +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field + +from app.core.deps import get_current_active_user, get_db +from app.core.logger import logger +from app.models.user import User +from app.schemas.base import ResponseModel + +router = APIRouter() + + +# ============================================ +# Schema 定义 +# ============================================ + +class DingtalkConfigUpdate(BaseModel): + """钉钉配置更新请求""" + app_key: Optional[str] = Field(None, description="钉钉AppKey") + app_secret: Optional[str] = Field(None, description="钉钉AppSecret") + agent_id: Optional[str] = Field(None, description="钉钉AgentId") + corp_id: Optional[str] = Field(None, description="钉钉CorpId") + enabled: Optional[bool] = Field(None, description="是否启用钉钉免密登录") + + +class DingtalkConfigResponse(BaseModel): + """钉钉配置响应""" + app_key: Optional[str] = None + app_secret_masked: Optional[str] = None # 脱敏显示 + agent_id: Optional[str] = None + corp_id: Optional[str] = None + enabled: bool = False + + +# ============================================ +# 辅助函数 +# ============================================ + +def check_admin_permission(user: User): + """检查是否为管理员""" + if user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要管理员权限" + ) + + +async def get_or_create_tenant_id(db: AsyncSession) -> int: + """获取或创建默认租户ID(简化版,假设单租户)""" + # 对于考培练系统,简化处理,使用固定的租户ID=1 + return 1 + + +async def get_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str) -> Optional[str]: + """获取系统配置值""" + result = await db.execute( + text(""" + SELECT config_value FROM tenant_configs + WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key + """), + {"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key} + ) + row = result.fetchone() + return row[0] if row else None + + +async def set_system_config(db: AsyncSession, tenant_id: int, config_group: str, config_key: str, config_value: str): + """设置系统配置值""" + # 检查是否已存在 + existing = await get_system_config(db, tenant_id, config_group, config_key) + + if existing is not None: + # 更新 + await db.execute( + text(""" + UPDATE tenant_configs + SET config_value = :config_value + WHERE tenant_id = :tenant_id AND config_group = :config_group AND config_key = :config_key + """), + {"tenant_id": tenant_id, "config_group": config_group, "config_key": config_key, "config_value": config_value} + ) + else: + # 插入 + await db.execute( + text(""" + INSERT INTO tenant_configs (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) + VALUES (:tenant_id, :config_group, :config_key, :config_value, 'string', :is_encrypted) + """), + { + "tenant_id": tenant_id, + "config_group": config_group, + "config_key": config_key, + "config_value": config_value, + "is_encrypted": 1 if config_key == 'DINGTALK_APP_SECRET' else 0 + } + ) + + +async def get_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str) -> bool: + """获取功能开关状态""" + # 先查租户级别 + result = await db.execute( + text(""" + SELECT is_enabled FROM feature_switches + WHERE feature_code = :feature_code AND tenant_id = :tenant_id + """), + {"tenant_id": tenant_id, "feature_code": feature_code} + ) + row = result.fetchone() + if row: + return bool(row[0]) + + # 再查默认值 + result = await db.execute( + text(""" + SELECT is_enabled FROM feature_switches + WHERE feature_code = :feature_code AND tenant_id IS NULL + """), + {"feature_code": feature_code} + ) + row = result.fetchone() + return bool(row[0]) if row else False + + +async def set_feature_switch(db: AsyncSession, tenant_id: int, feature_code: str, is_enabled: bool): + """设置功能开关状态""" + # 检查是否已存在租户级配置 + result = await db.execute( + text(""" + SELECT id FROM feature_switches + WHERE feature_code = :feature_code AND tenant_id = :tenant_id + """), + {"tenant_id": tenant_id, "feature_code": feature_code} + ) + row = result.fetchone() + + if row: + # 更新 + await db.execute( + text(""" + UPDATE feature_switches + SET is_enabled = :is_enabled + WHERE tenant_id = :tenant_id AND feature_code = :feature_code + """), + {"tenant_id": tenant_id, "feature_code": feature_code, "is_enabled": 1 if is_enabled else 0} + ) + else: + # 获取默认配置信息 + result = await db.execute( + text(""" + SELECT feature_name, feature_group, description FROM feature_switches + WHERE feature_code = :feature_code AND tenant_id IS NULL + """), + {"feature_code": feature_code} + ) + default_row = result.fetchone() + + if default_row: + # 插入租户级配置 + await db.execute( + text(""" + INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) + VALUES (:tenant_id, :feature_code, :feature_name, :feature_group, :is_enabled, :description) + """), + { + "tenant_id": tenant_id, + "feature_code": feature_code, + "feature_name": default_row[0], + "feature_group": default_row[1], + "is_enabled": 1 if is_enabled else 0, + "description": default_row[2] + } + ) + + +# ============================================ +# API 端点 +# ============================================ + +@router.get("/dingtalk", response_model=ResponseModel) +async def get_dingtalk_config( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取钉钉配置 + + 仅限管理员访问 + """ + check_admin_permission(current_user) + + tenant_id = await get_or_create_tenant_id(db) + + # 获取配置 + app_key = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY') + app_secret = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET') + agent_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID') + corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID') + enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login') + + # 脱敏处理 app_secret + app_secret_masked = None + if app_secret: + if len(app_secret) > 8: + app_secret_masked = app_secret[:4] + '****' + app_secret[-4:] + else: + app_secret_masked = '****' + + return ResponseModel( + message="获取成功", + data={ + "app_key": app_key, + "app_secret_masked": app_secret_masked, + "agent_id": agent_id, + "corp_id": corp_id, + "enabled": enabled, + } + ) + + +@router.put("/dingtalk", response_model=ResponseModel) +async def update_dingtalk_config( + config: DingtalkConfigUpdate, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 更新钉钉配置 + + 仅限管理员访问 + """ + check_admin_permission(current_user) + + tenant_id = await get_or_create_tenant_id(db) + + try: + # 更新配置 + if config.app_key is not None: + await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_KEY', config.app_key) + + if config.app_secret is not None: + await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_APP_SECRET', config.app_secret) + + if config.agent_id is not None: + await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_AGENT_ID', config.agent_id) + + if config.corp_id is not None: + await set_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID', config.corp_id) + + if config.enabled is not None: + await set_feature_switch(db, tenant_id, 'dingtalk_login', config.enabled) + + await db.commit() + + logger.info( + "钉钉配置已更新", + user_id=current_user.id, + username=current_user.username, + ) + + return ResponseModel(message="配置已保存") + + except Exception as e: + await db.rollback() + logger.error(f"更新钉钉配置失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="保存配置失败" + ) + + +@router.get("/all", response_model=ResponseModel) +async def get_all_settings( + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取所有系统设置概览 + + 仅限管理员访问 + """ + check_admin_permission(current_user) + + tenant_id = await get_or_create_tenant_id(db) + + # 钉钉配置状态 + dingtalk_enabled = await get_feature_switch(db, tenant_id, 'dingtalk_login') + dingtalk_corp_id = await get_system_config(db, tenant_id, 'dingtalk', 'DINGTALK_CORP_ID') + + return ResponseModel( + message="获取成功", + data={ + "dingtalk": { + "enabled": dingtalk_enabled, + "configured": bool(dingtalk_corp_id), # 是否已配置 + } + } + ) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 0354865..012c9b8 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -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("✅ 安全配置检查通过") + + # ============================================ # 动态配置获取(支持从数据库读取) # ============================================ diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index c5ce461..267d34a 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -1,242 +1,242 @@ -""" -定时任务调度模块 - -使用 APScheduler 实现定时任务: -- 通讯录增量同步(每30分钟) -- 通讯录完整同步(每天凌晨2点) -""" - -import os -import asyncio -from datetime import datetime -from typing import Optional - -from apscheduler.schedulers.asyncio import AsyncIOScheduler -from apscheduler.triggers.interval import IntervalTrigger -from apscheduler.triggers.cron import CronTrigger -from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED - -from app.core.logger import logger - - -class SchedulerManager: - """ - 定时任务调度管理器 - - 单例模式,统一管理所有定时任务 - """ - - _instance: Optional['SchedulerManager'] = None - _scheduler: Optional[AsyncIOScheduler] = None - _initialized: bool = False - - # 配置(可通过环境变量覆盖) - AUTO_SYNC_ENABLED: bool = True - INCREMENTAL_SYNC_INTERVAL_MINUTES: int = 30 # 增量同步间隔(分钟) - FULL_SYNC_HOUR: int = 2 # 完整同步执行时间(小时,24小时制) - - def __new__(cls): - if cls._instance is None: - cls._instance = super().__new__(cls) - return cls._instance - - @classmethod - def get_instance(cls) -> 'SchedulerManager': - """获取调度管理器实例""" - if cls._instance is None: - cls._instance = cls() - return cls._instance - - @classmethod - def _load_config(cls): - """从环境变量加载配置""" - cls.AUTO_SYNC_ENABLED = os.getenv('AUTO_SYNC_ENABLED', 'true').lower() == 'true' - cls.INCREMENTAL_SYNC_INTERVAL_MINUTES = int(os.getenv('AUTO_SYNC_INTERVAL_MINUTES', '30')) - cls.FULL_SYNC_HOUR = int(os.getenv('FULL_SYNC_HOUR', '2')) - - async def init(self, db_session_factory): - """ - 初始化调度器 - - Args: - db_session_factory: 数据库会话工厂(async_sessionmaker) - """ - if self._initialized: - logger.info("调度器已初始化,跳过") - return - - self._load_config() - - if not self.AUTO_SYNC_ENABLED: - logger.info("自动同步已禁用,调度器不启动") - return - - self._db_session_factory = db_session_factory - self._scheduler = AsyncIOScheduler(timezone='Asia/Shanghai') - - # 添加任务执行监听器 - self._scheduler.add_listener(self._job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR) - - # 注册定时任务 - self._register_jobs() - - self._initialized = True - logger.info("调度器初始化完成") - - def _register_jobs(self): - """注册所有定时任务""" - if not self._scheduler: - return - - # 1. 增量同步任务(每30分钟) - self._scheduler.add_job( - self._run_incremental_sync, - IntervalTrigger(minutes=self.INCREMENTAL_SYNC_INTERVAL_MINUTES), - id='employee_incremental_sync', - name='员工增量同步', - replace_existing=True, - max_instances=1, # 防止任务堆积 - ) - logger.info(f"已注册任务: 员工增量同步(每{self.INCREMENTAL_SYNC_INTERVAL_MINUTES}分钟)") - - # 2. 完整同步任务(每天凌晨2点) - self._scheduler.add_job( - self._run_full_sync, - CronTrigger(hour=self.FULL_SYNC_HOUR, minute=0), - id='employee_full_sync', - name='员工完整同步', - replace_existing=True, - max_instances=1, - ) - logger.info(f"已注册任务: 员工完整同步(每天{self.FULL_SYNC_HOUR}:00)") - - def _job_listener(self, event): - """任务执行监听器""" - job_id = event.job_id - - if event.exception: - logger.error( - f"定时任务执行失败", - job_id=job_id, - error=str(event.exception), - traceback=event.traceback - ) - else: - logger.info( - f"定时任务执行完成", - job_id=job_id, - return_value=str(event.retval) if event.retval else None - ) - - async def _run_incremental_sync(self): - """执行增量同步""" - from app.services.employee_sync_service import EmployeeSyncService - - logger.info("开始执行定时增量同步任务") - start_time = datetime.now() - - try: - async with self._db_session_factory() as db: - async with EmployeeSyncService(db) as sync_service: - stats = await sync_service.incremental_sync_employees() - - duration = (datetime.now() - start_time).total_seconds() - logger.info( - "定时增量同步完成", - duration_seconds=duration, - stats=stats - ) - return stats - - except Exception as e: - logger.error(f"定时增量同步失败: {str(e)}") - raise - - async def _run_full_sync(self): - """执行完整同步""" - from app.services.employee_sync_service import EmployeeSyncService - - logger.info("开始执行定时完整同步任务") - start_time = datetime.now() - - try: - async with self._db_session_factory() as db: - async with EmployeeSyncService(db) as sync_service: - stats = await sync_service.sync_employees() - - duration = (datetime.now() - start_time).total_seconds() - logger.info( - "定时完整同步完成", - duration_seconds=duration, - stats=stats - ) - return stats - - except Exception as e: - logger.error(f"定时完整同步失败: {str(e)}") - raise - - def start(self): - """启动调度器""" - if not self._scheduler: - logger.warning("调度器未初始化,无法启动") - return - - if self._scheduler.running: - logger.info("调度器已在运行") - return - - self._scheduler.start() - logger.info("调度器已启动") - - # 打印已注册的任务 - jobs = self._scheduler.get_jobs() - for job in jobs: - logger.info(f" - {job.name} (ID: {job.id}, 下次执行: {job.next_run_time})") - - def stop(self): - """停止调度器""" - if self._scheduler and self._scheduler.running: - self._scheduler.shutdown(wait=True) - logger.info("调度器已停止") - - def get_jobs(self): - """获取所有任务列表""" - if not self._scheduler: - return [] - - return [ - { - 'id': job.id, - 'name': job.name, - 'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None, - 'pending': job.pending, - } - for job in self._scheduler.get_jobs() - ] - - async def trigger_job(self, job_id: str): - """ - 手动触发任务 - - Args: - job_id: 任务ID - """ - if not self._scheduler: - raise RuntimeError("调度器未初始化") - - job = self._scheduler.get_job(job_id) - if not job: - raise ValueError(f"任务不存在: {job_id}") - - # 立即执行 - if job_id == 'employee_incremental_sync': - return await self._run_incremental_sync() - elif job_id == 'employee_full_sync': - return await self._run_full_sync() - else: - raise ValueError(f"未知任务: {job_id}") - - -# 全局调度管理器实例 -scheduler_manager = SchedulerManager.get_instance() +""" +定时任务调度模块 + +使用 APScheduler 实现定时任务: +- 通讯录增量同步(每30分钟) +- 通讯录完整同步(每天凌晨2点) +""" + +import os +import asyncio +from datetime import datetime +from typing import Optional + +from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.triggers.interval import IntervalTrigger +from apscheduler.triggers.cron import CronTrigger +from apscheduler.events import EVENT_JOB_ERROR, EVENT_JOB_EXECUTED + +from app.core.logger import logger + + +class SchedulerManager: + """ + 定时任务调度管理器 + + 单例模式,统一管理所有定时任务 + """ + + _instance: Optional['SchedulerManager'] = None + _scheduler: Optional[AsyncIOScheduler] = None + _initialized: bool = False + + # 配置(可通过环境变量覆盖) + AUTO_SYNC_ENABLED: bool = True + INCREMENTAL_SYNC_INTERVAL_MINUTES: int = 30 # 增量同步间隔(分钟) + FULL_SYNC_HOUR: int = 2 # 完整同步执行时间(小时,24小时制) + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + @classmethod + def get_instance(cls) -> 'SchedulerManager': + """获取调度管理器实例""" + if cls._instance is None: + cls._instance = cls() + return cls._instance + + @classmethod + def _load_config(cls): + """从环境变量加载配置""" + cls.AUTO_SYNC_ENABLED = os.getenv('AUTO_SYNC_ENABLED', 'true').lower() == 'true' + cls.INCREMENTAL_SYNC_INTERVAL_MINUTES = int(os.getenv('AUTO_SYNC_INTERVAL_MINUTES', '30')) + cls.FULL_SYNC_HOUR = int(os.getenv('FULL_SYNC_HOUR', '2')) + + async def init(self, db_session_factory): + """ + 初始化调度器 + + Args: + db_session_factory: 数据库会话工厂(async_sessionmaker) + """ + if self._initialized: + logger.info("调度器已初始化,跳过") + return + + self._load_config() + + if not self.AUTO_SYNC_ENABLED: + logger.info("自动同步已禁用,调度器不启动") + return + + self._db_session_factory = db_session_factory + self._scheduler = AsyncIOScheduler(timezone='Asia/Shanghai') + + # 添加任务执行监听器 + self._scheduler.add_listener(self._job_listener, EVENT_JOB_EXECUTED | EVENT_JOB_ERROR) + + # 注册定时任务 + self._register_jobs() + + self._initialized = True + logger.info("调度器初始化完成") + + def _register_jobs(self): + """注册所有定时任务""" + if not self._scheduler: + return + + # 1. 增量同步任务(每30分钟) + self._scheduler.add_job( + self._run_incremental_sync, + IntervalTrigger(minutes=self.INCREMENTAL_SYNC_INTERVAL_MINUTES), + id='employee_incremental_sync', + name='员工增量同步', + replace_existing=True, + max_instances=1, # 防止任务堆积 + ) + logger.info(f"已注册任务: 员工增量同步(每{self.INCREMENTAL_SYNC_INTERVAL_MINUTES}分钟)") + + # 2. 完整同步任务(每天凌晨2点) + self._scheduler.add_job( + self._run_full_sync, + CronTrigger(hour=self.FULL_SYNC_HOUR, minute=0), + id='employee_full_sync', + name='员工完整同步', + replace_existing=True, + max_instances=1, + ) + logger.info(f"已注册任务: 员工完整同步(每天{self.FULL_SYNC_HOUR}:00)") + + def _job_listener(self, event): + """任务执行监听器""" + job_id = event.job_id + + if event.exception: + logger.error( + f"定时任务执行失败", + job_id=job_id, + error=str(event.exception), + traceback=event.traceback + ) + else: + logger.info( + f"定时任务执行完成", + job_id=job_id, + return_value=str(event.retval) if event.retval else None + ) + + async def _run_incremental_sync(self): + """执行增量同步""" + from app.services.employee_sync_service import EmployeeSyncService + + logger.info("开始执行定时增量同步任务") + start_time = datetime.now() + + try: + async with self._db_session_factory() as db: + async with EmployeeSyncService(db) as sync_service: + stats = await sync_service.incremental_sync_employees() + + duration = (datetime.now() - start_time).total_seconds() + logger.info( + "定时增量同步完成", + duration_seconds=duration, + stats=stats + ) + return stats + + except Exception as e: + logger.error(f"定时增量同步失败: {str(e)}") + raise + + async def _run_full_sync(self): + """执行完整同步""" + from app.services.employee_sync_service import EmployeeSyncService + + logger.info("开始执行定时完整同步任务") + start_time = datetime.now() + + try: + async with self._db_session_factory() as db: + async with EmployeeSyncService(db) as sync_service: + stats = await sync_service.sync_employees() + + duration = (datetime.now() - start_time).total_seconds() + logger.info( + "定时完整同步完成", + duration_seconds=duration, + stats=stats + ) + return stats + + except Exception as e: + logger.error(f"定时完整同步失败: {str(e)}") + raise + + def start(self): + """启动调度器""" + if not self._scheduler: + logger.warning("调度器未初始化,无法启动") + return + + if self._scheduler.running: + logger.info("调度器已在运行") + return + + self._scheduler.start() + logger.info("调度器已启动") + + # 打印已注册的任务 + jobs = self._scheduler.get_jobs() + for job in jobs: + logger.info(f" - {job.name} (ID: {job.id}, 下次执行: {job.next_run_time})") + + def stop(self): + """停止调度器""" + if self._scheduler and self._scheduler.running: + self._scheduler.shutdown(wait=True) + logger.info("调度器已停止") + + def get_jobs(self): + """获取所有任务列表""" + if not self._scheduler: + return [] + + return [ + { + 'id': job.id, + 'name': job.name, + 'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None, + 'pending': job.pending, + } + for job in self._scheduler.get_jobs() + ] + + async def trigger_job(self, job_id: str): + """ + 手动触发任务 + + Args: + job_id: 任务ID + """ + if not self._scheduler: + raise RuntimeError("调度器未初始化") + + job = self._scheduler.get_job(job_id) + if not job: + raise ValueError(f"任务不存在: {job_id}") + + # 立即执行 + if job_id == 'employee_incremental_sync': + return await self._run_incremental_sync() + elif job_id == 'employee_full_sync': + return await self._run_full_sync() + else: + raise ValueError(f"未知任务: {job_id}") + + +# 全局调度管理器实例 +scheduler_manager = SchedulerManager.get_instance() diff --git a/backend/app/migrations/add_user_course_progress.sql b/backend/app/migrations/add_user_course_progress.sql new file mode 100644 index 0000000..6521115 --- /dev/null +++ b/backend/app/migrations/add_user_course_progress.sql @@ -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'; diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index 342dca8..059dad5 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -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", ] diff --git a/backend/app/models/certificate.py b/backend/app/models/certificate.py index d5be581..16aa1e0 100644 --- a/backend/app/models/certificate.py +++ b/backend/app/models/certificate.py @@ -1,76 +1,76 @@ -""" -证书系统数据模型 - -定义证书模板和用户证书的数据结构 -""" - -from datetime import datetime -from enum import Enum -from typing import Optional -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON -from sqlalchemy.orm import relationship - -from app.models.base import Base - - -class CertificateType(str, Enum): - """证书类型枚举""" - COURSE = "course" # 课程结业证书 - EXAM = "exam" # 考试合格证书 - ACHIEVEMENT = "achievement" # 成就证书 - - -class CertificateTemplate(Base): - """证书模板表""" - __tablename__ = "certificate_templates" - - id = Column(Integer, primary_key=True, autoincrement=True) - name = Column(String(100), nullable=False, comment="模板名称") - type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型") - background_url = Column(String(500), comment="证书背景图URL") - template_html = Column(Text, comment="HTML模板内容") - template_style = Column(Text, comment="CSS样式") - is_active = Column(Boolean, default=True, comment="是否启用") - sort_order = Column(Integer, default=0, comment="排序顺序") - created_at = Column(DateTime, nullable=False, default=datetime.now) - updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) - - # 关联 - certificates = relationship("UserCertificate", back_populates="template") - - -class UserCertificate(Base): - """用户证书表""" - __tablename__ = "user_certificates" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID") - template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID") - certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号") - title = Column(String(200), nullable=False, comment="证书标题") - description = Column(Text, comment="证书描述") - issued_at = Column(DateTime, default=datetime.now, comment="颁发时间") - valid_until = Column(DateTime, comment="有效期至") - - # 关联信息 - course_id = Column(Integer, comment="关联课程ID") - exam_id = Column(Integer, comment="关联考试ID") - badge_id = Column(Integer, comment="关联奖章ID") - - # 成绩信息 - score = Column(DECIMAL(5, 2), comment="考试分数") - completion_rate = Column(DECIMAL(5, 2), comment="完成率") - - # 生成的文件 - pdf_url = Column(String(500), comment="PDF文件URL") - image_url = Column(String(500), comment="分享图片URL") - - # 元数据 - meta_data = Column(JSON, comment="扩展元数据") - - created_at = Column(DateTime, nullable=False, default=datetime.now) - updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) - - # 关联 - template = relationship("CertificateTemplate", back_populates="certificates") - user = relationship("User", backref="certificates") +""" +证书系统数据模型 + +定义证书模板和用户证书的数据结构 +""" + +from datetime import datetime +from enum import Enum +from typing import Optional +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, Enum as SQLEnum, DECIMAL, JSON +from sqlalchemy.orm import relationship + +from app.models.base import Base + + +class CertificateType(str, Enum): + """证书类型枚举""" + COURSE = "course" # 课程结业证书 + EXAM = "exam" # 考试合格证书 + ACHIEVEMENT = "achievement" # 成就证书 + + +class CertificateTemplate(Base): + """证书模板表""" + __tablename__ = "certificate_templates" + + id = Column(Integer, primary_key=True, autoincrement=True) + name = Column(String(100), nullable=False, comment="模板名称") + type = Column(SQLEnum(CertificateType), nullable=False, comment="证书类型") + background_url = Column(String(500), comment="证书背景图URL") + template_html = Column(Text, comment="HTML模板内容") + template_style = Column(Text, comment="CSS样式") + is_active = Column(Boolean, default=True, comment="是否启用") + sort_order = Column(Integer, default=0, comment="排序顺序") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + # 关联 + certificates = relationship("UserCertificate", back_populates="template") + + +class UserCertificate(Base): + """用户证书表""" + __tablename__ = "user_certificates" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="用户ID") + template_id = Column(Integer, ForeignKey("certificate_templates.id"), nullable=False, comment="模板ID") + certificate_no = Column(String(50), unique=True, nullable=False, comment="证书编号") + title = Column(String(200), nullable=False, comment="证书标题") + description = Column(Text, comment="证书描述") + issued_at = Column(DateTime, default=datetime.now, comment="颁发时间") + valid_until = Column(DateTime, comment="有效期至") + + # 关联信息 + course_id = Column(Integer, comment="关联课程ID") + exam_id = Column(Integer, comment="关联考试ID") + badge_id = Column(Integer, comment="关联奖章ID") + + # 成绩信息 + score = Column(DECIMAL(5, 2), comment="考试分数") + completion_rate = Column(DECIMAL(5, 2), comment="完成率") + + # 生成的文件 + pdf_url = Column(String(500), comment="PDF文件URL") + image_url = Column(String(500), comment="分享图片URL") + + # 元数据 + meta_data = Column(JSON, comment="扩展元数据") + + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + # 关联 + template = relationship("CertificateTemplate", back_populates="certificates") + user = relationship("User", backref="certificates") diff --git a/backend/app/models/level.py b/backend/app/models/level.py index da39bf3..167ed4b 100644 --- a/backend/app/models/level.py +++ b/backend/app/models/level.py @@ -1,140 +1,140 @@ -""" -等级与奖章系统模型 - -包含: -- UserLevel: 用户等级信息 -- ExpHistory: 经验值变化历史 -- BadgeDefinition: 奖章定义 -- UserBadge: 用户已获得的奖章 -- LevelConfig: 等级配置 -""" - -from datetime import datetime, date -from typing import Optional, List -from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text -from sqlalchemy.orm import relationship - -from app.models.base import Base, BaseModel - - -class UserLevel(Base): - """用户等级表""" - __tablename__ = "user_levels" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) - level = Column(Integer, nullable=False, default=1, comment="当前等级") - exp = Column(Integer, nullable=False, default=0, comment="当前经验值") - total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值") - login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数") - max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数") - last_login_date = Column(Date, nullable=True, comment="最后登录日期") - last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间") - created_at = Column(DateTime, nullable=False, default=datetime.now) - updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) - - # 关联 - user = relationship("User", backref="user_level", uselist=False) - - -class ExpHistory(Base): - """经验值历史表""" - __tablename__ = "exp_history" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - exp_change = Column(Integer, nullable=False, comment="经验值变化") - exp_type = Column(String(50), nullable=False, index=True, comment="类型:exam/practice/training/task/login/badge/other") - source_id = Column(Integer, nullable=True, comment="来源记录ID") - description = Column(String(255), nullable=False, comment="描述") - level_before = Column(Integer, nullable=True, comment="变化前等级") - level_after = Column(Integer, nullable=True, comment="变化后等级") - created_at = Column(DateTime, nullable=False, default=datetime.now) - - # 关联 - user = relationship("User", backref="exp_histories") - - -class BadgeDefinition(Base): - """奖章定义表""" - __tablename__ = "badge_definitions" - - id = Column(Integer, primary_key=True, autoincrement=True) - code = Column(String(50), nullable=False, unique=True, comment="奖章编码") - name = Column(String(100), nullable=False, comment="奖章名称") - description = Column(String(255), nullable=False, comment="奖章描述") - icon = Column(String(100), nullable=False, default="Medal", comment="图标名称") - category = Column(String(50), nullable=False, index=True, comment="分类") - condition_type = Column(String(50), nullable=False, comment="条件类型") - condition_field = Column(String(100), nullable=True, comment="条件字段") - condition_value = Column(Integer, nullable=False, default=1, comment="条件数值") - exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值") - sort_order = Column(Integer, nullable=False, default=0, comment="排序") - is_active = Column(Boolean, nullable=False, default=True, comment="是否启用") - created_at = Column(DateTime, nullable=False, default=datetime.now) - updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) - - # 关联 - user_badges = relationship("UserBadge", back_populates="badge") - - -class UserBadge(Base): - """用户奖章表""" - __tablename__ = "user_badges" - - id = Column(Integer, primary_key=True, autoincrement=True) - user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) - badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True) - unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间") - is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知") - notified_at = Column(DateTime, nullable=True, comment="通知时间") - created_at = Column(DateTime, nullable=False, default=datetime.now) - - # 关联 - user = relationship("User", backref="badges") - badge = relationship("BadgeDefinition", back_populates="user_badges") - - -class LevelConfig(Base): - """等级配置表""" - __tablename__ = "level_configs" - - id = Column(Integer, primary_key=True, autoincrement=True) - level = Column(Integer, nullable=False, unique=True, comment="等级") - exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值") - total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值") - title = Column(String(50), nullable=False, comment="等级称号") - color = Column(String(20), nullable=True, comment="等级颜色") - created_at = Column(DateTime, nullable=False, default=datetime.now) - - -# 经验值类型枚举 -class ExpType: - """经验值类型""" - EXAM = "exam" # 考试 - PRACTICE = "practice" # 练习 - TRAINING = "training" # 陪练 - TASK = "task" # 任务 - LOGIN = "login" # 登录/签到 - BADGE = "badge" # 奖章奖励 - OTHER = "other" # 其他 - - -# 奖章分类枚举 -class BadgeCategory: - """奖章分类""" - LEARNING = "learning" # 学习进度 - EXAM = "exam" # 考试成绩 - PRACTICE = "practice" # 练习时长 - STREAK = "streak" # 连续打卡 - SPECIAL = "special" # 特殊成就 - - -# 条件类型枚举 -class ConditionType: - """解锁条件类型""" - COUNT = "count" # 次数 - SCORE = "score" # 分数 - STREAK = "streak" # 连续天数 - LEVEL = "level" # 等级 - DURATION = "duration" # 时长 +""" +等级与奖章系统模型 + +包含: +- UserLevel: 用户等级信息 +- ExpHistory: 经验值变化历史 +- BadgeDefinition: 奖章定义 +- UserBadge: 用户已获得的奖章 +- LevelConfig: 等级配置 +""" + +from datetime import datetime, date +from typing import Optional, List +from sqlalchemy import Column, Integer, String, DateTime, Date, Boolean, ForeignKey, Text +from sqlalchemy.orm import relationship + +from app.models.base import Base, BaseModel + + +class UserLevel(Base): + """用户等级表""" + __tablename__ = "user_levels" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + level = Column(Integer, nullable=False, default=1, comment="当前等级") + exp = Column(Integer, nullable=False, default=0, comment="当前经验值") + total_exp = Column(Integer, nullable=False, default=0, comment="累计获得经验值") + login_streak = Column(Integer, nullable=False, default=0, comment="连续登录天数") + max_login_streak = Column(Integer, nullable=False, default=0, comment="历史最长连续登录天数") + last_login_date = Column(Date, nullable=True, comment="最后登录日期") + last_checkin_at = Column(DateTime, nullable=True, comment="最后签到时间") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + # 关联 + user = relationship("User", backref="user_level", uselist=False) + + +class ExpHistory(Base): + """经验值历史表""" + __tablename__ = "exp_history" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + exp_change = Column(Integer, nullable=False, comment="经验值变化") + exp_type = Column(String(50), nullable=False, index=True, comment="类型:exam/practice/training/task/login/badge/other") + source_id = Column(Integer, nullable=True, comment="来源记录ID") + description = Column(String(255), nullable=False, comment="描述") + level_before = Column(Integer, nullable=True, comment="变化前等级") + level_after = Column(Integer, nullable=True, comment="变化后等级") + created_at = Column(DateTime, nullable=False, default=datetime.now) + + # 关联 + user = relationship("User", backref="exp_histories") + + +class BadgeDefinition(Base): + """奖章定义表""" + __tablename__ = "badge_definitions" + + id = Column(Integer, primary_key=True, autoincrement=True) + code = Column(String(50), nullable=False, unique=True, comment="奖章编码") + name = Column(String(100), nullable=False, comment="奖章名称") + description = Column(String(255), nullable=False, comment="奖章描述") + icon = Column(String(100), nullable=False, default="Medal", comment="图标名称") + category = Column(String(50), nullable=False, index=True, comment="分类") + condition_type = Column(String(50), nullable=False, comment="条件类型") + condition_field = Column(String(100), nullable=True, comment="条件字段") + condition_value = Column(Integer, nullable=False, default=1, comment="条件数值") + exp_reward = Column(Integer, nullable=False, default=0, comment="奖励经验值") + sort_order = Column(Integer, nullable=False, default=0, comment="排序") + is_active = Column(Boolean, nullable=False, default=True, comment="是否启用") + created_at = Column(DateTime, nullable=False, default=datetime.now) + updated_at = Column(DateTime, nullable=False, default=datetime.now, onupdate=datetime.now) + + # 关联 + user_badges = relationship("UserBadge", back_populates="badge") + + +class UserBadge(Base): + """用户奖章表""" + __tablename__ = "user_badges" + + id = Column(Integer, primary_key=True, autoincrement=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + badge_id = Column(Integer, ForeignKey("badge_definitions.id", ondelete="CASCADE"), nullable=False, index=True) + unlocked_at = Column(DateTime, nullable=False, default=datetime.now, comment="解锁时间") + is_notified = Column(Boolean, nullable=False, default=False, comment="是否已通知") + notified_at = Column(DateTime, nullable=True, comment="通知时间") + created_at = Column(DateTime, nullable=False, default=datetime.now) + + # 关联 + user = relationship("User", backref="badges") + badge = relationship("BadgeDefinition", back_populates="user_badges") + + +class LevelConfig(Base): + """等级配置表""" + __tablename__ = "level_configs" + + id = Column(Integer, primary_key=True, autoincrement=True) + level = Column(Integer, nullable=False, unique=True, comment="等级") + exp_required = Column(Integer, nullable=False, comment="升到此级所需经验值") + total_exp_required = Column(Integer, nullable=False, comment="累计所需经验值") + title = Column(String(50), nullable=False, comment="等级称号") + color = Column(String(20), nullable=True, comment="等级颜色") + created_at = Column(DateTime, nullable=False, default=datetime.now) + + +# 经验值类型枚举 +class ExpType: + """经验值类型""" + EXAM = "exam" # 考试 + PRACTICE = "practice" # 练习 + TRAINING = "training" # 陪练 + TASK = "task" # 任务 + LOGIN = "login" # 登录/签到 + BADGE = "badge" # 奖章奖励 + OTHER = "other" # 其他 + + +# 奖章分类枚举 +class BadgeCategory: + """奖章分类""" + LEARNING = "learning" # 学习进度 + EXAM = "exam" # 考试成绩 + PRACTICE = "practice" # 练习时长 + STREAK = "streak" # 连续打卡 + SPECIAL = "special" # 特殊成就 + + +# 条件类型枚举 +class ConditionType: + """解锁条件类型""" + COUNT = "count" # 次数 + SCORE = "score" # 分数 + STREAK = "streak" # 连续天数 + LEVEL = "level" # 等级 + DURATION = "duration" # 时长 diff --git a/backend/app/models/practice_room.py b/backend/app/models/practice_room.py index 6e58516..2cd75e9 100644 --- a/backend/app/models/practice_room.py +++ b/backend/app/models/practice_room.py @@ -1,122 +1,122 @@ -""" -双人对练房间模型 - -功能: -- 房间管理(创建、加入、状态) -- 参与者管理 -- 实时消息同步 -""" -from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON -from sqlalchemy.sql import func -from sqlalchemy.orm import relationship -from app.models.base import Base - - -class PracticeRoom(Base): - """双人对练房间模型""" - __tablename__ = "practice_rooms" - - id = Column(Integer, primary_key=True, index=True, comment="房间ID") - room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码") - room_name = Column(String(200), comment="房间名称") - - # 场景信息 - scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID") - scene_name = Column(String(200), comment="场景名称") - scene_type = Column(String(50), comment="场景类型") - scene_background = Column(Text, comment="场景背景") - - # 角色设置 - role_a_name = Column(String(50), default="角色A", comment="角色A名称(如销售顾问)") - role_b_name = Column(String(50), default="角色B", comment="角色B名称(如顾客)") - role_a_description = Column(Text, comment="角色A描述") - role_b_description = Column(Text, comment="角色B描述") - - # 参与者信息 - host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID") - guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID") - host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)") - max_participants = Column(Integer, default=2, comment="最大参与人数") - - # 状态和时间 - status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled") - created_at = Column(DateTime, server_default=func.now(), comment="创建时间") - started_at = Column(DateTime, comment="开始时间") - ended_at = Column(DateTime, comment="结束时间") - duration_seconds = Column(Integer, default=0, comment="对练时长(秒)") - - # 对话统计 - total_turns = Column(Integer, default=0, comment="总对话轮次") - role_a_turns = Column(Integer, default=0, comment="角色A发言次数") - role_b_turns = Column(Integer, default=0, comment="角色B发言次数") - - # 软删除 - is_deleted = Column(Boolean, default=False, comment="是否删除") - deleted_at = Column(DateTime, comment="删除时间") - - def __repr__(self): - return f"" - - @property - def is_full(self) -> bool: - """房间是否已满""" - return self.guest_user_id is not None - - @property - def participant_count(self) -> int: - """当前参与人数""" - count = 1 # 房主 - if self.guest_user_id: - count += 1 - return count - - def get_user_role(self, user_id: int) -> str: - """获取用户在房间中的角色""" - if user_id == self.host_user_id: - return self.host_role - elif user_id == self.guest_user_id: - return "B" if self.host_role == "A" else "A" - return None - - def get_role_name(self, role: str) -> str: - """获取角色名称""" - if role == "A": - return self.role_a_name - elif role == "B": - return self.role_b_name - return None - - def get_user_role_name(self, user_id: int) -> str: - """获取用户的角色名称""" - role = self.get_user_role(user_id) - return self.get_role_name(role) if role else None - - -class PracticeRoomMessage(Base): - """房间实时消息模型""" - __tablename__ = "practice_room_messages" - - id = Column(Integer, primary_key=True, index=True, comment="消息ID") - room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID") - user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID") - message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end") - content = Column(Text, comment="消息内容") - role_name = Column(String(50), comment="角色名称") - sequence = Column(Integer, nullable=False, comment="消息序号") - created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间") - - def __repr__(self): - return f"" - - def to_dict(self) -> dict: - """转换为字典(用于SSE推送)""" - return { - "id": self.id, - "room_id": self.room_id, - "user_id": self.user_id, - "message_type": self.message_type, - "content": self.content, - "role_name": self.role_name, - "sequence": self.sequence, - "created_at": self.created_at.isoformat() if self.created_at else None - } +""" +双人对练房间模型 + +功能: +- 房间管理(创建、加入、状态) +- 参与者管理 +- 实时消息同步 +""" +from sqlalchemy import Column, Integer, String, Text, Boolean, DateTime, ForeignKey, JSON +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.models.base import Base + + +class PracticeRoom(Base): + """双人对练房间模型""" + __tablename__ = "practice_rooms" + + id = Column(Integer, primary_key=True, index=True, comment="房间ID") + room_code = Column(String(10), unique=True, nullable=False, index=True, comment="6位邀请码") + room_name = Column(String(200), comment="房间名称") + + # 场景信息 + scene_id = Column(Integer, ForeignKey("practice_scenes.id", ondelete="SET NULL"), comment="关联场景ID") + scene_name = Column(String(200), comment="场景名称") + scene_type = Column(String(50), comment="场景类型") + scene_background = Column(Text, comment="场景背景") + + # 角色设置 + role_a_name = Column(String(50), default="角色A", comment="角色A名称(如销售顾问)") + role_b_name = Column(String(50), default="角色B", comment="角色B名称(如顾客)") + role_a_description = Column(Text, comment="角色A描述") + role_b_description = Column(Text, comment="角色B描述") + + # 参与者信息 + host_user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, comment="房主用户ID") + guest_user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="加入者用户ID") + host_role = Column(String(10), default="A", comment="房主选择的角色(A/B)") + max_participants = Column(Integer, default=2, comment="最大参与人数") + + # 状态和时间 + status = Column(String(20), default="waiting", index=True, comment="状态: waiting/ready/practicing/completed/canceled") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + started_at = Column(DateTime, comment="开始时间") + ended_at = Column(DateTime, comment="结束时间") + duration_seconds = Column(Integer, default=0, comment="对练时长(秒)") + + # 对话统计 + total_turns = Column(Integer, default=0, comment="总对话轮次") + role_a_turns = Column(Integer, default=0, comment="角色A发言次数") + role_b_turns = Column(Integer, default=0, comment="角色B发言次数") + + # 软删除 + is_deleted = Column(Boolean, default=False, comment="是否删除") + deleted_at = Column(DateTime, comment="删除时间") + + def __repr__(self): + return f"" + + @property + def is_full(self) -> bool: + """房间是否已满""" + return self.guest_user_id is not None + + @property + def participant_count(self) -> int: + """当前参与人数""" + count = 1 # 房主 + if self.guest_user_id: + count += 1 + return count + + def get_user_role(self, user_id: int) -> str: + """获取用户在房间中的角色""" + if user_id == self.host_user_id: + return self.host_role + elif user_id == self.guest_user_id: + return "B" if self.host_role == "A" else "A" + return None + + def get_role_name(self, role: str) -> str: + """获取角色名称""" + if role == "A": + return self.role_a_name + elif role == "B": + return self.role_b_name + return None + + def get_user_role_name(self, user_id: int) -> str: + """获取用户的角色名称""" + role = self.get_user_role(user_id) + return self.get_role_name(role) if role else None + + +class PracticeRoomMessage(Base): + """房间实时消息模型""" + __tablename__ = "practice_room_messages" + + id = Column(Integer, primary_key=True, index=True, comment="消息ID") + room_id = Column(Integer, ForeignKey("practice_rooms.id", ondelete="CASCADE"), nullable=False, index=True, comment="房间ID") + user_id = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="发送者用户ID") + message_type = Column(String(20), nullable=False, comment="消息类型: chat/system/join/leave/start/end") + content = Column(Text, comment="消息内容") + role_name = Column(String(50), comment="角色名称") + sequence = Column(Integer, nullable=False, comment="消息序号") + created_at = Column(DateTime(3), server_default=func.now(3), comment="创建时间") + + def __repr__(self): + return f"" + + def to_dict(self) -> dict: + """转换为字典(用于SSE推送)""" + return { + "id": self.id, + "room_id": self.room_id, + "user_id": self.user_id, + "message_type": self.message_type, + "content": self.content, + "role_name": self.role_name, + "sequence": self.sequence, + "created_at": self.created_at.isoformat() if self.created_at else None + } diff --git a/backend/app/models/user_course_progress.py b/backend/app/models/user_course_progress.py new file mode 100644 index 0000000..8de688f --- /dev/null +++ b/backend/app/models/user_course_progress.py @@ -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") diff --git a/backend/app/services/ai/duo_practice_analysis_service.py b/backend/app/services/ai/duo_practice_analysis_service.py index f598703..b64745b 100644 --- a/backend/app/services/ai/duo_practice_analysis_service.py +++ b/backend/app/services/ai/duo_practice_analysis_service.py @@ -1,323 +1,323 @@ -""" -双人对练分析服务 - -功能: -- 分析双人对练对话 -- 生成双方评估报告 -- 对话标注和建议 -""" -import json -import logging -from dataclasses import dataclass, field -from typing import Any, Dict, List, Optional - -from app.services.ai.ai_service import AIService -from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT - -logger = logging.getLogger(__name__) - - -@dataclass -class UserEvaluation: - """用户评估结果""" - user_name: str - role_name: str - total_score: int - dimensions: Dict[str, Dict[str, Any]] - highlights: List[str] - improvements: List[Dict[str, str]] - - -@dataclass -class DuoPracticeAnalysisResult: - """双人对练分析结果""" - # 整体评估 - interaction_quality: int = 0 - scene_restoration: int = 0 - overall_comment: str = "" - - # 用户A评估 - user_a_evaluation: Optional[UserEvaluation] = None - - # 用户B评估 - user_b_evaluation: Optional[UserEvaluation] = None - - # 对话标注 - dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list) - - # AI 元数据 - raw_response: str = "" - ai_provider: str = "" - ai_model: str = "" - ai_latency_ms: int = 0 - - -class DuoPracticeAnalysisService: - """ - 双人对练分析服务 - - 使用示例: - ```python - service = DuoPracticeAnalysisService() - result = await service.analyze( - scene_name="销售场景", - scene_background="客户咨询产品", - role_a_name="销售顾问", - role_b_name="顾客", - user_a_name="张三", - user_b_name="李四", - dialogue_history=dialogue_list, - duration_seconds=300, - total_turns=20 - ) - ``` - """ - - MODULE_CODE = "duo_practice_analysis" - - async def analyze( - self, - scene_name: str, - scene_background: str, - role_a_name: str, - role_b_name: str, - role_a_description: str, - role_b_description: str, - user_a_name: str, - user_b_name: str, - dialogue_history: List[Dict[str, Any]], - duration_seconds: int, - total_turns: int, - db: Any = None - ) -> DuoPracticeAnalysisResult: - """ - 分析双人对练 - - Args: - scene_name: 场景名称 - scene_background: 场景背景 - role_a_name: 角色A名称 - role_b_name: 角色B名称 - role_a_description: 角色A描述 - role_b_description: 角色B描述 - user_a_name: 用户A名称 - user_b_name: 用户B名称 - dialogue_history: 对话历史列表 - duration_seconds: 对练时长(秒) - total_turns: 总对话轮次 - db: 数据库会话 - - Returns: - DuoPracticeAnalysisResult: 分析结果 - """ - try: - logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}") - - # 格式化对话历史 - dialogue_text = self._format_dialogue_history(dialogue_history) - - # 创建 AI 服务 - ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) - - # 构建用户提示词 - user_prompt = USER_PROMPT.format( - scene_name=scene_name, - scene_background=scene_background or "未设置", - role_a_name=role_a_name, - role_b_name=role_b_name, - role_a_description=role_a_description or f"扮演{role_a_name}角色", - role_b_description=role_b_description or f"扮演{role_b_name}角色", - user_a_name=user_a_name, - user_b_name=user_b_name, - dialogue_history=dialogue_text, - duration_seconds=duration_seconds, - total_turns=total_turns - ) - - # 调用 AI - messages = [ - {"role": "system", "content": SYSTEM_PROMPT}, - {"role": "user", "content": user_prompt} - ] - - ai_response = await ai_service.chat( - messages=messages, - model="gemini-3-flash-preview", # 使用快速模型 - temperature=0.3, - prompt_name="duo_practice_analysis" - ) - - logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms") - - # 解析 AI 输出 - result = self._parse_analysis_result( - ai_response.content, - user_a_name=user_a_name, - user_b_name=user_b_name, - role_a_name=role_a_name, - role_b_name=role_b_name - ) - - # 填充 AI 元数据 - result.raw_response = ai_response.content - result.ai_provider = ai_response.provider - result.ai_model = ai_response.model - result.ai_latency_ms = ai_response.latency_ms - - return result - - except Exception as e: - logger.error(f"双人对练分析失败: {e}", exc_info=True) - # 返回空结果 - return DuoPracticeAnalysisResult( - overall_comment=f"分析失败: {str(e)}" - ) - - def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str: - """格式化对话历史""" - lines = [] - for d in dialogues: - speaker = d.get("role_name") or d.get("speaker", "未知") - content = d.get("content", "") - seq = d.get("sequence", 0) - lines.append(f"[{seq}] {speaker}:{content}") - return "\n".join(lines) - - def _parse_analysis_result( - self, - ai_output: str, - user_a_name: str, - user_b_name: str, - role_a_name: str, - role_b_name: str - ) -> DuoPracticeAnalysisResult: - """解析 AI 输出""" - result = DuoPracticeAnalysisResult() - - try: - # 尝试提取 JSON - json_str = ai_output - - # 如果输出包含 markdown 代码块,提取其中的 JSON - if "```json" in ai_output: - start = ai_output.find("```json") + 7 - end = ai_output.find("```", start) - json_str = ai_output[start:end].strip() - elif "```" in ai_output: - start = ai_output.find("```") + 3 - end = ai_output.find("```", start) - json_str = ai_output[start:end].strip() - - data = json.loads(json_str) - - # 解析整体评估 - overall = data.get("overall_evaluation", {}) - result.interaction_quality = overall.get("interaction_quality", 0) - result.scene_restoration = overall.get("scene_restoration", 0) - result.overall_comment = overall.get("overall_comment", "") - - # 解析用户A评估 - user_a_data = data.get("user_a_evaluation", {}) - if user_a_data: - result.user_a_evaluation = UserEvaluation( - user_name=user_a_data.get("user_name", user_a_name), - role_name=user_a_data.get("role_name", role_a_name), - total_score=user_a_data.get("total_score", 0), - dimensions=user_a_data.get("dimensions", {}), - highlights=user_a_data.get("highlights", []), - improvements=user_a_data.get("improvements", []) - ) - - # 解析用户B评估 - user_b_data = data.get("user_b_evaluation", {}) - if user_b_data: - result.user_b_evaluation = UserEvaluation( - user_name=user_b_data.get("user_name", user_b_name), - role_name=user_b_data.get("role_name", role_b_name), - total_score=user_b_data.get("total_score", 0), - dimensions=user_b_data.get("dimensions", {}), - highlights=user_b_data.get("highlights", []), - improvements=user_b_data.get("improvements", []) - ) - - # 解析对话标注 - result.dialogue_annotations = data.get("dialogue_annotations", []) - - except json.JSONDecodeError as e: - logger.warning(f"JSON 解析失败: {e}") - result.overall_comment = "AI 输出格式异常,请重试" - except Exception as e: - logger.error(f"解析分析结果失败: {e}") - result.overall_comment = f"解析失败: {str(e)}" - - return result - - def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]: - """将结果转换为字典(用于 API 响应)""" - return { - "overall_evaluation": { - "interaction_quality": result.interaction_quality, - "scene_restoration": result.scene_restoration, - "overall_comment": result.overall_comment - }, - "user_a_evaluation": { - "user_name": result.user_a_evaluation.user_name, - "role_name": result.user_a_evaluation.role_name, - "total_score": result.user_a_evaluation.total_score, - "dimensions": result.user_a_evaluation.dimensions, - "highlights": result.user_a_evaluation.highlights, - "improvements": result.user_a_evaluation.improvements - } if result.user_a_evaluation else None, - "user_b_evaluation": { - "user_name": result.user_b_evaluation.user_name, - "role_name": result.user_b_evaluation.role_name, - "total_score": result.user_b_evaluation.total_score, - "dimensions": result.user_b_evaluation.dimensions, - "highlights": result.user_b_evaluation.highlights, - "improvements": result.user_b_evaluation.improvements - } if result.user_b_evaluation else None, - "dialogue_annotations": result.dialogue_annotations, - "ai_metadata": { - "provider": result.ai_provider, - "model": result.ai_model, - "latency_ms": result.ai_latency_ms - } - } - - -# ==================== 全局实例 ==================== - -duo_practice_analysis_service = DuoPracticeAnalysisService() - - -# ==================== 便捷函数 ==================== - -async def analyze_duo_practice( - scene_name: str, - scene_background: str, - role_a_name: str, - role_b_name: str, - role_a_description: str, - role_b_description: str, - user_a_name: str, - user_b_name: str, - dialogue_history: List[Dict[str, Any]], - duration_seconds: int, - total_turns: int, - db: Any = None -) -> DuoPracticeAnalysisResult: - """便捷函数:分析双人对练""" - return await duo_practice_analysis_service.analyze( - scene_name=scene_name, - scene_background=scene_background, - role_a_name=role_a_name, - role_b_name=role_b_name, - role_a_description=role_a_description, - role_b_description=role_b_description, - user_a_name=user_a_name, - user_b_name=user_b_name, - dialogue_history=dialogue_history, - duration_seconds=duration_seconds, - total_turns=total_turns, - db=db - ) +""" +双人对练分析服务 + +功能: +- 分析双人对练对话 +- 生成双方评估报告 +- 对话标注和建议 +""" +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from app.services.ai.ai_service import AIService +from app.services.ai.prompts.duo_practice_prompts import SYSTEM_PROMPT, USER_PROMPT + +logger = logging.getLogger(__name__) + + +@dataclass +class UserEvaluation: + """用户评估结果""" + user_name: str + role_name: str + total_score: int + dimensions: Dict[str, Dict[str, Any]] + highlights: List[str] + improvements: List[Dict[str, str]] + + +@dataclass +class DuoPracticeAnalysisResult: + """双人对练分析结果""" + # 整体评估 + interaction_quality: int = 0 + scene_restoration: int = 0 + overall_comment: str = "" + + # 用户A评估 + user_a_evaluation: Optional[UserEvaluation] = None + + # 用户B评估 + user_b_evaluation: Optional[UserEvaluation] = None + + # 对话标注 + dialogue_annotations: List[Dict[str, Any]] = field(default_factory=list) + + # AI 元数据 + raw_response: str = "" + ai_provider: str = "" + ai_model: str = "" + ai_latency_ms: int = 0 + + +class DuoPracticeAnalysisService: + """ + 双人对练分析服务 + + 使用示例: + ```python + service = DuoPracticeAnalysisService() + result = await service.analyze( + scene_name="销售场景", + scene_background="客户咨询产品", + role_a_name="销售顾问", + role_b_name="顾客", + user_a_name="张三", + user_b_name="李四", + dialogue_history=dialogue_list, + duration_seconds=300, + total_turns=20 + ) + ``` + """ + + MODULE_CODE = "duo_practice_analysis" + + async def analyze( + self, + scene_name: str, + scene_background: str, + role_a_name: str, + role_b_name: str, + role_a_description: str, + role_b_description: str, + user_a_name: str, + user_b_name: str, + dialogue_history: List[Dict[str, Any]], + duration_seconds: int, + total_turns: int, + db: Any = None + ) -> DuoPracticeAnalysisResult: + """ + 分析双人对练 + + Args: + scene_name: 场景名称 + scene_background: 场景背景 + role_a_name: 角色A名称 + role_b_name: 角色B名称 + role_a_description: 角色A描述 + role_b_description: 角色B描述 + user_a_name: 用户A名称 + user_b_name: 用户B名称 + dialogue_history: 对话历史列表 + duration_seconds: 对练时长(秒) + total_turns: 总对话轮次 + db: 数据库会话 + + Returns: + DuoPracticeAnalysisResult: 分析结果 + """ + try: + logger.info(f"开始双人对练分析: {scene_name}, 轮次={total_turns}") + + # 格式化对话历史 + dialogue_text = self._format_dialogue_history(dialogue_history) + + # 创建 AI 服务 + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + + # 构建用户提示词 + user_prompt = USER_PROMPT.format( + scene_name=scene_name, + scene_background=scene_background or "未设置", + role_a_name=role_a_name, + role_b_name=role_b_name, + role_a_description=role_a_description or f"扮演{role_a_name}角色", + role_b_description=role_b_description or f"扮演{role_b_name}角色", + user_a_name=user_a_name, + user_b_name=user_b_name, + dialogue_history=dialogue_text, + duration_seconds=duration_seconds, + total_turns=total_turns + ) + + # 调用 AI + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt} + ] + + ai_response = await ai_service.chat( + messages=messages, + model="gemini-3-flash-preview", # 使用快速模型 + temperature=0.3, + prompt_name="duo_practice_analysis" + ) + + logger.info(f"AI 分析完成: provider={ai_response.provider}, latency={ai_response.latency_ms}ms") + + # 解析 AI 输出 + result = self._parse_analysis_result( + ai_response.content, + user_a_name=user_a_name, + user_b_name=user_b_name, + role_a_name=role_a_name, + role_b_name=role_b_name + ) + + # 填充 AI 元数据 + result.raw_response = ai_response.content + result.ai_provider = ai_response.provider + result.ai_model = ai_response.model + result.ai_latency_ms = ai_response.latency_ms + + return result + + except Exception as e: + logger.error(f"双人对练分析失败: {e}", exc_info=True) + # 返回空结果 + return DuoPracticeAnalysisResult( + overall_comment=f"分析失败: {str(e)}" + ) + + def _format_dialogue_history(self, dialogues: List[Dict[str, Any]]) -> str: + """格式化对话历史""" + lines = [] + for d in dialogues: + speaker = d.get("role_name") or d.get("speaker", "未知") + content = d.get("content", "") + seq = d.get("sequence", 0) + lines.append(f"[{seq}] {speaker}:{content}") + return "\n".join(lines) + + def _parse_analysis_result( + self, + ai_output: str, + user_a_name: str, + user_b_name: str, + role_a_name: str, + role_b_name: str + ) -> DuoPracticeAnalysisResult: + """解析 AI 输出""" + result = DuoPracticeAnalysisResult() + + try: + # 尝试提取 JSON + json_str = ai_output + + # 如果输出包含 markdown 代码块,提取其中的 JSON + if "```json" in ai_output: + start = ai_output.find("```json") + 7 + end = ai_output.find("```", start) + json_str = ai_output[start:end].strip() + elif "```" in ai_output: + start = ai_output.find("```") + 3 + end = ai_output.find("```", start) + json_str = ai_output[start:end].strip() + + data = json.loads(json_str) + + # 解析整体评估 + overall = data.get("overall_evaluation", {}) + result.interaction_quality = overall.get("interaction_quality", 0) + result.scene_restoration = overall.get("scene_restoration", 0) + result.overall_comment = overall.get("overall_comment", "") + + # 解析用户A评估 + user_a_data = data.get("user_a_evaluation", {}) + if user_a_data: + result.user_a_evaluation = UserEvaluation( + user_name=user_a_data.get("user_name", user_a_name), + role_name=user_a_data.get("role_name", role_a_name), + total_score=user_a_data.get("total_score", 0), + dimensions=user_a_data.get("dimensions", {}), + highlights=user_a_data.get("highlights", []), + improvements=user_a_data.get("improvements", []) + ) + + # 解析用户B评估 + user_b_data = data.get("user_b_evaluation", {}) + if user_b_data: + result.user_b_evaluation = UserEvaluation( + user_name=user_b_data.get("user_name", user_b_name), + role_name=user_b_data.get("role_name", role_b_name), + total_score=user_b_data.get("total_score", 0), + dimensions=user_b_data.get("dimensions", {}), + highlights=user_b_data.get("highlights", []), + improvements=user_b_data.get("improvements", []) + ) + + # 解析对话标注 + result.dialogue_annotations = data.get("dialogue_annotations", []) + + except json.JSONDecodeError as e: + logger.warning(f"JSON 解析失败: {e}") + result.overall_comment = "AI 输出格式异常,请重试" + except Exception as e: + logger.error(f"解析分析结果失败: {e}") + result.overall_comment = f"解析失败: {str(e)}" + + return result + + def result_to_dict(self, result: DuoPracticeAnalysisResult) -> Dict[str, Any]: + """将结果转换为字典(用于 API 响应)""" + return { + "overall_evaluation": { + "interaction_quality": result.interaction_quality, + "scene_restoration": result.scene_restoration, + "overall_comment": result.overall_comment + }, + "user_a_evaluation": { + "user_name": result.user_a_evaluation.user_name, + "role_name": result.user_a_evaluation.role_name, + "total_score": result.user_a_evaluation.total_score, + "dimensions": result.user_a_evaluation.dimensions, + "highlights": result.user_a_evaluation.highlights, + "improvements": result.user_a_evaluation.improvements + } if result.user_a_evaluation else None, + "user_b_evaluation": { + "user_name": result.user_b_evaluation.user_name, + "role_name": result.user_b_evaluation.role_name, + "total_score": result.user_b_evaluation.total_score, + "dimensions": result.user_b_evaluation.dimensions, + "highlights": result.user_b_evaluation.highlights, + "improvements": result.user_b_evaluation.improvements + } if result.user_b_evaluation else None, + "dialogue_annotations": result.dialogue_annotations, + "ai_metadata": { + "provider": result.ai_provider, + "model": result.ai_model, + "latency_ms": result.ai_latency_ms + } + } + + +# ==================== 全局实例 ==================== + +duo_practice_analysis_service = DuoPracticeAnalysisService() + + +# ==================== 便捷函数 ==================== + +async def analyze_duo_practice( + scene_name: str, + scene_background: str, + role_a_name: str, + role_b_name: str, + role_a_description: str, + role_b_description: str, + user_a_name: str, + user_b_name: str, + dialogue_history: List[Dict[str, Any]], + duration_seconds: int, + total_turns: int, + db: Any = None +) -> DuoPracticeAnalysisResult: + """便捷函数:分析双人对练""" + return await duo_practice_analysis_service.analyze( + scene_name=scene_name, + scene_background=scene_background, + role_a_name=role_a_name, + role_b_name=role_b_name, + role_a_description=role_a_description, + role_b_description=role_b_description, + user_a_name=user_a_name, + user_b_name=user_b_name, + dialogue_history=dialogue_history, + duration_seconds=duration_seconds, + total_turns=total_turns, + db=db + ) diff --git a/backend/app/services/ai/prompts/duo_practice_prompts.py b/backend/app/services/ai/prompts/duo_practice_prompts.py index a9190b2..95ff863 100644 --- a/backend/app/services/ai/prompts/duo_practice_prompts.py +++ b/backend/app/services/ai/prompts/duo_practice_prompts.py @@ -1,207 +1,207 @@ -""" -双人对练评估提示词模板 - -功能:评估双人角色扮演对练的表现 -""" - -# ==================== 元数据 ==================== - -PROMPT_META = { - "name": "duo_practice_analysis", - "display_name": "双人对练评估", - "description": "评估双人角色扮演对练中双方的表现", - "module": "kaopeilian", - "variables": [ - "scene_name", "scene_background", - "role_a_name", "role_b_name", - "role_a_description", "role_b_description", - "user_a_name", "user_b_name", - "dialogue_history", - "duration_seconds", "total_turns" - ], - "version": "1.0.0", - "author": "kaopeilian-team", -} - - -# ==================== 系统提示词 ==================== - -SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。 -你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。 - -评估原则: -1. 客观公正,基于对话内容给出评价 -2. 突出亮点,指出不足 -3. 给出具体、可操作的改进建议 -4. 考虑角色特点,评估角色代入度 - -输出格式要求: -- 必须返回有效的 JSON 格式 -- 分数范围 0-100 -- 建议具体可行""" - - -# ==================== 用户提示词模板 ==================== - -USER_PROMPT = """# 双人对练评估任务 - -## 场景信息 -- **场景名称**:{scene_name} -- **场景背景**:{scene_background} - -## 角色设置 -### {role_a_name} -- **扮演者**:{user_a_name} -- **角色描述**:{role_a_description} - -### {role_b_name} -- **扮演者**:{user_b_name} -- **角色描述**:{role_b_description} - -## 对练数据 -- **对练时长**:{duration_seconds} 秒 -- **总对话轮次**:{total_turns} 轮 - -## 对话记录 -{dialogue_history} - ---- - -## 评估要求 - -请按以下 JSON 格式输出评估结果: - -```json -{{ - "overall_evaluation": {{ - "interaction_quality": 85, - "scene_restoration": 80, - "overall_comment": "整体评价..." - }}, - "user_a_evaluation": {{ - "user_name": "{user_a_name}", - "role_name": "{role_a_name}", - "total_score": 85, - "dimensions": {{ - "role_immersion": {{ - "score": 85, - "comment": "角色代入度评价..." - }}, - "communication": {{ - "score": 80, - "comment": "沟通表达能力评价..." - }}, - "professional_knowledge": {{ - "score": 75, - "comment": "专业知识运用评价..." - }}, - "response_quality": {{ - "score": 82, - "comment": "回应质量评价..." - }}, - "goal_achievement": {{ - "score": 78, - "comment": "目标达成度评价..." - }} - }}, - "highlights": [ - "亮点1...", - "亮点2..." - ], - "improvements": [ - {{ - "issue": "问题描述", - "suggestion": "改进建议", - "example": "示例话术" - }} - ] - }}, - "user_b_evaluation": {{ - "user_name": "{user_b_name}", - "role_name": "{role_b_name}", - "total_score": 82, - "dimensions": {{ - "role_immersion": {{ - "score": 80, - "comment": "角色代入度评价..." - }}, - "communication": {{ - "score": 85, - "comment": "沟通表达能力评价..." - }}, - "professional_knowledge": {{ - "score": 78, - "comment": "专业知识运用评价..." - }}, - "response_quality": {{ - "score": 80, - "comment": "回应质量评价..." - }}, - "goal_achievement": {{ - "score": 75, - "comment": "目标达成度评价..." - }} - }}, - "highlights": [ - "亮点1...", - "亮点2..." - ], - "improvements": [ - {{ - "issue": "问题描述", - "suggestion": "改进建议", - "example": "示例话术" - }} - ] - }}, - "dialogue_annotations": [ - {{ - "sequence": 1, - "speaker": "{role_a_name}", - "tags": ["good_opening"], - "comment": "开场白自然得体" - }}, - {{ - "sequence": 3, - "speaker": "{role_b_name}", - "tags": ["needs_improvement"], - "comment": "可以更主动表达需求" - }} - ] -}} -``` - -请基于对话内容,给出客观、专业的评估。""" - - -# ==================== 维度说明 ==================== - -DIMENSION_DESCRIPTIONS = { - "role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定", - "communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体", - "professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备", - "response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性", - "goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果" -} - - -# ==================== 对话标签 ==================== - -DIALOGUE_TAGS = { - # 正面标签 - "good_opening": "开场良好", - "active_listening": "积极倾听", - "empathy": "共情表达", - "professional": "专业表现", - "good_closing": "结束得体", - "creative_response": "创意回应", - "problem_solving": "问题解决", - - # 需改进标签 - "needs_improvement": "需要改进", - "off_topic": "偏离主题", - "too_passive": "过于被动", - "lack_detail": "缺乏细节", - "missed_opportunity": "错失机会", - "unclear_expression": "表达不清" -} +""" +双人对练评估提示词模板 + +功能:评估双人角色扮演对练的表现 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "duo_practice_analysis", + "display_name": "双人对练评估", + "description": "评估双人角色扮演对练中双方的表现", + "module": "kaopeilian", + "variables": [ + "scene_name", "scene_background", + "role_a_name", "role_b_name", + "role_a_description", "role_b_description", + "user_a_name", "user_b_name", + "dialogue_history", + "duration_seconds", "total_turns" + ], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是一位资深的销售培训专家和沟通教练,擅长评估角色扮演对练的表现。 +你需要观察双人对练的对话记录,分别对两位参与者的表现进行专业评估。 + +评估原则: +1. 客观公正,基于对话内容给出评价 +2. 突出亮点,指出不足 +3. 给出具体、可操作的改进建议 +4. 考虑角色特点,评估角色代入度 + +输出格式要求: +- 必须返回有效的 JSON 格式 +- 分数范围 0-100 +- 建议具体可行""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """# 双人对练评估任务 + +## 场景信息 +- **场景名称**:{scene_name} +- **场景背景**:{scene_background} + +## 角色设置 +### {role_a_name} +- **扮演者**:{user_a_name} +- **角色描述**:{role_a_description} + +### {role_b_name} +- **扮演者**:{user_b_name} +- **角色描述**:{role_b_description} + +## 对练数据 +- **对练时长**:{duration_seconds} 秒 +- **总对话轮次**:{total_turns} 轮 + +## 对话记录 +{dialogue_history} + +--- + +## 评估要求 + +请按以下 JSON 格式输出评估结果: + +```json +{{ + "overall_evaluation": {{ + "interaction_quality": 85, + "scene_restoration": 80, + "overall_comment": "整体评价..." + }}, + "user_a_evaluation": {{ + "user_name": "{user_a_name}", + "role_name": "{role_a_name}", + "total_score": 85, + "dimensions": {{ + "role_immersion": {{ + "score": 85, + "comment": "角色代入度评价..." + }}, + "communication": {{ + "score": 80, + "comment": "沟通表达能力评价..." + }}, + "professional_knowledge": {{ + "score": 75, + "comment": "专业知识运用评价..." + }}, + "response_quality": {{ + "score": 82, + "comment": "回应质量评价..." + }}, + "goal_achievement": {{ + "score": 78, + "comment": "目标达成度评价..." + }} + }}, + "highlights": [ + "亮点1...", + "亮点2..." + ], + "improvements": [ + {{ + "issue": "问题描述", + "suggestion": "改进建议", + "example": "示例话术" + }} + ] + }}, + "user_b_evaluation": {{ + "user_name": "{user_b_name}", + "role_name": "{role_b_name}", + "total_score": 82, + "dimensions": {{ + "role_immersion": {{ + "score": 80, + "comment": "角色代入度评价..." + }}, + "communication": {{ + "score": 85, + "comment": "沟通表达能力评价..." + }}, + "professional_knowledge": {{ + "score": 78, + "comment": "专业知识运用评价..." + }}, + "response_quality": {{ + "score": 80, + "comment": "回应质量评价..." + }}, + "goal_achievement": {{ + "score": 75, + "comment": "目标达成度评价..." + }} + }}, + "highlights": [ + "亮点1...", + "亮点2..." + ], + "improvements": [ + {{ + "issue": "问题描述", + "suggestion": "改进建议", + "example": "示例话术" + }} + ] + }}, + "dialogue_annotations": [ + {{ + "sequence": 1, + "speaker": "{role_a_name}", + "tags": ["good_opening"], + "comment": "开场白自然得体" + }}, + {{ + "sequence": 3, + "speaker": "{role_b_name}", + "tags": ["needs_improvement"], + "comment": "可以更主动表达需求" + }} + ] +}} +``` + +请基于对话内容,给出客观、专业的评估。""" + + +# ==================== 维度说明 ==================== + +DIMENSION_DESCRIPTIONS = { + "role_immersion": "角色代入度:是否完全进入角色,语言风格、态度是否符合角色设定", + "communication": "沟通表达:语言是否清晰、逻辑是否通顺、表达是否得体", + "professional_knowledge": "专业知识:是否展现出角色应有的专业素养和知识储备", + "response_quality": "回应质量:对对方发言的回应是否及时、恰当、有针对性", + "goal_achievement": "目标达成:是否朝着对练目标推进,是否达成预期效果" +} + + +# ==================== 对话标签 ==================== + +DIALOGUE_TAGS = { + # 正面标签 + "good_opening": "开场良好", + "active_listening": "积极倾听", + "empathy": "共情表达", + "professional": "专业表现", + "good_closing": "结束得体", + "creative_response": "创意回应", + "problem_solving": "问题解决", + + # 需改进标签 + "needs_improvement": "需要改进", + "off_topic": "偏离主题", + "too_passive": "过于被动", + "lack_detail": "缺乏细节", + "missed_opportunity": "错失机会", + "unclear_expression": "表达不清" +} diff --git a/backend/app/services/badge_service.py b/backend/app/services/badge_service.py index c95ddc0..fd7e29f 100644 --- a/backend/app/services/badge_service.py +++ b/backend/app/services/badge_service.py @@ -1,586 +1,586 @@ -""" -奖章服务 - -提供奖章管理功能: -- 获取奖章定义 -- 检查奖章解锁条件 -- 授予奖章 -- 获取用户奖章 -""" - -from datetime import datetime -from typing import Optional, List, Dict, Any, Tuple -from sqlalchemy import select, func, and_, or_, case -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.logger import get_logger -from app.models.level import ( - BadgeDefinition, UserBadge, UserLevel, ExpHistory, ExpType, - BadgeCategory, ConditionType -) -from app.models.exam import Exam -from app.models.practice import PracticeSession, PracticeReport -from app.models.training import TrainingSession, TrainingReport -from app.models.task import TaskAssignment - -logger = get_logger(__name__) - - -class BadgeService: - """奖章服务""" - - def __init__(self, db: AsyncSession): - self.db = db - self._badge_definitions: Optional[List[BadgeDefinition]] = None - - async def _get_badge_definitions(self) -> List[BadgeDefinition]: - """获取所有奖章定义(带缓存)""" - if self._badge_definitions is None: - result = await self.db.execute( - select(BadgeDefinition) - .where(BadgeDefinition.is_active == True) - .order_by(BadgeDefinition.sort_order) - ) - self._badge_definitions = list(result.scalars().all()) - return self._badge_definitions - - async def get_all_badges(self) -> List[Dict[str, Any]]: - """ - 获取所有奖章定义 - - Returns: - 奖章定义列表 - """ - badges = await self._get_badge_definitions() - return [ - { - "id": b.id, - "code": b.code, - "name": b.name, - "description": b.description, - "icon": b.icon, - "category": b.category, - "condition_type": b.condition_type, - "condition_value": b.condition_value, - "exp_reward": b.exp_reward, - } - for b in badges - ] - - async def get_user_badges(self, user_id: int) -> List[Dict[str, Any]]: - """ - 获取用户已解锁的奖章 - - Args: - user_id: 用户ID - - Returns: - 用户奖章列表 - """ - result = await self.db.execute( - select(UserBadge, BadgeDefinition) - .join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id) - .where(UserBadge.user_id == user_id) - .order_by(UserBadge.unlocked_at.desc()) - ) - rows = result.all() - - return [ - { - "id": user_badge.id, - "badge_id": badge.id, - "code": badge.code, - "name": badge.name, - "description": badge.description, - "icon": badge.icon, - "category": badge.category, - "unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None, - "is_notified": user_badge.is_notified, - } - for user_badge, badge in rows - ] - - async def get_user_badges_with_status(self, user_id: int) -> List[Dict[str, Any]]: - """ - 获取所有奖章及用户解锁状态 - - Args: - user_id: 用户ID - - Returns: - 所有奖章列表(含解锁状态) - """ - # 获取所有奖章定义 - all_badges = await self._get_badge_definitions() - - # 获取用户已解锁的奖章 - result = await self.db.execute( - select(UserBadge).where(UserBadge.user_id == user_id) - ) - user_badges = {ub.badge_id: ub for ub in result.scalars().all()} - - badges_with_status = [] - for badge in all_badges: - user_badge = user_badges.get(badge.id) - badges_with_status.append({ - "id": badge.id, - "code": badge.code, - "name": badge.name, - "description": badge.description, - "icon": badge.icon, - "category": badge.category, - "condition_type": badge.condition_type, - "condition_value": badge.condition_value, - "exp_reward": badge.exp_reward, - "unlocked": user_badge is not None, - "unlocked_at": user_badge.unlocked_at.isoformat() if user_badge else None, - }) - - return badges_with_status - - async def _get_user_stats(self, user_id: int) -> Dict[str, Any]: - """ - 获取用户统计数据(用于检查奖章条件) - - Args: - user_id: 用户ID - - Returns: - 统计数据字典 - """ - stats = { - "login_count": 0, - "login_streak": 0, - "course_completed": 0, - "exam_passed": 0, - "exam_perfect_count": 0, - "exam_excellent": 0, - "practice_count": 0, - "practice_hours": 0, - "training_count": 0, - "first_practice_90": 0, - "user_level": 1, - } - - try: - # 获取用户等级信息 - result = await self.db.execute( - select(UserLevel).where(UserLevel.user_id == user_id) - ) - user_level = result.scalar_one_or_none() - if user_level: - stats["login_streak"] = user_level.login_streak or 0 - stats["user_level"] = user_level.level or 1 - - # 获取登录/签到次数(从经验值历史) - result = await self.db.execute( - select(func.count(ExpHistory.id)) - .where( - ExpHistory.user_id == user_id, - ExpHistory.exp_type == ExpType.LOGIN - ) - ) - stats["login_count"] = result.scalar() or 0 - - # 获取考试统计 - 使用 case 语句 - # 通过考试数量 - result = await self.db.execute( - select(func.count(Exam.id)) - .where( - Exam.user_id == user_id, - Exam.is_passed == True, - Exam.status == "submitted" - ) - ) - stats["exam_passed"] = result.scalar() or 0 - - # 满分考试数量(score >= 总分,通常是 100) - result = await self.db.execute( - select(func.count(Exam.id)) - .where( - Exam.user_id == user_id, - Exam.status == "submitted", - Exam.score >= Exam.total_score - ) - ) - stats["exam_perfect_count"] = result.scalar() or 0 - - # 优秀考试数量(90分以上) - result = await self.db.execute( - select(func.count(Exam.id)) - .where( - Exam.user_id == user_id, - Exam.status == "submitted", - Exam.score >= 90 - ) - ) - stats["exam_excellent"] = result.scalar() or 0 - - # 获取练习统计(PracticeSession - AI 陪练) - result = await self.db.execute( - select( - func.count(PracticeSession.id), - func.coalesce(func.sum(PracticeSession.duration_seconds), 0) - ) - .where( - PracticeSession.user_id == user_id, - PracticeSession.status == "completed" - ) - ) - row = result.first() - if row: - stats["practice_count"] = row[0] or 0 - total_seconds = row[1] or 0 - stats["practice_hours"] = float(total_seconds) / 3600.0 - - # 获取培训/陪练统计(TrainingSession) - result = await self.db.execute( - select(func.count(TrainingSession.id)) - .where( - TrainingSession.user_id == user_id, - TrainingSession.status == "COMPLETED" - ) - ) - stats["training_count"] = result.scalar() or 0 - - # 检查是否有高分陪练(90分以上) - result = await self.db.execute( - select(func.count(TrainingReport.id)) - .where( - TrainingReport.user_id == user_id, - TrainingReport.overall_score >= 90 - ) - ) - high_score_count = result.scalar() or 0 - stats["first_practice_90"] = 1 if high_score_count > 0 else 0 - - logger.debug(f"用户 {user_id} 奖章统计数据: {stats}") - - except Exception as e: - logger.error(f"获取用户统计数据失败: {e}") - - return stats - - async def check_and_award_badges(self, user_id: int) -> List[Dict[str, Any]]: - """ - 检查并授予用户符合条件的奖章 - - Args: - user_id: 用户ID - - Returns: - 新获得的奖章列表 - """ - # 获取用户统计数据 - stats = await self._get_user_stats(user_id) - - # 获取所有奖章定义 - all_badges = await self._get_badge_definitions() - - # 获取用户已有的奖章 - result = await self.db.execute( - select(UserBadge.badge_id).where(UserBadge.user_id == user_id) - ) - owned_badge_ids = {row[0] for row in result.all()} - - # 检查每个奖章的解锁条件 - newly_awarded = [] - for badge in all_badges: - if badge.id in owned_badge_ids: - continue - - # 检查条件 - condition_met = self._check_badge_condition(badge, stats) - - if condition_met: - # 授予奖章 - user_badge = UserBadge( - user_id=user_id, - badge_id=badge.id, - unlocked_at=datetime.now(), - is_notified=False - ) - self.db.add(user_badge) - - # 如果有经验奖励,添加经验值 - if badge.exp_reward > 0: - from app.services.level_service import LevelService - level_service = LevelService(self.db) - await level_service.add_exp( - user_id=user_id, - exp_amount=badge.exp_reward, - exp_type=ExpType.BADGE, - description=f"解锁奖章「{badge.name}」" - ) - - newly_awarded.append({ - "badge_id": badge.id, - "code": badge.code, - "name": badge.name, - "description": badge.description, - "icon": badge.icon, - "exp_reward": badge.exp_reward, - }) - - logger.info(f"用户 {user_id} 解锁奖章: {badge.name}") - - if newly_awarded: - await self.db.flush() - - return newly_awarded - - def _check_badge_condition(self, badge: BadgeDefinition, stats: Dict[str, Any]) -> bool: - """ - 检查奖章解锁条件 - - Args: - badge: 奖章定义 - stats: 用户统计数据 - - Returns: - 是否满足条件 - """ - condition_field = badge.condition_field - condition_value = badge.condition_value - condition_type = badge.condition_type - - if not condition_field: - return False - - current_value = stats.get(condition_field, 0) - - if condition_type == ConditionType.COUNT: - return current_value >= condition_value - elif condition_type == ConditionType.SCORE: - return current_value >= condition_value - elif condition_type == ConditionType.STREAK: - return current_value >= condition_value - elif condition_type == ConditionType.LEVEL: - return current_value >= condition_value - elif condition_type == ConditionType.DURATION: - return current_value >= condition_value - - return False - - async def award_badge(self, user_id: int, badge_code: str) -> Optional[Dict[str, Any]]: - """ - 直接授予用户奖章(用于特殊奖章) - - Args: - user_id: 用户ID - badge_code: 奖章编码 - - Returns: - 奖章信息(如果成功) - """ - # 获取奖章定义 - result = await self.db.execute( - select(BadgeDefinition).where(BadgeDefinition.code == badge_code) - ) - badge = result.scalar_one_or_none() - - if not badge: - logger.warning(f"奖章不存在: {badge_code}") - return None - - # 检查是否已拥有 - result = await self.db.execute( - select(UserBadge).where( - UserBadge.user_id == user_id, - UserBadge.badge_id == badge.id - ) - ) - if result.scalar_one_or_none(): - logger.info(f"用户 {user_id} 已拥有奖章: {badge_code}") - return None - - # 授予奖章 - user_badge = UserBadge( - user_id=user_id, - badge_id=badge.id, - unlocked_at=datetime.now(), - is_notified=False - ) - self.db.add(user_badge) - - # 添加经验值奖励 - if badge.exp_reward > 0: - from app.services.level_service import LevelService - level_service = LevelService(self.db) - await level_service.add_exp( - user_id=user_id, - exp_amount=badge.exp_reward, - exp_type=ExpType.BADGE, - description=f"解锁奖章「{badge.name}」" - ) - - await self.db.flush() - - logger.info(f"用户 {user_id} 获得奖章: {badge.name}") - - return { - "badge_id": badge.id, - "code": badge.code, - "name": badge.name, - "description": badge.description, - "icon": badge.icon, - "exp_reward": badge.exp_reward, - } - - async def get_unnotified_badges(self, user_id: int) -> List[Dict[str, Any]]: - """ - 获取用户未通知的新奖章 - - Args: - user_id: 用户ID - - Returns: - 未通知的奖章列表 - """ - result = await self.db.execute( - select(UserBadge, BadgeDefinition) - .join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id) - .where( - UserBadge.user_id == user_id, - UserBadge.is_notified == False - ) - .order_by(UserBadge.unlocked_at.desc()) - ) - rows = result.all() - - return [ - { - "user_badge_id": user_badge.id, - "badge_id": badge.id, - "code": badge.code, - "name": badge.name, - "description": badge.description, - "icon": badge.icon, - "exp_reward": badge.exp_reward, - "unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None, - } - for user_badge, badge in rows - ] - - async def mark_badges_notified(self, user_id: int, badge_ids: List[int] = None): - """ - 标记奖章为已通知 - - Args: - user_id: 用户ID - badge_ids: 要标记的奖章ID列表(为空则标记全部) - """ - from sqlalchemy import update - - query = update(UserBadge).where( - UserBadge.user_id == user_id, - UserBadge.is_notified == False - ) - - if badge_ids: - query = query.where(UserBadge.badge_id.in_(badge_ids)) - - query = query.values( - is_notified=True, - notified_at=datetime.now() - ) - - await self.db.execute(query) - await self.db.flush() - - async def check_badges_by_category( - self, - user_id: int, - categories: List[str] - ) -> List[Dict[str, Any]]: - """ - 按类别检查并授予奖章(优化触发时机) - - Args: - user_id: 用户ID - categories: 要检查的奖章类别列表 - - Returns: - 新获得的奖章列表 - """ - # 获取用户统计数据 - stats = await self._get_user_stats(user_id) - - # 获取指定类别的奖章定义 - result = await self.db.execute( - select(BadgeDefinition) - .where( - BadgeDefinition.is_active == True, - BadgeDefinition.category.in_(categories) - ) - .order_by(BadgeDefinition.sort_order) - ) - category_badges = list(result.scalars().all()) - - # 获取用户已有的奖章 - result = await self.db.execute( - select(UserBadge.badge_id).where(UserBadge.user_id == user_id) - ) - owned_badge_ids = {row[0] for row in result.all()} - - # 检查每个奖章的解锁条件 - newly_awarded = [] - for badge in category_badges: - if badge.id in owned_badge_ids: - continue - - # 检查条件 - condition_met = self._check_badge_condition(badge, stats) - - if condition_met: - # 授予奖章 - user_badge = UserBadge( - user_id=user_id, - badge_id=badge.id, - unlocked_at=datetime.now(), - is_notified=False - ) - self.db.add(user_badge) - - # 如果有经验奖励,添加经验值 - if badge.exp_reward > 0: - from app.services.level_service import LevelService - level_service = LevelService(self.db) - await level_service.add_exp( - user_id=user_id, - exp_amount=badge.exp_reward, - exp_type=ExpType.BADGE, - description=f"解锁奖章「{badge.name}」" - ) - - newly_awarded.append({ - "badge_id": badge.id, - "code": badge.code, - "name": badge.name, - "description": badge.description, - "icon": badge.icon, - "exp_reward": badge.exp_reward, - }) - - logger.info(f"用户 {user_id} 解锁奖章: {badge.name}") - - if newly_awarded: - await self.db.flush() - - return newly_awarded - - async def check_exam_badges(self, user_id: int) -> List[Dict[str, Any]]: - """考试后检查考试类奖章""" - return await self.check_badges_by_category(user_id, [BadgeCategory.EXAM]) - - async def check_practice_badges(self, user_id: int) -> List[Dict[str, Any]]: - """练习后检查练习类奖章""" - return await self.check_badges_by_category(user_id, [BadgeCategory.PRACTICE]) - - async def check_streak_badges(self, user_id: int) -> List[Dict[str, Any]]: - """签到后检查连续打卡类奖章""" - return await self.check_badges_by_category(user_id, [BadgeCategory.STREAK, BadgeCategory.LEARNING]) - - async def check_level_badges(self, user_id: int) -> List[Dict[str, Any]]: - """等级变化后检查等级类奖章""" - return await self.check_badges_by_category(user_id, [BadgeCategory.SPECIAL]) +""" +奖章服务 + +提供奖章管理功能: +- 获取奖章定义 +- 检查奖章解锁条件 +- 授予奖章 +- 获取用户奖章 +""" + +from datetime import datetime +from typing import Optional, List, Dict, Any, Tuple +from sqlalchemy import select, func, and_, or_, case +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.level import ( + BadgeDefinition, UserBadge, UserLevel, ExpHistory, ExpType, + BadgeCategory, ConditionType +) +from app.models.exam import Exam +from app.models.practice import PracticeSession, PracticeReport +from app.models.training import TrainingSession, TrainingReport +from app.models.task import TaskAssignment + +logger = get_logger(__name__) + + +class BadgeService: + """奖章服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self._badge_definitions: Optional[List[BadgeDefinition]] = None + + async def _get_badge_definitions(self) -> List[BadgeDefinition]: + """获取所有奖章定义(带缓存)""" + if self._badge_definitions is None: + result = await self.db.execute( + select(BadgeDefinition) + .where(BadgeDefinition.is_active == True) + .order_by(BadgeDefinition.sort_order) + ) + self._badge_definitions = list(result.scalars().all()) + return self._badge_definitions + + async def get_all_badges(self) -> List[Dict[str, Any]]: + """ + 获取所有奖章定义 + + Returns: + 奖章定义列表 + """ + badges = await self._get_badge_definitions() + return [ + { + "id": b.id, + "code": b.code, + "name": b.name, + "description": b.description, + "icon": b.icon, + "category": b.category, + "condition_type": b.condition_type, + "condition_value": b.condition_value, + "exp_reward": b.exp_reward, + } + for b in badges + ] + + async def get_user_badges(self, user_id: int) -> List[Dict[str, Any]]: + """ + 获取用户已解锁的奖章 + + Args: + user_id: 用户ID + + Returns: + 用户奖章列表 + """ + result = await self.db.execute( + select(UserBadge, BadgeDefinition) + .join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id) + .where(UserBadge.user_id == user_id) + .order_by(UserBadge.unlocked_at.desc()) + ) + rows = result.all() + + return [ + { + "id": user_badge.id, + "badge_id": badge.id, + "code": badge.code, + "name": badge.name, + "description": badge.description, + "icon": badge.icon, + "category": badge.category, + "unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None, + "is_notified": user_badge.is_notified, + } + for user_badge, badge in rows + ] + + async def get_user_badges_with_status(self, user_id: int) -> List[Dict[str, Any]]: + """ + 获取所有奖章及用户解锁状态 + + Args: + user_id: 用户ID + + Returns: + 所有奖章列表(含解锁状态) + """ + # 获取所有奖章定义 + all_badges = await self._get_badge_definitions() + + # 获取用户已解锁的奖章 + result = await self.db.execute( + select(UserBadge).where(UserBadge.user_id == user_id) + ) + user_badges = {ub.badge_id: ub for ub in result.scalars().all()} + + badges_with_status = [] + for badge in all_badges: + user_badge = user_badges.get(badge.id) + badges_with_status.append({ + "id": badge.id, + "code": badge.code, + "name": badge.name, + "description": badge.description, + "icon": badge.icon, + "category": badge.category, + "condition_type": badge.condition_type, + "condition_value": badge.condition_value, + "exp_reward": badge.exp_reward, + "unlocked": user_badge is not None, + "unlocked_at": user_badge.unlocked_at.isoformat() if user_badge else None, + }) + + return badges_with_status + + async def _get_user_stats(self, user_id: int) -> Dict[str, Any]: + """ + 获取用户统计数据(用于检查奖章条件) + + Args: + user_id: 用户ID + + Returns: + 统计数据字典 + """ + stats = { + "login_count": 0, + "login_streak": 0, + "course_completed": 0, + "exam_passed": 0, + "exam_perfect_count": 0, + "exam_excellent": 0, + "practice_count": 0, + "practice_hours": 0, + "training_count": 0, + "first_practice_90": 0, + "user_level": 1, + } + + try: + # 获取用户等级信息 + result = await self.db.execute( + select(UserLevel).where(UserLevel.user_id == user_id) + ) + user_level = result.scalar_one_or_none() + if user_level: + stats["login_streak"] = user_level.login_streak or 0 + stats["user_level"] = user_level.level or 1 + + # 获取登录/签到次数(从经验值历史) + result = await self.db.execute( + select(func.count(ExpHistory.id)) + .where( + ExpHistory.user_id == user_id, + ExpHistory.exp_type == ExpType.LOGIN + ) + ) + stats["login_count"] = result.scalar() or 0 + + # 获取考试统计 - 使用 case 语句 + # 通过考试数量 + result = await self.db.execute( + select(func.count(Exam.id)) + .where( + Exam.user_id == user_id, + Exam.is_passed == True, + Exam.status == "submitted" + ) + ) + stats["exam_passed"] = result.scalar() or 0 + + # 满分考试数量(score >= 总分,通常是 100) + result = await self.db.execute( + select(func.count(Exam.id)) + .where( + Exam.user_id == user_id, + Exam.status == "submitted", + Exam.score >= Exam.total_score + ) + ) + stats["exam_perfect_count"] = result.scalar() or 0 + + # 优秀考试数量(90分以上) + result = await self.db.execute( + select(func.count(Exam.id)) + .where( + Exam.user_id == user_id, + Exam.status == "submitted", + Exam.score >= 90 + ) + ) + stats["exam_excellent"] = result.scalar() or 0 + + # 获取练习统计(PracticeSession - AI 陪练) + result = await self.db.execute( + select( + func.count(PracticeSession.id), + func.coalesce(func.sum(PracticeSession.duration_seconds), 0) + ) + .where( + PracticeSession.user_id == user_id, + PracticeSession.status == "completed" + ) + ) + row = result.first() + if row: + stats["practice_count"] = row[0] or 0 + total_seconds = row[1] or 0 + stats["practice_hours"] = float(total_seconds) / 3600.0 + + # 获取培训/陪练统计(TrainingSession) + result = await self.db.execute( + select(func.count(TrainingSession.id)) + .where( + TrainingSession.user_id == user_id, + TrainingSession.status == "COMPLETED" + ) + ) + stats["training_count"] = result.scalar() or 0 + + # 检查是否有高分陪练(90分以上) + result = await self.db.execute( + select(func.count(TrainingReport.id)) + .where( + TrainingReport.user_id == user_id, + TrainingReport.overall_score >= 90 + ) + ) + high_score_count = result.scalar() or 0 + stats["first_practice_90"] = 1 if high_score_count > 0 else 0 + + logger.debug(f"用户 {user_id} 奖章统计数据: {stats}") + + except Exception as e: + logger.error(f"获取用户统计数据失败: {e}") + + return stats + + async def check_and_award_badges(self, user_id: int) -> List[Dict[str, Any]]: + """ + 检查并授予用户符合条件的奖章 + + Args: + user_id: 用户ID + + Returns: + 新获得的奖章列表 + """ + # 获取用户统计数据 + stats = await self._get_user_stats(user_id) + + # 获取所有奖章定义 + all_badges = await self._get_badge_definitions() + + # 获取用户已有的奖章 + result = await self.db.execute( + select(UserBadge.badge_id).where(UserBadge.user_id == user_id) + ) + owned_badge_ids = {row[0] for row in result.all()} + + # 检查每个奖章的解锁条件 + newly_awarded = [] + for badge in all_badges: + if badge.id in owned_badge_ids: + continue + + # 检查条件 + condition_met = self._check_badge_condition(badge, stats) + + if condition_met: + # 授予奖章 + user_badge = UserBadge( + user_id=user_id, + badge_id=badge.id, + unlocked_at=datetime.now(), + is_notified=False + ) + self.db.add(user_badge) + + # 如果有经验奖励,添加经验值 + if badge.exp_reward > 0: + from app.services.level_service import LevelService + level_service = LevelService(self.db) + await level_service.add_exp( + user_id=user_id, + exp_amount=badge.exp_reward, + exp_type=ExpType.BADGE, + description=f"解锁奖章「{badge.name}」" + ) + + newly_awarded.append({ + "badge_id": badge.id, + "code": badge.code, + "name": badge.name, + "description": badge.description, + "icon": badge.icon, + "exp_reward": badge.exp_reward, + }) + + logger.info(f"用户 {user_id} 解锁奖章: {badge.name}") + + if newly_awarded: + await self.db.flush() + + return newly_awarded + + def _check_badge_condition(self, badge: BadgeDefinition, stats: Dict[str, Any]) -> bool: + """ + 检查奖章解锁条件 + + Args: + badge: 奖章定义 + stats: 用户统计数据 + + Returns: + 是否满足条件 + """ + condition_field = badge.condition_field + condition_value = badge.condition_value + condition_type = badge.condition_type + + if not condition_field: + return False + + current_value = stats.get(condition_field, 0) + + if condition_type == ConditionType.COUNT: + return current_value >= condition_value + elif condition_type == ConditionType.SCORE: + return current_value >= condition_value + elif condition_type == ConditionType.STREAK: + return current_value >= condition_value + elif condition_type == ConditionType.LEVEL: + return current_value >= condition_value + elif condition_type == ConditionType.DURATION: + return current_value >= condition_value + + return False + + async def award_badge(self, user_id: int, badge_code: str) -> Optional[Dict[str, Any]]: + """ + 直接授予用户奖章(用于特殊奖章) + + Args: + user_id: 用户ID + badge_code: 奖章编码 + + Returns: + 奖章信息(如果成功) + """ + # 获取奖章定义 + result = await self.db.execute( + select(BadgeDefinition).where(BadgeDefinition.code == badge_code) + ) + badge = result.scalar_one_or_none() + + if not badge: + logger.warning(f"奖章不存在: {badge_code}") + return None + + # 检查是否已拥有 + result = await self.db.execute( + select(UserBadge).where( + UserBadge.user_id == user_id, + UserBadge.badge_id == badge.id + ) + ) + if result.scalar_one_or_none(): + logger.info(f"用户 {user_id} 已拥有奖章: {badge_code}") + return None + + # 授予奖章 + user_badge = UserBadge( + user_id=user_id, + badge_id=badge.id, + unlocked_at=datetime.now(), + is_notified=False + ) + self.db.add(user_badge) + + # 添加经验值奖励 + if badge.exp_reward > 0: + from app.services.level_service import LevelService + level_service = LevelService(self.db) + await level_service.add_exp( + user_id=user_id, + exp_amount=badge.exp_reward, + exp_type=ExpType.BADGE, + description=f"解锁奖章「{badge.name}」" + ) + + await self.db.flush() + + logger.info(f"用户 {user_id} 获得奖章: {badge.name}") + + return { + "badge_id": badge.id, + "code": badge.code, + "name": badge.name, + "description": badge.description, + "icon": badge.icon, + "exp_reward": badge.exp_reward, + } + + async def get_unnotified_badges(self, user_id: int) -> List[Dict[str, Any]]: + """ + 获取用户未通知的新奖章 + + Args: + user_id: 用户ID + + Returns: + 未通知的奖章列表 + """ + result = await self.db.execute( + select(UserBadge, BadgeDefinition) + .join(BadgeDefinition, UserBadge.badge_id == BadgeDefinition.id) + .where( + UserBadge.user_id == user_id, + UserBadge.is_notified == False + ) + .order_by(UserBadge.unlocked_at.desc()) + ) + rows = result.all() + + return [ + { + "user_badge_id": user_badge.id, + "badge_id": badge.id, + "code": badge.code, + "name": badge.name, + "description": badge.description, + "icon": badge.icon, + "exp_reward": badge.exp_reward, + "unlocked_at": user_badge.unlocked_at.isoformat() if user_badge.unlocked_at else None, + } + for user_badge, badge in rows + ] + + async def mark_badges_notified(self, user_id: int, badge_ids: List[int] = None): + """ + 标记奖章为已通知 + + Args: + user_id: 用户ID + badge_ids: 要标记的奖章ID列表(为空则标记全部) + """ + from sqlalchemy import update + + query = update(UserBadge).where( + UserBadge.user_id == user_id, + UserBadge.is_notified == False + ) + + if badge_ids: + query = query.where(UserBadge.badge_id.in_(badge_ids)) + + query = query.values( + is_notified=True, + notified_at=datetime.now() + ) + + await self.db.execute(query) + await self.db.flush() + + async def check_badges_by_category( + self, + user_id: int, + categories: List[str] + ) -> List[Dict[str, Any]]: + """ + 按类别检查并授予奖章(优化触发时机) + + Args: + user_id: 用户ID + categories: 要检查的奖章类别列表 + + Returns: + 新获得的奖章列表 + """ + # 获取用户统计数据 + stats = await self._get_user_stats(user_id) + + # 获取指定类别的奖章定义 + result = await self.db.execute( + select(BadgeDefinition) + .where( + BadgeDefinition.is_active == True, + BadgeDefinition.category.in_(categories) + ) + .order_by(BadgeDefinition.sort_order) + ) + category_badges = list(result.scalars().all()) + + # 获取用户已有的奖章 + result = await self.db.execute( + select(UserBadge.badge_id).where(UserBadge.user_id == user_id) + ) + owned_badge_ids = {row[0] for row in result.all()} + + # 检查每个奖章的解锁条件 + newly_awarded = [] + for badge in category_badges: + if badge.id in owned_badge_ids: + continue + + # 检查条件 + condition_met = self._check_badge_condition(badge, stats) + + if condition_met: + # 授予奖章 + user_badge = UserBadge( + user_id=user_id, + badge_id=badge.id, + unlocked_at=datetime.now(), + is_notified=False + ) + self.db.add(user_badge) + + # 如果有经验奖励,添加经验值 + if badge.exp_reward > 0: + from app.services.level_service import LevelService + level_service = LevelService(self.db) + await level_service.add_exp( + user_id=user_id, + exp_amount=badge.exp_reward, + exp_type=ExpType.BADGE, + description=f"解锁奖章「{badge.name}」" + ) + + newly_awarded.append({ + "badge_id": badge.id, + "code": badge.code, + "name": badge.name, + "description": badge.description, + "icon": badge.icon, + "exp_reward": badge.exp_reward, + }) + + logger.info(f"用户 {user_id} 解锁奖章: {badge.name}") + + if newly_awarded: + await self.db.flush() + + return newly_awarded + + async def check_exam_badges(self, user_id: int) -> List[Dict[str, Any]]: + """考试后检查考试类奖章""" + return await self.check_badges_by_category(user_id, [BadgeCategory.EXAM]) + + async def check_practice_badges(self, user_id: int) -> List[Dict[str, Any]]: + """练习后检查练习类奖章""" + return await self.check_badges_by_category(user_id, [BadgeCategory.PRACTICE]) + + async def check_streak_badges(self, user_id: int) -> List[Dict[str, Any]]: + """签到后检查连续打卡类奖章""" + return await self.check_badges_by_category(user_id, [BadgeCategory.STREAK, BadgeCategory.LEARNING]) + + async def check_level_badges(self, user_id: int) -> List[Dict[str, Any]]: + """等级变化后检查等级类奖章""" + return await self.check_badges_by_category(user_id, [BadgeCategory.SPECIAL]) diff --git a/backend/app/services/certificate_service.py b/backend/app/services/certificate_service.py index a31442f..9672ccb 100644 --- a/backend/app/services/certificate_service.py +++ b/backend/app/services/certificate_service.py @@ -1,516 +1,759 @@ -""" -证书服务 - -提供证书管理功能: -- 颁发证书 -- 获取证书列表 -- 生成证书PDF/图片 -- 验证证书 -""" - -import os -import io -import uuid -from datetime import datetime -from typing import Optional, List, Dict, Any -from sqlalchemy import select, func, and_ -from sqlalchemy.ext.asyncio import AsyncSession -from PIL import Image, ImageDraw, ImageFont -import qrcode - -from app.core.logger import get_logger -from app.core.config import settings -from app.models.certificate import CertificateTemplate, UserCertificate, CertificateType - -logger = get_logger(__name__) - - -class CertificateService: - """证书服务""" - - # 证书编号前缀 - CERT_NO_PREFIX = "KPL" - - def __init__(self, db: AsyncSession): - self.db = db - - async def get_templates(self, cert_type: Optional[str] = None) -> List[Dict[str, Any]]: - """ - 获取证书模板列表 - - Args: - cert_type: 证书类型过滤 - - Returns: - 模板列表 - """ - query = select(CertificateTemplate).where(CertificateTemplate.is_active == True) - - if cert_type: - query = query.where(CertificateTemplate.type == cert_type) - - query = query.order_by(CertificateTemplate.sort_order) - - result = await self.db.execute(query) - templates = result.scalars().all() - - return [ - { - "id": t.id, - "name": t.name, - "type": t.type.value if isinstance(t.type, CertificateType) else t.type, - "background_url": t.background_url, - "is_active": t.is_active, - } - for t in templates - ] - - async def _generate_certificate_no(self) -> str: - """生成唯一证书编号""" - year = datetime.now().year - - # 获取当年的证书数量 - result = await self.db.execute( - select(func.count(UserCertificate.id)) - .where(UserCertificate.certificate_no.like(f"{self.CERT_NO_PREFIX}-{year}-%")) - ) - count = result.scalar() or 0 - - # 生成编号:KPL-年份-6位序号 - cert_no = f"{self.CERT_NO_PREFIX}-{year}-{str(count + 1).zfill(6)}" - return cert_no - - async def issue_course_certificate( - self, - user_id: int, - course_id: int, - course_name: str, - completion_rate: float, - user_name: str - ) -> Dict[str, Any]: - """ - 颁发课程结业证书 - - Args: - user_id: 用户ID - course_id: 课程ID - course_name: 课程名称 - completion_rate: 完成率 - user_name: 用户姓名 - - Returns: - 证书信息 - """ - # 检查是否已颁发 - existing = await self.db.execute( - select(UserCertificate).where( - UserCertificate.user_id == user_id, - UserCertificate.course_id == course_id - ) - ) - if existing.scalar_one_or_none(): - raise ValueError("该课程证书已颁发") - - # 获取课程证书模板 - result = await self.db.execute( - select(CertificateTemplate).where( - CertificateTemplate.type == CertificateType.COURSE, - CertificateTemplate.is_active == True - ) - ) - template = result.scalar_one_or_none() - if not template: - raise ValueError("证书模板不存在") - - # 生成证书编号 - cert_no = await self._generate_certificate_no() - - # 创建证书 - certificate = UserCertificate( - user_id=user_id, - template_id=template.id, - certificate_no=cert_no, - title=f"《{course_name}》课程结业证书", - description=f"完成课程《{course_name}》的全部学习内容", - course_id=course_id, - completion_rate=completion_rate, - meta_data={ - "course_name": course_name, - "user_name": user_name, - "completion_rate": completion_rate - } - ) - - self.db.add(certificate) - await self.db.flush() - - logger.info(f"颁发课程证书: user_id={user_id}, course_id={course_id}, cert_no={cert_no}") - - return await self._format_certificate(certificate, template) - - async def issue_exam_certificate( - self, - user_id: int, - exam_id: int, - exam_name: str, - score: float, - user_name: str - ) -> Dict[str, Any]: - """ - 颁发考试合格证书 - - Args: - user_id: 用户ID - exam_id: 考试ID - exam_name: 考试名称 - score: 分数 - user_name: 用户姓名 - - Returns: - 证书信息 - """ - # 检查是否已颁发 - existing = await self.db.execute( - select(UserCertificate).where( - UserCertificate.user_id == user_id, - UserCertificate.exam_id == exam_id - ) - ) - if existing.scalar_one_or_none(): - raise ValueError("该考试证书已颁发") - - # 获取考试证书模板 - result = await self.db.execute( - select(CertificateTemplate).where( - CertificateTemplate.type == CertificateType.EXAM, - CertificateTemplate.is_active == True - ) - ) - template = result.scalar_one_or_none() - if not template: - raise ValueError("证书模板不存在") - - # 生成证书编号 - cert_no = await self._generate_certificate_no() - - # 创建证书 - certificate = UserCertificate( - user_id=user_id, - template_id=template.id, - certificate_no=cert_no, - title=f"《{exam_name}》考试合格证书", - description=f"在《{exam_name}》考试中成绩合格", - exam_id=exam_id, - score=score, - meta_data={ - "exam_name": exam_name, - "user_name": user_name, - "score": score - } - ) - - self.db.add(certificate) - await self.db.flush() - - logger.info(f"颁发考试证书: user_id={user_id}, exam_id={exam_id}, cert_no={cert_no}") - - return await self._format_certificate(certificate, template) - - async def issue_achievement_certificate( - self, - user_id: int, - badge_id: int, - badge_name: str, - badge_description: str, - user_name: str - ) -> Dict[str, Any]: - """ - 颁发成就证书 - - Args: - user_id: 用户ID - badge_id: 奖章ID - badge_name: 奖章名称 - badge_description: 奖章描述 - user_name: 用户姓名 - - Returns: - 证书信息 - """ - # 检查是否已颁发 - existing = await self.db.execute( - select(UserCertificate).where( - UserCertificate.user_id == user_id, - UserCertificate.badge_id == badge_id - ) - ) - if existing.scalar_one_or_none(): - raise ValueError("该成就证书已颁发") - - # 获取成就证书模板 - result = await self.db.execute( - select(CertificateTemplate).where( - CertificateTemplate.type == CertificateType.ACHIEVEMENT, - CertificateTemplate.is_active == True - ) - ) - template = result.scalar_one_or_none() - if not template: - raise ValueError("证书模板不存在") - - # 生成证书编号 - cert_no = await self._generate_certificate_no() - - # 创建证书 - certificate = UserCertificate( - user_id=user_id, - template_id=template.id, - certificate_no=cert_no, - title=f"「{badge_name}」成就证书", - description=badge_description, - badge_id=badge_id, - meta_data={ - "badge_name": badge_name, - "badge_description": badge_description, - "user_name": user_name - } - ) - - self.db.add(certificate) - await self.db.flush() - - logger.info(f"颁发成就证书: user_id={user_id}, badge_id={badge_id}, cert_no={cert_no}") - - return await self._format_certificate(certificate, template) - - async def get_user_certificates( - self, - user_id: int, - cert_type: Optional[str] = None, - offset: int = 0, - limit: int = 20 - ) -> Dict[str, Any]: - """ - 获取用户证书列表 - - Args: - user_id: 用户ID - cert_type: 证书类型过滤 - offset: 偏移量 - limit: 数量限制 - - Returns: - 证书列表和分页信息 - """ - query = ( - select(UserCertificate, CertificateTemplate) - .join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id) - .where(UserCertificate.user_id == user_id) - ) - - if cert_type: - query = query.where(CertificateTemplate.type == cert_type) - - # 获取总数 - count_query = select(func.count()).select_from(query.subquery()) - total_result = await self.db.execute(count_query) - total = total_result.scalar() or 0 - - # 分页查询 - query = query.order_by(UserCertificate.issued_at.desc()).offset(offset).limit(limit) - result = await self.db.execute(query) - rows = result.all() - - certificates = [ - await self._format_certificate(cert, template) - for cert, template in rows - ] - - return { - "items": certificates, - "total": total, - "offset": offset, - "limit": limit - } - - async def get_certificate_by_id(self, cert_id: int) -> Optional[Dict[str, Any]]: - """根据ID获取证书""" - result = await self.db.execute( - select(UserCertificate, CertificateTemplate) - .join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id) - .where(UserCertificate.id == cert_id) - ) - row = result.first() - - if not row: - return None - - cert, template = row - return await self._format_certificate(cert, template) - - async def get_certificate_by_no(self, cert_no: str) -> Optional[Dict[str, Any]]: - """根据编号获取证书(用于验证)""" - result = await self.db.execute( - select(UserCertificate, CertificateTemplate) - .join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id) - .where(UserCertificate.certificate_no == cert_no) - ) - row = result.first() - - if not row: - return None - - cert, template = row - return await self._format_certificate(cert, template, include_user=True) - - async def _format_certificate( - self, - cert: UserCertificate, - template: CertificateTemplate, - include_user: bool = False - ) -> Dict[str, Any]: - """格式化证书数据""" - data = { - "id": cert.id, - "certificate_no": cert.certificate_no, - "title": cert.title, - "description": cert.description, - "type": template.type.value if isinstance(template.type, CertificateType) else template.type, - "type_name": self._get_type_name(template.type), - "issued_at": cert.issued_at.isoformat() if cert.issued_at else None, - "valid_until": cert.valid_until.isoformat() if cert.valid_until else None, - "score": float(cert.score) if cert.score else None, - "completion_rate": float(cert.completion_rate) if cert.completion_rate else None, - "pdf_url": cert.pdf_url, - "image_url": cert.image_url, - "course_id": cert.course_id, - "exam_id": cert.exam_id, - "badge_id": cert.badge_id, - "meta_data": cert.meta_data, - "template": { - "id": template.id, - "name": template.name, - "background_url": template.background_url, - } - } - - if include_user and cert.user: - data["user"] = { - "id": cert.user.id, - "username": cert.user.username, - "full_name": cert.user.full_name, - } - - return data - - def _get_type_name(self, cert_type) -> str: - """获取证书类型名称""" - type_names = { - CertificateType.COURSE: "课程结业证书", - CertificateType.EXAM: "考试合格证书", - CertificateType.ACHIEVEMENT: "成就证书", - "course": "课程结业证书", - "exam": "考试合格证书", - "achievement": "成就证书", - } - return type_names.get(cert_type, "证书") - - async def generate_certificate_image( - self, - cert_id: int, - base_url: str = "" - ) -> bytes: - """ - 生成证书分享图片 - - Args: - cert_id: 证书ID - base_url: 基础URL(用于生成二维码链接) - - Returns: - 图片二进制数据 - """ - # 获取证书信息 - cert_data = await self.get_certificate_by_id(cert_id) - if not cert_data: - raise ValueError("证书不存在") - - # 创建图片 - width, height = 800, 600 - img = Image.new('RGB', (width, height), color='#f5f7fa') - draw = ImageDraw.Draw(img) - - # 尝试加载字体,如果失败则使用默认字体 - try: - title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36) - text_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20) - small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14) - except: - title_font = ImageFont.load_default() - text_font = ImageFont.load_default() - small_font = ImageFont.load_default() - - # 绘制标题 - title = cert_data.get("type_name", "证书") - draw.text((width // 2, 60), title, font=title_font, fill='#333333', anchor='mm') - - # 绘制证书标题 - cert_title = cert_data.get("title", "") - draw.text((width // 2, 140), cert_title, font=text_font, fill='#666666', anchor='mm') - - # 绘制描述 - description = cert_data.get("description", "") - draw.text((width // 2, 200), description, font=text_font, fill='#666666', anchor='mm') - - # 绘制分数/完成率(如果有) - if cert_data.get("score"): - score_text = f"成绩:{cert_data['score']}分" - draw.text((width // 2, 280), score_text, font=text_font, fill='#667eea', anchor='mm') - elif cert_data.get("completion_rate"): - rate_text = f"完成率:{cert_data['completion_rate']}%" - draw.text((width // 2, 280), rate_text, font=text_font, fill='#667eea', anchor='mm') - - # 绘制颁发日期 - if cert_data.get("issued_at"): - date_text = f"颁发日期:{cert_data['issued_at'][:10]}" - draw.text((width // 2, 360), date_text, font=small_font, fill='#999999', anchor='mm') - - # 绘制证书编号 - cert_no = cert_data.get("certificate_no", "") - draw.text((width // 2, 520), f"证书编号:{cert_no}", font=small_font, fill='#999999', anchor='mm') - - # 生成验证二维码 - if base_url and cert_no: - verify_url = f"{base_url}/verify/{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_img = qr_img.resize((80, 80)) - img.paste(qr_img, (width - 100, height - 100)) - - # 转换为字节 - img_bytes = io.BytesIO() - img.save(img_bytes, format='PNG') - img_bytes.seek(0) - - return img_bytes.getvalue() - - async def update_certificate_files( - self, - cert_id: int, - pdf_url: Optional[str] = None, - image_url: Optional[str] = None - ): - """更新证书文件URL""" - result = await self.db.execute( - select(UserCertificate).where(UserCertificate.id == cert_id) - ) - cert = result.scalar_one_or_none() - - if cert: - if pdf_url: - cert.pdf_url = pdf_url - if image_url: - cert.image_url = image_url - await self.db.flush() +""" +证书服务 + +提供证书管理功能: +- 颁发证书 +- 获取证书列表 +- 生成证书PDF/图片 +- 验证证书 +""" + +import os +import io +import uuid +from datetime import datetime +from typing import Optional, List, Dict, Any +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from PIL import Image, ImageDraw, ImageFont +import qrcode + +from app.core.logger import get_logger +from app.core.config import settings +from app.models.certificate import CertificateTemplate, UserCertificate, CertificateType + +logger = get_logger(__name__) + + +class CertificateService: + """证书服务""" + + # 证书编号前缀 + CERT_NO_PREFIX = "KPL" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_templates(self, cert_type: Optional[str] = None) -> List[Dict[str, Any]]: + """ + 获取证书模板列表 + + Args: + cert_type: 证书类型过滤 + + Returns: + 模板列表 + """ + query = select(CertificateTemplate).where(CertificateTemplate.is_active == True) + + if cert_type: + query = query.where(CertificateTemplate.type == cert_type) + + query = query.order_by(CertificateTemplate.sort_order) + + result = await self.db.execute(query) + templates = result.scalars().all() + + return [ + { + "id": t.id, + "name": t.name, + "type": t.type.value if isinstance(t.type, CertificateType) else t.type, + "background_url": t.background_url, + "is_active": t.is_active, + } + for t in templates + ] + + async def _generate_certificate_no(self) -> str: + """生成唯一证书编号""" + year = datetime.now().year + + # 获取当年的证书数量 + result = await self.db.execute( + select(func.count(UserCertificate.id)) + .where(UserCertificate.certificate_no.like(f"{self.CERT_NO_PREFIX}-{year}-%")) + ) + count = result.scalar() or 0 + + # 生成编号:KPL-年份-6位序号 + cert_no = f"{self.CERT_NO_PREFIX}-{year}-{str(count + 1).zfill(6)}" + return cert_no + + async def issue_course_certificate( + self, + user_id: int, + course_id: int, + course_name: str, + completion_rate: float, + user_name: str + ) -> Dict[str, Any]: + """ + 颁发课程结业证书 + + Args: + user_id: 用户ID + course_id: 课程ID + course_name: 课程名称 + completion_rate: 完成率 + user_name: 用户姓名 + + Returns: + 证书信息 + """ + # 检查是否已颁发 + existing = await self.db.execute( + select(UserCertificate).where( + UserCertificate.user_id == user_id, + UserCertificate.course_id == course_id + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("该课程证书已颁发") + + # 获取课程证书模板 + result = await self.db.execute( + select(CertificateTemplate).where( + CertificateTemplate.type == CertificateType.COURSE, + CertificateTemplate.is_active == True + ) + ) + template = result.scalar_one_or_none() + if not template: + raise ValueError("证书模板不存在") + + # 生成证书编号 + cert_no = await self._generate_certificate_no() + + # 创建证书 + certificate = UserCertificate( + user_id=user_id, + template_id=template.id, + certificate_no=cert_no, + title=f"《{course_name}》课程结业证书", + description=f"完成课程《{course_name}》的全部学习内容", + course_id=course_id, + completion_rate=completion_rate, + meta_data={ + "course_name": course_name, + "user_name": user_name, + "completion_rate": completion_rate + } + ) + + self.db.add(certificate) + await self.db.flush() + + logger.info(f"颁发课程证书: user_id={user_id}, course_id={course_id}, cert_no={cert_no}") + + return await self._format_certificate(certificate, template) + + async def issue_exam_certificate( + self, + user_id: int, + exam_id: int, + exam_name: str, + score: float, + user_name: str + ) -> Dict[str, Any]: + """ + 颁发考试合格证书 + + Args: + user_id: 用户ID + exam_id: 考试ID + exam_name: 考试名称 + score: 分数 + user_name: 用户姓名 + + Returns: + 证书信息 + """ + # 检查是否已颁发 + existing = await self.db.execute( + select(UserCertificate).where( + UserCertificate.user_id == user_id, + UserCertificate.exam_id == exam_id + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("该考试证书已颁发") + + # 获取考试证书模板 + result = await self.db.execute( + select(CertificateTemplate).where( + CertificateTemplate.type == CertificateType.EXAM, + CertificateTemplate.is_active == True + ) + ) + template = result.scalar_one_or_none() + if not template: + raise ValueError("证书模板不存在") + + # 生成证书编号 + cert_no = await self._generate_certificate_no() + + # 创建证书 + certificate = UserCertificate( + user_id=user_id, + template_id=template.id, + certificate_no=cert_no, + title=f"《{exam_name}》考试合格证书", + description=f"在《{exam_name}》考试中成绩合格", + exam_id=exam_id, + score=score, + meta_data={ + "exam_name": exam_name, + "user_name": user_name, + "score": score + } + ) + + self.db.add(certificate) + await self.db.flush() + + logger.info(f"颁发考试证书: user_id={user_id}, exam_id={exam_id}, cert_no={cert_no}") + + return await self._format_certificate(certificate, template) + + async def issue_achievement_certificate( + self, + user_id: int, + badge_id: int, + badge_name: str, + badge_description: str, + user_name: str + ) -> Dict[str, Any]: + """ + 颁发成就证书 + + Args: + user_id: 用户ID + badge_id: 奖章ID + badge_name: 奖章名称 + badge_description: 奖章描述 + user_name: 用户姓名 + + Returns: + 证书信息 + """ + # 检查是否已颁发 + existing = await self.db.execute( + select(UserCertificate).where( + UserCertificate.user_id == user_id, + UserCertificate.badge_id == badge_id + ) + ) + if existing.scalar_one_or_none(): + raise ValueError("该成就证书已颁发") + + # 获取成就证书模板 + result = await self.db.execute( + select(CertificateTemplate).where( + CertificateTemplate.type == CertificateType.ACHIEVEMENT, + CertificateTemplate.is_active == True + ) + ) + template = result.scalar_one_or_none() + if not template: + raise ValueError("证书模板不存在") + + # 生成证书编号 + cert_no = await self._generate_certificate_no() + + # 创建证书 + certificate = UserCertificate( + user_id=user_id, + template_id=template.id, + certificate_no=cert_no, + title=f"「{badge_name}」成就证书", + description=badge_description, + badge_id=badge_id, + meta_data={ + "badge_name": badge_name, + "badge_description": badge_description, + "user_name": user_name + } + ) + + self.db.add(certificate) + await self.db.flush() + + logger.info(f"颁发成就证书: user_id={user_id}, badge_id={badge_id}, cert_no={cert_no}") + + return await self._format_certificate(certificate, template) + + async def get_user_certificates( + self, + user_id: int, + cert_type: Optional[str] = None, + offset: int = 0, + limit: int = 20 + ) -> Dict[str, Any]: + """ + 获取用户证书列表 + + Args: + user_id: 用户ID + cert_type: 证书类型过滤 + offset: 偏移量 + limit: 数量限制 + + Returns: + 证书列表和分页信息 + """ + query = ( + select(UserCertificate, CertificateTemplate) + .join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id) + .where(UserCertificate.user_id == user_id) + ) + + if cert_type: + query = query.where(CertificateTemplate.type == cert_type) + + # 获取总数 + count_query = select(func.count()).select_from(query.subquery()) + total_result = await self.db.execute(count_query) + total = total_result.scalar() or 0 + + # 分页查询 + query = query.order_by(UserCertificate.issued_at.desc()).offset(offset).limit(limit) + result = await self.db.execute(query) + rows = result.all() + + certificates = [ + await self._format_certificate(cert, template) + for cert, template in rows + ] + + return { + "items": certificates, + "total": total, + "offset": offset, + "limit": limit + } + + async def get_certificate_by_id(self, cert_id: int) -> Optional[Dict[str, Any]]: + """根据ID获取证书""" + result = await self.db.execute( + select(UserCertificate, CertificateTemplate) + .join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id) + .where(UserCertificate.id == cert_id) + ) + row = result.first() + + if not row: + return None + + cert, template = row + return await self._format_certificate(cert, template) + + async def get_certificate_by_no(self, cert_no: str) -> Optional[Dict[str, Any]]: + """根据编号获取证书(用于验证)""" + result = await self.db.execute( + select(UserCertificate, CertificateTemplate) + .join(CertificateTemplate, UserCertificate.template_id == CertificateTemplate.id) + .where(UserCertificate.certificate_no == cert_no) + ) + row = result.first() + + if not row: + return None + + cert, template = row + return await self._format_certificate(cert, template, include_user=True) + + async def _format_certificate( + self, + cert: UserCertificate, + template: CertificateTemplate, + include_user: bool = False + ) -> Dict[str, Any]: + """格式化证书数据""" + data = { + "id": cert.id, + "certificate_no": cert.certificate_no, + "title": cert.title, + "description": cert.description, + "type": template.type.value if isinstance(template.type, CertificateType) else template.type, + "type_name": self._get_type_name(template.type), + "issued_at": cert.issued_at.isoformat() if cert.issued_at else None, + "valid_until": cert.valid_until.isoformat() if cert.valid_until else None, + "score": float(cert.score) if cert.score else None, + "completion_rate": float(cert.completion_rate) if cert.completion_rate else None, + "pdf_url": cert.pdf_url, + "image_url": cert.image_url, + "course_id": cert.course_id, + "exam_id": cert.exam_id, + "badge_id": cert.badge_id, + "meta_data": cert.meta_data, + "template": { + "id": template.id, + "name": template.name, + "background_url": template.background_url, + } + } + + if include_user and cert.user: + data["user"] = { + "id": cert.user.id, + "username": cert.user.username, + "full_name": cert.user.full_name, + } + + return data + + def _get_type_name(self, cert_type) -> str: + """获取证书类型名称""" + type_names = { + CertificateType.COURSE: "课程结业证书", + CertificateType.EXAM: "考试合格证书", + CertificateType.ACHIEVEMENT: "成就证书", + "course": "课程结业证书", + "exam": "考试合格证书", + "achievement": "成就证书", + } + return type_names.get(cert_type, "证书") + + async def generate_certificate_image( + self, + cert_id: int, + base_url: str = "" + ) -> bytes: + """ + 生成证书分享图片 + + Args: + cert_id: 证书ID + base_url: 基础URL(用于生成二维码链接) + + Returns: + 图片二进制数据 + """ + # 获取证书信息 + cert_data = await self.get_certificate_by_id(cert_id) + if not cert_data: + raise ValueError("证书不存在") + + # 创建图片 + width, height = 800, 600 + img = Image.new('RGB', (width, height), color='#f5f7fa') + draw = ImageDraw.Draw(img) + + # 尝试加载字体,如果失败则使用默认字体 + try: + title_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 36) + text_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 20) + small_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", 14) + except: + title_font = ImageFont.load_default() + text_font = ImageFont.load_default() + small_font = ImageFont.load_default() + + # 绘制标题 + title = cert_data.get("type_name", "证书") + draw.text((width // 2, 60), title, font=title_font, fill='#333333', anchor='mm') + + # 绘制证书标题 + cert_title = cert_data.get("title", "") + draw.text((width // 2, 140), cert_title, font=text_font, fill='#666666', anchor='mm') + + # 绘制描述 + description = cert_data.get("description", "") + draw.text((width // 2, 200), description, font=text_font, fill='#666666', anchor='mm') + + # 绘制分数/完成率(如果有) + if cert_data.get("score"): + score_text = f"成绩:{cert_data['score']}分" + draw.text((width // 2, 280), score_text, font=text_font, fill='#667eea', anchor='mm') + elif cert_data.get("completion_rate"): + rate_text = f"完成率:{cert_data['completion_rate']}%" + draw.text((width // 2, 280), rate_text, font=text_font, fill='#667eea', anchor='mm') + + # 绘制颁发日期 + if cert_data.get("issued_at"): + date_text = f"颁发日期:{cert_data['issued_at'][:10]}" + draw.text((width // 2, 360), date_text, font=small_font, fill='#999999', anchor='mm') + + # 绘制证书编号 + cert_no = cert_data.get("certificate_no", "") + draw.text((width // 2, 520), f"证书编号:{cert_no}", font=small_font, fill='#999999', anchor='mm') + + # 生成验证二维码 + if base_url and cert_no: + verify_url = f"{base_url}/verify/{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_img = qr_img.resize((80, 80)) + img.paste(qr_img, (width - 100, height - 100)) + + # 转换为字节 + img_bytes = io.BytesIO() + img.save(img_bytes, format='PNG') + img_bytes.seek(0) + + return img_bytes.getvalue() + + async def update_certificate_files( + self, + cert_id: int, + pdf_url: Optional[str] = None, + image_url: Optional[str] = None + ): + """更新证书文件URL""" + result = await self.db.execute( + select(UserCertificate).where(UserCertificate.id == cert_id) + ) + cert = result.scalar_one_or_none() + + if cert: + if pdf_url: + cert.pdf_url = pdf_url + 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""" + + + + + + + +
+
+
考培练学习平台
+
{cert_data.get('type_name', '证书')}
+
{user_name}
+
{cert_data.get('title', '')}
+
{cert_data.get('description', '')}
+ {"
成绩:" + str(cert_data.get('score')) + "分
" if cert_data.get('score') else ""} + {"
完成率:" + str(cert_data.get('completion_rate')) + "%
" if cert_data.get('completion_rate') else ""} + +
官方认证
+
+ + + """ + + # 尝试使用 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 \ No newline at end of file diff --git a/backend/app/services/dashboard_service.py b/backend/app/services/dashboard_service.py index 2c0bcb8..253662b 100644 --- a/backend/app/services/dashboard_service.py +++ b/backend/app/services/dashboard_service.py @@ -1,489 +1,489 @@ -""" -数据大屏服务 - -提供企业级和团队级数据大屏功能: -- 学习数据概览 -- 部门/团队对比 -- 趋势分析 -- 实时动态 -""" - -from datetime import datetime, timedelta, date -from typing import Optional, List, Dict, Any -from sqlalchemy import select, func, and_, or_, desc, case -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.logger import get_logger -from app.models.user import User -from app.models.course import Course, CourseMaterial -from app.models.exam import Exam -from app.models.practice import PracticeSession -from app.models.training import TrainingSession, TrainingReport -from app.models.level import UserLevel, ExpHistory, UserBadge -from app.models.position import Position -from app.models.position_member import PositionMember - -logger = get_logger(__name__) - - -class DashboardService: - """数据大屏服务""" - - def __init__(self, db: AsyncSession): - self.db = db - - async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]: - """ - 获取企业级数据概览 - - Args: - enterprise_id: 企业ID(可选,用于多租户) - - Returns: - 企业级数据概览 - """ - today = date.today() - week_ago = today - timedelta(days=7) - month_ago = today - timedelta(days=30) - - # 基础统计 - # 1. 总学员数 - result = await self.db.execute( - select(func.count(User.id)) - .where(User.is_deleted == False, User.role == 'trainee') - ) - total_users = result.scalar() or 0 - - # 2. 今日活跃用户(有经验值记录) - result = await self.db.execute( - select(func.count(func.distinct(ExpHistory.user_id))) - .where(func.date(ExpHistory.created_at) == today) - ) - today_active = result.scalar() or 0 - - # 3. 本周活跃用户 - result = await self.db.execute( - select(func.count(func.distinct(ExpHistory.user_id))) - .where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time())) - ) - week_active = result.scalar() or 0 - - # 4. 本月活跃用户 - result = await self.db.execute( - select(func.count(func.distinct(ExpHistory.user_id))) - .where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time())) - ) - month_active = result.scalar() or 0 - - # 5. 总学习时长(小时) - result = await self.db.execute( - select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) - .where(PracticeSession.status == 'completed') - ) - practice_hours = (result.scalar() or 0) / 3600 - - result = await self.db.execute( - select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)) - .where(TrainingSession.status == 'COMPLETED') - ) - training_hours = (result.scalar() or 0) / 3600 - - total_hours = round(practice_hours + training_hours, 1) - - # 6. 考试统计 - result = await self.db.execute( - select( - func.count(Exam.id), - func.count(case((Exam.is_passed == True, 1))), - func.avg(Exam.score) - ) - .where(Exam.status == 'submitted') - ) - exam_row = result.first() - exam_count = exam_row[0] or 0 - exam_passed = exam_row[1] or 0 - exam_avg_score = round(exam_row[2] or 0, 1) - exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0 - - # 7. 满分人数 - result = await self.db.execute( - select(func.count(func.distinct(Exam.user_id))) - .where(Exam.status == 'submitted', Exam.score >= Exam.total_score) - ) - perfect_users = result.scalar() or 0 - - # 8. 签到率(今日签到人数/总用户数) - result = await self.db.execute( - select(func.count(UserLevel.id)) - .where(func.date(UserLevel.last_login_date) == today) - ) - today_checkin = result.scalar() or 0 - checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0 - - return { - "overview": { - "total_users": total_users, - "today_active": today_active, - "week_active": week_active, - "month_active": month_active, - "total_hours": total_hours, - "checkin_rate": checkin_rate, - }, - "exam": { - "total_count": exam_count, - "pass_rate": exam_pass_rate, - "avg_score": exam_avg_score, - "perfect_users": perfect_users, - }, - "updated_at": datetime.now().isoformat() - } - - async def get_department_comparison(self) -> List[Dict[str, Any]]: - """ - 获取部门/团队学习对比数据 - - Returns: - 部门对比列表 - """ - # 获取所有岗位及其成员的学习数据 - result = await self.db.execute( - select(Position) - .where(Position.is_deleted == False) - .order_by(Position.name) - ) - positions = result.scalars().all() - - departments = [] - for pos in positions: - # 获取该岗位的成员数 - result = await self.db.execute( - select(func.count(PositionMember.id)) - .where(PositionMember.position_id == pos.id) - ) - member_count = result.scalar() or 0 - - if member_count == 0: - continue - - # 获取成员ID列表 - result = await self.db.execute( - select(PositionMember.user_id) - .where(PositionMember.position_id == pos.id) - ) - member_ids = [row[0] for row in result.all()] - - # 统计该岗位成员的学习数据 - # 考试通过率 - result = await self.db.execute( - select( - func.count(Exam.id), - func.count(case((Exam.is_passed == True, 1))) - ) - .where( - Exam.user_id.in_(member_ids), - Exam.status == 'submitted' - ) - ) - exam_row = result.first() - exam_total = exam_row[0] or 0 - exam_passed = exam_row[1] or 0 - pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0 - - # 平均学习时长 - result = await self.db.execute( - select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) - .where( - PracticeSession.user_id.in_(member_ids), - PracticeSession.status == 'completed' - ) - ) - total_seconds = result.scalar() or 0 - avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0 - - # 平均等级 - result = await self.db.execute( - select(func.avg(UserLevel.level)) - .where(UserLevel.user_id.in_(member_ids)) - ) - avg_level = round(result.scalar() or 1, 1) - - departments.append({ - "id": pos.id, - "name": pos.name, - "member_count": member_count, - "pass_rate": pass_rate, - "avg_hours": avg_hours, - "avg_level": avg_level, - }) - - # 按通过率排序 - departments.sort(key=lambda x: x["pass_rate"], reverse=True) - - return departments - - async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]: - """ - 获取学习趋势数据 - - Args: - days: 统计天数 - - Returns: - 趋势数据 - """ - today = date.today() - dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)] - - trend_data = [] - for d in dates: - # 当日活跃用户 - result = await self.db.execute( - select(func.count(func.distinct(ExpHistory.user_id))) - .where(func.date(ExpHistory.created_at) == d) - ) - active_users = result.scalar() or 0 - - # 当日新增学习时长 - result = await self.db.execute( - select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) - .where( - func.date(PracticeSession.created_at) == d, - PracticeSession.status == 'completed' - ) - ) - hours = round((result.scalar() or 0) / 3600, 1) - - # 当日考试次数 - result = await self.db.execute( - select(func.count(Exam.id)) - .where( - func.date(Exam.created_at) == d, - Exam.status == 'submitted' - ) - ) - exams = result.scalar() or 0 - - trend_data.append({ - "date": d.isoformat(), - "active_users": active_users, - "learning_hours": hours, - "exam_count": exams, - }) - - return { - "dates": [d.isoformat() for d in dates], - "trend": trend_data - } - - async def get_level_distribution(self) -> Dict[str, Any]: - """ - 获取等级分布数据 - - Returns: - 等级分布 - """ - result = await self.db.execute( - select(UserLevel.level, func.count(UserLevel.id)) - .group_by(UserLevel.level) - .order_by(UserLevel.level) - ) - rows = result.all() - - distribution = {row[0]: row[1] for row in rows} - - # 补全1-10级 - for i in range(1, 11): - if i not in distribution: - distribution[i] = 0 - - return { - "levels": list(range(1, 11)), - "counts": [distribution.get(i, 0) for i in range(1, 11)] - } - - async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]: - """ - 获取实时动态 - - Args: - limit: 数量限制 - - Returns: - 实时动态列表 - """ - activities = [] - - # 获取最近的经验值记录 - result = await self.db.execute( - select(ExpHistory, User) - .join(User, ExpHistory.user_id == User.id) - .order_by(ExpHistory.created_at.desc()) - .limit(limit) - ) - rows = result.all() - - for exp, user in rows: - activity_type = "学习" - if "考试" in (exp.description or ""): - activity_type = "考试" - elif "签到" in (exp.description or ""): - activity_type = "签到" - elif "陪练" in (exp.description or ""): - activity_type = "陪练" - elif "奖章" in (exp.description or ""): - activity_type = "奖章" - - activities.append({ - "id": exp.id, - "user_id": user.id, - "user_name": user.full_name or user.username, - "type": activity_type, - "description": exp.description, - "exp_amount": exp.exp_amount, - "created_at": exp.created_at.isoformat() if exp.created_at else None, - }) - - return activities - - async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]: - """ - 获取团队级数据大屏 - - Args: - team_leader_id: 团队负责人ID - - Returns: - 团队数据 - """ - # 获取团队负责人管理的岗位 - result = await self.db.execute( - select(Position) - .where( - Position.is_deleted == False, - or_( - Position.manager_id == team_leader_id, - Position.created_by == team_leader_id - ) - ) - ) - positions = result.scalars().all() - position_ids = [p.id for p in positions] - - if not position_ids: - return { - "members": [], - "overview": { - "total_members": 0, - "avg_level": 0, - "avg_exp": 0, - "total_badges": 0, - }, - "pending_tasks": [] - } - - # 获取团队成员 - result = await self.db.execute( - select(PositionMember.user_id) - .where(PositionMember.position_id.in_(position_ids)) - ) - member_ids = [row[0] for row in result.all()] - - if not member_ids: - return { - "members": [], - "overview": { - "total_members": 0, - "avg_level": 0, - "avg_exp": 0, - "total_badges": 0, - }, - "pending_tasks": [] - } - - # 获取成员详细信息 - result = await self.db.execute( - select(User, UserLevel) - .outerjoin(UserLevel, User.id == UserLevel.user_id) - .where(User.id.in_(member_ids)) - .order_by(UserLevel.total_exp.desc().nullslast()) - ) - rows = result.all() - - members = [] - total_exp = 0 - total_level = 0 - - for user, level in rows: - user_level = level.level if level else 1 - user_exp = level.total_exp if level else 0 - total_level += user_level - total_exp += user_exp - - # 获取用户奖章数 - result = await self.db.execute( - select(func.count(UserBadge.id)) - .where(UserBadge.user_id == user.id) - ) - badge_count = result.scalar() or 0 - - members.append({ - "id": user.id, - "username": user.username, - "full_name": user.full_name, - "avatar_url": user.avatar_url, - "level": user_level, - "total_exp": user_exp, - "badge_count": badge_count, - }) - - total_members = len(members) - - # 获取团队总奖章数 - result = await self.db.execute( - select(func.count(UserBadge.id)) - .where(UserBadge.user_id.in_(member_ids)) - ) - total_badges = result.scalar() or 0 - - return { - "members": members, - "overview": { - "total_members": total_members, - "avg_level": round(total_level / total_members, 1) if total_members > 0 else 0, - "avg_exp": round(total_exp / total_members) if total_members > 0 else 0, - "total_badges": total_badges, - }, - "positions": [{"id": p.id, "name": p.name} for p in positions] - } - - async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]: - """ - 获取课程热度排行 - - Args: - limit: 数量限制 - - Returns: - 课程排行列表 - """ - # 这里简化实现,实际应该统计课程学习次数 - result = await self.db.execute( - select(Course) - .where(Course.is_deleted == False, Course.is_published == True) - .order_by(Course.created_at.desc()) - .limit(limit) - ) - courses = result.scalars().all() - - ranking = [] - for i, course in enumerate(courses, 1): - ranking.append({ - "rank": i, - "id": course.id, - "name": course.name, - "description": course.description, - # 这里可以添加实际的学习人数统计 - "learners": 0, - }) - - return ranking +""" +数据大屏服务 + +提供企业级和团队级数据大屏功能: +- 学习数据概览 +- 部门/团队对比 +- 趋势分析 +- 实时动态 +""" + +from datetime import datetime, timedelta, date +from typing import Optional, List, Dict, Any +from sqlalchemy import select, func, and_, or_, desc, case +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.user import User +from app.models.course import Course, CourseMaterial +from app.models.exam import Exam +from app.models.practice import PracticeSession +from app.models.training import TrainingSession, TrainingReport +from app.models.level import UserLevel, ExpHistory, UserBadge +from app.models.position import Position +from app.models.position_member import PositionMember + +logger = get_logger(__name__) + + +class DashboardService: + """数据大屏服务""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_enterprise_overview(self, enterprise_id: Optional[int] = None) -> Dict[str, Any]: + """ + 获取企业级数据概览 + + Args: + enterprise_id: 企业ID(可选,用于多租户) + + Returns: + 企业级数据概览 + """ + today = date.today() + week_ago = today - timedelta(days=7) + month_ago = today - timedelta(days=30) + + # 基础统计 + # 1. 总学员数 + result = await self.db.execute( + select(func.count(User.id)) + .where(User.is_deleted == False, User.role == 'trainee') + ) + total_users = result.scalar() or 0 + + # 2. 今日活跃用户(有经验值记录) + result = await self.db.execute( + select(func.count(func.distinct(ExpHistory.user_id))) + .where(func.date(ExpHistory.created_at) == today) + ) + today_active = result.scalar() or 0 + + # 3. 本周活跃用户 + result = await self.db.execute( + select(func.count(func.distinct(ExpHistory.user_id))) + .where(ExpHistory.created_at >= datetime.combine(week_ago, datetime.min.time())) + ) + week_active = result.scalar() or 0 + + # 4. 本月活跃用户 + result = await self.db.execute( + select(func.count(func.distinct(ExpHistory.user_id))) + .where(ExpHistory.created_at >= datetime.combine(month_ago, datetime.min.time())) + ) + month_active = result.scalar() or 0 + + # 5. 总学习时长(小时) + result = await self.db.execute( + select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) + .where(PracticeSession.status == 'completed') + ) + practice_hours = (result.scalar() or 0) / 3600 + + result = await self.db.execute( + select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)) + .where(TrainingSession.status == 'COMPLETED') + ) + training_hours = (result.scalar() or 0) / 3600 + + total_hours = round(practice_hours + training_hours, 1) + + # 6. 考试统计 + result = await self.db.execute( + select( + func.count(Exam.id), + func.count(case((Exam.is_passed == True, 1))), + func.avg(Exam.score) + ) + .where(Exam.status == 'submitted') + ) + exam_row = result.first() + exam_count = exam_row[0] or 0 + exam_passed = exam_row[1] or 0 + exam_avg_score = round(exam_row[2] or 0, 1) + exam_pass_rate = round(exam_passed / exam_count * 100, 1) if exam_count > 0 else 0 + + # 7. 满分人数 + result = await self.db.execute( + select(func.count(func.distinct(Exam.user_id))) + .where(Exam.status == 'submitted', Exam.score >= Exam.total_score) + ) + perfect_users = result.scalar() or 0 + + # 8. 签到率(今日签到人数/总用户数) + result = await self.db.execute( + select(func.count(UserLevel.id)) + .where(func.date(UserLevel.last_login_date) == today) + ) + today_checkin = result.scalar() or 0 + checkin_rate = round(today_checkin / total_users * 100, 1) if total_users > 0 else 0 + + return { + "overview": { + "total_users": total_users, + "today_active": today_active, + "week_active": week_active, + "month_active": month_active, + "total_hours": total_hours, + "checkin_rate": checkin_rate, + }, + "exam": { + "total_count": exam_count, + "pass_rate": exam_pass_rate, + "avg_score": exam_avg_score, + "perfect_users": perfect_users, + }, + "updated_at": datetime.now().isoformat() + } + + async def get_department_comparison(self) -> List[Dict[str, Any]]: + """ + 获取部门/团队学习对比数据 + + Returns: + 部门对比列表 + """ + # 获取所有岗位及其成员的学习数据 + result = await self.db.execute( + select(Position) + .where(Position.is_deleted == False) + .order_by(Position.name) + ) + positions = result.scalars().all() + + departments = [] + for pos in positions: + # 获取该岗位的成员数 + result = await self.db.execute( + select(func.count(PositionMember.id)) + .where(PositionMember.position_id == pos.id) + ) + member_count = result.scalar() or 0 + + if member_count == 0: + continue + + # 获取成员ID列表 + result = await self.db.execute( + select(PositionMember.user_id) + .where(PositionMember.position_id == pos.id) + ) + member_ids = [row[0] for row in result.all()] + + # 统计该岗位成员的学习数据 + # 考试通过率 + result = await self.db.execute( + select( + func.count(Exam.id), + func.count(case((Exam.is_passed == True, 1))) + ) + .where( + Exam.user_id.in_(member_ids), + Exam.status == 'submitted' + ) + ) + exam_row = result.first() + exam_total = exam_row[0] or 0 + exam_passed = exam_row[1] or 0 + pass_rate = round(exam_passed / exam_total * 100, 1) if exam_total > 0 else 0 + + # 平均学习时长 + result = await self.db.execute( + select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) + .where( + PracticeSession.user_id.in_(member_ids), + PracticeSession.status == 'completed' + ) + ) + total_seconds = result.scalar() or 0 + avg_hours = round(total_seconds / 3600 / member_count, 1) if member_count > 0 else 0 + + # 平均等级 + result = await self.db.execute( + select(func.avg(UserLevel.level)) + .where(UserLevel.user_id.in_(member_ids)) + ) + avg_level = round(result.scalar() or 1, 1) + + departments.append({ + "id": pos.id, + "name": pos.name, + "member_count": member_count, + "pass_rate": pass_rate, + "avg_hours": avg_hours, + "avg_level": avg_level, + }) + + # 按通过率排序 + departments.sort(key=lambda x: x["pass_rate"], reverse=True) + + return departments + + async def get_learning_trend(self, days: int = 7) -> Dict[str, Any]: + """ + 获取学习趋势数据 + + Args: + days: 统计天数 + + Returns: + 趋势数据 + """ + today = date.today() + dates = [(today - timedelta(days=i)) for i in range(days-1, -1, -1)] + + trend_data = [] + for d in dates: + # 当日活跃用户 + result = await self.db.execute( + select(func.count(func.distinct(ExpHistory.user_id))) + .where(func.date(ExpHistory.created_at) == d) + ) + active_users = result.scalar() or 0 + + # 当日新增学习时长 + result = await self.db.execute( + select(func.coalesce(func.sum(PracticeSession.duration_seconds), 0)) + .where( + func.date(PracticeSession.created_at) == d, + PracticeSession.status == 'completed' + ) + ) + hours = round((result.scalar() or 0) / 3600, 1) + + # 当日考试次数 + result = await self.db.execute( + select(func.count(Exam.id)) + .where( + func.date(Exam.created_at) == d, + Exam.status == 'submitted' + ) + ) + exams = result.scalar() or 0 + + trend_data.append({ + "date": d.isoformat(), + "active_users": active_users, + "learning_hours": hours, + "exam_count": exams, + }) + + return { + "dates": [d.isoformat() for d in dates], + "trend": trend_data + } + + async def get_level_distribution(self) -> Dict[str, Any]: + """ + 获取等级分布数据 + + Returns: + 等级分布 + """ + result = await self.db.execute( + select(UserLevel.level, func.count(UserLevel.id)) + .group_by(UserLevel.level) + .order_by(UserLevel.level) + ) + rows = result.all() + + distribution = {row[0]: row[1] for row in rows} + + # 补全1-10级 + for i in range(1, 11): + if i not in distribution: + distribution[i] = 0 + + return { + "levels": list(range(1, 11)), + "counts": [distribution.get(i, 0) for i in range(1, 11)] + } + + async def get_realtime_activities(self, limit: int = 20) -> List[Dict[str, Any]]: + """ + 获取实时动态 + + Args: + limit: 数量限制 + + Returns: + 实时动态列表 + """ + activities = [] + + # 获取最近的经验值记录 + result = await self.db.execute( + select(ExpHistory, User) + .join(User, ExpHistory.user_id == User.id) + .order_by(ExpHistory.created_at.desc()) + .limit(limit) + ) + rows = result.all() + + for exp, user in rows: + activity_type = "学习" + if "考试" in (exp.description or ""): + activity_type = "考试" + elif "签到" in (exp.description or ""): + activity_type = "签到" + elif "陪练" in (exp.description or ""): + activity_type = "陪练" + elif "奖章" in (exp.description or ""): + activity_type = "奖章" + + activities.append({ + "id": exp.id, + "user_id": user.id, + "user_name": user.full_name or user.username, + "type": activity_type, + "description": exp.description, + "exp_amount": exp.exp_amount, + "created_at": exp.created_at.isoformat() if exp.created_at else None, + }) + + return activities + + async def get_team_dashboard(self, team_leader_id: int) -> Dict[str, Any]: + """ + 获取团队级数据大屏 + + Args: + team_leader_id: 团队负责人ID + + Returns: + 团队数据 + """ + # 获取团队负责人管理的岗位 + result = await self.db.execute( + select(Position) + .where( + Position.is_deleted == False, + or_( + Position.manager_id == team_leader_id, + Position.created_by == team_leader_id + ) + ) + ) + positions = result.scalars().all() + position_ids = [p.id for p in positions] + + if not position_ids: + return { + "members": [], + "overview": { + "total_members": 0, + "avg_level": 0, + "avg_exp": 0, + "total_badges": 0, + }, + "pending_tasks": [] + } + + # 获取团队成员 + result = await self.db.execute( + select(PositionMember.user_id) + .where(PositionMember.position_id.in_(position_ids)) + ) + member_ids = [row[0] for row in result.all()] + + if not member_ids: + return { + "members": [], + "overview": { + "total_members": 0, + "avg_level": 0, + "avg_exp": 0, + "total_badges": 0, + }, + "pending_tasks": [] + } + + # 获取成员详细信息 + result = await self.db.execute( + select(User, UserLevel) + .outerjoin(UserLevel, User.id == UserLevel.user_id) + .where(User.id.in_(member_ids)) + .order_by(UserLevel.total_exp.desc().nullslast()) + ) + rows = result.all() + + members = [] + total_exp = 0 + total_level = 0 + + for user, level in rows: + user_level = level.level if level else 1 + user_exp = level.total_exp if level else 0 + total_level += user_level + total_exp += user_exp + + # 获取用户奖章数 + result = await self.db.execute( + select(func.count(UserBadge.id)) + .where(UserBadge.user_id == user.id) + ) + badge_count = result.scalar() or 0 + + members.append({ + "id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "level": user_level, + "total_exp": user_exp, + "badge_count": badge_count, + }) + + total_members = len(members) + + # 获取团队总奖章数 + result = await self.db.execute( + select(func.count(UserBadge.id)) + .where(UserBadge.user_id.in_(member_ids)) + ) + total_badges = result.scalar() or 0 + + return { + "members": members, + "overview": { + "total_members": total_members, + "avg_level": round(total_level / total_members, 1) if total_members > 0 else 0, + "avg_exp": round(total_exp / total_members) if total_members > 0 else 0, + "total_badges": total_badges, + }, + "positions": [{"id": p.id, "name": p.name} for p in positions] + } + + async def get_course_ranking(self, limit: int = 10) -> List[Dict[str, Any]]: + """ + 获取课程热度排行 + + Args: + limit: 数量限制 + + Returns: + 课程排行列表 + """ + # 这里简化实现,实际应该统计课程学习次数 + result = await self.db.execute( + select(Course) + .where(Course.is_deleted == False, Course.is_published == True) + .order_by(Course.created_at.desc()) + .limit(limit) + ) + courses = result.scalars().all() + + ranking = [] + for i, course in enumerate(courses, 1): + ranking.append({ + "rank": i, + "id": course.id, + "name": course.name, + "description": course.description, + # 这里可以添加实际的学习人数统计 + "learners": 0, + }) + + return ranking diff --git a/backend/app/services/dingtalk_auth_service.py b/backend/app/services/dingtalk_auth_service.py index 574fc81..7ad8d4b 100644 --- a/backend/app/services/dingtalk_auth_service.py +++ b/backend/app/services/dingtalk_auth_service.py @@ -1,302 +1,302 @@ -""" -钉钉认证服务 - -提供钉钉免密登录功能,从数据库读取配置 -""" - -import json -import time -from typing import Optional, Dict, Any, Tuple - -import httpx -from sqlalchemy import text -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.logger import get_logger -from app.core.security import create_access_token, create_refresh_token -from app.models.user import User -from app.schemas.auth import Token -from app.services.user_service import UserService - -logger = get_logger(__name__) - -# 钉钉API地址 -DINGTALK_API_BASE = "https://oapi.dingtalk.com" - - -class DingtalkAuthService: - """钉钉认证服务""" - - def __init__(self, db: AsyncSession): - self.db = db - self.user_service = UserService(db) - self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time) - - async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]: - """ - 从数据库获取钉钉配置 - - Args: - tenant_id: 租户ID - - Returns: - 配置字典 {app_key, app_secret, agent_id, corp_id} - """ - result = await self.db.execute( - text(""" - SELECT config_key, config_value - FROM tenant_configs - WHERE tenant_id = :tenant_id AND config_group = 'dingtalk' - """), - {"tenant_id": tenant_id} - ) - rows = result.fetchall() - - config = {} - key_mapping = { - "DINGTALK_APP_KEY": "app_key", - "DINGTALK_APP_SECRET": "app_secret", - "DINGTALK_AGENT_ID": "agent_id", - "DINGTALK_CORP_ID": "corp_id", - } - - for row in rows: - if row[0] in key_mapping: - config[key_mapping[row[0]]] = row[1] - - return config - - async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool: - """ - 检查钉钉免密登录功能是否启用 - - Args: - tenant_id: 租户ID - - Returns: - 是否启用 - """ - # 先查租户级别的配置 - result = await self.db.execute( - text(""" - SELECT is_enabled FROM feature_switches - WHERE feature_code = 'dingtalk_login' - AND (tenant_id = :tenant_id OR tenant_id IS NULL) - ORDER BY tenant_id DESC - LIMIT 1 - """), - {"tenant_id": tenant_id} - ) - row = result.fetchone() - - if row: - return bool(row[0]) - - return False - - async def get_access_token(self, tenant_id: int) -> str: - """ - 获取钉钉访问令牌(带内存缓存) - - Args: - tenant_id: 租户ID - - Returns: - access_token - - Raises: - Exception: 获取失败时抛出异常 - """ - # 检查缓存 - if tenant_id in self._access_token_cache: - token, expire_time = self._access_token_cache[tenant_id] - if time.time() < expire_time - 300: # 提前5分钟刷新 - return token - - # 获取配置 - config = await self.get_dingtalk_config(tenant_id) - if not config.get("app_key") or not config.get("app_secret"): - raise ValueError("钉钉配置不完整,请在管理后台配置AppKey和AppSecret") - - # 调用钉钉API获取token - url = f"{DINGTALK_API_BASE}/gettoken" - params = { - "appkey": config["app_key"], - "appsecret": config["app_secret"], - } - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.get(url, params=params) - data = response.json() - - if data.get("errcode") != 0: - error_msg = data.get("errmsg", "未知错误") - logger.error(f"获取钉钉access_token失败: {error_msg}") - raise Exception(f"获取钉钉access_token失败: {error_msg}") - - access_token = data["access_token"] - expires_in = data.get("expires_in", 7200) - - # 缓存token - self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in) - - logger.info(f"获取钉钉access_token成功,有效期: {expires_in}秒") - return access_token - - async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]: - """ - 通过免登码获取钉钉用户信息 - - Args: - tenant_id: 租户ID - code: 免登授权码 - - Returns: - 用户信息 {userid, name, ...} - - Raises: - Exception: 获取失败时抛出异常 - """ - access_token = await self.get_access_token(tenant_id) - - url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo" - params = {"access_token": access_token} - payload = {"code": code} - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post(url, params=params, json=payload) - data = response.json() - - if data.get("errcode") != 0: - error_msg = data.get("errmsg", "未知错误") - logger.error(f"通过code获取钉钉用户信息失败: {error_msg}") - raise Exception(f"获取钉钉用户信息失败: {error_msg}") - - result = data.get("result", {}) - logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}") - - return result - - async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]: - """ - 获取钉钉用户详细信息 - - Args: - tenant_id: 租户ID - userid: 钉钉用户ID - - Returns: - 用户详细信息 - """ - access_token = await self.get_access_token(tenant_id) - - url = f"{DINGTALK_API_BASE}/topapi/v2/user/get" - params = {"access_token": access_token} - payload = {"userid": userid} - - async with httpx.AsyncClient(timeout=30.0) as client: - response = await client.post(url, params=params, json=payload) - data = response.json() - - if data.get("errcode") != 0: - error_msg = data.get("errmsg", "未知错误") - logger.warning(f"获取钉钉用户详情失败: {error_msg}") - return {} - - return data.get("result", {}) - - async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]: - """ - 钉钉免密登录主流程 - - Args: - tenant_id: 租户ID - code: 免登授权码 - - Returns: - (用户对象, Token对象) - - Raises: - Exception: 登录失败时抛出异常 - """ - # 1. 检查功能是否启用 - if not await self.is_dingtalk_login_enabled(tenant_id): - raise Exception("钉钉免密登录功能未启用") - - # 2. 通过code获取钉钉用户信息 - dingtalk_user = await self.get_user_info_by_code(tenant_id, code) - dingtalk_userid = dingtalk_user.get("userid") - - if not dingtalk_userid: - raise Exception("无法获取钉钉用户ID") - - # 3. 根据dingtalk_id查找系统用户 - logger.info(f"开始查找用户,钉钉userid: {dingtalk_userid}") - user = await self.user_service.get_by_dingtalk_id(dingtalk_userid) - - if not user: - logger.info(f"通过dingtalk_id未找到用户,尝试手机号匹配") - # 尝试通过手机号匹配 - user_detail = await self.get_user_detail(tenant_id, dingtalk_userid) - mobile = user_detail.get("mobile") - logger.info(f"获取到钉钉用户手机号: {mobile}") - - if mobile: - user = await self.user_service.get_by_phone(mobile) - if user: - # 绑定dingtalk_id - user.dingtalk_id = dingtalk_userid - await self.db.commit() - logger.info(f"通过手机号匹配成功,已绑定dingtalk_id: {dingtalk_userid}") - else: - logger.warning(f"通过手机号 {mobile} 也未找到用户") - else: - logger.warning("无法获取钉钉用户手机号") - - if not user: - logger.error(f"钉钉登录失败:dingtalk_userid={dingtalk_userid}, 未找到对应用户") - raise Exception("未找到对应的系统用户,请联系管理员") - - if not user.is_active: - raise Exception("用户已被禁用") - - # 4. 生成JWT Token - access_token = create_access_token(subject=user.id) - refresh_token = create_refresh_token(subject=user.id) - - # 5. 更新最后登录时间 - await self.user_service.update_last_login(user.id) - - logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}") - - return user, Token( - access_token=access_token, - refresh_token=refresh_token, - ) - - async def get_public_config(self, tenant_id: int) -> Dict[str, Any]: - """ - 获取钉钉公开配置(前端需要用于初始化JSDK) - - Args: - tenant_id: 租户ID - - Returns: - {corp_id, agent_id, enabled} - """ - enabled = await self.is_dingtalk_login_enabled(tenant_id) - - if not enabled: - return { - "enabled": False, - "corp_id": None, - "agent_id": None, - } - - config = await self.get_dingtalk_config(tenant_id) - - return { - "enabled": True, - "corp_id": config.get("corp_id"), - "agent_id": config.get("agent_id"), - } +""" +钉钉认证服务 + +提供钉钉免密登录功能,从数据库读取配置 +""" + +import json +import time +from typing import Optional, Dict, Any, Tuple + +import httpx +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.core.security import create_access_token, create_refresh_token +from app.models.user import User +from app.schemas.auth import Token +from app.services.user_service import UserService + +logger = get_logger(__name__) + +# 钉钉API地址 +DINGTALK_API_BASE = "https://oapi.dingtalk.com" + + +class DingtalkAuthService: + """钉钉认证服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self.user_service = UserService(db) + self._access_token_cache: Dict[int, Tuple[str, float]] = {} # tenant_id -> (token, expire_time) + + async def get_dingtalk_config(self, tenant_id: int) -> Dict[str, str]: + """ + 从数据库获取钉钉配置 + + Args: + tenant_id: 租户ID + + Returns: + 配置字典 {app_key, app_secret, agent_id, corp_id} + """ + result = await self.db.execute( + text(""" + SELECT config_key, config_value + FROM tenant_configs + WHERE tenant_id = :tenant_id AND config_group = 'dingtalk' + """), + {"tenant_id": tenant_id} + ) + rows = result.fetchall() + + config = {} + key_mapping = { + "DINGTALK_APP_KEY": "app_key", + "DINGTALK_APP_SECRET": "app_secret", + "DINGTALK_AGENT_ID": "agent_id", + "DINGTALK_CORP_ID": "corp_id", + } + + for row in rows: + if row[0] in key_mapping: + config[key_mapping[row[0]]] = row[1] + + return config + + async def is_dingtalk_login_enabled(self, tenant_id: int) -> bool: + """ + 检查钉钉免密登录功能是否启用 + + Args: + tenant_id: 租户ID + + Returns: + 是否启用 + """ + # 先查租户级别的配置 + result = await self.db.execute( + text(""" + SELECT is_enabled FROM feature_switches + WHERE feature_code = 'dingtalk_login' + AND (tenant_id = :tenant_id OR tenant_id IS NULL) + ORDER BY tenant_id DESC + LIMIT 1 + """), + {"tenant_id": tenant_id} + ) + row = result.fetchone() + + if row: + return bool(row[0]) + + return False + + async def get_access_token(self, tenant_id: int) -> str: + """ + 获取钉钉访问令牌(带内存缓存) + + Args: + tenant_id: 租户ID + + Returns: + access_token + + Raises: + Exception: 获取失败时抛出异常 + """ + # 检查缓存 + if tenant_id in self._access_token_cache: + token, expire_time = self._access_token_cache[tenant_id] + if time.time() < expire_time - 300: # 提前5分钟刷新 + return token + + # 获取配置 + config = await self.get_dingtalk_config(tenant_id) + if not config.get("app_key") or not config.get("app_secret"): + raise ValueError("钉钉配置不完整,请在管理后台配置AppKey和AppSecret") + + # 调用钉钉API获取token + url = f"{DINGTALK_API_BASE}/gettoken" + params = { + "appkey": config["app_key"], + "appsecret": config["app_secret"], + } + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.get(url, params=params) + data = response.json() + + if data.get("errcode") != 0: + error_msg = data.get("errmsg", "未知错误") + logger.error(f"获取钉钉access_token失败: {error_msg}") + raise Exception(f"获取钉钉access_token失败: {error_msg}") + + access_token = data["access_token"] + expires_in = data.get("expires_in", 7200) + + # 缓存token + self._access_token_cache[tenant_id] = (access_token, time.time() + expires_in) + + logger.info(f"获取钉钉access_token成功,有效期: {expires_in}秒") + return access_token + + async def get_user_info_by_code(self, tenant_id: int, code: str) -> Dict[str, Any]: + """ + 通过免登码获取钉钉用户信息 + + Args: + tenant_id: 租户ID + code: 免登授权码 + + Returns: + 用户信息 {userid, name, ...} + + Raises: + Exception: 获取失败时抛出异常 + """ + access_token = await self.get_access_token(tenant_id) + + url = f"{DINGTALK_API_BASE}/topapi/v2/user/getuserinfo" + params = {"access_token": access_token} + payload = {"code": code} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, params=params, json=payload) + data = response.json() + + if data.get("errcode") != 0: + error_msg = data.get("errmsg", "未知错误") + logger.error(f"通过code获取钉钉用户信息失败: {error_msg}") + raise Exception(f"获取钉钉用户信息失败: {error_msg}") + + result = data.get("result", {}) + logger.info(f"获取钉钉用户信息成功: userid={result.get('userid')}, name={result.get('name')}") + + return result + + async def get_user_detail(self, tenant_id: int, userid: str) -> Dict[str, Any]: + """ + 获取钉钉用户详细信息 + + Args: + tenant_id: 租户ID + userid: 钉钉用户ID + + Returns: + 用户详细信息 + """ + access_token = await self.get_access_token(tenant_id) + + url = f"{DINGTALK_API_BASE}/topapi/v2/user/get" + params = {"access_token": access_token} + payload = {"userid": userid} + + async with httpx.AsyncClient(timeout=30.0) as client: + response = await client.post(url, params=params, json=payload) + data = response.json() + + if data.get("errcode") != 0: + error_msg = data.get("errmsg", "未知错误") + logger.warning(f"获取钉钉用户详情失败: {error_msg}") + return {} + + return data.get("result", {}) + + async def dingtalk_login(self, tenant_id: int, code: str) -> Tuple[User, Token]: + """ + 钉钉免密登录主流程 + + Args: + tenant_id: 租户ID + code: 免登授权码 + + Returns: + (用户对象, Token对象) + + Raises: + Exception: 登录失败时抛出异常 + """ + # 1. 检查功能是否启用 + if not await self.is_dingtalk_login_enabled(tenant_id): + raise Exception("钉钉免密登录功能未启用") + + # 2. 通过code获取钉钉用户信息 + dingtalk_user = await self.get_user_info_by_code(tenant_id, code) + dingtalk_userid = dingtalk_user.get("userid") + + if not dingtalk_userid: + raise Exception("无法获取钉钉用户ID") + + # 3. 根据dingtalk_id查找系统用户 + logger.info(f"开始查找用户,钉钉userid: {dingtalk_userid}") + user = await self.user_service.get_by_dingtalk_id(dingtalk_userid) + + if not user: + logger.info(f"通过dingtalk_id未找到用户,尝试手机号匹配") + # 尝试通过手机号匹配 + user_detail = await self.get_user_detail(tenant_id, dingtalk_userid) + mobile = user_detail.get("mobile") + logger.info(f"获取到钉钉用户手机号: {mobile}") + + if mobile: + user = await self.user_service.get_by_phone(mobile) + if user: + # 绑定dingtalk_id + user.dingtalk_id = dingtalk_userid + await self.db.commit() + logger.info(f"通过手机号匹配成功,已绑定dingtalk_id: {dingtalk_userid}") + else: + logger.warning(f"通过手机号 {mobile} 也未找到用户") + else: + logger.warning("无法获取钉钉用户手机号") + + if not user: + logger.error(f"钉钉登录失败:dingtalk_userid={dingtalk_userid}, 未找到对应用户") + raise Exception("未找到对应的系统用户,请联系管理员") + + if not user.is_active: + raise Exception("用户已被禁用") + + # 4. 生成JWT Token + access_token = create_access_token(subject=user.id) + refresh_token = create_refresh_token(subject=user.id) + + # 5. 更新最后登录时间 + await self.user_service.update_last_login(user.id) + + logger.info(f"钉钉免密登录成功: user_id={user.id}, username={user.username}") + + return user, Token( + access_token=access_token, + refresh_token=refresh_token, + ) + + async def get_public_config(self, tenant_id: int) -> Dict[str, Any]: + """ + 获取钉钉公开配置(前端需要用于初始化JSDK) + + Args: + tenant_id: 租户ID + + Returns: + {corp_id, agent_id, enabled} + """ + enabled = await self.is_dingtalk_login_enabled(tenant_id) + + if not enabled: + return { + "enabled": False, + "corp_id": None, + "agent_id": None, + } + + config = await self.get_dingtalk_config(tenant_id) + + return { + "enabled": True, + "corp_id": config.get("corp_id"), + "agent_id": config.get("agent_id"), + } diff --git a/backend/app/services/level_service.py b/backend/app/services/level_service.py index e446880..0bc56ce 100644 --- a/backend/app/services/level_service.py +++ b/backend/app/services/level_service.py @@ -1,588 +1,588 @@ -""" -等级服务 - -提供用户等级管理功能: -- 经验值获取与计算 -- 等级升级判断 -- 每日签到 -- 排行榜查询 -""" - -from datetime import datetime, date, timedelta -from typing import Optional, List, Dict, Any, Tuple -from sqlalchemy import select, func, and_, desc -from sqlalchemy.ext.asyncio import AsyncSession - -from app.core.logger import get_logger -from app.models.level import ( - UserLevel, ExpHistory, LevelConfig, ExpType -) -from app.models.user import User - -logger = get_logger(__name__) - - -# 经验值配置 -EXP_CONFIG = { - # 每日签到 - "login_base": 10, # 基础签到经验 - "login_streak_7": 20, # 连续7天额外奖励 - "login_streak_30": 50, # 连续30天额外奖励 - - # 考试 - "exam_pass": 50, # 通过考试 - "exam_excellent": 30, # 90分以上额外 - "exam_perfect": 50, # 满分额外 - - # 练习 - "practice_complete": 20, # 完成练习 - "practice_good": 10, # 80分以上额外 - - # 陪练 - "training_complete": 30, # 完成陪练 - "training_good": 15, # 80分以上额外 - - # 任务 - "task_complete": 40, # 完成任务 -} - - -class LevelService: - """等级服务""" - - def __init__(self, db: AsyncSession): - self.db = db - self._level_configs: Optional[List[LevelConfig]] = None - - async def _get_level_configs(self) -> List[LevelConfig]: - """获取等级配置(带缓存)""" - if self._level_configs is None: - result = await self.db.execute( - select(LevelConfig).order_by(LevelConfig.level) - ) - self._level_configs = list(result.scalars().all()) - return self._level_configs - - async def get_or_create_user_level(self, user_id: int) -> UserLevel: - """ - 获取或创建用户等级记录 - - Args: - user_id: 用户ID - - Returns: - UserLevel 对象 - """ - result = await self.db.execute( - select(UserLevel).where(UserLevel.user_id == user_id) - ) - user_level = result.scalar_one_or_none() - - if not user_level: - user_level = UserLevel( - user_id=user_id, - level=1, - exp=0, - total_exp=0, - login_streak=0, - max_login_streak=0 - ) - self.db.add(user_level) - await self.db.flush() - logger.info(f"为用户 {user_id} 创建等级记录") - - return user_level - - async def get_user_level_info(self, user_id: int) -> Dict[str, Any]: - """ - 获取用户等级详细信息 - - Args: - user_id: 用户ID - - Returns: - 包含等级、经验值、称号等信息的字典 - """ - user_level = await self.get_or_create_user_level(user_id) - level_configs = await self._get_level_configs() - - # 获取当前等级配置 - current_config = next( - (c for c in level_configs if c.level == user_level.level), - level_configs[0] if level_configs else None - ) - - # 获取下一等级配置 - next_config = next( - (c for c in level_configs if c.level == user_level.level + 1), - None - ) - - # 计算升级所需经验 - exp_to_next_level = 0 - next_level_total_exp = 0 - if next_config: - next_level_total_exp = next_config.total_exp_required - exp_to_next_level = next_level_total_exp - user_level.total_exp - if exp_to_next_level < 0: - exp_to_next_level = 0 - - return { - "user_id": user_id, - "level": user_level.level, - "exp": user_level.exp, - "total_exp": user_level.total_exp, - "title": current_config.title if current_config else "初学者", - "color": current_config.color if current_config else "#909399", - "login_streak": user_level.login_streak, - "max_login_streak": user_level.max_login_streak, - "last_checkin_at": user_level.last_checkin_at.isoformat() if user_level.last_checkin_at else None, - "next_level_exp": next_level_total_exp, - "exp_to_next_level": exp_to_next_level, - "is_max_level": next_config is None, - } - - async def add_exp( - self, - user_id: int, - exp_amount: int, - exp_type: str, - description: str, - source_id: Optional[int] = None - ) -> Tuple[UserLevel, bool, Optional[int]]: - """ - 增加用户经验值 - - Args: - user_id: 用户ID - exp_amount: 经验值数量 - exp_type: 经验值类型 - description: 描述 - source_id: 来源ID(可选) - - Returns: - (用户等级对象, 是否升级, 新等级) - """ - if exp_amount <= 0: - logger.warning(f"尝试增加非正数经验值: {exp_amount}") - return await self.get_or_create_user_level(user_id), False, None - - user_level = await self.get_or_create_user_level(user_id) - level_before = user_level.level - - # 增加经验值 - user_level.exp += exp_amount - user_level.total_exp += exp_amount - - # 检查是否升级 - level_configs = await self._get_level_configs() - leveled_up = False - new_level = None - - for config in level_configs: - if config.level > user_level.level and user_level.total_exp >= config.total_exp_required: - user_level.level = config.level - leveled_up = True - new_level = config.level - - # 记录经验值历史 - exp_history = ExpHistory( - user_id=user_id, - exp_change=exp_amount, - exp_type=exp_type, - source_id=source_id, - description=description, - level_before=level_before, - level_after=user_level.level - ) - self.db.add(exp_history) - - await self.db.flush() - - if leveled_up: - logger.info(f"用户 {user_id} 升级: {level_before} -> {new_level}") - - logger.info(f"用户 {user_id} 获得 {exp_amount} 经验值: {description}") - - return user_level, leveled_up, new_level - - async def daily_checkin(self, user_id: int) -> Dict[str, Any]: - """ - 每日签到 - - Args: - user_id: 用户ID - - Returns: - 签到结果 - """ - user_level = await self.get_or_create_user_level(user_id) - today = date.today() - - # 检查今天是否已签到 - if user_level.last_login_date == today: - return { - "success": False, - "message": "今天已经签到过了", - "exp_gained": 0, - "login_streak": user_level.login_streak, - "already_checked_in": True - } - - # 计算连续登录 - yesterday = today - timedelta(days=1) - if user_level.last_login_date == yesterday: - # 连续登录 - user_level.login_streak += 1 - else: - # 中断了,重新计算 - user_level.login_streak = 1 - - # 更新最长连续登录记录 - if user_level.login_streak > user_level.max_login_streak: - user_level.max_login_streak = user_level.login_streak - - user_level.last_login_date = today - user_level.last_checkin_at = datetime.now() - - # 计算签到经验 - exp_gained = EXP_CONFIG["login_base"] - bonus_exp = 0 - bonus_reason = [] - - # 连续登录奖励 - if user_level.login_streak >= 30 and user_level.login_streak % 30 == 0: - bonus_exp += EXP_CONFIG["login_streak_30"] - bonus_reason.append(f"连续{user_level.login_streak}天") - elif user_level.login_streak >= 7 and user_level.login_streak % 7 == 0: - bonus_exp += EXP_CONFIG["login_streak_7"] - bonus_reason.append(f"连续{user_level.login_streak}天") - - total_exp = exp_gained + bonus_exp - - # 添加经验值 - description = f"每日签到" - if bonus_reason: - description += f"({', '.join(bonus_reason)}奖励)" - - _, leveled_up, new_level = await self.add_exp( - user_id=user_id, - exp_amount=total_exp, - exp_type=ExpType.LOGIN, - description=description - ) - - await self.db.commit() - - return { - "success": True, - "message": "签到成功", - "exp_gained": total_exp, - "base_exp": exp_gained, - "bonus_exp": bonus_exp, - "login_streak": user_level.login_streak, - "leveled_up": leveled_up, - "new_level": new_level, - "already_checked_in": False - } - - async def get_exp_history( - self, - user_id: int, - limit: int = 50, - offset: int = 0, - exp_type: Optional[str] = None - ) -> Tuple[List[Dict[str, Any]], int]: - """ - 获取经验值历史 - - Args: - user_id: 用户ID - limit: 限制数量 - offset: 偏移量 - exp_type: 类型筛选 - - Returns: - (历史记录列表, 总数) - """ - # 构建查询 - query = select(ExpHistory).where(ExpHistory.user_id == user_id) - - if exp_type: - query = query.where(ExpHistory.exp_type == exp_type) - - # 获取总数 - count_query = select(func.count(ExpHistory.id)).where(ExpHistory.user_id == user_id) - if exp_type: - count_query = count_query.where(ExpHistory.exp_type == exp_type) - total_result = await self.db.execute(count_query) - total = total_result.scalar() or 0 - - # 获取记录 - query = query.order_by(desc(ExpHistory.created_at)).limit(limit).offset(offset) - result = await self.db.execute(query) - records = result.scalars().all() - - history = [ - { - "id": r.id, - "exp_change": r.exp_change, - "exp_type": r.exp_type, - "description": r.description, - "level_before": r.level_before, - "level_after": r.level_after, - "created_at": r.created_at.isoformat() if r.created_at else None - } - for r in records - ] - - return history, total - - async def get_leaderboard( - self, - limit: int = 50, - offset: int = 0 - ) -> Tuple[List[Dict[str, Any]], int]: - """ - 获取等级排行榜 - - Args: - limit: 限制数量 - offset: 偏移量 - - Returns: - (排行榜列表, 总数) - """ - # 获取总数 - count_query = select(func.count(UserLevel.id)) - total_result = await self.db.execute(count_query) - total = total_result.scalar() or 0 - - # 获取排行榜(按等级和总经验值排序) - query = ( - select(UserLevel, User) - .join(User, UserLevel.user_id == User.id) - .where(User.is_deleted == False) - .order_by(desc(UserLevel.level), desc(UserLevel.total_exp)) - .limit(limit) - .offset(offset) - ) - result = await self.db.execute(query) - rows = result.all() - - # 获取等级配置 - level_configs = await self._get_level_configs() - config_map = {c.level: c for c in level_configs} - - leaderboard = [] - for i, (user_level, user) in enumerate(rows): - config = config_map.get(user_level.level) - leaderboard.append({ - "rank": offset + i + 1, - "user_id": user.id, - "username": user.username, - "full_name": user.full_name, - "avatar_url": user.avatar_url, - "level": user_level.level, - "title": config.title if config else "初学者", - "color": config.color if config else "#909399", - "total_exp": user_level.total_exp, - "login_streak": user_level.login_streak, - }) - - return leaderboard, total - - async def get_user_rank(self, user_id: int) -> Optional[int]: - """ - 获取用户排名 - - Args: - user_id: 用户ID - - Returns: - 排名(从1开始) - """ - user_level = await self.get_or_create_user_level(user_id) - - # 计算排在该用户前面的人数 - query = select(func.count(UserLevel.id)).where( - and_( - UserLevel.user_id != user_id, - ( - (UserLevel.level > user_level.level) | - ( - (UserLevel.level == user_level.level) & - (UserLevel.total_exp > user_level.total_exp) - ) - ) - ) - ) - result = await self.db.execute(query) - count = result.scalar() or 0 - - return count + 1 - - async def add_exam_exp( - self, - user_id: int, - exam_id: int, - score: float, - is_passed: bool - ) -> Optional[Dict[str, Any]]: - """ - 考试通过获得经验值 - - Args: - user_id: 用户ID - exam_id: 考试ID - score: 得分 - is_passed: 是否通过 - - Returns: - 经验值变化信息 - """ - if not is_passed: - return None - - exp_gained = EXP_CONFIG["exam_pass"] - bonus = [] - - if score >= 100: - exp_gained += EXP_CONFIG["exam_perfect"] - bonus.append("满分") - elif score >= 90: - exp_gained += EXP_CONFIG["exam_excellent"] - bonus.append("优秀") - - description = f"通过考试" - if bonus: - description += f"({', '.join(bonus)}奖励)" - - _, leveled_up, new_level = await self.add_exp( - user_id=user_id, - exp_amount=exp_gained, - exp_type=ExpType.EXAM, - description=description, - source_id=exam_id - ) - - return { - "exp_gained": exp_gained, - "leveled_up": leveled_up, - "new_level": new_level - } - - async def add_practice_exp( - self, - user_id: int, - session_id: int, - score: Optional[float] = None - ) -> Dict[str, Any]: - """ - 完成练习获得经验值 - - Args: - user_id: 用户ID - session_id: 练习会话ID - score: 得分(可选) - - Returns: - 经验值变化信息 - """ - exp_gained = EXP_CONFIG["practice_complete"] - bonus = [] - - if score is not None and score >= 80: - exp_gained += EXP_CONFIG["practice_good"] - bonus.append("高分") - - description = f"完成练习" - if bonus: - description += f"({', '.join(bonus)}奖励)" - - _, leveled_up, new_level = await self.add_exp( - user_id=user_id, - exp_amount=exp_gained, - exp_type=ExpType.PRACTICE, - description=description, - source_id=session_id - ) - - return { - "exp_gained": exp_gained, - "leveled_up": leveled_up, - "new_level": new_level - } - - async def add_training_exp( - self, - user_id: int, - session_id: int, - score: Optional[float] = None - ) -> Dict[str, Any]: - """ - 完成陪练获得经验值 - - Args: - user_id: 用户ID - session_id: 陪练会话ID - score: 得分(可选) - - Returns: - 经验值变化信息 - """ - exp_gained = EXP_CONFIG["training_complete"] - bonus = [] - - if score is not None and score >= 80: - exp_gained += EXP_CONFIG["training_good"] - bonus.append("高分") - - description = f"完成陪练" - if bonus: - description += f"({', '.join(bonus)}奖励)" - - _, leveled_up, new_level = await self.add_exp( - user_id=user_id, - exp_amount=exp_gained, - exp_type=ExpType.TRAINING, - description=description, - source_id=session_id - ) - - return { - "exp_gained": exp_gained, - "leveled_up": leveled_up, - "new_level": new_level - } - - async def add_task_exp( - self, - user_id: int, - task_id: int - ) -> Dict[str, Any]: - """ - 完成任务获得经验值 - - Args: - user_id: 用户ID - task_id: 任务ID - - Returns: - 经验值变化信息 - """ - exp_gained = EXP_CONFIG["task_complete"] - - _, leveled_up, new_level = await self.add_exp( - user_id=user_id, - exp_amount=exp_gained, - exp_type=ExpType.TASK, - description="完成任务", - source_id=task_id - ) - - return { - "exp_gained": exp_gained, - "leveled_up": leveled_up, - "new_level": new_level - } +""" +等级服务 + +提供用户等级管理功能: +- 经验值获取与计算 +- 等级升级判断 +- 每日签到 +- 排行榜查询 +""" + +from datetime import datetime, date, timedelta +from typing import Optional, List, Dict, Any, Tuple +from sqlalchemy import select, func, and_, desc +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.level import ( + UserLevel, ExpHistory, LevelConfig, ExpType +) +from app.models.user import User + +logger = get_logger(__name__) + + +# 经验值配置 +EXP_CONFIG = { + # 每日签到 + "login_base": 10, # 基础签到经验 + "login_streak_7": 20, # 连续7天额外奖励 + "login_streak_30": 50, # 连续30天额外奖励 + + # 考试 + "exam_pass": 50, # 通过考试 + "exam_excellent": 30, # 90分以上额外 + "exam_perfect": 50, # 满分额外 + + # 练习 + "practice_complete": 20, # 完成练习 + "practice_good": 10, # 80分以上额外 + + # 陪练 + "training_complete": 30, # 完成陪练 + "training_good": 15, # 80分以上额外 + + # 任务 + "task_complete": 40, # 完成任务 +} + + +class LevelService: + """等级服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self._level_configs: Optional[List[LevelConfig]] = None + + async def _get_level_configs(self) -> List[LevelConfig]: + """获取等级配置(带缓存)""" + if self._level_configs is None: + result = await self.db.execute( + select(LevelConfig).order_by(LevelConfig.level) + ) + self._level_configs = list(result.scalars().all()) + return self._level_configs + + async def get_or_create_user_level(self, user_id: int) -> UserLevel: + """ + 获取或创建用户等级记录 + + Args: + user_id: 用户ID + + Returns: + UserLevel 对象 + """ + result = await self.db.execute( + select(UserLevel).where(UserLevel.user_id == user_id) + ) + user_level = result.scalar_one_or_none() + + if not user_level: + user_level = UserLevel( + user_id=user_id, + level=1, + exp=0, + total_exp=0, + login_streak=0, + max_login_streak=0 + ) + self.db.add(user_level) + await self.db.flush() + logger.info(f"为用户 {user_id} 创建等级记录") + + return user_level + + async def get_user_level_info(self, user_id: int) -> Dict[str, Any]: + """ + 获取用户等级详细信息 + + Args: + user_id: 用户ID + + Returns: + 包含等级、经验值、称号等信息的字典 + """ + user_level = await self.get_or_create_user_level(user_id) + level_configs = await self._get_level_configs() + + # 获取当前等级配置 + current_config = next( + (c for c in level_configs if c.level == user_level.level), + level_configs[0] if level_configs else None + ) + + # 获取下一等级配置 + next_config = next( + (c for c in level_configs if c.level == user_level.level + 1), + None + ) + + # 计算升级所需经验 + exp_to_next_level = 0 + next_level_total_exp = 0 + if next_config: + next_level_total_exp = next_config.total_exp_required + exp_to_next_level = next_level_total_exp - user_level.total_exp + if exp_to_next_level < 0: + exp_to_next_level = 0 + + return { + "user_id": user_id, + "level": user_level.level, + "exp": user_level.exp, + "total_exp": user_level.total_exp, + "title": current_config.title if current_config else "初学者", + "color": current_config.color if current_config else "#909399", + "login_streak": user_level.login_streak, + "max_login_streak": user_level.max_login_streak, + "last_checkin_at": user_level.last_checkin_at.isoformat() if user_level.last_checkin_at else None, + "next_level_exp": next_level_total_exp, + "exp_to_next_level": exp_to_next_level, + "is_max_level": next_config is None, + } + + async def add_exp( + self, + user_id: int, + exp_amount: int, + exp_type: str, + description: str, + source_id: Optional[int] = None + ) -> Tuple[UserLevel, bool, Optional[int]]: + """ + 增加用户经验值 + + Args: + user_id: 用户ID + exp_amount: 经验值数量 + exp_type: 经验值类型 + description: 描述 + source_id: 来源ID(可选) + + Returns: + (用户等级对象, 是否升级, 新等级) + """ + if exp_amount <= 0: + logger.warning(f"尝试增加非正数经验值: {exp_amount}") + return await self.get_or_create_user_level(user_id), False, None + + user_level = await self.get_or_create_user_level(user_id) + level_before = user_level.level + + # 增加经验值 + user_level.exp += exp_amount + user_level.total_exp += exp_amount + + # 检查是否升级 + level_configs = await self._get_level_configs() + leveled_up = False + new_level = None + + for config in level_configs: + if config.level > user_level.level and user_level.total_exp >= config.total_exp_required: + user_level.level = config.level + leveled_up = True + new_level = config.level + + # 记录经验值历史 + exp_history = ExpHistory( + user_id=user_id, + exp_change=exp_amount, + exp_type=exp_type, + source_id=source_id, + description=description, + level_before=level_before, + level_after=user_level.level + ) + self.db.add(exp_history) + + await self.db.flush() + + if leveled_up: + logger.info(f"用户 {user_id} 升级: {level_before} -> {new_level}") + + logger.info(f"用户 {user_id} 获得 {exp_amount} 经验值: {description}") + + return user_level, leveled_up, new_level + + async def daily_checkin(self, user_id: int) -> Dict[str, Any]: + """ + 每日签到 + + Args: + user_id: 用户ID + + Returns: + 签到结果 + """ + user_level = await self.get_or_create_user_level(user_id) + today = date.today() + + # 检查今天是否已签到 + if user_level.last_login_date == today: + return { + "success": False, + "message": "今天已经签到过了", + "exp_gained": 0, + "login_streak": user_level.login_streak, + "already_checked_in": True + } + + # 计算连续登录 + yesterday = today - timedelta(days=1) + if user_level.last_login_date == yesterday: + # 连续登录 + user_level.login_streak += 1 + else: + # 中断了,重新计算 + user_level.login_streak = 1 + + # 更新最长连续登录记录 + if user_level.login_streak > user_level.max_login_streak: + user_level.max_login_streak = user_level.login_streak + + user_level.last_login_date = today + user_level.last_checkin_at = datetime.now() + + # 计算签到经验 + exp_gained = EXP_CONFIG["login_base"] + bonus_exp = 0 + bonus_reason = [] + + # 连续登录奖励 + if user_level.login_streak >= 30 and user_level.login_streak % 30 == 0: + bonus_exp += EXP_CONFIG["login_streak_30"] + bonus_reason.append(f"连续{user_level.login_streak}天") + elif user_level.login_streak >= 7 and user_level.login_streak % 7 == 0: + bonus_exp += EXP_CONFIG["login_streak_7"] + bonus_reason.append(f"连续{user_level.login_streak}天") + + total_exp = exp_gained + bonus_exp + + # 添加经验值 + description = f"每日签到" + if bonus_reason: + description += f"({', '.join(bonus_reason)}奖励)" + + _, leveled_up, new_level = await self.add_exp( + user_id=user_id, + exp_amount=total_exp, + exp_type=ExpType.LOGIN, + description=description + ) + + await self.db.commit() + + return { + "success": True, + "message": "签到成功", + "exp_gained": total_exp, + "base_exp": exp_gained, + "bonus_exp": bonus_exp, + "login_streak": user_level.login_streak, + "leveled_up": leveled_up, + "new_level": new_level, + "already_checked_in": False + } + + async def get_exp_history( + self, + user_id: int, + limit: int = 50, + offset: int = 0, + exp_type: Optional[str] = None + ) -> Tuple[List[Dict[str, Any]], int]: + """ + 获取经验值历史 + + Args: + user_id: 用户ID + limit: 限制数量 + offset: 偏移量 + exp_type: 类型筛选 + + Returns: + (历史记录列表, 总数) + """ + # 构建查询 + query = select(ExpHistory).where(ExpHistory.user_id == user_id) + + if exp_type: + query = query.where(ExpHistory.exp_type == exp_type) + + # 获取总数 + count_query = select(func.count(ExpHistory.id)).where(ExpHistory.user_id == user_id) + if exp_type: + count_query = count_query.where(ExpHistory.exp_type == exp_type) + total_result = await self.db.execute(count_query) + total = total_result.scalar() or 0 + + # 获取记录 + query = query.order_by(desc(ExpHistory.created_at)).limit(limit).offset(offset) + result = await self.db.execute(query) + records = result.scalars().all() + + history = [ + { + "id": r.id, + "exp_change": r.exp_change, + "exp_type": r.exp_type, + "description": r.description, + "level_before": r.level_before, + "level_after": r.level_after, + "created_at": r.created_at.isoformat() if r.created_at else None + } + for r in records + ] + + return history, total + + async def get_leaderboard( + self, + limit: int = 50, + offset: int = 0 + ) -> Tuple[List[Dict[str, Any]], int]: + """ + 获取等级排行榜 + + Args: + limit: 限制数量 + offset: 偏移量 + + Returns: + (排行榜列表, 总数) + """ + # 获取总数 + count_query = select(func.count(UserLevel.id)) + total_result = await self.db.execute(count_query) + total = total_result.scalar() or 0 + + # 获取排行榜(按等级和总经验值排序) + query = ( + select(UserLevel, User) + .join(User, UserLevel.user_id == User.id) + .where(User.is_deleted == False) + .order_by(desc(UserLevel.level), desc(UserLevel.total_exp)) + .limit(limit) + .offset(offset) + ) + result = await self.db.execute(query) + rows = result.all() + + # 获取等级配置 + level_configs = await self._get_level_configs() + config_map = {c.level: c for c in level_configs} + + leaderboard = [] + for i, (user_level, user) in enumerate(rows): + config = config_map.get(user_level.level) + leaderboard.append({ + "rank": offset + i + 1, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "avatar_url": user.avatar_url, + "level": user_level.level, + "title": config.title if config else "初学者", + "color": config.color if config else "#909399", + "total_exp": user_level.total_exp, + "login_streak": user_level.login_streak, + }) + + return leaderboard, total + + async def get_user_rank(self, user_id: int) -> Optional[int]: + """ + 获取用户排名 + + Args: + user_id: 用户ID + + Returns: + 排名(从1开始) + """ + user_level = await self.get_or_create_user_level(user_id) + + # 计算排在该用户前面的人数 + query = select(func.count(UserLevel.id)).where( + and_( + UserLevel.user_id != user_id, + ( + (UserLevel.level > user_level.level) | + ( + (UserLevel.level == user_level.level) & + (UserLevel.total_exp > user_level.total_exp) + ) + ) + ) + ) + result = await self.db.execute(query) + count = result.scalar() or 0 + + return count + 1 + + async def add_exam_exp( + self, + user_id: int, + exam_id: int, + score: float, + is_passed: bool + ) -> Optional[Dict[str, Any]]: + """ + 考试通过获得经验值 + + Args: + user_id: 用户ID + exam_id: 考试ID + score: 得分 + is_passed: 是否通过 + + Returns: + 经验值变化信息 + """ + if not is_passed: + return None + + exp_gained = EXP_CONFIG["exam_pass"] + bonus = [] + + if score >= 100: + exp_gained += EXP_CONFIG["exam_perfect"] + bonus.append("满分") + elif score >= 90: + exp_gained += EXP_CONFIG["exam_excellent"] + bonus.append("优秀") + + description = f"通过考试" + if bonus: + description += f"({', '.join(bonus)}奖励)" + + _, leveled_up, new_level = await self.add_exp( + user_id=user_id, + exp_amount=exp_gained, + exp_type=ExpType.EXAM, + description=description, + source_id=exam_id + ) + + return { + "exp_gained": exp_gained, + "leveled_up": leveled_up, + "new_level": new_level + } + + async def add_practice_exp( + self, + user_id: int, + session_id: int, + score: Optional[float] = None + ) -> Dict[str, Any]: + """ + 完成练习获得经验值 + + Args: + user_id: 用户ID + session_id: 练习会话ID + score: 得分(可选) + + Returns: + 经验值变化信息 + """ + exp_gained = EXP_CONFIG["practice_complete"] + bonus = [] + + if score is not None and score >= 80: + exp_gained += EXP_CONFIG["practice_good"] + bonus.append("高分") + + description = f"完成练习" + if bonus: + description += f"({', '.join(bonus)}奖励)" + + _, leveled_up, new_level = await self.add_exp( + user_id=user_id, + exp_amount=exp_gained, + exp_type=ExpType.PRACTICE, + description=description, + source_id=session_id + ) + + return { + "exp_gained": exp_gained, + "leveled_up": leveled_up, + "new_level": new_level + } + + async def add_training_exp( + self, + user_id: int, + session_id: int, + score: Optional[float] = None + ) -> Dict[str, Any]: + """ + 完成陪练获得经验值 + + Args: + user_id: 用户ID + session_id: 陪练会话ID + score: 得分(可选) + + Returns: + 经验值变化信息 + """ + exp_gained = EXP_CONFIG["training_complete"] + bonus = [] + + if score is not None and score >= 80: + exp_gained += EXP_CONFIG["training_good"] + bonus.append("高分") + + description = f"完成陪练" + if bonus: + description += f"({', '.join(bonus)}奖励)" + + _, leveled_up, new_level = await self.add_exp( + user_id=user_id, + exp_amount=exp_gained, + exp_type=ExpType.TRAINING, + description=description, + source_id=session_id + ) + + return { + "exp_gained": exp_gained, + "leveled_up": leveled_up, + "new_level": new_level + } + + async def add_task_exp( + self, + user_id: int, + task_id: int + ) -> Dict[str, Any]: + """ + 完成任务获得经验值 + + Args: + user_id: 用户ID + task_id: 任务ID + + Returns: + 经验值变化信息 + """ + exp_gained = EXP_CONFIG["task_complete"] + + _, leveled_up, new_level = await self.add_exp( + user_id=user_id, + exp_amount=exp_gained, + exp_type=ExpType.TASK, + description="完成任务", + source_id=task_id + ) + + return { + "exp_gained": exp_gained, + "leveled_up": leveled_up, + "new_level": new_level + } diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py index bc9e79c..3cec0b6 100644 --- a/backend/app/services/notification_service.py +++ b/backend/app/services/notification_service.py @@ -1,330 +1,419 @@ """ -站内消息通知服务 -提供通知的CRUD操作和业务逻辑 +通知推送服务 +支持钉钉、企业微信、站内消息等多种渠道 """ -from typing import List, Optional, Tuple -from sqlalchemy import select, and_, desc, func, update -from sqlalchemy.orm import selectinload +import os +import json +import logging +from datetime import datetime, timedelta +from typing import Optional, List, Dict, Any +import httpx from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_ -from app.core.logger import get_logger -from app.models.notification import Notification from app.models.user import User -from app.schemas.notification import ( - NotificationCreate, - NotificationBatchCreate, - NotificationResponse, - NotificationType, -) -from app.services.base_service import BaseService +from app.models.notification import Notification -logger = get_logger(__name__) +logger = logging.getLogger(__name__) -class NotificationService(BaseService[Notification]): - """ - 站内消息通知服务 +class NotificationChannel: + """通知渠道基类""" - 提供通知的创建、查询、标记已读等功能 - """ - - def __init__(self): - super().__init__(Notification) - - async def create_notification( + async def send( self, - db: AsyncSession, - notification_in: NotificationCreate - ) -> Notification: - """ - 创建单个通知 - - Args: - db: 数据库会话 - notification_in: 通知创建数据 - - Returns: - 创建的通知对象 - """ - notification = Notification( - user_id=notification_in.user_id, - title=notification_in.title, - content=notification_in.content, - type=notification_in.type.value if isinstance(notification_in.type, NotificationType) else notification_in.type, - related_id=notification_in.related_id, - related_type=notification_in.related_type, - sender_id=notification_in.sender_id, - is_read=False - ) - - db.add(notification) - await db.commit() - await db.refresh(notification) - - logger.info( - "创建通知成功", - notification_id=notification.id, - user_id=notification_in.user_id, - type=notification_in.type - ) - - return notification - - async def batch_create_notifications( - self, - db: AsyncSession, - batch_in: NotificationBatchCreate - ) -> List[Notification]: - """ - 批量创建通知(发送给多个用户) - - Args: - db: 数据库会话 - batch_in: 批量通知创建数据 - - Returns: - 创建的通知列表 - """ - notifications = [] - notification_type = batch_in.type.value if isinstance(batch_in.type, NotificationType) else batch_in.type - - for user_id in batch_in.user_ids: - notification = Notification( - user_id=user_id, - title=batch_in.title, - content=batch_in.content, - type=notification_type, - related_id=batch_in.related_id, - related_type=batch_in.related_type, - sender_id=batch_in.sender_id, - is_read=False - ) - notifications.append(notification) - db.add(notification) - - await db.commit() - - # 刷新所有对象 - for notification in notifications: - await db.refresh(notification) - - logger.info( - "批量创建通知成功", - count=len(notifications), - user_ids=batch_in.user_ids, - type=batch_in.type - ) - - return notifications - - async def get_user_notifications( - self, - db: AsyncSession, user_id: int, - skip: int = 0, - limit: int = 20, - is_read: Optional[bool] = None, - notification_type: Optional[str] = None - ) -> Tuple[List[NotificationResponse], int, int]: - """ - 获取用户的通知列表 - - Args: - db: 数据库会话 - user_id: 用户ID - skip: 跳过数量 - limit: 返回数量 - is_read: 是否已读筛选 - notification_type: 通知类型筛选 - - Returns: - (通知列表, 总数, 未读数) - """ - # 构建基础查询条件 - conditions = [Notification.user_id == user_id] - - if is_read is not None: - conditions.append(Notification.is_read == is_read) - - if notification_type: - conditions.append(Notification.type == notification_type) - - # 查询通知列表(带发送者信息) - stmt = ( - select(Notification) - .where(and_(*conditions)) - .order_by(desc(Notification.created_at)) - .offset(skip) - .limit(limit) - ) - - result = await db.execute(stmt) - notifications = result.scalars().all() - - # 统计总数 - count_stmt = select(func.count()).select_from(Notification).where(and_(*conditions)) - total_result = await db.execute(count_stmt) - total = total_result.scalar_one() - - # 统计未读数 - unread_stmt = ( - select(func.count()) - .select_from(Notification) - .where(and_(Notification.user_id == user_id, Notification.is_read == False)) - ) - unread_result = await db.execute(unread_stmt) - unread_count = unread_result.scalar_one() - - # 获取发送者信息 - sender_ids = [n.sender_id for n in notifications if n.sender_id] - sender_names = {} - if sender_ids: - sender_stmt = select(User.id, User.full_name).where(User.id.in_(sender_ids)) - sender_result = await db.execute(sender_stmt) - sender_names = {row[0]: row[1] for row in sender_result.fetchall()} - - # 构建响应 - responses = [] - for notification in notifications: - response = NotificationResponse( - id=notification.id, - user_id=notification.user_id, - title=notification.title, - content=notification.content, - type=notification.type, - is_read=notification.is_read, - related_id=notification.related_id, - related_type=notification.related_type, - sender_id=notification.sender_id, - sender_name=sender_names.get(notification.sender_id) if notification.sender_id else None, - created_at=notification.created_at, - updated_at=notification.updated_at - ) - responses.append(response) - - return responses, total, unread_count - - async def get_unread_count( - self, - db: AsyncSession, - user_id: int - ) -> Tuple[int, int]: - """ - 获取用户未读通知数量 - - Args: - db: 数据库会话 - user_id: 用户ID - - Returns: - (未读数, 总数) - """ - # 统计未读数 - unread_stmt = ( - select(func.count()) - .select_from(Notification) - .where(and_(Notification.user_id == user_id, Notification.is_read == False)) - ) - unread_result = await db.execute(unread_stmt) - unread_count = unread_result.scalar_one() - - # 统计总数 - total_stmt = ( - select(func.count()) - .select_from(Notification) - .where(Notification.user_id == user_id) - ) - total_result = await db.execute(total_stmt) - total = total_result.scalar_one() - - return unread_count, total - - async def mark_as_read( - self, - db: AsyncSession, - user_id: int, - notification_ids: Optional[List[int]] = None - ) -> int: - """ - 标记通知为已读 - - Args: - db: 数据库会话 - user_id: 用户ID - notification_ids: 通知ID列表,为空则标记全部 - - Returns: - 更新的数量 - """ - conditions = [ - Notification.user_id == user_id, - Notification.is_read == False - ] - - if notification_ids: - conditions.append(Notification.id.in_(notification_ids)) - - stmt = ( - update(Notification) - .where(and_(*conditions)) - .values(is_read=True) - ) - - result = await db.execute(stmt) - await db.commit() - - updated_count = result.rowcount - - logger.info( - "标记通知已读", - user_id=user_id, - notification_ids=notification_ids, - updated_count=updated_count - ) - - return updated_count - - async def delete_notification( - self, - db: AsyncSession, - user_id: int, - notification_id: int + title: str, + content: str, + **kwargs ) -> bool: """ - 删除通知 + 发送通知 Args: - db: 数据库会话 user_id: 用户ID - notification_id: 通知ID + title: 通知标题 + content: 通知内容 Returns: - 是否删除成功 + 是否发送成功 """ - stmt = select(Notification).where( - and_( - Notification.id == notification_id, - Notification.user_id == user_id + raise NotImplementedError + + +class DingtalkChannel(NotificationChannel): + """ + 钉钉通知渠道 + + 使用钉钉工作通知 API 发送消息 + 文档: https://open.dingtalk.com/document/orgapp/asynchronous-sending-of-enterprise-session-messages + """ + + def __init__( + self, + app_key: Optional[str] = None, + app_secret: Optional[str] = None, + agent_id: Optional[str] = None, + ): + self.app_key = app_key or os.getenv("DINGTALK_APP_KEY") + self.app_secret = app_secret or os.getenv("DINGTALK_APP_SECRET") + self.agent_id = agent_id or os.getenv("DINGTALK_AGENT_ID") + self._access_token = None + self._token_expires_at = None + + async def _get_access_token(self) -> str: + """获取钉钉访问令牌""" + if ( + self._access_token + and self._token_expires_at + and datetime.now() < self._token_expires_at + ): + return self._access_token + + url = "https://oapi.dingtalk.com/gettoken" + params = { + "appkey": self.app_key, + "appsecret": self.app_secret, + } + + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, timeout=10.0) + result = response.json() + + if result.get("errcode") == 0: + self._access_token = result["access_token"] + self._token_expires_at = datetime.now() + timedelta(seconds=7000) + return self._access_token + else: + raise Exception(f"获取钉钉Token失败: {result.get('errmsg')}") + + async def send( + self, + user_id: int, + title: str, + content: str, + dingtalk_user_id: Optional[str] = None, + **kwargs + ) -> bool: + """发送钉钉工作通知""" + if not all([self.app_key, self.app_secret, self.agent_id]): + logger.warning("钉钉配置不完整,跳过发送") + return False + + if not dingtalk_user_id: + logger.warning(f"用户 {user_id} 没有绑定钉钉ID") + return False + + try: + access_token = await self._get_access_token() + + url = f"https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2?access_token={access_token}" + + # 构建消息体 + msg = { + "agent_id": self.agent_id, + "userid_list": dingtalk_user_id, + "msg": { + "msgtype": "text", + "text": { + "content": f"{title}\n\n{content}" + } + } + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=msg, timeout=10.0) + result = response.json() + + if result.get("errcode") == 0: + logger.info(f"钉钉消息发送成功: user_id={user_id}") + return True + else: + logger.error(f"钉钉消息发送失败: {result.get('errmsg')}") + return False + except Exception as e: + logger.error(f"钉钉消息发送异常: {str(e)}") + return False + + +class WeworkChannel(NotificationChannel): + """ + 企业微信通知渠道 + + 使用企业微信应用消息 API + 文档: https://developer.work.weixin.qq.com/document/path/90236 + """ + + def __init__( + self, + corp_id: Optional[str] = None, + corp_secret: Optional[str] = None, + agent_id: Optional[str] = None, + ): + self.corp_id = corp_id or os.getenv("WEWORK_CORP_ID") + self.corp_secret = corp_secret or os.getenv("WEWORK_CORP_SECRET") + self.agent_id = agent_id or os.getenv("WEWORK_AGENT_ID") + self._access_token = None + self._token_expires_at = None + + async def _get_access_token(self) -> str: + """获取企业微信访问令牌""" + if ( + self._access_token + and self._token_expires_at + and datetime.now() < self._token_expires_at + ): + return self._access_token + + url = "https://qyapi.weixin.qq.com/cgi-bin/gettoken" + params = { + "corpid": self.corp_id, + "corpsecret": self.corp_secret, + } + + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, timeout=10.0) + result = response.json() + + if result.get("errcode") == 0: + self._access_token = result["access_token"] + self._token_expires_at = datetime.now() + timedelta(seconds=7000) + return self._access_token + else: + raise Exception(f"获取企微Token失败: {result.get('errmsg')}") + + async def send( + self, + user_id: int, + title: str, + content: str, + wework_user_id: Optional[str] = None, + **kwargs + ) -> bool: + """发送企业微信应用消息""" + if not all([self.corp_id, self.corp_secret, self.agent_id]): + logger.warning("企业微信配置不完整,跳过发送") + return False + + if not wework_user_id: + logger.warning(f"用户 {user_id} 没有绑定企业微信ID") + return False + + try: + access_token = await self._get_access_token() + + url = f"https://qyapi.weixin.qq.com/cgi-bin/message/send?access_token={access_token}" + + # 构建消息体 + msg = { + "touser": wework_user_id, + "msgtype": "text", + "agentid": int(self.agent_id), + "text": { + "content": f"{title}\n\n{content}" + } + } + + async with httpx.AsyncClient() as client: + response = await client.post(url, json=msg, timeout=10.0) + result = response.json() + + if result.get("errcode") == 0: + logger.info(f"企微消息发送成功: user_id={user_id}") + return True + else: + logger.error(f"企微消息发送失败: {result.get('errmsg')}") + return False + except Exception as e: + logger.error(f"企微消息发送异常: {str(e)}") + return False + + +class InAppChannel(NotificationChannel): + """站内消息通道""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def send( + self, + user_id: int, + title: str, + content: str, + notification_type: str = "system", + **kwargs + ) -> bool: + """创建站内消息""" + try: + notification = Notification( + user_id=user_id, + title=title, + content=content, + type=notification_type, + is_read=False, ) + self.db.add(notification) + await self.db.commit() + logger.info(f"站内消息创建成功: user_id={user_id}") + return True + except Exception as e: + logger.error(f"站内消息创建失败: {str(e)}") + return False + + +class NotificationService: + """ + 通知服务 + + 统一管理多渠道通知发送 + """ + + def __init__(self, db: AsyncSession): + self.db = db + self.channels = { + "dingtalk": DingtalkChannel(), + "wework": WeworkChannel(), + "inapp": InAppChannel(db), + } + + async def send_notification( + self, + user_id: int, + title: str, + content: str, + channels: Optional[List[str]] = None, + **kwargs + ) -> Dict[str, bool]: + """ + 发送通知 + + Args: + user_id: 用户ID + title: 通知标题 + content: 通知内容 + channels: 发送渠道列表,默认全部发送 + + Returns: + 各渠道发送结果 + """ + # 获取用户信息 + user = await self._get_user(user_id) + if not user: + return {"error": "用户不存在"} + + # 准备用户渠道标识 + user_channels = { + "dingtalk_user_id": getattr(user, "dingtalk_id", None), + "wework_user_id": getattr(user, "wework_userid", None), + } + + # 确定发送渠道 + target_channels = channels or ["inapp"] # 默认只发站内消息 + + results = {} + for channel_name in target_channels: + if channel_name in self.channels: + channel = self.channels[channel_name] + success = await channel.send( + user_id=user_id, + title=title, + content=content, + **user_channels, + **kwargs + ) + results[channel_name] = success + + return results + + async def send_learning_reminder( + self, + user_id: int, + course_name: str, + days_inactive: int = 3, + ) -> Dict[str, bool]: + """发送学习提醒""" + title = "📚 学习提醒" + content = f"您已有 {days_inactive} 天没有学习《{course_name}》课程了,快来继续学习吧!" + + return await self.send_notification( + user_id=user_id, + title=title, + content=content, + channels=["inapp", "dingtalk", "wework"], + notification_type="learning_reminder", + ) + + async def send_task_deadline_reminder( + self, + user_id: int, + task_name: str, + deadline: datetime, + ) -> Dict[str, bool]: + """发送任务截止提醒""" + days_left = (deadline - datetime.now()).days + title = "⏰ 任务截止提醒" + content = f"任务《{task_name}》将于 {deadline.strftime('%Y-%m-%d %H:%M')} 截止,还有 {days_left} 天,请尽快完成!" + + return await self.send_notification( + user_id=user_id, + title=title, + content=content, + channels=["inapp", "dingtalk", "wework"], + notification_type="task_deadline", + ) + + async def send_exam_reminder( + self, + user_id: int, + exam_name: str, + exam_time: datetime, + ) -> Dict[str, bool]: + """发送考试提醒""" + title = "📝 考试提醒" + content = f"考试《{exam_name}》将于 {exam_time.strftime('%Y-%m-%d %H:%M')} 开始,请提前做好准备!" + + return await self.send_notification( + user_id=user_id, + title=title, + content=content, + channels=["inapp", "dingtalk", "wework"], + notification_type="exam_reminder", + ) + + async def send_weekly_report( + self, + user_id: int, + study_time: int, + courses_completed: int, + exams_passed: int, + ) -> Dict[str, bool]: + """发送周学习报告""" + title = "📊 本周学习报告" + content = ( + f"本周学习总结:\n" + f"• 学习时长:{study_time // 60} 分钟\n" + f"• 完成课程:{courses_completed} 门\n" + f"• 通过考试:{exams_passed} 次\n\n" + f"继续加油!💪" ) - result = await db.execute(stmt) - notification = result.scalar_one_or_none() - - if notification: - await db.delete(notification) - await db.commit() - - logger.info( - "删除通知成功", - notification_id=notification_id, - user_id=user_id - ) - return True - - return False + return await self.send_notification( + user_id=user_id, + title=title, + content=content, + channels=["inapp", "dingtalk", "wework"], + notification_type="weekly_report", + ) + + async def _get_user(self, user_id: int) -> Optional[User]: + """获取用户信息""" + result = await self.db.execute( + select(User).where(User.id == user_id) + ) + return result.scalar_one_or_none() -# 创建服务实例 -notification_service = NotificationService() - +# 便捷函数 +def get_notification_service(db: AsyncSession) -> NotificationService: + """获取通知服务实例""" + return NotificationService(db) diff --git a/backend/app/services/permission_service.py b/backend/app/services/permission_service.py new file mode 100644 index 0000000..825b1a9 --- /dev/null +++ b/backend/app/services/permission_service.py @@ -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) diff --git a/backend/app/services/practice_room_service.py b/backend/app/services/practice_room_service.py index 60688fc..3ac53e7 100644 --- a/backend/app/services/practice_room_service.py +++ b/backend/app/services/practice_room_service.py @@ -1,526 +1,713 @@ -""" -双人对练房间服务 - -功能: -- 房间创建、加入、退出 -- 房间状态管理 -- 消息广播 -- 对练结束处理 -""" -import logging -import random -import string -from datetime import datetime -from typing import Optional, List, Dict, Any -from sqlalchemy.ext.asyncio import AsyncSession -from sqlalchemy import select, update, and_ -from sqlalchemy.orm import selectinload - -from app.models.practice_room import PracticeRoom, PracticeRoomMessage -from app.models.practice import PracticeDialogue, PracticeSession -from app.models.user import User - -logger = logging.getLogger(__name__) - - -class PracticeRoomService: - """双人对练房间服务""" - - # 房间状态常量 - STATUS_WAITING = "waiting" # 等待加入 - STATUS_READY = "ready" # 准备就绪 - STATUS_PRACTICING = "practicing" # 对练中 - STATUS_COMPLETED = "completed" # 已完成 - STATUS_CANCELED = "canceled" # 已取消 - - # 消息类型常量 - MSG_TYPE_CHAT = "chat" # 聊天消息 - MSG_TYPE_SYSTEM = "system" # 系统消息 - MSG_TYPE_JOIN = "join" # 加入消息 - MSG_TYPE_LEAVE = "leave" # 离开消息 - MSG_TYPE_START = "start" # 开始消息 - MSG_TYPE_END = "end" # 结束消息 - - def __init__(self, db: AsyncSession): - self.db = db - - # ==================== 房间管理 ==================== - - async def create_room( - self, - host_user_id: int, - scene_id: Optional[int] = None, - scene_name: Optional[str] = None, - scene_type: Optional[str] = None, - scene_background: Optional[str] = None, - role_a_name: str = "销售顾问", - role_b_name: str = "顾客", - role_a_description: Optional[str] = None, - role_b_description: Optional[str] = None, - host_role: str = "A", - room_name: Optional[str] = None - ) -> PracticeRoom: - """ - 创建对练房间 - - Args: - host_user_id: 房主用户ID - scene_id: 场景ID(可选) - scene_name: 场景名称 - scene_type: 场景类型 - scene_background: 场景背景 - role_a_name: 角色A名称 - role_b_name: 角色B名称 - role_a_description: 角色A描述 - role_b_description: 角色B描述 - host_role: 房主选择的角色(A或B) - room_name: 房间名称 - - Returns: - PracticeRoom: 创建的房间对象 - """ - # 生成唯一的6位房间码 - room_code = await self._generate_unique_room_code() - - # 创建房间 - room = PracticeRoom( - room_code=room_code, - room_name=room_name or f"{scene_name or '双人对练'}房间", - scene_id=scene_id, - scene_name=scene_name, - scene_type=scene_type, - scene_background=scene_background, - role_a_name=role_a_name, - role_b_name=role_b_name, - role_a_description=role_a_description, - role_b_description=role_b_description, - host_user_id=host_user_id, - host_role=host_role, - status=self.STATUS_WAITING - ) - - self.db.add(room) - await self.db.commit() - await self.db.refresh(room) - - logger.info(f"创建房间成功: room_code={room_code}, host_user_id={host_user_id}") - return room - - async def join_room( - self, - room_code: str, - user_id: int - ) -> PracticeRoom: - """ - 加入房间 - - Args: - room_code: 房间码 - user_id: 用户ID - - Returns: - PracticeRoom: 房间对象 - - Raises: - ValueError: 房间不存在、已满或状态不允许加入 - """ - # 查询房间 - room = await self.get_room_by_code(room_code) - if not room: - raise ValueError("房间不存在或已过期") - - # 检查是否是房主(房主重新进入) - if room.host_user_id == user_id: - return room - - # 检查房间状态 - if room.status not in [self.STATUS_WAITING, self.STATUS_READY]: - raise ValueError("房间已开始对练或已结束,无法加入") - - # 检查是否已满 - if room.guest_user_id and room.guest_user_id != user_id: - raise ValueError("房间已满") - - # 加入房间 - room.guest_user_id = user_id - room.status = self.STATUS_READY - - await self.db.commit() - await self.db.refresh(room) - - # 发送系统消息 - await self._add_system_message(room.id, f"用户已加入房间", self.MSG_TYPE_JOIN, user_id) - - logger.info(f"用户加入房间: room_code={room_code}, user_id={user_id}") - return room - - async def leave_room( - self, - room_code: str, - user_id: int - ) -> bool: - """ - 离开房间 - - Args: - room_code: 房间码 - user_id: 用户ID - - Returns: - bool: 是否成功离开 - """ - room = await self.get_room_by_code(room_code) - if not room: - return False - - # 如果是房主离开,取消房间 - if room.host_user_id == user_id: - room.status = self.STATUS_CANCELED - await self._add_system_message(room.id, "房主离开,房间已关闭", self.MSG_TYPE_LEAVE, user_id) - # 如果是嘉宾离开 - elif room.guest_user_id == user_id: - room.guest_user_id = None - room.status = self.STATUS_WAITING - await self._add_system_message(room.id, "对方已离开房间", self.MSG_TYPE_LEAVE, user_id) - else: - return False - - await self.db.commit() - logger.info(f"用户离开房间: room_code={room_code}, user_id={user_id}") - return True - - async def start_practice( - self, - room_code: str, - user_id: int - ) -> PracticeRoom: - """ - 开始对练(仅房主可操作) - - Args: - room_code: 房间码 - user_id: 用户ID(必须是房主) - - Returns: - PracticeRoom: 房间对象 - """ - room = await self.get_room_by_code(room_code) - if not room: - raise ValueError("房间不存在") - - if room.host_user_id != user_id: - raise ValueError("只有房主可以开始对练") - - if room.status != self.STATUS_READY: - raise ValueError("房间未就绪,请等待对方加入") - - room.status = self.STATUS_PRACTICING - room.started_at = datetime.now() - - await self.db.commit() - await self.db.refresh(room) - - # 发送开始消息 - await self._add_system_message(room.id, "对练开始!", self.MSG_TYPE_START) - - logger.info(f"对练开始: room_code={room_code}") - return room - - async def end_practice( - self, - room_code: str, - user_id: int - ) -> PracticeRoom: - """ - 结束对练 - - Args: - room_code: 房间码 - user_id: 用户ID - - Returns: - PracticeRoom: 房间对象 - """ - room = await self.get_room_by_code(room_code) - if not room: - raise ValueError("房间不存在") - - if room.status != self.STATUS_PRACTICING: - raise ValueError("对练未在进行中") - - # 计算时长 - if room.started_at: - duration = (datetime.now() - room.started_at).total_seconds() - room.duration_seconds = int(duration) - - room.status = self.STATUS_COMPLETED - room.ended_at = datetime.now() - - await self.db.commit() - await self.db.refresh(room) - - # 发送结束消息 - await self._add_system_message(room.id, "对练结束!", self.MSG_TYPE_END) - - logger.info(f"对练结束: room_code={room_code}, duration={room.duration_seconds}s") - return room - - # ==================== 消息管理 ==================== - - async def send_message( - self, - room_id: int, - user_id: int, - content: Optional[str], - role_name: Optional[str] = None, - message_type: Optional[str] = None, - extra_data: Optional[dict] = None - ) -> PracticeRoomMessage: - """ - 发送聊天消息或信令消息 - - Args: - room_id: 房间ID - user_id: 发送者ID - content: 消息内容 - role_name: 角色名称 - message_type: 消息类型(默认为 chat) - extra_data: 额外数据(用于 WebRTC 信令等) - - Returns: - PracticeRoomMessage: 消息对象 - """ - import json - - # 获取当前消息序号 - sequence = await self._get_next_sequence(room_id) - - # 如果是信令消息,将 extra_data 序列化到 content 中 - actual_content = content - if extra_data and not content: - actual_content = json.dumps(extra_data) - - message = PracticeRoomMessage( - room_id=room_id, - user_id=user_id, - message_type=message_type or self.MSG_TYPE_CHAT, - content=actual_content, - role_name=role_name, - sequence=sequence - ) - - self.db.add(message) - - # 只有聊天消息才更新房间统计 - if (message_type or self.MSG_TYPE_CHAT) == self.MSG_TYPE_CHAT: - room = await self.get_room_by_id(room_id) - if room: - room.total_turns += 1 - user_role = room.get_user_role(user_id) - if user_role == "A": - room.role_a_turns += 1 - elif user_role == "B": - room.role_b_turns += 1 - - await self.db.commit() - await self.db.refresh(message) - - return message - - async def get_messages( - self, - room_id: int, - since_sequence: int = 0, - limit: int = 100 - ) -> List[PracticeRoomMessage]: - """ - 获取房间消息(用于SSE轮询) - - Args: - room_id: 房间ID - since_sequence: 从该序号之后开始获取 - limit: 最大数量 - - Returns: - List[PracticeRoomMessage]: 消息列表 - """ - result = await self.db.execute( - select(PracticeRoomMessage) - .where( - and_( - PracticeRoomMessage.room_id == room_id, - PracticeRoomMessage.sequence > since_sequence - ) - ) - .order_by(PracticeRoomMessage.sequence) - .limit(limit) - ) - return list(result.scalars().all()) - - async def get_all_messages(self, room_id: int) -> List[PracticeRoomMessage]: - """ - 获取房间所有消息 - - Args: - room_id: 房间ID - - Returns: - List[PracticeRoomMessage]: 消息列表 - """ - result = await self.db.execute( - select(PracticeRoomMessage) - .where(PracticeRoomMessage.room_id == room_id) - .order_by(PracticeRoomMessage.sequence) - ) - return list(result.scalars().all()) - - # ==================== 查询方法 ==================== - - async def get_room_by_code(self, room_code: str) -> Optional[PracticeRoom]: - """根据房间码获取房间""" - result = await self.db.execute( - select(PracticeRoom).where( - and_( - PracticeRoom.room_code == room_code, - PracticeRoom.is_deleted == False - ) - ) - ) - return result.scalar_one_or_none() - - async def get_room_by_id(self, room_id: int) -> Optional[PracticeRoom]: - """根据ID获取房间""" - result = await self.db.execute( - select(PracticeRoom).where( - and_( - PracticeRoom.id == room_id, - PracticeRoom.is_deleted == False - ) - ) - ) - return result.scalar_one_or_none() - - async def get_user_rooms( - self, - user_id: int, - status: Optional[str] = None, - limit: int = 20 - ) -> List[PracticeRoom]: - """获取用户的房间列表""" - query = select(PracticeRoom).where( - and_( - (PracticeRoom.host_user_id == user_id) | (PracticeRoom.guest_user_id == user_id), - PracticeRoom.is_deleted == False - ) - ) - - if status: - query = query.where(PracticeRoom.status == status) - - query = query.order_by(PracticeRoom.created_at.desc()).limit(limit) - - result = await self.db.execute(query) - return list(result.scalars().all()) - - async def get_room_with_users(self, room_code: str) -> Optional[Dict[str, Any]]: - """获取房间详情(包含用户信息)""" - room = await self.get_room_by_code(room_code) - if not room: - return None - - # 获取用户信息 - host_user = None - guest_user = None - - if room.host_user_id: - result = await self.db.execute( - select(User).where(User.id == room.host_user_id) - ) - host_user = result.scalar_one_or_none() - - if room.guest_user_id: - result = await self.db.execute( - select(User).where(User.id == room.guest_user_id) - ) - guest_user = result.scalar_one_or_none() - - return { - "room": room, - "host_user": host_user, - "guest_user": guest_user, - "host_role_name": room.get_role_name(room.host_role), - "guest_role_name": room.get_role_name("B" if room.host_role == "A" else "A") if guest_user else None - } - - # ==================== 辅助方法 ==================== - - async def _generate_unique_room_code(self) -> str: - """生成唯一的6位房间码""" - for _ in range(10): # 最多尝试10次 - code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) - # 排除容易混淆的字符 - code = code.replace('0', 'X').replace('O', 'Y').replace('I', 'Z').replace('1', 'W') - - # 检查是否已存在 - existing = await self.get_room_by_code(code) - if not existing: - return code - - raise ValueError("无法生成唯一房间码,请稍后重试") - - async def _get_next_sequence(self, room_id: int) -> int: - """获取下一个消息序号""" - result = await self.db.execute( - select(PracticeRoomMessage.sequence) - .where(PracticeRoomMessage.room_id == room_id) - .order_by(PracticeRoomMessage.sequence.desc()) - .limit(1) - ) - last_seq = result.scalar_one_or_none() - return (last_seq or 0) + 1 - - async def _add_system_message( - self, - room_id: int, - content: str, - msg_type: str, - user_id: Optional[int] = None - ) -> PracticeRoomMessage: - """添加系统消息""" - sequence = await self._get_next_sequence(room_id) - - message = PracticeRoomMessage( - room_id=room_id, - user_id=user_id, - message_type=msg_type, - content=content, - sequence=sequence - ) - - self.db.add(message) - await self.db.commit() - await self.db.refresh(message) - - return message - - -# ==================== 便捷函数 ==================== - -async def create_practice_room( - db: AsyncSession, - host_user_id: int, - **kwargs -) -> PracticeRoom: - """便捷函数:创建房间""" - service = PracticeRoomService(db) - return await service.create_room(host_user_id, **kwargs) - - -async def join_practice_room( - db: AsyncSession, - room_code: str, - user_id: int -) -> PracticeRoom: - """便捷函数:加入房间""" - service = PracticeRoomService(db) - return await service.join_room(room_code, user_id) +""" +双人对练房间服务 + +功能: +- 房间创建、加入、退出 +- 房间状态管理 +- 消息广播 +- 对练结束处理 +""" +import logging +import random +import string +from datetime import datetime +from typing import Optional, List, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, update, and_ +from sqlalchemy.orm import selectinload + +from app.models.practice_room import PracticeRoom, PracticeRoomMessage +from app.models.practice import PracticeDialogue, PracticeSession +from app.models.user import User + +logger = logging.getLogger(__name__) + + +class PracticeRoomService: + """双人对练房间服务""" + + # 房间状态常量 + STATUS_WAITING = "waiting" # 等待加入 + STATUS_READY = "ready" # 准备就绪 + STATUS_PRACTICING = "practicing" # 对练中 + STATUS_COMPLETED = "completed" # 已完成 + STATUS_CANCELED = "canceled" # 已取消 + + # 消息类型常量 + MSG_TYPE_CHAT = "chat" # 聊天消息 + MSG_TYPE_SYSTEM = "system" # 系统消息 + MSG_TYPE_JOIN = "join" # 加入消息 + MSG_TYPE_LEAVE = "leave" # 离开消息 + MSG_TYPE_START = "start" # 开始消息 + MSG_TYPE_END = "end" # 结束消息 + + def __init__(self, db: AsyncSession): + self.db = db + + # ==================== 房间管理 ==================== + + async def create_room( + self, + host_user_id: int, + scene_id: Optional[int] = None, + scene_name: Optional[str] = None, + scene_type: Optional[str] = None, + scene_background: Optional[str] = None, + role_a_name: str = "销售顾问", + role_b_name: str = "顾客", + role_a_description: Optional[str] = None, + role_b_description: Optional[str] = None, + host_role: str = "A", + room_name: Optional[str] = None + ) -> PracticeRoom: + """ + 创建对练房间 + + Args: + host_user_id: 房主用户ID + scene_id: 场景ID(可选) + scene_name: 场景名称 + scene_type: 场景类型 + scene_background: 场景背景 + role_a_name: 角色A名称 + role_b_name: 角色B名称 + role_a_description: 角色A描述 + role_b_description: 角色B描述 + host_role: 房主选择的角色(A或B) + room_name: 房间名称 + + Returns: + PracticeRoom: 创建的房间对象 + """ + # 生成唯一的6位房间码 + room_code = await self._generate_unique_room_code() + + # 创建房间 + room = PracticeRoom( + room_code=room_code, + room_name=room_name or f"{scene_name or '双人对练'}房间", + scene_id=scene_id, + scene_name=scene_name, + scene_type=scene_type, + scene_background=scene_background, + role_a_name=role_a_name, + role_b_name=role_b_name, + role_a_description=role_a_description, + role_b_description=role_b_description, + host_user_id=host_user_id, + host_role=host_role, + status=self.STATUS_WAITING + ) + + self.db.add(room) + await self.db.commit() + await self.db.refresh(room) + + logger.info(f"创建房间成功: room_code={room_code}, host_user_id={host_user_id}") + return room + + async def join_room( + self, + room_code: str, + user_id: int + ) -> PracticeRoom: + """ + 加入房间 + + Args: + room_code: 房间码 + user_id: 用户ID + + Returns: + PracticeRoom: 房间对象 + + Raises: + ValueError: 房间不存在、已满或状态不允许加入 + """ + # 查询房间 + room = await self.get_room_by_code(room_code) + if not room: + raise ValueError("房间不存在或已过期") + + # 检查是否是房主(房主重新进入) + if room.host_user_id == user_id: + return room + + # 检查房间状态 + if room.status not in [self.STATUS_WAITING, self.STATUS_READY]: + raise ValueError("房间已开始对练或已结束,无法加入") + + # 检查是否已满 + if room.guest_user_id and room.guest_user_id != user_id: + raise ValueError("房间已满") + + # 加入房间 + room.guest_user_id = user_id + room.status = self.STATUS_READY + + await self.db.commit() + await self.db.refresh(room) + + # 发送系统消息 + await self._add_system_message(room.id, f"用户已加入房间", self.MSG_TYPE_JOIN, user_id) + + logger.info(f"用户加入房间: room_code={room_code}, user_id={user_id}") + return room + + async def leave_room( + self, + room_code: str, + user_id: int + ) -> bool: + """ + 离开房间 + + Args: + room_code: 房间码 + user_id: 用户ID + + Returns: + bool: 是否成功离开 + """ + room = await self.get_room_by_code(room_code) + if not room: + return False + + # 如果是房主离开,取消房间 + if room.host_user_id == user_id: + room.status = self.STATUS_CANCELED + await self._add_system_message(room.id, "房主离开,房间已关闭", self.MSG_TYPE_LEAVE, user_id) + # 如果是嘉宾离开 + elif room.guest_user_id == user_id: + room.guest_user_id = None + room.status = self.STATUS_WAITING + await self._add_system_message(room.id, "对方已离开房间", self.MSG_TYPE_LEAVE, user_id) + else: + return False + + await self.db.commit() + logger.info(f"用户离开房间: room_code={room_code}, user_id={user_id}") + return True + + async def start_practice( + self, + room_code: str, + user_id: int + ) -> PracticeRoom: + """ + 开始对练(仅房主可操作) + + Args: + room_code: 房间码 + user_id: 用户ID(必须是房主) + + Returns: + PracticeRoom: 房间对象 + """ + room = await self.get_room_by_code(room_code) + if not room: + raise ValueError("房间不存在") + + if room.host_user_id != user_id: + raise ValueError("只有房主可以开始对练") + + if room.status != self.STATUS_READY: + raise ValueError("房间未就绪,请等待对方加入") + + room.status = self.STATUS_PRACTICING + room.started_at = datetime.now() + + await self.db.commit() + await self.db.refresh(room) + + # 发送开始消息 + await self._add_system_message(room.id, "对练开始!", self.MSG_TYPE_START) + + logger.info(f"对练开始: room_code={room_code}") + return room + + async def end_practice( + self, + room_code: str, + user_id: int + ) -> PracticeRoom: + """ + 结束对练 + + Args: + room_code: 房间码 + user_id: 用户ID + + Returns: + PracticeRoom: 房间对象 + """ + room = await self.get_room_by_code(room_code) + if not room: + raise ValueError("房间不存在") + + if room.status != self.STATUS_PRACTICING: + raise ValueError("对练未在进行中") + + # 计算时长 + if room.started_at: + duration = (datetime.now() - room.started_at).total_seconds() + room.duration_seconds = int(duration) + + room.status = self.STATUS_COMPLETED + room.ended_at = datetime.now() + + await self.db.commit() + await self.db.refresh(room) + + # 发送结束消息 + await self._add_system_message(room.id, "对练结束!", self.MSG_TYPE_END) + + logger.info(f"对练结束: room_code={room_code}, duration={room.duration_seconds}s") + return room + + # ==================== 消息管理 ==================== + + async def send_message( + self, + room_id: int, + user_id: int, + content: Optional[str], + role_name: Optional[str] = None, + message_type: Optional[str] = None, + extra_data: Optional[dict] = None + ) -> PracticeRoomMessage: + """ + 发送聊天消息或信令消息 + + Args: + room_id: 房间ID + user_id: 发送者ID + content: 消息内容 + role_name: 角色名称 + message_type: 消息类型(默认为 chat) + extra_data: 额外数据(用于 WebRTC 信令等) + + Returns: + PracticeRoomMessage: 消息对象 + """ + import json + + # 获取当前消息序号 + sequence = await self._get_next_sequence(room_id) + + # 如果是信令消息,将 extra_data 序列化到 content 中 + actual_content = content + if extra_data and not content: + actual_content = json.dumps(extra_data) + + message = PracticeRoomMessage( + room_id=room_id, + user_id=user_id, + message_type=message_type or self.MSG_TYPE_CHAT, + content=actual_content, + role_name=role_name, + sequence=sequence + ) + + self.db.add(message) + + # 只有聊天消息才更新房间统计 + if (message_type or self.MSG_TYPE_CHAT) == self.MSG_TYPE_CHAT: + room = await self.get_room_by_id(room_id) + if room: + room.total_turns += 1 + user_role = room.get_user_role(user_id) + if user_role == "A": + room.role_a_turns += 1 + elif user_role == "B": + room.role_b_turns += 1 + + await self.db.commit() + await self.db.refresh(message) + + return message + + async def get_messages( + self, + room_id: int, + since_sequence: int = 0, + limit: int = 100 + ) -> List[PracticeRoomMessage]: + """ + 获取房间消息(用于SSE轮询) + + Args: + room_id: 房间ID + since_sequence: 从该序号之后开始获取 + limit: 最大数量 + + Returns: + List[PracticeRoomMessage]: 消息列表 + """ + result = await self.db.execute( + select(PracticeRoomMessage) + .where( + and_( + PracticeRoomMessage.room_id == room_id, + PracticeRoomMessage.sequence > since_sequence + ) + ) + .order_by(PracticeRoomMessage.sequence) + .limit(limit) + ) + return list(result.scalars().all()) + + async def get_all_messages(self, room_id: int) -> List[PracticeRoomMessage]: + """ + 获取房间所有消息 + + Args: + room_id: 房间ID + + Returns: + List[PracticeRoomMessage]: 消息列表 + """ + result = await self.db.execute( + select(PracticeRoomMessage) + .where(PracticeRoomMessage.room_id == room_id) + .order_by(PracticeRoomMessage.sequence) + ) + return list(result.scalars().all()) + + # ==================== 查询方法 ==================== + + async def get_room_by_code(self, room_code: str) -> Optional[PracticeRoom]: + """根据房间码获取房间""" + result = await self.db.execute( + select(PracticeRoom).where( + and_( + PracticeRoom.room_code == room_code, + PracticeRoom.is_deleted == False + ) + ) + ) + return result.scalar_one_or_none() + + async def get_room_by_id(self, room_id: int) -> Optional[PracticeRoom]: + """根据ID获取房间""" + result = await self.db.execute( + select(PracticeRoom).where( + and_( + PracticeRoom.id == room_id, + PracticeRoom.is_deleted == False + ) + ) + ) + return result.scalar_one_or_none() + + async def get_user_rooms( + self, + user_id: int, + status: Optional[str] = None, + limit: int = 20 + ) -> List[PracticeRoom]: + """获取用户的房间列表""" + query = select(PracticeRoom).where( + and_( + (PracticeRoom.host_user_id == user_id) | (PracticeRoom.guest_user_id == user_id), + PracticeRoom.is_deleted == False + ) + ) + + if status: + query = query.where(PracticeRoom.status == status) + + query = query.order_by(PracticeRoom.created_at.desc()).limit(limit) + + result = await self.db.execute(query) + return list(result.scalars().all()) + + async def get_room_with_users(self, room_code: str) -> Optional[Dict[str, Any]]: + """获取房间详情(包含用户信息)""" + room = await self.get_room_by_code(room_code) + if not room: + return None + + # 获取用户信息 + host_user = None + guest_user = None + + if room.host_user_id: + result = await self.db.execute( + select(User).where(User.id == room.host_user_id) + ) + host_user = result.scalar_one_or_none() + + if room.guest_user_id: + result = await self.db.execute( + select(User).where(User.id == room.guest_user_id) + ) + guest_user = result.scalar_one_or_none() + + return { + "room": room, + "host_user": host_user, + "guest_user": guest_user, + "host_role_name": room.get_role_name(room.host_role), + "guest_role_name": room.get_role_name("B" if room.host_role == "A" else "A") if guest_user else None + } + + # ==================== 辅助方法 ==================== + + async def _generate_unique_room_code(self) -> str: + """生成唯一的6位房间码""" + for _ in range(10): # 最多尝试10次 + code = ''.join(random.choices(string.ascii_uppercase + string.digits, k=6)) + # 排除容易混淆的字符 + code = code.replace('0', 'X').replace('O', 'Y').replace('I', 'Z').replace('1', 'W') + + # 检查是否已存在 + existing = await self.get_room_by_code(code) + if not existing: + return code + + raise ValueError("无法生成唯一房间码,请稍后重试") + + async def _get_next_sequence(self, room_id: int) -> int: + """获取下一个消息序号""" + result = await self.db.execute( + select(PracticeRoomMessage.sequence) + .where(PracticeRoomMessage.room_id == room_id) + .order_by(PracticeRoomMessage.sequence.desc()) + .limit(1) + ) + last_seq = result.scalar_one_or_none() + return (last_seq or 0) + 1 + + async def _add_system_message( + self, + room_id: int, + content: str, + msg_type: str, + user_id: Optional[int] = None + ) -> PracticeRoomMessage: + """添加系统消息""" + sequence = await self._get_next_sequence(room_id) + + message = PracticeRoomMessage( + room_id=room_id, + user_id=user_id, + message_type=msg_type, + content=content, + sequence=sequence + ) + + self.db.add(message) + await self.db.commit() + await self.db.refresh(message) + + return message + + # ==================== 报告生成 ==================== + + async def generate_report(self, room_id: int) -> Dict[str, Any]: + """ + 生成对练报告 + + Args: + room_id: 房间ID + + Returns: + 包含房间信息、对话分析、表现评估的完整报告 + """ + # 获取房间信息 + room = await self.get_room(room_id) + if not room: + return None + + # 获取房间消息 + messages = await self.get_messages(room_id) + chat_messages = [m for m in messages if m.message_type == self.MSG_TYPE_CHAT] + + # 获取用户信息 + host_user = await self._get_user(room.host_user_id) + guest_user = await self._get_user(room.guest_user_id) if room.guest_user_id else None + + # 分析对话 + analysis = self._analyze_conversation(room, chat_messages) + + # 构建报告 + report = { + "room": { + "id": room.id, + "room_code": room.room_code, + "scene_name": room.scene_name or "自由对练", + "scene_type": room.scene_type, + "scene_background": room.scene_background, + "role_a_name": room.role_a_name, + "role_b_name": room.role_b_name, + "status": room.status, + "duration_seconds": room.duration_seconds or 0, + "total_turns": room.total_turns or 0, + "started_at": room.started_at.isoformat() if room.started_at else None, + "ended_at": room.ended_at.isoformat() if room.ended_at else None, + }, + "participants": { + "host": { + "user_id": room.host_user_id, + "username": host_user.username if host_user else "未知用户", + "role": room.host_role, + "role_name": room.role_a_name if room.host_role == "A" else room.role_b_name, + }, + "guest": { + "user_id": room.guest_user_id, + "username": guest_user.username if guest_user else "未加入", + "role": "B" if room.host_role == "A" else "A", + "role_name": room.role_b_name if room.host_role == "A" else room.role_a_name, + } if room.guest_user_id else None, + }, + "analysis": analysis, + "messages": [ + { + "id": m.id, + "user_id": m.user_id, + "content": m.content, + "role_name": m.role_name, + "sequence": m.sequence, + "created_at": m.created_at.isoformat() if m.created_at else None, + } + for m in chat_messages + ], + } + + return report + + def _analyze_conversation( + self, + room: PracticeRoom, + messages: List[PracticeRoomMessage] + ) -> Dict[str, Any]: + """ + 分析对话内容 + + 返回对话分析结果,包括: + - 对话统计 + - 参与度分析 + - 对话质量评估 + - 改进建议 + """ + if not messages: + return { + "summary": "暂无对话记录", + "statistics": { + "total_messages": 0, + "role_a_messages": 0, + "role_b_messages": 0, + "avg_message_length": 0, + "conversation_duration": room.duration_seconds or 0, + }, + "participation": { + "role_a_ratio": 0, + "role_b_ratio": 0, + "balance_score": 0, + }, + "quality": { + "overall_score": 0, + "engagement_score": 0, + "response_quality": 0, + }, + "suggestions": ["尚无足够的对话数据进行分析"], + } + + # 统计消息 + role_a_messages = [m for m in messages if m.role_name == room.role_a_name] + role_b_messages = [m for m in messages if m.role_name == room.role_b_name] + + total_messages = len(messages) + role_a_count = len(role_a_messages) + role_b_count = len(role_b_messages) + + # 计算平均消息长度 + total_length = sum(len(m.content or "") for m in messages) + avg_length = round(total_length / total_messages) if total_messages > 0 else 0 + + # 计算参与度 + role_a_ratio = round(role_a_count / total_messages * 100, 1) if total_messages > 0 else 0 + role_b_ratio = round(role_b_count / total_messages * 100, 1) if total_messages > 0 else 0 + + # 平衡度评分(越接近50:50越高) + balance_score = round(100 - abs(role_a_ratio - 50) * 2, 1) + balance_score = max(0, min(100, balance_score)) + + # 质量评估(基于简单规则) + engagement_score = min(100, total_messages * 5) # 每条消息5分,最高100 + + # 响应质量(基于平均消息长度) + response_quality = min(100, avg_length * 2) # 每字2分,最高100 + + # 综合评分 + overall_score = round((balance_score + engagement_score + response_quality) / 3, 1) + + # 生成建议 + suggestions = [] + if balance_score < 70: + suggestions.append(f"对话参与度不均衡,建议{room.role_a_name if role_a_ratio < 50 else room.role_b_name}增加互动") + if avg_length < 20: + suggestions.append("平均消息较短,建议增加更详细的表达") + if total_messages < 10: + suggestions.append("对话轮次较少,建议增加更多交流") + if overall_score >= 80: + suggestions.append("对话质量良好,继续保持!") + elif overall_score < 60: + suggestions.append("建议增加对话深度和互动频率") + + if not suggestions: + suggestions.append("表现正常,可以尝试更复杂的场景练习") + + return { + "summary": f"本次对练共进行 {total_messages} 轮对话,时长 {room.duration_seconds or 0} 秒", + "statistics": { + "total_messages": total_messages, + "role_a_messages": role_a_count, + "role_b_messages": role_b_count, + "avg_message_length": avg_length, + "conversation_duration": room.duration_seconds or 0, + }, + "participation": { + "role_a_ratio": role_a_ratio, + "role_b_ratio": role_b_ratio, + "balance_score": balance_score, + }, + "quality": { + "overall_score": overall_score, + "engagement_score": engagement_score, + "response_quality": response_quality, + }, + "suggestions": suggestions, + } + + async def _get_user(self, user_id: Optional[int]) -> Optional[User]: + """获取用户信息""" + if not user_id: + return None + result = await self.db.execute( + select(User).where(User.id == user_id) + ) + return result.scalar_one_or_none() + + +# ==================== 便捷函数 ==================== + +async def create_practice_room( + db: AsyncSession, + host_user_id: int, + **kwargs +) -> PracticeRoom: + """便捷函数:创建房间""" + service = PracticeRoomService(db) + return await service.create_room(host_user_id, **kwargs) + + +async def join_practice_room( + db: AsyncSession, + room_code: str, + user_id: int +) -> PracticeRoom: + """便捷函数:加入房间""" + service = PracticeRoomService(db) + return await service.join_room(room_code, user_id) diff --git a/backend/app/services/recommendation_service.py b/backend/app/services/recommendation_service.py new file mode 100644 index 0000000..b02a2f4 --- /dev/null +++ b/backend/app/services/recommendation_service.py @@ -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) diff --git a/backend/app/services/scheduler_service.py b/backend/app/services/scheduler_service.py new file mode 100644 index 0000000..9ed027d --- /dev/null +++ b/backend/app/services/scheduler_service.py @@ -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 diff --git a/backend/app/services/speech_recognition.py b/backend/app/services/speech_recognition.py new file mode 100644 index 0000000..b748602 --- /dev/null +++ b/backend/app/services/speech_recognition.py @@ -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) diff --git a/backend/migrations/README.md b/backend/migrations/README.md index 2471627..98e575b 100644 --- a/backend/migrations/README.md +++ b/backend/migrations/README.md @@ -1,82 +1,82 @@ -# 数据库迁移说明 - -本目录包含 KPL 考培练系统的数据库迁移脚本。 - -## 迁移脚本列表 - -| 脚本 | 说明 | 创建时间 | -|------|------|----------| -| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 | - -## 执行迁移 - -### 测试环境(Docker) - -KPL 测试环境数据库在服务器 Docker 容器中运行: - -```bash -# 1. SSH 登录 KPL 服务器 -ssh root@ - -# 2. 进入项目目录 -cd /www/wwwroot/kpl.ireborn.com.cn - -# 3. 执行迁移(方法一:直接执行) -docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql - -# 或者(方法二:交互式执行) -docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev -# 然后复制粘贴 SQL 脚本内容执行 - -# 方法三:从本地执行(需要先上传SQL文件到服务器) -# scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/ -# ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql" -``` - -**注意**:MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认) - -### 生产环境 - -生产环境迁移前请确保: -1. 已备份数据库 -2. 在低峰期执行 -3. 测试环境验证通过 - -```bash -# 执行迁移(替换为实际的生产数据库配置) -mysql -h -u -p < backend/migrations/add_level_badge_system.sql -``` - -## 回滚方法 - -如需回滚,执行以下 SQL: - -```sql -DROP TABLE IF EXISTS user_badges; -DROP TABLE IF EXISTS badge_definitions; -DROP TABLE IF EXISTS exp_history; -DROP TABLE IF EXISTS level_configs; -DROP TABLE IF EXISTS user_levels; -``` - -## 验证迁移 - -执行以下查询验证表是否创建成功: - -```sql -SHOW TABLES LIKE '%level%'; -SHOW TABLES LIKE '%badge%'; -SHOW TABLES LIKE '%exp%'; - --- 查看表结构 -DESCRIBE user_levels; -DESCRIBE exp_history; -DESCRIBE badge_definitions; -DESCRIBE user_badges; -DESCRIBE level_configs; - --- 验证初始数据 -SELECT COUNT(*) FROM level_configs; -- 应该是 10 条 -SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条 -SELECT COUNT(*) FROM user_levels; -- 应该等于用户数 -``` +# 数据库迁移说明 + +本目录包含 KPL 考培练系统的数据库迁移脚本。 + +## 迁移脚本列表 + +| 脚本 | 说明 | 创建时间 | +|------|------|----------| +| `add_level_badge_system.sql` | 等级与奖章系统 | 2026-01-29 | + +## 执行迁移 + +### 测试环境(Docker) + +KPL 测试环境数据库在服务器 Docker 容器中运行: + +```bash +# 1. SSH 登录 KPL 服务器 +ssh root@ + +# 2. 进入项目目录 +cd /www/wwwroot/kpl.ireborn.com.cn + +# 3. 执行迁移(方法一:直接执行) +docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < backend/migrations/add_level_badge_system.sql + +# 或者(方法二:交互式执行) +docker exec -it kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev +# 然后复制粘贴 SQL 脚本内容执行 + +# 方法三:从本地执行(需要先上传SQL文件到服务器) +# scp backend/migrations/add_level_badge_system.sql root@<服务器IP>:/tmp/ +# ssh root@<服务器IP> "docker exec -i kpl-mysql-dev mysql -uroot -pnj861021 kpl_dev < /tmp/add_level_badge_system.sql" +``` + +**注意**:MySQL 容器密码为 `nj861021`(之前通过 `docker exec kpl-mysql-dev env | grep MYSQL` 确认) + +### 生产环境 + +生产环境迁移前请确保: +1. 已备份数据库 +2. 在低峰期执行 +3. 测试环境验证通过 + +```bash +# 执行迁移(替换为实际的生产数据库配置) +mysql -h -u -p < backend/migrations/add_level_badge_system.sql +``` + +## 回滚方法 + +如需回滚,执行以下 SQL: + +```sql +DROP TABLE IF EXISTS user_badges; +DROP TABLE IF EXISTS badge_definitions; +DROP TABLE IF EXISTS exp_history; +DROP TABLE IF EXISTS level_configs; +DROP TABLE IF EXISTS user_levels; +``` + +## 验证迁移 + +执行以下查询验证表是否创建成功: + +```sql +SHOW TABLES LIKE '%level%'; +SHOW TABLES LIKE '%badge%'; +SHOW TABLES LIKE '%exp%'; + +-- 查看表结构 +DESCRIBE user_levels; +DESCRIBE exp_history; +DESCRIBE badge_definitions; +DESCRIBE user_badges; +DESCRIBE level_configs; + +-- 验证初始数据 +SELECT COUNT(*) FROM level_configs; -- 应该是 10 条 +SELECT COUNT(*) FROM badge_definitions; -- 应该是 20 条 +SELECT COUNT(*) FROM user_levels; -- 应该等于用户数 +``` diff --git a/backend/migrations/add_certificate_system.sql b/backend/migrations/add_certificate_system.sql index 5411b51..5241372 100644 --- a/backend/migrations/add_certificate_system.sql +++ b/backend/migrations/add_certificate_system.sql @@ -1,166 +1,166 @@ --- ================================================================ --- 证书系统数据库迁移脚本 --- 创建日期: 2026-01-29 --- 功能: 添加证书模板表和用户证书表 --- ================================================================ - --- 事务开始 -START TRANSACTION; - --- ================================================================ --- 1. 创建证书模板表 --- ================================================================ -CREATE TABLE IF NOT EXISTS certificate_templates ( - id INT PRIMARY KEY AUTO_INCREMENT, - name VARCHAR(100) NOT NULL COMMENT '模板名称', - type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书', - background_url VARCHAR(500) COMMENT '证书背景图URL', - template_html TEXT COMMENT 'HTML模板内容', - template_style TEXT COMMENT 'CSS样式', - is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用', - sort_order INT DEFAULT 0 COMMENT '排序顺序', - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - INDEX idx_type (type), - INDEX idx_is_active (is_active) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表'; - --- ================================================================ --- 2. 创建用户证书表 --- ================================================================ -CREATE TABLE IF NOT EXISTS user_certificates ( - id INT PRIMARY KEY AUTO_INCREMENT, - user_id INT NOT NULL COMMENT '用户ID', - template_id INT NOT NULL COMMENT '模板ID', - certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号', - title VARCHAR(200) NOT NULL COMMENT '证书标题', - description TEXT COMMENT '证书描述/成就说明', - issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间', - valid_until DATETIME COMMENT '有效期至(NULL表示永久)', - - -- 关联信息 - course_id INT COMMENT '关联课程ID', - exam_id INT COMMENT '关联考试ID', - badge_id INT COMMENT '关联奖章ID', - - -- 成绩信息 - score DECIMAL(5,2) COMMENT '考试分数', - completion_rate DECIMAL(5,2) COMMENT '完成率', - - -- 生成的文件 - pdf_url VARCHAR(500) COMMENT 'PDF文件URL', - image_url VARCHAR(500) COMMENT '分享图片URL', - - -- 元数据 - meta_data JSON COMMENT '扩展元数据', - - created_at DATETIME DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (template_id) REFERENCES certificate_templates(id), - INDEX idx_user_id (user_id), - INDEX idx_certificate_no (certificate_no), - INDEX idx_course_id (course_id), - INDEX idx_exam_id (exam_id), - INDEX idx_issued_at (issued_at) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表'; - --- ================================================================ --- 3. 插入默认证书模板 --- ================================================================ -INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES --- 课程结业证书模板 -('课程结业证书', 'course', -'
-
- -

结业证书

-
-
-

兹证明 {{user_name}}

-

已完成课程《{{course_name}}》的全部学习内容

-

完成率:{{completion_rate}}%

-

颁发日期:{{issue_date}}

-
- -
', -'.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; } -.header { text-align: center; margin-bottom: 30px; } -.header .logo { font-size: 24px; color: #667eea; font-weight: bold; } -.header h1 { font-size: 36px; color: #333; margin: 20px 0; } -.body { text-align: center; padding: 30px 60px; } -.body .recipient { font-size: 20px; margin-bottom: 20px; } -.body .content { font-size: 18px; color: #555; margin-bottom: 15px; } -.body .completion { font-size: 16px; color: #667eea; } -.body .date { font-size: 14px; color: #888; margin-top: 30px; } -.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; } -.cert-no { font-size: 12px; color: #999; } -.qrcode { width: 80px; height: 80px; }', -TRUE, 1), - --- 考试合格证书模板 -('考试合格证书', 'exam', -'
-
- -

考试合格证书

-
-
-

兹证明 {{user_name}}

-

在《{{exam_name}}》考试中成绩合格

-
- {{score}} - -
-

考试日期:{{exam_date}}

-
- -
', -'.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; } -.exam-cert .header h1 { color: #2e7d32; } -.score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; } -.score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; } -.score-badge .unit { font-size: 18px; color: #666; }', -TRUE, 2), - --- 成就证书模板 -('成就证书', 'achievement', -'
-
- -

成就证书

-
-
-

兹证明 {{user_name}}

-
{{badge_icon}}
-

{{badge_name}}

-

{{badge_description}}

-

获得日期:{{achieve_date}}

-
- -
', -'.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; } -.achievement-cert .header h1 { color: #e65100; } -.achievement-icon { font-size: 64px; margin: 20px 0; } -.achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; } -.achievement-desc { font-size: 16px; color: #666; }', -TRUE, 3); - --- 提交事务 -COMMIT; - --- ================================================================ --- 验证脚本 --- ================================================================ --- SELECT * FROM certificate_templates; --- SELECT COUNT(*) AS template_count FROM certificate_templates; +-- ================================================================ +-- 证书系统数据库迁移脚本 +-- 创建日期: 2026-01-29 +-- 功能: 添加证书模板表和用户证书表 +-- ================================================================ + +-- 事务开始 +START TRANSACTION; + +-- ================================================================ +-- 1. 创建证书模板表 +-- ================================================================ +CREATE TABLE IF NOT EXISTS certificate_templates ( + id INT PRIMARY KEY AUTO_INCREMENT, + name VARCHAR(100) NOT NULL COMMENT '模板名称', + type ENUM('course', 'exam', 'achievement') NOT NULL COMMENT '证书类型: course=课程结业, exam=考试合格, achievement=成就证书', + background_url VARCHAR(500) COMMENT '证书背景图URL', + template_html TEXT COMMENT 'HTML模板内容', + template_style TEXT COMMENT 'CSS样式', + is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_type (type), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='证书模板表'; + +-- ================================================================ +-- 2. 创建用户证书表 +-- ================================================================ +CREATE TABLE IF NOT EXISTS user_certificates ( + id INT PRIMARY KEY AUTO_INCREMENT, + user_id INT NOT NULL COMMENT '用户ID', + template_id INT NOT NULL COMMENT '模板ID', + certificate_no VARCHAR(50) UNIQUE NOT NULL COMMENT '证书编号 KPL-年份-序号', + title VARCHAR(200) NOT NULL COMMENT '证书标题', + description TEXT COMMENT '证书描述/成就说明', + issued_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '颁发时间', + valid_until DATETIME COMMENT '有效期至(NULL表示永久)', + + -- 关联信息 + course_id INT COMMENT '关联课程ID', + exam_id INT COMMENT '关联考试ID', + badge_id INT COMMENT '关联奖章ID', + + -- 成绩信息 + score DECIMAL(5,2) COMMENT '考试分数', + completion_rate DECIMAL(5,2) COMMENT '完成率', + + -- 生成的文件 + pdf_url VARCHAR(500) COMMENT 'PDF文件URL', + image_url VARCHAR(500) COMMENT '分享图片URL', + + -- 元数据 + meta_data JSON COMMENT '扩展元数据', + + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (template_id) REFERENCES certificate_templates(id), + INDEX idx_user_id (user_id), + INDEX idx_certificate_no (certificate_no), + INDEX idx_course_id (course_id), + INDEX idx_exam_id (exam_id), + INDEX idx_issued_at (issued_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户证书表'; + +-- ================================================================ +-- 3. 插入默认证书模板 +-- ================================================================ +INSERT INTO certificate_templates (name, type, template_html, template_style, is_active, sort_order) VALUES +-- 课程结业证书模板 +('课程结业证书', 'course', +'
+
+ +

结业证书

+
+
+

兹证明 {{user_name}}

+

已完成课程《{{course_name}}》的全部学习内容

+

完成率:{{completion_rate}}%

+

颁发日期:{{issue_date}}

+
+ +
', +'.certificate { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #f5f7fa 0%, #e4e9f2 100%); font-family: "Microsoft YaHei", sans-serif; } +.header { text-align: center; margin-bottom: 30px; } +.header .logo { font-size: 24px; color: #667eea; font-weight: bold; } +.header h1 { font-size: 36px; color: #333; margin: 20px 0; } +.body { text-align: center; padding: 30px 60px; } +.body .recipient { font-size: 20px; margin-bottom: 20px; } +.body .content { font-size: 18px; color: #555; margin-bottom: 15px; } +.body .completion { font-size: 16px; color: #667eea; } +.body .date { font-size: 14px; color: #888; margin-top: 30px; } +.footer { display: flex; justify-content: space-between; align-items: center; margin-top: 40px; } +.cert-no { font-size: 12px; color: #999; } +.qrcode { width: 80px; height: 80px; }', +TRUE, 1), + +-- 考试合格证书模板 +('考试合格证书', 'exam', +'
+
+ +

考试合格证书

+
+
+

兹证明 {{user_name}}

+

在《{{exam_name}}》考试中成绩合格

+
+ {{score}} + +
+

考试日期:{{exam_date}}

+
+ +
', +'.certificate.exam-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #e8f5e9 0%, #c8e6c9 100%); font-family: "Microsoft YaHei", sans-serif; } +.exam-cert .header h1 { color: #2e7d32; } +.score-badge { display: inline-block; padding: 20px 40px; background: #fff; border-radius: 50%; box-shadow: 0 4px 20px rgba(0,0,0,0.1); margin: 20px 0; } +.score-badge .score { font-size: 48px; font-weight: bold; color: #2e7d32; } +.score-badge .unit { font-size: 18px; color: #666; }', +TRUE, 2), + +-- 成就证书模板 +('成就证书', 'achievement', +'
+
+ +

成就证书

+
+
+

兹证明 {{user_name}}

+
{{badge_icon}}
+

{{badge_name}}

+

{{badge_description}}

+

获得日期:{{achieve_date}}

+
+ +
', +'.certificate.achievement-cert { width: 800px; height: 600px; padding: 40px; background: linear-gradient(135deg, #fff3e0 0%, #ffe0b2 100%); font-family: "Microsoft YaHei", sans-serif; } +.achievement-cert .header h1 { color: #e65100; } +.achievement-icon { font-size: 64px; margin: 20px 0; } +.achievement-name { font-size: 24px; font-weight: bold; color: #e65100; margin: 10px 0; } +.achievement-desc { font-size: 16px; color: #666; }', +TRUE, 3); + +-- 提交事务 +COMMIT; + +-- ================================================================ +-- 验证脚本 +-- ================================================================ +-- SELECT * FROM certificate_templates; +-- SELECT COUNT(*) AS template_count FROM certificate_templates; diff --git a/backend/migrations/add_dingtalk_login.sql b/backend/migrations/add_dingtalk_login.sql index 820dc75..088f37e 100644 --- a/backend/migrations/add_dingtalk_login.sql +++ b/backend/migrations/add_dingtalk_login.sql @@ -1,41 +1,41 @@ --- ===================================================== --- 钉钉免密登录功能 - 数据库迁移脚本 --- 创建时间: 2026-01-28 --- 说明: 为考培练系统添加钉钉免密登录支持 --- ===================================================== - --- 1. 用户表添加 dingtalk_id 字段 --- ----------------------------------------------------- -ALTER TABLE users ADD COLUMN dingtalk_id VARCHAR(64) UNIQUE COMMENT '钉钉用户ID'; -CREATE INDEX idx_users_dingtalk_id ON users(dingtalk_id); - - --- 2. 配置模板表添加钉钉配置项 --- ----------------------------------------------------- -INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, is_required, is_secret, sort_order) VALUES -('dingtalk', 'DINGTALK_APP_KEY', 'AppKey', '钉钉应用的AppKey(从钉钉开放平台获取)', 'string', 1, 0, 1), -('dingtalk', 'DINGTALK_APP_SECRET', 'AppSecret', '钉钉应用的AppSecret(敏感信息)', 'string', 1, 1, 2), -('dingtalk', 'DINGTALK_AGENT_ID', 'AgentId', '钉钉应用的AgentId', 'string', 1, 0, 3), -('dingtalk', 'DINGTALK_CORP_ID', 'CorpId', '钉钉企业的CorpId', 'string', 1, 0, 4); - - --- 3. 功能开关表添加钉钉免密登录开关(默认禁用) --- ----------------------------------------------------- -INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES -(NULL, 'dingtalk_login', '钉钉免密登录', 'auth', 0, '启用后,用户可通过钉钉免密登录系统'); - - --- ===================================================== --- 回滚脚本(如需回滚,执行以下SQL) --- ===================================================== -/* --- 回滚步骤1: 删除功能开关 -DELETE FROM feature_switches WHERE feature_code = 'dingtalk_login'; - --- 回滚步骤2: 删除配置模板 -DELETE FROM config_templates WHERE config_group = 'dingtalk'; - --- 回滚步骤3: 删除用户表字段 -ALTER TABLE users DROP INDEX idx_users_dingtalk_id; -ALTER TABLE users DROP COLUMN dingtalk_id; -*/ +-- ===================================================== +-- 钉钉免密登录功能 - 数据库迁移脚本 +-- 创建时间: 2026-01-28 +-- 说明: 为考培练系统添加钉钉免密登录支持 +-- ===================================================== + +-- 1. 用户表添加 dingtalk_id 字段 +-- ----------------------------------------------------- +ALTER TABLE users ADD COLUMN dingtalk_id VARCHAR(64) UNIQUE COMMENT '钉钉用户ID'; +CREATE INDEX idx_users_dingtalk_id ON users(dingtalk_id); + + +-- 2. 配置模板表添加钉钉配置项 +-- ----------------------------------------------------- +INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, is_required, is_secret, sort_order) VALUES +('dingtalk', 'DINGTALK_APP_KEY', 'AppKey', '钉钉应用的AppKey(从钉钉开放平台获取)', 'string', 1, 0, 1), +('dingtalk', 'DINGTALK_APP_SECRET', 'AppSecret', '钉钉应用的AppSecret(敏感信息)', 'string', 1, 1, 2), +('dingtalk', 'DINGTALK_AGENT_ID', 'AgentId', '钉钉应用的AgentId', 'string', 1, 0, 3), +('dingtalk', 'DINGTALK_CORP_ID', 'CorpId', '钉钉企业的CorpId', 'string', 1, 0, 4); + + +-- 3. 功能开关表添加钉钉免密登录开关(默认禁用) +-- ----------------------------------------------------- +INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES +(NULL, 'dingtalk_login', '钉钉免密登录', 'auth', 0, '启用后,用户可通过钉钉免密登录系统'); + + +-- ===================================================== +-- 回滚脚本(如需回滚,执行以下SQL) +-- ===================================================== +/* +-- 回滚步骤1: 删除功能开关 +DELETE FROM feature_switches WHERE feature_code = 'dingtalk_login'; + +-- 回滚步骤2: 删除配置模板 +DELETE FROM config_templates WHERE config_group = 'dingtalk'; + +-- 回滚步骤3: 删除用户表字段 +ALTER TABLE users DROP INDEX idx_users_dingtalk_id; +ALTER TABLE users DROP COLUMN dingtalk_id; +*/ diff --git a/backend/migrations/add_level_badge_system.sql b/backend/migrations/add_level_badge_system.sql index 29323be..bc69c35 100644 --- a/backend/migrations/add_level_badge_system.sql +++ b/backend/migrations/add_level_badge_system.sql @@ -1,192 +1,192 @@ --- ===================================================== --- 等级与奖章系统数据库迁移脚本 --- 版本: 1.0.0 --- 创建时间: 2026-01-29 --- 说明: 添加用户等级系统和奖章系统相关表 --- ===================================================== - --- 使用事务确保原子性 -START TRANSACTION; - --- ===================================================== --- 1. 用户等级表 (user_levels) --- 存储用户的等级和经验值信息 --- ===================================================== -CREATE TABLE IF NOT EXISTS user_levels ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '用户ID', - level INT NOT NULL DEFAULT 1 COMMENT '当前等级', - exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值', - total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值', - login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数', - max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数', - last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)', - last_checkin_at DATETIME NULL COMMENT '最后签到时间', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_user_id (user_id), - INDEX idx_level (level), - INDEX idx_total_exp (total_exp), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表'; - --- ===================================================== --- 2. 经验值历史表 (exp_history) --- 记录每次经验值变化的详细信息 --- ===================================================== -CREATE TABLE IF NOT EXISTS exp_history ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '用户ID', - exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)', - exp_type VARCHAR(50) NOT NULL COMMENT '类型:exam/practice/training/task/login/badge/other', - source_id INT NULL COMMENT '来源记录ID(如考试ID、练习ID等)', - description VARCHAR(255) NOT NULL COMMENT '描述', - level_before INT NULL COMMENT '变化前等级', - level_after INT NULL COMMENT '变化后等级', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - INDEX idx_user_id (user_id), - INDEX idx_exp_type (exp_type), - INDEX idx_created_at (created_at), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表'; - --- ===================================================== --- 3. 奖章定义表 (badge_definitions) --- 定义所有可获得的奖章及其解锁条件 --- ===================================================== -CREATE TABLE IF NOT EXISTS badge_definitions ( - id INT AUTO_INCREMENT PRIMARY KEY, - code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)', - name VARCHAR(100) NOT NULL COMMENT '奖章名称', - description VARCHAR(255) NOT NULL COMMENT '奖章描述', - icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称(Element Plus 图标)', - category VARCHAR(50) NOT NULL COMMENT '分类:learning/exam/practice/streak/special', - condition_type VARCHAR(50) NOT NULL COMMENT '条件类型:count/score/streak/level/duration', - condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)', - condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值', - exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值', - sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序', - is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY uk_code (code), - INDEX idx_category (category), - INDEX idx_is_active (is_active) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表'; - --- ===================================================== --- 4. 用户奖章表 (user_badges) --- 记录用户已解锁的奖章 --- ===================================================== -CREATE TABLE IF NOT EXISTS user_badges ( - id INT AUTO_INCREMENT PRIMARY KEY, - user_id INT NOT NULL COMMENT '用户ID', - badge_id INT NOT NULL COMMENT '奖章ID', - unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间', - is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户', - notified_at DATETIME NULL COMMENT '通知时间', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uk_user_badge (user_id, badge_id), - INDEX idx_user_id (user_id), - INDEX idx_badge_id (badge_id), - INDEX idx_unlocked_at (unlocked_at), - FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, - FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表'; - --- ===================================================== --- 5. 等级配置表 (level_configs) --- 定义每个等级所需的经验值和称号 --- ===================================================== -CREATE TABLE IF NOT EXISTS level_configs ( - id INT AUTO_INCREMENT PRIMARY KEY, - level INT NOT NULL COMMENT '等级', - exp_required INT NOT NULL COMMENT '升到此级所需经验值', - total_exp_required INT NOT NULL COMMENT '累计所需经验值', - title VARCHAR(50) NOT NULL COMMENT '等级称号', - color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)', - created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, - UNIQUE KEY uk_level (level) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表'; - --- ===================================================== --- 6. 插入等级配置数据 --- ===================================================== -INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES -(1, 0, 0, '初学者', '#909399'), -(2, 100, 100, '入门学徒', '#67C23A'), -(3, 200, 300, '勤奋学员', '#67C23A'), -(4, 400, 700, '进阶学员', '#409EFF'), -(5, 600, 1300, '优秀学员', '#409EFF'), -(6, 1000, 2300, '精英学员', '#E6A23C'), -(7, 1500, 3800, '资深学员', '#E6A23C'), -(8, 2000, 5800, '学习达人', '#F56C6C'), -(9, 3000, 8800, '学霸', '#F56C6C'), -(10, 5000, 13800, '大师', '#9B59B6'); - --- ===================================================== --- 7. 插入奖章定义数据 --- ===================================================== - --- 7.1 学习进度类奖章 -INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES -('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101), -('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102), -('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103), -('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104); - --- 7.2 考试成绩类奖章 -INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES -('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201), -('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202), -('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203), -('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204), -('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205); - --- 7.3 练习时长类奖章 -INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES -('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301), -('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302), -('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303), -('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304); - --- 7.4 连续打卡类奖章 -INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES -('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401), -('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402), -('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403), -('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404); - --- 7.5 特殊成就类奖章 -INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES -('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501), -('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502), -('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503), -('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504); - --- ===================================================== --- 8. 为现有用户初始化等级数据 --- ===================================================== -INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date) -SELECT - id as user_id, - 1 as level, - 0 as exp, - 0 as total_exp, - 0 as login_streak, - NULL as last_login_date -FROM users -WHERE is_deleted = 0 -ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; - --- 提交事务 -COMMIT; - --- ===================================================== --- 回滚脚本(如需回滚,执行以下语句) --- ===================================================== --- DROP TABLE IF EXISTS user_badges; --- DROP TABLE IF EXISTS badge_definitions; --- DROP TABLE IF EXISTS exp_history; --- DROP TABLE IF EXISTS level_configs; --- DROP TABLE IF EXISTS user_levels; +-- ===================================================== +-- 等级与奖章系统数据库迁移脚本 +-- 版本: 1.0.0 +-- 创建时间: 2026-01-29 +-- 说明: 添加用户等级系统和奖章系统相关表 +-- ===================================================== + +-- 使用事务确保原子性 +START TRANSACTION; + +-- ===================================================== +-- 1. 用户等级表 (user_levels) +-- 存储用户的等级和经验值信息 +-- ===================================================== +CREATE TABLE IF NOT EXISTS user_levels ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '用户ID', + level INT NOT NULL DEFAULT 1 COMMENT '当前等级', + exp INT NOT NULL DEFAULT 0 COMMENT '当前经验值', + total_exp INT NOT NULL DEFAULT 0 COMMENT '累计获得经验值', + login_streak INT NOT NULL DEFAULT 0 COMMENT '连续登录天数', + max_login_streak INT NOT NULL DEFAULT 0 COMMENT '历史最长连续登录天数', + last_login_date DATE NULL COMMENT '最后登录日期(用于计算连续登录)', + last_checkin_at DATETIME NULL COMMENT '最后签到时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_id (user_id), + INDEX idx_level (level), + INDEX idx_total_exp (total_exp), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户等级表'; + +-- ===================================================== +-- 2. 经验值历史表 (exp_history) +-- 记录每次经验值变化的详细信息 +-- ===================================================== +CREATE TABLE IF NOT EXISTS exp_history ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '用户ID', + exp_change INT NOT NULL COMMENT '经验值变化(正为获得,负为扣除)', + exp_type VARCHAR(50) NOT NULL COMMENT '类型:exam/practice/training/task/login/badge/other', + source_id INT NULL COMMENT '来源记录ID(如考试ID、练习ID等)', + description VARCHAR(255) NOT NULL COMMENT '描述', + level_before INT NULL COMMENT '变化前等级', + level_after INT NULL COMMENT '变化后等级', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + INDEX idx_user_id (user_id), + INDEX idx_exp_type (exp_type), + INDEX idx_created_at (created_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='经验值历史表'; + +-- ===================================================== +-- 3. 奖章定义表 (badge_definitions) +-- 定义所有可获得的奖章及其解锁条件 +-- ===================================================== +CREATE TABLE IF NOT EXISTS badge_definitions ( + id INT AUTO_INCREMENT PRIMARY KEY, + code VARCHAR(50) NOT NULL COMMENT '奖章编码(唯一标识)', + name VARCHAR(100) NOT NULL COMMENT '奖章名称', + description VARCHAR(255) NOT NULL COMMENT '奖章描述', + icon VARCHAR(100) NOT NULL DEFAULT 'Medal' COMMENT '图标名称(Element Plus 图标)', + category VARCHAR(50) NOT NULL COMMENT '分类:learning/exam/practice/streak/special', + condition_type VARCHAR(50) NOT NULL COMMENT '条件类型:count/score/streak/level/duration', + condition_field VARCHAR(100) NULL COMMENT '条件字段(用于复杂条件)', + condition_value INT NOT NULL DEFAULT 1 COMMENT '条件数值', + exp_reward INT NOT NULL DEFAULT 0 COMMENT '解锁奖励经验值', + sort_order INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + is_active TINYINT(1) NOT NULL DEFAULT 1 COMMENT '是否启用', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_code (code), + INDEX idx_category (category), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='奖章定义表'; + +-- ===================================================== +-- 4. 用户奖章表 (user_badges) +-- 记录用户已解锁的奖章 +-- ===================================================== +CREATE TABLE IF NOT EXISTS user_badges ( + id INT AUTO_INCREMENT PRIMARY KEY, + user_id INT NOT NULL COMMENT '用户ID', + badge_id INT NOT NULL COMMENT '奖章ID', + unlocked_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '解锁时间', + is_notified TINYINT(1) NOT NULL DEFAULT 0 COMMENT '是否已通知用户', + notified_at DATETIME NULL COMMENT '通知时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_user_badge (user_id, badge_id), + INDEX idx_user_id (user_id), + INDEX idx_badge_id (badge_id), + INDEX idx_unlocked_at (unlocked_at), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (badge_id) REFERENCES badge_definitions(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户奖章表'; + +-- ===================================================== +-- 5. 等级配置表 (level_configs) +-- 定义每个等级所需的经验值和称号 +-- ===================================================== +CREATE TABLE IF NOT EXISTS level_configs ( + id INT AUTO_INCREMENT PRIMARY KEY, + level INT NOT NULL COMMENT '等级', + exp_required INT NOT NULL COMMENT '升到此级所需经验值', + total_exp_required INT NOT NULL COMMENT '累计所需经验值', + title VARCHAR(50) NOT NULL COMMENT '等级称号', + color VARCHAR(20) NULL COMMENT '等级颜色(十六进制)', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY uk_level (level) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='等级配置表'; + +-- ===================================================== +-- 6. 插入等级配置数据 +-- ===================================================== +INSERT INTO level_configs (level, exp_required, total_exp_required, title, color) VALUES +(1, 0, 0, '初学者', '#909399'), +(2, 100, 100, '入门学徒', '#67C23A'), +(3, 200, 300, '勤奋学员', '#67C23A'), +(4, 400, 700, '进阶学员', '#409EFF'), +(5, 600, 1300, '优秀学员', '#409EFF'), +(6, 1000, 2300, '精英学员', '#E6A23C'), +(7, 1500, 3800, '资深学员', '#E6A23C'), +(8, 2000, 5800, '学习达人', '#F56C6C'), +(9, 3000, 8800, '学霸', '#F56C6C'), +(10, 5000, 13800, '大师', '#9B59B6'); + +-- ===================================================== +-- 7. 插入奖章定义数据 +-- ===================================================== + +-- 7.1 学习进度类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('first_login', '初来乍到', '首次登录系统', 'Star', 'learning', 'count', 'login_count', 1, 10, 101), +('course_1', '求知若渴', '完成1门课程学习', 'Reading', 'learning', 'count', 'course_completed', 1, 30, 102), +('course_5', '博学多才', '完成5门课程学习', 'Collection', 'learning', 'count', 'course_completed', 5, 100, 103), +('course_10', '学识渊博', '完成10门课程学习', 'Files', 'learning', 'count', 'course_completed', 10, 200, 104); + +-- 7.2 考试成绩类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('exam_pass_1', '初试牛刀', '通过1次考试', 'Select', 'exam', 'count', 'exam_passed', 1, 20, 201), +('exam_pass_10', '身经百战', '通过10次考试', 'Finished', 'exam', 'count', 'exam_passed', 10, 100, 202), +('exam_pass_50', '考试达人', '通过50次考试', 'Trophy', 'exam', 'count', 'exam_passed', 50, 300, 203), +('exam_perfect', '完美答卷', '考试获得满分', 'Medal', 'exam', 'score', 'exam_perfect_count', 1, 150, 204), +('exam_excellent_10', '学霸之路', '10次考试90分以上', 'TrendCharts', 'exam', 'count', 'exam_excellent', 10, 200, 205); + +-- 7.3 练习时长类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('practice_1h', '初窥门径', '累计练习1小时', 'Clock', 'practice', 'duration', 'practice_hours', 1, 30, 301), +('practice_10h', '勤学苦练', '累计练习10小时', 'Timer', 'practice', 'duration', 'practice_hours', 10, 100, 302), +('practice_50h', '炉火纯青', '累计练习50小时', 'Stopwatch', 'practice', 'duration', 'practice_hours', 50, 300, 303), +('practice_100', '练习狂人', '完成100次练习', 'Operation', 'practice', 'count', 'practice_count', 100, 200, 304); + +-- 7.4 连续打卡类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('streak_3', '小试身手', '连续登录3天', 'Calendar', 'streak', 'streak', 'login_streak', 3, 20, 401), +('streak_7', '坚持一周', '连续登录7天', 'Calendar', 'streak', 'streak', 'login_streak', 7, 50, 402), +('streak_30', '持之以恒', '连续登录30天', 'Calendar', 'streak', 'streak', 'login_streak', 30, 200, 403), +('streak_100', '百日不懈', '连续登录100天', 'Calendar', 'streak', 'streak', 'login_streak', 100, 500, 404); + +-- 7.5 特殊成就类奖章 +INSERT INTO badge_definitions (code, name, description, icon, category, condition_type, condition_field, condition_value, exp_reward, sort_order) VALUES +('level_5', '初露锋芒', '达到5级', 'Rank', 'special', 'level', 'user_level', 5, 100, 501), +('level_10', '登峰造极', '达到满级', 'Crown', 'special', 'level', 'user_level', 10, 500, 502), +('training_master', '陪练大师', '完成50次陪练', 'Headset', 'special', 'count', 'training_count', 50, 300, 503), +('first_perfect_practice', '首次完美', '陪练首次获得90分以上', 'StarFilled', 'special', 'score', 'first_practice_90', 1, 100, 504); + +-- ===================================================== +-- 8. 为现有用户初始化等级数据 +-- ===================================================== +INSERT INTO user_levels (user_id, level, exp, total_exp, login_streak, last_login_date) +SELECT + id as user_id, + 1 as level, + 0 as exp, + 0 as total_exp, + 0 as login_streak, + NULL as last_login_date +FROM users +WHERE is_deleted = 0 +ON DUPLICATE KEY UPDATE updated_at = CURRENT_TIMESTAMP; + +-- 提交事务 +COMMIT; + +-- ===================================================== +-- 回滚脚本(如需回滚,执行以下语句) +-- ===================================================== +-- DROP TABLE IF EXISTS user_badges; +-- DROP TABLE IF EXISTS badge_definitions; +-- DROP TABLE IF EXISTS exp_history; +-- DROP TABLE IF EXISTS level_configs; +-- DROP TABLE IF EXISTS user_levels; diff --git a/backend/migrations/versions/add_practice_rooms_table.sql b/backend/migrations/versions/add_practice_rooms_table.sql index dd26d62..79d8b6f 100644 --- a/backend/migrations/versions/add_practice_rooms_table.sql +++ b/backend/migrations/versions/add_practice_rooms_table.sql @@ -1,186 +1,186 @@ --- ============================================================================ --- 双人对练功能数据库迁移脚本 --- 版本: 2026-01-28 --- 功能: 新增对练房间表,扩展现有表支持多人对练 --- ============================================================================ - --- ============================================================================ --- 1. 创建对练房间表 practice_rooms --- ============================================================================ -CREATE TABLE IF NOT EXISTS `practice_rooms` ( - `id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '房间ID', - `room_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '6位邀请码', - `room_name` VARCHAR(200) COMMENT '房间名称', - - -- 场景信息 - `scene_id` INT COMMENT '关联场景ID', - `scene_name` VARCHAR(200) COMMENT '场景名称', - `scene_type` VARCHAR(50) COMMENT '场景类型', - `scene_background` TEXT COMMENT '场景背景', - - -- 角色设置 - `role_a_name` VARCHAR(50) DEFAULT '角色A' COMMENT '角色A名称(如销售顾问)', - `role_b_name` VARCHAR(50) DEFAULT '角色B' COMMENT '角色B名称(如顾客)', - `role_a_description` TEXT COMMENT '角色A描述', - `role_b_description` TEXT COMMENT '角色B描述', - - -- 参与者信息 - `host_user_id` INT NOT NULL COMMENT '房主用户ID', - `guest_user_id` INT COMMENT '加入者用户ID', - `host_role` VARCHAR(10) DEFAULT 'A' COMMENT '房主选择的角色(A/B)', - `max_participants` INT DEFAULT 2 COMMENT '最大参与人数', - - -- 状态和时间 - `status` VARCHAR(20) DEFAULT 'waiting' COMMENT '状态: waiting/ready/practicing/completed/canceled', - `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', - `started_at` DATETIME COMMENT '开始时间', - `ended_at` DATETIME COMMENT '结束时间', - `duration_seconds` INT DEFAULT 0 COMMENT '对练时长(秒)', - - -- 对话统计 - `total_turns` INT DEFAULT 0 COMMENT '总对话轮次', - `role_a_turns` INT DEFAULT 0 COMMENT '角色A发言次数', - `role_b_turns` INT DEFAULT 0 COMMENT '角色B发言次数', - - -- 软删除 - `is_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除', - `deleted_at` DATETIME COMMENT '删除时间', - - -- 索引 - INDEX `idx_room_code` (`room_code`), - INDEX `idx_host_user` (`host_user_id`), - INDEX `idx_guest_user` (`guest_user_id`), - INDEX `idx_status` (`status`), - INDEX `idx_created_at` (`created_at`), - - -- 外键(可选,根据实际需求决定是否启用) - -- FOREIGN KEY (`scene_id`) REFERENCES `practice_scenes`(`id`) ON DELETE SET NULL, - -- FOREIGN KEY (`host_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, - -- FOREIGN KEY (`guest_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL - - CONSTRAINT `chk_host_role` CHECK (`host_role` IN ('A', 'B')) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='双人对练房间表'; - - --- ============================================================================ --- 2. 扩展对话记录表 practice_dialogues --- ============================================================================ - --- 添加用户ID字段(区分说话人) -ALTER TABLE `practice_dialogues` -ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '说话人用户ID' AFTER `speaker`; - --- 添加角色名称字段 -ALTER TABLE `practice_dialogues` -ADD COLUMN IF NOT EXISTS `role_name` VARCHAR(50) COMMENT '角色名称(如销售顾问/顾客)' AFTER `user_id`; - --- 添加房间ID字段 -ALTER TABLE `practice_dialogues` -ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`; - --- 添加消息类型字段 -ALTER TABLE `practice_dialogues` -ADD COLUMN IF NOT EXISTS `message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text/voice/system' AFTER `content`; - --- 添加索引 -ALTER TABLE `practice_dialogues` -ADD INDEX IF NOT EXISTS `idx_user_id` (`user_id`), -ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); - - --- ============================================================================ --- 3. 扩展会话表 practice_sessions --- ============================================================================ - --- 添加房间ID字段 -ALTER TABLE `practice_sessions` -ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `scene_type`; - --- 添加参与者角色字段 -ALTER TABLE `practice_sessions` -ADD COLUMN IF NOT EXISTS `participant_role` VARCHAR(50) COMMENT '用户在房间中的角色' AFTER `room_id`; - --- 添加会话类型字段 -ALTER TABLE `practice_sessions` -ADD COLUMN IF NOT EXISTS `session_type` VARCHAR(20) DEFAULT 'solo' COMMENT '会话类型: solo/duo' AFTER `participant_role`; - --- 添加索引 -ALTER TABLE `practice_sessions` -ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); - - --- ============================================================================ --- 4. 扩展报告表 practice_reports(支持双人报告) --- ============================================================================ - --- 添加房间ID字段 -ALTER TABLE `practice_reports` -ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`; - --- 添加用户ID字段(双人模式下每人一份报告) -ALTER TABLE `practice_reports` -ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '用户ID' AFTER `room_id`; - --- 添加报告类型字段 -ALTER TABLE `practice_reports` -ADD COLUMN IF NOT EXISTS `report_type` VARCHAR(20) DEFAULT 'solo' COMMENT '报告类型: solo/duo' AFTER `user_id`; - --- 添加对方评价字段(双人模式) -ALTER TABLE `practice_reports` -ADD COLUMN IF NOT EXISTS `partner_feedback` JSON COMMENT '对方表现评价' AFTER `suggestions`; - --- 添加互动质量评分 -ALTER TABLE `practice_reports` -ADD COLUMN IF NOT EXISTS `interaction_score` INT COMMENT '互动质量评分(0-100)' AFTER `partner_feedback`; - --- 修改唯一索引(允许同一session有多个报告) --- 注意:需要先删除旧的唯一索引 --- ALTER TABLE `practice_reports` DROP INDEX `session_id`; --- ALTER TABLE `practice_reports` ADD UNIQUE INDEX `idx_session_user` (`session_id`, `user_id`); - - --- ============================================================================ --- 5. 创建房间消息表(用于实时同步) --- ============================================================================ -CREATE TABLE IF NOT EXISTS `practice_room_messages` ( - `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', - `room_id` INT NOT NULL COMMENT '房间ID', - `user_id` INT COMMENT '发送者用户ID(系统消息为NULL)', - `message_type` VARCHAR(20) NOT NULL COMMENT '消息类型: chat/system/join/leave/start/end', - `content` TEXT COMMENT '消息内容', - `role_name` VARCHAR(50) COMMENT '角色名称', - `sequence` INT NOT NULL COMMENT '消息序号', - `created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒精度)', - - INDEX `idx_room_id` (`room_id`), - INDEX `idx_room_sequence` (`room_id`, `sequence`), - INDEX `idx_created_at` (`created_at`) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间实时消息表'; - - --- ============================================================================ --- 回滚脚本(如需回滚,执行以下语句) --- ============================================================================ -/* --- 删除新增的表 -DROP TABLE IF EXISTS `practice_room_messages`; -DROP TABLE IF EXISTS `practice_rooms`; - --- 删除 practice_dialogues 新增的列 -ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `user_id`; -ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `role_name`; -ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `room_id`; -ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `message_type`; - --- 删除 practice_sessions 新增的列 -ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `room_id`; -ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `participant_role`; -ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `session_type`; - --- 删除 practice_reports 新增的列 -ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `room_id`; -ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `user_id`; -ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `report_type`; -ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `partner_feedback`; -ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `interaction_score`; -*/ +-- ============================================================================ +-- 双人对练功能数据库迁移脚本 +-- 版本: 2026-01-28 +-- 功能: 新增对练房间表,扩展现有表支持多人对练 +-- ============================================================================ + +-- ============================================================================ +-- 1. 创建对练房间表 practice_rooms +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `practice_rooms` ( + `id` INT AUTO_INCREMENT PRIMARY KEY COMMENT '房间ID', + `room_code` VARCHAR(10) NOT NULL UNIQUE COMMENT '6位邀请码', + `room_name` VARCHAR(200) COMMENT '房间名称', + + -- 场景信息 + `scene_id` INT COMMENT '关联场景ID', + `scene_name` VARCHAR(200) COMMENT '场景名称', + `scene_type` VARCHAR(50) COMMENT '场景类型', + `scene_background` TEXT COMMENT '场景背景', + + -- 角色设置 + `role_a_name` VARCHAR(50) DEFAULT '角色A' COMMENT '角色A名称(如销售顾问)', + `role_b_name` VARCHAR(50) DEFAULT '角色B' COMMENT '角色B名称(如顾客)', + `role_a_description` TEXT COMMENT '角色A描述', + `role_b_description` TEXT COMMENT '角色B描述', + + -- 参与者信息 + `host_user_id` INT NOT NULL COMMENT '房主用户ID', + `guest_user_id` INT COMMENT '加入者用户ID', + `host_role` VARCHAR(10) DEFAULT 'A' COMMENT '房主选择的角色(A/B)', + `max_participants` INT DEFAULT 2 COMMENT '最大参与人数', + + -- 状态和时间 + `status` VARCHAR(20) DEFAULT 'waiting' COMMENT '状态: waiting/ready/practicing/completed/canceled', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `started_at` DATETIME COMMENT '开始时间', + `ended_at` DATETIME COMMENT '结束时间', + `duration_seconds` INT DEFAULT 0 COMMENT '对练时长(秒)', + + -- 对话统计 + `total_turns` INT DEFAULT 0 COMMENT '总对话轮次', + `role_a_turns` INT DEFAULT 0 COMMENT '角色A发言次数', + `role_b_turns` INT DEFAULT 0 COMMENT '角色B发言次数', + + -- 软删除 + `is_deleted` TINYINT(1) DEFAULT 0 COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + + -- 索引 + INDEX `idx_room_code` (`room_code`), + INDEX `idx_host_user` (`host_user_id`), + INDEX `idx_guest_user` (`guest_user_id`), + INDEX `idx_status` (`status`), + INDEX `idx_created_at` (`created_at`), + + -- 外键(可选,根据实际需求决定是否启用) + -- FOREIGN KEY (`scene_id`) REFERENCES `practice_scenes`(`id`) ON DELETE SET NULL, + -- FOREIGN KEY (`host_user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE, + -- FOREIGN KEY (`guest_user_id`) REFERENCES `users`(`id`) ON DELETE SET NULL + + CONSTRAINT `chk_host_role` CHECK (`host_role` IN ('A', 'B')) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='双人对练房间表'; + + +-- ============================================================================ +-- 2. 扩展对话记录表 practice_dialogues +-- ============================================================================ + +-- 添加用户ID字段(区分说话人) +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '说话人用户ID' AFTER `speaker`; + +-- 添加角色名称字段 +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `role_name` VARCHAR(50) COMMENT '角色名称(如销售顾问/顾客)' AFTER `user_id`; + +-- 添加房间ID字段 +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`; + +-- 添加消息类型字段 +ALTER TABLE `practice_dialogues` +ADD COLUMN IF NOT EXISTS `message_type` VARCHAR(20) DEFAULT 'text' COMMENT '消息类型: text/voice/system' AFTER `content`; + +-- 添加索引 +ALTER TABLE `practice_dialogues` +ADD INDEX IF NOT EXISTS `idx_user_id` (`user_id`), +ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); + + +-- ============================================================================ +-- 3. 扩展会话表 practice_sessions +-- ============================================================================ + +-- 添加房间ID字段 +ALTER TABLE `practice_sessions` +ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `scene_type`; + +-- 添加参与者角色字段 +ALTER TABLE `practice_sessions` +ADD COLUMN IF NOT EXISTS `participant_role` VARCHAR(50) COMMENT '用户在房间中的角色' AFTER `room_id`; + +-- 添加会话类型字段 +ALTER TABLE `practice_sessions` +ADD COLUMN IF NOT EXISTS `session_type` VARCHAR(20) DEFAULT 'solo' COMMENT '会话类型: solo/duo' AFTER `participant_role`; + +-- 添加索引 +ALTER TABLE `practice_sessions` +ADD INDEX IF NOT EXISTS `idx_room_id` (`room_id`); + + +-- ============================================================================ +-- 4. 扩展报告表 practice_reports(支持双人报告) +-- ============================================================================ + +-- 添加房间ID字段 +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `room_id` INT COMMENT '房间ID(双人对练时使用)' AFTER `session_id`; + +-- 添加用户ID字段(双人模式下每人一份报告) +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `user_id` INT COMMENT '用户ID' AFTER `room_id`; + +-- 添加报告类型字段 +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `report_type` VARCHAR(20) DEFAULT 'solo' COMMENT '报告类型: solo/duo' AFTER `user_id`; + +-- 添加对方评价字段(双人模式) +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `partner_feedback` JSON COMMENT '对方表现评价' AFTER `suggestions`; + +-- 添加互动质量评分 +ALTER TABLE `practice_reports` +ADD COLUMN IF NOT EXISTS `interaction_score` INT COMMENT '互动质量评分(0-100)' AFTER `partner_feedback`; + +-- 修改唯一索引(允许同一session有多个报告) +-- 注意:需要先删除旧的唯一索引 +-- ALTER TABLE `practice_reports` DROP INDEX `session_id`; +-- ALTER TABLE `practice_reports` ADD UNIQUE INDEX `idx_session_user` (`session_id`, `user_id`); + + +-- ============================================================================ +-- 5. 创建房间消息表(用于实时同步) +-- ============================================================================ +CREATE TABLE IF NOT EXISTS `practice_room_messages` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY COMMENT '消息ID', + `room_id` INT NOT NULL COMMENT '房间ID', + `user_id` INT COMMENT '发送者用户ID(系统消息为NULL)', + `message_type` VARCHAR(20) NOT NULL COMMENT '消息类型: chat/system/join/leave/start/end', + `content` TEXT COMMENT '消息内容', + `role_name` VARCHAR(50) COMMENT '角色名称', + `sequence` INT NOT NULL COMMENT '消息序号', + `created_at` DATETIME(3) DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间(毫秒精度)', + + INDEX `idx_room_id` (`room_id`), + INDEX `idx_room_sequence` (`room_id`, `sequence`), + INDEX `idx_created_at` (`created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='房间实时消息表'; + + +-- ============================================================================ +-- 回滚脚本(如需回滚,执行以下语句) +-- ============================================================================ +/* +-- 删除新增的表 +DROP TABLE IF EXISTS `practice_room_messages`; +DROP TABLE IF EXISTS `practice_rooms`; + +-- 删除 practice_dialogues 新增的列 +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `user_id`; +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `role_name`; +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `room_id`; +ALTER TABLE `practice_dialogues` DROP COLUMN IF EXISTS `message_type`; + +-- 删除 practice_sessions 新增的列 +ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `room_id`; +ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `participant_role`; +ALTER TABLE `practice_sessions` DROP COLUMN IF EXISTS `session_type`; + +-- 删除 practice_reports 新增的列 +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `room_id`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `user_id`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `report_type`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `partner_feedback`; +ALTER TABLE `practice_reports` DROP COLUMN IF EXISTS `interaction_score`; +*/ diff --git a/backend/requirements.txt b/backend/requirements.txt index a5a5d8d..d551780 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -53,4 +53,9 @@ jsonschema>=4.0.0 # PDF 文档提取 PyPDF2>=3.0.0 -python-docx>=1.0.0 \ No newline at end of file +python-docx>=1.0.0 + +# 证书生成 +Pillow>=10.0.0 +qrcode>=7.4.0 +weasyprint>=60.0 \ No newline at end of file diff --git a/docs/测试环境配置.md b/docs/测试环境配置.md index 7f1dbb1..753da08 100644 --- a/docs/测试环境配置.md +++ b/docs/测试环境配置.md @@ -1,217 +1,217 @@ -# 考培练系统 - 环境配置与部署指南 - -> 最后更新:2026-01-28 - -## 一、环境总览 - -| 环境 | 分支 | 域名 | dist 目录 | 用途 | -|------|------|------|-----------|------| -| **测试环境** | `test` | https://kpl.ireborn.com.cn | dist-test | 功能开发测试 | -| **预生产** | `staging` | https://aiedu.ireborn.com.cn | dist-staging | 集成测试/预发布 | -| **生产环境** | `main` | 各租户域名 | dist-prod | 正式生产 | -| **超级管理后台** | - | https://admin.kpl.ireborn.com.cn | - | SaaS管理 | - ---- - -## 二、租户列表 - -| 租户代码 | 名称 | 域名 | 前端端口 | 后端端口 | -|----------|------|------|----------|----------| -| hua | 华尔倍丽 | https://hua.ireborn.com.cn | 3010 | 8010 | -| yy | 杨扬宠物 | https://yy.ireborn.com.cn | 3011 | 8011 | -| hl | 武汉禾丽 | https://hl.ireborn.com.cn | 3012 | 8012 | -| xy | 芯颜定制 | https://xy.ireborn.com.cn | 3013 | 8013 | -| fw | 飞沃 | https://fw.ireborn.com.cn | 3014 | 8014 | -| ex | 恩喜成都总院 | https://ex.ireborn.com.cn | 3015 | 8015 | -| cxw | 崔曦文 | https://cxw.kpl.ireborn.com.cn | 3016 | 8016 | - ---- - -## 三、CI/CD 部署方式 - -### 3.1 开发1环境 (kpl-dev) - -```bash -# 推送到 test 分支自动部署 -git push cicd test -``` - -- **触发条件**:`test` 分支 push -- **部署目标**:kpl-dev 容器组 -- **访问地址**:https://kpl.ireborn.com.cn - ---- - -### 3.2 开发2环境 (主站) - -```bash -# 推送到 dev2 分支自动部署 -git push cicd dev2 -``` - -- **触发条件**:`dev2` 分支 push -- **部署目标**:kaopeilian 主站容器 -- **访问地址**:https://aiedu.ireborn.com.cn - ---- - -### 3.3 生产环境 (租户) - -```bash -# 推送到 main 分支,通过 commit message 控制部署范围 -git push cicd main -``` - -#### 部署所有租户 - -```bash -git commit -m "feat: 新功能上线 [all]" -git push cicd main -``` - -#### 部署单个租户 - -```bash -git commit -m "fix: 修复问题 [hua]" -git push cicd main -``` - -#### 部署多个租户 - -```bash -git commit -m "feat: 功能更新 [cxw,yy,hl]" -git push cicd main -``` - -#### 默认行为 - -```bash -# 不带标签默认部署所有租户 -git commit -m "feat: 常规更新" -git push cicd main -``` - ---- - -## 四、手动部署命令 - -### 4.1 SSH 登录服务器 - -```bash -ssh root@120.79.247.16 -# 密码: Rxm88808 -``` - -### 4.2 重启单个租户后端 - -```bash -cd /root/aiedu -docker restart cxw-backend -``` - -### 4.3 重建单个租户后端 - -```bash -cd /root/aiedu -docker-compose -f docker-compose.prod-multi.yml up -d --build --no-deps cxw-backend -``` - -### 4.4 查看日志 - -```bash -docker logs -f cxw-backend --tail 100 -``` - -### 4.5 重新编译前端(所有租户共享) - -```bash -cd /root/aiedu/kaopeilian-frontend -npm run build -``` - ---- - -## 五、数据库连接 - -### 5.1 生产共享 MySQL (prod-mysql) - -- **端口**:3309 -- **用户**:root -- **密码**:ProdMySQL2025!@# -- **数据库**:kaopeilian_hua, kaopeilian_yy, kaopeilian_hl, kaopeilian_xy, kaopeilian_fw, kaopeilian_ex, kaopeilian_cxw - -### 5.2 开发测试 MySQL (kpl-mysql-dev) - -- **端口**:3308 -- **用户**:root -- **密码**:nj861021 -- **数据库**:kaopeilian - -### 5.3 主站 MySQL (kaopeilian-mysql) - -- **端口**:3307 -- **用户**:root -- **密码**:nj861021 -- **数据库**:kaopeilian - ---- - -## 六、容器管理 - -### 当前运行容器统计 - -| 类型 | 数量 | -|------|------| -| 前端容器 | 11 | -| 后端容器 | 11 | -| Redis | 10 | -| MySQL | 4 | -| Nginx | 1 | -| **总计** | **37** | - -### 查看所有容器 - -```bash -docker ps --format 'table {{.Names}}\t{{.Status}}' -``` - ---- - -## 七、测试账户 - -| 角色 | 用户名 | 密码 | -|------|--------|------| -| 系统管理员 | admin | Admin123! | -| 培训经理 | manager | Admin123! | -| 测试学员 | testuser | Admin123! | - ---- - -## 八、注意事项 - -1. **前端共享**:所有生产租户共享同一套前端代码,编译一次全部更新 -2. **后端独立**:每个租户有独立的后端容器和数据库 -3. **域名解析**: - - `*.ireborn.com.cn` 解析到 120.79.242.43(SCRM服务器) - - `*.kpl.ireborn.com.cn` 解析到 120.79.247.16(考培练服务器) -4. **SSL证书**:使用 Let's Encrypt,自动续期 - ---- - -## 九、Git 仓库配置 - -```bash -# 查看远程仓库 -git remote -v - -# origin: GitHub 源代码仓库 -# cicd: Gitea CI/CD 触发仓库 - -# 常规开发 -git push origin main - -# 触发部署 -git push cicd test # 部署开发1 -git push cicd dev2 # 部署开发2 -git push cicd main # 部署生产 -``` +# 考培练系统 - 环境配置与部署指南 + +> 最后更新:2026-01-28 + +## 一、环境总览 + +| 环境 | 分支 | 域名 | dist 目录 | 用途 | +|------|------|------|-----------|------| +| **测试环境** | `test` | https://kpl.ireborn.com.cn | dist-test | 功能开发测试 | +| **预生产** | `staging` | https://aiedu.ireborn.com.cn | dist-staging | 集成测试/预发布 | +| **生产环境** | `main` | 各租户域名 | dist-prod | 正式生产 | +| **超级管理后台** | - | https://admin.kpl.ireborn.com.cn | - | SaaS管理 | + +--- + +## 二、租户列表 + +| 租户代码 | 名称 | 域名 | 前端端口 | 后端端口 | +|----------|------|------|----------|----------| +| hua | 华尔倍丽 | https://hua.ireborn.com.cn | 3010 | 8010 | +| yy | 杨扬宠物 | https://yy.ireborn.com.cn | 3011 | 8011 | +| hl | 武汉禾丽 | https://hl.ireborn.com.cn | 3012 | 8012 | +| xy | 芯颜定制 | https://xy.ireborn.com.cn | 3013 | 8013 | +| fw | 飞沃 | https://fw.ireborn.com.cn | 3014 | 8014 | +| ex | 恩喜成都总院 | https://ex.ireborn.com.cn | 3015 | 8015 | +| cxw | 崔曦文 | https://cxw.kpl.ireborn.com.cn | 3016 | 8016 | + +--- + +## 三、CI/CD 部署方式 + +### 3.1 开发1环境 (kpl-dev) + +```bash +# 推送到 test 分支自动部署 +git push cicd test +``` + +- **触发条件**:`test` 分支 push +- **部署目标**:kpl-dev 容器组 +- **访问地址**:https://kpl.ireborn.com.cn + +--- + +### 3.2 开发2环境 (主站) + +```bash +# 推送到 dev2 分支自动部署 +git push cicd dev2 +``` + +- **触发条件**:`dev2` 分支 push +- **部署目标**:kaopeilian 主站容器 +- **访问地址**:https://aiedu.ireborn.com.cn + +--- + +### 3.3 生产环境 (租户) + +```bash +# 推送到 main 分支,通过 commit message 控制部署范围 +git push cicd main +``` + +#### 部署所有租户 + +```bash +git commit -m "feat: 新功能上线 [all]" +git push cicd main +``` + +#### 部署单个租户 + +```bash +git commit -m "fix: 修复问题 [hua]" +git push cicd main +``` + +#### 部署多个租户 + +```bash +git commit -m "feat: 功能更新 [cxw,yy,hl]" +git push cicd main +``` + +#### 默认行为 + +```bash +# 不带标签默认部署所有租户 +git commit -m "feat: 常规更新" +git push cicd main +``` + +--- + +## 四、手动部署命令 + +### 4.1 SSH 登录服务器 + +```bash +ssh root@120.79.247.16 +# 密码: Rxm88808 +``` + +### 4.2 重启单个租户后端 + +```bash +cd /root/aiedu +docker restart cxw-backend +``` + +### 4.3 重建单个租户后端 + +```bash +cd /root/aiedu +docker-compose -f docker-compose.prod-multi.yml up -d --build --no-deps cxw-backend +``` + +### 4.4 查看日志 + +```bash +docker logs -f cxw-backend --tail 100 +``` + +### 4.5 重新编译前端(所有租户共享) + +```bash +cd /root/aiedu/kaopeilian-frontend +npm run build +``` + +--- + +## 五、数据库连接 + +### 5.1 生产共享 MySQL (prod-mysql) + +- **端口**:3309 +- **用户**:root +- **密码**:ProdMySQL2025!@# +- **数据库**:kaopeilian_hua, kaopeilian_yy, kaopeilian_hl, kaopeilian_xy, kaopeilian_fw, kaopeilian_ex, kaopeilian_cxw + +### 5.2 开发测试 MySQL (kpl-mysql-dev) + +- **端口**:3308 +- **用户**:root +- **密码**:nj861021 +- **数据库**:kaopeilian + +### 5.3 主站 MySQL (kaopeilian-mysql) + +- **端口**:3307 +- **用户**:root +- **密码**:nj861021 +- **数据库**:kaopeilian + +--- + +## 六、容器管理 + +### 当前运行容器统计 + +| 类型 | 数量 | +|------|------| +| 前端容器 | 11 | +| 后端容器 | 11 | +| Redis | 10 | +| MySQL | 4 | +| Nginx | 1 | +| **总计** | **37** | + +### 查看所有容器 + +```bash +docker ps --format 'table {{.Names}}\t{{.Status}}' +``` + +--- + +## 七、测试账户 + +| 角色 | 用户名 | 密码 | +|------|--------|------| +| 系统管理员 | admin | Admin123! | +| 培训经理 | manager | Admin123! | +| 测试学员 | testuser | Admin123! | + +--- + +## 八、注意事项 + +1. **前端共享**:所有生产租户共享同一套前端代码,编译一次全部更新 +2. **后端独立**:每个租户有独立的后端容器和数据库 +3. **域名解析**: + - `*.ireborn.com.cn` 解析到 120.79.242.43(SCRM服务器) + - `*.kpl.ireborn.com.cn` 解析到 120.79.247.16(考培练服务器) +4. **SSL证书**:使用 Let's Encrypt,自动续期 + +--- + +## 九、Git 仓库配置 + +```bash +# 查看远程仓库 +git remote -v + +# origin: GitHub 源代码仓库 +# cicd: Gitea CI/CD 触发仓库 + +# 常规开发 +git push origin main + +# 触发部署 +git push cicd test # 部署开发1 +git push cicd dev2 # 部署开发2 +git push cicd main # 部署生产 +``` diff --git a/frontend/src/api/certificate.ts b/frontend/src/api/certificate.ts index d4617e8..44468ba 100644 --- a/frontend/src/api/certificate.ts +++ b/frontend/src/api/certificate.ts @@ -1,149 +1,149 @@ -/** - * 证书系统 API - */ -import request from '@/api/request' - -// 证书类型 -export type CertificateType = 'course' | 'exam' | 'achievement' - -// 证书模板 -export interface CertificateTemplate { - id: number - name: string - type: CertificateType - background_url?: string - is_active: boolean -} - -// 证书信息 -export interface Certificate { - id: number - certificate_no: string - title: string - description?: string - type: CertificateType - type_name: string - issued_at: string - valid_until?: string - score?: number - completion_rate?: number - pdf_url?: string - image_url?: string - course_id?: number - exam_id?: number - badge_id?: number - meta_data?: Record - template?: { - id: number - name: string - background_url?: string - } - user?: { - id: number - username: string - full_name?: string - } -} - -// 证书列表响应 -export interface CertificateListResponse { - items: Certificate[] - total: number - offset: number - limit: number -} - -// 验证结果 -export interface VerifyResult { - valid: boolean - certificate_no: string - title?: string - type_name?: string - issued_at?: string - user?: { - id: number - username: string - full_name?: string - } -} - -/** - * 获取证书模板列表 - */ -export function getCertificateTemplates(type?: CertificateType) { - return request.get('/certificates/templates', { - params: { cert_type: type } - }) -} - -/** - * 获取我的证书列表 - */ -export function getMyCertificates(params?: { - cert_type?: CertificateType - offset?: number - limit?: number -}) { - return request.get('/certificates/me', { params }) -} - -/** - * 获取指定用户的证书列表 - */ -export function getUserCertificates(userId: number, params?: { - cert_type?: CertificateType - offset?: number - limit?: number -}) { - return request.get(`/certificates/user/${userId}`, { params }) -} - -/** - * 获取证书详情 - */ -export function getCertificateDetail(certId: number) { - return request.get(`/certificates/${certId}`) -} - -/** - * 获取证书分享图片URL - */ -export function getCertificateImageUrl(certId: number): string { - return `/api/v1/certificates/${certId}/image` -} - -/** - * 获取证书下载URL - */ -export function getCertificateDownloadUrl(certId: number): string { - return `/api/v1/certificates/${certId}/download` -} - -/** - * 验证证书 - */ -export function verifyCertificate(certNo: string) { - return request.get(`/certificates/verify/${certNo}`) -} - -/** - * 颁发课程证书 - */ -export function issueCoursCertificate(data: { - course_id: number - course_name: string - completion_rate?: number -}) { - return request.post('/certificates/issue/course', data) -} - -/** - * 颁发考试证书 - */ -export function issueExamCertificate(data: { - exam_id: number - exam_name: string - score: number -}) { - return request.post('/certificates/issue/exam', data) -} +/** + * 证书系统 API + */ +import request from '@/api/request' + +// 证书类型 +export type CertificateType = 'course' | 'exam' | 'achievement' + +// 证书模板 +export interface CertificateTemplate { + id: number + name: string + type: CertificateType + background_url?: string + is_active: boolean +} + +// 证书信息 +export interface Certificate { + id: number + certificate_no: string + title: string + description?: string + type: CertificateType + type_name: string + issued_at: string + valid_until?: string + score?: number + completion_rate?: number + pdf_url?: string + image_url?: string + course_id?: number + exam_id?: number + badge_id?: number + meta_data?: Record + template?: { + id: number + name: string + background_url?: string + } + user?: { + id: number + username: string + full_name?: string + } +} + +// 证书列表响应 +export interface CertificateListResponse { + items: Certificate[] + total: number + offset: number + limit: number +} + +// 验证结果 +export interface VerifyResult { + valid: boolean + certificate_no: string + title?: string + type_name?: string + issued_at?: string + user?: { + id: number + username: string + full_name?: string + } +} + +/** + * 获取证书模板列表 + */ +export function getCertificateTemplates(type?: CertificateType) { + return request.get('/certificates/templates', { + params: { cert_type: type } + }) +} + +/** + * 获取我的证书列表 + */ +export function getMyCertificates(params?: { + cert_type?: CertificateType + offset?: number + limit?: number +}) { + return request.get('/certificates/me', { params }) +} + +/** + * 获取指定用户的证书列表 + */ +export function getUserCertificates(userId: number, params?: { + cert_type?: CertificateType + offset?: number + limit?: number +}) { + return request.get(`/certificates/user/${userId}`, { params }) +} + +/** + * 获取证书详情 + */ +export function getCertificateDetail(certId: number) { + return request.get(`/certificates/${certId}`) +} + +/** + * 获取证书分享图片URL + */ +export function getCertificateImageUrl(certId: number): string { + return `/api/v1/certificates/${certId}/image` +} + +/** + * 获取证书下载URL + */ +export function getCertificateDownloadUrl(certId: number): string { + return `/api/v1/certificates/${certId}/download` +} + +/** + * 验证证书 + */ +export function verifyCertificate(certNo: string) { + return request.get(`/certificates/verify/${certNo}`) +} + +/** + * 颁发课程证书 + */ +export function issueCoursCertificate(data: { + course_id: number + course_name: string + completion_rate?: number +}) { + return request.post('/certificates/issue/course', data) +} + +/** + * 颁发考试证书 + */ +export function issueExamCertificate(data: { + exam_id: number + exam_name: string + score: number +}) { + return request.post('/certificates/issue/exam', data) +} diff --git a/frontend/src/api/dashboard.ts b/frontend/src/api/dashboard.ts index d7f3831..0e4d79c 100644 --- a/frontend/src/api/dashboard.ts +++ b/frontend/src/api/dashboard.ts @@ -175,4 +175,4 @@ export function getTeamDashboard() { */ export function getFullDashboardData() { return request.get('/dashboard/all') -} +} \ No newline at end of file diff --git a/frontend/src/api/duoPractice.ts b/frontend/src/api/duoPractice.ts index 660c35f..17ad0a7 100644 --- a/frontend/src/api/duoPractice.ts +++ b/frontend/src/api/duoPractice.ts @@ -1,222 +1,222 @@ -/** - * 双人对练 API - */ -import request from '@/api/request' - -// ==================== 类型定义 ==================== - -export interface CreateRoomRequest { - scene_id?: number - scene_name?: string - scene_type?: string - scene_background?: string - role_a_name?: string - role_b_name?: string - role_a_description?: string - role_b_description?: string - host_role?: 'A' | 'B' - room_name?: string -} - -export interface CreateRoomResponse { - room_code: string - room_id: number - room_name: string - my_role: string - my_role_name: string -} - -export interface JoinRoomResponse { - room_code: string - room_id: number - room_name: string - status: string - my_role: string - my_role_name: string -} - -export interface RoomUser { - id: number - username: string - full_name: string - avatar_url?: string -} - -export interface RoomInfo { - id: number - room_code: string - room_name?: string - scene_id?: number - scene_name?: string - scene_type?: string - scene_background?: string - role_a_name: string - role_b_name: string - role_a_description?: string - role_b_description?: string - host_role: string - status: string - created_at?: string - started_at?: string - ended_at?: string - duration_seconds: number - total_turns: number - role_a_turns: number - role_b_turns: number -} - -export interface RoomDetailResponse { - room: RoomInfo - host_user?: RoomUser - guest_user?: RoomUser - host_role_name?: string - guest_role_name?: string - my_role?: string - my_role_name?: string - is_host: boolean -} - -export type MessageType = - | 'chat' - | 'system' - | 'join' - | 'leave' - | 'start' - | 'end' - | 'voice_start' - | 'voice_offer' - | 'voice_answer' - | 'ice_candidate' - | 'voice_end' - -export interface RoomMessage { - id: number - room_id: number - user_id?: number - message_type: MessageType - content?: string - role_name?: string - sequence: number - created_at: string -} - -export interface WebRTCSignalRequest { - signal_type: 'voice_start' | 'voice_offer' | 'voice_answer' | 'ice_candidate' | 'voice_end' - payload: Record -} - -export interface MessagesResponse { - messages: RoomMessage[] - room_status: string - last_sequence: number -} - -export interface RoomListItem { - id: number - room_code: string - room_name?: string - scene_name?: string - status: string - is_host: boolean - created_at?: string - duration_seconds: number - total_turns: number -} - -// ==================== API 函数 ==================== - -/** - * 创建房间 - */ -export function createRoom(data: CreateRoomRequest) { - return request.post('/api/v1/practice/rooms', data) -} - -/** - * 加入房间 - */ -export function joinRoom(roomCode: string) { - return request.post('/api/v1/practice/rooms/join', { - room_code: roomCode - }) -} - -/** - * 获取房间详情 - */ -export function getRoomDetail(roomCode: string) { - return request.get(`/api/v1/practice/rooms/${roomCode}`) -} - -/** - * 开始对练 - */ -export function startPractice(roomCode: string) { - return request.post(`/api/v1/practice/rooms/${roomCode}/start`) -} - -/** - * 结束对练 - */ -export function endPractice(roomCode: string) { - return request.post(`/api/v1/practice/rooms/${roomCode}/end`) -} - -/** - * 离开房间 - */ -export function leaveRoom(roomCode: string) { - return request.post(`/api/v1/practice/rooms/${roomCode}/leave`) -} - -/** - * 发送消息 - */ -export function sendMessage(roomCode: string, content: string) { - return request.post(`/api/v1/practice/rooms/${roomCode}/message`, { - content - }) -} - -/** - * 获取消息列表 - */ -export function getMessages(roomCode: string, sinceSequence: number = 0) { - return request.get(`/api/v1/practice/rooms/${roomCode}/messages`, { - params: { since_sequence: sinceSequence } - }) -} - -/** - * 获取我的房间列表 - */ -export function getMyRooms(status?: string, limit: number = 20) { - return request.get<{ rooms: RoomListItem[] }>('/api/v1/practice/rooms', { - params: { status, limit } - }) -} - -/** - * 生成分享链接 - */ -export function generateShareLink(roomCode: string): string { - const baseUrl = window.location.origin - return `${baseUrl}/trainee/duo-practice/join/${roomCode}` -} - -/** - * 发送 WebRTC 信令 - */ -export function sendSignal(roomCode: string, signalType: string, payload: Record) { - return request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { - signal_type: signalType, - payload - }) -} - -/** - * 获取对练报告 - */ -export function getPracticeReport(roomCode: string) { - return request.get(`/api/v1/practice/rooms/${roomCode}/report`) -} +/** + * 双人对练 API + */ +import request from '@/api/request' + +// ==================== 类型定义 ==================== + +export interface CreateRoomRequest { + scene_id?: number + scene_name?: string + scene_type?: string + scene_background?: string + role_a_name?: string + role_b_name?: string + role_a_description?: string + role_b_description?: string + host_role?: 'A' | 'B' + room_name?: string +} + +export interface CreateRoomResponse { + room_code: string + room_id: number + room_name: string + my_role: string + my_role_name: string +} + +export interface JoinRoomResponse { + room_code: string + room_id: number + room_name: string + status: string + my_role: string + my_role_name: string +} + +export interface RoomUser { + id: number + username: string + full_name: string + avatar_url?: string +} + +export interface RoomInfo { + id: number + room_code: string + room_name?: string + scene_id?: number + scene_name?: string + scene_type?: string + scene_background?: string + role_a_name: string + role_b_name: string + role_a_description?: string + role_b_description?: string + host_role: string + status: string + created_at?: string + started_at?: string + ended_at?: string + duration_seconds: number + total_turns: number + role_a_turns: number + role_b_turns: number +} + +export interface RoomDetailResponse { + room: RoomInfo + host_user?: RoomUser + guest_user?: RoomUser + host_role_name?: string + guest_role_name?: string + my_role?: string + my_role_name?: string + is_host: boolean +} + +export type MessageType = + | 'chat' + | 'system' + | 'join' + | 'leave' + | 'start' + | 'end' + | 'voice_start' + | 'voice_offer' + | 'voice_answer' + | 'ice_candidate' + | 'voice_end' + +export interface RoomMessage { + id: number + room_id: number + user_id?: number + message_type: MessageType + content?: string + role_name?: string + sequence: number + created_at: string +} + +export interface WebRTCSignalRequest { + signal_type: 'voice_start' | 'voice_offer' | 'voice_answer' | 'ice_candidate' | 'voice_end' + payload: Record +} + +export interface MessagesResponse { + messages: RoomMessage[] + room_status: string + last_sequence: number +} + +export interface RoomListItem { + id: number + room_code: string + room_name?: string + scene_name?: string + status: string + is_host: boolean + created_at?: string + duration_seconds: number + total_turns: number +} + +// ==================== API 函数 ==================== + +/** + * 创建房间 + */ +export function createRoom(data: CreateRoomRequest) { + return request.post('/api/v1/practice/rooms', data) +} + +/** + * 加入房间 + */ +export function joinRoom(roomCode: string) { + return request.post('/api/v1/practice/rooms/join', { + room_code: roomCode + }) +} + +/** + * 获取房间详情 + */ +export function getRoomDetail(roomCode: string) { + return request.get(`/api/v1/practice/rooms/${roomCode}`) +} + +/** + * 开始对练 + */ +export function startPractice(roomCode: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/start`) +} + +/** + * 结束对练 + */ +export function endPractice(roomCode: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/end`) +} + +/** + * 离开房间 + */ +export function leaveRoom(roomCode: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/leave`) +} + +/** + * 发送消息 + */ +export function sendMessage(roomCode: string, content: string) { + return request.post(`/api/v1/practice/rooms/${roomCode}/message`, { + content + }) +} + +/** + * 获取消息列表 + */ +export function getMessages(roomCode: string, sinceSequence: number = 0) { + return request.get(`/api/v1/practice/rooms/${roomCode}/messages`, { + params: { since_sequence: sinceSequence } + }) +} + +/** + * 获取我的房间列表 + */ +export function getMyRooms(status?: string, limit: number = 20) { + return request.get<{ rooms: RoomListItem[] }>('/api/v1/practice/rooms', { + params: { status, limit } + }) +} + +/** + * 生成分享链接 + */ +export function generateShareLink(roomCode: string): string { + const baseUrl = window.location.origin + return `${baseUrl}/trainee/duo-practice/join/${roomCode}` +} + +/** + * 发送 WebRTC 信令 + */ +export function sendSignal(roomCode: string, signalType: string, payload: Record) { + return request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { + signal_type: signalType, + payload + }) +} + +/** + * 获取对练报告 + */ +export function getPracticeReport(roomCode: string) { + return request.get(`/api/v1/practice/rooms/${roomCode}/report`) +} diff --git a/frontend/src/api/level.ts b/frontend/src/api/level.ts index b97a04f..aa565d2 100644 --- a/frontend/src/api/level.ts +++ b/frontend/src/api/level.ts @@ -1,182 +1,182 @@ -/** - * 等级与奖章 API - */ - -import request from '@/api/request' - -// 类型定义 -export interface LevelInfo { - user_id: number - level: number - exp: number - total_exp: number - title: string - color: string - login_streak: number - max_login_streak: number - last_checkin_at: string | null - next_level_exp: number - exp_to_next_level: number - is_max_level: boolean -} - -export interface ExpHistoryItem { - id: number - exp_change: number - exp_type: string - description: string - level_before: number | null - level_after: number | null - created_at: string -} - -export interface LeaderboardItem { - rank: number - user_id: number - username: string - full_name: string | null - avatar_url: string | null - level: number - title: string - color: string - total_exp: number - login_streak: number -} - -export interface Badge { - id: number - code: string - name: string - description: string - icon: string - category: string - condition_type?: string - condition_value?: number - exp_reward: number - unlocked?: boolean - unlocked_at?: string | null -} - -export interface CheckinResult { - success: boolean - message: string - exp_gained: number - base_exp?: number - bonus_exp?: number - login_streak: number - leveled_up?: boolean - new_level?: number | null - already_checked_in?: boolean - new_badges?: Badge[] -} - -// API 函数 - -/** - * 获取当前用户等级信息 - */ -export function getMyLevel() { - return request.get('/level/me') -} - -/** - * 获取指定用户等级信息 - */ -export function getUserLevel(userId: number) { - return request.get(`/level/user/${userId}`) -} - -/** - * 每日签到 - */ -export function dailyCheckin() { - return request.post('/level/checkin') -} - -/** - * 获取经验值历史 - */ -export function getExpHistory(params?: { - limit?: number - offset?: number - exp_type?: string -}) { - return request.get<{ - items: ExpHistoryItem[] - total: number - limit: number - offset: number - }>('/level/exp-history', { params }) -} - -/** - * 获取等级排行榜 - */ -export function getLeaderboard(params?: { - limit?: number - offset?: number -}) { - return request.get<{ - items: LeaderboardItem[] - total: number - limit: number - offset: number - my_rank: number - my_level_info: LevelInfo - }>('/level/leaderboard', { params }) -} - -/** - * 获取所有奖章定义 - */ -export function getAllBadges() { - return request.get('/level/badges/all') -} - -/** - * 获取用户奖章(含解锁状态) - */ -export function getMyBadges() { - return request.get<{ - badges: Badge[] - total: number - unlocked_count: number - }>('/level/badges/me') -} - -/** - * 获取未通知的新奖章 - */ -export function getUnnotifiedBadges() { - return request.get('/level/badges/unnotified') -} - -/** - * 标记奖章为已通知 - */ -export function markBadgesNotified(badgeIds?: number[]) { - return request.post('/level/badges/mark-notified', badgeIds) -} - -/** - * 手动检查并授予奖章 - */ -export function checkAndAwardBadges() { - return request.post<{ - new_badges: Badge[] - count: number - }>('/level/check-badges') -} - -export default { - getMyLevel, - getUserLevel, - dailyCheckin, - getExpHistory, - getLeaderboard, - getAllBadges, - getMyBadges, - getUnnotifiedBadges, - markBadgesNotified, - checkAndAwardBadges -} +/** + * 等级与奖章 API + */ + +import request from '@/api/request' + +// 类型定义 +export interface LevelInfo { + user_id: number + level: number + exp: number + total_exp: number + title: string + color: string + login_streak: number + max_login_streak: number + last_checkin_at: string | null + next_level_exp: number + exp_to_next_level: number + is_max_level: boolean +} + +export interface ExpHistoryItem { + id: number + exp_change: number + exp_type: string + description: string + level_before: number | null + level_after: number | null + created_at: string +} + +export interface LeaderboardItem { + rank: number + user_id: number + username: string + full_name: string | null + avatar_url: string | null + level: number + title: string + color: string + total_exp: number + login_streak: number +} + +export interface Badge { + id: number + code: string + name: string + description: string + icon: string + category: string + condition_type?: string + condition_value?: number + exp_reward: number + unlocked?: boolean + unlocked_at?: string | null +} + +export interface CheckinResult { + success: boolean + message: string + exp_gained: number + base_exp?: number + bonus_exp?: number + login_streak: number + leveled_up?: boolean + new_level?: number | null + already_checked_in?: boolean + new_badges?: Badge[] +} + +// API 函数 + +/** + * 获取当前用户等级信息 + */ +export function getMyLevel() { + return request.get('/level/me') +} + +/** + * 获取指定用户等级信息 + */ +export function getUserLevel(userId: number) { + return request.get(`/level/user/${userId}`) +} + +/** + * 每日签到 + */ +export function dailyCheckin() { + return request.post('/level/checkin') +} + +/** + * 获取经验值历史 + */ +export function getExpHistory(params?: { + limit?: number + offset?: number + exp_type?: string +}) { + return request.get<{ + items: ExpHistoryItem[] + total: number + limit: number + offset: number + }>('/level/exp-history', { params }) +} + +/** + * 获取等级排行榜 + */ +export function getLeaderboard(params?: { + limit?: number + offset?: number +}) { + return request.get<{ + items: LeaderboardItem[] + total: number + limit: number + offset: number + my_rank: number + my_level_info: LevelInfo + }>('/level/leaderboard', { params }) +} + +/** + * 获取所有奖章定义 + */ +export function getAllBadges() { + return request.get('/level/badges/all') +} + +/** + * 获取用户奖章(含解锁状态) + */ +export function getMyBadges() { + return request.get<{ + badges: Badge[] + total: number + unlocked_count: number + }>('/level/badges/me') +} + +/** + * 获取未通知的新奖章 + */ +export function getUnnotifiedBadges() { + return request.get('/level/badges/unnotified') +} + +/** + * 标记奖章为已通知 + */ +export function markBadgesNotified(badgeIds?: number[]) { + return request.post('/level/badges/mark-notified', badgeIds) +} + +/** + * 手动检查并授予奖章 + */ +export function checkAndAwardBadges() { + return request.post<{ + new_badges: Badge[] + count: number + }>('/level/check-badges') +} + +export default { + getMyLevel, + getUserLevel, + dailyCheckin, + getExpHistory, + getLeaderboard, + getAllBadges, + getMyBadges, + getUnnotifiedBadges, + markBadgesNotified, + checkAndAwardBadges +} diff --git a/frontend/src/api/progress.ts b/frontend/src/api/progress.ts new file mode 100644 index 0000000..23246ec --- /dev/null +++ b/frontend/src/api/progress.ts @@ -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('/api/v1/progress/summary') +} + +/** + * 获取所有课程学习进度 + */ +export const getAllCourseProgress = (status?: string) => { + return request.get('/api/v1/progress/courses', { + params: status ? { status } : undefined, + }) +} + +/** + * 获取指定课程的详细学习进度 + */ +export const getCourseProgress = (courseId: number) => { + return request.get(`/api/v1/progress/courses/${courseId}`) +} + +/** + * 更新资料学习进度 + */ +export const updateMaterialProgress = ( + materialId: number, + data: MaterialProgressUpdate +) => { + return request.post( + `/api/v1/progress/materials/${materialId}`, + data + ) +} + +/** + * 标记资料为已完成 + */ +export const markMaterialComplete = (materialId: number) => { + return request.post( + `/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 = { + not_started: 'info', + in_progress: 'warning', + completed: 'success', + } + return typeMap[status] || 'info' +} + +export default { + getProgressSummary, + getAllCourseProgress, + getCourseProgress, + updateMaterialProgress, + markMaterialComplete, + startCourse, + formatStudyTime, + getProgressStatusText, + getProgressStatusType, +} diff --git a/frontend/src/components/BadgeCard.vue b/frontend/src/components/BadgeCard.vue index fb4c144..c004710 100644 --- a/frontend/src/components/BadgeCard.vue +++ b/frontend/src/components/BadgeCard.vue @@ -1,174 +1,174 @@ - - - - - + + + + + diff --git a/frontend/src/components/ExpProgress.vue b/frontend/src/components/ExpProgress.vue index ceaccc6..1a25f75 100644 --- a/frontend/src/components/ExpProgress.vue +++ b/frontend/src/components/ExpProgress.vue @@ -1,100 +1,100 @@ - - - - - + + + + + diff --git a/frontend/src/components/LevelBadge.vue b/frontend/src/components/LevelBadge.vue index f915108..6e82715 100644 --- a/frontend/src/components/LevelBadge.vue +++ b/frontend/src/components/LevelBadge.vue @@ -1,85 +1,85 @@ - - - - - + + + + + diff --git a/frontend/src/components/LevelUpDialog.vue b/frontend/src/components/LevelUpDialog.vue index 9dd8cd4..48cb367 100644 --- a/frontend/src/components/LevelUpDialog.vue +++ b/frontend/src/components/LevelUpDialog.vue @@ -1,297 +1,297 @@ - - - - - + + + + + diff --git a/frontend/src/composables/useVoiceCall.ts b/frontend/src/composables/useVoiceCall.ts index 3db33d1..36aa789 100644 --- a/frontend/src/composables/useVoiceCall.ts +++ b/frontend/src/composables/useVoiceCall.ts @@ -1,462 +1,462 @@ -/** - * 语音通话组合式函数 - * - * 功能: - * - 整合 WebRTC 管理和信令服务 - * - 管理通话状态 - * - 处理语音转文字 - */ -import { ref, computed, onUnmounted } from 'vue' -import { ElMessage } from 'element-plus' -import { WebRTCManager, createWebRTCManager, type ConnectionState } from '@/utils/webrtc' -import request from '@/api/request' - -export type VoiceCallState = 'idle' | 'requesting' | 'ringing' | 'connecting' | 'connected' | 'ended' - -export interface UseVoiceCallOptions { - roomCode: string - onTranscript?: (text: string, isFinal: boolean) => void - onRemoteTranscript?: (text: string) => void -} - -export function useVoiceCall(options: UseVoiceCallOptions) { - const { roomCode, onTranscript, onRemoteTranscript } = options - - // ==================== 状态 ==================== - const callState = ref('idle') - const connectionState = ref('idle') - const isMuted = ref(false) - const isRemoteMuted = ref(false) - const localAudioLevel = ref(0) - const remoteAudioLevel = ref(0) - const callDuration = ref(0) - const errorMessage = ref(null) - - // 语音识别相关 - const isTranscribing = ref(false) - const currentTranscript = ref('') - - // 内部状态 - let webrtcManager: WebRTCManager | null = null - let recognition: any = null // SpeechRecognition - let callTimer: number | null = null - let audioLevelTimer: number | null = null - - // ==================== 计算属性 ==================== - const isCallActive = computed(() => - ['requesting', 'ringing', 'connecting', 'connected'].includes(callState.value) - ) - - const canStartCall = computed(() => callState.value === 'idle') - const canEndCall = computed(() => isCallActive.value) - - // ==================== 信令 API ==================== - - async function sendSignal(signalType: string, payload: any) { - try { - await request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { - signal_type: signalType, - payload - }) - } catch (error) { - console.error('[VoiceCall] 发送信令失败:', error) - throw error - } - } - - // ==================== 通话控制 ==================== - - /** - * 发起语音通话 - */ - async function startCall() { - if (!canStartCall.value) { - console.warn('[VoiceCall] 无法发起通话,当前状态:', callState.value) - return - } - - try { - callState.value = 'requesting' - errorMessage.value = null - - // 创建 WebRTC 管理器 - webrtcManager = createWebRTCManager({ - onConnectionStateChange: handleConnectionStateChange, - onIceCandidate: handleIceCandidate, - onRemoteStream: handleRemoteStream, - onError: handleError - }) - - // 创建 Offer - const offer = await webrtcManager.createOffer() - - // 发送开始信令 - await sendSignal('voice_start', {}) - - // 发送 Offer - await sendSignal('voice_offer', { - type: offer.type, - sdp: offer.sdp - }) - - callState.value = 'ringing' - ElMessage.info('正在呼叫对方...') - - } catch (error: any) { - console.error('[VoiceCall] 发起通话失败:', error) - errorMessage.value = error.message || '发起通话失败' - callState.value = 'idle' - webrtcManager?.close() - webrtcManager = null - ElMessage.error(errorMessage.value) - } - } - - /** - * 接听语音通话 - */ - async function answerCall(offer: RTCSessionDescriptionInit) { - try { - callState.value = 'connecting' - errorMessage.value = null - - // 创建 WebRTC 管理器 - webrtcManager = createWebRTCManager({ - onConnectionStateChange: handleConnectionStateChange, - onIceCandidate: handleIceCandidate, - onRemoteStream: handleRemoteStream, - onError: handleError - }) - - // 处理 Offer 并创建 Answer - const answer = await webrtcManager.handleOffer(offer) - - // 发送 Answer - await sendSignal('voice_answer', { - type: answer.type, - sdp: answer.sdp - }) - - ElMessage.success('已接听通话') - - } catch (error: any) { - console.error('[VoiceCall] 接听通话失败:', error) - errorMessage.value = error.message || '接听通话失败' - callState.value = 'idle' - webrtcManager?.close() - webrtcManager = null - ElMessage.error(errorMessage.value) - } - } - - /** - * 拒绝来电 - */ - async function rejectCall() { - try { - await sendSignal('voice_end', { reason: 'rejected' }) - callState.value = 'idle' - } catch (error) { - console.error('[VoiceCall] 拒绝通话失败:', error) - } - } - - /** - * 结束通话 - */ - async function endCall() { - try { - await sendSignal('voice_end', { reason: 'ended' }) - } catch (error) { - console.error('[VoiceCall] 发送结束信令失败:', error) - } - - cleanup() - callState.value = 'ended' - - // 延迟恢复到 idle 状态 - setTimeout(() => { - callState.value = 'idle' - }, 1000) - } - - /** - * 切换静音 - */ - function toggleMute() { - if (webrtcManager) { - isMuted.value = !isMuted.value - webrtcManager.setMuted(isMuted.value) - } - } - - // ==================== 信令处理 ==================== - - /** - * 处理接收到的信令消息 - */ - async function handleSignal(signalType: string, payload: any, fromUserId: number) { - console.log('[VoiceCall] 收到信令:', signalType) - - switch (signalType) { - case 'voice_start': - // 收到通话请求 - if (callState.value === 'idle') { - callState.value = 'ringing' - ElMessage.info('收到语音通话请求') - } - break - - case 'voice_offer': - // 收到 Offer,自动接听 - if (callState.value === 'ringing' || callState.value === 'idle') { - await answerCall({ - type: payload.type, - sdp: payload.sdp - }) - } - break - - case 'voice_answer': - // 收到 Answer - if (webrtcManager && callState.value === 'ringing') { - await webrtcManager.handleAnswer({ - type: payload.type, - sdp: payload.sdp - }) - } - break - - case 'ice_candidate': - // 收到 ICE 候选 - if (webrtcManager && payload.candidate) { - await webrtcManager.addIceCandidate(payload) - } - break - - case 'voice_end': - // 对方结束通话 - cleanup() - callState.value = 'ended' - ElMessage.info('通话已结束') - setTimeout(() => { - callState.value = 'idle' - }, 1000) - break - } - } - - // ==================== WebRTC 回调 ==================== - - function handleConnectionStateChange(state: ConnectionState) { - connectionState.value = state - - if (state === 'connected') { - callState.value = 'connected' - startCallTimer() - startAudioLevelMonitor() - startSpeechRecognition() - ElMessage.success('语音通话已连接') - } else if (state === 'failed' || state === 'disconnected') { - if (callState.value === 'connected') { - ElMessage.warning('通话连接断开') - } - } - } - - async function handleIceCandidate(candidate: RTCIceCandidate) { - try { - await sendSignal('ice_candidate', candidate.toJSON()) - } catch (error) { - console.error('[VoiceCall] 发送 ICE 候选失败:', error) - } - } - - function handleRemoteStream(stream: MediaStream) { - console.log('[VoiceCall] 收到远程音频流') - - // 播放远程音频 - const audio = new Audio() - audio.srcObject = stream - audio.play().catch(e => console.error('[VoiceCall] 播放远程音频失败:', e)) - } - - function handleError(error: Error) { - console.error('[VoiceCall] WebRTC 错误:', error) - errorMessage.value = error.message - } - - // ==================== 语音识别 ==================== - - function startSpeechRecognition() { - // 检查浏览器支持 - const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition - - if (!SpeechRecognition) { - console.warn('[VoiceCall] 浏览器不支持语音识别') - return - } - - recognition = new SpeechRecognition() - recognition.continuous = true - recognition.interimResults = true - recognition.lang = 'zh-CN' - - recognition.onstart = () => { - isTranscribing.value = true - console.log('[VoiceCall] 语音识别已启动') - } - - recognition.onresult = (event: any) => { - let interimTranscript = '' - let finalTranscript = '' - - for (let i = event.resultIndex; i < event.results.length; i++) { - const transcript = event.results[i][0].transcript - if (event.results[i].isFinal) { - finalTranscript += transcript - } else { - interimTranscript += transcript - } - } - - currentTranscript.value = interimTranscript || finalTranscript - - if (finalTranscript) { - onTranscript?.(finalTranscript, true) - } else if (interimTranscript) { - onTranscript?.(interimTranscript, false) - } - } - - recognition.onerror = (event: any) => { - console.error('[VoiceCall] 语音识别错误:', event.error) - if (event.error !== 'no-speech') { - isTranscribing.value = false - } - } - - recognition.onend = () => { - // 如果通话还在进行,重新启动识别 - if (callState.value === 'connected' && !isMuted.value) { - recognition.start() - } else { - isTranscribing.value = false - } - } - - try { - recognition.start() - } catch (error) { - console.error('[VoiceCall] 启动语音识别失败:', error) - } - } - - function stopSpeechRecognition() { - if (recognition) { - recognition.stop() - recognition = null - } - isTranscribing.value = false - } - - // ==================== 辅助功能 ==================== - - function startCallTimer() { - callDuration.value = 0 - callTimer = window.setInterval(() => { - callDuration.value++ - }, 1000) - } - - function stopCallTimer() { - if (callTimer) { - clearInterval(callTimer) - callTimer = null - } - } - - function startAudioLevelMonitor() { - audioLevelTimer = window.setInterval(async () => { - if (webrtcManager) { - const localStream = webrtcManager.getLocalStream() - const remoteStream = webrtcManager.getRemoteStream() - - if (localStream) { - localAudioLevel.value = await webrtcManager.getAudioLevel(localStream) - } - if (remoteStream) { - remoteAudioLevel.value = await webrtcManager.getAudioLevel(remoteStream) - } - } - }, 100) - } - - function stopAudioLevelMonitor() { - if (audioLevelTimer) { - clearInterval(audioLevelTimer) - audioLevelTimer = null - } - } - - function formatDuration(seconds: number): string { - const mins = Math.floor(seconds / 60) - const secs = seconds % 60 - return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` - } - - // ==================== 清理 ==================== - - function cleanup() { - stopCallTimer() - stopAudioLevelMonitor() - stopSpeechRecognition() - - webrtcManager?.close() - webrtcManager = null - - isMuted.value = false - isRemoteMuted.value = false - localAudioLevel.value = 0 - remoteAudioLevel.value = 0 - currentTranscript.value = '' - } - - // 组件卸载时清理 - onUnmounted(() => { - if (isCallActive.value) { - endCall() - } - cleanup() - }) - - // ==================== 返回 ==================== - - return { - // 状态 - callState, - connectionState, - isMuted, - isRemoteMuted, - localAudioLevel, - remoteAudioLevel, - callDuration, - errorMessage, - isTranscribing, - currentTranscript, - - // 计算属性 - isCallActive, - canStartCall, - canEndCall, - - // 方法 - startCall, - answerCall, - rejectCall, - endCall, - toggleMute, - handleSignal, - formatDuration - } -} +/** + * 语音通话组合式函数 + * + * 功能: + * - 整合 WebRTC 管理和信令服务 + * - 管理通话状态 + * - 处理语音转文字 + */ +import { ref, computed, onUnmounted } from 'vue' +import { ElMessage } from 'element-plus' +import { WebRTCManager, createWebRTCManager, type ConnectionState } from '@/utils/webrtc' +import request from '@/api/request' + +export type VoiceCallState = 'idle' | 'requesting' | 'ringing' | 'connecting' | 'connected' | 'ended' + +export interface UseVoiceCallOptions { + roomCode: string + onTranscript?: (text: string, isFinal: boolean) => void + onRemoteTranscript?: (text: string) => void +} + +export function useVoiceCall(options: UseVoiceCallOptions) { + const { roomCode, onTranscript, onRemoteTranscript } = options + + // ==================== 状态 ==================== + const callState = ref('idle') + const connectionState = ref('idle') + const isMuted = ref(false) + const isRemoteMuted = ref(false) + const localAudioLevel = ref(0) + const remoteAudioLevel = ref(0) + const callDuration = ref(0) + const errorMessage = ref(null) + + // 语音识别相关 + const isTranscribing = ref(false) + const currentTranscript = ref('') + + // 内部状态 + let webrtcManager: WebRTCManager | null = null + let recognition: any = null // SpeechRecognition + let callTimer: number | null = null + let audioLevelTimer: number | null = null + + // ==================== 计算属性 ==================== + const isCallActive = computed(() => + ['requesting', 'ringing', 'connecting', 'connected'].includes(callState.value) + ) + + const canStartCall = computed(() => callState.value === 'idle') + const canEndCall = computed(() => isCallActive.value) + + // ==================== 信令 API ==================== + + async function sendSignal(signalType: string, payload: any) { + try { + await request.post(`/api/v1/practice/rooms/${roomCode}/signal`, { + signal_type: signalType, + payload + }) + } catch (error) { + console.error('[VoiceCall] 发送信令失败:', error) + throw error + } + } + + // ==================== 通话控制 ==================== + + /** + * 发起语音通话 + */ + async function startCall() { + if (!canStartCall.value) { + console.warn('[VoiceCall] 无法发起通话,当前状态:', callState.value) + return + } + + try { + callState.value = 'requesting' + errorMessage.value = null + + // 创建 WebRTC 管理器 + webrtcManager = createWebRTCManager({ + onConnectionStateChange: handleConnectionStateChange, + onIceCandidate: handleIceCandidate, + onRemoteStream: handleRemoteStream, + onError: handleError + }) + + // 创建 Offer + const offer = await webrtcManager.createOffer() + + // 发送开始信令 + await sendSignal('voice_start', {}) + + // 发送 Offer + await sendSignal('voice_offer', { + type: offer.type, + sdp: offer.sdp + }) + + callState.value = 'ringing' + ElMessage.info('正在呼叫对方...') + + } catch (error: any) { + console.error('[VoiceCall] 发起通话失败:', error) + errorMessage.value = error.message || '发起通话失败' + callState.value = 'idle' + webrtcManager?.close() + webrtcManager = null + ElMessage.error(errorMessage.value) + } + } + + /** + * 接听语音通话 + */ + async function answerCall(offer: RTCSessionDescriptionInit) { + try { + callState.value = 'connecting' + errorMessage.value = null + + // 创建 WebRTC 管理器 + webrtcManager = createWebRTCManager({ + onConnectionStateChange: handleConnectionStateChange, + onIceCandidate: handleIceCandidate, + onRemoteStream: handleRemoteStream, + onError: handleError + }) + + // 处理 Offer 并创建 Answer + const answer = await webrtcManager.handleOffer(offer) + + // 发送 Answer + await sendSignal('voice_answer', { + type: answer.type, + sdp: answer.sdp + }) + + ElMessage.success('已接听通话') + + } catch (error: any) { + console.error('[VoiceCall] 接听通话失败:', error) + errorMessage.value = error.message || '接听通话失败' + callState.value = 'idle' + webrtcManager?.close() + webrtcManager = null + ElMessage.error(errorMessage.value) + } + } + + /** + * 拒绝来电 + */ + async function rejectCall() { + try { + await sendSignal('voice_end', { reason: 'rejected' }) + callState.value = 'idle' + } catch (error) { + console.error('[VoiceCall] 拒绝通话失败:', error) + } + } + + /** + * 结束通话 + */ + async function endCall() { + try { + await sendSignal('voice_end', { reason: 'ended' }) + } catch (error) { + console.error('[VoiceCall] 发送结束信令失败:', error) + } + + cleanup() + callState.value = 'ended' + + // 延迟恢复到 idle 状态 + setTimeout(() => { + callState.value = 'idle' + }, 1000) + } + + /** + * 切换静音 + */ + function toggleMute() { + if (webrtcManager) { + isMuted.value = !isMuted.value + webrtcManager.setMuted(isMuted.value) + } + } + + // ==================== 信令处理 ==================== + + /** + * 处理接收到的信令消息 + */ + async function handleSignal(signalType: string, payload: any, fromUserId: number) { + console.log('[VoiceCall] 收到信令:', signalType) + + switch (signalType) { + case 'voice_start': + // 收到通话请求 + if (callState.value === 'idle') { + callState.value = 'ringing' + ElMessage.info('收到语音通话请求') + } + break + + case 'voice_offer': + // 收到 Offer,自动接听 + if (callState.value === 'ringing' || callState.value === 'idle') { + await answerCall({ + type: payload.type, + sdp: payload.sdp + }) + } + break + + case 'voice_answer': + // 收到 Answer + if (webrtcManager && callState.value === 'ringing') { + await webrtcManager.handleAnswer({ + type: payload.type, + sdp: payload.sdp + }) + } + break + + case 'ice_candidate': + // 收到 ICE 候选 + if (webrtcManager && payload.candidate) { + await webrtcManager.addIceCandidate(payload) + } + break + + case 'voice_end': + // 对方结束通话 + cleanup() + callState.value = 'ended' + ElMessage.info('通话已结束') + setTimeout(() => { + callState.value = 'idle' + }, 1000) + break + } + } + + // ==================== WebRTC 回调 ==================== + + function handleConnectionStateChange(state: ConnectionState) { + connectionState.value = state + + if (state === 'connected') { + callState.value = 'connected' + startCallTimer() + startAudioLevelMonitor() + startSpeechRecognition() + ElMessage.success('语音通话已连接') + } else if (state === 'failed' || state === 'disconnected') { + if (callState.value === 'connected') { + ElMessage.warning('通话连接断开') + } + } + } + + async function handleIceCandidate(candidate: RTCIceCandidate) { + try { + await sendSignal('ice_candidate', candidate.toJSON()) + } catch (error) { + console.error('[VoiceCall] 发送 ICE 候选失败:', error) + } + } + + function handleRemoteStream(stream: MediaStream) { + console.log('[VoiceCall] 收到远程音频流') + + // 播放远程音频 + const audio = new Audio() + audio.srcObject = stream + audio.play().catch(e => console.error('[VoiceCall] 播放远程音频失败:', e)) + } + + function handleError(error: Error) { + console.error('[VoiceCall] WebRTC 错误:', error) + errorMessage.value = error.message + } + + // ==================== 语音识别 ==================== + + function startSpeechRecognition() { + // 检查浏览器支持 + const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition + + if (!SpeechRecognition) { + console.warn('[VoiceCall] 浏览器不支持语音识别') + return + } + + recognition = new SpeechRecognition() + recognition.continuous = true + recognition.interimResults = true + recognition.lang = 'zh-CN' + + recognition.onstart = () => { + isTranscribing.value = true + console.log('[VoiceCall] 语音识别已启动') + } + + recognition.onresult = (event: any) => { + let interimTranscript = '' + let finalTranscript = '' + + for (let i = event.resultIndex; i < event.results.length; i++) { + const transcript = event.results[i][0].transcript + if (event.results[i].isFinal) { + finalTranscript += transcript + } else { + interimTranscript += transcript + } + } + + currentTranscript.value = interimTranscript || finalTranscript + + if (finalTranscript) { + onTranscript?.(finalTranscript, true) + } else if (interimTranscript) { + onTranscript?.(interimTranscript, false) + } + } + + recognition.onerror = (event: any) => { + console.error('[VoiceCall] 语音识别错误:', event.error) + if (event.error !== 'no-speech') { + isTranscribing.value = false + } + } + + recognition.onend = () => { + // 如果通话还在进行,重新启动识别 + if (callState.value === 'connected' && !isMuted.value) { + recognition.start() + } else { + isTranscribing.value = false + } + } + + try { + recognition.start() + } catch (error) { + console.error('[VoiceCall] 启动语音识别失败:', error) + } + } + + function stopSpeechRecognition() { + if (recognition) { + recognition.stop() + recognition = null + } + isTranscribing.value = false + } + + // ==================== 辅助功能 ==================== + + function startCallTimer() { + callDuration.value = 0 + callTimer = window.setInterval(() => { + callDuration.value++ + }, 1000) + } + + function stopCallTimer() { + if (callTimer) { + clearInterval(callTimer) + callTimer = null + } + } + + function startAudioLevelMonitor() { + audioLevelTimer = window.setInterval(async () => { + if (webrtcManager) { + const localStream = webrtcManager.getLocalStream() + const remoteStream = webrtcManager.getRemoteStream() + + if (localStream) { + localAudioLevel.value = await webrtcManager.getAudioLevel(localStream) + } + if (remoteStream) { + remoteAudioLevel.value = await webrtcManager.getAudioLevel(remoteStream) + } + } + }, 100) + } + + function stopAudioLevelMonitor() { + if (audioLevelTimer) { + clearInterval(audioLevelTimer) + audioLevelTimer = null + } + } + + function formatDuration(seconds: number): string { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}` + } + + // ==================== 清理 ==================== + + function cleanup() { + stopCallTimer() + stopAudioLevelMonitor() + stopSpeechRecognition() + + webrtcManager?.close() + webrtcManager = null + + isMuted.value = false + isRemoteMuted.value = false + localAudioLevel.value = 0 + remoteAudioLevel.value = 0 + currentTranscript.value = '' + } + + // 组件卸载时清理 + onUnmounted(() => { + if (isCallActive.value) { + endCall() + } + cleanup() + }) + + // ==================== 返回 ==================== + + return { + // 状态 + callState, + connectionState, + isMuted, + isRemoteMuted, + localAudioLevel, + remoteAudioLevel, + callDuration, + errorMessage, + isTranscribing, + currentTranscript, + + // 计算属性 + isCallActive, + canStartCall, + canEndCall, + + // 方法 + startCall, + answerCall, + rejectCall, + endCall, + toggleMute, + handleSignal, + formatDuration + } +} diff --git a/frontend/src/router/guard.ts b/frontend/src/router/guard.ts index 4d929b9..b9a5c11 100644 --- a/frontend/src/router/guard.ts +++ b/frontend/src/router/guard.ts @@ -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 { 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 diff --git a/frontend/src/stores/duoPracticeStore.ts b/frontend/src/stores/duoPracticeStore.ts index c3ce113..4bebb1b 100644 --- a/frontend/src/stores/duoPracticeStore.ts +++ b/frontend/src/stores/duoPracticeStore.ts @@ -1,413 +1,413 @@ -/** - * 双人对练状态管理 - */ -import { defineStore } from 'pinia' -import { ref, computed } from 'vue' -import { ElMessage } from 'element-plus' -import * as duoPracticeApi from '@/api/duoPractice' -import type { - RoomInfo, - RoomUser, - RoomMessage, - CreateRoomRequest -} from '@/api/duoPractice' - -export const useDuoPracticeStore = defineStore('duoPractice', () => { - // ==================== 状态 ==================== - - /** 房间码 */ - const roomCode = ref('') - - /** 房间信息 */ - const roomInfo = ref(null) - - /** 房主信息 */ - const hostUser = ref(null) - - /** 嘉宾信息 */ - const guestUser = ref(null) - - /** 我的角色 */ - const myRole = ref('') - - /** 我的角色名称 */ - const myRoleName = ref('') - - /** 是否是房主 */ - const isHost = ref(false) - - /** 消息列表 */ - const messages = ref([]) - - /** 最后消息序号(用于轮询) */ - const lastSequence = ref(0) - - /** 是否正在加载 */ - const isLoading = ref(false) - - /** 是否已连接(轮询中) */ - const isConnected = ref(false) - - /** 轮询定时器 */ - let pollingTimer: number | null = null - - /** 输入框内容 */ - const inputMessage = ref('') - - // ==================== 计算属性 ==================== - - /** 房间状态 */ - const roomStatus = computed(() => roomInfo.value?.status || 'unknown') - - /** 是否等待中 */ - const isWaiting = computed(() => roomStatus.value === 'waiting') - - /** 是否就绪 */ - const isReady = computed(() => roomStatus.value === 'ready') - - /** 是否对练中 */ - const isPracticing = computed(() => roomStatus.value === 'practicing') - - /** 是否已完成 */ - const isCompleted = computed(() => roomStatus.value === 'completed') - - /** 对方用户 */ - const partnerUser = computed(() => { - if (isHost.value) { - return guestUser.value - } else { - return hostUser.value - } - }) - - /** 对方角色名称 */ - const partnerRoleName = computed(() => { - if (!roomInfo.value) return '' - const partnerRole = myRole.value === 'A' ? 'B' : 'A' - return partnerRole === 'A' ? roomInfo.value.role_a_name : roomInfo.value.role_b_name - }) - - /** 聊天消息(过滤系统消息) */ - const chatMessages = computed(() => { - return messages.value.filter(m => m.message_type === 'chat') - }) - - /** 系统消息 */ - const systemMessages = computed(() => { - return messages.value.filter(m => m.message_type !== 'chat') - }) - - // ==================== 方法 ==================== - - /** - * 创建房间 - */ - const createRoom = async (request: CreateRoomRequest) => { - isLoading.value = true - try { - const res: any = await duoPracticeApi.createRoom(request) - if (res.code === 200) { - roomCode.value = res.data.room_code - myRole.value = res.data.my_role - myRoleName.value = res.data.my_role_name - isHost.value = true - - // 获取房间详情 - await fetchRoomDetail() - - return res.data - } else { - throw new Error(res.message || '创建房间失败') - } - } catch (error: any) { - ElMessage.error(error.message || '创建房间失败') - throw error - } finally { - isLoading.value = false - } - } - - /** - * 加入房间 - */ - const joinRoom = async (code: string) => { - isLoading.value = true - try { - const res: any = await duoPracticeApi.joinRoom(code.toUpperCase()) - if (res.code === 200) { - roomCode.value = res.data.room_code - myRole.value = res.data.my_role - myRoleName.value = res.data.my_role_name - isHost.value = false - - // 获取房间详情 - await fetchRoomDetail() - - return res.data - } else { - throw new Error(res.message || '加入房间失败') - } - } catch (error: any) { - ElMessage.error(error.message || '加入房间失败') - throw error - } finally { - isLoading.value = false - } - } - - /** - * 获取房间详情 - */ - const fetchRoomDetail = async () => { - if (!roomCode.value) return - - try { - const res: any = await duoPracticeApi.getRoomDetail(roomCode.value) - if (res.code === 200) { - roomInfo.value = res.data.room - hostUser.value = res.data.host_user - guestUser.value = res.data.guest_user - myRole.value = res.data.my_role || myRole.value - myRoleName.value = res.data.my_role_name || myRoleName.value - isHost.value = res.data.is_host - } - } catch (error) { - console.error('获取房间详情失败:', error) - } - } - - /** - * 开始对练 - */ - const startPractice = async () => { - if (!roomCode.value) return - - isLoading.value = true - try { - const res: any = await duoPracticeApi.startPractice(roomCode.value) - if (res.code === 200) { - ElMessage.success('对练开始!') - await fetchRoomDetail() - } else { - throw new Error(res.message || '开始失败') - } - } catch (error: any) { - ElMessage.error(error.message || '开始对练失败') - } finally { - isLoading.value = false - } - } - - /** - * 结束对练 - */ - const endPractice = async () => { - if (!roomCode.value) return - - isLoading.value = true - try { - const res: any = await duoPracticeApi.endPractice(roomCode.value) - if (res.code === 200) { - ElMessage.success('对练结束') - await fetchRoomDetail() - stopPolling() - return res.data - } else { - throw new Error(res.message || '结束失败') - } - } catch (error: any) { - ElMessage.error(error.message || '结束对练失败') - throw error - } finally { - isLoading.value = false - } - } - - /** - * 离开房间 - */ - const leaveRoom = async () => { - if (!roomCode.value) return - - try { - await duoPracticeApi.leaveRoom(roomCode.value) - resetState() - } catch (error) { - console.error('离开房间失败:', error) - } - } - - /** - * 发送消息 - */ - const sendMessage = async (content?: string) => { - const msg = content || inputMessage.value.trim() - if (!msg || !roomCode.value) return - - try { - const res: any = await duoPracticeApi.sendMessage(roomCode.value, msg) - if (res.code === 200) { - inputMessage.value = '' - // 消息会通过轮询获取 - } - } catch (error: any) { - ElMessage.error(error.message || '发送失败') - } - } - - /** - * 获取消息 - */ - const fetchMessages = async () => { - if (!roomCode.value) return - - try { - const res: any = await duoPracticeApi.getMessages(roomCode.value, lastSequence.value) - if (res.code === 200) { - const newMessages = res.data.messages - if (newMessages.length > 0) { - messages.value.push(...newMessages) - lastSequence.value = res.data.last_sequence - } - - // 检查房间状态变化 - if (res.data.room_status !== roomInfo.value?.status) { - await fetchRoomDetail() - } - } - } catch (error) { - console.error('获取消息失败:', error) - } - } - - /** - * 开始轮询消息 - */ - const startPolling = () => { - if (pollingTimer) return - - isConnected.value = true - - // 立即获取一次 - fetchMessages() - - // 每500ms轮询一次 - pollingTimer = window.setInterval(() => { - fetchMessages() - }, 500) - - console.log('[DuoPractice] 开始轮询消息') - } - - /** - * 停止轮询 - */ - const stopPolling = () => { - if (pollingTimer) { - clearInterval(pollingTimer) - pollingTimer = null - } - isConnected.value = false - console.log('[DuoPractice] 停止轮询消息') - } - - /** - * 重置状态 - */ - const resetState = () => { - stopPolling() - roomCode.value = '' - roomInfo.value = null - hostUser.value = null - guestUser.value = null - myRole.value = '' - myRoleName.value = '' - isHost.value = false - messages.value = [] - lastSequence.value = 0 - inputMessage.value = '' - isLoading.value = false - } - - /** - * 生成分享链接 - */ - const getShareLink = () => { - if (!roomCode.value) return '' - return duoPracticeApi.generateShareLink(roomCode.value) - } - - /** - * 复制房间码 - */ - const copyRoomCode = async () => { - if (!roomCode.value) return - - try { - await navigator.clipboard.writeText(roomCode.value) - ElMessage.success('房间码已复制') - } catch (error) { - ElMessage.error('复制失败') - } - } - - /** - * 复制分享链接 - */ - const copyShareLink = async () => { - const link = getShareLink() - if (!link) return - - try { - await navigator.clipboard.writeText(link) - ElMessage.success('链接已复制') - } catch (error) { - ElMessage.error('复制失败') - } - } - - // ==================== 返回 ==================== - - return { - // 状态 - roomCode, - roomInfo, - hostUser, - guestUser, - myRole, - myRoleName, - isHost, - messages, - lastSequence, - isLoading, - isConnected, - inputMessage, - - // 计算属性 - roomStatus, - isWaiting, - isReady, - isPracticing, - isCompleted, - partnerUser, - partnerRoleName, - chatMessages, - systemMessages, - - // 方法 - createRoom, - joinRoom, - fetchRoomDetail, - startPractice, - endPractice, - leaveRoom, - sendMessage, - fetchMessages, - startPolling, - stopPolling, - resetState, - getShareLink, - copyRoomCode, - copyShareLink - } -}) +/** + * 双人对练状态管理 + */ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import { ElMessage } from 'element-plus' +import * as duoPracticeApi from '@/api/duoPractice' +import type { + RoomInfo, + RoomUser, + RoomMessage, + CreateRoomRequest +} from '@/api/duoPractice' + +export const useDuoPracticeStore = defineStore('duoPractice', () => { + // ==================== 状态 ==================== + + /** 房间码 */ + const roomCode = ref('') + + /** 房间信息 */ + const roomInfo = ref(null) + + /** 房主信息 */ + const hostUser = ref(null) + + /** 嘉宾信息 */ + const guestUser = ref(null) + + /** 我的角色 */ + const myRole = ref('') + + /** 我的角色名称 */ + const myRoleName = ref('') + + /** 是否是房主 */ + const isHost = ref(false) + + /** 消息列表 */ + const messages = ref([]) + + /** 最后消息序号(用于轮询) */ + const lastSequence = ref(0) + + /** 是否正在加载 */ + const isLoading = ref(false) + + /** 是否已连接(轮询中) */ + const isConnected = ref(false) + + /** 轮询定时器 */ + let pollingTimer: number | null = null + + /** 输入框内容 */ + const inputMessage = ref('') + + // ==================== 计算属性 ==================== + + /** 房间状态 */ + const roomStatus = computed(() => roomInfo.value?.status || 'unknown') + + /** 是否等待中 */ + const isWaiting = computed(() => roomStatus.value === 'waiting') + + /** 是否就绪 */ + const isReady = computed(() => roomStatus.value === 'ready') + + /** 是否对练中 */ + const isPracticing = computed(() => roomStatus.value === 'practicing') + + /** 是否已完成 */ + const isCompleted = computed(() => roomStatus.value === 'completed') + + /** 对方用户 */ + const partnerUser = computed(() => { + if (isHost.value) { + return guestUser.value + } else { + return hostUser.value + } + }) + + /** 对方角色名称 */ + const partnerRoleName = computed(() => { + if (!roomInfo.value) return '' + const partnerRole = myRole.value === 'A' ? 'B' : 'A' + return partnerRole === 'A' ? roomInfo.value.role_a_name : roomInfo.value.role_b_name + }) + + /** 聊天消息(过滤系统消息) */ + const chatMessages = computed(() => { + return messages.value.filter(m => m.message_type === 'chat') + }) + + /** 系统消息 */ + const systemMessages = computed(() => { + return messages.value.filter(m => m.message_type !== 'chat') + }) + + // ==================== 方法 ==================== + + /** + * 创建房间 + */ + const createRoom = async (request: CreateRoomRequest) => { + isLoading.value = true + try { + const res: any = await duoPracticeApi.createRoom(request) + if (res.code === 200) { + roomCode.value = res.data.room_code + myRole.value = res.data.my_role + myRoleName.value = res.data.my_role_name + isHost.value = true + + // 获取房间详情 + await fetchRoomDetail() + + return res.data + } else { + throw new Error(res.message || '创建房间失败') + } + } catch (error: any) { + ElMessage.error(error.message || '创建房间失败') + throw error + } finally { + isLoading.value = false + } + } + + /** + * 加入房间 + */ + const joinRoom = async (code: string) => { + isLoading.value = true + try { + const res: any = await duoPracticeApi.joinRoom(code.toUpperCase()) + if (res.code === 200) { + roomCode.value = res.data.room_code + myRole.value = res.data.my_role + myRoleName.value = res.data.my_role_name + isHost.value = false + + // 获取房间详情 + await fetchRoomDetail() + + return res.data + } else { + throw new Error(res.message || '加入房间失败') + } + } catch (error: any) { + ElMessage.error(error.message || '加入房间失败') + throw error + } finally { + isLoading.value = false + } + } + + /** + * 获取房间详情 + */ + const fetchRoomDetail = async () => { + if (!roomCode.value) return + + try { + const res: any = await duoPracticeApi.getRoomDetail(roomCode.value) + if (res.code === 200) { + roomInfo.value = res.data.room + hostUser.value = res.data.host_user + guestUser.value = res.data.guest_user + myRole.value = res.data.my_role || myRole.value + myRoleName.value = res.data.my_role_name || myRoleName.value + isHost.value = res.data.is_host + } + } catch (error) { + console.error('获取房间详情失败:', error) + } + } + + /** + * 开始对练 + */ + const startPractice = async () => { + if (!roomCode.value) return + + isLoading.value = true + try { + const res: any = await duoPracticeApi.startPractice(roomCode.value) + if (res.code === 200) { + ElMessage.success('对练开始!') + await fetchRoomDetail() + } else { + throw new Error(res.message || '开始失败') + } + } catch (error: any) { + ElMessage.error(error.message || '开始对练失败') + } finally { + isLoading.value = false + } + } + + /** + * 结束对练 + */ + const endPractice = async () => { + if (!roomCode.value) return + + isLoading.value = true + try { + const res: any = await duoPracticeApi.endPractice(roomCode.value) + if (res.code === 200) { + ElMessage.success('对练结束') + await fetchRoomDetail() + stopPolling() + return res.data + } else { + throw new Error(res.message || '结束失败') + } + } catch (error: any) { + ElMessage.error(error.message || '结束对练失败') + throw error + } finally { + isLoading.value = false + } + } + + /** + * 离开房间 + */ + const leaveRoom = async () => { + if (!roomCode.value) return + + try { + await duoPracticeApi.leaveRoom(roomCode.value) + resetState() + } catch (error) { + console.error('离开房间失败:', error) + } + } + + /** + * 发送消息 + */ + const sendMessage = async (content?: string) => { + const msg = content || inputMessage.value.trim() + if (!msg || !roomCode.value) return + + try { + const res: any = await duoPracticeApi.sendMessage(roomCode.value, msg) + if (res.code === 200) { + inputMessage.value = '' + // 消息会通过轮询获取 + } + } catch (error: any) { + ElMessage.error(error.message || '发送失败') + } + } + + /** + * 获取消息 + */ + const fetchMessages = async () => { + if (!roomCode.value) return + + try { + const res: any = await duoPracticeApi.getMessages(roomCode.value, lastSequence.value) + if (res.code === 200) { + const newMessages = res.data.messages + if (newMessages.length > 0) { + messages.value.push(...newMessages) + lastSequence.value = res.data.last_sequence + } + + // 检查房间状态变化 + if (res.data.room_status !== roomInfo.value?.status) { + await fetchRoomDetail() + } + } + } catch (error) { + console.error('获取消息失败:', error) + } + } + + /** + * 开始轮询消息 + */ + const startPolling = () => { + if (pollingTimer) return + + isConnected.value = true + + // 立即获取一次 + fetchMessages() + + // 每500ms轮询一次 + pollingTimer = window.setInterval(() => { + fetchMessages() + }, 500) + + console.log('[DuoPractice] 开始轮询消息') + } + + /** + * 停止轮询 + */ + const stopPolling = () => { + if (pollingTimer) { + clearInterval(pollingTimer) + pollingTimer = null + } + isConnected.value = false + console.log('[DuoPractice] 停止轮询消息') + } + + /** + * 重置状态 + */ + const resetState = () => { + stopPolling() + roomCode.value = '' + roomInfo.value = null + hostUser.value = null + guestUser.value = null + myRole.value = '' + myRoleName.value = '' + isHost.value = false + messages.value = [] + lastSequence.value = 0 + inputMessage.value = '' + isLoading.value = false + } + + /** + * 生成分享链接 + */ + const getShareLink = () => { + if (!roomCode.value) return '' + return duoPracticeApi.generateShareLink(roomCode.value) + } + + /** + * 复制房间码 + */ + const copyRoomCode = async () => { + if (!roomCode.value) return + + try { + await navigator.clipboard.writeText(roomCode.value) + ElMessage.success('房间码已复制') + } catch (error) { + ElMessage.error('复制失败') + } + } + + /** + * 复制分享链接 + */ + const copyShareLink = async () => { + const link = getShareLink() + if (!link) return + + try { + await navigator.clipboard.writeText(link) + ElMessage.success('链接已复制') + } catch (error) { + ElMessage.error('复制失败') + } + } + + // ==================== 返回 ==================== + + return { + // 状态 + roomCode, + roomInfo, + hostUser, + guestUser, + myRole, + myRoleName, + isHost, + messages, + lastSequence, + isLoading, + isConnected, + inputMessage, + + // 计算属性 + roomStatus, + isWaiting, + isReady, + isPracticing, + isCompleted, + partnerUser, + partnerRoleName, + chatMessages, + systemMessages, + + // 方法 + createRoom, + joinRoom, + fetchRoomDetail, + startPractice, + endPractice, + leaveRoom, + sendMessage, + fetchMessages, + startPolling, + stopPolling, + resetState, + getShareLink, + copyRoomCode, + copyShareLink + } +}) diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts index 3849e5d..b76fbd8 100644 --- a/frontend/src/utils/auth.ts +++ b/frontend/src/utils/auth.ts @@ -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(() => { + // 忽略导入错误 + }) } /** diff --git a/frontend/src/utils/dingtalk.ts b/frontend/src/utils/dingtalk.ts index 6652454..4a5b573 100644 --- a/frontend/src/utils/dingtalk.ts +++ b/frontend/src/utils/dingtalk.ts @@ -1,227 +1,227 @@ -/** - * 钉钉SDK工具类 - * - * 提供钉钉环境检测、免登授权码获取等功能 - */ - -// 钉钉JSAPI类型声明 -declare global { - interface Window { - dd?: { - env: { - platform: 'notInDingTalk' | 'android' | 'ios' | 'pc' - } - ready: (callback: () => void) => void - error: (callback: (err: any) => void) => void - runtime: { - permission: { - requestAuthCode: (options: { - corpId: string - onSuccess: (result: { code: string }) => void - onFail: (err: any) => void - }) => void - } - } - biz: { - navigation: { - setTitle: (options: { title: string }) => void - } - } - } - } -} - -/** - * 钉钉配置接口 - */ -export interface DingtalkConfig { - enabled: boolean - corp_id: string | null - agent_id: string | null -} - -/** - * 检测是否在钉钉环境中 - */ -export function isDingtalkEnv(): boolean { - if (typeof window === 'undefined') { - console.log('[钉钉检测] window 不存在') - return false - } - - // 首先通过 User-Agent 检测 - const ua = navigator.userAgent.toLowerCase() - const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp') - console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100)) - - if (!window.dd) { - console.log('[钉钉检测] window.dd 不存在,但UA检测为:', isDingTalkUA) - return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true - } - - const platform = window.dd.env?.platform - console.log('[钉钉检测] dd.env.platform:', platform) - - return platform !== 'notInDingTalk' -} - -/** - * 获取钉钉平台类型 - */ -export function getDingtalkPlatform(): string { - if (!window.dd) return 'notInDingTalk' - return window.dd.env.platform -} - -/** - * 等待钉钉SDK就绪(带超时) - */ -export function waitDingtalkReady(timeout: number = 5000): Promise { - return new Promise((resolve, reject) => { - if (!window.dd) { - reject(new Error('钉钉SDK未加载')) - return - } - - let resolved = false - - // 超时处理 - const timer = setTimeout(() => { - if (!resolved) { - resolved = true - console.warn('钉钉SDK就绪超时,尝试继续执行') - resolve() // 超时后也尝试继续,可能SDK已经就绪 - } - }, timeout) - - window.dd.ready(() => { - if (!resolved) { - resolved = true - clearTimeout(timer) - console.log('钉钉SDK就绪') - resolve() - } - }) - - window.dd.error((err) => { - if (!resolved) { - resolved = true - clearTimeout(timer) - console.error('钉钉SDK错误:', err) - reject(err) - } - }) - }) -} - -/** - * 获取钉钉免登授权码 - * - * @param corpId 企业CorpId - * @returns 免登授权码 - */ -export function getAuthCode(corpId: string): Promise { - return new Promise((resolve, reject) => { - if (!window.dd) { - reject(new Error('钉钉SDK未加载')) - return - } - - if (!isDingtalkEnv()) { - reject(new Error('当前不在钉钉环境中')) - return - } - - window.dd.runtime.permission.requestAuthCode({ - corpId: corpId, - onSuccess: (result) => { - resolve(result.code) - }, - onFail: (err) => { - console.error('获取钉钉授权码失败:', err) - reject(new Error(err.message || '获取授权码失败')) - } - }) - }) -} - -/** - * 设置钉钉页面标题 - */ -export function setDingtalkTitle(title: string): void { - if (!window.dd || !isDingtalkEnv()) return - - try { - window.dd.biz.navigation.setTitle({ title }) - } catch (e) { - console.warn('设置钉钉标题失败:', e) - } -} - -/** - * 加载钉钉JSAPI SDK - * - * 动态加载钉钉SDK脚本 - */ -export function loadDingtalkSDK(): Promise { - return new Promise((resolve, reject) => { - // 如果已经加载过,直接返回 - if (window.dd) { - resolve() - return - } - - const script = document.createElement('script') - script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js' - script.async = true - - script.onload = () => { - console.log('钉钉SDK加载成功') - resolve() - } - - script.onerror = () => { - reject(new Error('钉钉SDK加载失败')) - } - - document.head.appendChild(script) - }) -} - -/** - * 钉钉免密登录完整流程 - * - * @param corpId 企业CorpId - * @param loginApi 登录API函数 - * @returns 登录结果 - */ -export async function dingtalkAutoLogin( - corpId: string, - loginApi: (code: string) => Promise -): Promise { - // 1. 检测钉钉环境 - if (!isDingtalkEnv()) { - throw new Error('当前不在钉钉环境中,无法使用免密登录') - } - - // 2. 等待SDK就绪 - await waitDingtalkReady() - - // 3. 获取授权码 - const code = await getAuthCode(corpId) - - // 4. 调用登录API - const result = await loginApi(code) - - return result -} - -export default { - isDingtalkEnv, - getDingtalkPlatform, - waitDingtalkReady, - getAuthCode, - setDingtalkTitle, - loadDingtalkSDK, - dingtalkAutoLogin -} +/** + * 钉钉SDK工具类 + * + * 提供钉钉环境检测、免登授权码获取等功能 + */ + +// 钉钉JSAPI类型声明 +declare global { + interface Window { + dd?: { + env: { + platform: 'notInDingTalk' | 'android' | 'ios' | 'pc' + } + ready: (callback: () => void) => void + error: (callback: (err: any) => void) => void + runtime: { + permission: { + requestAuthCode: (options: { + corpId: string + onSuccess: (result: { code: string }) => void + onFail: (err: any) => void + }) => void + } + } + biz: { + navigation: { + setTitle: (options: { title: string }) => void + } + } + } + } +} + +/** + * 钉钉配置接口 + */ +export interface DingtalkConfig { + enabled: boolean + corp_id: string | null + agent_id: string | null +} + +/** + * 检测是否在钉钉环境中 + */ +export function isDingtalkEnv(): boolean { + if (typeof window === 'undefined') { + console.log('[钉钉检测] window 不存在') + return false + } + + // 首先通过 User-Agent 检测 + const ua = navigator.userAgent.toLowerCase() + const isDingTalkUA = ua.includes('dingtalk') || ua.includes('aliapp') + console.log('[钉钉检测] UA检测:', isDingTalkUA, 'UA:', ua.substring(0, 100)) + + if (!window.dd) { + console.log('[钉钉检测] window.dd 不存在,但UA检测为:', isDingTalkUA) + return isDingTalkUA // 如果 UA 显示是钉钉但 SDK 还没加载,也返回 true + } + + const platform = window.dd.env?.platform + console.log('[钉钉检测] dd.env.platform:', platform) + + return platform !== 'notInDingTalk' +} + +/** + * 获取钉钉平台类型 + */ +export function getDingtalkPlatform(): string { + if (!window.dd) return 'notInDingTalk' + return window.dd.env.platform +} + +/** + * 等待钉钉SDK就绪(带超时) + */ +export function waitDingtalkReady(timeout: number = 5000): Promise { + return new Promise((resolve, reject) => { + if (!window.dd) { + reject(new Error('钉钉SDK未加载')) + return + } + + let resolved = false + + // 超时处理 + const timer = setTimeout(() => { + if (!resolved) { + resolved = true + console.warn('钉钉SDK就绪超时,尝试继续执行') + resolve() // 超时后也尝试继续,可能SDK已经就绪 + } + }, timeout) + + window.dd.ready(() => { + if (!resolved) { + resolved = true + clearTimeout(timer) + console.log('钉钉SDK就绪') + resolve() + } + }) + + window.dd.error((err) => { + if (!resolved) { + resolved = true + clearTimeout(timer) + console.error('钉钉SDK错误:', err) + reject(err) + } + }) + }) +} + +/** + * 获取钉钉免登授权码 + * + * @param corpId 企业CorpId + * @returns 免登授权码 + */ +export function getAuthCode(corpId: string): Promise { + return new Promise((resolve, reject) => { + if (!window.dd) { + reject(new Error('钉钉SDK未加载')) + return + } + + if (!isDingtalkEnv()) { + reject(new Error('当前不在钉钉环境中')) + return + } + + window.dd.runtime.permission.requestAuthCode({ + corpId: corpId, + onSuccess: (result) => { + resolve(result.code) + }, + onFail: (err) => { + console.error('获取钉钉授权码失败:', err) + reject(new Error(err.message || '获取授权码失败')) + } + }) + }) +} + +/** + * 设置钉钉页面标题 + */ +export function setDingtalkTitle(title: string): void { + if (!window.dd || !isDingtalkEnv()) return + + try { + window.dd.biz.navigation.setTitle({ title }) + } catch (e) { + console.warn('设置钉钉标题失败:', e) + } +} + +/** + * 加载钉钉JSAPI SDK + * + * 动态加载钉钉SDK脚本 + */ +export function loadDingtalkSDK(): Promise { + return new Promise((resolve, reject) => { + // 如果已经加载过,直接返回 + if (window.dd) { + resolve() + return + } + + const script = document.createElement('script') + script.src = 'https://g.alicdn.com/dingding/dingtalk-jsapi/3.0.12/dingtalk.open.js' + script.async = true + + script.onload = () => { + console.log('钉钉SDK加载成功') + resolve() + } + + script.onerror = () => { + reject(new Error('钉钉SDK加载失败')) + } + + document.head.appendChild(script) + }) +} + +/** + * 钉钉免密登录完整流程 + * + * @param corpId 企业CorpId + * @param loginApi 登录API函数 + * @returns 登录结果 + */ +export async function dingtalkAutoLogin( + corpId: string, + loginApi: (code: string) => Promise +): Promise { + // 1. 检测钉钉环境 + if (!isDingtalkEnv()) { + throw new Error('当前不在钉钉环境中,无法使用免密登录') + } + + // 2. 等待SDK就绪 + await waitDingtalkReady() + + // 3. 获取授权码 + const code = await getAuthCode(corpId) + + // 4. 调用登录API + const result = await loginApi(code) + + return result +} + +export default { + isDingtalkEnv, + getDingtalkPlatform, + waitDingtalkReady, + getAuthCode, + setDingtalkTitle, + loadDingtalkSDK, + dingtalkAutoLogin +} diff --git a/frontend/src/utils/permissionChecker.ts b/frontend/src/utils/permissionChecker.ts new file mode 100644 index 0000000..751a68d --- /dev/null +++ b/frontend/src/utils/permissionChecker.ts @@ -0,0 +1,211 @@ +/** + * 权限检查工具 + * 用于前端路由守卫和组件级权限控制 + */ + +import { authManager } from './auth' + +// 缓存团队成员关系 +const teamMembershipCache = new Map() +// 缓存课程访问权限 +const courseAccessCache = new Map() + +// 缓存过期时间(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 { + // 管理员可以访问所有团队 + 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 { + // 管理员和经理可以访问所有课程 + 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 { + 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, +} diff --git a/frontend/src/utils/speechRecognition.ts b/frontend/src/utils/speechRecognition.ts new file mode 100644 index 0000000..0fe0011 --- /dev/null +++ b/frontend/src/utils/speechRecognition.ts @@ -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 = { + '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 { + 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, +} diff --git a/frontend/src/utils/webrtc.ts b/frontend/src/utils/webrtc.ts index 2e8fffa..015bfe7 100644 --- a/frontend/src/utils/webrtc.ts +++ b/frontend/src/utils/webrtc.ts @@ -1,324 +1,324 @@ -/** - * WebRTC 连接管理模块 - * - * 功能: - * - 管理 RTCPeerConnection 生命周期 - * - 处理 SDP 交换 - * - 处理 ICE 候选收集 - * - 音频流管理 - */ - -export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed' - -export interface WebRTCConfig { - iceServers?: RTCIceServer[] - onLocalStream?: (stream: MediaStream) => void - onRemoteStream?: (stream: MediaStream) => void - onConnectionStateChange?: (state: ConnectionState) => void - onIceCandidate?: (candidate: RTCIceCandidate) => void - onError?: (error: Error) => void -} - -// 默认 ICE 服务器配置 -const DEFAULT_ICE_SERVERS: RTCIceServer[] = [ - { urls: 'stun:stun.l.google.com:19302' }, - { urls: 'stun:stun1.l.google.com:19302' }, - { urls: 'stun:stun2.l.google.com:19302' } -] - -export class WebRTCManager { - private peerConnection: RTCPeerConnection | null = null - private localStream: MediaStream | null = null - private remoteStream: MediaStream | null = null - private config: WebRTCConfig - private connectionState: ConnectionState = 'idle' - private pendingIceCandidates: RTCIceCandidate[] = [] - - constructor(config: WebRTCConfig = {}) { - this.config = { - iceServers: DEFAULT_ICE_SERVERS, - ...config - } - } - - /** - * 获取当前连接状态 - */ - getConnectionState(): ConnectionState { - return this.connectionState - } - - /** - * 获取本地音频流 - */ - getLocalStream(): MediaStream | null { - return this.localStream - } - - /** - * 获取远程音频流 - */ - getRemoteStream(): MediaStream | null { - return this.remoteStream - } - - /** - * 初始化本地音频流 - */ - async initLocalStream(): Promise { - try { - this.localStream = await navigator.mediaDevices.getUserMedia({ - audio: { - echoCancellation: true, - noiseSuppression: true, - autoGainControl: true - }, - video: false - }) - - this.config.onLocalStream?.(this.localStream) - return this.localStream - } catch (error) { - const err = error instanceof Error ? error : new Error('获取麦克风权限失败') - this.config.onError?.(err) - throw err - } - } - - /** - * 创建 PeerConnection - */ - private createPeerConnection(): RTCPeerConnection { - const pc = new RTCPeerConnection({ - iceServers: this.config.iceServers - }) - - // 监听 ICE 候选 - pc.onicecandidate = (event) => { - if (event.candidate) { - console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50)) - this.config.onIceCandidate?.(event.candidate) - } - } - - // 监听连接状态变化 - pc.onconnectionstatechange = () => { - console.log('[WebRTC] Connection state:', pc.connectionState) - this.updateConnectionState(pc.connectionState) - } - - // 监听 ICE 连接状态 - pc.oniceconnectionstatechange = () => { - console.log('[WebRTC] ICE connection state:', pc.iceConnectionState) - if (pc.iceConnectionState === 'failed') { - this.updateConnectionState('failed') - } - } - - // 监听远程流 - pc.ontrack = (event) => { - console.log('[WebRTC] Remote track received') - if (event.streams && event.streams[0]) { - this.remoteStream = event.streams[0] - this.config.onRemoteStream?.(this.remoteStream) - } - } - - return pc - } - - /** - * 更新连接状态 - */ - private updateConnectionState(state: RTCPeerConnectionState | string) { - const stateMap: Record = { - 'new': 'connecting', - 'connecting': 'connecting', - 'connected': 'connected', - 'disconnected': 'disconnected', - 'failed': 'failed', - 'closed': 'disconnected' - } - - this.connectionState = stateMap[state] || 'idle' - this.config.onConnectionStateChange?.(this.connectionState) - } - - /** - * 创建 Offer(发起方调用) - */ - async createOffer(): Promise { - if (!this.localStream) { - await this.initLocalStream() - } - - this.peerConnection = this.createPeerConnection() - this.updateConnectionState('connecting') - - // 添加本地音频轨道 - this.localStream!.getTracks().forEach(track => { - this.peerConnection!.addTrack(track, this.localStream!) - }) - - // 创建 Offer - const offer = await this.peerConnection.createOffer() - await this.peerConnection.setLocalDescription(offer) - - console.log('[WebRTC] Offer created') - return offer - } - - /** - * 处理 Offer(接收方调用) - */ - async handleOffer(offer: RTCSessionDescriptionInit): Promise { - if (!this.localStream) { - await this.initLocalStream() - } - - this.peerConnection = this.createPeerConnection() - this.updateConnectionState('connecting') - - // 添加本地音频轨道 - this.localStream!.getTracks().forEach(track => { - this.peerConnection!.addTrack(track, this.localStream!) - }) - - // 设置远程描述 - await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)) - - // 处理等待中的 ICE 候选 - for (const candidate of this.pendingIceCandidates) { - await this.peerConnection.addIceCandidate(candidate) - } - this.pendingIceCandidates = [] - - // 创建 Answer - const answer = await this.peerConnection.createAnswer() - await this.peerConnection.setLocalDescription(answer) - - console.log('[WebRTC] Answer created') - return answer - } - - /** - * 处理 Answer(发起方调用) - */ - async handleAnswer(answer: RTCSessionDescriptionInit): Promise { - if (!this.peerConnection) { - throw new Error('PeerConnection not initialized') - } - - await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer)) - - // 处理等待中的 ICE 候选 - for (const candidate of this.pendingIceCandidates) { - await this.peerConnection.addIceCandidate(candidate) - } - this.pendingIceCandidates = [] - - console.log('[WebRTC] Answer handled') - } - - /** - * 添加 ICE 候选 - */ - async addIceCandidate(candidate: RTCIceCandidateInit): Promise { - const iceCandidate = new RTCIceCandidate(candidate) - - if (this.peerConnection && this.peerConnection.remoteDescription) { - await this.peerConnection.addIceCandidate(iceCandidate) - console.log('[WebRTC] ICE candidate added') - } else { - // 如果远程描述还没设置,先缓存候选 - this.pendingIceCandidates.push(iceCandidate) - console.log('[WebRTC] ICE candidate queued') - } - } - - /** - * 静音/取消静音本地音频 - */ - setMuted(muted: boolean): void { - if (this.localStream) { - this.localStream.getAudioTracks().forEach(track => { - track.enabled = !muted - }) - } - } - - /** - * 检查是否静音 - */ - isMuted(): boolean { - if (this.localStream) { - const audioTrack = this.localStream.getAudioTracks()[0] - return audioTrack ? !audioTrack.enabled : true - } - return true - } - - /** - * 获取音频音量级别(用于音量指示器) - */ - async getAudioLevel(stream: MediaStream): Promise { - return new Promise((resolve) => { - const audioContext = new AudioContext() - const analyser = audioContext.createAnalyser() - const source = audioContext.createMediaStreamSource(stream) - - source.connect(analyser) - analyser.fftSize = 256 - - const dataArray = new Uint8Array(analyser.frequencyBinCount) - analyser.getByteFrequencyData(dataArray) - - // 计算平均音量 - const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length - - audioContext.close() - resolve(average / 255) // 归一化到 0-1 - }) - } - - /** - * 关闭连接 - */ - close(): void { - console.log('[WebRTC] Closing connection') - - // 停止本地流 - if (this.localStream) { - this.localStream.getTracks().forEach(track => track.stop()) - this.localStream = null - } - - // 停止远程流 - if (this.remoteStream) { - this.remoteStream.getTracks().forEach(track => track.stop()) - this.remoteStream = null - } - - // 关闭 PeerConnection - if (this.peerConnection) { - this.peerConnection.close() - this.peerConnection = null - } - - this.pendingIceCandidates = [] - this.updateConnectionState('disconnected') - } - - /** - * 重置管理器 - */ - reset(): void { - this.close() - this.connectionState = 'idle' - } -} - -// 导出单例工厂函数 -export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager { - return new WebRTCManager(config) -} +/** + * WebRTC 连接管理模块 + * + * 功能: + * - 管理 RTCPeerConnection 生命周期 + * - 处理 SDP 交换 + * - 处理 ICE 候选收集 + * - 音频流管理 + */ + +export type ConnectionState = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'failed' + +export interface WebRTCConfig { + iceServers?: RTCIceServer[] + onLocalStream?: (stream: MediaStream) => void + onRemoteStream?: (stream: MediaStream) => void + onConnectionStateChange?: (state: ConnectionState) => void + onIceCandidate?: (candidate: RTCIceCandidate) => void + onError?: (error: Error) => void +} + +// 默认 ICE 服务器配置 +const DEFAULT_ICE_SERVERS: RTCIceServer[] = [ + { urls: 'stun:stun.l.google.com:19302' }, + { urls: 'stun:stun1.l.google.com:19302' }, + { urls: 'stun:stun2.l.google.com:19302' } +] + +export class WebRTCManager { + private peerConnection: RTCPeerConnection | null = null + private localStream: MediaStream | null = null + private remoteStream: MediaStream | null = null + private config: WebRTCConfig + private connectionState: ConnectionState = 'idle' + private pendingIceCandidates: RTCIceCandidate[] = [] + + constructor(config: WebRTCConfig = {}) { + this.config = { + iceServers: DEFAULT_ICE_SERVERS, + ...config + } + } + + /** + * 获取当前连接状态 + */ + getConnectionState(): ConnectionState { + return this.connectionState + } + + /** + * 获取本地音频流 + */ + getLocalStream(): MediaStream | null { + return this.localStream + } + + /** + * 获取远程音频流 + */ + getRemoteStream(): MediaStream | null { + return this.remoteStream + } + + /** + * 初始化本地音频流 + */ + async initLocalStream(): Promise { + try { + this.localStream = await navigator.mediaDevices.getUserMedia({ + audio: { + echoCancellation: true, + noiseSuppression: true, + autoGainControl: true + }, + video: false + }) + + this.config.onLocalStream?.(this.localStream) + return this.localStream + } catch (error) { + const err = error instanceof Error ? error : new Error('获取麦克风权限失败') + this.config.onError?.(err) + throw err + } + } + + /** + * 创建 PeerConnection + */ + private createPeerConnection(): RTCPeerConnection { + const pc = new RTCPeerConnection({ + iceServers: this.config.iceServers + }) + + // 监听 ICE 候选 + pc.onicecandidate = (event) => { + if (event.candidate) { + console.log('[WebRTC] ICE candidate:', event.candidate.candidate?.substring(0, 50)) + this.config.onIceCandidate?.(event.candidate) + } + } + + // 监听连接状态变化 + pc.onconnectionstatechange = () => { + console.log('[WebRTC] Connection state:', pc.connectionState) + this.updateConnectionState(pc.connectionState) + } + + // 监听 ICE 连接状态 + pc.oniceconnectionstatechange = () => { + console.log('[WebRTC] ICE connection state:', pc.iceConnectionState) + if (pc.iceConnectionState === 'failed') { + this.updateConnectionState('failed') + } + } + + // 监听远程流 + pc.ontrack = (event) => { + console.log('[WebRTC] Remote track received') + if (event.streams && event.streams[0]) { + this.remoteStream = event.streams[0] + this.config.onRemoteStream?.(this.remoteStream) + } + } + + return pc + } + + /** + * 更新连接状态 + */ + private updateConnectionState(state: RTCPeerConnectionState | string) { + const stateMap: Record = { + 'new': 'connecting', + 'connecting': 'connecting', + 'connected': 'connected', + 'disconnected': 'disconnected', + 'failed': 'failed', + 'closed': 'disconnected' + } + + this.connectionState = stateMap[state] || 'idle' + this.config.onConnectionStateChange?.(this.connectionState) + } + + /** + * 创建 Offer(发起方调用) + */ + async createOffer(): Promise { + if (!this.localStream) { + await this.initLocalStream() + } + + this.peerConnection = this.createPeerConnection() + this.updateConnectionState('connecting') + + // 添加本地音频轨道 + this.localStream!.getTracks().forEach(track => { + this.peerConnection!.addTrack(track, this.localStream!) + }) + + // 创建 Offer + const offer = await this.peerConnection.createOffer() + await this.peerConnection.setLocalDescription(offer) + + console.log('[WebRTC] Offer created') + return offer + } + + /** + * 处理 Offer(接收方调用) + */ + async handleOffer(offer: RTCSessionDescriptionInit): Promise { + if (!this.localStream) { + await this.initLocalStream() + } + + this.peerConnection = this.createPeerConnection() + this.updateConnectionState('connecting') + + // 添加本地音频轨道 + this.localStream!.getTracks().forEach(track => { + this.peerConnection!.addTrack(track, this.localStream!) + }) + + // 设置远程描述 + await this.peerConnection.setRemoteDescription(new RTCSessionDescription(offer)) + + // 处理等待中的 ICE 候选 + for (const candidate of this.pendingIceCandidates) { + await this.peerConnection.addIceCandidate(candidate) + } + this.pendingIceCandidates = [] + + // 创建 Answer + const answer = await this.peerConnection.createAnswer() + await this.peerConnection.setLocalDescription(answer) + + console.log('[WebRTC] Answer created') + return answer + } + + /** + * 处理 Answer(发起方调用) + */ + async handleAnswer(answer: RTCSessionDescriptionInit): Promise { + if (!this.peerConnection) { + throw new Error('PeerConnection not initialized') + } + + await this.peerConnection.setRemoteDescription(new RTCSessionDescription(answer)) + + // 处理等待中的 ICE 候选 + for (const candidate of this.pendingIceCandidates) { + await this.peerConnection.addIceCandidate(candidate) + } + this.pendingIceCandidates = [] + + console.log('[WebRTC] Answer handled') + } + + /** + * 添加 ICE 候选 + */ + async addIceCandidate(candidate: RTCIceCandidateInit): Promise { + const iceCandidate = new RTCIceCandidate(candidate) + + if (this.peerConnection && this.peerConnection.remoteDescription) { + await this.peerConnection.addIceCandidate(iceCandidate) + console.log('[WebRTC] ICE candidate added') + } else { + // 如果远程描述还没设置,先缓存候选 + this.pendingIceCandidates.push(iceCandidate) + console.log('[WebRTC] ICE candidate queued') + } + } + + /** + * 静音/取消静音本地音频 + */ + setMuted(muted: boolean): void { + if (this.localStream) { + this.localStream.getAudioTracks().forEach(track => { + track.enabled = !muted + }) + } + } + + /** + * 检查是否静音 + */ + isMuted(): boolean { + if (this.localStream) { + const audioTrack = this.localStream.getAudioTracks()[0] + return audioTrack ? !audioTrack.enabled : true + } + return true + } + + /** + * 获取音频音量级别(用于音量指示器) + */ + async getAudioLevel(stream: MediaStream): Promise { + return new Promise((resolve) => { + const audioContext = new AudioContext() + const analyser = audioContext.createAnalyser() + const source = audioContext.createMediaStreamSource(stream) + + source.connect(analyser) + analyser.fftSize = 256 + + const dataArray = new Uint8Array(analyser.frequencyBinCount) + analyser.getByteFrequencyData(dataArray) + + // 计算平均音量 + const average = dataArray.reduce((a, b) => a + b, 0) / dataArray.length + + audioContext.close() + resolve(average / 255) // 归一化到 0-1 + }) + } + + /** + * 关闭连接 + */ + close(): void { + console.log('[WebRTC] Closing connection') + + // 停止本地流 + if (this.localStream) { + this.localStream.getTracks().forEach(track => track.stop()) + this.localStream = null + } + + // 停止远程流 + if (this.remoteStream) { + this.remoteStream.getTracks().forEach(track => track.stop()) + this.remoteStream = null + } + + // 关闭 PeerConnection + if (this.peerConnection) { + this.peerConnection.close() + this.peerConnection = null + } + + this.pendingIceCandidates = [] + this.updateConnectionState('disconnected') + } + + /** + * 重置管理器 + */ + reset(): void { + this.close() + this.connectionState = 'idle' + } +} + +// 导出单例工厂函数 +export function createWebRTCManager(config?: WebRTCConfig): WebRTCManager { + return new WebRTCManager(config) +} diff --git a/frontend/src/views/admin/data-dashboard.vue b/frontend/src/views/admin/data-dashboard.vue index a91e163..4c42641 100644 --- a/frontend/src/views/admin/data-dashboard.vue +++ b/frontend/src/views/admin/data-dashboard.vue @@ -1,768 +1,768 @@ - - - - - + + + + + diff --git a/frontend/src/views/admin/system-settings.vue b/frontend/src/views/admin/system-settings.vue index b098f9c..6be669e 100644 --- a/frontend/src/views/admin/system-settings.vue +++ b/frontend/src/views/admin/system-settings.vue @@ -1,251 +1,251 @@ - - - - - + + + + + diff --git a/frontend/src/views/trainee/ai-practice-coze.vue b/frontend/src/views/trainee/ai-practice-coze.vue index 895c262..d4c8659 100644 --- a/frontend/src/views/trainee/ai-practice-coze.vue +++ b/frontend/src/views/trainee/ai-practice-coze.vue @@ -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(null) const audioChunks = ref([]) +// 语音识别相关 +const speechRecognition = ref(null) +const recognizedText = ref('') +const isSpeechSupported = isSpeechRecognitionSupported() + // DOM引用 const messageContainer = ref() @@ -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 + } }) diff --git a/frontend/src/views/trainee/duo-practice-report.vue b/frontend/src/views/trainee/duo-practice-report.vue index 42a6c5c..dcebc65 100644 --- a/frontend/src/views/trainee/duo-practice-report.vue +++ b/frontend/src/views/trainee/duo-practice-report.vue @@ -1,544 +1,593 @@ - - - - - + + + + + diff --git a/frontend/src/views/trainee/duo-practice-room.vue b/frontend/src/views/trainee/duo-practice-room.vue index 66c4141..925568c 100644 --- a/frontend/src/views/trainee/duo-practice-room.vue +++ b/frontend/src/views/trainee/duo-practice-room.vue @@ -1,913 +1,913 @@ - - - - - + + + + + diff --git a/frontend/src/views/trainee/duo-practice.vue b/frontend/src/views/trainee/duo-practice.vue index ee5b74e..f92d555 100644 --- a/frontend/src/views/trainee/duo-practice.vue +++ b/frontend/src/views/trainee/duo-practice.vue @@ -1,401 +1,401 @@ - - - - - + + + + + diff --git a/frontend/src/views/trainee/leaderboard.vue b/frontend/src/views/trainee/leaderboard.vue index 6846057..11f107b 100644 --- a/frontend/src/views/trainee/leaderboard.vue +++ b/frontend/src/views/trainee/leaderboard.vue @@ -1,621 +1,621 @@ - - - - - + + + + + diff --git a/frontend/src/views/trainee/my-certificates.vue b/frontend/src/views/trainee/my-certificates.vue index 6beee92..c98f5f5 100644 --- a/frontend/src/views/trainee/my-certificates.vue +++ b/frontend/src/views/trainee/my-certificates.vue @@ -1,734 +1,734 @@ - - - - - + + + + +