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

fix: safely reconcile legacy member credit ledgers

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

+ 31 - 0
docs/migration/member-ledger-backfill-2026-10-14.md

@@ -0,0 +1,31 @@
+# 会员历史课时流水补导验收(2026-10-14)
+
+## 原因
+
+此前仅许诺完成四类历史流水样本补导;账户余额同步不等于充值、扣减明细同步。Mac 夜间直接接口执行器在业务数据之前的账号身份冲突处阻塞,未进入财务流水阶段。因此其他会员余额存在,但页面查不到完整历史流水。
+
+本次直接使用已部署管理鉴权接口,从旧 WXAPP `user_point_list` 分账户持续分页读取;没有使用同步桥、没有执行充值或结课。
+
+## 实际结果
+
+- 四类账户完整核验 159 名学员,636 份服务端回执,17,105 条来源记录;本次新增 16,879 条。
+- 默认“正常、学习中”会员列表现为 162 人,其中 158 人已完成四类历史流水核验。4 人(ID 77、53、52、23)没有可靠的旧系统身份映射,保留待核验,不按相同数字 ID 强行关联。
+- 张洪睿(1562)经用户明确确认是陪练老师;旧系统 GroupID=3。备份后事务内仅修正 `identityType` 为 `coach`,记录修改前后及确认依据。真实接口回读确认陪练列表可见、会员列表不再显示。已有账号的直接同步分支保留新系统身份及余额。
+- 176 条早期导入流水存在精确 8 小时时区偏移。仅在旧来源键、业务编号、用户、增减数量、原余额、原始说明、操作人完全匹配,且无创建/更新时间、无新系统业务单或已确认来源哈希时修正;逐条保存前后审计。其余冲突继续拒绝覆盖。
+- 1,206 个账号的四类余额逐项比较未变;`CreditTransaction`、`ReviewReward`、`LessonRecord`、`PayrollLine` 的数量及全行校验和均未变化。
+
+初始候选清单包含非默认列表中的停用账号、旧陪练身份及管理账号,服务端身份校验拒绝这些导入。批次原始报告仍标为 `partial`,不能描述为所有账号或全量夜间同步成功。
+
+## 验证与发布
+
+- `node --test scripts/tests/member-credit-ledger.test.mjs`:6 项通过,含分页、成套充值/退回、体验限制、结课、预留、时区修复与幂等保护。
+- `npm run cloud:validate`:11 个云函数定义通过。
+- 服务端按业务编号集合与来源哈希重新计算 636 份回执校验和,全部一致。
+- 真实页面抽查:曾知行 63 条、胡竣敏 94 条,首屏各 20 条并可展开余额明细。曾知行重复补导新增 0 条,流水总数及版本不变。
+- 已备份实际线上函数,只替换经测试的财务导入模块;`xiaoshu.ops.gateway.v3`、`xiaoshu.ops.gateway`、`xiaoshu.sync.direct-v1` 发布后全文回读一致。无需数据库结构迁移,无前端修改或发布。
+
+私有备份、批次回执、源数据及浏览器验证存于本机权限受限目录 `/Users/mac/.local/state/xiaoshu/cloud-releases/2026-10-14-member-credit-ledger/`,不提交凭据及逐人原始数据。
+
+## 保留的边界
+
+夜间任务的其他账号身份冲突仍是独立阻塞;本次财务补导不代表原 9 个业务数据集已完成当晚同步,也未更新夜间成功标志。未核实身份的账号不得通过放宽校验来补流水。

+ 14 - 1
scripts/cloud/legacy-finance.js

@@ -1,4 +1,11 @@
 // Server fetches authoritative WXAPP rows; clients cannot submit projected ledger data.
+function untouchedLegacyFinanceTimezoneRow(row,raw,id,iso){
+ return row.sourceKey==='[["ExpHisID",'+id+']]'&&String(row.expHisId)===id
+  &&row.createdAt==null&&row.updatedAt==null&&!row.transactionId&&!row.legacyFinanceId&&!row.legacyFinanceHash
+  &&Date.parse(row.hisTime)-Date.parse(iso)===8*60*60*1000
+  &&String(row.detail||'')===String(raw.Detail||'')&&String(row.remark||'')===String(raw.Remark||'')
+  &&Number(row.operator||0)===(Number(raw.Operator)||0)&&Number(row.scoreType||0)===(Number(raw.ScoreType)||0);
+}
 async function importLegacyFinance(co,uid,runId,readSource){
  if(!Number.isSafeInteger(uid)||uid<1||!/^[a-zA-Z0-9:_-]{8,100}$/.test(runId))throw Error('invalid_finance_identity');
  const users=await Psql.query('SELECT "objectId",username,"legacyUserData","sourceKey","isDeleted","identityType" FROM "_User" WHERE company=$1 AND "legacyUserId"=$2',[co,uid]);
@@ -29,7 +36,13 @@ async function importLegacyFinance(co,uid,runId,readSource){
     const hash=(await t.one('SELECT md5($1::jsonb::text) hash',[JSON.stringify(raw)])).hash;
     const prior=await t.any('SELECT to_jsonb(l) j FROM "'+table+'" l WHERE company=$1 AND ("legacyFinanceId"=$2 OR "sourceKey"=$3 OR to_jsonb(l)->>\'expHisId\'=$2)',[co,id,'[["ExpHisID",'+id+']]']);
     if(prior.length>1)throw Error('finance_duplicate_source');
-    if(prior.length){const j=prior[0].j;if(j.legacyFinanceHash&&j.legacyFinanceHash!==hash)throw Error('finance_source_conflict_preserved');if(Number(j.userId)!==uid||Number(j.score)!==delta||((j.scoreBefore===null?null:Number(j.scoreBefore))!==before)||Date.parse(j.hisTime)!==Date.parse(iso))throw Error('finance_existing_row_conflict');
+    if(prior.length){const j=prior[0].j;if(j.legacyFinanceHash&&j.legacyFinanceHash!==hash)throw Error('finance_source_conflict_preserved');if(Number(j.userId)!==uid||Number(j.score)!==delta||((j.scoreBefore===null?null:Number(j.scoreBefore))!==before))throw Error('finance_existing_row_conflict');
+     if(Date.parse(j.hisTime)!==Date.parse(iso)){
+      if(!untouchedLegacyFinanceTimezoneRow(j,raw,id,iso))throw Error('finance_existing_row_conflict');
+      const corrected=new Date(iso).toISOString();
+      await t.none('INSERT INTO "LegacySyncConflict"("objectId",company,"sourceKey",dataset,"legacyKey","conflictType",message,resolved,resolution,"createdAt","updatedAt") VALUES(xs_object_id(),$1,$2,\'finance\',$3,\'finance-legacy-timezone-repair\',$4,true,\'原始流水核验一致,仅修复早期导入时区偏移\',now(),now())',[co,'finance-timezone:'+account+':'+id,id,JSON.stringify({runId,account,studentId:uid,before:j,after:{...j,hisTime:corrected},sourceTime:raw.HisTime})]);
+      await t.none('UPDATE "'+table+'" SET "hisTime"=$2::timestamptz WHERE "objectId"=$1',[j.objectId,corrected]);
+     }
      await t.none('UPDATE "'+table+'" SET "legacyFinanceId"=$2,"legacyFinanceHash"=$3,"legacyFinanceRaw"=$4::jsonb WHERE "objectId"=$1',[j.objectId,id,hash,JSON.stringify(raw)]);
     }else{
      await t.none('INSERT INTO "'+table+'" SELECT * FROM jsonb_populate_record(NULL::"'+table+'",$1::jsonb)',[JSON.stringify({objectId:(await t.one('SELECT xs_object_id() id')).id,company:co,sourceKey:'legacy:finance:'+account+':'+id,userId:uid,score:delta,scoreBefore:before,hisTime:new Date(iso).toISOString(),createdAt:new Date().toISOString(),updatedAt:new Date().toISOString(),detail:String(raw.Detail||''),remark:String(raw.Remark||''),operator:Number(raw.Operator)||0,scoreType:Number(raw.ScoreType)||0,legacyFinanceId:id,legacyFinanceRaw:raw,legacyFinanceHash:hash})]);created++;

+ 16 - 0
scripts/tests/member-credit-ledger.test.mjs

@@ -88,3 +88,19 @@ test('体验预留只能一次,取消释放后可重新预约',async()=>{
  await query(`UPDATE "CourseCreditReservation" SET state='released' WHERE "objectId"='trialhold1'`);await reserve('trialhold2');assert.equal((await one(`SELECT xs_reserved('co',20,'UserExp') n`)).n,'1');
  }finally{await db.exec('ROLLBACK');}
 });
+
+test('仅修复未经编辑的早期旧流水8小时时区偏移,留存前后审计且不改变额度',async()=>{
+ const vm=await import('node:vm');
+ await db.exec('ALTER TABLE "UserExpDomP" ADD COLUMN "expHisId" numeric');
+ const Psql={query,any:query,one,oneOrNone:async(s,p)=>(await query(s,p))[0]||null,none:async(s,p)=>{await query(s,p);},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;}}};
+ const ctx={Psql,Date};vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/legacy-finance.js',import.meta.url),'utf8'),ctx);
+ const raw={ExpHisID:3001,UserID:20,UserName:'student',Score:10,score_before:0,HisTime:'2024-06-15T22:39:22',Detail:'原始充值',Remark:'旧系统',Operator:1,ScoreType:1};
+ const original={objectId:'old-timezone',company:'co',sourceKey:'[["ExpHisID",3001]]',expHisId:3001,userId:20,score:10,scoreBefore:0,hisTime:'2024-06-16T06:39:22+08:00',detail:raw.Detail,remark:raw.Remark,operator:1,scoreType:1};
+ const read=async p=>({items:p.stype===1?[raw]:[],page:{itemCount:p.stype===1?1:0,pageCount:1}});
+ await query('INSERT INTO "UserExpDomP" SELECT * FROM jsonb_populate_record(NULL::"UserExpDomP",$1::jsonb)',[JSON.stringify(original)]);
+ const before=await bal('student');const imported=await ctx.importLegacyFinance('co',20,'timezone-repair-1',read);assert.equal(imported.created,0);assert.deepEqual(await bal('student'),before);
+ const corrected=await one('SELECT "hisTime" FROM "UserExpDomP" WHERE "objectId"=$1',[original.objectId]);assert.equal(Date.parse(corrected.hisTime),Date.parse('2024-06-15T22:39:22+08:00'));
+ const audit=await one('SELECT message,resolved FROM "LegacySyncConflict" WHERE "sourceKey"=$1',['finance-timezone:Purse:3001']);assert.equal(audit.resolved,true);assert.equal(JSON.parse(audit.message).before.objectId,original.objectId);
+ assert.equal((await ctx.importLegacyFinance('co',20,'timezone-repair-2',read)).created,0);assert.equal((await one('SELECT count(*)::int n FROM "LegacySyncConflict" WHERE "sourceKey"=$1',['finance-timezone:Purse:3001'])).n,1);
+ for(const patch of [{updatedAt:'2026-10-01T00:00:00Z'},{createdAt:'2026-10-01T00:00:00Z'},{transactionId:'new-operation'},{legacyFinanceHash:'verified'},{sourceKey:'credit:new'},{hisTime:'2024-06-16T05:39:22+08:00'},{detail:'人工修正'}])assert.equal(ctx.untouchedLegacyFinanceTimezoneRow({...original,...patch},raw,'3001','2024-06-15T22:39:22+08:00'),false);
+});