migrate-word-audio.mjs 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051
  1. #!/usr/bin/env node
  2. import { readFileSync } from 'node:fs';
  3. import { randomBytes } from 'node:crypto';
  4. import { request as httpsRequest } from 'node:https';
  5. const sql=readFileSync(new URL('./sql/word-audio.sql',import.meta.url),'utf8');
  6. if(!process.argv.includes('--apply')) {
  7. console.log('只读检查:单词读音缓存迁移已就绪。--apply 创建服务端缓存与用量表,不生成音频。');
  8. process.exit(0);
  9. }
  10. const master=process.env.XIAOSHU_MASTER_KEY;
  11. if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY');
  12. const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,'');
  13. const appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
  14. async function request(path,options={}) {
  15. const res=await fetch(parseUrl+path,{...options,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}});
  16. const data=await res.json();if(!res.ok||data.error)throw new Error(data.error||data.message||data.retmsg||`HTTP ${res.status}`);return data;
  17. }
  18. function invokeMigration(url,token) {
  19. return new Promise((resolve,reject)=>{const body=JSON.stringify({token,params:{}}),req=httpsRequest(url,{method:'POST',headers:{'Content-Type':'application/json','Content-Length':Buffer.byteLength(body)}},(res)=>{let raw='';res.setEncoding('utf8');res.on('data',(chunk)=>raw+=chunk);res.on('end',()=>{try{resolve({ok:Number(res.statusCode)>=200&&Number(res.statusCode)<300,data:JSON.parse(raw)})}catch(error){reject(error)}});});req.on('error',reject);req.write(body);req.end();});
  20. }
  21. // Register additive Parse fields before SQL, so schema metadata and physical columns agree.
  22. const knownSchemas=(await request('/schemas')).results||[];
  23. const permissions={find:{},count:{},get:{},create:{},update:{},delete:{},addField:{}};
  24. for(const [name,fields] of Object.entries({
  25. WordAudioCache:{company:{type:'String'},word:{type:'String'},phonetic:{type:'String'},voice:{type:'String'},status:{type:'String'},url:{type:'String'},audio:{type:'String'},lease:{type:'String'},leaseUntil:{type:'Date'},retryAfter:{type:'Date'},lastError:{type:'String'}},
  26. WordAudioAttempt:{company:{type:'String'},actor:{type:'String'},cacheId:{type:'String'},providerCall:{type:'Boolean'},status:{type:'String'}},
  27. })){
  28. const schema=knownSchemas.find(row=>row.className===name);
  29. const missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema?.fields?.[key]));
  30. await request(schema?'/schemas/'+name:'/schemas',{method:schema?'PUT':'POST',body:JSON.stringify({...(schema?{}:{className:name}),fields:missing,classLevelPermissions:permissions})});
  31. }
  32. const company=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];
  33. if(!company)throw new Error('未找到帐套');
  34. const suffix=Date.now()+'_'+randomBytes(5).toString('hex'),path='xiaoshu/system/word-audio-'+suffix;
  35. let userId='',functionId='';
  36. try {
  37. const sessions=await request('/classes/_Session?include=user&limit=200&order=-updatedAt');
  38. const selected=sessions.results.find(s=>s.sessionToken&&s.user?.isAdmin&&!s.user?.isDisabled&&new Date(s.expiresAt.iso)>new Date());
  39. if(!selected)throw new Error('没有可用管理员会话');
  40. const account={objectId:selected.user.objectId,sessionToken:selected.sessionToken};const authorizedId=account.objectId;
  41. const code=`async function handler(request,response){try{const current=request.user;if(!current)return response.status(401).json({success:false});await current.fetch({useMasterKey:true});if(Date.now()>${Date.now()+600000}||current.id!==${JSON.stringify(authorizedId)})return response.status(403).json({success:false});await Psql.tx(async tx=>{await tx.none("SET LOCAL lock_timeout = '5s'");await tx.none(${JSON.stringify(sql)});});const counts=await Psql.query('SELECT COUNT(*)::int AS cached FROM "WordAudioCache"');return response.json({success:true,counts});}catch(error){return response.status(500).json({success:false,message:String(error.message||error)});}}`;
  42. const fn=await request('/classes/Function',{method:'POST',body:JSON.stringify({name:path,path,code,type:'standalone',desc:'单词读音缓存一次性数据库迁移',enabled:true,params:[],paramList:[],respType:'json'})});functionId=fn.objectId;
  43. const res=await invokeMigration(parseUrl.replace(/\/parse$/,'/api/functions/')+path,account.sessionToken);
  44. if(!res.ok||!res.data.success)throw new Error(res.data.message||'迁移失败');console.log(JSON.stringify(res.data,null,2));
  45. } finally {
  46. // Cleanup failures are reported, including IDs, so no temporary privileged surface is silently left behind.
  47. const failures=[];
  48. if(functionId)try{await request('/classes/Function/'+functionId,{method:'DELETE'});}catch{failures.push('临时函数 '+functionId);}
  49. if(userId)try{await request('/users/'+userId,{method:'DELETE'});}catch{failures.push('临时管理员 '+userId);}
  50. if(failures.length)throw new Error('需要清理:'+failures.join('、'));
  51. }