fix(canvas): release ZIP staging bytes and revalidate recovery files
This commit is contained in:
parent
87e6476ba8
commit
226a85d339
@ -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);
|
||||
|
||||
@ -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 }));
|
||||
|
||||
@ -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});}
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user