- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
432 lines
15 KiB
Markdown
432 lines
15 KiB
Markdown
# 考培练系统联调经验汇总
|
||
|
||
> 系统账号:admin / admin123 | 最后更新:2026-01-21
|
||
|
||
---
|
||
|
||
## 核心经验速查表
|
||
|
||
| 问题类型 | 根因 | 解决方案 |
|
||
|---------|------|---------|
|
||
| iframe只显示一半 | height:100%无明确父高度 | 改用flex:1填充空间 |
|
||
| 页面标题硬编码 | 使用静态默认值 | 从API动态获取实际数据 |
|
||
| 多租户ID不存在 | 硬编码默认ID=1 | 从关联表动态查询,禁止硬编码 |
|
||
| 422验证错误 | 前端传空字符串给枚举字段 | Pydantic验证器处理空字符串 |
|
||
| 500变400 | 业务异常未正确捕获 | 区分ExternalServiceError(400)和Exception(500) |
|
||
| JS文件404 | 浏览器缓存旧HTML | Nginx对index.html设置no-cache |
|
||
| API响应访问错误 | 多套一层data | 正确:`res.code`/`res.data`,错误:`res.data.code` |
|
||
| request.get参数无效 | 直接传params对象 | 正确:`{ params: {...} }` |
|
||
| 外键约束失败 | 关联ID不存在 | 传null而非0,或先创建主表记录 |
|
||
| 路由匹配错误 | 动态路由在具体路由前 | `/mistakes`必须在`/{exam_id}`之前定义 |
|
||
| 数据库表不存在 | 使用已废弃的中间表 | 检查数据库架构文档的更新历史 |
|
||
| API方法不存在 | 服务方法名与API调用名不一致 | 添加别名方法或修正调用名 |
|
||
| 前端数据访问为空 | API返回嵌套结构未正确解析 | 检查后端返回结构,正确解析如 `res.data?.conversations` |
|
||
| 页面显示"未命名课程" | 未正确解析API响应嵌套结构 | `res.data.name`而非`res.name` |
|
||
|
||
---
|
||
|
||
## 多租户排查必读
|
||
|
||
```bash
|
||
# 第一步:确认租户数据库
|
||
docker inspect <租户>-backend --format '{{range .Config.Env}}{{println .}}{{end}}' | grep DATABASE
|
||
```
|
||
|
||
| 租户 | 数据库容器 | 数据库名 |
|
||
|-----|-----------|---------|
|
||
| ex(恩喜成都) | prod-mysql | kaopeilian_ex |
|
||
| aiedu(演示版) | kaopeilian-mysql | kaopeilian |
|
||
| kpl(瑞小美) | kpl-mysql-dev | kaopeilian |
|
||
|
||
---
|
||
|
||
## 2026-01 问题记录
|
||
|
||
### AI 配置必须从管理库加载(2026-01-21,重要!)
|
||
|
||
**问题**:知识点分析功能返回 500/502 错误,日志显示"AI_PRIMARY_API_KEY 未配置"
|
||
|
||
**根因**:
|
||
1. `AIService._load_config_from_db()` 方法查询的是**租户数据库的 `ai_config` 表**
|
||
2. 实际 AI 配置存储在**管理库(kaopeilian_admin)的 `tenant_configs` 表**中
|
||
3. 配置加载路径错误导致无法获取 API Key
|
||
|
||
**解决方案**:
|
||
|
||
1. **修改 `ai_service.py`**:将 `_load_config_from_db()` 改为 `_load_config_from_admin_db()`,直接连接管理库查询:
|
||
```python
|
||
def _load_config_from_admin_db(self) -> Optional[AIConfig]:
|
||
# 获取当前租户编码
|
||
tenant_code = os.getenv("TENANT_CODE", "demo")
|
||
# 连接管理库
|
||
admin_db_url = f"mysql+pymysql://{user}:{pwd}@{host}:{port}/{admin_db_name}"
|
||
# 查询 tenants 获取 tenant_id
|
||
# 查询 tenant_configs WHERE tenant_id AND config_group='ai'
|
||
```
|
||
|
||
2. **更新所有租户的 .env 文件**:添加管理库连接配置
|
||
```env
|
||
# 租户配置(用于多租户部署)
|
||
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
|
||
```
|
||
|
||
3. **重启后端容器**:使新环境变量生效
|
||
```bash
|
||
cd /data/prod-envs && docker compose -f docker-compose.prod-multi.yml up -d ex-backend --force-recreate
|
||
```
|
||
|
||
**配置加载优先级(最终版)**:
|
||
1. 管理库 `tenant_configs` 表(按 tenant_code 查询)
|
||
2. 环境变量(fallback)
|
||
3. 代码默认值
|
||
|
||
**涉及文件**:
|
||
- `app/services/ai/ai_service.py`
|
||
- `/data/prod-envs/kaopeilian-backend/.env.{tenant}`
|
||
|
||
**团队基线补充**:多租户 AI 配置必须从管理库(`kaopeilian_admin.tenant_configs`)加载,禁止依赖租户数据库的本地表
|
||
|
||
---
|
||
|
||
### 彻底脱离 Dify(2026-01-21)
|
||
- **目标**:完全移除系统对 Dify 平台的依赖
|
||
- **方案**:
|
||
1. 删除所有 Dify 相关服务文件(`dify_gateway.py`、`dify_practice_service.py`、`app/services/ai/dify/` 目录)
|
||
2. 清理所有 `.env` 文件中的 `DIFY_*` 配置项
|
||
3. 删除 `config.py` 中的 Dify 配置
|
||
4. 更新所有 API 端点,移除 `engine` 参数(不再支持 v1/v2 切换)
|
||
5. 更新文档,移除所有 Dify 相关描述
|
||
- **结果**:所有 AI 功能现在使用 Python 原生实现,通过 4sapi.com/OpenRouter 调用 AI API
|
||
|
||
### 课程对话页面标题显示固定值
|
||
- **根因**:`chat-course.vue`中课程标题硬编码为"销售技巧基础训练"
|
||
- **方案**:`onMounted`中调用`getCourseDetail(courseId)` API获取实际课程名称
|
||
- **文件**:`src/views/trainee/chat-course.vue`
|
||
|
||
### 考试生成400错误-岗位不存在
|
||
- **根因**:硬编码`position_id=1`,ex租户岗位ID从118开始
|
||
- **方案**:从`PositionCourse`表动态查询课程关联的岗位
|
||
|
||
### 课程创建422验证错误
|
||
- **根因**:前端`category=""`空字符串,后端枚举未处理
|
||
- **方案**:`@field_validator`空字符串返回默认值`CourseCategory.GENERAL`
|
||
|
||
### 删除资料知识点关联500
|
||
- **根因**:使用已废弃的`material_knowledge_points`中间表
|
||
- **方案**:直接更新`knowledge_points.material_id`字段
|
||
|
||
---
|
||
|
||
## 2025-12~11 问题记录
|
||
|
||
### 删除用户500错误
|
||
- **根因**:`soft_delete(db_obj=user)`参数名错误
|
||
- **方案**:改为`soft_delete(user)`
|
||
|
||
### KPL域名500错误
|
||
- **根因**:数据库字段缺失
|
||
- **方案**:用备份恢复数据库
|
||
|
||
---
|
||
|
||
## 2025-10 问题记录
|
||
|
||
### AI试题生成504超时
|
||
- **根因**:默认10秒超时,AI服务需要较长时间
|
||
- **方案**:开发环境设置10分钟超时
|
||
|
||
### 考试成绩分页不起效
|
||
- **根因**:SQLAlchemy查询未使用offset/limit
|
||
- **方案**:`.offset((page-1)*size).limit(size)`
|
||
|
||
### 课程资料预览失效
|
||
- **根因**:URL硬编码`http://localhost:8000`
|
||
- **方案**:使用相对路径`/static/uploads/...`
|
||
|
||
### Mixed Content错误
|
||
- **根因**:HTTPS页面请求HTTP资源
|
||
- **方案**:所有资源URL使用相对路径
|
||
|
||
### 知识点分析任务失败
|
||
- **根因**:文件上传后未正确触发分析
|
||
- **方案**:检查任务队列状态
|
||
|
||
---
|
||
|
||
## 关键代码模式
|
||
|
||
### 正确的API响应访问
|
||
```typescript
|
||
const res = await getList()
|
||
// ✅ 正确
|
||
if (res.code === 200) { list.value = res.data }
|
||
// ❌ 错误
|
||
if (res.data.code === 200) { list.value = res.data.data }
|
||
```
|
||
|
||
### 正确的request.get调用
|
||
```typescript
|
||
// ✅ 正确
|
||
request.get(url, { params: { id: 1 } })
|
||
// ❌ 错误
|
||
request.get(url, { id: 1 })
|
||
```
|
||
|
||
### 业务异常处理
|
||
```python
|
||
try:
|
||
result = await service.action()
|
||
except ExternalServiceError as e:
|
||
raise HTTPException(status_code=400, detail=str(e)) # 业务错误
|
||
except Exception as e:
|
||
raise HTTPException(status_code=500, detail=str(e)) # 系统错误
|
||
```
|
||
|
||
---
|
||
|
||
## 团队基线补充
|
||
|
||
1. **多租户禁止硬编码ID** - 从关联表动态查询
|
||
2. **页面动态数据禁止硬编码** - 标题、名称等从API获取
|
||
3. **前端API调用前置检查** - 角色、权限、必填字段
|
||
4. **数据库架构变更后检查代码** - 搜索使用该表的所有服务
|
||
5. **FastAPI路由顺序** - 具体路由在动态路由之前
|
||
6. **SPA必须禁用HTML缓存** - `Cache-Control: no-cache`
|
||
|
||
---
|
||
|
||
## 2026-01-21 新增问题
|
||
|
||
### AI Key 管理规范审查(重要)
|
||
|
||
**问题**:代码中硬编码 API Key,违反安全规范
|
||
|
||
**违反的规范**:
|
||
- 《瑞小美AI接入规范.md》:**禁止在代码中硬编码 API Key**
|
||
- 《技术栈标准》:密码、密钥等敏感信息禁止硬编码到代码或镜像中
|
||
|
||
**完整修复方案**:
|
||
1. 新建数据库表 `ai_config` 存储 AI 配置
|
||
2. 修改 `ai_service.py` 优先从数据库读取配置,fallback 到环境变量
|
||
3. 移除代码中的硬编码 Key,使用空字符串作为默认值
|
||
4. 更新数据库架构文档,添加 ai_config 表说明
|
||
|
||
**数据库配置表**:
|
||
```sql
|
||
CREATE TABLE ai_config (
|
||
config_key VARCHAR(100) NOT NULL UNIQUE,
|
||
config_value TEXT,
|
||
description VARCHAR(255)
|
||
);
|
||
-- 插入配置:AI_PRIMARY_API_KEY, AI_ANTHROPIC_API_KEY 等
|
||
```
|
||
|
||
**配置加载优先级**(ai_service.py):
|
||
1. 数据库 ai_config 表(推荐)
|
||
2. 环境变量(fallback)
|
||
|
||
**默认模型不符合"优先最强"原则**:
|
||
- ❌ 错误:`default_model = "gemini-3-flash-preview"`
|
||
- ✅ 正确:`default_model = "claude-opus-4-5-20251101-thinking"`
|
||
|
||
**模型常量命名规范**:
|
||
```python
|
||
MODEL_PRIMARY = "claude-opus-4-5-20251101-thinking" # 🥇 首选
|
||
MODEL_STANDARD = "gemini-3-pro-preview" # 🥈 标准
|
||
MODEL_FAST = "gemini-3-flash-preview" # 🥉 快速
|
||
```
|
||
|
||
### kpl-backend-dev 缺失 jwt 模块
|
||
|
||
**问题**:容器启动失败,报错 `ModuleNotFoundError: No module named 'jwt'`
|
||
**方案**:`docker exec kpl-backend-dev pip install PyJWT`
|
||
**根因**:requirements.txt 中可能遗漏了 PyJWT 依赖
|
||
|
||
### 课程详情页文档预览只显示一半内容
|
||
|
||
**问题**:`/trainee/course-detail` 页面中,学习资料的文档预览(特别是 DOCX 转 HTML 的 iframe)只能显示一半内容
|
||
|
||
**根因**:CSS 布局问题,`.preview-content` 和 `.html-viewer` 使用 `height: 100%` 但父容器没有明确高度,导致 iframe 无法正确计算高度
|
||
|
||
**解决方案**:
|
||
1. 给 `.content-main` 添加 `display: flex; flex-direction: column;` 和 `min-height: calc(100vh - 280px)`
|
||
2. 给 `.preview-container` 添加 `flex: 1`
|
||
3. 给 `.preview-content` 添加 `display: flex; flex-direction: column;`
|
||
4. 所有预览容器(`.pdf-viewer-container`、`.html-viewer`、`.video-viewer`、`.markdown-viewer`、`.text-viewer`)改用 `flex: 1` 替代 `height: 100%`
|
||
|
||
**关键修改**:
|
||
```scss
|
||
// 父容器使用 flex 布局并设置最小高度
|
||
.content-main {
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: calc(100vh - 280px);
|
||
}
|
||
|
||
// 子容器使用 flex: 1 填充空间
|
||
.html-viewer {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
|
||
.html-iframe {
|
||
flex: 1;
|
||
min-height: 600px;
|
||
}
|
||
}
|
||
```
|
||
|
||
**教训**:`height: 100%` 依赖父元素有明确的高度值,在 flexbox 布局中应优先使用 `flex: 1` 来填充可用空间
|
||
|
||
### PDF.js 资源本地化
|
||
|
||
**问题**:使用国外 CDN(jsdelivr)加载 PDF.js 的 cmaps 和 standard_fonts,国内访问慢或不稳定
|
||
|
||
**解决方案**:
|
||
1. 从 `node_modules/pdfjs-dist/` 复制资源到 `public/pdfjs/`
|
||
2. 修改代码使用本地路径
|
||
|
||
**操作步骤**:
|
||
```bash
|
||
# 创建目录
|
||
mkdir -p public/pdfjs/{cmaps,standard_fonts}
|
||
|
||
# 复制资源(从 node_modules)
|
||
cp -r node_modules/pdfjs-dist/cmaps/* public/pdfjs/cmaps/
|
||
cp -r node_modules/pdfjs-dist/standard_fonts/* public/pdfjs/standard_fonts/
|
||
```
|
||
|
||
**代码修改**(`course-detail.vue`):
|
||
```typescript
|
||
// ❌ 原来:使用国外 CDN
|
||
const CMAP_URL = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@3.11.174/cmaps/'
|
||
|
||
// ✅ 现在:使用本地资源
|
||
const CMAP_URL = '/pdfjs/cmaps/'
|
||
const STANDARD_FONT_DATA_URL = '/pdfjs/standard_fonts/'
|
||
```
|
||
|
||
**注意**:
|
||
- `public/` 目录下的文件会被 Vite 原样复制到 `dist/`,无需额外配置
|
||
- 已在 `package.json` 添加 `postinstall` 脚本,每次 `npm install` 后自动同步资源
|
||
|
||
**package.json 脚本**:
|
||
```json
|
||
"postinstall": "npm run sync:pdfjs",
|
||
"sync:pdfjs": "mkdir -p public/pdfjs/cmaps public/pdfjs/standard_fonts && cp -r node_modules/pdfjs-dist/cmaps/* public/pdfjs/cmaps/ && cp -r node_modules/pdfjs-dist/standard_fonts/* public/pdfjs/standard_fonts/"
|
||
```
|
||
|
||
### 课程对话 API 500 错误(2026-01-21)
|
||
|
||
**问题**:访问 `/api/v1/course/conversations` 返回 500 错误
|
||
|
||
**根因**:
|
||
1. API 层调用 `course_chat_service_v2.get_conversations()`
|
||
2. 但服务类 `CourseChatServiceV2` 中只有 `list_user_conversations()` 方法
|
||
3. 方法名不一致导致 `AttributeError`
|
||
|
||
**解决方案**:
|
||
|
||
1. **后端添加别名方法**(`course_chat_service.py`):
|
||
```python
|
||
async def get_conversations(
|
||
self,
|
||
user_id: int,
|
||
course_id: Optional[int] = None,
|
||
limit: int = 20
|
||
) -> List[Dict[str, Any]]:
|
||
"""别名方法,供 API 层调用"""
|
||
conversations = await self.list_user_conversations(user_id, limit)
|
||
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]]:
|
||
"""别名方法"""
|
||
return await self.get_conversation_messages(conversation_id, limit)
|
||
```
|
||
|
||
2. **前端使用统一 HTTP 封装**(`courseChat.ts`):
|
||
```typescript
|
||
// ❌ 错误:直接使用 fetch,未利用项目 http 封装
|
||
const response = await fetch(`${BASE_URL}/api/v1/course/conversations?limit=${limit}`)
|
||
const result = await response.json()
|
||
return result.data || []
|
||
|
||
// ✅ 正确:使用 http 封装,自动处理认证、错误、重试
|
||
import http from '@/utils/http'
|
||
const response = await http.get<{ conversations: Conversation[]; total: number }>(
|
||
'/api/v1/course/conversations',
|
||
{ params: { limit } }
|
||
)
|
||
return response.data?.conversations || []
|
||
```
|
||
|
||
**教训**:
|
||
- API 层调用的方法名必须与服务层保持一致
|
||
- 前端解析返回数据时要检查嵌套结构
|
||
- **普通 JSON 请求必须使用项目统一的 http 封装**(Axios),仅 SSE 流式请求可用原生 fetch
|
||
- 热重载后需确认容器已成功加载新代码(`docker logs` 检查)
|
||
|
||
---
|
||
|
||
## 2026-01-20 新增问题
|
||
|
||
### 注入知识点数据解决方案
|
||
|
||
当课程确实没有知识点时,需要为课程添加知识点才能使用陪练功能:
|
||
|
||
```sql
|
||
-- 1. 先添加课程资料
|
||
INSERT INTO course_materials (course_id, name, description, file_url, file_type, file_size, sort_order, is_deleted)
|
||
VALUES (课程ID, '培训资料名称', '描述', '/uploads/materials/xxx.pdf', 'pdf', 1024000, 1, 0);
|
||
|
||
-- 2. 获取资料ID
|
||
SET @mat_id = LAST_INSERT_ID();
|
||
|
||
-- 3. 添加知识点
|
||
INSERT INTO knowledge_points (course_id, material_id, name, description, type, source, is_deleted) VALUES
|
||
(课程ID, @mat_id, '知识点名称', '详细描述...', '理论知识', 1, 0);
|
||
```
|
||
|
||
**知识点type可选值**:理论知识、实践技能、沟通技巧
|
||
**source字段**:0=手动添加,1=AI生成
|
||
|
||
### 课程对话页面显示"未命名课程"(2026-01-21)
|
||
|
||
**问题**:学员端"与课程对话"页面标题显示"未命名课程",而不是实际课程名称
|
||
|
||
**根因**:
|
||
1. `getCourseDetail` API 返回的是 `{ code: 200, data: { name: "...", ... }, message: "..." }` 格式
|
||
2. `chat-course.vue` 中直接访问 `data.name`,实际应该访问 `data.data.name`(因为 http 封装返回的是整个响应对象)
|
||
|
||
**解决方案**:
|
||
```typescript
|
||
// ❌ 错误:直接访问返回值属性
|
||
const data = await getCourseDetail(courseId)
|
||
courseInfo.value.title = data.title || data.name || '未命名课程'
|
||
|
||
// ✅ 正确:先检查 code,再从 data 中取值
|
||
const res: any = await getCourseDetail(courseId)
|
||
if (res.code === 200 && res.data) {
|
||
courseInfo.value.title = res.data.title || res.data.name || '未命名课程'
|
||
}
|
||
```
|
||
|
||
**教训**:http.ts 响应拦截器返回的是 `{ code, data, message }` 结构,需要从 `res.data` 中提取实际数据
|
||
|
||
**涉及文件**:
|
||
- `src/views/trainee/chat-course.vue`
|