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