- 从服务器拉取完整代码 - 按框架规范整理项目结构 - 配置 Drone CI 测试环境部署 - 包含后端(FastAPI)、前端(Vue3)、管理端 技术栈: Vue3 + TypeScript + FastAPI + MySQL
15 KiB
考培练系统联调经验汇总
系统账号: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 |
多租户排查必读
# 第一步:确认租户数据库
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 未配置"
根因:
AIService._load_config_from_db()方法查询的是租户数据库的ai_config表- 实际 AI 配置存储在管理库(kaopeilian_admin)的
tenant_configs表中 - 配置加载路径错误导致无法获取 API Key
解决方案:
-
修改
ai_service.py:将_load_config_from_db()改为_load_config_from_admin_db(),直接连接管理库查询: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' -
更新所有租户的 .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 -
重启后端容器:使新环境变量生效
cd /data/prod-envs && docker compose -f docker-compose.prod-multi.yml up -d ex-backend --force-recreate
配置加载优先级(最终版):
- 管理库
tenant_configs表(按 tenant_code 查询) - 环境变量(fallback)
- 代码默认值
涉及文件:
app/services/ai/ai_service.py/data/prod-envs/kaopeilian-backend/.env.{tenant}
团队基线补充:多租户 AI 配置必须从管理库(kaopeilian_admin.tenant_configs)加载,禁止依赖租户数据库的本地表
彻底脱离 Dify(2026-01-21)
- 目标:完全移除系统对 Dify 平台的依赖
- 方案:
- 删除所有 Dify 相关服务文件(
dify_gateway.py、dify_practice_service.py、app/services/ai/dify/目录) - 清理所有
.env文件中的DIFY_*配置项 - 删除
config.py中的 Dify 配置 - 更新所有 API 端点,移除
engine参数(不再支持 v1/v2 切换) - 更新文档,移除所有 Dify 相关描述
- 删除所有 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响应访问
const res = await getList()
// ✅ 正确
if (res.code === 200) { list.value = res.data }
// ❌ 错误
if (res.data.code === 200) { list.value = res.data.data }
正确的request.get调用
// ✅ 正确
request.get(url, { params: { id: 1 } })
// ❌ 错误
request.get(url, { id: 1 })
业务异常处理
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)) # 系统错误
团队基线补充
- 多租户禁止硬编码ID - 从关联表动态查询
- 页面动态数据禁止硬编码 - 标题、名称等从API获取
- 前端API调用前置检查 - 角色、权限、必填字段
- 数据库架构变更后检查代码 - 搜索使用该表的所有服务
- FastAPI路由顺序 - 具体路由在动态路由之前
- SPA必须禁用HTML缓存 -
Cache-Control: no-cache
2026-01-21 新增问题
AI Key 管理规范审查(重要)
问题:代码中硬编码 API Key,违反安全规范
违反的规范:
- 《瑞小美AI接入规范.md》:禁止在代码中硬编码 API Key
- 《技术栈标准》:密码、密钥等敏感信息禁止硬编码到代码或镜像中
完整修复方案:
- 新建数据库表
ai_config存储 AI 配置 - 修改
ai_service.py优先从数据库读取配置,fallback 到环境变量 - 移除代码中的硬编码 Key,使用空字符串作为默认值
- 更新数据库架构文档,添加 ai_config 表说明
数据库配置表:
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):
- 数据库 ai_config 表(推荐)
- 环境变量(fallback)
默认模型不符合"优先最强"原则:
- ❌ 错误:
default_model = "gemini-3-flash-preview" - ✅ 正确:
default_model = "claude-opus-4-5-20251101-thinking"
模型常量命名规范:
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 无法正确计算高度
解决方案:
- 给
.content-main添加display: flex; flex-direction: column;和min-height: calc(100vh - 280px) - 给
.preview-container添加flex: 1 - 给
.preview-content添加display: flex; flex-direction: column; - 所有预览容器(
.pdf-viewer-container、.html-viewer、.video-viewer、.markdown-viewer、.text-viewer)改用flex: 1替代height: 100%
关键修改:
// 父容器使用 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,国内访问慢或不稳定
解决方案:
- 从
node_modules/pdfjs-dist/复制资源到public/pdfjs/ - 修改代码使用本地路径
操作步骤:
# 创建目录
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):
// ❌ 原来:使用国外 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 脚本:
"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 错误
根因:
- API 层调用
course_chat_service_v2.get_conversations() - 但服务类
CourseChatServiceV2中只有list_user_conversations()方法 - 方法名不一致导致
AttributeError
解决方案:
- 后端添加别名方法(
course_chat_service.py):
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)
- 前端使用统一 HTTP 封装(
courseChat.ts):
// ❌ 错误:直接使用 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 新增问题
注入知识点数据解决方案
当课程确实没有知识点时,需要为课程添加知识点才能使用陪练功能:
-- 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)
问题:学员端"与课程对话"页面标题显示"未命名课程",而不是实际课程名称
根因:
getCourseDetailAPI 返回的是{ code: 200, data: { name: "...", ... }, message: "..." }格式chat-course.vue中直接访问data.name,实际应该访问data.data.name(因为 http 封装返回的是整个响应对象)
解决方案:
// ❌ 错误:直接访问返回值属性
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