Files
doodle-store/docs/v3.5/分类与题型首次导入操作手册.md
2026-10-20 17:29:10 +08:00

527 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 分类与题型首次导入操作手册
更新日期:2026-10-20。适用于把本地已整理的分类、题型及必要七牛资产映射,首次导入空的整合库或云端 `doodle_prod`。本文和配套模板不执行 CloudBase 用户数据迁移,也不代表生产库已经导入。
## 1. 本次到底导入哪些数据
| 表 | 本次处理 |
| ------------------ | --------------------------------------------------------------------------------------------- |
| `categories` | 导入本地全部分类,保留 ID、层级、发布状态、排序、图标与时间 |
| `worksheets` | 导入本地全部题型,保留 ID、内容、预览资产引用、发布状态、seed 与时间 |
| `assets` | 仅导入上述两表引用的预览图和分类图标映射,保留资产 ID、bucket、对象 key、摘要、状态及原始时间 |
| `admin_audit_logs` | 新增一条本批迁移审计;不复制本地历史操作日志 |
| 其他业务表 | 本次不复制数据;正式迁移会先创建完整表结构 |
**不能只导出前两张表。** `worksheets.preview_asset_id` 必须引用存在的 `assets.id`,分类的文件图标也可能引用资产。只导两张表会导致外键失败,或丢失已完成的七牛映射。
本次不会重新上传七牛文件。保存映射不等于重新验证对象可用;导入后还要检查资源域名及预览图访问。
**真实下载量和收藏量在本阶段初始化为 0。** 本地旧值保存在原始导出文件中供核对;`downloads_seed` / `likes_seed` 保留本地值。后续必须从 CloudBase 最新 `worksheets` 按 ID 赋值真实 `downloads` / `likes`,不与本地旧值相加,也不在导入历史记录时再次累加。本阶段完成不表示用户历史与统计已经迁移完毕。
本地只读核查记录(2026-10-20):
| 项目 | 数量 |
| -------------------------------- | ------ |
| 分类 | 7 |
| 题型 | 52 |
| 本批引用资产 | 52 |
| 本批含用户归属的资产 | 0 |
| 本批非七牛 / 非公开 / 未就绪资产 | 0 |
| 年级、内容资源、资源文件关联 | 均为 0 |
执行时以新导出的 `manifest.json` 和校验结果为准,不把以上数量写死为永久预期。
## 2. 推荐操作路线
```text
本机 doodle_dev(只读)
→ 导出 content.json
→ 打包 content-import.sql
→ 本机独立整合库试导、验证
→ 上传文件到云服务器
→ 在 1Panel 终端内执行 SQL
→ doodle_prod 验证、备份
```
第一次先导入 `doodle_migration_20261020` 进行试导。该库是一次性迁移整合库,不替代 `doodle_dev`,也无需常驻另一套服务器。
**云端 5432 不需要向公网放行。** 数据文件通过 1Panel 上传,SQL 在 PostgreSQL 容器内部执行。云端首次建表可按 [Adminer 建表指南](./云端数据库首次建表指南.md) 上传初始化 SQL,不依赖本机 SSH;下文另保留通过 SSH 隧道执行 Prisma 的备选方式。
现有旧 CloudBase 云函数、数据库、预拉取文件和任务持续保留。本次首次建库期间,不启动连接目标库的新 API 或后台任务,避免新写入影响对账。
## 3. 配套文件、适用条件和限制
配套模板:
- [只读导出 SQL](../../deploy/postgres/content/export.sql)
- [导入事务模板](../../deploy/postgres/content/apply.sql)
模板已包含:目标库名保护、迁移校验和与字段签名核对、分类/资产依赖检查、分类循环检查、首次空表保护、事务回滚、逐字段对账及迁移审计。
执行前确认:
1. 本机 Node.js / pnpm 与项目要求一致,依赖已按锁文件安装;Docker 和本地 PostgreSQL 正常运行。
2. 源库已应用项目正式迁移,没有失败或未完成的迁移。
3. 目标存在完整的新后端表结构,且 `assets`、`categories`、`worksheets` 三张表都为空。本流程不会清空、覆盖或自动 upsert 已有数据。
4. 本批资产全部为已就绪的七牛公开预览或分类图标,不包含头像。
5. 若资产有用户归属,目标必须已有对应生产用户。找不到归属时模板停止,不自动置空 owner、不导入本地测试用户。需先完成该身份的 CloudBase 映射,或另行明确公共内容资产归属转换。
6. 如果源库已经有年级/完整内容资源关联,或者出现 `generation_mode=resource`,本模板会停止;需扩展为包含相应关联的内容迁移,不能把缺失资源的题型当成完整导入。
现有 `categories:import`、`worksheets:import`、`assets:map` 是本地导入/回填工具,限定 `doodle_dev`;本手册使用独立的 PostgreSQL 内容快照流程,不修改它们的目标保护。
## 4. 本机:导出一致的内容快照
### 4.1 进入项目并确认状态
以下本机命令在同一个终端中顺序执行,所有文件路径均以仓库根目录为基准。每一步出现错误就停止,不继续后续步骤。
```bash
cd /Users/joey-xd/sites/doodle/doodle-store
node --version
pnpm --version
pnpm infra:status
umask 077
doodle_batch_dir="$PWD/deploy/data/content-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -m 700 "$doodle_batch_dir"
```
`deploy/data/` 已被 Git 忽略。导出文件、连接配置和真实密码不要提交仓库。不要通过 `pnpm format` 批量改写数据文件;原始快照的字节摘要用于本批对账。
### 4.2 执行只读导出
```bash
docker compose --env-file .env -f infra/compose.yaml \
exec -T postgres sh -c \
'exec psql -X -qAt -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_dev' \
< deploy/postgres/content/export.sql \
> "$doodle_batch_dir/content.json.partial" \
&& mv "$doodle_batch_dir/content.json.partial" "$doodle_batch_dir/content.json"
```
导出在一个只读、可重复读事务中完成,三份数组来自同一快照;不逐表分开导出,不修改开发库。导出包含所有状态的分类/题型,不会只筛选 active;资产范围自动按引用计算。
只有命令成功才生成正式的 `content.json`。若只剩 `.partial` 或文件为空,先排查错误,不打包导入。
### 4.3 打包并生成摘要
以下命令只读取内容快照和仓库 SQL 模板,生成三个辅助文件。它不会连接数据库。
```bash
node --input-type=module - "$doodle_batch_dir" <<'JS'
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
import { createHash } from 'node:crypto';
const dir = resolve(process.argv[2]);
const raw = readFileSync(resolve(dir, 'content.json'));
const payload = JSON.parse(raw.toString('utf8'));
if (payload.format !== 'doodle-content-v1' ||
payload.source_database !== 'doodle_dev' ||
!Array.isArray(payload.assets) ||
!Array.isArray(payload.categories) ||
!Array.isArray(payload.worksheets)) {
throw new Error('内容快照格式不正确');
}
const sha256 = (value) => createHash('sha256').update(value).digest('hex');
const digest = sha256(raw);
const template = readFileSync('deploy/postgres/content/apply.sql', 'utf8');
const prefix = [
'\\set ON_ERROR_STOP on',
'BEGIN;',
"SET LOCAL TIME ZONE 'UTC';",
"SET LOCAL lock_timeout = '5s';",
"SET LOCAL statement_timeout = '60s';",
"SET LOCAL doodle.content_import_sha256 = '" + digest + "';",
'CREATE TEMP TABLE content_import_encoded (body text NOT NULL) ON COMMIT DROP;',
'COPY content_import_encoded (body) FROM STDIN;',
].join('\n') + '\n';
const sql = prefix + raw.toString('base64') + '\n\\.\n' + template;
writeFileSync(resolve(dir, 'content-import.sql'), sql, { flag: 'wx', mode: 0o600 });
const manifest = {
format: payload.format,
exportedAt: payload.exported_at,
sourceSha256: digest,
assets: payload.assets.length,
categories: payload.categories.length,
worksheets: payload.worksheets.length,
assetsWithOwner: payload.assets.filter((a) => a.owner_user_id !== null).length,
excludedRelations: payload.excluded_relations,
statisticsState: 'pending-cloudbase',
};
writeFileSync(resolve(dir, 'manifest.json'), JSON.stringify(manifest, null, 4) + '\n',
{ flag: 'wx', mode: 0o600 });
writeFileSync(resolve(dir, 'SHA256SUMS'),
digest + ' content.json\n' + sha256(sql) + ' content-import.sql\n',
{ flag: 'wx', mode: 0o600 });
console.log(manifest);
JS
```
打包后的目录:
```text
content.json 原始内容及资产快照
content-import.sql 可直接交给 psql 的整批事务
manifest.json 本批时间、数量和源文件摘要
SHA256SUMS 上传前后校验文件完整性
```
原始 JSON 原封不动编码进 SQL,由 PostgreSQL 自己恢复 bigint 等字段。Node.js 只读取数组长度等元数据,不将解析后的数值重新序列化为导入数据,避免大整数精度损失。Base64 用于安全封装 JSON,不是加密。
打包路径已存在时会报错;保留上一批次,重新导出到新目录,不覆盖原始文件。
## 5. 本机:建立独立整合库并试导
### 5.1 创建空库
```bash
docker compose --env-file .env -f infra/compose.yaml \
exec -T postgres sh -c \
'exec createdb -U "$POSTGRES_USER" --template=template0 --encoding=UTF8 --lc-collate=C --lc-ctype=C doodle_migration_20261020'
```
库已存在时不要 drop 或清空。先查是否属于此前试导;新批次可改用 `doodle_migration_20261020_02`,并同步修改本节后续配置和命令。
### 5.2 建立仅用于整合库的连接配置
以下脚本读取当前服务端连接地址,检查确实是本地 `doodle_dev`,只替换库名并生成独立文件。不会改动根 `.env` 或 `apps/server/.env`,也不打印密码。
```bash
node --input-type=module <<'JS'
import { readFileSync, writeFileSync } from 'node:fs';
import { parseEnv } from 'node:util';
const source = parseEnv(readFileSync('apps/server/.env', 'utf8'));
const url = new URL(source.DATABASE_URL);
if (!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.pathname !== '/doodle_dev') {
throw new Error('请先核对本地服务端连接配置');
}
url.pathname = '/doodle_migration_20261020';
writeFileSync('.env.content-import', 'DATABASE_URL=' + url.href + '\n',
{ flag: 'wx', mode: 0o600 });
JS
```
文件已存在时先核对用途,不覆盖。该文件受 `.env.*` 忽略规则保护。
### 5.3 使用正式 Prisma 迁移创建结构
```bash
env -u DATABASE_URL node --env-file=.env.content-import --input-type=module <<'JS'
import { spawnSync } from 'node:child_process';
const url = new URL(process.env.DATABASE_URL);
if (!['127.0.0.1', 'localhost'].includes(url.hostname) ||
url.pathname !== '/doodle_migration_20261020') {
throw new Error('连接目标不是本次本地整合库');
}
for (const script of ['db:deploy', 'db:status']) {
const result = spawnSync('pnpm', ['--filter', '@doodle/server', script],
{ stdio: 'inherit', env: process.env });
if (result.error || result.status !== 0) process.exit(1);
}
JS
```
当前应成功登记三个迁移:`0_v35_baseline`、`20261016000000_category_publication`、`20261019000000_member_assets`。以后以仓库实际迁移为准,模板会核对源库和目标的迁移名称及校验和。
不要执行 `db push`、`migrate reset`、手工复制建表 SQL或仅手工写入迁移记录。迁移会创建完整业务表及必要默认配置,本阶段只填充内容数据。
### 5.4 试导同一份 SQL
```bash
docker compose --env-file .env -f infra/compose.yaml \
exec -T postgres sh -c \
'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_migration_20261020' \
< "$doodle_batch_dir/content-import.sql"
```
以进程成功退出,并出现 `CONTENT_IMPORT_COMMITTED` 为成功标志。导入事务内已逐字段比较写入结果与快照;两项真实计数与已归零的临时数据比较,其余字段保持原值。
如出现错误,连接退出时整个业务导入事务回滚。不会留下半批分类/题型或审计。目标结构及之前单独完成的 Prisma 迁移仍保留。
使用第 8 节的 SQL 在整合库核对结果。**不要为测试重复执行而清空整合库;第二次导入会因三张表非空被拒绝,这是首次导入保护的预期行为。**
## 6. 云端:创建空库、备份并初始化结构
### 6.1 在 1Panel 创建 doodle_prod
如已创建,核查后沿用,不重复新建:
| 项目 | 值 |
| --------------- | ----------------------------------------------- |
| 数据库 | `doodle_prod` |
| 所有者/迁移用户 | `doodle_prod_owner`,或此前为该库配置的专属账号 |
| 超级用户 | 不勾选 |
| 编码 | UTF-8 |
| 密码 | 独立随机密码,只保存在受控配置中 |
在 1Panel「终端」执行以下命令,确认实际实例。容器名来自 2026-10-20 检查,若重装过实例先从容器列表核对。
```bash
doodle_pg_container="1Panel-postgresql-RqP6"
docker exec "$doodle_pg_container" sh -c \
'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_prod -c "SELECT current_database(), current_user, version(); SELECT datname, pg_get_userbyid(datdba) AS owner, pg_encoding_to_char(encoding) AS encoding FROM pg_database WHERE datname = current_database();"'
```
这里 `POSTGRES_USER` 在容器内部展开,代表实例初始化管理员,不是待部署 API 的运行账号。如果该实例未配置此变量,在 1Panel 连接信息中核对实际管理员用户名并替换命令中的 `-U` 参数,不输出或复制密码到聊天。
### 6.2 创建导入前备份
即使刚创建空库,也记录导入前状态。以下命令在云服务器终端执行:
```bash
umask 077
doodle_backup_dir="/opt/doodle/backups/content-$(date -u +%Y%m%dT%H%M%SZ)"
mkdir -p "$doodle_backup_dir"
docker exec "$doodle_pg_container" sh -c \
'exec pg_dump -U "$POSTGRES_USER" -d doodle_prod --format=custom' \
> "$doodle_backup_dir/before-content.dump.partial" \
&& mv "$doodle_backup_dir/before-content.dump.partial" "$doodle_backup_dir/before-content.dump"
docker exec -i "$doodle_pg_container" pg_restore --list \
< "$doodle_backup_dir/before-content.dump" \
> "$doodle_backup_dir/before-content.list"
```
检查两条命令均成功,且正式备份文件存在。`pg_restore --list` 只检查归档目录可读,不替代完整恢复验证。完整数据库备份不包含集群账号密码及服务端配置;记录所有者和权限配置以便恢复。
不要运行 `deploy/postgres/backup.sh`:它属于此前独立 Compose 方案,不适用于当前由 1Panel 管理的实例。
### 6.3 用 SSH 隧道让本机 Prisma 连接云端
如果已按 [首次建表指南](./云端数据库首次建表指南.md) 完整恢复初始化 SQL,并核对 14 张表和三条迁移记录,本节可跳过,直接执行第 7 节内容导入。不要再次手工建表或登记相同迁移。
在**本机另一个终端**启动隧道:
```bash
ssh -N -o ExitOnForwardFailure=yes -o ServerAliveInterval=30 \
-L 127.0.0.1:15432:127.0.0.1:5432 \
-p 22 你的SSH用户名@175.24.138.234
```
替换真实 SSH 用户和端口;`22` 只是示例。使用你已有的 SSH 密钥/密码,不修改服务器登录配置。保持该终端运行。
连接路径:本机 `127.0.0.1:15432` → SSH → 云服务器 `127.0.0.1:5432` → PostgreSQL。前提是云端容器仍将 5432 映射到宿主机;若绑定或端口已调整,按实际配置修改转发目标。不要新增公网 5432 放行规则。
回到本机项目终端,新建权限为 600 的独立配置文件:
```bash
touch .env.production-import
chmod 600 .env.production-import
```
使用编辑器填入以下内容。若文件已存在,先核对其中内容,避免误用别的环境;密码中的特殊字符需要 URL 编码。
```dotenv
DATABASE_URL=postgresql://doodle_prod_owner:填写URL编码后的密码@127.0.0.1:15432/doodle_prod?schema=public
```
执行迁移:
```bash
env -u DATABASE_URL node --env-file=.env.production-import --input-type=module <<'JS'
import { spawnSync } from 'node:child_process';
const url = new URL(process.env.DATABASE_URL);
if (url.hostname !== '127.0.0.1' || url.port !== '15432' ||
url.pathname !== '/doodle_prod') {
throw new Error('目标不是经 SSH 隧道连接的 doodle_prod');
}
for (const script of ['db:deploy', 'db:status']) {
const result = spawnSync('pnpm', ['--filter', '@doodle/server', script],
{ stdio: 'inherit', env: process.env });
if (result.error || result.status !== 0) process.exit(1);
}
JS
```
该命令只使用专属连接文件,不修改开发服务端配置。若目标已经完成相同迁移,`db:deploy` 检查后不会重建表。迁移失败先处理原因,不伪造 applied 状态。
如果暂时无法 SSH,不需要改用公网 5432:可以之后准备含锁定 Prisma 和迁移文件的迁移容器,在共享 Docker 网络内执行。项目当前尚无这个发布镜像,因此本文给出的可操作路线是 SSH 隧道,而不是假设云端已经安装 Node.js/pnpm。
## 7. 云端:上传并导入经过试导的同一批次
### 7.1 上传和校验
在 1Panel 文件管理中创建专属批次目录,例如:
```text
/opt/doodle/imports/content-20261020-01/
```
上传本机**已经试导成功**的四个文件:`content.json`、`content-import.sql`、`manifest.json`、`SHA256SUMS`。不要上传 `.env`、密码、Mac 的 node_modules 或本地整库备份。
在 1Panel 终端中执行:
```bash
cd /opt/doodle/imports/content-20261020-01
chmod 700 .
chmod 600 content.json content-import.sql manifest.json SHA256SUMS
sha256sum -c SHA256SUMS
cat manifest.json
```
两个摘要必须均为 `OK`,数量应与本机试导一致。上传后不要编辑 JSON 或 SQL;任何修改都生成新批次、重新试导和计算摘要。
摘要用于核对传输完整性,不是来源签名。文件必须来自本次受控导出,不能执行不明来源的 SQL。
### 7.2 复核目标为空
在云端执行:
```bash
doodle_pg_container="1Panel-postgresql-RqP6"
docker exec "$doodle_pg_container" sh -c \
'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_prod -c "SELECT (SELECT count(*) FROM public.assets) AS assets, (SELECT count(*) FROM public.categories) AS categories, (SELECT count(*) FROM public.worksheets) AS worksheets;"'
```
三项必须为 0。若不是 0,停止使用本手册的首次导入入口,核对已有内容及其来源;不要 `TRUNCATE`、删除或恢复旧整库覆盖它们。
### 7.3 执行正式内容导入
仍在云端批次目录执行:
```bash
docker exec -i "$doodle_pg_container" sh -c \
'exec psql -X -v ON_ERROR_STOP=1 -U "$POSTGRES_USER" -d doodle_prod' \
< content-import.sql
```
必须同时满足:命令成功退出、出现 `CONTENT_IMPORT_COMMITTED`。只看到 INSERT 行数不表示整个事务已提交。
导入顺序为资产 → 分类 → 题型 → 对账 → 迁移审计 → 提交。外键和 CHECK 约束保持启用,不使用 `session_replication_role` 或关闭触发器来绕过错误。创建表的账号所有权不会因管理员执行数据插入而改变。
## 8. 导入后验证
### 8.1 在 SQL 客户端执行
本机整合库可通过容器 psql 或本机客户端运行;云端可使用 1Panel 终端的 psql 或 Adminer:
```sql
-- 先确认正在检查的库。
SELECT current_database();
-- 数量与本批 manifest 对照。
SELECT
(SELECT count(*) FROM public.assets) AS assets,
(SELECT count(*) FROM public.categories) AS categories,
(SELECT count(*) FROM public.worksheets) AS worksheets;
SELECT status, count(*) FROM public.categories GROUP BY status ORDER BY status;
SELECT status, count(*) FROM public.worksheets GROUP BY status ORDER BY status;
-- 预期为 0:题型缺分类或缺就绪预览。
SELECT count(*) AS broken_worksheet_references
FROM public.worksheets w
LEFT JOIN public.categories c ON c.id = w.category_id
LEFT JOIN public.assets a ON a.id = w.preview_asset_id
WHERE c.id IS NULL OR a.id IS NULL OR a.status <> 'ready'
OR a.provider <> 'qiniu' OR a.visibility <> 'public' OR a.purpose <> 'preview';
-- 第一阶段真实计数为 0,seed 保留本地值。
SELECT
sum(downloads) AS downloads,
sum(likes) AS likes,
sum(downloads_seed) AS downloads_seed,
sum(likes_seed) AS likes_seed
FROM public.worksheets;
-- 审计中应有本批源摘要、条数及 pending-cloudbase。
SELECT request_id, after_data, created_at
FROM public.admin_audit_logs
WHERE action = 'content.import'
ORDER BY created_at DESC;
-- 结构迁移全部完成;不应有未完成记录。
SELECT migration_name, checksum, finished_at, rolled_back_at
FROM public._prisma_migrations
ORDER BY migration_name;
```
当前记录对应 52 资产、7 分类、52 题型;状态分布和 seed 总量以该批次源数据为准。导入模板提交前已经逐字段比较全部三表,不仅检查数量。
### 8.2 核对资源访问
以实际服务端公开配置为准;当前已确认资源域名为 `https://cdn.joeyone.cn`,对象 key 自带 `doodle-assets/` 前缀。不要将该前缀重复拼接,也不要把 `source_ref` 的 cloud 地址直接当生产预览地址。
可在本机生成本批资源 URL 列表:
```bash
node --input-type=module - "$doodle_batch_dir" <<'JS'
import { readFileSync, writeFileSync } from 'node:fs';
import { resolve } from 'node:path';
const dir = resolve(process.argv[2]);
const payload = JSON.parse(readFileSync(resolve(dir, 'content.json'), 'utf8'));
const urls = payload.assets.map((a) =>
'https://cdn.joeyone.cn/' + a.object_key.split('/').map(encodeURIComponent).join('/'));
writeFileSync(resolve(dir, 'asset-urls.txt'), urls.join('\n') + '\n',
{ flag: 'wx', mode: 0o600 });
JS
```
浏览器抽查新旧预览,或逐一请求列表中的 URL。要求无 403/404 且图片可正常显示。若发现同 key 对象内容被改变,应按原同步报告摘要重新核验;仅有 HTTP 200 不证明文件内容相同。
### 8.3 Adminer 和后端验收边界
Adminer 选择 PostgreSQL,服务器填写 `1Panel-postgresql-RqP6:5432`,数据库为 `doodle_prod`,使用该库专属账号。不要填写默认 `db`;Adminer 自身容器内的 `127.0.0.1` 也不是 PostgreSQL。
API 尚未部署时,到此只能确认数据库与资源;不能声称小程序已联调成功。后端部署后再验证分类、题型、详情和预览接口。分类或其祖先为 draft/hidden 时,公开查询不返回是正常发布控制,不要为了通过验收批量改成 active。
后续 CloudBase 用户/统计整合、页面重建和正式切换完成前,不将本阶段归零的真实计数视为最终线上统计。
## 9. 导入后备份、失败处理和回退
1. 成功后按第 6.2 节方法创建 `after-content.dump`,使用新的备份目录/文件名;保留本批原始导出、manifest、摘要、审计和导入前备份。
2. 在独立恢复验证库试恢复 `after-content.dump`,确认表结构、数据和迁移历史都可读。仅执行 `pg_restore --list` 不足以完成恢复演练。
3. 导入事务失败:本批数据和审计整批回滚。排查后可在仍为空的目标重试;不要通过删除约束绕过错误。
4. 导入成功但需要撤回:先停止连接新目标库的应用写入并保留当前备份,在另一个空库恢复导入前备份、对账后再安排目标切换。本文不提供自动删生产库、清表或 `--clean` 命令。
5. 生产库已经有新用户或用户操作后,不再用本阶段快照覆盖整库;改用受控的内容更新和后续 CloudBase 合并流程。
可在 1Panel 另建空的恢复验证库,再在云端运行以下命令;这里只验证备份,不覆盖 `doodle_prod`:
```bash
docker exec -i "$doodle_pg_container" sh -c \
'exec pg_restore -U "$POSTGRES_USER" --dbname doodle_restore_check_20261020 --no-owner --no-acl --exit-on-error' \
< /实际备份目录/after-content.dump
```
验证库恢复时省略原所有权/ACL,便于检查归档内容;这不代表生产权限已经配置完成。真实恢复上线还需按目标所有者和应用账号恢复/核对权限。备份副本需要另存服务器之外。
## 10. 常见错误
| 现象 | 处理 |
| ------------------------------------- | --------------------------------------------------------------------------------------- |
| `getaddrinfo for db failed` | Adminer 服务器填实际 PostgreSQL 容器名,且两者在可通信的 Docker 网络 |
| 连接拒绝 / SSH 本机端口冲突 | 核对 SSH 登录、隧道是否仍运行、云端宿主机映射;改本机转发端口时同步改连接文件和保护检查 |
| `permission denied for schema public` | 核对数据库 owner;Prisma 迁移使用数据库所有者,不使用只具备 DML 的运行账号 |
| 迁移名称/校验和或字段签名不一致 | 先核对仓库版本和两端迁移状态,不跳过模板检查或伪造迁移记录 |
| 目标三张表必须为空 | 可能已经成功导入,或有其他批次;核对审计及数据,不重复清空导入 |
| 内容资产含未迁移用户归属 | 先核对生产用户映射;不要自动置空归属或顺带复制开发用户 |
| 资产未就绪/用途不匹配 | 回到本地完成资源核验或修正引用后重新导出,不伪造 ready |
| 已有年级/资源关联,或资源型题型 | 本手册的三表范围已不足,扩展完整内容批次后重新试导 |
| 分类循环或引用缺失 | 在内容源修复并重新导出;不要关闭数据库约束 |
| JSON 文件只有错误输出或为空 | 导出未成功,保留错误信息排查;不要继续打包 |
| SQL 输出 INSERT 后仍报错 | 没有最终提交标记就不算成功;查询目标确认事务回滚状态 |
## 11. 实施与验证记录
本手册交付范围是文档及配套 SQL 模板。2026-10-20 验证了文档中 17 段 Bash 命令与 5 段 Node.js 代码的语法,并从文档直接提取打包代码进行本机隔离库验证:
- 正常导入:52 条资产、7 条分类、52 条题型及 1 条迁移审计,提交前逐字段核对通过。
- 重复导入:被空表保护拒绝,已有数据保持不变。
- 错误目标库:被库名保护拒绝。
- 迁移校验和不一致:拒绝导入,业务表保持为空。
- 题型 CHECK 约束失败:之前已插入的资产和分类随事务全部回滚,未留下迁移审计。
- 计数:真实 downloads/likes 均为 0,downloads_seed=629、likes_seed=666,保留源值。
验证创建的三个隔离数据库已清理。源 doodle_dev 仅只读导出,未改动;未连接云端执行导入,也未在本次验证中请求七牛或测试 SSH/1Panel 上传。迁移快照、语法检查和回滚验证不能替代实际云端连接、生产权限和资源可访问性验收。
参考:[生产数据整合与导入方案](./生产数据整合与导入方案.md)、[Prisma 迁移说明](../../apps/server/prisma/README.md)、[PostgreSQL 18 psql](https://www.postgresql.org/docs/18/app-psql.html)、[PostgreSQL 18 JSON 函数](https://www.postgresql.org/docs/18/functions-json.html)、[Prisma 7 migrate deploy](https://www.prisma.io/docs/cli/v7/migrate/deploy)。