#!/usr/bin/env node import { readFileSync } from 'node:fs'; import { randomBytes } from 'node:crypto'; import { request as httpsRequest } from 'node:https'; const enrollment=readFileSync(new URL('./sql/member-enrollment.sql',import.meta.url),'utf8'); // Existing installations may predate whole-day appointment validation. const prerequisites=['xs_appointment_day','xs_appointment_valid_at','xs_start_appointment'].map(name=>{ const definition=enrollment.match(new RegExp('CREATE OR REPLACE FUNCTION '+name+'\\([\\s\\S]*?\\$\\$;'))?.[0]; if(!definition)throw Error('未找到课次流程依赖:'+name); return definition; }); const sql=prerequisites.join('\n')+'\n'+readFileSync(new URL('./sql/lesson-workflow.sql',import.meta.url),'utf8'); if(!process.argv.includes('--apply')) { console.log('只读检查:结课与评价迁移脚本已就绪。使用 --apply 执行;不修改历史教学或工资数据。'); process.exit(0); } const master=process.env.XIAOSHU_MASTER_KEY; if(!master)throw new Error('缺少 XIAOSHU_MASTER_KEY'); const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''); const appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main'; async function request(path,options={}) { const res=await fetch(parseUrl+path,{...options,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}}); const data=await res.json();if(!res.ok||data.error)throw new Error(data.error||data.message||data.retmsg||`HTTP ${res.status}`);return data; } function invokeMigration(url,token) { 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();}); } // Register additive Parse fields before SQL, so schema metadata and physical columns agree. for(const [name,fields] of Object.entries({ CommonModel:{teachingManaged:{type:'Boolean'}},CourseAppointment:{teachingManaged:{type:'Boolean'}}, LessonRecord:{teachingManaged:{type:'Boolean'}},LessonCompletionReport:{coachComment:{type:'String'}}, })){ const schema=await request('/schemas/'+name),missing=Object.fromEntries(Object.entries(fields).filter(([key])=>!schema.fields?.[key])); if(Object.keys(missing).length)await request('/schemas/'+name,{method:'PUT',body:JSON.stringify({fields:missing})}); } const company=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0]; if(!company)throw new Error('未找到帐套'); const suffix=Date.now()+'_'+randomBytes(5).toString('hex'),path='xiaoshu/system/lesson-workflow-'+suffix; let userId='',functionId=''; try { const sessions=await request('/classes/_Session?include=user&limit=200&order=-updatedAt'); const selected=sessions.results.find(s=>s.sessionToken&&s.user?.isAdmin&&!s.user?.isDisabled&&new Date(s.expiresAt.iso)>new Date()); if(!selected)throw new Error('没有可用管理员会话'); const account={objectId:selected.user.objectId,sessionToken:selected.sessionToken};const authorizedId=account.objectId; 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(current.id!==${JSON.stringify(authorizedId)})return response.status(403).json({success:false});await Psql.none(${JSON.stringify(sql)});const counts=await Psql.query('SELECT COUNT(*)::int AS commands FROM "LessonWorkflowCommand"');return response.json({success:true,counts});}catch(error){return response.status(500).json({success:false,message:String(error.message||error)});}}`; 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; const res=await invokeMigration(parseUrl.replace(/\/parse$/,'/api/functions/')+path,account.sessionToken); if(!res.ok||!res.data.success)throw new Error(res.data.message||'迁移失败');console.log(JSON.stringify(res.data,null,2)); } finally { // Cleanup failures are reported, including IDs, so no temporary privileged surface is silently left behind. const failures=[]; if(functionId)try{await request('/classes/Function/'+functionId,{method:'DELETE'});}catch{failures.push('临时函数 '+functionId);} if(userId)try{await request('/users/'+userId,{method:'DELETE'});}catch{failures.push('临时管理员 '+userId);} if(failures.length)throw new Error('需要清理:'+failures.join('、')); }