From 07451f555859bdb52927dbc184bce245870b16d8 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Thu, 5 Nov 2026 09:32:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=97=A7=E9=98=BF=E9=87=8C=E5=AD=98=E5=82=A8?= =?UTF-8?q?=E9=93=BE=E6=8E=A5=E4=BC=98=E5=85=88=E4=BD=BF=E7=94=A8=E4=B8=83?= =?UTF-8?q?=E7=89=9B=E5=89=AF=E6=9C=AC=E5=B9=B6=E5=81=9C=E6=AD=A2=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E6=BA=90=E9=87=8D=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/canvas/3d/model-proxy/route.ts | 14 +- app/api/image-proxy/route.ts | 20 ++- lib/ai/providers/alibaba-image.ts | 8 +- lib/canvas/generate-image.ts | 18 ++- lib/image-delivery.ts | 4 +- lib/request-error.ts | 3 + lib/storage/image-archive-db.ts | 18 ++- lib/storage/image-archive-urls.ts | 6 + lib/storage/image-byte-cache.ts | 15 +- lib/storage/migration-db.ts | 5 +- lib/storage/retired-storage.ts | 23 ++++ lib/storage/service.ts | 8 +- playwright.helpers.config.ts | 2 + ...mage-provider-error-classification.test.ts | 1 + tests/routes/legacy-oss-recovery.test.ts | 129 ++++++++++++++++++ tests/routes/retired-storage.test.ts | 10 +- 16 files changed, 258 insertions(+), 26 deletions(-) create mode 100644 tests/routes/legacy-oss-recovery.test.ts diff --git a/app/api/canvas/3d/model-proxy/route.ts b/app/api/canvas/3d/model-proxy/route.ts index f01d2025b..d350a52fd 100644 --- a/app/api/canvas/3d/model-proxy/route.ts +++ b/app/api/canvas/3d/model-proxy/route.ts @@ -1,5 +1,5 @@ import { NextResponse } from "next/server"; -import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; +import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { auth } from "@/app/(auth)/auth"; import { buildThreeAssetUrl } from "@/lib/canvas/three-asset-url"; @@ -182,6 +182,18 @@ async function handleRequest(request: Request, method: "GET" | "HEAD") { return NextResponse.json({ success: false, error: "仅支持 http/https 模型地址" }, { status: 400 }); } + if (normalizeLegacyAliyunStorageUrl(targetUrl.toString())) { + try { + const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db"); + targetUrl = new URL(await resolveLegacyAliyunStorageUrl(targetUrl.toString())); + } catch (error) { + if (error instanceof Error && error.message.includes("HTTP 410")) { + return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 }); + } + throw error; + } + } + if (!isAllowedModelHost(targetUrl.hostname)) { return NextResponse.json({ success: false, error: "不允许的模型地址域名" }, { status: 403 }); } diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index 9abf3db3e..f630c0617 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -1,5 +1,5 @@ import crypto from "node:crypto"; -import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; +import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; import { NextRequest, NextResponse } from "next/server"; import { formatDownloadFailure, getDownloadFailure } from "@/lib/canvas/download-error"; import { createLogger } from "@/lib/observability/logger"; @@ -298,6 +298,24 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Only http/https is supported" }, { status: 400 }); } + if (normalizeLegacyAliyunStorageUrl(target.toString())) { + try { + if (searchParams.get("original") === "1") { + const { buildImageProxyUrl } = await import("@/lib/image-delivery"); + buildImageProxyUrl(target.toString(), { delivery: "original" }); + } + const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db"); + target = new URL(await resolveLegacyAliyunStorageUrl(target.toString())); + } catch (error) { + if (searchParams.get("original") === "1") return downloadFailure(error); + const failure = getDownloadFailure(error); + return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId, + code: failure.code === "STORAGE_RETIRED" ? "storage_retired" : failure.code }, { + status: failure.status, headers: { "Cache-Control": "no-store" }, + }); + } + } + if (isBlockedHost(target.hostname)) { return NextResponse.json({ error: "Blocked target host" }, { status: 403 }); } diff --git a/lib/ai/providers/alibaba-image.ts b/lib/ai/providers/alibaba-image.ts index e76fe5240..1b25a4eea 100644 --- a/lib/ai/providers/alibaba-image.ts +++ b/lib/ai/providers/alibaba-image.ts @@ -14,6 +14,7 @@ import { isAlibabaImageTextToImageModelId } from "@/lib/ai/model-classification" import { buildReferenceImageOrderPrompt } from "@/lib/ai/providers/reference-image-order"; import { createDeferredUploadedImage } from "@/lib/storage/image-archive"; import { uploadFile } from "@/lib/storage/service"; +import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage"; import { isAlibabaQwenImage3ModelId, resolveAlibabaImageAsyncEndpoint, @@ -129,11 +130,16 @@ async function fetchImageBufferWithRetry( if (!match) throw new Error("输入图片数据格式无效"); return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] }; } + let sourceUrl = imageUrl; + if (normalizeLegacyAliyunStorageUrl(sourceUrl)) { + const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db"); + sourceUrl = await resolveLegacyAliyunStorageUrl(sourceUrl); + } let lastError: unknown = null; for (let attempt = 1; attempt <= attempts; attempt += 1) { const ctx = createAbortSignal(timeoutMs); try { - const response = await fetch(imageUrl, { + const response = await fetch(sourceUrl, { cache: "no-store", redirect: "follow", signal: ctx.signal, diff --git a/lib/canvas/generate-image.ts b/lib/canvas/generate-image.ts index 6a9bb6a02..bcee6aabd 100644 --- a/lib/canvas/generate-image.ts +++ b/lib/canvas/generate-image.ts @@ -45,6 +45,7 @@ import { import { uploadFile } from "@/lib/storage/service"; import { isImageModelBalanceBlocked, resolveConfiguredImageBalanceFallback } from "@/lib/ai/runtime/image-balance"; import { isProviderBalanceError } from "@/lib/request-error"; +import { normalizeLegacyAliyunStorageUrl } from "@/lib/storage/retired-storage"; export interface CanvasLocalEditCompositeRegion { x: number; @@ -4632,10 +4633,10 @@ function findCanvasFailoverModel(params: { } export async function generateCanvasImage( - request: CanvasGenerateImageRequest + input: CanvasGenerateImageRequest ): Promise { const startedAt = Date.now(); - if (request.agentImageMode === "pinned") request = { ...request, allowFailover: false }; + const request = input.agentImageMode === "pinned" ? { ...input, allowFailover: false } : input; const prompt = String(request.prompt || "").trim(); if (!prompt) { throw new Error("请输入提示词"); @@ -4873,11 +4874,16 @@ export async function generateCanvasImage( typeof request.ownerKey === "string" && request.ownerKey.trim() ? request.ownerKey.trim() : undefined; - const referenceImage = - typeof request.referenceImage === "string" ? request.referenceImage : undefined; - const referenceImages = Array.isArray(request.referenceImages) + const resolveReference = async (url: string) => { + if (!normalizeLegacyAliyunStorageUrl(url)) return url; + const { resolveLegacyAliyunStorageUrl } = await import("@/lib/storage/image-archive-db"); + return resolveLegacyAliyunStorageUrl(url); + }; + const referenceImage = typeof request.referenceImage === "string" + ? await resolveReference(request.referenceImage) : undefined; + const referenceImages = await Promise.all((Array.isArray(request.referenceImages) ? request.referenceImages - : []; + : []).map(resolveReference)); const debugLabel = String(request.debugLabel || "").trim() || "canvas-image"; const debugMeta = request.debugMeta && typeof request.debugMeta === "object" diff --git a/lib/image-delivery.ts b/lib/image-delivery.ts index 299ee7cc0..656106ab7 100644 --- a/lib/image-delivery.ts +++ b/lib/image-delivery.ts @@ -1,3 +1,5 @@ +import { normalizeLegacyAliyunStorageUrl } from "./storage/retired-storage"; + const IMAGE_PROXY_PATH = "/api/image-proxy"; type ImageProxyFormat = "webp" | "avif" | "jpeg" | "png"; @@ -200,7 +202,7 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {}) return trustedDirectStorageUrl; } - if (options.delivery !== "proxy") { + if (options.delivery !== "proxy" && !normalizeLegacyAliyunStorageUrl(source)) { const directDisplayUrl = resolveAliyunOssDisplayUrl(source, options); if (directDisplayUrl) { return directDisplayUrl; diff --git a/lib/request-error.ts b/lib/request-error.ts index 1bd2d9ffe..fd5a94902 100644 --- a/lib/request-error.ts +++ b/lib/request-error.ts @@ -243,6 +243,9 @@ export function getRequestFailureDetails( if (/content.?polic|moderation|safety|policy_rejected|内容(?:敏感|审核|限制)|不能生成像那样|不能生成那样|违规/i.test(text)) { return detail("CONTENT_POLICY_REJECTED", "模型服务因内容审核限制拒绝了本次请求。", "请调整描述或参考图中的受限内容后再提交;重复提交相同内容通常无法解决。"); } + if (/旧存储已停用|storage_retired/i.test(text)) { + return detail("SOURCE_STORAGE_RETIRED", "参考文件所在的旧存储已停用,且没有可用的归档副本。", "请重新上传原始文件后再提交;切换模型或重复提交无法恢复该文件。"); + } if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) { return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown"); } diff --git a/lib/storage/image-archive-db.ts b/lib/storage/image-archive-db.ts index 432cf3682..bb28f4ff0 100644 --- a/lib/storage/image-archive-db.ts +++ b/lib/storage/image-archive-db.ts @@ -1,5 +1,5 @@ import "server-only"; -import { isRetiredStorageUrl } from "./retired-storage"; +import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { createHash } from "node:crypto"; import { @@ -22,6 +22,8 @@ export function getImageArchiveDisplayHoldMs() { } export function imageArchiveSourceKey(source: string) { + const legacy = normalizeLegacyAliyunStorageUrl(source); + if (legacy) return legacy; if (!source.startsWith("data:")) return source; const configured = process.env.NEXT_PUBLIC_APP_URL || process.env.AUTH_URL || process.env.NEXTAUTH_URL; @@ -39,7 +41,7 @@ export async function enqueueImageArchiveTask(params: { metadata?: Record | null; }) { const sourceUrl = String(params.sourceUrl || "").trim(); - if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl)) return null; + if (!isGeneratedImageSource(sourceUrl) || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return null; const now = new Date(); const archiveAfterAt = params.archiveAfterAt || now; @@ -312,6 +314,18 @@ export async function getArchivedImageUrlMappings( return replacements; } +/** Legacy OSS is unavailable; only an existing durable copy can serve its URLs. */ +export async function resolveLegacyAliyunStorageUrl(source: string): Promise { + const legacy = normalizeLegacyAliyunStorageUrl(source); + if (!legacy) return source; + const mappings = await getArchivedImageUrlMappings([source, legacy]); + const target = mappings[source] || mappings[legacy]; + if (!target || normalizeLegacyAliyunStorageUrl(target) || isRetiredStorageUrl(target)) { + throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`); + } + return target; +} + // Normalize late writes too: an archive can finish before a task/canvas save arrives. export async function resolveArchivedImageUrls( value: T, diff --git a/lib/storage/image-archive-urls.ts b/lib/storage/image-archive-urls.ts index 344623a18..a870a8426 100644 --- a/lib/storage/image-archive-urls.ts +++ b/lib/storage/image-archive-urls.ts @@ -1,5 +1,6 @@ // Shared by the server and canvas; no storage or network work happens here. export type ArchivedImageUrlMap = Record; +const legacyOssUrlPattern = /https?:\/\/demo-sparkai\.oss-cn-shanghai\.aliyuncs\.com\/[^\s"'<>`\\)\]}|]+/gi; export function isGeneratedImageSource(value: string) { return ( @@ -11,6 +12,7 @@ export function collectImageArchiveSources(value: unknown): string[] { const urls = new Set(); const visit = (item: unknown) => { if (typeof item === "string") { + for (const match of item.matchAll(legacyOssUrlPattern)) urls.add(match[0].replace(/,+$/, "")); if (isGeneratedImageSource(item)) urls.add(item); else for (const match of item.matchAll(/!\[[^\]]*\]\(([^)]+)\)/g)) { @@ -35,6 +37,10 @@ export function replaceArchivedImageUrls(value: T, replacements: ArchivedImag .map((part) => replacements[part] || part) .join("|||"); return `![${nextAlt}](${replacements[url] || url})`; + }).replace(legacyOssUrlPattern, (url) => { + if (replacements[url]) return replacements[url]; + const source = url.replace(/,+$/, ""); + return (replacements[source] || source) + url.slice(source.length); }) as T; } if (!value || typeof value !== "object" || value instanceof Date) return value; diff --git a/lib/storage/image-byte-cache.ts b/lib/storage/image-byte-cache.ts index f387d92b1..adaf9137f 100644 --- a/lib/storage/image-byte-cache.ts +++ b/lib/storage/image-byte-cache.ts @@ -1,5 +1,5 @@ import "server-only"; -import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; +import { isRetiredStorageUrl, normalizeLegacyAliyunStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { createHash, randomUUID } from "node:crypto"; import { mkdir, readFile, writeFile, rename, readdir, stat, statfs, unlink } from "node:fs/promises"; import { join } from "node:path"; @@ -83,13 +83,18 @@ function isMediaResponse(data: ImageBytes) { } export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) { if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`); - return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => { - if (url.startsWith("data:")) { - const match = url.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i); + let sourceUrl = url; + if (normalizeLegacyAliyunStorageUrl(url)) { + const { resolveLegacyAliyunStorageUrl } = await import("./image-archive-db"); + sourceUrl = await resolveLegacyAliyunStorageUrl(url); + } + return onceImageBytes(`source:${options.scope || ""}:${sourceUrl}`, async () => { + if (sourceUrl.startsWith("data:")) { + const match = sourceUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,([a-z0-9+/=]+)$/i); if (!match) throw new Error("Invalid inline generated image"); return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] }; } - const response = await fetch(url, { + const response = await fetch(sourceUrl, { headers: options.headers, cache: "no-store", redirect: "follow", // The deadline covers the body as well as the response headers. signal: options.signal diff --git a/lib/storage/migration-db.ts b/lib/storage/migration-db.ts index 49e54f3fe..08d1ed85f 100644 --- a/lib/storage/migration-db.ts +++ b/lib/storage/migration-db.ts @@ -1,5 +1,5 @@ import "server-only"; -import { isRetiredStorageUrl } from "./retired-storage"; +import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, normalizeLegacyAliyunStorageUrl } from "./retired-storage"; import { and, asc, desc, eq, inArray, lte, sql } from "drizzle-orm"; import { db } from "@/lib/db"; @@ -19,6 +19,7 @@ function isUsableS3Config(config: StorageConfig | null | undefined): config is S config.isEnabled && !isRetiredStorageUrl(config.endpoint) && !isRetiredStorageUrl(config.publicUrl) && + !isLegacyAliyunStorageConfig(config) && String(config.endpoint || "").trim() && String(config.bucket || "").trim() && String(config.accessKeyId || "").trim() && @@ -83,7 +84,7 @@ export async function enqueueStorageMigrationTask({ migrateAfterAt: Date; cleanupAfterAt: Date; }) { - if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl)) return; + if (!sourceUrl || !sourcePath || !sourceStorageConfigId || isRetiredStorageUrl(sourceUrl) || normalizeLegacyAliyunStorageUrl(sourceUrl)) return; await db .insert(storageMigrationTask) diff --git a/lib/storage/retired-storage.ts b/lib/storage/retired-storage.ts index b5e5a7779..18d47455d 100644 --- a/lib/storage/retired-storage.ts +++ b/lib/storage/retired-storage.ts @@ -2,6 +2,29 @@ export const RETIRED_STORAGE_URL_PATTERN = '^https?://s3[.]1080p[.]hk([/:?#]|$)'; export const RETIRED_STORAGE_MESSAGE = '旧存储已停用,源文件已无法恢复,请重新上传。'; +export function normalizeLegacyAliyunStorageUrl(value: string | null | undefined): string | null { + if (!value) return null; + try { + let url = new URL(value, 'https://local.invalid'); + if (url.pathname === '/api/image-proxy' || url.pathname === '/api/canvas/3d/model-proxy') { + url = new URL(url.searchParams.get('url') || ''); + } + if (url.hostname !== 'demo-sparkai.oss-cn-shanghai.aliyuncs.com') return null; + url.protocol = 'https:'; + url.hash = ''; + for (const name of ['Signature', 'OSSAccessKeyId', 'Expires', 'security-token']) url.searchParams.delete(name); + url.searchParams.sort(); + return url.toString(); + } catch { return null; } +} + +export function isLegacyAliyunStorageConfig(config: { bucket?: string | null; endpoint?: string | null; publicUrl?: string | null }): boolean { + if (normalizeLegacyAliyunStorageUrl(config.publicUrl)) return true; + try { + return config.bucket === 'demo-sparkai' && new URL(config.endpoint || '').hostname.endsWith('.aliyuncs.com'); + } catch { return false; } +} + export function isRetiredStorageUrl(value: string | null | undefined): boolean { if (!value) return false; try { diff --git a/lib/storage/service.ts b/lib/storage/service.ts index 7bf669ca6..a98f71e6a 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -1,5 +1,5 @@ import "server-only"; -import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; +import { isRetiredStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "./retired-storage"; import { DeleteObjectCommand, PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; import { put } from "@vercel/blob"; @@ -150,7 +150,7 @@ export function getContentType(filename: string): string { } export function createS3Client(config: S3UploadConfig): S3Client { - if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl)) { + if (isRetiredStorageUrl(config.endpoint) || isRetiredStorageUrl(config.publicUrl) || isLegacyAliyunStorageConfig(config)) { throw new Error(RETIRED_STORAGE_MESSAGE); } const normalizedEndpoint = normalizeEndpoint(config.endpoint); @@ -343,6 +343,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null { !value.isEnabled || isRetiredStorageUrl(endpoint) || isRetiredStorageUrl(String(value.publicUrl || "")) || + isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: String(value.publicUrl || "") }) || !endpoint || !bucket || !accessKeyId || @@ -370,7 +371,8 @@ function buildEnvS3Config(): S3UploadConfig | null { const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? ""; const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? ""; - if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && bucket && accessKeyId && secretAccessKey) { + if (endpoint && !isRetiredStorageUrl(endpoint) && !isRetiredStorageUrl(process.env.S3_PUBLIC_URL) && + !isLegacyAliyunStorageConfig({ bucket, endpoint, publicUrl: process.env.S3_PUBLIC_URL }) && bucket && accessKeyId && secretAccessKey) { return { endpoint, region: process.env.S3_REGION?.trim() || "auto", diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index 2a028a8de..199b8d8d0 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", testMatch: [ + /routes\/legacy-oss-recovery\.test\.ts/, + /routes\/retired-storage\.test\.ts/, /routes\/original-image-download\.test\.ts/, /routes\/canvas-download-speed\.test\.ts/, /routes\/canvas-download-recovery\.test\.ts/, diff --git a/tests/routes/image-provider-error-classification.test.ts b/tests/routes/image-provider-error-classification.test.ts index 499fb9dcc..21aa07be9 100644 --- a/tests/routes/image-provider-error-classification.test.ts +++ b/tests/routes/image-provider-error-classification.test.ts @@ -17,6 +17,7 @@ test("known provider and platform failures explain their cause and next action", ["generate image failed", "PROVIDER_EXECUTION_FAILED", "稍后重试"], ["excessive system load", "PROVIDER_EXECUTION_FAILED", "稍后重试"], ["get image url failed", "SOURCE_IMAGE_UNAVAILABLE", "重新上传"], + ["HTTP 410: 旧存储已停用,源文件已无法恢复,请重新上传。", "SOURCE_STORAGE_RETIRED", "重新上传"], ["抠图处理失败", "PROCESSING_FAILED", "核对原任务"], ["去水印处理失败", "PROCESSING_FAILED", "核对原任务"], ["图片上传到存储桶失败", "RESULT_STORAGE_FAILED", "不要重复生成"], diff --git a/tests/routes/legacy-oss-recovery.test.ts b/tests/routes/legacy-oss-recovery.test.ts new file mode 100644 index 000000000..880268c97 --- /dev/null +++ b/tests/routes/legacy-oss-recovery.test.ts @@ -0,0 +1,129 @@ +import { test, expect } from "@playwright/test"; +import { NextRequest, NextResponse } from "next/server"; +import { serverLoader } from "../helpers/canvas-download-server-loader"; +import { normalizeLegacyAliyunStorageUrl, isLegacyAliyunStorageConfig, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; +import { collectImageArchiveSources, replaceArchivedImageUrls } from "@/lib/storage/image-archive-urls"; +import { buildImageProxyUrl } from "@/lib/image-delivery"; + +const legacy = "https://demo-sparkai.oss-cn-shanghai.aliyuncs.com/uploads/product.png"; +const permanent = "https://scqnycdn.jiansun.vip/legacy-oss/demo-sparkai/uploads/product.png"; + +test("legacy identity preserves image variants while dropping expired OSS credentials", () => { + const signed = legacy.replace("https:", "http:") + "?Signature=expired&Expires=1&OSSAccessKeyId=old&x-oss-process=image%2Fresize%2Cw_100#preview"; + expect(normalizeLegacyAliyunStorageUrl(signed)).toBe(legacy + "?x-oss-process=image%2Fresize%2Cw_100"); + expect(normalizeLegacyAliyunStorageUrl("/api/image-proxy?url=" + encodeURIComponent(signed))).toBe(normalizeLegacyAliyunStorageUrl(signed)); + for (const source of [permanent, "https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/image.png", "https://other-bucket.oss-cn-shanghai.aliyuncs.com/a.png"]) { + expect(normalizeLegacyAliyunStorageUrl(source)).toBeNull(); + } + expect(isLegacyAliyunStorageConfig({ bucket: "demo-sparkai", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(true); + expect(isLegacyAliyunStorageConfig({ bucket: "other-bucket", endpoint: "https://oss-cn-shanghai.aliyuncs.com" })).toBe(false); +}); + +test("late canvas saves replace legacy URLs embedded in SVG, HTML and Markdown originals", () => { + const content = { svg: ``, html: ``, text: `![thumb|||${legacy}|||2K](${permanent})` }; + expect(collectImageArchiveSources(content)).toContain(legacy); + const result = replaceArchivedImageUrls(content, { [legacy]: permanent }); + expect(JSON.stringify(result)).not.toContain("https://demo-sparkai."); + expect(result.svg).toContain(permanent); + expect(result.text).toBe(`![thumb|||${permanent}|||2K](${permanent})`); +}); + +test("legacy URLs in comma-separated reference descriptions keep sentence punctuation", () => { + const text = `Reference: ${legacy}, next reference`; + expect(collectImageArchiveSources(text)).toContain(legacy); + expect(replaceArchivedImageUrls(text, { [legacy]: permanent })).toBe(`Reference: ${permanent}, next reference`); +}); + +test("legacy display URLs go through archive resolution while other Alibaba buckets keep working", () => { + const url = buildImageProxyUrl(legacy, { width: 400 }); + expect(url).toMatch(/^\/api\/image-proxy\?/); + expect(new URL(url, "https://app.example").searchParams.get("url")).toBe(legacy); + expect(buildImageProxyUrl("https://dashscope-result-bj.oss-cn-beijing.aliyuncs.com/result.png", { width: 400 })).toContain("x-oss-process="); +}); + +function archiveLoader(rows: Array<{ sourceUrl: string; targetUrl: string | null }>) { + return serverLoader({ + "@/lib/db": { db: { select: () => ({ from: () => ({ where: async () => rows }) }), insert: () => { throw new Error("Legacy sources must not be queued"); } } }, + "@/lib/db/schema": { imageArchiveTask: { sourceUrl: "source", targetUrl: "target" } }, + })("lib/storage/image-archive-db.ts"); +} + +test("archive lookup serves signed legacy links from the durable Qiniu alias", async () => { + const archive = archiveLoader([{ sourceUrl: legacy, targetUrl: permanent }]); + expect(await archive.resolveLegacyAliyunStorageUrl(legacy + "?Expires=1&Signature=expired")).toBe(permanent); + expect(await archive.resolveLegacyAliyunStorageUrl(permanent)).toBe(permanent); + expect(await archive.enqueueImageArchiveTask({ sourceUrl: legacy })).toBeNull(); +}); + +test("legacy files without a durable copy fail permanently instead of fetching the old bucket", async () => { + const archive = archiveLoader([]); + await expect(archive.resolveLegacyAliyunStorageUrl(legacy)).rejects.toThrow("HTTP 410"); + let fetched = 0; + const bytes = serverLoader({ + "./image-archive-db": { resolveLegacyAliyunStorageUrl: archive.resolveLegacyAliyunStorageUrl }, + fetch: () => { fetched++; throw new Error("Unexpected old-bucket request"); }, + })("lib/storage/image-byte-cache.ts"); + await expect(bytes.fetchImageBytes(legacy)).rejects.toThrow(RETIRED_STORAGE_MESSAGE); + expect(fetched).toBe(0); +}); + +test("byte reads use the resolved Qiniu object and preserve its bytes", async () => { + const seen: string[] = []; + const target = permanent + "?acceptance=" + crypto.randomUUID(); + const original = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 1]); + const bytes = serverLoader({ + "./image-archive-db": { resolveLegacyAliyunStorageUrl: async () => target }, + fetch: async (url: string) => { seen.push(url); return new Response(original, { headers: { "content-type": "image/png" } }); }, + })("lib/storage/image-byte-cache.ts"); + expect((await bytes.fetchImageBytes(legacy)).buffer.equals(original)).toBe(true); + expect(seen).toEqual([target]); +}); + +test("preview requests redirect to Qiniu after resolving a legacy alias", async () => { + const route = serverLoader({ + "next/server": { NextResponse }, + "@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent }, + fetch: () => { throw new Error("Legacy previews must not fetch the old bucket"); }, + })("app/api/image-proxy/route.ts"); + const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?url=" + encodeURIComponent(legacy), { headers: { "sec-fetch-dest": "image" } })); + expect(response.status).toBe(307); + expect(response.headers.get("location")).toBe(permanent); +}); + +test("original downloads resolve the archived file without reading the legacy source", async () => { + const route = serverLoader({ + "next/server": { NextResponse }, + "@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => permanent }, + "@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async (url: string) => { + expect(url).toBe(permanent); + return { delivery: "cdn", downloadUrl: permanent + "?attname=original.png", filename: "original.png" }; + } }, + fetch: () => { throw new Error("Unexpected source request"); }, + })("app/api/image-proxy/route.ts"); + const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?original=1&download=resolve&url=" + encodeURIComponent(legacy))); + expect(response.status).toBe(200); + expect(await response.json()).toMatchObject({ delivery: "cdn", filename: "original.png" }); +}); + +test("unrecoverable preview and download requests return a cause and recommendation immediately", async () => { + let fetched = 0; + const route = serverLoader({ + "next/server": { NextResponse }, + "@/lib/storage/image-archive-db": { resolveLegacyAliyunStorageUrl: async () => { throw new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE); } }, + fetch: () => { fetched++; throw new Error("Unexpected source request"); }, + })("app/api/image-proxy/route.ts"); + for (const query of ["", "original=1&download=resolve&"]) { + const response = await route.GET(new NextRequest("https://app.example/api/image-proxy?" + query + "url=" + encodeURIComponent(legacy))); + const data = await response.json(); + expect(response.status).toBe(410); + expect(data.failure).toMatchObject({ code: "STORAGE_RETIRED", retryable: false }); + expect(data.failure.recommendation).toContain("重新上传"); + expect(data.error).toContain(data.requestId); + } + expect(fetched).toBe(0); +}); + +test("retired model inputs are reported as missing files rather than a model-service failure", async () => { + const { getRequestFailureDetails } = await import("@/lib/request-error"); + expect(getRequestFailureDetails(new Error("HTTP 410: " + RETIRED_STORAGE_MESSAGE))).toMatchObject({ code: "SOURCE_STORAGE_RETIRED", outcome: "failed" }); +}); diff --git a/tests/routes/retired-storage.test.ts b/tests/routes/retired-storage.test.ts index 2aab014d2..e195cd232 100644 --- a/tests/routes/retired-storage.test.ts +++ b/tests/routes/retired-storage.test.ts @@ -18,16 +18,18 @@ test('retired URLs include stored proxy URLs but do not include live storage', ( test('original image proxy returns permanent failure without redirecting or fetching', async () => { let fetches = 0; - const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw Error('Unexpected network'); } })('app/api/image-proxy/route.ts'); + const route = serverLoader({ 'next/server': { NextResponse }, fetch: () => { fetches++; throw new Error('Unexpected network'); } })('app/api/image-proxy/route.ts'); const response = await route.GET(new NextRequest('https://ai.yydd.ai/api/image-proxy?original=1&url=' + encodeURIComponent(retired), {headers:{'sec-fetch-dest':'image'}})); expect(response.status).toBe(410); - expect(await response.json()).toMatchObject({code:'storage_retired',error:RETIRED_STORAGE_MESSAGE}); + const data = await response.json(); + expect(data.failure).toMatchObject({code:'STORAGE_RETIRED',retryable:false}); + expect(data.error).toContain(data.requestId); expect(fetches).toBe(0); }); test('byte cache never fetches retired resources', async () => { let fetches = 0; - const module = serverLoader({ fetch: () => { fetches++; throw Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts'); + const module = serverLoader({ fetch: () => { fetches++; throw new Error('Unexpected network'); } })('lib/storage/image-byte-cache.ts'); await expect(module.fetchImageBytes(retired)).rejects.toThrow('HTTP 410'); expect(fetches).toBe(0); }); @@ -35,7 +37,7 @@ test('byte cache never fetches retired resources', async () => { test('retired sources cannot enter the archive queue', async () => { let inserts = 0; const module = serverLoader({ - '@/lib/db': {db:{insert:()=>{inserts++;throw Error('Unexpected insert');}}}, + '@/lib/db': {db:{insert:()=>{inserts++;throw new Error('Unexpected insert');}}}, '@/lib/db/schema': {}, })('lib/storage/image-archive-db.ts'); expect(await module.enqueueImageArchiveTask({sourceUrl:retired})).toBeNull(); -- 2.47.2