feat: 数据库导出文档

This commit is contained in:
R524809
2026-10-20 17:29:10 +08:00
parent 48c45ada89
commit 69ec885803
9 changed files with 2299 additions and 4 deletions
+17 -3
View File
@@ -1,6 +1,6 @@
# Doodle 云端部署准备 # Doodle 云端部署准备
更新时间:2026-10-19。本文件记录部署方案与待确认条件。已在 1Panel 申请根域及泛域名证书;尚未创建 Doodle 云端应用、应用域名 DNS 记录或镜像仓库,也未改动旧线上云开发链路。 更新时间:2026-10-20。本文件记录部署方案与待确认条件。已在 1Panel 申请根域及泛域名证书,并安装 PostgreSQL;尚未创建 Doodle 云端应用、应用域名 DNS 记录或镜像仓库,也未改动旧线上云开发链路。
## 已确认条件 ## 已确认条件
@@ -24,6 +24,20 @@
现有已发布小程序继续使用原云开发。保留全部旧云函数、预拉取模式、地址、生成/发布与定时链路。开发版跳过旧线上预拉取,体验/正式包保留原有预拉取行为;本次不关闭旧服务。 现有已发布小程序继续使用原云开发。保留全部旧云函数、预拉取模式、地址、生成/发布与定时链路。开发版跳过旧线上预拉取,体验/正式包保留原有预拉取行为;本次不关闭旧服务。
## 本次数据库迁移范围(2026-10-20 更新)
最新来源策略为:本地 `doodle_dev` 提供已整理的分类、题型、内容关联和七牛映射;CloudBase 提供最新用户、收藏、下载记录及题型真实计数。这取代此前“只导内容”和“直接全量复制本地库”的约定。
建议先在本机独立迁移库整合,再统一导入 `doodle_prod`,保留 `doodle_dev` 作为内容来源。`worksheets` 的内容/seed 与真实计数分字段合并;用户头像以线上最新绑定核验七牛映射;新后端页面配置和任务状态重新生成或初始化。完整逐表规则及依赖、身份、计数和补导处理见 [生产数据整合与导入方案](../docs/v3.5/生产数据整合与导入方案.md)。
保留正式表结构、约束、索引和真实 Prisma 迁移历史;数据库集群账号/密码及服务端运行密钥单独配置。旧 CloudBase 服务持续保留,一次快照导入不代表实时同步。当前只整理方案,尚未执行导出、整合或云端导入。
第一阶段的具体命令与试导结果见 [分类与题型首次导入操作手册](../docs/v3.5/分类与题型首次导入操作手册.md),配套 SQL 位于 `deploy/postgres/content/`。文档验证仅在本地隔离库执行,不表示云端已导入。
云端空库也可先通过 Adminer 执行 [initialize-doodle-prod.sql](./postgres/initialize-doodle-prod.sql) 初始化结构,无需先解决本机 DBX/SSH 连接。该文件由独立空库的正式迁移结果生成,含真实 Prisma 历史,不含开发业务数据;已验证恢复及 Prisma 接续兼容。按 [首次建表指南](../docs/v3.5/云端数据库首次建表指南.md) 操作,当前尚未在云端执行。
PostgreSQL 已由用户选择通过 1Panel 安装管理,2026-10-20 核查为 `postgres:18.6-alpine`,容器名 `1Panel-postgresql-RqP6`。不再启动本目录先前准备的 `compose.database.yaml` 创建第二套数据库实例;下文关于项目管理 PostgreSQL 编排的早期建议以此决策为准。
## 建议部署结构 ## 建议部署结构
采用“1Panel 管理入口,项目维护应用编排”的组合方式: 采用“1Panel 管理入口,项目维护应用编排”的组合方式:
@@ -121,7 +135,7 @@ API、PostgreSQL 的 Docker Compose、镜像版本、环境变量示例和备份
3. 准备数据库持久化、备份和恢复流程;备份副本需要保存到服务器之外。 3. 准备数据库持久化、备份和恢复流程;备份副本需要保存到服务器之外。
4. 核对基础镜像及应用镜像从服务器的拉取能力,再确定仓库分发或首次离线交付。 4. 核对基础镜像及应用镜像从服务器的拉取能力,再确定仓库分发或首次离线交付。
5. 确认 API 域名并配置 DNS、1Panel 反向代理,绑定已签发的证书 ID 11,验收 HTTPS 与续签部署链路。 5. 确认 API 域名并配置 DNS、1Panel 反向代理,绑定已签发的证书 ID 11,验收 HTTPS 与续签部署链路。
6. 初始化单套云端数据库、执行迁移、受控导入内容;不整库复制开发用户和临时数据。 6. 按最新方案将本地内容与 CloudBase 最新用户业务数据整合,对账后迁入 1Panel 管理的 `doodle_prod` 并备份。
7. 完成微信服务端出口 IP 与合法域名配置,再更新小程序 production 接口地址供体验版验收。 7. 完成微信服务端出口 IP 与合法域名配置,再更新小程序 production 接口地址供体验版验收。
当前仅完成 1Panel 泛域名证书申请,未部署 Doodle 云端应用或数据库。公网入口与云端数据库验收完成前,不将开发机的 `127.0.0.1` 地址当作可用的体验版接口。 当前已完成 1Panel 泛域名证书申请及 PostgreSQL 实例安装,尚未执行双来源数据整合与导入,也未部署 Doodle 云端应用。公网入口与云端数据库验收完成前,不将开发机的 `127.0.0.1` 地址当作可用的体验版接口。
+162
View File
@@ -0,0 +1,162 @@
-- 由文档中的打包步骤在事务和 COPY 数据段之后拼接;不要单独执行此文件。
DO $$
DECLARE
payload jsonb;
actual_migrations jsonb;
actual_columns jsonb;
BEGIN
IF current_database() <> 'doodle_prod'
AND current_database() !~ '^doodle_migration_[0-9]{8}(_[a-z0-9]+)?$' THEN
RAISE EXCEPTION '目标只能是 doodle_prod 或独立的 doodle_migration_YYYYMMDD 库';
END IF;
IF (SELECT count(*) FROM content_import_encoded) <> 1 THEN
RAISE EXCEPTION '导入包必须只有一份内容快照';
END IF;
SELECT convert_from(decode(body, 'base64'), 'UTF8')::jsonb
INTO STRICT payload FROM content_import_encoded;
IF payload->>'format' IS DISTINCT FROM 'doodle-content-v1'
OR payload->>'source_database' IS DISTINCT FROM 'doodle_dev'
OR jsonb_typeof(payload->'assets') IS DISTINCT FROM 'array'
OR jsonb_typeof(payload->'categories') IS DISTINCT FROM 'array'
OR jsonb_typeof(payload->'worksheets') IS DISTINCT FROM 'array'
OR payload->'excluded_relations' IS DISTINCT FROM
'{"worksheet_grades":0,"worksheet_resources":0,"worksheet_resource_assets":0}'::jsonb THEN
RAISE EXCEPTION '导入包格式错误,或已有年级/资源关联需要扩展本批范围';
END IF;
IF jsonb_array_length(payload->'categories') = 0
OR jsonb_array_length(payload->'worksheets') = 0 THEN
RAISE EXCEPTION '本批分类和题型不能为空';
END IF;
IF EXISTS (SELECT 1 FROM public._prisma_migrations
WHERE finished_at IS NULL AND rolled_back_at IS NULL) THEN
RAISE EXCEPTION '目标存在未完成迁移';
END IF;
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'name', migration_name, 'checksum', checksum
) ORDER BY migration_name), '[]'::jsonb)
INTO actual_migrations FROM public._prisma_migrations
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL;
IF actual_migrations IS DISTINCT FROM payload->'migration_history' THEN
RAISE EXCEPTION '源库与目标的迁移名称/校验和不一致';
END IF;
SELECT jsonb_agg(jsonb_build_object(
'table', c.relname, 'column', a.attname,
'type', format_type(a.atttypid, a.atttypmod), 'not_null', a.attnotnull
) ORDER BY c.relname, a.attname)
INTO actual_columns
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname IN ('assets', 'categories', 'worksheets')
AND a.attnum > 0 AND NOT a.attisdropped;
IF actual_columns IS DISTINCT FROM payload->'column_signature' THEN
RAISE EXCEPTION '源库与目标字段结构不一致,停止以免丢失字段';
END IF;
END $$;
CREATE TEMP TABLE content_import_payload ON COMMIT DROP AS
SELECT convert_from(decode(body, 'base64'), 'UTF8')::jsonb AS body
FROM content_import_encoded;
CREATE TEMP TABLE content_import_assets ON COMMIT DROP AS
SELECT a.* FROM content_import_payload p,
LATERAL jsonb_populate_recordset(NULL::public.assets, p.body->'assets') a;
CREATE TEMP TABLE content_import_categories ON COMMIT DROP AS
SELECT c.* FROM content_import_payload p,
LATERAL jsonb_populate_recordset(NULL::public.categories, p.body->'categories') c;
CREATE TEMP TABLE content_import_worksheets ON COMMIT DROP AS
SELECT w.* FROM content_import_payload p,
LATERAL jsonb_populate_recordset(NULL::public.worksheets, p.body->'worksheets') w;
-- 这一步只初始化内容。真实运营计数之后由 CloudBase 最新快照赋值,不复制开发计数。
UPDATE content_import_worksheets SET downloads = 0, likes = 0;
LOCK TABLE public.assets, public.categories, public.worksheets IN SHARE ROW EXCLUSIVE MODE;
DO $$
BEGIN
IF EXISTS (SELECT 1 FROM public.assets)
OR EXISTS (SELECT 1 FROM public.categories)
OR EXISTS (SELECT 1 FROM public.worksheets) THEN
RAISE EXCEPTION '目标三张表必须为空;本流程不覆盖、清空或重复合并已有内容';
END IF;
IF EXISTS (SELECT 1 FROM content_import_assets
WHERE provider IS DISTINCT FROM 'qiniu' OR visibility IS DISTINCT FROM 'public'
OR status IS DISTINCT FROM 'ready'
OR purpose NOT IN ('preview', 'category_icon')) THEN
RAISE EXCEPTION '本批只允许就绪的七牛公开预览/分类图标资产';
END IF;
IF EXISTS (SELECT 1 FROM content_import_assets a
WHERE a.owner_user_id IS NOT NULL
AND NOT EXISTS (SELECT 1 FROM public.users u WHERE u.id = a.owner_user_id)) THEN
RAISE EXCEPTION '内容资产含未迁移的用户归属,须先处理用户映射;不自动清空 owner';
END IF;
IF EXISTS (SELECT 1 FROM content_import_worksheets w
WHERE w.generation_mode <> 'procedural') THEN
RAISE EXCEPTION '资源型题型需要同时迁移完整资源关联,本批不能只导题型父表';
END IF;
IF EXISTS (SELECT 1 FROM content_import_worksheets w
WHERE NOT EXISTS (SELECT 1 FROM content_import_categories c WHERE c.id = w.category_id)
OR NOT EXISTS (SELECT 1 FROM content_import_assets a
WHERE a.id = w.preview_asset_id AND a.purpose = 'preview'))
OR EXISTS (SELECT 1 FROM content_import_categories c
WHERE (c.parent_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM content_import_categories p WHERE p.id = c.parent_id))
OR (c.icon_asset_id IS NOT NULL AND NOT EXISTS (
SELECT 1 FROM content_import_assets a WHERE a.id = c.icon_asset_id
AND a.purpose = 'category_icon'))) THEN
RAISE EXCEPTION '分类父级、题型分类或资产引用缺失/用途不匹配';
END IF;
IF EXISTS (SELECT 1 FROM content_import_assets a
WHERE NOT EXISTS (SELECT 1 FROM content_import_worksheets w WHERE w.preview_asset_id = a.id)
AND NOT EXISTS (SELECT 1 FROM content_import_categories c WHERE c.icon_asset_id = a.id)) THEN
RAISE EXCEPTION '导入包含本批内容未引用的资产';
END IF;
IF EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id, parent_id, ARRAY[id::text] AS path, false AS cycle
FROM content_import_categories
UNION ALL
SELECT a.id, c.parent_id, a.path || c.id::text, c.id = ANY(a.path)
FROM ancestors a JOIN content_import_categories c ON c.id = a.parent_id
WHERE NOT a.cycle
) SELECT 1 FROM ancestors WHERE cycle
) THEN
RAISE EXCEPTION '分类存在循环父子关系';
END IF;
END $$;
-- 单条分类 INSERT 包含所有父子行;语句结束时检查外键,无需关闭约束或触发器。
INSERT INTO public.assets SELECT * FROM content_import_assets;
INSERT INTO public.categories SELECT * FROM content_import_categories;
INSERT INTO public.worksheets SELECT * FROM content_import_worksheets;
-- 提交前逐字段核对;统计字段与已经归零的临时数据比较。
DO $$
BEGIN
IF EXISTS (SELECT * FROM public.assets EXCEPT SELECT * FROM content_import_assets)
OR EXISTS (SELECT * FROM content_import_assets EXCEPT SELECT * FROM public.assets)
OR EXISTS (SELECT * FROM public.categories EXCEPT SELECT * FROM content_import_categories)
OR EXISTS (SELECT * FROM content_import_categories EXCEPT SELECT * FROM public.categories)
OR EXISTS (SELECT * FROM public.worksheets EXCEPT SELECT * FROM content_import_worksheets)
OR EXISTS (SELECT * FROM content_import_worksheets EXCEPT SELECT * FROM public.worksheets) THEN
RAISE EXCEPTION '写入结果与导入包不一致,整批回滚';
END IF;
END $$;
INSERT INTO public.admin_audit_logs (
id, actor_type, action, resource_type, request_id, after_data, reason
)
SELECT
'content-import-' || current_setting('doodle.content_import_sha256'),
'migration', 'content.import', 'content-batch',
'content-import-' || current_setting('doodle.content_import_sha256'),
jsonb_build_object(
'assets', (SELECT count(*) FROM content_import_assets),
'categories', (SELECT count(*) FROM content_import_categories),
'worksheets', (SELECT count(*) FROM content_import_worksheets),
'sourceDatabase', body->>'source_database',
'exportedAt', body->>'exported_at',
'sourceSha256', current_setting('doodle.content_import_sha256'),
'statisticsState', 'pending-cloudbase'
),
'本地内容初始化;资产与业务 ID 保留,downloads/likes 初始化为 0,待 CloudBase 最新统计覆盖。'
FROM content_import_payload;
COMMIT;
\echo CONTENT_IMPORT_COMMITTED
+65
View File
@@ -0,0 +1,65 @@
\set ON_ERROR_STOP on
BEGIN ISOLATION LEVEL REPEATABLE READ READ ONLY;
SET LOCAL TIME ZONE 'UTC';
-- 只读导出本地内容,防止误把另一环境的数据当作本次内容来源。
DO $$
BEGIN
IF current_database() <> 'doodle_dev' THEN
RAISE EXCEPTION '导出只允许读取 doodle_dev';
END IF;
IF EXISTS (
SELECT 1 FROM public._prisma_migrations
WHERE finished_at IS NULL AND rolled_back_at IS NULL
) THEN
RAISE EXCEPTION '源库存在未完成迁移,请先核查';
END IF;
END $$;
WITH asset_ids AS (
SELECT preview_asset_id AS id FROM public.worksheets
UNION
SELECT icon_asset_id FROM public.categories WHERE icon_asset_id IS NOT NULL
)
SELECT jsonb_build_object(
'format', 'doodle-content-v1',
'source_database', current_database(),
'exported_at', transaction_timestamp(),
'migration_history', (
SELECT COALESCE(jsonb_agg(jsonb_build_object(
'name', migration_name, 'checksum', checksum
) ORDER BY migration_name), '[]'::jsonb)
FROM public._prisma_migrations
WHERE finished_at IS NOT NULL AND rolled_back_at IS NULL
),
-- 按字段名比较签名,兼容经旧 SQL 初始化、字段物理顺序不同的本地库。
'column_signature', (
SELECT jsonb_agg(jsonb_build_object(
'table', c.relname, 'column', a.attname,
'type', format_type(a.atttypid, a.atttypmod), 'not_null', a.attnotnull
) ORDER BY c.relname, a.attname)
FROM pg_attribute a
JOIN pg_class c ON c.oid = a.attrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname = 'public' AND c.relname IN ('assets', 'categories', 'worksheets')
AND a.attnum > 0 AND NOT a.attisdropped
),
'excluded_relations', jsonb_build_object(
'worksheet_grades', (SELECT count(*) FROM public.worksheet_grades),
'worksheet_resources', (SELECT count(*) FROM public.worksheet_resources),
'worksheet_resource_assets', (SELECT count(*) FROM public.worksheet_resource_assets)
),
'assets', (
SELECT COALESCE(jsonb_agg(to_jsonb(a) ORDER BY a.id), '[]'::jsonb)
FROM public.assets a JOIN asset_ids r ON r.id = a.id
),
'categories', (
SELECT COALESCE(jsonb_agg(to_jsonb(c) ORDER BY c.id), '[]'::jsonb)
FROM public.categories c
),
'worksheets', (
SELECT COALESCE(jsonb_agg(to_jsonb(w) ORDER BY w.id), '[]'::jsonb)
FROM public.worksheets w
)
);
COMMIT;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,148 @@
# 在 1Panel 环境初始化 doodle_prod 表结构
更新日期:2026-10-20。本页只说明首次建表,不导入本地分类/题型或 CloudBase 用户业务数据。已在本机隔离库验证,尚未执行云端建表。
## 1. 使用哪份文件
使用 [initialize-doodle-prod.sql](../../deploy/postgres/initialize-doodle-prod.sql),约 43 KB。
它从独立空库执行项目当前三次正式 Prisma 迁移后的结果生成,包含:
- 13 张完整业务表,含外键、唯一索引、部分索引、CHECK 约束和字段注释;
- 1 张 `_prisma_migrations` 技术表,包含实际执行产生的迁移历史及校验和;
- `page_configs/current` 和 `settings/homeAutoRefresh` 两条必要默认配置。
没有从 `doodle_dev` 复制分类、题型、用户或其他业务数据,也没有数据库账号密码、JWT 或微信/七牛密钥。原 CloudBase 服务和任务不受影响。
文件为普通 PostgreSQL 18 SQL,可用于 Adminer;没有 `psql` 专用命令或 `COPY FROM STDIN` 数据段。整个初始化包在单个事务中执行,错误会回滚。
## 2. 从 Adminer 登录云端 PostgreSQL
打开你已安装的 Adminer。填写:
| 配置 | 内容 |
| ------ | -------------------------------------------------- |
| 系统 | PostgreSQL |
| 服务器 | `1Panel-postgresql-RqP6:5432` |
| 用户名 | `joey_postgresql`,当前已核查为 doodle_prod 所有者 |
| 密码 | 该数据库账号的密码 |
| 数据库 | `doodle_prod` |
密码在 Adminer 中自行填写,不发到聊天。容器名若因重装改变,先在 1Panel 容器列表核对;Adminer 和 PostgreSQL 需要有可通信的内部 Docker 网络。
这里由服务器上的 Adminer 连接 PostgreSQL,不依赖本机 DBX 直连,也无需为了建表开放公网 5432。
## 3. 先确认选中了空库
进入「SQL 命令」,执行:
```sql
SELECT current_database(), current_user;
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
```
第一条的库名必须是 `doodle_prod`。第二条应无表。
如果已有表,先判断是否已经初始化,不执行清表或删库。初始化文件也会拒绝含用户表、视图或序列的目标,不会覆盖已有结构。
## 4. 上传并执行建表 SQL
1. 在 Adminer 的「SQL 命令」页面,使用文件上传/选择文件入口。
2. 选择本机文件:`/Users/joey-xd/sites/doodle/doodle-store/deploy/postgres/initialize-doodle-prod.sql`。
3. 保持遇到错误停止,不选择忽略错误继续。
4. 执行并检查结果,要求没有 SQL 错误。
也可以使用编辑器打开文件,全选复制到 Adminer 的 SQL 输入框,一次执行完整文件。不要只选执行其中一段:前后包含事务、空库保护和提交前校验。
不要改文件中的目标库名或移除保护;它只允许首次初始化空的 `doodle_prod`(另外保留了本机隔离恢复验证库名模式)。
如果出现报错,先保留错误信息。完整文件的业务 DDL 与默认数据在同一事务中,失败时不会提交半套结构;不要重复执行局部 CREATE TABLE 试图拼补。
## 5. 检查是否成功
重新查看表列表,应该有 14 张表:
```sql
SELECT tablename
FROM pg_tables
WHERE schemaname = 'public'
ORDER BY tablename;
```
分别是:
```text
_prisma_migrations
admin_audit_logs
assets
categories
download_logs
favorites
job_runs
page_configs
settings
users
worksheet_grades
worksheet_resource_assets
worksheet_resources
worksheets
```
检查迁移记录和默认配置:
```sql
SELECT migration_name, finished_at, rolled_back_at
FROM public._prisma_migrations
ORDER BY migration_name;
SELECT id, version FROM public.page_configs;
SELECT id, value FROM public.settings;
SELECT
(SELECT count(*) FROM public.categories) AS categories,
(SELECT count(*) FROM public.worksheets) AS worksheets,
(SELECT count(*) FROM public.assets) AS assets,
(SELECT count(*) FROM public.users) AS users;
```
应有三条迁移,均 finished_at 非空、rolled_back_at 为空:
```text
0_v35_baseline
20261016000000_category_publication
20261019000000_member_assets
```
默认配置应有 `current` 与 `homeAutoRefresh`。四个业务计数应全部为 0,说明只是建表,尚未导入任何业务内容。
执行默认配置 INSERT 不会启动任务进程;以后新后端启动时才按其设置运行,旧 CloudBase 的开关和任务持续独立。
## 6. 完成后做什么
1. 在 1Panel 为刚初始化的 `doodle_prod` 创建一份备份。
2. 继续导入本地分类、题型及它们引用的必要资产;详细范围见 [首次内容导入手册](./分类与题型首次导入操作手册.md)。
3. 后续再按 [生产数据整合与导入方案](./生产数据整合与导入方案.md) 迁移 CloudBase 用户业务和最新统计。
4. 后端应用使用独立运行账号,不能把实例管理账号直接作为长期运行配置。账号授权在后端部署时单独设置。
这份 SQL 是当前三次迁移对应的首次空库快照,不能用于已有生产库升级。后续新增表或字段仍通过仓库正式 Prisma 迁移发布,不编辑已应用的历史迁移。
本机验证已经确认:快照恢复后 `prisma migrate status` 正常,随后 `migrate deploy` 没有待执行迁移,无需手工补写或再次登记这三条迁移。
## 7. 生成与验证记录
- 2026-10-20:在本机独立空库运行项目锁定的 Prisma 7.10.0 迁移,再导出普通 INSERT SQL;没有读取开发库业务表。
- 已核对快照迁移校验和与仓库原文件一致。
- 在第二个空库恢复成功,确认 14 张表、3 条迁移历史和 2 条默认配置。
- 重复初始化和错误目标库保护通过。
- 恢复后 Prisma 状态检查及无待执行迁移的 deploy 验证通过。
- 所有本次构建/验证临时库均已删除,云端未执行初始化。
SHA-256:
```text
b96493a163a20ca4fec4c597e4eb4c282361050a373a442f6651b2af25c56b3f
```
@@ -0,0 +1,526 @@
# 分类与题型首次导入操作手册
更新日期: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)。
+3 -1
View File
@@ -2,6 +2,8 @@
更新时间:2026-10-19。本次整理当前进度和后续任务,未执行云端部署。验证结果来自此前开发记录,本次文档更新未重新运行全套测试。 更新时间:2026-10-19。本次整理当前进度和后续任务,未执行云端部署。验证结果来自此前开发记录,本次文档更新未重新运行全套测试。
2026-10-20 最新决策:云端 PostgreSQL 由 1Panel 安装管理。本地 `doodle_dev` 提供已整理的内容及七牛映射,CloudBase 提供最新用户、收藏、下载记录及题型真实计数;建议在本机独立迁移库整合后导入 `doodle_prod`。此前“仅导内容”和“直接全量复制本地库”的约定均已被替代;逐表来源和补导边界见 [生产数据整合与导入方案](./生产数据整合与导入方案.md)。其余尚未更新的历史部署建议不覆盖本条决策。
## 1. 当前阶段与保留约束 ## 1. 当前阶段与保留约束
本地数据库、主要业务后端、小程序开发代码的接口迁移及七牛接入已完成。下一阶段先准备部署工程,再部署云端测试 API 和独立测试数据库,供小程序体验版联调。 本地数据库、主要业务后端、小程序开发代码的接口迁移及七牛接入已完成。下一阶段先准备部署工程,再部署云端测试 API 和独立测试数据库,供小程序体验版联调。
@@ -119,7 +121,7 @@
### B. 云端数据与运维 ### B. 云端数据与运维
- [ ] 使用 `prisma migrate deploy` 执行迁移,不使用开发期重置命令初始化云端业务库。 - [ ] 使用 `prisma migrate deploy` 执行迁移,不使用开发期重置命令初始化云端业务库。
- [ ] 准备受控内容初始化流程,迁入分类、题型、关联资产及必要配置;核对 ID、发布状态、计数、分类关联和预览,不整库复制测试用户及临时记录。 - [ ] 按 2026-10-20 最新方案整合本地内容/七牛映射与 CloudBase 最新用户业务/统计;先在独立迁移库转换和对账,再迁入云端,账号和运行密钥单独配置。
- [ ] 配置数据库持久化、异机备份、恢复与迁移失败处理,并完成一次恢复验证。 - [ ] 配置数据库持久化、异机备份、恢复与迁移失败处理,并完成一次恢复验证。
- [ ] 保留上一可运行后端版本,明确应用回滚与数据库迁移的兼容边界。 - [ ] 保留上一可运行后端版本,明确应用回滚与数据库迁移的兼容边界。
@@ -0,0 +1,84 @@
# 生产数据整合与导入方案
更新时间:2026-10-20。本文整理本轮最新的数据来源与推荐导入流程,尚未执行导出、合并或云端导入。
## 1. 最新范围与数据来源
目标是建立同时包含本地已整理内容与 CloudBase 最新用户业务数据的 `doodle_prod`。本条替代此前“只导内容”和“直接整库复制本地 doodle_dev”两种约定。
- 内容来源:本地 `doodle_dev` 中已修改的分类、题型、内容关联和七牛资源映射。
- 用户业务来源:小程序实际使用的 CloudBase 环境中最新的用户、收藏和下载记录。
- 统计、头像、设置等跨来源数据按下表逐字段处理,不用任何一个来源整库覆盖另一个来源。
- 原始源文件和本地开发库保留;先在独立迁移库整合和验证,再导入云端。旧云函数、旧数据、预拉取文件及定时任务继续保留,本次不自动修改或停用。
## 2. 表与字段来源
当前 Prisma 定义 13 张业务表,另有 `_prisma_migrations` 技术表。以下为推荐执行规则,实际导入前预演并列出冲突。
| 目标表 | 数据来源与处理 |
| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `categories` | 本地。保留分类 ID、名称、层级、排序、发布状态及图标引用。 |
| `worksheets` | 内容字段取本地:标题、分类、路径、年龄/年级、难度、标签、排序、运营标记、发布状态、生成配置与预览资产引用。按相同 worksheet ID 合并 CloudBase 最新 `downloads` / `likes`;`downloads_seed` / `likes_seed` 保留本地值,不与真实计数混加。 |
| `worksheet_grades` | 本地。保留经本地维护的适用年级关系,不从旧兼容 grade 自动推断。 |
| `worksheet_resources` | 本地。保留内容资源及预览关系。 |
| `worksheet_resource_assets` | 本地。保留内容资源与文件关联。 |
| `assets` | 内容资源映射取本地;用户头像按 CloudBase 最新 `avatarUrl` 匹配已核验的七牛对象,缺失时补迁文件并建立资产。不能整表沿用本地旧头像绑定。 |
| `users` | CloudBase。保留有效的源 `_id → id`、OpenID、UnionID、昵称与原始时间;头像转换为新 `avatar_asset_id`。本地同一微信用户按 OpenID 对照,不带入本地测试身份、管理员授权或凭证状态。 |
| `favorites` | CloudBase 最新完整快照。按迁移后的 user ID 和稳定 worksheet ID 建立关联;校验唯一性,反映已取消的收藏,不将本地测试收藏合入。 |
| `download_logs` | CloudBase。保留原 ID、用户/题型引用、时间和已有 exportId;重复源记录、exportId 冲突及缺失关联进入报告,不对无 exportId 的不同历史记录擅自合并。 |
| `settings` | 逐项转换。导出 CloudBase 设置作为对照,迁移需要继续使用的业务配置;新后端专有配置单独初始化。旧 `homeAutoRefresh` 控制旧云任务,新开关控制新后端任务,不能把二者视为同一个执行器。 |
| `page_configs` | 在数据整合后由新后端重建。保留两边旧快照作核对,不直接恢复含旧资源地址或旧统计的页面缓存,不修改旧线上预拉取文件。 |
| `job_runs` | 初始化新后端任务状态。本地旧任务记录保留在源库/归档中供核对,不让开发环境的运行锁、租约或任务结果成为生产任务状态。 |
| `admin_audit_logs` | 为本次导入生成带来源、批次和摘要的迁移审计。本地历史审计保留归档,不直接当作已在线上发生的管理员操作。 |
| `_prisma_migrations` | 迁移库由仓库锁定的正式迁移初始化,保留其真实执行历史;若最终导出包含结构和该表,恢复后检查迁移状态,不重新手工建表。 |
所有源 ID 和业务计数的最终值均需报告;用户字段或设置缺少对应目标字段时采用明确映射,不透传未知字段。
## 3. 合并时需要单独处理的问题
### 题型计数
旧云函数会实时更新 CloudBase `worksheets.downloads` 和 `likes`,因此这两个字段不能取本地旧值,也不能在导入日志/收藏时再累加一次。旧计数写入与记录写入并非同一事务;先保留线上源计数,同时统计日志/收藏与计数的差异,不未经核实就用记录条数覆盖历史运营计数。
CloudBase 中不存在的本地新题型保留内容及展示 seed,真实计数建议初始化为 0,排除开发操作产生的计数。CloudBase 中存在、但本地不存在的题型进入差异清单;先补齐内容和七牛映射,再处理相关收藏/下载,不丢弃线上记录或用旧预览地址覆盖本地内容。
### 用户身份、头像和权限
CloudBase `_id` 保留为生产 user ID,同一小程序 AppID 下的 OpenID 用于核对本地与线上的相同身份,昵称和头像不能用作身份匹配。若本地资产归属使用了另一个 user ID,需要按已核对的身份映射转换;无法匹配的归属进入冲突清单。
旧用户结构没有新后端的完整权限与凭证字段。缺失时按新后端规则初始化 `role=user`、`status=active`、`auth_version`;生产管理员另按明确授权设置,不继承本地开发授权。线上存在的状态/权限字段须先核对其真实含义,不能盲目套用本地值。
`users.total_downloads` 需与 CloudBase 原始 `totalDownloads` 和有效下载记录对账。当前旧下载云函数未维护用户的 totalDownloads,不能只复制可能一直为 0 的字段;建议按去重和关联校验后的有效下载记录数建立该用户统计,并在报告中保留原值及调整理由。
头像必须以本次线上用户记录为准。已清除的头像保持为空;现有七牛对象只有在源引用和文件校验匹配时才复用。此前备份的头像可能过期,不能按文件名推断归属或默认认为用户仍在使用该图片。
CloudBase 旧注销逻辑仅删除 users,可能留下收藏或下载记录。找不到用户的记录进入隔离报告并保留原始导出,不为了满足关联而重新创建已注销账户。
### 快照时效与后续补导
一次导入得到的是某次快照,不会自动保持实时同步。CloudBase 继续接收用户操作时,导入后仍会新增下载、修改资料、取消收藏或删除账户;仅按时间追加新行无法完整处理这些变化。
正式切换前需另行安排最终快照/补导与对账;旧版本仍写 CloudBase 时还需要明确兼容或持续同步策略。本方案不以“已导入”为由自动停写旧服务,也不宣称新旧系统已实时一致。后续补导以 CloudBase 用户业务为来源,不再用本地旧整库备份覆盖已运行的生产库。
## 4. 推荐流程:先在本机独立迁移库整合
第一阶段分类、题型及必要资产的逐步命令、试导、云端连接和回退流程见 [分类与题型首次导入操作手册](./分类与题型首次导入操作手册.md)。该阶段真实题型计数暂置 0,后续仍按本方案从 CloudBase 最新快照赋值。
建议创建本地 `doodle_migration_YYYYMMDD` 作为本轮一次性整合库;它与日常 `doodle_dev` 分开,也不是需要长期维护的 staging 环境。
```text
本地 doodle_dev:内容与七牛映射 ─┐
├→ 本机独立迁移库 → 对账与恢复验证 → doodle_prod
CloudBase:最新用户业务与统计 ──┘
```
1. 保存两个来源的不可变导出、批次时间、条数与文件 SHA-256;CloudBase 导出覆盖 users、favorites、download_logs,以及用于计数/差异对照的 worksheets、categories、settings、page_configs。
2. 用项目正式 Prisma 迁移初始化独立迁移库,不修改 `doodle_dev`。
3. 导入本地内容及对应资产;若内容资产含用户归属,先按线上身份建立依赖所需的用户骨架。按分类父子及资源依赖顺序写入。
4. 导入 CloudBase 用户主体(头像先留空),核验/补迁头像资产后再绑定;处理 OpenID 重复、源 ID 冲突和用户归属映射。
5. 导入收藏、下载记录,合并线上题型计数,核对用户统计,完成设置转换和页面配置重建。
6. 对账内容完整性、源 ID、关联、记录数量、计数、头像归属、资源可访问性及管理员权限;冲突与隔离记录必须有处理结果或明确保留状态,不将它们默认为成功导入。
7. 对首次空目标制作完整 PostgreSQL 导入包,排除数据库集群角色/密码和本机运行密钥,试恢复验证后上传并导入 `doodle_prod`。若目标已使用,先核查并备份,改用可重复的受控合并流程,不直接覆盖。
8. 云端验证并备份;保留批次清单与映射,作为正式切换前补导、对账和回滚依据。
数据库密码、JWT 密钥、微信 AppSecret、七牛 AK/SK 不写入导出报告或源码。导入包包含用户数据,使用受控目录并通过 SSH/1Panel 传输;不要求开放 PostgreSQL 公网端口。
+109
View File
@@ -2,6 +2,115 @@
独立 Node.js 脚本项目,使用官方 qiniu SDK 7.15.2。入口命令在 doodle-store 根目录执行。只复制本地文件到已存在的七牛空间,不创建空间、不删除/覆盖远端对象、不修改数据库或业务 URL。 独立 Node.js 脚本项目,使用官方 qiniu SDK 7.15.2。入口命令在 doodle-store 根目录执行。只复制本地文件到已存在的七牛空间,不创建空间、不删除/覆盖远端对象、不修改数据库或业务 URL。
## 前置步骤:通过 tcb 下载云存储备份
更新日期:2026-10-20。本节记录日后重新导出资源的操作流程;本次编写文档未登录云环境或执行下载。已有备份位于 `/Users/joey-xd/sites/doodle/doodle-assets/cloud_backup`,无需为了使用同步工具重复下载。
流程为:`原小程序云存储 → tcb 下载到本地 → SOURCE_DIR 指向备份根目录 → asset-sync 上传七牛 → 按需回填数据库`。这里下载的是云存储文件,不是数据库记录、云函数代码或完整环境备份。
当前线上版小程序仍使用旧云开发,原预拉取模式、地址、生成链路和全部旧云函数必须保留。本流程只读取源文件,不删除源文件、不调整存储权限、不停用云函数或定时任务;后续的七牛同步也不会替代旧线上服务。
### 1. 安装并检查 CloudBase CLI
本机已安装 `@cloudbase/cli`,本次通过版本及帮助命令核对的版本为 **3.8.7**。新机器可安装同一版本复现操作;已有 tcb 时先检查版本,不必重复安装:
```bash
# 仅新机器未安装时执行
pnpm add -g @cloudbase/cli@3.8.7
tcb --version
tcb storage download --help
```
使用当前的空格形式 `tcb storage download`;旧文档可能使用冒号形式 `tcb storage:download`。升级 CLI 后先重新查看帮助。参考 [官方安装说明](https://docs.cloudbase.net/cli-v1/install)。
### 2. 登录并确认原云环境
以下 tcb 命令在 `doodle-mini` 目录执行,七牛同步命令则在 `doodle-store` 根目录执行:
```bash
cd /Users/joey-xd/sites/doodle/doodle-mini
# 按终端提示在浏览器中完成授权
tcb login
# 核对账号能访问的环境
tcb env list
# 显式指定原小程序环境,避免误用其他项目的默认环境
tcb storage list -e cloud1-9gifs7a2756e2c87
```
环境 ID 来自 `doodle-mini/cloudbaserc.json`。如果更换源环境,应重新核对并替换下列命令中的 ID;不要直接复制 `cloud://` 文件 ID 作为环境 ID。
授权账号必须能管理原小程序云环境的云存储。微信 AppSecret 和七牛 AK/SK 均不是 tcb 登录凭据。若列表中看不到原环境,先核对账号权限及环境所在地域;必要时按实际地域使用全局 `--region` 参数,不为下载另建环境或改变权限。
### 3. 下载全部资源
每次新备份使用独立目录,保留已有 `cloud_backup`。在同一个终端依次执行:
```bash
DOODLE_CLOUD_BACKUP="/Users/joey-xd/sites/doodle/doodle-assets/cloud_backup-$(date +%Y%m%d-%H%M%S)"
mkdir -p "$DOODLE_CLOUD_BACKUP"
tcb storage download / "$DOODLE_CLOUD_BACKUP" --dir -e cloud1-9gifs7a2756e2c87
```
`/` 表示下载云存储全部文件,`--dir` 表示目录下载。云端路径使用相对对象路径,不使用临时 HTTPS 链接。命令依据 [官方云存储下载说明](https://docs.cloudbase.net/cli-v1/storage)。本项目源端为旧小程序云环境,不因新后端采用 PostgreSQL 就改用 `storage objects` 等 PG 环境命令。
如果只补下载题型预览,可改用目录下载,目标仍保留 `assets/previews` 层级;这是全量下载的替代方式,无需两者都执行:
```bash
mkdir -p "$DOODLE_CLOUD_BACKUP/assets/previews"
tcb storage download assets/previews "$DOODLE_CLOUD_BACKUP/assets/previews" --dir -e cloud1-9gifs7a2756e2c87
```
其他目录按实际云端清单选择,例如 `assets/fonts`、`assets/materials`、`content`、`user-avatars`。云端仍在写入,目录下载不是某个时间点的原子快照;下载失败或重试后都要核对实际文件,不把命令退出成功等同于完整备份,也不将旧目录混入新备份来掩盖缺失。
### 4. 核对目录并连接七牛同步
备份根目录应保留云端相对路径,例如:
```text
cloud_backup-<时间>/
├── assets/
│ ├── fonts/
│ ├── materials/
│ └── previews/
│ ├── chinese/
│ ├── english/
│ ├── math/
│ ├── papers/
│ ├── pinyin/
│ └── puzzle/
├── content/
│ └── page-config.json
└── user-avatars/
```
这只是路径示例,不代表每个目录必有文件;以本次云端列表和下载结果为准。不要多套一层环境 ID、重复的 previews 目录,或直接把文件平铺到备份根目录,否则七牛对象 key 会变化。
抽查图片能打开、字体文件非空、`content/page-config.json` 能解析,并对照云端列表核对数量和大小。历史备份为 62 个文件,其中预览 51 个、头像 7 个、字体/素材/首页配置/demo 各 1 个;这是旧备份基线,不是未来下载必须满足的固定数量。demo 等其他对象也应保留原路径,不限于上方示例目录。
检查完成后,将 `packages/asset-sync/.env` 的 `SOURCE_DIR` 手动改为新备份的实际绝对路径;继续使用已有备份时保持原值:
```dotenv
SOURCE_DIR=/Users/joey-xd/sites/doodle/doodle-assets/cloud_backup
```
不要把包含 `$DOODLE_CLOUD_BACKUP` 的字符串直接写进 `.env`;该配置需要实际路径。`SOURCE_DIR` 指向包含 `assets`、`content`、`user-avatars` 的根目录,不是 `assets/previews` 子目录。备份目录不应存放下载日志、清单或报告,这些普通文件也可能被同步工具扫描上传。
回到 `doodle-store`,先预演并核对报告中的相对路径、目标 key 和文件数量,再正式上传:
```bash
cd /Users/joey-xd/sites/doodle/doodle-store
pnpm assets:sync
pnpm assets:sync --apply
```
本项目当前使用 `QINIU_KEY_PREFIX=doodle-assets/`,补同步已有资源时应保持该值,并与 server 配置一致;下方配置段中的 `doodle/` 是工具默认示例,不能在补同步时误换前缀。同步与数据库回填是独立步骤,详见本文末尾。
## 配置 ## 配置
已生成本机 `packages/asset-sync/.env`,源目录配置为: 已生成本机 `packages/asset-sync/.env`,源目录配置为: