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