fmode-client.mjs 3.5 KB

1234567891011121314151617181920212223242526272829303132333435
  1. import crypto from 'node:crypto';
  2. // 不读取管理配置,也不向客户端提供 masterKey。
  3. export function createFmodeClient({url=process.env.KSF_FUNCTIONS_URL||'https://kangshifu.fmode.cn/api/functions',appId=process.env.KSF_PARSE_APP_ID||'FKieRbIoGt_main',serviceToken=process.env.KSF_SERVICE_TOKEN,sessionToken=process.env.KSF_PARSE_SESSION_TOKEN,reportMetadata=null,fetchImpl=fetch}={}){
  4. const endpoint=new URL(url);
  5. if(endpoint.protocol!=='https:'||endpoint.hostname!=='kangshifu.fmode.cn'||endpoint.pathname!=='/api/functions'||appId!=='FKieRbIoGt_main')throw Error('不是康师傅正式动态函数入口');
  6. const headers={'Content-Type':'application/json','X-Parse-Application-Id':appId};
  7. if(sessionToken)headers['X-Parse-Session-Token']=sessionToken;
  8. else if(serviceToken)headers['X-Ksf-Service-Token']=serviceToken;
  9. function checkUrl(value){const target=new URL(value);if(target.origin!==endpoint.origin||target.pathname!==endpoint.pathname)throw Error('文件通道不属于当前后端');return target;}
  10. async function callFn(name,params={}){
  11. if(!/^ksf[A-Za-z]+$/.test(name))throw Error('函数名不合法');
  12. if(['ksfValidateImport','ksfSubmitImport'].includes(name)&&reportMetadata){
  13. params={...params,items:params.items.map(item=>item.className==='KfArtifact'?{...item,data:{stageCode:reportMetadata.stageCode,contentSchema:'ksf.html-outline.v1',...item.data}}:item)};
  14. }
  15. const response=await fetchImpl(url,{method:'POST',headers,body:JSON.stringify({path:'/'+name,params}),redirect:'error',signal:AbortSignal.timeout(45000)});
  16. let body;try{body=await response.json();}catch{throw Error('函数返回非 JSON');}
  17. return{http:response.status,ok:response.ok,body};
  18. }
  19. async function invoke(name,params={}){const r=await callFn(name,params),v=r.body.result;if(r.http!==200||v?.ok!==true||!Array.isArray(v.errors)||v.errors.length)throw Object.assign(Error(v?.errors?.map(e=>e.code+': '+e.message).join(';')||'函数调用失败'),{response:r});return v.data;}
  20. async function transfer(upload,bytes){
  21. const target=checkUrl(upload.url);if(target.searchParams.get('path')!=='/ksf/upload'||!['POST','PUT'].includes(upload.method))throw Error('上传准备信息无效');
  22. const r=await fetchImpl(target,{method:upload.method,headers:{...upload.headers,'Content-Type':'application/octet-stream'},body:bytes,redirect:'error',signal:AbortSignal.timeout(45000)});
  23. const receipt=await r.json();const actual=crypto.createHash('sha256').update(bytes).digest('hex');
  24. if(r.status!==200||receipt.ok!==true||receipt.sizeBytes!==bytes.length||receipt.sha256!==actual||!receipt.fileId)throw Error('文件传输失败或服务端字节回执不完整');
  25. return{ok:true,http:r.status,url:upload.url,method:upload.method,bytes:receipt.sizeBytes,sha256:receipt.sha256,fileId:receipt.fileId};
  26. }
  27. async function confirmation(previewId,reportIdentity){
  28. const state=await invoke('ksfGetPreviewConfirmation',{previewId});if(state.status!=='confirmed')throw Error('等待员工在确认页操作,不能构造确认凭据');
  29. const r=await invoke('ksfConfirmReport',{requestId:'confirm-receipt:'+state.eventId,reportIdentity,confirmationEvent:{eventId:state.eventId}});
  30. const i=r.confirmedIdentity;
  31. return{reportSeriesKey:i.reportSeriesKey,version:i.version,reportContentHash:i.reportContentHash,confirmationReceipt:r.confirmationReceipt,contentConfirmedByRef:{__type:'Pointer',className:'_User',objectId:i.confirmedBy},contentConfirmedAt:i.confirmedAt};
  32. }
  33. return{callFn,invoke,transfer,confirmation};
  34. }