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

feat: unify member credit operations and historical ledgers

彭峰 1 день назад
Родитель
Сommit
9bc5241445
26 измененных файлов с 588 добавлено и 65 удалено
  1. 8 0
      docs/migration/nightly-legacy-sync.md
  2. 52 0
      docs/qa/member-credit-ledger-2026-10-14/README.md
  3. 30 0
      projects/xiaoshu-admin/src/app/components/member-credit-account.component.html
  4. 34 31
      projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts
  5. 10 3
      projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts
  6. 3 0
      projects/xiaoshu-admin/src/app/components/member-enrollment.scss
  7. 2 2
      projects/xiaoshu-admin/src/app/member-enrollment.models.ts
  8. 4 4
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html
  9. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.spec.ts
  10. 1 1
      projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts
  11. 2 2
      projects/xiaoshu-admin/src/app/pages/operations-users.component.html
  12. 20 0
      scripts/check-member-credit-browser.mjs
  13. 10 2
      scripts/cloud/legacy-direct-control.js
  14. 1 1
      scripts/cloud/legacy-direct-sync.js
  15. 50 0
      scripts/cloud/legacy-finance.js
  16. 14 0
      scripts/cloud/member-credit-ledger.js
  17. 4 2
      scripts/cloud/member-enrollment.js
  18. 1 0
      scripts/deploy-admin-functions.mjs
  19. 1 1
      scripts/lib/legacy-direct-bundle.mjs
  20. 21 0
      scripts/migrate-member-credit-ledger.mjs
  21. 2 0
      scripts/run-nightly-direct-sync.mjs
  22. 11 7
      scripts/smoke-member-enrollment.mjs
  23. 38 7
      scripts/smoke-mobile-teaching-flow.mjs
  24. 161 0
      scripts/sql/member-credit-ledger.sql
  25. 17 1
      scripts/tests/direct-nightly-sync.test.mjs
  26. 90 0
      scripts/tests/member-credit-ledger.test.mjs

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

@@ -79,3 +79,11 @@ journalctl -u xiaoshu-legacy-sync-nightly.service --since today
 ```
 
 人工运行失败时先处理报告,不把 timer 已启用视为同步已成功。Persistent=true 允许主机恢复后补触发错过的运行;服务器必须支持该带时区的 OnCalendar 表达式,并以服务器实际 list-timers 输出验收。
+
+## 2026-10-14 财务流水分组
+
+直接同步新增四类会员流水(`user_point_list` 的 `stype=1/2/3/4`)。原九个业务数据集成功后,执行器按会员续跑财务分组,服务端保存原始分页、来源 ID、数量和校验和。完成回执必须同时通过九个业务数据集和财务分组的实际存储回读;缺少任何一个分组不得报成功。
+
+补导只补流水,不重放充值、扣费和工资,不覆盖账户余额及新系统体验资格。旧接口存在 `score`/`Score` 字段差异,均保留原始行。原始 `score_before` 原样展示;旧接口未提供可靠的调整后余额时显示“未记录”,不从当前余额倒推。已有来源记录内容不一致时记录冲突并保留新库。
+
+此次上线已更新独立 LaunchAgent 使用的 worker。许诺初中四账户人工核验完成,其中两类各 5 条,重复补导为 0,余额和工资未改变;这不代表当晚全量成功。原有账号身份冲突仍然是全量同步阻塞,必须继续按实际日志报告,不能以财务补导回执替代。

+ 52 - 0
docs/qa/member-credit-ledger-2026-10-14/README.md

@@ -0,0 +1,52 @@
+# 会员充值与课时流水验收
+
+## 行为与接口
+
+- `ops/users/credit-accounts` 返回四类账户、可用/预留、原业务单、`trialStatus`。
+- `ops/users/credit-preview` 计算成套变动及门店库存,返回 `version`。
+- `ops/users/adjust-balance` 接收 `expectedUpdatedAt=version` 和稳定幂等键;`direction=add/deduct/adjust`。
+- 普通 30 分钟按 1:0.5、60 分钟按 1:1 配套充值和原单扣回;旧单据未配套的部分按原单处理。
+- 独立调整需要 `evidence`、`adjustSign=add/deduct`、`adjustMode=paired/single`,不调整门店库存。
+- `ops/users/credit-ledger` 按完整历史分组、数据库分页;账户和业务筛选保留同一笔完整明细。导出逐页读取,同一版本和无重复业务单号才生成文件。
+- `ops/users/import-ledger` 仅管理角色可用。服务端从旧 WXAPP 四类账户分页读取,客户端不能提交导入内容。
+- 体验资格为 `pending/eligible/issued`,发放、独立补充、旧入口统一执行数据库限制;退款不重置。旧余额/流水/上课证据存在即视为已发放,缺少完整证据保持待核验。
+
+## 数据保护
+
+迁移不重算余额或工资。`CreditOperation` 保存成套业务及请求指纹,子流水保留 `CreditTransaction` 原有结构;账户和排课使用同一帐套锁。
+
+历史流水以帐套、账户类型、旧 `ExpHisID` 唯一定位。先写私有服务端原始页备份,再原子导入单个账户并保存回执;原始 `score_before` 缺失保留 NULL。旧 `ScoreType` 是原始交易类型,不用于推断账户。旧无时区时间按北京时间解析。不同账户或时间相近的历史条目不合并。
+
+真实余额、课时和工资不因补流水改变;同源内容发生变化时保留现有流水并写冲突。夜间任务新增四类财务分组,成功回执仍须同时验证原九个业务数据集;断点按服务端会员四账户回执恢复。账号身份冲突继续阻止业务同步,单个会员手工核验不能代表整夜成功。
+
+## 本地验收
+
+- SQL 实例验证成套原子记账、版本过期、原单部分退款、独立调整、预留保护、库存不足、体验一次性、旧入口拒绝重发、重复结课。
+- 40 次 30 分钟充值后 40/20;完成一次后 39/19.5;60 分钟及工资/扣费快照一致。
+- 历史导入 1,001 条跨页、空前余额、再次导入 0 条、冲突保留、余额不变。
+- 管理后台预览后修改内容必须重预览;网络失败保留输入和幂等键。
+
+## 发布边界
+
+数据库迁移:`node scripts/migrate-member-credit-ledger.mjs --apply`,凭据通过环境加载,必须设置私有 `XIAOSHU_CREDIT_BACKUP_DIR`。迁移拒绝仍有有效夜间任务租约的情况;先备份流水、账户、业务单、预留与原函数。
+
+配套发布运营网关、App 网关版本及 direct-v1 财务处理,然后原子更新 Mac LaunchAgent 使用的 worker 文件。学生/老师端和管理后台前端由用户发布。私有备份、真实会员反馈、会话及测试截屏不提交 Git。
+
+生产回读和隔离验收结果见同目录最终记录;任何未通过检查应明确保留,不能以构建通过替代真实验收。
+
+## 真实发布与回读(2026-10-14)
+
+- 原有任务已退出后暂停本机重试,等待服务端已有租约自然过期;备份后发布数据库迁移、两个运营网关、App 网关与直接同步网关。完成后恢复 LaunchAgent。没有抢占租约或删除有效锁。
+- 许诺初中补齐 10 条历史流水(60 分钟、陪练各 5 条,分别 +20、四次 -1)。分账户事务使首次兼容错误只阻止受影响账户;修正旧 `Score` 大小写差异后补齐剩余 5 条,再次全量读取新增 0。
+- 两账户当前余额仍各 16,充值补导前后四账户完全一致;复习工资数量、金额不变。
+- 旧陪练流水的 `score_before` 存在与相邻条目不一致的原始值,原样保留。旧接口没有可靠调整后余额,详情显示“未记录”,没有改写原始证据或倒推余额。
+- 修正真实创建会员时空邮箱写入导致 Parse 唯一邮箱冲突的问题;无邮箱时不写邮箱字段。
+- 浏览器连接真实后端验证:四账户、10 条历史、60 分钟与陪练各筛选 5 条、明细展开、北京时间排序、Excel 导出 5 行、390 像素窄屏无整页溢出。
+- 本地管理端 270 项、移动端 218 项、首次后端全集 195 项通过;新增财务 SQL 和直接同步专项 20 项通过,两个生产构建通过(已有样式预算/CommonJS 警告)。后续真实兼容修正再次通过财务 SQL 测试和云函数语法校验。
+- 隔离真实账号验证成套充值、40/20 → 39/19.5、退款与预留并发、充值与完课并发、独立调整、并发体验发放、退回后不能再次领取、流水余额一致和事务审计。测试账号及业务数据执行清理。
+
+私有证据位于 `~/.local/state/xiaoshu/cloud-releases/2026-10-14-member-credit-ledger/`,不提交 Git。前端没有代用户发布。夜间全量仍受既有身份冲突阻塞,未宣称完成当晚同步。
+- 最终补充真实验收:体验并发预约只成功一次,取消后恢复不消耗体验,结课仅扣 1 次体验、普通账户不变;重复结课不重复扣减;学生端可读取体验充值与消费流水。
+- 财务范围仅限已核实旧来源的会员,新系统创建的会员不会凭同名字段被送往旧系统核验。专项测试覆盖财务回执缺失、校验和被修改时拒绝全量完成;保留九个业务数据集原有核验。
+- 发布回读:三个网关、直接同步函数与本地代码一致,Mac 独立执行文件一致,LaunchAgent 已加载。最新本机报告仍为 `legacy_account_identity_conflict`,没有生成虚假的成功回执。
+- 统一流水接口实际数据库分页测试:1,001 条记录连续 11 页,业务 ID 无重复无遗漏;账户、日期筛选在分页前生效。

+ 30 - 0
projects/xiaoshu-admin/src/app/components/member-credit-account.component.html

@@ -0,0 +1,30 @@
+<section class="panel credit-panel"><header class="section-header"><h3>{{store?'门店课时账户':'充值与课时流水'}}</h3><button type="button" (click)="load()" [disabled]="saving()||loading()">刷新账户</button></header>
+@if(error()){<p role="alert" class="error">{{error()}}</p>}@if(message()){<p role="status">{{message()}}</p>}
+@if(loading()){<p role="status">正在刷新课时账户…</p>}
+@if(data();as value){
+<p>{{store?'总部入账后,可向所属学员划拨课时。':'充值来源:'+(value.storeName||'尚未关联门店,请先在会员资料中关联有效门店')}}</p>
+@if(!store){<p>体验资格:{{value.trialStatus==='issued'?'已发放过,不可再次领取':value.trialStatus==='eligible'?'可发放 1 次':'待核验旧系统体验记录'}} · 退回体验额度不恢复领取资格</p>@if(value.canPost&&value.trialStatus==='pending'){<button type="button" (click)="importHistory()" [disabled]="saving()">核验旧体验并补齐历史流水</button>}}
+<div class="accounts">@for(a of value.accounts;track a.account){<article><strong>{{a.label}}</strong><b>{{a.available | number:'1.0-2'}}</b><span>可用 · 余额 {{a.balance}} · 已预留 {{a.reserved}}</span>@if(a.balance<0){<small class="error">历史负余额,需核对</small>}@if(!store){<div class="account-actions">@if(value.canPost){<button type="button" (click)="chooseAccount(a.account)">充值</button>}<button type="button" (click)="chooseAccount(a.account,true)">明细</button></div>}</article>}</div>
+@if(!value.canPost){<p>当前账号没有充值或扣回权限,可查看账户和流水。</p>}
+@if(value.canPost){<form (ngSubmit)="store?submit():check()" (input)="resetPreview()" (change)="resetPreview()"><fieldset [disabled]="saving()||loading()">
+@if(!store){<label>业务类型<select name="direction" [(ngModel)]="direction" (ngModelChange)="sourceId='';resetPreview()"><option value="add">门店划拨充值</option><option value="deduct">原单扣回并退回门店</option><option value="adjust">历史差错调整</option></select></label>}
+@if(direction==='deduct'&&!store){<label class="wide">原充值单<select name="source" [(ngModel)]="sourceId" (ngModelChange)="selectSource($event)"><option value="">请选择原充值记录</option>@for(t of refundable();track t.objectId){<option [value]="t.objectId">{{t.createdAt|date:'yyyy-MM-dd HH:mm':'+0800'}} · {{label(t.account)}} · 可退 {{t.unreturned}}</option>}</select></label>}
+<label>课时类型<select name="account" [(ngModel)]="account" (ngModelChange)="resetPreview()" [disabled]="direction==='deduct'&&!store">@for(a of value.accounts;track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label>
+<label>数量<input name="amount" type="number" [min]="account==='UserPoint'?0.01:1" [step]="account==='UserPoint'?0.01:1" [(ngModel)]="amount" required /></label>
+<label>付款或记账方式<select name="method" [(ngModel)]="method" required><option value="">请选择</option><option>微信</option><option>支付宝</option><option>转账</option><option>线下收款</option><option>运营记账</option></select></label>
+@if(!store&&direction==='add'){<p class="wide">{{account==='UserPoint'?'单独补充陪练时长,用于独立复习服务或历史额度补充。':account==='UserExp'?'体验固定发放 1 次,结课不扣普通陪练余额。':'普通课充值自动配套陪练:30 分钟每次 0.5 小时,60 分钟每次 1 小时。'}}</p>}
+@if(direction==='adjust'){<label>增减方向<select name="adjustSign" [(ngModel)]="adjustSign"><option value="deduct">扣回差错额度</option><option value="add">补充差错额度</option></select></label><label>调整范围<select name="adjustMode" [(ngModel)]="adjustMode"><option value="paired">普通课时配套调整陪练</option><option value="single">仅纠正所选账户</option></select></label><label class="wide">核对依据<input name="evidence" [(ngModel)]="evidence" placeholder="填写原始单据、核对记录等证据" required /></label><p class="wide">独立调整只影响学员,不增加或扣减门店库存。</p>}
+<label class="wide">操作原因<input name="reason" [(ngModel)]="reason" minlength="2" maxlength="500" placeholder="填写本次充值、扣回或调整原因" required /></label>
+<button class="primary" type="submit" [disabled]="saving()||(!store&&!value.storeId&&direction==='add')">{{saving()?'处理中…':store?'确认总部入账':'预览账户变动'}}</button>
+@if(!store&&value.storeId){<a routerLink="/admin/stores">查看门店库存 / 总部入账</a>}
+</fieldset></form>}
+@if(preview();as p){<section class="preview-panel" aria-label="账户变动预览"><h3>确认本次变动 · 会员 {{p.studentId}}</h3><p>{{p.storeId?'原门店 ID '+p.storeId:'独立差错调整,仅影响学员'}}</p><div class="scroll"><table><thead><tr><th>账户</th><th>学员增减</th><th>学员余额</th><th>学员可用</th><th>门店余额</th><th>门店可用</th></tr></thead><tbody>@for(l of p.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta>0?'+':''}}{{l.delta}}</td><td>{{l.before}} → {{l.after}}</td><td>{{l.availableBefore}} → {{l.availableAfter}}</td><td>{{l.storeBefore??'—'}} → {{l.storeAfter??'—'}}</td><td>{{l.storeAvailableBefore??'—'}} → {{l.storeAvailableAfter??'—'}}</td></tr>}</tbody></table></div><button type="button" class="primary" (click)="submit()" [disabled]="saving()">{{saving()?'正在提交…':'确认记账'}}</button></section>}
+@if(store){<div class="scroll"><table><thead><tr><th>时间</th><th>业务</th><th>账户</th><th>数量</th><th>学员</th><th>原因</th></tr></thead><tbody>@for(t of value.transactions;track t.objectId){<tr><td>{{t.createdAt|date:'yyyy-MM-dd HH:mm':'+0800'}}</td><td>{{kindLabel(t.kind)}}</td><td>{{label(t.account)}}</td><td>{{t.amount}}</td><td>{{t.studentId||'—'}}</td><td>{{t.reason}}</td></tr>}</tbody></table></div>}
+}
+@if(!store){<section class="ledger-panel"><header class="section-header"><h3>全部课时流水</h3><button type="button" (click)="export()" [disabled]="exporting()">{{exporting()?'正在导出…':'导出 Excel'}}</button></header><p>时间均为北京时间;成套充值按一笔业务展示,可展开查看双方账户明细。</p>
+<div class="form-grid"><label>账户<select [(ngModel)]="filterAccount"><option value="">全部账户</option>@for(a of data()?.accounts||[];track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label><label>业务类型<select [(ngModel)]="filterKind"><option value="">全部业务</option>@for(k of ['transfer','refund','adjust','lesson','recharge','deduction'];track k){<option [value]="k">{{kindLabel(k)}}</option>}</select></label><app-operations-date-filter label="开始日期" [value]="dateFrom" (valueChange)="dateFrom=$event"/><app-operations-date-filter label="结束日期" [value]="dateTo" (valueChange)="dateTo=$event"/><div class="account-actions"><button type="button" (click)="query()">查询</button><button type="button" (click)="query(true)">重置</button></div></div>
+@if(ledgerError()){<p role="alert" class="error">{{ledgerError()}}</p>}@if(ledgerBusy()){<p role="status">正在加载流水…</p>}
+@for(row of ledger()?.items||[];track row.groupId){<details class="ledger-entry"><summary><time>{{row.at|date:'yyyy-MM-dd HH:mm':'+0800'}}</time><strong>{{kindLabel(row.lines[0].kind)}}</strong><span>@for(l of row.lines;track l.account){ {{label(l.account)}} {{l.delta>0?'+':''}}{{l.delta}} }</span><small>{{row.lines[0].reason||'未记录'}}</small></summary><div class="scroll"><table><thead><tr><th>账户</th><th>增减</th><th>调整前</th><th>调整后</th><th>操作人</th><th>来源与单据</th><th>门店</th></tr></thead><tbody>@for(l of row.lines;track l.account){<tr><td>{{label(l.account)}}</td><td>{{l.delta}}</td><td>{{l.before??'未记录'}}</td><td>{{l.after??'未记录'}}</td><td>{{l.operator||'未记录'}}</td><td>{{l.source==='legacy'?'旧系统':'新系统'}}<small>{{l.reference}}</small>@if(l.lessonId){<button type="button" (click)="lessonId=l.lessonId">查看上课记录</button>}</td><td>{{l.storeId||'未记录'}}@if(l.balances){<small>余额 {{l.balances.storeBefore??'—'}} → {{l.balances.storeAfter??'—'}}</small>}</td></tr>}</tbody></table></div><p>原因:{{row.lines[0].reason||'未记录'}}</p></details>}@empty{ @if(!ledgerBusy()&&!ledgerError()){<p>暂无符合条件的流水</p>} }
+<div class="section-header"><span>共 {{ledger()?.total||0}} 笔业务</span><label>每页<select [(ngModel)]="pageSize" (ngModelChange)="query()"><option [ngValue]="20">20 条</option><option [ngValue]="50">50 条</option><option [ngValue]="100">100 条</option></select></label><div class="account-actions"><button type="button" (click)="turn(-1)" [disabled]="page<=1||ledgerBusy()">上一页</button><span>{{page}}</span><button type="button" (click)="turn(1)" [disabled]="page*pageSize>=(ledger()?.total||0)||ledgerBusy()">下一页</button></div></div></section>}
+</section>
+@if(lessonId){<app-lesson-record-detail [lessonId]="lessonId" (closed)="lessonId=''"/>}

+ 34 - 31
projects/xiaoshu-admin/src/app/components/member-credit-account.component.ts

@@ -1,37 +1,40 @@
-import { DropdownDirective } from '../shared/dropdown.directive';
-import { CommonModule } from '@angular/common';
-import { Component, Input, Output, EventEmitter, OnChanges, inject, signal } from '@angular/core';
-import { FormsModule } from '@angular/forms';
-import { OperationsService } from '../operations.service';
-import { CreditAccounts, CreditTransaction } from '../member-enrollment.models';
-import { TeachingCreditAccount } from '../admin.models';
-@Component({selector:'app-member-credit-account',standalone:true,imports: [DropdownDirective, CommonModule,FormsModule],template:`
-<section class="panel credit-panel"><header class="section-header"><h3>{{store?'门店课时账户':'课时充值与划拨'}}</h3><button type="button" (click)="load()" [disabled]="saving()||loading()">刷新账户</button></header>
-@if(error()){<p role="alert" class="error">{{error()}}</p>}@if(message()){<p role="status">{{message()}}</p>}
-@if(loading()){<p role="status">正在刷新课时账户…</p>}
-@if(data();as value){
-<p>{{store?'总部入账后,可向所属学员划拨课时。':'充值来源:'+(value.storeName||'尚未关联门店')}}</p>
-<div class="accounts">@for(a of value.accounts;track a.account){<article><strong>{{a.label}}</strong><b>{{a.available | number:'1.0-2'}}</b><span>可用 · 余额 {{a.balance}} · 已预留 {{a.reserved}}</span></article>}</div>
-@if(value.canPost){<form (ngSubmit)="submit()"><fieldset [disabled]="saving()||loading()">
-@if(!store){<label>业务类型<select name="direction" [(ngModel)]="direction" (ngModelChange)="sourceId='' "><option value="add">门店划拨充值</option><option value="deduct">退回原门店</option></select></label>}
-@if(direction==='deduct'&&!store){<label>原划拨单<select name="source" [(ngModel)]="sourceId" (ngModelChange)="selectSource($event)"><option value="">请选择原划拨记录</option>@for(t of refundable();track t.objectId){<option [value]="t.objectId">{{t.createdAt|date:'MM-dd HH:mm'}} · {{label(t.account)}} · 尚未退回 {{t.unreturned}}</option>}</select></label>}
-<label>课时类型<select name="account" [(ngModel)]="account" [disabled]="direction==='deduct'&&!store">@for(a of value.accounts;track a.account){<option [value]="a.account">{{a.label}}</option>}</select></label>
-<label>数量<input name="amount" type="number" [min]="account==='UserPoint'?0.01:1" [step]="account==='UserPoint'?0.01:1" [(ngModel)]="amount" required /></label>
-<label>付款或记账方式<select name="method" [(ngModel)]="method" required><option value="">请选择</option><option>微信</option><option>支付宝</option><option>转账</option><option>线下收款</option><option>运营记账</option></select></label>
-<label class="wide">充值 / 退回原因<input name="reason" [(ngModel)]="reason" minlength="2" maxlength="500" placeholder="填写本次课时业务原因" required /></label>
-<button class="primary" type="submit" [disabled]="saving()||(!store&&!value.storeId&&direction==='add')">{{saving()?'处理中…':store?'确认总部入账':direction==='add'?'确认门店划拨':'确认退回原门店'}}</button></fieldset></form>}
-<div class="scroll"><table><thead><tr><th>时间</th><th>业务</th><th>项目</th><th>数量</th><th>门店</th><th>学员</th><th>原因</th></tr></thead><tbody>@for(t of value.transactions;track t.objectId){<tr><td>{{t.createdAt|date:'yyyy-MM-dd HH:mm'}}</td><td>{{t.kind==='fund'?'总部入账':t.kind==='transfer'?'门店划拨':'退回门店'}}</td><td>{{label(t.account)}}</td><td>{{t.amount}}</td><td>{{t.storeId}}</td><td>{{t.studentId||'—'}}</td><td>{{t.reason}}</td></tr>}@empty{<tr><td colspan="7">暂无新业务流水;历史记录保留在课时流水中。</td></tr>}</tbody></table></div>
-}</section>`,styleUrls:['./member-enrollment.scss']})
+import {DropdownDirective} from '../shared/dropdown.directive';
+import {CommonModule} from '@angular/common';
+import {Component,Input,Output,EventEmitter,OnChanges,ElementRef,inject,signal} from '@angular/core';
+import {FormsModule} from '@angular/forms';
+import {RouterLink} from '@angular/router';
+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 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 {
  @Input({required:true}) objectId='';@Input() store=false;@Input() refreshVersion=0;@Output() changed=new EventEmitter<void>();
- private operations=inject(OperationsService);loading=signal(false);private serial=0;data=signal<CreditAccounts|null>(null);error=signal('');message=signal('');saving=signal(false);
- account:TeachingCreditAccount='Purse';amount=0;direction='add';sourceId='';method='';reason='';private fingerprint='';private key='';
- ngOnChanges(){void this.load();}
+ 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='';
+ filterAccount='';filterKind='';dateFrom='';dateTo='';page=1;pageSize=20;
+ ngOnChanges(){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);}}
  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);}
- selectSource(id:string){const t=this.refundable().find(t=>t.objectId===id);if(t)this.account=t.account;}
- async submit(){if(this.saving()||this.loading())return;this.error.set('');this.message.set('');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.error.set('请填写有效数量、方式、操作原因;退回必须选择原划拨单');return;}
- const payload={account:this.account,amount:this.amount,direction:this.direction,sourceTransactionId:this.sourceId,payMethod:this.method},fingerprint=JSON.stringify([this.objectId,payload,this.reason]);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.key);this.amount=0;this.reason='';this.key='';this.fingerprint='';this.message.set('课时业务已完成,余额和流水已更新');await this.load();this.changed.emit();}catch(e){this.error.set(e instanceof Error?e.message:'课时业务失败');}finally{this.saving.set(false);}}
+ selectSource(id:string){const t=this.refundable().find(t=>t.objectId===id);if(t)this.account=t.account;this.resetPreview();}
+ resetPreview(){this.preview.set(null);}
+ 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);}}
+ 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);}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();}
+ turn(delta:number){this.page+=delta;void this.loadLedger();}
+ async importHistory(){if(this.saving())return;this.saving.set(true);this.error.set('');try{const r=await this.operations.request<{created:number}>('ops/users/import-ledger',{objectId:this.objectId,runId:crypto.randomUUID()});this.message.set('旧系统核验完成,补齐 '+r.created+' 条流水;现有余额未改变');await this.load();await this.loadLedger();}catch(e){this.error.set(e instanceof Error?e.message:'历史核验失败,请稍后重试');}finally{this.saving.set(false);}}
+ async export(){if(this.exporting())return;this.exporting.set(true);this.ledgerError.set('');const params=this.params();try{const first=await this.operations.request<LedgerPage>('ops/users/credit-ledger',{...params,page:1,pageSize:100}),rows=[...first.items];for(let p=2;rows.length<first.total;p++){const next=await this.operations.request<LedgerPage>('ops/users/credit-ledger',{...params,page:p,pageSize:100});if(!next.items.length||next.total!==first.total||next.version!==first.version)throw Error('导出期间流水变化,请重试');rows.push(...next.items);}if(new Set(rows.map(r=>r.groupId)).size!==rows.length)throw Error('导出期间流水排序变化,请重试');const xlsx=await import('xlsx');const sheet=xlsx.utils.json_to_sheet(rows.flatMap(r=>r.lines.map(l=>({'业务单号':r.groupId,'北京时间':new Date(r.at).toLocaleString('sv-SE',{timeZone:'Asia/Shanghai'}),'账户':this.label(l.account),'业务':this.kindLabel(l.kind),'增减':l.delta,'调整前':l.before??'未记录','调整后':l.after??'未记录','操作人':l.operator||'未记录','原因':l.reason,'来源':l.source==='legacy'?'旧系统':'新系统','关联单据':l.reference,'门店':l.storeId||'','门店调整前':l.balances?.storeBefore??'未记录','门店调整后':l.balances?.storeAfter??'未记录'}))));const book=xlsx.utils.book_new();xlsx.utils.book_append_sheet(book,sheet,'课时流水');xlsx.writeFile(book,'会员课时流水.xlsx');}catch(e){this.ledgerError.set(e instanceof Error?e.message:'导出失败');}finally{this.exporting.set(false);}}
 }

+ 10 - 3
projects/xiaoshu-admin/src/app/components/member-enrollment.component.spec.ts

@@ -27,12 +27,12 @@ describe('会员开课与课时操作', () => {
  });
  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('网络失败'));
-  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.submit();expect(operations.mutate.calls.mostRecent().args[5]).not.toBe(first);
+  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);
  });
  it('退回必须关联原划拨单,自动使用原课时账户', async () => {
   const fixture=TestBed.createComponent(MemberCreditAccountComponent),c=fixture.componentInstance;c.objectId='student';c.direction='deduct';c.amount=1;c.method='运营记账';c.reason='退回余量';
-  await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('原划拨单');
+  await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('原单');
  });
  it('分类加载失败后不能使用旧分类内容提交开课',async()=>{
   const c=TestBed.createComponent(MemberEnrollmentComponent).componentInstance;c.studentId=20;await c.load();c.toggle(100);operations.request.and.rejectWith(new Error('加载失败'));c.changeCategory('word_self_study');await c.load();c.selected.set([100]);await c.submit();expect(c.options()).toBeNull();expect(operations.mutate).not.toHaveBeenCalled();
@@ -43,3 +43,10 @@ describe('会员开课与课时操作', () => {
  });
 
 });
+
+describe('会员充值预览保护',()=>{
+ let operations:jasmine.SpyObj<OperationsService>;
+ beforeEach(()=>{operations=jasmine.createSpyObj('OperationsService',['request','mutate']);TestBed.configureTestingModule({imports:[MemberCreditAccountComponent],providers:[{provide:OperationsService,useValue:operations}]});});
+ it('必须先预览,预览后修改数量不能提交旧确认',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';await c.submit();expect(operations.mutate).not.toHaveBeenCalled();operations.request.and.resolveTo({version:'server-v1',lines:[{account:'Purse',delta:40},{account:'UserPoint',delta:20}]});await c.check();c.amount=20;await c.submit();expect(operations.mutate).not.toHaveBeenCalled();expect(c.error()).toContain('预览');});
+ it('服务端拒绝预览时保留输入并不执行记账',async()=>{const c=TestBed.createComponent(MemberCreditAccountComponent).componentInstance;c.objectId='s';c.amount=40;c.method='运营记账';c.reason='购买课时';operations.request.and.rejectWith(new Error('门店库存不足'));await c.check();expect(c.amount).toBe(40);expect(c.reason).toBe('购买课时');expect(c.preview()).toBeNull();expect(c.error()).toContain('库存不足');expect(operations.mutate).not.toHaveBeenCalled();});
+});

+ 3 - 0
projects/xiaoshu-admin/src/app/components/member-enrollment.scss

@@ -32,3 +32,6 @@ input[type=checkbox]:disabled { cursor:not-allowed; }
 .rules span { padding:8px 10px; background:#edf5f0; border:1px solid #dce9e1; border-radius:8px; }
 @media(max-width:900px) { .accounts{grid-template-columns:repeat(2,minmax(0,1fr))}fieldset{grid-template-columns:repeat(2,minmax(0,1fr))} }
 @media(max-width:650px) { .panel{padding:16px}.form-grid,fieldset{grid-template-columns:1fr}.wide{grid-column:auto}.accounts{gap:8px}article{padding:12px}.form-actions button{width:100%} }
+.account-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}.account-actions button{padding:7px 12px}.preview-panel{background:#edf7f2;border:1px solid #bddbcf;border-radius:12px;padding:18px;margin-bottom:24px}.ledger-panel{border-top:1px solid #dfe7e4;margin-top:24px;padding-top:24px}.ledger-entry{border:1px solid #dfe7e4;border-radius:10px;margin:12px 0;padding:14px}.ledger-entry summary{cursor:pointer;display:list-item;color:#294c3f}.ledger-entry summary>*{margin-right:16px}.ledger-entry small{overflow-wrap:anywhere}.ledger-entry .scroll{max-height:none}.ledger-entry time{font-variant-numeric:tabular-nums}.ledger-entry p{margin-bottom:0}
+
+.credit-panel a{color:#1b735d;font-weight:600;text-underline-offset:3px}.credit-panel form,.ledger-panel{scroll-margin-top:90px}

+ 2 - 2
projects/xiaoshu-admin/src/app/member-enrollment.models.ts

@@ -1,6 +1,6 @@
 import { TeachingCourseCategory, TeachingRule, TeachingCreditAccount, TeachingStageKey } from './admin.models';
 export interface CreditAccount { account: TeachingCreditAccount; label: string; balance: number; reserved: number; available: number; }
-export interface CreditTransaction { objectId: string; kind: 'fund'|'transfer'|'refund'; account: TeachingCreditAccount; amount: number; storeId: number; studentId: number; reason: string; payMethod: string; createdAt: string; unreturned: number; balances: {storeBefore:number;storeAfter:number;studentBefore:number;studentAfter:number}; }
-export interface CreditAccounts { accounts: CreditAccount[]; transactions: CreditTransaction[]; storeId: number; storeName: string; canPost: boolean; }
+export interface CreditTransaction { objectId: string; kind: 'fund'|'transfer'|'refund'|'adjust'; account: TeachingCreditAccount; amount: number; storeId: number; studentId: number; reason: string; payMethod: string; createdAt: string; unreturned: number; balances: {storeBefore:number;storeAfter:number;studentBefore:number;studentAfter:number}; }
+export interface CreditAccounts { trialStatus?: 'pending'|'eligible'|'issued'; accounts: CreditAccount[]; transactions: CreditTransaction[]; storeId: number; storeName: string; canPost: boolean; }
 export interface EnrollmentContent { courseId:number;courseName:string;stageKey:TeachingStageKey|'';stageName:string;wordCount:number;active:boolean;alreadyOpened:boolean;eligible:boolean;reason:string; }
 export interface EnrollmentOptions { categories: TeachingCourseCategory[]; categoryKey:string;eligible:boolean;reason:string;rules:Array<TeachingRule & {affordable:boolean}>;items:EnrollmentContent[]; }

+ 4 - 4
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.html

@@ -8,7 +8,7 @@
       <p>会员 ID {{ detail.userId || '—' }}@if (detail.username && detail.username !== detail.displayName) { · 账号 {{ detail.username }} }</p>
       <div class="profile-facts"><span>{{ detail.learningStatusLabel }}</span><span>{{ detail.parentDisplayName || '未绑定门店' }}</span><span>{{ detail.registeredAt | date:'yyyy-MM-dd' }} 注册</span></div>
     </div>
-    <nav class="quick-actions" aria-label="会员常用操作"><button class="profile-refresh" type="button" (click)="refreshBusiness()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [class.spin]="loading()" [size]="17" />刷新</button><button type="button" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />{{ detail.permissions.canManage ? '充值课时' : '查看课时' }}</button><button type="button" (click)="selectTab('courses')"><lucide-icon [img]="icons.BookOpen" [size]="17" />{{ detail.permissions.canEdit ? '按分类开课' : '查看课程' }}</button><a class="action-link primary" routerLink="/admin/schedule" [queryParams]="{studentId:detail.userId}"><lucide-icon [img]="icons.CalendarDays" [size]="17" />为该学员排课</a></nav>
+    <nav class="quick-actions" aria-label="会员常用操作"><button class="profile-refresh" type="button" (click)="refreshBusiness()" [disabled]="loading()"><lucide-icon [img]="icons.RefreshCw" [class.spin]="loading()" [size]="17" />刷新</button><button type="button" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />{{ detail.permissions.canManage ? '充值/扣回' : '查看课时' }}</button><button type="button" (click)="selectTab('courses')"><lucide-icon [img]="icons.BookOpen" [size]="17" />{{ detail.permissions.canEdit ? '按分类开课' : '查看课程' }}</button><a class="action-link primary" routerLink="/admin/schedule" [queryParams]="{studentId:detail.userId}"><lucide-icon [img]="icons.CalendarDays" [size]="17" />为该学员排课</a></nav>
   </section>
   @if (detail.writeReady === false) { <p class="notice" role="status">{{ detail.readOnlyReason }}</p> }
   @if (message()) { <p class="notice" role="status">{{ message() }}</p> } @if (error()) { <p class="notice error" role="alert">{{ error() }}</p> }
@@ -19,7 +19,7 @@
     <button type="button" [class.active]="tab()==='appointments'" [attr.aria-pressed]="tab()==='appointments'" (click)="selectTab('appointments')"><lucide-icon [img]="icons.CalendarDays" [size]="17" />预约</button>
     <button type="button" [class.active]="tab()==='lessons'" [attr.aria-pressed]="tab()==='lessons'" (click)="selectTab('lessons')"><lucide-icon [img]="icons.GraduationCap" [size]="17" />上课记录</button>
     <button type="button" [class.active]="tab()==='reports'" [attr.aria-pressed]="tab()==='reports'" (click)="selectTab('reports')"><lucide-icon [img]="icons.FileChartColumn" [size]="17" />学习报表</button>
-    <button type="button" [class.active]="tab()==='balances'" [attr.aria-pressed]="tab()==='balances'" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />课时流水</button>
+    <button type="button" [class.active]="tab()==='balances'" [attr.aria-pressed]="tab()==='balances'" (click)="selectTab('balances')"><lucide-icon [img]="icons.WalletCards" [size]="17" />充值与课时流水</button>
     <button type="button" [class.active]="tab()==='audit'" [attr.aria-pressed]="tab()==='audit'" (click)="selectTab('audit')"><lucide-icon [img]="icons.FileClock" [size]="17" />操作审计</button>
   </nav>
 
@@ -40,9 +40,9 @@
     @case ('appointments') { <section class="panel"><header><div><h2>预约记录</h2><span>查看课程、陪练、预约时间和业务状态</span></div></header><div class="record-table"><table><thead><tr><th>课程</th><th>陪练老师</th><th>预约时间</th><th>课型</th><th>状态</th></tr></thead><tbody>@for (row of related().appointments; track row['targetId']) { <tr><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] || '待分配' }}</td><td>{{ $any(row['appointmentAt'] || row['startsAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['classTypeLabel'] }}</td><td>{{ row['statusLabel'] }}</td></tr> } @empty { <tr><td colspan="5">暂无预约记录</td></tr> }</tbody></table></div></section> }
     @case ('lessons') { <app-lesson-records [studentId]="detail.userId" [embedded]="true" [refreshVersion]="refreshVersion()" /> }
     @case ('reports') { <section class="panel"><header><div><h2>单词学习报表</h2><span>查看该会员每次学习的掌握情况和复习计划</span></div><a class="primary action-link" routerLink="/admin/learning-reports" [queryParams]="{ studentObjectId: detail.objectId }">进入报表中心</a></header><div class="date-filters"><app-operations-date-filter label="开始日期" [value]="reportFrom()" (valueChange)="reportFrom.set($event)" /><app-operations-date-filter label="结束日期" [value]="reportTo()" (valueChange)="reportTo.set($event)" /><button type="button" class="primary" (click)="filterReports()" [disabled]="sectionLoading()">查询</button><button type="button" (click)="filterReports(true)" [disabled]="sectionLoading()">重置</button></div><div class="record-table"><table><thead><tr><th>学习日期</th><th>课程</th><th>陪练老师</th><th>学习结果</th><th>掌握率</th><th>状态</th><th>操作</th></tr></thead><tbody>@for (row of related().reports; track row['targetId']) { <tr><td>{{ row['studyDate'] || '日期缺失' }}</td><td>{{ row['courseName'] }}</td><td>{{ row['coachName'] }}</td><td>{{ row['learnedCount'] }} 个 · 掌握 {{ row['masteredCount'] }} · 待复习 {{ row['reviewDueCount'] }}</td><td>{{ row['masteryRate'] }}%</td><td>{{ row['statusLabel'] }}</td><td><a class="action-link compact" [routerLink]="['/admin/learning-reports']" [queryParams]="{ studentObjectId: detail.objectId, report: row['targetId'] }">查看报表</a></td></tr> } @empty { <tr><td colspan="7">暂无学习报表</td></tr> }</tbody></table></div></section> }
-    @case ('balances') { <section class="panel"><header><div><h2>课时与流水</h2><span>门店划拨与退回原门店均记录操作原因</span></div></header><app-member-credit-account [objectId]="detail.objectId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /><h3 class="record-heading">历史课时明细</h3><div class="record-table"><table><thead><tr><th>项目</th><th>变化</th><th>调整前</th><th>余额</th><th>时间</th><th>说明</th></tr></thead><tbody>@for (row of related().balances; track row['targetId']) { <tr><td>{{ row['moneyTypeLabel'] }}</td><td>{{ row['score'] }}</td><td>{{ row['scoreBefore'] }}</td><td>{{ row['balanceAfter'] }}</td><td>{{ $any(row['happenedAt']) | date:'yyyy-MM-dd HH:mm' }}</td><td>{{ row['detail'] }}</td></tr> } @empty { <tr><td colspan="6">暂无课时流水</td></tr> }</tbody></table></div></section> }
+    @case ('balances') { <app-member-credit-account [objectId]="detail.objectId" [refreshVersion]="refreshVersion()" (changed)="refreshBusiness()" /> }
     @case ('audit') { <div class="audit-grid"><section class="panel"><header><div><h2>运营跟进备注</h2><span>记录后续联系和服务事项</span></div></header>@if (detail.permissions.canEdit) { <label><span>新增备注</span><textarea [ngModel]="noteContent()" (ngModelChange)="noteContent.set($event)" placeholder="填写需要跟进的事项或沟通结果"></textarea></label><button class="primary" type="button" (click)="saveNote()" [disabled]="saving()"><lucide-icon [img]="icons.MessageSquarePlus" [size]="16" />保存备注</button> }<div class="timeline">@for (note of notes(); track note['objectId']) { <article><strong>{{ note['operatorName'] || '运营人员' }}</strong><time>{{ $any(note['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ note['content'] }}</p></article> } @empty { <p class="muted">暂无跟进备注</p> }</div></section><section class="panel"><header><div><h2>操作审计</h2><span>账号、资料、关系、课程和课时修改记录</span></div></header><div class="timeline audit">@for (item of related().audit; track item['objectId']) { <article><strong>{{ item['operatorName'] || '系统' }}</strong><time>{{ $any(item['createdAt']) | date:'yyyy-MM-dd HH:mm' }}</time><p>{{ item['summary'] || '会员业务操作' }}</p></article> } @empty { <p class="muted">暂无操作审计记录</p> }</div></section></div> }
   }
 
-  @if(tab()!=='overview'&&tab()!=='audit'&&tab()!=='lessons') {<nav class="pagination" aria-label="会员业务记录分页"><span>共 {{ recordTotal() }} 条 · 每页 {{ pageSize }} 条</span><button type="button" (click)="changePage(recordPage()-1)" [disabled]="sectionLoading()||recordPage()<=1">上一页</button><strong>{{ recordPage() }} / {{ pageCount() }}</strong><button type="button" (click)="changePage(recordPage()+1)" [disabled]="sectionLoading()||recordPage()>=pageCount()">下一页</button></nav>}
+  @if(tab()!=='overview'&&tab()!=='audit'&&tab()!=='lessons'&&tab()!=='balances') {<nav class="pagination" aria-label="会员业务记录分页"><span>共 {{ recordTotal() }} 条 · 每页 {{ pageSize }} 条</span><button type="button" (click)="changePage(recordPage()-1)" [disabled]="sectionLoading()||recordPage()<=1">上一页</button><strong>{{ recordPage() }} / {{ pageCount() }}</strong><button type="button" (click)="changePage(recordPage()+1)" [disabled]="sectionLoading()||recordPage()>=pageCount()">下一页</button></nav>}
 } }

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.spec.ts

@@ -23,7 +23,7 @@ describe('会员详情业务交互', () => {
   });
   it('刷新同时更新详情、当前记录和嵌入账户组件',async()=>{
     const c=TestBed.createComponent(OperationsUserDetailComponent).componentInstance;await c.load();await c.selectTab('balances');operations.request.calls.reset();await c.refreshBusiness();
-    expect(c.refreshVersion()).toBe(1);expect(operations.request).toHaveBeenCalledWith('ops/money-logs/list',{page:1,pageSize:20,studentId:1845});
+    expect(c.refreshVersion()).toBe(1);expect(operations.request).not.toHaveBeenCalledWith('ops/money-logs/list',{page:1,pageSize:20,studentId:1845});
   });
   it('快速切换页签时旧响应不能结束新页签加载或覆盖记录',async()=>{
     const c=TestBed.createComponent(OperationsUserDetailComponent).componentInstance;await c.load();let first!:(v:unknown)=>void,second!:(v:unknown)=>void;

+ 1 - 1
projects/xiaoshu-admin/src/app/pages/operations-user-detail.component.ts

@@ -43,7 +43,7 @@ export class OperationsUserDetailComponent implements OnInit {
   async filterReports(clear=false): Promise<void> { if(clear){this.reportFrom.set('');this.reportTo.set('');} if(this.reportFrom()&&this.reportTo()&&this.reportFrom()>this.reportTo()){this.error.set('开始日期不能晚于结束日期');return;}this.recordPage.set(1);await this.loadSection('reports'); }
   private kindFor(tab: MemberTab): OperationsListKind | null { return ({ courses: 'course-bindings', appointments: 'appointments', lessons: 'lessons', balances: 'money-logs' } as Partial<Record<MemberTab, OperationsListKind>>)[tab] || null; }
   async loadSection(tab = this.tab()): Promise<void> {
-    if(tab==='lessons')return;
+    if(tab==='lessons'||tab==='balances')return;
     const user = this.user(); if (!user || tab === 'overview') return;
     const serial=++this.sectionSerial; this.sectionLoading.set(true); this.error.set('');
     try {

+ 2 - 2
projects/xiaoshu-admin/src/app/pages/operations-users.component.html

@@ -30,10 +30,10 @@
   @if (loading() && !page()) { <div class="state"><lucide-icon class="spin" [img]="icons.LoaderCircle" [size]="26" />正在加载会员…</div> }
   @else {
     <div class="desktop-table"><table><thead><tr><th>会员</th><th>手机号</th><th>学习状态</th><th>注册时间</th><th>所属门店/代理</th><th>剩余课时</th><th>最近上课</th><th>下次预约</th><th>风险</th><th>账号</th><th><span class="sr-only">操作</span></th></tr></thead><tbody>
-      @for (row of page()?.items || []; track row.objectId) { <tr><td><div class="user-cell"><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.nickname || row.username }}</span></div></td><td>{{ row.mobile || row.mobileMasked || '—' }}</td><td><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span><small class="status-reason">{{ row.learningStatusReason }}</small></td><td><time>{{ row.registeredAt | date:'yyyy-MM-dd HH:mm' }}</time></td><td>{{ row.parentDisplayName || '未绑定' }}</td><td><strong>{{ row.periods30 | number }}</strong> × 30分<br/><strong>{{ row.periods60 | number }}</strong> × 60分<br/><small>{{ row.trialPeriods | number }} × 体验</small></td><td>{{ row.lastLessonAt ? (row.lastLessonAt | date:'MM-dd HH:mm') : '暂无' }}</td><td>{{ row.nextAppointmentAt ? (row.nextAppointmentAt | date:'MM-dd HH:mm') : '待排课' }}</td><td><div class="risk-list">@for (item of row.riskFlags || []; track item) { <span>{{ item }}</span> } @empty { <small>正常</small> }</div></td><td><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></td><td><a [routerLink]="['/admin/members', row.objectId]" aria-label="查看会员详情"><lucide-icon [img]="icons.Eye" [size]="17" /></a></td></tr> }
+      @for (row of page()?.items || []; track row.objectId) { <tr><td><div class="user-cell"><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.nickname || row.username }}</span></div></td><td>{{ row.mobile || row.mobileMasked || '—' }}</td><td><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span><small class="status-reason">{{ row.learningStatusReason }}</small></td><td><time>{{ row.registeredAt | date:'yyyy-MM-dd HH:mm' }}</time></td><td>{{ row.parentDisplayName || '未绑定' }}</td><td><strong>{{ row.periods30 | number }}</strong> × 30分<br/><strong>{{ row.periods60 | number }}</strong> × 60分<br/><small>{{ row.trialPeriods | number }} × 体验</small></td><td>{{ row.lastLessonAt ? (row.lastLessonAt | date:'MM-dd HH:mm') : '暂无' }}</td><td>{{ row.nextAppointmentAt ? (row.nextAppointmentAt | date:'MM-dd HH:mm') : '待排课' }}</td><td><div class="risk-list">@for (item of row.riskFlags || []; track item) { <span>{{ item }}</span> } @empty { <small>正常</small> }</div></td><td><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></td><td><a [routerLink]="['/admin/members', row.objectId]" [queryParams]="{tab:'balances'}">充值/扣回</a> <a [routerLink]="['/admin/members', row.objectId]" aria-label="查看会员详情"><lucide-icon [img]="icons.Eye" [size]="17" /></a></td></tr> }
       @empty { <tr><td colspan="11"><div class="state">没有符合条件的会员</div></td></tr> }
     </tbody></table></div>
-    <div class="mobile-cards">@for (row of page()?.items || []; track row.objectId) { <article><header><div><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.mobile || row.mobileMasked || '—' }}</span></div><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></header><div class="card-status"><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span>@for (item of row.riskFlags || []; track item) { <em>{{ item }}</em> }</div><dl><div><dt>注册时间</dt><dd>{{ row.registeredAt | date:'yyyy-MM-dd HH:mm' }}</dd></div><div><dt>所属代理</dt><dd>{{ row.parentDisplayName || '未绑定' }}</dd></div><div><dt>剩余课时</dt><dd>{{ row.periods30 }}×30分 · {{ row.periods60 }}×60分</dd></div><div><dt>下次预约</dt><dd>{{ row.nextAppointmentAt ? (row.nextAppointmentAt | date:'MM-dd HH:mm') : '待排课' }}</dd></div></dl><a [routerLink]="['/admin/members', row.objectId]">查看会员详情<lucide-icon [img]="icons.ChevronRight" [size]="17" /></a></article> } @empty { <div class="state">没有符合条件的会员</div> }</div>
+    <div class="mobile-cards">@for (row of page()?.items || []; track row.objectId) { <article><header><div><strong>{{ row.displayName }}</strong><span>ID {{ row.userId || '—' }} · {{ row.mobile || row.mobileMasked || '—' }}</span></div><span class="status" [class.disabled]="row.status === 'disabled'">{{ row.statusLabel }}</span></header><div class="card-status"><span class="learning-status" [attr.data-status]="row.learningStatus">{{ row.learningStatusLabel }}</span>@for (item of row.riskFlags || []; track item) { <em>{{ item }}</em> }</div><dl><div><dt>注册时间</dt><dd>{{ row.registeredAt | date:'yyyy-MM-dd HH:mm' }}</dd></div><div><dt>所属代理</dt><dd>{{ row.parentDisplayName || '未绑定' }}</dd></div><div><dt>剩余课时</dt><dd>{{ row.periods30 }}×30分 · {{ row.periods60 }}×60分</dd></div><div><dt>下次预约</dt><dd>{{ row.nextAppointmentAt ? (row.nextAppointmentAt | date:'MM-dd HH:mm') : '待排课' }}</dd></div></dl><a [routerLink]="['/admin/members', row.objectId]" [queryParams]="{tab:'balances'}">充值/扣回</a> <a [routerLink]="['/admin/members', row.objectId]">查看会员详情<lucide-icon [img]="icons.ChevronRight" [size]="17" /></a></article> } @empty { <div class="state">没有符合条件的会员</div> }</div>
     <footer class="pagination"><span>第 {{ page()?.page || 1 }} / {{ pageCount() }} 页 · 共 {{ page()?.total || 0 }} 人</span><div><button type="button" aria-label="上一页" [disabled]="(page()?.page || 1) <= 1" (click)="load((page()?.page || 1) - 1)"><lucide-icon [img]="icons.ChevronLeft" [size]="18" /></button><button type="button" aria-label="下一页" [disabled]="(page()?.page || 1) >= pageCount()" (click)="load((page()?.page || 1) + 1)"><lucide-icon [img]="icons.ChevronRight" [size]="18" /></button></div></footer>
   }
 </section>

+ 20 - 0
scripts/check-member-credit-browser.mjs

@@ -0,0 +1,20 @@
+import {chromium} from 'playwright';
+import {mkdirSync,writeFileSync,readFileSync} from 'node:fs';
+import * as XLSX from 'xlsx';
+import assert from 'node:assert/strict';
+export async function checkMemberCreditBrowser({auth,output,memberObjectId}){
+ mkdirSync(output,{recursive:true});const browser=await chromium.launch({headless:true,executablePath:'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'}),checks=[],errors=[];
+ try{
+ const context=await browser.newContext({viewport:{width:1440,height:1050}});await context.addInitScript(auth=>{localStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));sessionStorage.setItem('xiaoshu_admin_session_v1',JSON.stringify(auth));},auth);
+ const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));await page.goto('http://127.0.0.1:4300/admin/members/'+memberObjectId+'?tab=balances');
+ const panel=page.locator('app-member-credit-account');await panel.locator('.ledger-entry').first().waitFor({timeout:60000});assert.equal(await panel.locator('.accounts article').count(),4);assert.equal(await panel.locator('.ledger-entry').count(),10);checks.push('真实会员四类账户与 10 条历史流水');
+ await panel.locator('.ledger-entry').first().locator('summary').click();await panel.getByRole('columnheader',{name:'调整前',exact:true}).waitFor();checks.push('展开原始流水,前后余额、操作人、来源单据可见');
+ const dates=await panel.locator('.ledger-entry time').allTextContents();assert.deepEqual(dates,[...dates].sort().reverse());
+ const ledger=panel.locator('.ledger-panel'),account=ledger.getByLabel('账户',{exact:true});await account.click();await page.getByRole('option',{name:'60分钟课时',exact:true}).click();await ledger.getByRole('button',{name:'查询',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.ledger-entry').length===5);checks.push('60 分钟账户筛选 5 条,北京时间倒序');
+ const downloadPromise=page.waitForEvent('download');await ledger.getByRole('button',{name:'导出 Excel',exact:true}).click();const download=await downloadPromise;await download.saveAs(output+'/credits.xlsx');const book=XLSX.read(readFileSync(output+'/credits.xlsx'));assert.equal(XLSX.utils.sheet_to_json(book.Sheets[book.SheetNames[0]]).length,5);checks.push('真实筛选导出 5 行,与明细一致');
+ await account.click();await page.getByRole('option',{name:'陪练时长',exact:true}).click();await ledger.getByRole('button',{name:'查询',exact:true}).click();await page.waitForFunction(()=>document.querySelectorAll('.ledger-entry').length===5&&document.querySelector('.ledger-entry')?.textContent.includes('陪练'));checks.push('陪练账户筛选 5 条');
+ await ledger.getByRole('button',{name:'开始日期',exact:true}).click();await page.getByRole('dialog',{name:'选择开始日期'}).waitFor();await page.keyboard.press('Escape');checks.push('统一日期选择器展开与关闭');
+ await page.screenshot({path:output+'/member-credit-desktop.png',fullPage:true});await page.setViewportSize({width:390,height:844});await page.screenshot({path:output+'/member-credit-narrow.png',fullPage:true});assert.ok(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth+1));checks.push('390 像素窄屏无整页横向溢出');
+ assert.deepEqual(errors,[]);writeFileSync(output+'/browser-result.json',JSON.stringify({passed:true,checks},null,2));return{passed:true,checks};
+ }finally{await browser.close();}
+}

+ 10 - 2
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-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==='status')return {version:'2026-10-14-finance-ledger-v3',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"));
@@ -36,6 +36,8 @@ async function directControl(p,co,u) {
  });
  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==='finance-members')return Psql.query('SELECT u."legacyUserId" id FROM "_User" u WHERE u.company=$1 AND u."identityType"=\'member\' AND COALESCE(u."isDeleted",false)=false AND (u."sourceKey" LIKE \'legacy%\' OR u."sourceKey" LIKE \'[["UserID",%\') AND COALESCE(u."legacyUserData"->>\'SyncExcluded\',\'false\')<>\'true\' AND (SELECT count(*) FROM "LegacyFinanceReceipt" f WHERE f.company=u.company AND f."studentId"=u."legacyUserId" AND f."runId"=$2)<4 ORDER BY u."legacyUserId"',[co,p.runId]);
+ if(p.action==='finance')return importLegacyFinance(co,Number(p.studentId),p.runId,args=>directOld('user_point_list',args));
  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});}
@@ -84,7 +86,13 @@ async function directControl(p,co,u) {
   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};
+  const members=await t.any('SELECT "legacyUserId" id FROM "_User" WHERE company=$1 AND "identityType"=\'member\' AND COALESCE("isDeleted",false)=false AND ("sourceKey" LIKE \'legacy%\' OR "sourceKey" LIKE \'[["UserID",%\') AND COALESCE("legacyUserData"->>\'SyncExcluded\',\'false\')<>\'true\'',[co]);
+  const finance=await t.any('SELECT "studentId",account,ids,checksum FROM "LegacyFinanceReceipt" WHERE company=$1 AND "runId"=$2',[co,p.runId]);
+  if(members.some(m=>finance.filter(f=>Number(f.studentId)===Number(m.id)).length!==4))throw Error('finance_reconciliation_incomplete');
+  for(const [account,table] of [['Purse','UserExpDomP'],['SilverCoin','UserSIcon'],['UserExp','UserExpHis'],['UserPoint','UserUserPoint']]){
+   const mismatch=await t.one('SELECT count(*)::int n FROM "LegacyFinanceReceipt" r WHERE r.company=$1 AND r."runId"=$2 AND r.account=$3 AND (jsonb_array_length(r.ids)<>(SELECT count(*) FROM "'+table+'" l WHERE l.company=r.company AND l."userId"=r."studentId" AND l."legacyFinanceId" IN (SELECT jsonb_array_elements_text(r.ids))) OR r.checksum<>(SELECT md5(COALESCE(jsonb_agg(jsonb_build_object(\'id\',l."legacyFinanceId",\'hash\',l."legacyFinanceHash") ORDER BY l."legacyFinanceId"::numeric),\'[]\'::jsonb)::text) FROM "'+table+'" l WHERE l.company=r.company AND l."userId"=r."studentId" AND l."legacyFinanceId" IN (SELECT jsonb_array_elements_text(r.ids))))',[co,p.runId,account]);if(mismatch.n)throw Error('finance_postimage_changed');
+  }
+  const summary={...p.summary,finance:{members:members.length,streams:finance.length,rows:finance.reduce((n,f)=>n+f.ids.length,0),checksum:(await t.one('SELECT md5($1::jsonb::text) hash',[JSON.stringify(finance.sort((a,b)=>(a.studentId+":"+a.account).localeCompare(b.studentId+":"+b.account)))])).hash,reconciled:true},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;

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

@@ -2,7 +2,7 @@ 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)});
+ if(p.action==='finance-members'||p.action==='finance'||p.action==='status'||p.action==='claim'||p.action==='source'||p.action==='finish'||p.action==='conflicts'||p.action==='verify'||p.action==='supplement'||p.action==='evidence'||p.action==='reset-dataset')return response.json({success:true,data:await directControl(p,co,u)});
  await directLease(Psql,p,co);
  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));

+ 50 - 0
scripts/cloud/legacy-finance.js

@@ -0,0 +1,50 @@
+// Server fetches authoritative WXAPP rows; clients cannot submit projected ledger data.
+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]);
+ const u=users[0];if(users.length!==1||u.isDeleted||u.identityType!=='member')throw Error('finance_member_identity_unverified');
+ const source=String(u.sourceKey||''),legacy=u.legacyUserData||{};
+ if(source.startsWith('reading-')||source.startsWith('reading:')||legacy.SyncExcluded===true||legacy.ReadingGeneratedLegacyId||(!source.startsWith('legacy')&&!source.startsWith('[["UserID",')))throw Error('finance_member_identity_unverified');
+ const attempt=runId+':'+Date.now();
+ const specs=[['Purse','UserExpDomP'],['SilverCoin','UserSIcon'],['UserExp','UserExpHis'],['UserPoint','UserUserPoint']],out={studentId:uid,accounts:[],created:0,conflicts:0};
+ for(let index=0;index<specs.length;index++){
+  const [account,table]=specs[index],all=[],seen=new Set();let total=-1;
+  for(let page=1;page<=10000;page++){
+   const response=await readSource({uid,stype:index+1,psize:1000,cpage:page}),rows=response.items;
+   const count=Number(response.page.itemCount??rows.length),pages=Number(response.page.pageCount||Math.max(1,Math.ceil(count/1000)));
+   if(!Number.isSafeInteger(count)||count<0||!Number.isSafeInteger(pages)||pages<1||page>pages||(total>=0&&total!==count))throw Error('finance_source_shifted');total=count;
+   // Durable private backup before projection. A retry gets a new attempt key to retain earlier evidence.
+   await Psql.none('INSERT INTO "LegacyFinanceSourcePage"(company,"studentId",account,"runId",page,rows) VALUES($1,$2,$3,$4,$5,$6::jsonb) ON CONFLICT DO NOTHING',[co,uid,account,attempt,page,JSON.stringify(rows)]);
+   for(const r of rows){const id=String(r.ExpHisID??'');if(!/^\d+$/.test(id)||Number(r.UserID)!==uid||seen.has(id)||String(r.UserName||'').toLowerCase()!==String(legacy.UserName||u.username).toLowerCase())throw Error('finance_source_identity_or_duplicates');seen.add(id);all.push(r);}
+   if(page===pages)break;if(!rows.length||page===10000)throw Error('finance_incomplete_pages');
+  }
+  if(all.length!==total)throw Error('finance_source_count_mismatch');
+  const result=await Psql.tx(async t=>{
+   await t.any("SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))",[co]);
+   let created=0;const ids=[];
+   for(const raw of all){
+    const id=String(raw.ExpHisID),delta=Number(raw.score??raw.Score),before=raw.score_before===null||raw.score_before===undefined||raw.score_before===''?null:Number(raw.score_before);
+    const date=String(raw.HisTime||'').replace(' ','T'),iso=/([+-]\d{2}:?\d{2}|Z)$/.test(date)?date:date+'+08:00';
+    if((raw.score??raw.Score)===undefined||(raw.score??raw.Score)===null||!Number.isFinite(delta)||(before!==null&&!Number.isFinite(before))||Number.isNaN(Date.parse(iso)))throw Error('finance_invalid_values');
+    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');
+     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++;
+    }ids.push(id);
+   }
+   ids.sort((a,b)=>Number(a)-Number(b));
+   const actual=await t.any('SELECT "legacyFinanceId" id,"legacyFinanceHash" hash FROM "'+table+'" WHERE company=$1 AND "userId"=$2 AND "legacyFinanceId"=ANY($3::text[]) ORDER BY "legacyFinanceId"::numeric',[co,uid,ids]);
+   if(JSON.stringify(actual.map(r=>r.id))!==JSON.stringify(ids))throw Error('finance_id_reconciliation_failed');
+   const checksum=(await t.one('SELECT md5($1::jsonb::text) hash',[JSON.stringify(actual)])).hash;
+   await t.none('INSERT INTO "LegacyFinanceReceipt"(company,"studentId",account,"runId",ids,checksum) VALUES($1,$2,$3,$4,$5::jsonb,$6) ON CONFLICT(company,"studentId",account,"runId") DO UPDATE SET ids=EXCLUDED.ids,checksum=EXCLUDED.checksum',[co,uid,account,runId,JSON.stringify(ids),checksum]);
+   if(account==='UserExp'){
+    const used=all.some(r=>Number(r.score??r.Score)!==0)||Number(legacy.UserExp||0)>0||Boolean(await t.oneOrNone('SELECT "objectId" FROM "LessonRecord" WHERE company=$1 AND xymz::text=$2 AND ("courseCategoryKey"=\'trial\' OR kclx::text=\'3\') LIMIT 1',[co,String(uid)]));
+    await t.none('INSERT INTO "TrialEligibility"(company,"studentId",status,evidence) VALUES($1,$2,$3,$4::jsonb) ON CONFLICT(company,"studentId") DO UPDATE SET status=CASE WHEN "TrialEligibility".status=\'issued\' THEN \'issued\' ELSE EXCLUDED.status END,evidence="TrialEligibility".evidence||EXCLUDED.evidence,"updatedAt"=now()',[co,uid,used?'issued':'eligible',JSON.stringify({source:'WXAPP-full-trial-ledger',runId,count:all.length})]);
+   }
+   return{account,sourceCount:all.length,targetCount:actual.length,ids,checksum,created,reconciled:true};
+  }).catch(async error=>{await Psql.none('INSERT INTO "LegacySyncConflict"("objectId",company,"sourceKey",dataset,"legacyKey","conflictType",message,resolved,resolution,"createdAt","updatedAt") SELECT xs_object_id(),$1,$2,\'finance\',$3,\'finance-preserve-new\',$4,false,\'保留现有流水,待核对\',now(),now() WHERE NOT EXISTS(SELECT 1 FROM "LegacySyncConflict" WHERE company=$1 AND "sourceKey"=$2)',[co,'finance-conflict:'+uid+':'+account,String(uid),String(error.message)]);throw error;});out.accounts.push(result);out.created+=result.created;
+ }return out;
+}

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

@@ -0,0 +1,14 @@
+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;
+}
+async function memberLedger(context,input){
+ const target=await memberTarget(context,input,text(input.objectId||input.targetId)),co=pointerId(target.get('company')),uid=number(target.get('legacyUserId'));
+ const page=Math.max(1,Math.floor(number(input.page,1))),size=[20,50,100].includes(number(input.pageSize))?number(input.pageSize):20;
+ const from=text(input.dateFrom),to=text(input.dateTo);for(const d of [from,to])if(d&&!/^\d{4}-\d{2}-\d{2}$/.test(d))fail(400,'日期格式无效');if(from&&to&&from>to)fail(400,'开始日期不能晚于结束日期');
+ const result=await Psql.one(`WITH grouped AS (
+ SELECT "groupId",max("happenedAt") at,jsonb_agg(to_jsonb(l) ORDER BY account,id) lines FROM xs_member_ledger l WHERE company=$1 AND "studentId"=$2 GROUP BY "groupId"
+ ), filtered AS (SELECT * FROM grouped WHERE ($3='' OR EXISTS(SELECT 1 FROM jsonb_array_elements(lines) v WHERE v->>'account'=$3)) AND ($4='' OR EXISTS(SELECT 1 FROM jsonb_array_elements(lines) v WHERE v->>'kind'=$4)) AND ($5='' OR at>=NULLIF($5,'')::date::timestamp AT TIME ZONE 'Asia/Shanghai') AND ($6='' OR at<(NULLIF($6,'')::date+1)::timestamp AT TIME ZONE 'Asia/Shanghai'))
+ SELECT (SELECT md5(COALESCE(string_agg(lines::text,'' ORDER BY "groupId"),'')) FROM filtered) version,(SELECT count(*)::int FROM filtered) total,COALESCE((SELECT jsonb_agg(to_jsonb(p)) FROM (SELECT * FROM filtered ORDER BY at DESC NULLS LAST,"groupId" DESC LIMIT $7 OFFSET $8) p),'[]') items`,[co,uid,text(input.account),text(input.kind),from,to,size,(page-1)*size]);
+ return{...result,page,pageSize:size};
+}

+ 4 - 2
scripts/cloud/member-enrollment.js

@@ -7,14 +7,16 @@ async function creditAccounts(context,input,store=false) {
   const target=store?await scopedStoreObject(context,{...input,targetId:input.objectId||input.targetId}):await memberTarget(context,input,text(input.objectId||input.targetId));
   const uid=number(target.get('legacyUserId')),co=pointerId(target.get('company')),legacy=target.get('legacyUserData')||{},reserved=await reservedCreditsFor(context,input,uid);
   const accounts=Object.entries(TEACHING_CREDIT_LABELS).map(([account,label])=>({account,label,balance:number(legacy[account]),reserved:number(reserved[account]),available:number(legacy[account])-number(reserved[account])}));
-  const transactions=await Psql.query('SELECT t."objectId",t."kind",t."account",t."amount",t."storeId",t."studentId",t."sourceTransactionId",t."operatorId",t."reason",t."payMethod",t."balances",t."createdAt",t."amount"-COALESCE((SELECT SUM(r."amount") FROM "CreditTransaction" r WHERE r."company"=t."company" AND r."sourceTransactionId"=t."objectId" AND r."kind"=\'refund\'),0) AS "unreturned" FROM "CreditTransaction" t WHERE t."company"=$1 AND '+(store?'t."storeId"':'t."studentId"')+'=$2 ORDER BY t."createdAt" DESC LIMIT 200',[co,uid]);
+  const transactions=await Psql.query('SELECT t."objectId",t."kind",t."account",t."amount",t."storeId",t."studentId",t."sourceTransactionId",t."operatorId",t."reason",t."payMethod",t."balances",t."createdAt",t."operationId",t."amount"-COALESCE((SELECT SUM(r."amount") FROM "CreditTransaction" r WHERE r."company"=t."company" AND r."sourceTransactionId"=t."objectId" AND r."kind"=\'refund\'),0) AS "unreturned" FROM "CreditTransaction" t WHERE t."company"=$1 AND '+(store?'t."storeId"':'t."studentId"')+'=$2 ORDER BY t."createdAt" DESC',[co,uid]);
   const parent=store?null:await userByLegacyId(context,input,number(legacy.ParentUserID),2);
-  return{accounts,transactions,storeId:store?uid:number(legacy.ParentUserID),storeName:store?displayName(target.toJSON()):parent?displayName(parent):'',canPost:store?context.isSuperAdmin:roleOf(context)==='ops-manager'};
+  const trial=store?null:await Psql.oneOrNone('SELECT status,evidence FROM "TrialEligibility" WHERE company=$1 AND "studentId"=$2',[co,uid]);
+  return{trialStatus:trial?.status||'pending',accounts,transactions:transactions.filter(t=>store||!t.operationId||t.account!=='UserPoint'||!transactions.some(o=>o.operationId===t.operationId&&o.account!=='UserPoint')),storeId:store?uid:number(legacy.ParentUserID),storeName:store?displayName(target.toJSON()):parent?displayName(parent):'',canPost:store?context.isSuperAdmin:roleOf(context)==='ops-manager'};
 }
 async function postCredits(context,input,payload,store=false) {
   if(store)requireSuperAdmin(context);else requireRole(context,['ops-manager']);
   const target=store?await scopedStoreObject(context,input):await memberTarget(context,input,text(input.targetId));
   const account=text(payload.account)||({1:'Purse',2:'SilverCoin',3:'UserExp',4:'UserPoint'})[number(payload.type)],kind=store?'fund':payload.direction==='deduct'?'refund':'transfer';
+  if(!store){const result=await enrollmentSql('SELECT xs_credit_operation($1,$2,$3,$4::jsonb,$5,$6,$7) AS result',[pointerId(target.get('company')),context.current.id,target.id,JSON.stringify({...payload,account}),reasonOf(input),text(input.idempotencyKey),text(input.expectedUpdatedAt)]);return result.result;}
   const result=await enrollmentSql('SELECT xs_credit_post($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) AS result',[pointerId(target.get('company')),context.current.id,kind,target.id,account,number(payload.amount),text(payload.sourceTransactionId),reasonOf(input),text(payload.payMethod),text(input.idempotencyKey)]);
   const clean={...result.result};delete clean.request;return clean;
 }

Разница между файлами не показана из-за своего большого размера
+ 1 - 0
scripts/deploy-admin-functions.mjs


+ 1 - 1
scripts/lib/legacy-direct-bundle.mjs

@@ -12,5 +12,5 @@ export function buildDirectBundle(columns,windows=[],company='7pIbDBJmKx') {
  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));
+ return (prefix+['legacy-finance','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));
 }

+ 21 - 0
scripts/migrate-member-credit-ledger.mjs

@@ -0,0 +1,21 @@
+#!/usr/bin/env node
+import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
+import {randomUUID} from 'node:crypto';
+const sql=readFileSync(new URL('./sql/member-credit-ledger.sql',import.meta.url),'utf8');
+if(!process.argv.includes('--apply')){console.log('会员账户迁移就绪:成套充值、流水分组和一次性体验资格。使用 --apply 执行。');process.exit(0);}
+const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN||process.env.XIAOSHU_SESSION_TOKEN;
+if(!master||!token)throw Error('需要受保护环境中的主密钥和有效管理会话');
+const app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
+async function parse(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'数据库请求失败');return d;}
+const sessions=(await parse('/classes/_Session?'+new URLSearchParams({where:JSON.stringify({sessionToken:token}),include:'user',limit:'2'}))).results;
+const s=sessions?.[0];if(sessions.length!==1||!['super-admin','ops-manager'].includes(s.user?.adminRoleKey)||s.user.isDisabled||s.user.isDeleted||new Date(s.expiresAt?.iso)<new Date())throw Error('管理会话不可用');
+const co=s.user.company?.objectId;if(!co)throw Error('缺少帐套');
+const backupDir=process.env.XIAOSHU_CREDIT_BACKUP_DIR;if(!backupDir)throw Error('必须指定私有备份目录');mkdirSync(backupDir,{recursive:true,mode:0o700});
+const path='xiaoshu/system/member-credit-migrate-'+randomUUID(),expires=Date.now()+600000;let id;
+try{
+ const code=`async function handler(request,response){try{if(request.user?.id!==${JSON.stringify(s.user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});await request.user.fetch({useMasterKey:true});if(request.user.get('isDisabled')||request.user.get('isDeleted'))return response.status(403).json({success:false});const active=await Psql.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2 AND "leaseUntil">now()',[${JSON.stringify(co)},'running']);if(active.n)throw Error('夜间同步正在运行,暂缓迁移');if(request.body.params?.mode==='backup'){const tables=['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint','CreditTransaction','CourseCreditReservation'],backup={};for(const name of tables)backup[name]=await Psql.query('SELECT * FROM \"'+name+'\" WHERE company=$1',[${JSON.stringify(co)}]);backup.accounts=await Psql.query('SELECT \"objectId\",\"legacyUserId\",\"legacyUserData\" FROM \"_User\" WHERE company=$1',[${JSON.stringify(co)}]);backup.routines=await Psql.query(\"SELECT proname,pg_get_functiondef(oid) definition FROM pg_proc WHERE pronamespace='public'::regnamespace AND prokind='f' AND proname LIKE 'xs_%'\");return response.json({success:true,data:backup});}await Psql.tx(async t=>{await t.any(\"SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))\",[${JSON.stringify(co)}]);await t.none(${JSON.stringify(sql)});});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
+ new Function(code);
+ id=(await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
+ const backupResponse=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{mode:'backup'}})}),backup=await backupResponse.json();if(!backupResponse.ok||!backup.success)throw Error(backup.message||'备份失败');writeFileSync(backupDir+'/before.json',JSON.stringify(backup.data),{mode:0o600});
+ const response=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}})}),result=await response.json();if(!response.ok||!result.success)throw Error(result.message||'迁移失败');console.log('会员额度与流水迁移成功;余额和工资未重算。');
+}finally{if(id)await parse('/classes/Function/'+id,'DELETE');}

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

@@ -114,6 +114,8 @@ export async function runDirectSync(env=process.env,args=process.argv.slice(2)){
    }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.finance=report.finance||{members:[],created:0};
+  for(const member of await call({action:'finance-members'},2)){const result=await call({action:'finance',studentId:Number(member.id)},2);report.finance.members.push(result);report.finance.created+=result.created;await save(join(root,'direct-'+runId,'finance.json'),report.finance);}
   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));

+ 11 - 7
scripts/smoke-member-enrollment.mjs

@@ -11,7 +11,8 @@ function persist(){writeFileSync(statePath,JSON.stringify(state),{mode:0o600});}
 async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error('Parse '+path.split('?')[0]+': '+(d.error||r.status));return d;}
 async function call(path,token,params,allowFailure=false){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!allowFailure&&(!r.ok||d.success===false||d.retcode<0))throw Error((params.operation||params.action||path)+': '+(d.message||d.retmsg||d.error||r.status));return {status:r.status,data:d.data??d.result,raw:d};}
 const ops=async(operation,params={},allow=false)=>call('xiaoshu/ops/gateway-v3',state.admin.sessionToken,{operation,...params},allow);
-const mutate=(operation,targetId,payload,key=randomUUID(),allow=false)=>ops(operation,{targetId,payload,reason:'发布隔离账户验收',idempotencyKey:key},allow);
+const creditVersions=new Map();
+const mutate=async(operation,targetId,payload,key=randomUUID(),allow=false)=>{let expectedUpdatedAt='';if(operation==='ops/users/adjust-balance'){if(!creditVersions.has(key)){const preview=await ops('ops/users/credit-preview',{objectId:targetId,payload},allow);if(preview.status!==200)return preview;creditVersions.set(key,preview.data.version);}expectedUpdatedAt=creditVersions.get(key);}return ops(operation,{targetId,payload,reason:'发布隔离账户验收',idempotencyKey:key,expectedUpdatedAt},allow);};
 const expect=(ok,label)=>{if(!ok)throw Error('Assertion: '+label);console.log('PASS '+label);};
 async function user(kind,index,extra={}){const legacyUserId=999000000+(suffix%100000)*10+index;const value=await parse('/users','POST',{username:'enrollment_release_'+suffix+'_'+kind+'_'+index,password,company,identityType:kind,legacyGroupId:kind==='store'?2:kind==='coach'?3:1,legacyUserId,realName:'发布验收'+kind,legacyUserData:{UserID:legacyUserId,GroupID:kind==='store'?2:kind==='coach'?3:1,ParentUserID:0,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0},...extra});state.users.push({...value,legacyUserId,kind});persist();return state.users.at(-1);}
 async function installHelper(){const ids=state.users.map(u=>u.legacyUserId),idObjects=state.users.map(u=>u.objectId),path='xiaoshu/system/enrollment-check-'+suffix;
@@ -24,9 +25,11 @@ try{
  const student={objectId:created.objectId,legacyUserId:Number(created.userId),kind:'created-member'};state.users.push(student);persist();
  await installHelper();expect(!!created.objectId&&student.legacyUserId>0,'创建学员并关联门店');
  const zero=(await ops('ops/course-bindings/options',{studentId:empty.legacyUserId,categoryKey:'word'})).data;expect(!zero.eligible,'零余额开课资格被拒绝');
- for(const account of ['Purse','SilverCoin','UserPoint','UserExp']){const fundKey=randomUUID(),transferKey=randomUUID(),fund={account,amount:10,payMethod:'运营记账'},transfer={account,amount:5,payMethod:'运营记账',direction:'add'};for(let retry=0;retry<2;retry++){await mutate('ops/stores/credit-post',store.objectId,fund,fundKey);await mutate('ops/users/adjust-balance',student.objectId,transfer,transferKey);}}
- const storeAccounts=(await ops('ops/stores/credit-accounts',{objectId:store.objectId})).data;expect(storeAccounts.accounts.every(a=>a.balance===5)&&storeAccounts.transactions.length===8,'总部入账与双边划拨重试不重复记账');
- const accounts=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(accounts.accounts.every(a=>a.balance===5),'四类课时门店划拨后学员余额一致');
+ for(const account of ['Purse','SilverCoin','UserPoint','UserExp']){const key=randomUUID();await mutate('ops/stores/credit-post',store.objectId,{account,amount:20,payMethod:'运营记账'},key);await mutate('ops/stores/credit-post',store.objectId,{account,amount:20,payMethod:'运营记账'},key);}
+ for(const account of ['Purse','SilverCoin','UserPoint','UserExp']){const key=randomUUID(),p={account,amount:account==='UserExp'?1:5,payMethod:'运营记账',direction:'add'};await mutate('ops/users/adjust-balance',student.objectId,p,key);await mutate('ops/users/adjust-balance',student.objectId,p,key);}
+ const storeAccounts=(await ops('ops/stores/credit-accounts',{objectId:store.objectId})).data;expect(storeAccounts.accounts.find(a=>a.account==='UserPoint').balance===7.5,'配套划拨门店陪练余额正确,重试不重复记账');
+ const accounts=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(accounts.accounts.every(a=>a.balance===({Purse:5,SilverCoin:5,UserExp:1,UserPoint:12.5})[a.account]),'成套充值及独立陪练入账正确');
+ const trialAgain=await mutate('ops/users/adjust-balance',student.objectId,{account:'UserExp',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true);expect(trialAgain.status===409,'体验二次授予被拒绝');
  const initial=(await ops('ops/course-bindings/options',{studentId:student.legacyUserId,categoryKey:'word'})).data;expect(initial.categories.length===6,'六个新分类来自线上教学标准');
  for(const categoryKey of ['primary_writing','middle_writing','high_writing']){const o=(await ops('ops/course-bindings/options',{studentId:student.legacyUserId,categoryKey})).data;expect(o.items.every(i=>i.stageKey===categoryKey.split('_')[0]),categoryKey+' 学阶限制');}
  const course=initial.items.find(i=>i.eligible&&!i.alreadyOpened);expect(!!course,'完整目录存在可开内容');
@@ -44,10 +47,10 @@ try{
  const teacherView=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_companion_overview',mode:'coach',rangeStart:date,rangeEnd:date})).data;expect(teacherView.schedules.some(a=>a.bindingId===word.bindingIds[0]&&a.courseCategoryKey==='word'),'教师端预约保留开课分类');
  await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:generalId})});await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:11,content:JSON.stringify({GeneralID:generalId})});
  await mutate('ops/appointments/complete',appointment,{});
- const after=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(after.accounts.find(a=>a.account==='SilverCoin').balance===4&&after.accounts.find(a=>a.account==='UserPoint').balance===4,'教师完课与管理员重试只扣一次');
+ const after=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(after.accounts.find(a=>a.account==='SilverCoin').balance===4&&after.accounts.find(a=>a.account==='UserPoint').balance===11.5,'教师完课与管理员重试只扣一次');
  const a2=(await mutate('ops/appointments/create','',{...draft,bindingId:self.bindingIds[0],startTime:'12:00'})).data.targetIds[0];await mutate('ops/appointments/cancel',a2,{});await mutate('ops/appointments/restore',a2,{});await mutate('ops/appointments/cancel',a2,{});expect(true,'取消释放与恢复预留');
  const a3=(await mutate('ops/appointments/create','',{...draft,bindingId:word.bindingIds[0],startTime:'13:00'})).data.targetIds[0],a3Common=await parse('/classes/CommonModel/'+a3);await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:a3Common.generalId})});
- const rechargeRace=await Promise.all([mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true),mutate('ops/appointments/complete',a3,{},randomUUID(),true)]);expect(rechargeRace.every(r=>r.status===200),'充值与完课并发都成功');
+ const rechargeRace=await Promise.all([mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true),mutate('ops/appointments/complete',a3,{},randomUUID(),true)]);if(rechargeRace[0].status===409)rechargeRace[0]=await mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'});expect(rechargeRace.every(r=>r.status===200),'充值与完课并发,余额版本过期后刷新重试成功');
  const concurrentBalance=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(concurrentBalance.accounts.find(a=>a.account==='SilverCoin').balance===4,'充值与完课并发余额无覆盖');
  const transfer=accounts.transactions.find(t=>t.kind==='transfer'&&t.account==='Purse');await mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:4,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId});
  const drafts=['14:00','15:00'].map(startTime=>({...draft,bindingId:word.bindingIds[0],durationMinutes:30,startTime}));const race=await Promise.all(drafts.map(p=>mutate('ops/appointments/create','',p,randomUUID(),true)));expect(race.filter(r=>r.status===200).length===1&&race.filter(r=>r.status===409).length===1,'服务器多连接争抢最后一节课仅一单成功');
@@ -69,6 +72,7 @@ const statements=[
 ['DELETE FROM "CourseAppointment" WHERE "company"=$1 AND "szyh"::text=ANY($2::text[])',[co,texts]],
 ['DELETE FROM "CommonModel" c USING "CourseBinding" b WHERE c."company"=$1 AND b."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" AND b."yhid"::text=ANY($2::text[])',[co,texts]],
 ['DELETE FROM "CourseBinding" WHERE "company"=$1 AND "yhid"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "CreditOperation" WHERE company=$1 AND "studentId"=ANY($2::numeric[])',[co,ids]],['DELETE FROM "TrialEligibility" WHERE company=$1 AND "studentId"=ANY($2::numeric[])',[co,ids]],
 ...['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'].map(t=>['DELETE FROM "'+t+'" WHERE "company"=$1 AND "userId"=ANY($2::numeric[])',[co,ids]]),
 ['DELETE FROM "CreditTransaction" WHERE "company"=$1 AND ("storeId"=ANY($2::numeric[]) OR "studentId"=ANY($2::numeric[]))',[co,ids]],
 ['DELETE FROM "EnrollmentCommand" WHERE "company"=$1 AND "request"->>\'actor\'=$2',[co,admin]]];
@@ -76,7 +80,7 @@ const ledgerTables=['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'],acco
 const ledger=ledgerTables.map((table,i)=>`SELECT "userId",'${accounts[i]}' AS account,"score" FROM "${table}" WHERE "company"=$1 AND "userId"=ANY($2::numeric[])`).join(' UNION ALL ');
 const queries={counts:['SELECT "enrollmentStatus",count(*)::int AS count FROM "CourseBinding" WHERE "company"=$1 GROUP BY "enrollmentStatus"',[co]],
 reconcile:[`SELECT u."legacyUserId",ac.account,COALESCE((u."legacyUserData"->>ac.account)::numeric,0) balance,COALESCE(sum(l."score"),0) ledger FROM "_User" u CROSS JOIN unnest(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) ac(account) LEFT JOIN (${ledger}) l ON l."userId"=u."legacyUserId" AND l.account=ac.account WHERE u."objectId"=ANY($3::text[]) GROUP BY u."objectId",u."legacyUserId",ac.account`,[co,ids,objects]],
-audit:[`SELECT count(*)::int AS missing FROM "CreditTransaction" t WHERE t."company"=$1 AND t."operatorId"=$2 AND NOT EXISTS(SELECT 1 FROM "SysLog" s WHERE s."company"=t."company" AND s."sourceKey"='credit:'||t."objectId")`,[co,admin]]};
+audit:[`SELECT count(*)::int AS missing FROM "CreditTransaction" t WHERE t."company"=$1 AND t."operatorId"=$2 AND NOT EXISTS(SELECT 1 FROM "SysLog" s WHERE s."company"=t."company" AND (s."sourceKey"='credit:'||t."objectId" OR s."sourceKey"='credit-operation:'||t."operationId"))`,[co,admin]]};
 const code=`async function handler(request,response){try{if(!request.user||request.user.id!==${JSON.stringify(admin)})return response.status(403).json({success:false});const mode=(request.body.params||{}).mode,queries=${JSON.stringify(queries)};if(queries[mode])return response.json({success:true,data:await Psql.query(...queries[mode])});if(mode!=='cleanup')return response.status(400).json({success:false});for(const [sql,args] of ${JSON.stringify(statements)})await Psql.none(sql,args);return response.json({success:true,data:{cleaned:true}});}catch(e){return response.status(500).json({success:false,message:String(e.message),detail:e.detail||'',where:e.where||''});}}`;
 new Function(code);return code;
 }

+ 38 - 7
scripts/smoke-mobile-teaching-flow.mjs

@@ -11,7 +11,8 @@ function persist(){writeFileSync(statePath,JSON.stringify(state),{mode:0o600});}
 async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error('Parse '+path.split('?')[0]+': '+(d.error||r.status));return d;}
 async function call(path,token,params,allowFailure=false){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(60000)});const d=await r.json();if(!allowFailure&&(!r.ok||d.success===false||d.retcode<0))throw Error((params.operation||params.action||path)+': '+(d.message||d.retmsg||d.error||r.status));return {status:r.status,data:d.data??d.result,raw:d};}
 const ops=async(operation,params={},allow=false)=>call('xiaoshu/ops/gateway-v3',state.admin.sessionToken,{operation,...params},allow);
-const mutate=(operation,targetId,payload,key=randomUUID(),allow=false)=>ops(operation,{targetId,payload,reason:'发布隔离账户验收',idempotencyKey:key},allow);
+const creditVersions=new Map();
+const mutate=async(operation,targetId,payload,key=randomUUID(),allow=false)=>{let expectedUpdatedAt='';if(operation==='ops/users/adjust-balance'){if(!creditVersions.has(key)){const preview=await ops('ops/users/credit-preview',{objectId:targetId,payload},allow);if(preview.status!==200)return preview;creditVersions.set(key,preview.data.version);}expectedUpdatedAt=creditVersions.get(key);}return ops(operation,{targetId,payload,reason:'发布隔离账户验收',idempotencyKey:key,expectedUpdatedAt},allow);};
 const expect=(ok,label)=>{if(!ok)throw Error('Assertion: '+label);console.log('PASS '+label);};
 async function user(kind,index,extra={}){const legacyUserId=999000000+(suffix%100000)*10+index;const value=await parse('/users','POST',{username:'enrollment_release_'+suffix+'_'+kind+'_'+index,password,company,identityType:kind,legacyGroupId:kind==='store'?2:kind==='coach'?3:1,legacyUserId,realName:'发布验收'+kind,legacyUserData:{UserID:legacyUserId,GroupID:kind==='store'?2:kind==='coach'?3:1,ParentUserID:0,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0},...extra});state.users.push({...value,legacyUserId,kind});persist();return state.users.at(-1);}
 async function installHelper(){const ids=state.users.map(u=>u.legacyUserId),idObjects=state.users.map(u=>u.objectId),path='xiaoshu/system/enrollment-check-'+suffix;
@@ -23,11 +24,15 @@ try{
  const store=await user('store',2),coach=await user('coach',3),empty=await user('member',4);
  const created=(await mutate('ops/users/create','',{displayName:'发布验收学员',nickname:'发布验收学员',username:'release_member_'+suffix,email:'release_member_'+suffix+'@example.invalid',mobile:'199'+String(suffix).slice(-8),parentUserId:store.legacyUserId,password})).data;
  const student={objectId:created.objectId,legacyUserId:Number(created.userId),kind:'created-member'};state.users.push(student);persist();
+ const credit40Created=(await mutate('ops/users/create','',{displayName:'发布验收30分钟学员',nickname:'发布验收30分钟学员',username:'credit40_'+suffix,mobile:'191'+String(suffix).slice(-8),parentUserId:store.legacyUserId,password})).data;
+ const credit40={objectId:credit40Created.objectId,legacyUserId:Number(credit40Created.userId),kind:'created-member'};state.users.push(credit40);persist();
  await installHelper();expect(!!created.objectId&&student.legacyUserId>0,'创建学员并关联门店');
  const zero=(await ops('ops/course-bindings/options',{studentId:empty.legacyUserId,categoryKey:'word'})).data;expect(!zero.eligible,'零余额开课资格被拒绝');
- for(const account of ['Purse','SilverCoin','UserPoint','UserExp']){const fundKey=randomUUID(),transferKey=randomUUID(),fund={account,amount:10,payMethod:'运营记账'},transfer={account,amount:5,payMethod:'运营记账',direction:'add'};for(let retry=0;retry<2;retry++){await mutate('ops/stores/credit-post',store.objectId,fund,fundKey);await mutate('ops/users/adjust-balance',student.objectId,transfer,transferKey);}}
- const storeAccounts=(await ops('ops/stores/credit-accounts',{objectId:store.objectId})).data;expect(storeAccounts.accounts.every(a=>a.balance===5)&&storeAccounts.transactions.length===8,'总部入账与双边划拨重试不重复记账');
- const accounts=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(accounts.accounts.every(a=>a.balance===5),'四类课时门店划拨后学员余额一致');
+ for(const account of ['Purse','SilverCoin','UserPoint','UserExp']){const key=randomUUID();await mutate('ops/stores/credit-post',store.objectId,{account,amount:20,payMethod:'运营记账'},key);await mutate('ops/stores/credit-post',store.objectId,{account,amount:20,payMethod:'运营记账'},key);}
+ for(const account of ['Purse','SilverCoin','UserPoint','UserExp']){const key=randomUUID(),p={account,amount:account==='UserExp'?1:5,payMethod:'运营记账',direction:'add'};await mutate('ops/users/adjust-balance',student.objectId,p,key);await mutate('ops/users/adjust-balance',student.objectId,p,key);}
+ const storeAccounts=(await ops('ops/stores/credit-accounts',{objectId:store.objectId})).data;expect(storeAccounts.accounts.find(a=>a.account==='UserPoint').balance===7.5,'配套划拨门店陪练余额正确,重试不重复记账');
+ const accounts=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(accounts.accounts.every(a=>a.balance===({Purse:5,SilverCoin:5,UserExp:1,UserPoint:12.5})[a.account]),'成套充值及独立陪练入账正确');
+ const trialAgain=await mutate('ops/users/adjust-balance',student.objectId,{account:'UserExp',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true);expect(trialAgain.status===409,'体验二次授予被拒绝');
  const initial=(await ops('ops/course-bindings/options',{studentId:student.legacyUserId,categoryKey:'word'})).data;expect(initial.categories.length===6,'六个新分类来自线上教学标准');
  for(const categoryKey of ['primary_writing','middle_writing','high_writing']){const o=(await ops('ops/course-bindings/options',{studentId:student.legacyUserId,categoryKey})).data;expect(o.items.every(i=>i.stageKey===categoryKey.split('_')[0]),categoryKey+' 学阶限制');}
  const course=initial.items.find(i=>i.eligible&&!i.alreadyOpened);expect(!!course,'完整目录存在可开内容');
@@ -128,16 +133,41 @@ try{
  expect(trace.status===30&&trace.lessonCount===1&&trace.hasReport&&trace.studentReview.score===5&&trace.creditState==='consumed','后台对账显示唯一记录、报告、评价与消费预留');
  const afterVersion=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_live_versions',source:'database'})).data;expect(beforeVersion.version!==afterVersion.version,'跨设备更新版本随课堂推进变化');
  await mutate('ops/appointments/complete',appointment,{});
- const after=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(after.accounts.find(a=>a.account==='SilverCoin').balance===4&&after.accounts.find(a=>a.account==='UserPoint').balance===4,'教师完课与管理员重试只扣一次');
+ const after=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(after.accounts.find(a=>a.account==='SilverCoin').balance===4&&after.accounts.find(a=>a.account==='UserPoint').balance===11.5,'教师完课与管理员重试只扣一次');
  const a2=(await mutate('ops/appointments/create','',{...draft,bindingId:self.bindingIds[0],startTime:'12:00'})).data.targetIds[0];await mutate('ops/appointments/cancel',a2,{});await mutate('ops/appointments/restore',a2,{});await mutate('ops/appointments/cancel',a2,{});const cancelledCommon=await parse('/classes/CommonModel/'+a2);const cancelledStart=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:cancelledCommon.generalId})},true);expect(cancelledStart.status===409,'取消预约禁止开课');
  const a3=(await mutate('ops/appointments/create','',{...draft,bindingId:word.bindingIds[0],startTime:'13:00'})).data.targetIds[0],a3Common=await parse('/classes/CommonModel/'+a3);await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:a3Common.generalId})});
  const nextDetail=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:a3Common.generalId})).data[0];
- const rechargeRace=await Promise.all([mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true),call('xiaoshu/app/gateway',coach.sessionToken,{...feedback,orderId:a3Common.generalId,expectedUpdatedAt:nextDetail.updatedAt,requestKey:randomUUID()},true)]);expect(rechargeRace.every(r=>r.status===200),'充值与完课并发都成功');
+ const rechargeRace=await Promise.all([mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'},randomUUID(),true),call('xiaoshu/app/gateway',coach.sessionToken,{...feedback,orderId:a3Common.generalId,expectedUpdatedAt:nextDetail.updatedAt,requestKey:randomUUID()},true)]);if(rechargeRace[0].status===409)rechargeRace[0]=await mutate('ops/users/adjust-balance',student.objectId,{account:'SilverCoin',amount:1,payMethod:'运营记账',direction:'add'});expect(rechargeRace.every(r=>r.status===200),'充值与完课并发,余额版本过期后刷新重试成功');
  const concurrentBalance=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;expect(concurrentBalance.accounts.find(a=>a.account==='SilverCoin').balance===4,'充值与完课并发余额无覆盖');
  const transfer=accounts.transactions.find(t=>t.kind==='transfer'&&t.account==='Purse');await mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:4,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId});
  const drafts=['14:00','15:00'].map(startTime=>({...draft,bindingId:word.bindingIds[0],durationMinutes:30,startTime}));const race=await Promise.all(drafts.map(p=>mutate('ops/appointments/create','',p,randomUUID(),true)));expect(race.filter(r=>r.status===200).length===1&&race.filter(r=>r.status===409).length===1,'服务器多连接争抢最后一节课仅一单成功');
  const winner=race.find(r=>r.status===200).data.targetIds[0];await mutate('ops/appointments/cancel',winner,{});
  const race2=await Promise.all([mutate('ops/appointments/create','',drafts[0],randomUUID(),true),mutate('ops/users/adjust-balance',student.objectId,{account:'Purse',amount:1,payMethod:'运营记账',direction:'deduct',sourceTransactionId:transfer.objectId},randomUUID(),true)]);expect(race2.filter(r=>r.status===200).length===1,'退款和预留并发互斥');
+ // Exact customer recharge/complete scenario through deployed gateways and a separate isolated student.
+ for(const [account,amount] of [['Purse',40],['UserPoint',20]])await mutate('ops/stores/credit-post',store.objectId,{account,amount,payMethod:'运营记账'});
+ const p40={account:'Purse',amount:40,payMethod:'运营记账',direction:'add'},k40=randomUUID();await mutate('ops/users/adjust-balance',credit40.objectId,p40,k40);
+ let a40=(await ops('ops/users/credit-accounts',{objectId:credit40.objectId})).data;expect(a40.accounts.find(a=>a.account==='Purse').balance===40&&a40.accounts.find(a=>a.account==='UserPoint').balance===20,'真实30分钟成套充值40次得到40与20');
+ const b40=(await mutate('ops/course-bindings/save','',{studentId:credit40.legacyUserId,categoryKey:'word',courseIds:[course.courseId]})).data.bindingIds[0];
+ const c40=(await mutate('ops/appointments/create','',{...draft,studentId:credit40.legacyUserId,bindingId:b40,durationMinutes:30,startTime:'20:00'})).data.targetIds[0],g40=(await parse('/classes/CommonModel/'+c40)).generalId;
+ await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:g40})});
+ await call('xiaoshu/app/gateway',coach.sessionToken,{action:'stu_record_update_v2',orderId:g40,uid:credit40.legacyUserId,batchId:'credit40-round',saveMode:'confirm',expectedVersion:0,requestKey:randomUUID(),addon:JSON.stringify({bindingId:b40,xxqs:[{GeneralID:lessonWords[0].GeneralID,Title:lessonWords[0].Title,check:1}]})});
+ const d40=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:g40})).data[0],f40={...feedback,orderId:g40,expectedUpdatedAt:d40.updatedAt,requestKey:randomUUID()};
+ await call('xiaoshu/app/gateway',coach.sessionToken,f40);await call('xiaoshu/app/gateway',coach.sessionToken,f40);
+ a40=(await ops('ops/users/credit-accounts',{objectId:credit40.objectId})).data;expect(a40.accounts.find(a=>a.account==='Purse').balance===39&&a40.accounts.find(a=>a.account==='UserPoint').balance===19.5,'真实教师30分钟结课后39与19.5,重复结课不多扣');
+ const h40=(await ops('ops/users/credit-ledger',{objectId:credit40.objectId,pageSize:100})).data;expect(h40.items.flatMap(r=>r.lines).filter(l=>l.kind==='lesson').length===2,'真实课时与陪练完课流水同时生成并关联课次');
+ const badRole=await call('xiaoshu/ops/gateway-v3',coach.sessionToken,{operation:'ops/users/credit-preview',objectId:credit40.objectId,payload:p40},true);expect(badRole.status===403,'陪练无权充值');
+ const correction={account:'Purse',amount:1,direction:'adjust',adjustSign:'deduct',adjustMode:'paired',payMethod:'运营记账',evidence:'隔离历史差错凭据'},beforeCorrection=(await ops('ops/stores/credit-accounts',{objectId:store.objectId})).data.accounts;
+ const correctionKey=randomUUID();await mutate('ops/users/adjust-balance',credit40.objectId,correction,correctionKey);await mutate('ops/users/adjust-balance',credit40.objectId,correction,correctionKey);
+ const corrected=(await ops('ops/users/credit-accounts',{objectId:credit40.objectId})).data;expect(corrected.accounts.find(a=>a.account==='Purse').balance===38&&corrected.accounts.find(a=>a.account==='UserPoint').balance===19,'独立差错调整配套扣回,重试幂等');expect(JSON.stringify((await ops('ops/stores/credit-accounts',{objectId:store.objectId})).data.accounts)===JSON.stringify(beforeCorrection),'独立调整不改变门店库存');
+ const trial40={account:'UserExp',amount:1,direction:'add',payMethod:'运营记账'};const trialRace=await Promise.all([mutate('ops/users/adjust-balance',credit40.objectId,trial40,randomUUID(),true),mutate('ops/users/adjust-balance',credit40.objectId,trial40,randomUUID(),true)]);expect(trialRace.filter(r=>r.status===200).length===1,'真实并发体验发放仅一次成功');
+ const trialSource=(await ops('ops/users/credit-accounts',{objectId:credit40.objectId})).data.transactions.find(t=>t.account==='UserExp'&&t.kind==='transfer');await mutate('ops/users/adjust-balance',credit40.objectId,{...trial40,direction:'deduct',sourceTransactionId:trialSource.objectId});const reissue=await mutate('ops/users/adjust-balance',credit40.objectId,trial40,randomUUID(),true);expect(reissue.status===409,'真实体验退回后仍不可再次发放');
+ const trialOptions=(await ops('ops/course-bindings/options',{studentId:student.legacyUserId,categoryKey:'trial'})).data,trialCourse=trialOptions.items.find(i=>i.eligible&&!i.alreadyOpened);expect(!!trialCourse,'真实体验课程目录可开通');
+ const bt=(await mutate('ops/course-bindings/save','',{studentId:student.legacyUserId,categoryKey:'trial',courseIds:[trialCourse.courseId]})).data.bindingIds[0],trialDraft={...draft,courseId:trialCourse.courseId,bindingId:bt,durationMinutes:60,startTime:'21:00'};
+ const trialAppointments=await Promise.all([mutate('ops/appointments/create','',trialDraft,randomUUID(),true),mutate('ops/appointments/create','',{...trialDraft,startTime:'22:00'},randomUUID(),true)]);expect(trialAppointments.filter(r=>r.status===200).length===1,'真实并发体验预约仅一次成功');
+ const ct=trialAppointments.find(r=>r.status===200).data.targetIds[0];await mutate('ops/appointments/cancel',ct,{});await mutate('ops/appointments/restore',ct,{});expect((await ops('ops/users/credit-accounts',{objectId:student.objectId})).data.accounts.find(a=>a.account==='UserExp').balance===1,'取消体验释放预留,不扣体验余额');
+ const gt=(await parse('/classes/CommonModel/'+ct)).generalId,beforeTrial=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data.accounts;await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:gt})});const dt=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:gt})).data[0],ft={...feedback,orderId:gt,expectedUpdatedAt:dt.updatedAt,requestKey:randomUUID()};await call('xiaoshu/app/gateway',coach.sessionToken,ft);await call('xiaoshu/app/gateway',coach.sessionToken,ft);
+ const afterTrial=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data.accounts;expect(afterTrial.find(a=>a.account==='UserExp').balance===0&&afterTrial.filter(a=>a.account!=='UserExp').every(a=>a.balance===beforeTrial.find(b=>b.account===a.account).balance),'真实体验结课只扣1次体验,普通课时及陪练不变,重复结课不重复扣减');
+ const mobileLedger=await call('xiaoshu/app/gateway',studentToken,{action:'user_point_list',stype:3,cpage:1,psize:100});expect(mobileLedger.status===200&&mobileLedger.data.length>=2,'学生端回读体验充值与消费流水');
  const reconciliation=(await call(state.helper.path,state.admin.sessionToken,{mode:'reconcile'})).data;expect(reconciliation.every(r=>Number(r.balance)===Number(r.ledger)),'门店学员四类余额与流水逐项对账');
  const audit=(await call(state.helper.path,state.admin.sessionToken,{mode:'audit'})).data;expect(audit[0].missing===0,'全部课时业务单均有事务审计');
  const deletion=await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_request',reason:'隔离发布验证注销申请'}),deletionRetry=await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_request',reason:'隔离发布验证重复申请'});expect(deletion.data.id===deletionRetry.data.id,'真实注销重复请求幂等');
@@ -173,6 +203,7 @@ const statements=[
 ['DELETE FROM "CourseAppointment" WHERE "company"=$1 AND "szyh"::text=ANY($2::text[])',[co,texts]],
 ['DELETE FROM "CommonModel" c USING "CourseBinding" b WHERE c."company"=$1 AND b."company"=$1 AND c."modelId"=58 AND c."itemId"=b."id" AND b."yhid"::text=ANY($2::text[])',[co,texts]],
 ['DELETE FROM "CourseBinding" WHERE "company"=$1 AND "yhid"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "CreditOperation" WHERE company=$1 AND "studentId"=ANY($2::numeric[])',[co,ids]],['DELETE FROM "TrialEligibility" WHERE company=$1 AND "studentId"=ANY($2::numeric[])',[co,ids]],
 ...['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'].map(t=>['DELETE FROM "'+t+'" WHERE "company"=$1 AND "userId"=ANY($2::numeric[])',[co,ids]]),
 ['DELETE FROM "CreditTransaction" WHERE "company"=$1 AND ("storeId"=ANY($2::numeric[]) OR "studentId"=ANY($2::numeric[]))',[co,ids]],
 ['DELETE FROM "EnrollmentCommand" WHERE "company"=$1 AND "request"->>\'uid\'=ANY($2::text[])',[co,texts]]];
@@ -180,7 +211,7 @@ const ledgerTables=['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'],acco
 const ledger=ledgerTables.map((table,i)=>`SELECT "userId",'${accounts[i]}' AS account,"score" FROM "${table}" WHERE "company"=$1 AND "userId"=ANY($2::numeric[])`).join(' UNION ALL ');
 const queries={counts:['SELECT "enrollmentStatus",count(*)::int AS count FROM "CourseBinding" WHERE "company"=$1 GROUP BY "enrollmentStatus"',[co]],
 reconcile:[`SELECT u."legacyUserId",ac.account,COALESCE((u."legacyUserData"->>ac.account)::numeric,0) balance,COALESCE(sum(l."score"),0) ledger FROM "_User" u CROSS JOIN unnest(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) ac(account) LEFT JOIN (${ledger}) l ON l."userId"=u."legacyUserId" AND l.account=ac.account WHERE u."objectId"=ANY($3::text[]) GROUP BY u."objectId",u."legacyUserId",ac.account`,[co,ids,objects]],
-audit:[`SELECT count(*)::int AS missing FROM "CreditTransaction" t WHERE t."company"=$1 AND t."operatorId"=$2 AND NOT EXISTS(SELECT 1 FROM "SysLog" s WHERE s."company"=t."company" AND s."sourceKey"='credit:'||t."objectId")`,[co,admin]]};
+audit:[`SELECT count(*)::int AS missing FROM "CreditTransaction" t WHERE t."company"=$1 AND t."operatorId"=$2 AND NOT EXISTS(SELECT 1 FROM "SysLog" s WHERE s."company"=t."company" AND (s."sourceKey"='credit:'||t."objectId" OR s."sourceKey"='credit-operation:'||t."operationId"))`,[co,admin]]};
 const code=`async function handler(request,response){try{if(!request.user||request.user.id!==${JSON.stringify(admin)})return response.status(403).json({success:false});const mode=(request.body.params||{}).mode,queries=${JSON.stringify(queries)};if(queries[mode])return response.json({success:true,data:await Psql.query(...queries[mode])});if(mode!=='cleanup')return response.status(400).json({success:false});for(const [sql,args] of ${JSON.stringify(statements)})await Psql.none(sql,args);return response.json({success:true,data:{cleaned:true}});}catch(e){return response.status(500).json({success:false,message:String(e.message),detail:e.detail||'',where:e.where||''});}}`;
 new Function(code);return code;
 }

+ 161 - 0
scripts/sql/member-credit-ledger.sql

@@ -0,0 +1,161 @@
+-- Additive migration. Does not rewrite balances or replay historical charges.
+CREATE TABLE IF NOT EXISTS "CreditOperation" (
+ "objectId" text PRIMARY KEY, company text NOT NULL, "studentId" numeric NOT NULL,
+ "idempotencyKey" text NOT NULL, request jsonb NOT NULL, result jsonb NOT NULL,
+ "createdAt" timestamptz NOT NULL DEFAULT now(), UNIQUE(company,"idempotencyKey")
+);
+CREATE TABLE IF NOT EXISTS "TrialEligibility" (
+ company text NOT NULL, "studentId" numeric NOT NULL, status text NOT NULL CHECK(status IN ('pending','eligible','issued')),
+ evidence jsonb NOT NULL DEFAULT '{}', "updatedAt" timestamptz NOT NULL DEFAULT now(), PRIMARY KEY(company,"studentId")
+);
+CREATE TABLE IF NOT EXISTS "LegacyFinanceReceipt" (
+ company text NOT NULL, "studentId" numeric NOT NULL, account text NOT NULL,
+ "runId" text NOT NULL, ids jsonb NOT NULL, checksum text NOT NULL, "createdAt" timestamptz DEFAULT now(),
+ PRIMARY KEY(company,"studentId",account,"runId")
+);
+DO $$ DECLARE t text; BEGIN
+ FOREACH t IN ARRAY ARRAY['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'] LOOP
+ EXECUTE format('ALTER TABLE %I ADD COLUMN IF NOT EXISTS "legacyFinanceId" text, ADD COLUMN IF NOT EXISTS "legacyFinanceRaw" jsonb, ADD COLUMN IF NOT EXISTS "legacyFinanceHash" text',t);
+ EXECUTE format('CREATE UNIQUE INDEX IF NOT EXISTS %I ON %I(company,"legacyFinanceId") WHERE "legacyFinanceId" IS NOT NULL',lower(t)||'_finance_source',t);
+ END LOOP;
+END $$;
+ALTER TABLE "CreditTransaction" ADD COLUMN IF NOT EXISTS "operationId" text;
+INSERT INTO "TrialEligibility"(company,"studentId",status,evidence)
+ SELECT u.company,u."legacyUserId",CASE WHEN COALESCE((u."legacyUserData"->>'UserExp')::numeric,0)>0
+ OR EXISTS(SELECT 1 FROM "UserExpHis" h WHERE h.company=u.company AND h."userId"=u."legacyUserId" AND h.score<>0)
+ OR EXISTS(SELECT 1 FROM "LessonRecord" l WHERE l.company=u.company AND l.xymz::text=u."legacyUserId"::text AND (l."courseCategoryKey"='trial' OR l.kclx::text='3'))
+ THEN 'issued' ELSE 'pending' END,jsonb_build_object('source','migration-local-evidence') FROM "_User" u
+ WHERE u.company IS NOT NULL AND u."legacyUserId" IS NOT NULL AND u."identityType"='member'
+ ON CONFLICT(company,"studentId") DO UPDATE SET status=CASE WHEN EXCLUDED.status='issued' THEN 'issued' ELSE "TrialEligibility".status END;
+
+-- All balance entry points, including older gateways, must obey the lifetime grant guard.
+CREATE OR REPLACE FUNCTION xs_trial_grant_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE previous numeric=0; next_value numeric; qualification text;
+BEGIN
+ IF NEW."identityType" IS DISTINCT FROM 'member' THEN RETURN NEW;END IF;
+ next_value=COALESCE((NEW."legacyUserData"->>'UserExp')::numeric,0);
+ IF TG_OP='UPDATE' THEN previous=COALESCE((OLD."legacyUserData"->>'UserExp')::numeric,0);END IF;
+ IF next_value<=previous THEN RETURN NEW;END IF;
+ IF TG_OP='INSERT' AND COALESCE(to_jsonb(NEW)->>'sourceKey','') LIKE 'legacy%' AND NEW."legacyUserId" IS NOT NULL THEN
+  INSERT INTO "TrialEligibility"(company,"studentId",status,evidence) VALUES(NEW.company,NEW."legacyUserId",'issued',jsonb_build_object('source','initial-legacy-balance')) ON CONFLICT(company,"studentId") DO UPDATE SET status='issued';RETURN NEW;
+ END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||NEW.company));
+ SELECT status INTO qualification FROM "TrialEligibility" WHERE company=NEW.company AND "studentId"=NEW."legacyUserId" FOR UPDATE;
+ IF qualification IS DISTINCT FROM 'eligible' THEN RAISE EXCEPTION '体验资格已使用或待核验,每位会员仅可发放一次';END IF;
+ IF next_value-previous<>1 OR previous<>0 THEN RAISE EXCEPTION '体验数量固定 1 次';END IF;
+ UPDATE "TrialEligibility" SET status='issued',evidence=evidence||jsonb_build_object('issuedAt',now()),"updatedAt"=now() WHERE company=NEW.company AND "studentId"=NEW."legacyUserId";
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS xs_trial_grant_guard ON "_User";
+CREATE TRIGGER xs_trial_grant_guard BEFORE INSERT OR UPDATE OF "legacyUserData" ON "_User" FOR EACH ROW EXECUTE FUNCTION xs_trial_grant_guard();
+
+CREATE OR REPLACE FUNCTION xs_credit_plan(co text,target text,p jsonb) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE u "_User"; st "_User"; source "CreditTransaction"; companion "CreditTransaction"; a text=p->>'account'; n numeric=(p->>'amount')::numeric;
+ kind text=COALESCE(p->>'direction','add'); lines jsonb='[]'; item jsonb; signed numeric; paired boolean=true; before_value numeric; held numeric; store_value numeric; store_held numeric; delta numeric; qualification text; token text;
+BEGIN
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO u FROM "_User" WHERE company=co AND "objectId"=target;
+ IF NOT FOUND OR u."identityType"<>'member' OR COALESCE(u."isDisabled",false) OR COALESCE((to_jsonb(u)->>'isDeleted')::boolean,false) THEN RAISE EXCEPTION '学员不存在或已停用';END IF;
+ IF a IS NULL OR NOT a=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR n IS NULL OR n<=0 OR n>1000000000 OR n::text IN ('NaN','Infinity') OR round(n,2)<>n OR (a<>'UserPoint' AND trunc(n)<>n) THEN RAISE EXCEPTION '课时数量无效';END IF;
+ IF kind NOT IN ('add','deduct','adjust') OR length(trim(COALESCE(p->>'payMethod','')))<1 THEN RAISE EXCEPTION '请选择记账方式和业务类型';END IF;
+ IF kind='adjust' AND COALESCE(p->>'adjustMode','paired') NOT IN ('paired','single') THEN RAISE EXCEPTION '调整范围无效';END IF;
+ IF kind='adjust' AND (length(trim(COALESCE(p->>'evidence','')))<2 OR COALESCE(p->>'adjustSign','') NOT IN ('add','deduct')) THEN RAISE EXCEPTION '差错调整需要依据和增减方向';END IF;
+ SELECT status INTO qualification FROM "TrialEligibility" WHERE company=co AND "studentId"=u."legacyUserId";
+ IF a='UserExp' AND (kind='add' OR (kind='adjust' AND p->>'adjustSign'='add')) AND (qualification IS DISTINCT FROM 'eligible' OR n<>1) THEN RAISE EXCEPTION '体验资格已使用或待核验,数量固定 1 次';END IF;
+ IF kind='deduct' THEN
+  SELECT c.* INTO source FROM "CreditTransaction" c WHERE c.company=co AND c."objectId"=p->>'sourceTransactionId' AND c."studentId"=u."legacyUserId" AND c.account=a AND c.kind='transfer';
+  IF NOT FOUND THEN RAISE EXCEPTION '请选择有效原单;无原单请使用差错调整';END IF;
+  IF a='UserPoint' AND source."operationId" IS NOT NULL AND EXISTS(SELECT 1 FROM "CreditTransaction" c WHERE c.company=co AND c."operationId"=source."operationId" AND c.account IN ('Purse','SilverCoin')) THEN RAISE EXCEPTION '成套充值请从原课时主单按比例退回';END IF;
+  IF n>(source.amount-COALESCE((SELECT sum(t.amount) FROM "CreditTransaction" t WHERE t.company=co AND t."sourceTransactionId"=source."objectId" AND t.kind='refund'),0)) THEN RAISE EXCEPTION '超过原单剩余可退数量';END IF;
+  SELECT * INTO st FROM "_User" WHERE company=co AND "legacyUserId"=source."storeId";
+  IF source."operationId" IS NOT NULL AND a IN ('Purse','SilverCoin') THEN
+   SELECT c.* INTO companion FROM "CreditTransaction" c WHERE c.company=co AND c."operationId"=source."operationId" AND c.account='UserPoint' AND c.kind='transfer';
+   paired=FOUND;
+  ELSE paired=false;END IF;
+ ELSIF kind='add' THEN
+  SELECT * INTO st FROM "_User" WHERE company=co AND "legacyUserId"=NULLIF(u."legacyUserData"->>'ParentUserID','')::numeric;
+ ELSE paired=COALESCE(p->>'adjustMode','paired')='paired';END IF;
+ IF kind<>'adjust' AND (st."objectId" IS NULL OR st."identityType"<>'store' OR COALESCE(st."isDisabled",false) OR COALESCE((to_jsonb(st)->>'isDeleted')::boolean,false)) THEN RAISE EXCEPTION '请先关联有效门店';END IF;
+ signed=CASE WHEN kind='deduct' OR (kind='adjust' AND p->>'adjustSign'='deduct') THEN -1 ELSE 1 END;
+ lines=jsonb_build_array(jsonb_build_object('account',a,'amount',n,'sourceTransactionId',source."objectId"));
+ IF paired AND a IN ('Purse','SilverCoin') THEN lines=lines||jsonb_build_array(jsonb_build_object('account','UserPoint','amount',CASE WHEN kind='deduct' THEN n*companion.amount/source.amount WHEN a='Purse' THEN n*0.5 ELSE n END,'sourceTransactionId',companion."objectId"));END IF;
+ token=md5(jsonb_build_object('student',u."legacyUserData",'store',st."legacyUserData",'trial',qualification,'lines',lines,'source',source."objectId",'refunds',(SELECT jsonb_agg(to_jsonb(t) ORDER BY t."objectId") FROM "CreditTransaction" t WHERE t.company=co AND t.kind='refund' AND t."studentId"=u."legacyUserId"),'reserved',(SELECT jsonb_agg(jsonb_build_array(x,xs_reserved(co,u."legacyUserId"::numeric,x),xs_reserved(co,st."legacyUserId"::numeric,x))) FROM unnest(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) x))::text);
+ FOR item IN SELECT value FROM jsonb_array_elements(lines) LOOP
+  a=item->>'account';delta=signed*(item->>'amount')::numeric;before_value=COALESCE((u."legacyUserData"->>a)::numeric,0);held=xs_reserved(co,u."legacyUserId"::numeric,a);
+  IF delta<0 AND before_value-held+delta<0 THEN RAISE EXCEPTION '% 可扣回额度不足,不能扣回已使用或已预留额度',a;END IF;
+  store_value=COALESCE((st."legacyUserData"->>a)::numeric,0);store_held=xs_reserved(co,st."legacyUserId"::numeric,a);
+  IF kind='add' AND store_value-store_held<delta THEN RAISE EXCEPTION '门店 % 库存不足,缺少 %;请在门店账户办理总部入账',a,delta-(store_value-store_held);END IF;
+  lines=(SELECT jsonb_agg(CASE WHEN v->>'account'=a THEN v||jsonb_build_object('delta',delta,'before',before_value,'after',before_value+delta,'reserved',held,'availableBefore',before_value-held,'availableAfter',before_value-held+delta,'storeBefore',CASE WHEN kind='adjust' THEN NULL ELSE store_value END,'storeAfter',CASE WHEN kind='adjust' THEN NULL ELSE store_value-delta END,'storeAvailableBefore',CASE WHEN kind='adjust' THEN NULL ELSE store_value-store_held END,'storeAvailableAfter',CASE WHEN kind='adjust' THEN NULL ELSE store_value-store_held-delta END) ELSE v END) FROM jsonb_array_elements(lines) v);
+ END LOOP;
+ RETURN jsonb_build_object('version',token,'studentId',u."legacyUserId",'storeId',st."legacyUserId",'storeObjectId',st."objectId",'lines',lines,'trialStatus',COALESCE(qualification,'pending'));
+END $$;
+
+CREATE OR REPLACE FUNCTION xs_credit_operation(co text,actor text,target text,p jsonb,reason text,idem text,expected text) RETURNS jsonb LANGUAGE plpgsql AS $$
+DECLARE req jsonb=jsonb_build_object('actor',actor,'target',target,'payload',p,'reason',reason); previous "CreditOperation"; plan jsonb; item jsonb; r jsonb; results jsonb='[]'; oid text=xs_object_id(); tx text; kind text=p->>'direction'; uid numeric;
+BEGIN
+ IF length(trim(reason))<2 OR length(idem)<8 THEN RAISE EXCEPTION '缺少原因或幂等编号';END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||co));
+ SELECT * INTO previous FROM "CreditOperation" WHERE company=co AND "idempotencyKey"=idem;
+ IF FOUND THEN IF previous.request<>req THEN RAISE EXCEPTION '请求编号已用于其他操作';END IF;RETURN previous.result;END IF;
+ plan=xs_credit_plan(co,target,p);uid=(plan->>'studentId')::numeric;
+ IF expected IS NULL OR expected IS DISTINCT FROM plan->>'version' THEN RAISE EXCEPTION '账户或预留已变化,请刷新预览后提交';END IF;
+ FOR item IN SELECT value FROM jsonb_array_elements(plan->'lines') LOOP
+  IF kind='adjust' THEN
+   tx=xs_object_id();PERFORM set_config('xiaoshu.credit_write','yes',true);
+   UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[item->>'account'],item->'after'),"updatedAt"=now() WHERE company=co AND "objectId"=target;
+   PERFORM xs_ledger(co,uid,item->>'account',(item->>'delta')::numeric,(item->>'before')::numeric,tx,actor,reason||';依据:'||(p->>'evidence'),p->>'payMethod');
+   r=jsonb_build_object('objectId',tx,'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem||':'||(item->>'account'),'request',req,'kind','adjust','account',item->>'account','amount',(item->>'amount')::numeric,'studentId',uid,'operatorId',actor,'reason',reason,'payMethod',p->>'payMethod','balances',jsonb_build_object('studentBefore',item->'before','studentAfter',item->'after'),'operationId',oid);
+   PERFORM xs_insert('CreditTransaction',r);
+  ELSE
+   r=xs_credit_post(co,actor,CASE kind WHEN 'add' THEN 'transfer' ELSE 'refund' END,target,item->>'account',(item->>'amount')::numeric,COALESCE(item->>'sourceTransactionId',''),reason,p->>'payMethod',idem||':'||(item->>'account'));
+   UPDATE "CreditTransaction" SET "operationId"=oid WHERE "objectId"=r->>'objectId';
+  END IF;
+  results=results||jsonb_build_array(r-'request');
+ END LOOP;
+ r=plan||jsonb_build_object('objectId',oid,'transactions',results);
+ INSERT INTO "CreditOperation" VALUES(oid,co,uid,idem,req,r,now());
+ PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','credit-operation:'||oid,'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','会员额度业务','type1','operations-admin','type2',kind,'type3','CreditOperation','clevel',0,'content1',reason,'content2',actor,'detail',jsonb_build_object('request',req,'result',r)::text));
+ RETURN r;
+END $$;
+CREATE TABLE IF NOT EXISTS "LegacyFinanceSourcePage" (
+ company text NOT NULL,"studentId" numeric NOT NULL,account text NOT NULL,"runId" text NOT NULL,page integer NOT NULL,
+ rows jsonb NOT NULL,"createdAt" timestamptz DEFAULT now(),PRIMARY KEY(company,"studentId",account,"runId",page)
+);
+CREATE OR REPLACE VIEW xs_member_ledger AS
+WITH raw AS (
+ SELECT 'Purse'::text account,to_jsonb(l) j FROM "UserExpDomP" l UNION ALL
+ SELECT 'SilverCoin',to_jsonb(l) FROM "UserSIcon" l UNION ALL
+ SELECT 'UserExp',to_jsonb(l) FROM "UserExpHis" l UNION ALL
+ SELECT 'UserPoint',to_jsonb(l) FROM "UserUserPoint" l
+)
+SELECT r.account,r.j->>'objectId' id,r.j->>'company' company,(r.j->>'userId')::numeric "studentId",
+ COALESCE(t."operationId",r.account||':'||(r.j->>'objectId')) "groupId",
+ COALESCE((r.j->>'hisTime')::timestamptz,(r.j->>'createdAt')::timestamptz) "happenedAt",
+ (r.j->>'score')::numeric delta,(r.j->>'scoreBefore')::numeric "before",
+ CASE WHEN r.j->>'legacyFinanceId' IS NULL THEN (r.j->>'scoreBefore')::numeric+(r.j->>'score')::numeric ELSE NULL END "after",
+ COALESCE(t.kind,CASE WHEN r.j->>'transactionId' LIKE 'complete:%' OR r.j->>'detail' ~ '上课|结课|订单|陪练扣|课时扣' THEN 'lesson' WHEN (r.j->>'score')::numeric>0 THEN 'recharge' ELSE 'deduction' END) kind,
+ COALESCE(t.reason,r.j->>'detail',r.j->>'remark') reason,
+ CASE WHEN r.j->>'legacyFinanceId' IS NOT NULL OR r.j->>'sourceKey' NOT LIKE 'credit:%' THEN 'legacy' ELSE 'new' END source,
+ COALESCE(actor."realName",actor.nickname,t."operatorId",r.j->'legacyFinanceRaw'->>'Operator',r.j->>'remark') operator,
+ COALESCE(t."objectId",r.j->>'legacyFinanceId',r.j->>'transactionId',r.j->>'sourceKey') reference,
+ t."storeId",t.balances,r.j->'legacyFinanceRaw' "legacyRaw",
+ (SELECT l."objectId" FROM "LessonRecord" l WHERE l.company=r.j->>'company' AND l.xymz::text=r.j->>'userId' AND
+ (r.j->>'transactionId'='complete:'||(SELECT c."objectId" FROM "CommonModel" c WHERE c.company=l.company AND c."modelId"=54 AND c."generalId"::text=l.yyds::text LIMIT 1) OR (r.j->>'detail') ~ ('订单[:: ]*'||l.yyds::text||'([^0-9]|$)')) LIMIT 1) "lessonId"
+FROM raw r LEFT JOIN "CreditTransaction" t ON t.company=r.j->>'company' AND t."objectId"=r.j->>'transactionId'
+LEFT JOIN "_User" actor ON actor."objectId"=t."operatorId" AND actor.company=t.company;
+CREATE OR REPLACE FUNCTION xs_trial_reservation_guard() RETURNS trigger LANGUAGE plpgsql AS $$
+DECLARE cost jsonb;
+BEGIN
+ IF NEW.state NOT IN ('reserved','consumed') THEN RETURN NEW;END IF;
+ SELECT value INTO cost FROM jsonb_array_elements(COALESCE(NEW."creditCosts"::jsonb,'[]')) WHERE value->>'account'='UserExp';
+ IF cost IS NULL THEN RETURN NEW;END IF;
+ PERFORM pg_advisory_xact_lock(hashtext('xs-credit:'||NEW.company));
+ IF (cost->>'amount')::numeric<>1 OR jsonb_array_length(NEW."creditCosts"::jsonb)<>1 THEN RAISE EXCEPTION '体验课只扣一次体验,不扣普通陪练';END IF;
+ IF TG_OP='UPDATE' AND OLD.state='consumed' THEN RETURN NEW;END IF;
+ IF xs_reserved(NEW.company,NEW."studentId"::numeric,'UserExp',NEW."appointmentId")>0 OR EXISTS(
+ SELECT 1 FROM "LessonRecord" l WHERE l.company=NEW.company AND l.xymz::text=NEW."studentId"::text AND (l."courseCategoryKey"='trial' OR l.kclx::text='3')
+ ) THEN RAISE EXCEPTION '体验已使用或已有体验预约,不能重复预约';END IF;
+ RETURN NEW;
+END $$;
+DROP TRIGGER IF EXISTS xs_trial_reservation_guard ON "CourseCreditReservation";
+CREATE TRIGGER xs_trial_reservation_guard BEFORE INSERT OR UPDATE ON "CourseCreditReservation" FOR EACH ROW EXECUTE FUNCTION xs_trial_reservation_guard();

+ 17 - 1
scripts/tests/direct-nightly-sync.test.mjs

@@ -86,6 +86,7 @@ test('每日运行入口只使用直接接口、按依赖顺序并写私有报
    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==='finance-members')data=[];
    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;
@@ -143,6 +144,7 @@ test('未取得服务器对账证据时不完成运行;已退出进程的目
    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==='finance-members')data=[];
    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;}
@@ -157,6 +159,7 @@ test('未取得服务器对账证据时不完成运行;已退出进程的目
 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']);
+ for(const name of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec('CREATE TABLE IF NOT EXISTS "'+name+'"(company text,"userId" numeric,"legacyFinanceId" text,"legacyFinanceHash" text)');
  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 {
@@ -169,7 +172,9 @@ test('删除标记禁止恢复,阅读数字编号冲突不会当作小树身
 });
 
 test('只有全部数据集对账后才能完成,统计来自写入回执,完成重试幂等',async()=>{
+ await db.exec(`ALTER TABLE "_User" ADD COLUMN "identityType" text; CREATE TABLE "LegacyFinanceReceipt"(company text,"studentId" numeric,account text,"runId" text,ids jsonb,checksum text);`);
  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)');
+ for(const name of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec('CREATE TABLE IF NOT EXISTS "'+name+'"(company text,"userId" numeric,"legacyFinanceId" text,"legacyFinanceHash" text)');
  const previous=context.fetch;
  context.fetch=async()=>({ok:true,json:async()=>({retcode:0,result:[],page:{itemCount:0,pageCount:1}})});
  try{
@@ -180,7 +185,18 @@ test('只有全部数据集对账后才能完成,统计来自写入回执,
    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);
+  await db.exec(`INSERT INTO "_User"("objectId",company,"legacyUserId","identityType","sourceKey","legacyUserData") VALUES('finance-member','co',999,'member','legacy-sync:user:999','{}'),('new-only-member','co',1000,'member',NULL,'{"UserName":"new-only"}');`);
+  assert.deepEqual(JSON.parse(JSON.stringify((await call({action:'finance-members'})).data)),[{id:'999'}]);
+  assert.match((await call({action:'finish',summary})).message,/finance_reconciliation_incomplete/);
+  for(const account of ['Purse','SilverCoin','UserExp','UserPoint'])await sql(`INSERT INTO "LegacyFinanceReceipt" VALUES('co',999,$1,$2,'[]',md5('[]'::jsonb::text))`,[account,runId]);
+  await db.exec(`INSERT INTO "UserSIcon" VALUES('co',999,'source-1','checksum-1')`);
+  await sql(`UPDATE "LegacyFinanceReceipt" SET ids='["source-1"]',checksum=md5('[{"id":"source-1","hash":"checksum-1"}]'::jsonb::text) WHERE account='SilverCoin'`);
+  // Numeric source IDs are a server contract.
+  await db.exec(`UPDATE "UserSIcon" SET "legacyFinanceId"='1';UPDATE "LegacyFinanceReceipt" SET ids='["1"]',checksum='tampered' WHERE account='SilverCoin';`);
+  assert.match((await call({action:'finish',summary})).message,/finance_postimage_changed/);
+  await sql(`UPDATE "LegacyFinanceReceipt" SET checksum=md5('[{"id":"1","hash":"checksum-1"}]'::jsonb::text) WHERE account='SilverCoin'`);
+  const r=await call({action:'finish',summary});assert.equal(r.success,true,r.message);assert.equal(r.data.finance.rows,1);assert.equal(r.data.finance.members,1);
+
   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)));

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

@@ -0,0 +1,90 @@
+import {test,before,after} from 'node:test';
+import assert from 'node:assert/strict';
+import {readFileSync} from 'node:fs';
+import {PGlite} from '@electric-sql/pglite';
+const db=new PGlite();const sql=readFileSync(new URL('../sql/member-enrollment.sql',import.meta.url),'utf8');
+const query=async(s,p=[])=>(await db.query(s,p)).rows;
+const one=async(s,p=[])=>(await query(s,p))[0];
+before(async()=>{
+ await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" double precision,"legacyGroupId" double precision,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
+ CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" double precision,"cksl" double precision,"syjd" text);
+ CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" double precision,"itemId" double precision,"modelId" double precision,"nodeId" double precision,"tableName" text,"title" text,"subtitle" text,"status" double precision,"inputer" text);
+ CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"szyh" text,"kcid" text,"pl" text,"fxpl" text,"dszt" text,"dslx" text,"jffs" text,"jssj" text,"updatedAt" timestamptz,"creditCostsSnapshot" jsonb,"creditHoldStatus" text,"courseCategoryKey" text,"courseCategoryName" text,"teacherPaySnapshot" double precision);
+ CREATE TABLE "LessonRecord" ("objectId" text PRIMARY KEY,"company" text,"id" double precision,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"xymz" text,"jsmz" text,"kcid" text,"kclx" text,"kcmc" text,"yyds" text,"jffs" text,"kzsj" text,"lessonAt" timestamptz);
+ CREATE TABLE "DailyStudyRecord" ("objectId" text);
+ CREATE TABLE "CourseTeachingProfile" ("company" text,"courseId" double precision,"categoryKey" text,"confirmed" boolean);
+ CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" double precision,"appointmentId" text,"creditCosts" jsonb,"state" text,"consumedAt" timestamptz,"updatedAt" timestamptz);`);
+ for(const table of ['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'])await db.exec(`CREATE TABLE "${table}" ("objectId" text PRIMARY KEY,"company" text,"sourceKey" text UNIQUE,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" double precision,"score" double precision,"scoreBefore" double precision,"hisTime" timestamptz,"operator" double precision,"scoreType" double precision,"detail" text,"remark" text,"payMethod" text)`);
+ await db.exec(`INSERT INTO "_User" VALUES ('store','co',10,2,'store',false,'{}',now()),('student','co',20,1,'member',false,'{"ParentUserID":10}',now()),('empty','co',21,1,'member',false,'{}',now()),('foreign','other',22,1,'member',false,'{}',now());`);
+ await db.exec(`ALTER TABLE "CourseAppointment" ADD COLUMN "kssj" text,ADD COLUMN "plxm" text,ADD COLUMN "yykcid" text,ADD COLUMN "durationMinutes" numeric,ADD COLUMN "deliveryMode" text,ADD COLUMN "teachingRuleId" text,ADD COLUMN "bxrq" text,ADD COLUMN "sdsd" text,ADD COLUMN "yysj" text,ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz;
+ ALTER TABLE "CourseCreditReservation" ADD COLUMN "sourceKey" text,ADD COLUMN "createdAt" timestamptz,ADD COLUMN "appointmentGeneralId" numeric,ADD COLUMN "ruleId" text,ADD COLUMN "reservedAt" timestamptz;`);
+ await db.exec(`CREATE TABLE "Node" ("company" text,"nodeId" numeric,"parentId" numeric,"nodeName" text,"zstatus" numeric);
+ CREATE TABLE "SysLog" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text NOT NULL UNIQUE,"id" double precision,"cdate" timestamptz,"cname" text,"type1" text,"type2" text,"type3" text,"clevel" double precision,"cadminId" double precision,"detail" text,"content1" text,"content2" text);
+ INSERT INTO "Node" VALUES ('co',100,8,'词表',99),('co',101,8,'空词表',99);
+ INSERT INTO "CommonModel" ("objectId","company","nodeId","modelId","status") SELECT 'word-'||i,'co',100,52,99 FROM generate_series(1,200) i;`);
+ await db.exec(sql);
+ await db.exec('ALTER TABLE "_User" ADD COLUMN "realName" text, ADD COLUMN nickname text, ADD COLUMN username text');
+ await db.exec(readFileSync(new URL('../sql/member-credit-ledger.sql',import.meta.url),'utf8'));
+});
+after(()=>db.close());
+const bal=async(id)=>(await one('SELECT "legacyUserData" b FROM "_User" WHERE "objectId"=$1',[id])).b;
+const payload=(account,amount,extra={})=>({account,amount,direction:'add',payMethod:'运营记账',...extra});
+const plan=async(p)=>(await one("SELECT xs_credit_plan('co','student',$1::jsonb) r",[JSON.stringify(p)])).r;
+const apply=async(p,key,version)=>(await one("SELECT xs_credit_operation('co','admin','student',$1::jsonb,'测试充值',$2,$3) r",[JSON.stringify(p),key,version|| (await plan(p)).version])).r;
+test('成套充值、版本、退款、差错调整和原子回滚',async()=>{
+ await db.exec(`SELECT set_config('xiaoshu.credit_write','yes',false); UPDATE "_User" SET "legacyUserData"='{"Purse":100,"SilverCoin":100,"UserPoint":100,"UserExp":10}' WHERE "objectId"='store';`);
+ const p=payload('Purse',40),preview=await plan(p);assert.equal(preview.lines[1].amount,20);
+ const r=await apply(p,'paired-0001',preview.version);assert.equal((await bal('student')).Purse,40);assert.equal((await bal('student')).UserPoint,20);assert.equal((await bal('store')).UserPoint,80);
+ assert.deepEqual(await apply(p,'paired-0001',preview.version),r);
+ await assert.rejects(apply(payload('Purse',1),'stale-0001',preview.version),/预览/);
+ const refund=payload('Purse',10,{direction:'deduct',sourceTransactionId:r.transactions[0].objectId});await apply(refund,'refund-0001');assert.equal((await bal('student')).Purse,30);assert.equal((await bal('student')).UserPoint,15);assert.equal((await bal('store')).UserPoint,85);
+ await assert.rejects(apply({...refund,amount:31},'refund-too-many'),/可退/);
+ const correction=payload('Purse',2,{direction:'adjust',adjustSign:'deduct',adjustMode:'paired',evidence:'历史原单错误凭据'});await apply(correction,'correct-0001');assert.equal((await bal('student')).Purse,28);assert.equal((await bal('student')).UserPoint,14);assert.equal((await bal('store')).UserPoint,85);
+ const before=await bal('student');await assert.rejects(apply(payload('Purse',300),'stock-too-low'),/库存不足/);assert.deepEqual(await bal('student'),before);
+ await db.exec(`INSERT INTO "CourseCreditReservation"("objectId",company,"studentId","appointmentId","creditCosts",state) VALUES('held','co',20,'absent','[{"account":"Purse","amount":28}]','reserved');`);
+ await assert.rejects(apply(payload('Purse',1,{direction:'adjust',adjustSign:'deduct',evidence:'测试依据'}),'hold-00001'),/预留/);
+ await db.exec(`DELETE FROM "CourseCreditReservation" WHERE "objectId"='held'`);
+ const page=await query(`SELECT * FROM xs_member_ledger WHERE company='co' AND "studentId"=20`);assert.equal(new Set(page.map(r=>r.groupId)).size,3);assert.equal(page.length,6);
+});
+test('体验待核验阻止、一次授予、退回不恢复、旧入口也阻止',async()=>{
+ const p=payload('UserExp',1);await assert.rejects(apply(p,'trial-pending'),/待核验/);
+ await db.exec(`UPDATE "TrialEligibility" SET status='eligible' WHERE company='co' AND "studentId"=20`);
+ const r=await apply(p,'trial-00001');assert.equal((await bal('student')).UserExp,1);
+ await assert.rejects(apply(p,'trial-00002'),/体验/);
+ await apply(payload('UserExp',1,{direction:'deduct',sourceTransactionId:r.transactions[0].objectId}),'trial-return');
+ assert.equal((await bal('student')).UserExp,0);await assert.rejects(apply(p,'trial-after-return'),/体验/);
+ await assert.rejects(one("SELECT xs_credit_post('co','admin','transfer','student','UserExp',1,'','旧页面重试','转账','trial-old-gateway')"),/体验/);
+});
+test('完整旧流水持续分页导入,幂等且余额不变;未知余额保留空值',async()=>{
+ const vm=await import('node:vm');
+ await db.exec(`ALTER TABLE "_User" ADD "sourceKey" text,ADD "isDeleted" boolean; UPDATE "_User" SET "sourceKey"='legacy-sync:user:20',username='student',"legacyUserData"="legacyUserData"||'{"UserName":"student"}' WHERE "objectId"='student'; CREATE TABLE "LegacySyncConflict"("objectId" text,company text,"sourceKey" text,dataset text,"legacyKey" text,"conflictType" text,message text,resolved boolean,resolution text,"createdAt" timestamptz,"updatedAt" timestamptz);`);
+ 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;SELECT set_config('xiaoshu.credit_write','yes',true)");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);
+ await db.exec(`UPDATE "_User" SET "sourceKey"=NULL WHERE "objectId"='student'`);await assert.rejects(ctx.importLegacyFinance('co',20,'new-only-test',()=>{throw Error('must not fetch');}),/identity_unverified/);await db.exec(`UPDATE "_User" SET "sourceKey"='legacy-sync:user:20' WHERE "objectId"='student'`);
+ const rows=Array.from({length:1001},(_,i)=>({ExpHisID:i+1,UserID:20,UserName:'student',score:i===0?20:-1,score_before:i===0?null:20-i,HisTime:'2026-10-11T20:47:00',Detail:'原始记录'}));
+ const read=async p=>({items:p.stype===2?rows.slice((p.cpage-1)*1000,p.cpage*1000):[],page:{itemCount:p.stype===2?1001:0,pageCount:p.stype===2?2:1}});
+ const before=await bal('student'),r=await ctx.importLegacyFinance('co',20,'import-0001',read);assert.equal(r.created,1001);assert.deepEqual(await bal('student'),before);assert.equal((await ctx.importLegacyFinance('co',20,'import-0002',read)).created,0);assert.equal((await one('SELECT count(*)::int n FROM "LegacyFinanceReceipt"')).n,8);
+ assert.equal((await one(`SELECT "before" FROM xs_member_ledger WHERE reference='1' AND account='SilverCoin'`)).before,null);
+ const uppercase=async p=>({items:p.stype===4?[{...rows[0],ExpHisID:2001,score:undefined,Score:20}]:[],page:{itemCount:p.stype===4?1:0,pageCount:1}});assert.equal((await ctx.importLegacyFinance('co',20,'import-upper',uppercase)).created,1);assert.equal((await one(`SELECT "after" FROM xs_member_ledger WHERE reference='2001' AND account='UserPoint'`)).after,null);assert.equal(Number((await one(`SELECT score FROM "UserUserPoint" WHERE "legacyFinanceId"='2001'`)).score),20);
+ Object.assign(ctx,{memberTarget:async()=>({id:'student',get:k=>k==='company'?'co':20}),pointerId:v=>v,number:(v,f=0)=>Number.isFinite(Number(v))?Number(v):f,text:v=>String(v??''),fail:(_s,m)=>{throw Error(m);}});vm.runInContext(readFileSync(new URL('../cloud/member-credit-ledger.js',import.meta.url),'utf8'),ctx);
+ const ids=[];for(let page=1;page<=11;page++){const result=await ctx.memberLedger({},{account:'SilverCoin',page,pageSize:100,dateFrom:'2026-10-11',dateTo:'2026-10-11'});assert.equal(result.total,1001);ids.push(...result.items.map(r=>r.groupId));}assert.equal(ids.length,1001);assert.equal(new Set(ids).size,1001);assert.equal((await ctx.memberLedger({},{account:'SilverCoin',dateFrom:'2026-10-12'})).total,0);
+ rows[0].score=30;await assert.rejects(ctx.importLegacyFinance('co',20,'import-0003',read),/conflict/);assert.equal((await one('SELECT count(*)::int n FROM "LegacySyncConflict"')).n,1);assert.deepEqual(await bal('student'),before);
+});
+test('40 次30分钟结课后39和19.5;60分钟及重复结课快照一致',async()=>{
+ await db.exec("BEGIN;SELECT set_config('xiaoshu.credit_write','yes',true)");try{
+ await db.exec(`UPDATE "_User" SET "legacyUserData"='{"ParentUserID":10,"Purse":0,"SilverCoin":0,"UserPoint":0}' WHERE "objectId"='student';UPDATE "_User" SET "legacyUserData"='{"Purse":100,"SilverCoin":100,"UserPoint":100}' WHERE "objectId"='store';`);
+ await apply(payload('Purse',40),'scenario-40');assert.equal((await bal('student')).UserPoint,20);
+ const enroll=(await one(`SELECT xs_enroll('co','admin',20,'word','[{"courseId":100,"courseName":"词表","categoryName":"单词课"}]','[[{"account":"Purse","amount":1},{"account":"UserPoint","amount":0.5}]]','测试开课','scenario-enroll') r`)).r;
+ async function finish(id,account,point){const costs=[{account,amount:1},{account:'UserPoint',amount:point}];await query(`INSERT INTO "CourseAppointment"("objectId",company,id,szyh,kcid,pl,dszt,dslx,"bindingId","creditCostsSnapshot","teacherPaySnapshot","courseCategoryKey") VALUES($1,'co',$2,'20','100','30','10',$3,$4,$5::jsonb,30,'word')`,['a'+id,id,point===0.5?'1':'2',enroll.bindingIds[0],JSON.stringify(costs)]);await query(`INSERT INTO "CommonModel"("objectId",company,"modelId","generalId","itemId",status) VALUES($1,'co',54,$2,$2,99)`,['c'+id,id]);await query(`INSERT INTO "CourseCreditReservation"("objectId",company,"studentId","appointmentId","creditCosts",state) VALUES($1,'co',20,$2,$3::jsonb,'reserved')`,['r'+id,'c'+id,JSON.stringify(costs)]);const r=await one(`SELECT xs_complete_appointment('co',$1,'admin') r`,['c'+id]);assert.equal((await one(`SELECT xs_complete_appointment('co',$1,'admin') r`,['c'+id])).r.idempotent,true);return r.r;}
+ const r=await finish(9001,'Purse',0.5);assert.equal((await bal('student')).Purse,39);assert.equal((await bal('student')).UserPoint,19.5);assert.equal(Number((await one('SELECT "teacherPaySnapshot" FROM "LessonRecord" WHERE "objectId"=$1',[r.lessonId])).teacherPaySnapshot),30);
+ await apply(payload('SilverCoin',20),'scenario-60');await finish(9002,'SilverCoin',1);assert.equal((await bal('student')).SilverCoin,19);assert.equal((await bal('student')).UserPoint,38.5);
+ const lines=await query(`SELECT * FROM xs_member_ledger WHERE "lessonId"=$1`,[r.lessonId]);assert.equal(lines.length,2);assert.ok(lines.every(l=>l.kind==='lesson'));
+ }finally{await db.exec('ROLLBACK');}
+});
+test('体验预留只能一次,取消释放后可重新预约',async()=>{
+ await db.exec("BEGIN;SELECT set_config('xiaoshu.credit_write','yes',true)");try{await db.exec(`UPDATE "TrialEligibility" SET status='eligible' WHERE company='co' AND "studentId"=20;UPDATE "_User" SET "legacyUserData"=jsonb_set("legacyUserData",'{UserExp}','1') WHERE "objectId"='student'`);
+ const reserve=id=>query(`INSERT INTO "CourseCreditReservation"("objectId",company,"studentId","appointmentId","creditCosts",state) VALUES($1,'co',20,$1,'[{"account":"UserExp","amount":1}]','reserved')`,[id]);await reserve('trialhold1');
+ await db.exec('SAVEPOINT second_trial');await assert.rejects(reserve('trialhold2'),/预留|体验/);await db.exec('ROLLBACK TO SAVEPOINT second_trial');
+ 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');}
+});

Некоторые файлы не были показаны из-за большого количества измененных файлов