|
@@ -1,5 +1,5 @@
|
|
|
// Persistent jobs are processed by scripts/lesson-export-worker.mjs, never by detached request work.
|
|
// Persistent jobs are processed by scripts/lesson-export-worker.mjs, never by detached request work.
|
|
|
-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};}
|
|
|
|
|
|
|
+function lessonExportProjection(job){return{jobId:job.id,status:job.status,total:number(job.total),processed:number(job.processed),error:job.error||'',snapshotReady:job.snapshot_ready===true,expiresAt:job.expires_at};}
|
|
|
function lessonExportScope(context){return JSON.stringify([companyIdOf(context,{}),context.storeId||0,roleOf(context)]);}
|
|
function lessonExportScope(context){return JSON.stringify([companyIdOf(context,{}),context.storeId||0,roleOf(context)]);}
|
|
|
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;}
|
|
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;}
|
|
|
async function lessonExportCreate(context,input){
|
|
async function lessonExportCreate(context,input){
|
|
@@ -34,7 +34,7 @@ async function lessonExportClaim(context){
|
|
|
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");
|
|
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");
|
|
|
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{};
|
|
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{};
|
|
|
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{};
|
|
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{};
|
|
|
- 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]);
|
|
|
|
|
|
|
+ const claimed=await db.one("UPDATE xs_history_export_job SET status='running',processed=0,error='',lease=md5(random()::text||clock_timestamp()::text),lease_until=now()+interval '2 minutes',attempts=attempts+1 WHERE id=$1 RETURNING *",[job.id]);
|
|
|
return{...lessonExportProjection(claimed),lease:claimed.lease,snapshotReady:claimed.snapshot_ready};
|
|
return{...lessonExportProjection(claimed),lease:claimed.lease,snapshotReady:claimed.snapshot_ready};
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
@@ -48,11 +48,22 @@ async function lessonExportSnapshot(context,input){
|
|
|
const user=await new Parse.Query('_User').get(job.actor,{useMasterKey:true}),owner=await auth({user});
|
|
const user=await new Parse.Query('_User').get(job.actor,{useMasterKey:true}),owner=await auth({user});
|
|
|
if(lessonExportScope(owner)!==job.scope||companyIdOf(owner,{})!==job.company)fail(403,'导出账号权限已变化');
|
|
if(lessonExportScope(owner)!==job.scope||companyIdOf(owner,{})!==job.company)fail(403,'导出账号权限已变化');
|
|
|
requireRole(owner,['ops-manager','ops-staff','ops-auditor']);
|
|
requireRole(owner,['ops-manager','ops-staff','ops-auditor']);
|
|
|
- const plan=lessonHistoryPlan(owner,job.filters,null,null,true),result=await db.one(plan.q,plan.args);
|
|
|
|
|
- // Resolve categories in the same snapshot as the history, then freeze final exported fields.
|
|
|
|
|
- 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));
|
|
|
|
|
- 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)]);
|
|
|
|
|
- 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);
|
|
|
|
|
|
|
+ const plan=lessonHistoryPlan(owner,job.filters,null,null,true),jobParameter=plan.args.length+1;
|
|
|
|
|
+ // Keep the large snapshot inside PostgreSQL. Only 500 rows enter JS at once.
|
|
|
|
|
+ const inserted=await db.one(`WITH inserted AS (
|
|
|
|
|
+ INSERT INTO xs_history_export_row(job_id,position,value)
|
|
|
|
|
+ SELECT $${jobParameter}::text,element.ordinality::int,element.value FROM (${plan.q}) snapshot
|
|
|
|
|
+ CROSS JOIN LATERAL jsonb_array_elements(snapshot.items) WITH ORDINALITY element(value,ordinality) RETURNING 1
|
|
|
|
|
+ ) SELECT count(*)::int total FROM inserted`,[...plan.args,job.id]);
|
|
|
|
|
+ let position=0;
|
|
|
|
|
+ while(position<number(inserted.total)){
|
|
|
|
|
+ const batch=await db.query('SELECT position,value FROM xs_history_export_row WHERE job_id=$1 AND position>$2 ORDER BY position LIMIT 500',[job.id,position]);
|
|
|
|
|
+ if(!batch.length)fail(409,'导出快照不完整');
|
|
|
|
|
+ const resolved=(await lessonHistoryTypeRows(batch.map(row=>row.value),db)).map(lessonHistoryRow);
|
|
|
|
|
+ await db.none('UPDATE xs_history_export_row r SET value=b.value FROM jsonb_to_recordset($2::jsonb) b(position integer,value jsonb) WHERE r.job_id=$1 AND r.position=b.position',[job.id,JSON.stringify(batch.map((row,index)=>({position:row.position,value:resolved[index]})))]);
|
|
|
|
|
+ position=batch.at(-1).position;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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,number(inserted.total)]);return lessonExportProjection(updated);
|
|
|
});
|
|
});
|
|
|
}
|
|
}
|
|
|
async function lessonExportChunk(context,input){
|
|
async function lessonExportChunk(context,input){
|
|
@@ -63,11 +74,14 @@ async function lessonExportChunk(context,input){
|
|
|
}
|
|
}
|
|
|
async function lessonExportFinish(context,input){
|
|
async function lessonExportFinish(context,input){
|
|
|
return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);const data=text(input.base64);
|
|
return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);const data=text(input.base64);
|
|
|
- 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,'导出文件或记录数量无效');
|
|
|
|
|
|
|
+ if(!job.snapshot_ready||number(input.rowCount)!==job.total||!data||data.length>40_000_000||!/^[A-Za-z0-9+/]+={0,2}$/.test(data))fail(400,'导出文件或记录数量无效');
|
|
|
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'};});
|
|
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'};});
|
|
|
}
|
|
}
|
|
|
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'};});}
|
|
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'};});}
|
|
|
async function lessonExportMaintenance(context){
|
|
async function lessonExportMaintenance(context){
|
|
|
requireSuperAdmin(context);const installed=await Psql.one("SELECT to_regprocedure('xs_prune_read_revisions()') IS NOT NULL installed");
|
|
requireSuperAdmin(context);const installed=await Psql.one("SELECT to_regprocedure('xs_prune_read_revisions()') IS NOT NULL installed");
|
|
|
- if(installed.installed)await Psql.none('SELECT xs_prune_read_revisions()');return{completed:true};
|
|
|
|
|
|
|
+ if(installed.installed)await Psql.one('SELECT xs_prune_read_revisions()');
|
|
|
|
|
+ const cache=await Psql.one("SELECT to_regclass('xs_read_cache') IS NOT NULL installed");if(cache.installed)await Psql.none('DELETE FROM xs_read_cache WHERE expires_at<now() AND (lease_until IS NULL OR lease_until<now())');return{completed:true};
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+async function lessonExportRelease(context,input){return Psql.tx(async db=>{const job=await lessonExportLeased(context,input,db);await db.none("UPDATE xs_history_export_job SET lease_until=now()-interval '1 second' WHERE id=$1",[job.id]);return{jobId:job.id,status:'running'};});}
|