fix(canvas): defer expired archive cleanup after ZIP admission

This commit is contained in:
134 2026-10-20 22:58:28 +08:00
parent 226a85d339
commit 85aa711633
2 changed files with 22 additions and 6 deletions

View File

@ -169,7 +169,6 @@ export async function POST(request: NextRequest) {
const session = await auth();
if (!session?.user?.id) return NextResponse.json({ error: "未登录" }, { status: 401 });
try {
await cleanupZipTasks();
const body = await request.json();
let id = typeof body.taskId === "string" ? body.taskId : randomUUID();
let task = await loadZipTask(id, session.user.id);
@ -199,6 +198,7 @@ export async function POST(request: NextRequest) {
await saveZipTask(task);
}
if (task.status === "pending" || task.status === "running") schedule(task, request);
after(() => cleanupZipTasks().catch(() => { console.warn("[CanvasDownloadZipTask] Expired file cleanup deferred"); }));
return NextResponse.json({ success: true, task: serializeTask(task) }, { status: 202, headers: { "Cache-Control": "no-store" } });
} catch (error) {
console.warn("[CanvasDownloadZipTask] Create rejected", { message: (error as Error).message });

View File

@ -35,7 +35,7 @@ test("multi-select ZIP admission is immediate; complete archives survive restart
const response=await route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId,folderName:'作品',files})}));
expect(response.status).toBe(202);expect(fetches).toBe(0);
expect((await route.GET(request(`taskId=${taskId}&download=1`))).status).toBe(409);
await callbacks.shift()!();
while (callbacks.length) await callbacks.shift()!();
const state=await (await route.GET(request(`taskId=${taskId}`))).json();
expect(state.task).toMatchObject({status:'completed',successCount:2,failedCount:0});
const complete=await route.GET(request(`taskId=${taskId}&download=1`));
@ -63,10 +63,10 @@ test("all-source failures never produce an empty successful ZIP; retries reuse t
},{CANVAS_DOWNLOAD_ZIP_DIR:dir})('app/api/canvas/download-zip-task/route.ts');
const id=randomUUID();const post=()=>new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,retry:true,files:[{url:'https://provider.example/expired.png'}]})});
try {
await route.POST(post());await callbacks.shift()!();
await route.POST(post());while (callbacks.length) await callbacks.shift()!();
let state=await(await route.GET(new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`))).json();
expect(state.task.status).toBe('failed');expect(state.task.downloadUrl).toBe('');
available=true;await route.POST(post());await callbacks.shift()!();
available=true;await route.POST(post());while (callbacks.length) await callbacks.shift()!();
state=await(await route.GET(new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`))).json();
expect(state.task).toMatchObject({id,status:'completed',successCount:1});
} finally {await rm(dir,{recursive:true,force:true});}
@ -116,7 +116,7 @@ test("partial source failures are explicit; CDN redirects retain an authenticate
const id=randomUUID();const request=(extra='')=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}${extra}`);
try{
await route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({taskId:id,files:[{url:'https://provider.example/1'},{url:'https://provider.example/2'}]})}));
await callbacks.shift()!();
while (callbacks.length) await callbacks.shift()!();
const state=await(await route.GET(request())).json();expect(state.task).toMatchObject({status:'completed',successCount:1,failedCount:1});
const cdn=await route.GET(request('&download=1'));expect(cdn.status).toBe(307);expect(cdn.headers.get('location')).toBe('https://cdn.example/complete.zip');
const local=await route.GET(request('&download=1&local=1'));const zip=await JSZip.loadAsync(await local.arrayBuffer());
@ -137,7 +137,7 @@ test("recovery validates staged files instead of treating missing bytes as succe
const store=load('lib/canvas/download-zip-task-store.ts');
await store.saveZipTask({id,userId:'owner',folderName:'test',files:[{url:'https://provider.example/a',prepared:'0.image',filename:'a.png'}],totalCount:1,processedCount:1,successCount:1,failedCount:0,compressPercent:0,status:'running',stage:'preparing',createdAt:Date.now(),updatedAt:Date.now(),expiresAt:Date.now()+60000});
const route=load('app/api/canvas/download-zip-task/route.ts');const req=()=>new NextRequest(`http://localhost/api/canvas/download-zip-task?taskId=${id}`);
await route.GET(req());await callbacks.shift()!();
await route.GET(req());while (callbacks.length) await callbacks.shift()!();
const state=await(await route.GET(req())).json();expect(state.task).toMatchObject({status:'failed',successCount:0,failedCount:1,downloadUrl:''});
}finally{await rm(dir,{recursive:true,force:true});}
});
@ -153,3 +153,19 @@ test("large ZIP writing checks current free space as staged input bytes are rele
expect(result.bytes).toBeGreaterThan(48*1024**2);expect(checks).toBeGreaterThan(4);
}finally{await rm(dir,{recursive:true,force:true});}
});
test("slow expired-object cleanup cannot keep the ZIP admission request open",async()=>{
const dir=await mkdtemp(join(tmpdir(),'canvas-zip-cleanup-'));const callbacks:Array<()=>Promise<void>>=[];
const mocks:Record<string,unknown>={
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>callbacks.push(run)},
'@/lib/canvas/photoshop-safe-download':{},'@/lib/storage/canvas-zip-delivery':{},
};
const load=serverLoader(mocks,{CANVAS_DOWNLOAD_ZIP_DIR:dir});const store=load('lib/canvas/download-zip-task-store.ts');
mocks['@/lib/canvas/download-zip-task-store']={...store,cleanupZipTasks:()=>new Promise(()=>{})};
try{
const route=load('app/api/canvas/download-zip-task/route.ts');
const result=await Promise.race([route.POST(new NextRequest('http://localhost/api/canvas/download-zip-task',{method:'POST',body:JSON.stringify({files:[{url:'https://cdn.example/a.png'}]})})),new Promise(resolve=>setTimeout(()=>resolve('blocked'),1000))]);
expect(result).not.toBe('blocked');expect((result as Response).status).toBe(202);
}finally{await rm(dir,{recursive:true,force:true});}
});