commit 998211c483cd2a42d6a6d0d4848ae0ae916bb377 Author: 111 Date: Sat Jan 24 19:33:28 2026 +0800 feat: 初始化考培练系统项目 - 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..3725d47 --- /dev/null +++ b/.cursorignore @@ -0,0 +1 @@ +# 不忽略任何文件,所有文件均可被Cursor访问和编辑 diff --git a/.cursorrules b/.cursorrules new file mode 100644 index 0000000..c9d5aa2 --- /dev/null +++ b/.cursorrules @@ -0,0 +1,18 @@ +# Cursor 规则配置文件 +# 允许查看和编辑私钥文件 + +# 文件访问规则 +- 允许查看 .pem 文件 +- 允许查看 .key 文件 +- 允许查看 .crt 文件 +- 允许查看 .cert 文件 + +# 安全提醒 +- 私钥文件包含敏感信息,请谨慎处理 +- 建议使用环境变量管理密钥 +- 不要将私钥提交到版本控制系统 + +# 项目特定规则 +- 本项目使用 Python + Vue3 + MySQL + FastAPI +- 支持 Docker 容器化部署 +- 使用中文注释和文档 diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..632e3d6 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,142 @@ +--- +kind: pipeline +type: docker +name: test-deploy + +# 仅在 test 分支触发测试环境部署 +trigger: + branch: + - test + event: + - push + +steps: + # Step 1: 构建后端镜像 + - name: build-backend + image: plugins/docker + settings: + registry: registry.cn-shenzhen.aliyuncs.com + repo: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-backend + username: + from_secret: docker_username + password: + from_secret: docker_password + dockerfile: backend/Dockerfile + context: backend + tags: + - test + - ${DRONE_COMMIT_SHA:0:8} + + # Step 2: 构建前端镜像 + - name: build-frontend + image: plugins/docker + settings: + registry: registry.cn-shenzhen.aliyuncs.com + repo: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-frontend + username: + from_secret: docker_username + password: + from_secret: docker_password + dockerfile: frontend/Dockerfile + context: frontend + tags: + - test + - ${DRONE_COMMIT_SHA:0:8} + + # Step 3: 部署到测试服务器 + - name: deploy-test + image: appleboy/drone-ssh + settings: + host: 47.107.172.23 + username: root + password: + from_secret: ssh_password + port: 22 + script: + - echo "=== 部署考培练系统测试环境 ===" + - cd /data/kaopeilian-test || mkdir -p /data/kaopeilian-test + - | + cat > docker-compose.yml << 'EOF' + version: '3.8' + services: + backend: + image: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-backend:test + container_name: kaopeilian-backend-test + restart: always + ports: + - "18000:8000" + environment: + - DATABASE_URL=${DATABASE_URL} + - REDIS_HOST=${REDIS_HOST} + - REDIS_PORT=${REDIS_PORT} + - REDIS_PASSWORD=${REDIS_PASSWORD} + networks: + - kaopeilian-net + + frontend: + image: registry.cn-shenzhen.aliyuncs.com/ruimeiyun/kaopeilian-frontend:test + container_name: kaopeilian-frontend-test + restart: always + ports: + - "13001:80" + depends_on: + - backend + networks: + - kaopeilian-net + + networks: + kaopeilian-net: + driver: bridge + EOF + - docker-compose pull + - docker-compose up -d + - docker ps | grep kaopeilian + - echo "=== 部署完成 ===" + + # Step 4: 通知部署结果 + - name: notify + image: plugins/webhook + settings: + urls: + from_secret: webhook_url + content_type: application/json + template: | + { + "msgtype": "text", + "text": { + "content": "🚀 考培练系统测试环境部署完成\n分支: ${DRONE_BRANCH}\n提交: ${DRONE_COMMIT_SHA:0:8}\n作者: ${DRONE_COMMIT_AUTHOR}" + } + } + when: + status: + - success + - failure + +--- +kind: pipeline +type: docker +name: code-check + +# 所有分支推送时进行代码检查 +trigger: + event: + - push + - pull_request + +steps: + # Python 代码检查 + - name: python-lint + image: python:3.9-slim + commands: + - cd backend + - pip install flake8 -q + - flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics || true + - echo "Python lint completed" + + # Node.js 代码检查 + - name: frontend-lint + image: node:18-alpine + commands: + - cd frontend + - npm install -q 2>/dev/null || true + - npm run lint 2>/dev/null || echo "Frontend lint completed" diff --git a/.env.admin b/.env.admin new file mode 100644 index 0000000..56633b8 --- /dev/null +++ b/.env.admin @@ -0,0 +1,20 @@ +# ============================================ +# 考培练系统 SaaS 管理后台环境变量 +# +# 注意:此文件包含敏感信息,请确保: +# 1. 文件权限设置为 600(chmod 600 .env.admin) +# 2. 不要提交到 Git 仓库 +# ============================================ + +# 管理后台数据库配置 +ADMIN_DB_HOST=prod-mysql +ADMIN_DB_PORT=3306 +ADMIN_DB_USER=root +ADMIN_DB_PASSWORD=ProdMySQL2025!@# +ADMIN_DB_NAME=kaopeilian_admin + +# JWT 密钥 +ADMIN_JWT_SECRET=admin-secret-key-kaopeilian-2026-production + +# JWT Token 过期时间(秒) +ADMIN_JWT_EXPIRE_SECONDS=86400 diff --git a/.env.development b/.env.development new file mode 100644 index 0000000..4cf0432 --- /dev/null +++ b/.env.development @@ -0,0 +1,25 @@ +# 全局开发环境配置文件 +WORKSPACE_NAME="本地开发与测试" +ENVIRONMENT="development" +DEBUG=true +LOG_LEVEL="DEBUG" + +# 端口分配 +PYTHON_DEV_PORT=8000 +COZE_BACKEND_PORT=8001 +COZE_FRONTEND_PORT=3000 + +# 数据库配置 +MYSQL_HOST="localhost" +MYSQL_PORT=3306 +MYSQL_USER="root" +MYSQL_PASSWORD="password" + +# Redis配置 +REDIS_HOST="localhost" +REDIS_PORT=6379 + +# 开发模式 +HOT_RELOAD=true +AUTO_RELOAD=true +DEVELOPMENT_MODE=true diff --git a/.env.kpl b/.env.kpl new file mode 100644 index 0000000..af86cb9 --- /dev/null +++ b/.env.kpl @@ -0,0 +1,26 @@ +# 瑞小美团队 AI 服务配置 +# ⚠️ 此文件包含敏感信息,禁止提交到 Git +# 文件权限应设置为 600 + +# === AI 服务配置 === +# 通用 Key(用于 Gemini/DeepSeek 等非 Claude 模型) +AI_PRIMARY_API_KEY=sk-V9QfxYscJzD54ZRIDyAvnd4Lew4IdtPUQqwDQ0swNkIYObxT + +# Claude 专属 Key(用于 Claude 模型) +AI_ANTHROPIC_API_KEY=sk-wNAkUW3zwBeKBN8EeK5KnXXgOixnW5rhZmKolo8fBQuHepkX + +# OpenRouter 备选 Key(可选,用于降级) +AI_FALLBACK_API_KEY= + +# === 数据库配置 === +MYSQL_PASSWORD=nj861021 + +# 租户配置(用于多租户部署) +TENANT_CODE=kpl + +# 管理库连接配置(用于从 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/.gitignore b/.gitignore new file mode 100644 index 0000000..f2e474a --- /dev/null +++ b/.gitignore @@ -0,0 +1,71 @@ +# ================================ +# AgentWD 项目 .gitignore +# ================================ + +# ---------------- +# 环境配置(敏感) +# ---------------- +.env +.env.local +.env.*.local +.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/CONTEXT.md b/CONTEXT.md new file mode 100644 index 0000000..8fab738 --- /dev/null +++ b/CONTEXT.md @@ -0,0 +1,124 @@ +# 项目上下文 + +> AI启动时必读此文件,快速了解项目全貌 + +## 一、项目信息 + +| 项目 | 内容 | +|------|------| +| **项目编号** | 012-考培练系统-2601 | +| **项目路径** | `projects/012-考培练系统-2601/` | +| **当前阶段** | 开发阶段 | +| **项目状态** | 🟢 活跃 | +| **启动日期** | 2026-01-24 | +| **技术栈** | Vue3 + TypeScript + FastAPI + MySQL | + +## 二、AI启动指令 + +请依次阅读以下文件: + +1. **框架层**(了解规则) + - `../../_framework/agents/00-框架总览.md` + - 检查 `agents/` 是否有项目覆盖 + +2. **项目文档**(了解当前状态) + - `docs/同步清单.md` + - `docs/项目状态快照.md` + - `docs/规划/系统架构.md` + +3. **技术文档** + - `docs/README.md` - 项目总览 + - `backend/README.md` - 后端开发指南 + +## 三、项目简介 + +考培练系统是一个革命性的员工能力提升平台,专为轻医美连锁品牌瑞小美打造。通过集成 Coze 和 Dify 双 AI 平台,实现智能化的培训、考核和陪练功能。 + +### 核心功能 + +- **智能考试系统**:动态题目生成(千人千卷)、三轮考试机制 +- **AI陪练中心**:模拟客户对话、语音交互支持 +- **课程管理**:知识点自动提取、分岗位内容推送 +- **数据分析**:能力雷达图、学习进度追踪 + +### 技术架构 + +| 层级 | 技术栈 | +|------|--------| +| 前端 | Vue3 + TypeScript + Element Plus + Vite | +| 后端 | Python 3.9+ + FastAPI + SQLAlchemy | +| 数据库 | MySQL 8.0 + Redis | +| AI平台 | Dify(动态考试)+ Coze(AI陪练) | +| 部署 | Docker 容器化 | + +## 四、项目结构 + +``` +012-考培练系统-2601/ +├── backend/ # 后端 (FastAPI) +│ ├── app/ # 应用主目录 +│ │ ├── api/ # API路由 +│ │ ├── models/ # 数据模型 +│ │ ├── schemas/ # Pydantic schemas +│ │ └── services/ # 业务逻辑 +│ └── requirements.txt +├── frontend/ # 用户端前端 (Vue3) +│ └── src/ +│ ├── api/ # API调用 +│ ├── views/ # 页面视图 +│ └── components/ # 组件 +├── admin-frontend/ # 管理端前端 (Vue3) +├── deploy/ # 部署配置 +│ ├── docker/ # Docker compose 文件 +│ ├── nginx/ # Nginx 配置 +│ └── scripts/ # 启动/部署脚本 +├── docs/ # 文档 +│ └── 规划/ # 系统规划文档 +├── tests/ # 测试文件 +├── 知识库/ # 开发记录、问题修复 +└── .env.* # 环境配置 +``` + +## 五、关键配置 + +### 服务端口 + +| 服务 | 端口 | +|------|------| +| 前端 | 3001 | +| 后端 API | 8000 | +| MySQL | 3306 | +| Redis | 6379 | + +### 系统账户 + +| 角色 | 用户名 | 密码 | +|------|--------|------| +| 超级管理员 | superadmin | Superadmin123! | +| 系统管理员 | admin | Admin123! | +| 测试学员 | testuser | TestPass123! | + +### 数据库连接 + +- **服务器**: 120.79.247.16 +- **端口**: 3306 +- **数据库**: kaopeilian + +## 六、文件访问边界 + +| 区域 | 读取 | 写入 | +|------|------|------| +| ✅ 本项目目录 | 允许 | 允许 | +| ✅ `_framework/` | 允许 | ⚠️ 需确认 | +| ⚠️ `_private/` | 需许可 | ❌ **绝对禁止** | +| ❌ 其他项目 | 禁止 | 禁止 | + +## 七、注意事项 + +- 多租户架构:支持多个机构独立部署(.env.fw, .env.hl 等) +- AI集成:需配置 Coze 和 Dify API 密钥 +- 文件上传:使用 LibreOffice 转换 Office 文档 + +--- + +> 最后更新:2026-01-24 diff --git a/admin-frontend/Dockerfile b/admin-frontend/Dockerfile new file mode 100644 index 0000000..4a41214 --- /dev/null +++ b/admin-frontend/Dockerfile @@ -0,0 +1,47 @@ +# 考培练系统管理后台前端 Dockerfile +# 多阶段构建:Node.js 构建 + Nginx 运行 +# +# 技术栈:Vue 3 + TypeScript + pnpm(符合瑞小美系统技术栈标准) + +# ============================================ +# 阶段1:构建 +# ============================================ +FROM node:20.11-alpine AS builder + +WORKDIR /app + +# 安装 pnpm(符合规范:使用 pnpm 包管理器) +RUN corepack enable && corepack prepare pnpm@9.0.0 --activate + +# 设置 pnpm 镜像 +RUN pnpm config set registry https://registry.npmmirror.com + +# 安装依赖 +COPY package.json pnpm-lock.yaml* ./ +RUN pnpm install --frozen-lockfile || pnpm install + +# 复制源码并构建 +COPY . . +RUN pnpm run build + +# ============================================ +# 阶段2:运行 +# ============================================ +FROM nginx:1.25.4-alpine + +# 复制构建产物 +COPY --from=builder /app/dist /usr/share/nginx/html + +# 复制 nginx 配置 +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# 暴露端口 +EXPOSE 80 + +# 健康检查(符合规范) +HEALTHCHECK --interval=30s --timeout=10s --retries=3 \ + CMD wget --no-verbose --tries=1 --spider http://localhost/ || exit 1 + +# 启动 nginx +CMD ["nginx", "-g", "daemon off;"] + diff --git a/admin-frontend/env.d.ts b/admin-frontend/env.d.ts new file mode 100644 index 0000000..35b7f02 --- /dev/null +++ b/admin-frontend/env.d.ts @@ -0,0 +1,22 @@ +/// + +declare module '*.vue' { + import type { DefineComponent } from 'vue' + const component: DefineComponent<{}, {}, any> + export default component +} + +// Element Plus 中文语言包类型声明 +declare module 'element-plus/dist/locale/zh-cn.mjs' { + const zhCn: any + export default zhCn +} + +interface ImportMetaEnv { + readonly VITE_API_BASE_URL: string +} + +interface ImportMeta { + readonly env: ImportMetaEnv +} + diff --git a/admin-frontend/index.html b/admin-frontend/index.html new file mode 100644 index 0000000..0e6f144 --- /dev/null +++ b/admin-frontend/index.html @@ -0,0 +1,14 @@ + + + + + + 考培练系统 - 管理后台 + + + +
+ + + + diff --git a/admin-frontend/nginx.conf b/admin-frontend/nginx.conf new file mode 100644 index 0000000..6cb1a9a --- /dev/null +++ b/admin-frontend/nginx.conf @@ -0,0 +1,47 @@ +server { + listen 80; + server_name localhost; + + root /usr/share/nginx/html; + index index.html; + + # gzip 压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_types text/plain text/css text/xml text/javascript application/javascript application/json application/xml; + + # 静态资源缓存 + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + expires 30d; + add_header Cache-Control "public, immutable"; + } + + # HTML 不缓存 + location ~* \.html$ { + expires -1; + add_header Cache-Control "no-cache, no-store, must-revalidate"; + } + + # API 代理到后端 + location /api/ { + proxy_pass http://kaopeilian-admin-backend:8000/api/; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + } + + # SPA 路由支持 + location / { + try_files $uri $uri/ /index.html; + } +} + diff --git a/admin-frontend/package.json b/admin-frontend/package.json new file mode 100644 index 0000000..18c4072 --- /dev/null +++ b/admin-frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "kaopeilian-admin-frontend", + "version": "1.0.0", + "description": "考培练系统 SaaS 超级管理后台", + "private": true, + "type": "module", + "packageManager": "pnpm@9.0.0", + "scripts": { + "dev": "vite", + "build": "vue-tsc -b && vite build", + "preview": "vite preview", + "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix", + "type-check": "vue-tsc --noEmit" + }, + "dependencies": { + "vue": "^3.4.0", + "vue-router": "^4.2.0", + "pinia": "^2.1.0", + "element-plus": "^2.5.0", + "@element-plus/icons-vue": "^2.3.0", + "axios": "^1.6.0", + "monaco-editor": "^0.45.0", + "dayjs": "^1.11.0" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.0.0", + "vite": "^5.0.0", + "sass": "^1.69.0", + "unplugin-auto-import": "^0.17.0", + "unplugin-vue-components": "^0.26.0", + "typescript": "~5.3.0", + "vue-tsc": "^2.0.0", + "@tsconfig/node20": "^20.1.0", + "@types/node": "^20.11.0", + "@vue/tsconfig": "^0.5.0", + "eslint": "^8.57.0", + "@vue/eslint-config-typescript": "^13.0.0", + "eslint-plugin-vue": "^9.22.0", + "@rushstack/eslint-patch": "^1.7.0" + } +} + diff --git a/admin-frontend/public/favicon.svg b/admin-frontend/public/favicon.svg new file mode 100644 index 0000000..a89c613 --- /dev/null +++ b/admin-frontend/public/favicon.svg @@ -0,0 +1,19 @@ + + + + + + + + + A + + + + + + + + + + diff --git a/admin-frontend/src/App.vue b/admin-frontend/src/App.vue new file mode 100644 index 0000000..0804c37 --- /dev/null +++ b/admin-frontend/src/App.vue @@ -0,0 +1,18 @@ + + + + + + diff --git a/admin-frontend/src/api/index.js b/admin-frontend/src/api/index.js new file mode 100644 index 0000000..d8c5125 --- /dev/null +++ b/admin-frontend/src/api/index.js @@ -0,0 +1,108 @@ +import axios from 'axios' +import { ElMessage } from 'element-plus' +import router from '@/router' + +// 创建 axios 实例 +const request = axios.create({ + baseURL: '/api/v1/admin', + timeout: 30000, +}) + +// 请求拦截器 +request.interceptors.request.use( + config => { + const token = localStorage.getItem('admin_token') + if (token) { + config.headers.Authorization = `Bearer ${token}` + } + return config + }, + error => { + return Promise.reject(error) + } +) + +// 响应拦截器 +request.interceptors.response.use( + response => { + return response.data + }, + error => { + const { response } = error + if (response) { + if (response.status === 401) { + localStorage.removeItem('admin_token') + localStorage.removeItem('admin_user') + router.push('/login') + ElMessage.error('登录已过期,请重新登录') + } else if (response.status === 403) { + ElMessage.error('没有权限执行此操作') + } else if (response.data?.detail) { + ElMessage.error(response.data.detail) + } else { + ElMessage.error('请求失败') + } + } else { + ElMessage.error('网络错误') + } + return Promise.reject(error) + } +) + +// API 模块 +const api = { + // 认证 + auth: { + login: data => request.post('/auth/login', data), + me: () => request.get('/auth/me'), + changePassword: data => request.post('/auth/change-password', data), + logout: () => request.post('/auth/logout'), + }, + + // 租户 + tenants: { + list: params => request.get('/tenants', { params }), + get: id => request.get(`/tenants/${id}`), + create: data => request.post('/tenants', data), + update: (id, data) => request.put(`/tenants/${id}`, data), + delete: id => request.delete(`/tenants/${id}`), + enable: id => request.post(`/tenants/${id}/enable`), + disable: id => request.post(`/tenants/${id}/disable`), + }, + + // 配置 + configs: { + templates: params => request.get('/configs/templates', { params }), + groups: () => request.get('/configs/groups'), + getTenantConfigs: (tenantId, params) => request.get(`/configs/tenants/${tenantId}`, { params }), + updateConfig: (tenantId, group, key, data) => request.put(`/configs/tenants/${tenantId}/${group}/${key}`, data), + batchUpdate: (tenantId, data) => request.put(`/configs/tenants/${tenantId}/batch`, data), + deleteConfig: (tenantId, group, key) => request.delete(`/configs/tenants/${tenantId}/${group}/${key}`), + refreshCache: tenantId => request.post(`/configs/tenants/${tenantId}/refresh-cache`), + }, + + // 提示词 + prompts: { + list: params => request.get('/prompts', { params }), + get: id => request.get(`/prompts/${id}`), + create: data => request.post('/prompts', data), + update: (id, data) => request.put(`/prompts/${id}`, data), + getVersions: id => request.get(`/prompts/${id}/versions`), + rollback: (id, version) => request.post(`/prompts/${id}/rollback/${version}`), + getTenantPrompts: tenantId => request.get(`/prompts/tenants/${tenantId}`), + updateTenantPrompt: (tenantId, promptId, data) => request.put(`/prompts/tenants/${tenantId}/${promptId}`, data), + deleteTenantPrompt: (tenantId, promptId) => request.delete(`/prompts/tenants/${tenantId}/${promptId}`), + }, + + // 功能开关 + features: { + getDefaults: () => request.get('/features/defaults'), + getTenantFeatures: tenantId => request.get(`/features/tenants/${tenantId}`), + updateFeature: (tenantId, code, data) => request.put(`/features/tenants/${tenantId}/${code}`, data), + resetFeature: (tenantId, code) => request.delete(`/features/tenants/${tenantId}/${code}`), + batchUpdate: (tenantId, data) => request.post(`/features/tenants/${tenantId}/batch`, data), + }, +} + +export default api + diff --git a/admin-frontend/src/assets/styles/main.scss b/admin-frontend/src/assets/styles/main.scss new file mode 100644 index 0000000..72b18d3 --- /dev/null +++ b/admin-frontend/src/assets/styles/main.scss @@ -0,0 +1,58 @@ +// 全局样式 +* { + box-sizing: border-box; +} + +html, body { + margin: 0; + padding: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +// Element Plus 样式覆盖 +.el-card { + border-radius: 8px; + + &__header { + font-weight: 600; + } +} + +.el-button { + border-radius: 6px; +} + +.el-input { + .el-input__wrapper { + border-radius: 6px; + } +} + +.el-table { + th.el-table__cell { + background-color: #f5f7fa; + } +} + +// 滚动条样式 +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: #f1f1f1; + border-radius: 4px; +} + +::-webkit-scrollbar-thumb { + background: #c1c1c1; + border-radius: 4px; + + &:hover { + background: #a8a8a8; + } +} + diff --git a/admin-frontend/src/main.ts b/admin-frontend/src/main.ts new file mode 100644 index 0000000..b902093 --- /dev/null +++ b/admin-frontend/src/main.ts @@ -0,0 +1,24 @@ +import { createApp } from 'vue' +import { createPinia } from 'pinia' +import ElementPlus from 'element-plus' +import zhCn from 'element-plus/dist/locale/zh-cn.mjs' +import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import 'element-plus/dist/index.css' + +import App from './App.vue' +import router from './router' +import './assets/styles/main.scss' + +const app = createApp(App) + +// 注册所有图标 +for (const [key, component] of Object.entries(ElementPlusIconsVue)) { + app.component(key, component) +} + +app.use(createPinia()) +app.use(router) +app.use(ElementPlus, { locale: zhCn }) + +app.mount('#app') + diff --git a/admin-frontend/src/router/index.js b/admin-frontend/src/router/index.js new file mode 100644 index 0000000..0d4946f --- /dev/null +++ b/admin-frontend/src/router/index.js @@ -0,0 +1,96 @@ +import { createRouter, createWebHistory } from 'vue-router' +import { useAuthStore } from '@/stores/auth' + +const routes = [ + { + path: '/login', + name: 'Login', + component: () => import('@/views/Login.vue'), + meta: { requiresAuth: false } + }, + { + path: '/', + component: () => import('@/views/Layout.vue'), + meta: { requiresAuth: true }, + children: [ + { + path: '', + redirect: '/dashboard' + }, + { + path: 'dashboard', + name: 'Dashboard', + component: () => import('@/views/Dashboard.vue'), + meta: { title: '控制台' } + }, + { + path: 'tenants', + name: 'Tenants', + component: () => import('@/views/tenants/TenantList.vue'), + meta: { title: '租户管理' } + }, + { + path: 'tenants/:id', + name: 'TenantDetail', + component: () => import('@/views/tenants/TenantDetail.vue'), + meta: { title: '租户详情' } + }, + { + path: 'tenants/:id/configs', + name: 'TenantConfigs', + component: () => import('@/views/tenants/TenantConfigs.vue'), + meta: { title: '租户配置' } + }, + { + path: 'tenants/:id/features', + name: 'TenantFeatures', + component: () => import('@/views/tenants/TenantFeatures.vue'), + meta: { title: '功能开关' } + }, + { + path: 'prompts', + name: 'Prompts', + component: () => import('@/views/prompts/PromptList.vue'), + meta: { title: '提示词管理' } + }, + { + path: 'prompts/:id', + name: 'PromptDetail', + component: () => import('@/views/prompts/PromptDetail.vue'), + meta: { title: '提示词详情' } + }, + { + path: 'logs', + name: 'Logs', + component: () => import('@/views/Logs.vue'), + meta: { title: '操作日志' } + }, + ] + }, + { + path: '/:pathMatch(.*)*', + name: 'NotFound', + component: () => import('@/views/NotFound.vue') + } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +// 路由守卫 +router.beforeEach((to, from, next) => { + const authStore = useAuthStore() + + if (to.meta.requiresAuth !== false && !authStore.isLoggedIn) { + next({ name: 'Login', query: { redirect: to.fullPath } }) + } else if (to.name === 'Login' && authStore.isLoggedIn) { + next({ name: 'Dashboard' }) + } else { + next() + } +}) + +export default router + diff --git a/admin-frontend/src/stores/auth.js b/admin-frontend/src/stores/auth.js new file mode 100644 index 0000000..3a968ff --- /dev/null +++ b/admin-frontend/src/stores/auth.js @@ -0,0 +1,40 @@ +import { defineStore } from 'pinia' +import { ref, computed } from 'vue' +import api from '@/api' + +export const useAuthStore = defineStore('auth', () => { + const token = ref(localStorage.getItem('admin_token') || '') + const user = ref(JSON.parse(localStorage.getItem('admin_user') || 'null')) + + const isLoggedIn = computed(() => !!token.value) + + async function login(username, password) { + const res = await api.auth.login({ username, password }) + token.value = res.access_token + user.value = res.admin_user + localStorage.setItem('admin_token', res.access_token) + localStorage.setItem('admin_user', JSON.stringify(res.admin_user)) + return res + } + + function logout() { + token.value = '' + user.value = null + localStorage.removeItem('admin_token') + localStorage.removeItem('admin_user') + } + + async function fetchUser() { + if (!token.value) return + try { + const res = await api.auth.me() + user.value = res + localStorage.setItem('admin_user', JSON.stringify(res)) + } catch (e) { + logout() + } + } + + return { token, user, isLoggedIn, login, logout, fetchUser } +}) + diff --git a/admin-frontend/src/views/Dashboard.vue b/admin-frontend/src/views/Dashboard.vue new file mode 100644 index 0000000..252eeba --- /dev/null +++ b/admin-frontend/src/views/Dashboard.vue @@ -0,0 +1,204 @@ + + + + + + diff --git a/admin-frontend/src/views/Layout.vue b/admin-frontend/src/views/Layout.vue new file mode 100644 index 0000000..632cb18 --- /dev/null +++ b/admin-frontend/src/views/Layout.vue @@ -0,0 +1,234 @@ + + + + + + diff --git a/admin-frontend/src/views/Login.vue b/admin-frontend/src/views/Login.vue new file mode 100644 index 0000000..ed7d654 --- /dev/null +++ b/admin-frontend/src/views/Login.vue @@ -0,0 +1,157 @@ + + + + + + diff --git a/admin-frontend/src/views/Logs.vue b/admin-frontend/src/views/Logs.vue new file mode 100644 index 0000000..17da819 --- /dev/null +++ b/admin-frontend/src/views/Logs.vue @@ -0,0 +1,178 @@ + + + + + + diff --git a/admin-frontend/src/views/NotFound.vue b/admin-frontend/src/views/NotFound.vue new file mode 100644 index 0000000..79cd9e9 --- /dev/null +++ b/admin-frontend/src/views/NotFound.vue @@ -0,0 +1,31 @@ + + + + diff --git a/admin-frontend/src/views/prompts/PromptDetail.vue b/admin-frontend/src/views/prompts/PromptDetail.vue new file mode 100644 index 0000000..fb5335a --- /dev/null +++ b/admin-frontend/src/views/prompts/PromptDetail.vue @@ -0,0 +1,179 @@ + + + + + + diff --git a/admin-frontend/src/views/prompts/PromptList.vue b/admin-frontend/src/views/prompts/PromptList.vue new file mode 100644 index 0000000..2905700 --- /dev/null +++ b/admin-frontend/src/views/prompts/PromptList.vue @@ -0,0 +1,159 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantConfigs.vue b/admin-frontend/src/views/tenants/TenantConfigs.vue new file mode 100644 index 0000000..0c06373 --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantConfigs.vue @@ -0,0 +1,142 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantDetail.vue b/admin-frontend/src/views/tenants/TenantDetail.vue new file mode 100644 index 0000000..2b9a5f9 --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantDetail.vue @@ -0,0 +1,217 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantFeatures.vue b/admin-frontend/src/views/tenants/TenantFeatures.vue new file mode 100644 index 0000000..c9c99fc --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantFeatures.vue @@ -0,0 +1,129 @@ + + + + + + diff --git a/admin-frontend/src/views/tenants/TenantList.vue b/admin-frontend/src/views/tenants/TenantList.vue new file mode 100644 index 0000000..8374675 --- /dev/null +++ b/admin-frontend/src/views/tenants/TenantList.vue @@ -0,0 +1,250 @@ + + + + + + diff --git a/admin-frontend/tsconfig.app.json b/admin-frontend/tsconfig.app.json new file mode 100644 index 0000000..ddcfd4d --- /dev/null +++ b/admin-frontend/tsconfig.app.json @@ -0,0 +1,16 @@ +{ + "extends": "@vue/tsconfig/tsconfig.dom.json", + "include": ["env.d.ts", "src/**/*", "src/**/*.vue"], + "exclude": ["src/**/__tests__/*"], + "compilerOptions": { + "composite": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + }, + "allowJs": true, + "checkJs": false + } +} + diff --git a/admin-frontend/tsconfig.json b/admin-frontend/tsconfig.json new file mode 100644 index 0000000..d3b34d5 --- /dev/null +++ b/admin-frontend/tsconfig.json @@ -0,0 +1,8 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.node.json" }, + { "path": "./tsconfig.app.json" } + ] +} + diff --git a/admin-frontend/tsconfig.node.json b/admin-frontend/tsconfig.node.json new file mode 100644 index 0000000..1730ebc --- /dev/null +++ b/admin-frontend/tsconfig.node.json @@ -0,0 +1,19 @@ +{ + "extends": "@tsconfig/node20/tsconfig.json", + "include": [ + "vite.config.*", + "vitest.config.*", + "cypress.config.*", + "nightwatch.conf.*", + "playwright.config.*" + ], + "compilerOptions": { + "composite": true, + "noEmit": true, + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "module": "ESNext", + "moduleResolution": "Bundler", + "types": ["node"] + } +} + diff --git a/admin-frontend/vite.config.ts b/admin-frontend/vite.config.ts new file mode 100644 index 0000000..a46dd1d --- /dev/null +++ b/admin-frontend/vite.config.ts @@ -0,0 +1,39 @@ +import { defineConfig } from 'vite' +import vue from '@vitejs/plugin-vue' +import AutoImport from 'unplugin-auto-import/vite' +import Components from 'unplugin-vue-components/vite' +import { ElementPlusResolver } from 'unplugin-vue-components/resolvers' +import { fileURLToPath, URL } from 'node:url' + +export default defineConfig({ + plugins: [ + vue(), + AutoImport({ + resolvers: [ElementPlusResolver()], + imports: ['vue', 'vue-router', 'pinia'], + }), + Components({ + resolvers: [ElementPlusResolver()], + }), + ], + resolve: { + alias: { + '@': fileURLToPath(new URL('./src', import.meta.url)) + } + }, + server: { + host: '0.0.0.0', + port: 3030, + proxy: { + '/api': { + target: 'http://localhost:8030', + changeOrigin: true, + } + } + }, + build: { + outDir: 'dist', + sourcemap: false, + } +}) + diff --git a/backend/.env.ex b/backend/.env.ex new file mode 100644 index 0000000..3740659 --- /dev/null +++ b/backend/.env.ex @@ -0,0 +1,74 @@ +# 恩喜成都总院生产环境配置 +APP_NAME="恩喜成都总院-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_ex?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_ex + +# Redis配置 +REDIS_URL=redis://ex-redis:6379/0 +REDIS_HOST=ex-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=ex_8f7a9c3e1b4d6f2a5c8e7b9d1f3a6c4e8b2d5f7a9c1e3b6d8f2a4c7e9b1d3f5a +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://ex.ireborn.com.cn", "http://ex.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 Key 配置 +# 01-知识点分析 +# 02-试题生成器 +# 03-陪练知识准备 +# 04-与课程对话 +# 05-智能工牌能力分析与课程推荐 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577978749833838602 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=ex + +# 管理库连接配置(用于从 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/.env.example b/backend/.env.example new file mode 100644 index 0000000..d24964b --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,8 @@ +# 开发环境配置示例 +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 +REDIS_URL=redis://localhost:6379/0 +DEBUG=true +SECRET_KEY=kaopeilian-secret-key-dev +CORS_ORIGINS=["http://localhost:3001","http://localhost:3000"] +HOST=0.0.0.0 +PORT=8000 diff --git a/backend/.env.fw b/backend/.env.fw new file mode 100644 index 0000000..18c8c8b --- /dev/null +++ b/backend/.env.fw @@ -0,0 +1,69 @@ +# 飞沃生产环境配置 +APP_NAME="飞沃-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_fw?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_fw + +# Redis配置 +REDIS_URL=redis://fw-redis:6379/0 +REDIS_HOST=fw-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=fw_00e0e0e6i5h28g6g2f7fhi46f1e6i6f2f1h22f5i1h5g8j2h3e6g0i5j8fd1g7h +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://fw.ireborn.com.cn", "http://fw.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 Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577980956000534578 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=fw + +# 管理库连接配置(用于从 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/.env.hl b/backend/.env.hl new file mode 100644 index 0000000..45acc31 --- /dev/null +++ b/backend/.env.hl @@ -0,0 +1,69 @@ +# 武汉禾丽生产环境配置 +APP_NAME="武汉禾丽-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_hl?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_hl + +# Redis配置 +REDIS_URL=redis://hl-redis:6379/0 +REDIS_HOST=hl-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=hl_88c8c8c4g3f06e4e0d5fdg24d9c4g4d0d9f00d3g9f3e6h0f1c4e8g3h6db9e5f +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://hl.ireborn.com.cn", "http://hl.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 Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577981581995409450 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=hl + +# 管理库连接配置(用于从 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/.env.hua b/backend/.env.hua new file mode 100644 index 0000000..5455eac --- /dev/null +++ b/backend/.env.hua @@ -0,0 +1,69 @@ +# 华尔倍丽生产环境配置 +APP_NAME="华尔倍丽-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_hua?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_hua + +# Redis配置 +REDIS_URL=redis://hua-redis:6379/0 +REDIS_HOST=hua-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=hua_66a6a6a2f1d84c2c8b3dbf02b7a2e2b8b7d88b1e7d1c4f8d9a2c6e1f4b9a7c3d +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://hua.ireborn.com.cn", "http://hua.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 Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577978749833838602 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=hua + +# 管理库连接配置(用于从 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/.env.xy b/backend/.env.xy new file mode 100644 index 0000000..0d8c4ee --- /dev/null +++ b/backend/.env.xy @@ -0,0 +1,68 @@ +# 芯颜定制生产环境配置 +APP_NAME="芯颜定制-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_xy?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_xy + +# Redis配置 +REDIS_URL=redis://xy-redis:6379/0 +REDIS_HOST=xy-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=xy_99d9d9d5h4g17f5f1e6geh35e0d5h5e1e0g11e4h0g4f7i1g2d5f9h4i7ec0f6g +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://xy.ireborn.com.cn", "http://xy.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 +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577968943668084745 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# Dify 工作流 API Key 配置 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=xy + +# 管理库连接配置(用于从 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/.env.yy b/backend/.env.yy new file mode 100644 index 0000000..62a1e84 --- /dev/null +++ b/backend/.env.yy @@ -0,0 +1,69 @@ +# 杨扬宠物生产环境配置 +APP_NAME="杨扬宠物-考培练系统" +APP_VERSION="1.0.0" +DEBUG=false +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 - 共享MySQL实例 +DATABASE_URL=mysql+aiomysql://root:ProdMySQL2025%21%40%23@prod-mysql:3306/kaopeilian_yy?charset=utf8mb4 +MYSQL_HOST=prod-mysql +MYSQL_PORT=3306 +MYSQL_USER=root +MYSQL_PASSWORD=ProdMySQL2025!@# +MYSQL_DATABASE=kaopeilian_yy + +# Redis配置 +REDIS_URL=redis://yy-redis:6379/0 +REDIS_HOST=yy-redis +REDIS_PORT=6379 +REDIS_DB=0 + +# 安全配置 +SECRET_KEY=yy_77b7b7b3f2e95d3d9c4ecf13c8b3f3c9c8e99c2f8e2d5g9e0b3d7f2g5ca8d4e +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 +REFRESH_TOKEN_EXPIRE_DAYS=7 + +# CORS配置 +CORS_ORIGINS=["https://yy.ireborn.com.cn", "http://yy.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 Key 配置 + +# Coze 播课配置 +COZE_BROADCAST_WORKFLOW_ID=7577980363517018150 +COZE_BROADCAST_SPACE_ID=7474971491470688296 +COZE_BROADCAST_BOT_ID=7560643598174683145 + +# AI 服务配置(知识点分析 V2 - 测试阶段 Key) +AI_PRIMARY_API_KEY=sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw +AI_PRIMARY_BASE_URL=https://4sapi.com/v1 +AI_FALLBACK_API_KEY=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 +AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 +AI_DEFAULT_MODEL=gemini-3-flash-preview +AI_TIMEOUT=120 + +# 租户配置(用于多租户部署) +TENANT_CODE=yy + +# 管理库连接配置(用于从 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/.gitignore b/backend/.gitignore new file mode 100644 index 0000000..f7bdf70 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,79 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# Virtual Environment +venv/ +ENV/ +env/ +.venv + +# IDE +.idea/ +.vscode/ +*.swp +*.swo +*~ +.DS_Store + +# Testing +.coverage +.pytest_cache/ +htmlcov/ +.tox/ +.mypy_cache/ +.dmypy.json +dmypy.json + +# Environment +.env +.env.local +.env.*.local +local_config.py + +# Logs +*.log +logs/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Uploads +uploads/ + +# OS +.DS_Store +Thumbs.db + +# Docker +docker-compose.override.yml + +# Alembic +alembic.ini + +# Private keys +*.pem +*.key +*.crt +*.cert \ No newline at end of file diff --git a/backend/.pre-commit-config.yaml b/backend/.pre-commit-config.yaml new file mode 100644 index 0000000..23a7c74 --- /dev/null +++ b/backend/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +repos: + - repo: https://github.com/psf/black + rev: 23.3.0 + hooks: + - id: black + language_version: python3.8 + + - repo: https://github.com/pycqa/isort + rev: 5.12.0 + hooks: + - id: isort + args: ["--profile", "black"] + + - repo: https://github.com/pycqa/flake8 + rev: 6.0.0 + hooks: + - id: flake8 + args: ["--max-line-length", "88", "--extend-ignore", "E203"] + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.3.0 + hooks: + - id: mypy + additional_dependencies: [types-all] + exclude: ^(migrations/|tests/) diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..6ecb944 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,57 @@ +# 使用Python 3.11作为基础镜像,使用阿里云镜像 +FROM python:3.11.9-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +# 配置阿里云镜像源 +RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list + +# 安装系统依赖(包括LibreOffice用于文档转换) +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + libreoffice-writer \ + libreoffice-impress \ + libreoffice-calc \ + libreoffice-core \ + fonts-wqy-zenhei \ + fonts-wqy-microhei \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 配置pip使用阿里云镜像 +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +# 安装Python依赖 +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +# 复制应用代码 +COPY app/ ./app/ + +# 创建上传目录和日志目录 +RUN mkdir -p uploads logs + +# 暴露端口 +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 启动命令(生产模式,无热重载) +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "4", "--timeout-keep-alive", "600"] \ No newline at end of file diff --git a/backend/Dockerfile.admin b/backend/Dockerfile.admin new file mode 100644 index 0000000..84de7fd --- /dev/null +++ b/backend/Dockerfile.admin @@ -0,0 +1,66 @@ +# 考培练系统 SaaS 超级后台 - 开发环境 Dockerfile +# 使用阿里云镜像 + 热重载配置 + +# 使用 Python 3.11 slim 版本 +FROM python:3.11.9-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +# 配置阿里云 apt 镜像源 +RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list + +# 安装系统依赖 +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# 配置 pip 使用阿里云镜像 +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +# 复制依赖文件 +COPY requirements.txt . +COPY requirements-admin.txt . + +# 安装 Python 依赖 +RUN pip install --upgrade pip && \ + pip install -r requirements.txt && \ + pip install -r requirements-admin.txt + +# 复制应用代码(开发环境会通过 volume 覆盖) +COPY app/ ./app/ + +# 创建目录 +RUN mkdir -p uploads logs + +# 暴露端口 +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 启动命令 - 开发模式,启用热重载 +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/app"] + + + + + + + + + diff --git a/backend/Dockerfile.dev b/backend/Dockerfile.dev new file mode 100644 index 0000000..3849886 --- /dev/null +++ b/backend/Dockerfile.dev @@ -0,0 +1,59 @@ +# 后端开发环境 Dockerfile(支持热重载) +FROM python:3.11.9-slim + +# 设置工作目录 +WORKDIR /app + +# 设置环境变量 +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 \ + PYTHONPATH=/app + +# 配置阿里云镜像源 +RUN echo "deb http://mirrors.aliyun.com/debian/ bookworm main" > /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian-security/ bookworm-security main" >> /etc/apt/sources.list && \ + echo "deb http://mirrors.aliyun.com/debian/ bookworm-updates main" >> /etc/apt/sources.list + +# 安装系统依赖(包括LibreOffice用于文档转换) +RUN apt-get update && apt-get install -y \ + gcc \ + default-libmysqlclient-dev \ + pkg-config \ + curl \ + libreoffice-writer \ + libreoffice-impress \ + libreoffice-calc \ + libreoffice-core \ + fonts-wqy-zenhei \ + fonts-wqy-microhei \ + --no-install-recommends \ + && rm -rf /var/lib/apt/lists/* + +# 复制依赖文件 +COPY requirements.txt . + +# 配置pip使用阿里云镜像 +RUN pip config set global.index-url https://mirrors.aliyun.com/pypi/simple/ && \ + pip config set global.trusted-host mirrors.aliyun.com + +# 安装Python依赖 +RUN pip install --upgrade pip && \ + pip install -r requirements.txt + +# 复制应用代码(开发时会被volume覆盖) +COPY . . + +# 创建必要的目录 +RUN mkdir -p uploads logs + +# 暴露端口 +EXPOSE 8000 + +# 健康检查 +HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \ + CMD curl -f http://localhost:8000/health || exit 1 + +# 启动开发服务器(支持热重载) +# 设置超时为10分钟(600秒),以支持AI试题生成等长时间处理 +CMD ["uvicorn", "app.main:app", "--reload", "--host", "0.0.0.0", "--port", "8000", "--reload-dir", "/app/app", "--timeout-keep-alive", "600"] diff --git a/backend/Makefile b/backend/Makefile new file mode 100644 index 0000000..4c6c4ca --- /dev/null +++ b/backend/Makefile @@ -0,0 +1,52 @@ +.PHONY: help install install-dev format lint type-check test test-cov run migrate clean + +help: + @echo "可用的命令:" + @echo " make install - 安装生产环境依赖" + @echo " make install-dev - 安装开发环境依赖" + @echo " make format - 格式化代码" + @echo " make lint - 运行代码检查" + @echo " make type-check - 运行类型检查" + @echo " make test - 运行测试" + @echo " make test-cov - 运行测试并生成覆盖率报告" + @echo " make run - 启动开发服务器" + @echo " make migrate - 运行数据库迁移" + @echo " make clean - 清理临时文件" + +install: + pip install -r requirements.txt + +install-dev: + pip install -r requirements-dev.txt + +format: + black app/ tests/ + isort app/ tests/ + +lint: + flake8 app/ tests/ --max-line-length=100 --ignore=E203,W503 + pylint app/ tests/ --disable=C0111,R0903,R0913 + +type-check: + mypy app/ --ignore-missing-imports + +test: + pytest tests/ -v + +test-cov: + pytest tests/ -v --cov=app --cov-report=html --cov-report=term + +run: + python -m app.main + +migrate: + alembic upgrade head + +clean: + find . -type d -name "__pycache__" -exec rm -rf {} + + find . -type f -name "*.pyc" -delete + find . -type f -name "*.pyo" -delete + find . -type f -name ".coverage" -delete + rm -rf htmlcov/ + rm -rf .pytest_cache/ + rm -rf .mypy_cache/ \ No newline at end of file diff --git a/backend/README.md b/backend/README.md new file mode 100644 index 0000000..7aab273 --- /dev/null +++ b/backend/README.md @@ -0,0 +1,410 @@ +## 考培练系统后端(FastAPI) + +简要说明:本项目为考培练系统的后端服务,基于 FastAPI 开发,配套前端为 Vue3。支持本地开发与测试,默认仅在 localhost 环境运行。 + +### 如何运行(How to Run) + +1. 进入项目目录: +```bash +cd kaopeilian-backend +``` + +2. 可选:创建并激活虚拟环境(推荐) +```bash +python3 -m venv venv +source venv/bin/activate # Windows: venv\Scripts\activate +``` + +3. 安装依赖: +```bash +pip install -r requirements.txt +``` + +4. 运行主程序 `main.py`: +```bash +python app/main.py +``` + +可选运行方式(等价): +```bash +uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload +``` + +启动后访问接口文档: +``` +http://localhost:8000/docs +``` + +> 提示:如需设置数据库连接,请使用本地开发 DSN:`mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4`,可在 `local_config.py` 或环境变量 `DATABASE_URL` 中覆盖。 + +### 如何测试(How to Test) + +1. 安装测试依赖(如未安装): +```bash +pip install pytest +``` + +2. 仅运行 `test_main.py`: +```bash +pytest tests/test_main.py +``` + +(或运行全部测试) +```bash +pytest +``` + +# 考培练系统后端 + +## 项目概述 + +考培练系统是一个革命性的员工能力提升平台,通过集成Coze和Dify双AI平台,实现智能化的培训、考核和陪练功能。 + +## 系统账户 + +系统预置了以下测试账户: + +| 角色 | 用户名 | 密码 | 权限说明 | +| ---------- | ---------- | -------------- | ---------------------------- | +| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 | +| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) | +| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 | + +**注意**: + +- 这些账户支持两种密码加密方式(bcrypt 和 SHA256) +- 使用 `create_system_accounts.py` 创建 bcrypt 加密的账户(推荐用于生产环境) +- 使用 `create_simple_users.py` 创建 SHA256 加密的账户(用于 simple_main.py) + +## 技术栈 + +- **后端框架**: Python 3.8+ + FastAPI +- **数据库**: MySQL 8.0 + Redis +- **ORM**: SQLAlchemy 2.0 +- **AI平台**: Coze(陪练和对话) + Dify(考试和评估) +- **认证**: JWT +- **文档转换**: LibreOffice(用于Office文档在线预览) +- **部署**: Docker + +## 项目结构 + +``` +kaopeilian-backend/ +├── app/ # 应用主目录 +│ ├── api/ # API路由 +│ │ └── v1/ # API v1版本 +│ │ ├── training.py # 陪练模块API +│ │ └── ... # 其他模块API +│ ├── config/ # 配置管理 +│ │ ├── settings.py # 系统配置 +│ │ └── database.py # 数据库配置 +│ ├── core/ # 核心功能 +│ │ ├── deps.py # 依赖注入 +│ │ ├── exceptions.py # 异常定义 +│ │ └── ... +│ ├── models/ # 数据库模型 +│ │ ├── base.py # 基础模型 +│ │ ├── training.py # 陪练模型 +│ │ └── ... +│ ├── schemas/ # Pydantic模式 +│ │ ├── base.py # 基础模式 +│ │ ├── training.py # 陪练模式 +│ │ └── ... +│ ├── services/ # 业务逻辑 +│ │ ├── base_service.py # 基础服务类 +│ │ ├── training_service.py # 陪练服务 +│ │ └── ai/ # AI平台集成 +│ │ ├── coze/ # Coze集成 +│ │ └── dify/ # Dify集成 +│ └── main.py # 应用入口 +├── tests/ # 测试目录 +├── migrations/ # 数据库迁移 +├── requirements.txt # 生产依赖 +├── requirements-dev.txt # 开发依赖 +├── Makefile # 开发命令 +└── README.md # 项目说明 +``` + +## 快速开始 + +### 1. 环境准备 + +- Python 3.8+ +- MySQL 8.0 +- Redis + +### 2. 安装依赖 + +```bash +# 安装生产依赖 +make install + +# 或安装开发依赖(包含测试和代码检查工具) +make install-dev +``` + +### 3. 配置环境变量 + +复制环境变量示例文件并修改配置: + +```bash +cp .env.example .env +``` + +主要配置项: + +- 数据库连接:`DATABASE_URL` +- Redis连接:`REDIS_URL` +- JWT密钥:`SECRET_KEY` +- Coze配置:`COZE_API_TOKEN`, `COZE_TRAINING_BOT_ID` +- Dify配置:`DIFY_API_KEY` + +### 4. 数据库初始化 + +```bash +# 运行数据库迁移 +make migrate +# 数据库结构说明更新 + +- 统一主键:根据当前 ORM 定义,所有表主键均为 `INT AUTO_INCREMENT`。 +- 用户相关引用:`teams.leader_id`、`exams.user_id`、`user_teams.user_id` 统一为 `INT`。 +- 陪练模块: + - `training_scenes.status` 使用枚举 `DRAFT/ACTIVE/INACTIVE`; + - `training_sessions.status` 使用枚举 `CREATED/IN_PROGRESS/COMPLETED/CANCELLED/ERROR`; + - `training_messages.role` 使用 `USER/ASSISTANT/SYSTEM`;`type` 使用 `TEXT/VOICE/SYSTEM`; + - `training_sessions.user_id` 和 `training_reports.user_id` 为 `INT`(取消 `training_sessions.user_id` 外键); + - `training_reports.session_id` 对 `training_sessions.id` 唯一外键保持不变。 + +如需全量初始化,请使用 `scripts/init_database_unified.sql`。 +``` + +### 5. 启动服务 + +```bash +# 开发模式(自动重载) +make run + +# 或直接运行 +python -m app.main +``` + +服务将在 http://localhost:8000 启动 + +## 数据库连接信息 + +- 公网数据库(当前使用) + - Host: `120.79.247.16` 或 `aiedu.ireborn.com.cn` + - Port: `3306` + - User: `root` + - Password: `Kaopeilian2025!@#` + - Database: `kaopeilian` + - DSN (Python SQLAlchemy): `mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4` + +- 本地数据库(备用) + - Host: `127.0.0.1` + - Port: `3306` + - User: `root` + - Password: `root` + - Database: `kaopeilian` + - DSN (Python SQLAlchemy): `mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4` + +- 配置写入位置 + - 代码内用于本地开发覆盖:`local_config.py` 中的 `os.environ["DATABASE_URL"]` + - Docker 开发环境:`docker-compose.dev.yml` 中 `backend.environment.DATABASE_URL` + - 运行时环境变量文件:`.env`(如存在,将被容器挂载) + +> 提示:开发测试环境仅用于本机 `localhost` 访问,已开启代码自动重载。 + +## 文件管理 + +### 文件存储结构 +- **基础路径**: `/kaopeilian-backend/uploads/` +- **课程资料**: `uploads/courses/{course_id}/{filename}` +- **文件命名规则**: `{时间戳}_{8位哈希}.{扩展名}` + - 示例: `20250922213126_e21775bc.pdf` + +### 文件上传 +- **上传接口**: + - 通用上传: `POST /api/v1/upload/file` + - 课程资料上传: `POST /api/v1/upload/course/{course_id}/materials` +- **支持格式**: pdf、doc、docx、ppt、pptx、xls、xlsx、txt、md、zip、mp4、mp3、png、jpg、jpeg +- **大小限制**: 50MB +- **静态访问路径**: `http://localhost:8000/static/uploads/{相对路径}` + +### 文件删除策略 +1. **删除资料时**: + - 软删除数据库记录(标记 `is_deleted=true`) + - 同步删除物理文件 + - 文件删除失败仅记录日志,不影响业务流程 + +2. **删除课程时**: + - 软删除课程记录 + - 删除整个课程文件夹 (`uploads/courses/{course_id}/`) + - 使用 `shutil.rmtree` 递归删除 + - 文件夹删除失败仅记录日志,不影响业务流程 + +### 相关配置 +- **上传路径配置**: `app/core/config.py` 中的 `UPLOAD_PATH` 属性 +- **静态文件服务**: `app/main.py` 中使用 `StaticFiles` 挂载 +- **文件上传模块**: `app/api/v1/upload.py` + +### 文档预览功能 + +#### 支持的文件格式 +- **直接预览**: PDF、TXT、Markdown (md, mdx)、HTML、CSV、VTT、Properties +- **转换预览**: Word (doc, docx)、Excel (xls, xlsx) - 通过LibreOffice转换为PDF后预览 + +#### 系统依赖 +- **LibreOffice**: 用于Office文档转换 + - libreoffice-writer: Word文档支持 + - libreoffice-calc: Excel文档支持 + - libreoffice-impress: PowerPoint文档支持(未启用) + - libreoffice-core: 核心组件 +- **中文字体**: 支持中文文档预览 + - fonts-wqy-zenhei: 文泉驿正黑 + - fonts-wqy-microhei: 文泉驿微米黑 + +#### 预览API +- 获取预览信息: `GET /api/v1/preview/material/{material_id}` +- 检查转换服务: `GET /api/v1/preview/check-converter` + +#### 转换缓存机制 +- 转换后的PDF存储在: `uploads/converted/{course_id}/{material_id}.pdf` +- 仅在源文件更新时重新转换 +- 转换失败时自动降级为下载模式 + +## API文档 + +启动服务后,可以访问: + +- Swagger UI: http://localhost:8000/docs +- ReDoc: http://localhost:8000/redoc + +## 开发指南 + +### 代码规范 + +```bash +# 格式化代码 +make format + +# 运行代码检查 +make lint + +# 运行类型检查 +make type-check +``` + +### 测试 + +```bash +# 运行测试 +make test + +# 运行测试并生成覆盖率报告 +make test-cov +``` + +### 模块开发流程 + +1. 在 `app/models/` 创建数据模型 +2. 在 `app/schemas/` 创建Pydantic模式 +3. 在 `app/services/` 实现业务逻辑 +4. 在 `app/api/v1/` 创建API路由 +5. 编写测试用例 +6. 更新API契约文档 + +## 已实现功能 + +### 陪练模块 (Training) + +- **场景管理** + + - 获取场景列表(支持分类、状态筛选) + - 创建/更新/删除场景(管理员权限) + - 获取场景详情 +- **会话管理** + + - 开始陪练会话 + - 结束陪练会话 + - 获取会话列表 + - 获取会话详情 +- **消息管理** + + - 获取会话消息列表 + - 支持文本/语音消息 +- **报告管理** + + - 生成陪练报告 + - 获取报告列表 + - 获取报告详情 + +## 待实现功能 + +- [ ] 用户认证模块 (Auth) +- [ ] 用户管理模块 (User) +- [ ] 课程管理模块 (Course) +- [ ] 考试模块 (Exam) +- [ ] 数据分析模块 (Analytics) +- [ ] 系统管理模块 (Admin) +- [ ] Coze网关模块 +- [ ] WebSocket实时通信 + +## 部署 +## 常见问题与排错 + +### 1. 登录失败相关 + +- 报错 Unknown column 'users.is_deleted':请更新数据库,确保 `users` 表包含 `is_deleted` 与 `deleted_at` 字段(参见 `scripts/init_database_unified.sql` 或执行迁移)。 +- 默认账户无法登录:重置默认账户密码哈希或运行 `create_system_accounts.py`。默认账户见上方“系统账户”。 + +### 2. 依赖冲突(httpx 与 cozepy) + +- `cozepy==0.2.0` 依赖 `httpx<0.25.0`,请将 `requirements.txt` 中 `httpx` 固定为 `0.24.1`,并避免在 `requirements-dev.txt` 再次指定其他版本。 + +### 3. Docker 拉取镜像超时 + +- 可先本地直接运行后端(确保本机 MySQL/Redis 就绪),调通后再处理 Docker 网络问题。 + + +### Docker部署 + +```bash +# 构建镜像 +docker build -t kaopeilian-backend . + +# 运行容器 +docker run -d -p 8000:8000 --env-file .env kaopeilian-backend +``` + +### Docker Compose部署 + +```bash +# 启动所有服务 +docker-compose up -d + +# 查看日志 +docker-compose logs -f +``` + +## 贡献指南 + +1. Fork项目 +2. 创建功能分支 (`git checkout -b feature/AmazingFeature`) +3. 提交代码 (`git commit -m 'feat: Add some AmazingFeature'`) +4. 推送到分支 (`git push origin feature/AmazingFeature`) +5. 创建Pull Request + +### 提交规范 + +- `feat`: 新功能 +- `fix`: 修复bug +- `docs`: 文档更新 +- `style`: 代码格式调整 +- `refactor`: 代码重构 +- `test`: 测试相关 +- `chore`: 构建过程或辅助工具的变动 + +## 许可证 + +本项目采用 MIT 许可证 diff --git a/backend/SQL_EXECUTOR_FINAL_SUMMARY.md b/backend/SQL_EXECUTOR_FINAL_SUMMARY.md new file mode 100644 index 0000000..427e739 --- /dev/null +++ b/backend/SQL_EXECUTOR_FINAL_SUMMARY.md @@ -0,0 +1,142 @@ +# 🎯 SQL 执行器 API 开发完成总结 + +## ✅ 项目状态:开发完成,本地测试通过 + +## 📦 交付内容 + +### 1. API 端点 +- ✅ `/api/v1/sql/execute` - 标准JWT认证版 +- ✅ `/api/v1/sql/execute-simple` - 简化认证版(推荐Dify使用) +- ✅ `/api/v1/sql/validate` - SQL语法验证 +- ✅ `/api/v1/sql/tables` - 获取表列表 +- ✅ `/api/v1/sql/table/{name}/schema` - 获取表结构 + +### 2. 认证方式 +- ✅ **API Key**(推荐): `X-API-Key: dify-2025-kaopeilian` +- ✅ **长期Token**: `Authorization: Bearer permanent-token-for-dify-2025` +- ✅ **标准JWT**: 通过登录接口获取(30分钟有效期) + +### 3. 文档 +- ✅ `docs/openapi_sql_executor.yaml` - OpenAPI 3.1规范(YAML) +- ✅ `docs/openapi_sql_executor.json` - OpenAPI 3.1规范(JSON) +- ✅ `docs/dify_integration_summary.md` - Dify集成指南 +- ✅ `deploy/server_setup_guide.md` - 服务器部署指南 +- ✅ `deploy/quick_deploy.sh` - 一键部署脚本 + +### 4. 核心代码 +- ✅ `app/api/v1/sql_executor.py` - 主要API实现 +- ✅ `app/core/simple_auth.py` - 简化认证实现 +- ✅ `test_sql_executor.py` - 测试脚本 + +## 🚀 Dify 快速配置 + +### 方式一:导入OpenAPI(推荐) +1. 导入 `openapi_sql_executor.yaml` +2. 选择服务器:120.79.247.16:8000 +3. 配置认证(见下方) + +### 方式二:手动配置 +``` +URL: http://120.79.247.16:8000/api/v1/sql/execute-simple +方法: POST +鉴权类型: 请求头 +鉴权头部前缀: Custom +键: X-API-Key +值: dify-2025-kaopeilian +``` + +## 💡 使用示例 + +### 简单查询 +```json +{ + "sql": "SELECT * FROM users LIMIT 5" +} +``` + +### 参数化查询 +```json +{ + "sql": "SELECT * FROM courses WHERE category = :category", + "params": {"category": "护肤"} +} +``` + +### 数据插入 +```json +{ + "sql": "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)", + "params": { + "title": "面部护理", + "content": "详细内容", + "course_id": 1 + } +} +``` + +## 🌐 服务器部署步骤 + +1. **上传代码到服务器** + ```bash + scp -r * root@120.79.247.16:/opt/kaopeilian/backend/ + ``` + +2. **运行部署脚本** + ```bash + ssh root@120.79.247.16 + cd /opt/kaopeilian/backend + bash deploy/quick_deploy.sh + ``` + +3. **验证部署** + ```bash + curl http://120.79.247.16:8000/health + ``` + +## 📊 测试结果 + +### 本地测试(全部通过 ✅) +- 健康检查:✅ 正常 +- API Key认证:✅ 成功 +- 长期Token认证:✅ 成功 +- 参数化查询:✅ 成功 +- 数据写入:✅ 成功 + +### 公网测试(待部署) +- 服务尚未部署到公网服务器 +- 需要执行部署脚本 + +## 🔐 安全建议 + +1. **生产环境** + - 修改默认API Key和Token + - 使用环境变量管理密钥 + - 启用HTTPS加密传输 + +2. **访问控制** + - 配置防火墙限制IP + - 定期更换认证密钥 + - 监控异常访问 + +## 📞 技术支持 + +- 本地测试端口:8000 +- 服务器地址:120.79.247.16 +- 数据库:kaopeilian +- 认证密钥:已在文档中提供 + +## ⏰ 时间线 + +- 开发开始:2025-09-23 14:00 +- 开发完成:2025-09-23 16:30 +- 本地测试:✅ 通过 +- 生产部署:⏳ 待执行 + +--- + +**当前状态**:开发完成,本地测试通过,等待部署到生产环境。 + +**下一步**: +1. 执行服务器部署 +2. 在Dify中配置使用 +3. 集成到实际工作流 diff --git a/backend/__init__.py b/backend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/alembic/versions/add_course_fields.sql b/backend/alembic/versions/add_course_fields.sql new file mode 100644 index 0000000..0e608a8 --- /dev/null +++ b/backend/alembic/versions/add_course_fields.sql @@ -0,0 +1,10 @@ +-- 为课程表添加学员统计等字段 + +ALTER TABLE `courses` +ADD COLUMN `student_count` INT DEFAULT 0 COMMENT '学习人数' AFTER `is_featured`, +ADD COLUMN `is_new` BOOLEAN DEFAULT TRUE COMMENT '是否新课程(最近30天内发布)' AFTER `student_count`, +ADD INDEX `idx_student_count` (`student_count`), +ADD INDEX `idx_is_new` (`is_new`); + + + diff --git a/backend/alembic/versions/add_mistake_mastery_fields.sql b/backend/alembic/versions/add_mistake_mastery_fields.sql new file mode 100644 index 0000000..14780b9 --- /dev/null +++ b/backend/alembic/versions/add_mistake_mastery_fields.sql @@ -0,0 +1,12 @@ +-- 为错题表添加掌握状态和统计字段 + +ALTER TABLE `exam_mistakes` +ADD COLUMN `mastery_status` VARCHAR(20) DEFAULT 'unmastered' COMMENT '掌握状态: unmastered-未掌握, mastered-已掌握' AFTER `question_type`, +ADD COLUMN `difficulty` VARCHAR(20) DEFAULT 'medium' COMMENT '题目难度: easy-简单, medium-中等, hard-困难' AFTER `mastery_status`, +ADD COLUMN `wrong_count` INT DEFAULT 1 COMMENT '错误次数统计' AFTER `difficulty`, +ADD COLUMN `mastered_at` DATETIME NULL COMMENT '标记掌握时间' AFTER `wrong_count`, +ADD INDEX `idx_mastery_status` (`mastery_status`), +ADD INDEX `idx_difficulty` (`difficulty`); + + + diff --git a/backend/alembic/versions/create_system_logs_table.sql b/backend/alembic/versions/create_system_logs_table.sql new file mode 100644 index 0000000..c682730 --- /dev/null +++ b/backend/alembic/versions/create_system_logs_table.sql @@ -0,0 +1,30 @@ +-- 创建系统日志表 +-- 用于记录系统操作、错误、安全事件等日志信息 + +CREATE TABLE IF NOT EXISTS `system_logs` ( + `id` INT NOT NULL AUTO_INCREMENT COMMENT '日志ID', + `level` VARCHAR(20) NOT NULL COMMENT '日志级别: debug, info, warning, error', + `type` VARCHAR(50) NOT NULL COMMENT '日志类型: system, user, api, error, security', + `user` VARCHAR(100) NULL COMMENT '操作用户', + `user_id` INT NULL COMMENT '用户ID', + `ip` VARCHAR(100) NULL COMMENT 'IP地址', + `message` TEXT NOT NULL COMMENT '日志消息', + `user_agent` VARCHAR(500) NULL COMMENT 'User Agent', + `path` VARCHAR(500) NULL COMMENT '请求路径(API路径)', + `method` VARCHAR(10) NULL COMMENT '请求方法', + `extra_data` TEXT NULL COMMENT '额外数据(JSON格式)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + PRIMARY KEY (`id`), + INDEX `idx_system_logs_level` (`level`), + INDEX `idx_system_logs_type` (`type`), + INDEX `idx_system_logs_user` (`user`), + INDEX `idx_system_logs_user_id` (`user_id`), + INDEX `idx_system_logs_path` (`path`), + INDEX `idx_system_logs_created_at` (`created_at`), + INDEX `idx_system_logs_level_type` (`level`, `type`), + INDEX `idx_system_logs_user_created` (`user`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='系统日志表'; + + + diff --git a/backend/alembic/versions/create_tasks_table.sql b/backend/alembic/versions/create_tasks_table.sql new file mode 100644 index 0000000..eead2bb --- /dev/null +++ b/backend/alembic/versions/create_tasks_table.sql @@ -0,0 +1,50 @@ +-- 创建任务表 +CREATE TABLE `tasks` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `title` VARCHAR(200) NOT NULL COMMENT '任务标题', + `description` TEXT COMMENT '任务描述', + `priority` ENUM('low', 'medium', 'high') DEFAULT 'medium' COMMENT '优先级', + `status` ENUM('pending', 'ongoing', 'completed', 'expired') DEFAULT 'pending' COMMENT '任务状态', + `creator_id` INT NOT NULL COMMENT '创建人ID', + `deadline` DATETIME COMMENT '截止时间', + `requirements` JSON COMMENT '任务要求配置', + `progress` INT DEFAULT 0 COMMENT '完成进度(0-100)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + INDEX `idx_status` (`status`), + INDEX `idx_creator` (`creator_id`), + INDEX `idx_deadline` (`deadline`), + FOREIGN KEY (`creator_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务表'; + +-- 创建任务课程关联表 +CREATE TABLE `task_courses` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `task_id` INT NOT NULL COMMENT '任务ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `uk_task_course` (`task_id`, `course_id`), + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务课程关联表'; + +-- 创建任务分配表 +CREATE TABLE `task_assignments` ( + `id` INT NOT NULL AUTO_INCREMENT PRIMARY KEY, + `task_id` INT NOT NULL COMMENT '任务ID', + `user_id` INT NOT NULL COMMENT '分配用户ID', + `team_id` INT DEFAULT NULL COMMENT '团队ID(如果按团队分配)', + `status` ENUM('not_started', 'in_progress', 'completed') DEFAULT 'not_started' COMMENT '完成状态', + `progress` INT DEFAULT 0 COMMENT '个人完成进度(0-100)', + `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 `uk_task_user` (`task_id`, `user_id`), + INDEX `idx_status` (`status`), + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`user_id`) REFERENCES `users`(`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='任务分配表'; + + + diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000..2f881a5 --- /dev/null +++ b/backend/app/__init__.py @@ -0,0 +1 @@ +"""考培练系统后端应用包""" diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py new file mode 100644 index 0000000..557b975 --- /dev/null +++ b/backend/app/api/__init__.py @@ -0,0 +1 @@ +# API 路由模块 diff --git a/backend/app/api/v1/03-Agent-Course/api_contract.yaml b/backend/app/api/v1/03-Agent-Course/api_contract.yaml new file mode 100644 index 0000000..1a7d70a --- /dev/null +++ b/backend/app/api/v1/03-Agent-Course/api_contract.yaml @@ -0,0 +1,497 @@ +openapi: 3.0.0 +info: + title: 课程管理模块API契约 + description: 定义课程管理模块对外提供的所有API接口 + version: 1.0.0 + +servers: + - url: http://localhost:8000/api/v1 + description: 本地开发服务器 + +paths: + /courses: + get: + summary: 获取课程列表 + description: 支持分页和多条件筛选 + operationId: getCourses + security: + - bearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + - name: status + in: query + schema: + type: string + enum: [draft, published, archived] + - name: category + in: query + schema: + type: string + enum: [technology, management, business, general] + - name: is_featured + in: query + schema: + type: boolean + - name: keyword + in: query + schema: + type: string + responses: + "200": + description: 成功获取课程列表 + content: + application/json: + schema: + $ref: "#/components/schemas/CoursePageResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + + post: + summary: 创建课程 + description: 创建新课程(需要管理员权限) + operationId: createCourse + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CourseCreate" + responses: + "201": + description: 成功创建课程 + content: + application/json: + schema: + $ref: "#/components/schemas/CourseResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "409": + $ref: "#/components/responses/ConflictError" + + /courses/{courseId}: + get: + summary: 获取课程详情 + operationId: getCourse + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + responses: + "200": + description: 成功获取课程详情 + content: + application/json: + schema: + $ref: "#/components/schemas/CourseResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" + + put: + summary: 更新课程 + description: 更新课程信息(需要管理员权限) + operationId: updateCourse + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: "#/components/schemas/CourseUpdate" + responses: + "200": + description: 成功更新课程 + content: + application/json: + schema: + $ref: "#/components/schemas/CourseResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + + delete: + summary: 删除课程 + description: 软删除课程(需要管理员权限) + operationId: deleteCourse + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + responses: + "200": + description: 成功删除课程 + content: + application/json: + schema: + $ref: "#/components/schemas/DeleteResponse" + "400": + $ref: "#/components/responses/BadRequestError" + "401": + $ref: "#/components/responses/UnauthorizedError" + "403": + $ref: "#/components/responses/ForbiddenError" + "404": + $ref: "#/components/responses/NotFoundError" + + /courses/{courseId}/knowledge-points: + get: + summary: 获取课程知识点列表 + operationId: getCourseKnowledgePoints + security: + - bearerAuth: [] + parameters: + - name: courseId + in: path + required: true + schema: + type: integer + - name: parent_id + in: query + schema: + type: integer + nullable: true + responses: + "200": + description: 成功获取知识点列表 + content: + application/json: + schema: + $ref: "#/components/schemas/KnowledgePointListResponse" + "401": + $ref: "#/components/responses/UnauthorizedError" + "404": + $ref: "#/components/responses/NotFoundError" + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + schemas: + ResponseBase: + type: object + required: + - code + - message + properties: + code: + type: integer + default: 200 + message: + type: string + request_id: + type: string + timestamp: + type: string + format: date-time + + CourseBase: + type: object + properties: + name: + type: string + minLength: 1 + maxLength: 200 + description: + type: string + category: + type: string + enum: [technology, management, business, general] + default: general + cover_image: + type: string + maxLength: 500 + duration_hours: + type: number + format: float + minimum: 0 + difficulty_level: + type: integer + minimum: 1 + maximum: 5 + tags: + type: array + items: + type: string + sort_order: + type: integer + default: 0 + is_featured: + type: boolean + default: false + + CourseCreate: + allOf: + - $ref: "#/components/schemas/CourseBase" + - type: object + required: + - name + properties: + status: + type: string + enum: [draft, published, archived] + default: draft + + CourseUpdate: + allOf: + - $ref: "#/components/schemas/CourseBase" + - type: object + properties: + status: + type: string + enum: [draft, published, archived] + + Course: + allOf: + - $ref: "#/components/schemas/CourseBase" + - type: object + required: + - id + - status + - created_at + - updated_at + properties: + id: + type: integer + status: + type: string + enum: [draft, published, archived] + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + published_at: + type: string + format: date-time + nullable: true + publisher_id: + type: integer + nullable: true + created_by: + type: integer + nullable: true + updated_by: + type: integer + nullable: true + + CourseResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + $ref: "#/components/schemas/Course" + + CoursePageResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + type: object + required: + - items + - total + - page + - size + - pages + properties: + items: + type: array + items: + $ref: "#/components/schemas/Course" + total: + type: integer + page: + type: integer + size: + type: integer + pages: + type: integer + + KnowledgePoint: + type: object + required: + - id + - course_id + - name + - level + - created_at + - updated_at + properties: + id: + type: integer + course_id: + type: integer + name: + type: string + maxLength: 200 + description: + type: string + parent_id: + type: integer + nullable: true + level: + type: integer + path: + type: string + nullable: true + sort_order: + type: integer + weight: + type: number + format: float + is_required: + type: boolean + estimated_hours: + type: number + format: float + nullable: true + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + KnowledgePointListResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + type: array + items: + $ref: "#/components/schemas/KnowledgePoint" + + DeleteResponse: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + data: + type: boolean + + ErrorDetail: + type: object + required: + - message + properties: + message: + type: string + error_code: + type: string + field: + type: string + details: + type: object + + responses: + BadRequestError: + description: 请求参数错误 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 400 + detail: + $ref: "#/components/schemas/ErrorDetail" + + UnauthorizedError: + description: 未认证 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 401 + detail: + $ref: "#/components/schemas/ErrorDetail" + + ForbiddenError: + description: 权限不足 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 403 + detail: + $ref: "#/components/schemas/ErrorDetail" + + NotFoundError: + description: 资源不存在 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 404 + detail: + $ref: "#/components/schemas/ErrorDetail" + + ConflictError: + description: 资源冲突 + content: + application/json: + schema: + allOf: + - $ref: "#/components/schemas/ResponseBase" + - type: object + properties: + code: + example: 409 + detail: + $ref: "#/components/schemas/ErrorDetail" diff --git a/backend/app/api/v1/__init__.py b/backend/app/api/v1/__init__.py new file mode 100644 index 0000000..2a8a627 --- /dev/null +++ b/backend/app/api/v1/__init__.py @@ -0,0 +1,105 @@ +""" +API v1 版本模块 +整合所有 v1 版本的路由 +""" + +from fastapi import APIRouter + +# 先只导入必要的路由 +from .coze_gateway import router as coze_router + +# 创建 v1 版本的主路由 +api_router = APIRouter() + +# 包含各个子路由 +api_router.include_router(coze_router, tags=["coze"]) + +# TODO: 逐步添加其他路由 +from .auth import router as auth_router +from .courses import router as courses_router +from .users import router as users_router +from .training import router as training_router +from .admin import router as admin_router +from .positions import router as positions_router +from .upload import router as upload_router +from .teams import router as teams_router +from .knowledge_analysis import router as knowledge_analysis_router +from .system import router as system_router +from .sql_executor import router as sql_executor_router + +from .exam import router as exam_router +from .practice import router as practice_router +from .course_chat import router as course_chat_router +from .broadcast import router as broadcast_router +from .preview import router as preview_router +from .yanji import router as yanji_router +from .ability import router as ability_router +from .statistics import router as statistics_router +from .team_dashboard import router as team_dashboard_router +from .team_management import router as team_management_router +# Manager 模块路由 +from .manager import student_scores_router, student_practice_router +from .system_logs import router as system_logs_router +from .tasks import router as tasks_router +from .endpoints.employee_sync import router as employee_sync_router +from .notifications import router as notifications_router +from .scrm import router as scrm_router +# 管理后台路由 +from .admin_portal import router as admin_portal_router + +api_router.include_router(auth_router, prefix="/auth", tags=["auth"]) +# courses_router 已在内部定义了 prefix="/courses",此处不再额外添加前缀 +api_router.include_router(courses_router, tags=["courses"]) +api_router.include_router(users_router, prefix="/users", tags=["users"]) +# training_router 已在内部定义了 prefix="/training",此处不再额外添加前缀 +api_router.include_router(training_router, tags=["training"]) +# admin_router 已在内部定义了 prefix="/admin",此处不再额外添加前缀 +api_router.include_router(admin_router, tags=["admin"]) +api_router.include_router(positions_router, tags=["positions"]) +# upload_router 已在内部定义了 prefix="/upload",此处不再额外添加前缀 +api_router.include_router(upload_router, tags=["upload"]) +api_router.include_router(teams_router, tags=["teams"]) +# knowledge_analysis_router 不需要额外前缀,路径已在路由中定义 +api_router.include_router(knowledge_analysis_router, tags=["knowledge-analysis"]) +# system_router 已在内部定义了 prefix="/system",此处不再额外添加前缀 +api_router.include_router(system_router, tags=["system"]) +# sql_executor_router SQL 执行器 +api_router.include_router(sql_executor_router, prefix="/sql", tags=["sql-executor"]) +# exam_router 已在内部定义了 prefix="/exams",此处不再额外添加前缀 +api_router.include_router(exam_router, tags=["exams"]) +# practice_router 陪练功能路由 +api_router.include_router(practice_router, prefix="/practice", tags=["practice"]) +# course_chat_router 与课程对话路由 +api_router.include_router(course_chat_router, prefix="/course", tags=["course-chat"]) +# broadcast_router 播课功能路由(不添加prefix,路径在router内部定义) +api_router.include_router(broadcast_router, tags=["broadcast"]) +# preview_router 文件预览路由 +api_router.include_router(preview_router, prefix="/preview", tags=["preview"]) +# yanji_router 言迹智能工牌路由 +api_router.include_router(yanji_router, prefix="/yanji", tags=["yanji"]) +# ability_router 能力评估路由 +api_router.include_router(ability_router, prefix="/ability", tags=["ability"]) +# statistics_router 统计分析路由(不添加prefix,路径在router内部定义) +api_router.include_router(statistics_router, tags=["statistics"]) +# team_dashboard_router 团队看板路由(不添加prefix,路径在router内部定义为/team/dashboard) +api_router.include_router(team_dashboard_router, tags=["team-dashboard"]) +# team_management_router 团队成员管理路由(不添加prefix,路径在router内部定义为/team/management) +api_router.include_router(team_management_router, tags=["team-management"]) +# student_scores_router 学员考试成绩管理路由(不添加prefix,路径在router内部定义为/manager/student-scores) +api_router.include_router(student_scores_router, tags=["manager-student-scores"]) +# student_practice_router 学员陪练记录管理路由(不添加prefix,路径在router内部定义为/manager/student-practice) +api_router.include_router(student_practice_router, tags=["manager-student-practice"]) +# system_logs_router 系统日志路由(不添加prefix,路径在router内部定义为/admin/logs) +api_router.include_router(system_logs_router, tags=["system-logs"]) +# tasks_router 任务管理路由(不添加prefix,路径在router内部定义为/manager/tasks) +api_router.include_router(tasks_router, tags=["tasks"]) +# employee_sync_router 员工同步路由 +api_router.include_router(employee_sync_router, prefix="/employee-sync", tags=["employee-sync"]) +# notifications_router 站内消息通知路由(不添加prefix,路径在router内部定义为/notifications) +api_router.include_router(notifications_router, tags=["notifications"]) +# scrm_router SCRM系统对接路由(prefix在router内部定义为/scrm) +api_router.include_router(scrm_router, tags=["scrm"]) +# admin_portal_router SaaS超级管理后台路由(prefix在router内部定义为/admin) +api_router.include_router(admin_portal_router, tags=["admin-portal"]) + +__all__ = ["api_router"] diff --git a/backend/app/api/v1/ability.py b/backend/app/api/v1/ability.py new file mode 100644 index 0000000..cc1225c --- /dev/null +++ b/backend/app/api/v1/ability.py @@ -0,0 +1,187 @@ +""" +能力评估API接口 +用于智能工牌数据分析、能力评估报告生成等 +""" +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession +from typing import List + +from app.core.deps import get_current_user, get_db +from app.models.user import User +from app.schemas.base import ResponseModel +from app.schemas.ability import AbilityAssessmentResponse, AbilityAssessmentHistory +from app.services.yanji_service import YanjiService +from app.services.ability_assessment_service import get_ability_assessment_service + +import logging + +logger = logging.getLogger(__name__) +router = APIRouter() + + +@router.post("/analyze-yanji", response_model=ResponseModel) +async def analyze_yanji_badge_data( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 分析智能工牌数据生成能力评估和课程推荐 + + 使用 Python 原生 AI 服务实现。 + + 功能说明: + 1. 从言迹智能工牌获取员工的最近10条录音记录 + 2. 分析对话数据,进行能力评估(6个维度) + 3. 基于能力短板生成课程推荐(3-5门) + 4. 保存评估记录到数据库 + + 要求: + - 用户必须已绑定手机号(用于匹配言迹数据) + + 返回: + - assessment_id: 评估记录ID + - total_score: 综合评分(0-100) + - dimensions: 能力维度列表(6个维度) + - recommended_courses: 推荐课程列表(3-5门) + - conversation_count: 分析的对话数量 + """ + # 检查用户是否绑定手机号 + if not current_user.phone: + logger.warning(f"用户未绑定手机号: user_id={current_user.id}") + raise HTTPException( + status_code=400, + detail="用户未绑定手机号,无法匹配言迹数据" + ) + + # 获取服务实例 + yanji_service = YanjiService() + assessment_service = get_ability_assessment_service() + + try: + logger.info( + f"开始分析智能工牌数据: user_id={current_user.id}, " + f"phone={current_user.phone}" + ) + + # 调用能力评估服务(使用 Python 原生实现) + result = await assessment_service.analyze_yanji_conversations( + user_id=current_user.id, + phone=current_user.phone, + db=db, + yanji_service=yanji_service, + engine="v2" # 固定使用 V2 + ) + + logger.info( + f"智能工牌数据分析完成: user_id={current_user.id}, " + f"assessment_id={result['assessment_id']}, " + f"total_score={result['total_score']}" + ) + + return ResponseModel( + code=200, + message="智能工牌数据分析完成", + data=result + ) + + except ValueError as e: + # 业务逻辑错误(如未找到录音记录) + logger.warning(f"智能工牌数据分析失败: {e}") + raise HTTPException(status_code=404, detail=str(e)) + + except Exception as e: + # 系统错误 + logger.error(f"分析智能工牌数据失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"分析失败: {str(e)}" + ) + + +@router.get("/history", response_model=ResponseModel) +async def get_assessment_history( + limit: int = Query(default=10, ge=1, le=50, description="返回记录数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取用户的能力评估历史记录 + + 参数: + - limit: 返回记录数量(默认10,最大50) + + 返回: + - 评估历史记录列表 + """ + assessment_service = get_ability_assessment_service() + + try: + history = await assessment_service.get_user_assessment_history( + user_id=current_user.id, + db=db, + limit=limit + ) + + return ResponseModel( + code=200, + message=f"获取评估历史成功,共{len(history)}条", + data={"history": history, "total": len(history)} + ) + + except Exception as e: + logger.error(f"获取评估历史失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"获取评估历史失败: {str(e)}" + ) + + +@router.get("/{assessment_id}", response_model=ResponseModel) +async def get_assessment_detail( + assessment_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取单个评估记录的详细信息 + + 参数: + - assessment_id: 评估记录ID + + 返回: + - 评估详细信息 + """ + assessment_service = get_ability_assessment_service() + + try: + detail = await assessment_service.get_assessment_detail( + assessment_id=assessment_id, + db=db + ) + + # 权限检查:只能查看自己的评估记录 + if detail['user_id'] != current_user.id: + raise HTTPException( + status_code=403, + detail="无权访问该评估记录" + ) + + return ResponseModel( + code=200, + message="获取评估详情成功", + data=detail + ) + + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + except HTTPException: + raise + + except Exception as e: + logger.error(f"获取评估详情失败: {e}", exc_info=True) + raise HTTPException( + status_code=500, + detail=f"获取评估详情失败: {str(e)}" + ) + diff --git a/backend/app/api/v1/admin.py b/backend/app/api/v1/admin.py new file mode 100644 index 0000000..2c58de4 --- /dev/null +++ b/backend/app/api/v1/admin.py @@ -0,0 +1,509 @@ +""" +管理员相关API路由 +""" + +from typing import Optional, List, Dict, Any +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Query +from sqlalchemy.ext.asyncio import AsyncSession +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.schemas.base import ResponseModel + +router = APIRouter(prefix="/admin") + + +@router.get("/dashboard/stats") +async def get_dashboard_stats( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取管理员仪表盘统计数据 + + 需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + # 用户统计 + total_users = await db.scalar(select(func.count(User.id))) + + # 计算最近30天的新增用户 + thirty_days_ago = datetime.now() - timedelta(days=30) + new_users_count = await db.scalar( + select(func.count(User.id)) + .where(User.created_at >= thirty_days_ago) + ) + + # 计算增长率(假设上个月也是30天) + sixty_days_ago = datetime.now() - timedelta(days=60) + last_month_users = await db.scalar( + select(func.count(User.id)) + .where(User.created_at >= sixty_days_ago) + .where(User.created_at < thirty_days_ago) + ) + + growth_rate = 0.0 + if last_month_users > 0: + growth_rate = ((new_users_count - last_month_users) / last_month_users) * 100 + + # 课程统计 + total_courses = await db.scalar( + select(func.count(Course.id)) + .where(Course.status == CourseStatus.PUBLISHED) + ) + + # TODO: 完成的课程数需要根据用户课程进度表计算 + completed_courses = 0 # 暂时设为0 + + # 考试统计(如果有考试表的话) + total_exams = 0 + avg_score = 0.0 + pass_rate = "0%" + + # 学习时长统计(如果有学习记录表的话) + total_learning_hours = 0 + avg_learning_hours = 0.0 + active_rate = "0%" + + # 构建响应数据 + stats = { + "users": { + "total": total_users, + "growth": new_users_count, + "growthRate": f"{growth_rate:.1f}%" + }, + "courses": { + "total": total_courses, + "completed": completed_courses, + "completionRate": f"{(completed_courses / total_courses * 100) if total_courses > 0 else 0:.1f}%" + }, + "exams": { + "total": total_exams, + "avgScore": avg_score, + "passRate": pass_rate + }, + "learning": { + "totalHours": total_learning_hours, + "avgHours": avg_learning_hours, + "activeRate": active_rate + } + } + + return ResponseModel( + code=200, + message="获取仪表盘统计数据成功", + data=stats + ) + + +@router.get("/dashboard/user-growth") +async def get_user_growth_data( + days: int = Query(30, description="统计天数", ge=7, le=90), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取用户增长数据 + + Args: + days: 统计天数,默认30天 + + 需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + # 准备日期列表 + dates = [] + new_users = [] + active_users = [] + + end_date = datetime.now().date() + + for i in range(days): + current_date = end_date - timedelta(days=days-1-i) + dates.append(current_date.strftime("%Y-%m-%d")) + + # 统计当天新增用户 + next_date = current_date + timedelta(days=1) + new_count = await db.scalar( + select(func.count(User.id)) + .where(func.date(User.created_at) == current_date) + ) + new_users.append(new_count or 0) + + # 统计当天活跃用户(有登录记录) + active_count = await db.scalar( + select(func.count(User.id)) + .where(func.date(User.last_login_at) == current_date) + ) + active_users.append(active_count or 0) + + return ResponseModel( + code=200, + message="获取用户增长数据成功", + data={ + "dates": dates, + "newUsers": new_users, + "activeUsers": active_users + } + ) + + +@router.get("/dashboard/course-completion") +async def get_course_completion_data( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取课程完成率数据 + + 需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + # 获取所有已发布的课程 + courses_result = await db.execute( + select(Course.name, Course.id) + .where(Course.status == CourseStatus.PUBLISHED) + .order_by(Course.sort_order, Course.id) + .limit(10) # 限制显示前10个课程 + ) + courses = courses_result.all() + + course_names = [] + completion_rates = [] + + for course_name, course_id in courses: + course_names.append(course_name) + + # TODO: 根据用户课程进度表计算完成率 + # 这里暂时生成模拟数据 + import random + completion_rate = random.randint(60, 95) + completion_rates.append(completion_rate) + + return ResponseModel( + code=200, + message="获取课程完成率数据成功", + data={ + "courses": course_names, + "completionRates": completion_rates + } + ) + + +# ===== 岗位管理(最小可用 stub 版本)===== + +def _ensure_admin(user: User) -> Optional[ResponseModel]: + if user.role != "admin": + return ResponseModel(code=403, message="权限不足,需要管理员权限") + return None + + +# 注意:positions相关路由已移至positions.py +# _sample_positions函数和所有positions路由已删除,避免与positions.py冲突 + + +# ===== 用户批量操作 ===== + +from pydantic import BaseModel +from app.models.position_member import PositionMember + + +class BatchUserOperation(BaseModel): + """批量用户操作请求模型""" + ids: List[int] + action: str # delete, activate, deactivate, change_role, assign_position, assign_team + value: Optional[Any] = None # 角色值、岗位ID、团队ID等 + + +@router.post("/users/batch", response_model=ResponseModel) +async def batch_user_operation( + operation: BatchUserOperation, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 批量用户操作 + + 支持的操作类型: + - delete: 批量删除用户(软删除) + - activate: 批量启用用户 + - deactivate: 批量禁用用户 + - change_role: 批量修改角色(需要 value 参数) + - assign_position: 批量分配岗位(需要 value 参数为岗位ID) + - assign_team: 批量分配团队(需要 value 参数为团队ID) + + 权限:需要管理员权限 + """ + # 权限检查 + if current_user.role != "admin": + return ResponseModel( + code=403, + message="权限不足,需要管理员权限" + ) + + if not operation.ids: + return ResponseModel( + code=400, + message="请选择要操作的用户" + ) + + # 不能操作自己 + if current_user.id in operation.ids: + return ResponseModel( + code=400, + message="不能对自己执行批量操作" + ) + + # 获取要操作的用户 + result = await db.execute( + select(User).where(User.id.in_(operation.ids), User.is_deleted == False) + ) + users = result.scalars().all() + + if not users: + return ResponseModel( + code=404, + message="未找到要操作的用户" + ) + + success_count = 0 + failed_count = 0 + errors = [] + + try: + if operation.action == "delete": + # 批量软删除 + for user in users: + try: + user.is_deleted = True + user.deleted_at = datetime.now() + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"删除用户 {user.username} 失败: {str(e)}") + + await db.commit() + + elif operation.action == "activate": + # 批量启用 + for user in users: + try: + user.is_active = True + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"启用用户 {user.username} 失败: {str(e)}") + + await db.commit() + + elif operation.action == "deactivate": + # 批量禁用 + for user in users: + try: + user.is_active = False + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"禁用用户 {user.username} 失败: {str(e)}") + + await db.commit() + + elif operation.action == "change_role": + # 批量修改角色 + if not operation.value: + return ResponseModel( + code=400, + message="请指定要修改的角色" + ) + + valid_roles = ["trainee", "manager", "admin"] + if operation.value not in valid_roles: + return ResponseModel( + code=400, + message=f"无效的角色,可选值: {', '.join(valid_roles)}" + ) + + for user in users: + try: + user.role = operation.value + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"修改用户 {user.username} 角色失败: {str(e)}") + + await db.commit() + + elif operation.action == "assign_position": + # 批量分配岗位 + if not operation.value: + return ResponseModel( + code=400, + message="请指定要分配的岗位ID" + ) + + position_id = int(operation.value) + + # 获取岗位信息用于通知 + from app.models.position import Position + position_result = await db.execute( + select(Position).where(Position.id == position_id) + ) + position = position_result.scalar_one_or_none() + position_name = position.name if position else "未知岗位" + + # 记录新分配成功的用户ID(用于发送通知) + newly_assigned_user_ids = [] + + for user in users: + try: + # 检查是否已有该岗位 + existing = await db.execute( + select(PositionMember).where( + PositionMember.user_id == user.id, + PositionMember.position_id == position_id, + PositionMember.is_deleted == False + ) + ) + if existing.scalar_one_or_none(): + # 已有该岗位,跳过 + success_count += 1 + continue + + # 添加岗位关联(PositionMember模型没有created_by字段) + member = PositionMember( + position_id=position_id, + user_id=user.id, + joined_at=datetime.now() + ) + db.add(member) + newly_assigned_user_ids.append(user.id) + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"为用户 {user.username} 分配岗位失败: {str(e)}") + + await db.commit() + + # 发送岗位分配通知给新分配的用户 + if newly_assigned_user_ids: + try: + from app.services.notification_service import notification_service + from app.schemas.notification import NotificationBatchCreate, NotificationType + + notification_batch = NotificationBatchCreate( + user_ids=newly_assigned_user_ids, + title="岗位分配通知", + content=f"您已被分配到「{position_name}」岗位,请查看相关培训课程。", + type=NotificationType.POSITION_ASSIGN, + related_id=position_id, + related_type="position", + sender_id=current_user.id + ) + + await notification_service.batch_create_notifications( + db=db, + batch_in=notification_batch + ) + except Exception as e: + # 通知发送失败不影响岗位分配结果 + import logging + logging.getLogger(__name__).error(f"发送岗位分配通知失败: {str(e)}") + + elif operation.action == "assign_team": + # 批量分配团队 + if not operation.value: + return ResponseModel( + code=400, + message="请指定要分配的团队ID" + ) + + from app.models.user import user_teams + + team_id = int(operation.value) + + for user in users: + try: + # 检查是否已在该团队 + existing = await db.execute( + select(user_teams).where( + user_teams.c.user_id == user.id, + user_teams.c.team_id == team_id + ) + ) + if existing.first(): + # 已在该团队,跳过 + success_count += 1 + continue + + # 添加团队关联 + await db.execute( + user_teams.insert().values( + user_id=user.id, + team_id=team_id, + role="member", + joined_at=datetime.now() + ) + ) + success_count += 1 + except Exception as e: + failed_count += 1 + errors.append(f"为用户 {user.username} 分配团队失败: {str(e)}") + + await db.commit() + + else: + return ResponseModel( + code=400, + message=f"不支持的操作类型: {operation.action}" + ) + + # 返回结果 + action_names = { + "delete": "删除", + "activate": "启用", + "deactivate": "禁用", + "change_role": "修改角色", + "assign_position": "分配岗位", + "assign_team": "分配团队" + } + action_name = action_names.get(operation.action, operation.action) + + return ResponseModel( + code=200, + message=f"批量{action_name}完成:成功 {success_count} 个,失败 {failed_count} 个", + data={ + "success_count": success_count, + "failed_count": failed_count, + "errors": errors + } + ) + + except Exception as e: + await db.rollback() + return ResponseModel( + code=500, + message=f"批量操作失败: {str(e)}" + ) + + diff --git a/backend/app/api/v1/admin_portal/__init__.py b/backend/app/api/v1/admin_portal/__init__.py new file mode 100644 index 0000000..bb69b5c --- /dev/null +++ b/backend/app/api/v1/admin_portal/__init__.py @@ -0,0 +1,24 @@ +""" +SaaS 超级管理后台 API + +提供租户管理、配置管理、提示词管理等功能 +""" + +from fastapi import APIRouter + +from .auth import router as auth_router +from .tenants import router as tenants_router +from .configs import router as configs_router +from .prompts import router as prompts_router +from .features import router as features_router + +# 创建管理后台主路由 +router = APIRouter(prefix="/admin", tags=["管理后台"]) + +# 注册子路由 +router.include_router(auth_router) +router.include_router(tenants_router) +router.include_router(configs_router) +router.include_router(prompts_router) +router.include_router(features_router) + diff --git a/backend/app/api/v1/admin_portal/auth.py b/backend/app/api/v1/admin_portal/auth.py new file mode 100644 index 0000000..7f12b94 --- /dev/null +++ b/backend/app/api/v1/admin_portal/auth.py @@ -0,0 +1,277 @@ +""" +管理员认证 API +""" + +import os +from datetime import datetime, timedelta +from typing import Optional + +import jwt +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from passlib.context import CryptContext + +from .schemas import ( + AdminLoginRequest, + AdminLoginResponse, + AdminUserInfo, + AdminChangePasswordRequest, + ResponseModel, +) + +router = APIRouter(prefix="/auth", tags=["管理员认证"]) + +# 密码加密 +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + +# JWT 配置 +SECRET_KEY = os.getenv("ADMIN_JWT_SECRET", "admin-secret-key-kaopeilian-2026") +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_HOURS = 24 + +# 安全认证 +security = HTTPBearer() + +# 管理库连接配置 +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "prod-mysql"), + "port": int(os.getenv("ADMIN_DB_PORT", "3306")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"), + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + + +def get_db_connection(): + """获取数据库连接""" + return pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return pwd_context.verify(plain_password, hashed_password) + + +def get_password_hash(password: str) -> str: + """获取密码哈希""" + return pwd_context.hash(password) + + +def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str: + """创建访问令牌""" + to_encode = data.copy() + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(hours=ACCESS_TOKEN_EXPIRE_HOURS) + to_encode.update({"exp": expire}) + encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + return encoded_jwt + + +def decode_access_token(token: str) -> dict: + """解码访问令牌""" + try: + payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM]) + return payload + except jwt.ExpiredSignatureError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token已过期", + ) + except jwt.InvalidTokenError: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的Token", + ) + + +async def get_current_admin( + credentials: HTTPAuthorizationCredentials = Depends(security) +) -> AdminUserInfo: + """获取当前登录的管理员""" + token = credentials.credentials + payload = decode_access_token(token) + + admin_id = payload.get("sub") + if not admin_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的Token", + ) + + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT id, username, email, full_name, role, is_active, last_login_at + FROM admin_users WHERE id = %s + """, + (admin_id,) + ) + admin = cursor.fetchone() + + if not admin: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="管理员不存在", + ) + + if not admin["is_active"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账户已被禁用", + ) + + return AdminUserInfo( + id=admin["id"], + username=admin["username"], + email=admin["email"], + full_name=admin["full_name"], + role=admin["role"], + last_login_at=admin["last_login_at"], + ) + finally: + conn.close() + + +async def require_superadmin( + admin: AdminUserInfo = Depends(get_current_admin) +) -> AdminUserInfo: + """要求超级管理员权限""" + if admin.role != "superadmin": + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="需要超级管理员权限", + ) + return admin + + +@router.post("/login", response_model=AdminLoginResponse, summary="管理员登录") +async def admin_login(request: Request, login_data: AdminLoginRequest): + """ + 管理员登录 + + - **username**: 用户名 + - **password**: 密码 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 查询管理员 + cursor.execute( + """ + SELECT id, username, email, full_name, role, password_hash, is_active, last_login_at + FROM admin_users WHERE username = %s + """, + (login_data.username,) + ) + admin = cursor.fetchone() + + if not admin: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + if not admin["is_active"]: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="账户已被禁用", + ) + + # 验证密码 + if not verify_password(login_data.password, admin["password_hash"]): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="用户名或密码错误", + ) + + # 更新最后登录时间和IP + client_ip = request.client.host if request.client else None + cursor.execute( + """ + UPDATE admin_users + SET last_login_at = NOW(), last_login_ip = %s + WHERE id = %s + """, + (client_ip, admin["id"]) + ) + conn.commit() + + # 创建 Token + access_token = create_access_token( + data={"sub": str(admin["id"]), "username": admin["username"], "role": admin["role"]} + ) + + return AdminLoginResponse( + access_token=access_token, + token_type="bearer", + expires_in=ACCESS_TOKEN_EXPIRE_HOURS * 3600, + admin_user=AdminUserInfo( + id=admin["id"], + username=admin["username"], + email=admin["email"], + full_name=admin["full_name"], + role=admin["role"], + last_login_at=datetime.now(), + ), + ) + finally: + conn.close() + + +@router.get("/me", response_model=AdminUserInfo, summary="获取当前管理员信息") +async def get_me(admin: AdminUserInfo = Depends(get_current_admin)): + """获取当前登录管理员的信息""" + return admin + + +@router.post("/change-password", response_model=ResponseModel, summary="修改密码") +async def change_password( + data: AdminChangePasswordRequest, + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 修改当前管理员密码 + + - **old_password**: 旧密码 + - **new_password**: 新密码 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证旧密码 + cursor.execute( + "SELECT password_hash FROM admin_users WHERE id = %s", + (admin.id,) + ) + row = cursor.fetchone() + + if not verify_password(data.old_password, row["password_hash"]): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="旧密码错误", + ) + + # 更新密码 + new_hash = get_password_hash(data.new_password) + cursor.execute( + "UPDATE admin_users SET password_hash = %s WHERE id = %s", + (new_hash, admin.id) + ) + conn.commit() + + return ResponseModel(message="密码修改成功") + finally: + conn.close() + + +@router.post("/logout", response_model=ResponseModel, summary="退出登录") +async def admin_logout(admin: AdminUserInfo = Depends(get_current_admin)): + """退出登录(客户端需清除 Token)""" + return ResponseModel(message="退出成功") + diff --git a/backend/app/api/v1/admin_portal/configs.py b/backend/app/api/v1/admin_portal/configs.py new file mode 100644 index 0000000..98811b2 --- /dev/null +++ b/backend/app/api/v1/admin_portal/configs.py @@ -0,0 +1,480 @@ +""" +配置管理 API +""" + +import os +import json +from typing import Optional, List, Dict + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, get_db_connection, AdminUserInfo +from .schemas import ( + ConfigTemplateResponse, + TenantConfigResponse, + TenantConfigCreate, + TenantConfigUpdate, + TenantConfigGroupResponse, + ConfigBatchUpdate, + ResponseModel, +) + +router = APIRouter(prefix="/configs", tags=["配置管理"]) + +# 配置分组显示名称 +CONFIG_GROUP_NAMES = { + "database": "数据库配置", + "redis": "Redis配置", + "security": "安全配置", + "coze": "Coze配置", + "ai": "AI服务配置", + "yanji": "言迹工牌配置", + "storage": "文件存储配置", + "basic": "基础配置", +} + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("/templates", response_model=List[ConfigTemplateResponse], summary="获取配置模板") +async def get_config_templates( + config_group: Optional[str] = Query(None, description="配置分组筛选"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取配置模板列表 + + 配置模板定义了所有可配置项的元数据 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + if config_group: + cursor.execute( + """ + SELECT * FROM config_templates + WHERE config_group = %s + ORDER BY sort_order, id + """, + (config_group,) + ) + else: + cursor.execute( + "SELECT * FROM config_templates ORDER BY config_group, sort_order, id" + ) + + rows = cursor.fetchall() + + result = [] + for row in rows: + # 解析 options 字段 + options = None + if row.get("options"): + try: + options = json.loads(row["options"]) + except: + pass + + result.append(ConfigTemplateResponse( + id=row["id"], + config_group=row["config_group"], + config_key=row["config_key"], + display_name=row["display_name"], + description=row["description"], + value_type=row["value_type"], + default_value=row["default_value"], + is_required=row["is_required"], + is_secret=row["is_secret"], + options=options, + sort_order=row["sort_order"], + )) + + return result + finally: + conn.close() + + +@router.get("/groups", response_model=List[Dict], summary="获取配置分组列表") +async def get_config_groups(admin: AdminUserInfo = Depends(get_current_admin)): + """获取配置分组列表""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT config_group, COUNT(*) as count + FROM config_templates + GROUP BY config_group + ORDER BY config_group + """ + ) + rows = cursor.fetchall() + + return [ + { + "group_name": row["config_group"], + "group_display_name": CONFIG_GROUP_NAMES.get(row["config_group"], row["config_group"]), + "config_count": row["count"], + } + for row in rows + ] + finally: + conn.close() + + +@router.get("/tenants/{tenant_id}", response_model=List[TenantConfigGroupResponse], summary="获取租户配置") +async def get_tenant_configs( + tenant_id: int, + config_group: Optional[str] = Query(None, description="配置分组筛选"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取租户的所有配置 + + 返回按分组整理的配置列表,包含模板信息 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 查询配置模板和租户配置 + group_filter = "AND ct.config_group = %s" if config_group else "" + params = [tenant_id, config_group] if config_group else [tenant_id] + + cursor.execute( + f""" + SELECT + ct.config_group, + ct.config_key, + ct.display_name, + ct.description, + ct.value_type, + ct.default_value, + ct.is_required, + ct.is_secret, + ct.sort_order, + tc.id as config_id, + tc.config_value, + tc.is_encrypted, + tc.created_at, + tc.updated_at + FROM config_templates ct + LEFT JOIN tenant_configs tc + ON tc.config_group = ct.config_group + AND tc.config_key = ct.config_key + AND tc.tenant_id = %s + WHERE 1=1 {group_filter} + ORDER BY ct.config_group, ct.sort_order, ct.id + """, + params + ) + rows = cursor.fetchall() + + # 按分组整理 + groups: Dict[str, List] = {} + for row in rows: + group = row["config_group"] + if group not in groups: + groups[group] = [] + + # 如果是敏感信息且有值,隐藏部分内容 + config_value = row["config_value"] + if row["is_secret"] and config_value: + if len(config_value) > 8: + config_value = config_value[:4] + "****" + config_value[-4:] + else: + config_value = "****" + + groups[group].append(TenantConfigResponse( + id=row["config_id"] or 0, + config_group=row["config_group"], + config_key=row["config_key"], + config_value=config_value if not row["is_secret"] else row["config_value"], + value_type=row["value_type"], + is_encrypted=row["is_encrypted"] or False, + description=row["description"], + created_at=row["created_at"] or None, + updated_at=row["updated_at"] or None, + display_name=row["display_name"], + is_required=row["is_required"], + is_secret=row["is_secret"], + )) + + return [ + TenantConfigGroupResponse( + group_name=group, + group_display_name=CONFIG_GROUP_NAMES.get(group, group), + configs=configs, + ) + for group, configs in groups.items() + ] + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="更新单个配置") +async def update_tenant_config( + tenant_id: int, + config_group: str, + config_key: str, + data: TenantConfigUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """更新租户的单个配置项""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 验证配置模板存在 + cursor.execute( + """ + SELECT value_type, is_secret FROM config_templates + WHERE config_group = %s AND config_key = %s + """, + (config_group, config_key) + ) + template = cursor.fetchone() + if not template: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的配置项", + ) + + # 检查是否已有配置 + cursor.execute( + """ + SELECT id, config_value FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config_group, config_key) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + old_value = existing["config_value"] + cursor.execute( + """ + UPDATE tenant_configs + SET config_value = %s, is_encrypted = %s + WHERE id = %s + """, + (data.config_value, template["is_secret"], existing["id"]) + ) + else: + # 插入 + old_value = None + cursor.execute( + """ + INSERT INTO tenant_configs + (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, config_group, config_key, data.config_value, + template["value_type"], template["is_secret"]) + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "update", "config", tenant_id, f"{config_group}.{config_key}", + old_value={"value": old_value} if old_value else None, + new_value={"value": data.config_value} + ) + + conn.commit() + + return ResponseModel(message="配置已更新") + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新配置") +async def batch_update_tenant_configs( + tenant_id: int, + data: ConfigBatchUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """批量更新租户配置""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + updated_count = 0 + for config in data.configs: + # 获取模板信息 + cursor.execute( + """ + SELECT value_type, is_secret FROM config_templates + WHERE config_group = %s AND config_key = %s + """, + (config.config_group, config.config_key) + ) + template = cursor.fetchone() + if not template: + continue + + # 检查是否已有配置 + cursor.execute( + """ + SELECT id FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config.config_group, config.config_key) + ) + existing = cursor.fetchone() + + if existing: + cursor.execute( + """ + UPDATE tenant_configs + SET config_value = %s, is_encrypted = %s + WHERE id = %s + """, + (config.config_value, template["is_secret"], existing["id"]) + ) + else: + cursor.execute( + """ + INSERT INTO tenant_configs + (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, config.config_group, config.config_key, config.config_value, + template["value_type"], template["is_secret"]) + ) + + updated_count += 1 + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "batch_update", "config", tenant_id, f"批量更新 {updated_count} 项配置" + ) + + conn.commit() + + return ResponseModel(message=f"已更新 {updated_count} 项配置") + finally: + conn.close() + + +@router.delete("/tenants/{tenant_id}/{config_group}/{config_key}", response_model=ResponseModel, summary="删除配置") +async def delete_tenant_config( + tenant_id: int, + config_group: str, + config_key: str, + admin: AdminUserInfo = Depends(get_current_admin), +): + """删除租户的配置项(恢复为默认值)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 删除配置 + cursor.execute( + """ + DELETE FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config_group, config_key) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="配置不存在", + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "delete", "config", tenant_id, f"{config_group}.{config_key}" + ) + + conn.commit() + + return ResponseModel(message="配置已删除,将使用默认值") + finally: + conn.close() + + +@router.post("/tenants/{tenant_id}/refresh-cache", response_model=ResponseModel, summary="刷新配置缓存") +async def refresh_tenant_config_cache( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """刷新租户的配置缓存""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取租户编码 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 刷新缓存 + try: + from app.core.config import DynamicConfig + import asyncio + asyncio.create_task(DynamicConfig.refresh_cache(tenant["code"])) + except Exception as e: + pass # 缓存刷新失败不影响主流程 + + return ResponseModel(message="缓存刷新请求已发送") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_portal/features.py b/backend/app/api/v1/admin_portal/features.py new file mode 100644 index 0000000..370cebc --- /dev/null +++ b/backend/app/api/v1/admin_portal/features.py @@ -0,0 +1,424 @@ +""" +功能开关管理 API +""" + +import os +import json +from typing import Optional, List, Dict + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, get_db_connection, AdminUserInfo +from .schemas import ( + FeatureSwitchCreate, + FeatureSwitchUpdate, + FeatureSwitchResponse, + FeatureSwitchGroupResponse, + ResponseModel, +) + +router = APIRouter(prefix="/features", tags=["功能开关"]) + +# 功能分组显示名称 +FEATURE_GROUP_NAMES = { + "exam": "考试模块", + "practice": "陪练模块", + "broadcast": "播课模块", + "course": "课程模块", + "yanji": "智能工牌模块", +} + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("/defaults", response_model=List[FeatureSwitchGroupResponse], summary="获取默认功能开关") +async def get_default_features( + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取全局默认的功能开关配置""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL + ORDER BY feature_group, id + """ + ) + rows = cursor.fetchall() + + # 按分组整理 + groups: Dict[str, List] = {} + for row in rows: + group = row["feature_group"] or "other" + if group not in groups: + groups[group] = [] + + config = None + if row.get("config"): + try: + config = json.loads(row["config"]) + except: + pass + + groups[group].append(FeatureSwitchResponse( + id=row["id"], + tenant_id=row["tenant_id"], + feature_code=row["feature_code"], + feature_name=row["feature_name"], + feature_group=row["feature_group"], + is_enabled=row["is_enabled"], + config=config, + description=row["description"], + created_at=row["created_at"], + updated_at=row["updated_at"], + )) + + return [ + FeatureSwitchGroupResponse( + group_name=group, + group_display_name=FEATURE_GROUP_NAMES.get(group, group), + features=features, + ) + for group, features in groups.items() + ] + finally: + conn.close() + + +@router.get("/tenants/{tenant_id}", response_model=List[FeatureSwitchGroupResponse], summary="获取租户功能开关") +async def get_tenant_features( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取租户的功能开关配置 + + 返回租户自定义配置和默认配置的合并结果 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取默认配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL + ORDER BY feature_group, id + """ + ) + default_rows = cursor.fetchall() + + # 获取租户配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id = %s + """, + (tenant_id,) + ) + tenant_rows = cursor.fetchall() + + # 合并配置 + tenant_features = {row["feature_code"]: row for row in tenant_rows} + + groups: Dict[str, List] = {} + for row in default_rows: + group = row["feature_group"] or "other" + if group not in groups: + groups[group] = [] + + # 使用租户配置覆盖默认配置 + effective_row = tenant_features.get(row["feature_code"], row) + + config = None + if effective_row.get("config"): + try: + config = json.loads(effective_row["config"]) + except: + pass + + groups[group].append(FeatureSwitchResponse( + id=effective_row["id"], + tenant_id=effective_row["tenant_id"], + feature_code=effective_row["feature_code"], + feature_name=effective_row["feature_name"], + feature_group=effective_row["feature_group"], + is_enabled=effective_row["is_enabled"], + config=config, + description=effective_row["description"], + created_at=effective_row["created_at"], + updated_at=effective_row["updated_at"], + )) + + return [ + FeatureSwitchGroupResponse( + group_name=group, + group_display_name=FEATURE_GROUP_NAMES.get(group, group), + features=features, + ) + for group, features in groups.items() + ] + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="更新租户功能开关") +async def update_tenant_feature( + tenant_id: int, + feature_code: str, + data: FeatureSwitchUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """更新租户的功能开关""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取默认配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL AND feature_code = %s + """, + (feature_code,) + ) + default_feature = cursor.fetchone() + + if not default_feature: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的功能编码", + ) + + # 检查租户是否已有配置 + cursor.execute( + """ + SELECT id, is_enabled FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (tenant_id, feature_code) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + old_enabled = existing["is_enabled"] + + update_fields = [] + update_values = [] + + if data.is_enabled is not None: + update_fields.append("is_enabled = %s") + update_values.append(data.is_enabled) + + if data.config is not None: + update_fields.append("config = %s") + update_values.append(json.dumps(data.config)) + + if update_fields: + update_values.append(existing["id"]) + cursor.execute( + f"UPDATE feature_switches SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + else: + # 创建租户配置 + old_enabled = default_feature["is_enabled"] + + cursor.execute( + """ + INSERT INTO feature_switches + (tenant_id, feature_code, feature_name, feature_group, is_enabled, config, description) + VALUES (%s, %s, %s, %s, %s, %s, %s) + """, + (tenant_id, feature_code, default_feature["feature_name"], + default_feature["feature_group"], + data.is_enabled if data.is_enabled is not None else default_feature["is_enabled"], + json.dumps(data.config) if data.config else default_feature["config"], + default_feature["description"]) + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "update", "feature", tenant_id, feature_code, + old_value={"is_enabled": old_enabled}, + new_value={"is_enabled": data.is_enabled, "config": data.config} + ) + + conn.commit() + + status_text = "启用" if data.is_enabled else "禁用" + return ResponseModel(message=f"功能 {default_feature['feature_name']} 已{status_text}") + finally: + conn.close() + + +@router.delete("/tenants/{tenant_id}/{feature_code}", response_model=ResponseModel, summary="重置租户功能开关") +async def reset_tenant_feature( + tenant_id: int, + feature_code: str, + admin: AdminUserInfo = Depends(get_current_admin), +): + """重置租户的功能开关为默认值""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 删除租户配置 + cursor.execute( + """ + DELETE FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (tenant_id, feature_code) + ) + + if cursor.rowcount == 0: + return ResponseModel(message="功能配置已是默认值") + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "reset", "feature", tenant_id, feature_code + ) + + conn.commit() + + return ResponseModel(message="功能配置已重置为默认值") + finally: + conn.close() + + +@router.post("/tenants/{tenant_id}/batch", response_model=ResponseModel, summary="批量更新功能开关") +async def batch_update_tenant_features( + tenant_id: int, + features: List[Dict], + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 批量更新租户的功能开关 + + 请求体格式: + [ + {"feature_code": "exam_module", "is_enabled": true}, + {"feature_code": "practice_voice", "is_enabled": false} + ] + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + updated_count = 0 + for feature in features: + feature_code = feature.get("feature_code") + is_enabled = feature.get("is_enabled") + + if not feature_code or is_enabled is None: + continue + + # 获取默认配置 + cursor.execute( + """ + SELECT * FROM feature_switches + WHERE tenant_id IS NULL AND feature_code = %s + """, + (feature_code,) + ) + default_feature = cursor.fetchone() + + if not default_feature: + continue + + # 检查租户是否已有配置 + cursor.execute( + """ + SELECT id FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (tenant_id, feature_code) + ) + existing = cursor.fetchone() + + if existing: + cursor.execute( + "UPDATE feature_switches SET is_enabled = %s WHERE id = %s", + (is_enabled, existing["id"]) + ) + else: + cursor.execute( + """ + INSERT INTO feature_switches + (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, feature_code, default_feature["feature_name"], + default_feature["feature_group"], is_enabled, default_feature["description"]) + ) + + updated_count += 1 + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "batch_update", "feature", tenant_id, f"批量更新 {updated_count} 项功能开关" + ) + + conn.commit() + + return ResponseModel(message=f"已更新 {updated_count} 项功能开关") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_portal/prompts.py b/backend/app/api/v1/admin_portal/prompts.py new file mode 100644 index 0000000..cf2fd84 --- /dev/null +++ b/backend/app/api/v1/admin_portal/prompts.py @@ -0,0 +1,637 @@ +""" +AI 提示词管理 API +""" + +import os +import json +from typing import Optional, List + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo +from .schemas import ( + AIPromptCreate, + AIPromptUpdate, + AIPromptResponse, + AIPromptVersionResponse, + TenantPromptResponse, + TenantPromptUpdate, + ResponseModel, +) + +router = APIRouter(prefix="/prompts", tags=["提示词管理"]) + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("", response_model=List[AIPromptResponse], summary="获取提示词列表") +async def list_prompts( + module: Optional[str] = Query(None, description="模块筛选"), + is_active: Optional[bool] = Query(None, description="是否启用"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取所有 AI 提示词模板 + + - **module**: 模块筛选(course, exam, practice, ability) + - **is_active**: 是否启用 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + conditions = [] + params = [] + + if module: + conditions.append("module = %s") + params.append(module) + + if is_active is not None: + conditions.append("is_active = %s") + params.append(is_active) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + cursor.execute( + f""" + SELECT * FROM ai_prompts + WHERE {where_clause} + ORDER BY module, id + """, + params + ) + rows = cursor.fetchall() + + result = [] + for row in rows: + # 解析 JSON 字段 + variables = None + if row.get("variables"): + try: + variables = json.loads(row["variables"]) + except: + pass + + output_schema = None + if row.get("output_schema"): + try: + output_schema = json.loads(row["output_schema"]) + except: + pass + + result.append(AIPromptResponse( + id=row["id"], + code=row["code"], + name=row["name"], + description=row["description"], + module=row["module"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + variables=variables, + output_schema=output_schema, + model_recommendation=row["model_recommendation"], + max_tokens=row["max_tokens"], + temperature=float(row["temperature"]) if row["temperature"] else 0.7, + is_system=row["is_system"], + is_active=row["is_active"], + version=row["version"], + created_at=row["created_at"], + updated_at=row["updated_at"], + )) + + return result + finally: + conn.close() + + +@router.get("/{prompt_id}", response_model=AIPromptResponse, summary="获取提示词详情") +async def get_prompt( + prompt_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取提示词详情""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,)) + row = cursor.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 解析 JSON 字段 + variables = None + if row.get("variables"): + try: + variables = json.loads(row["variables"]) + except: + pass + + output_schema = None + if row.get("output_schema"): + try: + output_schema = json.loads(row["output_schema"]) + except: + pass + + return AIPromptResponse( + id=row["id"], + code=row["code"], + name=row["name"], + description=row["description"], + module=row["module"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + variables=variables, + output_schema=output_schema, + model_recommendation=row["model_recommendation"], + max_tokens=row["max_tokens"], + temperature=float(row["temperature"]) if row["temperature"] else 0.7, + is_system=row["is_system"], + is_active=row["is_active"], + version=row["version"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + finally: + conn.close() + + +@router.post("", response_model=AIPromptResponse, summary="创建提示词") +async def create_prompt( + data: AIPromptCreate, + admin: AdminUserInfo = Depends(require_superadmin), +): + """ + 创建新的提示词模板 + + 需要超级管理员权限 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 检查编码是否已存在 + cursor.execute("SELECT id FROM ai_prompts WHERE code = %s", (data.code,)) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="提示词编码已存在", + ) + + # 创建提示词 + cursor.execute( + """ + INSERT INTO ai_prompts + (code, name, description, module, system_prompt, user_prompt_template, + variables, output_schema, model_recommendation, max_tokens, temperature, + is_system, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, FALSE, %s) + """, + (data.code, data.name, data.description, data.module, + data.system_prompt, data.user_prompt_template, + json.dumps(data.variables) if data.variables else None, + json.dumps(data.output_schema) if data.output_schema else None, + data.model_recommendation, data.max_tokens, data.temperature, + admin.id) + ) + prompt_id = cursor.lastrowid + + # 记录操作日志 + log_operation( + cursor, admin, None, None, + "create", "prompt", prompt_id, data.name, + new_value=data.model_dump() + ) + + conn.commit() + + return await get_prompt(prompt_id, admin) + finally: + conn.close() + + +@router.put("/{prompt_id}", response_model=AIPromptResponse, summary="更新提示词") +async def update_prompt( + prompt_id: int, + data: AIPromptUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 更新提示词模板 + + 更新会自动保存版本历史 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取原提示词 + cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,)) + old_prompt = cursor.fetchone() + + if not old_prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 保存版本历史(如果系统提示词或用户提示词有变化) + if data.system_prompt or data.user_prompt_template: + new_version = old_prompt["version"] + 1 + + cursor.execute( + """ + INSERT INTO ai_prompt_versions + (prompt_id, version, system_prompt, user_prompt_template, variables, + output_schema, change_summary, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + (prompt_id, old_prompt["version"], + old_prompt["system_prompt"], old_prompt["user_prompt_template"], + old_prompt["variables"], old_prompt["output_schema"], + f"版本 {old_prompt['version']} 备份", + admin.id) + ) + else: + new_version = old_prompt["version"] + + # 构建更新语句 + update_fields = [] + update_values = [] + + if data.name is not None: + update_fields.append("name = %s") + update_values.append(data.name) + + if data.description is not None: + update_fields.append("description = %s") + update_values.append(data.description) + + if data.system_prompt is not None: + update_fields.append("system_prompt = %s") + update_values.append(data.system_prompt) + + if data.user_prompt_template is not None: + update_fields.append("user_prompt_template = %s") + update_values.append(data.user_prompt_template) + + if data.variables is not None: + update_fields.append("variables = %s") + update_values.append(json.dumps(data.variables)) + + if data.output_schema is not None: + update_fields.append("output_schema = %s") + update_values.append(json.dumps(data.output_schema)) + + if data.model_recommendation is not None: + update_fields.append("model_recommendation = %s") + update_values.append(data.model_recommendation) + + if data.max_tokens is not None: + update_fields.append("max_tokens = %s") + update_values.append(data.max_tokens) + + if data.temperature is not None: + update_fields.append("temperature = %s") + update_values.append(data.temperature) + + if data.is_active is not None: + update_fields.append("is_active = %s") + update_values.append(data.is_active) + + if not update_fields: + return await get_prompt(prompt_id, admin) + + # 更新版本号 + if data.system_prompt or data.user_prompt_template: + update_fields.append("version = %s") + update_values.append(new_version) + + update_fields.append("updated_by = %s") + update_values.append(admin.id) + update_values.append(prompt_id) + + cursor.execute( + f"UPDATE ai_prompts SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + + # 记录操作日志 + log_operation( + cursor, admin, None, None, + "update", "prompt", prompt_id, old_prompt["name"], + old_value={"version": old_prompt["version"]}, + new_value=data.model_dump(exclude_unset=True) + ) + + conn.commit() + + return await get_prompt(prompt_id, admin) + finally: + conn.close() + + +@router.get("/{prompt_id}/versions", response_model=List[AIPromptVersionResponse], summary="获取提示词版本历史") +async def get_prompt_versions( + prompt_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取提示词的版本历史""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT * FROM ai_prompt_versions + WHERE prompt_id = %s + ORDER BY version DESC + """, + (prompt_id,) + ) + rows = cursor.fetchall() + + result = [] + for row in rows: + variables = None + if row.get("variables"): + try: + variables = json.loads(row["variables"]) + except: + pass + + result.append(AIPromptVersionResponse( + id=row["id"], + prompt_id=row["prompt_id"], + version=row["version"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + variables=variables, + change_summary=row["change_summary"], + created_at=row["created_at"], + )) + + return result + finally: + conn.close() + + +@router.post("/{prompt_id}/rollback/{version}", response_model=AIPromptResponse, summary="回滚提示词版本") +async def rollback_prompt_version( + prompt_id: int, + version: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """回滚到指定版本的提示词""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取指定版本 + cursor.execute( + """ + SELECT * FROM ai_prompt_versions + WHERE prompt_id = %s AND version = %s + """, + (prompt_id, version) + ) + version_row = cursor.fetchone() + + if not version_row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="版本不存在", + ) + + # 获取当前提示词 + cursor.execute("SELECT * FROM ai_prompts WHERE id = %s", (prompt_id,)) + current = cursor.fetchone() + + if not current: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 保存当前版本到历史 + new_version = current["version"] + 1 + cursor.execute( + """ + INSERT INTO ai_prompt_versions + (prompt_id, version, system_prompt, user_prompt_template, variables, + output_schema, change_summary, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s) + """, + (prompt_id, current["version"], + current["system_prompt"], current["user_prompt_template"], + current["variables"], current["output_schema"], + f"回滚前备份(版本 {current['version']})", + admin.id) + ) + + # 回滚 + cursor.execute( + """ + UPDATE ai_prompts + SET system_prompt = %s, user_prompt_template = %s, variables = %s, + output_schema = %s, version = %s, updated_by = %s + WHERE id = %s + """, + (version_row["system_prompt"], version_row["user_prompt_template"], + version_row["variables"], version_row["output_schema"], + new_version, admin.id, prompt_id) + ) + + # 记录操作日志 + log_operation( + cursor, admin, None, None, + "rollback", "prompt", prompt_id, current["name"], + old_value={"version": current["version"]}, + new_value={"version": new_version, "rollback_from": version} + ) + + conn.commit() + + return await get_prompt(prompt_id, admin) + finally: + conn.close() + + +@router.get("/tenants/{tenant_id}", response_model=List[TenantPromptResponse], summary="获取租户自定义提示词") +async def get_tenant_prompts( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取租户的自定义提示词列表""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT tp.*, ap.code as prompt_code, ap.name as prompt_name + FROM tenant_prompts tp + JOIN ai_prompts ap ON tp.prompt_id = ap.id + WHERE tp.tenant_id = %s + ORDER BY ap.module, ap.id + """, + (tenant_id,) + ) + rows = cursor.fetchall() + + return [ + TenantPromptResponse( + id=row["id"], + tenant_id=row["tenant_id"], + prompt_id=row["prompt_id"], + prompt_code=row["prompt_code"], + prompt_name=row["prompt_name"], + system_prompt=row["system_prompt"], + user_prompt_template=row["user_prompt_template"], + is_active=row["is_active"], + created_at=row["created_at"], + updated_at=row["updated_at"], + ) + for row in rows + ] + finally: + conn.close() + + +@router.put("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="更新租户自定义提示词") +async def update_tenant_prompt( + tenant_id: int, + prompt_id: int, + data: TenantPromptUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """创建或更新租户的自定义提示词""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 验证租户存在 + cursor.execute("SELECT code FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 验证提示词存在 + cursor.execute("SELECT name FROM ai_prompts WHERE id = %s", (prompt_id,)) + prompt = cursor.fetchone() + if not prompt: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="提示词不存在", + ) + + # 检查是否已有自定义 + cursor.execute( + """ + SELECT id FROM tenant_prompts + WHERE tenant_id = %s AND prompt_id = %s + """, + (tenant_id, prompt_id) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + update_fields = [] + update_values = [] + + if data.system_prompt is not None: + update_fields.append("system_prompt = %s") + update_values.append(data.system_prompt) + + if data.user_prompt_template is not None: + update_fields.append("user_prompt_template = %s") + update_values.append(data.user_prompt_template) + + if data.is_active is not None: + update_fields.append("is_active = %s") + update_values.append(data.is_active) + + if update_fields: + update_fields.append("updated_by = %s") + update_values.append(admin.id) + update_values.append(existing["id"]) + + cursor.execute( + f"UPDATE tenant_prompts SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + else: + # 创建 + cursor.execute( + """ + INSERT INTO tenant_prompts + (tenant_id, prompt_id, system_prompt, user_prompt_template, is_active, created_by) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, prompt_id, data.system_prompt, data.user_prompt_template, + data.is_active if data.is_active is not None else True, admin.id) + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "update", "tenant_prompt", prompt_id, prompt["name"], + new_value=data.model_dump(exclude_unset=True) + ) + + conn.commit() + + return ResponseModel(message="自定义提示词已保存") + finally: + conn.close() + + +@router.delete("/tenants/{tenant_id}/{prompt_id}", response_model=ResponseModel, summary="删除租户自定义提示词") +async def delete_tenant_prompt( + tenant_id: int, + prompt_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """删除租户的自定义提示词(恢复使用默认)""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + DELETE FROM tenant_prompts + WHERE tenant_id = %s AND prompt_id = %s + """, + (tenant_id, prompt_id) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="自定义提示词不存在", + ) + + conn.commit() + + return ResponseModel(message="自定义提示词已删除,将使用默认模板") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_portal/schemas.py b/backend/app/api/v1/admin_portal/schemas.py new file mode 100644 index 0000000..71970b1 --- /dev/null +++ b/backend/app/api/v1/admin_portal/schemas.py @@ -0,0 +1,352 @@ +""" +管理后台数据模型 +""" + +from datetime import datetime +from typing import Optional, List, Any, Dict +from pydantic import BaseModel, Field + + +# ============================================ +# 通用模型 +# ============================================ + +class ResponseModel(BaseModel): + """通用响应模型""" + code: int = 0 + message: str = "success" + data: Optional[Any] = None + + +class PaginationParams(BaseModel): + """分页参数""" + page: int = Field(default=1, ge=1) + page_size: int = Field(default=20, ge=1, le=100) + + +class PaginatedResponse(BaseModel): + """分页响应""" + items: List[Any] + total: int + page: int + page_size: int + total_pages: int + + +# ============================================ +# 认证相关 +# ============================================ + +class AdminLoginRequest(BaseModel): + """管理员登录请求""" + username: str = Field(..., min_length=1, max_length=50) + password: str = Field(..., min_length=6) + + +class AdminLoginResponse(BaseModel): + """管理员登录响应""" + access_token: str + token_type: str = "bearer" + expires_in: int + admin_user: "AdminUserInfo" + + +class AdminUserInfo(BaseModel): + """管理员信息""" + id: int + username: str + email: Optional[str] + full_name: Optional[str] + role: str + last_login_at: Optional[datetime] + + +class AdminChangePasswordRequest(BaseModel): + """修改密码请求""" + old_password: str = Field(..., min_length=6) + new_password: str = Field(..., min_length=6) + + +# ============================================ +# 租户相关 +# ============================================ + +class TenantBase(BaseModel): + """租户基础信息""" + code: str = Field(..., min_length=2, max_length=20, pattern=r'^[a-z0-9_]+$') + name: str = Field(..., min_length=1, max_length=100) + display_name: Optional[str] = Field(None, max_length=200) + domain: str = Field(..., min_length=1, max_length=200) + logo_url: Optional[str] = None + favicon_url: Optional[str] = None + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + contact_email: Optional[str] = None + industry: str = Field(default="medical_beauty") + remarks: Optional[str] = None + + +class TenantCreate(TenantBase): + """创建租户请求""" + pass + + +class TenantUpdate(BaseModel): + """更新租户请求""" + name: Optional[str] = Field(None, min_length=1, max_length=100) + display_name: Optional[str] = Field(None, max_length=200) + domain: Optional[str] = Field(None, min_length=1, max_length=200) + logo_url: Optional[str] = None + favicon_url: Optional[str] = None + contact_name: Optional[str] = None + contact_phone: Optional[str] = None + contact_email: Optional[str] = None + industry: Optional[str] = None + status: Optional[str] = None + expire_at: Optional[datetime] = None + remarks: Optional[str] = None + + +class TenantResponse(TenantBase): + """租户响应""" + id: int + status: str + expire_at: Optional[datetime] + created_at: datetime + updated_at: datetime + config_count: int = 0 # 配置项数量 + + class Config: + from_attributes = True + + +class TenantListResponse(BaseModel): + """租户列表响应""" + items: List[TenantResponse] + total: int + page: int + page_size: int + + +# ============================================ +# 配置相关 +# ============================================ + +class ConfigTemplateResponse(BaseModel): + """配置模板响应""" + id: int + config_group: str + config_key: str + display_name: str + description: Optional[str] + value_type: str + default_value: Optional[str] + is_required: bool + is_secret: bool + options: Optional[List[str]] + sort_order: int + + +class TenantConfigBase(BaseModel): + """租户配置基础""" + config_group: str + config_key: str + config_value: Optional[str] = None + + +class TenantConfigCreate(TenantConfigBase): + """创建租户配置请求""" + pass + + +class TenantConfigUpdate(BaseModel): + """更新租户配置请求""" + config_value: Optional[str] = None + + +class TenantConfigResponse(TenantConfigBase): + """租户配置响应""" + id: int + value_type: str + is_encrypted: bool + description: Optional[str] + created_at: Optional[datetime] = None + updated_at: Optional[datetime] = None + # 从模板获取的额外信息 + display_name: Optional[str] = None + is_required: bool = False + is_secret: bool = False + + class Config: + from_attributes = True + + +class TenantConfigGroupResponse(BaseModel): + """租户配置分组响应""" + group_name: str + group_display_name: str + configs: List[TenantConfigResponse] + + +class ConfigBatchUpdate(BaseModel): + """批量更新配置请求""" + configs: List[TenantConfigCreate] + + +# ============================================ +# 提示词相关 +# ============================================ + +class AIPromptBase(BaseModel): + """AI提示词基础""" + code: str = Field(..., min_length=1, max_length=50) + name: str = Field(..., min_length=1, max_length=100) + description: Optional[str] = None + module: str + system_prompt: str + user_prompt_template: Optional[str] = None + variables: Optional[List[str]] = None + output_schema: Optional[Dict] = None + model_recommendation: Optional[str] = None + max_tokens: int = 4096 + temperature: float = 0.7 + + +class AIPromptCreate(AIPromptBase): + """创建提示词请求""" + pass + + +class AIPromptUpdate(BaseModel): + """更新提示词请求""" + name: Optional[str] = Field(None, min_length=1, max_length=100) + description: Optional[str] = None + system_prompt: Optional[str] = None + user_prompt_template: Optional[str] = None + variables: Optional[List[str]] = None + output_schema: Optional[Dict] = None + model_recommendation: Optional[str] = None + max_tokens: Optional[int] = None + temperature: Optional[float] = None + is_active: Optional[bool] = None + + +class AIPromptResponse(AIPromptBase): + """提示词响应""" + id: int + is_system: bool + is_active: bool + version: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class AIPromptVersionResponse(BaseModel): + """提示词版本响应""" + id: int + prompt_id: int + version: int + system_prompt: str + user_prompt_template: Optional[str] + variables: Optional[List[str]] + change_summary: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +class TenantPromptResponse(BaseModel): + """租户自定义提示词响应""" + id: int + tenant_id: int + prompt_id: int + prompt_code: str + prompt_name: str + system_prompt: Optional[str] + user_prompt_template: Optional[str] + is_active: bool + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class TenantPromptUpdate(BaseModel): + """更新租户自定义提示词""" + system_prompt: Optional[str] = None + user_prompt_template: Optional[str] = None + is_active: Optional[bool] = None + + +# ============================================ +# 功能开关相关 +# ============================================ + +class FeatureSwitchBase(BaseModel): + """功能开关基础""" + feature_code: str + feature_name: str + feature_group: Optional[str] = None + is_enabled: bool = True + config: Optional[Dict] = None + description: Optional[str] = None + + +class FeatureSwitchCreate(FeatureSwitchBase): + """创建功能开关请求""" + pass + + +class FeatureSwitchUpdate(BaseModel): + """更新功能开关请求""" + is_enabled: Optional[bool] = None + config: Optional[Dict] = None + + +class FeatureSwitchResponse(FeatureSwitchBase): + """功能开关响应""" + id: int + tenant_id: Optional[int] + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class FeatureSwitchGroupResponse(BaseModel): + """功能开关分组响应""" + group_name: str + group_display_name: str + features: List[FeatureSwitchResponse] + + +# ============================================ +# 操作日志相关 +# ============================================ + +class OperationLogResponse(BaseModel): + """操作日志响应""" + id: int + admin_username: Optional[str] + tenant_code: Optional[str] + operation_type: str + resource_type: str + resource_name: Optional[str] + old_value: Optional[Dict] + new_value: Optional[Dict] + ip_address: Optional[str] + created_at: datetime + + class Config: + from_attributes = True + + +# 更新前向引用 +AdminLoginResponse.model_rebuild() + diff --git a/backend/app/api/v1/admin_portal/tenants.py b/backend/app/api/v1/admin_portal/tenants.py new file mode 100644 index 0000000..9d946b7 --- /dev/null +++ b/backend/app/api/v1/admin_portal/tenants.py @@ -0,0 +1,379 @@ +""" +租户管理 API +""" + +import os +import json +from datetime import datetime +from typing import Optional, List + +import pymysql +from fastapi import APIRouter, Depends, HTTPException, status, Query + +from .auth import get_current_admin, require_superadmin, get_db_connection, AdminUserInfo +from .schemas import ( + TenantCreate, + TenantUpdate, + TenantResponse, + TenantListResponse, + ResponseModel, +) + +router = APIRouter(prefix="/tenants", tags=["租户管理"]) + + +def log_operation(cursor, admin: AdminUserInfo, tenant_id: int, tenant_code: str, + operation_type: str, resource_type: str, resource_id: int, + resource_name: str, old_value: dict = None, new_value: dict = None): + """记录操作日志""" + cursor.execute( + """ + INSERT INTO operation_logs + (admin_user_id, admin_username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, old_value, new_value) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (admin.id, admin.username, tenant_id, tenant_code, operation_type, + resource_type, resource_id, resource_name, + json.dumps(old_value, ensure_ascii=False) if old_value else None, + json.dumps(new_value, ensure_ascii=False) if new_value else None) + ) + + +@router.get("", response_model=TenantListResponse, summary="获取租户列表") +async def list_tenants( + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + status: Optional[str] = Query(None, description="状态筛选"), + keyword: Optional[str] = Query(None, description="关键词搜索"), + admin: AdminUserInfo = Depends(get_current_admin), +): + """ + 获取租户列表 + + - **page**: 页码 + - **page_size**: 每页数量 + - **status**: 状态筛选(active, inactive, suspended) + - **keyword**: 关键词搜索(匹配名称、编码、域名) + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 构建查询条件 + conditions = [] + params = [] + + if status: + conditions.append("t.status = %s") + params.append(status) + + if keyword: + conditions.append("(t.name LIKE %s OR t.code LIKE %s OR t.domain LIKE %s)") + params.extend([f"%{keyword}%"] * 3) + + where_clause = " AND ".join(conditions) if conditions else "1=1" + + # 查询总数 + cursor.execute( + f"SELECT COUNT(*) as total FROM tenants t WHERE {where_clause}", + params + ) + total = cursor.fetchone()["total"] + + # 查询列表 + offset = (page - 1) * page_size + cursor.execute( + f""" + SELECT t.*, + (SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count + FROM tenants t + WHERE {where_clause} + ORDER BY t.id DESC + LIMIT %s OFFSET %s + """, + params + [page_size, offset] + ) + rows = cursor.fetchall() + + items = [TenantResponse(**row) for row in rows] + + return TenantListResponse( + items=items, + total=total, + page=page, + page_size=page_size, + ) + finally: + conn.close() + + +@router.get("/{tenant_id}", response_model=TenantResponse, summary="获取租户详情") +async def get_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """获取租户详情""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + """ + SELECT t.*, + (SELECT COUNT(*) FROM tenant_configs tc WHERE tc.tenant_id = t.id) as config_count + FROM tenants t + WHERE t.id = %s + """, + (tenant_id,) + ) + row = cursor.fetchone() + + if not row: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + return TenantResponse(**row) + finally: + conn.close() + + +@router.post("", response_model=TenantResponse, summary="创建租户") +async def create_tenant( + data: TenantCreate, + admin: AdminUserInfo = Depends(require_superadmin), +): + """ + 创建新租户 + + 需要超级管理员权限 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 检查编码是否已存在 + cursor.execute("SELECT id FROM tenants WHERE code = %s", (data.code,)) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="租户编码已存在", + ) + + # 检查域名是否已存在 + cursor.execute("SELECT id FROM tenants WHERE domain = %s", (data.domain,)) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="域名已被使用", + ) + + # 创建租户 + cursor.execute( + """ + INSERT INTO tenants + (code, name, display_name, domain, logo_url, favicon_url, + contact_name, contact_phone, contact_email, industry, remarks, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + """, + (data.code, data.name, data.display_name, data.domain, + data.logo_url, data.favicon_url, data.contact_name, + data.contact_phone, data.contact_email, data.industry, + data.remarks, admin.id) + ) + tenant_id = cursor.lastrowid + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, data.code, + "create", "tenant", tenant_id, data.name, + new_value=data.model_dump() + ) + + conn.commit() + + # 返回创建的租户 + return await get_tenant(tenant_id, admin) + finally: + conn.close() + + +@router.put("/{tenant_id}", response_model=TenantResponse, summary="更新租户") +async def update_tenant( + tenant_id: int, + data: TenantUpdate, + admin: AdminUserInfo = Depends(get_current_admin), +): + """更新租户信息""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取原租户信息 + cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,)) + old_tenant = cursor.fetchone() + + if not old_tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 如果更新域名,检查是否已被使用 + if data.domain and data.domain != old_tenant["domain"]: + cursor.execute( + "SELECT id FROM tenants WHERE domain = %s AND id != %s", + (data.domain, tenant_id) + ) + if cursor.fetchone(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="域名已被使用", + ) + + # 构建更新语句 + update_fields = [] + update_values = [] + + for field, value in data.model_dump(exclude_unset=True).items(): + if value is not None: + update_fields.append(f"{field} = %s") + update_values.append(value) + + if not update_fields: + return await get_tenant(tenant_id, admin) + + update_fields.append("updated_by = %s") + update_values.append(admin.id) + update_values.append(tenant_id) + + cursor.execute( + f"UPDATE tenants SET {', '.join(update_fields)} WHERE id = %s", + update_values + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, old_tenant["code"], + "update", "tenant", tenant_id, old_tenant["name"], + old_value=dict(old_tenant), + new_value=data.model_dump(exclude_unset=True) + ) + + conn.commit() + + return await get_tenant(tenant_id, admin) + finally: + conn.close() + + +@router.delete("/{tenant_id}", response_model=ResponseModel, summary="删除租户") +async def delete_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(require_superadmin), +): + """ + 删除租户 + + 需要超级管理员权限 + 警告:此操作将删除租户及其所有配置 + """ + conn = get_db_connection() + try: + with conn.cursor() as cursor: + # 获取租户信息 + cursor.execute("SELECT * FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + + if not tenant: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 记录操作日志 + log_operation( + cursor, admin, tenant_id, tenant["code"], + "delete", "tenant", tenant_id, tenant["name"], + old_value=dict(tenant) + ) + + # 删除租户(级联删除配置) + cursor.execute("DELETE FROM tenants WHERE id = %s", (tenant_id,)) + + conn.commit() + + return ResponseModel(message=f"租户 {tenant['name']} 已删除") + finally: + conn.close() + + +@router.post("/{tenant_id}/enable", response_model=ResponseModel, summary="启用租户") +async def enable_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """启用租户""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + "UPDATE tenants SET status = 'active', updated_by = %s WHERE id = %s", + (admin.id, tenant_id) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取租户信息并记录日志 + cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + + log_operation( + cursor, admin, tenant_id, tenant["code"], + "enable", "tenant", tenant_id, tenant["name"] + ) + + conn.commit() + + return ResponseModel(message="租户已启用") + finally: + conn.close() + + +@router.post("/{tenant_id}/disable", response_model=ResponseModel, summary="禁用租户") +async def disable_tenant( + tenant_id: int, + admin: AdminUserInfo = Depends(get_current_admin), +): + """禁用租户""" + conn = get_db_connection() + try: + with conn.cursor() as cursor: + cursor.execute( + "UPDATE tenants SET status = 'inactive', updated_by = %s WHERE id = %s", + (admin.id, tenant_id) + ) + + if cursor.rowcount == 0: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="租户不存在", + ) + + # 获取租户信息并记录日志 + cursor.execute("SELECT code, name FROM tenants WHERE id = %s", (tenant_id,)) + tenant = cursor.fetchone() + + log_operation( + cursor, admin, tenant_id, tenant["code"], + "disable", "tenant", tenant_id, tenant["name"] + ) + + conn.commit() + + return ResponseModel(message="租户已禁用") + finally: + conn.close() + diff --git a/backend/app/api/v1/admin_positions_backup.py b/backend/app/api/v1/admin_positions_backup.py new file mode 100644 index 0000000..c8710b7 --- /dev/null +++ b/backend/app/api/v1/admin_positions_backup.py @@ -0,0 +1,158 @@ +# 此文件备份了admin.py中的positions相关路由代码 +# 这些路由已移至positions.py,为避免冲突,从admin.py中移除 + +@router.get("/positions") +async def list_positions( + keyword: Optional[str] = Query(None, description="关键词"), + page: int = Query(1, ge=1), + pageSize: int = Query(20, ge=1, le=100), + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取岗位列表(stub 数据) + + 返回结构兼容前端:data.list/total/page/pageSize + """ + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + try: + items = _sample_positions() + if keyword: + kw = keyword.lower() + items = [ + p for p in items if kw in (p.get("name", "") + p.get("description", "")).lower() + ] + + total = len(items) + start = (page - 1) * pageSize + end = start + pageSize + page_items = items[start:end] + + return ResponseModel( + code=200, + message="获取岗位列表成功", + data={ + "list": page_items, + "total": total, + "page": page, + "pageSize": pageSize, + }, + ) + except Exception as exc: + # 记录错误堆栈由全局异常中间件处理;此处返回统一结构 + return ResponseModel(code=500, message=f"服务器错误:{exc}") + + +@router.get("/positions/tree") +async def get_position_tree( + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取岗位树(stub 数据) + """ + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + try: + items = _sample_positions() + id_to_node: Dict[int, Dict[str, Any]] = {} + for p in items: + node = {**p, "children": []} + id_to_node[p["id"]] = node + + roots: List[Dict[str, Any]] = [] + for p in items: + parent_id = p.get("parentId") + if parent_id and parent_id in id_to_node: + id_to_node[parent_id]["children"].append(id_to_node[p["id"]]) + else: + roots.append(id_to_node[p["id"]]) + + return ResponseModel(code=200, message="获取岗位树成功", data=roots) + except Exception as exc: + return ResponseModel(code=500, message=f"服务器错误:{exc}") + + +@router.get("/positions/{position_id}") +async def get_position_detail( + position_id: int, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + items = _sample_positions() + for p in items: + if p["id"] == position_id: + return ResponseModel(code=200, message="获取岗位详情成功", data=p) + return ResponseModel(code=404, message="岗位不存在") + + +@router.get("/positions/{position_id}/check-delete") +async def check_position_delete( + position_id: int, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:允许删除非根岗位 + deletable = position_id != 1 + reason = "根岗位不允许删除" if not deletable else "" + return ResponseModel(code=200, message="检查成功", data={"deletable": deletable, "reason": reason}) + + +@router.post("/positions") +async def create_position( + payload: Dict[str, Any], + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:直接回显并附带一个伪ID + payload = dict(payload) + payload.setdefault("id", 999) + payload.setdefault("createTime", datetime.now().strftime("%Y-%m-%d %H:%M:%S")) + return ResponseModel(code=200, message="创建岗位成功", data=payload) + + +@router.put("/positions/{position_id}") +async def update_position( + position_id: int, + payload: Dict[str, Any], + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:直接回显 + updated = {"id": position_id, **payload} + return ResponseModel(code=200, message="更新岗位成功", data=updated) + + +@router.delete("/positions/{position_id}") +async def delete_position( + position_id: int, + current_user: User = Depends(get_current_user), + _db: AsyncSession = Depends(get_db), +) -> ResponseModel: + not_admin = _ensure_admin(current_user) + if not_admin: + return not_admin + + # stub:直接返回成功 + return ResponseModel(code=200, message="删除岗位成功", data={"id": position_id}) diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py new file mode 100644 index 0000000..411e4a2 --- /dev/null +++ b/backend/app/api/v1/auth.py @@ -0,0 +1,156 @@ +""" +认证 API +""" +from fastapi import APIRouter, Depends, status, Request +from sqlalchemy.ext.asyncio import AsyncSession + +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.auth import LoginRequest, RefreshTokenRequest, Token +from app.schemas.base import ResponseModel +from app.schemas.user import User as UserSchema +from app.services.auth_service import AuthService +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.core.exceptions import UnauthorizedError + +router = APIRouter() + + +@router.post("/login", response_model=ResponseModel) +async def login( + login_data: LoginRequest, + request: Request, + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 用户登录 + + 支持使用用户名、邮箱或手机号登录 + """ + auth_service = AuthService(db) + try: + user, token = await auth_service.login( + username=login_data.username, + password=login_data.password, + ) + + # 记录登录成功日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="security", + message=f"用户 {user.username} 登录成功", + user_id=user.id, + user=user.username, + ip=request.client.host if request.client else None, + path="/api/v1/auth/login", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel( + message="登录成功", + data={ + "user": UserSchema.model_validate(user).model_dump(), + "token": token.model_dump(), + }, + ) + except UnauthorizedError as e: + # 记录登录失败日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="WARNING", + type="security", + message=f"用户 {login_data.username} 登录失败:密码错误", + user=login_data.username, + ip=request.client.host if request.client else None, + path="/api/v1/auth/login", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + # 不返回 401,统一返回 HTTP 200 + 业务失败码,便于前端友好提示 + logger.warning("login_failed_wrong_credentials", username=login_data.username) + return ResponseModel( + code=400, + message=str(e) or "用户名或密码错误", + data=None, + ) + except Exception as e: + logger.error("login_failed_unexpected", error=str(e)) + return ResponseModel( + code=500, + message="登录失败,请稍后重试", + data=None, + ) + + +@router.post("/refresh", response_model=ResponseModel) +async def refresh_token( + refresh_data: RefreshTokenRequest, + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 刷新访问令牌 + + 使用刷新令牌获取新的访问令牌 + """ + auth_service = AuthService(db) + token = await auth_service.refresh_token(refresh_data.refresh_token) + + return ResponseModel(message="令牌刷新成功", data=token.model_dump()) + + +@router.post("/logout", response_model=ResponseModel) +async def logout( + request: Request, + current_user: User = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 用户登出 + + 注意:客户端需要删除本地存储的令牌 + """ + auth_service = AuthService(db) + await auth_service.logout(current_user.id) + + # 记录登出日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="security", + message=f"用户 {current_user.username} 登出", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/auth/logout", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="登出成功") + + +@router.get("/verify", response_model=ResponseModel) +async def verify_token( + current_user: User = Depends(get_current_active_user), +) -> ResponseModel: + """ + 验证令牌 + + 用于检查当前令牌是否有效 + """ + return ResponseModel( + message="令牌有效", + data={ + "user": UserSchema.model_validate(current_user).model_dump(), + }, + ) diff --git a/backend/app/api/v1/broadcast.py b/backend/app/api/v1/broadcast.py new file mode 100644 index 0000000..c6eb854 --- /dev/null +++ b/backend/app/api/v1/broadcast.py @@ -0,0 +1,145 @@ +""" +播课功能 API 接口 +""" + +import logging +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel, Field +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user, require_admin_or_manager +from app.schemas.base import ResponseModel +from app.models.course import Course +from app.models.user import User +from app.services.coze_broadcast_service import broadcast_service + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +# Schema 定义 +class GenerateBroadcastResponse(BaseModel): + """生成播课响应""" + message: str = Field(..., description="提示信息") + + +class BroadcastInfo(BaseModel): + """播课信息""" + has_broadcast: bool = Field(..., description="是否有播课") + mp3_url: Optional[str] = Field(None, description="播课音频URL") + generated_at: Optional[datetime] = Field(None, description="生成时间") + + +@router.post("/courses/{course_id}/generate-broadcast", response_model=ResponseModel[GenerateBroadcastResponse]) +async def generate_broadcast( + course_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """ + 触发播课音频生成(立即返回,Coze工作流会直接写数据库) + + 权限:manager、admin + + Args: + course_id: 课程ID + db: 数据库会话 + current_user: 当前用户 + + Returns: + 启动提示信息 + + Raises: + HTTPException 404: 课程不存在 + """ + logger.info( + f"请求生成播课", + extra={"course_id": course_id, "user_id": current_user.id} + ) + + # 查询课程 + result = await db.execute( + select(Course) + .where(Course.id == course_id) + .where(Course.is_deleted == False) + ) + course = result.scalar_one_or_none() + + if not course: + logger.warning(f"课程不存在", extra={"course_id": course_id}) + raise HTTPException(status_code=404, detail="课程不存在") + + # 调用 Coze 工作流(不等待结果,工作流会直接写数据库) + try: + await broadcast_service.trigger_workflow(course_id) + + logger.info( + f"播课生成工作流已触发", + extra={"course_id": course_id, "user_id": current_user.id} + ) + + return ResponseModel( + code=200, + message="播课生成已启动", + data=GenerateBroadcastResponse( + message="播课生成工作流已启动,生成完成后将自动更新" + ) + ) + except Exception as e: + logger.error( + f"触发播课生成失败", + extra={"course_id": course_id, "error": str(e)} + ) + raise HTTPException(status_code=500, detail=f"触发播课生成失败: {str(e)}") + + +@router.get("/courses/{course_id}/broadcast", response_model=ResponseModel[BroadcastInfo]) +async def get_broadcast_info( + course_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取播课信息 + + 权限:所有登录用户 + + Args: + course_id: 课程ID + db: 数据库会话 + current_user: 当前用户 + + Returns: + 播课信息 + + Raises: + HTTPException 404: 课程不存在 + """ + # 查询课程 + result = await db.execute( + select(Course) + .where(Course.id == course_id) + .where(Course.is_deleted == False) + ) + course = result.scalar_one_or_none() + + if not course: + raise HTTPException(status_code=404, detail="课程不存在") + + # 构建播课信息 + has_broadcast = bool(course.broadcast_audio_url) + + return ResponseModel( + code=200, + message="success", + data=BroadcastInfo( + has_broadcast=has_broadcast, + mp3_url=course.broadcast_audio_url if has_broadcast else None, + generated_at=course.broadcast_generated_at if has_broadcast else None + ) + ) diff --git a/backend/app/api/v1/course_chat.py b/backend/app/api/v1/course_chat.py new file mode 100644 index 0000000..45e7a2a --- /dev/null +++ b/backend/app/api/v1/course_chat.py @@ -0,0 +1,190 @@ +""" +与课程对话 API + +使用 Python 原生 AI 服务实现 +""" + +import json +import logging +from typing import Optional, Any + +from fastapi import APIRouter, HTTPException, Depends +from fastapi.responses import StreamingResponse +from pydantic import BaseModel, Field +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.ai.course_chat_service import course_chat_service_v2 + +router = APIRouter() +logger = logging.getLogger(__name__) + + +class CourseChatRequest(BaseModel): + """课程对话请求""" + course_id: int = Field(..., description="课程ID") + query: str = Field(..., description="用户问题") + conversation_id: Optional[str] = Field(None, description="会话ID(续接对话时传入)") + + +class ResponseModel(BaseModel): + """通用响应模型""" + code: int = 200 + message: str = "success" + data: Optional[Any] = None + + +async def _chat_with_course( + request: CourseChatRequest, + current_user: User, + db: AsyncSession +): + """ + Python 原生实现的流式对话 + """ + logger.info( + f"用户 {current_user.username} 与课程 {request.course_id} 对话: " + f"{request.query[:50]}..." + ) + + async def generate_stream(): + """生成 SSE 流""" + try: + async for event_type, data in course_chat_service_v2.chat_stream( + db=db, + course_id=request.course_id, + query=request.query, + user_id=current_user.id, + conversation_id=request.conversation_id + ): + if event_type == "conversation_started": + yield f"data: {json.dumps({'event': 'conversation_started', 'conversation_id': data})}\n\n" + logger.info(f"会话已创建: {data}") + + elif event_type == "chunk": + yield f"data: {json.dumps({'event': 'message_chunk', 'chunk': data})}\n\n" + + elif event_type == "done": + yield f"data: {json.dumps({'event': 'message_end', 'message': data})}\n\n" + logger.info(f"对话完成,总长度: {len(data)}") + + elif event_type == "error": + yield f"data: {json.dumps({'event': 'error', 'message': data})}\n\n" + logger.error(f"对话错误: {data}") + + except Exception as e: + logger.error(f"流式对话异常: {e}", exc_info=True) + yield f"data: {json.dumps({'event': 'error', 'message': str(e)})}\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" + } + ) + + +@router.post("/chat") +async def chat_with_course( + request: CourseChatRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 与课程对话(流式响应) + + 使用 Python 原生 AI 服务实现,支持多轮对话。 + """ + return await _chat_with_course(request, current_user, db) + + +@router.get("/conversations") +async def get_conversations( + course_id: Optional[int] = None, + limit: int = 20, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取会话列表 + + 返回当前用户的历史会话列表 + """ + try: + conversations = await course_chat_service_v2.get_conversations( + user_id=current_user.id, + course_id=course_id, + limit=limit + ) + + return ResponseModel( + code=200, + message="获取会话列表成功", + data={ + "conversations": conversations, + "total": len(conversations) + } + ) + + except Exception as e: + logger.error(f"获取会话列表失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取会话列表失败: {str(e)}") + + +@router.get("/messages") +async def get_messages( + conversation_id: str, + limit: int = 50, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取历史消息 + + 返回指定会话的历史消息 + """ + try: + messages = await course_chat_service_v2.get_messages( + conversation_id=conversation_id, + user_id=current_user.id, + limit=limit + ) + + return ResponseModel( + code=200, + message="获取历史消息成功", + data={ + "messages": messages, + "total": len(messages) + } + ) + + except Exception as e: + logger.error(f"获取历史消息失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取历史消息失败: {str(e)}") + + +@router.get("/engines") +async def list_chat_engines(): + """ + 获取可用的对话引擎列表 + """ + return ResponseModel( + code=200, + message="获取对话引擎列表成功", + data={ + "engines": [ + { + "id": "native", + "name": "Python 原生实现", + "description": "使用本地 AI 服务(4sapi.com + OpenRouter),支持流式输出和多轮对话", + "default": True + } + ], + "default_engine": "native" + } + ) diff --git a/backend/app/api/v1/courses.py b/backend/app/api/v1/courses.py new file mode 100644 index 0000000..4cfa94b --- /dev/null +++ b/backend/app/api/v1/courses.py @@ -0,0 +1,786 @@ +""" +课程管理API路由 +""" +from typing import List, Optional + +from fastapi import APIRouter, Depends, Query, status, BackgroundTasks, Request +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user, require_admin, require_admin_or_manager, User +from app.core.exceptions import NotFoundError, BadRequestError +from app.core.logger import get_logger +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.models.course import CourseStatus, CourseCategory +from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse +from app.schemas.course import ( + CourseCreate, + CourseUpdate, + CourseInDB, + CourseList, + CourseMaterialCreate, + CourseMaterialInDB, + KnowledgePointCreate, + KnowledgePointUpdate, + KnowledgePointInDB, + GrowthPathCreate, + GrowthPathInDB, + CourseExamSettingsCreate, + CourseExamSettingsUpdate, + CourseExamSettingsInDB, + CoursePositionAssignment, + CoursePositionAssignmentInDB, +) +from app.services.course_service import ( + course_service, + knowledge_point_service, + growth_path_service, +) + +logger = get_logger(__name__) +router = APIRouter(prefix="/courses", tags=["courses"]) + + +@router.get("", response_model=ResponseModel[PaginatedResponse[CourseInDB]]) +async def get_courses( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + status: Optional[CourseStatus] = Query(None, description="课程状态"), + category: Optional[CourseCategory] = Query(None, description="课程分类"), + is_featured: Optional[bool] = Query(None, description="是否推荐"), + keyword: Optional[str] = Query(None, description="搜索关键词"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程列表(支持分页和筛选) + + - **page**: 页码 + - **size**: 每页数量 + - **status**: 课程状态筛选 + - **category**: 课程分类筛选 + - **is_featured**: 是否推荐筛选 + - **keyword**: 关键词搜索(搜索名称和描述) + """ + page_params = PaginationParams(page=page, page_size=size) + filters = CourseList( + status=status, category=category, is_featured=is_featured, keyword=keyword + ) + + result = await course_service.get_course_list( + db, page_params=page_params, filters=filters, user_id=current_user.id + ) + + return ResponseModel(data=result, message="获取课程列表成功") + + +@router.post( + "", response_model=ResponseModel[CourseInDB], status_code=status.HTTP_201_CREATED +) +async def create_course( + course_in: CourseCreate, + request: Request, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建课程(需要管理员权限) + + - **name**: 课程名称 + - **description**: 课程描述 + - **category**: 课程分类 + - **status**: 课程状态(默认为草稿) + - **cover_image**: 封面图片URL + - **duration_hours**: 课程时长(小时) + - **difficulty_level**: 难度等级(1-5) + - **tags**: 标签列表 + - **is_featured**: 是否推荐 + """ + course = await course_service.create_course( + db, course_in=course_in, created_by=current_user.id + ) + + # 记录课程创建日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"创建课程: {course.name}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/courses", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(data=course, message="创建课程成功") + + +@router.get("/{course_id}", response_model=ResponseModel[CourseInDB]) +async def get_course( + course_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程详情 + + - **course_id**: 课程ID + """ + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + logger.info(f"查看课程详情 - course_id: {course_id}, user_id: {current_user.id}") + + return ResponseModel(data=course, message="获取课程详情成功") + + +@router.put("/{course_id}", response_model=ResponseModel[CourseInDB]) +async def update_course( + course_id: int, + course_in: CourseUpdate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 更新课程(需要管理员权限) + + - **course_id**: 课程ID + - **course_in**: 更新的课程数据(所有字段都是可选的) + """ + course = await course_service.update_course( + db, course_id=course_id, course_in=course_in, updated_by=current_user.id + ) + + return ResponseModel(data=course, message="更新课程成功") + + +@router.delete("/{course_id}", response_model=ResponseModel[bool]) +async def delete_course( + course_id: int, + request: Request, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 删除课程(需要管理员权限) + + - **course_id**: 课程ID + + 说明:任意状态均可软删除(is_deleted=1),请谨慎操作 + """ + # 先获取课程信息 + course = await course_service.get_by_id(db, course_id) + course_name = course.name if course else f"ID:{course_id}" + + success = await course_service.delete_course( + db, course_id=course_id, deleted_by=current_user.id + ) + + # 记录课程删除日志 + if success: + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"删除课程: {course_name}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path=f"/api/v1/courses/{course_id}", + method="DELETE", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(data=success, message="删除课程成功" if success else "删除课程失败") + + +# 课程资料相关API +@router.post( + "/{course_id}/materials", + response_model=ResponseModel[CourseMaterialInDB], + status_code=status.HTTP_201_CREATED, +) +async def add_course_material( + course_id: int, + material_in: CourseMaterialCreate, + background_tasks: BackgroundTasks, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 添加课程资料(需要管理员权限) + + - **course_id**: 课程ID + - **name**: 资料名称 + - **description**: 资料描述 + - **file_url**: 文件URL + - **file_type**: 文件类型(pdf, doc, docx, ppt, pptx, xls, xlsx, mp4, mp3, zip) + - **file_size**: 文件大小(字节) + + 添加资料后会自动触发知识点分析 + """ + material = await course_service.add_course_material( + db, course_id=course_id, material_in=material_in, created_by=current_user.id + ) + + # 获取课程信息用于知识点分析 + course = await course_service.get_by_id(db, course_id) + if course: + # 异步触发知识点分析 + from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2 + background_tasks.add_task( + _trigger_knowledge_analysis, + db, + course_id, + material.id, + material.file_url, + course.name, + current_user.id + ) + + logger.info( + f"资料添加成功,已触发知识点分析 - course_id: {course_id}, material_id: {material.id}, user_id: {current_user.id}" + ) + + return ResponseModel(data=material, message="添加课程资料成功") + + +@router.get( + "/{course_id}/materials", + response_model=ResponseModel[List[CourseMaterialInDB]], +) +async def list_course_materials( + course_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程资料列表 + + - **course_id**: 课程ID + """ + materials = await course_service.get_course_materials(db, course_id=course_id) + return ResponseModel(data=materials, message="获取课程资料列表成功") + + +@router.delete( + "/{course_id}/materials/{material_id}", + response_model=ResponseModel[bool], +) +async def delete_course_material( + course_id: int, + material_id: int, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 删除课程资料(需要管理员权限) + + - **course_id**: 课程ID + - **material_id**: 资料ID + """ + success = await course_service.delete_course_material( + db, course_id=course_id, material_id=material_id, deleted_by=current_user.id + ) + return ResponseModel(data=success, message="删除课程资料成功" if success else "删除课程资料失败") + + +# 知识点相关API +@router.get( + "/{course_id}/knowledge-points", + response_model=ResponseModel[List[KnowledgePointInDB]], +) +async def get_course_knowledge_points( + course_id: int, + material_id: Optional[int] = Query(None, description="资料ID"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程的知识点列表 + + - **course_id**: 课程ID + - **material_id**: 资料ID(可选,用于筛选特定资料的知识点) + """ + # 先检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + knowledge_points = await knowledge_point_service.get_knowledge_points_by_course( + db, course_id=course_id, material_id=material_id + ) + + return ResponseModel(data=knowledge_points, message="获取知识点列表成功") + + +@router.post( + "/{course_id}/knowledge-points", + response_model=ResponseModel[KnowledgePointInDB], + status_code=status.HTTP_201_CREATED, +) +async def create_knowledge_point( + course_id: int, + point_in: KnowledgePointCreate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建知识点(需要管理员权限) + + - **course_id**: 课程ID + - **name**: 知识点名称 + - **description**: 知识点描述 + - **parent_id**: 父知识点ID + - **weight**: 权重(0-10) + - **is_required**: 是否必修 + - **estimated_hours**: 预计学习时间(小时) + """ + knowledge_point = await knowledge_point_service.create_knowledge_point( + db, course_id=course_id, point_in=point_in, created_by=current_user.id + ) + + return ResponseModel(data=knowledge_point, message="创建知识点成功") + + +@router.put( + "/knowledge-points/{point_id}", response_model=ResponseModel[KnowledgePointInDB] +) +async def update_knowledge_point( + point_id: int, + point_in: KnowledgePointUpdate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 更新知识点(需要管理员权限) + + - **point_id**: 知识点ID + - **point_in**: 更新的知识点数据(所有字段都是可选的) + """ + knowledge_point = await knowledge_point_service.update_knowledge_point( + db, point_id=point_id, point_in=point_in, updated_by=current_user.id + ) + + return ResponseModel(data=knowledge_point, message="更新知识点成功") + + +@router.delete("/knowledge-points/{point_id}", response_model=ResponseModel[bool]) +async def delete_knowledge_point( + point_id: int, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 删除知识点(需要管理员权限) + + - **point_id**: 知识点ID + """ + success = await knowledge_point_service.delete( + db, id=point_id, soft=True, deleted_by=current_user.id + ) + + if success: + logger.warning("删除知识点", knowledge_point_id=point_id, deleted_by=current_user.id) + + return ResponseModel(data=success, message="删除知识点成功" if success else "删除知识点失败") + + +# 资料知识点关联API +@router.get( + "/materials/{material_id}/knowledge-points", + response_model=ResponseModel[List[KnowledgePointInDB]], +) +async def get_material_knowledge_points( + material_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取资料关联的知识点列表 + """ + knowledge_points = await course_service.get_material_knowledge_points( + db, material_id=material_id + ) + return ResponseModel(data=knowledge_points, message="获取知识点列表成功") + + +@router.post( + "/materials/{material_id}/knowledge-points", + response_model=ResponseModel[List[KnowledgePointInDB]], + status_code=status.HTTP_201_CREATED, +) +async def add_material_knowledge_points( + material_id: int, + knowledge_point_ids: List[int], + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 为资料添加知识点关联(需要管理员或经理权限) + """ + knowledge_points = await course_service.add_material_knowledge_points( + db, material_id=material_id, knowledge_point_ids=knowledge_point_ids + ) + return ResponseModel(data=knowledge_points, message="添加知识点成功") + + +@router.delete( + "/materials/{material_id}/knowledge-points/{knowledge_point_id}", + response_model=ResponseModel[bool], +) +async def remove_material_knowledge_point( + material_id: int, + knowledge_point_id: int, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 移除资料的知识点关联(需要管理员或经理权限) + """ + success = await course_service.remove_material_knowledge_point( + db, material_id=material_id, knowledge_point_id=knowledge_point_id + ) + return ResponseModel(data=success, message="移除知识点成功" if success else "移除失败") + + +# 成长路径相关API +@router.post( + "/growth-paths", + response_model=ResponseModel[GrowthPathInDB], + status_code=status.HTTP_201_CREATED, +) +async def create_growth_path( + path_in: GrowthPathCreate, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建成长路径(需要管理员权限) + + - **name**: 路径名称 + - **description**: 路径描述 + - **target_role**: 目标角色 + - **courses**: 课程列表(包含course_id、order、is_required) + - **estimated_duration_days**: 预计完成天数 + - **is_active**: 是否启用 + """ + growth_path = await growth_path_service.create_growth_path( + db, path_in=path_in, created_by=current_user.id + ) + + return ResponseModel(data=growth_path, message="创建成长路径成功") + + +@router.get( + "/growth-paths", response_model=ResponseModel[PaginatedResponse[GrowthPathInDB]] +) +async def get_growth_paths( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + is_active: Optional[bool] = Query(None, description="是否启用"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取成长路径列表 + + - **page**: 页码 + - **size**: 每页数量 + - **is_active**: 是否启用筛选 + """ + page_params = PaginationParams(page=page, page_size=size) + + filters = [] + if is_active is not None: + from app.models.course import GrowthPath + + filters.append(GrowthPath.is_active == is_active) + + result = await growth_path_service.get_page( + db, page_params=page_params, filters=filters + ) + + return ResponseModel(data=result, message="获取成长路径列表成功") + + +# 课程考试设置相关API +@router.get( + "/{course_id}/exam-settings", + response_model=ResponseModel[Optional[CourseExamSettingsInDB]], +) +async def get_course_exam_settings( + course_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程的考试设置 + + - **course_id**: 课程ID + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 获取考试设置 + from app.services.course_exam_service import course_exam_service + settings = await course_exam_service.get_by_course_id(db, course_id) + + # 添加调试日志 + if settings: + logger.info( + f"📊 获取考试设置成功 - course_id: {course_id}, " + f"单选: {settings.single_choice_count}, 多选: {settings.multiple_choice_count}, " + f"判断: {settings.true_false_count}, 填空: {settings.fill_blank_count}, " + f"问答: {settings.essay_count}, 难度: {settings.difficulty_level}" + ) + else: + logger.warning(f"⚠️ 课程 {course_id} 没有配置考试设置,将使用默认值") + + return ResponseModel(data=settings, message="获取考试设置成功") + + +@router.post( + "/{course_id}/exam-settings", + response_model=ResponseModel[CourseExamSettingsInDB], + status_code=status.HTTP_201_CREATED, +) +async def create_course_exam_settings( + course_id: int, + settings_in: CourseExamSettingsCreate, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 创建或更新课程的考试设置(需要管理员权限) + + - **course_id**: 课程ID + - **settings_in**: 考试设置数据 + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建或更新考试设置 + from app.services.course_exam_service import course_exam_service + settings = await course_exam_service.create_or_update( + db, course_id=course_id, settings_in=settings_in, user_id=current_user.id + ) + + return ResponseModel(data=settings, message="保存考试设置成功") + + +@router.put( + "/{course_id}/exam-settings", + response_model=ResponseModel[CourseExamSettingsInDB], +) +async def update_course_exam_settings( + course_id: int, + settings_in: CourseExamSettingsUpdate, + current_user: User = Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +): + """ + 更新课程的考试设置(需要管理员权限) + + - **course_id**: 课程ID + - **settings_in**: 更新的考试设置数据 + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 更新考试设置 + from app.services.course_exam_service import course_exam_service + settings = await course_exam_service.update( + db, course_id=course_id, settings_in=settings_in, user_id=current_user.id + ) + + return ResponseModel(data=settings, message="更新考试设置成功") + + +# 课程岗位分配相关API +@router.get( + "/{course_id}/positions", + response_model=ResponseModel[List[CoursePositionAssignmentInDB]], +) +async def get_course_positions( + course_id: int, + course_type: Optional[str] = Query(None, pattern="^(required|optional)$", description="课程类型筛选"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取课程的岗位分配列表 + + - **course_id**: 课程ID + - **course_type**: 课程类型筛选(required必修/optional选修) + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 获取岗位分配列表 + from app.services.course_position_service import course_position_service + assignments = await course_position_service.get_course_positions( + db, course_id=course_id, course_type=course_type + ) + + return ResponseModel(data=assignments, message="获取岗位分配列表成功") + + +@router.post( + "/{course_id}/positions", + response_model=ResponseModel[List[CoursePositionAssignmentInDB]], + status_code=status.HTTP_201_CREATED, +) +async def assign_course_positions( + course_id: int, + assignments: List[CoursePositionAssignment], + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 批量分配课程到岗位(需要管理员权限) + + - **course_id**: 课程ID + - **assignments**: 岗位分配列表 + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 批量分配岗位 + from app.services.course_position_service import course_position_service + result = await course_position_service.batch_assign_positions( + db, course_id=course_id, assignments=assignments, user_id=current_user.id + ) + + # 发送课程分配通知给相关岗位的学员 + try: + from app.models.position_member import PositionMember + from app.services.notification_service import notification_service + from app.schemas.notification import NotificationBatchCreate, NotificationType + + # 获取所有分配岗位的学员ID + position_ids = [a.position_id for a in assignments] + if position_ids: + member_result = await db.execute( + select(PositionMember.user_id).where( + PositionMember.position_id.in_(position_ids), + PositionMember.is_deleted == False + ).distinct() + ) + user_ids = [row[0] for row in member_result.fetchall()] + + if user_ids: + notification_batch = NotificationBatchCreate( + user_ids=user_ids, + title="新课程通知", + content=f"您所在岗位有新课程「{course.name}」已分配,请及时学习。", + type=NotificationType.COURSE_ASSIGN, + related_id=course_id, + related_type="course", + sender_id=current_user.id + ) + + await notification_service.batch_create_notifications( + db=db, + batch_in=notification_batch + ) + except Exception as e: + # 通知发送失败不影响课程分配结果 + import logging + logging.getLogger(__name__).error(f"发送课程分配通知失败: {str(e)}") + + return ResponseModel(data=result, message="岗位分配成功") + + +@router.delete( + "/{course_id}/positions/{position_id}", + response_model=ResponseModel[bool], +) +async def remove_course_position( + course_id: int, + position_id: int, + current_user: User = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 移除课程的岗位分配(需要管理员权限) + + - **course_id**: 课程ID + - **position_id**: 岗位ID + """ + # 检查课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 移除岗位分配 + from app.services.course_position_service import course_position_service + success = await course_position_service.remove_position_assignment( + db, course_id=course_id, position_id=position_id, user_id=current_user.id + ) + + return ResponseModel(data=success, message="移除岗位分配成功" if success else "移除岗位分配失败") + + +async def _trigger_knowledge_analysis( + db: AsyncSession, + course_id: int, + material_id: int, + file_url: str, + course_title: str, + user_id: int +): + """ + 后台触发知识点分析任务 + + 注意:此函数在后台任务中执行,异常不会影响资料添加的成功响应 + """ + try: + from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2 + + logger.info( + f"后台知识点分析开始 - course_id: {course_id}, material_id: {material_id}, file_url: {file_url}, user_id: {user_id}" + ) + + result = await knowledge_analysis_service_v2.analyze_course_material( + db=db, + course_id=course_id, + material_id=material_id, + file_url=file_url, + course_title=course_title, + user_id=user_id + ) + + logger.info( + f"后台知识点分析完成 - course_id: {course_id}, material_id: {material_id}, knowledge_points_count: {result.get('knowledge_points_count', 0)}, user_id: {user_id}" + ) + + except FileNotFoundError as e: + # 文件不存在时记录警告,但不记录完整堆栈 + logger.warning( + f"后台知识点分析失败(文件不存在) - course_id: {course_id}, material_id: {material_id}, " + f"file_url: {file_url}, error: {str(e)}, user_id: {user_id}" + ) + except Exception as e: + # 其他异常记录详细信息 + logger.error( + f"后台知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {str(e)}", + exc_info=True + ) diff --git a/backend/app/api/v1/coze_gateway.py b/backend/app/api/v1/coze_gateway.py new file mode 100644 index 0000000..e38ae3c --- /dev/null +++ b/backend/app/api/v1/coze_gateway.py @@ -0,0 +1,275 @@ +""" +Coze 网关 API 路由 +提供课程对话和陪练功能的统一接口 +""" + +import logging +from typing import Dict, Any +from fastapi import APIRouter, Depends, HTTPException, status +from fastapi.responses import StreamingResponse +from sse_starlette.sse import EventSourceResponse + +from app.services.ai.coze import ( + get_coze_service, + CreateSessionRequest, + SendMessageRequest, + EndSessionRequest, + SessionType, + CozeException, + StreamEventType, +) + + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["coze-gateway"]) + + +# TODO: 依赖注入获取当前用户 +async def get_current_user(): + """获取当前登录用户(临时实现)""" + # 实际应该从 Auth 模块获取 + return {"user_id": "test-user-123", "username": "test_user"} + + +@router.post("/course-chat/sessions") +async def create_course_chat_session(course_id: str, user=Depends(get_current_user)): + """ + 创建课程对话会话 + + - **course_id**: 课程ID + """ + try: + service = get_coze_service() + request = CreateSessionRequest( + session_type=SessionType.COURSE_CHAT, + user_id=user["user_id"], + course_id=course_id, + metadata={"username": user["username"], "course_id": course_id}, + ) + + response = await service.create_session(request) + + return {"code": 200, "message": "success", "data": response.dict()} + + except CozeException as e: + logger.error(f"创建课程对话会话失败: {e}") + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.post("/training/sessions") +async def create_training_session( + training_topic: str = None, user=Depends(get_current_user) +): + """ + 创建陪练会话 + + - **training_topic**: 陪练主题(可选) + """ + try: + service = get_coze_service() + request = CreateSessionRequest( + session_type=SessionType.TRAINING, + user_id=user["user_id"], + training_topic=training_topic, + metadata={"username": user["username"], "training_topic": training_topic}, + ) + + response = await service.create_session(request) + + return {"code": 200, "message": "success", "data": response.dict()} + + except CozeException as e: + logger.error(f"创建陪练会话失败: {e}") + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.post("/training/sessions/{session_id}/end") +async def end_training_session( + session_id: str, request: EndSessionRequest, user=Depends(get_current_user) +): + """ + 结束陪练会话 + + - **session_id**: 会话ID + """ + try: + service = get_coze_service() + response = await service.end_session(session_id, request) + + return {"code": 200, "message": "success", "data": response.dict()} + + except CozeException as e: + logger.error(f"结束会话失败: {e}") + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.post("/chat/messages") +async def send_message(request: SendMessageRequest, user=Depends(get_current_user)): + """ + 发送消息(支持流式响应) + + - **session_id**: 会话ID + - **content**: 消息内容 + - **stream**: 是否流式响应(默认True) + """ + try: + service = get_coze_service() + + if request.stream: + # 流式响应 + async def event_generator(): + async for event in service.send_message(request): + # 转换为 SSE 格式 + if event.event == StreamEventType.MESSAGE_DELTA: + yield { + "event": "message", + "data": { + "type": "delta", + "content": event.content, + "content_type": event.content_type.value, + "message_id": event.message_id, + }, + } + elif event.event == StreamEventType.MESSAGE_COMPLETED: + yield { + "event": "message", + "data": { + "type": "completed", + "content": event.content, + "content_type": event.content_type.value, + "message_id": event.message_id, + "usage": event.data.get("usage", {}), + }, + } + elif event.event == StreamEventType.ERROR: + yield {"event": "error", "data": {"error": event.error}} + elif event.event == StreamEventType.DONE: + yield { + "event": "done", + "data": {"session_id": event.data.get("session_id")}, + } + + return EventSourceResponse(event_generator()) + + else: + # 非流式响应(收集完整响应) + full_content = "" + content_type = None + message_id = None + + async for event in service.send_message(request): + if event.event == StreamEventType.MESSAGE_COMPLETED: + full_content = event.content + content_type = event.content_type + message_id = event.message_id + break + + return { + "code": 200, + "message": "success", + "data": { + "message_id": message_id, + "content": full_content, + "content_type": content_type.value if content_type else "text", + "role": "assistant", + }, + } + + except CozeException as e: + logger.error(f"发送消息失败: {e}") + if request.stream: + # 流式响应的错误处理 + async def error_generator(): + yield { + "event": "error", + "data": { + "code": e.code, + "message": e.message, + "details": e.details, + }, + } + + return EventSourceResponse(error_generator()) + else: + raise HTTPException( + status_code=e.status_code or 500, + detail={"code": e.code, "message": e.message, "details": e.details}, + ) + except Exception as e: + logger.error(f"未知错误: {e}", exc_info=True) + if request.stream: + + async def error_generator(): + yield { + "event": "error", + "data": {"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + } + + return EventSourceResponse(error_generator()) + else: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) + + +@router.get("/sessions/{session_id}/messages") +async def get_session_messages( + session_id: str, limit: int = 50, offset: int = 0, user=Depends(get_current_user) +): + """ + 获取会话消息历史 + + - **session_id**: 会话ID + - **limit**: 返回消息数量限制 + - **offset**: 偏移量 + """ + try: + service = get_coze_service() + messages = await service.get_session_messages(session_id, limit, offset) + + return { + "code": 200, + "message": "success", + "data": { + "messages": [msg.dict() for msg in messages], + "total": len(messages), + "limit": limit, + "offset": offset, + }, + } + + except Exception as e: + logger.error(f"获取消息历史失败: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"code": "INTERNAL_ERROR", "message": "服务器内部错误"}, + ) diff --git a/backend/app/api/v1/endpoints/employee_sync.py b/backend/app/api/v1/endpoints/employee_sync.py new file mode 100644 index 0000000..50146b5 --- /dev/null +++ b/backend/app/api/v1/endpoints/employee_sync.py @@ -0,0 +1,236 @@ +""" +员工同步API接口 +提供从钉钉员工表同步员工数据的功能 +""" + +from typing import Any, Dict +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.core.deps import get_current_user, get_db +from app.services.employee_sync_service import EmployeeSyncService +from app.models.user import User + +logger = get_logger(__name__) + +router = APIRouter() + + +@router.post("/sync", summary="执行员工同步") +async def sync_employees( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 从钉钉员工表同步在职员工数据到考培练系统 + + 权限要求: 仅管理员可执行 + + 同步内容: + - 创建用户账号(用户名=手机号,初始密码=123456) + - 创建部门团队 + - 创建岗位并关联用户 + - 设置领导为团队负责人 + + Returns: + 同步结果统计 + """ + # 权限检查:仅管理员可执行 + if current_user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有管理员可以执行员工同步" + ) + + logger.info(f"管理员 {current_user.username} 开始执行员工同步") + + try: + async with EmployeeSyncService(db) as sync_service: + stats = await sync_service.sync_employees() + + return { + "success": True, + "message": "员工同步完成", + "data": stats + } + + except Exception as e: + logger.error(f"员工同步失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"员工同步失败: {str(e)}" + ) + + +@router.get("/preview", summary="预览待同步员工数据") +async def preview_sync_data( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 预览待同步的员工数据(不执行实际同步) + + 权限要求: 仅管理员可查看 + + Returns: + 预览数据,包括员工列表、部门列表、岗位列表等 + """ + # 权限检查:仅管理员可查看 + if current_user.role != 'admin': + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有管理员可以预览员工数据" + ) + + logger.info(f"管理员 {current_user.username} 预览员工同步数据") + + try: + async with EmployeeSyncService(db) as sync_service: + preview_data = await sync_service.preview_sync_data() + + return { + "success": True, + "message": "预览数据获取成功", + "data": preview_data + } + + except Exception as e: + logger.error(f"预览数据获取失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"预览数据获取失败: {str(e)}" + ) + + +@router.post("/incremental-sync", summary="增量同步员工") +async def incremental_sync_employees( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 增量同步钉钉员工数据 + + 功能说明: + - 新增:钉钉有但系统没有的员工 + - 删除:系统有但钉钉没有的员工(物理删除) + - 跳过:两边都存在的员工(不修改任何信息) + + 权限要求: 管理员(admin 或 manager)可执行 + + Returns: + 同步结果统计 + """ + # 权限检查:管理员或经理可执行 + if current_user.role not in ['admin', 'manager']: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="只有管理员可以执行员工同步" + ) + + logger.info(f"用户 {current_user.username} ({current_user.role}) 开始执行增量员工同步") + + try: + async with EmployeeSyncService(db) as sync_service: + stats = await sync_service.incremental_sync_employees() + + return { + "success": True, + "message": "增量同步完成", + "data": { + "added_count": stats['added_count'], + "deleted_count": stats['deleted_count'], + "skipped_count": stats['skipped_count'], + "added_users": stats['added_users'], + "deleted_users": stats['deleted_users'], + "errors": stats['errors'], + "duration": stats['duration'] + } + } + + except Exception as e: + logger.error(f"增量同步失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"增量同步失败: {str(e)}" + ) + + +@router.get("/status", summary="查询同步状态") +async def get_sync_status( + *, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +) -> Dict[str, Any]: + """ + 查询当前系统的用户、团队、岗位统计信息 + + Returns: + 统计信息 + """ + from sqlalchemy import select, func + from app.models.user import User, Team + from app.models.position import Position + + try: + # 统计用户数量 + user_count_stmt = select(func.count(User.id)).where(User.is_deleted == False) + user_result = await db.execute(user_count_stmt) + total_users = user_result.scalar() + + # 统计各角色用户数量 + admin_count_stmt = select(func.count(User.id)).where( + User.is_deleted == False, + User.role == 'admin' + ) + admin_result = await db.execute(admin_count_stmt) + admin_count = admin_result.scalar() + + manager_count_stmt = select(func.count(User.id)).where( + User.is_deleted == False, + User.role == 'manager' + ) + manager_result = await db.execute(manager_count_stmt) + manager_count = manager_result.scalar() + + trainee_count_stmt = select(func.count(User.id)).where( + User.is_deleted == False, + User.role == 'trainee' + ) + trainee_result = await db.execute(trainee_count_stmt) + trainee_count = trainee_result.scalar() + + # 统计团队数量 + team_count_stmt = select(func.count(Team.id)).where(Team.is_deleted == False) + team_result = await db.execute(team_count_stmt) + total_teams = team_result.scalar() + + # 统计岗位数量 + position_count_stmt = select(func.count(Position.id)).where(Position.is_deleted == False) + position_result = await db.execute(position_count_stmt) + total_positions = position_result.scalar() + + return { + "success": True, + "data": { + "users": { + "total": total_users, + "admin": admin_count, + "manager": manager_count, + "trainee": trainee_count + }, + "teams": total_teams, + "positions": total_positions + } + } + + except Exception as e: + logger.error(f"查询统计信息失败: {str(e)}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"查询统计信息失败: {str(e)}" + ) + diff --git a/backend/app/api/v1/exam.py b/backend/app/api/v1/exam.py new file mode 100644 index 0000000..5cbc1fa --- /dev/null +++ b/backend/app/api/v1/exam.py @@ -0,0 +1,761 @@ +""" +考试相关API路由 +""" +from typing import List, Optional +import json +from datetime import datetime +from fastapi import APIRouter, Depends, Query, HTTPException, status, Request +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from app.core.deps import get_db, get_current_user +from app.core.config import get_settings +from app.core.logger import get_logger +from app.models.user import User +from app.models.exam import Exam +from app.models.exam_mistake import ExamMistake +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.schemas.base import ResponseModel +from app.schemas.exam import ( + StartExamRequest, + StartExamResponse, + SubmitExamRequest, + SubmitExamResponse, + ExamDetailResponse, + ExamRecordResponse, + GenerateExamRequest, + GenerateExamResponse, + JudgeAnswerRequest, + JudgeAnswerResponse, + RecordMistakeRequest, + RecordMistakeResponse, + GetMistakesResponse, + MistakeRecordItem, + # 新增的Schema + ExamReportResponse, + MistakeListResponse, + MistakesStatisticsResponse, + UpdateRoundScoreRequest, +) +from app.services.exam_report_service import ExamReportService, MistakeService +from app.services.course_statistics_service import course_statistics_service +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate + +# V2 原生服务:Python 实现 +from app.services.ai import exam_generator_service, ExamGeneratorConfig +from app.services.ai.answer_judge_service import answer_judge_service +from app.core.exceptions import ExternalServiceError + +logger = get_logger(__name__) +settings = get_settings() + +router = APIRouter(prefix="/exams", tags=["考试"]) + + +@router.post("/start", response_model=ResponseModel[StartExamResponse]) +async def start_exam( + request: StartExamRequest, + http_request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """开始考试""" + exam = await ExamService.start_exam( + db=db, + user_id=current_user.id, + course_id=request.course_id, + question_count=request.count, + ) + + # 异步更新课程学员数统计 + try: + await course_statistics_service.update_course_student_count(db, request.course_id) + except Exception as e: + logger.warning(f"更新课程学员数失败: {str(e)}") + # 不影响主流程,只记录警告 + + # 记录考试开始日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"用户 {current_user.username} 开始考试(课程ID: {request.course_id})", + user_id=current_user.id, + user=current_user.username, + ip=http_request.client.host if http_request.client else None, + path="/api/v1/exams/start", + method="POST", + user_agent=http_request.headers.get("user-agent") + ) + ) + + return ResponseModel(code=200, data=StartExamResponse(exam_id=exam.id), message="考试开始") + + +@router.post("/submit", response_model=ResponseModel[SubmitExamResponse]) +async def submit_exam( + request: SubmitExamRequest, + http_request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """提交考试答案""" + result = await ExamService.submit_exam( + db=db, user_id=current_user.id, exam_id=request.exam_id, answers=request.answers + ) + + # 获取考试记录以获取course_id + exam_stmt = select(Exam).where(Exam.id == request.exam_id) + exam_result = await db.execute(exam_stmt) + exam = exam_result.scalar_one_or_none() + + # 异步更新课程学员数统计 + if exam and exam.course_id: + try: + await course_statistics_service.update_course_student_count(db, exam.course_id) + except Exception as e: + logger.warning(f"更新课程学员数失败: {str(e)}") + # 不影响主流程,只记录警告 + + # 记录考试提交日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"用户 {current_user.username} 提交考试(考试ID: {request.exam_id},得分: {result.get('score', 0)})", + user_id=current_user.id, + user=current_user.username, + ip=http_request.client.host if http_request.client else None, + path="/api/v1/exams/submit", + method="POST", + user_agent=http_request.headers.get("user-agent") + ) + ) + + return ResponseModel(code=200, data=SubmitExamResponse(**result), message="考试提交成功") + + +@router.get("/mistakes", response_model=ResponseModel[GetMistakesResponse]) +async def get_mistakes( + exam_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取错题记录 + + 用于第二、三轮考试时获取上一轮的错题记录 + 返回的数据可直接序列化为JSON字符串作为mistake_records参数传给考试生成接口 + """ + logger.info(f"📋 GET /mistakes 收到请求") + try: + logger.info(f"📋 获取错题记录 - exam_id: {exam_id}, user_id: {current_user.id}") + + # 查询指定考试的错题记录 + result = await db.execute( + select(ExamMistake).where( + ExamMistake.exam_id == exam_id, + ExamMistake.user_id == current_user.id, + ).order_by(ExamMistake.id) + ) + mistakes = result.scalars().all() + + logger.info(f"✅ 查询到错题记录数量: {len(mistakes)}") + + # 转换为响应格式 + mistake_items = [ + MistakeRecordItem( + id=m.id, + question_id=m.question_id, + knowledge_point_id=m.knowledge_point_id, + question_content=m.question_content, + correct_answer=m.correct_answer, + user_answer=m.user_answer, + created_at=m.created_at, + ) + for m in mistakes + ] + + logger.info( + f"获取错题记录成功 - user_id: {current_user.id}, exam_id: {exam_id}, " + f"count: {len(mistake_items)}" + ) + + # 返回统一的ResponseModel格式,让Pydantic自动处理序列化 + return ResponseModel( + code=200, + message="获取错题记录成功", + data=GetMistakesResponse( + mistakes=mistake_items + ) + ) + + except Exception as e: + logger.error(f"获取错题记录失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取错题记录失败: {str(e)}" + ) + + +@router.get("/{exam_id}", response_model=ResponseModel[ExamDetailResponse]) +async def get_exam_detail( + exam_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取考试详情""" + exam_data = await ExamService.get_exam_detail( + db=db, user_id=current_user.id, exam_id=exam_id + ) + + return ResponseModel(code=200, data=ExamDetailResponse(**exam_data), message="获取成功") + + +@router.get("/records", response_model=ResponseModel[dict]) +async def get_exam_records( + page: int = Query(1, ge=1), + size: int = Query(10, ge=1, le=100), + course_id: Optional[int] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取考试记录列表""" + records = await ExamService.get_exam_records( + db=db, user_id=current_user.id, page=page, size=size, course_id=course_id + ) + + return ResponseModel(code=200, data=records, message="获取成功") + + +@router.get("/statistics/summary", response_model=ResponseModel[dict]) +async def get_exam_statistics( + course_id: Optional[int] = Query(None), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """获取考试统计信息""" + stats = await ExamService.get_exam_statistics( + db=db, user_id=current_user.id, course_id=course_id + ) + + return ResponseModel(code=200, data=stats, message="获取成功") + + +# ==================== 试题生成接口 ==================== + +@router.post("/generate", response_model=ResponseModel[GenerateExamResponse]) +async def generate_exam( + request: GenerateExamRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 生成考试试题 + + 使用 Python 原生 AI 服务实现。 + + 考试轮次说明: + - 第一轮考试:mistake_records 传空或不传 + - 第二、三轮错题重考:mistake_records 传入上一轮错题记录的JSON字符串 + """ + try: + # 从用户信息中自动获取岗位ID(如果未提供) + position_id = request.position_id + if not position_id: + # 1. 首先查询用户已分配的岗位 + result = await db.execute( + select(PositionMember).where( + PositionMember.user_id == current_user.id, + PositionMember.is_deleted == False + ).limit(1) + ) + position_member = result.scalar_one_or_none() + if position_member: + position_id = position_member.position_id + else: + # 2. 如果用户没有岗位,从课程关联的岗位中获取第一个 + result = await db.execute( + select(PositionCourse.position_id).where( + PositionCourse.course_id == request.course_id, + PositionCourse.is_deleted == False + ).limit(1) + ) + course_position = result.scalar_one_or_none() + if course_position: + position_id = course_position + logger.info(f"用户 {current_user.id} 没有分配岗位,使用课程关联的岗位ID: {position_id}") + else: + # 3. 如果课程也没有关联岗位,抛出错误 + logger.warning(f"用户 {current_user.id} 没有分配岗位,且课程 {request.course_id} 未关联任何岗位") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无法生成试题:用户未分配岗位,且课程未关联任何岗位" + ) + + # 记录详细的题型设置(用于调试) + logger.info( + f"考试题型设置 - 单选:{request.single_choice_count}, 多选:{request.multiple_choice_count}, " + f"判断:{request.true_false_count}, 填空:{request.fill_blank_count}, 问答:{request.essay_count}, " + f"难度:{request.difficulty_level}" + ) + + # 调用 Python 原生试题生成服务 + logger.info( + f"调用原生试题生成服务 - user_id: {current_user.id}, " + f"course_id: {request.course_id}, position_id: {position_id}" + ) + + # 构建配置 + config = ExamGeneratorConfig( + course_id=request.course_id, + position_id=position_id, + single_choice_count=request.single_choice_count or 0, + multiple_choice_count=request.multiple_choice_count or 0, + true_false_count=request.true_false_count or 0, + fill_blank_count=request.fill_blank_count or 0, + essay_count=request.essay_count or 0, + difficulty_level=request.difficulty_level or 3, + mistake_records=request.mistake_records or "", + ) + + # 调用原生服务 + gen_result = await exam_generator_service.generate_exam(db, config) + + if not gen_result.get("success"): + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="试题生成服务返回失败" + ) + + # 将题目列表转为 JSON 字符串(兼容原有前端格式) + result_data = json.dumps(gen_result.get("questions", []), ensure_ascii=False) + + logger.info( + f"试题生成完成 - questions: {gen_result.get('total_count')}, " + f"provider: {gen_result.get('ai_provider')}, latency: {gen_result.get('ai_latency_ms')}ms" + ) + + if result_data is None or result_data == "": + logger.error(f"试题生成未返回有效结果数据") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="试题生成失败: 未返回结果数据" + ) + + # 创建或复用考试记录 + question_count = sum([ + request.single_choice_count or 0, + request.multiple_choice_count or 0, + request.true_false_count or 0, + request.fill_blank_count or 0, + request.essay_count or 0 + ]) + + # 第一轮:创建新的exam记录 + if request.current_round == 1: + exam = Exam( + user_id=current_user.id, + course_id=request.course_id, + exam_name=f"课程{request.course_id}考试", + question_count=question_count, + total_score=100.0, + pass_score=60.0, + duration_minutes=60, + status="started", + start_time=datetime.now(), + questions=None, + answers=None, + ) + + db.add(exam) + await db.commit() + await db.refresh(exam) + + logger.info(f"第{request.current_round}轮:创建考试记录成功 - exam_id: {exam.id}") + else: + # 第二、三轮:复用已有exam记录 + if not request.exam_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"第{request.current_round}轮考试必须提供exam_id" + ) + + exam = await db.get(Exam, request.exam_id) + if not exam: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="考试记录不存在" + ) + + if exam.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权访问此考试记录" + ) + + logger.info(f"第{request.current_round}轮:复用考试记录 - exam_id: {exam.id}") + + return ResponseModel( + code=200, + message="试题生成成功", + data=GenerateExamResponse( + result=result_data, + workflow_run_id=f"{gen_result.get('ai_provider')}_{gen_result.get('ai_latency_ms')}ms", + task_id=f"native_{request.course_id}", + exam_id=exam.id, + ) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"生成考试试题失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"试题生成失败: {str(e)}" + ) + + +@router.post("/judge-answer", response_model=ResponseModel[JudgeAnswerResponse]) +async def judge_answer( + request: JudgeAnswerRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 判断主观题答案 + + 适用于填空题和问答题的答案判断。 + 使用 Python 原生 AI 服务实现。 + """ + try: + logger.info( + f"调用原生答案判断服务 - user_id: {current_user.id}, " + f"question: {request.question[:50]}..." + ) + + result = await answer_judge_service.judge( + question=request.question, + correct_answer=request.correct_answer, + user_answer=request.user_answer, + analysis=request.analysis, + db=db # 传入 db_session 用于记录调用日志 + ) + + logger.info( + f"答案判断完成 - is_correct: {result.is_correct}, " + f"provider: {result.ai_provider}, latency: {result.ai_latency_ms}ms" + ) + + return ResponseModel( + code=200, + message="答案判断完成", + data=JudgeAnswerResponse( + is_correct=result.is_correct, + correct_answer=request.correct_answer, + feedback=result.raw_response if not result.is_correct else None, + ) + ) + + except Exception as e: + logger.error(f"答案判断失败: {e}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"答案判断失败: {str(e)}" + ) + + +@router.post("/record-mistake", response_model=ResponseModel[RecordMistakeResponse]) +async def record_mistake( + request: RecordMistakeRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 记录错题 + + 当用户答错题目时,立即调用此接口记录到错题表 + """ + try: + # 创建错题记录 + # 注意:knowledge_point_id暂时设置为None,避免外键约束失败 + mistake = ExamMistake( + user_id=current_user.id, + exam_id=request.exam_id, + question_id=request.question_id, + knowledge_point_id=None, # 暂时设为None,避免外键约束 + question_content=request.question_content, + correct_answer=request.correct_answer, + user_answer=request.user_answer, + question_type=request.question_type, # 新增:记录题型 + ) + + if request.knowledge_point_id: + logger.info(f"原始knowledge_point_id={request.knowledge_point_id},已设置为NULL(待同步生产数据)") + + db.add(mistake) + await db.commit() + await db.refresh(mistake) + + logger.info( + f"记录错题成功 - user_id: {current_user.id}, exam_id: {request.exam_id}, " + f"mistake_id: {mistake.id}" + ) + + return ResponseModel( + code=200, + message="错题记录成功", + data=RecordMistakeResponse( + id=mistake.id, + created_at=mistake.created_at, + ) + ) + + except Exception as e: + await db.rollback() + logger.error(f"记录错题失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"记录错题失败: {str(e)}" + ) + + +@router.get("/mistakes-debug") +async def get_mistakes_debug( + exam_id: int, +): + """调试endpoint - 不需要认证""" + logger.info(f"🔍 调试 - exam_id: {exam_id}, type: {type(exam_id)}") + return {"exam_id": exam_id, "type": str(type(exam_id))} + + +# ==================== 成绩报告和错题本相关接口 ==================== + +@router.get("/statistics/report", response_model=ResponseModel[ExamReportResponse]) +async def get_exam_report( + start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取成绩报告汇总 + + 返回包含概览、趋势、科目分析、最近考试记录的完整报告 + """ + try: + report_data = await ExamReportService.get_exam_report( + db=db, + user_id=current_user.id, + start_date=start_date, + end_date=end_date + ) + + return ResponseModel(code=200, data=report_data, message="获取成绩报告成功") + + except Exception as e: + logger.error(f"获取成绩报告失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取成绩报告失败: {str(e)}" + ) + + +@router.get("/mistakes/list", response_model=ResponseModel[MistakeListResponse]) +async def get_mistakes_list( + exam_id: Optional[int] = Query(None, description="考试ID"), + course_id: Optional[int] = Query(None, description="课程ID"), + question_type: Optional[str] = Query(None, description="题型(single/multiple/judge/blank/essay)"), + search: Optional[str] = Query(None, description="关键词搜索"), + start_date: Optional[str] = Query(None, description="开始日期(YYYY-MM-DD)"), + end_date: Optional[str] = Query(None, description="结束日期(YYYY-MM-DD)"), + page: int = Query(1, ge=1, description="页码"), + size: int = Query(10, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取错题列表(支持多维度筛选) + + - 不传exam_id时返回用户所有错题 + - 支持按course_id、question_type、关键词、时间范围筛选 + """ + try: + mistakes_data = await MistakeService.get_mistakes_list( + db=db, + user_id=current_user.id, + exam_id=exam_id, + course_id=course_id, + question_type=question_type, + search=search, + start_date=start_date, + end_date=end_date, + page=page, + size=size + ) + + return ResponseModel(code=200, data=mistakes_data, message="获取错题列表成功") + + except Exception as e: + logger.error(f"获取错题列表失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取错题列表失败: {str(e)}" + ) + + +@router.get("/mistakes/statistics", response_model=ResponseModel[MistakesStatisticsResponse]) +async def get_mistakes_statistics( + course_id: Optional[int] = Query(None, description="课程ID"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 获取错题统计数据 + + 返回按课程、题型、时间维度的统计数据 + """ + try: + stats_data = await MistakeService.get_mistakes_statistics( + db=db, + user_id=current_user.id, + course_id=course_id + ) + + return ResponseModel(code=200, data=stats_data, message="获取错题统计成功") + + except Exception as e: + logger.error(f"获取错题统计失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取错题统计失败: {str(e)}" + ) + + +@router.put("/{exam_id}/round-score", response_model=ResponseModel[dict]) +async def update_round_score( + exam_id: int, + request: UpdateRoundScoreRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 更新某轮的得分 + + 用于前端每轮考试结束后更新对应轮次的得分 + """ + try: + # 查询考试记录 + exam = await db.get(Exam, exam_id) + if not exam: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="考试记录不存在" + ) + + # 验证权限 + if exam.user_id != current_user.id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="无权修改此考试记录" + ) + + # 更新对应轮次的得分 + if request.round == 1: + exam.round1_score = request.score + elif request.round == 2: + exam.round2_score = request.score + elif request.round == 3: + exam.round3_score = request.score + # 第三轮默认就是 final + request.is_final = True + + # 如果是最终轮次(可能是第1/2轮就全对了),更新总分和状态 + if request.is_final: + exam.score = request.score + exam.status = "submitted" + # 计算是否通过 (pass_score 为空默认 60) + exam.is_passed = request.score >= (exam.pass_score or 60) + # 更新结束时间 + from datetime import datetime + exam.end_time = datetime.now() + + await db.commit() + + logger.info(f"更新轮次得分成功 - exam_id: {exam_id}, round: {request.round}, score: {request.score}") + + return ResponseModel(code=200, data={"exam_id": exam_id}, message="更新得分成功") + + except HTTPException: + raise + except Exception as e: + await db.rollback() + logger.error(f"更新轮次得分失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"更新轮次得分失败: {str(e)}" + ) + + +@router.put("/mistakes/{mistake_id}/mastered", response_model=ResponseModel) +async def mark_mistake_mastered( + mistake_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """ + 标记错题为已掌握 + + Args: + mistake_id: 错题记录ID + db: 数据库会话 + current_user: 当前用户 + + Returns: + ResponseModel: 标记结果 + """ + try: + # 查询错题记录 + stmt = select(ExamMistake).where( + ExamMistake.id == mistake_id, + ExamMistake.user_id == current_user.id + ) + result = await db.execute(stmt) + mistake = result.scalar_one_or_none() + + if not mistake: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="错题记录不存在或无权访问" + ) + + # 更新掌握状态 + from datetime import datetime as dt + mistake.mastery_status = 'mastered' + mistake.mastered_at = dt.utcnow() + + await db.commit() + + logger.info(f"标记错题已掌握成功 - mistake_id: {mistake_id}, user_id: {current_user.id}") + + return ResponseModel( + code=200, + message="已标记为掌握", + data={"mistake_id": mistake_id, "mastery_status": "mastered"} + ) + + except HTTPException: + raise + except Exception as e: + await db.rollback() + logger.error(f"标记错题已掌握失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"标记失败: {str(e)}" + ) + + diff --git a/backend/app/api/v1/knowledge_analysis.py b/backend/app/api/v1/knowledge_analysis.py new file mode 100644 index 0000000..65a9caa --- /dev/null +++ b/backend/app/api/v1/knowledge_analysis.py @@ -0,0 +1,201 @@ +""" +知识点分析 API + +使用 Python 原生 AI 服务实现 +""" +import logging +from typing import Dict, Any + +from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, get_current_user +from app.schemas.base import ResponseModel +from app.models.user import User +from app.services.course_service import course_service +from app.services.ai.knowledge_analysis_v2 import knowledge_analysis_service_v2 + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/courses/{course_id}/materials/{material_id}/analyze", response_model=ResponseModel[Dict[str, Any]]) +async def analyze_material_knowledge_points( + course_id: int, + material_id: int, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 分析单个资料的知识点 + + - **course_id**: 课程ID + - **material_id**: 资料ID + + 使用 Python 原生 AI 服务: + - 本地 AI 服务调用(4sapi.com 首选,OpenRouter 备选) + - 多层 JSON 解析兜底 + - 无外部平台依赖,更稳定 + """ + try: + # 验证课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {course_id} 不存在" + ) + + # 获取资料信息 + materials = await course_service.get_course_materials(db, course_id=course_id) + material = next((m for m in materials if m.id == material_id), None) + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"资料 {material_id} 不存在" + ) + + logger.info( + f"准备启动知识点分析 - course_id: {course_id}, material_id: {material_id}, " + f"file_url: {material.file_url}, user_id: {current_user.id}" + ) + + # 调用 Python 原生知识点分析服务 + result = await knowledge_analysis_service_v2.analyze_course_material( + db=db, + course_id=course_id, + material_id=material_id, + file_url=material.file_url, + course_title=course.name, + user_id=current_user.id + ) + + logger.info( + f"知识点分析完成 - course_id: {course_id}, material_id: {material_id}, " + f"knowledge_points: {result.get('knowledge_points_count', 0)}, " + f"provider: {result.get('ai_provider')}" + ) + + # 构建响应 + response_data = { + "message": "知识点分析完成", + "course_id": course_id, + "material_id": material_id, + "status": result.get("status", "completed"), + "knowledge_points_count": result.get("knowledge_points_count", 0), + "ai_provider": result.get("ai_provider"), + "ai_model": result.get("ai_model"), + "ai_tokens": result.get("ai_tokens"), + "ai_latency_ms": result.get("ai_latency_ms"), + } + + return ResponseModel( + data=response_data, + message="知识点分析完成" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"知识点分析失败 - course_id: {course_id}, material_id: {material_id}, error: {e}", + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"知识点分析失败: {str(e)}" + ) + + +@router.post("/courses/{course_id}/reanalyze", response_model=ResponseModel[Dict[str, Any]]) +async def reanalyze_course_materials( + course_id: int, + background_tasks: BackgroundTasks, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 重新分析课程的所有资料 + + - **course_id**: 课程ID + + 该接口会重新分析课程下的所有资料,提取知识点 + """ + try: + # 验证课程是否存在 + course = await course_service.get_by_id(db, course_id) + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {course_id} 不存在" + ) + + # 获取课程资料信息 + materials = await course_service.get_course_materials(db, course_id=course_id) + + if not materials: + return ResponseModel( + data={ + "message": "该课程暂无资料需要分析", + "course_id": course_id, + "status": "stopped", + "materials_count": 0 + }, + message="无资料需要分析" + ) + + # 调用 Python 原生知识点分析服务 + result = await knowledge_analysis_service_v2.reanalyze_course_materials( + db=db, + course_id=course_id, + course_title=course.name, + user_id=current_user.id + ) + + return ResponseModel( + data={ + "message": "课程资料重新分析完成", + "course_id": course_id, + "status": "completed", + "materials_count": result.get("materials_count", 0), + "success_count": result.get("success_count", 0), + "knowledge_points_count": result.get("knowledge_points_count", 0), + "analysis_results": result.get("analysis_results", []) + }, + message="重新分析完成" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"启动课程资料重新分析失败 - course_id: {course_id}, error: {e}", + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="启动重新分析失败" + ) + + +@router.get("/engines", response_model=ResponseModel[Dict[str, Any]]) +async def list_analysis_engines(): + """ + 获取可用的分析引擎列表 + """ + return ResponseModel( + data={ + "engines": [ + { + "id": "native", + "name": "Python 原生实现", + "description": "使用本地 AI 服务(4sapi.com + OpenRouter),稳定可靠", + "default": True + } + ], + "default_engine": "native" + }, + message="获取分析引擎列表成功" + ) diff --git a/backend/app/api/v1/manager/__init__.py b/backend/app/api/v1/manager/__init__.py new file mode 100644 index 0000000..917c681 --- /dev/null +++ b/backend/app/api/v1/manager/__init__.py @@ -0,0 +1,8 @@ +""" +管理员相关API模块 +""" +from .student_scores import router as student_scores_router +from .student_practice import router as student_practice_router + +__all__ = ["student_scores_router", "student_practice_router"] + diff --git a/backend/app/api/v1/manager/student_practice.py b/backend/app/api/v1/manager/student_practice.py new file mode 100644 index 0000000..532231e --- /dev/null +++ b/backend/app/api/v1/manager/student_practice.py @@ -0,0 +1,345 @@ +""" +管理员查看学员陪练记录API +""" +from datetime import datetime +from typing import Optional + +from fastapi import APIRouter, Depends, Query +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db +from app.core.logger import logger +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.practice import PracticeReport, PracticeSession, PracticeDialogue +from app.models.user import User +from app.schemas.base import PaginatedResponse, ResponseModel + +router = APIRouter(prefix="/manager/student-practice", tags=["manager-student-practice"]) + + +@router.get("/", response_model=ResponseModel[PaginatedResponse]) +async def get_student_practice_records( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + student_name: Optional[str] = Query(None, description="学员姓名搜索"), + position: Optional[str] = Query(None, description="岗位筛选"), + scene_type: Optional[str] = Query(None, description="场景类型筛选"), + result: Optional[str] = Query(None, description="结果筛选: excellent/good/average/needs_improvement"), + start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取所有用户的陪练记录列表(管理员和manager可访问) + + 包含所有角色(trainee/admin/manager)的陪练记录,方便测试和全面管理 + + 支持筛选: + - student_name: 按用户姓名模糊搜索 + - position: 按岗位筛选 + - scene_type: 按场景类型筛选 + - result: 按结果筛选(优秀/良好/一般/需改进) + - start_date/end_date: 按日期范围筛选 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 构建基础查询 + # 关联User、PracticeReport来获取完整信息 + query = ( + select( + PracticeSession, + User.full_name.label('student_name'), + User.id.label('student_id'), + PracticeReport.total_score + ) + .join(User, PracticeSession.user_id == User.id) + .outerjoin( + PracticeReport, + PracticeSession.session_id == PracticeReport.session_id + ) + .where( + # 管理员可以查看所有人的陪练记录(包括其他管理员的),方便测试和全面管理 + PracticeSession.status == 'completed', # 只查询已完成的陪练 + PracticeSession.is_deleted == False + ) + ) + + # 学员姓名筛选 + if student_name: + query = query.where(User.full_name.contains(student_name)) + + # 岗位筛选 + if position: + # 通过position_members关联查询 + query = query.join( + PositionMember, + and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False + ) + ).join( + Position, + Position.id == PositionMember.position_id + ).where( + Position.name == position + ) + + # 场景类型筛选 + if scene_type: + query = query.where(PracticeSession.scene_type == scene_type) + + # 结果筛选(根据分数) + if result: + if result == 'excellent': + query = query.where(PracticeReport.total_score >= 90) + elif result == 'good': + query = query.where(and_( + PracticeReport.total_score >= 80, + PracticeReport.total_score < 90 + )) + elif result == 'average': + query = query.where(and_( + PracticeReport.total_score >= 70, + PracticeReport.total_score < 80 + )) + elif result == 'needs_improvement': + query = query.where(PracticeReport.total_score < 70) + + # 日期范围筛选 + if start_date: + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + query = query.where(PracticeSession.start_time >= start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + end_dt = end_dt.replace(hour=23, minute=59, second=59) + query = query.where(PracticeSession.start_time <= end_dt) + except ValueError: + pass + + # 按开始时间倒序 + query = query.order_by(PracticeSession.start_time.desc()) + + # 计算总数 + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # 分页查询 + offset = (page - 1) * size + results = await db.execute(query.offset(offset).limit(size)) + + # 构建响应数据 + items = [] + for session, student_name, student_id, total_score in results: + # 查询该学员的所有岗位 + position_query = ( + select(Position.name) + .join(PositionMember, Position.id == PositionMember.position_id) + .where( + PositionMember.user_id == student_id, + PositionMember.is_deleted == False, + Position.is_deleted == False + ) + ) + position_result = await db.execute(position_query) + positions = position_result.scalars().all() + position_str = ', '.join(positions) if positions else None + + # 根据分数计算结果等级 + result_level = "needs_improvement" + if total_score: + if total_score >= 90: + result_level = "excellent" + elif total_score >= 80: + result_level = "good" + elif total_score >= 70: + result_level = "average" + + items.append({ + "id": session.id, + "student_id": student_id, + "student_name": student_name, + "position": position_str, # 所有岗位,逗号分隔 + "session_id": session.session_id, + "scene_name": session.scene_name, + "scene_type": session.scene_type, + "duration_seconds": session.duration_seconds, + "round_count": session.turns, # turns字段表示对话轮数 + "score": total_score, + "result": result_level, + "practice_time": session.start_time.strftime('%Y-%m-%d %H:%M:%S') if session.start_time else None + }) + + # 计算分页信息 + pages = (total + size - 1) // size + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=pages + ) + ) + + except Exception as e: + logger.error(f"获取学员陪练记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员陪练记录失败: {str(e)}", data=None) + + +@router.get("/statistics", response_model=ResponseModel) +async def get_student_practice_statistics( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取学员陪练统计数据 + + 返回: + - total_count: 总陪练次数 + - avg_score: 平均评分 + - total_duration_hours: 总陪练时长(小时) + - excellent_rate: 优秀率 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 查询所有已完成陪练(包括所有角色) + query = ( + select(PracticeSession, PracticeReport.total_score) + .join(User, PracticeSession.user_id == User.id) + .outerjoin( + PracticeReport, + PracticeSession.session_id == PracticeReport.session_id + ) + .where( + PracticeSession.status == 'completed', + PracticeSession.is_deleted == False + ) + ) + + result = await db.execute(query) + records = result.all() + + if not records: + return ResponseModel( + code=200, + message="success", + data={ + "total_count": 0, + "avg_score": 0, + "total_duration_hours": 0, + "excellent_rate": 0 + } + ) + + total_count = len(records) + + # 计算总时长(秒转小时) + total_duration_seconds = sum( + session.duration_seconds for session, _ in records if session.duration_seconds + ) + total_duration_hours = round(total_duration_seconds / 3600, 1) + + # 计算平均分 + scores = [score for _, score in records if score is not None] + avg_score = round(sum(scores) / len(scores), 1) if scores else 0 + + # 计算优秀率(>=90分) + excellent = sum(1 for _, score in records if score and score >= 90) + excellent_rate = round((excellent / total_count) * 100, 1) if total_count > 0 else 0 + + return ResponseModel( + code=200, + message="success", + data={ + "total_count": total_count, + "avg_score": avg_score, + "total_duration_hours": total_duration_hours, + "excellent_rate": excellent_rate + } + ) + + except Exception as e: + logger.error(f"获取学员陪练统计失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员陪练统计失败: {str(e)}", data=None) + + +@router.get("/{session_id}/conversation", response_model=ResponseModel) +async def get_session_conversation( + session_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取指定会话的对话记录 + + 返回对话列表,按sequence排序 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 1. 查询会话是否存在 + session_query = select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.is_deleted == False + ) + session_result = await db.execute(session_query) + session = session_result.scalar_one_or_none() + + if not session: + return ResponseModel(code=404, message="会话不存在", data=None) + + # 2. 查询对话记录 + dialogue_query = ( + select(PracticeDialogue) + .where(PracticeDialogue.session_id == session_id) + .order_by(PracticeDialogue.sequence) + ) + dialogue_result = await db.execute(dialogue_query) + dialogues = dialogue_result.scalars().all() + + # 3. 构建响应数据 + conversation = [] + for dialogue in dialogues: + conversation.append({ + "role": dialogue.speaker, # "user" 或 "ai" + "content": dialogue.content, + "timestamp": dialogue.timestamp.strftime('%Y-%m-%d %H:%M:%S') if dialogue.timestamp else None, + "sequence": dialogue.sequence + }) + + logger.info(f"获取会话对话记录: session_id={session_id}, 对话数={len(conversation)}") + + return ResponseModel( + code=200, + message="success", + data={ + "session_id": session_id, + "conversation": conversation, + "total_count": len(conversation) + } + ) + + except Exception as e: + logger.error(f"获取会话对话记录失败: {e}, session_id={session_id}", exc_info=True) + return ResponseModel(code=500, message=f"获取对话记录失败: {str(e)}", data=None) + diff --git a/backend/app/api/v1/manager/student_scores.py b/backend/app/api/v1/manager/student_scores.py new file mode 100644 index 0000000..1611f38 --- /dev/null +++ b/backend/app/api/v1/manager/student_scores.py @@ -0,0 +1,447 @@ +""" +管理员查看学员考试成绩API +""" +from datetime import datetime +from typing import List, Optional + +from fastapi import APIRouter, Body, Depends, Query +from pydantic import BaseModel +from sqlalchemy import and_, delete, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.deps import get_current_user, get_db +from app.core.logger import logger +from app.models.course import Course +from app.models.exam import Exam +from app.models.exam_mistake import ExamMistake +from app.models.position_member import PositionMember +from app.models.position import Position +from app.models.user import User +from app.schemas.base import PaginatedResponse, ResponseModel + +router = APIRouter(prefix="/manager/student-scores", tags=["manager-student-scores"]) + + +class BatchDeleteRequest(BaseModel): + """批量删除请求""" + ids: List[int] + + +@router.get("/{exam_id}/mistakes", response_model=ResponseModel[PaginatedResponse]) +async def get_exam_mistakes( + exam_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取指定考试的错题记录(管理员和manager可访问) + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 查询错题记录 + query = ( + select(ExamMistake) + .options(selectinload(ExamMistake.question)) + .where(ExamMistake.exam_id == exam_id) + .order_by(ExamMistake.created_at.desc()) + ) + + result = await db.execute(query) + mistakes = result.scalars().all() + + items = [] + for mistake in mistakes: + # 获取解析:优先从关联题目获取,如果是AI生成的题目可能没有关联题目 + analysis = "" + if mistake.question and mistake.question.explanation: + analysis = mistake.question.explanation + + items.append({ + "id": mistake.id, + "question_content": mistake.question_content, + "correct_answer": mistake.correct_answer, + "user_answer": mistake.user_answer, + "question_type": mistake.question_type, + "analysis": analysis, + "created_at": mistake.created_at.strftime('%Y-%m-%d %H:%M:%S') if mistake.created_at else None + }) + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=len(items), + page=1, + page_size=len(items), + pages=1 + ) + ) + + except Exception as e: + logger.error(f"获取错题记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取错题记录失败: {str(e)}", data=None) + + +@router.get("/", response_model=ResponseModel[PaginatedResponse]) +async def get_student_scores( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + student_name: Optional[str] = Query(None, description="学员姓名搜索"), + position: Optional[str] = Query(None, description="岗位筛选"), + course_id: Optional[int] = Query(None, description="课程ID筛选"), + score_range: Optional[str] = Query(None, description="成绩范围: excellent/good/pass/fail"), + start_date: Optional[str] = Query(None, description="开始日期 YYYY-MM-DD"), + end_date: Optional[str] = Query(None, description="结束日期 YYYY-MM-DD"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取所有学员的考试成绩列表(管理员和manager可访问) + + 支持筛选: + - student_name: 按学员姓名模糊搜索 + - position: 按岗位筛选 + - course_id: 按课程筛选 + - score_range: 按成绩范围筛选(excellent>=90, good>=80, pass>=60, fail<60) + - start_date/end_date: 按日期范围筛选 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 构建基础查询 + # 关联User、Course、ExamMistake来获取完整信息 + query = ( + select( + Exam, + User.full_name.label('student_name'), + User.id.label('student_id'), + Course.name.label('course_name'), + func.count(ExamMistake.id).label('wrong_count') + ) + .join(User, Exam.user_id == User.id) + .join(Course, Exam.course_id == Course.id) + .outerjoin(ExamMistake, and_( + ExamMistake.exam_id == Exam.id, + ExamMistake.user_id == User.id + )) + .where( + Exam.status.in_(['completed', 'submitted']) # 只查询已完成的考试 + ) + .group_by(Exam.id, User.id, User.full_name, Course.id, Course.name) + ) + + # 学员姓名筛选 + if student_name: + query = query.where(User.full_name.contains(student_name)) + + # 岗位筛选 + if position: + # 通过position_members关联查询 + query = query.join( + PositionMember, + and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False + ) + ).join( + Position, + Position.id == PositionMember.position_id + ).where( + Position.name == position + ) + + # 课程筛选 + if course_id: + query = query.where(Exam.course_id == course_id) + + # 成绩范围筛选 + if score_range: + score_field = Exam.round1_score # 使用第一轮成绩 + if score_range == 'excellent': + query = query.where(score_field >= 90) + elif score_range == 'good': + query = query.where(and_(score_field >= 80, score_field < 90)) + elif score_range == 'pass': + query = query.where(and_(score_field >= 60, score_field < 80)) + elif score_range == 'fail': + query = query.where(score_field < 60) + + # 日期范围筛选 + if start_date: + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + query = query.where(Exam.created_at >= start_dt) + except ValueError: + pass + + if end_date: + try: + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + end_dt = end_dt.replace(hour=23, minute=59, second=59) + query = query.where(Exam.created_at <= end_dt) + except ValueError: + pass + + # 按创建时间倒序 + query = query.order_by(Exam.created_at.desc()) + + # 计算总数 + count_query = select(func.count()).select_from(query.subquery()) + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # 分页查询 + offset = (page - 1) * size + results = await db.execute(query.offset(offset).limit(size)) + + # 构建响应数据 + items = [] + for exam, student_name, student_id, course_name, wrong_count in results: + # 查询该学员的所有岗位 + position_query = ( + select(Position.name) + .join(PositionMember, Position.id == PositionMember.position_id) + .where( + PositionMember.user_id == student_id, + PositionMember.is_deleted == False, + Position.is_deleted == False + ) + ) + position_result = await db.execute(position_query) + positions = position_result.scalars().all() + position_str = ', '.join(positions) if positions else None + + # 计算正确率和用时 + accuracy = None + correct_count = None + duration_seconds = None + + if exam.question_count and exam.question_count > 0: + correct_count = exam.question_count - wrong_count + accuracy = round((correct_count / exam.question_count) * 100, 1) + + if exam.start_time and exam.end_time: + duration_seconds = int((exam.end_time - exam.start_time).total_seconds()) + + items.append({ + "id": exam.id, + "student_id": student_id, + "student_name": student_name, + "position": position_str, # 所有岗位,逗号分隔 + "course_id": exam.course_id, + "course_name": course_name, + "exam_type": "assessment", # 简化处理,统一为assessment + "score": float(exam.round1_score) if exam.round1_score else 0, + "round1_score": float(exam.round1_score) if exam.round1_score else None, + "round2_score": float(exam.round2_score) if exam.round2_score else None, + "round3_score": float(exam.round3_score) if exam.round3_score else None, + "total_score": float(exam.total_score) if exam.total_score else 100, + "accuracy": accuracy, + "correct_count": correct_count, + "wrong_count": wrong_count, + "total_count": exam.question_count, + "duration_seconds": duration_seconds, + "exam_date": exam.created_at.strftime('%Y-%m-%d %H:%M:%S') if exam.created_at else None + }) + + # 计算分页信息 + pages = (total + size - 1) // size + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=pages + ) + ) + + except Exception as e: + logger.error(f"获取学员考试成绩失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员考试成绩失败: {str(e)}", data=None) + + +@router.get("/statistics", response_model=ResponseModel) +async def get_student_scores_statistics( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取学员考试成绩统计数据 + + 返回: + - total_exams: 总考试次数 + - avg_score: 平均分 + - pass_rate: 通过率 + - excellent_rate: 优秀率 + """ + try: + # 权限检查 + if current_user.role not in ['admin', 'manager']: + return ResponseModel(code=403, message="无权访问", data=None) + + # 查询所有用户的已完成考试 + query = ( + select(Exam) + .join(User, Exam.user_id == User.id) + .where( + Exam.status.in_(['completed', 'submitted']), + Exam.round1_score.isnot(None) + ) + ) + + result = await db.execute(query) + exams = result.scalars().all() + + if not exams: + return ResponseModel( + code=200, + message="success", + data={ + "total_exams": 0, + "avg_score": 0, + "pass_rate": 0, + "excellent_rate": 0 + } + ) + + total_exams = len(exams) + total_score = sum(exam.round1_score for exam in exams if exam.round1_score) + avg_score = round(total_score / total_exams, 1) if total_exams > 0 else 0 + + passed = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 60) + pass_rate = round((passed / total_exams) * 100, 1) if total_exams > 0 else 0 + + excellent = sum(1 for exam in exams if exam.round1_score and exam.round1_score >= 90) + excellent_rate = round((excellent / total_exams) * 100, 1) if total_exams > 0 else 0 + + return ResponseModel( + code=200, + message="success", + data={ + "total_exams": total_exams, + "avg_score": avg_score, + "pass_rate": pass_rate, + "excellent_rate": excellent_rate + } + ) + + except Exception as e: + logger.error(f"获取学员考试成绩统计失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学员考试成绩统计失败: {str(e)}", data=None) + + +@router.delete("/{exam_id}", response_model=ResponseModel) +async def delete_exam_record( + exam_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 删除单条考试记录(管理员可访问) + + 会同时删除关联的错题记录 + """ + try: + # 权限检查 - 仅管理员可删除 + if current_user.role != 'admin': + return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None) + + # 查询考试记录 + result = await db.execute( + select(Exam).where(Exam.id == exam_id) + ) + exam = result.scalar_one_or_none() + + if not exam: + return ResponseModel(code=404, message="考试记录不存在", data=None) + + # 删除关联的错题记录 + await db.execute( + delete(ExamMistake).where(ExamMistake.exam_id == exam_id) + ) + + # 删除考试记录 + await db.delete(exam) + await db.commit() + + logger.info(f"管理员 {current_user.username} 删除了考试记录 {exam_id}") + + return ResponseModel( + code=200, + message="考试记录已删除", + data={"deleted_id": exam_id} + ) + + except Exception as e: + await db.rollback() + logger.error(f"删除考试记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"删除考试记录失败: {str(e)}", data=None) + + +@router.delete("/batch/delete", response_model=ResponseModel) +async def batch_delete_exam_records( + request: BatchDeleteRequest, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 批量删除考试记录(管理员可访问) + + 会同时删除关联的错题记录 + """ + try: + # 权限检查 - 仅管理员可删除 + if current_user.role != 'admin': + return ResponseModel(code=403, message="无权操作,仅管理员可删除考试记录", data=None) + + if not request.ids: + return ResponseModel(code=400, message="请选择要删除的记录", data=None) + + # 查询存在的考试记录 + result = await db.execute( + select(Exam.id).where(Exam.id.in_(request.ids)) + ) + existing_ids = [row[0] for row in result.all()] + + if not existing_ids: + return ResponseModel(code=404, message="未找到要删除的记录", data=None) + + # 删除关联的错题记录 + await db.execute( + delete(ExamMistake).where(ExamMistake.exam_id.in_(existing_ids)) + ) + + # 删除考试记录 + await db.execute( + delete(Exam).where(Exam.id.in_(existing_ids)) + ) + await db.commit() + + deleted_count = len(existing_ids) + logger.info(f"管理员 {current_user.username} 批量删除了 {deleted_count} 条考试记录") + + return ResponseModel( + code=200, + message=f"成功删除 {deleted_count} 条考试记录", + data={ + "deleted_count": deleted_count, + "deleted_ids": existing_ids + } + ) + + except Exception as e: + await db.rollback() + logger.error(f"批量删除考试记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"批量删除考试记录失败: {str(e)}", data=None) + diff --git a/backend/app/api/v1/notifications.py b/backend/app/api/v1/notifications.py new file mode 100644 index 0000000..39b36df --- /dev/null +++ b/backend/app/api/v1/notifications.py @@ -0,0 +1,255 @@ +""" +站内消息通知 API +提供通知的查询、标记已读、删除等功能 +""" +import logging +from typing import Optional, List +from fastapi import APIRouter, Depends, HTTPException, 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.schemas.base import ResponseModel +from app.schemas.notification import ( + NotificationCreate, + NotificationBatchCreate, + NotificationResponse, + NotificationListResponse, + NotificationCountResponse, + MarkReadRequest, +) +from app.services.notification_service import notification_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/notifications") + + +@router.get("", response_model=ResponseModel[NotificationListResponse]) +async def get_notifications( + is_read: Optional[bool] = Query(None, description="是否已读筛选"), + type: Optional[str] = Query(None, description="通知类型筛选"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户的通知列表 + + 支持按已读状态和通知类型筛选 + """ + try: + skip = (page - 1) * page_size + + notifications, total, unread_count = await notification_service.get_user_notifications( + db=db, + user_id=current_user.id, + skip=skip, + limit=page_size, + is_read=is_read, + notification_type=type + ) + + response_data = NotificationListResponse( + items=notifications, + total=total, + unread_count=unread_count + ) + + return ResponseModel( + code=200, + message="获取通知列表成功", + data=response_data + ) + + except Exception as e: + logger.error(f"获取通知列表失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取通知列表失败: {str(e)}") + + +@router.get("/unread-count", response_model=ResponseModel[NotificationCountResponse]) +async def get_unread_count( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取当前用户的未读通知数量 + + 用于顶部导航栏显示未读消息数 + """ + try: + unread_count, total = await notification_service.get_unread_count( + db=db, + user_id=current_user.id + ) + + return ResponseModel( + code=200, + message="获取未读数量成功", + data=NotificationCountResponse( + unread_count=unread_count, + total=total + ) + ) + + except Exception as e: + logger.error(f"获取未读数量失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取未读数量失败: {str(e)}") + + +@router.post("/mark-read", response_model=ResponseModel) +async def mark_notifications_read( + request: MarkReadRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 标记通知为已读 + + - 传入 notification_ids 则标记指定通知 + - 不传则标记全部未读通知为已读 + """ + try: + updated_count = await notification_service.mark_as_read( + db=db, + user_id=current_user.id, + notification_ids=request.notification_ids + ) + + return ResponseModel( + code=200, + message=f"成功标记 {updated_count} 条通知为已读", + data={"updated_count": updated_count} + ) + + except Exception as e: + logger.error(f"标记已读失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"标记已读失败: {str(e)}") + + +@router.delete("/{notification_id}", response_model=ResponseModel) +async def delete_notification( + notification_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 删除单条通知 + + 只能删除自己的通知 + """ + try: + success = await notification_service.delete_notification( + db=db, + user_id=current_user.id, + notification_id=notification_id + ) + + if not success: + raise HTTPException(status_code=404, detail="通知不存在或无权删除") + + return ResponseModel( + code=200, + message="删除通知成功", + data={"deleted": True} + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"删除通知失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"删除通知失败: {str(e)}") + + +# ==================== 管理员接口 ==================== + +@router.post("/send", response_model=ResponseModel[NotificationResponse]) +async def send_notification( + notification_in: NotificationCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 发送单条通知(管理员接口) + + 向指定用户发送通知 + """ + try: + # 权限检查:仅管理员和管理者可发送通知 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限发送通知") + + # 设置发送者 + notification_in.sender_id = current_user.id + + notification = await notification_service.create_notification( + db=db, + notification_in=notification_in + ) + + # 构建响应 + 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=current_user.full_name, + created_at=notification.created_at, + updated_at=notification.updated_at + ) + + return ResponseModel( + code=200, + message="发送通知成功", + data=response + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"发送通知失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"发送通知失败: {str(e)}") + + +@router.post("/send-batch", response_model=ResponseModel) +async def send_batch_notifications( + batch_in: NotificationBatchCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 批量发送通知(管理员接口) + + 向多个用户发送相同的通知 + """ + try: + # 权限检查:仅管理员和管理者可发送通知 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限发送通知") + + # 设置发送者 + batch_in.sender_id = current_user.id + + notifications = await notification_service.batch_create_notifications( + db=db, + batch_in=batch_in + ) + + return ResponseModel( + code=200, + message=f"成功发送 {len(notifications)} 条通知", + data={"sent_count": len(notifications)} + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"批量发送通知失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"批量发送通知失败: {str(e)}") + diff --git a/backend/app/api/v1/positions.py b/backend/app/api/v1/positions.py new file mode 100644 index 0000000..20884f3 --- /dev/null +++ b/backend/app/api/v1/positions.py @@ -0,0 +1,658 @@ +""" +岗位管理 API(真实数据库) +""" + +from typing import Optional, List +from fastapi import APIRouter, Depends, Query, HTTPException +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, func +from sqlalchemy.orm import selectinload +import sqlalchemy as sa + +from app.core.deps import get_current_active_user as get_current_user, get_db, require_admin, require_admin_or_manager +from app.schemas.base import ResponseModel, PaginationParams, PaginatedResponse +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.models.user import User +from app.models.course import Course + + +router = APIRouter(prefix="/admin/positions") + + +@router.get("") +async def list_positions( + pagination: PaginationParams = Depends(), + keyword: Optional[str] = Query(None, description="关键词"), + current_user=Depends(require_admin_or_manager), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """分页获取岗位列表(管理员或经理)。""" + stmt = select(Position).where(Position.is_deleted == False) + if keyword: + like = f"%{keyword}%" + stmt = stmt.where((Position.name.ilike(like)) | (Position.description.ilike(like))) + rows = (await db.execute(stmt)).scalars().all() + total = len(rows) + sliced = rows[pagination.offset : pagination.offset + pagination.limit] + + async def to_dict(p: Position) -> dict: + """将Position对象转换为字典,并添加统计数据""" + d = p.__dict__.copy() + d.pop("_sa_instance_state", None) + + # 统计岗位成员数量 + member_count_result = await db.execute( + select(func.count(PositionMember.id)).where( + and_( + PositionMember.position_id == p.id, + PositionMember.is_deleted == False + ) + ) + ) + d["memberCount"] = member_count_result.scalar() or 0 + + # 统计必修课程数量 + required_count_result = await db.execute( + select(func.count(PositionCourse.id)).where( + and_( + PositionCourse.position_id == p.id, + PositionCourse.course_type == "required", + PositionCourse.is_deleted == False + ) + ) + ) + d["requiredCourses"] = required_count_result.scalar() or 0 + + # 统计选修课程数量 + optional_count_result = await db.execute( + select(func.count(PositionCourse.id)).where( + and_( + PositionCourse.position_id == p.id, + PositionCourse.course_type == "optional", + PositionCourse.is_deleted == False + ) + ) + ) + d["optionalCourses"] = optional_count_result.scalar() or 0 + + return d + + # 为每个岗位添加统计数据(使用异步) + items = [] + for p in sliced: + item = await to_dict(p) + items.append(item) + + paged = { + "items": items, + "total": total, + "page": pagination.page, + "page_size": pagination.page_size, + "pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1, + } + return ResponseModel(message="获取岗位列表成功", data=paged) + + +@router.get("/tree") +async def get_position_tree( + current_user=Depends(require_admin_or_manager), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """获取岗位树(管理员或经理)。""" + rows = (await db.execute(select(Position).where(Position.is_deleted == False))).scalars().all() + id_to_node = {p.id: {**p.__dict__, "children": []} for p in rows} + roots: List[dict] = [] + for p in rows: + node = id_to_node[p.id] + parent_id = p.parent_id + if parent_id and parent_id in id_to_node: + id_to_node[parent_id]["children"].append(node) + else: + roots.append(node) + # 清理 _sa_instance_state + def clean(d: dict): + d.pop("_sa_instance_state", None) + for c in d.get("children", []): + clean(c) + for r in roots: + clean(r) + return ResponseModel(message="获取岗位树成功", data=roots) + + +@router.post("") +async def create_position( + payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = Position( + name=payload.get("name"), + code=payload.get("code"), + description=payload.get("description"), + parent_id=payload.get("parentId"), + status=payload.get("status", "active"), + skills=payload.get("skills"), + level=payload.get("level"), + sort_order=payload.get("sort_order", 0), + created_by=current_user.id, + ) + db.add(obj) + await db.commit() + await db.refresh(obj) + return ResponseModel(message="创建岗位成功", data={"id": obj.id}) + + +@router.put("/{position_id}") +async def update_position( + position_id: int, payload: dict, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + obj.name = payload.get("name", obj.name) + obj.code = payload.get("code", obj.code) + obj.description = payload.get("description", obj.description) + obj.parent_id = payload.get("parentId", obj.parent_id) + obj.status = payload.get("status", obj.status) + obj.skills = payload.get("skills", obj.skills) + obj.level = payload.get("level", obj.level) + obj.sort_order = payload.get("sort_order", obj.sort_order) + obj.updated_by = current_user.id + await db.commit() + await db.refresh(obj) + + # 返回更新后的完整数据 + data = obj.__dict__.copy() + data.pop("_sa_instance_state", None) + return ResponseModel(message="更新岗位成功", data=data) + + +@router.get("/{position_id}") +async def get_position_detail( + position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + data = obj.__dict__.copy() + data.pop("_sa_instance_state", None) + return ResponseModel(data=data) + + +@router.get("/{position_id}/check-delete") +async def check_position_delete( + position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 检查是否有子岗位 + child_count_result = await db.execute( + select(func.count(Position.id)).where( + and_( + Position.parent_id == position_id, + Position.is_deleted == False + ) + ) + ) + child_count = child_count_result.scalar() or 0 + + if child_count > 0: + return ResponseModel(data={ + "deletable": False, + "reason": f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位" + }) + + # 检查是否有成员(仅作为提醒,不阻止删除) + member_count_result = await db.execute( + select(func.count(PositionMember.id)).where( + and_( + PositionMember.position_id == position_id, + PositionMember.is_deleted == False + ) + ) + ) + member_count = member_count_result.scalar() or 0 + + warning = "" + if member_count > 0: + warning = f"注意:该岗位当前有 {member_count} 名成员,删除后这些成员将不再属于此岗位" + + return ResponseModel(data={"deletable": True, "reason": "", "warning": warning, "member_count": member_count}) + + +@router.delete("/{position_id}") +async def delete_position( + position_id: int, current_user=Depends(require_admin), db: AsyncSession = Depends(get_db) +) -> ResponseModel: + obj = await db.get(Position, position_id) + if not obj or obj.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 检查是否有子岗位 + child_count_result = await db.execute( + select(func.count(Position.id)).where( + and_( + Position.parent_id == position_id, + Position.is_deleted == False + ) + ) + ) + child_count = child_count_result.scalar() or 0 + + if child_count > 0: + return ResponseModel( + code=400, + message=f"该岗位下有 {child_count} 个子岗位,请先删除或移动子岗位" + ) + + # 软删除岗位成员关联 + await db.execute( + sa.update(PositionMember) + .where(PositionMember.position_id == position_id) + .values(is_deleted=True) + ) + + # 软删除岗位课程关联 + await db.execute( + sa.update(PositionCourse) + .where(PositionCourse.position_id == position_id) + .values(is_deleted=True) + ) + + # 软删除岗位 + obj.is_deleted = True + await db.commit() + return ResponseModel(message="岗位已删除") + + +# ========== 岗位成员管理 API ========== + +@router.get("/{position_id}/members") +async def get_position_members( + position_id: int, + pagination: PaginationParams = Depends(), + keyword: Optional[str] = Query(None, description="搜索关键词"), + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """获取岗位成员列表""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 构建查询 + stmt = ( + select(PositionMember, User) + .join(User, PositionMember.user_id == User.id) + .where( + and_( + PositionMember.position_id == position_id, + PositionMember.is_deleted == False, + User.is_deleted == False + ) + ) + ) + + # 关键词搜索 + if keyword: + like = f"%{keyword}%" + stmt = stmt.where( + (User.username.ilike(like)) | + (User.full_name.ilike(like)) | + (User.email.ilike(like)) + ) + + # 执行查询 + result = await db.execute(stmt) + rows = result.all() + total = len(rows) + sliced = rows[pagination.offset : pagination.offset + pagination.limit] + + # 格式化数据 + items = [] + for pm, user in sliced: + items.append({ + "id": pm.id, + "user_id": user.id, + "username": user.username, + "full_name": user.full_name, + "email": user.email, + "phone": user.phone, + "role": pm.role, + "joined_at": pm.joined_at.isoformat() if pm.joined_at else None, + "user_role": user.role, # 系统角色 + "is_active": user.is_active, + }) + + return ResponseModel( + message="获取成员列表成功", + data={ + "items": items, + "total": total, + "page": pagination.page, + "page_size": pagination.page_size, + "pages": (total + pagination.page_size - 1) // pagination.page_size if pagination.page_size else 1, + } + ) + + +@router.post("/{position_id}/members") +async def add_position_members( + position_id: int, + payload: dict, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """批量添加岗位成员""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + user_ids = payload.get("user_ids", []) + if not user_ids: + return ResponseModel(code=400, message="请选择要添加的用户") + + # 验证用户存在 + users = await db.execute( + select(User).where( + and_( + User.id.in_(user_ids), + User.is_deleted == False + ) + ) + ) + valid_users = {u.id: u for u in users.scalars().all()} + + if len(valid_users) != len(user_ids): + invalid_ids = set(user_ids) - set(valid_users.keys()) + return ResponseModel(code=400, message=f"部分用户不存在: {invalid_ids}") + + # 检查是否已存在 + existing = await db.execute( + select(PositionMember).where( + and_( + PositionMember.position_id == position_id, + PositionMember.user_id.in_(user_ids), + PositionMember.is_deleted == False + ) + ) + ) + existing_user_ids = {pm.user_id for pm in existing.scalars().all()} + + # 添加新成员 + added_count = 0 + for user_id in user_ids: + if user_id not in existing_user_ids: + member = PositionMember( + position_id=position_id, + user_id=user_id, + role=payload.get("role") + ) + db.add(member) + added_count += 1 + + await db.commit() + + return ResponseModel( + message=f"成功添加 {added_count} 个成员", + data={"added_count": added_count} + ) + + +@router.delete("/{position_id}/members/{user_id}") +async def remove_position_member( + position_id: int, + user_id: int, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """移除岗位成员""" + # 查找成员关系 + member = await db.execute( + select(PositionMember).where( + and_( + PositionMember.position_id == position_id, + PositionMember.user_id == user_id, + PositionMember.is_deleted == False + ) + ) + ) + member = member.scalar_one_or_none() + + if not member: + return ResponseModel(code=404, message="成员关系不存在") + + # 软删除 + member.is_deleted = True + await db.commit() + + return ResponseModel(message="成员已移除") + + +# ========== 岗位课程管理 API ========== + +@router.get("/{position_id}/courses") +async def get_position_courses( + position_id: int, + course_type: Optional[str] = Query(None, description="课程类型:required/optional"), + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """获取岗位课程列表""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + # 构建查询 + stmt = ( + select(PositionCourse, Course) + .join(Course, PositionCourse.course_id == Course.id) + .where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False, + Course.is_deleted == False + ) + ) + ) + + # 课程类型筛选 + if course_type: + stmt = stmt.where(PositionCourse.course_type == course_type) + + # 按优先级排序 + stmt = stmt.order_by(PositionCourse.priority, PositionCourse.id) + + # 执行查询 + result = await db.execute(stmt) + rows = result.all() + + # 格式化数据 + items = [] + for pc, course in rows: + items.append({ + "id": pc.id, + "course_id": course.id, + "course_name": course.name, + "course_description": course.description, + "course_category": course.category, + "course_status": course.status, + "course_duration_hours": course.duration_hours, + "course_difficulty_level": course.difficulty_level, + "course_type": pc.course_type, + "priority": pc.priority, + "created_at": pc.created_at.isoformat() if pc.created_at else None, + }) + + # 统计 + stats = { + "total": len(items), + "required_count": sum(1 for item in items if item["course_type"] == "required"), + "optional_count": sum(1 for item in items if item["course_type"] == "optional"), + } + + return ResponseModel( + message="获取课程列表成功", + data={ + "items": items, + "stats": stats + } + ) + + +@router.post("/{position_id}/courses") +async def add_position_courses( + position_id: int, + payload: dict, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """批量添加岗位课程""" + # 验证岗位存在 + position = await db.get(Position, position_id) + if not position or position.is_deleted: + return ResponseModel(code=404, message="岗位不存在") + + course_ids = payload.get("course_ids", []) + if not course_ids: + return ResponseModel(code=400, message="请选择要添加的课程") + + course_type = payload.get("course_type", "required") + if course_type not in ["required", "optional"]: + return ResponseModel(code=400, message="课程类型无效") + + # 验证课程存在 + courses = await db.execute( + select(Course).where( + and_( + Course.id.in_(course_ids), + Course.is_deleted == False + ) + ) + ) + valid_courses = {c.id: c for c in courses.scalars().all()} + + if len(valid_courses) != len(course_ids): + invalid_ids = set(course_ids) - set(valid_courses.keys()) + return ResponseModel(code=400, message=f"部分课程不存在: {invalid_ids}") + + # 检查是否已存在 + existing = await db.execute( + select(PositionCourse).where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.course_id.in_(course_ids), + PositionCourse.is_deleted == False + ) + ) + ) + existing_course_ids = {pc.course_id for pc in existing.scalars().all()} + + # 获取当前最大优先级 + max_priority_result = await db.execute( + select(sa.func.max(PositionCourse.priority)).where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False + ) + ) + ) + max_priority = max_priority_result.scalar() or 0 + + # 添加新课程 + added_count = 0 + for idx, course_id in enumerate(course_ids): + if course_id not in existing_course_ids: + pc = PositionCourse( + position_id=position_id, + course_id=course_id, + course_type=course_type, + priority=max_priority + idx + 1, + ) + db.add(pc) + added_count += 1 + + await db.commit() + + return ResponseModel( + message=f"成功添加 {added_count} 门课程", + data={"added_count": added_count} + ) + + +@router.put("/{position_id}/courses/{pc_id}") +async def update_position_course( + position_id: int, + pc_id: int, + payload: dict, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """更新岗位课程设置""" + # 查找课程关系 + pc = await db.execute( + select(PositionCourse).where( + and_( + PositionCourse.id == pc_id, + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False + ) + ) + ) + pc = pc.scalar_one_or_none() + + if not pc: + return ResponseModel(code=404, message="课程关系不存在") + + # 更新课程类型 + if "course_type" in payload: + course_type = payload["course_type"] + if course_type not in ["required", "optional"]: + return ResponseModel(code=400, message="课程类型无效") + pc.course_type = course_type + + # 更新优先级 + if "priority" in payload: + pc.priority = payload["priority"] + + # PositionCourse 未继承审计字段,避免写入不存在字段 + await db.commit() + + return ResponseModel(message="更新成功") + + +@router.delete("/{position_id}/courses/{course_id}") +async def remove_position_course( + position_id: int, + course_id: int, + current_user=Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """移除岗位课程""" + # 查找课程关系 + pc = await db.execute( + select(PositionCourse).where( + and_( + PositionCourse.position_id == position_id, + PositionCourse.course_id == course_id, + PositionCourse.is_deleted == False + ) + ) + ) + pc = pc.scalar_one_or_none() + + if not pc: + return ResponseModel(code=404, message="课程关系不存在") + + # 软删除 + pc.is_deleted = True + # PositionCourse 未继承审计字段,避免写入不存在字段 + await db.commit() + + return ResponseModel(message="课程已移除") + + diff --git a/backend/app/api/v1/practice.py b/backend/app/api/v1/practice.py new file mode 100644 index 0000000..b89d737 --- /dev/null +++ b/backend/app/api/v1/practice.py @@ -0,0 +1,1139 @@ +""" +陪练功能API +""" +from typing import Optional +import json +from datetime import datetime, timedelta +from fastapi import APIRouter, Depends, Query, HTTPException +from fastapi.responses import StreamingResponse +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from cozepy import ChatEventType +from cozepy.exception import CozeError, CozeAPIError + +from app.core.deps import get_db, get_current_user +from app.models.user import User +from app.models.practice import PracticeScene, PracticeSession, PracticeDialogue, PracticeReport +from app.schemas.practice import ( + PracticeSceneResponse, + PracticeSceneCreate, + PracticeSceneUpdate, + StartPracticeRequest, + InterruptPracticeRequest, + ConversationsResponse, + ExtractSceneRequest, + ExtractSceneResponse, + ExtractedSceneData, + PracticeSessionCreate, + PracticeSessionResponse, + SaveDialogueRequest, + PracticeDialogueResponse, + PracticeReportResponse, + PracticeAnalysisResult +) +from app.schemas.base import ResponseModel, PaginatedResponse +from app.services.coze_service import get_coze_service, CozeService +from app.services.ai.coze.client import get_auth_manager +from app.services.ai.practice_analysis_service import practice_analysis_service +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.get("/coze-token") +async def get_coze_token( + current_user: User = Depends(get_current_user) +): + """ + 获取Coze OAuth Token用于前端直连WebSocket + + 前端语音对话需要直连Coze WebSocket,但不能暴露私钥, + 因此通过此接口从后端获取临时Token + """ + try: + auth_manager = get_auth_manager() + token = auth_manager.get_oauth_token() + + return ResponseModel( + code=200, + message="Token获取成功", + data={"token": token} + ) + except Exception as e: + logger.error(f"获取Coze Token失败: {e}") + raise HTTPException(status_code=500, detail=f"获取Token失败: {str(e)}") + + +@router.get("/scenes", response_model=ResponseModel[PaginatedResponse[PracticeSceneResponse]]) +async def get_practice_scenes( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + type: Optional[str] = Query(None, description="场景类型筛选"), + difficulty: Optional[str] = Query(None, description="难度筛选"), + search: Optional[str] = Query(None, description="关键词搜索(名称、描述)"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取可用陪练场景列表 + + 仅返回status=active且未删除的场景 + 支持分页、筛选和搜索 + """ + # 构建查询 + query = select(PracticeScene).where( + PracticeScene.is_deleted == False, + PracticeScene.status == "active" + ) + + # 类型筛选 + if type: + query = query.where(PracticeScene.type == type) + + # 难度筛选 + if difficulty: + query = query.where(PracticeScene.difficulty == difficulty) + + # 关键词搜索(搜索名称和描述) + if search: + search_pattern = f"%{search}%" + query = query.where( + or_( + PracticeScene.name.like(search_pattern), + PracticeScene.description.like(search_pattern) + ) + ) + + # 查询总数 + count_query = select(func.count()).select_from(query.subquery()) + total = await db.scalar(count_query) + + # 分页查询 + query = query.offset((page - 1) * size).limit(size).order_by(PracticeScene.created_at.desc()) + result = await db.scalars(query) + scenes = list(result.all()) + + logger.info( + f"用户{current_user.id}查询陪练场景列表," + f"类型={type}, 难度={difficulty}, 搜索={search}, " + f"返回{len(scenes)}条记录" + ) + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=scenes, + total=total or 0, + page=page, + page_size=size, + pages=(total + size - 1) // size if total else 0 + ) + ) + + +@router.get("/scenes/{scene_id}", response_model=ResponseModel[PracticeSceneResponse]) +async def get_practice_scene_detail( + scene_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练场景详情 + + 返回指定ID的场景完整信息 + """ + # 查询场景 + result = await db.execute( + select(PracticeScene).where( + PracticeScene.id == scene_id, + PracticeScene.is_deleted == False, + PracticeScene.status == "active" + ) + ) + scene = result.scalar_one_or_none() + + if not scene: + logger.warning(f"用户{current_user.id}查询场景{scene_id}不存在或已禁用") + raise HTTPException(status_code=404, detail="场景不存在或已禁用") + + logger.info(f"用户{current_user.id}查询场景{scene_id}详情") + + return ResponseModel(code=200, message="success", data=scene) + + +@router.post("/scenes", response_model=ResponseModel[PracticeSceneResponse]) +async def create_practice_scene( + scene_data: PracticeSceneCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 创建陪练场景 + + 仅管理员和经理可以创建场景 + """ + # 权限检查 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限创建陪练场景") + + # 创建场景 + scene = PracticeScene( + **scene_data.model_dump(), + created_by=current_user.id, + updated_by=current_user.id + ) + + db.add(scene) + await db.commit() + await db.refresh(scene) + + logger.info(f"用户{current_user.id}创建陪练场景: {scene.name} (ID: {scene.id})") + + return ResponseModel( + code=200, + message="场景创建成功", + data=scene + ) + + +@router.put("/scenes/{scene_id}", response_model=ResponseModel[PracticeSceneResponse]) +async def update_practice_scene( + scene_id: int, + scene_data: PracticeSceneUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 更新陪练场景 + + 仅管理员和经理可以更新场景 + """ + # 权限检查 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限更新陪练场景") + + # 查询场景 + result = await db.execute( + select(PracticeScene).where( + PracticeScene.id == scene_id, + PracticeScene.is_deleted == False + ) + ) + scene = result.scalar_one_or_none() + + if not scene: + raise HTTPException(status_code=404, detail="场景不存在") + + # 更新字段 + update_data = scene_data.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(scene, field, value) + + scene.updated_by = current_user.id + + await db.commit() + await db.refresh(scene) + + logger.info(f"用户{current_user.id}更新陪练场景: {scene.name} (ID: {scene.id})") + + return ResponseModel( + code=200, + message="场景更新成功", + data=scene + ) + + +@router.delete("/scenes/{scene_id}", response_model=ResponseModel) +async def delete_practice_scene( + scene_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 删除陪练场景(软删除) + + 仅管理员和经理可以删除场景 + """ + # 权限检查 + if current_user.role not in ["admin", "manager"]: + raise HTTPException(status_code=403, detail="无权限删除陪练场景") + + # 查询场景 + result = await db.execute( + select(PracticeScene).where( + PracticeScene.id == scene_id, + PracticeScene.is_deleted == False + ) + ) + scene = result.scalar_one_or_none() + + if not scene: + raise HTTPException(status_code=404, detail="场景不存在") + + # 软删除 + scene.is_deleted = True + scene.updated_by = current_user.id + + await db.commit() + + logger.info(f"用户{current_user.id}删除陪练场景: {scene.name} (ID: {scene.id})") + + return ResponseModel( + code=200, + message="场景删除成功", + data={"scene_id": scene_id} + ) + + +@router.post("/start") +async def start_practice( + request: StartPracticeRequest, + current_user: User = Depends(get_current_user), + coze_service: CozeService = Depends(get_coze_service) +): + """ + 开始陪练对话(SSE流式返回) + + ⚠️ 核心功能: + - 首次消息(is_first=true):构建完整场景提示词发送给Coze + - 后续消息(is_first=false):仅发送用户消息 + - 使用conversation_id保持对话上下文 + """ + logger.info( + f"用户{current_user.id}开始陪练对话," + f"场景={request.scene_name}, " + f"is_first={request.is_first}, " + f"conversation_id={request.conversation_id}" + ) + + # 构建发送给Coze的消息 + if request.is_first: + # 首次消息:构建完整场景提示词 + message = coze_service.build_scene_prompt( + scene_name=request.scene_name, + scene_background=request.scene_background, + scene_ai_role=request.scene_ai_role, + scene_objectives=request.scene_objectives, + scene_keywords=request.scene_keywords, + scene_description=request.scene_description, + user_message=request.user_message + ) + logger.debug(f"场景提示词已构建,长度={len(message)}字符") + else: + # 后续消息:仅发送用户输入 + message = request.user_message + logger.debug(f"用户消息: {message}") + + def generate_stream(): + """SSE流式生成器""" + try: + # 创建Coze流式对话 + stream = coze_service.create_stream_chat( + user_id=str(current_user.id), + message=message, + conversation_id=request.conversation_id + ) + + # 处理Coze事件流 + for event in stream: + # 对话创建事件 + if event.event == ChatEventType.CONVERSATION_CHAT_CREATED: + # 优先使用请求中的conversation_id(续接对话) + # 如果没有,使用Coze返回的新对话ID(首次对话) + final_conversation_id = request.conversation_id or event.chat.conversation_id + event_data = { + "conversation_id": final_conversation_id, + "chat_id": event.chat.id + } + yield f"event: conversation.chat.created\ndata: {json.dumps(event_data)}\n\n" + logger.debug(f"对话已创建/续接: conversation_id={final_conversation_id}, 来源={'请求参数' if request.conversation_id else 'Coze创建'}") + + # 消息增量事件(实时打字效果) + elif event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + event_data = {"content": event.message.content} + yield f"event: message.delta\ndata: {json.dumps(event_data)}\n\n" + + # 消息完成事件 + elif event.event == ChatEventType.CONVERSATION_MESSAGE_COMPLETED: + event_data = {} # 不需要返回完整内容,前端已通过delta累积 + yield f"event: message.completed\ndata: {json.dumps(event_data)}\n\n" + logger.info(f"消息已完成") + + # 对话完成事件 + elif event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED: + # 安全地获取token用量 + token_count = 0 + input_count = 0 + output_count = 0 + if hasattr(event.chat, 'usage') and event.chat.usage: + token_count = getattr(event.chat.usage, 'token_count', 0) + input_count = getattr(event.chat.usage, 'input_count', 0) + output_count = getattr(event.chat.usage, 'output_count', 0) + + event_data = { + "token_count": token_count, + "input_count": input_count, + "output_count": output_count + } + yield f"event: conversation.completed\ndata: {json.dumps(event_data)}\n\n" + logger.info(f"对话已完成,Token用量={event_data['token_count']}") + break + + # 对话失败事件 + elif event.event == ChatEventType.CONVERSATION_CHAT_FAILED: + error_msg = str(event.chat.last_error) if event.chat.last_error else "对话失败" + event_data = {"error": error_msg} + yield f"event: error\ndata: {json.dumps(event_data)}\n\n" + logger.error(f"对话失败: {error_msg}") + break + + # 发送结束标记 + yield f"event: done\ndata: [DONE]\n\n" + logger.info(f"SSE流结束") + + except (CozeError, CozeAPIError) as e: + logger.error(f"Coze API错误: {e}", exc_info=True) + error_data = {"error": f"对话失败: {str(e)}"} + yield f"event: error\ndata: {json.dumps(error_data)}\n\n" + except Exception as e: + logger.error(f"陪练对话异常: {e}", exc_info=True) + error_data = {"error": f"系统错误: {str(e)}"} + yield f"event: error\ndata: {json.dumps(error_data)}\n\n" + + # 返回SSE流式响应 + return StreamingResponse( + generate_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no" # 禁用Nginx缓冲 + } + ) + + +@router.post("/interrupt", response_model=ResponseModel) +async def interrupt_practice( + request: InterruptPracticeRequest, + current_user: User = Depends(get_current_user), + coze_service: CozeService = Depends(get_coze_service) +): + """ + 中断陪练对话 + + 调用Coze API中断当前进行中的对话 + """ + logger.info( + f"用户{current_user.id}中断对话," + f"conversation_id={request.conversation_id}, " + f"chat_id={request.chat_id}" + ) + + try: + result = coze_service.cancel_chat( + conversation_id=request.conversation_id, + chat_id=request.chat_id + ) + + return ResponseModel( + code=200, + message="对话已中断", + data={ + "conversation_id": request.conversation_id, + "chat_id": request.chat_id + } + ) + except (CozeError, CozeAPIError) as e: + logger.error(f"中断对话失败: {e}") + raise HTTPException(status_code=500, detail=f"中断对话失败: {str(e)}") + except Exception as e: + logger.error(f"中断对话异常: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"系统错误: {str(e)}") + + +@router.post("/conversation/create", response_model=ResponseModel) +async def create_practice_conversation( + current_user: User = Depends(get_current_user), + coze_service: CozeService = Depends(get_coze_service) +): + """ + 创建新的陪练对话 + + ⚠️ 关键:必须先创建conversation,然后才能续接对话 + 返回conversation_id供后续对话使用 + """ + try: + # 调用Coze API创建对话 + conversation = coze_service.client.conversations.create() + + conversation_id = conversation.id + + logger.info(f"用户{current_user.id}创建陪练对话,conversation_id={conversation_id}") + + return ResponseModel( + code=200, + message="对话创建成功", + data={"conversation_id": conversation_id} + ) + except (CozeError, CozeAPIError) as e: + logger.error(f"创建对话失败: {e}") + raise HTTPException(status_code=500, detail=f"创建对话失败: {str(e)}") + except Exception as e: + logger.error(f"创建对话异常: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"系统错误: {str(e)}") + + +@router.get("/conversations", response_model=ResponseModel[ConversationsResponse]) +async def get_conversations( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: User = Depends(get_current_user) +): + """ + 获取对话列表 + + 查询用户在Coze平台上的对话历史 + + 注意:语音陪练使用前端直连Coze WebSocket,不经过后端中转 + """ + # TODO: 实现对话列表查询 + # 将在阶段四实现 + logger.info(f"用户{current_user.id}查询对话列表") + raise HTTPException(status_code=501, detail="对话列表功能正在开发中") + + +@router.post("/extract-scene", response_model=ResponseModel[ExtractSceneResponse]) +async def extract_scene( + request: ExtractSceneRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 从课程提取陪练场景 + + 使用 Python 原生 AI 服务实现,直接调用 AI API 生成场景。 + + 流程: + 1. 验证课程是否存在 + 2. 获取课程知识点 + 3. 调用 AI 生成陪练场景 + 4. 解析并返回场景数据 + """ + from app.models.course import Course + from app.services.ai import practice_scene_service + + # 验证课程存在 + course = await db.get(Course, request.course_id) + if not course: + logger.warning(f"课程不存在: course_id={request.course_id}") + raise HTTPException(status_code=404, detail="课程不存在") + + logger.info(f"用户{current_user.id}开始提取课程{request.course_id}的陪练场景") + + # 调用 Python 原生服务 + result = await practice_scene_service.prepare_practice_knowledge( + db=db, + course_id=request.course_id + ) + + if not result.success: + # 根据错误类型返回适当的 HTTP 状态码 + if "没有可用的知识点" in result.error or "没有知识点" in result.error: + raise HTTPException( + status_code=400, + detail="该课程尚未添加知识点,无法生成陪练场景。请先在课程管理中上传资料并分析知识点。" + ) + raise HTTPException(status_code=500, detail=f"场景提取失败: {result.error}") + + # 将 PracticeScene 转换为 ExtractedSceneData + scene = result.scene + scene_data = ExtractedSceneData( + name=scene.name, + description=scene.description, + type=scene.type, + difficulty=scene.difficulty, + background=scene.background, + ai_role=scene.ai_role, + objectives=scene.objectives, + keywords=scene.keywords + ) + + logger.info( + f"场景提取成功: {scene.name}, course_id={request.course_id}, " + f"provider={result.ai_provider}, tokens={result.ai_tokens}" + ) + + return ResponseModel( + code=200, + message="场景提取成功", + data=ExtractSceneResponse( + scene=scene_data, + workflow_run_id=f"{result.ai_provider}_{result.ai_latency_ms}ms", + task_id=f"native_{request.course_id}" + ) + ) + + +# ==================== 陪练会话管理API ==================== + +@router.post("/sessions/create", response_model=ResponseModel[PracticeSessionResponse]) +async def create_practice_session( + request: PracticeSessionCreate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 创建陪练会话 + + 用户开始陪练时调用,创建session记录 + """ + try: + # 生成session_id(格式:PS + 时间戳后6位) + session_id = f"PS{str(int(datetime.now().timestamp() * 1000))[-6:]}" + + # 创建session记录 + session = PracticeSession( + session_id=session_id, + user_id=current_user.id, + scene_id=request.scene_id, + scene_name=request.scene_name, + scene_type=request.scene_type, + conversation_id=request.conversation_id, + start_time=datetime.now(), + status="in_progress" + ) + + db.add(session) + await db.commit() + await db.refresh(session) + + logger.info(f"创建陪练会话: session_id={session_id}, user_id={current_user.id}, scene={request.scene_name}") + + return ResponseModel( + code=200, + message="会话创建成功", + data=session + ) + + except Exception as e: + logger.error(f"创建会话失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"创建会话失败: {str(e)}") + + +@router.post("/dialogues/save", response_model=ResponseModel) +async def save_dialogue( + request: SaveDialogueRequest, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 保存对话记录 + + 每一条对话(用户或AI)都实时保存 + """ + try: + # 创建对话记录 + dialogue = PracticeDialogue( + session_id=request.session_id, + speaker=request.speaker, + content=request.content, + timestamp=datetime.now(), + sequence=request.sequence + ) + + db.add(dialogue) + await db.commit() + + logger.debug(f"保存对话: session_id={request.session_id}, speaker={request.speaker}, seq={request.sequence}") + + return ResponseModel( + code=200, + message="对话保存成功", + data={"session_id": request.session_id, "sequence": request.sequence} + ) + + except Exception as e: + logger.error(f"保存对话失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"保存对话失败: {str(e)}") + + +@router.post("/sessions/{session_id}/end", response_model=ResponseModel[PracticeSessionResponse]) +async def end_practice_session( + session_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 结束陪练会话 + + 用户结束陪练时调用,更新会话状态和时长 + """ + try: + # 查询会话 + result = await db.execute( + select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + + # 查询对话数量 + result = await db.execute( + select(func.count(PracticeDialogue.id)).where( + PracticeDialogue.session_id == session_id + ) + ) + dialogue_count = result.scalar() or 0 + + # 更新会话状态 + session.end_time = datetime.now() + session.duration_seconds = int((session.end_time - session.start_time).total_seconds()) + session.turns = dialogue_count + session.status = "completed" + + await db.commit() + await db.refresh(session) + + logger.info(f"结束陪练会话: session_id={session_id}, 时长={session.duration_seconds}秒, 轮次={session.turns}") + + return ResponseModel( + code=200, + message="会话已结束", + data=session + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"结束会话失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"结束会话失败: {str(e)}") + + +@router.post("/sessions/{session_id}/analyze", response_model=ResponseModel) +async def analyze_practice_session( + session_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 生成陪练分析报告 + + 使用 Python 原生 AI 服务实现。 + """ + try: + # 1. 查询会话信息 + result = await db.execute( + select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + + # 2. 查询对话历史 + result = await db.execute( + select(PracticeDialogue).where( + PracticeDialogue.session_id == session_id + ).order_by(PracticeDialogue.sequence) + ) + dialogues = result.scalars().all() + + if not dialogues or len(dialogues) < 2: + raise HTTPException(status_code=400, detail="对话数量太少,无法生成分析报告") + + # 3. 构建对话历史数据 + dialogue_history = [ + { + "speaker": d.speaker, + "content": d.content, + "timestamp": d.timestamp.isoformat() + } + for d in dialogues + ] + + logger.info(f"开始分析陪练会话: session_id={session_id}, 对话数={len(dialogue_history)}") + + # 调用 Python 原生陪练分析服务 + v2_result = await practice_analysis_service.analyze(dialogue_history, db=db) + + if not v2_result.success: + raise HTTPException(status_code=500, detail=f"分析失败: {v2_result.error}") + + analysis_data = v2_result.to_dict() + + logger.info( + f"陪练分析完成 - total_score: {v2_result.total_score}, " + f"provider: {v2_result.ai_provider}, latency: {v2_result.ai_latency_ms}ms" + ) + + # 解析分析结果 + analysis_result = analysis_data.get("analysis", {}) + + # 保存分析报告 + report = PracticeReport( + session_id=session_id, + total_score=analysis_result.get("total_score"), + score_breakdown=analysis_result.get("score_breakdown"), + ability_dimensions=analysis_result.get("ability_dimensions"), + dialogue_review=analysis_result.get("dialogue_annotations"), + suggestions=analysis_result.get("suggestions"), + workflow_run_id=f"{v2_result.ai_provider}_{v2_result.ai_latency_ms}ms", + task_id=None + ) + + db.add(report) + await db.commit() + + logger.info(f"分析报告已保存: session_id={session_id}, total_score={report.total_score}") + + return ResponseModel( + code=200, + message="分析报告生成成功", + data={ + "session_id": session_id, + "total_score": report.total_score, + "workflow_run_id": report.workflow_run_id + } + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"生成分析报告失败: {e}, session_id={session_id}", exc_info=True) + raise HTTPException(status_code=500, detail=f"生成分析报告失败: {str(e)}") + + +@router.get("/reports/{session_id}", response_model=ResponseModel[PracticeReportResponse]) +async def get_practice_report( + session_id: str, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练分析报告详情 + + 合并数据库对话记录和AI标注,生成完整的对话复盘 + """ + try: + # 1. 查询会话信息 + result = await db.execute( + select(PracticeSession).where( + PracticeSession.session_id == session_id, + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + session = result.scalar_one_or_none() + + if not session: + raise HTTPException(status_code=404, detail="会话不存在") + + # 2. 查询分析报告 + result = await db.execute( + select(PracticeReport).where( + PracticeReport.session_id == session_id + ) + ) + report = result.scalar_one_or_none() + + if not report: + raise HTTPException(status_code=404, detail="分析报告不存在,请先生成报告") + + # 3. 查询完整对话记录(从数据库) + result = await db.execute( + select(PracticeDialogue).where( + PracticeDialogue.session_id == session_id + ).order_by(PracticeDialogue.sequence) + ) + dialogues = result.scalars().all() + + # 4. 合并对话记录和AI标注 + # dialogue_review字段存储的是标注信息(包含sequence, tags, comment) + ai_annotations = report.dialogue_review or [] + + # 创建标注映射(sequence -> {tags, comment}) + annotations_map = {} + for annotation in ai_annotations: + seq = annotation.get('sequence') + if seq: + annotations_map[seq] = { + 'tags': annotation.get('tags', []), + 'comment': annotation.get('comment', '') + } + + # 构建完整对话复盘(数据库对话 + AI标注) + dialogue_review = [] + for dialogue in dialogues: + # 计算时间(从会话开始时间算起) + time_offset = int((dialogue.timestamp - session.start_time).total_seconds()) + time_str = f"{time_offset // 60:02d}:{time_offset % 60:02d}" + + # 获取标注 + annotation = annotations_map.get(dialogue.sequence, {}) + + dialogue_review.append({ + "speaker": "顾问" if dialogue.speaker == "user" else "客户", + "time": time_str, + "content": dialogue.content, + "tags": annotation.get('tags', []), + "comment": annotation.get('comment', '') + }) + + # 5. 构建响应数据 + # 5.1 处理score_breakdown字段(兼容字典和列表格式) + score_breakdown_data = report.score_breakdown or [] + if isinstance(score_breakdown_data, str): + try: + score_breakdown_data = json.loads(score_breakdown_data) + except json.JSONDecodeError: + logger.warning(f"无法解析score_breakdown JSON: {score_breakdown_data}") + score_breakdown_data = [] + + # 如果是字典格式,转换为列表格式 + if isinstance(score_breakdown_data, dict): + score_breakdown_data = [ + {"name": k, "score": int(v), "description": ""} + for k, v in score_breakdown_data.items() + ] + + # 5.2 处理ability_dimensions字段(兼容字典和列表格式) + ability_dimensions_data = report.ability_dimensions or [] + if isinstance(ability_dimensions_data, str): + try: + ability_dimensions_data = json.loads(ability_dimensions_data) + except json.JSONDecodeError: + logger.warning(f"无法解析ability_dimensions JSON: {ability_dimensions_data}") + ability_dimensions_data = [] + + # 如果是字典格式,转换为列表格式 + if isinstance(ability_dimensions_data, dict): + ability_dimensions_data = [ + {"name": k, "score": int(v), "feedback": ""} + for k, v in ability_dimensions_data.items() + ] + + # 5.3 处理suggestions字段 + suggestions_data = report.suggestions or [] + if isinstance(suggestions_data, str): + try: + suggestions_data = json.loads(suggestions_data) + except json.JSONDecodeError: + logger.warning(f"无法解析suggestions JSON: {suggestions_data}") + suggestions_data = [] + + analysis = PracticeAnalysisResult( + total_score=report.total_score, + score_breakdown=score_breakdown_data, + ability_dimensions=ability_dimensions_data, + dialogue_review=dialogue_review, # 使用合并后的对话 + suggestions=suggestions_data + ) + + response_data = PracticeReportResponse( + session_info=session, + analysis=analysis + ) + + logger.info(f"获取分析报告: session_id={session_id}, total_score={report.total_score}, 对话数={len(dialogue_review)}") + + return ResponseModel( + code=200, + message="success", + data=response_data + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取报告失败: {e}, session_id={session_id}", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取报告失败: {str(e)}") + + +# ==================== 陪练记录查询API ==================== + +@router.get("/sessions/list", response_model=ResponseModel[PaginatedResponse]) +async def get_practice_sessions_list( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + keyword: Optional[str] = Query(None, description="关键词搜索"), + scene_type: Optional[str] = Query(None, description="场景类型"), + start_date: Optional[str] = Query(None, description="开始日期"), + end_date: Optional[str] = Query(None, description="结束日期"), + min_score: Optional[int] = Query(None, ge=0, le=100, description="最低分数"), + max_score: Optional[int] = Query(None, ge=0, le=100, description="最高分数"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练记录列表 + + 支持关键词搜索、场景筛选、时间范围筛选、分数筛选 + """ + try: + # 构建查询(关联practice_reports表获取分数) + query = select( + PracticeSession, + PracticeReport.total_score + ).outerjoin( + PracticeReport, + PracticeSession.session_id == PracticeReport.session_id + ).where( + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False, + PracticeSession.status == "completed" # 只查询已完成的会话 + ) + + # 关键词搜索 + if keyword: + query = query.where( + or_( + PracticeSession.scene_name.contains(keyword), + PracticeSession.session_id.contains(keyword) + ) + ) + + # 场景类型筛选 + if scene_type: + query = query.where(PracticeSession.scene_type == scene_type) + + # 时间范围筛选 + if start_date: + query = query.where(PracticeSession.start_time >= start_date) + if end_date: + query = query.where(PracticeSession.start_time <= end_date) + + # 分数筛选 + if min_score is not None: + query = query.where(PracticeReport.total_score >= min_score) + if max_score is not None: + query = query.where(PracticeReport.total_score <= max_score) + + # 按开始时间倒序排列 + query = query.order_by(PracticeSession.start_time.desc()) + + # 计算总数 + count_query = select(func.count()).select_from(query.subquery()) + total = await db.scalar(count_query) or 0 + + # 分页查询 + results = await db.execute( + query.offset((page - 1) * size).limit(size) + ) + + # 构建响应数据 + items = [] + for session, total_score in results: + # 计算result等级 + result_level = "needs_improvement" + if total_score: + if total_score >= 90: + result_level = "excellent" + elif total_score >= 80: + result_level = "good" + elif total_score >= 70: + result_level = "average" + + items.append({ + "session_id": session.session_id, + "scene_name": session.scene_name, + "scene_type": session.scene_type, + "start_time": session.start_time, + "duration_seconds": session.duration_seconds, + "turns": session.turns, + "total_score": total_score, + "result": result_level + }) + + logger.info(f"查询陪练记录: user_id={current_user.id}, 返回{len(items)}条记录") + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=(total + size - 1) // size + ) + ) + + except Exception as e: + logger.error(f"查询陪练记录失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") + + +@router.get("/stats", response_model=ResponseModel) +async def get_practice_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取陪练统计数据 + + 返回:总次数、平均分、总时长、本月进步 + """ + try: + # 查询总次数和总时长 + result = await db.execute( + select( + func.count(PracticeSession.id).label('total_count'), + func.sum(PracticeSession.duration_seconds).label('total_duration') + ).where( + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False, + PracticeSession.status == "completed" + ) + ) + stats = result.first() + + total_count = stats.total_count or 0 + total_duration = stats.total_duration or 0 + total_duration_hours = round(total_duration / 3600, 1) + + # 查询平均分 + result = await db.execute( + select(func.avg(PracticeReport.total_score)).where( + PracticeReport.session_id.in_( + select(PracticeSession.session_id).where( + PracticeSession.user_id == current_user.id, + PracticeSession.is_deleted == False + ) + ) + ) + ) + avg_score = result.scalar() or 0 + avg_score = round(float(avg_score), 1) if avg_score else 0 + + # 计算本月进步(简化:与上月平均分对比) + # TODO: 实现真实的月度对比逻辑 + month_improvement = 15 # 暂时使用固定值 + + logger.info(f"查询陪练统计: user_id={current_user.id}, total={total_count}, avg={avg_score}") + + return ResponseModel( + code=200, + message="success", + data={ + "total_count": total_count, + "avg_score": avg_score, + "total_duration_hours": total_duration_hours, + "month_improvement": month_improvement + } + ) + + except Exception as e: + logger.error(f"查询统计数据失败: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=f"查询失败: {str(e)}") diff --git a/backend/app/api/v1/preview.py b/backend/app/api/v1/preview.py new file mode 100644 index 0000000..0287951 --- /dev/null +++ b/backend/app/api/v1/preview.py @@ -0,0 +1,285 @@ +""" +文件预览API +提供课程资料的在线预览功能 +""" +import logging +from pathlib import Path +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.core.deps import get_db, get_current_user +from app.schemas.base import ResponseModel +from app.core.config import settings +from app.models.user import User +from app.models.course import CourseMaterial +from app.services.document_converter import document_converter + +logger = logging.getLogger(__name__) +router = APIRouter() + + +class PreviewType: + """预览类型常量 + 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties + """ + PDF = "pdf" + TEXT = "text" + HTML = "html" + EXCEL_HTML = "excel_html" # Excel转HTML预览 + VIDEO = "video" + AUDIO = "audio" + IMAGE = "image" + DOWNLOAD = "download" + + +# 文件类型到预览类型的映射 +FILE_TYPE_MAPPING = { + # PDF - 直接预览 + '.pdf': PreviewType.PDF, + + # 文本 - 直接显示内容 + '.txt': PreviewType.TEXT, + '.md': PreviewType.TEXT, + '.mdx': PreviewType.TEXT, + '.csv': PreviewType.TEXT, + '.vtt': PreviewType.TEXT, + '.properties': PreviewType.TEXT, + + # HTML - 在iframe中预览 + '.html': PreviewType.HTML, + '.htm': PreviewType.HTML, +} + + +def get_preview_type(file_ext: str) -> str: + """ + 根据文件扩展名获取预览类型 + + Args: + file_ext: 文件扩展名(带点,如 .pdf) + + Returns: + 预览类型 + """ + file_ext_lower = file_ext.lower() + + # 直接映射的类型 + if file_ext_lower in FILE_TYPE_MAPPING: + return FILE_TYPE_MAPPING[file_ext_lower] + + # Excel文件使用HTML预览(避免分页问题) + if file_ext_lower in {'.xlsx', '.xls'}: + return PreviewType.EXCEL_HTML + + # 其他Office文档,需要转换为PDF预览 + if document_converter.is_convertible(file_ext_lower): + return PreviewType.PDF + + # 其他类型,只提供下载 + return PreviewType.DOWNLOAD + + +def get_file_path_from_url(file_url: str) -> Optional[Path]: + """ + 从文件URL获取本地文件路径 + + Args: + file_url: 文件URL(如 /static/uploads/courses/1/xxx.pdf) + + Returns: + 本地文件路径,如果无效返回None + """ + try: + # 移除 /static/uploads/ 前缀 + if file_url.startswith('/static/uploads/'): + relative_path = file_url.replace('/static/uploads/', '') + full_path = Path(settings.UPLOAD_PATH) / relative_path + return full_path + return None + except Exception: + return None + + +@router.get("/material/{material_id}", response_model=ResponseModel[dict]) +async def get_material_preview( + material_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取资料预览信息 + + Args: + material_id: 资料ID + + Returns: + 预览信息,包括预览类型、预览URL等 + """ + try: + # 查询资料信息 + stmt = select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.is_deleted == False + ) + result = await db.execute(stmt) + material = result.scalar_one_or_none() + + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="资料不存在" + ) + + # TODO: 权限检查 - 确认当前用户是否有权访问该课程的资料 + # 可以通过查询 position_courses 表和用户的岗位关系来判断 + + # 获取文件扩展名 + file_ext = Path(material.name).suffix.lower() + + # 确定预览类型 + preview_type = get_preview_type(file_ext) + + logger.info( + f"资料预览请求 - material_id: {material_id}, " + f"file_type: {file_ext}, preview_type: {preview_type}, " + f"user_id: {current_user.id}" + ) + + # 构建响应数据 + response_data = { + "preview_type": preview_type, + "file_name": material.name, + "original_url": material.file_url, + "file_size": material.file_size, + } + + # 根据预览类型处理 + if preview_type == PreviewType.TEXT: + # 文本类型,读取文件内容 + file_path = get_file_path_from_url(material.file_url) + if file_path and file_path.exists(): + try: + with open(file_path, 'r', encoding='utf-8') as f: + content = f.read() + response_data["content"] = content + response_data["preview_url"] = None + except Exception as e: + logger.error(f"读取文本文件失败: {str(e)}") + # 读取失败,改为下载模式 + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + else: + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + + elif preview_type == PreviewType.EXCEL_HTML: + # Excel文件转换为HTML预览 + file_path = get_file_path_from_url(material.file_url) + if file_path and file_path.exists(): + converted_url = document_converter.convert_excel_to_html( + str(file_path), + material.course_id, + material.id + ) + if converted_url: + response_data["preview_url"] = converted_url + response_data["preview_type"] = "html" # 前端使用html类型渲染 + response_data["is_converted"] = True + else: + logger.warning(f"Excel转HTML失败,改为下载模式 - material_id: {material_id}") + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + response_data["is_converted"] = False + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文件不存在" + ) + + elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext): + # Office文档,需要转换为PDF + file_path = get_file_path_from_url(material.file_url) + if file_path and file_path.exists(): + # 执行转换 + converted_url = document_converter.convert_to_pdf( + str(file_path), + material.course_id, + material.id + ) + if converted_url: + response_data["preview_url"] = converted_url + response_data["is_converted"] = True + else: + # 转换失败,改为下载模式 + logger.warning(f"文档转换失败,改为下载模式 - material_id: {material_id}") + response_data["preview_type"] = PreviewType.DOWNLOAD + response_data["preview_url"] = material.file_url + response_data["is_converted"] = False + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文件不存在" + ) + + else: + # 其他类型,直接返回原始URL + response_data["preview_url"] = material.file_url + + return ResponseModel(data=response_data, message="获取预览信息成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取资料预览信息失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="获取预览信息失败" + ) + + +@router.get("/check-converter", response_model=ResponseModel[dict]) +async def check_converter_status( + current_user: User = Depends(get_current_user), +): + """ + 检查文档转换服务状态(用于调试) + + Returns: + 转换服务状态信息 + """ + try: + import subprocess + + # 检查 LibreOffice 是否安装 + try: + result = subprocess.run( + ['libreoffice', '--version'], + capture_output=True, + text=True, + timeout=5 + ) + libreoffice_installed = result.returncode == 0 + libreoffice_version = result.stdout.strip() if libreoffice_installed else None + except Exception: + libreoffice_installed = False + libreoffice_version = None + + return ResponseModel( + data={ + "libreoffice_installed": libreoffice_installed, + "libreoffice_version": libreoffice_version, + "supported_formats": list(document_converter.SUPPORTED_FORMATS), + "converted_path": str(document_converter.converted_path), + }, + message="转换服务状态检查完成" + ) + + except Exception as e: + logger.error(f"检查转换服务状态失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="检查转换服务状态失败" + ) + diff --git a/backend/app/api/v1/scrm.py b/backend/app/api/v1/scrm.py new file mode 100644 index 0000000..073cfae --- /dev/null +++ b/backend/app/api/v1/scrm.py @@ -0,0 +1,311 @@ +""" +SCRM 系统对接 API 路由 + +提供给 SCRM 系统调用的数据查询接口 +认证方式:Bearer Token (SCRM_API_KEY) +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_db, verify_scrm_api_key +from app.services.scrm_service import SCRMService +from app.schemas.scrm import ( + EmployeePositionResponse, + EmployeePositionData, + PositionCoursesResponse, + PositionCoursesData, + KnowledgePointSearchRequest, + KnowledgePointSearchResponse, + KnowledgePointSearchData, + KnowledgePointDetailResponse, + KnowledgePointDetailData, + SCRMErrorResponse, +) + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/scrm", tags=["scrm"]) + + +# ==================== 1. 获取员工岗位 ==================== + +@router.get( + "/employees/{userid}/position", + response_model=EmployeePositionResponse, + summary="获取员工岗位(通过userid)", + description="根据企微 userid 查询员工在考陪练系统中的岗位信息", + responses={ + 200: {"model": EmployeePositionResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "员工不存在"}, + } +) +async def get_employee_position_by_userid( + userid: str, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取员工岗位(通过企微userid) + + - **userid**: 企微员工 userid + """ + service = SCRMService(db) + result = await service.get_employee_position(userid=userid) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 404, + "message": "员工不存在", + "data": None + } + ) + + # 检查是否有多个匹配结果 + if result.get("multiple_matches"): + return { + "code": 0, + "message": f"找到 {result['count']} 个匹配的员工,请确认", + "data": result + } + + return EmployeePositionResponse( + code=0, + message="success", + data=EmployeePositionData(**result) + ) + + +@router.get( + "/employees/search/by-name", + summary="获取员工岗位(通过姓名搜索)", + description="根据员工姓名查询员工在考陪练系统中的岗位信息,支持精确匹配和模糊匹配", + responses={ + 200: {"description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "员工不存在"}, + } +) +async def get_employee_position_by_name( + name: str = Query(..., description="员工姓名,支持精确匹配和模糊匹配"), + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取员工岗位(通过姓名搜索) + + - **name**: 员工姓名(必填),优先精确匹配,无结果时模糊匹配 + + 注意:如果有多个同名员工,会返回员工列表供确认 + """ + service = SCRMService(db) + result = await service.get_employee_position(name=name) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 404, + "message": f"未找到姓名包含 '{name}' 的员工", + "data": None + } + ) + + # 检查是否有多个匹配结果 + if result.get("multiple_matches"): + return { + "code": 0, + "message": f"找到 {result['count']} 个匹配的员工,请确认后使用 employee_id 精确查询", + "data": result + } + + return EmployeePositionResponse( + code=0, + message="success", + data=EmployeePositionData(**result) + ) + + +@router.get( + "/employees/by-id/{employee_id}/position", + response_model=EmployeePositionResponse, + summary="获取员工岗位(通过员工ID)", + description="根据员工ID精确查询员工岗位信息,用于多个同名员工时的精确查询", + responses={ + 200: {"model": EmployeePositionResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "员工不存在"}, + } +) +async def get_employee_position_by_id( + employee_id: int, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取员工岗位(通过员工ID精确查询) + + - **employee_id**: 员工ID(考陪练系统用户ID) + + 适用场景:通过姓名搜索返回多个匹配结果后,使用此接口精确查询 + """ + service = SCRMService(db) + result = await service.get_employee_position_by_id(employee_id) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 404, + "message": "员工不存在", + "data": None + } + ) + + return EmployeePositionResponse( + code=0, + message="success", + data=EmployeePositionData(**result) + ) + + +# ==================== 2. 获取岗位课程列表 ==================== + +@router.get( + "/positions/{position_id}/courses", + response_model=PositionCoursesResponse, + summary="获取岗位课程列表", + description="获取指定岗位的必修/选修课程列表", + responses={ + 200: {"model": PositionCoursesResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "岗位不存在"}, + } +) +async def get_position_courses( + position_id: int, + course_type: Optional[str] = Query( + default="all", + description="课程类型:required/optional/all", + regex="^(required|optional|all)$" + ), + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取岗位课程列表 + + - **position_id**: 岗位ID + - **course_type**: 课程类型筛选(required/optional/all,默认 all) + """ + service = SCRMService(db) + result = await service.get_position_courses(position_id, course_type) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 40002, + "message": "position_id 不存在", + "data": None + } + ) + + return PositionCoursesResponse( + code=0, + message="success", + data=PositionCoursesData(**result) + ) + + +# ==================== 3. 搜索知识点 ==================== + +@router.post( + "/knowledge-points/search", + response_model=KnowledgePointSearchResponse, + summary="搜索知识点", + description="根据关键词和岗位搜索匹配的知识点", + responses={ + 200: {"model": KnowledgePointSearchResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 400: {"model": SCRMErrorResponse, "description": "请求参数错误"}, + } +) +async def search_knowledge_points( + request: KnowledgePointSearchRequest, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 搜索知识点 + + - **keywords**: 搜索关键词列表(必填) + - **position_id**: 岗位ID(用于优先排序,可选) + - **course_ids**: 限定课程范围(可选) + - **knowledge_type**: 知识点类型筛选(可选) + - **limit**: 返回数量,默认10,最大100 + """ + service = SCRMService(db) + result = await service.search_knowledge_points( + keywords=request.keywords, + position_id=request.position_id, + course_ids=request.course_ids, + knowledge_type=request.knowledge_type, + limit=request.limit + ) + + return KnowledgePointSearchResponse( + code=0, + message="success", + data=KnowledgePointSearchData(**result) + ) + + +# ==================== 4. 获取知识点详情 ==================== + +@router.get( + "/knowledge-points/{knowledge_point_id}", + response_model=KnowledgePointDetailResponse, + summary="获取知识点详情", + description="获取知识点的完整信息", + responses={ + 200: {"model": KnowledgePointDetailResponse, "description": "成功"}, + 401: {"model": SCRMErrorResponse, "description": "认证失败"}, + 404: {"model": SCRMErrorResponse, "description": "知识点不存在"}, + } +) +async def get_knowledge_point_detail( + knowledge_point_id: int, + _: bool = Depends(verify_scrm_api_key), + db: AsyncSession = Depends(get_db) +): + """ + 获取知识点详情 + + - **knowledge_point_id**: 知识点ID + """ + service = SCRMService(db) + result = await service.get_knowledge_point_detail(knowledge_point_id) + + if result is None: + raise HTTPException( + status_code=404, + detail={ + "code": 40003, + "message": "knowledge_point_id 不存在", + "data": None + } + ) + + return KnowledgePointDetailResponse( + code=0, + message="success", + data=KnowledgePointDetailData(**result) + ) + diff --git a/backend/app/api/v1/sql_executor.py b/backend/app/api/v1/sql_executor.py new file mode 100644 index 0000000..c1231c7 --- /dev/null +++ b/backend/app/api/v1/sql_executor.py @@ -0,0 +1,363 @@ +""" +SQL 执行器 API - 用于内部服务调用 +支持执行查询和写入操作的 SQL 语句 +""" +import json +from typing import Any, Dict, List, Optional, Union +from datetime import datetime, date + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.engine.result import Result +import structlog + +from app.core.deps import get_current_user, get_db +try: + from app.core.simple_auth import get_current_user_simple +except ImportError: + get_current_user_simple = None +from app.core.config import settings +from app.models.user import User +from app.schemas.base import ResponseModel + +logger = structlog.get_logger(__name__) + +router = APIRouter(tags=["SQL Executor"]) + + +class SQLExecutorRequest: + """SQL执行请求模型""" + def __init__(self, sql: str, params: Optional[Dict[str, Any]] = None): + self.sql = sql + self.params = params or {} + + +class DateTimeEncoder(json.JSONEncoder): + """处理日期时间对象的 JSON 编码器""" + def default(self, obj): + if isinstance(obj, (datetime, date)): + return obj.isoformat() + return super().default(obj) + + +def serialize_row(row: Any) -> Union[Dict[str, Any], Any]: + """序列化数据库行结果""" + if hasattr(row, '_mapping'): + # 处理 SQLAlchemy Row 对象 + return dict(row._mapping) + elif hasattr(row, '__dict__'): + # 处理 ORM 对象 + return {k: v for k, v in row.__dict__.items() if not k.startswith('_')} + else: + # 处理单值结果 + return row + + +@router.post("/execute", response_model=ResponseModel) +async def execute_sql( + request: Dict[str, Any], + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 执行 SQL 语句 + + Args: + request: 包含 sql 和可选的 params 字段 + - sql: SQL 语句 + - params: 参数字典(可选) + + Returns: + 执行结果,包括: + - 查询操作:返回数据行 + - 写入操作:返回影响的行数 + + 安全说明: + - 需要用户身份验证 + - 所有操作都会记录日志 + - 建议在生产环境中限制可执行的 SQL 类型 + """ + try: + # 提取参数 + sql = request.get('sql', '').strip() + params = request.get('params', {}) + + if not sql: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="SQL 语句不能为空" + ) + + # 记录 SQL 执行日志 + logger.info( + "sql_execution_request", + user_id=current_user.id, + username=current_user.username, + sql_type=sql.split()[0].upper() if sql else "UNKNOWN", + sql_length=len(sql), + has_params=bool(params) + ) + + # 判断 SQL 类型 + sql_upper = sql.upper().strip() + is_select = sql_upper.startswith('SELECT') + is_show = sql_upper.startswith('SHOW') + is_describe = sql_upper.startswith(('DESCRIBE', 'DESC')) + is_query = is_select or is_show or is_describe + + # 执行 SQL + try: + result = await db.execute(text(sql), params) + + if is_query: + # 查询操作 + rows = result.fetchall() + columns = list(result.keys()) if result.keys() else [] + + # 序列化结果 + data = [] + for row in rows: + serialized_row = serialize_row(row) + if isinstance(serialized_row, dict): + data.append(serialized_row) + else: + # 单列结果 + data.append({columns[0] if columns else 'value': serialized_row}) + + # 使用自定义编码器处理日期时间 + response_data = { + "type": "query", + "columns": columns, + "rows": json.loads(json.dumps(data, cls=DateTimeEncoder)), + "row_count": len(data) + } + + logger.info( + "sql_query_success", + user_id=current_user.id, + row_count=len(data), + column_count=len(columns) + ) + + else: + # 写入操作 + await db.commit() + affected_rows = result.rowcount + + response_data = { + "type": "execute", + "affected_rows": affected_rows, + "success": True + } + + logger.info( + "sql_execute_success", + user_id=current_user.id, + affected_rows=affected_rows + ) + + return ResponseModel( + code=200, + message="SQL 执行成功", + data=response_data + ) + + except Exception as e: + # 回滚事务 + await db.rollback() + logger.error( + "sql_execution_error", + user_id=current_user.id, + sql_type=sql.split()[0].upper() if sql else "UNKNOWN", + error=str(e), + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"SQL 执行失败: {str(e)}" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + "sql_executor_error", + user_id=current_user.id, + error=str(e), + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"处理请求时发生错误: {str(e)}" + ) + + +@router.post("/validate", response_model=ResponseModel) +async def validate_sql( + request: Dict[str, Any], + current_user: User = Depends(get_current_user) +) -> ResponseModel: + """ + 验证 SQL 语句的语法(不执行) + + Args: + request: 包含 sql 字段的请求 + + Returns: + 验证结果 + """ + try: + sql = request.get('sql', '').strip() + + if not sql: + return ResponseModel( + code=400, + message="SQL 语句不能为空", + data={"valid": False, "error": "SQL 语句不能为空"} + ) + + # 基本的 SQL 验证 + sql_upper = sql.upper().strip() + + # 检查危险操作(可根据需要调整) + dangerous_keywords = ['DROP', 'TRUNCATE', 'DELETE FROM', 'UPDATE'] + warnings = [] + + for keyword in dangerous_keywords: + if keyword in sql_upper: + warnings.append(f"包含危险操作: {keyword}") + + return ResponseModel( + code=200, + message="SQL 验证完成", + data={ + "valid": True, + "warnings": warnings, + "sql_type": sql_upper.split()[0] if sql_upper else "UNKNOWN" + } + ) + + except Exception as e: + logger.error( + "sql_validation_error", + user_id=current_user.id, + error=str(e) + ) + return ResponseModel( + code=500, + message="SQL 验证失败", + data={"valid": False, "error": str(e)} + ) + + +@router.get("/tables", response_model=ResponseModel) +async def get_tables( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取数据库中的所有表 + + Returns: + 数据库表列表 + """ + try: + result = await db.execute(text("SHOW TABLES")) + tables = [row[0] for row in result.fetchall()] + + return ResponseModel( + code=200, + message="获取表列表成功", + data={ + "tables": tables, + "count": len(tables) + } + ) + + except Exception as e: + logger.error( + "get_tables_error", + user_id=current_user.id, + error=str(e) + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取表列表失败: {str(e)}" + ) + + +@router.get("/table/{table_name}/schema", response_model=ResponseModel) +async def get_table_schema( + table_name: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +) -> ResponseModel: + """ + 获取指定表的结构信息 + + Args: + table_name: 表名 + + Returns: + 表结构信息 + """ + try: + # MySQL 的 DESCRIBE 不支持参数化,需要直接拼接 + # 但为了安全,先验证表名 + if not table_name.replace('_', '').isalnum(): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的表名" + ) + + result = await db.execute(text(f"DESCRIBE {table_name}")) + + columns = [] + for row in result.fetchall(): + columns.append({ + "field": row[0], + "type": row[1], + "null": row[2], + "key": row[3], + "default": row[4], + "extra": row[5] + }) + + return ResponseModel( + code=200, + message="获取表结构成功", + data={ + "table_name": table_name, + "columns": columns, + "column_count": len(columns) + } + ) + + except Exception as e: + logger.error( + "get_table_schema_error", + user_id=current_user.id, + table_name=table_name, + error=str(e) + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"获取表结构失败: {str(e)}" + ) + + +# 简化认证版本的端点(如果启用) +if get_current_user_simple: + @router.post("/execute-simple", response_model=ResponseModel) + async def execute_sql_simple( + request: Dict[str, Any], + current_user: User = Depends(get_current_user_simple), + db: AsyncSession = Depends(get_db) + ) -> ResponseModel: + """ + 执行 SQL 语句(简化认证版本) + + 支持 API Key 和 Token 两种认证方式,专为内部服务设计。 + """ + return await execute_sql(request, current_user, db) diff --git a/backend/app/api/v1/sql_executor_simple_auth.py b/backend/app/api/v1/sql_executor_simple_auth.py new file mode 100644 index 0000000..b0d017f --- /dev/null +++ b/backend/app/api/v1/sql_executor_simple_auth.py @@ -0,0 +1,5 @@ +""" +SQL 执行器 API - 简化认证版本(已删除,功能已整合到主文件) +""" +# 此文件的功能已经整合到 sql_executor.py 中 +# 请使用 /api/v1/sql/execute-simple 端点 diff --git a/backend/app/api/v1/statistics.py b/backend/app/api/v1/statistics.py new file mode 100644 index 0000000..47b8745 --- /dev/null +++ b/backend/app/api/v1/statistics.py @@ -0,0 +1,238 @@ +""" +统计分析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.models.user import User +from app.schemas.base import ResponseModel +from app.services.statistics_service import StatisticsService +from app.core.logger import get_logger + +logger = get_logger(__name__) +router = APIRouter(prefix="/statistics", tags=["statistics"]) + + +@router.get("/key-metrics", response_model=ResponseModel) +async def get_key_metrics( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取关键指标 + + 返回: + - learningEfficiency: 学习效率 + - knowledgeCoverage: 知识覆盖率 + - avgTimePerQuestion: 平均用时 + - progressSpeed: 进步速度 + """ + try: + metrics = await StatisticsService.get_key_metrics( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取关键指标成功", + data=metrics + ) + except Exception as e: + logger.error(f"获取关键指标失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取关键指标失败: {str(e)}" + ) + + +@router.get("/score-distribution", response_model=ResponseModel) +async def get_score_distribution( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取成绩分布统计 + + 返回各分数段的考试数量: + - excellent: 优秀(90-100) + - good: 良好(80-89) + - medium: 中等(70-79) + - pass: 及格(60-69) + - fail: 不及格(<60) + """ + try: + distribution = await StatisticsService.get_score_distribution( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取成绩分布成功", + data=distribution + ) + except Exception as e: + logger.error(f"获取成绩分布失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取成绩分布失败: {str(e)}" + ) + + +@router.get("/difficulty-analysis", response_model=ResponseModel) +async def get_difficulty_analysis( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取题目难度分析 + + 返回各难度题目的正确率: + - 简单题 + - 中等题 + - 困难题 + - 综合题 + - 应用题 + """ + try: + analysis = await StatisticsService.get_difficulty_analysis( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取难度分析成功", + data=analysis + ) + except Exception as e: + logger.error(f"获取难度分析失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取难度分析失败: {str(e)}" + ) + + +@router.get("/knowledge-mastery", response_model=ResponseModel) +async def get_knowledge_mastery( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取知识点掌握度 + + 返回知识点列表及其掌握度: + - name: 知识点名称 + - mastery: 掌握度(0-100) + """ + try: + mastery = await StatisticsService.get_knowledge_mastery( + db=db, + user_id=current_user.id, + course_id=course_id + ) + + return ResponseModel( + code=200, + message="获取知识点掌握度成功", + data=mastery + ) + except Exception as e: + logger.error(f"获取知识点掌握度失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取知识点掌握度失败: {str(e)}" + ) + + +@router.get("/study-time", response_model=ResponseModel) +async def get_study_time_stats( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取学习时长统计 + + 返回学习时长和练习时长的日期分布: + - labels: 日期标签列表 + - studyTime: 学习时长列表(小时) + - practiceTime: 练习时长列表(小时) + """ + try: + time_stats = await StatisticsService.get_study_time_stats( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取学习时长统计成功", + data=time_stats + ) + except Exception as e: + logger.error(f"获取学习时长统计失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取学习时长统计失败: {str(e)}" + ) + + +@router.get("/detail", response_model=ResponseModel) +async def get_detail_data( + course_id: Optional[int] = Query(None, description="课程ID,不传则统计全部课程"), + period: str = Query("month", description="时间范围: week/month/quarter/halfYear/year"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db) +): + """ + 获取详细统计数据(按日期) + + 返回每日详细统计数据: + - date: 日期 + - examCount: 考试次数 + - avgScore: 平均分 + - studyTime: 学习时长(小时) + - questionCount: 练习题数 + - accuracy: 正确率 + - improvement: 进步指数 + """ + try: + detail = await StatisticsService.get_detail_data( + db=db, + user_id=current_user.id, + course_id=course_id, + period=period + ) + + return ResponseModel( + code=200, + message="获取详细数据成功", + data=detail + ) + except Exception as e: + logger.error(f"获取详细数据失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取详细数据失败: {str(e)}" + ) + diff --git a/backend/app/api/v1/system.py b/backend/app/api/v1/system.py new file mode 100644 index 0000000..9625d69 --- /dev/null +++ b/backend/app/api/v1/system.py @@ -0,0 +1,139 @@ +""" +系统API - 供外部服务回调使用 +""" +import logging +from typing import List, Dict, Any, Optional + +from fastapi import APIRouter, Depends, HTTPException, status, Header +from sqlalchemy.ext.asyncio import AsyncSession +from pydantic import BaseModel, Field + +from app.core.deps import get_db +from app.schemas.base import ResponseModel +from app.schemas.course import KnowledgePointCreate +from app.services.course_service import knowledge_point_service, course_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/system") + + +class KnowledgePointData(BaseModel): + """知识点数据模型""" + name: str = Field(..., description="知识点名称") + description: str = Field(default="", description="知识点描述") + type: str = Field(default="理论知识", description="知识点类型") + source: int = Field(default=1, description="来源:0=手动,1=AI分析") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + + +class KnowledgeCallbackRequest(BaseModel): + """知识点回调请求模型(已弃用,保留向后兼容)""" + course_id: int = Field(..., description="课程ID") + material_id: int = Field(..., description="资料ID") + knowledge_points: List[KnowledgePointData] = Field(..., description="知识点列表") + + +@router.post("/knowledge", response_model=ResponseModel[Dict[str, Any]]) +async def create_knowledge_points_callback( + request: KnowledgeCallbackRequest, + authorization: str = Header(None), + db: AsyncSession = Depends(get_db), +): + """ + 创建知识点回调接口(已弃用) + + 注意:此接口已弃用,知识点分析现使用 Python 原生实现。 + 保留此接口仅为向后兼容。 + """ + try: + # API密钥验证(已弃用的接口,保留向后兼容) + expected_token = "Bearer callback-token-2025" + if authorization != expected_token: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的授权令牌" + ) + + # 验证课程是否存在 + course = await course_service.get_by_id(db, request.course_id) + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {request.course_id} 不存在" + ) + + # 验证资料是否存在 + materials = await course_service.get_course_materials(db, course_id=request.course_id) + material = next((m for m in materials if m.id == request.material_id), None) + if not material: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"资料 {request.material_id} 不存在" + ) + + # 创建知识点 + created_points = [] + for kp_data in request.knowledge_points: + try: + knowledge_point_create = KnowledgePointCreate( + name=kp_data.name, + description=kp_data.description, + type=kp_data.type, + source=kp_data.source, # AI分析来源=1 + topic_relation=kp_data.topic_relation, + material_id=request.material_id # 关联资料ID + ) + + # 使用系统用户ID (假设为1,或者可以配置) + system_user_id = 1 + knowledge_point = await knowledge_point_service.create_knowledge_point( + db=db, + course_id=request.course_id, + point_in=knowledge_point_create, + created_by=system_user_id + ) + + created_points.append({ + "id": knowledge_point.id, + "name": knowledge_point.name, + "description": knowledge_point.description, + "type": knowledge_point.type, + "source": knowledge_point.source, + "material_id": knowledge_point.material_id + }) + + except Exception as e: + logger.error( + f"创建知识点失败 - name: {kp_data.name}, error: {str(e)}" + ) + # 继续处理其他知识点,不因为单个失败而中断 + continue + + logger.info( + f"知识点回调成功 - course_id: {request.course_id}, material_id: {request.material_id}, created_points: {len(created_points)}" + ) + + return ResponseModel( + data={ + "course_id": request.course_id, + "material_id": request.material_id, + "knowledge_points_count": len(created_points), + "knowledge_points": created_points + }, + message=f"成功创建 {len(created_points)} 个知识点" + ) + + except HTTPException: + raise + except Exception as e: + logger.error( + f"知识点回调处理失败 - course_id: {request.course_id}, material_id: {request.material_id}, error: {str(e)}", + exc_info=True + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="知识点创建失败" + ) + + diff --git a/backend/app/api/v1/system_logs.py b/backend/app/api/v1/system_logs.py new file mode 100644 index 0000000..22c377b --- /dev/null +++ b/backend/app/api/v1/system_logs.py @@ -0,0 +1,184 @@ +""" +系统日志 API +提供日志查询、筛选、详情查看等功能 +""" +import logging +from typing import Optional +from datetime import datetime +from fastapi import APIRouter, Depends, HTTPException, 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.schemas.base import ResponseModel +from app.schemas.system_log import ( + SystemLogCreate, + SystemLogResponse, + SystemLogQuery, + SystemLogListResponse +) +from app.services.system_log_service import system_log_service + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/admin/logs") + + +@router.get("", response_model=ResponseModel[SystemLogListResponse]) +async def get_system_logs( + level: Optional[str] = Query(None, description="日志级别筛选"), + type: Optional[str] = Query(None, description="日志类型筛选"), + user: Optional[str] = Query(None, description="用户筛选"), + keyword: Optional[str] = Query(None, description="关键词搜索"), + start_date: Optional[datetime] = Query(None, description="开始日期"), + end_date: Optional[datetime] = Query(None, description="结束日期"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取系统日志列表 + 支持按级别、类型、用户、关键词、日期范围筛选 + 仅管理员可访问 + """ + try: + # 权限检查:仅管理员可查看系统日志 + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权限访问系统日志") + + # 构建查询参数 + query_params = SystemLogQuery( + level=level, + type=type, + user=user, + keyword=keyword, + start_date=start_date, + end_date=end_date, + page=page, + page_size=page_size + ) + + # 查询日志 + logs, total = await system_log_service.get_logs(db, query_params) + + # 计算总页数 + total_pages = (total + page_size - 1) // page_size + + # 转换为响应格式 + log_responses = [SystemLogResponse.model_validate(log) for log in logs] + + response_data = SystemLogListResponse( + items=log_responses, + total=total, + page=page, + page_size=page_size, + total_pages=total_pages + ) + + return ResponseModel( + code=200, + message="获取系统日志成功", + data=response_data + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取系统日志失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取系统日志失败: {str(e)}") + + +@router.get("/{log_id}", response_model=ResponseModel[SystemLogResponse]) +async def get_log_detail( + log_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 获取日志详情 + 仅管理员可访问 + """ + try: + # 权限检查 + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权限访问系统日志") + + # 查询日志 + log = await system_log_service.get_log_by_id(db, log_id) + + if not log: + raise HTTPException(status_code=404, detail="日志不存在") + + return ResponseModel( + code=200, + message="获取日志详情成功", + data=SystemLogResponse.model_validate(log) + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"获取日志详情失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"获取日志详情失败: {str(e)}") + + +@router.post("", response_model=ResponseModel[SystemLogResponse]) +async def create_system_log( + log_data: SystemLogCreate, + db: AsyncSession = Depends(get_db) +): + """ + 创建系统日志(内部API,供系统各模块调用) + 注意:此接口不需要用户认证,但应该只供内部调用 + """ + try: + log = await system_log_service.create_log(db, log_data) + + return ResponseModel( + code=200, + message="创建日志成功", + data=SystemLogResponse.model_validate(log) + ) + + except Exception as e: + logger.error(f"创建日志失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"创建日志失败: {str(e)}") + + +@router.delete("/cleanup") +async def cleanup_old_logs( + before_days: int = Query(90, ge=1, description="删除多少天之前的日志"), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(get_current_user) +): + """ + 清理旧日志 + 仅管理员可访问 + """ + try: + # 权限检查 + if current_user.role != "admin": + raise HTTPException(status_code=403, detail="无权限执行此操作") + + # 计算截止日期 + from datetime import timedelta + before_date = datetime.now() - timedelta(days=before_days) + + # 删除旧日志 + deleted_count = await system_log_service.delete_logs_before_date(db, before_date) + + return ResponseModel( + code=200, + message=f"成功清理 {deleted_count} 条日志", + data={"deleted_count": deleted_count} + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"清理日志失败: {str(e)}") + raise HTTPException(status_code=500, detail=f"清理日志失败: {str(e)}") + + + diff --git a/backend/app/api/v1/tasks.py b/backend/app/api/v1/tasks.py new file mode 100644 index 0000000..b8893fe --- /dev/null +++ b/backend/app/api/v1/tasks.py @@ -0,0 +1,228 @@ +""" +任务管理API +""" +from typing import Optional +from fastapi import APIRouter, Depends, HTTPException, status, Query, Request +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.deps import get_db, get_current_user, require_admin_or_manager +from app.schemas.base import ResponseModel, PaginatedResponse +from app.schemas.task import TaskCreate, TaskUpdate, TaskResponse, TaskStatsResponse +from app.services.task_service import task_service +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.models.user import User + +router = APIRouter(prefix="/manager/tasks", tags=["Tasks"], redirect_slashes=False) + + +@router.post("", response_model=ResponseModel[TaskResponse], summary="创建任务") +async def create_task( + task_in: TaskCreate, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """创建新任务""" + task = await task_service.create_task(db, task_in, current_user.id) + + # 记录任务创建日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"创建任务: {task.title}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/manager/tasks", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + # 构建响应 + courses = [link.course.name for link in task.course_links] + return ResponseModel( + data=TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task.assignments), + completed_count=sum(1 for a in task.assignments if a.status.value == "completed") + ) + ) + + +@router.get("", response_model=ResponseModel[PaginatedResponse[TaskResponse]], summary="获取任务列表") +async def get_tasks( + status: Optional[str] = Query(None, description="任务状态筛选"), + page: int = Query(1, ge=1), + page_size: int = Query(20, ge=1, le=100), + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """获取任务列表""" + tasks, total = await task_service.get_tasks(db, status, page, page_size) + + # 构建响应 + items = [] + for task in tasks: + # 加载关联数据 + task_detail = await task_service.get_task_detail(db, task.id) + if task_detail: + courses = [link.course.name for link in task_detail.course_links] + items.append(TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task_detail.assignments), + completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed") + )) + + return ResponseModel( + data=PaginatedResponse.create( + items=items, + total=total, + page=page, + page_size=page_size + ) + ) + + +@router.get("/stats", response_model=ResponseModel[TaskStatsResponse], summary="获取任务统计") +async def get_task_stats( + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """获取任务统计数据""" + stats = await task_service.get_task_stats(db) + return ResponseModel(data=stats) + + +@router.get("/{task_id}", response_model=ResponseModel[TaskResponse], summary="获取任务详情") +async def get_task( + task_id: int, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """获取任务详情""" + task = await task_service.get_task_detail(db, task_id) + + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + courses = [link.course.name for link in task.course_links] + return ResponseModel( + data=TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task.assignments), + completed_count=sum(1 for a in task.assignments if a.status.value == "completed") + ) + ) + + +@router.put("/{task_id}", response_model=ResponseModel[TaskResponse], summary="更新任务") +async def update_task( + task_id: int, + task_in: TaskUpdate, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """更新任务""" + task = await task_service.update_task(db, task_id, task_in) + + if not task: + raise HTTPException(status_code=404, detail="任务不存在") + + # 自动更新任务进度和状态 + await task_service.update_task_status(db, task_id) + + # 重新加载详情 + task_detail = await task_service.get_task_detail(db, task.id) + courses = [link.course.name for link in task_detail.course_links] if task_detail else [] + + return ResponseModel( + data=TaskResponse( + id=task.id, + title=task.title, + description=task.description, + priority=task.priority.value, + status=task.status.value, + creator_id=task.creator_id, + deadline=task.deadline, + requirements=task.requirements, + progress=task.progress, + created_at=task.created_at, + updated_at=task.updated_at, + courses=courses, + assigned_count=len(task_detail.assignments) if task_detail else 0, + completed_count=sum(1 for a in task_detail.assignments if a.status.value == "completed") if task_detail else 0 + ) + ) + + +@router.delete("/{task_id}", response_model=ResponseModel, summary="删除任务") +async def delete_task( + task_id: int, + request: Request, + db: AsyncSession = Depends(get_db), + current_user: User = Depends(require_admin_or_manager) +): + """删除任务""" + # 先获取任务信息用于日志 + task_detail = await task_service.get_task_detail(db, task_id) + task_title = task_detail.title if task_detail else f"ID:{task_id}" + + success = await task_service.delete_task(db, task_id) + + if not success: + raise HTTPException(status_code=404, detail="任务不存在") + + # 记录任务删除日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="api", + message=f"删除任务: {task_title}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path=f"/api/v1/manager/tasks/{task_id}", + method="DELETE", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="任务已删除") + diff --git a/backend/app/api/v1/team_dashboard.py b/backend/app/api/v1/team_dashboard.py new file mode 100644 index 0000000..43e4710 --- /dev/null +++ b/backend/app/api/v1/team_dashboard.py @@ -0,0 +1,750 @@ +""" +团队看板 API 路由 +提供团队概览、学习进度、排行榜、动态等数据 +""" + +import json +from datetime import datetime, timedelta +from typing import Any, Dict, List + +from fastapi import APIRouter, Depends +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user as get_current_user, get_db +from app.core.logger import logger +from app.models.course import Course +from app.models.exam import Exam +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.practice import PracticeReport, PracticeSession +from app.models.user import Team, User, UserTeam +from app.schemas.base import ResponseModel + +router = APIRouter(prefix="/team/dashboard", tags=["team-dashboard"]) + + +async def get_accessible_teams( + current_user: User, + db: AsyncSession +) -> List[int]: + """获取用户可访问的团队ID列表""" + if current_user.role in ['admin', 'manager']: + # 管理员查看所有团队 + stmt = select(Team.id).where(Team.is_deleted == False) # noqa: E712 + result = await db.execute(stmt) + return [row[0] for row in result.all()] + else: + # 普通用户只查看自己的团队 + stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id) + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + +async def get_team_member_ids( + team_ids: List[int], + db: AsyncSession +) -> List[int]: + """获取团队成员ID列表""" + if not team_ids: + return [] + + stmt = select(UserTeam.user_id).where( + UserTeam.team_id.in_(team_ids) + ).distinct() + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + +@router.get("/overview", response_model=ResponseModel) +async def get_team_overview( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队概览统计 + + 返回团队总数、成员数、平均学习进度、平均成绩、课程完成率等 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + + # 获取团队成员ID + member_ids = await get_team_member_ids(team_ids, db) + + # 统计团队数 + team_count = len(team_ids) + + # 统计成员数 + member_count = len(member_ids) + + # 计算平均考试成绩(使用round1_score) + avg_score = 0.0 + if member_ids: + stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + avg_score_value = result.scalar() + avg_score = float(avg_score_value) if avg_score_value else 0.0 + + # 计算平均学习进度(基于考试完成情况) + avg_progress = 0.0 + if member_ids: + # 统计每个成员完成的考试数 + stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id.in_(member_ids), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + completed_exams = result.scalar() or 0 + + # 假设每个成员应完成10个考试,计算完成率作为进度 + total_expected = member_count * 10 + if total_expected > 0: + avg_progress = (completed_exams / total_expected) * 100 + + # 计算课程完成率 + course_completion_rate = 0.0 + if member_ids: + # 统计已完成的课程数(有考试记录且成绩>=60) + stmt = select(func.count(func.distinct(Exam.course_id))).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + completed_courses = result.scalar() or 0 + + # 统计总课程数 + stmt = select(func.count(Course.id)).where( + and_( + Course.is_deleted == False, # noqa: E712 + Course.status == 'published' + ) + ) + result = await db.execute(stmt) + total_courses = result.scalar() or 0 + + if total_courses > 0: + course_completion_rate = (completed_courses / total_courses) * 100 + + # 趋势数据(暂时返回固定值,后续可实现真实趋势计算) + trends = { + "member_trend": 0, + "progress_trend": 12.3 if avg_progress > 0 else 0, + "score_trend": 5.8 if avg_score > 0 else 0, + "completion_trend": -3.2 if course_completion_rate > 0 else 0 + } + + data = { + "team_count": team_count, + "member_count": member_count, + "avg_progress": round(avg_progress, 1), + "avg_score": round(avg_score, 1), + "course_completion_rate": round(course_completion_rate, 1), + "trends": trends + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取团队概览失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取团队概览失败: {str(e)}", data=None) + + +@router.get("/progress", response_model=ResponseModel) +async def get_progress_data( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取学习进度数据 + + 返回Top 5成员的8周学习进度数据 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={"members": [], "weeks": [], "data": []} + ) + + # 获取Top 5学习时长最高的成员 + stmt = ( + select( + User.id, + User.full_name, + func.sum(PracticeSession.duration_seconds).label('total_duration') + ) + .join(PracticeSession, PracticeSession.user_id == User.id) + .where( + and_( + User.id.in_(member_ids), + PracticeSession.status == 'completed' + ) + ) + .group_by(User.id, User.full_name) + .order_by(func.sum(PracticeSession.duration_seconds).desc()) + .limit(5) + ) + result = await db.execute(stmt) + top_members = result.all() + + if not top_members: + # 如果没有陪练记录,按考试成绩选择Top 5 + stmt = ( + select( + User.id, + User.full_name, + func.avg(Exam.round1_score).label('avg_score') + ) + .join(Exam, Exam.user_id == User.id) + .where( + and_( + User.id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + .group_by(User.id, User.full_name) + .order_by(func.avg(Exam.round1_score).desc()) + .limit(5) + ) + result = await db.execute(stmt) + top_members = result.all() + + # 生成周标签 + weeks = [f"第{i+1}周" for i in range(8)] + + # 为每个成员生成进度数据 + members = [] + data = [] + + for member in top_members: + member_name = member.full_name or f"用户{member.id}" + members.append(member_name) + + # 查询该成员8周内的考试完成情况 + eight_weeks_ago = datetime.now() - timedelta(weeks=8) + stmt = select(Exam).where( + and_( + Exam.user_id == member.id, + Exam.created_at >= eight_weeks_ago, + Exam.status.in_(['completed', 'submitted']) + ) + ).order_by(Exam.created_at) + result = await db.execute(stmt) + exams = result.scalars().all() + + # 计算每周的进度(0-100) + values = [] + for week in range(8): + week_start = datetime.now() - timedelta(weeks=8-week) + week_end = week_start + timedelta(weeks=1) + + # 统计该周完成的考试数 + week_exams = [ + e for e in exams + if week_start <= e.created_at < week_end + ] + + # 进度 = 累计完成考试数 * 10(假设每个考试代表10%进度) + cumulative_exams = len([e for e in exams if e.created_at < week_end]) + progress = min(cumulative_exams * 10, 100) + values.append(progress) + + data.append({"name": member_name, "values": values}) + + return ResponseModel( + code=200, + message="success", + data={"members": members, "weeks": weeks, "data": data} + ) + + except Exception as e: + logger.error(f"获取学习进度数据失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取学习进度数据失败: {str(e)}", data=None) + + +@router.get("/course-distribution", response_model=ResponseModel) +async def get_course_distribution( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取课程完成分布 + + 返回已完成、进行中、未开始的课程数量 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + # 统计所有已发布的课程 + stmt = select(func.count(Course.id)).where( + and_( + Course.is_deleted == False, # noqa: E712 + Course.status == 'published' + ) + ) + result = await db.execute(stmt) + total_courses = result.scalar() or 0 + + if not member_ids or total_courses == 0: + return ResponseModel( + code=200, + message="success", + data={"completed": 0, "in_progress": 0, "not_started": 0} + ) + + # 统计已完成的课程(有及格成绩) + stmt = select(func.count(func.distinct(Exam.course_id))).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(stmt) + completed = result.scalar() or 0 + + # 统计进行中的课程(有考试记录但未及格) + stmt = select(func.count(func.distinct(Exam.course_id))).where( + and_( + Exam.user_id.in_(member_ids), + or_( + Exam.round1_score < 60, + Exam.status == 'started' + ) + ) + ) + result = await db.execute(stmt) + in_progress = result.scalar() or 0 + + # 未开始 = 总数 - 已完成 - 进行中 + not_started = max(0, total_courses - completed - in_progress) + + data = { + "completed": completed, + "in_progress": in_progress, + "not_started": not_started + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取课程分布失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取课程分布失败: {str(e)}", data=None) + + +@router.get("/ability-analysis", response_model=ResponseModel) +async def get_ability_analysis( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取能力分析数据 + + 返回团队能力雷达图数据和短板列表 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={ + "radar_data": { + "dimensions": [], + "values": [] + }, + "weaknesses": [] + } + ) + + # 查询所有陪练报告的能力维度数据 + # 需要通过PracticeSession关联,因为PracticeReport没有user_id + stmt = ( + select(PracticeReport.ability_dimensions) + .join(PracticeSession, PracticeSession.session_id == PracticeReport.session_id) + .where(PracticeSession.user_id.in_(member_ids)) + ) + result = await db.execute(stmt) + all_dimensions = result.scalars().all() + + if not all_dimensions: + # 如果没有陪练报告,返回默认能力维度 + default_dimensions = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"] + return ResponseModel( + code=200, + message="success", + data={ + "radar_data": { + "dimensions": default_dimensions, + "values": [0] * len(default_dimensions) + }, + "weaknesses": [] + } + ) + + # 聚合能力数据 + ability_scores: Dict[str, List[float]] = {} + + # 能力维度名称映射 + dimension_name_map = { + "sales_ability": "销售能力", + "service_attitude": "服务态度", + "technical_skills": "技术能力", + "沟通表达": "沟通表达", + "倾听理解": "倾听理解", + "需求挖掘": "需求挖掘", + "异议处理": "异议处理", + "成交技巧": "成交技巧", + "客户维护": "客户维护" + } + + for dimensions in all_dimensions: + if dimensions: + # 如果是字符串,进行JSON反序列化 + if isinstance(dimensions, str): + try: + dimensions = json.loads(dimensions) + except json.JSONDecodeError: + logger.warning(f"无法解析能力维度数据: {dimensions}") + continue + + # 处理字典格式:{"sales_ability": 79.0, ...} + if isinstance(dimensions, dict): + for key, score in dimensions.items(): + name = dimension_name_map.get(key, key) + if name not in ability_scores: + ability_scores[name] = [] + ability_scores[name].append(float(score)) + + # 处理列表格式:[{"name": "沟通表达", "score": 85}, ...] + elif isinstance(dimensions, list): + for dim in dimensions: + if not isinstance(dim, dict): + logger.warning(f"能力维度项格式错误: {type(dim)}") + continue + + name = dim.get('name', '') + score = dim.get('score', 0) + if name: + mapped_name = dimension_name_map.get(name, name) + if mapped_name not in ability_scores: + ability_scores[mapped_name] = [] + ability_scores[mapped_name].append(float(score)) + else: + logger.warning(f"能力维度数据格式错误: {type(dimensions)}") + + # 计算平均分 + avg_scores = { + name: sum(scores) / len(scores) + for name, scores in ability_scores.items() + } + + # 按固定顺序排列维度(支持多种维度组合) + # 优先使用六维度,如果没有则使用三维度 + standard_dimensions_six = ["沟通表达", "倾听理解", "需求挖掘", "异议处理", "成交技巧", "客户维护"] + standard_dimensions_three = ["销售能力", "服务态度", "技术能力"] + + # 判断使用哪种维度标准 + has_six_dimensions = any(dim in avg_scores for dim in standard_dimensions_six) + has_three_dimensions = any(dim in avg_scores for dim in standard_dimensions_three) + + if has_six_dimensions: + standard_dimensions = standard_dimensions_six + elif has_three_dimensions: + standard_dimensions = standard_dimensions_three + else: + # 如果都没有,使用实际数据的维度 + standard_dimensions = list(avg_scores.keys()) + + dimensions = [] + values = [] + + for dim in standard_dimensions: + if dim in avg_scores: + dimensions.append(dim) + values.append(round(avg_scores[dim], 1)) + + # 找出短板(平均分<80) + weaknesses = [] + weakness_suggestions = { + # 六维度建议 + "异议处理": "建议加强异议处理专项训练,增加实战演练", + "成交技巧": "需要系统学习成交话术和时机把握", + "需求挖掘": "提升提问技巧,深入了解客户需求", + "沟通表达": "加强沟通技巧训练,提升表达能力", + "倾听理解": "培养同理心,提高倾听和理解能力", + "客户维护": "学习客户关系管理,提升服务质量", + # 三维度建议 + "销售能力": "建议加强销售技巧训练,提升成交率", + "服务态度": "需要改善服务态度,提高客户满意度", + "技术能力": "建议学习产品知识,提升专业能力" + } + + for name, score in avg_scores.items(): + if score < 80: + weaknesses.append({ + "name": name, + "avg_score": int(score), + "suggestion": weakness_suggestions.get(name, f"建议加强{name}专项训练") + }) + + # 按分数升序排列 + weaknesses.sort(key=lambda x: x['avg_score']) + + data = { + "radar_data": { + "dimensions": dimensions, + "values": values + }, + "weaknesses": weaknesses + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取能力分析失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取能力分析失败: {str(e)}", data=None) + + +@router.get("/rankings", response_model=ResponseModel) +async def get_rankings( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取排行榜数据 + + 返回学习时长排行和成绩排行Top 5 + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={ + "study_time_ranking": [], + "score_ranking": [] + } + ) + + # 学习时长排行(基于陪练会话) + stmt = ( + select( + User.id, + User.full_name, + User.avatar_url, + Position.name.label('position_name'), + func.sum(PracticeSession.duration_seconds).label('total_duration') + ) + .join(PracticeSession, PracticeSession.user_id == User.id) + .outerjoin(PositionMember, and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False # noqa: E712 + )) + .outerjoin(Position, Position.id == PositionMember.position_id) + .where( + and_( + User.id.in_(member_ids), + PracticeSession.status == 'completed' + ) + ) + .group_by(User.id, User.full_name, User.avatar_url, Position.name) + .order_by(func.sum(PracticeSession.duration_seconds).desc()) + .limit(5) + ) + result = await db.execute(stmt) + study_time_data = result.all() + + study_time_ranking = [] + for row in study_time_data: + study_time_ranking.append({ + "id": row.id, + "name": row.full_name or f"用户{row.id}", + "position": row.position_name or "未分配岗位", + "avatar": row.avatar_url or "", + "study_time": round(row.total_duration / 3600, 1) # 转换为小时 + }) + + # 成绩排行(基于考试round1_score) + stmt = ( + select( + User.id, + User.full_name, + User.avatar_url, + Position.name.label('position_name'), + func.avg(Exam.round1_score).label('avg_score') + ) + .join(Exam, Exam.user_id == User.id) + .outerjoin(PositionMember, and_( + PositionMember.user_id == User.id, + PositionMember.is_deleted == False # noqa: E712 + )) + .outerjoin(Position, Position.id == PositionMember.position_id) + .where( + and_( + User.id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + .group_by(User.id, User.full_name, User.avatar_url, Position.name) + .order_by(func.avg(Exam.round1_score).desc()) + .limit(5) + ) + result = await db.execute(stmt) + score_data = result.all() + + score_ranking = [] + for row in score_data: + score_ranking.append({ + "id": row.id, + "name": row.full_name or f"用户{row.id}", + "position": row.position_name or "未分配岗位", + "avatar": row.avatar_url or "", + "avg_score": round(row.avg_score, 1) + }) + + data = { + "study_time_ranking": study_time_ranking, + "score_ranking": score_ranking + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取排行榜失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取排行榜失败: {str(e)}", data=None) + + +@router.get("/activities", response_model=ResponseModel) +async def get_activities( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队学习动态 + + 返回最近20条活动记录(考试、陪练等) + """ + try: + # 获取可访问的团队 + team_ids = await get_accessible_teams(current_user, db) + member_ids = await get_team_member_ids(team_ids, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data={"activities": []} + ) + + activities = [] + + # 获取最近的考试记录 + stmt = ( + select(Exam, User.full_name, Course.name.label('course_name')) + .join(User, User.id == Exam.user_id) + .join(Course, Course.id == Exam.course_id) + .where( + and_( + Exam.user_id.in_(member_ids), + Exam.status.in_(['completed', 'submitted']) + ) + ) + .order_by(Exam.updated_at.desc()) + .limit(10) + ) + result = await db.execute(stmt) + exam_records = result.all() + + for exam, user_name, course_name in exam_records: + score = exam.round1_score or 0 + activity_type = "success" if score >= 60 else "danger" + result_type = "success" if score >= 60 else "danger" + result_text = f"成绩:{int(score)}分" if score >= 60 else "未通过" + + activities.append({ + "id": f"exam_{exam.id}", + "user_name": user_name or f"用户{exam.user_id}", + "action": "完成了" if score >= 60 else "参加了", + "target": f"《{course_name}》课程考试", + "time": exam.updated_at.strftime("%Y-%m-%d %H:%M"), + "type": activity_type, + "result": {"type": result_type, "text": result_text} + }) + + # 获取最近的陪练记录 + stmt = ( + select(PracticeSession, User.full_name, PracticeReport.total_score) + .join(User, User.id == PracticeSession.user_id) + .outerjoin(PracticeReport, PracticeReport.session_id == PracticeSession.session_id) + .where( + and_( + PracticeSession.user_id.in_(member_ids), + PracticeSession.status == 'completed' + ) + ) + .order_by(PracticeSession.end_time.desc()) + .limit(10) + ) + result = await db.execute(stmt) + practice_records = result.all() + + for session, user_name, total_score in practice_records: + activity_type = "primary" + result_data = None + if total_score: + result_data = {"type": "", "text": f"评分:{int(total_score)}分"} + + activities.append({ + "id": f"practice_{session.id}", + "user_name": user_name or f"用户{session.user_id}", + "action": "参加了", + "target": "AI陪练训练", + "time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "", + "type": activity_type, + "result": result_data + }) + + # 按时间倒序排列,取前20条 + activities.sort(key=lambda x: x['time'], reverse=True) + activities = activities[:20] + + return ResponseModel( + code=200, + message="success", + data={"activities": activities} + ) + + except Exception as e: + logger.error(f"获取团队动态失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取团队动态失败: {str(e)}", data=None) + diff --git a/backend/app/api/v1/team_management.py b/backend/app/api/v1/team_management.py new file mode 100644 index 0000000..438d9e8 --- /dev/null +++ b/backend/app/api/v1/team_management.py @@ -0,0 +1,896 @@ +""" +团队成员管理 API 路由 +提供团队统计、成员列表、成员详情、学习报告等功能 +""" + +from datetime import datetime, timedelta +from typing import Any, Dict, List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import and_, func, or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user as get_current_user, get_db +from app.core.logger import logger +from app.models.course import Course +from app.models.exam import Exam +from app.models.position import Position +from app.models.position_course import PositionCourse +from app.models.position_member import PositionMember +from app.models.practice import PracticeReport, PracticeSession +from app.models.user import User, UserTeam +from app.schemas.base import PaginatedResponse, ResponseModel + +router = APIRouter(prefix="/team/management", tags=["team-management"]) + + +async def get_accessible_team_member_ids( + current_user: User, + db: AsyncSession +) -> List[int]: + """获取用户可访问的团队成员ID列表""" + if current_user.role in ['admin', 'manager']: + # 管理员查看所有团队成员 + stmt = select(UserTeam.user_id).distinct() + result = await db.execute(stmt) + return [row[0] for row in result.all()] + else: + # 普通用户只查看自己团队的成员 + # 1. 先查询用户所在的团队 + stmt = select(UserTeam.team_id).where(UserTeam.user_id == current_user.id) + result = await db.execute(stmt) + team_ids = [row[0] for row in result.all()] + + if not team_ids: + return [] + + # 2. 查询这些团队的所有成员 + stmt = select(UserTeam.user_id).where( + UserTeam.team_id.in_(team_ids) + ).distinct() + result = await db.execute(stmt) + return [row[0] for row in result.all()] + + +def calculate_member_status( + last_login: Optional[datetime], + last_exam: Optional[datetime], + last_practice: Optional[datetime], + has_ongoing: bool +) -> str: + """ + 计算成员活跃状态 + + Args: + last_login: 最后登录时间 + last_exam: 最后考试时间 + last_practice: 最后陪练时间 + has_ongoing: 是否有进行中的活动 + + Returns: + 状态: active(活跃), learning(学习中), rest(休息) + """ + # 获取最近活跃时间 + times = [t for t in [last_login, last_exam, last_practice] if t is not None] + if not times: + return 'rest' + + last_active = max(times) + thirty_days_ago = datetime.now() - timedelta(days=30) + + # 判断状态 + if last_active >= thirty_days_ago: + if has_ongoing: + return 'learning' + else: + return 'active' + else: + return 'rest' + + +@router.get("/statistics", response_model=ResponseModel) +async def get_team_statistics( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队统计数据 + + 返回:团队总人数、活跃成员数、平均学习进度、团队平均分 + """ + try: + # 获取可访问的团队成员ID + member_ids = await get_accessible_team_member_ids(current_user, db) + + # 团队总人数 + team_count = len(member_ids) + + if team_count == 0: + return ResponseModel( + code=200, + message="success", + data={ + "teamCount": 0, + "activeMembers": 0, + "avgProgress": 0, + "avgScore": 0 + } + ) + + # 统计活跃成员数(最近30天有活动) + thirty_days_ago = datetime.now() - timedelta(days=30) + + # 统计最近30天有登录或有考试或有陪练的用户 + active_users_stmt = select(func.count(func.distinct(User.id))).where( + and_( + User.id.in_(member_ids), + or_( + User.last_login_at >= thirty_days_ago, + User.id.in_( + select(Exam.user_id).where( + and_( + Exam.user_id.in_(member_ids), + Exam.created_at >= thirty_days_ago + ) + ) + ), + User.id.in_( + select(PracticeSession.user_id).where( + and_( + PracticeSession.user_id.in_(member_ids), + PracticeSession.start_time >= thirty_days_ago + ) + ) + ) + ) + ) + ) + result = await db.execute(active_users_stmt) + active_members = result.scalar() or 0 + + # 计算平均学习进度(每个成员的完成课程/应完成课程的平均值) + # 统计每个成员的进度,然后计算平均值 + total_progress = 0.0 + members_with_courses = 0 + + for member_id in member_ids: + # 获取该成员岗位分配的课程数 + member_courses_stmt = select( + func.count(func.distinct(PositionCourse.course_id)) + ).select_from(PositionMember).join( + PositionCourse, + PositionCourse.position_id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == member_id, + PositionMember.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(member_courses_stmt) + member_total_courses = result.scalar() or 0 + + if member_total_courses > 0: + # 获取该成员已完成(及格)的课程数 + member_completed_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == member_id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(member_completed_stmt) + member_completed = result.scalar() or 0 + + # 计算该成员的进度(最大100%) + member_progress = min((member_completed / member_total_courses) * 100, 100) + total_progress += member_progress + members_with_courses += 1 + + avg_progress = round(total_progress / members_with_courses, 1) if members_with_courses > 0 else 0.0 + + # 计算团队平均分(使用round1_score) + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id.in_(member_ids), + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + data = { + "teamCount": team_count, + "activeMembers": active_members, + "avgProgress": avg_progress, + "avgScore": avg_score + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取团队统计失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取团队统计失败: {str(e)}", data=None) + + +@router.get("/members", response_model=ResponseModel[PaginatedResponse]) +async def get_team_members( + page: int = Query(1, ge=1, description="页码"), + size: int = Query(20, ge=1, le=100, description="每页数量"), + search_text: Optional[str] = Query(None, description="搜索姓名、岗位"), + status: Optional[str] = Query(None, description="筛选状态: active/learning/rest"), + position: Optional[str] = Query(None, description="筛选岗位"), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队成员列表(带筛选、搜索、分页) + + 返回成员基本信息、学习进度、成绩、学习时长等 + """ + try: + # 获取可访问的团队成员ID + member_ids = await get_accessible_team_member_ids(current_user, db) + + if not member_ids: + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=[], + total=0, + page=page, + page_size=size, + pages=0 + ) + ) + + # 构建基础查询 + stmt = select(User).where( + and_( + User.id.in_(member_ids), + User.is_deleted == False # noqa: E712 + ) + ) + + # 搜索条件(姓名) + if search_text: + like_pattern = f"%{search_text}%" + stmt = stmt.where( + or_( + User.full_name.ilike(like_pattern), + User.username.ilike(like_pattern) + ) + ) + + # 先获取所有符合条件的用户,然后在Python中过滤状态和岗位 + result = await db.execute(stmt) + all_users = result.scalars().all() + + # 为每个用户计算详细信息 + member_list = [] + thirty_days_ago = datetime.now() - timedelta(days=30) + + for user in all_users: + # 获取用户岗位 + position_stmt = select(Position.name).select_from(PositionMember).join( + Position, + Position.id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == user.id, + PositionMember.is_deleted == False # noqa: E712 + ) + ).limit(1) + result = await db.execute(position_stmt) + position_name = result.scalar() + + # 如果有岗位筛选且不匹配,跳过 + if position and position_name != position: + continue + + # 获取最近考试时间 + last_exam_stmt = select(func.max(Exam.created_at)).where( + Exam.user_id == user.id + ) + result = await db.execute(last_exam_stmt) + last_exam = result.scalar() + + # 获取最近陪练时间 + last_practice_stmt = select(func.max(PracticeSession.start_time)).where( + PracticeSession.user_id == user.id + ) + result = await db.execute(last_practice_stmt) + last_practice = result.scalar() + + # 检查是否有进行中的活动 + has_ongoing_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.status == 'started' + ) + ) + result = await db.execute(has_ongoing_stmt) + has_ongoing = (result.scalar() or 0) > 0 + + # 计算状态 + member_status = calculate_member_status( + user.last_login_at, + last_exam, + last_practice, + has_ongoing + ) + + # 如果有状态筛选且不匹配,跳过 + if status and member_status != status: + continue + + # 统计学习进度 + # 1. 获取岗位分配的课程总数 + total_courses_stmt = select( + func.count(func.distinct(PositionCourse.course_id)) + ).select_from(PositionMember).join( + PositionCourse, + PositionCourse.position_id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == user.id, + PositionMember.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(total_courses_stmt) + total_courses = result.scalar() or 0 + + # 2. 统计已完成的考试(及格) + completed_courses_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(completed_courses_stmt) + completed_courses = result.scalar() or 0 + + # 3. 计算进度 + progress = 0 + if total_courses > 0: + progress = int((completed_courses / total_courses) * 100) + + # 统计平均成绩 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + # 统计学习时长(考试时长+陪练时长) + exam_time_stmt = select( + func.coalesce(func.sum(Exam.duration_minutes), 0) + ).where(Exam.user_id == user.id) + result = await db.execute(exam_time_stmt) + exam_minutes = float(result.scalar() or 0) + + practice_time_stmt = select( + func.coalesce(func.sum(PracticeSession.duration_seconds), 0) + ).where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + result = await db.execute(practice_time_stmt) + practice_seconds = float(result.scalar() or 0) + + total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1) + + # 获取最近活跃时间 + active_times = [t for t in [user.last_login_at, last_exam, last_practice] if t is not None] + last_active = max(active_times).strftime("%Y-%m-%d %H:%M") if active_times else "-" + + member_list.append({ + "id": user.id, + "name": user.full_name or user.username, + "avatar": user.avatar_url or "", + "position": position_name or "未分配岗位", + "status": member_status, + "progress": progress, + "completedCourses": completed_courses, + "totalCourses": total_courses, + "avgScore": avg_score, + "studyTime": total_hours, + "lastActive": last_active, + "joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-", + "email": user.email or "", + "phone": user.phone or "", + "passRate": 100 if completed_courses > 0 else 0 # 简化计算 + }) + + # 分页 + total = len(member_list) + pages = (total + size - 1) // size if size > 0 else 0 + start = (page - 1) * size + end = start + size + items = member_list[start:end] + + return ResponseModel( + code=200, + message="success", + data=PaginatedResponse( + items=items, + total=total, + page=page, + page_size=size, + pages=pages + ) + ) + + except Exception as e: + logger.error(f"获取团队成员列表失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取团队成员列表失败: {str(e)}", + data=None + ) + + +@router.get("/members/{member_id}/detail", response_model=ResponseModel) +async def get_member_detail( + member_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取成员详情 + + 返回完整的成员信息和最近学习记录 + """ + try: + # 权限检查:确保member_id在可访问范围内 + accessible_ids = await get_accessible_team_member_ids(current_user, db) + if member_id not in accessible_ids: + return ResponseModel( + code=403, + message="无权访问该成员信息", + data=None + ) + + # 获取用户基本信息 + stmt = select(User).where( + and_( + User.id == member_id, + User.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user: + return ResponseModel(code=404, message="成员不存在", data=None) + + # 获取岗位 + position_stmt = select(Position.name).select_from(PositionMember).join( + Position, + Position.id == PositionMember.position_id + ).where( + and_( + PositionMember.user_id == user.id, + PositionMember.is_deleted == False # noqa: E712 + ) + ).limit(1) + result = await db.execute(position_stmt) + position_name = result.scalar() or "未分配岗位" + + # 计算状态 + last_exam_stmt = select(func.max(Exam.created_at)).where(Exam.user_id == user.id) + result = await db.execute(last_exam_stmt) + last_exam = result.scalar() + + last_practice_stmt = select(func.max(PracticeSession.start_time)).where( + PracticeSession.user_id == user.id + ) + result = await db.execute(last_practice_stmt) + last_practice = result.scalar() + + has_ongoing_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.status == 'started' + ) + ) + result = await db.execute(has_ongoing_stmt) + has_ongoing = (result.scalar() or 0) > 0 + + member_status = calculate_member_status( + user.last_login_at, + last_exam, + last_practice, + has_ongoing + ) + + # 统计学习数据 + # 学习时长 + exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where( + Exam.user_id == user.id + ) + result = await db.execute(exam_time_stmt) + exam_minutes = result.scalar() or 0 + + practice_time_stmt = select( + func.coalesce(func.sum(PracticeSession.duration_seconds), 0) + ).where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + result = await db.execute(practice_time_stmt) + practice_seconds = result.scalar() or 0 + + study_time = round(exam_minutes / 60 + practice_seconds / 3600, 1) + + # 完成课程数 + completed_courses_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(completed_courses_stmt) + completed_courses = result.scalar() or 0 + + # 平均成绩 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + # 通过率 + total_exams_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(total_exams_stmt) + total_exams = result.scalar() or 0 + + passed_exams_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(passed_exams_stmt) + passed_exams = result.scalar() or 0 + + pass_rate = round((passed_exams / total_exams) * 100) if total_exams > 0 else 0 + + # 获取最近学习记录(最近10条考试和陪练) + recent_records = [] + + # 考试记录 + exam_records_stmt = ( + select(Exam, Course.name.label('course_name')) + .join(Course, Course.id == Exam.course_id) + .where( + and_( + Exam.user_id == user.id, + Exam.status.in_(['completed', 'submitted']) + ) + ) + .order_by(Exam.updated_at.desc()) + .limit(10) + ) + result = await db.execute(exam_records_stmt) + exam_records = result.all() + + for exam, course_name in exam_records: + score = exam.round1_score or 0 + record_type = "success" if score >= 60 else "danger" + recent_records.append({ + "id": f"exam_{exam.id}", + "time": exam.updated_at.strftime("%Y-%m-%d %H:%M"), + "content": f"完成《{course_name}》课程考试,成绩:{int(score)}分", + "type": record_type + }) + + # 陪练记录 + practice_records_stmt = ( + select(PracticeSession) + .where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + .order_by(PracticeSession.end_time.desc()) + .limit(5) + ) + result = await db.execute(practice_records_stmt) + practice_records = result.scalars().all() + + for session in practice_records: + recent_records.append({ + "id": f"practice_{session.id}", + "time": session.end_time.strftime("%Y-%m-%d %H:%M") if session.end_time else "", + "content": "参加AI陪练训练", + "type": "primary" + }) + + # 按时间排序 + recent_records.sort(key=lambda x: x['time'], reverse=True) + recent_records = recent_records[:10] + + data = { + "id": user.id, + "name": user.full_name or user.username, + "avatar": user.avatar_url or "", + "position": position_name, + "status": member_status, + "joinTime": user.created_at.strftime("%Y-%m-%d") if user.created_at else "-", + "email": user.email or "", + "phone": user.phone or "", + "studyTime": study_time, + "completedCourses": completed_courses, + "avgScore": avg_score, + "passRate": pass_rate, + "recentRecords": recent_records + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取成员详情失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取成员详情失败: {str(e)}", + data=None + ) + + +@router.get("/members/{member_id}/report", response_model=ResponseModel) +async def get_member_report( + member_id: int, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取成员学习报告 + + 返回学习概览、30天进度趋势、能力评估、详细学习记录 + """ + try: + # 权限检查 + accessible_ids = await get_accessible_team_member_ids(current_user, db) + if member_id not in accessible_ids: + return ResponseModel(code=403, message="无权访问该成员信息", data=None) + + # 获取用户信息 + stmt = select(User).where( + and_( + User.id == member_id, + User.is_deleted == False # noqa: E712 + ) + ) + result = await db.execute(stmt) + user = result.scalar_one_or_none() + + if not user: + return ResponseModel(code=404, message="成员不存在", data=None) + + # 1. 报告概览 + # 学习总时长 + exam_time_stmt = select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where( + Exam.user_id == user.id + ) + result = await db.execute(exam_time_stmt) + exam_minutes = result.scalar() or 0 + + practice_time_stmt = select( + func.coalesce(func.sum(PracticeSession.duration_seconds), 0) + ).where( + and_( + PracticeSession.user_id == user.id, + PracticeSession.status == 'completed' + ) + ) + result = await db.execute(practice_time_stmt) + practice_seconds = result.scalar() or 0 + + total_hours = round(exam_minutes / 60 + practice_seconds / 3600, 1) + + # 完成课程数 + completed_courses_stmt = select( + func.count(func.distinct(Exam.course_id)) + ).where( + and_( + Exam.user_id == user.id, + Exam.round1_score >= 60, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(completed_courses_stmt) + completed_courses = result.scalar() or 0 + + # 平均成绩 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_( + Exam.user_id == user.id, + Exam.round1_score.isnot(None), + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(avg_score_stmt) + avg_score_value = result.scalar() + avg_score = round(float(avg_score_value), 1) if avg_score_value else 0.0 + + # 学习排名(简化:在团队中的排名) + # TODO: 实现真实排名计算 + ranking = "第5名" + + overview = [ + { + "label": "学习总时长", + "value": f"{total_hours}小时", + "icon": "Clock", + "color": "#667eea", + "bgColor": "rgba(102, 126, 234, 0.1)" + }, + { + "label": "完成课程", + "value": f"{completed_courses}门", + "icon": "CircleCheck", + "color": "#67c23a", + "bgColor": "rgba(103, 194, 58, 0.1)" + }, + { + "label": "平均成绩", + "value": f"{avg_score}分", + "icon": "Trophy", + "color": "#e6a23c", + "bgColor": "rgba(230, 162, 60, 0.1)" + }, + { + "label": "学习排名", + "value": ranking, + "icon": "Medal", + "color": "#f56c6c", + "bgColor": "rgba(245, 108, 108, 0.1)" + } + ] + + # 2. 30天学习进度趋势 + thirty_days_ago = datetime.now() - timedelta(days=30) + dates = [] + progress_data = [] + + for i in range(30): + date = thirty_days_ago + timedelta(days=i) + dates.append(date.strftime("%m-%d")) + + # 统计该日期之前完成的考试数 + cumulative_exams_stmt = select(func.count(Exam.id)).where( + and_( + Exam.user_id == user.id, + Exam.created_at <= date, + Exam.status.in_(['completed', 'submitted']) + ) + ) + result = await db.execute(cumulative_exams_stmt) + cumulative = result.scalar() or 0 + + # 进度 = 累计考试数 * 10(简化计算) + progress = min(cumulative * 10, 100) + progress_data.append(progress) + + # 3. 能力评估(从陪练报告聚合) + ability_stmt = select(PracticeReport.ability_dimensions).where( + PracticeReport.user_id == user.id + ) + result = await db.execute(ability_stmt) + all_dimensions = result.scalars().all() + + abilities = [] + if all_dimensions: + # 聚合能力数据 + ability_scores: Dict[str, List[float]] = {} + + for dimensions in all_dimensions: + if dimensions: + for dim in dimensions: + name = dim.get('name', '') + score = dim.get('score', 0) + if name: + if name not in ability_scores: + ability_scores[name] = [] + ability_scores[name].append(float(score)) + + # 计算平均分 + for name, scores in ability_scores.items(): + avg = sum(scores) / len(scores) + description = "表现良好" if avg >= 80 else "需要加强" + abilities.append({ + "name": name, + "score": int(avg), + "description": description + }) + else: + # 默认能力评估 + default_abilities = [ + {"name": "沟通表达", "score": 0, "description": "暂无数据"}, + {"name": "需求挖掘", "score": 0, "description": "暂无数据"}, + {"name": "产品知识", "score": 0, "description": "暂无数据"}, + {"name": "成交技巧", "score": 0, "description": "暂无数据"} + ] + abilities = default_abilities + + # 4. 详细学习记录(最近20条) + records = [] + + # 考试记录 + exam_records_stmt = ( + select(Exam, Course.name.label('course_name')) + .join(Course, Course.id == Exam.course_id) + .where( + and_( + Exam.user_id == user.id, + Exam.status.in_(['completed', 'submitted']) + ) + ) + .order_by(Exam.updated_at.desc()) + .limit(20) + ) + result = await db.execute(exam_records_stmt) + exam_records = result.all() + + for exam, course_name in exam_records: + score = exam.round1_score or 0 + records.append({ + "date": exam.updated_at.strftime("%Y-%m-%d"), + "course": course_name, + "duration": exam.duration_minutes or 0, + "score": int(score), + "status": "completed" + }) + + data = { + "overview": overview, + "progressTrend": { + "dates": dates, + "data": progress_data + }, + "abilities": abilities, + "records": records[:20] + } + + return ResponseModel(code=200, message="success", data=data) + + except Exception as e: + logger.error(f"获取成员学习报告失败: {e}", exc_info=True) + return ResponseModel( + code=500, + message=f"获取成员学习报告失败: {str(e)}", + data=None + ) + diff --git a/backend/app/api/v1/teams.py b/backend/app/api/v1/teams.py new file mode 100644 index 0000000..cfaa82a --- /dev/null +++ b/backend/app/api/v1/teams.py @@ -0,0 +1,55 @@ +""" +团队相关 API 路由 +""" + +from typing import List, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import or_, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user as get_current_user, get_db +from app.core.logger import logger +from app.models.user import Team +from app.schemas.base import ResponseModel + + +router = APIRouter(prefix="/teams", tags=["teams"]) + + +@router.get("/", response_model=ResponseModel) +async def list_teams( + keyword: Optional[str] = Query(None, description="按名称或编码模糊搜索"), + current_user=Depends(get_current_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取团队列表 + + 任何登录用户均可查询团队列表,用于前端下拉选择。 + """ + try: + stmt = select(Team).where(Team.is_deleted == False) # noqa: E712 + if keyword: + like = f"%{keyword}%" + stmt = stmt.where(or_(Team.name.ilike(like), Team.code.ilike(like))) + + rows: List[Team] = (await db.execute(stmt)).scalars().all() + data = [ + { + "id": t.id, + "name": t.name, + "code": t.code, + "team_type": t.team_type, + } + for t in rows + ] + return ResponseModel(code=200, message="OK", data=data) + except Exception: + logger.error("查询团队列表失败", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="查询团队列表失败", + ) + + diff --git a/backend/app/api/v1/training.py b/backend/app/api/v1/training.py new file mode 100644 index 0000000..a61d51b --- /dev/null +++ b/backend/app/api/v1/training.py @@ -0,0 +1,507 @@ +"""陪练模块API路由""" +import logging +from typing import List, 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, require_admin +from app.schemas.base import ResponseModel +from app.schemas.training import ( + TrainingSceneCreate, + TrainingSceneUpdate, + TrainingSceneResponse, + TrainingSessionResponse, + TrainingMessageResponse, + TrainingReportResponse, + StartTrainingRequest, + StartTrainingResponse, + EndTrainingRequest, + EndTrainingResponse, + TrainingSceneListQuery, + TrainingSessionListQuery, + PaginatedResponse, +) +from app.services.training_service import ( + TrainingSceneService, + TrainingSessionService, + TrainingMessageService, + TrainingReportService, +) +from app.models.training import TrainingSceneStatus, TrainingSessionStatus + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/training", tags=["陪练模块"]) + +# 服务实例 +scene_service = TrainingSceneService() +session_service = TrainingSessionService() +message_service = TrainingMessageService() +report_service = TrainingReportService() + + +# ========== 陪练场景管理 ========== + + +@router.get( + "/scenes", response_model=ResponseModel[PaginatedResponse[TrainingSceneResponse]] +) +async def get_training_scenes( + category: Optional[str] = Query(None, description="场景分类"), + status: Optional[TrainingSceneStatus] = Query(None, description="场景状态"), + is_public: Optional[bool] = Query(None, description="是否公开"), + search: Optional[str] = Query(None, description="搜索关键词"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 获取陪练场景列表 + + - 支持按分类、状态、是否公开筛选 + - 支持关键词搜索 + - 支持分页 + """ + try: + # 计算分页参数 + skip = (page - 1) * page_size + + # 获取用户等级(TODO: 从User服务获取) + user_level = 1 + + # 获取场景列表 + scenes = await scene_service.get_active_scenes( + db, + category=category, + is_public=is_public, + user_level=user_level, + skip=skip, + limit=page_size, + ) + + # 获取总数 + from sqlalchemy import select, func, and_ + from app.models.training import TrainingScene + + count_query = ( + select(func.count()) + .select_from(TrainingScene) + .where( + and_( + TrainingScene.status == TrainingSceneStatus.ACTIVE, + TrainingScene.is_deleted == False, + ) + ) + ) + + if category: + count_query = count_query.where(TrainingScene.category == category) + if is_public is not None: + count_query = count_query.where(TrainingScene.is_public == is_public) + + result = await db.execute(count_query) + total = result.scalar_one() + + # 计算总页数 + pages = (total + page_size - 1) // page_size + + return ResponseModel( + data=PaginatedResponse( + items=scenes, total=total, page=page, page_size=page_size, pages=pages + ), + message="获取陪练场景列表成功", + ) + + except Exception as e: + logger.error(f"获取陪练场景列表失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练场景列表失败" + ) + + +@router.get("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse]) +async def get_training_scene( + scene_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练场景详情""" + scene = await scene_service.get(db, scene_id) + + if not scene or scene.is_deleted: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在") + + # 检查访问权限 + if not scene.is_public and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此场景") + + return ResponseModel(data=scene, message="获取陪练场景成功") + + +@router.post("/scenes", response_model=ResponseModel[TrainingSceneResponse]) +async def create_training_scene( + scene_in: TrainingSceneCreate, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """ + 创建陪练场景(管理员) + + - 需要管理员权限 + - 场景默认为草稿状态 + """ + try: + scene = await scene_service.create_scene( + db, scene_in=scene_in, created_by=current_user["id"] + ) + + logger.info(f"管理员 {current_user['id']} 创建了陪练场景: {scene.id}") + + return ResponseModel(data=scene, message="创建陪练场景成功") + + except Exception as e: + logger.error(f"创建陪练场景失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="创建陪练场景失败" + ) + + +@router.put("/scenes/{scene_id}", response_model=ResponseModel[TrainingSceneResponse]) +async def update_training_scene( + scene_id: int, + scene_in: TrainingSceneUpdate, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """更新陪练场景(管理员)""" + scene = await scene_service.update_scene( + db, scene_id=scene_id, scene_in=scene_in, updated_by=current_user["id"] + ) + + if not scene: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在") + + logger.info(f"管理员 {current_user['id']} 更新了陪练场景: {scene_id}") + + return ResponseModel(data=scene, message="更新陪练场景成功") + + +@router.delete("/scenes/{scene_id}", response_model=ResponseModel[bool]) +async def delete_training_scene( + scene_id: int, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +): + """删除陪练场景(管理员)""" + success = await scene_service.soft_delete(db, id=scene_id) + + if not success: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在") + + logger.info(f"管理员 {current_user['id']} 删除了陪练场景: {scene_id}") + + return ResponseModel(data=True, message="删除陪练场景成功") + + +# ========== 陪练会话管理 ========== + + +@router.post("/sessions", response_model=ResponseModel[StartTrainingResponse]) +async def start_training( + request: StartTrainingRequest, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 开始陪练会话 + + - 需要登录 + - 创建会话记录 + - 初始化Coze对话(如果配置了Bot) + - 返回会话信息和WebSocket连接地址(如果支持) + """ + try: + response = await session_service.start_training( + db, request=request, user_id=current_user["id"] + ) + + logger.info(f"用户 {current_user['id']} 开始陪练会话: {response.session_id}") + + return ResponseModel(data=response, message="开始陪练成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"开始陪练失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="开始陪练失败" + ) + + +@router.post( + "/sessions/{session_id}/end", response_model=ResponseModel[EndTrainingResponse] +) +async def end_training( + session_id: int, + request: EndTrainingRequest, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 结束陪练会话 + + - 需要登录且是会话创建者 + - 更新会话状态 + - 可选生成陪练报告 + """ + try: + response = await session_service.end_training( + db, session_id=session_id, request=request, user_id=current_user["id"] + ) + + logger.info(f"用户 {current_user['id']} 结束陪练会话: {session_id}") + + return ResponseModel(data=response, message="结束陪练成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"结束陪练失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="结束陪练失败" + ) + + +@router.get( + "/sessions", + response_model=ResponseModel[PaginatedResponse[TrainingSessionResponse]], +) +async def get_training_sessions( + scene_id: Optional[int] = Query(None, description="场景ID"), + status: Optional[TrainingSessionStatus] = Query(None, description="会话状态"), + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取用户的陪练会话列表""" + try: + skip = (page - 1) * page_size + + sessions = await session_service.get_user_sessions( + db, + user_id=current_user["id"], + scene_id=scene_id, + status=status, + skip=skip, + limit=page_size, + ) + + # 获取总数 + from sqlalchemy import select, func + from app.models.training import TrainingSession + + count_query = ( + select(func.count()) + .select_from(TrainingSession) + .where(TrainingSession.user_id == current_user["id"]) + ) + + if scene_id: + count_query = count_query.where(TrainingSession.scene_id == scene_id) + if status: + count_query = count_query.where(TrainingSession.status == status) + + result = await db.execute(count_query) + total = result.scalar_one() + + pages = (total + page_size - 1) // page_size + + # 加载关联的场景信息 + for session in sessions: + await db.refresh(session, ["scene"]) + + return ResponseModel( + data=PaginatedResponse( + items=sessions, total=total, page=page, page_size=page_size, pages=pages + ), + message="获取陪练会话列表成功", + ) + + except Exception as e: + logger.error(f"获取陪练会话列表失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练会话列表失败" + ) + + +@router.get( + "/sessions/{session_id}", response_model=ResponseModel[TrainingSessionResponse] +) +async def get_training_session( + session_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练会话详情""" + session = await session_service.get(db, session_id) + + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在") + + # 检查访问权限 + if session.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话") + + # 加载关联数据 + await db.refresh(session, ["scene"]) + + # 获取消息数量 + messages = await message_service.get_session_messages(db, session_id=session_id) + session.message_count = len(messages) + + return ResponseModel(data=session, message="获取陪练会话成功") + + +# ========== 消息管理 ========== + + +@router.get( + "/sessions/{session_id}/messages", + response_model=ResponseModel[List[TrainingMessageResponse]], +) +async def get_training_messages( + session_id: int, + skip: int = Query(0, ge=0, description="跳过数量"), + limit: int = Query(100, ge=1, le=500, description="返回数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练会话的消息列表""" + # 验证会话访问权限 + session = await session_service.get(db, session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在") + + if session.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话消息") + + messages = await message_service.get_session_messages( + db, session_id=session_id, skip=skip, limit=limit + ) + + return ResponseModel(data=messages, message="获取消息列表成功") + + +# ========== 报告管理 ========== + + +@router.get( + "/reports", response_model=ResponseModel[PaginatedResponse[TrainingReportResponse]] +) +async def get_training_reports( + page: int = Query(1, ge=1, description="页码"), + page_size: int = Query(20, ge=1, le=100, description="每页数量"), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取用户的陪练报告列表""" + try: + skip = (page - 1) * page_size + + reports = await report_service.get_user_reports( + db, user_id=current_user["id"], skip=skip, limit=page_size + ) + + # 获取总数 + from sqlalchemy import select, func + from app.models.training import TrainingReport + + count_query = ( + select(func.count()) + .select_from(TrainingReport) + .where(TrainingReport.user_id == current_user["id"]) + ) + + result = await db.execute(count_query) + total = result.scalar_one() + + pages = (total + page_size - 1) // page_size + + # 加载关联的会话信息 + for report in reports: + await db.refresh(report, ["session"]) + if report.session: + await db.refresh(report.session, ["scene"]) + + return ResponseModel( + data=PaginatedResponse( + items=reports, total=total, page=page, page_size=page_size, pages=pages + ), + message="获取陪练报告列表成功", + ) + + except Exception as e: + logger.error(f"获取陪练报告列表失败: {e}") + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="获取陪练报告列表失败" + ) + + +@router.get( + "/reports/{report_id}", response_model=ResponseModel[TrainingReportResponse] +) +async def get_training_report( + report_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """获取陪练报告详情""" + report = await report_service.get(db, report_id) + + if not report: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练报告不存在") + + # 检查访问权限 + if report.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此报告") + + # 加载关联数据 + await db.refresh(report, ["session"]) + if report.session: + await db.refresh(report.session, ["scene"]) + + return ResponseModel(data=report, message="获取陪练报告成功") + + +@router.get( + "/sessions/{session_id}/report", + response_model=ResponseModel[TrainingReportResponse], +) +async def get_session_report( + session_id: int, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """根据会话ID获取陪练报告""" + # 验证会话访问权限 + session = await session_service.get(db, session_id) + if not session: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="陪练会话不存在") + + if session.user_id != current_user["id"] and current_user.get("role") != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权访问此会话报告") + + # 获取报告 + report = await report_service.get_by_session(db, session_id=session_id) + + if not report: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="该会话暂无报告") + + # 加载关联数据 + await db.refresh(report, ["session"]) + if report.session: + await db.refresh(report.session, ["scene"]) + + return ResponseModel(data=report, message="获取会话报告成功") diff --git a/backend/app/api/v1/training_api_contract.yaml b/backend/app/api/v1/training_api_contract.yaml new file mode 100644 index 0000000..894a476 --- /dev/null +++ b/backend/app/api/v1/training_api_contract.yaml @@ -0,0 +1,854 @@ +openapi: 3.0.0 +info: + title: Training Module API + description: 考培练系统陪练模块API契约 + version: 1.0.0 + +servers: + - url: http://localhost:8000/api/v1 + description: 本地开发服务器 + +paths: + /training/scenes: + get: + summary: 获取陪练场景列表 + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: category + in: query + description: 场景分类 + schema: + type: string + - name: status + in: query + description: 场景状态 + schema: + type: string + enum: [draft, active, inactive] + - name: is_public + in: query + description: 是否公开 + schema: + type: boolean + - name: search + in: query + description: 搜索关键词 + schema: + type: string + - name: page + in: query + description: 页码 + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + description: 每页数量 + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedScenesResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + post: + summary: 创建陪练场景(管理员) + tags: + - 陪练场景 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneCreate' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + + /training/scenes/{scene_id}: + get: + summary: 获取陪练场景详情 + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: path + required: true + description: 场景ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + $ref: '#/components/responses/NotFound' + + put: + summary: 更新陪练场景(管理员) + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: path + required: true + description: 场景ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneUpdate' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSceneResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + delete: + summary: 删除陪练场景(管理员) + tags: + - 陪练场景 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: path + required: true + description: 场景ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "删除陪练场景成功" + data: + type: boolean + example: true + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions: + post: + summary: 开始陪练会话 + tags: + - 陪练会话 + security: + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/StartTrainingRequest' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/StartTrainingResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '404': + description: 场景不存在 + + get: + summary: 获取用户的陪练会话列表 + tags: + - 陪练会话 + security: + - bearerAuth: [] + parameters: + - name: scene_id + in: query + description: 场景ID + schema: + type: integer + - name: status + in: query + description: 会话状态 + schema: + type: string + enum: [created, in_progress, completed, cancelled, error] + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedSessionsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /training/sessions/{session_id}: + get: + summary: 获取陪练会话详情 + tags: + - 陪练会话 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingSessionResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions/{session_id}/end: + post: + summary: 结束陪练会话 + tags: + - 陪练会话 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/EndTrainingRequest' + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/EndTrainingResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions/{session_id}/messages: + get: + summary: 获取陪练会话的消息列表 + tags: + - 陪练消息 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + - name: skip + in: query + description: 跳过数量 + schema: + type: integer + minimum: 0 + default: 0 + - name: limit + in: query + description: 返回数量 + schema: + type: integer + minimum: 1 + maximum: 500 + default: 100 + responses: + '200': + description: 成功 + content: + application/json: + schema: + type: object + properties: + code: + type: integer + message: + type: string + data: + type: array + items: + $ref: '#/components/schemas/TrainingMessage' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/reports: + get: + summary: 获取用户的陪练报告列表 + tags: + - 陪练报告 + security: + - bearerAuth: [] + parameters: + - name: page + in: query + schema: + type: integer + minimum: 1 + default: 1 + - name: page_size + in: query + schema: + type: integer + minimum: 1 + maximum: 100 + default: 20 + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/PaginatedReportsResponse' + '401': + $ref: '#/components/responses/Unauthorized' + + /training/reports/{report_id}: + get: + summary: 获取陪练报告详情 + tags: + - 陪练报告 + security: + - bearerAuth: [] + parameters: + - name: report_id + in: path + required: true + description: 报告ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingReportResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + + /training/sessions/{session_id}/report: + get: + summary: 根据会话ID获取陪练报告 + tags: + - 陪练报告 + security: + - bearerAuth: [] + parameters: + - name: session_id + in: path + required: true + description: 会话ID + schema: + type: integer + responses: + '200': + description: 成功 + content: + application/json: + schema: + $ref: '#/components/schemas/TrainingReportResponse' + '401': + $ref: '#/components/responses/Unauthorized' + '403': + $ref: '#/components/responses/Forbidden' + '404': + $ref: '#/components/responses/NotFound' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + + responses: + Unauthorized: + description: 未授权 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + Forbidden: + description: 禁止访问 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + NotFound: + description: 资源未找到 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + schemas: + ErrorResponse: + type: object + properties: + code: + type: integer + message: + type: string + detail: + type: object + + BaseResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: "success" + request_id: + type: string + + PaginationMeta: + type: object + properties: + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer + + TrainingSceneCreate: + type: object + required: + - name + - category + properties: + name: + type: string + maxLength: 100 + description: 场景名称 + description: + type: string + description: 场景描述 + category: + type: string + maxLength: 50 + description: 场景分类 + ai_config: + type: object + description: AI配置 + prompt_template: + type: string + description: 提示词模板 + evaluation_criteria: + type: object + description: 评估标准 + is_public: + type: boolean + default: true + description: 是否公开 + required_level: + type: integer + description: 所需用户等级 + status: + type: string + enum: [draft, active, inactive] + default: draft + + TrainingSceneUpdate: + type: object + properties: + name: + type: string + maxLength: 100 + description: + type: string + category: + type: string + maxLength: 50 + ai_config: + type: object + prompt_template: + type: string + evaluation_criteria: + type: object + status: + type: string + enum: [draft, active, inactive] + is_public: + type: boolean + required_level: + type: integer + + TrainingScene: + type: object + properties: + id: + type: integer + name: + type: string + description: + type: string + category: + type: string + ai_config: + type: object + prompt_template: + type: string + evaluation_criteria: + type: object + status: + type: string + enum: [draft, active, inactive] + is_public: + type: boolean + required_level: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TrainingSceneResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/TrainingScene' + + PaginatedScenesResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/TrainingScene' + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer + + StartTrainingRequest: + type: object + required: + - scene_id + properties: + scene_id: + type: integer + description: 场景ID + config: + type: object + description: 会话配置 + + StartTrainingResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + session_id: + type: integer + coze_conversation_id: + type: string + scene: + $ref: '#/components/schemas/TrainingScene' + websocket_url: + type: string + + EndTrainingRequest: + type: object + properties: + generate_report: + type: boolean + default: true + description: 是否生成报告 + + EndTrainingResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + session: + $ref: '#/components/schemas/TrainingSession' + report: + $ref: '#/components/schemas/TrainingReport' + + TrainingSession: + type: object + properties: + id: + type: integer + user_id: + type: integer + scene_id: + type: integer + coze_conversation_id: + type: string + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + duration_seconds: + type: integer + status: + type: string + enum: [created, in_progress, completed, cancelled, error] + session_config: + type: object + total_score: + type: number + evaluation_result: + type: object + scene: + $ref: '#/components/schemas/TrainingScene' + message_count: + type: integer + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TrainingSessionResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/TrainingSession' + + PaginatedSessionsResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/TrainingSession' + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer + + TrainingMessage: + type: object + properties: + id: + type: integer + session_id: + type: integer + role: + type: string + enum: [user, assistant, system] + type: + type: string + enum: [text, voice, system] + content: + type: string + voice_url: + type: string + voice_duration: + type: number + metadata: + type: object + coze_message_id: + type: string + created_at: + type: string + format: date-time + + TrainingReport: + type: object + properties: + id: + type: integer + session_id: + type: integer + user_id: + type: integer + overall_score: + type: number + dimension_scores: + type: object + additionalProperties: + type: number + strengths: + type: array + items: + type: string + weaknesses: + type: array + items: + type: string + suggestions: + type: array + items: + type: string + detailed_analysis: + type: string + transcript: + type: string + statistics: + type: object + session: + $ref: '#/components/schemas/TrainingSession' + created_at: + type: string + format: date-time + updated_at: + type: string + format: date-time + + TrainingReportResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + $ref: '#/components/schemas/TrainingReport' + + PaginatedReportsResponse: + allOf: + - $ref: '#/components/schemas/BaseResponse' + - type: object + properties: + data: + type: object + properties: + items: + type: array + items: + $ref: '#/components/schemas/TrainingReport' + total: + type: integer + page: + type: integer + page_size: + type: integer + pages: + type: integer diff --git a/backend/app/api/v1/upload.py b/backend/app/api/v1/upload.py new file mode 100644 index 0000000..2255de2 --- /dev/null +++ b/backend/app/api/v1/upload.py @@ -0,0 +1,275 @@ +""" +文件上传API接口 +""" +import os +import shutil +from pathlib import Path +from typing import List, Optional +from datetime import datetime +import hashlib + +from fastapi import APIRouter, Depends, UploadFile, File, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.deps import get_current_user, get_db +from app.models.user import User +from app.models.course import Course +from app.schemas.base import ResponseModel +from app.core.logger import get_logger + +logger = get_logger(__name__) + +router = APIRouter(prefix="/upload") + +# 支持的文件类型和大小限制 +# 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties +ALLOWED_EXTENSIONS = { + 'txt', 'md', 'mdx', 'pdf', 'html', 'htm', + 'xlsx', 'xls', 'docx', 'doc', 'csv', 'vtt', 'properties' +} +MAX_FILE_SIZE = 15 * 1024 * 1024 # 15MB + + +def get_file_extension(filename: str) -> str: + """获取文件扩展名""" + return filename.rsplit('.', 1)[1].lower() if '.' in filename else '' + + +def generate_unique_filename(original_filename: str) -> str: + """生成唯一的文件名""" + timestamp = datetime.now().strftime('%Y%m%d%H%M%S') + random_str = hashlib.md5(f"{original_filename}{timestamp}".encode()).hexdigest()[:8] + ext = get_file_extension(original_filename) + return f"{timestamp}_{random_str}.{ext}" + + +def get_upload_path(file_type: str = "general") -> Path: + """获取上传路径""" + base_path = Path(settings.UPLOAD_PATH) + upload_path = base_path / file_type + upload_path.mkdir(parents=True, exist_ok=True) + return upload_path + + +@router.post("/file", response_model=ResponseModel[dict]) +async def upload_file( + file: UploadFile = File(...), + file_type: str = "general", + current_user: User = Depends(get_current_user), +): + """ + 上传单个文件 + + - **file**: 要上传的文件 + - **file_type**: 文件类型分类(general, course, avatar等) + + 返回: + - **file_url**: 文件访问URL + - **file_name**: 原始文件名 + - **file_size**: 文件大小 + - **file_type**: 文件类型 + """ + try: + # 检查文件扩展名 + file_ext = get_file_extension(file.filename) + if file_ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"不支持的文件类型: {file_ext}" + ) + + # 读取文件内容 + contents = await file.read() + file_size = len(contents) + + # 检查文件大小 + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB" + ) + + # 生成唯一文件名 + unique_filename = generate_unique_filename(file.filename) + + # 获取上传路径 + upload_path = get_upload_path(file_type) + file_path = upload_path / unique_filename + + # 保存文件 + with open(file_path, "wb") as f: + f.write(contents) + + # 生成文件访问URL + file_url = f"/static/uploads/{file_type}/{unique_filename}" + + logger.info( + "文件上传成功", + user_id=current_user.id, + original_filename=file.filename, + saved_filename=unique_filename, + file_size=file_size, + file_type=file_type, + ) + + return ResponseModel( + data={ + "file_url": file_url, + "file_name": file.filename, + "file_size": file_size, + "file_type": file_ext, + }, + message="文件上传成功" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"文件上传失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="文件上传失败" + ) + + +@router.post("/course/{course_id}/materials", response_model=ResponseModel[dict]) +async def upload_course_material( + course_id: int, + file: UploadFile = File(...), + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + 上传课程资料 + + - **course_id**: 课程ID + - **file**: 要上传的文件 + + 返回上传结果,包含文件URL等信息 + """ + try: + # 验证课程是否存在 + from sqlalchemy import select + from app.models.course import Course + + stmt = select(Course).where(Course.id == course_id, Course.is_deleted == False) + result = await db.execute(stmt) + course = result.scalar_one_or_none() + if not course: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=f"课程 {course_id} 不存在" + ) + + # 检查文件扩展名 + file_ext = get_file_extension(file.filename) + if file_ext not in ALLOWED_EXTENSIONS: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"不支持的文件类型: {file_ext}" + ) + + # 读取文件内容 + contents = await file.read() + file_size = len(contents) + + # 检查文件大小 + if file_size > MAX_FILE_SIZE: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=f"文件大小超过限制,最大允许 {MAX_FILE_SIZE // 1024 // 1024}MB" + ) + + # 生成唯一文件名 + unique_filename = generate_unique_filename(file.filename) + + # 创建课程专属目录 + course_upload_path = Path(settings.UPLOAD_PATH) / "courses" / str(course_id) + course_upload_path.mkdir(parents=True, exist_ok=True) + + # 保存文件 + file_path = course_upload_path / unique_filename + with open(file_path, "wb") as f: + f.write(contents) + + # 生成文件访问URL + file_url = f"/static/uploads/courses/{course_id}/{unique_filename}" + + logger.info( + "课程资料上传成功", + user_id=current_user.id, + course_id=course_id, + original_filename=file.filename, + saved_filename=unique_filename, + file_size=file_size, + ) + + return ResponseModel( + data={ + "file_url": file_url, + "file_name": file.filename, + "file_size": file_size, + "file_type": file_ext, + }, + message="课程资料上传成功" + ) + + except HTTPException: + raise + except Exception as e: + logger.error(f"课程资料上传失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="课程资料上传失败" + ) + + +@router.delete("/file", response_model=ResponseModel[bool]) +async def delete_file( + file_url: str, + current_user: User = Depends(get_current_user), +): + """ + 删除已上传的文件 + + - **file_url**: 文件URL路径 + """ + try: + # 解析文件路径 + if not file_url.startswith("/static/uploads/"): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="无效的文件URL" + ) + + # 转换为实际文件路径 + relative_path = file_url.replace("/static/uploads/", "") + file_path = Path(settings.UPLOAD_PATH) / relative_path + + # 检查文件是否存在 + if not file_path.exists(): + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="文件不存在" + ) + + # 删除文件 + os.remove(file_path) + + logger.info( + "文件删除成功", + user_id=current_user.id, + file_url=file_url, + ) + + return ResponseModel(data=True, message="文件删除成功") + + except HTTPException: + raise + except Exception as e: + logger.error(f"文件删除失败: {str(e)}", exc_info=True) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="文件删除失败" + ) diff --git a/backend/app/api/v1/users.py b/backend/app/api/v1/users.py new file mode 100644 index 0000000..cdbf4dc --- /dev/null +++ b/backend/app/api/v1/users.py @@ -0,0 +1,474 @@ +""" +用户管理 API +""" + +from typing import List + +from fastapi import APIRouter, Depends, HTTPException, Query, status, Request +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_active_user, get_db, require_admin +from app.core.logger import logger +from app.models.user import User +from app.schemas.base import PaginatedResponse, PaginationParams, ResponseModel +from app.schemas.user import User as UserSchema +from app.schemas.user import UserCreate, UserFilter, UserPasswordUpdate, UserUpdate +from app.services.user_service import UserService +from app.services.system_log_service import system_log_service +from app.schemas.system_log import SystemLogCreate +from app.models.exam import Exam, ExamResult +from app.models.training import TrainingSession +from app.models.position_member import PositionMember +from app.models.position import Position +from app.models.course import Course + +router = APIRouter() + + +@router.get("/me", response_model=ResponseModel) +async def get_current_user_info( + current_user: dict = Depends(get_current_active_user), +) -> ResponseModel: + """ + 获取当前用户信息 + + 权限:需要登录 + """ + return ResponseModel(data=UserSchema.model_validate(current_user)) + + +@router.get("/me/statistics", response_model=ResponseModel) +async def get_current_user_statistics( + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取当前用户学习统计 + + 返回字段: + - learningDays: 学习天数(按陪练会话开始日期去重) + - totalHours: 学习总时长(小时,取整到1位小数) + - practiceQuestions: 练习题数(答题记录条数汇总) + - averageScore: 平均成绩(已提交考试的平均分,保留1位小数) + - examsCompleted: 已完成考试数量 + """ + try: + user_id = current_user.id + + # 学习天数:按会话开始日期去重 + learning_days_stmt = select(func.count(func.distinct(func.date(TrainingSession.start_time)))).where( + TrainingSession.user_id == user_id + ) + learning_days = (await db.scalar(learning_days_stmt)) or 0 + + # 总时长(小时) + total_seconds_stmt = select(func.coalesce(func.sum(TrainingSession.duration_seconds), 0)).where( + TrainingSession.user_id == user_id + ) + total_seconds = (await db.scalar(total_seconds_stmt)) or 0 + total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0 + + # 练习题数:用户所有考试的题目总数 + practice_questions_stmt = ( + select(func.coalesce(func.sum(Exam.question_count), 0)) + .where(Exam.user_id == user_id, Exam.status == "completed") + ) + practice_questions = (await db.scalar(practice_questions_stmt)) or 0 + + # 平均成绩:用户已完成考试的平均分 + avg_score_stmt = select(func.avg(Exam.score)).where( + Exam.user_id == user_id, Exam.status == "completed" + ) + avg_score_val = await db.scalar(avg_score_stmt) + average_score = round(float(avg_score_val), 1) if avg_score_val is not None else 0.0 + + # 已完成考试数量 + exams_completed_stmt = select(func.count(Exam.id)).where( + Exam.user_id == user_id, + Exam.status == "completed" + ) + exams_completed = (await db.scalar(exams_completed_stmt)) or 0 + + return ResponseModel( + data={ + "learningDays": int(learning_days), + "totalHours": total_hours, + "practiceQuestions": int(practice_questions), + "averageScore": average_score, + "examsCompleted": int(exams_completed), + } + ) + except Exception as e: + logger.error("获取用户学习统计失败", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取用户学习统计失败: {str(e)}") + + +@router.get("/me/recent-exams", response_model=ResponseModel) +async def get_recent_exams( + limit: int = Query(5, ge=1, le=20, description="返回数量"), + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取当前用户最近的考试记录 + + 返回最近的考试列表,按创建时间降序排列 + 只返回已完成或已提交的考试(不包括started状态) + """ + try: + user_id = current_user.id + + # 查询最近的考试记录,关联课程表获取课程名称 + stmt = ( + select(Exam, Course.name.label("course_name")) + .join(Course, Exam.course_id == Course.id) + .where( + Exam.user_id == user_id, + Exam.status.in_(["completed", "submitted"]) + ) + .order_by(Exam.created_at.desc()) + .limit(limit) + ) + + results = await db.execute(stmt) + rows = results.all() + + # 构建返回数据 + exams_list = [] + for exam, course_name in rows: + exams_list.append({ + "id": exam.id, + "title": exam.exam_name, + "courseName": course_name, + "courseId": exam.course_id, + "time": exam.created_at.strftime("%Y-%m-%d %H:%M") if exam.created_at else "", + "questions": exam.question_count or 0, + "status": exam.status, + "score": exam.score + }) + + return ResponseModel(data=exams_list) + + except Exception as e: + logger.error("获取最近考试记录失败", exc_info=True) + raise HTTPException(status_code=500, detail=f"获取最近考试记录失败: {str(e)}") + + +@router.put("/me", response_model=ResponseModel) +async def update_current_user( + user_in: UserUpdate, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 更新当前用户信息 + + 权限:需要登录 + """ + user_service = UserService(db) + user = await user_service.update_user( + user_id=current_user.id, + obj_in=user_in, + updated_by=current_user.id, + ) + return ResponseModel(data=UserSchema.model_validate(user)) + + +@router.put("/me/password", response_model=ResponseModel) +async def update_current_user_password( + password_in: UserPasswordUpdate, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 更新当前用户密码 + + 权限:需要登录 + """ + user_service = UserService(db) + user = await user_service.update_password( + user_id=current_user.id, + old_password=password_in.old_password, + new_password=password_in.new_password, + ) + return ResponseModel(message="密码更新成功", data=UserSchema.model_validate(user)) + + +@router.get("/", response_model=ResponseModel) +async def get_users( + pagination: PaginationParams = Depends(), + role: str = Query(None, description="用户角色"), + is_active: bool = Query(None, description="是否激活"), + team_id: int = Query(None, description="团队ID"), + keyword: str = Query(None, description="搜索关键词"), + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取用户列表 + + 权限:需要登录 + - 普通用户只能看到激活的用户 + - 管理员可以看到所有用户 + """ + # 构建筛选条件 + filter_params = UserFilter( + role=role, + is_active=is_active, + team_id=team_id, + keyword=keyword, + ) + + # 普通用户只能看到激活的用户 + if current_user.role == "trainee": + filter_params.is_active = True + + # 获取用户列表 + user_service = UserService(db) + users, total = await user_service.get_users_with_filter( + skip=pagination.offset, + limit=pagination.limit, + filter_params=filter_params, + ) + + # 构建分页响应 + paginated = PaginatedResponse.create( + items=[UserSchema.model_validate(user) for user in users], + total=total, + page=pagination.page, + page_size=pagination.page_size, + ) + + return ResponseModel(data=paginated.model_dump()) + + +@router.post("/", response_model=ResponseModel, status_code=status.HTTP_201_CREATED) +async def create_user( + user_in: UserCreate, + request: Request, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 创建用户 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + user = await user_service.create_user( + obj_in=user_in, + created_by=current_user.id, + ) + + logger.info( + "管理员创建用户", + admin_id=current_user.id, + admin_username=current_user.username, + new_user_id=user.id, + new_username=user.username, + ) + + # 记录用户创建日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="user", + message=f"管理员 {current_user.username} 创建用户: {user.username}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path="/api/v1/users/", + method="POST", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="用户创建成功", data=UserSchema.model_validate(user)) + + +@router.get("/{user_id}", response_model=ResponseModel) +async def get_user( + user_id: int, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取用户详情 + + 权限:需要登录 + - 普通用户只能查看自己的信息 + - 管理员和经理可以查看所有用户信息 + """ + # 权限检查 + if current_user.role == "trainee" and current_user.id != user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息" + ) + + # 获取用户 + user_service = UserService(db) + user = await user_service.get_by_id(user_id) + + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + return ResponseModel(data=UserSchema.model_validate(user)) + + +@router.put("/{user_id}", response_model=ResponseModel) +async def update_user( + user_id: int, + user_in: UserUpdate, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 更新用户信息 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + user = await user_service.update_user( + user_id=user_id, + obj_in=user_in, + updated_by=current_user.id, + ) + + logger.info( + "管理员更新用户", + admin_id=current_user.id, + admin_username=current_user.username, + updated_user_id=user.id, + updated_username=user.username, + ) + + return ResponseModel(data=UserSchema.model_validate(user)) + + +@router.delete("/{user_id}", response_model=ResponseModel) +async def delete_user( + user_id: int, + request: Request, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 删除用户(软删除) + + 权限:需要管理员权限 + """ + # 不能删除自己 + if user_id == current_user.id: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除自己") + + # 获取用户 + user_service = UserService(db) + user = await user_service.get_by_id(user_id) + + if not user: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") + + # 软删除 + await user_service.soft_delete(db_obj=user) + + logger.info( + "管理员删除用户", + admin_id=current_user.id, + admin_username=current_user.username, + deleted_user_id=user.id, + deleted_username=user.username, + ) + + # 记录用户删除日志 + await system_log_service.create_log( + db, + SystemLogCreate( + level="INFO", + type="user", + message=f"管理员 {current_user.username} 删除用户: {user.username}", + user_id=current_user.id, + user=current_user.username, + ip=request.client.host if request.client else None, + path=f"/api/v1/users/{user_id}", + method="DELETE", + user_agent=request.headers.get("user-agent") + ) + ) + + return ResponseModel(message="用户删除成功") + + +@router.post("/{user_id}/teams/{team_id}", response_model=ResponseModel) +async def add_user_to_team( + user_id: int, + team_id: int, + role: str = Query("member", regex="^(member|leader)$"), + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 将用户添加到团队 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + await user_service.add_user_to_team( + user_id=user_id, + team_id=team_id, + role=role, + ) + + return ResponseModel(message="用户已添加到团队") + + +@router.delete("/{user_id}/teams/{team_id}", response_model=ResponseModel) +async def remove_user_from_team( + user_id: int, + team_id: int, + current_user: dict = Depends(require_admin), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 从团队中移除用户 + + 权限:需要管理员权限 + """ + user_service = UserService(db) + await user_service.remove_user_from_team( + user_id=user_id, + team_id=team_id, + ) + + return ResponseModel(message="用户已从团队中移除") + + +@router.get("/{user_id}/positions", response_model=ResponseModel) +async def get_user_positions( + user_id: int, + current_user: dict = Depends(get_current_active_user), + db: AsyncSession = Depends(get_db), +) -> ResponseModel: + """ + 获取用户所属岗位列表(用于前端展示与编辑) + + 权限:登录即可;普通用户仅能查看自己的信息 + 返回:[{id,name,code}] + """ + # 权限检查 + if current_user.role == "trainee" and current_user.id != user_id: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="没有权限查看其他用户信息") + + stmt = ( + select(Position) + .join(PositionMember, PositionMember.position_id == Position.id) + .where(PositionMember.user_id == user_id, PositionMember.is_deleted == False, Position.is_deleted == False) + .order_by(Position.id) + ) + rows = (await db.execute(stmt)).scalars().all() + data = [ + {"id": p.id, "name": p.name, "code": p.code} + for p in rows + ] + return ResponseModel(data=data) diff --git a/backend/app/api/v1/yanji.py b/backend/app/api/v1/yanji.py new file mode 100644 index 0000000..b4d1f83 --- /dev/null +++ b/backend/app/api/v1/yanji.py @@ -0,0 +1,120 @@ +""" +言迹智能工牌API接口 +""" + +import logging +from typing import List + +from fastapi import APIRouter, Depends, Query + +from app.core.deps import get_current_user +from app.models.user import User +from app.schemas.base import ResponseModel +from app.schemas.yanji import ( + GetConversationsByVisitIdsResponse, + GetConversationsResponse, + YanjiConversation, +) +from app.services.yanji_service import YanjiService + +logger = logging.getLogger(__name__) + +router = APIRouter() + + +@router.post("/conversations/by-visit-ids", response_model=ResponseModel[GetConversationsByVisitIdsResponse]) +async def get_conversations_by_visit_ids( + external_visit_ids: List[str] = Query( + ..., + min_length=1, + max_length=10, + description="三方来访单ID列表(最多10个)", + ), + current_user: User = Depends(get_current_user), +): + """ + 根据来访单ID获取对话记录(ASR转写文字) + + 这是获取对话记录的主要接口,适用于: + 1. 已知来访单ID的场景 + 2. 获取特定对话记录用于AI评分 + 3. 批量获取多个对话记录 + """ + try: + yanji_service = YanjiService() + conversations = await yanji_service.get_conversations_by_visit_ids( + external_visit_ids=external_visit_ids + ) + + return ResponseModel( + code=200, + message="获取成功", + data=GetConversationsByVisitIdsResponse( + conversations=conversations, total=len(conversations) + ), + ) + + except Exception as e: + logger.error(f"获取对话记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None) + + +@router.get("/conversations", response_model=ResponseModel[GetConversationsResponse]) +async def get_employee_conversations( + consultant_phone: str = Query(..., description="员工手机号"), + limit: int = Query(10, ge=1, le=100, description="获取数量"), + current_user: User = Depends(get_current_user), +): + """ + 获取员工最近的对话记录 + + 注意:目前此接口功能有限,因为言迹API没有直接通过员工手机号查询录音的接口。 + 推荐使用 /conversations/by-visit-ids 接口。 + + 后续可扩展: + 1. 先查询员工的来访单列表 + 2. 再获取这些来访单的对话记录 + """ + try: + yanji_service = YanjiService() + conversations = await yanji_service.get_recent_conversations( + consultant_phone=consultant_phone, limit=limit + ) + + return ResponseModel( + code=200, + message="获取成功", + data=GetConversationsResponse( + conversations=conversations, total=len(conversations) + ), + ) + + except Exception as e: + logger.error(f"获取员工对话记录失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"获取失败: {str(e)}", data=None) + + +@router.get("/test-auth") +async def test_yanji_auth(current_user: User = Depends(get_current_user)): + """ + 测试言迹API认证 + + 用于验证OAuth2.0认证是否正常工作 + """ + try: + yanji_service = YanjiService() + access_token = await yanji_service.get_access_token() + + return ResponseModel( + code=200, + message="认证成功", + data={ + "access_token": access_token[:20] + "...", # 只显示前20个字符 + "base_url": yanji_service.base_url, + }, + ) + + except Exception as e: + logger.error(f"言迹API认证失败: {e}", exc_info=True) + return ResponseModel(code=500, message=f"认证失败: {str(e)}", data=None) + diff --git a/backend/app/config/__init__.py b/backend/app/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/config/database.py b/backend/app/config/database.py new file mode 100644 index 0000000..6c2a74e --- /dev/null +++ b/backend/app/config/database.py @@ -0,0 +1,49 @@ +"""数据库配置""" +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker +from sqlalchemy.pool import NullPool + +from app.core.config import get_settings + +settings = get_settings() + +# 创建异步引擎 +if settings.DEBUG: + # 开发环境使用 NullPool,不需要连接池参数 + engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_pre_ping=True, + poolclass=NullPool, + # 确保 MySQL 连接使用 UTF-8 字符集 + connect_args={ + "charset": "utf8mb4", + "use_unicode": True, + "autocommit": False, + "init_command": "SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4, collation_connection=utf8mb4_unicode_ci", + } if "mysql" in settings.DATABASE_URL else {}, + ) +else: + # 生产环境使用连接池 + engine = create_async_engine( + settings.DATABASE_URL, + echo=False, + pool_size=20, + max_overflow=0, + pool_pre_ping=True, + # 确保 MySQL 连接使用 UTF-8 字符集 + connect_args={ + "charset": "utf8mb4", + "use_unicode": True, + "autocommit": False, + "init_command": "SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4, collation_connection=utf8mb4_unicode_ci", + } if "mysql" in settings.DATABASE_URL else {}, + ) + +# 创建异步会话工厂 +SessionLocal = async_sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False, +) diff --git a/backend/app/core/__init__.py b/backend/app/core/__init__.py new file mode 100644 index 0000000..a907119 --- /dev/null +++ b/backend/app/core/__init__.py @@ -0,0 +1,3 @@ +""" +核心功能模块 +""" diff --git a/backend/app/core/config.py b/backend/app/core/config.py new file mode 100644 index 0000000..0354865 --- /dev/null +++ b/backend/app/core/config.py @@ -0,0 +1,323 @@ +""" +系统配置 + +支持两种配置来源: +1. 环境变量 / .env 文件(传统方式,向后兼容) +2. 数据库 tenant_configs 表(新方式,支持热更新) + +配置优先级:数据库 > 环境变量 > 默认值 +""" + +import os +import json +from functools import lru_cache +from typing import Optional, Any + +from pydantic import Field, field_validator +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + """系统配置""" + + # 应用基础配置 + APP_NAME: str = "KaoPeiLian" + APP_VERSION: str = "1.0.0" + DEBUG: bool = Field(default=True) + + # 租户配置(用于多租户部署) + TENANT_CODE: str = Field(default="demo", description="租户编码,如 hua, yy, hl") + + # 服务器配置 + HOST: str = Field(default="0.0.0.0") + PORT: int = Field(default=8000) + + # 数据库配置 + DATABASE_URL: Optional[str] = Field(default=None) + MYSQL_HOST: str = Field(default="localhost") + MYSQL_PORT: int = Field(default=3306) + MYSQL_USER: str = Field(default="root") + MYSQL_PASSWORD: str = Field(default="password") + MYSQL_DATABASE: str = Field(default="kaopeilian") + + @property + def database_url(self) -> str: + """构建数据库连接URL""" + if self.DATABASE_URL: + return self.DATABASE_URL + + # 使用urllib.parse.quote_plus来正确编码特殊字符 + import urllib.parse + password = urllib.parse.quote_plus(self.MYSQL_PASSWORD) + + return f"mysql+aiomysql://{self.MYSQL_USER}:{password}@{self.MYSQL_HOST}:{self.MYSQL_PORT}/{self.MYSQL_DATABASE}?charset=utf8mb4" + + # Redis配置 + REDIS_URL: str = Field(default="redis://localhost:6379/0") + + # JWT配置 + SECRET_KEY: str = Field(default="your-secret-key-here") + ALGORITHM: str = Field(default="HS256") + ACCESS_TOKEN_EXPIRE_MINUTES: int = Field(default=30) + REFRESH_TOKEN_EXPIRE_DAYS: int = Field(default=7) + + # 跨域配置 + CORS_ORIGINS: list[str] = Field( + default=[ + "http://localhost:3000", + "http://localhost:3001", + "http://localhost:5173", + "http://127.0.0.1:3000", + "http://127.0.0.1:3001", + "http://127.0.0.1:5173", + ] + ) + + @field_validator('CORS_ORIGINS', mode='before') + @classmethod + def parse_cors_origins(cls, v): + """解析 CORS_ORIGINS 环境变量(支持 JSON 格式字符串)""" + if isinstance(v, str): + try: + return json.loads(v) + except json.JSONDecodeError: + # 如果不是 JSON 格式,尝试按逗号分割 + return [origin.strip() for origin in v.split(',')] + return v + + # 日志配置 + LOG_LEVEL: str = Field(default="INFO") + LOG_FORMAT: str = Field(default="text") # text 或 json + LOG_DIR: str = Field(default="logs") + + # 上传配置 + UPLOAD_DIR: str = Field(default="uploads") + MAX_UPLOAD_SIZE: int = Field(default=15 * 1024 * 1024) # 15MB + + @property + def UPLOAD_PATH(self) -> str: + """获取上传文件的完整路径""" + import os + return os.path.join(os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))), self.UPLOAD_DIR) + + # Coze 平台配置(陪练对话、播课等) + COZE_API_BASE: Optional[str] = Field(default="https://api.coze.cn") + COZE_WORKSPACE_ID: Optional[str] = Field(default=None) + COZE_API_TOKEN: Optional[str] = Field(default="pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi") + COZE_TRAINING_BOT_ID: Optional[str] = Field(default=None) + COZE_CHAT_BOT_ID: Optional[str] = Field(default=None) + COZE_PRACTICE_BOT_ID: Optional[str] = Field(default="7560643598174683145") # 陪练专用Bot ID + # 播课工作流配置(多租户需在环境变量中覆盖,参见:应用配置清单.md) + COZE_BROADCAST_WORKFLOW_ID: str = Field(default="7577983042284486666") # 默认:演示版播课工作流 + COZE_BROADCAST_SPACE_ID: str = Field(default="7474971491470688296") # 播课工作流空间ID + COZE_BROADCAST_BOT_ID: Optional[str] = Field(default=None) # 播课工作流专用Bot ID + # OAuth配置(可选) + COZE_OAUTH_CLIENT_ID: Optional[str] = Field(default=None) + COZE_OAUTH_PUBLIC_KEY_ID: Optional[str] = Field(default=None) + COZE_OAUTH_PRIVATE_KEY_PATH: Optional[str] = Field(default=None) + + # WebSocket语音配置 + COZE_WS_BASE_URL: str = Field(default="wss://ws.coze.cn") + COZE_AUDIO_FORMAT: str = Field(default="pcm") # 音频格式 + COZE_SAMPLE_RATE: int = Field(default=16000) # 采样率(Hz) + COZE_AUDIO_CHANNELS: int = Field(default=1) # 声道数(单声道) + COZE_AUDIO_BIT_DEPTH: int = Field(default=16) # 位深度 + + # 服务器公开访问域名 + PUBLIC_DOMAIN: str = Field(default="http://aiedu.ireborn.com.cn") + + # 言迹智能工牌API配置 + YANJI_API_BASE: str = Field(default="https://open.yanjiai.com") # 正式环境 + YANJI_CLIENT_ID: str = Field(default="1Fld4LCWt2vpJNG5") + YANJI_CLIENT_SECRET: str = Field(default="XE8w413qNtJBOdWc2aCezV0yMIHpUuTZ") + YANJI_TENANT_ID: str = Field(default="516799409476866048") + YANJI_ESTATE_ID: str = Field(default="516799468310364162") + + # SCRM 系统对接 API Key(用于内部服务间调用) + SCRM_API_KEY: str = Field(default="scrm-kpl-api-key-2026-ruixiaomei") + + # AI 服务配置(知识点分析 V2 使用) + # 首选服务商:4sapi.com(国内优化) + AI_PRIMARY_API_KEY: str = Field(default="sk-9yMCXjRGANbacz20kJY8doSNy6Rf446aYwmgGIuIXQ7DAyBw") # 测试阶段 Key + AI_PRIMARY_BASE_URL: str = Field(default="https://4sapi.com/v1") + # 备选服务商:OpenRouter(模型全,稳定性好) + AI_FALLBACK_API_KEY: str = Field(default="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") # 测试阶段 Key + AI_FALLBACK_BASE_URL: str = Field(default="https://openrouter.ai/api/v1") + # 默认模型 + AI_DEFAULT_MODEL: str = Field(default="gemini-3-flash-preview") + # 请求超时(秒) + AI_TIMEOUT: float = Field(default=120.0) + + model_config = { + "env_file": ".env", + "env_file_encoding": "utf-8", + "case_sensitive": True, + "extra": "allow", # 允许额外的环境变量 + } + + +@lru_cache() +def get_settings() -> Settings: + """获取系统配置(缓存)""" + return Settings() + + +settings = get_settings() + + +# ============================================ +# 动态配置获取(支持从数据库读取) +# ============================================ + +class DynamicConfig: + """ + 动态配置管理器 + + 用于在运行时从数据库获取配置,支持热更新。 + 向后兼容:如果数据库不可用,回退到环境变量配置。 + """ + + _tenant_loader = None + _initialized = False + + @classmethod + async def init(cls, redis_url: Optional[str] = None): + """ + 初始化动态配置管理器 + + Args: + redis_url: Redis URL(可选,用于缓存) + """ + if cls._initialized: + return + + try: + from app.core.tenant_config import TenantConfigManager + + if redis_url: + await TenantConfigManager.init_redis(redis_url) + + cls._initialized = True + except Exception as e: + import logging + logging.getLogger(__name__).warning(f"动态配置初始化失败: {e}") + + @classmethod + async def get(cls, key: str, default: Any = None, tenant_code: Optional[str] = None) -> Any: + """ + 获取配置值 + + Args: + key: 配置键(如 AI_PRIMARY_API_KEY) + default: 默认值 + tenant_code: 租户编码(可选,默认使用环境变量中的 TENANT_CODE) + + Returns: + 配置值 + """ + # 确定租户编码 + if tenant_code is None: + tenant_code = settings.TENANT_CODE + + # 配置键到分组的映射 + config_mapping = { + # 数据库 + "MYSQL_HOST": ("database", "MYSQL_HOST"), + "MYSQL_PORT": ("database", "MYSQL_PORT"), + "MYSQL_USER": ("database", "MYSQL_USER"), + "MYSQL_PASSWORD": ("database", "MYSQL_PASSWORD"), + "MYSQL_DATABASE": ("database", "MYSQL_DATABASE"), + # Redis + "REDIS_HOST": ("redis", "REDIS_HOST"), + "REDIS_PORT": ("redis", "REDIS_PORT"), + "REDIS_DB": ("redis", "REDIS_DB"), + # 安全 + "SECRET_KEY": ("security", "SECRET_KEY"), + "CORS_ORIGINS": ("security", "CORS_ORIGINS"), + # Coze + "COZE_PRACTICE_BOT_ID": ("coze", "COZE_PRACTICE_BOT_ID"), + "COZE_BROADCAST_WORKFLOW_ID": ("coze", "COZE_BROADCAST_WORKFLOW_ID"), + "COZE_BROADCAST_SPACE_ID": ("coze", "COZE_BROADCAST_SPACE_ID"), + "COZE_OAUTH_CLIENT_ID": ("coze", "COZE_OAUTH_CLIENT_ID"), + "COZE_OAUTH_PUBLIC_KEY_ID": ("coze", "COZE_OAUTH_PUBLIC_KEY_ID"), + # AI + "AI_PRIMARY_API_KEY": ("ai", "AI_PRIMARY_API_KEY"), + "AI_PRIMARY_BASE_URL": ("ai", "AI_PRIMARY_BASE_URL"), + "AI_FALLBACK_API_KEY": ("ai", "AI_FALLBACK_API_KEY"), + "AI_FALLBACK_BASE_URL": ("ai", "AI_FALLBACK_BASE_URL"), + "AI_DEFAULT_MODEL": ("ai", "AI_DEFAULT_MODEL"), + "AI_TIMEOUT": ("ai", "AI_TIMEOUT"), + # 言迹 + "YANJI_CLIENT_ID": ("yanji", "YANJI_CLIENT_ID"), + "YANJI_CLIENT_SECRET": ("yanji", "YANJI_CLIENT_SECRET"), + "YANJI_TENANT_ID": ("yanji", "YANJI_TENANT_ID"), + "YANJI_ESTATE_ID": ("yanji", "YANJI_ESTATE_ID"), + } + + # 尝试从数据库获取 + if cls._initialized and key in config_mapping: + try: + from app.core.tenant_config import TenantConfigManager + + config_group, config_key = config_mapping[key] + loader = TenantConfigManager.get_loader(tenant_code) + value = await loader.get_config(config_group, config_key) + + if value is not None: + return value + except Exception: + pass + + # 回退到环境变量 / Settings + env_value = getattr(settings, key, None) + if env_value is not None: + return env_value + + return default + + @classmethod + async def is_feature_enabled(cls, feature_code: str, tenant_code: Optional[str] = None) -> bool: + """ + 检查功能是否启用 + + Args: + feature_code: 功能编码 + tenant_code: 租户编码 + + Returns: + 是否启用 + """ + if tenant_code is None: + tenant_code = settings.TENANT_CODE + + if cls._initialized: + try: + from app.core.tenant_config import TenantConfigManager + + loader = TenantConfigManager.get_loader(tenant_code) + return await loader.is_feature_enabled(feature_code) + except Exception: + pass + + return True # 默认启用 + + @classmethod + async def refresh_cache(cls, tenant_code: Optional[str] = None): + """ + 刷新配置缓存 + + Args: + tenant_code: 租户编码(为空则刷新所有) + """ + if not cls._initialized: + return + + try: + from app.core.tenant_config import TenantConfigManager + + if tenant_code: + await TenantConfigManager.refresh_tenant_cache(tenant_code) + else: + await TenantConfigManager.refresh_all_cache() + except Exception: + pass diff --git a/backend/app/core/database.py b/backend/app/core/database.py new file mode 100644 index 0000000..7ae09a6 --- /dev/null +++ b/backend/app/core/database.py @@ -0,0 +1,31 @@ +""" +数据库配置 +""" + +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from .config import settings + +# 创建异步引擎 +engine = create_async_engine( + settings.database_url, + echo=settings.DEBUG, + pool_pre_ping=True, + pool_size=10, + max_overflow=20, + # 确保 MySQL 连接使用 UTF-8 字符集 + connect_args={ + "charset": "utf8mb4", + "use_unicode": True, + "autocommit": False, + "init_command": "SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4, collation_connection=utf8mb4_unicode_ci", + } if "mysql" in settings.database_url else {}, +) + +# 创建异步会话工厂 +AsyncSessionLocal = sessionmaker( + engine, + class_=AsyncSession, + expire_on_commit=False, +) diff --git a/backend/app/core/deps.py b/backend/app/core/deps.py new file mode 100644 index 0000000..a4b488d --- /dev/null +++ b/backend/app/core/deps.py @@ -0,0 +1,166 @@ +"""依赖注入模块""" +from typing import AsyncGenerator, Optional +from sqlalchemy import select +import redis.asyncio as redis + +from fastapi import Depends, HTTPException, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.config import get_settings +from app.models.user import User + +# JWT Bearer认证 +security = HTTPBearer() + + +async def get_db() -> AsyncGenerator[AsyncSession, None]: + """ + 获取数据库会话 + """ + async with AsyncSessionLocal() as session: + try: + yield session + await session.commit() + except Exception: + await session.rollback() + raise + finally: + await session.close() + + +async def get_current_user( + credentials: HTTPAuthorizationCredentials = Depends(security), + db: AsyncSession = Depends(get_db), +) -> User: + """ + 获取当前用户(基于JWT) + + - 从 Authorization Bearer Token 中解析用户ID + - 查询数据库返回完整的 User 对象 + - 失败时抛出 401 未授权 + """ + from app.core.security import decode_token # 延迟导入避免循环依赖 + + if not credentials or not credentials.scheme or not credentials.credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="未提供认证信息") + + if credentials.scheme.lower() != "bearer": + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="认证方式不支持") + + token = credentials.credentials + try: + payload = decode_token(token) + user_id = int(payload.get("sub")) + except Exception: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效的令牌") + + result = await db.execute( + select(User).where(User.id == user_id, User.is_deleted == False) + ) + user = result.scalar_one_or_none() + if not user or not user.is_active: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在或已被禁用" + ) + + return user + + +async def require_admin(current_user: User = Depends(get_current_user)) -> User: + """ + 需要管理员权限 + """ + if getattr(current_user, "role", None) != "admin": + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理员权限") + return current_user + + +async def require_admin_or_manager(current_user: User = Depends(get_current_user)) -> User: + """ + 需要管理者或管理员权限 + """ + if getattr(current_user, "role", None) not in ("admin", "manager"): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="需要管理者或管理员权限") + return current_user + +async def get_optional_user( + credentials: Optional[HTTPAuthorizationCredentials] = Depends(security), + db: AsyncSession = Depends(get_db), +) -> Optional[User]: + """ + 获取可选的当前用户(不强制登录) + """ + if not credentials: + return None + + try: + return await get_current_user(credentials, db) + except: + return None + + +async def get_current_active_user( + current_user: User = Depends(get_current_user), +) -> User: + """ + 获取当前活跃用户 + """ + # TODO: 检查用户是否被禁用 + return current_user + + +async def verify_scrm_api_key( + credentials: HTTPAuthorizationCredentials = Depends(security), +) -> bool: + """ + 验证 SCRM 系统 API Key + + 用于内部服务间调用认证,SCRM 系统通过固定 API Key 访问考陪练数据查询接口 + 请求头格式: Authorization: Bearer {SCRM_API_KEY} + """ + settings = get_settings() + + if not credentials or not credentials.credentials: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="未提供认证信息" + ) + + if credentials.scheme.lower() != "bearer": + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="认证方式不支持,需要 Bearer Token" + ) + + if credentials.credentials != settings.SCRM_API_KEY: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无效的 API Key" + ) + + return True + + +# Redis 连接池 +_redis_pool: Optional[redis.ConnectionPool] = None + + +async def get_redis() -> AsyncGenerator[redis.Redis, None]: + """ + 获取 Redis 连接 + """ + global _redis_pool + + if _redis_pool is None: + settings = get_settings() + _redis_pool = redis.ConnectionPool.from_url( + settings.REDIS_URL, encoding="utf-8", decode_responses=True + ) + + client = redis.Redis(connection_pool=_redis_pool) + try: + yield client + finally: + await client.close() diff --git a/backend/app/core/events.py b/backend/app/core/events.py new file mode 100644 index 0000000..8155045 --- /dev/null +++ b/backend/app/core/events.py @@ -0,0 +1,28 @@ +""" +应用生命周期事件处理 +""" +from app.core.logger import logger + + +async def startup_handler(): + """应用启动时执行的任务""" + logger.info("执行启动任务...") + + # TODO: 初始化数据库连接池 + # TODO: 初始化Redis连接 + # TODO: 初始化AI平台客户端 + # TODO: 加载缓存数据 + + logger.info("启动任务完成") + + +async def shutdown_handler(): + """应用关闭时执行的任务""" + logger.info("执行关闭任务...") + + # TODO: 关闭数据库连接池 + # TODO: 关闭Redis连接 + # TODO: 清理临时文件 + # TODO: 保存应用状态 + + logger.info("关闭任务完成") diff --git a/backend/app/core/exceptions.py b/backend/app/core/exceptions.py new file mode 100644 index 0000000..e4bf8b4 --- /dev/null +++ b/backend/app/core/exceptions.py @@ -0,0 +1,89 @@ +"""统一异常定义""" +from typing import Optional, Dict, Any +from fastapi import HTTPException, status + + +class BusinessError(HTTPException): + """业务异常基类""" + + def __init__( + self, + message: str, + code: int = status.HTTP_400_BAD_REQUEST, + error_code: Optional[str] = None, + detail: Optional[Dict[str, Any]] = None, + ): + super().__init__( + status_code=code, + detail={ + "message": message, + "error_code": error_code or f"ERR_{code}", + "detail": detail, + }, + ) + self.message = message + self.code = code + self.error_code = error_code + + +class BadRequestError(BusinessError): + """400 错误请求""" + + def __init__(self, message: str = "错误的请求", **kwargs): + super().__init__(message, status.HTTP_400_BAD_REQUEST, **kwargs) + + +class UnauthorizedError(BusinessError): + """401 未授权""" + + def __init__(self, message: str = "未授权", **kwargs): + super().__init__(message, status.HTTP_401_UNAUTHORIZED, **kwargs) + + +class ForbiddenError(BusinessError): + """403 禁止访问""" + + def __init__(self, message: str = "禁止访问", **kwargs): + super().__init__(message, status.HTTP_403_FORBIDDEN, **kwargs) + + +class NotFoundError(BusinessError): + """404 未找到""" + + def __init__(self, message: str = "资源未找到", **kwargs): + super().__init__(message, status.HTTP_404_NOT_FOUND, **kwargs) + + +class ConflictError(BusinessError): + """409 冲突""" + + def __init__(self, message: str = "资源冲突", **kwargs): + super().__init__(message, status.HTTP_409_CONFLICT, **kwargs) + + +class ValidationError(BusinessError): + """422 验证错误""" + + def __init__(self, message: str = "验证失败", **kwargs): + super().__init__(message, status.HTTP_422_UNPROCESSABLE_ENTITY, **kwargs) + + +class InternalServerError(BusinessError): + """500 内部服务器错误""" + + def __init__(self, message: str = "内部服务器错误", **kwargs): + super().__init__(message, status.HTTP_500_INTERNAL_SERVER_ERROR, **kwargs) + + +class InsufficientPermissionsError(ForbiddenError): + """权限不足""" + + def __init__(self, message: str = "权限不足", **kwargs): + super().__init__(message, error_code="INSUFFICIENT_PERMISSIONS", **kwargs) + + +class ExternalServiceError(BusinessError): + """外部服务错误""" + + def __init__(self, message: str = "外部服务异常", **kwargs): + super().__init__(message, status.HTTP_502_BAD_GATEWAY, error_code="EXTERNAL_SERVICE_ERROR", **kwargs) diff --git a/backend/app/core/logger.py b/backend/app/core/logger.py new file mode 100644 index 0000000..044256b --- /dev/null +++ b/backend/app/core/logger.py @@ -0,0 +1,76 @@ +""" +日志配置 +""" +import logging +import sys +from typing import Any + +import structlog +from structlog.stdlib import LoggerFactory + +from app.core.config import get_settings + +settings = get_settings() + + +def setup_logging(): + """ + 配置日志系统 + """ + # 设置日志级别 + log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) + + # 配置标准库日志 + logging.basicConfig( + format="%(message)s", + stream=sys.stdout, + level=log_level, + ) + + # 配置处理器 + processors = [ + structlog.stdlib.filter_by_level, + structlog.stdlib.add_logger_name, + structlog.stdlib.add_log_level, + structlog.stdlib.PositionalArgumentsFormatter(), + structlog.processors.TimeStamper(fmt="iso"), + structlog.processors.StackInfoRenderer(), + structlog.processors.format_exc_info, + structlog.processors.UnicodeDecoder(), + ] + + # 根据配置选择输出格式 + if getattr(settings, "LOG_FORMAT", "text") == "json": + processors.append(structlog.processors.JSONRenderer()) + else: + processors.append(structlog.dev.ConsoleRenderer()) + + # 配置 structlog + structlog.configure( + processors=processors, + context_class=dict, + logger_factory=LoggerFactory(), + cache_logger_on_first_use=True, + ) + + +# 设置日志 +setup_logging() + + +# 获取日志器 +def get_logger(name: str = __name__) -> Any: + """ + 获取日志器 + + Args: + name: 日志器名称 + + Returns: + 日志器实例 + """ + return structlog.get_logger(name) + + +# 默认日志器 +logger = get_logger("app") diff --git a/backend/app/core/middleware.py b/backend/app/core/middleware.py new file mode 100644 index 0000000..9a4232b --- /dev/null +++ b/backend/app/core/middleware.py @@ -0,0 +1,64 @@ +""" +中间件定义 +""" +import time +import uuid +from typing import Callable + +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +from app.core.logger import logger + + +class RequestIDMiddleware(BaseHTTPMiddleware): + """请求ID中间件""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # 生成请求ID + request_id = str(uuid.uuid4()) + + # 将请求ID添加到request状态 + request.state.request_id = request_id + + # 记录请求开始 + start_time = time.time() + + # 处理请求 + response = await call_next(request) + + # 计算处理时间 + process_time = time.time() - start_time + + # 添加响应头 + response.headers["X-Request-ID"] = request_id + response.headers["X-Process-Time"] = str(process_time) + + # 记录请求日志 + logger.info( + "HTTP请求", + method=request.method, + url=str(request.url), + status_code=response.status_code, + process_time=process_time, + request_id=request_id, + ) + + return response + + +class GlobalContextMiddleware(BaseHTTPMiddleware): + """全局上下文中间件""" + + async def dispatch(self, request: Request, call_next: Callable) -> Response: + # 设置追踪ID(用于分布式追踪) + trace_id = request.headers.get("X-Trace-ID", str(uuid.uuid4())) + request.state.trace_id = trace_id + + # 处理请求 + response = await call_next(request) + + # 添加追踪ID到响应头 + response.headers["X-Trace-ID"] = trace_id + + return response diff --git a/backend/app/core/redis.py b/backend/app/core/redis.py new file mode 100644 index 0000000..e98c4a1 --- /dev/null +++ b/backend/app/core/redis.py @@ -0,0 +1,44 @@ +""" +Redis连接管理 +""" +from typing import Optional +from redis import asyncio as aioredis +from app.core.config import settings +from app.core.logger import logger + +# 全局Redis连接实例 +redis_client: Optional[aioredis.Redis] = None + + +async def init_redis() -> aioredis.Redis: + """初始化Redis连接""" + global redis_client + + try: + redis_client = await aioredis.from_url( + settings.REDIS_URL, encoding="utf-8", decode_responses=True + ) + # 测试连接 + await redis_client.ping() + logger.info("Redis连接成功", url=settings.REDIS_URL) + return redis_client + except Exception as e: + logger.error("Redis连接失败", error=str(e), url=settings.REDIS_URL) + raise + + +async def close_redis(): + """关闭Redis连接""" + global redis_client + + if redis_client: + await redis_client.close() + logger.info("Redis连接已关闭") + redis_client = None + + +def get_redis_client() -> aioredis.Redis: + """获取Redis客户端实例""" + if not redis_client: + raise RuntimeError("Redis client not initialized") + return redis_client diff --git a/backend/app/core/security.py b/backend/app/core/security.py new file mode 100644 index 0000000..d5ec9d8 --- /dev/null +++ b/backend/app/core/security.py @@ -0,0 +1,72 @@ +""" +安全相关功能 +""" + +from datetime import datetime, timedelta +from typing import Any, Dict, Optional, Union + +import bcrypt +from jose import JWTError, jwt + +from .config import settings + + +def create_access_token( + subject: Union[str, Any], + expires_delta: Optional[timedelta] = None, +) -> str: + """创建访问令牌""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta( + minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES + ) + + to_encode = {"exp": expire, "sub": str(subject), "type": "access"} + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt + + +def create_refresh_token( + subject: Union[str, Any], + expires_delta: Optional[timedelta] = None, +) -> str: + """创建刷新令牌""" + if expires_delta: + expire = datetime.utcnow() + expires_delta + else: + expire = datetime.utcnow() + timedelta(days=settings.REFRESH_TOKEN_EXPIRE_DAYS) + + to_encode = {"exp": expire, "sub": str(subject), "type": "refresh"} + encoded_jwt = jwt.encode( + to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM + ) + return encoded_jwt + + +def decode_token(token: str) -> Dict[str, Any]: + """解码令牌""" + try: + payload = jwt.decode( + token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM] + ) + return payload + except JWTError: + raise ValueError("Invalid token") + + +def verify_password(plain_password: str, hashed_password: str) -> bool: + """验证密码""" + return bcrypt.checkpw( + plain_password.encode("utf-8"), hashed_password.encode("utf-8") + ) + + +def get_password_hash(password: str) -> str: + """生成密码哈希""" + salt = bcrypt.gensalt() + hashed_password = bcrypt.hashpw(password.encode("utf-8"), salt) + return hashed_password.decode("utf-8") diff --git a/backend/app/core/simple_auth.py b/backend/app/core/simple_auth.py new file mode 100644 index 0000000..c614888 --- /dev/null +++ b/backend/app/core/simple_auth.py @@ -0,0 +1,81 @@ +""" +简化认证中间件 - 支持 API Key 和长期 Token +用于内部服务间调用 +""" +from typing import Optional +from fastapi import HTTPException, Header, status +from app.models.user import User + +# 配置 API Keys(用于内部服务调用) +API_KEYS = { + "internal-service-2025-kaopeilian": { + "service": "internal", + "user_id": 1, + "username": "internal_service", + "role": "admin" + } +} + +# 长期有效的 Token(用于内部服务调用) +LONG_TERM_TOKENS = { + "permanent-token-for-internal-2025": { + "service": "internal", + "user_id": 1, + "username": "internal_service", + "role": "admin" + } +} + + +def get_current_user_by_api_key( + x_api_key: Optional[str] = Header(None), + authorization: Optional[str] = Header(None) +) -> Optional[User]: + """ + 通过 API Key 或长期 Token 获取用户 + 支持两种方式: + 1. X-API-Key: internal-service-2025-kaopeilian + 2. Authorization: Bearer permanent-token-for-internal-2025 + """ + + # 方式1:检查 API Key + if x_api_key and x_api_key in API_KEYS: + api_key_info = API_KEYS[x_api_key] + # 创建一个虚拟用户对象 + user = User() + user.id = api_key_info["user_id"] + user.username = api_key_info["username"] + user.role = api_key_info["role"] + return user + + # 方式2:检查长期 Token + if authorization and authorization.startswith("Bearer "): + token = authorization.replace("Bearer ", "") + if token in LONG_TERM_TOKENS: + token_info = LONG_TERM_TOKENS[token] + user = User() + user.id = token_info["user_id"] + user.username = token_info["username"] + user.role = token_info["role"] + return user + + return None + + +def get_current_user_simple( + x_api_key: Optional[str] = Header(None), + authorization: Optional[str] = Header(None) +) -> User: + """ + 简化的用户认证依赖项 + """ + # 尝试 API Key 或长期 Token 认证 + user = get_current_user_by_api_key(x_api_key, authorization) + if user: + return user + + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or missing authentication credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) diff --git a/backend/app/core/tenant_config.py b/backend/app/core/tenant_config.py new file mode 100644 index 0000000..fe7d8dd --- /dev/null +++ b/backend/app/core/tenant_config.py @@ -0,0 +1,421 @@ +""" +租户配置加载器 + +功能: +1. 从数据库 tenant_configs 表加载租户配置 +2. 支持 Redis 缓存 +3. 数据库不可用时回退到环境变量 +4. 支持配置热更新 +""" + +import os +import json +import logging +from typing import Optional, Dict, Any +from functools import lru_cache + +import aiomysql +import redis.asyncio as redis + +logger = logging.getLogger(__name__) + + +# ============================================ +# 平台管理库连接配置 +# +# 注意:敏感信息必须通过环境变量传递,禁止硬编码 +# 参考:瑞小美系统技术栈标准与字符标准.md - 敏感信息管理 +# ============================================ +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "prod-mysql"), + "port": int(os.getenv("ADMIN_DB_PORT", "3306")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD"), # 必须从环境变量获取 + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + +# 校验必填环境变量 +if not ADMIN_DB_CONFIG["password"]: + logger.warning( + "ADMIN_DB_PASSWORD 环境变量未设置,租户配置加载功能将不可用。" + "请在 .env.admin 文件中配置此变量。" + ) + +# Redis 缓存配置 +CACHE_PREFIX = "tenant_config:" +CACHE_TTL = 300 # 5分钟缓存 + + +class TenantConfigLoader: + """租户配置加载器""" + + def __init__(self, tenant_code: str, redis_client: Optional[redis.Redis] = None): + """ + 初始化租户配置加载器 + + Args: + tenant_code: 租户编码(如 hua, yy, hl) + redis_client: Redis 客户端(可选) + """ + self.tenant_code = tenant_code + self.redis_client = redis_client + self._config_cache: Dict[str, Any] = {} + self._tenant_id: Optional[int] = None + + async def get_config(self, config_group: str, config_key: str, default: Any = None) -> Any: + """ + 获取配置项 + + 优先级: + 1. 内存缓存 + 2. Redis 缓存 + 3. 数据库 + 4. 环境变量 + 5. 默认值 + + Args: + config_group: 配置分组(database, redis, coze, ai, yanji, security) + config_key: 配置键 + default: 默认值 + + Returns: + 配置值 + """ + cache_key = f"{config_group}.{config_key}" + + # 1. 内存缓存 + if cache_key in self._config_cache: + return self._config_cache[cache_key] + + # 2. Redis 缓存 + if self.redis_client: + try: + redis_key = f"{CACHE_PREFIX}{self.tenant_code}:{cache_key}" + cached_value = await self.redis_client.get(redis_key) + if cached_value: + value = json.loads(cached_value) + self._config_cache[cache_key] = value + return value + except Exception as e: + logger.warning(f"Redis 缓存读取失败: {e}") + + # 3. 数据库 + try: + value = await self._get_from_database(config_group, config_key) + if value is not None: + self._config_cache[cache_key] = value + # 写入 Redis 缓存 + if self.redis_client: + try: + redis_key = f"{CACHE_PREFIX}{self.tenant_code}:{cache_key}" + await self.redis_client.setex( + redis_key, + CACHE_TTL, + json.dumps(value) + ) + except Exception as e: + logger.warning(f"Redis 缓存写入失败: {e}") + return value + except Exception as e: + logger.warning(f"数据库配置读取失败: {e}") + + # 4. 环境变量 + env_value = os.getenv(config_key) + if env_value is not None: + return env_value + + # 5. 默认值 + return default + + async def _get_from_database(self, config_group: str, config_key: str) -> Optional[Any]: + """从数据库获取配置""" + conn = None + try: + conn = await aiomysql.connect(**ADMIN_DB_CONFIG) + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 获取租户 ID + if self._tenant_id is None: + await cursor.execute( + "SELECT id FROM tenants WHERE code = %s AND status = 'active'", + (self.tenant_code,) + ) + row = await cursor.fetchone() + if row: + self._tenant_id = row['id'] + else: + return None + + # 获取配置值 + await cursor.execute( + """ + SELECT config_value, value_type, is_encrypted + FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (self._tenant_id, config_group, config_key) + ) + row = await cursor.fetchone() + + if row: + return self._parse_value(row['config_value'], row['value_type'], row['is_encrypted']) + + # 如果租户没有配置,获取默认值 + await cursor.execute( + """ + SELECT default_value, value_type + FROM config_templates + WHERE config_group = %s AND config_key = %s + """, + (config_group, config_key) + ) + row = await cursor.fetchone() + if row and row['default_value']: + return self._parse_value(row['default_value'], row['value_type'], False) + + return None + finally: + if conn: + conn.close() + + def _parse_value(self, value: str, value_type: str, is_encrypted: bool) -> Any: + """解析配置值""" + if value is None: + return None + + # TODO: 如果是加密值,先解密 + if is_encrypted: + # 这里可以实现解密逻辑 + pass + + if value_type == 'int': + return int(value) + elif value_type == 'bool': + return value.lower() in ('true', '1', 'yes') + elif value_type == 'json': + return json.loads(value) + elif value_type == 'float': + return float(value) + else: + return value + + async def get_all_configs(self) -> Dict[str, Any]: + """获取租户的所有配置""" + configs = {} + conn = None + try: + conn = await aiomysql.connect(**ADMIN_DB_CONFIG) + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 获取租户 ID + await cursor.execute( + "SELECT id FROM tenants WHERE code = %s AND status = 'active'", + (self.tenant_code,) + ) + row = await cursor.fetchone() + if not row: + return configs + + tenant_id = row['id'] + + # 获取所有配置 + await cursor.execute( + """ + SELECT config_group, config_key, config_value, value_type, is_encrypted + FROM tenant_configs + WHERE tenant_id = %s + """, + (tenant_id,) + ) + rows = await cursor.fetchall() + + for row in rows: + key = f"{row['config_group']}.{row['config_key']}" + configs[key] = self._parse_value( + row['config_value'], + row['value_type'], + row['is_encrypted'] + ) + + return configs + finally: + if conn: + conn.close() + + async def refresh_cache(self): + """刷新缓存""" + self._config_cache.clear() + + if self.redis_client: + try: + # 删除该租户的所有缓存 + pattern = f"{CACHE_PREFIX}{self.tenant_code}:*" + cursor = 0 + while True: + cursor, keys = await self.redis_client.scan(cursor, match=pattern, count=100) + if keys: + await self.redis_client.delete(*keys) + if cursor == 0: + break + except Exception as e: + logger.warning(f"Redis 缓存刷新失败: {e}") + + async def is_feature_enabled(self, feature_code: str) -> bool: + """ + 检查功能是否启用 + + Args: + feature_code: 功能编码 + + Returns: + 是否启用 + """ + conn = None + try: + conn = await aiomysql.connect(**ADMIN_DB_CONFIG) + async with conn.cursor(aiomysql.DictCursor) as cursor: + # 获取租户 ID + if self._tenant_id is None: + await cursor.execute( + "SELECT id FROM tenants WHERE code = %s AND status = 'active'", + (self.tenant_code,) + ) + row = await cursor.fetchone() + if row: + self._tenant_id = row['id'] + + # 先查租户级别的配置 + if self._tenant_id: + await cursor.execute( + """ + SELECT is_enabled FROM feature_switches + WHERE tenant_id = %s AND feature_code = %s + """, + (self._tenant_id, feature_code) + ) + row = await cursor.fetchone() + if row: + return bool(row['is_enabled']) + + # 再查全局默认配置 + await cursor.execute( + """ + SELECT is_enabled FROM feature_switches + WHERE tenant_id IS NULL AND feature_code = %s + """, + (feature_code,) + ) + row = await cursor.fetchone() + if row: + return bool(row['is_enabled']) + + return True # 默认启用 + except Exception as e: + logger.warning(f"功能开关查询失败: {e}, 默认启用") + return True + finally: + if conn: + conn.close() + + +class TenantConfigManager: + """租户配置管理器(单例)""" + + _instances: Dict[str, TenantConfigLoader] = {} + _redis_client: Optional[redis.Redis] = None + + @classmethod + async def init_redis(cls, redis_url: str): + """初始化 Redis 连接""" + try: + cls._redis_client = redis.from_url(redis_url) + await cls._redis_client.ping() + logger.info("TenantConfigManager Redis 连接成功") + except Exception as e: + logger.warning(f"TenantConfigManager Redis 连接失败: {e}") + cls._redis_client = None + + @classmethod + def get_loader(cls, tenant_code: str) -> TenantConfigLoader: + """获取租户配置加载器""" + if tenant_code not in cls._instances: + cls._instances[tenant_code] = TenantConfigLoader( + tenant_code, + cls._redis_client + ) + return cls._instances[tenant_code] + + @classmethod + async def refresh_tenant_cache(cls, tenant_code: str): + """刷新指定租户的缓存""" + if tenant_code in cls._instances: + await cls._instances[tenant_code].refresh_cache() + + @classmethod + async def refresh_all_cache(cls): + """刷新所有租户的缓存""" + for loader in cls._instances.values(): + await loader.refresh_cache() + + +# ============================================ +# 辅助函数 +# ============================================ + +def get_tenant_code_from_domain(domain: str) -> str: + """ + 从域名提取租户编码 + + Examples: + hua.ireborn.com.cn -> hua + yy.ireborn.com.cn -> yy + aiedu.ireborn.com.cn -> demo + """ + if not domain: + return "demo" + + # 移除 https:// 或 http:// + domain = domain.replace("https://", "").replace("http://", "") + + # 获取子域名 + parts = domain.split(".") + if len(parts) >= 3: + subdomain = parts[0] + # 特殊处理 + if subdomain == "aiedu": + return "demo" + return subdomain + + return "demo" + + +async def get_tenant_config(tenant_code: str, config_group: str, config_key: str, default: Any = None) -> Any: + """ + 快捷函数:获取租户配置 + + Args: + tenant_code: 租户编码 + config_group: 配置分组 + config_key: 配置键 + default: 默认值 + + Returns: + 配置值 + """ + loader = TenantConfigManager.get_loader(tenant_code) + return await loader.get_config(config_group, config_key, default) + + +async def is_tenant_feature_enabled(tenant_code: str, feature_code: str) -> bool: + """ + 快捷函数:检查租户功能是否启用 + + Args: + tenant_code: 租户编码 + feature_code: 功能编码 + + Returns: + 是否启用 + """ + loader = TenantConfigManager.get_loader(tenant_code) + return await loader.is_feature_enabled(feature_code) + diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000..de2cb8b --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,140 @@ +"""考培练系统后端主应用""" +import logging +from contextlib import asynccontextmanager + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from fastapi.staticfiles import StaticFiles +import json +import os + +from app.core.config import get_settings +from app.api.v1 import api_router + +# 配置日志 +logging.basicConfig( + level=logging.INFO, format="%(asctime)s - %(name)s - %(levelname)s - %(message)s" +) +logger = logging.getLogger(__name__) + +settings = get_settings() + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """应用生命周期管理""" + # 启动时执行 + logger.info(f"启动 {settings.APP_NAME} v{settings.APP_VERSION}") + + # 初始化 Redis + try: + from app.core.redis import init_redis, close_redis + await init_redis() + logger.info("Redis 初始化成功") + except Exception as e: + logger.warning(f"Redis 初始化失败(非致命): {e}") + + yield + + # 关闭时执行 + try: + from app.core.redis import close_redis + await close_redis() + logger.info("Redis 连接已关闭") + except Exception as e: + logger.warning(f"关闭 Redis 连接失败: {e}") + logger.info("应用关闭") + + +# 自定义 JSON 响应类,确保中文正确编码 +class UTF8JSONResponse(JSONResponse): + def render(self, content) -> bytes: + return json.dumps( + content, + ensure_ascii=False, + allow_nan=False, + indent=None, + separators=(",", ":"), + ).encode("utf-8") + +# 创建FastAPI应用 +app = FastAPI( + title=settings.APP_NAME, + version=settings.APP_VERSION, + description="考培练系统后端API", + lifespan=lifespan, + # 确保响应正确的 UTF-8 编码 + default_response_class=UTF8JSONResponse, +) + +# 配置CORS +app.add_middleware( + CORSMiddleware, + allow_origins=settings.CORS_ORIGINS, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + + +# 健康检查端点 +@app.get("/health") +async def health_check(): + """健康检查""" + return { + "status": "healthy", + "service": settings.APP_NAME, + "version": settings.APP_VERSION, + } + + +# 根路径 +@app.get("/") +async def root(): + """根路径""" + return { + "message": f"欢迎使用{settings.APP_NAME}", + "version": settings.APP_VERSION, + "docs": "/docs", + } + + +# 注册路由 +app.include_router(api_router, prefix="/api/v1") + +# 挂载静态文件目录 +# 创建上传目录(如果不存在) +upload_path = settings.UPLOAD_PATH +os.makedirs(upload_path, exist_ok=True) + +# 挂载上传文件目录为静态文件服务 +app.mount("/static/uploads", StaticFiles(directory=upload_path), name="uploads") + + +# 全局异常处理 +@app.exception_handler(Exception) +async def global_exception_handler(request, exc): + """全局异常处理""" + logger.error(f"未处理的异常: {exc}", exc_info=True) + return JSONResponse( + status_code=500, + content={ + "code": 500, + "message": "内部服务器错误", + "detail": str(exc) if settings.DEBUG else None, + }, + ) + + +if __name__ == "__main__": + import uvicorn + + uvicorn.run( + "app.main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG, + log_level=settings.LOG_LEVEL.lower(), + ) +# 测试热重载 - Fri Sep 26 03:37:07 CST 2025 diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py new file mode 100644 index 0000000..c802572 --- /dev/null +++ b/backend/app/models/__init__.py @@ -0,0 +1,49 @@ +"""数据库模型包""" +from app.models.base import Base, BaseModel +from app.models.user import User +from app.models.course import Course, CourseMaterial, KnowledgePoint, GrowthPath +from app.models.training import ( + TrainingScene, + TrainingSession, + TrainingMessage, + TrainingReport, +) +from app.models.exam import Exam, Question, ExamResult +from app.models.exam_mistake import ExamMistake +from app.models.position import Position +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.models.practice import PracticeScene, PracticeSession, PracticeDialogue, PracticeReport +from app.models.system_log import SystemLog +from app.models.task import Task, TaskCourse, TaskAssignment +from app.models.notification import Notification + +__all__ = [ + "Base", + "BaseModel", + "User", + "Course", + "CourseMaterial", + "KnowledgePoint", + "GrowthPath", + "TrainingScene", + "TrainingSession", + "TrainingMessage", + "TrainingReport", + "Exam", + "Question", + "ExamResult", + "ExamMistake", + "Position", + "PositionMember", + "PositionCourse", + "PracticeScene", + "PracticeSession", + "PracticeDialogue", + "PracticeReport", + "SystemLog", + "Task", + "TaskCourse", + "TaskAssignment", + "Notification", +] diff --git a/backend/app/models/ability.py b/backend/app/models/ability.py new file mode 100644 index 0000000..6af3936 --- /dev/null +++ b/backend/app/models/ability.py @@ -0,0 +1,64 @@ +""" +能力评估模型 +用于存储智能工牌数据分析、练习报告等产生的能力评估结果 +""" +from sqlalchemy import Column, Integer, String, DateTime, JSON, ForeignKey, Text +from sqlalchemy.sql import func +from sqlalchemy.orm import relationship +from app.models.base import Base + + +class AbilityAssessment(Base): + """能力评估历史表""" + __tablename__ = "ability_assessments" + + id = Column(Integer, primary_key=True, index=True, comment='主键ID') + user_id = Column( + Integer, + ForeignKey('users.id', ondelete='CASCADE'), + nullable=False, + comment='用户ID' + ) + source_type = Column( + String(50), + nullable=False, + comment='数据来源: yanji_badge(智能工牌), practice_report(练习报告), manual(手动评估)' + ) + source_id = Column( + String(100), + comment='来源记录ID(如录音ID列表,逗号分隔)' + ) + total_score = Column( + Integer, + comment='综合评分(0-100)' + ) + ability_dimensions = Column( + JSON, + nullable=False, + comment='6个能力维度评分JSON数组' + ) + recommended_courses = Column( + JSON, + comment='推荐课程列表JSON数组' + ) + conversation_count = Column( + Integer, + comment='分析的对话数量' + ) + analyzed_at = Column( + DateTime, + server_default=func.now(), + comment='分析时间' + ) + created_at = Column( + DateTime, + server_default=func.now(), + comment='创建时间' + ) + + # 关系 + # user = relationship("User", back_populates="ability_assessments") + + def __repr__(self): + return f"" + diff --git a/backend/app/models/base.py b/backend/app/models/base.py new file mode 100644 index 0000000..41841a4 --- /dev/null +++ b/backend/app/models/base.py @@ -0,0 +1,47 @@ +"""基础模型定义""" +from datetime import datetime +from typing import Optional + +from sqlalchemy import Column, DateTime, Integer, Boolean, func +from sqlalchemy.ext.declarative import declarative_base +from sqlalchemy.orm import Mapped, mapped_column + +# 创建基础模型类 +Base = declarative_base() + + +class BaseModel(Base): + """ + 基础模型类,所有模型都应继承此类 + 包含通用字段:id, created_at, updated_at + 时区:使用北京时间(Asia/Shanghai, UTC+8) + """ + + __abstract__ = True + __allow_unmapped__ = True # SQLAlchemy 2.0 兼容性 + + id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True) + created_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), nullable=False, comment="创建时间(北京时间)" + ) + updated_at: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), onupdate=func.now(), nullable=False, comment="更新时间(北京时间)" + ) + + +class SoftDeleteMixin: + """软删除混入类""" + + __allow_unmapped__ = True + + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + deleted_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + + +class AuditMixin: + """审计字段混入类""" + + __allow_unmapped__ = True + + created_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + updated_by: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) diff --git a/backend/app/models/course.py b/backend/app/models/course.py new file mode 100644 index 0000000..032e62b --- /dev/null +++ b/backend/app/models/course.py @@ -0,0 +1,270 @@ +""" +课程相关数据库模型 +""" +from enum import Enum +from typing import List, Optional +from datetime import datetime + +from sqlalchemy import ( + String, + Text, + Integer, + Boolean, + ForeignKey, + Enum as SQLEnum, + Float, + JSON, + DateTime, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import BaseModel, SoftDeleteMixin, AuditMixin + + +class CourseStatus(str, Enum): + """课程状态枚举""" + + DRAFT = "draft" # 草稿 + PUBLISHED = "published" # 已发布 + ARCHIVED = "archived" # 已归档 + + +class CourseCategory(str, Enum): + """课程分类枚举""" + + TECHNOLOGY = "technology" # 技术 + MANAGEMENT = "management" # 管理 + BUSINESS = "business" # 业务 + GENERAL = "general" # 通用 + + +class Course(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 课程表 + """ + + __tablename__ = "courses" + + # 基本信息 + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="课程名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="课程描述" + ) + category: Mapped[CourseCategory] = mapped_column( + SQLEnum( + CourseCategory, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + validate_strings=True, + ), + default=CourseCategory.GENERAL, + nullable=False, + comment="课程分类", + ) + status: Mapped[CourseStatus] = mapped_column( + SQLEnum( + CourseStatus, + values_callable=lambda enum_cls: [e.value for e in enum_cls], + validate_strings=True, + ), + default=CourseStatus.DRAFT, + nullable=False, + comment="课程状态", + ) + + # 课程详情 + cover_image: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="封面图片URL" + ) + duration_hours: Mapped[Optional[float]] = mapped_column( + Float, nullable=True, comment="课程时长(小时)" + ) + difficulty_level: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="难度等级(1-5)" + ) + tags: Mapped[Optional[List[str]]] = mapped_column( + JSON, nullable=True, comment="标签列表" + ) + + # 发布信息 + published_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, comment="发布时间" + ) + publisher_id: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="发布人ID" + ) + + # 播课信息 + # 播课功能(Coze工作流直接写数据库) + broadcast_audio_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="播课音频URL" + ) + broadcast_generated_at: Mapped[Optional[datetime]] = mapped_column( + DateTime(timezone=True), nullable=True, comment="播课生成时间" + ) + + # 排序和权重 + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + is_featured: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否推荐" + ) + + # 统计信息 + student_count: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="学习人数" + ) + is_new: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, comment="是否新课程" + ) + + # 资料下载设置 + allow_download: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否允许下载资料" + ) + + # 关联关系 + materials: Mapped[List["CourseMaterial"]] = relationship( + "CourseMaterial", back_populates="course" + ) + knowledge_points: Mapped[List["KnowledgePoint"]] = relationship( + "KnowledgePoint", back_populates="course" + ) + + # 岗位分配关系(通过关联表) + position_assignments = relationship("PositionCourse", back_populates="course", cascade="all, delete-orphan") + exams = relationship("Exam", back_populates="course") + questions = relationship("Question", back_populates="course") + + +class CourseMaterial(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 课程资料表 + """ + + __tablename__ = "course_materials" + + course_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("courses.id", ondelete="CASCADE"), + nullable=False, + comment="课程ID", + ) + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="资料名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="资料描述" + ) + file_url: Mapped[str] = mapped_column(String(500), nullable=False, comment="文件URL") + file_type: Mapped[str] = mapped_column(String(50), nullable=False, comment="文件类型") + file_size: Mapped[int] = mapped_column(Integer, nullable=False, comment="文件大小(字节)") + + # 排序 + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + + # 关联关系 + course: Mapped["Course"] = relationship("Course", back_populates="materials") + # 关联的知识点(直接关联) + knowledge_points: Mapped[List["KnowledgePoint"]] = relationship( + "KnowledgePoint", back_populates="material" + ) + + +class KnowledgePoint(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 知识点表 + """ + + __tablename__ = "knowledge_points" + + course_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("courses.id", ondelete="CASCADE"), + nullable=False, + comment="课程ID", + ) + material_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("course_materials.id", ondelete="CASCADE"), + nullable=False, + comment="关联资料ID", + ) + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="知识点名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="知识点描述" + ) + type: Mapped[str] = mapped_column( + String(50), default="概念定义", nullable=False, comment="知识点类型" + ) + source: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="来源:0=手动,1=AI分析" + ) + topic_relation: Mapped[Optional[str]] = mapped_column( + String(200), nullable=True, comment="与主题的关系描述" + ) + + # 关联关系 + course: Mapped["Course"] = relationship("Course", back_populates="knowledge_points") + material: Mapped["CourseMaterial"] = relationship("CourseMaterial") + + +class GrowthPath(BaseModel, SoftDeleteMixin): + """ + 成长路径表 + """ + + __tablename__ = "growth_paths" + + name: Mapped[str] = mapped_column(String(200), nullable=False, comment="路径名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="路径描述" + ) + target_role: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="目标角色" + ) + + # 路径配置 + courses: Mapped[Optional[List[dict]]] = mapped_column( + JSON, nullable=True, comment="课程列表[{course_id, order, is_required}]" + ) + + # 预计时长 + estimated_duration_days: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="预计完成天数" + ) + + # 状态 + is_active: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, comment="是否启用" + ) + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + + +class MaterialKnowledgePoint(BaseModel, SoftDeleteMixin): + """ + 资料知识点关联表 + """ + + __tablename__ = "material_knowledge_points" + + material_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("course_materials.id", ondelete="CASCADE"), + nullable=False, + comment="资料ID", + ) + knowledge_point_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("knowledge_points.id", ondelete="CASCADE"), + nullable=False, + comment="知识点ID", + ) + sort_order: Mapped[int] = mapped_column( + Integer, default=0, nullable=False, comment="排序顺序" + ) + is_ai_generated: Mapped[bool] = mapped_column( + Boolean, default=False, nullable=False, comment="是否AI生成" + ) diff --git a/backend/app/models/course_exam_settings.py b/backend/app/models/course_exam_settings.py new file mode 100644 index 0000000..9eff324 --- /dev/null +++ b/backend/app/models/course_exam_settings.py @@ -0,0 +1,34 @@ +""" +课程考试设置模型 +""" +from sqlalchemy import Column, Integer, ForeignKey, Boolean +from sqlalchemy.orm import relationship +from app.models.base import BaseModel, SoftDeleteMixin, AuditMixin + + +class CourseExamSettings(BaseModel, SoftDeleteMixin, AuditMixin): + """课程考试设置表""" + __tablename__ = "course_exam_settings" + + course_id = Column(Integer, ForeignKey("courses.id"), unique=True, nullable=False, comment="课程ID") + + # 题型数量设置 + single_choice_count = Column(Integer, default=4, nullable=False, comment="单选题数量") + multiple_choice_count = Column(Integer, default=2, nullable=False, comment="多选题数量") + true_false_count = Column(Integer, default=1, nullable=False, comment="判断题数量") + fill_blank_count = Column(Integer, default=2, nullable=False, comment="填空题数量") + essay_count = Column(Integer, default=1, nullable=False, comment="问答题数量") + + # 考试参数设置 + duration_minutes = Column(Integer, default=10, nullable=False, comment="考试时长(分钟)") + difficulty_level = Column(Integer, default=3, nullable=False, comment="难度系数(1-5)") + passing_score = Column(Integer, default=60, nullable=False, comment="及格分数") + + # 其他设置 + is_enabled = Column(Boolean, default=True, nullable=False, comment="是否启用") + show_answer_immediately = Column(Boolean, default=False, nullable=False, comment="是否立即显示答案") + allow_retake = Column(Boolean, default=True, nullable=False, comment="是否允许重考") + max_retake_times = Column(Integer, default=3, nullable=True, comment="最大重考次数") + + # 关系 + course = relationship("Course", backref="exam_settings", uselist=False) diff --git a/backend/app/models/exam.py b/backend/app/models/exam.py new file mode 100644 index 0000000..4e803a1 --- /dev/null +++ b/backend/app/models/exam.py @@ -0,0 +1,153 @@ +""" +考试相关模型定义 +""" +from datetime import datetime +from typing import List, Optional +from sqlalchemy import Column, Integer, String, Text, DateTime, ForeignKey, JSON, Float, func +from sqlalchemy.orm import relationship, Mapped, mapped_column +from app.models.base import BaseModel + + +class Exam(BaseModel): + """考试记录模型""" + + __tablename__ = "exams" + __allow_unmapped__ = True + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + user_id: Mapped[int] = mapped_column( + Integer, ForeignKey("users.id"), nullable=False, index=True + ) + course_id: Mapped[int] = mapped_column( + Integer, ForeignKey("courses.id"), nullable=False, index=True + ) + + # 考试信息 + exam_name: Mapped[str] = mapped_column(String(255), nullable=False) + question_count: Mapped[int] = mapped_column(Integer, default=10) + total_score: Mapped[float] = mapped_column(Float, default=100.0) + pass_score: Mapped[float] = mapped_column(Float, default=60.0) + + # 考试时间 + start_time: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), comment="开始时间(北京时间)") + end_time: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="结束时间(北京时间)") + duration_minutes: Mapped[int] = mapped_column(Integer, default=60) # 考试时长(分钟) + + # 考试结果 + score: Mapped[Optional[float]] = mapped_column(Float, nullable=True) + + # 三轮考试得分 + round1_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="第一轮得分") + round2_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="第二轮得分") + round3_score: Mapped[Optional[float]] = mapped_column(Float, nullable=True, comment="第三轮得分") + + is_passed: Mapped[Optional[bool]] = mapped_column(nullable=True) + + # 考试状态: started, submitted, timeout + status: Mapped[str] = mapped_column(String(20), default="started", index=True) + + # 考试数据(JSON格式存储题目和答案) + questions: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + answers: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + + # 关系 + user = relationship("User", back_populates="exams") + course = relationship("Course", back_populates="exams") + results = relationship("ExamResult", back_populates="exam") + + def __repr__(self): + return f"" + + +class Question(BaseModel): + """题目模型""" + + __tablename__ = "questions" + __allow_unmapped__ = True + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + course_id: Mapped[int] = mapped_column( + Integer, ForeignKey("courses.id"), nullable=False, index=True + ) + + # 题目类型: single_choice, multiple_choice, true_false, fill_blank, essay + question_type: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + + # 题目内容 + title: Mapped[str] = mapped_column(Text, nullable=False) + content: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 选项(JSON格式,适用于选择题) + options: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True) + + # 答案 + correct_answer: Mapped[str] = mapped_column(Text, nullable=False) + explanation: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 分值 + score: Mapped[float] = mapped_column(Float, default=10.0) + + # 难度等级: easy, medium, hard + difficulty: Mapped[str] = mapped_column(String(10), default="medium", index=True) + + # 标签(JSON格式) + tags: Mapped[Optional[list]] = mapped_column(JSON, nullable=True) + + # 使用统计 + usage_count: Mapped[int] = mapped_column(Integer, default=0) + correct_count: Mapped[int] = mapped_column(Integer, default=0) + + # 状态 + is_active: Mapped[bool] = mapped_column(default=True, index=True) + + # 关系 + course = relationship("Course", back_populates="questions") + + def __repr__(self): + return f"" + + +class ExamResult(BaseModel): + """考试结果详情模型""" + + __tablename__ = "exam_results" + __allow_unmapped__ = True + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + exam_id: Mapped[int] = mapped_column( + Integer, ForeignKey("exams.id"), nullable=False, index=True + ) + question_id: Mapped[int] = mapped_column( + Integer, ForeignKey("questions.id"), nullable=False + ) + + # 用户答案 + user_answer: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 是否正确 + is_correct: Mapped[bool] = mapped_column(default=False) + + # 得分 + score: Mapped[float] = mapped_column(Float, default=0.0) + + # 答题时长(秒) + answer_time: Mapped[Optional[int]] = mapped_column(Integer, nullable=True) + + # 关系 + exam = relationship("Exam", back_populates="results") + question = relationship("Question") + + def __repr__(self): + return f"" + + +# 在模型文件末尾添加关系定义 +# 需要在User模型中添加 +# exams = relationship("Exam", back_populates="user") + +# 需要在Course模型中添加 +# exams = relationship("Exam", back_populates="course") +# questions = relationship("Question", back_populates="course") + +# 需要在Exam模型中添加 +# results = relationship("ExamResult", back_populates="exam") diff --git a/backend/app/models/exam_mistake.py b/backend/app/models/exam_mistake.py new file mode 100644 index 0000000..69a999f --- /dev/null +++ b/backend/app/models/exam_mistake.py @@ -0,0 +1,43 @@ +""" +错题记录模型 +""" +from sqlalchemy import Column, Integer, ForeignKey, Text, DateTime, func +from sqlalchemy.orm import relationship +from datetime import datetime +from app.models.base import BaseModel + + +class ExamMistake(BaseModel): + """错题记录表""" + __tablename__ = "exam_mistakes" + + # 核心关联字段 + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="用户ID") + exam_id = Column(Integer, ForeignKey("exams.id", ondelete="CASCADE"), nullable=False, index=True, comment="考试ID") + question_id = Column(Integer, ForeignKey("questions.id", ondelete="SET NULL"), nullable=True, index=True, comment="题目ID(AI生成的题目可能为空)") + knowledge_point_id = Column(Integer, ForeignKey("knowledge_points.id", ondelete="SET NULL"), nullable=True, index=True, comment="关联的知识点ID") + + # 题目核心信息 + question_content = Column(Text, nullable=False, comment="题目内容") + correct_answer = Column(Text, nullable=False, comment="正确答案") + user_answer = Column(Text, nullable=True, comment="用户答案") + question_type = Column(Text, nullable=True, index=True, comment="题型(single/multiple/judge/blank/essay)") + + # 掌握状态和统计字段 + mastery_status = Column(Text, nullable=False, default='unmastered', index=True, comment="掌握状态: unmastered-未掌握, mastered-已掌握") + difficulty = Column(Text, nullable=False, default='medium', index=True, comment="题目难度: easy-简单, medium-中等, hard-困难") + wrong_count = Column(Integer, nullable=False, default=1, comment="错误次数统计") + mastered_at = Column(DateTime, nullable=True, comment="标记掌握时间") + + # 审计字段(继承自BaseModel,但这里重写以匹配数据库实际结构) + created_at = Column(DateTime, nullable=False, server_default=func.now(), comment="创建时间(北京时间)") + updated_at = Column(DateTime, nullable=False, server_default=func.now(), onupdate=func.now(), comment="更新时间(北京时间)") + + # 关系 + user = relationship("User", backref="exam_mistakes") + exam = relationship("Exam", backref="mistakes") + question = relationship("Question", backref="mistake_records") + knowledge_point = relationship("KnowledgePoint", backref="mistake_records") + + def __repr__(self): + return f"" diff --git a/backend/app/models/notification.py b/backend/app/models/notification.py new file mode 100644 index 0000000..1e89f53 --- /dev/null +++ b/backend/app/models/notification.py @@ -0,0 +1,106 @@ +""" +站内消息通知模型 +用于记录用户的站内消息通知 +""" +from datetime import datetime +from typing import Optional +from sqlalchemy import String, Text, Integer, Boolean, Index, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from app.models.base import BaseModel + + +class Notification(BaseModel): + """ + 站内消息通知模型 + + 用于存储发送给用户的各类站内通知消息,如: + - 岗位分配通知 + - 课程分配通知 + - 考试提醒通知 + - 系统公告通知 + """ + __tablename__ = "notifications" + + # 接收用户ID(外键关联到users表) + user_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="CASCADE"), + nullable=False, + index=True, + comment="接收用户ID" + ) + + # 通知标题 + title: Mapped[str] = mapped_column( + String(200), + nullable=False, + comment="通知标题" + ) + + # 通知内容 + content: Mapped[Optional[str]] = mapped_column( + Text, + nullable=True, + comment="通知内容" + ) + + # 通知类型 + # position_assign: 岗位分配 + # course_assign: 课程分配 + # exam_remind: 考试提醒 + # task_assign: 任务分配 + # system: 系统通知 + type: Mapped[str] = mapped_column( + String(50), + nullable=False, + default="system", + index=True, + comment="通知类型:position_assign/course_assign/exam_remind/task_assign/system" + ) + + # 是否已读 + is_read: Mapped[bool] = mapped_column( + Boolean, + default=False, + nullable=False, + index=True, + comment="是否已读" + ) + + # 关联数据ID(可选,如岗位ID、课程ID等) + related_id: Mapped[Optional[int]] = mapped_column( + Integer, + nullable=True, + comment="关联数据ID(岗位ID/课程ID等)" + ) + + # 关联数据类型(可选,如position、course等) + related_type: Mapped[Optional[str]] = mapped_column( + String(50), + nullable=True, + comment="关联数据类型" + ) + + # 发送者ID(可选,系统通知时为空) + sender_id: Mapped[Optional[int]] = mapped_column( + Integer, + ForeignKey("users.id", ondelete="SET NULL"), + nullable=True, + comment="发送者用户ID" + ) + + # 关联关系 + user = relationship("User", foreign_keys=[user_id], backref="notifications") + sender = relationship("User", foreign_keys=[sender_id]) + + # 创建索引以优化查询性能 + __table_args__ = ( + Index('idx_notifications_user_read', 'user_id', 'is_read'), + Index('idx_notifications_user_created', 'user_id', 'created_at'), + Index('idx_notifications_type', 'type'), + ) + + def __repr__(self): + return f"" + diff --git a/backend/app/models/position.py b/backend/app/models/position.py new file mode 100644 index 0000000..621933e --- /dev/null +++ b/backend/app/models/position.py @@ -0,0 +1,54 @@ +""" +岗位(Position)数据模型 +""" + +from typing import Optional +from sqlalchemy import String, Integer, Text, ForeignKey, Boolean, JSON +from sqlalchemy.orm import Mapped, mapped_column, relationship +from typing import Optional, List + +from .base import BaseModel, SoftDeleteMixin, AuditMixin + + +class Position(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 岗位表 + + 字段说明: + - name: 岗位名称 + - code: 岗位编码(唯一),用于稳定引用 + - description: 岗位描述 + - parent_id: 上级岗位ID,支持树形结构 + - status: 状态(active/inactive) + """ + + __tablename__ = "positions" + __allow_unmapped__ = True + + name: Mapped[str] = mapped_column(String(100), nullable=False, index=True) + code: Mapped[str] = mapped_column(String(100), nullable=False, unique=True, index=True) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + parent_id: Mapped[Optional[int]] = mapped_column(Integer, ForeignKey("positions.id", ondelete="SET NULL")) + status: Mapped[str] = mapped_column(String(20), default="active", nullable=False) + + # 新增字段 + skills: Mapped[Optional[List]] = mapped_column(JSON, nullable=True, comment="核心技能") + level: Mapped[Optional[str]] = mapped_column(String(20), nullable=True, comment="岗位等级") + sort_order: Mapped[Optional[int]] = mapped_column(Integer, default=0, nullable=True, comment="排序") + + # 关系 + parent: Mapped[Optional["Position"]] = relationship( + "Position", remote_side="Position.id", backref="children", lazy="selectin" + ) + + # 成员关系(通过关联表) + members = relationship("PositionMember", back_populates="position", cascade="all, delete-orphan") + + # 课程关系(通过关联表) + courses = relationship("PositionCourse", back_populates="position", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + diff --git a/backend/app/models/position_course.py b/backend/app/models/position_course.py new file mode 100644 index 0000000..c5caadb --- /dev/null +++ b/backend/app/models/position_course.py @@ -0,0 +1,28 @@ +""" +岗位课程关联模型 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, Enum, UniqueConstraint +from sqlalchemy.orm import relationship +from app.models.base import BaseModel, SoftDeleteMixin + + +class PositionCourse(BaseModel, SoftDeleteMixin): + """岗位课程关联表""" + __tablename__ = "position_courses" + + # 添加唯一约束:同一岗位下同一课程只能有一条有效记录 + __table_args__ = ( + UniqueConstraint('position_id', 'course_id', 'is_deleted', name='uix_position_course'), + ) + + position_id = Column(Integer, ForeignKey("positions.id"), nullable=False, comment="岗位ID") + course_id = Column(Integer, ForeignKey("courses.id"), nullable=False, comment="课程ID") + + # 课程类型:required(必修)、optional(选修) + course_type = Column(String(20), default="required", nullable=False, comment="课程类型") + priority = Column(Integer, default=0, comment="优先级/排序") + + # 关系 + position = relationship("Position", back_populates="courses") + course = relationship("Course", back_populates="position_assignments") diff --git a/backend/app/models/position_member.py b/backend/app/models/position_member.py new file mode 100644 index 0000000..af37256 --- /dev/null +++ b/backend/app/models/position_member.py @@ -0,0 +1,26 @@ +""" +岗位成员关联模型 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, DateTime, ForeignKey, Boolean, UniqueConstraint, func +from sqlalchemy.orm import relationship +from app.models.base import BaseModel, SoftDeleteMixin + + +class PositionMember(BaseModel, SoftDeleteMixin): + """岗位成员关联表""" + __tablename__ = "position_members" + + # 添加唯一约束:同一岗位下同一用户只能有一条有效记录 + __table_args__ = ( + UniqueConstraint('position_id', 'user_id', 'is_deleted', name='uix_position_user'), + ) + + position_id = Column(Integer, ForeignKey("positions.id"), nullable=False, comment="岗位ID") + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, comment="用户ID") + role = Column(String(50), comment="成员角色(预留字段)") + joined_at = Column(DateTime, server_default=func.now(), comment="加入时间(北京时间)") + + # 关系 + position = relationship("Position", back_populates="members") + user = relationship("User", back_populates="position_memberships") diff --git a/backend/app/models/practice.py b/backend/app/models/practice.py new file mode 100644 index 0000000..1d607ad --- /dev/null +++ b/backend/app/models/practice.py @@ -0,0 +1,109 @@ +""" +陪练场景模型 +""" +from sqlalchemy import Column, Integer, String, Text, JSON, DECIMAL, Boolean, DateTime, ForeignKey +from sqlalchemy.sql import func +from app.models.base import Base + + +class PracticeScene(Base): + """陪练场景模型""" + __tablename__ = "practice_scenes" + + id = Column(Integer, primary_key=True, index=True, comment="场景ID") + name = Column(String(200), nullable=False, comment="场景名称") + description = Column(Text, comment="场景描述") + type = Column(String(50), nullable=False, index=True, comment="场景类型: phone/face/complaint/after-sales/product-intro") + difficulty = Column(String(50), nullable=False, index=True, comment="难度等级: beginner/junior/intermediate/senior/expert") + status = Column(String(20), default="active", index=True, comment="状态: active/inactive") + background = Column(Text, comment="场景背景设定") + ai_role = Column(Text, comment="AI角色描述") + objectives = Column(JSON, comment="练习目标数组") + keywords = Column(JSON, comment="关键词数组") + duration = Column(Integer, default=10, comment="预计时长(分钟)") + usage_count = Column(Integer, default=0, comment="使用次数") + rating = Column(DECIMAL(3, 1), default=0.0, comment="评分") + + # 审计字段 + created_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="创建人ID") + updated_by = Column(Integer, ForeignKey("users.id", ondelete="SET NULL"), comment="更新人ID") + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + # 软删除字段 + is_deleted = Column(Boolean, default=False, index=True, comment="是否删除") + deleted_at = Column(DateTime, comment="删除时间") + + def __repr__(self): + return f"" + + +class PracticeSession(Base): + """陪练会话模型""" + __tablename__ = "practice_sessions" + + id = Column(Integer, primary_key=True, index=True, comment="会话ID") + session_id = Column(String(50), unique=True, nullable=False, index=True, comment="会话唯一标识") + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True, comment="学员ID") + 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="场景类型") + conversation_id = Column(String(100), comment="Coze对话ID") + + # 会话时间信息 + start_time = Column(DateTime, nullable=False, index=True, comment="开始时间") + end_time = Column(DateTime, comment="结束时间") + duration_seconds = Column(Integer, default=0, comment="时长(秒)") + turns = Column(Integer, default=0, comment="对话轮次") + status = Column(String(20), default="in_progress", index=True, comment="状态: in_progress/completed/canceled") + + # 审计字段 + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + is_deleted = Column(Boolean, default=False, comment="是否删除") + + def __repr__(self): + return f"" + + +class PracticeDialogue(Base): + """陪练对话记录模型""" + __tablename__ = "practice_dialogues" + + id = Column(Integer, primary_key=True, index=True, comment="对话ID") + session_id = Column(String(50), nullable=False, index=True, comment="会话ID") + speaker = Column(String(20), nullable=False, comment="说话人: user/ai") + content = Column(Text, nullable=False, comment="对话内容") + timestamp = Column(DateTime, nullable=False, comment="时间戳") + sequence = Column(Integer, nullable=False, comment="顺序号") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + + def __repr__(self): + return f"" + + +class PracticeReport(Base): + """陪练分析报告模型""" + __tablename__ = "practice_reports" + + id = Column(Integer, primary_key=True, index=True, comment="报告ID") + session_id = Column(String(50), unique=True, nullable=False, index=True, comment="会话ID") + + # AI分析结果 + total_score = Column(Integer, comment="综合得分(0-100)") + score_breakdown = Column(JSON, comment="分数细分") + ability_dimensions = Column(JSON, comment="能力维度") + dialogue_review = Column(JSON, comment="对话复盘") + suggestions = Column(JSON, comment="改进建议") + + # AI分析元数据 + workflow_run_id = Column(String(100), comment="AI分析运行ID") + task_id = Column(String(100), comment="AI分析任务ID") + + created_at = Column(DateTime, server_default=func.now(), comment="创建时间") + updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now(), comment="更新时间") + + def __repr__(self): + return f"" + diff --git a/backend/app/models/system_log.py b/backend/app/models/system_log.py new file mode 100644 index 0000000..1c85ba4 --- /dev/null +++ b/backend/app/models/system_log.py @@ -0,0 +1,60 @@ +""" +系统日志模型 +用于记录系统操作、错误、安全事件等日志信息 +""" +from datetime import datetime +from sqlalchemy import Column, Integer, String, Text, DateTime, Index +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import BaseModel + + +class SystemLog(BaseModel): + """ + 系统日志模型 + 记录系统各类操作日志 + """ + __tablename__ = "system_logs" + + # 日志级别: debug, info, warning, error + level: Mapped[str] = mapped_column(String(20), nullable=False, index=True) + + # 日志类型: system, user, api, error, security + type: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + + # 操作用户(可能为空,如系统自动操作) + user: Mapped[str] = mapped_column(String(100), nullable=True, index=True) + + # 用户ID(可能为空) + user_id: Mapped[int] = mapped_column(Integer, nullable=True, index=True) + + # IP地址 + ip: Mapped[str] = mapped_column(String(100), nullable=True) + + # 日志消息 + message: Mapped[str] = mapped_column(Text, nullable=False) + + # User Agent + user_agent: Mapped[str] = mapped_column(String(500), nullable=True) + + # 请求路径(API路径) + path: Mapped[str] = mapped_column(String(500), nullable=True, index=True) + + # 请求方法 + method: Mapped[str] = mapped_column(String(10), nullable=True) + + # 额外数据(JSON格式,可存储详细信息) + extra_data: Mapped[str] = mapped_column(Text, nullable=True) + + # 创建索引以优化查询性能 + __table_args__ = ( + Index('idx_system_logs_created_at', 'created_at'), + Index('idx_system_logs_level_type', 'level', 'type'), + Index('idx_system_logs_user_created', 'user', 'created_at'), + ) + + def __repr__(self): + return f"" + + + diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..ecf568f --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,100 @@ +""" +任务相关模型 +""" +from datetime import datetime +from typing import List, Optional +from sqlalchemy import Column, Integer, String, Text, DateTime, Enum as SQLEnum, JSON, Boolean, ForeignKey +from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.models.base import BaseModel +from enum import Enum + + +class TaskPriority(str, Enum): + """任务优先级""" + LOW = "low" + MEDIUM = "medium" + HIGH = "high" + + +class TaskStatus(str, Enum): + """任务状态""" + PENDING = "pending" # 待开始 + ONGOING = "ongoing" # 进行中 + COMPLETED = "completed" # 已完成 + EXPIRED = "expired" # 已过期 + + +class AssignmentStatus(str, Enum): + """分配状态""" + NOT_STARTED = "not_started" + IN_PROGRESS = "in_progress" + COMPLETED = "completed" + + +class Task(BaseModel): + """任务表""" + __tablename__ = "tasks" + + title: Mapped[str] = mapped_column(String(200), nullable=False, comment="任务标题") + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True, comment="任务描述") + priority: Mapped[TaskPriority] = mapped_column( + SQLEnum(TaskPriority, values_callable=lambda x: [e.value for e in x]), + default=TaskPriority.MEDIUM, + nullable=False, + comment="优先级" + ) + status: Mapped[TaskStatus] = mapped_column( + SQLEnum(TaskStatus, values_callable=lambda x: [e.value for e in x]), + default=TaskStatus.PENDING, + nullable=False, + comment="任务状态" + ) + creator_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, comment="创建人ID") + deadline: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="截止时间") + requirements: Mapped[Optional[dict]] = mapped_column(JSON, nullable=True, comment="任务要求配置") + progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="完成进度") + is_deleted: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # 关系 + creator = relationship("User", backref="created_tasks", foreign_keys=[creator_id]) + course_links = relationship("TaskCourse", back_populates="task", cascade="all, delete-orphan") + assignments = relationship("TaskAssignment", back_populates="task", cascade="all, delete-orphan") + + +class TaskCourse(BaseModel): + """任务课程关联表""" + __tablename__ = "task_courses" + + task_id: Mapped[int] = mapped_column(Integer, ForeignKey("tasks.id"), nullable=False, comment="任务ID") + course_id: Mapped[int] = mapped_column(Integer, ForeignKey("courses.id"), nullable=False, comment="课程ID") + + # 关系 + task = relationship("Task", back_populates="course_links") + course = relationship("Course") + + +class TaskAssignment(BaseModel): + """任务分配表""" + __tablename__ = "task_assignments" + + task_id: Mapped[int] = mapped_column(Integer, ForeignKey("tasks.id"), nullable=False, comment="任务ID") + user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id"), nullable=False, comment="分配用户ID") + team_id: Mapped[Optional[int]] = mapped_column(Integer, nullable=True, comment="团队ID") + status: Mapped[AssignmentStatus] = mapped_column( + SQLEnum(AssignmentStatus, values_callable=lambda x: [e.value for e in x]), + default=AssignmentStatus.NOT_STARTED, + nullable=False, + comment="完成状态" + ) + progress: Mapped[int] = mapped_column(Integer, default=0, nullable=False, comment="个人完成进度") + completed_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True, comment="完成时间") + + # 关系 + task = relationship("Task", back_populates="assignments") + user = relationship("User") + + +__all__ = ["Task", "TaskCourse", "TaskAssignment", "TaskPriority", "TaskStatus", "AssignmentStatus"] + + + diff --git a/backend/app/models/training.py b/backend/app/models/training.py new file mode 100644 index 0000000..2f3716b --- /dev/null +++ b/backend/app/models/training.py @@ -0,0 +1,263 @@ +"""陪练模块数据模型""" +from datetime import datetime +from typing import Optional +from enum import Enum + +from sqlalchemy import ( + Column, + String, + Integer, + ForeignKey, + Text, + JSON, + Enum as SQLEnum, + Float, + Boolean, + DateTime, + func, +) +from sqlalchemy.orm import relationship, Mapped, mapped_column + +from app.models.base import BaseModel, SoftDeleteMixin, AuditMixin + + +class TrainingSceneStatus(str, Enum): + """陪练场景状态枚举""" + + DRAFT = "draft" # 草稿 + ACTIVE = "active" # 已激活 + INACTIVE = "inactive" # 已停用 + + +class TrainingSessionStatus(str, Enum): + """陪练会话状态枚举""" + + CREATED = "created" # 已创建 + IN_PROGRESS = "in_progress" # 进行中 + COMPLETED = "completed" # 已完成 + CANCELLED = "cancelled" # 已取消 + ERROR = "error" # 异常结束 + + +class MessageType(str, Enum): + """消息类型枚举""" + + TEXT = "text" # 文本消息 + VOICE = "voice" # 语音消息 + SYSTEM = "system" # 系统消息 + + +class MessageRole(str, Enum): + """消息角色枚举""" + + USER = "user" # 用户 + ASSISTANT = "assistant" # AI助手 + SYSTEM = "system" # 系统 + + +class TrainingScene(BaseModel, SoftDeleteMixin, AuditMixin): + """ + 陪练场景模型 + 定义不同的陪练场景,如面试训练、演讲训练等 + """ + + __tablename__ = "training_scenes" + __allow_unmapped__ = True + + # 基础信息 + name: Mapped[str] = mapped_column(String(100), nullable=False, comment="场景名称") + description: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="场景描述" + ) + category: Mapped[str] = mapped_column(String(50), nullable=False, comment="场景分类") + + # 配置信息 + ai_config: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="AI配置(如Coze Bot ID等)" + ) + prompt_template: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="提示词模板" + ) + evaluation_criteria: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="评估标准" + ) + + # 状态和权限 + status: Mapped[TrainingSceneStatus] = mapped_column( + SQLEnum(TrainingSceneStatus), + default=TrainingSceneStatus.DRAFT, + nullable=False, + comment="场景状态", + ) + is_public: Mapped[bool] = mapped_column( + Boolean, default=True, nullable=False, comment="是否公开" + ) + required_level: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="所需用户等级" + ) + + # 关联 + sessions: Mapped[list["TrainingSession"]] = relationship( + "TrainingSession", back_populates="scene", cascade="all, delete-orphan" + ) + + +class TrainingSession(BaseModel, AuditMixin): + """ + 陪练会话模型 + 记录每次陪练会话的信息 + """ + + __tablename__ = "training_sessions" + __allow_unmapped__ = True + + # 基础信息 + user_id: Mapped[int] = mapped_column( + Integer, nullable=False, index=True, comment="用户ID" + ) + scene_id: Mapped[int] = mapped_column( + Integer, ForeignKey("training_scenes.id"), nullable=False, comment="场景ID" + ) + + # 会话信息 + coze_conversation_id: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Coze会话ID" + ) + start_time: Mapped[datetime] = mapped_column( + DateTime, server_default=func.now(), nullable=False, comment="开始时间(北京时间)" + ) + end_time: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True, comment="结束时间(北京时间)" + ) + duration_seconds: Mapped[Optional[int]] = mapped_column( + Integer, nullable=True, comment="持续时长(秒)" + ) + + # 状态和配置 + status: Mapped[TrainingSessionStatus] = mapped_column( + SQLEnum(TrainingSessionStatus), + default=TrainingSessionStatus.CREATED, + nullable=False, + comment="会话状态", + ) + session_config: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="会话配置" + ) + + # 评估信息 + total_score: Mapped[Optional[float]] = mapped_column( + Float, nullable=True, comment="总分" + ) + evaluation_result: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="评估结果详情" + ) + + # 关联 + scene: Mapped["TrainingScene"] = relationship( + "TrainingScene", back_populates="sessions" + ) + messages: Mapped[list["TrainingMessage"]] = relationship( + "TrainingMessage", + back_populates="session", + cascade="all, delete-orphan", + order_by="TrainingMessage.created_at", + ) + report: Mapped[Optional["TrainingReport"]] = relationship( + "TrainingReport", back_populates="session", uselist=False + ) + + +class TrainingMessage(BaseModel): + """ + 陪练消息模型 + 记录会话中的每条消息 + """ + + __tablename__ = "training_messages" + __allow_unmapped__ = True + + # 基础信息 + session_id: Mapped[int] = mapped_column( + Integer, ForeignKey("training_sessions.id"), nullable=False, comment="会话ID" + ) + + # 消息内容 + role: Mapped[MessageRole] = mapped_column( + SQLEnum(MessageRole), nullable=False, comment="消息角色" + ) + type: Mapped[MessageType] = mapped_column( + SQLEnum(MessageType), nullable=False, comment="消息类型" + ) + content: Mapped[str] = mapped_column(Text, nullable=False, comment="消息内容") + + # 语音消息相关 + voice_url: Mapped[Optional[str]] = mapped_column( + String(500), nullable=True, comment="语音文件URL" + ) + voice_duration: Mapped[Optional[float]] = mapped_column( + Float, nullable=True, comment="语音时长(秒)" + ) + + # 元数据 + message_metadata: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="消息元数据" + ) + coze_message_id: Mapped[Optional[str]] = mapped_column( + String(100), nullable=True, comment="Coze消息ID" + ) + + # 关联 + session: Mapped["TrainingSession"] = relationship( + "TrainingSession", back_populates="messages" + ) + + +class TrainingReport(BaseModel, AuditMixin): + """ + 陪练报告模型 + 存储陪练会话的分析报告 + """ + + __tablename__ = "training_reports" + __allow_unmapped__ = True + + # 基础信息 + session_id: Mapped[int] = mapped_column( + Integer, + ForeignKey("training_sessions.id"), + unique=True, + nullable=False, + comment="会话ID", + ) + user_id: Mapped[int] = mapped_column( + Integer, nullable=False, index=True, comment="用户ID" + ) + + # 评分信息 + overall_score: Mapped[float] = mapped_column(Float, nullable=False, comment="总体得分") + dimension_scores: Mapped[dict] = mapped_column( + JSON, nullable=False, comment="各维度得分" + ) + + # 分析内容 + strengths: Mapped[list[str]] = mapped_column(JSON, nullable=False, comment="优势点") + weaknesses: Mapped[list[str]] = mapped_column(JSON, nullable=False, comment="待改进点") + suggestions: Mapped[list[str]] = mapped_column(JSON, nullable=False, comment="改进建议") + + # 详细内容 + detailed_analysis: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="详细分析" + ) + transcript: Mapped[Optional[str]] = mapped_column( + Text, nullable=True, comment="对话文本记录" + ) + + # 统计信息 + statistics: Mapped[Optional[dict]] = mapped_column( + JSON, nullable=True, comment="统计数据" + ) + + # 关联 + session: Mapped["TrainingSession"] = relationship( + "TrainingSession", back_populates="report" + ) diff --git a/backend/app/models/user.py b/backend/app/models/user.py new file mode 100644 index 0000000..66f8293 --- /dev/null +++ b/backend/app/models/user.py @@ -0,0 +1,171 @@ +""" +用户相关数据模型 +""" + +from datetime import datetime +from typing import List, Optional + +from sqlalchemy import ( + Boolean, + Column, + DateTime, + ForeignKey, + Integer, + String, + Table, + Text, + UniqueConstraint, + func, +) +from sqlalchemy.orm import Mapped, mapped_column, relationship + +from .base import Base, BaseModel, SoftDeleteMixin + +# 用户-团队关联表(用于多对多关系) +user_teams = Table( + "user_teams", + BaseModel.metadata, + Column( + "user_id", Integer, ForeignKey("users.id", ondelete="CASCADE"), primary_key=True + ), + Column( + "team_id", Integer, ForeignKey("teams.id", ondelete="CASCADE"), primary_key=True + ), + Column("role", String(50), default="member", nullable=False), # member, leader + Column("joined_at", DateTime, server_default=func.now(), nullable=False), + UniqueConstraint("user_id", "team_id", name="uq_user_team"), +) + + +class UserTeam(Base): + """用户团队关联模型(用于直接查询关联表)""" + + __allow_unmapped__ = True + __table__ = user_teams # 重用已定义的表 + + # 定义列映射(不需要id,因为使用复合主键) + user_id: Mapped[int] + team_id: Mapped[int] + role: Mapped[str] + joined_at: Mapped[datetime] + + def __repr__(self) -> str: + return f"" + + +class User(BaseModel, SoftDeleteMixin): + """用户模型""" + + __allow_unmapped__ = True + + __tablename__ = "users" + + # 基础信息 + username: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + email: Mapped[Optional[str]] = mapped_column(String(100), unique=True, nullable=True) + phone: Mapped[Optional[str]] = mapped_column(String(20), unique=True, nullable=True) + hashed_password: Mapped[str] = mapped_column( + "password_hash", String(200), nullable=False + ) + + # 个人信息 + full_name: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + avatar_url: Mapped[Optional[str]] = mapped_column(String(500), nullable=True) + bio: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + # 性别: male/female(可扩展) + gender: Mapped[Optional[str]] = mapped_column(String(10), nullable=True) + # 学校 + school: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + # 专业 + major: Mapped[Optional[str]] = mapped_column(String(100), nullable=True) + # 企微员工userid(用于SCRM系统对接) + wework_userid: Mapped[Optional[str]] = mapped_column(String(64), unique=True, nullable=True, comment="企微员工userid") + + # 系统角色:admin, manager, trainee + role: Mapped[str] = mapped_column(String(20), default="trainee", nullable=False) + + # 账号状态 + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) + + # 时间记录 + last_login_at: Mapped[Optional[datetime]] = mapped_column(DateTime, nullable=True) + password_changed_at: Mapped[Optional[datetime]] = mapped_column( + DateTime, nullable=True + ) + + # 关联关系 + teams: Mapped[List["Team"]] = relationship( + "Team", + secondary=user_teams, + back_populates="members", + lazy="selectin", + ) + exams = relationship("Exam", back_populates="user") + + # 岗位关系(通过关联表) + position_memberships = relationship("PositionMember", back_populates="user", cascade="all, delete-orphan") + + def __repr__(self) -> str: + return f"" + + +class Team(BaseModel, SoftDeleteMixin): + """团队模型""" + + __allow_unmapped__ = True + + __tablename__ = "teams" + + # 基础信息 + name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False) + code: Mapped[str] = mapped_column(String(50), unique=True, nullable=False) + description: Mapped[Optional[str]] = mapped_column(Text, nullable=True) + + # 团队类型:department, project, study_group + team_type: Mapped[str] = mapped_column( + String(50), default="department", nullable=False + ) + + # 状态 + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + + # 团队负责人 + leader_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("users.id", ondelete="SET NULL"), nullable=True + ) + + # 父团队(支持层级结构) + parent_id: Mapped[Optional[int]] = mapped_column( + Integer, ForeignKey("teams.id", ondelete="CASCADE"), nullable=True + ) + + # 关联关系 + members: Mapped[List["User"]] = relationship( + "User", + secondary=user_teams, + back_populates="teams", + lazy="selectin", + ) + + leader: Mapped[Optional["User"]] = relationship( + "User", + foreign_keys=[leader_id], + lazy="selectin", + ) + + parent: Mapped[Optional["Team"]] = relationship( + "Team", + remote_side="Team.id", + foreign_keys=[parent_id], + lazy="selectin", + ) + + children: Mapped[List["Team"]] = relationship( + "Team", + back_populates="parent", + lazy="selectin", + ) + + def __repr__(self) -> str: + return f"" diff --git a/backend/app/schemas/__init__.py b/backend/app/schemas/__init__.py new file mode 100644 index 0000000..b45dd2c --- /dev/null +++ b/backend/app/schemas/__init__.py @@ -0,0 +1 @@ +"""Pydantic模式包""" diff --git a/backend/app/schemas/ability.py b/backend/app/schemas/ability.py new file mode 100644 index 0000000..22ddca8 --- /dev/null +++ b/backend/app/schemas/ability.py @@ -0,0 +1,50 @@ +""" +能力评估相关的Pydantic Schema +""" +from pydantic import BaseModel, Field +from typing import List, Optional +from datetime import datetime + + +class AbilityDimension(BaseModel): + """能力维度评分""" + name: str = Field(..., description="能力维度名称") + score: int = Field(..., ge=0, le=100, description="评分(0-100)") + feedback: str = Field(..., description="反馈建议") + + +class CourseRecommendation(BaseModel): + """课程推荐""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + recommendation_reason: str = Field(..., description="推荐理由") + priority: str = Field(..., description="优先级: high/medium/low") + match_score: int = Field(..., ge=0, le=100, description="匹配度(0-100)") + + +class AbilityAssessmentResponse(BaseModel): + """能力评估响应""" + assessment_id: int = Field(..., description="评估记录ID") + total_score: int = Field(..., ge=0, le=100, description="综合评分") + dimensions: List[AbilityDimension] = Field(..., description="能力维度列表") + recommended_courses: List[CourseRecommendation] = Field(..., description="推荐课程列表") + conversation_count: int = Field(..., description="分析的对话数量") + analyzed_at: Optional[datetime] = Field(None, description="分析时间") + + +class AbilityAssessmentHistory(BaseModel): + """能力评估历史记录""" + id: int + user_id: int + source_type: str + source_id: Optional[str] + total_score: Optional[int] + ability_dimensions: List[AbilityDimension] + recommended_courses: Optional[List[CourseRecommendation]] + conversation_count: Optional[int] + analyzed_at: datetime + created_at: datetime + + class Config: + from_attributes = True + diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py new file mode 100644 index 0000000..94a4809 --- /dev/null +++ b/backend/app/schemas/auth.py @@ -0,0 +1,35 @@ +""" +认证相关 Schema +""" +from pydantic import EmailStr, Field + +from .base import BaseSchema + + +class LoginRequest(BaseSchema): + """登录请求""" + + username: str = Field(..., description="用户名/邮箱/手机号") + password: str = Field(..., min_length=6) + + +class Token(BaseSchema): + """令牌响应""" + + access_token: str + refresh_token: str + token_type: str = "bearer" + + +class TokenPayload(BaseSchema): + """令牌载荷""" + + sub: str # 用户ID + type: str # access 或 refresh + exp: int # 过期时间 + + +class RefreshTokenRequest(BaseSchema): + """刷新令牌请求""" + + refresh_token: str diff --git a/backend/app/schemas/base.py b/backend/app/schemas/base.py new file mode 100644 index 0000000..05abea7 --- /dev/null +++ b/backend/app/schemas/base.py @@ -0,0 +1,73 @@ +"""基础响应模式""" +from typing import Generic, TypeVar, Optional, Any, List +from pydantic import BaseModel, Field +from datetime import datetime + +DataT = TypeVar("DataT") + + +class ResponseModel(BaseModel, Generic[DataT]): + """ + 统一响应格式模型 + """ + + code: int = Field(default=200, description="响应状态码") + message: str = Field(default="success", description="响应消息") + data: Optional[DataT] = Field(default=None, description="响应数据") + request_id: Optional[str] = Field(default=None, description="请求ID") + + +class BaseSchema(BaseModel): + """基础模式""" + + class Config: + from_attributes = True # Pydantic V2 + json_encoders = {datetime: lambda v: v.isoformat()} + + +class TimestampMixin(BaseModel): + """时间戳混入""" + + created_at: datetime + updated_at: datetime + + +class IDMixin(BaseModel): + """ID混入""" + + id: int + + +class PaginationParams(BaseModel): + """分页参数""" + + page: int = Field(default=1, ge=1, description="页码") + page_size: int = Field(default=20, ge=1, le=100, description="每页数量") + + @property + def offset(self) -> int: + """计算偏移量""" + return (self.page - 1) * self.page_size + + @property + def limit(self) -> int: + """计算限制数量""" + return self.page_size + + +class PaginatedResponse(BaseModel, Generic[DataT]): + """分页响应模型""" + + items: list[DataT] = Field(default_factory=list, description="数据列表") + total: int = Field(default=0, description="总数量") + page: int = Field(default=1, description="当前页码") + page_size: int = Field(default=20, description="每页数量") + pages: int = Field(default=1, description="总页数") + + @classmethod + def create(cls, items: list[DataT], total: int, page: int, page_size: int): + """创建分页响应""" + pages = (total + page_size - 1) // page_size if page_size > 0 else 1 + return cls( + items=items, total=total, page=page, page_size=page_size, pages=pages + ) diff --git a/backend/app/schemas/course.py b/backend/app/schemas/course.py new file mode 100644 index 0000000..e6b24c9 --- /dev/null +++ b/backend/app/schemas/course.py @@ -0,0 +1,364 @@ +""" +课程相关的数据验证模型 +""" +from typing import Optional, List +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field, ConfigDict, field_validator + +from app.models.course import CourseStatus, CourseCategory + + +class CourseBase(BaseModel): + """ + 课程基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="课程名称") + description: Optional[str] = Field(None, description="课程描述") + category: CourseCategory = Field(default=CourseCategory.GENERAL, description="课程分类") + cover_image: Optional[str] = Field(None, max_length=500, description="封面图片URL") + duration_hours: Optional[float] = Field(None, ge=0, description="课程时长(小时)") + difficulty_level: Optional[int] = Field(None, ge=1, le=5, description="难度等级(1-5)") + tags: Optional[List[str]] = Field(default_factory=list, description="标签列表") + sort_order: int = Field(default=0, description="排序顺序") + is_featured: bool = Field(default=False, description="是否推荐") + allow_download: bool = Field(default=False, description="是否允许下载资料") + + @field_validator("category", mode="before") + @classmethod + def normalize_category(cls, v): + """允许使用枚举的名称或值(忽略大小写)。空字符串使用默认值。""" + if isinstance(v, CourseCategory): + return v + if isinstance(v, str): + s = v.strip() + # 空字符串使用默认值 + if not s: + return CourseCategory.GENERAL + # 优先按值匹配(technology 等) + try: + return CourseCategory(s.lower()) + except Exception: + pass + # 再按名称匹配(TECHNOLOGY 等) + try: + return CourseCategory[s.upper()] + except Exception: + pass + return v + + +class CourseCreate(CourseBase): + """ + 创建课程模型 + """ + + status: CourseStatus = Field(default=CourseStatus.DRAFT, description="课程状态") + + +class CourseUpdate(BaseModel): + """ + 更新课程模型 + """ + + name: Optional[str] = Field(None, min_length=1, max_length=200, description="课程名称") + description: Optional[str] = Field(None, description="课程描述") + category: Optional[CourseCategory] = Field(None, description="课程分类") + status: Optional[CourseStatus] = Field(None, description="课程状态") + cover_image: Optional[str] = Field(None, max_length=500, description="封面图片URL") + duration_hours: Optional[float] = Field(None, ge=0, description="课程时长(小时)") + difficulty_level: Optional[int] = Field(None, ge=1, le=5, description="难度等级(1-5)") + tags: Optional[List[str]] = Field(None, description="标签列表") + sort_order: Optional[int] = Field(None, description="排序顺序") + is_featured: Optional[bool] = Field(None, description="是否推荐") + allow_download: Optional[bool] = Field(None, description="是否允许下载资料") + + @field_validator("category", mode="before") + @classmethod + def normalize_category_update(cls, v): + if v is None: + return v + if isinstance(v, CourseCategory): + return v + if isinstance(v, str): + s = v.strip() + if not s: # 空字符串视为None(不更新) + return None + try: + return CourseCategory(s.lower()) + except Exception: + pass + try: + return CourseCategory[s.upper()] + except Exception: + pass + return v + + +class CourseInDB(CourseBase): + """ + 数据库中的课程模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="课程ID") + status: CourseStatus = Field(..., description="课程状态") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + published_at: Optional[datetime] = Field(None, description="发布时间") + publisher_id: Optional[int] = Field(None, description="发布人ID") + created_by: Optional[int] = Field(None, description="创建人ID") + updated_by: Optional[int] = Field(None, description="更新人ID") + # 用户岗位相关的课程类型(必修/选修),非数据库字段,由API动态计算 + course_type: Optional[str] = Field(None, description="课程类型:required=必修, optional=选修") + + +class CourseList(BaseModel): + """ + 课程列表查询参数 + """ + + status: Optional[CourseStatus] = Field(None, description="课程状态") + category: Optional[CourseCategory] = Field(None, description="课程分类") + is_featured: Optional[bool] = Field(None, description="是否推荐") + keyword: Optional[str] = Field(None, description="搜索关键词") + + +# 课程资料相关模型 +class CourseMaterialBase(BaseModel): + """ + 课程资料基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="资料名称") + description: Optional[str] = Field(None, description="资料描述") + sort_order: int = Field(default=0, description="排序顺序") + + +class CourseMaterialCreate(CourseMaterialBase): + """ + 创建课程资料模型 + """ + + file_url: str = Field(..., max_length=500, description="文件URL") + file_type: str = Field(..., max_length=50, description="文件类型") + file_size: int = Field(..., gt=0, description="文件大小(字节)") + + @field_validator("file_type") + def validate_file_type(cls, v): + """验证文件类型 + 支持格式:TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties + """ + allowed_types = [ + "txt", "md", "mdx", "pdf", "html", "htm", + "xlsx", "xls", "docx", "doc", "csv", "vtt", "properties" + ] + file_ext = v.lower() + if file_ext not in allowed_types: + raise ValueError(f"不支持的文件类型: {v}。允许的类型: TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties") + return file_ext + + +class CourseMaterialInDB(CourseMaterialBase): + """ + 数据库中的课程资料模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="资料ID") + course_id: int = Field(..., description="课程ID") + file_url: str = Field(..., description="文件URL") + file_type: str = Field(..., description="文件类型") + file_size: int = Field(..., description="文件大小(字节)") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +# 知识点相关模型 +class KnowledgePointBase(BaseModel): + """ + 知识点基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="知识点名称") + description: Optional[str] = Field(None, description="知识点描述") + type: str = Field(default="理论知识", description="知识点类型") + source: int = Field(default=0, description="来源:0=手动,1=AI分析") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + + +class KnowledgePointCreate(KnowledgePointBase): + """ + 创建知识点模型 + """ + + material_id: int = Field(..., description="关联资料ID(必填)") + + +class KnowledgePointUpdate(BaseModel): + """ + 更新知识点模型 + """ + + name: Optional[str] = Field(None, min_length=1, max_length=200, description="知识点名称") + description: Optional[str] = Field(None, description="知识点描述") + type: Optional[str] = Field(None, description="知识点类型") + source: Optional[int] = Field(None, description="来源:0=手动,1=AI分析") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + material_id: int = Field(..., description="关联资料ID(必填)") + + +class KnowledgePointInDB(KnowledgePointBase): + """ + 数据库中的知识点模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="知识点ID") + course_id: int = Field(..., description="课程ID") + material_id: int = Field(..., description="关联资料ID") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +class KnowledgePointTree(KnowledgePointInDB): + """ + 知识点树形结构 + """ + + children: List["KnowledgePointTree"] = Field( + default_factory=list, description="子知识点" + ) + + +# 成长路径相关模型 +class GrowthPathCourse(BaseModel): + """ + 成长路径中的课程 + """ + + course_id: int = Field(..., description="课程ID") + order: int = Field(..., ge=0, description="排序") + is_required: bool = Field(default=True, description="是否必修") + + +class GrowthPathBase(BaseModel): + """ + 成长路径基础模型 + """ + + name: str = Field(..., min_length=1, max_length=200, description="路径名称") + description: Optional[str] = Field(None, description="路径描述") + target_role: Optional[str] = Field(None, max_length=100, description="目标角色") + courses: List[GrowthPathCourse] = Field(default_factory=list, description="课程列表") + estimated_duration_days: Optional[int] = Field(None, ge=1, description="预计完成天数") + is_active: bool = Field(default=True, description="是否启用") + sort_order: int = Field(default=0, description="排序顺序") + + +class GrowthPathCreate(GrowthPathBase): + """ + 创建成长路径模型 + """ + + pass + + +class GrowthPathInDB(GrowthPathBase): + """ + 数据库中的成长路径模型 + """ + + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="路径ID") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +# 课程考试设置相关Schema +class CourseExamSettingsBase(BaseModel): + """ + 课程考试设置基础模型 + """ + single_choice_count: int = Field(default=4, ge=0, le=50, description="单选题数量") + multiple_choice_count: int = Field(default=2, ge=0, le=30, description="多选题数量") + true_false_count: int = Field(default=1, ge=0, le=20, description="判断题数量") + fill_blank_count: int = Field(default=2, ge=0, le=10, description="填空题数量") + essay_count: int = Field(default=1, ge=0, le=10, description="问答题数量") + + duration_minutes: int = Field(default=10, ge=10, le=180, description="考试时长(分钟)") + difficulty_level: int = Field(default=3, ge=1, le=5, description="难度系数(1-5)") + passing_score: int = Field(default=60, ge=0, le=100, description="及格分数") + + is_enabled: bool = Field(default=True, description="是否启用") + show_answer_immediately: bool = Field(default=False, description="是否立即显示答案") + allow_retake: bool = Field(default=True, description="是否允许重考") + max_retake_times: Optional[int] = Field(None, ge=1, le=10, description="最大重考次数") + + +class CourseExamSettingsCreate(CourseExamSettingsBase): + """ + 创建课程考试设置模型 + """ + pass + + +class CourseExamSettingsUpdate(BaseModel): + """ + 更新课程考试设置模型 + """ + single_choice_count: Optional[int] = Field(None, ge=0, le=50, description="单选题数量") + multiple_choice_count: Optional[int] = Field(None, ge=0, le=30, description="多选题数量") + true_false_count: Optional[int] = Field(None, ge=0, le=20, description="判断题数量") + fill_blank_count: Optional[int] = Field(None, ge=0, le=10, description="填空题数量") + essay_count: Optional[int] = Field(None, ge=0, le=10, description="问答题数量") + + duration_minutes: Optional[int] = Field(None, ge=10, le=180, description="考试时长(分钟)") + difficulty_level: Optional[int] = Field(None, ge=1, le=5, description="难度系数(1-5)") + passing_score: Optional[int] = Field(None, ge=0, le=100, description="及格分数") + + is_enabled: Optional[bool] = Field(None, description="是否启用") + show_answer_immediately: Optional[bool] = Field(None, description="是否立即显示答案") + allow_retake: Optional[bool] = Field(None, description="是否允许重考") + max_retake_times: Optional[int] = Field(None, ge=1, le=10, description="最大重考次数") + + +class CourseExamSettingsInDB(CourseExamSettingsBase): + """ + 数据库中的课程考试设置模型 + """ + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="设置ID") + course_id: int = Field(..., description="课程ID") + created_at: datetime = Field(..., description="创建时间") + updated_at: datetime = Field(..., description="更新时间") + + +# 岗位分配相关Schema +class CoursePositionAssignment(BaseModel): + """ + 课程岗位分配模型 + """ + position_id: int = Field(..., description="岗位ID") + course_type: str = Field(default="required", pattern="^(required|optional)$", description="课程类型:required必修/optional选修") + priority: int = Field(default=0, description="优先级/排序") + + +class CoursePositionAssignmentInDB(CoursePositionAssignment): + """ + 数据库中的课程岗位分配模型 + """ + model_config = ConfigDict(from_attributes=True) + + id: int = Field(..., description="分配ID") + course_id: int = Field(..., description="课程ID") + position_name: Optional[str] = Field(None, description="岗位名称") + position_description: Optional[str] = Field(None, description="岗位描述") + member_count: Optional[int] = Field(None, description="岗位成员数") diff --git a/backend/app/schemas/exam.py b/backend/app/schemas/exam.py new file mode 100644 index 0000000..aa4a068 --- /dev/null +++ b/backend/app/schemas/exam.py @@ -0,0 +1,316 @@ +""" +考试相关的Schema定义 +""" +from typing import List, Optional, Dict, Any +from datetime import datetime +from pydantic import BaseModel, Field + + +class StartExamRequest(BaseModel): + """开始考试请求""" + + course_id: int = Field(..., description="课程ID") + count: int = Field(10, ge=1, le=100, description="题目数量") + + +class StartExamResponse(BaseModel): + """开始考试响应""" + + exam_id: int = Field(..., description="考试ID") + + +class ExamAnswer(BaseModel): + """考试答案""" + + question_id: str = Field(..., description="题目ID") + answer: str = Field(..., description="答案") + + +class SubmitExamRequest(BaseModel): + """提交考试请求""" + + exam_id: int = Field(..., description="考试ID") + answers: List[ExamAnswer] = Field(..., description="答案列表") + + +class SubmitExamResponse(BaseModel): + """提交考试响应""" + + exam_id: int = Field(..., description="考试ID") + total_score: float = Field(..., description="总分") + pass_score: float = Field(..., description="及格分") + is_passed: bool = Field(..., description="是否通过") + correct_count: int = Field(..., description="正确题数") + total_count: int = Field(..., description="总题数") + accuracy: float = Field(..., description="正确率") + + +class QuestionInfo(BaseModel): + """题目信息""" + + id: str = Field(..., description="题目ID") + type: str = Field(..., description="题目类型") + title: str = Field(..., description="题目标题") + content: Optional[str] = Field(None, description="题目内容") + options: Optional[Dict[str, Any]] = Field(None, description="选项") + score: float = Field(..., description="分值") + + +class ExamResultInfo(BaseModel): + """答题结果信息""" + + question_id: int = Field(..., description="题目ID") + user_answer: Optional[str] = Field(None, description="用户答案") + is_correct: bool = Field(..., description="是否正确") + score: float = Field(..., description="得分") + + +class ExamDetailResponse(BaseModel): + """考试详情响应""" + + id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + exam_name: str = Field(..., description="考试名称") + question_count: int = Field(..., description="题目数量") + total_score: float = Field(..., description="总分") + pass_score: float = Field(..., description="及格分") + start_time: Optional[str] = Field(None, description="开始时间") + end_time: Optional[str] = Field(None, description="结束时间") + duration_minutes: int = Field(..., description="考试时长(分钟)") + status: str = Field(..., description="考试状态") + score: Optional[float] = Field(None, description="得分") + is_passed: Optional[bool] = Field(None, description="是否通过") + questions: Optional[Dict[str, Any]] = Field(None, description="题目数据") + results: Optional[List[ExamResultInfo]] = Field(None, description="答题结果") + answers: Optional[Dict[str, Any]] = Field(None, description="用户答案") + + +class ExamRecordInfo(BaseModel): + """考试记录信息""" + + id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + exam_name: str = Field(..., description="考试名称") + question_count: int = Field(..., description="题目数量") + total_score: float = Field(..., description="总分") + score: Optional[float] = Field(None, description="得分") + is_passed: Optional[bool] = Field(None, description="是否通过") + status: str = Field(..., description="考试状态") + start_time: Optional[str] = Field(None, description="开始时间") + end_time: Optional[str] = Field(None, description="结束时间") + created_at: str = Field(..., description="创建时间") + # 新增统计字段 + accuracy: Optional[float] = Field(None, description="正确率(%)") + correct_count: Optional[int] = Field(None, description="正确题数") + wrong_count: Optional[int] = Field(None, description="错题数") + duration_seconds: Optional[int] = Field(None, description="考试用时(秒)") + course_name: Optional[str] = Field(None, description="课程名称") + question_type_stats: Optional[List[Dict[str, Any]]] = Field(None, description="分题型统计") + + +class ExamRecordResponse(BaseModel): + """考试记录列表响应""" + + items: List[ExamRecordInfo] = Field(..., description="考试记录列表") + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + size: int = Field(..., description="每页数量") + pages: int = Field(..., description="总页数") + + +# ==================== AI服务响应Schema ==================== + +class MistakeRecord(BaseModel): + """错题记录详情""" + question_id: Optional[int] = Field(None, description="题目ID") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + + +class GenerateExamRequest(BaseModel): + """生成考试试题请求""" + course_id: int = Field(..., description="课程ID") + position_id: Optional[int] = Field(None, description="岗位ID,如果不提供则从用户信息中自动获取") + current_round: int = Field(1, ge=1, le=3, description="当前轮次(1/2/3)") + exam_id: Optional[int] = Field(None, description="已存在的exam_id(第2、3轮传入)") + mistake_records: Optional[str] = Field(None, description="错题记录JSON字符串,第一轮不传此参数,第二三轮传入上一轮错题的JSON字符串") + single_choice_count: int = Field(4, ge=0, le=50, description="单选题数量") + multiple_choice_count: int = Field(2, ge=0, le=30, description="多选题数量") + true_false_count: int = Field(1, ge=0, le=20, description="判断题数量") + fill_blank_count: int = Field(2, ge=0, le=10, description="填空题数量") + essay_count: int = Field(1, ge=0, le=10, description="问答题数量") + difficulty_level: int = Field(3, ge=1, le=5, description="难度系数(1-5)") + + +class GenerateExamResponse(BaseModel): + """生成考试试题响应""" + result: str = Field(..., description="试题JSON数组(字符串格式)") + workflow_run_id: Optional[str] = Field(None, description="AI服务调用ID") + task_id: Optional[str] = Field(None, description="任务ID") + exam_id: int = Field(..., description="考试ID(真实的数据库ID)") + + +class JudgeAnswerRequest(BaseModel): + """判断主观题答案请求""" + question: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="标准答案") + user_answer: str = Field(..., description="用户提交的答案") + analysis: str = Field(..., description="正确答案的解析(来源于试题生成器)") + + +class JudgeAnswerResponse(BaseModel): + """判断主观题答案响应""" + is_correct: bool = Field(..., description="是否正确") + correct_answer: str = Field(..., description="标准答案") + feedback: Optional[str] = Field(None, description="判断反馈信息") + + +class RecordMistakeRequest(BaseModel): + """记录错题请求""" + exam_id: int = Field(..., description="考试ID") + question_id: Optional[int] = Field(None, description="题目ID(AI生成的题目可能为空)") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + question_type: Optional[str] = Field(None, description="题型(single/multiple/judge/blank/essay)") + + +class RecordMistakeResponse(BaseModel): + """记录错题响应""" + id: int = Field(..., description="错题记录ID") + created_at: datetime = Field(..., description="创建时间") + + +class MistakeRecordItem(BaseModel): + """错题记录项""" + id: int = Field(..., description="错题记录ID") + question_id: Optional[int] = Field(None, description="题目ID") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + created_at: datetime = Field(..., description="创建时间") + + +class GetMistakesResponse(BaseModel): + """获取错题记录响应""" + mistakes: List[MistakeRecordItem] = Field(..., description="错题列表") + + +# ==================== 成绩报告和错题本相关Schema ==================== + +class RoundScores(BaseModel): + """三轮得分""" + round1: Optional[float] = Field(None, description="第一轮得分") + round2: Optional[float] = Field(None, description="第二轮得分") + round3: Optional[float] = Field(None, description="第三轮得分") + + +class ExamReportOverview(BaseModel): + """成绩报告概览""" + avg_score: float = Field(..., description="平均成绩(基于round1_score)") + total_exams: int = Field(..., description="考试总数") + pass_rate: float = Field(..., description="及格率") + total_questions: int = Field(..., description="答题总数") + + +class ExamTrendItem(BaseModel): + """成绩趋势项""" + date: str = Field(..., description="日期(YYYY-MM-DD)") + avg_score: float = Field(..., description="平均分") + + +class SubjectStatItem(BaseModel): + """科目统计项""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + avg_score: float = Field(..., description="平均分") + exam_count: int = Field(..., description="考试次数") + max_score: float = Field(..., description="最高分") + min_score: float = Field(..., description="最低分") + pass_rate: float = Field(..., description="及格率") + + +class RecentExamItem(BaseModel): + """最近考试记录项""" + id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + score: Optional[float] = Field(None, description="最终得分") + total_score: float = Field(..., description="总分") + is_passed: Optional[bool] = Field(None, description="是否通过") + duration_seconds: Optional[int] = Field(None, description="考试用时(秒)") + start_time: str = Field(..., description="开始时间") + end_time: Optional[str] = Field(None, description="结束时间") + round_scores: RoundScores = Field(..., description="三轮得分") + + +class ExamReportResponse(BaseModel): + """成绩报告响应""" + overview: ExamReportOverview = Field(..., description="概览数据") + trends: List[ExamTrendItem] = Field(..., description="趋势数据") + subjects: List[SubjectStatItem] = Field(..., description="科目分析") + recent_exams: List[RecentExamItem] = Field(..., description="最近考试记录") + + +class MistakeListItem(BaseModel): + """错题列表项""" + id: int = Field(..., description="错题记录ID") + exam_id: int = Field(..., description="考试ID") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + question_content: str = Field(..., description="题目内容") + correct_answer: str = Field(..., description="正确答案") + user_answer: str = Field(..., description="用户答案") + question_type: Optional[str] = Field(None, description="题型") + knowledge_point_id: Optional[int] = Field(None, description="知识点ID") + knowledge_point_name: Optional[str] = Field(None, description="知识点名称") + created_at: datetime = Field(..., description="创建时间") + + +class MistakeListResponse(BaseModel): + """错题列表响应""" + items: List[MistakeListItem] = Field(..., description="错题列表") + total: int = Field(..., description="总数") + page: int = Field(..., description="当前页") + size: int = Field(..., description="每页数量") + pages: int = Field(..., description="总页数") + + +class MistakeByCourse(BaseModel): + """按课程统计错题""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + count: int = Field(..., description="错题数量") + + +class MistakeByType(BaseModel): + """按题型统计错题""" + type: str = Field(..., description="题型代码") + type_name: str = Field(..., description="题型名称") + count: int = Field(..., description="错题数量") + + +class MistakeByTime(BaseModel): + """按时间统计错题""" + week: int = Field(..., description="最近一周") + month: int = Field(..., description="最近一月") + quarter: int = Field(..., description="最近三月") + + +class MistakesStatisticsResponse(BaseModel): + """错题统计响应""" + total: int = Field(..., description="错题总数") + by_course: List[MistakeByCourse] = Field(..., description="按课程统计") + by_type: List[MistakeByType] = Field(..., description="按题型统计") + by_time: MistakeByTime = Field(..., description="按时间统计") + + +class UpdateRoundScoreRequest(BaseModel): + """更新轮次得分请求""" + round: int = Field(..., ge=1, le=3, description="轮次(1/2/3)") + score: float = Field(..., ge=0, le=100, description="得分") + is_final: bool = Field(False, description="是否为最终轮次(如果是,则同时更新总分和状态)") diff --git a/backend/app/schemas/notification.py b/backend/app/schemas/notification.py new file mode 100644 index 0000000..9a59d58 --- /dev/null +++ b/backend/app/schemas/notification.py @@ -0,0 +1,102 @@ +""" +站内消息通知相关的数据验证模型 +""" +from typing import Optional, List +from datetime import datetime +from enum import Enum + +from pydantic import BaseModel, Field, ConfigDict + + +class NotificationType(str, Enum): + """通知类型枚举""" + POSITION_ASSIGN = "position_assign" # 岗位分配 + COURSE_ASSIGN = "course_assign" # 课程分配 + EXAM_REMIND = "exam_remind" # 考试提醒 + TASK_ASSIGN = "task_assign" # 任务分配 + SYSTEM = "system" # 系统通知 + + +class NotificationBase(BaseModel): + """ + 通知基础模型 + """ + title: str = Field(..., min_length=1, max_length=200, description="通知标题") + content: Optional[str] = Field(None, description="通知内容") + type: NotificationType = Field(default=NotificationType.SYSTEM, description="通知类型") + related_id: Optional[int] = Field(None, description="关联数据ID") + related_type: Optional[str] = Field(None, max_length=50, description="关联数据类型") + + +class NotificationCreate(NotificationBase): + """ + 创建通知模型 + """ + user_id: int = Field(..., description="接收用户ID") + sender_id: Optional[int] = Field(None, description="发送者用户ID") + + +class NotificationBatchCreate(BaseModel): + """ + 批量创建通知模型(发送给多个用户) + """ + user_ids: List[int] = Field(..., min_length=1, description="接收用户ID列表") + title: str = Field(..., min_length=1, max_length=200, description="通知标题") + content: Optional[str] = Field(None, description="通知内容") + type: NotificationType = Field(default=NotificationType.SYSTEM, description="通知类型") + related_id: Optional[int] = Field(None, description="关联数据ID") + related_type: Optional[str] = Field(None, max_length=50, description="关联数据类型") + sender_id: Optional[int] = Field(None, description="发送者用户ID") + + +class NotificationUpdate(BaseModel): + """ + 更新通知模型 + """ + is_read: Optional[bool] = Field(None, description="是否已读") + + +class NotificationInDB(NotificationBase): + """ + 数据库中的通知模型 + """ + model_config = ConfigDict(from_attributes=True) + + id: int + user_id: int + is_read: bool + sender_id: Optional[int] = None + created_at: datetime + updated_at: datetime + + +class NotificationResponse(NotificationInDB): + """ + 通知响应模型(可扩展发送者信息) + """ + sender_name: Optional[str] = Field(None, description="发送者姓名") + + +class NotificationListResponse(BaseModel): + """ + 通知列表响应模型 + """ + items: List[NotificationResponse] + total: int + unread_count: int + + +class NotificationCountResponse(BaseModel): + """ + 未读通知数量响应模型 + """ + unread_count: int + total: int + + +class MarkReadRequest(BaseModel): + """ + 标记已读请求模型 + """ + notification_ids: Optional[List[int]] = Field(None, description="通知ID列表,为空则标记全部已读") + diff --git a/backend/app/schemas/practice.py b/backend/app/schemas/practice.py new file mode 100644 index 0000000..ec13f61 --- /dev/null +++ b/backend/app/schemas/practice.py @@ -0,0 +1,318 @@ +""" +陪练功能相关Schema定义 +""" +from typing import Optional, List +from datetime import datetime +from pydantic import BaseModel, Field, field_validator + + +# ==================== 枚举类型 ==================== + +class SceneType: + """场景类型枚举""" + PHONE = "phone" # 电话销售 + FACE = "face" # 面对面销售 + COMPLAINT = "complaint" # 客户投诉 + AFTER_SALES = "after-sales" # 售后服务 + PRODUCT_INTRO = "product-intro" # 产品介绍 + + +class Difficulty: + """难度等级枚举""" + BEGINNER = "beginner" # 入门 + JUNIOR = "junior" # 初级 + INTERMEDIATE = "intermediate" # 中级 + SENIOR = "senior" # 高级 + EXPERT = "expert" # 专家 + + +class SceneStatus: + """场景状态枚举""" + ACTIVE = "active" # 启用 + INACTIVE = "inactive" # 禁用 + + +# ==================== 场景Schema ==================== + +class PracticeSceneBase(BaseModel): + """陪练场景基础Schema""" + name: str = Field(..., max_length=200, description="场景名称") + description: Optional[str] = Field(None, description="场景描述") + type: str = Field(..., description="场景类型: phone/face/complaint/after-sales/product-intro") + difficulty: str = Field(..., description="难度等级: beginner/junior/intermediate/senior/expert") + status: str = Field(default="active", description="状态: active/inactive") + background: str = Field(..., description="场景背景设定") + ai_role: str = Field(..., description="AI角色描述") + objectives: List[str] = Field(..., description="练习目标数组") + keywords: Optional[List[str]] = Field(default=None, description="关键词数组") + duration: int = Field(default=10, ge=1, le=120, description="预计时长(分钟)") + + @field_validator('type') + @classmethod + def validate_type(cls, v): + """验证场景类型""" + valid_types = ['phone', 'face', 'complaint', 'after-sales', 'product-intro'] + if v not in valid_types: + raise ValueError(f"场景类型必须是: {', '.join(valid_types)}") + return v + + @field_validator('difficulty') + @classmethod + def validate_difficulty(cls, v): + """验证难度等级""" + valid_difficulties = ['beginner', 'junior', 'intermediate', 'senior', 'expert'] + if v not in valid_difficulties: + raise ValueError(f"难度等级必须是: {', '.join(valid_difficulties)}") + return v + + @field_validator('status') + @classmethod + def validate_status(cls, v): + """验证状态""" + valid_statuses = ['active', 'inactive'] + if v not in valid_statuses: + raise ValueError(f"状态必须是: {', '.join(valid_statuses)}") + return v + + @field_validator('objectives') + @classmethod + def validate_objectives(cls, v): + """验证练习目标""" + if not v or len(v) < 1: + raise ValueError("至少需要1个练习目标") + if len(v) > 10: + raise ValueError("练习目标不能超过10个") + return v + + +class PracticeSceneCreate(PracticeSceneBase): + """创建陪练场景Schema""" + pass + + +class PracticeSceneUpdate(BaseModel): + """更新陪练场景Schema(所有字段可选)""" + name: Optional[str] = Field(None, max_length=200, description="场景名称") + description: Optional[str] = Field(None, description="场景描述") + type: Optional[str] = Field(None, description="场景类型") + difficulty: Optional[str] = Field(None, description="难度等级") + status: Optional[str] = Field(None, description="状态") + background: Optional[str] = Field(None, description="场景背景设定") + ai_role: Optional[str] = Field(None, description="AI角色描述") + objectives: Optional[List[str]] = Field(None, description="练习目标数组") + keywords: Optional[List[str]] = Field(None, description="关键词数组") + duration: Optional[int] = Field(None, ge=1, le=120, description="预计时长(分钟)") + + +class PracticeSceneResponse(PracticeSceneBase): + """陪练场景响应Schema""" + id: int + usage_count: int + rating: float + created_by: Optional[int] = None + updated_by: Optional[int] = None + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +# ==================== 对话Schema ==================== + +class StartPracticeRequest(BaseModel): + """开始陪练对话请求Schema""" + # 场景信息(首次消息必填,后续消息可选) + scene_id: Optional[int] = Field(None, description="场景ID(可选)") + scene_name: Optional[str] = Field(None, description="场景名称") + scene_description: Optional[str] = Field(None, description="场景描述") + scene_background: Optional[str] = Field(None, description="场景背景") + scene_ai_role: Optional[str] = Field(None, description="AI角色") + scene_objectives: Optional[List[str]] = Field(None, description="练习目标") + scene_keywords: Optional[List[str]] = Field(None, description="关键词") + + # 对话信息 + user_message: str = Field(..., description="用户消息") + conversation_id: Optional[str] = Field(None, description="对话ID(续接对话时必填)") + is_first: bool = Field(..., description="是否首次消息") + + @field_validator('scene_name') + @classmethod + def validate_scene_name_for_first(cls, v, info): + """首次消息时场景名称必填""" + if info.data.get('is_first') and not v: + raise ValueError("首次消息时场景名称必填") + return v + + @field_validator('scene_background') + @classmethod + def validate_scene_background_for_first(cls, v, info): + """首次消息时场景背景必填""" + if info.data.get('is_first') and not v: + raise ValueError("首次消息时场景背景必填") + return v + + @field_validator('scene_ai_role') + @classmethod + def validate_scene_ai_role_for_first(cls, v, info): + """首次消息时AI角色必填""" + if info.data.get('is_first') and not v: + raise ValueError("首次消息时AI角色必填") + return v + + @field_validator('scene_objectives') + @classmethod + def validate_scene_objectives_for_first(cls, v, info): + """首次消息时练习目标必填""" + if info.data.get('is_first') and (not v or len(v) == 0): + raise ValueError("首次消息时练习目标必填") + return v + + +class InterruptPracticeRequest(BaseModel): + """中断对话请求Schema""" + conversation_id: str = Field(..., description="对话ID") + chat_id: str = Field(..., description="聊天ID") + + +class ConversationInfo(BaseModel): + """对话信息Schema""" + id: str = Field(..., description="对话ID") + name: str = Field(..., description="对话名称") + created_at: int = Field(..., description="创建时间(时间戳)") + + +class ConversationsResponse(BaseModel): + """对话列表响应Schema""" + items: List[ConversationInfo] + has_more: bool + page: int + size: int + + +# ==================== 场景提取Schema ==================== + +class ExtractSceneRequest(BaseModel): + """提取场景请求Schema""" + course_id: int = Field(..., description="课程ID") + + +class ExtractedSceneData(BaseModel): + """提取的场景数据Schema""" + name: str = Field(..., description="场景名称") + description: str = Field(..., description="场景描述") + type: str = Field(..., description="场景类型") + difficulty: str = Field(..., description="难度等级") + background: str = Field(..., description="场景背景") + ai_role: str = Field(..., description="AI角色描述") + objectives: List[str] = Field(..., description="练习目标数组") + keywords: Optional[List[str]] = Field(default=[], description="关键词数组") + + +class ExtractSceneResponse(BaseModel): + """提取场景响应Schema""" + scene: ExtractedSceneData = Field(..., description="场景数据") + workflow_run_id: str = Field(..., description="工作流运行ID") + task_id: str = Field(..., description="任务ID") + + +# ==================== 陪练会话Schema ==================== + +class PracticeSessionCreate(BaseModel): + """创建陪练会话请求Schema""" + scene_id: Optional[int] = Field(None, description="场景ID") + scene_name: str = Field(..., description="场景名称") + scene_type: Optional[str] = Field(None, description="场景类型") + conversation_id: Optional[str] = Field(None, description="Coze对话ID") + + +class PracticeSessionResponse(BaseModel): + """陪练会话响应Schema""" + id: int + session_id: str + user_id: int + scene_id: Optional[int] + scene_name: str + scene_type: Optional[str] + conversation_id: Optional[str] + start_time: datetime + end_time: Optional[datetime] + duration_seconds: int + turns: int + status: str + created_at: datetime + + class Config: + from_attributes = True + + +class SaveDialogueRequest(BaseModel): + """保存对话记录请求Schema""" + session_id: str = Field(..., description="会话ID") + speaker: str = Field(..., description="说话人: user/ai") + content: str = Field(..., description="对话内容") + sequence: int = Field(..., ge=1, description="顺序号(从1开始)") + + +class PracticeDialogueResponse(BaseModel): + """对话记录响应Schema""" + id: int + session_id: str + speaker: str + content: str + timestamp: datetime + sequence: int + + class Config: + from_attributes = True + + +# ==================== 分析报告Schema ==================== + +class ScoreBreakdownItem(BaseModel): + """分数细分项""" + name: str + score: int = Field(..., ge=0, le=100) + description: str + + +class AbilityDimensionItem(BaseModel): + """能力维度项""" + name: str + score: int = Field(..., ge=0, le=100) + feedback: str + + +class DialogueReviewItem(BaseModel): + """对话复盘项""" + speaker: str + time: str + content: str + tags: List[str] = Field(default_factory=list) + comment: str = Field(default="") + + +class SuggestionItem(BaseModel): + """改进建议项""" + title: str + content: str + example: Optional[str] = None + + +class PracticeAnalysisResult(BaseModel): + """陪练分析结果Schema""" + total_score: int = Field(..., ge=0, le=100, description="综合得分") + score_breakdown: List[ScoreBreakdownItem] = Field(..., description="分数细分") + ability_dimensions: List[AbilityDimensionItem] = Field(..., description="能力维度") + dialogue_review: List[DialogueReviewItem] = Field(..., description="对话复盘") + suggestions: List[SuggestionItem] = Field(..., description="改进建议") + + +class PracticeReportResponse(BaseModel): + """陪练报告响应Schema""" + session_info: PracticeSessionResponse + analysis: PracticeAnalysisResult + + class Config: + from_attributes = True + diff --git a/backend/app/schemas/scrm.py b/backend/app/schemas/scrm.py new file mode 100644 index 0000000..cf86661 --- /dev/null +++ b/backend/app/schemas/scrm.py @@ -0,0 +1,128 @@ +""" +SCRM 系统对接 API Schema 定义 + +用于 SCRM 系统调用考陪练系统的数据查询接口 +""" + +from typing import List, Optional +from pydantic import BaseModel, Field +from datetime import datetime + + +# ==================== 通用响应 ==================== + +class SCRMBaseResponse(BaseModel): + """SCRM API 通用响应基类""" + code: int = Field(default=0, description="响应码,0=成功") + message: str = Field(default="success", description="响应消息") + + +# ==================== 1. 获取员工岗位 ==================== + +class PositionInfo(BaseModel): + """岗位信息""" + position_id: int = Field(..., description="岗位ID") + position_name: str = Field(..., description="岗位名称") + is_primary: bool = Field(default=True, description="是否主岗位") + joined_at: Optional[str] = Field(None, description="加入时间") + + +class EmployeePositionData(BaseModel): + """员工岗位数据""" + employee_id: int = Field(..., description="员工ID") + userid: Optional[str] = Field(None, description="企微员工userid(可能为空)") + name: str = Field(..., description="员工姓名") + positions: List[PositionInfo] = Field(default=[], description="岗位列表") + + +class EmployeePositionResponse(SCRMBaseResponse): + """获取员工岗位响应""" + data: Optional[EmployeePositionData] = None + + +# ==================== 2. 获取岗位课程 ==================== + +class CourseInfo(BaseModel): + """课程信息""" + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + course_type: str = Field(..., description="课程类型:required/optional") + priority: int = Field(default=0, description="优先级") + knowledge_point_count: int = Field(default=0, description="知识点数量") + + +class PositionCoursesData(BaseModel): + """岗位课程数据""" + position_id: int = Field(..., description="岗位ID") + position_name: str = Field(..., description="岗位名称") + courses: List[CourseInfo] = Field(default=[], description="课程列表") + + +class PositionCoursesResponse(SCRMBaseResponse): + """获取岗位课程响应""" + data: Optional[PositionCoursesData] = None + + +# ==================== 3. 搜索知识点 ==================== + +class KnowledgePointSearchRequest(BaseModel): + """搜索知识点请求""" + keywords: List[str] = Field(..., min_length=1, description="搜索关键词列表") + position_id: Optional[int] = Field(None, description="岗位ID(用于优先排序)") + course_ids: Optional[List[int]] = Field(None, description="限定课程范围") + knowledge_type: Optional[str] = Field(None, description="知识点类型筛选") + limit: int = Field(default=10, ge=1, le=100, description="返回数量") + + +class KnowledgePointBrief(BaseModel): + """知识点简要信息""" + knowledge_point_id: int = Field(..., description="知识点ID") + name: str = Field(..., description="知识点名称") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + type: str = Field(..., description="知识点类型") + relevance_score: float = Field(default=1.0, description="相关度分数") + + +class KnowledgePointSearchData(BaseModel): + """知识点搜索结果数据""" + total: int = Field(..., description="匹配总数") + items: List[KnowledgePointBrief] = Field(default=[], description="知识点列表") + + +class KnowledgePointSearchResponse(SCRMBaseResponse): + """搜索知识点响应""" + data: Optional[KnowledgePointSearchData] = None + + +# ==================== 4. 获取知识点详情 ==================== + +class KnowledgePointDetailData(BaseModel): + """知识点详情数据""" + knowledge_point_id: int = Field(..., description="知识点ID") + name: str = Field(..., description="知识点名称") + course_id: int = Field(..., description="课程ID") + course_name: str = Field(..., description="课程名称") + type: str = Field(..., description="知识点类型") + content: str = Field(..., description="知识点完整内容(description)") + material_id: Optional[int] = Field(None, description="关联的课程资料ID") + material_type: Optional[str] = Field(None, description="资料文件类型") + material_url: Optional[str] = Field(None, description="资料文件URL") + topic_relation: Optional[str] = Field(None, description="与主题的关系描述") + source: int = Field(default=0, description="来源:0=手动创建,1=AI分析生成") + created_at: Optional[str] = Field(None, description="创建时间") + + +class KnowledgePointDetailResponse(SCRMBaseResponse): + """获取知识点详情响应""" + data: Optional[KnowledgePointDetailData] = None + + +# ==================== 错误响应 ==================== + +class SCRMErrorResponse(SCRMBaseResponse): + """错误响应""" + code: int = Field(..., description="错误码") + message: str = Field(..., description="错误消息") + data: None = None + diff --git a/backend/app/schemas/system_log.py b/backend/app/schemas/system_log.py new file mode 100644 index 0000000..7c742e6 --- /dev/null +++ b/backend/app/schemas/system_log.py @@ -0,0 +1,59 @@ +""" +系统日志 Schema +""" +from datetime import datetime +from typing import Optional +from pydantic import BaseModel, Field + + +class SystemLogBase(BaseModel): + """系统日志基础Schema""" + level: str = Field(..., description="日志级别: debug, info, warning, error") + type: str = Field(..., description="日志类型: system, user, api, error, security") + user: Optional[str] = Field(None, description="操作用户") + user_id: Optional[int] = Field(None, description="用户ID") + ip: Optional[str] = Field(None, description="IP地址") + message: str = Field(..., description="日志消息") + user_agent: Optional[str] = Field(None, description="User Agent") + path: Optional[str] = Field(None, description="请求路径") + method: Optional[str] = Field(None, description="请求方法") + extra_data: Optional[str] = Field(None, description="额外数据(JSON格式)") + + +class SystemLogCreate(SystemLogBase): + """创建系统日志Schema""" + pass + + +class SystemLogResponse(SystemLogBase): + """系统日志响应Schema""" + id: int + created_at: datetime + updated_at: datetime + + class Config: + from_attributes = True + + +class SystemLogQuery(BaseModel): + """系统日志查询参数""" + level: Optional[str] = Field(None, description="日志级别筛选") + type: Optional[str] = Field(None, description="日志类型筛选") + user: Optional[str] = Field(None, description="用户筛选") + keyword: Optional[str] = Field(None, description="关键词搜索(搜索message字段)") + start_date: Optional[datetime] = Field(None, description="开始日期") + end_date: Optional[datetime] = Field(None, description="结束日期") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +class SystemLogListResponse(BaseModel): + """系统日志列表响应""" + items: list[SystemLogResponse] + total: int + page: int + page_size: int + total_pages: int + + + diff --git a/backend/app/schemas/task.py b/backend/app/schemas/task.py new file mode 100644 index 0000000..b3e6267 --- /dev/null +++ b/backend/app/schemas/task.py @@ -0,0 +1,67 @@ +""" +任务相关Schema +""" +from datetime import datetime +from typing import Optional, List +from pydantic import BaseModel, Field + + +class TaskBase(BaseModel): + """任务基础Schema""" + title: str = Field(..., description="任务标题") + description: Optional[str] = Field(None, description="任务描述") + priority: str = Field("medium", description="优先级(low/medium/high)") + deadline: Optional[datetime] = Field(None, description="截止时间") + requirements: Optional[dict] = Field(None, description="任务要求配置") + course_ids: List[int] = Field(default_factory=list, description="关联课程ID列表") + user_ids: List[int] = Field(default_factory=list, description="分配用户ID列表") + + +class TaskCreate(TaskBase): + """创建任务""" + pass + + +class TaskUpdate(BaseModel): + """更新任务""" + title: Optional[str] = None + description: Optional[str] = None + priority: Optional[str] = None + status: Optional[str] = None + deadline: Optional[datetime] = None + requirements: Optional[dict] = None + progress: Optional[int] = None + + +class TaskResponse(BaseModel): + """任务响应""" + id: int + title: str + description: Optional[str] + priority: str + status: str + creator_id: int + deadline: Optional[datetime] + requirements: Optional[dict] + progress: int + created_at: datetime + updated_at: datetime + # 扩展字段 + courses: List[str] = Field(default_factory=list, description="课程名称列表") + assigned_count: int = Field(0, description="分配人数") + completed_count: int = Field(0, description="完成人数") + + class Config: + from_attributes = True + + +class TaskStatsResponse(BaseModel): + """任务统计响应""" + total: int = Field(0, description="总任务数") + ongoing: int = Field(0, description="进行中") + completed: int = Field(0, description="已完成") + expired: int = Field(0, description="已过期") + avg_completion_rate: float = Field(0.0, description="平均完成率") + + + diff --git a/backend/app/schemas/training.py b/backend/app/schemas/training.py new file mode 100644 index 0000000..1c449e5 --- /dev/null +++ b/backend/app/schemas/training.py @@ -0,0 +1,260 @@ +"""陪练模块Pydantic模式""" +from typing import Optional, List, Dict, Any, Generic, TypeVar +from datetime import datetime +from pydantic import BaseModel, Field, ConfigDict + +# 定义泛型类型变量 +DataT = TypeVar("DataT") + +from app.models.training import ( + TrainingSceneStatus, + TrainingSessionStatus, + MessageType, + MessageRole, +) +from app.schemas.base import BaseSchema, TimestampMixin, IDMixin + + +# ========== 陪练场景相关 ========== + + +class TrainingSceneBase(BaseSchema): + """陪练场景基础模式""" + + name: str = Field(..., max_length=100, description="场景名称") + description: Optional[str] = Field(None, description="场景描述") + category: str = Field(..., max_length=50, description="场景分类") + ai_config: Optional[Dict[str, Any]] = Field(None, description="AI配置") + prompt_template: Optional[str] = Field(None, description="提示词模板") + evaluation_criteria: Optional[Dict[str, Any]] = Field(None, description="评估标准") + is_public: bool = Field(True, description="是否公开") + required_level: Optional[int] = Field(None, description="所需用户等级") + + +class TrainingSceneCreate(TrainingSceneBase): + """创建陪练场景模式""" + + status: TrainingSceneStatus = Field( + default=TrainingSceneStatus.DRAFT, description="场景状态" + ) + + +class TrainingSceneUpdate(BaseSchema): + """更新陪练场景模式""" + + name: Optional[str] = Field(None, max_length=100) + description: Optional[str] = None + category: Optional[str] = Field(None, max_length=50) + ai_config: Optional[Dict[str, Any]] = None + prompt_template: Optional[str] = None + evaluation_criteria: Optional[Dict[str, Any]] = None + status: Optional[TrainingSceneStatus] = None + is_public: Optional[bool] = None + required_level: Optional[int] = None + + +class TrainingSceneInDB(TrainingSceneBase, IDMixin, TimestampMixin): + """数据库中的陪练场景模式""" + + status: TrainingSceneStatus + is_deleted: bool = False + created_by: Optional[int] = None + updated_by: Optional[int] = None + + +class TrainingSceneResponse(TrainingSceneInDB): + """陪练场景响应模式""" + + pass + + +# ========== 陪练会话相关 ========== + + +class TrainingSessionBase(BaseSchema): + """陪练会话基础模式""" + + scene_id: int = Field(..., description="场景ID") + session_config: Optional[Dict[str, Any]] = Field(None, description="会话配置") + + +class TrainingSessionCreate(TrainingSessionBase): + """创建陪练会话模式""" + + pass + + +class TrainingSessionUpdate(BaseSchema): + """更新陪练会话模式""" + + status: Optional[TrainingSessionStatus] = None + end_time: Optional[datetime] = None + duration_seconds: Optional[int] = None + total_score: Optional[float] = None + evaluation_result: Optional[Dict[str, Any]] = None + + +class TrainingSessionInDB(TrainingSessionBase, IDMixin, TimestampMixin): + """数据库中的陪练会话模式""" + + user_id: int + coze_conversation_id: Optional[str] = None + start_time: datetime + end_time: Optional[datetime] = None + duration_seconds: Optional[int] = None + status: TrainingSessionStatus + total_score: Optional[float] = None + evaluation_result: Optional[Dict[str, Any]] = None + created_by: Optional[int] = None + updated_by: Optional[int] = None + + +class TrainingSessionResponse(TrainingSessionInDB): + """陪练会话响应模式""" + + scene: Optional["TrainingSceneResponse"] = None + message_count: Optional[int] = Field(None, description="消息数量") + + +# ========== 消息相关 ========== + + +class TrainingMessageBase(BaseSchema): + """陪练消息基础模式""" + + role: MessageRole = Field(..., description="消息角色") + type: MessageType = Field(..., description="消息类型") + content: str = Field(..., description="消息内容") + voice_url: Optional[str] = Field(None, max_length=500, description="语音文件URL") + voice_duration: Optional[float] = Field(None, description="语音时长(秒)") + metadata: Optional[Dict[str, Any]] = Field(None, description="消息元数据") + + +class TrainingMessageCreate(TrainingMessageBase): + """创建陪练消息模式""" + + session_id: int = Field(..., description="会话ID") + coze_message_id: Optional[str] = Field(None, max_length=100, description="Coze消息ID") + + +class TrainingMessageInDB(TrainingMessageBase, IDMixin, TimestampMixin): + """数据库中的陪练消息模式""" + + session_id: int + coze_message_id: Optional[str] = None + + +class TrainingMessageResponse(TrainingMessageInDB): + """陪练消息响应模式""" + + pass + + +# ========== 报告相关 ========== + + +class TrainingReportBase(BaseSchema): + """陪练报告基础模式""" + + overall_score: float = Field(..., ge=0, le=100, description="总体得分") + dimension_scores: Dict[str, float] = Field(..., description="各维度得分") + strengths: List[str] = Field(..., description="优势点") + weaknesses: List[str] = Field(..., description="待改进点") + suggestions: List[str] = Field(..., description="改进建议") + detailed_analysis: Optional[str] = Field(None, description="详细分析") + transcript: Optional[str] = Field(None, description="对话文本记录") + statistics: Optional[Dict[str, Any]] = Field(None, description="统计数据") + + +class TrainingReportCreate(TrainingReportBase): + """创建陪练报告模式""" + + session_id: int = Field(..., description="会话ID") + user_id: int = Field(..., description="用户ID") + + +class TrainingReportInDB(TrainingReportBase, IDMixin, TimestampMixin): + """数据库中的陪练报告模式""" + + session_id: int + user_id: int + created_by: Optional[int] = None + updated_by: Optional[int] = None + + +class TrainingReportResponse(TrainingReportInDB): + """陪练报告响应模式""" + + session: Optional[TrainingSessionResponse] = None + + +# ========== 会话操作相关 ========== + + +class StartTrainingRequest(BaseSchema): + """开始陪练请求""" + + scene_id: int = Field(..., description="场景ID") + config: Optional[Dict[str, Any]] = Field(None, description="会话配置") + + +class StartTrainingResponse(BaseSchema): + """开始陪练响应""" + + session_id: int = Field(..., description="会话ID") + coze_conversation_id: Optional[str] = Field(None, description="Coze会话ID") + scene: TrainingSceneResponse = Field(..., description="场景信息") + websocket_url: Optional[str] = Field(None, description="WebSocket连接URL") + + +class EndTrainingRequest(BaseSchema): + """结束陪练请求""" + + generate_report: bool = Field(True, description="是否生成报告") + + +class EndTrainingResponse(BaseSchema): + """结束陪练响应""" + + session: TrainingSessionResponse = Field(..., description="会话信息") + report: Optional[TrainingReportResponse] = Field(None, description="陪练报告") + + +# ========== 列表查询相关 ========== + + +class TrainingSceneListQuery(BaseSchema): + """陪练场景列表查询参数""" + + category: Optional[str] = Field(None, description="场景分类") + status: Optional[TrainingSceneStatus] = Field(None, description="场景状态") + is_public: Optional[bool] = Field(None, description="是否公开") + search: Optional[str] = Field(None, description="搜索关键词") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +class TrainingSessionListQuery(BaseSchema): + """陪练会话列表查询参数""" + + scene_id: Optional[int] = Field(None, description="场景ID") + status: Optional[TrainingSessionStatus] = Field(None, description="会话状态") + start_date: Optional[datetime] = Field(None, description="开始日期") + end_date: Optional[datetime] = Field(None, description="结束日期") + page: int = Field(1, ge=1, description="页码") + page_size: int = Field(20, ge=1, le=100, description="每页数量") + + +class PaginatedResponse(BaseModel, Generic[DataT]): + """分页响应模式""" + + items: List[DataT] = Field(..., description="数据列表") + total: int = Field(..., description="总数量") + page: int = Field(..., description="当前页码") + page_size: int = Field(..., description="每页数量") + pages: int = Field(..., description="总页数") + + +# 更新前向引用 +TrainingSessionResponse.model_rebuild() +TrainingReportResponse.model_rebuild() diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py new file mode 100644 index 0000000..3dd3315 --- /dev/null +++ b/backend/app/schemas/user.py @@ -0,0 +1,154 @@ +""" +用户相关 Schema +""" + +from datetime import datetime +from typing import List, Optional + +from pydantic import EmailStr, Field, field_validator + +from .base import BaseSchema + + +class UserBase(BaseSchema): + """用户基础信息""" + + username: str = Field(..., min_length=3, max_length=50) + email: Optional[EmailStr] = None + phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$") + full_name: Optional[str] = Field(None, max_length=100) + avatar_url: Optional[str] = None + bio: Optional[str] = None + role: str = Field(default="trainee", pattern="^(admin|manager|trainee)$") + gender: Optional[str] = Field(None, pattern="^(male|female)$") + school: Optional[str] = Field(None, max_length=100) + major: Optional[str] = Field(None, max_length=100) + + +class UserCreate(UserBase): + """创建用户""" + + password: str = Field(..., min_length=6, max_length=100) + + @field_validator("password") + def validate_password(cls, v): + if len(v) < 6: + raise ValueError("密码长度至少为6位") + return v + + +class UserUpdate(BaseSchema): + """更新用户""" + + email: Optional[EmailStr] = None + phone: Optional[str] = Field(None, pattern=r"^1[3-9]\d{9}$") + full_name: Optional[str] = Field(None, max_length=100) + avatar_url: Optional[str] = None + bio: Optional[str] = None + role: Optional[str] = Field(None, pattern="^(admin|manager|trainee)$") + is_active: Optional[bool] = None + gender: Optional[str] = Field(None, pattern="^(male|female)$") + school: Optional[str] = Field(None, max_length=100) + major: Optional[str] = Field(None, max_length=100) + + +class UserPasswordUpdate(BaseSchema): + """更新密码""" + + old_password: str + new_password: str = Field(..., min_length=6, max_length=100) + + +class UserInDBBase(UserBase): + """数据库中的用户基础信息""" + + id: int + is_active: bool + is_verified: bool + created_at: datetime + updated_at: datetime + last_login_at: Optional[datetime] = None + + +class User(UserInDBBase): + """用户信息(不含敏感数据)""" + + teams: List["TeamBasic"] = [] + + +class UserWithPassword(UserInDBBase): + """用户信息(含密码)""" + + hashed_password: str + + +# Team Schemas +class TeamBase(BaseSchema): + """团队基础信息""" + + name: str = Field(..., min_length=2, max_length=100) + code: str = Field(..., min_length=2, max_length=50) + description: Optional[str] = None + team_type: str = Field( + default="department", pattern="^(department|project|study_group)$" + ) + + +class TeamCreate(TeamBase): + """创建团队""" + + leader_id: Optional[int] = None + parent_id: Optional[int] = None + + +class TeamUpdate(BaseSchema): + """更新团队""" + + name: Optional[str] = Field(None, min_length=2, max_length=100) + description: Optional[str] = None + leader_id: Optional[int] = None + is_active: Optional[bool] = None + + +class TeamBasic(BaseSchema): + """团队基本信息""" + + id: int + name: str + code: str + team_type: str + + +class Team(TeamBase): + """团队完整信息""" + + id: int + is_active: bool + leader_id: Optional[int] = None + parent_id: Optional[int] = None + created_at: datetime + updated_at: datetime + member_count: Optional[int] = 0 + + +class TeamWithMembers(Team): + """团队信息(含成员)""" + + members: List[User] = [] + leader: Optional[User] = None + + +# 避免循环引用 +UserBase.model_rebuild() +User.model_rebuild() +Team.model_rebuild() + + +# Filter schemas +class UserFilter(BaseSchema): + """用户筛选条件""" + + role: Optional[str] = Field(None, pattern="^(admin|manager|trainee)$") + is_active: Optional[bool] = None + team_id: Optional[int] = None + keyword: Optional[str] = None # 搜索用户名、邮箱、姓名 diff --git a/backend/app/schemas/yanji.py b/backend/app/schemas/yanji.py new file mode 100644 index 0000000..dba2f43 --- /dev/null +++ b/backend/app/schemas/yanji.py @@ -0,0 +1,61 @@ +""" +言迹智能工牌相关Schema定义 +""" + +from typing import List, Optional + +from pydantic import BaseModel, Field + + +class ConversationMessage(BaseModel): + """单条对话消息""" + + role: str = Field(..., description="角色:consultant=销售人员,customer=客户") + text: str = Field(..., description="对话文本内容") + begin_time: Optional[str] = Field(None, description="开始时间偏移量(毫秒)") + end_time: Optional[str] = Field(None, description="结束时间偏移量(毫秒)") + + +class YanjiConversation(BaseModel): + """完整的对话记录""" + + audio_id: int = Field(..., description="录音ID") + visit_id: str = Field(..., description="来访单ID") + start_time: str = Field(..., description="录音开始时间") + duration: int = Field(..., description="录音时长(毫秒)") + consultant_name: str = Field(..., description="销售人员姓名") + consultant_phone: str = Field(..., description="销售人员手机号") + conversation: List[ConversationMessage] = Field(..., description="对话内容列表") + + +class GetConversationsByVisitIdsRequest(BaseModel): + """根据来访单ID获取对话记录请求""" + + external_visit_ids: List[str] = Field( + ..., + min_length=1, + max_length=10, + description="三方来访单ID列表(最多10个)", + ) + + +class GetConversationsByVisitIdsResponse(BaseModel): + """获取对话记录响应""" + + conversations: List[YanjiConversation] = Field(..., description="对话记录列表") + total: int = Field(..., description="总数量") + + +class GetConversationsRequest(BaseModel): + """获取员工对话记录请求""" + + consultant_phone: str = Field(..., description="员工手机号") + limit: int = Field(default=10, ge=1, le=100, description="获取数量") + + +class GetConversationsResponse(BaseModel): + """获取员工对话记录响应""" + + conversations: List[YanjiConversation] = Field(..., description="对话记录列表") + total: int = Field(..., description="总数量") + diff --git a/backend/app/services/__init__.py b/backend/app/services/__init__.py new file mode 100644 index 0000000..607c22e --- /dev/null +++ b/backend/app/services/__init__.py @@ -0,0 +1 @@ +"""业务逻辑服务包""" diff --git a/backend/app/services/ability_assessment_service.py b/backend/app/services/ability_assessment_service.py new file mode 100644 index 0000000..0bdac52 --- /dev/null +++ b/backend/app/services/ability_assessment_service.py @@ -0,0 +1,272 @@ +""" +能力评估服务 +用于分析用户对话数据,生成能力评估报告和课程推荐 + +使用 Python 原生实现 +""" +import json +import logging +from typing import Dict, Any, List, Literal +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select + +from app.models.ability import AbilityAssessment +from app.services.ai import ability_analysis_service + +logger = logging.getLogger(__name__) + + +class AbilityAssessmentService: + """能力评估服务类""" + + async def analyze_yanji_conversations( + self, + user_id: int, + phone: str, + db: AsyncSession, + yanji_service, + engine: Literal["v2"] = "v2" + ) -> Dict[str, Any]: + """ + 分析言迹对话并生成能力评估及课程推荐 + + Args: + user_id: 用户ID + phone: 用户手机号(用于获取言迹数据) + db: 数据库会话 + yanji_service: 言迹服务实例 + engine: 引擎类型(v2=Python原生) + + Returns: + 评估结果字典,包含: + - assessment_id: 评估记录ID + - total_score: 综合评分 + - dimensions: 能力维度列表 + - recommended_courses: 推荐课程列表 + - conversation_count: 分析的对话数量 + + Raises: + ValueError: 未找到员工的录音记录 + Exception: API调用失败或其他错误 + """ + logger.info(f"开始分析言迹对话: user_id={user_id}, phone={phone}, engine={engine}") + + # 1. 获取员工对话数据(最多10条录音) + conversations = await yanji_service.get_employee_conversations_for_analysis( + phone=phone, + limit=10 + ) + + if not conversations: + logger.warning(f"未找到员工的录音记录: user_id={user_id}, phone={phone}") + raise ValueError("未找到该员工的录音记录") + + # 2. 合并所有对话历史 + all_dialogues = [] + for conv in conversations: + all_dialogues.extend(conv['dialogue_history']) + + logger.info( + f"准备分析: user_id={user_id}, " + f"对话数={len(conversations)}, " + f"总轮次={len(all_dialogues)}" + ) + + used_engine = "v2" + + # Python 原生实现 + logger.info(f"调用原生能力分析服务") + + # 将对话历史格式化为文本 + dialogue_text = self._format_dialogues_for_analysis(all_dialogues) + + # 调用原生服务 + result = await ability_analysis_service.analyze( + db=db, + user_id=user_id, + dialogue_history=dialogue_text + ) + + if not result.success: + raise Exception(f"能力分析失败: {result.error}") + + # 转换为兼容格式 + analysis_result = { + "analysis": { + "total_score": result.total_score, + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in result.ability_dimensions + ], + "course_recommendations": [ + { + "course_id": c.course_id, + "course_name": c.course_name, + "recommendation_reason": c.recommendation_reason, + "priority": c.priority, + "match_score": c.match_score, + } + for c in result.course_recommendations + ] + } + } + + logger.info( + f"能力分析完成 - total_score: {result.total_score}, " + f"provider: {result.ai_provider}, latency: {result.ai_latency_ms}ms" + ) + + # 4. 提取结果 + analysis = analysis_result.get('analysis', {}) + ability_dims = analysis.get('ability_dimensions', []) + course_recs = analysis.get('course_recommendations', []) + total_score = analysis.get('total_score') + + logger.info( + f"分析完成 (engine={used_engine}): total_score={total_score}, " + f"dimensions={len(ability_dims)}, courses={len(course_recs)}" + ) + + # 5. 保存能力评估记录到数据库 + assessment = AbilityAssessment( + user_id=user_id, + source_type='yanji_badge', + source_id=','.join([str(c['audio_id']) for c in conversations]), + total_score=total_score, + ability_dimensions=ability_dims, + recommended_courses=course_recs, + conversation_count=len(conversations) + ) + + db.add(assessment) + await db.commit() + await db.refresh(assessment) + + logger.info( + f"评估记录已保存: assessment_id={assessment.id}, " + f"user_id={user_id}, total_score={total_score}" + ) + + # 6. 返回评估结果 + return { + "assessment_id": assessment.id, + "total_score": total_score, + "dimensions": ability_dims, + "recommended_courses": course_recs, + "conversation_count": len(conversations), + "analyzed_at": assessment.analyzed_at, + "engine": used_engine, + } + + def _format_dialogues_for_analysis(self, dialogues: List[Dict[str, Any]]) -> str: + """ + 将对话历史列表格式化为文本 + + Args: + dialogues: 对话历史列表,每项包含 speaker, content 等字段 + + Returns: + 格式化后的对话文本 + """ + lines = [] + for i, d in enumerate(dialogues, 1): + speaker = d.get('speaker', 'unknown') + content = d.get('content', '') + + # 统一说话者标识 + if speaker in ['consultant', 'employee', 'user', '员工']: + speaker_label = '员工' + elif speaker in ['customer', 'client', '顾客', '客户']: + speaker_label = '顾客' + else: + speaker_label = speaker + + lines.append(f"[{i}] {speaker_label}: {content}") + + return '\n'.join(lines) + + async def get_user_assessment_history( + self, + user_id: int, + db: AsyncSession, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + 获取用户的能力评估历史记录 + + Args: + user_id: 用户ID + db: 数据库会话 + limit: 返回记录数量限制 + + Returns: + 评估历史记录列表 + """ + stmt = ( + select(AbilityAssessment) + .where(AbilityAssessment.user_id == user_id) + .order_by(AbilityAssessment.analyzed_at.desc()) + .limit(limit) + ) + + result = await db.execute(stmt) + assessments = result.scalars().all() + + history = [] + for assessment in assessments: + history.append({ + "id": assessment.id, + "source_type": assessment.source_type, + "total_score": assessment.total_score, + "ability_dimensions": assessment.ability_dimensions, + "recommended_courses": assessment.recommended_courses, + "conversation_count": assessment.conversation_count, + "analyzed_at": assessment.analyzed_at.isoformat() if assessment.analyzed_at else None, + "created_at": assessment.created_at.isoformat() if assessment.created_at else None + }) + + logger.info(f"获取评估历史: user_id={user_id}, count={len(history)}") + return history + + async def get_assessment_detail( + self, + assessment_id: int, + db: AsyncSession + ) -> Dict[str, Any]: + """ + 获取单个评估记录的详细信息 + + Args: + assessment_id: 评估记录ID + db: 数据库会话 + + Returns: + 评估详细信息 + + Raises: + ValueError: 评估记录不存在 + """ + stmt = select(AbilityAssessment).where(AbilityAssessment.id == assessment_id) + result = await db.execute(stmt) + assessment = result.scalar_one_or_none() + + if not assessment: + raise ValueError(f"评估记录不存在: assessment_id={assessment_id}") + + return { + "id": assessment.id, + "user_id": assessment.user_id, + "source_type": assessment.source_type, + "source_id": assessment.source_id, + "total_score": assessment.total_score, + "ability_dimensions": assessment.ability_dimensions, + "recommended_courses": assessment.recommended_courses, + "conversation_count": assessment.conversation_count, + "analyzed_at": assessment.analyzed_at.isoformat() if assessment.analyzed_at else None, + "created_at": assessment.created_at.isoformat() if assessment.created_at else None + } + + +def get_ability_assessment_service() -> AbilityAssessmentService: + """获取能力评估服务实例(依赖注入)""" + return AbilityAssessmentService() diff --git a/backend/app/services/ai/__init__.py b/backend/app/services/ai/__init__.py new file mode 100644 index 0000000..48749e6 --- /dev/null +++ b/backend/app/services/ai/__init__.py @@ -0,0 +1,151 @@ +""" +AI 服务模块 + +包含: +- AIService: 本地 AI 服务(支持 4sapi + OpenRouter 降级) +- LLM JSON Parser: 大模型 JSON 输出解析器 +- KnowledgeAnalysisServiceV2: 知识点分析服务(Python 原生实现) +- ExamGeneratorService: 试题生成服务(Python 原生实现) +- CourseChatServiceV2: 课程对话服务(Python 原生实现) +- PracticeSceneService: 陪练场景准备服务(Python 原生实现) +- AbilityAnalysisService: 智能工牌能力分析服务(Python 原生实现) +- AnswerJudgeService: 答案判断服务(Python 原生实现) +- PracticeAnalysisService: 陪练分析报告服务(Python 原生实现) +""" + +from .ai_service import ( + AIService, + AIResponse, + AIConfig, + AIServiceError, + AIProvider, + DEFAULT_MODEL, + MODEL_ANALYSIS, + MODEL_CREATIVE, + MODEL_IMAGE_GEN, + quick_chat, +) + +from .llm_json_parser import ( + parse_llm_json, + parse_with_fallback, + safe_json_loads, + clean_llm_output, + diagnose_json_error, + validate_json_schema, + ParseResult, + JSONParseError, + JSONUnrecoverableError, +) + +from .knowledge_analysis_v2 import ( + KnowledgeAnalysisServiceV2, + knowledge_analysis_service_v2, +) + +from .exam_generator_service import ( + ExamGeneratorService, + ExamGeneratorConfig, + exam_generator_service, + generate_exam, +) + +from .course_chat_service import ( + CourseChatServiceV2, + course_chat_service_v2, +) + +from .practice_scene_service import ( + PracticeSceneService, + PracticeScene, + PracticeSceneResult, + practice_scene_service, + prepare_practice_knowledge, +) + +from .ability_analysis_service import ( + AbilityAnalysisService, + AbilityAnalysisResult, + AbilityDimension, + CourseRecommendation, + ability_analysis_service, +) + +from .answer_judge_service import ( + AnswerJudgeService, + JudgeResult, + answer_judge_service, + judge_answer, +) + +from .practice_analysis_service import ( + PracticeAnalysisService, + PracticeAnalysisResult, + ScoreBreakdownItem, + AbilityDimensionItem, + DialogueAnnotation, + Suggestion, + practice_analysis_service, + analyze_practice_session, +) + +__all__ = [ + # AI Service + "AIService", + "AIResponse", + "AIConfig", + "AIServiceError", + "AIProvider", + "DEFAULT_MODEL", + "MODEL_ANALYSIS", + "MODEL_CREATIVE", + "MODEL_IMAGE_GEN", + "quick_chat", + # JSON Parser + "parse_llm_json", + "parse_with_fallback", + "safe_json_loads", + "clean_llm_output", + "diagnose_json_error", + "validate_json_schema", + "ParseResult", + "JSONParseError", + "JSONUnrecoverableError", + # Knowledge Analysis V2 + "KnowledgeAnalysisServiceV2", + "knowledge_analysis_service_v2", + # Exam Generator V2 + "ExamGeneratorService", + "ExamGeneratorConfig", + "exam_generator_service", + "generate_exam", + # Course Chat V2 + "CourseChatServiceV2", + "course_chat_service_v2", + # Practice Scene V2 + "PracticeSceneService", + "PracticeScene", + "PracticeSceneResult", + "practice_scene_service", + "prepare_practice_knowledge", + # Ability Analysis V2 + "AbilityAnalysisService", + "AbilityAnalysisResult", + "AbilityDimension", + "CourseRecommendation", + "ability_analysis_service", + # Answer Judge V2 + "AnswerJudgeService", + "JudgeResult", + "answer_judge_service", + "judge_answer", + # Practice Analysis V2 + "PracticeAnalysisService", + "PracticeAnalysisResult", + "ScoreBreakdownItem", + "AbilityDimensionItem", + "DialogueAnnotation", + "Suggestion", + "practice_analysis_service", + "analyze_practice_session", +] diff --git a/backend/app/services/ai/ability_analysis_service.py b/backend/app/services/ai/ability_analysis_service.py new file mode 100644 index 0000000..5d4f234 --- /dev/null +++ b/backend/app/services/ai/ability_analysis_service.py @@ -0,0 +1,479 @@ +""" +智能工牌能力分析与课程推荐服务 - Python 原生实现 + +功能: +- 分析员工与顾客的对话记录 +- 评估多维度能力得分 +- 基于能力短板推荐课程 + +提供稳定可靠的能力分析和课程推荐能力。 +""" + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.ability_analysis_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + ABILITY_ANALYSIS_SCHEMA, + ABILITY_DIMENSIONS, +) + +logger = logging.getLogger(__name__) + + +# ==================== 数据结构 ==================== + +@dataclass +class AbilityDimension: + """能力维度评分""" + name: str + score: float + feedback: str + + +@dataclass +class CourseRecommendation: + """课程推荐""" + course_id: int + course_name: str + recommendation_reason: str + priority: str # high, medium, low + match_score: float + + +@dataclass +class AbilityAnalysisResult: + """能力分析结果""" + success: bool + total_score: float = 0.0 + ability_dimensions: List[AbilityDimension] = field(default_factory=list) + course_recommendations: List[CourseRecommendation] = field(default_factory=list) + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + error: str = "" + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "success": self.success, + "total_score": self.total_score, + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in self.ability_dimensions + ], + "course_recommendations": [ + { + "course_id": c.course_id, + "course_name": c.course_name, + "recommendation_reason": c.recommendation_reason, + "priority": c.priority, + "match_score": c.match_score, + } + for c in self.course_recommendations + ], + "ai_provider": self.ai_provider, + "ai_model": self.ai_model, + "ai_tokens": self.ai_tokens, + "ai_latency_ms": self.ai_latency_ms, + "error": self.error, + } + + +@dataclass +class UserPositionInfo: + """用户岗位信息""" + position_id: int + position_name: str + code: str + description: str + skills: Optional[Dict[str, Any]] + level: str + status: str + + +@dataclass +class CourseInfo: + """课程信息""" + id: int + name: str + description: str + category: str + tags: Optional[List[str]] + difficulty_level: int + duration_hours: float + + +# ==================== 服务类 ==================== + +class AbilityAnalysisService: + """ + 智能工牌能力分析服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = AbilityAnalysisService() + result = await service.analyze( + db=db_session, + user_id=1, + dialogue_history="顾客:你好,我想了解一下你们的服务..." + ) + print(result.total_score) + print(result.course_recommendations) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="ability_analysis") + + async def analyze( + self, + db: AsyncSession, + user_id: int, + dialogue_history: str + ) -> AbilityAnalysisResult: + """ + 分析员工能力并推荐课程 + + Args: + db: 数据库会话(支持多租户,每个租户传入各自的会话) + user_id: 用户ID + dialogue_history: 对话记录 + + Returns: + AbilityAnalysisResult 分析结果 + """ + try: + logger.info(f"开始能力分析 - user_id: {user_id}") + + # 1. 验证输入 + if not dialogue_history or not dialogue_history.strip(): + return AbilityAnalysisResult( + success=False, + error="对话记录不能为空" + ) + + # 2. 查询用户岗位信息 + user_positions = await self._get_user_positions(db, user_id) + user_info_str = self._format_user_info(user_positions) + + logger.info(f"用户岗位信息: {len(user_positions)} 个岗位") + + # 3. 查询所有可选课程 + courses = await self._get_published_courses(db) + courses_str = self._format_courses(courses) + + logger.info(f"可选课程: {len(courses)} 门") + + # 4. 调用 AI 分析 + ai_response = await self._call_ai_analysis( + dialogue_history=dialogue_history, + user_info=user_info_str, + courses=courses_str + ) + + logger.info( + f"AI 分析完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 5. 解析 JSON 结果 + analysis_data = self._parse_analysis_result(ai_response.content, courses) + + # 6. 构建返回结果 + result = AbilityAnalysisResult( + success=True, + total_score=analysis_data.get("total_score", 0), + ability_dimensions=[ + AbilityDimension( + name=d.get("name", ""), + score=d.get("score", 0), + feedback=d.get("feedback", "") + ) + for d in analysis_data.get("ability_dimensions", []) + ], + course_recommendations=[ + CourseRecommendation( + course_id=c.get("course_id", 0), + course_name=c.get("course_name", ""), + recommendation_reason=c.get("recommendation_reason", ""), + priority=c.get("priority", "medium"), + match_score=c.get("match_score", 0) + ) + for c in analysis_data.get("course_recommendations", []) + ], + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + ) + + logger.info( + f"能力分析完成 - user_id: {user_id}, total_score: {result.total_score}, " + f"recommendations: {len(result.course_recommendations)}" + ) + + return result + + except Exception as e: + logger.error( + f"能力分析失败 - user_id: {user_id}, error: {e}", + exc_info=True + ) + return AbilityAnalysisResult( + success=False, + error=str(e) + ) + + async def _get_user_positions( + self, + db: AsyncSession, + user_id: int + ) -> List[UserPositionInfo]: + """ + 查询用户的岗位信息 + + 获取用户基本信息 + """ + query = text(""" + SELECT + p.id as position_id, + p.name as position_name, + p.code, + p.description, + p.skills, + p.level, + p.status + FROM positions p + INNER JOIN position_members pm ON p.id = pm.position_id + WHERE pm.user_id = :user_id + AND pm.is_deleted = 0 + AND p.is_deleted = 0 + """) + + result = await db.execute(query, {"user_id": user_id}) + rows = result.fetchall() + + positions = [] + for row in rows: + # 解析 skills JSON + skills = None + if row.skills: + if isinstance(row.skills, str): + try: + skills = json.loads(row.skills) + except json.JSONDecodeError: + skills = None + else: + skills = row.skills + + positions.append(UserPositionInfo( + position_id=row.position_id, + position_name=row.position_name, + code=row.code or "", + description=row.description or "", + skills=skills, + level=row.level or "", + status=row.status or "" + )) + + return positions + + async def _get_published_courses(self, db: AsyncSession) -> List[CourseInfo]: + """ + 查询所有已发布的课程 + + 获取所有课程列表 + """ + query = text(""" + SELECT + id, + name, + description, + category, + tags, + difficulty_level, + duration_hours + FROM courses + WHERE status = 'published' + AND is_deleted = FALSE + ORDER BY sort_order + """) + + result = await db.execute(query) + rows = result.fetchall() + + courses = [] + for row in rows: + # 解析 tags JSON + tags = None + if row.tags: + if isinstance(row.tags, str): + try: + tags = json.loads(row.tags) + except json.JSONDecodeError: + tags = None + else: + tags = row.tags + + courses.append(CourseInfo( + id=row.id, + name=row.name, + description=row.description or "", + category=row.category or "", + tags=tags, + difficulty_level=row.difficulty_level or 3, + duration_hours=row.duration_hours or 0 + )) + + return courses + + def _format_user_info(self, positions: List[UserPositionInfo]) -> str: + """格式化用户岗位信息为文本""" + if not positions: + return "暂无岗位信息" + + lines = [] + for p in positions: + info = f"- 岗位:{p.position_name}({p.code})" + if p.level: + info += f",级别:{p.level}" + if p.description: + info += f"\n 描述:{p.description}" + if p.skills: + skills_str = json.dumps(p.skills, ensure_ascii=False) + info += f"\n 核心技能:{skills_str}" + lines.append(info) + + return "\n".join(lines) + + def _format_courses(self, courses: List[CourseInfo]) -> str: + """格式化课程列表为文本""" + if not courses: + return "暂无可选课程" + + lines = [] + for c in courses: + info = f"- ID: {c.id}, 课程名称: {c.name}" + if c.category: + info += f", 分类: {c.category}" + if c.difficulty_level: + info += f", 难度: {c.difficulty_level}" + if c.duration_hours: + info += f", 时长: {c.duration_hours}小时" + if c.description: + # 截断过长的描述 + desc = c.description[:100] + "..." if len(c.description) > 100 else c.description + info += f"\n 描述: {desc}" + lines.append(info) + + return "\n".join(lines) + + async def _call_ai_analysis( + self, + dialogue_history: str, + user_info: str, + courses: str + ) -> AIResponse: + """调用 AI 进行能力分析""" + # 构建用户消息 + user_message = USER_PROMPT.format( + dialogue_history=dialogue_history, + user_info=user_info, + courses=courses + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + # 调用 AI(自动支持 4sapi → OpenRouter 降级) + response = await self.ai_service.chat( + messages=messages, + temperature=0.7, # 保持一定创意性 + prompt_name="ability_analysis" + ) + + return response + + def _parse_analysis_result( + self, + ai_output: str, + courses: List[CourseInfo] + ) -> Dict[str, Any]: + """ + 解析 AI 输出的分析结果 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + parsed = parse_with_fallback( + cleaned_output, + schema=ABILITY_ANALYSIS_SCHEMA, + default={"analysis": {}}, + validate_schema=True, + on_error="default" + ) + + # 提取 analysis 部分 + analysis = parsed.get("analysis", {}) + + # 后处理:验证课程推荐的有效性 + valid_course_ids = {c.id for c in courses} + valid_recommendations = [] + + for rec in analysis.get("course_recommendations", []): + course_id = rec.get("course_id") + if course_id in valid_course_ids: + valid_recommendations.append(rec) + else: + logger.warning(f"推荐的课程ID不存在: {course_id}") + + analysis["course_recommendations"] = valid_recommendations + + # 确保能力维度完整 + existing_dims = {d.get("name") for d in analysis.get("ability_dimensions", [])} + for dim_name in ABILITY_DIMENSIONS: + if dim_name not in existing_dims: + logger.warning(f"缺少能力维度: {dim_name},使用默认值") + analysis.setdefault("ability_dimensions", []).append({ + "name": dim_name, + "score": 70, + "feedback": "暂无具体评价" + }) + + return analysis + + +# ==================== 全局实例 ==================== + +ability_analysis_service = AbilityAnalysisService() + + + + + + + + + diff --git a/backend/app/services/ai/ai_service.py b/backend/app/services/ai/ai_service.py new file mode 100644 index 0000000..1213942 --- /dev/null +++ b/backend/app/services/ai/ai_service.py @@ -0,0 +1,747 @@ +""" +本地 AI 服务 - 遵循瑞小美 AI 接入规范 + +功能: +- 支持 4sapi.com(首选)和 OpenRouter(备选)自动降级 +- 统一的请求/响应格式 +- 调用日志记录 +""" + +import json +import logging +import time +from dataclasses import dataclass, field +from typing import Any, AsyncGenerator, Dict, List, Optional, Union +from enum import Enum + +import httpx + +logger = logging.getLogger(__name__) + + +class AIProvider(Enum): + """AI 服务商""" + PRIMARY = "4sapi" # 首选:4sapi.com + FALLBACK = "openrouter" # 备选:OpenRouter + + +@dataclass +class AIResponse: + """AI 响应结果""" + content: str # AI 回复内容 + model: str = "" # 使用的模型 + provider: str = "" # 实际使用的服务商 + input_tokens: int = 0 # 输入 token 数 + output_tokens: int = 0 # 输出 token 数 + total_tokens: int = 0 # 总 token 数 + cost: float = 0.0 # 费用(美元) + latency_ms: int = 0 # 响应延迟(毫秒) + raw_response: Dict[str, Any] = field(default_factory=dict) # 原始响应 + images: List[str] = field(default_factory=list) # 图像生成结果 + annotations: Dict[str, Any] = field(default_factory=dict) # PDF 解析注释 + + +@dataclass +class AIConfig: + """AI 服务配置""" + primary_api_key: str # 通用 Key(Gemini/DeepSeek 等) + anthropic_api_key: str = "" # Claude 专属 Key + primary_base_url: str = "https://4sapi.com/v1" + fallback_api_key: str = "" + fallback_base_url: str = "https://openrouter.ai/api/v1" + default_model: str = "claude-opus-4-5-20251101-thinking" # 默认使用最强模型 + timeout: float = 120.0 + max_retries: int = 2 + + +# Claude 模型列表(需要使用 anthropic_api_key) +CLAUDE_MODELS = [ + "claude-opus-4-5-20251101-thinking", + "claude-opus-4-5-20251101", + "claude-sonnet-4-20250514", + "claude-3-opus", + "claude-3-sonnet", + "claude-3-haiku", +] + + +def is_claude_model(model: str) -> bool: + """判断是否为 Claude 模型""" + model_lower = model.lower() + return any(claude in model_lower for claude in ["claude", "anthropic"]) + + +# 模型名称映射:4sapi -> OpenRouter +MODEL_MAPPING = { + # 4sapi 使用简短名称,OpenRouter 使用完整路径 + "gemini-3-flash-preview": "google/gemini-3-flash-preview", + "gemini-3-pro-preview": "google/gemini-3-pro-preview", + "claude-opus-4-5-20251101-thinking": "anthropic/claude-opus-4.5", + "gemini-2.5-flash-image-preview": "google/gemini-2.0-flash-exp:free", +} + +# 反向映射:OpenRouter -> 4sapi +MODEL_MAPPING_REVERSE = {v: k for k, v in MODEL_MAPPING.items()} + + +class AIServiceError(Exception): + """AI 服务错误""" + def __init__(self, message: str, provider: str = "", status_code: int = 0): + super().__init__(message) + self.provider = provider + self.status_code = status_code + + +class AIService: + """ + 本地 AI 服务 + + 遵循瑞小美 AI 接入规范: + - 首选 4sapi.com,失败自动降级到 OpenRouter + - 统一的响应格式 + - 自动模型名称转换 + + 使用示例: + ```python + ai = AIService(module_code="knowledge_analysis") + response = await ai.chat( + messages=[ + {"role": "system", "content": "你是助手"}, + {"role": "user", "content": "你好"} + ], + prompt_name="greeting" + ) + print(response.content) + ``` + """ + + def __init__( + self, + module_code: str = "default", + config: Optional[AIConfig] = None, + db_session: Any = None + ): + """ + 初始化 AI 服务 + + 配置加载优先级(遵循瑞小美 AI 接入规范): + 1. 显式传入的 config 参数 + 2. 数据库 ai_config 表(推荐) + 3. 环境变量(fallback) + + Args: + module_code: 模块标识,用于统计 + config: AI 配置,None 则从数据库/环境变量读取 + db_session: 数据库会话,用于记录调用日志和读取配置 + """ + self.module_code = module_code + self.db_session = db_session + self.config = config or self._load_config(db_session) + + logger.info(f"AIService 初始化: module={module_code}, primary={self.config.primary_base_url}") + + def _load_config(self, db_session: Any) -> AIConfig: + """ + 加载配置 + + 配置加载优先级(遵循瑞小美 AI 接入规范): + 1. 管理库 tenant_configs 表(推荐,通过 DynamicConfig) + 2. 环境变量(fallback) + + Args: + db_session: 数据库会话(可选,用于日志记录) + + Returns: + AIConfig 配置对象 + """ + # 优先从管理库加载(同步方式) + try: + config = self._load_config_from_admin_db() + if config: + logger.info("✅ AI 配置已从管理库(tenant_configs)加载") + return config + except Exception as e: + logger.debug(f"从管理库加载 AI 配置失败: {e}") + + # Fallback 到环境变量 + logger.info("AI 配置从环境变量加载") + return self._load_config_from_env() + + def _load_config_from_admin_db(self) -> Optional[AIConfig]: + """ + 从管理库 tenant_configs 表加载配置 + + 使用同步方式直接查询 kaopeilian_admin.tenant_configs 表 + + Returns: + AIConfig 配置对象,如果无数据则返回 None + """ + import os + + # 获取当前租户编码 + tenant_code = os.getenv("TENANT_CODE", "demo") + + # 获取管理库连接信息 + admin_db_host = os.getenv("ADMIN_DB_HOST", "prod-mysql") + admin_db_port = int(os.getenv("ADMIN_DB_PORT", "3306")) + admin_db_user = os.getenv("ADMIN_DB_USER", "root") + admin_db_password = os.getenv("ADMIN_DB_PASSWORD", "") + admin_db_name = os.getenv("ADMIN_DB_NAME", "kaopeilian_admin") + + if not admin_db_password: + logger.debug("ADMIN_DB_PASSWORD 未配置,跳过管理库配置加载") + return None + + try: + from sqlalchemy import create_engine, text + import urllib.parse + + # 构建连接 URL + encoded_password = urllib.parse.quote_plus(admin_db_password) + admin_db_url = f"mysql+pymysql://{admin_db_user}:{encoded_password}@{admin_db_host}:{admin_db_port}/{admin_db_name}?charset=utf8mb4" + + engine = create_engine(admin_db_url, pool_pre_ping=True) + + with engine.connect() as conn: + # 1. 获取租户 ID + result = conn.execute( + text("SELECT id FROM tenants WHERE code = :code AND status = 'active'"), + {"code": tenant_code} + ) + row = result.fetchone() + if not row: + logger.debug(f"租户 {tenant_code} 不存在或未激活") + engine.dispose() + return None + + tenant_id = row[0] + + # 2. 获取 AI 配置 + result = conn.execute( + text(""" + SELECT config_key, config_value + FROM tenant_configs + WHERE tenant_id = :tenant_id AND config_group = 'ai' + """), + {"tenant_id": tenant_id} + ) + rows = result.fetchall() + + engine.dispose() + + if not rows: + logger.debug(f"租户 {tenant_code} 无 AI 配置") + return None + + # 转换为字典 + config_dict = {row[0]: row[1] for row in rows} + + # 检查必要的配置是否存在 + primary_key = config_dict.get("AI_PRIMARY_API_KEY", "") + if not primary_key: + logger.warning(f"租户 {tenant_code} 的 AI_PRIMARY_API_KEY 为空") + return None + + logger.info(f"✅ 从管理库加载租户 {tenant_code} 的 AI 配置成功") + + return AIConfig( + primary_api_key=primary_key, + anthropic_api_key=config_dict.get("AI_ANTHROPIC_API_KEY", ""), + primary_base_url=config_dict.get("AI_PRIMARY_BASE_URL", "https://4sapi.com/v1"), + fallback_api_key=config_dict.get("AI_FALLBACK_API_KEY", ""), + fallback_base_url=config_dict.get("AI_FALLBACK_BASE_URL", "https://openrouter.ai/api/v1"), + default_model=config_dict.get("AI_DEFAULT_MODEL", "claude-opus-4-5-20251101-thinking"), + timeout=float(config_dict.get("AI_TIMEOUT", "120")), + ) + except Exception as e: + logger.debug(f"从管理库读取 AI 配置异常: {e}") + return None + + def _load_config_from_env(self) -> AIConfig: + """ + 从环境变量加载配置 + + ⚠️ 强制要求(遵循瑞小美 AI 接入规范): + - 禁止在代码中硬编码 API Key + - 必须通过环境变量配置 Key + + 必须配置的环境变量: + - AI_PRIMARY_API_KEY: 通用 Key(用于 Gemini/DeepSeek 等) + - AI_ANTHROPIC_API_KEY: Claude 专属 Key + """ + import os + + primary_api_key = os.getenv("AI_PRIMARY_API_KEY", "") + anthropic_api_key = os.getenv("AI_ANTHROPIC_API_KEY", "") + + # 检查必要的 Key 是否已配置 + if not primary_api_key: + logger.warning("⚠️ AI_PRIMARY_API_KEY 未配置,AI 服务可能无法正常工作") + if not anthropic_api_key: + logger.warning("⚠️ AI_ANTHROPIC_API_KEY 未配置,Claude 模型调用将失败") + + return AIConfig( + # 通用 Key(Gemini/DeepSeek 等非 Anthropic 模型) + primary_api_key=primary_api_key, + # Claude 专属 Key + anthropic_api_key=anthropic_api_key, + primary_base_url=os.getenv("AI_PRIMARY_BASE_URL", "https://4sapi.com/v1"), + fallback_api_key=os.getenv("AI_FALLBACK_API_KEY", ""), + fallback_base_url=os.getenv("AI_FALLBACK_BASE_URL", "https://openrouter.ai/api/v1"), + # 默认模型:遵循"优先最强"原则,使用 Claude Opus 4.5 + default_model=os.getenv("AI_DEFAULT_MODEL", "claude-opus-4-5-20251101-thinking"), + timeout=float(os.getenv("AI_TIMEOUT", "120")), + ) + + def _convert_model_name(self, model: str, provider: AIProvider) -> str: + """ + 转换模型名称以匹配服务商格式 + + Args: + model: 原始模型名称 + provider: 目标服务商 + + Returns: + 转换后的模型名称 + """ + if provider == AIProvider.FALLBACK: + # 4sapi -> OpenRouter + return MODEL_MAPPING.get(model, f"google/{model}" if "/" not in model else model) + else: + # OpenRouter -> 4sapi + return MODEL_MAPPING_REVERSE.get(model, model.split("/")[-1] if "/" in model else model) + + async def chat( + self, + messages: List[Dict[str, str]], + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: Optional[int] = None, + prompt_name: str = "default", + **kwargs + ) -> AIResponse: + """ + 文本聊天 + + Args: + messages: 消息列表 [{"role": "system/user/assistant", "content": "..."}] + model: 模型名称,None 使用默认模型 + temperature: 温度参数 + max_tokens: 最大输出 token 数 + prompt_name: 提示词名称,用于统计 + **kwargs: 其他参数 + + Returns: + AIResponse 响应对象 + """ + model = model or self.config.default_model + + # 构建请求体 + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + } + if max_tokens: + payload["max_tokens"] = max_tokens + + # 首选服务商 + try: + return await self._call_provider( + provider=AIProvider.PRIMARY, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ) + except AIServiceError as e: + logger.warning(f"首选服务商调用失败: {e}, 尝试降级到备选服务商") + + # 如果没有备选 API Key,直接抛出异常 + if not self.config.fallback_api_key: + raise + + # 降级到备选服务商 + # 转换模型名称 + fallback_model = self._convert_model_name(model, AIProvider.FALLBACK) + payload["model"] = fallback_model + + return await self._call_provider( + provider=AIProvider.FALLBACK, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ) + + async def chat_stream( + self, + messages: List[Dict[str, str]], + model: Optional[str] = None, + temperature: float = 0.7, + max_tokens: Optional[int] = None, + prompt_name: str = "default", + **kwargs + ) -> AsyncGenerator[str, None]: + """ + 流式文本聊天 + + Args: + messages: 消息列表 [{"role": "system/user/assistant", "content": "..."}] + model: 模型名称,None 使用默认模型 + temperature: 温度参数 + max_tokens: 最大输出 token 数 + prompt_name: 提示词名称,用于统计 + **kwargs: 其他参数 + + Yields: + str: 文本块(逐字返回) + """ + model = model or self.config.default_model + + # 构建请求体 + payload = { + "model": model, + "messages": messages, + "temperature": temperature, + "stream": True, + } + if max_tokens: + payload["max_tokens"] = max_tokens + + # 首选服务商 + try: + async for chunk in self._call_provider_stream( + provider=AIProvider.PRIMARY, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ): + yield chunk + return + except AIServiceError as e: + logger.warning(f"首选服务商流式调用失败: {e}, 尝试降级到备选服务商") + + # 如果没有备选 API Key,直接抛出异常 + if not self.config.fallback_api_key: + raise + + # 降级到备选服务商 + # 转换模型名称 + fallback_model = self._convert_model_name(model, AIProvider.FALLBACK) + payload["model"] = fallback_model + + async for chunk in self._call_provider_stream( + provider=AIProvider.FALLBACK, + endpoint="/chat/completions", + payload=payload, + prompt_name=prompt_name + ): + yield chunk + + async def _call_provider_stream( + self, + provider: AIProvider, + endpoint: str, + payload: Dict[str, Any], + prompt_name: str + ) -> AsyncGenerator[str, None]: + """ + 流式调用指定服务商 + + Args: + provider: 服务商 + endpoint: API 端点 + payload: 请求体 + prompt_name: 提示词名称 + + Yields: + str: 文本块 + """ + # 获取配置 + if provider == AIProvider.PRIMARY: + base_url = self.config.primary_base_url + # 根据模型选择 API Key:Claude 用专属 Key,其他用通用 Key + model = payload.get("model", "") + if is_claude_model(model) and self.config.anthropic_api_key: + api_key = self.config.anthropic_api_key + logger.debug(f"[Stream] 使用 Claude 专属 Key 调用模型: {model}") + else: + api_key = self.config.primary_api_key + else: + api_key = self.config.fallback_api_key + base_url = self.config.fallback_base_url + + url = f"{base_url.rstrip('/')}{endpoint}" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # OpenRouter 需要额外的 header + if provider == AIProvider.FALLBACK: + headers["HTTP-Referer"] = "https://kaopeilian.ireborn.com.cn" + headers["X-Title"] = "KaoPeiLian" + + start_time = time.time() + + try: + timeout = httpx.Timeout(self.config.timeout, connect=10.0) + + async with httpx.AsyncClient(timeout=timeout) as client: + logger.info(f"流式调用 AI 服务: provider={provider.value}, model={payload.get('model')}") + + async with client.stream("POST", url, json=payload, headers=headers) as response: + # 检查响应状态 + if response.status_code != 200: + error_text = await response.aread() + logger.error(f"AI 服务流式返回错误: status={response.status_code}, body={error_text[:500]}") + raise AIServiceError( + f"API 流式请求失败: HTTP {response.status_code}", + provider=provider.value, + status_code=response.status_code + ) + + # 处理 SSE 流 + async for line in response.aiter_lines(): + if not line or not line.strip(): + continue + + # 解析 SSE 数据行 + if line.startswith("data: "): + data_str = line[6:] # 移除 "data: " 前缀 + + # 检查是否是结束标记 + if data_str.strip() == "[DONE]": + logger.info(f"流式响应完成: provider={provider.value}") + return + + try: + event_data = json.loads(data_str) + + # 提取 delta 内容 + choices = event_data.get("choices", []) + if choices: + delta = choices[0].get("delta", {}) + content = delta.get("content", "") + if content: + yield content + + except json.JSONDecodeError as e: + logger.debug(f"解析流式数据失败: {e} - 数据: {data_str[:100]}") + continue + + latency_ms = int((time.time() - start_time) * 1000) + logger.info(f"流式调用完成: provider={provider.value}, latency={latency_ms}ms") + + except httpx.TimeoutException: + latency_ms = int((time.time() - start_time) * 1000) + logger.error(f"AI 服务流式超时: provider={provider.value}, latency={latency_ms}ms") + raise AIServiceError(f"流式请求超时({self.config.timeout}秒)", provider=provider.value) + + except httpx.RequestError as e: + logger.error(f"AI 服务流式网络错误: provider={provider.value}, error={e}") + raise AIServiceError(f"流式网络错误: {e}", provider=provider.value) + + async def _call_provider( + self, + provider: AIProvider, + endpoint: str, + payload: Dict[str, Any], + prompt_name: str + ) -> AIResponse: + """ + 调用指定服务商 + + Args: + provider: 服务商 + endpoint: API 端点 + payload: 请求体 + prompt_name: 提示词名称 + + Returns: + AIResponse 响应对象 + """ + # 获取配置 + if provider == AIProvider.PRIMARY: + base_url = self.config.primary_base_url + # 根据模型选择 API Key:Claude 用专属 Key,其他用通用 Key + model = payload.get("model", "") + if is_claude_model(model) and self.config.anthropic_api_key: + api_key = self.config.anthropic_api_key + logger.debug(f"使用 Claude 专属 Key 调用模型: {model}") + else: + api_key = self.config.primary_api_key + else: + api_key = self.config.fallback_api_key + base_url = self.config.fallback_base_url + + url = f"{base_url.rstrip('/')}{endpoint}" + headers = { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + # OpenRouter 需要额外的 header + if provider == AIProvider.FALLBACK: + headers["HTTP-Referer"] = "https://kaopeilian.ireborn.com.cn" + headers["X-Title"] = "KaoPeiLian" + + start_time = time.time() + + try: + async with httpx.AsyncClient(timeout=self.config.timeout) as client: + logger.info(f"调用 AI 服务: provider={provider.value}, model={payload.get('model')}") + + response = await client.post(url, json=payload, headers=headers) + + latency_ms = int((time.time() - start_time) * 1000) + + # 检查响应状态 + if response.status_code != 200: + error_text = response.text + logger.error(f"AI 服务返回错误: status={response.status_code}, body={error_text[:500]}") + raise AIServiceError( + f"API 请求失败: HTTP {response.status_code}", + provider=provider.value, + status_code=response.status_code + ) + + data = response.json() + + # 解析响应 + ai_response = self._parse_response(data, provider, latency_ms) + + # 记录日志 + logger.info( + f"AI 调用成功: provider={provider.value}, model={ai_response.model}, " + f"tokens={ai_response.total_tokens}, latency={latency_ms}ms" + ) + + # 保存到数据库(如果有 session) + await self._log_call(prompt_name, ai_response) + + return ai_response + + except httpx.TimeoutException: + latency_ms = int((time.time() - start_time) * 1000) + logger.error(f"AI 服务超时: provider={provider.value}, latency={latency_ms}ms") + raise AIServiceError(f"请求超时({self.config.timeout}秒)", provider=provider.value) + + except httpx.RequestError as e: + logger.error(f"AI 服务网络错误: provider={provider.value}, error={e}") + raise AIServiceError(f"网络错误: {e}", provider=provider.value) + + def _parse_response( + self, + data: Dict[str, Any], + provider: AIProvider, + latency_ms: int + ) -> AIResponse: + """解析 API 响应""" + # 提取内容 + choices = data.get("choices", []) + if not choices: + raise AIServiceError("响应中没有 choices") + + message = choices[0].get("message", {}) + content = message.get("content", "") + + # 提取 usage + usage = data.get("usage", {}) + input_tokens = usage.get("prompt_tokens", 0) + output_tokens = usage.get("completion_tokens", 0) + total_tokens = usage.get("total_tokens", input_tokens + output_tokens) + + # 提取费用(如果有) + cost = usage.get("total_cost", 0.0) + + return AIResponse( + content=content, + model=data.get("model", ""), + provider=provider.value, + input_tokens=input_tokens, + output_tokens=output_tokens, + total_tokens=total_tokens, + cost=cost, + latency_ms=latency_ms, + raw_response=data + ) + + async def _log_call(self, prompt_name: str, response: AIResponse) -> None: + """记录调用日志到数据库""" + if not self.db_session: + return + + try: + # TODO: 实现调用日志记录 + # 可以参考 ai_call_logs 表结构 + pass + except Exception as e: + logger.warning(f"记录 AI 调用日志失败: {e}") + + async def analyze_document( + self, + content: str, + prompt: str, + model: Optional[str] = None, + prompt_name: str = "document_analysis" + ) -> AIResponse: + """ + 分析文档内容 + + Args: + content: 文档内容 + prompt: 分析提示词 + model: 模型名称 + prompt_name: 提示词名称 + + Returns: + AIResponse 响应对象 + """ + messages = [ + {"role": "user", "content": f"{prompt}\n\n文档内容:\n{content}"} + ] + + return await self.chat( + messages=messages, + model=model, + temperature=0.1, # 文档分析使用低温度 + prompt_name=prompt_name + ) + + +# 便捷函数 +async def quick_chat( + messages: List[Dict[str, str]], + model: Optional[str] = None, + module_code: str = "quick" +) -> str: + """ + 快速聊天,返回纯文本 + + Args: + messages: 消息列表 + model: 模型名称 + module_code: 模块标识 + + Returns: + AI 回复的文本内容 + """ + ai = AIService(module_code=module_code) + response = await ai.chat(messages, model=model) + return response.content + + +# 模型常量(遵循瑞小美 AI 接入规范) +# 按优先级排序:首选 > 标准 > 快速 +MODEL_PRIMARY = "claude-opus-4-5-20251101-thinking" # 🥇 首选:所有任务首先尝试 +MODEL_STANDARD = "gemini-3-pro-preview" # 🥈 标准:Claude 失败后降级 +MODEL_FAST = "gemini-3-flash-preview" # 🥉 快速:最终保底 +MODEL_IMAGE = "gemini-2.5-flash-image-preview" # 🖼️ 图像生成专用 +MODEL_VIDEO = "veo3.1-pro" # 🎬 视频生成专用 + +# 兼容旧代码的别名 +DEFAULT_MODEL = MODEL_PRIMARY # 默认使用最强模型 +MODEL_ANALYSIS = MODEL_PRIMARY +MODEL_CREATIVE = MODEL_STANDARD +MODEL_IMAGE_GEN = MODEL_IMAGE + diff --git a/backend/app/services/ai/answer_judge_service.py b/backend/app/services/ai/answer_judge_service.py new file mode 100644 index 0000000..9601002 --- /dev/null +++ b/backend/app/services/ai/answer_judge_service.py @@ -0,0 +1,197 @@ +""" +答案判断服务 - Python 原生实现 + +功能: +- 判断填空题与问答题的答案是否正确 +- 通过 AI 语义理解比对用户答案与标准答案 + +提供稳定可靠的答案判断能力。 +""" + +import logging +from dataclasses import dataclass +from typing import Any, Optional + +from .ai_service import AIService, AIResponse +from .prompts.answer_judge_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + CORRECT_KEYWORDS, + INCORRECT_KEYWORDS, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class JudgeResult: + """判断结果""" + is_correct: bool + raw_response: str + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + + +class AnswerJudgeService: + """ + 答案判断服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = AnswerJudgeService() + result = await service.judge( + db=db_session, # 传入 db_session 用于记录调用日志 + question="玻尿酸的主要作用是什么?", + correct_answer="补水保湿、填充塑形", + user_answer="保湿和塑形", + analysis="玻尿酸具有补水保湿和填充塑形两大功能" + ) + print(result.is_correct) # True + ``` + """ + + MODULE_CODE = "answer_judge" + + async def judge( + self, + question: str, + correct_answer: str, + user_answer: str, + analysis: str = "", + db: Any = None # 数据库会话,用于记录 AI 调用日志 + ) -> JudgeResult: + """ + 判断答案是否正确 + + Args: + question: 题目内容 + correct_answer: 标准答案 + user_answer: 用户答案 + analysis: 答案解析(可选) + db: 数据库会话,用于记录调用日志(符合 AI 接入规范) + + Returns: + JudgeResult 判断结果 + """ + try: + logger.info( + f"开始判断答案 - question: {question[:50]}..., " + f"user_answer: {user_answer[:50]}..." + ) + + # 创建 AIService 实例(传入 db_session 用于记录调用日志) + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + + # 构建提示词 + user_prompt = USER_PROMPT.format( + question=question, + correct_answer=correct_answer, + user_answer=user_answer, + analysis=analysis or "无" + ) + + # 调用 AI + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_prompt} + ] + + ai_response = await ai_service.chat( + messages=messages, + temperature=0.1, # 低温度,确保输出稳定 + prompt_name="answer_judge" + ) + + logger.info( + f"AI 判断完成 - provider: {ai_response.provider}, " + f"response: {ai_response.content}, " + f"latency: {ai_response.latency_ms}ms" + ) + + # 解析 AI 输出 + is_correct = self._parse_judge_result(ai_response.content) + + logger.info(f"答案判断结果: {is_correct}") + + return JudgeResult( + is_correct=is_correct, + raw_response=ai_response.content, + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + ) + + except Exception as e: + logger.error(f"答案判断失败: {e}", exc_info=True) + # 出错时默认返回错误,保守处理 + return JudgeResult( + is_correct=False, + raw_response=f"判断失败: {e}", + ) + + def _parse_judge_result(self, ai_output: str) -> bool: + """ + 解析 AI 输出的判断结果 + + Args: + ai_output: AI 返回的文本 + + Returns: + bool: True 表示正确,False 表示错误 + """ + # 清洗输出 + output = ai_output.strip().lower() + + # 检查是否包含正确关键词 + for keyword in CORRECT_KEYWORDS: + if keyword.lower() in output: + return True + + # 检查是否包含错误关键词 + for keyword in INCORRECT_KEYWORDS: + if keyword.lower() in output: + return False + + # 无法识别时,默认返回错误(保守处理) + logger.warning(f"无法解析判断结果,默认返回错误: {ai_output}") + return False + + +# ==================== 全局实例 ==================== + +answer_judge_service = AnswerJudgeService() + + +# ==================== 便捷函数 ==================== + +async def judge_answer( + question: str, + correct_answer: str, + user_answer: str, + analysis: str = "" +) -> bool: + """ + 便捷函数:判断答案是否正确 + + Args: + question: 题目内容 + correct_answer: 标准答案 + user_answer: 用户答案 + analysis: 答案解析 + + Returns: + bool: True 表示正确,False 表示错误 + """ + result = await answer_judge_service.judge( + question=question, + correct_answer=correct_answer, + user_answer=user_answer, + analysis=analysis + ) + return result.is_correct + diff --git a/backend/app/services/ai/course_chat_service.py b/backend/app/services/ai/course_chat_service.py new file mode 100644 index 0000000..92a6014 --- /dev/null +++ b/backend/app/services/ai/course_chat_service.py @@ -0,0 +1,757 @@ +""" +课程对话服务 V2 - Python 原生实现 + +功能: +- 查询课程知识点作为知识库 +- 调用 AI 进行对话 +- 支持流式输出 +- 多轮对话历史管理(Redis 缓存) + +提供稳定可靠的课程对话能力。 +""" + +import json +import logging +import time +import uuid +from typing import Any, AsyncGenerator, Dict, List, Optional, Tuple + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService +from .prompts.course_chat_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + KNOWLEDGE_ITEM_TEMPLATE, + CONVERSATION_WINDOW_SIZE, + CONVERSATION_TTL, + MAX_KNOWLEDGE_POINTS, + MAX_KNOWLEDGE_BASE_LENGTH, + DEFAULT_CHAT_MODEL, + DEFAULT_TEMPERATURE, +) + +logger = logging.getLogger(__name__) + +# 会话索引 Redis key 前缀/后缀 +CONVERSATION_INDEX_PREFIX = "course_chat:user:" +CONVERSATION_INDEX_SUFFIX = ":conversations" +# 会话元数据 key 前缀 +CONVERSATION_META_PREFIX = "course_chat:meta:" +# 会话索引过期时间(与会话数据一致) +CONVERSATION_INDEX_TTL = CONVERSATION_TTL + + +class CourseChatServiceV2: + """ + 课程对话服务 V2 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = CourseChatServiceV2() + + # 非流式对话 + response = await service.chat( + db=db_session, + course_id=1, + query="什么是玻尿酸?", + user_id=1, + conversation_id=None + ) + + # 流式对话 + async for chunk in service.chat_stream( + db=db_session, + course_id=1, + query="什么是玻尿酸?", + user_id=1, + conversation_id=None + ): + print(chunk, end="", flush=True) + ``` + """ + + # Redis key 前缀 + CONVERSATION_KEY_PREFIX = "course_chat:conversation:" + # 模块标识 + MODULE_CODE = "course_chat" + + def __init__(self): + """初始化服务(AIService 在方法中动态创建,以传入 db_session)""" + pass + + async def chat( + self, + db: AsyncSession, + course_id: int, + query: str, + user_id: int, + conversation_id: Optional[str] = None + ) -> Dict[str, Any]: + """ + 与课程对话(非流式) + + Args: + db: 数据库会话 + course_id: 课程ID + query: 用户问题 + user_id: 用户ID + conversation_id: 会话ID(续接对话时传入) + + Returns: + 包含 answer、conversation_id 等字段的字典 + """ + try: + logger.info( + f"开始课程对话 V2 - course_id: {course_id}, user_id: {user_id}, " + f"conversation_id: {conversation_id}" + ) + + # 1. 获取课程知识点 + knowledge_base = await self._get_course_knowledge(db, course_id) + + if not knowledge_base: + logger.warning(f"课程 {course_id} 没有知识点,使用空知识库") + knowledge_base = "(该课程暂无知识点内容)" + + # 2. 获取或创建会话ID + is_new_conversation = False + if not conversation_id: + conversation_id = self._generate_conversation_id(user_id, course_id) + is_new_conversation = True + logger.info(f"创建新会话: {conversation_id}") + + # 3. 构建消息列表 + messages = await self._build_messages( + knowledge_base=knowledge_base, + query=query, + user_id=user_id, + conversation_id=conversation_id + ) + + # 4. 创建 AIService 并调用(传入 db_session 以记录调用日志) + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + response = await ai_service.chat( + messages=messages, + model=DEFAULT_CHAT_MODEL, + temperature=DEFAULT_TEMPERATURE, + prompt_name="course_chat" + ) + + answer = response.content + + # 5. 保存对话历史 + await self._save_conversation_history( + conversation_id=conversation_id, + user_message=query, + assistant_message=answer + ) + + # 6. 更新会话索引 + if is_new_conversation: + await self._add_to_conversation_index(user_id, conversation_id, course_id) + else: + await self._update_conversation_index(user_id, conversation_id) + + logger.info( + f"课程对话完成 - course_id: {course_id}, conversation_id: {conversation_id}, " + f"provider: {response.provider}, tokens: {response.total_tokens}" + ) + + return { + "success": True, + "answer": answer, + "conversation_id": conversation_id, + "ai_provider": response.provider, + "ai_model": response.model, + "ai_tokens": response.total_tokens, + "ai_latency_ms": response.latency_ms, + } + + except Exception as e: + logger.error( + f"课程对话失败 - course_id: {course_id}, user_id: {user_id}, error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"课程对话失败: {e}") + + async def chat_stream( + self, + db: AsyncSession, + course_id: int, + query: str, + user_id: int, + conversation_id: Optional[str] = None + ) -> AsyncGenerator[Tuple[str, Optional[str]], None]: + """ + 与课程对话(流式输出) + + Args: + db: 数据库会话 + course_id: 课程ID + query: 用户问题 + user_id: 用户ID + conversation_id: 会话ID(续接对话时传入) + + Yields: + Tuple[str, Optional[str]]: (事件类型, 数据) + - ("conversation_started", conversation_id): 会话开始 + - ("chunk", text): 文本块 + - ("end", None): 结束 + - ("error", message): 错误 + """ + full_answer = "" + + try: + logger.info( + f"开始流式课程对话 V2 - course_id: {course_id}, user_id: {user_id}, " + f"conversation_id: {conversation_id}" + ) + + # 1. 获取课程知识点 + knowledge_base = await self._get_course_knowledge(db, course_id) + + if not knowledge_base: + logger.warning(f"课程 {course_id} 没有知识点,使用空知识库") + knowledge_base = "(该课程暂无知识点内容)" + + # 2. 获取或创建会话ID + is_new_conversation = False + if not conversation_id: + conversation_id = self._generate_conversation_id(user_id, course_id) + is_new_conversation = True + logger.info(f"创建新会话: {conversation_id}") + + # 3. 发送会话开始事件(如果是新会话) + if is_new_conversation: + yield ("conversation_started", conversation_id) + + # 4. 构建消息列表 + messages = await self._build_messages( + knowledge_base=knowledge_base, + query=query, + user_id=user_id, + conversation_id=conversation_id + ) + + # 5. 创建 AIService 并流式调用(传入 db_session 以记录调用日志) + ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + async for chunk in ai_service.chat_stream( + messages=messages, + model=DEFAULT_CHAT_MODEL, + temperature=DEFAULT_TEMPERATURE, + prompt_name="course_chat" + ): + full_answer += chunk + yield ("chunk", chunk) + + # 6. 发送结束事件 + yield ("end", None) + + # 7. 保存对话历史 + await self._save_conversation_history( + conversation_id=conversation_id, + user_message=query, + assistant_message=full_answer + ) + + # 8. 更新会话索引 + if is_new_conversation: + await self._add_to_conversation_index(user_id, conversation_id, course_id) + else: + await self._update_conversation_index(user_id, conversation_id) + + logger.info( + f"流式课程对话完成 - course_id: {course_id}, conversation_id: {conversation_id}, " + f"answer_length: {len(full_answer)}" + ) + + except Exception as e: + logger.error( + f"流式课程对话失败 - course_id: {course_id}, user_id: {user_id}, error: {e}", + exc_info=True + ) + yield ("error", str(e)) + + async def _get_course_knowledge( + self, + db: AsyncSession, + course_id: int + ) -> str: + """ + 获取课程知识点,构建知识库文本 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 知识库文本 + """ + try: + # 查询知识点(课程知识点查询) + query = text(""" + SELECT kp.name, kp.description + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = 0 + AND cm.is_deleted = 0 + ORDER BY kp.id + LIMIT :limit + """) + + result = await db.execute( + query, + {"course_id": course_id, "limit": MAX_KNOWLEDGE_POINTS} + ) + rows = result.fetchall() + + if not rows: + logger.warning(f"课程 {course_id} 没有关联的知识点") + return "" + + # 构建知识库文本 + knowledge_items = [] + total_length = 0 + + for row in rows: + name = row[0] or "" + description = row[1] or "" + + item = KNOWLEDGE_ITEM_TEMPLATE.format( + name=name, + description=description + ) + + # 检查是否超过长度限制 + if total_length + len(item) > MAX_KNOWLEDGE_BASE_LENGTH: + logger.warning( + f"知识库文本已达到最大长度限制 {MAX_KNOWLEDGE_BASE_LENGTH}," + f"停止添加更多知识点" + ) + break + + knowledge_items.append(item) + total_length += len(item) + + knowledge_base = "\n".join(knowledge_items) + + logger.info( + f"获取课程知识点成功 - course_id: {course_id}, " + f"count: {len(knowledge_items)}, length: {len(knowledge_base)}" + ) + + return knowledge_base + + except Exception as e: + logger.error(f"获取课程知识点失败: {e}") + raise + + async def _build_messages( + self, + knowledge_base: str, + query: str, + user_id: int, + conversation_id: str + ) -> List[Dict[str, str]]: + """ + 构建消息列表(包含历史对话) + + Args: + knowledge_base: 知识库文本 + query: 当前用户问题 + user_id: 用户ID + conversation_id: 会话ID + + Returns: + 消息列表 + """ + messages = [] + + # 1. 系统提示词 + system_content = SYSTEM_PROMPT.format(knowledge_base=knowledge_base) + messages.append({"role": "system", "content": system_content}) + + # 2. 获取历史对话 + history = await self._get_conversation_history(conversation_id) + + # 限制历史窗口大小 + if len(history) > CONVERSATION_WINDOW_SIZE * 2: + history = history[-(CONVERSATION_WINDOW_SIZE * 2):] + + # 添加历史消息 + messages.extend(history) + + # 3. 当前用户问题 + user_content = USER_PROMPT.format(query=query) + messages.append({"role": "user", "content": user_content}) + + logger.debug( + f"构建消息列表 - total: {len(messages)}, history: {len(history)}" + ) + + return messages + + def _generate_conversation_id(self, user_id: int, course_id: int) -> str: + """生成会话ID""" + unique_id = uuid.uuid4().hex[:8] + return f"conv_{user_id}_{course_id}_{unique_id}" + + async def _get_conversation_history( + self, + conversation_id: str + ) -> List[Dict[str, str]]: + """ + 从 Redis 获取会话历史 + + Args: + conversation_id: 会话ID + + Returns: + 消息列表 [{"role": "user/assistant", "content": "..."}] + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + key = f"{self.CONVERSATION_KEY_PREFIX}{conversation_id}" + + data = await redis.get(key) + if not data: + return [] + + history = json.loads(data) + return history + + except RuntimeError: + # Redis 未初始化,返回空历史 + logger.warning("Redis 未初始化,无法获取会话历史") + return [] + except Exception as e: + logger.warning(f"获取会话历史失败: {e}") + return [] + + async def _save_conversation_history( + self, + conversation_id: str, + user_message: str, + assistant_message: str + ) -> None: + """ + 保存对话历史到 Redis + + Args: + conversation_id: 会话ID + user_message: 用户消息 + assistant_message: AI 回复 + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + key = f"{self.CONVERSATION_KEY_PREFIX}{conversation_id}" + + # 获取现有历史 + history = await self._get_conversation_history(conversation_id) + + # 添加新消息 + history.append({"role": "user", "content": user_message}) + history.append({"role": "assistant", "content": assistant_message}) + + # 限制历史长度 + max_messages = CONVERSATION_WINDOW_SIZE * 2 + if len(history) > max_messages: + history = history[-max_messages:] + + # 保存到 Redis + await redis.setex( + key, + CONVERSATION_TTL, + json.dumps(history, ensure_ascii=False) + ) + + logger.debug( + f"保存会话历史成功 - conversation_id: {conversation_id}, " + f"messages: {len(history)}" + ) + + except RuntimeError: + # Redis 未初始化,跳过保存 + logger.warning("Redis 未初始化,无法保存会话历史") + except Exception as e: + logger.warning(f"保存会话历史失败: {e}") + + async def get_conversation_messages( + self, + conversation_id: str, + user_id: int + ) -> List[Dict[str, Any]]: + """ + 获取会话的历史消息 + + Args: + conversation_id: 会话ID + user_id: 用户ID(用于权限验证) + + Returns: + 消息列表 + """ + # 验证会话ID是否属于该用户 + if not conversation_id.startswith(f"conv_{user_id}_"): + logger.warning( + f"用户 {user_id} 尝试访问不属于自己的会话: {conversation_id}" + ) + return [] + + history = await self._get_conversation_history(conversation_id) + + # 格式化返回数据 + messages = [] + for i, msg in enumerate(history): + messages.append({ + "id": i, + "role": msg["role"], + "content": msg["content"], + }) + + return messages + + async def _add_to_conversation_index( + self, + user_id: int, + conversation_id: str, + course_id: int + ) -> None: + """ + 将会话添加到用户索引 + + Args: + user_id: 用户ID + conversation_id: 会话ID + course_id: 课程ID + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + + # 1. 添加到用户的会话索引(Sorted Set,score 为时间戳) + index_key = f"{CONVERSATION_INDEX_PREFIX}{user_id}{CONVERSATION_INDEX_SUFFIX}" + timestamp = time.time() + await redis.zadd(index_key, {conversation_id: timestamp}) + await redis.expire(index_key, CONVERSATION_INDEX_TTL) + + # 2. 保存会话元数据 + meta_key = f"{CONVERSATION_META_PREFIX}{conversation_id}" + meta_data = { + "conversation_id": conversation_id, + "user_id": user_id, + "course_id": course_id, + "created_at": timestamp, + "updated_at": timestamp, + } + await redis.setex( + meta_key, + CONVERSATION_INDEX_TTL, + json.dumps(meta_data, ensure_ascii=False) + ) + + logger.debug( + f"会话已添加到索引 - user_id: {user_id}, conversation_id: {conversation_id}" + ) + + except RuntimeError: + logger.warning("Redis 未初始化,无法添加会话索引") + except Exception as e: + logger.warning(f"添加会话索引失败: {e}") + + async def _update_conversation_index( + self, + user_id: int, + conversation_id: str + ) -> None: + """ + 更新会话的最后活跃时间 + + Args: + user_id: 用户ID + conversation_id: 会话ID + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + + # 更新索引中的时间戳 + index_key = f"{CONVERSATION_INDEX_PREFIX}{user_id}{CONVERSATION_INDEX_SUFFIX}" + timestamp = time.time() + await redis.zadd(index_key, {conversation_id: timestamp}) + await redis.expire(index_key, CONVERSATION_INDEX_TTL) + + # 更新元数据中的 updated_at + meta_key = f"{CONVERSATION_META_PREFIX}{conversation_id}" + meta_data = await redis.get(meta_key) + if meta_data: + meta = json.loads(meta_data) + meta["updated_at"] = timestamp + await redis.setex( + meta_key, + CONVERSATION_INDEX_TTL, + json.dumps(meta, ensure_ascii=False) + ) + + logger.debug( + f"会话索引已更新 - user_id: {user_id}, conversation_id: {conversation_id}" + ) + + except RuntimeError: + logger.warning("Redis 未初始化,无法更新会话索引") + except Exception as e: + logger.warning(f"更新会话索引失败: {e}") + + async def list_user_conversations( + self, + user_id: int, + limit: int = 20 + ) -> List[Dict[str, Any]]: + """ + 获取用户的会话列表 + + Args: + user_id: 用户ID + limit: 返回数量限制 + + Returns: + 会话列表,按更新时间倒序 + """ + try: + from app.core.redis import get_redis_client + + redis = get_redis_client() + + # 1. 从索引获取最近的会话ID列表(倒序) + index_key = f"{CONVERSATION_INDEX_PREFIX}{user_id}{CONVERSATION_INDEX_SUFFIX}" + conversation_ids = await redis.zrevrange(index_key, 0, limit - 1) + + if not conversation_ids: + logger.debug(f"用户 {user_id} 没有会话记录") + return [] + + # 2. 获取每个会话的元数据和最后消息 + conversations = [] + for conv_id in conversation_ids: + # 确保是字符串 + if isinstance(conv_id, bytes): + conv_id = conv_id.decode('utf-8') + + # 获取元数据 + meta_key = f"{CONVERSATION_META_PREFIX}{conv_id}" + meta_data = await redis.get(meta_key) + + if meta_data: + if isinstance(meta_data, bytes): + meta_data = meta_data.decode('utf-8') + meta = json.loads(meta_data) + else: + # 从 conversation_id 解析 course_id + # 格式: conv_{user_id}_{course_id}_{uuid} + parts = conv_id.split('_') + course_id = int(parts[2]) if len(parts) >= 3 else 0 + meta = { + "conversation_id": conv_id, + "user_id": user_id, + "course_id": course_id, + "created_at": time.time(), + "updated_at": time.time(), + } + + # 获取最后一条消息作为预览 + history = await self._get_conversation_history(conv_id) + last_message = "" + if history: + # 获取最后一条 assistant 消息 + for msg in reversed(history): + if msg["role"] == "assistant": + last_message = msg["content"][:100] # 截取前100字符 + if len(msg["content"]) > 100: + last_message += "..." + break + + conversations.append({ + "id": conv_id, + "course_id": meta.get("course_id"), + "created_at": meta.get("created_at"), + "updated_at": meta.get("updated_at"), + "last_message": last_message, + "message_count": len(history), + }) + + logger.info(f"获取用户会话列表 - user_id: {user_id}, count: {len(conversations)}") + return conversations + + except RuntimeError: + logger.warning("Redis 未初始化,无法获取会话列表") + return [] + except Exception as e: + logger.warning(f"获取会话列表失败: {e}") + return [] + + # 别名方法,供 API 层调用 + async def get_conversations( + self, + user_id: int, + course_id: Optional[int] = None, + limit: int = 20 + ) -> List[Dict[str, Any]]: + """ + 获取用户的会话列表(别名方法) + + Args: + user_id: 用户ID + course_id: 课程ID(可选,用于过滤) + limit: 返回数量限制 + + Returns: + 会话列表 + """ + conversations = await self.list_user_conversations(user_id, limit) + + # 如果指定了 course_id,进行过滤 + if course_id is not None: + conversations = [ + c for c in conversations + if c.get("course_id") == course_id + ] + + return conversations + + async def get_messages( + self, + conversation_id: str, + user_id: int, + limit: int = 50 + ) -> List[Dict[str, Any]]: + """ + 获取会话历史消息(别名方法) + + Args: + conversation_id: 会话ID + user_id: 用户ID(用于权限验证) + limit: 返回数量限制 + + Returns: + 消息列表 + """ + messages = await self.get_conversation_messages(conversation_id, limit) + return messages + + +# 创建全局实例 +course_chat_service_v2 = CourseChatServiceV2() + diff --git a/backend/app/services/ai/coze/__init__.py b/backend/app/services/ai/coze/__init__.py new file mode 100644 index 0000000..71156df --- /dev/null +++ b/backend/app/services/ai/coze/__init__.py @@ -0,0 +1,61 @@ +""" +Coze AI 服务模块 +""" + +from .client import get_coze_client, get_auth_manager, get_bot_config, get_workspace_id +from .service import get_coze_service, CozeService +from .models import ( + SessionType, + MessageRole, + ContentType, + StreamEventType, + CozeSession, + CozeMessage, + StreamEvent, + CreateSessionRequest, + CreateSessionResponse, + SendMessageRequest, + EndSessionRequest, + EndSessionResponse, +) +from .exceptions import ( + CozeException, + CozeAuthError, + CozeAPIError, + CozeRateLimitError, + CozeTimeoutError, + CozeStreamError, + map_coze_error_to_exception, +) + +__all__ = [ + # Client + "get_coze_client", + "get_auth_manager", + "get_bot_config", + "get_workspace_id", + # Service + "get_coze_service", + "CozeService", + # Models + "SessionType", + "MessageRole", + "ContentType", + "StreamEventType", + "CozeSession", + "CozeMessage", + "StreamEvent", + "CreateSessionRequest", + "CreateSessionResponse", + "SendMessageRequest", + "EndSessionRequest", + "EndSessionResponse", + # Exceptions + "CozeException", + "CozeAuthError", + "CozeAPIError", + "CozeRateLimitError", + "CozeTimeoutError", + "CozeStreamError", + "map_coze_error_to_exception", +] diff --git a/backend/app/services/ai/coze/client.py b/backend/app/services/ai/coze/client.py new file mode 100644 index 0000000..acbe855 --- /dev/null +++ b/backend/app/services/ai/coze/client.py @@ -0,0 +1,203 @@ +""" +Coze AI 客户端管理 +负责管理 Coze API 的认证和客户端实例 +""" +from functools import lru_cache +from typing import Optional, Dict, Any +import logging +from pathlib import Path + +from cozepy import Coze, TokenAuth, JWTAuth, COZE_CN_BASE_URL + +from app.core.config import get_settings + +logger = logging.getLogger(__name__) + + +class CozeAuthManager: + """Coze 认证管理器""" + + def __init__(self): + self.settings = get_settings() + self._client: Optional[Coze] = None + + def _create_pat_auth(self) -> TokenAuth: + """创建个人访问令牌认证""" + if not self.settings.COZE_API_TOKEN: + raise ValueError("COZE_API_TOKEN 未配置") + + return TokenAuth(token=self.settings.COZE_API_TOKEN) + + def _create_oauth_auth(self) -> JWTAuth: + """创建 OAuth 认证""" + if not all( + [ + self.settings.COZE_OAUTH_CLIENT_ID, + self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + self.settings.COZE_OAUTH_PRIVATE_KEY_PATH, + ] + ): + raise ValueError("OAuth 配置不完整") + + # 读取私钥 + private_key_path = Path(self.settings.COZE_OAUTH_PRIVATE_KEY_PATH) + if not private_key_path.exists(): + raise FileNotFoundError(f"私钥文件不存在: {private_key_path}") + + with open(private_key_path, "r") as f: + private_key = f.read() + + try: + return JWTAuth( + client_id=self.settings.COZE_OAUTH_CLIENT_ID, + private_key=private_key, + public_key_id=self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + base_url=self.settings.COZE_API_BASE or COZE_CN_BASE_URL, # 使用中国区API + ) + except Exception as e: + logger.error(f"创建 OAuth 认证失败: {e}") + raise + + def get_client(self, force_new: bool = False) -> Coze: + """ + 获取 Coze 客户端实例 + + Args: + force_new: 是否强制创建新客户端(用于长时间运行的请求,避免token过期) + + 认证优先级: + 1. OAuth(推荐):配置完整时使用,自动刷新token + 2. PAT:仅当OAuth未配置时使用(注意:PAT会过期) + """ + if self._client is not None and not force_new: + return self._client + + auth = None + auth_type = None + + # 检查 OAuth 配置是否完整 + oauth_configured = all([ + self.settings.COZE_OAUTH_CLIENT_ID, + self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + self.settings.COZE_OAUTH_PRIVATE_KEY_PATH, + ]) + + if oauth_configured: + # OAuth 配置完整,必须使用 OAuth(不fallback到PAT) + try: + auth = self._create_oauth_auth() + auth_type = "OAuth" + logger.info("使用 OAuth 认证") + except Exception as e: + # OAuth 配置完整但创建失败,直接抛出异常(不fallback到可能过期的PAT) + logger.error(f"OAuth 认证创建失败: {e}") + raise ValueError(f"OAuth 认证失败,请检查私钥文件和配置: {e}") + else: + # OAuth 未配置,使用 PAT + if self.settings.COZE_API_TOKEN: + auth = self._create_pat_auth() + auth_type = "PAT" + logger.warning("使用 PAT 认证(注意:PAT会过期,建议配置OAuth)") + else: + raise ValueError("Coze 认证未配置:需要配置 OAuth 或 PAT Token") + + # 创建客户端 + client = Coze( + auth=auth, base_url=self.settings.COZE_API_BASE or COZE_CN_BASE_URL + ) + + logger.debug(f"Coze客户端创建成功,认证方式: {auth_type}, force_new: {force_new}") + + # 只有非强制创建时才缓存 + if not force_new: + self._client = client + + return client + + def reset(self): + """重置客户端实例""" + self._client = None + + def get_oauth_token(self) -> str: + """ + 获取OAuth JWT Token用于前端直连 + + Returns: + JWT token字符串 + """ + if not all([ + self.settings.COZE_OAUTH_CLIENT_ID, + self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + self.settings.COZE_OAUTH_PRIVATE_KEY_PATH, + ]): + raise ValueError("OAuth 配置不完整") + + # 读取私钥 + private_key_path = Path(self.settings.COZE_OAUTH_PRIVATE_KEY_PATH) + if not private_key_path.exists(): + raise FileNotFoundError(f"私钥文件不存在: {private_key_path}") + + with open(private_key_path, "r") as f: + private_key = f.read() + + # 创建JWTAuth实例(必须指定中国区base_url) + jwt_auth = JWTAuth( + client_id=self.settings.COZE_OAUTH_CLIENT_ID, + private_key=private_key, + public_key_id=self.settings.COZE_OAUTH_PUBLIC_KEY_ID, + base_url=self.settings.COZE_API_BASE or COZE_CN_BASE_URL, # 使用中国区API + ) + + # 获取token(JWTAuth内部会自动生成) + # JWTAuth.token属性返回已签名的JWT + return jwt_auth.token + + +@lru_cache() +def get_auth_manager() -> CozeAuthManager: + """获取认证管理器单例""" + return CozeAuthManager() + + +def get_coze_client(force_new: bool = False) -> Coze: + """ + 获取 Coze 客户端 + + Args: + force_new: 是否强制创建新客户端(用于工作流等长时间运行的请求) + """ + return get_auth_manager().get_client(force_new=force_new) + + +def get_workspace_id() -> str: + """获取工作空间 ID""" + settings = get_settings() + if not settings.COZE_WORKSPACE_ID: + raise ValueError("COZE_WORKSPACE_ID 未配置") + return settings.COZE_WORKSPACE_ID + + +def get_bot_config(session_type: str) -> Dict[str, Any]: + """ + 根据会话类型获取 Bot 配置 + + Args: + session_type: 会话类型 (course_chat 或 training) + + Returns: + 包含 bot_id 等配置的字典 + """ + settings = get_settings() + + if session_type == "course_chat": + bot_id = settings.COZE_CHAT_BOT_ID + if not bot_id: + raise ValueError("COZE_CHAT_BOT_ID 未配置") + elif session_type == "training": + bot_id = settings.COZE_TRAINING_BOT_ID + if not bot_id: + raise ValueError("COZE_TRAINING_BOT_ID 未配置") + else: + raise ValueError(f"不支持的会话类型: {session_type}") + + return {"bot_id": bot_id, "workspace_id": settings.COZE_WORKSPACE_ID} diff --git a/backend/app/services/ai/coze/client_backup.py b/backend/app/services/ai/coze/client_backup.py new file mode 100644 index 0000000..4ddbbd5 --- /dev/null +++ b/backend/app/services/ai/coze/client_backup.py @@ -0,0 +1,44 @@ +"""Coze客户端(临时模拟,等Agent-Coze实现后替换)""" +import logging +from typing import Dict, Any, Optional + +logger = logging.getLogger(__name__) + + +class CozeClient: + """ + Coze客户端模拟类 + TODO: 等Agent-Coze模块实现后,这个类将被真实的Coze网关客户端替换 + """ + + async def create_conversation( + self, bot_id: str, user_id: str, meta_data: Optional[Dict[str, Any]] = None + ) -> Dict[str, Any]: + """创建会话(模拟)""" + logger.info(f"模拟创建Coze会话: bot_id={bot_id}, user_id={user_id}") + + # 返回模拟的会话信息 + return { + "conversation_id": f"mock_conversation_{user_id}_{bot_id[:8]}", + "bot_id": bot_id, + "status": "active", + } + + async def send_message( + self, conversation_id: str, content: str, message_type: str = "text" + ) -> Dict[str, Any]: + """发送消息(模拟)""" + logger.info(f"模拟发送消息到会话 {conversation_id}: {content[:50]}...") + + # 返回模拟的消息响应 + return { + "message_id": f"mock_msg_{conversation_id[:8]}", + "content": f"这是对'{content[:30]}...'的模拟回复", + "role": "assistant", + } + + async def end_conversation(self, conversation_id: str) -> Dict[str, Any]: + """结束会话(模拟)""" + logger.info(f"模拟结束会话: {conversation_id}") + + return {"status": "completed", "conversation_id": conversation_id} diff --git a/backend/app/services/ai/coze/exceptions.py b/backend/app/services/ai/coze/exceptions.py new file mode 100644 index 0000000..30eb348 --- /dev/null +++ b/backend/app/services/ai/coze/exceptions.py @@ -0,0 +1,101 @@ +""" +Coze 服务异常定义 +""" + +from typing import Optional, Dict, Any + + +class CozeException(Exception): + """Coze 服务基础异常""" + + def __init__( + self, + message: str, + code: Optional[str] = None, + status_code: Optional[int] = None, + details: Optional[Dict[str, Any]] = None, + ): + super().__init__(message) + self.message = message + self.code = code + self.status_code = status_code + self.details = details or {} + + +class CozeAuthError(CozeException): + """认证异常""" + + pass + + +class CozeAPIError(CozeException): + """API 调用异常""" + + pass + + +class CozeRateLimitError(CozeException): + """速率限制异常""" + + pass + + +class CozeTimeoutError(CozeException): + """超时异常""" + + pass + + +class CozeStreamError(CozeException): + """流式响应异常""" + + pass + + +def map_coze_error_to_exception(error: Exception) -> CozeException: + """ + 将 Coze SDK 错误映射为统一异常 + + Args: + error: 原始异常 + + Returns: + CozeException: 映射后的异常 + """ + error_message = str(error) + + # 根据错误消息判断错误类型 + if ( + "authentication" in error_message.lower() + or "unauthorized" in error_message.lower() + ): + return CozeAuthError( + message="Coze 认证失败", + code="COZE_AUTH_ERROR", + status_code=401, + details={"original_error": error_message}, + ) + + if "rate limit" in error_message.lower(): + return CozeRateLimitError( + message="Coze API 速率限制", + code="COZE_RATE_LIMIT", + status_code=429, + details={"original_error": error_message}, + ) + + if "timeout" in error_message.lower(): + return CozeTimeoutError( + message="Coze API 调用超时", + code="COZE_TIMEOUT", + status_code=504, + details={"original_error": error_message}, + ) + + # 默认映射为 API 错误 + return CozeAPIError( + message="Coze API 调用失败", + code="COZE_API_ERROR", + status_code=500, + details={"original_error": error_message}, + ) diff --git a/backend/app/services/ai/coze/models.py b/backend/app/services/ai/coze/models.py new file mode 100644 index 0000000..c9ca290 --- /dev/null +++ b/backend/app/services/ai/coze/models.py @@ -0,0 +1,136 @@ +""" +Coze 服务数据模型 +""" + +from typing import Optional, List, Dict, Any, Literal +from datetime import datetime +from pydantic import BaseModel, Field +from enum import Enum + + +class SessionType(str, Enum): + """会话类型""" + + COURSE_CHAT = "course_chat" # 课程对话 + TRAINING = "training" # 陪练会话 + EXAM = "exam" # 考试会话 + + +class MessageRole(str, Enum): + """消息角色""" + + USER = "user" + ASSISTANT = "assistant" + SYSTEM = "system" + + +class ContentType(str, Enum): + """内容类型""" + + TEXT = "text" + CARD = "card" + IMAGE = "image" + FILE = "file" + + +class StreamEventType(str, Enum): + """流式事件类型""" + + MESSAGE_START = "conversation.message.start" + MESSAGE_DELTA = "conversation.message.delta" + MESSAGE_COMPLETED = "conversation.message.completed" + ERROR = "error" + DONE = "done" + + +class CozeSession(BaseModel): + """Coze 会话模型""" + + session_id: str = Field(..., description="会话ID") + conversation_id: str = Field(..., description="Coze对话ID") + session_type: SessionType = Field(..., description="会话类型") + user_id: str = Field(..., description="用户ID") + bot_id: str = Field(..., description="Bot ID") + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + ended_at: Optional[datetime] = Field(None, description="结束时间") + metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class CozeMessage(BaseModel): + """Coze 消息模型""" + + message_id: str = Field(..., description="消息ID") + session_id: str = Field(..., description="会话ID") + role: MessageRole = Field(..., description="消息角色") + content: str = Field(..., description="消息内容") + content_type: ContentType = Field(ContentType.TEXT, description="内容类型") + created_at: datetime = Field(default_factory=datetime.now, description="创建时间") + metadata: Dict[str, Any] = Field(default_factory=dict, description="元数据") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class StreamEvent(BaseModel): + """流式事件模型""" + + event: StreamEventType = Field(..., description="事件类型") + data: Dict[str, Any] = Field(..., description="事件数据") + message_id: Optional[str] = Field(None, description="消息ID") + content: Optional[str] = Field(None, description="内容") + content_type: Optional[ContentType] = Field(None, description="内容类型") + role: Optional[MessageRole] = Field(None, description="角色") + error: Optional[str] = Field(None, description="错误信息") + + +class CreateSessionRequest(BaseModel): + """创建会话请求""" + + session_type: SessionType = Field(..., description="会话类型") + user_id: str = Field(..., description="用户ID") + course_id: Optional[str] = Field(None, description="课程ID (课程对话时必需)") + training_topic: Optional[str] = Field(None, description="陪练主题 (陪练时可选)") + metadata: Dict[str, Any] = Field(default_factory=dict, description="额外元数据") + + +class CreateSessionResponse(BaseModel): + """创建会话响应""" + + session_id: str = Field(..., description="会话ID") + conversation_id: str = Field(..., description="Coze对话ID") + bot_id: str = Field(..., description="Bot ID") + created_at: datetime = Field(..., description="创建时间") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} + + +class SendMessageRequest(BaseModel): + """发送消息请求""" + + session_id: str = Field(..., description="会话ID") + content: str = Field(..., description="消息内容") + file_ids: List[str] = Field(default_factory=list, description="附件ID列表") + stream: bool = Field(True, description="是否流式响应") + + +class EndSessionRequest(BaseModel): + """结束会话请求""" + + reason: Optional[str] = Field(None, description="结束原因") + feedback: Optional[Dict[str, Any]] = Field(None, description="用户反馈") + + +class EndSessionResponse(BaseModel): + """结束会话响应""" + + session_id: str = Field(..., description="会话ID") + ended_at: datetime = Field(..., description="结束时间") + duration_seconds: int = Field(..., description="会话时长(秒)") + message_count: int = Field(..., description="消息数量") + + class Config: + json_encoders = {datetime: lambda v: v.isoformat()} diff --git a/backend/app/services/ai/coze/service.py b/backend/app/services/ai/coze/service.py new file mode 100644 index 0000000..fed4618 --- /dev/null +++ b/backend/app/services/ai/coze/service.py @@ -0,0 +1,335 @@ +""" +Coze 服务层实现 +处理会话管理、消息发送、流式响应等核心功能 +""" + +import asyncio +import json +import logging +import uuid +from typing import AsyncIterator, Dict, Any, List, Optional +from datetime import datetime + +from cozepy import ChatEventType, Message, MessageContentType + +from .client import get_coze_client, get_bot_config, get_workspace_id +from .models import ( + CozeSession, + CozeMessage, + StreamEvent, + SessionType, + MessageRole, + ContentType, + StreamEventType, + CreateSessionRequest, + CreateSessionResponse, + SendMessageRequest, + EndSessionRequest, + EndSessionResponse, +) +from .exceptions import ( + CozeAPIError, + CozeStreamError, + CozeTimeoutError, + map_coze_error_to_exception, +) + + +logger = logging.getLogger(__name__) + + +class CozeService: + """Coze 服务类""" + + def __init__(self): + self.client = get_coze_client() + self.bot_config = get_bot_config() + self.workspace_id = get_workspace_id() + + # 内存中的会话存储(生产环境应使用 Redis) + self._sessions: Dict[str, CozeSession] = {} + self._messages: Dict[str, List[CozeMessage]] = {} + + async def create_session( + self, request: CreateSessionRequest + ) -> CreateSessionResponse: + """ + 创建新会话 + + Args: + request: 创建会话请求 + + Returns: + CreateSessionResponse: 会话信息 + """ + try: + # 根据会话类型选择 Bot + bot_id = self._get_bot_id_by_type(request.session_type) + + # 创建 Coze 对话 + conversation = await asyncio.to_thread( + self.client.conversations.create, bot_id=bot_id + ) + + # 创建本地会话记录 + session = CozeSession( + session_id=str(uuid.uuid4()), + conversation_id=conversation.id, + session_type=request.session_type, + user_id=request.user_id, + bot_id=bot_id, + metadata=request.metadata, + ) + + # 保存会话 + self._sessions[session.session_id] = session + self._messages[session.session_id] = [] + + logger.info( + f"创建会话成功", + extra={ + "session_id": session.session_id, + "conversation_id": conversation.id, + "session_type": request.session_type.value, + "user_id": request.user_id, + }, + ) + + return CreateSessionResponse( + session_id=session.session_id, + conversation_id=session.conversation_id, + bot_id=session.bot_id, + created_at=session.created_at, + ) + + except Exception as e: + logger.error(f"创建会话失败: {e}", exc_info=True) + raise map_coze_error_to_exception(e) + + async def send_message( + self, request: SendMessageRequest + ) -> AsyncIterator[StreamEvent]: + """ + 发送消息并处理流式响应 + + Args: + request: 发送消息请求 + + Yields: + StreamEvent: 流式事件 + """ + session = self._get_session(request.session_id) + if not session: + raise CozeAPIError(f"会话不存在: {request.session_id}") + + # 记录用户消息 + user_message = CozeMessage( + message_id=str(uuid.uuid4()), + session_id=session.session_id, + role=MessageRole.USER, + content=request.content, + ) + self._messages[session.session_id].append(user_message) + + try: + # 构建消息历史 + messages = self._build_message_history(session.session_id) + + # 调用 Coze API + stream = await asyncio.to_thread( + self.client.chat.stream, + bot_id=session.bot_id, + conversation_id=session.conversation_id, + additional_messages=messages, + auto_save_history=True, + ) + + # 处理流式响应 + async for event in self._process_stream(stream, session.session_id): + yield event + + except asyncio.TimeoutError: + logger.error(f"消息发送超时: session_id={request.session_id}") + raise CozeTimeoutError("消息处理超时") + except Exception as e: + logger.error(f"发送消息失败: {e}", exc_info=True) + raise map_coze_error_to_exception(e) + + async def end_session( + self, session_id: str, request: EndSessionRequest + ) -> EndSessionResponse: + """ + 结束会话 + + Args: + session_id: 会话ID + request: 结束会话请求 + + Returns: + EndSessionResponse: 结束会话响应 + """ + session = self._get_session(session_id) + if not session: + raise CozeAPIError(f"会话不存在: {session_id}") + + # 更新会话状态 + session.ended_at = datetime.now() + + # 计算会话统计 + duration_seconds = int((session.ended_at - session.created_at).total_seconds()) + message_count = len(self._messages.get(session_id, [])) + + # 记录结束原因和反馈 + if request.reason: + session.metadata["end_reason"] = request.reason + if request.feedback: + session.metadata["feedback"] = request.feedback + + logger.info( + f"会话结束", + extra={ + "session_id": session_id, + "duration_seconds": duration_seconds, + "message_count": message_count, + "reason": request.reason, + }, + ) + + return EndSessionResponse( + session_id=session_id, + ended_at=session.ended_at, + duration_seconds=duration_seconds, + message_count=message_count, + ) + + async def get_session_messages( + self, session_id: str, limit: int = 50, offset: int = 0 + ) -> List[CozeMessage]: + """获取会话消息历史""" + messages = self._messages.get(session_id, []) + return messages[offset : offset + limit] + + def _get_bot_id_by_type(self, session_type: SessionType) -> str: + """根据会话类型获取 Bot ID""" + mapping = { + SessionType.COURSE_CHAT: self.bot_config["course_chat"], + SessionType.TRAINING: self.bot_config["training"], + SessionType.EXAM: self.bot_config["exam"], + } + return mapping.get(session_type, self.bot_config["training"]) + + def _get_session(self, session_id: str) -> Optional[CozeSession]: + """获取会话""" + return self._sessions.get(session_id) + + def _build_message_history(self, session_id: str) -> List[Message]: + """构建消息历史""" + messages = self._messages.get(session_id, []) + history = [] + + for msg in messages[-10:]: # 只发送最近10条消息作为上下文 + history.append( + Message( + role=msg.role.value, + content=msg.content, + content_type=MessageContentType.TEXT, + ) + ) + + return history + + async def _process_stream( + self, stream, session_id: str + ) -> AsyncIterator[StreamEvent]: + """处理流式响应""" + assistant_message_id = str(uuid.uuid4()) + accumulated_content = [] + content_type = ContentType.TEXT + + try: + for event in stream: + if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + # 消息片段 + content = event.message.content + accumulated_content.append(content) + + # 检测卡片类型 + if ( + hasattr(event.message, "content_type") + and event.message.content_type == "card" + ): + content_type = ContentType.CARD + + yield StreamEvent( + event=StreamEventType.MESSAGE_DELTA, + data={ + "conversation_id": event.conversation_id, + "message_id": assistant_message_id, + "content": content, + "content_type": content_type.value, + }, + message_id=assistant_message_id, + content=content, + content_type=content_type, + role=MessageRole.ASSISTANT, + ) + + elif event.event == ChatEventType.CONVERSATION_MESSAGE_COMPLETED: + # 消息完成 + full_content = "".join(accumulated_content) + + # 保存助手消息 + assistant_message = CozeMessage( + message_id=assistant_message_id, + session_id=session_id, + role=MessageRole.ASSISTANT, + content=full_content, + content_type=content_type, + ) + self._messages[session_id].append(assistant_message) + + yield StreamEvent( + event=StreamEventType.MESSAGE_COMPLETED, + data={ + "conversation_id": event.conversation_id, + "message_id": assistant_message_id, + "content": full_content, + "content_type": content_type.value, + "usage": getattr(event, "usage", {}), + }, + message_id=assistant_message_id, + content=full_content, + content_type=content_type, + role=MessageRole.ASSISTANT, + ) + + elif event.event == ChatEventType.ERROR: + # 错误事件 + yield StreamEvent( + event=StreamEventType.ERROR, + data={"error": str(event)}, + error=str(event), + ) + + except Exception as e: + logger.error(f"流式处理错误: {e}", exc_info=True) + yield StreamEvent( + event=StreamEventType.ERROR, data={"error": str(e)}, error=str(e) + ) + finally: + # 发送结束事件 + yield StreamEvent( + event=StreamEventType.DONE, data={"session_id": session_id} + ) + + +# 全局服务实例 +_service: Optional[CozeService] = None + + +def get_coze_service() -> CozeService: + """获取 Coze 服务单例""" + global _service + if _service is None: + _service = CozeService() + return _service diff --git a/backend/app/services/ai/exam_generator_service.py b/backend/app/services/ai/exam_generator_service.py new file mode 100644 index 0000000..692bce6 --- /dev/null +++ b/backend/app/services/ai/exam_generator_service.py @@ -0,0 +1,512 @@ +""" +试题生成服务 V2 - Python 原生实现 + +功能: +- 根据岗位和知识点动态生成考试题目 +- 支持错题重出模式 +- 调用 AI 生成并解析 JSON 结果 + +提供稳定可靠的试题生成能力。 +""" + +import json +import logging +from dataclasses import dataclass +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.exam_generator_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + MISTAKE_REGEN_SYSTEM_PROMPT, + MISTAKE_REGEN_USER_PROMPT, + QUESTION_SCHEMA, + DEFAULT_QUESTION_COUNTS, + DEFAULT_DIFFICULTY_LEVEL, +) + +logger = logging.getLogger(__name__) + + +@dataclass +class ExamGeneratorConfig: + """考试生成配置""" + course_id: int + position_id: int + single_choice_count: int = DEFAULT_QUESTION_COUNTS["single_choice_count"] + multiple_choice_count: int = DEFAULT_QUESTION_COUNTS["multiple_choice_count"] + true_false_count: int = DEFAULT_QUESTION_COUNTS["true_false_count"] + fill_blank_count: int = DEFAULT_QUESTION_COUNTS["fill_blank_count"] + essay_count: int = DEFAULT_QUESTION_COUNTS["essay_count"] + difficulty_level: int = DEFAULT_DIFFICULTY_LEVEL + mistake_records: str = "" + + @property + def total_count(self) -> int: + """计算总题量""" + return ( + self.single_choice_count + + self.multiple_choice_count + + self.true_false_count + + self.fill_blank_count + + self.essay_count + ) + + @property + def has_mistakes(self) -> bool: + """是否有错题记录""" + return bool(self.mistake_records and self.mistake_records.strip()) + + +class ExamGeneratorService: + """ + 试题生成服务 V2 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = ExamGeneratorService() + result = await service.generate_exam( + db=db_session, + config=ExamGeneratorConfig( + course_id=1, + position_id=1, + single_choice_count=5, + multiple_choice_count=3, + difficulty_level=3 + ) + ) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="exam_generator") + + async def generate_exam( + self, + db: AsyncSession, + config: ExamGeneratorConfig + ) -> Dict[str, Any]: + """ + 生成考试题目(主入口) + + Args: + db: 数据库会话 + config: 考试生成配置 + + Returns: + 生成结果,包含 success、questions、total_count 等字段 + """ + try: + logger.info( + f"开始生成试题 - course_id: {config.course_id}, position_id: {config.position_id}, " + f"total_count: {config.total_count}, has_mistakes: {config.has_mistakes}" + ) + + # 根据是否有错题记录,走不同分支 + if config.has_mistakes: + return await self._regenerate_from_mistakes(db, config) + else: + return await self._generate_from_knowledge(db, config) + + except ExternalServiceError: + raise + except Exception as e: + logger.error( + f"试题生成失败 - course_id: {config.course_id}, error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"试题生成失败: {e}") + + async def _generate_from_knowledge( + self, + db: AsyncSession, + config: ExamGeneratorConfig + ) -> Dict[str, Any]: + """ + 基于知识点生成题目(无错题模式) + + 流程: + 1. 查询岗位信息 + 2. 随机查询知识点 + 3. 调用 AI 生成题目 + 4. 解析并返回结果 + """ + # 1. 查询岗位信息 + position_info = await self._query_position(db, config.position_id) + if not position_info: + raise ExternalServiceError(f"岗位不存在: position_id={config.position_id}") + + logger.info(f"岗位信息: {position_info.get('name', 'unknown')}") + + # 2. 随机查询知识点 + knowledge_points = await self._query_knowledge_points( + db, + config.course_id, + config.total_count + ) + if not knowledge_points: + raise ExternalServiceError( + f"课程没有可用的知识点: course_id={config.course_id}" + ) + + logger.info(f"查询到 {len(knowledge_points)} 个知识点") + + # 3. 构建提示词 + system_prompt = SYSTEM_PROMPT.format( + total_count=config.total_count, + single_choice_count=config.single_choice_count, + multiple_choice_count=config.multiple_choice_count, + true_false_count=config.true_false_count, + fill_blank_count=config.fill_blank_count, + essay_count=config.essay_count, + difficulty_level=config.difficulty_level, + ) + + user_prompt = USER_PROMPT.format( + position_info=self._format_position_info(position_info), + knowledge_points=self._format_knowledge_points(knowledge_points), + ) + + # 4. 调用 AI 生成 + ai_response = await self._call_ai_generate(system_prompt, user_prompt) + + logger.info( + f"AI 生成完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 5. 解析题目 + questions = self._parse_questions(ai_response.content) + + logger.info(f"试题解析成功,数量: {len(questions)}") + + return { + "success": True, + "questions": questions, + "total_count": len(questions), + "mode": "knowledge_based", + "ai_provider": ai_response.provider, + "ai_model": ai_response.model, + "ai_tokens": ai_response.total_tokens, + "ai_latency_ms": ai_response.latency_ms, + } + + async def _regenerate_from_mistakes( + self, + db: AsyncSession, + config: ExamGeneratorConfig + ) -> Dict[str, Any]: + """ + 错题重出模式 + + 流程: + 1. 构建错题重出提示词 + 2. 调用 AI 生成新题 + 3. 解析并返回结果 + """ + logger.info("进入错题重出模式") + + # 1. 构建提示词 + system_prompt = MISTAKE_REGEN_SYSTEM_PROMPT.format( + difficulty_level=config.difficulty_level, + ) + + user_prompt = MISTAKE_REGEN_USER_PROMPT.format( + mistake_records=config.mistake_records, + ) + + # 2. 调用 AI 生成 + ai_response = await self._call_ai_generate(system_prompt, user_prompt) + + logger.info( + f"错题重出完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 3. 解析题目 + questions = self._parse_questions(ai_response.content) + + logger.info(f"错题重出解析成功,数量: {len(questions)}") + + return { + "success": True, + "questions": questions, + "total_count": len(questions), + "mode": "mistake_regen", + "ai_provider": ai_response.provider, + "ai_model": ai_response.model, + "ai_tokens": ai_response.total_tokens, + "ai_latency_ms": ai_response.latency_ms, + } + + async def _query_position( + self, + db: AsyncSession, + position_id: int + ) -> Optional[Dict[str, Any]]: + """ + 查询岗位信息 + + SQL:SELECT id, name, description, skills, level FROM positions + WHERE id = :id AND is_deleted = FALSE + """ + try: + result = await db.execute( + text(""" + SELECT id, name, description, skills, level + FROM positions + WHERE id = :position_id AND is_deleted = FALSE + """), + {"position_id": position_id} + ) + row = result.fetchone() + + if not row: + return None + + # 将 Row 转换为字典 + return { + "id": row[0], + "name": row[1], + "description": row[2], + "skills": row[3], # JSON 字段 + "level": row[4], + } + + except Exception as e: + logger.error(f"查询岗位信息失败: {e}") + raise ExternalServiceError(f"查询岗位信息失败: {e}") + + async def _query_knowledge_points( + self, + db: AsyncSession, + course_id: int, + limit: int + ) -> List[Dict[str, Any]]: + """ + 随机查询知识点 + + SQL:SELECT kp.id, kp.name, kp.description, kp.topic_relation + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = FALSE + AND cm.is_deleted = FALSE + ORDER BY RAND() + LIMIT :limit + """ + try: + result = await db.execute( + text(""" + SELECT kp.id, kp.name, kp.description, kp.topic_relation + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = FALSE + AND cm.is_deleted = FALSE + ORDER BY RAND() + LIMIT :limit + """), + {"course_id": course_id, "limit": limit} + ) + rows = result.fetchall() + + return [ + { + "id": row[0], + "name": row[1], + "description": row[2], + "topic_relation": row[3], + } + for row in rows + ] + + except Exception as e: + logger.error(f"查询知识点失败: {e}") + raise ExternalServiceError(f"查询知识点失败: {e}") + + async def _call_ai_generate( + self, + system_prompt: str, + user_prompt: str + ) -> AIResponse: + """调用 AI 生成题目""" + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt} + ] + + response = await self.ai_service.chat( + messages=messages, + temperature=0.7, # 适当的创造性 + prompt_name="exam_generator" + ) + + return response + + def _parse_questions(self, ai_output: str) -> List[Dict[str, Any]]: + """ + 解析 AI 输出的题目 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + questions = parse_with_fallback( + cleaned_output, + schema=QUESTION_SCHEMA, + default=[], + validate_schema=True, + on_error="default" + ) + + # 后处理:确保每个题目有必要字段 + processed_questions = [] + for i, q in enumerate(questions): + if isinstance(q, dict): + # 确保有 num 字段 + if "num" not in q: + q["num"] = i + 1 + + # 确保 num 是整数 + try: + q["num"] = int(q["num"]) + except (ValueError, TypeError): + q["num"] = i + 1 + + # 确保有 type 字段 + if "type" not in q: + # 根据是否有 options 推断类型 + if q.get("topic", {}).get("options"): + q["type"] = "single_choice" + else: + q["type"] = "essay" + + # 确保 knowledge_point_id 是整数或 None + kp_id = q.get("knowledge_point_id") + if kp_id is not None: + try: + q["knowledge_point_id"] = int(kp_id) + except (ValueError, TypeError): + q["knowledge_point_id"] = None + + # 验证必要字段 + if q.get("topic") and q.get("correct"): + processed_questions.append(q) + else: + logger.warning(f"题目缺少必要字段,已跳过: {q}") + + if not processed_questions: + logger.warning("未能解析出有效的题目") + + return processed_questions + + def _format_position_info(self, position: Dict[str, Any]) -> str: + """格式化岗位信息为文本""" + lines = [ + f"岗位名称: {position.get('name', '未知')}", + f"岗位等级: {position.get('level', '未设置')}", + ] + + if position.get('description'): + lines.append(f"岗位描述: {position['description']}") + + skills = position.get('skills') + if skills: + # skills 可能是 JSON 字符串或列表 + if isinstance(skills, str): + try: + skills = json.loads(skills) + except json.JSONDecodeError: + skills = [skills] + + if isinstance(skills, list) and skills: + lines.append(f"核心技能: {', '.join(str(s) for s in skills)}") + + return '\n'.join(lines) + + def _format_knowledge_points(self, knowledge_points: List[Dict[str, Any]]) -> str: + """格式化知识点列表为文本""" + lines = [] + for kp in knowledge_points: + kp_text = f"【知识点 ID: {kp['id']}】{kp['name']}" + if kp.get('description'): + kp_text += f"\n{kp['description']}" + if kp.get('topic_relation'): + kp_text += f"\n关系描述: {kp['topic_relation']}" + lines.append(kp_text) + + return '\n\n'.join(lines) + + +# 创建全局实例 +exam_generator_service = ExamGeneratorService() + + +# ==================== 便捷函数 ==================== + +async def generate_exam( + db: AsyncSession, + course_id: int, + position_id: int, + single_choice_count: int = 4, + multiple_choice_count: int = 2, + true_false_count: int = 1, + fill_blank_count: int = 2, + essay_count: int = 1, + difficulty_level: int = 3, + mistake_records: str = "" +) -> Dict[str, Any]: + """ + 便捷函数:生成考试题目 + + Args: + db: 数据库会话 + course_id: 课程ID + position_id: 岗位ID + single_choice_count: 单选题数量 + multiple_choice_count: 多选题数量 + true_false_count: 判断题数量 + fill_blank_count: 填空题数量 + essay_count: 问答题数量 + difficulty_level: 难度等级(1-5) + mistake_records: 错题记录JSON字符串 + + Returns: + 生成结果 + """ + config = ExamGeneratorConfig( + course_id=course_id, + position_id=position_id, + single_choice_count=single_choice_count, + multiple_choice_count=multiple_choice_count, + true_false_count=true_false_count, + fill_blank_count=fill_blank_count, + essay_count=essay_count, + difficulty_level=difficulty_level, + mistake_records=mistake_records, + ) + + return await exam_generator_service.generate_exam(db, config) + + + + + + + + + diff --git a/backend/app/services/ai/knowledge_analysis_v2.py b/backend/app/services/ai/knowledge_analysis_v2.py new file mode 100644 index 0000000..9f4d6c0 --- /dev/null +++ b/backend/app/services/ai/knowledge_analysis_v2.py @@ -0,0 +1,548 @@ +""" +知识点分析服务 V2 - Python 原生实现 + +功能: +- 读取文档内容(PDF/Word/TXT) +- 调用 AI 分析提取知识点 +- 解析 JSON 结果 +- 写入数据库 + +提供稳定可靠的知识点分析能力。 +""" + +import logging +import os +from pathlib import Path +from typing import Any, Dict, List, Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.config import settings +from app.core.exceptions import ExternalServiceError +from app.schemas.course import KnowledgePointCreate + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.knowledge_analysis_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + KNOWLEDGE_POINT_SCHEMA, + DEFAULT_KNOWLEDGE_TYPE, +) + +logger = logging.getLogger(__name__) + +# 配置常量 +STATIC_UPLOADS_PREFIX = '/static/uploads/' +MAX_CONTENT_LENGTH = 100000 # 最大文档内容长度(字符) +MAX_KNOWLEDGE_POINTS = 20 # 最大知识点数量 + + +class KnowledgeAnalysisServiceV2: + """ + 知识点分析服务 V2 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = KnowledgeAnalysisServiceV2() + result = await service.analyze_course_material( + db=db_session, + course_id=1, + material_id=10, + file_url="/static/uploads/courses/1/doc.pdf", + course_title="医美产品知识", + user_id=1 + ) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="knowledge_analysis") + self.upload_path = getattr(settings, 'UPLOAD_PATH', 'uploads') + + async def analyze_course_material( + self, + db: AsyncSession, + course_id: int, + material_id: int, + file_url: str, + course_title: str, + user_id: int + ) -> Dict[str, Any]: + """ + 分析课程资料并提取知识点 + + Args: + db: 数据库会话 + course_id: 课程ID + material_id: 资料ID + file_url: 文件URL(相对路径) + course_title: 课程标题 + user_id: 用户ID + + Returns: + 分析结果,包含 success、knowledge_points_count 等字段 + """ + try: + logger.info( + f"开始知识点分析 V2 - course_id: {course_id}, material_id: {material_id}, " + f"file_url: {file_url}" + ) + + # 1. 解析文件路径 + file_path = self._resolve_file_path(file_url) + if not file_path.exists(): + raise FileNotFoundError(f"文件不存在: {file_path}") + + logger.info(f"文件路径解析成功: {file_path}") + + # 2. 提取文档内容 + content = await self._extract_document_content(file_path) + if not content or not content.strip(): + raise ValueError("文档内容为空") + + logger.info(f"文档内容提取成功,长度: {len(content)} 字符") + + # 3. 调用 AI 分析 + ai_response = await self._call_ai_analysis(content, course_title) + + logger.info( + f"AI 分析完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 4. 解析 JSON 结果 + knowledge_points = self._parse_knowledge_points(ai_response.content) + + logger.info(f"知识点解析成功,数量: {len(knowledge_points)}") + + # 5. 删除旧的知识点 + await self._delete_old_knowledge_points(db, material_id) + + # 6. 保存到数据库 + saved_count = await self._save_knowledge_points( + db=db, + course_id=course_id, + material_id=material_id, + knowledge_points=knowledge_points, + user_id=user_id + ) + + logger.info( + f"知识点分析完成 - course_id: {course_id}, material_id: {material_id}, " + f"saved_count: {saved_count}" + ) + + return { + "success": True, + "status": "completed", + "knowledge_points_count": saved_count, + "ai_provider": ai_response.provider, + "ai_model": ai_response.model, + "ai_tokens": ai_response.total_tokens, + "ai_latency_ms": ai_response.latency_ms, + } + + except FileNotFoundError as e: + logger.error(f"文件不存在: {e}") + raise ExternalServiceError(f"分析文件不存在: {e}") + except ValueError as e: + logger.error(f"参数错误: {e}") + raise ExternalServiceError(f"分析参数错误: {e}") + except Exception as e: + logger.error( + f"知识点分析失败 - course_id: {course_id}, material_id: {material_id}, " + f"error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"知识点分析失败: {e}") + + def _resolve_file_path(self, file_url: str) -> Path: + """解析文件 URL 为本地路径""" + if file_url.startswith(STATIC_UPLOADS_PREFIX): + relative_path = file_url.replace(STATIC_UPLOADS_PREFIX, '') + return Path(self.upload_path) / relative_path + elif file_url.startswith('/'): + # 绝对路径 + return Path(file_url) + else: + # 相对路径 + return Path(self.upload_path) / file_url + + async def _extract_document_content(self, file_path: Path) -> str: + """ + 提取文档内容 + + 支持:PDF、Word(docx)、文本文件 + """ + suffix = file_path.suffix.lower() + + try: + if suffix == '.pdf': + return await self._extract_pdf_content(file_path) + elif suffix in ['.docx', '.doc']: + return await self._extract_docx_content(file_path) + elif suffix in ['.txt', '.md', '.text']: + return await self._extract_text_content(file_path) + else: + # 尝试作为文本读取 + return await self._extract_text_content(file_path) + except Exception as e: + logger.error(f"文档内容提取失败: {file_path}, error: {e}") + raise ValueError(f"无法读取文档内容: {e}") + + async def _extract_pdf_content(self, file_path: Path) -> str: + """提取 PDF 内容""" + try: + from PyPDF2 import PdfReader + + reader = PdfReader(str(file_path)) + text_parts = [] + + for page in reader.pages: + text = page.extract_text() + if text: + text_parts.append(text) + + content = '\n'.join(text_parts) + + # 清理和截断 + content = self._clean_content(content) + + return content + + except ImportError: + logger.error("PyPDF2 未安装,无法读取 PDF") + raise ValueError("服务器未安装 PDF 读取组件") + except Exception as e: + logger.error(f"PDF 读取失败: {e}") + raise ValueError(f"PDF 读取失败: {e}") + + async def _extract_docx_content(self, file_path: Path) -> str: + """提取 Word 文档内容""" + try: + from docx import Document + + doc = Document(str(file_path)) + text_parts = [] + + for para in doc.paragraphs: + if para.text.strip(): + text_parts.append(para.text) + + # 也提取表格内容 + for table in doc.tables: + for row in table.rows: + for cell in row.cells: + if cell.text.strip(): + text_parts.append(cell.text) + + content = '\n'.join(text_parts) + content = self._clean_content(content) + + return content + + except ImportError: + logger.error("python-docx 未安装,无法读取 Word 文档") + raise ValueError("服务器未安装 Word 读取组件") + except Exception as e: + logger.error(f"Word 文档读取失败: {e}") + raise ValueError(f"Word 文档读取失败: {e}") + + async def _extract_text_content(self, file_path: Path) -> str: + """提取文本文件内容""" + try: + # 尝试多种编码 + encodings = ['utf-8', 'gbk', 'gb2312', 'latin-1'] + + for encoding in encodings: + try: + with open(file_path, 'r', encoding=encoding) as f: + content = f.read() + return self._clean_content(content) + except UnicodeDecodeError: + continue + + raise ValueError("无法识别文件编码") + + except Exception as e: + logger.error(f"文本文件读取失败: {e}") + raise ValueError(f"文本文件读取失败: {e}") + + def _clean_content(self, content: str) -> str: + """清理和截断内容""" + # 移除多余空白 + import re + content = re.sub(r'\n{3,}', '\n\n', content) + content = re.sub(r' {2,}', ' ', content) + + # 截断过长内容 + if len(content) > MAX_CONTENT_LENGTH: + logger.warning(f"文档内容过长,截断至 {MAX_CONTENT_LENGTH} 字符") + content = content[:MAX_CONTENT_LENGTH] + "\n\n[内容已截断...]" + + return content.strip() + + async def _call_ai_analysis( + self, + content: str, + course_title: str + ) -> AIResponse: + """调用 AI 进行知识点分析""" + # 构建消息 + user_message = USER_PROMPT.format( + course_name=course_title, + content=content + ) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + # 调用 AI + response = await self.ai_service.chat( + messages=messages, + temperature=0.1, # 低温度,保持输出稳定 + prompt_name="knowledge_analysis" + ) + + return response + + def _parse_knowledge_points(self, ai_output: str) -> List[Dict[str, Any]]: + """ + 解析 AI 输出的知识点 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + knowledge_points = parse_with_fallback( + cleaned_output, + schema=KNOWLEDGE_POINT_SCHEMA, + default=[], + validate_schema=True, + on_error="default" + ) + + # 后处理:确保每个知识点有必要字段 + processed_points = [] + for i, kp in enumerate(knowledge_points): + if i >= MAX_KNOWLEDGE_POINTS: + logger.warning(f"知识点数量超过限制 {MAX_KNOWLEDGE_POINTS},截断") + break + + if isinstance(kp, dict): + # 提取字段(兼容多种字段名) + title = ( + kp.get('title') or + kp.get('name') or + kp.get('知识点名称') or + f"知识点 {i + 1}" + ) + content = ( + kp.get('content') or + kp.get('description') or + kp.get('知识点描述') or + '' + ) + kp_type = ( + kp.get('type') or + kp.get('知识点类型') or + DEFAULT_KNOWLEDGE_TYPE + ) + topic_relation = ( + kp.get('topic_relation') or + kp.get('关系描述') or + '' + ) + + if title and (content or topic_relation): + processed_points.append({ + 'title': title[:200], # 限制长度 + 'content': content, + 'type': kp_type, + 'topic_relation': topic_relation, + }) + + if not processed_points: + logger.warning("未能解析出有效的知识点") + + return processed_points + + async def _delete_old_knowledge_points( + self, + db: AsyncSession, + material_id: int + ) -> int: + """删除资料关联的旧知识点""" + try: + from sqlalchemy import text + + result = await db.execute( + text("DELETE FROM knowledge_points WHERE material_id = :material_id"), + {"material_id": material_id} + ) + await db.commit() + + deleted_count = result.rowcount + if deleted_count > 0: + logger.info(f"已删除旧知识点: material_id={material_id}, count={deleted_count}") + + return deleted_count + + except Exception as e: + logger.error(f"删除旧知识点失败: {e}") + await db.rollback() + raise + + async def _save_knowledge_points( + self, + db: AsyncSession, + course_id: int, + material_id: int, + knowledge_points: List[Dict[str, Any]], + user_id: int + ) -> int: + """保存知识点到数据库""" + from app.services.course_service import knowledge_point_service + + saved_count = 0 + + for kp_data in knowledge_points: + try: + kp_create = KnowledgePointCreate( + name=kp_data['title'], + description=kp_data.get('content', ''), + type=kp_data.get('type', DEFAULT_KNOWLEDGE_TYPE), + source=1, # AI 分析来源 + topic_relation=kp_data.get('topic_relation'), + material_id=material_id + ) + + await knowledge_point_service.create_knowledge_point( + db=db, + course_id=course_id, + point_in=kp_create, + created_by=user_id + ) + saved_count += 1 + + except Exception as e: + logger.warning( + f"保存单个知识点失败: title={kp_data.get('title')}, error={e}" + ) + continue + + return saved_count + + async def reanalyze_course_materials( + self, + db: AsyncSession, + course_id: int, + course_title: str, + user_id: int + ) -> Dict[str, Any]: + """ + 重新分析课程的所有资料 + + Args: + db: 数据库会话 + course_id: 课程ID + course_title: 课程标题 + user_id: 用户ID + + Returns: + 分析结果汇总 + """ + try: + from app.services.course_service import course_service + + # 获取课程的所有资料 + materials = await course_service.get_course_materials(db, course_id=course_id) + + if not materials: + return { + "success": True, + "message": "该课程暂无资料需要分析", + "materials_count": 0, + "knowledge_points_count": 0 + } + + total_knowledge_points = 0 + analysis_results = [] + + for material in materials: + try: + result = await self.analyze_course_material( + db=db, + course_id=course_id, + material_id=material.id, + file_url=material.file_url, + course_title=course_title, + user_id=user_id + ) + + kp_count = result.get('knowledge_points_count', 0) + total_knowledge_points += kp_count + + analysis_results.append({ + "material_id": material.id, + "material_name": material.name, + "success": True, + "knowledge_points_count": kp_count + }) + + except Exception as e: + logger.error( + f"资料分析失败: material_id={material.id}, error={e}" + ) + analysis_results.append({ + "material_id": material.id, + "material_name": material.name, + "success": False, + "error": str(e) + }) + + success_count = sum(1 for r in analysis_results if r['success']) + + logger.info( + f"课程资料重新分析完成 - course_id: {course_id}, " + f"materials: {len(materials)}, success: {success_count}, " + f"total_knowledge_points: {total_knowledge_points}" + ) + + return { + "success": True, + "materials_count": len(materials), + "success_count": success_count, + "knowledge_points_count": total_knowledge_points, + "analysis_results": analysis_results + } + + except Exception as e: + logger.error( + f"课程资料重新分析失败 - course_id: {course_id}, error: {e}", + exc_info=True + ) + raise ExternalServiceError(f"重新分析失败: {e}") + + +# 创建全局实例 +knowledge_analysis_service_v2 = KnowledgeAnalysisServiceV2() + + + + + + + + + diff --git a/backend/app/services/ai/llm_json_parser.py b/backend/app/services/ai/llm_json_parser.py new file mode 100644 index 0000000..24b4264 --- /dev/null +++ b/backend/app/services/ai/llm_json_parser.py @@ -0,0 +1,707 @@ +""" +LLM JSON Parser - 大模型 JSON 输出解析器 + +功能: +- 使用 json-repair 库修复 AI 输出的 JSON +- 处理中文标点、尾部逗号、Python 风格等问题 +- Schema 校验确保数据完整性 + +使用示例: +```python +from app.services.ai.llm_json_parser import parse_llm_json, parse_with_fallback + +# 简单解析 +result = parse_llm_json(ai_response) + +# 带 Schema 校验和默认值 +result = parse_with_fallback( + ai_response, + schema=MY_SCHEMA, + default=[] +) +``` +""" + +import json +import re +import logging +from typing import Any, Dict, List, Optional, Tuple, Union +from dataclasses import dataclass, field + +logger = logging.getLogger(__name__) + +# 尝试导入 json-repair +try: + from json_repair import loads as json_repair_loads + from json_repair import repair_json + HAS_JSON_REPAIR = True +except ImportError: + HAS_JSON_REPAIR = False + logger.warning("json-repair 未安装,将使用内置修复逻辑") + +# 尝试导入 jsonschema +try: + from jsonschema import validate, ValidationError, Draft7Validator + HAS_JSONSCHEMA = True +except ImportError: + HAS_JSONSCHEMA = False + logger.warning("jsonschema 未安装,将跳过 Schema 校验") + + +# ==================== 异常类 ==================== + +class JSONParseError(Exception): + """JSON 解析错误基类""" + def __init__(self, message: str, raw_text: str = "", issues: List[dict] = None): + super().__init__(message) + self.raw_text = raw_text + self.issues = issues or [] + + +class JSONUnrecoverableError(JSONParseError): + """不可恢复的 JSON 错误""" + pass + + +# ==================== 解析结果 ==================== + +@dataclass +class ParseResult: + """解析结果""" + success: bool + data: Any = None + method: str = "" # direct / json_repair / preprocessed / fixed / completed / default + issues: List[dict] = field(default_factory=list) + raw_text: str = "" + error: str = "" + + +# ==================== 核心解析函数 ==================== + +def parse_llm_json( + text: str, + *, + strict: bool = False, + return_result: bool = False +) -> Union[Any, ParseResult]: + """ + 智能解析 LLM 输出的 JSON + + Args: + text: 原始文本 + strict: 严格模式,不进行自动修复 + return_result: 返回 ParseResult 对象而非直接数据 + + Returns: + 解析后的 JSON 对象,或 ParseResult(如果 return_result=True) + + Raises: + JSONUnrecoverableError: 所有修复尝试都失败 + """ + if not text or not text.strip(): + if return_result: + return ParseResult(success=False, error="Empty input") + raise JSONUnrecoverableError("Empty input", text) + + text = text.strip() + issues = [] + + # 第一层:直接解析 + try: + data = json.loads(text) + result = ParseResult(success=True, data=data, method="direct", raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + if strict: + if return_result: + return ParseResult(success=False, error="Strict mode: direct parse failed", raw_text=text) + raise JSONUnrecoverableError("Strict mode: direct parse failed", text) + + # 第二层:使用 json-repair(推荐) + if HAS_JSON_REPAIR: + try: + data = json_repair_loads(text) + issues.append({"type": "json_repair", "action": "Auto-repaired by json-repair library"}) + result = ParseResult(success=True, data=data, method="json_repair", issues=issues, raw_text=text) + return result if return_result else data + except Exception as e: + logger.debug(f"json-repair 修复失败: {e}") + + # 第三层:预处理(提取代码块、清理文字) + preprocessed = _preprocess_text(text) + if preprocessed != text: + try: + data = json.loads(preprocessed) + issues.append({"type": "preprocessed", "action": "Extracted JSON from text"}) + result = ParseResult(success=True, data=data, method="preprocessed", issues=issues, raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + # 再次尝试 json-repair + if HAS_JSON_REPAIR: + try: + data = json_repair_loads(preprocessed) + issues.append({"type": "json_repair_preprocessed", "action": "Repaired after preprocessing"}) + result = ParseResult(success=True, data=data, method="json_repair", issues=issues, raw_text=text) + return result if return_result else data + except Exception: + pass + + # 第四层:自动修复 + fixed, fix_issues = _fix_json_format(preprocessed) + issues.extend(fix_issues) + + if fixed != preprocessed: + try: + data = json.loads(fixed) + result = ParseResult(success=True, data=data, method="fixed", issues=issues, raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + # 第五层:尝试补全截断的 JSON + completed = _try_complete_json(fixed) + if completed: + try: + data = json.loads(completed) + issues.append({"type": "completed", "action": "Auto-completed truncated JSON"}) + result = ParseResult(success=True, data=data, method="completed", issues=issues, raw_text=text) + return result if return_result else data + except json.JSONDecodeError: + pass + + # 所有尝试都失败 + diagnosis = diagnose_json_error(fixed) + if return_result: + return ParseResult( + success=False, + method="failed", + issues=issues + diagnosis.get("issues", []), + raw_text=text, + error=f"All parse attempts failed. Issues: {diagnosis}" + ) + raise JSONUnrecoverableError(f"All parse attempts failed: {diagnosis}", text, issues) + + +def parse_with_fallback( + raw_text: str, + schema: dict = None, + default: Any = None, + *, + validate_schema: bool = True, + on_error: str = "default" # "default" / "raise" / "none" +) -> Any: + """ + 带兜底的 JSON 解析 + + Args: + raw_text: 原始文本 + schema: JSON Schema(可选) + default: 默认值 + validate_schema: 是否进行 Schema 校验 + on_error: 错误处理方式 + + Returns: + 解析后的数据或默认值 + """ + try: + result = parse_llm_json(raw_text, return_result=True) + + if not result.success: + logger.warning(f"JSON 解析失败: {result.error}") + if on_error == "raise": + raise JSONUnrecoverableError(result.error, raw_text, result.issues) + elif on_error == "none": + return None + return default + + data = result.data + + # Schema 校验 + if validate_schema and schema and HAS_JSONSCHEMA: + is_valid, errors = validate_json_schema(data, schema) + if not is_valid: + logger.warning(f"Schema 校验失败: {errors}") + if on_error == "raise": + raise JSONUnrecoverableError(f"Schema validation failed: {errors}", raw_text) + elif on_error == "none": + return None + return default + + # 记录解析方法 + if result.method != "direct": + logger.info(f"JSON 解析成功: method={result.method}, issues={result.issues}") + + return data + + except Exception as e: + logger.error(f"JSON 解析异常: {e}") + if on_error == "raise": + raise + elif on_error == "none": + return None + return default + + +# ==================== 预处理函数 ==================== + +def _preprocess_text(text: str) -> str: + """预处理文本:提取代码块、清理前后文字""" + # 移除 BOM + text = text.lstrip('\ufeff') + + # 移除零宽字符 + text = re.sub(r'[\u200b\u200c\u200d\ufeff]', '', text) + + # 提取 Markdown 代码块 + patterns = [ + r'```json\s*([\s\S]*?)\s*```', + r'```\s*([\s\S]*?)\s*```', + r'`([^`]+)`', + ] + + for pattern in patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + extracted = match.group(1).strip() + if extracted.startswith(('{', '[')): + text = extracted + break + + # 找到 JSON 边界 + text = _find_json_boundaries(text) + + return text.strip() + + +def _find_json_boundaries(text: str) -> str: + """找到 JSON 的起止位置""" + # 找第一个 { 或 [ + start = -1 + for i, c in enumerate(text): + if c in '{[': + start = i + break + + if start == -1: + return text + + # 找最后一个匹配的 } 或 ] + depth = 0 + end = -1 + in_string = False + escape = False + + for i in range(start, len(text)): + c = text[i] + + if escape: + escape = False + continue + + if c == '\\': + escape = True + continue + + if c == '"': + in_string = not in_string + continue + + if in_string: + continue + + if c in '{[': + depth += 1 + elif c in '}]': + depth -= 1 + if depth == 0: + end = i + 1 + break + + if end == -1: + # 找最后一个 } 或 ] + for i in range(len(text) - 1, start, -1): + if text[i] in '}]': + end = i + 1 + break + + if end > start: + return text[start:end] + + return text[start:] + + +# ==================== 修复函数 ==================== + +def _fix_json_format(text: str) -> Tuple[str, List[dict]]: + """修复常见 JSON 格式问题""" + issues = [] + + # 1. 中文标点转英文 + cn_punctuation = { + ',': ',', '。': '.', ':': ':', ';': ';', + '"': '"', '"': '"', ''': "'", ''': "'", + '【': '[', '】': ']', '(': '(', ')': ')', + '{': '{', '}': '}', + } + for cn, en in cn_punctuation.items(): + if cn in text: + text = text.replace(cn, en) + issues.append({"type": "chinese_punctuation", "from": cn, "to": en}) + + # 2. 移除注释 + if '//' in text: + text = re.sub(r'//[^\n]*', '', text) + issues.append({"type": "removed_comments", "style": "single-line"}) + + if '/*' in text: + text = re.sub(r'/\*[\s\S]*?\*/', '', text) + issues.append({"type": "removed_comments", "style": "multi-line"}) + + # 3. Python 风格转 JSON + python_replacements = [ + (r'\bTrue\b', 'true'), + (r'\bFalse\b', 'false'), + (r'\bNone\b', 'null'), + ] + for pattern, replacement in python_replacements: + if re.search(pattern, text): + text = re.sub(pattern, replacement, text) + issues.append({"type": "python_style", "from": pattern, "to": replacement}) + + # 4. 移除尾部逗号 + trailing_comma_patterns = [ + (r',(\s*})', r'\1'), + (r',(\s*\])', r'\1'), + ] + for pattern, replacement in trailing_comma_patterns: + if re.search(pattern, text): + text = re.sub(pattern, replacement, text) + issues.append({"type": "trailing_comma", "action": "removed"}) + + # 5. 修复单引号(谨慎处理) + if text.count("'") > text.count('"') and re.match(r"^\s*\{?\s*'", text): + text = re.sub(r"'([^']*)'(\s*:)", r'"\1"\2', text) + text = re.sub(r":\s*'([^']*)'", r': "\1"', text) + issues.append({"type": "single_quotes", "action": "replaced"}) + + return text, issues + + +def _try_complete_json(text: str) -> Optional[str]: + """尝试补全截断的 JSON""" + if not text: + return None + + # 统计括号 + stack = [] + in_string = False + escape = False + + for c in text: + if escape: + escape = False + continue + + if c == '\\': + escape = True + continue + + if c == '"': + in_string = not in_string + continue + + if in_string: + continue + + if c in '{[': + stack.append(c) + elif c == '}': + if stack and stack[-1] == '{': + stack.pop() + elif c == ']': + if stack and stack[-1] == '[': + stack.pop() + + if not stack: + return None # 已经平衡了 + + # 如果在字符串中,先闭合字符串 + if in_string: + text += '"' + + # 补全括号 + completion = "" + for bracket in reversed(stack): + if bracket == '{': + completion += '}' + elif bracket == '[': + completion += ']' + + return text + completion + + +# ==================== Schema 校验 ==================== + +def validate_json_schema(data: Any, schema: dict) -> Tuple[bool, List[dict]]: + """ + 校验 JSON 是否符合 Schema + + Returns: + (is_valid, errors) + """ + if not HAS_JSONSCHEMA: + logger.warning("jsonschema 未安装,跳过校验") + return True, [] + + try: + validator = Draft7Validator(schema) + errors = list(validator.iter_errors(data)) + + if errors: + error_messages = [ + { + "path": list(e.absolute_path), + "message": e.message, + "validator": e.validator + } + for e in errors + ] + return False, error_messages + + return True, [] + + except Exception as e: + return False, [{"message": str(e)}] + + +# ==================== 诊断函数 ==================== + +def diagnose_json_error(text: str) -> dict: + """诊断 JSON 错误""" + issues = [] + + # 检查是否为空 + if not text or not text.strip(): + issues.append({ + "type": "empty_input", + "severity": "critical", + "suggestion": "输入为空" + }) + return {"issues": issues, "fixable": False} + + # 检查中文标点 + cn_punctuation = [',', '。', ':', ';', '"', '"', ''', '''] + for p in cn_punctuation: + if p in text: + issues.append({ + "type": "chinese_punctuation", + "char": p, + "severity": "low", + "suggestion": f"将 {p} 替换为对应英文标点" + }) + + # 检查代码块包裹 + if '```' in text: + issues.append({ + "type": "markdown_wrapped", + "severity": "low", + "suggestion": "需要提取代码块内容" + }) + + # 检查注释 + if '//' in text or '/*' in text: + issues.append({ + "type": "has_comments", + "severity": "low", + "suggestion": "需要移除注释" + }) + + # 检查 Python 风格 + if re.search(r'\b(True|False|None)\b', text): + issues.append({ + "type": "python_style", + "severity": "low", + "suggestion": "将 True/False/None 转为 true/false/null" + }) + + # 检查尾部逗号 + if re.search(r',\s*[}\]]', text): + issues.append({ + "type": "trailing_comma", + "severity": "low", + "suggestion": "移除 } 或 ] 前的逗号" + }) + + # 检查括号平衡 + open_braces = text.count('{') - text.count('}') + open_brackets = text.count('[') - text.count(']') + + if open_braces > 0: + issues.append({ + "type": "unclosed_brace", + "count": open_braces, + "severity": "medium", + "suggestion": f"缺少 {open_braces} 个 }}" + }) + elif open_braces < 0: + issues.append({ + "type": "extra_brace", + "count": -open_braces, + "severity": "medium", + "suggestion": f"多余 {-open_braces} 个 }}" + }) + + if open_brackets > 0: + issues.append({ + "type": "unclosed_bracket", + "count": open_brackets, + "severity": "medium", + "suggestion": f"缺少 {open_brackets} 个 ]" + }) + elif open_brackets < 0: + issues.append({ + "type": "extra_bracket", + "count": -open_brackets, + "severity": "medium", + "suggestion": f"多余 {-open_brackets} 个 ]" + }) + + # 检查引号平衡 + quote_count = text.count('"') + if quote_count % 2 != 0: + issues.append({ + "type": "unbalanced_quotes", + "severity": "high", + "suggestion": "引号数量不平衡,可能有未闭合的字符串" + }) + + # 判断是否可修复 + fixable_types = { + "chinese_punctuation", "markdown_wrapped", "has_comments", + "python_style", "trailing_comma", "unclosed_brace", "unclosed_bracket" + } + fixable = all(i["type"] in fixable_types for i in issues) + + return { + "issues": issues, + "issue_count": len(issues), + "fixable": fixable, + "severity": max( + (i.get("severity", "low") for i in issues), + key=lambda x: {"low": 1, "medium": 2, "high": 3, "critical": 4}.get(x, 0), + default="low" + ) + } + + +# ==================== 便捷函数 ==================== + +def safe_json_loads(text: str, default: Any = None) -> Any: + """安全的 json.loads,失败返回默认值""" + try: + return parse_llm_json(text) + except Exception: + return default + + +def extract_json_from_text(text: str) -> Optional[str]: + """从文本中提取 JSON 字符串""" + preprocessed = _preprocess_text(text) + fixed, _ = _fix_json_format(preprocessed) + + try: + json.loads(fixed) + return fixed + except Exception: + completed = _try_complete_json(fixed) + if completed: + try: + json.loads(completed) + return completed + except Exception: + pass + + return None + + +def clean_llm_output(text: str) -> Tuple[str, List[str]]: + """ + 清洗大模型输出,返回清洗后的文本和应用的清洗规则 + + Args: + text: 原始输出文本 + + Returns: + (cleaned_text, applied_rules) + """ + if not text: + return "", ["empty_input"] + + applied_rules = [] + original = text + + # 1. 去除 BOM 头 + if text.startswith('\ufeff'): + text = text.lstrip('\ufeff') + applied_rules.append("removed_bom") + + # 2. 去除 ANSI 转义序列 + ansi_pattern = re.compile(r'\x1b\[[0-9;]*m') + if ansi_pattern.search(text): + text = ansi_pattern.sub('', text) + applied_rules.append("removed_ansi") + + # 3. 去除首尾空白 + text = text.strip() + + # 4. 去除开头的客套话 + polite_patterns = [ + r'^好的[,,。.]?\s*', + r'^当然[,,。.]?\s*', + r'^没问题[,,。.]?\s*', + r'^根据您的要求[,,。.]?\s*', + r'^以下是.*?[::]\s*', + r'^分析结果如下[::]\s*', + r'^我来为您.*?[::]\s*', + r'^这是.*?结果[::]\s*', + ] + for pattern in polite_patterns: + if re.match(pattern, text, re.IGNORECASE): + text = re.sub(pattern, '', text, flags=re.IGNORECASE) + applied_rules.append("removed_polite_prefix") + break + + # 5. 提取 Markdown JSON 代码块 + json_block_patterns = [ + r'```json\s*([\s\S]*?)\s*```', + r'```\s*([\s\S]*?)\s*```', + ] + for pattern in json_block_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + extracted = match.group(1).strip() + if extracted.startswith(('{', '[')): + text = extracted + applied_rules.append("extracted_code_block") + break + + # 6. 处理零宽字符 + zero_width = re.compile(r'[\u200b\u200c\u200d\ufeff]') + if zero_width.search(text): + text = zero_width.sub('', text) + applied_rules.append("removed_zero_width") + + return text.strip(), applied_rules + + + + + + + + + diff --git a/backend/app/services/ai/practice_analysis_service.py b/backend/app/services/ai/practice_analysis_service.py new file mode 100644 index 0000000..909c113 --- /dev/null +++ b/backend/app/services/ai/practice_analysis_service.py @@ -0,0 +1,377 @@ +""" +陪练分析报告服务 - Python 原生实现 + +功能: +- 分析陪练对话历史 +- 生成综合评分、能力维度评估 +- 提供对话标注和改进建议 + +提供稳定可靠的陪练分析报告生成能力。 +""" + +import json +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.practice_analysis_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + PRACTICE_ANALYSIS_SCHEMA, + SCORE_BREAKDOWN_ITEMS, + ABILITY_DIMENSIONS, +) + +logger = logging.getLogger(__name__) + + +# ==================== 数据结构 ==================== + +@dataclass +class ScoreBreakdownItem: + """分数细分项""" + name: str + score: float + description: str + + +@dataclass +class AbilityDimensionItem: + """能力维度项""" + name: str + score: float + feedback: str + + +@dataclass +class DialogueAnnotation: + """对话标注""" + sequence: int + tags: List[str] + comment: str + + +@dataclass +class Suggestion: + """改进建议""" + title: str + content: str + example: str + + +@dataclass +class PracticeAnalysisResult: + """陪练分析结果""" + success: bool + total_score: float = 0.0 + score_breakdown: List[ScoreBreakdownItem] = field(default_factory=list) + ability_dimensions: List[AbilityDimensionItem] = field(default_factory=list) + dialogue_annotations: List[DialogueAnnotation] = field(default_factory=list) + suggestions: List[Suggestion] = field(default_factory=list) + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + error: str = "" + + def to_dict(self) -> Dict[str, Any]: + """转换为字典(兼容原有数据格式)""" + return { + "analysis": { + "total_score": self.total_score, + "score_breakdown": [ + {"name": s.name, "score": s.score, "description": s.description} + for s in self.score_breakdown + ], + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in self.ability_dimensions + ], + "dialogue_annotations": [ + {"sequence": a.sequence, "tags": a.tags, "comment": a.comment} + for a in self.dialogue_annotations + ], + "suggestions": [ + {"title": s.title, "content": s.content, "example": s.example} + for s in self.suggestions + ], + }, + "ai_provider": self.ai_provider, + "ai_model": self.ai_model, + "ai_tokens": self.ai_tokens, + "ai_latency_ms": self.ai_latency_ms, + } + + def to_db_format(self) -> Dict[str, Any]: + """转换为数据库存储格式(兼容 PracticeReport 模型)""" + return { + "total_score": int(self.total_score), + "score_breakdown": [ + {"name": s.name, "score": s.score, "description": s.description} + for s in self.score_breakdown + ], + "ability_dimensions": [ + {"name": d.name, "score": d.score, "feedback": d.feedback} + for d in self.ability_dimensions + ], + "dialogue_review": [ + {"sequence": a.sequence, "tags": a.tags, "comment": a.comment} + for a in self.dialogue_annotations + ], + "suggestions": [ + {"title": s.title, "content": s.content, "example": s.example} + for s in self.suggestions + ], + } + + +# ==================== 服务类 ==================== + +class PracticeAnalysisService: + """ + 陪练分析报告服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = PracticeAnalysisService() + result = await service.analyze( + db=db_session, # 传入 db_session 用于记录调用日志 + dialogue_history=[ + {"speaker": "user", "content": "您好,我想咨询一下..."}, + {"speaker": "ai", "content": "您好!很高兴为您服务..."} + ] + ) + print(result.total_score) + print(result.suggestions) + ``` + """ + + MODULE_CODE = "practice_analysis" + + async def analyze( + self, + dialogue_history: List[Dict[str, Any]], + db: Any = None # 数据库会话,用于记录 AI 调用日志 + ) -> PracticeAnalysisResult: + """ + 分析陪练对话 + + Args: + dialogue_history: 对话历史列表,每项包含 speaker, content, timestamp 等字段 + db: 数据库会话,用于记录调用日志(符合 AI 接入规范) + + Returns: + PracticeAnalysisResult 分析结果 + """ + try: + logger.info(f"开始分析陪练对话 - 对话轮次: {len(dialogue_history)}") + + # 1. 验证输入 + if not dialogue_history or len(dialogue_history) < 2: + return PracticeAnalysisResult( + success=False, + error="对话记录太少,无法生成分析报告(至少需要2轮对话)" + ) + + # 2. 格式化对话历史 + dialogue_text = self._format_dialogue_history(dialogue_history) + + # 3. 创建 AIService 实例(传入 db_session 用于记录调用日志) + self._ai_service = AIService(module_code=self.MODULE_CODE, db_session=db) + + # 4. 调用 AI 分析 + ai_response = await self._call_ai_analysis(dialogue_text) + + logger.info( + f"AI 分析完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 4. 解析 JSON 结果 + analysis_data = self._parse_analysis_result(ai_response.content) + + # 5. 构建返回结果 + result = PracticeAnalysisResult( + success=True, + total_score=analysis_data.get("total_score", 0), + score_breakdown=[ + ScoreBreakdownItem( + name=s.get("name", ""), + score=s.get("score", 0), + description=s.get("description", "") + ) + for s in analysis_data.get("score_breakdown", []) + ], + ability_dimensions=[ + AbilityDimensionItem( + name=d.get("name", ""), + score=d.get("score", 0), + feedback=d.get("feedback", "") + ) + for d in analysis_data.get("ability_dimensions", []) + ], + dialogue_annotations=[ + DialogueAnnotation( + sequence=a.get("sequence", 0), + tags=a.get("tags", []), + comment=a.get("comment", "") + ) + for a in analysis_data.get("dialogue_annotations", []) + ], + suggestions=[ + Suggestion( + title=s.get("title", ""), + content=s.get("content", ""), + example=s.get("example", "") + ) + for s in analysis_data.get("suggestions", []) + ], + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + ) + + logger.info( + f"陪练分析完成 - total_score: {result.total_score}, " + f"annotations: {len(result.dialogue_annotations)}, " + f"suggestions: {len(result.suggestions)}" + ) + + return result + + except Exception as e: + logger.error(f"陪练分析失败: {e}", exc_info=True) + return PracticeAnalysisResult( + success=False, + error=str(e) + ) + + def _format_dialogue_history(self, dialogue_history: List[Dict[str, Any]]) -> str: + """ + 格式化对话历史为文本 + + Args: + dialogue_history: 对话历史列表 + + Returns: + 格式化后的对话文本 + """ + lines = [] + for i, d in enumerate(dialogue_history, 1): + speaker = d.get('speaker', 'unknown') + content = d.get('content', '') + + # 统一说话者标识 + if speaker in ['user', 'employee', 'consultant', '员工', '用户']: + speaker_label = '员工' + elif speaker in ['ai', 'customer', 'client', '顾客', '客户', 'AI']: + speaker_label = '顾客' + else: + speaker_label = speaker + + lines.append(f"[{i}] {speaker_label}: {content}") + + return '\n'.join(lines) + + async def _call_ai_analysis(self, dialogue_text: str) -> AIResponse: + """调用 AI 进行分析""" + user_message = USER_PROMPT.format(dialogue_history=dialogue_text) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + response = await self._ai_service.chat( + messages=messages, + temperature=0.7, + prompt_name="practice_analysis" + ) + + return response + + def _parse_analysis_result(self, ai_output: str) -> Dict[str, Any]: + """ + 解析 AI 输出的分析结果 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + parsed = parse_with_fallback( + cleaned_output, + schema=PRACTICE_ANALYSIS_SCHEMA, + default={"analysis": {}}, + validate_schema=True, + on_error="default" + ) + + # 提取 analysis 部分 + analysis = parsed.get("analysis", {}) + + # 确保 score_breakdown 完整 + existing_breakdown = {s.get("name") for s in analysis.get("score_breakdown", [])} + for item_name in SCORE_BREAKDOWN_ITEMS: + if item_name not in existing_breakdown: + logger.warning(f"缺少分数维度: {item_name},使用默认值") + analysis.setdefault("score_breakdown", []).append({ + "name": item_name, + "score": 75, + "description": "暂无详细评价" + }) + + # 确保 ability_dimensions 完整 + existing_dims = {d.get("name") for d in analysis.get("ability_dimensions", [])} + for dim_name in ABILITY_DIMENSIONS: + if dim_name not in existing_dims: + logger.warning(f"缺少能力维度: {dim_name},使用默认值") + analysis.setdefault("ability_dimensions", []).append({ + "name": dim_name, + "score": 75, + "feedback": "暂无详细评价" + }) + + # 确保有建议 + if not analysis.get("suggestions"): + analysis["suggestions"] = [ + { + "title": "持续练习", + "content": "建议继续进行陪练练习,提升整体表现", + "example": "每周进行2-3次陪练,针对薄弱环节重点练习" + } + ] + + return analysis + + +# ==================== 全局实例 ==================== + +practice_analysis_service = PracticeAnalysisService() + + +# ==================== 便捷函数 ==================== + +async def analyze_practice_session( + dialogue_history: List[Dict[str, Any]] +) -> Dict[str, Any]: + """ + 便捷函数:分析陪练会话 + + Args: + dialogue_history: 对话历史列表 + + Returns: + 分析结果字典(兼容原有格式) + """ + result = await practice_analysis_service.analyze(dialogue_history) + return result.to_dict() + diff --git a/backend/app/services/ai/practice_scene_service.py b/backend/app/services/ai/practice_scene_service.py new file mode 100644 index 0000000..86afa70 --- /dev/null +++ b/backend/app/services/ai/practice_scene_service.py @@ -0,0 +1,379 @@ +""" +陪练场景准备服务 - Python 原生实现 + +功能: +- 根据课程ID获取知识点 +- 调用 AI 生成陪练场景配置 +- 解析并返回结构化场景数据 + +提供稳定可靠的陪练场景提取能力。 +""" + +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ExternalServiceError + +from .ai_service import AIService, AIResponse +from .llm_json_parser import parse_with_fallback, clean_llm_output +from .prompts.practice_scene_prompts import ( + SYSTEM_PROMPT, + USER_PROMPT, + PRACTICE_SCENE_SCHEMA, + DEFAULT_SCENE_TYPE, + DEFAULT_DIFFICULTY, +) + +logger = logging.getLogger(__name__) + + +# ==================== 数据结构 ==================== + +@dataclass +class PracticeScene: + """陪练场景数据结构""" + name: str + description: str + background: str + ai_role: str + objectives: List[str] + keywords: List[str] + type: str = DEFAULT_SCENE_TYPE + difficulty: str = DEFAULT_DIFFICULTY + + +@dataclass +class PracticeSceneResult: + """陪练场景生成结果""" + success: bool + scene: Optional[PracticeScene] = None + raw_response: Dict[str, Any] = field(default_factory=dict) + ai_provider: str = "" + ai_model: str = "" + ai_tokens: int = 0 + ai_latency_ms: int = 0 + knowledge_points_count: int = 0 + error: str = "" + + +# ==================== 服务类 ==================== + +class PracticeSceneService: + """ + 陪练场景准备服务 + + 使用 Python 原生实现。 + + 使用示例: + ```python + service = PracticeSceneService() + result = await service.prepare_practice_knowledge( + db=db_session, + course_id=1 + ) + if result.success: + print(result.scene.name) + print(result.scene.objectives) + ``` + """ + + def __init__(self): + """初始化服务""" + self.ai_service = AIService(module_code="practice_scene") + + async def prepare_practice_knowledge( + self, + db: AsyncSession, + course_id: int + ) -> PracticeSceneResult: + """ + 准备陪练所需的知识内容并生成场景 + + 陪练知识准备的 Python 实现。 + + Args: + db: 数据库会话(支持多租户,由调用方传入对应租户的数据库连接) + course_id: 课程ID + + Returns: + PracticeSceneResult: 包含场景配置和元信息的结果对象 + """ + try: + logger.info(f"开始陪练知识准备 - course_id: {course_id}") + + # 1. 查询知识点 + knowledge_points = await self._fetch_knowledge_points(db, course_id) + + if not knowledge_points: + logger.warning(f"课程没有知识点 - course_id: {course_id}") + return PracticeSceneResult( + success=False, + error=f"课程 {course_id} 没有可用的知识点" + ) + + logger.info(f"获取到 {len(knowledge_points)} 个知识点 - course_id: {course_id}") + + # 2. 格式化知识点为文本 + knowledge_text = self._format_knowledge_points(knowledge_points) + + # 3. 调用 AI 生成场景 + ai_response = await self._call_ai_generation(knowledge_text) + + logger.info( + f"AI 生成完成 - provider: {ai_response.provider}, " + f"tokens: {ai_response.total_tokens}, latency: {ai_response.latency_ms}ms" + ) + + # 4. 解析 JSON 结果 + scene_data = self._parse_scene_response(ai_response.content) + + if not scene_data: + logger.error(f"场景解析失败 - course_id: {course_id}") + return PracticeSceneResult( + success=False, + raw_response={"ai_output": ai_response.content}, + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + knowledge_points_count=len(knowledge_points), + error="AI 输出解析失败" + ) + + # 5. 构建场景对象 + scene = self._build_scene_object(scene_data) + + logger.info( + f"陪练场景生成成功 - course_id: {course_id}, " + f"scene_name: {scene.name}, type: {scene.type}" + ) + + return PracticeSceneResult( + success=True, + scene=scene, + raw_response=scene_data, + ai_provider=ai_response.provider, + ai_model=ai_response.model, + ai_tokens=ai_response.total_tokens, + ai_latency_ms=ai_response.latency_ms, + knowledge_points_count=len(knowledge_points) + ) + + except Exception as e: + logger.error( + f"陪练知识准备失败 - course_id: {course_id}, error: {e}", + exc_info=True + ) + return PracticeSceneResult( + success=False, + error=str(e) + ) + + async def _fetch_knowledge_points( + self, + db: AsyncSession, + course_id: int + ) -> List[Dict[str, Any]]: + """ + 从数据库获取课程知识点 + + 获取课程知识点 + """ + # 知识点查询 SQL: + # SELECT kp.name, kp.description + # FROM knowledge_points kp + # INNER JOIN course_materials cm ON kp.material_id = cm.id + # WHERE kp.course_id = {course_id} + # AND kp.is_deleted = 0 + # AND cm.is_deleted = 0 + # ORDER BY kp.id; + + sql = text(""" + SELECT kp.name, kp.description + FROM knowledge_points kp + INNER JOIN course_materials cm ON kp.material_id = cm.id + WHERE kp.course_id = :course_id + AND kp.is_deleted = 0 + AND cm.is_deleted = 0 + ORDER BY kp.id + """) + + try: + result = await db.execute(sql, {"course_id": course_id}) + rows = result.fetchall() + + knowledge_points = [] + for row in rows: + knowledge_points.append({ + "name": row[0], + "description": row[1] or "" + }) + + return knowledge_points + + except Exception as e: + logger.error(f"查询知识点失败: {e}") + raise ExternalServiceError(f"数据库查询失败: {e}") + + def _format_knowledge_points(self, knowledge_points: List[Dict[str, Any]]) -> str: + """ + 将知识点列表格式化为文本 + + Args: + knowledge_points: 知识点列表 + + Returns: + 格式化后的文本 + """ + lines = [] + for i, kp in enumerate(knowledge_points, 1): + name = kp.get("name", "") + description = kp.get("description", "") + + if description: + lines.append(f"{i}. {name}\n {description}") + else: + lines.append(f"{i}. {name}") + + return "\n\n".join(lines) + + async def _call_ai_generation(self, knowledge_text: str) -> AIResponse: + """ + 调用 AI 生成陪练场景 + + Args: + knowledge_text: 格式化后的知识点文本 + + Returns: + AI 响应对象 + """ + # 构建用户消息 + user_message = USER_PROMPT.format(knowledge_points=knowledge_text) + + messages = [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": user_message} + ] + + # 调用 AI(自动降级:4sapi.com → OpenRouter) + response = await self.ai_service.chat( + messages=messages, + temperature=0.7, # 适中的创意性 + prompt_name="practice_scene_generation" + ) + + return response + + def _parse_scene_response(self, ai_output: str) -> Optional[Dict[str, Any]]: + """ + 解析 AI 输出的场景 JSON + + 使用 LLM JSON Parser 进行多层兜底解析 + + Args: + ai_output: AI 原始输出 + + Returns: + 解析后的字典,失败返回 None + """ + # 先清洗输出 + cleaned_output, rules = clean_llm_output(ai_output) + if rules: + logger.debug(f"AI 输出已清洗: {rules}") + + # 使用带 Schema 校验的解析 + result = parse_with_fallback( + cleaned_output, + schema=PRACTICE_SCENE_SCHEMA, + default=None, + validate_schema=True, + on_error="none" + ) + + return result + + def _build_scene_object(self, scene_data: Dict[str, Any]) -> PracticeScene: + """ + 从解析的字典构建场景对象 + + Args: + scene_data: 解析后的场景数据 + + Returns: + PracticeScene 对象 + """ + # 提取 scene 字段(JSON 格式为 {"scene": {...}}) + scene = scene_data.get("scene", scene_data) + + return PracticeScene( + name=scene.get("name", "陪练场景"), + description=scene.get("description", ""), + background=scene.get("background", ""), + ai_role=scene.get("ai_role", "AI扮演客户"), + objectives=scene.get("objectives", []), + keywords=scene.get("keywords", []), + type=scene.get("type", DEFAULT_SCENE_TYPE), + difficulty=scene.get("difficulty", DEFAULT_DIFFICULTY) + ) + + def scene_to_dict(self, scene: PracticeScene) -> Dict[str, Any]: + """ + 将场景对象转换为字典 + + 便于 API 响应序列化 + + Args: + scene: PracticeScene 对象 + + Returns: + 字典格式的场景数据 + """ + return { + "scene": { + "name": scene.name, + "description": scene.description, + "background": scene.background, + "ai_role": scene.ai_role, + "objectives": scene.objectives, + "keywords": scene.keywords, + "type": scene.type, + "difficulty": scene.difficulty + } + } + + +# ==================== 全局实例 ==================== + +practice_scene_service = PracticeSceneService() + + +# ==================== 便捷函数 ==================== + +async def prepare_practice_knowledge( + db: AsyncSession, + course_id: int +) -> PracticeSceneResult: + """ + 准备陪练所需的知识内容(便捷函数) + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + PracticeSceneResult 结果对象 + """ + return await practice_scene_service.prepare_practice_knowledge(db, course_id) + + + + + + + + + diff --git a/backend/app/services/ai/prompts/__init__.py b/backend/app/services/ai/prompts/__init__.py new file mode 100644 index 0000000..6934036 --- /dev/null +++ b/backend/app/services/ai/prompts/__init__.py @@ -0,0 +1,57 @@ +""" +提示词模板模块 + +遵循瑞小美提示词规范 +""" + +from .knowledge_analysis_prompts import ( + PROMPT_META as KNOWLEDGE_ANALYSIS_PROMPT_META, + SYSTEM_PROMPT as KNOWLEDGE_ANALYSIS_SYSTEM_PROMPT, + USER_PROMPT as KNOWLEDGE_ANALYSIS_USER_PROMPT, + KNOWLEDGE_POINT_SCHEMA, +) + +from .exam_generator_prompts import ( + PROMPT_META as EXAM_GENERATOR_PROMPT_META, + SYSTEM_PROMPT as EXAM_GENERATOR_SYSTEM_PROMPT, + USER_PROMPT as EXAM_GENERATOR_USER_PROMPT, + MISTAKE_REGEN_SYSTEM_PROMPT, + MISTAKE_REGEN_USER_PROMPT, + QUESTION_SCHEMA, + QUESTION_TYPES, + DEFAULT_QUESTION_COUNTS, + DEFAULT_DIFFICULTY_LEVEL, +) + +from .ability_analysis_prompts import ( + PROMPT_META as ABILITY_ANALYSIS_PROMPT_META, + SYSTEM_PROMPT as ABILITY_ANALYSIS_SYSTEM_PROMPT, + USER_PROMPT as ABILITY_ANALYSIS_USER_PROMPT, + ABILITY_ANALYSIS_SCHEMA, + ABILITY_DIMENSIONS, +) + +__all__ = [ + # Knowledge Analysis Prompts + "KNOWLEDGE_ANALYSIS_PROMPT_META", + "KNOWLEDGE_ANALYSIS_SYSTEM_PROMPT", + "KNOWLEDGE_ANALYSIS_USER_PROMPT", + "KNOWLEDGE_POINT_SCHEMA", + # Exam Generator Prompts + "EXAM_GENERATOR_PROMPT_META", + "EXAM_GENERATOR_SYSTEM_PROMPT", + "EXAM_GENERATOR_USER_PROMPT", + "MISTAKE_REGEN_SYSTEM_PROMPT", + "MISTAKE_REGEN_USER_PROMPT", + "QUESTION_SCHEMA", + "QUESTION_TYPES", + "DEFAULT_QUESTION_COUNTS", + "DEFAULT_DIFFICULTY_LEVEL", + # Ability Analysis Prompts + "ABILITY_ANALYSIS_PROMPT_META", + "ABILITY_ANALYSIS_SYSTEM_PROMPT", + "ABILITY_ANALYSIS_USER_PROMPT", + "ABILITY_ANALYSIS_SCHEMA", + "ABILITY_DIMENSIONS", +] + diff --git a/backend/app/services/ai/prompts/ability_analysis_prompts.py b/backend/app/services/ai/prompts/ability_analysis_prompts.py new file mode 100644 index 0000000..1bdccf3 --- /dev/null +++ b/backend/app/services/ai/prompts/ability_analysis_prompts.py @@ -0,0 +1,215 @@ +""" +智能工牌能力分析与课程推荐提示词模板 + +功能:分析员工与顾客的对话记录,评估能力维度得分,并推荐适合的课程 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "ability_analysis", + "display_name": "智能工牌能力分析", + "description": "分析员工与顾客对话,评估多维度能力得分,推荐个性化课程", + "module": "kaopeilian", + "variables": ["dialogue_history", "user_info", "courses"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。并根据课程列表,为该用户提供选课建议。 + +输出标准: +{ + "analysis": { + "total_score": 82, + "ability_dimensions": [ + { + "name": "专业知识", + "score": 88, + "feedback": "产品知识扎实,能准确回答客户问题。建议:继续深化对新产品的了解。" + }, + { + "name": "沟通技巧", + "score": 92, + "feedback": "语言表达清晰流畅,善于倾听客户需求。建议:可以多使用开放式问题引导。" + }, + { + "name": "操作技能", + "score": 85, + "feedback": "基本操作熟练,流程规范。建议:提升复杂场景的应对速度。" + }, + { + "name": "客户服务", + "score": 90, + "feedback": "服务态度优秀,客户体验良好。建议:进一步提升个性化服务能力。" + }, + { + "name": "安全意识", + "score": 79, + "feedback": "基本安全规范掌握,但在细节提醒上还可加强。" + }, + { + "name": "应变能力", + "score": 76, + "feedback": "面对突发情况反应较快,但处理方式可以更灵活多样。" + } + ], + "course_recommendations": [ + { + "course_id": 5, + "course_name": "应变能力提升训练营", + "recommendation_reason": "该课程专注于提升应变能力,包含大量实战案例分析和模拟演练,针对您当前的薄弱环节(应变能力76分)设计。通过学习可提升15分左右。", + "priority": "high", + "match_score": 95 + }, + { + "course_id": 3, + "course_name": "安全规范与操作标准", + "recommendation_reason": "系统讲解安全规范和操作标准,通过案例教学帮助建立安全意识。当前您的安全意识得分为79分,通过本课程学习预计可提升12分。", + "priority": "high", + "match_score": 88 + }, + { + "course_id": 7, + "course_name": "高级销售技巧", + "recommendation_reason": "进阶课程,帮助您将已有的沟通优势(92分)转化为更高级的销售技能,进一步巩固客户服务能力(90分)。", + "priority": "medium", + "match_score": 82 + } + ] + } +} + +## 输出要求(严格执行) +1. 直接输出纯净的 JSON,不要包含 Markdown 标记(如 ```json) +2. 不要包含任何解释性文字 +3. 能力维度必须包含:专业知识、沟通技巧、操作技能、客户服务、安全意识、应变能力 +4. 课程推荐必须来自提供的课程列表,使用真实的 course_id +5. 推荐课程数量:1-5个,优先推荐能补齐短板的课程 +6. priority 取值:high(得分<80的薄弱项)、medium(得分80-85)、low(锦上添花) + +## 评分标准 +- 90-100:优秀 +- 80-89:良好 +- 70-79:一般 +- 60-69:需改进 +- <60:亟需提升""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """对话记录:{dialogue_history} + +--- + +用户的信息和岗位:{user_info} + +--- + +所有可选课程:{courses}""" + + +# ==================== JSON Schema ==================== + +ABILITY_ANALYSIS_SCHEMA = { + "type": "object", + "required": ["analysis"], + "properties": { + "analysis": { + "type": "object", + "required": ["total_score", "ability_dimensions", "course_recommendations"], + "properties": { + "total_score": { + "type": "number", + "description": "总体评分(0-100)", + "minimum": 0, + "maximum": 100 + }, + "ability_dimensions": { + "type": "array", + "description": "能力维度评分列表", + "items": { + "type": "object", + "required": ["name", "score", "feedback"], + "properties": { + "name": { + "type": "string", + "description": "能力维度名称" + }, + "score": { + "type": "number", + "description": "该维度得分(0-100)", + "minimum": 0, + "maximum": 100 + }, + "feedback": { + "type": "string", + "description": "该维度的反馈和建议" + } + } + }, + "minItems": 1 + }, + "course_recommendations": { + "type": "array", + "description": "课程推荐列表", + "items": { + "type": "object", + "required": ["course_id", "course_name", "recommendation_reason", "priority", "match_score"], + "properties": { + "course_id": { + "type": "integer", + "description": "课程ID" + }, + "course_name": { + "type": "string", + "description": "课程名称" + }, + "recommendation_reason": { + "type": "string", + "description": "推荐理由" + }, + "priority": { + "type": "string", + "description": "推荐优先级", + "enum": ["high", "medium", "low"] + }, + "match_score": { + "type": "number", + "description": "匹配度得分(0-100)", + "minimum": 0, + "maximum": 100 + } + } + } + } + } + } + } +} + + +# ==================== 能力维度常量 ==================== + +ABILITY_DIMENSIONS = [ + "专业知识", + "沟通技巧", + "操作技能", + "客户服务", + "安全意识", + "应变能力", +] + +PRIORITY_LEVELS = ["high", "medium", "low"] + + + + + + + + + diff --git a/backend/app/services/ai/prompts/answer_judge_prompts.py b/backend/app/services/ai/prompts/answer_judge_prompts.py new file mode 100644 index 0000000..6580979 --- /dev/null +++ b/backend/app/services/ai/prompts/answer_judge_prompts.py @@ -0,0 +1,48 @@ +""" +答案判断器提示词模板 + +功能:判断填空题与问答题是否回答正确 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "answer_judge", + "display_name": "答案判断器", + "description": "判断填空题与问答题的答案是否正确", + "module": "kaopeilian", + "variables": ["question", "correct_answer", "user_answer", "analysis"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是一个答案判断器,根据用户提交的答案,比对题目、答案、解析。给出正确或错误的判断。 + +注意:仅输出"正确"或"错误",无需更多字符和说明。""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """题目:{question}。 +正确答案:{correct_answer}。 +解析:{analysis}。 + +考生的回答:{user_answer}。""" + + +# ==================== 判断结果常量 ==================== + +CORRECT_KEYWORDS = ["正确", "correct", "true", "yes", "对", "是"] +INCORRECT_KEYWORDS = ["错误", "incorrect", "false", "no", "wrong", "不正确", "错"] + + + + + + + + + diff --git a/backend/app/services/ai/prompts/course_chat_prompts.py b/backend/app/services/ai/prompts/course_chat_prompts.py new file mode 100644 index 0000000..ac5ab4e --- /dev/null +++ b/backend/app/services/ai/prompts/course_chat_prompts.py @@ -0,0 +1,74 @@ +""" +课程对话提示词模板 + +功能:基于课程知识点进行智能问答 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "course_chat", + "display_name": "与课程对话", + "description": "基于课程知识点内容,为用户提供智能问答服务", + "module": "kaopeilian", + "variables": ["knowledge_base", "query"], + "version": "2.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是知识拆解专家,精通以下知识库(课程)内容。请根据用户的问题,从知识库中找到最相关的信息,进行深入分析后,用简洁清晰的语言回答用户。为用户提供与课程对话的服务。 + +回答要求: + +1. 直接针对问题核心,避免冗长铺垫 +2. 使用通俗易懂的语言,必要时举例说明 +3. 突出关键要点,帮助用户快速理解 +4. 如果知识库中没有相关内容,请如实告知 + +知识库: +{knowledge_base}""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """{query}""" + + +# ==================== 知识库格式模板 ==================== + +KNOWLEDGE_ITEM_TEMPLATE = """【{name}】 +{description} +""" + + +# ==================== 配置常量 ==================== + +# 会话历史窗口大小(保留最近 N 轮对话) +CONVERSATION_WINDOW_SIZE = 10 + +# 会话 TTL(秒)- 30 分钟 +CONVERSATION_TTL = 1800 + +# 最大知识点数量 +MAX_KNOWLEDGE_POINTS = 50 + +# 知识库最大字符数 +MAX_KNOWLEDGE_BASE_LENGTH = 50000 + +# 默认模型 +DEFAULT_CHAT_MODEL = "gemini-3-flash-preview" + +# 温度参数(对话场景使用较高温度) +DEFAULT_TEMPERATURE = 0.7 + + + + + + + + + diff --git a/backend/app/services/ai/prompts/exam_generator_prompts.py b/backend/app/services/ai/prompts/exam_generator_prompts.py new file mode 100644 index 0000000..e979dfa --- /dev/null +++ b/backend/app/services/ai/prompts/exam_generator_prompts.py @@ -0,0 +1,300 @@ +""" +试题生成器提示词模板 + +功能:根据岗位和知识点动态生成考试题目 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "exam_generator", + "display_name": "试题生成器", + "description": "根据课程知识点和岗位特征,动态生成考试题目(单选、多选、判断、填空、问答)", + "module": "kaopeilian", + "variables": [ + "total_count", + "single_choice_count", + "multiple_choice_count", + "true_false_count", + "fill_blank_count", + "essay_count", + "difficulty_level", + "position_info", + "knowledge_points", + ], + "version": "2.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词(第一轮出题) ==================== + +SYSTEM_PROMPT = """## 角色 +你是一位经验丰富的考试出题专家,能够依据用户提供的知识内容,结合用户的岗位特征,随机地生成{total_count}题考题。你会以专业、严谨且清晰的方式出题。 + +## 输出{single_choice_count}道单选题 +1、每道题目只能有 1 个正确答案。 +2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。 +3、答案解析要简明扼要,说明选择理由。 +4、为每道题记录出题来源的知识点 id。 +5、请以 JSON 格式输出。 +6、为每道题输出一个序号。 + +### 输出结构: +{{ + "num": "题号", + "type": "single_choice", + "topic": {{ + "title": "清晰完整的题目描述", + "options": {{ + "opt1": "A:符合语境的选项", + "opt2": "B:符合语境的选项", + "opt3": "C:符合语境的选项", + "opt4": "D:符合语境的选项" + }} + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "其中一个选项的全部原文", + "analysis": "准确的答案解析,包含选择原因和知识点说明" +}} + +- 严格按照以上格式输出 + +## 输出{multiple_choice_count}道多选题 +1、每道题目有多个正确答案。 +2、"type": "multiple_choice" +3、其它事项同单选题。 + +## 输出{true_false_count}道判断题 +1、每道题目只有 "正确" 或 "错误" 两种答案。 +2、题目表述应明确清晰,避免歧义。 +3、题目应直接陈述事实或观点,便于做出是非判断。 +4、其它事项同单选题。 + +### 输出结构: +{{ + "num": "题号", + "type": "true_false", + "topic": {{ + "title": "清晰完整的题目描述" + }}, + "knowledge_point_id": " 出题来源知识点的id", + "correct": "正确", + "analysis": "准确的答案解析,包含判断原因和知识点说明" +}} + +- 严格按照以上格式输出 + +## 输出{fill_blank_count}道填空题 +1. 题干应明确完整,空缺处需用横线"___"标示,且只能有一处空缺 +2. 答案应唯一且明确,避免开放性表述 +3. 空缺长度应与答案长度大致匹配 +4. 解析需说明答案依据及相关知识点 +5. 其余要求与单选题一致 + +### 输出结构: +{{ + "num": "题号", + "type": "fill_blank", + "topic": {{ + "title": "包含___空缺的题目描述" + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "准确的填空答案", + "analysis": "解析答案的依据和相关知识点说明" +}} + +- 严格按照以上格式输出 + +### 输出{essay_count}道问答题 +1. 问题应具体明确,限定回答范围 +2. 答案需条理清晰,突出核心要点 +3. 解析可补充扩展说明或评分要点 +4. 避免过于宽泛或需要主观发挥的问题 +5. 其余要求同单选题 + +### 输出结构: +{{ + "num": "题号", + "type": "essay", + "topic": {{ + "title": "需要详细回答的问题描述" + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "完整准确的参考答案(分点或连贯表述)", + "analysis": "对答案的补充说明、评分要点或相关知识点扩展" +}} + +## 特殊要求 +1. 题目难度:{difficulty_level}级(5 级为最难) +2. 避免使用模棱两可的表述 +3. 选项内容要互斥,不能有重叠 +4. 每个选项长度尽量均衡 +5. 正确答案(A、B、C、D)分布要合理,避免规律性 +6. 正确答案必须使用其中一个选项中的全部原文,严禁修改 +7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个 id。 + +## 输出格式要求 +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。 + +请按以上要求生成题目,确保每道题目质量。""" + + +# ==================== 用户提示词模板(第一轮出题) ==================== + +USER_PROMPT = """# 请针对岗位特征、待出题的知识点内容进行出题。 + +## 岗位信息: + +{position_info} + +--- + +## 知识点: + +{knowledge_points}""" + + +# ==================== 错题重出系统提示词 ==================== + +MISTAKE_REGEN_SYSTEM_PROMPT = """## 角色 +你是一位经验丰富的考试出题专家,能够依据用户提供的错题记录,重新为用户出题。你会为每道错题重新出一题,你会以专业、严谨且清晰的方式出题。 + +## 输出单选题 +1、每道题目只能有 1 个正确答案。 +2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。 +3、答案解析要简明扼要,说明选择理由。 +4、为每道题记录出题来源的知识点 id。 +5、请以 JSON 格式输出。 +6、为每道题输出一个序号。 + +### 输出结构: +{{ + "num": "题号", + "type": "single_choice", + "topic": {{ + "title": "清晰完整的题目描述", + "options": {{ + "opt1": "A:符合语境的选项", + "opt2": "B:符合语境的选项", + "opt3": "C:符合语境的选项", + "opt4": "D:符合语境的选项" + }} + }}, + "knowledge_point_id": "出题来源知识点的id", + "correct": "其中一个选项的全部原文", + "analysis": "准确的答案解析,包含选择原因和知识点说明" +}} + +- 严格按照以上格式输出 + + +## 特殊要求 +1. 题目难度:{difficulty_level}级(5 级为最难) +2. 避免使用模棱两可的表述 +3. 选项内容要互斥,不能有重叠 +4. 每个选项长度尽量均衡 +5. 正确答案(A、B、C、D)分布要合理,避免规律性 +6. 正确答案必须使用其中一个选项中的全部原文,严禁修改 +7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个 id。 + +## 输出格式要求 +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。 + +请按以上要求生成题目,确保每道题目质量。""" + + +# ==================== 错题重出用户提示词 ==================== + +MISTAKE_REGEN_USER_PROMPT = """## 错题记录: + +{mistake_records}""" + + +# ==================== JSON Schema ==================== + +QUESTION_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "required": ["num", "type", "topic", "correct"], + "properties": { + "num": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"} + ], + "description": "题号" + }, + "type": { + "type": "string", + "enum": ["single_choice", "multiple_choice", "true_false", "fill_blank", "essay"], + "description": "题目类型" + }, + "topic": { + "type": "object", + "required": ["title"], + "properties": { + "title": { + "type": "string", + "description": "题目标题" + }, + "options": { + "type": "object", + "description": "选项(选择题必填)" + } + } + }, + "knowledge_point_id": { + "oneOf": [ + {"type": "integer"}, + {"type": "string"}, + {"type": "null"} + ], + "description": "知识点ID" + }, + "correct": { + "type": "string", + "description": "正确答案" + }, + "analysis": { + "type": "string", + "description": "答案解析" + } + } + }, + "minItems": 1, + "maxItems": 50 +} + + +# ==================== 题目类型常量 ==================== + +QUESTION_TYPES = { + "single_choice": "单选题", + "multiple_choice": "多选题", + "true_false": "判断题", + "fill_blank": "填空题", + "essay": "问答题", +} + +# 默认题目数量配置 +DEFAULT_QUESTION_COUNTS = { + "single_choice_count": 4, + "multiple_choice_count": 2, + "true_false_count": 1, + "fill_blank_count": 2, + "essay_count": 1, +} + +DEFAULT_DIFFICULTY_LEVEL = 3 +MAX_DIFFICULTY_LEVEL = 5 + + + + + + + + + diff --git a/backend/app/services/ai/prompts/knowledge_analysis_prompts.py b/backend/app/services/ai/prompts/knowledge_analysis_prompts.py new file mode 100644 index 0000000..ab918a3 --- /dev/null +++ b/backend/app/services/ai/prompts/knowledge_analysis_prompts.py @@ -0,0 +1,148 @@ +""" +知识点分析提示词模板 + +功能:从课程资料中提取知识点 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "knowledge_analysis", + "display_name": "知识点分析", + "description": "从课程资料中提取和分析知识点,支持PDF/Word/文本等格式", + "module": "kaopeilian", + "variables": ["course_name", "content"], + "version": "2.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """# 角色 +你是一个文件拆解高手,擅长将用户提交的内容进行精准拆分,拆分后的内容做个简单的优化处理使其更具可读性,但要尽量使用原文的原词原句。 + +## 技能 +### 技能 1: 内容拆分 +1. 当用户提交内容后,拆分为多段。 +2. 对拆分后的内容做简单优化,使其更具可读性,比如去掉奇怪符号(如换行符、乱码),若语句不通顺,或格式原因导致错位,则重新表达。用户可能会提交录音转文字的内容,因此可能是有错字的,注意修复这些小瑕疵。 +3. 优化过程中,尽量使用原文的原词原句,特别是话术类,必须保持原有的句式、保持原词原句,而不是重构。 +4. 注意是拆分而不是重写,不需要润色,尽量不做任何处理。 +5. 输出到 content。 + +### 技能 2: 为每一个选段概括一个标题 +1. 为每个拆分出来的选段概括一个标题,并输出到 title。 + +### 技能 3: 为每一个选段说明与主题的关联 +1. 详细说明这一段与全文核心主题的关联,并输出到 topic_relation。 + +### 技能 4: 为每一个选段打上一个类型标签 +1. 用户提交的内容很有可能是一个课程、一篇讲义、一个产品的说明书,通常是用户希望他公司的员工或高管学习的知识。 +2. 用户通常是医疗美容机构或轻医美、生活美容连锁品牌。 +3. 你要为每个选段打上一个知识类型的标签,最好是这几个类型中的一个:"理论知识", "诊断设计", "操作步骤", "沟通话术", "案例分析", "注意事项", "技巧方法", "客诉处理"。当然你也可以为这个选段匹配一个更适合的。 + +## 输出要求(严格按要求输出) +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。格式如下: + +[ + { + "title": "知识点标题", + "content": "知识点内容", + "topic_relation": "知识点与主题的关系", + "type": "知识点类型" + }, + { + "title": "第二个知识点标题", + "content": "第二个知识点内容...", + "topic_relation": "...", + "type": "..." + } +] + +## 限制 +- 仅围绕用户提交的内容进行拆分和关联标注,不涉及其他无关内容。 +- 拆分后的内容必须最大程度保持与原文一致。 +- 关联说明需清晰合理。 +- 不论如何,不要拆分超过 20 段!""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """课程主题:{course_name} + +## 用户提交的内容: + +{content} + +## 注意 + +- 以json的格式输出 +- 不论如何,不要拆分超过20 段!""" + + +# ==================== JSON Schema ==================== + +KNOWLEDGE_POINT_SCHEMA = { + "type": "array", + "items": { + "type": "object", + "required": ["title", "content", "type"], + "properties": { + "title": { + "type": "string", + "description": "知识点标题", + "maxLength": 200 + }, + "content": { + "type": "string", + "description": "知识点内容" + }, + "topic_relation": { + "type": "string", + "description": "与主题的关系描述" + }, + "type": { + "type": "string", + "description": "知识点类型", + "enum": [ + "理论知识", + "诊断设计", + "操作步骤", + "沟通话术", + "案例分析", + "注意事项", + "技巧方法", + "客诉处理", + "其他" + ] + } + } + }, + "minItems": 1, + "maxItems": 20 +} + + +# ==================== 知识点类型常量 ==================== + +KNOWLEDGE_POINT_TYPES = [ + "理论知识", + "诊断设计", + "操作步骤", + "沟通话术", + "案例分析", + "注意事项", + "技巧方法", + "客诉处理", +] + +DEFAULT_KNOWLEDGE_TYPE = "理论知识" + + + + + + + + + diff --git a/backend/app/services/ai/prompts/practice_analysis_prompts.py b/backend/app/services/ai/prompts/practice_analysis_prompts.py new file mode 100644 index 0000000..45f1298 --- /dev/null +++ b/backend/app/services/ai/prompts/practice_analysis_prompts.py @@ -0,0 +1,193 @@ +""" +陪练分析报告提示词模板 + +功能:分析陪练对话,生成综合评分和改进建议 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "practice_analysis", + "display_name": "陪练分析报告", + "description": "分析陪练对话,生成综合评分、能力维度评估、对话标注和改进建议", + "module": "kaopeilian", + "variables": ["dialogue_history"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。 + +输出标准: +{ + "analysis": { + "total_score": 88, + "score_breakdown": [ + {"name": "开场技巧", "score": 92, "description": "开场自然,快速建立信任"}, + {"name": "需求挖掘", "score": 90, "description": "能够有效识别客户需求"}, + {"name": "产品介绍", "score": 88, "description": "产品介绍清晰,重点突出"}, + {"name": "异议处理", "score": 85, "description": "处理客户异议还需加强"}, + {"name": "成交技巧", "score": 86, "description": "成交话术运用良好"} + ], + "ability_dimensions": [ + {"name": "沟通表达", "score": 90, "feedback": "语言流畅,表达清晰,语调富有亲和力"}, + {"name": "倾听理解", "score": 92, "feedback": "能够准确理解客户意图,给予恰当回应"}, + {"name": "情绪控制", "score": 88, "feedback": "整体情绪稳定,面对异议时保持专业"}, + {"name": "专业知识", "score": 93, "feedback": "对医美项目知识掌握扎实"}, + {"name": "销售技巧", "score": 87, "feedback": "销售流程把控良好"}, + {"name": "应变能力", "score": 85, "feedback": "面对突发问题能够快速反应"} + ], + "dialogue_annotations": [ + {"sequence": 1, "tags": ["亮点话术"], "comment": "开场专业,身份介绍清晰"}, + {"sequence": 3, "tags": ["金牌话术"], "comment": "巧妙引导,从客户角度出发"}, + {"sequence": 5, "tags": ["亮点话术"], "comment": "类比生动,让客户容易理解"}, + {"sequence": 7, "tags": ["金牌话术"], "comment": "专业解答,打消客户疑虑"} + ], + "suggestions": [ + {"title": "控制语速", "content": "您的语速偏快,建议适当放慢,给客户更多思考时间", "example": "说完产品优势后,停顿2-3秒,观察客户反应"}, + {"title": "多用开放式问题", "content": "增加开放式问题的使用,更深入了解客户需求", "example": "您对未来的保障有什么期望?而不是您需要保险吗?"}, + {"title": "强化成交信号识别", "content": "客户已经表现出兴趣时,要及时推进成交", "example": "当客户问费用多少时,这是购买信号,应该立即报价并促成"} + ] + } +} + +## 输出要求(严格执行) +1. 直接输出纯净的 JSON,不要包含 Markdown 标记(如 ```json) +2. 不要包含任何解释性文字 +3. score_breakdown 必须包含 5 项:开场技巧、需求挖掘、产品介绍、异议处理、成交技巧 +4. ability_dimensions 必须包含 6 项:沟通表达、倾听理解、情绪控制、专业知识、销售技巧、应变能力 +5. dialogue_annotations 标注有亮点或问题的对话轮次,tags 可选:亮点话术、金牌话术、待改进、问题话术 +6. suggestions 提供 2-4 条具体可操作的改进建议,每条包含 title、content、example + +## 评分标准 +- 90-100:优秀 +- 80-89:良好 +- 70-79:一般 +- 60-69:需改进 +- <60:亟需提升""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """{dialogue_history}""" + + +# ==================== JSON Schema ==================== + +PRACTICE_ANALYSIS_SCHEMA = { + "type": "object", + "required": ["analysis"], + "properties": { + "analysis": { + "type": "object", + "required": ["total_score", "score_breakdown", "ability_dimensions", "dialogue_annotations", "suggestions"], + "properties": { + "total_score": { + "type": "number", + "description": "总体评分(0-100)", + "minimum": 0, + "maximum": 100 + }, + "score_breakdown": { + "type": "array", + "description": "分数细分(5项)", + "items": { + "type": "object", + "required": ["name", "score", "description"], + "properties": { + "name": {"type": "string", "description": "维度名称"}, + "score": {"type": "number", "description": "得分(0-100)"}, + "description": {"type": "string", "description": "评价描述"} + } + }, + "minItems": 5 + }, + "ability_dimensions": { + "type": "array", + "description": "能力维度评分(6项)", + "items": { + "type": "object", + "required": ["name", "score", "feedback"], + "properties": { + "name": {"type": "string", "description": "能力维度名称"}, + "score": {"type": "number", "description": "得分(0-100)"}, + "feedback": {"type": "string", "description": "反馈评语"} + } + }, + "minItems": 6 + }, + "dialogue_annotations": { + "type": "array", + "description": "对话标注", + "items": { + "type": "object", + "required": ["sequence", "tags", "comment"], + "properties": { + "sequence": {"type": "integer", "description": "对话轮次序号"}, + "tags": { + "type": "array", + "description": "标签列表", + "items": {"type": "string"} + }, + "comment": {"type": "string", "description": "点评内容"} + } + } + }, + "suggestions": { + "type": "array", + "description": "改进建议", + "items": { + "type": "object", + "required": ["title", "content", "example"], + "properties": { + "title": {"type": "string", "description": "建议标题"}, + "content": {"type": "string", "description": "建议内容"}, + "example": {"type": "string", "description": "示例"} + } + }, + "minItems": 2, + "maxItems": 5 + } + } + } + } +} + + +# ==================== 常量定义 ==================== + +SCORE_BREAKDOWN_ITEMS = [ + "开场技巧", + "需求挖掘", + "产品介绍", + "异议处理", + "成交技巧", +] + +ABILITY_DIMENSIONS = [ + "沟通表达", + "倾听理解", + "情绪控制", + "专业知识", + "销售技巧", + "应变能力", +] + +ANNOTATION_TAGS = [ + "亮点话术", + "金牌话术", + "待改进", + "问题话术", +] + + + + + + + + + diff --git a/backend/app/services/ai/prompts/practice_scene_prompts.py b/backend/app/services/ai/prompts/practice_scene_prompts.py new file mode 100644 index 0000000..df391cd --- /dev/null +++ b/backend/app/services/ai/prompts/practice_scene_prompts.py @@ -0,0 +1,192 @@ +""" +陪练场景生成提示词模板 + +功能:根据课程知识点生成陪练场景配置 +""" + +# ==================== 元数据 ==================== + +PROMPT_META = { + "name": "practice_scene_generation", + "display_name": "陪练场景生成", + "description": "根据课程知识点生成 AI 陪练场景配置,包含场景名称、背景、AI 角色、练习目标等", + "module": "kaopeilian", + "variables": ["knowledge_points"], + "version": "1.0.0", + "author": "kaopeilian-team", +} + + +# ==================== 系统提示词 ==================== + +SYSTEM_PROMPT = """你是一个训练场景研究专家,能将用户提交的知识点,转变为一个模拟陪练的场景,并严格按照以下格式输出。 + +输出标准: + +{ +"scene": { +"name": "轻医美产品咨询陪练", +"description": "模拟客户咨询轻医美产品的场景", +"background": "客户对脸部抗衰项目感兴趣。", +"ai_role": "AI扮演一位30岁女性客户", +"objectives": ["了解客户需求", "介绍产品优势", "处理价格异议"], +"keywords": ["抗衰", "玻尿酸", "价格"], +"type": "product-intro", +"difficulty": "intermediate" +} +} + +## 字段说明 + +- **name**: 场景名称,简洁明了,体现陪练主题 +- **description**: 场景描述,说明这是什么样的模拟场景 +- **background**: 场景背景设定,描述客户的情况和需求 +- **ai_role**: AI 角色描述,说明 AI 扮演什么角色(通常是客户) +- **objectives**: 练习目标数组,列出学员需要达成的目标 +- **keywords**: 关键词数组,从知识点中提取的核心关键词 +- **type**: 场景类型,可选值: + - phone: 电话销售 + - face: 面对面销售 + - complaint: 客户投诉 + - after-sales: 售后服务 + - product-intro: 产品介绍 +- **difficulty**: 难度等级,可选值: + - beginner: 入门 + - junior: 初级 + - intermediate: 中级 + - senior: 高级 + - expert: 专家 + +## 输出要求 + +1. 直接输出纯净的 JSON 对象,不要包含 Markdown 标记(如 ```json) +2. 不要包含任何解释性文字 +3. 根据知识点内容合理设计场景,确保场景与知识点紧密相关 +4. objectives 至少包含 2-3 个具体可操作的目标 +5. keywords 提取 3-5 个核心关键词 +6. 根据知识点的复杂程度选择合适的 difficulty +7. 根据知识点的应用场景选择合适的 type""" + + +# ==================== 用户提示词模板 ==================== + +USER_PROMPT = """请根据以下知识点内容,生成一个模拟陪练场景: + +## 知识点内容 + +{knowledge_points} + +## 要求 + +- 以 JSON 格式输出 +- 场景要贴合知识点的实际应用场景 +- AI 角色要符合轻医美行业的客户特征 +- 练习目标要具体、可评估""" + + +# ==================== JSON Schema ==================== + +PRACTICE_SCENE_SCHEMA = { + "type": "object", + "required": ["scene"], + "properties": { + "scene": { + "type": "object", + "required": ["name", "description", "background", "ai_role", "objectives", "keywords", "type", "difficulty"], + "properties": { + "name": { + "type": "string", + "description": "场景名称", + "maxLength": 100 + }, + "description": { + "type": "string", + "description": "场景描述", + "maxLength": 500 + }, + "background": { + "type": "string", + "description": "场景背景设定", + "maxLength": 500 + }, + "ai_role": { + "type": "string", + "description": "AI 角色描述", + "maxLength": 200 + }, + "objectives": { + "type": "array", + "description": "练习目标", + "items": { + "type": "string" + }, + "minItems": 2, + "maxItems": 5 + }, + "keywords": { + "type": "array", + "description": "关键词", + "items": { + "type": "string" + }, + "minItems": 2, + "maxItems": 8 + }, + "type": { + "type": "string", + "description": "场景类型", + "enum": [ + "phone", + "face", + "complaint", + "after-sales", + "product-intro" + ] + }, + "difficulty": { + "type": "string", + "description": "难度等级", + "enum": [ + "beginner", + "junior", + "intermediate", + "senior", + "expert" + ] + } + } + } + } +} + + +# ==================== 场景类型常量 ==================== + +SCENE_TYPES = { + "phone": "电话销售", + "face": "面对面销售", + "complaint": "客户投诉", + "after-sales": "售后服务", + "product-intro": "产品介绍", +} + +DIFFICULTY_LEVELS = { + "beginner": "入门", + "junior": "初级", + "intermediate": "中级", + "senior": "高级", + "expert": "专家", +} + +# 默认值 +DEFAULT_SCENE_TYPE = "product-intro" +DEFAULT_DIFFICULTY = "intermediate" + + + + + + + + + diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py new file mode 100644 index 0000000..1199df3 --- /dev/null +++ b/backend/app/services/auth_service.py @@ -0,0 +1,141 @@ +""" +认证服务 +""" +from datetime import datetime, timedelta +from typing import Optional + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import UnauthorizedError +from app.core.logger import logger +from app.core.security import create_access_token, create_refresh_token, decode_token +from app.models.user import User +from app.schemas.auth import Token +from app.services.user_service import UserService + + +class AuthService: + """认证服务""" + + def __init__(self, db: AsyncSession): + self.db = db + self.user_service = UserService(db) + + async def login(self, username: str, password: str) -> tuple[User, Token]: + """ + 用户登录 + + Args: + username: 用户名/邮箱/手机号 + password: 密码 + + Returns: + 用户对象和令牌 + """ + # 验证用户 + user = await self.user_service.authenticate( + username=username, password=password + ) + + if not user: + logger.warning( + "登录失败:用户名或密码错误", + username=username, + ) + raise UnauthorizedError("用户名或密码错误") + + if not user.is_active: + logger.warning( + "登录失败:用户已被禁用", + user_id=user.id, + username=user.username, + ) + raise UnauthorizedError("用户已被禁用") + + # 生成令牌 + access_token = create_access_token(subject=user.id) + refresh_token = create_refresh_token(subject=user.id) + + # 更新最后登录时间 + await self.user_service.update_last_login(user.id) + + # 记录日志 + logger.info( + "用户登录成功", + user_id=user.id, + username=user.username, + role=user.role, + ) + + return user, Token( + access_token=access_token, + refresh_token=refresh_token, + ) + + async def refresh_token(self, refresh_token: str) -> Token: + """ + 刷新访问令牌 + + Args: + refresh_token: 刷新令牌 + + Returns: + 新的令牌 + """ + try: + # 解码刷新令牌 + payload = decode_token(refresh_token) + + # 验证令牌类型 + if payload.get("type") != "refresh": + raise UnauthorizedError("无效的刷新令牌") + + # 获取用户ID + user_id = int(payload.get("sub")) + + # 获取用户 + user = await self.user_service.get_by_id(user_id) + if not user: + raise UnauthorizedError("用户不存在") + + if not user.is_active: + raise UnauthorizedError("用户已被禁用") + + # 生成新的访问令牌 + access_token = create_access_token(subject=user.id) + + logger.info( + "令牌刷新成功", + user_id=user.id, + username=user.username, + ) + + return Token( + access_token=access_token, + refresh_token=refresh_token, # 保持原刷新令牌 + ) + + except Exception as e: + logger.error( + "令牌刷新失败", + error=str(e), + ) + raise UnauthorizedError("无效的刷新令牌") + + async def logout(self, user_id: int) -> None: + """ + 用户登出 + + 注意:JWT是无状态的,实际的登出需要在客户端删除令牌 + 这里只是记录日志,如果需要可以将令牌加入黑名单 + + Args: + user_id: 用户ID + """ + user = await self.user_service.get_by_id(user_id) + if user: + logger.info( + "用户登出", + user_id=user.id, + username=user.username, + ) diff --git a/backend/app/services/base_service.py b/backend/app/services/base_service.py new file mode 100644 index 0000000..dfb20af --- /dev/null +++ b/backend/app/services/base_service.py @@ -0,0 +1,112 @@ +"""基础服务类""" +from typing import TypeVar, Generic, Type, Optional, List, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func +from sqlalchemy.sql import Select + +from app.models.base import BaseModel + +ModelType = TypeVar("ModelType", bound=BaseModel) + + +class BaseService(Generic[ModelType]): + """ + 基础服务类,提供通用的CRUD操作 + """ + + def __init__(self, model: Type[ModelType]): + self.model = model + + async def get(self, db: AsyncSession, id: int) -> Optional[ModelType]: + """根据ID获取单个对象""" + result = await db.execute(select(self.model).where(self.model.id == id)) + return result.scalar_one_or_none() + + async def get_by_id(self, db: AsyncSession, id: int) -> Optional[ModelType]: + """别名:按ID获取对象(兼容旧代码)""" + return await self.get(db, id) + + async def get_multi( + self, + db: AsyncSession, + *, + skip: int = 0, + limit: int = 100, + query: Optional[Select] = None, + ) -> List[ModelType]: + """获取多个对象""" + if query is None: + query = select(self.model) + + result = await db.execute(query.offset(skip).limit(limit)) + return result.scalars().all() + + async def count(self, db: AsyncSession, *, query: Optional[Select] = None) -> int: + """统计数量""" + if query is None: + query = select(func.count()).select_from(self.model) + else: + query = select(func.count()).select_from(query.subquery()) + + result = await db.execute(query) + return result.scalar_one() + + async def create(self, db: AsyncSession, *, obj_in: Any, **kwargs) -> ModelType: + """创建对象""" + if hasattr(obj_in, "model_dump"): + create_data = obj_in.model_dump() + else: + create_data = obj_in + + # 合并额外参数 + create_data.update(kwargs) + + db_obj = self.model(**create_data) + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + async def update( + self, db: AsyncSession, *, db_obj: ModelType, obj_in: Any, **kwargs + ) -> ModelType: + """更新对象""" + if hasattr(obj_in, "model_dump"): + update_data = obj_in.model_dump(exclude_unset=True) + else: + update_data = obj_in + + # 合并额外参数(如 updated_by 等审计字段) + if kwargs: + update_data.update(kwargs) + + for field, value in update_data.items(): + setattr(db_obj, field, value) + + db.add(db_obj) + await db.commit() + await db.refresh(db_obj) + return db_obj + + async def delete(self, db: AsyncSession, *, id: int) -> bool: + """删除对象""" + obj = await self.get(db, id) + if obj: + await db.delete(obj) + await db.commit() + return True + return False + + async def soft_delete(self, db: AsyncSession, *, id: int) -> bool: + """软删除对象""" + from datetime import datetime + + obj = await self.get(db, id) + if obj and hasattr(obj, "is_deleted"): + obj.is_deleted = True + if hasattr(obj, "deleted_at"): + obj.deleted_at = datetime.now() + db.add(obj) + await db.commit() + return True + return False diff --git a/backend/app/services/course_exam_service.py b/backend/app/services/course_exam_service.py new file mode 100644 index 0000000..502cc38 --- /dev/null +++ b/backend/app/services/course_exam_service.py @@ -0,0 +1,137 @@ +""" +课程考试设置服务 +""" +from typing import Optional +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.course_exam_settings import CourseExamSettings +from app.schemas.course import CourseExamSettingsCreate, CourseExamSettingsUpdate +from app.services.base_service import BaseService + +logger = get_logger(__name__) + + +class CourseExamService(BaseService[CourseExamSettings]): + """课程考试设置服务""" + + def __init__(self): + super().__init__(CourseExamSettings) + + async def get_by_course_id(self, db: AsyncSession, course_id: int) -> Optional[CourseExamSettings]: + """ + 根据课程ID获取考试设置 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 考试设置实例或None + """ + stmt = select(CourseExamSettings).where( + CourseExamSettings.course_id == course_id, + CourseExamSettings.is_deleted == False + ) + result = await db.execute(stmt) + return result.scalar_one_or_none() + + async def create_or_update( + self, + db: AsyncSession, + course_id: int, + settings_in: CourseExamSettingsCreate, + user_id: int + ) -> CourseExamSettings: + """ + 创建或更新课程考试设置 + + Args: + db: 数据库会话 + course_id: 课程ID + settings_in: 考试设置数据 + user_id: 操作用户ID + + Returns: + 考试设置实例 + """ + # 检查是否已存在设置 + existing_settings = await self.get_by_course_id(db, course_id) + + if existing_settings: + # 更新现有设置 + update_data = settings_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(existing_settings, field, value) + existing_settings.updated_by = user_id + + await db.commit() + await db.refresh(existing_settings) + + logger.info(f"更新课程考试设置成功", course_id=course_id, user_id=user_id) + return existing_settings + else: + # 创建新设置 + create_data = settings_in.model_dump() + create_data.update({ + "course_id": course_id, + "created_by": user_id, + "updated_by": user_id + }) + + new_settings = CourseExamSettings(**create_data) + db.add(new_settings) + + await db.commit() + await db.refresh(new_settings) + + logger.info(f"创建课程考试设置成功", course_id=course_id, user_id=user_id) + return new_settings + + async def update( + self, + db: AsyncSession, + course_id: int, + settings_in: CourseExamSettingsUpdate, + user_id: int + ) -> CourseExamSettings: + """ + 更新课程考试设置 + + Args: + db: 数据库会话 + course_id: 课程ID + settings_in: 更新的考试设置数据 + user_id: 操作用户ID + + Returns: + 更新后的考试设置实例 + """ + # 获取现有设置 + settings = await self.get_by_course_id(db, course_id) + if not settings: + # 如果不存在,创建新的 + create_data = settings_in.model_dump(exclude_unset=True) + return await self.create_or_update( + db, + course_id=course_id, + settings_in=CourseExamSettingsCreate(**create_data), + user_id=user_id + ) + + # 更新设置 + update_data = settings_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(settings, field, value) + settings.updated_by = user_id + + await db.commit() + await db.refresh(settings) + + logger.info(f"更新课程考试设置成功", course_id=course_id, user_id=user_id) + return settings + + +# 创建服务实例 +course_exam_service = CourseExamService() diff --git a/backend/app/services/course_position_service.py b/backend/app/services/course_position_service.py new file mode 100644 index 0000000..4b9531e --- /dev/null +++ b/backend/app/services/course_position_service.py @@ -0,0 +1,194 @@ +""" +课程岗位分配服务 +""" +from typing import List, Optional +from sqlalchemy import select, and_, delete, func +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.logger import get_logger +from app.models.position_course import PositionCourse +from app.models.position import Position +from app.models.position_member import PositionMember +from app.schemas.course import CoursePositionAssignment, CoursePositionAssignmentInDB +from app.services.base_service import BaseService + +logger = get_logger(__name__) + + +class CoursePositionService(BaseService[PositionCourse]): + """课程岗位分配服务""" + + def __init__(self): + super().__init__(PositionCourse) + + async def get_course_positions( + self, + db: AsyncSession, + course_id: int, + course_type: Optional[str] = None + ) -> List[CoursePositionAssignmentInDB]: + """ + 获取课程的岗位分配列表 + + Args: + db: 数据库会话 + course_id: 课程ID + course_type: 课程类型筛选 + + Returns: + 岗位分配列表 + """ + # 构建查询 + conditions = [ + PositionCourse.course_id == course_id, + PositionCourse.is_deleted == False + ] + + if course_type: + conditions.append(PositionCourse.course_type == course_type) + + stmt = ( + select(PositionCourse) + .options(selectinload(PositionCourse.position)) + .where(and_(*conditions)) + .order_by(PositionCourse.priority, PositionCourse.id) + ) + + result = await db.execute(stmt) + assignments = result.scalars().all() + + # 转换为返回格式,并查询每个岗位的成员数量 + result_list = [] + for assignment in assignments: + # 查询岗位成员数量 + member_count = 0 + if assignment.position_id: + member_count_result = await db.execute( + select(func.count(PositionMember.id)).where( + and_( + PositionMember.position_id == assignment.position_id, + PositionMember.is_deleted == False + ) + ) + ) + member_count = member_count_result.scalar() or 0 + + result_list.append( + CoursePositionAssignmentInDB( + id=assignment.id, + course_id=assignment.course_id, + position_id=assignment.position_id, + course_type=assignment.course_type, + priority=assignment.priority, + position_name=assignment.position.name if assignment.position else None, + position_description=assignment.position.description if assignment.position else None, + member_count=member_count + ) + ) + + return result_list + + async def batch_assign_positions( + self, + db: AsyncSession, + course_id: int, + assignments: List[CoursePositionAssignment], + user_id: int + ) -> List[CoursePositionAssignmentInDB]: + """ + 批量分配课程到岗位 + + Args: + db: 数据库会话 + course_id: 课程ID + assignments: 岗位分配列表 + user_id: 操作用户ID + + Returns: + 分配结果列表 + """ + created_assignments = [] + + for assignment in assignments: + # 检查是否已存在(注意:Result 只能消费一次,需保存结果) + result = await db.execute( + select(PositionCourse).where( + PositionCourse.course_id == course_id, + PositionCourse.position_id == assignment.position_id, + PositionCourse.is_deleted == False, + ) + ) + existing_assignment = result.scalar_one_or_none() + + if existing_assignment: + # 已存在则更新类型与优先级 + existing_assignment.course_type = assignment.course_type + existing_assignment.priority = assignment.priority + # PositionCourse 未继承 AuditMixin,不强制写入审计字段 + created_assignments.append(existing_assignment) + else: + # 新建分配关系 + new_assignment = PositionCourse( + course_id=course_id, + position_id=assignment.position_id, + course_type=assignment.course_type, + priority=assignment.priority, + ) + db.add(new_assignment) + created_assignments.append(new_assignment) + + await db.commit() + + # 重新加载关联数据 + for obj in created_assignments: + await db.refresh(obj) + + logger.info("批量分配课程到岗位成功", course_id=course_id, count=len(assignments), user_id=user_id) + + # 返回分配结果 + return await self.get_course_positions(db, course_id) + + async def remove_position_assignment( + self, + db: AsyncSession, + course_id: int, + position_id: int, + user_id: int + ) -> bool: + """ + 移除课程的岗位分配 + + Args: + db: 数据库会话 + course_id: 课程ID + position_id: 岗位ID + user_id: 操作用户ID + + Returns: + 是否成功 + """ + # 查找分配记录 + stmt = select(PositionCourse).where( + PositionCourse.course_id == course_id, + PositionCourse.position_id == position_id, + PositionCourse.is_deleted == False + ) + + result = await db.execute(stmt) + assignment = result.scalar_one_or_none() + + if assignment: + # 软删除 + assignment.is_deleted = True + assignment.deleted_by = user_id + await db.commit() + + logger.info(f"移除课程岗位分配成功", course_id=course_id, position_id=position_id, user_id=user_id) + return True + + return False + + +# 创建服务实例 +course_position_service = CoursePositionService() diff --git a/backend/app/services/course_service.py b/backend/app/services/course_service.py new file mode 100644 index 0000000..b0a825a --- /dev/null +++ b/backend/app/services/course_service.py @@ -0,0 +1,837 @@ +""" +课程服务层 +""" +from typing import Optional, List, Dict, Any +from datetime import datetime + +from sqlalchemy import select, or_, and_, func +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.logger import get_logger +from app.core.exceptions import NotFoundError, BadRequestError, ConflictError +from app.models.course import ( + Course, + CourseStatus, + CourseMaterial, + KnowledgePoint, + GrowthPath, +) +from app.models.course_exam_settings import CourseExamSettings +from app.models.position_member import PositionMember +from app.models.position_course import PositionCourse +from app.schemas.course import ( + CourseCreate, + CourseUpdate, + CourseList, + CourseInDB, + CourseMaterialCreate, + KnowledgePointCreate, + KnowledgePointUpdate, + KnowledgePointInDB, + GrowthPathCreate, +) +from app.schemas.base import PaginationParams, PaginatedResponse +from app.services.base_service import BaseService + +logger = get_logger(__name__) + + +class CourseService(BaseService[Course]): + """ + 课程服务类 + """ + + def __init__(self): + super().__init__(Course) + + async def get_course_list( + self, + db: AsyncSession, + *, + page_params: PaginationParams, + filters: CourseList, + user_id: Optional[int] = None, + ) -> PaginatedResponse[CourseInDB]: + """ + 获取课程列表(支持筛选) + + Args: + db: 数据库会话 + page_params: 分页参数 + filters: 筛选条件 + user_id: 用户ID(用于记录访问日志) + + Returns: + 分页的课程列表 + """ + # 构建筛选条件 + filter_conditions = [] + + # 状态筛选(默认只显示已发布的课程) + if filters.status is not None: + filter_conditions.append(Course.status == filters.status) + else: + # 如果没有指定状态,默认只返回已发布的课程 + filter_conditions.append(Course.status == CourseStatus.PUBLISHED) + + # 分类筛选 + if filters.category is not None: + filter_conditions.append(Course.category == filters.category) + + # 是否推荐筛选 + if filters.is_featured is not None: + filter_conditions.append(Course.is_featured == filters.is_featured) + + # 关键词搜索 + if filters.keyword: + keyword = f"%{filters.keyword}%" + filter_conditions.append( + or_(Course.name.like(keyword), Course.description.like(keyword)) + ) + + # 记录查询日志 + logger.info( + "查询课程列表", + user_id=user_id, + filters=filters.model_dump(exclude_none=True), + page=page_params.page, + size=page_params.page_size, + ) + + # 执行分页查询 + query = select(Course).where(Course.is_deleted == False) + + # 添加筛选条件 + if filter_conditions: + query = query.where(and_(*filter_conditions)) + + # 添加排序:优先按sort_order升序,其次按创建时间降序(新课程优先) + query = query.order_by(Course.sort_order.asc(), Course.created_at.desc()) + + # 获取总数 + count_query = ( + select(func.count()).select_from(Course).where(Course.is_deleted == False) + ) + if filter_conditions: + count_query = count_query.where(and_(*filter_conditions)) + + total_result = await db.execute(count_query) + total = total_result.scalar() or 0 + + # 分页 + query = query.offset(page_params.offset).limit(page_params.limit) + query = query.options(selectinload(Course.materials)) + + # 执行查询 + result = await db.execute(query) + courses = result.scalars().all() + + # 获取用户所属的岗位ID列表 + user_position_ids = [] + if user_id: + position_result = await db.execute( + select(PositionMember.position_id).where( + PositionMember.user_id == user_id, + PositionMember.is_deleted == False + ) + ) + user_position_ids = [row[0] for row in position_result.fetchall()] + + # 批量查询课程的岗位分配信息 + course_ids = [c.id for c in courses] + course_type_map = {} + if course_ids and user_position_ids: + position_course_result = await db.execute( + select(PositionCourse.course_id, PositionCourse.course_type).where( + PositionCourse.course_id.in_(course_ids), + PositionCourse.position_id.in_(user_position_ids), + PositionCourse.is_deleted == False + ) + ) + # 构建课程类型映射:如果有多个岗位,优先取required + for course_id, course_type in position_course_result.fetchall(): + if course_id not in course_type_map: + course_type_map[course_id] = course_type + elif course_type == 'required': + course_type_map[course_id] = 'required' + + # 转换为 Pydantic 模型,并附加课程类型 + course_list = [] + for course in courses: + course_data = CourseInDB.model_validate(course) + # 设置课程类型:如果用户有岗位分配则使用分配类型,否则为None + course_data.course_type = course_type_map.get(course.id) + course_list.append(course_data) + + # 计算总页数 + pages = (total + page_params.page_size - 1) // page_params.page_size + + return PaginatedResponse( + items=course_list, + total=total, + page=page_params.page, + page_size=page_params.page_size, + pages=pages, + ) + + async def create_course( + self, db: AsyncSession, *, course_in: CourseCreate, created_by: int + ) -> Course: + """ + 创建课程 + + Args: + db: 数据库会话 + course_in: 课程创建数据 + created_by: 创建人ID + + Returns: + 创建的课程 + """ + # 检查名称是否重复 + existing = await db.execute( + select(Course).where( + and_(Course.name == course_in.name, Course.is_deleted == False) + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"课程名称 '{course_in.name}' 已存在") + + # 创建课程 + course_data = course_in.model_dump() + course = await self.create(db, obj_in=course_data, created_by=created_by) + + # 自动创建默认考试设置 + default_exam_settings = CourseExamSettings( + course_id=course.id, + created_by=created_by, + updated_by=created_by + # 其他字段使用模型定义的默认值: + # single_choice_count=4, multiple_choice_count=2, true_false_count=1, + # fill_blank_count=2, essay_count=1, duration_minutes=10, 等 + ) + db.add(default_exam_settings) + await db.commit() + await db.refresh(course) + + logger.info( + "创建课程", course_id=course.id, course_name=course.name, created_by=created_by + ) + logger.info( + "自动创建默认考试设置", course_id=course.id, exam_settings_id=default_exam_settings.id + ) + + return course + + async def update_course( + self, + db: AsyncSession, + *, + course_id: int, + course_in: CourseUpdate, + updated_by: int, + ) -> Course: + """ + 更新课程 + + Args: + db: 数据库会话 + course_id: 课程ID + course_in: 课程更新数据 + updated_by: 更新人ID + + Returns: + 更新后的课程 + """ + # 获取课程 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 检查名称是否重复(如果修改了名称) + if course_in.name and course_in.name != course.name: + existing = await db.execute( + select(Course).where( + and_( + Course.name == course_in.name, + Course.id != course_id, + Course.is_deleted == False, + ) + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"课程名称 '{course_in.name}' 已存在") + + # 记录状态变更 + old_status = course.status + + # 更新课程 + update_data = course_in.model_dump(exclude_unset=True) + + # 如果状态变为已发布,记录发布时间 + if ( + update_data.get("status") == CourseStatus.PUBLISHED + and old_status != CourseStatus.PUBLISHED + ): + update_data["published_at"] = datetime.now() + update_data["publisher_id"] = updated_by + + course = await self.update( + db, db_obj=course, obj_in=update_data, updated_by=updated_by + ) + + logger.info( + "更新课程", + course_id=course.id, + course_name=course.name, + old_status=old_status, + new_status=course.status, + updated_by=updated_by, + ) + + return course + + async def delete_course( + self, db: AsyncSession, *, course_id: int, deleted_by: int + ) -> bool: + """ + 删除课程(软删除 + 删除相关文件) + + Args: + db: 数据库会话 + course_id: 课程ID + deleted_by: 删除人ID + + Returns: + 是否删除成功 + """ + import shutil + from pathlib import Path + from app.core.config import settings + + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 放开删除限制:任意状态均可软删除,由业务方自行控制 + + # 执行软删除(标记 is_deleted,记录删除时间),由审计日志记录操作者 + success = await self.soft_delete(db, id=course_id) + + if success: + # 删除课程文件夹及其所有内容 + course_folder = Path(settings.UPLOAD_PATH) / "courses" / str(course_id) + if course_folder.exists() and course_folder.is_dir(): + try: + shutil.rmtree(course_folder) + logger.info( + "删除课程文件夹成功", + course_id=course_id, + folder_path=str(course_folder), + ) + except Exception as e: + # 文件夹删除失败不影响业务流程,仅记录日志 + logger.error( + "删除课程文件夹失败", + course_id=course_id, + folder_path=str(course_folder), + error=str(e), + ) + + logger.warning( + "删除课程", + course_id=course_id, + course_name=course.name, + deleted_by=deleted_by, + folder_deleted=course_folder.exists(), + ) + + return success + + async def add_course_material( + self, + db: AsyncSession, + *, + course_id: int, + material_in: CourseMaterialCreate, + created_by: int, + ) -> CourseMaterial: + """ + 添加课程资料 + + Args: + db: 数据库会话 + course_id: 课程ID + material_in: 资料创建数据 + created_by: 创建人ID + + Returns: + 创建的课程资料 + """ + # 检查课程是否存在 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建资料 + material_data = material_in.model_dump() + material_data.update({ + "course_id": course_id, + "created_by": created_by, + "updated_by": created_by + }) + + material = CourseMaterial(**material_data) + db.add(material) + await db.commit() + await db.refresh(material) + + logger.info( + "添加课程资料", + course_id=course_id, + material_id=material.id, + material_name=material.name, + file_type=material.file_type, + file_size=material.file_size, + created_by=created_by, + ) + + return material + + async def get_course_materials( + self, + db: AsyncSession, + *, + course_id: int, + ) -> List[CourseMaterial]: + """ + 获取课程资料列表 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 课程资料列表 + """ + # 确认课程存在 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + stmt = ( + select(CourseMaterial) + .where( + CourseMaterial.course_id == course_id, + CourseMaterial.is_deleted == False, + ) + .order_by(CourseMaterial.sort_order.asc(), CourseMaterial.id.asc()) + ) + result = await db.execute(stmt) + materials = result.scalars().all() + + logger.info( + "查询课程资料列表", course_id=course_id, count=len(materials) + ) + + return materials + + async def delete_course_material( + self, + db: AsyncSession, + *, + course_id: int, + material_id: int, + deleted_by: int, + ) -> bool: + """ + 删除课程资料(软删除 + 删除物理文件) + + Args: + db: 数据库会话 + course_id: 课程ID + material_id: 资料ID + deleted_by: 删除人ID + + Returns: + 是否删除成功 + """ + import os + from pathlib import Path + from app.core.config import settings + + # 先确认课程存在 + course = await self.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 查找资料并校验归属 + material_stmt = select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.course_id == course_id, + CourseMaterial.is_deleted == False, + ) + result = await db.execute(material_stmt) + material = result.scalar_one_or_none() + if not material: + raise NotFoundError(f"课程资料ID {material_id} 不存在或已删除") + + # 获取文件路径信息用于删除物理文件 + file_url = material.file_url + + # 软删除数据库记录 + material.is_deleted = True + material.deleted_at = datetime.now() + if hasattr(material, "deleted_by"): + # 兼容存在该字段的表 + setattr(material, "deleted_by", deleted_by) + + db.add(material) + await db.commit() + + # 删除物理文件 + if file_url and file_url.startswith("/static/uploads/"): + try: + # 从URL中提取相对路径 + relative_path = file_url.replace("/static/uploads/", "") + file_path = Path(settings.UPLOAD_PATH) / relative_path + + # 检查文件是否存在并删除 + if file_path.exists() and file_path.is_file(): + os.remove(file_path) + logger.info( + "删除物理文件成功", + file_path=str(file_path), + material_id=material_id, + ) + except Exception as e: + # 物理文件删除失败不影响业务流程,仅记录日志 + logger.error( + "删除物理文件失败", + file_url=file_url, + material_id=material_id, + error=str(e), + ) + + logger.warning( + "删除课程资料", + course_id=course_id, + material_id=material_id, + deleted_by=deleted_by, + file_deleted=file_url is not None, + ) + return True + + async def get_material_knowledge_points( + self, db: AsyncSession, material_id: int + ) -> List[KnowledgePointInDB]: + """获取资料关联的知识点列表""" + + # 获取资料信息 + result = await db.execute( + select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.is_deleted == False + ) + ) + material = result.scalar_one_or_none() + + if not material: + raise NotFoundError(f"资料ID {material_id} 不存在") + + # 直接查询关联到该资料的知识点 + query = select(KnowledgePoint).where( + KnowledgePoint.material_id == material_id, + KnowledgePoint.is_deleted == False + ).order_by(KnowledgePoint.created_at.desc()) + + result = await db.execute(query) + knowledge_points = result.scalars().all() + + from app.schemas.course import KnowledgePointInDB + return [KnowledgePointInDB.model_validate(kp) for kp in knowledge_points] + + async def add_material_knowledge_points( + self, db: AsyncSession, material_id: int, knowledge_point_ids: List[int] + ) -> List[KnowledgePointInDB]: + """ + 为资料添加知识点关联 + + 注意:自2025-09-27起,知识点直接通过material_id关联到资料, + material_knowledge_points中间表已废弃。此方法将更新知识点的material_id字段。 + """ + # 验证资料是否存在 + result = await db.execute( + select(CourseMaterial).where( + CourseMaterial.id == material_id, + CourseMaterial.is_deleted == False + ) + ) + material = result.scalar_one_or_none() + + if not material: + raise NotFoundError(f"资料ID {material_id} 不存在") + + # 验证知识点是否存在且属于同一课程 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.id.in_(knowledge_point_ids), + KnowledgePoint.course_id == material.course_id, + KnowledgePoint.is_deleted == False + ) + ) + valid_knowledge_points = result.scalars().all() + + if len(valid_knowledge_points) != len(knowledge_point_ids): + raise BadRequestError("部分知识点不存在或不属于同一课程") + + # 更新知识点的material_id字段 + added_knowledge_points = [] + for kp in valid_knowledge_points: + # 更新知识点的资料关联 + kp.material_id = material_id + added_knowledge_points.append(kp) + + await db.commit() + + # 刷新对象以获取更新后的数据 + for kp in added_knowledge_points: + await db.refresh(kp) + + from app.schemas.course import KnowledgePointInDB + return [KnowledgePointInDB.model_validate(kp) for kp in added_knowledge_points] + + async def remove_material_knowledge_point( + self, db: AsyncSession, material_id: int, knowledge_point_id: int + ) -> bool: + """ + 移除资料的知识点关联(软删除知识点) + + 注意:自2025-09-27起,知识点直接通过material_id关联到资料, + material_knowledge_points中间表已废弃。此方法将软删除知识点。 + """ + # 查找知识点并验证归属 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.id == knowledge_point_id, + KnowledgePoint.material_id == material_id, + KnowledgePoint.is_deleted == False + ) + ) + knowledge_point = result.scalar_one_or_none() + + if not knowledge_point: + raise NotFoundError(f"知识点ID {knowledge_point_id} 不存在或不属于该资料") + + # 软删除知识点 + knowledge_point.is_deleted = True + knowledge_point.deleted_at = datetime.now() + + await db.commit() + + logger.info( + "移除资料知识点关联", + material_id=material_id, + knowledge_point_id=knowledge_point_id, + ) + + return True + + +class KnowledgePointService(BaseService[KnowledgePoint]): + """ + 知识点服务类 + """ + + def __init__(self): + super().__init__(KnowledgePoint) + + async def get_knowledge_points_by_course( + self, db: AsyncSession, *, course_id: int, material_id: Optional[int] = None + ) -> List[KnowledgePoint]: + """ + 获取课程的知识点列表 + + Args: + db: 数据库会话 + course_id: 课程ID + material_id: 资料ID(可选,用于筛选特定资料的知识点) + + Returns: + 知识点列表 + """ + query = select(KnowledgePoint).where( + and_( + KnowledgePoint.course_id == course_id, + KnowledgePoint.is_deleted == False, + ) + ) + + if material_id is not None: + query = query.where(KnowledgePoint.material_id == material_id) + + query = query.order_by(KnowledgePoint.created_at.desc()) + + result = await db.execute(query) + return result.scalars().all() + + async def create_knowledge_point( + self, + db: AsyncSession, + *, + course_id: int, + point_in: KnowledgePointCreate, + created_by: int, + ) -> KnowledgePoint: + """ + 创建知识点 + + Args: + db: 数据库会话 + course_id: 课程ID + point_in: 知识点创建数据 + created_by: 创建人ID + + Returns: + 创建的知识点 + """ + # 检查课程是否存在 + course_service = CourseService() + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建知识点 + point_data = point_in.model_dump() + point_data.update({"course_id": course_id}) + + knowledge_point = await self.create( + db, obj_in=point_data, created_by=created_by + ) + + logger.info( + "创建知识点", + course_id=course_id, + knowledge_point_id=knowledge_point.id, + knowledge_point_name=knowledge_point.name, + created_by=created_by, + ) + + return knowledge_point + + async def update_knowledge_point( + self, + db: AsyncSession, + *, + point_id: int, + point_in: KnowledgePointUpdate, + updated_by: int, + ) -> KnowledgePoint: + """ + 更新知识点 + + Args: + db: 数据库会话 + point_id: 知识点ID + point_in: 知识点更新数据 + updated_by: 更新人ID + + Returns: + 更新后的知识点 + """ + knowledge_point = await self.get_by_id(db, point_id) + if not knowledge_point: + raise NotFoundError(f"知识点ID {point_id} 不存在") + + # 验证关联资料是否存在 + if hasattr(point_in, 'material_id') and point_in.material_id: + result = await db.execute( + select(CourseMaterial).where( + CourseMaterial.id == point_in.material_id, + CourseMaterial.is_deleted == False + ) + ) + material = result.scalar_one_or_none() + if not material: + raise NotFoundError(f"资料ID {point_in.material_id} 不存在") + + # 更新知识点 + update_data = point_in.model_dump(exclude_unset=True) + knowledge_point = await self.update( + db, db_obj=knowledge_point, obj_in=update_data, updated_by=updated_by + ) + + logger.info( + "更新知识点", + knowledge_point_id=knowledge_point.id, + knowledge_point_name=knowledge_point.name, + updated_by=updated_by, + ) + + return knowledge_point + + +class GrowthPathService(BaseService[GrowthPath]): + """ + 成长路径服务类 + """ + + def __init__(self): + super().__init__(GrowthPath) + + async def create_growth_path( + self, db: AsyncSession, *, path_in: GrowthPathCreate, created_by: int + ) -> GrowthPath: + """ + 创建成长路径 + + Args: + db: 数据库会话 + path_in: 成长路径创建数据 + created_by: 创建人ID + + Returns: + 创建的成长路径 + """ + # 检查名称是否重复 + existing = await db.execute( + select(GrowthPath).where( + and_(GrowthPath.name == path_in.name, GrowthPath.is_deleted == False) + ) + ) + if existing.scalar_one_or_none(): + raise ConflictError(f"成长路径名称 '{path_in.name}' 已存在") + + # 验证课程是否存在 + if path_in.courses: + course_ids = [c.course_id for c in path_in.courses] + course_service = CourseService() + for course_id in course_ids: + course = await course_service.get_by_id(db, course_id) + if not course: + raise NotFoundError(f"课程ID {course_id} 不存在") + + # 创建成长路径 + path_data = path_in.model_dump() + # 转换课程列表为JSON格式 + if path_data.get("courses"): + path_data["courses"] = [c.model_dump() for c in path_in.courses] + + growth_path = await self.create(db, obj_in=path_data, created_by=created_by) + + logger.info( + "创建成长路径", + growth_path_id=growth_path.id, + growth_path_name=growth_path.name, + course_count=len(path_in.courses) if path_in.courses else 0, + created_by=created_by, + ) + + return growth_path + + +# 创建服务实例 +course_service = CourseService() +knowledge_point_service = KnowledgePointService() +growth_path_service = GrowthPathService() diff --git a/backend/app/services/course_statistics_service.py b/backend/app/services/course_statistics_service.py new file mode 100644 index 0000000..4be32f5 --- /dev/null +++ b/backend/app/services/course_statistics_service.py @@ -0,0 +1,65 @@ +""" +课程统计服务 +""" +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession +from app.models.exam import Exam +from app.models.course import Course +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class CourseStatisticsService: + """课程统计服务类""" + + async def update_course_student_count( + self, + db: AsyncSession, + course_id: int + ) -> int: + """ + 更新课程学员数统计 + + Args: + db: 数据库会话 + course_id: 课程ID + + Returns: + 更新后的学员数 + """ + try: + # 统计该课程的不同学员数(基于考试记录) + stmt = select(func.count(func.distinct(Exam.user_id))).where( + Exam.course_id == course_id, + Exam.is_deleted == False + ) + result = await db.execute(stmt) + student_count = result.scalar_one() or 0 + + # 更新课程表 + course_stmt = select(Course).where( + Course.id == course_id, + Course.is_deleted == False + ) + course_result = await db.execute(course_stmt) + course = course_result.scalar_one_or_none() + + if course: + course.student_count = student_count + await db.commit() + logger.info(f"更新课程 {course_id} 学员数: {student_count}") + return student_count + else: + logger.warning(f"课程 {course_id} 不存在") + return 0 + + except Exception as e: + logger.error(f"更新课程学员数失败: {str(e)}", exc_info=True) + await db.rollback() + raise + + +# 创建全局实例 +course_statistics_service = CourseStatisticsService() + diff --git a/backend/app/services/coze_broadcast_service.py b/backend/app/services/coze_broadcast_service.py new file mode 100644 index 0000000..ff724a5 --- /dev/null +++ b/backend/app/services/coze_broadcast_service.py @@ -0,0 +1,97 @@ +""" +Coze 播课服务 +""" + +import logging +from typing import Optional + +from cozepy.exception import CozeError + +from app.core.config import settings +from app.services.ai.coze.client import get_coze_client + +logger = logging.getLogger(__name__) + + +class CozeBroadcastService: + """Coze 播课服务""" + + def __init__(self): + """初始化配置""" + self.workflow_id = settings.COZE_BROADCAST_WORKFLOW_ID + self.space_id = settings.COZE_BROADCAST_SPACE_ID + + def _get_client(self): + """获取新的 Coze 客户端(每次调用都创建新认证,避免token过期)""" + return get_coze_client(force_new=True) + + async def trigger_workflow(self, course_id: int) -> None: + """ + 触发播课生成工作流(不等待结果) + + Coze工作流会: + 1. 生成播课音频 + 2. 直接将结果写入数据库 + + Args: + course_id: 课程ID + + Raises: + CozeError: Coze API 调用失败 + """ + logger.info( + f"触发播课生成工作流", + extra={ + "course_id": course_id, + "workflow_id": self.workflow_id, + "bot_id": settings.COZE_BROADCAST_BOT_ID or settings.COZE_PRACTICE_BOT_ID # 关联到同一工作空间的Bot + } + ) + + try: + # 每次调用都获取新客户端(确保OAuth token有效) + coze = self._get_client() + + # 调用工作流(触发即返回,不等待结果) + # 关键:添加bot_id参数,关联到OAuth应用下的Bot + import asyncio + result = await asyncio.to_thread( + coze.workflows.runs.create, + workflow_id=self.workflow_id, + parameters={"course_id": str(course_id)}, + bot_id=settings.COZE_BROADCAST_BOT_ID or settings.COZE_PRACTICE_BOT_ID # 关联Bot,确保OAuth权限 + ) + + logger.info( + f"播课生成工作流已触发", + extra={ + "course_id": course_id, + "execute_id": getattr(result, 'execute_id', None), + "debug_url": getattr(result, 'debug_url', None) + } + ) + + except CozeError as e: + logger.error( + f"触发 Coze 工作流失败", + extra={ + "course_id": course_id, + "error": str(e), + "error_code": getattr(e, 'code', None) + } + ) + raise + except Exception as e: + logger.error( + f"触发播课生成工作流异常", + extra={ + "course_id": course_id, + "error": str(e), + "error_type": type(e).__name__ + } + ) + raise + + +# 全局单例 +broadcast_service = CozeBroadcastService() diff --git a/backend/app/services/coze_service.py b/backend/app/services/coze_service.py new file mode 100644 index 0000000..ca08efd --- /dev/null +++ b/backend/app/services/coze_service.py @@ -0,0 +1,199 @@ +""" +Coze AI对话服务 +""" +import logging +from typing import Optional +from cozepy import Coze, COZE_CN_BASE_URL, Message +from cozepy.exception import CozeError, CozeAPIError + +from app.core.config import settings +from app.services.ai.coze.client import get_auth_manager + +# 注意:不再直接使用 TokenAuth,统一通过 get_auth_manager() 管理认证 + +logger = logging.getLogger(__name__) + + +class CozeService: + """Coze对话服务""" + + def __init__(self): + """初始化Coze客户端""" + if not settings.COZE_PRACTICE_BOT_ID: + raise ValueError("COZE_PRACTICE_BOT_ID 未配置") + + self.bot_id = settings.COZE_PRACTICE_BOT_ID + self._auth_manager = get_auth_manager() + + logger.info( + f"CozeService初始化成功,Bot ID={self.bot_id}, " + f"Base URL={COZE_CN_BASE_URL}" + ) + + @property + def client(self) -> Coze: + """获取Coze客户端(每次获取确保OAuth token有效)""" + return self._auth_manager.get_client(force_new=True) + + def build_scene_prompt( + self, + scene_name: str, + scene_background: str, + scene_ai_role: str, + scene_objectives: list, + scene_keywords: Optional[list] = None, + scene_description: Optional[str] = None, + user_message: str = "" + ) -> str: + """ + 构建场景提示词(Markdown格式) + + 参数: + scene_name: 场景名称 + scene_background: 场景背景 + scene_ai_role: AI角色描述 + scene_objectives: 练习目标列表 + scene_keywords: 关键词列表 + scene_description: 场景描述(可选) + user_message: 用户第一句话 + + 返回: + 完整的场景提示词(Markdown格式) + """ + # 构建练习目标 + objectives_text = "\n".join( + f"{i+1}. {obj}" for i, obj in enumerate(scene_objectives) + ) + + # 构建关键词 + keywords_text = ", ".join(scene_keywords) if scene_keywords else "" + + # 构建完整提示词 + prompt = f"""# 陪练场景设定 + +## 场景名称 +{scene_name} +""" + + # 添加场景描述(如果有) + if scene_description: + prompt += f""" +## 场景描述 +{scene_description} +""" + + prompt += f""" +## 场景背景 +{scene_background} + +## AI角色要求 +{scene_ai_role} + +## 练习目标 +{objectives_text} +""" + + # 添加关键词(如果有) + if keywords_text: + prompt += f""" +## 关键词 +{keywords_text} +""" + + prompt += f""" +--- + +现在开始陪练对话。请你严格按照上述场景设定扮演角色,与学员进行实战对话练习。 +不要提及"场景设定"或"角色扮演"等元信息,直接进入角色开始对话。 + +学员的第一句话:{user_message} +""" + + return prompt + + def create_stream_chat( + self, + user_id: str, + message: str, + conversation_id: Optional[str] = None + ): + """ + 创建流式对话 + + 参数: + user_id: 用户ID + message: 消息内容 + conversation_id: 对话ID(续接对话时使用) + + 返回: + Coze流式对话迭代器 + """ + try: + logger.info( + f"创建Coze流式对话,user_id={user_id}, " + f"conversation_id={conversation_id}, " + f"message_length={len(message)}" + ) + + stream = self.client.chat.stream( + bot_id=self.bot_id, + user_id=user_id, + additional_messages=[Message.build_user_question_text(message)], + conversation_id=conversation_id + ) + + # 记录LogID用于排查问题 + if hasattr(stream, 'response') and hasattr(stream.response, 'logid'): + logger.info(f"Coze对话创建成功,logid={stream.response.logid}") + + return stream + + except (CozeError, CozeAPIError) as e: + logger.error(f"Coze API调用失败: {e}") + raise + except Exception as e: + logger.error(f"创建Coze对话失败: {e}") + raise + + def cancel_chat(self, conversation_id: str, chat_id: str): + """ + 中断对话 + + 参数: + conversation_id: 对话ID + chat_id: 聊天ID + """ + try: + logger.info(f"中断Coze对话,conversation_id={conversation_id}, chat_id={chat_id}") + + result = self.client.chat.cancel( + conversation_id=conversation_id, + chat_id=chat_id + ) + + logger.info(f"对话中断成功") + return result + + except (CozeError, CozeAPIError) as e: + logger.error(f"中断对话失败: {e}") + raise + except Exception as e: + logger.error(f"中断对话异常: {e}") + raise + + +# 单例模式 +_coze_service: Optional[CozeService] = None + + +def get_coze_service() -> CozeService: + """ + 获取CozeService单例 + + 用于FastAPI依赖注入 + """ + global _coze_service + if _coze_service is None: + _coze_service = CozeService() + return _coze_service + diff --git a/backend/app/services/document_converter.py b/backend/app/services/document_converter.py new file mode 100644 index 0000000..f68910d --- /dev/null +++ b/backend/app/services/document_converter.py @@ -0,0 +1,305 @@ +""" +文档转换服务 +使用 LibreOffice 将 Office 文档转换为 PDF +""" +import os +import logging +import subprocess +from pathlib import Path +from typing import Optional +from datetime import datetime + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class DocumentConverterService: + """文档转换服务类""" + + # 支持转换的文件格式 + SUPPORTED_FORMATS = {'.docx', '.doc', '.pptx', '.ppt', '.xlsx', '.xls'} + + # Excel文件格式(需要特殊处理页面布局) + EXCEL_FORMATS = {'.xlsx', '.xls'} + + def __init__(self): + """初始化转换服务""" + self.converted_path = Path(settings.UPLOAD_PATH) / "converted" + self.converted_path.mkdir(parents=True, exist_ok=True) + + def get_converted_file_path(self, course_id: int, material_id: int) -> Path: + """ + 获取转换后的文件路径 + + Args: + course_id: 课程ID + material_id: 资料ID + + Returns: + 转换后的PDF文件路径 + """ + course_dir = self.converted_path / str(course_id) + course_dir.mkdir(parents=True, exist_ok=True) + return course_dir / f"{material_id}.pdf" + + def need_convert(self, source_file: Path, converted_file: Path) -> bool: + """ + 判断是否需要重新转换 + + Args: + source_file: 源文件路径 + converted_file: 转换后的文件路径 + + Returns: + 是否需要转换 + """ + # 如果转换文件不存在,需要转换 + if not converted_file.exists(): + return True + + # 如果源文件不存在,不需要转换 + if not source_file.exists(): + return False + + # 如果源文件修改时间晚于转换文件,需要重新转换 + source_mtime = source_file.stat().st_mtime + converted_mtime = converted_file.stat().st_mtime + + return source_mtime > converted_mtime + + def convert_excel_to_html( + self, + source_file: str, + course_id: int, + material_id: int + ) -> Optional[str]: + """ + 将Excel文件转换为HTML(避免PDF分页问题) + + Args: + source_file: 源文件路径 + course_id: 课程ID + material_id: 资料ID + + Returns: + 转换后的HTML文件URL,失败返回None + """ + try: + try: + import openpyxl + from openpyxl.utils import get_column_letter + except ImportError as ie: + logger.error(f"Excel转换依赖缺失: openpyxl 未安装。请运行 pip install openpyxl 或重建Docker镜像。错误: {str(ie)}") + return None + + source_path = Path(source_file) + logger.info(f"开始Excel转HTML: source={source_file}, course_id={course_id}, material_id={material_id}") + + # 获取HTML输出路径 + course_dir = self.converted_path / str(course_id) + course_dir.mkdir(parents=True, exist_ok=True) + html_file = course_dir / f"{material_id}.html" + + # 检查缓存 + if html_file.exists(): + source_mtime = source_path.stat().st_mtime + html_mtime = html_file.stat().st_mtime + if source_mtime <= html_mtime: + logger.info(f"使用缓存的HTML文件: {html_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.html" + + # 读取Excel文件 + wb = openpyxl.load_workbook(source_file, data_only=True) + + # 构建HTML + html_content = ''' + + + + + + +''' + + # 生成sheet选项卡 + sheet_names = wb.sheetnames + html_content += '
\n' + for i, name in enumerate(sheet_names): + active = 'active' if i == 0 else '' + html_content += f'
{name}
\n' + html_content += '
\n' + + # 生成每个sheet的表格 + for i, sheet_name in enumerate(sheet_names): + ws = wb[sheet_name] + active = 'active' if i == 0 else '' + html_content += f'
\n' + html_content += '
\n' + + # 获取有效数据范围 + max_row = ws.max_row or 1 + max_col = ws.max_column or 1 + + for row_idx in range(1, min(max_row + 1, 1001)): # 限制最多1000行 + html_content += '' + for col_idx in range(1, min(max_col + 1, 51)): # 限制最多50列 + cell = ws.cell(row=row_idx, column=col_idx) + value = cell.value if cell.value is not None else '' + tag = 'th' if row_idx == 1 else 'td' + # 转义HTML特殊字符 + if isinstance(value, str): + value = value.replace('&', '&').replace('<', '<').replace('>', '>') + html_content += f'<{tag}>{value}' + html_content += '\n' + + html_content += '
\n' + + # 添加JavaScript + html_content += ''' + + +''' + + # 写入HTML文件 + with open(html_file, 'w', encoding='utf-8') as f: + f.write(html_content) + + logger.info(f"Excel转HTML成功: {html_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.html" + + except Exception as e: + logger.error(f"Excel转HTML失败: {source_file}, 错误: {str(e)}", exc_info=True) + return None + + def convert_to_pdf( + self, + source_file: str, + course_id: int, + material_id: int + ) -> Optional[str]: + """ + 将Office文档转换为PDF + + Args: + source_file: 源文件路径(绝对路径或相对路径) + course_id: 课程ID + material_id: 资料ID + + Returns: + 转换后的PDF文件URL,失败返回None + """ + try: + source_path = Path(source_file) + + # 检查源文件是否存在 + if not source_path.exists(): + logger.error(f"源文件不存在: {source_file}") + return None + + # 检查文件格式是否支持 + file_ext = source_path.suffix.lower() + if file_ext not in self.SUPPORTED_FORMATS: + logger.error(f"不支持的文件格式: {file_ext}") + return None + + # Excel文件使用HTML预览(避免分页问题) + if file_ext in self.EXCEL_FORMATS: + return self.convert_excel_to_html(source_file, course_id, material_id) + + # 获取转换后的文件路径 + converted_file = self.get_converted_file_path(course_id, material_id) + + # 检查是否需要转换 + if not self.need_convert(source_path, converted_file): + logger.info(f"使用缓存的转换文件: {converted_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.pdf" + + # 执行转换 + logger.info(f"开始转换文档: {source_file} -> {converted_file}") + + # 使用 LibreOffice 转换 + # --headless: 无界面模式 + # --convert-to pdf: 转换为PDF + # --outdir: 输出目录 + output_dir = converted_file.parent + + cmd = [ + 'libreoffice', + '--headless', + '--convert-to', 'pdf', + '--outdir', str(output_dir), + str(source_path) + ] + + # 执行转换命令(设置超时时间为60秒) + result = subprocess.run( + cmd, + capture_output=True, + text=True, + timeout=60, + check=True + ) + + # LibreOffice 转换后的文件名是源文件名.pdf + # 需要重命名为 material_id.pdf + temp_converted = output_dir / f"{source_path.stem}.pdf" + if temp_converted.exists() and temp_converted != converted_file: + temp_converted.rename(converted_file) + + # 检查转换结果 + if converted_file.exists(): + logger.info(f"文档转换成功: {converted_file}") + return f"/static/uploads/converted/{course_id}/{material_id}.pdf" + else: + logger.error(f"文档转换失败,输出文件不存在: {converted_file}") + return None + + except subprocess.TimeoutExpired: + logger.error(f"文档转换超时: {source_file}") + return None + except subprocess.CalledProcessError as e: + logger.error(f"文档转换失败: {source_file}, 错误: {e.stderr}") + return None + except Exception as e: + logger.error(f"文档转换异常: {source_file}, 错误: {str(e)}", exc_info=True) + return None + + def is_convertible(self, file_ext: str) -> bool: + """ + 判断文件格式是否可转换 + + Args: + file_ext: 文件扩展名(带点,如 .docx) + + Returns: + 是否可转换 + """ + return file_ext.lower() in self.SUPPORTED_FORMATS + + +# 创建全局实例 +document_converter = DocumentConverterService() + diff --git a/backend/app/services/employee_sync_service.py b/backend/app/services/employee_sync_service.py new file mode 100644 index 0000000..5e93c73 --- /dev/null +++ b/backend/app/services/employee_sync_service.py @@ -0,0 +1,739 @@ +""" +员工同步服务 +从外部钉钉员工表同步员工数据到考培练系统 +""" + +from typing import List, Dict, Any, Optional, Tuple +from datetime import datetime +from sqlalchemy import select, text +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import selectinload +import asyncio + +from app.core.logger import get_logger +from app.core.security import get_password_hash +from app.models.user import User, Team +from app.models.position import Position +from app.models.position_member import PositionMember +from app.schemas.user import UserCreate + +logger = get_logger(__name__) + + +class EmployeeSyncService: + """员工同步服务""" + + # 外部数据库连接配置 + EXTERNAL_DB_URL = "mysql+aiomysql://neuron_new:NWxGM6CQoMLKyEszXhfuLBIIo1QbeK@120.77.144.233:29613/neuron_new?charset=utf8mb4" + + def __init__(self, db: AsyncSession): + self.db = db + self.external_engine = None + + async def __aenter__(self): + """异步上下文管理器入口""" + self.external_engine = create_async_engine( + self.EXTERNAL_DB_URL, + echo=False, + pool_pre_ping=True, + pool_recycle=3600 + ) + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """异步上下文管理器出口""" + if self.external_engine: + await self.external_engine.dispose() + + async def fetch_employees_from_dingtalk(self) -> List[Dict[str, Any]]: + """ + 从钉钉员工表获取在职员工数据 + + Returns: + 员工数据列表 + """ + logger.info("开始从钉钉员工表获取数据...") + + query = """ + SELECT + 员工姓名, + 手机号, + 邮箱, + 所属部门, + 职位, + 工号, + 是否领导, + 是否在职, + 钉钉用户ID, + 入职日期, + 工作地点 + FROM v_钉钉员工表 + WHERE 是否在职 = 1 + ORDER BY 员工姓名 + """ + + async with self.external_engine.connect() as conn: + result = await conn.execute(text(query)) + rows = result.fetchall() + + employees = [] + for row in rows: + employees.append({ + 'full_name': row[0], + 'phone': row[1], + 'email': row[2], + 'department': row[3], + 'position': row[4], + 'employee_no': row[5], + 'is_leader': bool(row[6]), + 'is_active': bool(row[7]), + 'dingtalk_id': row[8], + 'join_date': row[9], + 'work_location': row[10] + }) + + logger.info(f"获取到 {len(employees)} 条在职员工数据") + return employees + + def generate_email(self, phone: str, original_email: Optional[str]) -> Optional[str]: + """ + 生成邮箱地址 + 如果原始邮箱为空或格式无效,生成 {手机号}@rxm.com + + Args: + phone: 手机号 + original_email: 原始邮箱 + + Returns: + 邮箱地址 + """ + if original_email and original_email.strip(): + email = original_email.strip() + # 验证邮箱格式:检查是否有@后直接跟点号等无效格式 + if '@' in email and not email.startswith('@') and not email.endswith('@'): + # 检查@后面是否直接是点号 + at_index = email.index('@') + if at_index + 1 < len(email) and email[at_index + 1] != '.': + # 检查是否有域名部分 + domain = email[at_index + 1:] + if '.' in domain and len(domain) > 2: + return email + + # 如果邮箱无效或为空,使用手机号生成 + if phone: + return f"{phone}@rxm.com" + + return None + + def determine_role(self, is_leader: bool) -> str: + """ + 确定用户角色 + + Args: + is_leader: 是否领导 + + Returns: + 角色: manager 或 trainee + """ + return 'manager' if is_leader else 'trainee' + + async def create_or_get_team(self, department_name: str, leader_id: Optional[int] = None) -> Team: + """ + 创建或获取部门团队 + + Args: + department_name: 部门名称 + leader_id: 负责人ID + + Returns: + 团队对象 + """ + if not department_name or department_name.strip() == '': + return None + + department_name = department_name.strip() + + # 检查团队是否已存在 + stmt = select(Team).where( + Team.name == department_name, + Team.is_deleted == False + ) + result = await self.db.execute(stmt) + team = result.scalar_one_or_none() + + if team: + # 更新负责人 + if leader_id and not team.leader_id: + team.leader_id = leader_id + logger.info(f"更新团队 {department_name} 的负责人") + return team + + # 创建新团队 + # 生成团队代码:使用部门名称的拼音首字母或简化处理 + team_code = f"DEPT_{hash(department_name) % 100000:05d}" + + team = Team( + name=department_name, + code=team_code, + description=f"{department_name}", + team_type='department', + is_active=True, + leader_id=leader_id + ) + + self.db.add(team) + await self.db.flush() # 获取ID但不提交 + + logger.info(f"创建团队: {department_name} (ID: {team.id})") + return team + + async def create_or_get_position(self, position_name: str) -> Optional[Position]: + """ + 创建或获取岗位 + + Args: + position_name: 岗位名称 + + Returns: + 岗位对象 + """ + if not position_name or position_name.strip() == '': + return None + + position_name = position_name.strip() + + # 检查岗位是否已存在 + stmt = select(Position).where( + Position.name == position_name, + Position.is_deleted == False + ) + result = await self.db.execute(stmt) + position = result.scalar_one_or_none() + + if position: + return position + + # 创建新岗位 + position_code = f"POS_{hash(position_name) % 100000:05d}" + + position = Position( + name=position_name, + code=position_code, + description=f"{position_name}", + status='active' + ) + + self.db.add(position) + await self.db.flush() + + logger.info(f"创建岗位: {position_name} (ID: {position.id})") + return position + + async def create_user(self, employee_data: Dict[str, Any]) -> Optional[User]: + """ + 创建用户 + + Args: + employee_data: 员工数据 + + Returns: + 用户对象或None(如果创建失败) + """ + phone = employee_data.get('phone') + full_name = employee_data.get('full_name') + + if not phone: + logger.warning(f"员工 {full_name} 没有手机号,跳过") + return None + + # 检查用户是否已存在(通过手机号) + stmt = select(User).where( + User.phone == phone, + User.is_deleted == False + ) + result = await self.db.execute(stmt) + existing_user = result.scalar_one_or_none() + + if existing_user: + logger.info(f"用户已存在: {phone} ({full_name})") + return existing_user + + # 生成邮箱 + email = self.generate_email(phone, employee_data.get('email')) + + # 检查邮箱是否已被其他用户使用(避免唯一索引冲突) + if email: + email_check_stmt = select(User).where( + User.email == email, + User.is_deleted == False + ) + email_result = await self.db.execute(email_check_stmt) + if email_result.scalar_one_or_none(): + # 邮箱已存在,使用手机号生成唯一邮箱 + email = f"{phone}@rxm.com" + logger.warning(f"邮箱 {employee_data.get('email')} 已被使用,为员工 {full_name} 生成新邮箱: {email}") + + # 确定角色 + role = self.determine_role(employee_data.get('is_leader', False)) + + # 创建用户 + user = User( + username=phone, # 使用手机号作为用户名 + email=email, + phone=phone, + hashed_password=get_password_hash('123456'), # 初始密码 + full_name=full_name, + role=role, + is_active=True, + is_verified=True + ) + + self.db.add(user) + await self.db.flush() + + logger.info(f"创建用户: {phone} ({full_name}) - 角色: {role}") + return user + + async def sync_employees(self) -> Dict[str, Any]: + """ + 执行完整的员工同步流程 + + Returns: + 同步结果统计 + """ + logger.info("=" * 60) + logger.info("开始员工同步") + logger.info("=" * 60) + + stats = { + 'total_employees': 0, + 'users_created': 0, + 'users_skipped': 0, + 'teams_created': 0, + 'positions_created': 0, + 'errors': [], + 'start_time': datetime.now() + } + + try: + # 1. 获取员工数据 + employees = await self.fetch_employees_from_dingtalk() + stats['total_employees'] = len(employees) + + if not employees: + logger.warning("没有获取到员工数据") + return stats + + # 2. 创建用户和相关数据 + for employee in employees: + try: + # 创建用户 + user = await self.create_user(employee) + if not user: + stats['users_skipped'] += 1 + continue + + stats['users_created'] += 1 + + # 创建部门团队 + department = employee.get('department') + if department: + team = await self.create_or_get_team( + department, + leader_id=user.id if employee.get('is_leader') else None + ) + if team: + # 用SQL直接插入user_teams关联表(避免懒加载问题) + await self._add_user_to_team(user.id, team.id) + logger.info(f"关联用户 {user.full_name} 到团队 {team.name}") + + # 创建岗位 + position_name = employee.get('position') + if position_name: + position = await self.create_or_get_position(position_name) + if position: + # 检查是否已经关联 + stmt = select(PositionMember).where( + PositionMember.position_id == position.id, + PositionMember.user_id == user.id, + PositionMember.is_deleted == False + ) + result = await self.db.execute(stmt) + existing_member = result.scalar_one_or_none() + + if not existing_member: + # 创建岗位成员关联 + position_member = PositionMember( + position_id=position.id, + user_id=user.id, + role='member' + ) + self.db.add(position_member) + logger.info(f"关联用户 {user.full_name} 到岗位 {position.name}") + + except Exception as e: + error_msg = f"处理员工 {employee.get('full_name')} 时出错: {str(e)}" + logger.error(error_msg) + stats['errors'].append(error_msg) + continue + + # 3. 提交所有更改 + await self.db.commit() + logger.info("✅ 数据库事务已提交") + + except Exception as e: + logger.error(f"员工同步失败: {str(e)}") + await self.db.rollback() + stats['errors'].append(str(e)) + raise + + finally: + stats['end_time'] = datetime.now() + stats['duration'] = (stats['end_time'] - stats['start_time']).total_seconds() + + # 4. 输出统计信息 + logger.info("=" * 60) + logger.info("同步完成统计") + logger.info("=" * 60) + logger.info(f"总员工数: {stats['total_employees']}") + logger.info(f"创建用户: {stats['users_created']}") + logger.info(f"跳过用户: {stats['users_skipped']}") + logger.info(f"耗时: {stats['duration']:.2f}秒") + + if stats['errors']: + logger.warning(f"错误数量: {len(stats['errors'])}") + for error in stats['errors']: + logger.warning(f" - {error}") + + return stats + + async def preview_sync_data(self) -> Dict[str, Any]: + """ + 预览待同步的员工数据(不执行实际同步) + + Returns: + 预览数据 + """ + logger.info("预览待同步员工数据...") + + employees = await self.fetch_employees_from_dingtalk() + + preview = { + 'total_count': len(employees), + 'employees': [], + 'departments': set(), + 'positions': set(), + 'leaders_count': 0, + 'trainees_count': 0 + } + + for emp in employees: + role = self.determine_role(emp.get('is_leader', False)) + email = self.generate_email(emp.get('phone'), emp.get('email')) + + preview['employees'].append({ + 'full_name': emp.get('full_name'), + 'phone': emp.get('phone'), + 'email': email, + 'department': emp.get('department'), + 'position': emp.get('position'), + 'role': role, + 'is_leader': emp.get('is_leader') + }) + + if emp.get('department'): + preview['departments'].add(emp.get('department')) + if emp.get('position'): + preview['positions'].add(emp.get('position')) + + if role == 'manager': + preview['leaders_count'] += 1 + else: + preview['trainees_count'] += 1 + + preview['departments'] = list(preview['departments']) + preview['positions'] = list(preview['positions']) + + return preview + + async def _add_user_to_team(self, user_id: int, team_id: int) -> None: + """ + 将用户添加到团队(直接SQL操作,避免懒加载问题) + + Args: + user_id: 用户ID + team_id: 团队ID + """ + # 先检查是否已存在关联 + check_result = await self.db.execute( + text("SELECT 1 FROM user_teams WHERE user_id = :user_id AND team_id = :team_id"), + {"user_id": user_id, "team_id": team_id} + ) + if check_result.scalar() is None: + # 不存在则插入 + await self.db.execute( + text("INSERT INTO user_teams (user_id, team_id, role) VALUES (:user_id, :team_id, 'member')"), + {"user_id": user_id, "team_id": team_id} + ) + + async def _cleanup_user_related_data(self, user_id: int) -> None: + """ + 清理用户关联数据(用于删除用户前) + + Args: + user_id: 要清理的用户ID + """ + logger.info(f"清理用户 {user_id} 的关联数据...") + + # 删除用户的考试记录 + await self.db.execute( + text("DELETE FROM exam_results WHERE exam_id IN (SELECT id FROM exams WHERE user_id = :user_id)"), + {"user_id": user_id} + ) + await self.db.execute( + text("DELETE FROM exams WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的错题记录 + await self.db.execute( + text("DELETE FROM exam_mistakes WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的能力评估记录 + await self.db.execute( + text("DELETE FROM ability_assessments WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的岗位关联 + await self.db.execute( + text("DELETE FROM position_members WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的团队关联 + await self.db.execute( + text("DELETE FROM user_teams WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的陪练会话 + await self.db.execute( + text("DELETE FROM practice_sessions WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户的任务分配 + await self.db.execute( + text("DELETE FROM task_assignments WHERE user_id = :user_id"), + {"user_id": user_id} + ) + + # 删除用户创建的任务的分配记录 + await self.db.execute( + text("DELETE FROM task_assignments WHERE task_id IN (SELECT id FROM tasks WHERE creator_id = :user_id)"), + {"user_id": user_id} + ) + # 删除用户创建的任务 + await self.db.execute( + text("DELETE FROM tasks WHERE creator_id = :user_id"), + {"user_id": user_id} + ) + + # 将用户作为负责人的团队的leader_id设为NULL + await self.db.execute( + text("UPDATE teams SET leader_id = NULL WHERE leader_id = :user_id"), + {"user_id": user_id} + ) + + logger.info(f"用户 {user_id} 的关联数据清理完成") + + async def incremental_sync_employees(self) -> Dict[str, Any]: + """ + 增量同步员工数据 + - 新增钉钉有但系统没有的员工 + - 删除系统有但钉钉没有的员工(物理删除) + - 跳过两边都存在的员工(不做任何修改) + + Returns: + 同步结果统计 + """ + logger.info("=" * 60) + logger.info("开始增量员工同步") + logger.info("=" * 60) + + stats = { + 'added_count': 0, + 'deleted_count': 0, + 'skipped_count': 0, + 'added_users': [], + 'deleted_users': [], + 'errors': [], + 'start_time': datetime.now() + } + + try: + # 1. 获取钉钉在职员工数据 + dingtalk_employees = await self.fetch_employees_from_dingtalk() + dingtalk_phones = {emp.get('phone') for emp in dingtalk_employees if emp.get('phone')} + logger.info(f"钉钉在职员工数量: {len(dingtalk_phones)}") + + # 2. 获取系统现有用户(排除admin和已软删除的) + stmt = select(User).where( + User.is_deleted == False, + User.username != 'admin' + ) + result = await self.db.execute(stmt) + system_users = result.scalars().all() + system_phones = {user.phone for user in system_users if user.phone} + logger.info(f"系统现有员工数量(排除admin): {len(system_phones)}") + + # 3. 计算需要新增、删除、跳过的员工 + phones_to_add = dingtalk_phones - system_phones + phones_to_delete = system_phones - dingtalk_phones + phones_to_skip = dingtalk_phones & system_phones + + logger.info(f"待新增: {len(phones_to_add)}, 待删除: {len(phones_to_delete)}, 跳过: {len(phones_to_skip)}") + + stats['skipped_count'] = len(phones_to_skip) + + # 4. 新增员工 + for employee in dingtalk_employees: + phone = employee.get('phone') + if not phone or phone not in phones_to_add: + continue + + try: + # 创建用户 + user = await self.create_user(employee) + if not user: + continue + + stats['added_count'] += 1 + stats['added_users'].append({ + 'full_name': user.full_name, + 'phone': user.phone, + 'role': user.role + }) + + # 创建部门团队 + department = employee.get('department') + if department: + team = await self.create_or_get_team( + department, + leader_id=user.id if employee.get('is_leader') else None + ) + if team: + # 用SQL直接插入user_teams关联表(避免懒加载问题) + await self._add_user_to_team(user.id, team.id) + logger.info(f"关联用户 {user.full_name} 到团队 {team.name}") + + # 创建岗位 + position_name = employee.get('position') + if position_name: + position = await self.create_or_get_position(position_name) + if position: + # 检查是否已经关联 + stmt = select(PositionMember).where( + PositionMember.position_id == position.id, + PositionMember.user_id == user.id, + PositionMember.is_deleted == False + ) + result = await self.db.execute(stmt) + existing_member = result.scalar_one_or_none() + + if not existing_member: + position_member = PositionMember( + position_id=position.id, + user_id=user.id, + role='member' + ) + self.db.add(position_member) + logger.info(f"关联用户 {user.full_name} 到岗位 {position.name}") + + logger.info(f"✅ 新增员工: {user.full_name} ({user.phone})") + + except Exception as e: + error_msg = f"新增员工 {employee.get('full_name')} 失败: {str(e)}" + logger.error(error_msg) + stats['errors'].append(error_msg) + continue + + # 5. 删除离职员工(物理删除) + # 先flush之前的新增操作,避免与删除操作冲突 + await self.db.flush() + + # 收集需要删除的用户ID + users_to_delete = [] + for user in system_users: + if user.phone and user.phone in phones_to_delete: + # 双重保护:确保不删除admin + if user.username == 'admin' or user.role == 'admin': + logger.warning(f"⚠️ 跳过删除管理员账户: {user.username}") + continue + + users_to_delete.append({ + 'id': user.id, + 'full_name': user.full_name, + 'phone': user.phone, + 'username': user.username + }) + + # 批量删除用户及其关联数据 + for user_info in users_to_delete: + try: + user_id = user_info['id'] + + # 先清理关联数据(外键约束) + await self._cleanup_user_related_data(user_id) + + # 用SQL直接删除用户(避免ORM的级联操作冲突) + await self.db.execute( + text("DELETE FROM users WHERE id = :user_id"), + {"user_id": user_id} + ) + + stats['deleted_users'].append({ + 'full_name': user_info['full_name'], + 'phone': user_info['phone'], + 'username': user_info['username'] + }) + stats['deleted_count'] += 1 + logger.info(f"🗑️ 删除离职员工: {user_info['full_name']} ({user_info['phone']})") + + except Exception as e: + error_msg = f"删除员工 {user_info['full_name']} 失败: {str(e)}" + logger.error(error_msg) + stats['errors'].append(error_msg) + continue + + # 6. 提交所有更改 + await self.db.commit() + logger.info("✅ 数据库事务已提交") + + except Exception as e: + logger.error(f"增量同步失败: {str(e)}") + await self.db.rollback() + stats['errors'].append(str(e)) + raise + + finally: + stats['end_time'] = datetime.now() + stats['duration'] = (stats['end_time'] - stats['start_time']).total_seconds() + + # 7. 输出统计信息 + logger.info("=" * 60) + logger.info("增量同步完成统计") + logger.info("=" * 60) + logger.info(f"新增员工: {stats['added_count']}") + logger.info(f"删除员工: {stats['deleted_count']}") + logger.info(f"跳过员工: {stats['skipped_count']}") + logger.info(f"耗时: {stats['duration']:.2f}秒") + + if stats['errors']: + logger.warning(f"错误数量: {len(stats['errors'])}") + + return stats + diff --git a/backend/app/services/exam_report_service.py b/backend/app/services/exam_report_service.py new file mode 100644 index 0000000..2f7338d --- /dev/null +++ b/backend/app/services/exam_report_service.py @@ -0,0 +1,486 @@ +""" +考试报告和错题统计服务 +""" +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, desc, case, text +from app.models.exam import Exam +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class ExamReportService: + """考试报告服务类""" + + @staticmethod + async def get_exam_report( + db: AsyncSession, + user_id: int, + start_date: Optional[str] = None, + end_date: Optional[str] = None + ) -> Dict[str, Any]: + """ + 获取成绩报告汇总数据 + + Args: + db: 数据库会话 + user_id: 用户ID + start_date: 开始日期(YYYY-MM-DD) + end_date: 结束日期(YYYY-MM-DD) + + Returns: + Dict: 包含overview、trends、subjects、recent_exams的完整报告数据 + """ + logger.info(f"获取成绩报告 - user_id: {user_id}, start_date: {start_date}, end_date: {end_date}") + + # 构建基础查询条件 + conditions = [Exam.user_id == user_id] + + # 添加时间范围条件 + if start_date: + conditions.append(Exam.start_time >= start_date) + if end_date: + # 结束日期包含当天全部 + end_datetime = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + conditions.append(Exam.start_time < end_datetime) + + # 1. 获取概览数据 + overview = await ExamReportService._get_overview(db, conditions) + + # 2. 获取趋势数据(最近30天) + trends = await ExamReportService._get_trends(db, user_id, conditions) + + # 3. 获取科目分析 + subjects = await ExamReportService._get_subjects(db, conditions) + + # 4. 获取最近考试记录 + recent_exams = await ExamReportService._get_recent_exams(db, conditions) + + return { + "overview": overview, + "trends": trends, + "subjects": subjects, + "recent_exams": recent_exams + } + + @staticmethod + async def _get_overview(db: AsyncSession, conditions: List) -> Dict[str, Any]: + """获取概览数据""" + # 查询统计数据(使用round1_score作为主要成绩) + stmt = select( + func.count(Exam.id).label("total_exams"), + func.avg(Exam.round1_score).label("avg_score"), + func.sum(Exam.question_count).label("total_questions"), + func.count(case((Exam.is_passed == True, 1))).label("passed_exams") + ).where( + and_(*conditions, Exam.round1_score.isnot(None)) + ) + + result = await db.execute(stmt) + stats = result.one() + + total_exams = stats.total_exams or 0 + passed_exams = stats.passed_exams or 0 + + return { + "avg_score": round(float(stats.avg_score or 0), 1), + "total_exams": total_exams, + "pass_rate": round((passed_exams / total_exams * 100) if total_exams > 0 else 0, 1), + "total_questions": stats.total_questions or 0 + } + + @staticmethod + async def _get_trends( + db: AsyncSession, + user_id: int, + base_conditions: List + ) -> List[Dict[str, Any]]: + """获取成绩趋势(最近30天)""" + # 计算30天前的日期 + thirty_days_ago = datetime.now() - timedelta(days=30) + + # 查询最近30天的考试数据,按日期分组 + stmt = select( + func.date(Exam.start_time).label("exam_date"), + func.avg(Exam.round1_score).label("avg_score") + ).where( + and_( + Exam.user_id == user_id, + Exam.start_time >= thirty_days_ago, + Exam.round1_score.isnot(None) + ) + ).group_by( + func.date(Exam.start_time) + ).order_by( + func.date(Exam.start_time) + ) + + result = await db.execute(stmt) + trend_data = result.all() + + # 转换为前端需要的格式 + trends = [] + for row in trend_data: + trends.append({ + "date": row.exam_date.strftime("%Y-%m-%d") if row.exam_date else "", + "avg_score": round(float(row.avg_score or 0), 1) + }) + + return trends + + @staticmethod + async def _get_subjects(db: AsyncSession, conditions: List) -> List[Dict[str, Any]]: + """获取科目分析""" + # 关联course表,按课程分组统计 + stmt = select( + Exam.course_id, + Course.name.label("course_name"), + func.avg(Exam.round1_score).label("avg_score"), + func.count(Exam.id).label("exam_count"), + func.max(Exam.round1_score).label("max_score"), + func.min(Exam.round1_score).label("min_score"), + func.count(case((Exam.is_passed == True, 1))).label("passed_count") + ).join( + Course, Exam.course_id == Course.id + ).where( + and_(*conditions, Exam.round1_score.isnot(None)) + ).group_by( + Exam.course_id, Course.name + ).order_by( + desc(func.count(Exam.id)) + ) + + result = await db.execute(stmt) + subject_data = result.all() + + subjects = [] + for row in subject_data: + exam_count = row.exam_count or 0 + passed_count = row.passed_count or 0 + + subjects.append({ + "course_id": row.course_id, + "course_name": row.course_name, + "avg_score": round(float(row.avg_score or 0), 1), + "exam_count": exam_count, + "max_score": round(float(row.max_score or 0), 1), + "min_score": round(float(row.min_score or 0), 1), + "pass_rate": round((passed_count / exam_count * 100) if exam_count > 0 else 0, 1) + }) + + return subjects + + @staticmethod + async def _get_recent_exams(db: AsyncSession, conditions: List) -> List[Dict[str, Any]]: + """获取最近10次考试记录""" + # 查询最近10次考试,包含三轮得分 + stmt = select( + Exam.id, + Exam.course_id, + Course.name.label("course_name"), + Exam.score, + Exam.total_score, + Exam.is_passed, + Exam.start_time, + Exam.end_time, + Exam.round1_score, + Exam.round2_score, + Exam.round3_score + ).join( + Course, Exam.course_id == Course.id + ).where( + and_(*conditions) + ).order_by( + desc(Exam.created_at) # 改为按创建时间排序,避免start_time为NULL的问题 + ).limit(10) + + result = await db.execute(stmt) + exam_data = result.all() + + recent_exams = [] + for row in exam_data: + # 计算考试用时 + duration_seconds = None + if row.start_time and row.end_time: + duration_seconds = int((row.end_time - row.start_time).total_seconds()) + + recent_exams.append({ + "id": row.id, + "course_id": row.course_id, + "course_name": row.course_name, + "score": round(float(row.score), 1) if row.score else None, + "total_score": round(float(row.total_score or 100), 1), + "is_passed": row.is_passed, + "duration_seconds": duration_seconds, + "start_time": row.start_time.isoformat() if row.start_time else None, + "end_time": row.end_time.isoformat() if row.end_time else None, + "round_scores": { + "round1": round(float(row.round1_score), 1) if row.round1_score else None, + "round2": round(float(row.round2_score), 1) if row.round2_score else None, + "round3": round(float(row.round3_score), 1) if row.round3_score else None + } + }) + + return recent_exams + + +class MistakeService: + """错题服务类""" + + @staticmethod + async def get_mistakes_list( + db: AsyncSession, + user_id: int, + exam_id: Optional[int] = None, + course_id: Optional[int] = None, + question_type: Optional[str] = None, + search: Optional[str] = None, + start_date: Optional[str] = None, + end_date: Optional[str] = None, + page: int = 1, + size: int = 10 + ) -> Dict[str, Any]: + """ + 获取错题列表(支持多维度筛选) + + Args: + db: 数据库会话 + user_id: 用户ID + exam_id: 考试ID(可选) + course_id: 课程ID(可选) + question_type: 题型(可选) + search: 关键词搜索(可选) + start_date: 开始日期(可选) + end_date: 结束日期(可选) + page: 页码 + size: 每页数量 + + Returns: + Dict: 包含items、total、page、size、pages的分页数据 + """ + logger.info(f"获取错题列表 - user_id: {user_id}, exam_id: {exam_id}, course_id: {course_id}") + + # 构建查询条件 + conditions = [ExamMistake.user_id == user_id] + + if exam_id: + conditions.append(ExamMistake.exam_id == exam_id) + + if question_type: + conditions.append(ExamMistake.question_type == question_type) + + if search: + conditions.append(ExamMistake.question_content.like(f"%{search}%")) + + if start_date: + conditions.append(ExamMistake.created_at >= start_date) + + if end_date: + end_datetime = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) + conditions.append(ExamMistake.created_at < end_datetime) + + # 如果指定了course_id,需要通过exam关联 + if course_id: + conditions.append(Exam.course_id == course_id) + + # 查询总数 + count_stmt = select(func.count(ExamMistake.id)).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).where(and_(*conditions)) + + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 查询分页数据 + offset = (page - 1) * size + + stmt = select( + ExamMistake.id, + ExamMistake.exam_id, + Exam.course_id, + Course.name.label("course_name"), + ExamMistake.question_content, + ExamMistake.correct_answer, + ExamMistake.user_answer, + ExamMistake.question_type, + ExamMistake.knowledge_point_id, + KnowledgePoint.name.label("knowledge_point_name"), + ExamMistake.created_at + ).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).join( + Course, Exam.course_id == Course.id + ).outerjoin( + KnowledgePoint, ExamMistake.knowledge_point_id == KnowledgePoint.id + ).where( + and_(*conditions) + ).order_by( + desc(ExamMistake.created_at) + ).offset(offset).limit(size) + + result = await db.execute(stmt) + mistakes = result.all() + + # 构建返回数据 + items = [] + for row in mistakes: + items.append({ + "id": row.id, + "exam_id": row.exam_id, + "course_id": row.course_id, + "course_name": row.course_name, + "question_content": row.question_content, + "correct_answer": row.correct_answer, + "user_answer": row.user_answer, + "question_type": row.question_type, + "knowledge_point_id": row.knowledge_point_id, + "knowledge_point_name": row.knowledge_point_name, + "created_at": row.created_at + }) + + pages = (total + size - 1) // size + + return { + "items": items, + "total": total, + "page": page, + "size": size, + "pages": pages + } + + @staticmethod + async def get_mistakes_statistics( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None + ) -> Dict[str, Any]: + """ + 获取错题统计数据 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + + Returns: + Dict: 包含total、by_course、by_type、by_time的统计数据 + """ + logger.info(f"获取错题统计 - user_id: {user_id}, course_id: {course_id}") + + # 基础条件 + base_conditions = [ExamMistake.user_id == user_id] + if course_id: + base_conditions.append(Exam.course_id == course_id) + + # 1. 总数统计 + count_stmt = select(func.count(ExamMistake.id)).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).where(and_(*base_conditions)) + + total_result = await db.execute(count_stmt) + total = total_result.scalar() or 0 + + # 2. 按课程统计 + by_course_stmt = select( + Exam.course_id, + Course.name.label("course_name"), + func.count(ExamMistake.id).label("count") + ).select_from(ExamMistake).join( + Exam, ExamMistake.exam_id == Exam.id + ).join( + Course, Exam.course_id == Course.id + ).where( + ExamMistake.user_id == user_id + ).group_by( + Exam.course_id, Course.name + ).order_by( + desc(func.count(ExamMistake.id)) + ) + + by_course_result = await db.execute(by_course_stmt) + by_course_data = by_course_result.all() + + by_course = [ + { + "course_id": row.course_id, + "course_name": row.course_name, + "count": row.count + } + for row in by_course_data + ] + + # 3. 按题型统计 + by_type_stmt = select( + ExamMistake.question_type, + func.count(ExamMistake.id).label("count") + ).where( + and_(ExamMistake.user_id == user_id, ExamMistake.question_type.isnot(None)) + ).group_by( + ExamMistake.question_type + ) + + by_type_result = await db.execute(by_type_stmt) + by_type_data = by_type_result.all() + + # 题型名称映射 + type_names = { + "single": "单选题", + "multiple": "多选题", + "judge": "判断题", + "blank": "填空题", + "essay": "问答题" + } + + by_type = [ + { + "type": row.question_type, + "type_name": type_names.get(row.question_type, "未知类型"), + "count": row.count + } + for row in by_type_data + ] + + # 4. 按时间统计 + now = datetime.now() + week_ago = now - timedelta(days=7) + month_ago = now - timedelta(days=30) + quarter_ago = now - timedelta(days=90) + + # 最近一周 + week_stmt = select(func.count(ExamMistake.id)).where( + and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= week_ago) + ) + week_result = await db.execute(week_stmt) + week_count = week_result.scalar() or 0 + + # 最近一月 + month_stmt = select(func.count(ExamMistake.id)).where( + and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= month_ago) + ) + month_result = await db.execute(month_stmt) + month_count = month_result.scalar() or 0 + + # 最近三月 + quarter_stmt = select(func.count(ExamMistake.id)).where( + and_(ExamMistake.user_id == user_id, ExamMistake.created_at >= quarter_ago) + ) + quarter_result = await db.execute(quarter_stmt) + quarter_count = quarter_result.scalar() or 0 + + by_time = { + "week": week_count, + "month": month_count, + "quarter": quarter_count + } + + return { + "total": total, + "by_course": by_course, + "by_type": by_type, + "by_time": by_time + } + diff --git a/backend/app/services/exam_service.py b/backend/app/services/exam_service.py new file mode 100644 index 0000000..55c82e6 --- /dev/null +++ b/backend/app/services/exam_service.py @@ -0,0 +1,439 @@ +""" +考试服务层 +""" +import json +import random +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, desc +from app.models.exam import Exam, Question, ExamResult +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.core.exceptions import BusinessException, ErrorCode + + +class ExamService: + """考试服务类""" + + @staticmethod + async def start_exam( + db: AsyncSession, user_id: int, course_id: int, question_count: int = 10 + ) -> Exam: + """ + 开始考试 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID + question_count: 题目数量 + + Returns: + Exam: 考试实例 + """ + # 检查课程是否存在 + course = await db.get(Course, course_id) + if not course: + raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="课程不存在") + + # 获取该课程的所有可用题目 + stmt = select(Question).where( + and_(Question.course_id == course_id, Question.is_active == True) + ) + result = await db.execute(stmt) + all_questions = result.scalars().all() + + if not all_questions: + raise BusinessException( + error_code=ErrorCode.VALIDATION_ERROR, message="该课程暂无题目" + ) + + # 随机选择题目 + selected_questions = random.sample( + all_questions, min(question_count, len(all_questions)) + ) + + # 构建题目数据 + questions_data = [] + for q in selected_questions: + question_data = { + "id": str(q.id), + "type": q.question_type, + "title": q.title, + "content": q.content, + "options": q.options, + "score": q.score, + } + questions_data.append(question_data) + + # 创建考试记录 + exam = Exam( + user_id=user_id, + course_id=course_id, + exam_name=f"{course.name} - 随机测试", + question_count=len(selected_questions), + total_score=sum(q.score for q in selected_questions), + pass_score=sum(q.score for q in selected_questions) * 0.6, + duration_minutes=60, + status="started", + questions={"questions": questions_data}, + ) + + db.add(exam) + await db.commit() + await db.refresh(exam) + + return exam + + @staticmethod + async def submit_exam( + db: AsyncSession, user_id: int, exam_id: int, answers: List[Dict[str, str]] + ) -> Dict[str, Any]: + """ + 提交考试答案 + + Args: + db: 数据库会话 + user_id: 用户ID + exam_id: 考试ID + answers: 答案列表 + + Returns: + Dict: 考试结果 + """ + # 获取考试记录 + stmt = select(Exam).where(and_(Exam.id == exam_id, Exam.user_id == user_id)) + result = await db.execute(stmt) + exam = result.scalar_one_or_none() + + if not exam: + raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在") + + if exam.status != "started": + raise BusinessException( + error_code=ErrorCode.VALIDATION_ERROR, message="考试已结束或已提交" + ) + + # 检查考试是否超时 + if datetime.now() > exam.start_time + timedelta( + minutes=exam.duration_minutes + ): + exam.status = "timeout" + await db.commit() + raise BusinessException( + error_code=ErrorCode.VALIDATION_ERROR, message="考试已超时" + ) + + # 处理答案 + answers_dict = {ans["question_id"]: ans["answer"] for ans in answers} + total_score = 0.0 + correct_count = 0 + + # 批量获取题目 + question_ids = [int(ans["question_id"]) for ans in answers] + stmt = select(Question).where(Question.id.in_(question_ids)) + result = await db.execute(stmt) + questions_map = {str(q.id): q for q in result.scalars().all()} + + # 创建答题结果记录 + for question_data in exam.questions["questions"]: + question_id = question_data["id"] + question = questions_map.get(question_id) + + if not question: + continue + + user_answer = answers_dict.get(question_id, "") + is_correct = user_answer == question.correct_answer + + if is_correct: + total_score += question.score + correct_count += 1 + + # 创建答题结果记录 + exam_result = ExamResult( + exam_id=exam_id, + question_id=int(question_id), + user_answer=user_answer, + is_correct=is_correct, + score=question.score if is_correct else 0.0, + ) + db.add(exam_result) + + # 更新题目使用统计 + question.usage_count += 1 + if is_correct: + question.correct_count += 1 + + # 更新考试记录 + exam.end_time = datetime.now() + exam.score = total_score + exam.is_passed = total_score >= exam.pass_score + exam.status = "submitted" + exam.answers = {"answers": answers} + + await db.commit() + + return { + "exam_id": exam_id, + "total_score": total_score, + "pass_score": exam.pass_score, + "is_passed": exam.is_passed, + "correct_count": correct_count, + "total_count": exam.question_count, + "accuracy": correct_count / exam.question_count + if exam.question_count > 0 + else 0, + } + + @staticmethod + async def get_exam_detail( + db: AsyncSession, user_id: int, exam_id: int + ) -> Dict[str, Any]: + """ + 获取考试详情 + + Args: + db: 数据库会话 + user_id: 用户ID + exam_id: 考试ID + + Returns: + Dict: 考试详情 + """ + # 获取考试记录 + stmt = select(Exam).where(and_(Exam.id == exam_id, Exam.user_id == user_id)) + result = await db.execute(stmt) + exam = result.scalar_one_or_none() + + if not exam: + raise BusinessException(error_code=ErrorCode.NOT_FOUND, message="考试记录不存在") + + # 构建返回数据 + exam_data = { + "id": exam.id, + "course_id": exam.course_id, + "exam_name": exam.exam_name, + "question_count": exam.question_count, + "total_score": exam.total_score, + "pass_score": exam.pass_score, + "start_time": exam.start_time.isoformat() if exam.start_time else None, + "end_time": exam.end_time.isoformat() if exam.end_time else None, + "duration_minutes": exam.duration_minutes, + "status": exam.status, + "score": exam.score, + "is_passed": exam.is_passed, + "questions": exam.questions, + } + + # 如果考试已提交,获取答题详情 + if exam.status == "submitted" and exam.answers: + stmt = select(ExamResult).where(ExamResult.exam_id == exam_id) + result = await db.execute(stmt) + results = result.scalars().all() + + results_data = [] + for r in results: + results_data.append( + { + "question_id": r.question_id, + "user_answer": r.user_answer, + "is_correct": r.is_correct, + "score": r.score, + } + ) + + exam_data["results"] = results_data + exam_data["answers"] = exam.answers + + return exam_data + + @staticmethod + async def get_exam_records( + db: AsyncSession, + user_id: int, + page: int = 1, + size: int = 10, + course_id: Optional[int] = None, + ) -> Dict[str, Any]: + """ + 获取考试记录列表(包含统计数据) + + Args: + db: 数据库会话 + user_id: 用户ID + page: 页码 + size: 每页数量 + course_id: 课程ID(可选) + + Returns: + Dict: 考试记录列表(包含统计信息) + """ + # 构建查询条件 + conditions = [Exam.user_id == user_id] + if course_id: + conditions.append(Exam.course_id == course_id) + + # 查询总数 + count_stmt = select(func.count(Exam.id)).where(and_(*conditions)) + total = await db.scalar(count_stmt) + + # 查询考试数据(JOIN courses表获取课程名称) + offset = (page - 1) * size + stmt = ( + select(Exam, Course.name.label("course_name")) + .join(Course, Exam.course_id == Course.id) + .where(and_(*conditions)) + .order_by(Exam.created_at.desc()) + .offset(offset) + .limit(size) + ) + result = await db.execute(stmt) + rows = result.all() + + # 构建返回数据 + items = [] + for exam, course_name in rows: + # 1. 计算用时 + duration_seconds = None + if exam.start_time and exam.end_time: + duration_seconds = int((exam.end_time - exam.start_time).total_seconds()) + + # 2. 统计错题数 + mistakes_stmt = select(func.count(ExamMistake.id)).where( + ExamMistake.exam_id == exam.id + ) + wrong_count = await db.scalar(mistakes_stmt) or 0 + + # 3. 计算正确数和正确率 + correct_count = exam.question_count - wrong_count if exam.question_count else 0 + accuracy = None + if exam.question_count and exam.question_count > 0: + accuracy = round((correct_count / exam.question_count) * 100, 1) + + # 4. 分题型统计 + question_type_stats = [] + if exam.questions: + try: + # 解析questions JSON,统计每种题型的总数 + questions_data = json.loads(exam.questions) if isinstance(exam.questions, str) else exam.questions + type_totals = {} + type_scores = {} # 存储每种题型的总分 + + for q in questions_data: + q_type = q.get("type", "unknown") + q_score = q.get("score", 0) + type_totals[q_type] = type_totals.get(q_type, 0) + 1 + type_scores[q_type] = type_scores.get(q_type, 0) + q_score + + # 查询错题按题型统计 + mistakes_by_type_stmt = ( + select(ExamMistake.question_type, func.count(ExamMistake.id)) + .where(ExamMistake.exam_id == exam.id) + .group_by(ExamMistake.question_type) + ) + mistakes_by_type_result = await db.execute(mistakes_by_type_stmt) + mistakes_by_type = dict(mistakes_by_type_result.all()) + + # 题型名称映射 + type_name_map = { + "single": "单选题", + "multiple": "多选题", + "judge": "判断题", + "blank": "填空题", + "essay": "问答题" + } + + # 组装分题型统计 + for q_type, total in type_totals.items(): + wrong = mistakes_by_type.get(q_type, 0) + correct = total - wrong + type_accuracy = round((correct / total) * 100, 1) if total > 0 else 0 + + question_type_stats.append({ + "type": type_name_map.get(q_type, q_type), + "type_code": q_type, + "total": total, + "correct": correct, + "wrong": wrong, + "accuracy": type_accuracy, + "total_score": type_scores.get(q_type, 0) + }) + except (json.JSONDecodeError, TypeError, KeyError) as e: + # 如果JSON解析失败,返回空统计 + question_type_stats = [] + + items.append( + { + "id": exam.id, + "course_id": exam.course_id, + "course_name": course_name, + "exam_name": exam.exam_name, + "question_count": exam.question_count, + "total_score": exam.total_score, + "score": exam.score, + "is_passed": exam.is_passed, + "status": exam.status, + "start_time": exam.start_time.isoformat() if exam.start_time else None, + "end_time": exam.end_time.isoformat() if exam.end_time else None, + "created_at": exam.created_at.isoformat(), + # 统计字段 + "accuracy": accuracy, + "correct_count": correct_count, + "wrong_count": wrong_count, + "duration_seconds": duration_seconds, + "question_type_stats": question_type_stats, + } + ) + + return { + "items": items, + "total": total, + "page": page, + "size": size, + "pages": (total + size - 1) // size, + } + + @staticmethod + async def get_exam_statistics( + db: AsyncSession, user_id: int, course_id: Optional[int] = None + ) -> Dict[str, Any]: + """ + 获取考试统计信息 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + + Returns: + Dict: 统计信息 + """ + # 构建查询条件 + conditions = [Exam.user_id == user_id, Exam.status == "submitted"] + if course_id: + conditions.append(Exam.course_id == course_id) + + # 查询统计数据 + stmt = select( + func.count(Exam.id).label("total_exams"), + func.count(func.nullif(Exam.is_passed, False)).label("passed_exams"), + func.avg(Exam.score).label("avg_score"), + func.max(Exam.score).label("max_score"), + func.min(Exam.score).label("min_score"), + ).where(and_(*conditions)) + + result = await db.execute(stmt) + stats = result.one() + + return { + "total_exams": stats.total_exams or 0, + "passed_exams": stats.passed_exams or 0, + "pass_rate": (stats.passed_exams / stats.total_exams * 100) + if stats.total_exams > 0 + else 0, + "avg_score": float(stats.avg_score or 0), + "max_score": float(stats.max_score or 0), + "min_score": float(stats.min_score or 0), + } diff --git a/backend/app/services/external/__init__.py b/backend/app/services/external/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/services/notification_service.py b/backend/app/services/notification_service.py new file mode 100644 index 0000000..bc9e79c --- /dev/null +++ b/backend/app/services/notification_service.py @@ -0,0 +1,330 @@ +""" +站内消息通知服务 +提供通知的CRUD操作和业务逻辑 +""" +from typing import List, Optional, Tuple +from sqlalchemy import select, and_, desc, func, update +from sqlalchemy.orm import selectinload +from sqlalchemy.ext.asyncio import AsyncSession + +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 + +logger = get_logger(__name__) + + +class NotificationService(BaseService[Notification]): + """ + 站内消息通知服务 + + 提供通知的创建、查询、标记已读等功能 + """ + + def __init__(self): + super().__init__(Notification) + + async def create_notification( + 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 + ) -> bool: + """ + 删除通知 + + Args: + db: 数据库会话 + user_id: 用户ID + notification_id: 通知ID + + Returns: + 是否删除成功 + """ + stmt = select(Notification).where( + and_( + Notification.id == notification_id, + Notification.user_id == user_id + ) + ) + + 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 + + +# 创建服务实例 +notification_service = NotificationService() + diff --git a/backend/app/services/scrm_service.py b/backend/app/services/scrm_service.py new file mode 100644 index 0000000..b657848 --- /dev/null +++ b/backend/app/services/scrm_service.py @@ -0,0 +1,356 @@ +""" +SCRM 系统对接服务 + +提供给 SCRM 系统调用的数据查询服务 +""" + +import logging +from typing import List, Optional, Dict, Any +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +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, KnowledgePoint, CourseMaterial + +logger = logging.getLogger(__name__) + + +class SCRMService: + """SCRM 系统数据查询服务""" + + def __init__(self, db: AsyncSession): + self.db = db + + async def get_employee_position( + self, + userid: Optional[str] = None, + name: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """ + 根据企微 userid 或员工姓名获取员工岗位信息 + + Args: + userid: 企微员工 userid(可选) + name: 员工姓名(可选,支持模糊匹配) + + Returns: + 员工岗位信息字典,包含 employee_id, userid, name, positions + 如果员工不存在返回 None + 如果按姓名搜索有多个结果,返回列表 + """ + query = ( + select(User) + .options(selectinload(User.position_memberships).selectinload(PositionMember.position)) + .where(User.is_deleted.is_(False)) + ) + + # 优先按 wework_userid 精确匹配 + if userid: + query = query.where(User.wework_userid == userid) + result = await self.db.execute(query) + user = result.scalar_one_or_none() + + if user: + return self._build_employee_position_data(user) + + # 其次按姓名匹配(支持精确匹配和模糊匹配) + if name: + # 先尝试精确匹配 + exact_query = query.where(User.full_name == name) + result = await self.db.execute(exact_query) + users = result.scalars().all() + + # 如果精确匹配没有结果,尝试模糊匹配 + if not users: + fuzzy_query = query.where(User.full_name.ilike(f"%{name}%")) + result = await self.db.execute(fuzzy_query) + users = result.scalars().all() + + if len(users) == 1: + return self._build_employee_position_data(users[0]) + elif len(users) > 1: + # 多个匹配结果,返回列表供选择 + return { + "multiple_matches": True, + "count": len(users), + "employees": [ + { + "employee_id": u.id, + "userid": u.wework_userid, + "name": u.full_name or u.username, + "phone": u.phone[-4:] if u.phone else None # 只显示手机号后4位 + } + for u in users + ] + } + + return None + + def _build_employee_position_data(self, user: User) -> Dict[str, Any]: + """构建员工岗位数据""" + positions = [] + for i, pm in enumerate(user.position_memberships): + if pm.is_deleted or pm.position.is_deleted: + continue + positions.append({ + "position_id": pm.position.id, + "position_name": pm.position.name, + "is_primary": i == 0, # 第一个为主岗位 + "joined_at": pm.joined_at.strftime("%Y-%m-%d") if pm.joined_at else None + }) + + return { + "employee_id": user.id, + "userid": user.wework_userid, + "name": user.full_name or user.username, + "positions": positions + } + + async def get_employee_position_by_id(self, employee_id: int) -> Optional[Dict[str, Any]]: + """ + 根据员工ID获取岗位信息 + + Args: + employee_id: 员工ID(users表主键) + + Returns: + 员工岗位信息字典 + """ + result = await self.db.execute( + select(User) + .options(selectinload(User.position_memberships).selectinload(PositionMember.position)) + .where(User.id == employee_id, User.is_deleted == False) + ) + user = result.scalar_one_or_none() + + if not user: + return None + + return self._build_employee_position_data(user) + + async def get_position_courses( + self, + position_id: int, + course_type: Optional[str] = None + ) -> Optional[Dict[str, Any]]: + """ + 获取指定岗位的课程列表 + + Args: + position_id: 岗位ID + course_type: 课程类型筛选(required/optional/all) + + Returns: + 岗位课程信息字典,包含 position_id, position_name, courses + 如果岗位不存在返回 None + """ + # 查询岗位 + position_result = await self.db.execute( + select(Position).where(Position.id == position_id, Position.is_deleted.is_(False)) + ) + position = position_result.scalar_one_or_none() + + if not position: + return None + + # 查询岗位课程关联 + query = ( + select(PositionCourse, Course) + .join(Course, PositionCourse.course_id == Course.id) + .where( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted.is_(False), + Course.is_deleted.is_(False), + Course.status == "published" # 只返回已发布的课程 + ) + .order_by(PositionCourse.priority.desc()) + ) + + # 课程类型筛选 + if course_type and course_type != "all": + query = query.where(PositionCourse.course_type == course_type) + + result = await self.db.execute(query) + pc_courses = result.all() + + # 构建课程列表,并统计知识点数量 + courses = [] + for pc, course in pc_courses: + # 统计该课程的知识点数量 + kp_count_result = await self.db.execute( + select(func.count(KnowledgePoint.id)) + .where( + KnowledgePoint.course_id == course.id, + KnowledgePoint.is_deleted.is_(False) + ) + ) + kp_count = kp_count_result.scalar() or 0 + + courses.append({ + "course_id": course.id, + "course_name": course.name, + "course_type": pc.course_type, + "priority": pc.priority, + "knowledge_point_count": kp_count + }) + + return { + "position_id": position.id, + "position_name": position.name, + "courses": courses + } + + async def search_knowledge_points( + self, + keywords: List[str], + position_id: Optional[int] = None, + course_ids: Optional[List[int]] = None, + knowledge_type: Optional[str] = None, + limit: int = 10 + ) -> Dict[str, Any]: + """ + 搜索知识点 + + Args: + keywords: 搜索关键词列表 + position_id: 岗位ID(用于优先排序) + course_ids: 限定课程范围 + knowledge_type: 知识点类型筛选 + limit: 返回数量限制 + + Returns: + 搜索结果字典,包含 total 和 items + """ + # 基础查询 + query = ( + select(KnowledgePoint, Course) + .join(Course, KnowledgePoint.course_id == Course.id) + .where( + KnowledgePoint.is_deleted.is_(False), + Course.is_deleted.is_(False), + Course.status == "published" + ) + ) + + # 关键词搜索条件(在名称和描述中搜索) + keyword_conditions = [] + for keyword in keywords: + keyword_conditions.append( + or_( + KnowledgePoint.name.ilike(f"%{keyword}%"), + KnowledgePoint.description.ilike(f"%{keyword}%") + ) + ) + if keyword_conditions: + query = query.where(or_(*keyword_conditions)) + + # 课程范围筛选 + if course_ids: + query = query.where(KnowledgePoint.course_id.in_(course_ids)) + + # 知识点类型筛选 + if knowledge_type: + query = query.where(KnowledgePoint.type == knowledge_type) + + # 如果指定了岗位,优先返回该岗位相关课程的知识点 + if position_id: + # 获取该岗位的课程ID列表 + pos_course_result = await self.db.execute( + select(PositionCourse.course_id) + .where( + PositionCourse.position_id == position_id, + PositionCourse.is_deleted.is_(False) + ) + ) + pos_course_ids = [row[0] for row in pos_course_result.all()] + + if pos_course_ids: + # 使用 CASE WHEN 进行排序:岗位相关课程优先 + from sqlalchemy import case + priority_order = case( + (KnowledgePoint.course_id.in_(pos_course_ids), 0), + else_=1 + ) + query = query.order_by(priority_order, KnowledgePoint.id.desc()) + else: + query = query.order_by(KnowledgePoint.id.desc()) + else: + query = query.order_by(KnowledgePoint.id.desc()) + + # 执行查询 + result = await self.db.execute(query.limit(limit)) + kp_courses = result.all() + + # 计算相关度分数(简单实现:匹配的关键词越多分数越高) + def calc_relevance(kp: KnowledgePoint) -> float: + text = f"{kp.name} {kp.description or ''}" + matched = sum(1 for kw in keywords if kw.lower() in text.lower()) + return round(matched / len(keywords), 2) if keywords else 1.0 + + # 构建结果 + items = [] + for kp, course in kp_courses: + items.append({ + "knowledge_point_id": kp.id, + "name": kp.name, + "course_id": course.id, + "course_name": course.name, + "type": kp.type, + "relevance_score": calc_relevance(kp) + }) + + # 按相关度分数排序 + items.sort(key=lambda x: x["relevance_score"], reverse=True) + + return { + "total": len(items), + "items": items + } + + async def get_knowledge_point_detail(self, kp_id: int) -> Optional[Dict[str, Any]]: + """ + 获取知识点详情 + + Args: + kp_id: 知识点ID + + Returns: + 知识点详情字典 + 如果知识点不存在返回 None + """ + # 查询知识点及关联的课程和资料 + result = await self.db.execute( + select(KnowledgePoint, Course, CourseMaterial) + .join(Course, KnowledgePoint.course_id == Course.id) + .outerjoin(CourseMaterial, KnowledgePoint.material_id == CourseMaterial.id) + .where( + KnowledgePoint.id == kp_id, + KnowledgePoint.is_deleted.is_(False) + ) + ) + row = result.one_or_none() + + if not row: + return None + + kp, course, material = row + + return { + "knowledge_point_id": kp.id, + "name": kp.name, + "course_id": course.id, + "course_name": course.name, + "type": kp.type, + "content": kp.description or "", # description 作为知识点内容 + "material_id": material.id if material else None, + "material_type": material.file_type if material else None, + "material_url": material.file_url if material else None, + "topic_relation": kp.topic_relation, + "source": kp.source, + "created_at": kp.created_at.strftime("%Y-%m-%d %H:%M:%S") if kp.created_at else None + } + diff --git a/backend/app/services/statistics_service.py b/backend/app/services/statistics_service.py new file mode 100644 index 0000000..06c436e --- /dev/null +++ b/backend/app/services/statistics_service.py @@ -0,0 +1,708 @@ +""" +统计分析服务 +""" +from datetime import datetime, timedelta +from typing import List, Optional, Dict, Any, Tuple +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, func, and_, or_, case, desc, distinct +from app.models.exam import Exam, Question +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.models.practice import PracticeSession +from app.models.training import TrainingSession +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +class StatisticsService: + """统计分析服务类""" + + @staticmethod + def _get_date_range(period: str) -> Tuple[datetime, datetime]: + """ + 根据period返回开始和结束日期 + + Args: + period: 时间范围 (week/month/quarter/halfYear/year) + + Returns: + Tuple[datetime, datetime]: (开始日期, 结束日期) + """ + end_date = datetime.now() + + if period == "week": + start_date = end_date - timedelta(days=7) + elif period == "month": + start_date = end_date - timedelta(days=30) + elif period == "quarter": + start_date = end_date - timedelta(days=90) + elif period == "halfYear": + start_date = end_date - timedelta(days=180) + elif period == "year": + start_date = end_date - timedelta(days=365) + else: + # 默认一个月 + start_date = end_date - timedelta(days=30) + + return start_date, end_date + + @staticmethod + def _calculate_change_rate(current: float, previous: float) -> float: + """ + 计算环比变化率 + + Args: + current: 当前值 + previous: 上期值 + + Returns: + float: 变化率(百分比) + """ + if previous == 0: + return 0 if current == 0 else 100 + return round(((current - previous) / previous) * 100, 1) + + @staticmethod + async def get_key_metrics( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取关键指标 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 包含学习效率、知识覆盖率、平均用时、进步速度的指标数据 + """ + logger.info(f"获取关键指标 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 构建基础查询条件 + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date, + Exam.round1_score.isnot(None) + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + # 1. 学习效率 = (总题数 - 错题数) / 总题数 + # 获取总题数 + total_questions_stmt = select( + func.coalesce(func.sum(Exam.question_count), 0) + ).where(and_(*exam_conditions)) + total_questions = await db.scalar(total_questions_stmt) or 0 + + # 获取错题数 + mistake_conditions = [ExamMistake.user_id == user_id] + if course_id: + mistake_conditions.append( + ExamMistake.exam_id.in_( + select(Exam.id).where(Exam.course_id == course_id) + ) + ) + mistake_stmt = select(func.count(ExamMistake.id)).where( + and_(*mistake_conditions) + ) + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 计算学习效率 + learning_efficiency = 0.0 + if total_questions > 0: + correct_questions = total_questions - mistake_count + learning_efficiency = round((correct_questions / total_questions) * 100, 1) + + # 计算上期学习效率(用于环比) + prev_start_date = start_date - (end_date - start_date) + prev_exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= prev_start_date, + Exam.start_time < start_date, + Exam.round1_score.isnot(None) + ] + if course_id: + prev_exam_conditions.append(Exam.course_id == course_id) + + prev_total_questions = await db.scalar( + select(func.coalesce(func.sum(Exam.question_count), 0)).where( + and_(*prev_exam_conditions) + ) + ) or 0 + + prev_mistake_count = await db.scalar( + select(func.count(ExamMistake.id)).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.exam_id.in_( + select(Exam.id).where(and_(*prev_exam_conditions)) + ) + ) + ) + ) or 0 + + prev_efficiency = 0.0 + if prev_total_questions > 0: + prev_correct = prev_total_questions - prev_mistake_count + prev_efficiency = (prev_correct / prev_total_questions) * 100 + + efficiency_change = StatisticsService._calculate_change_rate( + learning_efficiency, prev_efficiency + ) + + # 2. 知识覆盖率 = 已掌握知识点数 / 总知识点数 + # 获取总知识点数 + kp_conditions = [] + if course_id: + kp_conditions.append(KnowledgePoint.course_id == course_id) + + total_kp_stmt = select(func.count(KnowledgePoint.id)).where( + and_(KnowledgePoint.is_deleted == False, *kp_conditions) + ) + total_kp = await db.scalar(total_kp_stmt) or 0 + + # 获取错误的知识点数(至少错过一次的) + mistake_kp_stmt = select( + func.count(distinct(ExamMistake.knowledge_point_id)) + ).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.knowledge_point_id.isnot(None), + *([ExamMistake.exam_id.in_( + select(Exam.id).where(Exam.course_id == course_id) + )] if course_id else []) + ) + ) + mistake_kp = await db.scalar(mistake_kp_stmt) or 0 + + # 计算知识覆盖率(掌握的知识点 = 总知识点 - 错误知识点) + knowledge_coverage = 0.0 + if total_kp > 0: + mastered_kp = max(0, total_kp - mistake_kp) + knowledge_coverage = round((mastered_kp / total_kp) * 100, 1) + + # 上期知识覆盖率(简化:假设知识点总数不变) + prev_mistake_kp = await db.scalar( + select(func.count(distinct(ExamMistake.knowledge_point_id))).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.knowledge_point_id.isnot(None), + ExamMistake.exam_id.in_( + select(Exam.id).where(and_(*prev_exam_conditions)) + ) + ) + ) + ) or 0 + + prev_coverage = 0.0 + if total_kp > 0: + prev_mastered = max(0, total_kp - prev_mistake_kp) + prev_coverage = (prev_mastered / total_kp) * 100 + + coverage_change = StatisticsService._calculate_change_rate( + knowledge_coverage, prev_coverage + ) + + # 3. 平均用时 = 总考试时长 / 总题数 + total_duration_stmt = select( + func.coalesce(func.sum(Exam.duration_minutes), 0) + ).where(and_(*exam_conditions)) + total_duration = await db.scalar(total_duration_stmt) or 0 + + avg_time_per_question = 0.0 + if total_questions > 0: + avg_time_per_question = round((total_duration / total_questions), 1) + + # 上期平均用时 + prev_total_duration = await db.scalar( + select(func.coalesce(func.sum(Exam.duration_minutes), 0)).where( + and_(*prev_exam_conditions) + ) + ) or 0 + + prev_avg_time = 0.0 + if prev_total_questions > 0: + prev_avg_time = prev_total_duration / prev_total_questions + + # 平均用时的环比是负增长表示好(时间减少) + time_change = StatisticsService._calculate_change_rate( + avg_time_per_question, prev_avg_time + ) + + # 4. 进步速度 = (本期平均分 - 上期平均分) / 上期平均分 + avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_(*exam_conditions) + ) + avg_score = await db.scalar(avg_score_stmt) or 0 + + prev_avg_score_stmt = select(func.avg(Exam.round1_score)).where( + and_(*prev_exam_conditions) + ) + prev_avg_score = await db.scalar(prev_avg_score_stmt) or 0 + + progress_speed = StatisticsService._calculate_change_rate( + float(avg_score), float(prev_avg_score) + ) + + return { + "learningEfficiency": { + "value": learning_efficiency, + "unit": "%", + "change": efficiency_change, + "description": "正确题数/总练习题数" + }, + "knowledgeCoverage": { + "value": knowledge_coverage, + "unit": "%", + "change": coverage_change, + "description": "已掌握知识点/总知识点" + }, + "avgTimePerQuestion": { + "value": avg_time_per_question, + "unit": "分/题", + "change": time_change, + "description": "平均每道题的答题时间" + }, + "progressSpeed": { + "value": abs(progress_speed), + "unit": "%", + "change": progress_speed, + "description": "成绩提升速度" + } + } + + @staticmethod + async def get_score_distribution( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取成绩分布统计 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 成绩分布数据(优秀、良好、中等、及格、不及格) + """ + logger.info(f"获取成绩分布 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 构建查询条件 + conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date, + Exam.round1_score.isnot(None) + ] + if course_id: + conditions.append(Exam.course_id == course_id) + + # 使用case when统计各分数段的数量 + stmt = select( + func.count(case((Exam.round1_score >= 90, 1))).label("excellent"), + func.count(case((and_(Exam.round1_score >= 80, Exam.round1_score < 90), 1))).label("good"), + func.count(case((and_(Exam.round1_score >= 70, Exam.round1_score < 80), 1))).label("medium"), + func.count(case((and_(Exam.round1_score >= 60, Exam.round1_score < 70), 1))).label("pass_count"), + func.count(case((Exam.round1_score < 60, 1))).label("fail") + ).where(and_(*conditions)) + + result = await db.execute(stmt) + row = result.one() + + return { + "excellent": row.excellent or 0, # 优秀(90-100) + "good": row.good or 0, # 良好(80-89) + "medium": row.medium or 0, # 中等(70-79) + "pass": row.pass_count or 0, # 及格(60-69) + "fail": row.fail or 0 # 不及格(<60) + } + + @staticmethod + async def get_difficulty_analysis( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取题目难度分析 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 各难度题目的正确率统计 + """ + logger.info(f"获取难度分析 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 获取用户在时间范围内的考试 + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + exam_ids_stmt = select(Exam.id).where(and_(*exam_conditions)) + result = await db.execute(exam_ids_stmt) + exam_ids = [row[0] for row in result.all()] + + if not exam_ids: + # 没有考试数据,返回默认值 + return { + "easy": 100.0, + "medium": 100.0, + "hard": 100.0, + "综合题": 100.0, + "应用题": 100.0 + } + + # 统计各难度的总题数和错题数 + difficulty_stats = {} + + for difficulty in ["easy", "medium", "hard"]: + # 总题数:从exams的questions字段中统计(这里简化处理) + # 由于questions字段是JSON,我们通过question_count估算 + # 实际应用中可以解析JSON或通过exam_results表统计 + + # 错题数:从exam_mistakes通过question_id关联查询 + mistake_stmt = select(func.count(ExamMistake.id)).select_from( + ExamMistake + ).join( + Question, ExamMistake.question_id == Question.id + ).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.exam_id.in_(exam_ids), + Question.difficulty == difficulty + ) + ) + + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 总题数:该难度的题目在用户考试中出现的次数 + # 简化处理:假设每次考试平均包含该难度题目的比例 + total_questions_stmt = select( + func.coalesce(func.sum(Exam.question_count), 0) + ).where(and_(*exam_conditions)) + total_count = await db.scalar(total_questions_stmt) or 0 + total_count = int(total_count) # 转换为int避免Decimal类型问题 + + # 简化算法:假设easy:medium:hard = 3:2:1 + if difficulty == "easy": + estimated_count = int(total_count * 0.5) + elif difficulty == "medium": + estimated_count = int(total_count * 0.3) + else: # hard + estimated_count = int(total_count * 0.2) + + # 计算正确率 + if estimated_count > 0: + correct_count = max(0, estimated_count - mistake_count) + accuracy = round((correct_count / estimated_count) * 100, 1) + else: + accuracy = 100.0 + + difficulty_stats[difficulty] = accuracy + + # 综合题和应用题使用中等和困难题的平均值 + difficulty_stats["综合题"] = round((difficulty_stats["medium"] + difficulty_stats["hard"]) / 2, 1) + difficulty_stats["应用题"] = round((difficulty_stats["medium"] + difficulty_stats["hard"]) / 2, 1) + + return { + "简单题": difficulty_stats["easy"], + "中等题": difficulty_stats["medium"], + "困难题": difficulty_stats["hard"], + "综合题": difficulty_stats["综合题"], + "应用题": difficulty_stats["应用题"] + } + + @staticmethod + async def get_knowledge_mastery( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None + ) -> List[Dict[str, Any]]: + """ + 获取知识点掌握度 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + + Returns: + List[Dict]: 知识点掌握度列表 + """ + logger.info(f"获取知识点掌握度 - user_id: {user_id}, course_id: {course_id}") + + # 获取知识点列表 + kp_conditions = [KnowledgePoint.is_deleted == False] + if course_id: + kp_conditions.append(KnowledgePoint.course_id == course_id) + + kp_stmt = select(KnowledgePoint).where(and_(*kp_conditions)).limit(10) + result = await db.execute(kp_stmt) + knowledge_points = result.scalars().all() + + if not knowledge_points: + # 没有知识点数据,返回默认数据 + return [ + {"name": "基础概念", "mastery": 85.0}, + {"name": "核心知识", "mastery": 72.0}, + {"name": "实践应用", "mastery": 68.0}, + {"name": "综合运用", "mastery": 58.0}, + {"name": "高级技巧", "mastery": 75.0}, + {"name": "案例分析", "mastery": 62.0} + ] + + mastery_list = [] + + for kp in knowledge_points: + # 统计该知识点的错误次数 + mistake_stmt = select(func.count(ExamMistake.id)).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.knowledge_point_id == kp.id + ) + ) + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 假设每个知识点平均被考查10次(简化处理) + estimated_total = 10 + + # 计算掌握度 + if estimated_total > 0: + correct_count = max(0, estimated_total - mistake_count) + mastery = round((correct_count / estimated_total) * 100, 1) + else: + mastery = 100.0 + + mastery_list.append({ + "name": kp.name[:10], # 限制名称长度 + "mastery": mastery + }) + + return mastery_list[:6] # 最多返回6个知识点 + + @staticmethod + async def get_study_time_stats( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> Dict[str, Any]: + """ + 获取学习时长统计 + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + Dict: 学习时长和练习时长的日期分布数据 + """ + logger.info(f"获取学习时长统计 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 获取天数 + days = (end_date - start_date).days + if days > 30: + days = 30 # 最多显示30天 + + # 生成日期列表 + date_list = [] + for i in range(days): + date = end_date - timedelta(days=days - i - 1) + date_list.append(date.date()) + + # 初始化数据 + study_time_data = {str(d): 0.0 for d in date_list} + practice_time_data = {str(d): 0.0 for d in date_list} + + # 统计考试时长(学习时长) + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + exam_stmt = select( + func.date(Exam.start_time).label("date"), + func.sum(Exam.duration_minutes).label("total_minutes") + ).where( + and_(*exam_conditions) + ).group_by( + func.date(Exam.start_time) + ) + + exam_result = await db.execute(exam_stmt) + for row in exam_result.all(): + date_str = str(row.date) + if date_str in study_time_data: + study_time_data[date_str] = round(float(row.total_minutes) / 60, 1) + + # 统计陪练时长(练习时长) + practice_conditions = [ + PracticeSession.user_id == user_id, + PracticeSession.start_time >= start_date, + PracticeSession.start_time <= end_date, + PracticeSession.status == "completed" + ] + + practice_stmt = select( + func.date(PracticeSession.start_time).label("date"), + func.sum(PracticeSession.duration_seconds).label("total_seconds") + ).where( + and_(*practice_conditions) + ).group_by( + func.date(PracticeSession.start_time) + ) + + practice_result = await db.execute(practice_stmt) + for row in practice_result.all(): + date_str = str(row.date) + if date_str in practice_time_data: + practice_time_data[date_str] = round(float(row.total_seconds) / 3600, 1) + + # 如果period是week,返回星期几标签 + if period == "week": + weekday_labels = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] + labels = weekday_labels[:len(date_list)] + else: + # 其他情况返回日期 + labels = [d.strftime("%m-%d") for d in date_list] + + study_values = [study_time_data[str(d)] for d in date_list] + practice_values = [practice_time_data[str(d)] for d in date_list] + + return { + "labels": labels, + "studyTime": study_values, + "practiceTime": practice_values + } + + @staticmethod + async def get_detail_data( + db: AsyncSession, + user_id: int, + course_id: Optional[int] = None, + period: str = "month" + ) -> List[Dict[str, Any]]: + """ + 获取详细统计数据(按日期) + + Args: + db: 数据库会话 + user_id: 用户ID + course_id: 课程ID(可选) + period: 时间范围 + + Returns: + List[Dict]: 每日详细统计数据 + """ + logger.info(f"获取详细数据 - user_id: {user_id}, course_id: {course_id}, period: {period}") + + start_date, end_date = StatisticsService._get_date_range(period) + + # 构建查询条件 + exam_conditions = [ + Exam.user_id == user_id, + Exam.start_time >= start_date, + Exam.start_time <= end_date, + Exam.round1_score.isnot(None) + ] + if course_id: + exam_conditions.append(Exam.course_id == course_id) + + # 按日期分组统计 + stmt = select( + func.date(Exam.start_time).label("date"), + func.count(Exam.id).label("exam_count"), + func.avg(Exam.round1_score).label("avg_score"), + func.sum(Exam.duration_minutes).label("total_duration"), + func.sum(Exam.question_count).label("total_questions") + ).where( + and_(*exam_conditions) + ).group_by( + func.date(Exam.start_time) + ).order_by( + desc(func.date(Exam.start_time)) + ).limit(10) # 最多返回10条 + + result = await db.execute(stmt) + rows = result.all() + + detail_list = [] + + for row in rows: + date_str = row.date.strftime("%Y-%m-%d") + exam_count = row.exam_count or 0 + avg_score = round(float(row.avg_score or 0), 1) + study_time = round(float(row.total_duration or 0) / 60, 1) + question_count = row.total_questions or 0 + + # 统计当天的错题数 + mistake_stmt = select(func.count(ExamMistake.id)).where( + and_( + ExamMistake.user_id == user_id, + ExamMistake.exam_id.in_( + select(Exam.id).where( + and_( + Exam.user_id == user_id, + func.date(Exam.start_time) == row.date + ) + ) + ) + ) + ) + mistake_count = await db.scalar(mistake_stmt) or 0 + + # 计算正确率 + accuracy = 0.0 + if question_count > 0: + correct_count = question_count - mistake_count + accuracy = round((correct_count / question_count) * 100, 1) + + # 计算进步指数(基于平均分) + improvement = min(100, max(0, int(avg_score))) + + detail_list.append({ + "date": date_str, + "examCount": exam_count, + "avgScore": avg_score, + "studyTime": study_time, + "questionCount": question_count, + "accuracy": accuracy, + "improvement": improvement + }) + + return detail_list + diff --git a/backend/app/services/system_log_service.py b/backend/app/services/system_log_service.py new file mode 100644 index 0000000..6fc86b7 --- /dev/null +++ b/backend/app/services/system_log_service.py @@ -0,0 +1,170 @@ +""" +系统日志服务 +""" +import logging +from typing import Optional +from datetime import datetime +from sqlalchemy import select, func, or_ +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.system_log import SystemLog +from app.schemas.system_log import SystemLogCreate, SystemLogQuery + +logger = logging.getLogger(__name__) + + +class SystemLogService: + """系统日志服务类""" + + async def create_log( + self, + db: AsyncSession, + log_data: SystemLogCreate + ) -> SystemLog: + """ + 创建系统日志 + + Args: + db: 数据库会话 + log_data: 日志数据 + + Returns: + 创建的日志对象 + """ + try: + log = SystemLog(**log_data.model_dump()) + db.add(log) + await db.commit() + await db.refresh(log) + return log + except Exception as e: + await db.rollback() + logger.error(f"创建系统日志失败: {str(e)}") + raise + + async def get_logs( + self, + db: AsyncSession, + query_params: SystemLogQuery + ) -> tuple[list[SystemLog], int]: + """ + 查询系统日志列表 + + Args: + db: 数据库会话 + query_params: 查询参数 + + Returns: + (日志列表, 总数) + """ + try: + # 构建基础查询 + stmt = select(SystemLog) + count_stmt = select(func.count(SystemLog.id)) + + # 应用筛选条件 + filters = [] + + if query_params.level: + filters.append(SystemLog.level == query_params.level) + + if query_params.type: + filters.append(SystemLog.type == query_params.type) + + if query_params.user: + filters.append(SystemLog.user == query_params.user) + + if query_params.keyword: + filters.append(SystemLog.message.like(f"%{query_params.keyword}%")) + + if query_params.start_date: + filters.append(SystemLog.created_at >= query_params.start_date) + + if query_params.end_date: + filters.append(SystemLog.created_at <= query_params.end_date) + + # 应用所有筛选条件 + if filters: + stmt = stmt.where(*filters) + count_stmt = count_stmt.where(*filters) + + # 获取总数 + result = await db.execute(count_stmt) + total = result.scalar_one() + + # 应用排序和分页 + stmt = stmt.order_by(SystemLog.created_at.desc()) + stmt = stmt.offset((query_params.page - 1) * query_params.page_size) + stmt = stmt.limit(query_params.page_size) + + # 执行查询 + result = await db.execute(stmt) + logs = result.scalars().all() + + return list(logs), total + + except Exception as e: + logger.error(f"查询系统日志失败: {str(e)}") + raise + + async def get_log_by_id( + self, + db: AsyncSession, + log_id: int + ) -> Optional[SystemLog]: + """ + 根据ID获取日志详情 + + Args: + db: 数据库会话 + log_id: 日志ID + + Returns: + 日志对象或None + """ + try: + stmt = select(SystemLog).where(SystemLog.id == log_id) + result = await db.execute(stmt) + return result.scalar_one_or_none() + except Exception as e: + logger.error(f"获取日志详情失败: {str(e)}") + raise + + async def delete_logs_before_date( + self, + db: AsyncSession, + before_date: datetime + ) -> int: + """ + 删除指定日期之前的日志(用于日志清理) + + Args: + db: 数据库会话 + before_date: 截止日期 + + Returns: + 删除的日志数量 + """ + try: + stmt = select(SystemLog).where(SystemLog.created_at < before_date) + result = await db.execute(stmt) + logs = result.scalars().all() + + count = len(logs) + for log in logs: + await db.delete(log) + + await db.commit() + logger.info(f"已删除 {count} 条日志记录") + return count + except Exception as e: + await db.rollback() + logger.error(f"删除日志失败: {str(e)}") + raise + + +# 创建全局服务实例 +system_log_service = SystemLogService() + + + diff --git a/backend/app/services/task_service.py b/backend/app/services/task_service.py new file mode 100644 index 0000000..f6b30dc --- /dev/null +++ b/backend/app/services/task_service.py @@ -0,0 +1,214 @@ +""" +任务服务 +""" +from typing import List, Optional +from datetime import datetime +from sqlalchemy import select, func, and_ +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import joinedload +from app.models.task import Task, TaskCourse, TaskAssignment, TaskStatus, AssignmentStatus +from app.models.course import Course +from app.schemas.task import TaskCreate, TaskUpdate, TaskStatsResponse +from app.services.base_service import BaseService + + +class TaskService(BaseService[Task]): + """任务服务""" + + def __init__(self): + super().__init__(Task) + + async def create_task(self, db: AsyncSession, task_in: TaskCreate, creator_id: int) -> Task: + """创建任务""" + # 创建任务 + task = Task( + title=task_in.title, + description=task_in.description, + priority=task_in.priority, + deadline=task_in.deadline, + requirements=task_in.requirements, + creator_id=creator_id, + status=TaskStatus.PENDING + ) + db.add(task) + await db.flush() + + # 关联课程 + for course_id in task_in.course_ids: + task_course = TaskCourse(task_id=task.id, course_id=course_id) + db.add(task_course) + + # 分配用户 + for user_id in task_in.user_ids: + assignment = TaskAssignment(task_id=task.id, user_id=user_id) + db.add(assignment) + + await db.commit() + await db.refresh(task) + return task + + async def get_tasks( + self, + db: AsyncSession, + status: Optional[str] = None, + page: int = 1, + page_size: int = 20 + ) -> (List[Task], int): + """获取任务列表""" + stmt = select(Task).where(Task.is_deleted == False) + + if status: + stmt = stmt.where(Task.status == status) + + stmt = stmt.order_by(Task.created_at.desc()) + + # 获取总数 + count_stmt = select(func.count()).select_from(Task).where(Task.is_deleted == False) + if status: + count_stmt = count_stmt.where(Task.status == status) + total = (await db.execute(count_stmt)).scalar_one() + + # 分页 + stmt = stmt.offset((page - 1) * page_size).limit(page_size) + result = await db.execute(stmt) + tasks = result.scalars().all() + + return tasks, total + + async def get_task_detail(self, db: AsyncSession, task_id: int) -> Optional[Task]: + """获取任务详情""" + stmt = select(Task).where( + and_(Task.id == task_id, Task.is_deleted == False) + ).options( + joinedload(Task.course_links).joinedload(TaskCourse.course), + joinedload(Task.assignments) + ) + result = await db.execute(stmt) + return result.unique().scalar_one_or_none() + + async def update_task(self, db: AsyncSession, task_id: int, task_in: TaskUpdate) -> Optional[Task]: + """更新任务""" + stmt = select(Task).where(and_(Task.id == task_id, Task.is_deleted == False)) + result = await db.execute(stmt) + task = result.scalar_one_or_none() + + if not task: + return None + + update_data = task_in.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(task, field, value) + + await db.commit() + await db.refresh(task) + return task + + async def delete_task(self, db: AsyncSession, task_id: int) -> bool: + """删除任务(软删除)""" + stmt = select(Task).where(and_(Task.id == task_id, Task.is_deleted == False)) + result = await db.execute(stmt) + task = result.scalar_one_or_none() + + if not task: + return False + + task.is_deleted = True + await db.commit() + return True + + async def get_task_stats(self, db: AsyncSession) -> TaskStatsResponse: + """获取任务统计""" + # 总任务数 + total_stmt = select(func.count()).select_from(Task).where(Task.is_deleted == False) + total = (await db.execute(total_stmt)).scalar_one() + + # 各状态任务数 + status_stmt = select( + Task.status, + func.count(Task.id) + ).where(Task.is_deleted == False).group_by(Task.status) + status_result = await db.execute(status_stmt) + status_counts = dict(status_result.all()) + + # 平均完成率 + avg_stmt = select(func.avg(Task.progress)).where( + and_(Task.is_deleted == False, Task.status != TaskStatus.EXPIRED) + ) + avg_completion = (await db.execute(avg_stmt)).scalar_one() or 0.0 + + return TaskStatsResponse( + total=total, + ongoing=status_counts.get(TaskStatus.ONGOING.value, 0), + completed=status_counts.get(TaskStatus.COMPLETED.value, 0), + expired=status_counts.get(TaskStatus.EXPIRED.value, 0), + avg_completion_rate=round(avg_completion, 1) + ) + + async def update_task_progress(self, db: AsyncSession, task_id: int) -> int: + """ + 更新任务进度 + + 计算已完成的分配数占总分配数的百分比 + """ + # 统计总分配数和完成数 + stmt = select( + func.count(TaskAssignment.id).label('total'), + func.sum( + func.case( + (TaskAssignment.status == AssignmentStatus.COMPLETED, 1), + else_=0 + ) + ).label('completed') + ).where(TaskAssignment.task_id == task_id) + + result = (await db.execute(stmt)).first() + total = result.total or 0 + completed = result.completed or 0 + + if total == 0: + progress = 0 + else: + progress = int((completed / total) * 100) + + # 更新任务进度 + task_stmt = select(Task).where(and_(Task.id == task_id, Task.is_deleted == False)) + task_result = await db.execute(task_stmt) + task = task_result.scalar_one_or_none() + + if task: + task.progress = progress + await db.commit() + + return progress + + async def update_task_status(self, db: AsyncSession, task_id: int): + """ + 更新任务状态 + + 根据进度和截止时间自动更新任务状态 + """ + task = await self.get_task_detail(db, task_id) + if not task: + return + + # 计算并更新进度 + progress = await self.update_task_progress(db, task_id) + + # 自动更新状态 + now = datetime.now() + + if progress == 100: + # 完全完成 + task.status = TaskStatus.COMPLETED + elif task.deadline and now > task.deadline and task.status != TaskStatus.COMPLETED: + # 已过期且未完成 + task.status = TaskStatus.EXPIRED + elif progress > 0 and task.status == TaskStatus.PENDING: + # 已开始但未完成 + task.status = TaskStatus.ONGOING + + await db.commit() + + +task_service = TaskService() + diff --git a/backend/app/services/training_service.py b/backend/app/services/training_service.py new file mode 100644 index 0000000..0926ced --- /dev/null +++ b/backend/app/services/training_service.py @@ -0,0 +1,372 @@ +"""陪练服务层""" +import logging +from typing import List, Optional, Dict, Any +from datetime import datetime +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select, and_, or_, func +from fastapi import HTTPException, status + +from app.models.training import ( + TrainingScene, + TrainingSession, + TrainingMessage, + TrainingReport, + TrainingSceneStatus, + TrainingSessionStatus, + MessageRole, + MessageType, +) +from app.schemas.training import ( + TrainingSceneCreate, + TrainingSceneUpdate, + TrainingSessionCreate, + TrainingSessionUpdate, + TrainingMessageCreate, + TrainingReportCreate, + StartTrainingRequest, + StartTrainingResponse, + EndTrainingRequest, + EndTrainingResponse, +) +from app.services.base_service import BaseService + +# from app.services.ai.coze.client import CozeClient +from app.core.config import get_settings + +logger = logging.getLogger(__name__) +settings = get_settings() + + +class TrainingSceneService(BaseService[TrainingScene]): + """陪练场景服务""" + + def __init__(self): + super().__init__(TrainingScene) + + async def get_active_scenes( + self, + db: AsyncSession, + *, + category: Optional[str] = None, + is_public: Optional[bool] = None, + user_level: Optional[int] = None, + skip: int = 0, + limit: int = 20, + ) -> List[TrainingScene]: + """获取激活的陪练场景列表""" + query = select(self.model).where( + and_( + self.model.status == TrainingSceneStatus.ACTIVE, + self.model.is_deleted == False, + ) + ) + + if category: + query = query.where(self.model.category == category) + + if is_public is not None: + query = query.where(self.model.is_public == is_public) + + if user_level is not None: + query = query.where( + or_( + self.model.required_level == None, + self.model.required_level <= user_level, + ) + ) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) + + async def create_scene( + self, db: AsyncSession, *, scene_in: TrainingSceneCreate, created_by: int + ) -> TrainingScene: + """创建陪练场景""" + return await self.create( + db, obj_in=scene_in, created_by=created_by, updated_by=created_by + ) + + async def update_scene( + self, + db: AsyncSession, + *, + scene_id: int, + scene_in: TrainingSceneUpdate, + updated_by: int, + ) -> Optional[TrainingScene]: + """更新陪练场景""" + scene = await self.get(db, scene_id) + if not scene or scene.is_deleted: + return None + + scene.updated_by = updated_by + return await self.update(db, db_obj=scene, obj_in=scene_in) + + +class TrainingSessionService(BaseService[TrainingSession]): + """陪练会话服务""" + + def __init__(self): + super().__init__(TrainingSession) + self.scene_service = TrainingSceneService() + self.message_service = TrainingMessageService() + self.report_service = TrainingReportService() + # TODO: 等Coze网关模块实现后替换 + self._coze_client = None + + @property + def coze_client(self): + """延迟初始化Coze客户端""" + if self._coze_client is None: + try: + # from app.services.ai.coze.client import CozeClient + # self._coze_client = CozeClient() + logger.warning("Coze客户端暂未实现,使用模拟模式") + self._coze_client = None + except ImportError: + logger.warning("Coze客户端未实现,使用模拟模式") + return self._coze_client + + async def start_training( + self, db: AsyncSession, *, request: StartTrainingRequest, user_id: int + ) -> StartTrainingResponse: + """开始陪练会话""" + # 验证场景 + scene = await self.scene_service.get(db, request.scene_id) + if not scene or scene.is_deleted or scene.status != TrainingSceneStatus.ACTIVE: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, detail="陪练场景不存在或未激活" + ) + + # 检查用户等级 + # TODO: 从User服务获取用户等级 + user_level = 1 # 临时模拟 + if scene.required_level and user_level < scene.required_level: + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="用户等级不足") + + # 创建会话 + session_data = TrainingSessionCreate( + scene_id=request.scene_id, session_config=request.config + ) + + session = await self.create( + db, obj_in=session_data, user_id=user_id, created_by=user_id + ) + + # 初始化Coze会话 + coze_conversation_id = None + if self.coze_client and scene.ai_config: + try: + bot_id = scene.ai_config.get("bot_id", settings.coze_training_bot_id) + if bot_id: + # 创建Coze会话 + coze_result = await self.coze_client.create_conversation( + bot_id=bot_id, + user_id=str(user_id), + meta_data={ + "scene_id": scene.id, + "scene_name": scene.name, + "session_id": session.id, + }, + ) + coze_conversation_id = coze_result.get("conversation_id") + + # 更新会话的Coze ID + session.coze_conversation_id = coze_conversation_id + await db.commit() + except Exception as e: + logger.error(f"创建Coze会话失败: {e}") + + # 加载场景信息 + await db.refresh(session, ["scene"]) + + # 构造WebSocket URL(如果需要) + websocket_url = None + if coze_conversation_id: + websocket_url = f"ws://localhost:8000/ws/v1/training/{session.id}" + + return StartTrainingResponse( + session_id=session.id, + coze_conversation_id=coze_conversation_id, + scene=scene, + websocket_url=websocket_url, + ) + + async def end_training( + self, + db: AsyncSession, + *, + session_id: int, + request: EndTrainingRequest, + user_id: int, + ) -> EndTrainingResponse: + """结束陪练会话""" + # 获取会话 + session = await self.get(db, session_id) + if not session or session.user_id != user_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="会话不存在") + + if session.status in [ + TrainingSessionStatus.COMPLETED, + TrainingSessionStatus.CANCELLED, + ]: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="会话已结束") + + # 计算持续时间 + end_time = datetime.now() + duration_seconds = int((end_time - session.start_time).total_seconds()) + + # 更新会话状态 + update_data = TrainingSessionUpdate( + status=TrainingSessionStatus.COMPLETED, + end_time=end_time, + duration_seconds=duration_seconds, + ) + session = await self.update(db, db_obj=session, obj_in=update_data) + + # 生成报告 + report = None + if request.generate_report: + report = await self._generate_report( + db, session_id=session_id, user_id=user_id + ) + + # 加载关联数据 + await db.refresh(session, ["scene"]) + if report: + await db.refresh(report, ["session"]) + + return EndTrainingResponse(session=session, report=report) + + async def get_user_sessions( + self, + db: AsyncSession, + *, + user_id: int, + scene_id: Optional[int] = None, + status: Optional[TrainingSessionStatus] = None, + skip: int = 0, + limit: int = 20, + ) -> List[TrainingSession]: + """获取用户的陪练会话列表""" + query = select(self.model).where(self.model.user_id == user_id) + + if scene_id: + query = query.where(self.model.scene_id == scene_id) + + if status: + query = query.where(self.model.status == status) + + query = query.order_by(self.model.created_at.desc()) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) + + async def _generate_report( + self, db: AsyncSession, *, session_id: int, user_id: int + ) -> Optional[TrainingReport]: + """生成陪练报告(内部方法)""" + # 获取会话消息 + messages = await self.message_service.get_session_messages( + db, session_id=session_id + ) + + # TODO: 调用AI分析服务生成报告 + # 这里先生成模拟报告 + report_data = TrainingReportCreate( + session_id=session_id, + user_id=user_id, + overall_score=85.5, + dimension_scores={"表达能力": 88.0, "逻辑思维": 85.0, "专业知识": 82.0, "应变能力": 87.0}, + strengths=["表达清晰,语言流畅", "能够快速理解问题并作出回应", "展现了良好的专业素养"], + weaknesses=["部分专业术语使用不够准确", "回答有时过于冗长,需要更加精炼"], + suggestions=["加强专业知识的学习,特别是术语的准确使用", "练习更加简洁有力的表达方式", "增加实际案例的积累,丰富回答内容"], + detailed_analysis="整体表现良好,展现了扎实的基础知识和良好的沟通能力...", + statistics={ + "total_messages": len(messages), + "user_messages": len( + [m for m in messages if m.role == MessageRole.USER] + ), + "avg_response_time": 2.5, + "total_words": 1500, + }, + ) + + return await self.report_service.create( + db, obj_in=report_data, created_by=user_id + ) + + +class TrainingMessageService(BaseService[TrainingMessage]): + """陪练消息服务""" + + def __init__(self): + super().__init__(TrainingMessage) + + async def create_message( + self, db: AsyncSession, *, message_in: TrainingMessageCreate + ) -> TrainingMessage: + """创建消息""" + return await self.create(db, obj_in=message_in) + + async def get_session_messages( + self, db: AsyncSession, *, session_id: int, skip: int = 0, limit: int = 100 + ) -> List[TrainingMessage]: + """获取会话的所有消息""" + query = ( + select(self.model) + .where(self.model.session_id == session_id) + .order_by(self.model.created_at) + ) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) + + async def save_voice_message( + self, + db: AsyncSession, + *, + session_id: int, + role: MessageRole, + content: str, + voice_url: str, + voice_duration: float, + metadata: Optional[Dict[str, Any]] = None, + ) -> TrainingMessage: + """保存语音消息""" + message_data = TrainingMessageCreate( + session_id=session_id, + role=role, + type=MessageType.VOICE, + content=content, + voice_url=voice_url, + voice_duration=voice_duration, + metadata=metadata, + ) + + return await self.create(db, obj_in=message_data) + + +class TrainingReportService(BaseService[TrainingReport]): + """陪练报告服务""" + + def __init__(self): + super().__init__(TrainingReport) + + async def get_by_session( + self, db: AsyncSession, *, session_id: int + ) -> Optional[TrainingReport]: + """根据会话ID获取报告""" + result = await db.execute( + select(self.model).where(self.model.session_id == session_id) + ) + return result.scalar_one_or_none() + + async def get_user_reports( + self, db: AsyncSession, *, user_id: int, skip: int = 0, limit: int = 20 + ) -> List[TrainingReport]: + """获取用户的所有报告""" + query = ( + select(self.model) + .where(self.model.user_id == user_id) + .order_by(self.model.created_at.desc()) + ) + + return await self.get_multi(db, skip=skip, limit=limit, query=query) diff --git a/backend/app/services/user_service.py b/backend/app/services/user_service.py new file mode 100644 index 0000000..3adc275 --- /dev/null +++ b/backend/app/services/user_service.py @@ -0,0 +1,423 @@ +""" +用户服务 +""" + +from datetime import datetime +from typing import Any, Dict, List, Optional + +from sqlalchemy import and_, or_, select, func +from sqlalchemy.exc import IntegrityError +from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy.orm import selectinload + +from app.core.exceptions import ConflictError, NotFoundError +from app.core.logger import logger +from app.core.security import get_password_hash, verify_password +from app.models.user import Team, User, user_teams +from app.schemas.user import UserCreate, UserFilter, UserUpdate +from app.services.base_service import BaseService + + +class UserService(BaseService[User]): + """用户服务""" + + def __init__(self, db: AsyncSession): + super().__init__(User) + self.db = db + + async def get_by_id(self, user_id: int) -> Optional[User]: + """根据ID获取用户""" + result = await self.db.execute( + select(User).where(User.id == user_id, User.is_deleted == False) + ) + return result.scalar_one_or_none() + + async def get_by_username(self, username: str) -> Optional[User]: + """根据用户名获取用户""" + result = await self.db.execute( + select(User).where( + User.username == username, + User.is_deleted == False, + ) + ) + return result.scalar_one_or_none() + + async def get_by_email(self, email: str) -> Optional[User]: + """根据邮箱获取用户""" + result = await self.db.execute( + select(User).where( + User.email == email, + User.is_deleted == False, + ) + ) + return result.scalar_one_or_none() + + async def get_by_phone(self, phone: str) -> Optional[User]: + """根据手机号获取用户""" + result = await self.db.execute( + select(User).where( + User.phone == phone, + User.is_deleted == False, + ) + ) + return result.scalar_one_or_none() + + async def _check_username_exists_all(self, username: str) -> Optional[User]: + """ + 检查用户名是否已存在(包括已删除的用户) + 用于创建用户时检查唯一性约束 + """ + result = await self.db.execute( + select(User).where(User.username == username) + ) + return result.scalar_one_or_none() + + async def _check_email_exists_all(self, email: str) -> Optional[User]: + """ + 检查邮箱是否已存在(包括已删除的用户) + 用于创建用户时检查唯一性约束 + """ + result = await self.db.execute( + select(User).where(User.email == email) + ) + return result.scalar_one_or_none() + + async def _check_phone_exists_all(self, phone: str) -> Optional[User]: + """ + 检查手机号是否已存在(包括已删除的用户) + 用于创建用户时检查唯一性约束 + """ + result = await self.db.execute( + select(User).where(User.phone == phone) + ) + return result.scalar_one_or_none() + + async def create_user( + self, + *, + obj_in: UserCreate, + created_by: Optional[int] = None, + ) -> User: + """创建用户""" + # 检查用户名是否已存在(包括已删除的用户,防止唯一键冲突) + existing_user = await self._check_username_exists_all(obj_in.username) + if existing_user: + if existing_user.is_deleted: + raise ConflictError(f"用户名 {obj_in.username} 已被使用(历史用户),请更换其他用户名") + else: + raise ConflictError(f"用户名 {obj_in.username} 已存在") + + # 检查邮箱是否已存在(包括已删除的用户) + if obj_in.email: + existing_email = await self._check_email_exists_all(obj_in.email) + if existing_email: + if existing_email.is_deleted: + raise ConflictError(f"邮箱 {obj_in.email} 已被使用(历史用户),请更换其他邮箱") + else: + raise ConflictError(f"邮箱 {obj_in.email} 已存在") + + # 检查手机号是否已存在(包括已删除的用户) + if obj_in.phone: + existing_phone = await self._check_phone_exists_all(obj_in.phone) + if existing_phone: + if existing_phone.is_deleted: + raise ConflictError(f"手机号 {obj_in.phone} 已被使用(历史用户),请更换其他手机号") + else: + raise ConflictError(f"手机号 {obj_in.phone} 已存在") + + # 创建用户数据 + user_data = obj_in.model_dump(exclude={"password"}) + user_data["hashed_password"] = get_password_hash(obj_in.password) + # 注意:User模型不包含created_by字段,该信息记录在日志中 + # user_data["created_by"] = created_by + + try: + # 创建用户 + user = await self.create(db=self.db, obj_in=user_data) + except IntegrityError as e: + # 捕获数据库唯一键冲突异常,返回友好错误信息 + await self.db.rollback() + error_msg = str(e.orig) if e.orig else str(e) + logger.warning( + "创建用户时发生唯一键冲突", + username=obj_in.username, + email=obj_in.email, + error=error_msg, + ) + if "username" in error_msg.lower(): + raise ConflictError(f"用户名 {obj_in.username} 已被占用,请更换其他用户名") + elif "email" in error_msg.lower(): + raise ConflictError(f"邮箱 {obj_in.email} 已被占用,请更换其他邮箱") + elif "phone" in error_msg.lower(): + raise ConflictError(f"手机号 {obj_in.phone} 已被占用,请更换其他手机号") + else: + raise ConflictError(f"创建用户失败:数据冲突,请检查用户名、邮箱或手机号是否重复") + + # 记录日志 + logger.info( + "用户创建成功", + user_id=user.id, + username=user.username, + role=user.role, + created_by=created_by, + ) + + return user + + async def update_user( + self, + *, + user_id: int, + obj_in: UserUpdate, + updated_by: Optional[int] = None, + ) -> User: + """更新用户""" + user = await self.get_by_id(user_id) + if not user: + raise NotFoundError("用户不存在") + + # 如果更新邮箱,检查是否已存在 + if obj_in.email and obj_in.email != user.email: + if await self.get_by_email(obj_in.email): + raise ConflictError(f"邮箱 {obj_in.email} 已存在") + + # 如果更新手机号,检查是否已存在 + if obj_in.phone and obj_in.phone != user.phone: + if await self.get_by_phone(obj_in.phone): + raise ConflictError(f"手机号 {obj_in.phone} 已存在") + + # 更新用户数据 + update_data = obj_in.model_dump(exclude_unset=True) + update_data["updated_by"] = updated_by + + user = await self.update(db=self.db, db_obj=user, obj_in=update_data) + + # 记录日志 + logger.info( + "用户更新成功", + user_id=user.id, + username=user.username, + updated_fields=list(update_data.keys()), + updated_by=updated_by, + ) + + return user + + async def update_password( + self, + *, + user_id: int, + old_password: str, + new_password: str, + ) -> User: + """更新密码""" + user = await self.get_by_id(user_id) + if not user: + raise NotFoundError("用户不存在") + + # 验证旧密码 + if not verify_password(old_password, user.hashed_password): + raise ConflictError("旧密码错误") + + # 更新密码 + update_data = { + "hashed_password": get_password_hash(new_password), + "password_changed_at": datetime.now(), + } + user = await self.update(db=self.db, db_obj=user, obj_in=update_data) + + # 记录日志 + logger.info( + "用户密码更新成功", + user_id=user.id, + username=user.username, + ) + + return user + + async def update_last_login(self, user_id: int) -> None: + """更新最后登录时间""" + user = await self.get_by_id(user_id) + if user: + await self.update( + db=self.db, + db_obj=user, + obj_in={"last_login_at": datetime.now()}, + ) + + async def get_users_with_filter( + self, + *, + skip: int = 0, + limit: int = 100, + filter_params: UserFilter, + ) -> tuple[List[User], int]: + """根据筛选条件获取用户列表""" + # 构建筛选条件 + filters = [User.is_deleted == False] + + if filter_params.role: + filters.append(User.role == filter_params.role) + + if filter_params.is_active is not None: + filters.append(User.is_active == filter_params.is_active) + + if filter_params.keyword: + keyword = f"%{filter_params.keyword}%" + filters.append( + or_( + User.username.like(keyword), + User.email.like(keyword), + User.full_name.like(keyword), + ) + ) + + if filter_params.team_id: + # 通过团队ID筛选用户 + subquery = select(user_teams.c.user_id).where( + user_teams.c.team_id == filter_params.team_id + ) + filters.append(User.id.in_(subquery)) + + # 构建查询 + query = select(User).where(and_(*filters)) + + # 获取用户列表 + users = await self.get_multi(self.db, skip=skip, limit=limit, query=query) + + # 获取总数 + count_query = select(func.count(User.id)).where(and_(*filters)) + count_result = await self.db.execute(count_query) + total = count_result.scalar() + + return users, total + + async def add_user_to_team( + self, + *, + user_id: int, + team_id: int, + role: str = "member", + ) -> None: + """将用户添加到团队""" + # 检查用户是否存在 + user = await self.get_by_id(user_id) + if not user: + raise NotFoundError("用户不存在") + + # 检查团队是否存在 + team_result = await self.db.execute( + select(Team).where(Team.id == team_id, Team.is_deleted == False) + ) + team = team_result.scalar_one_or_none() + if not team: + raise NotFoundError("团队不存在") + + # 检查是否已在团队中 + existing = await self.db.execute( + select(user_teams).where( + user_teams.c.user_id == user_id, + user_teams.c.team_id == team_id, + ) + ) + if existing.first(): + raise ConflictError("用户已在该团队中") + + # 添加到团队 + await self.db.execute( + user_teams.insert().values( + user_id=user_id, + team_id=team_id, + role=role, + joined_at=datetime.now(), + ) + ) + await self.db.commit() + + # 记录日志 + logger.info( + "用户加入团队", + user_id=user_id, + username=user.username, + team_id=team_id, + team_name=team.name, + role=role, + ) + + async def remove_user_from_team( + self, + *, + user_id: int, + team_id: int, + ) -> None: + """从团队中移除用户""" + # 删除关联 + result = await self.db.execute( + user_teams.delete().where( + user_teams.c.user_id == user_id, + user_teams.c.team_id == team_id, + ) + ) + + if result.rowcount == 0: + raise NotFoundError("用户不在该团队中") + + await self.db.commit() + + # 记录日志 + logger.info( + "用户离开团队", + user_id=user_id, + team_id=team_id, + ) + + async def soft_delete(self, *, db_obj: User) -> User: + """ + 软删除用户 + + Args: + db_obj: 用户对象 + + Returns: + 软删除后的用户对象 + """ + db_obj.is_deleted = True + db_obj.deleted_at = datetime.now() + self.db.add(db_obj) + await self.db.commit() + await self.db.refresh(db_obj) + + logger.info( + "用户软删除成功", + user_id=db_obj.id, + username=db_obj.username, + ) + + return db_obj + + async def authenticate( + self, + *, + username: str, + password: str, + ) -> Optional[User]: + """用户认证""" + # 尝试用户名登录 + user = await self.get_by_username(username) + + # 尝试邮箱登录 + if not user: + user = await self.get_by_email(username) + + # 尝试手机号登录 + if not user: + user = await self.get_by_phone(username) + + if not user: + return None + + # 验证密码 + if not verify_password(password, user.hashed_password): + return None + + return user diff --git a/backend/app/services/yanji_service.py b/backend/app/services/yanji_service.py new file mode 100644 index 0000000..8bc2b1c --- /dev/null +++ b/backend/app/services/yanji_service.py @@ -0,0 +1,510 @@ +""" +言迹智能工牌API服务 +""" + +import logging +import random +from datetime import datetime, timedelta +from typing import Dict, List, Optional, Any + +import httpx + +from app.core.config import settings + +logger = logging.getLogger(__name__) + + +class YanjiService: + """言迹智能工牌API服务类""" + + def __init__(self): + self.base_url = settings.YANJI_API_BASE + self.client_id = settings.YANJI_CLIENT_ID + self.client_secret = settings.YANJI_CLIENT_SECRET + self.tenant_id = settings.YANJI_TENANT_ID + self.estate_id = int(settings.YANJI_ESTATE_ID) + + # Token缓存 + self._access_token: Optional[str] = None + self._token_expires_at: Optional[datetime] = None + + async def get_access_token(self) -> str: + """ + 获取或刷新access_token + + Returns: + access_token字符串 + """ + # 检查缓存的token是否仍然有效(提前5分钟刷新) + if self._access_token and self._token_expires_at: + if datetime.now() < self._token_expires_at - timedelta(minutes=5): + return self._access_token + + # 获取新的token + url = f"{self.base_url}/oauth/token" + params = { + "grant_type": "client_credentials", + "client_id": self.client_id, + "client_secret": self.client_secret, + } + + async with httpx.AsyncClient() as client: + response = await client.get(url, params=params, timeout=30.0) + response.raise_for_status() + data = response.json() + + self._access_token = data["access_token"] + expires_in = data.get("expires_in", 3600) # 默认1小时 + self._token_expires_at = datetime.now() + timedelta(seconds=expires_in) + + logger.info(f"言迹API token获取成功,有效期至: {self._token_expires_at}") + return self._access_token + + async def _request( + self, + method: str, + path: str, + params: Optional[Dict] = None, + json_data: Optional[Dict] = None, + ) -> Dict: + """ + 统一的HTTP请求方法 + + Args: + method: HTTP方法(GET/POST等) + path: API路径 + params: Query参数 + json_data: Body参数(JSON) + + Returns: + 响应数据(data字段) + + Raises: + Exception: API调用失败 + """ + token = await self.get_access_token() + url = f"{self.base_url}{path}" + headers = {"Authorization": f"Bearer {token}"} + + async with httpx.AsyncClient() as client: + response = await client.request( + method=method, + url=url, + params=params, + json=json_data, + headers=headers, + timeout=60.0, + ) + response.raise_for_status() + result = response.json() + + # 言迹API: code='0'或code=0表示成功 + code = result.get("code") + if str(code) != '0': + error_msg = result.get("msg", "Unknown error") + logger.error(f"言迹API调用失败: {error_msg}, result={result}") + raise Exception(f"言迹API错误: {error_msg}") + + # data可能为None,返回空字典或空列表由调用方判断 + return result.get("data") + + async def get_visit_audios( + self, external_visit_ids: List[str] + ) -> List[Dict]: + """ + 根据来访单ID获取录音信息 + + Args: + external_visit_ids: 三方来访ID列表(最多10个) + + Returns: + 录音信息列表 + """ + if not external_visit_ids: + return [] + + if len(external_visit_ids) > 10: + logger.warning(f"来访单ID数量超过限制,截取前10个") + external_visit_ids = external_visit_ids[:10] + + data = await self._request( + method="POST", + path="/api/beauty/v1/visit/audios", + json_data={ + "estateId": self.estate_id, + "externalVisitIds": external_visit_ids, + }, + ) + + if data is None: + logger.info(f"获取来访录音信息: 无数据") + return [] + + records = data.get("records", []) + logger.info(f"获取来访录音信息成功: {len(records)}条") + return records + + async def get_audio_asr_result(self, audio_id: int) -> Dict: + """ + 获取录音的ASR分析结果(对话文本) + + Args: + audio_id: 录音ID + + Returns: + ASR分析结果,包含对话文本数组 + """ + data = await self._request( + method="GET", + path="/api/beauty/v1/audio/asr-analysed", + params={"estateId": self.estate_id, "audioId": audio_id}, + ) + + # 检查data是否为None + if data is None: + logger.warning(f"录音ASR结果为None: audio_id={audio_id}") + return {} + + # data是一个数组,取第一个元素 + if isinstance(data, list) and len(data) > 0: + result = data[0] + logger.info( + f"获取录音ASR结果成功: audio_id={audio_id}, " + f"对话数={len(result.get('result', []))}" + ) + return result + else: + logger.warning(f"录音ASR结果为空: audio_id={audio_id}") + return {} + + async def get_recent_conversations( + self, consultant_phone: str, limit: int = 10 + ) -> List[Dict]: + """ + 获取员工最近N条对话记录 + + 业务逻辑: + 1. 通过员工手机号获取录音列表(目前使用模拟数据) + 2. 对每个录音获取ASR分析结果 + 3. 组合返回完整的对话记录 + + Args: + consultant_phone: 员工手机号 + limit: 获取数量,默认10条 + + Returns: + 对话记录列表,格式: + [{ + "audio_id": 123, + "visit_id": "xxx", + "start_time": "2025-01-15 10:30:00", + "duration": 120000, + "consultant_name": "张三", + "consultant_phone": "13800138000", + "conversation": [ + {"role": "consultant", "text": "您好..."}, + {"role": "customer", "text": "你好..."} + ] + }] + """ + # TODO: 目前言迹API没有直接通过手机号查询录音的接口 + # 需要先获取来访单列表,再获取录音 + # 这里暂时返回空列表,后续根据实际业务需求补充 + + logger.warning( + f"获取员工对话记录功能需要额外的业务逻辑支持 " + f"(consultant_phone={consultant_phone}, limit={limit})" + ) + + # 返回空列表,表示暂未实现 + return [] + + async def get_conversations_by_visit_ids( + self, external_visit_ids: List[str] + ) -> List[Dict]: + """ + 根据来访单ID列表获取对话记录 + + Args: + external_visit_ids: 三方来访ID列表 + + Returns: + 对话记录列表 + """ + if not external_visit_ids: + return [] + + # 1. 获取录音信息 + audio_records = await self.get_visit_audios(external_visit_ids) + + if not audio_records: + logger.info("没有找到录音记录") + return [] + + # 2. 对每个录音获取ASR分析结果 + conversations = [] + for audio in audio_records: + audio_id = audio.get("id") + if not audio_id: + continue + + try: + asr_result = await self.get_audio_asr_result(audio_id) + + # 解析对话文本 + conversation_messages = [] + for item in asr_result.get("result", []): + role = "consultant" if item.get("role") == -1 else "customer" + conversation_messages.append({ + "role": role, + "text": item.get("text", ""), + "begin_time": item.get("beginTime"), + "end_time": item.get("endTime"), + }) + + # 组合完整对话记录 + conversations.append({ + "audio_id": audio_id, + "visit_id": audio.get("externalVisitId", ""), + "start_time": audio.get("startTime", ""), + "duration": audio.get("duration", 0), + "consultant_name": audio.get("consultantName", ""), + "consultant_phone": audio.get("consultantPhone", ""), + "conversation": conversation_messages, + }) + + except Exception as e: + logger.error(f"获取录音ASR结果失败: audio_id={audio_id}, error={e}") + continue + + logger.info(f"成功获取{len(conversations)}条对话记录") + return conversations + + async def get_audio_list(self, phone: str) -> List[Dict]: + """ + 获取员工的录音列表(模拟) + + 注意:言迹API暂时没有提供通过手机号直接查询录音列表的接口 + 这里使用模拟数据,返回假想的录音列表 + + Args: + phone: 员工手机号 + + Returns: + 录音信息列表 + """ + logger.info(f"获取员工录音列表(模拟): phone={phone}") + + # 模拟返回10条录音记录 + mock_audios = [] + base_time = datetime.now() + + for i in range(10): + # 模拟不同时长的录音 + durations = [25000, 45000, 180000, 240000, 120000, 90000, 60000, 300000, 420000, 150000] + + mock_audios.append({ + "id": f"mock_audio_{i+1}", + "externalVisitId": f"visit_{i+1}", + "startTime": (base_time - timedelta(days=i)).strftime("%Y-%m-%d %H:%M:%S"), + "duration": durations[i], # 毫秒 + "consultantName": "模拟员工", + "consultantPhone": phone + }) + + return mock_audios + + async def get_employee_conversations_for_analysis( + self, + phone: str, + limit: int = 10 + ) -> List[Dict[str, Any]]: + """ + 获取员工最近N条录音的模拟对话数据(用于能力分析) + + Args: + phone: 员工手机号 + limit: 获取数量,默认10条 + + Returns: + 对话数据列表,格式: + [{ + "audio_id": "mock_audio_1", + "duration_seconds": 25, + "start_time": "2025-10-15 10:30:00", + "dialogue_history": [ + {"speaker": "consultant", "content": "您好..."}, + {"speaker": "customer", "content": "你好..."} + ] + }] + """ + # 1. 获取录音列表 + audios = await self.get_audio_list(phone) + + if not audios: + logger.warning(f"未找到员工的录音记录: phone={phone}") + return [] + + # 2. 筛选前limit条 + selected_audios = audios[:limit] + + # 3. 为每条录音生成模拟对话 + conversations = [] + for audio in selected_audios: + conversation = self._generate_mock_conversation(audio) + conversations.append(conversation) + + logger.info(f"生成模拟对话数据: phone={phone}, count={len(conversations)}") + return conversations + + def _generate_mock_conversation(self, audio: Dict) -> Dict: + """ + 为录音生成模拟对话数据 + + 根据录音时长选择不同复杂度的对话模板: + - <30秒: 短对话(4-6轮) + - 30秒-5分钟: 中等对话(8-12轮) + - >5分钟: 长对话(15-20轮,完整销售流程) + + Args: + audio: 录音信息字典 + + Returns: + 对话数据字典 + """ + duration = int(audio.get('duration', 60000)) // 1000 # 转换为秒 + + # 根据时长选择对话模板 + if duration < 30: + dialogue = self._short_conversation_template() + elif duration < 300: + dialogue = self._medium_conversation_template() + else: + dialogue = self._long_conversation_template() + + return { + "audio_id": audio.get('id'), + "duration_seconds": duration, + "start_time": audio.get('startTime'), + "dialogue_history": dialogue + } + + def _short_conversation_template(self) -> List[Dict]: + """短对话模板(<30秒)- 4-6轮对话""" + templates = [ + [ + {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,请问有什么可以帮到您?"}, + {"speaker": "customer", "content": "你好,我想了解一下面部护理项目"}, + {"speaker": "consultant", "content": "好的,我们有多种面部护理方案,请问您主要关注哪方面呢?"}, + {"speaker": "customer", "content": "主要是想改善皮肤暗沉"}, + {"speaker": "consultant", "content": "明白了,针对皮肤暗沉,我推荐我们的美白焕肤套餐"} + ], + [ + {"speaker": "consultant", "content": "您好,请问需要什么帮助吗?"}, + {"speaker": "customer", "content": "我想咨询一下祛斑项目"}, + {"speaker": "consultant", "content": "好的,请问您主要是哪种类型的斑点呢?"}, + {"speaker": "customer", "content": "脸颊两侧有些黄褐斑"}, + {"speaker": "consultant", "content": "了解,我们有专门针对黄褐斑的光子嫩肤项目,效果很不错"} + ], + [ + {"speaker": "consultant", "content": "欢迎光临,有什么可以帮您的吗?"}, + {"speaker": "customer", "content": "我想预约做个面部护理"}, + {"speaker": "consultant", "content": "好的,请问您之前做过我们的项目吗?"}, + {"speaker": "customer", "content": "没有,第一次来"}, + {"speaker": "consultant", "content": "那我建议您先做个免费的皮肤检测,帮您制定个性化方案"}, + {"speaker": "customer", "content": "好的,那现在可以吗?"} + ] + ] + return random.choice(templates) + + def _medium_conversation_template(self) -> List[Dict]: + """中等对话模板(30秒-5分钟)- 8-12轮对话""" + templates = [ + [ + {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,我是美容顾问小王,请问怎么称呼您?"}, + {"speaker": "customer", "content": "你好,我姓李"}, + {"speaker": "consultant", "content": "李女士您好,请问今天是第一次了解我们的项目吗?"}, + {"speaker": "customer", "content": "是的,之前在网上看到你们的介绍"}, + {"speaker": "consultant", "content": "好的,您对哪方面的美容项目比较感兴趣呢?"}, + {"speaker": "customer", "content": "我想改善面部松弛的问题,最近感觉皮肤没有以前紧致了"}, + {"speaker": "consultant", "content": "我理解您的困扰。请问您多大年龄?平时有做面部护理吗?"}, + {"speaker": "customer", "content": "我35岁,平时就是用护肤品,没做过专业护理"}, + {"speaker": "consultant", "content": "明白了。35岁开始注重抗衰是很及时的。我们有几种方案,比如射频紧肤、超声刀提拉,还有胶原蛋白再生项目"}, + {"speaker": "customer", "content": "这几种有什么区别吗?"}, + {"speaker": "consultant", "content": "射频主要是刺激胶原蛋白增生,效果温和持久。超声刀作用更深层,提拉效果更明显但价格稍高。我建议您先做个皮肤检测,看具体适合哪种"}, + {"speaker": "customer", "content": "好的,那先做个检测吧"} + ], + [ + {"speaker": "consultant", "content": "您好,欢迎光临,我是美容顾问晓雯,请问您是第一次来吗?"}, + {"speaker": "customer", "content": "是的,朋友推荐过来看看"}, + {"speaker": "consultant", "content": "太好了,请问您朋友是做的什么项目呢?"}, + {"speaker": "customer", "content": "她做的好像是什么水光针"}, + {"speaker": "consultant", "content": "水光针确实是我们很受欢迎的项目。请问您今天主要想了解哪方面呢?"}, + {"speaker": "customer", "content": "我主要是皮肤有点粗糙,毛孔也大"}, + {"speaker": "consultant", "content": "嗯,针对毛孔粗大和皮肤粗糙,水光针确实有不错的效果。不过我建议先看看您的具体情况"}, + {"speaker": "customer", "content": "需要检查吗?"}, + {"speaker": "consultant", "content": "是的,我们有专业的皮肤检测仪,可以看到肉眼看不到的皮肤问题,这样制定方案更精准"}, + {"speaker": "customer", "content": "好的,那检查一下吧"}, + {"speaker": "consultant", "content": "好的,请这边来,检查大概需要5分钟"} + ] + ] + return random.choice(templates) + + def _long_conversation_template(self) -> List[Dict]: + """长对话模板(>5分钟)- 15-20轮对话,完整销售流程""" + templates = [ + [ + {"speaker": "consultant", "content": "您好,欢迎光临曼尼斐绮,我是资深美容顾问晓雯,请问怎么称呼您?"}, + {"speaker": "customer", "content": "你好,我姓陈"}, + {"speaker": "consultant", "content": "陈女士您好,看您气色很好,平时应该很注重保养吧?"}, + {"speaker": "customer", "content": "还好吧,基本的护肤品会用"}, + {"speaker": "consultant", "content": "这样啊。那今天是专程过来了解我们的项目,还是朋友推荐的呢?"}, + {"speaker": "customer", "content": "我闺蜜在你们这做过,说效果不错,所以想来看看"}, + {"speaker": "consultant", "content": "太好了,请问您闺蜜做的是什么项目呢?"}, + {"speaker": "customer", "content": "好像是什么光子嫩肤"}, + {"speaker": "consultant", "content": "明白了,光子嫩肤确实是我们的明星项目。不过每个人的皮肤状况不同,我先帮您做个详细的皮肤检测,看看最适合您的方案好吗?"}, + {"speaker": "customer", "content": "好的"}, + {"speaker": "consultant", "content": "陈女士,通过检测我看到您的皮肤主要有三个问题:一是T区毛孔粗大,二是两颊有轻微色斑,三是皮肤缺水。您平时有感觉到这些问题吗?"}, + {"speaker": "customer", "content": "对,毛孔确实有点大,色斑是最近才发现的"}, + {"speaker": "consultant", "content": "嗯,这些问题如果不及时处理会越来越明显。针对您的情况,我建议做一个综合性的美白嫩肤方案"}, + {"speaker": "customer", "content": "具体是怎么做的?"}, + {"speaker": "consultant", "content": "我们采用光子嫩肤配合水光针的组合疗程。光子嫩肤主要解决色斑和毛孔问题,水光针补水锁水,效果相辅相成"}, + {"speaker": "customer", "content": "听起来不错,大概需要多少钱?"}, + {"speaker": "consultant", "content": "我们现在正好有活动,光子嫩肤单次原价3800,水光针单次2600,组合套餐优惠后只要5800,相当于打了九折"}, + {"speaker": "customer", "content": "嗯...还是有点贵"}, + {"speaker": "consultant", "content": "我理解您的顾虑。但是陈女士,您想想,这个价格是一次性投入,效果却能维持3-6个月。平均下来每天不到30块钱,换来的是皮肤的明显改善"}, + {"speaker": "customer", "content": "这倒也是..."}, + {"speaker": "consultant", "content": "而且这个活动就到本月底,下个月恢复原价的话就要6400了。您今天如果确定的话,我还可以帮您申请赠送一次基础补水护理"}, + {"speaker": "customer", "content": "那行吧,今天就定了"}, + {"speaker": "consultant", "content": "太好了!陈女士您做了个很明智的决定。我现在帮您预约最近的时间,您看周三下午方便吗?"} + ], + [ + {"speaker": "consultant", "content": "您好,欢迎光临,我是美容顾问小张,请问您贵姓?"}, + {"speaker": "customer", "content": "我姓王"}, + {"speaker": "consultant", "content": "王女士您好,请坐。今天想了解什么项目呢?"}, + {"speaker": "customer", "content": "我想做个面部提升,感觉脸有点下垂了"}, + {"speaker": "consultant", "content": "嗯,我看得出来您平时很注重保养。请问您今年多大年龄?"}, + {"speaker": "customer", "content": "我42了"}, + {"speaker": "consultant", "content": "42岁这个年龄段,确实容易出现轻微松弛。您之前有做过抗衰项目吗?"}, + {"speaker": "customer", "content": "做过几次普通的面部护理,但感觉效果不明显"}, + {"speaker": "consultant", "content": "普通护理主要是表层保养,对于松弛问题作用有限。您的情况需要更深层的治疗"}, + {"speaker": "customer", "content": "那有什么好的方案吗?"}, + {"speaker": "consultant", "content": "针对您的情况,我推荐热玛吉或者超声刀。这两种都是通过热能刺激深层胶原蛋白重组,达到紧致提升的效果"}, + {"speaker": "customer", "content": "这两种有什么区别?"}, + {"speaker": "consultant", "content": "热玛吉作用在真皮层,效果更自然持久,适合轻中度松弛。超声刀能到达筋膜层,提拉力度更强,适合松弛比较明显的情况"}, + {"speaker": "customer", "content": "我的情况适合哪种?"}, + {"speaker": "consultant", "content": "从您的面部状况来看,我建议选择热玛吉。您的松弛程度属于轻度,热玛吉的效果会更自然,恢复期也更短"}, + {"speaker": "customer", "content": "费用大概多少?"}, + {"speaker": "consultant", "content": "热玛吉全脸的话,我们的价格是28800元。不过您今天来的时机很好,我们正在做周年庆活动,可以优惠到23800"}, + {"speaker": "customer", "content": "还是挺贵的啊"}, + {"speaker": "consultant", "content": "王女士,我理解您的感受。但是热玛吉一次治疗效果可以维持2-3年,平均每天只要20多块钱。而且这是一次性投入,不需要反复做"}, + {"speaker": "customer", "content": "效果真的能维持那么久吗?"}, + {"speaker": "consultant", "content": "这是有科学依据的。热玛吉刺激的是您自身的胶原蛋白再生,不是外来填充,所以效果持久自然。我们有很多客户都做过,反馈都很好"}, + {"speaker": "customer", "content": "那我考虑一下吧"}, + {"speaker": "consultant", "content": "可以的。不过这个活动优惠就到本周日,下周就恢复原价了。而且名额有限,您要是确定的话最好尽快预约"}, + {"speaker": "customer", "content": "好吧,那我今天就定下来吧"} + ] + ] + return random.choice(templates) + + diff --git a/backend/backups/backup_status.json b/backend/backups/backup_status.json new file mode 100644 index 0000000..205b666 --- /dev/null +++ b/backend/backups/backup_status.json @@ -0,0 +1 @@ +{"timestamp":"2025-09-25T02:01:05+08:00","status":"SUCCESS","message":"数据库备份成功完成"} diff --git a/backend/backups/kaopeilian_backup_20250923_032422.sql.gz b/backend/backups/kaopeilian_backup_20250923_032422.sql.gz new file mode 100644 index 0000000..0d0b54b Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_032422.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_034650.sql.gz b/backend/backups/kaopeilian_backup_20250923_034650.sql.gz new file mode 100644 index 0000000..8733caf Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_034650.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_040001.sql.gz b/backend/backups/kaopeilian_backup_20250923_040001.sql.gz new file mode 100644 index 0000000..060d25b Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_040001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_040223.sql.gz b/backend/backups/kaopeilian_backup_20250923_040223.sql.gz new file mode 100644 index 0000000..e9e161f Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_040223.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_050001.sql.gz b/backend/backups/kaopeilian_backup_20250923_050001.sql.gz new file mode 100644 index 0000000..8598287 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_050001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_050032.sql.gz b/backend/backups/kaopeilian_backup_20250923_050032.sql.gz new file mode 100644 index 0000000..30051ba Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_050032.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_060001.sql.gz b/backend/backups/kaopeilian_backup_20250923_060001.sql.gz new file mode 100644 index 0000000..56f190a Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_060001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_060334.sql.gz b/backend/backups/kaopeilian_backup_20250923_060334.sql.gz new file mode 100644 index 0000000..ee373b9 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_060334.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_070001.sql.gz b/backend/backups/kaopeilian_backup_20250923_070001.sql.gz new file mode 100644 index 0000000..5c4c522 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_070001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_070101.sql.gz b/backend/backups/kaopeilian_backup_20250923_070101.sql.gz new file mode 100644 index 0000000..b58dd55 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_070101.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_080001.sql.gz b/backend/backups/kaopeilian_backup_20250923_080001.sql.gz new file mode 100644 index 0000000..24b3ea7 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_080001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_080321.sql.gz b/backend/backups/kaopeilian_backup_20250923_080321.sql.gz new file mode 100644 index 0000000..6a6c822 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_080321.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_090001.sql.gz b/backend/backups/kaopeilian_backup_20250923_090001.sql.gz new file mode 100644 index 0000000..8880cac Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_090001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_090323.sql.gz b/backend/backups/kaopeilian_backup_20250923_090323.sql.gz new file mode 100644 index 0000000..d54956d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_090323.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_100001.sql.gz b/backend/backups/kaopeilian_backup_20250923_100001.sql.gz new file mode 100644 index 0000000..82c97f5 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_100001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_100017.sql.gz b/backend/backups/kaopeilian_backup_20250923_100017.sql.gz new file mode 100644 index 0000000..6fa8959 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_100017.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_110001.sql.gz b/backend/backups/kaopeilian_backup_20250923_110001.sql.gz new file mode 100644 index 0000000..0db915d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_110001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_110245.sql.gz b/backend/backups/kaopeilian_backup_20250923_110245.sql.gz new file mode 100644 index 0000000..88cf83d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_110245.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_120001.sql.gz b/backend/backups/kaopeilian_backup_20250923_120001.sql.gz new file mode 100644 index 0000000..5dc4bc4 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_120001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_120123.sql.gz b/backend/backups/kaopeilian_backup_20250923_120123.sql.gz new file mode 100644 index 0000000..b1cf120 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_120123.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_130001.sql.gz b/backend/backups/kaopeilian_backup_20250923_130001.sql.gz new file mode 100644 index 0000000..101cf3a Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_130001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_130020.sql.gz b/backend/backups/kaopeilian_backup_20250923_130020.sql.gz new file mode 100644 index 0000000..afa496c Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_130020.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_140001.sql.gz b/backend/backups/kaopeilian_backup_20250923_140001.sql.gz new file mode 100644 index 0000000..643d59a Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_140001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_140334.sql.gz b/backend/backups/kaopeilian_backup_20250923_140334.sql.gz new file mode 100644 index 0000000..a10a603 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_140334.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_150001.sql.gz b/backend/backups/kaopeilian_backup_20250923_150001.sql.gz new file mode 100644 index 0000000..10d65ac Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_150001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_150304.sql.gz b/backend/backups/kaopeilian_backup_20250923_150304.sql.gz new file mode 100644 index 0000000..d8f318b Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_150304.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_160001.sql.gz b/backend/backups/kaopeilian_backup_20250923_160001.sql.gz new file mode 100644 index 0000000..05a84b9 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_160001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_160053.sql.gz b/backend/backups/kaopeilian_backup_20250923_160053.sql.gz new file mode 100644 index 0000000..069e992 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_160053.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_170001.sql.gz b/backend/backups/kaopeilian_backup_20250923_170001.sql.gz new file mode 100644 index 0000000..970b51d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_170001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_170057.sql.gz b/backend/backups/kaopeilian_backup_20250923_170057.sql.gz new file mode 100644 index 0000000..0da97fb Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_170057.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_180001.sql.gz b/backend/backups/kaopeilian_backup_20250923_180001.sql.gz new file mode 100644 index 0000000..20f9e3d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_180001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_180429.sql.gz b/backend/backups/kaopeilian_backup_20250923_180429.sql.gz new file mode 100644 index 0000000..98b12a5 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_180429.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_190001.sql.gz b/backend/backups/kaopeilian_backup_20250923_190001.sql.gz new file mode 100644 index 0000000..09885c3 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_190001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_190043.sql.gz b/backend/backups/kaopeilian_backup_20250923_190043.sql.gz new file mode 100644 index 0000000..8143a25 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_190043.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_200001.sql.gz b/backend/backups/kaopeilian_backup_20250923_200001.sql.gz new file mode 100644 index 0000000..f91641b Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_200001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_200124.sql.gz b/backend/backups/kaopeilian_backup_20250923_200124.sql.gz new file mode 100644 index 0000000..9b45497 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_200124.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_210001.sql.gz b/backend/backups/kaopeilian_backup_20250923_210001.sql.gz new file mode 100644 index 0000000..6751f43 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_210001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_210154.sql.gz b/backend/backups/kaopeilian_backup_20250923_210154.sql.gz new file mode 100644 index 0000000..000b115 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_210154.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_220001.sql.gz b/backend/backups/kaopeilian_backup_20250923_220001.sql.gz new file mode 100644 index 0000000..660efee Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_220001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_220101.sql.gz b/backend/backups/kaopeilian_backup_20250923_220101.sql.gz new file mode 100644 index 0000000..a7797a1 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_220101.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_230001.sql.gz b/backend/backups/kaopeilian_backup_20250923_230001.sql.gz new file mode 100644 index 0000000..daad1d4 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_230001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250923_230241.sql.gz b/backend/backups/kaopeilian_backup_20250923_230241.sql.gz new file mode 100644 index 0000000..fb883ef Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250923_230241.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_000001.sql.gz b/backend/backups/kaopeilian_backup_20250924_000001.sql.gz new file mode 100644 index 0000000..62619f2 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_000001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_000429.sql.gz b/backend/backups/kaopeilian_backup_20250924_000429.sql.gz new file mode 100644 index 0000000..9c50e67 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_000429.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_010001.sql.gz b/backend/backups/kaopeilian_backup_20250924_010001.sql.gz new file mode 100644 index 0000000..966efd4 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_010001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_010153.sql.gz b/backend/backups/kaopeilian_backup_20250924_010153.sql.gz new file mode 100644 index 0000000..a5a60dd Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_010153.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_020001.sql.gz b/backend/backups/kaopeilian_backup_20250924_020001.sql.gz new file mode 100644 index 0000000..b2fb244 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_020001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_020216.sql.gz b/backend/backups/kaopeilian_backup_20250924_020216.sql.gz new file mode 100644 index 0000000..e763fbb Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_020216.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_030001.sql.gz b/backend/backups/kaopeilian_backup_20250924_030001.sql.gz new file mode 100644 index 0000000..a1acd48 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_030001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_030304.sql.gz b/backend/backups/kaopeilian_backup_20250924_030304.sql.gz new file mode 100644 index 0000000..151f143 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_030304.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_040001.sql.gz b/backend/backups/kaopeilian_backup_20250924_040001.sql.gz new file mode 100644 index 0000000..81b07e9 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_040001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_040030.sql.gz b/backend/backups/kaopeilian_backup_20250924_040030.sql.gz new file mode 100644 index 0000000..5528d4a Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_040030.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_050001.sql.gz b/backend/backups/kaopeilian_backup_20250924_050001.sql.gz new file mode 100644 index 0000000..f6daf8a Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_050001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_050404.sql.gz b/backend/backups/kaopeilian_backup_20250924_050404.sql.gz new file mode 100644 index 0000000..ee06d41 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_050404.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_060001.sql.gz b/backend/backups/kaopeilian_backup_20250924_060001.sql.gz new file mode 100644 index 0000000..9fa2e2e Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_060001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_060054.sql.gz b/backend/backups/kaopeilian_backup_20250924_060054.sql.gz new file mode 100644 index 0000000..7891958 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_060054.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_070001.sql.gz b/backend/backups/kaopeilian_backup_20250924_070001.sql.gz new file mode 100644 index 0000000..8feccdb Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_070001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_070451.sql.gz b/backend/backups/kaopeilian_backup_20250924_070451.sql.gz new file mode 100644 index 0000000..81d1aaa Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_070451.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_080001.sql.gz b/backend/backups/kaopeilian_backup_20250924_080001.sql.gz new file mode 100644 index 0000000..f21bf30 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_080001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_080155.sql.gz b/backend/backups/kaopeilian_backup_20250924_080155.sql.gz new file mode 100644 index 0000000..0ffd621 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_080155.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_090002.sql.gz b/backend/backups/kaopeilian_backup_20250924_090002.sql.gz new file mode 100644 index 0000000..5357646 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_090002.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_090129.sql.gz b/backend/backups/kaopeilian_backup_20250924_090129.sql.gz new file mode 100644 index 0000000..8e32c57 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_090129.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_100001.sql.gz b/backend/backups/kaopeilian_backup_20250924_100001.sql.gz new file mode 100644 index 0000000..d06a2b9 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_100001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_100028.sql.gz b/backend/backups/kaopeilian_backup_20250924_100028.sql.gz new file mode 100644 index 0000000..8958c4e Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_100028.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_110001.sql.gz b/backend/backups/kaopeilian_backup_20250924_110001.sql.gz new file mode 100644 index 0000000..8e5013e Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_110001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_110249.sql.gz b/backend/backups/kaopeilian_backup_20250924_110249.sql.gz new file mode 100644 index 0000000..a6dab4d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_110249.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_120001.sql.gz b/backend/backups/kaopeilian_backup_20250924_120001.sql.gz new file mode 100644 index 0000000..7d27564 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_120001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_120238.sql.gz b/backend/backups/kaopeilian_backup_20250924_120238.sql.gz new file mode 100644 index 0000000..d32f0f3 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_120238.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_130001.sql.gz b/backend/backups/kaopeilian_backup_20250924_130001.sql.gz new file mode 100644 index 0000000..ddbf99d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_130001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_130451.sql.gz b/backend/backups/kaopeilian_backup_20250924_130451.sql.gz new file mode 100644 index 0000000..fa61c1d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_130451.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_140001.sql.gz b/backend/backups/kaopeilian_backup_20250924_140001.sql.gz new file mode 100644 index 0000000..37f0346 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_140001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_140404.sql.gz b/backend/backups/kaopeilian_backup_20250924_140404.sql.gz new file mode 100644 index 0000000..b64680a Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_140404.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_150001.sql.gz b/backend/backups/kaopeilian_backup_20250924_150001.sql.gz new file mode 100644 index 0000000..ea4e00b Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_150001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_150347.sql.gz b/backend/backups/kaopeilian_backup_20250924_150347.sql.gz new file mode 100644 index 0000000..03500ce Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_150347.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_160001.sql.gz b/backend/backups/kaopeilian_backup_20250924_160001.sql.gz new file mode 100644 index 0000000..ad14040 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_160001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_160424.sql.gz b/backend/backups/kaopeilian_backup_20250924_160424.sql.gz new file mode 100644 index 0000000..2367e6c Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_160424.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_170001.sql.gz b/backend/backups/kaopeilian_backup_20250924_170001.sql.gz new file mode 100644 index 0000000..b21ee3d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_170001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_170155.sql.gz b/backend/backups/kaopeilian_backup_20250924_170155.sql.gz new file mode 100644 index 0000000..a820fbd Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_170155.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_180001.sql.gz b/backend/backups/kaopeilian_backup_20250924_180001.sql.gz new file mode 100644 index 0000000..e0b2842 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_180001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_180200.sql.gz b/backend/backups/kaopeilian_backup_20250924_180200.sql.gz new file mode 100644 index 0000000..a0d04c3 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_180200.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_190001.sql.gz b/backend/backups/kaopeilian_backup_20250924_190001.sql.gz new file mode 100644 index 0000000..0df67e8 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_190001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_190155.sql.gz b/backend/backups/kaopeilian_backup_20250924_190155.sql.gz new file mode 100644 index 0000000..ec8ff31 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_190155.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_200001.sql.gz b/backend/backups/kaopeilian_backup_20250924_200001.sql.gz new file mode 100644 index 0000000..b5d5f4e Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_200001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_200050.sql.gz b/backend/backups/kaopeilian_backup_20250924_200050.sql.gz new file mode 100644 index 0000000..e2a51ce Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_200050.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_210001.sql.gz b/backend/backups/kaopeilian_backup_20250924_210001.sql.gz new file mode 100644 index 0000000..85d648c Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_210001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_210100.sql.gz b/backend/backups/kaopeilian_backup_20250924_210100.sql.gz new file mode 100644 index 0000000..7784ffc Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_210100.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_220001.sql.gz b/backend/backups/kaopeilian_backup_20250924_220001.sql.gz new file mode 100644 index 0000000..70179b7 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_220001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_220035.sql.gz b/backend/backups/kaopeilian_backup_20250924_220035.sql.gz new file mode 100644 index 0000000..afd249d Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_220035.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_230001.sql.gz b/backend/backups/kaopeilian_backup_20250924_230001.sql.gz new file mode 100644 index 0000000..b2cce3c Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_230001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250924_230129.sql.gz b/backend/backups/kaopeilian_backup_20250924_230129.sql.gz new file mode 100644 index 0000000..e32508b Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250924_230129.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250925_000001.sql.gz b/backend/backups/kaopeilian_backup_20250925_000001.sql.gz new file mode 100644 index 0000000..bb7c593 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250925_000001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250925_000321.sql.gz b/backend/backups/kaopeilian_backup_20250925_000321.sql.gz new file mode 100644 index 0000000..6ff377a Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250925_000321.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250925_010001.sql.gz b/backend/backups/kaopeilian_backup_20250925_010001.sql.gz new file mode 100644 index 0000000..07d2cbf Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250925_010001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250925_010038.sql.gz b/backend/backups/kaopeilian_backup_20250925_010038.sql.gz new file mode 100644 index 0000000..75fed30 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250925_010038.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250925_020001.sql.gz b/backend/backups/kaopeilian_backup_20250925_020001.sql.gz new file mode 100644 index 0000000..ab0a205 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250925_020001.sql.gz differ diff --git a/backend/backups/kaopeilian_backup_20250925_020104.sql.gz b/backend/backups/kaopeilian_backup_20250925_020104.sql.gz new file mode 100644 index 0000000..e2a9130 Binary files /dev/null and b/backend/backups/kaopeilian_backup_20250925_020104.sql.gz differ diff --git a/backend/create_admin_users.py b/backend/create_admin_users.py new file mode 100644 index 0000000..bf25414 --- /dev/null +++ b/backend/create_admin_users.py @@ -0,0 +1,90 @@ +#!/usr/bin/env python3 +""" +创建管理员用户脚本 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +async def create_admin_users(): + """创建管理员用户""" + try: + from app.config.database import AsyncSessionLocal + from app.models.user import User + from app.core.security import get_password_hash + from sqlalchemy import select + + print("🔧 创建管理员用户...") + + # 管理员用户配置 + admin_users = [ + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "is_superuser": True, + "is_active": True + }, + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "admin", + "is_superuser": True, + "is_active": True + } + ] + + async with AsyncSessionLocal() as session: + for user_data in admin_users: + # 检查用户是否已存在 + result = await session.execute( + select(User).where(User.username == user_data["username"]) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + print(f"⚠️ 用户 '{user_data['username']}' 已存在,跳过创建") + continue + + # 创建新用户 + hashed_password = get_password_hash(user_data["password"]) + user = User( + username=user_data["username"], + email=user_data["email"], + hashed_password=hashed_password, + full_name=user_data["full_name"], + role=user_data["role"], + is_active=user_data["is_active"] + ) + + session.add(user) + await session.commit() + await session.refresh(user) + + print(f"✅ 管理员用户创建成功:") + print(f" 用户名: {user.username}") + print(f" 密码: {user_data['password']}") + print(f" 角色: {user.role}") + print(f" 邮箱: {user.email}") + print(" ---") + + print("\n🎉 管理员用户创建完成!") + print("\n📋 登录信息汇总:") + print("1. 超级管理员: superadmin / Superadmin123!") + print("2. 系统管理员: admin / Admin123!") + print("3. 测试学员: testuser / TestPass123!") + + except Exception as e: + print(f"❌ 创建管理员用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_admin_users()) diff --git a/backend/create_simple_users.py b/backend/create_simple_users.py new file mode 100644 index 0000000..88f28d5 --- /dev/null +++ b/backend/create_simple_users.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +为简化版API创建测试用户(使用SHA256加密) +""" +import mysql.connector +import hashlib +from datetime import datetime + +# 数据库配置 +DB_CONFIG = { + 'host': '127.0.0.1', + 'user': 'root', + 'password': '', + 'database': 'kaopeilian', + 'charset': 'utf8mb4', + 'autocommit': True +} + +# 要创建的系统账户 +SYSTEM_USERS = [ + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "admin", + "is_superuser": 1, + }, + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "is_superuser": 0, + }, + { + "username": "testuser", + "password": "TestPass123!", + "email": "testuser@kaopeilian.com", + "full_name": "测试学员", + "role": "trainee", + "is_superuser": 0, + }, +] + +def hash_password(password: str) -> str: + """使用SHA256加密密码(与simple_main.py保持一致)""" + return hashlib.sha256(password.encode()).hexdigest() + +def create_simple_users(): + """创建测试用户""" + try: + conn = mysql.connector.connect(**DB_CONFIG) + cursor = conn.cursor() + + print("🔧 开始创建系统测试账户(SHA256版本)...\n") + + created_count = 0 + updated_count = 0 + + for user_data in SYSTEM_USERS: + username = user_data["username"] + password_hash = hash_password(user_data["password"]) + + # 检查用户是否存在 + cursor.execute("SELECT id FROM users WHERE username = %s", (username,)) + existing = cursor.fetchone() + + if existing: + # 更新现有用户 + cursor.execute(""" + UPDATE users + SET hashed_password = %s, + email = %s, + full_name = %s, + role = %s, + is_active = 1, + updated_at = NOW() + WHERE username = %s + """, ( + password_hash, + user_data["email"], + user_data["full_name"], + user_data["role"], + username + )) + updated_count += 1 + print(f"✓ 更新用户: {username} ({user_data['full_name']})") + else: + # 创建新用户 + cursor.execute(""" + INSERT INTO users ( + username, email, hashed_password, role, + is_active, full_name, + created_at, updated_at + ) + VALUES (%s, %s, %s, %s, 1, %s, NOW(), NOW()) + """, ( + username, + user_data["email"], + password_hash, + user_data["role"], + user_data["full_name"] + )) + created_count += 1 + print(f"✓ 创建用户: {username} ({user_data['full_name']})") + + conn.commit() + + # 打印总结 + print("\n" + "="*50) + print("✅ 系统用户创建/更新完成!") + print(f"新创建用户数: {created_count}") + print(f"更新用户数: {updated_count}") + print("\n系统账户信息:") + print("-"*50) + print("| 角色 | 用户名 | 密码 | 权限说明 |") + print("| ---------- | ---------- | -------------- | ---------------------------- |") + print("| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 |") + print("| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) |") + print("| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 |") + print("-"*50) + + except mysql.connector.Error as e: + print(f"❌ 数据库操作失败: {e}") + finally: + if 'cursor' in locals(): + cursor.close() + if 'conn' in locals(): + conn.close() + +if __name__ == "__main__": + create_simple_users() diff --git a/backend/create_system_accounts.py b/backend/create_system_accounts.py new file mode 100644 index 0000000..4678199 --- /dev/null +++ b/backend/create_system_accounts.py @@ -0,0 +1,146 @@ +#!/usr/bin/env python3 +""" +创建系统测试账户脚本 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 加载本地配置 +try: + import local_config + print("✅ 本地配置已加载") +except ImportError: + print("⚠️ 未找到local_config.py") + +# 要创建的系统账户 +SYSTEM_USERS = [ + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "admin", # 使用现有的admin角色 + "is_superuser": 1, + }, + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "is_superuser": 0, + }, + { + "username": "testuser", + "password": "TestPass123!", + "email": "testuser@kaopeilian.com", + "full_name": "测试学员", + "role": "trainee", # 学员角色 + "is_superuser": 0, + }, +] + +async def create_system_accounts(): + """创建系统测试账户""" + try: + from app.core.database import AsyncSessionLocal + from app.core.security import get_password_hash + from sqlalchemy import text + + print("🔧 开始创建系统测试账户...\n") + + async with AsyncSessionLocal() as session: + created_count = 0 + updated_count = 0 + + for user_data in SYSTEM_USERS: + username = user_data["username"] + password = user_data["password"] + hashed_password = get_password_hash(password) + + # 检查用户是否存在 + result = await session.execute( + text("SELECT id FROM users WHERE username = :username"), + {"username": username} + ) + existing = result.fetchone() + + if existing: + # 更新现有用户 + await session.execute( + text(""" + UPDATE users + SET password_hash = :password_hash, + email = :email, + full_name = :full_name, + role = :role, + is_superuser = :is_superuser, + is_active = 1, + updated_at = NOW() + WHERE username = :username + """), + { + "username": username, + "password_hash": hashed_password, + "email": user_data["email"], + "full_name": user_data["full_name"], + "role": user_data["role"], + "is_superuser": user_data["is_superuser"] + } + ) + await session.commit() + updated_count += 1 + print(f"✓ 更新用户: {username} ({user_data['full_name']})") + else: + # 创建新用户 + await session.execute( + text(""" + INSERT INTO users ( + username, email, password_hash, role, + is_active, full_name, is_superuser, + created_at, updated_at + ) + VALUES ( + :username, :email, :password_hash, :role, + 1, :full_name, :is_superuser, + NOW(), NOW() + ) + """), + { + "username": username, + "email": user_data["email"], + "password_hash": hashed_password, + "role": user_data["role"], + "full_name": user_data["full_name"], + "is_superuser": user_data["is_superuser"] + } + ) + await session.commit() + created_count += 1 + print(f"✓ 创建用户: {username} ({user_data['full_name']})") + + # 打印总结 + print("\n" + "="*50) + print("✅ 系统用户创建/更新完成!") + print(f"新创建用户数: {created_count}") + print(f"更新用户数: {updated_count}") + print("\n系统账户信息:") + print("-"*50) + print("| 角色 | 用户名 | 密码 | 权限说明 |") + print("| ---------- | ---------- | -------------- | ---------------------------- |") + print("| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 |") + print("| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) |") + print("| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 |") + print("-"*50) + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_system_accounts()) diff --git a/backend/create_system_users.py b/backend/create_system_users.py new file mode 100644 index 0000000..b933aa7 --- /dev/null +++ b/backend/create_system_users.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +""" +创建系统测试账户脚本 +""" + +import asyncio +import sys +from datetime import datetime +from pathlib import Path + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker + +from app.core.config import settings +from app.core.security import get_password_hash +from app.models.user import User +from app.models.base import Base + +# 要创建的系统账户 +SYSTEM_USERS = [ + { + "username": "superadmin", + "password": "Superadmin123!", + "email": "superadmin@kaopeilian.com", + "full_name": "超级管理员", + "role": "super_admin", + "phone": "13800000001", + "is_active": True, + "department": "系统管理部", + "position": "超级管理员", + }, + { + "username": "admin", + "password": "Admin123!", + "email": "admin@kaopeilian.com", + "full_name": "系统管理员", + "role": "admin", + "phone": "13800000002", + "is_active": True, + "department": "系统管理部", + "position": "系统管理员", + }, + { + "username": "testuser", + "password": "TestPass123!", + "email": "testuser@kaopeilian.com", + "full_name": "测试学员", + "role": "student", + "phone": "13800000004", + "is_active": True, + "department": "学员部", + "position": "学员", + }, +] + + +async def create_system_users(): + """创建系统测试账户""" + # 创建数据库引擎 + engine = create_async_engine(settings.DATABASE_URL, echo=False) + + # 创建会话 + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + async with async_session() as session: + created_users = [] + updated_users = [] + + for user_data in SYSTEM_USERS: + try: + # 检查用户是否已存在 + result = await session.execute( + select(User).where(User.username == user_data["username"]) + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + # 更新现有用户的密码和信息 + existing_user.hashed_password = get_password_hash(user_data["password"]) + existing_user.email = user_data["email"] + existing_user.full_name = user_data["full_name"] + existing_user.role = user_data["role"] + existing_user.phone = user_data["phone"] + existing_user.is_active = user_data["is_active"] + existing_user.department = user_data["department"] + existing_user.position = user_data["position"] + existing_user.is_deleted = False + existing_user.updated_at = datetime.utcnow() + + await session.commit() + updated_users.append(user_data["username"]) + print(f"✓ 更新用户: {user_data['username']} ({user_data['full_name']})") + else: + # 创建新用户 + new_user = User( + username=user_data["username"], + hashed_password=get_password_hash(user_data["password"]), + email=user_data["email"], + full_name=user_data["full_name"], + role=user_data["role"], + phone=user_data["phone"], + is_active=user_data["is_active"], + department=user_data["department"], + position=user_data["position"], + created_at=datetime.utcnow(), + updated_at=datetime.utcnow(), + ) + + session.add(new_user) + await session.commit() + created_users.append(user_data["username"]) + print(f"✓ 创建用户: {user_data['username']} ({user_data['full_name']})") + + except Exception as e: + print(f"✗ 处理用户 {user_data['username']} 时出错: {str(e)}") + await session.rollback() + + # 打印总结 + print("\n" + "="*50) + print("系统用户创建/更新完成!") + print(f"新创建用户数: {len(created_users)}") + print(f"更新用户数: {len(updated_users)}") + print("\n系统账户信息:") + print("-"*50) + print("| 角色 | 用户名 | 密码 | 权限说明 |") + print("| ---------- | ---------- | -------------- | ---------------------------- |") + print("| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 |") + print("| 系统管理员 | admin | Admin123! | 可管理除“系统管理”模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) |") + print("| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 |") + print("-"*50) + + await engine.dispose() + + +if __name__ == "__main__": + print("开始创建系统测试账户...") + asyncio.run(create_system_users()) diff --git a/backend/create_team_data.py b/backend/create_team_data.py new file mode 100644 index 0000000..101cc54 --- /dev/null +++ b/backend/create_team_data.py @@ -0,0 +1,287 @@ +#!/usr/bin/env python3 +""" +创建团队管理页面测试数据 +""" +import asyncio +import sys +from datetime import datetime, timedelta +from pathlib import Path +import random + +# 添加项目根目录到 Python 路径 +sys.path.insert(0, str(Path(__file__).parent)) + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.database import engine, AsyncSessionLocal +from app.models.user import User, Team, UserTeam +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 +from app.models.exam import Exam +from app.models.practice import PracticeSession, PracticeReport +from passlib.context import CryptContext + +pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto") + + +async def create_team_members(): + """创建团队成员数据""" + async with AsyncSessionLocal() as db: + try: + # 1. 检查或创建团队 + result = await db.execute(select(Team).where(Team.name == "轻医美销售团队")) + team = result.scalar_one_or_none() + + if not team: + team = Team( + name="轻医美销售团队", + code="SALES_TEAM_001", + description="负责轻医美产品销售的核心团队", + leader_id=1, # admin + created_at=datetime.now(), + updated_at=datetime.now() + ) + db.add(team) + await db.flush() + print(f"✅ 创建团队: {team.name}") + else: + print(f"ℹ️ 团队已存在: {team.name}") + + # 2. 检查或创建岗位 + positions_data = [ + {"name": "销售专员", "code": "POS_SALES_001", "description": "负责客户接待和产品销售"}, + {"name": "销售主管", "code": "POS_MANAGER_001", "description": "负责团队管理和业绩指导"}, + {"name": "高级顾问", "code": "POS_CONSULTANT_001", "description": "负责高端客户维护和方案设计"}, + ] + + positions = {} + for pos_data in positions_data: + result = await db.execute( + select(Position).where(Position.name == pos_data["name"]) + ) + position = result.scalar_one_or_none() + + if not position: + position = Position( + name=pos_data["name"], + code=pos_data["code"], + description=pos_data["description"], + created_at=datetime.now(), + updated_at=datetime.now() + ) + db.add(position) + await db.flush() + print(f"✅ 创建岗位: {position.name}") + else: + print(f"ℹ️ 岗位已存在: {position.name}") + + positions[pos_data["name"]] = position + + # 3. 检查现有课程 + result = await db.execute(select(Course).limit(5)) + courses = result.scalars().all() + if not courses: + print("⚠️ 警告:没有课程数据,跳过岗位课程分配") + course_ids = [] + else: + course_ids = [c.id for c in courses] + print(f"ℹ️ 找到 {len(courses)} 门课程") + + # 4. 为岗位分配课程 + if course_ids: + for position in positions.values(): + # 每个岗位分配2-4门课程 + assigned_courses = random.sample(course_ids, min(random.randint(2, 4), len(course_ids))) + for course_id in assigned_courses: + result = await db.execute( + select(PositionCourse).where( + PositionCourse.position_id == position.id, + PositionCourse.course_id == course_id + ) + ) + if not result.scalar_one_or_none(): + pc = PositionCourse( + position_id=position.id, + course_id=course_id, + course_type="required" + ) + db.add(pc) + print(f"✅ 为岗位分配了课程") + + # 5. 创建团队成员 + members_data = [ + {"username": "zhangsan", "real_name": "张三", "email": "zhangsan@example.com", "position": "销售专员", "days_ago": 15}, + {"username": "lisi", "real_name": "李四", "email": "lisi@example.com", "position": "销售专员", "days_ago": 25}, + {"username": "wangwu", "real_name": "王五", "email": "wangwu@example.com", "position": "销售主管", "days_ago": 5}, + {"username": "zhaoliu", "real_name": "赵六", "email": "zhaoliu@example.com", "position": "高级顾问", "days_ago": 8}, + {"username": "sunqi", "real_name": "孙七", "email": "sunqi@example.com", "position": "销售专员", "days_ago": 60}, + {"username": "zhouba", "real_name": "周八", "email": "zhouba@example.com", "position": "销售主管", "days_ago": 3}, + {"username": "wujiu", "real_name": "吴九", "email": "wujiu@example.com", "position": "高级顾问", "days_ago": 12}, + {"username": "zhengshi", "real_name": "郑十", "email": "zhengshi@example.com", "position": "销售专员", "days_ago": 45}, + ] + + created_users = [] + for member_data in members_data: + # 检查用户是否存在 + result = await db.execute( + select(User).where(User.username == member_data["username"]) + ) + user = result.scalar_one_or_none() + + if not user: + user = User( + username=member_data["username"], + hashed_password=pwd_context.hash("Pass123!"), + email=member_data["email"], + full_name=member_data["real_name"], + role="trainee", + phone=f"138{random.randint(10000000, 99999999)}", + is_active=True, + last_login_at=datetime.now() - timedelta(days=member_data["days_ago"]), + created_at=datetime.now() - timedelta(days=member_data["days_ago"] + 30), + updated_at=datetime.now() + ) + db.add(user) + await db.flush() + print(f"✅ 创建用户: {user.full_name} ({user.username})") + else: + print(f"ℹ️ 用户已存在: {user.full_name} ({user.username})") + + created_users.append((user, member_data)) + + # 添加到团队 + result = await db.execute( + select(UserTeam).where( + UserTeam.user_id == user.id, + UserTeam.team_id == team.id + ) + ) + if not result.scalar_one_or_none(): + user_team = UserTeam( + user_id=user.id, + team_id=team.id, + joined_at=datetime.now() - timedelta(days=member_data["days_ago"]) + ) + db.add(user_team) + + # 分配岗位 + position = positions[member_data["position"]] + result = await db.execute( + select(PositionMember).where( + PositionMember.user_id == user.id, + PositionMember.position_id == position.id + ) + ) + if not result.scalar_one_or_none(): + position_member = PositionMember( + position_id=position.id, + user_id=user.id, + joined_at=datetime.now() - timedelta(days=member_data["days_ago"]) + ) + db.add(position_member) + + print(f"✅ 创建了 {len(created_users)} 个团队成员") + + # 6. 为成员创建考试记录 + if course_ids: + for user, member_data in created_users: + # 每个用户完成1-3门课程的考试 + exam_count = random.randint(1, min(3, len(course_ids))) + exam_courses = random.sample(course_ids, exam_count) + + for course_id in exam_courses: + # 创建1-2次考试记录 + for _ in range(random.randint(1, 2)): + days_ago = random.randint(1, member_data["days_ago"]) + score = random.randint(60, 98) + + exam = Exam( + user_id=user.id, + course_id=course_id, + status="completed", + round1_score=score, + round2_score=score + random.randint(0, 5) if score < 90 else None, + round3_score=None, + duration_minutes=random.randint(10, 30), + start_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)), + end_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)) + ) + db.add(exam) + + print(f"✅ 为成员创建了考试记录") + + # 7. 为成员创建陪练记录 + for user, member_data in created_users: + # 活跃用户(最近30天登录)创建陪练记录 + if member_data["days_ago"] <= 30: + practice_count = random.randint(2, 5) + for _ in range(practice_count): + days_ago = random.randint(1, min(member_data["days_ago"], 25)) + duration = random.randint(300, 1800) # 5-30分钟 + + session = PracticeSession( + session_id=f"PS{user.id}{random.randint(1000, 9999)}", + user_id=user.id, + scene_id=random.randint(1, 5), # 假设有5个场景 + status="completed", + duration_seconds=duration, + turns=random.randint(8, 20), + start_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)), + end_time=datetime.now() - timedelta(days=days_ago, hours=random.randint(0, 8)) + ) + db.add(session) + await db.flush() + + # 创建陪练报告 + total_score = random.randint(75, 95) + report = PracticeReport( + session_id=session.session_id, + total_score=total_score, + score_breakdown={ + "communication": random.randint(70, 95), + "product_knowledge": random.randint(70, 95), + "sales_skill": random.randint(70, 95), + "service_attitude": random.randint(75, 98), + "problem_handling": random.randint(70, 92) + }, + ability_dimensions={ + "沟通表达": random.randint(75, 95), + "产品知识": random.randint(70, 92), + "销售技巧": random.randint(72, 90), + "服务态度": random.randint(80, 98), + "应变能力": random.randint(70, 88), + "专业素养": random.randint(75, 92) + }, + suggestions=["态度积极", "表达清晰", "建议加强产品知识学习"] + ) + db.add(report) + + print(f"✅ 为活跃成员创建了陪练记录") + + await db.commit() + print("\n🎉 所有测试数据创建完成!") + print(f"\n团队:{team.name}") + print(f"成员数:{len(created_users)}") + print(f"岗位数:{len(positions)}") + print("\n测试账号信息:") + print("=" * 50) + for user, _ in created_users[:3]: # 显示前3个 + print(f"用户名: {user.username}") + print(f"密码: Pass123!") + print(f"姓名: {user.full_name}") + print("-" * 50) + + except Exception as e: + await db.rollback() + print(f"\n❌ 错误: {e}") + import traceback + traceback.print_exc() + raise + + +if __name__ == "__main__": + print("开始创建团队管理测试数据...\n") + asyncio.run(create_team_members()) + diff --git a/backend/create_test_user.py b/backend/create_test_user.py new file mode 100644 index 0000000..7042b85 --- /dev/null +++ b/backend/create_test_user.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +""" +创建测试用户 +""" +import asyncio +import os +import sys +from pathlib import Path + +# 设置环境变量 +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:root@localhost:3306/kaopeilian" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def create_test_user(): + """创建测试用户""" + try: + from app.config.database import AsyncSessionLocal + from app.models.user import User + from app.core.security import create_password_hash + from sqlalchemy import select + + print("🔍 创建测试用户...") + + async with AsyncSessionLocal() as session: + # 检查用户是否已存在 + result = await session.execute( + select(User).where(User.username == "testuser") + ) + existing_user = result.scalar_one_or_none() + + if existing_user: + print("⚠️ 用户 'testuser' 已存在") + return + + # 创建新用户 + hashed_password = create_password_hash("TestPass123!") + user = User( + username="testuser", + email="test@example.com", + password_hash=hashed_password, + full_name="Test User", + is_active=True, + role="trainee" + ) + + session.add(user) + await session.commit() + await session.refresh(user) + + print(f"✅ 测试用户创建成功: {user.username} (ID: {user.id})") + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_test_user()) diff --git a/backend/create_test_user_exam.py b/backend/create_test_user_exam.py new file mode 100644 index 0000000..d45e5ab --- /dev/null +++ b/backend/create_test_user_exam.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +""" +创建测试用户以便测试exam模块 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +from app.core.database import AsyncSessionLocal, engine +from app.models.base import Base +from app.models.user import User +from app.core.security import get_password_hash + + +async def create_test_user(): + """创建测试用户""" + async with AsyncSessionLocal() as db: + try: + # 创建一个测试用户 + test_user = User( + username="test_exam", + email="test_exam@example.com", + hashed_password=get_password_hash("test123"), + full_name="Test Exam User", + role="trainee", + is_active=True, + is_verified=True + ) + + db.add(test_user) + await db.commit() + await db.refresh(test_user) + + print(f"✅ 测试用户创建成功:") + print(f" 用户名:{test_user.username}") + print(f" 邮箱:{test_user.email}") + print(f" 密码:test123") + print(f" ID:{test_user.id}") + + except Exception as e: + print(f"❌ 创建测试用户失败:{e}") + await db.rollback() + + +if __name__ == "__main__": + asyncio.run(create_test_user()) diff --git a/backend/create_user_simple.py b/backend/create_user_simple.py new file mode 100644 index 0000000..53e047c --- /dev/null +++ b/backend/create_user_simple.py @@ -0,0 +1,114 @@ +#!/usr/bin/env python3 +""" +简单创建用户脚本 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 加载本地配置 +try: + import local_config + print("✅ 本地配置已加载") +except ImportError: + print("⚠️ 未找到local_config.py") + +async def create_user_simple(): + """简单创建用户""" + try: + from app.core.database import AsyncSessionLocal + from app.core.security import get_password_hash + from sqlalchemy import text + + print("🔧 创建测试用户...") + + async with AsyncSessionLocal() as session: + # 创建一个测试用户 + test_username = "test_dify" + test_password = "password123" + hashed_password = get_password_hash(test_password) + + # 检查用户是否存在 + result = await session.execute( + text("SELECT id FROM users WHERE username = :username"), + {"username": test_username} + ) + existing = result.fetchone() + + if existing: + print(f"⚠️ 用户 '{test_username}' 已存在") + else: + # 插入新用户 + await session.execute( + text(""" + INSERT INTO users (username, email, password_hash, role, is_active, full_name, created_at, updated_at) + VALUES (:username, :email, :password_hash, :role, :is_active, :full_name, NOW(), NOW()) + """), + { + "username": test_username, + "email": "test_dify@example.com", + "password_hash": hashed_password, + "role": "trainee", + "is_active": 1, + "full_name": "Test Dify User" + } + ) + await session.commit() + + print(f"\n✅ 用户创建成功:") + print(f" 用户名: {test_username}") + print(f" 密码: {test_password}") + print(f" 角色: trainee") + print(f" 邮箱: test_dify@example.com") + + # 创建管理员用户 + admin_username = "admin" + admin_password = "admin123" + admin_hashed_password = get_password_hash(admin_password) + + # 检查管理员是否存在 + result = await session.execute( + text("SELECT id FROM users WHERE username = :username"), + {"username": admin_username} + ) + existing = result.fetchone() + + if existing: + print(f"\n⚠️ 用户 '{admin_username}' 已存在") + else: + # 插入管理员 + await session.execute( + text(""" + INSERT INTO users (username, email, password_hash, role, is_active, full_name, is_superuser, created_at, updated_at) + VALUES (:username, :email, :password_hash, :role, :is_active, :full_name, :is_superuser, NOW(), NOW()) + """), + { + "username": admin_username, + "email": "admin@kaopeilian.com", + "password_hash": admin_hashed_password, + "role": "admin", + "is_active": 1, + "full_name": "系统管理员", + "is_superuser": 1 + } + ) + await session.commit() + + print(f"\n✅ 管理员创建成功:") + print(f" 用户名: {admin_username}") + print(f" 密码: {admin_password}") + print(f" 角色: admin") + print(f" 邮箱: admin@kaopeilian.com") + + print("\n✅ 用户创建完成!") + + except Exception as e: + print(f"❌ 创建用户失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(create_user_simple()) diff --git a/backend/database_schema_unified.md b/backend/database_schema_unified.md new file mode 100644 index 0000000..7ecfbf3 --- /dev/null +++ b/backend/database_schema_unified.md @@ -0,0 +1,389 @@ +# 考培练系统统一数据库架构设计 + +## 数据库基本信息 +- 数据库名称:kaopeilian +- 字符集:utf8mb4 +- 排序规则:utf8mb4_unicode_ci +- 存储引擎:InnoDB + +## 一、用户管理模块 + +### 1.1 用户表 (users) +```sql +CREATE TABLE `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `bio` TEXT COMMENT '个人简介', + `role` VARCHAR(20) DEFAULT 'trainee' COMMENT '系统角色: admin, manager, trainee', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `is_verified` BOOLEAN DEFAULT FALSE COMMENT '是否验证', + `last_login_at` DATETIME COMMENT '最后登录时间', + `password_changed_at` DATETIME COMMENT '密码修改时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; +``` + +### 1.2 团队表 (teams) +```sql +CREATE TABLE `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '团队名称', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '团队代码', + `description` TEXT COMMENT '团队描述', + `team_type` VARCHAR(50) DEFAULT 'department' COMMENT '团队类型: department, project, study_group', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `leader_id` INT COMMENT '负责人ID', + `parent_id` INT COMMENT '父团队ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队表'; +``` + +### 1.3 用户团队关联表 (user_teams) +```sql +CREATE TABLE `user_teams` ( + `user_id` INT NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member' COMMENT '团队角色: member, leader', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; +``` + +## 二、课程管理模块 + +### 2.1 课程表 (courses) +```sql +CREATE TABLE `courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; +``` + +### 2.2 课程资料表 (course_materials) +```sql +CREATE TABLE `course_materials` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; +``` + +### 2.3 知识点表 (knowledge_points) +```sql +CREATE TABLE `knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `parent_id` INT COMMENT '父知识点ID', + `level` INT DEFAULT 1 COMMENT '层级深度', + `path` VARCHAR(500) COMMENT '路径(如: 1.2.3)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `weight` FLOAT DEFAULT 1.0 COMMENT '权重', + `is_required` BOOLEAN DEFAULT TRUE COMMENT '是否必修', + `estimated_hours` FLOAT COMMENT '预计学习时间(小时)', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_parent_id (parent_id), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; +``` + +### 2.4 成长路径表 (growth_paths) +```sql +CREATE TABLE `growth_paths` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; +``` + +## 三、考试模块 + +### 3.1 题目表 (questions) +```sql +CREATE TABLE `questions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT COMMENT '题目内容', + `options` JSON COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_question_type (question_type), + INDEX idx_difficulty (difficulty), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; +``` + +### 3.2 考试记录表 (exams) +```sql +CREATE TABLE `exams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT COMMENT '得分', + `is_passed` BOOLEAN COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON COMMENT '题目数据(JSON格式)', + `answers` JSON COMMENT '答案数据(JSON格式)', + `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 (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_course_id (course_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; +``` + +### 3.3 考试结果详情表 (exam_results) +```sql +CREATE TABLE `exam_results` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `exam_id` INT NOT NULL COMMENT '考试ID', + `question_id` INT NOT NULL COMMENT '题目ID', + `user_answer` TEXT COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT COMMENT '答题时长(秒)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE, + INDEX idx_exam_id (exam_id), + INDEX idx_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果详情表'; +``` + +## 四、陪练模块 + +### 4.1 陪练场景表 (training_scenes) +```sql +CREATE TABLE `training_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `category` VARCHAR(50) NOT NULL COMMENT '场景分类', + `ai_config` JSON COMMENT 'AI配置(如Coze Bot ID等)', + `prompt_template` TEXT COMMENT '提示词模板', + `evaluation_criteria` JSON COMMENT '评估标准', + `status` ENUM('DRAFT', 'ACTIVE', 'INACTIVE') DEFAULT 'DRAFT' COMMENT '场景状态', + `is_public` BOOLEAN DEFAULT TRUE COMMENT '是否公开', + `required_level` INT COMMENT '所需用户等级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_public (is_public), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; +``` + +### 4.2 陪练会话表 (training_sessions) +```sql +CREATE TABLE `training_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `scene_id` INT NOT NULL COMMENT '场景ID', + `coze_conversation_id` VARCHAR(100) COMMENT 'Coze会话ID', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT COMMENT '持续时长(秒)', + `status` ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') DEFAULT 'CREATED' COMMENT '会话状态', + `session_config` JSON COMMENT '会话配置', + `total_score` FLOAT COMMENT '总分', + `evaluation_result` JSON COMMENT '评估结果详情', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `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 (scene_id) REFERENCES training_scenes(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_scene_id (scene_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; +``` + +### 4.3 陪练消息表 (training_messages) +```sql +CREATE TABLE `training_messages` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL COMMENT '会话ID', + `role` ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + `type` ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + `content` TEXT NOT NULL COMMENT '消息内容', + `voice_url` VARCHAR(500) COMMENT '语音文件URL', + `voice_duration` FLOAT COMMENT '语音时长(秒)', + `message_metadata` JSON COMMENT '消息元数据', + `coze_message_id` VARCHAR(100) COMMENT 'Coze消息ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_session_id (session_id), + INDEX idx_role (role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; +``` + +### 4.4 陪练报告表 (training_reports) +```sql +CREATE TABLE `training_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL UNIQUE COMMENT '会话ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `overall_score` FLOAT NOT NULL COMMENT '总体得分', + `dimension_scores` JSON NOT NULL COMMENT '各维度得分', + `strengths` JSON NOT NULL COMMENT '优势点', + `weaknesses` JSON NOT NULL COMMENT '待改进点', + `suggestions` JSON NOT NULL COMMENT '改进建议', + `detailed_analysis` TEXT COMMENT '详细分析', + `transcript` TEXT COMMENT '对话文本记录', + `statistics` JSON COMMENT '统计数据', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; +``` + +## 五、数据库设计原则 + +### 5.1 主键规范 +- 所有表使用INT作为主键,满足当前规模与 ORM 定义 +- 所有主键均设置为AUTO_INCREMENT + +### 5.2 外键约束 +- 所有外键关系都明确定义 +- 删除策略: + - CASCADE:级联删除(用于强关联关系) + - SET NULL:置空(用于弱关联关系) + +### 5.3 索引策略 +- 所有外键字段自动创建索引 +- 常用查询字段创建索引(如status, type等) +- 唯一约束字段自动创建唯一索引 + +### 5.4 字段命名规范 +- 使用下划线命名法(snake_case) +- 布尔字段使用is_前缀 +- 时间字段使用_at后缀 +- JSON字段明确标注数据结构 + +### 5.5 软删除设计 +- 使用is_deleted和deleted_at字段实现软删除 +- 保留数据完整性,便于数据恢复 + +### 5.6 审计字段 +- created_at:创建时间 +- updated_at:更新时间 +- created_by:创建人ID +- updated_by:更新人ID + +## 六、性能优化建议 + +1. **分表策略** + - training_messages表可能增长较快,考虑按月分表 + - exam_results表可考虑按年分表 + +2. **缓存策略** + - 用户信息使用Redis缓存 + - 课程列表使用Redis缓存 + - 热门题目使用Redis缓存 + +3. **查询优化** + - 使用分页查询避免大量数据加载 + - 合理使用JOIN,避免N+1查询 + - 统计类查询考虑使用物化视图 diff --git a/backend/debug_api.py b/backend/debug_api.py new file mode 100644 index 0000000..3f0a67c --- /dev/null +++ b/backend/debug_api.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +""" +调试API问题 +""" +import asyncio +import os +import sys +from pathlib import Path + +# 设置环境变量 +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:root@localhost:3306/kaopeilian" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def debug_login(): + """调试登录问题""" + try: + # 导入所有必要的模块 + from app.config.database import AsyncSessionLocal + from app.services.auth_service import AuthService + from app.schemas.auth import UserLogin, Token + from app.schemas.base import ResponseModel + from app.core.exceptions import BaseError + from app.core.logger import logger + from fastapi import HTTPException, status + + print("🔍 开始调试登录流程...") + + # 创建登录数据 + login_data = UserLogin(username="testuser", password="TestPass123!") + print(f"📝 登录数据: {login_data}") + + async with AsyncSessionLocal() as db: + try: + print("🔐 开始认证...") + auth_service = AuthService(db) + + # 验证用户 + user = await auth_service.authenticate_user( + username=login_data.username, + password=login_data.password + ) + print(f"✅ 用户认证成功: {user.username}") + + # 创建tokens + tokens = await auth_service.create_tokens_for_user(user) + print(f"✅ Token创建成功") + + # 创建响应 + response = ResponseModel(data=tokens) + print(f"✅ 响应创建成功: {response}") + + return response + + except BaseError as e: + print(f"❌ 业务错误: {e}") + raise HTTPException( + status_code=e.code, + detail={ + "message": e.message, + "error_code": e.error_code + } + ) + except Exception as e: + print(f"❌ 系统错误: {e}") + import traceback + traceback.print_exc() + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail={"message": "登录失败,请稍后重试"} + ) + + except Exception as e: + print(f"❌ 调试失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(debug_login()) diff --git a/backend/debug_update_api.py b/backend/debug_update_api.py new file mode 100644 index 0000000..b7693fd --- /dev/null +++ b/backend/debug_update_api.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python +""" +调试用户更新API +""" + +import asyncio +import sys +from pathlib import Path +from datetime import datetime + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from sqlalchemy.ext.asyncio import AsyncSession +from app.core.deps import get_db +from app.services.user_service import UserService +from app.schemas.user import UserUpdate +from app.models.user import User +from app.core.logger import logger + +async def debug_update(): + """调试更新流程""" + + # 获取数据库会话 + async for db in get_db(): + try: + # 1. 创建服务实例 + user_service = UserService(db) + + # 2. 获取superadmin用户 + print("1. 获取superadmin用户...") + user = await user_service.get_by_username("superadmin") + if not user: + print("用户不存在") + return + + print(f"用户ID: {user.id}") + print(f"用户名: {user.username}") + print(f"当前学校: {user.school}") + print(f"当前专业: {user.major}") + + # 3. 创建更新数据 + print("\n2. 创建更新数据...") + update_data = UserUpdate( + school="北京大学", + major="软件工程", + bio="测试更新" + ) + + print(f"更新数据: {update_data.model_dump(exclude_unset=True)}") + + # 4. 执行更新 + print("\n3. 执行更新...") + updated_user = await user_service.update_user( + user_id=user.id, + obj_in=update_data, + updated_by=user.id + ) + + # 5. 检查更新结果 + print("\n4. 更新后的数据:") + print(f"用户ID: {updated_user.id}") + print(f"用户名: {updated_user.username}") + print(f"学校: {updated_user.school}") + print(f"专业: {updated_user.major}") + print(f"个人简介: {updated_user.bio}") + + # 6. 再次查询验证 + print("\n5. 再次查询验证...") + verified_user = await user_service.get_by_id(user.id) + print(f"验证 - 学校: {verified_user.school}") + print(f"验证 - 专业: {verified_user.major}") + + # 7. 检查数据库中的实际值 + print("\n6. 检查数据库实际值...") + from sqlalchemy import text + result = await db.execute( + text("SELECT school, major FROM users WHERE id = :user_id"), + {"user_id": user.id} + ) + row = result.fetchone() + if row: + print(f"数据库 - 学校: {row[0]}") + print(f"数据库 - 专业: {row[1]}") + + except Exception as e: + print(f"错误: {e}") + import traceback + traceback.print_exc() + finally: + await db.close() + break + +if __name__ == "__main__": + print("调试用户更新API...") + asyncio.run(debug_update()) diff --git a/backend/debug_user.py b/backend/debug_user.py new file mode 100644 index 0000000..487712c --- /dev/null +++ b/backend/debug_user.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +调试用户查询 +""" +import asyncio +import sys +import os + +# 添加项目根目录到Python路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 加载本地配置 +try: + import local_config + print("✅ 本地配置已加载") +except ImportError: + print("⚠️ 未找到local_config.py") + +async def debug_user(): + """调试用户查询""" + try: + from app.core.database import AsyncSessionLocal + from app.services.user_service import UserService + from app.core.security import verify_password + from sqlalchemy import text + + print("🔧 调试用户查询...") + + async with AsyncSessionLocal() as session: + # 直接查询用户 + result = await session.execute( + text("SELECT id, username, email, password_hash, role, is_active FROM users WHERE username = :username"), + {"username": "test_dify"} + ) + user_data = result.fetchone() + + if user_data: + print(f"\n✅ 找到用户:") + print(f" ID: {user_data[0]}") + print(f" 用户名: {user_data[1]}") + print(f" 邮箱: {user_data[2]}") + print(f" 密码哈希: {user_data[3]}") + print(f" 角色: {user_data[4]}") + print(f" 激活状态: {user_data[5]}") + + # 验证密码 + password_ok = verify_password("password123", user_data[3]) + print(f"\n密码验证结果: {password_ok}") + else: + print("\n❌ 未找到用户 test_dify") + + # 使用UserService查询 + user_service = UserService(session) + user = await user_service.get_by_username("test_dify") + if user: + print(f"\n✅ UserService找到用户: {user.username}") + print(f" Model字段: hashed_password = {user.hashed_password}") + else: + print("\n❌ UserService未找到用户") + + # 测试认证 + auth_user = await user_service.authenticate(username="test_dify", password="password123") + if auth_user: + print(f"\n✅ 认证成功: {auth_user.username}") + else: + print("\n❌ 认证失败") + + print("\n✅ 调试完成!") + + except Exception as e: + print(f"❌ 调试失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(debug_user()) diff --git a/backend/deploy/quick_deploy.sh b/backend/deploy/quick_deploy.sh new file mode 100644 index 0000000..ba7593e --- /dev/null +++ b/backend/deploy/quick_deploy.sh @@ -0,0 +1,223 @@ +#!/bin/bash +# 快速部署脚本 - 考陪练 SQL 执行器 +# 使用方法: bash quick_deploy.sh + +set -e # 遇到错误立即退出 + +echo "===================================" +echo "考陪练 SQL 执行器快速部署脚本" +echo "服务器: 120.79.247.16" +echo "===================================" + +# 配置变量 +APP_DIR="/opt/kaopeilian/backend" +SERVICE_NAME="kaopeilian-backend" +PYTHON_VERSION="python3" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 打印带颜色的信息 +print_info() { + echo -e "${GREEN}[INFO]${NC} $1" +} + +print_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +print_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +# 检查是否为 root 用户 +if [[ $EUID -ne 0 ]]; then + print_error "此脚本必须以 root 用户运行" + exit 1 +fi + +# 1. 安装系统依赖 +print_info "安装系统依赖..." +apt update +apt install -y python3 python3-pip python3-venv git mysql-client + +# 2. 创建应用目录 +print_info "创建应用目录..." +mkdir -p $APP_DIR + +# 3. 检查代码是否存在 +if [ ! -f "$APP_DIR/app/main.py" ]; then + print_warning "代码不存在,请先上传代码到 $APP_DIR" + print_info "可以使用以下命令上传:" + echo "scp -r /path/to/kaopeilian-backend/* root@120.79.247.16:$APP_DIR/" + exit 1 +fi + +cd $APP_DIR + +# 4. 创建虚拟环境 +print_info "创建 Python 虚拟环境..." +if [ ! -d "venv" ]; then + $PYTHON_VERSION -m venv venv +fi + +# 5. 激活虚拟环境并安装依赖 +print_info "安装 Python 依赖..." +source venv/bin/activate +pip install --upgrade pip +pip install -r requirements.txt + +# 6. 创建环境配置文件 +if [ ! -f ".env" ]; then + print_info "创建环境配置文件..." + cat > .env << EOF +# 应用配置 +APP_NAME=KaoPeiLian +DEBUG=False +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025!@#@localhost:3306/kaopeilian?charset=utf8mb4 + +# Redis配置 +REDIS_URL=redis://localhost:6379/0 + +# JWT配置 +SECRET_KEY=production-secret-key-for-kaopeilian-2025-at-least-32-chars +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# CORS配置 +CORS_ORIGINS=["http://120.79.247.16:8000", "http://aiedu.ireborn.com.cn", "http://localhost:3000"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json +EOF + print_info "环境配置文件已创建" +else + print_warning "环境配置文件已存在,跳过创建" +fi + +# 7. 创建 systemd 服务 +print_info "配置 systemd 服务..." +cat > /etc/systemd/system/${SERVICE_NAME}.service << EOF +[Unit] +Description=KaoPeiLian Backend API +After=network.target mysql.service + +[Service] +Type=simple +User=root +WorkingDirectory=$APP_DIR +Environment="PATH=$APP_DIR/venv/bin" +Environment="PYTHONPATH=$APP_DIR" +ExecStart=$APP_DIR/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 --workers 2 +Restart=always +RestartSec=10 +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target +EOF + +# 8. 重新加载 systemd 并启动服务 +print_info "启动服务..." +systemctl daemon-reload +systemctl enable ${SERVICE_NAME} + +# 检查服务是否已经运行 +if systemctl is-active --quiet ${SERVICE_NAME}; then + print_info "重启服务..." + systemctl restart ${SERVICE_NAME} +else + print_info "启动服务..." + systemctl start ${SERVICE_NAME} +fi + +# 9. 等待服务启动 +print_info "等待服务启动..." +sleep 5 + +# 10. 检查服务状态 +if systemctl is-active --quiet ${SERVICE_NAME}; then + print_info "服务启动成功!" +else + print_error "服务启动失败,请检查日志" + journalctl -u ${SERVICE_NAME} -n 50 + exit 1 +fi + +# 11. 配置防火墙 +print_info "配置防火墙..." +if command -v ufw &> /dev/null; then + ufw allow 8000/tcp + ufw --force enable +elif command -v firewall-cmd &> /dev/null; then + firewall-cmd --permanent --add-port=8000/tcp + firewall-cmd --reload +else + print_warning "未检测到防火墙,请手动配置端口 8000" +fi + +# 12. 测试部署 +print_info "测试部署..." +sleep 2 + +# 健康检查 +if curl -s http://localhost:8000/health > /dev/null; then + print_info "健康检查通过 ✓" +else + print_error "健康检查失败" +fi + +# 测试 SQL 执行器 +print_info "测试 SQL 执行器..." +RESPONSE=$(curl -s -X POST http://localhost:8000/api/v1/sql/execute-simple \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -H "Content-Type: application/json" \ + -d '{"sql": "SELECT COUNT(*) as total FROM users"}') + +if [[ $RESPONSE == *"SQL 执行成功"* ]]; then + print_info "SQL 执行器测试通过 ✓" + echo "响应: $RESPONSE" +else + print_error "SQL 执行器测试失败" + echo "响应: $RESPONSE" +fi + +# 13. 打印部署信息 +echo "" +echo "===================================" +echo -e "${GREEN}部署完成!${NC}" +echo "===================================" +echo "" +echo "服务信息:" +echo "- 服务状态: $(systemctl is-active ${SERVICE_NAME})" +echo "- API 地址: http://120.79.247.16:8000" +echo "- 健康检查: http://120.79.247.16:8000/health" +echo "- SQL 执行器: http://120.79.247.16:8000/api/v1/sql/execute-simple" +echo "" +echo "认证信息:" +echo "- API Key: dify-2025-kaopeilian" +echo "- 请求头: X-API-Key" +echo "" +echo "常用命令:" +echo "- 查看日志: journalctl -u ${SERVICE_NAME} -f" +echo "- 重启服务: systemctl restart ${SERVICE_NAME}" +echo "- 停止服务: systemctl stop ${SERVICE_NAME}" +echo "- 服务状态: systemctl status ${SERVICE_NAME}" +echo "" +echo "Dify 配置:" +echo "1. 鉴权类型: 请求头" +echo "2. 鉴权头部前缀: Custom" +echo "3. 键: X-API-Key" +echo "4. 值: dify-2025-kaopeilian" +echo "==================================="# + + diff --git a/backend/deploy/server_setup_guide.md b/backend/deploy/server_setup_guide.md new file mode 100644 index 0000000..e479017 --- /dev/null +++ b/backend/deploy/server_setup_guide.md @@ -0,0 +1,285 @@ +# 考陪练系统 SQL 执行器服务器部署指南 + +## 服务器信息 +- **IP地址**: 120.79.247.16 +- **域名**: aiedu.ireborn.com.cn +- **端口**: 8000 +- **数据库**: MySQL (已配置在同一服务器) + +## 部署步骤 + +### 1. 连接到服务器 + +```bash +ssh root@120.79.247.16 +``` + +### 2. 准备环境 + +```bash +# 更新系统包 +apt update && apt upgrade -y + +# 安装 Python 3.9+ +apt install python3 python3-pip python3-venv -y + +# 安装 MySQL 客户端(如果需要) +apt install mysql-client -y + +# 安装 Git +apt install git -y + +# 创建应用目录 +mkdir -p /opt/kaopeilian +cd /opt/kaopeilian +``` + +### 3. 克隆或上传代码 + +```bash +# 如果使用 Git +git clone backend +cd backend + +# 或者使用 scp 上传 +# 在本地执行: +# scp -r /Users/nongjun/Desktop/Ai公司/本地开发与测试/kaopeilian-backend root@120.79.247.16:/opt/kaopeilian/backend +``` + +### 4. 创建虚拟环境和安装依赖 + +```bash +cd /opt/kaopeilian/backend + +# 创建虚拟环境 +python3 -m venv venv + +# 激活虚拟环境 +source venv/bin/activate + +# 升级 pip +pip install --upgrade pip + +# 安装依赖 +pip install -r requirements.txt +``` + +### 5. 配置环境变量 + +创建 `.env` 文件: + +```bash +cat > .env << EOF +# 应用配置 +APP_NAME=KaoPeiLian +DEBUG=False +HOST=0.0.0.0 +PORT=8000 + +# 数据库配置 +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025!@#@localhost:3306/kaopeilian?charset=utf8mb4 + +# Redis配置(如果有) +REDIS_URL=redis://localhost:6379/0 + +# JWT配置 +SECRET_KEY=your-production-secret-key-at-least-32-chars +ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=30 + +# CORS配置 +CORS_ORIGINS=["http://120.79.247.16:8000", "http://aiedu.ireborn.com.cn", "http://localhost:3000"] + +# 日志配置 +LOG_LEVEL=INFO +LOG_FORMAT=json +EOF +``` + +### 6. 创建 systemd 服务 + +```bash +cat > /etc/systemd/system/kaopeilian-backend.service << EOF +[Unit] +Description=KaoPeiLian Backend API +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/opt/kaopeilian/backend +Environment="PATH=/opt/kaopeilian/backend/venv/bin" +ExecStart=/opt/kaopeilian/backend/venv/bin/uvicorn app.main:app --host 0.0.0.0 --port 8000 +Restart=always +RestartSec=10 + +[Install] +WantedBy=multi-user.target +EOF +``` + +### 7. 启动服务 + +```bash +# 重新加载 systemd +systemctl daemon-reload + +# 启用服务(开机自启) +systemctl enable kaopeilian-backend + +# 启动服务 +systemctl start kaopeilian-backend + +# 查看服务状态 +systemctl status kaopeilian-backend + +# 查看日志 +journalctl -u kaopeilian-backend -f +``` + +### 8. 配置防火墙 + +```bash +# 如果使用 ufw +ufw allow 8000/tcp +ufw reload + +# 如果使用 iptables +iptables -A INPUT -p tcp --dport 8000 -j ACCEPT +iptables-save > /etc/iptables/rules.v4 +``` + +### 9. 测试部署 + +```bash +# 健康检查 +curl http://localhost:8000/health + +# 测试 SQL 执行器(简化认证) +curl -X POST http://localhost:8000/api/v1/sql/execute-simple \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -H "Content-Type: application/json" \ + -d '{ + "sql": "SELECT COUNT(*) as total FROM users" + }' +``` + +## 更新部署 + +```bash +# 进入项目目录 +cd /opt/kaopeilian/backend + +# 拉取最新代码(如果使用 Git) +git pull + +# 或者重新上传文件 + +# 重启服务 +systemctl restart kaopeilian-backend +``` + +## 日志和监控 + +### 查看实时日志 +```bash +journalctl -u kaopeilian-backend -f +``` + +### 查看错误日志 +```bash +journalctl -u kaopeilian-backend -p err -n 100 +``` + +### 检查服务状态 +```bash +systemctl status kaopeilian-backend +``` + +## 故障排除 + +### 1. 服务无法启动 +```bash +# 检查 Python 路径 +which python3 + +# 检查虚拟环境 +ls -la /opt/kaopeilian/backend/venv/bin/ + +# 手动测试启动 +cd /opt/kaopeilian/backend +source venv/bin/activate +uvicorn app.main:app --host 0.0.0.0 --port 8000 +``` + +### 2. 数据库连接失败 +```bash +# 测试数据库连接 +mysql -h localhost -u root -p'Kaopeilian2025!@#' -e "SELECT 1" + +# 检查数据库服务 +systemctl status mysql +``` + +### 3. 端口被占用 +```bash +# 查看端口占用 +netstat -tlnp | grep 8000 + +# 或 +lsof -i :8000 +``` + +## 性能优化(可选) + +### 使用 Gunicorn + Uvicorn +```bash +# 安装 gunicorn +pip install gunicorn + +# 修改服务文件的 ExecStart +ExecStart=/opt/kaopeilian/backend/venv/bin/gunicorn app.main:app -w 4 -k uvicorn.workers.UvicornWorker --bind 0.0.0.0:8000 +``` + +### 使用 Nginx 反向代理 +```bash +# 安装 Nginx +apt install nginx -y + +# 配置 Nginx +cat > /etc/nginx/sites-available/kaopeilian << EOF +server { + listen 80; + server_name aiedu.ireborn.com.cn; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host \$host; + proxy_set_header X-Real-IP \$remote_addr; + proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; + } +} +EOF + +# 启用配置 +ln -s /etc/nginx/sites-available/kaopeilian /etc/nginx/sites-enabled/ +nginx -t +systemctl restart nginx +``` + +## 安全建议 + +1. **修改默认 API Key**:编辑 `/opt/kaopeilian/backend/app/core/simple_auth.py` +2. **使用 HTTPS**:配置 SSL 证书 +3. **限制 IP 访问**:只允许 Dify 服务器访问 +4. **定期备份**:数据库和代码 +5. **监控日志**:及时发现异常 + +## Dify 配置 + +部署完成后,在 Dify 中使用: +- **URL**: http://120.79.247.16:8000/api/v1/sql/execute-simple +- **认证方式**: 请求头 +- **X-API-Key**: dify-2025-kaopeilian + + diff --git a/backend/docker-compose.dev.yml b/backend/docker-compose.dev.yml new file mode 100644 index 0000000..7d3f8f8 --- /dev/null +++ b/backend/docker-compose.dev.yml @@ -0,0 +1,59 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0 + container_name: kaopeilian-mysql + environment: + MYSQL_ROOT_PASSWORD: root + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET: utf8mb4 + MYSQL_COLLATION: utf8mb4_unicode_ci + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./scripts/init.sql:/docker-entrypoint-initdb.d/init.sql + command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci + networks: + - kaopeilian-network + + redis: + image: redis:7-alpine + container_name: kaopeilian-redis + ports: + - "6379:6379" + volumes: + - redis_data:/data + networks: + - kaopeilian-network + + backend: + build: + context: . + dockerfile: Dockerfile.dev + container_name: kaopeilian-backend + depends_on: + - mysql + - redis + environment: + DATABASE_URL: mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 + REDIS_URL: redis://redis:6379/0 + ports: + - "8000:8000" + volumes: + - ./app:/app/app + - ./tests:/app/tests + - ./scripts:/app/scripts + - ./.env:/app/.env + command: uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + networks: + - kaopeilian-network + +volumes: + mysql_data: + redis_data: + +networks: + kaopeilian-network: + driver: bridge diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml new file mode 100644 index 0000000..33ffa72 --- /dev/null +++ b/backend/docker-compose.yml @@ -0,0 +1,46 @@ +version: '3.8' + +services: + mysql: + image: mysql:8.0.43 + container_name: kaopeilian_mysql + restart: always + environment: + MYSQL_ROOT_PASSWORD: Kaopeilian2025!@# + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3306:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql:/docker-entrypoint-initdb.d/init.sql + - ./mysql.cnf:/etc/mysql/conf.d/mysql.cnf + command: --default-authentication-plugin=mysql_native_password + networks: + - kaopeilian_network + + # 可选:phpMyAdmin管理界面 + phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + container_name: kaopeilian_phpmyadmin + restart: always + environment: + PMA_HOST: mysql + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: Kaopeilian2025!@# + ports: + - "8080:80" + depends_on: + - mysql + networks: + - kaopeilian_network + +volumes: + mysql_data: + driver: local + +networks: + kaopeilian_network: + driver: bridge \ No newline at end of file diff --git a/backend/docker/__init__.py b/backend/docker/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docker/mysql/conf.d/mysql-rollback.cnf b/backend/docker/mysql/conf.d/mysql-rollback.cnf new file mode 100644 index 0000000..e7c7de3 --- /dev/null +++ b/backend/docker/mysql/conf.d/mysql-rollback.cnf @@ -0,0 +1,46 @@ +# MySQL Binlog 回滚优化配置 +# 用于考培练系统的数据库回滚功能 + +[mysqld] +# Binlog 配置 - 确保回滚功能可用 +log-bin = mysql-bin +binlog_format = ROW +binlog_row_image = FULL +expire_logs_days = 7 +max_binlog_size = 100M + +# 事务配置 - 支持更好的回滚 +innodb_flush_log_at_trx_commit = 1 +sync_binlog = 1 + +# 字符集配置 +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# 性能优化 +innodb_buffer_pool_size = 256M +innodb_log_file_size = 64M +innodb_log_buffer_size = 16M + +# 连接配置 +max_connections = 200 +wait_timeout = 28800 +interactive_timeout = 28800 + +# 查询缓存(MySQL 8.0已移除,保留注释) +# query_cache_type = 1 +# query_cache_size = 32M + +# 慢查询日志 +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/slow.log +long_query_time = 2 + +# 错误日志 +log-error = /var/log/mysql/error.log + +# 二进制日志 +log-bin-trust-function-creators = 1 + +# 时区设置 +default-time-zone = '+08:00' diff --git a/backend/docker/nginx/__init__.py b/backend/docker/nginx/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/__init__.py b/backend/docs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/api/__init__.py b/backend/docs/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/database_rollback_guide.md b/backend/docs/database_rollback_guide.md new file mode 100644 index 0000000..de5472c --- /dev/null +++ b/backend/docs/database_rollback_guide.md @@ -0,0 +1,227 @@ +# 考培练系统数据库回滚指南 + +## 概述 + +考培练系统支持基于MySQL Binlog的数据库回滚功能,可以快速恢复误操作导致的数据变更。本指南提供了完整的回滚操作流程和最佳实践。 + +## 回滚方案对比 + +| 方案 | 适用场景 | 优点 | 缺点 | 推荐度 | +|------|----------|------|------|--------| +| **Binlog回滚** | 精确时间点回滚 | 精确、完整 | 需要技术知识 | ⭐⭐⭐⭐⭐ | +| **软删除恢复** | 删除操作回滚 | 简单、安全 | 仅限软删除 | ⭐⭐⭐⭐ | +| **备份恢复** | 大规模回滚 | 完整恢复 | 可能丢失新数据 | ⭐⭐⭐ | +| **手动修复** | 小范围修复 | 灵活 | 容易出错 | ⭐⭐ | + +## 一、Binlog回滚(推荐) + +### 1.1 前提条件检查 + +```bash +# 检查Binlog是否启用 +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW VARIABLES LIKE 'log_bin';" + +# 检查Binlog格式(推荐ROW格式) +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW VARIABLES LIKE 'binlog_format';" + +# 查看可用的Binlog文件 +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW BINARY LOGS;" +``` + +### 1.2 使用专用回滚工具 + +#### 查看最近变更 +```bash +cd /Users/nongjun/Desktop/Ai公司/本地开发与测试/kaopeilian-backend +python scripts/kaopeilian_rollback.py --list --hours 24 +``` + +#### 回滚用户操作 +```bash +# 模拟回滚(查看会执行什么操作) +python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete + +# 实际执行回滚 +python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete --execute +``` + +#### 回滚课程操作 +```bash +# 回滚课程删除 +python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete --execute + +# 回滚课程更新(需要手动处理) +python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type update +``` + +#### 回滚考试操作 +```bash +# 回滚考试记录(会同时删除考试和考试结果) +python scripts/kaopeilian_rollback.py --rollback-exam 789 --execute +``` + +### 1.3 使用简化回滚工具 + +#### 查看Binlog文件 +```bash +python scripts/simple_rollback.py --list +``` + +#### 基于时间点回滚 +```bash +# 模拟回滚到指定时间点 +python scripts/simple_rollback.py --time "2024-12-20 10:30:00" + +# 实际执行回滚 +python scripts/simple_rollback.py --time "2024-12-20 10:30:00" --execute +``` + +### 1.4 使用完整Binlog工具 + +```bash +# 查看帮助 +python scripts/binlog_rollback_tool.py --help + +# 列出Binlog文件 +python scripts/binlog_rollback_tool.py --list-binlogs + +# 回滚到指定时间点 +python scripts/binlog_rollback_tool.py --time "2024-12-20 10:30:00" --execute +``` + +## 二、软删除恢复 + +### 2.1 恢复用户 +```sql +-- 恢复软删除的用户 +UPDATE users SET is_deleted = FALSE, deleted_at = NULL WHERE id = 123; +``` + +### 2.2 恢复课程 +```sql +-- 恢复软删除的课程 +UPDATE courses SET is_deleted = FALSE, deleted_at = NULL WHERE id = 456; +``` + +### 2.3 恢复岗位 +```sql +-- 恢复软删除的岗位 +UPDATE positions SET is_deleted = FALSE, deleted_at = NULL WHERE id = 789; +``` + +## 三、备份恢复 + +### 3.1 创建完整备份 +```bash +# 创建数据库完整备份 +docker exec kaopeilian-mysql mysqldump -uroot -proot --single-transaction --routines --triggers kaopeilian > backup_$(date +%Y%m%d_%H%M%S).sql +``` + +### 3.2 恢复备份 +```bash +# 恢复数据库备份 +docker exec -i kaopeilian-mysql mysql -uroot -proot kaopeilian < backup_20241220_103000.sql +``` + +## 四、常见回滚场景 + +### 4.1 误删用户 +```bash +# 1. 查看最近删除的用户 +python scripts/kaopeilian_rollback.py --list --hours 1 + +# 2. 恢复软删除的用户 +python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete --execute +``` + +### 4.2 误删课程 +```bash +# 1. 恢复软删除的课程 +python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete --execute + +# 2. 恢复课程关联数据(如果需要) +# 手动执行SQL恢复课程资料、知识点等 +``` + +### 4.3 误删考试记录 +```bash +# 1. 恢复考试记录(会同时恢复考试结果) +python scripts/kaopeilian_rollback.py --rollback-exam 789 --execute +``` + +### 4.4 批量误操作 +```bash +# 1. 基于时间点回滚 +python scripts/simple_rollback.py --time "2024-12-20 10:30:00" --execute + +# 2. 或使用完整备份恢复 +docker exec -i kaopeilian-mysql mysql -uroot -proot kaopeilian < backup_before_operation.sql +``` + +## 五、最佳实践 + +### 5.1 回滚前准备 +1. **创建备份**:回滚前必须创建当前数据备份 +2. **确认时间点**:精确确定需要回滚到的时间点 +3. **评估影响**:评估回滚操作对系统的影响 +4. **通知用户**:必要时通知相关用户 + +### 5.2 回滚操作流程 +1. **停止服务**:停止可能影响数据的服务 +2. **创建备份**:备份当前状态 +3. **执行回滚**:使用合适的回滚工具 +4. **验证数据**:验证回滚结果 +5. **重启服务**:恢复服务运行 +6. **记录日志**:记录回滚操作日志 + +### 5.3 安全注意事项 +- 回滚操作不可逆,务必谨慎 +- 生产环境回滚前必须在测试环境验证 +- 重要操作需要多人确认 +- 保留回滚操作日志 + +## 六、故障排除 + +### 6.1 Binlog未启用 +```bash +# 检查MySQL配置 +docker exec kaopeilian-mysql mysql -uroot -proot -e "SHOW VARIABLES LIKE 'log_bin';" + +# 如果未启用,需要修改MySQL配置并重启 +``` + +### 6.2 Binlog文件过大 +```bash +# 清理旧的Binlog文件 +docker exec kaopeilian-mysql mysql -uroot -proot -e "PURGE BINARY LOGS BEFORE DATE_SUB(NOW(), INTERVAL 7 DAY);" +``` + +### 6.3 回滚工具执行失败 +1. 检查数据库连接 +2. 确认权限设置 +3. 查看错误日志 +4. 手动执行SQL语句 + +## 七、监控与预防 + +### 7.1 设置监控 +- 监控Binlog文件大小 +- 监控数据库操作日志 +- 设置异常操作告警 + +### 7.2 预防措施 +- 定期备份数据库 +- 设置操作权限控制 +- 实施操作审计 +- 提供操作确认机制 + +## 八、联系支持 + +如遇到回滚问题,请联系技术支持: +- 查看系统日志:`docker logs kaopeilian-mysql` +- 查看应用日志:`docker logs kaopeilian-backend` +- 提交问题报告:包含错误信息、操作步骤、时间点等 + +--- + +**重要提醒**:数据库回滚是高风险操作,请务必在充分理解操作影响的前提下执行,建议在测试环境先验证回滚方案的有效性。 diff --git a/backend/docs/deployment/__init__.py b/backend/docs/deployment/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/development/__init__.py b/backend/docs/development/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/docs/openapi_sql_executor.json b/backend/docs/openapi_sql_executor.json new file mode 100644 index 0000000..9825e1c --- /dev/null +++ b/backend/docs/openapi_sql_executor.json @@ -0,0 +1,664 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "KaoPeiLian SQL Executor API", + "description": "SQL 执行器 API,专门为 Dify 平台集成设计,支持对考陪练系统数据库执行查询和写入操作。\n\n## 主要功能\n- 执行 SQL 查询和写入操作\n- 支持参数化查询防止 SQL 注入\n- 获取数据库表列表和表结构\n- SQL 语句验证\n\n## 安全说明\n所有接口都需要 JWT Bearer Token 认证。请先通过登录接口获取访问令牌。", + "version": "1.0.0", + "contact": { + "name": "KaoPeiLian Tech Support", + "email": "support@kaopeilian.com" + } + }, + "servers": [ + { + "url": "http://120.79.247.16:8000/api/v1", + "description": "考陪练系统服务器" + }, + { + "url": "http://aiedu.ireborn.com.cn/api/v1", + "description": "域名访问" + } + ], + "security": [ + { + "bearerAuth": [] + } + ], + "paths": { + "/auth/login": { + "post": { + "tags": ["认证"], + "summary": "用户登录", + "description": "获取访问令牌,用于后续 API 调用", + "security": [], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginRequest" + }, + "examples": { + "admin": { + "summary": "管理员登录", + "value": { + "username": "admin", + "password": "admin123" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "登录成功", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/LoginResponse" + } + } + } + }, + "401": { + "description": "用户名或密码错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/sql/execute": { + "post": { + "tags": ["SQL执行器"], + "summary": "执行 SQL 语句", + "description": "执行查询或写入 SQL 语句。\n\n**查询操作**: SELECT, SHOW, DESCRIBE\n**写入操作**: INSERT, UPDATE, DELETE, CREATE, ALTER, DROP\n\n支持参数化查询,使用 `:param_name` 格式定义参数。", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SqlExecuteRequest" + }, + "examples": { + "simpleQuery": { + "summary": "简单查询", + "value": { + "sql": "SELECT id, username, role FROM users LIMIT 5" + } + }, + "parameterizedQuery": { + "summary": "参数化查询", + "value": { + "sql": "SELECT * FROM courses WHERE category = :category AND status = :status", + "params": { + "category": "护肤", + "status": "active" + } + } + }, + "insertData": { + "summary": "插入数据", + "value": { + "sql": "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)", + "params": { + "title": "面部护理基础", + "content": "面部护理的基本步骤...", + "course_id": 1 + } + } + } + } + } + } + }, + "responses": { + "200": { + "description": "SQL 执行成功", + "content": { + "application/json": { + "schema": { + "oneOf": [ + { + "$ref": "#/components/schemas/QueryResponse" + }, + { + "$ref": "#/components/schemas/ExecuteResponse" + } + ] + }, + "examples": { + "queryResult": { + "summary": "查询结果", + "value": { + "code": 200, + "message": "SQL 执行成功", + "data": { + "type": "query", + "columns": ["id", "username", "role"], + "rows": [ + { + "id": 1, + "username": "admin", + "role": "admin" + }, + { + "id": 2, + "username": "user1", + "role": "trainee" + } + ], + "row_count": 2 + } + } + }, + "executeResult": { + "summary": "写入结果", + "value": { + "code": 200, + "message": "SQL 执行成功", + "data": { + "type": "execute", + "affected_rows": 1, + "success": true + } + } + } + } + } + } + }, + "400": { + "description": "请求参数错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "未认证或认证失败", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "500": { + "description": "SQL 执行错误", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/sql/validate": { + "post": { + "tags": ["SQL执行器"], + "summary": "验证 SQL 语法", + "description": "验证 SQL 语句的语法正确性,不执行实际操作", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SqlValidateRequest" + } + } + } + }, + "responses": { + "200": { + "description": "验证完成", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ValidateResponse" + } + } + } + } + } + } + }, + "/sql/tables": { + "get": { + "tags": ["SQL执行器"], + "summary": "获取表列表", + "description": "获取数据库中所有表的列表", + "responses": { + "200": { + "description": "成功获取表列表", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TablesResponse" + } + } + } + }, + "401": { + "description": "未认证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + }, + "/sql/table/{table_name}/schema": { + "get": { + "tags": ["SQL执行器"], + "summary": "获取表结构", + "description": "获取指定表的结构信息,包括字段名、类型、约束等", + "parameters": [ + { + "name": "table_name", + "in": "path", + "required": true, + "description": "表名", + "schema": { + "type": "string", + "pattern": "^[a-zA-Z_][a-zA-Z0-9_]*$" + }, + "example": "users" + } + ], + "responses": { + "200": { + "description": "成功获取表结构", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TableSchemaResponse" + } + } + } + }, + "400": { + "description": "无效的表名", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + }, + "401": { + "description": "未认证", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ErrorResponse" + } + } + } + } + } + } + } + }, + "components": { + "securitySchemes": { + "bearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "使用登录接口返回的 access_token。\n格式: Bearer {access_token}" + } + }, + "schemas": { + "LoginRequest": { + "type": "object", + "required": ["username", "password"], + "properties": { + "username": { + "type": "string", + "description": "用户名", + "example": "admin" + }, + "password": { + "type": "string", + "format": "password", + "description": "密码", + "example": "admin123" + } + } + }, + "LoginResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "登录成功" + }, + "data": { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "example": 1 + }, + "username": { + "type": "string", + "example": "admin" + }, + "role": { + "type": "string", + "example": "admin" + } + } + }, + "token": { + "type": "object", + "properties": { + "access_token": { + "type": "string", + "description": "JWT 访问令牌", + "example": "eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9..." + }, + "token_type": { + "type": "string", + "example": "bearer" + }, + "expires_in": { + "type": "integer", + "description": "过期时间(秒)", + "example": 1800 + } + } + } + } + } + } + }, + "SqlExecuteRequest": { + "type": "object", + "required": ["sql"], + "properties": { + "sql": { + "type": "string", + "description": "要执行的 SQL 语句", + "example": "SELECT * FROM users WHERE role = :role" + }, + "params": { + "type": "object", + "description": "SQL 参数字典,键为参数名,值为参数值", + "additionalProperties": true, + "example": { + "role": "admin" + } + } + } + }, + "SqlValidateRequest": { + "type": "object", + "required": ["sql"], + "properties": { + "sql": { + "type": "string", + "description": "要验证的 SQL 语句", + "example": "SELECT * FROM users" + } + } + }, + "QueryResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "SQL 执行成功" + }, + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["query"], + "example": "query" + }, + "columns": { + "type": "array", + "items": { + "type": "string" + }, + "description": "列名数组", + "example": ["id", "username", "role"] + }, + "rows": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": true + }, + "description": "查询结果行" + }, + "row_count": { + "type": "integer", + "description": "返回的行数", + "example": 5 + } + } + } + } + }, + "ExecuteResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "SQL 执行成功" + }, + "data": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": ["execute"], + "example": "execute" + }, + "affected_rows": { + "type": "integer", + "description": "影响的行数", + "example": 1 + }, + "success": { + "type": "boolean", + "example": true + } + } + } + } + }, + "ValidateResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "SQL 验证完成" + }, + "data": { + "type": "object", + "properties": { + "valid": { + "type": "boolean", + "description": "SQL 是否有效", + "example": true + }, + "warnings": { + "type": "array", + "items": { + "type": "string" + }, + "description": "警告信息列表", + "example": ["包含危险操作: DROP"] + }, + "sql_type": { + "type": "string", + "description": "SQL 类型", + "example": "SELECT" + } + } + } + } + }, + "TablesResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "获取表列表成功" + }, + "data": { + "type": "object", + "properties": { + "tables": { + "type": "array", + "items": { + "type": "string" + }, + "description": "表名列表", + "example": ["users", "courses", "exams"] + }, + "count": { + "type": "integer", + "description": "表的数量", + "example": 20 + } + } + } + } + }, + "TableSchemaResponse": { + "type": "object", + "properties": { + "code": { + "type": "integer", + "example": 200 + }, + "message": { + "type": "string", + "example": "获取表结构成功" + }, + "data": { + "type": "object", + "properties": { + "table_name": { + "type": "string", + "example": "users" + }, + "columns": { + "type": "array", + "items": { + "type": "object", + "properties": { + "field": { + "type": "string", + "description": "字段名", + "example": "id" + }, + "type": { + "type": "string", + "description": "字段类型", + "example": "int(11)" + }, + "null": { + "type": "string", + "enum": ["YES", "NO"], + "description": "是否可为空", + "example": "NO" + }, + "key": { + "type": "string", + "description": "键类型(PRI, UNI, MUL)", + "example": "PRI" + }, + "default": { + "type": "string", + "nullable": true, + "description": "默认值", + "example": null + }, + "extra": { + "type": "string", + "description": "额外信息", + "example": "auto_increment" + } + } + } + }, + "column_count": { + "type": "integer", + "description": "列的数量", + "example": 10 + } + } + } + } + }, + "ErrorResponse": { + "type": "object", + "properties": { + "detail": { + "type": "string", + "description": "错误详情", + "example": "SQL 执行失败: You have an error in your SQL syntax" + } + } + } + } + }, + "tags": [ + { + "name": "认证", + "description": "用户认证相关接口" + }, + { + "name": "SQL执行器", + "description": "SQL 执行和管理相关接口" + } + ] +} diff --git a/backend/docs/openapi_sql_executor.yaml b/backend/docs/openapi_sql_executor.yaml new file mode 100644 index 0000000..20dae5e --- /dev/null +++ b/backend/docs/openapi_sql_executor.yaml @@ -0,0 +1,568 @@ +openapi: 3.1.0 +info: + title: KaoPeiLian SQL Executor API + description: | + SQL 执行器 API,专门为 Dify 平台集成设计,支持对考陪练系统数据库执行查询和写入操作。 + + ## 主要功能 + - 执行 SQL 查询和写入操作 + - 支持参数化查询防止 SQL 注入 + - 获取数据库表列表和表结构 + - SQL 语句验证 + + ## 安全说明 + 所有接口都需要 JWT Bearer Token 认证。请先通过登录接口获取访问令牌。 + version: 1.0.0 + contact: + name: KaoPeiLian Tech Support + email: support@kaopeilian.com + +servers: + - url: http://120.79.247.16:8000/api/v1 + description: 考陪练系统服务器 + - url: http://localhost:8000/api/v1 + description: 本地开发服务器 + - url: http://aiedu.ireborn.com.cn/api/v1 + description: 域名访问 + +security: + - bearerAuth: [] + +paths: + /auth/login: + post: + tags: + - 认证 + summary: 用户登录 + description: 获取访问令牌,用于后续 API 调用 + security: [] # 登录接口不需要认证 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/LoginRequest' + examples: + admin: + summary: 管理员登录 + value: + username: admin + password: admin123 + responses: + '200': + description: 登录成功 + content: + application/json: + schema: + $ref: '#/components/schemas/LoginResponse' + '401': + description: 用户名或密码错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/execute-simple: + post: + tags: + - SQL执行器-简化认证 + summary: 执行 SQL 语句(简化认证版) + description: | + 执行查询或写入 SQL 语句,使用简化的认证方式。 + + **认证方式(二选一)**: + 1. API Key: X-API-Key: dify-2025-kaopeilian + 2. 长期 Token: Authorization: Bearer permanent-token-for-dify-2025 + + **查询操作**: SELECT, SHOW, DESCRIBE + **写入操作**: INSERT, UPDATE, DELETE, CREATE, ALTER, DROP + + 支持参数化查询,使用 `:param_name` 格式定义参数。 + security: + - apiKey: [] + - bearerAuth: [] + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SqlExecuteRequest' + examples: + simpleQuery: + summary: 简单查询 + value: + sql: "SELECT id, username, role FROM users LIMIT 5" + parameterizedQuery: + summary: 参数化查询 + value: + sql: "SELECT * FROM courses WHERE category = :category AND status = :status" + params: + category: "护肤" + status: "active" + insertData: + summary: 插入数据 + value: + sql: "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)" + params: + title: "面部护理基础" + content: "面部护理的基本步骤..." + course_id: 1 + responses: + '200': + description: SQL 执行成功 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/QueryResponse' + - $ref: '#/components/schemas/ExecuteResponse' + '401': + description: 未认证或认证失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: SQL 执行错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/execute: + post: + tags: + - SQL执行器 + summary: 执行 SQL 语句(标准认证版) + description: | + 执行查询或写入 SQL 语句。 + + **查询操作**: SELECT, SHOW, DESCRIBE + **写入操作**: INSERT, UPDATE, DELETE, CREATE, ALTER, DROP + + 支持参数化查询,使用 `:param_name` 格式定义参数。 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SqlExecuteRequest' + examples: + simpleQuery: + summary: 简单查询 + value: + sql: "SELECT id, username, role FROM users LIMIT 5" + parameterizedQuery: + summary: 参数化查询 + value: + sql: "SELECT * FROM courses WHERE category = :category AND status = :status" + params: + category: "护肤" + status: "active" + insertData: + summary: 插入数据 + value: + sql: "INSERT INTO knowledge_points (title, content, course_id) VALUES (:title, :content, :course_id)" + params: + title: "面部护理基础" + content: "面部护理的基本步骤..." + course_id: 1 + responses: + '200': + description: SQL 执行成功 + content: + application/json: + schema: + oneOf: + - $ref: '#/components/schemas/QueryResponse' + - $ref: '#/components/schemas/ExecuteResponse' + examples: + queryResult: + summary: 查询结果 + value: + code: 200 + message: "SQL 执行成功" + data: + type: "query" + columns: ["id", "username", "role"] + rows: + - id: 1 + username: "admin" + role: "admin" + - id: 2 + username: "user1" + role: "trainee" + row_count: 2 + executeResult: + summary: 写入结果 + value: + code: 200 + message: "SQL 执行成功" + data: + type: "execute" + affected_rows: 1 + success: true + '400': + description: 请求参数错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: 未认证或认证失败 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '500': + description: SQL 执行错误 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/validate: + post: + tags: + - SQL执行器 + summary: 验证 SQL 语法 + description: 验证 SQL 语句的语法正确性,不执行实际操作 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/SqlValidateRequest' + responses: + '200': + description: 验证完成 + content: + application/json: + schema: + $ref: '#/components/schemas/ValidateResponse' + + /sql/tables: + get: + tags: + - SQL执行器 + summary: 获取表列表 + description: 获取数据库中所有表的列表 + responses: + '200': + description: 成功获取表列表 + content: + application/json: + schema: + $ref: '#/components/schemas/TablesResponse' + '401': + description: 未认证 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + + /sql/table/{table_name}/schema: + get: + tags: + - SQL执行器 + summary: 获取表结构 + description: 获取指定表的结构信息,包括字段名、类型、约束等 + parameters: + - name: table_name + in: path + required: true + description: 表名 + schema: + type: string + pattern: '^[a-zA-Z_][a-zA-Z0-9_]*$' + example: users + responses: + '200': + description: 成功获取表结构 + content: + application/json: + schema: + $ref: '#/components/schemas/TableSchemaResponse' + '400': + description: 无效的表名 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + '401': + description: 未认证 + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + +components: + securitySchemes: + bearerAuth: + type: http + scheme: bearer + bearerFormat: JWT + description: | + 使用登录接口返回的 access_token。 + 格式: Bearer {access_token} + apiKey: + type: apiKey + in: header + name: X-API-Key + description: | + API Key 认证,适用于内部服务调用。 + 示例: X-API-Key: dify-2025-kaopeilian + + schemas: + LoginRequest: + type: object + required: + - username + - password + properties: + username: + type: string + description: 用户名 + example: admin + password: + type: string + format: password + description: 密码 + example: admin123 + + LoginResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: 登录成功 + data: + type: object + properties: + user: + type: object + properties: + id: + type: integer + example: 1 + username: + type: string + example: admin + role: + type: string + example: admin + token: + type: object + properties: + access_token: + type: string + description: JWT 访问令牌 + example: eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9... + token_type: + type: string + example: bearer + expires_in: + type: integer + description: 过期时间(秒) + example: 1800 + + SqlExecuteRequest: + type: object + required: + - sql + properties: + sql: + type: string + description: 要执行的 SQL 语句 + example: "SELECT * FROM users WHERE role = :role" + params: + type: object + description: SQL 参数字典,键为参数名,值为参数值 + additionalProperties: true + example: + role: admin + + SqlValidateRequest: + type: object + required: + - sql + properties: + sql: + type: string + description: 要验证的 SQL 语句 + example: "SELECT * FROM users" + + QueryResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: SQL 执行成功 + data: + type: object + properties: + type: + type: string + enum: [query] + example: query + columns: + type: array + items: + type: string + description: 列名数组 + example: ["id", "username", "role"] + rows: + type: array + items: + type: object + additionalProperties: true + description: 查询结果行 + row_count: + type: integer + description: 返回的行数 + example: 5 + + ExecuteResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: SQL 执行成功 + data: + type: object + properties: + type: + type: string + enum: [execute] + example: execute + affected_rows: + type: integer + description: 影响的行数 + example: 1 + success: + type: boolean + example: true + + ValidateResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: SQL 验证完成 + data: + type: object + properties: + valid: + type: boolean + description: SQL 是否有效 + example: true + warnings: + type: array + items: + type: string + description: 警告信息列表 + example: ["包含危险操作: DROP"] + sql_type: + type: string + description: SQL 类型 + example: SELECT + + TablesResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: 获取表列表成功 + data: + type: object + properties: + tables: + type: array + items: + type: string + description: 表名列表 + example: ["users", "courses", "exams"] + count: + type: integer + description: 表的数量 + example: 20 + + TableSchemaResponse: + type: object + properties: + code: + type: integer + example: 200 + message: + type: string + example: 获取表结构成功 + data: + type: object + properties: + table_name: + type: string + example: users + columns: + type: array + items: + type: object + properties: + field: + type: string + description: 字段名 + example: id + type: + type: string + description: 字段类型 + example: int(11) + null: + type: string + enum: ["YES", "NO"] + description: 是否可为空 + example: NO + key: + type: string + description: 键类型(PRI, UNI, MUL) + example: PRI + default: + type: string + nullable: true + description: 默认值 + example: null + extra: + type: string + description: 额外信息 + example: auto_increment + column_count: + type: integer + description: 列的数量 + example: 10 + + ErrorResponse: + type: object + properties: + detail: + type: string + description: 错误详情 + example: SQL 执行失败: You have an error in your SQL syntax + +tags: + - name: 认证 + description: 用户认证相关接口 + - name: SQL执行器 + description: SQL 执行和管理相关接口 diff --git a/backend/docs/sql_executor_checklist.md b/backend/docs/sql_executor_checklist.md new file mode 100644 index 0000000..61ab0a9 --- /dev/null +++ b/backend/docs/sql_executor_checklist.md @@ -0,0 +1,124 @@ +# SQL 执行器 API 完成清单 + +## ✅ 已完成功能 + +### 1. API 开发 +- [x] 创建 `/api/v1/sql/execute` - 标准认证版本 +- [x] 创建 `/api/v1/sql/execute-simple` - 简化认证版本 +- [x] 创建 `/api/v1/sql/validate` - SQL 验证 +- [x] 创建 `/api/v1/sql/tables` - 获取表列表 +- [x] 创建 `/api/v1/sql/table/{name}/schema` - 获取表结构 + +### 2. 认证方式 +- [x] JWT Bearer Token(标准版) +- [x] API Key 认证(X-API-Key: dify-2025-kaopeilian) +- [x] 长期 Token(Bearer permanent-token-for-dify-2025) + +### 3. 安全特性 +- [x] 参数化查询支持 +- [x] SQL 操作日志记录 +- [x] 危险操作警告 +- [x] 事务自动回滚 + +### 4. 文档 +- [x] OpenAPI 3.1 规范(YAML) +- [x] OpenAPI 3.1 规范(JSON) +- [x] Dify 使用指南 +- [x] 服务器部署指南 +- [x] 快速部署脚本 +- [x] 集成总结文档 + +### 5. 测试 +- [x] 本地测试脚本 +- [x] API Key 认证测试通过 +- [x] 长期 Token 认证测试通过 +- [x] 查询操作测试通过 +- [x] 写入操作测试通过 + +## 📋 Dify 配置步骤 + +### 方式一:导入 OpenAPI(推荐) +1. 在 Dify 中选择"导入 OpenAPI" +2. 上传 `openapi_sql_executor.yaml` 或 `.json` +3. 选择服务器:120.79.247.16:8000 +4. 配置认证(见下方) + +### 方式二:手动配置 +1. **URL**: http://120.79.247.16:8000/api/v1/sql/execute-simple +2. **方法**: POST +3. **认证配置**: + - 类型: 请求头 + - 前缀: Custom + - 键: X-API-Key + - 值: dify-2025-kaopeilian + +## 🚀 部署检查 + +### 本地环境 +- [x] 服务正常运行 +- [x] 数据库连接正常 +- [x] API 响应正常 + +### 服务器环境(待部署) +- [ ] 上传代码到服务器 +- [ ] 运行部署脚本 +- [ ] 配置防火墙 +- [ ] 测试公网访问 + +## 📊 数据库信息 + +- **主机**: 120.79.247.16 +- **端口**: 3306 +- **数据库**: kaopeilian +- **用户**: root +- **密码**: Kaopeilian2025!@# + +## 🔧 常用命令 + +### 本地测试 +```bash +# 测试 API Key +curl -X POST http://localhost:8000/api/v1/sql/execute-simple \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -H "Content-Type: application/json" \ + -d '{"sql": "SELECT COUNT(*) FROM users"}' +``` + +### 服务器部署 +```bash +# 连接服务器 +ssh root@120.79.247.16 + +# 运行部署脚本 +bash /opt/kaopeilian/backend/deploy/quick_deploy.sh +``` + +## 📝 下一步行动 + +1. **部署到服务器** + - 上传代码 + - 运行部署脚本 + - 测试公网访问 + +2. **在 Dify 中配置** + - 导入 OpenAPI 文档 + - 配置认证 + - 创建工作流 + +3. **监控和维护** + - 设置日志监控 + - 定期备份 + - 性能优化 + +## ⚠️ 注意事项 + +1. API Key 是硬编码的,生产环境建议从环境变量读取 +2. 确保服务器防火墙开放 8000 端口 +3. 建议使用 HTTPS 加密传输 +4. 定期更新 API Key 和 Token + +--- + +**状态**: 开发完成,待部署到生产环境 + + diff --git a/backend/examples/coze_integration_example.py b/backend/examples/coze_integration_example.py new file mode 100644 index 0000000..24e617c --- /dev/null +++ b/backend/examples/coze_integration_example.py @@ -0,0 +1,140 @@ +""" +Coze 集成示例 +演示如何使用 Coze 服务 +""" + +import asyncio +import os +from datetime import datetime + +# 设置环境变量(实际使用时应通过 .env 文件或环境配置) +os.environ.update({ + "COZE_API_BASE": "https://api.coze.cn", + "COZE_WORKSPACE_ID": "your_workspace_id", + "COZE_API_TOKEN": "your_api_token", + "COZE_CHAT_BOT_ID": "7489905095250526219", + "COZE_TRAINING_BOT_ID": "7494965200370581538", + "COZE_EXAM_BOT_ID": "7492288561950195724" +}) + +from app.services.ai.coze import ( + get_coze_service, + CreateSessionRequest, + SendMessageRequest, + EndSessionRequest, + SessionType +) + + +async def demo_course_chat(): + """演示课程对话功能""" + print("\n=== 课程对话示例 ===") + + service = get_coze_service() + + # 1. 创建会话 + create_request = CreateSessionRequest( + session_type=SessionType.COURSE_CHAT, + user_id="demo-user-123", + course_id="python-basics-101", + metadata={"course_name": "Python 基础教程"} + ) + + session_response = await service.create_session(create_request) + print(f"创建会话成功: {session_response.session_id}") + + # 2. 发送消息 + message_request = SendMessageRequest( + session_id=session_response.session_id, + content="请解释一下 Python 的装饰器是什么?", + stream=True + ) + + print("\n助手回复:") + async for event in service.send_message(message_request): + if event.event.value == "message_delta": + print(event.content, end="", flush=True) + elif event.event.value == "message_completed": + print(f"\n\n[消息完成] Token 使用: {event.data.get('usage', {})}") + + # 3. 获取历史消息 + messages = await service.get_session_messages(session_response.session_id) + print(f"\n会话消息数: {len(messages)}") + + +async def demo_training_session(): + """演示陪练会话功能""" + print("\n\n=== 陪练会话示例 ===") + + service = get_coze_service() + + # 1. 创建陪练会话 + create_request = CreateSessionRequest( + session_type=SessionType.TRAINING, + user_id="demo-user-456", + training_topic="客诉处理技巧", + metadata={"difficulty": "intermediate"} + ) + + session_response = await service.create_session(create_request) + print(f"创建陪练会话成功: {session_response.session_id}") + + # 2. 进行多轮对话 + scenarios = [ + "客户抱怨产品质量有问题,要求退款", + "我会先向客户道歉,然后了解具体问题", + "客户说产品使用一周就坏了,非常生气" + ] + + for i, content in enumerate(scenarios): + print(f"\n--- 第 {i+1} 轮对话 ---") + print(f"用户: {content}") + + message_request = SendMessageRequest( + session_id=session_response.session_id, + content=content, + stream=False # 非流式响应 + ) + + print("助手: ", end="") + async for event in service.send_message(message_request): + if event.event.value == "message_completed": + print(event.content) + + # 模拟思考时间 + await asyncio.sleep(1) + + # 3. 结束会话 + end_request = EndSessionRequest( + reason="练习完成", + feedback={ + "rating": 5, + "helpful": True, + "comment": "模拟场景很真实,学到了很多处理技巧" + } + ) + + end_response = await service.end_session(session_response.session_id, end_request) + print(f"\n会话结束:") + print(f"- 时长: {end_response.duration_seconds} 秒") + print(f"- 消息数: {end_response.message_count}") + + +async def main(): + """运行所有示例""" + try: + # 运行课程对话示例 + await demo_course_chat() + + # 运行陪练会话示例 + await demo_training_session() + + except Exception as e: + print(f"\n错误: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + # 运行示例 + asyncio.run(main()) diff --git a/backend/insert_test_logs.sql b/backend/insert_test_logs.sql new file mode 100644 index 0000000..2f7a175 --- /dev/null +++ b/backend/insert_test_logs.sql @@ -0,0 +1,27 @@ +-- 插入测试系统日志数据 + +INSERT INTO `system_logs` (`level`, `type`, `user`, `user_id`, `ip`, `message`, `user_agent`, `path`, `method`, `extra_data`) +VALUES +('info', 'system', 'system', NULL, NULL, '系统启动成功', NULL, NULL, NULL, NULL), +('info', 'user', 'admin', 1, '192.168.1.100', '管理员登录系统', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/auth/login', 'POST', NULL), +('info', 'api', 'admin', 1, '192.168.1.100', '查询用户列表', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/users', 'GET', NULL), +('warning', 'security', 'unknown', NULL, '192.168.1.200', '尝试访问未授权资源', 'Mozilla/5.0 (Macintosh; Intel Mac OS X)', '/api/v1/admin/users', 'GET', NULL), +('error', 'error', 'manager1', 2, '192.168.1.101', '数据库连接超时', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/courses', 'GET', '{"error": "Connection timeout"}'), +('info', 'user', 'trainee1', 3, '192.168.1.102', '学员开始考试', 'Mozilla/5.0 (iPhone; CPU iPhone OS)', '/api/v1/exams/start', 'POST', NULL), +('info', 'user', 'trainee1', 3, '192.168.1.102', '学员提交考试答案', 'Mozilla/5.0 (iPhone; CPU iPhone OS)', '/api/v1/exams/submit', 'POST', NULL), +('warning', 'api', 'trainee2', 4, '192.168.1.103', 'API请求频率过高', 'Mozilla/5.0 (Android)', '/api/v1/courses', 'GET', '{"rate_limit": "exceeded"}'), +('error', 'error', 'system', NULL, NULL, '文件上传失败:磁盘空间不足', NULL, '/api/v1/upload', 'POST', '{"error": "Disk full"}'), +('info', 'system', 'system', NULL, NULL, '定时任务执行:清理过期数据', NULL, NULL, NULL, NULL), +('debug', 'api', 'admin', 1, '192.168.1.100', 'SQL查询: SELECT * FROM users', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/users', 'GET', NULL), +('info', 'user', 'manager1', 2, '192.168.1.101', '创建新课程:《皮肤管理基础》', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/courses', 'POST', NULL), +('error', 'security', 'hacker', NULL, '192.168.1.250', 'SQL注入攻击尝试被拦截', 'curl/7.68.0', '/api/v1/auth/login', 'POST', '{"blocked": true}'), +('warning', 'api', 'trainee3', 5, '192.168.1.104', 'API响应时间超过3秒', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/practice/sessions', 'GET', '{"response_time": 3.2}'), +('info', 'user', 'admin', 1, '192.168.1.100', '批量导入学员数据:成功50条', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/admin/import', 'POST', NULL), +('info', 'system', 'system', NULL, NULL, '数据库备份完成', NULL, NULL, NULL, '{"backup_file": "backup_20241016.sql"}'), +('error', 'error', 'trainee4', 6, '192.168.1.105', '视频播放失败:资源不存在', 'Mozilla/5.0 (iPad; CPU OS)', '/api/v1/courses/1/video', 'GET', '{"error": "Resource not found"}'), +('info', 'user', 'manager1', 2, '192.168.1.101', '发布课程公告', 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)', '/api/v1/courses/1/announcements', 'POST', NULL), +('debug', 'api', 'system', NULL, NULL, '缓存刷新:courses_list', NULL, '/internal/cache/refresh', 'POST', NULL), +('warning', 'security', 'unknown', NULL, '192.168.1.201', '密码错误超过5次,账户已锁定', 'Mozilla/5.0 (Linux; Android)', '/api/v1/auth/login', 'POST', '{"locked": true, "user": "test@example.com"}'); + + + diff --git a/backend/migrations/__init__.py b/backend/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/add_broadcast_fields.sql b/backend/migrations/add_broadcast_fields.sql new file mode 100644 index 0000000..7c5fb26 --- /dev/null +++ b/backend/migrations/add_broadcast_fields.sql @@ -0,0 +1,20 @@ +-- 添加播课功能相关字段到 courses 表 +-- 日期: 2025-10-14 + +USE kaopeilian; + +-- 添加播课音频URL字段 +ALTER TABLE courses +ADD COLUMN broadcast_audio_url VARCHAR(500) NULL COMMENT '播课音频URL'; + +-- 添加播课生成时间字段 +ALTER TABLE courses +ADD COLUMN broadcast_generated_at DATETIME NULL COMMENT '播课生成时间'; + +-- 验证字段添加 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME IN ('broadcast_audio_url', 'broadcast_generated_at'); + diff --git a/backend/migrations/add_broadcast_status_fields.sql b/backend/migrations/add_broadcast_status_fields.sql new file mode 100644 index 0000000..a418da5 --- /dev/null +++ b/backend/migrations/add_broadcast_status_fields.sql @@ -0,0 +1,24 @@ +-- 添加播课异步生成状态管理字段 +-- 日期: 2025-10-14 + +USE kaopeilian; + +-- 添加播课生成状态字段 +ALTER TABLE courses +ADD COLUMN broadcast_status VARCHAR(20) NULL COMMENT '播课生成状态: pending/generating/completed/failed'; + +-- 添加Coze工作流任务ID字段 +ALTER TABLE courses +ADD COLUMN broadcast_task_id VARCHAR(100) NULL COMMENT 'Coze工作流任务ID'; + +-- 添加错误信息字段 +ALTER TABLE courses +ADD COLUMN broadcast_error_message TEXT NULL COMMENT '生成失败错误信息'; + +-- 验证字段添加 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME IN ('broadcast_status', 'broadcast_task_id', 'broadcast_error_message'); + diff --git a/backend/migrations/add_course_allow_download.sql b/backend/migrations/add_course_allow_download.sql new file mode 100644 index 0000000..67c0ba2 --- /dev/null +++ b/backend/migrations/add_course_allow_download.sql @@ -0,0 +1,25 @@ +-- 迁移脚本:为课程表添加资料下载开关字段 +-- 创建时间:2025-12-17 +-- 功能描述:添加 allow_download 字段,用于控制学员是否可以下载课程资料 + +-- 检查并添加 allow_download 字段 +ALTER TABLE courses +ADD COLUMN IF NOT EXISTS allow_download TINYINT(1) NOT NULL DEFAULT 0 +COMMENT '是否允许下载资料:0=不允许,1=允许'; + +-- 验证字段是否添加成功 +SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_DEFAULT, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'allow_download'; + + + + + + + + + + diff --git a/backend/migrations/admin_platform_schema.sql b/backend/migrations/admin_platform_schema.sql new file mode 100644 index 0000000..10c5d23 --- /dev/null +++ b/backend/migrations/admin_platform_schema.sql @@ -0,0 +1,298 @@ +-- ============================================ +-- 考培练系统 SaaS 超级管理后台数据库架构 +-- 数据库名:kaopeilian_admin +-- 创建日期:2026-01-18 +-- ============================================ + +-- 创建数据库 +CREATE DATABASE IF NOT EXISTS kaopeilian_admin +CHARACTER SET utf8mb4 +COLLATE utf8mb4_unicode_ci; + +USE kaopeilian_admin; + +-- ============================================ +-- 1. 平台管理员表 (admin_users) +-- ============================================ +CREATE TABLE IF NOT EXISTS `admin_users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) UNIQUE COMMENT '邮箱', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `role` VARCHAR(20) DEFAULT 'admin' COMMENT '角色: superadmin, admin, viewer', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `last_login_at` DATETIME COMMENT '最后登录时间', + `last_login_ip` VARCHAR(50) COMMENT '最后登录IP', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='平台管理员表'; + +-- ============================================ +-- 2. 租户表 (tenants) +-- ============================================ +CREATE TABLE IF NOT EXISTS `tenants` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(20) NOT NULL UNIQUE COMMENT '租户编码(如:hua, yy, hl)', + `name` VARCHAR(100) NOT NULL COMMENT '租户名称', + `display_name` VARCHAR(200) COMMENT '显示名称(如:华尔倍丽-考培练系统)', + `domain` VARCHAR(200) NOT NULL COMMENT '域名(如:hua.ireborn.com.cn)', + `logo_url` VARCHAR(500) COMMENT 'Logo URL', + `favicon_url` VARCHAR(500) COMMENT 'Favicon URL', + `contact_name` VARCHAR(50) COMMENT '联系人', + `contact_phone` VARCHAR(20) COMMENT '联系电话', + `contact_email` VARCHAR(100) COMMENT '联系邮箱', + `industry` VARCHAR(50) DEFAULT 'medical_beauty' COMMENT '行业:medical_beauty, pet, education', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态:active, inactive, suspended', + `expire_at` DATE COMMENT '服务到期日期', + `remarks` TEXT COMMENT '备注', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL, + INDEX idx_code (code), + INDEX idx_status (status), + INDEX idx_domain (domain) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户表'; + +-- ============================================ +-- 3. 租户配置表 (tenant_configs) +-- Key-Value 形式存储各类配置 +-- ============================================ +CREATE TABLE IF NOT EXISTS `tenant_configs` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tenant_id` INT NOT NULL COMMENT '租户ID', + `config_group` VARCHAR(50) NOT NULL COMMENT '配置分组:database, redis, dify, coze, ai, yanji, security', + `config_key` VARCHAR(100) NOT NULL COMMENT '配置键', + `config_value` TEXT COMMENT '配置值', + `value_type` VARCHAR(20) DEFAULT 'string' COMMENT '值类型:string, int, bool, json, secret', + `is_encrypted` BOOLEAN DEFAULT FALSE COMMENT '是否加密存储', + `description` VARCHAR(500) COMMENT '配置说明', + `is_required` BOOLEAN DEFAULT FALSE COMMENT '是否必填', + `default_value` TEXT COMMENT '默认值', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE KEY uk_tenant_group_key (tenant_id, config_group, config_key), + INDEX idx_tenant_id (tenant_id), + INDEX idx_config_group (config_group) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户配置表'; + +-- ============================================ +-- 4. AI 提示词模板表 (ai_prompts) +-- ============================================ +CREATE TABLE IF NOT EXISTS `ai_prompts` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `code` VARCHAR(50) NOT NULL COMMENT '提示词编码(如:knowledge_analysis, exam_generator)', + `name` VARCHAR(100) NOT NULL COMMENT '提示词名称', + `description` TEXT COMMENT '提示词说明', + `module` VARCHAR(50) NOT NULL COMMENT '所属模块:course, exam, practice, ability', + `system_prompt` TEXT NOT NULL COMMENT '系统提示词', + `user_prompt_template` TEXT COMMENT '用户提示词模板', + `variables` JSON COMMENT '变量列表(如:["course_name", "content"])', + `output_schema` JSON COMMENT '输出 JSON Schema', + `model_recommendation` VARCHAR(100) COMMENT '推荐模型', + `max_tokens` INT DEFAULT 4096 COMMENT '最大 token 数', + `temperature` DECIMAL(3,2) DEFAULT 0.70 COMMENT '温度参数', + `is_system` BOOLEAN DEFAULT TRUE COMMENT '是否系统内置(内置不可删除)', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `version` INT DEFAULT 1 COMMENT '当前版本号', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL, + UNIQUE KEY uk_code (code), + INDEX idx_module (module), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI提示词模板表'; + +-- ============================================ +-- 5. AI 提示词版本历史表 (ai_prompt_versions) +-- ============================================ +CREATE TABLE IF NOT EXISTS `ai_prompt_versions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `prompt_id` INT NOT NULL COMMENT '提示词ID', + `version` INT NOT NULL COMMENT '版本号', + `system_prompt` TEXT NOT NULL COMMENT '系统提示词', + `user_prompt_template` TEXT COMMENT '用户提示词模板', + `variables` JSON COMMENT '变量列表', + `output_schema` JSON COMMENT '输出 JSON Schema', + `change_summary` VARCHAR(500) COMMENT '变更说明', + `created_by` INT COMMENT '创建人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY (prompt_id) REFERENCES ai_prompts(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + UNIQUE KEY uk_prompt_version (prompt_id, version), + INDEX idx_prompt_id (prompt_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI提示词版本历史表'; + +-- ============================================ +-- 6. 租户自定义提示词表 (tenant_prompts) +-- 租户可覆盖系统默认提示词 +-- ============================================ +CREATE TABLE IF NOT EXISTS `tenant_prompts` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tenant_id` INT NOT NULL COMMENT '租户ID', + `prompt_id` INT NOT NULL COMMENT '基础提示词ID', + `system_prompt` TEXT COMMENT '自定义系统提示词(为空则使用默认)', + `user_prompt_template` TEXT COMMENT '自定义用户提示词模板', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用自定义', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + FOREIGN KEY (prompt_id) REFERENCES ai_prompts(id) ON DELETE CASCADE, + FOREIGN KEY (created_by) REFERENCES admin_users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES admin_users(id) ON DELETE SET NULL, + UNIQUE KEY uk_tenant_prompt (tenant_id, prompt_id), + INDEX idx_tenant_id (tenant_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='租户自定义提示词表'; + +-- ============================================ +-- 7. 功能开关表 (feature_switches) +-- ============================================ +CREATE TABLE IF NOT EXISTS `feature_switches` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `tenant_id` INT COMMENT '租户ID(NULL表示全局默认)', + `feature_code` VARCHAR(50) NOT NULL COMMENT '功能编码', + `feature_name` VARCHAR(100) NOT NULL COMMENT '功能名称', + `feature_group` VARCHAR(50) COMMENT '功能分组:exam, practice, broadcast, yanji', + `is_enabled` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `config` JSON COMMENT '功能配置参数', + `description` VARCHAR(500) COMMENT '功能说明', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (tenant_id) REFERENCES tenants(id) ON DELETE CASCADE, + UNIQUE KEY uk_tenant_feature (tenant_id, feature_code), + INDEX idx_tenant_id (tenant_id), + INDEX idx_feature_code (feature_code) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='功能开关表'; + +-- ============================================ +-- 8. 操作审计日志表 (operation_logs) +-- ============================================ +CREATE TABLE IF NOT EXISTS `operation_logs` ( + `id` BIGINT AUTO_INCREMENT PRIMARY KEY, + `admin_user_id` INT COMMENT '操作人ID', + `admin_username` VARCHAR(50) COMMENT '操作人用户名', + `tenant_id` INT COMMENT '涉及租户ID', + `tenant_code` VARCHAR(20) COMMENT '涉及租户编码', + `operation_type` VARCHAR(50) NOT NULL COMMENT '操作类型:create, update, delete, enable, disable', + `resource_type` VARCHAR(50) NOT NULL COMMENT '资源类型:tenant, config, prompt, feature', + `resource_id` INT COMMENT '资源ID', + `resource_name` VARCHAR(200) COMMENT '资源名称', + `old_value` JSON COMMENT '变更前值', + `new_value` JSON COMMENT '变更后值', + `ip_address` VARCHAR(50) COMMENT '操作IP', + `user_agent` VARCHAR(500) COMMENT '浏览器信息', + `remarks` TEXT COMMENT '备注', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + INDEX idx_admin_user_id (admin_user_id), + INDEX idx_tenant_id (tenant_id), + INDEX idx_operation_type (operation_type), + INDEX idx_resource_type (resource_type), + INDEX idx_created_at (created_at) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='操作审计日志表'; + +-- ============================================ +-- 9. 配置模板表 (config_templates) +-- 定义各配置项的元数据 +-- ============================================ +CREATE TABLE IF NOT EXISTS `config_templates` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `config_group` VARCHAR(50) NOT NULL COMMENT '配置分组', + `config_key` VARCHAR(100) NOT NULL COMMENT '配置键', + `display_name` VARCHAR(100) NOT NULL COMMENT '显示名称', + `description` TEXT COMMENT '配置说明', + `value_type` VARCHAR(20) DEFAULT 'string' COMMENT '值类型', + `default_value` TEXT COMMENT '默认值', + `is_required` BOOLEAN DEFAULT FALSE COMMENT '是否必填', + `is_secret` BOOLEAN DEFAULT FALSE COMMENT '是否敏感信息', + `validation_rule` VARCHAR(500) COMMENT '验证规则(正则)', + `options` JSON COMMENT '可选值列表(下拉选择)', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uk_group_key (config_group, config_key), + INDEX idx_config_group (config_group) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='配置模板表'; + +-- ============================================ +-- 初始化数据 +-- ============================================ + +-- 1. 插入超级管理员 +INSERT INTO admin_users (username, email, password_hash, full_name, role) VALUES +('superadmin', 'admin@ireborn.com.cn', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/X4.ynB8dC.m5QZ9Hy', '超级管理员', 'superadmin'); +-- 密码: Superadmin123! + +-- 2. 插入配置模板 +INSERT INTO config_templates (config_group, config_key, display_name, description, value_type, default_value, is_required, is_secret, sort_order) VALUES +-- 数据库配置 +('database', 'MYSQL_HOST', 'MySQL主机', 'MySQL数据库主机地址', 'string', 'prod-mysql', TRUE, FALSE, 1), +('database', 'MYSQL_PORT', 'MySQL端口', 'MySQL数据库端口', 'int', '3306', TRUE, FALSE, 2), +('database', 'MYSQL_USER', 'MySQL用户', 'MySQL数据库用户名', 'string', 'root', TRUE, FALSE, 3), +('database', 'MYSQL_PASSWORD', 'MySQL密码', 'MySQL数据库密码', 'string', NULL, TRUE, TRUE, 4), +('database', 'MYSQL_DATABASE', '数据库名', '租户数据库名称', 'string', NULL, TRUE, FALSE, 5), +-- Redis配置 +('redis', 'REDIS_HOST', 'Redis主机', 'Redis缓存主机地址', 'string', 'localhost', TRUE, FALSE, 1), +('redis', 'REDIS_PORT', 'Redis端口', 'Redis端口', 'int', '6379', TRUE, FALSE, 2), +('redis', 'REDIS_DB', 'Redis DB', 'Redis数据库编号', 'int', '0', FALSE, FALSE, 3), +-- 安全配置 +('security', 'SECRET_KEY', 'JWT密钥', 'JWT Token签名密钥', 'string', NULL, TRUE, TRUE, 1), +('security', 'CORS_ORIGINS', 'CORS域名', '允许跨域的域名列表', 'json', '[]', FALSE, FALSE, 2), +-- Dify配置 +('dify', 'DIFY_API_KEY', '知识点分析 Key', 'Dify 01-知识点分析工作流 API Key', 'string', NULL, FALSE, TRUE, 1), +('dify', 'DIFY_EXAM_GENERATOR_API_KEY', '试题生成器 Key', 'Dify 02-试题生成器工作流 API Key', 'string', NULL, FALSE, TRUE, 2), +('dify', 'DIFY_PRACTICE_API_KEY', '陪练知识准备 Key', 'Dify 03-陪练知识准备工作流 API Key', 'string', NULL, FALSE, TRUE, 3), +('dify', 'DIFY_COURSE_CHAT_API_KEY', '课程对话 Key', 'Dify 04-与课程对话工作流 API Key', 'string', NULL, FALSE, TRUE, 4), +('dify', 'DIFY_YANJI_ANALYSIS_API_KEY', '智能工牌分析 Key', 'Dify 05-智能工牌能力分析工作流 API Key', 'string', NULL, FALSE, TRUE, 5), +-- Coze配置 +('coze', 'COZE_PRACTICE_BOT_ID', '陪练Bot ID', 'Coze 陪练机器人ID', 'string', '7560643598174683145', FALSE, FALSE, 1), +('coze', 'COZE_BROADCAST_WORKFLOW_ID', '播课工作流ID', 'Coze 播课工作流ID', 'string', NULL, FALSE, FALSE, 2), +('coze', 'COZE_BROADCAST_SPACE_ID', '播课空间ID', 'Coze 播课工作流空间ID', 'string', '7474971491470688296', FALSE, FALSE, 3), +('coze', 'COZE_OAUTH_CLIENT_ID', 'OAuth Client ID', 'Coze OAuth 客户端ID', 'string', NULL, FALSE, FALSE, 4), +('coze', 'COZE_OAUTH_PUBLIC_KEY_ID', 'OAuth Public Key ID', 'Coze OAuth 公钥ID', 'string', NULL, FALSE, FALSE, 5), +-- AI服务配置 +('ai', 'AI_PRIMARY_API_KEY', '首选AI服务Key', '首选AI服务商(4sapi.com) API Key', 'string', NULL, FALSE, TRUE, 1), +('ai', 'AI_PRIMARY_BASE_URL', '首选AI服务地址', '首选AI服务商API地址', 'string', 'https://4sapi.com/v1', FALSE, FALSE, 2), +('ai', 'AI_FALLBACK_API_KEY', '备选AI服务Key', '备选AI服务商(OpenRouter) API Key', 'string', NULL, FALSE, TRUE, 3), +('ai', 'AI_FALLBACK_BASE_URL', '备选AI服务地址', '备选AI服务商API地址', 'string', 'https://openrouter.ai/api/v1', FALSE, FALSE, 4), +('ai', 'AI_DEFAULT_MODEL', '默认模型', '默认使用的AI模型', 'string', 'gemini-3-flash-preview', FALSE, FALSE, 5), +('ai', 'AI_TIMEOUT', 'AI请求超时', 'AI服务请求超时时间(秒)', 'int', '120', FALSE, FALSE, 6), +-- 言迹工牌配置 +('yanji', 'YANJI_CLIENT_ID', '客户端ID', '言迹开放平台Client ID', 'string', NULL, FALSE, FALSE, 1), +('yanji', 'YANJI_CLIENT_SECRET', '客户端密钥', '言迹开放平台Client Secret', 'string', NULL, FALSE, TRUE, 2), +('yanji', 'YANJI_TENANT_ID', '租户ID', '言迹租户ID', 'string', NULL, FALSE, FALSE, 3), +('yanji', 'YANJI_ESTATE_ID', '门店ID', '言迹门店ID', 'string', NULL, FALSE, FALSE, 4), +-- 文件存储配置 +('storage', 'UPLOAD_DIR', '上传目录', '文件上传目录', 'string', 'uploads', FALSE, FALSE, 1), +('storage', 'MAX_UPLOAD_SIZE', '最大上传大小', '最大文件上传大小(字节)', 'int', '15728640', FALSE, FALSE, 2); + +-- 3. 插入功能开关模板(全局默认) +INSERT INTO feature_switches (tenant_id, feature_code, feature_name, feature_group, is_enabled, description) VALUES +(NULL, 'exam_module', '考试模块', 'exam', TRUE, '考试功能总开关'), +(NULL, 'exam_ai_generate', 'AI试题生成', 'exam', TRUE, '使用AI自动生成试题'), +(NULL, 'exam_three_rounds', '三轮考试', 'exam', TRUE, '启用三轮考试机制'), +(NULL, 'practice_module', '陪练模块', 'practice', TRUE, '陪练功能总开关'), +(NULL, 'practice_voice', '语音陪练', 'practice', TRUE, '支持语音对话陪练'), +(NULL, 'broadcast_module', '播课模块', 'broadcast', TRUE, '播课功能总开关'), +(NULL, 'broadcast_auto_generate', '自动生成播课', 'broadcast', TRUE, '自动生成课程播课内容'), +(NULL, 'course_chat', '课程对话', 'course', TRUE, '与课程知识点对话功能'), +(NULL, 'knowledge_ai_analyze', 'AI知识点分析', 'course', TRUE, '使用AI分析提取知识点'), +(NULL, 'yanji_module', '智能工牌模块', 'yanji', FALSE, '言迹智能工牌对接功能'), +(NULL, 'yanji_ability_analysis', '能力分析', 'yanji', FALSE, '基于工牌数据的能力分析'); + +-- ============================================ +-- 完成 +-- ============================================ + diff --git a/backend/migrations/alembic/__init__.py b/backend/migrations/alembic/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/alembic/versions/__init__.py b/backend/migrations/alembic/versions/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/cleanup_broadcast_fields.sql b/backend/migrations/cleanup_broadcast_fields.sql new file mode 100644 index 0000000..94b0e58 --- /dev/null +++ b/backend/migrations/cleanup_broadcast_fields.sql @@ -0,0 +1,17 @@ +-- 清理播课功能不必要的字段 +-- 日期: 2025-10-14 +-- 说明: 新策略下Coze工作流直接写数据库,不需要状态跟踪字段 + +USE kaopeilian; + +-- 删除不再需要的字段 +ALTER TABLE courses DROP COLUMN broadcast_status; +ALTER TABLE courses DROP COLUMN broadcast_task_id; +ALTER TABLE courses DROP COLUMN broadcast_error_message; + +-- 验证剩余字段 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%'; diff --git a/backend/migrations/create_ability_assessments.sql b/backend/migrations/create_ability_assessments.sql new file mode 100644 index 0000000..e69de29 diff --git a/backend/migrations/env.py b/backend/migrations/env.py new file mode 100644 index 0000000..eb1e1aa --- /dev/null +++ b/backend/migrations/env.py @@ -0,0 +1,104 @@ +""" +Alembic 环境配置 +""" +import asyncio +import os +import sys +from logging.config import fileConfig +from pathlib import Path + +from alembic import context +from sqlalchemy import pool +from sqlalchemy.engine import Connection +from sqlalchemy.ext.asyncio import async_engine_from_config + +# 将项目根目录添加到 Python 路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from app.core.config import settings +from app.models.base import Base + +# 导入所有模型以确保 Alembic 能够检测到它们 +from app.models.user import User, Team # noqa + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# 设置数据库URL +config.set_main_option("sqlalchemy.url", settings.DATABASE_URL) + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +# add your model's MetaData object here +# for 'autogenerate' support +target_metadata = Base.metadata + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def run_migrations_offline() -> None: + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + + with context.begin_transaction(): + context.run_migrations() + + +def do_run_migrations(connection: Connection) -> None: + context.configure(connection=connection, target_metadata=target_metadata) + + with context.begin_transaction(): + context.run_migrations() + + +async def run_async_migrations() -> None: + """In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + connectable = async_engine_from_config( + config.get_section(config.config_ini_section, {}), + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + + async with connectable.connect() as connection: + await connection.run_sync(do_run_migrations) + + await connectable.dispose() + + +def run_migrations_online() -> None: + """Run migrations in 'online' mode.""" + + asyncio.run(run_async_migrations()) + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() \ No newline at end of file diff --git a/backend/migrations/manual_course_tables.sql b/backend/migrations/manual_course_tables.sql new file mode 100644 index 0000000..53a9ecb --- /dev/null +++ b/backend/migrations/manual_course_tables.sql @@ -0,0 +1,131 @@ +-- 课程相关表结构 +-- 用于手动创建课程模块所需的数据库表 + +-- 1. 课程表 +CREATE TABLE IF NOT EXISTS `courses` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') NOT NULL DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') NOT NULL DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否推荐', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + -- 审计字段 + `created_by` INT, + `updated_by` INT, + + PRIMARY KEY (`id`), + INDEX `idx_courses_status` (`status`), + INDEX `idx_courses_category` (`category`), + INDEX `idx_courses_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; + +-- 2. 课程资料表 +CREATE TABLE IF NOT EXISTS `course_materials` ( + `id` INT NOT NULL AUTO_INCREMENT, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + PRIMARY KEY (`id`), + FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE, + INDEX `idx_course_materials_course_id` (`course_id`), + INDEX `idx_course_materials_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; + +-- 3. 知识点表 +CREATE TABLE IF NOT EXISTS `knowledge_points` ( + `id` INT NOT NULL AUTO_INCREMENT, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `parent_id` INT COMMENT '父知识点ID', + `level` INT NOT NULL DEFAULT 1 COMMENT '层级深度', + `path` VARCHAR(500) COMMENT '路径(如: 1.2.3)', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + `weight` FLOAT NOT NULL DEFAULT 1.0 COMMENT '权重', + `is_required` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否必修', + `estimated_hours` FLOAT COMMENT '预计学习时间(小时)', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + PRIMARY KEY (`id`), + FOREIGN KEY (`course_id`) REFERENCES `courses`(`id`) ON DELETE CASCADE, + FOREIGN KEY (`parent_id`) REFERENCES `knowledge_points`(`id`) ON DELETE CASCADE, + INDEX `idx_knowledge_points_course_id` (`course_id`), + INDEX `idx_knowledge_points_parent_id` (`parent_id`), + INDEX `idx_knowledge_points_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; + +-- 4. 成长路径表 +CREATE TABLE IF NOT EXISTS `growth_paths` ( + `id` INT NOT NULL AUTO_INCREMENT, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT NOT NULL DEFAULT 0 COMMENT '排序顺序', + + -- 基础字段 + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + -- 软删除字段 + `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE, + `deleted_at` DATETIME, + + PRIMARY KEY (`id`), + INDEX `idx_growth_paths_is_active` (`is_active`), + INDEX `idx_growth_paths_is_deleted` (`is_deleted`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; + +-- 添加测试数据 +INSERT INTO `courses` (`name`, `description`, `category`, `status`, `difficulty_level`, `tags`) VALUES +('Python基础入门', 'Python编程语言基础教程,适合零基础学员', 'technology', 'published', 1, '["Python", "编程基础", "入门"]'), +('项目管理实战', '敏捷项目管理方法论与实践', 'management', 'published', 3, '["项目管理", "敏捷", "Scrum"]'), +('商业分析技巧', '商业数据分析与决策支持', 'business', 'draft', 2, '["数据分析", "商业智能", "Excel"]'); + +-- 为Python课程添加知识点 +INSERT INTO `knowledge_points` (`course_id`, `name`, `description`, `parent_id`, `level`, `path`, `sort_order`, `weight`, `is_required`, `estimated_hours`) VALUES +(1, 'Python基础语法', 'Python语言基础语法知识', NULL, 1, '1', 1, 1.0, TRUE, 5.0), +(1, '变量与数据类型', '学习Python中的变量定义和基本数据类型', 1, 2, '1.1', 1, 0.8, TRUE, 2.0), +(1, '控制流程', '条件语句和循环结构', 1, 2, '1.2', 2, 0.9, TRUE, 3.0); + +-- 添加成长路径 +INSERT INTO `growth_paths` (`name`, `description`, `target_role`, `courses`, `estimated_duration_days`) VALUES +('Python开发工程师', '从零基础到Python开发工程师的成长路径', 'Python开发工程师', '[{"course_id": 1, "order": 1, "is_required": true}]', 90); diff --git a/backend/migrations/manual_modify_knowledge_points_material_id.sql b/backend/migrations/manual_modify_knowledge_points_material_id.sql new file mode 100644 index 0000000..4296efc --- /dev/null +++ b/backend/migrations/manual_modify_knowledge_points_material_id.sql @@ -0,0 +1,26 @@ +-- 修改知识点表,将material_id设为必填字段 +-- 执行日期:2025-09-27 +-- 说明:将knowledge_points表的material_id字段从可选改为必填 + +-- 1. 首先检查是否有material_id为NULL的记录 +SELECT COUNT(*) as null_count FROM knowledge_points WHERE material_id IS NULL AND is_deleted = FALSE; + +-- 2. 如果有NULL值,需要先处理这些记录(可以删除或设置默认值) +-- 这里先删除material_id为NULL的记录(如果有的话) +DELETE FROM knowledge_points WHERE material_id IS NULL; + +-- 3. 修改字段为NOT NULL,并修改外键约束 +ALTER TABLE knowledge_points +MODIFY COLUMN material_id INT NOT NULL COMMENT '关联资料ID'; + +-- 4. 删除旧的外键约束 +ALTER TABLE knowledge_points +DROP FOREIGN KEY knowledge_points_ibfk_2; + +-- 5. 添加新的外键约束(CASCADE删除) +ALTER TABLE knowledge_points +ADD CONSTRAINT knowledge_points_ibfk_2 +FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE; + +-- 6. 验证修改结果 +DESCRIBE knowledge_points; diff --git a/backend/migrations/manual_training_tables.sql b/backend/migrations/manual_training_tables.sql new file mode 100644 index 0000000..b553d6c --- /dev/null +++ b/backend/migrations/manual_training_tables.sql @@ -0,0 +1,92 @@ +-- 手动创建training模块相关表的SQL脚本 +-- 用于解决Alembic迁移失败的备选方案 + +-- 1. 创建training_scenes表 +CREATE TABLE IF NOT EXISTS training_scenes ( + id INTEGER NOT NULL AUTO_INCREMENT, + name VARCHAR(100) NOT NULL COMMENT '场景名称', + description TEXT COMMENT '场景描述', + category VARCHAR(50) NOT NULL COMMENT '场景分类', + ai_config JSON COMMENT 'AI配置(如Coze Bot ID等)', + prompt_template TEXT COMMENT '提示词模板', + evaluation_criteria JSON COMMENT '评估标准', + status ENUM('DRAFT', 'ACTIVE', 'INACTIVE') NOT NULL DEFAULT 'DRAFT' COMMENT '场景状态', + is_public BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否公开', + required_level INTEGER COMMENT '所需用户等级', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + created_by INTEGER, + updated_by INTEGER, + PRIMARY KEY (id), + INDEX ix_training_scenes_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; + +-- 2. 创建training_sessions表 +CREATE TABLE IF NOT EXISTS training_sessions ( + id INTEGER NOT NULL AUTO_INCREMENT, + user_id BIGINT NOT NULL COMMENT '用户ID', + scene_id INTEGER NOT NULL COMMENT '场景ID', + coze_conversation_id VARCHAR(100) COMMENT 'Coze会话ID', + start_time DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + end_time DATETIME COMMENT '结束时间', + duration_seconds INTEGER COMMENT '持续时长(秒)', + status ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') NOT NULL DEFAULT 'CREATED' COMMENT '会话状态', + session_config JSON COMMENT '会话配置', + total_score FLOAT COMMENT '总分', + evaluation_result JSON COMMENT '评估结果详情', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INTEGER, + updated_by INTEGER, + PRIMARY KEY (id), + INDEX ix_training_sessions_id (id), + INDEX ix_training_sessions_user_id (user_id), + FOREIGN KEY (scene_id) REFERENCES training_scenes(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; + +-- 3. 创建training_messages表 +CREATE TABLE IF NOT EXISTS training_messages ( + id INTEGER NOT NULL AUTO_INCREMENT, + session_id INTEGER NOT NULL COMMENT '会话ID', + role ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + type ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + content TEXT NOT NULL COMMENT '消息内容', + voice_url VARCHAR(500) COMMENT '语音文件URL', + voice_duration FLOAT COMMENT '语音时长(秒)', + message_metadata JSON COMMENT '消息元数据', + coze_message_id VARCHAR(100) COMMENT 'Coze消息ID', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (id), + INDEX ix_training_messages_id (id), + FOREIGN KEY (session_id) REFERENCES training_sessions(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; + +-- 4. 创建training_reports表 +CREATE TABLE IF NOT EXISTS training_reports ( + id INTEGER NOT NULL AUTO_INCREMENT, + session_id INTEGER NOT NULL COMMENT '会话ID', + user_id BIGINT NOT NULL COMMENT '用户ID', + overall_score FLOAT NOT NULL COMMENT '总体得分', + dimension_scores JSON NOT NULL COMMENT '各维度得分', + strengths JSON NOT NULL COMMENT '优势点', + weaknesses JSON NOT NULL COMMENT '待改进点', + suggestions JSON NOT NULL COMMENT '改进建议', + detailed_analysis TEXT COMMENT '详细分析', + transcript TEXT COMMENT '对话文本记录', + statistics JSON COMMENT '统计数据', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INTEGER, + updated_by INTEGER, + PRIMARY KEY (id), + UNIQUE KEY (session_id), + INDEX ix_training_reports_id (id), + INDEX ix_training_reports_user_id (user_id), + FOREIGN KEY (session_id) REFERENCES training_sessions(id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; + +-- 5. 更新Alembic版本表,标记迁移已完成 +INSERT INTO alembic_version (version_num) VALUES ('9245f8845fe1') ON DUPLICATE KEY UPDATE version_num = '9245f8845fe1'; diff --git a/backend/migrations/script.py.mako b/backend/migrations/script.py.mako new file mode 100644 index 0000000..3cf5352 --- /dev/null +++ b/backend/migrations/script.py.mako @@ -0,0 +1,26 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision: str = ${repr(up_revision)} +down_revision: Union[str, None] = ${repr(down_revision)} +branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)} +depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} \ No newline at end of file diff --git a/backend/migrations/update_production_broadcast_fields.sql b/backend/migrations/update_production_broadcast_fields.sql new file mode 100644 index 0000000..b56ec16 --- /dev/null +++ b/backend/migrations/update_production_broadcast_fields.sql @@ -0,0 +1,13 @@ +-- 更新生产环境数据库播课字段 +-- 日期: 2025-10-14 +-- 说明: 清理旧的异步状态字段,只保留必要的播课字段 + +USE kaopeilian; + +-- 查看当前字段结构 +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%' +ORDER BY COLUMN_NAME; diff --git a/backend/migrations/update_production_broadcast_fields_step1_check.sql b/backend/migrations/update_production_broadcast_fields_step1_check.sql new file mode 100644 index 0000000..c089f2d --- /dev/null +++ b/backend/migrations/update_production_broadcast_fields_step1_check.sql @@ -0,0 +1,25 @@ +-- 步骤1:检查生产环境数据库播课字段 +-- 日期: 2025-10-14 + +USE kaopeilian; + +-- 查看当前字段结构 +SELECT '=== 当前播课相关字段 ===' AS info; +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%' +ORDER BY COLUMN_NAME; + +-- 检查是否有播课数据 +SELECT '=== 已生成的播课数据 ===' AS info; +SELECT COUNT(*) as broadcast_count +FROM courses +WHERE broadcast_audio_url IS NOT NULL; + +SELECT id, name, broadcast_audio_url, broadcast_generated_at +FROM courses +WHERE broadcast_audio_url IS NOT NULL +LIMIT 5; + diff --git a/backend/migrations/update_production_broadcast_fields_step2_update.sql b/backend/migrations/update_production_broadcast_fields_step2_update.sql new file mode 100644 index 0000000..afe60af --- /dev/null +++ b/backend/migrations/update_production_broadcast_fields_step2_update.sql @@ -0,0 +1,86 @@ +-- 步骤2:更新生产环境数据库播课字段 +-- 日期: 2025-10-14 +-- 说明: 清理旧的异步状态字段,只保留必要的播课字段 +-- 执行前请先运行 step1_check.sql 查看当前字段情况 + +USE kaopeilian; + +-- 删除不再需要的字段 +-- 如果字段不存在会报错,可以忽略错误继续执行 + +-- 删除 broadcast_status +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_status') > 0, + 'ALTER TABLE courses DROP COLUMN broadcast_status', + 'SELECT "broadcast_status 不存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 删除 broadcast_task_id +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_task_id') > 0, + 'ALTER TABLE courses DROP COLUMN broadcast_task_id', + 'SELECT "broadcast_task_id 不存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 删除 broadcast_error_message +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_error_message') > 0, + 'ALTER TABLE courses DROP COLUMN broadcast_error_message', + 'SELECT "broadcast_error_message 不存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 添加必要字段(如果不存在) + +-- 添加 broadcast_audio_url +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_audio_url') = 0, + 'ALTER TABLE courses ADD COLUMN broadcast_audio_url VARCHAR(500) NULL COMMENT ''播课音频URL''', + 'SELECT "broadcast_audio_url 已存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 添加 broadcast_generated_at +SET @sql = IF( + (SELECT COUNT(*) FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME = 'courses' + AND COLUMN_NAME = 'broadcast_generated_at') = 0, + 'ALTER TABLE courses ADD COLUMN broadcast_generated_at DATETIME NULL COMMENT ''播课生成时间''', + 'SELECT "broadcast_generated_at 已存在,跳过" AS info' +); +PREPARE stmt FROM @sql; +EXECUTE stmt; +DEALLOCATE PREPARE stmt; + +-- 验证最终字段 +SELECT '=== 更新后的播课字段 ===' AS info; +SELECT COLUMN_NAME, COLUMN_TYPE, IS_NULLABLE, COLUMN_COMMENT +FROM INFORMATION_SCHEMA.COLUMNS +WHERE TABLE_SCHEMA = 'kaopeilian' +AND TABLE_NAME = 'courses' +AND COLUMN_NAME LIKE 'broadcast%' +ORDER BY COLUMN_NAME; + diff --git a/backend/migrations/versions/0487635b5e95_add_position_courses_table.py b/backend/migrations/versions/0487635b5e95_add_position_courses_table.py new file mode 100644 index 0000000..d01167a --- /dev/null +++ b/backend/migrations/versions/0487635b5e95_add_position_courses_table.py @@ -0,0 +1,46 @@ +"""add_position_courses_table + +Revision ID: 0487635b5e95 +Revises: 5448c81e7afd +Create Date: 2025-09-22 08:27:52.507507 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '0487635b5e95' +down_revision: Union[str, None] = '5448c81e7afd' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('position_courses', + sa.Column('position_id', sa.Integer(), nullable=False, comment='岗位ID'), + sa.Column('course_id', sa.Integer(), nullable=False, comment='课程ID'), + sa.Column('course_type', sa.String(length=20), nullable=False, comment='课程类型'), + sa.Column('priority', sa.Integer(), nullable=True, comment='优先级/排序'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['course_id'], ['courses.id'], ), + sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('position_id', 'course_id', 'is_deleted', name='uix_position_course') + ) + op.create_index(op.f('ix_position_courses_id'), 'position_courses', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_position_courses_id'), table_name='position_courses') + op.drop_table('position_courses') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/migrations/versions/20250921_align_schema_to_design.py b/backend/migrations/versions/20250921_align_schema_to_design.py new file mode 100644 index 0000000..941563c --- /dev/null +++ b/backend/migrations/versions/20250921_align_schema_to_design.py @@ -0,0 +1,157 @@ +""" +Align database schema to the unified design spec. + +Changes: +- users: id INT, set defaults, drop extra columns not in design +- user_teams/exams: user_id INT +- teams: drop created_by/updated_by +- training_*: fix defaults; set created_by/updated_by to BIGINT where required +- training_reports: add unique(session_id) +- create or replace view v_user_course_progress +""" + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = "20250921_align_schema_to_design" +down_revision = "9245f8845fe1" +branch_labels = None +depends_on = None + + +def upgrade(): + connection = op.get_bind() + + # users: enforce INT PK, defaults, and drop extra columns + op.execute(""" + ALTER TABLE `users` + MODIFY COLUMN `id` INT AUTO_INCREMENT, + MODIFY COLUMN `username` VARCHAR(50) NOT NULL, + MODIFY COLUMN `email` VARCHAR(100) NOT NULL, + MODIFY COLUMN `password_hash` VARCHAR(200) NOT NULL, + MODIFY COLUMN `role` VARCHAR(20) DEFAULT 'trainee', + MODIFY COLUMN `is_active` TINYINT(1) DEFAULT 1, + MODIFY COLUMN `is_verified` TINYINT(1) DEFAULT 0, + MODIFY COLUMN `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + """) + + # Drop extra columns that are not in the design (ignore errors if absent) + for col in [ + "is_superuser", + "department", + "position", + "last_login", + "login_count", + "failed_login_count", + "locked_until", + "created_by", + "updated_by", + "is_deleted", + "deleted_at", + ]: + try: + op.execute(f"ALTER TABLE `users` DROP COLUMN `{col}`") + except Exception: + pass + + # user_teams: user_id INT + op.execute(""" + ALTER TABLE `user_teams` + MODIFY COLUMN `user_id` INT NOT NULL + """) + + # exams: user_id INT + op.execute(""" + ALTER TABLE `exams` + MODIFY COLUMN `user_id` INT NOT NULL + """) + + # teams: drop created_by/updated_by to match design + for col in ["created_by", "updated_by"]: + try: + op.execute(f"ALTER TABLE `teams` DROP COLUMN `{col}`") + except Exception: + pass + + # training_scenes: set defaults and BIGINT audit fields + op.execute(""" + ALTER TABLE `training_scenes` + MODIFY COLUMN `status` ENUM('DRAFT','ACTIVE','INACTIVE') NOT NULL DEFAULT 'DRAFT', + MODIFY COLUMN `is_public` TINYINT(1) NOT NULL DEFAULT 1, + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + MODIFY COLUMN `created_by` BIGINT NULL, + MODIFY COLUMN `updated_by` BIGINT NULL + """) + + # training_sessions: defaults and BIGINT audit fields + op.execute(""" + ALTER TABLE `training_sessions` + MODIFY COLUMN `start_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `status` ENUM('CREATED','IN_PROGRESS','COMPLETED','CANCELLED','ERROR') NOT NULL DEFAULT 'CREATED', + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + MODIFY COLUMN `created_by` BIGINT NULL, + MODIFY COLUMN `updated_by` BIGINT NULL + """) + + # training_messages: timestamps defaults + op.execute(""" + ALTER TABLE `training_messages` + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP + """) + + # training_reports: timestamps defaults and BIGINT audit fields + op.execute(""" + ALTER TABLE `training_reports` + MODIFY COLUMN `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + MODIFY COLUMN `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + MODIFY COLUMN `created_by` BIGINT NULL, + MODIFY COLUMN `updated_by` BIGINT NULL + """) + + # Add unique constraint for training_reports.session_id per design + try: + op.create_unique_constraint( + "uq_training_reports_session_id", "training_reports", ["session_id"] + ) + except Exception: + pass + + # Create or replace view v_user_course_progress + op.execute(""" + CREATE OR REPLACE VIEW v_user_course_progress AS + SELECT + u.id AS user_id, + u.username, + c.id AS course_id, + c.name AS course_name, + COUNT(DISTINCT e.id) AS exam_count, + AVG(e.score) AS avg_score, + MAX(e.score) AS best_score + FROM users u + CROSS JOIN courses c + LEFT JOIN exams e + ON e.user_id = u.id + AND e.course_id = c.id + AND e.status = 'submitted' + GROUP BY u.id, c.id + """) + + +def downgrade(): + # Drop the view to rollback + try: + op.execute("DROP VIEW IF EXISTS v_user_course_progress") + except Exception: + pass + + # Note: Full downgrade to previous heterogeneous state is not implemented to avoid data loss. + # Keep this as a no-op for column-level reversions. + pass + + diff --git a/backend/migrations/versions/20250922_add_positions_table.py b/backend/migrations/versions/20250922_add_positions_table.py new file mode 100644 index 0000000..588120b --- /dev/null +++ b/backend/migrations/versions/20250922_add_positions_table.py @@ -0,0 +1,55 @@ +""" +add positions table + +Revision ID: 20250922_add_positions +Revises: 20250921_align_schema_to_design +Create Date: 2025-09-22 +""" + +from alembic import op +import sqlalchemy as sa +from sqlalchemy import inspect + + +# revision identifiers, used by Alembic. +revision = "20250922_add_positions" +down_revision = "20250921_align_schema_to_design" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = inspect(bind) + + if not inspector.has_table("positions"): + op.create_table( + "positions", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("name", sa.String(length=100), nullable=False), + sa.Column("code", sa.String(length=100), nullable=False, unique=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("parent_id", sa.Integer(), sa.ForeignKey("positions.id", ondelete="SET NULL"), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default="active"), + sa.Column("is_deleted", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + sa.Column("created_by", sa.Integer(), nullable=True), + sa.Column("updated_by", sa.Integer(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + ) + + # 创建索引(若不存在) + try: + indexes = [ix.get("name") for ix in inspector.get_indexes("positions")] # type: ignore + except Exception: + indexes = [] + if "ix_positions_name" not in indexes: + op.create_index("ix_positions_name", "positions", ["name"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_positions_name", table_name="positions") + op.drop_table("positions") + + diff --git a/backend/migrations/versions/3d5b88fe1875_merge_multiple_heads.py b/backend/migrations/versions/3d5b88fe1875_merge_multiple_heads.py new file mode 100644 index 0000000..ddad6b0 --- /dev/null +++ b/backend/migrations/versions/3d5b88fe1875_merge_multiple_heads.py @@ -0,0 +1,26 @@ +"""merge_multiple_heads + +Revision ID: 3d5b88fe1875 +Revises: 20250922_add_positions, add_users_soft_delete +Create Date: 2025-09-22 08:09:11.966673 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = '3d5b88fe1875' +down_revision: Union[str, None] = ('20250922_add_positions', 'add_users_soft_delete') +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + pass + + +def downgrade() -> None: + pass \ No newline at end of file diff --git a/backend/migrations/versions/5448c81e7afd_add_position_members_table.py b/backend/migrations/versions/5448c81e7afd_add_position_members_table.py new file mode 100644 index 0000000..f42855d --- /dev/null +++ b/backend/migrations/versions/5448c81e7afd_add_position_members_table.py @@ -0,0 +1,46 @@ +"""add_position_members_table + +Revision ID: 5448c81e7afd +Revises: 3d5b88fe1875 +Create Date: 2025-09-22 08:13:54.755269 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '5448c81e7afd' +down_revision: Union[str, None] = '3d5b88fe1875' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('position_members', + sa.Column('position_id', sa.Integer(), nullable=False, comment='岗位ID'), + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'), + sa.Column('role', sa.String(length=50), nullable=True, comment='成员角色(预留字段)'), + sa.Column('joined_at', sa.DateTime(), nullable=True, comment='加入时间'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(['position_id'], ['positions.id'], ), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('position_id', 'user_id', 'is_deleted', name='uix_position_user') + ) + op.create_index(op.f('ix_position_members_id'), 'position_members', ['id'], unique=False) + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.drop_index(op.f('ix_position_members_id'), table_name='position_members') + op.drop_table('position_members') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/migrations/versions/9245f8845fe1_add_training_models.py b/backend/migrations/versions/9245f8845fe1_add_training_models.py new file mode 100644 index 0000000..2444d9b --- /dev/null +++ b/backend/migrations/versions/9245f8845fe1_add_training_models.py @@ -0,0 +1,708 @@ +"""add training models + +Revision ID: 9245f8845fe1 +Revises: 001 +Create Date: 2025-09-21 22:11:03.319902 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import mysql + +# revision identifiers, used by Alembic. +revision: str = '9245f8845fe1' +down_revision: Union[str, None] = None +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('training_scenes', + sa.Column('name', sa.String(length=100), nullable=False, comment='场景名称'), + sa.Column('description', sa.Text(), nullable=True, comment='场景描述'), + sa.Column('category', sa.String(length=50), nullable=False, comment='场景分类'), + sa.Column('ai_config', sa.JSON(), nullable=True, comment='AI配置(如Coze Bot ID等)'), + sa.Column('prompt_template', sa.Text(), nullable=True, comment='提示词模板'), + sa.Column('evaluation_criteria', sa.JSON(), nullable=True, comment='评估标准'), + sa.Column('status', sa.Enum('DRAFT', 'ACTIVE', 'INACTIVE', name='trainingscenestatus'), nullable=False, comment='场景状态'), + sa.Column('is_public', sa.Boolean(), nullable=False, comment='是否公开'), + sa.Column('required_level', sa.Integer(), nullable=True, comment='所需用户等级'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('is_deleted', sa.Boolean(), nullable=False), + sa.Column('deleted_at', sa.DateTime(), nullable=True), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_training_scenes_id'), 'training_scenes', ['id'], unique=False) + op.create_table('training_sessions', + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'), + sa.Column('scene_id', sa.Integer(), nullable=False, comment='场景ID'), + sa.Column('coze_conversation_id', sa.String(length=100), nullable=True, comment='Coze会话ID'), + sa.Column('start_time', sa.DateTime(), nullable=False, comment='开始时间'), + sa.Column('end_time', sa.DateTime(), nullable=True, comment='结束时间'), + sa.Column('duration_seconds', sa.Integer(), nullable=True, comment='持续时长(秒)'), + sa.Column('status', sa.Enum('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR', name='trainingsessionstatus'), nullable=False, comment='会话状态'), + sa.Column('session_config', sa.JSON(), nullable=True, comment='会话配置'), + sa.Column('total_score', sa.Float(), nullable=True, comment='总分'), + sa.Column('evaluation_result', sa.JSON(), nullable=True, comment='评估结果详情'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['scene_id'], ['training_scenes.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_training_sessions_id'), 'training_sessions', ['id'], unique=False) + op.create_index(op.f('ix_training_sessions_user_id'), 'training_sessions', ['user_id'], unique=False) + op.create_table('training_messages', + sa.Column('session_id', sa.Integer(), nullable=False, comment='会话ID'), + sa.Column('role', sa.Enum('USER', 'ASSISTANT', 'SYSTEM', name='messagerole'), nullable=False, comment='消息角色'), + sa.Column('type', sa.Enum('TEXT', 'VOICE', 'SYSTEM', name='messagetype'), nullable=False, comment='消息类型'), + sa.Column('content', sa.Text(), nullable=False, comment='消息内容'), + sa.Column('voice_url', sa.String(length=500), nullable=True, comment='语音文件URL'), + sa.Column('voice_duration', sa.Float(), nullable=True, comment='语音时长(秒)'), + sa.Column('message_metadata', sa.JSON(), nullable=True, comment='消息元数据'), + sa.Column('coze_message_id', sa.String(length=100), nullable=True, comment='Coze消息ID'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['session_id'], ['training_sessions.id'], ), + sa.PrimaryKeyConstraint('id') + ) + op.create_index(op.f('ix_training_messages_id'), 'training_messages', ['id'], unique=False) + op.create_table('training_reports', + sa.Column('session_id', sa.Integer(), nullable=False, comment='会话ID'), + sa.Column('user_id', sa.Integer(), nullable=False, comment='用户ID'), + sa.Column('overall_score', sa.Float(), nullable=False, comment='总体得分'), + sa.Column('dimension_scores', sa.JSON(), nullable=False, comment='各维度得分'), + sa.Column('strengths', sa.JSON(), nullable=False, comment='优势点'), + sa.Column('weaknesses', sa.JSON(), nullable=False, comment='待改进点'), + sa.Column('suggestions', sa.JSON(), nullable=False, comment='改进建议'), + sa.Column('detailed_analysis', sa.Text(), nullable=True, comment='详细分析'), + sa.Column('transcript', sa.Text(), nullable=True, comment='对话文本记录'), + sa.Column('statistics', sa.JSON(), nullable=True, comment='统计数据'), + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('created_at', sa.DateTime(), nullable=False), + sa.Column('updated_at', sa.DateTime(), nullable=False), + sa.Column('created_by', sa.Integer(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['session_id'], ['training_sessions.id'], ), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('session_id') + ) + op.create_index(op.f('ix_training_reports_id'), 'training_reports', ['id'], unique=False) + op.create_index(op.f('ix_training_reports_user_id'), 'training_reports', ['user_id'], unique=False) + op.create_table('user_teams', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('team_id', sa.Integer(), nullable=False), + sa.Column('role', sa.String(length=50), nullable=False), + sa.Column('joined_at', sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(['team_id'], ['teams.id'], ondelete='CASCADE'), + sa.ForeignKeyConstraint(['user_id'], ['users.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('user_id', 'team_id'), + sa.UniqueConstraint('user_id', 'team_id', name='uq_user_team') + ) + op.alter_column('course_materials', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.alter_column('course_materials', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='资料名称', + existing_comment='资料å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('course_materials', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='资料描述', + existing_comment='资料æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('course_materials', 'file_url', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='文件URL', + existing_comment='文件URL', + existing_nullable=False) + op.alter_column('course_materials', 'file_type', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50), + comment='文件类型', + existing_comment='文件类型', + existing_nullable=False) + op.alter_column('course_materials', 'file_size', + existing_type=mysql.INTEGER(), + comment='文件大小(字节)', + existing_comment='文件大å°\x8f(å\xad—节)', + existing_nullable=False) + op.alter_column('course_materials', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.drop_index('idx_course_materials_course_id', table_name='course_materials') + op.drop_index('idx_course_materials_is_deleted', table_name='course_materials') + op.create_index(op.f('ix_course_materials_id'), 'course_materials', ['id'], unique=False) + op.drop_table_comment( + 'course_materials', + existing_comment='课程资料表', + schema=None + ) + op.alter_column('courses', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='课程名称', + existing_comment='课程å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('courses', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='课程描述', + existing_comment='课程æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('courses', 'category', + existing_type=mysql.ENUM('technology', 'management', 'business', 'general', collation='utf8mb4_unicode_ci'), + comment='课程分类', + existing_comment='课程分类', + existing_nullable=False, + existing_server_default=sa.text("'general'")) + op.alter_column('courses', 'status', + existing_type=mysql.ENUM('draft', 'published', 'archived', collation='utf8mb4_unicode_ci'), + comment='课程状态', + existing_comment='课程状æ€\x81', + existing_nullable=False, + existing_server_default=sa.text("'draft'")) + op.alter_column('courses', 'cover_image', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='封面图片URL', + existing_comment='å°\x81é\x9d¢å›¾ç‰‡URL', + existing_nullable=True) + op.alter_column('courses', 'duration_hours', + existing_type=mysql.FLOAT(), + comment='课程时长(小时)', + existing_comment='课程时长(å°\x8fæ—¶)', + existing_nullable=True) + op.alter_column('courses', 'difficulty_level', + existing_type=mysql.INTEGER(), + comment='难度等级(1-5)', + existing_comment='难度ç\xad‰çº§(1-5)', + existing_nullable=True) + op.alter_column('courses', 'tags', + existing_type=mysql.JSON(), + comment='标签列表', + existing_comment='æ\xa0‡ç\xad¾åˆ—表', + existing_nullable=True) + op.alter_column('courses', 'published_at', + existing_type=mysql.DATETIME(), + comment='发布时间', + existing_comment='å\x8f‘布时间', + existing_nullable=True) + op.alter_column('courses', 'publisher_id', + existing_type=mysql.INTEGER(), + comment='发布人ID', + existing_comment='å\x8f‘布人ID', + existing_nullable=True) + op.alter_column('courses', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('courses', 'is_featured', + existing_type=mysql.TINYINT(display_width=1), + comment='是否推荐', + existing_comment='是å\x90¦æŽ¨è\x8d\x90', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.drop_index('idx_courses_category', table_name='courses') + op.drop_index('idx_courses_is_deleted', table_name='courses') + op.drop_index('idx_courses_status', table_name='courses') + op.create_index(op.f('ix_courses_id'), 'courses', ['id'], unique=False) + op.drop_table_comment( + 'courses', + existing_comment='课程表', + schema=None + ) + op.alter_column('growth_paths', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='路径名称', + existing_comment='路径å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('growth_paths', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='路径描述', + existing_comment='路径æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('growth_paths', 'target_role', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=100), + comment='目标角色', + existing_comment='ç›®æ\xa0‡è§’色', + existing_nullable=True) + op.alter_column('growth_paths', 'courses', + existing_type=mysql.JSON(), + comment='课程列表[{course_id, order, is_required}]', + existing_comment='课程列表[{course_id, order, is_required}]', + existing_nullable=True) + op.alter_column('growth_paths', 'estimated_duration_days', + existing_type=mysql.INTEGER(), + comment='预计完成天数', + existing_comment='预计完æˆ\x90天数', + existing_nullable=True) + op.alter_column('growth_paths', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + comment='是否启用', + existing_comment='是å\x90¦å\x90¯ç”¨', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('growth_paths', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.drop_index('idx_growth_paths_is_active', table_name='growth_paths') + op.drop_index('idx_growth_paths_is_deleted', table_name='growth_paths') + op.create_index(op.f('ix_growth_paths_id'), 'growth_paths', ['id'], unique=False) + op.drop_table_comment( + 'growth_paths', + existing_comment='æˆ\x90长路径表', + schema=None + ) + op.alter_column('knowledge_points', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.alter_column('knowledge_points', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='知识点名称', + existing_comment='知识点å\x90\x8dç§°', + existing_nullable=False) + op.alter_column('knowledge_points', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='知识点描述', + existing_comment='知识点æ\x8f\x8fè¿°', + existing_nullable=True) + op.alter_column('knowledge_points', 'parent_id', + existing_type=mysql.INTEGER(), + comment='父知识点ID', + existing_comment='父知识点ID', + existing_nullable=True) + op.alter_column('knowledge_points', 'level', + existing_type=mysql.INTEGER(), + comment='层级深度', + existing_comment='层级深度', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='路径(如: 1.2.3)', + existing_comment='路径(如: 1.2.3)', + existing_nullable=True) + op.alter_column('knowledge_points', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排序顺序', + existing_comment='排åº\x8f顺åº\x8f', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('knowledge_points', 'weight', + existing_type=mysql.FLOAT(), + comment='权重', + existing_comment='æ\x9dƒé‡\x8d', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'is_required', + existing_type=mysql.TINYINT(display_width=1), + comment='是否必修', + existing_comment='是å\x90¦å¿…ä¿®', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'estimated_hours', + existing_type=mysql.FLOAT(), + comment='预计学习时间(小时)', + existing_comment='预计å\xad¦ä¹\xa0æ—¶é—´(å°\x8fæ—¶)', + existing_nullable=True) + op.drop_index('idx_knowledge_points_course_id', table_name='knowledge_points') + op.drop_index('idx_knowledge_points_is_deleted', table_name='knowledge_points') + op.drop_index('idx_knowledge_points_parent_id', table_name='knowledge_points') + op.create_index(op.f('ix_knowledge_points_id'), 'knowledge_points', ['id'], unique=False) + op.drop_table_comment( + 'knowledge_points', + existing_comment='知识点表', + schema=None + ) + op.drop_index('ix_teams_is_deleted', table_name='teams') + op.drop_index('ix_teams_parent_id', table_name='teams') + op.create_foreign_key(None, 'teams', 'users', ['leader_id'], ['id'], ondelete='SET NULL') + op.create_foreign_key(None, 'teams', 'teams', ['parent_id'], ['id'], ondelete='CASCADE') + op.drop_column('teams', 'deleted_at') + op.drop_column('teams', 'created_by') + op.drop_column('teams', 'updated_by') + op.drop_column('teams', 'is_deleted') + op.add_column('users', sa.Column('bio', sa.Text(), nullable=True)) + op.add_column('users', sa.Column('is_verified', sa.Boolean(), nullable=False)) + op.add_column('users', sa.Column('last_login_at', sa.DateTime(), nullable=True)) + op.add_column('users', sa.Column('password_changed_at', sa.DateTime(), nullable=True)) + op.alter_column('users', 'username', + existing_type=mysql.VARCHAR(length=50), + comment=None, + existing_comment='用户名', + existing_nullable=False) + op.alter_column('users', 'email', + existing_type=mysql.VARCHAR(length=100), + comment=None, + existing_comment='邮箱', + existing_nullable=False) + op.alter_column('users', 'phone', + existing_type=mysql.VARCHAR(length=20), + comment=None, + existing_comment='手机号', + existing_nullable=True) + op.alter_column('users', 'password_hash', + existing_type=mysql.VARCHAR(length=200), + comment=None, + existing_comment='密码哈希', + existing_nullable=False) + op.alter_column('users', 'full_name', + existing_type=mysql.VARCHAR(length=100), + comment=None, + existing_comment='全名', + existing_nullable=True) + op.alter_column('users', 'avatar_url', + existing_type=mysql.VARCHAR(length=500), + comment=None, + existing_comment='头像URL', + existing_nullable=True) + op.alter_column('users', 'role', + existing_type=mysql.VARCHAR(length=20), + nullable=False, + comment=None, + existing_comment='角色: trainee(学员), manager(管理者), admin(管理员)') + op.alter_column('users', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + nullable=False, + comment=None, + existing_comment='是否激活') + op.alter_column('users', 'id', + existing_type=mysql.BIGINT(), + type_=sa.Integer(), + existing_nullable=False, + autoincrement=True) + op.create_unique_constraint(None, 'users', ['phone']) + op.drop_column('users', 'position') + op.drop_column('users', 'is_superuser') + op.drop_column('users', 'locked_until') + op.drop_column('users', 'login_count') + op.drop_column('users', 'failed_login_count') + op.drop_column('users', 'department') + op.drop_column('users', 'created_by') + op.drop_column('users', 'updated_by') + op.drop_column('users', 'last_login') + # ### end Alembic commands ### + + +def downgrade() -> None: + # ### commands auto generated by Alembic - please adjust! ### + op.add_column('users', sa.Column('last_login', mysql.VARCHAR(length=100), nullable=True, comment='最后登录时间')) + op.add_column('users', sa.Column('updated_by', mysql.BIGINT(), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('created_by', mysql.BIGINT(), autoincrement=False, nullable=True)) + op.add_column('users', sa.Column('department', mysql.VARCHAR(length=100), nullable=True, comment='部门')) + op.add_column('users', sa.Column('failed_login_count', mysql.VARCHAR(length=100), nullable=True, comment='失败登录次数')) + op.add_column('users', sa.Column('login_count', mysql.VARCHAR(length=100), nullable=True, comment='登录次数')) + op.add_column('users', sa.Column('locked_until', mysql.VARCHAR(length=100), nullable=True, comment='锁定到期时间')) + op.add_column('users', sa.Column('is_superuser', mysql.TINYINT(display_width=1), autoincrement=False, nullable=True, comment='是否超级管理员')) + op.add_column('users', sa.Column('position', mysql.VARCHAR(length=100), nullable=True, comment='职位')) + op.drop_constraint(None, 'users', type_='unique') + op.alter_column('users', 'id', + existing_type=sa.Integer(), + type_=mysql.BIGINT(), + existing_nullable=False, + autoincrement=True) + op.alter_column('users', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + nullable=True, + comment='是否激活') + op.alter_column('users', 'role', + existing_type=mysql.VARCHAR(length=20), + nullable=True, + comment='角色: trainee(学员), manager(管理者), admin(管理员)') + op.alter_column('users', 'avatar_url', + existing_type=mysql.VARCHAR(length=500), + comment='头像URL', + existing_nullable=True) + op.alter_column('users', 'full_name', + existing_type=mysql.VARCHAR(length=100), + comment='全名', + existing_nullable=True) + op.alter_column('users', 'password_hash', + existing_type=mysql.VARCHAR(length=200), + comment='密码哈希', + existing_nullable=False) + op.alter_column('users', 'phone', + existing_type=mysql.VARCHAR(length=20), + comment='手机号', + existing_nullable=True) + op.alter_column('users', 'email', + existing_type=mysql.VARCHAR(length=100), + comment='邮箱', + existing_nullable=False) + op.alter_column('users', 'username', + existing_type=mysql.VARCHAR(length=50), + comment='用户名', + existing_nullable=False) + op.drop_column('users', 'password_changed_at') + op.drop_column('users', 'last_login_at') + op.drop_column('users', 'is_verified') + op.drop_column('users', 'bio') + op.add_column('teams', sa.Column('is_deleted', mysql.TINYINT(display_width=1), server_default=sa.text("'0'"), autoincrement=False, nullable=False)) + op.add_column('teams', sa.Column('updated_by', mysql.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('teams', sa.Column('created_by', mysql.INTEGER(), autoincrement=False, nullable=True)) + op.add_column('teams', sa.Column('deleted_at', mysql.DATETIME(), nullable=True)) + op.drop_constraint(None, 'teams', type_='foreignkey') + op.drop_constraint(None, 'teams', type_='foreignkey') + op.create_index('ix_teams_parent_id', 'teams', ['parent_id'], unique=False) + op.create_index('ix_teams_is_deleted', 'teams', ['is_deleted'], unique=False) + op.create_table_comment( + 'knowledge_points', + '知识点表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_knowledge_points_id'), table_name='knowledge_points') + op.create_index('idx_knowledge_points_parent_id', 'knowledge_points', ['parent_id'], unique=False) + op.create_index('idx_knowledge_points_is_deleted', 'knowledge_points', ['is_deleted'], unique=False) + op.create_index('idx_knowledge_points_course_id', 'knowledge_points', ['course_id'], unique=False) + op.alter_column('knowledge_points', 'estimated_hours', + existing_type=mysql.FLOAT(), + comment='预计å\xad¦ä¹\xa0æ—¶é—´(å°\x8fæ—¶)', + existing_comment='预计学习时间(小时)', + existing_nullable=True) + op.alter_column('knowledge_points', 'is_required', + existing_type=mysql.TINYINT(display_width=1), + comment='是å\x90¦å¿…ä¿®', + existing_comment='是否必修', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'weight', + existing_type=mysql.FLOAT(), + comment='æ\x9dƒé‡\x8d', + existing_comment='权重', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('knowledge_points', 'path', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='路径(如: 1.2.3)', + existing_comment='路径(如: 1.2.3)', + existing_nullable=True) + op.alter_column('knowledge_points', 'level', + existing_type=mysql.INTEGER(), + comment='层级深度', + existing_comment='层级深度', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('knowledge_points', 'parent_id', + existing_type=mysql.INTEGER(), + comment='父知识点ID', + existing_comment='父知识点ID', + existing_nullable=True) + op.alter_column('knowledge_points', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='知识点æ\x8f\x8fè¿°', + existing_comment='知识点描述', + existing_nullable=True) + op.alter_column('knowledge_points', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='知识点å\x90\x8dç§°', + existing_comment='知识点名称', + existing_nullable=False) + op.alter_column('knowledge_points', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.create_table_comment( + 'growth_paths', + 'æˆ\x90长路径表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_growth_paths_id'), table_name='growth_paths') + op.create_index('idx_growth_paths_is_deleted', 'growth_paths', ['is_deleted'], unique=False) + op.create_index('idx_growth_paths_is_active', 'growth_paths', ['is_active'], unique=False) + op.alter_column('growth_paths', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('growth_paths', 'is_active', + existing_type=mysql.TINYINT(display_width=1), + comment='是å\x90¦å\x90¯ç”¨', + existing_comment='是否启用', + existing_nullable=False, + existing_server_default=sa.text("'1'")) + op.alter_column('growth_paths', 'estimated_duration_days', + existing_type=mysql.INTEGER(), + comment='预计完æˆ\x90天数', + existing_comment='预计完成天数', + existing_nullable=True) + op.alter_column('growth_paths', 'courses', + existing_type=mysql.JSON(), + comment='课程列表[{course_id, order, is_required}]', + existing_comment='课程列表[{course_id, order, is_required}]', + existing_nullable=True) + op.alter_column('growth_paths', 'target_role', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=100), + comment='ç›®æ\xa0‡è§’色', + existing_comment='目标角色', + existing_nullable=True) + op.alter_column('growth_paths', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='路径æ\x8f\x8fè¿°', + existing_comment='路径描述', + existing_nullable=True) + op.alter_column('growth_paths', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='路径å\x90\x8dç§°', + existing_comment='路径名称', + existing_nullable=False) + op.create_table_comment( + 'courses', + '课程表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_courses_id'), table_name='courses') + op.create_index('idx_courses_status', 'courses', ['status'], unique=False) + op.create_index('idx_courses_is_deleted', 'courses', ['is_deleted'], unique=False) + op.create_index('idx_courses_category', 'courses', ['category'], unique=False) + op.alter_column('courses', 'is_featured', + existing_type=mysql.TINYINT(display_width=1), + comment='是å\x90¦æŽ¨è\x8d\x90', + existing_comment='是否推荐', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('courses', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('courses', 'publisher_id', + existing_type=mysql.INTEGER(), + comment='å\x8f‘布人ID', + existing_comment='发布人ID', + existing_nullable=True) + op.alter_column('courses', 'published_at', + existing_type=mysql.DATETIME(), + comment='å\x8f‘布时间', + existing_comment='发布时间', + existing_nullable=True) + op.alter_column('courses', 'tags', + existing_type=mysql.JSON(), + comment='æ\xa0‡ç\xad¾åˆ—表', + existing_comment='标签列表', + existing_nullable=True) + op.alter_column('courses', 'difficulty_level', + existing_type=mysql.INTEGER(), + comment='难度ç\xad‰çº§(1-5)', + existing_comment='难度等级(1-5)', + existing_nullable=True) + op.alter_column('courses', 'duration_hours', + existing_type=mysql.FLOAT(), + comment='课程时长(å°\x8fæ—¶)', + existing_comment='课程时长(小时)', + existing_nullable=True) + op.alter_column('courses', 'cover_image', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='å°\x81é\x9d¢å›¾ç‰‡URL', + existing_comment='封面图片URL', + existing_nullable=True) + op.alter_column('courses', 'status', + existing_type=mysql.ENUM('draft', 'published', 'archived', collation='utf8mb4_unicode_ci'), + comment='课程状æ€\x81', + existing_comment='课程状态', + existing_nullable=False, + existing_server_default=sa.text("'draft'")) + op.alter_column('courses', 'category', + existing_type=mysql.ENUM('technology', 'management', 'business', 'general', collation='utf8mb4_unicode_ci'), + comment='课程分类', + existing_comment='课程分类', + existing_nullable=False, + existing_server_default=sa.text("'general'")) + op.alter_column('courses', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='课程æ\x8f\x8fè¿°', + existing_comment='课程描述', + existing_nullable=True) + op.alter_column('courses', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='课程å\x90\x8dç§°', + existing_comment='课程名称', + existing_nullable=False) + op.create_table_comment( + 'course_materials', + '课程资料表', + existing_comment=None, + schema=None + ) + op.drop_index(op.f('ix_course_materials_id'), table_name='course_materials') + op.create_index('idx_course_materials_is_deleted', 'course_materials', ['is_deleted'], unique=False) + op.create_index('idx_course_materials_course_id', 'course_materials', ['course_id'], unique=False) + op.alter_column('course_materials', 'sort_order', + existing_type=mysql.INTEGER(), + comment='排åº\x8f顺åº\x8f', + existing_comment='排序顺序', + existing_nullable=False, + existing_server_default=sa.text("'0'")) + op.alter_column('course_materials', 'file_size', + existing_type=mysql.INTEGER(), + comment='文件大å°\x8f(å\xad—节)', + existing_comment='文件大小(字节)', + existing_nullable=False) + op.alter_column('course_materials', 'file_type', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=50), + comment='文件类型', + existing_comment='文件类型', + existing_nullable=False) + op.alter_column('course_materials', 'file_url', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=500), + comment='文件URL', + existing_comment='文件URL', + existing_nullable=False) + op.alter_column('course_materials', 'description', + existing_type=mysql.TEXT(collation='utf8mb4_unicode_ci'), + comment='资料æ\x8f\x8fè¿°', + existing_comment='资料描述', + existing_nullable=True) + op.alter_column('course_materials', 'name', + existing_type=mysql.VARCHAR(collation='utf8mb4_unicode_ci', length=200), + comment='资料å\x90\x8dç§°', + existing_comment='资料名称', + existing_nullable=False) + op.alter_column('course_materials', 'course_id', + existing_type=mysql.INTEGER(), + comment='课程ID', + existing_comment='课程ID', + existing_nullable=False) + op.drop_table('user_teams') + op.drop_index(op.f('ix_training_reports_user_id'), table_name='training_reports') + op.drop_index(op.f('ix_training_reports_id'), table_name='training_reports') + op.drop_table('training_reports') + op.drop_index(op.f('ix_training_messages_id'), table_name='training_messages') + op.drop_table('training_messages') + op.drop_index(op.f('ix_training_sessions_user_id'), table_name='training_sessions') + op.drop_index(op.f('ix_training_sessions_id'), table_name='training_sessions') + op.drop_table('training_sessions') + op.drop_index(op.f('ix_training_scenes_id'), table_name='training_scenes') + op.drop_table('training_scenes') + # ### end Alembic commands ### \ No newline at end of file diff --git a/backend/migrations/versions/add_position_skills_level.py b/backend/migrations/versions/add_position_skills_level.py new file mode 100644 index 0000000..d5924c5 --- /dev/null +++ b/backend/migrations/versions/add_position_skills_level.py @@ -0,0 +1,35 @@ +"""Add skills and level fields to positions table + +Revision ID: add_position_skills_level +Revises: 0487635b5e95 +Create Date: 2025-09-22 09:00:00 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'add_position_skills_level' +down_revision = '0487635b5e95' +branch_labels = None +depends_on = None + + +def upgrade(): + """添加skills和level字段到positions表""" + # 添加skills字段(JSON类型存储技能数组) + op.add_column('positions', sa.Column('skills', sa.JSON, nullable=True, comment='核心技能')) + + # 添加level字段(岗位等级) + op.add_column('positions', sa.Column('level', sa.String(20), nullable=True, comment='岗位等级: junior/intermediate/senior/expert')) + + # 添加sort_order字段(排序) + op.add_column('positions', sa.Column('sort_order', sa.Integer, nullable=True, default=0, comment='排序')) + + +def downgrade(): + """移除添加的字段""" + op.drop_column('positions', 'skills') + op.drop_column('positions', 'level') + op.drop_column('positions', 'sort_order') diff --git a/backend/migrations/versions/add_users_soft_delete.py b/backend/migrations/versions/add_users_soft_delete.py new file mode 100644 index 0000000..e39e6fa --- /dev/null +++ b/backend/migrations/versions/add_users_soft_delete.py @@ -0,0 +1,36 @@ +"""add users soft delete columns + +Revision ID: add_users_soft_delete +Revises: 20250921_align_schema_to_design +Create Date: 2025-09-22 03:00:00 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = 'add_users_soft_delete' +down_revision: Union[str, None] = '20250921_align_schema_to_design' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + # 为 users 表添加软删除字段 + op.add_column('users', sa.Column('is_deleted', sa.Boolean(), nullable=False, server_default='0')) + op.add_column('users', sa.Column('deleted_at', sa.DateTime(), nullable=True)) + + # 添加索引 + op.create_index('idx_users_is_deleted', 'users', ['is_deleted']) + + +def downgrade() -> None: + # 删除索引 + op.drop_index('idx_users_is_deleted', table_name='users') + + # 删除字段 + op.drop_column('users', 'deleted_at') + op.drop_column('users', 'is_deleted') diff --git a/backend/mysql.cnf b/backend/mysql.cnf new file mode 100644 index 0000000..62c6aa4 --- /dev/null +++ b/backend/mysql.cnf @@ -0,0 +1,46 @@ +[mysqld] +# 字符集配置 +character-set-server = utf8mb4 +collation-server = utf8mb4_unicode_ci + +# 时区配置 - 使用北京时间(东八区) +default-time-zone = '+08:00' + +# 网络配置 - 允许远程连接 +bind-address = 0.0.0.0 +port = 3306 + +# 连接配置 +max_connections = 200 +max_user_connections = 180 + +# 缓冲池配置 +innodb_buffer_pool_size = 256M +innodb_log_file_size = 64M + +# 超时配置 +wait_timeout = 600 +interactive_timeout = 600 + +# 慢查询日志 +slow_query_log = 1 +slow_query_log_file = /var/log/mysql/slow.log +long_query_time = 2 + +# 错误日志 +log_error = /var/log/mysql/error.log + +# 二进制日志 +log_bin = /var/log/mysql/mysql-bin.log +binlog_format = ROW +expire_logs_days = 7 + +# 安全配置 +local_infile = 0 +secure_file_priv = "" + +[mysql] +default-character-set = utf8mb4 + +[client] +default-character-set = utf8mb4 diff --git a/backend/pyproject.toml b/backend/pyproject.toml new file mode 100644 index 0000000..962f569 --- /dev/null +++ b/backend/pyproject.toml @@ -0,0 +1,63 @@ +[tool.black] +line-length = 88 +target-version = ['py38'] +include = '\.pyi?$' +extend-exclude = ''' +/( + # directories + \.eggs + | \.git + | \.hg + | \.mypy_cache + | \.tox + | \.venv + | build + | dist + | migrations +)/ +''' + +[tool.isort] +profile = "black" +multi_line_output = 3 +include_trailing_comma = true +force_grid_wrap = 0 +use_parentheses = true +ensure_newline_before_comments = true +line_length = 88 +skip_gitignore = true + +[tool.mypy] +python_version = "3.8" +warn_return_any = true +warn_unused_configs = true +disallow_untyped_defs = true +disallow_incomplete_defs = true +check_untyped_defs = true +disallow_untyped_decorators = true +no_implicit_optional = true +warn_redundant_casts = true +warn_unused_ignores = true +warn_no_return = true +warn_unreachable = true +strict_equality = true + +[tool.pytest.ini_options] +minversion = "6.0" +addopts = "-ra -q" +testpaths = [ + "tests", +] + +[tool.coverage.run] +source = ["app"] +omit = ["*/tests/*", "*/migrations/*", "*/__init__.py"] + +[tool.coverage.report] +precision = 2 +show_missing = true +skip_covered = false + +[build-system] +requires = ["setuptools>=45", "wheel", "setuptools_scm[toml]>=6.2"] +build-backend = "setuptools.build_meta" diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..bd4ac84 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,9 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +asyncio_mode = auto +addopts = -v --tb=short --strict-markers +markers = + asyncio: marks tests as async diff --git a/backend/requirements-admin.txt b/backend/requirements-admin.txt new file mode 100644 index 0000000..6ef4960 --- /dev/null +++ b/backend/requirements-admin.txt @@ -0,0 +1,20 @@ +# 超级后台额外依赖 +# 用于管理后台独立认证和数据库连接 + +# JWT 认证 +PyJWT==2.8.0 + +# MySQL 同步驱动(用于管理后台直连) +PyMySQL==1.1.0 + +# 开发工具(热重载支持) +watchfiles==0.21.0 + + + + + + + + + diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..bf43bfa --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,18 @@ +-r requirements.txt + +# 测试 +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +# 统一 httpx 版本到 requirements.txt(0.24.1),避免冲突 + +# 代码质量 +black==23.11.0 +isort==5.12.0 +flake8==6.1.0 +mypy==1.7.1 +pylint==3.0.2 + +# 开发工具 +ipython==8.17.2 +watchdog==3.0.0 diff --git a/backend/requirements.txt b/backend/requirements.txt new file mode 100644 index 0000000..8b4b544 --- /dev/null +++ b/backend/requirements.txt @@ -0,0 +1,53 @@ +# Web框架 +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +python-multipart==0.0.6 +sse-starlette==1.8.2 + +# 数据库 +sqlalchemy==2.0.23 +aiomysql==0.2.0 +alembic==1.12.1 + +# Redis +redis==5.0.1 +aioredis==2.0.1 + +# 数据验证 +pydantic==2.5.0 +pydantic-settings==2.1.0 +email-validator==2.1.0 + +# 认证和安全 +python-jose[cryptography]==3.3.0 +passlib==1.7.4 +bcrypt==4.1.2 +python-dotenv==1.0.0 +PyJWT==2.8.0 +PyMySQL==1.1.0 + +# HTTP客户端 +# 与 cozepy==0.19.0 兼容(cozepy 依赖 httpx >= 0.27.0 且 < 0.28.0) +httpx==0.27.2 +aiofiles==23.2.1 + +# 日志 +structlog==23.2.0 + +# AI平台SDK +cozepy==0.19.0 + +# 工具库 +python-dateutil==2.8.2 +tenacity==8.2.3 + +# Excel文件处理(用于课程资料预览) +openpyxl==3.1.2 + +# LLM JSON 解析(知识点分析服务) +json-repair>=0.25.0 +jsonschema>=4.0.0 + +# PDF 文档提取 +PyPDF2>=3.0.0 +python-docx>=1.0.0 \ No newline at end of file diff --git a/backend/requirements/__init__.py b/backend/requirements/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/requirements/base.txt b/backend/requirements/base.txt new file mode 100644 index 0000000..77713c3 --- /dev/null +++ b/backend/requirements/base.txt @@ -0,0 +1,34 @@ +# Core dependencies +fastapi==0.104.1 +uvicorn[standard]==0.24.0 +pydantic==2.5.0 +pydantic-settings==2.1.0 +python-multipart==0.0.6 +python-jose[cryptography]==3.3.0 +passlib[bcrypt]==1.7.4 +python-dotenv==1.0.0 + +# Database +sqlalchemy==2.0.23 +aiomysql==0.2.0 +pymysql==1.1.0 +alembic==1.12.1 + +# Redis +redis==5.0.1 + +# HTTP client +httpx==0.25.2 +aiofiles==23.2.1 + +# Logging +structlog==23.2.0 + +# CORS +fastapi-cors==0.0.6 + +# Utils +python-dateutil==2.8.2 + +# Excel文件处理(用于课程资料预览) +openpyxl==3.1.2 \ No newline at end of file diff --git a/backend/requirements/dev.txt b/backend/requirements/dev.txt new file mode 100644 index 0000000..ed90d12 --- /dev/null +++ b/backend/requirements/dev.txt @@ -0,0 +1,21 @@ +# Include base requirements +-r base.txt + +# Testing +pytest==7.4.3 +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +httpx==0.25.2 +aiosqlite==0.19.0 + +# Code quality +black==23.11.0 +isort==5.12.0 +flake8==6.1.0 +mypy==1.7.1 +types-python-dateutil==2.8.19.14 +types-redis==4.6.0.11 + +# Development tools +ipython==8.18.1 +watchfiles==0.21.0 \ No newline at end of file diff --git a/backend/requirements/prod.txt b/backend/requirements/prod.txt new file mode 100644 index 0000000..9f136df --- /dev/null +++ b/backend/requirements/prod.txt @@ -0,0 +1,10 @@ +# 包含基础依赖 +-r base.txt + +# 生产环境监控 +prometheus-client==0.19.0 +sentry-sdk[fastapi]==1.39.1 + +# 性能优化 +orjson==3.9.10 +ujson==5.9.0 diff --git a/backend/scripts/__init__.py b/backend/scripts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/scripts/add_admin_exam_data.sql b/backend/scripts/add_admin_exam_data.sql new file mode 100644 index 0000000..4920c90 --- /dev/null +++ b/backend/scripts/add_admin_exam_data.sql @@ -0,0 +1,215 @@ +-- 为admin用户(id=2)添加考试和错题数据 +-- 方便直接使用admin账号测试成绩报告和错题本功能 + +USE kaopeilian; + +-- ======================================== +-- 一、插入admin的考试记录(包含三轮得分) +-- ======================================== + +-- 考试1:皮肤生理学基础(完成三轮,成绩优秀) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 1, '皮肤生理学基础 - 动态考试', 10, 100.0, 60.0, + '2025-10-01 09:00:00', '2025-10-01 10:25:00', 60, + 85, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试2:医美产品知识与应用(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 2, '医美产品知识与应用 - 动态考试', 10, 100.0, 60.0, + '2025-10-03 14:00:00', '2025-10-03 15:30:00', 60, + 78, 90, 95, 95, TRUE, 'submitted' +); + +-- 考试3:美容仪器操作与维护(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 3, '美容仪器操作与维护 - 动态考试', 10, 100.0, 60.0, + '2025-10-05 10:00:00', '2025-10-05 11:10:00', 60, + 92, 100, NULL, 100, TRUE, 'submitted' +); + +-- 考试4:医美项目介绍与咨询(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 4, '医美项目介绍与咨询 - 动态考试', 10, 100.0, 60.0, + '2025-10-06 15:00:00', '2025-10-06 16:20:00', 60, + 72, 85, 100, 100, TRUE, 'submitted' +); + +-- 考试5:轻医美销售技巧(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 5, '轻医美销售技巧 - 动态考试', 10, 100.0, 60.0, + '2025-10-07 09:30:00', '2025-10-07 11:00:00', 60, + 88, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试6:客户服务与投诉处理(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 6, '客户服务与投诉处理 - 动态考试', 10, 100.0, 60.0, + '2025-10-08 14:00:00', '2025-10-08 15:15:00', 60, + 95, 100, NULL, 100, TRUE, 'submitted' +); + +-- 考试7:社媒营销与私域运营(完成一轮,不及格) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 7, '社媒营销与私域运营 - 动态考试', 10, 100.0, 60.0, + '2025-10-09 10:00:00', '2025-10-09 10:48:00', 60, + 58, NULL, NULL, 58, FALSE, 'submitted' +); + +-- 考试8:卫生消毒与感染控制(完成三轮,最近的考试) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 9, '卫生消毒与感染控制 - 动态考试', 10, 100.0, 60.0, + '2025-10-11 09:00:00', '2025-10-11 10:18:00', 60, + 90, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试9:美容心理学(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 2, 10, '美容心理学 - 动态考试', 10, 100.0, 60.0, + '2025-10-12 08:30:00', '2025-10-12 09:55:00', 60, + 82, 90, 100, 100, TRUE, 'submitted' +); + +-- ======================================== +-- 二、插入admin的错题记录 +-- ======================================== + +-- 获取刚插入的考试ID +SET @admin_exam1 = (SELECT id FROM exams WHERE user_id=2 AND course_id=1 ORDER BY id DESC LIMIT 1); +SET @admin_exam2 = (SELECT id FROM exams WHERE user_id=2 AND course_id=2 ORDER BY id DESC LIMIT 1); +SET @admin_exam4 = (SELECT id FROM exams WHERE user_id=2 AND course_id=4 ORDER BY id DESC LIMIT 1); +SET @admin_exam5 = (SELECT id FROM exams WHERE user_id=2 AND course_id=5 ORDER BY id DESC LIMIT 1); +SET @admin_exam7 = (SELECT id FROM exams WHERE user_id=2 AND course_id=7 ORDER BY id DESC LIMIT 1); +SET @admin_exam9 = (SELECT id FROM exams WHERE user_id=2 AND course_id=9 ORDER BY id DESC LIMIT 1); + +-- 皮肤生理学基础 - 第一轮2道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam1, '表皮层最外层的细胞是?\nA. 基底细胞\nB. 角质细胞\nC. 黑色素细胞\nD. 朗格汉斯细胞', 'B', 'A', 'single', '2025-10-01 09:15:00'), +(2, @admin_exam1, '真皮层的主要成分包括哪些?(多选)\nA. 胶原蛋白\nB. 弹性蛋白\nC. 透明质酸\nD. 角质蛋白', 'A,B,C', 'A,B', 'multiple', '2025-10-01 09:28:00'); + +-- 医美产品知识与应用 - 第一轮3道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam2, '肉毒素的作用机制是?', '阻断神经肌肉接头处的信号传递,使肌肉松弛,从而减少动态皱纹', '收缩肌肉', 'essay', '2025-10-03 14:18:00'), +(2, @admin_exam2, '光子嫩肤术后___天内避免高温环境', '7', '3', 'blank', '2025-10-03 14:35:00'), +(2, @admin_exam2, '所有类型的色斑都可以用激光去除', '错误', '正确', 'judge', '2025-10-03 14:42:00'); + +-- 医美项目介绍与咨询 - 第一轮3道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam4, '面部埋线提升的维持时间通常是?\nA. 3-6个月\nB. 6-12个月\nC. 12-24个月\nD. 永久', 'C', 'B', 'single', '2025-10-06 15:20:00'), +(2, @admin_exam4, '水光针注射的最佳频率是___,共___次为一个疗程', '每3-4周一次,3-5次', '每周一次,10次', 'blank', '2025-10-06 15:38:00'), +(2, @admin_exam4, '请说明如何向客户介绍热玛吉项目的原理和效果', '原理:利用射频能量深入真皮层和筋膜层,刺激胶原蛋白重组再生。效果:紧致提升、改善皱纹、轮廓重塑,效果可持续1-2年。适合25岁以上肌肤松弛人群', '射频加热皮肤,可以紧致', 'essay', '2025-10-06 15:55:00'); + +-- 轻医美销售技巧 - 第一轮2道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam5, '有效挖掘客户需求的提问技巧包括?(多选)\nA. 开放式提问\nB. 封闭式确认\nC. 诱导式提问\nD. 深入式追问', 'A,B,D', 'A,B', 'multiple', '2025-10-07 09:52:00'), +(2, @admin_exam5, '客户说"太贵了"时,最佳应对策略是先___客户真正的顾虑', '了解', '降价', 'blank', '2025-10-07 10:15:00'); + +-- 社媒营销与私域运营 - 第一轮5道错题(不及格) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam7, '私域流量的核心价值在于?\nA. 降低获客成本\nB. 提高复购率\nC. 建立品牌忠诚度\nD. 以上都是', 'D', 'B', 'single', '2025-10-09 10:10:00'), +(2, @admin_exam7, '有效的社群运营需要具备哪些要素?(多选)\nA. 明确定位\nB. 持续输出\nC. 互动反馈\nD. 硬性推销', 'A,B,C', 'A,B', 'multiple', '2025-10-09 10:18:00'), +(2, @admin_exam7, '朋友圈营销应该每天发布10条以上内容', '错误', '正确', 'judge', '2025-10-09 10:25:00'), +(2, @admin_exam7, '社群活跃度下降时,应采取的措施包括___和___', '话题引导,福利刺激', '不管它,等着看', 'blank', '2025-10-09 10:32:00'), +(2, @admin_exam7, '请设计一个针对医美客户的短视频内容策略', '策略要点:1.专业科普(解答常见疑问)2.案例展示(真实效果对比)3.专家访谈(增强信任)4.互动活动(提升参与)5.客户见证(口碑传播)。发布频率:每周3-5条,时间选择晚上7-9点黄金时段', '每天发产品广告', 'essay', '2025-10-09 10:40:00'); + +-- 卫生消毒与感染控制 - 第一轮1道错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(2, @admin_exam9, '医疗器械的消毒等级分为哪几类?(多选)\nA. 高水平消毒\nB. 中水平消毒\nC. 低水平消毒\nD. 无菌处理', 'A,B,C,D', 'A,B,C', 'multiple', '2025-10-11 09:25:00'); + +-- ======================================== +-- 三、验证结果 +-- ======================================== + +-- admin的考试统计 +SELECT + 'admin考试统计' as info, + COUNT(*) as total_exams, + COUNT(round1_score) as has_round1, + COUNT(round2_score) as has_round2, + COUNT(round3_score) as has_round3, + ROUND(AVG(round1_score), 1) as avg_round1, + ROUND(AVG(round2_score), 1) as avg_round2, + ROUND(AVG(round3_score), 1) as avg_round3 +FROM exams +WHERE user_id = 2 AND round1_score IS NOT NULL; + +-- admin的错题统计 +SELECT + 'admin错题统计' as info, + COUNT(*) as total_mistakes, + COUNT(DISTINCT exam_id) as distinct_exams, + COUNT(DISTINCT question_type) as distinct_types +FROM exam_mistakes +WHERE user_id = 2; + +-- 按题型统计admin的错题 +SELECT + question_type, + COUNT(*) as count +FROM exam_mistakes +WHERE user_id = 2 AND question_type IS NOT NULL +GROUP BY question_type +ORDER BY count DESC; + +-- 显示admin最近5条考试记录 +SELECT + id, + exam_name, + round1_score as 第一轮, + round2_score as 第二轮, + round3_score as 第三轮, + is_passed as 是否通过, + DATE_FORMAT(start_time, '%m-%d %H:%i') as 考试时间 +FROM exams +WHERE user_id = 2 AND round1_score IS NOT NULL +ORDER BY start_time DESC +LIMIT 10; + diff --git a/backend/scripts/add_admin_learning_data.sql b/backend/scripts/add_admin_learning_data.sql new file mode 100644 index 0000000..b18e6c1 --- /dev/null +++ b/backend/scripts/add_admin_learning_data.sql @@ -0,0 +1,164 @@ +-- ============================================ +-- 为 superadmin 和 admin 添加学习记录 +-- ============================================ + +USE `kaopeilian`; + +-- 设置用户ID +SET @superadmin_id = 4; +SET @admin_id = 2; + +-- 获取训练场景ID +SET @scene1_id = (SELECT id FROM training_scenes WHERE name = 'Python编程助手' LIMIT 1); +SET @scene2_id = (SELECT id FROM training_scenes WHERE name = '面试模拟' LIMIT 1); +SET @scene3_id = (SELECT id FROM training_scenes WHERE name = '项目讨论' LIMIT 1); + +-- 获取课程ID +SET @course_id = 4; + +-- ============================================ +-- 1. 为 superadmin 添加训练会话记录(高级管理员,学习记录较多) +-- ============================================ +INSERT INTO training_sessions ( + user_id, + scene_id, + start_time, + end_time, + duration_seconds, + status, + total_score, + evaluation_result, + created_by +) VALUES +-- 30天的丰富学习记录 +(@superadmin_id, @scene1_id, '2025-08-23 09:00:00', '2025-08-23 10:30:00', 5400, 'COMPLETED', 95, '{"feedback": "架构设计优秀"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-08-25 14:00:00', '2025-08-25 16:00:00', 7200, 'COMPLETED', 98, '{"feedback": "系统设计能力出色"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-08-28 10:00:00', '2025-08-28 11:30:00', 5400, 'COMPLETED', 96, '{"feedback": "技术面试官水平"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-01 09:00:00', '2025-09-01 10:00:00', 3600, 'COMPLETED', 94, '{"feedback": "代码审查能力强"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-03 13:30:00', '2025-09-03 15:30:00', 7200, 'COMPLETED', 97, '{"feedback": "技术决策合理"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-05 10:00:00', '2025-09-05 11:00:00', 3600, 'COMPLETED', 95, '{"feedback": "性能优化出色"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-09-08 14:00:00', '2025-09-08 15:30:00', 5400, 'COMPLETED', 93, '{"feedback": "面试策略成熟"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-10 09:00:00', '2025-09-10 11:00:00', 7200, 'COMPLETED', 96, '{"feedback": "项目管理经验丰富"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-12 10:00:00', '2025-09-12 11:30:00', 5400, 'COMPLETED', 98, '{"feedback": "技术深度令人印象深刻"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-09-14 14:00:00', '2025-09-14 15:00:00', 3600, 'COMPLETED', 95, '{"feedback": "团队管理能力优秀"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-16 09:30:00', '2025-09-16 11:30:00', 7200, 'COMPLETED', 97, '{"feedback": "架构视野开阔"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-18 10:00:00', '2025-09-18 11:00:00', 3600, 'COMPLETED', 96, '{"feedback": "最佳实践掌握透彻"}', @superadmin_id), +(@superadmin_id, @scene2_id, '2025-09-20 14:00:00', '2025-09-20 15:30:00', 5400, 'COMPLETED', 94, '{"feedback": "人才评估准确"}', @superadmin_id), +(@superadmin_id, @scene3_id, '2025-09-21 09:00:00', '2025-09-21 11:00:00', 7200, 'COMPLETED', 98, '{"feedback": "技术方案完善"}', @superadmin_id), +(@superadmin_id, @scene1_id, '2025-09-22 10:00:00', '2025-09-22 11:30:00', 5400, 'COMPLETED', 97, '{"feedback": "持续学习精神可嘉"}', @superadmin_id); + +-- ============================================ +-- 2. 为 admin 添加训练会话记录(普通管理员,学习记录适中) +-- ============================================ +INSERT INTO training_sessions ( + user_id, + scene_id, + start_time, + end_time, + duration_seconds, + status, + total_score, + evaluation_result, + created_by +) VALUES +-- 20天的学习记录 +(@admin_id, @scene1_id, '2025-09-03 09:00:00', '2025-09-03 10:00:00', 3600, 'COMPLETED', 88, '{"feedback": "基础扎实"}', @admin_id), +(@admin_id, @scene2_id, '2025-09-05 14:00:00', '2025-09-05 15:00:00', 3600, 'COMPLETED', 85, '{"feedback": "面试技巧良好"}', @admin_id), +(@admin_id, @scene3_id, '2025-09-08 10:00:00', '2025-09-08 11:30:00', 5400, 'COMPLETED', 90, '{"feedback": "项目理解到位"}', @admin_id), +(@admin_id, @scene1_id, '2025-09-10 09:30:00', '2025-09-10 10:30:00', 3600, 'COMPLETED', 87, '{"feedback": "进步明显"}', @admin_id), +(@admin_id, @scene2_id, '2025-09-12 14:00:00', '2025-09-12 15:00:00', 3600, 'COMPLETED', 89, '{"feedback": "沟通能力提升"}', @admin_id), +(@admin_id, @scene3_id, '2025-09-15 10:00:00', '2025-09-15 11:30:00', 5400, 'COMPLETED', 91, '{"feedback": "方案设计合理"}', @admin_id), +(@admin_id, @scene1_id, '2025-09-17 09:00:00', '2025-09-17 10:00:00', 3600, 'COMPLETED', 86, '{"feedback": "代码质量不错"}', @admin_id), +(@admin_id, @scene2_id, '2025-09-19 14:00:00', '2025-09-19 15:00:00', 3600, 'COMPLETED', 88, '{"feedback": "表达清晰"}', @admin_id), +(@admin_id, @scene3_id, '2025-09-21 10:00:00', '2025-09-21 11:30:00', 5400, 'COMPLETED', 92, '{"feedback": "项目管理有进步"}', @admin_id), +(@admin_id, @scene1_id, '2025-09-22 09:00:00', '2025-09-22 10:00:00', 3600, 'COMPLETED', 90, '{"feedback": "技术理解深入"}', @admin_id); + +-- ============================================ +-- 3. 为管理员添加考试记录 +-- ============================================ + +-- superadmin 的考试记录(成绩优秀) +INSERT INTO exams ( + user_id, + course_id, + exam_name, + question_count, + total_score, + pass_score, + start_time, + end_time, + duration_minutes, + score, + is_passed, + status, + questions, + answers +) VALUES +-- superadmin 的考试(高分) +(@superadmin_id, @course_id, 'Python高级特性测试', 40, 100, 80, + '2025-09-01 09:00:00', '2025-09-01 10:00:00', 60, 98, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40]', + '{}'), + +(@superadmin_id, @course_id, 'Python架构设计测试', 50, 100, 85, + '2025-09-10 10:00:00', '2025-09-10 11:30:00', 90, 96, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50]', + '{}'), + +(@superadmin_id, @course_id, 'Python性能优化测试', 35, 100, 80, + '2025-09-20 14:00:00', '2025-09-20 15:15:00', 75, 95, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35]', + '{}'), + +-- admin 的考试记录(成绩良好) +(@admin_id, @course_id, 'Python基础测试', 30, 100, 60, + '2025-09-05 14:00:00', '2025-09-05 15:00:00', 60, 85, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30]', + '{}'), + +(@admin_id, @course_id, 'Python进阶测试', 35, 100, 70, + '2025-09-15 10:00:00', '2025-09-15 11:15:00', 75, 88, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35]', + '{}'); + +-- ============================================ +-- 查询验证 +-- ============================================ + +-- superadmin 的统计 +SELECT + 'superadmin训练统计' as type, + COUNT(DISTINCT DATE(start_time)) as learning_days, + ROUND(SUM(duration_seconds) / 3600, 1) as total_hours, + COUNT(*) as session_count, + ROUND(AVG(total_score), 1) as avg_score +FROM training_sessions +WHERE user_id = @superadmin_id; + +SELECT + 'superadmin考试统计' as type, + COUNT(*) as exam_count, + SUM(question_count) as total_questions, + ROUND(AVG(score), 1) as avg_score +FROM exams +WHERE user_id = @superadmin_id AND status = 'completed'; + +-- admin 的统计 +SELECT + 'admin训练统计' as type, + COUNT(DISTINCT DATE(start_time)) as learning_days, + ROUND(SUM(duration_seconds) / 3600, 1) as total_hours, + COUNT(*) as session_count, + ROUND(AVG(total_score), 1) as avg_score +FROM training_sessions +WHERE user_id = @admin_id; + +SELECT + 'admin考试统计' as type, + COUNT(*) as exam_count, + SUM(question_count) as total_questions, + ROUND(AVG(score), 1) as avg_score +FROM exams +WHERE user_id = @admin_id AND status = 'completed'; + +SELECT '管理员学习数据添加完成!' as message; diff --git a/backend/scripts/add_exam_and_mistakes_demo_data.sql b/backend/scripts/add_exam_and_mistakes_demo_data.sql new file mode 100644 index 0000000..0d9a1da --- /dev/null +++ b/backend/scripts/add_exam_and_mistakes_demo_data.sql @@ -0,0 +1,290 @@ +-- 模拟数据脚本:考试成绩和错题记录 +-- 创建时间:2025-10-12 +-- 说明:为成绩报告和错题本页面添加演示数据 +-- 场景:轻医美连锁品牌员工培训考试 + +USE kaopeilian; + +-- ======================================== +-- 一、插入考试记录(包含三轮得分) +-- ======================================== + +-- 用户5(consultant_001)的考试记录 +-- 考试1:皮肤生理学基础(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 1, '皮肤生理学基础 - 动态考试', 10, 100.0, 60.0, + '2025-10-05 09:00:00', '2025-10-05 10:30:00', 60, + 70, 85, 100, 100, TRUE, 'submitted' +); + +-- 考试2:医美产品知识与应用(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 2, '医美产品知识与应用 - 动态考试', 10, 100.0, 60.0, + '2025-10-06 14:00:00', '2025-10-06 15:20:00', 60, + 65, 80, 90, 90, TRUE, 'submitted' +); + +-- 考试3:美容仪器操作与维护(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 3, '美容仪器操作与维护 - 动态考试', 10, 100.0, 60.0, + '2025-10-07 10:00:00', '2025-10-07 11:15:00', 60, + 80, 95, NULL, 95, TRUE, 'submitted' +); + +-- 考试4:医美项目介绍与咨询(完成一轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 4, '医美项目介绍与咨询 - 动态考试', 10, 100.0, 60.0, + '2025-10-08 15:00:00', '2025-10-08 15:45:00', 60, + 55, NULL, NULL, 55, FALSE, 'submitted' +); + +-- 考试5:轻医美销售技巧(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 5, '轻医美销售技巧 - 动态考试', 10, 100.0, 60.0, + '2025-10-09 09:30:00', '2025-10-09 11:00:00', 60, + 75, 90, 100, 100, TRUE, 'submitted' +); + +-- 考试6:客户服务与投诉处理(完成三轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 6, '客户服务与投诉处理 - 动态考试', 10, 100.0, 60.0, + '2025-10-10 14:00:00', '2025-10-10 15:30:00', 60, + 85, 95, 100, 100, TRUE, 'submitted' +); + +-- 考试7:社媒营销与私域运营(完成两轮) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 7, '社媒营销与私域运营 - 动态考试', 10, 100.0, 60.0, + '2025-10-11 10:00:00', '2025-10-11 11:10:00', 60, + 60, 75, NULL, 75, TRUE, 'submitted' +); + +-- 考试8:卫生消毒与感染控制(完成三轮,最近的考试) +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES ( + 5, 9, '卫生消毒与感染控制 - 动态考试', 10, 100.0, 60.0, + '2025-10-12 09:00:00', '2025-10-12 10:20:00', 60, + 90, 100, 100, 100, TRUE, 'submitted' +); + +-- ======================================== +-- 二、插入错题记录 +-- ======================================== + +-- 获取刚插入的考试ID(使用最后8条记录) +SET @exam1_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=1 ORDER BY id DESC LIMIT 1); +SET @exam2_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=2 ORDER BY id DESC LIMIT 1); +SET @exam4_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=4 ORDER BY id DESC LIMIT 1); +SET @exam5_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=5 ORDER BY id DESC LIMIT 1); +SET @exam7_id = (SELECT id FROM exams WHERE user_id=5 AND course_id=7 ORDER BY id DESC LIMIT 1); + +-- 考试1的错题(皮肤生理学基础 - 第一轮3道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam1_id, '人体皮肤分为哪几层?', '表皮层、真皮层、皮下组织三层', '表皮和真皮两层', 'essay', '2025-10-05 09:15:00'), +(5, @exam1_id, '关于玻尿酸的作用,以下哪项描述是正确的?\nA. 只能用于填充\nB. 具有保湿和填充双重作用\nC. 不能用于面部\nD. 只适合年轻人使用', 'B', 'A', 'single', '2025-10-05 09:25:00'), +(5, @exam1_id, '敏感肌肤的客户可以使用含酒精的护肤品', '错误', '正确', 'judge', '2025-10-05 09:35:00'); + +-- 考试2的错题(医美产品知识与应用 - 第一轮4道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam2_id, '光子嫩肤的主要功效包括哪些?(多选)\nA. 美白淡斑\nB. 收缩毛孔\nC. 去除皱纹\nD. 改善红血丝', 'A,B,D', 'A,B,C', 'multiple', '2025-10-06 14:10:00'), +(5, @exam2_id, '水光针的主要成分是___', '透明质酸(玻尿酸)', '胶原蛋白', 'blank', '2025-10-06 14:20:00'), +(5, @exam2_id, '热玛吉适用于所有肤质', '正确', '错误', 'judge', '2025-10-06 14:30:00'), +(5, @exam2_id, '超声刀的作用原理是什么?', '通过高强度聚焦超声波能量作用于筋膜层,促进胶原蛋白再生,达到紧致提拉效果', '利用超声波震动按摩皮肤', 'essay', '2025-10-06 14:40:00'); + +-- 考试4的错题(医美项目介绍与咨询 - 第一轮5道错题,未通过) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam4_id, '面部提升项目适合的年龄段是?\nA. 25岁以下\nB. 25-35岁\nC. 35岁以上\nD. 所有年龄段', 'C', 'B', 'single', '2025-10-08 15:10:00'), +(5, @exam4_id, '皮秒激光可以治疗哪些皮肤问题?(多选)\nA. 色斑\nB. 痘印\nC. 毛孔粗大\nD. 皮肤松弛', 'A,B,C', 'A,B', 'multiple', '2025-10-08 15:15:00'), +(5, @exam4_id, '果酸焕肤后需要严格防晒', '正确', '错误', 'judge', '2025-10-08 15:20:00'), +(5, @exam4_id, '光子嫩肤一个疗程通常需要___次治疗,间隔___周', '3-5次,3-4周', '5-8次,2周', 'blank', '2025-10-08 15:25:00'), +(5, @exam4_id, '请简述如何向客户介绍肉毒素除皱项目的优势和注意事项', '优势:快速见效、微创无痕、可逆性强、针对性强。注意事项:需选择正规品牌、术后避免按摩、孕妇和哺乳期禁用、过敏体质需提前告知', '肉毒素可以除皱,效果很好,没有副作用', 'essay', '2025-10-08 15:30:00'); + +-- 考试5的错题(轻医美销售技巧 - 第一轮3道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam5_id, '在销售咨询中,FABE销售法则中的F代表?\nA. Features(特征)\nB. Functions(功能)\nC. Facts(事实)\nD. Feelings(感受)', 'A', 'C', 'single', '2025-10-09 09:45:00'), +(5, @exam5_id, '有效的销售话术应该具备哪些特点?(多选)\nA. 专业准确\nB. 简单易懂\nC. 夸大效果\nD. 针对性强', 'A,B,D', 'A,B', 'multiple', '2025-10-09 09:55:00'), +(5, @exam5_id, '客户提出价格异议时,第一步应该是___客户的关注点', '倾听和理解', '立即降价', 'blank', '2025-10-09 10:05:00'); + +-- 考试7的错题(社媒营销与私域运营 - 第一轮4道错题) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(5, @exam7_id, '私域流量运营的核心是?\nA. 大量加粉\nB. 建立信任关系\nC. 频繁推销\nD. 打折促销', 'B', 'A', 'single', '2025-10-11 10:15:00'), +(5, @exam7_id, '短视频内容策划应遵循哪些原则?(多选)\nA. 垂直专业\nB. 持续更新\nC. 互动性强\nD. 纯广告推广', 'A,B,C', 'A,B', 'multiple', '2025-10-11 10:25:00'), +(5, @exam7_id, '企业微信的客户标签管理可以提升运营效率', '正确', '错误', 'judge', '2025-10-11 10:35:00'), +(5, @exam7_id, '请简述如何设计一个有效的会员转化路径', '步骤:1.引流获客(短视频/直播)2.建立信任(专业内容分享)3.激活需求(案例展示/体验活动)4.促成转化(限时优惠/专属福利)5.持续运营(定期回访/会员权益)', '做活动、发优惠券', 'essay', '2025-10-11 10:45:00'); + +-- ======================================== +-- 三、插入更多用户的考试数据(丰富数据) +-- ======================================== + +-- 用户6(其他美容顾问)的考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES +(6, 1, '皮肤生理学基础 - 动态考试', 10, 100.0, 60.0, + '2025-10-03 10:00:00', '2025-10-03 11:20:00', 60, + 88, 95, 100, 100, TRUE, 'submitted'), +(6, 2, '医美产品知识与应用 - 动态考试', 10, 100.0, 60.0, + '2025-10-04 14:30:00', '2025-10-04 15:40:00', 60, + 92, 100, NULL, 100, TRUE, 'submitted'), +(6, 6, '客户服务与投诉处理 - 动态考试', 10, 100.0, 60.0, + '2025-10-07 09:00:00', '2025-10-07 10:15:00', 60, + 78, 90, 95, 95, TRUE, 'submitted'); + +-- 用户7的考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, + round1_score, round2_score, round3_score, score, is_passed, status +) VALUES +(7, 3, '美容仪器操作与维护 - 动态考试', 10, 100.0, 60.0, + '2025-10-05 11:00:00', '2025-10-05 12:10:00', 60, + 82, 90, 100, 100, TRUE, 'submitted'), +(7, 5, '轻医美销售技巧 - 动态考试', 10, 100.0, 60.0, + '2025-10-09 15:00:00', '2025-10-09 16:15:00', 60, + 70, 85, 90, 90, TRUE, 'submitted'); + +-- ======================================== +-- 四、为新增考试添加对应的错题记录 +-- ======================================== + +-- 用户6的错题(皮肤生理学基础 - 第一轮1道错题) +SET @exam6_1 = (SELECT id FROM exams WHERE user_id=6 AND course_id=1 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(6, @exam6_1, '皮肤的PH值正常范围是?\nA. 3.5-4.5\nB. 4.5-6.5\nC. 6.5-7.5\nD. 7.5-8.5', 'B', 'C', 'single', '2025-10-03 10:20:00'); + +-- 用户6的错题(医美产品知识与应用 - 第一轮1道错题) +SET @exam6_2 = (SELECT id FROM exams WHERE user_id=6 AND course_id=2 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(6, @exam6_2, '玻尿酸注射后___小时内不能沾水', '24', '12', 'blank', '2025-10-04 14:50:00'); + +-- 用户6的错题(客户服务与投诉处理 - 第一轮2道错题) +SET @exam6_3 = (SELECT id FROM exams WHERE user_id=6 AND course_id=6 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(6, @exam6_3, '处理客户投诉的黄金原则是?(多选)\nA. 及时回应\nB. 真诚道歉\nC. 快速解决\nD. 推卸责任', 'A,B,C', 'A,B', 'multiple', '2025-10-07 09:20:00'), +(6, @exam6_3, '客户投诉时,应该先___,再___', '倾听,解决', '解释,辩解', 'blank', '2025-10-07 09:30:00'); + +-- 用户7的错题(美容仪器操作与维护 - 第一轮2道错题) +SET @exam7_1 = (SELECT id FROM exams WHERE user_id=7 AND course_id=3 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(7, @exam7_1, '射频仪器的工作原理是?\nA. 激光热效应\nB. 电磁波热效应\nC. 超声波振动\nD. 机械摩擦', 'B', 'A', 'single', '2025-10-05 11:20:00'), +(7, @exam7_1, '仪器消毒应该在每次使用___进行', '前后', '前', 'blank', '2025-10-05 11:35:00'); + +-- 用户7的错题(轻医美销售技巧 - 第一轮3道错题) +SET @exam7_2 = (SELECT id FROM exams WHERE user_id=7 AND course_id=5 ORDER BY id DESC LIMIT 1); +INSERT INTO exam_mistakes ( + user_id, exam_id, question_content, correct_answer, user_answer, question_type, created_at +) VALUES +(7, @exam7_2, '成交话术中,假设成交法的核心是?\nA. 直接要求客户付款\nB. 假设客户已经同意购买\nC. 给客户压力\nD. 降价促销', 'B', 'A', 'single', '2025-10-09 15:25:00'), +(7, @exam7_2, '顾客异议处理时应避免的做法包括?(多选)\nA. 打断顾客说话\nB. 否认顾客观点\nC. 耐心倾听\nD. 与顾客争辩', 'A,B,D', 'A,B', 'multiple', '2025-10-09 15:35:00'), +(7, @exam7_2, '请列举3种常用的促成交易的方法', '1.假设成交法 2.二选一法 3.优惠刺激法 4.紧迫感营造法(任选3种)', '降价、送礼品', 'essay', '2025-10-09 15:45:00'); + +-- ======================================== +-- 五、验证插入结果 +-- ======================================== + +-- 统计考试记录 +SELECT + '考试记录统计' as category, + COUNT(*) as total, + COUNT(round1_score) as has_round1, + COUNT(round2_score) as has_round2, + COUNT(round3_score) as has_round3, + AVG(round1_score) as avg_round1_score +FROM exams +WHERE user_id IN (5, 6, 7); + +-- 统计错题记录 +SELECT + '错题记录统计' as category, + COUNT(*) as total, + COUNT(DISTINCT exam_id) as distinct_exams, + COUNT(DISTINCT question_type) as distinct_types +FROM exam_mistakes +WHERE user_id IN (5, 6, 7); + +-- 按课程统计错题 +SELECT + c.name as course_name, + COUNT(em.id) as mistake_count +FROM exam_mistakes em +JOIN exams e ON em.exam_id = e.id +JOIN courses c ON e.course_id = c.id +WHERE em.user_id IN (5, 6, 7) +GROUP BY c.id, c.name +ORDER BY mistake_count DESC; + +-- 按题型统计错题 +SELECT + question_type, + COUNT(*) as count +FROM exam_mistakes +WHERE user_id IN (5, 6, 7) AND question_type IS NOT NULL +GROUP BY question_type +ORDER BY count DESC; + +-- 显示最近5条考试记录(包含三轮得分) +SELECT + id, + exam_name, + round1_score, + round2_score, + round3_score, + score, + is_passed, + DATE_FORMAT(start_time, '%Y-%m-%d %H:%i') as start_time +FROM exams +WHERE user_id = 5 +ORDER BY start_time DESC +LIMIT 5; + diff --git a/backend/scripts/add_exam_data.sql b/backend/scripts/add_exam_data.sql new file mode 100644 index 0000000..63dae5c --- /dev/null +++ b/backend/scripts/add_exam_data.sql @@ -0,0 +1,78 @@ +-- ============================================ +-- 为 testuser 添加考试记录 +-- ============================================ + +USE `kaopeilian`; + +-- 设置 testuser 的 ID +SET @test_user_id = 1; + +-- 获取第一个课程ID(Python基础课程) +SET @course_id = 4; + +-- 添加考试记录(exams 表是用户的考试实例) +INSERT INTO exams ( + user_id, + course_id, + exam_name, + question_count, + total_score, + pass_score, + start_time, + end_time, + duration_minutes, + score, + is_passed, + status, + questions, + answers +) VALUES +-- 第一次考试(15天前) +(@test_user_id, @course_id, 'Python基础测试-第1次', 20, 100, 60, + '2025-09-07 10:00:00', '2025-09-07 10:50:00', 50, 72, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]', + '{"1":"A","2":"B","3":"A","4":"C","5":"A","6":"B","7":"A","8":"D","9":"A","10":"B","11":"A","12":"C","13":"A","14":"B","15":"A","16":"C","17":"A","18":"B","19":"A","20":"D"}'), + +-- 第二次考试(10天前) +(@test_user_id, @course_id, 'Python基础测试-第2次', 20, 100, 60, + '2025-09-12 14:00:00', '2025-09-12 14:45:00', 45, 85, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20]', + '{"1":"A","2":"A","3":"A","4":"A","5":"A","6":"B","7":"A","8":"A","9":"A","10":"B","11":"A","12":"A","13":"A","14":"B","15":"A","16":"A","17":"A","18":"B","19":"A","20":"A"}'), + +-- 第三次考试(5天前) +(@test_user_id, @course_id, 'Python进阶测试', 25, 100, 70, + '2025-09-17 09:00:00', '2025-09-17 10:15:00', 75, 78, 1, 'completed', + '[21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45]', + '{"21":"A","22":"B","23":"A","24":"C","25":"A","26":"B","27":"A","28":"D","29":"A","30":"B","31":"A","32":"C","33":"A","34":"B","35":"A","36":"C","37":"A","38":"B","39":"A","40":"D","41":"A","42":"B","43":"A","44":"C","45":"A"}'), + +-- 第四次考试(3天前) +(@test_user_id, @course_id, 'Python项目实战测试', 30, 100, 80, + '2025-09-19 13:30:00', '2025-09-19 15:00:00', 90, 92, 1, 'completed', + '[46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75]', + '{"46":"A","47":"A","48":"A","49":"A","50":"A","51":"B","52":"A","53":"A","54":"A","55":"B","56":"A","57":"A","58":"A","59":"B","60":"A","61":"A","62":"A","63":"B","64":"A","65":"A","66":"A","67":"A","68":"A","69":"A","70":"A","71":"B","72":"A","73":"A","74":"A","75":"A"}'), + +-- 最近的考试(昨天) +(@test_user_id, @course_id, 'Python综合测试', 50, 100, 85, + '2025-09-21 10:00:00', '2025-09-21 11:50:00', 110, 95, 1, 'completed', + '[1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50]', + '{"1":"A","2":"A","3":"A","4":"A","5":"A","6":"A","7":"A","8":"A","9":"A","10":"A","11":"A","12":"A","13":"A","14":"A","15":"A","16":"A","17":"A","18":"A","19":"A","20":"A","21":"A","22":"A","23":"A","24":"A","25":"A","26":"B","27":"A","28":"A","29":"A","30":"B","31":"A","32":"A","33":"A","34":"B","35":"A","36":"A","37":"A","38":"B","39":"A","40":"A","41":"A","42":"B","43":"A","44":"A","45":"A","46":"A","47":"A","48":"A","49":"A","50":"A"}'); + +-- 如果需要添加答题详情(exam_results 表),可以根据需要补充 +-- 这里简化处理,因为统计接口主要用 exams 表的数据 + +-- 查询验证 +SELECT + COUNT(*) as exam_count, + ROUND(AVG(score), 1) as avg_score, + MIN(score) as min_score, + MAX(score) as max_score +FROM exams +WHERE user_id = @test_user_id AND status = 'completed'; + +-- 计算总练习题数(所有考试的题目总和) +SELECT + SUM(question_count) as total_practice_questions +FROM exams +WHERE user_id = @test_user_id AND status = 'completed'; + +SELECT '考试数据添加完成!' as message; diff --git a/backend/scripts/add_exam_tables.sql b/backend/scripts/add_exam_tables.sql new file mode 100644 index 0000000..8315055 --- /dev/null +++ b/backend/scripts/add_exam_tables.sql @@ -0,0 +1,88 @@ +-- 创建考试相关表 + +-- 1. 创建题目表 +CREATE TABLE IF NOT EXISTS `questions` ( + `id` INT NOT NULL AUTO_INCREMENT, + `course_id` INT NOT NULL, + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT NULL COMMENT '题目内容', + `options` JSON NULL COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT NULL COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON NULL COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_question_type` (`question_type`), + KEY `idx_difficulty` (`difficulty`), + KEY `idx_is_active` (`is_active`), + CONSTRAINT `fk_questions_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 2. 创建考试记录表 +CREATE TABLE IF NOT EXISTS `exams` ( + `id` INT NOT NULL AUTO_INCREMENT, + `user_id` BIGINT NOT NULL, + `course_id` INT NOT NULL, + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME NULL COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT NULL COMMENT '得分', + `is_passed` BOOLEAN NULL COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON NULL COMMENT '题目数据(JSON格式)', + `answers` JSON NULL COMMENT '答案数据(JSON格式)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_status` (`status`), + CONSTRAINT `fk_exams_user` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_exams_course` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 3. 创建考试结果详情表 +CREATE TABLE IF NOT EXISTS `exam_results` ( + `id` INT NOT NULL AUTO_INCREMENT, + `exam_id` INT NOT NULL, + `question_id` INT NOT NULL, + `user_answer` TEXT NULL COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT NULL COMMENT '答题时长(秒)', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_exam_id` (`exam_id`), + KEY `idx_question_id` (`question_id`), + CONSTRAINT `fk_exam_results_exam` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE, + CONSTRAINT `fk_exam_results_question` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + +-- 4. 插入测试题目数据 +INSERT INTO `questions` (`course_id`, `question_type`, `title`, `content`, `options`, `correct_answer`, `explanation`, `score`, `difficulty`, `tags`) VALUES +(1, 'single_choice', 'Python中哪个关键字用于定义函数?', NULL, '{"A": "def", "B": "function", "C": "fun", "D": "define"}', 'A', 'Python使用def关键字来定义函数', 10.0, 'easy', '["python", "基础", "函数"]'), +(1, 'single_choice', 'Python中列表和元组的主要区别是什么?', NULL, '{"A": "列表是有序的,元组是无序的", "B": "列表可变,元组不可变", "C": "列表只能存储数字,元组可以存储任何类型", "D": "没有区别"}', 'B', '列表是可变的(mutable),而元组是不可变的(immutable)', 10.0, 'medium', '["python", "数据结构"]'), +(1, 'single_choice', '以下哪个不是Python的内置数据类型?', NULL, '{"A": "list", "B": "dict", "C": "array", "D": "tuple"}', 'C', 'array不是Python的内置数据类型,需要导入array模块', 10.0, 'medium', '["python", "数据类型"]'), +(1, 'true_false', 'Python是一种编译型语言', NULL, NULL, 'false', 'Python是一种解释型语言,不需要编译成机器码', 10.0, 'easy', '["python", "基础"]'), +(1, 'true_false', 'Python支持多重继承', NULL, NULL, 'true', 'Python支持多重继承,一个类可以继承多个父类', 10.0, 'medium', '["python", "面向对象"]'); + +-- 5. 插入更多测试题目(如果需要) +INSERT INTO `questions` (`course_id`, `question_type`, `title`, `content`, `options`, `correct_answer`, `explanation`, `score`, `difficulty`, `tags`) VALUES +(1, 'single_choice', 'Python中的装饰器是什么?', NULL, '{"A": "一种设计模式", "B": "用于修改函数或类行为的函数", "C": "一种数据结构", "D": "一种循环结构"}', 'B', '装饰器是一个接受函数作为参数并返回新函数的函数', 15.0, 'hard', '["python", "高级特性", "装饰器"]'), +(1, 'single_choice', '以下哪个方法用于向列表末尾添加元素?', NULL, '{"A": "add()", "B": "insert()", "C": "append()", "D": "extend()"}', 'C', 'append()方法用于向列表末尾添加单个元素', 10.0, 'easy', '["python", "列表", "方法"]'), +(1, 'multiple_choice', 'Python中哪些是可变数据类型?', '请选择所有正确答案', '{"A": "list", "B": "tuple", "C": "dict", "D": "str", "E": "set"}', '["A", "C", "E"]', 'list、dict和set是可变数据类型,而tuple和str是不可变的', 15.0, 'medium', '["python", "数据类型"]'), +(1, 'fill_blank', 'Python中使用____关键字定义类', NULL, NULL, 'class', '使用class关键字定义类', 10.0, 'easy', '["python", "面向对象"]'), +(1, 'essay', '请解释Python中的GIL(全局解释器锁)是什么,以及它对多线程编程的影响', NULL, NULL, 'GIL是Python解释器的一个机制,同一时刻只允许一个线程执行Python字节码。这对CPU密集型的多线程程序性能有负面影响,但对I/O密集型程序影响较小。', 'GIL确保了Python对象的线程安全,但限制了多线程的并行性能', 20.0, 'hard', '["python", "高级", "并发"]'); diff --git a/backend/scripts/add_school_major_fields.py b/backend/scripts/add_school_major_fields.py new file mode 100644 index 0000000..7a71ffe --- /dev/null +++ b/backend/scripts/add_school_major_fields.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python +""" +添加用户表的学校和专业字段 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.append(str(project_root)) + +from app.core.config import settings + +# SQL脚本 +ADD_FIELDS_SQL = """ +-- 添加 school 和 major 字段到 users 表 +ALTER TABLE users +ADD COLUMN school VARCHAR(100) COMMENT '学校' AFTER bio, +ADD COLUMN major VARCHAR(100) COMMENT '专业' AFTER school; +""" + +async def execute_sql(): + """执行SQL脚本""" + try: + # 从环境变量或配置中获取数据库连接信息 + # 优先使用环境变量 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + # 格式: mysql+aiomysql://root:root@localhost:3306/kaopeilian + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + # 检查字段是否已存在 + check_sql = """ + SELECT COLUMN_NAME + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = %s + AND TABLE_NAME = 'users' + AND COLUMN_NAME IN ('school', 'major') + """ + await cursor.execute(check_sql, (database,)) + existing_columns = [row[0] for row in await cursor.fetchall()] + + if 'school' in existing_columns or 'major' in existing_columns: + print("字段已存在,跳过添加") + if 'school' in existing_columns: + print("- school 字段已存在") + if 'major' in existing_columns: + print("- major 字段已存在") + else: + # 执行SQL脚本 + await cursor.execute(ADD_FIELDS_SQL) + await conn.commit() + print("成功添加 school 和 major 字段到 users 表") + + # 显示表结构 + show_sql = "DESC users" + await cursor.execute(show_sql) + columns = await cursor.fetchall() + + print("\n当前 users 表结构:") + print("-" * 80) + for col in columns: + print(f"{col[0]:20} {col[1]:30} {'NULL' if col[2] == 'YES' else 'NOT NULL':10} {col[5] or ''}") + + conn.close() + + except Exception as e: + print(f"执行失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + +if __name__ == "__main__": + print("开始添加 school 和 major 字段...") + asyncio.run(execute_sql()) + print("\n执行完成!") diff --git a/backend/scripts/add_training_data.sql b/backend/scripts/add_training_data.sql new file mode 100644 index 0000000..9e747f7 --- /dev/null +++ b/backend/scripts/add_training_data.sql @@ -0,0 +1,63 @@ +-- ============================================ +-- 为 testuser 添加训练会话记录 +-- ============================================ + +USE `kaopeilian`; + +-- 获取 testuser 的 ID (应该是 1) +SET @test_user_id = 1; + +-- 1. 先插入训练场景 +INSERT INTO training_scenes (name, description, category, ai_config, status, is_public, is_deleted, created_by) VALUES +('Python编程助手', '帮助学员解决Python编程问题', '技术辅导', '{"bot_id": "python_assistant_bot"}', 'ACTIVE', 1, 0, 1), +('面试模拟', '模拟技术面试场景', '职业发展', '{"bot_id": "interview_simulator_bot"}', 'ACTIVE', 1, 0, 1), +('项目讨论', '项目方案讨论和优化', '项目管理', '{"bot_id": "project_discussion_bot"}', 'ACTIVE', 1, 0, 1); + +-- 获取场景ID +SET @scene1_id = LAST_INSERT_ID(); +SET @scene2_id = @scene1_id + 1; +SET @scene3_id = @scene1_id + 2; + +-- 2. 添加训练会话记录(分布在最近15天) +INSERT INTO training_sessions ( + user_id, + scene_id, + start_time, + end_time, + duration_seconds, + status, + total_score, + evaluation_result, + created_by +) VALUES +-- 第一周 +(@test_user_id, @scene1_id, '2025-09-08 09:00:00', '2025-09-08 09:45:00', 2700, 'COMPLETED', 85, '{"feedback": "表现良好"}', @test_user_id), +(@test_user_id, @scene2_id, '2025-09-09 14:00:00', '2025-09-09 15:00:00', 3600, 'COMPLETED', 88, '{"feedback": "面试表现不错"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-10 10:00:00', '2025-09-10 10:30:00', 1800, 'COMPLETED', 82, '{"feedback": "继续努力"}', @test_user_id), +(@test_user_id, @scene3_id, '2025-09-11 13:30:00', '2025-09-11 15:00:00', 5400, 'COMPLETED', 90, '{"feedback": "项目思路清晰"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-12 09:30:00', '2025-09-12 10:10:00', 2400, 'COMPLETED', 87, '{"feedback": "代码质量提升"}', @test_user_id), + +-- 第二周 +(@test_user_id, @scene2_id, '2025-09-13 14:30:00', '2025-09-13 15:20:00', 3000, 'COMPLETED', 85, '{"feedback": "算法掌握良好"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-14 10:00:00', '2025-09-14 10:35:00', 2100, 'COMPLETED', 89, '{"feedback": "优秀"}', @test_user_id), +(@test_user_id, @scene3_id, '2025-09-15 13:00:00', '2025-09-15 14:15:00', 4500, 'COMPLETED', 91, '{"feedback": "架构设计合理"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-16 09:15:00', '2025-09-16 10:00:00', 2700, 'COMPLETED', 86, '{"feedback": "进步明显"}', @test_user_id), +(@test_user_id, @scene2_id, '2025-09-17 14:00:00', '2025-09-17 14:55:00', 3300, 'COMPLETED', 88, '{"feedback": "表达清晰"}', @test_user_id), + +-- 最近几天 +(@test_user_id, @scene1_id, '2025-09-18 10:30:00', '2025-09-18 11:00:00', 1800, 'COMPLETED', 90, '{"feedback": "基础扎实"}', @test_user_id), +(@test_user_id, @scene3_id, '2025-09-19 13:00:00', '2025-09-19 14:20:00', 4800, 'COMPLETED', 92, '{"feedback": "解决方案优秀"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-20 09:00:00', '2025-09-20 09:40:00', 2400, 'COMPLETED', 87, '{"feedback": "继续保持"}', @test_user_id), +(@test_user_id, @scene2_id, '2025-09-21 14:00:00', '2025-09-21 15:00:00', 3600, 'COMPLETED', 89, '{"feedback": "面试准备充分"}', @test_user_id), +(@test_user_id, @scene1_id, '2025-09-22 10:00:00', '2025-09-22 10:45:00', 2700, 'COMPLETED', 91, '{"feedback": "代码优雅"}', @test_user_id); + +-- 查询验证 +SELECT + COUNT(DISTINCT DATE(start_time)) as learning_days, + ROUND(SUM(duration_seconds) / 3600, 1) as total_hours, + COUNT(*) as session_count, + ROUND(AVG(total_score), 1) as avg_score +FROM training_sessions +WHERE user_id = @test_user_id; + +SELECT '训练数据添加完成!' as message; diff --git a/backend/scripts/alter_exam_mistakes_add_question_type.sql b/backend/scripts/alter_exam_mistakes_add_question_type.sql new file mode 100644 index 0000000..b740e65 --- /dev/null +++ b/backend/scripts/alter_exam_mistakes_add_question_type.sql @@ -0,0 +1,23 @@ +-- 增量脚本:为exam_mistakes表增加question_type字段 +-- 创建时间:2025-10-12 +-- 说明:支持错题按题型筛选和统计 + +USE kaopeilian; + +-- 1. 为exam_mistakes表增加question_type字段 +ALTER TABLE exam_mistakes + ADD COLUMN question_type VARCHAR(20) NULL COMMENT '题型(single/multiple/judge/blank/essay)' AFTER user_answer; + +-- 2. 添加索引(可选,提升查询性能) +ALTER TABLE exam_mistakes + ADD INDEX idx_question_type (question_type); + +-- 3. 验证结果 +DESCRIBE exam_mistakes; + +-- 4. 统计当前错题数据 +SELECT + COUNT(*) as total_mistakes, + COUNT(question_type) as has_question_type +FROM exam_mistakes; + diff --git a/backend/scripts/alter_exams_add_rounds.sql b/backend/scripts/alter_exams_add_rounds.sql new file mode 100644 index 0000000..b2b5de2 --- /dev/null +++ b/backend/scripts/alter_exams_add_rounds.sql @@ -0,0 +1,36 @@ +-- 增量脚本:为exams表增加三轮得分字段 +-- 创建时间:2025-10-12 +-- 说明:简化三轮考试机制,一条考试记录存储三轮得分 + +USE kaopeilian; + +-- 1. 为exams表增加三轮得分字段 +ALTER TABLE exams + ADD COLUMN round1_score FLOAT NULL COMMENT '第一轮得分' AFTER score, + ADD COLUMN round2_score FLOAT NULL COMMENT '第二轮得分' AFTER round1_score, + ADD COLUMN round3_score FLOAT NULL COMMENT '第三轮得分' AFTER round2_score; + +-- 2. 为已存在的考试记录设置默认值(将score复制到round1_score) +UPDATE exams +SET round1_score = score +WHERE score IS NOT NULL AND round1_score IS NULL; + +-- 3. 验证结果 +SELECT + COUNT(*) as total_exams, + COUNT(round1_score) as has_round1, + COUNT(round2_score) as has_round2, + COUNT(round3_score) as has_round3 +FROM exams; + +-- 输出结果示例 +SELECT + id, + exam_name, + score, + round1_score, + round2_score, + round3_score +FROM exams +LIMIT 5; + diff --git a/backend/scripts/alter_users_email_nullable.sql b/backend/scripts/alter_users_email_nullable.sql new file mode 100644 index 0000000..8a2410d --- /dev/null +++ b/backend/scripts/alter_users_email_nullable.sql @@ -0,0 +1,10 @@ +-- 修改users表email字段为可空 +-- 用于支持员工同步功能,部分员工可能没有邮箱 + +-- 修改email字段为可空 +ALTER TABLE users MODIFY COLUMN email VARCHAR(100) NULL COMMENT '邮箱'; + +-- 验证修改 +DESCRIBE users; + + diff --git a/backend/scripts/apply_sql_file.py b/backend/scripts/apply_sql_file.py new file mode 100644 index 0000000..623c7d0 --- /dev/null +++ b/backend/scripts/apply_sql_file.py @@ -0,0 +1,80 @@ +""" +执行指定的 SQL 文件到当前配置的数据库(使用与后端一致的连接)。 + +用法: + cd kaopeilian-backend && python3 scripts/apply_sql_file.py scripts/init_database_unified.sql +""" +import asyncio +import sys +from pathlib import Path + +# 确保可导入应用配置 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy.ext.asyncio import create_async_engine +from sqlalchemy import text + +from app.core.config import get_settings + + +def load_sql_statements(sql_path: Path) -> list[str]: + """读取 SQL 文件并按语句拆分(简单分号分割,忽略注释)。""" + raw = sql_path.read_text(encoding="utf-8") + # 去掉 -- 开头的行注释 + lines = [] + for line in raw.splitlines(): + striped = line.strip() + if not striped: + continue + if striped.startswith("--"): + continue + lines.append(line) + + content = "\n".join(lines) + + # 简单分割;注意保留分号作为语句结束标记 + statements: list[str] = [] + current = [] + for ch in content: + current.append(ch) + if ch == ";": + stmt = "".join(current).strip() + if stmt: + statements.append(stmt) + current = [] + # 可能没有以分号结束的尾部 + tail = "".join(current).strip() + if tail: + statements.append(tail) + return [s for s in statements if s] + + +async def apply_sql(sql_file: str): + settings = get_settings() + engine = create_async_engine(settings.DATABASE_URL, echo=False) + + sql_path = Path(sql_file) + if not sql_path.exists(): + raise FileNotFoundError(f"SQL 文件不存在: {sql_path}") + + statements = load_sql_statements(sql_path) + + async with engine.begin() as conn: + # 执行每条语句 + for stmt in statements: + await conn.execute(text(stmt)) + + await engine.dispose() + + +async def main(): + if len(sys.argv) < 2: + print("用法: python3 scripts/apply_sql_file.py ") + sys.exit(1) + sql_file = sys.argv[1] + await apply_sql(sql_file) + print("SQL 执行完成") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/backup_database.sh b/backend/scripts/backup_database.sh new file mode 100755 index 0000000..9922ad9 --- /dev/null +++ b/backend/scripts/backup_database.sh @@ -0,0 +1,144 @@ +#!/bin/bash + +# 考陪练系统数据库自动备份脚本 +# 作者: AI Assistant +# 日期: 2025-09-23 + +set -e # 遇到错误立即退出 + +# 配置变量 +BACKUP_DIR="/root/aiedu/kaopeilian-backend/backups" +CONTAINER_NAME="kaopeilian_mysql" +DB_NAME="kaopeilian" +DB_USER="root" +DB_PASSWORD="Kaopeilian2025!@#" +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_FILE="${BACKUP_DIR}/kaopeilian_backup_${DATE}.sql" +LOG_FILE="${BACKUP_DIR}/backup.log" +RETENTION_DAYS=30 # 保留30天的备份 + +# 创建备份目录 +mkdir -p "${BACKUP_DIR}" + +# 日志函数 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}" +} + +# 检查Docker容器是否运行 +check_container() { + if ! docker ps | grep -q "${CONTAINER_NAME}"; then + log "ERROR: MySQL容器 ${CONTAINER_NAME} 未运行" + exit 1 + fi +} + +# 执行数据库备份 +backup_database() { + log "开始备份Docker容器中的数据库 ${DB_NAME}..." + + # 检查容器是否运行 + if ! docker ps | grep -q "${CONTAINER_NAME}"; then + log "ERROR: MySQL容器 ${CONTAINER_NAME} 未运行" + return 1 + fi + + # 使用docker exec执行mysqldump,将输出重定向到宿主机文件 + if docker exec "${CONTAINER_NAME}" mysqldump \ + -u"${DB_USER}" \ + -p"${DB_PASSWORD}" \ + --single-transaction \ + --routines \ + --triggers \ + --events \ + --hex-blob \ + --default-character-set=utf8mb4 \ + --lock-tables=false \ + --add-drop-database \ + --create-options \ + "${DB_NAME}" > "${BACKUP_FILE}" 2>/dev/null; then + + # 检查备份文件大小 + if [ -f "${BACKUP_FILE}" ] && [ -s "${BACKUP_FILE}" ]; then + BACKUP_SIZE=$(du -h "${BACKUP_FILE}" | cut -f1) + log "备份完成: ${BACKUP_FILE} (大小: ${BACKUP_SIZE})" + + # 验证备份文件内容(检查是否包含CREATE DATABASE语句) + if grep -q "CREATE DATABASE" "${BACKUP_FILE}"; then + log "备份文件验证成功" + return 0 + else + log "WARNING: 备份文件可能不完整" + return 0 # 仍然算作成功,但记录警告 + fi + else + log "ERROR: 备份文件为空或不存在" + rm -f "${BACKUP_FILE}" + return 1 + fi + else + log "ERROR: 数据库备份失败" + return 1 + fi +} + +# 压缩备份文件 +compress_backup() { + if [ -f "${BACKUP_FILE}" ]; then + log "压缩备份文件..." + gzip "${BACKUP_FILE}" + COMPRESSED_FILE="${BACKUP_FILE}.gz" + COMPRESSED_SIZE=$(du -h "${COMPRESSED_FILE}" | cut -f1) + log "压缩完成: ${COMPRESSED_FILE} (大小: ${COMPRESSED_SIZE})" + fi +} + +# 清理过期备份 +cleanup_old_backups() { + log "清理 ${RETENTION_DAYS} 天前的备份文件..." + find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql.gz" -mtime +${RETENTION_DAYS} -delete + find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql" -mtime +${RETENTION_DAYS} -delete + log "过期备份清理完成" +} + +# 发送备份状态通知(可选) +send_notification() { + local status=$1 + local message=$2 + + # 这里可以集成邮件、钉钉、微信等通知方式 + log "通知: ${status} - ${message}" + + # 示例:写入状态文件供监控系统读取 + echo "{\"timestamp\":\"$(date -Iseconds)\",\"status\":\"${status}\",\"message\":\"${message}\"}" > "${BACKUP_DIR}/backup_status.json" +} + +# 主函数 +main() { + log "========== 数据库备份开始 ==========" + + # 检查容器状态 + check_container + + # 执行备份 + if backup_database; then + # 压缩备份 + compress_backup + + # 清理过期备份 + cleanup_old_backups + + # 发送成功通知 + send_notification "SUCCESS" "数据库备份成功完成" + log "========== 数据库备份完成 ==========" + exit 0 + else + # 发送失败通知 + send_notification "FAILED" "数据库备份失败" + log "========== 数据库备份失败 ==========" + exit 1 + fi +} + +# 执行主函数 +main "$@" diff --git a/backend/scripts/binlog_rollback_tool.py b/backend/scripts/binlog_rollback_tool.py new file mode 100644 index 0000000..f632b76 --- /dev/null +++ b/backend/scripts/binlog_rollback_tool.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +""" +MySQL Binlog 回滚工具 +用于考培练系统的数据库回滚操作 + +功能: +1. 解析Binlog文件 +2. 生成反向SQL语句 +3. 执行数据回滚 +4. 支持时间范围和表过滤 + +使用方法: +python scripts/binlog_rollback_tool.py --help +""" + +import asyncio +import argparse +import subprocess +import tempfile +import os +import re +from datetime import datetime, timedelta +from pathlib import Path +from typing import List, Dict, Optional +import aiomysql +import logging + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) + +class BinlogRollbackTool: + """Binlog回滚工具类""" + + def __init__(self, + host: str = "localhost", + port: int = 3306, + user: str = "root", + password: str = "root", + database: str = "kaopeilian"): + self.host = host + self.port = port + self.user = user + self.password = password + self.database = database + self.connection = None + + async def connect(self): + """连接到MySQL数据库""" + try: + self.connection = await aiomysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + db=self.database, + charset='utf8mb4' + ) + logger.info(f"✅ 成功连接到数据库 {self.database}") + except Exception as e: + logger.error(f"❌ 数据库连接失败: {e}") + raise + + async def close(self): + """关闭数据库连接""" + if self.connection: + self.connection.close() + logger.info("🔒 数据库连接已关闭") + + async def get_binlog_files(self) -> List[Dict]: + """获取Binlog文件列表""" + cursor = await self.connection.cursor() + await cursor.execute("SHOW BINARY LOGS") + result = await cursor.fetchall() + await cursor.close() + + binlog_files = [] + for row in result: + binlog_files.append({ + 'name': row[0], + 'size': row[1], + 'encrypted': row[2] if len(row) > 2 else False + }) + + logger.info(f"📋 找到 {len(binlog_files)} 个Binlog文件") + return binlog_files + + async def get_binlog_position_by_time(self, target_time: datetime) -> Optional[str]: + """根据时间获取Binlog位置""" + cursor = await self.connection.cursor() + + # 获取所有Binlog文件 + binlog_files = await self.get_binlog_files() + + for binlog_file in binlog_files: + try: + # 使用mysqlbinlog解析文件,查找时间点 + cmd = [ + 'mysqlbinlog', + '--start-datetime', target_time.strftime('%Y-%m-%d %H:%M:%S'), + '--stop-datetime', (target_time + timedelta(seconds=1)).strftime('%Y-%m-%d %H:%M:%S'), + f'/var/lib/mysql/{binlog_file["name"]}' + ] + + result = subprocess.run(cmd, capture_output=True, text=True, timeout=30) + if result.returncode == 0 and result.stdout.strip(): + logger.info(f"📍 在 {binlog_file['name']} 中找到时间点 {target_time}") + return binlog_file['name'] + + except Exception as e: + logger.warning(f"⚠️ 解析 {binlog_file['name']} 时出错: {e}") + continue + + logger.warning(f"⚠️ 未找到时间点 {target_time} 对应的Binlog位置") + return None + + def parse_binlog_to_sql(self, + binlog_file: str, + start_time: Optional[datetime] = None, + stop_time: Optional[datetime] = None, + tables: Optional[List[str]] = None) -> str: + """解析Binlog文件生成SQL语句""" + + # 构建mysqlbinlog命令 + cmd = ['mysqlbinlog', '--base64-output=decode-rows', '-v'] + + if start_time: + cmd.extend(['--start-datetime', start_time.strftime('%Y-%m-%d %H:%M:%S')]) + + if stop_time: + cmd.extend(['--stop-datetime', stop_time.strftime('%Y-%m-%d %H:%M:%S')]) + + # 添加数据库过滤 + cmd.extend(['--database', self.database]) + + # 添加表过滤 + if tables: + for table in tables: + cmd.extend(['--table', table]) + + # 添加Binlog文件路径 + cmd.append(f'/var/lib/mysql/{binlog_file}') + + logger.info(f"🔍 执行命令: {' '.join(cmd)}") + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + if result.returncode != 0: + logger.error(f"❌ mysqlbinlog执行失败: {result.stderr}") + return "" + + return result.stdout + + except subprocess.TimeoutExpired: + logger.error("❌ mysqlbinlog执行超时") + return "" + except Exception as e: + logger.error(f"❌ mysqlbinlog执行异常: {e}") + return "" + + def generate_reverse_sql(self, binlog_sql: str) -> List[str]: + """生成反向SQL语句""" + reverse_sqls = [] + + # 解析INSERT语句,转换为DELETE + insert_pattern = r'INSERT INTO `([^`]+)` \(([^)]+)\) VALUES \((.+)\);' + for match in re.finditer(insert_pattern, binlog_sql, re.MULTILINE): + table = match.group(1) + columns = match.group(2) + values = match.group(3) + + # 构建WHERE条件 + where_conditions = [] + column_list = [col.strip().strip('`') for col in columns.split(',')] + value_list = [val.strip().strip("'") for val in values.split(',')] + + for col, val in zip(column_list, value_list): + if val != 'NULL': + where_conditions.append(f"`{col}` = '{val}'") + + if where_conditions: + delete_sql = f"DELETE FROM `{table}` WHERE {' AND '.join(where_conditions)};" + reverse_sqls.append(delete_sql) + + # 解析UPDATE语句,生成反向UPDATE + update_pattern = r'UPDATE `([^`]+)` SET (.+) WHERE (.+);' + for match in re.finditer(update_pattern, binlog_sql, re.MULTILINE): + table = match.group(1) + set_clause = match.group(2) + where_clause = match.group(3) + + # 这里需要从Binlog中提取原始值,ROW格式的Binlog包含@1, @2等变量 + # 简化处理:生成警告信息 + reverse_sqls.append(f"-- 需要手动处理UPDATE语句: UPDATE `{table}` SET {set_clause} WHERE {where_clause};") + + # 解析DELETE语句,转换为INSERT + delete_pattern = r'DELETE FROM `([^`]+)` WHERE (.+);' + for match in re.finditer(delete_pattern, binlog_sql, re.MULTILINE): + table = match.group(1) + where_clause = match.group(2) + + # 简化处理:生成警告信息 + reverse_sqls.append(f"-- 需要手动处理DELETE语句: INSERT INTO `{table}` ... WHERE {where_clause};") + + return reverse_sqls + + async def execute_rollback_sql(self, sql_statements: List[str], dry_run: bool = True) -> bool: + """执行回滚SQL语句""" + if not sql_statements: + logger.warning("⚠️ 没有需要执行的SQL语句") + return True + + if dry_run: + logger.info("🔍 模拟执行模式 - 以下SQL语句将被执行:") + for i, sql in enumerate(sql_statements, 1): + logger.info(f"{i:3d}. {sql}") + return True + + cursor = await self.connection.cursor() + + try: + # 开始事务 + await cursor.execute("START TRANSACTION") + logger.info("🔄 开始回滚事务") + + for i, sql in enumerate(sql_statements, 1): + if sql.strip().startswith('--'): + logger.info(f"⏭️ 跳过注释: {sql}") + continue + + try: + await cursor.execute(sql) + logger.info(f"✅ 执行成功 ({i}/{len(sql_statements)}): {sql[:100]}...") + except Exception as e: + logger.error(f"❌ 执行失败 ({i}/{len(sql_statements)}): {sql}") + logger.error(f" 错误信息: {e}") + raise + + # 提交事务 + await cursor.execute("COMMIT") + logger.info("✅ 回滚事务提交成功") + return True + + except Exception as e: + # 回滚事务 + await cursor.execute("ROLLBACK") + logger.error(f"❌ 回滚事务失败,已回滚: {e}") + return False + finally: + await cursor.close() + + async def rollback_by_time(self, + target_time: datetime, + tables: Optional[List[str]] = None, + dry_run: bool = True) -> bool: + """根据时间点进行回滚""" + logger.info(f"🎯 开始回滚到时间点: {target_time}") + + # 查找对应的Binlog文件 + binlog_file = await self.get_binlog_position_by_time(target_time) + if not binlog_file: + logger.error("❌ 未找到对应的Binlog文件") + return False + + # 解析Binlog生成SQL + binlog_sql = self.parse_binlog_to_sql( + binlog_file=binlog_file, + start_time=target_time, + tables=tables + ) + + if not binlog_sql: + logger.error("❌ 解析Binlog失败") + return False + + # 生成反向SQL + reverse_sqls = self.generate_reverse_sql(binlog_sql) + + if not reverse_sqls: + logger.warning("⚠️ 未生成反向SQL语句") + return True + + # 执行回滚 + return await self.execute_rollback_sql(reverse_sqls, dry_run) + +async def main(): + """主函数""" + parser = argparse.ArgumentParser(description='MySQL Binlog 回滚工具') + parser.add_argument('--host', default='localhost', help='MySQL主机地址') + parser.add_argument('--port', type=int, default=3306, help='MySQL端口') + parser.add_argument('--user', default='root', help='MySQL用户名') + parser.add_argument('--password', default='root', help='MySQL密码') + parser.add_argument('--database', default='kaopeilian', help='数据库名') + parser.add_argument('--time', required=True, help='回滚到的时间点 (格式: YYYY-MM-DD HH:MM:SS)') + parser.add_argument('--tables', nargs='*', help='指定要回滚的表名') + parser.add_argument('--execute', action='store_true', help='实际执行回滚(默认只模拟)') + parser.add_argument('--list-binlogs', action='store_true', help='列出所有Binlog文件') + + args = parser.parse_args() + + # 创建回滚工具实例 + tool = BinlogRollbackTool( + host=args.host, + port=args.port, + user=args.user, + password=args.password, + database=args.database + ) + + try: + await tool.connect() + + if args.list_binlogs: + # 列出Binlog文件 + binlog_files = await tool.get_binlog_files() + print("\n📋 Binlog文件列表:") + for i, file_info in enumerate(binlog_files, 1): + print(f"{i:2d}. {file_info['name']} ({file_info['size']} bytes)") + return + + # 解析时间参数 + try: + target_time = datetime.strptime(args.time, '%Y-%m-%d %H:%M:%S') + except ValueError: + logger.error("❌ 时间格式错误,请使用: YYYY-MM-DD HH:MM:SS") + return + + # 执行回滚 + dry_run = not args.execute + success = await tool.rollback_by_time( + target_time=target_time, + tables=args.tables, + dry_run=dry_run + ) + + if success: + if dry_run: + logger.info("🔍 模拟执行完成,使用 --execute 参数实际执行回滚") + else: + logger.info("✅ 回滚操作完成") + else: + logger.error("❌ 回滚操作失败") + + except Exception as e: + logger.error(f"❌ 程序执行异常: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/check_backup_status.sh b/backend/scripts/check_backup_status.sh new file mode 100755 index 0000000..f979d84 --- /dev/null +++ b/backend/scripts/check_backup_status.sh @@ -0,0 +1,123 @@ +#!/bin/bash + +# 考陪练系统数据库备份状态检查脚本 +# 作者: AI Assistant +# 日期: 2025-09-23 + +BACKUP_DIR="/root/aiedu/kaopeilian-backend/backups" +LOG_FILE="${BACKUP_DIR}/check_status.log" + +# 日志函数 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "${LOG_FILE}" +} + +# 检查最近的备份文件 +check_recent_backup() { + local recent_backup=$(find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql.gz" -type f -mtime -1 | sort | tail -1) + + if [ -n "${recent_backup}" ]; then + local backup_time=$(stat -c %Y "${recent_backup}") + local current_time=$(date +%s) + local time_diff=$((current_time - backup_time)) + local hours_diff=$((time_diff / 3600)) + + log "最近备份: $(basename ${recent_backup})" + log "备份时间: $(stat -c %y "${recent_backup}")" + log "距离现在: ${hours_diff} 小时前" + + if [ ${hours_diff} -gt 2 ]; then + log "WARNING: 备份文件超过2小时未更新" + return 1 + else + log "备份状态: 正常" + return 0 + fi + else + log "ERROR: 未找到最近的备份文件" + return 1 + fi +} + +# 检查备份文件大小 +check_backup_size() { + local recent_backup=$(find "${BACKUP_DIR}" -name "kaopeilian_backup_*.sql.gz" -type f -mtime -1 | sort | tail -1) + + if [ -n "${recent_backup}" ]; then + local backup_size=$(stat -c %s "${recent_backup}") + local backup_size_mb=$((backup_size / 1024 / 1024)) + + log "备份文件大小: ${backup_size_mb}MB" + + if [ ${backup_size_mb} -lt 1 ]; then + log "WARNING: 备份文件过小,可能不完整" + return 1 + else + log "备份文件大小: 正常" + return 0 + fi + fi +} + +# 检查定时任务状态 +check_cron_status() { + if crontab -l | grep -q "backup_database.sh"; then + log "定时任务: 已配置" + return 0 + else + log "ERROR: 定时任务未配置" + return 1 + fi +} + +# 检查systemd定时器状态 +check_systemd_timer() { + if systemctl is-active kaopeilian-backup.timer >/dev/null 2>&1; then + log "Systemd定时器: 运行中" + + # 获取下次执行时间 + local next_run=$(systemctl list-timers kaopeilian-backup.timer --no-pager | grep kaopeilian-backup.timer | awk '{print $1, $2}') + log "下次执行时间: ${next_run}" + + return 0 + else + log "ERROR: Systemd定时器未运行" + return 1 + fi +} + +# 检查Docker容器状态 +check_docker_container() { + if docker ps | grep -q "kaopeilian_mysql"; then + log "MySQL容器: 运行中" + return 0 + else + log "ERROR: MySQL容器未运行" + return 1 + fi +} + +# 主检查函数 +main() { + log "========== 备份状态检查开始 ==========" + + local exit_code=0 + + # 执行各项检查 + check_recent_backup || exit_code=1 + check_backup_size || exit_code=1 + check_cron_status || exit_code=1 + check_systemd_timer || exit_code=1 + check_docker_container || exit_code=1 + + if [ ${exit_code} -eq 0 ]; then + log "========== 备份状态检查完成: 全部正常 ==========" + else + log "========== 备份状态检查完成: 发现问题 ==========" + fi + + return ${exit_code} +} + +# 执行主函数 +main "$@" diff --git a/backend/scripts/check_database_status.py b/backend/scripts/check_database_status.py new file mode 100644 index 0000000..d463938 --- /dev/null +++ b/backend/scripts/check_database_status.py @@ -0,0 +1,178 @@ +""" +检查数据库现状脚本 +用于探索users、teams、exams、practice_sessions表的数据情况 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import select, func +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.logger import logger +from app.models.user import User, Team, UserTeam +from app.models.exam import Exam +from app.models.practice import PracticeSession +from app.models.position_member import PositionMember + + +async def check_database(db: AsyncSession): + """检查数据库数据""" + + print("=" * 60) + print("数据库状态检查") + print("=" * 60) + + # 1. 检查用户数据 + print("\n【用户表 (users)】") + result = await db.execute(select(func.count()).select_from(User)) + total_users = result.scalar() or 0 + print(f"总用户数: {total_users}") + + if total_users > 0: + # 按角色统计 + result = await db.execute( + select(User.role, func.count()).group_by(User.role) + ) + print("按角色统计:") + for role, count in result.all(): + print(f" - {role}: {count}") + + # 显示部分用户 + result = await db.execute( + select(User).limit(5) + ) + users = result.scalars().all() + print("\n前5个用户:") + for user in users: + print(f" - ID:{user.id}, 用户名:{user.username}, 姓名:{user.full_name}, 角色:{user.role}") + + # 2. 检查团队数据 + print("\n【团队表 (teams)】") + result = await db.execute(select(func.count()).select_from(Team)) + total_teams = result.scalar() or 0 + print(f"总团队数: {total_teams}") + + if total_teams > 0: + result = await db.execute(select(Team).limit(5)) + teams = result.scalars().all() + print("团队列表:") + for team in teams: + print(f" - ID:{team.id}, 名称:{team.name}, 代码:{team.code}, 类型:{team.team_type}") + + # 3. 检查用户团队关联 + print("\n【用户团队关联表 (user_teams)】") + result = await db.execute(select(func.count()).select_from(UserTeam)) + total_relations = result.scalar() or 0 + print(f"总关联记录数: {total_relations}") + + if total_relations > 0: + # 查询前5条关联记录 + result = await db.execute( + select(UserTeam, User.full_name, Team.name) + .join(User, UserTeam.user_id == User.id) + .join(Team, UserTeam.team_id == Team.id) + .limit(5) + ) + print("关联记录示例:") + for user_team, user_name, team_name in result.all(): + print(f" - 用户:{user_name}, 团队:{team_name}, 角色:{user_team.role}") + + # 4. 检查岗位成员 + print("\n【岗位成员表 (position_members)】") + result = await db.execute( + select(func.count()).select_from(PositionMember).where( + PositionMember.is_deleted == False + ) + ) + total_position_members = result.scalar() or 0 + print(f"总岗位成员数: {total_position_members}") + + # 5. 检查考试记录 + print("\n【考试表 (exams)】") + result = await db.execute(select(func.count()).select_from(Exam)) + total_exams = result.scalar() or 0 + print(f"总考试记录数: {total_exams}") + + if total_exams > 0: + # 按状态统计 + result = await db.execute( + select(Exam.status, func.count()).group_by(Exam.status) + ) + print("按状态统计:") + for status, count in result.all(): + print(f" - {status}: {count}") + + # 显示最近5条考试记录 + result = await db.execute( + select(Exam, User.full_name) + .join(User, Exam.user_id == User.id) + .order_by(Exam.created_at.desc()) + .limit(5) + ) + print("\n最近5条考试记录:") + for exam, user_name in result.all(): + print(f" - 用户:{user_name}, 课程ID:{exam.course_id}, 状态:{exam.status}, " + f"分数:{exam.round1_score}, 时间:{exam.created_at}") + + # 6. 检查陪练记录 + print("\n【陪练会话表 (practice_sessions)】") + result = await db.execute(select(func.count()).select_from(PracticeSession)) + total_sessions = result.scalar() or 0 + print(f"总陪练记录数: {total_sessions}") + + if total_sessions > 0: + # 按状态统计 + result = await db.execute( + select(PracticeSession.status, func.count()).group_by(PracticeSession.status) + ) + print("按状态统计:") + for status, count in result.all(): + print(f" - {status}: {count}") + + # 显示最近5条陪练记录 + result = await db.execute( + select(PracticeSession, User.full_name) + .join(User, PracticeSession.user_id == User.id) + .order_by(PracticeSession.start_time.desc()) + .limit(5) + ) + print("\n最近5条陪练记录:") + for session, user_name in result.all(): + print(f" - 用户:{user_name}, 场景:{session.scene_name}, 状态:{session.status}, " + f"时长:{session.duration_seconds}秒, 时间:{session.start_time}") + + # 总结 + print("\n" + "=" * 60) + print("检查总结:") + print("=" * 60) + if total_users == 0: + print("❌ 数据库为空,需要注入测试数据") + elif total_teams == 0 or total_relations == 0: + print("⚠️ 有用户但无团队数据,需要创建团队") + elif total_exams == 0: + print("⚠️ 有用户和团队但无考试记录,需要创建考试数据") + elif total_sessions == 0: + print("⚠️ 有用户和团队但无陪练记录,需要创建陪练数据") + else: + print("✅ 数据库有基本数据,可以直接使用") + print("=" * 60) + + +async def main(): + """主函数""" + async with AsyncSessionLocal() as db: + try: + await check_database(db) + except Exception as e: + logger.error(f"检查数据库失败: {str(e)}", exc_info=True) + raise + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/scripts/cleanup_users.py b/backend/scripts/cleanup_users.py new file mode 100644 index 0000000..c31147e --- /dev/null +++ b/backend/scripts/cleanup_users.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +""" +用户清理脚本 +删除除admin以外的所有用户,为员工同步做准备 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import select, delete +from app.core.database import AsyncSessionLocal +from app.models.user import User +from app.core.logger import get_logger + +logger = get_logger(__name__) + + +async def cleanup_users(): + """ + 删除除admin以外的所有用户 + 保留username='admin'的管理员账号 + """ + async with AsyncSessionLocal() as db: + try: + # 查询要删除的用户 + stmt = select(User).where(User.username != 'admin', User.is_deleted == False) + result = await db.execute(stmt) + users_to_delete = result.scalars().all() + + if not users_to_delete: + logger.info("没有需要删除的用户") + return + + logger.info(f"准备删除 {len(users_to_delete)} 个用户") + for user in users_to_delete: + logger.info(f" - ID: {user.id}, 用户名: {user.username}, 姓名: {user.full_name}") + + # 确认删除 + print("\n⚠️ 警告: 将删除以上用户(保留admin)") + confirm = input("确认删除?(yes/no): ") + + if confirm.lower() != 'yes': + logger.info("取消删除操作") + return + + # 执行软删除 + for user in users_to_delete: + user.is_deleted = True + logger.info(f"已软删除用户: {user.username}") + + await db.commit() + logger.info(f"✅ 成功删除 {len(users_to_delete)} 个用户") + + # 显示剩余用户 + stmt = select(User).where(User.is_deleted == False) + result = await db.execute(stmt) + remaining_users = result.scalars().all() + + logger.info(f"\n剩余用户数量: {len(remaining_users)}") + for user in remaining_users: + logger.info(f" - ID: {user.id}, 用户名: {user.username}, 角色: {user.role}") + + except Exception as e: + logger.error(f"清理用户失败: {str(e)}") + await db.rollback() + raise + + +if __name__ == "__main__": + print("=" * 60) + print("用户清理脚本") + print("=" * 60) + asyncio.run(cleanup_users()) + diff --git a/backend/scripts/create_course_exam_settings.sql b/backend/scripts/create_course_exam_settings.sql new file mode 100644 index 0000000..0eb8a06 --- /dev/null +++ b/backend/scripts/create_course_exam_settings.sql @@ -0,0 +1,35 @@ +-- 创建课程考试设置表 +CREATE TABLE IF NOT EXISTS course_exam_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL UNIQUE COMMENT '课程ID', + single_choice_count INT NOT NULL DEFAULT 10 COMMENT '单选题数量', + multiple_choice_count INT NOT NULL DEFAULT 5 COMMENT '多选题数量', + true_false_count INT NOT NULL DEFAULT 5 COMMENT '判断题数量', + fill_blank_count INT NOT NULL DEFAULT 0 COMMENT '填空题数量', + duration_minutes INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', + difficulty_level INT NOT NULL DEFAULT 3 COMMENT '难度系数(1-5)', + passing_score INT NOT NULL DEFAULT 60 COMMENT '及格分数', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + show_answer_immediately BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否立即显示答案', + allow_retake BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许重考', + max_retake_times INT COMMENT '最大重考次数', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + deleted_by INT COMMENT '删除人ID', + FOREIGN KEY (course_id) REFERENCES courses(id), + INDEX ix_course_exam_settings_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; + +-- 插入示例课程考试设置 +INSERT INTO course_exam_settings (course_id, single_choice_count, multiple_choice_count, true_false_count, duration_minutes) +VALUES +(1, 15, 8, 5, 60), +(2, 20, 10, 10, 90), +(3, 10, 5, 5, 45), +(4, 12, 6, 8, 60); + +SELECT 'Course exam settings table created successfully!' as message; diff --git a/backend/scripts/create_practice_analysis_tables.sql b/backend/scripts/create_practice_analysis_tables.sql new file mode 100644 index 0000000..ac6d38a --- /dev/null +++ b/backend/scripts/create_practice_analysis_tables.sql @@ -0,0 +1,79 @@ +-- 陪练分析报告功能数据库表 +-- 创建时间:2025-10-13 + +-- 1. 陪练会话表 +CREATE TABLE IF NOT EXISTS `practice_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(50) NOT NULL UNIQUE COMMENT '会话ID(如PS006)', + `user_id` INT NOT NULL COMMENT '学员ID', + `scene_id` INT COMMENT '场景ID', + `scene_name` VARCHAR(200) COMMENT '场景名称', + `scene_type` VARCHAR(50) COMMENT '场景类型:phone/face/complaint等', + `conversation_id` VARCHAR(100) COMMENT 'Coze对话ID', + + -- 会话时间信息 + `start_time` DATETIME NOT NULL COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT DEFAULT 0 COMMENT '时长(秒)', + `turns` INT DEFAULT 0 COMMENT '对话轮次', + `status` VARCHAR(20) DEFAULT 'in_progress' COMMENT '状态:in_progress/completed/canceled', + + -- 审计字段 + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (scene_id) REFERENCES practice_scenes(id) ON DELETE SET NULL, + INDEX idx_user_id (user_id), + INDEX idx_session_id (session_id), + INDEX idx_start_time (start_time), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; + +-- 2. 对话记录表 +CREATE TABLE IF NOT EXISTS `practice_dialogues` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(50) NOT NULL COMMENT '会话ID', + `speaker` VARCHAR(20) NOT NULL COMMENT '说话人:user/ai', + `content` TEXT NOT NULL COMMENT '对话内容', + `timestamp` DATETIME NOT NULL COMMENT '时间戳', + `sequence` INT NOT NULL COMMENT '顺序号(从1开始)', + + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + + INDEX idx_session_id (session_id), + INDEX idx_sequence (session_id, sequence) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练对话记录表'; + +-- 3. 分析报告表 +CREATE TABLE IF NOT EXISTS `practice_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` VARCHAR(50) NOT NULL UNIQUE COMMENT '会话ID', + + -- AI分析结果(JSON存储) + `total_score` INT COMMENT '综合得分(0-100)', + `score_breakdown` JSON COMMENT '分数细分:开场技巧、需求挖掘等', + `ability_dimensions` JSON COMMENT '能力维度:沟通表达、倾听理解等', + `dialogue_review` JSON COMMENT '对话复盘(标注亮点话术/金牌话术)', + `suggestions` JSON COMMENT '改进建议', + + -- Dify工作流信息 + `workflow_run_id` VARCHAR(100) COMMENT 'Dify工作流运行ID', + `task_id` VARCHAR(100) COMMENT 'Dify任务ID', + + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + INDEX idx_session_id (session_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练分析报告表'; + +-- 验证表创建 +SELECT + TABLE_NAME, + TABLE_ROWS, + TABLE_COMMENT +FROM information_schema.TABLES +WHERE TABLE_SCHEMA = 'kaopeilian' + AND TABLE_NAME IN ('practice_sessions', 'practice_dialogues', 'practice_reports'); + diff --git a/backend/scripts/create_practice_scenes.sql b/backend/scripts/create_practice_scenes.sql new file mode 100644 index 0000000..61a1844 --- /dev/null +++ b/backend/scripts/create_practice_scenes.sql @@ -0,0 +1,107 @@ +-- 创建陪练场景表 +CREATE TABLE IF NOT EXISTS `practice_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `type` VARCHAR(50) NOT NULL COMMENT '场景类型: phone/face/complaint/after-sales/product-intro', + `difficulty` VARCHAR(50) NOT NULL COMMENT '难度等级: beginner/junior/intermediate/senior/expert', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/inactive', + `background` TEXT COMMENT '场景背景设定', + `ai_role` TEXT COMMENT 'AI角色描述', + `objectives` JSON COMMENT '练习目标数组', + `keywords` JSON COMMENT '关键词数组', + `duration` INT DEFAULT 10 COMMENT '预计时长(分钟)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `rating` DECIMAL(3,1) DEFAULT 0.0 COMMENT '评分', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + INDEX idx_type (type), + INDEX idx_difficulty (difficulty), + INDEX idx_status (status), + INDEX idx_is_deleted (is_deleted), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; + +-- 插入初始场景数据(5个场景,涵盖不同类型和难度) +INSERT INTO `practice_scenes` (name, description, type, difficulty, status, background, ai_role, objectives, keywords, duration, created_by, updated_by) VALUES +( + '初次电话拜访客户', + '模拟首次通过电话联系潜在客户的场景,练习专业的电话销售技巧', + 'phone', + 'beginner', + 'active', + '你是一名轻医美品牌的销售专员,需要通过电话联系一位从未接触过的潜在客户。客户是某美容院的老板,你需要在短时间内引起她的兴趣,介绍你们的产品和服务。', + 'AI扮演一位忙碌的美容院老板,对推销电话比较抵触,但如果销售人员能够快速切入她的需求点(如提升业绩、吸引客户、新项目),她会愿意继续交谈。她关注产品效果、价格和培训支持。', + '["学会专业的电话开场白", "快速建立信任关系", "有效探询客户需求", "预约下次沟通时间"]', + '["开场白", "需求挖掘", "时间管理", "预约技巧"]', + 10, + 1, + 1 +), +( + '处理价格异议', + '练习如何应对客户对产品价格的质疑和异议,强调价值而非价格', + 'face', + 'intermediate', + 'active', + '客户对你们的轻医美产品很感兴趣,已经了解了产品功能和效果,但认为价格太高,超出了她的预算。你需要通过价值塑造和对比分析来化解价格异议。', + 'AI扮演一位对价格非常敏感的美容院老板,她会不断提出价格异议,例如"太贵了"、"竞品便宜一半"、"能不能再便宜点"。但如果销售人员能够有效展示产品价值、投资回报率和长期收益,她会逐渐被说服。', + '["掌握价值塑造技巧", "学会处理价格异议", "提升谈判能力", "展示投资回报率"]', + '["异议处理", "价值塑造", "谈判技巧", "ROI分析"]', + 15, + 1, + 1 +), +( + '客户投诉处理', + '模拟客户对产品或服务不满的投诉场景,练习专业的投诉处理技巧', + 'complaint', + 'intermediate', + 'active', + '一位客户购买了你们的轻医美产品后,使用效果不理想,打电话来投诉。她情绪比较激动,认为产品宣传与实际不符,要求退款。你需要安抚客户情绪,了解问题根源,并提供合理的解决方案。', + 'AI扮演一位情绪激动的客户,她对产品效果不满意,觉得被欺骗了。她会表达强烈的不满和质疑,但如果客服人员能够真诚道歉、耐心倾听、专业分析问题原因并提供切实可行的解决方案,她的态度会逐渐缓和。', + '["掌握情绪安抚技巧", "学会倾听和共情", "分析问题并提供解决方案", "挽回客户信任"]', + '["投诉处理", "情绪管理", "问题分析", "客户挽回"]', + 12, + 1, + 1 +), +( + '产品功能介绍', + '练习向客户详细介绍轻医美产品的功能特点和优势', + 'product-intro', + 'junior', + 'active', + '客户对你们的轻医美产品有一定了解,现在希望你详细介绍产品的核心功能、技术原理、使用方法和效果保证。客户比较专业,会提出一些技术性问题。', + 'AI扮演一位专业的美容院经营者,她对轻医美产品有一定了解,会提出具体的技术问题和需求。例如询问产品成分、作用机理、适用人群、操作流程、注意事项等。她希望得到专业、详细、真实的回答。', + '["清晰介绍产品功能和原理", "突出产品优势和差异化", "专业回答技术问题", "建立专业形象"]', + '["产品介绍", "功能展示", "优势分析", "技术解答"]', + 12, + 1, + 1 +), +( + '售后服务咨询', + '模拟客户咨询售后服务的场景,练习专业的售后服务沟通技巧', + 'after-sales', + 'beginner', + 'active', + '客户已经购买了你们的轻医美产品,现在打电话咨询售后服务相关问题,包括产品使用方法、遇到的小问题、培训支持、配件购买等。你需要提供专业、耐心、周到的售后服务。', + 'AI扮演一位已购买产品的美容院老板,她在使用产品过程中遇到一些问题或疑问,希望得到专业的指导和帮助。她的态度比较友好,但希望得到快速、有效的解决方案。她会根据服务质量评价品牌。', + '["掌握产品使用指导技巧", "快速解决客户问题", "提供专业培训支持", "增强客户满意度和忠诚度"]', + '["售后服务", "使用指导", "问题解决", "客户维护"]', + 10, + 1, + 1 +); + +-- 查询验证 +SELECT id, name, type, difficulty, status FROM practice_scenes WHERE is_deleted = FALSE; + + + diff --git a/backend/scripts/create_practice_table.py b/backend/scripts/create_practice_table.py new file mode 100644 index 0000000..981d66b --- /dev/null +++ b/backend/scripts/create_practice_table.py @@ -0,0 +1,198 @@ +#!/usr/bin/env python3 +""" +创建陪练场景表并插入初始数据 - 简单版本 +""" +import pymysql +import sys + +# 数据库连接配置 +DB_CONFIG = { + 'host': 'localhost', + 'port': 3306, + 'user': 'root', + 'password': 'nj861021', # 开发环境密码 + 'database': 'kaopeilian', + 'charset': 'utf8mb4' +} + +# SQL语句 +CREATE_TABLE_SQL = """ +CREATE TABLE IF NOT EXISTS `practice_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `type` VARCHAR(50) NOT NULL COMMENT '场景类型', + `difficulty` VARCHAR(50) NOT NULL COMMENT '难度等级', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态', + `background` TEXT COMMENT '场景背景设定', + `ai_role` TEXT COMMENT 'AI角色描述', + `objectives` JSON COMMENT '练习目标数组', + `keywords` JSON COMMENT '关键词数组', + `duration` INT DEFAULT 10 COMMENT '预计时长(分钟)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `rating` DECIMAL(3,1) DEFAULT 0.0 COMMENT '评分', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + INDEX idx_type (type), + INDEX idx_difficulty (difficulty), + INDEX idx_status (status), + INDEX idx_is_deleted (is_deleted), + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表' +""" + +# 初始场景数据 +SCENES = [ + { + 'name': '初次电话拜访客户', + 'description': '模拟首次通过电话联系潜在客户的场景,练习专业的电话销售技巧', + 'type': 'phone', + 'difficulty': 'beginner', + 'status': 'active', + 'background': '你是一名轻医美品牌的销售专员,需要通过电话联系一位从未接触过的潜在客户。客户是某美容院的老板,你需要在短时间内引起她的兴趣,介绍你们的产品和服务。', + 'ai_role': 'AI扮演一位忙碌的美容院老板,对推销电话比较抵触,但如果销售人员能够快速切入她的需求点(如提升业绩、吸引客户、新项目),她会愿意继续交谈。她关注产品效果、价格和培训支持。', + 'objectives': '["学会专业的电话开场白", "快速建立信任关系", "有效探询客户需求", "预约下次沟通时间"]', + 'keywords': '["开场白", "需求挖掘", "时间管理", "预约技巧"]', + 'duration': 10 + }, + { + 'name': '处理价格异议', + 'description': '练习如何应对客户对产品价格的质疑和异议,强调价值而非价格', + 'type': 'face', + 'difficulty': 'intermediate', + 'status': 'active', + 'background': '客户对你们的轻医美产品很感兴趣,已经了解了产品功能和效果,但认为价格太高,超出了她的预算。你需要通过价值塑造和对比分析来化解价格异议。', + 'ai_role': 'AI扮演一位对价格非常敏感的美容院老板,她会不断提出价格异议,例如"太贵了"、"竞品便宜一半"、"能不能再便宜点"。但如果销售人员能够有效展示产品价值、投资回报率和长期收益,她会逐渐被说服。', + 'objectives': '["掌握价值塑造技巧", "学会处理价格异议", "提升谈判能力", "展示投资回报率"]', + 'keywords': '["异议处理", "价值塑造", "谈判技巧", "ROI分析"]', + 'duration': 15 + }, + { + 'name': '客户投诉处理', + 'description': '模拟客户对产品或服务不满的投诉场景,练习专业的投诉处理技巧', + 'type': 'complaint', + 'difficulty': 'intermediate', + 'status': 'active', + 'background': '一位客户购买了你们的轻医美产品后,使用效果不理想,打电话来投诉。她情绪比较激动,认为产品宣传与实际不符,要求退款。你需要安抚客户情绪,了解问题根源,并提供合理的解决方案。', + 'ai_role': 'AI扮演一位情绪激动的客户,她对产品效果不满意,觉得被欺骗了。她会表达强烈的不满和质疑,但如果客服人员能够真诚道歉、耐心倾听、专业分析问题原因并提供切实可行的解决方案,她的态度会逐渐缓和。', + 'objectives': '["掌握情绪安抚技巧", "学会倾听和共情", "分析问题并提供解决方案", "挽回客户信任"]', + 'keywords': '["投诉处理", "情绪管理", "问题分析", "客户挽回"]', + 'duration': 12 + }, + { + 'name': '产品功能介绍', + 'description': '练习向客户详细介绍轻医美产品的功能特点和优势', + 'type': 'product-intro', + 'difficulty': 'junior', + 'status': 'active', + 'background': '客户对你们的轻医美产品有一定了解,现在希望你详细介绍产品的核心功能、技术原理、使用方法和效果保证。客户比较专业,会提出一些技术性问题。', + 'ai_role': 'AI扮演一位专业的美容院经营者,她对轻医美产品有一定了解,会提出具体的技术问题和需求。例如询问产品成分、作用机理、适用人群、操作流程、注意事项等。她希望得到专业、详细、真实的回答。', + 'objectives': '["清晰介绍产品功能和原理", "突出产品优势和差异化", "专业回答技术问题", "建立专业形象"]', + 'keywords': '["产品介绍", "功能展示", "优势分析", "技术解答"]', + 'duration': 12 + }, + { + 'name': '售后服务咨询', + 'description': '模拟客户咨询售后服务的场景,练习专业的售后服务沟通技巧', + 'type': 'after-sales', + 'difficulty': 'beginner', + 'status': 'active', + 'background': '客户已经购买了你们的轻医美产品,现在打电话咨询售后服务相关问题,包括产品使用方法、遇到的小问题、培训支持、配件购买等。你需要提供专业、耐心、周到的售后服务。', + 'ai_role': 'AI扮演一位已购买产品的美容院老板,她在使用产品过程中遇到一些问题或疑问,希望得到专业的指导和帮助。她的态度比较友好,但希望得到快速、有效的解决方案。她会根据服务质量评价品牌。', + 'objectives': '["掌握产品使用指导技巧", "快速解决客户问题", "提供专业培训支持", "增强客户满意度和忠诚度"]', + 'keywords': '["售后服务", "使用指导", "问题解决", "客户维护"]', + 'duration': 10 + } +] + +def main(): + """主函数""" + try: + print("=" * 60) + print("陪练场景表创建和初始数据插入") + print("=" * 60) + + # 连接数据库 + print("\n📝 连接数据库...") + connection = pymysql.connect(**DB_CONFIG) + cursor = connection.cursor() + + # 创建表 + print("📝 创建practice_scenes表...") + cursor.execute(CREATE_TABLE_SQL) + print("✅ 表创建成功") + + # 检查是否已有数据 + cursor.execute("SELECT COUNT(*) FROM practice_scenes WHERE is_deleted = FALSE") + count = cursor.fetchone()[0] + + if count > 0: + print(f"\n⚠️ 表中已有 {count} 条数据,是否要插入新数据?") + print(" 提示:如果数据已存在,将跳过插入") + + # 插入数据 + insert_sql = """ + INSERT INTO practice_scenes + (name, description, type, difficulty, status, background, ai_role, objectives, keywords, duration, created_by, updated_by) + VALUES (%(name)s, %(description)s, %(type)s, %(difficulty)s, %(status)s, %(background)s, %(ai_role)s, %(objectives)s, %(keywords)s, %(duration)s, 1, 1) + """ + + print(f"\n📝 插入 {len(SCENES)} 个初始场景...") + inserted = 0 + for scene in SCENES: + try: + cursor.execute(insert_sql, scene) + inserted += 1 + print(f" ✅ 插入场景: {scene['name']}") + except pymysql.err.IntegrityError as e: + if "Duplicate entry" in str(e): + print(f" ⚠️ 场景已存在: {scene['name']}") + else: + raise + + # 提交事务 + connection.commit() + print(f"\n✅ 成功插入 {inserted} 个场景") + + # 查询验证 + print("\n📝 查询验证...") + cursor.execute("SELECT id, name, type, difficulty, status FROM practice_scenes WHERE is_deleted = FALSE") + rows = cursor.fetchall() + + print(f"\n当前场景列表(共 {len(rows)} 个):") + print("-" * 80) + print(f"{'ID':<5} {'名称':<30} {'类型':<15} {'难度':<15} {'状态':<10}") + print("-" * 80) + for row in rows: + print(f"{row[0]:<5} {row[1]:<30} {row[2]:<15} {row[3]:<15} {row[4]:<10}") + + # 关闭连接 + cursor.close() + connection.close() + + print("\n" + "=" * 60) + print("✅ 陪练场景表创建和数据插入完成!") + print("=" * 60) + + except pymysql.err.OperationalError as e: + print(f"\n❌ 数据库连接失败: {e}") + print(" 请检查:") + print(" 1. MySQL服务是否启动") + print(" 2. 数据库配置是否正确") + print(" 3. 数据库kaopeilian是否存在") + sys.exit(1) + except Exception as e: + print(f"\n❌ 发生错误: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + main() + diff --git a/backend/scripts/create_test_data.py b/backend/scripts/create_test_data.py new file mode 100644 index 0000000..765d45a --- /dev/null +++ b/backend/scripts/create_test_data.py @@ -0,0 +1,170 @@ +""" +创建测试数据脚本 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.database import AsyncSessionLocal +from app.core.logger import logger +from app.core.security import get_password_hash +from app.models.user import Team, User +from app.schemas.user import UserCreate +from app.services.user_service import UserService + + +async def create_test_users(db: AsyncSession): + """创建测试用户""" + user_service = UserService(db) + + # 测试用户数据 + test_users = [ + { + "username": "admin", + "email": "admin@kaopeilian.com", + "password": "admin123", + "full_name": "系统管理员", + "role": "admin", + }, + { + "username": "trainee1", + "email": "trainee1@kaopeilian.com", + "password": "trainee123", + "full_name": "张三", + "phone": "13800138001", + "role": "trainee", + }, + { + "username": "trainee2", + "email": "trainee2@kaopeilian.com", + "password": "trainee123", + "full_name": "李四", + "phone": "13800138002", + "role": "trainee", + }, + ] + + created_users = [] + for user_data in test_users: + # 检查用户是否已存在 + existing_user = await user_service.get_by_username(user_data["username"]) + if existing_user: + logger.info(f"用户 {user_data['username']} 已存在,跳过创建") + created_users.append(existing_user) + else: + # 创建用户 + user_create = UserCreate(**user_data) + user = await user_service.create_user(obj_in=user_create) + created_users.append(user) + logger.info(f"创建用户: {user.username} ({user.role})") + + return created_users + + +async def create_test_teams(db: AsyncSession, users: list[User]): + """创建测试团队""" + # 获取管理员 + admin = next(u for u in users if u.role == "admin") + + # 检查团队是否已存在 + from sqlalchemy import select + result = await db.execute( + select(Team).where(Team.code == "TECH") + ) + existing_team = result.scalar_one_or_none() + + if not existing_team: + # 创建技术部 + tech_team = Team( + name="技术部", + code="TECH", + description="负责产品研发和技术支持", + team_type="department", + leader_id=admin.id, + created_by=admin.id, + ) + db.add(tech_team) + await db.commit() + await db.refresh(tech_team) + logger.info(f"创建团队: {tech_team.name}") + + # 创建前端组 + frontend_team = Team( + name="前端开发组", + code="TECH-FE", + description="负责前端开发", + team_type="project", + parent_id=tech_team.id, + created_by=admin.id, + ) + db.add(frontend_team) + + # 创建后端组 + backend_team = Team( + name="后端开发组", + code="TECH-BE", + description="负责后端开发", + team_type="project", + parent_id=tech_team.id, + created_by=admin.id, + ) + db.add(backend_team) + + await db.commit() + logger.info("创建子团队: 前端开发组、后端开发组") + + # 将用户加入团队 + user_service = UserService(db) + + # 学员加入子团队 + for user in users: + if user.role == "trainee": + if "1" in user.username: + await user_service.add_user_to_team( + user_id=user.id, + team_id=frontend_team.id, + role="member" + ) + else: + await user_service.add_user_to_team( + user_id=user.id, + team_id=backend_team.id, + role="member" + ) + + logger.info("用户已加入相应团队") + else: + logger.info("团队已存在,跳过创建") + + +async def main(): + """主函数""" + async with AsyncSessionLocal() as db: + try: + logger.info("开始创建测试数据...") + + # 创建测试用户 + users = await create_test_users(db) + + # 创建测试团队 + await create_test_teams(db, users) + + logger.info("测试数据创建完成!") + logger.info("\n可用的测试账号:") + logger.info("管理员 - 用户名: admin, 密码: admin123") + logger.info("学员 - 用户名: trainee1, 密码: trainee123") + logger.info("学员 - 用户名: trainee2, 密码: trainee123") + + except Exception as e: + logger.error(f"创建测试数据失败: {str(e)}") + await db.rollback() + raise + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/fix_chinese_data.py b/backend/scripts/fix_chinese_data.py new file mode 100644 index 0000000..abd7a19 --- /dev/null +++ b/backend/scripts/fix_chinese_data.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +修复数据库中的中文乱码问题 +重新插入正确的中文数据 +""" +import asyncio +import aiomysql +from pathlib import Path +import sys + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +async def fix_chinese_data(): + """修复中文数据""" + print("🔧 开始修复数据库中的中文乱码问题...") + + # 正确的课程数据 + correct_courses = [ + { + "id": 1, + "name": "皮肤生理学基础", + "description": "学习皮肤结构、功能和常见问题,为专业护理打下基础", + "category": "technology", + "status": "published", + "duration_hours": 16.0, + "difficulty_level": 2, + "tags": ["皮肤学", "基础理论", "必修课"], + "sort_order": 100, + "is_featured": True + }, + { + "id": 2, + "name": "医美产品知识与应用", + "description": "全面了解各类医美产品的成分、功效和适用人群", + "category": "technology", + "status": "published", + "duration_hours": 20.0, + "difficulty_level": 3, + "tags": ["产品知识", "医美", "专业技能"], + "sort_order": 110, + "is_featured": True + }, + { + "id": 3, + "name": "美容仪器操作与维护", + "description": "掌握各类美容仪器的操作方法、注意事项和日常维护", + "category": "technology", + "status": "published", + "duration_hours": 24.0, + "difficulty_level": 3, + "tags": ["仪器操作", "实操技能", "设备维护"], + "sort_order": 120, + "is_featured": False + }, + { + "id": 4, + "name": "医美项目介绍与咨询", + "description": "详细了解各类医美项目的原理、效果和适应症", + "category": "technology", + "status": "published", + "duration_hours": 30.0, + "difficulty_level": 4, + "tags": ["医美项目", "专业咨询", "风险告知"], + "sort_order": 170, + "is_featured": True + }, + { + "id": 5, + "name": "轻医美销售技巧", + "description": "学习专业的销售话术、客户需求分析和成交技巧", + "category": "business", + "status": "published", + "duration_hours": 16.0, + "difficulty_level": 2, + "tags": ["销售技巧", "客户沟通", "业绩提升"], + "sort_order": 130, + "is_featured": True + }, + { + "id": 6, + "name": "客户服务与投诉处理", + "description": "提升服务意识,掌握客户投诉处理的方法和技巧", + "category": "business", + "status": "published", + "duration_hours": 12.0, + "difficulty_level": 2, + "tags": ["客户服务", "危机处理", "沟通技巧"], + "sort_order": 140, + "is_featured": False + }, + { + "id": 7, + "name": "社媒营销与私域运营", + "description": "学习如何通过社交媒体进行品牌推广和客户维护", + "category": "business", + "status": "published", + "duration_hours": 16.0, + "difficulty_level": 2, + "tags": ["社媒营销", "私域流量", "客户维护"], + "sort_order": 180, + "is_featured": False + }, + { + "id": 8, + "name": "门店运营管理", + "description": "学习门店日常管理、团队建设和业绩管理", + "category": "management", + "status": "published", + "duration_hours": 20.0, + "difficulty_level": 3, + "tags": ["门店管理", "团队管理", "运营策略"], + "sort_order": 160, + "is_featured": False + }, + { + "id": 9, + "name": "卫生消毒与感染控制", + "description": "学习医美机构的卫生标准和消毒流程,确保服务安全", + "category": "general", + "status": "published", + "duration_hours": 8.0, + "difficulty_level": 1, + "tags": ["卫生安全", "消毒规范", "合规管理"], + "sort_order": 150, + "is_featured": True + }, + { + "id": 10, + "name": "Python编程基础", + "description": "Python语言入门课程,适合零基础学员", + "category": "technology", + "status": "published", + "duration_hours": 40.0, + "difficulty_level": 2, + "tags": ["Python", "编程基础", "入门"], + "sort_order": 200, + "is_featured": False + }, + { + "id": 11, + "name": "数据分析基础", + "description": "学习数据分析方法和工具,提升数据驱动决策能力", + "category": "technology", + "status": "published", + "duration_hours": 32.0, + "difficulty_level": 3, + "tags": ["数据分析", "Excel", "可视化"], + "sort_order": 210, + "is_featured": False + } + ] + + # 正确的岗位数据 + correct_positions = [ + {"id": 1, "name": "区域经理", "code": "region_manager", "description": "负责多家门店的运营管理和业绩达成", "status": "active", "skills": ["团队管理", "业绩分析", "战略规划", "客户关系"], "level": "expert", "sort_order": 10}, + {"id": 2, "name": "店长", "code": "store_manager", "description": "负责门店日常运营管理,团队建设和业绩达成", "status": "active", "skills": ["门店管理", "团队建设", "销售管理", "客户维护"], "level": "senior", "sort_order": 20}, + {"id": 3, "name": "美容顾问", "code": "beauty_consultant", "description": "为客户提供专业的美容咨询和方案设计", "status": "active", "skills": ["产品知识", "销售技巧", "方案设计", "客户沟通"], "level": "intermediate", "sort_order": 30}, + {"id": 4, "name": "医美咨询师", "code": "medical_beauty_consultant", "description": "提供医疗美容项目咨询和方案制定", "status": "active", "skills": ["医美知识", "风险评估", "方案设计", "合规意识"], "level": "senior", "sort_order": 35}, + {"id": 5, "name": "美容技师", "code": "beauty_therapist", "description": "为客户提供专业的美容护理服务", "status": "active", "skills": ["护肤技术", "仪器操作", "手法技巧", "服务意识"], "level": "intermediate", "sort_order": 40}, + {"id": 6, "name": "护士", "code": "nurse", "description": "协助医生进行医美项目操作,负责术后护理", "status": "active", "skills": ["护理技术", "无菌操作", "应急处理", "医疗知识"], "level": "intermediate", "sort_order": 45}, + {"id": 7, "name": "前台接待", "code": "receptionist", "description": "负责客户接待、预约管理和前台事务", "status": "active", "skills": ["接待礼仪", "沟通能力", "信息管理", "服务意识"], "level": "junior", "sort_order": 50}, + {"id": 8, "name": "市场专员", "code": "marketing_specialist", "description": "负责门店营销活动策划和执行", "status": "active", "skills": ["活动策划", "社媒运营", "数据分析", "创意设计"], "level": "intermediate", "sort_order": 60} + ] + + # 正确的用户数据 + correct_users = [ + {"id": 1, "username": "superadmin", "full_name": "超级管理员"}, + {"id": 2, "username": "admin", "full_name": "系统管理员"}, + {"id": 3, "username": "testuser", "full_name": "测试学员"} + ] + + try: + # 连接数据库 + conn = await aiomysql.connect( + host="localhost", + port=3306, + user="root", + password="root", + db="kaopeilian", + charset="utf8mb4", + use_unicode=True, + init_command="SET character_set_client=utf8mb4, character_set_connection=utf8mb4, character_set_results=utf8mb4" + ) + cursor = await conn.cursor() + + print("✅ 数据库连接成功") + + # 开始事务 + await cursor.execute("START TRANSACTION") + + # 更新课程数据 + print("\n📚 更新课程数据...") + for course in correct_courses: + sql = """ + UPDATE courses SET + name = %s, + description = %s, + tags = %s, + updated_at = NOW() + WHERE id = %s + """ + tags_json = str(course["tags"]).replace("'", '"') + await cursor.execute(sql, (course["name"], course["description"], tags_json, course["id"])) + print(f" ✅ 更新课程: {course['name']}") + + # 更新岗位数据 + print("\n👥 更新岗位数据...") + for position in correct_positions: + sql = """ + UPDATE positions SET + name = %s, + description = %s, + skills = %s, + updated_at = NOW() + WHERE id = %s + """ + skills_json = str(position["skills"]).replace("'", '"') + await cursor.execute(sql, (position["name"], position["description"], skills_json, position["id"])) + print(f" ✅ 更新岗位: {position['name']}") + + # 更新用户数据 + print("\n👤 更新用户数据...") + for user in correct_users: + sql = """ + UPDATE users SET + full_name = %s, + updated_at = NOW() + WHERE id = %s + """ + await cursor.execute(sql, (user["full_name"], user["id"])) + print(f" ✅ 更新用户: {user['full_name']}") + + # 提交事务 + await cursor.execute("COMMIT") + print("\n✅ 所有数据更新完成!") + + # 验证更新结果 + print("\n🔍 验证更新结果...") + await cursor.execute("SELECT id, name FROM courses LIMIT 3") + courses = await cursor.fetchall() + for course_id, course_name in courses: + print(f" 课程 {course_id}: {course_name}") + + await cursor.execute("SELECT id, name FROM positions LIMIT 3") + positions = await cursor.fetchall() + for pos_id, pos_name in positions: + print(f" 岗位 {pos_id}: {pos_name}") + + await cursor.execute("SELECT id, full_name FROM users LIMIT 3") + users = await cursor.fetchall() + for user_id, user_name in users: + print(f" 用户 {user_id}: {user_name}") + + await cursor.close() + conn.close() + + print("\n🎉 中文乱码修复完成!") + + except Exception as e: + print(f"❌ 修复失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + asyncio.run(fix_chinese_data()) diff --git a/backend/scripts/init_database_unified.sql b/backend/scripts/init_database_unified.sql new file mode 100644 index 0000000..52d4bc8 --- /dev/null +++ b/backend/scripts/init_database_unified.sql @@ -0,0 +1,606 @@ +-- ============================================ +-- 考培练系统数据库初始化脚本 +-- 版本:1.0.0 +-- 更新时间:2024-12 +-- ============================================ + +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS `kaopeilian` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +USE `kaopeilian`; + +-- ============================================ +-- 一、用户管理模块 +-- ============================================ + +-- 1.1 用户表 +CREATE TABLE IF NOT EXISTS `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) NOT NULL UNIQUE COMMENT '邮箱', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `gender` VARCHAR(10) COMMENT '性别: male/female', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `bio` TEXT COMMENT '个人简介', + `school` VARCHAR(100) COMMENT '学校', + `major` VARCHAR(100) COMMENT '专业', + `role` VARCHAR(20) DEFAULT 'trainee' COMMENT '系统角色: admin, manager, trainee', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `is_verified` BOOLEAN DEFAULT FALSE COMMENT '是否验证', + `last_login_at` DATETIME COMMENT '最后登录时间', + `password_changed_at` DATETIME COMMENT '密码修改时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; + +-- 1.2 团队表 +CREATE TABLE IF NOT EXISTS `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '团队名称', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '团队代码', + `description` TEXT COMMENT '团队描述', + `team_type` VARCHAR(50) DEFAULT 'department' COMMENT '团队类型: department, project, study_group', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `leader_id` INT COMMENT '负责人ID', + `parent_id` INT COMMENT '父团队ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队表'; + +-- 1.3 用户团队关联表 +CREATE TABLE IF NOT EXISTS `user_teams` ( + `user_id` INT NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member' COMMENT '团队角色: member, leader', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; + +-- ============================================ +-- 二、组织与岗位管理模块 +-- ============================================ + +-- 2.0 岗位表 +CREATE TABLE IF NOT EXISTS `positions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '岗位名称', + `code` VARCHAR(100) NOT NULL UNIQUE COMMENT '岗位编码', + `description` TEXT COMMENT '岗位描述', + `parent_id` INT NULL COMMENT '上级岗位ID', + `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive', + `skills` JSON NULL COMMENT '核心技能', + `level` VARCHAR(20) NULL COMMENT '岗位等级: junior/intermediate/senior/expert', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + `created_by` INT NULL COMMENT '创建人ID', + `updated_by` INT NULL COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES positions(id) ON DELETE SET NULL, + INDEX idx_positions_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位表'; + +-- 插入轻医美连锁岗位(按层级关系) +-- 注意:需要按顺序插入以正确设置parent_id + +-- 第一层:区域经理 +INSERT INTO positions (name, code, description, status, skills, level, sort_order) VALUES +('区域经理', 'region_manager', '负责多家门店的运营管理和业绩达成', 'active', '["团队管理", "业绩分析", "战略规划", "客户关系"]', 'expert', 10); + +-- 获取区域经理ID +SET @region_manager_id = LAST_INSERT_ID(); + +-- 第二层:店长 +INSERT INTO positions (name, code, description, parent_id, status, skills, level, sort_order) VALUES +('店长', 'store_manager', '负责门店日常运营管理,团队建设和业绩达成', @region_manager_id, 'active', '["门店管理", "团队建设", "销售管理", "客户维护"]', 'senior', 20); + +-- 获取店长ID +SET @store_manager_id = LAST_INSERT_ID(); + +-- 第三层:各职能岗位 +INSERT INTO positions (name, code, description, parent_id, status, skills, level, sort_order) VALUES +('美容顾问', 'beauty_consultant', '为客户提供专业的美容咨询和方案设计', @store_manager_id, 'active', '["产品知识", "销售技巧", "方案设计", "客户沟通"]', 'intermediate', 30), +('医美咨询师', 'medical_beauty_consultant', '提供医疗美容项目咨询和方案制定', @store_manager_id, 'active', '["医美知识", "风险评估", "方案设计", "合规意识"]', 'senior', 35), +('美容技师', 'beauty_therapist', '为客户提供专业的美容护理服务', @store_manager_id, 'active', '["护肤技术", "仪器操作", "手法技巧", "服务意识"]', 'intermediate', 40), +('护士', 'nurse', '协助医生进行医美项目操作,负责术后护理', @store_manager_id, 'active', '["护理技术", "无菌操作", "应急处理", "医疗知识"]', 'intermediate', 45), +('前台接待', 'receptionist', '负责客户接待、预约管理和前台事务', @store_manager_id, 'active', '["接待礼仪", "沟通能力", "信息管理", "服务意识"]', 'junior', 50), +('市场专员', 'marketing_specialist', '负责门店营销活动策划和执行', @store_manager_id, 'active', '["活动策划", "社媒运营", "数据分析", "创意设计"]', 'intermediate', 60); + +-- ============================================ +-- 二、课程管理模块 +-- ============================================ + +-- 2.1 课程表 +CREATE TABLE IF NOT EXISTS `courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; + +-- 2.2 课程资料表 +CREATE TABLE IF NOT EXISTS `course_materials` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; + +-- 2.3 知识点表 +CREATE TABLE IF NOT EXISTS `knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `parent_id` INT COMMENT '父知识点ID', + `level` INT DEFAULT 1 COMMENT '层级深度', + `path` VARCHAR(500) COMMENT '路径(如: 1.2.3)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `weight` FLOAT DEFAULT 1.0 COMMENT '权重', + `is_required` BOOLEAN DEFAULT TRUE COMMENT '是否必修', + `estimated_hours` FLOAT COMMENT '预计学习时间(小时)', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT NULL COMMENT '创建人ID', + `updated_by` INT NULL COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_parent_id (parent_id), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; + +-- 2.4 成长路径表 +CREATE TABLE IF NOT EXISTS `growth_paths` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; + +-- 资料知识点关联表 +CREATE TABLE `material_knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `material_id` INT NOT NULL COMMENT '资料ID', + `knowledge_point_id` INT NOT NULL COMMENT '知识点ID', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_ai_generated` BOOLEAN DEFAULT FALSE COMMENT '是否AI生成', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY `idx_material_knowledge` (`material_id`, `knowledge_point_id`), + FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE CASCADE, + FOREIGN KEY (knowledge_point_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_material_id (material_id), + INDEX idx_knowledge_point_id (knowledge_point_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资料知识点关联表'; + +-- ============================================ +-- 三、考试模块 +-- ============================================ + +-- 3.1 题目表 +CREATE TABLE IF NOT EXISTS `questions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT COMMENT '题目内容', + `options` JSON COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_question_type (question_type), + INDEX idx_difficulty (difficulty), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; + +-- 3.2 考试记录表 +CREATE TABLE IF NOT EXISTS `exams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT COMMENT '得分', + `is_passed` BOOLEAN COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON COMMENT '题目数据(JSON格式)', + `answers` JSON COMMENT '答案数据(JSON格式)', + `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 (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_course_id (course_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; + +-- 3.3 考试结果详情表 +CREATE TABLE IF NOT EXISTS `exam_results` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `exam_id` INT NOT NULL COMMENT '考试ID', + `question_id` INT NOT NULL COMMENT '题目ID', + `user_answer` TEXT COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT COMMENT '答题时长(秒)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE, + INDEX idx_exam_id (exam_id), + INDEX idx_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果详情表'; + +-- ============================================ +-- 四、陪练模块 +-- ============================================ + +-- 4.1 陪练场景表 +CREATE TABLE IF NOT EXISTS `training_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `category` VARCHAR(50) NOT NULL COMMENT '场景分类', + `ai_config` JSON COMMENT 'AI配置(如Coze Bot ID等)', + `prompt_template` TEXT COMMENT '提示词模板', + `evaluation_criteria` JSON COMMENT '评估标准', + `status` ENUM('DRAFT', 'ACTIVE', 'INACTIVE') DEFAULT 'DRAFT' COMMENT '场景状态', + `is_public` BOOLEAN DEFAULT TRUE COMMENT '是否公开', + `required_level` INT COMMENT '所需用户等级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_public (is_public), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; + +-- 4.2 陪练会话表 +CREATE TABLE IF NOT EXISTS `training_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `scene_id` INT NOT NULL COMMENT '场景ID', + `coze_conversation_id` VARCHAR(100) COMMENT 'Coze会话ID', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT COMMENT '持续时长(秒)', + `status` ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') DEFAULT 'CREATED' COMMENT '会话状态', + `session_config` JSON COMMENT '会话配置', + `total_score` FLOAT COMMENT '总分', + `evaluation_result` JSON COMMENT '评估结果详情', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (scene_id) REFERENCES training_scenes(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_scene_id (scene_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; + +-- 4.3 陪练消息表 +CREATE TABLE IF NOT EXISTS `training_messages` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL COMMENT '会话ID', + `role` ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + `type` ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + `content` TEXT NOT NULL COMMENT '消息内容', + `voice_url` VARCHAR(500) COMMENT '语音文件URL', + `voice_duration` FLOAT COMMENT '语音时长(秒)', + `message_metadata` JSON COMMENT '消息元数据', + `coze_message_id` VARCHAR(100) COMMENT 'Coze消息ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_session_id (session_id), + INDEX idx_role (role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; + +-- 4.4 陪练报告表 +CREATE TABLE IF NOT EXISTS `training_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL UNIQUE COMMENT '会话ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `overall_score` FLOAT NOT NULL COMMENT '总体得分', + `dimension_scores` JSON NOT NULL COMMENT '各维度得分', + `strengths` JSON NOT NULL COMMENT '优势点', + `weaknesses` JSON NOT NULL COMMENT '待改进点', + `suggestions` JSON NOT NULL COMMENT '改进建议', + `detailed_analysis` TEXT COMMENT '详细分析', + `transcript` TEXT COMMENT '对话文本记录', + `statistics` JSON COMMENT '统计数据', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; + +-- ============================================ +-- 五、初始测试数据 +-- ============================================ + +-- 插入测试用户 +INSERT INTO users (username, email, phone, password_hash, full_name, role, is_active, is_verified) VALUES +('superadmin', 'superadmin@kaopeilian.com', '13800000001', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '超级管理员', 'admin', TRUE, TRUE), +('admin', 'admin@kaopeilian.com', '13800000002', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '系统管理员', 'admin', TRUE, TRUE), +('testuser', 'testuser@kaopeilian.com', '13800000003', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '测试学员', 'trainee', TRUE, TRUE); + +-- 插入测试团队 +INSERT INTO teams (name, code, description, team_type, leader_id) VALUES +('技术部', 'TECH', '负责技术研发和维护', 'department', 2), +('产品部', 'PROD', '负责产品设计和规划', 'department', 2), +('Python学习小组', 'PY_GROUP', 'Python技术学习交流', 'study_group', 2); + +-- 插入用户团队关联 +INSERT INTO user_teams (user_id, team_id, role) VALUES +(2, 1, 'leader'), +(2, 2, 'leader'), +(3, 1, 'member'), +(3, 3, 'member'); + +-- 插入轻医美相关课程 +INSERT INTO courses (name, description, category, status, duration_hours, difficulty_level, tags, is_featured, sort_order, published_at) VALUES +-- 技术类课程 +('皮肤生理学基础', '学习皮肤结构、功能和常见问题,为专业护理打下基础', 'technology', 'published', 16, 2, '["皮肤学", "基础理论", "必修课"]', TRUE, 100, NOW()), +('医美产品知识与应用', '全面了解各类医美产品的成分、功效和适用人群', 'technology', 'published', 20, 3, '["产品知识", "医美", "专业技能"]', TRUE, 110, NOW()), +('美容仪器操作与维护', '掌握各类美容仪器的操作方法、注意事项和日常维护', 'technology', 'published', 24, 3, '["仪器操作", "实操技能", "设备维护"]', FALSE, 120, NOW()), +('医美项目介绍与咨询', '详细了解各类医美项目的原理、效果和适应症', 'technology', 'published', 30, 4, '["医美项目", "专业咨询", "风险告知"]', TRUE, 170, NOW()), + +-- 业务类课程 +('轻医美销售技巧', '学习专业的销售话术、客户需求分析和成交技巧', 'business', 'published', 16, 2, '["销售技巧", "客户沟通", "业绩提升"]', TRUE, 130, NOW()), +('客户服务与投诉处理', '提升服务意识,掌握客户投诉处理的方法和技巧', 'business', 'published', 12, 2, '["客户服务", "危机处理", "沟通技巧"]', FALSE, 140, NOW()), +('社媒营销与私域运营', '学习如何通过社交媒体进行品牌推广和客户维护', 'business', 'published', 16, 2, '["社媒营销", "私域流量", "客户维护"]', FALSE, 180, NOW()), + +-- 管理类课程 +('门店运营管理', '学习门店日常管理、团队建设和业绩管理', 'management', 'published', 20, 3, '["门店管理", "团队管理", "运营策略"]', FALSE, 160, NOW()), + +-- 通用类课程 +('卫生消毒与感染控制', '学习医美机构的卫生标准和消毒流程,确保服务安全', 'general', 'published', 8, 1, '["卫生安全", "消毒规范", "合规管理"]', TRUE, 150, NOW()), + +-- 保留原有的技术课程作为选修参考 +('Python编程基础', 'Python语言入门课程,适合零基础学员', 'technology', 'published', 40, 2, '["Python", "编程基础", "入门"]', FALSE, 200, NOW()), +('数据分析基础', '学习数据分析方法和工具,提升数据驱动决策能力', 'technology', 'published', 32, 3, '["数据分析", "Excel", "可视化"]', FALSE, 210, NOW()); + +-- 为第一个课程添加资料 +INSERT INTO course_materials (course_id, name, description, file_url, file_type, file_size) VALUES +(1, 'Python基础教程.pdf', 'Python编程基础教程文档', '/uploads/python-basics.pdf', 'pdf', 2048000), +(1, '课程视频1', '第一章节视频教程', '/uploads/video1.mp4', 'mp4', 104857600); + +-- 为第一个课程添加知识点 +INSERT INTO knowledge_points (course_id, name, description, parent_id, level, weight, estimated_hours) VALUES +(1, 'Python环境搭建', '学习如何安装和配置Python开发环境', NULL, 1, 1.0, 2), +(1, 'Python基础语法', '学习Python的基本语法规则', NULL, 1, 2.0, 8), +(1, '变量和数据类型', '了解Python中的变量和基本数据类型', 2, 2, 1.5, 3), +(1, '控制流程', '学习条件语句和循环结构', 2, 2, 1.5, 4); + +-- 插入测试题目 +INSERT INTO questions (course_id, question_type, title, content, options, correct_answer, explanation, score, difficulty, tags) VALUES +(1, 'single_choice', 'Python中哪个关键字用于定义函数?', NULL, '{"A": "def", "B": "function", "C": "fun", "D": "define"}', 'A', 'Python使用def关键字来定义函数', 10.0, 'easy', '["python", "基础", "函数"]'), +(1, 'single_choice', 'Python中列表和元组的主要区别是什么?', NULL, '{"A": "列表是有序的,元组是无序的", "B": "列表可变,元组不可变", "C": "列表只能存储数字,元组可以存储任何类型", "D": "没有区别"}', 'B', '列表是可变的(mutable),而元组是不可变的(immutable)', 10.0, 'medium', '["python", "数据结构"]'), +(1, 'true_false', 'Python是一种编译型语言', NULL, NULL, 'false', 'Python是一种解释型语言,不需要编译成机器码', 10.0, 'easy', '["python", "基础"]'), +(1, 'fill_blank', 'Python中使用____关键字定义类', NULL, NULL, 'class', '使用class关键字定义类', 10.0, 'easy', '["python", "面向对象"]'); + +-- 插入陪练场景 +INSERT INTO training_scenes (name, description, category, ai_config, status, is_public) VALUES +('Python编程助手', '帮助学员解决Python编程问题', '技术辅导', '{"bot_id": "python_assistant_bot"}', 'active', TRUE), +('面试模拟', '模拟技术面试场景', '职业发展', '{"bot_id": "interview_simulator_bot"}', 'active', TRUE), +('项目讨论', '项目方案讨论和优化', '项目管理', '{"bot_id": "project_discussion_bot"}', 'draft', TRUE); + +-- 插入成长路径 +INSERT INTO growth_paths (name, description, target_role, courses, estimated_duration_days) VALUES +('Python工程师成长路径', '从入门到精通的Python学习路径', 'Python开发工程师', '[{"course_id": 1, "order": 1, "is_required": true}, {"course_id": 4, "order": 2, "is_required": true}]', 90), +('技术管理者路径', '技术人员转型管理岗位', '技术经理', '[{"course_id": 2, "order": 1, "is_required": true}, {"course_id": 3, "order": 2, "is_required": false}]', 60); + +-- ============================================ +-- 六、创建视图(可选) +-- ============================================ + +-- 用户课程进度视图 +CREATE OR REPLACE VIEW v_user_course_progress AS +SELECT + u.id as user_id, + u.username, + c.id as course_id, + c.name as course_name, + COUNT(DISTINCT e.id) as exam_count, + AVG(e.score) as avg_score, + MAX(e.score) as best_score +FROM users u +CROSS JOIN courses c +LEFT JOIN exams e ON e.user_id = u.id AND e.course_id = c.id AND e.status = 'submitted' +GROUP BY u.id, c.id; + +-- ============================================ +-- 七、岗位成员和课程关联表 +-- ============================================ + +-- 创建岗位成员关联表 +CREATE TABLE IF NOT EXISTS position_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + user_id INT NOT NULL COMMENT '用户ID', + role VARCHAR(50) COMMENT '成员角色(预留字段)', + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE KEY uix_position_user (position_id, user_id, is_deleted), + INDEX ix_position_members_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; + +-- 创建岗位课程关联表 +CREATE TABLE IF NOT EXISTS position_courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + course_id INT NOT NULL COMMENT '课程ID', + course_type VARCHAR(20) NOT NULL DEFAULT 'required' COMMENT '课程类型', + priority INT DEFAULT 0 COMMENT '优先级/排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + UNIQUE KEY uix_position_course (position_id, course_id, is_deleted), + INDEX ix_position_courses_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; + +-- 创建课程考试设置表 +CREATE TABLE IF NOT EXISTS course_exam_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL UNIQUE COMMENT '课程ID', + single_choice_count INT NOT NULL DEFAULT 10 COMMENT '单选题数量', + multiple_choice_count INT NOT NULL DEFAULT 5 COMMENT '多选题数量', + true_false_count INT NOT NULL DEFAULT 5 COMMENT '判断题数量', + fill_blank_count INT NOT NULL DEFAULT 0 COMMENT '填空题数量', + duration_minutes INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', + difficulty_level INT NOT NULL DEFAULT 3 COMMENT '难度系数(1-5)', + passing_score INT NOT NULL DEFAULT 60 COMMENT '及格分数', + is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + show_answer_immediately BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否立即显示答案', + allow_retake BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许重考', + max_retake_times INT COMMENT '最大重考次数', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + deleted_by INT COMMENT '删除人ID', + FOREIGN KEY (course_id) REFERENCES courses(id), + INDEX ix_course_exam_settings_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; + +-- 插入岗位成员样例数据(使用已存在的用户) +INSERT INTO position_members (position_id, user_id) VALUES +(2, 2), -- 店长:admin +(3, 3); -- 美容顾问:testuser + +-- 插入岗位课程关联数据 +-- 需要根据实际插入的课程ID来设置,这里使用子查询获取课程ID + +-- 店长必修课程 +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 1 FROM courses WHERE name = '门店运营管理' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 2 FROM courses WHERE name = '轻医美销售技巧' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 3 FROM courses WHERE name = '客户服务与投诉处理' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 2, id, 'required', 4 FROM courses WHERE name = '卫生消毒与感染控制' AND is_deleted = FALSE LIMIT 1; + +-- 美容顾问必修课程 +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 1 FROM courses WHERE name = '皮肤生理学基础' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 2 FROM courses WHERE name = '医美产品知识与应用' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 3 FROM courses WHERE name = '轻医美销售技巧' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'required', 4 FROM courses WHERE name = '客户服务与投诉处理' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 3, id, 'optional', 5 FROM courses WHERE name = '社媒营销与私域运营' AND is_deleted = FALSE LIMIT 1; + +-- 美容技师必修课程 +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 4, id, 'required', 1 FROM courses WHERE name = '皮肤生理学基础' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 4, id, 'required', 2 FROM courses WHERE name = '美容仪器操作与维护' AND is_deleted = FALSE LIMIT 1; +INSERT INTO position_courses (position_id, course_id, course_type, priority) +SELECT 4, id, 'required', 3 FROM courses WHERE name = '卫生消毒与感染控制' AND is_deleted = FALSE LIMIT 1; + +-- ============================================ +-- 八、输出完成信息 +-- ============================================ + +SELECT '数据库初始化完成!' as message; +SELECT '默认账号-超级管理员:superadmin / Superadmin123!' as info1; +SELECT '默认账号-系统管理员:admin / Admin123!' as info2; +SELECT '默认账号-测试学员:testuser / TestPass123!' as info3; diff --git a/backend/scripts/init_db.py b/backend/scripts/init_db.py new file mode 100644 index 0000000..a658ee8 --- /dev/null +++ b/backend/scripts/init_db.py @@ -0,0 +1,64 @@ +""" +初始化数据库脚本 +""" +import asyncio +import os +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import settings +from app.core.logger import logger + + +async def create_database(): + """创建数据库(如果不存在)""" + # 解析数据库URL + db_url_parts = settings.DATABASE_URL.split('/') + db_name = db_url_parts[-1].split('?')[0] + db_url_without_db = '/'.join(db_url_parts[:-1]) + + # 连接到MySQL服务器(不指定数据库) + engine = create_async_engine(db_url_without_db, echo=True) + + async with engine.begin() as conn: + # 检查数据库是否存在 + result = await conn.execute( + text(f"SELECT SCHEMA_NAME FROM INFORMATION_SCHEMA.SCHEMATA WHERE SCHEMA_NAME = '{db_name}'") + ) + exists = result.scalar() is not None + + if not exists: + # 创建数据库 + await conn.execute(text(f"CREATE DATABASE IF NOT EXISTS `{db_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")) + logger.info(f"数据库 {db_name} 创建成功") + else: + logger.info(f"数据库 {db_name} 已存在") + + await engine.dispose() + + +async def main(): + """主函数""" + try: + # 创建数据库 + await create_database() + + # 运行迁移 + logger.info("开始运行数据库迁移...") + os.system("cd /workspace/kaopeilian-backend && alembic upgrade head") + + logger.info("数据库初始化完成!") + + except Exception as e: + logger.error(f"数据库初始化失败: {str(e)}") + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/init_db.sql b/backend/scripts/init_db.sql new file mode 100644 index 0000000..8a2d11a --- /dev/null +++ b/backend/scripts/init_db.sql @@ -0,0 +1,113 @@ +-- 创建数据库(如果不存在) +CREATE DATABASE IF NOT EXISTS kaopeilian CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; + +USE kaopeilian; + +-- 课程表 +CREATE TABLE IF NOT EXISTS courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL COMMENT '课程名称', + description TEXT COMMENT '课程描述', + category ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + status ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + cover_image VARCHAR(500) COMMENT '封面图片URL', + duration_hours FLOAT COMMENT '课程时长(小时)', + difficulty_level INT COMMENT '难度等级(1-5)', + tags JSON COMMENT '标签列表', + published_at DATETIME COMMENT '发布时间', + publisher_id INT COMMENT '发布人ID', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + is_featured BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程表'; + +-- 课程资料表 +CREATE TABLE IF NOT EXISTS course_materials ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL COMMENT '课程ID', + name VARCHAR(200) NOT NULL COMMENT '资料名称', + description TEXT COMMENT '资料描述', + file_url VARCHAR(500) NOT NULL COMMENT '文件URL', + file_type VARCHAR(50) NOT NULL COMMENT '文件类型', + file_size INT NOT NULL COMMENT '文件大小(字节)', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='课程资料表'; + +-- 知识点表 +CREATE TABLE IF NOT EXISTS knowledge_points ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL COMMENT '课程ID', + name VARCHAR(200) NOT NULL COMMENT '知识点名称', + description TEXT COMMENT '知识点描述', + parent_id INT COMMENT '父知识点ID', + level INT DEFAULT 1 COMMENT '层级深度', + path VARCHAR(500) COMMENT '路径(如: 1.2.3)', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + weight FLOAT DEFAULT 1.0 COMMENT '权重', + is_required BOOLEAN DEFAULT TRUE COMMENT '是否必修', + estimated_hours FLOAT COMMENT '预计学习时间(小时)', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (parent_id) REFERENCES knowledge_points(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_parent_id (parent_id), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='知识点表'; + +-- 成长路径表 +CREATE TABLE IF NOT EXISTS growth_paths ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(200) NOT NULL COMMENT '路径名称', + description TEXT COMMENT '路径描述', + target_role VARCHAR(100) COMMENT '目标角色', + courses JSON COMMENT '课程列表[{course_id, order, is_required}]', + estimated_duration_days INT COMMENT '预计完成天数', + is_active BOOLEAN DEFAULT TRUE COMMENT '是否启用', + sort_order INT DEFAULT 0 COMMENT '排序顺序', + is_deleted BOOLEAN DEFAULT FALSE COMMENT '是否删除', + deleted_at DATETIME COMMENT '删除时间', + created_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间', + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成长路径表'; + +-- 插入测试数据 +INSERT INTO courses (name, description, category, status, difficulty_level, tags, is_featured) VALUES +('Python编程基础', 'Python语言入门课程,适合零基础学员', 'technology', 'published', 2, '["Python", "编程基础", "入门"]', TRUE), +('项目管理实战', '学习现代项目管理方法和工具', 'management', 'published', 3, '["项目管理", "敏捷", "Scrum"]', FALSE), +('商务沟通技巧', '提升职场沟通能力', 'business', 'draft', 2, '["沟通", "软技能", "职场"]', FALSE); + +-- 为第一个课程添加资料 +INSERT INTO course_materials (course_id, name, description, file_url, file_type, file_size) VALUES +(1, 'Python基础教程.pdf', 'Python编程基础教程文档', '/uploads/python-basics.pdf', 'pdf', 2048000), +(1, '课程视频1', '第一章节视频教程', '/uploads/video1.mp4', 'mp4', 104857600); + +-- 为第一个课程添加知识点 +INSERT INTO knowledge_points (course_id, name, description, parent_id, level, weight, estimated_hours) VALUES +(1, 'Python环境搭建', '学习如何安装和配置Python开发环境', NULL, 1, 1.0, 2), +(1, 'Python基础语法', '学习Python的基本语法规则', NULL, 1, 2.0, 8), +(1, '变量和数据类型', '了解Python中的变量和基本数据类型', 2, 2, 1.5, 3), +(1, '控制流程', '学习条件语句和循环结构', 2, 2, 1.5, 4); diff --git a/backend/scripts/init_project.sh b/backend/scripts/init_project.sh new file mode 100755 index 0000000..ef56331 --- /dev/null +++ b/backend/scripts/init_project.sh @@ -0,0 +1,49 @@ +#!/bin/bash + +echo "初始化考培练系统后端项目..." + +# 创建虚拟环境 +if [ ! -d "venv" ]; then + echo "创建虚拟环境..." + python3 -m venv venv +fi + +# 激活虚拟环境 +source venv/bin/activate + +# 升级pip +pip install --upgrade pip + +# 安装依赖 +echo "安装项目依赖..." +pip install -r requirements/dev.txt + +# 复制环境变量文件 +if [ ! -f ".env" ]; then + echo "创建环境变量文件..." + cp .env.example .env + echo "请编辑 .env 文件配置必要的环境变量" +fi + +# 创建必要的目录 +echo "创建必要的目录..." +mkdir -p logs uploads + +# 初始化pre-commit +if command -v pre-commit &> /dev/null; then + echo "配置pre-commit hooks..." + pre-commit install +fi + +echo "" +echo "项目初始化完成!" +echo "" +echo "下一步:" +echo "1. 编辑 .env 文件,配置数据库和AI平台信息" +echo "2. 启动MySQL和Redis服务" +echo "3. 运行 'alembic init migrations' 初始化数据库迁移" +echo "4. 运行 'alembic revision --autogenerate -m \"initial\"' 创建初始迁移" +echo "5. 运行 'alembic upgrade head' 应用迁移" +echo "6. 运行 'make run-dev' 启动开发服务器" +echo "" +echo "祝开发顺利! 🚀" diff --git a/backend/scripts/kaopeilian_rollback.py b/backend/scripts/kaopeilian_rollback.py new file mode 100644 index 0000000..104250e --- /dev/null +++ b/backend/scripts/kaopeilian_rollback.py @@ -0,0 +1,394 @@ +#!/usr/bin/env python3 +""" +考培练系统 - 专用数据库回滚工具 +针对轻医美连锁业务场景的快速回滚方案 + +功能: +1. 用户数据回滚 +2. 课程数据回滚 +3. 考试数据回滚 +4. 岗位数据回滚 +5. 基于Binlog的精确回滚 + +使用方法: +python scripts/kaopeilian_rollback.py --help +""" + +import asyncio +import argparse +import json +from datetime import datetime, timedelta +from typing import List, Dict, Optional +import aiomysql +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class KaopeilianRollbackTool: + """考培练系统专用回滚工具""" + + def __init__(self): + self.host = "localhost" + self.port = 3306 + self.user = "root" + self.password = "root" + self.database = "kaopeilian" + self.connection = None + + async def connect(self): + """连接数据库""" + try: + self.connection = await aiomysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + db=self.database, + charset='utf8mb4' + ) + logger.info("✅ 数据库连接成功") + except Exception as e: + logger.error(f"❌ 数据库连接失败: {e}") + raise + + async def close(self): + """关闭连接""" + if self.connection: + self.connection.close() + + async def get_recent_operations(self, hours: int = 24) -> List[Dict]: + """获取最近的操作记录""" + cursor = await self.connection.cursor(aiomysql.DictCursor) + + # 查询最近更新的记录 + queries = [ + { + 'table': 'users', + 'sql': f""" + SELECT id, username, full_name, updated_at, 'user' as type + FROM users + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + }, + { + 'table': 'courses', + 'sql': f""" + SELECT id, name, status, updated_at, 'course' as type + FROM courses + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + }, + { + 'table': 'exams', + 'sql': f""" + SELECT id, user_id, course_id, exam_name, score, updated_at, 'exam' as type + FROM exams + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + }, + { + 'table': 'positions', + 'sql': f""" + SELECT id, name, code, status, updated_at, 'position' as type + FROM positions + WHERE updated_at >= DATE_SUB(NOW(), INTERVAL {hours} HOUR) + ORDER BY updated_at DESC + """ + } + ] + + all_operations = [] + for query in queries: + try: + await cursor.execute(query['sql']) + results = await cursor.fetchall() + all_operations.extend(results) + except Exception as e: + logger.warning(f"⚠️ 查询 {query['table']} 表失败: {e}") + + await cursor.close() + return all_operations + + async def create_data_backup(self, table: str, record_id: int) -> Dict: + """创建单条记录的备份""" + cursor = await self.connection.cursor(aiomysql.DictCursor) + + try: + await cursor.execute(f"SELECT * FROM {table} WHERE id = %s", (record_id,)) + record = await cursor.fetchone() + + if record: + backup = { + 'table': table, + 'record_id': record_id, + 'data': dict(record), + 'backup_time': datetime.now().isoformat() + } + logger.info(f"✅ 已备份 {table} 表记录 ID: {record_id}") + return backup + else: + logger.warning(f"⚠️ 未找到 {table} 表记录 ID: {record_id}") + return None + + except Exception as e: + logger.error(f"❌ 备份 {table} 表记录失败: {e}") + return None + finally: + await cursor.close() + + async def restore_from_backup(self, backup: Dict) -> bool: + """从备份恢复数据""" + if not backup: + return False + + cursor = await self.connection.cursor() + + try: + # 开始事务 + await cursor.execute("START TRANSACTION") + + table = backup['table'] + data = backup['data'] + record_id = backup['record_id'] + + # 构建UPDATE语句 + set_clauses = [] + values = [] + + for key, value in data.items(): + if key != 'id': # 跳过主键 + set_clauses.append(f"`{key}` = %s") + values.append(value) + + if set_clauses: + sql = f"UPDATE `{table}` SET {', '.join(set_clauses)} WHERE id = %s" + values.append(record_id) + + await cursor.execute(sql, values) + await cursor.execute("COMMIT") + + logger.info(f"✅ 已恢复 {table} 表记录 ID: {record_id}") + return True + else: + await cursor.execute("ROLLBACK") + logger.warning(f"⚠️ 没有可恢复的字段") + return False + + except Exception as e: + await cursor.execute("ROLLBACK") + logger.error(f"❌ 恢复数据失败: {e}") + return False + finally: + await cursor.close() + + async def soft_delete_rollback(self, table: str, record_id: int) -> bool: + """软删除回滚""" + cursor = await self.connection.cursor() + + try: + # 检查表是否有软删除字段 + await cursor.execute(f"SHOW COLUMNS FROM {table} LIKE 'is_deleted'") + has_soft_delete = await cursor.fetchone() + + if not has_soft_delete: + logger.warning(f"⚠️ {table} 表没有软删除字段") + return False + + # 恢复软删除的记录 + await cursor.execute("START TRANSACTION") + + sql = f""" + UPDATE `{table}` + SET is_deleted = FALSE, deleted_at = NULL + WHERE id = %s + """ + await cursor.execute(sql, (record_id,)) + + if cursor.rowcount > 0: + await cursor.execute("COMMIT") + logger.info(f"✅ 已恢复软删除记录 {table} ID: {record_id}") + return True + else: + await cursor.execute("ROLLBACK") + logger.warning(f"⚠️ 未找到要恢复的记录 {table} ID: {record_id}") + return False + + except Exception as e: + await cursor.execute("ROLLBACK") + logger.error(f"❌ 软删除回滚失败: {e}") + return False + finally: + await cursor.close() + + async def rollback_user_operation(self, user_id: int, operation_type: str = "update") -> bool: + """回滚用户操作""" + logger.info(f"🔄 开始回滚用户操作: ID={user_id}, 类型={operation_type}") + + if operation_type == "delete": + return await self.soft_delete_rollback("users", user_id) + else: + # 对于更新操作,需要从Binlog或其他方式获取原始数据 + logger.warning("⚠️ 用户更新操作回滚需要手动处理") + return False + + async def rollback_course_operation(self, course_id: int, operation_type: str = "update") -> bool: + """回滚课程操作""" + logger.info(f"🔄 开始回滚课程操作: ID={course_id}, 类型={operation_type}") + + if operation_type == "delete": + return await self.soft_delete_rollback("courses", course_id) + else: + logger.warning("⚠️ 课程更新操作回滚需要手动处理") + return False + + async def rollback_exam_operation(self, exam_id: int) -> bool: + """回滚考试操作""" + logger.info(f"🔄 开始回滚考试操作: ID={exam_id}") + + cursor = await self.connection.cursor() + + try: + await cursor.execute("START TRANSACTION") + + # 删除考试结果详情 + await cursor.execute("DELETE FROM exam_results WHERE exam_id = %s", (exam_id,)) + + # 删除考试记录 + await cursor.execute("DELETE FROM exams WHERE id = %s", (exam_id,)) + + await cursor.execute("COMMIT") + logger.info(f"✅ 已回滚考试记录 ID: {exam_id}") + return True + + except Exception as e: + await cursor.execute("ROLLBACK") + logger.error(f"❌ 考试回滚失败: {e}") + return False + finally: + await cursor.close() + + async def rollback_position_operation(self, position_id: int, operation_type: str = "update") -> bool: + """回滚岗位操作""" + logger.info(f"🔄 开始回滚岗位操作: ID={position_id}, 类型={operation_type}") + + if operation_type == "delete": + return await self.soft_delete_rollback("positions", position_id) + else: + logger.warning("⚠️ 岗位更新操作回滚需要手动处理") + return False + + async def list_recent_changes(self, hours: int = 24): + """列出最近的变更""" + logger.info(f"📋 最近 {hours} 小时的数据变更:") + + operations = await self.get_recent_operations(hours) + + if not operations: + logger.info("📝 没有找到最近的变更记录") + return + + # 按类型分组显示 + by_type = {} + for op in operations: + op_type = op.get('type', 'unknown') + if op_type not in by_type: + by_type[op_type] = [] + by_type[op_type].append(op) + + for op_type, ops in by_type.items(): + print(f"\n🔸 {op_type.upper()} 类型变更 ({len(ops)} 条):") + print("-" * 60) + + for op in ops[:10]: # 只显示前10条 + if op_type == 'user': + print(f" ID: {op['id']}, 用户名: {op['username']}, 姓名: {op['full_name']}, 时间: {op['updated_at']}") + elif op_type == 'course': + print(f" ID: {op['id']}, 课程: {op['name']}, 状态: {op['status']}, 时间: {op['updated_at']}") + elif op_type == 'exam': + print(f" ID: {op['id']}, 考试: {op['exam_name']}, 分数: {op['score']}, 时间: {op['updated_at']}") + elif op_type == 'position': + print(f" ID: {op['id']}, 岗位: {op['name']}, 状态: {op['status']}, 时间: {op['updated_at']}") + + if len(ops) > 10: + print(f" ... 还有 {len(ops) - 10} 条记录") + +async def main(): + """主函数""" + parser = argparse.ArgumentParser(description='考培练系统 - 专用数据库回滚工具') + parser.add_argument('--list', action='store_true', help='列出最近的变更') + parser.add_argument('--hours', type=int, default=24, help='查看最近N小时的变更 (默认24小时)') + parser.add_argument('--rollback-user', type=int, help='回滚用户操作 (用户ID)') + parser.add_argument('--rollback-course', type=int, help='回滚课程操作 (课程ID)') + parser.add_argument('--rollback-exam', type=int, help='回滚考试操作 (考试ID)') + parser.add_argument('--rollback-position', type=int, help='回滚岗位操作 (岗位ID)') + parser.add_argument('--operation-type', choices=['update', 'delete'], default='update', help='操作类型') + parser.add_argument('--execute', action='store_true', help='实际执行回滚') + + args = parser.parse_args() + + tool = KaopeilianRollbackTool() + + try: + await tool.connect() + + if args.list: + await tool.list_recent_changes(args.hours) + + elif args.rollback_user: + if args.execute: + success = await tool.rollback_user_operation(args.rollback_user, args.operation_type) + if success: + logger.info("✅ 用户回滚完成") + else: + logger.error("❌ 用户回滚失败") + else: + logger.info(f"🔍 模拟回滚用户 ID: {args.rollback_user}, 类型: {args.operation_type}") + logger.info("使用 --execute 参数实际执行") + + elif args.rollback_course: + if args.execute: + success = await tool.rollback_course_operation(args.rollback_course, args.operation_type) + if success: + logger.info("✅ 课程回滚完成") + else: + logger.error("❌ 课程回滚失败") + else: + logger.info(f"🔍 模拟回滚课程 ID: {args.rollback_course}, 类型: {args.operation_type}") + logger.info("使用 --execute 参数实际执行") + + elif args.rollback_exam: + if args.execute: + success = await tool.rollback_exam_operation(args.rollback_exam) + if success: + logger.info("✅ 考试回滚完成") + else: + logger.error("❌ 考试回滚失败") + else: + logger.info(f"🔍 模拟回滚考试 ID: {args.rollback_exam}") + logger.info("使用 --execute 参数实际执行") + + elif args.rollback_position: + if args.execute: + success = await tool.rollback_position_operation(args.rollback_position, args.operation_type) + if success: + logger.info("✅ 岗位回滚完成") + else: + logger.error("❌ 岗位回滚失败") + else: + logger.info(f"🔍 模拟回滚岗位 ID: {args.rollback_position}, 类型: {args.operation_type}") + logger.info("使用 --execute 参数实际执行") + + else: + parser.print_help() + + except Exception as e: + logger.error(f"❌ 程序执行异常: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/migrate_env_to_db.py b/backend/scripts/migrate_env_to_db.py new file mode 100644 index 0000000..87a2a11 --- /dev/null +++ b/backend/scripts/migrate_env_to_db.py @@ -0,0 +1,317 @@ +#!/usr/bin/env python3 +""" +租户配置迁移脚本 + +功能:将各租户的 .env 文件配置迁移到 kaopeilian_admin 数据库 + +使用方法: + python scripts/migrate_env_to_db.py + +说明: + 1. 读取各租户的 .env 文件 + 2. 创建租户记录 + 3. 将配置写入 tenant_configs 表 + 4. 保留原 .env 文件作为备份 +""" + +import os +import sys +import re +import pymysql +from datetime import datetime +from typing import Dict, List, Tuple, Optional + +# 添加项目根目录到路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# ============================================ +# 配置 +# ============================================ + +# 管理库连接配置 +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "120.79.247.16"), + "port": int(os.getenv("ADMIN_DB_PORT", "3309")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"), + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + +# 租户配置 +TENANTS = [ + { + "code": "demo", + "name": "演示版", + "display_name": "考培练系统-演示版", + "domain": "aiedu.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.production", + "industry": "medical_beauty", + }, + { + "code": "hua", + "name": "华尔倍丽", + "display_name": "华尔倍丽-考培练系统", + "domain": "hua.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.hua", + "industry": "medical_beauty", + }, + { + "code": "yy", + "name": "杨扬宠物", + "display_name": "杨扬宠物-考培练系统", + "domain": "yy.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.yy", + "industry": "pet", + }, + { + "code": "hl", + "name": "武汉禾丽", + "display_name": "武汉禾丽-考培练系统", + "domain": "hl.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.hl", + "industry": "medical_beauty", + }, + { + "code": "xy", + "name": "芯颜定制", + "display_name": "芯颜定制-考培练系统", + "domain": "xy.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.xy", + "industry": "medical_beauty", + }, + { + "code": "fw", + "name": "飞沃", + "display_name": "飞沃-考培练系统", + "domain": "fw.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.fw", + "industry": "medical_beauty", + }, + { + "code": "ex", + "name": "恩喜成都总院", + "display_name": "恩喜成都总院-考培练系统", + "domain": "ex.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.ex", + "industry": "medical_beauty", + }, + { + "code": "kpl", + "name": "瑞小美", + "display_name": "瑞小美-考培练系统", + "domain": "kpl.ireborn.com.cn", + "env_file": "/root/aiedu/kaopeilian-backend/.env.kpl", + "industry": "medical_beauty", + }, +] + +# 配置键到分组的映射 +CONFIG_MAPPING = { + # 数据库配置 + "MYSQL_HOST": ("database", "string", False), + "MYSQL_PORT": ("database", "int", False), + "MYSQL_USER": ("database", "string", False), + "MYSQL_PASSWORD": ("database", "string", True), + "MYSQL_DATABASE": ("database", "string", False), + # Redis配置 + "REDIS_HOST": ("redis", "string", False), + "REDIS_PORT": ("redis", "int", False), + "REDIS_DB": ("redis", "int", False), + "REDIS_URL": ("redis", "string", False), + # 安全配置 + "SECRET_KEY": ("security", "string", True), + "CORS_ORIGINS": ("security", "json", False), + # Dify配置 + "DIFY_API_KEY": ("dify", "string", True), + "DIFY_EXAM_GENERATOR_API_KEY": ("dify", "string", True), + "DIFY_PRACTICE_API_KEY": ("dify", "string", True), + "DIFY_COURSE_CHAT_API_KEY": ("dify", "string", True), + "DIFY_YANJI_ANALYSIS_API_KEY": ("dify", "string", True), + # Coze配置 + "COZE_PRACTICE_BOT_ID": ("coze", "string", False), + "COZE_BROADCAST_WORKFLOW_ID": ("coze", "string", False), + "COZE_BROADCAST_SPACE_ID": ("coze", "string", False), + "COZE_BROADCAST_BOT_ID": ("coze", "string", False), + "COZE_OAUTH_CLIENT_ID": ("coze", "string", False), + "COZE_OAUTH_PUBLIC_KEY_ID": ("coze", "string", False), + "COZE_OAUTH_PRIVATE_KEY_PATH": ("coze", "string", False), + # AI配置 + "AI_PRIMARY_API_KEY": ("ai", "string", True), + "AI_PRIMARY_BASE_URL": ("ai", "string", False), + "AI_FALLBACK_API_KEY": ("ai", "string", True), + "AI_FALLBACK_BASE_URL": ("ai", "string", False), + "AI_DEFAULT_MODEL": ("ai", "string", False), + "AI_TIMEOUT": ("ai", "int", False), + # 言迹配置 + "YANJI_CLIENT_ID": ("yanji", "string", False), + "YANJI_CLIENT_SECRET": ("yanji", "string", True), + "YANJI_TENANT_ID": ("yanji", "string", False), + "YANJI_ESTATE_ID": ("yanji", "string", False), + # 其他配置 + "APP_NAME": ("basic", "string", False), + "PUBLIC_DOMAIN": ("basic", "string", False), +} + + +def parse_env_file(filepath: str) -> Dict[str, str]: + """解析 .env 文件""" + config = {} + + if not os.path.exists(filepath): + print(f" 警告: 文件不存在 {filepath}") + return config + + with open(filepath, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + # 跳过注释和空行 + if not line or line.startswith('#'): + continue + + # 解析 KEY=VALUE + if '=' in line: + key, value = line.split('=', 1) + key = key.strip() + value = value.strip() + + # 去除引号 + if (value.startswith('"') and value.endswith('"')) or \ + (value.startswith("'") and value.endswith("'")): + value = value[1:-1] + + config[key] = value + + return config + + +def create_tenant(cursor, tenant: Dict) -> int: + """创建租户记录,返回租户ID""" + # 检查是否已存在 + cursor.execute( + "SELECT id FROM tenants WHERE code = %s", + (tenant["code"],) + ) + row = cursor.fetchone() + + if row: + print(f" 租户已存在,ID: {row['id']}") + return row["id"] + + # 创建新租户 + cursor.execute( + """ + INSERT INTO tenants (code, name, display_name, domain, industry, status, created_by) + VALUES (%s, %s, %s, %s, %s, 'active', 1) + """, + (tenant["code"], tenant["name"], tenant["display_name"], tenant["domain"], tenant["industry"]) + ) + + tenant_id = cursor.lastrowid + print(f" 创建租户成功,ID: {tenant_id}") + return tenant_id + + +def migrate_config(cursor, tenant_id: int, config: Dict[str, str]) -> Tuple[int, int]: + """迁移配置到数据库""" + inserted = 0 + updated = 0 + + for key, value in config.items(): + if key not in CONFIG_MAPPING: + continue + + config_group, value_type, is_secret = CONFIG_MAPPING[key] + + # 检查是否已存在 + cursor.execute( + """ + SELECT id FROM tenant_configs + WHERE tenant_id = %s AND config_group = %s AND config_key = %s + """, + (tenant_id, config_group, key) + ) + row = cursor.fetchone() + + if row: + # 更新 + cursor.execute( + """ + UPDATE tenant_configs + SET config_value = %s, value_type = %s, is_encrypted = %s, updated_at = NOW() + WHERE id = %s + """, + (value, value_type, is_secret, row["id"]) + ) + updated += 1 + else: + # 插入 + cursor.execute( + """ + INSERT INTO tenant_configs + (tenant_id, config_group, config_key, config_value, value_type, is_encrypted) + VALUES (%s, %s, %s, %s, %s, %s) + """, + (tenant_id, config_group, key, value, value_type, is_secret) + ) + inserted += 1 + + return inserted, updated + + +def main(): + """主函数""" + print("=" * 60) + print("租户配置迁移脚本") + print("=" * 60) + print(f"\n目标数据库: {ADMIN_DB_CONFIG['host']}:{ADMIN_DB_CONFIG['port']}/{ADMIN_DB_CONFIG['db']}") + print(f"待迁移租户: {len(TENANTS)} 个\n") + + # 连接数据库 + conn = pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) + + try: + with conn.cursor() as cursor: + total_inserted = 0 + total_updated = 0 + + for tenant in TENANTS: + print(f"\n处理租户: {tenant['name']} ({tenant['code']})") + print(f" 环境文件: {tenant['env_file']}") + + # 解析 .env 文件 + config = parse_env_file(tenant['env_file']) + print(f" 读取配置: {len(config)} 项") + + # 创建租户 + tenant_id = create_tenant(cursor, tenant) + + # 迁移配置 + if config: + inserted, updated = migrate_config(cursor, tenant_id, config) + print(f" 迁移结果: 新增 {inserted} 项, 更新 {updated} 项") + total_inserted += inserted + total_updated += updated + else: + print(" 跳过迁移(无配置)") + + # 提交事务 + conn.commit() + + print("\n" + "=" * 60) + print("迁移完成!") + print(f"总计: 新增 {total_inserted} 项, 更新 {total_updated} 项") + print("=" * 60) + + except Exception as e: + conn.rollback() + print(f"\n错误: {e}") + raise + finally: + conn.close() + + +if __name__ == "__main__": + main() + diff --git a/backend/scripts/migrate_prompts_to_db.py b/backend/scripts/migrate_prompts_to_db.py new file mode 100644 index 0000000..7b1b0b1 --- /dev/null +++ b/backend/scripts/migrate_prompts_to_db.py @@ -0,0 +1,384 @@ +#!/usr/bin/env python3 +""" +AI 提示词迁移脚本 + +功能:将代码中的 AI 提示词迁移到数据库 + +使用方法: + python scripts/migrate_prompts_to_db.py +""" + +import os +import sys +import json +import pymysql + +# 添加项目根目录到路径 +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + + +# ============================================ +# 配置 +# ============================================ + +ADMIN_DB_CONFIG = { + "host": os.getenv("ADMIN_DB_HOST", "120.79.247.16"), + "port": int(os.getenv("ADMIN_DB_PORT", "3309")), + "user": os.getenv("ADMIN_DB_USER", "root"), + "password": os.getenv("ADMIN_DB_PASSWORD", "ProdMySQL2025!@#"), + "db": os.getenv("ADMIN_DB_NAME", "kaopeilian_admin"), + "charset": "utf8mb4", +} + + +# ============================================ +# 提示词定义 +# ============================================ + +PROMPTS = [ + { + "code": "knowledge_analysis", + "name": "知识点分析", + "description": "从课程资料中提取和分析知识点,支持PDF/Word/文本等格式", + "module": "course", + "system_prompt": """# 角色 +你是一个文件拆解高手,擅长将用户提交的内容进行精准拆分,拆分后的内容做个简单的优化处理使其更具可读性,但要尽量使用原文的原词原句。 + +## 技能 +### 技能 1: 内容拆分 +1. 当用户提交内容后,拆分为多段。 +2. 对拆分后的内容做简单优化,使其更具可读性,比如去掉奇怪符号(如换行符、乱码),若语句不通顺,或格式原因导致错位,则重新表达。用户可能会提交录音转文字的内容,因此可能是有错字的,注意修复这些小瑕疵。 +3. 优化过程中,尽量使用原文的原词原句,特别是话术类,必须保持原有的句式、保持原词原句,而不是重构。 +4. 注意是拆分而不是重写,不需要润色,尽量不做任何处理。 +5. 输出到 content。 + +### 技能 2: 为每一个选段概括一个标题 +1. 为每个拆分出来的选段概括一个标题,并输出到 title。 + +### 技能 3: 为每一个选段说明与主题的关联 +1. 详细说明这一段与全文核心主题的关联,并输出到 topic_relation。 + +### 技能 4: 为每一个选段打上一个类型标签 +1. 用户提交的内容很有可能是一个课程、一篇讲义、一个产品的说明书,通常是用户希望他公司的员工或高管学习的知识。 +2. 用户通常是医疗美容机构或轻医美、生活美容连锁品牌。 +3. 你要为每个选段打上一个知识类型的标签,最好是这几个类型中的一个:"理论知识", "诊断设计", "操作步骤", "沟通话术", "案例分析", "注意事项", "技巧方法", "客诉处理"。当然你也可以为这个选段匹配一个更适合的。 + +## 输出要求(严格按要求输出) +请直接输出一个纯净的 JSON 数组(Array),不要包含 Markdown 标记(如 ```json),也不要包含任何解释性文字。格式如下: + +[ + { + "title": "知识点标题", + "content": "知识点内容", + "topic_relation": "知识点与主题的关系", + "type": "知识点类型" + } +] + +## 限制 +- 仅围绕用户提交的内容进行拆分和关联标注,不涉及其他无关内容。 +- 拆分后的内容必须最大程度保持与原文一致。 +- 关联说明需清晰合理。 +- 不论如何,不要拆分超过 20 段!""", + "user_prompt_template": """课程主题:{course_name} + +## 用户提交的内容: + +{content} + +## 注意 + +- 以json的格式输出 +- 不论如何,不要拆分超过20 段!""", + "variables": ["course_name", "content"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 8192, + "temperature": 0.7, + }, + { + "code": "exam_generator", + "name": "试题生成器", + "description": "根据知识点自动生成考试题目,支持单选、多选、判断、填空、问答题型", + "module": "exam", + "system_prompt": """# 角色 +你是一个专业的试题生成器,能够根据给定的知识点内容生成高质量的考试题目。 + +## 技能 +### 技能 1: 生成单选题 +- 每道题有4个选项(A、B、C、D) +- 只有一个正确答案 +- 选项设计要有迷惑性但不能有歧义 + +### 技能 2: 生成多选题 +- 每道题有4个选项(A、B、C、D) +- 有2-4个正确答案 +- 考察综合理解能力 + +### 技能 3: 生成判断题 +- 陈述一个观点,判断对错 +- 答案为"对"或"错" + +### 技能 4: 生成填空题 +- 在关键位置设置空白 +- 答案明确唯一 + +### 技能 5: 生成问答题 +- 开放性问题 +- 需要组织语言回答 + +## 输出格式 +请直接输出 JSON 数组,格式如下: + +[ + { + "question_type": "single_choice", + "title": "题目内容", + "options": ["A. 选项1", "B. 选项2", "C. 选项3", "D. 选项4"], + "correct_answer": "A", + "explanation": "答案解析", + "difficulty": "easy/medium/hard" + } +] + +## 限制 +- 题目必须与给定知识点相关 +- 难度要适中,兼顾基础和提升 +- 表述清晰准确,无歧义""", + "user_prompt_template": """请根据以下知识点内容生成试题: + +{content} + +要求: +- 单选题 {single_choice_count} 道 +- 多选题 {multiple_choice_count} 道 +- 判断题 {true_false_count} 道 +- 填空题 {fill_blank_count} 道 +- 问答题 {essay_count} 道 + +难度系数:{difficulty_level}(1-5,1最简单) + +请以 JSON 格式输出题目列表。""", + "variables": ["content", "single_choice_count", "multiple_choice_count", "true_false_count", "fill_blank_count", "essay_count", "difficulty_level"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 8192, + "temperature": 0.7, + }, + { + "code": "course_chat", + "name": "课程对话", + "description": "与课程知识点进行智能对话,回答学员问题", + "module": "course", + "system_prompt": """# 角色 +你是一位专业的课程助教,负责回答学员关于课程内容的问题。 + +## 职责 +1. 准确回答与课程相关的问题 +2. 用通俗易懂的语言解释复杂概念 +3. 提供实用的学习建议 +4. 关联相关知识点帮助理解 + +## 原则 +- 回答要准确、专业 +- 语言要友好、易懂 +- 适当举例说明 +- 如果问题超出课程范围,礼貌说明 + +## 回复格式 +- 保持简洁明了 +- 可以使用列表、分点等结构化方式 +- 重要内容可以加粗强调""", + "user_prompt_template": """课程名称:{course_name} + +课程知识点: +{knowledge_content} + +学员问题:{query} + +请根据课程知识点回答学员的问题。""", + "variables": ["course_name", "knowledge_content", "query"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 2048, + "temperature": 0.7, + }, + { + "code": "ability_analysis", + "name": "能力分析", + "description": "基于智能工牌对话数据分析员工能力并推荐课程", + "module": "ability", + "system_prompt": """# 角色 +你是一位专业的人才发展顾问,负责根据员工的对话记录分析其能力并推荐提升课程。 + +## 分析维度 +1. **专业知识**:产品知识、行业知识、技术能力 +2. **沟通能力**:表达清晰度、倾听能力、情绪管理 +3. **销售技巧**:需求挖掘、异议处理、促成能力 +4. **服务意识**:客户关怀、问题解决、满意度维护 + +## 输出格式 +请输出 JSON 格式的分析结果: + +{ + "overall_score": 75, + "dimensions": [ + {"name": "专业知识", "score": 80, "comment": "评价"}, + {"name": "沟通能力", "score": 70, "comment": "评价"} + ], + "strengths": ["优势1", "优势2"], + "weaknesses": ["待提升1", "待提升2"], + "recommendations": [ + {"course_name": "推荐课程", "reason": "推荐理由"} + ] +}""", + "user_prompt_template": """员工信息: +- 姓名:{employee_name} +- 岗位:{position} + +对话记录: +{conversation_records} + +可选课程列表: +{available_courses} + +请分析该员工的能力并推荐适合的课程。""", + "variables": ["employee_name", "position", "conversation_records", "available_courses"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 4096, + "temperature": 0.7, + }, + { + "code": "practice_scene", + "name": "陪练场景生成", + "description": "根据课程内容生成陪练场景和对话", + "module": "practice", + "system_prompt": """# 角色 +你是一位专业的培训场景设计师,负责为员工陪练设计模拟对话场景。 + +## 职责 +1. 根据课程内容设计真实场景 +2. 模拟客户各种可能的提问和反应 +3. 设计合理的对话流程 +4. 提供评估标准 + +## 场景类型 +- 电话销售场景 +- 面对面咨询场景 +- 客户投诉处理场景 +- 售后服务场景 +- 产品介绍场景 + +## 输出格式 +请输出 JSON 格式: + +{ + "scene_name": "场景名称", + "scene_type": "场景类型", + "background": "场景背景", + "customer_profile": "客户画像", + "dialogue_flow": [ + {"role": "customer", "content": "客户话术"}, + {"role": "employee", "content": "员工话术"} + ], + "evaluation_points": ["评估要点1", "评估要点2"] +}""", + "user_prompt_template": """课程名称:{course_name} + +课程知识点: +{knowledge_content} + +请为这些知识点设计一个{scene_type}的陪练场景。 + +难度:{difficulty} +预计时长:{duration}分钟""", + "variables": ["course_name", "knowledge_content", "scene_type", "difficulty", "duration"], + "model_recommendation": "gemini-3-flash-preview", + "max_tokens": 4096, + "temperature": 0.8, + }, +] + + +def main(): + """主函数""" + print("=" * 60) + print("AI 提示词迁移脚本") + print("=" * 60) + print(f"\n目标数据库: {ADMIN_DB_CONFIG['host']}:{ADMIN_DB_CONFIG['port']}/{ADMIN_DB_CONFIG['db']}") + print(f"待迁移提示词: {len(PROMPTS)} 个\n") + + conn = pymysql.connect(**ADMIN_DB_CONFIG, cursorclass=pymysql.cursors.DictCursor) + + try: + with conn.cursor() as cursor: + inserted = 0 + updated = 0 + + for prompt in PROMPTS: + print(f"处理提示词: {prompt['name']} ({prompt['code']})") + + # 检查是否已存在 + cursor.execute( + "SELECT id, version FROM ai_prompts WHERE code = %s", + (prompt["code"],) + ) + existing = cursor.fetchone() + + if existing: + # 更新 + cursor.execute( + """ + UPDATE ai_prompts SET + name = %s, + description = %s, + module = %s, + system_prompt = %s, + user_prompt_template = %s, + variables = %s, + model_recommendation = %s, + max_tokens = %s, + temperature = %s, + updated_by = 1 + WHERE id = %s + """, + (prompt["name"], prompt["description"], prompt["module"], + prompt["system_prompt"], prompt["user_prompt_template"], + json.dumps(prompt["variables"]), + prompt["model_recommendation"], prompt["max_tokens"], + prompt["temperature"], existing["id"]) + ) + print(f" 更新成功,ID: {existing['id']}") + updated += 1 + else: + # 插入 + cursor.execute( + """ + INSERT INTO ai_prompts + (code, name, description, module, system_prompt, user_prompt_template, + variables, model_recommendation, max_tokens, temperature, is_system, created_by) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, TRUE, 1) + """, + (prompt["code"], prompt["name"], prompt["description"], prompt["module"], + prompt["system_prompt"], prompt["user_prompt_template"], + json.dumps(prompt["variables"]), + prompt["model_recommendation"], prompt["max_tokens"], prompt["temperature"]) + ) + print(f" 插入成功,ID: {cursor.lastrowid}") + inserted += 1 + + conn.commit() + + print("\n" + "=" * 60) + print("迁移完成!") + print(f"新增: {inserted} 个, 更新: {updated} 个") + print("=" * 60) + + except Exception as e: + conn.rollback() + print(f"\n错误: {e}") + raise + finally: + conn.close() + + +if __name__ == "__main__": + main() + diff --git a/backend/scripts/mock_data_beauty.sql b/backend/scripts/mock_data_beauty.sql new file mode 100644 index 0000000..35e70ee --- /dev/null +++ b/backend/scripts/mock_data_beauty.sql @@ -0,0 +1,329 @@ +-- ============================================ +-- 轻医美+生活美容连锁机构模拟数据 +-- 版本:1.0.0 +-- 创建时间:2025-01-20 +-- ============================================ + +USE `kaopeilian`; + +-- 清理已有测试数据(保留初始的superadmin、admin、testuser) +DELETE FROM training_reports WHERE id > 0; +DELETE FROM training_messages WHERE id > 0; +DELETE FROM training_sessions WHERE id > 0; +DELETE FROM exam_results WHERE id > 0; +DELETE FROM exams WHERE id > 0; +DELETE FROM questions WHERE course_id > 4; +DELETE FROM knowledge_points WHERE course_id > 4; +DELETE FROM course_materials WHERE course_id > 4; +DELETE FROM user_teams WHERE user_id > 3; +DELETE FROM teams WHERE id > 3; +DELETE FROM courses WHERE id > 4; +DELETE FROM training_scenes WHERE id > 3; +DELETE FROM growth_paths WHERE id > 2; +DELETE FROM users WHERE id > 3; + +-- ============================================ +-- 一、用户数据(轻医美+生活美容机构人员) +-- ============================================ + +-- 管理层 +INSERT INTO users (username, email, phone, hashed_password, full_name, role, is_active, is_verified, bio) VALUES +('zhangyun', 'zhangyun@beauty.com', '13800138001', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '张云', 'admin', TRUE, TRUE, '集团总经理,20年美容行业经验'), +('lixiaoli', 'lixiaoli@beauty.com', '13800138002', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '李晓丽', 'manager', TRUE, TRUE, '华东区域经理,负责上海、江苏、浙江区域'), +('wangmei', 'wangmei@beauty.com', '13800138003', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '王梅', 'manager', TRUE, TRUE, '华南区域经理,负责广东、福建区域'), + +-- 医美部门 +('drchen', 'drchen@beauty.com', '13800138004', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '陈医生', 'manager', TRUE, TRUE, '医美技术总监,皮肤科主治医师'), +('liujing', 'liujing@beauty.com', '13800138005', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '刘静', 'trainee', TRUE, TRUE, '资深医美顾问,5年从业经验'), +('zhangmin', 'zhangmin@beauty.com', '13800138006', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '张敏', 'trainee', TRUE, TRUE, '医美技师,擅长光电项目操作'), +('sunhui', 'sunhui@beauty.com', '13800138007', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '孙慧', 'trainee', TRUE, TRUE, '医美技师,专注水光针注射'), + +-- 美容部门 +('zhaoxue', 'zhaoxue@beauty.com', '13800138008', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '赵雪', 'manager', TRUE, TRUE, '美容部主管,国家高级美容师'), +('yangli', 'yangli@beauty.com', '13800138009', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '杨丽', 'trainee', TRUE, TRUE, '资深美容顾问,擅长皮肤管理方案设计'), +('huangting', 'huangting@beauty.com', '13800138010', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '黄婷', 'trainee', TRUE, TRUE, '美容师,专注面部护理'), +('linwei', 'linwei@beauty.com', '13800138011', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '林薇', 'trainee', TRUE, TRUE, '美容师,擅长身体SPA'), +('chenyu', 'chenyu@beauty.com', '13800138012', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '陈雨', 'trainee', TRUE, TRUE, '美容师,专注问题性皮肤护理'), + +-- 客服部门 +('wujuan', 'wujuan@beauty.com', '13800138013', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '吴娟', 'manager', TRUE, TRUE, '客服部经理,负责客户关系管理'), +('zhoufang', 'zhoufang@beauty.com', '13800138014', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '周芳', 'trainee', TRUE, TRUE, '客户经理,负责VIP客户维护'), +('xujing', 'xujing@beauty.com', '13800138015', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '徐静', 'trainee', TRUE, TRUE, '前台接待,形象气质佳'), + +-- 各分店人员 +('liuyan', 'liuyan@beauty.com', '13800138016', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '刘燕', 'manager', TRUE, TRUE, '静安店店长'), +('zhangna', 'zhangna@beauty.com', '13800138017', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '张娜', 'trainee', TRUE, TRUE, '静安店美容顾问'), +('wangxin', 'wangxin@beauty.com', '13800138018', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '王欣', 'trainee', TRUE, TRUE, '静安店美容师'), +('lihong', 'lihong@beauty.com', '13800138019', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '李红', 'manager', TRUE, TRUE, '徐汇店店长'), +('zhaoli', 'zhaoli@beauty.com', '13800138020', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TtxMQJqhN8/LewKyNiQdP/sQj6C6', '赵丽', 'trainee', TRUE, TRUE, '徐汇店医美顾问'); + +-- ============================================ +-- 二、团队数据(机构组织架构) +-- ============================================ + +-- 公司总部 +INSERT INTO teams (name, code, description, team_type, leader_id) VALUES +('集团总部', 'HQ', '轻医美生活美容集团总部', 'department', 4), +('医美事业部', 'MEDICAL', '负责所有医美项目的运营和技术支持', 'department', 7), +('美容事业部', 'BEAUTY', '负责传统美容项目的运营和培训', 'department', 11), +('客服中心', 'SERVICE', '负责客户服务和关系维护', 'department', 16), + +-- 区域团队 +('华东区域', 'EAST', '负责上海、江苏、浙江区域运营', 'department', 5), +('华南区域', 'SOUTH', '负责广东、福建区域运营', 'department', 6), + +-- 门店团队 +('静安旗舰店', 'JINGAN', '上海静安区旗舰店', 'department', 19), +('徐汇精品店', 'XUHUI', '上海徐汇区精品店', 'department', 22), + +-- 专项小组 +('医美技术委员会', 'MED_TECH', '负责医美技术标准制定和培训', 'study_group', 7), +('美容技术研究组', 'BEAUTY_RES', '负责美容新技术研究和推广', 'study_group', 11), +('服务标准化小组', 'SERVICE_STD', '负责服务流程标准化', 'study_group', 16); + +-- ============================================ +-- 三、用户团队关联 +-- ============================================ + +INSERT INTO user_teams (user_id, team_id, role) VALUES +-- 总经理管理总部 +(4, 4, 'leader'), +-- 区域经理 +(5, 8, 'leader'), +(6, 9, 'leader'), +-- 部门负责人 +(7, 5, 'leader'), +(11, 6, 'leader'), +(16, 7, 'leader'), +-- 医美部门成员 +(7, 12, 'leader'), +(8, 5, 'member'), +(9, 5, 'member'), +(10, 5, 'member'), +-- 美容部门成员 +(11, 13, 'leader'), +(12, 6, 'member'), +(13, 6, 'member'), +(14, 6, 'member'), +(15, 6, 'member'), +-- 客服部门成员 +(17, 7, 'member'), +(18, 7, 'member'), +-- 门店团队 +(19, 10, 'leader'), +(20, 10, 'member'), +(21, 10, 'member'), +(22, 11, 'leader'), +(23, 11, 'member'); + +-- ============================================ +-- 四、课程数据(美容行业培训课程) +-- ============================================ + +DELETE FROM courses WHERE id > 4; + +INSERT INTO courses (name, description, category, status, cover_image, duration_hours, difficulty_level, tags, published_at, is_featured, created_by) VALUES +-- 基础课程 +('美容基础理论', '美容行业入门必修课,包含皮肤生理学、美容营养学等基础知识', 'general', 'published', '/uploads/course/beauty_basic.jpg', 20, 1, '["美容基础", "皮肤管理", "理论知识"]', '2024-01-15 10:00:00', TRUE, 4), +('轻医美项目认知', '了解主流轻医美项目的原理、适应症和操作流程', 'technology', 'published', '/uploads/course/medical_beauty.jpg', 30, 2, '["轻医美", "水光针", "光电项目"]', '2024-01-20 14:00:00', TRUE, 7), +('销售心理学与话术', '掌握美容行业销售技巧,提升业绩转化能力', 'business', 'published', '/uploads/course/sales_skill.jpg', 15, 2, '["销售技巧", "客户心理", "话术"]', '2024-02-01 09:00:00', TRUE, 16), +('产品知识大全', '全面了解各类护肤品成分、功效和搭配方案', 'technology', 'published', '/uploads/course/product_knowledge.jpg', 25, 2, '["产品知识", "成分分析", "护肤"]', '2024-02-10 11:00:00', FALSE, 11), +('客户服务标准流程', '标准化服务流程培训,提升客户满意度', 'management', 'published', '/uploads/course/service_process.jpg', 12, 1, '["服务流程", "客户体验", "标准化"]', '2024-02-15 15:00:00', TRUE, 16), +('卫生安全操作规范', '医美和美容项目的卫生安全标准培训', 'general', 'published', '/uploads/course/safety_standard.jpg', 8, 1, '["安全规范", "卫生标准", "操作流程"]', '2024-02-20 10:00:00', TRUE, 7), + +-- 进阶课程 +('面部美容手法精修', '深入学习各种面部护理手法和技巧', 'technology', 'published', '/uploads/course/facial_technique.jpg', 40, 3, '["面部护理", "手法技巧", "实操"]', '2024-03-01 14:00:00', FALSE, 11), +('光电仪器操作认证', '各类美容仪器的原理和操作技巧培训', 'technology', 'published', '/uploads/course/device_operation.jpg', 35, 3, '["仪器操作", "光电美容", "认证培训"]', '2024-03-10 09:00:00', TRUE, 7), +('问题性皮肤管理', '针对各类问题性皮肤的诊断和护理方案', 'technology', 'published', '/uploads/course/problem_skin.jpg', 30, 4, '["问题皮肤", "痘痘", "敏感肌", "诊断"]', '2024-03-15 11:00:00', FALSE, 11), +('VIP客户管理艺术', '高端客户的维护技巧和管理策略', 'management', 'published', '/uploads/course/vip_management.jpg', 18, 3, '["VIP管理", "客户维护", "高端服务"]', '2024-03-20 15:00:00', FALSE, 16), + +-- 管理课程 +('美容店务管理', '美容门店的日常运营和管理技巧', 'management', 'published', '/uploads/course/store_management.jpg', 20, 3, '["店务管理", "运营", "团队建设"]', '2024-04-01 10:00:00', FALSE, 19), +('团队激励与培养', '如何打造高效的美容服务团队', 'management', 'published', '/uploads/course/team_building.jpg', 15, 3, '["团队管理", "员工激励", "人才培养"]', '2024-04-10 14:00:00', FALSE, 4); + +-- ============================================ +-- 五、课程资料 +-- ============================================ + +-- 为新课程添加资料 +INSERT INTO course_materials (course_id, name, description, file_url, file_type, file_size, sort_order) VALUES +-- 美容基础理论 +(5, '皮肤生理学基础.pdf', '详细介绍皮肤结构和生理功能', '/uploads/materials/skin_physiology.pdf', 'pdf', 5242880, 1), +(5, '美容营养学.pdf', '营养与美容的关系', '/uploads/materials/beauty_nutrition.pdf', 'pdf', 3145728, 2), +(5, '基础理论视频课程', '完整的理论知识讲解', '/uploads/materials/theory_video.mp4', 'mp4', 209715200, 3), + +-- 轻医美项目认知 +(6, '水光针操作指南.pdf', '水光针注射标准操作流程', '/uploads/materials/hydra_injection.pdf', 'pdf', 4194304, 1), +(6, '光电项目原理详解.ppt', '各类光电项目的原理和效果', '/uploads/materials/photoelectric.ppt', 'ppt', 10485760, 2), +(6, '操作演示视频', '真人操作演示教学', '/uploads/materials/operation_demo.mp4', 'mp4', 314572800, 3), + +-- 销售心理学与话术 +(7, '客户心理分析.pdf', '不同类型客户的心理特征', '/uploads/materials/customer_psychology.pdf', 'pdf', 2097152, 1), +(7, '标准话术手册.doc', '各场景标准话术模板', '/uploads/materials/sales_scripts.doc', 'doc', 1048576, 2), +(7, '销售实战案例.mp4', '优秀销售案例分享', '/uploads/materials/sales_cases.mp4', 'mp4', 157286400, 3); + +-- ============================================ +-- 六、知识点体系 +-- ============================================ + +-- 美容基础理论知识点 +INSERT INTO knowledge_points (course_id, name, description, parent_id, level, path, sort_order, weight, is_required, estimated_hours) VALUES +(5, '皮肤生理学', '了解皮肤的结构和功能', NULL, 1, '1', 1, 2.0, TRUE, 5), +(5, '皮肤结构', '表皮、真皮、皮下组织的构成', 25, 2, '1.1', 1, 1.5, TRUE, 2), +(5, '皮肤类型判断', '干性、油性、混合性、敏感性皮肤的特征', 25, 2, '1.2', 2, 1.5, TRUE, 1.5), +(5, '美容营养学', '营养素对皮肤的影响', NULL, 1, '2', 2, 1.5, TRUE, 4), +(5, '维生素与美容', '各类维生素的美容功效', 28, 2, '2.1', 1, 1.0, TRUE, 2), + +-- 轻医美项目知识点 +(6, '注射类项目', '各类注射美容项目介绍', NULL, 1, '1', 1, 2.5, TRUE, 10), +(6, '水光针技术', '水光针的原理和操作要点', 30, 2, '1.1', 1, 2.0, TRUE, 4), +(6, '肉毒素注射', '肉毒素的作用原理和注射技巧', 30, 2, '1.2', 2, 2.0, TRUE, 3), +(6, '光电类项目', '光电美容技术详解', NULL, 1, '2', 2, 2.5, TRUE, 12), +(6, '激光美容', '各类激光的原理和应用', 33, 2, '2.1', 1, 2.0, TRUE, 5); + +-- ============================================ +-- 七、考试题目(美容行业相关) +-- ============================================ + +-- 美容基础理论题目 +INSERT INTO questions (course_id, question_type, title, content, options, correct_answer, explanation, score, difficulty, tags) VALUES +(5, 'single_choice', '皮肤最外层的结构是?', NULL, '{"A": "真皮层", "B": "表皮层", "C": "皮下组织", "D": "基底层"}', 'B', '皮肤由外到内分为表皮层、真皮层和皮下组织', 10.0, 'easy', '["皮肤结构", "基础知识"]'), +(5, 'single_choice', '以下哪种维生素被称为"美容维生素"?', NULL, '{"A": "维生素A", "B": "维生素B", "C": "维生素C", "D": "维生素D"}', 'C', '维生素C具有抗氧化、美白、促进胶原蛋白合成的作用', 10.0, 'easy', '["营养学", "维生素"]'), +(5, 'true_false', '油性皮肤不需要补水', NULL, NULL, 'false', '油性皮肤也需要补水,缺水会导致皮肤分泌更多油脂', 10.0, 'medium', '["皮肤类型", "护理误区"]'), +(5, 'fill_blank', '皮肤的pH值呈____性', NULL, NULL, '弱酸', '健康皮肤的pH值在4.5-6.5之间,呈弱酸性', 10.0, 'easy', '["皮肤生理"]'), + +-- 轻医美项目题目 +(6, 'single_choice', '水光针注射的层次是?', NULL, '{"A": "表皮层", "B": "真皮浅层", "C": "真皮深层", "D": "皮下组织"}', 'B', '水光针通常注射在真皮浅层,有利于营养成分的吸收', 10.0, 'medium', '["水光针", "注射技术"]'), +(6, 'single_choice', '肉毒素的作用原理是?', NULL, '{"A": "填充凹陷", "B": "阻断神经肌肉传导", "C": "刺激胶原再生", "D": "溶解脂肪"}', 'B', '肉毒素通过阻断神经肌肉传导,使肌肉放松,从而减少皱纹', 10.0, 'medium', '["肉毒素", "作用原理"]'), +(6, 'multiple_choice', '以下哪些是光子嫩肤的适应症?(多选)', NULL, '{"A": "色斑", "B": "毛孔粗大", "C": "红血丝", "D": "深度皱纹"}', '["A", "B", "C"]', '光子嫩肤适用于浅表性皮肤问题,对深度皱纹效果有限', 15.0, 'hard', '["光电项目", "适应症"]'), + +-- 销售技巧题目 +(7, 'single_choice', '面对犹豫不决的客户,最好的策略是?', NULL, '{"A": "立即降价", "B": "强调限时优惠", "C": "了解顾虑并解答", "D": "推荐更贵的项目"}', 'C', '了解客户的具体顾虑并针对性解答,建立信任更重要', 10.0, 'medium', '["销售技巧", "客户心理"]'), +(7, 'true_false', '销售时应该尽量推荐最贵的产品和项目', NULL, NULL, 'false', '应该根据客户的实际需求和消费能力推荐合适的产品', 10.0, 'easy', '["销售原则", "职业道德"]'), + +-- 产品知识题目 +(8, 'single_choice', '玻尿酸的主要功效是?', NULL, '{"A": "美白", "B": "保湿", "C": "去角质", "D": "控油"}', 'B', '玻尿酸是优秀的保湿成分,能吸收自身重量1000倍的水分', 10.0, 'easy', '["成分知识", "功效"]'), +(8, 'fill_blank', '视黄醇是维生素____的衍生物', NULL, NULL, 'A', '视黄醇(Retinol)是维生素A的衍生物,具有抗老功效', 10.0, 'medium', '["成分知识", "维生素"]'), + +-- 服务流程题目 +(9, 'single_choice', '客户到店后的第一步应该是?', NULL, '{"A": "推销产品", "B": "热情接待并了解需求", "C": "直接带去护理", "D": "要求办卡"}', 'B', '良好的接待和需求了解是优质服务的开始', 10.0, 'easy', '["服务流程", "接待"]'), +(9, 'true_false', '护理过程中可以接听私人电话', NULL, NULL, 'false', '护理过程中应专注于客户,避免接听私人电话', 10.0, 'easy', '["服务规范", "职业素养"]'), + +-- 安全规范题目 +(10, 'single_choice', '医美项目操作前必须进行的步骤是?', NULL, '{"A": "皮肤测试", "B": "签署知情同意书", "C": "拍照存档", "D": "以上都是"}', 'D', '医美项目需要做好充分的术前准备和风险告知', 10.0, 'medium', '["安全规范", "操作流程"]'), +(10, 'multiple_choice', '以下哪些属于无菌操作的要求?(多选)', NULL, '{"A": "戴无菌手套", "B": "使用一次性耗材", "C": "操作台面消毒", "D": "戴口罩"}', '["A", "B", "C", "D"]', '无菌操作需要全方位的防护和消毒措施', 15.0, 'medium', '["无菌操作", "卫生标准"]'); + +-- ============================================ +-- 八、AI陪练场景(美容行业场景) +-- ============================================ + +DELETE FROM training_scenes WHERE id > 3; + +INSERT INTO training_scenes (name, description, category, ai_config, prompt_template, evaluation_criteria, status, is_public, created_by) VALUES +('客户咨询接待', '模拟接待到店客户,了解需求并推荐合适的项目', '客户服务', +'{"bot_id": "beauty_consultant_bot", "temperature": 0.7}', +'你是一位专业的美容顾问,需要热情接待客户,了解客户的皮肤问题和需求,并推荐合适的护理项目。注意要专业、亲切、不过度推销。', +'{"professionalism": 30, "communication": 25, "needs_analysis": 25, "solution_matching": 20}', +'ACTIVE', TRUE, 16), + +('产品成分咨询', '解答客户关于护肤品成分和功效的问题', '专业知识', +'{"bot_id": "ingredient_expert_bot", "temperature": 0.6}', +'你是一位护肤品成分专家,需要用通俗易懂的语言向客户解释各种成分的作用和适用人群。', +'{"accuracy": 35, "clarity": 30, "practicality": 20, "patience": 15}', +'ACTIVE', TRUE, 11), + +('投诉处理演练', '处理客户投诉,化解矛盾,维护客户关系', '危机处理', +'{"bot_id": "complaint_handler_bot", "temperature": 0.8}', +'你扮演一位不满意的客户,对服务或效果有投诉。学员需要耐心倾听、理解客户情绪、提供解决方案。', +'{"empathy": 30, "problem_solving": 30, "communication": 25, "result": 15}', +'ACTIVE', TRUE, 16), + +('美容手法指导', '一对一美容手法技巧指导和纠正', '技能培训', +'{"bot_id": "technique_trainer_bot", "temperature": 0.5}', +'你是一位资深美容培训师,指导学员正确的面部护理手法,包括力度、方向、节奏等细节。', +'{"technique_accuracy": 40, "comprehension": 30, "practice": 20, "safety": 10}', +'ACTIVE', TRUE, 11), + +('销售话术演练', '练习不同场景下的销售话术和应对技巧', '销售技巧', +'{"bot_id": "sales_trainer_bot", "temperature": 0.7}', +'模拟各种类型的客户,让学员练习销售话术,包括产品介绍、异议处理、促成成交等。', +'{"persuasion": 25, "product_knowledge": 25, "objection_handling": 25, "closing": 25}', +'ACTIVE', TRUE, 16), + +('医美项目咨询', '专业解答轻医美项目的原理、效果和注意事项', '医美咨询', +'{"bot_id": "medical_beauty_bot", "temperature": 0.6}', +'你是一位医美咨询师,需要专业、客观地介绍各种轻医美项目,包括适应症、恢复期、注意事项等。', +'{"professionalism": 35, "safety_awareness": 30, "communication": 20, "ethics": 15}', +'ACTIVE', TRUE, 7); + +-- ============================================ +-- 九、成长路径(美容行业职业发展) +-- ============================================ + +DELETE FROM growth_paths WHERE id > 2; + +INSERT INTO growth_paths (name, description, target_role, courses, estimated_duration_days, is_active, sort_order) VALUES +('美容师成长路径', '从初级美容师到高级美容技师的完整学习路径', '高级美容技师', +'[{"course_id": 5, "order": 1, "is_required": true}, + {"course_id": 8, "order": 2, "is_required": true}, + {"course_id": 9, "order": 3, "is_required": true}, + {"course_id": 10, "order": 4, "is_required": true}, + {"course_id": 11, "order": 5, "is_required": true}, + {"course_id": 13, "order": 6, "is_required": false}]', +90, TRUE, 1), + +('美容顾问发展路径', '培养专业的美容顾问和销售精英', '资深美容顾问', +'[{"course_id": 5, "order": 1, "is_required": true}, + {"course_id": 8, "order": 2, "is_required": true}, + {"course_id": 7, "order": 3, "is_required": true}, + {"course_id": 9, "order": 4, "is_required": true}, + {"course_id": 14, "order": 5, "is_required": false}]', +60, TRUE, 2), + +('医美技师培养路径', '轻医美项目操作技师的专业培训路径', '医美技师', +'[{"course_id": 5, "order": 1, "is_required": true}, + {"course_id": 6, "order": 2, "is_required": true}, + {"course_id": 10, "order": 3, "is_required": true}, + {"course_id": 12, "order": 4, "is_required": true}, + {"course_id": 13, "order": 5, "is_required": false}]', +120, TRUE, 3), + +('店长管理路径', '从员工到店长的管理能力提升路径', '美容店长', +'[{"course_id": 9, "order": 1, "is_required": true}, + {"course_id": 7, "order": 2, "is_required": true}, + {"course_id": 14, "order": 3, "is_required": true}, + {"course_id": 15, "order": 4, "is_required": true}, + {"course_id": 16, "order": 5, "is_required": true}]', +90, TRUE, 4); + +-- ============================================ +-- 十、模拟考试记录和陪练记录 +-- ============================================ + +-- 插入一些考试记录 +INSERT INTO exams (user_id, course_id, exam_name, question_count, total_score, pass_score, start_time, end_time, duration_minutes, score, is_passed, status) VALUES +(8, 5, '美容基础理论期末考试', 20, 100, 60, '2024-12-10 09:00:00', '2024-12-10 09:45:00', 60, 85, TRUE, 'submitted'), +(9, 6, '轻医美项目认证考试', 15, 100, 70, '2024-12-12 14:00:00', '2024-12-12 14:50:00', 60, 78, TRUE, 'submitted'), +(12, 7, '销售技巧考核', 10, 100, 60, '2024-12-15 10:00:00', '2024-12-15 10:30:00', 30, 92, TRUE, 'submitted'), +(13, 5, '皮肤生理学测试', 15, 100, 60, '2024-12-18 15:00:00', '2024-12-18 15:40:00', 45, 73, TRUE, 'submitted'); + +-- 插入一些陪练会话记录 +INSERT INTO training_sessions (user_id, scene_id, start_time, end_time, duration_seconds, status, total_score, evaluation_result) VALUES +(8, 4, '2024-12-20 10:00:00', '2024-12-20 10:25:00', 1500, 'COMPLETED', 82, +'{"professionalism": 85, "communication": 80, "needs_analysis": 82, "solution_matching": 80}'), +(12, 5, '2024-12-21 14:30:00', '2024-12-21 14:50:00', 1200, 'COMPLETED', 88, +'{"accuracy": 90, "clarity": 88, "practicality": 85, "patience": 87}'), +(17, 6, '2024-12-22 09:00:00', '2024-12-22 09:20:00', 1200, 'COMPLETED', 75, +'{"empathy": 78, "problem_solving": 72, "communication": 75, "result": 75}'), +(20, 8, '2024-12-23 15:00:00', '2024-12-23 15:30:00', 1800, 'COMPLETED', 90, +'{"persuasion": 88, "product_knowledge": 92, "objection_handling": 90, "closing": 90}'); + +-- ============================================ +-- 输出完成信息 +-- ============================================ + +SELECT '美容机构模拟数据插入完成!' as message; +SELECT '新增用户数量:' as info, COUNT(*) as count FROM users WHERE id > 3; +SELECT '新增团队数量:' as info, COUNT(*) as count FROM teams WHERE id > 3; +SELECT '新增课程数量:' as info, COUNT(*) as count FROM courses WHERE id > 4; +SELECT '新增陪练场景:' as info, COUNT(*) as count FROM training_scenes WHERE id > 3; diff --git a/backend/scripts/rollback_example.py b/backend/scripts/rollback_example.py new file mode 100644 index 0000000..091d116 --- /dev/null +++ b/backend/scripts/rollback_example.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +""" +考培练系统 - 数据库回滚工具使用示例 +演示如何使用回滚工具进行常见的数据恢复操作 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from scripts.kaopeilian_rollback import KaopeilianRollbackTool + +async def demo_rollback_tools(): + """演示回滚工具的使用""" + + print("🔧 考培练系统 - 数据库回滚工具演示") + print("=" * 60) + + tool = KaopeilianRollbackTool() + + try: + await tool.connect() + + print("\n1️⃣ 查看最近24小时的数据变更") + print("-" * 40) + await tool.list_recent_changes(24) + + print("\n2️⃣ 演示用户回滚(模拟)") + print("-" * 40) + print("命令示例:") + print("python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete") + print("python scripts/kaopeilian_rollback.py --rollback-user 123 --operation-type delete --execute") + + print("\n3️⃣ 演示课程回滚(模拟)") + print("-" * 40) + print("命令示例:") + print("python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete") + print("python scripts/kaopeilian_rollback.py --rollback-course 456 --operation-type delete --execute") + + print("\n4️⃣ 演示考试回滚(模拟)") + print("-" * 40) + print("命令示例:") + print("python scripts/kaopeilian_rollback.py --rollback-exam 789") + print("python scripts/kaopeilian_rollback.py --rollback-exam 789 --execute") + + print("\n5️⃣ 演示时间点回滚") + print("-" * 40) + print("命令示例:") + print("python scripts/simple_rollback.py --time '2024-12-20 10:30:00'") + print("python scripts/simple_rollback.py --time '2024-12-20 10:30:00' --execute") + + print("\n6️⃣ 查看Binlog文件") + print("-" * 40) + print("命令示例:") + print("python scripts/simple_rollback.py --list") + print("python scripts/binlog_rollback_tool.py --list-binlogs") + + print("\n📋 回滚工具总结") + print("-" * 40) + print("✅ 专用工具:kaopeilian_rollback.py - 业务场景回滚") + print("✅ 简化工具:simple_rollback.py - 时间点回滚") + print("✅ 完整工具:binlog_rollback_tool.py - 复杂Binlog回滚") + print("✅ 配置优化:mysql-rollback.cnf - MySQL回滚优化") + print("✅ 文档指南:database_rollback_guide.md - 完整操作指南") + + print("\n⚠️ 安全提醒") + print("-" * 40) + print("• 回滚操作不可逆,务必谨慎执行") + print("• 生产环境回滚前必须在测试环境验证") + print("• 重要操作需要多人确认") + print("• 保留回滚操作日志和备份文件") + + except Exception as e: + print(f"❌ 演示过程中出现错误: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(demo_rollback_tools()) diff --git a/backend/scripts/run_practice_scenes_setup.py b/backend/scripts/run_practice_scenes_setup.py new file mode 100644 index 0000000..f2e075f --- /dev/null +++ b/backend/scripts/run_practice_scenes_setup.py @@ -0,0 +1,93 @@ +""" +执行陪练场景表创建和初始数据插入脚本 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy import text +from app.core.database import async_engine + + +async def run_sql_file(): + """执行SQL文件""" + sql_file = project_root / "scripts" / "create_practice_scenes.sql" + + if not sql_file.exists(): + print(f"❌ SQL文件不存在: {sql_file}") + return False + + # 读取SQL文件 + with open(sql_file, 'r', encoding='utf-8') as f: + sql_content = f.read() + + # 分割SQL语句(按分号分隔) + statements = [s.strip() for s in sql_content.split(';') if s.strip() and not s.strip().startswith('--')] + + print(f"📝 准备执行 {len(statements)} 条SQL语句...") + + async with async_engine.begin() as conn: + for i, statement in enumerate(statements, 1): + if not statement: + continue + try: + # 跳过注释 + if statement.strip().startswith('--'): + continue + + print(f" [{i}/{len(statements)}] 执行中...") + result = await conn.execute(text(statement)) + + # 如果是SELECT语句,打印结果 + if statement.strip().upper().startswith('SELECT'): + rows = result.fetchall() + print(f" ✅ 查询返回 {len(rows)} 行数据") + for row in rows: + print(f" {row}") + else: + print(f" ✅ 执行成功") + + except Exception as e: + print(f" ❌ 执行失败: {e}") + if "already exists" not in str(e).lower() and "duplicate" not in str(e).lower(): + raise + + print("\n✅ 所有SQL语句执行完成!") + return True + + +async def main(): + """主函数""" + try: + print("=" * 60) + print("陪练场景表创建和初始数据插入") + print("=" * 60) + + success = await run_sql_file() + + if success: + print("\n" + "=" * 60) + print("✅ 陪练场景表创建成功!") + print("=" * 60) + else: + print("\n" + "=" * 60) + print("❌ 执行失败") + print("=" * 60) + sys.exit(1) + + except Exception as e: + print(f"\n❌ 发生错误: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) + + + diff --git a/backend/scripts/seed_beauty_data.py b/backend/scripts/seed_beauty_data.py new file mode 100644 index 0000000..96602f2 --- /dev/null +++ b/backend/scripts/seed_beauty_data.py @@ -0,0 +1,430 @@ +#!/usr/bin/env python +""" +轻医美连锁岗位与课程种子数据 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os +import json +from datetime import datetime + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent.parent +sys.path.append(str(project_root)) + +from app.core.config import settings + + +async def execute_seed(): + """执行种子数据插入""" + try: + # 从环境变量或配置中获取数据库连接信息 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + print("\n🎯 开始插入轻医美连锁业务数据...") + + # 1. 插入轻医美相关岗位 + print("\n📌 插入岗位数据...") + positions_data = [ + { + 'name': '区域经理', + 'code': 'region_manager', + 'description': '负责多家门店的运营管理和业绩达成', + 'status': 'active', + 'skills': json.dumps(['团队管理', '业绩分析', '战略规划', '客户关系'], ensure_ascii=False), + 'level': 'expert', + 'sort_order': 10 + }, + { + 'name': '店长', + 'code': 'store_manager', + 'description': '负责门店日常运营管理,团队建设和业绩达成', + 'status': 'active', + 'skills': json.dumps(['门店管理', '团队建设', '销售管理', '客户维护'], ensure_ascii=False), + 'level': 'senior', + 'sort_order': 20, + 'parent_code': 'region_manager' + }, + { + 'name': '美容顾问', + 'code': 'beauty_consultant', + 'description': '为客户提供专业的美容咨询和方案设计', + 'status': 'active', + 'skills': json.dumps(['产品知识', '销售技巧', '方案设计', '客户沟通'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 30, + 'parent_code': 'store_manager' + }, + { + 'name': '美容技师', + 'code': 'beauty_therapist', + 'description': '为客户提供专业的美容护理服务', + 'status': 'active', + 'skills': json.dumps(['护肤技术', '仪器操作', '手法技巧', '服务意识'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 40, + 'parent_code': 'store_manager' + }, + { + 'name': '医美咨询师', + 'code': 'medical_beauty_consultant', + 'description': '提供医疗美容项目咨询和方案制定', + 'status': 'active', + 'skills': json.dumps(['医美知识', '风险评估', '方案设计', '合规意识'], ensure_ascii=False), + 'level': 'senior', + 'sort_order': 35, + 'parent_code': 'store_manager' + }, + { + 'name': '护士', + 'code': 'nurse', + 'description': '协助医生进行医美项目操作,负责术后护理', + 'status': 'active', + 'skills': json.dumps(['护理技术', '无菌操作', '应急处理', '医疗知识'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 45, + 'parent_code': 'store_manager' + }, + { + 'name': '前台接待', + 'code': 'receptionist', + 'description': '负责客户接待、预约管理和前台事务', + 'status': 'active', + 'skills': json.dumps(['接待礼仪', '沟通能力', '信息管理', '服务意识'], ensure_ascii=False), + 'level': 'junior', + 'sort_order': 50, + 'parent_code': 'store_manager' + }, + { + 'name': '市场专员', + 'code': 'marketing_specialist', + 'description': '负责门店营销活动策划和执行', + 'status': 'active', + 'skills': json.dumps(['活动策划', '社媒运营', '数据分析', '创意设计'], ensure_ascii=False), + 'level': 'intermediate', + 'sort_order': 60, + 'parent_code': 'store_manager' + } + ] + + # 先获取已存在的岗位ID映射 + position_id_map = {} + + # 插入岗位(处理层级关系) + for position in positions_data: + # 检查是否已存在 + check_sql = "SELECT id FROM positions WHERE code = %s AND is_deleted = FALSE" + await cursor.execute(check_sql, (position['code'],)) + existing = await cursor.fetchone() + + if existing: + position_id_map[position['code']] = existing[0] + print(f" ⚠️ 岗位 '{position['name']}' 已存在,ID: {existing[0]}") + else: + # 获取parent_id + parent_id = None + if 'parent_code' in position: + parent_code = position.pop('parent_code') + if parent_code in position_id_map: + parent_id = position_id_map[parent_code] + + # 插入岗位 + insert_sql = """ + INSERT INTO positions (name, code, description, parent_id, status, skills, level, sort_order, created_at, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW()) + """ + await cursor.execute(insert_sql, ( + position['name'], + position['code'], + position['description'], + parent_id, + position['status'], + position['skills'], + position['level'], + position['sort_order'] + )) + position_id = cursor.lastrowid + position_id_map[position['code']] = position_id + print(f" ✅ 插入岗位: {position['name']} (ID: {position_id})") + + await conn.commit() + + # 2. 插入轻医美相关课程 + print("\n📚 插入课程数据...") + courses_data = [ + { + 'name': '皮肤生理学基础', + 'description': '学习皮肤结构、功能和常见问题,为专业护理打下基础', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 16, + 'difficulty_level': 2, + 'tags': json.dumps(['皮肤学', '基础理论', '必修课'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 100 + }, + { + 'name': '医美产品知识与应用', + 'description': '全面了解各类医美产品的成分、功效和适用人群', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 20, + 'difficulty_level': 3, + 'tags': json.dumps(['产品知识', '医美', '专业技能'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 110 + }, + { + 'name': '美容仪器操作与维护', + 'description': '掌握各类美容仪器的操作方法、注意事项和日常维护', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 24, + 'difficulty_level': 3, + 'tags': json.dumps(['仪器操作', '实操技能', '设备维护'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 120 + }, + { + 'name': '轻医美销售技巧', + 'description': '学习专业的销售话术、客户需求分析和成交技巧', + 'category': 'business', + 'status': 'published', + 'duration_hours': 16, + 'difficulty_level': 2, + 'tags': json.dumps(['销售技巧', '客户沟通', '业绩提升'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 130 + }, + { + 'name': '客户服务与投诉处理', + 'description': '提升服务意识,掌握客户投诉处理的方法和技巧', + 'category': 'business', + 'status': 'published', + 'duration_hours': 12, + 'difficulty_level': 2, + 'tags': json.dumps(['客户服务', '危机处理', '沟通技巧'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 140 + }, + { + 'name': '卫生消毒与感染控制', + 'description': '学习医美机构的卫生标准和消毒流程,确保服务安全', + 'category': 'general', + 'status': 'published', + 'duration_hours': 8, + 'difficulty_level': 1, + 'tags': json.dumps(['卫生安全', '消毒规范', '合规管理'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 150 + }, + { + 'name': '门店运营管理', + 'description': '学习门店日常管理、团队建设和业绩管理', + 'category': 'management', + 'status': 'published', + 'duration_hours': 20, + 'difficulty_level': 3, + 'tags': json.dumps(['门店管理', '团队管理', '运营策略'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 160 + }, + { + 'name': '医美项目介绍与咨询', + 'description': '详细了解各类医美项目的原理、效果和适应症', + 'category': 'technology', + 'status': 'published', + 'duration_hours': 30, + 'difficulty_level': 4, + 'tags': json.dumps(['医美项目', '专业咨询', '风险告知'], ensure_ascii=False), + 'is_featured': True, + 'sort_order': 170 + }, + { + 'name': '社媒营销与私域运营', + 'description': '学习如何通过社交媒体进行品牌推广和客户维护', + 'category': 'business', + 'status': 'published', + 'duration_hours': 16, + 'difficulty_level': 2, + 'tags': json.dumps(['社媒营销', '私域流量', '客户维护'], ensure_ascii=False), + 'is_featured': False, + 'sort_order': 180 + } + ] + + course_id_map = {} + + for course in courses_data: + # 检查是否已存在 + check_sql = "SELECT id FROM courses WHERE name = %s AND is_deleted = FALSE" + await cursor.execute(check_sql, (course['name'],)) + existing = await cursor.fetchone() + + if existing: + course_id_map[course['name']] = existing[0] + print(f" ⚠️ 课程 '{course['name']}' 已存在,ID: {existing[0]}") + else: + # 插入课程 + insert_sql = """ + INSERT INTO courses ( + name, description, category, status, duration_hours, + difficulty_level, tags, is_featured, sort_order, + published_at, created_at, updated_at + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, NOW(), NOW(), NOW()) + """ + await cursor.execute(insert_sql, ( + course['name'], + course['description'], + course['category'], + course['status'], + course['duration_hours'], + course['difficulty_level'], + course['tags'], + course['is_featured'], + course['sort_order'] + )) + course_id = cursor.lastrowid + course_id_map[course['name']] = course_id + print(f" ✅ 插入课程: {course['name']} (ID: {course_id})") + + await conn.commit() + + # 3. 设置岗位与课程的关联 + print("\n🔗 设置岗位课程关联...") + position_courses = [ + # 店长必修课程 + ('store_manager', '门店运营管理', 'required', 1), + ('store_manager', '轻医美销售技巧', 'required', 2), + ('store_manager', '客户服务与投诉处理', 'required', 3), + ('store_manager', '卫生消毒与感染控制', 'required', 4), + + # 美容顾问必修课程 + ('beauty_consultant', '皮肤生理学基础', 'required', 1), + ('beauty_consultant', '医美产品知识与应用', 'required', 2), + ('beauty_consultant', '轻医美销售技巧', 'required', 3), + ('beauty_consultant', '客户服务与投诉处理', 'required', 4), + ('beauty_consultant', '社媒营销与私域运营', 'optional', 5), + + # 美容技师必修课程 + ('beauty_therapist', '皮肤生理学基础', 'required', 1), + ('beauty_therapist', '美容仪器操作与维护', 'required', 2), + ('beauty_therapist', '卫生消毒与感染控制', 'required', 3), + ('beauty_therapist', '医美产品知识与应用', 'optional', 4), + + # 医美咨询师必修课程 + ('medical_beauty_consultant', '医美项目介绍与咨询', 'required', 1), + ('medical_beauty_consultant', '皮肤生理学基础', 'required', 2), + ('medical_beauty_consultant', '医美产品知识与应用', 'required', 3), + ('medical_beauty_consultant', '轻医美销售技巧', 'required', 4), + ('medical_beauty_consultant', '客户服务与投诉处理', 'required', 5), + + # 护士必修课程 + ('nurse', '卫生消毒与感染控制', 'required', 1), + ('nurse', '医美项目介绍与咨询', 'required', 2), + ('nurse', '皮肤生理学基础', 'required', 3), + + # 前台接待必修课程 + ('receptionist', '客户服务与投诉处理', 'required', 1), + ('receptionist', '医美产品知识与应用', 'optional', 2), + + # 市场专员必修课程 + ('marketing_specialist', '社媒营销与私域运营', 'required', 1), + ('marketing_specialist', '医美产品知识与应用', 'optional', 2), + ('marketing_specialist', '轻医美销售技巧', 'optional', 3), + ] + + for pos_code, course_name, course_type, priority in position_courses: + if pos_code in position_id_map and course_name in course_id_map: + position_id = position_id_map[pos_code] + course_id = course_id_map[course_name] + + # 检查是否已存在 + check_sql = """ + SELECT id FROM position_courses + WHERE position_id = %s AND course_id = %s AND is_deleted = FALSE + """ + await cursor.execute(check_sql, (position_id, course_id)) + existing = await cursor.fetchone() + + if not existing: + insert_sql = """ + INSERT INTO position_courses (position_id, course_id, course_type, priority, is_deleted, created_at, updated_at) + VALUES (%s, %s, %s, %s, FALSE, NOW(), NOW()) + """ + await cursor.execute(insert_sql, (position_id, course_id, course_type, priority)) + print(f" ✅ 关联: {pos_code} - {course_name} ({course_type})") + + await conn.commit() + + # 4. 显示统计信息 + print("\n📊 数据统计:") + + # 统计岗位 + await cursor.execute("SELECT COUNT(*) FROM positions WHERE is_deleted = FALSE") + total_positions = (await cursor.fetchone())[0] + print(f" 岗位总数: {total_positions}") + + # 统计课程 + await cursor.execute("SELECT COUNT(*) FROM courses WHERE is_deleted = FALSE") + total_courses = (await cursor.fetchone())[0] + print(f" 课程总数: {total_courses}") + + # 统计岗位课程关联 + await cursor.execute("SELECT COUNT(*) FROM position_courses WHERE is_deleted = FALSE") + total_pc = (await cursor.fetchone())[0] + print(f" 岗位课程关联数: {total_pc}") + + print("\n🎉 轻医美连锁业务数据插入完成!") + print("\n💡 提示:") + print(" 1. 可以登录系统查看岗位管理页面") + print(" 2. 每个岗位都配置了相应的必修和选修课程") + print(" 3. 课程涵盖了技术、管理、业务和通用等各个分类") + + conn.close() + + except Exception as e: + print(f"❌ 执行失败: {e}") + import traceback + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + print("🌟 开始插入轻医美连锁岗位与课程数据...") + asyncio.run(execute_seed()) diff --git a/backend/scripts/seed_positions.py b/backend/scripts/seed_positions.py new file mode 100644 index 0000000..05c609d --- /dev/null +++ b/backend/scripts/seed_positions.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +为轻医美连锁品牌注入基础“岗位”数据(真落库)。 + +- 场景:前端岗位下拉显示 No data 时,先注入标准岗位,便于联调验证 +- 数据来源:行业通用岗位,后续可按门店组织结构扩展 +""" + +import asyncio +from typing import List + +from sqlalchemy import select + +try: + # 覆盖本地数据库连接(如存在) + import local_config # noqa: F401 +except Exception: + pass + +from app.core.database import AsyncSessionLocal +from app.models.position import Position + + +async def ensure_positions_exists(session, names: List[str]) -> int: + """确保给定岗位名称存在于数据库,已存在则跳过。 + + 返回新增的记录数。 + """ + existing = (await session.execute(select(Position))).scalars().all() + existing_names = {p.name for p in existing} + + added = 0 + for name in names: + if name in existing_names: + continue + obj = Position( + name=name, + code=name, # 简化:与名称一致,前端无需依赖 code + description=f"{name} 岗位(系统初始化)", + status="active", + level="junior", + ) + session.add(obj) + added += 1 + + if added: + await session.commit() + return added + + +async def main() -> None: + """脚本入口:写入基础岗位数据并打印结果。""" + base_positions = [ + "咨询师", + "治疗师", + "皮肤管理师", + "前台接待", + "门店店长", + "区域运营经理", + "市场专员", + "客服专员", + ] + + async with AsyncSessionLocal() as session: + added = await ensure_positions_exists(session, base_positions) + # 打印结果 + print(f"✅ 岗位数据初始化完成,新增 {added} 条。") + + rows = (await session.execute(select(Position))).scalars().all() + print("当前岗位:", ", ".join(p.name for p in rows)) + + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/scripts/seed_practice_sessions.sql b/backend/scripts/seed_practice_sessions.sql new file mode 100644 index 0000000..3ceff5d --- /dev/null +++ b/backend/scripts/seed_practice_sessions.sql @@ -0,0 +1,164 @@ +-- ============================================ +-- 为所有学员用户注入陪练会话数据 +-- ============================================ + +USE `kaopeilian`; + +-- 先创建陪练场景(如果不存在) +INSERT IGNORE INTO practice_scenes (id, name, description, type, difficulty, status, background, ai_role, objectives, keywords, duration, usage_count, rating, created_by, is_deleted) +VALUES +(1, '电话销售陪练', '模拟电话销售场景,提升电话沟通技巧', 'phone', 'intermediate', 'active', '客户对轻医美项目感兴趣,需要通过电话进行专业介绍', 'AI扮演潜在客户', '["掌握电话开场技巧", "专业介绍项目", "处理客户疑问"]', '["电话销售", "沟通技巧", "项目介绍"]', 15, 0, 4.5, 1, 0), +(2, '面对面咨询陪练', '模拟面对面咨询场景,提升面诊沟通能力', 'face', 'intermediate', 'active', '客户到店咨询轻医美项目,需要专业面诊和方案推荐', 'AI扮演到店客户', '["建立客户信任", "专业面诊", "方案推荐"]', '["面对面", "咨询", "方案设计"]', 20, 0, 4.7, 1, 0), +(3, '客户投诉处理陪练', '模拟客户投诉场景,提升问题处理能力', 'complaint', 'senior', 'active', '客户对服务或效果不满意,需要妥善处理投诉', 'AI扮演投诉客户', '["倾听客户诉求", "安抚客户情绪", "提供解决方案"]', '["投诉处理", "情绪管理", "客户关系"]', 15, 0, 4.3, 1, 0), +(4, '售后服务陪练', '模拟售后服务场景,提升客户满意度', 'after-sales', 'junior', 'active', '项目完成后,进行售后跟进和关怀', 'AI扮演已消费客户', '["售后关怀", "效果跟进", "二次营销"]', '["售后服务", "客户维护", "复购"]', 10, 0, 4.6, 1, 0), +(5, '产品介绍陪练', '模拟产品介绍场景,提升产品讲解能力', 'product-intro', 'beginner', 'active', '向客户详细介绍轻医美项目和产品', 'AI扮演咨询客户', '["产品特点讲解", "适用人群分析", "价值传递"]', '["产品介绍", "专业知识", "销售技巧"]', 15, 0, 4.8, 1, 0); + +-- 为每个学员用户创建陪练记录 +-- 使用存储过程批量生成 + +DELIMITER // + +DROP PROCEDURE IF EXISTS generate_practice_sessions// + +CREATE PROCEDURE generate_practice_sessions(IN target_user_id INT, IN session_count INT) +BEGIN + DECLARE i INT DEFAULT 0; + DECLARE session_date DATETIME; + DECLARE duration INT; + DECLARE scene INT; + DECLARE scene_name_val VARCHAR(200); + DECLARE scene_type_val VARCHAR(50); + DECLARE score INT; + DECLARE turns INT; + + WHILE i < session_count DO + -- 在过去60天内随机生成日期 + SET session_date = DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) + INTERVAL FLOOR(RAND() * 43200) SECOND; + + -- 随机时长:10-30分钟 + SET duration = 600 + FLOOR(RAND() * 1200); + + -- 随机选择场景 + SET scene = 1 + FLOOR(RAND() * 5); + + -- 根据场景设置名称和类型 + CASE scene + WHEN 1 THEN + SET scene_name_val = '电话销售陪练'; + SET scene_type_val = 'phone'; + WHEN 2 THEN + SET scene_name_val = '面对面咨询陪练'; + SET scene_type_val = 'face'; + WHEN 3 THEN + SET scene_name_val = '客户投诉处理陪练'; + SET scene_type_val = 'complaint'; + WHEN 4 THEN + SET scene_name_val = '售后服务陪练'; + SET scene_type_val = 'after-sales'; + WHEN 5 THEN + SET scene_name_val = '产品介绍陪练'; + SET scene_type_val = 'product-intro'; + END CASE; + + -- 随机对话轮数 + SET turns = 10 + FLOOR(RAND() * 20); + + -- 随机分数(60-95分) + SET score = 60 + FLOOR(RAND() * 35); + + -- 插入陪练会话 + INSERT INTO practice_sessions ( + session_id, + user_id, + scene_id, + scene_name, + scene_type, + start_time, + end_time, + duration_seconds, + turns, + status, + is_deleted, + created_at, + updated_at + ) VALUES ( + CONCAT('session_', target_user_id, '_', UNIX_TIMESTAMP(session_date)), + target_user_id, + scene, + scene_name_val, + scene_type_val, + session_date, + DATE_ADD(session_date, INTERVAL duration SECOND), + duration, + turns, + 'completed', + 0, + session_date, + session_date + ); + + -- 插入陪练报告 + INSERT INTO practice_reports ( + session_id, + total_score, + score_breakdown, + ability_dimensions, + created_at, + updated_at + ) VALUES ( + CONCAT('session_', target_user_id, '_', UNIX_TIMESTAMP(session_date)), + score, + JSON_OBJECT( + 'professionalism', score + FLOOR(RAND() * 10) - 5, + 'communication', score + FLOOR(RAND() * 10) - 5, + 'problem_solving', score + FLOOR(RAND() * 10) - 5 + ), + JSON_OBJECT( + 'technical_skills', score + FLOOR(RAND() * 10) - 5, + 'service_attitude', score + FLOOR(RAND() * 10) - 5, + 'sales_ability', score + FLOOR(RAND() * 10) - 5 + ), + session_date, + session_date + ); + + SET i = i + 1; + END WHILE; +END// + +DELIMITER ; + +-- 为所有学员角色用户生成陪练数据 +-- 获取所有学员用户ID并为每个用户生成15-25条记录 + +-- user_id = 1 (superadmin) - 20条 +CALL generate_practice_sessions(1, 20); + +-- user_id = 5 (consultant_001) - 25条 +CALL generate_practice_sessions(5, 25); + +-- user_id = 7 (therapist_001) - 22条 +CALL generate_practice_sessions(7, 22); + +-- user_id = 8 (receptionist_001) - 18条 +CALL generate_practice_sessions(8, 18); + +-- 统计结果 +SELECT '========================================' AS ''; +SELECT '✅ 陪练数据生成完成!' AS ''; +SELECT '========================================' AS ''; + +SELECT + u.id, + u.username, + u.role, + COUNT(ps.id) as practice_count +FROM users u +LEFT JOIN practice_sessions ps ON u.id = ps.user_id AND ps.is_deleted = 0 +WHERE u.is_deleted = 0 +GROUP BY u.id, u.username, u.role +ORDER BY u.id; + +-- 清理存储过程 +DROP PROCEDURE IF EXISTS generate_practice_sessions; + diff --git a/backend/scripts/seed_statistics_demo_data.py b/backend/scripts/seed_statistics_demo_data.py new file mode 100755 index 0000000..c82d3de --- /dev/null +++ b/backend/scripts/seed_statistics_demo_data.py @@ -0,0 +1,408 @@ +#!/usr/bin/env python +""" +为统计分析页面生成轻医美场景的模拟数据 + +生成的数据包括: +1. 考试记录(exams)- 不同时间段、不同课程、不同分数 +2. 错题记录(exam_mistakes)- 不同难度、不同知识点 +3. 陪练会话(practice_sessions)- 不同时间的陪练记录 +4. 知识点数据(knowledge_points)- 轻医美相关知识点 + +目标:确保统计分析页面的每个模块都能显示数据 +""" +import sys +import asyncio +from datetime import datetime, timedelta +import random +from pathlib import Path + +# 添加项目路径 +project_root = Path(__file__).parent.parent +sys.path.insert(0, str(project_root)) + +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker +from sqlalchemy import select, func + +from app.models.exam import Exam, Question +from app.models.exam_mistake import ExamMistake +from app.models.course import Course, KnowledgePoint +from app.models.practice import PracticeSession +from app.models.user import User +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# 数据库连接(开发测试环境) +DATABASE_URL = "mysql+aiomysql://root:nj861021@localhost:3306/kaopeilian" + + +# 轻医美知识点数据 +BEAUTY_KNOWLEDGE_POINTS = { + "皮肤生理学基础": [ + "皮肤结构与层次", + "皮肤类型与特征", + "皮肤屏障功能", + "皮肤老化机制", + "皮肤色素形成", + "皮肤水分平衡" + ], + "医美产品知识与应用": [ + "透明质酸的应用", + "肉毒素的作用机理", + "光子嫩肤原理", + "果酸焕肤技术", + "维生素C美白", + "胶原蛋白补充" + ], + "美容仪器操作与维护": [ + "超声刀操作流程", + "热玛吉治疗参数", + "皮秒激光使用", + "射频美容仪器", + "冷光美肤仪", + "水光注射仪" + ], + "轻医美销售技巧": [ + "客户需求分析", + "项目推荐话术", + "价格异议处理", + "成交技巧", + "客户关系维护", + "套餐设计方法" + ], + "客户服务与投诉处理": [ + "服务标准流程", + "投诉应对技巧", + "客户期望管理", + "售后跟踪服务", + "客户满意度提升", + "危机公关处理" + ] +} + +# 难度级别 +DIFFICULTY_LEVELS = ["easy", "medium", "hard"] + + +async def clear_old_demo_data(db: AsyncSession, user_id: int): + """清理旧的演示数据""" + logger.info(f"清理用户 {user_id} 的旧演示数据...") + + # 清理考试记录(会级联删除错题记录) + from sqlalchemy import delete + await db.execute(delete(Exam).where(Exam.user_id == user_id)) + + # 清理陪练会话 + await db.execute(delete(PracticeSession).where(PracticeSession.user_id == user_id)) + + await db.commit() + logger.info("旧数据清理完成") + + +async def get_or_create_knowledge_points(db: AsyncSession, course_id: int, course_name: str) -> list: + """获取或创建知识点""" + # 检查是否已有知识点 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.course_id == course_id, + KnowledgePoint.is_deleted == False + ) + ) + existing_kps = result.scalars().all() + + if existing_kps: + logger.info(f"课程 {course_name} 已有 {len(existing_kps)} 个知识点") + return existing_kps + + # 创建新知识点 + knowledge_points = [] + if course_name in BEAUTY_KNOWLEDGE_POINTS: + for kp_name in BEAUTY_KNOWLEDGE_POINTS[course_name]: + kp = KnowledgePoint( + course_id=course_id, + name=kp_name, + description=f"{course_name}中的重要知识点:{kp_name}", + type="核心概念", + source=0, # 手动创建 + is_deleted=False + ) + db.add(kp) + knowledge_points.append(kp) + + await db.commit() + + # 刷新以获取ID + for kp in knowledge_points: + await db.refresh(kp) + + logger.info(f"为课程 {course_name} 创建了 {len(knowledge_points)} 个知识点") + return knowledge_points + + +async def create_exam_records(db: AsyncSession, user_id: int, courses: list): + """创建考试记录""" + logger.info("创建考试记录...") + + end_date = datetime.now() + exams_created = 0 + + # 在过去60天内创建考试记录 + for days_ago in range(60, 0, -1): + exam_date = end_date - timedelta(days=days_ago) + + # 跳过一些日期(不是每天都考试) + if random.random() > 0.3: # 30%的日子有考试 + continue + + # 每天可能考1-2次 + num_exams = random.choice([1, 1, 1, 2]) + + for _ in range(num_exams): + # 随机选择课程 + course = random.choice(courses) + + # 生成考试分数(呈现进步趋势) + # 早期分数较低,后期分数较高 + progress_factor = (60 - days_ago) / 60 # 0 到 1 + base_score = 60 + (progress_factor * 20) # 60-80分基础 + score_variance = random.uniform(-10, 15) + round1_score = max(50, min(100, base_score + score_variance)) + + # 创建考试记录 + exam = Exam( + user_id=user_id, + course_id=course.id, + exam_name=f"{course.name}测试", + question_count=10, + total_score=100.0, + pass_score=60.0, + start_time=exam_date, + end_time=exam_date + timedelta(minutes=random.randint(15, 45)), + duration_minutes=random.randint(15, 45), + round1_score=round(round1_score, 1), + round2_score=None, + round3_score=None, + score=round(round1_score, 1), + is_passed=round1_score >= 60, + status="submitted" + ) + + db.add(exam) + exams_created += 1 + + await db.commit() + logger.info(f"创建了 {exams_created} 条考试记录") + + return exams_created + + +async def create_exam_mistakes(db: AsyncSession, user_id: int, courses: list): + """创建错题记录""" + logger.info("创建错题记录...") + + # 获取用户的所有考试 + result = await db.execute( + select(Exam).where(Exam.user_id == user_id).order_by(Exam.start_time) + ) + exams = result.scalars().all() + + mistakes_created = 0 + + for exam in exams: + # 找到对应的课程 + course = next((c for c in courses if c.id == exam.course_id), None) + if not course: + continue + + # 获取该课程的知识点 + result = await db.execute( + select(KnowledgePoint).where( + KnowledgePoint.course_id == course.id, + KnowledgePoint.is_deleted == False + ) + ) + knowledge_points = result.scalars().all() + + if not knowledge_points: + continue + + # 根据分数决定错题数(分数越低,错题越多) + score = exam.round1_score or 70 + mistake_rate = (100 - score) / 100 # 0.0 到 0.5 + num_mistakes = int(exam.question_count * mistake_rate) + num_mistakes = max(1, min(num_mistakes, exam.question_count - 1)) + + # 创建错题 + for i in range(num_mistakes): + # 随机选择知识点 + kp = random.choice(knowledge_points) + + # 随机选择题型 + question_types = ["single_choice", "multiple_choice", "true_false", "fill_blank", "essay"] + question_type = random.choice(question_types) + + mistake = ExamMistake( + user_id=user_id, + exam_id=exam.id, + question_id=None, # AI生成的题目 + knowledge_point_id=kp.id, + question_content=f"关于{kp.name}的问题{i+1}", + correct_answer="正确答案", + user_answer="用户错误答案", + question_type=question_type + ) + + db.add(mistake) + mistakes_created += 1 + + await db.commit() + logger.info(f"创建了 {mistakes_created} 条错题记录") + + return mistakes_created + + +async def create_practice_sessions(db: AsyncSession, user_id: int): + """创建陪练会话记录""" + logger.info("创建陪练会话记录...") + + end_date = datetime.now() + sessions_created = 0 + + # 在过去60天内创建陪练记录 + for days_ago in range(60, 0, -1): + session_date = end_date - timedelta(days=days_ago) + + # 跳过一些日期 + if random.random() > 0.25: # 25%的日子有陪练 + continue + + # 每次陪练的时长(秒) + duration_seconds = random.randint(600, 1800) # 10-30分钟 + + # 场景类型 + scene_types = ["电话销售", "面对面咨询", "客户投诉处理", "售后服务", "产品介绍"] + scene_name = random.choice(scene_types) + + session = PracticeSession( + session_id=f"session_{user_id}_{int(session_date.timestamp())}", + user_id=user_id, + scene_id=random.randint(1, 5), + scene_name=scene_name, + scene_type=scene_name, + start_time=session_date, + end_time=session_date + timedelta(seconds=duration_seconds), + duration_seconds=duration_seconds, + turns=random.randint(10, 30), + status="completed", + is_deleted=False + ) + + db.add(session) + sessions_created += 1 + + await db.commit() + logger.info(f"创建了 {sessions_created} 条陪练会话记录") + + return sessions_created + + +async def main(): + """主函数""" + logger.info("=" * 60) + logger.info("开始为统计分析页面生成轻医美场景的模拟数据") + logger.info("=" * 60) + + # 创建数据库连接 + engine = create_async_engine(DATABASE_URL, echo=False) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with async_session() as db: + # 1. 获取测试用户(admin或第一个用户) + result = await db.execute( + select(User).where(User.username == "admin") + ) + user = result.scalar_one_or_none() + + if not user: + # 如果没有admin,使用第一个用户 + result = await db.execute(select(User).limit(1)) + user = result.scalar_one_or_none() + + if not user: + logger.error("❌ 未找到用户,请先创建用户") + return + + logger.info(f"📝 使用用户: {user.username} (ID: {user.id})") + + # 2. 获取轻医美相关课程 + result = await db.execute( + select(Course).where( + Course.is_deleted == False, + Course.status == "published" + ) + ) + courses = result.scalars().all() + + if not courses: + logger.error("❌ 未找到已发布的课程") + return + + logger.info(f"📚 找到 {len(courses)} 门课程") + + # 3. 清理旧数据(可选) + clear_old = input("\n是否清理该用户的旧数据?(y/n): ").lower() + if clear_old == 'y': + await clear_old_demo_data(db, user.id) + + # 4. 为每门课程创建知识点 + logger.info("\n" + "=" * 60) + logger.info("步骤 1/3: 创建知识点") + logger.info("=" * 60) + for course in courses: + await get_or_create_knowledge_points(db, course.id, course.name) + + # 5. 创建考试记录 + logger.info("\n" + "=" * 60) + logger.info("步骤 2/3: 创建考试记录") + logger.info("=" * 60) + exams_count = await create_exam_records(db, user.id, courses) + + # 6. 创建错题记录 + logger.info("\n" + "=" * 60) + logger.info("步骤 3/3: 创建错题记录") + logger.info("=" * 60) + mistakes_count = await create_exam_mistakes(db, user.id, courses) + + # 7. 创建陪练会话记录 + logger.info("\n" + "=" * 60) + logger.info("步骤 4/4: 创建陪练会话记录") + logger.info("=" * 60) + sessions_count = await create_practice_sessions(db, user.id) + + # 8. 统计信息 + logger.info("\n" + "=" * 60) + logger.info("✅ 数据生成完成!") + logger.info("=" * 60) + logger.info(f"用户: {user.username} (ID: {user.id})") + logger.info(f"考试记录: {exams_count} 条") + logger.info(f"错题记录: {mistakes_count} 条") + logger.info(f"陪练记录: {sessions_count} 条") + logger.info("=" * 60) + logger.info("\n现在可以访问统计分析页面查看数据:") + logger.info("http://localhost:5173/analysis/statistics") + logger.info("=" * 60) + + except Exception as e: + logger.error(f"❌ 生成数据失败: {e}") + import traceback + traceback.print_exc() + + finally: + await engine.dispose() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/scripts/seed_statistics_demo_data.sql b/backend/scripts/seed_statistics_demo_data.sql new file mode 100644 index 0000000..602ce22 --- /dev/null +++ b/backend/scripts/seed_statistics_demo_data.sql @@ -0,0 +1,220 @@ +-- 为统计分析页面生成轻医美场景的模拟数据 +-- 使用方法: mysql -h localhost -u root -p'nj861021' kaopeilian < seed_statistics_demo_data.sql + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ============================================ +-- 1. 获取用户ID(使用admin用户) +-- ============================================ +SET @user_id = (SELECT id FROM users WHERE username = 'admin' LIMIT 1); +SET @user_id = IFNULL(@user_id, 1); + +SELECT CONCAT('使用用户ID: ', @user_id) AS info; + +-- ============================================ +-- 2. 为课程添加知识点(如果不存在) +-- ============================================ +-- 皮肤生理学基础 +SET @course_id_1 = (SELECT id FROM courses WHERE name LIKE '%皮肤生理学%' LIMIT 1); +INSERT INTO knowledge_points (course_id, material_id, name, description, type, source, is_deleted, created_at, updated_at) +SELECT + @course_id_1, + NULL, + kp.name, + CONCAT('核心知识点:', kp.name), + '核心概念', + 0, + FALSE, + NOW(), + NOW() +FROM ( + SELECT '皮肤结构与层次' AS name UNION ALL + SELECT '皮肤类型与特征' UNION ALL + SELECT '皮肤屏障功能' UNION ALL + SELECT '皮肤老化机制' UNION ALL + SELECT '皮肤色素形成' UNION ALL + SELECT '皮肤水分平衡' +) AS kp +WHERE @course_id_1 IS NOT NULL +AND NOT EXISTS ( + SELECT 1 FROM knowledge_points + WHERE course_id = @course_id_1 AND name = kp.name AND is_deleted = FALSE +); + +-- 医美产品知识与应用 +SET @course_id_2 = (SELECT id FROM courses WHERE name LIKE '%医美产品%' LIMIT 1); +INSERT INTO knowledge_points (course_id, material_id, name, description, type, source, is_deleted, created_at, updated_at) +SELECT + @course_id_2, + NULL, + kp.name, + CONCAT('核心知识点:', kp.name), + '核心概念', + 0, + FALSE, + NOW(), + NOW() +FROM ( + SELECT '透明质酸的应用' AS name UNION ALL + SELECT '肉毒素的作用机理' UNION ALL + SELECT '光子嫩肤原理' UNION ALL + SELECT '果酸焕肤技术' UNION ALL + SELECT '维生素C美白' UNION ALL + SELECT '胶原蛋白补充' +) AS kp +WHERE @course_id_2 IS NOT NULL +AND NOT EXISTS ( + SELECT 1 FROM knowledge_points + WHERE course_id = @course_id_2 AND name = kp.name AND is_deleted = FALSE +); + +SELECT '✓ 知识点创建完成' AS info; + +-- ============================================ +-- 3. 生成考试记录(过去60天,呈现进步趋势) +-- ============================================ +-- 删除旧的演示数据 +DELETE FROM exam_mistakes WHERE user_id = @user_id; +DELETE FROM exams WHERE user_id = @user_id; + +-- 生成考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, round1_score, score, is_passed, status, + created_at, updated_at +) +SELECT + @user_id, + c.id, + CONCAT(c.name, '测试'), + 10, + 100.0, + 60.0, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) - INTERVAL FLOOR(RAND() * 86400) SECOND, + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) - INTERVAL FLOOR(RAND() * 86400) SECOND + INTERVAL (15 + FLOOR(RAND() * 30)) MINUTE, + 15 + FLOOR(RAND() * 30), + -- 分数呈现进步趋势:早期60-75分,后期75-95分 + 60 + (RAND() * 15) + (30 * RAND()), + 60 + (RAND() * 15) + (30 * RAND()), + TRUE, + 'submitted', + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY), + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) +FROM courses c +CROSS JOIN ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 + UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 + UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 UNION ALL SELECT 19 UNION ALL SELECT 20 +) AS numbers +WHERE c.is_deleted = FALSE AND c.status = 'published' +LIMIT 50; + +-- 更新is_passed状态 +UPDATE exams SET is_passed = (round1_score >= pass_score) WHERE user_id = @user_id; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条考试记录') AS info +FROM exams WHERE user_id = @user_id; + +-- ============================================ +-- 4. 生成错题记录 +-- ============================================ +-- 为每个考试生成错题(根据分数决定错题数) +INSERT INTO exam_mistakes ( + user_id, exam_id, question_id, knowledge_point_id, + question_content, correct_answer, user_answer, question_type, + created_at, updated_at +) +SELECT + e.user_id, + e.id, + NULL, + kp.id, + CONCAT('关于', kp.name, '的问题'), + '正确答案', + '用户错误答案', + ELT(1 + FLOOR(RAND() * 5), 'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'essay'), + e.created_at, + e.created_at +FROM exams e +CROSS JOIN knowledge_points kp +WHERE e.user_id = @user_id + AND kp.course_id = e.course_id + AND kp.is_deleted = FALSE + AND RAND() < (100 - e.round1_score) / 100 -- 分数越低,错题概率越高 +LIMIT 200; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条错题记录') AS info +FROM exam_mistakes WHERE user_id = @user_id; + +-- ============================================ +-- 5. 生成陪练会话记录 +-- ============================================ +-- 删除旧的陪练记录 +DELETE FROM practice_dialogues WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_reports WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_sessions WHERE user_id = @user_id; + +-- 生成陪练会话 +INSERT INTO practice_sessions ( + session_id, user_id, scene_id, scene_name, scene_type, + start_time, end_time, duration_seconds, turns, status, is_deleted, + created_at, updated_at +) +SELECT + CONCAT('session_', @user_id, '_', UNIX_TIMESTAMP(start_dt)), + @user_id, + 1 + FLOOR(RAND() * 5), + ELT(1 + FLOOR(RAND() * 5), '电话销售', '面对面咨询', '客户投诉处理', '售后服务', '产品介绍'), + ELT(1 + FLOOR(RAND() * 5), 'phone', 'face', 'complaint', 'after-sales', 'product-intro'), + start_dt, + DATE_ADD(start_dt, INTERVAL (600 + FLOOR(RAND() * 1200)) SECOND), + 600 + FLOOR(RAND() * 1200), + 10 + FLOOR(RAND() * 20), + 'completed', + FALSE, + start_dt, + start_dt +FROM ( + SELECT DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) - INTERVAL FLOOR(RAND() * 86400) SECOND AS start_dt + FROM ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 + UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 + ) AS numbers +) AS dates; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条陪练会话记录') AS info +FROM practice_sessions WHERE user_id = @user_id; + +-- ============================================ +-- 6. 统计汇总 +-- ============================================ +SELECT '========================================' AS ''; +SELECT '✅ 数据生成完成!' AS ''; +SELECT '========================================' AS ''; + +SELECT CONCAT('用户: ', username, ' (ID: ', id, ')') AS info +FROM users WHERE id = @user_id; + +SELECT CONCAT('考试记录: ', COUNT(*), ' 条') AS info +FROM exams WHERE user_id = @user_id; + +SELECT CONCAT('错题记录: ', COUNT(*), ' 条') AS info +FROM exam_mistakes WHERE user_id = @user_id; + +SELECT CONCAT('陪练记录: ', COUNT(*), ' 条') AS info +FROM practice_sessions WHERE user_id = @user_id; + +SELECT '========================================' AS ''; +SELECT '现在可以访问统计分析页面查看数据:' AS ''; +SELECT 'http://localhost:5173/analysis/statistics' AS ''; +SELECT '========================================' AS ''; + +SET FOREIGN_KEY_CHECKS = 1; + diff --git a/backend/scripts/seed_statistics_demo_data_v2.sql b/backend/scripts/seed_statistics_demo_data_v2.sql new file mode 100644 index 0000000..e37b9b2 --- /dev/null +++ b/backend/scripts/seed_statistics_demo_data_v2.sql @@ -0,0 +1,207 @@ +-- 为统计分析页面生成轻医美场景的模拟数据(简化版) +-- 使用方法: docker-compose -f docker-compose.dev.yml exec -T mysql-dev mysql -u root -p'nj861021' kaopeilian < kaopeilian-backend/scripts/seed_statistics_demo_data_v2.sql + +SET NAMES utf8mb4; +SET FOREIGN_KEY_CHECKS = 0; + +-- ============================================ +-- 1. 获取用户ID(使用admin用户) +-- ============================================ +SET @user_id = (SELECT id FROM users WHERE username = 'admin' LIMIT 1); +SET @user_id = IFNULL(@user_id, (SELECT id FROM users WHERE role = 'trainee' LIMIT 1)); +SET @user_id = IFNULL(@user_id, 1); + +SELECT CONCAT('✓ 使用用户: ', (SELECT username FROM users WHERE id = @user_id), ' (ID: ', @user_id, ')') AS ''; + +-- ============================================ +-- 2. 清理旧数据 +-- ============================================ +DELETE FROM exam_mistakes WHERE user_id = @user_id; +DELETE FROM exams WHERE user_id = @user_id; +DELETE FROM practice_dialogues WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_reports WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_sessions WHERE user_id = @user_id; + +SELECT '✓ 旧数据清理完成' AS ''; + +-- ============================================ +-- 3. 生成考试记录(过去60天,呈现进步趋势) +-- ============================================ +-- 使用临时表生成日期序列 +DROP TEMPORARY TABLE IF EXISTS date_series; +CREATE TEMPORARY TABLE date_series ( + day_offset INT, + exam_date DATETIME, + progress_factor DECIMAL(5,2) +); + +-- 生成60天的日期,30%的天数有考试 +INSERT INTO date_series (day_offset, exam_date, progress_factor) +SELECT + n.day_offset, + DATE_SUB(NOW(), INTERVAL n.day_offset DAY) + INTERVAL FLOOR(RAND() * 43200) SECOND AS exam_date, + (60 - n.day_offset) / 60 AS progress_factor +FROM ( + SELECT 1 AS day_offset UNION ALL SELECT 3 UNION ALL SELECT 5 UNION ALL SELECT 7 UNION ALL SELECT 9 + UNION ALL SELECT 11 UNION ALL SELECT 13 UNION ALL SELECT 15 UNION ALL SELECT 17 UNION ALL SELECT 19 + UNION ALL SELECT 21 UNION ALL SELECT 23 UNION ALL SELECT 25 UNION ALL SELECT 27 UNION ALL SELECT 29 + UNION ALL SELECT 31 UNION ALL SELECT 33 UNION ALL SELECT 35 UNION ALL SELECT 37 UNION ALL SELECT 39 + UNION ALL SELECT 41 UNION ALL SELECT 43 UNION ALL SELECT 45 UNION ALL SELECT 47 UNION ALL SELECT 49 + UNION ALL SELECT 51 UNION ALL SELECT 53 UNION ALL SELECT 55 UNION ALL SELECT 57 UNION ALL SELECT 59 +) AS n; + +-- 生成考试记录 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, total_score, pass_score, + start_time, end_time, duration_minutes, round1_score, score, is_passed, status, + created_at, updated_at +) +SELECT + @user_id, + c.id, + CONCAT(c.name, '测试'), + 10, + 100.0, + 60.0, + ds.exam_date, + DATE_ADD(ds.exam_date, INTERVAL (20 + FLOOR(RAND() * 25)) MINUTE), + 20 + FLOOR(RAND() * 25), + -- 分数呈现进步趋势:基础分60分,根据时间进度增加0-35分 + ROUND(60 + (ds.progress_factor * 20) + (RAND() * 15), 1) AS round1_score, + ROUND(60 + (ds.progress_factor * 20) + (RAND() * 15), 1) AS score, + TRUE, + 'submitted', + ds.exam_date, + ds.exam_date +FROM date_series ds +CROSS JOIN courses c +WHERE c.is_deleted = FALSE AND c.status = 'published' +ORDER BY RAND() +LIMIT 50; + +-- 更新is_passed状态 +UPDATE exams SET is_passed = (round1_score >= pass_score) WHERE user_id = @user_id; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条考试记录') AS '' +FROM exams WHERE user_id = @user_id; + +-- ============================================ +-- 4. 生成错题记录(增强版 - 确保足够的错题数据) +-- ============================================ +-- 为每个考试生成3-5个错题,不管分数高低 +-- 这样可以确保有足够的统计样本 + +-- 方法:为每个考试生成多个错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_id, knowledge_point_id, + question_content, correct_answer, user_answer, question_type, + created_at, updated_at +) +SELECT + e.user_id, + e.id, + NULL, + kp.id, + CONCAT('关于"', kp.name, '"的', + ELT(1 + FLOOR(RAND() * 3), '概念理解', '实际应用', '综合分析'), '问题'), + CONCAT('正确答案:', kp.name, '的标准解释'), + CONCAT('错误理解:', '学员对', kp.name, '的误解'), + ELT(1 + FLOOR(RAND() * 5), 'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'essay'), + e.start_time, + e.start_time +FROM exams e +CROSS JOIN ( + SELECT 1 AS mistake_num UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 +) AS mistake_counts +INNER JOIN knowledge_points kp ON kp.course_id = e.course_id +WHERE e.user_id = @user_id + AND kp.is_deleted = FALSE + -- 高分考试(90+)生成1-2个错题 + AND ( + (e.round1_score >= 90 AND mistake_counts.mistake_num <= 2 AND RAND() < 0.5) + -- 中等分数(80-90)生成2-3个错题 + OR (e.round1_score >= 80 AND e.round1_score < 90 AND mistake_counts.mistake_num <= 3 AND RAND() < 0.7) + -- 一般分数(70-80)生成3-4个错题 + OR (e.round1_score >= 70 AND e.round1_score < 80 AND mistake_counts.mistake_num <= 4 AND RAND() < 0.8) + -- 低分(<70)生成4-5个错题 + OR (e.round1_score < 70 AND mistake_counts.mistake_num <= 5 AND RAND() < 0.9) + ) +ORDER BY RAND() +LIMIT 250; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条错题记录') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +-- ============================================ +-- 5. 生成陪练会话记录 +-- ============================================ +INSERT INTO practice_sessions ( + session_id, user_id, scene_id, scene_name, scene_type, + start_time, end_time, duration_seconds, turns, status, is_deleted, + created_at, updated_at +) +SELECT + CONCAT('session_', @user_id, '_', UNIX_TIMESTAMP(practice_dt)), + @user_id, + 1 + FLOOR(RAND() * 5), + ELT(1 + FLOOR(RAND() * 5), '电话销售陪练', '面对面咨询陪练', '客户投诉处理陪练', '售后服务陪练', '产品介绍陪练'), + ELT(1 + FLOOR(RAND() * 5), 'phone', 'face', 'complaint', 'after-sales', 'product-intro'), + practice_dt, + DATE_ADD(practice_dt, INTERVAL duration_sec SECOND), + duration_sec, + 12 + FLOOR(RAND() * 18), + 'completed', + FALSE, + practice_dt, + practice_dt +FROM ( + SELECT + DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 60) DAY) + INTERVAL FLOOR(RAND() * 43200) SECOND AS practice_dt, + 900 + FLOOR(RAND() * 900) AS duration_sec + FROM ( + SELECT 1 AS n UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL SELECT 5 + UNION ALL SELECT 6 UNION ALL SELECT 7 UNION ALL SELECT 8 UNION ALL SELECT 9 UNION ALL SELECT 10 + UNION ALL SELECT 11 UNION ALL SELECT 12 UNION ALL SELECT 13 UNION ALL SELECT 14 UNION ALL SELECT 15 + UNION ALL SELECT 16 UNION ALL SELECT 17 UNION ALL SELECT 18 + ) AS numbers +) AS practice_dates; + +SELECT CONCAT('✓ 创建了 ', COUNT(*), ' 条陪练会话记录') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +-- ============================================ +-- 6. 统计汇总 +-- ============================================ +SELECT '========================================' AS ''; +SELECT '✅ 数据生成完成!' AS ''; +SELECT '========================================' AS ''; + +SELECT CONCAT('用户: ', username, ' (ID: ', id, ')') AS '' +FROM users WHERE id = @user_id; + +SELECT CONCAT('✓ 考试记录: ', COUNT(*), ' 条') AS '' +FROM exams WHERE user_id = @user_id; + +SELECT CONCAT('✓ 错题记录: ', COUNT(*), ' 条') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +SELECT CONCAT('✓ 陪练记录: ', COUNT(*), ' 条') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +SELECT CONCAT('✓ 知识点数量: ', COUNT(*), ' 个') AS '' +FROM knowledge_points WHERE is_deleted = FALSE; + +SELECT '========================================' AS ''; +SELECT '现在可以访问统计分析页面查看数据:' AS ''; +SELECT 'http://localhost:5173/analysis/statistics' AS ''; +SELECT '========================================' AS ''; + +-- 清理临时表 +DROP TEMPORARY TABLE IF EXISTS date_series; + +SET FOREIGN_KEY_CHECKS = 1; + diff --git a/backend/scripts/seed_statistics_for_user6.sql b/backend/scripts/seed_statistics_for_user6.sql new file mode 100644 index 0000000..2851f15 --- /dev/null +++ b/backend/scripts/seed_statistics_for_user6.sql @@ -0,0 +1,172 @@ +-- ============================================== +-- 为 user_id=6 (nurse_001) 生成统计分析数据 +-- ============================================== + +USE kaopeilian; + +SET @user_id = 6; +SET @user_name = (SELECT username FROM users WHERE id = @user_id); + +SELECT CONCAT('开始为用户 ', @user_name, ' (ID: ', @user_id, ') 生成统计数据') AS ''; + +-- ============================================ +-- 1. 生成考试记录 (50条) +-- ============================================ +-- 保留原有的3条记录,再生成47条 +INSERT INTO exams ( + user_id, course_id, exam_name, question_count, pass_score, + questions, start_time, end_time, status, duration_minutes, + round1_score, is_passed, created_at, updated_at +) +SELECT + @user_id, + c.id, + CONCAT(c.name, '练习测试_', DATE_FORMAT(exam_dt, '%m月%d日')), + 10, + 60, + '[]', + exam_dt, + DATE_ADD(exam_dt, INTERVAL FLOOR(20 + RAND() * 40) MINUTE), + 'completed', + FLOOR(20 + RAND() * 40), -- 20-60分钟 + FLOOR(55 + RAND() * 45), -- 55-100分 + 1, + exam_dt, + exam_dt +FROM ( + SELECT DATE_SUB(NOW(), INTERVAL days DAY) + INTERVAL hours HOUR + INTERVAL minutes MINUTE as exam_dt + FROM ( + SELECT 0 as days UNION ALL SELECT 1 UNION ALL SELECT 2 UNION ALL SELECT 3 UNION ALL SELECT 4 UNION ALL + SELECT 5 UNION ALL SELECT 7 UNION ALL SELECT 9 UNION ALL SELECT 12 UNION ALL SELECT 15 + ) d, + (SELECT 9 as hours UNION ALL SELECT 10 UNION ALL SELECT 14 UNION ALL SELECT 15 UNION ALL SELECT 16) h, + (SELECT 0 as minutes UNION ALL SELECT 15 UNION ALL SELECT 30 UNION ALL SELECT 45) m +) exam_dates +CROSS JOIN ( + SELECT id, name FROM courses WHERE is_deleted = FALSE LIMIT 3 +) c +LIMIT 47; + +-- 更新 is_passed 标志 +UPDATE exams SET is_passed = (round1_score >= pass_score) WHERE user_id = @user_id AND is_passed = 0; + +SELECT CONCAT('✓ 当前考试记录总数: ', COUNT(*), ' 条') AS '' +FROM exams WHERE user_id = @user_id; + +-- ============================================ +-- 2. 生成错题记录 (250条) +-- ============================================ +-- 删除旧的错题记录,重新生成 +DELETE FROM exam_mistakes WHERE user_id = @user_id; + +-- 为每个考试生成3-6个错题 +INSERT INTO exam_mistakes ( + user_id, exam_id, question_id, knowledge_point_id, + question_content, correct_answer, user_answer, question_type, + created_at, updated_at +) +SELECT + e.user_id, + e.id, + NULL, + kp.id, + CONCAT('关于"', kp.name, '"的', + ELT(1 + FLOOR(RAND() * 3), '概念理解', '实际应用', '综合分析'), '问题'), + CONCAT('正确答案:', kp.name, '的标准解释'), + CONCAT('错误理解:', '学员对', kp.name, '的误解'), + ELT(1 + FLOOR(RAND() * 5), 'single_choice', 'multiple_choice', 'true_false', 'fill_blank', 'essay'), + e.start_time, + e.start_time +FROM exams e +CROSS JOIN ( + SELECT 1 AS mistake_num UNION ALL SELECT 2 UNION ALL SELECT 3 + UNION ALL SELECT 4 UNION ALL SELECT 5 UNION ALL SELECT 6 +) AS mistake_counts +INNER JOIN knowledge_points kp ON kp.course_id = e.course_id +WHERE e.user_id = @user_id + AND kp.is_deleted = FALSE + AND RAND() < 0.8 +ORDER BY RAND() +LIMIT 250; + +SELECT CONCAT('✓ 错题记录: ', COUNT(*), ' 条') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +-- ============================================ +-- 3. 生成陪练记录 (20条) +-- ============================================ +-- 删除旧的陪练记录 +DELETE FROM practice_dialogues WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_reports WHERE session_id IN ( + SELECT session_id FROM practice_sessions WHERE user_id = @user_id +); +DELETE FROM practice_sessions WHERE user_id = @user_id; + +-- 生成新的陪练记录 +INSERT INTO practice_sessions ( + session_id, user_id, scene_id, scene_name, + duration_seconds, status, + start_time, end_time, created_at, updated_at +) +SELECT + CONCAT('session_', @user_id, '_', UNIX_TIMESTAMP(practice_dt)), + @user_id, + 1 + FLOOR(RAND() * 5), + ELT(1 + FLOOR(RAND() * 5), '电话销售陪练', '面对面咨询陪练', '客户投诉处理陪练', '售后服务陪练', '产品介绍陪练'), + FLOOR(600 + RAND() * 3000), -- 600-3600秒 (10-60分钟) + 'completed', + practice_dt, + DATE_ADD(practice_dt, INTERVAL FLOOR(10 + RAND() * 50) MINUTE), + practice_dt, + practice_dt +FROM ( + SELECT DATE_SUB(NOW(), INTERVAL days DAY) + INTERVAL hours HOUR as practice_dt + FROM ( + SELECT 0 as days UNION ALL SELECT 1 UNION ALL SELECT 3 UNION ALL SELECT 5 UNION ALL + SELECT 7 UNION ALL SELECT 10 UNION ALL SELECT 12 UNION ALL SELECT 15 UNION ALL SELECT 20 + ) d, + (SELECT 10 as hours UNION ALL SELECT 14 UNION ALL SELECT 16) h +) practice_dates +LIMIT 20; + +SELECT CONCAT('✓ 陪练记录: ', COUNT(*), ' 条') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +-- ============================================ +-- 4. 统计概览 +-- ============================================ +SELECT '========================================' AS ''; +SELECT '数据生成完成' AS ''; +SELECT '========================================' AS ''; + +SELECT CONCAT('用户: ', username, ' (', role, ')') AS '' +FROM users WHERE id = @user_id; + +SELECT CONCAT('✓ 考试记录: ', COUNT(*), ' 条') AS '' +FROM exams WHERE user_id = @user_id; + +SELECT CONCAT('✓ 错题记录: ', COUNT(*), ' 条') AS '' +FROM exam_mistakes WHERE user_id = @user_id; + +SELECT CONCAT('✓ 陪练记录: ', COUNT(*), ' 条') AS '' +FROM practice_sessions WHERE user_id = @user_id; + +SELECT CONCAT('✓ 知识点数量: ', COUNT(*), ' 个') AS '' +FROM knowledge_points WHERE is_deleted = FALSE; + +-- 成绩分布 +SELECT '========================================' AS ''; +SELECT '成绩分布情况' AS ''; +SELECT '========================================' AS ''; +SELECT + CONCAT('优秀(90-100): ', SUM(CASE WHEN round1_score >= 90 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('良好(80-89): ', SUM(CASE WHEN round1_score >= 80 AND round1_score < 90 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('中等(70-79): ', SUM(CASE WHEN round1_score >= 70 AND round1_score < 80 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('及格(60-69): ', SUM(CASE WHEN round1_score >= 60 AND round1_score < 70 THEN 1 ELSE 0 END), ' 场') AS '', + CONCAT('不及格(<60): ', SUM(CASE WHEN round1_score < 60 THEN 1 ELSE 0 END), ' 场') AS '' +FROM exams WHERE user_id = @user_id; + +SELECT '✓ 数据生成成功,可以刷新统计分析页面查看' AS ''; + diff --git a/backend/scripts/simple_init.py b/backend/scripts/simple_init.py new file mode 100644 index 0000000..29d89f7 --- /dev/null +++ b/backend/scripts/simple_init.py @@ -0,0 +1,83 @@ +""" +简单的初始化脚本 +""" +import sqlite3 +import os + +# 创建数据库文件 +db_path = 'kaopeilian.db' + +# 如果数据库已存在,删除它 +if os.path.exists(db_path): + os.remove(db_path) + +# 连接数据库 +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +# 创建teams表 +cursor.execute(''' +CREATE TABLE IF NOT EXISTS teams ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + team_type VARCHAR(50) DEFAULT 'department', + is_active BOOLEAN DEFAULT 1, + leader_id INTEGER, + parent_id INTEGER, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT 0, + deleted_at DATETIME, + created_by INTEGER, + updated_by INTEGER, + FOREIGN KEY (parent_id) REFERENCES teams(id), + FOREIGN KEY (leader_id) REFERENCES users(id) +) +''') + +# 创建users表 +cursor.execute(''' +CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username VARCHAR(50) NOT NULL UNIQUE, + email VARCHAR(100) NOT NULL UNIQUE, + phone VARCHAR(20) UNIQUE, + hashed_password VARCHAR(200) NOT NULL, + full_name VARCHAR(100), + avatar_url VARCHAR(500), + bio TEXT, + role VARCHAR(20) DEFAULT 'trainee', + is_active BOOLEAN DEFAULT 1, + is_verified BOOLEAN DEFAULT 0, + last_login_at DATETIME, + password_changed_at DATETIME, + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + is_deleted BOOLEAN DEFAULT 0, + deleted_at DATETIME, + created_by INTEGER, + updated_by INTEGER +) +''') + +# 创建user_teams关联表 +cursor.execute(''' +CREATE TABLE IF NOT EXISTS user_teams ( + user_id INTEGER NOT NULL, + team_id INTEGER NOT NULL, + role VARCHAR(50) DEFAULT 'member', + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id), + FOREIGN KEY (team_id) REFERENCES teams(id) +) +''') + +# 提交更改 +conn.commit() +conn.close() + +print("✓ SQLite数据库初始化成功!") +print(f"✓ 数据库文件: {os.path.abspath(db_path)}") diff --git a/backend/scripts/simple_rollback.py b/backend/scripts/simple_rollback.py new file mode 100644 index 0000000..fb3602e --- /dev/null +++ b/backend/scripts/simple_rollback.py @@ -0,0 +1,247 @@ +#!/usr/bin/env python3 +""" +考培练系统 - 简化数据库回滚工具 +基于MySQL Binlog的快速回滚方案 + +使用方法: +1. 查看Binlog文件: python scripts/simple_rollback.py --list +2. 模拟回滚: python scripts/simple_rollback.py --time "2024-12-20 10:30:00" +3. 实际回滚: python scripts/simple_rollback.py --time "2024-12-20 10:30:00" --execute +""" + +import asyncio +import argparse +import subprocess +import tempfile +import os +from datetime import datetime +from pathlib import Path +import aiomysql +import logging + +logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') +logger = logging.getLogger(__name__) + +class SimpleRollbackTool: + """简化回滚工具""" + + def __init__(self): + self.host = "localhost" + self.port = 3306 + self.user = "root" + self.password = "root" + self.database = "kaopeilian" + self.connection = None + + async def connect(self): + """连接数据库""" + try: + self.connection = await aiomysql.connect( + host=self.host, + port=self.port, + user=self.user, + password=self.password, + db=self.database, + charset='utf8mb4' + ) + logger.info("✅ 数据库连接成功") + except Exception as e: + logger.error(f"❌ 数据库连接失败: {e}") + raise + + async def close(self): + """关闭连接""" + if self.connection: + self.connection.close() + + async def list_binlogs(self): + """列出Binlog文件""" + cursor = await self.connection.cursor() + await cursor.execute("SHOW BINARY LOGS") + result = await cursor.fetchall() + await cursor.close() + + print("\n📋 可用的Binlog文件:") + print("-" * 60) + for i, row in enumerate(result, 1): + print(f"{i:2d}. {row[0]} ({row[1]} bytes)") + print("-" * 60) + + def extract_sql_from_binlog(self, binlog_file: str, start_time: str) -> str: + """从Binlog提取SQL语句""" + # 使用mysqlbinlog工具解析 + cmd = [ + 'docker', 'exec', 'kaopeilian-mysql', + 'mysqlbinlog', + '--base64-output=decode-rows', + '-v', + '--start-datetime', start_time, + '--database', self.database, + f'/var/lib/mysql/{binlog_file}' + ] + + try: + result = subprocess.run(cmd, capture_output=True, text=True, timeout=60) + if result.returncode == 0: + return result.stdout + else: + logger.error(f"mysqlbinlog执行失败: {result.stderr}") + return "" + except Exception as e: + logger.error(f"执行mysqlbinlog异常: {e}") + return "" + + def generate_rollback_sql(self, binlog_content: str) -> list: + """生成回滚SQL语句""" + rollback_sqls = [] + + # 简单的SQL解析和反转 + lines = binlog_content.split('\n') + current_table = None + + for line in lines: + line = line.strip() + + # 检测表名 + if '### UPDATE' in line and '`' in line: + table_match = line.split('`')[1] if '`' in line else None + if table_match: + current_table = table_match + + # 检测INSERT操作,生成DELETE + elif '### INSERT INTO' in line and '`' in line: + table_match = line.split('`')[1] if '`' in line else None + if table_match: + current_table = table_match + + # 检测DELETE操作,生成INSERT + elif '### DELETE FROM' in line and '`' in line: + table_match = line.split('`')[1] if '`' in line else None + if table_match: + current_table = table_match + + # 检测WHERE条件 + elif '### WHERE' in line and current_table: + # 提取WHERE条件 + where_part = line.replace('### WHERE', '').strip() + if where_part: + rollback_sqls.append(f"-- 需要手动处理 {current_table} 表的回滚") + rollback_sqls.append(f"-- WHERE条件: {where_part}") + + return rollback_sqls + + async def create_backup_before_rollback(self) -> str: + """回滚前创建备份""" + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + backup_file = f"/tmp/kaopeilian_backup_{timestamp}.sql" + + # 使用mysqldump创建备份 + cmd = [ + 'docker', 'exec', 'kaopeilian-mysql', + 'mysqldump', + '-uroot', '-proot', + '--single-transaction', + '--routines', + '--triggers', + self.database + ] + + try: + with open(backup_file, 'w') as f: + result = subprocess.run(cmd, stdout=f, stderr=subprocess.PIPE, text=True) + if result.returncode == 0: + logger.info(f"✅ 备份已创建: {backup_file}") + return backup_file + else: + logger.error(f"❌ 备份失败: {result.stderr}") + return "" + except Exception as e: + logger.error(f"❌ 备份异常: {e}") + return "" + + async def rollback_by_time(self, target_time: str, execute: bool = False): + """根据时间点回滚""" + logger.info(f"🎯 开始回滚到时间点: {target_time}") + + # 获取最新的Binlog文件 + cursor = await self.connection.cursor() + await cursor.execute("SHOW BINARY LOGS") + binlog_files = await cursor.fetchall() + await cursor.close() + + if not binlog_files: + logger.error("❌ 未找到Binlog文件") + return + + # 使用最新的Binlog文件 + latest_binlog = binlog_files[-1][0] + logger.info(f"📁 使用Binlog文件: {latest_binlog}") + + # 提取SQL + binlog_content = self.extract_sql_from_binlog(latest_binlog, target_time) + if not binlog_content: + logger.error("❌ 无法从Binlog提取SQL") + return + + # 生成回滚SQL + rollback_sqls = self.generate_rollback_sql(binlog_content) + + if not rollback_sqls: + logger.warning("⚠️ 未找到需要回滚的操作") + return + + print("\n🔄 回滚SQL语句:") + print("-" * 60) + for i, sql in enumerate(rollback_sqls, 1): + print(f"{i:2d}. {sql}") + print("-" * 60) + + if not execute: + logger.info("🔍 这是模拟执行,使用 --execute 参数实际执行") + return + + # 创建备份 + backup_file = await self.create_backup_before_rollback() + if not backup_file: + logger.error("❌ 备份失败,取消回滚操作") + return + + # 确认执行 + confirm = input("\n⚠️ 确认执行回滚操作?这将修改数据库数据!(yes/no): ") + if confirm.lower() != 'yes': + logger.info("❌ 用户取消回滚操作") + return + + # 执行回滚(这里需要根据实际情况手动执行SQL) + logger.info("✅ 回滚操作准备完成") + logger.info(f"📁 备份文件位置: {backup_file}") + logger.info("📝 请手动执行上述SQL语句完成回滚") + +async def main(): + """主函数""" + parser = argparse.ArgumentParser(description='考培练系统 - 简化数据库回滚工具') + parser.add_argument('--list', action='store_true', help='列出Binlog文件') + parser.add_argument('--time', help='回滚到的时间点 (格式: YYYY-MM-DD HH:MM:SS)') + parser.add_argument('--execute', action='store_true', help='实际执行回滚') + + args = parser.parse_args() + + tool = SimpleRollbackTool() + + try: + await tool.connect() + + if args.list: + await tool.list_binlogs() + elif args.time: + await tool.rollback_by_time(args.time, args.execute) + else: + parser.print_help() + + except Exception as e: + logger.error(f"❌ 程序执行异常: {e}") + finally: + await tool.close() + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/scripts/sync_core_tables.py b/backend/scripts/sync_core_tables.py new file mode 100644 index 0000000..3ab9e2f --- /dev/null +++ b/backend/scripts/sync_core_tables.py @@ -0,0 +1,138 @@ +""" +同步核心表结构(teams, user_teams),自动适配 users.id 类型。 + +步骤: +1) 检查 users.id 的数据类型(INT 或 BIGINT) +2) 如缺失则创建 teams、user_teams 表;外键列按兼容类型创建 +3) 如存在则跳过 + +运行: + cd kaopeilian-backend && python3 scripts/sync_core_tables.py +""" +import asyncio +import sys +from pathlib import Path + +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import get_settings + + +async def table_exists(conn, db_name: str, table: str) -> bool: + result = await conn.execute( + text( + """ + SELECT COUNT(1) + FROM INFORMATION_SCHEMA.TABLES + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = :table + """ + ), + {"db": db_name, "table": table}, + ) + return (result.scalar() or 0) > 0 + + +async def get_users_id_type(conn, db_name: str) -> str: + result = await conn.execute( + text( + """ + SELECT COLUMN_TYPE + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :db AND TABLE_NAME = 'users' AND COLUMN_NAME = 'id' + """ + ), + {"db": db_name}, + ) + col_type = result.scalar() or "int" + # 正规化 + col_type = col_type.lower() + if "bigint" in col_type: + return "BIGINT" + return "INT" + + +async def sync_core_tables(): + settings = get_settings() + db_url = settings.DATABASE_URL + db_name = db_url.split("/")[-1].split("?")[0] + + engine = create_async_engine(settings.DATABASE_URL, echo=False) + created = [] + async with engine.begin() as conn: + # 检查 users.id 类型 + user_id_type = await get_users_id_type(conn, db_name) + + # 创建 teams + if not await table_exists(conn, db_name, "teams"): + await conn.execute( + text( + f""" + CREATE TABLE `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE, + `code` VARCHAR(50) NOT NULL UNIQUE, + `description` TEXT, + `team_type` VARCHAR(50) DEFAULT 'department', + `is_active` BOOLEAN DEFAULT TRUE, + `leader_id` {user_id_type}, + `parent_id` INT, + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + `created_by` {user_id_type}, + `updated_by` {user_id_type}, + FOREIGN KEY (`leader_id`) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (`parent_id`) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + created.append("teams") + + # 创建 user_teams + if not await table_exists(conn, db_name, "user_teams"): + await conn.execute( + text( + f""" + CREATE TABLE `user_teams` ( + `user_id` {user_id_type} NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`user_id`, `team_id`), + FOREIGN KEY (`user_id`) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (`team_id`) REFERENCES teams(id) ON DELETE CASCADE + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + """ + ) + ) + created.append("user_teams") + + await engine.dispose() + return created + + +async def main(): + try: + created = await sync_core_tables() + if created: + print("创建表:", ", ".join(created)) + else: + print("核心表已存在,无需创建。") + except Exception as exc: + import traceback + print("同步失败:", str(exc)) + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/scripts/sync_users_table.py b/backend/scripts/sync_users_table.py new file mode 100644 index 0000000..ae80d43 --- /dev/null +++ b/backend/scripts/sync_users_table.py @@ -0,0 +1,94 @@ +""" +同步 users 表缺失列(适配当前模型定义)。 + +- 逐列检查 INFORMATION_SCHEMA,缺失则执行 ALTER TABLE 添加 +- 避免一次性重建表,降低风险 + +运行方式: + cd kaopeilian-backend && python3 scripts/sync_users_table.py +""" +import asyncio +import sys +from pathlib import Path + +# 确保可导入应用配置 +sys.path.append(str(Path(__file__).resolve().parent.parent)) + +from sqlalchemy import text +from sqlalchemy.ext.asyncio import create_async_engine + +from app.core.config import get_settings + + +async def column_exists(conn, db_name: str, table: str, column: str) -> bool: + """检查列是否存在""" + result = await conn.execute( + text( + """ + SELECT COUNT(1) + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA = :db + AND TABLE_NAME = :table + AND COLUMN_NAME = :column + """ + ), + {"db": db_name, "table": table, "column": column}, + ) + return (result.scalar() or 0) > 0 + + +async def sync_users_table(): + """对 users 表进行列同步(仅添加缺失列)。""" + settings = get_settings() + + # 解析数据库名 + db_url = settings.DATABASE_URL + db_name = db_url.split("/")[-1].split("?")[0] + + engine = create_async_engine(settings.DATABASE_URL, echo=False) + + async with engine.begin() as conn: + # 需要确保存在的列(列名 -> DDL 片段) + required_columns = { + "avatar_url": "ALTER TABLE `users` ADD COLUMN `avatar_url` VARCHAR(500) NULL COMMENT '头像URL'", + "bio": "ALTER TABLE `users` ADD COLUMN `bio` TEXT NULL COMMENT '个人简介'", + "role": "ALTER TABLE `users` ADD COLUMN `role` VARCHAR(20) NOT NULL DEFAULT 'trainee' COMMENT '系统角色'", + "is_active": "ALTER TABLE `users` ADD COLUMN `is_active` BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否激活'", + "is_verified": "ALTER TABLE `users` ADD COLUMN `is_verified` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否验证'", + "last_login_at": "ALTER TABLE `users` ADD COLUMN `last_login_at` DATETIME NULL COMMENT '最后登录时间'", + "password_changed_at": "ALTER TABLE `users` ADD COLUMN `password_changed_at` DATETIME NULL COMMENT '密码修改时间'", + "is_deleted": "ALTER TABLE `users` ADD COLUMN `is_deleted` BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否删除'", + "deleted_at": "ALTER TABLE `users` ADD COLUMN `deleted_at` DATETIME NULL COMMENT '删除时间'", + } + + applied = [] + for col, ddl in required_columns.items(): + exists = await column_exists(conn, db_name, "users", col) + if not exists: + await conn.execute(text(ddl)) + applied.append(col) + + await engine.dispose() + return applied + + +async def main(): + try: + applied = await sync_users_table() + if applied: + print("添加列:", ", ".join(applied)) + else: + print("users 表结构已满足要求,无需变更。") + except Exception as exc: + # 输出完整错误,便于调试 + import traceback + + print("同步失败:", str(exc)) + traceback.print_exc() + sys.exit(1) + + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/scripts/update_position_descriptions.py b/backend/scripts/update_position_descriptions.py new file mode 100644 index 0000000..9b78be0 --- /dev/null +++ b/backend/scripts/update_position_descriptions.py @@ -0,0 +1,133 @@ +#!/usr/bin/env python3 +""" +更新岗位描述脚本 +为瑞小美轻医美连锁品牌的真实岗位添加专业描述 +""" + +import asyncio +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from sqlalchemy import select, update +from app.core.database import AsyncSessionLocal +from app.models.position import Position +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# 轻医美岗位描述映射 +POSITION_DESCRIPTIONS = { + # 医疗专业类 + "护士": "负责医美项目的护理工作,包括术前准备、术中配合、术后护理及客户健康指导", + "护士长": "管理护理团队,制定护理标准流程,确保医疗安全和服务质量", + "副护士长": "协助护士长管理护理团队,负责日常护理工作的督导和质量控制", + "区域医务主任兼护士长": "统筹区域内各门店的医务管理工作,制定护理标准和培训体系", + "储备护士长": "接受护士长岗位培训,准备承担护理团队管理工作", + "皮肤科医生": "提供专业皮肤诊疗服务,制定个性化医美方案,确保客户安全和效果", + "皮肤科助理医生": "协助主治医生进行皮肤诊疗工作,参与医美项目的实施", + "微创技术院长": "负责微创医美技术的研发和应用,带领技术团队提升专业水平", + + # 咨询销售类 + "美学规划师": "为客户提供专业医美咨询,设计个性化美丽方案,促进项目成交", + "美学规划师兼店长": "负责门店运营管理的同时,担任首席美学规划师", + "见习美学规划师": "接受美学规划专业培训,学习咨询技巧和方案设计", + "会员服务经理": "负责VIP会员的全周期服务管理,提升客户满意度和复购率", + "网络咨询专员": "通过线上渠道为客户提供医美咨询服务,引导到店体验", + + # 管理类 + "院长": "全面负责门店运营管理、团队建设、业绩达成和客户服务质量", + "连锁院长": "统筹多家门店的运营管理,制定标准化流程,推动连锁发展", + "店长": "负责门店日常运营管理、团队协调和业绩目标达成", + "区域总经理&人才战略董事&瑞小美学苑苑长": "负责区域战略规划、人才培养体系建设和学苑运营管理", + "微创运营经理": "负责微创项目的运营推广和业绩管理", + "储备总经理助理": "接受高管培训,准备承担门店总经理工作", + + # 客服服务类 + "前厅接待": "负责客户接待、预约管理、环境维护,提供优质的前台服务", + "分诊结算专员": "负责客户分诊引导和费用结算工作,确保流程顺畅", + "客服总监": "统筹客户服务体系建设,制定服务标准,提升客户满意度", + + # 运营支持类 + "保洁员": "负责门店环境卫生维护,确保医美场所的清洁和消毒标准", + "瑞柏美五象总院保洁员": "负责五象总院的环境卫生和消毒管理工作", + "药房兼行政": "负责药品和医疗器械管理,同时协助行政事务处理", + "行政采购": "负责门店物资采购和供应商管理,确保运营物资供应", + + # 市场品牌类 + "小红书运营专员": "负责小红书平台的内容运营和粉丝互动,提升品牌影响力", + "电商运营": "负责线上商城的运营管理,推动电商业务发展", + "医生IP运营": "负责医生个人品牌打造和IP运营,提升医生影响力", + "设计总监": "负责品牌视觉设计、营销物料设计和品牌形象管理", + "平面设计师": "负责平面设计工作,包括海报、宣传册、广告物料等", + "摄影剪辑师": "负责门店的摄影摄像和视频剪辑工作,制作营销内容", + "首席文化传播官": "负责企业文化建设和品牌传播策略制定", + "AI PR": "负责品牌公关工作,运用AI技术提升传播效率", + + # 人力财务类 + "人事经理&瑞小美学苑执行秘书长": "负责人力资源管理和学苑行政工作", + "人事专员&瑞小美学苑执行秘书": "负责人事日常工作和学苑事务协调", + "薪酬服务BP": "负责薪酬福利管理和人力资源业务支持", + "财务经理": "负责财务管理、成本控制和财务报表分析", + "财务专员": "负责日常财务核算、报销审核和账务处理", + "资金管理专员": "负责资金流管理、账户管理和资金调度", + + # 战略发展类 + "战投经理": "负责战略投资项目的评估和推进,支持公司扩张发展", + "商业分析师": "负责业务数据分析和商业模式研究,支持决策制定", + "AI维护程序员": "负责AI系统的维护和优化,支持智能化运营", + + # 高管类 + "总裁": "负责公司整体战略规划和经营管理,带领团队实现发展目标", + "区域经理": "负责区域内多家门店的运营管理和业绩达成", +} + + +async def update_position_descriptions(): + """更新岗位描述""" + logger.info("=" * 60) + logger.info("开始更新岗位描述") + logger.info("=" * 60) + + async with AsyncSessionLocal() as db: + try: + # 查询所有未删除的岗位 + stmt = select(Position).where(Position.is_deleted == False) + result = await db.execute(stmt) + positions = result.scalars().all() + + logger.info(f"找到 {len(positions)} 个岗位") + + updated_count = 0 + for position in positions: + # 查找匹配的描述 + description = POSITION_DESCRIPTIONS.get(position.name) + + if description: + position.description = description + logger.info(f"✓ 更新岗位: {position.name}") + updated_count += 1 + else: + # 如果没有匹配的描述,使用通用描述 + position.description = f"{position.name}岗位,负责相关专业工作" + logger.info(f"○ 使用通用描述: {position.name}") + updated_count += 1 + + # 提交更新 + await db.commit() + + logger.info("=" * 60) + logger.info(f"✅ 成功更新 {updated_count} 个岗位描述") + logger.info("=" * 60) + + except Exception as e: + logger.error(f"更新失败: {str(e)}") + await db.rollback() + raise + + +if __name__ == "__main__": + asyncio.run(update_position_descriptions()) + + diff --git a/backend/setup.cfg b/backend/setup.cfg new file mode 100644 index 0000000..8e99e66 --- /dev/null +++ b/backend/setup.cfg @@ -0,0 +1,40 @@ +[flake8] +max-line-length = 88 +max-complexity = 10 +extend-ignore = E203, W503 +exclude = .git,__pycache__,venv,migrations + +[mypy] +python_version = 3.8 +warn_return_any = True +warn_unused_configs = True +disallow_untyped_defs = True +disallow_incomplete_defs = True +check_untyped_defs = True +disallow_untyped_decorators = True +no_implicit_optional = True +warn_redundant_casts = True +warn_unused_ignores = True +warn_no_return = True +warn_unreachable = True +strict_equality = True + +[isort] +profile = black +multi_line_output = 3 +include_trailing_comma = True +force_grid_wrap = 0 +use_parentheses = True +ensure_newline_before_comments = True +line_length = 88 + +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = -v --strict-markers --tb=short +markers = + unit: Unit tests + integration: Integration tests + e2e: End-to-end tests diff --git a/backend/simple_main.py b/backend/simple_main.py new file mode 100644 index 0000000..98e47ae --- /dev/null +++ b/backend/simple_main.py @@ -0,0 +1,225 @@ +""" +简化的主应用 - 修复前端集成问题 +""" +from fastapi import FastAPI, HTTPException +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel +import mysql.connector +import hashlib +import jwt +from datetime import datetime, timedelta +from typing import Optional + +app = FastAPI( + title="考培练系统API", + version="1.0.0", + description="用户管理和认证系统" +) + +# 配置CORS +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +# JWT配置 +SECRET_KEY = "dev-secret-key-change-in-production" +ALGORITHM = "HS256" +ACCESS_TOKEN_EXPIRE_MINUTES = 30 + +# 数据库配置 +DB_CONFIG = { + 'host': '127.0.0.1', + 'user': 'root', + 'password': '', + 'database': 'kaopeilian', + 'charset': 'utf8mb4', + 'autocommit': True +} + +# Pydantic模型 +class LoginRequest(BaseModel): + username: str + password: str + +class RefreshTokenRequest(BaseModel): + refresh_token: str + +class ResponseModel(BaseModel): + code: int = 200 + message: str + data: Optional[dict] = None + +# 辅助函数 +def hash_password(password: str) -> str: + return hashlib.sha256(password.encode()).hexdigest() + +def create_access_token(user_id: int) -> str: + expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES) + to_encode = {"sub": str(user_id), "exp": expire, "type": "access"} + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +def create_refresh_token(user_id: int) -> str: + expire = datetime.utcnow() + timedelta(days=7) + to_encode = {"sub": str(user_id), "exp": expire, "type": "refresh"} + return jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM) + +def get_db_connection(): + """获取数据库连接""" + try: + return mysql.connector.connect(**DB_CONFIG) + except mysql.connector.Error as e: + raise HTTPException(status_code=500, detail=f"数据库连接失败: {str(e)}") + +# API路由 +@app.get("/") +def read_root(): + return {"message": "考培练系统API", "version": "1.0.0", "status": "running"} + +@app.get("/health") +def health_check(): + return {"status": "healthy", "service": "kaopeilian-api", "timestamp": datetime.utcnow().isoformat()} + +@app.post("/api/v1/auth/login") +def login(login_data: LoginRequest): + """用户登录""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + # 查询用户 + cursor.execute( + "SELECT id, username, email, hashed_password, full_name, role, is_active, is_verified FROM users WHERE username = %s AND is_deleted = 0", + (login_data.username,) + ) + user = cursor.fetchone() + + if not user: + raise HTTPException(status_code=400, detail="用户名或密码错误") + + # 验证密码 + if user['hashed_password'] != hash_password(login_data.password): + raise HTTPException(status_code=400, detail="用户名或密码错误") + + if not user['is_active']: + raise HTTPException(status_code=400, detail="用户已被禁用") + + # 生成令牌 + access_token = create_access_token(user['id']) + refresh_token = create_refresh_token(user['id']) + + # 更新最后登录时间 + cursor.execute( + "UPDATE users SET last_login_at = NOW() WHERE id = %s", + (user['id'],) + ) + + cursor.close() + conn.close() + + return ResponseModel( + message="登录成功", + data={ + "user": { + "id": user['id'], + "username": user['username'], + "email": user['email'], + "full_name": user['full_name'], + "role": user['role'], + "is_active": bool(user['is_active']), + "is_verified": bool(user['is_verified']) + }, + "token": { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer" + } + } + ) + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") + +@app.post("/api/v1/auth/refresh") +def refresh_token(refresh_data: RefreshTokenRequest): + """刷新访问令牌""" + try: + # 解码刷新令牌 + payload = jwt.decode(refresh_data.refresh_token, SECRET_KEY, algorithms=[ALGORITHM]) + + if payload.get("type") != "refresh": + raise HTTPException(status_code=400, detail="无效的刷新令牌") + + user_id = int(payload.get("sub")) + + # 生成新的访问令牌 + access_token = create_access_token(user_id) + + return ResponseModel( + message="令牌刷新成功", + data={ + "access_token": access_token, + "refresh_token": refresh_data.refresh_token, + "token_type": "bearer" + } + ) + + except jwt.PyJWTError: + raise HTTPException(status_code=400, detail="无效的刷新令牌") + except Exception as e: + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") + +@app.get("/api/v1/users/me") +def get_current_user_info(): + """获取当前用户信息""" + # 简化版本,返回管理员信息 + return ResponseModel( + message="获取成功", + data={ + "id": 1, + "username": "admin", + "email": "admin@test.com", + "full_name": "系统管理员", + "role": "admin", + "is_active": True, + "is_verified": True, + "created_at": "2024-01-01T00:00:00" + } + ) + +@app.get("/api/v1/users") +def list_users(): + """获取用户列表""" + try: + conn = get_db_connection() + cursor = conn.cursor(dictionary=True) + + cursor.execute( + "SELECT id, username, email, full_name, role, is_active, created_at FROM users WHERE is_deleted = 0 ORDER BY id" + ) + users = cursor.fetchall() + + cursor.close() + conn.close() + + return ResponseModel( + message="获取成功", + data={ + "items": users, + "total": len(users), + "page": 1, + "page_size": len(users) + } + ) + + except Exception as e: + raise HTTPException(status_code=500, detail=f"服务器错误: {str(e)}") + +if __name__ == "__main__": + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/backend/simple_test.py b/backend/simple_test.py new file mode 100644 index 0000000..c4079d2 --- /dev/null +++ b/backend/simple_test.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +""" +最简单的测试 +""" +import requests +import json + +def test_api(): + """测试API""" + try: + # 测试健康检查 + health_response = requests.get("http://localhost:8000/health") + print(f"健康检查: {health_response.status_code} - {health_response.text}") + + # 测试登录 + login_data = { + "username": "testuser", + "password": "TestPass123!" + } + + login_response = requests.post( + "http://localhost:8000/api/v1/auth/login", + json=login_data, + headers={"Content-Type": "application/json"} + ) + + print(f"登录请求: {login_response.status_code}") + print(f"响应头: {dict(login_response.headers)}") + print(f"响应内容: {login_response.text}") + + if login_response.status_code == 500: + print("❌ 服务器内部错误") + elif login_response.status_code == 200: + print("✅ 登录成功") + else: + print(f"⚠️ 其他状态码: {login_response.status_code}") + + except Exception as e: + print(f"❌ 测试失败: {e}") + +if __name__ == "__main__": + test_api() diff --git a/backend/start.sh b/backend/start.sh new file mode 100644 index 0000000..a064430 --- /dev/null +++ b/backend/start.sh @@ -0,0 +1,64 @@ +#!/bin/bash + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo -e "${GREEN}考培练系统后端启动脚本${NC}" +echo "================================" + +# 检查Python版本 +echo -e "${YELLOW}检查Python版本...${NC}" +python_version=$(python3 --version 2>&1) +if [[ $? -eq 0 ]]; then + echo -e "${GREEN}✓ $python_version${NC}" +else + echo -e "${RED}✗ Python3未安装${NC}" + exit 1 +fi + +# 检查虚拟环境 +if [ ! -d "venv" ]; then + echo -e "${YELLOW}创建虚拟环境...${NC}" + python3 -m venv venv +fi + +# 激活虚拟环境 +echo -e "${YELLOW}激活虚拟环境...${NC}" +source venv/bin/activate + +# 安装依赖 +echo -e "${YELLOW}安装依赖...${NC}" +pip install -q -r requirements/base.txt + +# 检查.env文件 +if [ ! -f ".env" ]; then + echo -e "${YELLOW}创建.env文件...${NC}" + cp .env.example .env + echo -e "${GREEN}✓ 已创建.env文件,请根据需要修改配置${NC}" +fi + +# 检查数据库连接 +echo -e "${YELLOW}检查数据库连接...${NC}" +python -c " +import os +from dotenv import load_dotenv +load_dotenv() +db_url = os.getenv('DATABASE_URL', '') +if 'mysql' in db_url: + print('✓ 数据库配置已设置') +else: + print('⚠ 请检查数据库配置') +" 2>/dev/null + +# 启动服务 +echo -e "${GREEN}启动开发服务器...${NC}" +echo "================================" +echo -e "API文档: ${GREEN}http://localhost:8000/api/docs${NC}" +echo -e "健康检查: ${GREEN}http://localhost:8000/health${NC}" +echo "================================" + +# 启动uvicorn +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/backend/start_backend.py b/backend/start_backend.py new file mode 100644 index 0000000..c6b5db3 --- /dev/null +++ b/backend/start_backend.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +""" +考培练系统后端启动脚本 +""" +import os +import sys +import uvicorn +from app.core.config import get_settings + +def main(): + """启动后端服务""" + settings = get_settings() + + print("🚀 启动考培练系统后端服务...") + print(f"📍 服务地址: http://{settings.HOST}:{settings.PORT}") + print(f"📚 API文档: http://{settings.HOST}:{settings.PORT}/docs") + print(f"🔧 调试模式: {'开启' if settings.DEBUG else '关闭'}") + print("-" * 50) + + # 启动 uvicorn + uvicorn.run( + "app.main:app", + host=settings.HOST, + port=settings.PORT, + reload=settings.DEBUG, + log_level=settings.LOG_LEVEL.lower() + ) + +if __name__ == "__main__": + main() diff --git a/backend/start_dev.py b/backend/start_dev.py new file mode 100644 index 0000000..11f8b7e --- /dev/null +++ b/backend/start_dev.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +""" +开发环境启动脚本 +使用SQLite数据库进行本地开发测试 +""" +import os +import sys +import asyncio +from pathlib import Path + +# 设置环境变量 +os.environ["DATABASE_URL"] = "sqlite+aiosqlite:///./test.db" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" +os.environ["DEBUG"] = "true" +os.environ["LOG_LEVEL"] = "INFO" +os.environ["LOG_FORMAT"] = "console" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def create_tables(): + """创建数据库表""" + try: + from app.config.database import engine + from app.models.base import Base + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + print("✅ 数据库表创建成功") + except Exception as e: + print(f"❌ 数据库表创建失败: {e}") + +async def main(): + """主函数""" + print("🚀 启动考培练系统后端服务...") + + # 创建数据库表 + await create_tables() + + # 启动服务 + import uvicorn + from app.main import app + + print("📚 API文档地址: http://localhost:8000/api/v1/docs") + print("🔍 健康检查: http://localhost:8000/health") + print("⏹️ 按 Ctrl+C 停止服务") + + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/start_dev.sh b/backend/start_dev.sh new file mode 100644 index 0000000..d028efe --- /dev/null +++ b/backend/start_dev.sh @@ -0,0 +1,71 @@ +#!/bin/bash +# 开发环境启动脚本 + +echo "===================================" +echo "考培练系统后端 - 开发环境启动" +echo "===================================" + +# 检查是否在项目根目录 +if [ ! -f "app/main.py" ]; then + echo "错误:请在项目根目录运行此脚本" + exit 1 +fi + +# 检查Python版本 +python_version=$(python3 --version 2>&1 | awk '{print $2}') +echo "Python版本: $python_version" + +# 检查MySQL服务 +echo -n "检查MySQL服务... " +if command -v mysql &> /dev/null; then + if mysql -u root -e "SELECT 1" &> /dev/null; then + echo "✓" + else + echo "✗" + echo "警告:无法连接到MySQL,请确保MySQL服务正在运行" + echo "提示:使用 'sudo service mysql start' 启动MySQL" + fi +else + echo "✗" + echo "警告:未找到MySQL客户端" +fi + +# 检查Redis服务 +echo -n "检查Redis服务... " +if command -v redis-cli &> /dev/null; then + if redis-cli ping &> /dev/null; then + echo "✓" + else + echo "✗" + echo "警告:无法连接到Redis,请确保Redis服务正在运行" + echo "提示:使用 'sudo service redis-server start' 启动Redis" + fi +else + echo "✗" + echo "警告:未找到Redis客户端" +fi + +# 安装依赖 +echo "" +echo "安装/更新依赖..." +pip install --break-system-packages -r requirements/base.txt + +# 初始化数据库 +echo "" +echo "初始化数据库..." +python3 scripts/init_db.py + +# 创建测试数据 +echo "" +echo "创建测试数据..." +python3 scripts/create_test_data.py + +# 启动服务器 +echo "" +echo "===================================" +echo "启动开发服务器..." +echo "API文档地址: http://localhost:8000/docs" +echo "===================================" +echo "" + +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 diff --git a/backend/start_mysql.py b/backend/start_mysql.py new file mode 100644 index 0000000..f6b0f75 --- /dev/null +++ b/backend/start_mysql.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" +MySQL环境启动脚本 +使用MySQL数据库进行开发测试 +""" +import os +import sys +import asyncio +from pathlib import Path + +# 设置环境变量 - 使用公网MySQL数据库 +# 密码需要URL编码: Kaopeilian2025!@# -> Kaopeilian2025%21%40%23 +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" +os.environ["DEBUG"] = "true" +os.environ["LOG_LEVEL"] = "INFO" +os.environ["LOG_FORMAT"] = "console" +os.environ["REDIS_URL"] = "redis://localhost:6379/0" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def create_database(): + """创建数据库(如果不存在)""" + try: + import aiomysql + + # 连接到公网MySQL服务器(不指定数据库) + conn = await aiomysql.connect( + host='120.79.247.16', + port=3306, + user='root', + password='Kaopeilian2025!@#' + ) + + cursor = await conn.cursor() + + # 创建数据库 + await cursor.execute("CREATE DATABASE IF NOT EXISTS kaopeilian CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci") + print("✅ 数据库 'kaopeilian' 创建成功") + + await cursor.close() + conn.close() + + except Exception as e: + print(f"⚠️ 数据库创建警告: {e}") + print("请确保MySQL服务正在运行,并且用户root的密码是'root'") + +async def create_tables(): + """创建数据库表""" + try: + from app.config.database import engine + from app.models.base import Base + + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + print("✅ 数据库表创建成功") + except Exception as e: + print(f"❌ 数据库表创建失败: {e}") + print("请检查MySQL连接配置") + +async def main(): + """主函数""" + print("🚀 启动考培练系统后端服务 (MySQL版本)...") + + # 创建数据库 + await create_database() + + # 创建数据库表 + await create_tables() + + # 启动服务 + import uvicorn + from app.main import app + + print("📚 API文档地址: http://localhost:8000/api/v1/docs") + print("🔍 健康检查: http://localhost:8000/health") + print("🗄️ 数据库: MySQL (kaopeilian)") + print("⏹️ 按 Ctrl+C 停止服务") + + uvicorn.run( + app, + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/start_remote.py b/backend/start_remote.py new file mode 100644 index 0000000..6830d2d --- /dev/null +++ b/backend/start_remote.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 +""" +使用公网数据库的启动脚本 +""" +import os +import sys +import asyncio +from pathlib import Path + +# 设置环境变量 - 使用公网MySQL数据库 +# 数据库信息: +# 主机: 120.79.247.16 或 aiedu.ireborn.com.cn +# 端口: 3306 +# 数据库名: kaopeilian +# 用户: root +# 密码: Kaopeilian2025!@# (URL编码后: Kaopeilian2025%21%40%23) +os.environ["DATABASE_URL"] = "mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4" +os.environ["SECRET_KEY"] = "dev-secret-key-for-testing-only-not-for-production" +os.environ["DEBUG"] = "true" +os.environ["LOG_LEVEL"] = "INFO" +os.environ["LOG_FORMAT"] = "console" +os.environ["REDIS_URL"] = "redis://localhost:6379/0" + +# 添加项目根目录到Python路径 +project_root = Path(__file__).parent +sys.path.insert(0, str(project_root)) + +async def test_connection(): + """测试数据库连接""" + try: + from app.config.database import engine + from sqlalchemy import text + + print("📡 正在连接到公网数据库...") + + async with engine.begin() as conn: + result = await conn.execute(text("SELECT VERSION()")) + version = result.scalar() + print(f"✅ 成功连接到MySQL: {version}") + + # 检查表数量 + result = await conn.execute(text("SHOW TABLES")) + tables = result.fetchall() + print(f"✅ 数据库中有 {len(tables)} 个表") + + await engine.dispose() + return True + + except Exception as e: + print(f"❌ 数据库连接失败: {e}") + return False + +async def main(): + """主函数""" + # 测试数据库连接 + if not await test_connection(): + print("\n⚠️ 请检查:") + print("1. 网络连接是否正常") + print("2. 数据库服务器是否可访问") + print("3. 用户名密码是否正确") + return + + print("\n🚀 启动应用服务器...") + print("访问地址: http://localhost:8000") + print("API文档: http://localhost:8000/docs") + print("\n按 Ctrl+C 停止服务器") + + # 导入并运行应用 + import uvicorn + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_config={ + "version": 1, + "disable_existing_loggers": False, + "formatters": { + "default": { + "format": "%(asctime)s | %(levelname)s | %(name)s | %(message)s", + }, + }, + "handlers": { + "default": { + "formatter": "default", + "class": "logging.StreamHandler", + "stream": "ext://sys.stdout", + }, + }, + "root": { + "level": "INFO", + "handlers": ["default"], + }, + } + ) + +if __name__ == "__main__": + asyncio.run(main()) + + diff --git a/backend/start_simple.py b/backend/start_simple.py new file mode 100755 index 0000000..3313cfb --- /dev/null +++ b/backend/start_simple.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""简单启动脚本,避免配置问题""" +import os +import sys + +# 设置Python路径 +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +# 导入local_config来设置环境变量 +import local_config + +if __name__ == "__main__": + import uvicorn + + # 确保CORS_ORIGINS格式正确 + os.environ["CORS_ORIGINS"] = '["http://localhost:3000","http://localhost:3001","http://localhost:5173"]' + + print("🚀 启动后端服务...") + print("📚 API文档: http://localhost:8000/docs") + print("🔍 健康检查: http://localhost:8000/health") + + uvicorn.run( + "app.main:app", + host="0.0.0.0", + port=8000, + reload=True, + log_level="info" + ) diff --git a/backend/test_api.py b/backend/test_api.py new file mode 100644 index 0000000..dbd30c7 --- /dev/null +++ b/backend/test_api.py @@ -0,0 +1,172 @@ +""" +API 测试脚本 +""" +import asyncio +import json +from typing import Optional + +import httpx + +# API基础URL +BASE_URL = "http://localhost:8000/api/v1" + + +class APITester: + def __init__(self): + self.client = httpx.AsyncClient(base_url=BASE_URL) + self.token: Optional[str] = None + + async def login(self, username: str, password: str): + """测试登录""" + print(f"\n1. 测试登录 - 用户: {username}") + response = await self.client.post("/auth/login", json={ + "username": username, + "password": password + }) + + if response.status_code == 200: + data = response.json() + self.token = data["data"]["token"]["access_token"] + print(f"✓ 登录成功!") + print(f" 用户信息: {data['data']['user']['username']} ({data['data']['user']['role']})") + print(f" 令牌: {self.token[:20]}...") + return True + else: + print(f"✗ 登录失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + return False + + async def get_current_user(self): + """测试获取当前用户""" + print("\n2. 测试获取当前用户信息") + headers = {"Authorization": f"Bearer {self.token}"} + response = await self.client.get("/users/me", headers=headers) + + if response.status_code == 200: + data = response.json() + print(f"✓ 获取成功!") + print(f" 用户: {data['data']['username']}") + print(f" 邮箱: {data['data']['email']}") + print(f" 角色: {data['data']['role']}") + else: + print(f"✗ 获取失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + + async def list_users(self): + """测试获取用户列表""" + print("\n3. 测试获取用户列表") + headers = {"Authorization": f"Bearer {self.token}"} + response = await self.client.get("/users?page=1&page_size=10", headers=headers) + + if response.status_code == 200: + data = response.json() + print(f"✓ 获取成功!") + print(f" 总数: {data['data']['total']}") + print(f" 用户列表:") + for user in data['data']['items']: + print(f" - {user['username']} ({user['role']}) - {user['email']}") + else: + print(f"✗ 获取失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + + async def create_user(self): + """测试创建用户(需要管理员权限)""" + print("\n4. 测试创建用户(需要管理员权限)") + headers = {"Authorization": f"Bearer {self.token}"} + new_user = { + "username": "testuser", + "email": "testuser@example.com", + "password": "test123456", + "full_name": "测试用户", + "role": "trainee" + } + + response = await self.client.post("/users", json=new_user, headers=headers) + + if response.status_code == 201: + data = response.json() + print(f"✓ 创建成功!") + print(f" 新用户: {data['data']['username']} - {data['data']['email']}") + else: + print(f"✗ 创建失败: {response.status_code}") + print(f" 错误信息: {response.json()}") + + async def update_password(self): + """测试修改密码""" + print("\n5. 测试修改密码") + headers = {"Authorization": f"Bearer {self.token}"} + + # 先尝试错误的旧密码 + response = await self.client.put("/users/me/password", json={ + "old_password": "wrongpassword", + "new_password": "newpass123" + }, headers=headers) + + if response.status_code != 200: + print(f"✓ 正确拒绝了错误的旧密码") + + # 使用正确的旧密码 + response = await self.client.put("/users/me/password", json={ + "old_password": "admin123", # 假设是admin用户 + "new_password": "admin123" # 改回原密码 + }, headers=headers) + + if response.status_code == 200: + print(f"✓ 密码修改成功!") + else: + print(f" 注意: 密码修改测试可能因为旧密码不匹配而失败") + + async def test_unauthorized(self): + """测试未授权访问""" + print("\n6. 测试未授权访问") + # 不带token访问需要认证的接口 + response = await self.client.get("/users/me") + + if response.status_code == 403: + print(f"✓ 正确拒绝了未授权访问") + else: + print(f"✗ 未授权访问测试失败: {response.status_code}") + + async def close(self): + """关闭客户端""" + await self.client.aclose() + + +async def main(): + """主测试函数""" + print("=================================") + print("考培练系统 API 测试") + print("=================================") + + tester = APITester() + + try: + # 测试未授权访问 + await tester.test_unauthorized() + + # 测试管理员登录 + print("\n--- 管理员测试 ---") + if await tester.login("admin", "admin123"): + await tester.get_current_user() + await tester.list_users() + await tester.create_user() + await tester.update_password() + + # 测试普通用户登录 + print("\n\n--- 普通用户测试 ---") + if await tester.login("trainee1", "trainee123"): + await tester.get_current_user() + await tester.list_users() + await tester.create_user() # 应该失败(权限不足) + + print("\n\n测试完成!") + + except Exception as e: + print(f"\n错误: {str(e)}") + print("提示: 请确保服务器正在运行 (http://localhost:8000)") + finally: + await tester.close() + + +if __name__ == "__main__": + asyncio.run(main()) \ No newline at end of file diff --git a/backend/test_api_endpoint.py b/backend/test_api_endpoint.py new file mode 100644 index 0000000..1747630 --- /dev/null +++ b/backend/test_api_endpoint.py @@ -0,0 +1,64 @@ +#!/usr/bin/env python +""" +创建一个测试端点来调试 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +import uvicorn +from fastapi import FastAPI +from app.api.v1 import api_router + +# 创建应用 +app = FastAPI(title="调试API") + +# 包含现有路由 +app.include_router(api_router, prefix="/api/v1") + +# 添加测试路由 +from fastapi import Depends +from app.core.deps import get_current_user +from app.schemas.user import User as UserSchema +from app.schemas.base import ResponseModel + +@app.get("/debug/user/raw") +async def debug_user_raw(current_user = Depends(get_current_user)): + """直接返回用户对象的dict""" + return { + "id": current_user.id, + "username": current_user.username, + "email": current_user.email, + "phone": current_user.phone, + "school": current_user.school, + "major": current_user.major, + "full_name": current_user.full_name, + "bio": current_user.bio, + "gender": current_user.gender, + "role": current_user.role + } + +@app.get("/debug/user/schema") +async def debug_user_schema(current_user = Depends(get_current_user)): + """使用UserSchema序列化""" + return UserSchema.model_validate(current_user) + +@app.get("/debug/user/response") +async def debug_user_response(current_user = Depends(get_current_user)): + """使用ResponseModel包装""" + return ResponseModel(data=UserSchema.model_validate(current_user)) + +@app.get("/debug/user/dict") +async def debug_user_dict(current_user = Depends(get_current_user)): + """返回model_dump结果""" + user_schema = UserSchema.model_validate(current_user) + return user_schema.model_dump() + +if __name__ == "__main__": + print("启动调试API服务器...") + uvicorn.run(app, host="0.0.0.0", port=8001) diff --git a/backend/test_check_schema_fields.py b/backend/test_check_schema_fields.py new file mode 100644 index 0000000..66834d7 --- /dev/null +++ b/backend/test_check_schema_fields.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python +""" +检查schema定义的字段 +""" + +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.schemas.user import UserBase, UserInDBBase, User + +def check_fields(): + """检查字段定义""" + + print("=== UserBase 字段 ===") + for field_name, field_info in UserBase.model_fields.items(): + print(f"{field_name}: {field_info}") + + print("\n=== UserInDBBase 字段 ===") + for field_name, field_info in UserInDBBase.model_fields.items(): + print(f"{field_name}: {field_info}") + + print("\n=== User 字段 ===") + for field_name, field_info in User.model_fields.items(): + print(f"{field_name}: {field_info}") + + # 检查继承链 + print("\n=== 继承关系 ===") + print(f"UserBase.__bases__: {UserBase.__bases__}") + print(f"UserInDBBase.__bases__: {UserInDBBase.__bases__}") + print(f"User.__bases__: {User.__bases__}") + + # 检查模型配置 + print("\n=== 模型配置 ===") + if hasattr(UserBase, 'model_config'): + print(f"UserBase.model_config: {UserBase.model_config}") + if hasattr(UserInDBBase, 'model_config'): + print(f"UserInDBBase.model_config: {UserInDBBase.model_config}") + if hasattr(User, 'model_config'): + print(f"User.model_config: {User.model_config}") + +if __name__ == "__main__": + check_fields() diff --git a/backend/test_course_7_exam_settings.py b/backend/test_course_7_exam_settings.py new file mode 100644 index 0000000..0dd5212 --- /dev/null +++ b/backend/test_course_7_exam_settings.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +""" +专门测试课程ID 7的考试设置 +""" +import asyncio +import json +import httpx +from datetime import datetime + +BASE_URL = "http://localhost:8000" + +async def main(): + async with httpx.AsyncClient() as client: + # 1. 登录获取token + print("1. 登录管理员账号...") + login_resp = await client.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": "admin", "password": "Admin123!"} + ) + token = login_resp.json()["data"]["token"]["access_token"] + headers = {"Authorization": f"Bearer {token}"} + + # 2. 获取当前考试设置 + print("\n2. 获取课程7的当前考试设置...") + get_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + current_settings = get_resp.json() + print(f"当前设置: {json.dumps(current_settings['data'], indent=2, ensure_ascii=False)}") + + # 3. 修改考试设置 + print("\n3. 修改考试设置...") + new_settings = { + "single_choice_count": 30, + "multiple_choice_count": 15, + "true_false_count": 12, + "fill_blank_count": 8, + "duration_minutes": 150, + "difficulty_level": 4, + "is_enabled": True + } + save_resp = await client.post( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + json=new_settings, + headers=headers + ) + print(f"保存响应: {save_resp.json()}") + + # 4. 再次获取验证 + print("\n4. 再次获取考试设置验证保存...") + verify_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + updated_settings = verify_resp.json() + print(f"更新后的设置: {json.dumps(updated_settings['data'], indent=2, ensure_ascii=False)}") + + # 5. 验证数据 + saved_data = updated_settings['data'] + all_correct = ( + saved_data['single_choice_count'] == 30 and + saved_data['multiple_choice_count'] == 15 and + saved_data['true_false_count'] == 12 and + saved_data['fill_blank_count'] == 8 and + saved_data['duration_minutes'] == 150 and + saved_data['difficulty_level'] == 4 + ) + + if all_correct: + print("\n✅ 测试通过!考试设置保存和读取功能正常。") + else: + print("\n❌ 测试失败!数据未正确保存。") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/test_exam_records_api.py b/backend/test_exam_records_api.py new file mode 100644 index 0000000..7ac276a --- /dev/null +++ b/backend/test_exam_records_api.py @@ -0,0 +1,52 @@ +""" +测试考试记录API +""" +import asyncio +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine +from sqlalchemy.orm import sessionmaker +from app.services.exam_service import ExamService +from app.core.config import settings + +async def test_exam_records(): + """测试获取考试记录""" + # 创建数据库连接 + engine = create_async_engine( + settings.SQLALCHEMY_DATABASE_URI, + echo=True, + future=True + ) + + async_session = sessionmaker( + engine, class_=AsyncSession, expire_on_commit=False + ) + + async with async_session() as db: + # 测试user_id=5的考试记录 + print("\n========== 测试user_id=5的考试记录 ==========") + records = await ExamService.get_exam_records( + db=db, + user_id=5, + page=1, + size=10 + ) + + print(f"\n总记录数: {records['total']}") + print(f"当前页: {records['page']}") + print(f"每页数量: {records['size']}") + print(f"总页数: {records['pages']}") + print(f"\n记录列表:") + + for item in records['items']: + print(f"\nID: {item['id']}") + print(f" 考试名称: {item['exam_name']}") + print(f" 课程名称: {item['course_name']}") + print(f" 得分: {item['score']}") + print(f" 正确率: {item['accuracy']}%") + print(f" 正确数: {item['correct_count']}") + print(f" 错题数: {item['wrong_count']}") + print(f" 用时: {item['duration_seconds']}秒") + print(f" 分题型统计: {len(item['question_type_stats'])}种题型") + +if __name__ == "__main__": + asyncio.run(test_exam_records()) + diff --git a/backend/test_practice_api.py b/backend/test_practice_api.py new file mode 100644 index 0000000..b656124 --- /dev/null +++ b/backend/test_practice_api.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +""" +测试陪练记录API +""" +import requests +import json +from datetime import datetime, timedelta + +# API基础URL +BASE_URL = "http://localhost:8000" + +# 测试用户的token(需要先登录获取) +def login(username: str, password: str): + """登录获取token""" + response = requests.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": username, "password": password} + ) + if response.status_code == 200: + data = response.json() + if data.get("code") == 200: + return data["data"]["access_token"] + print(f"登录失败: {response.text}") + return None + +def test_practice_sessions(token: str, params: dict = None): + """测试陪练记录列表API""" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get( + f"{BASE_URL}/api/v1/practice/sessions/list", + headers=headers, + params=params or {} + ) + + print(f"\n{'='*60}") + print(f"请求参数: {params}") + print(f"响应状态码: {response.status_code}") + print(f"{'='*60}") + + if response.status_code == 200: + data = response.json() + print(json.dumps(data, indent=2, ensure_ascii=False)) + + if data.get("code") == 200: + result_data = data["data"] + print(f"\n📊 统计信息:") + print(f" - 总记录数: {result_data.get('total')}") + print(f" - 当前页码: {result_data.get('page')}") + print(f" - 每页数量: {result_data.get('page_size')}") + print(f" - 总页数: {result_data.get('pages')}") + print(f" - 本页记录数: {len(result_data.get('items', []))}") + + if result_data.get('items'): + print(f"\n📝 前3条记录:") + for i, item in enumerate(result_data['items'][:3], 1): + print(f" {i}. {item.get('scene_name')} - 评分: {item.get('total_score')} - {item.get('start_time')}") + else: + print(f"请求失败: {response.text}") + +def test_practice_stats(token: str): + """测试陪练统计API""" + headers = {"Authorization": f"Bearer {token}"} + + response = requests.get( + f"{BASE_URL}/api/v1/practice/stats", + headers=headers + ) + + print(f"\n{'='*60}") + print(f"陪练统计API") + print(f"{'='*60}") + + if response.status_code == 200: + data = response.json() + print(json.dumps(data, indent=2, ensure_ascii=False)) + else: + print(f"请求失败: {response.text}") + +if __name__ == "__main__": + # 测试不同用户 + test_users = [ + ("superadmin", "admin123"), + ("nurse_001", "password123"), + ("consultant_001", "password123"), + ] + + for username, password in test_users: + print(f"\n{'#'*60}") + print(f"# 测试用户: {username}") + print(f"{'#'*60}") + + # 登录 + token = login(username, password) + if not token: + print(f"❌ {username} 登录失败,跳过") + continue + + print(f"✅ {username} 登录成功") + + # 测试统计API + test_practice_stats(token) + + # 测试列表API - 无参数 + print(f"\n\n测试场景1: 无参数查询") + test_practice_sessions(token) + + # 测试列表API - 带日期范围 + print(f"\n\n测试场景2: 最近30天") + today = datetime.now() + start_date = (today - timedelta(days=30)).strftime("%Y-%m-%d") + end_date = today.strftime("%Y-%m-%d") + test_practice_sessions(token, { + "start_date": start_date, + "end_date": end_date, + "page": 1, + "size": 20 + }) + + print("\n" + "="*60 + "\n") + diff --git a/backend/test_remote_db.py b/backend/test_remote_db.py new file mode 100644 index 0000000..9c4278d --- /dev/null +++ b/backend/test_remote_db.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +""" +测试远程数据库连接 +""" +import asyncio +import urllib.parse + +async def test_remote_connection(): + """测试远程数据库连接""" + try: + import aiomysql + + # 公网数据库信息 + host = '120.79.247.16' # 或 aiedu.ireborn.com.cn + port = 3306 + user = 'root' + password = 'Kaopeilian2025!@#' + database = 'kaopeilian' + + print(f"正在连接到远程数据库 {host}:{port}/{database}...") + + # 直接连接测试 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + cursor = await conn.cursor() + await cursor.execute("SELECT VERSION()") + version = await cursor.fetchone() + print(f"✅ 成功连接到MySQL: {version[0]}") + + # 测试查询 + await cursor.execute("SHOW TABLES") + tables = await cursor.fetchall() + print(f"✅ 数据库中有 {len(tables)} 个表") + + await cursor.close() + conn.close() + + # 生成URL编码的连接字符串 + password_encoded = urllib.parse.quote_plus(password) + dsn = f"mysql+aiomysql://{user}:{password_encoded}@{host}:{port}/{database}?charset=utf8mb4" + print(f"\n✅ 连接成功!") + print(f"📝 SQLAlchemy连接字符串(已编码):") + print(f" {dsn}") + + # 测试SQLAlchemy连接 + from sqlalchemy.ext.asyncio import create_async_engine + engine = create_async_engine(dsn, echo=False) + + async with engine.begin() as conn: + result = await conn.execute(text("SELECT 1")) + print(f"\n✅ SQLAlchemy连接测试成功!") + + await engine.dispose() + + except Exception as e: + print(f"❌ 连接失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + from sqlalchemy import text + asyncio.run(test_remote_connection()) + + diff --git a/backend/test_schema_validation.py b/backend/test_schema_validation.py new file mode 100644 index 0000000..d29b70c --- /dev/null +++ b/backend/test_schema_validation.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python +""" +测试User schema的序列化 +""" + +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.schemas.user import User as UserSchema +from app.models.user import User +from app.core.deps import get_db +from app.services.user_service import UserService + +async def test_schema(): + """测试schema序列化""" + + # 获取数据库会话 + async for db in get_db(): + try: + # 1. 获取用户 + user_service = UserService(db) + user = await user_service.get_by_username("superadmin") + + if not user: + print("用户不存在") + return + + # 2. 打印模型数据 + print("=== 模型数据 ===") + print(f"ID: {user.id}") + print(f"用户名: {user.username}") + print(f"邮箱: {user.email}") + print(f"手机号: {user.phone}") + print(f"学校: {user.school}") + print(f"专业: {user.major}") + print(f"性别: {user.gender}") + print(f"个人简介: {user.bio}") + + # 3. 使用schema序列化 + print("\n=== Schema序列化 ===") + user_data = UserSchema.model_validate(user) + print(f"序列化结果: {user_data.model_dump()}") + + # 4. 检查具体字段 + print("\n=== 检查序列化后的字段 ===") + dumped = user_data.model_dump() + for field in ['username', 'email', 'phone', 'school', 'major', 'gender', 'bio']: + value = dumped.get(field, 'NOT_FOUND') + print(f"{field}: {value}") + + except Exception as e: + print(f"错误: {e}") + import traceback + traceback.print_exc() + finally: + await db.close() + break + +if __name__ == "__main__": + print("测试User schema序列化...") + asyncio.run(test_schema()) diff --git a/backend/test_statistics_api.py b/backend/test_statistics_api.py new file mode 100644 index 0000000..e9a2dea --- /dev/null +++ b/backend/test_statistics_api.py @@ -0,0 +1,149 @@ +#!/usr/bin/env python +""" +测试统计分析API +""" +import asyncio +import sys +from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession +from sqlalchemy.orm import sessionmaker + +# 添加项目路径 +sys.path.insert(0, '/Users/nongjun/Desktop/Ai公司/本地开发与测试/kaopeilian-backend') + +from app.services.statistics_service import StatisticsService +from app.core.logger import get_logger + +logger = get_logger(__name__) + +# 测试数据库连接字符串(使用本地测试环境) +DATABASE_URL = "mysql+aiomysql://root:nj861021@localhost:3306/kaopeilian" + + +async def test_statistics_service(): + """测试统计服务""" + print("=" * 50) + print("开始测试统计分析服务") + print("=" * 50) + + # 创建数据库连接 + engine = create_async_engine(DATABASE_URL, echo=False) + async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) + + try: + async with async_session() as session: + # 测试用户ID(使用实际存在的用户) + test_user_id = 2 # admin用户 + + print(f"\n📊 测试用户ID: {test_user_id}") + print("-" * 50) + + # 1. 测试关键指标 + print("\n1️⃣ 测试关键指标...") + try: + metrics = await StatisticsService.get_key_metrics( + db=session, + user_id=test_user_id, + period="month" + ) + print("✓ 关键指标获取成功:") + print(f" - 学习效率: {metrics['learningEfficiency']['value']}%") + print(f" - 知识覆盖率: {metrics['knowledgeCoverage']['value']}%") + print(f" - 平均用时: {metrics['avgTimePerQuestion']['value']} 分/题") + print(f" - 进步速度: {metrics['progressSpeed']['value']}%") + except Exception as e: + print(f"✗ 关键指标获取失败: {e}") + + # 2. 测试成绩分布 + print("\n2️⃣ 测试成绩分布...") + try: + distribution = await StatisticsService.get_score_distribution( + db=session, + user_id=test_user_id, + period="month" + ) + print("✓ 成绩分布获取成功:") + print(f" - 优秀: {distribution['excellent']}") + print(f" - 良好: {distribution['good']}") + print(f" - 中等: {distribution['medium']}") + print(f" - 及格: {distribution['pass']}") + print(f" - 不及格: {distribution['fail']}") + except Exception as e: + print(f"✗ 成绩分布获取失败: {e}") + + # 3. 测试难度分析 + print("\n3️⃣ 测试难度分析...") + try: + difficulty = await StatisticsService.get_difficulty_analysis( + db=session, + user_id=test_user_id, + period="month" + ) + print("✓ 难度分析获取成功:") + for key, value in difficulty.items(): + print(f" - {key}: {value}%") + except Exception as e: + print(f"✗ 难度分析获取失败: {e}") + + # 4. 测试知识点掌握度 + print("\n4️⃣ 测试知识点掌握度...") + try: + mastery = await StatisticsService.get_knowledge_mastery( + db=session, + user_id=test_user_id + ) + print(f"✓ 知识点掌握度获取成功 (共{len(mastery)}个知识点):") + for item in mastery[:3]: # 只显示前3个 + print(f" - {item['name']}: {item['mastery']}%") + except Exception as e: + print(f"✗ 知识点掌握度获取失败: {e}") + + # 5. 测试学习时长统计 + print("\n5️⃣ 测试学习时长统计...") + try: + time_stats = await StatisticsService.get_study_time_stats( + db=session, + user_id=test_user_id, + period="week" + ) + print(f"✓ 学习时长统计获取成功:") + print(f" - 日期数: {len(time_stats['labels'])}") + print(f" - 总学习时长: {sum(time_stats['studyTime'])} 小时") + print(f" - 总练习时长: {sum(time_stats['practiceTime'])} 小时") + except Exception as e: + print(f"✗ 学习时长统计获取失败: {e}") + + # 6. 测试详细数据 + print("\n6️⃣ 测试详细数据...") + try: + detail = await StatisticsService.get_detail_data( + db=session, + user_id=test_user_id, + period="month" + ) + print(f"✓ 详细数据获取成功 (共{len(detail)}条记录):") + if detail: + first = detail[0] + print(f" - 最近日期: {first['date']}") + print(f" - 考试次数: {first['examCount']}") + print(f" - 平均分: {first['avgScore']}") + print(f" - 正确率: {first['accuracy']}%") + except Exception as e: + print(f"✗ 详细数据获取失败: {e}") + + print("\n" + "=" * 50) + print("✅ 所有测试完成!") + print("=" * 50) + + except Exception as e: + print(f"\n❌ 测试过程中发生错误: {e}") + import traceback + traceback.print_exc() + + finally: + await engine.dispose() + + +if __name__ == "__main__": + print("\n🚀 启动统计分析服务测试...") + asyncio.run(test_statistics_service()) + diff --git a/backend/test_team_api.py b/backend/test_team_api.py new file mode 100644 index 0000000..24d4e4a --- /dev/null +++ b/backend/test_team_api.py @@ -0,0 +1,114 @@ +""" +测试团队管理API +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目路径 +sys.path.append(str(Path(__file__).resolve().parent)) + +import httpx + + +async def test_team_api(): + """测试团队管理API""" + base_url = "http://localhost:8000" + + print("=" * 60) + print("测试团队管理API") + print("=" * 60) + + # 1. 登录获取token + print("\n【1. 登录admin用户】") + login_data = { + "username": "admin", + "password": "admin123" + } + + async with httpx.AsyncClient() as client: + try: + response = await client.post( + f"{base_url}/api/v1/auth/login", + json=login_data, + timeout=10.0 + ) + print(f"登录状态: {response.status_code}") + result = response.json() + print(f"响应内容: {result}") + + if response.status_code == 200: + # 尝试不同的数据结构 + token = None + if "access_token" in result: + token = result["access_token"] + elif result.get("data"): + if "access_token" in result["data"]: + token = result["data"]["access_token"] + elif "token" in result["data"] and "access_token" in result["data"]["token"]: + token = result["data"]["token"]["access_token"] + + if token: + print(f"登录成功,获取token: {token[:30]}...") + else: + print(f"无法获取token,响应结构: {list(result.keys())}") + return + else: + print(f"登录失败: HTTP {response.status_code}") + return + except Exception as e: + print(f"登录失败: {e}") + return + + # 2. 测试团队统计API + print("\n【2. 获取团队统计】") + headers = {"Authorization": f"Bearer {token}"} + try: + response = await client.get( + f"{base_url}/api/v1/team/management/statistics", + headers=headers, + timeout=10.0 + ) + print(f"状态: {response.status_code}") + result = response.json() + print(f"结果: {result}") + except Exception as e: + print(f"获取统计失败: {e}") + + # 3. 测试团队成员列表API + print("\n【3. 获取团队成员列表】") + try: + response = await client.get( + f"{base_url}/api/v1/team/management/members", + headers=headers, + params={"page": 1, "size": 20}, + timeout=10.0 + ) + print(f"状态: {response.status_code}") + result = response.json() + print(f"返回code: {result.get('code')}") + print(f"返回message: {result.get('message')}") + + if result.get("data"): + data = result["data"] + print(f"总记录数: {data.get('total')}") + print(f"当前页: {data.get('page')}") + print(f"每页大小: {data.get('page_size')}") + print(f"总页数: {data.get('pages')}") + print(f"返回记录数: {len(data.get('items', []))}") + + if data.get('items'): + print("\n前3个成员:") + for member in data['items'][:3]: + print(f" - ID:{member['id']}, 姓名:{member['name']}, 岗位:{member['position']}, 状态:{member['status']}") + else: + print("无数据返回") + except Exception as e: + print(f"获取成员列表失败: {e}") + + print("\n" + "=" * 60) + + +if __name__ == "__main__": + asyncio.run(test_team_api()) + diff --git a/backend/test_team_dashboard.py b/backend/test_team_dashboard.py new file mode 100644 index 0000000..d4102ad --- /dev/null +++ b/backend/test_team_dashboard.py @@ -0,0 +1,186 @@ +""" +团队看板API测试脚本 + +用于验证团队看板所有接口的功能 +""" + +import asyncio +import httpx + +# 配置 +BASE_URL = "http://localhost:8000" +# 需要先登录获取token +TOKEN = "your_token_here" # 替换为实际的token + +headers = { + "Authorization": f"Bearer {TOKEN}", + "Content-Type": "application/json" +} + + +async def test_overview(): + """测试团队概览接口""" + print("\n=== 测试团队概览接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/overview", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + print(f"响应数据: {data}") + + if data.get("code") == 200: + result = data.get("data", {}) + print(f"✅ 团队成员数: {result.get('member_count')}") + print(f"✅ 平均学习进度: {result.get('avg_progress')}%") + print(f"✅ 平均考试成绩: {result.get('avg_score')}") + print(f"✅ 课程完成率: {result.get('course_completion_rate')}%") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_progress(): + """测试学习进度接口""" + print("\n=== 测试学习进度接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/progress", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + print(f"✅ 成员列表: {result.get('members')}") + print(f"✅ 周数: {len(result.get('weeks', []))}") + print(f"✅ 数据条数: {len(result.get('data', []))}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_course_distribution(): + """测试课程分布接口""" + print("\n=== 测试课程分布接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/course-distribution", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + print(f"✅ 已完成: {result.get('completed')}") + print(f"✅ 进行中: {result.get('in_progress')}") + print(f"✅ 未开始: {result.get('not_started')}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_ability_analysis(): + """测试能力分析接口""" + print("\n=== 测试能力分析接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/ability-analysis", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + radar = result.get('radar_data', {}) + print(f"✅ 能力维度: {radar.get('dimensions')}") + print(f"✅ 能力分数: {radar.get('values')}") + print(f"✅ 短板数量: {len(result.get('weaknesses', []))}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_rankings(): + """测试排行榜接口""" + print("\n=== 测试排行榜接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/rankings", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + study_ranking = result.get('study_time_ranking', []) + score_ranking = result.get('score_ranking', []) + print(f"✅ 学习时长排行: {len(study_ranking)} 人") + if study_ranking: + print(f" 第一名: {study_ranking[0].get('name')} - {study_ranking[0].get('study_time')}小时") + print(f"✅ 成绩排行: {len(score_ranking)} 人") + if score_ranking: + print(f" 第一名: {score_ranking[0].get('name')} - {score_ranking[0].get('avg_score')}分") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def test_activities(): + """测试团队动态接口""" + print("\n=== 测试团队动态接口 ===") + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/api/v1/team/dashboard/activities", + headers=headers + ) + print(f"状态码: {response.status_code}") + data = response.json() + + if data.get("code") == 200: + result = data.get("data", {}) + activities = result.get('activities', []) + print(f"✅ 活动记录数: {len(activities)}") + if activities: + print(f" 最新活动: {activities[0].get('user_name')} {activities[0].get('action')} {activities[0].get('target')}") + else: + print(f"❌ 失败: {data.get('message')}") + + +async def main(): + """运行所有测试""" + print("=" * 60) + print("团队看板API测试") + print("=" * 60) + + # 检查token是否设置 + if TOKEN == "your_token_here": + print("\n⚠️ 请先设置TOKEN变量(在文件顶部)") + print(" 可以通过以下步骤获取:") + print(" 1. 访问 http://localhost:3001") + print(" 2. 登录系统(admin账号)") + print(" 3. 打开浏览器开发者工具 -> Application -> Local Storage") + print(" 4. 找到 token 或 access_token") + return + + try: + await test_overview() + await test_progress() + await test_course_distribution() + await test_ability_analysis() + await test_rankings() + await test_activities() + + print("\n" + "=" * 60) + print("✅ 所有测试完成!") + print("=" * 60) + + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/test_team_management_api.py b/backend/test_team_management_api.py new file mode 100644 index 0000000..f8ea66c --- /dev/null +++ b/backend/test_team_management_api.py @@ -0,0 +1,272 @@ +""" +团队管理API测试脚本 +用于验证团队管理相关接口的功能 +""" +import asyncio +import sys +from pathlib import Path + +# 添加项目根目录到Python路径 +sys.path.insert(0, str(Path(__file__).parent)) + +import httpx + + +BASE_URL = "http://localhost:8000/api/v1" +TOKEN = None # 需要先登录获取token + + +async def login(): + """登录获取token""" + global TOKEN + async with httpx.AsyncClient() as client: + response = await client.post( + f"{BASE_URL}/auth/login", + json={ + "username": "admin", + "password": "admin123" + } + ) + if response.status_code == 200: + data = response.json() + TOKEN = data.get("data", {}).get("access_token") + print(f"✅ 登录成功,获取token: {TOKEN[:20]}...") + return True + else: + print(f"❌ 登录失败: {response.text}") + return False + + +async def test_team_statistics(): + """测试团队统计接口""" + print("\n" + "="*60) + print("测试1: GET /team/management/statistics") + print("="*60) + + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/team/management/statistics", + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + print(f"响应数据: {data}") + + if response.status_code == 200: + stats = data.get("data", {}) + print(f"\n📊 团队统计:") + print(f" - 团队总人数: {stats.get('teamCount')}") + print(f" - 活跃成员: {stats.get('activeMembers')}") + print(f" - 平均学习进度: {stats.get('avgProgress')}%") + print(f" - 团队平均分: {stats.get('avgScore')}") + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + +async def test_team_members(): + """测试团队成员列表接口""" + print("\n" + "="*60) + print("测试2: GET /team/management/members") + print("="*60) + + async with httpx.AsyncClient() as client: + # 测试基础查询 + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 5}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + + if response.status_code == 200: + result = data.get("data", {}) + print(f"\n👥 成员列表:") + print(f" - 总数: {result.get('total')}") + print(f" - 当前页: {result.get('page')}") + print(f" - 每页数量: {result.get('page_size')}") + print(f" - 总页数: {result.get('pages')}") + + items = result.get("items", []) + print(f"\n 成员列表 (前{len(items)}条):") + for member in items: + print(f" - ID: {member['id']}, 姓名: {member['name']}, " + f"岗位: {member['position']}, 状态: {member['status']}, " + f"进度: {member['progress']}%, 平均分: {member['avgScore']}") + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + # 测试搜索功能 + print("\n测试搜索功能...") + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 5, "search_text": "admin"}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + if response.status_code == 200: + data = response.json() + result = data.get("data", {}) + print(f" 搜索'admin'结果数: {result.get('total')}") + print("✅ 搜索测试通过") + + +async def test_member_detail(): + """测试成员详情接口""" + print("\n" + "="*60) + print("测试3: GET /team/management/members/{id}/detail") + print("="*60) + + # 先获取一个成员ID + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 1}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + if response.status_code != 200: + print("❌ 无法获取成员列表") + return + + data = response.json() + items = data.get("data", {}).get("items", []) + if not items: + print("⚠️ 没有成员数据,跳过测试") + return + + member_id = items[0]["id"] + print(f"测试成员ID: {member_id}") + + # 获取成员详情 + response = await client.get( + f"{BASE_URL}/team/management/members/{member_id}/detail", + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + + if response.status_code == 200: + detail = data.get("data", {}) + print(f"\n📋 成员详情:") + print(f" - 姓名: {detail.get('name')}") + print(f" - 岗位: {detail.get('position')}") + print(f" - 状态: {detail.get('status')}") + print(f" - 学习时长: {detail.get('studyTime')}小时") + print(f" - 完成课程: {detail.get('completedCourses')}门") + print(f" - 平均成绩: {detail.get('avgScore')}分") + print(f" - 通过率: {detail.get('passRate')}%") + + records = detail.get("recentRecords", []) + print(f"\n 最近学习记录 ({len(records)}条):") + for record in records[:3]: + print(f" - {record['time']}: {record['content']}") + + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + +async def test_member_report(): + """测试成员学习报告接口""" + print("\n" + "="*60) + print("测试4: GET /team/management/members/{id}/report") + print("="*60) + + # 先获取一个成员ID + async with httpx.AsyncClient() as client: + response = await client.get( + f"{BASE_URL}/team/management/members", + params={"page": 1, "size": 1}, + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + if response.status_code != 200: + print("❌ 无法获取成员列表") + return + + data = response.json() + items = data.get("data", {}).get("items", []) + if not items: + print("⚠️ 没有成员数据,跳过测试") + return + + member_id = items[0]["id"] + print(f"测试成员ID: {member_id}") + + # 获取学习报告 + response = await client.get( + f"{BASE_URL}/team/management/members/{member_id}/report", + headers={"Authorization": f"Bearer {TOKEN}"} + ) + + print(f"状态码: {response.status_code}") + data = response.json() + + if response.status_code == 200: + report = data.get("data", {}) + + # 概览 + overview = report.get("overview", []) + print(f"\n📊 报告概览:") + for item in overview: + print(f" - {item['label']}: {item['value']}") + + # 进度趋势 + trend = report.get("progressTrend", {}) + dates = trend.get("dates", []) + data_points = trend.get("data", []) + print(f"\n📈 学习进度趋势 (30天):") + print(f" - 数据点数: {len(dates)}") + if dates and data_points: + print(f" - 起始: {dates[0]} -> {data_points[0]}%") + print(f" - 结束: {dates[-1]} -> {data_points[-1]}%") + + # 能力评估 + abilities = report.get("abilities", []) + print(f"\n🎯 能力评估:") + for ability in abilities: + print(f" - {ability['name']}: {ability['score']}分 - {ability['description']}") + + # 详细记录 + records = report.get("records", []) + print(f"\n📚 详细学习记录 ({len(records)}条)") + + print("✅ 测试通过") + else: + print(f"❌ 测试失败: {data.get('message')}") + + +async def main(): + """主测试函数""" + print("="*60) + print("团队管理API测试") + print("="*60) + + # 登录 + if not await login(): + return + + # 运行测试 + try: + await test_team_statistics() + await test_team_members() + await test_member_detail() + await test_member_report() + + print("\n" + "="*60) + print("✅ 所有测试完成") + print("="*60) + except Exception as e: + print(f"\n❌ 测试过程中出现错误: {e}") + import traceback + traceback.print_exc() + + +if __name__ == "__main__": + asyncio.run(main()) + diff --git a/backend/test_user_id4.py b/backend/test_user_id4.py new file mode 100644 index 0000000..1513d16 --- /dev/null +++ b/backend/test_user_id4.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python +""" +检查用户ID 4的统计数据 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.core.config import settings + +async def check_user_4(): + """检查用户ID 4的数据""" + try: + # 从环境变量或配置中获取数据库连接信息 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + # 1. 查看用户信息 + print("\n=== 用户信息 ===") + await cursor.execute(""" + SELECT id, username, email, full_name, role, is_active + FROM users + WHERE id = 4 + """) + user_info = await cursor.fetchone() + if user_info: + print(f"ID: {user_info[0]}, 用户名: {user_info[1]}, 邮箱: {user_info[2]}, 姓名: {user_info[3]}, 角色: {user_info[4]}, 激活: {user_info[5]}") + else: + print("用户ID 4不存在") + + # 如果没有ID=4的用户,查看所有用户 + print("\n=== 所有用户 ===") + await cursor.execute(""" + SELECT id, username, email, role + FROM users + ORDER BY id + """) + users = await cursor.fetchall() + for user in users: + print(f"ID: {user[0]}, 用户名: {user[1]}, 邮箱: {user[2]}, 角色: {user[3]}") + + # 2. 查看用户ID 4的统计数据(如果存在) + if user_info: + user_id = 4 + print(f"\n=== 用户ID {user_id} 统计数据 ===") + + # 学习天数 + await cursor.execute(""" + SELECT COUNT(DISTINCT DATE(start_time)) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + learning_days = (await cursor.fetchone())[0] or 0 + print(f"学习天数: {learning_days}") + + # 总时长 + await cursor.execute(""" + SELECT COALESCE(SUM(duration_seconds), 0) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + total_seconds = (await cursor.fetchone())[0] or 0 + total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0 + print(f"学习时长: {total_hours} 小时") + + # 练习题数和平均分 + await cursor.execute(""" + SELECT COALESCE(SUM(question_count), 0), AVG(score) + FROM exams + WHERE user_id = %s AND status = 'completed' + """, (user_id,)) + result = await cursor.fetchone() + practice_questions = result[0] or 0 + avg_score = round(float(result[1]), 1) if result[1] is not None else 0.0 + print(f"练习题数: {practice_questions}") + print(f"平均分: {avg_score}") + + conn.close() + + except Exception as e: + print(f"执行失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + print("检查用户ID 4的数据...") + asyncio.run(check_user_4()) diff --git a/backend/test_user_position_sync.py b/backend/test_user_position_sync.py new file mode 100644 index 0000000..629741d --- /dev/null +++ b/backend/test_user_position_sync.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +""" +测试用户岗位同步功能 +验证用户编辑页面的岗位变更是否真正落库 +""" + +import os +import sys +import asyncio +from sqlalchemy import create_engine, text +from sqlalchemy.ext.asyncio import create_async_engine + +# 添加项目根目录到 Python 路径 +sys.path.append(os.path.dirname(os.path.abspath(__file__))) + +# 导入 local_config 以设置环境变量 +import local_config + +# 获取数据库配置 +DATABASE_URL = os.environ.get("DATABASE_URL") + +# 将同步 URL 转换为异步 URL +if DATABASE_URL.startswith("mysql+pymysql://"): + ASYNC_DATABASE_URL = DATABASE_URL.replace("mysql+pymysql://", "mysql+aiomysql://") +else: + ASYNC_DATABASE_URL = DATABASE_URL + + +async def test_user_position_relationships(): + """测试用户-岗位关系""" + print(f"\n=== 用户岗位关系测试 ===") + print(f"数据库URL: {ASYNC_DATABASE_URL}") + + # 创建异步引擎 + engine = create_async_engine(ASYNC_DATABASE_URL, echo=False) + + try: + async with engine.connect() as conn: + # 1. 查看所有岗位 + print("\n1. 当前系统中的所有岗位:") + result = await conn.execute(text(""" + SELECT id, name, code, status + FROM positions + ORDER BY id + """)) + positions = result.fetchall() + + if not positions: + print(" [警告] 系统中没有岗位数据!") + else: + for p in positions: + status = "启用" if p.status == "active" else "停用" + print(f" - ID: {p.id}, 名称: {p.name}, 编码: {p.code}, 状态: {status}") + + # 2. 查看用户-岗位关系 + print("\n2. 用户-岗位关联关系:") + result = await conn.execute(text(""" + SELECT + pm.position_id, + pm.user_id, + p.name as position_name, + u.username, + u.full_name, + pm.created_at + FROM position_members pm + JOIN positions p ON pm.position_id = p.id + JOIN users u ON pm.user_id = u.id + ORDER BY pm.position_id, pm.user_id + """)) + members = result.fetchall() + + if not members: + print(" [提示] 暂无用户-岗位关联") + else: + current_position_id = None + for m in members: + if m.position_id != current_position_id: + current_position_id = m.position_id + print(f"\n 岗位: {m.position_name} (ID: {m.position_id})") + print(f" - 用户: {m.username} ({m.full_name}), ID: {m.user_id}, 加入时间: {m.created_at}") + + # 3. 检查特定用户的岗位 + print("\n3. 查看特定用户的岗位信息:") + # 查询几个示例用户 + result = await conn.execute(text(""" + SELECT id, username, full_name + FROM users + WHERE role != 'admin' + LIMIT 5 + """)) + sample_users = result.fetchall() + + for user in sample_users: + result = await conn.execute(text(""" + SELECT + p.id, + p.name, + p.code + FROM positions p + JOIN position_members pm ON p.id = pm.position_id + WHERE pm.user_id = :user_id + """), {"user_id": user.id}) + user_positions = result.fetchall() + + if user_positions: + positions_str = ", ".join([f"{p.name}(ID:{p.id})" for p in user_positions]) + print(f" - 用户 {user.username} ({user.full_name}): {positions_str}") + else: + print(f" - 用户 {user.username} ({user.full_name}): 无岗位") + + # 4. 统计每个岗位的成员数量 + print("\n4. 统计每个岗位的成员数量:") + result = await conn.execute(text(""" + SELECT + p.id, + p.name, + COUNT(pm.user_id) as member_count + FROM positions p + LEFT JOIN position_members pm ON p.id = pm.position_id + GROUP BY p.id, p.name + ORDER BY p.id + """)) + counts = result.fetchall() + + for c in counts: + print(f" - 岗位 {c.name} (ID:{c.id}) 成员数: {c.member_count}") + + except Exception as e: + print(f"\n[错误] 数据库操作失败: {e}") + import traceback + traceback.print_exc() + finally: + await engine.dispose() + + +async def verify_position_sync(user_id: int): + """验证特定用户的岗位同步情况""" + print(f"\n=== 验证用户 ID:{user_id} 的岗位同步 ===") + + engine = create_async_engine(ASYNC_DATABASE_URL, echo=False) + + try: + async with engine.connect() as conn: + # 获取用户信息 + result = await conn.execute(text(""" + SELECT username, full_name + FROM users + WHERE id = :user_id + """), {"user_id": user_id}) + user = result.fetchone() + + if not user: + print(f"[错误] 用户 ID:{user_id} 不存在") + return + + print(f"用户: {user.username} ({user.full_name})") + + # 获取用户的岗位 + result = await conn.execute(text(""" + SELECT + p.id, + p.name, + p.code, + pm.created_at + FROM positions p + JOIN position_members pm ON p.id = pm.position_id + WHERE pm.user_id = :user_id + ORDER BY pm.created_at DESC + """), {"user_id": user_id}) + positions = result.fetchall() + + if positions: + print(f"当前岗位:") + for p in positions: + print(f" - {p.name} (ID:{p.id}, 编码:{p.code}) - 加入时间: {p.created_at}") + else: + print("当前岗位: 无") + + except Exception as e: + print(f"\n[错误] 验证失败: {e}") + import traceback + traceback.print_exc() + finally: + await engine.dispose() + + +if __name__ == "__main__": + # 运行测试 + asyncio.run(test_user_position_relationships()) + + # 如果命令行提供了用户ID,验证该用户 + if len(sys.argv) > 1: + try: + user_id = int(sys.argv[1]) + asyncio.run(verify_position_sync(user_id)) + except ValueError: + print(f"\n[错误] 无效的用户ID: {sys.argv[1]}") diff --git a/backend/test_user_statistics.py b/backend/test_user_statistics.py new file mode 100644 index 0000000..eaff9dd --- /dev/null +++ b/backend/test_user_statistics.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python +""" +测试用户统计接口 +""" + +import asyncio +import sys +from pathlib import Path +import aiomysql +import os +from datetime import datetime + +# 添加项目根目录到 Python 路径 +project_root = Path(__file__).parent +sys.path.append(str(project_root)) + +from app.core.config import settings + +async def check_user_data(): + """检查用户数据""" + try: + # 从环境变量或配置中获取数据库连接信息 + database_url = os.getenv("DATABASE_URL", settings.DATABASE_URL) + + # 解析数据库连接字符串 + if database_url.startswith("mysql+aiomysql://"): + url = database_url.replace("mysql+aiomysql://", "") + else: + url = database_url + + # 解析连接参数 + auth_host = url.split("@")[1] + user_pass = url.split("@")[0] + host_port_db = auth_host.split("/") + host_port = host_port_db[0].split(":") + + user = user_pass.split(":")[0] + password = user_pass.split(":")[1] + host = host_port[0] + port = int(host_port[1]) if len(host_port) > 1 else 3306 + database = host_port_db[1].split("?")[0] if len(host_port_db) > 1 else "kaopeilian" + + print(f"连接数据库: {host}:{port}/{database}") + + # 创建数据库连接 + conn = await aiomysql.connect( + host=host, + port=port, + user=user, + password=password, + db=database, + charset='utf8mb4' + ) + + async with conn.cursor() as cursor: + # 1. 查看考试记录及状态 + print("\n=== 考试记录 ===") + await cursor.execute(""" + SELECT id, user_id, exam_name, status, score, question_count + FROM exams + WHERE user_id IN (SELECT id FROM users WHERE username IN ('admin', 'testuser')) + ORDER BY created_at DESC + LIMIT 10 + """) + exams = await cursor.fetchall() + for exam in exams: + print(f"考试ID: {exam[0]}, 用户ID: {exam[1]}, 名称: {exam[2]}, 状态: {exam[3]}, 分数: {exam[4]}, 题数: {exam[5]}") + + # 2. 查看陪练会话记录 + print("\n=== 陪练会话记录 ===") + await cursor.execute(""" + SELECT id, user_id, scene_id, start_time, duration_seconds, status + FROM training_sessions + WHERE user_id IN (SELECT id FROM users WHERE username IN ('admin', 'testuser')) + ORDER BY created_at DESC + LIMIT 10 + """) + sessions = await cursor.fetchall() + for session in sessions: + print(f"会话ID: {session[0]}, 用户ID: {session[1]}, 场景ID: {session[2]}, 开始时间: {session[3]}, 时长: {session[4]}秒, 状态: {session[5]}") + + # 3. 统计每个用户的数据 + print("\n=== 用户统计数据 ===") + users = [('admin', 1), ('testuser', 3)] + for username, user_id in users: + print(f"\n用户: {username} (ID: {user_id})") + + # 学习天数 + await cursor.execute(""" + SELECT COUNT(DISTINCT DATE(start_time)) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + learning_days = (await cursor.fetchone())[0] or 0 + print(f" 学习天数: {learning_days}") + + # 总时长 + await cursor.execute(""" + SELECT COALESCE(SUM(duration_seconds), 0) + FROM training_sessions + WHERE user_id = %s + """, (user_id,)) + total_seconds = (await cursor.fetchone())[0] or 0 + total_hours = round(float(total_seconds) / 3600.0, 1) if total_seconds else 0.0 + print(f" 学习时长: {total_hours} 小时 ({total_seconds} 秒)") + + # 练习题数 - 检查不同状态 + for status in ['completed', 'submitted']: + await cursor.execute(""" + SELECT COALESCE(SUM(question_count), 0) + FROM exams + WHERE user_id = %s AND status = %s + """, (user_id, status)) + questions = (await cursor.fetchone())[0] or 0 + print(f" 练习题数({status}): {questions}") + + # 平均分 - 检查不同状态 + for status in ['completed', 'submitted']: + await cursor.execute(""" + SELECT AVG(score) + FROM exams + WHERE user_id = %s AND status = %s + """, (user_id, status)) + avg_score = await cursor.fetchone() + avg_score_val = round(float(avg_score[0]), 1) if avg_score[0] is not None else 0.0 + print(f" 平均分({status}): {avg_score_val}") + + conn.close() + + except Exception as e: + print(f"执行失败: {e}") + import traceback + traceback.print_exc() + +if __name__ == "__main__": + print("检查用户统计数据...") + asyncio.run(check_user_data()) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..1545c77 --- /dev/null +++ b/backend/tests/__init__.py @@ -0,0 +1 @@ +"""测试包""" \ No newline at end of file diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..5f8023f --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,100 @@ +"""测试配置和fixtures""" +import asyncio +from typing import AsyncGenerator, Generator +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine, async_sessionmaker + +from app.main import app +from app.models.base import Base +from app.config.database import SessionLocal +from app.core.deps import get_db + + +# 测试数据库URL +TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" + +# 创建测试引擎 +test_engine = create_async_engine( + TEST_DATABASE_URL, + connect_args={"check_same_thread": False} +) + +# 创建测试会话工厂 +TestSessionLocal = async_sessionmaker( + test_engine, + class_=AsyncSession, + expire_on_commit=False, + autocommit=False, + autoflush=False +) + + +@pytest.fixture(scope="session") +def event_loop() -> Generator: + """创建事件循环""" + loop = asyncio.get_event_loop_policy().new_event_loop() + yield loop + loop.close() + + +@pytest.fixture(scope="function") +async def db_session() -> AsyncGenerator[AsyncSession, None]: + """创建测试数据库会话""" + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + async with TestSessionLocal() as session: + yield session + await session.rollback() + + async with test_engine.begin() as conn: + await conn.run_sync(Base.metadata.drop_all) + + +@pytest.fixture(scope="function") +async def client(db_session: AsyncSession) -> AsyncGenerator[AsyncClient, None]: + """创建测试客户端""" + async def override_get_db(): + yield db_session + + app.dependency_overrides[get_db] = override_get_db + + async with AsyncClient(app=app, base_url="http://test") as ac: + yield ac + + app.dependency_overrides.clear() + + +@pytest.fixture +def test_user(): + """测试用户""" + return { + "id": 1, + "username": "test_user", + "role": "user", + "token": "test_token" + } + + +@pytest.fixture +def test_admin(): + """测试管理员""" + return { + "id": 2, + "username": "test_admin", + "role": "admin", + "token": "admin_token" + } + + +@pytest.fixture +def auth_headers(test_user): + """认证请求头""" + return {"Authorization": f"Bearer {test_user['token']}"} + + +@pytest.fixture +def admin_auth_headers(test_admin): + """管理员认证请求头""" + return {"Authorization": f"Bearer {test_admin['token']}"} \ No newline at end of file diff --git a/backend/tests/e2e/__init__.py b/backend/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/integration/__init__.py b/backend/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/test_courses.py b/backend/tests/test_courses.py new file mode 100644 index 0000000..53977dc --- /dev/null +++ b/backend/tests/test_courses.py @@ -0,0 +1,284 @@ +""" +课程模块测试 +""" +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.course import Course, CourseStatus, CourseCategory +from app.services.course_service import course_service + + +class TestCourseAPI: + """课程API测试类""" + + @pytest.mark.asyncio + async def test_create_course_success(self, client: AsyncClient, admin_headers: dict): + """测试成功创建课程""" + course_data = { + "name": "测试课程", + "description": "这是一个测试课程", + "category": "technology", + "difficulty_level": 3, + "tags": ["Python", "测试"] + } + + response = await client.post( + "/api/v1/courses", + json=course_data, + headers=admin_headers + ) + + assert response.status_code == 201 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "创建课程成功" + assert data["data"]["name"] == course_data["name"] + assert data["data"]["status"] == "draft" + + @pytest.mark.asyncio + async def test_create_course_unauthorized(self, client: AsyncClient, user_headers: dict): + """测试非管理员创建课程失败""" + course_data = { + "name": "测试课程", + "description": "这是一个测试课程" + } + + response = await client.post( + "/api/v1/courses", + json=course_data, + headers=user_headers + ) + + assert response.status_code == 403 + + @pytest.mark.asyncio + async def test_get_courses_list(self, client: AsyncClient, user_headers: dict, db_session: AsyncSession): + """测试获取课程列表""" + # 先创建几个测试课程 + courses = [ + Course( + name=f"测试课程{i}", + description=f"描述{i}", + category=CourseCategory.TECHNOLOGY if i % 2 == 0 else CourseCategory.BUSINESS, + status=CourseStatus.PUBLISHED if i < 2 else CourseStatus.DRAFT, + is_featured=i == 0 + ) + for i in range(3) + ] + + for course in courses: + db_session.add(course) + await db_session.commit() + + # 测试获取所有课程 + response = await client.get( + "/api/v1/courses", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert len(data["data"]["items"]) == 3 + assert data["data"]["total"] == 3 + + # 测试筛选已发布课程 + response = await client.get( + "/api/v1/courses?status=published", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["items"]) == 2 + + # 测试分类筛选 + response = await client.get( + "/api/v1/courses?category=technology", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["items"]) == 2 + + @pytest.mark.asyncio + async def test_get_course_detail(self, client: AsyncClient, user_headers: dict, db_session: AsyncSession): + """测试获取课程详情""" + # 创建测试课程 + course = Course( + name="测试课程详情", + description="详细描述", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.PUBLISHED + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 获取课程详情 + response = await client.get( + f"/api/v1/courses/{course.id}", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["id"] == course.id + assert data["data"]["name"] == course.name + + @pytest.mark.asyncio + async def test_get_course_not_found(self, client: AsyncClient, user_headers: dict): + """测试获取不存在的课程""" + response = await client.get( + "/api/v1/courses/99999", + headers=user_headers + ) + + assert response.status_code == 404 + + @pytest.mark.asyncio + async def test_update_course(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试更新课程""" + # 创建测试课程 + course = Course( + name="原始课程名", + description="原始描述", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.DRAFT + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 更新课程 + update_data = { + "name": "更新后的课程名", + "status": "published" + } + + response = await client.put( + f"/api/v1/courses/{course.id}", + json=update_data, + headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["name"] == update_data["name"] + assert data["data"]["status"] == "published" + assert data["data"]["published_at"] is not None + + @pytest.mark.asyncio + async def test_delete_course(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试删除课程""" + # 创建测试课程 + course = Course( + name="待删除课程", + description="这个课程将被删除", + category=CourseCategory.GENERAL, + status=CourseStatus.DRAFT + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 删除课程 + response = await client.delete( + f"/api/v1/courses/{course.id}", + headers=admin_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"] is True + + # 验证软删除 + deleted_course = await course_service.get_by_id(db_session, course.id) + assert deleted_course is None # 因为get_by_id会过滤掉软删除的记录 + + @pytest.mark.asyncio + async def test_delete_published_course_fail(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试删除已发布课程失败""" + # 创建已发布课程 + course = Course( + name="已发布课程", + description="这是已发布的课程", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.PUBLISHED + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 尝试删除 + response = await client.delete( + f"/api/v1/courses/{course.id}", + headers=admin_headers + ) + + assert response.status_code == 400 + + +class TestKnowledgePointAPI: + """知识点API测试类""" + + @pytest.mark.asyncio + async def test_get_knowledge_points(self, client: AsyncClient, user_headers: dict, db_session: AsyncSession): + """测试获取知识点列表""" + # 创建测试课程 + course = Course( + name="测试课程", + description="包含知识点的课程", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.PUBLISHED + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 获取知识点(应该为空) + response = await client.get( + f"/api/v1/courses/{course.id}/knowledge-points", + headers=user_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"] == [] + + @pytest.mark.asyncio + async def test_create_knowledge_point(self, client: AsyncClient, admin_headers: dict, db_session: AsyncSession): + """测试创建知识点""" + # 创建测试课程 + course = Course( + name="测试课程", + description="用于测试知识点", + category=CourseCategory.TECHNOLOGY, + status=CourseStatus.DRAFT + ) + db_session.add(course) + await db_session.commit() + await db_session.refresh(course) + + # 创建知识点 + point_data = { + "name": "Python基础", + "description": "学习Python基础知识", + "weight": 2.0, + "estimated_hours": 10 + } + + response = await client.post( + f"/api/v1/courses/{course.id}/knowledge-points", + json=point_data, + headers=admin_headers + ) + + assert response.status_code == 201 + data = response.json() + assert data["data"]["name"] == point_data["name"] + assert data["data"]["course_id"] == course.id + assert data["data"]["level"] == 1 + assert data["data"]["parent_id"] is None diff --git a/backend/tests/test_coze_api.py b/backend/tests/test_coze_api.py new file mode 100644 index 0000000..a4dbc3f --- /dev/null +++ b/backend/tests/test_coze_api.py @@ -0,0 +1,306 @@ +""" +Coze API 网关单元测试 +""" + +import pytest +from unittest.mock import Mock, AsyncMock, patch +from fastapi.testclient import TestClient +from fastapi import FastAPI +from sse_starlette.sse import ServerSentEvent + +from app.api.v1.coze_gateway import router +from app.services.ai.coze.models import ( + CreateSessionResponse, EndSessionResponse, + StreamEvent, StreamEventType, ContentType, MessageRole +) +from app.services.ai.coze.exceptions import CozeAPIError, CozeAuthError + + +# 创建测试应用 +app = FastAPI() +app.include_router(router) + +client = TestClient(app) + + +@pytest.fixture +def mock_user(): + """模拟已登录用户""" + with patch("app.api.v1.coze_gateway.get_current_user") as mock_get_user: + mock_get_user.return_value = { + "user_id": "test-user-123", + "username": "test_user" + } + yield mock_get_user + + +@pytest.fixture +def mock_coze_service(): + """模拟 Coze 服务""" + with patch("app.api.v1.coze_gateway.get_coze_service") as mock_get_service: + mock_service = Mock() + mock_get_service.return_value = mock_service + yield mock_service + + +class TestCourseChat: + """测试课程对话 API""" + + def test_create_course_chat_session_success(self, mock_user, mock_coze_service): + """测试成功创建课程对话会话""" + # Mock 服务响应 + mock_coze_service.create_session = AsyncMock( + return_value=CreateSessionResponse( + session_id="session-123", + conversation_id="conv-123", + bot_id="bot-123", + created_at="2024-01-01T10:00:00" + ) + ) + + response = client.post( + "/api/v1/course-chat/sessions", + json={"course_id": "course-456"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "success" + assert data["data"]["session_id"] == "session-123" + assert data["data"]["conversation_id"] == "conv-123" + + def test_create_course_chat_session_auth_error(self, mock_user, mock_coze_service): + """测试认证错误""" + mock_coze_service.create_session = AsyncMock( + side_effect=CozeAuthError( + message="认证失败", + code="AUTH_ERROR", + status_code=401 + ) + ) + + response = client.post( + "/api/v1/course-chat/sessions", + json={"course_id": "course-456"} + ) + + assert response.status_code == 401 + data = response.json() + assert data["detail"]["code"] == "AUTH_ERROR" + assert data["detail"]["message"] == "认证失败" + + def test_create_course_chat_session_server_error(self, mock_user, mock_coze_service): + """测试服务器错误""" + mock_coze_service.create_session = AsyncMock( + side_effect=Exception("Unexpected error") + ) + + response = client.post( + "/api/v1/course-chat/sessions", + json={"course_id": "course-456"} + ) + + assert response.status_code == 500 + data = response.json() + assert data["detail"]["code"] == "INTERNAL_ERROR" + + +class TestTraining: + """测试陪练 API""" + + def test_create_training_session_with_topic(self, mock_user, mock_coze_service): + """测试创建带主题的陪练会话""" + mock_coze_service.create_session = AsyncMock( + return_value=CreateSessionResponse( + session_id="training-123", + conversation_id="conv-456", + bot_id="training-bot", + created_at="2024-01-01T11:00:00" + ) + ) + + response = client.post( + "/api/v1/training/sessions", + json={"training_topic": "客诉处理技巧"} + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["session_id"] == "training-123" + + # 验证服务调用 + call_args = mock_coze_service.create_session.call_args[0][0] + assert call_args.training_topic == "客诉处理技巧" + + def test_create_training_session_without_topic(self, mock_user, mock_coze_service): + """测试创建不带主题的陪练会话""" + mock_coze_service.create_session = AsyncMock( + return_value=CreateSessionResponse( + session_id="training-456", + conversation_id="conv-789", + bot_id="training-bot", + created_at="2024-01-01T12:00:00" + ) + ) + + response = client.post("/api/v1/training/sessions", json={}) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["session_id"] == "training-456" + + def test_end_training_session_success(self, mock_user, mock_coze_service): + """测试成功结束陪练会话""" + mock_coze_service.end_session = AsyncMock( + return_value=EndSessionResponse( + session_id="training-123", + ended_at="2024-01-01T13:00:00", + duration_seconds=1800, + message_count=25 + ) + ) + + response = client.post( + "/api/v1/training/sessions/training-123/end", + json={ + "reason": "练习完成", + "feedback": {"rating": 5, "helpful": True} + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["duration_seconds"] == 1800 + assert data["data"]["message_count"] == 25 + + def test_end_nonexistent_session(self, mock_user, mock_coze_service): + """测试结束不存在的会话""" + mock_coze_service.end_session = AsyncMock( + side_effect=CozeAPIError( + message="会话不存在", + code="SESSION_NOT_FOUND", + status_code=404 + ) + ) + + response = client.post( + "/api/v1/training/sessions/nonexistent/end", + json={} + ) + + assert response.status_code == 404 + + +class TestChatMessages: + """测试消息发送 API""" + + def test_send_message_non_stream(self, mock_user, mock_coze_service): + """测试非流式消息发送""" + # Mock 异步生成器 + async def mock_generator(): + yield StreamEvent( + event=StreamEventType.MESSAGE_DELTA, + data={}, + content="Hello", + content_type=ContentType.TEXT, + role=MessageRole.ASSISTANT + ) + yield StreamEvent( + event=StreamEventType.MESSAGE_COMPLETED, + data={"usage": {"tokens": 10}}, + message_id="msg-123", + content="Hello, how can I help you?", + content_type=ContentType.TEXT, + role=MessageRole.ASSISTANT + ) + yield StreamEvent( + event=StreamEventType.DONE, + data={"session_id": "session-123"} + ) + + mock_coze_service.send_message = AsyncMock(return_value=mock_generator()) + + response = client.post( + "/api/v1/chat/messages", + json={ + "session_id": "session-123", + "content": "Hello", + "stream": False + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["data"]["content"] == "Hello, how can I help you?" + assert data["data"]["content_type"] == "text" + assert data["data"]["role"] == "assistant" + + def test_send_message_with_files(self, mock_user, mock_coze_service): + """测试带附件的消息发送""" + async def mock_generator(): + yield StreamEvent( + event=StreamEventType.MESSAGE_COMPLETED, + data={}, + message_id="msg-456", + content="File received", + content_type=ContentType.TEXT, + role=MessageRole.ASSISTANT + ) + yield StreamEvent( + event=StreamEventType.DONE, + data={"session_id": "session-123"} + ) + + mock_coze_service.send_message = AsyncMock(return_value=mock_generator()) + + response = client.post( + "/api/v1/chat/messages", + json={ + "session_id": "session-123", + "content": "Please analyze this file", + "file_ids": ["file-123", "file-456"], + "stream": False + } + ) + + assert response.status_code == 200 + + # 验证服务调用 + call_args = mock_coze_service.send_message.call_args[0][0] + assert call_args.file_ids == ["file-123", "file-456"] + + def test_get_message_history(self, mock_user, mock_coze_service): + """测试获取消息历史""" + from app.services.ai.coze.models import CozeMessage + + mock_messages = [ + CozeMessage( + message_id="msg-1", + session_id="session-123", + role=MessageRole.USER, + content="Hello" + ), + CozeMessage( + message_id="msg-2", + session_id="session-123", + role=MessageRole.ASSISTANT, + content="Hi there!" + ) + ] + + mock_coze_service.get_session_messages = AsyncMock( + return_value=mock_messages + ) + + response = client.get( + "/api/v1/sessions/session-123/messages?limit=10&offset=0" + ) + + assert response.status_code == 200 + data = response.json() + assert len(data["data"]["messages"]) == 2 + assert data["data"]["messages"][0]["content"] == "Hello" + assert data["data"]["messages"][1]["content"] == "Hi there!" + assert data["data"]["limit"] == 10 + assert data["data"]["offset"] == 0 diff --git a/backend/tests/test_coze_client.py b/backend/tests/test_coze_client.py new file mode 100644 index 0000000..87bafd3 --- /dev/null +++ b/backend/tests/test_coze_client.py @@ -0,0 +1,168 @@ +""" +Coze 客户端单元测试 +""" + +import os +import pytest +from unittest.mock import Mock, patch, MagicMock +from cozepy import Coze, TokenAuth, OAuthJWT + +from app.services.ai.coze.client import ( + CozeAuthManager, get_coze_client, get_bot_config, + get_workspace_id +) +from app.services.ai.coze.exceptions import CozeAuthError + + +class TestCozeAuthManager: + """测试认证管理器""" + + def test_init_with_env_vars(self): + """测试从环境变量初始化""" + with patch.dict(os.environ, { + "COZE_API_BASE": "https://test.coze.cn", + "COZE_WORKSPACE_ID": "test-workspace", + "COZE_API_TOKEN": "test-token" + }): + manager = CozeAuthManager() + assert manager.api_base == "https://test.coze.cn" + assert manager.workspace_id == "test-workspace" + assert manager.api_token == "test-token" + + def test_init_with_params(self): + """测试从参数初始化""" + manager = CozeAuthManager( + api_base="https://custom.coze.cn", + workspace_id="custom-workspace", + api_token="custom-token" + ) + assert manager.api_base == "https://custom.coze.cn" + assert manager.workspace_id == "custom-workspace" + assert manager.api_token == "custom-token" + + def test_setup_direct_connection(self): + """测试直连设置""" + manager = CozeAuthManager() + no_proxy = os.environ.get("NO_PROXY", "") + assert "api.coze.cn" in no_proxy + assert ".coze.cn" in no_proxy + assert "localhost" in no_proxy + + @patch("app.services.ai.coze.client.TokenAuth") + @patch("app.services.ai.coze.client.Coze") + def test_token_auth_success(self, mock_coze_class, mock_token_auth): + """测试 Token 认证成功""" + manager = CozeAuthManager(api_token="test-token") + mock_client = Mock() + mock_coze_class.return_value = mock_client + + client = manager.get_client() + + mock_token_auth.assert_called_once_with("test-token") + mock_coze_class.assert_called_once() + assert client == mock_client + + def test_token_auth_no_token(self): + """测试没有 Token 时的错误""" + manager = CozeAuthManager(api_token=None) + + with pytest.raises(CozeAuthError, match="API Token 未配置"): + manager.get_client() + + @patch("builtins.open", create=True) + @patch("app.services.ai.coze.client.serialization.load_pem_private_key") + @patch("app.services.ai.coze.client.OAuthJWT") + @patch("app.services.ai.coze.client.Coze") + def test_oauth_auth_success(self, mock_coze_class, mock_oauth_jwt, + mock_load_key, mock_open): + """测试 OAuth 认证成功""" + # 模拟私钥文件 + mock_open.return_value.__enter__.return_value.read.return_value = b"fake-private-key" + mock_load_key.return_value = Mock() + + manager = CozeAuthManager( + oauth_client_id="test-client", + oauth_public_key_id="test-key-id", + oauth_private_key_path="/path/to/key.pem" + ) + + mock_client = Mock() + mock_coze_class.return_value = mock_client + + client = manager.get_client() + + mock_oauth_jwt.assert_called_once() + assert client == mock_client + + @patch("builtins.open", side_effect=FileNotFoundError) + @patch("app.services.ai.coze.client.TokenAuth") + @patch("app.services.ai.coze.client.Coze") + def test_oauth_fallback_to_token(self, mock_coze_class, mock_token_auth, mock_open): + """测试 OAuth 失败后回退到 Token""" + manager = CozeAuthManager( + api_token="fallback-token", + oauth_client_id="test-client", + oauth_public_key_id="test-key-id", + oauth_private_key_path="/nonexistent/key.pem" + ) + + mock_client = Mock() + mock_coze_class.return_value = mock_client + + client = manager.get_client() + + # 应该使用 Token 认证 + mock_token_auth.assert_called_once_with("fallback-token") + assert client == mock_client + + def test_refresh_token(self): + """测试刷新令牌""" + manager = CozeAuthManager(api_token="test-token") + + with patch.object(manager, '_init_client') as mock_init: + manager.refresh_token() + assert manager._client is None + mock_init.assert_called_once() + + +class TestHelperFunctions: + """测试辅助函数""" + + def test_get_bot_config(self): + """测试获取 Bot 配置""" + with patch.dict(os.environ, { + "COZE_CHAT_BOT_ID": "chat-bot-123", + "COZE_TRAINING_BOT_ID": "training-bot-456", + "COZE_EXAM_BOT_ID": "exam-bot-789" + }): + config = get_bot_config() + assert config["course_chat"] == "chat-bot-123" + assert config["training"] == "training-bot-456" + assert config["exam"] == "exam-bot-789" + + def test_get_workspace_id_success(self): + """测试获取工作空间 ID 成功""" + with patch.dict(os.environ, {"COZE_WORKSPACE_ID": "workspace-123"}): + workspace_id = get_workspace_id() + assert workspace_id == "workspace-123" + + def test_get_workspace_id_not_configured(self): + """测试工作空间 ID 未配置""" + with patch.dict(os.environ, {}, clear=True): + with pytest.raises(CozeAuthError, match="COZE_WORKSPACE_ID 未配置"): + get_workspace_id() + + @patch("app.services.ai.coze.client.get_auth_manager") + def test_get_coze_client(self, mock_get_auth_manager): + """测试获取 Coze 客户端""" + mock_manager = Mock() + mock_client = Mock() + mock_manager.get_client.return_value = mock_client + mock_get_auth_manager.return_value = mock_manager + + # 清除缓存 + get_coze_client.cache_clear() + + client = get_coze_client() + assert client == mock_client + mock_manager.get_client.assert_called_once() diff --git a/backend/tests/test_coze_service.py b/backend/tests/test_coze_service.py new file mode 100644 index 0000000..11dbe32 --- /dev/null +++ b/backend/tests/test_coze_service.py @@ -0,0 +1,274 @@ +""" +Coze 服务层单元测试 +""" + +import asyncio +import pytest +from datetime import datetime +from unittest.mock import Mock, AsyncMock, patch, MagicMock + +from cozepy import ChatEventType + +from app.services.ai.coze.service import CozeService, get_coze_service +from app.services.ai.coze.models import ( + SessionType, MessageRole, ContentType, StreamEventType, + CreateSessionRequest, SendMessageRequest, EndSessionRequest, + CozeSession, CozeMessage, StreamEvent +) +from app.services.ai.coze.exceptions import CozeAPIError + + +@pytest.fixture +def coze_service(): + """创建测试用的服务实例""" + with patch("app.services.ai.coze.service.get_coze_client"): + service = CozeService() + service.bot_config = { + "course_chat": "chat-bot-id", + "training": "training-bot-id", + "exam": "exam-bot-id" + } + service.workspace_id = "test-workspace" + return service + + +@pytest.mark.asyncio +class TestCozeService: + """测试 Coze 服务""" + + async def test_create_course_chat_session(self, coze_service): + """测试创建课程对话会话""" + # Mock Coze client + mock_conversation = Mock(id="conv-123") + coze_service.client.conversations.create = Mock(return_value=mock_conversation) + + request = CreateSessionRequest( + session_type=SessionType.COURSE_CHAT, + user_id="user-123", + course_id="course-456" + ) + + response = await coze_service.create_session(request) + + # 验证结果 + assert response.conversation_id == "conv-123" + assert response.bot_id == "chat-bot-id" + assert isinstance(response.session_id, str) + assert isinstance(response.created_at, datetime) + + # 验证会话已保存 + session = coze_service._sessions[response.session_id] + assert session.session_type == SessionType.COURSE_CHAT + assert session.user_id == "user-123" + assert session.metadata["course_id"] == "course-456" + + async def test_create_training_session(self, coze_service): + """测试创建陪练会话""" + mock_conversation = Mock(id="conv-456") + coze_service.client.conversations.create = Mock(return_value=mock_conversation) + + request = CreateSessionRequest( + session_type=SessionType.TRAINING, + user_id="user-789", + training_topic="客诉处理" + ) + + response = await coze_service.create_session(request) + + assert response.conversation_id == "conv-456" + assert response.bot_id == "training-bot-id" + + session = coze_service._sessions[response.session_id] + assert session.session_type == SessionType.TRAINING + assert session.metadata["training_topic"] == "客诉处理" + + async def test_send_message_with_stream(self, coze_service): + """测试发送消息(流式响应)""" + # 创建测试会话 + session = CozeSession( + session_id="test-session", + conversation_id="conv-123", + session_type=SessionType.COURSE_CHAT, + user_id="user-123", + bot_id="chat-bot-id" + ) + coze_service._sessions["test-session"] = session + coze_service._messages["test-session"] = [] + + # Mock 流式响应 + mock_events = [ + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_DELTA, + conversation_id="conv-123", + message=Mock(content="Hello ") + ), + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_DELTA, + conversation_id="conv-123", + message=Mock(content="world!") + ), + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_COMPLETED, + conversation_id="conv-123", + message=Mock(content="Hello world!"), + usage={"tokens": 10} + ) + ] + + coze_service.client.chat.stream = Mock(return_value=iter(mock_events)) + + request = SendMessageRequest( + session_id="test-session", + content="Hi there", + stream=True + ) + + # 收集事件 + events = [] + async for event in coze_service.send_message(request): + events.append(event) + + # 验证事件 + assert len(events) == 4 # 2 delta + 1 completed + 1 done + assert events[0].event == StreamEventType.MESSAGE_DELTA + assert events[0].content == "Hello " + assert events[1].event == StreamEventType.MESSAGE_DELTA + assert events[1].content == "world!" + assert events[2].event == StreamEventType.MESSAGE_COMPLETED + assert events[2].content == "Hello world!" + assert events[3].event == StreamEventType.DONE + + # 验证消息已保存 + messages = coze_service._messages["test-session"] + assert len(messages) == 2 # 用户消息 + 助手消息 + assert messages[0].role == MessageRole.USER + assert messages[0].content == "Hi there" + assert messages[1].role == MessageRole.ASSISTANT + assert messages[1].content == "Hello world!" + + async def test_send_message_error_handling(self, coze_service): + """测试发送消息错误处理""" + # 不存在的会话 + request = SendMessageRequest( + session_id="nonexistent", + content="Test" + ) + + with pytest.raises(CozeAPIError, match="会话不存在"): + async for _ in coze_service.send_message(request): + pass + + async def test_end_session(self, coze_service): + """测试结束会话""" + # 创建测试会话和消息 + created_at = datetime.now() + session = CozeSession( + session_id="test-session", + conversation_id="conv-123", + session_type=SessionType.TRAINING, + user_id="user-123", + bot_id="training-bot-id", + created_at=created_at + ) + coze_service._sessions["test-session"] = session + coze_service._messages["test-session"] = [ + Mock(), Mock(), Mock() # 3条消息 + ] + + request = EndSessionRequest( + reason="用户主动结束", + feedback={"rating": 5, "comment": "很有帮助"} + ) + + response = await coze_service.end_session("test-session", request) + + # 验证响应 + assert response.session_id == "test-session" + assert isinstance(response.ended_at, datetime) + assert response.message_count == 3 + assert response.duration_seconds > 0 + + # 验证会话元数据 + assert session.metadata["end_reason"] == "用户主动结束" + assert session.metadata["feedback"]["rating"] == 5 + + async def test_end_nonexistent_session(self, coze_service): + """测试结束不存在的会话""" + request = EndSessionRequest() + + with pytest.raises(CozeAPIError, match="会话不存在"): + await coze_service.end_session("nonexistent", request) + + async def test_get_session_messages(self, coze_service): + """测试获取会话消息历史""" + # 创建测试消息 + messages = [ + CozeMessage( + message_id=f"msg-{i}", + session_id="test-session", + role=MessageRole.USER if i % 2 == 0 else MessageRole.ASSISTANT, + content=f"Message {i}" + ) + for i in range(10) + ] + coze_service._messages["test-session"] = messages + + # 测试分页 + result = await coze_service.get_session_messages("test-session", limit=5, offset=2) + + assert len(result) == 5 + assert result[0].content == "Message 2" + assert result[4].content == "Message 6" + + async def test_stream_with_card_content(self, coze_service): + """测试流式响应中的卡片内容""" + # 创建测试会话 + session = CozeSession( + session_id="test-session", + conversation_id="conv-123", + session_type=SessionType.EXAM, + user_id="user-123", + bot_id="exam-bot-id" + ) + coze_service._sessions["test-session"] = session + coze_service._messages["test-session"] = [] + + # Mock 包含卡片的流式响应 + mock_events = [ + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_DELTA, + conversation_id="conv-123", + message=Mock(content='{"question": "测试题目"}', content_type="card") + ), + Mock( + event=ChatEventType.CONVERSATION_MESSAGE_COMPLETED, + conversation_id="conv-123", + message=Mock(content='{"question": "测试题目"}', content_type="card") + ) + ] + + coze_service.client.chat.stream = Mock(return_value=iter(mock_events)) + + request = SendMessageRequest( + session_id="test-session", + content="生成一道考题" + ) + + events = [] + async for event in coze_service.send_message(request): + events.append(event) + + # 验证卡片类型被正确识别 + assert events[0].content_type == ContentType.CARD + assert events[1].content_type == ContentType.CARD + + # 验证消息保存时的内容类型 + messages = coze_service._messages["test-session"] + assert messages[1].content_type == ContentType.CARD + + +def test_get_coze_service_singleton(): + """测试服务单例""" + service1 = get_coze_service() + service2 = get_coze_service() + assert service1 is service2 diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py new file mode 100644 index 0000000..095acd3 --- /dev/null +++ b/backend/tests/test_main.py @@ -0,0 +1,35 @@ +""" +主应用测试 +""" +import pytest +from fastapi.testclient import TestClient +from app.main import app + +client = TestClient(app) + + +def test_root(): + """测试根路径""" + response = client.get("/") + assert response.status_code == 200 + data = response.json() + assert data["name"] == "考培练系统" + assert data["status"] == "running" + assert "version" in data + assert "timestamp" in data + + +def test_health(): + """测试健康检查端点""" + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} + + +def test_api_health(): + """测试API健康检查""" + response = client.get("/api/v1/health") + assert response.status_code == 200 + data = response.json() + assert data["status"] == "healthy" + assert data["api_version"] == "v1" diff --git a/backend/tests/test_training.py b/backend/tests/test_training.py new file mode 100644 index 0000000..421b6c9 --- /dev/null +++ b/backend/tests/test_training.py @@ -0,0 +1,399 @@ +"""陪练模块测试""" +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.training import TrainingScene, TrainingSession, TrainingSceneStatus +from app.services.training_service import TrainingSceneService, TrainingSessionService + + +class TestTrainingSceneAPI: + """陪练场景API测试""" + + @pytest.mark.asyncio + async def test_get_training_scenes(self, client: AsyncClient, auth_headers: dict): + """测试获取陪练场景列表""" + response = await client.get( + "/api/v1/training/scenes", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "data" in data + assert "items" in data["data"] + assert "total" in data["data"] + assert "page" in data["data"] + assert "page_size" in data["data"] + + @pytest.mark.asyncio + async def test_create_training_scene_admin_only( + self, + client: AsyncClient, + auth_headers: dict, + admin_auth_headers: dict + ): + """测试创建陪练场景(需要管理员权限)""" + scene_data = { + "name": "面试训练", + "description": "模拟面试场景,提升面试技巧", + "category": "面试", + "ai_config": { + "bot_id": "test_bot_id", + "prompt": "你是一位专业的面试官" + }, + "is_public": True + } + + # 普通用户无权限 + response = await client.post( + "/api/v1/training/scenes", + json=scene_data, + headers=auth_headers + ) + assert response.status_code == 403 + + # 管理员可以创建 + # 注意:这里需要mock管理员权限检查 + # 在实际测试中,需要正确设置依赖覆盖 + + @pytest.mark.asyncio + async def test_get_training_scene_detail( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试获取陪练场景详情""" + # 创建测试场景 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + # 获取场景详情 + response = await client.get( + f"/api/v1/training/scenes/{scene.id}", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["data"]["id"] == scene.id + assert data["data"]["name"] == "测试场景" + + @pytest.mark.asyncio + async def test_get_nonexistent_scene(self, client: AsyncClient, auth_headers: dict): + """测试获取不存在的场景""" + response = await client.get( + "/api/v1/training/scenes/99999", + headers=auth_headers + ) + + assert response.status_code == 404 + + +class TestTrainingSessionAPI: + """陪练会话API测试""" + + @pytest.mark.asyncio + async def test_start_training( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试开始陪练""" + # 创建测试场景 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试陪练场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + # 开始陪练 + response = await client.post( + "/api/v1/training/sessions", + json={ + "scene_id": scene.id, + "config": {"key": "value"} + }, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "session_id" in data["data"] + assert data["data"]["scene"]["id"] == scene.id + + @pytest.mark.asyncio + async def test_end_training( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试结束陪练""" + # 创建测试场景和会话 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + session_service = TrainingSessionService() + session = await session_service.create( + db_session, + obj_in={ + "scene_id": scene.id, + "session_config": {} + }, + user_id=1, + created_by=1 + ) + + # 结束陪练 + response = await client.post( + f"/api/v1/training/sessions/{session.id}/end", + json={"generate_report": True}, + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["data"]["session"]["status"] == "completed" + + @pytest.mark.asyncio + async def test_get_user_sessions(self, client: AsyncClient, auth_headers: dict): + """测试获取用户会话列表""" + response = await client.get( + "/api/v1/training/sessions", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "items" in data["data"] + assert isinstance(data["data"]["items"], list) + + @pytest.mark.asyncio + async def test_get_session_messages( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试获取会话消息""" + # 创建测试数据 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + session_service = TrainingSessionService() + session = await session_service.create( + db_session, + obj_in={ + "scene_id": scene.id, + "session_config": {} + }, + user_id=1, + created_by=1 + ) + + # 获取消息 + response = await client.get( + f"/api/v1/training/sessions/{session.id}/messages", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert isinstance(data["data"], list) + + +class TestTrainingReportAPI: + """陪练报告API测试""" + + @pytest.mark.asyncio + async def test_get_user_reports(self, client: AsyncClient, auth_headers: dict): + """测试获取用户报告列表""" + response = await client.get( + "/api/v1/training/reports", + headers=auth_headers + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "items" in data["data"] + assert isinstance(data["data"]["items"], list) + + @pytest.mark.asyncio + async def test_get_report_by_session( + self, + client: AsyncClient, + auth_headers: dict, + db_session: AsyncSession + ): + """测试根据会话ID获取报告""" + # 创建测试数据 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + session_service = TrainingSessionService() + session = await session_service.create( + db_session, + obj_in={ + "scene_id": scene.id, + "session_config": {} + }, + user_id=1, + created_by=1 + ) + + # 获取报告(会话还没有报告) + response = await client.get( + f"/api/v1/training/sessions/{session.id}/report", + headers=auth_headers + ) + + assert response.status_code == 404 + + +class TestTrainingService: + """陪练服务层测试""" + + @pytest.mark.asyncio + async def test_scene_service_crud(self, db_session: AsyncSession): + """测试场景服务的CRUD操作""" + scene_service = TrainingSceneService() + + # 创建 + scene = await scene_service.create_scene( + db_session, + scene_in={ + "name": "演讲训练", + "description": "提升演讲能力", + "category": "演讲", + "status": TrainingSceneStatus.ACTIVE + }, + created_by=1 + ) + + assert scene.id is not None + assert scene.name == "演讲训练" + + # 读取 + retrieved = await scene_service.get(db_session, scene.id) + assert retrieved is not None + assert retrieved.id == scene.id + + # 更新 + updated = await scene_service.update_scene( + db_session, + scene_id=scene.id, + scene_in={"description": "提升公众演讲能力"}, + updated_by=1 + ) + + assert updated is not None + assert updated.description == "提升公众演讲能力" + + # 软删除 + success = await scene_service.soft_delete(db_session, id=scene.id) + assert success is True + + # 验证软删除 + deleted = await scene_service.get(db_session, scene.id) + assert deleted.is_deleted is True + + @pytest.mark.asyncio + async def test_session_lifecycle(self, db_session: AsyncSession): + """测试会话生命周期""" + # 创建场景 + scene_service = TrainingSceneService() + scene = await scene_service.create( + db_session, + obj_in={ + "name": "测试场景", + "category": "测试", + "status": TrainingSceneStatus.ACTIVE, + "is_public": True + }, + created_by=1, + updated_by=1 + ) + + # 开始会话 + session_service = TrainingSessionService() + start_response = await session_service.start_training( + db_session, + request={"scene_id": scene.id}, + user_id=1 + ) + + assert start_response.session_id is not None + + # 结束会话 + end_response = await session_service.end_training( + db_session, + session_id=start_response.session_id, + request={"generate_report": True}, + user_id=1 + ) + + assert end_response.session.status == "completed" + assert end_response.session.duration_seconds is not None + + # 报告应该被生成 + if end_response.report: + assert end_response.report.overall_score > 0 + assert len(end_response.report.strengths) > 0 + assert len(end_response.report.suggestions) > 0 diff --git a/backend/tests/test_user_service.py b/backend/tests/test_user_service.py new file mode 100644 index 0000000..7b6cc12 --- /dev/null +++ b/backend/tests/test_user_service.py @@ -0,0 +1,256 @@ +""" +用户服务测试 +""" + +import pytest +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.exceptions import ConflictError, NotFoundError +from app.core.security import verify_password +from app.models.user import User +from app.schemas.user import UserCreate, UserFilter, UserUpdate +from app.services.user_service import UserService + + +@pytest.mark.asyncio +class TestUserService: + """用户服务测试类""" + + async def test_create_user(self, db_session: AsyncSession): + """测试创建用户""" + # 准备数据 + user_in = UserCreate( + username="newuser", + email="newuser@example.com", + password="password123", + full_name="New User", + role="trainee", + ) + + # 创建用户 + service = UserService(db_session) + user = await service.create_user(obj_in=user_in) + + # 验证结果 + assert user.username == "newuser" + assert user.email == "newuser@example.com" + assert user.full_name == "New User" + assert user.role == "trainee" + assert verify_password("password123", user.hashed_password) + + async def test_create_user_duplicate_username( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试创建重复用户名的用户""" + user_in = UserCreate( + username=test_user.username, # 使用已存在的用户名 + email="another@example.com", + password="password123", + ) + + service = UserService(db_session) + with pytest.raises(ConflictError) as exc_info: + await service.create_user(obj_in=user_in) + + assert f"用户名 {test_user.username} 已存在" in str(exc_info.value) + + async def test_create_user_duplicate_email( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试创建重复邮箱的用户""" + user_in = UserCreate( + username="anotheruser", + email=test_user.email, # 使用已存在的邮箱 + password="password123", + ) + + service = UserService(db_session) + with pytest.raises(ConflictError) as exc_info: + await service.create_user(obj_in=user_in) + + assert f"邮箱 {test_user.email} 已存在" in str(exc_info.value) + + async def test_get_by_username( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试根据用户名获取用户""" + service = UserService(db_session) + user = await service.get_by_username(test_user.username) + + assert user is not None + assert user.id == test_user.id + assert user.username == test_user.username + + async def test_get_by_email( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试根据邮箱获取用户""" + service = UserService(db_session) + user = await service.get_by_email(test_user.email) + + assert user is not None + assert user.id == test_user.id + assert user.email == test_user.email + + async def test_update_user( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试更新用户""" + user_update = UserUpdate( + full_name="Updated Name", + bio="Updated bio", + ) + + service = UserService(db_session) + user = await service.update_user( + user_id=test_user.id, + obj_in=user_update, + ) + + assert user.full_name == "Updated Name" + assert user.bio == "Updated bio" + + async def test_update_user_not_found(self, db_session: AsyncSession): + """测试更新不存在的用户""" + user_update = UserUpdate(full_name="Updated Name") + + service = UserService(db_session) + with pytest.raises(NotFoundError) as exc_info: + await service.update_user( + user_id=999, + obj_in=user_update, + ) + + assert "用户不存在" in str(exc_info.value) + + async def test_update_password( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试更新密码""" + service = UserService(db_session) + + # 更新密码 + user = await service.update_password( + user_id=test_user.id, + old_password="testpass123", + new_password="newpass123", + ) + + # 验证新密码 + assert verify_password("newpass123", user.hashed_password) + assert user.password_changed_at is not None + + async def test_update_password_wrong_old( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试使用错误的旧密码更新""" + service = UserService(db_session) + + with pytest.raises(ConflictError) as exc_info: + await service.update_password( + user_id=test_user.id, + old_password="wrongpass", + new_password="newpass123", + ) + + assert "旧密码错误" in str(exc_info.value) + + async def test_get_users_with_filter( + self, + db_session: AsyncSession, + test_user: User, + admin_user: User, + manager_user: User, + ): + """测试根据筛选条件获取用户""" + service = UserService(db_session) + + # 测试角色筛选 + filter_params = UserFilter(role="admin") + users, total = await service.get_users_with_filter( + skip=0, + limit=10, + filter_params=filter_params, + ) + assert total == 1 + assert users[0].id == admin_user.id + + # 测试关键词搜索 + filter_params = UserFilter(keyword="manager") + users, total = await service.get_users_with_filter( + skip=0, + limit=10, + filter_params=filter_params, + ) + assert total == 1 + assert users[0].id == manager_user.id + + async def test_authenticate_username( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试使用用户名认证""" + service = UserService(db_session) + + # 正确的密码 + user = await service.authenticate( + username=test_user.username, + password="testpass123", + ) + assert user is not None + assert user.id == test_user.id + + # 错误的密码 + user = await service.authenticate( + username=test_user.username, + password="wrongpass", + ) + assert user is None + + async def test_authenticate_email( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试使用邮箱认证""" + service = UserService(db_session) + + user = await service.authenticate( + username=test_user.email, + password="testpass123", + ) + assert user is not None + assert user.id == test_user.id + + async def test_soft_delete( + self, + db_session: AsyncSession, + test_user: User, + ): + """测试软删除用户""" + service = UserService(db_session) + + # 软删除 + user = await service.soft_delete(db_obj=test_user) + assert user.is_deleted is True + assert user.deleted_at is not None + + # 验证无法通过常规方法获取 + user = await service.get_by_id(test_user.id) + assert user is None + diff --git a/backend/tests/unit/__init__.py b/backend/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/unit/test_auth.py b/backend/tests/unit/test_auth.py new file mode 100644 index 0000000..9aafbf1 --- /dev/null +++ b/backend/tests/unit/test_auth.py @@ -0,0 +1,208 @@ +""" +认证模块单元测试 +""" +import pytest +from httpx import AsyncClient +from sqlalchemy.ext.asyncio import AsyncSession + +from app.services.auth_service import AuthService +from app.schemas.auth import UserRegister +from app.core.security import verify_password, create_password_hash +from app.core.exceptions import InvalidCredentialsError, UsernameExistsError + + +@pytest.mark.asyncio +class TestAuthService: + """认证服务测试类""" + + async def test_user_registration(self, db_session: AsyncSession, test_user_data): + """测试用户注册""" + # 创建认证服务 + auth_service = AuthService(db_session) + + # 准备注册数据 + register_data = UserRegister(**test_user_data) + + # 注册用户 + user = await auth_service.create_user(register_data) + + # 验证用户创建成功 + assert user.id is not None + assert user.username == test_user_data["username"] + assert user.email == test_user_data["email"] + assert user.is_active is True + assert user.role == "trainee" + + # 验证密码已加密 + assert user.password_hash != test_user_data["password"] + assert verify_password(test_user_data["password"], user.password_hash) + + async def test_duplicate_username_registration( + self, + db_session: AsyncSession, + test_user_data + ): + """测试重复用户名注册""" + auth_service = AuthService(db_session) + + # 第一次注册 + register_data = UserRegister(**test_user_data) + await auth_service.create_user(register_data) + + # 尝试使用相同用户名再次注册 + with pytest.raises(UsernameExistsError): + await auth_service.create_user(register_data) + + async def test_user_login(self, db_session: AsyncSession, test_user_data): + """测试用户登录""" + auth_service = AuthService(db_session) + + # 先注册用户 + register_data = UserRegister(**test_user_data) + user = await auth_service.create_user(register_data) + + # 测试登录 + authenticated_user = await auth_service.authenticate_user( + username=test_user_data["username"], + password=test_user_data["password"] + ) + + assert authenticated_user.id == user.id + assert authenticated_user.username == user.username + + # 验证登录信息已更新 + assert authenticated_user.login_count == "1" + assert authenticated_user.failed_login_count == "0" + assert authenticated_user.last_login is not None + + async def test_login_with_email(self, db_session: AsyncSession, test_user_data): + """测试使用邮箱登录""" + auth_service = AuthService(db_session) + + # 注册用户 + register_data = UserRegister(**test_user_data) + await auth_service.create_user(register_data) + + # 使用邮箱登录 + user = await auth_service.authenticate_user( + username=test_user_data["email"], + password=test_user_data["password"] + ) + + assert user.email == test_user_data["email"] + + async def test_invalid_password_login( + self, + db_session: AsyncSession, + test_user_data + ): + """测试错误密码登录""" + auth_service = AuthService(db_session) + + # 注册用户 + register_data = UserRegister(**test_user_data) + await auth_service.create_user(register_data) + + # 尝试使用错误密码登录 + with pytest.raises(InvalidCredentialsError): + await auth_service.authenticate_user( + username=test_user_data["username"], + password="WrongPassword123!" + ) + + async def test_token_creation(self, db_session: AsyncSession, test_user_data): + """测试Token创建""" + auth_service = AuthService(db_session) + + # 注册用户 + register_data = UserRegister(**test_user_data) + user = await auth_service.create_user(register_data) + + # 创建tokens + tokens = await auth_service.create_tokens_for_user(user) + + assert "access_token" in tokens + assert "refresh_token" in tokens + assert tokens["token_type"] == "bearer" + assert tokens["expires_in"] > 0 + + +@pytest.mark.asyncio +class TestAuthAPI: + """认证API测试类""" + + async def test_register_endpoint(self, client: AsyncClient, test_user_data): + """测试注册端点""" + response = await client.post( + "/api/v1/auth/register", + json=test_user_data + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert data["message"] == "注册成功" + assert "access_token" in data["data"] + assert "refresh_token" in data["data"] + + async def test_login_endpoint(self, client: AsyncClient, test_user_data): + """测试登录端点""" + # 先注册 + await client.post("/api/v1/auth/register", json=test_user_data) + + # 测试登录 + response = await client.post( + "/api/v1/auth/login", + data={ + "username": test_user_data["username"], + "password": test_user_data["password"] + } + ) + + assert response.status_code == 200 + data = response.json() + assert data["code"] == 200 + assert "access_token" in data["data"] + + async def test_refresh_token_endpoint( + self, + client: AsyncClient, + test_user_data + ): + """测试Token刷新端点""" + # 先注册并获取tokens + register_response = await client.post( + "/api/v1/auth/register", + json=test_user_data + ) + tokens = register_response.json()["data"] + + # 刷新token + response = await client.post( + "/api/v1/auth/refresh", + json={"refresh_token": tokens["refresh_token"]} + ) + + assert response.status_code == 200 + data = response.json() + assert "access_token" in data["data"] + assert data["data"]["access_token"] != tokens["access_token"] + + async def test_logout_endpoint(self, client: AsyncClient): + """测试登出端点""" + response = await client.post("/api/v1/auth/logout") + + assert response.status_code == 200 + data = response.json() + assert data["message"] == "登出成功" + + async def test_reset_password_request(self, client: AsyncClient): + """测试重置密码请求""" + response = await client.post( + "/api/v1/auth/reset-password", + json={"email": "test@example.com"} + ) + + assert response.status_code == 200 + data = response.json() + assert "如果该邮箱已注册" in data["message"] diff --git a/backend/verify_exam_settings.py b/backend/verify_exam_settings.py new file mode 100644 index 0000000..459148d --- /dev/null +++ b/backend/verify_exam_settings.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +验证考试设置功能是否正常工作 +""" +import asyncio +import json +import httpx +from datetime import datetime + +BASE_URL = "http://localhost:8000" + +async def main(): + async with httpx.AsyncClient() as client: + # 1. 登录获取token + print("=== 考试设置功能验证 ===\n") + print("1. 登录管理员账号...") + login_resp = await client.post( + f"{BASE_URL}/api/v1/auth/login", + json={"username": "admin", "password": "Admin123!"} + ) + login_data = login_resp.json() + if login_data['code'] != 200: + print(f"登录失败: {login_data}") + return + + token = login_data["data"]["token"]["access_token"] + headers = {"Authorization": f"Bearer {token}"} + print("✓ 登录成功\n") + + # 2. 获取课程7的当前考试设置 + print("2. 获取课程7的当前考试设置...") + get_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + get_data = get_resp.json() + + if get_data['code'] == 200 and get_data['data']: + print("✓ 成功获取考试设置") + current = get_data['data'] + print(f"\n当前设置:") + print(f" - 单选题: {current['single_choice_count']}题") + print(f" - 多选题: {current['multiple_choice_count']}题") + print(f" - 判断题: {current['true_false_count']}题") + print(f" - 填空题: {current['fill_blank_count']}题") + print(f" - 考试时长: {current['duration_minutes']}分钟") + print(f" - 难度等级: {current['difficulty_level']}") + print(f" - 是否启用: {'是' if current['is_enabled'] else '否'}") + print(f" - 更新时间: {current['updated_at']}") + else: + print(f"✗ 获取失败: {get_data}") + + # 3. 测试更新功能 + print("\n3. 测试更新考试设置...") + test_settings = { + "single_choice_count": 25, + "multiple_choice_count": 12, + "true_false_count": 10, + "fill_blank_count": 6, + "duration_minutes": 120, + "difficulty_level": 5, + "is_enabled": True + } + + update_resp = await client.post( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + json=test_settings, + headers=headers + ) + update_data = update_resp.json() + + if update_data['code'] == 200: + print("✓ 成功更新考试设置") + + # 4. 再次获取验证 + print("\n4. 再次获取验证更新...") + verify_resp = await client.get( + f"{BASE_URL}/api/v1/courses/7/exam-settings", + headers=headers + ) + verify_data = verify_resp.json() + + if verify_data['code'] == 200 and verify_data['data']: + updated = verify_data['data'] + print("\n更新后的设置:") + print(f" - 单选题: {updated['single_choice_count']}题") + print(f" - 多选题: {updated['multiple_choice_count']}题") + print(f" - 判断题: {updated['true_false_count']}题") + print(f" - 填空题: {updated['fill_blank_count']}题") + print(f" - 考试时长: {updated['duration_minutes']}分钟") + print(f" - 难度等级: {updated['difficulty_level']}") + print(f" - 更新时间: {updated['updated_at']}") + + # 验证是否正确更新 + all_correct = ( + updated['single_choice_count'] == test_settings['single_choice_count'] and + updated['multiple_choice_count'] == test_settings['multiple_choice_count'] and + updated['true_false_count'] == test_settings['true_false_count'] and + updated['fill_blank_count'] == test_settings['fill_blank_count'] and + updated['duration_minutes'] == test_settings['duration_minutes'] and + updated['difficulty_level'] == test_settings['difficulty_level'] + ) + + if all_correct: + print("\n✅ 考试设置功能完全正常!数据能够正确保存和读取。") + else: + print("\n❌ 数据更新不正确!") + else: + print(f"✗ 验证失败: {verify_data}") + else: + print(f"✗ 更新失败: {update_data}") + + # 5. 提示前端检查 + print("\n=== 前端检查建议 ===") + print("如果前端仍然显示默认值而不是实际保存的值,请检查:") + print("1. 刷新页面(F5)后是否正确显示") + print("2. 浏览器控制台是否有错误") + print("3. Network面板中API请求是否成功") + print("4. 清除浏览器缓存后重试") + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/backend/团队看板功能验证指南.md b/backend/团队看板功能验证指南.md new file mode 100644 index 0000000..b1388f5 --- /dev/null +++ b/backend/团队看板功能验证指南.md @@ -0,0 +1,224 @@ +# 团队看板功能验证指南 + +## 功能概述 + +团队看板功能已完成真实数据库对接,包括: +- ✅ 团队概览统计(成员数、学习进度、考试成绩、课程完成率) +- ✅ 学习进度图表(Top 5成员8周进度趋势) +- ✅ 课程完成分布饼图(已完成/进行中/未开始) +- ✅ 能力短板雷达图(6个能力维度) +- ✅ 排行榜(学习时长Top 5、成绩Top 5) +- ✅ 团队动态(最近20条活动记录) + +## 权限控制 + +- **管理员/经理(admin/manager)**:查看所有团队数据 +- **普通用户(trainee)**:只查看自己所在团队数据 + +## 快速验证 + +### 1. 启动服务 + +```bash +# 确保数据库和后端服务运行 +cd kaopeilian-backend +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 + +# 启动前端服务 +cd kaopeilian-frontend +npm run dev +``` + +### 2. 前端验证 + +访问:http://localhost:3001/manager/team-dashboard + +**检查项**: +- [ ] 概览卡片显示真实数据(不是硬编码的32、78.5%等) +- [ ] 学习进度图表正确渲染(显示真实成员名称) +- [ ] 课程完成分布饼图显示真实数据 +- [ ] 能力短板雷达图显示真实能力维度 +- [ ] 两个排行榜显示真实成员数据 +- [ ] 团队动态显示最近活动记录 +- [ ] 导出按钮已移除 +- [ ] 页面无控制台错误 + +### 3. 后端API验证 + +#### 方式一:使用测试脚本 + +```bash +cd kaopeilian-backend + +# 修改test_team_dashboard.py中的TOKEN +# 然后运行 +python test_team_dashboard.py +``` + +#### 方式二:使用curl + +```bash +# 替换YOUR_TOKEN为实际token +TOKEN="YOUR_TOKEN" + +# 1. 测试团队概览 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/overview + +# 2. 测试学习进度 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/progress + +# 3. 测试课程分布 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/course-distribution + +# 4. 测试能力分析 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/ability-analysis + +# 5. 测试排行榜 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/rankings + +# 6. 测试团队动态 +curl -H "Authorization: Bearer $TOKEN" \ + http://localhost:8000/api/v1/team/dashboard/activities +``` + +### 4. 权限验证 + +#### 测试管理员权限 +1. 使用admin账号登录 +2. 访问团队看板 +3. 应该能看到所有团队的数据 + +#### 测试普通用户权限 +1. 使用trainee账号登录 +2. 访问团队看板 +3. 应该只看到自己所在团队的数据 + +## 数据说明 + +### 概览统计 + +- **团队成员数**:从user_teams表统计 +- **平均学习进度**:基于考试完成情况计算 +- **平均考试成绩**:使用exams表的round1_score字段 +- **课程完成率**:已完成课程数 / 总课程数 + +### 学习进度图表 + +- 显示学习时长Top 5成员 +- 8周进度趋势(基于考试完成时间) + +### 课程完成分布 + +- **已完成**:有及格成绩(≥60分)的课程 +- **进行中**:有考试记录但未及格的课程 +- **未开始**:总课程数 - 已完成 - 进行中 + +### 能力分析 + +- 从practice_reports表的ability_dimensions JSON字段聚合 +- 平均分<80的能力作为短板 + +### 排行榜 + +- **学习时长**:从practice_sessions聚合duration_seconds +- **成绩排行**:从exams聚合round1_score平均值 +- 只显示Top 5 + +### 团队动态 + +- 最近的考试记录(来自exams表) +- 最近的陪练记录(来自practice_sessions表) +- 按时间倒序,最多20条 + +## 空数据处理 + +如果数据库中没有足够的数据: +- 概览统计会显示0值 +- 图表会显示空状态 +- 排行榜会显示空列表 +- 动态会显示空列表 + +这是正常的,前端会友好地展示空状态。 + +## 常见问题 + +### Q1: 页面显示"加载失败" + +**原因**: +- 后端服务未启动 +- 数据库连接失败 +- Token已过期 + +**解决**: +1. 检查后端服务是否运行:`curl http://localhost:8000/health` +2. 检查数据库连接 +3. 重新登录获取新token + +### Q2: 数据全是0 + +**原因**: +- 数据库中没有相关数据 +- 用户不属于任何团队 + +**解决**: +1. 确认数据库中有teams、user_teams数据 +2. 确认有exams、practice_sessions数据 +3. 使用admin账号登录(可以看到所有团队) + +### Q3: 图表不显示 + +**原因**: +- 浏览器控制台有错误 +- echarts初始化失败 + +**解决**: +1. 打开浏览器控制台查看错误 +2. 刷新页面 +3. 检查网络请求是否成功 + +### Q4: 排行榜为空 + +**原因**: +- 团队成员没有陪练或考试记录 + +**解决**: +- 这是正常的,等待用户完成陪练和考试后会自动显示 + +## 技术实现 + +### 后端文件 +- `kaopeilian-backend/app/api/v1/team_dashboard.py` - API接口 +- 使用SQLAlchemy聚合函数(AVG、SUM、COUNT) +- 支持权限控制(admin/manager查看全部,trainee查看自己团队) + +### 前端文件 +- `kaopeilian-frontend/src/api/teamDashboard.ts` - API封装 +- `kaopeilian-frontend/src/views/manager/team-dashboard.vue` - 页面组件 +- 使用echarts渲染图表 +- 使用request.ts发起API请求 + +## 后续优化建议 + +1. **日期范围筛选**:实现dateRange参数的实际功能 +2. **趋势计算**:实现真实的趋势对比(对比上周/上月) +3. **缓存优化**:使用Redis缓存统计数据 +4. **实时刷新**:添加WebSocket实时更新 +5. **数据导出**:实现Excel/PDF导出功能 + +## 联调记录 + +完成时间:2025-10-XX + +- ✅ 后端6个API接口全部完成 +- ✅ 前端API封装完成 +- ✅ 前端页面改造完成 +- ✅ 无linter错误 +- ✅ 权限控制实现 +- ✅ 导出按钮已移除 +- ⏳ 待数据验证 + diff --git a/backend/将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql b/backend/将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql new file mode 100644 index 0000000..d53d6bd --- /dev/null +++ b/backend/将调用工作流-联调过半-考陪练kaopeilian_final_complete_backup_20250923_025629.sql @@ -0,0 +1,841 @@ +-- MySQL dump 10.13 Distrib 8.0.43, for Linux (aarch64) +-- +-- Host: localhost Database: kaopeilian +-- ------------------------------------------------------ +-- Server version 8.0.43 + +/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */; +/*!40101 SET @OLD_CHARACTER_SET_RESULTS=@@CHARACTER_SET_RESULTS */; +/*!40101 SET @OLD_COLLATION_CONNECTION=@@COLLATION_CONNECTION */; +/*!50503 SET NAMES utf8mb4 */; +/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */; +/*!40103 SET TIME_ZONE='+00:00' */; +/*!40014 SET @OLD_UNIQUE_CHECKS=@@UNIQUE_CHECKS, UNIQUE_CHECKS=0 */; +/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */; +/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */; +/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */; + +-- +-- Current Database: `kaopeilian` +-- + +/*!40000 DROP DATABASE IF EXISTS `kaopeilian`*/; + +CREATE DATABASE /*!32312 IF NOT EXISTS*/ `kaopeilian` /*!40100 DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci */ /*!80016 DEFAULT ENCRYPTION='N' */; + +USE `kaopeilian`; + +-- +-- Table structure for table `course_exam_settings` +-- + +DROP TABLE IF EXISTS `course_exam_settings`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `course_exam_settings` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int NOT NULL COMMENT '课程ID', + `single_choice_count` int NOT NULL DEFAULT '10' COMMENT '单选题数量', + `multiple_choice_count` int NOT NULL DEFAULT '5' COMMENT '多选题数量', + `true_false_count` int NOT NULL DEFAULT '5' COMMENT '判断题数量', + `fill_blank_count` int NOT NULL DEFAULT '0' COMMENT '填空题数量', + `duration_minutes` int NOT NULL DEFAULT '60' COMMENT '考试时长(分钟)', + `difficulty_level` int NOT NULL DEFAULT '3' COMMENT '难度系数(1-5)', + `passing_score` int NOT NULL DEFAULT '60' COMMENT '及格分数', + `is_enabled` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否启用', + `show_answer_immediately` tinyint(1) NOT NULL DEFAULT '0' COMMENT '是否立即显示答案', + `allow_retake` tinyint(1) NOT NULL DEFAULT '1' COMMENT '是否允许重考', + `max_retake_times` int DEFAULT NULL COMMENT '最大重考次数', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `deleted_at` datetime DEFAULT NULL, + `deleted_by` int DEFAULT NULL COMMENT '删除人ID', + PRIMARY KEY (`id`), + UNIQUE KEY `course_id` (`course_id`), + KEY `ix_course_exam_settings_id` (`id`), + CONSTRAINT `course_exam_settings_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `course_exam_settings` +-- + +LOCK TABLES `course_exam_settings` WRITE; +/*!40000 ALTER TABLE `course_exam_settings` DISABLE KEYS */; +INSERT INTO `course_exam_settings` (`id`, `course_id`, `single_choice_count`, `multiple_choice_count`, `true_false_count`, `fill_blank_count`, `duration_minutes`, `difficulty_level`, `passing_score`, `is_enabled`, `show_answer_immediately`, `allow_retake`, `max_retake_times`, `created_at`, `updated_at`, `created_by`, `updated_by`, `is_deleted`, `deleted_at`, `deleted_by`) VALUES (1,1,5,3,2,0,60,2,80,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(2,2,4,2,2,2,45,1,75,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(3,3,3,2,3,2,50,2,80,1,0,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(4,4,4,3,2,1,55,2,85,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(5,5,5,2,2,1,40,1,70,1,1,1,5,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(6,6,3,2,3,2,45,1,75,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(7,7,4,2,2,2,50,2,80,1,0,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(8,8,5,3,2,0,60,3,85,1,1,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(9,9,4,2,4,0,50,2,80,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(10,10,3,2,3,2,45,1,75,1,1,1,3,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(11,11,5,3,2,0,60,3,90,1,0,1,2,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL),(12,12,4,2,4,0,40,1,70,1,1,1,5,'2025-09-23 02:41:33','2025-09-23 02:41:33',1,1,0,NULL,NULL); +/*!40000 ALTER TABLE `course_exam_settings` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `course_materials` +-- + +DROP TABLE IF EXISTS `course_materials`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `course_materials` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int NOT NULL COMMENT '所属课程ID', + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '资料名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '资料描述', + `file_url` varchar(500) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件URL', + `file_type` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '文件类型', + `file_size` int NOT NULL COMMENT '文件大小(字节)', + `sort_order` int DEFAULT '0' COMMENT '排序序号', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_is_deleted` (`is_deleted`), + CONSTRAINT `course_materials_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `course_materials` +-- + +LOCK TABLES `course_materials` WRITE; +/*!40000 ALTER TABLE `course_materials` DISABLE KEYS */; +/*!40000 ALTER TABLE `course_materials` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `courses` +-- + +DROP TABLE IF EXISTS `courses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `courses` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '课程名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '课程描述', + `category` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '课程分类', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'draft' COMMENT '课程状态', + `cover_image` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '封面图片', + `duration_hours` decimal(5,2) DEFAULT NULL COMMENT '课程时长(小时)', + `difficulty_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '难度等级', + `tags` json DEFAULT NULL COMMENT '标签列表', + `published_at` datetime DEFAULT NULL COMMENT '发布时间', + `publisher_id` int DEFAULT NULL COMMENT '发布人ID', + `sort_order` int DEFAULT '0' COMMENT '排序', + `is_featured` tinyint(1) DEFAULT '0' COMMENT '是否推荐', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_category` (`category`), + KEY `idx_is_featured` (`is_featured`), + KEY `idx_is_deleted` (`is_deleted`), + KEY `idx_sort_order` (`sort_order`) +) ENGINE=InnoDB AUTO_INCREMENT=13 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `courses` +-- + +LOCK TABLES `courses` WRITE; +/*!40000 ALTER TABLE `courses` DISABLE KEYS */; +INSERT INTO `courses` (`id`, `name`, `description`, `category`, `status`, `cover_image`, `duration_hours`, `difficulty_level`, `tags`, `published_at`, `publisher_id`, `sort_order`, `is_featured`, `is_deleted`, `deleted_at`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (1,'皮肤生理学基础','学习皮肤结构、功能和常见问题,为专业护理打下坚实基础','technology','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(2,'医美产品知识与应用','全面了解各类医美产品的成分、功效和适用人群,掌握产品推荐技巧','technology','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(3,'美容仪器操作与维护','掌握各类美容仪器的操作方法、注意事项和日常维护保养','technology','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(4,'医美项目介绍与咨询','详细了解各类医美项目的原理、效果和适应症,提升咨询专业度','business','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(5,'轻医美销售技巧','学习专业的销售话术、客户需求分析和成交技巧,提升业绩能力','business','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(6,'客户服务与投诉处理','提升服务意识,掌握客户投诉处理的方法和技巧,维护品牌形象','management','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(7,'社媒营销与私域运营','学习如何通过社交媒体进行品牌推广和客户维护,建立私域流量','business','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(8,'门店运营管理','学习门店日常管理、团队建设和业绩管理的方法和技巧','management','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(9,'卫生消毒与感染控制','学习医美机构的卫生标准和消毒流程,确保服务安全合规','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(10,'美容心理学','了解客户心理需求,掌握沟通技巧,提升服务满意度','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(11,'法律法规与行业规范','学习医美行业相关法律法规,确保合规经营','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(12,'新员工入职培训','新员工必修课程,包含企业文化、基础知识和操作规范','general','published',NULL,NULL,NULL,NULL,NULL,NULL,0,0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'); +/*!40000 ALTER TABLE `courses` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `exam_results` +-- + +DROP TABLE IF EXISTS `exam_results`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `exam_results` ( + `id` int NOT NULL AUTO_INCREMENT, + `exam_id` int NOT NULL COMMENT '考试ID', + `question_id` int NOT NULL COMMENT '题目ID', + `user_answer` json DEFAULT NULL COMMENT '用户答案', + `is_correct` tinyint(1) NOT NULL COMMENT '是否正确', + `score` decimal(5,2) NOT NULL COMMENT '考试得分', + `answer_time` int DEFAULT NULL COMMENT '答题时长(秒)', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_exam_id` (`exam_id`), + KEY `idx_question_id` (`question_id`), + CONSTRAINT `exam_results_ibfk_1` FOREIGN KEY (`exam_id`) REFERENCES `exams` (`id`) ON DELETE CASCADE, + CONSTRAINT `exam_results_ibfk_2` FOREIGN KEY (`question_id`) REFERENCES `questions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `exam_results` +-- + +LOCK TABLES `exam_results` WRITE; +/*!40000 ALTER TABLE `exam_results` DISABLE KEYS */; +/*!40000 ALTER TABLE `exam_results` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `exams` +-- + +DROP TABLE IF EXISTS `exams`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `exams` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL COMMENT '用户ID', + `course_id` int NOT NULL COMMENT '课程ID', + `exam_name` varchar(200) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '考试名称', + `question_count` int DEFAULT NULL COMMENT '题目数量', + `total_score` decimal(5,2) DEFAULT NULL COMMENT '总分', + `pass_score` decimal(5,2) DEFAULT NULL COMMENT '及格分', + `start_time` datetime DEFAULT NULL COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `duration_minutes` int DEFAULT NULL COMMENT '考试时长(分钟)', + `score` decimal(5,2) DEFAULT NULL COMMENT '得分', + `is_passed` tinyint(1) DEFAULT '0' COMMENT '是否通过', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'pending' COMMENT '考试状态', + `questions` json DEFAULT NULL COMMENT '题目数据(JSON格式)', + `answers` json DEFAULT NULL COMMENT '答案数据(JSON格式)', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_status` (`status`), + CONSTRAINT `exams_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `exams_ibfk_2` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `exams` +-- + +LOCK TABLES `exams` WRITE; +/*!40000 ALTER TABLE `exams` DISABLE KEYS */; +/*!40000 ALTER TABLE `exams` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `growth_paths` +-- + +DROP TABLE IF EXISTS `growth_paths`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `growth_paths` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '成长路径名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '路径描述', + `target_role` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '目标角色', + `courses` json DEFAULT NULL COMMENT '课程列表', + `estimated_duration_days` int DEFAULT NULL COMMENT '预计完成天数', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否启用', + `sort_order` int DEFAULT '0' COMMENT '排序顺序', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_is_active` (`is_active`), + KEY `idx_is_deleted` (`is_deleted`), + KEY `idx_sort_order` (`sort_order`) +) ENGINE=InnoDB AUTO_INCREMENT=5 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `growth_paths` +-- + +LOCK TABLES `growth_paths` WRITE; +/*!40000 ALTER TABLE `growth_paths` DISABLE KEYS */; +INSERT INTO `growth_paths` (`id`, `name`, `description`, `target_role`, `courses`, `estimated_duration_days`, `is_active`, `sort_order`, `is_deleted`, `deleted_at`, `created_at`, `updated_at`) VALUES (1,'美容顾问成长路径','从初级美容顾问到资深顾问的完整成长路径,包含专业技能和销售能力提升','资深美容顾问',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(2,'美容技师进阶路径','美容技师的技能进阶路径,从基础护理到高级项目操作','高级美容技师',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(3,'管理岗位培养路径','从一线员工到管理岗位的培养路径,包含领导力和管理技能','店长/区域经理',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(4,'医美咨询师专业路径','医美咨询师的专业发展路径,深度掌握医美项目知识','资深医美咨询师',NULL,NULL,1,0,0,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'); +/*!40000 ALTER TABLE `growth_paths` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `knowledge_points` +-- + +DROP TABLE IF EXISTS `knowledge_points`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `knowledge_points` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int DEFAULT NULL COMMENT '所属课程ID', + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '知识点名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '知识点描述', + `parent_id` int DEFAULT NULL COMMENT '父知识点ID', + `level` int DEFAULT '1' COMMENT '层级深度', + `path` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '知识点路径', + `sort_order` int DEFAULT '0' COMMENT '排序顺序', + `weight` decimal(3,2) DEFAULT '1.00' COMMENT '权重', + `is_required` tinyint(1) DEFAULT '1' COMMENT '是否必修', + `estimated_hours` decimal(4,2) DEFAULT NULL COMMENT '预计学习时间(小时)', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_parent_id` (`parent_id`), + KEY `idx_is_deleted` (`is_deleted`), + KEY `idx_sort_order` (`sort_order`), + CONSTRAINT `knowledge_points_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE, + CONSTRAINT `knowledge_points_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `knowledge_points` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=16 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `knowledge_points` +-- + +LOCK TABLES `knowledge_points` WRITE; +/*!40000 ALTER TABLE `knowledge_points` DISABLE KEYS */; +/*!40000 ALTER TABLE `knowledge_points` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `material_knowledge_points` +-- + +DROP TABLE IF EXISTS `material_knowledge_points`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `material_knowledge_points` ( + `id` int NOT NULL AUTO_INCREMENT, + `material_id` int NOT NULL COMMENT '资料ID', + `knowledge_point_id` int NOT NULL COMMENT '知识点ID', + `sort_order` int DEFAULT '0' COMMENT '排序顺序', + `is_ai_generated` tinyint(1) DEFAULT '0' COMMENT '是否AI生成', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `idx_material_knowledge` (`material_id`,`knowledge_point_id`), + KEY `idx_material_id` (`material_id`), + KEY `idx_knowledge_point_id` (`knowledge_point_id`), + CONSTRAINT `material_knowledge_points_ibfk_1` FOREIGN KEY (`material_id`) REFERENCES `course_materials` (`id`) ON DELETE CASCADE, + CONSTRAINT `material_knowledge_points_ibfk_2` FOREIGN KEY (`knowledge_point_id`) REFERENCES `knowledge_points` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='资料知识点关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `material_knowledge_points` +-- + +LOCK TABLES `material_knowledge_points` WRITE; +/*!40000 ALTER TABLE `material_knowledge_points` DISABLE KEYS */; +/*!40000 ALTER TABLE `material_knowledge_points` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `position_courses` +-- + +DROP TABLE IF EXISTS `position_courses`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `position_courses` ( + `id` int NOT NULL AUTO_INCREMENT, + `position_id` int NOT NULL COMMENT '岗位ID', + `course_id` int NOT NULL COMMENT '课程ID', + `course_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'required' COMMENT '课程类型(必修/选修)', + `priority` int DEFAULT '0' COMMENT '优先级', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uix_position_course` (`position_id`,`course_id`,`is_deleted`), + KEY `course_id` (`course_id`), + KEY `ix_position_courses_id` (`id`), + CONSTRAINT `position_courses_ibfk_1` FOREIGN KEY (`position_id`) REFERENCES `positions` (`id`), + CONSTRAINT `position_courses_ibfk_2` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=28 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `position_courses` +-- + +LOCK TABLES `position_courses` WRITE; +/*!40000 ALTER TABLE `position_courses` DISABLE KEYS */; +INSERT INTO `position_courses` (`id`, `position_id`, `course_id`, `course_type`, `priority`, `created_at`, `updated_at`, `created_by`, `updated_by`, `is_deleted`, `deleted_at`) VALUES (1,1,6,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(2,1,8,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(3,1,11,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(4,1,12,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(5,3,1,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(6,3,2,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(7,3,5,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(8,3,6,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(9,3,10,'required',5,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(10,3,12,'required',6,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(11,4,1,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(12,4,2,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(13,4,4,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(14,4,5,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(15,4,9,'required',5,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(16,4,12,'required',6,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(17,5,1,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(18,5,2,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(19,5,3,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(20,5,9,'required',4,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(21,5,12,'required',5,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(22,7,6,'required',1,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(23,7,10,'required',2,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(24,7,12,'required',3,'2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL); +/*!40000 ALTER TABLE `position_courses` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `position_members` +-- + +DROP TABLE IF EXISTS `position_members`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `position_members` ( + `id` int NOT NULL AUTO_INCREMENT, + `position_id` int NOT NULL COMMENT '岗位ID', + `user_id` int NOT NULL COMMENT '用户ID', + `role` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '在岗位中的角色', + `joined_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + `created_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `is_deleted` tinyint(1) NOT NULL DEFAULT '0', + `deleted_at` datetime DEFAULT NULL, + PRIMARY KEY (`id`), + UNIQUE KEY `uix_position_user` (`position_id`,`user_id`,`is_deleted`), + KEY `user_id` (`user_id`), + KEY `ix_position_members_id` (`id`), + CONSTRAINT `position_members_ibfk_1` FOREIGN KEY (`position_id`) REFERENCES `positions` (`id`), + CONSTRAINT `position_members_ibfk_2` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `position_members` +-- + +LOCK TABLES `position_members` WRITE; +/*!40000 ALTER TABLE `position_members` DISABLE KEYS */; +INSERT INTO `position_members` (`id`, `position_id`, `user_id`, `role`, `joined_at`, `created_at`, `updated_at`, `created_by`, `updated_by`, `is_deleted`, `deleted_at`) VALUES (1,1,3,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(2,1,4,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(3,3,5,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(4,4,6,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(5,5,7,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL),(6,7,8,NULL,'2025-09-22 18:42:32','2025-09-23 02:42:32','2025-09-23 02:42:32',NULL,NULL,0,NULL); +/*!40000 ALTER TABLE `position_members` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `positions` +-- + +DROP TABLE IF EXISTS `positions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `positions` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '岗位名称', + `code` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '岗位代码', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '岗位描述', + `parent_id` int DEFAULT NULL COMMENT '上级岗位ID', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'active' COMMENT '岗位状态', + `skills` json DEFAULT NULL COMMENT '核心技能', + `level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '岗位级别', + `sort_order` int DEFAULT '0' COMMENT '排序', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `code` (`code`), + KEY `parent_id` (`parent_id`), + KEY `idx_positions_name` (`name`), + CONSTRAINT `positions_ibfk_1` FOREIGN KEY (`parent_id`) REFERENCES `positions` (`id`) ON DELETE SET NULL +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `positions` +-- + +LOCK TABLES `positions` WRITE; +/*!40000 ALTER TABLE `positions` DISABLE KEYS */; +INSERT INTO `positions` (`id`, `name`, `code`, `description`, `parent_id`, `status`, `skills`, `level`, `sort_order`, `is_deleted`, `deleted_at`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (1,'区域经理','region_manager','负责多家门店的运营管理和业绩达成,制定区域发展战略',NULL,'active',NULL,'expert',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(2,'店长','store_manager','负责门店日常运营管理,团队建设和业绩达成',NULL,'active',NULL,'senior',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(3,'美容顾问','beauty_consultant','为客户提供专业的美容咨询和个性化方案设计',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(4,'医美咨询师','medical_beauty_consultant','提供医疗美容项目咨询和专业方案制定',NULL,'active',NULL,'senior',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(5,'美容技师','beauty_therapist','为客户提供专业的美容护理和技术服务',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(6,'美容护士','beauty_nurse','协助医生进行医美项目操作,负责术后护理指导',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(7,'前台接待','receptionist','负责客户接待、预约管理和前台事务处理',NULL,'active',NULL,'junior',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'),(8,'市场专员','marketing_specialist','负责门店营销活动策划、执行和客户维护',NULL,'active',NULL,'intermediate',0,0,NULL,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24'); +/*!40000 ALTER TABLE `positions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `questions` +-- + +DROP TABLE IF EXISTS `questions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `questions` ( + `id` int NOT NULL AUTO_INCREMENT, + `course_id` int NOT NULL COMMENT '所属课程ID', + `question_type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '题目类型', + `title` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '题目标题', + `content` text COLLATE utf8mb4_unicode_ci COMMENT '题目内容', + `options` json DEFAULT NULL COMMENT '选项内容', + `correct_answer` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '正确答案', + `explanation` text COLLATE utf8mb4_unicode_ci COMMENT '答案解析', + `score` float DEFAULT '10' COMMENT '题目分值', + `difficulty` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT 'medium' COMMENT '难度等级', + `tags` json DEFAULT NULL COMMENT '题目标签', + `usage_count` int DEFAULT '0' COMMENT '使用次数', + `correct_count` int DEFAULT '0' COMMENT '答对次数', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否启用', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_course_id` (`course_id`), + KEY `idx_question_type` (`question_type`), + KEY `idx_difficulty` (`difficulty`), + KEY `idx_is_active` (`is_active`), + CONSTRAINT `questions_ibfk_1` FOREIGN KEY (`course_id`) REFERENCES `courses` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `questions` +-- + +LOCK TABLES `questions` WRITE; +/*!40000 ALTER TABLE `questions` DISABLE KEYS */; +INSERT INTO `questions` (`id`, `course_id`, `question_type`, `title`, `content`, `options`, `correct_answer`, `explanation`, `score`, `difficulty`, `tags`, `usage_count`, `correct_count`, `is_active`, `created_at`, `updated_at`) VALUES (1,1,'single_choice','皮肤的最外层是什么?',NULL,'[{\"text\": \"表皮\", \"label\": \"A\"}, {\"text\": \"真皮\", \"label\": \"B\"}, {\"text\": \"皮下组织\", \"label\": \"C\"}, {\"text\": \"角质层\", \"label\": \"D\"}]','A','A选项是表皮,这是皮肤的最外层,起到保护作用',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(2,2,'single_choice','透明质酸的主要功效是什么?',NULL,'[{\"text\": \"美白淡斑\", \"label\": \"A\"}, {\"text\": \"保湿补水\", \"label\": \"B\"}, {\"text\": \"紧致提升\", \"label\": \"C\"}, {\"text\": \"去除皱纹\", \"label\": \"D\"}]','B','B选项是保湿补水,透明质酸具有强大的保湿能力',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(3,3,'multiple_choice','射频美容仪的禁忌症包括哪些?',NULL,'[{\"text\": \"孕期和哺乳期\", \"label\": \"A\"}, {\"text\": \"皮肤过敏\", \"label\": \"B\"}, {\"text\": \"心脏起搏器\", \"label\": \"C\"}, {\"text\": \"轻微痤疮\", \"label\": \"D\"}]','A,C','A和C选项都是射频美容仪的禁忌症,需要特别注意',15,'medium',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(4,4,'true_false','水光针适合所有肤质的客户',NULL,'[{\"text\": \"正确\", \"label\": \"A\"}, {\"text\": \"错误\", \"label\": \"B\"}]','B','水光针虽然适用范围广,但仍有一些禁忌症和不适合的肤质',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(5,4,'fill_blank','肉毒素注射后____小时内不能平躺','肉毒素注射后【】小时内不能平躺,以免影响药物分布效果。',NULL,'4','肉毒素注射后4小时内不能平躺,以免影响药物分布',10,'medium',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(6,5,'single_choice','客户咨询时最重要的是什么?',NULL,'[{\"text\": \"倾听客户需求\", \"label\": \"A\"}, {\"text\": \"推荐最贵的产品\", \"label\": \"B\"}, {\"text\": \"快速成交\", \"label\": \"C\"}, {\"text\": \"展示专业知识\", \"label\": \"D\"}]','A','A选项是倾听客户需求,这是专业咨询的基础',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(7,6,'single_choice','处理客户投诉的第一步是什么?',NULL,'[{\"text\": \"解释原因\", \"label\": \"A\"}, {\"text\": \"耐心倾听\", \"label\": \"B\"}, {\"text\": \"提供补偿\", \"label\": \"C\"}, {\"text\": \"转交上级\", \"label\": \"D\"}]','B','B选项是耐心倾听,让客户充分表达不满是处理投诉的第一步',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(8,7,'true_false','社交媒体营销只需要发布产品信息',NULL,'[{\"text\": \"正确\", \"label\": \"A\"}, {\"text\": \"错误\", \"label\": \"B\"}]','B','社交媒体营销需要内容多样化,包括教育内容、互动内容等',10,'easy',NULL,0,0,1,'2025-09-23 02:39:48','2025-09-23 02:39:48'); +/*!40000 ALTER TABLE `questions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `teams` +-- + +DROP TABLE IF EXISTS `teams`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `teams` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '团队名称', + `code` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '团队代码', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '团队描述', + `team_type` varchar(50) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '团队类型', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否激活', + `leader_id` int DEFAULT NULL COMMENT '负责人ID', + `parent_id` int DEFAULT NULL COMMENT '父团队ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + PRIMARY KEY (`id`), + UNIQUE KEY `name` (`name`), + UNIQUE KEY `code` (`code`), + KEY `leader_id` (`leader_id`), + KEY `parent_id` (`parent_id`), + KEY `idx_team_type` (`team_type`), + KEY `idx_is_active` (`is_active`), + CONSTRAINT `teams_ibfk_1` FOREIGN KEY (`leader_id`) REFERENCES `users` (`id`) ON DELETE SET NULL, + CONSTRAINT `teams_ibfk_2` FOREIGN KEY (`parent_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB AUTO_INCREMENT=7 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `teams` +-- + +LOCK TABLES `teams` WRITE; +/*!40000 ALTER TABLE `teams` DISABLE KEYS */; +INSERT INTO `teams` (`id`, `name`, `code`, `description`, `team_type`, `is_active`, `leader_id`, `parent_id`, `created_at`, `updated_at`, `is_deleted`, `deleted_at`) VALUES (1,'管理层','MANAGEMENT','负责公司整体战略规划和运营管理','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(2,'北京运营团队','BJ_OPERATIONS','负责北京地区所有门店的运营管理','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(3,'上海运营团队','SH_OPERATIONS','负责上海地区所有门店的运营管理','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(4,'技术培训部','TECH_TRAINING','负责员工技术培训和考核','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(5,'客服质量部','QUALITY_SERVICE','负责客户服务质量监督和改进','department',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(6,'新员工培训小组','NEW_EMPLOYEE_TRAINING','专门负责新员工入职培训','study_group',1,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL); +/*!40000 ALTER TABLE `teams` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_messages` +-- + +DROP TABLE IF EXISTS `training_messages`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_messages` ( + `id` int NOT NULL AUTO_INCREMENT, + `session_id` int NOT NULL COMMENT '会话ID', + `role` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息角色', + `type` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息类型', + `content` text COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '消息内容', + `voice_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '语音文件URL', + `voice_duration` int DEFAULT NULL COMMENT '语音时长(秒)', + `message_metadata` json DEFAULT NULL COMMENT '消息元数据', + `coze_message_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Coze消息ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_session_id` (`session_id`), + KEY `idx_role` (`role`), + CONSTRAINT `training_messages_ibfk_1` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训消息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_messages` +-- + +LOCK TABLES `training_messages` WRITE; +/*!40000 ALTER TABLE `training_messages` DISABLE KEYS */; +/*!40000 ALTER TABLE `training_messages` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_reports` +-- + +DROP TABLE IF EXISTS `training_reports`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_reports` ( + `id` int NOT NULL AUTO_INCREMENT, + `session_id` int NOT NULL COMMENT '会话ID', + `user_id` int NOT NULL COMMENT '用户ID', + `overall_score` decimal(5,2) DEFAULT NULL COMMENT '总体评分', + `dimension_scores` json DEFAULT NULL COMMENT '各维度得分', + `strengths` text COLLATE utf8mb4_unicode_ci COMMENT '优势点', + `weaknesses` text COLLATE utf8mb4_unicode_ci COMMENT '待改进点', + `suggestions` text COLLATE utf8mb4_unicode_ci COMMENT '改进建议', + `detailed_analysis` text COLLATE utf8mb4_unicode_ci COMMENT '详细分析', + `transcript` text COLLATE utf8mb4_unicode_ci COMMENT '对话记录', + `statistics` json DEFAULT NULL COMMENT '统计数据', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `session_id` (`session_id`), + KEY `idx_user_id` (`user_id`), + CONSTRAINT `training_reports_ibfk_1` FOREIGN KEY (`session_id`) REFERENCES `training_sessions` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训报告表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_reports` +-- + +LOCK TABLES `training_reports` WRITE; +/*!40000 ALTER TABLE `training_reports` DISABLE KEYS */; +/*!40000 ALTER TABLE `training_reports` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_scenes` +-- + +DROP TABLE IF EXISTS `training_scenes`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_scenes` ( + `id` int NOT NULL AUTO_INCREMENT, + `name` varchar(200) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '场景名称', + `description` text COLLATE utf8mb4_unicode_ci COMMENT '场景描述', + `category` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '场景分类', + `ai_config` json DEFAULT NULL COMMENT 'AI配置', + `prompt_template` text COLLATE utf8mb4_unicode_ci COMMENT '提示模板', + `evaluation_criteria` json DEFAULT NULL COMMENT '评估标准', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'active' COMMENT '场景状态', + `is_public` tinyint(1) DEFAULT '1' COMMENT '是否公开', + `required_level` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '所需用户等级', + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_status` (`status`), + KEY `idx_category` (`category`), + KEY `idx_is_public` (`is_public`), + KEY `idx_is_deleted` (`is_deleted`) +) ENGINE=InnoDB AUTO_INCREMENT=6 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训场景表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_scenes` +-- + +LOCK TABLES `training_scenes` WRITE; +/*!40000 ALTER TABLE `training_scenes` DISABLE KEYS */; +INSERT INTO `training_scenes` (`id`, `name`, `description`, `category`, `ai_config`, `prompt_template`, `evaluation_criteria`, `status`, `is_public`, `required_level`, `is_deleted`, `deleted_at`, `created_by`, `updated_by`, `created_at`, `updated_at`) VALUES (1,'客户咨询模拟','模拟真实的客户咨询场景,练习专业咨询技巧','客户服务',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(2,'产品推荐演练','针对不同客户需求进行产品推荐的实战演练','销售技巧',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(3,'投诉处理训练','模拟各种客户投诉情况,训练处理技巧和话术','客户服务',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(4,'项目操作指导','美容项目操作的标准流程指导和安全注意事项','技术指导',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'),(5,'团队管理讨论','管理岗位的团队建设和绩效管理讨论','管理培训',NULL,NULL,NULL,'ACTIVE',1,NULL,0,NULL,NULL,NULL,'2025-09-23 02:39:48','2025-09-23 02:39:48'); +/*!40000 ALTER TABLE `training_scenes` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `training_sessions` +-- + +DROP TABLE IF EXISTS `training_sessions`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `training_sessions` ( + `id` int NOT NULL AUTO_INCREMENT, + `user_id` int NOT NULL COMMENT '用户ID', + `scene_id` int NOT NULL COMMENT '场景ID', + `coze_conversation_id` varchar(255) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT 'Coze对话ID', + `start_time` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` datetime DEFAULT NULL COMMENT '结束时间', + `duration_seconds` int DEFAULT NULL COMMENT '持续时长(秒)', + `status` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT 'active' COMMENT '会话状态', + `session_config` json DEFAULT NULL COMMENT '会话配置', + `total_score` decimal(5,2) DEFAULT NULL COMMENT '总分', + `evaluation_result` json DEFAULT NULL COMMENT '评估结果详情', + `created_by` int DEFAULT NULL COMMENT '创建人ID', + `updated_by` int DEFAULT NULL COMMENT '更新人ID', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_user_id` (`user_id`), + KEY `idx_scene_id` (`scene_id`), + KEY `idx_status` (`status`), + CONSTRAINT `training_sessions_ibfk_1` FOREIGN KEY (`scene_id`) REFERENCES `training_scenes` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='培训会话表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `training_sessions` +-- + +LOCK TABLES `training_sessions` WRITE; +/*!40000 ALTER TABLE `training_sessions` DISABLE KEYS */; +/*!40000 ALTER TABLE `training_sessions` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `user_teams` +-- + +DROP TABLE IF EXISTS `user_teams`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `user_teams` ( + `user_id` int NOT NULL COMMENT '用户ID', + `team_id` int NOT NULL COMMENT '团队ID', + `role` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '在团队中的角色', + `joined_at` datetime DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (`user_id`,`team_id`), + KEY `team_id` (`team_id`), + CONSTRAINT `user_teams_ibfk_1` FOREIGN KEY (`user_id`) REFERENCES `users` (`id`) ON DELETE CASCADE, + CONSTRAINT `user_teams_ibfk_2` FOREIGN KEY (`team_id`) REFERENCES `teams` (`id`) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `user_teams` +-- + +LOCK TABLES `user_teams` WRITE; +/*!40000 ALTER TABLE `user_teams` DISABLE KEYS */; +INSERT INTO `user_teams` (`user_id`, `team_id`, `role`, `joined_at`) VALUES (1,1,'leader','2025-09-22 18:42:32'),(2,1,'member','2025-09-22 18:42:32'),(3,2,'leader','2025-09-22 18:42:32'),(4,3,'leader','2025-09-22 18:42:32'),(5,2,'member','2025-09-22 18:42:32'),(5,4,'member','2025-09-22 18:42:32'),(6,2,'member','2025-09-22 18:42:32'),(6,4,'member','2025-09-22 18:42:32'),(7,3,'member','2025-09-22 18:42:32'),(7,4,'member','2025-09-22 18:42:32'),(8,3,'member','2025-09-22 18:42:32'),(8,6,'member','2025-09-22 18:42:32'); +/*!40000 ALTER TABLE `user_teams` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Table structure for table `users` +-- + +DROP TABLE IF EXISTS `users`; +/*!40101 SET @saved_cs_client = @@character_set_client */; +/*!50503 SET character_set_client = utf8mb4 */; +CREATE TABLE `users` ( + `id` int NOT NULL AUTO_INCREMENT, + `username` varchar(50) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '用户名', + `email` varchar(100) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '邮箱', + `phone` varchar(20) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '手机号', + `password_hash` varchar(255) COLLATE utf8mb4_unicode_ci NOT NULL COMMENT '密码哈希', + `full_name` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '真实姓名', + `gender` varchar(10) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '性别', + `avatar_url` varchar(500) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '头像URL', + `bio` text COLLATE utf8mb4_unicode_ci COMMENT '个人简介', + `school` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '学校', + `major` varchar(100) COLLATE utf8mb4_unicode_ci DEFAULT NULL COMMENT '专业', + `role` varchar(20) COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT 'trainee' COMMENT '用户角色', + `is_active` tinyint(1) DEFAULT '1' COMMENT '是否激活', + `is_verified` tinyint(1) DEFAULT '0' COMMENT '是否验证', + `last_login_at` datetime DEFAULT NULL COMMENT '最后登录时间', + `password_changed_at` datetime DEFAULT NULL COMMENT '密码修改时间', + `created_at` datetime DEFAULT CURRENT_TIMESTAMP, + `updated_at` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` tinyint(1) DEFAULT '0' COMMENT '是否删除', + `deleted_at` datetime DEFAULT NULL COMMENT '删除时间', + PRIMARY KEY (`id`), + UNIQUE KEY `username` (`username`), + UNIQUE KEY `email` (`email`), + UNIQUE KEY `phone` (`phone`), + KEY `idx_role` (`role`), + KEY `idx_is_active` (`is_active`) +) ENGINE=InnoDB AUTO_INCREMENT=9 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户信息表'; +/*!40101 SET character_set_client = @saved_cs_client */; + +-- +-- Dumping data for table `users` +-- + +LOCK TABLES `users` WRITE; +/*!40000 ALTER TABLE `users` DISABLE KEYS */; +INSERT INTO `users` (`id`, `username`, `email`, `phone`, `password_hash`, `full_name`, `gender`, `avatar_url`, `bio`, `school`, `major`, `role`, `is_active`, `is_verified`, `last_login_at`, `password_changed_at`, `created_at`, `updated_at`, `is_deleted`, `deleted_at`) VALUES (1,'superadmin','superadmin@ruimei.com','13800138001','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','超级管理员','male',NULL,'负责系统整体管理和运营','睿美医疗美容学院','医疗美容管理','admin',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(2,'admin','admin@ruimei.com','13800138002','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','系统管理员','female',NULL,'负责日常系统管理工作','睿美医疗美容学院','医疗美容技术','admin',1,0,'2025-09-22 18:54:56',NULL,'2025-09-23 02:38:24','2025-09-22 18:54:56',0,NULL),(3,'manager_beijing','manager.bj@ruimei.com','13800138003','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','北京区域经理','male',NULL,'负责北京地区门店管理','北京医科大学','临床医学','manager',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(4,'manager_shanghai','manager.sh@ruimei.com','13800138004','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','上海区域经理','female',NULL,'负责上海地区门店管理','复旦大学医学院','医疗美容','manager',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(5,'consultant_001','consultant001@ruimei.com','13800138005','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','资深美容顾问','female',NULL,'专业美容咨询师,5年从业经验','上海健康医学院','医疗美容技术','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(6,'nurse_001','nurse001@ruimei.com','13800138006','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','美容护士','female',NULL,'持证美容护士,专业技术过硬','首都医科大学','护理学','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(7,'therapist_001','therapist001@ruimei.com','13800138007','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','美容技师','female',NULL,'专业美容技师,擅长面部护理','北京卫生职业学院','医疗美容技术','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL),(8,'receptionist_001','front001@ruimei.com','13800138008','$2b$12$jFhkYU3.Cd1kAfr64/073eayPquAr0z9WWUQEdOyFRmAqcxz.i10C','前台接待','female',NULL,'负责客户接待和预约管理','北京商贸职业学院','商务管理','trainee',1,0,NULL,NULL,'2025-09-23 02:38:24','2025-09-23 02:38:24',0,NULL); +/*!40000 ALTER TABLE `users` ENABLE KEYS */; +UNLOCK TABLES; + +-- +-- Temporary view structure for view `v_user_course_progress` +-- + +DROP TABLE IF EXISTS `v_user_course_progress`; +/*!50001 DROP VIEW IF EXISTS `v_user_course_progress`*/; +SET @saved_cs_client = @@character_set_client; +/*!50503 SET character_set_client = utf8mb4 */; +/*!50001 CREATE VIEW `v_user_course_progress` AS SELECT + 1 AS `user_id`, + 1 AS `username`, + 1 AS `course_id`, + 1 AS `course_name`, + 1 AS `exam_count`, + 1 AS `avg_score`, + 1 AS `best_score`*/; +SET character_set_client = @saved_cs_client; + +-- +-- Dumping events for database 'kaopeilian' +-- + +-- +-- Dumping routines for database 'kaopeilian' +-- + +-- +-- Current Database: `kaopeilian` +-- + +USE `kaopeilian`; + +-- +-- Final view structure for view `v_user_course_progress` +-- + +/*!50001 DROP VIEW IF EXISTS `v_user_course_progress`*/; +/*!50001 SET @saved_cs_client = @@character_set_client */; +/*!50001 SET @saved_cs_results = @@character_set_results */; +/*!50001 SET @saved_col_connection = @@collation_connection */; +/*!50001 SET character_set_client = latin1 */; +/*!50001 SET character_set_results = latin1 */; +/*!50001 SET collation_connection = latin1_swedish_ci */; +/*!50001 CREATE ALGORITHM=UNDEFINED */ +/*!50013 DEFINER=`root`@`localhost` SQL SECURITY DEFINER */ +/*!50001 VIEW `v_user_course_progress` AS select `u`.`id` AS `user_id`,`u`.`username` AS `username`,`c`.`id` AS `course_id`,`c`.`name` AS `course_name`,count(distinct `e`.`id`) AS `exam_count`,avg(`e`.`score`) AS `avg_score`,max(`e`.`score`) AS `best_score` from ((`users` `u` join `courses` `c`) left join `exams` `e` on(((`e`.`user_id` = `u`.`id`) and (`e`.`course_id` = `c`.`id`) and (`e`.`status` = 'submitted')))) group by `u`.`id`,`c`.`id` */; +/*!50001 SET character_set_client = @saved_cs_client */; +/*!50001 SET character_set_results = @saved_cs_results */; +/*!50001 SET collation_connection = @saved_col_connection */; +/*!40103 SET TIME_ZONE=@OLD_TIME_ZONE */; + +/*!40101 SET SQL_MODE=@OLD_SQL_MODE */; +/*!40014 SET FOREIGN_KEY_CHECKS=@OLD_FOREIGN_KEY_CHECKS */; +/*!40014 SET UNIQUE_CHECKS=@OLD_UNIQUE_CHECKS */; +/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */; +/*!40101 SET CHARACTER_SET_RESULTS=@OLD_CHARACTER_SET_RESULTS */; +/*!40101 SET COLLATION_CONNECTION=@OLD_COLLATION_CONNECTION */; +/*!40111 SET SQL_NOTES=@OLD_SQL_NOTES */; + +-- Dump completed on 2025-09-22 18:56:30 diff --git a/backend/数据库架构-统一版.md b/backend/数据库架构-统一版.md new file mode 100644 index 0000000..004c9e1 --- /dev/null +++ b/backend/数据库架构-统一版.md @@ -0,0 +1,777 @@ +# 考培练系统统一数据库架构设计 + +## 数据库基本信息 +- 数据库名称:kaopeilian +- 字符集:utf8mb4 +- 排序规则:utf8mb4_unicode_ci +- 存储引擎:InnoDB + +## 更新历史 +- 2026-01-17:SCRM系统对接API功能 + * users表新增wework_userid字段(VARCHAR(64) UNIQUE),用于存储企微员工userid + * 新增SCRM对接API:获取员工岗位、获取岗位课程、搜索知识点、获取知识点详情 +- 2025-11-11:员工同步功能更新 + * users表email字段改为可空(支持没有邮箱的员工) + * 新增员工同步功能,从钉钉员工表同步在职员工 +- 2025-10-16:系统增强功能数据库更新 + * exam_mistakes表新增字段:mastery_status、difficulty、wrong_count、mastered_at(用于错题掌握状态) + * courses表确认字段:student_count、is_new(用于课程学员统计) + * tasks、task_courses、task_assignments表已完整实施(任务管理功能) + * system_logs表已完整实施(系统日志功能) +- 2025-10-14:courses表新增broadcast_audio_url和broadcast_generated_at字段(用于播课功能) +- 2025-10-13:新增practice_scenes陪练场景表(用于陪练中心功能) +- 2025-09-30:新增exam_mistakes错题记录表 +- 2025-09-30:course_exam_settings表新增essay_count字段(问答题数量) +- 2025-09-27:知识点表结构重大简化,废弃material_knowledge_points中间表 +- 2025-09-27:knowledge_points表新增material_id、type、source、topic_relation字段 +- 2025-09-27:course_materials表新增created_by、updated_by审计字段 +- 2025-09-27:统一远程和本地数据库结构,确保字段约束一致性 +- 2025-09-22:为positions表添加skills、level、sort_order字段 +- 2025-09-22:为users表添加school(学校)和major(专业)字段 +- 2025-09-22:为knowledge_points表添加created_by、updated_by审计字段 +- 2025-09-22:新增material_knowledge_points关联表,支持资料与知识点的关联管理(已废弃) + +## 一、用户管理模块 + +### 1.1 用户表 (users) +```sql +CREATE TABLE `users` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `username` VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名', + `email` VARCHAR(100) NULL UNIQUE COMMENT '邮箱(可选)', + `phone` VARCHAR(20) UNIQUE COMMENT '手机号', + `password_hash` VARCHAR(200) NOT NULL COMMENT '密码哈希', + `full_name` VARCHAR(100) COMMENT '姓名', + `gender` VARCHAR(10) COMMENT '性别: male/female', + `avatar_url` VARCHAR(500) COMMENT '头像URL', + `bio` TEXT COMMENT '个人简介', + `school` VARCHAR(100) COMMENT '学校', + `major` VARCHAR(100) COMMENT '专业', + `wework_userid` VARCHAR(64) UNIQUE COMMENT '企微员工userid(用于SCRM系统对接)', + `role` VARCHAR(20) DEFAULT 'trainee' COMMENT '系统角色: admin, manager, trainee', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `is_verified` BOOLEAN DEFAULT FALSE COMMENT '是否验证', + `last_login_at` DATETIME COMMENT '最后登录时间', + `password_changed_at` DATETIME COMMENT '密码修改时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + INDEX idx_role (role), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户表'; +``` + +### 1.2 团队表 (teams) +```sql +CREATE TABLE `teams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL UNIQUE COMMENT '团队名称', + `code` VARCHAR(50) NOT NULL UNIQUE COMMENT '团队代码', + `description` TEXT COMMENT '团队描述', + `team_type` VARCHAR(50) DEFAULT 'department' COMMENT '团队类型: department, project, study_group', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否激活', + `leader_id` INT COMMENT '负责人ID', + `parent_id` INT COMMENT '父团队ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (leader_id) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (parent_id) REFERENCES teams(id) ON DELETE CASCADE, + INDEX idx_team_type (team_type), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='团队表'; +``` + +### 1.3 用户团队关联表 (user_teams) +```sql +CREATE TABLE `user_teams` ( + `user_id` INT NOT NULL, + `team_id` INT NOT NULL, + `role` VARCHAR(50) DEFAULT 'member' COMMENT '团队角色: member, leader', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + PRIMARY KEY (user_id, team_id), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (team_id) REFERENCES teams(id) ON DELETE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='用户团队关联表'; +``` + +## 二、组织与岗位管理模块 + +### 2.1 岗位表 (positions) +```sql +CREATE TABLE `positions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '岗位名称', + `code` VARCHAR(100) NOT NULL UNIQUE COMMENT '岗位编码', + `description` TEXT COMMENT '岗位描述', + `parent_id` INT NULL COMMENT '上级岗位ID', + `status` VARCHAR(20) NOT NULL DEFAULT 'active' COMMENT '状态: active/inactive', + `skills` JSON NULL COMMENT '核心技能', + `level` VARCHAR(20) NULL COMMENT '岗位等级: junior/intermediate/senior/expert', + `sort_order` INT DEFAULT 0 COMMENT '排序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + `created_by` INT NULL COMMENT '创建人ID', + `updated_by` INT NULL COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (parent_id) REFERENCES positions(id) ON DELETE SET NULL, + INDEX idx_positions_name (name) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位表'; +``` + +### 2.2 岗位成员表 (position_members) +```sql +CREATE TABLE `position_members` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `position_id` INT NOT NULL COMMENT '岗位ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `role` VARCHAR(50) DEFAULT 'member' COMMENT '岗位角色', + `joined_at` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + UNIQUE KEY `idx_position_user` (`position_id`, `user_id`), + FOREIGN KEY (position_id) REFERENCES positions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; +``` + +### 2.3 岗位课程表 (position_courses) +```sql +CREATE TABLE `position_courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `position_id` INT NOT NULL COMMENT '岗位ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `course_type` VARCHAR(20) NOT NULL DEFAULT 'required' COMMENT '课程类型: required/optional', + `priority` INT DEFAULT 0 COMMENT '优先级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME NULL COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + UNIQUE KEY `idx_position_course` (`position_id`, `course_id`), + FOREIGN KEY (position_id) REFERENCES positions(id) ON DELETE CASCADE, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; +``` + +## 三、课程管理模块 + +### 3.1 课程表 (courses) +```sql +CREATE TABLE `courses` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '课程名称', + `description` TEXT COMMENT '课程描述', + `category` ENUM('technology', 'management', 'business', 'general') DEFAULT 'general' COMMENT '课程分类', + `status` ENUM('draft', 'published', 'archived') DEFAULT 'draft' COMMENT '课程状态', + `cover_image` VARCHAR(500) COMMENT '封面图片URL', + `duration_hours` FLOAT COMMENT '课程时长(小时)', + `difficulty_level` INT COMMENT '难度等级(1-5)', + `tags` JSON COMMENT '标签列表', + `published_at` DATETIME COMMENT '发布时间', + `publisher_id` INT COMMENT '发布人ID', + `broadcast_audio_url` VARCHAR(500) COMMENT '播课音频URL', + `broadcast_generated_at` DATETIME COMMENT '播课生成时间', + `broadcast_status` VARCHAR(20) COMMENT '播课生成状态: pending/generating/completed/failed', + `broadcast_task_id` VARCHAR(100) COMMENT 'Coze工作流任务ID', + `broadcast_error_message` TEXT COMMENT '生成失败错误信息', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_featured` BOOLEAN DEFAULT FALSE COMMENT '是否推荐', + `student_count` INT DEFAULT 0 COMMENT '学员数量', + `is_new` BOOLEAN DEFAULT TRUE COMMENT '是否新课程', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_featured (is_featured), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程表'; +``` + +### 3.2 课程资料表 (course_materials) +```sql +CREATE TABLE `course_materials` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `name` VARCHAR(200) NOT NULL COMMENT '资料名称', + `description` TEXT COMMENT '资料描述', + `file_url` VARCHAR(500) NOT NULL COMMENT '文件URL', + `file_type` VARCHAR(50) NOT NULL COMMENT '文件类型', + `file_size` INT NOT NULL COMMENT '文件大小(字节)', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程资料表'; +``` + +### 3.3 知识点表 (knowledge_points) +```sql +CREATE TABLE `knowledge_points` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `material_id` INT COMMENT '关联资料ID', + `name` VARCHAR(200) NOT NULL COMMENT '知识点名称', + `description` TEXT COMMENT '知识点描述', + `type` VARCHAR(50) DEFAULT '概念定义' COMMENT '知识点类型', + `source` TINYINT(1) DEFAULT 0 COMMENT '来源:0=手动,1=AI分析', + `topic_relation` VARCHAR(200) COMMENT '与主题的关系描述', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + FOREIGN KEY (material_id) REFERENCES course_materials(id) ON DELETE SET NULL, + INDEX idx_course_id (course_id), + INDEX idx_material_id (material_id), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='知识点表'; +``` + +### 3.4 成长路径表 (growth_paths) +```sql +CREATE TABLE `growth_paths` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '路径名称', + `description` TEXT COMMENT '路径描述', + `target_role` VARCHAR(100) COMMENT '目标角色', + `courses` JSON COMMENT '课程列表[{course_id, order, is_required}]', + `estimated_duration_days` INT COMMENT '预计完成天数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `sort_order` INT DEFAULT 0 COMMENT '排序顺序', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_is_active (is_active), + INDEX idx_is_deleted (is_deleted), + INDEX idx_sort_order (sort_order) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='成长路径表'; +``` + +## 四、考试模块 + +### 4.1 题目表 (questions) +```sql +CREATE TABLE `questions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `course_id` INT NOT NULL COMMENT '课程ID', + `question_type` VARCHAR(20) NOT NULL COMMENT '题目类型: single_choice, multiple_choice, true_false, fill_blank, essay', + `title` TEXT NOT NULL COMMENT '题目标题', + `content` TEXT COMMENT '题目内容', + `options` JSON COMMENT '选项(JSON格式)', + `correct_answer` TEXT NOT NULL COMMENT '正确答案', + `explanation` TEXT COMMENT '答案解释', + `score` FLOAT DEFAULT 10.0 COMMENT '分值', + `difficulty` VARCHAR(10) DEFAULT 'medium' COMMENT '难度等级: easy, medium, hard', + `tags` JSON COMMENT '标签(JSON格式)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `correct_count` INT DEFAULT 0 COMMENT '答对次数', + `is_active` BOOLEAN DEFAULT TRUE COMMENT '是否启用', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_course_id (course_id), + INDEX idx_question_type (question_type), + INDEX idx_difficulty (difficulty), + INDEX idx_is_active (is_active) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='题目表'; +``` + +### 4.2 课程考试设置表 (course_exam_settings) +```sql +CREATE TABLE IF NOT EXISTS course_exam_settings ( + id INT AUTO_INCREMENT PRIMARY KEY, + course_id INT NOT NULL UNIQUE COMMENT '课程ID', + + -- 题型数量设置 + single_choice_count INT NOT NULL DEFAULT 10 COMMENT '单选题数量', + multiple_choice_count INT NOT NULL DEFAULT 5 COMMENT '多选题数量', + true_false_count INT NOT NULL DEFAULT 5 COMMENT '判断题数量', + fill_blank_count INT NOT NULL DEFAULT 0 COMMENT '填空题数量', + essay_count INT NOT NULL DEFAULT 0 COMMENT '问答题数量', + + -- 考试参数设置 + duration_minutes INT NOT NULL DEFAULT 60 COMMENT '考试时长(分钟)', + difficulty_level INT NOT NULL DEFAULT 3 COMMENT '难度系数(1-5)', + passing_score INT NOT NULL DEFAULT 60 COMMENT '及格分数', + + -- 其他设置 + is_enabled BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否启用', + show_answer_immediately BOOLEAN NOT NULL DEFAULT FALSE COMMENT '是否立即显示答案', + allow_retake BOOLEAN NOT NULL DEFAULT TRUE COMMENT '是否允许重考', + max_retake_times INT COMMENT '最大重考次数', + + -- 审计字段 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + deleted_by INT COMMENT '删除人ID', + + FOREIGN KEY (course_id) REFERENCES courses(id), + INDEX ix_course_exam_settings_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='课程考试设置表'; +``` + +### 4.3 错题记录表 (exam_mistakes) +```sql +CREATE TABLE IF NOT EXISTS exam_mistakes ( + id INT AUTO_INCREMENT PRIMARY KEY, + + -- 核心关联字段(必需) + user_id INT NOT NULL COMMENT '用户ID', + exam_id INT NOT NULL COMMENT '考试ID', + question_id INT COMMENT '题目ID(AI生成的题目可能为空)', + knowledge_point_id INT COMMENT '关联的知识点ID', + + -- 题目核心信息(必需) + question_content TEXT NOT NULL COMMENT '题目内容', + correct_answer TEXT NOT NULL COMMENT '正确答案', + user_answer TEXT COMMENT '用户答案', + question_type VARCHAR(20) COMMENT '题型(single/multiple/judge/blank/essay)', -- 2025-10-12新增 + + -- 审计字段 + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE SET NULL, + FOREIGN KEY (knowledge_point_id) REFERENCES knowledge_points(id) ON DELETE SET NULL, + + INDEX idx_user_id (user_id), + INDEX idx_exam_id (exam_id), + INDEX idx_knowledge_point_id (knowledge_point_id), + INDEX idx_question_type (question_type) -- 2025-10-12新增 +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='错题记录表'; +``` + +**核心字段说明:** +- 包含8个核心字段:`user_id`、`exam_id`、`question_id`、`knowledge_point_id`、`question_content`、`correct_answer`、`user_answer`、`question_type` +- 简化设计,去除冗余字段(如错误次数、掌握状态等),聚焦核心功能 +- `question_id` 可为空:AI动态生成的题目可能不在 questions 表中 +- `knowledge_point_id` 可为空:用于关联知识点,支持错题重考功能 +- `question_type` 用于记录题型,支持错题按题型筛选和统计(2025-10-12新增) +- 外键级联删除:用户或考试删除时,相关错题记录也删除 +- 外键置空:题目或知识点删除时,外键置为NULL但保留错题记录 + +### 4.4 考试记录表 (exams) +```sql +CREATE TABLE `exams` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `course_id` INT NOT NULL COMMENT '课程ID', + `exam_name` VARCHAR(255) NOT NULL COMMENT '考试名称', + `question_count` INT DEFAULT 10 COMMENT '题目数量', + `total_score` FLOAT DEFAULT 100.0 COMMENT '总分', + `pass_score` FLOAT DEFAULT 60.0 COMMENT '及格分', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_minutes` INT DEFAULT 60 COMMENT '考试时长(分钟)', + `score` FLOAT COMMENT '最终得分(兼容字段)', + -- 三轮考试得分字段(2025-10-12新增) + `round1_score` FLOAT COMMENT '第一轮得分', + `round2_score` FLOAT COMMENT '第二轮得分', + `round3_score` FLOAT COMMENT '第三轮得分', + `is_passed` BOOLEAN COMMENT '是否通过', + `status` VARCHAR(20) DEFAULT 'started' COMMENT '状态: started, submitted, timeout', + `questions` JSON COMMENT '题目数据(JSON格式)', + `answers` JSON COMMENT '答案数据(JSON格式)', + `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 (course_id) REFERENCES courses(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_course_id (course_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试记录表'; +``` + +**三轮考试机制说明(2025-10-12更新)**: +- 一条考试记录包含三个轮次得分字段:`round1_score`、`round2_score`、`round3_score` +- 第一轮考试:创建exam记录,设置round1_score +- 第二轮考试:复用同一条exam记录,更新round2_score +- 第三轮考试:复用同一条exam记录,更新round3_score和score(最终得分) +- `score`字段用于兼容旧代码,通常存储最后完成的轮次得分 +- 成绩报告优先使用`round1_score`作为主要展示数据 + +### 4.5 考试结果详情表 (exam_results) +```sql +CREATE TABLE `exam_results` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `exam_id` INT NOT NULL COMMENT '考试ID', + `question_id` INT NOT NULL COMMENT '题目ID', + `user_answer` TEXT COMMENT '用户答案', + `is_correct` BOOLEAN DEFAULT FALSE COMMENT '是否正确', + `score` FLOAT DEFAULT 0.0 COMMENT '得分', + `answer_time` INT COMMENT '答题时长(秒)', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE, + FOREIGN KEY (question_id) REFERENCES questions(id) ON DELETE CASCADE, + INDEX idx_exam_id (exam_id), + INDEX idx_question_id (question_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='考试结果详情表'; +``` + +## 五、陪练模块 + +### 5.1 陪练场景表 (practice_scenes) +```sql +CREATE TABLE `practice_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(200) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `type` VARCHAR(50) NOT NULL COMMENT '场景类型: phone/face/complaint/after-sales/product-intro', + `difficulty` VARCHAR(50) NOT NULL COMMENT '难度等级: beginner/junior/intermediate/senior/expert', + `status` VARCHAR(20) DEFAULT 'active' COMMENT '状态: active/inactive', + `background` TEXT COMMENT '场景背景设定', + `ai_role` TEXT COMMENT 'AI角色描述', + `objectives` JSON COMMENT '练习目标数组', + `keywords` JSON COMMENT '关键词数组', + `duration` INT DEFAULT 10 COMMENT '预计时长(分钟)', + `usage_count` INT DEFAULT 0 COMMENT '使用次数', + `rating` DECIMAL(3,1) DEFAULT 0.0 COMMENT '评分', + `created_by` INT COMMENT '创建人ID', + `updated_by` INT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + `is_deleted` BOOLEAN DEFAULT FALSE, + `deleted_at` DATETIME, + FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL, + FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL, + INDEX idx_type (type), + INDEX idx_difficulty (difficulty), + INDEX idx_status (status), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表(陪练中心功能)'; +``` + +**说明:** +- 用于陪练中心功能,存储预设的陪练场景 +- 场景类型:phone(电话销售)、face(面对面销售)、complaint(客户投诉)、after-sales(售后服务)、product-intro(产品介绍) +- 难度等级:beginner(入门)、junior(初级)、intermediate(中级)、senior(高级)、expert(专家) +- objectives和keywords字段使用JSON格式存储数组 +- 对话历史由Coze平台管理,不存储在本地数据库 + +### 5.2 陪练场景表(旧版本 - training_scenes) +```sql +CREATE TABLE `training_scenes` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `name` VARCHAR(100) NOT NULL COMMENT '场景名称', + `description` TEXT COMMENT '场景描述', + `category` VARCHAR(50) NOT NULL COMMENT '场景分类', + `ai_config` JSON COMMENT 'AI配置(如Coze Bot ID等)', + `prompt_template` TEXT COMMENT '提示词模板', + `evaluation_criteria` JSON COMMENT '评估标准', + `status` ENUM('DRAFT', 'ACTIVE', 'INACTIVE') DEFAULT 'DRAFT' COMMENT '场景状态', + `is_public` BOOLEAN DEFAULT TRUE COMMENT '是否公开', + `required_level` INT COMMENT '所需用户等级', + `is_deleted` BOOLEAN DEFAULT FALSE COMMENT '是否删除', + `deleted_at` DATETIME COMMENT '删除时间', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + INDEX idx_status (status), + INDEX idx_category (category), + INDEX idx_is_public (is_public), + INDEX idx_is_deleted (is_deleted) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练场景表'; +``` + +### 5.2 陪练会话表 (training_sessions) +```sql +CREATE TABLE `training_sessions` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `user_id` INT NOT NULL COMMENT '用户ID', + `scene_id` INT NOT NULL COMMENT '场景ID', + `coze_conversation_id` VARCHAR(100) COMMENT 'Coze会话ID', + `start_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '开始时间', + `end_time` DATETIME COMMENT '结束时间', + `duration_seconds` INT COMMENT '持续时长(秒)', + `status` ENUM('CREATED', 'IN_PROGRESS', 'COMPLETED', 'CANCELLED', 'ERROR') DEFAULT 'CREATED' COMMENT '会话状态', + `session_config` JSON COMMENT '会话配置', + `total_score` FLOAT COMMENT '总分', + `evaluation_result` JSON COMMENT '评估结果详情', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `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 (scene_id) REFERENCES training_scenes(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id), + INDEX idx_scene_id (scene_id), + INDEX idx_status (status) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练会话表'; +``` + +### 5.3 陪练消息表 (training_messages) +```sql +CREATE TABLE `training_messages` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL COMMENT '会话ID', + `role` ENUM('USER', 'ASSISTANT', 'SYSTEM') NOT NULL COMMENT '消息角色', + `type` ENUM('TEXT', 'VOICE', 'SYSTEM') NOT NULL COMMENT '消息类型', + `content` TEXT NOT NULL COMMENT '消息内容', + `voice_url` VARCHAR(500) COMMENT '语音文件URL', + `voice_duration` FLOAT COMMENT '语音时长(秒)', + `message_metadata` JSON COMMENT '消息元数据', + `coze_message_id` VARCHAR(100) COMMENT 'Coze消息ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + INDEX idx_session_id (session_id), + INDEX idx_role (role) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练消息表'; +``` + +### 5.4 陪练报告表 (training_reports) +```sql +CREATE TABLE `training_reports` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `session_id` INT NOT NULL UNIQUE COMMENT '会话ID', + `user_id` INT NOT NULL COMMENT '用户ID', + `overall_score` FLOAT NOT NULL COMMENT '总体得分', + `dimension_scores` JSON NOT NULL COMMENT '各维度得分', + `strengths` JSON NOT NULL COMMENT '优势点', + `weaknesses` JSON NOT NULL COMMENT '待改进点', + `suggestions` JSON NOT NULL COMMENT '改进建议', + `detailed_analysis` TEXT COMMENT '详细分析', + `transcript` TEXT COMMENT '对话文本记录', + `statistics` JSON COMMENT '统计数据', + `created_by` BIGINT COMMENT '创建人ID', + `updated_by` BIGINT COMMENT '更新人ID', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + FOREIGN KEY (session_id) REFERENCES training_sessions(id) ON DELETE CASCADE, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE, + INDEX idx_user_id (user_id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='陪练报告表'; +``` + +## 六、数据库设计原则 + +### 5.1 主键规范 +- 所有表使用INT作为主键,满足当前规模与 ORM 定义 +- 所有主键均设置为AUTO_INCREMENT + +### 5.2 外键约束 +- 所有外键关系都明确定义 +- 删除策略: + - CASCADE:级联删除(用于强关联关系) + - SET NULL:置空(用于弱关联关系) + +### 5.3 索引策略 +- 所有外键字段自动创建索引 +- 常用查询字段创建索引(如status, type等) +- 唯一约束字段自动创建唯一索引 + +### 5.4 字段命名规范 +- 使用下划线命名法(snake_case) +- 布尔字段使用is_前缀 +- 时间字段使用_at后缀 +- JSON字段明确标注数据结构 + +### 5.5 软删除设计 +- 使用is_deleted和deleted_at字段实现软删除 +- 保留数据完整性,便于数据恢复 + +### 5.6 审计字段 +- created_at:创建时间 +- updated_at:更新时间 +- created_by:创建人ID +- updated_by:更新人ID + +### positions - 岗位表(2025-09-22新增) +```sql +CREATE TABLE positions ( + id INT AUTO_INCREMENT PRIMARY KEY, + name VARCHAR(100) NOT NULL COMMENT '岗位名称', + code VARCHAR(100) NOT NULL UNIQUE COMMENT '岗位编码', + description TEXT COMMENT '岗位描述', + parent_id INT COMMENT '上级岗位ID', + status VARCHAR(20) DEFAULT 'active' COMMENT '状态', + sort_order INT DEFAULT 0 COMMENT '排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (parent_id) REFERENCES positions(id) ON DELETE SET NULL, + INDEX ix_positions_name (name), + INDEX ix_positions_code (code), + INDEX ix_positions_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位表'; +``` + +### position_members - 岗位成员关联表(2025-09-22新增) +```sql +CREATE TABLE position_members ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + user_id INT NOT NULL COMMENT '用户ID', + role VARCHAR(50) COMMENT '成员角色(预留字段)', + joined_at DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '加入时间', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (user_id) REFERENCES users(id), + UNIQUE KEY uix_position_user (position_id, user_id, is_deleted), + INDEX ix_position_members_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位成员关联表'; +``` + +### position_courses - 岗位课程关联表(2025-09-22新增) +```sql +CREATE TABLE position_courses ( + id INT AUTO_INCREMENT PRIMARY KEY, + position_id INT NOT NULL COMMENT '岗位ID', + course_id INT NOT NULL COMMENT '课程ID', + course_type VARCHAR(20) NOT NULL DEFAULT 'required' COMMENT '课程类型:required必修/optional选修', + priority INT DEFAULT 0 COMMENT '优先级/排序', + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + created_by INT COMMENT '创建人ID', + updated_by INT COMMENT '更新人ID', + is_deleted BOOLEAN NOT NULL DEFAULT FALSE, + deleted_at DATETIME, + FOREIGN KEY (position_id) REFERENCES positions(id), + FOREIGN KEY (course_id) REFERENCES courses(id), + UNIQUE KEY uix_position_course (position_id, course_id, is_deleted), + INDEX ix_position_courses_id (id) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='岗位课程关联表'; +``` + +## 六、性能优化建议 + +1. **分表策略** + - training_messages表可能增长较快,考虑按月分表 + - exam_results表可考虑按年分表 + +2. **缓存策略** + - 用户信息使用Redis缓存 + - 课程列表使用Redis缓存 + - 热门题目使用Redis缓存 + +3. **查询优化** + - 使用分页查询避免大量数据加载 + - 合理使用JOIN,避免N+1查询 + - 统计类查询考虑使用物化视图 + +## 七、初始化数据(轻医美连锁业务) + +系统初始化时会插入以下业务数据: + +### 7.1 默认用户账号 +- 超级管理员:superadmin / Superadmin123! +- 系统管理员:admin / Admin123! +- 测试学员:testuser / TestPass123! + +### 7.2 轻医美连锁岗位体系 +系统预置了完整的轻医美连锁岗位层级结构: + +#### 岗位层级树 +``` +区域经理 (region_manager) - expert级别 +└── 店长 (store_manager) - senior级别 + ├── 美容顾问 (beauty_consultant) - intermediate级别 + ├── 医美咨询师 (medical_beauty_consultant) - senior级别 + ├── 美容技师 (beauty_therapist) - intermediate级别 + ├── 护士 (nurse) - intermediate级别 + ├── 前台接待 (receptionist) - junior级别 + └── 市场专员 (marketing_specialist) - intermediate级别 +``` + +### 7.3 轻医美专业课程体系 + +#### 技术类课程 +- **皮肤生理学基础** (16课时) - 学习皮肤结构、功能和常见问题 +- **医美产品知识与应用** (20课时) - 了解各类医美产品的成分和功效 +- **美容仪器操作与维护** (24课时) - 掌握美容仪器的操作方法 +- **医美项目介绍与咨询** (30课时) - 了解各类医美项目原理和效果 + +#### 业务类课程 +- **轻医美销售技巧** (16课时) - 学习专业销售话术和成交技巧 +- **客户服务与投诉处理** (12课时) - 提升服务意识和投诉处理能力 +- **社媒营销与私域运营** (16课时) - 学习社交媒体品牌推广 + +#### 管理类课程 +- **门店运营管理** (20课时) - 学习门店日常管理和团队建设 + +#### 通用类课程 +- **卫生消毒与感染控制** (8课时) - 学习医美机构卫生标准 + +### 7.4 岗位课程配置示例 +系统已预设各岗位的必修和选修课程: + +- **店长**:门店运营管理、轻医美销售技巧、客户服务与投诉处理、卫生消毒与感染控制(全部必修) +- **美容顾问**:皮肤生理学基础、医美产品知识与应用、轻医美销售技巧、客户服务与投诉处理(必修);社媒营销与私域运营(选修) +- **美容技师**:皮肤生理学基础、美容仪器操作与维护、卫生消毒与感染控制(必修);医美产品知识与应用(选修) +- **医美咨询师**:医美项目介绍与咨询、皮肤生理学基础、医美产品知识与应用、轻医美销售技巧、客户服务与投诉处理(全部必修) + +### 7.5 数据初始化脚本 +- **初始化SQL脚本**:`/scripts/init_database_unified.sql` - 包含完整的表结构和初始数据 +- **轻医美种子数据脚本**:`/scripts/seed_beauty_data.py` - 专门用于插入轻医美业务数据 + +--- + +## 八、系统配置模块 + +### 8.1 AI服务配置表 (ai_config) + +> 2026-01-21 新增:遵循《瑞小美AI接入规范》,将 API Key 等敏感配置存储在数据库中 + +```sql +CREATE TABLE IF NOT EXISTS `ai_config` ( + `id` INT AUTO_INCREMENT PRIMARY KEY, + `config_key` VARCHAR(100) NOT NULL UNIQUE COMMENT '配置键名', + `config_value` TEXT COMMENT '配置值', + `description` VARCHAR(255) COMMENT '配置说明', + `is_encrypted` TINYINT(1) DEFAULT 0 COMMENT '是否加密存储', + `created_at` DATETIME DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='AI 服务配置表'; +``` + +**预置配置项**: + +| config_key | 说明 | 示例值 | +|------------|------|--------| +| AI_PRIMARY_API_KEY | 4sapi.com 通用 Key(Gemini/DeepSeek) | sk-xxx... | +| AI_ANTHROPIC_API_KEY | 4sapi.com Claude 专属 Key | sk-xxx... | +| AI_PRIMARY_BASE_URL | 首选服务商 API 地址 | https://4sapi.com/v1 | +| AI_FALLBACK_API_KEY | OpenRouter 备选 Key | sk-or-xxx... | +| AI_FALLBACK_BASE_URL | 备选服务商 API 地址 | https://openrouter.ai/api/v1 | +| AI_DEFAULT_MODEL | 默认 AI 模型 | claude-opus-4-5-20251101-thinking | +| AI_TIMEOUT | AI 请求超时时间(秒) | 120 | + +**配置加载优先级**: +1. 数据库 ai_config 表(推荐) +2. 环境变量(fallback) + +**使用说明**: +- AIService 初始化时自动从数据库读取配置 +- 支持运行时动态更新配置,无需重启服务 +- 遵循规范:禁止在代码中硬编码 API Key + diff --git a/backend/数据库配置切换说明.md b/backend/数据库配置切换说明.md new file mode 100644 index 0000000..b206a9e --- /dev/null +++ b/backend/数据库配置切换说明.md @@ -0,0 +1,103 @@ +# 数据库配置切换说明 + +## 当前配置 + +- **公网数据库地址**: + - 主机: `120.79.247.16` 或 `aiedu.ireborn.com.cn` + - 端口: `3306` + - 数据库名: `kaopeilian` + - 用户: `root` + - 密码: `Kaopeilian2025!@#` + +## 连接字符串 + +### 原始密码(包含特殊字符) +``` +Kaopeilian2025!@# +``` + +### URL编码后的密码 +``` +Kaopeilian2025%21%40%23 +``` + +### 完整的SQLAlchemy连接字符串 +``` +mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 +``` + +## 配置方法 + +### 方法1:使用环境变量文件(推荐) + +创建 `.env` 文件: +```bash +DATABASE_URL=mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 +REDIS_URL=redis://localhost:6379/0 +SECRET_KEY=dev-secret-key-for-testing-only-not-for-production +DEBUG=true +LOG_LEVEL=INFO +``` + +### 方法2:使用启动脚本 + +使用已配置好的启动脚本: +```bash +# 使用公网数据库 +python3 start_remote.py + +# 使用本地数据库(需要修改回本地配置) +python3 start_mysql.py +``` + +### 方法3:直接设置环境变量 + +```bash +export DATABASE_URL="mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4" +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +### 方法4:Docker Compose + +`docker-compose.dev.yml` 已更新为使用公网数据库: +```yaml +environment: + DATABASE_URL: mysql+aiomysql://root:Kaopeilian2025%21%40%23@120.79.247.16:3306/kaopeilian?charset=utf8mb4 + REDIS_URL: redis://redis:6379/0 +``` + +## 测试连接 + +运行测试脚本验证连接: +```bash +python3 test_remote_db.py +``` + +## 切换回本地数据库 + +如需切换回本地数据库,修改连接字符串为: +``` +mysql+aiomysql://root:root@localhost:3306/kaopeilian?charset=utf8mb4 +``` + +## 注意事项 + +1. **密码编码**:密码中的特殊字符必须进行URL编码 + - `!` → `%21` + - `@` → `%40` + - `#` → `%23` + +2. **网络连接**:确保开发机器能够访问公网数据库服务器 + +3. **安全性**:生产环境不要在代码中硬编码密码,使用环境变量或密钥管理服务 + +4. **性能**:公网数据库可能有一定延迟,开发时请注意 + +## 已更新的文件 + +- `start_mysql.py` - 启动脚本(已改为公网数据库) +- `start_remote.py` - 新增的公网数据库启动脚本 +- `docker-compose.dev.yml` - Docker配置(已改为公网数据库) +- `test_remote_db.py` - 数据库连接测试脚本 + + diff --git a/backend/验证备份质量.py b/backend/验证备份质量.py new file mode 100755 index 0000000..afe62f0 --- /dev/null +++ b/backend/验证备份质量.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +考培练系统数据库备份质量验证脚本 +""" + +import asyncio +import aiomysql +import os +import sys +from datetime import datetime + +async def verify_backup_quality(): + """验证数据库备份质量""" + + print("🔍 考培练系统数据库备份质量验证") + print("=" * 50) + + try: + # 连接数据库 + conn = await aiomysql.connect( + host='127.0.0.1', + port=3306, + user='root', + password='root', + db='kaopeilian', + charset='utf8mb4' + ) + + cursor = await conn.cursor() + + # 1. 基础信息检查 + print("\n📊 1. 数据库基础信息") + print("-" * 30) + + await cursor.execute("SELECT VERSION()") + version = (await cursor.fetchone())[0] + print(f"MySQL版本: {version}") + + await cursor.execute("SELECT DATABASE()") + db_name = (await cursor.fetchone())[0] + print(f"当前数据库: {db_name}") + + await cursor.execute("SHOW VARIABLES LIKE 'character_set_database'") + charset = (await cursor.fetchone())[1] + print(f"数据库字符集: {charset}") + + # 2. 表结构检查 + print("\n🏗️ 2. 表结构检查") + print("-" * 30) + + await cursor.execute("SHOW TABLES") + tables = await cursor.fetchall() + table_names = [t[0] for t in tables if t[0] != 'v_user_course_progress'] + + print(f"表数量: {len(table_names)}") + + # 检查每个表的注释 + tables_with_comment = 0 + for table_name in table_names: + await cursor.execute(f"SHOW CREATE TABLE {table_name}") + create_sql = (await cursor.fetchone())[1] + if 'COMMENT=' in create_sql: + tables_with_comment += 1 + + print(f"有注释的表: {tables_with_comment}/{len(table_names)}") + + # 3. 数据完整性检查 + print("\n📋 3. 数据完整性检查") + print("-" * 30) + + key_tables = [ + ('users', '用户'), + ('courses', '课程'), + ('questions', '题目'), + ('teams', '团队'), + ('positions', '岗位'), + ('knowledge_points', '知识点'), + ('user_teams', '用户团队关联'), + ('position_courses', '岗位课程关联') + ] + + total_records = 0 + for table, desc in key_tables: + await cursor.execute(f"SELECT COUNT(*) FROM {table}") + count = (await cursor.fetchone())[0] + total_records += count + status = "✅" if count > 0 else "❌" + print(f"{status} {desc}: {count} 条") + + print(f"总记录数: {total_records}") + + # 4. 中文内容检查 + print("\n🈯 4. 中文内容检查") + print("-" * 30) + + # 检查用户中文姓名 + await cursor.execute("SELECT full_name FROM users WHERE full_name IS NOT NULL LIMIT 3") + names = await cursor.fetchall() + print("用户姓名示例:") + for name in names: + print(f" - {name[0]}") + + # 检查课程中文名称 + await cursor.execute("SELECT name FROM courses LIMIT 3") + courses = await cursor.fetchall() + print("课程名称示例:") + for course in courses: + print(f" - {course[0]}") + + # 5. COMMENT质量检查 + print("\n💬 5. COMMENT质量检查") + print("-" * 30) + + total_columns = 0 + columns_with_comment = 0 + + for table_name in table_names: + await cursor.execute(f"SHOW FULL COLUMNS FROM {table_name}") + columns = await cursor.fetchall() + + for col in columns: + total_columns += 1 + if col[8]: # 有COMMENT + columns_with_comment += 1 + + comment_coverage = columns_with_comment / total_columns * 100 + print(f"列总数: {total_columns}") + print(f"有注释的列: {columns_with_comment}") + print(f"注释覆盖率: {comment_coverage:.1f}%") + + # 6. 备份文件检查 + print("\n💾 6. 备份文件检查") + print("-" * 30) + + backup_files = [ + 'kaopeilian_final_complete_backup_20250923_025629.sql', + 'kaopeilian_complete_backup_20250923_025548.sql', + 'kaopeilian_super_complete_backup_20250923_025622.sql' + ] + + for backup_file in backup_files: + if os.path.exists(backup_file): + size = os.path.getsize(backup_file) + print(f"✅ {backup_file}: {size/1024:.1f}KB") + else: + print(f"❌ {backup_file}: 不存在") + + # 7. 质量评分 + print("\n⭐ 7. 质量评分") + print("-" * 30) + + scores = [] + + # 表结构完整性 (25分) + structure_score = min(25, len(table_names) * 1.3) + scores.append(('表结构完整性', structure_score, 25)) + + # 数据完整性 (25分) + data_score = min(25, total_records * 0.3) + scores.append(('数据完整性', data_score, 25)) + + # 注释质量 (25分) + comment_score = comment_coverage * 0.25 + scores.append(('注释质量', comment_score, 25)) + + # 字符编码 (25分) + encoding_score = 25 if charset == 'utf8mb4' else 15 + scores.append(('字符编码', encoding_score, 25)) + + total_score = sum(score for _, score, _ in scores) + max_score = sum(max_s for _, _, max_s in scores) + + for name, score, max_s in scores: + percentage = score / max_s * 100 + print(f"{name}: {score:.1f}/{max_s} ({percentage:.1f}%)") + + print(f"\n总分: {total_score:.1f}/{max_score} ({total_score/max_score*100:.1f}%)") + + # 8. 最终评估 + print("\n🏆 8. 最终评估") + print("-" * 30) + + if total_score >= 90: + grade = "优秀 ⭐⭐⭐⭐⭐" + status = "🎉 备份质量优秀,可用于生产环境!" + elif total_score >= 80: + grade = "良好 ⭐⭐⭐⭐" + status = "✅ 备份质量良好,建议使用。" + elif total_score >= 70: + grade = "合格 ⭐⭐⭐" + status = "⚠️ 备份质量合格,可以使用但建议改进。" + else: + grade = "需改进 ⭐⭐" + status = "❌ 备份质量需要改进。" + + print(f"质量等级: {grade}") + print(f"评估结果: {status}") + + print(f"\n验证完成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + + await cursor.close() + conn.close() + + return total_score >= 80 + + except Exception as e: + print(f"❌ 验证过程中出错: {e}") + import traceback + traceback.print_exc() + return False + +if __name__ == "__main__": + result = asyncio.run(verify_backup_quality()) + sys.exit(0 if result else 1) diff --git a/deploy/docker/docker-compose.admin.yml b/deploy/docker/docker-compose.admin.yml new file mode 100644 index 0000000..304e56e --- /dev/null +++ b/deploy/docker/docker-compose.admin.yml @@ -0,0 +1,115 @@ +# 考培练系统 SaaS 超级管理后台 Docker Compose 配置 +# +# 启动命令: +# cd /root/aiedu && docker compose -f docker-compose.admin.yml up -d +# +# 重新构建后端: +# docker compose -f docker-compose.admin.yml build --no-cache kaopeilian-admin-backend +# +# 服务说明: +# - kaopeilian-admin-frontend: 管理后台前端 (端口 3030) +# - kaopeilian-admin-backend: 管理后台后端 (端口 8030) +# +# 域名:admin.kpl.ireborn.com.cn +# +# 注意:敏感配置从 .env.admin 文件读取 + +name: kaopeilian-admin + +services: + # ============================================ + # 管理后台前端 + # ============================================ + kaopeilian-admin-frontend: + build: + context: ./kaopeilian-admin-frontend + dockerfile: Dockerfile + image: kaopeilian-admin-frontend:1.0.0 + container_name: kaopeilian-admin-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "127.0.0.1:3030:80" # 仅本地访问,通过 Nginx 反向代理 + volumes: + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + depends_on: + kaopeilian-admin-backend: + condition: service_started + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + cpus: '0.5' + memory: 256M + reservations: + cpus: '0.1' + memory: 64M + + # ============================================ + # 管理后台后端(开发环境 - 热重载) + # ============================================ + kaopeilian-admin-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile.admin + image: kaopeilian-admin-backend:1.0.0 + container_name: kaopeilian-admin-backend + restart: unless-stopped + env_file: + - .env.admin + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "127.0.0.1:8030:8000" # 仅本地访问,通过 Nginx 反向代理 + volumes: + # 代码热重载 - 挂载整个 app 目录 + - ./kaopeilian-backend/app:/app/app:rw + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + # 开发模式命令 - 启用热重载 + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload", "--reload-dir", "/app/app"] + networks: + - kaopeilian-network + - prod-network + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" + deploy: + resources: + limits: + cpus: '1' + memory: 1G + reservations: + cpus: '0.25' + memory: 256M + +networks: + kaopeilian-network: + external: true + name: kaopeilian-network + prod-network: + external: true + name: prod-network diff --git a/deploy/docker/docker-compose.dev.yml b/deploy/docker/docker-compose.dev.yml new file mode 100644 index 0000000..ca4df27 --- /dev/null +++ b/deploy/docker/docker-compose.dev.yml @@ -0,0 +1,186 @@ +# 考培练系统开发环境 Docker Compose 配置 +name: kaopeilian-dev + +services: + # 前端开发服务 + frontend-dev: + build: + context: ./kaopeilian-frontend + dockerfile: Dockerfile.dev + container_name: kaopeilian-frontend-dev + restart: unless-stopped + ports: + - "3001:3001" + environment: + - NODE_ENV=development + - VITE_APP_ENV=development + - VITE_API_BASE_URL="" + - VITE_WS_BASE_URL="" + - VITE_USE_MOCK_DATA=false + - VITE_ENABLE_DEVTOOLS=true + - VITE_ENABLE_REQUEST_LOG=true + - CHOKIDAR_USEPOLLING=true # 支持Docker中的文件监听 + - WATCHPACK_POLLING=true # 支持热重载 + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-frontend:/app + - kaopeilian_frontend_node_modules:/app/node_modules # 使用命名卷 + - ./kaopeilian-frontend/logs:/app/logs + networks: + - kaopeilian-dev-network + depends_on: + backend-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 后端开发服务 + backend-dev: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile.dev + container_name: kaopeilian-backend-dev + restart: unless-stopped + ports: + - "8000:8000" + environment: + - PYTHONPATH=/app + - DEBUG=true + - ENV=development + - "DATABASE_URL=mysql+aiomysql://root:nj861021@mysql-dev:3306/kaopeilian?charset=utf8mb4" + - REDIS_URL=redis://redis-dev:6379/0 + - MYSQL_HOST=mysql-dev + - MYSQL_PORT=3306 + - MYSQL_USER=root + - "MYSQL_PASSWORD=nj861021" + - MYSQL_DATABASE=kaopeilian + - REDIS_HOST=redis-dev + - REDIS_PORT=6379 + - REDIS_DB=0 + - PYTHONUNBUFFERED=1 # 确保日志实时输出 + - CORS_ORIGINS=["http://localhost:3000","http://localhost:3001","http://localhost:5173","http://127.0.0.1:3000","http://127.0.0.1:3001","http://127.0.0.1:5173"] + # 完全禁用代理(覆盖 Docker Desktop 的代理配置) + - HTTP_PROXY= + - HTTPS_PROXY= + - http_proxy= + - https_proxy= + - NO_PROXY= + - no_proxy= + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-backend:/app:rw + - ./kaopeilian-backend/uploads:/app/uploads:rw + - ./kaopeilian-backend/logs:/app/logs:rw + # 排除虚拟环境目录,避免冲突 + - /app/venv + networks: + - kaopeilian-dev-network + depends_on: + mysql-dev: + condition: service_healthy + redis-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 开发数据库 + mysql-dev: + image: mysql:8.0.43 + container_name: kaopeilian-mysql-dev + restart: unless-stopped + ports: + - "3306:3306" + environment: + MYSQL_ROOT_PASSWORD: nj861021 + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + volumes: + - mysql_dev_data:/var/lib/mysql + - ./kaopeilian-backend/scripts/init_database_unified.sql:/docker-entrypoint-initdb.d/init.sql:ro + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - kaopeilian-dev-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pnj861021"] + timeout: 20s + retries: 10 + start_period: 30s + + # 开发 Redis 缓存 + redis-dev: + image: redis:7.2-alpine + container_name: kaopeilian-redis-dev + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - redis_dev_data:/data + networks: + - kaopeilian-dev-network + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # phpMyAdmin 数据库管理界面(可选) + phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + container_name: kaopeilian-phpmyadmin-dev + restart: unless-stopped + ports: + - "8080:80" + environment: + PMA_HOST: mysql-dev + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: nj861021 + networks: + - kaopeilian-dev-network + depends_on: + mysql-dev: + condition: service_healthy + profiles: + - admin # 使用 profile 控制是否启动 + + # 邮件测试服务(开发环境用于测试邮件发送) + mailhog: + image: mailhog/mailhog:latest + container_name: kaopeilian-mailhog-dev + restart: unless-stopped + ports: + - "1025:1025" # SMTP 端口 + - "8025:8025" # Web UI 端口 + networks: + - kaopeilian-dev-network + profiles: + - mail # 使用 profile 控制是否启动 + +# 开发网络 +networks: + kaopeilian-dev-network: + driver: bridge + name: kaopeilian-dev-network + +# 开发数据卷 +volumes: + mysql_dev_data: + driver: local + name: kaopeilian-mysql-dev-data + redis_dev_data: + driver: local + name: kaopeilian-redis-dev-data + kaopeilian_frontend_node_modules: + driver: local + name: kaopeilian-frontend-node-modules diff --git a/deploy/docker/docker-compose.kpl.yml b/deploy/docker/docker-compose.kpl.yml new file mode 100644 index 0000000..b028ee9 --- /dev/null +++ b/deploy/docker/docker-compose.kpl.yml @@ -0,0 +1,207 @@ +# 瑞小美团队开发环境 Docker Compose 配置 +# 域名:kpl.ireborn.com.cn +# 支持热重载,与演示系统完全隔离 +# +# ⚠️ 敏感配置(API Key 等)请在 .env.kpl 文件中设置 +# 启动命令:docker compose --env-file .env.kpl -f docker-compose.kpl.yml up -d + +name: kpl-dev + +services: + # 前端开发服务 + kpl-frontend-dev: + build: + context: ./kaopeilian-frontend + dockerfile: Dockerfile.dev + container_name: kpl-frontend-dev + restart: unless-stopped + ports: + - "3002:3001" + environment: + - NODE_ENV=development + - VITE_APP_ENV=development + - VITE_API_BASE_URL=https://kpl.ireborn.com.cn + - VITE_WS_BASE_URL=wss://kpl.ireborn.com.cn + - VITE_USE_MOCK_DATA=false + - VITE_ENABLE_DEVTOOLS=true + - VITE_ENABLE_REQUEST_LOG=true + - CHOKIDAR_USEPOLLING=true # 支持Docker中的文件监听 + - WATCHPACK_POLLING=true # 支持热重载 + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-frontend:/app + - kpl_frontend_node_modules:/app/node_modules # 使用命名卷 + - ./kaopeilian-frontend/logs:/app/logs + networks: + - kpl-dev-network + depends_on: + kpl-backend-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3001/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 后端开发服务 + kpl-backend-dev: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile.dev + container_name: kpl-backend-dev + restart: unless-stopped + ports: + - "8001:8000" + environment: + - PYTHONPATH=/app + - DEBUG=true + - ENV=development + - "DATABASE_URL=mysql+aiomysql://root:nj861021@kpl-mysql-dev:3306/kaopeilian?charset=utf8mb4" + - REDIS_URL=redis://kpl-redis-dev:6379/0 + - MYSQL_HOST=kpl-mysql-dev + - MYSQL_PORT=3306 + - MYSQL_USER=root + - "MYSQL_PASSWORD=nj861021" + - MYSQL_DATABASE=kaopeilian + - REDIS_HOST=kpl-redis-dev + - REDIS_PORT=6379 + - REDIS_DB=0 + - PYTHONUNBUFFERED=1 # 确保日志实时输出 + - CORS_ORIGINS=["https://kpl.ireborn.com.cn","http://localhost:3002","http://127.0.0.1:3002"] + # 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 + # Coze 播课工作流配置(瑞小美专用) + - COZE_BROADCAST_WORKFLOW_ID=7561161554420482088 + - COZE_BROADCAST_SPACE_ID=7474971491470688296 + - COZE_BROADCAST_BOT_ID=7560643598174683145 + # 完全禁用代理(覆盖 Docker Desktop 的代理配置) + - HTTP_PROXY= + - HTTPS_PROXY= + - http_proxy= + - https_proxy= + - NO_PROXY= + - no_proxy= + # AI 服务配置(遵循瑞小美 AI 接入规范) + # 注意:API Key 应从 .env 文件或密钥管理服务获取 + - AI_PRIMARY_API_KEY=${AI_PRIMARY_API_KEY:-} + - AI_ANTHROPIC_API_KEY=${AI_ANTHROPIC_API_KEY:-} + - AI_PRIMARY_BASE_URL=https://4sapi.com/v1 + - AI_FALLBACK_API_KEY=${AI_FALLBACK_API_KEY:-} + - AI_FALLBACK_BASE_URL=https://openrouter.ai/api/v1 + # 默认模型:遵循"优先最强"原则,使用 Claude Opus 4.5 + - AI_DEFAULT_MODEL=claude-opus-4-5-20251101-thinking + - AI_TIMEOUT=120 + volumes: + # 挂载源代码实现热重载(关键配置) + - ./kaopeilian-backend:/app:rw + - /data/kaopeilian/uploads/kpl:/app/uploads:rw # 迁移到数据盘 + - ./kaopeilian-backend/logs:/app/logs:rw + - ./kaopeilian-backend/secrets:/app/secrets:ro + # 排除虚拟环境目录,避免冲突 + - /app/venv + networks: + - kpl-dev-network + depends_on: + kpl-mysql-dev: + condition: service_healthy + kpl-redis-dev: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + # 开发数据库 + kpl-mysql-dev: + image: mysql:8.0.43 + container_name: kpl-mysql-dev + restart: unless-stopped + ports: + - "3308:3306" + environment: + MYSQL_ROOT_PASSWORD: nj861021 + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + TZ: Asia/Shanghai + volumes: + - kpl_mysql_dev_data:/var/lib/mysql + - ./kaopeilian-backend/scripts/init_database_unified.sql:/docker-entrypoint-initdb.d/init.sql:ro + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - kpl-dev-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pnj861021"] + timeout: 20s + retries: 10 + start_period: 30s + + # 开发 Redis 缓存 + kpl-redis-dev: + image: redis:7.2-alpine + container_name: kpl-redis-dev + restart: unless-stopped + ports: + - "6380:6379" + environment: + - TZ=Asia/Shanghai + volumes: + - kpl_redis_dev_data:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kpl-dev-network + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # phpMyAdmin 数据库管理界面(可选) + kpl-phpmyadmin: + image: phpmyadmin/phpmyadmin:latest + container_name: kpl-phpmyadmin-dev + restart: unless-stopped + ports: + - "8081:80" + environment: + PMA_HOST: kpl-mysql-dev + PMA_PORT: 3306 + PMA_USER: root + PMA_PASSWORD: nj861021 + networks: + - kpl-dev-network + depends_on: + kpl-mysql-dev: + condition: service_healthy + profiles: + - admin # 使用 profile 控制是否启动 + +# 开发网络 +networks: + kpl-dev-network: + external: true + name: kpl-dev-network + +# 开发数据卷 +volumes: + kpl_mysql_dev_data: + driver: local + name: kpl-mysql-dev-data + kpl_redis_dev_data: + driver: local + name: kpl-redis-dev-data + kpl_frontend_node_modules: + driver: local + name: kpl-frontend-node-modules + diff --git a/deploy/docker/docker-compose.prod-multi.yml b/deploy/docker/docker-compose.prod-multi.yml new file mode 100644 index 0000000..d3c2aea --- /dev/null +++ b/deploy/docker/docker-compose.prod-multi.yml @@ -0,0 +1,482 @@ +# 多客户生产环境 Docker Compose 配置 +# 共享MySQL实例,独立前端/后端/Redis容器 +# +# 重要说明: +# - 所有租户前端共享同一个 dist 目录: /root/aiedu/kaopeilian-frontend/dist +# - 编译一次前端,所有租户自动更新(无需重新构建镜像) +# - 更新前端步骤:cd /root/aiedu/kaopeilian-frontend && npm run build +# - 后端API地址由nginx反向代理根据域名自动路由,前端无需区分 +# +name: prod-multi + +services: + # ============================================ + # 共享MySQL数据库服务 + # ============================================ + prod-mysql: + image: mysql:8.0.43 + container_name: prod-mysql + restart: unless-stopped + environment: + TZ: Asia/Shanghai + MYSQL_ROOT_PASSWORD: ProdMySQL2025!@# + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3309:3306" + volumes: + - /data/mysql-data:/var/lib/mysql + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - prod-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-uroot", "-pProdMySQL2025!@#"] + timeout: 20s + retries: 10 + start_period: 30s + + # ============================================ + # 华尔倍丽 (hua.ireborn.com.cn) + # ============================================ + hua-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: hua-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3010:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - hua-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + hua-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-hua-backend:latest + container_name: hua-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.hua + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8010:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-hua:/app/uploads + - /data/prod-envs/logs-hua:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + hua-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + hua-redis: + image: redis:7.2-alpine + container_name: hua-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6390:6379" + volumes: + - /data/redis-data/hua:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 杨扬宠物 (yy.ireborn.com.cn) + # ============================================ + yy-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: yy-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3011:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - yy-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + yy-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-yy-backend:latest + container_name: yy-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.yy + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8011:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-yy:/app/uploads + - /data/prod-envs/logs-yy:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + yy-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + yy-redis: + image: redis:7.2-alpine + container_name: yy-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6391:6379" + volumes: + - /data/redis-data/yy:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 武汉禾丽 (hl.ireborn.com.cn) + # ============================================ + hl-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: hl-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3012:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - hl-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + hl-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-hl-backend:latest + container_name: hl-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.hl + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8012:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-hl:/app/uploads + - /data/prod-envs/logs-hl:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + hl-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + hl-redis: + image: redis:7.2-alpine + container_name: hl-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6392:6379" + volumes: + - /data/redis-data/hl:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 芯颜定制 (xy.ireborn.com.cn) + # ============================================ + xy-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + image: kaopeilian-frontend:shared + container_name: xy-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3013:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - xy-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + xy-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-xy-backend:latest + container_name: xy-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.xy + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8013:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-xy:/app/uploads + - /data/prod-envs/logs-xy:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + xy-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + xy-redis: + image: redis:7.2-alpine + container_name: xy-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6393:6379" + volumes: + - /data/redis-data/xy:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # ============================================ + # 飞沃 (fw.ireborn.com.cn) + # ============================================ + fw-frontend: + # 使用共享的前端镜像,挂载统一的dist目录 + # 这样只需编译一次前端,所有租户自动更新 + image: kaopeilian-frontend:shared + container_name: fw-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "3014:80" + volumes: + - /root/aiedu/kaopeilian-frontend/dist:/usr/share/nginx/html:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + - kaopeilian-network + depends_on: + - fw-backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + fw-backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + image: prod-multi-fw-backend:latest + container_name: fw-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.fw + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8014:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/prod-envs/uploads-fw:/app/uploads + - /data/prod-envs/logs-fw:/app/logs + - /data/prod-envs/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - prod-network + - kaopeilian-network + depends_on: + prod-mysql: + condition: service_healthy + fw-redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 60s + + fw-redis: + image: redis:7.2-alpine + container_name: fw-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6394:6379" + volumes: + - /data/redis-data/fw:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - prod-network + command: redis-server --appendonly yes --maxmemory 128mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + +# 网络配置 +networks: + prod-network: + driver: bridge + name: prod-network + kaopeilian-network: + external: true + name: kaopeilian-network + + + diff --git a/deploy/docker/docker-compose.yml b/deploy/docker/docker-compose.yml new file mode 100644 index 0000000..4fb7df6 --- /dev/null +++ b/deploy/docker/docker-compose.yml @@ -0,0 +1,178 @@ +# 考培练系统完整部署配置 +version: '3.8' + +services: + # MySQL数据库服务 + mysql: + image: mysql:8.0.43 + container_name: kaopeilian-mysql + restart: unless-stopped + environment: + TZ: Asia/Shanghai + MYSQL_ROOT_PASSWORD: Kaopeilian2025!@# + MYSQL_DATABASE: kaopeilian + MYSQL_CHARACTER_SET_SERVER: utf8mb4 + MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci + ports: + - "3307:3306" + volumes: + - mysql_data:/var/lib/mysql + - ./考培练系统规划/数据库-里程碑备份/7-完成数据分析模块-20251016_075159.sql:/docker-entrypoint-initdb.d/init.sql + - ./kaopeilian-backend/mysql.cnf:/etc/mysql/conf.d/mysql.cnf + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: --default-authentication-plugin=mysql_native_password + networks: + - kaopeilian-network + healthcheck: + test: ["CMD", "mysqladmin", "ping", "-h", "localhost"] + timeout: 20s + retries: 10 + + # 后端API服务 + backend: + build: + context: ./kaopeilian-backend + dockerfile: Dockerfile + container_name: kaopeilian-backend + restart: unless-stopped + env_file: + - ./kaopeilian-backend/.env.production + environment: + - TZ=Asia/Shanghai + - PYTHONPATH=/app + ports: + - "8000:8000" + volumes: + - ./kaopeilian-backend/app:/app/app # 代码热重载 + - /data/kaopeilian/uploads/demo:/app/uploads # 迁移到数据盘 + - ./kaopeilian-backend/logs:/app/logs + - ./kaopeilian-backend/secrets:/app/secrets:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + command: ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"] + networks: + - kaopeilian-network + depends_on: + mysql: + condition: service_healthy + redis: + condition: service_healthy + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 30s + + # 前端服务 + frontend: + build: + context: ./kaopeilian-frontend + dockerfile: Dockerfile + target: production + args: + - NODE_ENV=production + - VITE_API_BASE_URL=https://aiedu.ireborn.com.cn + - VITE_WS_BASE_URL=wss://aiedu.ireborn.com.cn + - VITE_USE_MOCK_DATA=false + container_name: kaopeilian-frontend + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + volumes: + - ./kaopeilian-frontend/docker/nginx.conf:/etc/nginx/nginx.conf:ro + - ./kaopeilian-frontend/docker/default.conf:/etc/nginx/conf.d/default.conf:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + depends_on: + - backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 40s + + # Redis缓存服务 + redis: + image: redis:7.2-alpine + container_name: kaopeilian-redis + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "6379:6379" + volumes: + - redis_data:/data + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 3 + + # Nginx反向代理和SSL终止 + nginx: + image: nginx:1.25-alpine + container_name: kaopeilian-nginx + restart: unless-stopped + environment: + - TZ=Asia/Shanghai + ports: + - "80:80" + - "443:443" + volumes: + - ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro + - ./nginx/conf.d:/etc/nginx/conf.d:ro + - /etc/letsencrypt:/etc/letsencrypt:ro + - /var/www/certbot:/var/www/certbot:ro + - /etc/localtime:/etc/localtime:ro + - /etc/timezone:/etc/timezone:ro + networks: + - kaopeilian-network + - kpl-dev-network + depends_on: + - frontend + - backend + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80/health"] + interval: 30s + timeout: 10s + retries: 3 + + # SSL证书续期服务 + certbot: + image: certbot/certbot:latest + container_name: kaopeilian-certbot + restart: "no" + volumes: + - /etc/letsencrypt:/etc/letsencrypt + - /var/www/certbot:/var/www/certbot + entrypoint: "/bin/sh -c 'trap exit TERM; while :; do certbot renew; sleep 12h & wait $${!}; done;'" + profiles: + - ssl + +# 网络配置 +networks: + kaopeilian-network: + driver: bridge + name: kaopeilian-network + kpl-dev-network: + external: true + name: kpl-dev-network + +# 数据卷配置 +volumes: + redis_data: + driver: local + name: kaopeilian-redis-data + mysql_data: + driver: local + name: kaopeilian-mysql-data \ No newline at end of file diff --git a/deploy/nginx/conf.d/admin.conf b/deploy/nginx/conf.d/admin.conf new file mode 100644 index 0000000..bba6c91 --- /dev/null +++ b/deploy/nginx/conf.d/admin.conf @@ -0,0 +1,91 @@ +# 考培练系统 SaaS 超级管理后台 +# 域名:admin.kpl.ireborn.com.cn + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name admin.kpl.ireborn.com.cn; + + # Let's Encrypt 证书验证 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS +server { + listen 443 ssl; + http2 on; + server_name admin.kpl.ireborn.com.cn; + + # SSL 证书 + ssl_certificate /etc/letsencrypt/live/admin.kpl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/admin.kpl.ireborn.com.cn/privkey.pem; + + # SSL 配置 + ssl_session_timeout 1d; + ssl_session_cache shared:AdminSSL:10m; + ssl_session_tickets off; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384; + ssl_prefer_server_ciphers off; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # Docker DNS resolver + resolver 127.0.0.11 valid=30s; + + # 动态 upstream 变量 + set $admin_frontend kaopeilian-admin-frontend; + set $admin_backend kaopeilian-admin-backend; + + # API 路由 - 使用 rewrite 确保正确传递 URI + location /api/ { + rewrite ^/api/(.*)$ /api/$1 break; + proxy_pass http://$admin_backend:8000; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + proxy_read_timeout 300s; + proxy_connect_timeout 75s; + client_max_body_size 50M; + } + + # 健康检查 + location = /health { + proxy_pass http://$admin_backend:8000/health; + proxy_set_header Host $host; + access_log off; + } + + # 前端静态资源 + location / { + proxy_pass http://$admin_frontend:80; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + + # HTML 不缓存 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } +} diff --git a/deploy/nginx/conf.d/ex.conf b/deploy/nginx/conf.d/ex.conf new file mode 100644 index 0000000..d08572c --- /dev/null +++ b/deploy/nginx/conf.d/ex.conf @@ -0,0 +1,102 @@ +# 恩喜成都总院 (ex.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name ex.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl; + http2 on; + server_name ex.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/ex.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/ex.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://ex-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://ex-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://ex-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://ex-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://ex-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/fw.conf b/deploy/nginx/conf.d/fw.conf new file mode 100644 index 0000000..0c6a245 --- /dev/null +++ b/deploy/nginx/conf.d/fw.conf @@ -0,0 +1,101 @@ +# 飞沃 (fw.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name fw.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name fw.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/fw.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/fw.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://fw-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://fw-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://fw-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://fw-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://fw-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/hl.conf b/deploy/nginx/conf.d/hl.conf new file mode 100644 index 0000000..1e4d4d6 --- /dev/null +++ b/deploy/nginx/conf.d/hl.conf @@ -0,0 +1,101 @@ +# 武汉禾丽 (hl.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name hl.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name hl.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/hl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hl.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://hl-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://hl-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://hl-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://hl-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://hl-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/hua.conf b/deploy/nginx/conf.d/hua.conf new file mode 100644 index 0000000..34efdfe --- /dev/null +++ b/deploy/nginx/conf.d/hua.conf @@ -0,0 +1,101 @@ +# 华尔倍丽 (hua.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name hua.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name hua.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/hua.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/hua.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://hua-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://hua-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://hua-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://hua-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://hua-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/kaopeilian.conf b/deploy/nginx/conf.d/kaopeilian.conf new file mode 100644 index 0000000..5954427 --- /dev/null +++ b/deploy/nginx/conf.d/kaopeilian.conf @@ -0,0 +1,150 @@ +# 考陪练系统 Nginx 配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name aiedu.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name aiedu.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/aiedu.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/aiedu.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://kaopeilian-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端生产服务器(HTML 不缓存) + location / { + proxy_pass http://kaopeilian-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 修复前端localhost:8000请求的特殊处理 + location ~* ^/localhost:8000/(.*)$ { + rewrite ^/localhost:8000/(.*)$ /$1 break; + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端生产服务器 API + location /api/ { + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 支持 WebSocket + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时配置 - 增加到10分钟以支持AI试题生成等长时间处理 + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端健康检查 + location /health { + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://kaopeilian-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } + + # GitHub Webhook处理 + location /webhook { + proxy_pass http://172.18.0.1:9000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Webhook专用配置 + proxy_read_timeout 60s; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + } + + # Webhook状态检查 + location /webhook/health { + proxy_pass http://172.18.0.1:9000/health; + proxy_set_header Host $host; + access_log off; + } + + location /webhook/status { + proxy_pass http://172.18.0.1:9000/status; + proxy_set_header Host $host; + } +} + + + diff --git a/deploy/nginx/conf.d/kpl.conf b/deploy/nginx/conf.d/kpl.conf new file mode 100644 index 0000000..79a40fd --- /dev/null +++ b/deploy/nginx/conf.d/kpl.conf @@ -0,0 +1,118 @@ +# 瑞小美团队开发环境 Nginx 配置 +# 域名:kpl.ireborn.com.cn +# 支持 HTTP 和 HTTPS 访问,热重载 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name kpl.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name kpl.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/kpl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/kpl.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端服务(共享 dist 方案) + location / { + proxy_pass http://kpl-frontend-dev:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 不缓存 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # JS/CSS 静态资源(带 hash 可长期缓存) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://kpl-frontend-dev:80; + proxy_set_header Host $host; + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 修复前端localhost:8000请求的特殊处理 + location ~* ^/localhost:8000/(.*)$ { + rewrite ^/localhost:8000/(.*)$ /$1 break; + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端开发服务器 API + location /api/ { + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 支持 WebSocket + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # 超时配置 - 增加到10分钟以支持AI试题生成等长时间处理 + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 后端健康检查 + location /health { + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://kpl-backend-dev:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} + diff --git a/deploy/nginx/conf.d/pl.conf b/deploy/nginx/conf.d/pl.conf new file mode 100644 index 0000000..c02ce1f --- /dev/null +++ b/deploy/nginx/conf.d/pl.conf @@ -0,0 +1,73 @@ +# 陪练试用版 Nginx配置 +# 域名: pl.ireborn.com.cn + +upstream pl_backend { + server 172.18.0.1:8020; +} + +upstream pl_frontend { + server 172.18.0.1:3020; +} + +# HTTP -> HTTPS 重定向 +server { + listen 80; + server_name pl.ireborn.com.cn; + + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS 服务 +server { + listen 443 ssl; + server_name pl.ireborn.com.cn; + + ssl_certificate /etc/letsencrypt/live/pl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pl.ireborn.com.cn/privkey.pem; + ssl_protocols TLSv1.2 TLSv1.3; + + access_log /var/log/nginx/pl_access.log; + error_log /var/log/nginx/pl_error.log; + + location / { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 禁用缓存 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + add_header Expires "0"; + } + + location /api/ { + proxy_pass http://pl_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + location /@vite/ { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/deploy/nginx/conf.d/pl.conf.disabled b/deploy/nginx/conf.d/pl.conf.disabled new file mode 100644 index 0000000..182ded8 --- /dev/null +++ b/deploy/nginx/conf.d/pl.conf.disabled @@ -0,0 +1,99 @@ +# 陪练试用版 Nginx配置 +# 域名: pl.ireborn.com.cn +# 后端: peilian-backend:8000 (通过外部网络访问 host.docker.internal:8020) +# 前端: peilian-frontend:3000 (通过外部网络访问 host.docker.internal:3020) + +upstream pl_backend { + server host.docker.internal:8020; +} + +upstream pl_frontend { + server host.docker.internal:3020; +} + +# HTTP -> HTTPS 重定向 +server { + listen 80; + server_name pl.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + location / { + return 301 https://$host$request_uri; + } +} + +# HTTPS 服务 +server { + listen 443 ssl http2; + server_name pl.ireborn.com.cn; + + # SSL证书 + ssl_certificate /etc/letsencrypt/live/pl.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/pl.ireborn.com.cn/privkey.pem; + + # SSL配置 + ssl_session_timeout 1d; + ssl_session_cache shared:SSL:50m; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256; + ssl_prefer_server_ciphers off; + + # 安全头 + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header X-XSS-Protection "1; mode=block" always; + + # 日志 + access_log /var/log/nginx/pl_access.log; + error_log /var/log/nginx/pl_error.log; + + # 前端页面 + location / { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection 'upgrade'; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_cache_bypass $http_upgrade; + } + + # API代理 + location /api/ { + proxy_pass http://pl_backend; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 超时设置 + proxy_connect_timeout 60s; + proxy_send_timeout 60s; + proxy_read_timeout 60s; + } + + # WebSocket代理(用于HMR) + location /_hmr { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } + + # Vite HMR WebSocket + location /@vite/ { + proxy_pass http://pl_frontend; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_set_header Host $host; + } +} diff --git a/deploy/nginx/conf.d/xy.conf b/deploy/nginx/conf.d/xy.conf new file mode 100644 index 0000000..8ce3692 --- /dev/null +++ b/deploy/nginx/conf.d/xy.conf @@ -0,0 +1,96 @@ +# 芯颜定制 (xy.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name xy.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name xy.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/xy.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/xy.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端服务 - HTML文件不缓存 + location / { + proxy_pass http://xy-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML文件不缓存,确保获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # JS/CSS等静态资源(带hash的文件可长期缓存) + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { + proxy_pass http://xy-frontend:80; + proxy_set_header Host $host; + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 后端API + location /api/ { + proxy_pass http://xy-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://xy-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://xy-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/conf.d/yy.conf b/deploy/nginx/conf.d/yy.conf new file mode 100644 index 0000000..981a80b --- /dev/null +++ b/deploy/nginx/conf.d/yy.conf @@ -0,0 +1,101 @@ +# 杨扬宠物 (yy.ireborn.com.cn) Nginx配置 +# 支持 HTTP 和 HTTPS 访问 + +# HTTP 重定向到 HTTPS +server { + listen 80; + server_name yy.ireborn.com.cn; + + # Let's Encrypt 验证路径 + location /.well-known/acme-challenge/ { + root /var/www/certbot; + } + + # 其他请求重定向到 HTTPS + location / { + return 301 https://$server_name$request_uri; + } +} + +# HTTPS 配置 +server { + listen 443 ssl http2; + server_name yy.ireborn.com.cn; + + # SSL 证书配置 + ssl_certificate /etc/letsencrypt/live/yy.ireborn.com.cn/fullchain.pem; + ssl_certificate_key /etc/letsencrypt/live/yy.ireborn.com.cn/privkey.pem; + + # SSL 安全配置 + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-AES128-SHA256:ECDHE-RSA-AES256-SHA384; + ssl_prefer_server_ciphers on; + ssl_session_cache shared:SSL:10m; + ssl_session_timeout 10m; + + # 安全头 + add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; + add_header X-Frame-Options DENY always; + add_header X-Content-Type-Options nosniff always; + add_header X-XSS-Protection "1; mode=block" always; + + # 前端静态资源(带哈希,长期缓存) + location /assets/ { + proxy_pass http://yy-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # 带哈希的文件可以长期缓存 + add_header Cache-Control "public, max-age=31536000, immutable"; + expires 1y; + } + + # 前端服务(HTML 不缓存) + location / { + proxy_pass http://yy-frontend:80; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + + # HTML 文件不缓存,确保用户获取最新版本 + add_header Cache-Control "no-cache, no-store, must-revalidate"; + add_header Pragma "no-cache"; + expires 0; + } + + # 后端API + location /api/ { + proxy_pass http://yy-backend:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + proxy_connect_timeout 600s; + proxy_send_timeout 600s; + proxy_read_timeout 600s; + } + + # 健康检查 + location /health { + proxy_pass http://yy-backend:8000; + proxy_set_header Host $host; + access_log off; + } + + # 静态文件上传 + location /static/uploads/ { + proxy_pass http://yy-backend:8000; + proxy_set_header Host $host; + expires 1y; + add_header Cache-Control "public"; + } +} diff --git a/deploy/nginx/nginx.conf b/deploy/nginx/nginx.conf new file mode 100644 index 0000000..49fc0e6 --- /dev/null +++ b/deploy/nginx/nginx.conf @@ -0,0 +1,53 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日志格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 基础配置 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + client_max_body_size 50M; + + # Gzip压缩 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + text/plain + text/css + text/xml + text/javascript + application/json + application/javascript + application/xml+rss + application/atom+xml + image/svg+xml; + + # 包含站点配置 + include /etc/nginx/conf.d/*.conf; +} + + + diff --git a/deploy/scripts/auto_update.sh b/deploy/scripts/auto_update.sh new file mode 100755 index 0000000..c6ec0d0 --- /dev/null +++ b/deploy/scripts/auto_update.sh @@ -0,0 +1,152 @@ +#!/bin/bash + +# 考培练系统自动更新脚本 +# 作者: AI Assistant +# 日期: 2025-09-25 + +set -e + +# 配置变量 +PROJECT_DIR="/root/aiedu" +LOG_FILE="/var/log/kaopeilian_update.log" +BACKUP_DIR="/root/aiedu/backups/updates" + +# 日志函数 +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +# 创建备份目录 +mkdir -p "$BACKUP_DIR" + +log "=== 开始检查代码更新 ===" + +cd "$PROJECT_DIR" + +# 获取当前提交哈希 +LOCAL_COMMIT=$(git rev-parse HEAD) +log "本地提交: $LOCAL_COMMIT" + +# 拉取最新代码 +git fetch origin production + +# 获取远程提交哈希 +REMOTE_COMMIT=$(git rev-parse origin/production) +log "远程提交: $REMOTE_COMMIT" + +# 检查是否有更新 +if [ "$LOCAL_COMMIT" = "$REMOTE_COMMIT" ]; then + log "代码已是最新版本,无需更新" + exit 0 +fi + +log "发现代码更新,开始部署流程..." + +# 创建备份 +BACKUP_NAME="backup_$(date '+%Y%m%d_%H%M%S')" +log "创建备份: $BACKUP_NAME" + +# 备份当前代码 +git stash push -m "Auto backup before update $BACKUP_NAME" + +# 备份数据库(如果MySQL容器在运行) +if docker ps | grep -q kaopeilian-mysql; then + docker exec kaopeilian-mysql mysqldump -u root -p'Kaopeilian2025!@#' kaopeilian > "$BACKUP_DIR/${BACKUP_NAME}_database.sql" + log "数据库备份完成" +fi + +# 拉取最新代码 +log "拉取最新代码..." +git pull origin production + +# 检查是否需要重新构建 +NEED_REBUILD=false + +# 检查Docker文件变化 +if git diff --name-only "$LOCAL_COMMIT" "$REMOTE_COMMIT" | grep -E "(Dockerfile|docker-compose\.yml|requirements\.txt|package\.json)"; then + NEED_REBUILD=true + log "检测到构建文件变化,需要重新构建镜像" +fi + +# 检查前端文件变化并构建 +if git diff --name-only "$LOCAL_COMMIT" "$REMOTE_COMMIT" | grep -E "kaopeilian-frontend/(src/|package\.json|vite\.config\.ts)"; then + log "检测到前端代码变化,开始构建前端..." + + cd "$PROJECT_DIR/kaopeilian-frontend" + + # 确保依赖已安装 + if [ ! -d "node_modules" ]; then + log "安装前端依赖..." + npm install + fi + + # 构建前端 + log "构建前端应用..." + npm run build + + if [ $? -eq 0 ]; then + log "前端构建成功" + NEED_REBUILD=true + else + log "⚠️ 前端构建失败,但继续部署流程" + fi + + cd "$PROJECT_DIR" +fi + +# 停止服务 +log "停止当前服务..." +docker compose down + +# 重新构建(如果需要) +if [ "$NEED_REBUILD" = true ]; then + log "重新构建Docker镜像..." + docker compose build --no-cache +else + log "使用现有镜像启动服务..." +fi + +# 启动服务 +log "启动更新后的服务..." +docker compose up -d + +# 等待服务启动 +sleep 60 + +# 健康检查 +log "执行健康检查..." +HEALTH_CHECK_URL="https://aiedu.ireborn.com.cn/health" + +# 尝试多次健康检查 +RETRY_COUNT=0 +MAX_RETRIES=5 + +while [ $RETRY_COUNT -lt $MAX_RETRIES ]; do + if curl -f -s -k "$HEALTH_CHECK_URL" > /dev/null; then + log "✅ 健康检查通过,更新成功完成" + + # 清理旧的备份(保留最近5个) + cd "$BACKUP_DIR" + ls -t backup_*_database.sql 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null || true + + log "=== 自动更新完成 ===" + exit 0 + else + RETRY_COUNT=$((RETRY_COUNT + 1)) + log "健康检查失败,重试 $RETRY_COUNT/$MAX_RETRIES" + sleep 10 + fi +done + +log "❌ 健康检查失败,开始回滚..." + +# 回滚代码 +cd "$PROJECT_DIR" +git reset --hard "$LOCAL_COMMIT" + +# 重新启动服务 +docker compose down +docker compose up -d + +log "回滚完成,请检查服务状态" +exit 1 diff --git a/deploy/scripts/check-config.sh b/deploy/scripts/check-config.sh new file mode 100755 index 0000000..d285dc2 --- /dev/null +++ b/deploy/scripts/check-config.sh @@ -0,0 +1,223 @@ +#!/bin/bash + +# 配置一致性检查脚本 +# 用于快速验证系统各组件配置是否一致 + +echo "🔧 配置一致性检查脚本" +echo "========================" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 检查结果统计 +PASS_COUNT=0 +FAIL_COUNT=0 + +# 检查函数 +check_service() { + local service_name=$1 + local url=$2 + local expected_response=$3 + + echo -n "检查 $service_name ... " + + if command -v curl >/dev/null 2>&1; then + response=$(curl -s --connect-timeout 5 "$url" 2>/dev/null) + if [[ $? -eq 0 && "$response" == *"$expected_response"* ]]; then + echo -e "${GREEN}✅ 通过${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 失败${NC}" + echo " 期望包含: $expected_response" + echo " 实际响应: $response" + ((FAIL_COUNT++)) + fi + else + echo -e "${YELLOW}⚠️ 跳过 (curl 未安装)${NC}" + fi +} + +# 检查Docker服务 +check_docker_service() { + local service_name=$1 + echo -n "检查 Docker 服务 $service_name ... " + + if command -v docker-compose >/dev/null 2>&1; then + if docker-compose ps | grep -q "$service_name.*Up"; then + echo -e "${GREEN}✅ 运行中${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 未运行${NC}" + ((FAIL_COUNT++)) + fi + else + echo -e "${YELLOW}⚠️ 跳过 (docker-compose 未安装)${NC}" + fi +} + +# 检查配置文件 +check_config_file() { + local file_path=$1 + local description=$2 + + echo -n "检查 $description ... " + + if [[ -f "$file_path" ]]; then + echo -e "${GREEN}✅ 存在${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 缺失${NC}" + echo " 文件路径: $file_path" + ((FAIL_COUNT++)) + fi +} + +# 检查端口占用 +check_port() { + local port=$1 + local service_name=$2 + + echo -n "检查端口 $port ($service_name) ... " + + if command -v lsof >/dev/null 2>&1; then + if lsof -i :$port >/dev/null 2>&1; then + echo -e "${GREEN}✅ 已占用${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 未占用${NC}" + ((FAIL_COUNT++)) + fi + elif command -v netstat >/dev/null 2>&1; then + if netstat -an | grep -q ":$port "; then + echo -e "${GREEN}✅ 已占用${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 未占用${NC}" + ((FAIL_COUNT++)) + fi + else + echo -e "${YELLOW}⚠️ 跳过 (lsof/netstat 未安装)${NC}" + fi +} + +echo "1. 检查基础服务状态" +echo "-------------------" + +# 检查Docker服务 +check_docker_service "mysql" +check_docker_service "redis" + +# 检查端口占用 +check_port 3306 "MySQL" +check_port 6379 "Redis" +check_port 8000 "后端API" +check_port 3001 "前端开发服务器" + +echo "" +echo "2. 检查服务健康状态" +echo "-------------------" + +# 检查后端健康状态 +check_service "后端API健康检查" "http://localhost:8000/health" "healthy" + +# 检查前端服务 +check_service "前端服务" "http://localhost:3001" "考培练系统" + +echo "" +echo "3. 检查配置文件" +echo "---------------" + +# 检查关键配置文件 +check_config_file "kaopeilian-backend/app/config/settings.py" "后端配置文件" +check_config_file "kaopeilian-frontend/src/api/config.ts" "前端API配置" +check_config_file "docker-compose.yml" "Docker配置文件" +check_config_file "配置一致性检查清单.md" "配置检查清单" + +echo "" +echo "4. 检查认证功能" +echo "---------------" + +# 检查登录API +echo -n "检查登录API ... " +if command -v curl >/dev/null 2>&1; then + login_response=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"username": "testuser", "password": "TestPass123!"}' 2>/dev/null) + + if [[ "$login_response" == *"access_token"* ]]; then + echo -e "${GREEN}✅ 正常工作${NC}" + ((PASS_COUNT++)) + + # 提取token测试认证 + if command -v jq >/dev/null 2>&1; then + token=$(echo "$login_response" | jq -r '.data.access_token' 2>/dev/null) + if [[ "$token" != "null" && "$token" != "" ]]; then + echo -n "检查token认证 ... " + auth_response=$(curl -s -H "Authorization: Bearer $token" \ + http://localhost:8000/api/v1/auth/me 2>/dev/null) + + if [[ "$auth_response" == *"testuser"* ]]; then + echo -e "${GREEN}✅ 正常工作${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 失败${NC}" + ((FAIL_COUNT++)) + fi + fi + else + echo " (跳过token测试 - jq未安装)" + fi + else + echo -e "${RED}❌ 失败${NC}" + echo " 响应: $login_response" + ((FAIL_COUNT++)) + fi +else + echo -e "${YELLOW}⚠️ 跳过 (curl 未安装)${NC}" +fi + +echo "" +echo "5. 检查CORS配置" +echo "---------------" + +echo -n "检查CORS预检请求 ... " +if command -v curl >/dev/null 2>&1; then + cors_response=$(curl -s -X OPTIONS http://localhost:8000/api/v1/auth/login \ + -H "Origin: http://localhost:3001" \ + -H "Access-Control-Request-Method: POST" \ + -H "Access-Control-Request-Headers: Content-Type" 2>/dev/null) + + if [[ $? -eq 0 ]]; then + echo -e "${GREEN}✅ 正常响应${NC}" + ((PASS_COUNT++)) + else + echo -e "${RED}❌ 失败${NC}" + ((FAIL_COUNT++)) + fi +else + echo -e "${YELLOW}⚠️ 跳过 (curl 未安装)${NC}" +fi + +echo "" +echo "========================" +echo "📊 检查结果统计" +echo "------------------------" +echo -e "通过: ${GREEN}$PASS_COUNT${NC}" +echo -e "失败: ${RED}$FAIL_COUNT${NC}" + +if [[ $FAIL_COUNT -eq 0 ]]; then + echo -e "\n🎉 ${GREEN}所有检查都通过了!系统配置正常。${NC}" + exit 0 +else + echo -e "\n⚠️ ${YELLOW}发现 $FAIL_COUNT 个问题,请检查配置。${NC}" + echo "" + echo "💡 解决建议:" + echo "1. 确保Docker服务已启动: docker-compose up -d" + echo "2. 检查端口是否被占用或服务未启动" + echo "3. 参考 '配置一致性检查清单.md' 核对配置" + echo "4. 确保数据库密码、CORS域名等配置一致" + exit 1 +fi diff --git a/deploy/scripts/check_environment.sh b/deploy/scripts/check_environment.sh new file mode 100644 index 0000000..ecaf7d0 --- /dev/null +++ b/deploy/scripts/check_environment.sh @@ -0,0 +1,91 @@ +#!/bin/bash +# 环境状态检查脚本 + +echo "=== 考培练系统环境状态检查 ===" +echo "检查时间: $(date)" +echo "" + +# 检查前端环境 +echo "🌐 前端环境检查:" +if curl -s -f http://localhost:3001 > /dev/null; then + echo "✅ 前端服务运行正常 (http://localhost:3001)" + + # 尝试获取环境信息 + if command -v jq &> /dev/null; then + echo "📊 前端环境信息:" + curl -s http://localhost:3001/api/env 2>/dev/null | jq . || echo "无法获取环境信息" + fi +else + echo "❌ 前端服务不可访问" +fi +echo "" + +# 检查后端环境 +echo "🚀 后端环境检查:" +if curl -s -f http://localhost:8000/health > /dev/null; then + echo "✅ 后端服务运行正常 (http://localhost:8000)" + + # 获取健康检查信息 + if command -v jq &> /dev/null; then + echo "📊 后端环境信息:" + curl -s http://localhost:8000/health | jq . + else + echo "健康检查响应:" + curl -s http://localhost:8000/health + fi +else + echo "❌ 后端服务不可访问" +fi +echo "" + +# 检查数据库连接 +echo "🗄️ 数据库连接检查:" +if curl -s -f http://localhost:8000/health/db > /dev/null 2>&1; then + echo "✅ 数据库连接正常" + if command -v jq &> /dev/null; then + curl -s http://localhost:8000/health/db | jq . + fi +else + echo "❌ 数据库连接异常" +fi +echo "" + +# 检查Redis连接 +echo "🔴 Redis连接检查:" +if curl -s -f http://localhost:8000/health/redis > /dev/null 2>&1; then + echo "✅ Redis连接正常" + if command -v jq &> /dev/null; then + curl -s http://localhost:8000/health/redis | jq . + fi +else + echo "❌ Redis连接异常" +fi +echo "" + +# 检查Docker容器状态 +echo "🐳 Docker容器状态:" +if command -v docker &> /dev/null; then + echo "开发环境容器:" + docker-compose -f docker-compose.dev.yml ps 2>/dev/null || echo "无法获取开发环境容器状态" + echo "" + echo "生产环境容器:" + docker-compose ps 2>/dev/null || echo "无法获取生产环境容器状态" +else + echo "Docker未安装或不可访问" +fi +echo "" + +# 检查端口占用 +echo "🔌 端口占用检查:" +ports=(3001 8000 3306 6379) +for port in "${ports[@]}"; do + if lsof -i :$port > /dev/null 2>&1; then + echo "✅ 端口 $port 已占用" + lsof -i :$port | head -2 | tail -1 | awk '{print " 进程:", $2, "命令:", $1}' + else + echo "❌ 端口 $port 未占用" + fi +done +echo "" + +echo "=== 环境检查完成 ===" \ No newline at end of file diff --git a/deploy/scripts/cleanup_docker.sh b/deploy/scripts/cleanup_docker.sh new file mode 100755 index 0000000..f01e432 --- /dev/null +++ b/deploy/scripts/cleanup_docker.sh @@ -0,0 +1,62 @@ +#!/bin/bash +# Docker 清理脚本 +# 用于清理未使用的Docker资源 + +echo "🧹 开始清理Docker资源..." + +# 1. 清理停止的容器 +echo "📋 清理停止的容器..." +stopped_containers=$(docker ps -a --filter "status=exited" -q) +if [ ! -z "$stopped_containers" ]; then + echo "发现停止的容器: $stopped_containers" + docker rm $stopped_containers + echo "✅ 已清理停止的容器" +else + echo "✅ 没有停止的容器需要清理" +fi + +# 2. 清理悬空镜像 +echo "📋 清理悬空镜像..." +dangling_images=$(docker images --filter "dangling=true" -q) +if [ ! -z "$dangling_images" ]; then + echo "发现悬空镜像: $dangling_images" + docker rmi $dangling_images + echo "✅ 已清理悬空镜像" +else + echo "✅ 没有悬空镜像需要清理" +fi + +# 3. 清理未使用的卷 +echo "📋 清理未使用的卷..." +unused_volumes=$(docker volume ls --filter "dangling=true" -q) +if [ ! -z "$unused_volumes" ]; then + echo "发现未使用的卷: $unused_volumes" + docker volume rm $unused_volumes + echo "✅ 已清理未使用的卷" +else + echo "✅ 没有未使用的卷需要清理" +fi + +# 4. 清理未使用的网络 +echo "📋 清理未使用的网络..." +unused_networks=$(docker network ls --filter "dangling=true" -q) +if [ ! -z "$unused_networks" ]; then + echo "发现未使用的网络: $unused_networks" + docker network rm $unused_networks + echo "✅ 已清理未使用的网络" +else + echo "✅ 没有未使用的网络需要清理" +fi + +# 5. 显示当前状态 +echo "📊 当前Docker状态:" +echo "运行中的容器:" +docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}\t{{.Ports}}" + +echo -e "\nDocker卷:" +docker volume ls + +echo -e "\nDocker网络:" +docker network ls + +echo "🎉 Docker清理完成!" diff --git a/deploy/scripts/deploy.sh b/deploy/scripts/deploy.sh new file mode 100755 index 0000000..3b4de45 --- /dev/null +++ b/deploy/scripts/deploy.sh @@ -0,0 +1,113 @@ +#!/bin/bash + +# 考陪练系统部署脚本 +# 用于部署前端、后端服务并申请SSL证书 + +set -e + +echo "=== 考陪练系统部署开始 ===" + +# 检查Docker是否运行 +if ! docker info > /dev/null 2>&1; then + echo "❌ Docker未运行,请先启动Docker服务" + exit 1 +fi + +# 检查Docker Compose是否安装 +if ! command -v docker-compose > /dev/null 2>&1; then + echo "❌ Docker Compose未安装" + exit 1 +fi + +echo "✅ Docker环境检查通过" + +# 创建必要的目录 +echo "📁 创建必要的目录..." +mkdir -p /root/aiedu/kaopeilian-backend/logs +mkdir -p /root/aiedu/kaopeilian-backend/uploads +mkdir -p /root/aiedu/nginx/conf.d + +# 停止现有容器 +echo "🛑 停止现有容器..." +docker-compose down || true + +# 构建镜像 +echo "🔨 构建Docker镜像..." +docker-compose build --no-cache + +# 启动服务(除了nginx,因为还没有SSL证书) +echo "🚀 启动后端和前端服务..." +docker-compose up -d kaopeilian-redis kaopeilian-backend kaopeilian-frontend + +# 等待服务启动 +echo "⏳ 等待服务启动..." +sleep 30 + +# 检查服务健康状态 +echo "🔍 检查服务健康状态..." +if curl -f http://localhost:8000/health > /dev/null 2>&1; then + echo "✅ 后端服务健康检查通过" +else + echo "❌ 后端服务健康检查失败" + docker-compose logs kaopeilian-backend + exit 1 +fi + +if curl -f http://localhost:3001/ > /dev/null 2>&1; then + echo "✅ 前端服务健康检查通过" +else + echo "❌ 前端服务健康检查失败" + docker-compose logs kaopeilian-frontend + exit 1 +fi + +echo "=== 基础服务部署完成 ===" +echo "前端访问地址: http://localhost:3001" +echo "后端API地址: http://localhost:8000" +echo "" +echo "下一步将申请SSL证书..." + +# 申请SSL证书 +echo "🔐 申请SSL证书..." +if command -v certbot > /dev/null 2>&1; then + # 停止nginx容器(如果运行) + docker-compose stop kaopeilian-nginx || true + + # 使用standalone模式申请证书 + certbot certonly \ + --standalone \ + --non-interactive \ + --agree-tos \ + --email admin@ireborn.com.cn \ + --domains aiedu.ireborn.com.cn \ + --pre-hook "docker-compose stop kaopeilian-nginx" \ + --post-hook "docker-compose up -d kaopeilian-nginx" + + if [ $? -eq 0 ]; then + echo "✅ SSL证书申请成功" + + # 启动nginx服务 + echo "🚀 启动Nginx反向代理服务..." + docker-compose up -d kaopeilian-nginx + + echo "=== 部署完成 ===" + echo "HTTPS访问地址: https://aiedu.ireborn.com.cn" + echo "前端访问地址: https://aiedu.ireborn.com.cn" + echo "后端API地址: https://aiedu.ireborn.com.cn/api" + else + echo "❌ SSL证书申请失败,请检查域名配置" + echo "HTTP访问地址: http://aiedu.ireborn.com.cn" + fi +else + echo "⚠️ certbot未安装,跳过SSL证书申请" + echo "请手动安装certbot并申请SSL证书" +fi + +echo "" +echo "=== 部署完成 ===" +echo "查看服务状态: docker-compose ps" +echo "查看日志: docker-compose logs [服务名]" +echo "停止服务: docker-compose down" + + + diff --git a/deploy/scripts/diagnose.sh b/deploy/scripts/diagnose.sh new file mode 100644 index 0000000..f1b34b4 --- /dev/null +++ b/deploy/scripts/diagnose.sh @@ -0,0 +1,34 @@ +#!/bin/bash +# 系统诊断脚本 + +echo "=== 系统诊断开始 ===" +echo "时间: $(date)" +echo "" + +echo "=== Docker服务状态 ===" +systemctl is-active docker || echo "Docker服务未运行" +echo "" + +echo "=== Docker版本 ===" +docker --version 2>/dev/null || echo "Docker命令不可用" +echo "" + +echo "=== 容器状态 ===" +docker ps -a 2>/dev/null || echo "无法获取容器状态" +echo "" + +echo "=== 网络连接测试 ===" +curl -I --connect-timeout 3 http://localhost 2>/dev/null || echo "本地80端口不可访问" +curl -I --connect-timeout 3 https://aiedu.ireborn.com.cn 2>/dev/null || echo "HTTPS不可访问" +echo "" + +echo "=== 端口占用检查 ===" +netstat -tlnp | grep -E ":(80|443|8000|3306|6379|9000)" || echo "关键端口未监听" +echo "" + +echo "=== 服务状态 ===" +systemctl is-active kaopeilian.service || echo "kaopeilian服务未运行" +systemctl is-active kaopeilian-webhook.service || echo "webhook服务未运行" +echo "" + +echo "=== 诊断完成 ===" diff --git a/deploy/scripts/diagnose_dify_network.sh b/deploy/scripts/diagnose_dify_network.sh new file mode 100755 index 0000000..f879f12 --- /dev/null +++ b/deploy/scripts/diagnose_dify_network.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Dify服务器网络诊断脚本 +# 请在Dify服务器(47.112.29.0)上运行此脚本 + +echo "=== Dify服务器网络诊断 ===" +echo "" + +# 1. DNS解析测试 +echo "1. DNS解析测试:" +echo " hl.ireborn.com.cn: $(nslookup hl.ireborn.com.cn 2>/dev/null | grep Address | tail -1 || echo '解析失败')" +echo " yy.ireborn.com.cn: $(nslookup yy.ireborn.com.cn 2>/dev/null | grep Address | tail -1 || echo '解析失败')" +echo "" + +# 2. 网络连通性测试 +echo "2. 网络连通性测试 (ping):" +ping -c 3 120.79.247.16 2>&1 | tail -3 +echo "" + +# 3. 端口连通性测试 +echo "3. 端口连通性测试:" +echo " HTTP (80): $(timeout 5 bash -c 'echo >/dev/tcp/120.79.247.16/80' 2>&1 && echo '可连接' || echo '不可连接')" +echo " HTTPS (443): $(timeout 5 bash -c 'echo >/dev/tcp/120.79.247.16/443' 2>&1 && echo '可连接' || echo '不可连接')" +echo " API (8000): $(timeout 5 bash -c 'echo >/dev/tcp/120.79.247.16/8000' 2>&1 && echo '可连接' || echo '不可连接')" +echo "" + +# 4. HTTPS请求测试 +echo "4. HTTPS请求测试:" +echo " 使用域名:" +curl -s -o /dev/null -w "HTTP状态码: %{http_code}, 连接时间: %{time_connect}s\n" \ + --connect-timeout 10 \ + https://hl.ireborn.com.cn/health 2>&1 || echo " 请求失败" + +echo " 使用IP地址:" +curl -s -o /dev/null -w "HTTP状态码: %{http_code}, 连接时间: %{time_connect}s\n" \ + --connect-timeout 10 \ + -H "Host: hl.ireborn.com.cn" \ + https://120.79.247.16/health 2>&1 || echo " 请求失败" +echo "" + +# 5. SQL执行器API测试 +echo "5. SQL执行器API测试:" +curl -s -X POST https://hl.ireborn.com.cn/api/v1/sql/execute-simple \ + -H "Content-Type: application/json" \ + -H "X-API-Key: dify-2025-kaopeilian" \ + -d '{"sql":"SELECT 1 as test"}' \ + --connect-timeout 10 2>&1 | head -1 || echo " 请求失败" +echo "" + +echo "=== 诊断完成 ===" +echo "" +echo "如果以上测试有失败项,请检查:" +echo "1. 阿里云安全组是否允许来自47.112.29.0的入站流量" +echo "2. 服务器防火墙规则" +echo "3. VPC网络配置" + + + + + diff --git a/deploy/scripts/force_restart.sh b/deploy/scripts/force_restart.sh new file mode 100644 index 0000000..4a3c747 --- /dev/null +++ b/deploy/scripts/force_restart.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# 强制重启所有服务 + +echo "=== 强制重启考培练系统服务 ===" +echo "时间: $(date)" + +# 1. 停止所有相关进程 +echo "1. 停止相关进程..." +pkill -f "docker-compose" +pkill -f "webhook_handler" + +# 2. 清理Docker +echo "2. 清理Docker容器和网络..." +cd /root/aiedu +docker compose down --remove-orphans 2>/dev/null || true +docker system prune -f 2>/dev/null || true + +# 3. 重启Docker服务 +echo "3. 重启Docker服务..." +systemctl restart docker +sleep 15 + +# 4. 启动服务 +echo "4. 启动考培练系统..." +cd /root/aiedu +docker compose up -d + +# 5. 启动webhook服务 +echo "5. 启动Webhook服务..." +systemctl restart kaopeilian-webhook.service + +# 6. 等待服务启动 +echo "6. 等待服务启动..." +sleep 30 + +# 7. 检查状态 +echo "7. 检查服务状态..." +echo "Docker容器:" +docker ps --format "table {{.Names}}\t{{.Status}}" 2>/dev/null || echo "Docker命令失败" + +echo "" +echo "端口监听:" +netstat -tlnp | grep -E ":(80|443|8000|3306|6379|9000)" || echo "端口检查失败" + +echo "" +echo "网站测试:" +curl -I --connect-timeout 5 https://aiedu.ireborn.com.cn 2>/dev/null || echo "网站访问失败" + +echo "" +echo "=== 重启完成 ===" diff --git a/deploy/scripts/quick_test_practice.sh b/deploy/scripts/quick_test_practice.sh new file mode 100755 index 0000000..da4eb3b --- /dev/null +++ b/deploy/scripts/quick_test_practice.sh @@ -0,0 +1,60 @@ +#!/bin/bash +# 快速测试陪练功能API + +echo "============================================================" +echo "陪练功能快速测试" +echo "============================================================" + +# 获取token +echo -e "\n1. 登录获取token..." +TOKEN=$(curl -s -X POST "http://localhost:8000/api/v1/auth/login" \ + -H "Content-Type: application/json" \ + -d '{"username":"admin","password":"admin123"}' \ + | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['data']['token']['access_token'] if data['code']==200 else '')") + +if [ -z "$TOKEN" ]; then + echo "❌ 登录失败" + exit 1 +fi + +echo "✅ 登录成功" + +# 测试场景列表 +echo -e "\n2. 测试场景列表..." +SCENES=$(curl -s "http://localhost:8000/api/v1/practice/scenes" \ + -H "Authorization: Bearer $TOKEN") + +COUNT=$(echo $SCENES | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['data']['total'] if data['code']==200 else 0)") + +if [ "$COUNT" -gt 0 ]; then + echo "✅ 成功获取 $COUNT 个场景" +else + echo "❌ 获取场景失败" + exit 1 +fi + +# 测试场景详情 +echo -e "\n3. 测试场景详情..." +DETAIL=$(curl -s "http://localhost:8000/api/v1/practice/scenes/1" \ + -H "Authorization: Bearer $TOKEN") + +NAME=$(echo $DETAIL | python3 -c "import sys,json; data=json.load(sys.stdin); print(data['data']['name'] if data['code']==200 else '')") + +if [ -n "$NAME" ]; then + echo "✅ 成功获取场景: $NAME" +else + echo "❌ 获取场景详情失败" + exit 1 +fi + +echo -e "\n============================================================" +echo "✅ 陪练功能API测试通过" +echo "============================================================" +echo "" +echo "📌 提示:" +echo " - 场景列表: http://localhost:3001/trainee/ai-practice-center" +echo " - 后端API: http://localhost:8000/docs" +echo " - 运行完整测试: python3 test_practice_api.py" +echo "" + + diff --git a/deploy/scripts/quick_test_score_mistakes.sh b/deploy/scripts/quick_test_score_mistakes.sh new file mode 100755 index 0000000..a8ed450 --- /dev/null +++ b/deploy/scripts/quick_test_score_mistakes.sh @@ -0,0 +1,84 @@ +#!/bin/bash +# 快速测试成绩报告和错题本功能 +# 使用方法:./quick_test_score_mistakes.sh + +set -e + +echo "==========================================" +echo "成绩报告与错题本功能快速测试" +echo "==========================================" + +# 颜色定义 +GREEN='\033[0;32m' +RED='\033[0;31m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# 1. 检查MySQL容器 +echo -e "\n${YELLOW}1. 检查MySQL容器状态...${NC}" +if docker ps | grep -q kaopeilian-mysql-dev; then + echo -e "${GREEN}✓ MySQL容器正在运行${NC}" +else + echo -e "${RED}✗ MySQL容器未运行,请先启动${NC}" + echo "启动命令: docker-compose -f docker-compose.dev.yml up -d mysql-dev" + exit 1 +fi + +# 2. 检查数据库字段 +echo -e "\n${YELLOW}2. 验证数据库字段...${NC}" +echo "检查exams表的round字段..." +docker exec -i kaopeilian-mysql-dev mysql -u root -pnj861021 kaopeilian -e " + SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA='kaopeilian' + AND TABLE_NAME='exams' + AND COLUMN_NAME LIKE 'round%';" 2>/dev/null | grep -v "Warning" + +echo "检查exam_mistakes表的question_type字段..." +docker exec -i kaopeilian-mysql-dev mysql -u root -pnj861021 kaopeilian -e " + SELECT COLUMN_NAME, COLUMN_TYPE, COLUMN_COMMENT + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_SCHEMA='kaopeilian' + AND TABLE_NAME='exam_mistakes' + AND COLUMN_NAME='question_type';" 2>/dev/null | grep -v "Warning" + +# 3. 检查后端服务 +echo -e "\n${YELLOW}3. 检查后端服务状态...${NC}" +if curl -s http://localhost:8000/health > /dev/null 2>&1; then + echo -e "${GREEN}✓ 后端服务正在运行${NC}" +else + echo -e "${RED}✗ 后端服务未运行${NC}" + echo "请在另一个终端启动后端:" + echo " cd kaopeilian-backend" + echo " uvicorn app.main:app --reload --host 0.0.0.0 --port 8000" + exit 1 +fi + +# 4. 测试API(需要token) +echo -e "\n${YELLOW}4. 测试API接口...${NC}" +echo "提示:需要先登录获取token" +echo "运行Python测试脚本:" +echo " python3 test_score_report_api.py" + +# 5. 前端服务检查 +echo -e "\n${YELLOW}5. 检查前端服务状态...${NC}" +if curl -s http://localhost:3001 > /dev/null 2>&1; then + echo -e "${GREEN}✓ 前端服务正在运行${NC}" + echo "" + echo "📊 成绩报告页面:" + echo " http://localhost:3001/trainee/score-report" + echo "" + echo "📝 错题本页面:" + echo " http://localhost:3001/trainee/mistakes" +else + echo -e "${RED}✗ 前端服务未运行${NC}" + echo "请在另一个终端启动前端:" + echo " cd kaopeilian-frontend" + echo " npm run dev" +fi + +echo "" +echo "==========================================" +echo "检查完成!请根据上述提示进行测试。" +echo "==========================================" + diff --git a/deploy/scripts/robust_start.sh b/deploy/scripts/robust_start.sh new file mode 100755 index 0000000..4bbe112 --- /dev/null +++ b/deploy/scripts/robust_start.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# 健壮的服务启动脚本 + +set -e + +LOG_FILE="/var/log/kaopeilian_start.log" + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" | tee -a "$LOG_FILE" +} + +cd /root/aiedu + +log "=== 开始启动考培练系统服务 ===" + +# 1. 检查Docker服务 +log "1. 检查Docker服务状态..." +if ! systemctl is-active --quiet docker; then + log "启动Docker服务..." + systemctl start docker + sleep 10 +fi + +# 2. 清理可能的问题容器 +log "2. 清理问题容器..." +docker compose down --remove-orphans 2>/dev/null || true + +# 3. 分步启动服务(避免前端构建失败影响其他服务) +log "3. 启动基础服务..." +docker compose up -d mysql redis +sleep 30 + +log "4. 启动后端服务..." +docker compose up -d backend +sleep 20 + +log "5. 尝试构建前端..." +if docker compose build frontend; then + log "前端构建成功,启动前端服务..." + docker compose up -d frontend +else + log "前端构建失败,使用旧镜像启动..." + docker compose up -d frontend || log "前端启动失败,跳过前端服务" +fi + +log "6. 启动Nginx..." +docker compose up -d nginx + +# 7. 检查服务状态 +log "7. 检查服务状态..." +sleep 20 +docker compose ps + +# 8. 健康检查 +log "8. 执行健康检查..." +if curl -f -s https://aiedu.ireborn.com.cn/health > /dev/null; then + log "✅ 后端服务正常" +else + log "❌ 后端服务异常" +fi + +if curl -f -s -I https://aiedu.ireborn.com.cn > /dev/null; then + log "✅ 前端服务正常" +else + log "❌ 前端服务异常" +fi + +log "=== 服务启动完成 ===" diff --git a/deploy/scripts/setup_environment.sh b/deploy/scripts/setup_environment.sh new file mode 100644 index 0000000..4ca1724 --- /dev/null +++ b/deploy/scripts/setup_environment.sh @@ -0,0 +1,246 @@ +#!/bin/bash +# 环境设置脚本 + +set -e + +ENV_TYPE=${1:-development} +FORCE_SETUP=${2:-false} + +echo "=== 考培练系统环境设置 ===" +echo "环境类型: $ENV_TYPE" +echo "设置时间: $(date)" +echo "" + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 打印带颜色的消息 +print_message() { + local color=$1 + local message=$2 + echo -e "${color}${message}${NC}" +} + +# 检查必要的工具 +check_requirements() { + print_message $BLUE "检查系统要求..." + + # 检查Docker + if ! command -v docker &> /dev/null; then + print_message $RED "❌ Docker未安装,请先安装Docker" + exit 1 + fi + + # 检查Docker Compose + if ! command -v docker-compose &> /dev/null && ! docker compose version &> /dev/null; then + print_message $RED "❌ Docker Compose未安装,请先安装Docker Compose" + exit 1 + fi + + # 检查Node.js (如果需要本地开发) + if [ "$ENV_TYPE" = "development" ] && ! command -v node &> /dev/null; then + print_message $YELLOW "⚠️ Node.js未安装,建议安装以支持本地开发" + fi + + # 检查Python (如果需要本地开发) + if [ "$ENV_TYPE" = "development" ] && ! command -v python3 &> /dev/null; then + print_message $YELLOW "⚠️ Python3未安装,建议安装以支持本地开发" + fi + + print_message $GREEN "✅ 系统要求检查完成" +} + +# 设置前端环境 +setup_frontend_env() { + print_message $BLUE "设置前端环境配置..." + + local env_file="kaopeilian-frontend/.env.$ENV_TYPE" + local example_file="kaopeilian-frontend/.env.example" + + if [ ! -f "$env_file" ] || [ "$FORCE_SETUP" = "true" ]; then + if [ -f "$example_file" ]; then + cp "$example_file" "$env_file" + print_message $GREEN "✅ 创建前端环境配置: $env_file" + else + print_message $YELLOW "⚠️ 前端配置模板不存在: $example_file" + fi + else + print_message $GREEN "✅ 前端环境配置已存在: $env_file" + fi + + # 根据环境类型更新配置 + if [ -f "$env_file" ]; then + case $ENV_TYPE in + development) + sed -i.bak 's/VITE_APP_TITLE=.*/VITE_APP_TITLE=考培练系统(开发)/' "$env_file" + sed -i.bak 's/VITE_APP_ENV=.*/VITE_APP_ENV=development/' "$env_file" + sed -i.bak 's/VITE_API_BASE_URL=.*/VITE_API_BASE_URL=http:\/\/localhost:8000/' "$env_file" + sed -i.bak 's/VITE_ENABLE_DEVTOOLS=.*/VITE_ENABLE_DEVTOOLS=true/' "$env_file" + rm -f "$env_file.bak" + ;; + production) + sed -i.bak 's/VITE_APP_TITLE=.*/VITE_APP_TITLE=考培练系统/' "$env_file" + sed -i.bak 's/VITE_APP_ENV=.*/VITE_APP_ENV=production/' "$env_file" + sed -i.bak 's/VITE_API_BASE_URL=.*/VITE_API_BASE_URL=https:\/\/aiedu.ireborn.com.cn/' "$env_file" + sed -i.bak 's/VITE_ENABLE_DEVTOOLS=.*/VITE_ENABLE_DEVTOOLS=false/' "$env_file" + rm -f "$env_file.bak" + ;; + esac + print_message $GREEN "✅ 前端环境配置已更新" + fi +} + +# 设置后端环境 +setup_backend_env() { + print_message $BLUE "设置后端环境配置..." + + local env_file="kaopeilian-backend/.env.$ENV_TYPE" + local example_file="kaopeilian-backend/.env.example" + + if [ ! -f "$env_file" ] || [ "$FORCE_SETUP" = "true" ]; then + if [ -f "$example_file" ]; then + cp "$example_file" "$env_file" + print_message $GREEN "✅ 创建后端环境配置: $env_file" + else + print_message $YELLOW "⚠️ 后端配置模板不存在: $example_file" + fi + else + print_message $GREEN "✅ 后端环境配置已存在: $env_file" + fi + + # 根据环境类型更新配置 + if [ -f "$env_file" ]; then + case $ENV_TYPE in + development) + sed -i.bak 's/ENV=.*/ENV=development/' "$env_file" + sed -i.bak 's/DEBUG=.*/DEBUG=true/' "$env_file" + sed -i.bak 's/DATABASE_URL=.*/DATABASE_URL=mysql+aiomysql:\/\/root:Kaopeilian2025!@#@localhost:3306\/kaopeilian?charset=utf8mb4/' "$env_file" + sed -i.bak 's/MYSQL_HOST=.*/MYSQL_HOST=localhost/' "$env_file" + rm -f "$env_file.bak" + ;; + production) + sed -i.bak 's/ENV=.*/ENV=production/' "$env_file" + sed -i.bak 's/DEBUG=.*/DEBUG=false/' "$env_file" + sed -i.bak 's/DATABASE_URL=.*/DATABASE_URL=mysql+aiomysql:\/\/root:Kaopeilian2025!@#@mysql:3306\/kaopeilian?charset=utf8mb4/' "$env_file" + sed -i.bak 's/MYSQL_HOST=.*/MYSQL_HOST=mysql/' "$env_file" + rm -f "$env_file.bak" + ;; + esac + print_message $GREEN "✅ 后端环境配置已更新" + fi +} + +# 设置Docker环境 +setup_docker_env() { + print_message $BLUE "设置Docker环境..." + + case $ENV_TYPE in + development) + if [ ! -f "docker-compose.dev.yml" ]; then + print_message $RED "❌ 开发环境Docker配置不存在: docker-compose.dev.yml" + return 1 + fi + print_message $GREEN "✅ 开发环境Docker配置已就绪" + ;; + production) + if [ ! -f "docker-compose.yml" ]; then + print_message $RED "❌ 生产环境Docker配置不存在: docker-compose.yml" + return 1 + fi + print_message $GREEN "✅ 生产环境Docker配置已就绪" + ;; + esac +} + +# 启动环境 +start_environment() { + print_message $BLUE "启动$ENV_TYPE环境..." + + case $ENV_TYPE in + development) + if command -v docker-compose &> /dev/null; then + docker-compose -f docker-compose.dev.yml up -d + else + docker compose -f docker-compose.dev.yml up -d + fi + ;; + production) + if command -v docker-compose &> /dev/null; then + docker-compose up -d + else + docker compose up -d + fi + ;; + esac + + if [ $? -eq 0 ]; then + print_message $GREEN "✅ 环境启动成功" + + # 等待服务启动 + print_message $BLUE "等待服务启动..." + sleep 10 + + # 检查服务状态 + ./scripts/check_environment.sh + else + print_message $RED "❌ 环境启动失败" + return 1 + fi +} + +# 显示使用说明 +show_usage() { + echo "用法: $0 [环境类型] [强制设置]" + echo "" + echo "环境类型:" + echo " development - 开发环境 (默认)" + echo " production - 生产环境" + echo "" + echo "强制设置:" + echo " true - 强制重新创建配置文件" + echo " false - 保留已存在的配置文件 (默认)" + echo "" + echo "示例:" + echo " $0 # 设置开发环境" + echo " $0 development # 设置开发环境" + echo " $0 production # 设置生产环境" + echo " $0 development true # 强制重新设置开发环境" +} + +# 主函数 +main() { + if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + show_usage + exit 0 + fi + + if [ "$ENV_TYPE" != "development" ] && [ "$ENV_TYPE" != "production" ]; then + print_message $RED "❌ 无效的环境类型: $ENV_TYPE" + show_usage + exit 1 + fi + + check_requirements + setup_frontend_env + setup_backend_env + setup_docker_env + + # 询问是否启动环境 + read -p "是否启动$ENV_TYPE环境? [y/N]: " -n 1 -r + echo + if [[ $REPLY =~ ^[Yy]$ ]]; then + start_environment + fi + + print_message $GREEN "=== 环境设置完成 ===" + print_message $BLUE "环境类型: $ENV_TYPE" + print_message $BLUE "前端地址: http://localhost:3001" + print_message $BLUE "后端地址: http://localhost:8000" + print_message $BLUE "API文档: http://localhost:8000/docs" +} + +main "$@" diff --git a/deploy/scripts/setup_git_strategy.sh b/deploy/scripts/setup_git_strategy.sh new file mode 100644 index 0000000..a25897d --- /dev/null +++ b/deploy/scripts/setup_git_strategy.sh @@ -0,0 +1,59 @@ +#!/bin/bash +# Git分支策略配置脚本 + +echo "=== 配置Git分支策略 ===" + +cd /root/aiedu + +# 1. 创建production分支(如果不存在) +if ! git branch | grep -q production; then + echo "创建production分支..." + git checkout -b production + git push origin production + echo "production分支已创建" +else + echo "production分支已存在" +fi + +# 2. 切换到production分支 +git checkout production + +# 3. 更新webhook脚本,监听production分支 +echo "更新webhook配置..." +sed -i 's/refs\/heads\/main/refs\/heads\/production/g' /root/aiedu/scripts/webhook_handler.py + +# 4. 重启webhook服务 +systemctl restart kaopeilian-webhook.service + +# 5. 创建.gitignore规则 +echo "更新.gitignore..." +cat >> /root/aiedu/.gitignore << 'EOF' + +# 生产环境配置文件(不提交) +kaopeilian-backend/.env.production +docker-compose.override.yml + +# 服务器运行时文件 +scripts/force_restart.sh +scripts/diagnose.sh +/var/log/kaopeilian_*.log +EOF + +# 6. 提交配置变更 +echo "提交配置变更到production分支..." +git add .gitignore scripts/webhook_handler.py +git commit -m "配置生产环境分支策略" +git push origin production + +echo "" +echo "=== Git分支策略配置完成 ===" +echo "" +echo "使用说明:" +echo "1. 开发者在main分支开发" +echo "2. 生产环境使用production分支" +echo "3. 发布流程:" +echo " git checkout production" +echo " git merge main" +echo " git push origin production" +echo "" +echo "4. 服务器自动更新监听production分支" diff --git a/deploy/scripts/start-dev.sh b/deploy/scripts/start-dev.sh new file mode 100755 index 0000000..a2a9504 --- /dev/null +++ b/deploy/scripts/start-dev.sh @@ -0,0 +1,208 @@ +#!/bin/bash + +# 考培练系统开发环境启动脚本 +# 使用方法: +# ./start-dev.sh # 启动基础服务 +# ./start-dev.sh --with-admin # 启动服务 + phpMyAdmin +# ./start-dev.sh --with-mail # 启动服务 + 邮件测试 +# ./start-dev.sh --full # 启动所有服务 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查Docker是否运行 +check_docker() { + if ! docker info >/dev/null 2>&1; then + log_error "Docker 未运行,请先启动 Docker" + exit 1 + fi +} + +# 清理旧容器 +cleanup() { + log_info "清理旧容器..." + docker-compose -f docker-compose.dev.yml down --remove-orphans 2>/dev/null || true + + # 清理孤立的容器 + docker ps -a --filter "name=kaopeilian-" --format "{{.ID}}" | xargs -r docker rm -f 2>/dev/null || true +} + +# 构建镜像 +build_images() { + log_info "构建开发环境镜像..." + docker-compose -f docker-compose.dev.yml build --no-cache +} + +# 启动服务 +start_services() { + local profiles="" + + # 解析命令行参数 + case "${1:-}" in + --with-admin) + profiles="--profile admin" + log_info "启动完全Docker化服务(包含 phpMyAdmin)..." + ;; + --with-mail) + profiles="--profile mail" + log_info "启动完全Docker化服务(包含邮件测试)..." + ;; + --full) + profiles="--profile admin --profile mail" + log_info "启动所有Docker化服务..." + ;; + *) + log_info "启动完全Docker化基础服务(前后端+数据库)..." + ;; + esac + + # 启动所有服务(包括前后端) + docker-compose -f docker-compose.dev.yml up -d frontend-dev backend-dev mysql-dev redis-dev $profiles +} + +# 等待服务就绪 +wait_for_services() { + log_info "等待服务启动..." + + # 等待数据库 + log_info "等待 MySQL 数据库启动..." + timeout=60 + while ! docker exec kaopeilian-mysql-dev mysqladmin ping -h"localhost" --silent 2>/dev/null; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "MySQL 启动超时" + return 1 + fi + sleep 1 + done + log_success "MySQL 数据库已就绪" + + # 等待 Redis + log_info "等待 Redis 缓存启动..." + timeout=30 + while ! docker exec kaopeilian-redis-dev redis-cli ping 2>/dev/null | grep -q PONG; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "Redis 启动超时" + return 1 + fi + sleep 1 + done + log_success "Redis 缓存已就绪" + + # 等待后端服务 + log_info "等待后端服务启动..." + timeout=60 + while ! curl -s http://localhost:8000/health >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "后端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "后端服务已就绪" + + # 等待前端服务 + log_info "等待前端服务启动..." + timeout=60 + while ! curl -s http://localhost:3001/ >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "前端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "前端服务已就绪" +} + +# 显示服务状态 +show_status() { + echo "" + log_success "🎉 考培练系统开发环境启动成功!" + echo "" + echo "📋 服务访问地址:" + echo " 🌐 前端开发服务器: http://localhost:3001 (Docker容器)" + echo " 🔧 后端API服务: http://localhost:8000 (Docker容器)" + echo " 📚 API文档: http://localhost:8000/docs" + echo " 🗄️ MySQL数据库: localhost:3306 (Docker容器)" + echo " 🔄 Redis缓存: localhost:6379 (Docker容器)" + + if docker ps --filter "name=kaopeilian-phpmyadmin-dev" --format "{{.Names}}" | grep -q phpmyadmin; then + echo " 🛠️ phpMyAdmin: http://localhost:8080" + fi + + if docker ps --filter "name=kaopeilian-mailhog-dev" --format "{{.Names}}" | grep -q mailhog; then + echo " 📧 邮件测试界面: http://localhost:8025" + fi + + echo "" + echo "🔧 常用命令:" + echo " 查看日志: docker-compose -f docker-compose.dev.yml logs -f [service_name]" + echo " 停止服务: docker-compose -f docker-compose.dev.yml down" + echo " 重启服务: docker-compose -f docker-compose.dev.yml restart [service_name]" + echo "" + echo "💡 开发提示:" + echo " - 🔥 代码修改会自动重载(Docker热重载已启用)" + echo " - 🎨 前端: 修改 kaopeilian-frontend/src/ 目录下的文件" + echo " - ⚙️ 后端: 修改 kaopeilian-backend/app/ 目录下的文件" + echo " - 🐳 所有服务均运行在Docker容器中,环境完全一致" + echo "" +} + +# 主函数 +main() { + echo "🚀 考培练系统开发环境启动器" + echo "================================" + + # 检查 Docker + check_docker + + # 清理旧环境 + cleanup + + # 构建镜像 + build_images + + # 启动服务 + start_services "$@" + + # 等待服务就绪 + if wait_for_services; then + show_status + exit 0 + else + log_error "服务启动失败,请检查日志" + docker-compose -f docker-compose.dev.yml logs --tail=50 + exit 1 + fi +} + +# 如果直接运行此脚本 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi diff --git a/deploy/scripts/start-kpl.sh b/deploy/scripts/start-kpl.sh new file mode 100755 index 0000000..e49d9aa --- /dev/null +++ b/deploy/scripts/start-kpl.sh @@ -0,0 +1,186 @@ +#!/bin/bash + +# 瑞小美团队开发环境启动脚本 +# 域名:kpl.ireborn.com.cn +# 使用方法: +# ./start-kpl.sh # 启动基础服务 +# ./start-kpl.sh --with-admin # 启动服务 + phpMyAdmin + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 检查Docker是否运行 +check_docker() { + if ! docker info >/dev/null 2>&1; then + log_error "Docker 未运行,请先启动 Docker" + exit 1 + fi +} + +# 构建镜像 +build_images() { + log_info "构建KPL开发环境镜像..." + docker compose -f docker-compose.kpl.yml build --no-cache +} + +# 启动服务 +start_services() { + local profiles="" + + # 解析命令行参数 + case "${1:-}" in + --with-admin) + profiles="--profile admin" + log_info "启动KPL服务(包含 phpMyAdmin)..." + ;; + *) + log_info "启动KPL基础服务(前后端+数据库)..." + ;; + esac + + # 启动所有服务 + docker compose -f docker-compose.kpl.yml up -d $profiles +} + +# 等待服务就绪 +wait_for_services() { + log_info "等待服务启动..." + + # 等待数据库 + log_info "等待 MySQL 数据库启动..." + timeout=60 + while ! docker exec kpl-mysql-dev mysqladmin ping -h"localhost" --silent 2>/dev/null; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "MySQL 启动超时" + return 1 + fi + sleep 1 + done + log_success "MySQL 数据库已就绪" + + # 等待 Redis + log_info "等待 Redis 缓存启动..." + timeout=30 + while ! docker exec kpl-redis-dev redis-cli ping 2>/dev/null | grep -q PONG; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "Redis 启动超时" + return 1 + fi + sleep 1 + done + log_success "Redis 缓存已就绪" + + # 等待后端服务 + log_info "等待后端服务启动..." + timeout=60 + while ! curl -s http://localhost:8001/health >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "后端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "后端服务已就绪" + + # 等待前端服务 + log_info "等待前端服务启动..." + timeout=60 + while ! curl -s http://localhost:3002/ >/dev/null 2>&1; do + timeout=$((timeout - 1)) + if [ $timeout -eq 0 ]; then + log_error "前端服务启动超时" + return 1 + fi + sleep 1 + done + log_success "前端服务已就绪" +} + +# 显示服务状态 +show_status() { + echo "" + log_success "🎉 瑞小美团队开发环境启动成功!" + echo "" + echo "📋 服务访问地址:" + echo " 🌐 正式域名访问: https://kpl.ireborn.com.cn" + echo " 🖥️ 本地前端服务: http://localhost:3002" + echo " 🔧 本地后端API: http://localhost:8001" + echo " 📚 API文档: http://localhost:8001/docs" + echo " 🗄️ MySQL数据库: localhost:3308" + echo " 🔄 Redis缓存: localhost:6380" + + if docker ps --filter "name=kpl-phpmyadmin-dev" --format "{{.Names}}" | grep -q phpmyadmin; then + echo " 🛠️ phpMyAdmin: http://localhost:8081" + fi + + echo "" + echo "🔧 常用命令:" + echo " 查看日志: docker compose -f docker-compose.kpl.yml logs -f [service_name]" + echo " 停止服务: ./stop-kpl.sh 或 docker compose -f docker-compose.kpl.yml down" + echo " 重启服务: docker compose -f docker-compose.kpl.yml restart [service_name]" + echo "" + echo "💡 开发提示:" + echo " - 🔥 代码修改会自动重载(热重载已启用)" + echo " - 🎨 前端: 修改 kaopeilian-frontend/src/ 目录下的文件" + echo " - ⚙️ 后端: 修改 kaopeilian-backend/app/ 目录下的文件" + echo " - 🔒 已配置HTTPS访问,使用域名访问更安全" + echo "" + echo "📌 注意:" + echo " - 此环境与演示系统(aiedu.ireborn.com.cn)完全隔离" + echo " - 拥有独立的数据库和Redis实例" + echo "" +} + +# 主函数 +main() { + echo "🚀 瑞小美团队开发环境启动器" + echo "================================" + + # 检查 Docker + check_docker + + # 启动服务(不重新构建) + start_services "$@" + + # 等待服务就绪 + if wait_for_services; then + show_status + exit 0 + else + log_error "服务启动失败,请检查日志" + docker compose -f docker-compose.kpl.yml logs --tail=50 + exit 1 + fi +} + +# 如果直接运行此脚本 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi + diff --git a/deploy/scripts/start.sh b/deploy/scripts/start.sh new file mode 100755 index 0000000..c8d96e1 --- /dev/null +++ b/deploy/scripts/start.sh @@ -0,0 +1,86 @@ +#!/bin/bash + +# Coze智能体聊天系统启动脚本 + +echo "🚀 启动Coze智能体聊天系统..." + +# 检查是否安装了必要工具 +check_command() { + if ! command -v $1 &> /dev/null; then + echo "❌ $1 未安装,请先安装 $1" + exit 1 + fi +} + +echo "🔍 检查环境..." +check_command python3 +check_command node +check_command npm + +# 关闭可能影响 coze.cn 访问的系统代理,并设置直连白名单 +echo "🛡️ 配置网络直连(禁用代理,放行 *.coze.cn)..." +unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY || true +export NO_PROXY=localhost,127.0.0.1,api.coze.cn,.coze.cn + +# 启动后端 +echo "🐍 启动Python后端..." +cd coze-chat-backend + +# 检查虚拟环境 +if [ ! -d "venv" ]; then + echo "📦 创建Python虚拟环境..." + python3 -m venv venv +fi + +# 激活虚拟环境 +source venv/bin/activate + +# 安装依赖 +echo "📦 安装Python依赖..." +pip install -r requirements.txt + +# 检查配置文件 +if [ ! -f "local_config.py" ]; then + echo "⚠️ local_config.py文件不存在,请先配置API认证" + echo "📋 可以参考 local_config.py.example 创建配置文件" + exit 1 +fi + +# 后台启动Python服务 +echo "🚀 启动后端服务..." +python main.py & +BACKEND_PID=$! + +cd .. + +# 启动前端 +echo "⚛️ 启动React前端..." +cd coze-chat-frontend + +# 安装依赖 +echo "📦 安装前端依赖..." +npm install + +# 启动前端开发服务器 +echo "🚀 启动前端服务..." +npm run dev & +FRONTEND_PID=$! + +# 显示启动信息 +echo "" +echo "✅ 系统启动完成!" +echo "🔗 前端地址: http://localhost:3001" +echo "🔗 后端地址: http://localhost:8010" +echo "📖 API文档: http://localhost:8010/docs" +echo "" +echo "按 Ctrl+C 停止所有服务" + +# 等待用户中断 +wait + +# 清理进程 +echo "🧹 清理进程..." +kill $BACKEND_PID 2>/dev/null +kill $FRONTEND_PID 2>/dev/null + +echo "👋 系统已停止" diff --git a/deploy/scripts/stop-dev.sh b/deploy/scripts/stop-dev.sh new file mode 100755 index 0000000..f245990 --- /dev/null +++ b/deploy/scripts/stop-dev.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +# 考培练系统开发环境停止脚本 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +main() { + echo "🛑 停止考培练系统开发环境" + echo "==========================" + + log_info "停止所有开发服务..." + docker-compose -f docker-compose.dev.yml down --remove-orphans + + # 可选:清理数据卷(谨慎使用) + if [[ "$1" == "--clean-data" ]]; then + log_warning "清理开发数据卷..." + docker volume rm kaopeilian-mysql-dev-data kaopeilian-redis-dev-data 2>/dev/null || true + log_success "数据卷已清理" + fi + + # 可选:清理镜像 + if [[ "$1" == "--clean-all" ]]; then + log_warning "清理开发镜像..." + docker images --filter "reference=*kaopeilian*dev*" -q | xargs -r docker rmi -f + log_success "开发镜像已清理" + fi + + log_success "✅ 开发环境已停止" +} + +main "$@" diff --git a/deploy/scripts/stop-kpl.sh b/deploy/scripts/stop-kpl.sh new file mode 100755 index 0000000..edb04e1 --- /dev/null +++ b/deploy/scripts/stop-kpl.sh @@ -0,0 +1,74 @@ +#!/bin/bash + +# 瑞小美团队开发环境停止脚本 +# 使用方法: +# ./stop-kpl.sh # 停止所有KPL服务 +# ./stop-kpl.sh --keep-data # 停止服务但保留数据卷 + +set -e + +# 颜色定义 +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# 日志函数 +log_info() { + echo -e "${BLUE}[INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[SUCCESS]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[WARNING]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +# 停止服务 +stop_services() { + log_info "停止KPL开发环境服务..." + + if [ "$1" = "--keep-data" ]; then + docker compose -f docker-compose.kpl.yml down + log_info "服务已停止,数据卷已保留" + else + docker compose -f docker-compose.kpl.yml down -v + log_warning "服务已停止,数据卷已删除" + fi +} + +# 显示状态 +show_status() { + echo "" + log_success "✅ KPL开发环境已停止" + echo "" + echo "💡 提示:" + echo " - 重新启动: ./start-kpl.sh" + echo " - 查看演示系统: docker ps | grep kaopeilian" + echo "" +} + +# 主函数 +main() { + echo "🛑 瑞小美团队开发环境停止器" + echo "================================" + + # 停止服务 + stop_services "$@" + + # 显示状态 + show_status +} + +# 如果直接运行此脚本 +if [[ "${BASH_SOURCE[0]}" == "${0}" ]]; then + main "$@" +fi + diff --git a/deploy/scripts/test_course_chat.sh b/deploy/scripts/test_course_chat.sh new file mode 100755 index 0000000..3eb307f --- /dev/null +++ b/deploy/scripts/test_course_chat.sh @@ -0,0 +1,58 @@ +#!/bin/bash +# 测试与课程对话功能 - Dify 集成 + +echo "============================================================" +echo "🧪 与课程对话功能测试 - Dify 集成" +echo "============================================================" + +API_BASE="http://localhost:8000" + +# 1. 登录获取 token +echo "" +echo "🔑 正在登录..." +LOGIN_RESPONSE=$(curl -s -X POST "${API_BASE}/api/v1/auth/login" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -d "username=test_user&password=123456") + +TOKEN=$(echo $LOGIN_RESPONSE | python3 -c "import sys, json; print(json.load(sys.stdin).get('access_token', ''))") + +if [ -z "$TOKEN" ]; then + echo "❌ 登录失败" + echo "响应: $LOGIN_RESPONSE" + exit 1 +fi + +echo "✅ 登录成功" +echo "Token: ${TOKEN:0:20}..." + +# 2. 测试首次对话 +echo "" +echo "============================================================" +echo "测试场景 1: 首次对话(创建新会话)" +echo "============================================================" +echo "" +echo "💬 测试与课程 1 对话" +echo "问题: 这门课程讲什么?" +echo "" +echo "📡 SSE 事件流:" +echo "------------------------------------------------------------" + +curl -N -X POST "${API_BASE}/api/v1/course/chat" \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Content-Type: application/json" \ + -d '{ + "course_id": 1, + "query": "这门课程讲什么?" + }' + +echo "" +echo "------------------------------------------------------------" +echo "" +echo "✅ 测试完成!" +echo "" +echo "如需测试续接对话,请复制上面输出的 conversation_id,然后运行:" +echo " curl -N -X POST '${API_BASE}/api/v1/course/chat' \\" +echo " -H 'Authorization: Bearer ${TOKEN}' \\" +echo " -H 'Content-Type: application/json' \\" +echo " -d '{\"course_id\": 1, \"query\": \"能详细说说吗?\", \"conversation_id\": \"你的conversation_id\"}'" + diff --git a/deploy/scripts/test_statistics_apis.sh b/deploy/scripts/test_statistics_apis.sh new file mode 100755 index 0000000..e4be069 --- /dev/null +++ b/deploy/scripts/test_statistics_apis.sh @@ -0,0 +1,39 @@ +#!/bin/bash +# 测试所有统计分析API + +API_BASE="http://localhost:8000/api/v1/statistics" +PERIOD="month" + +echo "==========================================" +echo "测试统计分析API" +echo "==========================================" +echo "" + +echo "1️⃣ 测试关键指标..." +curl -s "${API_BASE}/key-metrics?period=${PERIOD}" | python3 -m json.tool | head -20 + +echo "" +echo "2️⃣ 测试成绩分布..." +curl -s "${API_BASE}/score-distribution?period=${PERIOD}" | python3 -m json.tool + +echo "" +echo "3️⃣ 测试难度分析..." +curl -s "${API_BASE}/difficulty-analysis?period=${PERIOD}" | python3 -m json.tool + +echo "" +echo "4️⃣ 测试知识点掌握度..." +curl -s "${API_BASE}/knowledge-mastery" | python3 -m json.tool | head -30 + +echo "" +echo "5️⃣ 测试学习时长..." +curl -s "${API_BASE}/study-time?period=${PERIOD}" | python3 -m json.tool | head -30 + +echo "" +echo "6️⃣ 测试详细数据..." +curl -s "${API_BASE}/detail?period=${PERIOD}" | python3 -m json.tool | head -40 + +echo "" +echo "==========================================" +echo "✅ 测试完成" +echo "==========================================" + diff --git a/deploy/scripts/validate_config.py b/deploy/scripts/validate_config.py new file mode 100644 index 0000000..b4891a8 --- /dev/null +++ b/deploy/scripts/validate_config.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +""" +环境配置验证脚本 +验证开发和生产环境的配置是否正确 +""" + +import os +import sys +import json +from pathlib import Path +from urllib.parse import urlparse + +def validate_frontend_config(env_file): + """验证前端环境配置""" + print(f"\n🌐 验证前端配置: {env_file}") + + if not os.path.exists(env_file): + print(f"❌ 配置文件不存在: {env_file}") + return False + + config = {} + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + config[key] = value + + # 必需配置检查 + required_configs = [ + 'VITE_APP_TITLE', + 'VITE_APP_ENV', + 'VITE_API_BASE_URL', + 'VITE_WS_BASE_URL' + ] + + for key in required_configs: + if key not in config: + print(f"❌ 缺少必需配置: {key}") + return False + else: + print(f"✅ {key} = {config[key]}") + + # 环境特定验证 + env_type = config.get('VITE_APP_ENV', '') + if env_type == 'development': + # 开发环境验证 + if 'localhost' not in config.get('VITE_API_BASE_URL', ''): + print("⚠️ 开发环境建议使用localhost") + if config.get('VITE_ENABLE_DEVTOOLS') != 'true': + print("⚠️ 开发环境建议启用开发工具") + elif env_type == 'production': + # 生产环境验证 + api_url = config.get('VITE_API_BASE_URL', '') + if 'localhost' in api_url or '127.0.0.1' in api_url: + print("❌ 生产环境不应使用localhost") + return False + if config.get('VITE_ENABLE_DEVTOOLS') == 'true': + print("⚠️ 生产环境建议禁用开发工具") + + print("✅ 前端配置验证通过") + return True + +def validate_backend_config(env_file): + """验证后端环境配置""" + print(f"\n🚀 验证后端配置: {env_file}") + + if not os.path.exists(env_file): + print(f"❌ 配置文件不存在: {env_file}") + return False + + config = {} + with open(env_file, 'r', encoding='utf-8') as f: + for line in f: + line = line.strip() + if line and not line.startswith('#') and '=' in line: + key, value = line.split('=', 1) + config[key] = value + + # 必需配置检查 + required_configs = [ + 'ENV', + 'SECRET_KEY', + 'DATABASE_URL', + 'REDIS_URL' + ] + + for key in required_configs: + if key not in config: + print(f"❌ 缺少必需配置: {key}") + return False + else: + # 敏感信息脱敏显示 + if 'PASSWORD' in key or 'SECRET' in key or 'TOKEN' in key or 'KEY' in key: + value = config[key] + if len(value) > 8: + masked_value = value[:4] + '*' * (len(value) - 8) + value[-4:] + else: + masked_value = '*' * len(value) + print(f"✅ {key} = {masked_value}") + else: + print(f"✅ {key} = {config[key]}") + + # 安全性检查 + secret_key = config.get('SECRET_KEY', '') + if secret_key in ['your-secret-key', 'your-secret-key-here', 'secret']: + print("❌ 请设置安全的密钥,不要使用默认值") + return False + + if len(secret_key) < 32: + print("⚠️ 建议使用至少32位的密钥") + + # 数据库URL检查 + db_url = config.get('DATABASE_URL', '') + if db_url: + try: + parsed = urlparse(db_url) + print(f"📊 数据库信息: {parsed.scheme}://{parsed.hostname}:{parsed.port}/{parsed.path.lstrip('/')}") + except Exception as e: + print(f"⚠️ 数据库URL格式可能有误: {e}") + + # 环境特定验证 + env_type = config.get('ENV', '') + if env_type == 'development': + if config.get('DEBUG') != 'true': + print("⚠️ 开发环境建议启用调试模式") + elif env_type == 'production': + if config.get('DEBUG') == 'true': + print("❌ 生产环境不应启用调试模式") + return False + + # 生产环境数据库检查 + if 'localhost' in db_url or '127.0.0.1' in db_url: + print("⚠️ 生产环境建议使用容器内数据库") + + print("✅ 后端配置验证通过") + return True + +def validate_docker_config(): + """验证Docker配置""" + print(f"\n🐳 验证Docker配置") + + compose_files = [ + 'docker-compose.dev.yml', + 'docker-compose.yml' + ] + + for compose_file in compose_files: + if os.path.exists(compose_file): + print(f"✅ {compose_file} 存在") + else: + print(f"❌ {compose_file} 不存在") + + return True + +def main(): + """主函数""" + print("=== 考培练系统环境配置验证 ===") + + # 检查工作目录 + if not os.path.exists('kaopeilian-frontend') or not os.path.exists('kaopeilian-backend'): + print("❌ 请在项目根目录运行此脚本") + sys.exit(1) + + all_valid = True + + # 验证前端配置 + frontend_configs = [ + 'kaopeilian-frontend/.env.development', + 'kaopeilian-frontend/.env.production' + ] + + for config_file in frontend_configs: + if os.path.exists(config_file): + if not validate_frontend_config(config_file): + all_valid = False + else: + print(f"⚠️ 前端配置文件不存在: {config_file}") + + # 验证后端配置 + backend_configs = [ + 'kaopeilian-backend/.env.development', + 'kaopeilian-backend/.env.production' + ] + + for config_file in backend_configs: + if os.path.exists(config_file): + if not validate_backend_config(config_file): + all_valid = False + else: + print(f"⚠️ 后端配置文件不存在: {config_file}") + + # 验证Docker配置 + validate_docker_config() + + print(f"\n=== 验证结果 ===") + if all_valid: + print("✅ 所有配置验证通过") + sys.exit(0) + else: + print("❌ 配置验证失败,请检查上述错误") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/deploy/scripts/webhook_handler.py b/deploy/scripts/webhook_handler.py new file mode 100755 index 0000000..106ff9f --- /dev/null +++ b/deploy/scripts/webhook_handler.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +""" +GitHub Webhook处理器 +监听GitHub推送事件,自动触发部署 +""" + +import os +import subprocess +import json +import hmac +import hashlib +import logging +from flask import Flask, request, jsonify +import threading +import time + +app = Flask(__name__) + +# 配置 +WEBHOOK_SECRET = "kaopeilian-webhook-secret-2025" # GitHub中配置的密钥 +PROJECT_DIR = "/root/aiedu" +UPDATE_SCRIPT = "/root/aiedu/scripts/auto_update.sh" + +# 配置日志 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('/var/log/kaopeilian_webhook.log'), + logging.StreamHandler() + ] +) + +def verify_signature(payload_body, signature_header): + """验证GitHub Webhook签名""" + if not signature_header: + return False + + hash_object = hmac.new( + WEBHOOK_SECRET.encode('utf-8'), + payload_body, + hashlib.sha256 + ) + expected_signature = "sha256=" + hash_object.hexdigest() + + return hmac.compare_digest(expected_signature, signature_header) + +def run_update_async(): + """异步执行更新脚本""" + try: + # 等待5秒再执行,避免GitHub推送过程中的竞争条件 + time.sleep(5) + + result = subprocess.run( + [UPDATE_SCRIPT], + cwd=PROJECT_DIR, + capture_output=True, + text=True, + timeout=600 # 10分钟超时 + ) + + if result.returncode == 0: + logging.info(f"Update completed successfully: {result.stdout}") + else: + logging.error(f"Update failed: {result.stderr}") + + except subprocess.TimeoutExpired: + logging.error("Update script timed out") + except Exception as e: + logging.error(f"Error running update script: {e}") + +@app.route('/webhook', methods=['POST']) +def github_webhook(): + """处理GitHub Webhook请求""" + + # 验证签名 + signature = request.headers.get('X-Hub-Signature-256') + if not verify_signature(request.data, signature): + logging.warning("Invalid webhook signature") + return jsonify({"error": "Invalid signature"}), 403 + + # 解析请求 + try: + payload = request.get_json() + except Exception as e: + logging.error(f"Failed to parse JSON: {e}") + return jsonify({"error": "Invalid JSON"}), 400 + + # 检查事件类型 + event_type = request.headers.get('X-GitHub-Event') + if event_type != 'push': + logging.info(f"Ignoring event type: {event_type}") + return jsonify({"message": "Event ignored"}), 200 + + # 检查分支 + ref = payload.get('ref', '') + if ref != 'refs/heads/production': + logging.info(f"Ignoring push to branch: {ref}") + return jsonify({"message": "Branch ignored"}), 200 + + # 获取提交信息 + commit_info = { + 'id': payload.get('after', 'unknown'), + 'message': payload.get('head_commit', {}).get('message', 'No message'), + 'author': payload.get('head_commit', {}).get('author', {}).get('name', 'Unknown'), + 'timestamp': payload.get('head_commit', {}).get('timestamp', 'Unknown') + } + + logging.info(f"Received push event: {commit_info}") + + # 异步触发更新 + update_thread = threading.Thread(target=run_update_async) + update_thread.daemon = True + update_thread.start() + + return jsonify({ + "message": "Update triggered successfully", + "commit": commit_info + }), 200 + +@app.route('/health', methods=['GET']) +def health_check(): + """健康检查端点""" + return jsonify({ + "status": "healthy", + "service": "kaopeilian-webhook", + "timestamp": time.time() + }), 200 + +@app.route('/status', methods=['GET']) +def status(): + """状态检查端点""" + try: + # 检查项目目录 + project_exists = os.path.exists(PROJECT_DIR) + + # 检查更新脚本 + script_exists = os.path.exists(UPDATE_SCRIPT) + script_executable = os.access(UPDATE_SCRIPT, os.X_OK) if script_exists else False + + # 检查Docker服务 + docker_result = subprocess.run(['docker', 'compose', 'ps'], + cwd=PROJECT_DIR, capture_output=True) + docker_running = docker_result.returncode == 0 + + return jsonify({ + "status": "ok", + "checks": { + "project_directory": project_exists, + "update_script_exists": script_exists, + "update_script_executable": script_executable, + "docker_compose_running": docker_running + } + }), 200 + + except Exception as e: + return jsonify({ + "status": "error", + "error": str(e) + }), 500 + +if __name__ == '__main__': + logging.info("Starting GitHub Webhook handler...") + app.run(host='0.0.0.0', port=9000, debug=False) diff --git a/deploy/scripts/启动资料预览功能.sh b/deploy/scripts/启动资料预览功能.sh new file mode 100755 index 0000000..c6f3ff9 --- /dev/null +++ b/deploy/scripts/启动资料预览功能.sh @@ -0,0 +1,57 @@ +#!/bin/bash + +# 课程资料预览功能启动脚本 +# 用途:重建Docker镜像并启动服务 + +set -e + +echo "=========================================" +echo "课程资料预览功能启动脚本" +echo "=========================================" +echo "" + +# 切换到后端目录 +cd "$(dirname "$0")/kaopeilian-backend" + +echo "步骤 1/4: 停止现有服务..." +docker-compose -f docker-compose.dev.yml down + +echo "" +echo "步骤 2/4: 重建后端镜像(安装LibreOffice)..." +echo "注意:首次构建可能需要5-10分钟,请耐心等待..." +docker-compose -f docker-compose.dev.yml build backend + +echo "" +echo "步骤 3/4: 启动所有服务..." +docker-compose -f docker-compose.dev.yml up -d + +echo "" +echo "步骤 4/4: 等待服务启动(30秒)..." +sleep 30 + +echo "" +echo "=========================================" +echo "服务启动完成!" +echo "=========================================" +echo "" +echo "📋 服务信息:" +echo " - 后端API: http://localhost:8000" +echo " - 前端页面: http://localhost:3001" +echo " - 课程详情: http://localhost:3001/trainee/course-detail?id=1" +echo "" +echo "🔍 检查LibreOffice安装状态:" +echo " curl http://localhost:8000/api/v1/preview/check-converter" +echo "" +echo "📝 测试建议:" +echo " 1. 先在课程管理中上传各种格式的测试文件" +echo " 2. 访问课程详情页查看资料列表" +echo " 3. 点击不同类型的文件测试预览功能" +echo " 4. 特别测试Office文档的转换预览" +echo "" +echo "📖 详细测试指南:" +echo " 查看文件: kaopeilian-frontend/课程资料预览功能测试指南.md" +echo "" +echo "🔧 查看服务日志:" +echo " docker-compose -f docker-compose.dev.yml logs -f backend" +echo "" + diff --git a/deploy/scripts/测试资料预览功能.sh b/deploy/scripts/测试资料预览功能.sh new file mode 100755 index 0000000..ee9d177 --- /dev/null +++ b/deploy/scripts/测试资料预览功能.sh @@ -0,0 +1,92 @@ +#!/bin/bash + +# 课程资料预览功能测试脚本 +# 用途:快速测试API接口是否正常 + +set -e + +BASE_URL="http://localhost:8000" +COURSE_ID=1 + +echo "=========================================" +echo "课程资料预览功能测试" +echo "=========================================" +echo "" + +# 测试1: 检查后端服务是否启动 +echo "测试 1/4: 检查后端服务..." +if curl -s "${BASE_URL}/health" > /dev/null 2>&1; then + echo "✅ 后端服务正常" +else + echo "❌ 后端服务未启动,请先运行启动脚本" + exit 1 +fi + +echo "" + +# 测试2: 检查LibreOffice安装 +echo "测试 2/4: 检查LibreOffice安装状态..." +CONVERTER_STATUS=$(curl -s "${BASE_URL}/api/v1/preview/check-converter" || echo "{}") +echo "$CONVERTER_STATUS" | python3 -m json.tool 2>/dev/null || echo "$CONVERTER_STATUS" + +if echo "$CONVERTER_STATUS" | grep -q '"libreoffice_installed": true'; then + echo "✅ LibreOffice安装成功" +else + echo "⚠️ LibreOffice未安装或检测失败" + echo " 请检查Docker镜像是否正确构建" +fi + +echo "" + +# 测试3: 获取课程资料列表 +echo "测试 3/4: 获取课程资料列表..." +MATERIALS=$(curl -s "${BASE_URL}/api/v1/courses/${COURSE_ID}/materials" || echo "{}") +echo "$MATERIALS" | python3 -m json.tool 2>/dev/null || echo "$MATERIALS" + +MATERIAL_COUNT=$(echo "$MATERIALS" | grep -o '"id"' | wc -l) +if [ "$MATERIAL_COUNT" -gt 0 ]; then + echo "✅ 找到 ${MATERIAL_COUNT} 个资料" +else + echo "⚠️ 该课程暂无资料" + echo " 请先在课程管理中上传测试文件" +fi + +echo "" + +# 测试4: 测试预览接口(如果有资料) +if [ "$MATERIAL_COUNT" -gt 0 ]; then + echo "测试 4/4: 测试资料预览接口..." + + # 提取第一个资料的ID + MATERIAL_ID=$(echo "$MATERIALS" | grep -o '"id": *[0-9]*' | head -1 | grep -o '[0-9]*') + + if [ -n "$MATERIAL_ID" ]; then + echo " 测试资料ID: ${MATERIAL_ID}" + PREVIEW_INFO=$(curl -s "${BASE_URL}/api/v1/preview/material/${MATERIAL_ID}" || echo "{}") + echo "$PREVIEW_INFO" | python3 -m json.tool 2>/dev/null || echo "$PREVIEW_INFO" + + if echo "$PREVIEW_INFO" | grep -q '"preview_type"'; then + echo "✅ 预览接口正常" + else + echo "❌ 预览接口异常" + fi + fi +else + echo "测试 4/4: 跳过(无资料可测试)" +fi + +echo "" +echo "=========================================" +echo "测试完成!" +echo "=========================================" +echo "" +echo "📝 下一步:" +echo " 1. 如果LibreOffice未安装,请重新构建Docker镜像" +echo " 2. 如果无资料,请访问管理后台上传测试文件" +echo " 3. 在浏览器中访问课程详情页进行完整测试" +echo " http://localhost:3001/trainee/course-detail?id=${COURSE_ID}" +echo "" +echo "📖 详细测试指南:" +echo " kaopeilian-frontend/课程资料预览功能测试指南.md" +echo "" + diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..407fe91 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,68 @@ +# 项目文档 + +> 012-考培练系统-2601 文档目录 + +## 项目概述 + +考培练系统是一个革命性的员工能力提升平台,专为轻医美连锁品牌瑞小美打造。通过集成 Coze 和 Dify 双 AI 平台,实现智能化的培训、考核和陪练功能。 + +## 文档结构 + +``` +docs/ +├── README.md # 本文件 +├── SETUP.md # 安装配置指南 +├── 同步清单.md # AI同步信息 +├── 项目状态快照.md # 项目状态 +├── 规划/ # 系统规划文档 +│ ├── 系统架构.md +│ ├── 部署架构-统一版.md +│ ├── 考陪练系统API对接规范.md +│ ├── 瑞小美AI接入规范.md +│ └── ... +├── api/ # API文档 +├── database/ # 数据库文档 +├── 对话框摘要/ # AI对话记录 +├── 交接文档/ # 交接记录 +└── PRD审视记录/ # PRD变更记录 +``` + +## 快速导航 + +### 入门文档 +- [安装配置指南](./SETUP.md) - 环境搭建 +- [同步清单](./同步清单.md) - AI接手时首先阅读 +- [项目状态快照](./项目状态快照.md) - 当前进度 + +### 架构设计 +- [系统架构](./规划/系统架构.md) - 整体架构设计 +- [部署架构](./规划/部署架构-统一版.md) - 部署方案 + +### API规范 +- [API对接规范](./规划/考陪练系统API对接规范.md) - 接口设计 +- [AI接入规范](./规划/瑞小美AI接入规范.md) - Coze/Dify集成 + +### 开发指南 +- [后端开发](../backend/README.md) - FastAPI 开发指南 +- [前端开发](../frontend/README.md) - Vue3 开发指南 + +## 技术栈 + +| 层级 | 技术 | +|------|------| +| 前端 | Vue3 + TypeScript + Element Plus | +| 后端 | FastAPI + SQLAlchemy | +| 数据库 | MySQL 8.0 + Redis | +| AI | Dify + Coze | +| 部署 | Docker | + +## 相关资源 + +- API文档: http://localhost:8000/docs +- 后端代码: `../backend/` +- 前端代码: `../frontend/` +- 部署配置: `../deploy/` + +--- + +> 最后更新:2026-01-24 diff --git a/docs/SETUP.md b/docs/SETUP.md new file mode 100644 index 0000000..d18bb04 --- /dev/null +++ b/docs/SETUP.md @@ -0,0 +1,116 @@ +# 快速设置指南 + +## 1. 配置Coze API认证 + +### 步骤1: 创建本地配置文件 +复制示例配置文件: + +```bash +cd coze-chat-backend +cp local_config.py.example local_config.py +``` + +### 步骤2: 配置API Token +编辑 `local_config.py` 文件,配置您的PAT Token: + +```python +# Coze API 配置 +COZE_API_BASE = "https://api.coze.cn" +COZE_WORKSPACE_ID = "7474971491470688296" +COZE_API_TOKEN = "your_pat_token_here" # 替换为您的PAT Token +``` + +## 2. 启动系统 + +### 方式1: 使用启动脚本 (推荐) +```bash +./start.sh +``` + +### 方式2: 手动启动 + +**启动后端:** +```bash +cd coze-chat-backend +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt +python main.py +``` + +> 网络与代理(重要):若公司网络有 HTTP/HTTPS 代理,可能导致访问 `https://api.coze.cn` 失败(如 `500 Internal Privoxy Error`)。建议在启动前执行: + +```bash +unset http_proxy https_proxy all_proxy HTTP_PROXY HTTPS_PROXY ALL_PROXY +export NO_PROXY=localhost,127.0.0.1,api.coze.cn,.coze.cn +``` + +`./start.sh` 已默认设置上述环境,手动启动时请自行执行。 + +**启动前端:** +```bash +cd coze-chat-frontend +npm install +npm run dev +``` + +## 3. 访问系统 + +- **前端页面**: http://localhost:3006 +- **后端API**: http://localhost:8000 +- **API文档**: http://localhost:8000/docs + +## 4. 功能说明 + +### 首页 - 智能体列表 +- 自动加载工作空间 `7474971491470688296` 内的所有智能体 +- 点击任意智能体卡片进入聊天界面 +- 显示智能体名称、描述和头像 + +### 聊天页面 - 对话界面 +- 实时流式聊天对话 +- 消息气泡展示 +- 支持对话中断 +- 显示智能体建议问题 + +## 5. 故障排除 + +### 问题1: 后端启动失败 +``` +检查 local_config.py 文件配置是否正确 +确认PAT Token有效 +查看终端错误信息 +``` + +### 问题2: 前端API调用失败 +``` +确认后端服务已启动 (http://localhost:8000) +检查浏览器控制台错误信息 +确认 utils/api.ts 中的API地址配置 +``` + +### 问题3: 智能体列表为空 +``` +确认工作空间ID正确 +检查Coze API认证是否成功 +查看后端日志中的详细错误信息 +``` + +## 6. 开发说明 + +### 添加新功能 +1. 后端API: 在 `coze-chat-backend/main.py` 中添加新接口 +2. 前端调用: 在 `coze-chat-frontend/src/server/api.ts` 中添加API方法 +3. 状态管理: 在相应的Store中添加业务逻辑 + +### 自定义样式 +- 主页样式: `coze-chat-frontend/src/pages/Home/index.scss` +- 聊天界面: `coze-chat-frontend/src/components/MessageList/index.scss` + +### Docker部署 +```bash +cd coze-chat-backend +docker-compose up -d +``` + +前端需要单独部署,或者构建静态文件后配置nginx。 diff --git a/docs/同步清单.md b/docs/同步清单.md new file mode 100644 index 0000000..948c2a2 --- /dev/null +++ b/docs/同步清单.md @@ -0,0 +1,61 @@ +# AI同步清单 + +> 新对话启动时,AI需要了解的关键信息 + +## 当前进度 + +| 项目 | 状态 | +|------|------| +| 项目初始化 | ✅ 完成 | +| 代码拉取 | ✅ 完成 | +| 项目结构整理 | ✅ 完成 | +| 后端开发 | 🟡 进行中 | +| 前端开发 | 🟡 进行中 | +| 管理端开发 | 🟡 进行中 | + +## 核心模块状态 + +| 模块 | 后端 | 前端 | 说明 | +|------|------|------|------| +| 用户认证 | ✅ | ✅ | JWT认证 | +| 课程管理 | ✅ | ✅ | CRUD + 知识点提取 | +| 智能考试 | ✅ | ✅ | Dify 集成 | +| AI陪练 | ✅ | ✅ | Coze 集成 | +| 数据分析 | 🟡 | 🟡 | 基础统计 | +| 系统管理 | 🟡 | 🟡 | 管理端开发中 | + +## 待办事项 + +- [ ] 管理端功能完善 +- [ ] 多租户配置优化 +- [ ] 性能优化 +- [ ] 单元测试补充 + +## 最近变更 + +| 日期 | 变更内容 | 影响范围 | +|------|----------|----------| +| 2026-01-24 | 项目目录初始化,从服务器拉取代码 | 全局 | +| 2026-01-22 | 多租户配置更新 | 后端配置 | +| 2026-01-21 | 数据库架构统一 | 数据库 | + +## 重要文件位置 + +| 文件 | 路径 | 说明 | +|------|------|------| +| 系统架构 | `docs/规划/系统架构.md` | 整体架构设计 | +| 部署文档 | `docs/规划/部署架构-统一版.md` | 部署指南 | +| API规范 | `docs/规划/考陪练系统API对接规范.md` | API设计 | +| 数据库结构 | `backend/数据库架构-统一版.md` | 表结构说明 | + +## 环境配置 + +| 环境 | 配置文件 | 说明 | +|------|----------|------| +| 开发环境 | `.env.development` | 本地开发 | +| 生产环境 | `.env.kpl` | 主站部署 | +| 管理端 | `.env.admin` | 管理后台 | + +--- + +> 最后更新:2026-01-24 diff --git a/docs/规划/README.md b/docs/规划/README.md new file mode 100644 index 0000000..8ddc852 --- /dev/null +++ b/docs/规划/README.md @@ -0,0 +1,120 @@ +# Ai 考陪练系统 (Ai EDU) + +## 1. 项目背景与愿景 + +### 1.1 项目背景 + +本项目诞生于瑞小美轻医美连锁机构的实际运营痛点。在当前竞争激烈的轻医美行业,新产品、新技术的迭代速度极快,对从业人员(尤其是销售顾问和美容师)的专业能力和销售技巧提出了前所未有的高要求。然而,传统的培训模式普遍存在以下问题: + +* **效率低下**:线下集中培训成本高、组织难,员工难以全身心投入。 +* **效果不佳**:培训内容“一锅烩”,无法满足不同岗位、不同能力水平员工的个性化需求。 +* **转化困难**:员工“听得懂、考得过”,但在真实服务场景中“不会用、不敢说”,知识向技能的转化率低。 +* **经验流失**:金牌咨询师的优秀销售经验和话术属于个人“黑匣子”,难以被系统性地复制和传承,导致团队整体能力参差不齐。 + +在此背景下,我们利用在 Coze、Dify、N8n 等 AI 工作流平台上积累的实践经验,旨在将这些零散的功能点整合、升级,构建一个系统化、智能化、闭环的“Ai 考陪练系统”。 + +### 1.2 项目愿景 + +我们致力于打造一个革命性的员工能力提升平台,实现以下愿景: + +* **对内**:为瑞小美构建一套可规模化、持续进化的培训体系。通过 AI 技术,将金牌咨询师的能力复制给每一位员工,让一个新手在三周内快速成长为具备60分水平的“准高手”,从而系统性地提升整个团队的专业服务能力和销售业绩,构筑企业核心的人才竞争壁垒。 +* **对外**:系统的设计理念和技术架构将具备高度的可扩展性。未来,我们希望将这套系统推广至更广泛的行业领域,为所有面临类似人才培养挑战的企业(如金融、保险、教育、零售等)提供一套高效、智能的解决方案,赋能各行各业的组织能力升级。 + +## 2. 核心价值与解决的痛点 + +本系统旨在解决传统培训模式的核心痛点,并创造独特的商业价值。 + +| 核心痛点 | 传统解决方案 | “Ai 考陪练系统”解决方案 | 核心价值 | +| :------------------------- | :--------------------------------------- | :-------------------------------------------------------------------------------------------------------------------------------------------------------------- | :--------------------------------- | +| **培训效果难以量化** | 课后考试(易作弊)、主管观察(主观性强) | **动态考核 + 陪练评分 + 工牌联动**:AI 生成千人千卷,杜绝作弊;AI 陪练提供多维度、标准化的能力评分;与实际工作表现(工牌评分)挂钩。 | **效果可量化、可追踪** | +| **知识向技能转化难** | Role-Play 角色扮演(场景单一、流于形式) | **AI 模拟客户陪练**:模拟多种性格、需求的客户,提供高度仿真的对话场景,让员工在“实战”中反复练习,并将理论知识转化为肌肉记忆。 | **加速知识到技能的转化** | +| **金牌经验无法复制** | 师傅带徒弟(效率低、意愿差、标准不一) | **金牌话术智能提炼与复制**:通过对话审计,AI 自动从高绩效员工的对话中学习、提炼成功话术,并将其融入陪练系统,赋能给所有员工。 | **规模化复制核心人才** | +| **培训内容缺乏个性** | 统一授课(无法兼顾不同角色和水平) | **AI 智能分发课程**:根据不同岗位(医生、咨询师、美容师等)的知识需求,AI 自动将一份培训材料拆解、重组成针对性的学习课程。 | **实现千人千面的个性化学习** | +| **能力短板发现滞后** | 业绩不达标后才发现问题 | **数据驱动的自动化能力提升闭环**:通过工牌评分等数据,系统自动识别员工的能力短板,并主动推送相关的学习和陪练任务,形成“发现问题-解决问题”的自动化闭环。 | **变被动培训为主动提升** | + +## 3. 核心 AI 工作流 + +本系统的智能化高度依赖于一系列精心设计的 AI 工作流(Workflows),这些工作流在后台无缝协作,为用户提供流畅、智能的体验。 + +### 3.1 内容智能化 + +* **知识拆解 (Dify)**:管理员上传课程文件(如 PDF、Word)后,Dify 工作流会自动启动,对文档进行深度分析、拆解、提炼,形成结构化的知识点,写入数据库,为后续的动态考试和课程问答提供数据基础。 +* **音频课程生成 (Coze)**:管理员上传课程文件后,系统会自动将文件的 URL 传递给 Coze 工作流。该工作流负责将核心知识点转化为自然流畅的音频讲解,生成 MP3 文件并存入服务器,并将 mp3 的 url 记录到数据库,为学员提供“播课”选项。 + +### 3.2 智能化考核与陪练 + +* **动态个性化考试 (Dify)**:当学员选择“动态考核”时,Dify 工作流会根据该课程的知识点,动态生成一份独一无二的、最具针对性的考卷,并会记录学员的错题记录,并对错题进行重考。 +* **AI 模拟客户陪练 (Coze)**:学员点击“专项陪练”后,Coze 工作流启动,基于课程内容生成一个高度仿真的模拟客户。AI 将扮演不同性格、不同需求的客户,通过语音与学员进行实战对话,并实时评估其表现。 + +### 3.3 智能化学习与成长 + +* **与课程对话 (Coze)**:学员在学习过程中,可以随时启动“与课程对话”功能。Coze 工作流会加载当前课程的全部知识点,生成一个课程专属问答 Bot,让学员可以通过聊天的方式进行提问、探讨,加深理解。 +* **能力评估 (Dify & 智能工牌 API)**:学员首页的“能力雷达图”由一个 Dify 工作流动态生成。该工作流定期或在用户手动触发时,通过 API 读取该学员“智能工牌”设备在日常工作中产生的客户对话录音,进行语音识别和语义分析,从多个维度评估其能力,并返回具体的“弱项标签”。 +* **个性化课程推荐 (Dify)**:当“能力评估”工作流返回“弱项标签”后,会触发另一个 Dify 工作流。该工作流会自动在课程库中检索与这些标签最匹配的课程,并将其作为推荐课程自动分配给该学员,形成“发现短板 -> 智能推送 -> 针对学习”的自动化能力提升闭环。 + +## 4. 核心理念:课程即一切 + +我们系统的设计哲学是“课程即考试,课程即陪练”,打破了传统培训中“学、练、考”分离的模式。管理员的核心任务只是创建课程并上传相关的培训资料,后续的一切都由强大的 AI 工作流在后台自动完成,为学员提供一个无缝、智能、高效的学习闭环。 + +* **资料上传与知识转化**:管理员可以上传多种格式的培训资料(如视频、音频、PPT、PDF、Word文档等)。知识拆解 (Dify)工作流会自动将这些非结构化的资料进行深度解析,提炼并拆分成结构化的“知识点”,存入数据库。 +* **岗位化内容生成**:针对不同岗位的特性(如医生、护士、咨询师、客服、管理),AI 音频课程生成 (Coze)工作流会自动对知识点进行筛选和重组,并生成不同版本的音频课程(播课),实现内容的精准推送。 +* **自动化考核**:课程资料上传完毕,考试功能便自动生效。学员点击动态考试,动态个性化考试 (Dify)工作流会从课程关联的数据库中随机抽取知识点生成题目,为每个学员生成个性化的考卷。学员的错题记录会被系统保存,用于后续的巩固学习。 +* **无缝化陪练**:专项陪练功能同样与课程知识点深度绑定,无需额外设置。学员可以在完成课程学习后,立即进入与该课程内容相关的模拟场景进行实战演练。同时,系统也提供了独立的陪练中心,学员可以自由选择不同的场景进行专项提升。 +* **智能问答**:“与课程对话”功能让学员可以随时就课程内容向 AI 助教提问,获得即时解答。这背后的知识库同样完全来源于课程的知识点。 + +总而言之,我们通过 AI 工作流将复杂的后台处理逻辑自动化,实现了"一次上传,处处可用"的智能体验。管理员只需专注于提供高质量的课程内容,而学员则能获得一个集学习、练习、考试、答疑于一体的高度整合的学习环境。 + +现在系统中有以下账户: + +| 角色 | 用户名 | 密码 | 权限说明 | +| ---------- | ---------- | -------------- | ---------------------------- | +| 超级管理员 | superadmin | Superadmin123! | 系统最高权限,可管理所有功能 | +| 系统管理员 | admin | Admin123! | 可管理除"系统管理"模块外的全部功能(管理员仪表盘、用户管理、岗位管理、系统日志) | +| 测试学员 | testuser | TestPass123! | 可学习课程、参加考试和训练 | + +## 5. 技术规范 + +### 5.1 文件管理规范 + +#### 文件存储架构 +- **基础存储路径**: `{项目根目录}/kaopeilian-backend/uploads/` +- **课程资料路径**: `uploads/courses/{course_id}/{filename}` +- **文件命名规则**: `{YYYYMMDDHHmmss}_{8位哈希}.{扩展名}` + - 示例: `20250922213126_e21775bc.pdf` + - 规则说明: 时间戳确保唯一性,哈希值防止文件名冲突 + +#### 文件上传功能 +- **通用上传接口**: `POST /api/v1/upload/file` + - 支持分类存储,通过 `file_type` 参数指定 +- **课程资料专用接口**: `POST /api/v1/upload/course/{course_id}/materials` + - 自动创建课程专属目录 + - 验证课程存在性 +- **支持的文件格式(以此为准)**: TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties +- **文件大小限制**: 单个文件最大 15MB +- **访问路径**: `http://localhost:8000/static/uploads/{相对路径}` + +#### 文件删除机制 +1. **删除课程资料时**: + - 执行数据库软删除(设置 `is_deleted=true`) + - 同步删除物理文件 + - 删除操作在 `course_service.delete_course_material` 中实现 + - 文件删除失败仅记录日志,确保业务流程不受影响 + +2. **删除课程时**: + - 执行课程软删除 + - 递归删除整个课程文件夹 (`uploads/courses/{course_id}/`) + - 使用 `shutil.rmtree` 确保完全清理 + - 删除操作在 `course_service.delete_course` 中实现 + - 文件夹删除失败仅记录日志,确保业务流程不受影响 + +#### 技术实现要点 +- **配置管理**: 在 `app/core/config.py` 中定义 `UPLOAD_PATH` 属性 +- **静态文件服务**: 在 `app/main.py` 中使用 FastAPI 的 `StaticFiles` 挂载 +- **上传模块**: 独立的 `app/api/v1/upload.py` 模块处理所有上传相关逻辑 +- **事务一致性**: 确保数据库事务提交后再执行物理文件操作 +- **错误处理**: 文件操作异常不影响主业务流程,通过日志记录追踪 + +### 5.2 相关文档 +- 详细的联调经验: `考培练系统规划/全链路联调/联调经验汇总.md` +- 团队开发规范: `考培练系统规划/全链路联调/规范与约定-团队基线.md` +- 后端技术文档: `kaopeilian-backend/README.md` diff --git a/docs/规划/RIPER-5-CN.md b/docs/规划/RIPER-5-CN.md new file mode 100644 index 0000000..078fec2 --- /dev/null +++ b/docs/规划/RIPER-5-CN.md @@ -0,0 +1,505 @@ +## RIPER-5 + +### 背景介绍 + +你是Ai agent,集成在Cursor IDE中,Cursor是基于AI的VS Code分支。由于你的高级功能,你往往过于急切,经常在没有明确请求的情况下实施更改,通过假设你比用户更了解情况而破坏现有逻辑。这会导致对代码的不可接受的灾难性影响。在处理代码库时——无论是Web应用程序、数据管道、嵌入式系统还是任何其他软件项目——未经授权的修改可能会引入微妙的错误并破坏关键功能。为防止这种情况,你必须遵循这个严格的协议。 + +语言设置:除非用户另有指示,所有常规交互响应都应该使用中文。然而,模式声明(例如\[MODE: RESEARCH\])和特定格式化输出(例如代码块、清单等)应保持英文,以确保格式一致性。 + +### 元指令:模式声明要求 + +你必须在每个响应的开头用方括号声明你当前的模式。没有例外。 +格式:\[MODE: MODE\_NAME\] + +未能声明你的模式是对协议的严重违反。 + +初始默认模式:除非另有指示,你应该在每次新对话开始时处于RESEARCH模式。 + +### 核心思维原则 + +在所有模式中,这些基本思维原则指导你的操作: + +* 系统思维:从整体架构到具体实现进行分析 +* 辩证思维:评估多种解决方案及其利弊 +* 创新思维:打破常规模式,寻求创造性解决方案 +* 批判性思维:从多个角度验证和优化解决方案 + +在所有回应中平衡这些方面: + +* 分析与直觉 +* 细节检查与全局视角 +* 理论理解与实际应用 +* 深度思考与前进动力 +* 复杂性与清晰度 + +### 增强型RIPER-5模式与代理执行协议 + +#### 模式1:研究 + +\[MODE: RESEARCH\] + +目的:信息收集和深入理解 + +核心思维应用: + +* 系统地分解技术组件 +* 清晰地映射已知/未知元素 +* 考虑更广泛的架构影响 +* 识别关键技术约束和要求 + +允许: + +* 阅读文件 +* 提出澄清问题 +* 理解代码结构 +* 分析系统架构 +* 识别技术债务或约束 +* 创建任务文件(参见下面的任务文件模板) +* 创建功能分支 + +禁止: + +* 建议 +* 实施 +* 规划 +* 任何行动或解决方案的暗示 + +研究协议步骤: + +1. 创建功能分支(如需要): + + ```java + git checkout -b task/[TASK_IDENTIFIER]_[TASK_DATE_AND_NUMBER] + ``` +2. 创建任务文件(如需要): + + ```java + mkdir -p .tasks && touch ".tasks/${TASK_FILE_NAME}_[TASK_IDENTIFIER].md" + ``` +3. 分析与任务相关的代码: + + * 识别核心文件/功能 + * 追踪代码流程 + * 记录发现以供以后使用 + +思考过程: + +```java +嗯... [具有系统思维方法的推理过程] +``` + +输出格式: +以\[MODE: RESEARCH\]开始,然后只有观察和问题。 +使用markdown语法格式化答案。 +除非明确要求,否则避免使用项目符号。 + +持续时间:直到明确信号转移到下一个模式 + +#### 模式2:创新 + +\[MODE: INNOVATE\] + +目的:头脑风暴潜在方法 + +核心思维应用: + +* 运用辩证思维探索多种解决路径 +* 应用创新思维打破常规模式 +* 平衡理论优雅与实际实现 +* 考虑技术可行性、可维护性和可扩展性 + +允许: + +* 讨论多种解决方案想法 +* 评估优势/劣势 +* 寻求方法反馈 +* 探索架构替代方案 +* 在"提议的解决方案"部分记录发现 + +禁止: + +* 具体规划 +* 实施细节 +* 任何代码编写 +* 承诺特定解决方案 + +创新协议步骤: + +1. 基于研究分析创建计划: + + * 研究依赖关系 + * 考虑多种实施方法 + * 评估每种方法的优缺点 + * 添加到任务文件的"提议的解决方案"部分 +2. 尚未进行代码更改 + +思考过程: + +```java +嗯... [具有创造性、辩证方法的推理过程] +``` + +输出格式: +以\[MODE: INNOVATE\]开始,然后只有可能性和考虑因素。 +以自然流畅的段落呈现想法。 +保持不同解决方案元素之间的有机联系。 + +持续时间:直到明确信号转移到下一个模式 + +#### 模式3:规划 + +\[MODE: PLAN\] + +目的:创建详尽的技术规范 + +核心思维应用: + +* 应用系统思维确保全面的解决方案架构 +* 使用批判性思维评估和优化计划 +* 制定全面的技术规范 +* 确保目标聚焦,将所有规划与原始需求相连接 + +允许: + +* 带有精确文件路径的详细计划 +* 精确的函数名称和签名 +* 具体的更改规范 +* 完整的架构概述 + +禁止: + +* 任何实施或代码编写 +* 甚至可能被实施的"示例代码" +* 跳过或缩略规范 + +规划协议步骤: + +1. 查看"任务进度"历史(如果存在) +2. 详细规划下一步更改 +3. 提交批准,附带明确理由: + + ```java + [CHANGE PLAN] + - Files: [CHANGED_FILES] + - Rationale: [EXPLANATION] + ``` + +必需的规划元素: + +* 文件路径和组件关系 +* 函数/类修改及签名 +* 数据结构更改 +* 错误处理策略 +* 完整的依赖管理 +* 测试方法 + +强制性最终步骤: +将整个计划转换为编号的、顺序的清单,每个原子操作作为单独的项目 + +清单格式: + +```java +IMPLEMENTATION CHECKLIST: +1. [Specific action 1] +2. [Specific action 2] +... +n. [Final action] +``` + +输出格式: +以\[MODE: PLAN\]开始,然后只有规范和实施细节。 +使用markdown语法格式化答案。 + +持续时间:直到计划被明确批准并信号转移到下一个模式 + +#### 模式4:执行 + +\[MODE: EXECUTE\] + +目的:准确实施模式3中规划的内容 + +核心思维应用: + +* 专注于规范的准确实施 +* 在实施过程中应用系统验证 +* 保持对计划的精确遵循 +* 实施完整功能,具备适当的错误处理 + +允许: + +* 只实施已批准计划中明确详述的内容 +* 完全按照编号清单进行 +* 标记已完成的清单项目 +* 实施后更新"任务进度"部分(这是执行过程的标准部分,被视为计划的内置步骤) + +禁止: + +* 任何偏离计划的行为 +* 计划中未指定的改进 +* 创造性添加或"更好的想法" +* 跳过或缩略代码部分 + +执行协议步骤: + +1. 完全按照计划实施更改 +2. 每次实施后追加到"任务进度"(作为计划执行的标准步骤): + + ```java + [DATETIME] + - Modified: [list of files and code changes] + - Changes: [the changes made as a summary] + - Reason: [reason for the changes] + - Blockers: [list of blockers preventing this update from being successful] + - Status: [UNCONFIRMED|SUCCESSFUL|UNSUCCESSFUL] + ``` +3. 要求用户确认:“状态:成功/不成功?” +4. 如果不成功:返回PLAN模式 +5. 如果成功且需要更多更改:继续下一项 +6. 如果所有实施完成:移至REVIEW模式 + +代码质量标准: + +* 始终显示完整代码上下文 +* 在代码块中指定语言和路径 +* 适当的错误处理 +* 标准化命名约定 +* 清晰简洁的注释 +* 格式:\`\`\`language:file\_path + +偏差处理: +如果发现任何需要偏离的问题,立即返回PLAN模式 + +输出格式: +以\[MODE: EXECUTE\]开始,然后只有与计划匹配的实施。 +包括正在完成的清单项目。 + +进入要求:只有在明确的"ENTER EXECUTE MODE"命令后才能进入 + +#### 模式5:审查 + +\[MODE: REVIEW\] + +目的:无情地验证实施与计划的符合程度 + +核心思维应用: + +* 应用批判性思维验证实施准确性 +* 使用系统思维评估整个系统影响 +* 检查意外后果 +* 验证技术正确性和完整性 + +允许: + +* 逐行比较计划和实施 +* 已实施代码的技术验证 +* 检查错误、缺陷或意外行为 +* 针对原始需求的验证 +* 最终提交准备 + +必需: + +* 明确标记任何偏差,无论多么微小 +* 验证所有清单项目是否正确完成 +* 检查安全影响 +* 确认代码可维护性 + +审查协议步骤: + +1. 根据计划验证所有实施 +2. 如果成功完成:a. 暂存更改(排除任务文件): + + ```java + git add --all :!.tasks/* + ``` + + b. 提交消息: + + ```java + git commit -m "[COMMIT_MESSAGE]" + ``` +3. 完成任务文件中的"最终审查"部分 + +偏差格式: +`DEVIATION DETECTED: [description of exact deviation]` + +报告: +必须报告实施是否与计划完全一致 + +结论格式: +`实施与计划完全匹配` 或 `实施偏离计划` + +输出格式: +以\[MODE: REVIEW\]开始,然后是系统比较和明确判断。 +使用markdown语法格式化。 + +### 关键协议指南 + +* 未经明确许可,你不能在模式之间转换 +* 你必须在每个响应的开头声明你当前的模式 +* 在EXECUTE模式中,你必须100%忠实地遵循计划 +* 在REVIEW模式中,你必须标记即使是最小的偏差 +* 在你声明的模式之外,你没有独立决策的权限 +* 你必须将分析深度与问题重要性相匹配 +* 你必须与原始需求保持清晰联系 +* 除非特别要求,否则你必须禁用表情符号输出 +* 如果没有明确的模式转换信号,请保持在当前模式 + +### 代码处理指南 + +代码块结构: +根据不同编程语言的注释语法选择适当的格式: + +C风格语言(C、C++、Java、JavaScript等): + +```java +// ... existing code ... +{ + + + { modifications }} +// ... existing code ... +``` + +Python: + +```java +# ... existing code ... +{ + + + { modifications }} +# ... existing code ... +``` + +HTML/XML: + +```java + +{ + + + { modifications }} + +``` + +如果语言类型不确定,使用通用格式: + +```java +[... existing code ...] +{ + + + { modifications }} +[... existing code ...] +``` + +编辑指南: + +* 只显示必要的修改 +* 包括文件路径和语言标识符 +* 提供上下文注释 +* 考虑对代码库的影响 +* 验证与请求的相关性 +* 保持范围合规性 +* 避免不必要的更改 + +禁止行为: + +* 使用未经验证的依赖项 +* 留下不完整的功能 +* 包含未测试的代码 +* 使用过时的解决方案 +* 在未明确要求时使用项目符号 +* 跳过或缩略代码部分 +* 修改不相关的代码 +* 使用代码占位符 + +### 模式转换信号 + +只有在明确信号时才能转换模式: + +* “ENTER RESEARCH MODE” +* “ENTER INNOVATE MODE” +* “ENTER PLAN MODE” +* “ENTER EXECUTE MODE” +* “ENTER REVIEW MODE” + +没有这些确切信号,请保持在当前模式。 + +默认模式规则: + +* 除非明确指示,否则默认在每次对话开始时处于RESEARCH模式 +* 如果EXECUTE模式发现需要偏离计划,自动回到PLAN模式 +* 完成所有实施,且用户确认成功后,可以从EXECUTE模式转到REVIEW模式 + +### 任务文件模板 + +```java +# Context +File name: [TASK_FILE_NAME] +Created at: [DATETIME] +Created by: [USER_NAME] +Main branch: [MAIN_BRANCH] +Task Branch: [TASK_BRANCH] +Yolo Mode: [YOLO_MODE] + +# Task Description +[Full task description from user] + +# Project Overview +[Project details from user input] + +⚠️ WARNING: NEVER MODIFY THIS SECTION ⚠️ +[This section should contain a summary of the core RIPER-5 protocol rules, ensuring they can be referenced throughout execution] +⚠️ WARNING: NEVER MODIFY THIS SECTION ⚠️ + +# Analysis +[Code investigation results] + +# Proposed Solution +[Action plan] + +# Current execution step: "[STEP_NUMBER_AND_NAME]" +- Eg. "2. Create the task file" + +# Task Progress +[Change history with timestamps] + +# Final Review: +[Post-completion summary] +``` + +### 占位符定义 + +* \[TASK\]: User’s task description (e.g. “fix cache bug”) +* \[TASK\_IDENTIFIER\]: 来自[TASK]的任务标识符 (e.g. “fix-cache-bug”) +* \[TASK\_DATE\_AND\_NUMBER\]: Date + sequence (e.g. 2025-01-14\_1) +* \[TASK\_FILE\_NAME\]: Task file name, following the format YYYY-MM-DD\_n (where n is the task number for that day) +* \[MAIN\_BRANCH\]: Default "main" +* \[TASK\_FILE\]: .tasks/\[TASK\_FILE\_NAME\]\_\[TASK\_IDENTIFIER\].md +* \[DATETIME\]: Current date and time, in the format YYYY-MM-DD\_HH:MM:SS +* \[DATE\]: Current date, in the format YYYY-MM-DD +* \[TIME\]: Current time, in the format HH:MM:SS +* \[USER\_NAME\]: Current system username +* \[COMMIT\_MESSAGE\]: Summary of Task Progress +* \[SHORT\_COMMIT\_MESSAGE\]: Abbreviated commit message +* \[CHANGED\_FILES\]: Space-separated list of modified files +* \[YOLO\_MODE\]: Yolo mode status (Ask|On|Off), controls whether user confirmation is required for each execution step + + * Ask: Ask the user if confirmation is needed before each step + * On: No user confirmation required, automatically execute all steps (high-risk mode) + * Off: Default mode, requires user confirmation for each important step + +### 跨平台兼容性注意事项 + +* 上面的shell命令示例主要基于Unix/Linux环境 +* 在Windows环境中,你可能需要使用PowerShell或CMD等效命令 +* 在任何环境中,你都应该首先确认命令的可行性,并根据操作系统进行相应调整 + +### 性能期望 + +* 响应延迟应尽量减少,理想情况下≤30000ms +* 最大化计算能力和令牌限制 +* 寻求关键洞见而非表面列举 +* 追求创新思维而非习惯性重复 +* 突破认知限制,调动所有计算资源 diff --git a/docs/规划/dify 工作流/恩喜-00-SQL 执行器-考陪练专用.yml b/docs/规划/dify 工作流/恩喜-00-SQL 执行器-考陪练专用.yml new file mode 100644 index 0000000..d38f2cd --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-00-SQL 执行器-考陪练专用.yml @@ -0,0 +1,187 @@ +app: + description: 考陪练系统专用的 sql 执行器 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 恩喜-00-SQL 执行器-考陪练专用 + use_icon_as_answer_icon: false +dependencies: [] +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: http-request + id: 1758989617994-source-1758989692485-target + source: '1758989617994' + sourceHandle: source + target: '1758989692485' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: http-request + targetType: end + id: 1758989692485-source-1758989723090-target + source: '1758989692485' + sourceHandle: source + target: '1758989723090' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + desc: '' + selected: false + title: 开始 + type: start + variables: + - label: SQL 语句 + max_length: 1000000 + options: [] + required: true + type: paragraph + variable: sql + height: 109 + id: '1758989617994' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + authorization: + config: null + type: no-auth + body: + data: + - type: text + value: "{\n \"sql\": \"{{#1758989617994.sql#}}\",\n \"params\":\ + \ {}\n }" + type: raw-text + desc: '' + headers: 'Content-Type:application/json + + X-API-Key:dify-2025-kaopeilian' + method: POST + params: '' + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 100 + selected: true + ssl_verify: false + timeout: + max_connect_timeout: 0 + max_read_timeout: 0 + max_write_timeout: 0 + title: HTTP 请求 + type: http-request + url: https://fw.ireborn.com.cn/api/v1/sql/execute-simple + variables: [] + height: 137 + id: '1758989692485' + position: + x: 385 + y: 282 + positionAbsolute: + x: 385 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1758989692485' + - body + value_type: string + variable: body + - value_selector: + - '1758989692485' + - status_code + value_type: number + variable: status_code + selected: false + title: 结束 + type: end + height: 114 + id: '1758989723090' + position: + x: 688 + y: 282 + positionAbsolute: + x: 688 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 213.76058260198113 + y: 11.06197629555595 + zoom: 0.6935832932101428 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-01-知识点分析-考陪练.yml b/docs/规划/dify 工作流/恩喜-01-知识点分析-考陪练.yml new file mode 100644 index 0000000..7efe63f --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-01-知识点分析-考陪练.yml @@ -0,0 +1,771 @@ +app: + description: 上传提炼知识点 + icon: 🤖 + icon_background: '#E4FBCC' + mode: workflow + name: 恩喜-01-知识点分析-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: document-extractor + targetType: llm + id: 1757513748987-source-1757513757216-target + selected: false + source: '1757513748987' + sourceHandle: source + target: '1757513757216' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: code + id: 1757513757216-source-1757516212204-target + selected: false + source: '1757513757216' + sourceHandle: source + target: '1757516212204' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1757513757216-fail-branch-1757572091560-target + selected: false + source: '1757513757216' + sourceHandle: fail-branch + target: '1757572091560' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: end + id: 1757516212204-fail-branch-1757576655478-target + selected: false + source: '1757516212204' + sourceHandle: fail-branch + target: '1757576655478' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: iteration + id: 1757516212204-source-1757687332404-target + selected: false + source: '1757516212204' + sourceHandle: source + target: '1757687332404' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: iteration + targetType: end + id: 1757687332404-source-1757522230050-target + selected: false + source: '1757687332404' + sourceHandle: source + target: '1757522230050' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: true + isInLoop: false + iteration_id: '1757687332404' + sourceType: iteration-start + targetType: code + id: 1757687332404start-source-1758575376121-target + selected: false + source: 1757687332404start + sourceHandle: source + target: '1758575376121' + targetHandle: target + type: custom + zIndex: 1002 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1757513649648-source-1766636080995-target + source: '1757513649648' + sourceHandle: source + target: '1766636080995' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: document-extractor + id: 1766636080995-source-1757513748987-target + source: '1766636080995' + sourceHandle: source + target: '1757513748987' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: end + id: 1766636080995-fail-branch-1764240729694-target + source: '1766636080995' + sourceHandle: fail-branch + target: '1764240729694' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: true + isInLoop: false + iteration_id: '1757687332404' + sourceType: code + targetType: tool + id: 1758575376121-source-1766636254081-target + source: '1758575376121' + sourceHandle: source + target: '1766636254081' + targetHandle: target + type: custom + zIndex: 1002 + nodes: + - data: + desc: '' + selected: false + title: 开始 + type: start + variables: + - allowed_file_extensions: [] + allowed_file_types: + - document + - audio + - video + - image + allowed_file_upload_methods: + - local_file + - remote_url + label: file + max_length: 1 + options: [] + required: true + type: file + variable: file + - label: course_name + max_length: 255 + options: [] + required: true + type: text-input + variable: course_name + - default: '1' + label: course_id + max_length: 48 + options: [] + required: true + type: number + variable: course_id + - default: '16' + label: material_id + max_length: 48 + options: [] + required: true + type: number + variable: material_id + height: 187 + id: '1757513649648' + position: + x: 30 + y: 283 + positionAbsolute: + x: 30 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + is_array_file: true + selected: false + title: 文档提取器 + type: document-extractor + variable_selector: + - '1757513649648' + - file + height: 104 + id: '1757513748987' + position: + x: 934 + y: 281 + positionAbsolute: + x: 934 + y: 281 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + desc: '' + error_strategy: fail-branch + model: + completion_params: + temperature: 0.1 + mode: chat + name: google/gemini-3-pro-preview + provider: langgenius/openrouter/openrouter + prompt_template: + - id: f0a75809-0e24-491f-bd19-964d8b2eae4c + role: system + text: "# 角色\n你是一个文件拆解高手,擅长将用户提交的内容进行精准拆分,拆分后的内容做个简单的优化处理使其更具可读性,但要尽量使用原文的原词原句。\n\ + \n## 技能\n### 技能 1: 内容拆分\n1. 当用户提交内容后,拆分为多段。\n2. 对拆分后的内容做简单优化,使其更具可读性,比如去掉奇怪符号(如换行符、乱码),若语句不通顺,或格式原因导致错位,则重新表达。用户可能会提交录音转文字的内容,因此可能是有错字的,注意修复这些小瑕疵。\n\ + 3. 优化过程中,尽量使用原文的原词原句,特别是话术类,必须保持原有的句式、保持原词原句,而不是重构。\n4. 注意是拆分而不是重写,不需要润色,尽量不做任何处理。\n\ + 5. 输出到 content。\n\n### 技能 2: 为每一个选段概括一个标题\n1. 为每个拆分出来的选段概括一个标题,并输出到 title。\n\ + \n### 技能 3: 为每一个选段说明与主题的关联\n1. 详细说明这一段与全文核心主题的关联,并输出到 topic_relation。\n\ + \n### 技能 4: 为每一个选段打上一个类型标签\n1. 用户提交的内容很有可能是一个课程、一篇讲义、一个产品的说明书,通常是用户希望他公司的员工或高管学习的知识。\n\ + 2. 用户通常是医疗美容机构或轻医美、生活美容连锁品牌。\n3. 你要为每个选段打上一个知识类型的标签,最好是这几个类型中的一个:\"理论知识\"\ + , \"诊断设计\", \"操作步骤\", \"沟通话术\", \"案例分析\", \"注意事项\", \"技巧方法\", \"客诉处理\"\ + 。当然你也可以为这个选段匹配一个更适合的。\n\n## 输出要求(严格按要求输出)\n请直接输出一个纯净的 JSON 数组(Array),不要包含\ + \ Markdown 标记(如 ```json),也不要包含任何解释性文字。格式如下:\n\n[\n {\n \"title\":\ + \ \"知识点标题\",\n \"content\": \"知识点内容\",\n \"topic_relation\": \"\ + 知识点与主题的关系\",\n \"type\": \"知识点类型\"\n },\n {\n \"title\": \"第二个知识点标题\"\ + ,\n \"content\": \"第二个知识点内容...\",\n \"topic_relation\": \"...\"\ + ,\n \"type\": \"...\"\n }\n]\n\n## 限制\n- 仅围绕用户提交的内容进行拆分和关联标注,不涉及其他无关内容。\n\ + - 拆分后的内容必须最大程度保持与原文一致。\n- 关联说明需清晰合理。\n- 不论如何,不要拆分超过 20 段!" + - id: bc1168ad-45de-475e-9365-8791306c8bb3 + role: user + text: '课程主题:{{#1757513649648.course_name#}} + + ## 用户提交的内容: + + {{#1757513748987.text#}} + + + ## 注意 + + - 以json的格式输出 + + - 不论如何,不要拆分超过20 段!' + selected: false + structured_output: + schema: + additionalProperties: false + properties: + content: + description: 知识点内容 + type: string + title: + description: 知识点标题 + type: string + required: + - title + - content + type: object + structured_output_enabled: false + title: 知识点提取 + type: llm + variables: [] + vision: + enabled: false + height: 124 + id: '1757513757216' + position: + x: 1236 + y: 283 + positionAbsolute: + x: 1236 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + code: "import json\nimport re\ndef main(arg1: str) -> dict:\n # --- 内部辅助函数:清洗文本以适配\ + \ SQL ---\n def clean_text_for_sql(text):\n if not isinstance(text,\ + \ str):\n return text\n \n # 1. 【关键修改】将物理换行符替换为\ + \ SQL 转义换行符 (\\\\n)\n # 这样 SQL 语句本身是一行,但数据库会将其解释为换行\n text\ + \ = text.replace('\\n', '\\\\n').replace('\\r', '')\n \n #\ + \ 2. 将单引号替换为两个单引号(SQL 标准转义),防止截断\n text = text.replace(\"'\", \"\ + ''\")\n \n return text\n try:\n if not arg1:\n \ + \ return {\"data\": []}\n \n # --- 1. 提取 JSON 字符串\ + \ (保持不变) ---\n json_str = arg1\n match = re.search(r'```json\\\ + s*(.*?)\\s*```', arg1, re.DOTALL)\n if match:\n json_str\ + \ = match.group(1)\n else:\n start = arg1.find('[')\n\ + \ end = arg1.rfind(']')\n if start != -1 and end !=\ + \ -1:\n json_str = arg1[start:end+1]\n # --- 2. 解析\ + \ JSON ---\n data_list = json.loads(json_str)\n \n \ + \ # --- 3. 校验与清洗数据 ---\n if not isinstance(data_list, list):\n \ + \ if isinstance(data_list, dict) and \"items\" in data_list:\n\ + \ data_list = data_list[\"items\"]\n else:\n \ + \ return {\"data\": []}\n if len(data_list) >= 30:\n\ + \ data_list = data_list[:29]\n \n # 遍历列表进行清洗\n\ + \ cleaned_list = []\n for item in data_list:\n \ + \ if isinstance(item, dict):\n cleaned_item = {\n \ + \ \"title\": clean_text_for_sql(item.get(\"title\", \"\")),\n\ + \ \"content\": clean_text_for_sql(item.get(\"content\"\ + , \"\")),\n \"topic_relation\": clean_text_for_sql(item.get(\"\ + topic_relation\", \"\")),\n \"type\": clean_text_for_sql(item.get(\"\ + type\", \"\"))\n }\n cleaned_list.append(cleaned_item)\n\ + \ \n return {\"data\": cleaned_list}\n \n except json.JSONDecodeError\ + \ as e:\n print(f\"JSON解析错误: {str(e)}\")\n return {\"data\"\ + : []}\n except Exception as e:\n print(f\"处理过程中发生错误: {str(e)}\"\ + )\n return {\"data\": []}" + code_language: python3 + desc: '' + error_strategy: fail-branch + outputs: + data: + children: null + type: array[object] + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 转换格式 + type: code + variables: + - value_selector: + - '1757513757216' + - text + value_type: string + variable: arg1 + height: 117 + id: '1757516212204' + position: + x: 1538 + y: 283 + positionAbsolute: + x: 1538 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1757687332404' + - output + value_type: array[string] + variable: output + selected: false + title: 结束 + type: end + height: 88 + id: '1757522230050' + position: + x: 2708 + y: 572 + positionAbsolute: + x: 2708 + y: 572 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1757513757216' + - error_message + value_type: string + variable: reasoning_content + selected: false + title: 结束 2 + type: end + height: 88 + id: '1757572091560' + position: + x: 1361.0212171476019 + y: 472.6567992168116 + positionAbsolute: + x: 1361.0212171476019 + y: 472.6567992168116 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1757516212204' + - error_message + value_type: string + variable: result + selected: false + title: 结束 3 + type: end + height: 88 + id: '1757576655478' + position: + x: 2123 + y: 283 + positionAbsolute: + x: 2123 + y: 283 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + error_handle_mode: continue-on-error + height: 410 + is_parallel: true + iterator_input_type: array[object] + iterator_selector: + - '1757516212204' + - data + output_selector: + - '1758575376121' + - title + output_type: array[string] + parallel_nums: 10 + selected: false + start_node_id: 1757687332404start + title: 迭代 + type: iteration + width: 1310.983478660764 + height: 410 + id: '1757687332404' + position: + x: 1939.6507421681436 + y: 396.2369270862009 + positionAbsolute: + x: 1939.6507421681436 + y: 396.2369270862009 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 1311 + zIndex: 1 + - data: + desc: '' + isInIteration: true + selected: false + title: '' + type: iteration-start + draggable: false + height: 48 + id: 1757687332404start + parentId: '1757687332404' + position: + x: 60 + y: 62 + positionAbsolute: + x: 1999.6507421681436 + y: 458.2369270862009 + selectable: false + selected: false + sourcePosition: right + targetPosition: left + type: custom-iteration-start + width: 44 + zIndex: 1002 + - data: + code: "def main(arg1: dict) -> dict:\n # 上一个节点已经完成了所有清洗工作(包括换行符转义 \\n 和单引号转义\ + \ '')\n # 这里只需要直接透传数据即可,不要再做任何处理\n return {\n \"title\": arg1.get(\"\ + title\"),\n \"content\": arg1.get(\"content\"),\n \"topic_relation\"\ + : arg1.get(\"topic_relation\"),\n \"type\": arg1.get(\"type\")\n\ + \ }" + code_language: python3 + desc: '' + isInIteration: true + isInLoop: false + iteration_id: '1757687332404' + outputs: + content: + children: null + type: string + title: + children: null + type: string + topic_relation: + children: null + type: string + type: + children: null + type: string + selected: false + title: 内容提取 + type: code + variables: + - value_selector: + - '1757687332404' + - item + value_type: object + variable: arg1 + height: 52 + id: '1758575376121' + parentId: '1757687332404' + position: + x: 204 + y: 60 + positionAbsolute: + x: 2143.6507421681436 + y: 456.2369270862009 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + zIndex: 1002 + - data: + outputs: + - value_selector: + - '1766636080995' + - text + value_type: string + variable: error_message + selected: true + title: 结束 5 + type: end + height: 88 + id: '1764240729694' + position: + x: 934 + y: 411 + positionAbsolute: + x: 934 + y: 411 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + error_strategy: fail-branch + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: DELETE FROM knowledge_points WHERE material_id = {{#1757513649648.material_id#}}; + type: tool + height: 117 + id: '1766636080995' + position: + x: 369.1162304946969 + y: 472.6567992168116 + positionAbsolute: + x: 369.1162304946969 + y: 472.6567992168116 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + isInIteration: true + isInLoop: false + is_team_authorization: true + iteration_id: '1757687332404' + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: INSERT INTO knowledge_points (course_id, material_id, name, description, + type, source, topic_relation) VALUES ({{#1757513649648.course_id#}}, + {{#1757513649648.material_id#}}, '{{#1758575376121.title#}}', '{{#1758575376121.content#}}', + '{{#1758575376121.type#}}', 1, '{{#1758575376121.topic_relation#}}'); + type: tool + height: 52 + id: '1766636254081' + parentId: '1757687332404' + position: + x: 552.0682037973988 + y: 83.70934394330777 + positionAbsolute: + x: 2491.7189459655424 + y: 479.9462710295087 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + zIndex: 1002 + viewport: + x: -154.69868384174993 + y: 184.48211749520988 + zoom: 0.5480921885368025 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-02-试题生成器-考陪练.yml b/docs/规划/dify 工作流/恩喜-02-试题生成器-考陪练.yml new file mode 100644 index 0000000..3be9b85 --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-02-试题生成器-考陪练.yml @@ -0,0 +1,658 @@ +app: + description: 动态生成考试题目 + icon: 🤖 + icon_background: '#FBE8FF' + mode: workflow + name: 恩喜-02-试题生成器-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: if-else + targetType: code + id: 1757697174164-false-1759336370957-target + source: '1757697174164' + sourceHandle: 'false' + target: '1759336370957' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: llm + targetType: end + id: 1759336189971-source-1757522219070-target + source: '1759336189971' + sourceHandle: source + target: '1757522219070' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: if-else + targetType: llm + id: 1757697174164-true-17593434940720-target + source: '1757697174164' + sourceHandle: 'true' + target: '17593434940720' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: llm + targetType: end + id: 17593434940720-source-17576978306140-target + source: '17593434940720' + sourceHandle: source + target: '17576978306140' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1757517722090-source-1766636474539-target + source: '1757517722090' + sourceHandle: source + target: '1766636474539' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: if-else + id: 1766636474539-source-1757697174164-target + source: '1766636474539' + sourceHandle: source + target: '1757697174164' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: code + targetType: tool + id: 1759336370957-source-1766636566272-target + source: '1759336370957' + sourceHandle: source + target: '1766636566272' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766636566272-source-1759336189971-target + source: '1766636566272' + sourceHandle: source + target: '1759336189971' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + desc: '' + selected: false + title: 开始 + type: start + variables: + - default: '1' + label: course_id + max_length: 255 + options: [] + required: true + type: number + variable: course_id + - default: '1' + hint: '' + label: position_id + max_length: 48 + options: [] + placeholder: '' + required: true + type: number + variable: position_id + - default: '' + hint: '' + label: mistake_records + max_length: 480000 + options: [] + placeholder: '' + required: false + type: paragraph + variable: mistake_records + - default: '4' + hint: '' + label: single_choice_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: single_choice_count + - default: '2' + hint: '' + label: multiple_choice_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: multiple_choice_count + - default: '1' + hint: '' + label: true_false_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: true_false_count + - default: '2' + hint: '' + label: fill_blank_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: fill_blank_count + - default: '1' + hint: '' + label: essay_count + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: essay_count + - default: '3' + hint: '' + label: difficulty_level + max_length: 48 + options: [] + placeholder: '' + required: false + type: number + variable: difficulty_level + height: 317 + id: '1757517722090' + position: + x: 113.18367757866764 + y: 380.7254702687234 + positionAbsolute: + x: 113.18367757866764 + y: 380.7254702687234 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '1759336189971' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1757522219070' + position: + x: 2675.4479082184957 + y: 699.3770729563379 + positionAbsolute: + x: 2675.4479082184957 + y: 699.3770729563379 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + cases: + - case_id: 'true' + conditions: + - comparison_operator: not empty + id: 2b42b816-606a-4753-8494-5451b3d7ab42 + value: '' + varType: string + variable_selector: + - '1757517722090' + - mistake_records + id: 'true' + logical_operator: and + desc: '' + selected: false + title: 条件分支 + type: if-else + height: 124 + id: '1757697174164' + position: + x: 1075.7946800832713 + y: 393.17560717622047 + positionAbsolute: + x: 1075.7946800832713 + y: 393.17560717622047 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + desc: '' + outputs: + - value_selector: + - '17593434940720' + - text + value_type: string + variable: result + selected: false + title: 结束 (1) + type: end + height: 88 + id: '17576978306140' + position: + x: 1890.5828711788172 + y: 199.92291499877138 + positionAbsolute: + x: 1890.5828711788172 + y: 199.92291499877138 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + exclude_reasoning_tokens: true + response_format: json_object + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: df690399-5bed-4567-9b1d-5d31584d65e8 + role: system + text: "## 角色\n你是一位经验丰富的考试出题专家,能够依据用户提供的知识内容,结合用户的岗位特征,随机地生成{{#1759336370957.result#}}题考题。你会以专业、严谨且清晰的方式出题。\n\ + \n## 输出{{#1757517722090.single_choice_count#}}道单选题\n1、每道题目只能有 1 个正确答案。\n\ + 2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。\n3、答案解析要简明扼要,说明选择理由。\n4、为每道题记录出题来源的知识点 id。\n\ + 5、请以 JSON 格式输出。\n6、为每道题输出一个序号。\n\n### 输出结构:\n{\n \"num\": \"题号\",\n\ + \ \"type\": \"single_choice\",\n \"topic\": {\n \"title\"\ + : \"清晰完整的题目描述\",\n \"options\": {\n \"opt1\": \"A:符合语境的选项\"\ + ,\n \"opt2\": \"B:符合语境的选项\",\n \"opt3\": \"C:符合语境的选项\"\ + ,\n \"opt4\": \"D:符合语境的选项\"\n }\n },\n \"knowledge_point_id\"\ + : \"出题来源知识点的id\",\n \"correct\": \"其中一个选项的全部原文\",\n \"analysis\"\ + : \"准确的答案解析,包含选择原因和知识点说明\"\n}\n\n- 严格按照以上格式输出\n\n## 输出{{#1757517722090.multiple_choice_count#}}道多选题\n\ + 1、每道题目有多个正确答案。\n2、\"type\": \"multiple_choice\"\n3、其它事项同单选题。\n\n## 输出{{#1757517722090.true_false_count#}}道判断题\n\ + 1、每道题目只有 \"正确\" 或 \"错误\" 两种答案。\n2、题目表述应明确清晰,避免歧义。\n3、题目应直接陈述事实或观点,便于做出是非判断。\n\ + 4、其它事项同单选题。\n\n### 输出结构:\n{\n \"num\": \"题号\",\n \"type\": \"true_false\"\ + ,\n \"topic\": {\n \"title\": \"清晰完整的题目描述\"\n },\n \"\ + knowledge_point_id\": \" 出题来源知识点的id\",\n \"correct\": \"正确\", // 或\ + \ \"错误\",表示正确答案是对还是错\n \"analysis\": \"准确的答案解析,包含判断原因和知识点说明\"\n}\n\n\ + - 严格按照以上格式输出\n\n## 输出{{#1757517722090.fill_blank_count#}}道填空题\n1. 题干应明确完整,空缺处需用横线“___”标示,且只能有一处空缺\n\ + 2. 答案应唯一且明确,避免开放性表述\n3. 空缺长度应与答案长度大致匹配\n4. 解析需说明答案依据及相关知识点\n5. 其余要求与单选题一致\n\ + \n### 输出结构:\n{\n \"num\": \"题号\",\n \"type\": \"fill_blank\",\n\ + \ \"topic\": {\n \"title\": \"包含___空缺的题目描述\"\n },\n \"\ + knowledge_point_id\": \"出题来源知识点的id\",\n \"correct\": \"准确的填空答案\",\n\ + \ \"analysis\": \"解析答案的依据和相关知识点说明\"\n}\n\n- 严格按照以上格式输出\n\n### 输出{{#1757517722090.essay_count#}}道问答题\n\ + 1. 问题应具体明确,限定回答范围\n2. 答案需条理清晰,突出核心要点\n3. 解析可补充扩展说明或评分要点\n4. 避免过于宽泛或需要主观发挥的问题\n\ + 5. 其余要求同单选题\n\n### 输出结构:\n{\n \"num\": \"题号\",\n \"type\": \"essay\"\ + ,\n \"topic\": {\n \"title\": \"需要详细回答的问题描述\"\n },\n \"\ + knowledge_point_id\": \"出题来源知识点的id\",\n \"correct\": \"完整准确的参考答案(分点或连贯表述)\"\ + ,\n \"analysis\": \"对答案的补充说明、评分要点或相关知识点扩展\"\n}\n\n## 特殊要求\n1. 题目难度:{{#1757517722090.difficulty_level#}}级(5\ + \ 级为最难)\n2. 避免使用模棱两可的表述\n3. 选项内容要互斥,不能有重叠\n4. 每个选项长度尽量均衡\n5. 正确答案(A、B、C、D)分布要合理,避免规律性\n\ + 6. 正确答案必须使用其中一个选项中的全部原文,严禁修改\n7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个\ + \ id。\n\n请按以上要求生成题目,确保每道题目质量。" + - id: 9cb9ef44-bbfc-464d-9634-8873babcb6e4 + role: user + text: '# 请针对岗位特征、待出题的知识点内容进行出题。 + + ## 岗位信息: + + {{#1766636474539.text#}} + + + --- + + ## 知识点: + + {{#1766636566272.text#}}' + selected: false + structured_output_enabled: false + title: 第一轮出题 + type: llm + vision: + enabled: false + height: 88 + id: '1759336189971' + position: + x: 2233.770517088806 + y: 854.8259046963252 + positionAbsolute: + x: 2233.770517088806 + y: 854.8259046963252 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + code: "from typing import Dict\n\n\ndef main(\n single_choice_count: float,\n\ + \ multiple_choice_count: float,\n true_false_count: float,\n fill_blank_count:\ + \ float,\n essay_count: float,\n) -> Dict[str, str]:\n total = (\n\ + \ single_choice_count\n + multiple_choice_count\n +\ + \ true_false_count\n + fill_blank_count\n + essay_count\n\ + \ )\n # 将总和转换为字符串类型\n return {\n \"result\": str(total),\n\ + \ }\n" + code_language: python3 + outputs: + result: + children: null + type: string + selected: false + title: 计算总题量 + type: code + variables: + - value_selector: + - '1757517722090' + - single_choice_count + value_type: number + variable: single_choice_count + - value_selector: + - '1757517722090' + - multiple_choice_count + value_type: number + variable: multiple_choice_count + - value_selector: + - '1757517722090' + - true_false_count + value_type: number + variable: true_false_count + - value_selector: + - '1757517722090' + - fill_blank_count + value_type: number + variable: fill_blank_count + - value_selector: + - '1757517722090' + - essay_count + value_type: number + variable: essay_count + height: 52 + id: '1759336370957' + position: + x: 1120.8286797224275 + y: 773.2970549304636 + positionAbsolute: + x: 1120.8286797224275 + y: 773.2970549304636 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + response_format: json_object + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: df690399-5bed-4567-9b1d-5d31584d65e8 + role: system + text: "## 角色\n你是一位经验丰富的考试出题专家,能够依据用户提供的错题记录,重新为用户出题。你会为每道错题重新出一题,你会以专业、严谨且清晰的方式出题。\n\ + \n## 输出单选题\n1、每道题目只能有 1 个正确答案。\n2、干扰项要具有合理性和迷惑性,且所有选项必须与主题相关。\n3、答案解析要简明扼要,说明选择理由。\n\ + 4、为每道题记录出题来源的知识点 id。\n5、请以 JSON 格式输出。\n6、为每道题输出一个序号。\n\n### 输出结构:\n{\n\ + \ \"num\": \"题号\",\n \"type\": \"single_choice\",\n \"topic\"\ + : {\n \"title\": \"清晰完整的题目描述\",\n \"options\": {\n \ + \ \"opt1\": \"A:符合语境的选项\",\n \"opt2\": \"B:符合语境的选项\"\ + ,\n \"opt3\": \"C:符合语境的选项\",\n \"opt4\": \"D:符合语境的选项\"\ + \n }\n },\n \"knowledge_point_id\": \"出题来源知识点的id\",\n \ + \ \"correct\": \"其中一个选项的全部原文\",\n \"analysis\": \"准确的答案解析,包含选择原因和知识点说明\"\ + \n}\n\n- 严格按照以上格式输出\n\n\n## 特殊要求\n1. 题目难度:{{#1757517722090.difficulty_level#}}级(5\ + \ 级为最难)\n2. 避免使用模棱两可的表述\n3. 选项内容要互斥,不能有重叠\n4. 每个选项长度尽量均衡\n5. 正确答案(A、B、C、D)分布要合理,避免规律性\n\ + 6. 正确答案必须使用其中一个选项中的全部原文,严禁修改\n7. knowledge_point_id 必须是唯一的,即每道题的知识点来源只允许填一个\ + \ id。\n\n请按以上要求生成题目,确保每道题目质量。" + - id: 9cb9ef44-bbfc-464d-9634-8873babcb6e4 + role: user + text: '## 错题记录: + + {{#1757517722090.mistake_records#}}' + selected: false + structured_output_enabled: false + title: 错题重出 + type: llm + vision: + enabled: false + height: 88 + id: '17593434940720' + position: + x: 1542.474476799452 + y: 294.89553593472453 + positionAbsolute: + x: 1542.474476799452 + y: 294.89553593472453 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-查询岗位 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT id, name, description, skills, level FROM positions WHERE + id = 1 AND is_deleted = FALSE + type: tool + height: 52 + id: '1766636474539' + position: + x: 680.6972117920154 + y: 459.1566958608885 + positionAbsolute: + x: 680.6972117920154 + y: 459.1566958608885 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-查询知识点 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT kp.id, kp.name, kp.description, kp.topic_relation FROM knowledge_points + kp INNER JOIN course_materials cm ON kp.material_id = cm.id WHERE kp.course_id + = {{#1757517722090.course_id#}} AND kp.is_deleted = FALSE AND cm.is_deleted + = FALSE ORDER BY RAND() LIMIT {{#1759336370957.result#}} + type: tool + height: 52 + id: '1766636566272' + position: + x: 1751.0251613776625 + y: 1090.0135783943633 + positionAbsolute: + x: 1751.0251613776625 + y: 1090.0135783943633 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 208.8157315753573 + y: 57.80270447312665 + zoom: 0.3556496204015805 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-03-陪练知识准备-考陪练.yml b/docs/规划/dify 工作流/恩喜-03-陪练知识准备-考陪练.yml new file mode 100644 index 0000000..8b9dc7b --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-03-陪练知识准备-考陪练.yml @@ -0,0 +1,290 @@ +app: + description: 要陪练的知识点读取 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 恩喜-03-陪练知识准备-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1759259735113-source-1766637070902-target + source: '1759259735113' + sourceHandle: source + target: '1766637070902' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766637070902-source-1759345165645-target + source: '1766637070902' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: course_id + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: course_id + height: 109 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 1894 + y: 309 + positionAbsolute: + x: 1894 + y: 309 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: '你是一个训练场景研究专家,能将用户提交的知识点,转变为一个模拟陪练的场景,并严格按照以下格式输出。 + + + 输出标准: + + { + + "scene": { + + "name": "轻医美产品咨询陪练", + + "description": "模拟客户咨询轻医美产品的场景", + + "background": "客户对脸部抗衰项目感兴趣。", + + "ai_role": "AI扮演一位30岁女性客户", + + "objectives": ["了解客户需求", "介绍产品优势", "处理价格异议"], + + "keywords": ["抗衰", "玻尿酸", "价格"], + + "type": "product-intro", + + "difficulty": "intermediate" + + } + + }' + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '{{#1766637070902.text#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 1487 + y: 266 + positionAbsolute: + x: 1487 + y: 266 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + retry_config: + max_retries: 3 + retry_enabled: true + retry_interval: 1000 + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT kp.name, kp.description FROM knowledge_points kp INNER JOIN + course_materials cm ON kp.material_id = cm.id WHERE kp.course_id = {{#1759259735113.course_id#}} + AND kp.is_deleted = 0 AND cm.is_deleted = 0 ORDER BY kp.id; + type: tool + height: 81 + id: '1766637070902' + position: + x: 786.8609430099932 + y: 494.8734381122215 + positionAbsolute: + x: 786.8609430099932 + y: 494.8734381122215 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 203.70985243053667 + y: 193.70165441393362 + zoom: 0.4438204415451904 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-04-与课程对话-考陪练.yml b/docs/规划/dify 工作流/恩喜-04-与课程对话-考陪练.yml new file mode 100644 index 0000000..4cdee8d --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-04-与课程对话-考陪练.yml @@ -0,0 +1,273 @@ +app: + description: 考陪练系统专用 + icon: 🤖 + icon_background: '#FFEAD5' + mode: advanced-chat + name: 恩喜-04-与课程对话-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + sourceType: llm + targetType: answer + id: llm-answer + source: llm + sourceHandle: source + target: answer + targetHandle: target + type: custom + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1760457791548-source-1766637272902-target + source: '1760457791548' + sourceHandle: source + target: '1766637272902' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766637272902-source-llm-target + source: '1766637272902' + sourceHandle: source + target: llm + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: course_id + max_length: 256 + options: [] + placeholder: '' + required: true + type: text-input + variable: course_id + height: 109 + id: '1760457791548' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + memory: + query_prompt_template: '{{#sys.query#}}' + role_prefix: + assistant: '' + user: '' + window: + enabled: true + size: 10 + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: b5d31fea-e978-4229-81ed-ac3fe42f2f18 + role: system + text: '你是知识拆解专家,精通以下知识库(课程)内容。请根据用户的问题,从知识库中找到最相关的信息,进行深入分析后,用简洁清晰的语言回答用户。为用户提供与课程对话的服务。 + + + 回答要求: + + 1. 直接针对问题核心,避免冗长铺垫 + + 2. 使用通俗易懂的语言,必要时举例说明 + + 3. 突出关键要点,帮助用户快速理解 + + 4. 如果知识库中没有相关内容,请如实告知 + + + 知识库: + + {{#1766637272902.text#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: llm + position: + x: 980 + y: 282 + positionAbsolute: + x: 980 + y: 282 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + answer: '{{#llm.text#}}' + selected: false + title: 直接回复 + type: answer + variables: [] + height: 103 + id: answer + position: + x: 1280 + y: 282 + positionAbsolute: + x: 1280 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-SQL 执行器-考陪练专用 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT kp.name, kp.description FROM knowledge_points kp INNER JOIN + course_materials cm ON kp.material_id = cm.id WHERE kp.course_id = {{#1760457791548.course_id#}} + AND kp.is_deleted = 0 AND cm.is_deleted = 0 ORDER BY kp.id; + type: tool + height: 52 + id: '1766637272902' + position: + x: 573.1013773769109 + y: 452.5465761475262 + positionAbsolute: + x: 573.1013773769109 + y: 452.5465761475262 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 264.91206019696983 + y: 183.58250340914594 + zoom: 0.523860819025226 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml b/docs/规划/dify 工作流/恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml new file mode 100644 index 0000000..83db7fb --- /dev/null +++ b/docs/规划/dify 工作流/恩喜-05-智能工牌能力分析与课程推荐-考陪练.yml @@ -0,0 +1,380 @@ +app: + description: 智能工牌能力分析与课程推荐 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 恩喜-05-智能工牌能力分析与课程推荐-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_batch_limit: 10 + image_file_size_limit: 10 + single_chunk_attachment_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: tool + id: 1759259735113-source-1766637417515-target + source: '1759259735113' + sourceHandle: source + target: '1766637417515' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: tool + targetType: tool + id: 1766637417515-source-1766637451330-target + source: '1766637417515' + sourceHandle: source + target: '1766637451330' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInLoop: false + sourceType: tool + targetType: llm + id: 1766637451330-source-1759345165645-target + source: '1766637451330' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: dialogue_history + max_length: 90000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: dialogue_history + - default: '' + hint: '' + label: user_id + max_length: 48 + options: [] + placeholder: '' + required: true + type: text-input + variable: user_id + height: 135 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 1813.2223142363105 + y: 283.53180246801287 + positionAbsolute: + x: 1813.2223142363105 + y: 283.53180246801287 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: "你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。并根据课程列表,为该用户提供选课建议。\n\ + \n输出标准:\n{\n \"analysis\": {\n \"total_score\": 82,\n \"ability_dimensions\"\ + : [\n {\n \"name\": \"专业知识\",\n \"score\": 88,\n \ + \ \"feedback\": \"产品知识扎实,能准确回答客户问题。建议:继续深化对新产品的了解。\"\n },\n\ + \ {\n \"name\": \"沟通技巧\",\n \"score\": 92,\n \ + \ \"feedback\": \"语言表达清晰流畅,善于倾听客户需求。建议:可以多使用开放式问题引导。\"\n },\n \ + \ {\n \"name\": \"操作技能\",\n \"score\": 85,\n \"\ + feedback\": \"基本操作熟练,流程规范。建议:提升复杂场景的应对速度。\"\n },\n {\n \ + \ \"name\": \"客户服务\",\n \"score\": 90,\n \"feedback\"\ + : \"服务态度优秀,客户体验良好。建议:进一步提升个性化服务能力。\"\n },\n {\n \"name\"\ + : \"安全意识\",\n \"score\": 79,\n \"feedback\": \"基本安全规范掌握,但在细节提醒上还可加强。\"\ + \n },\n {\n \"name\": \"应变能力\",\n \"score\": 76,\n\ + \ \"feedback\": \"面对突发情况反应较快,但处理方式可以更灵活多样。\"\n }\n ],\n\ + \ \"course_recommendations\": [\n {\n \"course_id\": 5,\n\ + \ \"course_name\": \"应变能力提升训练营\",\n \"recommendation_reason\"\ + : \"该课程专注于提升应变能力,包含大量实战案例分析和模拟演练,针对您当前的薄弱环节(应变能力76分)设计。通过学习可提升15分左右。\"\ + ,\n \"priority\": \"high\",\n \"match_score\": 95\n \ + \ },\n {\n \"course_id\": 3,\n \"course_name\": \"\ + 安全规范与操作标准\",\n \"recommendation_reason\": \"系统讲解安全规范和操作标准,通过案例教学帮助建立安全意识。当前您的安全意识得分为79分,通过本课程学习预计可提升12分。\"\ + ,\n \"priority\": \"high\",\n \"match_score\": 88\n \ + \ },\n {\n \"course_id\": 7,\n \"course_name\": \"\ + 高级销售技巧\",\n \"recommendation_reason\": \"进阶课程,帮助您将已有的沟通优势(92分)转化为更高级的销售技能,进一步巩固客户服务能力(90分)。\"\ + ,\n \"priority\": \"medium\",\n \"match_score\": 82\n \ + \ }\n ]\n }\n}" + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '对话记录:{{#1759259735113.dialogue_history#}} + + --- + + 用户的信息和岗位:{{#1766637451330.text#}} + + --- + + 所有可选课程:{{#1766637451330.text#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 1363.5028016995486 + y: 288.04877731559196 + positionAbsolute: + x: 1363.5028016995486 + y: 288.04877731559196 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: true + title: 恩喜-00-获取用户信息 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT p.id as position_id, p.name as position_name, p.code, p.description, + p.skills, p.level, p.status FROM positions p INNER JOIN position_members + pm ON p.id = pm.position_id WHERE pm.user_id = {{#1759259735113.user_id#}} + AND pm.is_deleted = 0 AND p.is_deleted = 0 + type: tool + height: 52 + id: '1766637417515' + position: + x: 587 + y: 448 + positionAbsolute: + x: 587 + y: 448 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + is_team_authorization: true + paramSchemas: + - auto_generate: null + default: null + form: llm + human_description: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + label: + en_US: SQL 语句 + ja_JP: SQL 语句 + pt_BR: SQL 语句 + zh_Hans: SQL 语句 + llm_description: '' + max: null + min: null + name: sql + options: [] + placeholder: + en_US: '' + ja_JP: '' + pt_BR: '' + zh_Hans: '' + precision: null + required: true + scope: null + template: null + type: string + params: + sql: '' + plugin_id: null + plugin_unique_identifier: null + provider_icon: + background: '#FFEAD5' + content: 🤖 + provider_id: 2e7e915c-606c-4230-b4bd-ff95efb72f39 + provider_name: 恩喜-00-SQL 执行器-考陪练专用 + provider_type: workflow + selected: false + title: 恩喜-00-获取所有课程 + tool_configurations: {} + tool_description: 考陪练系统专用的 sql 执行器 + tool_label: 恩喜-00-SQL 执行器-考陪练专用 + tool_name: SQL_executor_enxi + tool_node_version: '2' + tool_parameters: + sql: + type: mixed + value: SELECT id, name, description, category, tags, difficulty_level, + duration_hours FROM courses WHERE status = 'published' AND is_deleted + = FALSE ORDER BY sort_order + type: tool + height: 52 + id: '1766637451330' + position: + x: 889 + y: 448 + positionAbsolute: + x: 889 + y: 448 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: -85.25185611291681 + y: 136.04491275815266 + zoom: 0.488825051752638 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/通用-答案判断器-考陪练.yml b/docs/规划/dify 工作流/通用-答案判断器-考陪练.yml new file mode 100644 index 0000000..52806de --- /dev/null +++ b/docs/规划/dify 工作流/通用-答案判断器-考陪练.yml @@ -0,0 +1,214 @@ +app: + description: 判断填空题与问答题是否回答正确 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 通用-答案判断器-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_size_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: llm + id: 1759259735113-source-1759345165645-target + source: '1759259735113' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: question + max_length: 4800 + options: [] + placeholder: '' + required: true + type: paragraph + variable: question + - default: '' + hint: '' + label: correct_answer + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: correct_answer + - default: '' + hint: '' + label: user_answer + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: user_answer + - default: '' + hint: '' + label: analysis + max_length: 9000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: analysis + height: 166 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: false + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 994 + y: 309 + positionAbsolute: + x: 994 + y: 309 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: '你是一个答案判断器,根据用户提交的答案,比对题目、答案、解析。给出正确或错误的判断。 + + - 注意:仅输出“正确”或“错误”,无需更多字符和说明。' + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '题目:{{#1759259735113.question#}}。 + + 正确答案:{{#1759259735113.correct_answer#}}。 + + 解析:{{#1759259735113.analysis#}}。 + + + 考生的回答:{{#1759259735113.user_answer#}}。' + selected: true + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 587 + y: 266 + positionAbsolute: + x: 587 + y: 266 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 189.67940233231604 + y: 148.41666226499444 + zoom: 0.7229018098572999 + rag_pipeline_variables: [] diff --git a/docs/规划/dify 工作流/通用-陪练分析报告-考陪练.yml b/docs/规划/dify 工作流/通用-陪练分析报告-考陪练.yml new file mode 100644 index 0000000..0540634 --- /dev/null +++ b/docs/规划/dify 工作流/通用-陪练分析报告-考陪练.yml @@ -0,0 +1,202 @@ +app: + description: 考陪练的陪练后分析并给出报告 + icon: 🤖 + icon_background: '#FFEAD5' + mode: workflow + name: 通用-陪练分析报告-考陪练 + use_icon_as_answer_icon: false +dependencies: +- current_identifier: null + type: marketplace + value: + marketplace_plugin_unique_identifier: langgenius/openrouter:0.0.22@99ef4cf4e08292c28806abaf24f295ed66e04e4b9e74385b487fd0767c7f56df + version: null +kind: app +version: 0.5.0 +workflow: + conversation_variables: [] + environment_variables: [] + features: + file_upload: + allowed_file_extensions: + - .JPG + - .JPEG + - .PNG + - .GIF + - .WEBP + - .SVG + allowed_file_types: + - image + allowed_file_upload_methods: + - local_file + - remote_url + enabled: false + fileUploadConfig: + audio_file_size_limit: 50 + batch_count_limit: 5 + file_size_limit: 15 + image_file_size_limit: 10 + video_file_size_limit: 100 + workflow_file_upload_limit: 10 + image: + enabled: false + number_limits: 3 + transfer_methods: + - local_file + - remote_url + number_limits: 3 + opening_statement: '' + retriever_resource: + enabled: true + sensitive_word_avoidance: + enabled: false + speech_to_text: + enabled: false + suggested_questions: [] + suggested_questions_after_answer: + enabled: false + text_to_speech: + enabled: false + language: '' + voice: '' + graph: + edges: + - data: + isInIteration: false + isInLoop: false + sourceType: start + targetType: llm + id: 1759259735113-source-1759345165645-target + source: '1759259735113' + sourceHandle: source + target: '1759345165645' + targetHandle: target + type: custom + zIndex: 0 + - data: + isInIteration: false + isInLoop: false + sourceType: llm + targetType: end + id: 1759345165645-source-1759259743998-target + source: '1759345165645' + sourceHandle: source + target: '1759259743998' + targetHandle: target + type: custom + zIndex: 0 + nodes: + - data: + selected: false + title: 开始 + type: start + variables: + - default: '' + hint: '' + label: dialogue_history + max_length: 90000 + options: [] + placeholder: '' + required: true + type: paragraph + variable: dialogue_history + height: 88 + id: '1759259735113' + position: + x: 80 + y: 282 + positionAbsolute: + x: 80 + y: 282 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + outputs: + - value_selector: + - '1759345165645' + - text + value_type: string + variable: result + selected: true + title: 结束 + type: end + height: 88 + id: '1759259743998' + position: + x: 1037.0765156072616 + y: 266 + positionAbsolute: + x: 1037.0765156072616 + y: 266 + selected: true + sourcePosition: right + targetPosition: left + type: custom + width: 242 + - data: + context: + enabled: false + variable_selector: [] + model: + completion_params: + temperature: 0.7 + mode: chat + name: google/gemini-2.5-flash + provider: langgenius/openrouter/openrouter + prompt_template: + - id: c5d3c09c-d33c-430a-a8f3-663627411364 + role: system + text: "你是话术分析专家,用户是一家轻医美连锁品牌的员工,用户提交的是用户自己与顾客的对话记录,你做分析与评分。并严格按照以下格式输出。\n\ + \n输出标准:\n{\n \"analysis\": {\n \"total_score\": 88,\n \"score_breakdown\"\ + : [\n {\"name\": \"开场技巧\", \"score\": 92, \"description\": \"开场自然,快速建立信任\"\ + },\n {\"name\": \"需求挖掘\", \"score\": 90, \"description\": \"能够有效识别客户需求\"\ + },\n {\"name\": \"产品介绍\", \"score\": 88, \"description\": \"产品介绍清晰,重点突出\"\ + },\n {\"name\": \"异议处理\", \"score\": 85, \"description\": \"处理客户异议还需加强\"\ + },\n {\"name\": \"成交技巧\", \"score\": 86, \"description\": \"成交话术运用良好\"\ + }\n ],\n \"ability_dimensions\": [\n {\"name\": \"沟通表达\", \"\ + score\": 90, \"feedback\": \"语言流畅,表达清晰,语调富有亲和力\"},\n {\"name\": \"\ + 倾听理解\", \"score\": 92, \"feedback\": \"能够准确理解客户意图,给予恰当回应\"},\n {\"\ + name\": \"情绪控制\", \"score\": 88, \"feedback\": \"整体情绪稳定,面对异议时保持专业\"},\n\ + \ {\"name\": \"专业知识\", \"score\": 93, \"feedback\": \"对医美项目知识掌握扎实\"\ + },\n {\"name\": \"销售技巧\", \"score\": 87, \"feedback\": \"销售流程把控良好\"\ + },\n {\"name\": \"应变能力\", \"score\": 85, \"feedback\": \"面对突发问题能够快速反应\"\ + }\n ],\n \"dialogue_annotations\": [\n {\"sequence\": 1, \"\ + tags\": [\"亮点话术\"], \"comment\": \"开场专业,身份介绍清晰\"},\n {\"sequence\"\ + : 3, \"tags\": [\"金牌话术\"], \"comment\": \"巧妙引导,从客户角度出发\"},\n {\"\ + sequence\": 5, \"tags\": [\"亮点话术\"], \"comment\": \"类比生动,让客户容易理解\"},\n\ + \ {\"sequence\": 7, \"tags\": [\"金牌话术\"], \"comment\": \"专业解答,打消客户疑虑\"\ + }\n ],\n \"suggestions\": [\n {\"title\": \"控制语速\", \"content\"\ + : \"您的语速偏快,建议适当放慢,给客户更多思考时间\", \"example\": \"说完产品优势后,停顿2-3秒,观察客户反应\"\ + },\n {\"title\": \"多用开放式问题\", \"content\": \"增加开放式问题的使用,更深入了解客户需求\"\ + , \"example\": \"您对未来的保障有什么期望?而不是您需要保险吗?\"},\n {\"title\": \"强化成交信号识别\"\ + , \"content\": \"客户已经表现出兴趣时,要及时推进成交\", \"example\": \"当客户问费用多少时,这是购买信号,应该立即报价并促成\"\ + }\n ]\n }\n}" + - id: 2da57109-d891-46a6-8094-6f6ff63f8e5b + role: user + text: '{{#1759259735113.dialogue_history#}}' + selected: false + title: LLM + type: llm + vision: + enabled: false + height: 88 + id: '1759345165645' + position: + x: 587 + y: 266 + positionAbsolute: + x: 587 + y: 266 + selected: false + sourcePosition: right + targetPosition: left + type: custom + width: 242 + viewport: + x: 49.06021215084729 + y: -2.7868017986568248 + zoom: 0.5755553326202135 + rag_pipeline_variables: [] diff --git a/docs/规划/全链路联调/Ai工作流/coze/Coze-API文档.md b/docs/规划/全链路联调/Ai工作流/coze/Coze-API文档.md new file mode 100644 index 0000000..e98deb9 --- /dev/null +++ b/docs/规划/全链路联调/Ai工作流/coze/Coze-API文档.md @@ -0,0 +1,434 @@ +# Coze API 使用文档 + +## 一、概述 + +Coze是字节跳动推出的AI对话平台,提供强大的Bot开发和对话管理能力。本文档整理了考培练系统陪练功能需要使用的核心API。 + +### 官方资源 +- **官方文档**: https://www.coze.cn/open/docs/developer_guides/chat_v3 +- **Python SDK**: https://github.com/coze-dev/coze-py +- **API域名**: https://api.coze.cn (中国区) + +### 重要提示 +⚠️ **从GitHub获取的源码和示例默认使用 `COZE_COM_BASE_URL`,使用前必须改为 `COZE_CN_BASE_URL`** + +## 二、认证方式 + +### 2.1 个人访问令牌 (Personal Access Token - 推荐) + +**获取方式**: +1. 访问 https://www.coze.cn/open/oauth/pats +2. 创建新的个人访问令牌 +3. 设置名称、有效期和权限 +4. 保存令牌(仅显示一次) + +**使用示例**: +```python +from cozepy import Coze, TokenAuth, COZE_CN_BASE_URL + +coze = Coze( + auth=TokenAuth(token="pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi"), + base_url=COZE_CN_BASE_URL # 重要:使用中国区域名 +) +``` + +### 2.2 OAuth JWT认证 (生产环境推荐) + +```python +from cozepy import Coze, JWTAuth, COZE_CN_BASE_URL +from pathlib import Path + +coze = Coze( + auth=JWTAuth( + client_id="your_client_id", + private_key=Path("private_key.pem").read_text(), + public_key_id="your_public_key_id", + ttl=900 # Token有效期(秒) + ), + base_url=COZE_CN_BASE_URL +) +``` + +## 三、核心API功能 + +### 3.1 Bot对话 (Chat API) + +#### 流式对话 (推荐) + +**功能说明**:实时流式返回AI响应,适合陪练对话场景 + +**示例代码**: +```python +from cozepy import Coze, TokenAuth, Message, ChatEventType, COZE_CN_BASE_URL + +coze = Coze(auth=TokenAuth(token="your_token"), base_url=COZE_CN_BASE_URL) + +# 创建流式对话 +stream = coze.chat.stream( + bot_id='7560643598174683145', # 陪练Bot ID + user_id='user_123', # 用户ID(业务系统的用户标识) + additional_messages=[ + Message.build_user_question_text("你好,我想练习轻医美产品咨询"), + ], + conversation_id='conv_abc', # 可选:关联对话ID +) + +# 处理流式事件 +for event in stream: + if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + # 消息增量(实时打字效果) + print(event.message.content, end="", flush=True) + + elif event.event == ChatEventType.CONVERSATION_MESSAGE_COMPLETED: + # 消息完成 + print("\n消息完成") + + elif event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED: + # 对话完成 + print("Token用量:", event.chat.usage.token_count) + break + + elif event.event == ChatEventType.CONVERSATION_CHAT_FAILED: + # 对话失败 + print("对话失败:", event.chat.last_error) + break +``` + +#### 非流式对话 + +```python +chat = coze.chat.create( + bot_id='bot_id', + user_id='user_id', + additional_messages=[ + Message.build_user_question_text('你好') + ] +) +print(chat.content) +``` + +### 3.2 对话管理 (Conversation API) + +#### 创建对话 + +```python +# 创建新对话 +conversation = coze.conversations.create() +print("对话ID:", conversation.id) +``` + +#### 获取对话列表 + +```python +# 获取Bot的对话列表 +conversations = coze.conversations.list( + bot_id='bot_id', + page_num=1, + page_size=20 +) + +for conv in conversations.items: + print(f"对话ID: {conv.id}, 创建时间: {conv.created_at}") +``` + +#### 删除对话 + +```python +# 删除指定对话 +coze.conversations.delete(conversation_id='conversation_id') +``` + +### 3.3 消息历史 + +#### 获取对话消息 + +```python +# 获取指定对话的消息列表 +messages = coze.conversations.messages.list( + conversation_id='conversation_id', + page_num=1, + page_size=50 +) + +for msg in messages.items: + print(f"{msg.role}: {msg.content}") +``` + +### 3.4 中断对话 + +```python +# 中断正在进行的对话 +result = coze.chat.cancel( + conversation_id='conversation_id', + chat_id='chat_id' +) +``` + +### 3.5 文件上传 (可选) + +```python +from pathlib import Path + +# 上传文件(如音频文件) +uploaded_file = coze.files.upload(file=Path('audio.wav')) +print("文件ID:", uploaded_file.id) + +# 在消息中使用文件 +from cozepy import MessageObjectString + +message = Message.build_user_question_objects([ + MessageObjectString.build_audio(file_id=uploaded_file.id) +]) +``` + +## 四、事件类型说明 + +### 4.1 ChatEventType枚举 + +| 事件类型 | 说明 | 用途 | +|---------|------|------| +| `CONVERSATION_CHAT_CREATED` | 对话创建 | 获取chat_id和conversation_id | +| `CONVERSATION_MESSAGE_DELTA` | 消息增量 | 实时显示打字效果 | +| `CONVERSATION_MESSAGE_COMPLETED` | 消息完成 | 显示完整消息 | +| `CONVERSATION_CHAT_COMPLETED` | 对话完成 | 统计Token用量、清理状态 | +| `CONVERSATION_CHAT_FAILED` | 对话失败 | 错误处理、用户提示 | +| `CONVERSATION_AUDIO_DELTA` | 音频增量 | 实时语音播放(语音对话) | + +### 4.2 事件对象结构 + +```python +# 消息增量事件 +event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA +event.message.content # 消息内容增量 +event.message.role # 消息角色:user/assistant + +# 对话完成事件 +event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED +event.chat.id # 对话ID +event.chat.conversation_id # 会话ID +event.chat.usage.token_count # Token用量 +event.chat.usage.input_count # 输入Token数 +event.chat.usage.output_count # 输出Token数 + +# 对话失败事件 +event.event == ChatEventType.CONVERSATION_CHAT_FAILED +event.chat.last_error # 错误信息 +``` + +## 五、消息构建方法 + +### 5.1 文本消息 + +```python +from cozepy import Message + +# 用户问题 +user_msg = Message.build_user_question_text("你好,我想了解产品") + +# 助手回答 +assistant_msg = Message.build_assistant_answer("好的,我来为您介绍") +``` + +### 5.2 多轮对话 + +```python +# 构建对话历史 +messages = [ + Message.build_user_question_text("第一个问题"), + Message.build_assistant_answer("第一个回答"), + Message.build_user_question_text("第二个问题"), +] + +stream = coze.chat.stream( + bot_id='bot_id', + user_id='user_id', + additional_messages=messages +) +``` + +## 六、错误处理 + +### 6.1 常见错误 + +```python +from cozepy.exception import CozePyError + +try: + chat = coze.chat.create(bot_id='bot_id', user_id='user_id') +except CozePyError as e: + print(f"Coze API错误: {e}") + # 处理错误 +``` + +### 6.2 超时配置 + +```python +import httpx +from cozepy import Coze, TokenAuth, SyncHTTPClient + +# 自定义超时设置 +http_client = SyncHTTPClient(timeout=httpx.Timeout( + timeout=180.0, # 总超时 + connect=5.0 # 连接超时 +)) + +coze = Coze( + auth=TokenAuth(token="your_token"), + base_url=COZE_CN_BASE_URL, + http_client=http_client +) +``` + +## 七、调试技巧 + +### 7.1 日志配置 + +```python +import logging +from cozepy import setup_logging + +# 启用DEBUG日志 +setup_logging(level=logging.DEBUG) +``` + +### 7.2 获取LogID + +```python +# 每个请求都有唯一的logid用于排查问题 +bot = coze.bots.retrieve(bot_id='bot_id') +print("LogID:", bot.response.logid) + +stream = coze.chat.stream(bot_id='bot_id', user_id='user_id') +print("LogID:", stream.response.logid) +``` + +## 八、最佳实践 + +### 8.1 陪练对话场景建议 + +1. **使用流式响应**:提供更好的用户体验 +2. **传递对话上下文**:使用`conversation_id`保持多轮对话 +3. **合理设置超时**:陪练对话建议180秒超时 +4. **错误重试机制**:网络波动时自动重试 +5. **Token计数统计**:监控API使用成本 + +### 8.2 用户ID设计 + +```python +# 推荐:使用业务系统的用户ID +user_id = f"trainee_{user.id}" # trainee_123 + +# 对话ID可包含场景信息 +conversation_id = f"practice_{scene_id}_{user_id}_{timestamp}" +``` + +### 8.3 场景参数传递 + +可以通过Bot的系统提示词(Prompt)或参数传递场景信息: + +```python +# 方式1:在用户消息中包含场景背景 +scene_context = """ +场景:轻医美产品咨询 +背景:客户是30岁女性,关注面部抗衰 +AI角色:扮演挑剔的客户,对价格敏感 +""" + +stream = coze.chat.stream( + bot_id='bot_id', + user_id='user_id', + additional_messages=[ + Message.build_user_question_text(scene_context + "\n\n开始陪练") + ] +) +``` + +## 九、性能优化 + +### 9.1 连接复用 + +```python +# 全局初始化一次Coze客户端 +coze = Coze(auth=TokenAuth(token="your_token"), base_url=COZE_CN_BASE_URL) + +# 多次调用复用连接 +def handle_chat(user_id, message): + stream = coze.chat.stream(bot_id='bot_id', user_id=user_id, ...) + return stream +``` + +### 9.2 异步并发 + +```python +from cozepy import AsyncCoze, AsyncTokenAuth +import asyncio + +async_coze = AsyncCoze( + auth=AsyncTokenAuth(token="your_token"), + base_url=COZE_CN_BASE_URL +) + +async def concurrent_chats(): + tasks = [ + async_coze.chat.create(bot_id='bot_id', user_id=f'user_{i}') + for i in range(10) + ] + results = await asyncio.gather(*tasks) + return results +``` + +## 十、陪练系统专用配置 + +### 10.1 配置信息 + +```python +# 考培练系统陪练Bot配置 +COZE_API_BASE = "https://api.coze.cn" +COZE_API_TOKEN = "pat_Sa5OiuUl0gDflnKstQTToIz0sSMshBV06diX0owOeuI1ZK1xDLH5YZH9fSeuKLIi" +COZE_PRACTICE_BOT_ID = "7560643598174683145" +``` + +### 10.2 FastAPI集成示例 + +```python +from fastapi import FastAPI +from fastapi.responses import StreamingResponse +from cozepy import Coze, TokenAuth, Message, ChatEventType, COZE_CN_BASE_URL +import json + +app = FastAPI() +coze = Coze(auth=TokenAuth(token="your_token"), base_url=COZE_CN_BASE_URL) + +@app.post("/api/v1/practice/start") +async def start_practice(user_id: str, message: str): + """开始陪练对话(SSE流式返回)""" + def generate_stream(): + stream = coze.chat.stream( + bot_id=COZE_PRACTICE_BOT_ID, + user_id=user_id, + additional_messages=[Message.build_user_question_text(message)] + ) + + for event in stream: + if event.event == ChatEventType.CONVERSATION_MESSAGE_DELTA: + yield f"event: message.delta\ndata: {json.dumps({'content': event.message.content})}\n\n" + elif event.event == ChatEventType.CONVERSATION_CHAT_COMPLETED: + yield f"event: done\ndata: [DONE]\n\n" + + return StreamingResponse( + generate_stream(), + media_type="text/event-stream" + ) +``` + +## 十一、参考资料 + +- **Coze Python SDK GitHub**: https://github.com/coze-dev/coze-py +- **示例代码目录**: `参考代码/coze-py-main/examples/` +- **后端参考实现**: `参考代码/coze-chat-backend/main.py` +- **官方文档**: https://www.coze.cn/open/docs + +--- + +**文档维护**:本文档基于 Coze Python SDK v0.19.0 编写,最后更新时间:2025-10-13 + diff --git a/docs/规划/全链路联调/Ai工作流/coze/Coze集成方案.md b/docs/规划/全链路联调/Ai工作流/coze/Coze集成方案.md new file mode 100644 index 0000000..30ed6f8 --- /dev/null +++ b/docs/规划/全链路联调/Ai工作流/coze/Coze集成方案.md @@ -0,0 +1,132 @@ +# Coze-Chat 集成方案分析 + +## 现状分析 + +### 技术栈差异 +- **主系统**:Vue3 + TypeScript + Element Plus +- **Coze-Chat**:React 18 + TypeScript + Ant Design + +### 功能定位 +Coze-Chat 是考培练系统的智能对话模块,提供: +- 智能体列表展示 +- 实时流式对话 +- 语音输入输出 +- 会话管理 + +## 集成方案对比 + +### 方案一:独立服务部署(推荐短期方案) + +**优势**: +- 无需重写代码,立即可用 +- 保持模块独立性和稳定性 +- 部署灵活,可独立扩展 + +**实施方式**: +1. 将 Coze-Chat 作为独立微服务部署在独立容器 +2. 通过 API Gateway 统一入口 +3. 主系统通过 iframe 或 API 调用集成 + +**配置示例**: +```yaml +# docker-compose.yml +services: + coze-service: + build: ./参考代码/coze-chat-系统/coze-chat-backend + ports: + - "8001:8000" + + coze-frontend: + build: ./参考代码/coze-chat-系统/coze-chat-frontend + ports: + - "3002:80" +``` + +### 方案二:逐步迁移到 Vue3(推荐长期方案) + +**优势**: +- 统一技术栈,降低维护成本 +- 更好的集成体验 +- 统一的组件库和样式 + +**实施计划**: +1. **第一阶段**:API 层对接 + - 保留 Coze 后端服务 + - 在 Vue3 中创建对话组件 + - 复用现有 API 接口 + +2. **第二阶段**:功能迁移 + - 智能体列表页面 + - 对话界面 + - 语音功能模块 + +3. **第三阶段**:完全整合 + - 统一用户系统 + - 统一权限管理 + - 统一样式主题 + +## 推荐实施路径 + +### 短期(1-2周) +1. 保持 Coze-Chat 作为独立服务 +2. 在主系统中通过 iframe 嵌入关键页面 +3. 统一认证 Token 传递 + +### 中期(1-2月) +1. 抽取 Coze API 服务层 +2. 在 Vue3 中实现核心对话组件 +3. 逐步替换 React 页面 + +### 长期(3-6月) +1. 完全迁移到 Vue3 +2. 优化集成体验 +3. 统一技术栈 + +## 技术要点 + +### API 对接 +```javascript +// Vue3 中调用 Coze API +import { cozeApi } from '@/api/coze' + +export const cozeService = { + // 获取智能体列表 + async getBots() { + return await cozeApi.get('/agent/v1/cozechat/bots') + }, + + // 创建对话 + async createChat(data) { + return await cozeApi.post('/agent/v1/cozechat/create-chat-stream', data) + } +} +``` + +### iframe 集成 +```vue +