Files
ruoyi-vue-pro/docs/education/scalar-contract-from-source.md

226 lines
7.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 TokenJWT+ `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。源码级契约已在此文档冻结。