Browse Source

fix(admin): reconcile uncertain credit submissions and recover confirmed conversion

彭峰 7 hours ago
parent
commit
a15201f7c2

+ 66 - 0
docs/qa/member-credit-conversion-recovery-2026-11-15.md

@@ -0,0 +1,66 @@
+# 田柏文换课未完成及充值报错核查
+
+2026-11-15,用户反馈会员1712在10:30—10:38两次充值报错。核查对象为田柏文高中(SGCcqnOkSZ),帐套7pIbDBJmKx、门店73。用户随后明确确认业务意图:**2次60分钟换成4次30分钟,陪练总时长不变**。
+
+## 已证实的实际结果
+
+原始操作不是“两次都没有到账”,也不是两笔普通购课充值:
+
+| 北京时间 | 实际落账 | 原业务单 |
+|---|---|---|
+| 10:30:03 | 独立扣减1次60分钟,不联动陪练 | 1e823451840d1396ddca |
+| 10:30:41 | 门店划拨增加2次30分钟,自动配套增加1小时陪练;门店分别扣2次和1小时 | 9311981df1e061b552e7 |
+| 10:38:51 | 再独立扣减1次60分钟,不联动陪练 | bdf9a7428bb43d1ec3c6 |
+
+没有第二笔30分钟充值回执。原划拨主单a9d72dc6207bb71a2fd3,配套陪练单49b787e5345cb3ca394d。更正前学员余额Purse=3、SilverCoin=2、UserExp=0、UserPoint=5.5;有效预留分别2、0、0、1。因此换课少补2次30分钟,且误走普通充值使陪练多出1小时并扣了门店库存。
+
+## 报错证据与边界
+
+- 截图的“连接暂未完成,提交结果待核对”由前端对连接失败/网关临时错误或20秒超时统一生成,不能证明数据库未提交。
+- `ops/users/credit-preview`等课时读取未进入只读白名单;仅预览出错也会显示“提交结果待核对”,造成业务状态误导。
+- 原界面只在组件内保存幂等号,刷新会丢失;失败后未读取CreditOperation回执,且改数量会生成新请求号。响应丢失可能因此被当成失败后另建业务。
+- 本次真实路径5轮预览和5轮预约列表全部成功:预览113—395ms,预约334—472ms。当前数据库活动采样未见阻塞。
+- 未拿到10:30—10:38原始请求级日志,不能断言早上的连接问题来自某条慢SQL、共享服务或哪一跳网络,也不能以当前正常宣称历史链路故障已根治。
+
+## 已执行的生产窄范围更正
+
+仅在用户确认换课意图后执行。脚本`scripts/repairs/tian-bowen-conversion-2026-11-15.mjs`中的恢复函数由限时、固定身份/帐套/目标的临时入口调用,整个过程包在同一个`Psql.db.tx`中:
+
+1. 取得原业务同一帐套额度锁,验证完整原单、两次独立扣减凭据、目标余额及所属门店。
+2. 独立更正补4次30分钟,不联动陪练或门店。
+3. 将原先误走充值的2次30分钟及配套1小时按原单退回门店。
+4. 事务内核验最终余额、有效预留和门店增减;任一不符则整笔回滚。
+
+先做生产事务回滚演练,再正式执行。新增更正单db061558bde6e0a1ca39、原单退款单9a0bdb776d0b2e0e3698。再次使用相同请求号只返回这两笔原回执,`alreadyApplied:true`,没有重复增减。完整请求指纹绑定操作者、学员、原因、原单和原扣减证据;原流水全部保留。
+
+| 账户 | 更正前余额 | 更正后余额 | 预留 | 可用 |
+|---|---:|---:|---:|---:|
+| 30分钟课时 | 3 | 5 | 2 | 3 |
+| 60分钟课时 | 2 | 2 | 0 | 2 |
+| 体验课时 | 0 | 0 | 0 | 0 |
+| 陪练小时 | 5.5 | 4.5 | 1 | 3.5 |
+
+门店仅原路退回2次30分钟与1小时陪练,60分钟/体验库存保持原值。没有创建/取消/结束预约,没有改学习记录、结课扣费、工资或收费规则。
+
+## 本地代码修复,尚未部署
+
+- 将课时预览、账户、流水和回执查询明确归为读取;读超时显示加载提示。
+- 新增只读`ops/users/credit-status`,按帐套、学员、当前操作者和原请求号隔离,只返回是否存在提交回执及业务号。查不到不能当作原请求失败。
+- 提交前保存待核验原请求号与原内容,按帐套/操作者/学员隔离在sessionStorage;刷新恢复后继续核验。存储被浏览器禁用时仅保证同页面保留,未承诺跨标签页/浏览器/设备恢复。
+- 丢失提交响应后查回执:已确认落账才清表单;无法确认则保留原号,同内容重试沿用原号,改数量不允许另建充值。
+- 旧学员的晚返回响应不能清除新学员待核验数据。既有未知请求遇重试4xx也不丢掉原号。
+
+新行为不自动重放金融写入,不修改实际充值公式/SQL、不调整任何其他学员。它不新增通用“换课”产品入口;上述恢复脚本仅用于本次已确认的个案。
+
+## 验证
+
+- 新增前端回归在旧实现7项失败、29项通过;修复后完整管理端402/402通过。
+- 完整服务端596/596通过;恢复与回执查询针对性8/8通过,覆盖原子回滚、退款回店、预留、重复执行、不同请求指纹冲突、回执权限隔离。
+- 独立审查发现并修正幂等指纹和旧响应跨账户清理两个问题,复审通过。
+- 管理端生产构建通过;11个云函数定义校验通过。
+- 最终正式接口再次读取余额、预留、可用值与上表一致;原充值主单unreturned=0。
+- 生产ops/gateway-v3 SHA-256保持`6f99a3826ab90bb3911d13bf33c2290ebbf850d77ac8163ac4c20b22c8518c0f`,证明未部署本地新网关;未上传新后台页面。
+
+私有原始账本、审计、事务结果、日志与身份信息保存在`/Users/mac/.local/state/xiaoshu/qa/2026-11-15-recharge/`,不进入Git。所有临时入口执行后删除。
+
+**交付状态:个案课时已按确认口径修正并回读核对;防误报和回执保护代码本地测试完成,未上线。历史连接超时的具体来源尚缺原始请求证据。**

+ 4 - 0
projects/xiaoshu-admin/src/app/cloud-functions.service.spec.ts

@@ -141,3 +141,7 @@ describe('CloudFunctionsService', () => {
   });
 
 });
+
+describe('课时查询与记账区分',()=>{
+ for(const operation of ['ops/users/credit-preview','ops/users/credit-accounts','ops/users/credit-ledger','ops/users/credit-status'])it(`${operation} 超时不提示已经提交`,fakeAsync(()=>{TestBed.configureTestingModule({providers:[CloudFunctionsService,provideHttpClient(),provideHttpClientTesting(),{provide:AdminSessionService,useValue:{session:()=>({sessionToken:'token'})}}]});const service=TestBed.inject(CloudFunctionsService),http=TestBed.inject(HttpTestingController);let message='';service.operations(operation).catch(e=>message=e.message);http.expectOne(`${CLOUD_FUNCTION_BASE_URL}/xiaoshu/ops/gateway-v3`);tick(20001);expect(message).toContain('加载');expect(message).not.toContain('提交结果');http.verify();}));
+});

+ 1 - 1
projects/xiaoshu-admin/src/app/cloud-functions.service.ts

@@ -40,7 +40,7 @@ export class CloudFunctionsService {
 
   // Explicitly allow only reads. Unknown operations and all submissions must never replay.
   private isReadOperation(operation: string): boolean {
-    return /^ops\/(?:users\/(?:list|detail|notes|audit)|schedule\/calendar|live\/versions|dashboard\/(?:summary|schedule-trend)|coaches\/(?:list|detail)|stores\/(?:list|detail)|coach-availability\/list|learning-reports\/(?:list|detail)|vocabulary\/(?:tree|list)|teaching-rules\/list|pay-rates\/list|appointments\/(?:preflight|list))$/.test(operation);
+    return /^ops\/(?:users\/(?:list|detail|notes|audit|credit-preview|credit-accounts|credit-ledger|credit-status)|schedule\/calendar|live\/versions|dashboard\/(?:summary|schedule-trend)|coaches\/(?:list|detail)|stores\/(?:list|detail)|coach-availability\/list|learning-reports\/(?:list|detail)|vocabulary\/(?:tree|list)|teaching-rules\/list|pay-rates\/list|appointments\/(?:preflight|list))$/.test(operation);
   }
 
   private transportMessage(readOnly: boolean): string {

+ 16 - 5
projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts

@@ -4,26 +4,29 @@ import {CommonModule} from '@angular/common';
 import {Component,Input,Output,EventEmitter,OnChanges,ElementRef,computed,inject,signal} from '@angular/core';
 import {FormsModule} from '@angular/forms';
 import {RouterLink} from '@angular/router';
+import {CloudFunctionError} from '../cloud-functions.service';
 import {OperationsService} from '../operations.service';
 import {CreditAccounts,CreditTransaction} from '../member-enrollment.models';
 import {TeachingCreditAccount} from '../admin.models';
 import {OperationsDateFilterComponent} from './operations-date-filter.component';
 import {LessonRecordDetailComponent} from '../pages/lesson-record-detail.component';
 interface CreditLine {account:string;delta:number;before:number|null;after:number|null;availableBefore:number;availableAfter:number;storeBefore:number|null;storeAfter:number|null;storeAvailableBefore:number|null;storeAvailableAfter:number|null;kind:string;reason:string;source:string;operator:string;reference:string;lessonId:string;storeId:number;balances?:{storeBefore:number;storeAfter:number};}
+interface PendingCredit {key:string;fingerprint:string;payload:Record<string,unknown>;reason:string;version:string;}
 interface CreditPreview {version:string;studentId:number;storeId:number;lines:CreditLine[];}
 interface LedgerPage {total:number;version:string;items:{groupId:string;at:string;lines:CreditLine[]}[];}
 @Component({selector:'app-member-credit-account',standalone:true,imports:[DropdownDirective,CommonModule,FormsModule,RouterLink,OperationsDateFilterComponent,LessonRecordDetailComponent],templateUrl:'./member-credit-account.component.html',styleUrls:['./member-enrollment.scss']})
 export class MemberCreditAccountComponent implements OnChanges {
  readonly isStore=(()=>{const sessions=inject(AdminSessionService);return computed(()=>sessions.session()?.operationsRole==='store-admin');})();
  @Input({required:true}) objectId='';@Input() store=false;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();
+ private sessions=inject(AdminSessionService);private pending:PendingCredit|null=null;
  private host=inject(ElementRef<HTMLElement>);private operations=inject(OperationsService);loading=signal(false);private serial=0;data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);preview=signal<CreditPreview|null>(null);
  ledger=signal<LedgerPage|null>(null);ledgerBusy=signal(false);exporting=signal(false);ledgerError=signal('');private ledgerSerial=0;lessonId='';
  account:TeachingCreditAccount='Purse';amount=0;direction='add';sourceId='';method='';reason='';adjustSign='deduct';adjustMode='paired';evidence='';private fingerprint='';private key='';private previewFingerprint='';
  readonly expandedLedger=signal(new Set<string>());
  toggleLedger(groupId:string){this.expandedLedger.update(current=>{const next=new Set(current);if(next.has(groupId))next.delete(groupId);else next.add(groupId);return next;});}
  filterAccount='';filterKind='';dateFrom='';dateTo='';page=1;pageSize=20;
- ngOnChanges(){this.expandedLedger.set(new Set());this.preview.set(null);void this.load();if(!this.store)void this.loadLedger();}
- async load(){if(!this.objectId)return;const serial=++this.serial;this.loading.set(true);this.error.set('');try{const result=await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId});if(serial===this.serial)this.data.set(result);}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'账户加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
+ ngOnChanges(){this.restorePending();this.expandedLedger.set(new Set());this.preview.set(null);void this.load();if(!this.store)void this.loadLedger();}
+ async load(){if(!this.objectId)return;const serial=++this.serial;this.loading.set(true);this.error.set('');try{await this.reconcile();const result=await this.operations.request<CreditAccounts>(this.store?'ops/stores/credit-accounts':'ops/users/credit-accounts',{objectId:this.objectId});if(serial===this.serial){this.data.set(result);if(this.pending)this.error.set('上一笔充值结果尚待核对,原请求号已保留;请先核对流水,同一笔重试不会重复记账。');}}catch(e){if(serial===this.serial){this.data.set(null);this.error.set(e instanceof Error?e.message:'账户加载失败');}}finally{if(serial===this.serial)this.loading.set(false);}}
  label(account:string){return this.data()?.accounts.find(a=>a.account===account)?.label||account;}
  kindLabel(kind:string){return ({fund:'总部入账',transfer:'门店充值',refund:'原单扣回',adjust:'差错调整',lesson:'完课扣减',recharge:'历史充值',deduction:'历史扣减'} as Record<string,string>)[kind]||kind;}
  refundable():CreditTransaction[]{return(this.data()?.transactions||[]).filter(t=>t.kind==='transfer'&&Number(t.unreturned)>0);}
@@ -32,9 +35,17 @@ export class MemberCreditAccountComponent implements OnChanges {
  chooseAccount(account:TeachingCreditAccount,history=false){this.account=account;this.resetPreview();if(history){this.filterAccount=account;this.page=1;void this.loadLedger();}else{this.direction='add';if(account==='UserExp')this.amount=1;}requestAnimationFrame(()=>this.host.nativeElement.querySelector(history?'.ledger-panel':'form')?.scrollIntoView({behavior:'smooth',block:'start'}));}
  payload(){return{account:this.account,amount:this.amount,direction:this.direction,sourceTransactionId:this.sourceId,payMethod:this.method,...(this.direction==='adjust'?{adjustSign:this.adjustSign,adjustMode:this.adjustMode,evidence:this.evidence}:{})};}
  private valid(){if(!Number.isFinite(this.amount)||this.amount<=0||(this.account!=='UserPoint'&&!Number.isInteger(this.amount))||Math.abs(this.amount*100-Math.round(this.amount*100))>0.000001||!this.method||this.reason.trim().length<2||(!this.store&&this.direction==='deduct'&&!this.sourceId)||(this.direction==='adjust'&&this.evidence.trim().length<2)){this.error.set('请填写有效数量、方式和原因;扣回需原单,差错调整需核对依据');return false;}return true;}
- async check(){if(this.saving()||!this.valid())return;this.saving.set(true);this.error.set('');this.preview.set(null);try{const payload=this.payload();this.previewFingerprint=JSON.stringify([this.objectId,payload,this.reason]);this.preview.set(await this.operations.request<CreditPreview>('ops/users/credit-preview',{objectId:this.objectId,payload}));}catch(e){this.error.set(e instanceof Error?e.message:'预览失败');}finally{this.saving.set(false);}}
- async submit(){if(this.saving()||this.loading()||!this.valid())return;this.error.set('');this.message.set('');const payload=this.payload(),fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(!this.store&&(!this.preview()||fingerprint!==this.previewFingerprint)){this.preview.set(null);this.error.set('请先预览本次账户变动');return;}if(fingerprint!==this.fingerprint){this.fingerprint=fingerprint;this.key=crypto.randomUUID();}
- this.saving.set(true);try{await this.operations.mutate(this.store?'ops/stores/credit-post':'ops/users/adjust-balance',this.objectId,this.reason,payload,this.preview()?.version||'',this.key);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.preview.set(null);this.message.set('课时业务已完成,余额和流水已更新');await this.load();if(!this.store)await this.loadLedger();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'课时业务失败');}finally{this.saving.set(false);}}
+ private storageKey(){const s=this.sessions.session();return s?.objectId&&!this.store?'xiaoshu:pending-credit:'+JSON.stringify([s.company?.objectId||'',s.objectId,this.objectId]):'';}
+ private savePending(value:PendingCredit|null){this.pending=value;const key=this.storageKey();if(key)try{if(value)sessionStorage.setItem(key,JSON.stringify(value));else sessionStorage.removeItem(key);}catch{/* Same-page retries still retain the key when storage is unavailable. */}}
+ private restorePending(){const key=this.storageKey();if(!key)return;try{const raw=sessionStorage.getItem(key);if(!raw){this.pending=null;return;}const p=JSON.parse(raw) as PendingCredit;if(!p.key||!p.payload||!p.reason||!p.fingerprint||!p.version)return;this.pending=p;this.key=p.key;this.fingerprint=p.fingerprint;this.account=p.payload['account'] as TeachingCreditAccount;this.amount=Number(p.payload['amount']);this.direction=String(p.payload['direction']);if(this.direction==='adjust'){this.adjustSign=String(p.payload['adjustSign']);this.adjustMode=String(p.payload['adjustMode']);this.evidence=String(p.payload['evidence']||'');}this.sourceId=String(p.payload['sourceTransactionId']||'');this.method=String(p.payload['payMethod']||'');this.reason=p.reason;}catch{/* Never send a malformed saved command. */}}
+ private completed(){this.savePending(null);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.preview.set(null);this.error.set('');this.message.set('课时业务已完成,余额和流水已更新');}
+ private async reconcile():Promise<boolean>{if(!this.pending||this.store)return false;const key=this.pending.key,target=this.objectId,storage=this.storageKey();try{const status=await this.operations.request<{committed:boolean;operationId?:string}>('ops/users/credit-status',{objectId:target,idempotencyKey:key});if(target!==this.objectId||storage!==this.storageKey()||this.pending?.key!==key)return false;if(status.committed&&status.operationId){this.completed();return true;}}catch{/* A failed lookup cannot establish whether a write committed. */}return false;}
+ private pendingMatches(fingerprint:string){if(this.pending&&this.pending.fingerprint!==fingerprint){this.error.set('上一笔充值结果尚待核对,请先刷新账户;不能修改内容后另建充值。');return false;}return true;}
+ async check(){if(this.saving()||!this.valid())return;this.saving.set(true);this.error.set('');this.preview.set(null);try{if(await this.reconcile()){await this.load();await this.loadLedger();this.changed.emit();return;}const payload=this.payload(),fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(!this.pendingMatches(fingerprint))return;const result=await this.operations.request<CreditPreview>('ops/users/credit-preview',{objectId:this.objectId,payload});if(fingerprint!==JSON.stringify([this.objectId,this.payload(),this.reason]))return;this.previewFingerprint=fingerprint;this.preview.set(result);}catch(e){this.error.set(e instanceof Error?e.message:'预览失败');}finally{this.saving.set(false);}}
+ async submit(){if(this.saving()||this.loading()||!this.valid())return;this.error.set('');this.message.set('');const payload=this.payload(),fingerprint=JSON.stringify([this.objectId,payload,this.reason]);if(!this.pendingMatches(fingerprint))return;if(!this.store&&(!this.preview()||fingerprint!==this.previewFingerprint)){this.preview.set(null);this.error.set('请先预览本次账户变动');return;}if(fingerprint!==this.fingerprint){this.fingerprint=fingerprint;this.key=crypto.randomUUID();}
+ const version=this.preview()?.version||'',target=this.objectId,storage=this.storageKey(),requestKey=this.key,wasPending=Boolean(this.pending);const current=()=>target===this.objectId&&storage===this.storageKey()&&(!this.pending||this.pending.key===requestKey);if(!this.store)this.savePending({key:this.key,fingerprint,payload,reason:this.reason,version});
+ this.saving.set(true);try{await this.operations.mutate(this.store?'ops/stores/credit-post':'ops/users/adjust-balance',this.objectId,this.reason,payload,version,requestKey);if(storage)try{const saved=JSON.parse(sessionStorage.getItem(storage)||'null') as PendingCredit|null;if(saved?.key===requestKey)sessionStorage.removeItem(storage);}catch{}if(!current())return;this.completed();await this.load();if(!this.store)await this.loadLedger();this.changed.emit();}catch(e){if(!current())return;if(await this.reconcile()){await this.load();await this.loadLedger();this.changed.emit();}else{if(!wasPending&&e instanceof CloudFunctionError&&[400,403,404,409,422].includes(e.status))this.savePending(null);this.error.set(e instanceof Error?e.message:'课时业务失败');}}finally{this.saving.set(false);}}
+
  params(){return{objectId:this.objectId,account:this.filterAccount,kind:this.filterKind,dateFrom:this.dateFrom,dateTo:this.dateTo,page:this.page,pageSize:this.pageSize};}
  async loadLedger(){const serial=++this.ledgerSerial;this.ledgerBusy.set(true);this.ledgerError.set('');try{const result=await this.operations.request<LedgerPage>('ops/users/credit-ledger',this.params());if(serial===this.ledgerSerial){this.ledger.set(result);this.expandedLedger.set(new Set());}}catch(e){if(serial===this.ledgerSerial)this.ledgerError.set(e instanceof Error?e.message:'流水加载失败');}finally{if(serial===this.ledgerSerial)this.ledgerBusy.set(false);}}
  query(clear=false){if(clear){this.filterAccount='';this.filterKind='';this.dateFrom='';this.dateTo='';}this.page=1;void this.loadLedger();}

+ 13 - 2
projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts

@@ -32,10 +32,10 @@ describe('会员开课与课时操作', () => {
   const fixture=TestBed.createComponent(MemberEnrollmentComponent);fixture.componentInstance.studentId=20;await fixture.componentInstance.load();fixture.detectChanges();
   expect((fixture.nativeElement.querySelector('input[type=checkbox]') as HTMLInputElement).disabled).toBeTrue();expect(fixture.nativeElement.textContent).toContain('对应课时不足');
  });
- it('网络失败后充值重试沿用幂等键,修改数量才更换', async () => {
+ it('网络失败后充值重试沿用幂等键,核对前修改数量不得另建充值', async () => {
   const fixture=TestBed.createComponent(MemberCreditAccountComponent),c=fixture.componentInstance;c.objectId='student';c.amount=5;c.method='运营记账';c.reason='购买课时';operations.mutate.and.rejectWith(new Error('网络失败'));
   operations.request.and.resolveTo({version:'v1',lines:[]});await c.check();await c.submit();const first=operations.mutate.calls.mostRecent().args[5];await c.submit();expect(operations.mutate.calls.mostRecent().args[5]).toBe(first);
-  c.amount=6;await c.check();await c.submit();expect(operations.mutate.calls.mostRecent().args[5]).not.toBe(first);
+  c.amount=6;await c.check();await c.submit();expect(operations.mutate.calls.mostRecent().args[5]).toBe(first);expect(operations.mutate.calls.count()).toBe(2);expect(c.error()).toContain('核对');
  });
  it('退回必须关联原划拨单,自动使用原课时账户', async () => {
   const fixture=TestBed.createComponent(MemberCreditAccountComponent),c=fixture.componentInstance;c.objectId='student';c.direction='deduct';c.amount=1;c.method='运营记账';c.reason='退回余量';
@@ -73,3 +73,14 @@ describe('开课课程选择',()=>{
   c.search.set('');c.toggle(464);c.saving.set(true);c.toggle(464);c.clearSelection();expect(c.selected()).toEqual([464]);
  });
 });
+
+describe('充值丢失响应的回执核验',()=>{
+ let operations:jasmine.SpyObj<OperationsService>;
+ beforeEach(()=>{sessionStorage.clear();operations=jasmine.createSpyObj('OperationsService',['request','mutate']);TestBed.configureTestingModule({imports:[MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations},{provide:AdminSessionService,useValue:{session:()=>({objectId:'actor',company:{objectId:'co'},operationsRole:'ops-manager'})}}]});operations.request.and.callFake(async<T>(op:string)=> (op.endsWith('credit-preview')?{version:'v1',lines:[]}:op.endsWith('credit-status')?{committed:true,operationId:'receipt'}:op.endsWith('credit-accounts')?{accounts:[],transactions:[]}:{items:[],total:0}) as T);});
+ afterEach(()=>sessionStorage.clear());
+ function member(){const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='student';c.amount=2;c.method='微信';c.reason='购买课时';return c;}
+ it('提交响应丢失但回执已落库时显示完成且不再次充值',async()=>{const c=member();await c.check();operations.mutate.and.rejectWith(new Error('连接暂未完成'));await c.submit();expect(operations.request).toHaveBeenCalledWith('ops/users/credit-status',jasmine.objectContaining({objectId:'student',idempotencyKey:jasmine.any(String)}));expect(c.message()).toContain('已完成');expect(c.amount).toBe(0);expect(c.error()).toBe('');expect(operations.mutate.calls.count()).toBe(1);});
+ it('刷新页面仍携带原幂等号核验,不丢掉待核对充值',async()=>{const c=member();await c.check();operations.request.and.callFake(async<T>(op:string)=> (op.endsWith('credit-status')?Promise.reject(new Error('断网')):{version:'v1',lines:[]}) as T);operations.mutate.and.rejectWith(new Error('断网'));await c.submit();const key=operations.mutate.calls.mostRecent().args[5];operations.request.calls.reset();operations.request.and.callFake(async<T>(op:string)=> (op.endsWith('credit-status')?{committed:true,operationId:'receipt'}:op.endsWith('credit-accounts')?{accounts:[],transactions:[]}:{items:[],total:0}) as T);const next=member();next.ngOnChanges();await new Promise(resolve=>setTimeout(resolve,0));expect(operations.request).toHaveBeenCalledWith('ops/users/credit-status',{objectId:'student',idempotencyKey:key});expect(next.message()).toContain('已完成');expect(operations.mutate.calls.count()).toBe(1);});
+ it('旧学员提交返回时不能清除新学员的待核验充值',async()=>{operations.request.and.callFake(async<T>(op:string)=>(op.endsWith('credit-preview')?{version:'v1',lines:[]}:op.endsWith('credit-status')?{committed:false}:op.endsWith('credit-accounts')?{accounts:[],transactions:[]}:{items:[],total:0}) as T);const b=member();b.objectId='student-b';await b.check();operations.mutate.and.rejectWith(new Error('断网'));await b.submit();const storage='xiaoshu:pending-credit:'+JSON.stringify(['co','actor','student-b']),saved=sessionStorage.getItem(storage);const a=member();await a.check();let finish!:(x:unknown)=>void;operations.mutate.and.callFake(<T>()=>new Promise<T>(resolve=>finish=x=>resolve(x as T)));const pending=a.submit();a.objectId='student-b';a.ngOnChanges();await new Promise(resolve=>setTimeout(resolve,0));finish({});await pending;expect(sessionStorage.getItem(storage)).toBe(saved);expect(a.amount).toBe(2);expect(a.message()).toBe('');});
+ it('未核对清楚时修改数量不能生成另一笔充值',async()=>{const c=member();await c.check();operations.mutate.and.rejectWith(new Error('断网'));operations.request.and.rejectWith(new Error('断网'));await c.submit();c.amount=4;await c.check();await c.submit();expect(operations.mutate.calls.count()).toBe(1);expect(c.error()).toContain('核对');});
+});

+ 6 - 0
scripts/cloud/member-credit-ledger.js

@@ -1,3 +1,9 @@
+async function memberCreditStatus(context,input){
+ requireRole(context,['ops-manager']);const target=await memberTarget(context,input,text(input.objectId||input.targetId)),key=text(input.idempotencyKey);if(key.length<8||key.length>200)fail(400,'请求编号无效');
+ const row=await Psql.oneOrNone(`SELECT "objectId" FROM "CreditOperation" WHERE company=$1 AND "studentId"=$2 AND "idempotencyKey"=$3 AND request->>'target'=$4 AND request->>'actor'=$5`,[pointerId(target.get('company')),number(target.get('legacyUserId')),key,target.id,context.current.id]);
+ // No receipt yet is an unknown result, not proof that an in-flight command failed.
+ return row?{committed:true,operationId:row.objectId}:{committed:false};
+}
 async function memberCreditPreview(context,input,payload){
  requireRole(context,['ops-manager']);const target=await memberTarget(context,input,text(input.objectId||input.targetId));
  return(await enrollmentSql('SELECT xs_credit_plan($1,$2,$3::jsonb) result',[pointerId(target.get('company')),target.id,JSON.stringify(payload)])).result;

+ 1 - 0
scripts/deploy-admin-functions.mjs

@@ -4182,6 +4182,7 @@ async function handler(request,response) {
     if(operation==='ops/stores/credit-preview') return response.json({success:true,data:await storeCreditPreview(context,input,payload)});
     if(operation==='ops/stores/credit-ledger') return response.json({success:true,data:await storeCreditLedger(context,input)});
     if(operation==='ops/stores/credit-grants/list'||operation==='ops/stores/credit-grants/save') return response.json({success:true,data:await storeCreditGrants(context,input,payload,operation.endsWith('/save'))});
+    if(operation==='ops/users/credit-status') return response.json({success:true,data:await memberCreditStatus(context,input)});
     if(operation==='ops/users/credit-preview') return response.json({success:true,data:await memberCreditPreview(context,input,payload)});
     if(operation==='ops/users/credit-ledger') return response.json({success:true,data:await memberLedger(context,input)});
     if(operation==='ops/users/import-ledger'){requireRole(context,['ops-manager']);const target=await memberTarget(context,input,text(input.objectId));const cfg=await legacyScheduleConfig();return response.json({success:true,data:await importLegacyFinance(pointerId(target.get('company')),number(target.get('legacyUserId')),text(input.runId),async(args)=>legacyScheduleRequest(cfg,{action:'user_point_list',...args}))});}

+ 30 - 0
scripts/repairs/tian-bowen-conversion-2026-11-15.mjs

@@ -0,0 +1,30 @@
+// Run only inside one database transaction after the original conversion is confirmed.
+// Existing business functions create every correction receipt and enforce reservations.
+export async function recoverCreditConversion(tx, scope) {
+ const {companyId:co,actorId:actor,targetId:target,studentId:uid,sourceTransactionId:source,debitOperationIds:debits,key,reason}=scope;
+ await tx.one("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[co]);
+ const keys=[key+':replace',key+':reverse'];
+ const replacementPayload={account:'Purse',amount:4,direction:'adjust',adjustSign:'add',adjustMode:'single',evidence:'用户确认2次60分钟换4次30分钟,陪练总时长不变;关联原单 '+source+';原60分钟扣减 '+[...debits].sort().join(','),payMethod:'运营记账'};
+ const reversalPayload={account:'Purse',amount:2,direction:'deduct',sourceTransactionId:source,payMethod:'运营记账'};
+ const requests=[replacementPayload,reversalPayload].map(payload=>JSON.stringify({actor,target,payload,reason}));
+ const prior=await tx.query('SELECT "idempotencyKey",result,("studentId"=$3 AND request=CASE "idempotencyKey" WHEN $4 THEN $5::jsonb ELSE $6::jsonb END) matched FROM "CreditOperation" WHERE company=$1 AND "idempotencyKey"=ANY($2::text[])',[co,keys,uid,keys[0],...requests]);
+ if(prior.length){if(prior.length!==2||prior.some(x=>!x.matched))throw Error('修复回执不完整或请求冲突,请人工核查');return {alreadyApplied:true,receipts:keys.map(k=>prior.find(x=>x.idempotencyKey===k).result)};}
+ const balances=async()=> (await tx.one('SELECT "legacyUserData" data FROM "_User" WHERE company=$1 AND "objectId"=$2 AND "legacyUserId"=$3 AND "identityType"=\'member\'',[co,target,uid])).data;
+ const before=await balances();
+ if(Number(before.Purse)!==3||Number(before.SilverCoin)!==2||Number(before.UserExp)!==0||Number(before.UserPoint)!==5.5)throw Error('学员余额已变化,请重新核对');
+ const origin=await tx.one('SELECT * FROM "CreditTransaction" WHERE company=$1 AND "objectId"=$2 AND "studentId"=$3 AND account=\'Purse\' AND kind=\'transfer\' AND amount=2',[co,source,uid]);
+ if(Number(origin.storeId)!==73||Number(before.ParentUserID)!==73)throw Error('原单门店不符');
+ const companion=await tx.one('SELECT * FROM "CreditTransaction" WHERE company=$1 AND "operationId"=$2 AND account=\'UserPoint\' AND kind=\'transfer\' AND amount=1',[co,origin.operationId]);
+ if(Number(companion.studentId)!==uid||Number(companion.storeId)!==73)throw Error('原单配套时长不符');
+ const deductions=await tx.query('SELECT request FROM "CreditOperation" WHERE company=$1 AND "objectId"=ANY($2::text[]) AND "studentId"=$3',[co,debits,uid]);
+ if(deductions.length!==2||deductions.some(x=>x.request.target!==target||x.request.payload.account!=='SilverCoin'||x.request.payload.direction!=='adjust'||x.request.payload.adjustSign!=='deduct'||x.request.payload.adjustMode!=='single'||Number(x.request.payload.amount)!==1))throw Error('原60分钟扣减凭据不符');
+ const store=await tx.one('SELECT "objectId","legacyUserData" data FROM "_User" WHERE company=$1 AND "legacyUserId"=73 AND "identityType"=\'store\'',[co]);
+ const held=async()=> (await tx.one("SELECT jsonb_object_agg(a,xs_reserved($1,$2::numeric,a)) data FROM unnest(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) a",[co,uid])).data;
+ const reserved=await held();
+ const post=async(payload,idem)=>{const p=JSON.stringify(payload),plan=(await tx.one('SELECT xs_credit_plan($1,$2,$3::jsonb) result',[co,target,p])).result;return(await tx.one('SELECT xs_credit_operation($1,$2,$3,$4::jsonb,$5,$6,$7) result',[co,actor,target,p,reason,idem,plan.version])).result;};
+ const replacement=await post(replacementPayload,keys[0]);
+ const reversal=await post(reversalPayload,keys[1]);
+ const after=await balances(),storeAfter=(await tx.one('SELECT "legacyUserData" data FROM "_User" WHERE company=$1 AND "objectId"=$2',[co,store.objectId])).data;
+ if(Number(after.Purse)!==5||Number(after.SilverCoin)!==2||Number(after.UserExp)!==0||Number(after.UserPoint)!==4.5||JSON.stringify(await held())!==JSON.stringify(reserved)||Number(storeAfter.Purse)!==Number(store.data.Purse)+2||Number(storeAfter.UserPoint)!==Number(store.data.UserPoint)+1||Number(storeAfter.SilverCoin)!==Number(store.data.SilverCoin)||Number(storeAfter.UserExp)!==Number(store.data.UserExp))throw Error('更正后核账不符,整笔回滚');
+ return {alreadyApplied:false,before,after,reserved,receipts:[replacement,reversal],storeBefore:store.data,storeAfter};
+}

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

@@ -104,3 +104,20 @@ test('仅修复未经编辑的早期旧流水8小时时区偏移,留存前后
  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);
 });
+
+test('已确认的换课修复原子退回误充值,保留预留且重复执行不重加',async()=>{
+ const {recoverCreditConversion}=await import('../repairs/tian-bowen-conversion-2026-11-15.mjs');
+ await db.exec('BEGIN');try{
+ await db.exec(`SELECT set_config('xiaoshu.credit_write','yes',true);UPDATE "_User" SET "legacyUserId"=73,"legacyUserData"='{"Purse":100,"SilverCoin":100,"UserPoint":100,"UserExp":0}' WHERE "objectId"='store';UPDATE "_User" SET "legacyUserData"='{"ParentUserID":73,"Purse":1,"SilverCoin":4,"UserPoint":4.5,"UserExp":0}' WHERE "objectId"='student';`);
+ const p=payload('SilverCoin',1,{direction:'adjust',adjustSign:'deduct',adjustMode:'single',evidence:'换两次半小时'});const d1=await apply(p,'convert-old-debit1'),original=await apply(payload('Purse',2),'convert-old-transfer'),d2=await apply(p,'convert-old-debit2');
+ await query(`INSERT INTO "CourseCreditReservation"("objectId",company,"studentId","appointmentId","creditCosts",state) VALUES('convert-hold','co',20,'absent','[{"account":"Purse","amount":2},{"account":"UserPoint","amount":1}]','reserved')`);
+ const scope={companyId:'co',actorId:'admin',targetId:'student',studentId:20,sourceTransactionId:original.transactions[0].objectId,debitOperationIds:[d1.objectId,d2.objectId],key:'conversion-fix',reason:'用户确认换课时更正'};
+ await db.exec('SAVEPOINT before_recovery');
+ const broken={one:async(s,p)=>{if(s.includes('xs_credit_operation')&&p[5].endsWith(':reverse'))throw Error('模拟中途断开');return one(s,p);},query};
+ await assert.rejects(recoverCreditConversion(broken,scope),/中途断开/);await db.exec('ROLLBACK TO SAVEPOINT before_recovery');assert.equal((await bal('student')).Purse,3);assert.equal((await bal('store')).Purse,98);
+ const result=await recoverCreditConversion({one,query},scope);assert.equal(result.alreadyApplied,false);assert.deepEqual(await bal('student'),{ParentUserID:73,Purse:5,SilverCoin:2,UserPoint:4.5,UserExp:0});assert.equal((await bal('store')).Purse,100);assert.equal((await bal('store')).UserPoint,100);assert.equal(Number(result.reserved.Purse),2);assert.equal(Number(result.reserved.UserPoint),1);
+ assert.equal((await recoverCreditConversion({one,query},scope)).alreadyApplied,true);for(const changed of [{targetId:'other'},{sourceTransactionId:'other'},{reason:'另一原因'},{actorId:'other'},{studentId:21}])await assert.rejects(recoverCreditConversion({one,query},{...scope,...changed}),/冲突/);assert.equal((await bal('student')).Purse,5);
+ }finally{await db.exec('ROLLBACK');}
+});
+
+ test('充值回执查询按帐套、学员、操作者和原请求号隔离,不返回其他人的结果',async()=>{const vm=await import('node:vm');const ctx={Psql:{oneOrNone:async(s,p)=>(await query(s,p))[0]||null},requireRole:(_c,roles)=>assert.deepEqual(Array.from(roles),['ops-manager']),memberTarget:async()=>({id:'student',get:k=>k==='company'?'co':20}),text:x=>String(x??''),number:Number,pointerId:x=>x,fail:(_s,m)=>{throw Error(m);}};vm.createContext(ctx);vm.runInContext(readFileSync(new URL('../cloud/member-credit-ledger.js',import.meta.url),'utf8'),ctx);const context={current:{id:'admin'}};const found=await ctx.memberCreditStatus(context,{objectId:'student',idempotencyKey:'paired-0001'});assert.equal(found.committed,true);assert.ok(found.operationId);assert.equal((await ctx.memberCreditStatus({current:{id:'other'}},{objectId:'student',idempotencyKey:'paired-0001'})).committed,false);assert.equal((await ctx.memberCreditStatus(context,{objectId:'student',idempotencyKey:'not-posted-01'})).committed,false);await assert.rejects(ctx.memberCreditStatus(context,{objectId:'student',idempotencyKey:'x'}),/编号/);});