| 123456789101112131415161718192021222324252627282930313233343536373839 |
- import{randomBytes,randomUUID}from'node:crypto';import{writeFileSync,mkdirSync}from'node:fs';
- const co='7pIbDBJmKx',base='https://server.xiaoshu.pro',app='7pIbDBJmKx_main',h={'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':process.env.XIAOSHU_MASTER_KEY},suffix=Date.now(),uid=990000000+suffix%1000000,users=[],functions=[],results=[];let helper;
- async function parse(path,method='GET',data){const r=await fetch(base+'/parse'+path,{method,headers:h,...(data?{body:JSON.stringify(data)}:{})});const d=await r.json();if(!r.ok)throw Error(path.split('?')[0]+': '+(d.error||d.message||d.retmsg||r.status));return d;}
- async function call(token,params,path='xiaoshu/app/gateway'){const r=await fetch(base+'/api/functions/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params}),signal:AbortSignal.timeout(55000)});const d=await r.json();return{status:r.status,...d};}
- function check(ok,label){results.push({label,passed:!!ok});console.log((ok?'PASS ':'FAIL ')+label);if(!ok)throw Error(label);}
- try{
- const sessions=(await parse('/classes/_Session?include=user&limit=200&order=-updatedAt')).results;const admin=sessions.find(s=>s.user?.isAdmin&&new Date(s.expiresAt.iso)>new Date());if(!admin)throw Error('no admin');
- for(const [kind,group,id]of [['student',1,uid],['teacher',3,uid+1]]){const u=await parse('/users','POST',{username:'teachcheck_'+suffix+'_'+kind,password:randomBytes(24).toString('base64url'),company:{__type:'Pointer',className:'Company',objectId:co},legacyGroupId:group,legacyUserId:id,identityType:kind==='student'?'member':'coach',realName:'教学隔离验收',legacyUserData:{UserID:id,GroupID:group,ParentUserID:0,Purse:0,SilverCoin:0,UserExp:0,UserPoint:0}});users.push({...u,uid:id});writeFileSync('/tmp/mobile-fixture-ids.json',JSON.stringify(users.map(({objectId,uid})=>({objectId,uid}))));}
- const student=users[0],teacher=users[1];check(!!student.sessionToken&&!!teacher.sessionToken,'隔离学生及老师会话创建');
- const words=(await parse('/classes/CommonModel?where='+encodeURIComponent(JSON.stringify({modelId:52,status:99}))+'&limit=1&keys=generalId,nodeId')).results;const word=words[0];
- const input={action:'e_add_words',uid:student.uid,wordsId:String(word.generalId),kcid:word.nodeId,save:1,requestKey:randomUUID()};const repeated=await Promise.all([call(student.sessionToken,input),call(student.sessionToken,input),call(student.sessionToken,input)]);console.log(repeated.map(r=>({status:r.status,msg:r.retmsg})));check(repeated.every(r=>r.status===200),'单词学习并发提交返回成功');
- const practices=(await parse('/classes/PracticeRecord?where='+encodeURIComponent(JSON.stringify({yhid:student.uid}))+'&limit=100')).results;check(practices.length===1&&Number(practices[0].xxcs)===1,'并发及重复请求只增加一次学习次数');
- const study={action:'content_add_zt',requestKey:randomUUID(),content:JSON.stringify({ModelID:56,nodeId:291,title:'隔离验收'}),addon:JSON.stringify({UserID:student.uid,createReviewPlan:false,dqrq:'20260910',xxqs:JSON.stringify([{GeneralID:word.generalId,Title:'test',check:1}])})};const first=await call(student.sessionToken,study);console.log({studyStatus:first.status,msg:first.retmsg});const again=await call(student.sessionToken,study);check(first.status===200&&first.result===again.result,'学习记录双表原子保存及网络重试幂等');
- const unit=await call(student.sessionToken,{action:'unit_record_update',requestKey:randomUUID(),userId:student.uid,bookId:word.nodeId,nids:String(word.nodeId),wordIds:JSON.stringify({[word.nodeId]:String(word.generalId)})});check(unit.status===200,'单元进度归属学生并保存');
- const assessment={action:'content_add',requestKey:randomUUID(),content:JSON.stringify({ModelID:61,nodeId:389,title:'隔离测评'}),addon:JSON.stringify({UserID:student.uid,df:1})};const exam=await call(student.sessionToken,assessment),examAgain=await call(student.sessionToken,assessment);check(exam.status===200&&exam.result===examAgain.result,'测评保存和重复提交幂等');
- // Verify the deployed reward trigger inside a subtransaction, then roll back the entire fixture.
- const rewardPath='xiaoshu/system/teaching-reward-check-'+suffix,taskId='reward-fixture-'+suffix;
- const rewardSql=`DO $check$ DECLARE n int; amount numeric; BEGIN BEGIN
- INSERT INTO "MemoryPracticeRecord"("objectId","createdAt","updatedAt","company","sourceKey","id","yhid","plid","xxjlid","kcid","kywrq","kywsj","fxzt") VALUES ('${taskId}',now(),now(),'${co}','${taskId}',${uid+5},${student.uid},${teacher.uid},${Number(first.result)},${word.nodeId},'2026-09-10','2026-09-10 10:00','0');
- PERFORM xiaoshu_complete_review('${co}','${taskId}');PERFORM xiaoshu_complete_review('${co}','${taskId}');
- UPDATE "MemoryPracticeRecord" SET "fxzt"='1',"wcsj"='2026-10-01 00:00:00' WHERE "objectId"='${taskId}';
- SELECT count(*),sum(r."amount") INTO n,amount FROM "ReviewReward" r WHERE "company"='${co}' AND "taskId"='${taskId}' AND "status"='earned' AND "incomeMonth"=to_char(now() AT TIME ZONE 'Asia/Shanghai','YYYY-MM');
- RAISE EXCEPTION USING ERRCODE='ZX001',MESSAGE='rollback teaching fixture';EXCEPTION WHEN SQLSTATE 'ZX001' THEN NULL;END;
- IF n<>1 OR amount<>1 THEN RAISE EXCEPTION 'reward verification failed'; END IF;
- IF EXISTS(SELECT 1 FROM "ReviewReward" WHERE "taskId"='${taskId}') THEN RAISE EXCEPTION 'fixture rollback failed';END IF; END $check$;`;
- const rewardFn=await parse('/classes/Function','POST',{name:rewardPath,path:rewardPath,type:'standalone',enabled:true,params:[],paramList:[],respType:'json',code:`async function handler(request,response){if(request.user?.id!==${JSON.stringify(admin.user.objectId)})return response.status(403).json({});await Psql.none(${JSON.stringify(rewardSql)});return response.json({success:true});}`});
- try{const checked=await call(admin.sessionToken,{},rewardPath);console.log({rewardStatus:checked.status,error:checked.retmsg||checked.message});check(checked.success,'线上抗遗忘首次完成仅计薪一次、跨月重试不改月份(事务回滚验收)');}finally{await parse('/classes/Function/'+rewardFn.objectId,'DELETE');}
- const foreign=await call(teacher.sessionToken,{...input,requestKey:randomUUID()});check(foreign.status===403,'未关联老师不能修改学生学习记录');
- const article={action:'reading/create',className:'SurveyItem',requestKey:randomUUID(),value:{type:'reading',title:'隔离阅读验收',content:'test content',createOptions:{tpl:'test',params:{wordList:['test']}}}};const a=await call(student.sessionToken,article),ar=await call(student.sessionToken,article);console.log({readingStatus:a.status,msg:a.retmsg});check(a.status===200&&a.result?.objectId===ar.result?.objectId,'阅读保存及重试幂等');
- check(a.result?.operatorId===student.objectId&&a.result?.learnerLegacyId===student.uid,'阅读归属和操作者由服务端固定');
- const bad=await call(teacher.sessionToken,{action:'reading/get',className:'SurveyItem',objectId:a.result.objectId,studentId:student.uid});check(bad.status===403,'阅读跨学员权限拒绝');
- const credit=await call(student.sessionToken,{action:'app_credit_overview',uid:student.uid});check(credit.status===200&&credit.result.accounts.length===4&&credit.result.accounts.every(a=>a.balance===0),'四类课时读取独立账户');
- }finally{
- // Only remove rows owned by these disposable fixture IDs. Their credentials are never logged.
- const sessions=(await parse('/classes/_Session?include=user&limit=200&order=-updatedAt')).results;const admin=sessions.find(s=>s.user?.isAdmin&&new Date(s.expiresAt.iso)>new Date());
- if(users.length&&admin){const ids=users.map(u=>u.uid),objects=users.map(u=>u.objectId),path='xiaoshu/system/teaching-clean-'+suffix;const code=`async function handler(request,response){if(request.user?.id!==${JSON.stringify(admin.user.objectId)})return response.status(403).json({});await Psql.none('DELETE FROM "CommonModel" WHERE "company"=$1 AND "modelId"=56 AND "itemId" IN (SELECT "id" FROM "DailyStudyRecord" WHERE "company"=$1 AND "userId"=ANY($2::numeric[]));DELETE FROM "CommonModel" WHERE "company"=$1 AND "modelId"=61 AND "itemId" IN (SELECT "id" FROM "AssessmentProfile" WHERE "company"=$1 AND "userId"::text=ANY($3::text[]));DELETE FROM "AssessmentProfile" WHERE "company"=$1 AND "userId"::text=ANY($3::text[]);DELETE FROM "UnitLearningRecord" WHERE "legacyUserId"=ANY($2::numeric[]);DELETE FROM "DailyStudyRecord" WHERE "company"=$1 AND "userId"=ANY($2::numeric[]);DELETE FROM "PracticeRecord" WHERE "company"=$1 AND "yhid"=ANY($2::numeric[]);DELETE FROM "MobileTeachingCommand" WHERE "company"=$1 AND "actorId"=ANY($3::text[]);DELETE FROM "SurveyLog" WHERE "operatorId"=ANY($4::text[]);DELETE FROM "SurveyItem" WHERE "operatorId"=ANY($4::text[])',[${JSON.stringify(co)},${JSON.stringify(ids)},${JSON.stringify(ids.map(String))},${JSON.stringify(objects)}]);return response.json({success:true});}`;const fn=await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'});try{const cleaned=await call(admin.sessionToken,{},path);console.log({cleanupStatus:cleaned.status,error:cleaned.retmsg||cleaned.message});check(cleaned.success,'隔离业务数据清理');}finally{await parse('/classes/Function/'+fn.objectId,'DELETE');}}
- for(const u of users)await parse('/users/'+u.objectId,'DELETE');
- mkdirSync('tmp/mobile-teaching-verification',{recursive:true});writeFileSync('tmp/mobile-teaching-verification/write-audit.json',JSON.stringify({date:new Date().toISOString(),results},null,2));
- }
|