# 考培练系统联调经验汇总

> 系统账号：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`
