feat(education): complete Flyway migration and atomic submit

This commit is contained in:
2026-07-30 12:06:55 +08:00
parent ce02f8acb4
commit 79a5799502
228 changed files with 32551 additions and 1376 deletions

View File

@@ -0,0 +1,225 @@
# 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。源码级契约已在此文档冻结。