527 lines
28 KiB
Markdown
527 lines
28 KiB
Markdown
# 分类与题型首次导入操作手册
|
||
|
||
更新日期: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)。
|