# 内容导入契约 更新时间:2026-07-12 ## 结论 内容导入的最终规范化、校验、租户隔离、幂等和审计必须由后端负责。 前端只负责: - 上传或粘贴 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` 和审计日志。 异步 worker 使用数据库持久 lease,不能只依赖进程内状态: - claim 是单条 `UPDATE ... FROM (SELECT ... FOR UPDATE SKIP LOCKED)`,多实例不会领取同一个 job。 - 每次 claim 都生成新的 `lease_token` fencing token,并写入 `locked_by`、`locked_at`、`lease_expires_at`、`last_heartbeat_at`。 - 长任务按 `WORKER_IMPORT_HEARTBEAT_INTERVAL_MS` 续租;该值必须小于 `WORKER_IMPORT_LEASE_SECONDS` 的一半。 - worker 崩溃后,其他实例可在 lease 过期后重新领取,并原子增加 `attempt_count`。 - 完成、失败和重试提交都必须同时匹配 job、`status=importing`、未过期 lease 和 `lease_token`。旧实例丢失 lease 后,其导入事务整体回滚,不能覆盖接管者的结果或审计。 - attempt 已耗尽的过期 job 会直接转为 `failed`,不会额外执行一次。 生产建议先保持默认配置: ```env WORKER_IMPORT_LEASE_SECONDS=120 WORKER_IMPORT_HEARTBEAT_INTERVAL_MS=30000 ``` lease 应覆盖数据库短暂抖动,但不应长到显著拖慢崩溃恢复;调整时必须同时运行 `test:worker:imports` 和 production readiness。 ## 模板、字段映射和导入后复检 租户后台前端不要把导入字段写死在页面里。导入页初始化时先读取字段映射,下载模板时调用模板接口: ```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= ``` 读取。异步导入未完成时触发复检会返回 `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 展示和复检结果面板;继续补完整目标入口/集合选择表单。 - 后续按大租户数据量补导入任务分页预览、抽样校验和导入性能压测。