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

28 KiB
Raw Blame History

分类与题型首次导入操作手册

更新日期: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. 推荐操作路线

本机 doodle_dev(只读)
    → 导出 content.json
    → 打包 content-import.sql
    → 本机独立整合库试导、验证
    → 上传文件到云服务器
    → 在 1Panel 终端内执行 SQL
    → doodle_prod 验证、备份

第一次先导入 doodle_migration_20261020 进行试导。该库是一次性迁移整合库,不替代 doodle_dev,也无需常驻另一套服务器。

云端 5432 不需要向公网放行。 数据文件通过 1Panel 上传,SQL 在 PostgreSQL 容器内部执行。云端首次建表可按 Adminer 建表指南 上传初始化 SQL,不依赖本机 SSH;下文另保留通过 SSH 隧道执行 Prisma 的备选方式。

现有旧 CloudBase 云函数、数据库、预拉取文件和任务持续保留。本次首次建库期间,不启动连接目标库的新 API 或后台任务,避免新写入影响对账。

3. 配套文件、适用条件和限制

配套模板:

模板已包含:目标库名保护、迁移校验和与字段签名核对、分类/资产依赖检查、分类循环检查、首次空表保护、事务回滚、逐字段对账及迁移审计。

执行前确认:

  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 进入项目并确认状态

以下本机命令在同一个终端中顺序执行,所有文件路径均以仓库根目录为基准。每一步出现错误就停止,不继续后续步骤。

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 执行只读导出

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 模板,生成三个辅助文件。它不会连接数据库。

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

打包后的目录:

content.json          原始内容及资产快照
content-import.sql    可直接交给 psql 的整批事务
manifest.json         本批时间、数量和源文件摘要
SHA256SUMS            上传前后校验文件完整性

原始 JSON 原封不动编码进 SQL,由 PostgreSQL 自己恢复 bigint 等字段。Node.js 只读取数组长度等元数据,不将解析后的数值重新序列化为导入数据,避免大整数精度损失。Base64 用于安全封装 JSON,不是加密。

打包路径已存在时会报错;保留上一批次,重新导出到新目录,不覆盖原始文件。

5. 本机:建立独立整合库并试导

5.1 创建空库

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,也不打印密码。

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 迁移创建结构

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

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 检查,若重装过实例先从容器列表核对。

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 创建导入前备份

即使刚创建空库,也记录导入前状态。以下命令在云服务器终端执行:

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 连接云端

如果已按 首次建表指南 完整恢复初始化 SQL,并核对 14 张表和三条迁移记录,本节可跳过,直接执行第 7 节内容导入。不要再次手工建表或登记相同迁移。

在本机另一个终端启动隧道:

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 的独立配置文件:

touch .env.production-import
chmod 600 .env.production-import

使用编辑器填入以下内容。若文件已存在,先核对其中内容,避免误用别的环境;密码中的特殊字符需要 URL 编码。

DATABASE_URL=postgresql://doodle_prod_owner:填写URL编码后的密码@127.0.0.1:15432/doodle_prod?schema=public

执行迁移:

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 文件管理中创建专属批次目录,例如:

/opt/doodle/imports/content-20261020-01/

上传本机已经试导成功的四个文件:content.json、content-import.sql、manifest.json、SHA256SUMS。不要上传 .env、密码、Mac 的 node_modules 或本地整库备份。

在 1Panel 终端中执行:

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 复核目标为空

在云端执行:

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 执行正式内容导入

仍在云端批次目录执行:

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:

-- 先确认正在检查的库。
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 列表:

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:

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 上传。迁移快照、语法检查和回滚验证不能替代实际云端连接、生产权限和资源可访问性验收。

参考:生产数据整合与导入方案、Prisma 迁移说明、PostgreSQL 18 psql、PostgreSQL 18 JSON 函数、Prisma 7 migrate deploy。