73 lines
12 KiB
JavaScript
73 lines
12 KiB
JavaScript
// 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()
|