| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148 |
- 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];
- const post=async(kind,target,amount,idem,account='Purse',source='')=>(await one('SELECT xs_credit_post(\'co\',\'admin\',$1,$2,$3,$4,$5,\'测试入账\',\'转账\',$6) AS result',[kind,target,account,amount,source,idem])).result;
- 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" 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);
- });
- after(()=>db.close());
- test('总部入账、双方划拨、幂等与原请求校验',async()=>{
- await post('fund','store',10,'fund-00001');await post('fund','store',10,'fund-00001');assert.equal(await balance('store'),10);
- 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'),/关联/);
- });
- test('退回原门店,重复退款和数量校验',async()=>{
- const tx=await post('transfer','student',2,'transfer-02');await post('refund','student',1,'refund-001','Purse',tx.objectId);
- assert.equal(await balance('student'),5);await assert.rejects(post('refund','student',2,'refund-002','Purse',tx.objectId),/可退/);
- await assert.rejects(post('fund','store',0.5,'fraction-01'),/整数/);await assert.rejects(post('refund','student',1,'unlinked-01','Purse','unknown'),/原划拨单/);
- });
- test('零余额不能开课,多账户规则全部满足',async()=>{
- await assert.rejects(enroll('word','no-credit-1',21),/课时不足/);
- await assert.rejects(enroll('word','no-point-01',20,100,[[{account:'Purse',amount:1},{account:'UserPoint',amount:0.5}]]),/课时不足/);
- await post('fund','store',5,'fund-point-01','UserPoint');await post('transfer','student',2,'transfer-point','UserPoint');
- });
- test('同词表跨分类可开,同分类幂等且不扣课时,正确栏目和自动数量',async()=>{
- const before=await balance('student');await enroll('word','enroll-word-01');await enroll('word','enroll-word-01');await assert.rejects(enroll('word','enroll-word-02'),/已开课/);await enroll('word_self_study','enroll-self-01');
- const rows=await query('SELECT * FROM "CourseBinding" WHERE "yhid"=\'20\'');assert.equal(rows.length,2);assert.equal(Number(rows[0].cksl),200);assert.equal(await balance('student'),before);
- assert.equal((await one('SELECT count(*)::int AS n FROM "CommonModel" WHERE "modelId"=58 AND "nodeId"=28 AND "tableName"=\'ZL_C_kcbd\'')).n,2);
- await assert.rejects(enroll('word','enroll-word-01',20,101),/请求编号/);
- });
- async function appointment(id,binding,cost=1){await query('INSERT INTO "CourseAppointment" ("objectId","company","id","szyh","kcid","pl","dszt","dslx","bindingId","creditCostsSnapshot") VALUES ($1,\'co\',$2,\'20\',\'100\',\'30\',\'10\',\'1\',$3,$4::jsonb)',[id,Number(id.replace(/\D/g,'')),binding,JSON.stringify([{account:'Purse',amount:cost}])]);await query('INSERT INTO "CommonModel" ("objectId","company","modelId","generalId","itemId","status") VALUES ($1,\'co\',54,$2,$2,99)',['c'+id,Number(id.replace(/\D/g,''))]);}
- test('预约必须明确分类,预留阻止超额退款和重复预留',async()=>{
- const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
- await appointment('a1001',b,4);await appointment('a1002','',4);
- await assert.rejects(query('SELECT xs_validate_appointment(\'co\',\'ca1002\')'),/唯一/);
- await query('INSERT INTO "CourseCreditReservation" ("objectId","company","studentId","appointmentId","creditCosts","state","consumedAt","updatedAt") VALUES (\'r1\',\'co\',20,\'ca1001\',\'[{"account":"Purse","amount":4}]\',\'reserved\',NULL,now())');
- await assert.rejects(query('INSERT INTO "CourseCreditReservation" ("objectId","company","studentId","appointmentId","creditCosts","state","consumedAt","updatedAt") VALUES (\'r2\',\'co\',20,\'ca1002\',\'[{"account":"Purse","amount":4}]\',\'reserved\',NULL,now())'),/不足/);
- const tx=(await one('SELECT "objectId" FROM "CreditTransaction" WHERE "idempotencyKey"=\'transfer-01\'')).objectId;
- await assert.rejects(post('refund','student',2,'reserved-refund','Purse',tx),/预留/);
- await assert.rejects(query('SELECT xs_enrollment_status(\'co\',$1,false,\'admin\',\'取消测试\')',[b]),/未结束/);
- });
- test('完课单事务扣减、消费预留和学习事实,重试不重复',async()=>{
- const before=await balance('student');const done=(await one('SELECT xs_complete_appointment(\'co\',\'ca1001\',\'teacher\') AS r')).r;
- assert.equal(done.periodDeducted,true);assert.equal(await balance('student'),before-4);
- const again=(await one('SELECT xs_complete_appointment(\'co\',\'ca1001\',\'teacher\') AS r')).r;assert.equal(again.idempotent,true);assert.equal(await balance('student'),before-4);
- assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "objectId"=\'r1\'')).state,'consumed');
- assert.equal((await one('SELECT count(*)::int AS n FROM "LessonRecord"')).n,1);
- });
- test('迁移可重复运行,已有分类和学习进度不重置',async()=>{
- await query('UPDATE "CourseBinding" SET "yxx"=50 WHERE "categoryKey"=\'word\'');await db.exec(sql);assert.equal(Number((await one('SELECT "yxx" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).yxx),50);
- });
- const scheduleEntry=(binding,start,cost=1)=>({common:{title:'学员单词课',subtitle:'词表'},addon:{szyh:'20',kcid:'100',yykcid:'100',bindingId:binding,courseCategoryKey:'word',courseCategoryName:'单词课',durationMinutes:30,deliveryMode:'online',teachingRuleId:'rule',teacherPaySnapshot:15,creditCostsSnapshot:[{account:'Purse',amount:cost}],pl:'30',fxpl:'30',plxm:'老师',dslx:'1',dszt:'0',jffs:'线上',bxrq:start.slice(0,10),sdsd:start.slice(11),yysj:start}});
- const saveSchedule=async(entries,key,edit='',expected='')=>(await one('SELECT xs_schedule_save(\'co\',\'admin\',$1::jsonb,$2::jsonb,$3,$4,$5) AS r',[JSON.stringify({entries,edit}),JSON.stringify(entries),key,edit,expected])).r;
- test('排课保存、修改、取消恢复均同步预留,重试不重复',async()=>{
- const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
- const entry=scheduleEntry(b,'2030-01-01 10:00');const saved=await saveSchedule([entry],'schedule-001');const again=await saveSchedule([entry],'schedule-001');assert.deepEqual(again,saved);
- const aid=saved.targetIds[0];assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
- await assert.rejects(saveSchedule([scheduleEntry(b,'2030-01-01 10:10')],'schedule-conflict'),/冲突/);
- await saveSchedule([scheduleEntry(b,'2030-01-01 11:00')],'schedule-update',aid);
- await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\')',[aid]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'released');
- await query('SELECT xs_schedule_cancel(\'co\',$1,true,\'admin\',\'测试恢复\')',[aid]);assert.equal((await one('SELECT "state" FROM "CourseCreditReservation" WHERE "appointmentId"=$1',[aid])).state,'reserved');
- await query('SELECT xs_schedule_cancel(\'co\',$1,false,\'admin\',\'测试取消\')',[aid]);
- });
- test('批量排课超余额整体回滚,不留下半张预约或预留',async()=>{
- const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
- const before=(await one('SELECT count(*)::int AS n FROM "CourseAppointment"')).n;
- await assert.rejects(saveSchedule([scheduleEntry(b,'2030-02-01 10:00'),scheduleEntry(b,'2030-02-02 10:00')],'schedule-rollback'),/不足/);
- assert.equal((await one('SELECT count(*)::int AS n FROM "CourseAppointment"')).n,before);
- });
- test('余额更新不能被旧资料覆盖,空课程开课事务回滚',async()=>{
- await assert.rejects(query('UPDATE "_User" SET "legacyUserData"=\'{"Purse":999}\' WHERE "objectId"=\'student\''),/业务单/);
- await query('UPDATE "_User" SET "legacyUserData"="legacyUserData"||\'{"HoneyName":"新名字"}\' WHERE "objectId"=\'student\'');
- await assert.rejects(enroll('trial','enroll-empty-01',20,101),/内容为空/);
- });
- test('双流水或审计失败时总部入账与业务单全部回滚',async()=>{
- const before=await balance('store');
- await db.exec(`CREATE FUNCTION xs_test_audit_fail() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN RAISE EXCEPTION 'audit failure';END $$;CREATE TRIGGER xs_test_audit_fail BEFORE INSERT ON "SysLog" FOR EACH ROW EXECUTE FUNCTION xs_test_audit_fail();`);
- await assert.rejects(post('fund','store',2,'fund-audit-fail'),/audit failure/);assert.equal(await balance('store'),before);
- assert.equal((await one('SELECT count(*)::int AS n FROM "CreditTransaction" WHERE "idempotencyKey"=\'fund-audit-fail\'')).n,0);
- await db.exec('DROP TRIGGER xs_test_audit_fail ON "SysLog"');
- });
- test('最后一节课的竞争提交仅一张预约成功,完课后重试原开课仍成功',async()=>{
- const b=(await one('SELECT "objectId" FROM "CourseBinding" WHERE "categoryKey"=\'word\'')).objectId;
- const results=await Promise.allSettled([saveSchedule([scheduleEntry(b,'2031-01-01 10:00')],'last-credit-01'),saveSchedule([scheduleEntry(b,'2031-01-02 10:00')],'last-credit-02')]);
- assert.equal(results.filter(r=>r.status==='fulfilled').length,1);const aid=results.find(r=>r.status==='fulfilled').value.targetIds[0];
- await query('SELECT xs_start_appointment(\'co\',$1)',[aid]);assert.equal((await one('SELECT "dszt" FROM "CourseAppointment" a JOIN "CommonModel" c ON c."itemId"=a."id" AND c."modelId"=54 WHERE c."objectId"=$1',[aid])).dszt,'10');
- await query('SELECT xs_complete_appointment(\'co\',$1,\'teacher\')',[aid]);assert.equal(await balance('student'),0);
- const retry=await enroll('word','enroll-word-01');assert.equal(retry.count,1);
- });
- test('运营网关开课排课与教师网关完课联调,共用同一事务与分类快照',async()=>{
- const {default:vm}=await import('node:vm');const source=readFileSync(new URL('../deploy-admin-functions.mjs',import.meta.url),'utf8');
- const c={Date,Psql:{query,one,oneOrNone:one,none:query}};vm.createContext(c);vm.runInContext(source.match(/const operationsGatewayCode = String.raw`([\s\S]*?)\n`;/)[1]+readFileSync(new URL('../cloud/member-enrollment.js',import.meta.url),'utf8'),c);
- c.companyIdOf=()=> 'co';c.requireRole=()=>{};c.teachingCategoryRows=async()=>vm.runInContext('TEACHING_CATEGORY_DEFAULTS',c);c.teachingRuleRows=async()=>c.defaultTeachingRules();c.allAppointmentRows=async()=>[];c.availabilityRows=async()=>[];
- await query('INSERT INTO "_User" VALUES (\'teacher\',\'co\',30,3,\'coach\',false,\'{}\',now())');
- await post('fund','store',1,'integration-fund','UserExp');await post('transfer','student',1,'integration-transfer','UserExp');
- const context={current:{id:'admin'}},result=await c.saveEnrollment(context,{reason:'联调开课',idempotencyKey:'integration-enroll'},{studentId:20,categoryKey:'trial',courseIds:[100]});
- assert.equal(result.count,1);const bid=result.bindingIds[0];
- const saved=await c.saveEnrollmentSchedule(context,{reason:'联调排课',idempotencyKey:'integration-schedule'},{studentId:20,coachId:30,bindingId:bid,courseId:100,durationMinutes:60,deliveryMode:'online',date:'2032-01-01',startTime:'10:00',recurrence:'once',occurrences:1});
- const aid=saved.targetIds[0],row=await one('SELECT a.*,c."generalId" FROM "CourseAppointment" a JOIN "CommonModel" c ON c."itemId"=a."id" AND c."modelId"=54 WHERE c."objectId"=$1',[aid]);assert.equal(row.courseCategoryKey,'trial');assert.equal(Number(row.teacherPaySnapshot),30);
- const app={Date,Psql:{query,one,oneOrNone:one,none:query},DEFAULT_COMPANY_ID:'co',number:Number,fail:(_,msg)=>{throw Error(msg);},memoryReviewsForOrder:async()=>({count:0,created:[]})};vm.createContext(app);vm.runInContext(readFileSync(new URL('../cloud/member-enrollment-app.js',import.meta.url),'utf8'),app);
- await app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,10);await app.enrollmentOrderTransition({}, {id:'teacher'},row,row.generalId,11);
- assert.equal(await balance('student','UserExp'),0);const lesson=await one('SELECT * FROM "LessonRecord" WHERE "bindingId"=$1',[bid]);assert.equal(lesson.courseCategoryKey,'trial');
- c.appointmentPair=async()=>({common:{id:aid,get:key=>key==='company'?{objectId:'co'}:null}});
- const repeat=await c.completeAppointment(context,{targetId:aid,reason:'联调重试'});assert.equal(repeat.idempotent,true);assert.equal((await one('SELECT count(*)::int AS n FROM "UserExpHis" WHERE "transactionId"=$1',['complete:'+aid])).n,1);
- });
- test('回收保留进度,恢复重新校验余额并保留原分类',async()=>{
- const b=await one('SELECT * FROM "CourseBinding" WHERE "categoryKey"=\'word\'');
- await query('UPDATE "CommonModel" SET "status"=-2 WHERE "objectId"=\'ca1002\'');
- await query('SELECT xs_enrollment_status(\'co\',$1,false,\'admin\',\'回收测试\')',[b.objectId]);
- const costs=JSON.stringify([[{account:'Purse',amount:1}]]);
- await assert.rejects(query('SELECT xs_enrollment_status(\'co\',$1,true,\'admin\',\'恢复测试\',$2::jsonb)',[b.objectId,costs]),/课时不足/);
- await post('transfer','student',1,'restore-transfer');
- await query('SELECT xs_enrollment_status(\'co\',$1,true,\'admin\',\'恢复测试\',$2::jsonb)',[b.objectId,costs]);
- const restored=await one('SELECT * FROM "CourseBinding" WHERE "objectId"=$1',[b.objectId]);assert.equal(restored.enrollmentStatus,'active');assert.equal(restored.categoryKey,'word');assert.equal(Number(restored.yxx),50);assert.equal(await balance('student'),1);
- });
- test('已有唯一约束下重复迁移,冲突历史绑定保留进度并标记待确认',async()=>{
- await db.exec(`INSERT INTO "CourseTeachingProfile" VALUES ('co',100,'word',true);
- INSERT INTO "CourseBinding" ("objectId","company","id","yhid","kcid","yxx") VALUES ('history1','co',901,'20','100',5),('history2','co',902,'20','100',7);
- INSERT INTO "CommonModel" ("objectId","company","generalId","itemId","modelId","nodeId","tableName","status") VALUES ('hc1','co',901,901,58,100,'ZL_C_yhkcbd',99),('hc2','co',902,902,58,100,'ZL_C_yhkcbd',99);`);
- await db.exec(sql);await db.exec(sql);
- const history=await query('SELECT * FROM "CourseBinding" WHERE "objectId" IN (\'history1\',\'history2\') ORDER BY "objectId"');assert.deepEqual(history.map(r=>r.enrollmentStatus),['pending','pending']);assert.deepEqual(history.map(r=>Number(r.yxx)),[5,7]);
- assert.equal(Number((await one('SELECT "nodeId" FROM "CommonModel" WHERE "objectId"=\'hc1\'')).nodeId),28);
- });
|