226 lines
7.9 KiB
Markdown
226 lines
7.9 KiB
Markdown
# Scalar (tiku-backend) 接口契约 —— 从源码提取
|
||
|
||
日期:2026-07-28
|
||
来源:`/Users/tiku1/code/tiku-backend` 仓库 NestJS Controller、DTO 和装饰器
|
||
状态:源码级契约冻结,真实 endpoint 验证需启动完整运行时(Supabase + API server)
|
||
|
||
## 运行时信息
|
||
|
||
- 框架:NestJS + Fastify
|
||
- OpenAPI 路径:`/openapi.json`(仅非生产环境)
|
||
- Scalar 文档:`/docs`(仅非生产环境)
|
||
- 认证:Bearer Token(JWT)+ `x-tenant-id` header
|
||
- 响应 envelope:`{ items/item, meta: { requestId } }`
|
||
|
||
## RuoYi Education 实际调用的路径
|
||
|
||
以下为 RuoYi `ScalarCatalogProvider` 使用的只读 GET 路径:
|
||
|
||
### 1. GET /api/catalog/regions
|
||
|
||
- **安全方案**: `x-tenant-id` header (`@ApiSecurity('tenant-id')`, `@TenantAccess()`)
|
||
- **查询参数**: `regionId?` (UUID, 可选)
|
||
- **响应**: `{ items: CatalogRegionResponseDto[], meta: { requestId } }`
|
||
- **CatalogRegionResponseDto 字段**:
|
||
- `id` (uuid, 必填)
|
||
- `legacyId` (string, nullable)
|
||
- `name` (string, 必填)
|
||
- `code` (string, nullable)
|
||
- `shortName` (string, nullable)
|
||
- `fullName` (string, nullable)
|
||
- `icon` (string, nullable)
|
||
- `pinyin` (string, nullable)
|
||
- `isHot` (boolean, 必填)
|
||
- `isActive` (boolean, 必填)
|
||
- `order` (number, 必填)
|
||
|
||
### 2. GET /api/catalog/region-modules
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**: `regionId?` (UUID)
|
||
- **响应**: `{ items: CatalogRegionModuleResponseDto[], meta }`
|
||
- **字段**: id, legacyId, regionId, name, type, icon, color, textColor, description, route, isPrimarySchoolModule, isActive, order
|
||
|
||
### 3. GET /api/catalog/module-nodes
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**:
|
||
- `regionId?` (UUID)
|
||
- `moduleId?` (UUID)
|
||
- `parentId?` (string, "root" 表示根节点)
|
||
- **响应**: `{ items: CatalogEntityDto[], meta }` — 通用实体列表
|
||
|
||
### 4. GET /api/catalog/schools
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**: `regionId?`, `schoolId?`
|
||
- **响应**: `{ items: CatalogSchoolResponseDto[], meta }`
|
||
- **字段**: id, legacyId, regionId, moduleId, name, professionalExamDate, metadata, createdAt, updatedAt
|
||
|
||
### 5. GET /api/catalog/majors
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**: `regionId?`, `schoolId?`, `majorId?`, `moduleId?`, `type?`
|
||
- **响应**: `{ items: CatalogMajorResponseDto[], meta }`
|
||
|
||
### 6. GET /api/catalog/subjects
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**: `regionId?`, `schoolId?`, `majorId?` (UUID), `moduleId?` (UUID), `type?` (string)
|
||
- **响应**: `{ items: CatalogEntityDto[], meta }`
|
||
|
||
### 7. GET /api/catalog/categories
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**: `subjectId?` (UUID), `nodeId?` (UUID, 旧导航节点)
|
||
- **响应**: `{ items: CatalogEntityDto[], meta }`
|
||
|
||
### 8. GET /api/catalog/questions
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**:
|
||
- `subjectId?` (UUID)
|
||
- `categoryId?` (UUID)
|
||
- `nodeId?` (UUID, 旧导航节点)
|
||
- `entryId?` (UUID)
|
||
- `contentNodeId?` (UUID)
|
||
- `collectionId?` (UUID)
|
||
- `questionIds?` (string | string[], 逗号分隔或重复传参)
|
||
- `limit?` (int, 1-2000)
|
||
- **响应**: `{ items: QuestionResponseDto[], meta }`
|
||
- **QuestionResponseDto 字段** (extends CatalogEntityDto):
|
||
- `id` (uuid)
|
||
- `type` (string, 必填 — 题型)
|
||
- `typeLabel` (string, nullable)
|
||
- `difficulty` (number, nullable)
|
||
- `content` (unknown, 题干)
|
||
- `options` (array, 选项列表)
|
||
- `explanation` (string, nullable — **敏感字段**)
|
||
- `hasVideoExplanation` (boolean)
|
||
- 继承字段: legacyId, name, title, regionId, order, isActive, description, metadata
|
||
- **注意**: `options` 中包含正确选项标记、`explanation` 包含答案解析。RuoYi 在返回学生端 DTO 前必须剥离这些字段。
|
||
|
||
### 9. GET /api/catalog/content-entries
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**:
|
||
- `regionId?` (UUID)
|
||
- `entryType?` (string)
|
||
- `includeHidden?` (boolean, default false)
|
||
- **响应**: `{ items: ContentEntryResponseDto[], meta }`
|
||
|
||
### 10. GET /api/catalog/content-nodes
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**:
|
||
- `entryId` (UUID, **必填**)
|
||
- `parentId?` (string, "root" 表示根节点)
|
||
- `mode?` ('children' | 'flat', default 'children')
|
||
- `includeInactive?` (boolean, default false)
|
||
- `markerType?` (string)
|
||
- **响应**: `{ items: ContentNodeResponseDto[], meta }`
|
||
|
||
### 11. GET /api/catalog/question-collections
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**:
|
||
- `regionId?` (UUID)
|
||
- `entryId?` (UUID)
|
||
- `nodeId?` (UUID)
|
||
- `collectionType?` (string)
|
||
- `limit?` (int, 1-2000)
|
||
- **响应**: `{ items: QuestionCollectionResponseDto[], meta }`
|
||
|
||
### 12. GET /api/catalog/question-collections/questions
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**:
|
||
- `collectionId` (UUID, **必填**)
|
||
- `limit?` (int, 1-2000)
|
||
- **响应**: `{ items: QuestionResponseDto[], meta }`
|
||
|
||
### 13. GET /api/catalog/practice-blueprints
|
||
|
||
- **安全方案**: `x-tenant-id`
|
||
- **查询参数**:
|
||
- `entryId?` (UUID)
|
||
- `nodeId?` (UUID)
|
||
- `collectionId?` (UUID)
|
||
- `mode?` (string)
|
||
- `limit?` (int, 1-2000)
|
||
- **响应**: `{ items: PracticeBlueprintResponseDto[], meta }`
|
||
- **字段**: id, mode, entryId, nodeId, collectionId, questionLimit, durationMinutes + CatalogEntityDto 继承字段
|
||
|
||
## 认证机制
|
||
|
||
### 租户识别
|
||
- 所有 catalog 路径使用 `@TenantAccess()` 装饰器 → `AccessPolicy { kind: 'tenant' }`
|
||
- 租户 ID 从 `x-tenant-id` 请求头提取(CORS 白名单包含此头)
|
||
- `Principal` 装饰器从请求上下文提取 `principal.tenant.tenantId`
|
||
|
||
### Bearer Token
|
||
- catalog 的大多数端点不需要 Bearer(只读、租户级访问)
|
||
- `assets`、`assets/download`、`assets/preview` 需要 `@ApiBearerAuth()`
|
||
- 学习写入路径 (`/api/learning/*`) 需要 `@ApiBearerAuth()` + `@TenantUserAccess()`
|
||
|
||
## 响应格式
|
||
|
||
### 成功
|
||
```json
|
||
{
|
||
"items": [...],
|
||
"meta": { "requestId": "uuid" }
|
||
}
|
||
```
|
||
或
|
||
```json
|
||
{
|
||
"item": {...},
|
||
"meta": { "requestId": "uuid" }
|
||
}
|
||
```
|
||
|
||
### 错误
|
||
```json
|
||
{
|
||
"error": "面向调用方的错误信息",
|
||
"code": "REQUIRED_FIELD",
|
||
"requestId": "uuid",
|
||
"meta": { "requestId": "uuid" }
|
||
}
|
||
```
|
||
|
||
## 与 RuoYi adapter 的差异
|
||
|
||
| 项目 | RuoYi (Java) 假设 | Scalar (tiku-backend) 实际 |
|
||
|------|-------------------|---------------------------|
|
||
| 基础路径 | 配置的 `base-url` | `/api/catalog/*` |
|
||
| 认证头 | `Authorization: Bearer <token>` | 大多数 catalog 端点只需 `x-tenant-id`,不需要 Bearer |
|
||
| 租户头 | `x-tenant-id` | `x-tenant-id` ✅ 一致 |
|
||
| 分页 | `page` + `pageSize` | `limit` (1-2000),无 page 参数! |
|
||
| 题目过滤 | `published=true&hidden=false` 由 RuoYi 追加 | Scalar 端已有 `isActive` 过滤,但无 `published`/`hidden` query 参数 |
|
||
| 响应 envelope | 预期 `items` + 可能的 `total` | `items` + `meta.requestId`,无 `total` 字段! |
|
||
| 正确答案 | RuoYi 在返回学生端前剥离 | `QuestionResponseDto.options` 包含正确选项标记 |
|
||
|
||
## ⚠️ 关键差异
|
||
|
||
1. **分页**: RuoYi `ScalarCatalogProvider` 使用 `page` + `pageSize` query 参数,但 Scalar 只接受 `limit`。Java 端第 526-540 行固定追加 `page` 和 `pageSize` —— 这些参数在 Scalar controller 中不存在,会被忽略。
|
||
2. **total 字段**: RuoYi adapter 期望服务端返回 `total` 用于分页,但 Scalar 响应没有此字段。如果 RuoYi 依赖 `total` 做前端分页计算,需要确认 adapter 如何处理。
|
||
3. **published/hidden**: RuoYi 端固定追加 `published=true&hidden=false`,但这些参数在 Scalar controller DTO 中未定义。Scalar 的过滤逻辑在 repository 层而非 query 参数层。
|
||
|
||
## OpenAPI 生成方式
|
||
|
||
```bash
|
||
# 需要 Docker + Supabase 运行
|
||
cd tiku-backend
|
||
npm run supabase:start
|
||
npm run dev:api
|
||
curl http://127.0.0.1:8787/openapi.json > /tmp/tiku-openapi.json
|
||
|
||
# 或通过测试套件(也会启动真实服务器)
|
||
BACKEND_TEST_SKIP_DATABASE=true npm run test:backend:migration
|
||
# 生成文件:/tmp/tiku-openapi.json
|
||
```
|
||
|
||
当前环境不具备 Supabase/Docker,无法生成运行时 OpenAPI JSON。源码级契约已在此文档冻结。
|