11 KiB
内容导入契约
更新时间:2026-06-29
结论
内容导入的最终规范化、校验、租户隔离、幂等和审计必须由后端负责。
前端只负责:
- 上传或粘贴 JSON/Excel/CSV。
- 做轻量格式预检查,减少明显错误。
- 展示后端 preview 返回的
job/items/issues。 - 让运营人员修正数据后再确认导入。
迁移脚本只负责:
- 从 PocketBase 导出或旧 JSON 中抽取数据。
- 转换成新架构推荐格式。
- 调用后端 preview/import API。
不建议迁移脚本直接绕过后端写业务表,除非是一次性内控迁移,并且必须额外跑导入后校验。
已实现导入 API
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_jobscontent_import_itemscontent_import_issuesaudit_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 后台消费。
表格导入安全边界:
- 单文件最大 8MB。
- 单次最多解析 5000 行、160 列。
- 单个单元格最多保留 100000 字符。
- 后端只保存解析后的原始行和规范化 payload,不把
fileBase64长期写入数据库。 - 任务列表会返回
sourceFormat、executionMode和parserMetadata,前端可展示解析器、Sheet、行数等信息。
异步导入调用方式:
{
"previewJobId": "uuid",
"executionMode": "async",
"allowPartial": false
}
异步导入状态流:
preview -> pending -> importing -> completed
preview -> pending -> importing -> completed_with_errors
preview -> pending -> failed
worker 命令:
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 和审计日志。
模板、字段映射和导入后复检
租户后台前端不要把导入字段写死在页面里。导入页初始化时先读取字段映射,下载模板时调用模板接口:
GET /api/tenant-content/imports/field-mapping?importType=questions
GET /api/tenant-content/imports/templates?importType=questions&format=csv
importType 支持:
questions | vocabulary | handbook | scoreline | videos
templates 返回:
{
"item": {
"importType": "questions",
"format": "csv",
"fileName": "questions-import-template.csv",
"mimeType": "text/csv; charset=utf-8",
"contentBase64": "...",
"fields": []
}
}
前端可用 contentBase64 生成下载文件,或用 contentPreview 做在线预览。fields 包含规范字段、中文别名、是否必填和示例,适合做字段映射 UI。
导入完成后,租户后台应主动触发复检:
POST /api/tenant-content/imports/post-check
{
"jobId": "uuid"
}
复检会确认:
- job 行数、有效行、已处理行是否一致。
- 处理成功的 item 是否都有
target_id。 - 题目是否存在当前版本,且已绑定目标集合。
- 单词单元是否存在且有单词。
- 知识手册是否存在章节/条目。
- 分数线字段、院校、专业、记录是否都落到对应表。
- 视频是否存在题目绑定。
复检结果会写入 content_import_jobs.summary.importPostCheck,也可以通过:
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 |
单词导入
推荐新格式:
{
"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。
知识手册导入
推荐新格式:
{
"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、图片、表格、阅读理解子题等字段。导入时可以传:
subjectIdcategoryIdentryIdcontentNodeIdcollectionId
这样题目会同时落到旧兼容表和新内容导航/题目集合。
分数线导入
分数线导入支持字段、院校、专业、年份记录一起提交,适合把旧题库地区分数线 JSON 转成新结构后统一 preview/import。
推荐格式:
{
"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 做权益和签名校验。
推荐格式:
{
"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。
下一步
- 用真实 PocketBase 全量导出数据做多轮 dry-run,并把复检报告作为上线验收材料。
- 前端补可视化字段映射、模板下载按钮、导入 job 轮询、逐行 issue 展示和复检结果面板。
- 后续按大租户数据量补导入任务分页预览、抽样校验和导入性能压测。