gongxiangp2/scripts/prepare-native-brand-import.mjs

73 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Prepare a four-row additive data release. This program never connects to a database.
// Apply and rollback SQL remain reviewable, separate deployment actions.
import fs from 'node:fs/promises'
import path from 'node:path'
import {createHash,randomUUID} from 'node:crypto'
import {fileURLToPath} from 'node:url'
export const SOURCE_VERSION='enterprise-web-20261013.1'
export const SOURCE_PACK_SHA256='1b77db4014c0f1cba9ae2b65c51d29c1eaf327bdb889151af935eb2f702085df'
export const SOURCE_MIGRATION_SHA256='449c0bfaa1abedde3a244b7aa268dcf1a1d952483ebe4559cfbe02144dca3acf'
export const SOURCE_FILES=Object.freeze({
'references/brand-style-prompts.md':'b7b67dd0a2dbc66906869260d7746ee6276c1687d9ad6b45551691858c399e7a',
'references/gujia-visual-style.md':'2d90238d97b77e776a0efba0ec26278090ad8cecec23df6bb93451183b9de21a',
})
const BRANDS=['顾家','欧意朗','索菲亚','亿家达']
const VISUAL=['palette','materials','props','lighting','composition','quality']
const sha=bytes=>createHash('sha256').update(bytes).digest('hex')
const check=(value,message)=>{if(!value)throw Error(message)}
const exactKeys=(value,keys)=>value&&typeof value==='object'&&!Array.isArray(value)&&Object.keys(value).sort().join('|')===keys.slice().sort().join('|')
const text=(value,max)=>typeof value==='string'&&value.trim().length>0&&value.length<=max
export function validateBrand(profile){
check(exactKeys(profile,['name','tone','primaryColor','backgroundColor','textColor','fontFamily','status','visualRules','source']),'品牌字段与已验收契约不一致')
check(BRANDS.includes(profile.name)&&text(profile.tone,2000),'仅允许四个已有来源的品牌')
check(['primaryColor','backgroundColor','textColor'].every(k=>/^#[a-f0-9]{6}$/i.test(profile[k])),'品牌配色格式无效')
check(profile.fontFamily==='Noto Sans SC'&&profile.status==='draft','不得改写原有字体和待确认状态')
check(exactKeys(profile.visualRules,VISUAL)&&VISUAL.every(k=>text(profile.visualRules[k],2000)),'不能遗漏或补造原有visualRules')
const source=profile.source
check(exactKeys(source,['label','version','files','adaptation'])&&source.label==='企业知识库'&&source.version===SOURCE_VERSION&&text(source.adaptation,1000),'品牌来源或适配声明无效')
check(Array.isArray(source.files)&&source.files.length>=1&&source.files.length<=2&&new Set(source.files.map(f=>f.name)).size===source.files.length,'品牌原文清单无效')
for(const file of source.files)check(exactKeys(file,['name','sha256'])&&SOURCE_FILES[file.name]===file.sha256,'品牌原文哈希不符合已验收来源')
return profile
}
const quote=s=>"'"+s.replaceAll("'","''")+"'"
const literal=rows=>quote(JSON.stringify(rows))+'::jsonb'
const desired=rows=>`SELECT * FROM jsonb_to_recordset(${literal(rows)}) AS d(id text,name text,revision integer,profile jsonb)`
function prelude(readOnly){return `\\set ON_ERROR_STOP on\nSET client_encoding='UTF8';\nBEGIN ISOLATION LEVEL SERIALIZABLE${readOnly?' READ ONLY':''};\nSET LOCAL lock_timeout='5s';\nSET LOCAL statement_timeout='45s';\nSELECT set_config('jiansun.expected_database', :'expected_database', true);\nDO $identity$ BEGIN\n IF current_database() IS DISTINCT FROM current_setting('jiansun.expected_database') THEN RAISE EXCEPTION 'Database identity mismatch'; END IF;\n IF to_regclass('public.detail_generator_brand_profiles') IS NULL THEN RAISE EXCEPTION 'Existing brand profile table is missing; no schema will be created'; END IF;\nEND $identity$;\n`}
function conflictGuard(rows){return `DO $brands$ DECLARE d record; BEGIN\n FOR d IN ${desired(rows)} LOOP\n IF EXISTS(SELECT 1 FROM public.detail_generator_brand_profiles p WHERE p.name=d.name OR p.id=d.id) THEN RAISE EXCEPTION 'Brand already exists; refusing overwrite: %',d.name; END IF;\n IF NOT EXISTS(SELECT 1 FROM public.categories c WHERE c.name=d.name) THEN RAISE EXCEPTION 'Existing category is missing: %',d.name; END IF;\n END LOOP;\nEND $brands$;\n`}
export function sqlPackage(rows,packageSha){
const header=`-- Four existing knowledge profiles only. Package SHA256: ${packageSha}\n-- Supply psql -X -v expected_database=<verified database> -f <file>.\n-- No schema, user, material, project, design resource or audit table changes.\n`
const dryRun=header+prelude(true)+conflictGuard(rows)+`SELECT json_build_object('mode','read_only_dry_run','wouldInsert',4,'existingProfiles',(SELECT count(*) FROM public.detail_generator_brand_profiles),'packageSha256',${quote(packageSha)});\nROLLBACK;\n`
const apply=header+prelude(false)+`SELECT pg_advisory_xact_lock(20261022,130004);\nLOCK TABLE public.detail_generator_brand_profiles IN SHARE ROW EXCLUSIVE MODE;\n`+conflictGuard(rows)+`INSERT INTO public.detail_generator_brand_profiles(id,name,profile,revision)\nSELECT id,name,profile,revision FROM (${desired(rows)}) desired\nRETURNING id,name,revision,profile->'source'->>'version' AS source_version,xmin::text AS import_xmin;\nSELECT json_build_object('applied',4,'packageSha256',${quote(packageSha)},'importXmin',(SELECT min(xmin::text) FROM public.detail_generator_brand_profiles WHERE id IN (${rows.map(r=>quote(r.id)).join(',')})),'rows',(SELECT json_agg(json_build_object('id',id,'name',name,'revision',revision)) FROM public.detail_generator_brand_profiles WHERE id IN (${rows.map(r=>quote(r.id)).join(',')})));\nCOMMIT;\n`
const verify=header+prelude(true)+`DO $verify$ DECLARE d record; BEGIN\n FOR d IN ${desired(rows)} LOOP\n IF NOT EXISTS(SELECT 1 FROM public.detail_generator_brand_profiles p WHERE p.id=d.id AND p.name=d.name AND p.revision=1 AND p.profile=d.profile) THEN RAISE EXCEPTION 'Imported profile differs from exact source payload: %',d.name; END IF;\n END LOOP;\nEND $verify$;\nSELECT json_build_object('verified',4,'packageSha256',${quote(packageSha)},'importXmin',(SELECT min(xmin::text) FROM public.detail_generator_brand_profiles WHERE id IN (${rows.map(r=>quote(r.id)).join(',')})));\nROLLBACK;\n`
const rollback=header+`-- Also supply -v import_xmin=<exact apply receipt importXmin>.\n-- Abort on any changed row or referenced ID; never modify old project snapshots.\n`+prelude(false)+`SELECT set_config('jiansun.import_xmin', :'import_xmin', true);\nSELECT pg_advisory_xact_lock(20261022,130004);\nLOCK TABLE public.detail_generator_brand_profiles IN SHARE ROW EXCLUSIVE MODE;\nDO $rollback$\nDECLARE d record; r record; used boolean; tables text[]:=ARRAY['detail_generator_projects','desktop_workspace_projects','detail_generator_jobs','detail_generator_design_resources','detail_generator_knowledge_revisions']; t text;\nBEGIN\n IF current_setting('jiansun.import_xmin') !~ '^[0-9]+$' THEN RAISE EXCEPTION 'Original import transaction identity is required'; END IF;\n FOREACH t IN ARRAY tables LOOP\n IF to_regclass('public.'||t) IS NOT NULL THEN EXECUTE format('LOCK TABLE public.%I IN SHARE MODE',t); END IF;\n END LOOP;\n FOR d IN ${desired(rows)} LOOP\n IF NOT EXISTS(SELECT 1 FROM public.detail_generator_brand_profiles p WHERE p.id=d.id AND p.name=d.name AND p.revision=1 AND p.profile=d.profile AND p.xmin::text=current_setting('jiansun.import_xmin')) THEN RAISE EXCEPTION 'Imported row is missing or has changed: %',d.name; END IF;\n FOR r IN SELECT table_name,column_name,data_type FROM information_schema.columns WHERE table_schema='public' AND table_name=ANY(tables) AND (data_type IN ('json','jsonb') OR column_name IN ('resource_id','brand_profile_id','profile_id','brandProfileId','profileId')) LOOP\n IF r.data_type IN ('json','jsonb') THEN\n EXECUTE format('SELECT EXISTS(SELECT 1 FROM public.%I WHERE jsonb_path_exists(%I::jsonb, ''$.** ? (@ == $target)'', jsonb_build_object(''target'', $1)))',r.table_name,r.column_name) INTO used USING d.id;\n ELSE\n EXECUTE format('SELECT EXISTS(SELECT 1 FROM public.%I WHERE %I::text=$1)',r.table_name,r.column_name) INTO used USING d.id;\n END IF;\n IF used THEN RAISE EXCEPTION 'Brand is referenced by %.%; refusing rollback: %',r.table_name,r.column_name,d.name; END IF;\n END LOOP;\n END LOOP;\nEND $rollback$;\nDELETE FROM public.detail_generator_brand_profiles p USING (${desired(rows)}) d WHERE p.id=d.id AND p.name=d.name AND p.revision=1 AND p.profile=d.profile AND p.xmin::text=current_setting('jiansun.import_xmin') RETURNING p.id,p.name,p.revision;\nCOMMIT;\n`
return {dryRun,apply,verify,rollback}
}
export function prepareImport({packBytes,migrationBytes,originals,batchId=randomUUID()}){
check(/^[a-f0-9]{8}(?:-[a-f0-9]{4}){3}-[a-f0-9]{12}$/.test(batchId),'Import batch UUID is invalid')
check(sha(packBytes)===SOURCE_PACK_SHA256,'已验收知识包的字节哈希变化,停止准备')
if(migrationBytes)check(sha(migrationBytes)===SOURCE_MIGRATION_SHA256,'20261013验收SQL哈希变化,停止准备')
for(const [file,digest]of Object.entries(SOURCE_FILES))check(originals[file]&&sha(originals[file])===digest,'企业知识原文哈希变化或文件缺失:'+file)
const pack=JSON.parse(packBytes.toString('utf8'))
check(pack.version===SOURCE_VERSION&&Array.isArray(pack.brands)&&pack.brands.length===4,'知识包版本或品牌数量变化')
const rows=BRANDS.map(name=>{const profile=pack.brands.find(p=>p.name===name);validateBrand(profile);return {id:'enterprise-brand-'+batchId+'-'+sha(name).slice(0,8),name,revision:1,profile:structuredClone(profile)}})
check(new Set(rows.map(r=>r.id)).size===4,'Import IDs are duplicated')
const bundle={format:'jiansun-brand-import-v1',batchId,sourceVersion:SOURCE_VERSION,sourcePackSha256:SOURCE_PACK_SHA256,sourceMigrationSha256:SOURCE_MIGRATION_SHA256,originalFiles:SOURCE_FILES,rows}
const bytes=Buffer.from(JSON.stringify(bundle,null,2)+'\n');const packageSha=sha(bytes)
return {bundle,bytes,packageSha,sql:sqlPackage(rows,packageSha)}
}
async function main(){
const args=process.argv.slice(2);const options={};for(let n=0;n<args.length;n+=2){check(['--out','--source-root'].includes(args[n])&&args[n+1],'仅支持 --out 和 --source-root');options[args[n]]=args[n+1]}
const root=path.resolve(path.dirname(fileURLToPath(import.meta.url)),'..');const out=path.resolve(options['--out']||path.join(root,'output/native-web-completion-20261022/brand-import'));const sourceRoot=path.resolve(options['--source-root']||'S:/企业知识库/Skills/jinbao-detail-page-production')
check(out.startsWith(path.join(root,'output')+path.sep),'输出仅允许写入当前项目output目录');await fs.mkdir(out,{recursive:true})
check(!(await fs.readdir(out)).length,'输出目录必须为空,防止覆盖待部署导入包')
const originals={};for(const file of Object.keys(SOURCE_FILES))originals[file]=await fs.readFile(path.join(sourceRoot,file))
let migrationBytes;try{migrationBytes=await fs.readFile(path.join(root,'prisma/migrations/20261013_detail_design_knowledge/migration.sql'))}catch(error){if(error.code!=='ENOENT')throw error}
const prepared=prepareImport({packBytes:await fs.readFile(path.join(root,'lib/detail-generator/knowledge/enterprise-20261013.json')),migrationBytes,originals})
const files={'brands.json':prepared.bytes,'dry-run.sql':prepared.sql.dryRun,'apply.sql':prepared.sql.apply,'verify.sql':prepared.sql.verify,'rollback.sql':prepared.sql.rollback}
for(const [name,bytes]of Object.entries(files))await fs.writeFile(path.join(out,name),bytes,{flag:'wx'})
const manifest={preparedAt:new Date().toISOString(),databaseConnections:0,modelRequests:0,productionWrites:0,batchId:prepared.bundle.batchId,sourceVersion:SOURCE_VERSION,sourcePackSha256:SOURCE_PACK_SHA256,sourceMigrationSha256:SOURCE_MIGRATION_SHA256,sourceFilesVerified:SOURCE_FILES,packageSha256:prepared.packageSha,brands:prepared.bundle.rows.map(row=>({id:row.id,name:row.name,revision:row.revision,profileSha256:sha(JSON.stringify(row.profile)),visualRulesSha256:sha(JSON.stringify(row.profile.visualRules))})),artifacts:Object.fromEntries(Object.entries(files).map(([name,bytes])=>[name,sha(bytes)])),notes:['Profiles retain original draft status and source adaptation statement.','Hex palette values were originally adapted from colour names; this import does not assert brand approval.','No new schema or knowledge management route is required.','Apply conflicts fail the transaction; no overwrite or ON CONFLICT update.','Rollback requires the exact original xmin and unmodified revision 1 payload; referenced rows cannot be removed.']}
await fs.writeFile(path.join(out,'manifest.json'),JSON.stringify(manifest,null,2)+'\n',{flag:'wx'});console.log(JSON.stringify({prepared:true,output:out,packageSha256:prepared.packageSha,rows:manifest.brands,databaseConnections:0,productionWrites:0},null,2))
}
if(process.argv[1]&&path.resolve(process.argv[1])===fileURLToPath(import.meta.url))await main()