From 33662210dd22486e864499a7ed02d674e20a6c1e Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 11:33:20 +0800 Subject: [PATCH] Use archived source URLs for background removal and preserve timeout causes --- lib/ai/rmbg/service.ts | 53 ++++++++++-- lib/canvas/capabilities/headless-executor.ts | 1 + lib/request-error.ts | 9 ++ playwright.helpers.config.ts | 1 + .../helpers/canvas-download-server-loader.ts | 1 + tests/routes/rmbg-delivery.test.ts | 82 +++++++++++++++++++ 6 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 tests/routes/rmbg-delivery.test.ts diff --git a/lib/ai/rmbg/service.ts b/lib/ai/rmbg/service.ts index 80d4a3763..078482b03 100644 --- a/lib/ai/rmbg/service.ts +++ b/lib/ai/rmbg/service.ts @@ -27,6 +27,7 @@ type RmbgConfig = { }; export type ExecuteRmbgJobParams = { + requestId?: string; userId: string; imageBuffer: Buffer; imageBase64?: string; @@ -53,7 +54,7 @@ export class RmbgServiceError extends Error { detail?: string; constructor(message: string, status = 500, detail?: string) { - super(message); + super(detail && detail !== message ? `${message}:${detail}` : message); this.name = "RmbgServiceError"; this.status = status; this.detail = detail; @@ -163,7 +164,7 @@ function getAosFriendlyErrorMessage(detail: string): string | null { async function prepareImageForAos( imageBuffer: Buffer -): Promise<{ buffer: Buffer; mimeType: string; filename: string }> { +): Promise<{ buffer: Buffer; mimeType: string; filename: string; resized: boolean }> { try { const sharp = await import("sharp"); const image = sharp.default(imageBuffer); @@ -189,6 +190,7 @@ async function prepareImageForAos( buffer: imageBuffer, mimeType, filename: `image.${filenameExt}`, + resized: false, }; } @@ -203,10 +205,11 @@ async function prepareImageForAos( buffer: resizedBuffer, mimeType, filename: `image.${filenameExt}`, + resized: true, }; } catch (error) { logger.warn("aos_image_preprocess_failed", error); - return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png" }; + return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png", resized: true }; } } @@ -369,20 +372,38 @@ async function processWithAosSegmentation( foregroundType?: "" | "person" | "object" | "stamp"; crop?: 0 | 1; format?: "png" | "jpg"; + sourceImageUrl?: string | null; + requestId?: string; } ): Promise<{ success: boolean; imageBuffer?: Buffer; processing_time?: number; error?: string }> { const startTime = Date.now(); const baseUrl = (config.apiBaseUrl || "https://techsz.aoscdn.com").replace(/\/+$/, ""); const createUrl = `${baseUrl}/api/tasks/visual/segmentation`; const headers = { "X-API-KEY": config.apiKey }; + let stage = "create"; + let providerTaskId: string | undefined; try { const normalizedImage = await prepareImageForAos(imageBuffer); const createForm = new FormData(); - const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], { - type: normalizedImage.mimeType, - }); - createForm.append("image_file", imageBlob, normalizedImage.filename); + let publicSourceUrl: string | null = null; + if (!normalizedImage.resized && config.sourceImageUrl) { + try { + const source = new URL(config.sourceImageUrl); + const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || ""); + if (["http:", "https:"].includes(source.protocol) && source.hostname === cdn.hostname) { + publicSourceUrl = source.toString(); + } + } catch { /* Uploaded files and other sources use the file API. */ } + } + if (publicSourceUrl) { + createForm.append("image_url", publicSourceUrl); + } else { + const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], { + type: normalizedImage.mimeType, + }); + createForm.append("image_file", imageBlob, normalizedImage.filename); + } createForm.append("sync", "0"); createForm.append("return_type", "1"); createForm.append("output_type", "2"); @@ -392,6 +413,9 @@ async function processWithAosSegmentation( createForm.append("type", config.foregroundType); } + const createStartedAt = Date.now(); + logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : normalizedImage.buffer.length }); + const createResponse = await withTimeout( (signal) => fetch(createUrl, { @@ -416,11 +440,14 @@ async function processWithAosSegmentation( } const taskId = createPayload.data.task_id as string; + providerTaskId = taskId; + logger.info("aos_task_created", { requestId: config.requestId, taskId, createMs: Date.now() - createStartedAt }); const taskUrl = `${createUrl}/${taskId}`; const maxPollCount = 60; for (let pollCount = 0; pollCount < maxPollCount; pollCount += 1) { await sleep(1000); + stage = "poll"; const pollResponse = await withTimeout( (signal) => @@ -454,6 +481,7 @@ async function processWithAosSegmentation( pollCount, taskId, }); + stage = "download"; const imageResponse = await withTimeout( (signal) => fetch(imageUrl, { @@ -499,7 +527,13 @@ async function processWithAosSegmentation( return { success: false, error: "AOS任务超时(最多60秒)" }; } catch (error) { if (error instanceof Error && error.name === "AbortError") { - return { success: false, error: "AOS请求超时,请稍后重试" }; + logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length }); + const message = stage === "create" + ? "AOS创建任务超时(120秒),结果尚未确认" + : stage === "poll" + ? `AOS查询任务超时,结果尚未确认(任务 ${providerTaskId})` + : `AOS结果下载超时,任务已完成(任务 ${providerTaskId})`; + return { success: false, error: message }; } logger.warn("aos_provider_request_error", error); return { success: false, error: error instanceof Error ? error.message : "未知错误" }; @@ -601,6 +635,7 @@ export async function executeRmbgJob( params: ExecuteRmbgJobParams ): Promise { const { + requestId, userId, imageBuffer, imageBase64 = imageBuffer.toString("base64"), @@ -657,6 +692,8 @@ export async function executeRmbgJob( foregroundType: foregroundType ?? config.aosForegroundType ?? "", crop: config.aosCrop === 1 ? 1 : 0, format: config.aosFormat === "jpg" ? "jpg" : "png", + sourceImageUrl, + requestId, }); if (!result.success || !result.imageBuffer) { diff --git a/lib/canvas/capabilities/headless-executor.ts b/lib/canvas/capabilities/headless-executor.ts index 4000d8d1d..3a451ae76 100644 --- a/lib/canvas/capabilities/headless-executor.ts +++ b/lib/canvas/capabilities/headless-executor.ts @@ -128,6 +128,7 @@ async function executeForTarget( if (command.capabilityId === "image.remove_background") { const imageBuffer = await fetchImageBufferFromUrl(target.sourceImageUrl); const result = await executeRmbgJob({ + requestId: command.requestId, userId: params.userId, imageBuffer, sourceImageUrl: target.sourceImageUrl, diff --git a/lib/request-error.ts b/lib/request-error.ts index fd5a94902..0201155f9 100644 --- a/lib/request-error.ts +++ b/lib/request-error.ts @@ -231,6 +231,15 @@ export function getRequestFailureDetails( if (isGatewayResponseTimeout(error)) { return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown"); } + if (/AOS创建任务超时/.test(text)) { + return detail("RMBG_CREATE_TIMEOUT", "抠图服务创建任务超时(120秒),结果尚未确认。", "请先查看原任务或联系管理员核对;不要连续点击,以免重复处理。", "unknown"); + } + if (/AOS查询任务超时/.test(text)) { + return detail("RMBG_POLL_TIMEOUT", "抠图服务查询任务超时,原任务可能仍在处理。", "请保留当前页面并联系管理员核对原任务,避免重新提交。", "unknown"); + } + if (/AOS结果下载超时/.test(text)) { + return detail("RMBG_RESULT_DOWNLOAD_TIMEOUT", "抠图已完成,但结果图片下载超时。", "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown"); + } if ((isRecord(error) && error.providerOutcomeUnknown === true) || /结果(?:尚)?未确认|结果暂未确认|request_outcome_unknown|任务执行中断|may still be running/i.test(text)) { return detail("RESULT_UNCONFIRMED", "任务结果尚未确认,可能仍在处理。", "请先查看原任务或历史记录;持续没有结果时联系管理员,避免重复生成。", "unknown"); } diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 199b8d8d0..9de162c7f 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -3,6 +3,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", testMatch: [ + /routes\/rmbg-delivery\.test\.ts/, /routes\/legacy-oss-recovery\.test\.ts/, /routes\/retired-storage\.test\.ts/, /routes\/original-image-download\.test\.ts/, diff --git a/tests/helpers/canvas-download-server-loader.ts b/tests/helpers/canvas-download-server-loader.ts index 08e71b369..e75ac07cd 100644 --- a/tests/helpers/canvas-download-server-loader.ts +++ b/tests/helpers/canvas-download-server-loader.ts @@ -53,6 +53,7 @@ export function serverLoader(mocks: Record, env: Record ({ value: { provider: "aoscdn", aosApiKey: "test-key" } }) }, + "@/lib/db": { db: {} }, + "@/lib/db/schema": { generatedImage: {} }, + "@/lib/storage/service": { uploadFile: async (name: string) => ({ url: `${cdn}/${name}` }) }, + fetch, + setTimeout: timer || ((callback: () => void, ms: number) => setTimeout(callback, ms === 1000 ? 0 : ms)), + }, { NEXT_PUBLIC_CANVAS_CDN_ORIGIN: cdn })("lib/ai/rmbg/service.ts"); +} + +test("AOS uses a public Qiniu original URL without uploading the image file again", async () => { + const image = await sharp({ create: { width: 1024, height: 768, channels: 3, background: "#abcdef" } }).png().toBuffer(); + let creates = 0; + const source = `${cdn}/uploads/original.png`; + const service = rmbgLoader(async (input, options) => { + const url = String(input); + if (options?.method === "POST") { + creates++; + const form = options.body as FormData; + expect(form.get("image_url")).toBe(source); + expect(form.has("image_file")).toBe(false); + expect(form.get("sync")).toBe("0"); + return Response.json({ data: { task_id: "task-url" } }); + } + if (url.endsWith("/task-url")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } }); + return new Response(image, { headers: { "content-type": "image/png" } }); + }); + const result = await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true }); + expect(creates).toBe(1); + expect(result.provider).toBe("aoscdn"); + expect(result.original_url).toContain(cdn); +}); + +test("oversized images and non-CDN sources keep the normalized file upload", async () => { + for (const [width, source] of [[5000, `${cdn}/large.png`], [1000, "http://127.0.0.1/private.png"], [1000, "https://other.example/image.png"]] as const) { + const image = await sharp({ create: { width, height: 20, channels: 3, background: "#abcdef" } }).png().toBuffer(); + const service = rmbgLoader(async (input, options) => { + if (options?.method === "POST") { + const form = options.body as FormData; + expect(form.has("image_url")).toBe(false); + const file = form.get("image_file") as File; + expect((await sharp(Buffer.from(await file.arrayBuffer())).metadata()).width).toBe(Math.min(width, 4096)); + return Response.json({ data: { task_id: "task-file" } }); + } + if (String(input).endsWith("/task-file")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } }); + return new Response(image, { headers: { "content-type": "image/png" } }); + }); + await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: source, skipGallerySave: true }); + } +}); + +test("a create timeout preserves its cause and never creates another paid task", async () => { + const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).png().toBuffer(); + let creates = 0; + let abort: Error; + const service = rmbgLoader(async () => { creates++; throw abort; }); + abort = new service.RmbgServiceError("request timed out"); + abort.name = "AbortError"; + let failure: Error | undefined; + try { + await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/source.png`, skipGallerySave: true }); + } catch (error) { failure = error as Error; } + expect(creates).toBe(1); + expect(failure?.message).toContain("AOS创建任务超时(120秒)"); + expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_CREATE_TIMEOUT", outcome: "unknown" }); +}); + +test("poll and result download timeouts retain their original task and distinct advice", () => { + const poll = getRequestFailureDetails("抠图处理失败:AOS查询任务超时,结果尚未确认(任务 task-1)"); + const download = getRequestFailureDetails("抠图处理失败:AOS结果下载超时,任务已完成(任务 task-1)"); + expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" }); + expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" }); + expect(download.recommendation).toContain("取回原任务结果"); +});