# 内容导入契约

更新时间：2026-06-29

## 结论

内容导入的最终规范化、校验、租户隔离、幂等和审计必须由后端负责。

前端只负责：

- 上传或粘贴 JSON/Excel/CSV。
- 做轻量格式预检查，减少明显错误。
- 展示后端 preview 返回的 `job/items/issues`。
- 让运营人员修正数据后再确认导入。

迁移脚本只负责：

- 从 PocketBase 导出或旧 JSON 中抽取数据。
- 转换成新架构推荐格式。
- 调用后端 preview/import API。

不建议迁移脚本直接绕过后端写业务表，除非是一次性内控迁移，并且必须额外跑导入后校验。

## 已实现导入 API

```text
POST /api/tenant-content/imports/preview/questions
POST /api/tenant-content/imports/questions

POST /api/tenant-content/imports/preview/vocabulary
POST /api/tenant-content/imports/vocabulary

POST /api/tenant-content/imports/preview/handbook
POST /api/tenant-content/imports/handbook

POST /api/tenant-content/imports/preview/scoreline
POST /api/tenant-content/imports/scoreline

POST /api/tenant-content/imports/preview/videos
POST /api/tenant-content/imports/videos

GET  /api/tenant-content/imports
GET  /api/tenant-content/imports/issues
GET  /api/tenant-content/imports/field-mapping
GET  /api/tenant-content/imports/templates
POST /api/tenant-content/imports/post-check
GET  /api/tenant-content/imports/post-check
```

所有导入都会写入：

- `content_import_jobs`
- `content_import_items`
- `content_import_issues`
- `audit_logs`

## 格式支持

当前题目、单词、知识手册、分数线和视频导入均支持：

- `sourceFormat=json`：直接提交规范 JSON 或兼容旧模板。
- `sourceFormat=csv`：提交 `csvText`、`fileContent`、`payload` 或 `fileBase64`，后端按表头归一化。
- `sourceFormat=excel`：提交 `.xlsx` 的 `fileBase64`，可选 `sheetName` 或 `sheetIndex`；多 Sheet 会被解析进同一个 `content_import_jobs`。
- 同步导入默认在 API 请求内完成；大批量导入可在确认导入时传 `executionMode=async`，API 会把 job 置为 `pending`，由 imports worker 后台消费。

表格导入安全边界：

- `.xlsx` 解析统一使用 `read-excel-file`，生产运行时不要重新引入 `exceljs`；如需更换解析器，必须先跑 `npm run audit:runtime` 和完整导入回归。
- 单文件最大 8MB。
- 单次最多解析 5000 行、160 列。
- 单个单元格最多保留 100000 字符。
- 后端只保存解析后的原始行和规范化 payload，不把 `fileBase64` 长期写入数据库。
- 任务列表会返回 `sourceFormat`、`executionMode` 和 `parserMetadata`，前端可展示解析器、Sheet、行数等信息。

异步导入调用方式：

```json
{
  "previewJobId": "uuid",
  "executionMode": "async",
  "allowPartial": false
}
```

异步导入状态流：

```text
preview -> pending -> importing -> completed
preview -> pending -> importing -> completed_with_errors
preview -> pending -> failed
```

worker 命令：

```bash
npm --workspace @tiku-saas/worker run imports:once
```

前端提交异步导入后不要重复同步执行同一 job；只需要轮询 `GET /api/tenant-content/imports` 并用 `GET /api/tenant-content/imports/issues` 展示问题行。worker 会按 `attempt_count/max_attempts` 记录重试，失败时写入 `errorMessage` 和审计日志。

## 模板、字段映射和导入后复检

租户后台前端不要把导入字段写死在页面里。导入页初始化时先读取字段映射，下载模板时调用模板接口：

```text
GET /api/tenant-content/imports/field-mapping?importType=questions
GET /api/tenant-content/imports/templates?importType=questions&format=csv
```

`importType` 支持：

```text
questions | vocabulary | handbook | scoreline | videos
```

`templates` 返回：

```json
{
  "item": {
    "importType": "questions",
    "format": "csv",
    "fileName": "questions-import-template.csv",
    "mimeType": "text/csv; charset=utf-8",
    "contentBase64": "...",
    "fields": []
  }
}
```

前端可用 `contentBase64` 生成下载文件，或用 `contentPreview` 做在线预览。`fields` 包含规范字段、中文别名、是否必填和示例，适合做字段映射 UI。

CSV/Excel 支持本次导入字段别名覆盖，字段名必须是后端支持的规范字段。后端会按导入类型做目标字段白名单校验，并拒绝 `__proto__`、`constructor`、`prototype` 等危险对象键；前端不能把字段映射当成绕过后端 schema 的扩展机制。JSON 导入应直接提交规范字段，通常不需要 `fieldMappingOverrides`。

字段别名覆盖示例：

```json
{
  "sourceFormat": "csv",
  "sourceName": "questions-custom-headers.csv",
  "csvText": "旧编号,自定义题干,左选项,右选项,正确项\nq1,题干,A,B,B",
  "fieldMappingOverrides": {
    "legacyId": ["旧编号"],
    "content": ["自定义题干"],
    "optionA": ["左选项"],
    "optionB": ["右选项"],
    "answer": ["正确项"]
  }
}
```

导入完成后，租户后台应主动触发复检：

```json
POST /api/tenant-content/imports/post-check
{
  "jobId": "uuid"
}
```

复检会确认：

- job 行数、有效行、已处理行是否一致。
- 处理成功的 item 是否都有 `target_id`。
- 题目是否存在当前版本，且已绑定目标集合。
- 单词单元是否存在且有单词。
- 知识手册是否存在章节/条目。
- 分数线字段、院校、专业、记录是否都落到对应表。
- 视频是否存在题目绑定。

复检结果会写入 `content_import_jobs.summary.importPostCheck`，也可以通过：

```text
GET /api/tenant-content/imports/post-check?jobId=<jobId>
```

读取。异步导入未完成时触发复检会返回 `IMPORT_JOB_NOT_COMPLETED`。

推荐 CSV/Excel 表头：

| 类型 | 常用表头 |
| --- | --- |
| 题目 | `legacyId`、`题型/type`、`题干/content`、`选项A`-`选项H`、`答案`、`解析`、`难度`、`标签` |
| 单词 | `unitLegacyId`、`unitName`、`wordLegacyId`、`word`、`phonetic`、`meaning`、`example`、`difficulty`、`tags` |
| 知识手册 | `subjectLegacyId`、`subjectName`、`chapterLegacyId`、`chapterName`、`sectionName`、`entryLegacyId`、`title`、`content`、`summary`、`tags` |
| 分数线 | 多 Sheet 推荐 `fields`、`schools`、`majors`、`records`；记录 Sheet 可包含任意动态字段列 |
| 视频 | `legacyId`、`title`、`videoUrl`、`thumbnailUrl`、`durationSeconds`、`subjectId`、`questionId` 或 `legacyQuestionId`、`accessMode` |

## 单词导入

推荐新格式：

```json
{
  "regionId": "uuid",
  "entryId": "uuid",
  "contentNodeId": "uuid",
  "units": [
    {
      "legacyId": "unit-1",
      "name": "Unit 1 - 高频核心词",
      "description": "专升本考试高频词汇",
      "order": 1,
      "words": [
        {
          "legacyId": "word-abandon",
          "word": "abandon",
          "phonetic": "/əˈbændən/",
          "meaning": "v. 放弃，抛弃",
          "example": "He had to abandon his car in the snow.",
          "exampleTranslation": "他不得不把车丢弃在雪地里。",
          "difficulty": 3,
          "tags": ["高频词"],
          "order": 1
        }
      ]
    }
  ]
}
```

兼容旧模板：

- `vocabulary_units_示例数据`
- `vocabulary_示例数据`

后端会把旧模板归一化为 `vocabulary_units/vocabulary_words`，并可自动挂到 `content_entries/content_nodes`。

## 知识手册导入

推荐新格式：

```json
{
  "regionId": "uuid",
  "entryId": "uuid",
  "contentNodeId": "uuid",
  "subjects": [
    {
      "legacyId": "handbook-chinese",
      "name": "大学语文",
      "type": "guide",
      "chapters": [
        {
          "legacyId": "chapter-outline",
          "name": "一、语文考纲",
          "sections": [
            {
              "legacyId": "section-outline",
              "name": "考纲解读",
              "entries": [
                {
                  "legacyId": "entry-outline",
                  "title": "2024年天津专升本语文考试大纲",
                  "summary": "全面解读语文考试要求",
                  "content": "Markdown 内容",
                  "tags": ["考纲"]
                }
              ]
            }
          ]
        }
      ]
    }
  ]
}
```

映射规则：

- 手册入口：`content_entries.entry_type = handbook`
- 书籍/科目：`handbook_subjects`，并生成或绑定一个 `content_nodes`
- 章节：`handbook_chapters`，并生成章节节点
- 小节：默认进入 `content_nodes`，作为知识点的目录节点
- 知识点：`handbook_entries`，通过 `content_node_id` 归属到小节或章节

## 题目导入

题目继续兼容旧题库 JSON 数组格式，并支持 Markdown、KaTeX、图片、表格、阅读理解子题等字段。导入时可以传：

- `subjectId`
- `categoryId`
- `entryId`
- `contentNodeId`
- `collectionId`

这样题目会同时落到旧兼容表和新内容导航/题目集合。

## 分数线导入

分数线导入支持字段、院校、专业、年份记录一起提交，适合把旧题库地区分数线 JSON 转成新结构后统一 preview/import。

推荐格式：

```json
{
  "regionId": "uuid",
  "sourceName": "scoreline-tianjin-2025.json",
  "fields": [
    {
      "legacyId": "tj-min-score",
      "fieldKey": "minScore",
      "fieldName": "最低分",
      "fieldType": "number",
      "unit": "分",
      "isFilter": true,
      "isTrend": true,
      "order": 1
    }
  ],
  "schools": [
    {
      "legacyId": "school-a",
      "name": "天津测试学院",
      "shortName": "测试学院",
      "schoolType": "public",
      "isHot": true
    }
  ],
  "majors": [
    {
      "legacyId": "major-a",
      "schoolLegacyId": "school-a",
      "name": "软件工程"
    }
  ],
  "records": [
    {
      "legacyId": "record-a-2025",
      "schoolLegacyId": "school-a",
      "majorLegacyId": "major-a",
      "year": 2025,
      "fieldValues": {
        "minScore": 188,
        "planCount": 60
      }
    }
  ]
}
```

校验规则：

- `kind` 可为 `field/school/major/record`；使用 `fields/schools/majors/records` 分桶时后端会自动补。
- `record` 必须能通过 `schoolId`、`schoolLegacyId` 或 `schoolName` 定位院校。
- `major` 必须能通过 `schoolId`、`schoolLegacyId` 或 `schoolName` 定位院校。
- `fieldValues` 保存动态字段值，前端筛选和趋势图应先读取 `/api/scoreline/fields`。
- 相同 `legacyId` 再导入会幂等更新；内容 hash 未变化时计入 `skipped`。

## 视频导入

视频导入支持视频基础信息和题目绑定一起提交。付费视频不要在列表页暴露可播放 URL，播放仍走 `POST /api/videos/play` 做权益和签名校验。

推荐格式：

```json
{
  "sourceName": "question-videos.json",
  "videos": [
    {
      "legacyId": "video-001",
      "title": "函数极限精讲",
      "description": "题目解析视频",
      "videoUrl": "https://example.com/private/video-001.mp4",
      "thumbnailUrl": "https://example.com/thumb/video-001.jpg",
      "durationSeconds": 120,
      "knowledgeTags": ["高数", "极限"],
      "subjectId": "uuid",
      "accessMode": "svip",
      "freePreviewSeconds": 15,
      "bindings": [
        {
          "legacyId": "video-001-question-001",
          "questionId": "uuid",
          "videoType": "specific",
          "order": 1
        }
      ]
    }
  ]
}
```

校验规则：

- `title` 必填。
- `accessMode` 可为 `free`、`svip`、`video_quota`。
- 绑定题目必须提供 `questionId` 或 `legacyQuestionId`，且题目必须属于当前租户。
- `assetId` 可以绑定 `content_assets` 台账资源；生产建议优先用资源台账和签名播放，不让前端长期持有私有视频 URL。
- 导入成功会写 `question_videos`，并把题目 `has_video_explanation` 标记为 true。

## 幂等规则

- 优先使用 `legacyId` 作为跨迁移稳定标识。
- 没有 `legacyId` 时，后端按导入 job 和行号生成内部标识。
- 相同 `legacyId` 再导入会更新。
- 内容 hash 未变化时标记为 `skipped`。

## 下一步

- 已补 `npm run pb:import:dry-run` 静态报告工具；拿到真实 PocketBase 全量导出数据后做多轮 dry-run，并把 dry-run 报告、正式导入报告和复检报告作为上线验收材料。
- Taro 租户内容页已接模板下载、可视化字段映射第一版、导入 job 详情/轮询、逐行 issue 展示和复检结果面板；继续补完整目标入口/集合选择表单。
- 后续按大租户数据量补导入任务分页预览、抽样校验和导入性能压测。
