From f157161ad906978a8b926ae340b519df83d317f6 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 11 Nov 2026 09:31:22 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8E=BB=E6=B0=B4=E5=8D=B0=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E5=B7=B2=E5=AD=98=E4=B8=83=E7=89=9B=E7=9A=84?= =?UTF-8?q?=E5=9B=BE=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/watermark-remove/route.ts | 83 +++++++++++++++-------- tests/routes/image-delivery-qiniu.test.ts | 59 ++++++++++++++++ 2 files changed, 112 insertions(+), 30 deletions(-) diff --git a/app/api/watermark-remove/route.ts b/app/api/watermark-remove/route.ts index 2c1227008..601165d40 100644 --- a/app/api/watermark-remove/route.ts +++ b/app/api/watermark-remove/route.ts @@ -6,6 +6,7 @@ import { eq } from "drizzle-orm"; import { uploadFile } from "@/lib/storage/service"; import { createMediaAssetRelation, + getDefaultStorageConfig, getPointsSystemConfig, getMediaAssetsByUserAndUrls, upsertMediaAsset, @@ -21,6 +22,25 @@ export const maxDuration = 600; const WATERMARK_SYNC_WAIT_TIMEOUT_MS = 20_000; const WATERMARK_POLL_INTERVAL_MS = 1200; +async function resolveStoredQiniuImageUrl(source: string): Promise { + const config = await getDefaultStorageConfig().catch(() => null); + if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null; + + try { + const base = new URL(config.publicUrl); + const url = new URL(source); + const prefix = base.pathname.replace(/\/$/, "") + "/"; + if (base.protocol !== "https:" || url.origin !== base.origin || + url.username || url.password || url.search || url.hash || + !url.pathname.startsWith(prefix)) return null; + + const objectKey = url.pathname.slice(prefix.length); + return /^uploads\/[\w.-]+$/.test(objectKey) ? url.toString() : null; + } catch { + return null; + } +} + // 获取去水印API配置 async function getWatermarkRemoveConfig() { try { @@ -131,13 +151,16 @@ async function persistWatermarkResult(params: { processing_time?: number; }> { try { - const imageResponse = await fetch(params.processedImageUrl); - if (!imageResponse.ok) { - throw new Error("下载处理后的图片失败"); + let resultUrl = await resolveStoredQiniuImageUrl(params.processedImageUrl); + if (!resultUrl) { + const imageResponse = await fetch(params.processedImageUrl); + if (!imageResponse.ok) { + throw new Error("下载处理后的图片失败"); + } + const imageBuffer = await imageResponse.arrayBuffer(); + const filename = `watermark-removed-${Date.now()}.png`; + resultUrl = (await uploadFile(filename, imageBuffer)).url; } - const imageBuffer = await imageResponse.arrayBuffer(); - const filename = `watermark-removed-${Date.now()}.png`; - const uploadResult = await uploadFile(filename, imageBuffer); let saved = false; let saveError: unknown = null; @@ -150,8 +173,8 @@ async function persistWatermarkResult(params: { try { ([savedImage] = await db.insert(generatedImage).values({ userId: params.userId, - thumbnailUrl: uploadResult.url, - originalUrl: uploadResult.url, + thumbnailUrl: resultUrl, + originalUrl: resultUrl, imageSize: "2K", prompt: "去水印处理", modelId: "watermark-remove", @@ -184,9 +207,9 @@ async function persistWatermarkResult(params: { mediaType: "image", sourceType: "edited", status: "ready", - originalUrl: uploadResult.url, - previewUrl: uploadResult.url, - thumbnailUrl: uploadResult.url, + originalUrl: resultUrl, + previewUrl: resultUrl, + thumbnailUrl: resultUrl, displayName: "去水印处理", prompt: "去水印处理", modelId: "watermark-remove", @@ -217,8 +240,8 @@ async function persistWatermarkResult(params: { return { success: true, - original_url: uploadResult.url, - thumbnail_url: uploadResult.url, + original_url: resultUrl, + thumbnail_url: resultUrl, processing_time: typeof params.startedAt === "number" ? (Date.now() - params.startedAt) / 1000 : undefined, }; @@ -362,7 +385,7 @@ export async function POST(request: NextRequest) { const body = await request.json(); const { image_url } = body; - if (!image_url) { + if (typeof image_url !== "string" || !image_url) { return NextResponse.json( { error: "请提供图片URL" }, { status: 400 } @@ -413,24 +436,24 @@ export async function POST(request: NextRequest) { ); } } - // 如果是 HTTP/HTTPS URL,下载后重新上传确保公网可访问 + // 七牛上的公开图片可以直接交给去水印服务读取。 else if (image_url.startsWith("http://") || image_url.startsWith("https://")) { - try { - console.log("[WatermarkRemove] 下载原图并上传到S3..."); - const imageResponse = await fetch(image_url); - if (!imageResponse.ok) { - throw new Error("下载原图失败"); + const storedImageUrl = await resolveStoredQiniuImageUrl(image_url); + publicImageUrl = storedImageUrl || image_url; + if (!storedImageUrl) { + try { + console.log("[WatermarkRemove] 下载原图并上传到S3..."); + const imageResponse = await fetch(image_url); + if (!imageResponse.ok) { + throw new Error("下载原图失败"); + } + const imageBuffer = await imageResponse.arrayBuffer(); + const filename = `watermark-input-${Date.now()}.jpg`; + publicImageUrl = (await uploadFile(filename, imageBuffer)).url; + console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl); + } catch (downloadError) { + console.error("[WatermarkRemove] 下载/上传原图失败,使用原始URL:", downloadError); } - const imageBuffer = await imageResponse.arrayBuffer(); - const filename = `watermark-input-${Date.now()}.jpg`; - const uploadResult = await uploadFile(filename, imageBuffer); - publicImageUrl = uploadResult.url; - console.log("[WatermarkRemove] 图片已上传到S3:", publicImageUrl); - } catch (downloadError) { - console.error("[WatermarkRemove] 下载/上传原图失败:", downloadError); - // 如果下载失败,尝试使用原始URL - console.log("[WatermarkRemove] 尝试使用原始URL"); - publicImageUrl = image_url; } } else { return NextResponse.json( diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts index ed02cef4e..d1dad76ee 100644 --- a/tests/routes/image-delivery-qiniu.test.ts +++ b/tests/routes/image-delivery-qiniu.test.ts @@ -7,6 +7,65 @@ import { serverLoader } from "../helpers/canvas-download-server-loader"; import { NextResponse } from "next/server"; test.describe("Qiniu image delivery", () => { + for (const source of [ + "https://cdn.example/media/uploads/source.png", + "https://cdn.example/media/uploads/source.png?token=temporary", + "https://cdn.example/media-other/uploads/source.png", + ]) { + test(`watermark removal handles ${source} according to its storage origin and signature`, async () => { + const storedResult = "https://cdn.example/media/uploads/result.png"; + const uploadedInput = "https://cdn.example/media/uploads/staged.png"; + const reusesSource = source === "https://cdn.example/media/uploads/source.png"; + const transferred: string[] = []; + const uploaded: string[] = []; + const saved: string[] = []; + const route = serverLoader({ + "next/server": { NextResponse: Response }, + "@/app/(auth)/auth": { auth: async () => ({ user: { id: "user-1" } }) }, + "@/lib/db": { db: { + select: () => ({ from: () => ({ where: () => ({ limit: async () => [{ value: { apiKey: "fixture" } }] }) }) }), + insert: () => ({ values: (row: { originalUrl: string }) => ({ + returning: async () => { saved.push(row.originalUrl); return [{ id: "image-1" }]; }, + }) }), + } }, + "@/lib/db/schema": { systemSettings: {}, generatedImage: {} }, + "drizzle-orm": { eq: () => null }, + "@/lib/db/queries": { + getDefaultStorageConfig: async () => ({ type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example/media" }), + getPointsSystemConfig: async () => ({}), + getMediaAssetsByUserAndUrls: async () => [], + upsertMediaAsset: async () => null, + }, + "@/lib/storage/service": { + uploadFile: async (filename: string) => { uploaded.push(filename); return { url: uploadedInput }; }, + }, + "@/lib/points/feature-charge": { prepareFeaturePointsCharge: async () => ({ ok: true, chargeable: false }) }, + "@/lib/points/config": { getImageToolCreditCost: () => 0 }, + "@/lib/canvas/generate-image": {}, + fetch: async (url: string, init?: RequestInit) => { + if (new URL(url).hostname === "techsz.aoscdn.com") { + if (init?.method === "POST") { + expect((init.body as FormData).get("url")).toBe(reusesSource ? source : uploadedInput); + return Response.json({ data: { task_id: "fixture-task" } }); + } + return Response.json({ data: { state: 1, file: storedResult } }); + } + transferred.push(url); + return new Response(new Uint8Array([1, 2, 3])); + }, + })("app/api/watermark-remove/route.ts"); + + const response = await route.POST(new Request("https://app.example/api/watermark-remove", { + method: "POST", + body: JSON.stringify({ image_url: source }), + })); + expect(await response.json()).toMatchObject({ success: true, original_url: storedResult }); + expect(transferred).toEqual(reusesSource ? [] : [source]); + expect(uploaded).toHaveLength(reusesSource ? 0 : 1); + expect(saved).toEqual([storedResult]); + }); + } + test("model result buckets use our thumbnail endpoint without forbidden OSS transforms", () => { const source = "https://dashscope-a717.oss-accelerate.aliyuncs.com/output.png"; const preview = new URL(buildImageProxyUrl(source, { width: 960, format: "webp" }), "https://app.example");