#!/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 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; 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 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,'完整目录存在可开内容'); 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===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)]);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,'退款和预留并发互斥'); 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]], ['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]]]; 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" 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; }