diff --git a/app/api/canvas/download-zip-task/route.ts b/app/api/canvas/download-zip-task/route.ts index 7ad1533f5..fec67e8bf 100644 --- a/app/api/canvas/download-zip-task/route.ts +++ b/app/api/canvas/download-zip-task/route.ts @@ -110,6 +110,7 @@ async function processTask(id: string, userId: string, requestOrigin: string, co const index = cursor++; const file = task!.files[index]; const path = join(zipTaskPath(id), `${index}.image`); if (file.prepared && file.filename && (await stat(path).catch(() => null))?.size) continue; + delete file.prepared; delete file.filename; const result = await fetchCanvasDownloadAsset({ file, index, requestOrigin, cookie, signal: AbortSignal.timeout(120_000) }).catch(() => ({ ok: false as const, error: "原图读取超时或源地址暂时不可用" })); if (result.ok) { @@ -129,7 +130,8 @@ async function processTask(id: string, userId: string, requestOrigin: string, co if (!task.successCount) throw new Error("所选原图暂时都无法读取,请稍后重试;画布图片不会被删除"); const zip = new JSZip(); const folder = zip.folder(task.folderName) || zip; for (const file of task.files) if (file.prepared && file.filename) - folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared))); + folder.file(file.filename, createReadStream(join(zipTaskPath(id), file.prepared)) + .once("close", () => { void rm(join(zipTaskPath(id), file.prepared!), { force: true }).catch(() => undefined); })); if (task.failedCount) folder.file("_download-failures.txt", task.files.filter(f => f.error) .map(f => `${f.name || "图片"}:${f.error}`).join("\n")); task.stage = "正在生成完整 ZIP 文件"; await saveZipTask(task); diff --git a/lib/canvas/download-zip-stream.ts b/lib/canvas/download-zip-stream.ts index 941252007..d5d6400a5 100644 --- a/lib/canvas/download-zip-stream.ts +++ b/lib/canvas/download-zip-stream.ts @@ -19,12 +19,20 @@ export async function writeDownloadZip(zip: JSZip, id: string, progress: (percen const path = join(directory, `${id}.zip`); const temporary = `${path}.partial`; try { - const disk = await statfs(directory); - const budget = disk.bavail * disk.bsize - 1024 * 1024 * 1024; - let written = 0; + const reserve = 1024 ** 3 + 32 * 1024 ** 2; + const initialDisk = await statfs(directory); + if (initialDisk.bavail * initialDisk.bsize < reserve) + throw new Error("服务器下载空间不足,请减少本次图片数量"); + let sinceCheck = 8 * 1024 ** 2; const guard = new Transform({ transform(chunk, _encoding, callback) { - written += chunk.length; - callback(written > budget ? new Error("服务器下载空间不足,请减少本次图片数量") : null, chunk); + sinceCheck += chunk.length; + if (sinceCheck < 8 * 1024 ** 2) { callback(null, chunk); return; } + sinceCheck = 0; + // Input files are released as their streams close, so available disk grows + // while the ZIP is written. Check current space instead of a stale budget. + void statfs(directory).then(disk => { + callback(disk.bavail * disk.bsize < reserve ? new Error("服务器下载空间不足,请减少本次图片数量") : null, chunk); + }, error => callback(error)); } }); await pipeline(zip.generateNodeStream({ type: "nodebuffer", streamFiles: true, compression: "STORE" }, metadata => progress(metadata.percent)), guard, createWriteStream(temporary, { mode: 0o600 })); diff --git a/tests/routes/canvas-zip-download.test.ts b/tests/routes/canvas-zip-download.test.ts index 5a22aa8f1..c092f3274 100644 --- a/tests/routes/canvas-zip-download.test.ts +++ b/tests/routes/canvas-zip-download.test.ts @@ -124,3 +124,32 @@ test("partial source failures are explicit; CDN redirects retain an authenticate owner='stranger';expect((await route.GET(request('&download=1'))).status).toBe(404); }finally{await rm(dir,{recursive:true,force:true});} }); + +test("recovery validates staged files instead of treating missing bytes as successful images",async()=>{ + const dir=await mkdtemp(join(tmpdir(),'canvas-zip-missing-'));const callbacks:Array<()=>Promise>=[];const id=randomUUID(); + const load=serverLoader({ + '@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})}, + 'next/server':{NextResponse,after:(run:()=>Promise)=>callbacks.push(run)}, + '@/lib/canvas/photoshop-safe-download':{fetchCanvasDownloadAsset:async()=>({ok:false,error:'source expired'})}, + '@/lib/storage/canvas-zip-delivery':{publishCanvasZip:async()=>null}, + },{CANVAS_DOWNLOAD_ZIP_DIR:dir}); + try{ + 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()!(); + 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});} +}); + +test("large ZIP writing checks current free space as staged input bytes are released",async()=>{ + const fs=await import('node:fs/promises');const {createReadStream}=await import('node:fs'); + const dir=await mkdtemp(join(tmpdir(),'canvas-zip-space-'));let checks=0; + const module=serverLoader({'node:fs/promises':{...fs,statfs:async()=>{checks++;return {bsize:1,bavail:1024**3+33*1024**2};}}},{CANVAS_DOWNLOAD_ZIP_DIR:dir})('lib/canvas/download-zip-stream.ts'); + try{ + const source=join(dir,'source.bin');const handle=await fs.open(source,'w');await handle.truncate(48*1024**2);await handle.close(); + const zip=new JSZip();zip.file('large.bin',createReadStream(source)); + const result=await module.writeDownloadZip(zip,randomUUID(),()=>{}); + expect(result.bytes).toBeGreaterThan(48*1024**2);expect(checks).toBeGreaterThan(4); + }finally{await rm(dir,{recursive:true,force:true});} +});