Files
012-kaopeilian/docs/规划/全链路联调/联调经验汇总.md
111 998211c483 feat: 初始化考培练系统项目
- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
2026-01-24 19:33:28 +08:00

432 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 考培练系统联调经验汇总
> 系统账号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`)加载,禁止依赖租户数据库的本地表
---
### 彻底脱离 Dify2026-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 资源本地化
**问题**:使用国外 CDNjsdelivr加载 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`