deploy-support-workflow.mjs 7.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. #!/usr/bin/env node
  2. // Scope both patches to the actual online baseline and migrate only the reply ledger.
  3. import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
  4. import {createHash,randomBytes} from 'node:crypto';
  5. import {resolve} from 'node:path';
  6. import {pathToFileURL} from 'node:url';
  7. const hash=s=>createHash('sha256').update(s).digest('hex');
  8. const adminHelper=readFileSync(new URL('./cloud/support-requests.js',import.meta.url),'utf8').trim();
  9. const appHelper=readFileSync(new URL('./cloud/support-requests-app.js',import.meta.url),'utf8').trim();
  10. const processDispatch=" if(operation==='ops/support-requests/process') return response.json({success:true,data:await supportRequestProcess(context,input)});";
  11. const listDispatch=" if(operation==='ops/support-requests/list') return response.json({success:true,data:await supportRequestsPage(context,input)});";
  12. const appDispatch=" if (['app_support_requests_list','app_support_request_read'].includes(action)) return response.json(envelope(await appSupportRequests(input,current)));";
  13. function replaceOnce(code,anchor,value){if(code.split(anchor).length!==2)throw Error('发布锚点缺失或不唯一');return code.replace(anchor,value);}
  14. 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);}
  15. export function patchWorkflow(code,kind){
  16. let next=code;
  17. if(kind==='admin'){
  18. if(code.includes(adminHelper)&&code.includes(processDispatch))return code;
  19. if(code.includes('supportRequestProcess('))throw Error('线上已有不同版本处理逻辑');
  20. const start=code.indexOf('// Both mobile roles submit category 22');
  21. if(start<0)throw Error('未找到已发布的反馈看板');
  22. const end=code.indexOf('\n}',start)+2,old=code.slice(start,end);
  23. if(hash(old+'\n')!=='6414de94dd69ee55cecb634ccf9ba08a837397d316c0d6ba0d482047f03db7e0')throw Error('线上反馈模块已变化,停止覆盖');
  24. next=replaceOnce(next,old,adminHelper);
  25. next=replaceOnce(next,listDispatch,processDispatch+'\n'+listDispatch);
  26. }else if(kind==='app'){
  27. if(code.includes(appHelper)&&code.includes(appDispatch))return supportCapabilities(code);
  28. if(code.includes('appSupportRequests('))throw Error('线上已有不同版本回复逻辑');
  29. const anchor=" if (action === 'guestbook_add')";
  30. next=replaceOnce(next,anchor,appDispatch+'\n'+anchor)+'\n'+appHelper+'\n';
  31. }else throw Error('无效网关类型');
  32. if(kind==='app')next=supportCapabilities(next);
  33. if(new Function(next+'\nreturn typeof handler;')()!=='function')throw Error('网关语法校验失败');
  34. return next;
  35. }
  36. export async function deployWorkflow(commit=false){
  37. const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN;
  38. if(!master||!token)throw Error('缺少受信任部署配置');
  39. 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(/\/$/,'');
  40. const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master};
  41. 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;};
  42. 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;};
  43. const meResponse=await fetch(base+'/users/me',{headers:{'X-Parse-Application-Id':app,'X-Parse-Session-Token':token}}),me=await meResponse.json();
  44. if(!meResponse.ok||!me.objectId||!me.company?.objectId)throw Error('无法确认发布账号归属');
  45. const idle=await call('xiaoshu/sync/direct-v1',{action:'status'});
  46. if(!Array.isArray(idle.data?.runs)||idle.data.runs.some(r=>r.status==='running'))throw Error('同步任务状态不可用或正在运行');
  47. const root=resolve('dist/support-workflow-release',new Date().toISOString().replace(/[:.]/g,'-'));mkdirSync(root,{recursive:true,mode:0o700});
  48. const candidates=[];
  49. for(const [kind,path] of [['app','xiaoshu/app/gateway'],['admin','xiaoshu/ops/gateway-v3']]){
  50. const rows=(await parse('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path}),limit:'2'}))).results;
  51. if(rows?.length!==1)throw Error('未找到唯一网关 '+path);
  52. const before=rows[0],code=patchWorkflow(before.code,kind);
  53. 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});
  54. }
  55. if(!commit){console.log(JSON.stringify({dryRun:true,backup:root}));return;}
  56. let helper;
  57. try{
  58. const sql=readFileSync(new URL('./sql/support-requests.sql',import.meta.url),'utf8'),path='xiaoshu/system/support-migration-'+randomBytes(12).toString('hex');
  59. 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});}}`;
  60. helper=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});
  61. await call(path);
  62. for(const {kind,before,code} of candidates){
  63. const latest=await parse('/classes/Function/'+before.objectId);
  64. if(latest.code!==before.code||latest.updatedAt!==before.updatedAt)throw Error('网关已被并行发布修改,停止覆盖 '+kind);
  65. if(code!==before.code)await parse('/classes/Function/'+before.objectId,'PUT',{code});
  66. if((await parse('/classes/Function/'+before.objectId)).code!==code)throw Error('发布回读失败 '+kind);
  67. }
  68. const live=await call('xiaoshu/ops/gateway-v3',{operation:'ops/support-requests/list',page:1,pageSize:1});
  69. if(!Array.isArray(live.data?.items)||typeof live.data.canProcess!=='boolean')throw Error('真实看板查询失败');
  70. 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));
  71. }finally{if(helper)await parse('/classes/Function/'+helper.objectId,'DELETE');}
  72. }
  73. 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;});