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 云端部署准备
更新时间: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 管理入口,项目维护应用编排”的组合方式:
@@ -121,7 +135,7 @@ API、PostgreSQL 的 Docker Compose、镜像版本、环境变量示例和备份
3. 准备数据库持久化、备份和恢复流程;备份副本需要保存到服务器之外。
4. 核对基础镜像及应用镜像从服务器的拉取能力,再确定仓库分发或首次离线交付。
5. 确认 API 域名并配置 DNS、1Panel 反向代理,绑定已签发的证书 ID 11,验收 HTTPS 与续签部署链路。
6. 初始化单套云端数据库、执行迁移、受控导入内容;不整库复制开发用户和临时数据。
6. 按最新方案将本地内容与 CloudBase 最新用户业务数据整合,对账后迁入 1Panel 管理的 `doodle_prod` 并备份。
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