diff --git a/app/api/canvas/download-zip-task/route.ts b/app/api/canvas/download-zip-task/route.ts index fec67e8bf..2f5de4a34 100644 --- a/app/api/canvas/download-zip-task/route.ts +++ b/app/api/canvas/download-zip-task/route.ts @@ -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 }); diff --git a/tests/routes/canvas-zip-download.test.ts b/tests/routes/canvas-zip-download.test.ts index c092f3274..157c1eee7 100644 --- a/tests/routes/canvas-zip-download.test.ts +++ b/tests/routes/canvas-zip-download.test.ts @@ -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>=[]; + const mocks:Record={ + '@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, + 'next/server':{NextResponse,after:(run:()=>Promise)=>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});} +});