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

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

15 KiB
Raw Permalink Blame History

考培练系统联调经验汇总

系统账号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 未配置"

根因

  1. AIService._load_config_from_db() 方法查询的是租户数据库的 ai_config
  2. 实际 AI 配置存储在管理库kaopeilian_admintenant_configs
  3. 配置加载路径错误导致无法获取 API Key

解决方案

  1. 修改 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'
    
  2. 更新所有租户的 .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. 重启后端容器:使新环境变量生效

    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.pydify_practice_service.pyapp/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=1ex租户岗位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))  # 系统错误

团队基线补充

  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 表说明

数据库配置表

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"

模型常量命名规范

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%

关键修改

// 父容器使用 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. 修改代码使用本地路径

操作步骤

# 创建目录
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 错误

根因

  1. API 层调用 course_chat_service_v2.get_conversations()
  2. 但服务类 CourseChatServiceV2 中只有 list_user_conversations() 方法
  3. 方法名不一致导致 AttributeError

解决方案

  1. 后端添加别名方法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)
  1. 前端使用统一 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

问题:学员端"与课程对话"页面标题显示"未命名课程",而不是实际课程名称

根因

  1. getCourseDetail API 返回的是 { code: 200, data: { name: "...", ... }, message: "..." } 格式
  2. 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