Browse Source

fix(sync): reconcile confirmed legacy appointment deletions

彭峰 7 hours ago
parent
commit
800d130da1

+ 92 - 0
scripts/cloud/legacy-appointment-deletions.js

@@ -0,0 +1,92 @@
+// Only confirmed absences in the published legacy appointment catalog are cancelled.
+// Keep the appointment row, balances, reservations and teaching evidence intact.
+async function appointmentDeletionOld(args) {
+ const config=await Parse.Config.get({useMasterKey:true}),url=config.get('legacyScheduleApiUrl'),apiId=config.get('legacyScheduleApiId'),apiKey=config.get('legacyScheduleApiKey');
+ if(!url||!apiId||!apiKey)throw Error('legacy_api_not_configured');
+ let response,payload;
+ try{response=await fetch(url+(url.includes('?')?'&':'?')+new URLSearchParams({action:'content_list',modelId:'54',...args,apiId,apiKey}),{signal:AbortSignal.timeout(20000)});payload=await response.json();}catch{throw Error('legacy_appointment_read_failed');}
+ if(!response.ok||![0,1].includes(Number(payload.retcode)))throw Error('legacy_appointment_read_rejected');
+ let items=payload.result;try{if(typeof items==='string')items=JSON.parse(items);}catch{throw Error('invalid_legacy_appointments');}
+ const total=Number(payload.page?.itemCount),pageCount=Number(payload.page?.pageCount);
+ if(!Array.isArray(items)||!Number.isSafeInteger(total)||total<0||!Number.isSafeInteger(pageCount)||pageCount<0)throw Error('invalid_legacy_pagination');
+ const ids=items.map(row=>Number(row.GeneralID??row.generalId));
+ if(ids.some(id=>!Number.isSafeInteger(id)||id<1)||new Set(ids).size!==ids.length)throw Error('invalid_legacy_ids');
+ return{ids,total,pageCount};
+}
+async function appointmentDeletionSnapshot(db,co,runId) {
+ const pages=await db.any('SELECT page,ids,total,"capturedAt" FROM "LegacyDirectSourcePage" WHERE company=$1 AND "runId"=$2 AND model=54 ORDER BY page',[co,runId]);
+ const total=Number(pages[0]?.total),ids=pages.flatMap(page=>page.ids);
+ if(!pages.length||!Number.isSafeInteger(total)||total<1||pages.length!==Math.ceil(total/1000)||pages.some((page,i)=>Number(page.page)!==i+1||Number(page.total)!==total||Date.now()-Date.parse(page.capturedAt)>30*60000)||ids.length!==total||new Set(ids).size!==total)throw Error('incomplete_appointment_snapshot');
+ return{ids,total,canary:ids[0]};
+}
+function appointmentDeletionImported(row) {
+ return typeof row?.sourceKey==='string'&&(/^legacy:model:54:general:\d+$/.test(row.sourceKey)||/\[\["(?:ID|GeneralID)",\d+\]\]/.test(row.sourceKey));
+}
+function appointmentDeletionUnmodified(row) {
+ const updated=Date.parse(row?.updatedAt);
+ return appointmentDeletionImported(row)&&!row.teachingManaged&&(row.createdAt===row.updatedAt||!row.updatedAt||updated<Date.parse('2026-08-01')||__WINDOWS__.some(([a,b])=>updated>=a&&updated<=b));
+}
+async function appointmentDeletionConflict(db,co,gid,reason) {
+ const key='legacy-appointment-deletion:'+gid;
+ await db.none('INSERT INTO "LegacySyncConflict" ("objectId",company,"sourceKey",dataset,"legacyKey","conflictType",message,resolved,resolution,"createdAt","updatedAt") SELECT substr(md5($1),1,20),$2,$1,\'54\',$3,\'legacy-deleted-preserve-new\',$4,false,\'保留新系统,待人工复核\',now(),now() WHERE NOT EXISTS(SELECT 1 FROM "LegacySyncConflict" WHERE company=$2 AND "sourceKey"=$1)',[key,co,String(gid),reason]);
+}
+async function handler(request,response) {
+ try {
+  const user=request.user;if(!user)return response.status(401).json({success:false});await user.fetch({useMasterKey:true});
+  const co=__COMPANY_ID__;if(user.get('adminRoleKey')!=='super-admin'||user.get('isDisabled')||user.get('isDeleted')||user.get('company')?.id!==co)return response.status(403).json({success:false});
+  const p=request.body?.params||{};
+  if(p.action==='status')return response.json({success:true,data:{version:'confirmed-ids-v1'}});
+  if(!/^[a-f0-9-]{36}$/.test(p.runId||''))throw Error('invalid_deletion_run');
+  const sourceRunId=p.sourceRunId||p.runId;if(!/^[a-f0-9-]{36}$/.test(sourceRunId))throw Error('invalid_snapshot_run');
+  if(p.action==='source') {
+   if(sourceRunId!==p.runId||!Number.isSafeInteger(p.page)||p.page<1||p.page>1000)throw Error('invalid_snapshot_page');
+   const data=await appointmentDeletionOld({cpage:String(p.page),psize:'1000'});
+   if(data.ids.length!==Math.min(1000,Math.max(0,data.total-(p.page-1)*1000)))throw Error('partial_appointment_page');
+   await Psql.none('INSERT INTO "LegacyDirectSourcePage" (company,"runId",model,page,ids,total,checksum) VALUES($1,$2,54,$3,$4::jsonb,$5,md5($4::jsonb::text)) ON CONFLICT(company,"runId",model,page) DO UPDATE SET ids=EXCLUDED.ids,total=EXCLUDED.total,checksum=EXCLUDED.checksum,"capturedAt"=now()',[co,p.runId,p.page,JSON.stringify(data.ids),data.total]);
+   return response.json({success:true,data:{total:data.total,pages:Math.max(1,data.pageCount),count:data.ids.length}});
+  }
+  if(p.action!=='reconcile')throw Error('invalid_deletion_action');
+  const after=Number(p.after||0);if(!Number.isSafeInteger(after)||after<0)throw Error('invalid_deletion_cursor');
+  const snapshot=await appointmentDeletionSnapshot(Psql,co,sourceRunId);
+  const key='appointment-deletion:'+sourceRunId+':'+after+':'+(p.dryRun?'preview':'apply');
+  const prior=await Psql.oneOrNone('SELECT result FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND key=$3',[co,p.runId,key]);
+  if(prior)return response.json({success:true,data:prior.result});
+  const rows=await Psql.any('SELECT c."generalId",to_jsonb(c) AS common,to_jsonb(a) AS addon FROM "CommonModel" c LEFT JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=54 AND c.status=99 AND c."generalId">$2 AND (c."sourceKey" LIKE \'legacy:model:54:general:%\' OR c."sourceKey" LIKE \'[["ID",%\' OR c."sourceKey" LIKE \'[["GeneralID",%\') AND c."generalId"<>ALL($3::numeric[]) ORDER BY c."generalId" LIMIT 50',[co,after,snapshot.ids]);
+  if(new Set(rows.map(row=>Number(row.generalId))).size!==rows.length)throw Error('duplicate_appointment_identity');
+  // A positive control detects a rejected/empty catalog; the same ID filter verifies
+  // every candidate. Errors, malformed pages and partial results never imply deletion.
+  const canary=await appointmentDeletionOld({ids:String(snapshot.canary),cpage:'1',psize:'1000'});
+  if(canary.total!==1||canary.ids.length!==1||canary.ids[0]!==snapshot.canary)throw Error('legacy_positive_control_failed');
+  const ids=rows.map(row=>Number(row.generalId));
+  const live=ids.length?await appointmentDeletionOld({ids:ids.join(','),cpage:'1',psize:'1000'}):{ids:[],total:0,pageCount:0};
+  if(live.total!==live.ids.length||live.pageCount>1||live.ids.some(id=>!ids.includes(id)))throw Error('unverified_appointment_absence');
+  const present=new Set(live.ids);
+  const result=await Psql.tx(async db=>{
+   await db.any("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[co]);
+   const receipt=await db.oneOrNone('SELECT result FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND key=$3',[co,p.runId,key]);if(receipt)return receipt.result;
+   const out={scanned:rows.length,present:present.size,cancelled:0,wouldCancel:0,conflicts:0,after:ids.at(-1)||after,hasMore:rows.length===50,changes:[]};
+   for(const row of rows){
+    const gid=Number(row.generalId);if(present.has(gid))continue;
+    const c=(await db.oneOrNone('SELECT to_jsonb(c) AS data FROM "CommonModel" c WHERE company=$1 AND "objectId"=$2 FOR UPDATE',[co,row.common.objectId]))?.data;
+    if(!c||Number(c.status)===-2)continue;
+    const a=(await db.oneOrNone('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a WHERE company=$1 AND id=$2 FOR UPDATE',[co,c.itemId]))?.data;
+    let reason='';
+    if(!a||!appointmentDeletionImported(c)||!appointmentDeletionImported(a))reason='来源或主附表不完整';
+    else if(JSON.stringify(c)!==JSON.stringify(row.common)||JSON.stringify(a)!==JSON.stringify(row.addon))reason='核对期间新系统已有修改';
+    else if(c.teachingManaged||a.teachingManaged||a.bindingId||a.creditHoldStatus||a.courseCategoryKey||a.payStatus||Number(a.dszt||0)!==0)reason='保留新系统排课、课时预留、上课或计薪凭据';
+    else if(await db.oneOrNone('SELECT "objectId" FROM "LessonRecord" WHERE company=$1 AND yyds::text=ANY($2::text[]) LIMIT 1',[co,[String(gid),String(a.id)]]))reason='已有上课记录';
+    else {
+     const baseline=await db.oneOrNone('SELECT fingerprint=md5($4::jsonb::text) AS matches FROM "LegacyDirectBaseline" WHERE company=$1 AND model=$2 AND gid=$3',[co,54,gid,JSON.stringify([c,a])]);
+     if(baseline?!baseline.matches:!appointmentDeletionUnmodified(c)||!appointmentDeletionUnmodified(a))reason='保留新系统已修改的课次';
+    }
+    if(reason){if(!p.dryRun)await appointmentDeletionConflict(db,co,gid,reason);out.conflicts++;continue;}
+    if(p.dryRun){out.wouldCancel++;continue;}
+    await db.none('UPDATE "CommonModel" SET status=-2,"updatedAt"=now() WHERE company=$1 AND "objectId"=$2',[co,c.objectId]);
+    out.cancelled++;out.changes.push({generalId:gid,objectId:c.objectId,previousStatus:c.status,status:-2,source:'old-api/WXAPP',confirmedAbsent:true});
+   }
+   await db.none('INSERT INTO "LegacyDirectBatch" (company,"runId",key,fingerprint,result) VALUES($1,$2,$3,md5($4),$5::jsonb)',[co,p.runId,key,JSON.stringify([sourceRunId,after,ids,live.ids]),JSON.stringify(out)]);
+   return out;
+  });
+  return response.json({success:true,data:result});
+ }catch(error){return response.status(500).json({success:false,message:String(error.message||'appointment_deletion_failed')});}
+}

+ 1 - 0
scripts/cloud/legacy-direct-sync.js

@@ -2,6 +2,7 @@ async function handler(request,response){
 try{
 try{
  const u=request.user;if(!u)return response.status(401).json({success:false});await u.fetch({useMasterKey:true});if(u.get('isDisabled')||u.get('isDeleted')||u.get('adminRoleKey')!=='super-admin'||u.get('company')?.id!==__COMPANY_ID__)return response.status(403).json({success:false});
  const u=request.user;if(!u)return response.status(401).json({success:false});await u.fetch({useMasterKey:true});if(u.get('isDisabled')||u.get('isDeleted')||u.get('adminRoleKey')!=='super-admin'||u.get('company')?.id!==__COMPANY_ID__)return response.status(403).json({success:false});
  const co=__COMPANY_ID__,p=request.body?.params||{},specs=__SPECS__,windows=__WINDOWS__,q=x=>'"'+x+'"',s=specs[p.modelId],m=Number(p.modelId);
  const co=__COMPANY_ID__,p=request.body?.params||{},specs=__SPECS__,windows=__WINDOWS__,q=x=>'"'+x+'"',s=specs[p.modelId],m=Number(p.modelId);
+ if(p.action==='status'){const result=await directControl(p,co,u);return response.json({success:true,data:{...result,appointmentDeletionVersion:'confirmed-ids-v1'}});}
  if(p.action==='review-income'||p.action==='finance-members'||p.action==='finance'||p.action==='status'||p.action==='claim'||p.action==='source'||p.action==='finish'||p.action==='conflicts'||p.action==='verify'||p.action==='supplement'||p.action==='evidence'||p.action==='reset-dataset')return response.json({success:true,data:await directControl(p,co,u)});
  if(p.action==='review-income'||p.action==='finance-members'||p.action==='finance'||p.action==='status'||p.action==='claim'||p.action==='source'||p.action==='finish'||p.action==='conflicts'||p.action==='verify'||p.action==='supplement'||p.action==='evidence'||p.action==='reset-dataset')return response.json({success:true,data:await directControl(p,co,u)});
  await directLease(Psql,p,co);
  await directLease(Psql,p,co);
  const legacy=r=>r&&typeof r.sourceKey==='string'&&(/\[\["(?:ID|GeneralID)",\d+\]\]/.test(r.sourceKey)||/^legacy:model:\d+:general:\d+$/.test(r.sourceKey));
  const legacy=r=>r&&typeof r.sourceKey==='string'&&(/\[\["(?:ID|GeneralID)",\d+\]\]/.test(r.sourceKey)||/^legacy:model:\d+:general:\d+$/.test(r.sourceKey));

+ 29 - 0
scripts/deploy-legacy-appointment-deletions.mjs

@@ -0,0 +1,29 @@
+#!/usr/bin/env node
+import fs from 'node:fs';
+import {createHash} from 'node:crypto';
+import {buildAppointmentDeletionGateway,patchAppointmentDeletionCapability} from './lib/legacy-appointment-deletions.mjs';
+const env=process.env,master=env.XIAOSHU_MASTER_KEY,token=env.XIAOSHU_SYNC_OPERATOR_TOKEN,out=env.XIAOSHU_DIRECT_DEPLOY_REPORT_DIR;
+if(!master||!token||!out)throw Error('Authorized deployment configuration and private backup directory required');
+fs.mkdirSync(out,{recursive:true,mode:0o700});
+const base=(env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',co=env.XIAOSHU_SYNC_COMPANY_ID||'7pIbDBJmKx';
+const hash=value=>createHash('sha256').update(value).digest('hex');
+async function api(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined,signal:AbortSignal.timeout(30000)}),d=await r.json();if(!r.ok||d.error)throw Error('Deployment API HTTP '+r.status);return d;}
+async function find(path){const rows=(await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({path}),limit:'2'}))).results;if(rows.length>1)throw Error('Ambiguous deployment target');return rows[0];}
+const direct=await find('xiaoshu/sync/direct-v1');if(!direct)throw Error('Direct sync gateway missing');
+const match=direct.code.match(/windows=(\[.*?\]),q=/);if(!match)throw Error('Unknown existing migration windows');
+const code=buildAppointmentDeletionGateway(co,JSON.parse(match[1])),patched=patchAppointmentDeletionCapability(direct.code);new Function(patched);
+const path='xiaoshu/sync/appointment-deletions-v1',previous=await find(path);
+fs.writeFileSync(out+'/deletion-function-before.json',JSON.stringify(previous||null),{mode:0o600});fs.writeFileSync(out+'/direct-function-before.json',JSON.stringify(direct),{mode:0o600});
+const plan={directId:direct.objectId,beforeHash:hash(direct.code),afterHash:hash(patched),deletionHash:hash(code),path};
+fs.writeFileSync(out+'/deployment-plan.json',JSON.stringify(plan,null,2),{mode:0o600});
+if(!process.argv.includes('--apply')){console.log(JSON.stringify({...plan,prepared:true}));process.exit(0);}
+if(hash((await api('/classes/Function/'+direct.objectId)).code)!==plan.beforeHash)throw Error('Concurrent sync gateway change');
+const definition={name:'xiaoshu.sync.appointment-deletions.v1',path,type:'standalone',enabled:true,code,params:[],paramList:[],respType:'json',desc:'确认旧预约删除,保护新系统数据与教学凭据'};
+const saved=await api('/classes/Function'+(previous?'/'+previous.objectId:''),previous?'PUT':'POST',definition),id=previous?.objectId||saved.objectId;
+if(hash((await api('/classes/Function/'+id)).code)!==plan.deletionHash)throw Error('Deletion gateway readback failed');
+const status=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{action:'status'}}),signal:AbortSignal.timeout(20000)}),result=await status.json();
+if(!status.ok||!result.success||result.data.version!=='confirmed-ids-v1')throw Error('Deletion gateway authorization/status failed');
+if(hash((await api('/classes/Function/'+direct.objectId)).code)!==plan.beforeHash)throw Error('Concurrent sync gateway change');
+if(plan.beforeHash!==plan.afterHash)await api('/classes/Function/'+direct.objectId,'PUT',{code:patched});
+if(hash((await api('/classes/Function/'+direct.objectId)).code)!==plan.afterHash)throw Error('Direct gateway readback failed');
+fs.writeFileSync(out+'/deployment-verified.json',JSON.stringify({...plan,id,verified:true,at:new Date().toISOString()},null,2),{mode:0o600});console.log(JSON.stringify({deployed:true,sourceReadbackVerified:true,existingSyncCodePreserved:true}));

+ 11 - 0
scripts/lib/legacy-appointment-deletions.mjs

@@ -0,0 +1,11 @@
+import {readFileSync} from 'node:fs';
+export function buildAppointmentDeletionGateway(company,windows=[]){
+ const code=readFileSync(new URL('../cloud/legacy-appointment-deletions.js',import.meta.url),'utf8').replaceAll('__COMPANY_ID__',JSON.stringify(company)).replaceAll('__WINDOWS__',JSON.stringify(windows));
+ new Function(code);return code;
+}
+export function patchAppointmentDeletionCapability(code){
+ if(code.includes("appointmentDeletionVersion:'confirmed-ids-v1'"))return code;
+ const handler=code.indexOf('async function handler(request,response)'),at=code.indexOf(" if(p.action===",handler);
+ if(handler<0||at<handler)throw Error('Unknown direct sync handler');
+ return code.slice(0,at)+" if(p.action==='status'){const result=await directControl(p,co,u);return response.json({success:true,data:{...result,appointmentDeletionVersion:'confirmed-ids-v1'}});}\n"+code.slice(at);
+}

+ 44 - 0
scripts/reconcile-legacy-appointment-deletions.mjs

@@ -0,0 +1,44 @@
+#!/usr/bin/env node
+import {randomUUID} from 'node:crypto';
+import {mkdir,writeFile,rename} from 'node:fs/promises';
+import {homedir} from 'node:os';
+import {resolve,join} from 'node:path';
+import {fileURLToPath} from 'node:url';
+
+export async function reconcileAppointmentDeletions(env=process.env,{sourceRunId,dryRun=false,call:providedCall,onProgress=()=>{}}={}){
+ const root=resolve(env.XIAOSHU_SYNC_REPORT_DIR||join(homedir(),'.local/state/xiaoshu/legacy-sync'));
+ await mkdir(root,{recursive:true,mode:0o700});
+ const report={runId:randomUUID(),sourceRunId,dryRun,startedAt:new Date().toISOString(),status:'running',scanned:0,present:0,cancelled:0,wouldCancel:0,conflicts:0};
+ const path=join(root,'appointment-deletions-'+report.runId+'.json');
+ const save=async()=>{await writeFile(path+'.tmp',JSON.stringify(report,null,2),{mode:0o600});await rename(path+'.tmp',path);};
+ const call=providedCall||(async params=>{
+  if(!env.XIAOSHU_SYNC_OPERATOR_TOKEN)throw Error('missing_sync_operator_session');
+  const url=(env.XIAOSHU_FUNCTION_URL||'https://server.xiaoshu.pro/api/functions').replace(/\/$/,'')+'/xiaoshu/sync/appointment-deletions-v1';
+  for(let attempt=0;;attempt++)try{
+   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();
+   if(!response.ok||!value.success)throw Error(value.message||'appointment_deletion_request_failed');return value.data;
+  }catch(error){if(attempt>=2)throw error;}
+ });
+ const request=params=>call({...params,runId:report.runId,...(sourceRunId?{sourceRunId}:{})});
+ try{
+  if((await request({action:'status'})).version!=='confirmed-ids-v1')throw Error('wrong_appointment_deletion_gateway');
+  if(!sourceRunId){
+   const first=await request({action:'source',page:1});
+   if(!Number.isSafeInteger(first.total)||first.total<1||first.pages!==Math.ceil(first.total/1000))throw Error('incomplete_appointment_snapshot');
+   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});}
+  }
+  let after=0;
+  for(let batch=0;batch<10000;batch++){
+   const result=await request({action:'reconcile',after,dryRun});
+   for(const key of ['scanned','present','cancelled','wouldCancel','conflicts'])report[key]+=Number(result[key]||0);
+   await save();onProgress({phase:'reconcile',scanned:report.scanned,cancelled:report.cancelled,conflicts:report.conflicts});
+   if(!result.hasMore){report.status=dryRun?'preview':report.conflicts?'completed-with-preserved-conflicts':'completed';return report;}
+   if(!Number.isSafeInteger(result.after)||result.after<=after)throw Error('appointment_deletion_cursor_stalled');after=result.after;
+  }
+  throw Error('appointment_deletion_batch_limit');
+ }catch(error){report.status='blocked';report.reason=String(error.message||'appointment_deletion_failed').replace(/r:[A-Za-z0-9]+/g,'[redacted]');throw error;}
+ finally{report.finishedAt=new Date().toISOString();await save();}
+}
+if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
+ 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;});
+}

+ 6 - 0
scripts/run-nightly-direct-sync.mjs

@@ -7,6 +7,7 @@ import {randomUUID,randomBytes,createHash} from 'node:crypto';
 import {fileURLToPath} from 'node:url';
 import {fileURLToPath} from 'node:url';
 import {specs,modelOrder} from './lib/legacy-direct-specs.mjs';
 import {specs,modelOrder} from './lib/legacy-direct-specs.mjs';
 import {projectionFor,legacyAddonFields,signatureValue} from './lib/legacy-direct-projection.mjs';
 import {projectionFor,legacyAddonFields,signatureValue} from './lib/legacy-direct-projection.mjs';
+import {reconcileAppointmentDeletions} from './reconcile-legacy-appointment-deletions.mjs';
 
 
 export function changesFor(m,source,target,fields){
 export function changesFor(m,source,target,fields){
  const result={};for(const field of fields){const key=Object.keys(source).find(k=>k.toLowerCase()===field.toLowerCase()),raw=source[key];
  const result={};for(const field of fields){const key=Object.keys(source).find(k=>k.toLowerCase()===field.toLowerCase()),raw=source[key];
@@ -110,6 +111,11 @@ export async function runDirectSync(env=process.env,args=process.argv.slice(2)){
    if(allIds.size!==total)throw Error('source_shifted_restart_dataset');
    if(allIds.size!==total)throw Error('source_shifted_restart_dataset');
    stats.sourceContentChecksum=contentHash.digest('hex');stats.sourceIdChecksum=createHash('md5').update([...allIds].sort((a,b)=>a-b).join('\n')).digest('hex');
    stats.sourceContentChecksum=contentHash.digest('hex');stats.sourceIdChecksum=createHash('md5').update([...allIds].sort((a,b)=>a-b).join('\n')).digest('hex');
    const evidence=await call({action:'evidence',modelId:m,conflicts:stats.conflicts},2);if(evidence.sourceChecksum!==stats.sourceIdChecksum||!evidence.reconciled)throw Error('source_target_checksum_mismatch');
    const evidence=await call({action:'evidence',modelId:m,conflicts:stats.conflicts},2);if(evidence.sourceChecksum!==stats.sourceIdChecksum||!evidence.reconciled)throw Error('source_target_checksum_mismatch');
+   if(m===54&&status.appointmentDeletionVersion==='confirmed-ids-v1'){
+    report.appointmentDeletions=await reconcileAppointmentDeletions(env);
+    stats.deleted=report.appointmentDeletions.cancelled;
+    stats.conflicts+=report.appointmentDeletions.conflicts;
+   }
    Object.assign(stats,evidence);report.datasets.push(stats);await save(join(runRoot,'progress.json'),report);break;
    Object.assign(stats,evidence);report.datasets.push(stats);await save(join(runRoot,'progress.json'),report);break;
    }catch(e){if(!String(e.message).includes('source_shifted_restart_dataset')||attempt>=2)throw e;attempt++;await call({action:'reset-dataset',modelId:m});for(const file of await readdir(runRoot))if(file.startsWith(m+'-')&&file!==m+'-attempt.json')await rm(join(runRoot,file));await save(attemptPath,{attempt});console.log(JSON.stringify({modelId:m,restartForSourceChanges:attempt}));}
    }catch(e){if(!String(e.message).includes('source_shifted_restart_dataset')||attempt>=2)throw e;attempt++;await call({action:'reset-dataset',modelId:m});for(const file of await readdir(runRoot))if(file.startsWith(m+'-')&&file!==m+'-attempt.json')await rm(join(runRoot,file));await save(attemptPath,{attempt});console.log(JSON.stringify({modelId:m,restartForSourceChanges:attempt}));}
    }
    }

+ 141 - 0
scripts/tests/legacy-appointment-deletions.test.mjs

@@ -0,0 +1,141 @@
+import {test,before,after,beforeEach} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {mkdtemp,rm} from 'node:fs/promises';
+import {tmpdir} from 'node:os';
+import {join} from 'node:path';
+import vm from 'node:vm';
+import {PGlite} from '@electric-sql/pglite';
+import {buildAppointmentDeletionGateway,patchAppointmentDeletionCapability} from '../lib/legacy-appointment-deletions.mjs';
+import {reconcileAppointmentDeletions} from '../reconcile-legacy-appointment-deletions.mjs';
+import {runDirectSync} from '../run-nightly-direct-sync.mjs';
+
+const db=new PGlite(),runId='12345678-1234-4234-8234-123456789abc';
+const sql=async(q,args=[])=>(await db.query(q,args)).rows;
+const Psql={any:sql,query:sql,one:async(q,a)=>(await sql(q,a))[0],oneOrNone:async(q,a)=>(await sql(q,a))[0]||null,none:async(q,a)=>{await sql(q,a);}};
+Psql.tx=async fn=>{await db.exec('BEGIN');try{const value=await fn(Psql);await db.exec('COMMIT');return value;}catch(error){await db.exec('ROLLBACK');throw error;}};
+let context,liveIds,fail,wrongFilter,canaryMissing,readHook;
+before(async()=>{
+ await db.exec(readFileSync(new URL('../sql/legacy-direct-sync.sql',import.meta.url),'utf8'));
+ await db.exec(`CREATE TABLE "CommonModel"("objectId" text PRIMARY KEY,company text,"sourceKey" text,"generalId" numeric,"itemId" numeric,"modelId" numeric,status numeric,title text,"createdAt" timestamptz,"updatedAt" timestamptz,"teachingManaged" boolean);
+ CREATE TABLE "CourseAppointment"("objectId" text PRIMARY KEY,company text,id numeric,"sourceKey" text,dszt text,pl text,"bindingId" text,"creditHoldStatus" text,"courseCategoryKey" text,"payStatus" text,"createdAt" timestamptz,"updatedAt" timestamptz,"teachingManaged" boolean);
+ CREATE TABLE "LessonRecord"("objectId" text,company text,yyds text);
+ CREATE TABLE "LegacySyncConflict"("objectId" text PRIMARY KEY,company text,"sourceKey" text,dataset text,"legacyKey" text,"conflictType" text,message text,resolved boolean,resolution text,"createdAt" timestamptz,"updatedAt" timestamptz);`);
+});
+after(()=>db.close());
+beforeEach(async()=>{
+ await db.exec('TRUNCATE "CommonModel","CourseAppointment","LessonRecord","LegacySyncConflict","LegacyDirectBaseline","LegacyDirectBatch","LegacyDirectSourcePage"');
+ liveIds=new Set([100]);fail=false;wrongFilter=false;canaryMissing=false;readHook=null;
+ context={Psql,URLSearchParams,AbortSignal,Date,Parse:{Config:{get:async()=>({get:key=>({legacyScheduleApiUrl:'https://old.invalid/api',legacyScheduleApiId:'test',legacyScheduleApiKey:'test'}[key])})}},fetch:async url=>{
+  const p=new URL(url).searchParams,requested=p.get('ids')?.split(',').map(Number);
+  if(readHook&&requested&&!requested.includes(100))await readHook();
+  if(fail&&requested&&!requested.includes(100))return{ok:true,json:async()=>({retcode:-1,result:[],page:{itemCount:0,pageCount:0}})};
+  let ids=requested?requested.filter(id=>liveIds.has(id)):[100];
+  if(requested&&wrongFilter&&!requested.includes(100))ids=[999];
+  if(requested&&canaryMissing&&requested.includes(100))ids=[];
+  return{ok:true,json:async()=>({retcode:1,result:JSON.stringify(ids.map(GeneralID=>({GeneralID}))),page:{itemCount:ids.length,pageCount:ids.length?1:0}})};
+ }};
+ vm.createContext(context);vm.runInContext(buildAppointmentDeletionGateway('co'),context);
+});
+async function call(params,role='super-admin',company='co'){
+ let status=200,body;const response={status:n=>{status=n;return response;},json:value=>{body=value;return value;}};
+ await context.handler({user:{id:'operator',fetch:async()=>{},get:key=>({adminRoleKey:role,company:{id:company}}[key])},body:{params:{runId,...params}}},response);
+ return{status,...body};
+}
+async function seed(id,{source='legacy',status='0',managed=false,hold='',binding='',pay='',company='co'}={}){
+ const sourceKey=source==='legacy'?'legacy:model:54:general:'+id:'cloud:appointment:'+id;
+ await sql('INSERT INTO "CommonModel" VALUES($1,$2,$3,$4,$4,54,99,\'original\',\'2026-10-01\',\'2026-10-01\',$5)',['c'+id,company,sourceKey,id,managed]);
+ await sql('INSERT INTO "CourseAppointment" VALUES($1,$2,$3,$4,$5,\'30\',$6,$7,\'\',$8,\'2026-10-01\',\'2026-10-01\',$9)',['a'+id,company,id,sourceKey,status,binding,hold,pay,managed]);
+}
+async function snapshot(){const result=await call({action:'source',page:1});assert.equal(result.success,true,result.message);}
+const status=async id=>(await sql('SELECT status FROM "CommonModel" WHERE "generalId"=$1',[id]))[0].status;
+
+test('确认缺失的旧预约标记取消,保留附表及业务字段;同一批次重放不重复修改',async()=>{
+ await seed(101);await snapshot();const before=(await sql('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a'))[0].data;
+ const first=await call({action:'reconcile'});assert.equal(first.success,true,first.message);assert.equal(first.data.cancelled,1);assert.equal(Number(await status(101)),-2);
+ assert.deepEqual((await sql('SELECT to_jsonb(a) AS data FROM "CourseAppointment" a'))[0].data,before);
+ assert.deepEqual(JSON.parse(JSON.stringify((await call({action:'reconcile'})).data)),JSON.parse(JSON.stringify(first.data)));
+ assert.equal((await sql('SELECT COUNT(*)::int count FROM "LegacyDirectBatch"'))[0].count,1);
+ assert.equal(first.data.changes[0].confirmedAbsent,true);
+});
+test('旧系统恢复的编号即使不在快照中,也不能被取消',async()=>{
+ await seed(101);await snapshot();liveIds.add(101);
+ const result=await call({action:'reconcile'});assert.equal(result.data.cancelled,0);assert.equal(result.data.present,1);assert.equal(Number(await status(101)),99);
+});
+test('预览不改业务数据或保存冲突,预览后仍可执行实际核对',async()=>{
+ await seed(101);await seed(102,{status:'30'});await snapshot();
+ const result=await call({action:'reconcile',dryRun:true});assert.equal(result.data.wouldCancel,1);assert.equal(result.data.cancelled,0);assert.equal(Number(await status(101)),99);
+ assert.equal((await sql('SELECT count(*)::int count FROM "LegacySyncConflict"'))[0].count,0);
+ assert.equal((await call({action:'reconcile'})).data.cancelled,1);
+});
+test('接口失败、空目录正向验证失败及编号筛选失效都不能触发取消',async()=>{
+ for(const mode of ['failure','canary','filter']){
+  await db.exec('TRUNCATE "CommonModel","CourseAppointment","LegacyDirectBatch"');await seed(101);await snapshot();
+  fail=mode==='failure';canaryMissing=mode==='canary';wrongFilter=mode==='filter';
+  const result=await call({action:'reconcile'});assert.equal(result.success,false,mode);assert.equal(Number(await status(101)),99);
+  fail=false;canaryMissing=false;wrongFilter=false;
+ }
+});
+test('分页不完整、重复编号或过期快照拒绝删除',async()=>{
+ await seed(101);await snapshot();
+ for(const update of ["total=2","ids='[100,100]'::jsonb,total=2",'"capturedAt"=now()-interval \'1 hour\'']){
+  await sql('UPDATE "LegacyDirectSourcePage" SET '+update);
+  const result=await call({action:'reconcile'});assert.equal(result.success,false);assert.match(result.message,/incomplete_appointment_snapshot/);assert.equal(Number(await status(101)),99);
+  await snapshot();
+ }
+});
+test('新建、已上课、上课中、已预留课时、已计薪和已修改课次受保护,冲突幂等保存',async()=>{
+ await seed(101,{source:'cloud'});await seed(102,{status:'30'});await seed(103,{status:'10'});await seed(104,{hold:'ready'});await seed(105,{binding:'binding'});await seed(106,{pay:'earned'});await seed(107,{managed:true});await seed(108);
+ await sql('INSERT INTO "LessonRecord" VALUES(\'lesson\',\'co\',\'108\')');
+ await seed(109);const row=(await sql('SELECT to_jsonb(c) c,to_jsonb(a) a FROM "CommonModel" c JOIN "CourseAppointment" a ON a.id=c."itemId" WHERE c."generalId"=109'))[0];
+ await sql('INSERT INTO "LegacyDirectBaseline"(company,model,gid,fingerprint) VALUES(\'co\',54,109,md5($1::jsonb::text))',[JSON.stringify([row.c,row.a])]);await sql('UPDATE "CourseAppointment" SET pl=\'changed\' WHERE id=109');
+ await snapshot();const result=await call({action:'reconcile'});assert.equal(result.success,true,result.message);assert.equal(result.data.cancelled,0);assert.equal(result.data.conflicts,8);
+ for(let id=101;id<=109;id++)assert.equal(Number(await status(id)),99);
+ assert.equal((await sql('SELECT count(*)::int count FROM "LegacySyncConflict"'))[0].count,8);
+});
+test('回查接口期间发生的新系统写入保留,不被删除覆盖',async()=>{
+ await seed(101);await snapshot();readHook=()=>sql('UPDATE "CourseAppointment" SET pl=\'concurrent\' WHERE id=101');
+ const result=await call({action:'reconcile'});assert.equal(result.data.cancelled,0);assert.equal(result.data.conflicts,1);assert.equal(Number(await status(101)),99);
+});
+test('其他帐套、非超级管理员不能访问,查询不得碰其他帐套课次',async()=>{
+ await seed(101,{company:'other'});assert.equal((await call({action:'status'},'ops-manager')).status,403);assert.equal((await call({action:'status'},'super-admin','other')).status,403);
+ await snapshot();const result=await call({action:'reconcile'});assert.equal(result.data.scanned,0);assert.equal(Number(await status(101)),99);
+});
+test('客户端完整分页后才核对,空目录和游标停滞明确失败,报告可追踪',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'appointment-delete-'));
+ try{
+  const actions=[];const report=await reconcileAppointmentDeletions({XIAOSHU_SYNC_REPORT_DIR:root},{call:async p=>{actions.push(p.action);if(p.action==='status')return{version:'confirmed-ids-v1'};if(p.action==='source')return{total:1001,pages:2};return{scanned:2,cancelled:1,present:0,conflicts:1,hasMore:false,after:101};}});
+  assert.deepEqual(actions,['status','source','source','reconcile']);assert.equal(report.status,'completed-with-preserved-conflicts');assert.equal(report.cancelled,1);
+  await assert.rejects(reconcileAppointmentDeletions({XIAOSHU_SYNC_REPORT_DIR:root},{call:async p=>p.action==='status'?{version:'confirmed-ids-v1'}:{total:0,pages:1}}),/incomplete_appointment_snapshot/);
+  await assert.rejects(reconcileAppointmentDeletions({XIAOSHU_SYNC_REPORT_DIR:root},{sourceRunId:runId,call:async p=>p.action==='status'?{version:'confirmed-ids-v1'}:{scanned:50,hasMore:true,after:0}}),/cursor_stalled/);
+ }finally{await rm(root,{recursive:true,force:true});}
+});
+test('同步能力补丁可重复使用,并保留现有复习和财务代码',()=>{
+ const original='async function handler(request,response){\n const p=request.body.params,co="co";\n if(p.action===\'status\')return directControl(p,co,u);\n}\nfunction directControl(){}';
+ const patched=patchAppointmentDeletionCapability(original);assert.equal(patchAppointmentDeletionCapability(patched),patched);assert.ok(patched.includes(original.slice(original.indexOf(" if(p.action==='status')"))));
+});
+test('夜间任务在预约对账后自动检查删除,并将保留冲突纳入汇总',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'nightly-appointment-delete-')),saved=globalThis.fetch;const actions=[];
+ const {createHash}=await import('node:crypto');
+ try{
+  globalThis.fetch=async(url,options)=>{
+   const p=JSON.parse(options.body).params;let data;
+   if(String(url).endsWith('/appointment-deletions-v1')){
+    actions.push(p.action);data=p.action==='status'?{version:'confirmed-ids-v1'}:p.action==='source'?{total:1,pages:1,count:1}:{scanned:3,cancelled:2,conflicts:1,hasMore:false,after:101};
+   }else switch(p.action){
+    case 'status':data={source:'old-api/WXAPP',bridgeUsed:false,appointmentDeletionVersion:'confirmed-ids-v1'};break;
+    case 'claim':data={runId,businessDate:'2026-11-02'};break;
+    case 'supplement':data={accounts:{conflicts:0},nodes:{}};break;
+    case 'source':data={items:p.modelId===54?[{GeneralID:100}]:[],page:{itemCount:p.modelId===54?1:0,pageCount:1}};break;
+    case 'read':data=[{generalId:100,common:{objectId:'c100'},addon:{id:100}}];break;
+    case 'evidence':data={sourceChecksum:createHash('md5').update(p.modelId===54?'100':'').digest('hex'),reconciled:true};break;
+    case 'finance-members':data=[];break;
+    case 'finish':data=p.summary;break;
+    default:throw Error('Unexpected action '+p.action);
+   }
+   return{ok:true,json:async()=>({success:true,data})};
+  };
+  const result=await runDirectSync({XIAOSHU_SYNC_REPORT_DIR:root,XIAOSHU_SYNC_OPERATOR_TOKEN:'test'});
+  assert.equal(result.status,'completed-with-preserved-conflicts',result.reason);assert.equal(result.appointmentDeletions.cancelled,2);assert.equal(result.datasets.find(row=>row.modelId===54).deleted,2);assert.equal(result.conflicts,1);assert.deepEqual(actions,['status','source','reconcile']);
+ }finally{globalThis.fetch=saved;await rm(root,{recursive:true,force:true});}
+});