feat: 数据库导出文档
This commit is contained in:
+17
-3
@@ -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` 地址当作可用的体验版接口。
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user