# PocketBase 真实数据迁移验收 Runbook

更新时间：2026-06-30

这份文档用于把旧 PocketBase 生产数据迁移到新的 Supabase/PostgreSQL 多租户题库 SaaS。目标不是简单把 JSON 塞进新库，而是证明旧系统的用户、题库、订单、权益、学习数据和运营配置进入新模型后仍能支撑业务上线。

## 适用范围

本流程适用于正式切换前的迁移演练、预生产验收和最终切换。旧 PocketBase/React 项目只作为导出来源和功能参照，新系统以 `apps/api`、`apps/worker`、`supabase/migrations` 和 `apps/taro` 为准。

迁移覆盖：

- 用户、租户成员、学生资料、身份映射。
- 地区、入口、分类、科目、题目、题目版本、题库集合、练习蓝图。
- 错题、收藏、最近练习、背单词进度和收藏。
- 单词、知识手册、分数线、题目视频、资料资源台账。
- 订单、支付、权益、SVIP 套餐、激活码、优惠券。
- Banner、公告、FAQ、考试日期、勋章、CRM、推广关系和统计数据。

## 迁移原则

- 不直接复用 PocketBase 的不规范字段作为长期模型，全部经过导入器规范化。
- 旧 `id` 必须保留到 `legacy_id` 或相关 legacy 字段，方便追溯和二次修复。
- 所有租户内数据必须带 `tenant_id`，不得出现跨租户共享业务行。
- 密码、token、secret、openid、unionid、短信验证码、支付密钥等不得进入 public schema。
- 私有资料、PDF、题图、视频不能变成长效 URL，必须进入 `content_assets` 台账并由后端签名。
- 导入前 dry-run 有 blocker 时禁止正式导入；上线切换前 `pb:import:validate` 不能有 FAIL。

## 环境准备

本地或预生产环境需要：

- Node.js 20+
- Docker Desktop
- Supabase CLI
- 已执行最新数据库迁移

建议使用独立迁移验收库，不要混用日常开发 smoke 数据库。迁移演练开始前：

```bash
npm install
npm run supabase:start
npm run supabase:reset
```

如果要验证 API 主链路，可以在真实数据导入后再补充最小管理账号或临时测试账号。不要在真实迁移验收库中先跑 `npm run db:smoke-seed`，否则统计和抽样验收会被模拟数据污染。

## 导出目录

当前工作区已有旧 PocketBase SQLite 数据参考：

```text
F:\project\参考\旧题库数据库文件
  data.db
  auxiliary.db
  storage/
```

`data.db` 和 `auxiliary.db` 必须按只读源处理，不能直接在旧库上执行修复 SQL。当前已补 `npm run pb:export:sqlite`，会用只读 SQLite 连接把 PocketBase collection 导出为规范 JSON，再进入下面的 dry-run/import 管线；`storage/` 中的附件、题图、PDF、视频封面等资源会同步生成资源迁移清单，最终进入 `content_assets` 和对象存储，不允许把旧本地路径或长效 URL 直接写给前端。

把 PocketBase 导出的集合 JSON 放到仓库根目录：

```text
pb_export/
  users.json
  regions.json
  region_modules.json
  module_nodes.json
  subjects.json
  categories.json
  questions.json
  orders.json
  svip_plans.json
  codes.json
  code_batches.json
  vocabulary_units.json
  vocabulary.json
  handbook_subjects.json
  handbook_chapters.json
  handbook_entries.json
  scoreline_schools.json
  scoreline_majors.json
  scoreline_fields.json
  scoreline_records.json
  video_explanations.json
  question_videos.json
```

可以用 `PB_EXPORT_DIR` 指向其他目录：

```powershell
$env:PB_EXPORT_DIR="F:\migration\pb_export_20260630"
npm run pb:import:dry-run
```

如果输入源仍是 SQLite，而不是 JSON 目录，先执行 `scripts/import-pocketbase` 的 SQLite 只读导出命令，建议输出到仓库外或 `.gitignore` 覆盖的 `pb_export/`：

```powershell
$env:PB_SQLITE_DIR="F:\project\参考\旧题库数据库文件"
$env:PB_EXPORT_DIR="F:\project\pb_export"
npm run pb:export:sqlite
```

导出脚本行为：

- 使用只读 SQLite 连接，禁止修改 `data.db`、`auxiliary.db` 和 `storage/`。
- 每个 PocketBase collection 输出一个 JSON 文件，并保留旧 `id`、`created`、`updated`、relation 字段和文件字段。
- `password`、`tokenKey`、`secret`、`wechatSessionKey`、短信验证码等密钥类字段默认不会写入普通 JSON；`openid/unionid` 默认也会移除，只在 `sqlite-export-manifest.json` 里记录字段和数量。后续如果确实要迁移第三方登录身份，必须走单独的加密身份迁移链路，不混入普通 `pb_export`。
- 手机号、邮箱、业务旧 ID、订单、题目、学习记录等迁移必需字段会保留，用于自营 ToC 租户的数据归属和售后追溯。
- 资源清单输出到 `storage-manifest.json`，包含相对路径、hash、size、mime 和旧 collection/record/file 三元组，后续由对象存储迁移步骤转为 `content_assets`。
- `auxiliary.db` 默认只写 `_logs` 的数量和 level 摘要，不导出完整日志正文，避免把 UA、请求参数或历史 token 放进迁移数据包。

可选环境变量：

```powershell
$env:PB_SQLITE_DATA_DB="F:\project\参考\旧题库数据库文件\data.db"
$env:PB_SQLITE_AUX_DB="F:\project\参考\旧题库数据库文件\auxiliary.db"
$env:PB_SQLITE_STORAGE_DIR="F:\project\参考\旧题库数据库文件\storage"
$env:PB_SQLITE_COLLECTIONS="users,regions,subjects,categories,questions"
$env:PB_SQLITE_BATCH_SIZE="2000"
```

正常迁移不要设置 `PB_SQLITE_INCLUDE_SENSITIVE_IDENTITIES=true`。这个开关只允许在加密迁移工作区临时使用，并且导出的身份文件不得提交 Git。

当前真实库只读导出基线：

```text
data.db: 58 个业务 collection，248555 条记录
questions: 74102
users: 3670
orders: 636
user_answer_records: 85442
vocabulary: 3500
handbook_entries: 2676
storage-manifest: 9 个原始资源文件
auxiliary.db: _logs 121715 条，仅摘要
```

最近一次 production dry-run 仍有 2 个真实数据 blocker，需要在正式切换前处理：

- `orders.userId` 覆盖率 95.3%，30 个订单缺用户，其中 7 个为已支付订单。处理策略应优先从支付通知、手机号、订单号或人工售后记录找回用户；找不回的已支付订单必须进入人工异常台账，不允许静默开权益。
- `handbook_chapters.subjectId` 覆盖率 94.4%，22 个章节缺所属手册。处理策略应按章节标题和条目归属归并到正确 `handbook_subjects`，无法归并的放入迁移隔离手册并标记待人工复核。

当前导入器已经补齐下面 4 个旧集合的标准化 mapper：

- `user_answer_records` 85442 条会导入到 `answer_records`，并按错误记录重建 `wrong_questions`。旧用户为空或旧用户已不存在的答题记录会跳过并写 warning，不作为财务/权益级 blocker。
- `mock_exam_configs` 48 条会导入到 `practice_blueprints(mode='mock_exam', assembly_type='filters')`。
- `referral_qrcodes` 79 条会导入到 `referral_qrcodes`，并同步补 `referral_codes`。
- `commission_settings` 1 条会导入到 `tenant_commission_settings`。

当前导入器还会把旧题库的树形导航整理成新 SaaS 题库导航：

- `region_modules` 生成 `content_entries(entry_type='question_practice')`。
- `module_nodes`、`subjects`、`categories` 生成 `content_nodes`，保留任意深度分类和考试意向标记。
- `questions.nodeId` 会优先生成 `module_node:*:direct` 题目合集；旧库真实数据中 74102 道题都有 `nodeId`，因此这是迁移后的第一归属。
- `categoryId` 只覆盖 27665 道题，作为补充分类合集处理，不再作为唯一前端入口。
- 每个有效合集自动生成顺序刷题和随机刷题 `practice_blueprints`，旧 `mock_exam_configs` 继续生成全真模拟蓝图。
- 每道已发布旧题都会回填 `entry_id`、`content_node_id`、`primary_collection_id`，方便 Taro 前端直接按 `content_entries/content_nodes/question_collections/practice_blueprints` 对接。

导入器对两个真实 blocker 采用“隔离 + 审计”策略，不会静默丢数据：

- 缺用户订单仍导入 `orders/payments/order_items`，`raw_payload.migration.reviewRequired=true`、`entitlementBlocked=true`，并写入 `pb_import_issues`。缺用户订单不会自动开通权益。
- 缺 `subjectId` 的手册章节会挂到 `handbook_subjects.legacy_id='__migration_orphan_handbook_subject__'` 的“迁移待复核手册”，章节 `metadata.reviewRequired=true`，并写入 `pb_import_issues`。

production dry-run 还有这些 warning，属于运营处理项：

- `dashboard_cache`、空 `customer_messages/smscodes/tenant_config` 可不作为正式数据源，必要时只保留审计摘要。
- `recent_practices` 和少量 `user_answer_records` 有旧用户引用断裂，需要在导入后复核 `pb_import_issues`。

最新真实导入演练结果：

```text
环境：本地 Supabase，先执行 npx supabase db reset
命令：npm run pb:import:json
耗时：约 10 分 11 秒
导入后校验：npm run pb:import:validate => 0 failures, 3 warnings

核心计数：
platform_users: 3670
questions: 74102
answer_records: 85199
wrong_questions: 38205
orders: 636
entitlements: 447
referral_qrcodes: 79
tenant_commission_settings: 1

新题库导航计数：
content_entries: 11
content_nodes: 2830
question_collections: 1597
question_collection_items: 82106
practice_blueprints: 3102
questions_missing_entry/node/collection: 0
node_direct_collections: 1518
hidden_review_entries: 1
orphan_review_collections: 5
orphan_review_questions: 103
```

最新导入 run 的 `pb_import_issues` 仍有 29 个 critical，需要正式切换前人工复核：

- 7 个 `orders` 已支付订单缺用户：订单会进入财务复核，不自动开通权益。
- 22 个 `handbook_chapters` 缺所属手册：章节会挂到“迁移待复核手册”。

导入后校验的 3 个 warnings 当前含义：

- 2533 道题保留 `legacy_category_id` 但无法解析到旧 `categories/module_nodes`。其中能落到明确 `nodeId` 或公开科目的题会进入对应合集；确实断裂且不可公开归类的 103 道题进入隐藏的“迁移待复核题库”，不会出现在学生端目录。
- 5298 条旧答题记录保留 `legacy_question_id` 但无法解析到题目，说明旧学习日志引用了已删除/未导出的题目；这些记录不会进入错题本有效组卷。
- 最新 run 存在 29 个 critical import issues，即上面的付费订单和手册章节人工复核项。

旧 `nodeId` 断裂的题目分两类处理：

- 103 道题：科目可解析，但旧节点和科目入口都无法恢复，进入隐藏入口 `migration_review:orphan_questions`、inactive 节点和 draft 合集，只给租户后台复核。
- 129 道题：旧节点断裂，但科目/入口仍可公开解析，挂到对应公开科目 fallback 合集，学生端可正常看到。

性能注意：真实 `pb:import:json` 已能在约 10 分钟完成 248555 条真实记录导入并生成新导航。后续若题库规模继续增长，仍建议继续批量化 `questions/question_versions`、记录阶段耗时、增加失败恢复和断点重跑策略，并按 `docs/refactor/performance-benchmark-runbook.md` 做导入后 API 压测。

## 阶段 1：静态 Dry-Run

先运行不写数据库的静态报告。默认是 `development` profile，适合开发环境快速发现 JSON 形态、关系和敏感字段问题：

```bash
npm run pb:import:dry-run
```

生成机器可读报告：

```bash
npm run pb:import:dry-run -- --json > migration-dry-run-report.json
```

预生产验收、最终切换和 CI 必须使用 `production` profile，并建议同时打开 warning 阻断：

```bash
npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings
```

也可以用环境变量指定 profile：

```powershell
$env:PB_DRY_RUN_PROFILE="production"
npm run pb:import:dry-run -- --json --fail-on-warnings
Remove-Item Env:\PB_DRY_RUN_PROFILE
```

`--profile` 只接受 `development` 或 `production`，拼写错误会 fail closed 并返回 blocker，避免正式迁移时误用开发模式。

dry-run 会检查：

- 导出目录是否存在。
- JSON 是否可解析，集合是否是数组或 `{ items: [] }` / `{ records: [] }`。
- 核心集合是否缺失。
- 旧记录 `id` 是否缺失或重复。
- `docs/pb_schema.json` 中的 relation 是否能解析到导出数据。
- 敏感字段是否出现在旧导出中。
- 未映射集合是否需要补 mapper。
- 用户、题目、订单、SVIP、激活码、单词、手册、分数线、视频等业务数量。
- `production` profile 会额外检查生产迁移必需集合：`users`、`questions`、`subjects`、`categories`、`orders`、`svip_plans`、`codes`、`vocabulary_units`、`vocabulary`、`handbook_subjects`、`handbook_chapters`、`handbook_entries`。
- `migrationReadiness.criticalFieldCoverage` 会统计关键字段覆盖率，例如 `users.phone`、`questions.subjectId/categoryId/content`、`orders.userId/planId/status`、`codes.code`、单词和手册的归属字段；生产模式下低于阈值会变成 blocker。
- 对 SQLite 导出，`questions.categoryId` 会把 `nodeId` 视为新架构有效归属，`vocabulary.unitId` 会兼容旧字段 `unit`。

准入标准：

- `blockers = 0`。
- 正式切换前建议 `warnings = 0`；如确有历史脏数据，需要记录处理结论、影响范围和接受人。
- `businessCounts` 与旧后台统计口径差异必须能解释。
- 生产切换前 `migrationProfile` 必须是 `production`，且 `migrationReadiness.requiredCollections` 不能有缺失或记录数不足。

## 阶段 2：正式导入演练

确认目标租户：

```powershell
$env:TENANT_ID="00000000-0000-0000-0000-000000000001"
$env:TENANT_SLUG="master"
$env:TENANT_NAME="工学题库主租户"
```

执行导入：

```bash
npm run pb:import:json
```

默认不会把旧系统密钥值写入新库，只会记录脱敏和问题项。只有在迁移受控密钥到 `app_private.tenant_secrets` 时才允许临时打开：

```powershell
$env:IMPORT_SECRET_VALUES="true"
npm run pb:import:json
Remove-Item Env:\IMPORT_SECRET_VALUES
```

密钥导入后必须立即执行生产就绪检查和人工抽查，确保 public 表不含密钥明文。

## 阶段 3：导入后校验

导入完成后运行：

```bash
npm run pb:import:validate
```

正式切换前建议使用 warning 阻断：

```powershell
$env:FAIL_ON_WARNINGS="true"
npm run pb:import:validate
Remove-Item Env:\FAIL_ON_WARNINGS
```

必须通过的关键检查：

- 租户存在。
- 最新一次导入的核心 raw records 已规范化。
- 每道题都有 current version。
- `question_versions.tenant_id` 与题目一致。
- 已支付订单有 payment 行。
- 权益关联到有效用户。
- `platform_users.raw_profile` 不含敏感身份字段。
- `tenant_settings.public_config` 不含密钥。
- `crm_config.secret_ref` 只引用 `app_private.tenant_secrets`。

当前演练可接受但上线前必须确认的 warning：

- 已支付订单缺用户不能自动开权益，必须由财务/运营确认是否补绑用户、退款、作废或保留售后台账。
- 手册章节缺归属需要内容负责人确认归并到正确手册，或保留在迁移待复核手册并在前端隐藏。
- 已删除分类下的题目和已删除题目对应的学习记录，需要按运营口径决定是否建立“迁移待复核分类”、按科目批量归档，或仅保留 legacy trace。

## 阶段 4：抽样验收

结构校验通过后，先运行只读业务抽样脚本：

```bash
npm run pb:import:sample
```

它和 `pb:import:validate` 的边界不同：

- `pb:import:validate` 证明核心表关系、敏感字段和迁移隔离策略没有断。
- `pb:import:sample` 证明真实迁移数据能被新 SaaS 业务模型消费，覆盖题库入口、内容节点、合集、练习蓝图、题目当前版本、答题记录、错题、收藏、单词、知识手册、分数线、订单、支付、权益、激活码、内容资源、视频和敏感字段泄露。
- 脚本只读数据库，不修复数据，不生成测试数据；FAIL 表示当前业务模型不能安全消费该部分迁移数据，WARN 表示旧数据或上线配置需要人工复核。

常用参数：

```powershell
$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
$env:PB_SAMPLE_TENANT_SLUG="master"
npm run pb:import:sample

# 需要保存本地报告时开启；报告目录已被 .gitignore 忽略
$env:PB_SAMPLE_WRITE_REPORT="true"
npm run pb:import:sample
Remove-Item Env:\PB_SAMPLE_WRITE_REPORT
```

当前真实迁移库最近一次抽样结果：

```text
npm run pb:import:sample => 0 failures, 6 warnings, 1 skipped, 39 passed
```

其中 6 个 warning 分别对应：迁移待复核题目、部分旧答题记录引用已删除题、已支付缺用户订单、已使用但缺使用人的激活码、已知 critical import issues 人工复核项、最新导入 warning/error issue 留档项；`videos.not_present` 为 SKIP，因为当前旧库没有题目视频记录。

每次演练都应至少抽样下面数据：

| 范围 | 抽样建议 | 验收点 |
| --- | --- | --- |
| 用户 | 随机 20 个学生、5 个管理员/销售/教师 | 手机号、昵称、角色、禁用状态、地区/院校目标、会员状态 |
| 题库 | 每个地区至少 2 个入口，每个入口抽 2 条路径 | 入口、分类层级、考试意向标记、题目数量 |
| 题目 | 每种题型至少 10 道，含阅读理解/案例分析 | 题干、选项、答案、解析、子题、图片/公式、难度、标签 |
| 练习 | 顺序、随机、全真模拟各 3 次 | 组卷、答题、判分、错题、收藏、报告、复盘 |
| 单词 | 每个地区/科目抽 2 个单元 | 单词、音标、释义、例句、收藏、进度 |
| 知识手册 | 每个手册抽 2 个章节 | Markdown、图片、公式、目录、权限 |
| 分数线 | 每个地区抽 2 所学校、2 个专业 | 年份、动态字段、趋势查询 |
| 视频 | 抽 20 道带视频题 | 权限、次数扣减、签名 URL、水印 traceId、播放日志 |
| 资料 | 抽 PDF/图片各 10 个 | `content_assets` 台账、扫描状态、预览/下载短签名、水印 |
| 订单权益 | 抽 20 个付费订单、20 个激活码 | 订单状态、payment、entitlement、地区/范围、过期时间 |
| 营销 | 抽优惠券、激活码批次、勋章 | 规则、核销记录、发放记录 |
| CRM/推广 | 抽销售、代理、自然流用户 | 首绑保护、推广来源、队列 payload、分佣归因 |

抽样结果建议保存到 `docs/refactor/migration-reports/`。真实数据报告可能包含业务敏感信息，提交前必须脱敏；如包含用户手机号、订单号、openid、支付流水号，不要提交到 Git。

## 阶段 5：API 与 Taro 联调验收

迁移数据通过校验后，再启动 API：

```bash
npm run dev:api
```

前端联调至少跑通：

- 学生 H5：登录、首页、地区选择、题库入口、刷题、错题、收藏、背单词、知识手册、分数线、资料、视频、会员、订单、激活码、个人中心。
- 租户后台 H5：数据看板、学生管理、题库内容、导入、公共题库采纳/同步、营销、财务、主题、权限、成员。
- 平台后台 H5：租户、套餐、订阅、账单、用量、公共题库授权。

前端只允许通过 `apps/api` 获取业务数据和签名资源。不要在前端直接访问 Supabase 表或拼接对象存储私有 URL。

## 阶段 6：最终切换前冻结

正式切换窗口建议：

1. 公告维护窗口。
2. 旧 PocketBase 进入只读或暂停写入。
3. 导出最终 JSON。
4. 执行 production strict dry-run。
5. 重置目标生产库或清理目标租户迁移数据。
6. 执行正式导入。
7. 执行 `pb:import:validate` strict 模式。
8. 抽样验收核心链路。
9. 切换域名/API 配置。
10. 保留旧系统只读快照，至少覆盖一个完整售后周期。

## 回滚策略

切换后如果发现阻断级问题：

- 立即停止新系统写入或进入维护模式。
- 保留新库快照和 API 日志，便于定位导入器或业务 API 问题。
- 将域名/API 流量切回旧 PocketBase 只读或旧生产服务。
- 修复 mapper 或数据清洗规则后重新跑 dry-run、导入和抽样验收。

不要在问题未定位时手工批量修改生产表。所有批量修复应沉淀为可重复脚本或 importer mapper 修复，并保留审计记录。

## 上线准出标准

满足以下条件后，才建议进入生产切换：

- `npm run pb:import:dry-run -- --profile=production --json --fail-on-warnings` 通过，或全部 warning 有签字确认的处理结论。
- `npm run pb:import:validate` 无 FAIL；生产切换前 strict 模式无 WARN，或 WARN 已确认。
- 核心业务抽样通过，尤其是题目答案解析、会员权益、订单支付、错题收藏、资料视频权限。
- `npm run readiness:production` 和 `npm run readiness:production:db` 通过。
- API/Taro 三端核心链路在迁移数据上跑通。
- 对象存储、短信、OAuth、支付、CRM webhook 使用生产 provider 或明确标记为暂不开启。
- 已准备数据库备份、旧系统快照、回滚步骤和负责人。
