From 6e4350fe2b887674f3f76f2246da73e2efd2e238 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 13:22:03 +0800 Subject: [PATCH 1/2] =?UTF-8?q?=E6=8C=89=E4=B8=8A=E6=B8=B8=E6=8A=A0?= =?UTF-8?q?=E5=9B=BE=E9=99=90=E5=88=B6=E6=A0=A1=E9=AA=8C=E5=8E=9F=E5=9B=BE?= =?UTF-8?q?=E5=B9=B6=E8=BF=94=E5=9B=9E=E5=85=B7=E4=BD=93=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E5=8E=9F=E5=9B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/ai/rmbg/service.ts | 104 +++++++++--------- lib/request-error.ts | 44 ++++++++ .../routes/canvas-toolbar-capability.test.ts | 17 +++ tests/routes/rmbg-delivery.test.ts | 85 +++++++++++++- 4 files changed, 192 insertions(+), 58 deletions(-) diff --git a/lib/ai/rmbg/service.ts b/lib/ai/rmbg/service.ts index bfd28cf4d..859e41691 100644 --- a/lib/ai/rmbg/service.ts +++ b/lib/ai/rmbg/service.ts @@ -62,7 +62,11 @@ export class RmbgServiceError extends Error { } const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); +// https://picwish.cn/background-removal-api-doc const AOS_MAX_RESOLUTION = 4096; +const AOS_MAX_INPUT_BYTES = 50 * 1024 * 1024; +// The shared AOS state -5 documents a separate limit for image_url downloads. +const AOS_MAX_URL_INPUT_BYTES = 30 * 1024 * 1024; const AOS_CREATE_TIMEOUT_MS = 120_000; const AOS_POLL_TIMEOUT_MS = 60_000; const AOS_DOWNLOAD_TIMEOUT_MS = 60_000; @@ -176,53 +180,43 @@ function getAosFriendlyErrorMessage(detail: string): string | null { async function prepareImageForAos( imageBuffer: Buffer -): Promise<{ buffer: Buffer; mimeType: string; filename: string; resized: boolean }> { +): Promise<{ buffer: Buffer; mimeType: string; filename: string }> { + if (imageBuffer.length > AOS_MAX_INPUT_BYTES) { + throw new RmbgServiceError( + `抠图参考图文件大小超限(${(imageBuffer.length / 1024 / 1024).toFixed(2)} MB),当前 AOS 上游最大支持 50 MB。`, + 400 + ); + } + + let metadata: import("sharp").Metadata; try { const sharp = await import("sharp"); - const image = sharp.default(imageBuffer); - const metadata = await image.metadata(); - const width = metadata.width; - const height = metadata.height; - const format = (metadata.format || "png").toLowerCase(); - - const ext = format === "jpeg" ? "jpg" : format; - const filenameExt = - ext === "jpg" || ext === "png" || ext === "webp" || ext === "tiff" ? ext : "png"; - const mimeType = - ext === "jpg" - ? "image/jpeg" - : ext === "webp" - ? "image/webp" - : ext === "tiff" - ? "image/tiff" - : "image/png"; - - if (!width || !height || (width <= AOS_MAX_RESOLUTION && height <= AOS_MAX_RESOLUTION)) { - return { - buffer: imageBuffer, - mimeType, - filename: `image.${filenameExt}`, - resized: false, - }; - } - - const resizedBuffer = await image - .resize(AOS_MAX_RESOLUTION, AOS_MAX_RESOLUTION, { - fit: "inside", - withoutEnlargement: true, - }) - .toBuffer(); - - return { - buffer: resizedBuffer, - mimeType, - filename: `image.${filenameExt}`, - resized: true, - }; + metadata = await sharp.default(imageBuffer, { limitInputPixels: false }).metadata(); } catch (error) { - logger.warn("aos_image_preprocess_failed", error); - return { buffer: imageBuffer, mimeType: "image/png", filename: "image.png", resized: true }; + // AOS also accepts BMP/RAW/RGB formats that Sharp cannot identify. + logger.warn("aos_image_metadata_unavailable", error); + return { buffer: imageBuffer, mimeType: "application/octet-stream", filename: "image" }; } + + const { width, height } = metadata; + if (width && height && (width > AOS_MAX_RESOLUTION || height > AOS_MAX_RESOLUTION)) { + throw new RmbgServiceError( + `抠图参考图分辨率超限(${width}×${height} 像素),当前 AOS 上游要求宽、高均不超过 ${AOS_MAX_RESOLUTION} 像素。`, + 400 + ); + } + + const format = metadata.format || ""; + if (["gif", "svg", "heif", "avif", "jp2", "jxl"].includes(format)) { + const name = format === "heif" ? "HEIF/AVIF" : format.toUpperCase(); + throw new RmbgServiceError(`抠图参考图格式不支持(${name}),当前 AOS 上游不支持此格式。`, 400); + } + const ext = format === "jpeg" ? "jpg" : format; + return { + buffer: imageBuffer, + mimeType: format ? `image/${format}` : "application/octet-stream", + filename: ext ? `image.${ext}` : "image", + }; } async function processWithRMBG( @@ -394,12 +388,12 @@ async function processWithAosSegmentation( const headers = { "X-API-KEY": config.apiKey }; let stage = "create"; let providerTaskId: string | undefined; + const sourceImage = await prepareImageForAos(imageBuffer); try { - const normalizedImage = await prepareImageForAos(imageBuffer); const createForm = new FormData(); let publicSourceUrl: string | null = null; - if (!normalizedImage.resized && config.sourceImageUrl) { + if (config.sourceImageUrl && imageBuffer.length <= AOS_MAX_URL_INPUT_BYTES) { try { const source = new URL(config.sourceImageUrl); const cdn = new URL(process.env.NEXT_PUBLIC_CANVAS_CDN_ORIGIN || ""); @@ -411,10 +405,10 @@ async function processWithAosSegmentation( if (publicSourceUrl) { createForm.append("image_url", publicSourceUrl); } else { - const imageBlob = new Blob([new Uint8Array(normalizedImage.buffer)], { - type: normalizedImage.mimeType, + const imageBlob = new Blob([new Uint8Array(sourceImage.buffer)], { + type: sourceImage.mimeType, }); - createForm.append("image_file", imageBlob, normalizedImage.filename); + createForm.append("image_file", imageBlob, sourceImage.filename); } createForm.append("sync", "0"); createForm.append("return_type", "1"); @@ -426,7 +420,7 @@ async function processWithAosSegmentation( } 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 }); + logger.info("aos_task_create_started", { requestId: config.requestId, sourceMode: publicSourceUrl ? "url" : "file", inputBytes: imageBuffer.length, uploadBytes: publicSourceUrl ? 0 : sourceImage.buffer.length }); const createResponse = await withTimeout( (signal) => @@ -447,7 +441,7 @@ async function processWithAosSegmentation( }); return { success: false, - error: `AOS任务创建失败: ${createPayload?.message || createResponse.statusText}`, + error: `AOS任务创建失败(HTTP ${createResponse.status}): ${createPayload?.message || createResponse.statusText}`, }; } @@ -485,7 +479,7 @@ async function processWithAosSegmentation( }); return { success: false, - error: `AOS任务查询失败: ${pollPayload?.message || pollResponse.statusText}`, + error: `AOS任务查询失败(HTTP ${pollResponse.status},任务 ${taskId}): ${pollPayload?.message || pollResponse.statusText}`, }; } @@ -518,7 +512,7 @@ async function processWithAosSegmentation( }); return { success: false, - error: `AOS结果下载失败: ${imageResponse.status} ${imageResponse.statusText}`, + error: `AOS结果下载失败(HTTP ${imageResponse.status},任务 ${taskId}): ${imageResponse.statusText}`, }; } @@ -538,13 +532,13 @@ async function processWithAosSegmentation( }); return { success: false, - error: `AOS任务失败: ${errMessage || stateDetail || pollPayload?.message || "状态异常"}`, + error: `AOS任务失败(状态 ${state},任务 ${taskId}): ${errMessage || stateDetail || pollPayload?.message || "未提供详情"}`, }; } } - logger.warn("aos_task_timeout", { maxPollCount }); - return { success: false, error: "AOS任务超时(最多60秒)" }; + logger.warn("aos_task_timeout", { requestId: config.requestId, taskId, maxPollCount }); + return { success: false, error: `AOS查询任务超时,结果尚未确认(任务 ${taskId})` }; } catch (error) { if (error instanceof Error && error.name === "AbortError") { logger.warn("aos_request_timeout", { requestId: config.requestId, stage, taskId: providerTaskId, inputBytes: imageBuffer.length }); diff --git a/lib/request-error.ts b/lib/request-error.ts index f3f9304dc..9d517e45b 100644 --- a/lib/request-error.ts +++ b/lib/request-error.ts @@ -228,6 +228,50 @@ export function getRequestFailureDetails( const status = getRequestErrorStatus(error); const detail = (code: string, message: string, recommendation: string, outcome: "failed" | "unknown" = "failed") => ({ code, message, recommendation, outcome }); + if (/抠图参考图文件大小超限/.test(text)) { + return detail("RMBG_INPUT_TOO_LARGE", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 50 MB 上限。", "请手动压缩至 50 MB 以内后重新提交。本次未提交上游抠图任务。"); + } + if (/抠图参考图分辨率超限/.test(text)) { + return detail("RMBG_INPUT_RESOLUTION_EXCEEDED", getRequestErrorMessage(error) || "抠图参考图超过 AOS 的 4096 像素上限。", "请手动将宽、高均调整至 4096 像素以内,再重新提交。本次未提交上游抠图任务。"); + } + if (/抠图参考图格式不支持/.test(text)) { + return detail("RMBG_INPUT_FORMAT_UNSUPPORTED", getRequestErrorMessage(error) || "AOS 不支持此参考图格式。", "请导出为 JPG、PNG 或 WebP 后重新提交。本次未提交上游抠图任务。"); + } + const aosResolution = text.match(/(?:resolution excess,\s*width=(\d+),\s*height=(\d+)|图片分辨率超限((\d+)x(\d+)))/i); + if (aosResolution) { + return detail("RMBG_PROVIDER_RESOLUTION_EXCEEDED", `AOS 拒绝了参考图:分辨率为 ${aosResolution[1] || aosResolution[3]}×${aosResolution[2] || aosResolution[4]} 像素,宽、高均须不超过 4096 像素。`, "请手动调整尺寸后重新提交。"); + } + if (/AOS.*invalid file|图片文件无法识别/i.test(text)) { + return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。"); + } + const aosState = text.match(/AOS任务失败(状态 (-\d+),/); + if (aosState) { + const state = Number(aosState[1]); + if (state === -5) return detail("RMBG_PROVIDER_URL_TOO_LARGE", "AOS 拒绝读取参考图:链接取图大小超过上游的 30 MB 上限。", "请联系管理员核对文件提交方式;不要连续点击。上游文件上传接口允许最大 50 MB。"); + if (state === -7) return detail("RMBG_PROVIDER_INVALID_FILE", "AOS 无法识别参考图,文件可能损坏或格式不受支持。", "请确认原图可以正常打开,并导出为 JPG、PNG 或 WebP 后重新提交。"); + if (state === -3) return detail("RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED", "AOS 下载参考图失败,未能读取原图。", "请确认原图链接可打开;若原图正常,请联系管理员检查上游取图线路,并提供请求编号。"); + if (state === -2) return detail("RMBG_PROVIDER_RESULT_STORAGE_FAILED", "AOS 已完成处理,但上游保存结果图片失败。", "请联系管理员核对原任务结果,避免立即重复抠图。", "unknown"); + if (state === -8) return detail("RMBG_PROVIDER_PROCESSING_TIMEOUT", "AOS 抠图处理超时,上游已返回失败状态。", "请稍后重试;若持续发生,请联系管理员并提供请求编号。"); + if (state === -11) return detail("RMBG_PROVIDER_EMPTY_RESULT", "AOS 抠图结果为空,未能得到有效主体。", "请检查图片中的主体是否清晰,尝试使用主体更明确的图片。"); + if ([-17, -16, -14, -10].includes(state)) return detail("RMBG_PROVIDER_INPUT_REJECTED", `AOS 拒绝处理参考图,图片内容未通过上游检查(状态 ${state})。`, "请更换符合上游要求的参考图;如认为判断有误,请联系管理员核对请求编号。"); + if (state === -15) return detail("RMBG_PROVIDER_RESOURCE_UNAVAILABLE", "AOS 处理资源不足,未能完成本次抠图。", "请稍后再试;持续失败时联系管理员检查上游服务。"); + return detail("RMBG_PROVIDER_TASK_FAILED", getRequestErrorMessage(error) || `AOS 抠图任务失败(状态 ${state})。`, "请联系管理员并提供请求编号,核对上游任务详情。"); + } + if (/AOS任务创建失败(HTTP 413)/.test(text)) { + return detail("RMBG_PROVIDER_FILE_TOO_LARGE", "AOS 拒绝了参考图:文件超过上游大小限制(文件上传最大 50 MB)。", "请手动压缩文件后重新提交;若文件已小于限制,请联系管理员核对上游返回。"); + } + if (/AOS任务创建失败(HTTP (400|401|404))/.test(text)) { + return detail("RMBG_PROVIDER_REQUEST_REJECTED", getRequestErrorMessage(error) || "AOS 拒绝了抠图请求。", "请联系管理员核对上游参数、服务权限及接口配置,并提供请求编号。"); + } + if (/AOS任务创建失败(HTTP (200|5\d\d))/.test(text)) { + return detail("RMBG_PROVIDER_CREATE_UNCONFIRMED", getRequestErrorMessage(error) || "AOS 未能确认抠图任务是否创建成功。", "请联系管理员核对原请求,避免连续提交造成重复处理。", "unknown"); + } + const aosReadFailure = text.match(/AOS(任务查询|结果下载)失败(HTTP (\d+)/); + if (aosReadFailure) { + return aosReadFailure[1] === "任务查询" + ? detail("RMBG_PROVIDER_POLL_FAILED", `AOS 查询抠图任务失败(HTTP ${aosReadFailure[2]}),原任务结果尚未确认。`, "请联系管理员核对原任务,不要重复提交。", "unknown") + : detail("RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", `抠图已完成,但结果图片下载失败(HTTP ${aosReadFailure[2]})。`, "请联系管理员按请求编号取回原任务结果,避免重复抠图。", "unknown"); + } if (isGatewayResponseTimeout(error)) { return detail("RESULT_UNCONFIRMED", "模型服务响应超时(HTTP 524),生成结果尚未确认。", "请先查看历史记录,避免重复提交。", "unknown"); } diff --git a/tests/routes/canvas-toolbar-capability.test.ts b/tests/routes/canvas-toolbar-capability.test.ts index 907502d19..6622813c3 100644 --- a/tests/routes/canvas-toolbar-capability.test.ts +++ b/tests/routes/canvas-toolbar-capability.test.ts @@ -4,6 +4,7 @@ import { executeCanvasToolbarCapability } from "@/lib/client/canvas-toolbar-capa import { createCanvasCapabilityClientCommand } from "@/lib/canvas/capabilities/client-command"; import { useCanvasStore } from "@/store/canvas"; import { canvasService } from "@/services/canvas"; +import { formatRequestFailure, getRequestFailureDetails } from "@/lib/request-error"; const originalFetch = globalThis.fetch; const node = (id: string) => ({ id, type: "image", position: { x: 0, y: 0 }, @@ -86,6 +87,22 @@ test("a lost submission response polls the original task and never resubmits", a expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source", "result"]); }); +test("a recovered matting rejection shows its dimensions and advice without changing the canvas or resubmitting", async () => { + const submitted = createCanvasCapabilityClientCommand({ projectId: "project-a", capabilityId: "image.remove_background", nodeIds: ["source"], source: "toolbar" }); + const failure = getRequestFailureDetails("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。"); + const message = formatRequestFailure(failure, submitted.requestId); + let posts = 0; + globalThis.fetch = (async (url, options) => { + if (options?.method === "POST") { posts++; return new Response(null, { status: 504 }); } + expect(String(url)).toContain(encodeURIComponent(submitted.requestId)); + return Response.json({ success: true, task: { status: "failed", failure, error: message } }); + }) as typeof fetch; + await expect(executeCanvasToolbarCapability(submitted)).rejects.toThrow(message); + expect(posts).toBe(1); + expect(useCanvasStore.getState().nodes.map(n => n.id)).toEqual(["source"]); + expect(useCanvasStore.getState().canvasRevision).toBe("r1"); +}); + test("switching projects during processing does not load the old result into the new project", async () => { globalThis.fetch = (async (url) => { if (String(url).endsWith("/capabilities")) { diff --git a/tests/routes/rmbg-delivery.test.ts b/tests/routes/rmbg-delivery.test.ts index 4da52f79d..18462e74b 100644 --- a/tests/routes/rmbg-delivery.test.ts +++ b/tests/routes/rmbg-delivery.test.ts @@ -39,15 +39,17 @@ test("AOS uses a public Qiniu original URL without uploading the image file agai 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) { +test("non-CDN sources upload the original file without changing its bytes or dimensions", async () => { + for (const [width, source] of [[4096, "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)); + const submitted = Buffer.from(await file.arrayBuffer()); + expect(submitted.equals(image)).toBe(true); + expect((await sharp(submitted).metadata()).width).toBe(width); 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" } }); @@ -57,6 +59,80 @@ test("oversized images and non-CDN sources keep the normalized file upload", asy } }); +test("AOS rejects either oversized dimension before creating a task and gives the exact dimensions", async () => { + for (const [width, height] of [[4097, 20], [20, 4097]]) { + const image = await sharp({ create: { width, height, channels: 3, background: "white" } }).png().toBuffer(); + let creates = 0; + const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); }); + let failure: Error | undefined; + try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, sourceImageUrl: `${cdn}/large.png`, skipGallerySave: true }); } + catch (error) { failure = error as Error; } + expect(creates).toBe(0); + expect(failure).toMatchObject({ status: 400 }); + expect(failure?.message).toContain(`${width}×${height}`); + const details = getRequestFailureDetails(failure); + expect(details).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" }); + expect(details.message).toContain("4096"); + expect(details.recommendation).toContain("本次未提交上游抠图任务"); + } +}); + +test("AOS accepts 4096 pixels and 50 MB but rejects a larger file without submitting it", async () => { + const output = await sharp({ create: { width: 4096, height: 20, channels: 3, background: "white" } }).png().toBuffer(); + const boundary = Buffer.alloc(50 * 1024 * 1024); + output.copy(boundary); + let creates = 0; + const service = rmbgLoader(async (input, options) => { + if (options?.method === "POST") { + creates++; + const form = options.body as FormData; + expect(form.has("image_url")).toBe(false); + expect((form.get("image_file") as File).size).toBe(boundary.length); + return Response.json({ data: { task_id: "boundary-task" } }); + } + if (String(input).endsWith("/boundary-task")) return Response.json({ data: { state: 1, image: "https://output.example/image.png" } }); + return new Response(output, { headers: { "content-type": "image/png" } }); + }); + await service.executeRmbgJob({ userId: "test", imageBuffer: boundary, sourceImageUrl: `${cdn}/boundary.png`, skipGallerySave: true }); + expect(creates).toBe(1); + let failure: Error | undefined; + try { await service.executeRmbgJob({ userId: "test", imageBuffer: Buffer.alloc(boundary.length + 1), skipGallerySave: true }); } + catch (error) { failure = error as Error; } + expect(creates).toBe(1); + expect(failure).toMatchObject({ status: 400 }); + expect(getRequestFailureDetails(failure)).toMatchObject({ code: "RMBG_INPUT_TOO_LARGE", outcome: "failed" }); + expect(getRequestFailureDetails(failure).message).toContain("50 MB"); +}); + +test("formats unsupported by AOS are rejected explicitly without converting or submitting", async () => { + for (const format of ["gif", "avif"] as const) { + const image = await sharp({ create: { width: 16, height: 16, channels: 3, background: "white" } }).toFormat(format).toBuffer(); + let creates = 0; + const service = rmbgLoader(async () => { creates++; throw new Error("Unexpected provider call"); }); + let failure: Error | undefined; + try { await service.executeRmbgJob({ userId: "test", imageBuffer: image, skipGallerySave: true }); } + catch (error) { failure = error as Error; } + expect(creates).toBe(0); + const details = getRequestFailureDetails(failure); + expect(details).toMatchObject({ code: "RMBG_INPUT_FORMAT_UNSUPPORTED", outcome: "failed" }); + expect(details.message).toContain(format.toUpperCase()); + expect(details.recommendation).toContain("JPG、PNG 或 WebP"); + } +}); + +test("AOS input rejections retain their cause instead of becoming a generic processing failure", () => { + const resolution = getRequestFailureDetails("抠图处理失败:图片分辨率超限(5000x3000),AOS要求宽高都不超过 4096px。请缩小后重试。"); + expect(resolution).toMatchObject({ code: "RMBG_PROVIDER_RESOLUTION_EXCEEDED", outcome: "failed" }); + expect(resolution.message).toContain("5000×3000"); + expect(getRequestFailureDetails("抠图处理失败:图片文件无法识别,请尝试转换为 JPG/PNG 后重试。")).toMatchObject({ code: "RMBG_PROVIDER_INVALID_FILE", outcome: "failed" }); + expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 413): Payload Too Large")).toMatchObject({ code: "RMBG_PROVIDER_FILE_TOO_LARGE", outcome: "failed" }); + for (const [state, code] of [[-3, "RMBG_PROVIDER_SOURCE_DOWNLOAD_FAILED"], [-5, "RMBG_PROVIDER_URL_TOO_LARGE"], [-7, "RMBG_PROVIDER_INVALID_FILE"], [-8, "RMBG_PROVIDER_PROCESSING_TIMEOUT"], [-11, "RMBG_PROVIDER_EMPTY_RESULT"]] as const) { + const details = getRequestFailureDetails(`抠图处理失败:AOS任务失败(状态 ${state},任务 original-task): Failed`); + expect(details).toMatchObject({ code, outcome: "failed" }); + expect(details.recommendation).not.toBe(""); + } +}); + 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; @@ -79,6 +155,9 @@ test("poll and result download timeouts retain their original task and distinct expect(poll).toMatchObject({ code: "RMBG_POLL_TIMEOUT", outcome: "unknown" }); expect(download).toMatchObject({ code: "RMBG_RESULT_DOWNLOAD_TIMEOUT", outcome: "unknown" }); expect(download.recommendation).toContain("取回原任务结果"); + expect(getRequestFailureDetails("抠图处理失败:AOS任务查询失败(HTTP 404,任务 task-1): Not Found")).toMatchObject({ code: "RMBG_PROVIDER_POLL_FAILED", outcome: "unknown" }); + expect(getRequestFailureDetails("抠图处理失败:AOS结果下载失败(HTTP 500,任务 task-1): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_RESULT_DOWNLOAD_FAILED", outcome: "unknown" }); + expect(getRequestFailureDetails("抠图处理失败:AOS任务创建失败(HTTP 500): Internal Error")).toMatchObject({ code: "RMBG_PROVIDER_CREATE_UNCONFIRMED", outcome: "unknown" }); }); test("network and body interruptions retry only the original task reads", async () => { -- 2.47.2 From 751cffae05b2513e05aec1ddb2a87f5bb9154e38 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 13:23:23 +0800 Subject: [PATCH 2/2] =?UTF-8?q?=E6=8A=A0=E5=9B=BE=E8=BE=93=E5=85=A5?= =?UTF-8?q?=E8=B6=85=E9=99=90=E8=BF=94=E5=9B=9E=E6=98=8E=E7=A1=AE=E7=9A=84?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E9=94=99=E8=AF=AF=E7=8A=B6=E6=80=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/canvas/[id]/capabilities/route.ts | 3 ++- tests/routes/rmbg-delivery.test.ts | 25 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/app/api/canvas/[id]/capabilities/route.ts b/app/api/canvas/[id]/capabilities/route.ts index f146da491..4d1ae30e5 100644 --- a/app/api/canvas/[id]/capabilities/route.ts +++ b/app/api/canvas/[id]/capabilities/route.ts @@ -2,6 +2,7 @@ import { NextResponse } from "next/server"; import { ZodError } from "zod"; import { auth } from "@/app/(auth)/auth"; +import { RmbgServiceError } from "@/lib/ai/rmbg/service"; import { canvasCapabilityClientCommandSchema } from "@/lib/canvas/capabilities/client-command"; import { CanvasCapabilityRuntimeError } from "@/lib/canvas/capabilities/context"; import { getCanvasCapabilityManifest } from "@/lib/canvas/capabilities/registry"; @@ -90,7 +91,7 @@ export async function POST( const failure = getRequestFailureDetails(error, "执行画布能力失败"); return NextResponse.json( { success: false, error: formatRequestFailure(failure, requestId), failure, requestId }, - { status: 500 } + { status: error instanceof RmbgServiceError ? error.status : 500 } ); } } diff --git a/tests/routes/rmbg-delivery.test.ts b/tests/routes/rmbg-delivery.test.ts index 18462e74b..2c1f44d09 100644 --- a/tests/routes/rmbg-delivery.test.ts +++ b/tests/routes/rmbg-delivery.test.ts @@ -133,6 +133,31 @@ test("AOS input rejections retain their cause instead of becoming a generic proc } }); +test("the canvas API returns input-limit errors as HTTP 400 and preserves the same advice when polling", async () => { + const service = rmbgLoader(async () => { throw new Error("Unexpected provider call"); }); + const error = new service.RmbgServiceError("抠图参考图分辨率超限(5056×3392 像素),当前 AOS 上游要求宽、高均不超过 4096 像素。", 400); + const route = serverLoader({ + "@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-a" } }) }, + "@/lib/ai/rmbg/service": { RmbgServiceError: service.RmbgServiceError }, + "@/lib/canvas/capabilities/client-command": { canvasCapabilityClientCommandSchema: { parse: (v: unknown) => v } }, + "@/lib/canvas/capabilities/context": { CanvasCapabilityRuntimeError: class extends Error {} }, + "@/lib/canvas/agent-mutations": { CanvasAgentMutationError: class extends Error {} }, + "@/lib/canvas/capabilities/registry": { getCanvasCapabilityManifest: () => [] }, + "@/lib/canvas/capabilities/runtime": { executeCanvasCapabilityRuntime: async () => { throw error; } }, + "@/lib/canvas/capabilities/task-store": { getCanvasCapabilityTask: async () => ({ id: "task-a", status: "failed", error: error.message }) }, + })("app/api/canvas/[id]/capabilities/route.ts"); + const params = { params: Promise.resolve({ id: "project-a" }) }; + const response = await route.POST(new Request("https://example.com/api/canvas/project-a/capabilities", { method: "POST", body: JSON.stringify({ requestId: "request-a" }) }), params); + expect(response.status).toBe(400); + const submitted = await response.json(); + expect(submitted.failure).toMatchObject({ code: "RMBG_INPUT_RESOLUTION_EXCEEDED", outcome: "failed" }); + expect(submitted.error).toContain("5056×3392"); + expect(submitted.error).toContain("本次未提交上游抠图任务"); + const polled = await (await route.GET(new Request("https://example.com/api/canvas/project-a/capabilities?requestId=request-a"), params)).json(); + expect(polled.task.failure).toEqual(submitted.failure); + expect(polled.task.error).toContain(submitted.failure.recommendation); +}); + 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; -- 2.47.2