瀏覽代碼

fix(sync): retire foreign accounts and protect legacy identities

彭峰 4 天之前
父節點
當前提交
2c99c1a3ca

+ 45 - 0
docs/migration/account-retirement-2026-10-13.md

@@ -0,0 +1,45 @@
+# 2026-10-13 指定账号清理与同步来源保护
+
+## 生产处理结果
+
+用户原名单去重为 27 个账号(ID 51 重复),随后明确追加两个阅读账号(1852、1859),共 29 个。按对象 ID、旧数字编号、来源、更新时间共同校验后软删除并停用,注销 77 个登录会话。保留删除凭据和 `SyncExcluded` 标记,避免后续账号补录恢复。操作前快照及处理回执位于本机私有目录 `~/.local/state/xiaoshu/account-cleanup/2026-10-13/`,不提交个人资料和凭据。
+
+14 个被删除账号来自 `reading-unified:user:*`。旧阅读合并脚本按 MAX(legacyUserId)+1 给未匹配的阅读账号分配编号,误占了旧系统编号空间。其中 13 个编号与旧 WXAPP 返回的真实小树账号碰撞:1846、1847、1848、1850、1851、1852、1853、1854、1855、1856、1857、1858、1859。删除指定阅读账号时撤销其伪编号,在删除凭据中保留原编号;按实时旧接口核实身份后恢复 13 个独立小树账号。1849 没有当前旧源账号,未猜测或补造身份。
+
+恢复时保留 1858 在新系统已核验过的余额(30 分钟课时 40、陪练时长 20),记录与旧源差异,不以旧源 39 / 19.5 覆盖。其余恢复账号以已核实的旧源初始资料建立;未修改其他已有账户余额。
+
+管理后台实际列表接口复查:会员列表 628 条、陪练列表 167 条,29 个删除对象均不再出现。恢复的 8 个会员、5 个陪练显示正确旧源身份,其中 2 个陪练沿用旧源停用状态。数字编号被真实小树用户继续使用,不代表被删的阅读账号复活。
+
+## 数据完整性
+
+清理前后按完整行内容计算校验和,以下关联数据数量和校验和一致:
+
+| 数据 | 条数 |
+|---|---:|
+| 课程绑定 | 29 |
+| 每日学习 | 94 |
+| 练习 | 2179 |
+| 抗遗忘 | 951 |
+| 预约 | 304 |
+| 上课 | 80 |
+| 复习计薪凭据 | 420 |
+| 四类课时流水合计 | 77 |
+
+29 个删除对象仍有删除标记,可审计恢复;其可用会话为 0。没有级联删除课程、工资或历史流水,也没有重算工资。历史计薪凭据中的姓名快照未重写,本次验证的是管理列表身份和业务记录完整性。
+
+## 防再次导入
+
+- 夜间补账号和手工 backfill 均跳过已删除、`SyncExcluded` 账号,禁止 `--refresh-existing` 恢复这些账号。
+- `reading-*` 编号不能作为老系统身份匹配证据;发现冲突记录专门原因,夜间执行器停止后续业务投影,避免继续串号。
+- 阅读账号只有 `ReadingSourceVerified=xiaoshu` 且匹配明确时才能合并。未知来源和乍兔账号不自动合并;未匹配、歧义账号不再晋升为小树会员,也不生成旧系统数字编号。
+- 删除对象的原编号只是审计字段;真正的 `legacyUserId` 与 `legacyUserData.UserID` 同时撤销,防止旧查询的回退逻辑再次匹配。
+
+线上直接同步网关已发布 `2026-10-13-account-protection-v2` 并逐字回读。使用实际部署的筛选函数只读核验旧源全部 737 个账号:735 匹配、2 个明确删除的旧账号跳过、0 缺失、0 编号冲突。此次未伪造运行检查点,也未重新执行整轮业务同步。
+
+Mac 的已安装执行器已更新且与测试源码一致;LaunchAgent 仍为北京时间 23:00,上一退出码 0,`--check` 返回 ready。该检查不是下一次定时执行的成功回执。
+
+## 验证
+
+`node --test scripts/tests/direct-nightly-sync.test.mjs scripts/tests/reading-account-policy.test.mjs scripts/tests/retire-imported-accounts.test.mjs`:20 项通过。覆盖删除保护、跨来源禁止合并、伪编号冲突、冲突时业务同步阻断、幂等清理和身份版本变化时事务回滚。
+
+真实云函数 SDK 的 Company 指针创建与本地模拟存在差异;身份恢复通过 Parse REST 完成并回读。夜间新增账号路径改为读取真实 Company 对象,生产只读序列化验证确认其指针有效,避免使用不兼容的双参数 createWithoutData。

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

@@ -14,6 +14,8 @@ launchctl print gui/$(id -u)/cn.xiaoshu.legacy-direct-sync-nightly
 
 `--check` 只确认鉴权和同步服务身份。手工命令执行今天;系统任务使用 `--scheduled`,23 点前补上一业务日,23 点后执行今天。同一帐套、业务日期已成功时直接读取成功回执;失败沿用运行编号、缓存的源分页和写入计划续跑。目录锁和服务端租约共同防止重复并发。残留目录锁只有在确认 owner.json 中的进程已退出后才自动清理;进程仍存活或无法确认时停止,避免并发。
 
+账号来源保护(2026-10-13):已删除及 `SyncExcluded` 账号跳过;`reading-*` 账号的数字编号不视为旧系统身份。发现编号冲突时记录冲突并停止后续业务投影,不能把真实学员的课程挂到阅读账号。阅读合并只接收显式核实为小树的来源;未核实、乍兔、未匹配及有歧义的账号保留待核验,不再自动生成小树编号。详见 [账号清理验收](account-retirement-2026-10-13.md)。
+
 同步范围:账号只新增并匹配旧用户编号、栏目只补缺;业务数据依次为专项训练、词库、课程绑定、每日学习、练习、抗遗忘、预约、上课、测评。现有账号资料和余额、人工开课分类、已准备的排课、新系统教学记录、已生成的复习计薪凭据均保留。新增账号需旧角色明确为学员、门店或陪练,不能隐式创建管理员;新密码随机生成,不迁移旧密码散列。
 
 服务端保存真实读取页的编号和内容校验和,核对每条业务编号恰有一份主表及关联附表。写入为主附表与批次回执的同一事务,写后校验字段。保存完整投影指纹;只在新系统未修改时更新,指纹或微秒版本变化均记录冲突。网络重试复用批次结果。源数据分页期间变化时重新扫描该数据集。成功回执基于服务器再次核验,不以计数或客户端标志替代验证。冲突意味着有意保留新数据,不表示源内容与目标完全相同。

+ 16 - 2
scripts/backfill-legacy-users.mjs

@@ -75,7 +75,7 @@ async function parseUsers() {
   for (let skip = 0; ; skip += 1000) {
     const query = new URLSearchParams({
       where: JSON.stringify({ company }), limit: '1000', skip: String(skip), order: 'objectId',
-      keys: 'objectId,username,legacyUserId,legacyGroupId,legacyUserData,isAdmin,isDeleted,identityType',
+      keys: 'objectId,username,legacyUserId,legacyGroupId,legacyUserData,isAdmin,isDeleted,identityType,sourceKey',
     });
     const page = (await parse(`/users?${query}`)).results || [];
     rows.push(...page);
@@ -142,6 +142,14 @@ function projection(row) {
   return { legacyUserId, username, body };
 }
 
+// Deleted identities are durable tombstones. Reading-generated numeric IDs are
+// not proof of ownership in the old Xiaoshu account namespace.
+function legacyAccountDecision(row) {
+  if (row?.isDeleted === true || row?.legacyUserData?.SyncExcluded === true) return 'excluded';
+  if (String(row?.sourceKey || '').startsWith('reading-')) return 'identity-conflict';
+  return 'eligible';
+}
+
 function requestGroups(requests, size = 40) {
   const groups = [];
   for (let index = 0; index < requests.length; index += size) groups.push(requests.slice(index, index + size));
@@ -223,6 +231,7 @@ const conflicts = [];
 let creates = 0;
 let attaches = 0;
 let updates = 0;
+let excluded = 0;
 for (const row of source.rows) {
   const item = projection(row);
   let target = byLegacyId.get(item.legacyUserId);
@@ -236,6 +245,11 @@ for (const row of source.rows) {
       item.username = `legacy_${item.legacyUserId}_${item.username}`.slice(0, 120);
     }
   }
+  if (target && legacyAccountDecision(target) !== 'eligible') {
+    if (legacyAccountDecision(target) === 'excluded') excluded += 1;
+    else conflicts.push({ legacyUserId: item.legacyUserId, reason: 'reading-id-is-not-legacy-identity' });
+    continue;
+  }
   if (target) {
     if (refreshExisting || !number(target.legacyUserId || target.legacyUserData?.UserID)) {
       requests.push({ method: 'PUT', path: `/parse/users/${target.objectId}`, body: item.body });
@@ -254,7 +268,7 @@ const report = {
   startedAt: new Date().toISOString(), mode: commit ? 'commit' : 'dry-run', companyId: company.objectId,
   legacyTotal: source.total, legacyUnique: source.rows.length, legacyDuplicates: source.duplicates,
   parseTotalBefore: existing.length, parseLegacyIdsBefore: byLegacyId.size,
-  creates, attaches, updates, conflicts, requests: requests.length, batches: groups.length,
+  creates, attaches, updates, excluded, conflicts, requests: requests.length, batches: groups.length,
 };
 
 try {

+ 1 - 1
scripts/cloud/legacy-direct-control.js

@@ -21,7 +21,7 @@ async function directOld(action,args={}) {
 }
 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==='status')return {version:'2026-10-13-account-protection-v2',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"));

+ 11 - 3
scripts/cloud/legacy-direct-supplement.js

@@ -1,12 +1,19 @@
 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};
+ const result={accounts:{source:users.length,created:0,matched:0,missing:0,conflicts:0,excluded:0,identityConflicts: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]);
+  const found=await Psql.query('SELECT "objectId",username,"legacyUserData","isDeleted","sourceKey" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,id]);
   if(found.length>1)throw Error('duplicate_legacy_user_identity');
+  const decision=found.length?legacyAccountDecision(found[0]):'eligible';
+  if(decision==='excluded'){result.accounts.excluded++;continue;}
+  if(decision==='identity-conflict'){
+   result.accounts.identityConflicts++;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,\'reading-legacy-id-collision\',\'阅读账号生成的编号不能代表旧系统身份\',false,\'保留原记录,待核实身份\',now(),now() WHERE NOT EXISTS(SELECT 1 FROM "LegacySyncConflict" WHERE company=$2 AND "sourceKey"=$1)',['direct-api:user-identity:'+id,co,String(id)]);
+   continue;
+  }
   if(!found.length){
    await Psql.tx(async t=>{
    // Serialize retries for the same legacy identity even if the first HTTP response was lost.
@@ -16,8 +23,9 @@ async function directSupplement(p,co){
    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 tenant=await new Parse.Query('Company').get(co,{useMasterKey:true});
    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);
+   for(const[k,v]of Object.entries(value.body))user.set(k,k==='company'?tenant: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});

+ 72 - 0
scripts/lib/reading-account-policy.mjs

@@ -0,0 +1,72 @@
+export function text(value) { return String(value ?? '').trim(); }
+function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }
+function legacyId(row) { return number(row.legacyUserId || row.legacyUserData?.UserID); }
+function normalizePhone(value) {
+  const digits = text(value).replace(/\D/g, '');
+  if (/^86\d{11}$/.test(digits)) return digits.slice(2);
+  return /^1\d{10}$/.test(digits) ? digits : '';
+}
+function phones(row) {
+  return new Set([
+    row.mobile, row.phone, row.mobilePhoneNumber, row.username,
+    row.legacyUserData?.Mobile, row.legacyUserData?.UserName,
+  ].map(normalizePhone).filter(Boolean));
+}
+function normalizeName(value) {
+  return text(value).toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
+}
+function names(row) {
+  return new Set([
+    row.nickname, row.nickName, row.realName, row.name,
+    row.legacyUserData?.HoneyName, row.legacyUserData?.TrueName,
+  ].map(normalizeName).filter((value) => value.length >= 2));
+}
+function intersects(left, right) { return [...left].some((value) => right.has(value)); }
+export function isCanonical(row) { return text(row.sourceKey).startsWith('legacy-sync:user:') && !row.isDeleted; }
+export function isReadingImport(row) {
+  return !text(row.sourceKey) && !legacyId(row) && row.isAdmin !== true && !row.isDeleted;
+}
+
+export function buildPlan(users) {
+  const targets = users.filter(isCanonical);
+  const sources = users.filter(isReadingImport);
+  const targetsByPhone = new Map();
+  for (const target of targets) {
+    for (const phone of phones(target)) {
+      const values = targetsByPhone.get(phone) || [];
+      values.push(target);
+      targetsByPhone.set(phone, values);
+    }
+  }
+
+  const merge = [];
+  const promote = []; // Never allocate old-system IDs for reading accounts.
+  const held = [];
+  let nameResolved = 0;
+  let ambiguous = 0;
+  let unmatched = 0;
+  for (const source of sources) {
+    if (source.legacyUserData?.ReadingSourceVerified !== 'xiaoshu') {
+      held.push({ objectId: source.objectId, reason: 'unverified-source' });
+      continue;
+    }
+    const candidates = new Map();
+    for (const phone of phones(source)) {
+      for (const candidate of targetsByPhone.get(phone) || []) candidates.set(candidate.objectId, candidate);
+    }
+    let possible = [...candidates.values()];
+    if (possible.length > 1) {
+      const sourceNames = names(source);
+      const nameMatches = possible.filter((candidate) => intersects(sourceNames, names(candidate)));
+      if (nameMatches.length === 1) { possible = nameMatches; nameResolved += 1; }
+    }
+    if (possible.length === 1) {
+      merge.push({ source: source.objectId, target: possible[0].objectId });
+    } else {
+      held.push({ objectId: source.objectId, reason: possible.length > 1 ? 'ambiguous-identity' : 'unmatched-identity' });
+      if (possible.length > 1) ambiguous += 1;
+      else unmatched += 1;
+    }
+  }
+  return { targets, sources, merge, promote, held, nameResolved, ambiguous, unmatched };
+}

+ 30 - 0
scripts/lib/retire-imported-accounts.mjs

@@ -0,0 +1,30 @@
+// Operator-only maintenance primitive. Callers must authenticate and supply an
+// explicit reviewed identity list; no account is selected by display name alone.
+export async function retireImportedAccounts(db, company, targets, actor, receipt) {
+  if (!targets.length || new Set(targets.map(x=>x.objectId)).size !== targets.length) throw Error('invalid_target_list');
+  return db.tx(async t=>{
+    await t.any('SELECT pg_advisory_xact_lock(hashtext($1))',['direct-sync:'+company]);
+    if(await t.oneOrNone('SELECT "runId" FROM "LegacyDirectRun" WHERE company=$1 AND status=\'running\' AND "leaseUntil">now() LIMIT 1',[company])) throw Error('another_sync_running');
+    const rows=await t.any('SELECT "objectId","isAdmin",roles,"adminRoleKey","legacyUserId","sourceKey","updatedAt","isDeleted","legacyUserData" FROM "_User" WHERE company=$1 AND "objectId"=ANY($2::text[]) FOR UPDATE',[company,targets.map(x=>x.objectId)]);
+    if(rows.length!==targets.length) throw Error('target_missing_or_wrong_company');
+    const pending=[];
+    for(const expected of targets){
+      const row=rows.find(x=>x.objectId===expected.objectId);
+      if(row.isAdmin || row.adminRoleKey || (row.roles||[]).some(r=>/admin|manager/.test(r))) throw Error('privileged_target');
+      if(row.isDeleted && row.legacyUserData?.RetirementReceipt===receipt) continue;
+      if(row.isDeleted || Number(row.legacyUserId)!==Number(expected.legacyUserId) || (row.sourceKey||'')!==(expected.sourceKey||'') || new Date(row.updatedAt).getTime()!==new Date(expected.updatedAt).getTime()) throw Error('target_changed');
+      pending.push(row);
+    }
+    let detached=0;
+    for(const row of pending){
+      const synthetic=String(row.sourceKey||'').startsWith('reading-unified:user:');
+      const legacy={...row.legacyUserData,State:0,SyncExcluded:true,OpsDeletedReason:'user-requested-test-or-foreign-account',RetiredLegacyUserID:Number(row.legacyUserId),RetirementReceipt:receipt,OpsDeletedAt:new Date().toISOString()};
+      if(synthetic){delete legacy.UserID;detached++;}
+      await t.none('UPDATE "_User" SET "isDeleted"=true,"isDisabled"=true,"legacyUserId"=$3,"legacyUserData"=$4::jsonb,"updatedAt"=now() WHERE company=$1 AND "objectId"=$2',[company,row.objectId,synthetic?null:row.legacyUserId,JSON.stringify(legacy)]);
+    }
+    const sessions=await t.any('DELETE FROM "_Session" WHERE "user"=ANY($1::text[]) RETURNING "objectId"',[targets.flatMap(x=>[x.objectId,'_User$'+x.objectId])]);
+    const result={requested:targets.length,retired:pending.length,alreadyRetired:targets.length-pending.length,detachedSyntheticIds:detached,revokedSessions:sessions.length};
+    await t.none('INSERT INTO "SysLog" ("objectId",company,"sourceKey",type1,type2,type3,detail,"createdAt","updatedAt") SELECT substr(md5($1),1,20),$2,$1,\'migration\',\'retire-imported-accounts\',\'user-request\',$3,now(),now() WHERE NOT EXISTS(SELECT 1 FROM "SysLog" WHERE company=$2 AND "sourceKey"=$1)',[receipt,company,JSON.stringify({actor,...result,objectIds:targets.map(x=>x.objectId)})]);
+    return result;
+  });
+}

+ 10 - 74
scripts/merge-reading-users.mjs

@@ -1,6 +1,7 @@
 #!/usr/bin/env node
 
 import { randomBytes } from 'node:crypto';
+import { buildPlan, isReadingImport, isCanonical, text } from './lib/reading-account-policy.mjs';
 
 const APP_ID = process.env.XIAOSHU_PARSE_APP_ID || '7pIbDBJmKx_main';
 const MASTER_KEY = process.env.XIAOSHU_MASTER_KEY || '';
@@ -56,74 +57,6 @@ async function allRows(path, where, keys) {
   return rows;
 }
 
-function text(value) { return String(value ?? '').trim(); }
-function number(value) { const parsed = Number(value); return Number.isFinite(parsed) ? parsed : 0; }
-function legacyId(row) { return number(row.legacyUserId || row.legacyUserData?.UserID); }
-function normalizePhone(value) {
-  const digits = text(value).replace(/\D/g, '');
-  if (/^86\d{11}$/.test(digits)) return digits.slice(2);
-  return /^1\d{10}$/.test(digits) ? digits : '';
-}
-function phones(row) {
-  return new Set([
-    row.mobile, row.phone, row.mobilePhoneNumber, row.username,
-    row.legacyUserData?.Mobile, row.legacyUserData?.UserName,
-  ].map(normalizePhone).filter(Boolean));
-}
-function normalizeName(value) {
-  return text(value).toLowerCase().replace(/[\s\p{P}\p{S}]+/gu, '');
-}
-function names(row) {
-  return new Set([
-    row.nickname, row.nickName, row.realName, row.name,
-    row.legacyUserData?.HoneyName, row.legacyUserData?.TrueName,
-  ].map(normalizeName).filter((value) => value.length >= 2));
-}
-function intersects(left, right) { return [...left].some((value) => right.has(value)); }
-function isCanonical(row) { return text(row.sourceKey).startsWith('legacy-sync:user:') && !row.isDeleted; }
-function isReadingImport(row) {
-  return !text(row.sourceKey) && !legacyId(row) && row.isAdmin !== true && !row.isDeleted;
-}
-
-function buildPlan(users) {
-  const targets = users.filter(isCanonical);
-  const sources = users.filter(isReadingImport);
-  const targetsByPhone = new Map();
-  for (const target of targets) {
-    for (const phone of phones(target)) {
-      const values = targetsByPhone.get(phone) || [];
-      values.push(target);
-      targetsByPhone.set(phone, values);
-    }
-  }
-
-  const merge = [];
-  const promote = [];
-  let nameResolved = 0;
-  let ambiguous = 0;
-  let unmatched = 0;
-  for (const source of sources) {
-    const candidates = new Map();
-    for (const phone of phones(source)) {
-      for (const candidate of targetsByPhone.get(phone) || []) candidates.set(candidate.objectId, candidate);
-    }
-    let possible = [...candidates.values()];
-    if (possible.length > 1) {
-      const sourceNames = names(source);
-      const nameMatches = possible.filter((candidate) => intersects(sourceNames, names(candidate)));
-      if (nameMatches.length === 1) { possible = nameMatches; nameResolved += 1; }
-    }
-    if (possible.length === 1) {
-      merge.push({ source: source.objectId, target: possible[0].objectId });
-    } else {
-      promote.push(source.objectId);
-      if (possible.length > 1) ambiguous += 1;
-      else unmatched += 1;
-    }
-  }
-  return { targets, sources, merge, promote, nameResolved, ambiguous, unmatched };
-}
-
 async function ownerStats() {
   const result = {};
   for (const className of ['SurveyItem', 'SurveyLog']) {
@@ -160,16 +93,15 @@ async function executePlan(plan) {
   })).sessionToken;
 
   const embeddedMerge = JSON.stringify(plan.merge).replace(/</g, '\\u003c');
-  const embeddedPromote = JSON.stringify(plan.promote).replace(/</g, '\\u003c');
   const code = String.raw`
 async function handler(request,response){
   try{
     const current=request.user||(typeof user!=='undefined'?user:null);if(!current)return response.status(401).json({success:false,message:'需要超级管理员会话'});await current.fetch({useMasterKey:true});
     const roles=Array.isArray(current.get('roles'))?current.get('roles').map(String):[];if(current.get('adminRoleKey')!=='super-admin'&&!roles.includes('super-admin'))return response.status(403).json({success:false,message:'仅超级管理员可执行'});
-    const companyId=${JSON.stringify(COMPANY_ID)},merge=${embeddedMerge},promote=${embeddedPromote};
+    const companyId=${JSON.stringify(COMPANY_ID)},merge=${embeddedMerge};
     await Psql.none('ALTER TABLE "SurveyLog" ALTER COLUMN "user" TYPE text USING CAST("user" AS text)');
     const merged=merge.length?await Psql.one('WITH input AS MATERIALIZED (SELECT x.source,x.target FROM jsonb_to_recordset($2::jsonb) AS x(source text,target text)),valid AS MATERIALIZED (SELECT i.source,i.target FROM input i JOIN "_User" s ON s."objectId"=i.source AND s."company"=$1 JOIN "_User" t ON t."objectId"=i.target AND t."company"=$1 WHERE COALESCE(s."isDeleted",FALSE)=FALSE AND COALESCE(t."isDeleted",FALSE)=FALSE),items AS (UPDATE "SurveyItem" r SET "user"=v.target,"updatedAt"=NOW() FROM valid v WHERE CAST(r."user" AS text) IN (v.source,\'_User$\'||v.source) RETURNING r."objectId"),logs AS (UPDATE "SurveyLog" r SET "user"=v.target,"updatedAt"=NOW() FROM valid v WHERE CAST(r."user" AS text) IN (v.source,\'_User$\'||v.source) RETURNING r."objectId"),profiles AS (UPDATE "_User" t SET "level"=COALESCE(NULLIF(t."level",\'\'),NULLIF(s."level",\'\')),"updatedAt"=NOW() FROM valid v JOIN "_User" s ON s."objectId"=v.source WHERE t."objectId"=v.target RETURNING t."objectId"),sessions AS (DELETE FROM "_Session" x USING valid v WHERE CAST(x."user" AS text) IN (v.source,\'_User$\'||v.source) RETURNING x."objectId"),sources AS (UPDATE "_User" s SET "isDisabled"=TRUE,"isDeleted"=TRUE,"identityType"=\'merged\',"sourceKey"=\'reading-merged:user:\'||s."objectId","legacyUserData"=COALESCE(s."legacyUserData",\'{}\'::jsonb)||jsonb_build_object(\'ReadingMergedIntoObjectId\',v.target,\'ReadingMergedAt\',NOW()),"updatedAt"=NOW() FROM valid v WHERE s."objectId"=v.source RETURNING s."objectId") SELECT (SELECT COUNT(*)::int FROM sources) users,(SELECT COUNT(*)::int FROM items) items,(SELECT COUNT(*)::int FROM logs) logs,(SELECT COUNT(*)::int FROM sessions) sessions',[companyId,JSON.stringify(merge)]):{users:0,items:0,logs:0,sessions:0};
-    const promoted=promote.length?await Psql.one('WITH lock_row AS MATERIALIZED (SELECT pg_advisory_xact_lock(hashtext(\'xiaoshu-legacy-user-id\'))),input AS MATERIALIZED (SELECT value#>>\'{}\' object_id FROM jsonb_array_elements($2::jsonb)),maximum AS MATERIALIZED (SELECT COALESCE(MAX(CASE WHEN COALESCE("legacyUserId",0)>0 THEN "legacyUserId" ELSE 0 END),0)::bigint max_id FROM "_User",lock_row WHERE "company"=$1),candidates AS MATERIALIZED (SELECT u."objectId",(SELECT max_id FROM maximum)+ROW_NUMBER() OVER(ORDER BY u."objectId") legacy_id FROM "_User" u JOIN input i ON i.object_id=u."objectId" WHERE u."company"=$1 AND COALESCE(u."isDeleted",FALSE)=FALSE AND COALESCE(u."legacyUserId",0)=0),updated AS (UPDATE "_User" u SET "legacyUserId"=c.legacy_id,"legacyGroupId"=1,"identityType"=\'member\',"sourceKey"=\'reading-unified:user:\'||u."objectId","isDisabled"=FALSE,"isDeleted"=FALSE,"registeredAt"=COALESCE(u."registeredAt",u."createdAt"),"legacyUserData"=COALESCE(u."legacyUserData",\'{}\'::jsonb)||jsonb_build_object(\'UserID\',c.legacy_id,\'UserName\',COALESCE(NULLIF(u."username",\'\'),\'reading_\'||u."objectId"),\'HoneyName\',COALESCE(NULLIF(u."nickname",\'\'),NULLIF(u."realName",\'\'),NULLIF(u."username",\'\'),\'阅读会员\'),\'Mobile\',COALESCE(NULLIF(u."mobile",\'\'),NULLIF(u."phone",\'\'),CASE WHEN u."username"~\'^1[0-9]{10}$\' THEN u."username" ELSE \'\' END),\'GroupID\',1,\'ParentUserID\',0,\'RegTime\',COALESCE(u."registeredAt",u."createdAt"),\'State\',1,\'ReadingUnifiedAt\',NOW()),"updatedAt"=NOW() FROM candidates c WHERE u."objectId"=c."objectId" RETURNING u."objectId") SELECT COUNT(*)::int users FROM updated',[companyId,JSON.stringify(promote)]):{users:0};
+    const promoted={users:0};
     response.json({success:true,data:{merged,promoted}});
   }catch(error){response.status(Number(error.status)||500).json({success:false,message:String(error.message||error)});}
 }`;
@@ -221,8 +153,9 @@ const summary = {
   maxMergeSourceIdLength: Math.max(0, ...plan.merge.map((item) => item.source.length)),
   maxMergeTargetIdLength: Math.max(0, ...plan.merge.map((item) => item.target.length)),
   nameResolved: plan.nameResolved,
-  ambiguousPromoted: plan.ambiguous,
-  unmatchedPromoted: plan.unmatched,
+  heldForVerification: plan.held,
+  ambiguousHeld: plan.ambiguous,
+  unmatchedHeld: plan.unmatched,
   surveyItemsToMove: plan.merge.reduce((total, item) => total + (ownersBefore.SurveyItem.counts.get(item.source) || 0), 0),
   surveyLogsToMove: plan.merge.reduce((total, item) => total + (ownersBefore.SurveyLog.counts.get(item.source) || 0), 0),
 };
@@ -248,7 +181,10 @@ try {
       surveyItems: ownersAfter.SurveyItem.records,
       surveyLogs: ownersAfter.SurveyLog.records,
     };
-    if (remaining.length || invalidOwners.size) throw new Error(`合并后验证失败:未归一账号 ${remaining.length},无效阅读数据归属 ${invalidOwners.size}`);
+    // Unverified reading owners remain untouched, instead of receiving invented legacy IDs.
+    const remainingPlan = buildPlan(usersAfter);
+    if (remainingPlan.merge.length) throw new Error('合并后仍有已核实待合并账号');
+    if (ownersAfter.SurveyItem.records !== ownersBefore.SurveyItem.records || ownersAfter.SurveyLog.records !== ownersBefore.SurveyLog.records) throw new Error('阅读记录数量变化');
   }
   process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`);
 } finally {

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

@@ -60,6 +60,7 @@ export async function runDirectSync(env=process.env,args=process.argv.slice(2)){
   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);
+  if(Number(report.supplement.accounts?.identityConflicts||0)>0)throw Error('legacy_account_identity_conflict: resolve reading-generated IDs before syncing business records');
   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){

+ 34 - 2
scripts/tests/direct-nightly-sync.test.mjs

@@ -117,9 +117,11 @@ test('新增账号与栏目幂等补齐,已有账户余额和目录名称保
  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,\'新系统目录\')');
+ await db.exec('ALTER TABLE "_User" ADD COLUMN "isDeleted" boolean, ADD COLUMN "sourceKey" text');
  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.Parse.Query=class{constructor(className){assert.equal(className,'Company');}async get(id){return{id,className:'Company'};}};
+ context.Parse.Object={createWithoutData:()=>{throw Error('Production SDK does not support two-argument createWithoutData');}};
+ 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" ("objectId",company,username,"legacyUserId","legacyUserData") 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);
@@ -150,6 +152,20 @@ test('未取得服务器对账证据时不完成运行;已退出进程的目
  }finally{globalThis.fetch=realFetch;await rm(root,{recursive:true,force:true});}
 });
 
+test('删除标记禁止恢复,阅读数字编号冲突不会当作小树身份匹配', async()=>{
+ await sql('INSERT INTO "_User" ("objectId",company,username,"legacyUserId","legacyUserData","isDeleted","sourceKey") VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7)', ['deleted-user','co','deleted',55,'{"Purse":88,"SyncExcluded":true}',true,'legacy-sync:user:55']);
+ await sql('INSERT INTO "_User" ("objectId",company,username,"legacyUserId","legacyUserData","isDeleted","sourceKey") VALUES ($1,$2,$3,$4,$5::jsonb,$6,$7)', ['reading-user','co','reading',1855,'{"Purse":77}',false,'reading-unified:user:reading-user']);
+ const previous=context.fetch;
+ context.fetch=async url=>({ok:true,json:async()=>({retcode:0,page:{pageCount:1},result:new URL(url).searchParams.get('action')==='user_list'?[{UserID:55,UserName:'deleted',GroupID:3,State:1},{UserID:1855,UserName:'real-student',GroupID:1}]:[]})});
+ try {
+  const r=await call({action:'supplement'});assert.equal(r.success,true,r.message);
+  assert.equal(r.data.accounts.excluded,1);assert.equal(r.data.accounts.identityConflicts,1);assert.equal(r.data.accounts.matched,0);assert.equal(r.data.accounts.created,0);
+  assert.equal((await sql('SELECT "isDeleted" FROM "_User" WHERE "objectId"=$1',['deleted-user']))[0].isDeleted,true);
+  assert.equal((await sql('SELECT "legacyUserData" FROM "_User" WHERE "objectId"=$1',['reading-user']))[0].legacyUserData.Purse,77);
+  assert.equal((await sql('SELECT count(*)::int n FROM "LegacySyncConflict" WHERE "conflictType"=$1',['reading-legacy-id-collision']))[0].n,1);
+ } finally {context.fetch=previous;}
+});
+
 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;
@@ -186,3 +202,19 @@ test('只清理七天前已完成运行的原始缓存,保留失败断点及
   assert.equal(files.filter(x=>x.endsWith('.json')).length,3);
  }finally{await rm(root,{recursive:true,force:true});}
 });
+
+
+test('账号编号冲突时停止业务数据同步,保留阻塞报告',async()=>{
+ const root=await mkdtemp(join(tmpdir(),'direct-identity-block-')),realFetch=globalThis.fetch;let sourceCalls=0;
+ try{
+  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:'identity-block-000001',businessDate:'2026-10-13'};
+   else if(p.action==='supplement')data={accounts:{identityConflicts:1}};
+   else {sourceCalls++;throw Error('business writes must not run');}
+   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.equal(sourceCalls,0);assert.match(JSON.stringify(r),/legacy_account_identity_conflict/);
+ }finally{globalThis.fetch=realFetch;await rm(root,{recursive:true,force:true});}
+});

+ 25 - 0
scripts/tests/reading-account-policy.test.mjs

@@ -0,0 +1,25 @@
+import {test} from 'node:test';
+import assert from 'node:assert/strict';
+import {buildPlan} from '../lib/reading-account-policy.mjs';
+const canonical={objectId:'old',sourceKey:'legacy-sync:user:8',legacyUserId:8,mobile:'13900000000'};
+const reading={objectId:'reading',mobile:'13900000000'};
+test('手机号相同也不能跨来源自动合并,乍兔及未知阅读账号均待核实',()=>{
+ for(const origin of [undefined,'zatu']){
+  const plan=buildPlan([canonical,{...reading,legacyUserData:{ReadingSourceVerified:origin}}]);
+  assert.equal(plan.merge.length,0);assert.equal(plan.promote.length,0);assert.equal(plan.held[0].reason,'unverified-source');
+ }
+});
+test('已核实为小树且唯一匹配的阅读账号可合并',()=>{
+ const plan=buildPlan([canonical,{...reading,legacyUserData:{ReadingSourceVerified:'xiaoshu'}}]);
+ assert.deepEqual(plan.merge,[{source:'reading',target:'old'}]);assert.equal(plan.promote.length,0);
+});
+test('已核实但未匹配或歧义的阅读账号也不能生成旧系统编号',()=>{
+ const verified={...reading,legacyUserData:{ReadingSourceVerified:'xiaoshu'}};
+ for(const users of [[verified],[canonical,{...canonical,objectId:'second'},verified]]){
+  const plan=buildPlan(users);assert.equal(plan.merge.length,0);assert.equal(plan.promote.length,0);assert.equal(plan.held.length,1);
+ }
+});
+test('已删除来源及已删除小树目标不能复活',()=>{
+ assert.equal(buildPlan([canonical,{...reading,isDeleted:true}]).sources.length,0);
+ const plan=buildPlan([{...canonical,isDeleted:true},{...reading,legacyUserData:{ReadingSourceVerified:'xiaoshu'}}]);assert.equal(plan.merge.length,0);assert.equal(plan.promote.length,0);
+});

+ 18 - 0
scripts/tests/retire-imported-accounts.test.mjs

@@ -0,0 +1,18 @@
+import{test}from'node:test';import assert from'node:assert/strict';import{PGlite}from'@electric-sql/pglite';import{retireImportedAccounts}from'../lib/retire-imported-accounts.mjs';
+test('按对象身份原子注销,释放阅读伪编号,保留课时和业务记录;重复执行无副作用',async()=>{
+ const db=new PGlite();const q=async(s,p)=>(await db.query(s,p)).rows;
+ const a={any:q,oneOrNone:async(s,p)=>(await q(s,p))[0],none:async(s,p)=>{await q(s,p);}};a.tx=async f=>{await db.exec('BEGIN');try{const r=await f(a);await db.exec('COMMIT');return r;}catch(e){await db.exec('ROLLBACK');throw e;}};
+ try{
+ await db.exec('CREATE TABLE "_User" ("objectId" text,company text,"isAdmin" boolean,roles text[],"adminRoleKey" text,"legacyUserId" numeric,"sourceKey" text,"updatedAt" timestamptz,"isDeleted" boolean,"isDisabled" boolean,"legacyUserData" jsonb);CREATE TABLE "_Session" ("objectId" text,"user" text);CREATE TABLE "LegacyDirectRun" ("runId" text,company text,status text,"leaseUntil" timestamptz);CREATE TABLE "SysLog" ("objectId" text,company text,"sourceKey" text,type1 text,type2 text,type3 text,detail text,"createdAt" timestamptz,"updatedAt" timestamptz);CREATE TABLE lessons (uid int,amount int);INSERT INTO lessons VALUES(1855,100)');
+ await q('INSERT INTO "_User" ("objectId",company,"legacyUserId","sourceKey","updatedAt","legacyUserData") VALUES ($1,$2,$3,$4,$5,$6::jsonb)',['reading','co',1855,'reading-unified:user:reading','2026-10-13T00:00:00Z','{"UserID":1855,"Purse":88}']);
+ await q('INSERT INTO "_Session" VALUES ($1,$2)',['session','_User$reading']);
+ const target={objectId:'reading',legacyUserId:1855,sourceKey:'reading-unified:user:reading',updatedAt:'2026-10-13T00:00:00Z'};
+ await assert.rejects(retireImportedAccounts(a,'other',[target],'operator','r'),/wrong_company/);
+ await assert.rejects(retireImportedAccounts(a,'co',[{...target,legacyUserId:1}],'operator','r'),/target_changed/);
+ assert.equal((await q('SELECT * FROM "_Session"')).length,1);
+ const r=await retireImportedAccounts(a,'co',[target],'operator','r');assert.equal(r.retired,1);assert.equal(r.detachedSyntheticIds,1);assert.equal(r.revokedSessions,1);
+ const row=(await q('SELECT * FROM "_User"'))[0];assert.equal(row.isDeleted,true);assert.equal(row.isDisabled,true);assert.equal(row.legacyUserId,null);assert.equal(row.legacyUserData.UserID,undefined);assert.equal(row.legacyUserData.Purse,88);assert.equal(row.legacyUserData.SyncExcluded,true);
+ assert.equal((await q('SELECT * FROM lessons'))[0].amount,100);
+ assert.equal((await retireImportedAccounts(a,'co',[target],'operator','r')).alreadyRetired,1);assert.equal((await q('SELECT * FROM "SysLog"')).length,1);
+ }finally{await db.close();}
+});