lesson-export.js 8.2 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273
  1. // Persistent jobs are processed by scripts/lesson-export-worker.mjs, never by detached request work.
  2. function lessonExportProjection(job){return{jobId:job.id,status:job.status,total:number(job.total),processed:number(job.processed),error:job.error||'',expiresAt:job.expires_at};}
  3. function lessonExportScope(context){return JSON.stringify([companyIdOf(context,{}),context.storeId||0,roleOf(context)]);}
  4. function lessonExportFilters(input){const out={};for(const key of ['studentId','search','coachName','storeName','courseName','dateFrom','dateTo'])if(input[key]!==undefined)out[key]=input[key];return out;}
  5. async function lessonExportCreate(context,input){
  6. requireRole(context,['ops-manager','ops-staff','ops-auditor']);const company=companyIdOf(context,input),actor=text(context.current.id),key=text(input.idempotencyKey),filters=lessonExportFilters(input),scope=lessonExportScope(context);
  7. if(!company||key.length<16||key.length>100)fail(400,'导出请求编号无效');
  8. return Psql.tx(async db=>{
  9. await db.one('SELECT pg_advisory_xact_lock(hashtext($1),hashtext($2))',[company,actor]);
  10. await db.none('DELETE FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND expires_at<=now()',[company,actor]);
  11. const prior=(await db.query('SELECT * FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND request_key=$3 AND expires_at>now()',[company,actor,key]))[0];
  12. if(prior){if(JSON.stringify(prior.filters)!==JSON.stringify(JSON.parse(JSON.stringify(filters)))||prior.scope!==scope){
  13. // PostgreSQL jsonb canonicalizes key order; compare its canonical representation instead.
  14. const same=await db.one('SELECT filters=$2::jsonb same FROM xs_history_export_job WHERE id=$1',[prior.id,JSON.stringify(filters)]);if(!same.same||prior.scope!==scope)fail(409,'导出请求条件已变化');
  15. }return lessonExportProjection(prior);}
  16. const active=(await db.query("SELECT * FROM xs_history_export_job WHERE company=$1 AND actor=$2 AND status IN('queued','running') AND expires_at>now()",[company,actor]))[0];
  17. if(active){const same=await db.one('SELECT filters=$2::jsonb same FROM xs_history_export_job WHERE id=$1',[active.id,JSON.stringify(filters)]);if(!same.same||active.scope!==scope)fail(409,'已有导出进行中,请等待或取消');return lessonExportProjection(active);}
  18. const job=await db.one('INSERT INTO xs_history_export_job(company,actor,scope,request_key,filters) VALUES($1,$2,$3,$4,$5::jsonb) RETURNING *',[company,actor,scope,key,JSON.stringify(filters)]);return lessonExportProjection(job);
  19. });
  20. }
  21. async function lessonExportOwned(context,input,db=Psql){
  22. requireRole(context,['ops-manager','ops-staff','ops-auditor']);
  23. const job=(await db.query('SELECT * FROM xs_history_export_job WHERE id=$1 AND company=$2 AND actor=$3 AND scope=$4 AND expires_at>now()',[text(input.jobId),companyIdOf(context,input),text(context.current.id),lessonExportScope(context)]))[0];
  24. if(!job)fail(404,'未找到有效导出任务');return job;
  25. }
  26. async function lessonExportStatus(context,input){return lessonExportProjection(await lessonExportOwned(context,input));}
  27. async function lessonExportCancel(context,input){const job=await lessonExportOwned(context,input);await Psql.none("UPDATE xs_history_export_job SET status='cancelled',lease=NULL,lease_until=NULL,file_base64=NULL WHERE id=$1 AND status IN('queued','running')",[job.id]);return lessonExportStatus(context,input);}
  28. async function lessonExportDownload(context,input){const job=await lessonExportOwned(context,input);if(job.status!=='ready'||!job.file_base64)fail(409,'导出尚未完成');return{fileName:'训练记录.xlsx',contentType:'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',base64:job.file_base64};}
  29. async function lessonExportClaim(context){
  30. requireSuperAdmin(context);
  31. return Psql.tx(async db=>{
  32. await db.one('SELECT pg_advisory_xact_lock(1736201,1)');
  33. await db.none('DELETE FROM xs_history_export_job WHERE expires_at<=now()');
  34. await db.none("UPDATE xs_history_export_job SET status='failed',error='后台任务多次中断,请重新导出',lease=NULL,lease_until=NULL WHERE status='running' AND lease_until<now() AND attempts>=3");
  35. const active=await db.one("SELECT EXISTS(SELECT 1 FROM xs_history_export_job WHERE status='running' AND lease_until>now()) active");if(active.active)return{};
  36. const job=(await db.query("SELECT * FROM xs_history_export_job WHERE status='queued' OR (status='running' AND lease_until<now() AND attempts<3) ORDER BY created_at LIMIT 1 FOR UPDATE SKIP LOCKED"))[0];if(!job)return{};
  37. const claimed=await db.one("UPDATE xs_history_export_job SET status='running',processed=0,lease=md5(random()::text||clock_timestamp()::text),lease_until=now()+interval '2 minutes',attempts=attempts+1 WHERE id=$1 RETURNING *",[job.id]);
  38. return{...lessonExportProjection(claimed),lease:claimed.lease,snapshotReady:claimed.snapshot_ready};
  39. });
  40. }
  41. async function lessonExportLeased(context,input,db=Psql){
  42. requireSuperAdmin(context);const job=(await db.query("SELECT * FROM xs_history_export_job WHERE id=$1 AND lease=$2 AND status='running' AND lease_until>now() AND expires_at>now() FOR UPDATE",[text(input.jobId),text(input.lease)]))[0];if(!job)fail(409,'导出租约已失效');return job;
  43. }
  44. async function lessonExportSnapshot(context,input){
  45. return Psql.tx(async db=>{
  46. await db.none("SET TRANSACTION ISOLATION LEVEL REPEATABLE READ");await db.none("SET LOCAL statement_timeout='60s'");
  47. const job=await lessonExportLeased(context,input,db);if(job.snapshot_ready)return lessonExportProjection(job);
  48. const user=await new Parse.Query('_User').get(job.actor,{useMasterKey:true}),owner=await auth({user});
  49. if(lessonExportScope(owner)!==job.scope||companyIdOf(owner,{})!==job.company)fail(403,'导出账号权限已变化');
  50. requireRole(owner,['ops-manager','ops-staff','ops-auditor']);
  51. const plan=lessonHistoryPlan(owner,job.filters,null,null,true),result=await db.one(plan.q,plan.args);
  52. // Resolve categories in the same snapshot as the history, then freeze final exported fields.
  53. const raw=result.items||[],rows=[];for(let i=0;i<raw.length;i+=500)rows.push(...(await lessonHistoryTypeRows(raw.slice(i,i+500),db)).map(lessonHistoryRow));
  54. await db.none('INSERT INTO xs_history_export_row(job_id,position,value) SELECT $1,ordinality::int,value FROM jsonb_array_elements($2::jsonb) WITH ORDINALITY',[job.id,JSON.stringify(rows)]);
  55. const updated=await db.one("UPDATE xs_history_export_job SET total=$2,snapshot_ready=true,lease_until=now()+interval '2 minutes' WHERE id=$1 RETURNING *",[job.id,rows.length]);return lessonExportProjection(updated);
  56. });
  57. }
  58. async function lessonExportChunk(context,input){
  59. return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);if(!job.snapshot_ready)fail(409,'导出快照尚未完成');const offset=Math.max(0,Math.floor(number(input.offset)));
  60. const rows=await db.query('SELECT value FROM xs_history_export_row WHERE job_id=$1 AND position>$2 ORDER BY position LIMIT 500',[job.id,offset]);
  61. await db.none("UPDATE xs_history_export_job SET processed=$2,lease_until=now()+interval '2 minutes' WHERE id=$1",[job.id,Math.min(job.total,offset+rows.length)]);
  62. return{items:rows.map(r=>r.value),total:job.total,offset};});
  63. }
  64. async function lessonExportFinish(context,input){
  65. return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);const data=text(input.base64);
  66. if(!job.snapshot_ready||number(input.rowCount)!==job.total||!data||data.length>14_000_000||!/^[A-Za-z0-9+/]+={0,2}$/.test(data))fail(400,'导出文件或记录数量无效');
  67. await db.none("UPDATE xs_history_export_job SET status='ready',processed=total,file_base64=$2,lease=NULL,lease_until=NULL WHERE id=$1",[job.id,data]);return{jobId:job.id,status:'ready'};});
  68. }
  69. async function lessonExportFail(context,input){return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);await db.none("UPDATE xs_history_export_job SET status='failed',error='导出未能完成,请稍后重试',lease=NULL,lease_until=NULL WHERE id=$1",[job.id]);return{jobId:job.id,status:'failed'};});}
  70. async function lessonExportMaintenance(context){
  71. requireSuperAdmin(context);const installed=await Psql.one("SELECT to_regprocedure('xs_prune_read_revisions()') IS NOT NULL installed");
  72. if(installed.installed)await Psql.none('SELECT xs_prune_read_revisions()');return{completed:true};
  73. }