| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217 |
- #!/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,rmSync} 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/mobile-teaching-flow-'+suffix);mkdirSync(fixtureDirectory,{recursive:true,mode:0o700});const statePath=join(fixtureDirectory,'fixture.json');
- const password=randomBytes(54).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{
- const existingSessions=(await parse('/classes/_Session?include=user&limit=1000&order=-updatedAt')).results;
- const authorizer=existingSessions.find(s=>s.user?.adminRoleKey==='super-admin'&&new Date(s.expiresAt.iso)>new Date());if(!authorizer)throw Error('缺少管理员会话');state.admin={objectId:authorizer.user.objectId,sessionToken:authorizer.sessionToken};
- 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 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 temporaryToken=(await parse('/login','POST',{username:'release_member_'+suffix,password})).sessionToken;
- const forced=await call('xiaoshu/app/gateway',temporaryToken,{action:'app_learning_overview'},true);expect(forced.status===403&&forced.raw.code==='PASSWORD_RESET_REQUIRED','临时密码禁止教学接口');
- const finalPassword=randomBytes(54).toString('base64url');await call('xiaoshu/app/gateway',temporaryToken,{action:'app_password_change',currentPassword:password,newPassword:finalPassword});
- const oldSession=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':appId,'X-Parse-Session-Token':temporaryToken}});expect(!oldSession.ok,'改密撤销原登录会话');
- const oldLogin=await call('xiaoshu/app/gateway','',{action:'user_login_passwd',name:'release_member_'+suffix,passwd:password},true);expect(oldLogin.status===401,'旧密码不能通过迁移覆盖新密码');
- const relogin=(await call('xiaoshu/app/gateway','',{action:'user_login_passwd',name:'release_member_'+suffix,passwd:finalPassword})).data;const studentToken=relogin.sessionToken;expect(studentToken?.startsWith('r:')&&!relogin.passwordResetRequired,'72位新密码登录成功并解除临时密码状态');
- 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().toLocaleDateString('sv-SE',{timeZone:'Asia/Shanghai'}),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',source:'database',rangeStart:date,rangeEnd:date})).data;expect(teacherView.schedules.some(a=>a.bindingId===word.bindingIds[0]&&a.courseCategoryKey==='word'),'教师端预约保留开课分类');
- const assisted=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'reading/create',studentId:student.legacyUserId,requestKey:randomUUID(),className:'SurveyItem',value:{type:'reading',title:'隔离老师带读',content:'A test article.',createOptions:{tpl:'test',params:{wordList:['test']}}}})).data;
- expect(assisted.learnerLegacyId===student.legacyUserId&&assisted.operatorId===coach.objectId,'老师带读归属所选学生并记录老师');
- const studentArticle=(await call('xiaoshu/app/gateway',studentToken,{action:'reading/get',className:'SurveyItem',objectId:assisted.objectId})).data;expect(studentArticle.objectId===assisted.objectId,'学生读取老师带读文章');
- const beforeVersion=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_live_versions',source:'database'})).data;
- await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:10,content:JSON.stringify({GeneralID:generalId})});
- const classroom=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:generalId})).data[0];
- expect(Array.isArray(classroom.allowedActions)&&typeof classroom.blockedReason==='string'&&typeof classroom.validToday==='boolean'&&Number.isFinite(Date.parse(classroom.serverNow)),'详情返回 allowedActions、blockedReason、validToday 和 serverNow');
- expect(classroom.allowedActions.includes('complete'),'当天可提前开始并允许结课');
- const unit=(Array.isArray(units[0].data)?units[0].data:[])[0];expect(!!unit?.NodeID,'测试课程有可学习单元');
- const progressBefore=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;
- const unitRequest={action:'app_lesson_unit_complete',orderId:String(generalId),bindingId:word.bindingIds[0],nodeId:String(unit.NodeID),requestKey:randomUUID()};
- const deniedUnit=await call('xiaoshu/app/gateway',empty.sessionToken,unitRequest,true);expect(deniedUnit.status===403,'非预约老师不能标记单元');
- const wrongUnit=await call('xiaoshu/app/gateway',coach.sessionToken,{...unitRequest,nodeId:'999999999999'},true);expect(wrongUnit.status===403,'其他课程单元不能标记');
- const completedUnit=(await call('xiaoshu/app/gateway',coach.sessionToken,unitRequest)).data;
- const retriedUnit=(await call('xiaoshu/app/gateway',coach.sessionToken,unitRequest)).data;
- expect(completedUnit.percent===100&&completedUnit.total>0&&completedUnit.learned===completedUnit.total&&retriedUnit.learned===completedUnit.learned,'整单元完成保存且重复点击幂等');
- const freshUnits=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'node_list',ifunit:1,pid:course.courseId,bindingId:word.bindingIds[0],userId:student.legacyUserId})).data;
- const freshUnit=freshUnits.find(u=>String(u.NodeID)===String(unit.NodeID));expect(freshUnit.yx_word===freshUnit.total&&freshUnit.process===100,'刷新读取真实词条进度仍为100%');
- const progressAfter=(await ops('ops/users/credit-accounts',{objectId:student.objectId})).data;
- expect(JSON.stringify(progressBefore.accounts)===JSON.stringify(progressAfter.accounts)&&progressBefore.transactions.length===progressAfter.transactions.length,'整单元标记不扣课时不增加流水');
- const beforeLesson=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_lesson_report',id:generalId})).data;
- expect(beforeLesson.records.length===0&&beforeLesson.antiForget.total===0&&!beforeLesson.feedback,'整单元标记不伪造课堂记录及抗遗忘');
- const vocabulary=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:52,nodeid:unit.NodeID,psize:30})).data;
- const external=(await call('xiaoshu/app/gateway',studentToken,{action:'content_list',modelId:52,orders:'NEWID()',psize:20,excludeWordIds:vocabulary.map(w=>w.GeneralID).join(',')})).data;
- expect(external.length>=3&&external.every(w=>!vocabulary.some(v=>v.GeneralID===w.GeneralID)),'随机干扰词接口排除本轮完整词表');
- const learnedWord=vocabulary[0];expect(Number(learnedWord?.GeneralID)>0,'读取真实词库内容用于隔离课堂');
- const lessonWords=vocabulary.slice(0,17);expect(lessonWords.length===17,'真实词库提供17个隔离学习词');
- let studyId,lastStudyRequest;for(const [index,range] of [[0,[0,9]],[1,[9,14]],[2,[14,17]]]){
- const batchId='smoke-round-'+index,words=lessonWords.slice(...range).map(w=>({GeneralID:w.GeneralID,Title:w.Title,check:1}));
- const request={action:'stu_record_update_v2',orderId:generalId,uid:student.legacyUserId,batchId,saveMode:'confirm',expectedVersion:0,requestKey:randomUUID(),addon:JSON.stringify({bindingId:word.bindingIds[0],xxqs:words})};
- lastStudyRequest=request;const saved=(await call('xiaoshu/app/gateway',coach.sessionToken,request)).data;studyId=saved.GeneralID;
- expect(saved.learned===range[1],'第'+(index+1)+'轮累计'+range[1]+'个词');
- const repeated=(await call('xiaoshu/app/gateway',coach.sessionToken,request)).data;expect(repeated.GeneralID===studyId&&repeated.learned===range[1],'第'+(index+1)+'轮重试幂等');
- }
- const feedback={action:'app_lesson_complete',orderId:generalId,expectedUpdatedAt:classroom.updatedAt,requestKey:randomUUID(),feedback:{contentSummary:'隔离教学验收',reviewStatus:'完成',reviewSummary:'复习已完成',trainingStatus:'正常进行',trainingSummary:'已完成本单元',homework:'无作业',noHomework:true,notes:'隔离账号验收',coachComment:'隔离老师点评'}};
- const pending={action:'stu_record_update_v2',orderId:generalId,uid:student.legacyUserId,batchId:'smoke-save-complete-race',saveMode:'draft',expectedVersion:0,requestKey:randomUUID(),addon:JSON.stringify({bindingId:word.bindingIds[0],xxqs:[{GeneralID:lessonWords[0].GeneralID,check:0}]})};
- const pendingSaved=(await call('xiaoshu/app/gateway',coach.sessionToken,pending)).data;
- const raceConfirm={...pending,saveMode:'confirm',expectedVersion:pendingSaved.batchVersion,requestKey:randomUUID(),addon:JSON.stringify({bindingId:word.bindingIds[0],xxqs:[{GeneralID:lessonWords[0].GeneralID,check:1}]})};
- const saveCompleteRace=await Promise.all([call('xiaoshu/app/gateway',coach.sessionToken,raceConfirm,true),call('xiaoshu/app/gateway',coach.sessionToken,feedback,true)]);
- expect(saveCompleteRace[0].status===200&&[200,409].includes(saveCompleteRace[1].status),'真实多连接保存与结课共享锁,不丢批次');
- const completed=await Promise.all([call('xiaoshu/app/gateway',coach.sessionToken,feedback),call('xiaoshu/app/gateway',coach.sessionToken,feedback)]);
- expect(completed.every(r=>r.data.status===30),'并发结课均返回已结课');
- const lateRetry=(await call('xiaoshu/app/gateway',coach.sessionToken,lastStudyRequest)).data;expect(lateRetry.learned===17&&lateRetry.GeneralID===studyId,'已成功批次在并发结课后重试仍返回原结果');
- const detail=(await call('xiaoshu/app/gateway',studentToken,{action:'e_order_detail',id:generalId})).data;
- expect(Number(detail[0]?.dszt)===30&&detail[0]?.feedback?.contentSummary==='隔离教学验收'&&detail[0]?.allowedActions.includes('review'),'学生评价前已结课且可读报告');
- const lessonReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;
- expect(lessonReport.feedback?.contentSummary==='隔离教学验收'&&Number(lessonReport.feedback?.vocabularyCount)===17,'新库报告使用已保存课堂成果');
- expect(lessonReport.records.length===1&&Number(lessonReport.records[0].learned)===17,'整节课只显示一条17词学习记录');
- expect(lessonReport.antiForget.total===10,'整节课生成10次抗遗忘');
- const source=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'app_reading_source',sourceStudyId:studyId,studentId:student.legacyUserId})).data;
- expect(source.wordIds.length===17&&source.wordList.length===17,'AI阅读服务器来源包含完整17词');
- const staleAi=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'reading/ai',sourceStudyId:studyId,sourceVersion:'stale',studentId:student.legacyUserId,prompt:'Do not generate on stale version.'},true);expect(staleAi.status===409,'旧课堂词表版本不能触发AI生成');
- if(process.env.XIAOSHU_SMOKE_AI==='1'){
- const article=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'reading/ai',sourceStudyId:studyId,sourceVersion:source.sourceVersion,studentId:student.legacyUserId,prompt:'Write one short English story for a student. Include every classroom target word below. Return the story only. Target words: '+source.wordList.join(', ')})).data;
- expect(typeof article.content==='string'&&article.content.length>50,'完整17词课堂来源真实AI请求成功返回文章(不保存、不覆盖历史文章)');
- }
- const reviewRows=await parse('/classes/MemoryPracticeRecord?'+new URLSearchParams({where:JSON.stringify({company,yhid:student.legacyUserId,orderId:Number(generalId)}),limit:'20'}));
- expect(reviewRows.results.length===10&&reviewRows.results.every(r=>Number(r.xxjlid)===Number(studyId)),'十次复习均指向完整课堂词表');
- const feedbackForeign=await call('xiaoshu/app/gateway',empty.sessionToken,feedback,true);expect(feedbackForeign.status===403||feedbackForeign.status===409,'其他用户无法结课');
- const review={action:'app_lesson_review',orderId:generalId,score:5,comment:'隔离学生评价',requestKey:randomUUID()};
- const firstReview=(await call('xiaoshu/app/gateway',studentToken,review)).data;
- const repeatedReview=(await call('xiaoshu/app/gateway',studentToken,{...review,score:1,comment:'不能覆盖首次评价'})).data;
- expect(Number(firstReview.score)===5&&Number(repeatedReview.score)===5,'评价仅首次生效');
- const editableDetail=(await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_detail',id:generalId})).data[0];
- expect(editableDetail.allowedActions.includes('editFeedback')&&!!editableDetail.feedback.revision,'已结课老师获得修改权限和精确报告版本');
- const amendment={action:'app_lesson_feedback_update',orderId:generalId,expectedUpdatedAt:editableDetail.feedback.revision,requestKey:randomUUID(),feedback:{...feedback.feedback,contentSummary:'隔离验收修改后的学习内容',coachComment:'修改后的点评',reviewStatus:'',trainingStatus:''}};
- const deniedEdit=await call('xiaoshu/app/gateway',studentToken,amendment,true);expect(deniedEdit.status===403||deniedEdit.status===409,'学生不能修改老师报告');
- const amended=await call('xiaoshu/app/gateway',coach.sessionToken,amendment);const amendedRetry=await call('xiaoshu/app/gateway',coach.sessionToken,amendment);
- expect(amended.data.saved&&amendedRetry.data.saved,'老师修改保存且重试幂等');
- const staleEdit=await call('xiaoshu/app/gateway',coach.sessionToken,{...amendment,requestKey:randomUUID()},true);expect(staleEdit.status===409,'旧报告版本拒绝覆盖新内容');
- const amendedReport=(await call('xiaoshu/app/gateway',studentToken,{action:'app_lesson_report',id:generalId})).data;
- expect(amendedReport.feedback.contentSummary==='隔离验收修改后的学习内容'&&amendedReport.feedback.coachComment==='修改后的点评','学生报告回读更新后的反馈');
- expect(Number(amendedReport.feedback.vocabularyCount)===17&&Number(amendedReport.order.studentReview.score)===5&&Number(amendedReport.order.dszt)===30,'修改保留学习成果、学生评价和结课状态');
- const deniedReview=await call('xiaoshu/app/gateway',empty.sessionToken,review,true);expect(deniedReview.status===403||deniedReview.status===409,'其他学生不能评价');
- const oldWrite=await call('xiaoshu/app/gateway',coach.sessionToken,{action:'e_order_update_v2',status:11,content:JSON.stringify({GeneralID:generalId})},true);expect(oldWrite.status===409,'旧客户端不能回退结课状态');
- const trace=(await ops('ops/lesson-feedback/detail',{targetId:appointment})).data;
- 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===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)]);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,'真实注销重复请求幂等');
- await mutate('ops/privacy/process',deletion.data.id,{version:deletion.data.version,status:'processing'});
- const deletionStatus=(await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_deletion_status'})).data;expect(deletionStatus.items.some(r=>r.id===deletion.data.id&&r.status==='processing'),'后台处理状态在学生端回读');
- await call('xiaoshu/app/gateway',empty.sessionToken,{action:'app_logout'});const revoked=await fetch(base+'/parse/users/me',{headers:{'X-Parse-Application-Id':appId,'X-Parse-Session-Token':empty.sessionToken}});expect(!revoked.ok,'退出后原token服务端已撤销');
- 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');rmSync(statePath,{force:true});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 "PracticeRecord" WHERE company=$1 AND yhid=ANY($2::numeric[])',[co,ids]],
- ['DELETE FROM "MobileTeachingCommand" WHERE company=$1 AND "actorId"=ANY($2::text[])',[co,objects]],
- ['DELETE FROM \"LessonStudyBatch\" WHERE company=$1 AND \"studentId\"=ANY($2::numeric[])',[co,ids]],
- ['DELETE FROM \"LessonStudyCommand\" WHERE company=$1 AND actor=ANY($2::text[])',[co,objects]],
- ['DELETE FROM \"AccountDeletionRequest\" WHERE company=$1 AND \"userId\"=ANY($2::text[])',[co,objects]],
- ['DELETE FROM "SurveyLog" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
- ['DELETE FROM "SurveyItem" WHERE "learnerLegacyId"=ANY($1::numeric[])',[ids]],
- ['DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($2::text[])',[co,texts]],
- ['DELETE FROM "LessonCompletionReport" WHERE "company"=$1 AND "appointmentGeneralId" IN (SELECT c."generalId" FROM "CommonModel" c JOIN "CourseAppointment" a ON a."company"=c."company" AND a."id"=c."itemId" WHERE c."company"=$1 AND c."modelId"=54 AND a."szyh"::text=ANY($2::text[]))',[co,texts]],
- ['DELETE FROM "LessonWorkflowCommand" WHERE company=$1 AND actor=ANY($2::text[])',[co,objects]],
- ...['LessonReview','LessonWorkflowIssue'].map(t=>['DELETE FROM "'+t+'" WHERE company=$1 AND "appointmentGeneralId" IN (SELECT c."generalId"::text FROM "CommonModel" c JOIN "CourseAppointment" a ON a.company=c.company AND a.id=c."itemId" WHERE c.company=$1 AND c."modelId"=54 AND a.szyh::text=ANY($2::text[]))',[co,texts]]),
- ['DELETE FROM "CommonModel" c USING "DailyStudyRecord" d WHERE c.company=$1 AND d.company=$1 AND c."modelId"=56 AND c."itemId"=d.id AND d."userId"=ANY($2::numeric[])',[co,ids]],
- ['DELETE FROM "DailyStudyRecord" WHERE company=$1 AND "userId"=ANY($2::numeric[])',[co,ids]],
- ['DELETE FROM "CommonModel" c USING "MemoryPracticeRecord" m WHERE c.company=$1 AND m.company=$1 AND c."modelId"=60 AND c."itemId"=m.id AND m.yhid=ANY($2::numeric[])',[co,ids]],
- ['DELETE FROM "MemoryPracticeRecord" WHERE company=$1 AND yhid=ANY($2::numeric[])',[co,ids]],
- ['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"->>\'uid\'=ANY($2::text[])',[co,texts]]];
- 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;
- }
|