# 考培练系统联调经验汇总 > 系统账号: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`