migrate-member-credit-ledger.mjs 4.2 KB

123456789101112131415161718192021
  1. #!/usr/bin/env node
  2. import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
  3. import {randomUUID} from 'node:crypto';
  4. const sql=readFileSync(new URL('./sql/member-credit-ledger.sql',import.meta.url),'utf8');
  5. if(!process.argv.includes('--apply')){console.log('会员账户迁移就绪:成套充值、流水分组和一次性体验资格。使用 --apply 执行。');process.exit(0);}
  6. const master=process.env.XIAOSHU_MASTER_KEY,token=process.env.XIAOSHU_SYNC_OPERATOR_TOKEN||process.env.XIAOSHU_SESSION_TOKEN;
  7. if(!master||!token)throw Error('需要受保护环境中的主密钥和有效管理会话');
  8. const app=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main',base=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
  9. 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},body:body?JSON.stringify(body):undefined});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||'数据库请求失败');return d;}
  10. const sessions=(await parse('/classes/_Session?'+new URLSearchParams({where:JSON.stringify({sessionToken:token}),include:'user',limit:'2'}))).results;
  11. const s=sessions?.[0];if(sessions.length!==1||!['super-admin','ops-manager'].includes(s.user?.adminRoleKey)||s.user.isDisabled||s.user.isDeleted||new Date(s.expiresAt?.iso)<new Date())throw Error('管理会话不可用');
  12. const co=s.user.company?.objectId;if(!co)throw Error('缺少帐套');
  13. const backupDir=process.env.XIAOSHU_CREDIT_BACKUP_DIR;if(!backupDir)throw Error('必须指定私有备份目录');mkdirSync(backupDir,{recursive:true,mode:0o700});
  14. const path='xiaoshu/system/member-credit-migrate-'+randomUUID(),expires=Date.now()+600000;let id;
  15. try{
  16. const code=`async function handler(request,response){try{if(request.user?.id!==${JSON.stringify(s.user.objectId)}||Date.now()>${expires})return response.status(403).json({success:false});await request.user.fetch({useMasterKey:true});if(request.user.get('isDisabled')||request.user.get('isDeleted'))return response.status(403).json({success:false});const active=await Psql.one('SELECT count(*)::int n FROM "LegacyDirectRun" WHERE company=$1 AND status=$2 AND "leaseUntil">now()',[${JSON.stringify(co)},'running']);if(active.n)throw Error('夜间同步正在运行,暂缓迁移');if(request.body.params?.mode==='backup'){const tables=['UserExpDomP','UserSIcon','UserExpHis','UserUserPoint','CreditTransaction','CourseCreditReservation'],backup={};for(const name of tables)backup[name]=await Psql.query('SELECT * FROM \"'+name+'\" WHERE company=$1',[${JSON.stringify(co)}]);backup.accounts=await Psql.query('SELECT \"objectId\",\"legacyUserId\",\"legacyUserData\" FROM \"_User\" WHERE company=$1',[${JSON.stringify(co)}]);backup.routines=await Psql.query(\"SELECT proname,pg_get_functiondef(oid) definition FROM pg_proc WHERE pronamespace='public'::regnamespace AND prokind='f' AND proname LIKE 'xs_%'\");return response.json({success:true,data:backup});}await Psql.tx(async t=>{await t.any(\"SELECT pg_advisory_xact_lock(hashtext('xs-credit:'||$1))\",[${JSON.stringify(co)}]);await t.none(${JSON.stringify(sql)});});return response.json({success:true});}catch(e){return response.status(500).json({success:false,message:e.message});}}`;
  17. new Function(code);
  18. id=(await parse('/classes/Function','POST',{name:path,path,code,type:'standalone',enabled:true,params:[],paramList:[],respType:'json'})).objectId;
  19. const backupResponse=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{mode:'backup'}})}),backup=await backupResponse.json();if(!backupResponse.ok||!backup.success)throw Error(backup.message||'备份失败');writeFileSync(backupDir+'/before.json',JSON.stringify(backup.data),{mode:0o600});
  20. const response=await fetch(base.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token,params:{}})}),result=await response.json();if(!response.ok||!result.success)throw Error(result.message||'迁移失败');console.log('会员额度与流水迁移成功;余额和工资未重算。');
  21. }finally{if(id)await parse('/classes/Function/'+id,'DELETE');}