Files
doodle-store/deploy/postgres/content/apply.sql
T
2026-10-20 17:29:10 +08:00

163 lines
8.5 KiB
SQL

-- 由文档中的打包步骤在事务和 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