Просмотр исходного кода

feat(sync): run legacy API sync nightly on macOS

彭峰 6 часов назад
Родитель
Сommit
afec26f251

+ 1 - 2
deployment/systemd/xiaoshu-legacy-sync-nightly.service

@@ -8,8 +8,7 @@ Type=oneshot
 WorkingDirectory=/opt/xiaoshu-angular
 EnvironmentFile=/etc/xiaoshu/legacy-sync-worker.env
 Environment=XIAOSHU_SYNC_REPORT_DIR=/var/lib/xiaoshu-legacy-sync
-Environment=XIAOSHU_SYNC_DATASETS=nodes,vocabulary,special-training,course-bindings,learning-records,practice-records,memory-records,assessments,appointments,lessons
-ExecStart=/usr/bin/node scripts/run-nightly-legacy-sync.mjs
+ExecStart=/usr/bin/node scripts/run-nightly-direct-sync.mjs --scheduled
 TimeoutStartSec=50min
 User=xiaoshu
 Group=xiaoshu

+ 29 - 0
docs/migration/direct-nightly-acceptance-2026-10-12.md

@@ -0,0 +1,29 @@
+# Mac 旧接口夜间同步验收(2026-10-12 业务日)
+
+用户已确认使用本机。Mac LaunchAgent 已安装并回读日历配置为 Asia/Shanghai 每晚 23:00。首次实跑由安装加载触发,开始于 2026-10-12 23:50:36,完成于 2026-10-13 00:21:13;未来首次真正的 23 点日历触发仍由自动任务 23 核查,不能把本次安装触发称为已验证未来日历触发。
+
+执行来源 old-api/WXAPP,bridgeUsed=false。服务端运行编号 `11fe9a30-3b47-4403-b334-7544ac3e02bb`,状态 `completed-with-preserved-conflicts`。共核验本轮实际捕获的 822,523 个业务编号;所有编号均有一份主记录和关联附表,编号校验和相符。新增 53 条、更新 181 条、保留 1,330 条差异(业务记录 1,177 条、账号资料/余额差异 153 条),失败 0 条。保留冲突意味着新旧内容有意不同,不代表源内容完全覆盖目标。
+
+| 数据集 | 本轮旧接口记录 | 新增 | 更新 | 保留冲突 | 失败 |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| 专项训练 | 19 | 0 | 0 | 19 | 0 |
+| 词库 | 106,520 | 0 | 0 | 148 | 0 |
+| 课程绑定 | 1,519 | 0 | 0 | 105 | 0 |
+| 每日学习 | 19,638 | 2 | 0 | 0 | 0 |
+| 练习记录 | 443,601 | 29 | 74 | 224 | 0 |
+| 抗遗忘 | 200,355 | 20 | 98 | 370 | 0 |
+| 预约 | 31,254 | 0 | 9 | 311 | 0 |
+| 上课 | 19,411 | 2 | 0 | 0 | 0 |
+| 测评 | 206 | 0 | 0 | 0 | 0 |
+
+737 个旧账号、461 个栏目均已匹配,新增和缺失均为 0;现有账号余额未覆盖。源删除的旧记录不自动删除新系统数据。
+
+在抗遗忘和预约写入前与本轮结束后,正金额 ReviewReward 共 1,270 条、合计 ¥1,270.00,618 条已准备或由新系统管理的预约,以及 1,193 个账号的旧账户余额字段集合,其校验值均一致。保护比对文件保存在私有部署目录,不公开业务明细。
+
+同一个 LaunchAgent 再次 kickstart 返回 `already_completed`,两次系统执行均 exit 0;服务端仍只有这一轮成功运行。25 项同步与复习工资测试通过,含鉴权、主附表事务回滚、批次重试、微秒版本/投影指纹保护、已计薪记录保护、缺失核验拒绝成功、完成回执幂等、失效进程锁恢复和缓存保留。线上匿名访问返回 401,部署源码回读一致,发布及核验临时函数均已删除并确认。
+
+运行文件位于 `~/Library/Application Support/XiaoshuSync/`;凭据文件为 0600,运行不使用主密钥;电脑须开机并登录,Codex 可关闭。运行报告位于 `~/.local/state/xiaoshu/legacy-sync/2026-10-12-direct-11fe9a30-3b47-4403-b334-7544ac3e02bb.json`。每日失败每 5 分钟重试,断点保留;成功分页缓存保留 7 天,报告继续保留。
+
+Codex 自动任务 23 已改为只核查本机系统任务与真实服务端回执,避免双重执行。未放宽工资结算门槛,settlementReady=false。
+
+本次交付排除已有的 `projects/xiaoshu-mobile/src/app/features/learning/learning-page.component.ts`、`projects/xiaoshu-mobile/src/app/core/home-learning-modules.integration.spec.ts` 改动,以及历史迁移报告和 perf 缓存。

+ 26 - 0
docs/migration/nightly-legacy-sync.md

@@ -1,5 +1,31 @@
 # 每天 23:00 的旧系统同步任务
 
+## 当前方案:Mac 定时任务直接读取旧接口(2026-10-12)
+
+用户已确认使用这台 Mac。当前入口为 `scripts/run-nightly-direct-sync.mjs`,通过新系统的专用鉴权网关 `xiaoshu/sync/direct-v1` 读取此前成功使用的旧系统 WXAPP 接口。不经过同步桥,也不要求发布旧站程序。旧接口凭据由服务端配置读取,本机只持有专用超级管理员会话,不保存主密钥。
+
+安装命令:`npm run sync-worker:install-mac`。安装器校验 Mac 时区为 Asia/Shanghai、凭据文件为 0600,并将独立运行文件复制到 `~/Library/Application Support/XiaoshuSync/`,安装 `~/Library/LaunchAgents/cn.xiaoshu.legacy-direct-sync-nightly.plist`。每天北京时间 23:00 运行;登录加载时补跑最近一个已到达的 23 点业务日期;休眠错过日历触发会在唤醒时补跑。失败每 5 分钟重试。电脑须开机并登录该用户,Codex 和聊天窗口可关闭。电脑关机不能按时同步。
+
+```bash
+node --env-file=/Users/mac/.config/xiaoshu/legacy-sync-nightly.env scripts/run-nightly-direct-sync.mjs --check
+node --env-file=/Users/mac/.config/xiaoshu/legacy-sync-nightly.env scripts/run-nightly-direct-sync.mjs
+launchctl print gui/$(id -u)/cn.xiaoshu.legacy-direct-sync-nightly
+```
+
+`--check` 只确认鉴权和同步服务身份。手工命令执行今天;系统任务使用 `--scheduled`,23 点前补上一业务日,23 点后执行今天。同一帐套、业务日期已成功时直接读取成功回执;失败沿用运行编号、缓存的源分页和写入计划续跑。目录锁和服务端租约共同防止重复并发。残留目录锁只有在确认 owner.json 中的进程已退出后才自动清理;进程仍存活或无法确认时停止,避免并发。
+
+同步范围:账号只新增并匹配旧用户编号、栏目只补缺;业务数据依次为专项训练、词库、课程绑定、每日学习、练习、抗遗忘、预约、上课、测评。现有账号资料和余额、人工开课分类、已准备的排课、新系统教学记录、已生成的复习计薪凭据均保留。新增账号需旧角色明确为学员、门店或陪练,不能隐式创建管理员;新密码随机生成,不迁移旧密码散列。
+
+服务端保存真实读取页的编号和内容校验和,核对每条业务编号恰有一份主表及关联附表。写入为主附表与批次回执的同一事务,写后校验字段。保存完整投影指纹;只在新系统未修改时更新,指纹或微秒版本变化均记录冲突。网络重试复用批次结果。源数据分页期间变化时重新扫描该数据集。成功回执基于服务器再次核验,不以计数或客户端标志替代验证。冲突意味着有意保留新数据,不表示源内容与目标完全相同。
+
+本机报告、分页缓存和 launchagent 日志保存在 `~/.local/state/xiaoshu/legacy-sync/`,目录 0700、报告 0600。已成功运行的原始分页缓存保留 7 天后自动清理,报告继续保留,失败运行的断点不清理。凭据保存在 `~/.config/xiaoshu/legacy-sync-nightly.env`(0600),不得提交或输出。线上函数备份保存于私有 state 目录。发布命令 `scripts/deploy-direct-sync.mjs` 只在发布进程内使用主密钥;定时任务不需要主密钥。
+
+这是一日一次同步,不能代替工资结算所需的新鲜度和专项核验;不会设置 settlementReady,不会自动解决既有冲突。Codex 自动任务 23 仅核查 Mac 任务和真实回执,不再执行另一套桥同步。系统任务安装、同入口实跑、未来首次 23 点触发是三个不同验收项目;实际结果以私有运行报告和服务端回执为准。
+
+本次安装与实跑结果见 [2026-10-12 夜间同步验收](direct-nightly-acceptance-2026-10-12.md):新增 53、更新 181、保留冲突 1,330、失败 0;同入口重复触发已验证。
+
+## 历史方案(已被直接接口方案替代,不再作为运行要求)
+
 时区固定 Asia/Shanghai,每晚 23:00 从旧系统读取并同步到新系统。客户继续使用旧站;保留新系统修改,记录冲突,不自动覆盖冲突、删除新系统记录或修改账户课时余额。
 
 ## 2026-10-11 核查

+ 4 - 2
package.json

@@ -33,7 +33,7 @@
     "sync-bridge:publish": "dotnet publish legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release -r win-x64 --self-contained true -o dist/legacy-sync-bridge-win-x64",
     "sync-bridge:package": "npm run sync-bridge:publish && bash legacy-sync-bridge/deployment/package.sh",
     "sync-bridge:smoke": "dotnet build legacy-sync-bridge/Xiaoshu.LegacySyncBridge.csproj -c Release --no-restore && node scripts/smoke-legacy-sync-bridge.mjs",
-    "sync-worker:nightly": "node scripts/run-nightly-legacy-sync.mjs",
+    "sync-worker:nightly": "node scripts/run-nightly-direct-sync.mjs",
     "sync-worker:once": "node scripts/run-legacy-sync-worker.mjs --once",
     "sync-worker:start": "node scripts/run-legacy-sync-worker.mjs",
     "test:sync-worker": "node --test scripts/tests/legacy-sync-worker.test.mjs",
@@ -58,7 +58,9 @@
     "test:admin-release": "node --test scripts/tests/admin-release.test.mjs",
     "test:lesson-workflow": "node --test scripts/tests/lesson-workflow.test.mjs",
     "lessons:migrate": "node scripts/migrate-lesson-workflow.mjs",
-    "lessons:smoke": "node scripts/smoke-mobile-teaching-flow.mjs"
+    "lessons:smoke": "node scripts/smoke-mobile-teaching-flow.mjs",
+    "sync-worker:install-mac": "node scripts/install-direct-sync-launchagent.mjs",
+    "test:direct-sync": "node --test scripts/tests/direct-nightly-sync.test.mjs scripts/tests/review-rewards.test.mjs"
   },
   "private": true,
   "dependencies": {

+ 93 - 0
scripts/cloud/legacy-direct-control.js

@@ -0,0 +1,93 @@
+async function directLease(db,p,co) {
+ const r=await db.oneOrNone('UPDATE "LegacyDirectRun" SET "leaseUntil"=now()+interval \'5 minutes\' WHERE company=$1 AND "runId"=$2 AND owner=$3 AND status=\'running\' AND "leaseUntil">now() RETURNING "runId"',[co,p.runId,p.owner]);
+ if(!r)throw Error('sync_lease_unavailable');
+}
+async function directBaseline(t,co,m,gid,row) {
+ await t.none('INSERT INTO "LegacyDirectBaseline" (company,model,gid,fingerprint) VALUES($1,$2,$3,md5($4::jsonb::text)) ON CONFLICT(company,model,gid) DO UPDATE SET fingerprint=EXCLUDED.fingerprint,"updatedAt"=now()',[co,m,gid,JSON.stringify([row.common,row.addon])]);
+}
+async function directUnchanged(t,co,m,gid,c,a,fallback) {
+ const r=await t.oneOrNone('SELECT fingerprint=md5($4::jsonb::text) matches FROM "LegacyDirectBaseline" WHERE company=$1 AND model=$2 AND gid=$3',[co,m,gid,JSON.stringify([c,a])]);
+ return r?r.matches:fallback(c)&&fallback(a);
+}
+async function directOld(action,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('direct_api_not_configured');
+ const query=Object.entries({action,...args,apiId,apiKey}).map(([k,v])=>encodeURIComponent(k)+'='+encodeURIComponent(String(v))).join('&');
+ let j;
+ try {const r=await fetch(url+(url.includes('?')?'&':'?')+query,{signal:AbortSignal.timeout(60000)});if(!r.ok)throw Error();j=await r.json();} catch {throw Error('legacy_read_failed');}
+ if(Number(j.retcode)===-1)throw Error('legacy_read_rejected');
+ const items=typeof j.result==='string'?JSON.parse(j.result):j.result;
+ if(!Array.isArray(items))throw Error('legacy_invalid_result');return {items,page:j.page||{}};
+}
+async function directControl(p,co,u) {
+ const specs=__SPECS__;
+ if(p.action==='status')return {version:'2026-10-12-direct-nightly-v1',source:'old-api/WXAPP',bridgeUsed:false,runs:await Psql.query('SELECT "businessDate","runId",status,"startedAt","finishedAt",summary FROM "LegacyDirectRun" WHERE company=$1 ORDER BY "startedAt" DESC LIMIT 3',[co])};
+ if(p.action==='claim')return Psql.tx(async t=>{
+  await t.any('SELECT pg_advisory_xact_lock(hashtext($1))',['direct-sync:'+co]);
+  const today=(await t.one("SELECT to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM-DD') AS business_day,to_char((now() AT TIME ZONE 'Asia/Shanghai')-interval '1 day','YYYY-MM-DD') AS yesterday"));
+  const day=p.targetBusinessDate||today.business_day;if(![today.business_day,today.yesterday].includes(day))throw Error('invalid_business_date');
+  const latest=await t.oneOrNone('SELECT * FROM "LegacyDirectRun" WHERE company=$1 AND status=\'running\' ORDER BY "startedAt" DESC LIMIT 1',[co]);
+  if(latest&&latest.owner!==p.owner&&new Date(latest.leaseUntil).getTime()>Date.now())throw Error('another_sync_running');
+  const existing=latest||await t.oneOrNone('SELECT * FROM "LegacyDirectRun" WHERE company=$1 AND "businessDate"=$2',[co,day]);
+  if(existing&&existing.status!=='running')return {alreadyCompleted:true,...existing};
+  if(!/^[a-zA-Z0-9-]{10,80}$/.test(p.owner||'')||!/^[a-zA-Z0-9-]{10,80}$/.test(p.proposedRunId||''))throw Error('invalid_run_identity');
+  if(existing){await t.none('UPDATE "LegacyDirectRun" SET owner=$2,"leaseUntil"=now()+interval \'5 minutes\' WHERE "runId"=$1',[existing.runId,p.owner]);return {runId:existing.runId,businessDate:existing.businessDate,resumed:true};}
+  await t.none('INSERT INTO "LegacyDirectRun" (company,"businessDate","runId",owner,"leaseUntil") VALUES($1,$2,$3,$4,now()+interval \'5 minutes\')',[co,day,p.proposedRunId,p.owner]);return {runId:p.proposedRunId,businessDate:day,resumed:false};
+ });
+ if(p.action==='finish'){const prior=await Psql.oneOrNone('SELECT status,summary FROM "LegacyDirectRun" WHERE company=$1 AND "runId"=$2 AND owner=$3',[co,p.runId,p.owner]);if(prior&&prior.status!=='running')return prior.summary;}
+ await directLease(Psql,p,co);
+ if(p.action==='source'){
+  const m=Number(p.modelId);if(!specs[m])throw Error('invalid_model');
+  if(p.id){if(!Number.isSafeInteger(p.id)||p.id<1)throw Error('invalid_id');return directOld('content_get',{id:p.id});}
+  if(!Number.isSafeInteger(p.page)||p.page<1||p.page>10000)throw Error('invalid_page');
+  const result=await directOld('content_list',{modelId:m,psize:p.countOnly?1:1000,cpage:p.page,...(m===2?{nid:3}:{})});
+  if(!p.countOnly){const ids=result.items.map(x=>Number(x.GeneralID??x.generalId));if(ids.some(id=>!Number.isSafeInteger(id)||id<1))throw Error('invalid_source_id');
+   await Psql.none('INSERT INTO "LegacyDirectSourcePage" (company,"runId",model,page,ids,total,checksum) VALUES($1,$2,$3,$4,$5::jsonb,$6,md5($7::jsonb::text)) ON CONFLICT(company,"runId",model,page) DO UPDATE SET ids=EXCLUDED.ids,total=EXCLUDED.total,checksum=EXCLUDED.checksum',[co,p.runId,m,p.page,JSON.stringify(ids),Number(result.page.itemCount??ids.length),JSON.stringify(result.items)]);
+  }return result;
+ }
+ if(p.action==='supplement')return directSupplement(p,co);
+ if(p.action==='conflicts'){
+  if(!Array.isArray(p.rows)||p.rows.length>100)throw Error('invalid_conflicts');
+  return Psql.tx(async t=>{await directLease(t,p,co);for(const c of p.rows){if(!specs[Number(c.modelId)]||!Number.isSafeInteger(c.generalId))throw Error('invalid_conflict');
+   const source='direct-api:'+c.modelId+':'+c.generalId;
+   await t.none('INSERT INTO "LegacySyncConflict" ("objectId",company,"sourceKey",dataset,"legacyKey","conflictType",message,resolved,resolution,"createdAt","updatedAt") SELECT substr(md5($1),1,20),$2,$1,$3,$4,\'direct-api-preserve-new\',$5,false,\'保留新系统,待人工复核\',now(),now() WHERE NOT EXISTS(SELECT 1 FROM "LegacySyncConflict" WHERE company=$2 AND "sourceKey"=$1)',[source,co,String(c.modelId),String(c.generalId),JSON.stringify({fields:c.fields||[],reason:c.reason||'保留新系统'})]);
+  }return {saved:p.rows.length};});
+ }
+ if(p.action==='verify'){
+  const m=Number(p.modelId),s=specs[m];if(!s||!Array.isArray(p.ids)||p.ids.length>10000||p.ids.some(x=>!Number.isSafeInteger(x)||x<1))throw Error('invalid_verification');
+  return Psql.query('SELECT c."generalId",count(*)::int common_rows,count(a."objectId")::int addon_rows FROM "CommonModel" c LEFT JOIN "'+s.className+'" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId"=ANY($3::numeric[]) GROUP BY c."generalId" ORDER BY c."generalId"',[co,m,p.ids]);
+ }
+ if(p.action==='evidence'){
+  const m=Number(p.modelId),s=specs[m];if(!s)throw Error('invalid_evidence_model');
+  const latest=await directOld('content_list',{modelId:m,psize:1,cpage:1,...(m===2?{nid:3}:{})});
+  return Psql.tx(async t=>{
+   await directLease(t,p,co);
+   const pages=await t.any('SELECT page,ids,total,checksum FROM "LegacyDirectSourcePage" WHERE company=$1 AND "runId"=$2 AND model=$3 ORDER BY page',[co,p.runId,m]);
+   const ids=[...new Set(pages.flatMap(r=>r.ids))].sort((a,b)=>a-b),total=Number(latest.page.itemCount??latest.items.length);
+   if(!pages.length||ids.length!==total||pages.some((r,i)=>Number(r.page)!==i+1||Number(r.total)!==total)||pages.length!==Math.max(1,Math.ceil(total/1000)))throw Error('source_shifted_restart_dataset');
+   const rows=await t.any('SELECT c."generalId",count(*)::int common_rows,count(a."objectId")::int addon_rows FROM "CommonModel" c LEFT JOIN "'+s.className+'" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId" IN (SELECT jsonb_array_elements_text($3::jsonb)::numeric) GROUP BY c."generalId" ORDER BY c."generalId"',[co,m,JSON.stringify(ids)]);
+   if(rows.length!==ids.length||rows.some(r=>r.common_rows!==1||r.addon_rows!==1))throw Error('server_projection_reconciliation_failed');
+   const checks=await t.one('SELECT md5($1) source,md5($2) target,md5($3) content',[ids.join('\n'),rows.map(r=>Number(r.generalId)).join('\n'),pages.map(r=>r.checksum).join('\n')]);
+   if(checks.source!==checks.target)throw Error('server_id_checksum_failed');
+   await t.none('INSERT INTO "LegacyDirectEvidence" (company,"runId",model,ids,"sourceCount","sourceChecksum","targetChecksum",conflicts,"contentChecksum") VALUES($1,$2,$3,$4::jsonb,$5,$6,$7,$8,$9) ON CONFLICT(company,"runId",model) DO UPDATE SET ids=EXCLUDED.ids,"sourceCount"=EXCLUDED."sourceCount","sourceChecksum"=EXCLUDED."sourceChecksum","targetChecksum"=EXCLUDED."targetChecksum",conflicts=EXCLUDED.conflicts,"contentChecksum"=EXCLUDED."contentChecksum"',[co,p.runId,m,JSON.stringify(ids),ids.length,checks.source,checks.target,Number(p.conflicts||0),checks.content]);
+   return {modelId:m,sourceCount:ids.length,sourceChecksum:checks.source,targetChecksum:checks.target,sourcePageContentChecksum:checks.content,reconciled:true};
+  });
+ }
+ if(p.action==='reset-dataset'){if(!specs[Number(p.modelId)])throw Error('invalid_model');await Psql.none('DELETE FROM "LegacyDirectSourcePage" WHERE company=$1 AND "runId"=$2 AND model=$3',[co,p.runId,p.modelId]);await Psql.none('DELETE FROM "LegacyDirectEvidence" WHERE company=$1 AND "runId"=$2 AND model=$3',[co,p.runId,p.modelId]);return {reset:true};}
+ if(p.action==='finish')return Psql.tx(async t=>{
+  await directLease(t,p,co);
+  const expected=[2,52,58,56,53,60,54,59,61],d=p.summary?.datasets;
+  if(!Array.isArray(d)||d.length!==expected.length||!expected.every(m=>d.some(x=>x.modelId===m&&x.reconciled&&x.failed===0)))throw Error('reconciliation_incomplete');
+  // Recompute ID coverage on the server, using the actual run's captured IDs, before a success receipt.
+  const evidence=await t.any('SELECT model,ids,"sourceCount","sourceChecksum","targetChecksum" FROM "LegacyDirectEvidence" WHERE company=$1 AND "runId"=$2',[co,p.runId]);
+  if(evidence.length!==expected.length)throw Error('server_reconciliation_missing');
+  for(const e of evidence){const s=specs[e.model],r=await t.one('SELECT count(*)::int n FROM (SELECT c."generalId" FROM "CommonModel" c JOIN "'+s.className+'" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId" IN (SELECT jsonb_array_elements_text($3::jsonb)::numeric) GROUP BY c."generalId" HAVING count(*)=1) x',[co,e.model,JSON.stringify(e.ids)]);if(r.n!==Number(e.sourceCount)||e.sourceChecksum!==e.targetChecksum)throw Error('server_id_reconciliation_failed');}
+  const totals=await t.any('SELECT (result->>\'modelId\')::int model,COALESCE(sum((result->>\'createdCommon\')::int),0)::int created,COALESCE(sum((result->>\'createdAddon\')::int),0)::int addon,COALESCE(sum((result->>\'updated\')::int),0)::int updated FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 GROUP BY (result->>\'modelId\')::int',[co,p.runId]);
+  for(const x of d){const total=totals.find(t=>t.model===x.modelId);x.created=total?.created||0;x.createdAddon=total?.addon||0;x.updated=total?.updated||0;}
+  const summary={...p.summary,createdBusinessRecords:d.reduce((n,x)=>n+x.created,0),refreshedBusinessRecords:d.reduce((n,x)=>n+x.updated,0),completedAt:new Date().toISOString(),source:'old-api/WXAPP',bridgeUsed:false,continuousSync:false,settlementReady:false,nightlyAutomation:true};
+  await t.none('UPDATE "LegacyDirectRun" SET status=$3,summary=$4::jsonb,"finishedAt"=now(),"leaseUntil"=now() WHERE company=$1 AND "runId"=$2',[co,p.runId,summary.status,JSON.stringify(summary)]);
+  await t.none('INSERT INTO "SysLog" ("objectId",company,"sourceKey",type1,type2,type3,detail,"createdAt","updatedAt") VALUES(substr(md5($1),1,20),$2,$1,\'migration\',\'legacy-direct-sync-completed\',\'nightly\',$3,now(),now())',['direct-nightly:'+p.runId,co,JSON.stringify({actor:u.id,action:'legacy-direct-sync-completed',after:summary})]);
+  return summary;
+ });
+ throw Error('invalid_control_action');
+}

+ 45 - 0
scripts/cloud/legacy-direct-supplement.js

@@ -0,0 +1,45 @@
+async function directSupplement(p,co){
+ async function all(action,args){const first=await directOld(action,{...args,psize:1000,cpage:1}),rows=[...first.items];for(let page=2;page<=Number(first.page.pageCount||1);page++)rows.push(...(await directOld(action,{...args,psize:1000,cpage:page})).items);return rows;}
+ const users=await all('user_list',{uid:0}),nodes=await all('node_list',{nid:0});
+ const result={accounts:{source:users.length,created:0,matched:0,missing:0,conflicts:0},nodes:{source:nodes.length,created:0,matched:0,missing:0},existingAccountBalancesUntouched:true};
+ for(const row of users){
+  const value=projection(row),id=value.legacyUserId;if(!Number.isSafeInteger(id)||id<1)throw Error('invalid_legacy_user_identity');
+  await directLease(Psql,p,co);
+  const found=await Psql.query('SELECT "objectId",username,"legacyUserData" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,id]);
+  if(found.length>1)throw Error('duplicate_legacy_user_identity');
+  if(!found.length){
+   await Psql.tx(async t=>{
+   // Serialize retries for the same legacy identity even if the first HTTP response was lost.
+   await t.any('SELECT pg_advisory_xact_lock(hashtext($1))',['direct-sync-user:'+co+':'+id]);
+   if(await t.oneOrNone('SELECT "objectId" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,id]))return;
+   if(![1,2,3].includes(value.body.legacyGroupId))throw Error('new_legacy_user_role_requires_confirmation');
+   let username=value.username;
+   if(await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE lower(username)=lower($1)',[username]))username='legacy_'+id+'_'+username;
+   if(await Psql.oneOrNone('SELECT "objectId" FROM "_User" WHERE lower(username)=lower($1)',[username]))throw Error('new_legacy_user_login_conflict');
+   const user=new Parse.User();
+   for(const[k,v]of Object.entries(value.body))user.set(k,k==='company'?Parse.Object.createWithoutData('Company',co):v?.__type==='Date'?new Date(v.iso):v);
+   user.set('identityType',value.body.legacyGroupId===3?'coach':value.body.legacyGroupId===2?'store':'member');user.setUsername(username);
+   user.setPassword((await Psql.one('SELECT gen_random_uuid()::text||gen_random_uuid()::text AS password')).password);
+   await user.save(null,{useMasterKey:true});
+   const check=await Psql.query('SELECT "objectId" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,id]);if(check.length!==1)throw Error('new_legacy_user_identity_verification_failed');
+   result.accounts.created++;
+   });
+  }else{
+   const old=found[0].legacyUserData||{},fields=Object.keys(value.body.legacyUserData).filter(k=>!['LastLoginTime','LoginTimes'].includes(k)&&String(value.body.legacyUserData[k]??'')!==String(old[k]??''));
+   if(fields.length){result.accounts.conflicts++;
+    await Psql.none('INSERT INTO "LegacySyncConflict" ("objectId",company,"sourceKey",dataset,"legacyKey","conflictType",message,resolved,resolution,"createdAt","updatedAt") SELECT substr(md5($1),1,20),$2,$1,\'users\',$3,\'direct-api-preserve-new\',$4,false,\'保留新系统账号及余额\',now(),now() WHERE NOT EXISTS(SELECT 1 FROM "LegacySyncConflict" WHERE company=$2 AND "sourceKey"=$1)',['direct-api:user:'+id,co,String(id),JSON.stringify({fields,policy:'preserve-new'})]);
+   }
+  }result.accounts.matched++;
+ }
+ const mapping={nodeId:'NodeID',parentId:'ParentID',nodeName:'NodeName',depth:'Depth',orderId:'OrderID',contentModel:'ContentModel',zstatus:'ZStatus',nodeType:'NodeType',nodeDir:'NodeDir',nodePicUrl:'NodePic',description:'Description',cuser:'CUser',cuname:'CUName'};
+ const columns=await Psql.query('SELECT column_name,data_type FROM information_schema.columns WHERE table_schema=\'public\' AND table_name=\'Node\'');
+ const knownNodes=new Set((await Psql.query('SELECT "nodeId" FROM "Node" WHERE company=$1',[co])).map(r=>Number(r.nodeId)));
+ for(const row of nodes){const id=Number(row.NodeID);if(!Number.isSafeInteger(id)||id<1)throw Error('invalid_node_identity');if(knownNodes.has(id)){result.nodes.matched++;continue;}
+  await Psql.tx(async t=>{await directLease(t,p,co);await t.any('SELECT pg_advisory_xact_lock(hashtext($1))',['direct-sync-node:'+co]);const found=await t.any('SELECT "objectId" FROM "Node" WHERE company=$1 AND "nodeId"=$2',[co,id]);if(found.length>1)throw Error('duplicate_node_identity');if(found.length)return;
+   const body={objectId:(await t.one('SELECT substr(md5(gen_random_uuid()::text),1,20) id')).id,company:co,sourceKey:'legacy:node:'+id,createdAt:new Date().toISOString(),updatedAt:new Date().toISOString()};
+   for(const[field,key]of Object.entries(mapping)){const c=columns.find(c=>c.column_name===field);if(c&&row[key]!==undefined)body[field]=['numeric','double precision','integer'].includes(c.data_type)?Number(row[key]||0):c.data_type==='boolean'?row[key]===true||String(row[key])==='1':String(row[key]??'');}
+   const fields=Object.keys(body).filter(k=>columns.some(c=>c.column_name===k));await t.none('INSERT INTO "Node" ('+fields.map(f=>'"'+f+'"').join(',')+') SELECT '+fields.map(f=>'v."'+f+'"').join(',')+' FROM jsonb_populate_record(NULL::"Node",$1::jsonb) v',[JSON.stringify(body)]);result.nodes.created++;
+  });result.nodes.matched++;
+ }
+ return result;
+}

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

@@ -0,0 +1,75 @@
+async function handler(request,response){
+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 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'||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);
+ const legacy=r=>r&&typeof r.sourceKey==='string'&&(/\[\["(?:ID|GeneralID)",\d+\]\]/.test(r.sourceKey)||/^legacy:model:\d+:general:\d+$/.test(r.sourceKey));
+ const unchanged=r=>legacy(r)&&!r.teachingManaged&&(r.createdAt===r.updatedAt||!r.updatedAt||Date.parse(r.updatedAt)<Date.parse('2026-08-01')||windows.some(([a,b])=>Date.parse(r.updatedAt)>=a&&Date.parse(r.updatedAt)<=b));
+ const sameVersion=(a,b)=>a===b;
+ if(p.action==='overview'){
+ const data=[];for(const [id,spec] of Object.entries(specs)){const row=await Psql.one('SELECT (SELECT count(*)::int FROM "CommonModel" WHERE company=$1 AND "modelId"=$2) common,(SELECT count(*)::int FROM "'+spec.className+'" WHERE company=$1) addon,(SELECT count(*)::int FROM "CommonModel" c WHERE c.company=$1 AND c."modelId"=$2 AND NOT EXISTS(SELECT 1 FROM "'+spec.className+'" a WHERE a.company=c.company AND a.id=c."itemId")) missing_links',[co,Number(id)]);data.push({modelId:Number(id),...row});}return response.json({success:true,data});
+ }
+ if(!s)throw Error('Invalid model');
+ const readSql='SELECT c."generalId",to_jsonb(c) AS common,to_jsonb(a) AS addon'+(m===60?',to_jsonb(r) AS reward':'')+' FROM "CommonModel" c LEFT JOIN "'+s.className+'" a ON a.company=c.company AND a.id=c."itemId"'+(m===60?' LEFT JOIN "ReviewReward" r ON r.company=a.company AND r."taskId"=a."objectId"':'')+' WHERE c.company=$1 AND c."modelId"=$2 AND c."generalId"=ANY($3::numeric[])';
+ if(p.action==='read'){const ids=p.ids||[];if(ids.length>1000||ids.some(x=>!Number.isSafeInteger(x)||x<1))throw Error('Invalid IDs');return response.json({success:true,data:await Psql.query(readSql,[co,m,ids])});}
+ if(!['append','patch'].includes(p.action)||!Array.isArray(p.rows)||p.rows.length>50)throw Error('Invalid mutation');
+ const result=await Psql.tx(async t=>{
+  await directLease(t,p,co);
+  const fingerprint=(await t.one('SELECT md5($1) fingerprint',[JSON.stringify([p.action,m,p.rows])])).fingerprint;
+  const receipt=await t.oneOrNone('SELECT fingerprint,result FROM "LegacyDirectBatch" WHERE company=$1 AND "runId"=$2 AND key=$3',[co,p.runId,p.batchKey]);if(receipt){if(receipt.fingerprint!==fingerprint)throw Error('batch_key_reused');return receipt.result;}
+  await t.any("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[co]);
+  const out={modelId:m,createdCommon:0,createdAddon:0,updated:0,duplicates:0,rewardEvidence:0,conflicts:[],postimages:[]};
+  async function insert(table,data,allowed){if(Object.keys(data).some(k=>!allowed.includes(k)))throw Error('Unexpected projected field');const cols=Object.keys(data).map(q).join(',');return t.one('INSERT INTO '+q(table)+' ('+cols+') SELECT '+Object.keys(data).map(k=>'v.'+q(k)).join(',')+' FROM jsonb_populate_record(NULL::'+q(table)+',$1::jsonb) v RETURNING *',[JSON.stringify(data)]);}
+  for(const incoming of p.rows){
+   const gid=Number(incoming.generalId);if(!Number.isSafeInteger(gid)||gid<1)throw Error('Invalid incoming ID');
+   const cc=(await t.oneOrNone('SELECT to_jsonb(c) data FROM "CommonModel" c WHERE company=$1 AND "modelId"=$2 AND "generalId"=$3 FOR UPDATE',[co,m,gid]))?.data;
+   if(p.action==='append'){
+    if(cc&&(cc.teachingManaged||Number(cc.status)===-2||!legacy(cc))){out.conflicts.push({generalId:gid,reason:'新系统课次已受保护'});continue;}
+    let item=cc?Number(cc.itemId):Number(incoming.addon?.id||gid);
+    let aa=(await t.oneOrNone('SELECT to_jsonb(a) data FROM '+q(s.className)+' a WHERE company=$1 AND id=$2 FOR UPDATE',[co,item]))?.data;
+    if(cc&&aa)continue;
+    const sourceKey='legacy:model:'+m+':general:'+gid;
+    if(!cc&&aa){item=900000000000+gid*100+m;aa=(await t.oneOrNone('SELECT to_jsonb(a) data FROM '+q(s.className)+' a WHERE company=$1 AND id=$2 FOR UPDATE',[co,item]))?.data;if(aa){out.conflicts.push({generalId:gid,reason:'附表编号冲突'});continue;}}
+    const orphan=await t.oneOrNone('SELECT to_jsonb(a) data FROM '+q(s.className)+' a WHERE company=$1 AND "sourceKey"=$2 FOR UPDATE',[co,sourceKey]);
+    if(orphan){out.conflicts.push({generalId:gid,reason:'已有同源孤立附表,保留待核对'});continue;}
+    const now=new Date().toISOString(),common={...incoming.common,company:co,sourceKey,generalId:gid,itemId:item,modelId:m,createdAt:now,updatedAt:now},addon={...incoming.addon,company:co,sourceKey,id:item,createdAt:now,updatedAt:now};
+    if(!common.objectId||!addon.objectId)throw Error('Missing object IDs');
+    if(m===60){
+     const key=(await t.one('SELECT xiaoshu_review_key($1,$2,$3) key',[String(addon.yhid),String(addon.xxjlid),String(addon.kywrq).slice(0,10)])).key;
+     const duplicate=key&&await t.oneOrNone('SELECT "objectId" FROM "MemoryPracticeRecord" WHERE company=$1 AND "rewardTaskKey"=$2 LIMIT 1',[co,key]);
+     addon.rewardTaskKey=duplicate?null:key;addon.rewardDuplicate=Boolean(duplicate);if(duplicate)out.duplicates++;
+     // Same bounded historical-import semantics used on 2026-10-08. Only this transaction and this allowlisted table; restore triggers before recording historical zero-amount evidence.
+     await t.none("SET LOCAL session_replication_role='replica'");
+     await insert(s.className,addon,s.addonColumns);await t.none("SET LOCAL session_replication_role='origin'");
+     if(String(addon.fxzt)==='1'){await t.any('SELECT xiaoshu_record_review_reward(m,true) FROM "MemoryPracticeRecord" m WHERE m.company=$1 AND m."objectId"=$2',[co,addon.objectId]);out.rewardEvidence++;}
+    }else await insert(s.className,addon,s.addonColumns);
+    out.createdAddon++;
+    if(!cc){await insert('CommonModel',common,s.commonColumns);out.createdCommon++;}
+   }else{
+    if(!cc){out.conflicts.push({generalId:gid,reason:'主记录不存在'});continue;}
+    const aa=(await t.oneOrNone('SELECT to_jsonb(a) data FROM '+q(s.className)+' a WHERE company=$1 AND id=$2 FOR UPDATE',[co,cc.itemId]))?.data;
+    if(!aa||cc.objectId!==incoming.commonId||aa.objectId!==incoming.addonId||!sameVersion(cc.updatedAt,incoming.commonVersion)||!sameVersion(aa.updatedAt,incoming.addonVersion)||!(await directUnchanged(t,co,m,gid,cc,aa,unchanged))){
+     out.conflicts.push({generalId:gid,reason:'新系统已有修改、版本变化或来源不可确认'});continue;
+    }
+    if(m===2||m===52||cc.teachingManaged||aa.teachingManaged||m===54&&(aa.bindingId||aa.creditHoldStatus||aa.courseCategoryKey)||m===58&&aa.enrollmentStatus==='active'){out.conflicts.push({generalId:gid,reason:'保留新系统课程内容或教学凭据'});continue;}
+    const fields=Object.keys(incoming.values||{});if(!fields.length||fields.some(f=>!s.fields.includes(f)))throw Error('Invalid patch fields');
+    if(m===60&&(await t.oneOrNone('SELECT "objectId" FROM "ReviewReward" WHERE company=$1 AND "taskId"=$2',[co,aa.objectId])||fields.some(f=>['yhid','xxjlid','kywrq','plid'].includes(f)))){out.conflicts.push({generalId:gid,reason:'保留奖励凭据或复习身份'});continue;}
+    if(m===60)await t.none("SET LOCAL session_replication_role='replica'");
+    await t.none('UPDATE '+q(s.className)+' a SET '+fields.map(f=>q(f)+'=v.'+q(f)).join(',')+',"updatedAt"=now() FROM jsonb_populate_record(NULL::'+q(s.className)+',$3::jsonb) v WHERE a.company=$1 AND a."objectId"=$2',[co,aa.objectId,JSON.stringify(incoming.values)]);
+    if(m===60){await t.none("SET LOCAL session_replication_role='origin'");await t.any('SELECT xiaoshu_record_review_reward(m,true) FROM "MemoryPracticeRecord" m WHERE m.company=$1 AND m."objectId"=$2 AND m.fxzt::text=\'1\'',[co,aa.objectId]);}
+    out.updated++;
+   }
+   const after=await t.oneOrNone(readSql,[co,m,[gid]]);if(!after?.common||!after.addon)throw Error('Atomic projection verification failed');
+   if(p.action==='patch'){const expected=(await t.one('SELECT to_jsonb(v) expected FROM jsonb_populate_record(NULL::'+q(s.className)+',$1::jsonb) v',[JSON.stringify(incoming.values)])).expected;if(Object.keys(incoming.values).some(f=>JSON.stringify(expected[f])!==JSON.stringify(after.addon[f])))throw Error('Post-write field verification failed');}
+   if(m===60&&after.reward&&!(Number(after.reward.amount)===0&&['historical','exception'].includes(after.reward.status)))throw Error('Historical reward verification failed');
+   await directBaseline(t,co,m,gid,after);
+   out.postimages.push(after);
+  }
+  if(!/^[A-Za-z0-9:._-]{1,160}$/.test(p.batchKey||''))throw Error('Invalid batch key');
+  await t.none('INSERT INTO "LegacyDirectBatch" (company,"runId",key,fingerprint,result) VALUES($1,$2,$3,$4,$5::jsonb)',[co,p.runId,p.batchKey,fingerprint,JSON.stringify(out)]);
+  return out;
+ });
+ return response.json({success:true,data:result});
+}catch(e){return response.status(500).json({success:false,message:String(e.message||e)});}
+}

+ 32 - 0
scripts/deploy-direct-sync.mjs

@@ -0,0 +1,32 @@
+#!/usr/bin/env node
+import {readFileSync,writeFileSync,mkdirSync,readdirSync} from 'node:fs';
+import {randomUUID} from 'node:crypto';
+import {buildDirectBundle} from './lib/legacy-direct-bundle.mjs';
+const key=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN;
+const out=process.env.XIAOSHU_DIRECT_DEPLOY_REPORT_DIR;
+if(!key||!token||!out)throw Error('Deployment credentials and private backup directory required');
+mkdirSync(out,{recursive:true,mode:0o700});
+const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',co=process.env.XIAOSHU_SYNC_COMPANY_ID||'7pIbDBJmKx';
+const headers={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':key};
+async function api(path,method='GET',body){const r=await fetch(base+path,{method,headers,...(body?{body:JSON.stringify(body)}:{}),signal:AbortSignal.timeout(90000)}),j=await r.json();if(!r.ok||j.error)throw Error('Deployment HTTP '+r.status);return j;}
+const sessions=(await api('/classes/_Session?include=user&limit=1000&order=-updatedAt')).results;
+const session=sessions.find(s=>s.sessionToken===token&&s.user?.adminRoleKey==='super-admin'&&!s.user.isDisabled&&s.user.company?.objectId===co);
+if(!session)throw Error('A valid scoped super-admin session is required');
+const path='xiaoshu/system/direct-deploy-'+randomUUID(),expiry=Date.now()+600000;
+const sql=readFileSync(new URL('./sql/legacy-direct-sync.sql',import.meta.url),'utf8');let helperId;
+try{
+ const code=`async function handler(request,response){if(!request.user||request.user.id!==${JSON.stringify(session.user.objectId)}||Date.now()>${expiry})return response.status(403).json({success:false});try{await Psql.tx(async t=>t.none(${JSON.stringify(sql)}));return response.json({success:true,data:await Psql.query(${JSON.stringify("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public'")})});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+ new Function(code);helperId=(await api('/classes/Function','POST',{name:path,path,type:'standalone',enabled:true,code,params:[],paramList:[],respType:'json'})).objectId;
+ const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}})}),j=await r.json();if(!r.ok||!j.success)throw Error('Direct sync schema installation failed');
+ const windows=[];const directory=new URL('../docs/migration/',import.meta.url);
+ for(const f of readdirSync(directory))if(/^core-business-backfill-commit-2026-09-0[12]T.*\.json$/.test(f)){const d=JSON.parse(readFileSync(new URL(f,directory),'utf8'));windows.push([Date.parse(d.startedAt),Date.parse(d.completedAt)]);}
+ const bundle=buildDirectBundle(j.data,windows,co);new Function(bundle);
+ const name='xiaoshu.sync.direct-v1',route='xiaoshu/sync/direct-v1';
+ const current=(await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({name}),limit:'2'}))).results;if(current.length>1)throw Error('Duplicate direct sync gateway');
+ writeFileSync(out+'/function-before.json',JSON.stringify(current,null,2),{mode:0o600});
+ const definition={name,path:route,type:'standalone',enabled:true,code:bundle,params:[],paramList:[],respType:'json',desc:'旧WXAPP只读接口夜间同步,保留新修改与批次幂等'};
+ const result=current.length?await api('/classes/Function/'+current[0].objectId,'PUT',definition):await api('/classes/Function','POST',definition);
+ const actual=await api('/classes/Function/'+(current[0]?.objectId||result.objectId));if(actual.code!==bundle)throw Error('Deployed source readback mismatch');
+ writeFileSync(out+'/function-after.json',JSON.stringify(actual,null,2),{mode:0o600});
+ console.log('Direct API sync gateway deployed; source readback matches.');
+}finally{if(helperId){await api('/classes/Function/'+helperId,'DELETE');const remaining=await api('/classes/Function?'+new URLSearchParams({where:JSON.stringify({objectId:helperId}),count:'1',limit:'0'}));if(remaining.count)throw Error('Deployment helper cleanup failed');console.log('Deployment helper cleanup verified.');}}

+ 38 - 0
scripts/install-direct-sync-launchagent.mjs

@@ -0,0 +1,38 @@
+#!/usr/bin/env node
+import {mkdir,copyFile,chmod,readFile,writeFile,stat,realpath} from 'node:fs/promises';
+import {homedir} from 'node:os';
+import {join} from 'node:path';
+import {spawnSync} from 'node:child_process';
+const label='cn.xiaoshu.legacy-direct-sync-nightly',home=homedir();
+const root=join(home,'Library/Application Support/XiaoshuSync'),env=join(home,'.config/xiaoshu/legacy-sync-nightly.env'),state=join(home,'.local/state/xiaoshu/legacy-sync');
+if(process.platform!=='darwin')throw Error('This installer requires macOS');
+if(new Intl.DateTimeFormat().resolvedOptions().timeZone!=='Asia/Shanghai')throw Error('macOS timezone must be Asia/Shanghai for the 23:00 calendar schedule');
+if((await stat(env)).mode%512!==384)throw Error('Credential environment must have mode 0600');
+await mkdir(join(root,'scripts/lib'),{recursive:true,mode:0o700});await mkdir(state,{recursive:true,mode:0o700});
+for(const file of ['run-nightly-direct-sync.mjs','lib/legacy-direct-projection.mjs','lib/legacy-direct-specs.mjs']){const dest=join(root,'scripts',file);await copyFile(new URL(file,import.meta.url),dest);await chmod(dest,0o600);}
+const node=await realpath(process.execPath),script=join(root,'scripts/run-nightly-direct-sync.mjs');
+const escape=s=>s.replaceAll('&','&amp;').replaceAll('<','&lt;').replaceAll('>','&gt;');
+const xml=`<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0"><dict>
+<key>Label</key><string>${label}</string>
+<key>ProgramArguments</key><array><string>${escape(node)}</string><string>--env-file=${escape(env)}</string><string>${escape(script)}</string><string>--scheduled</string></array>
+<key>WorkingDirectory</key><string>${escape(root)}</string>
+<key>StartCalendarInterval</key><dict><key>Hour</key><integer>23</integer><key>Minute</key><integer>0</integer></dict>
+<key>RunAtLoad</key><true/>
+<key>KeepAlive</key><dict><key>SuccessfulExit</key><false/></dict>
+<key>ThrottleInterval</key><integer>300</integer>
+<key>ProcessType</key><string>Background</string>
+<key>StandardOutPath</key><string>${escape(join(state,'launchagent.log'))}</string>
+<key>StandardErrorPath</key><string>${escape(join(state,'launchagent-error.log'))}</string>
+<key>EnvironmentVariables</key><dict><key>TZ</key><string>Asia/Shanghai</string><key>NO_PROXY</key><string>server.xiaoshu.pro,localhost,127.0.0.1,::1</string></dict>
+<key>Umask</key><integer>63</integer>
+</dict></plist>`;
+const agents=join(home,'Library/LaunchAgents');await mkdir(agents,{recursive:true});const path=join(agents,label+'.plist');await writeFile(path,xml,{mode:0o600});
+const lint=spawnSync('/usr/bin/plutil',['-lint',path],{encoding:'utf8'});if(lint.status!==0)throw Error('LaunchAgent plist validation failed');
+const domain='gui/'+process.getuid(),target=domain+'/'+label;
+spawnSync('/bin/launchctl',['bootout',target],{encoding:'utf8'});
+const installed=spawnSync('/bin/launchctl',['bootstrap',domain,path],{encoding:'utf8'});if(installed.status!==0)throw Error('LaunchAgent bootstrap failed: '+installed.stderr);
+const check=spawnSync('/bin/launchctl',['print',target],{encoding:'utf8'});if(check.status!==0||!check.stdout.includes(label)||!check.stdout.includes(script))throw Error('LaunchAgent readback failed');
+await writeFile(join(state,'launchagent-install.json'),JSON.stringify({label,path,installedAt:new Date().toISOString(),timeZone:'Asia/Shanghai',hour:23,minute:0,node,script,credentialMode:'0600',readbackVerified:true},null,2),{mode:0o600});
+console.log(JSON.stringify({label,installed:true,timeZone:'Asia/Shanghai',schedule:'23:00',runtime:root,systemReadbackVerified:true}));

+ 16 - 0
scripts/lib/legacy-direct-bundle.mjs

@@ -0,0 +1,16 @@
+import {readFileSync} from 'node:fs';
+import {specs} from './legacy-direct-specs.mjs';
+import {legacyAddonFields} from './legacy-direct-projection.mjs';
+export function buildDirectBundle(columns,windows=[],company='7pIbDBJmKx') {
+ const definitions=structuredClone(specs);
+ for(const [m,s] of Object.entries(definitions)){
+  s.fields=legacyAddonFields[m]||(m==='52'?['sy','yb','lj','yp']:['content','synopsis','mp4','ico','author','source']);
+  s.addonColumns=columns.filter(c=>c.table_name===s.className).map(c=>c.column_name);
+  s.commonColumns=columns.filter(c=>c.table_name==='CommonModel').map(c=>c.column_name);
+  if(!s.addonColumns.includes('id')||!s.commonColumns.includes('generalId'))throw Error('Unexpected physical schema');
+ }
+ const userSource=readFileSync(new URL('../backfill-legacy-users.mjs',import.meta.url),'utf8');
+ const userProjection=userSource.slice(userSource.indexOf('function text(value)'),userSource.indexOf('function requestGroups('));
+ const prefix='const company={__type:"Pointer",className:"Company",objectId:'+JSON.stringify(company)+'};\n'+userProjection;
+ return (prefix+['legacy-direct-supplement','legacy-direct-control','legacy-direct-sync'].map(n=>readFileSync(new URL('../cloud/'+n+'.js',import.meta.url),'utf8')).join('\n')).replaceAll('__SPECS__',JSON.stringify(definitions)).replaceAll('__WINDOWS__',JSON.stringify(windows)).replaceAll('__COMPANY_ID__',JSON.stringify(company));
+}

+ 98 - 0
scripts/lib/legacy-direct-projection.mjs

@@ -0,0 +1,98 @@
+function text(value) { return String(value ?? '').trim(); }
+function number(value, fallback = 0) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : fallback; }
+function dateValue(value) {
+  const raw = text(value);
+  if (!/^\d{4}-\d{1,2}-\d{1,2}(?:[ T]\d{1,2}:\d{2}(?::\d{2}(?:\.\d+)?)?)?/.test(raw)) return null;
+  const normalized = raw.replace(/^(\d{4})-(\d)(?=-)/, '$1-0$2').replace(/-(\d)(?=[ T])/, '-0$1').replace(' ', 'T');
+  const date = new Date(normalized + (normalized.includes('T') ? '+08:00' : 'T00:00:00+08:00'));
+  return Number.isNaN(date.getTime()) ? null : { __type: 'Date', iso: date.toISOString() };
+}
+function setIf(body, key, value) { if (value !== null && value !== undefined && value !== '') body[key] = value; }
+
+const legacyAddonFields = {
+  53: ['kcid','scid','xxcs','yhid','jrscb'],
+  54: ['pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid'],
+  56: ['pl','con','djq','ygg','dqrq','dsid','fxrl','xxqs','szmdid','userId','learned'],
+  58: ['yxx','cksl','kcid','syjd','yhid'],
+  59: ['pf','jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid'],
+  60: ['fxzt','kcid','plid','wcsj','yhid','kywrq','kywsj','xxjlid','orderId'],
+  61: ['df','askid','wrong','userId','answerid','dontKnow','prevScore','totalScore'],
+};
+const numericAddonFields = new Set(['scid','xxcs','yhid@53','djq','ygg','userId@56','learned','yxx','cksl','pf','kcid@60','plid','yhid@60','xxjlid','orderId']);
+
+function signatureValue(modelId, field, value) {
+  return numericAddonFields.has(field) || numericAddonFields.has(`${field}@${modelId}`) ? number(value) : text(value);
+}
+
+function addonSignature(modelId, source) {
+  return JSON.stringify(legacyAddonFields[modelId].map((field) => signatureValue(modelId, field, source[field])));
+}
+
+function commonBody(modelId, row, itemId) {
+  const spec = specs[modelId];
+  const generalId = number(row.GeneralID ?? row.generalId);
+  const body = {
+    company,
+    sourceKey: `legacy:model:${modelId}:general:${generalId}`,
+    generalId,
+    itemId,
+    modelId,
+    nodeId: number(row.NodeID ?? row.nodeId, spec.nodeId),
+    tableName: spec.tableName,
+    title: text(row.Title ?? row.title),
+    subtitle: text(row.Subtitle ?? row.subtitle),
+    inputer: text(row.Inputer ?? row.inputer),
+    topImg: text(row.TopImg ?? row.topImg),
+    template: text(row.Template ?? row.template),
+    hits: number(row.Hits ?? row.hits),
+    status: 99,
+  };
+  const created = dateValue(row.CreateTime ?? row.createTime);
+  if (created) { body.createTime = created; body.upDateTime = created; }
+  return body;
+}
+
+function addonBody(modelId, row, itemId, generalId) {
+  // Parse REST/SDK reserves the property name `id`. The physical legacy id is
+  // assigned in one controlled SQL step after both objects exist.
+  const body = { company, sourceKey: `legacy:model:${modelId}:general:${generalId}` };
+  if (modelId === 53) Object.assign(body, {
+    kcid: text(row.kcid), scid: number(row.scid), xxcs: number(row.xxcs), yhid: number(row.yhid), jrscb: text(row.jrscb),
+  });
+  if (modelId === 54) for (const field of ['pl','bxrq','dslx','dszt','fxpl','jffs','jssj','kcid','kssj','plxm','scsj','sdsd','szmd','szyh','yysj','yykcid']) body[field] = text(row[field]);
+  if (modelId === 56) Object.assign(body, {
+    pl: text(row.pl), con: text(row.con), djq: number(row.djq), ygg: number(row.ygg), dqrq: text(row.dqrq),
+    dsid: text(row.dsid), fxrl: text(row.fxrl), xxqs: text(row.xxqs), szmdid: text(row.szmdid),
+    userId: number(row.UserID ?? row.userId), learned: number(row.learned),
+  });
+  if (modelId === 58) Object.assign(body, {
+    yxx: number(row.yxx), cksl: number(row.cksl), kcid: text(row.kcid), syjd: text(row.syjd), yhid: text(row.yhid),
+  });
+  if (modelId === 59) {
+    for (const field of ['jffs','jsmz','kcid','kclx','kcmc','kzsj','pjnr','pldp','xymz','yyds','plpjsj','szmdid']) body[field] = text(row[field]);
+    setIf(body, 'pf', row.pf === null || row.pf === '' ? null : number(row.pf));
+    body.legacyGeneralId = generalId;
+    body.studentName = text(row.Title ?? row.title);
+    body.coachName = text(row.Inputer ?? row.inputer);
+    body.courseName = text(row.kcmc ?? row.Subtitle ?? row.subtitle);
+    body.storeId = number(row.szmdid);
+    const lessonAt = dateValue(row.plpjsj) || dateValue(row.CreateTime ?? row.createTime);
+    const sourceCreatedAt = dateValue(row.CreateTime ?? row.createTime);
+    if (lessonAt) body.lessonAt = lessonAt;
+    if (sourceCreatedAt) body.sourceCreatedAt = sourceCreatedAt;
+  }
+  if (modelId === 60) Object.assign(body, {
+    fxzt: text(row.fxzt), kcid: number(row.kcid), plid: number(row.plid), wcsj: text(row.wcsj), yhid: number(row.yhid),
+    kywrq: text(row.kywrq), kywsj: text(row.kywsj), xxjlid: number(row.xxjlid), orderId: number(row.orderId),
+  });
+  if (modelId === 61) Object.assign(body, {
+    df: text(row.df), askid: text(row.askid), wrong: text(row.wrong), userId: text(row.UserID ?? row.userId),
+    answerid: text(row.answerid), dontKnow: text(row.dontKnow), prevScore: text(row.prev_score ?? row.prevScore), totalScore: text(row.totalScore),
+  });
+  return body;
+}
+
+
+export function projectionFor(companyValue, specsValue) { company=companyValue; specs=specsValue; return {commonBody,addonBody,legacyAddonFields,signatureValue}; }
+let company, specs;
+export { commonBody, addonBody, legacyAddonFields, signatureValue };

+ 2 - 0
scripts/lib/legacy-direct-specs.mjs

@@ -0,0 +1,2 @@
+export const specs={2:{label:'专项训练',className:'ContentArticle',tableName:'ZL_C_Article',nodeId:3},52:{label:'词库',className:'VocabularyWord',tableName:'ZL_C_word',nodeId:8},58:{label:'课程绑定',className:'CourseBinding',tableName:'ZL_C_kcbd',nodeId:28},56:{label:'每日学习',className:'DailyStudyRecord',tableName:'ZL_C_ss',nodeId:291},53:{label:'练习记录',className:'PracticeRecord',tableName:'ZL_C_lxjl',nodeId:32},60:{label:'抗遗忘',className:'MemoryPracticeRecord',tableName:'ZL_C_gywjl',nodeId:388},54:{label:'预约',className:'CourseAppointment',tableName:'ZL_C_order',nodeId:29},59:{label:'上课',className:'LessonRecord',tableName:'ZL_C_skjl',nodeId:296},61:{label:'测评',className:'AssessmentProfile',tableName:'ZL_C_cpda',nodeId:389}};
+export const modelOrder=[2,52,58,56,53,60,54,59,61];

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

@@ -0,0 +1,125 @@
+#!/usr/bin/env node
+import {readFile,writeFile,mkdir,rename,rm,readdir} from 'node:fs/promises';
+import {homedir} from 'node:os';
+import {join,resolve} from 'node:path';
+import {randomUUID,randomBytes,createHash} from 'node:crypto';
+import {fileURLToPath} from 'node:url';
+import {specs,modelOrder} from './lib/legacy-direct-specs.mjs';
+import {projectionFor,legacyAddonFields,signatureValue} from './lib/legacy-direct-projection.mjs';
+
+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];
+  if(raw===undefined||m===59&&field==='pf'&&(raw===null||raw===''))continue;
+  const normalize=v=>legacyAddonFields[m]?signatureValue(m,field,v):String(v??'').trim();
+  if(normalize(raw)!==normalize(target[field]))result[field]=normalize(raw);
+ }return result;
+}
+export function scheduledBusinessDate(now=new Date()){
+ const format=d=>new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(d);
+ const hour=Number(new Intl.DateTimeFormat('en-GB',{timeZone:'Asia/Shanghai',hour:'2-digit',hourCycle:'h23'}).format(now));
+ return format(new Date(now.getTime()-(hour<23?86400000:0)));
+}
+// Keep durable reports; only expire raw page caches of completed runs after seven days.
+export async function pruneCompletedCaches(root,now=Date.now()){
+ for(const name of await readdir(root)){
+  if(!/^\d{4}-\d{2}-\d{2}-direct-[a-f0-9-]{36}\.json$/.test(name))continue;
+  let report;try{report=JSON.parse(await readFile(join(root,name),'utf8'));}catch{continue;}
+  if(!['completed','completed-with-preserved-conflicts'].includes(report.status)||!/^([a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})$/.test(report.runId||''))continue;
+  const finished=Date.parse(report.finishedAt||report.completedAt);
+  if(Number.isFinite(finished)&&finished<now-7*86400000)await rm(join(root,'direct-'+report.runId),{recursive:true,force:true});
+ }
+}
+export async function runDirectSync(env=process.env,args=process.argv.slice(2)){
+ const root=resolve(env.XIAOSHU_SYNC_REPORT_DIR||join(homedir(),'.local/state/xiaoshu/legacy-sync'));
+ await mkdir(root,{recursive:true,mode:0o700});
+ const report={source:'old-api/WXAPP',bridgeUsed:false,startedAt:new Date().toISOString(),status:'running',datasets:[],failed:0};
+ const owner=randomUUID(),token=env.XIAOSHU_SYNC_OPERATOR_TOKEN,url=(env.XIAOSHU_FUNCTION_URL||'https://server.xiaoshu.pro/api/functions').replace(/\/$/,'')+'/xiaoshu/sync/direct-v1';
+ const lock=join(root,'direct-nightly.lock');let locked=false,runId,runRoot;
+ const save=async(path,value)=>{const tmp=path+'.tmp';await writeFile(tmp,JSON.stringify(value),{mode:0o600});await rename(tmp,path);};
+ const load=async path=>{try{return JSON.parse(await readFile(path,'utf8'));}catch(e){if(e.code==='ENOENT')return null;throw e;}};
+ async function call(params,retries=0){
+  for(let n=0;;n++){try{const r=await fetch(url,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{...params,runId,owner}}),signal:AbortSignal.timeout(90000)}),j=await r.json();
+   if(!r.ok||!j.success)throw Error(j.message||'direct_gateway_http_'+r.status);return j.data;
+  }catch(e){if(n>=retries)throw e;await new Promise(r=>setTimeout(r,1000*(n+1)));}}
+ }
+ try{
+  if(!token)throw Error('missing_sync_operator_session');
+  if(env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT&&Date.parse(env.XIAOSHU_SYNC_OPERATOR_EXPIRES_AT)<=Date.now())throw Error('expired_sync_operator_session');
+  try{await mkdir(lock,{mode:0o700});locked=true;}catch(e){
+   if(e.code!=='EEXIST')throw e;
+   const prior=await load(join(lock,'owner.json'));let exited=false;
+   if(Number.isSafeInteger(prior?.pid)&&prior.pid>0){try{process.kill(prior.pid,0);}catch(probe){if(probe.code==='ESRCH')exited=true;}}
+   if(!exited)throw Error('another_run_or_stale_lock');
+   await rm(lock,{recursive:true});await mkdir(lock,{mode:0o700});locked=true;
+  }
+  await save(join(lock,'owner.json'),{pid:process.pid,owner,startedAt:report.startedAt});
+  await pruneCompletedCaches(root);
+  const status=await call({action:'status'},2);if(status.source!=='old-api/WXAPP'||status.bridgeUsed!==false)throw Error('wrong_direct_gateway');
+  if(args.includes('--check')){report.status='ready';return report;}
+  const claim=await call({action:'claim',proposedRunId:randomUUID(),...(args.includes('--scheduled')?{targetBusinessDate:scheduledBusinessDate()}: {})});
+  if(claim.alreadyCompleted){Object.assign(report,{status:'already_completed',businessDate:claim.businessDate,previous:claim.summary});return report;}
+  runId=claim.runId;report.runId=runId;report.businessDate=claim.businessDate;runRoot=join(root,'direct-'+runId);await mkdir(runRoot,{recursive:true,mode:0o700});
+  report.supplement=await call({action:'supplement'},2);
+  const company={__type:'Pointer',className:'Company',objectId:env.XIAOSHU_SYNC_COMPANY_ID||'7pIbDBJmKx'},projection=projectionFor(company,specs);
+  const physical=b=>Object.fromEntries(Object.entries(b).map(([k,v])=>[k,v?.__type==='Date'?v.iso:v?.__type==='Pointer'?v.objectId:v]));
+  for(const m of modelOrder){
+   const attemptPath=join(runRoot,`${m}-attempt.json`);let attempt=(await load(attemptPath))?.attempt||0;
+   while(true){try{
+   const s=specs[m],fields=legacyAddonFields[m]||(m===52?['sy','yb','lj','yp']:['content','synopsis','mp4','ico','author','source']);
+   const sourcePage=async page=>{const path=join(runRoot,`${m}-source-${page}.json`),cached=await load(path);if(cached)return cached;const value=await call({action:'source',modelId:m,page},2);await save(path,value);return value;};
+   const first=await sourcePage(1),total=Number(first.page.itemCount??first.items.length),pages=Math.max(1,Number(first.page.pageCount)||Math.ceil(total/1000));
+   const allIds=new Set(),stats={modelId:m,label:s.label,source:total,created:0,createdAddon:0,updated:0,conflicts:0,failed:0,pages,reconciled:false},contentHash=createHash('sha256');
+   for(let page=1;page<=pages;page++){
+    const source=page===1?first:await sourcePage(page);contentHash.update(JSON.stringify(source.items));
+    const ids=source.items.map(r=>Number(r.GeneralID??r.generalId));for(const id of ids){if(!Number.isSafeInteger(id)||id<1)throw Error('invalid_source_id');allIds.add(id);}
+    const donePath=join(runRoot,`${m}-page-${page}-done.json`);let result=await load(donePath);
+    if(!result){
+     const planPath=join(runRoot,`${m}-page-${page}-plan.json`);let plan=await load(planPath);
+     if(!plan){
+      const target=await call({action:'read',modelId:m,ids},2),map=new Map();for(const t of target){if(map.has(Number(t.generalId)))throw Error('duplicate_target_id');map.set(Number(t.generalId),t);}
+      plan={append:[],patch:[],conflicts:[]};const seen=new Set();
+      for(let row of source.items){const id=Number(row.GeneralID??row.generalId);if(seen.has(id))continue;seen.add(id);const t=map.get(id);
+       if(!t?.addon){let item=t?Number(t.common.itemId):id;
+        if(m===52||m===2){const detail=(await call({action:'source',modelId:m,id},2)).items[0];if(detail){row={...row,...detail};if(!t)item=Number(detail.ID??detail.id??id);}}
+        for(const f of fields){const k=Object.keys(row).find(k=>k.toLowerCase()===f.toLowerCase());if(k)row={...row,[f]:row[k]};}
+        const common=physical(projection.commonBody(m,row,item)),addon=physical(projection.addonBody(m,row,item,id));
+        if(!legacyAddonFields[m])for(const f of fields){const k=Object.keys(row).find(k=>k.toLowerCase()===f.toLowerCase());if(k)addon[f]=String(row[k]??'');}
+        common.objectId=randomBytes(10).toString('hex');addon.objectId=randomBytes(10).toString('hex');addon.id=item;plan.append.push({generalId:id,common,addon,before:t||null});continue;
+       }
+       const values=changesFor(m,row,t.addon,fields);
+       const metadata=['title','subtitle','inputer'].filter(f=>{const k=Object.keys(row).find(k=>k.toLowerCase()===f);return k!==undefined&&String(row[k]??'').trim()!==String(t.common[f]??'').trim();});
+       if(metadata.length)plan.conflicts.push({modelId:m,generalId:id,fields:metadata,reason:'保留新系统元数据'});
+       if(Object.keys(values).length)plan.patch.push({generalId:id,commonId:t.common.objectId,addonId:t.addon.objectId,commonVersion:t.common.updatedAt,addonVersion:t.addon.updatedAt,values,before:t});
+      }await save(planPath,plan);
+     }
+     result={created:0,createdAddon:0,updated:0,conflicts:[...plan.conflicts]};
+     for(const action of ['append','patch'])for(let i=0;i<plan[action].length;i+=50){const rows=plan[action].slice(i,i+50),r=await call({action,modelId:m,rows,batchKey:`${m}:r${attempt}:${page}:${action}:${i}`},2);
+      result.created+=r.createdCommon;result.createdAddon+=r.createdAddon;result.updated+=r.updated;result.conflicts.push(...r.conflicts.map(c=>({...c,modelId:m})));
+      const post=new Map(r.postimages.map(t=>[Number(t.generalId),t]));
+      for(const x of rows){const after=post.get(x.generalId);if(after&&action==='patch'&&Object.keys(changesFor(m,x.values,after.addon,fields)).length)throw Error('post_write_field_mismatch');}
+     }
+     const dedup=new Map();for(const c of result.conflicts){const previous=dedup.get(c.generalId);dedup.set(c.generalId,previous?{...c,fields:[...new Set([...(previous.fields||[]),...(c.fields||[])])]}:c);}result.conflicts=[...dedup.values()];
+     for(let i=0;i<result.conflicts.length;i+=100)await call({action:'conflicts',rows:result.conflicts.slice(i,i+100)},2);
+     await save(donePath,result);
+    }
+    for(const key of ['created','createdAddon','updated'])stats[key]+=result[key];stats.conflicts+=result.conflicts.length;
+    if(page%20===0||page===pages)console.log(JSON.stringify({dataset:s.label,pagesDone:page,pages,created:stats.created,updated:stats.updated,conflicts:stats.conflicts}));
+   }
+   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');
+   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');
+   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}));}
+   }
+  }
+  report.createdBusinessRecords=report.datasets.reduce((n,d)=>n+d.created,0);report.refreshedBusinessRecords=report.datasets.reduce((n,d)=>n+d.updated,0);report.accounts=report.supplement.accounts;report.nodes=report.supplement.nodes;report.conflicts=report.datasets.reduce((n,d)=>n+d.conflicts,0)+Number(report.accounts.conflicts||0);
+  report.status=report.conflicts?'completed-with-preserved-conflicts':'completed';report.allCapturedSourceIdsReconciled=true;
+  Object.assign(report,await call({action:'finish',summary:report},2));
+  return report;
+ }catch(e){report.status='blocked';report.reason=String(e.message||'sync_failed').replace(/r:[A-Za-z0-9]+/g,'[redacted]').slice(0,240);report.failed=1;return report;}
+ finally{report.finishedAt=new Date().toISOString();await save(join(root,`${report.businessDate||new Intl.DateTimeFormat('en-CA',{timeZone:'Asia/Shanghai',year:'numeric',month:'2-digit',day:'2-digit'}).format(new Date())}-direct-${runId||owner}.json`),report);if(locked)await rm(lock,{recursive:true});}
+}
+if(process.argv[1]&&resolve(process.argv[1])===fileURLToPath(import.meta.url)){
+ const r=await runDirectSync();console.log(JSON.stringify({status:r.status,reason:r.reason||'',runId:r.runId,created:r.createdBusinessRecords,updated:r.refreshedBusinessRecords,conflicts:r.conflicts}));
+ process.exitCode=['completed','completed-with-preserved-conflicts','already_completed','ready'].includes(r.status)?0:1;
+}

+ 28 - 0
scripts/sql/legacy-direct-sync.sql

@@ -0,0 +1,28 @@
+CREATE TABLE IF NOT EXISTS "LegacyDirectRun" (
+ company text NOT NULL, "businessDate" text NOT NULL, "runId" text NOT NULL UNIQUE,
+ owner text NOT NULL, "leaseUntil" timestamptz NOT NULL, status text NOT NULL DEFAULT 'running',
+ "startedAt" timestamptz NOT NULL DEFAULT now(), "finishedAt" timestamptz, summary jsonb,
+ PRIMARY KEY(company,"businessDate")
+);
+CREATE TABLE IF NOT EXISTS "LegacyDirectBaseline" (
+ company text NOT NULL, model numeric NOT NULL, gid numeric NOT NULL, fingerprint text NOT NULL,
+ "updatedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,model,gid)
+);
+CREATE TABLE IF NOT EXISTS "LegacyDirectBatch" (
+ company text NOT NULL, "runId" text NOT NULL, key text NOT NULL, fingerprint text NOT NULL,
+ result jsonb NOT NULL, "createdAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"runId",key)
+);
+CREATE TABLE IF NOT EXISTS "LegacyDirectEvidence" (
+ company text NOT NULL, "runId" text NOT NULL, model numeric NOT NULL,
+ ids jsonb NOT NULL, "sourceCount" numeric NOT NULL, "sourceChecksum" text NOT NULL,
+ "targetChecksum" text NOT NULL, conflicts numeric NOT NULL, "contentChecksum" text NOT NULL,
+ PRIMARY KEY(company,"runId",model)
+);
+REVOKE ALL ON "LegacyDirectRun","LegacyDirectBaseline","LegacyDirectBatch","LegacyDirectEvidence" FROM PUBLIC;
+
+CREATE TABLE IF NOT EXISTS "LegacyDirectSourcePage" (
+ company text NOT NULL, "runId" text NOT NULL, model numeric NOT NULL, page numeric NOT NULL,
+ ids jsonb NOT NULL, total numeric NOT NULL, checksum text NOT NULL, "capturedAt" timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY(company,"runId",model,page)
+);
+REVOKE ALL ON "LegacyDirectSourcePage" FROM PUBLIC;

+ 188 - 0
scripts/tests/direct-nightly-sync.test.mjs

@@ -0,0 +1,188 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {mkdtemp,rm,readdir,mkdir,writeFile} 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 {buildDirectBundle} from '../lib/legacy-direct-bundle.mjs';
+import {specs} from '../lib/legacy-direct-specs.mjs';
+import {legacyAddonFields} from '../lib/legacy-direct-projection.mjs';
+import {runDirectSync,changesFor,scheduledBusinessDate,pruneCompletedCaches} from '../run-nightly-direct-sync.mjs';
+const db=new PGlite();let context,runId;const owner='test-owner-0001';
+const sql=async(s,p=[])=>(await db.query(s,p)).rows;
+const Psql={query:sql,any:sql,one:async(s,p)=>(await sql(s,p))[0],oneOrNone:async(s,p)=>(await sql(s,p))[0]||null,none:async(s,p)=>{await sql(s,p);}};
+Psql.tx=async f=>{await db.exec('BEGIN');try{const r=await f(Psql);await db.exec('COMMIT');return r;}catch(e){await db.exec('ROLLBACK');throw e;}};
+async function call(params,role='super-admin',company='co'){
+ let code=200,body;const response={status:n=>{code=n;return response;},json:j=>{body=j;return j;}};
+ await context.handler({user:{id:'operator',fetch:async()=>{},get:k=>({adminRoleKey:role,company:{id:company}}[k])},body:{params:{runId,owner,...params}}},response);
+ return {code,...body};
+}
+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,"createdAt" timestamptz,"updatedAt" timestamptz,status numeric,"teachingManaged" boolean,title text)');
+ for(const [m,s] of Object.entries(specs)){
+  const fields=legacyAddonFields[m]||(m==='52'?['sy','yb','lj','yp']:['content','synopsis','mp4','ico','author','source']);
+  await db.exec('CREATE TABLE "'+s.className+'" ("objectId" text PRIMARY KEY,company text,id numeric,"sourceKey" text,"createdAt" timestamptz,"updatedAt" timestamptz,"teachingManaged" boolean,'+fields.map(f=>'"'+f+'" text').join(',')+')');
+ }
+ await db.exec('ALTER TABLE "MemoryPracticeRecord" ADD "rewardTaskKey" text, ADD "rewardDuplicate" boolean');
+ const rewardSource=readFileSync(new URL('../sql/review-rewards.sql',import.meta.url),'utf8');await db.exec(rewardSource.match(/CREATE OR REPLACE FUNCTION xiaoshu_review_key[\s\S]*?\$\$;/)[0]);
+ await db.exec('CREATE TABLE "ReviewReward" ("objectId" text,company text,"taskId" text,amount numeric,status text)');
+ await db.exec('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)');
+ const columns=await sql("SELECT table_name,column_name FROM information_schema.columns WHERE table_schema='public'");
+ context={Psql,Parse:{Config:{get:async()=>({get:k=>({legacyScheduleApiUrl:'https://legacy.invalid/api',legacyScheduleApiId:'test',legacyScheduleApiKey:'test'}[k])})}},fetch:async()=>({ok:true,json:async()=>({retcode:0,result:[{GeneralID:101,kcid:'5',xxcs:1}],page:{itemCount:1,pageCount:1}})}),AbortSignal,Date,console};
+ vm.createContext(context);vm.runInContext(buildDirectBundle(columns,[],'co'),context);
+ const r=await call({action:'claim',proposedRunId:'test-run-00001'});assert.equal(r.success,true,JSON.stringify(r));runId=r.data.runId;
+});
+after(()=>db.close());
+test('非超级管理员、其他帐套与另一执行者不能访问或写入',async()=>{
+ assert.equal((await call({action:'status'},'ops-manager')).code,403);
+ assert.equal((await call({action:'status'},'super-admin','other')).code,403);
+ assert.match((await call({action:'claim',owner:'another-owner-0001',proposedRunId:'another-run-00001'})).message,/another_sync_running/);
+ assert.match((await call({action:'read',modelId:53,ids:[],owner:'wrong-owner-0001'})).message,/lease/);
+});
+let original;
+test('主附表原子新增、批次重试复用结果,不允许同一批次编号换内容',async()=>{
+ const row={generalId:101,common:{objectId:'common101',status:99,title:'旧记录'},addon:{objectId:'addon101',id:101,kcid:'5',xxcs:'1'}};
+ const first=await call({action:'append',modelId:53,batchKey:'append-101',rows:[row]});assert.equal(first.success,true);assert.equal(first.data.createdCommon,1);original=first.data.postimages[0];
+ const again=await call({action:'append',modelId:53,batchKey:'append-101',rows:[row]});assert.deepEqual(JSON.parse(JSON.stringify(first.data)),JSON.parse(JSON.stringify(again.data)));
+ assert.equal((await sql('SELECT count(*)::int n FROM "PracticeRecord"'))[0].n,1);
+ assert.match((await call({action:'append',modelId:53,batchKey:'append-101',rows:[{...row,generalId:102}]})).message,/batch_key_reused/);
+ const bad={generalId:102,common:{objectId:'common102',unknown:'bad'},addon:{objectId:'addon102',id:102,kcid:'5'}};
+ assert.equal((await call({action:'append',modelId:53,batchKey:'bad-102',rows:[bad]})).success,false);
+ assert.equal((await sql('SELECT count(*)::int n FROM "PracticeRecord" WHERE id=102'))[0].n,0);
+});
+test('已保存投影可刷新,修改后的记录保留;微秒级版本变化也拒绝覆盖',async()=>{
+ const patch=()=>({generalId:101,commonId:original.common.objectId,addonId:original.addon.objectId,commonVersion:original.common.updatedAt,addonVersion:original.addon.updatedAt,values:{xxcs:'2'}});
+ let r=await call({action:'patch',modelId:53,batchKey:'patch-101',rows:[patch()]});assert.equal(r.data.updated,1);original=r.data.postimages[0];
+ await sql('UPDATE "PracticeRecord" SET xxcs=\'新系统修改\' WHERE id=101');
+ r=await call({action:'patch',modelId:53,batchKey:'preserve-101',rows:[patch()]});assert.equal(r.data.updated,0);assert.equal(r.data.conflicts.length,1);
+ await sql('UPDATE "PracticeRecord" SET xxcs=\'2\',"updatedAt"=\'2026-10-12T10:00:00.000002Z\' WHERE id=101');
+ const stale={...patch(),addonVersion:'2026-10-12T10:00:00.000001+00:00'};
+ r=await call({action:'patch',modelId:53,batchKey:'microsecond-101',rows:[stale]});assert.equal(r.data.updated,0);
+});
+test('服务端捕获真实源编号并重新核对投影,缺主附表时不能生成对账凭据',async()=>{
+ assert.equal((await call({action:'source',modelId:53,page:1})).success,true);
+ const evidence=await call({action:'evidence',modelId:53,conflicts:1});assert.equal(evidence.success,true);assert.equal(evidence.data.sourceChecksum,evidence.data.targetChecksum);
+ await sql('DELETE FROM "PracticeRecord" WHERE id=101');
+ assert.match((await call({action:'evidence',modelId:53})).message,/projection_reconciliation_failed/);
+ assert.match((await call({action:'finish',summary:{datasets:[]}})).message,/reconciliation_incomplete/);
+});
+test('冲突记录重复保存不增加重复行',async()=>{
+ for(let i=0;i<2;i++)assert.equal((await call({action:'conflicts',rows:[{modelId:53,generalId:101,fields:['xxcs']}]})).success,true);
+ assert.equal((await sql('SELECT count(*)::int n FROM "LegacySyncConflict"'))[0].n,1);
+});
+test('字段差异按业务类型规范化,旧空评分不抹掉新评分',()=>{
+ assert.deepEqual(changesFor(53,{xxcs:'1'},{xxcs:1},['xxcs']),{});
+ assert.deepEqual(changesFor(59,{pf:''},{pf:5},['pf']),{});
+ assert.deepEqual(changesFor(60,{FXZT:'1'},{fxzt:'0'},['fxzt']),{fxzt:'1'});
+});
+test('每日运行入口只使用直接接口、按依赖顺序并写私有报告,未对账不报成功',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'direct-sync-test-')),realFetch=globalThis.fetch,order=[];
+ try{
+  globalThis.fetch=async(url,options)=>{assert.ok(url.endsWith('/xiaoshu/sync/direct-v1'));const p=JSON.parse(options.body).params;let data;
+   if(p.action==='status')data={source:'old-api/WXAPP',bridgeUsed:false};
+   else if(p.action==='claim')data={runId:'mock-run-000001',businessDate:'2026-10-12'};
+   else if(p.action==='supplement')data={accounts:{missing:0},nodes:{missing:0}};
+   else if(p.action==='source'){order.push(p.modelId);data={items:[],page:{itemCount:0,pageCount:1}};}
+   else if(p.action==='read')data=[];
+   else if(p.action==='evidence')data={sourceChecksum:'d41d8cd98f00b204e9800998ecf8427e',targetChecksum:'d41d8cd98f00b204e9800998ecf8427e',reconciled:true};
+   else if(p.action==='finish')data=p.summary;
+   return {ok:true,json:async()=>({success:true,data})};
+  };
+  const r=await runDirectSync({XIAOSHU_SYNC_REPORT_DIR:root,XIAOSHU_SYNC_OPERATOR_TOKEN:'test'});assert.equal(r.status,'completed');assert.ok(order.indexOf(56)<order.indexOf(60));assert.ok(order.indexOf(54)<order.indexOf(59));
+  assert.ok((await readdir(root)).some(f=>f.endsWith('.json')));assert.ok(!(await readdir(root)).includes('direct-nightly.lock'));
+ }finally{globalThis.fetch=realFetch;await rm(root,{recursive:true,force:true});}
+});
+
+
+test('有新系统计薪凭据的复习不回退、不重算工资',async()=>{
+ const row={generalId:301,common:{objectId:'memory-common',status:99},addon:{objectId:'memory-addon',id:301,yhid:'20',xxjlid:'33',plid:'30',kywrq:'2026-10-12',fxzt:'0'}};
+ const inserted=await call({action:'append',modelId:60,batchKey:'memory-301',rows:[row]});assert.equal(inserted.success,true,inserted.message);const t=inserted.data.postimages[0];
+ await sql('INSERT INTO "ReviewReward" VALUES(\'earned-reward\',\'co\',\'memory-addon\',1,\'earned\')');
+ const r=await call({action:'patch',modelId:60,batchKey:'memory-protected',rows:[{generalId:301,commonId:t.common.objectId,addonId:t.addon.objectId,commonVersion:t.common.updatedAt,addonVersion:t.addon.updatedAt,values:{fxzt:'1'}}]});
+ assert.equal(r.success,true,r.message);assert.equal(r.data.updated,0);assert.equal(r.data.conflicts.length,1);
+ assert.equal((await sql('SELECT amount FROM "ReviewReward" WHERE "objectId"=\'earned-reward\''))[0].amount,'1');
+ assert.equal((await sql('SELECT fxzt FROM "MemoryPracticeRecord" WHERE id=301'))[0].fxzt,'0');
+});
+test('北京时间23点前补昨天、23点后执行今天,跨UTC日期仍正确',()=>{
+ assert.equal(scheduledBusinessDate(new Date('2026-10-12T14:59:00Z')),'2026-10-11');
+ assert.equal(scheduledBusinessDate(new Date('2026-10-12T15:00:00Z')),'2026-10-12');
+ assert.equal(scheduledBusinessDate(new Date('2026-10-12T16:01:00Z')),'2026-10-12');
+});
+
+
+test('新增账号与栏目幂等补齐,已有账户余额和目录名称保持不变',async()=>{
+ await db.exec('CREATE TABLE "_User" ("objectId" text PRIMARY KEY,company text,username text UNIQUE,"legacyUserId" numeric,"legacyUserData" jsonb);CREATE TABLE "Node" ("objectId" text PRIMARY KEY,company text,"sourceKey" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"createdAt" timestamptz,"updatedAt" timestamptz)');
+ await sql('INSERT INTO "_User" VALUES(\'existing-user\',\'co\',\'existing\',20,\'{"Purse":99}\')');
+ await sql('INSERT INTO "Node" ("objectId",company,"nodeId","nodeName") VALUES(\'existing-node\',\'co\',8,\'新系统目录\')');
+ const previous=context.fetch;let passwords=0;
+ context.Parse.Object={createWithoutData:(className,id)=>({id,className})};
+ context.Parse.User=class{constructor(){this.fields={};}set(k,v){this.fields[k]=v;}setUsername(v){this.fields.username=v;}setPassword(v){assert.ok(v.length>60);passwords++;}async save(){const f=this.fields;assert.equal(f.identityType,'member');assert.equal(f.adminRoleKey,undefined);assert.equal(f.password,undefined);await sql('INSERT INTO "_User" VALUES($1,$2,$3,$4,$5::jsonb)',['new-user',f.company.id,f.username,f.legacyUserId,JSON.stringify(f.legacyUserData)]);}};
+ context.fetch=async url=>{const action=new URL(url).searchParams.get('action');return {ok:true,json:async()=>({retcode:0,page:{pageCount:1},result:action==='user_list'?[{UserID:20,UserName:'existing',GroupID:1,Purse:1},{UserID:4000,UserName:'new-member',GroupID:1,Purse:10,Password:'ignored-hash'}]:[{NodeID:8,NodeName:'旧目录'},{NodeID:9,ParentID:8,NodeName:'新目录'}]})};};
+ try{let r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,1);assert.equal(r.data.nodes.created,1);
+  r=await call({action:'supplement'});assert.equal(r.success,true,r.message);assert.equal(r.data.accounts.created,0);assert.equal(r.data.nodes.created,0);assert.equal(passwords,1);
+  assert.equal((await sql('SELECT "legacyUserData" FROM "_User" WHERE "legacyUserId"=20'))[0].legacyUserData.Purse,99);
+  assert.equal((await sql('SELECT "nodeName" FROM "Node" WHERE "nodeId"=8'))[0].nodeName,'新系统目录');
+ }finally{context.fetch=previous;}
+});
+
+
+test('未取得服务器对账证据时不完成运行;已退出进程的目录锁可恢复',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'direct-sync-negative-')),realFetch=globalThis.fetch;let finished=false;
+ try{
+  await mkdir(join(root,'direct-nightly.lock'));
+  await writeFile(join(root,'direct-nightly.lock/owner.json'),JSON.stringify({pid:2147483647}));
+  globalThis.fetch=async(url,options)=>{const p=JSON.parse(options.body).params;let data;
+   if(p.action==='status')data={source:'old-api/WXAPP',bridgeUsed:false};
+   else if(p.action==='claim')data={runId:'failed-run-000001',businessDate:'2026-10-12'};
+   else if(p.action==='supplement')data={accounts:{missing:0},nodes:{missing:0}};
+   else if(p.action==='source')data={items:[],page:{itemCount:0,pageCount:1}};
+   else if(p.action==='read')data=[];
+   else if(p.action==='evidence')data={sourceChecksum:'d41d8cd98f00b204e9800998ecf8427e',reconciled:false};
+   else if(p.action==='finish'){finished=true;data=p.summary;}
+   return {ok:true,json:async()=>({success:true,data})};
+  };
+  const r=await runDirectSync({XIAOSHU_SYNC_REPORT_DIR:root,XIAOSHU_SYNC_OPERATOR_TOKEN:'test'});
+  assert.equal(r.status,'blocked');assert.match(r.reason,/checksum/);assert.equal(finished,false);
+  assert.ok(!(await readdir(root)).includes('direct-nightly.lock'));
+ }finally{globalThis.fetch=realFetch;await rm(root,{recursive:true,force:true});}
+});
+
+test('只有全部数据集对账后才能完成,统计来自写入回执,完成重试幂等',async()=>{
+ await db.exec('CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,company text,"sourceKey" text,type1 text,type2 text,type3 text,detail text,"createdAt" timestamptz,"updatedAt" timestamptz)');
+ const previous=context.fetch;
+ context.fetch=async()=>({ok:true,json:async()=>({retcode:0,result:[],page:{itemCount:0,pageCount:1}})});
+ try{
+  const datasets=[];
+  for(const modelId of [2,52,58,56,53,60,54,59,61]){
+   assert.equal((await call({action:'source',modelId,page:1})).success,true);
+   const e=await call({action:'evidence',modelId});assert.equal(e.success,true,e.message);
+   datasets.push({modelId,created:999,updated:999,failed:0,reconciled:true});
+  }
+  const summary={status:'completed-with-preserved-conflicts',datasets,conflicts:1};
+  const r=await call({action:'finish',summary});assert.equal(r.success,true,r.message);
+  assert.equal(r.data.createdBusinessRecords,2);assert.equal(r.data.refreshedBusinessRecords,1);
+  assert.equal(r.data.bridgeUsed,false);assert.equal(r.data.settlementReady,false);
+  const again=await call({action:'finish',summary});assert.deepEqual(JSON.parse(JSON.stringify(again.data)),JSON.parse(JSON.stringify(r.data)));
+  assert.equal((await sql('SELECT count(*)::int n FROM "SysLog"'))[0].n,1);
+  assert.equal((await call({action:'claim',proposedRunId:'next-run-0000001'})).data.alreadyCompleted,true);
+ }finally{context.fetch=previous;}
+});
+
+
+test('只清理七天前已完成运行的原始缓存,保留失败断点及报告',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'direct-sync-retention-'));
+ const ids=['11111111-1111-1111-1111-111111111111','22222222-2222-2222-2222-222222222222','33333333-3333-3333-3333-333333333333'];
+ try{
+  for(let i=0;i<ids.length;i++){
+   await mkdir(join(root,'direct-'+ids[i]));
+   await writeFile(join(root,'2026-10-01-direct-'+ids[i]+'.json'),JSON.stringify({runId:ids[i],status:i===1?'blocked':'completed',finishedAt:i===2?'2026-10-12T12:00:00Z':'2026-10-01T12:00:00Z'}));
+  }
+  await pruneCompletedCaches(root,Date.parse('2026-10-13T00:00:00Z'));
+  const files=await readdir(root);
+  assert.ok(!files.includes('direct-'+ids[0]));assert.ok(files.includes('direct-'+ids[1]));assert.ok(files.includes('direct-'+ids[2]));
+  assert.equal(files.filter(x=>x.endsWith('.json')).length,3);
+ }finally{await rm(root,{recursive:true,force:true});}
+});