fix(canvas): release ZIP staging bytes and revalidate recovery files

This commit is contained in:
134 2026-10-20 18:50:58 +08:00
parent 87e6476ba8
commit 226a85d339
3 changed files with 45 additions and 6 deletions

View File

@ -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);

View File

@ -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 }));

View File

@ -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<void>>=[];const id=randomUUID();
const load=serverLoader({
'@/app/(auth)/auth':{auth:async()=>({user:{id:'owner'}})},
'next/server':{NextResponse,after:(run:()=>Promise<void>)=>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});}
});