migrate-teacher-earnings.mjs 5.1 KB

1234567891011121314151617181920212223242526272829
  1. #!/usr/bin/env node
  2. import {teacherEarningsMigration} from './teacher-earnings-migration.mjs';
  3. import {mkdir,writeFile} from 'node:fs/promises';
  4. import {resolve} from 'node:path';
  5. import {createHash,randomUUID} from 'node:crypto';
  6. const sql=teacherEarningsMigration(),sha=createHash('sha256').update(sql).digest('hex');
  7. if(!process.argv.includes('--apply')){console.log(JSON.stringify({ready:true,applied:false,sha256:sha,transactionCount:1,prerequisites:['member-enrollment','mobile-teaching','lesson-study-batches','payroll-integrity'],message:'使用 --apply --backup-dir=私有绝对路径 执行;不自动纠正历史工资。'}));process.exit(0);}
  8. const backupArg=process.argv.find(a=>a.startsWith('--backup-dir='))?.slice(13),master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SESSION_TOKEN;
  9. if(!backupArg?.startsWith('/')||!master||!token)throw Error('需要私有绝对备份路径、主密钥及现有管理员会话,不自动创建账号');
  10. const base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
  11. async function parse(path,method='GET',body){const r=await fetch(base+path,{method,headers:{'Content-Type':'application/json','X-Parse-Application-Id':app,'X-Parse-Master-Key':master,'X-Parse-Session-Token':token},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'迁移访问失败');return d;}
  12. const user=await parse('/users/me');if(!['super-admin','ops-manager'].includes(user.adminRoleKey)||user.isDisabled||user.isDeleted||!user.company?.objectId)throw Error('有效管理员身份缺失');
  13. const name='xiaoshu/system/teacher-earnings-'+randomUUID(),expires=Date.now()+15*60000;let functionId;
  14. const backup=resolve(backupArg);await mkdir(backup,{recursive:true,mode:0o700});
  15. const tables=['CourseAppointment','LessonRecord','LessonCompletionReport','MemoryPracticeRecord','ReviewReward','ReviewRewardCorrection','PayrollBatch','PayrollLine','PayrollCommand'];
  16. const code=`async function handler(request,response){try{
  17. const current=request.user;if(!current||current.id!==${JSON.stringify(user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});await current.fetch({useMasterKey:true});if(current.get('isDisabled')||current.get('isDeleted')||!['super-admin','ops-manager'].includes(current.get('adminRoleKey')))return response.status(403).json({success:false});
  18. const active=await Psql.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2 AND "leaseUntil">now()',[${JSON.stringify(user.company.objectId)},'running']);if(active.n)throw Error('夜间同步正在运行,暂缓迁移');
  19. const mode=request.body.params.mode;
  20. if(mode==='backup'){const snapshot=await Psql.tx(async t=>{await t.none('SET TRANSACTION ISOLATION LEVEL REPEATABLE READ READ ONLY');const out={};for(const table of ${JSON.stringify(tables)}){const exists=await t.one('SELECT to_regclass($1) present',['"'+table+'"']);if(exists.present)out[table]=await t.query('SELECT to_jsonb(x) AS row FROM "'+table+'" x');}out.functions=await t.query("SELECT proname,pg_get_functiondef(oid) AS definition FROM pg_proc WHERE pronamespace='public'::regnamespace AND (proname LIKE 'xs_%' OR proname LIKE 'xiaoshu_%')");out.columns=await t.query('SELECT * FROM information_schema.columns WHERE table_schema=$1',['public']);return out;});return response.json({success:true,snapshot});}
  21. if(mode!=='apply'||request.body.params.sha256!==${JSON.stringify(sha)})throw Error('迁移版本不匹配');await Psql.none(${JSON.stringify(sql)});
  22. const readback=await Psql.one('SELECT EXISTS(SELECT 1 FROM information_schema.columns WHERE table_name=$1 AND column_name=$2) AS "feedbackStatus",to_regprocedure($3) IS NOT NULL AS "reviewCompletion",to_regprocedure($4) IS NOT NULL AS correction',['LessonCompletionReport','feedbackStatus','xs_review_complete_result(text,text,text)','xs_review_reward_correct(text,text,text,text,text,integer)']);return response.json({success:true,readback,sha256:${JSON.stringify(sha)}});
  23. }catch(e){return response.status(500).json({success:false,message:String(e.message||e)});}}`;
  24. async function call(mode){const r=await fetch(base.replace(/\/parse$/,'/api/functions/')+name,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{mode,sha256:sha}})});const d=await r.json();if(!r.ok||!d.success)throw Error(d.message||'迁移失败');return d;}
  25. try{
  26. functionId=(await parse('/classes/Function','POST',{name,path:name,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
  27. const data=await call('backup');await writeFile(backup+'/before.json',JSON.stringify(data.snapshot),{mode:0o600,flag:'wx'});await writeFile(backup+'/migration.sql',sql,{mode:0o600,flag:'wx'});
  28. const result=await call('apply');await writeFile(backup+'/result.json',JSON.stringify(result,null,2),{mode:0o600,flag:'wx'});if(!Object.values(result.readback).every(Boolean))throw Error('迁移回读不完整,禁止发布网关');console.log(JSON.stringify({applied:true,backup,sha256:sha,readback:result.readback}));
  29. }finally{if(functionId)await parse('/classes/Function/'+functionId,'DELETE');}