#!/usr/bin/env node // Scope both patches to the actual online baseline and migrate only the reply ledger. import {readFileSync,writeFileSync,mkdirSync} from 'node:fs'; import {createHash,randomBytes} from 'node:crypto'; import {resolve} from 'node:path'; import {pathToFileURL} from 'node:url'; const hash=s=>createHash('sha256').update(s).digest('hex'); const adminHelper=readFileSync(new URL('./cloud/support-requests.js',import.meta.url),'utf8').trim(); const appHelper=readFileSync(new URL('./cloud/support-requests-app.js',import.meta.url),'utf8').trim(); const processDispatch=" if(operation==='ops/support-requests/process') return response.json({success:true,data:await supportRequestProcess(context,input)});"; const listDispatch=" if(operation==='ops/support-requests/list') return response.json({success:true,data:await supportRequestsPage(context,input)});"; const appDispatch=" if (['app_support_requests_list','app_support_request_read'].includes(action)) return response.json(envelope(await appSupportRequests(input,current)));"; function replaceOnce(code,anchor,value){if(code.split(anchor).length!==2)throw Error('发布锚点缺失或不唯一');return code.replace(anchor,value);} function supportCapabilities(code){if(!code.includes("action === 'migration_status'"))return code;const prefix="implemented: ['app_support_requests_list','app_support_request_read',";return code.includes(prefix)?code:replaceOnce(code,'implemented: [',prefix);} export function patchWorkflow(code,kind){ let next=code; if(kind==='admin'){ if(code.includes(adminHelper)&&code.includes(processDispatch))return code; if(code.includes('supportRequestProcess('))throw Error('线上已有不同版本处理逻辑'); const start=code.indexOf('// Both mobile roles submit category 22'); if(start<0)throw Error('未找到已发布的反馈看板'); const end=code.indexOf('\n}',start)+2,old=code.slice(start,end); if(hash(old+'\n')!=='6414de94dd69ee55cecb634ccf9ba08a837397d316c0d6ba0d482047f03db7e0')throw Error('线上反馈模块已变化,停止覆盖'); next=replaceOnce(next,old,adminHelper); next=replaceOnce(next,listDispatch,processDispatch+'\n'+listDispatch); }else if(kind==='app'){ if(code.includes(appHelper)&&code.includes(appDispatch))return supportCapabilities(code); if(code.includes('appSupportRequests('))throw Error('线上已有不同版本回复逻辑'); const anchor=" if (action === 'guestbook_add')"; next=replaceOnce(next,anchor,appDispatch+'\n'+anchor)+'\n'+appHelper+'\n'; }else throw Error('无效网关类型'); if(kind==='app')next=supportCapabilities(next); if(new Function(next+'\nreturn typeof handler;')()!=='function')throw Error('网关语法校验失败'); return next; } export async function deployWorkflow(commit=false){ const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN; if(!master||!token)throw Error('缺少受信任部署配置'); const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',functionBase=(process.env.XIAOSHU_FUNCTION_URL||base.replace(/\/parse$/,'/api/functions')).replace(/\/$/,''); const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master}; const parse=async(path,method='GET',body)=>{const r=await fetch(base+path,{method,headers,body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)});const d=await r.json();if(!r.ok||d.error)throw Error('发布接口失败 '+path.split('?')[0]+' HTTP '+r.status);return d;}; const call=async(path,params={})=>{const r=await fetch(functionBase+'/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!r.ok||d.success===false)throw Error(d.message||'网关验证失败 HTTP '+r.status);return d;}; const meResponse=await fetch(base+'/users/me',{headers:{'X-Parse-Application-Id':app,'X-Parse-Session-Token':token}}),me=await meResponse.json(); if(!meResponse.ok||!me.objectId||!me.company?.objectId)throw Error('无法确认发布账号归属'); const idle=await call('xiaoshu/sync/direct-v1',{action:'status'}); if(!Array.isArray(idle.data?.runs)||idle.data.runs.some(r=>r.status==='running'))throw Error('同步任务状态不可用或正在运行'); const root=resolve('dist/support-workflow-release',new Date().toISOString().replace(/[:.]/g,'-'));mkdirSync(root,{recursive:true,mode:0o700}); const candidates=[]; for(const [kind,path] of [['app','xiaoshu/app/gateway'],['admin','xiaoshu/ops/gateway-v3']]){ const rows=(await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path}),limit:'2'}))).results; if(rows?.length!==1)throw Error('未找到唯一网关 '+path); const before=rows[0],code=patchWorkflow(before.code,kind); writeFileSync(root+'/'+kind+'-before.json',JSON.stringify(before,null,2),{mode:0o600});writeFileSync(root+'/'+kind+'-candidate.js',code,{mode:0o600});candidates.push({kind,before,code}); } if(!commit){console.log(JSON.stringify({dryRun:true,backup:root}));return;} let helper; try{ const sql=readFileSync(new URL('./sql/support-requests.sql',import.meta.url),'utf8'),path='xiaoshu/system/support-migration-'+randomBytes(12).toString('hex'); const code=`async function handler(request,response){try{const u=request.user;if(!u||u.id!==${JSON.stringify(me.objectId)}||Date.now()>${Date.now()+600000})return response.status(403).json({success:false});await u.fetch({useMasterKey:true});if(u.get('company')?.id!==${JSON.stringify(me.company.objectId)})return response.status(403).json({success:false});await Psql.tx(async t=>{await t.none(${JSON.stringify(sql)});});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:e.message});}}`; helper=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'}); await call(path); for(const {kind,before,code} of candidates){ const latest=await parse('/classes/Function/'+before.objectId); if(latest.code!==before.code||latest.updatedAt!==before.updatedAt)throw Error('网关已被并行发布修改,停止覆盖 '+kind); if(code!==before.code)await parse('/classes/Function/'+before.objectId,'PUT',{code}); if((await parse('/classes/Function/'+before.objectId)).code!==code)throw Error('发布回读失败 '+kind); } const live=await call('xiaoshu/ops/gateway-v3',{operation:'ops/support-requests/list',page:1,pageSize:1}); if(!Array.isArray(live.data?.items)||typeof live.data.canProcess!=='boolean')throw Error('真实看板查询失败'); const proof={deployed:true,backup:root,total:live.data.total,gateways:candidates.map(x=>({kind:x.kind,id:x.before.objectId,before:hash(x.before.code),after:hash(x.code)}))};writeFileSync(root+'/proof.json',JSON.stringify(proof,null,2),{mode:0o600});console.log(JSON.stringify(proof)); }finally{if(helper)await parse('/classes/Function/'+helper.objectId,'DELETE');} } if(process.argv[1]&&import.meta.url===pathToFileURL(resolve(process.argv[1])).href)deployWorkflow(process.argv.includes('--commit')).catch(e=>{console.error(e.message);process.exitCode=1;});