# 考培练系统规范与约定（团队基线）

> 最后更新：2026-01-21 | 所有开发必须遵循

---

## 核心规范速查

| 规范 | 核心原则 | 检查项 |
|------|---------|--------|
| 静态资源 | 使用相对路径，禁止硬编码域名 | 无localhost、无IP、无端口 |
| 页面动态数据 | 从API获取，禁止硬编码 | 无固定标题、名称等占位符 |
| API响应 | `res.code`和`res.data`，不要多套一层 | 无`res.data.code` |
| request.get | 参数必须包装为`{ params }` | 无直接传对象 |
| 多租户ID | 禁止硬编码默认值 | 无`id=1`默认值 |
| AI服务 | 通过AIService调用，传db_session | 无直接API调用 |
| **AI Key** | **从管理库加载，禁止硬编码** | **无sk-xxx字符串** |
| **AI配置** | **从 kaopeilian_admin.tenant_configs 读取** | **按租户隔离** |
| **默认模型** | **优先最强：Claude Opus 4.5** | **非gemini-flash** |
| 时区 | 统一Asia/Shanghai | 容器TZ环境变量 |

---

## 数据库规范

### 用户姓名字段
- `full_name` = 人名（张三、李四）
- ❌ 不要存职位名称（资深美容顾问）

### 模拟数据
- 用户：轻医美行业常见中文姓名
- 学员示例：李美琳、王芳、陈静

---

## 前端规范

### 静态资源访问
```typescript
// ✅ 正确：相对路径
const url = '/static/uploads/courses/1/file.pdf'

// ❌ 错误：硬编码
const url = `http://localhost:8000${path}`
```

### API响应访问
```typescript
const res = await getList()
// ✅ 正确
if (res.code === 200) { data.value = res.data }
// ❌ 错误
if (res.data.code === 200) { data.value = res.data.data }
```

### request.get参数
```typescript
// ✅ 正确
request.get(url, { params: { id: 1 } })
// ❌ 错误
request.get(url, { id: 1 })
```

### 页面动态数据获取
```typescript
// ✅ 正确：从API获取实际数据
const courseInfo = ref({ title: '加载中...', id: route.query.courseId })
onMounted(async () => {
  const data = await getCourseDetail(courseInfo.value.id)
  courseInfo.value.title = data.title || data.name
})

// ❌ 错误：硬编码占位符
const courseInfo = ref({ title: '销售技巧基础训练', id: '1' })
```

### API调用前置检查
```typescript
// 调用受限API前检查条件
if (userInfo.role !== 'trainee' || !userInfo.phone) {
  ElMessage.warning('请先绑定手机号')
  return
}
```

### HTTP 客户端选择（2026-01-21 新增）
```typescript
// ✅ 正确：普通 JSON 请求使用统一的 http 封装
import http from '@/utils/http'

const response = await http.get<{ data: Course[] }>('/api/v1/courses')
return response.data

// ✅ 正确：SSE 流式请求必须使用原生 fetch（Axios 不支持 ReadableStream）
const response = await fetch('/api/v1/course/chat', {
  method: 'POST',
  headers: { 'Authorization': `Bearer ${token}` },
  body: JSON.stringify(params)
})
return response.body  // ReadableStream

// ❌ 错误：普通请求也用 fetch，无法利用统一的认证、错误处理、重试机制
const response = await fetch('/api/v1/course/conversations')
```

**http 封装优势**：
- 自动注入 `Authorization: Bearer {token}`
- 401 自动刷新 Token 并重试
- 统一错误处理和用户提示
- 请求日志和重试机制

### CSS高度填充（iframe/预览容器）
```scss
// ❌ 错误：height:100% 依赖父元素有明确高度，在 flex 布局中常失效
.preview-content {
  height: 100%;
  .html-iframe {
    height: 100%;  // 父元素无明确高度时计算为0
  }
}

// ✅ 正确：使用 flex:1 填充可用空间
.preview-content {
  display: flex;
  flex-direction: column;
  min-height: 500px;
  
  .html-viewer {
    flex: 1;
    display: flex;
    flex-direction: column;
    
    .html-iframe {
      flex: 1;
      min-height: 600px;  // 保底最小高度
    }
  }
}
```

---

## 后端规范

### 多租户ID默认值
```python
# ❌ 错误：硬编码
position_id = 1

# ✅ 正确：动态查询
result = await db.execute(select(PositionCourse.position_id).where(...))
position_id = result.scalar_one_or_none()
if not position_id:
    raise HTTPException(400, "未找到关联岗位")
```

### 业务异常处理
```python
try:
    result = await service.action()
except ExternalServiceError as e:
    raise HTTPException(400, str(e))  # 业务错误→400
except Exception as e:
    raise HTTPException(500, str(e))  # 系统错误→500
```

### FastAPI路由顺序
```python
# ✅ 正确：具体路由在前
@router.get("/mistakes")      # 先定义
@router.get("/{exam_id}")     # 后定义
```

### Pydantic空字符串处理
```python
@field_validator("category", mode="before")
def normalize(cls, v):
    if isinstance(v, str) and not v.strip():
        return CourseCategory.GENERAL  # 空字符串→默认值
    return v
```

---

## AI服务规范

### 统一调用方式
```python
# ✅ 正确：通过AIService，传db_session
ai_service = AIService(module_code="answer_judge", db_session=db)
response = await ai_service.chat(messages=[...], prompt_name="answer_judge")

# ❌ 错误：直接调用API、不传db_session
```

### 提示词文件位置
`app/services/ai/prompts/{功能名}_prompts.py`

### AI 服务实现
- 所有 AI 功能使用 Python 原生实现
- 服务商策略：4sapi.com 首选 → OpenRouter 备选（自动降级）
- 无外部 AI 平台依赖，100% 可控

### AI 配置加载规范（强制！）

**配置存储位置**：管理库 `kaopeilian_admin.tenant_configs` 表

**配置加载优先级**：
1. 管理库 `tenant_configs` 表（按 TENANT_CODE 查询）
2. 环境变量（fallback）
3. 代码默认值（仅用于开发）

**容器必须的环境变量**：
```env
# .env.{tenant} 文件必须包含
TENANT_CODE=ex

# 管理库连接配置
ADMIN_DB_HOST=prod-mysql
ADMIN_DB_PORT=3306
ADMIN_DB_USER=root
ADMIN_DB_PASSWORD=ProdMySQL2025!@#
ADMIN_DB_NAME=kaopeilian_admin
```

**数据库配置表结构**：
```sql
-- kaopeilian_admin.tenant_configs
SELECT config_key, config_value 
FROM tenant_configs 
WHERE tenant_id = (SELECT id FROM tenants WHERE code = 'ex')
  AND config_group = 'ai';
```

### API Key 管理规范（强制）

```python
# ❌ 禁止：代码中硬编码 API Key
primary_api_key = "sk-V9Qfx..."

# ❌ 禁止：查询租户数据库的本地表
SELECT * FROM ai_config  -- 错误！应查管理库

# ✅ 正确：从管理库加载，fallback 到环境变量
primary_api_key = await load_from_admin_db("AI_PRIMARY_API_KEY")
if not primary_api_key:
    primary_api_key = os.getenv("AI_PRIMARY_API_KEY", "")
```

**敏感配置管理**：
- 敏感配置统一存储在管理库 `tenant_configs` 表
- `.env` 文件仅存储数据库连接信息，权限设置为 600
- 更新配置后重启容器：`docker compose up -d --force-recreate`

### 默认模型规范

```python
# ✅ 正确：遵循"优先最强"原则
DEFAULT_MODEL = "claude-opus-4-5-20251101-thinking"  # 默认使用最强模型

# ❌ 错误：使用保底模型作为默认值
DEFAULT_MODEL = "gemini-3-flash-preview"  # 这是最弱的保底模型
```

**模型常量命名**：
```python
MODEL_PRIMARY = "claude-opus-4-5-20251101-thinking"  # 🥇 首选
MODEL_STANDARD = "gemini-3-pro-preview"              # 🥈 标准
MODEL_FAST = "gemini-3-flash-preview"                # 🥉 快速/保底
```

---

## Nginx配置

### SPA缓存策略
```nginx
location / {
    try_files $uri $uri/ /index.html;
    # HTML不缓存
    add_header Cache-Control "no-cache, no-store, must-revalidate" always;
}

location /assets/ {
    # 静态资源长期缓存（带hash）
    expires 1y;
    add_header Cache-Control "public, immutable";
}
```

### 静态文件代理
```nginx
location /static/uploads/ {
    proxy_pass http://kaopeilian-backend-dev:8000;
}
```

---

## 检查清单

### 新功能开发
- [ ] 静态资源使用相对路径
- [ ] 页面动态数据从API获取，无硬编码占位符
- [ ] API响应正确访问`res.code`/`res.data`
- [ ] request.get参数包装为`{ params }`
- [ ] AI调用通过AIService并传db_session
- [ ] 无硬编码ID默认值

### 多租户排查
- [ ] 确认租户数据库：`docker inspect <租户>-backend | grep DATABASE`
- [ ] 检查数据是否在正确的库中
- [ ] 确认ID在该租户数据库存在

### 部署后验证
- [ ] 清除浏览器缓存测试
- [ ] 检查JS文件hash是否匹配
- [ ] 检查静态资源能否访问
