| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546 |
- #!/usr/bin/env node
- import {readFileSync,writeFileSync,mkdirSync} from 'node:fs';
- import {randomBytes,createHash} from 'node:crypto';
- import {resolve,join} from 'node:path';
- import {enrollmentSchemas} from './member-enrollment-schema.mjs';
- const sql=readFileSync(new URL('./sql/member-enrollment.sql',import.meta.url),'utf8'),apply=process.argv.includes('--apply');
- if(process.argv.includes('--validate')){if(!sql.includes('xs_complete_appointment'))throw Error('迁移不完整');console.log('用户开课迁移与 '+Object.keys(enrollmentSchemas).length+' 个 schema 定义已就绪;未连接数据库。');process.exit(0);}
- const master=process.env.XIAOSHU_MASTER_KEY;if(!master)throw Error('缺少 XIAOSHU_MASTER_KEY;未连接生产环境');
- const parseUrl=(process.env.XIAOSHU_PARSE_URL||'https://server.xiaoshu.pro/parse').replace(/\/$/,''),appId=process.env.XIAOSHU_PARSE_APP_ID||'7pIbDBJmKx_main';
- async function request(path,init={}){const r=await fetch(parseUrl+path,{...init,headers:{'Content-Type':'application/json','X-Parse-Application-Id':appId,'X-Parse-Master-Key':master}});const d=await r.json();if(!r.ok||d.error)throw Error(d.error||`HTTP ${r.status}`);return d;}
- async function rows(name,where={},keys=''){const all=[];let last='';for(;;){const q={...where,...(last?{objectId:{$gt:last}}:{})},result=await request('/classes/'+name+'?'+new URLSearchParams({where:JSON.stringify(q),limit:'1000',order:'objectId',...(keys?{keys}: {})}));all.push(...result.results);if(result.results.length<1000)break;last=result.results.at(-1).objectId;}return all;}
- const backup={createdAt:new Date().toISOString(),sqlSha256:createHash('sha256').update(sql).digest('hex'),tables:{}};
- for(const t of ['CourseBinding','CourseTeachingProfile','CourseCreditReservation'])backup.tables[t]=await rows(t);
- backup.tables.CommonModel=await rows('CommonModel',{modelId:{$in:[54,58]}});
- backup.tables._User=await rows('_User',{},'objectId,company,legacyUserId,legacyGroupId,identityType,isDisabled,legacyUserData');
- backup.tables.Node=await rows('Node',{},'objectId,company,nodeId,parentId,zstatus,nodeName');
- backup.tables.CourseAppointment=await rows('CourseAppointment');
- const categories=new Set(['word','trial','primary_writing','middle_writing','high_writing','word_self_study']);
- const profiles=new Map(backup.tables.CourseTeachingProfile.filter(p=>p.confirmed&&categories.has(p.categoryKey)).map(p=>[(p.company?.objectId||p.company)+':'+p.courseId,p.categoryKey]));
- const report={bindings:backup.tables.CourseBinding.length,missingCategory:0,missingContentRecord:0,incorrectContentMetadata:0,duplicateGroups:0,reservations:backup.tables.CourseCreditReservation.length};const groups=new Map();
- for(const b of backup.tables.CourseBinding){const co=b.company?.objectId||b.company,cat=b.categoryKey||profiles.get(co+':'+b.kcid);if(!categories.has(cat))report.missingCategory++;const c=backup.tables.CommonModel.find(c=>(c.company?.objectId||c.company)===co&&Number(c.modelId)===58&&String(c.itemId)===String(b.id));if(!c)report.missingContentRecord++;else if(Number(c.nodeId)!==28||c.tableName!=='ZL_C_kcbd')report.incorrectContentMetadata++;if(Number(c?.status)!==-2&&cat){const k=[co,b.yhid,cat,b.kcid].join(':');groups.set(k,(groups.get(k)||0)+1);}}
- report.duplicateGroups=[...groups.values()].filter(n=>n>1).length;
- const companyOf=r=>r.company?.objectId||r.company;
- report.orphanBindings=[];report.accountIssues=[];report.duplicateReservations=[];
- for(const b of backup.tables.CourseBinding){const co=companyOf(b);if(!backup.tables._User.some(u=>companyOf(u)===co&&String(u.legacyUserId)===String(b.yhid))||!backup.tables.Node.some(n=>companyOf(n)===co&&String(n.nodeId)===String(b.kcid)))report.orphanBindings.push(b.objectId);}
- const reserved=new Map(),appointments=new Map();
- for(const r of backup.tables.CourseCreditReservation.filter(r=>r.state==='reserved')){const co=companyOf(r),key=co+':'+r.appointmentId;appointments.set(key,(appointments.get(key)||0)+1);for(const cost of r.creditCosts||[]){const k=[co,r.studentId,cost.account].join(':');reserved.set(k,(reserved.get(k)||0)+Number(cost.amount));}}
- report.duplicateReservations=[...appointments].filter(([,count])=>count>1).map(([key,count])=>({key,count}));
- for(const u of backup.tables._User)for(const account of ['Purse','SilverCoin','UserExp','UserPoint']){const balance=Number(u.legacyUserData?.[account]||0),hold=reserved.get([companyOf(u),u.legacyUserId,account].join(':'))||0;if(!Number.isFinite(balance)||balance<0||hold>balance)report.accountIssues.push({objectId:u.objectId,account,balance,reserved:hold});}
- const acknowledgedPath=process.argv.find(a=>a.startsWith('--acknowledged-deficits='))?.split('=').slice(1).join('=');
- const acknowledged=acknowledgedPath?JSON.parse(readFileSync(acknowledgedPath,'utf8')):[];
- report.preservedHistoricalDeficits=report.accountIssues.filter(issue=>Number.isFinite(issue.balance)&&issue.balance<0&&issue.reserved===0&&acknowledged.some(a=>a.objectId===issue.objectId&&a.account===issue.account&&a.balance===issue.balance&&a.evidence));
- report.requiresManualReconciliation=report.accountIssues.length>report.preservedHistoricalDeficits.length||report.duplicateReservations.length>0;
- const dir=resolve(process.argv.find(a=>a.startsWith('--backup-dir='))?.slice(13)||'tmp/member-enrollment-'+Date.now());mkdirSync(dir,{recursive:true,mode:0o700});writeFileSync(join(dir,'before.json'),JSON.stringify(backup,null,2),{mode:0o600});writeFileSync(join(dir,'report.json'),JSON.stringify(report,null,2),{mode:0o600});console.log(JSON.stringify({report,backupDirectory:dir,apply},null,2));
- if(!apply)process.exit(0);
- if(report.requiresManualReconciliation)throw Error('预检发现负余额、超额预留或重复预留;请先核对 report.json 后再迁移');
- // Register Parse metadata before adding constraints/functions. New business tables are master-only.
- for(const [name,fields] of Object.entries(enrollmentSchemas)){let schema;try{schema=await request('/schemas/'+name);}catch(e){if(!['CreditTransaction','EnrollmentCommand'].includes(name))throw e;schema=null;}
- const missing=Object.fromEntries(Object.entries(fields).filter(([k])=>!schema?.fields?.[k]));if(!schema||Object.keys(missing).length)await request('/schemas/'+name,{method:schema?'PUT':'POST',body:JSON.stringify({className:name,fields:missing,...(!schema?{classLevelPermissions:{find:{},get:{},count:{},create:{},update:{},delete:{},addField:{}}}:{})})});}
- const co=(await request('/classes/Company?limit=1&keys=objectId')).results?.[0];if(!co)throw Error('帐套不存在');
- const suffix=Date.now()+'_'+randomBytes(4).toString('hex'),path='xiaoshu/system/member-enrollment-'+suffix;let uid='',fid='';
- try{const user=await request('/users',{method:'POST',body:JSON.stringify({username:'enrollment_migration_'+suffix,password:randomBytes(32).toString('base64url'),isAdmin:true,adminRoleKey:'super-admin',roles:['admin','super-admin'],company:{__type:'Pointer',className:'Company',objectId:co.objectId}})});uid=user.objectId;
- const code=`async function handler(request,response){const current=request.user;if(!current||current.id!==${JSON.stringify(uid)})return response.status(403).json({success:false});try{await Psql.none(${JSON.stringify(sql)});return response.json({success:true});}catch(error){return response.status(500).json({success:false,message:String(error.message)});}}`;
- const fn=await request('/classes/Function',{method:'POST',body:JSON.stringify({name:path,path,code,type:'standalone',desc:'用户开课事务迁移',enabled:true,params:[],paramList:[],respType:'json'})});fid=fn.objectId;
- const r=await fetch(parseUrl.replace(/\/parse$/,'/api/functions/')+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:user.sessionToken,params:{}})}),result=await r.json();if(!r.ok||!result.success)throw Error(result.message||'迁移失败');console.log('迁移已完成;请部署运营与 App 网关并运行隔离账户验收。');
- }finally{const failures=[];if(fid)try{await request('/classes/Function/'+fid,{method:'DELETE'});}catch{failures.push('Function '+fid);}if(uid)try{await request('/users/'+uid,{method:'DELETE'});}catch{failures.push('User '+uid);}if(failures.length)throw Error('需要清理临时资源:'+failures.join(', '));}
|