reconcile-legacy-appointment-deletions.mjs 3.5 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344
  1. #!/usr/bin/env node
  2. import {randomUUID} from 'node:crypto';
  3. import {mkdir,writeFile,rename} from 'node:fs/promises';
  4. import {homedir} from 'node:os';
  5. import {resolve,join} from 'node:path';
  6. import {fileURLToPath} from 'node:url';
  7. export async function reconcileAppointmentDeletions(env=process.env,{sourceRunId,dryRun=false,call:providedCall,onProgress=()=>{}}={}){
  8. const root=resolve(env.XIAOSHU_SYNC_REPORT_DIR||join(homedir(),'.local/state/xiaoshu/legacy-sync'));
  9. await mkdir(root,{recursive:true,mode:0o700});
  10. const report={runId:randomUUID(),sourceRunId,dryRun,startedAt:new Date().toISOString(),status:'running',scanned:0,present:0,cancelled:0,wouldCancel:0,conflicts:0};
  11. const path=join(root,'appointment-deletions-'+report.runId+'.json');
  12. const save=async()=>{await writeFile(path+'.tmp',JSON.stringify(report,null,2),{mode:0o600});await rename(path+'.tmp',path);};
  13. const call=providedCall||(async params=>{
  14. if(!env.XIAOSHU_SYNC_OPERATOR_TOKEN)throw Error('missing_sync_operator_session');
  15. const url=(env.XIAOSHU_FUNCTION_URL||'https://server.xiaoshu.pro/api/functions').replace(/\/$/,'')+'/xiaoshu/sync/appointment-deletions-v1';
  16. for(let attempt=0;;attempt++)try{
  17. const response=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:env.XIAOSHU_SYNC_OPERATOR_TOKEN,params}),signal:AbortSignal.timeout(60000)}),value=await response.json();
  18. if(!response.ok||!value.success)throw Error(value.message||'appointment_deletion_request_failed');return value.data;
  19. }catch(error){if(attempt>=2)throw error;}
  20. });
  21. const request=params=>call({...params,runId:report.runId,...(sourceRunId?{sourceRunId}:{})});
  22. try{
  23. if((await request({action:'status'})).version!=='confirmed-ids-v1')throw Error('wrong_appointment_deletion_gateway');
  24. if(!sourceRunId){
  25. const first=await request({action:'source',page:1});
  26. if(!Number.isSafeInteger(first.total)||first.total<1||first.pages!==Math.ceil(first.total/1000))throw Error('incomplete_appointment_snapshot');
  27. for(let page=2;page<=first.pages;page++){const next=await request({action:'source',page});if(next.total!==first.total||next.pages!==first.pages)throw Error('source_shifted_restart_dataset');onProgress({phase:'source',page,pages:first.pages});}
  28. }
  29. let after=0;
  30. for(let batch=0;batch<10000;batch++){
  31. const result=await request({action:'reconcile',after,dryRun});
  32. for(const key of ['scanned','present','cancelled','wouldCancel','conflicts'])report[key]+=Number(result[key]||0);
  33. await save();onProgress({phase:'reconcile',scanned:report.scanned,cancelled:report.cancelled,conflicts:report.conflicts});
  34. if(!result.hasMore){report.status=dryRun?'preview':report.conflicts?'completed-with-preserved-conflicts':'completed';return report;}
  35. if(!Number.isSafeInteger(result.after)||result.after<=after)throw Error('appointment_deletion_cursor_stalled');after=result.after;
  36. }
  37. throw Error('appointment_deletion_batch_limit');
  38. }catch(error){report.status='blocked';report.reason=String(error.message||'appointment_deletion_failed').replace(/r:[A-Za-z0-9]+/g,'[redacted]');throw error;}
  39. finally{report.finishedAt=new Date().toISOString();await save();}
  40. }
  41. if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
  42. reconcileAppointmentDeletions(process.env,{dryRun:!process.argv.includes('--apply'),onProgress:value=>console.log(JSON.stringify(value))}).then(value=>console.log(JSON.stringify(value))).catch(()=>{console.error('旧预约删除核对未完成,请查看受保护的运行报告');process.exitCode=1;});
  43. }