Procházet zdrojové kódy

fix(members): deploy enrollment transactions with production schema compatibility

彭峰 před 2 týdny
rodič
revize
8ed75746e9

+ 43 - 0
docs/migration/member-enrollment-release-2026-09-09.md

@@ -0,0 +1,43 @@
+# 用户管理与按分类开课上线验收(2026-09-09)
+
+## 发布结果
+
+生产数据库 `server.xiaoshu.pro` 已完成用户开课迁移。运营主网关、运营 V3 网关、管理网关、App 网关均已发布;发布后读取服务器代码核对 SHA-256,与本地定义一致。
+
+管理端与移动端生产构建通过,本地管理端登录页可打开。仓库没有公网静态站点托管配置,尚未发布或验证公网前端页面;本次线上结果指数据库与云函数,以及真实网关请求验收。
+
+## 备份、迁移和修复
+
+- 原始业务备份保存在受限目录 `tmp/member-enrollment-1788941878957/`;包括账号余额、开课、预约、预留、分类映射、内容关联及目录。发布前云函数和 schema 另有本机受限备份,未提交凭据或业务数据到 Git。
+- 安装课时事务、开课及预约函数、账户保护触发器、有效开课唯一约束和幂等约束。
+- 按生产物理类型补齐用户编号的显式 numeric 转换;审计写入原有 SysLog 字段及必填 sourceKey;Parse 流水 schema 补齐 payMethod。事务回归测试覆盖这些生产差异。
+- 预检兼容 Parse 返回的字符串形式数字,避免把已有模型 58 关联误判为缺失。
+- 一笔既有 `-1` 节余额已核对至 2024-06-02 的原始管理员流水。通过逐项声明保留欠课,未调整为零、未生成退款归属;没有预留超额或重复预留。
+
+迁移后共有 1,514 条历史开课记录:216 条有效、1,298 条待确认。待确认包含未确定新分类或关联缺失的记录;预检发现 254 条孤立关联。没有自动合并、删除学习进度或猜测分类。待确认记录需要完成分类和关联核对后才能继续按新流程排课。
+
+## 真实服务器验收
+
+`npm run members:smoke` 的 25 项断言通过,使用一次性门店、教师、学员及管理员,调用已发布的运营与 App 网关:
+
+- 新增学员并关联门店;四类账户总部入账和门店划拨,重复请求不重复记账。
+- 零余额实际开课请求被拒绝;六个分类可选,语法写作内容按学阶限制。
+- 同词表分别开单词课、自学课;同分类重复开课拒绝,原请求重试返回同一记录。
+- 用户端返回两个独立开课分类;同词表单元和词汇进度查询一致;教师预约携带对应 bindingId 和分类。
+- 旧排课请求缺少 bindingId 且存在分类歧义时返回 409。
+- 教师开始、完课与管理员重试只扣一次;取消释放和恢复预留成功。
+- 服务器独立 HTTP 请求并发验证:最后一节课仅一个预约成功;退款与预留仅一方成功;充值与完课均成功且余额没有覆盖。
+- 门店及学员四类账户余额与所有对应流水逐项对账;全部课时业务单都有事务审计。
+
+`npm run ops:smoke` 单独运行通过,覆盖现有会员、门店、陪练、日历、教学标准、工资及学习报表。旧冒烟用例已改为传具体 bindingId 并识别新的未开课提示。
+
+发布后再次读取生产数据,与迁移前备份比较:真实账号四类余额变更数 0,历史学习进度变更数 0,残留测试账号 0、临时函数 0、测试预留 0。测试业务行已清理,操作审计保留。
+
+## 本地验证
+
+- 用户开课数据库及网关测试:23 项通过,包含生产编号类型和审计约束。
+- 管理端 Chrome Headless:171 项通过。
+- 云函数语法:11 个定义通过。
+- 管理端与移动端生产构建通过。
+
+服务器验收没有改动真实教学规则;规则更新不重算既有预约快照的验证由本地事务与网关测试覆盖。本记录不代表公网前端或真实学员人工体验验收。

+ 4 - 2
docs/migration/member-enrollment.md

@@ -24,7 +24,7 @@
 
 1. 本地执行 `npm run members:migrate -- --validate` 检查迁移文件;不连接服务器。
 2. 在已配置 `XIAOSHU_MASTER_KEY` 的发布环境执行 `npm run members:migrate`。默认只读,导出受限权限的 `tmp/member-enrollment-*/before.json` 和 `report.json`,记录 SQL 摘要。自定义环境可设置 XIAOSHU_PARSE_URL、XIAOSHU_PARSE_APP_ID。
-3. 核对完整数据库备份和迁移报告。报告包含分类缺失、孤立绑定、错误内容栏目、重复绑定、账户异常与重复预留。负余额、超额或重复预留会阻止 apply;历史流水不推断退款来源。
+3. 核对完整数据库备份和迁移报告。报告包含分类缺失、孤立绑定、错误内容栏目、重复绑定、账户异常与重复预留。负余额、超额或重复预留会阻止 apply;历史流水不推断退款来源。已核对原始流水的历史欠课,可用 `--acknowledged-deficits=/受限路径/deficits.json` 单独声明:数组项包括 objectId、account、准确 balance 和 evidence。仅允许保留没有预留的既有负数;不会改写余额或放过超额预留。
 4. 安排停止业务写入的发布窗口,执行 `npm run members:migrate -- --apply`。它先备份,再补 Parse schema,执行确定性修复和约束安装,最后清理临时迁移函数和临时账号。
 5. 部署运营与 App 云函数,再发布管理端和移动端构建。SQL schema 必须先于新网关。禁止重新启用旧账号、开课和预约同步。
 6. 用隔离测试门店和学员完成线上受控验收:总部入账、划拨、跨分类开同词表、各规则排课、取消恢复、开始完课、原单退回;核对账户、业务单、流水和工资快照。
@@ -41,4 +41,6 @@
 - `npm run test:review-rewards`:受影响的完课与工资奖励回归。
 - `npm run cloud:validate`、`npm run build`:云函数语法及管理端、移动端生产构建。
 
-本次工作环境没有 XIAOSHU_MASTER_KEY,未执行生产预检、数据修改、部署或真实账号联调。生产多连接事务验收与受控业务验收须在发布环境完成;本地验证结果不代表已经上线。
+2026-09-09 已使用受限发布凭据完成生产备份、迁移及四个网关发布;真实隔离账户验收见 [上线验收记录](member-enrollment-release-2026-09-09.md)。管理端与移动端生产构建已完成,公网静态站点发布仍需明确托管地址。
+
+`npm run members:smoke` 创建隔离门店、教师和学员,在目标服务器执行真实业务、并发 HTTP 请求与对账,最终清理测试业务行、临时函数和账号,保留审计记录。需要 XIAOSHU_MASTER_KEY;切换环境可设置 XIAOSHU_SERVER_URL、XIAOSHU_PARSE_APP_ID、XIAOSHU_COMPANY_ID。隔离状态文件仅写入权限受限的 tmp 目录,清理失败时根据文件定位残留资源。不要与会比较全站人数的 `ops:smoke` 同时运行。

+ 2 - 1
package.json

@@ -45,7 +45,8 @@
     "test:review-rewards": "node --test scripts/tests/review-rewards.test.mjs",
     "review-rewards:migrate": "node scripts/migrate-review-rewards.mjs",
     "test:member-enrollment": "node --test scripts/tests/member-enrollment.test.mjs scripts/tests/member-enrollment-gateway.test.mjs",
-    "members:migrate": "node scripts/migrate-member-enrollment.mjs"
+    "members:migrate": "node scripts/migrate-member-enrollment.mjs",
+    "members:smoke": "node scripts/smoke-member-enrollment.mjs"
   },
   "private": true,
   "dependencies": {

+ 1 - 1
scripts/member-enrollment-schema.mjs

@@ -4,5 +4,5 @@ export const enrollmentSchemas={
  LessonRecord:{bindingId:S,courseCategoryKey:S,courseCategoryName:S,teacherPaySnapshot:N,creditCostsSnapshot:A},DailyStudyRecord:{bindingId:S},
  CreditTransaction:{company:C,idempotencyKey:S,request:O,kind:S,account:S,amount:N,storeId:N,studentId:N,sourceTransactionId:S,operatorId:S,reason:S,payMethod:S,balances:O},
  EnrollmentCommand:{company:C,idempotencyKey:S,request:O,result:O},
- ...Object.fromEntries(['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'].map(t=>[t,{transactionId:S}]))
+ ...Object.fromEntries(['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'].map(t=>[t,{transactionId:S,payMethod:S}]))
 };

+ 5 - 2
scripts/migrate-member-enrollment.mjs

@@ -18,7 +18,7 @@ backup.tables.CourseAppointment=await rows('CourseAppointment');
 const categories=new Set(['word','trial','primary_writing','middle_writing','high_writing','word_self_study']);
 const profiles=new Map(backup.tables.CourseTeachingProfile.filter(p=>p.confirmed&&categories.has(p.categoryKey)).map(p=>[(p.company?.objectId||p.company)+':'+p.courseId,p.categoryKey]));
 const report={bindings:backup.tables.CourseBinding.length,missingCategory:0,missingContentRecord:0,incorrectContentMetadata:0,duplicateGroups:0,reservations:backup.tables.CourseCreditReservation.length};const groups=new Map();
-for(const b of backup.tables.CourseBinding){const co=b.company?.objectId||b.company,cat=b.categoryKey||profiles.get(co+':'+b.kcid);if(!categories.has(cat))report.missingCategory++;const c=backup.tables.CommonModel.find(c=>(c.company?.objectId||c.company)===co&&c.modelId===58&&c.itemId===b.id);if(!c)report.missingContentRecord++;else if(c.nodeId!==28||c.tableName!=='ZL_C_kcbd')report.incorrectContentMetadata++;if(c?.status!==-2&&cat){const k=[co,b.yhid,cat,b.kcid].join(':');groups.set(k,(groups.get(k)||0)+1);}}
+for(const b of backup.tables.CourseBinding){const co=b.company?.objectId||b.company,cat=b.categoryKey||profiles.get(co+':'+b.kcid);if(!categories.has(cat))report.missingCategory++;const c=backup.tables.CommonModel.find(c=>(c.company?.objectId||c.company)===co&&Number(c.modelId)===58&&String(c.itemId)===String(b.id));if(!c)report.missingContentRecord++;else if(Number(c.nodeId)!==28||c.tableName!=='ZL_C_kcbd')report.incorrectContentMetadata++;if(Number(c?.status)!==-2&&cat){const k=[co,b.yhid,cat,b.kcid].join(':');groups.set(k,(groups.get(k)||0)+1);}}
 report.duplicateGroups=[...groups.values()].filter(n=>n>1).length;
 const companyOf=r=>r.company?.objectId||r.company;
 report.orphanBindings=[];report.accountIssues=[];report.duplicateReservations=[];
@@ -27,7 +27,10 @@ const reserved=new Map(),appointments=new Map();
 for(const r of backup.tables.CourseCreditReservation.filter(r=>r.state==='reserved')){const co=companyOf(r),key=co+':'+r.appointmentId;appointments.set(key,(appointments.get(key)||0)+1);for(const cost of r.creditCosts||[]){const k=[co,r.studentId,cost.account].join(':');reserved.set(k,(reserved.get(k)||0)+Number(cost.amount));}}
 report.duplicateReservations=[...appointments].filter(([,count])=>count>1).map(([key,count])=>({key,count}));
 for(const u of backup.tables._User)for(const account of ['Purse','SilverCoin','UserExp','UserPoint']){const balance=Number(u.legacyUserData?.[account]||0),hold=reserved.get([companyOf(u),u.legacyUserId,account].join(':'))||0;if(!Number.isFinite(balance)||balance<0||hold>balance)report.accountIssues.push({objectId:u.objectId,account,balance,reserved:hold});}
-report.requiresManualReconciliation=report.accountIssues.length>0||report.duplicateReservations.length>0;
+const acknowledgedPath=process.argv.find(a=>a.startsWith('--acknowledged-deficits='))?.split('=').slice(1).join('=');
+const acknowledged=acknowledgedPath?JSON.parse(readFileSync(acknowledgedPath,'utf8')):[];
+report.preservedHistoricalDeficits=report.accountIssues.filter(issue=>Number.isFinite(issue.balance)&&issue.balance<0&&issue.reserved===0&&acknowledged.some(a=>a.objectId===issue.objectId&&a.account===issue.account&&a.balance===issue.balance&&a.evidence));
+report.requiresManualReconciliation=report.accountIssues.length>report.preservedHistoricalDeficits.length||report.duplicateReservations.length>0;
 const dir=resolve(process.argv.find(a=>a.startsWith('--backup-dir='))?.slice(13)||'tmp/member-enrollment-'+Date.now());mkdirSync(dir,{recursive:true,mode:0o700});writeFileSync(join(dir,'before.json'),JSON.stringify(backup,null,2),{mode:0o600});writeFileSync(join(dir,'report.json'),JSON.stringify(report,null,2),{mode:0o600});console.log(JSON.stringify({report,backupDirectory:dir,apply},null,2));
 if(!apply)process.exit(0);
 if(report.requiresManualReconciliation)throw Error('预检发现负余额、超额预留或重复预留;请先核对 report.json 后再迁移');

+ 82 - 0
scripts/smoke-member-enrollment.mjs

@@ -0,0 +1,82 @@
+#!/usr/bin/env node
+// Creates disposable users in the target company, verifies real transactions, then removes all fixture business rows.
+import {randomBytes,randomUUID} from 'node:crypto';
+import {writeFileSync,mkdirSync} from 'node:fs';
+import {resolve,join} from 'node:path';
+const co=process.env.XIAOSHU_COMPANY_ID||'7pIbDBJmKx',base=(process.env.XIAOSHU_SERVER_URL||'https://server.xiaoshu.pro').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',suffix=Date.now(),state={users:[],co,admin:null,helper:null};
+if(!process.env.XIAOSHU_MASTER_KEY)throw Error('缺少 XIAOSHU_MASTER_KEY');
+const fixtureDirectory=resolve('tmp/member-enrollment-smoke-'+suffix);mkdirSync(fixtureDirectory,{recursive:true,mode:0o700});const statePath=join(fixtureDirectory,'fixture.json');
+const password=randomBytes(24).toString('base64url'),company={__type:'Pointer',className:'Company',objectId:co};
+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 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;
+const code=helperCode(state);
+const fn=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});state.helper={...fn,path};persist();}
+try{
+ state.admin=await user('member',1,{isAdmin:true,adminRoleKey:'super-admin',roles:['admin','super-admin','ops-manager']});
+ 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,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();
+ 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),'四类课时门店划拨后学员余额一致');
+ 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,'完整目录存在可开内容');
+ const key=randomUUID(),payload={studentId:student.legacyUserId,categoryKey:'word',courseIds:[course.courseId]};const word=(await mutate('ops/course-bindings/save','',payload,key)).data;const retry=(await mutate('ops/course-bindings/save','',payload,key)).data;expect(word.bindingIds[0]===retry.bindingIds[0],'重复开课请求幂等');
+ const self=(await mutate('ops/course-bindings/save','',{...payload,categoryKey:'word_self_study'})).data;expect(self.bindingIds[0]!==word.bindingIds[0],'同词表跨分类分别开课');
+ const zeroSave=await mutate('ops/course-bindings/save','',{...payload,studentId:empty.legacyUserId},randomUUID(),true);expect(zeroSave.status===409,'零余额实际开课请求被拒绝');
+ const duplicate=await mutate('ops/course-bindings/save','',payload,randomUUID(),true);expect(duplicate.status===409,'同分类重复开课被拒绝');
+ const studentToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
+ const learning=(await call('xiaoshu/app/gateway',studentToken,{action:'app_learning_overview'})).data;expect(learning.courses.length===2&&new Set(learning.courses.map(c=>c.categoryKey)).size===2,'用户端分别展示两个开课分类');
+ const units=await Promise.all([word,self].map(b=>call('xiaoshu/app/gateway',studentToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:b.bindingIds[0]})));expect(JSON.stringify(units[0].data)===JSON.stringify(units[1].data),'同词表跨分类沿用共享词汇进度');
+ const date=new Date(Date.now()+10*86400000).toISOString().slice(0,10),draft={studentId:student.legacyUserId,coachId:coach.legacyUserId,courseId:course.courseId,durationMinutes:60,deliveryMode:'online',date,startTime:'10:00',recurrence:'once',occurrences:1};
+ const ambiguous=await ops('ops/appointments/preflight',{payload:draft},true);expect(ambiguous.status===409,'旧排课请求遇分类歧义明确拒绝');
+ const appointment=(await mutate('ops/appointments/create','',{...draft,bindingId:word.bindingIds[0]})).data.targetIds[0];
+ const appData=await parse('/classes/CommonModel/'+appointment);const generalId=appData.generalId;
+ 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 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 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,'退款和预留并发互斥');
+ 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 snapshot=(await call(state.helper.path,state.admin.sessionToken,{mode:'counts'})).data;console.log('ENROLLMENT_COUNTS',snapshot);console.log('ONLINE_SMOKE_PASSED');
+}catch(e){console.error('ONLINE_SMOKE_FAILED',e.message);process.exitCode=1;}
+finally{let clean=true;if(state.helper)try{await call(state.helper.path,state.admin.sessionToken,{mode:'cleanup'});}catch(e){console.error('CLEANUP_FAILED',e.message);clean=false;}if(clean){if(state.helper)await parse('/classes/Function/'+state.helper.objectId,'DELETE');for(const u of [...state.users].reverse())await parse('/users/'+u.objectId,'DELETE');console.log('FIXTURES_CLEANED');}else{console.error('隔离资源待清理,受限状态文件:'+statePath);process.exitCode=1;}}
+
+function helperCode(state){
+const ids=state.users.map(u=>u.legacyUserId),objects=state.users.map(u=>u.objectId),texts=ids.map(String),co=state.co,admin=state.admin.objectId;
+const statements=[
+['DELETE FROM "CourseCreditReservation" WHERE "company"=$1 AND "studentId"=ANY($2::numeric[])',[co,ids]],
+['DELETE FROM "CommonModel" c USING "LessonRecord" l WHERE c."company"=$1 AND l."company"=$1 AND c."modelId"=59 AND c."itemId"=l."id" AND l."xymz"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "LessonRecord" WHERE "company"=$1 AND "xymz"::text=ANY($2::text[])',[co,texts]],
+['DELETE FROM "CommonModel" c USING "CourseAppointment" a WHERE c."company"=$1 AND a."company"=$1 AND c."modelId"=54 AND c."itemId"=a."id" AND a."szyh"::text=ANY($2::text[])',[co,texts]],
+['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]],
+...['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]]];
+const ledgerTables=['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint'],accounts=['Purse','SilverCoin','UserExp','UserPoint'];
+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]]};
+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;
+}

+ 3 - 3
scripts/smoke-operations-functions.mjs

@@ -94,12 +94,12 @@ try {
   if (schedule.courses.some((course) => !Array.isArray(course.memberIds)) || !schedule.courses.some((course) => course.memberIds.length > 0)) throw new Error('排课课程没有返回会员绑定范围');
   const memberWithUnboundCourse = schedule.members.filter((member) => Number(member.userId) > 0 && !member.readOnly).map((member) => ({ member, course: schedule.courses.find((course) => Number(course.courseId) > 0 && course.courseId !== 398 && !course.memberIds.includes(member.userId)) })).find((item) => item.course);
   if (memberWithUnboundCourse) {
-    const deniedCourse = await ops({ operation: 'ops/appointments/preflight', payload: { studentId: memberWithUnboundCourse.member.userId, courseId: memberWithUnboundCourse.course.courseId, coachId: 0, classType: 1, date: schedule.dataDateTo || today, startTime: '09:00', recurrence: 'once', occurrences: 1 } }, 409);
-    if (!String(deniedCourse.message || '').includes('未绑定到当前会员')) throw new Error('排课预检没有阻止选择其他会员的课程');
+    const deniedCourse = await ops({ operation: 'ops/appointments/preflight', payload: { studentId: memberWithUnboundCourse.member.userId, courseId: memberWithUnboundCourse.course.courseId, bindingId: memberWithUnboundCourse.course.bindingId, coachId: 0, classType: 1, date: schedule.dataDateTo || today, startTime: '09:00', recurrence: 'once', occurrences: 1 } }, 409);
+    if (!/尚未有效开课/.test(String(deniedCourse.message || ''))) throw new Error('排课预检没有阻止选择其他会员的课程');
   }
   const preflightPair = schedule.members.filter((member) => Number(member.userId) > 0 && !member.readOnly).flatMap((member) => schedule.courses.filter((course) => Number(course.courseId) > 0 && course.mappingConfirmed && (course.availableToAll === true || course.memberIds.includes(member.userId))).map((course) => { const classType = course.categoryKey === 'trial' && Number(member.trialPeriods) >= 2 ? 3 : course.supportedDurations?.includes(30) && Number(member.periods30) >= 2 && Number(member.userPoint) >= 1 ? 1 : course.supportedDurations?.includes(60) && Number(member.periods60) >= 2 && Number(member.userPoint) >= 2 ? 2 : 0; return { member, course, classType }; })).find((item) => item.classType);
   if (preflightPair) {
-    const preflight = (await ops({ operation: 'ops/appointments/preflight', payload: { studentId: preflightPair.member.userId, courseId: preflightPair.course.courseId, coachId: 0, classType: preflightPair.classType, date: '2099-01-05', startTime: '09:00', recurrence: 'weekly', occurrences: 2 } })).data;
+    const preflight = (await ops({ operation: 'ops/appointments/preflight', payload: { studentId: preflightPair.member.userId, courseId: preflightPair.course.courseId, bindingId: preflightPair.course.bindingId, coachId: 0, classType: preflightPair.classType, date: '2099-01-05', startTime: '09:00', recurrence: 'weekly', occurrences: 2 } })).data;
     if (!Array.isArray(preflight.candidates) || !Array.isArray(preflight.conflicts) || !Array.isArray(preflight.conflictIndexes) || typeof preflight.issueCount !== 'number' || preflight.availableCount + preflight.conflictCount !== preflight.candidates.length || preflight.conflictCount !== preflight.conflictIndexes.length) throw new Error('批量排课逐项预检结构或按课次去重统计无效');
   }
   if (!schedule.totalAppointments || !schedule.dataDateFrom || !schedule.dataDateTo) throw new Error(`历史排课总量或数据日期范围缺失:${JSON.stringify({ keys: Object.keys(schedule), totalAppointments: schedule.totalAppointments, dataDateFrom: schedule.dataDateFrom, dataDateTo: schedule.dataDateTo })}`);

+ 8 - 8
scripts/sql/member-enrollment.sql

@@ -88,18 +88,18 @@ BEGIN
  IF kind='transfer' AND COALESCE(NULLIF(u."legacyUserData"->>'ParentUserID',''),'0')::numeric<>st."legacyUserId" THEN RAISE EXCEPTION '所属门店已变更,请重新提交';END IF;
  PERFORM set_config('xiaoshu.credit_write','yes',true);
  sb=COALESCE((st."legacyUserData"->>account)::numeric,0);ub=COALESCE((u."legacyUserData"->>account)::numeric,0);
- IF kind='transfer' AND sb-xs_reserved(co,st."legacyUserId",account)<amount THEN RAISE EXCEPTION '门店可用课时不足'; END IF;
- IF kind='refund' AND ub-xs_reserved(co,u."legacyUserId",account)<amount THEN RAISE EXCEPTION '学员可退课时不足,已使用或已预留课时不能退回'; END IF;
+ IF kind='transfer' AND sb-xs_reserved(co,st."legacyUserId"::numeric,account)<amount THEN RAISE EXCEPTION '门店可用课时不足'; END IF;
+ IF kind='refund' AND ub-xs_reserved(co,u."legacyUserId"::numeric,account)<amount THEN RAISE EXCEPTION '学员可退课时不足,已使用或已预留课时不能退回'; END IF;
  delta=CASE WHEN kind='transfer' THEN -amount ELSE amount END;
  UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[account],to_jsonb(sb+delta)),"updatedAt"=now() WHERE "objectId"=st."objectId";
- PERFORM xs_ledger(co,st."legacyUserId",account,delta,sb,tx,actor,reason,method);
+ PERFORM xs_ledger(co,st."legacyUserId"::numeric,account,delta,sb,tx,actor,reason,method);
  IF kind<>'fund' THEN
   UPDATE "_User" SET "legacyUserData"=jsonb_set(COALESCE("legacyUserData",'{}'),ARRAY[account],to_jsonb(ub-delta)),"updatedAt"=now() WHERE "objectId"=u."objectId";
-  PERFORM xs_ledger(co,u."legacyUserId",account,-delta,ub,tx,actor,reason,method);
+  PERFORM xs_ledger(co,u."legacyUserId"::numeric,account,-delta,ub,tx,actor,reason,method);
  END IF;
  result=jsonb_build_object('objectId',tx,'company',co,'createdAt',now(),'updatedAt',now(),'idempotencyKey',idem,'request',req,'kind',kind,'account',account,'amount',amount,'storeId',st."legacyUserId",'studentId',CASE WHEN kind='fund' THEN NULL ELSE u."legacyUserId" END,'sourceTransactionId',original,'operatorId',actor,'reason',reason,'payMethod',method,'balances',jsonb_build_object('storeBefore',sb,'storeAfter',sb+delta,'studentBefore',ub,'studentAfter',CASE WHEN kind='fund' THEN NULL ELSE ub-delta END));
  PERFORM xs_insert('CreditTransaction',result);
- PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'userId',actor,'logType','operations-admin','remind','[Operations] credit-'||kind||' CreditTransaction/'||tx||',原因:'||reason,'detail',result::text,'content',result::text));
+ PERFORM xs_insert('SysLog',jsonb_build_object('objectId',xs_object_id(),'company',co,'createdAt',now(),'updatedAt',now(),'sourceKey','credit:'||tx,'id',nextval('xiaoshu_business_id'),'cdate',now(),'cname','课时业务','type1','operations-admin','type2','credit-'||kind,'type3','CreditTransaction','clevel',0,'cadminId',(SELECT "legacyUserId" FROM "_User" WHERE "objectId"=actor),'detail',result::text,'content1',reason,'content2',actor));
  RETURN result;
 END $$;
 
@@ -171,7 +171,7 @@ BEGIN
   IF COALESCE(jsonb_array_length(NEW."creditCosts"::jsonb),0)=0 THEN RAISE EXCEPTION '预约缺少有效课时';END IF;
   IF u."objectId" IS NULL OR COALESCE(u."isDisabled",false) THEN RAISE EXCEPTION '学员不存在或已停用'; END IF;
   FOR cost IN SELECT value FROM jsonb_array_elements(NEW."creditCosts"::jsonb) LOOP
-   IF NOT (cost->>'account')=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR (cost->>'amount')::numeric<=0 OR COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(NEW."company",NEW."studentId",cost->>'account',COALESCE(NEW."appointmentId",''))<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足,不能重复预留';END IF;
+   IF NOT (cost->>'account')=ANY(ARRAY['Purse','SilverCoin','UserExp','UserPoint']) OR (cost->>'amount')::numeric<=0 OR COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(NEW."company",NEW."studentId"::numeric,cost->>'account',COALESCE(NEW."appointmentId",''))<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足,不能重复预留';END IF;
   END LOOP;
  END IF;
  RETURN NEW;
@@ -218,7 +218,7 @@ BEGIN
  IF COALESCE(a."creditHoldStatus",'ready')<>'ready' THEN RAISE EXCEPTION '历史预约课时待处理';END IF;
  IF jsonb_array_length(COALESCE(a."creditCostsSnapshot"::jsonb,'[]'))=0 THEN RAISE EXCEPTION '预约缺少课时快照';END IF;
  FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
-  IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId",cost->>'account',aid)<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足'; END IF;
+  IF COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0)-xs_reserved(co,u."legacyUserId"::numeric,cost->>'account',aid)<(cost->>'amount')::numeric THEN RAISE EXCEPTION '可用课时不足'; END IF;
  END LOOP;
  RETURN u."objectId";
 END $$;
@@ -244,7 +244,7 @@ BEGIN
   FOR cost IN SELECT value FROM jsonb_array_elements(a."creditCostsSnapshot"::jsonb) LOOP
    before_value=COALESCE((u."legacyUserData"->>(cost->>'account'))::numeric,0);
    u."legacyUserData"=jsonb_set(u."legacyUserData",ARRAY[cost->>'account'],to_jsonb(before_value-(cost->>'amount')::numeric));
-   PERFORM xs_ledger(co,u."legacyUserId",cost->>'account',-(cost->>'amount')::numeric,before_value,tx,actor,'完课扣除课时');
+   PERFORM xs_ledger(co,u."legacyUserId"::numeric,cost->>'account',-(cost->>'amount')::numeric,before_value,tx,actor,'完课扣除课时');
   END LOOP;
   UPDATE "_User" SET "legacyUserData"=u."legacyUserData","updatedAt"=now() WHERE "objectId"=u."objectId";
  END IF;

+ 10 - 9
scripts/tests/member-enrollment.test.mjs

@@ -9,20 +9,20 @@ const post=async(kind,target,amount,idem,account='Purse',source='')=>(await one(
 const balance=async(id,account='Purse')=>Number((await one('SELECT "legacyUserData"->>$2 AS b FROM "_User" WHERE "objectId"=$1',[id,account])).b||0);
 const enroll=async(category,idem,uid=20,course=100,costs=[[{account:'Purse',amount:1}]])=>(await one('SELECT xs_enroll(\'co\',\'admin\',$1,$2,$3::jsonb,$4::jsonb,\'测试开课\',$5) AS result',[uid,category,JSON.stringify([{courseId:course,courseName:'词表',wordCount:200,categoryName:category}]),JSON.stringify(costs),idem])).result;
 before(async()=>{
- await db.exec(`CREATE TABLE "_User" ("objectId" text PRIMARY KEY,"company" text,"legacyUserId" numeric,"legacyGroupId" numeric,"identityType" text,"isDisabled" boolean,"legacyUserData" jsonb,"updatedAt" timestamptz);
- CREATE TABLE "CourseBinding" ("objectId" text PRIMARY KEY,"company" text,"id" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"yhid" text,"kcid" text,"yxx" numeric,"cksl" numeric,"syjd" text);
- CREATE TABLE "CommonModel" ("objectId" text PRIMARY KEY,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"createTime" timestamptz,"sourceKey" text,"generalId" numeric,"itemId" numeric,"modelId" numeric,"nodeId" numeric,"tableName" text,"title" text,"subtitle" text,"status" numeric,"inputer" text);
- CREATE TABLE "CourseAppointment" ("objectId" text PRIMARY KEY,"company" text,"id" numeric,"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" numeric);
- CREATE TABLE "LessonRecord" ("objectId" text PRIMARY KEY,"company" text,"id" numeric,"createdAt" timestamptz,"updatedAt" timestamptz,"sourceKey" text,"xymz" text,"jsmz" text,"kcid" text,"kclx" text,"kcmc" text,"yyds" text,"jffs" text,"kzsj" text,"lessonAt" timestamptz);
+ 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" numeric,"categoryKey" text,"confirmed" boolean);
- CREATE TABLE "CourseCreditReservation" ("objectId" text PRIMARY KEY,"company" text,"studentId" numeric,"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" numeric,"score" numeric,"scoreBefore" numeric,"hisTime" timestamptz,"operator" numeric,"scoreType" numeric,"detail" text,"remark" text,"payMethod" 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,"company" text,"createdAt" timestamptz,"updatedAt" timestamptz,"userId" text,"logType" text,"remind" text,"detail" text,"content" text);
+ 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);
@@ -33,6 +33,7 @@ test('总部入账、双方划拨、幂等与原请求校验',async()=>{
  await assert.rejects(post('fund','store',9,'fund-00001'),/请求编号/);
  const tx=await post('transfer','student',4,'transfer-01');assert.equal(await balance('store'),6);assert.equal(await balance('student'),4);
  assert.equal((await one('SELECT count(*)::int AS n FROM "UserExpDomP" WHERE "transactionId"=$1',[tx.objectId])).n,2);
+ const audit=await one('SELECT * FROM "SysLog" WHERE "sourceKey"=$1',['credit:'+tx.objectId]);assert.equal(audit.type2,'credit-transfer');assert.equal(JSON.parse(audit.detail).objectId,tx.objectId);assert.equal(audit.content1,'测试入账');
  await assert.rejects(post('transfer','student',20,'transfer-fail'),/不足/);assert.equal(await balance('store'),6);assert.equal(await balance('student'),4);
  await assert.rejects(post('transfer','foreign',1,'foreign-01'),/不存在/);await assert.rejects(post('transfer','empty',1,'no-store-01'),/关联/);
 });