Compare commits
No commits in common. "main" and "codex/image-tool-failure-advice-20261104" have entirely different histories.
main
...
codex/imag
@ -8,8 +8,6 @@ import { ArrowLeft, Download, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsR
|
||||
import { Input } from "@/components/ui/input";
|
||||
import Image from "next/image";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
import { toast } from "sonner";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
type FeaturedImage = {
|
||||
id: string;
|
||||
@ -76,11 +74,18 @@ export default function FeaturedPage() {
|
||||
|
||||
const handleDownload = async (url: string, prompt: string) => {
|
||||
try {
|
||||
await downloadOriginalImage({ url, name: "featured-image" });
|
||||
toast.success("原图已开始下载");
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = `featured-${Date.now()}.png`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -13,7 +13,6 @@ import { requestLongTaskResult } from "@/lib/client/long-task";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatBeijingTime } from "@/lib/utils";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
type GalleryImage = {
|
||||
id: string;
|
||||
@ -60,7 +59,7 @@ function buildGalleryDownloadFilename(url: string, alt: string, contentType: str
|
||||
}
|
||||
}
|
||||
|
||||
return `${safeBase}.${getImageDownloadExtension(contentType) || extension}`;
|
||||
return `${safeBase}-${Date.now()}.${getImageDownloadExtension(contentType) || extension}`;
|
||||
}
|
||||
|
||||
export default function GalleryPage() {
|
||||
@ -120,11 +119,25 @@ export default function GalleryPage() {
|
||||
const parsed = new URL(originalUrl, window.location.origin);
|
||||
const source = parsed.pathname === "/api/image-proxy" ? originalUrl : inferOriginalImageUrl(originalUrl);
|
||||
|
||||
await downloadOriginalImage({ url: source, name: buildGalleryDownloadFilename(originalUrl, alt, "") });
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(source, { delivery: "original" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = buildGalleryDownloadFilename(originalUrl, alt, blob.type);
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.setTimeout(() => window.URL.revokeObjectURL(blobUrl), 1000);
|
||||
toast.success("图片已开始下载");
|
||||
} catch (error) {
|
||||
console.error("Download failed:", error);
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,9 +1,6 @@
|
||||
import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation";
|
||||
import { after, NextRequest, NextResponse } from "next/server";
|
||||
import { auth } from "@/app/(auth)/auth";
|
||||
import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation";
|
||||
@ -16,22 +13,6 @@ import {
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const maxDuration = 120;
|
||||
const logger = createLogger("canvas.download");
|
||||
|
||||
function failed(failure: DownloadFailure, requestId: string) {
|
||||
logger.warn("request_failed", { requestId, code: failure.code, status: failure.status });
|
||||
return NextResponse.json({ success: false, error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
||||
status: failure.status,
|
||||
headers: { "Cache-Control": "no-store", ...(failure.status === 429 ? { "Retry-After": "3" } : {}) },
|
||||
});
|
||||
}
|
||||
|
||||
function preparing(requestId: string) {
|
||||
return NextResponse.json({ delivery: "preparing", retryAfter: 2, requestId,
|
||||
message: "正在准备原图,完成后将自动开始下载" }, {
|
||||
status: 202, headers: { "Retry-After": "2", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
|
||||
async function prepareDownload(input: DownloadPreparationInput, signal: AbortSignal) {
|
||||
// A proxy candidate for the same original must not repeat the same slow
|
||||
@ -40,25 +21,22 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig
|
||||
const source = extractImageProxySourceUrl(url) || url;
|
||||
return inferOriginalImageUrl(source) || source;
|
||||
}))];
|
||||
let lastError: unknown;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
signal.throwIfAborted();
|
||||
for (const url of sources) {
|
||||
signal.throwIfAborted();
|
||||
const result = await fetchCanvasDownloadAsset({
|
||||
file: { url, name: "original" }, index: 0,
|
||||
requestOrigin: input.requestOrigin, cookie: input.cookie,
|
||||
includeSequencePrefix: false, timeoutMs: 60_000, retryCount: 1, signal,
|
||||
includeSequencePrefix: false, timeoutMs: 12_000, retryCount: 1, signal,
|
||||
});
|
||||
if (result.ok) return { buffer: result.bytes, contentType: result.contentType };
|
||||
lastError = result.error;
|
||||
}
|
||||
// Each pass resolves the latest archive URL inside fetchCanvasDownloadAsset.
|
||||
// No AI/model request is involved and a completed source fetch is shared.
|
||||
if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||
else break;
|
||||
if (attempt < 2) await new Promise(resolve => setTimeout(resolve, 1_000));
|
||||
}
|
||||
throw new Error(String(lastError || "Original image sources unavailable"));
|
||||
throw new Error("Original image sources unavailable");
|
||||
}
|
||||
|
||||
function getRequestOrigin(request: NextRequest): string {
|
||||
@ -98,43 +76,31 @@ function resolveDownloadUrls(body: Record<string, unknown>, origin: string): str
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const requestId = randomUUID();
|
||||
try {
|
||||
const session = await auth();
|
||||
if (!session?.user?.id) {
|
||||
return failed(getDownloadFailure("未登录"), requestId);
|
||||
return NextResponse.json({ success: false, error: "未登录" }, { status: 401 });
|
||||
}
|
||||
|
||||
const body = (await request.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
const requestOrigin = getRequestOrigin(request);
|
||||
const candidates = resolveDownloadUrls(body, requestOrigin);
|
||||
if (candidates.length && candidates.every(isRetiredStorageUrl)) {
|
||||
return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId);
|
||||
return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||
}
|
||||
const urls = candidates.filter(url => !isRetiredStorageUrl(url));
|
||||
const requestedUrls = [...urls];
|
||||
if (urls.length === 0) {
|
||||
return failed(getDownloadFailure("图片地址无效"), requestId);
|
||||
return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 });
|
||||
}
|
||||
|
||||
const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image");
|
||||
if (body.preferDirect === true) {
|
||||
const { task, direct, canPrepareDirect } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch((error) => {
|
||||
logger.warn("cdn_lookup_failed", error, { requestId });
|
||||
return { task: null, direct: null, canPrepareDirect: false };
|
||||
});
|
||||
const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null }));
|
||||
if (direct) return NextResponse.json(direct, { headers: { "Cache-Control": "no-store" } });
|
||||
if (task && canPrepareDirect && body.preparation === "async") {
|
||||
const asset = (task.metadata as { downloadAsset?: { status?: string; failure?: DownloadFailure; retryAt?: string } } | null)?.downloadAsset;
|
||||
if (asset?.status === "abandoned" || (asset?.status === "failed" && Date.parse(asset.retryAt || "") > Date.now())) {
|
||||
return failed(asset.failure || getDownloadFailure(null), task.id);
|
||||
}
|
||||
scheduleImageDownloadPreparation(task.id);
|
||||
return preparing(task.id);
|
||||
}
|
||||
if (task?.targetUrl) {
|
||||
if (!urls.includes(task.targetUrl)) urls.unshift(task.targetUrl);
|
||||
scheduleImageDownloadPreparation(task.id);
|
||||
after(() => prepareImageArchiveDownload(task.id));
|
||||
}
|
||||
}
|
||||
const cookie = request.headers.get("cookie") || "";
|
||||
@ -143,10 +109,13 @@ export async function POST(request: NextRequest) {
|
||||
userId: session.user.id, urls: requestedUrls, requestOrigin, cookie,
|
||||
});
|
||||
if (result.status === "preparing") {
|
||||
return preparing(result.requestId);
|
||||
return NextResponse.json({ delivery: "preparing", retryAfter: 3,
|
||||
message: "正在准备原图,完成后将自动下载" }, {
|
||||
status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" },
|
||||
});
|
||||
}
|
||||
if (result.status === "failed") {
|
||||
return failed(result.failure, result.requestId);
|
||||
return NextResponse.json({ success: false, error: result.error }, { status: 502 });
|
||||
}
|
||||
return new NextResponse(new Uint8Array(result.asset.buffer), { headers: {
|
||||
"Content-Type": result.asset.contentType,
|
||||
@ -167,14 +136,17 @@ export async function POST(request: NextRequest) {
|
||||
includeSequencePrefix: false,
|
||||
});
|
||||
if (result.ok) break;
|
||||
failures.push(result.error);
|
||||
failures.push(`${url}: ${result.error}`);
|
||||
}
|
||||
|
||||
if (!result?.ok) {
|
||||
if (failures.length > 0) {
|
||||
logger.warn("all_sources_failed", { requestId, failures });
|
||||
console.warn("[CanvasDownloadImage] All candidates failed:", failures);
|
||||
}
|
||||
return failed(getDownloadFailure(result?.error), requestId);
|
||||
return NextResponse.json(
|
||||
{ success: false, error: result?.error || "图片源文件暂时不可访问" },
|
||||
{ status: 502 }
|
||||
);
|
||||
}
|
||||
|
||||
return new NextResponse(result.bytes, {
|
||||
@ -187,7 +159,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error("download_failed", error, { requestId });
|
||||
return failed(getDownloadFailure(error), requestId);
|
||||
console.error("[CanvasDownloadImage] Failed:", error);
|
||||
return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,11 +1,8 @@
|
||||
import crypto from "node:crypto";
|
||||
import { isRetiredStorageUrl, 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";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const logger = createLogger("image.download");
|
||||
|
||||
const DEFAULT_QUALITY = 82;
|
||||
const MAX_WIDTH = 4096;
|
||||
@ -270,17 +267,8 @@ function redirectToDirectImage(target: URL): NextResponse {
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const { searchParams } = request.nextUrl;
|
||||
const requestId = crypto.randomUUID();
|
||||
const downloadFailure = (error: unknown) => {
|
||||
const failure = getDownloadFailure(error);
|
||||
logger.warn("original_download_failed", error, { requestId, code: failure.code, status: failure.status });
|
||||
return NextResponse.json({ error: formatDownloadFailure(failure, requestId), failure, requestId }, {
|
||||
status: failure.status, headers: { "Cache-Control": "no-store" },
|
||||
});
|
||||
};
|
||||
const rawUrl = (searchParams.get("url") || "").trim();
|
||||
if (isRetiredStorageUrl(rawUrl)) {
|
||||
if (searchParams.get("original") === "1") return downloadFailure(RETIRED_STORAGE_MESSAGE);
|
||||
return NextResponse.json({ error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 });
|
||||
}
|
||||
if (!rawUrl) {
|
||||
@ -318,18 +306,6 @@ export async function GET(request: NextRequest) {
|
||||
);
|
||||
}
|
||||
|
||||
if (original && searchParams.get("download") === "resolve") {
|
||||
try {
|
||||
const { buildImageProxyUrl } = await import("@/lib/image-delivery");
|
||||
buildImageProxyUrl(target.toString(), { delivery: "original" });
|
||||
const { resolveOriginalCdnDownload } = await import("@/lib/storage/original-image-download");
|
||||
const direct = await resolveOriginalCdnDownload(target.toString(), searchParams.get("name") || "", request.signal);
|
||||
return NextResponse.json(direct || { delivery: "proxy" }, { headers: { "Cache-Control": "no-store" } });
|
||||
} catch (error) {
|
||||
return downloadFailure(error);
|
||||
}
|
||||
}
|
||||
|
||||
if (!forceProxy && !original) {
|
||||
const directImageTarget = resolveDirectImageTarget(
|
||||
request,
|
||||
@ -370,11 +346,10 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const fetchSignal = AbortSignal.any([
|
||||
request.signal,
|
||||
AbortSignal.timeout(original ? 60_000 : width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
||||
AbortSignal.timeout(width && width <= 256 ? SMALL_PREVIEW_FETCH_TIMEOUT_MS : FETCH_TIMEOUT_MS),
|
||||
]);
|
||||
const upstreamFailure = (error: unknown) => {
|
||||
if (request.signal.aborted) return new NextResponse(null, { status: 499 });
|
||||
if (original) return downloadFailure(error);
|
||||
const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError";
|
||||
return NextResponse.json(
|
||||
{ error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" },
|
||||
@ -394,7 +369,6 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
|
||||
if (!upstreamResponse.ok) {
|
||||
if (original) return downloadFailure(`HTTP ${upstreamResponse.status}`);
|
||||
return NextResponse.json(
|
||||
{ error: `Upstream responded with ${upstreamResponse.status}` },
|
||||
{ status: 502 }
|
||||
@ -403,7 +377,6 @@ export async function GET(request: NextRequest) {
|
||||
|
||||
const contentType = upstreamResponse.headers.get("content-type") || "application/octet-stream";
|
||||
if (!contentType.toLowerCase().startsWith("image/")) {
|
||||
if (original) return downloadFailure("Source is not an image");
|
||||
return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 });
|
||||
}
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@ -576,11 +575,31 @@ export function AgentGenerationWorkspace({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement("a");
|
||||
link.href = raw;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { appendGenerationMessage, mergeGenerationHistory, parseAppendedGenerationMessage, recoverReplacementRun } from "@/lib/agents/replacement-run-recovery";
|
||||
@ -6794,11 +6793,31 @@ export function AgentGenerationWorkspaceDarkV2({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement("a");
|
||||
link.href = raw;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
}
|
||||
}, []);
|
||||
const uploadBase64ResultToStorage = useCallback(async (base64Url: string) => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import { useChat } from "@ai-sdk/react";
|
||||
import { DefaultChatTransport } from "ai";
|
||||
@ -726,11 +725,30 @@ export function AgentGenerationWorkspaceNew({
|
||||
toast.error("图片地址无效");
|
||||
return;
|
||||
}
|
||||
const filename = buildDownloadFilename(raw);
|
||||
try {
|
||||
await downloadOriginalImage({ url: raw, name: buildDownloadFilename(raw) });
|
||||
toast.success("原图已开始下载");
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
const response = await fetch(raw);
|
||||
if (!response.ok) {
|
||||
throw new Error(`download failed: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blobUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(blobUrl);
|
||||
} catch {
|
||||
const link = document.createElement("a");
|
||||
link.href = raw;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
toast.message("已打开原图,可右键另存为");
|
||||
}
|
||||
}, []);
|
||||
|
||||
|
||||
@ -5,8 +5,7 @@ import dynamic from "next/dynamic";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { X, Download, Edit, Target, Paintbrush, Sparkles, Scissors, Expand, Maximize2, Move3D, Droplet } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
|
||||
const ImageAnnotator = dynamic(
|
||||
() => import("./image-annotator").then((module) => module.ImageAnnotator),
|
||||
@ -24,6 +23,25 @@ const AngleEditDialog = dynamic(
|
||||
}
|
||||
);
|
||||
|
||||
function triggerBrowserDownload(
|
||||
url: string,
|
||||
filename?: string,
|
||||
options?: { openInNewTab?: boolean }
|
||||
) {
|
||||
const link = document.createElement("a");
|
||||
link.href = url;
|
||||
if (filename) {
|
||||
link.download = filename;
|
||||
}
|
||||
if (options?.openInNewTab) {
|
||||
link.target = "_blank";
|
||||
}
|
||||
link.rel = "noopener noreferrer";
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
export function ImagePreview({
|
||||
src,
|
||||
alt,
|
||||
@ -220,12 +238,24 @@ export function ImagePreview({
|
||||
toast.error("下载失败,请重试");
|
||||
return;
|
||||
}
|
||||
const filenameBase = `generated-image-${Date.now()}`;
|
||||
try {
|
||||
await downloadOriginalImage({ url: safeOriginalUrl });
|
||||
toast.success("原图已开始下载");
|
||||
const response = await fetch(
|
||||
buildImageProxyUrl(safeOriginalUrl, { delivery: "original" })
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new Error(`下载失败(${response.status})`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const extension = getImageDownloadExtension(blob.type);
|
||||
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||
const url = window.URL.createObjectURL(blob);
|
||||
triggerBrowserDownload(url, filename);
|
||||
window.URL.revokeObjectURL(url);
|
||||
toast.success("下载成功");
|
||||
} catch (error) {
|
||||
console.error("下载失败:", error);
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
toast.error(error instanceof Error && error.message.includes("无法确认原图") ? error.message : "原图下载失败,请重试");
|
||||
}
|
||||
};
|
||||
|
||||
@ -307,18 +337,9 @@ export function ImagePreview({
|
||||
}}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
onLoad={(event) => {
|
||||
event.currentTarget.style.aspectRatio = "";
|
||||
event.currentTarget.parentElement?.style.removeProperty("width");
|
||||
event.currentTarget.alt = "Generated Image";
|
||||
}}
|
||||
onError={(event) => {
|
||||
if (event.currentTarget.src !== (thumbnail || original)) {
|
||||
event.currentTarget.src = thumbnail || original;
|
||||
} else if (!compactGrid) {
|
||||
event.currentTarget.style.aspectRatio = "1";
|
||||
event.currentTarget.parentElement?.style.setProperty("width", "100%");
|
||||
event.currentTarget.alt = "预览暂不可用,可尝试下载原图";
|
||||
}
|
||||
}}
|
||||
onClick={() => setIsOpen(true)}
|
||||
|
||||
@ -9,7 +9,6 @@ import { Button } from "./ui/button";
|
||||
import { X, Download, Target, Paintbrush } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { ImageAnnotator } from "./image-annotator";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
export const PreviewAttachment = ({
|
||||
attachment,
|
||||
@ -74,11 +73,18 @@ export const PreviewAttachment = ({
|
||||
// 下载图片函数
|
||||
const handleDownload = async () => {
|
||||
try {
|
||||
await downloadOriginalImage({ url, name: name ?? "image" });
|
||||
toast.success("原图已开始下载");
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const downloadUrl = window.URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = downloadUrl;
|
||||
link.download = name ?? `image-${Date.now()}.jpg`;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
window.URL.revokeObjectURL(downloadUrl);
|
||||
} catch (error) {
|
||||
console.error("下载失败:", error);
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
"use client";
|
||||
import { downloadOriginalImage, getCanvasImageDownloadErrorMessage } from "@/lib/client/canvas-image-download";
|
||||
|
||||
import {
|
||||
ArrowLeft,
|
||||
@ -2074,9 +2073,18 @@ export function SceneFusionView({
|
||||
|
||||
const downloadImage = async (url: string, filename: string) => {
|
||||
try {
|
||||
await downloadOriginalImage({ url, name: filename });
|
||||
} catch (error) {
|
||||
toast.error(getCanvasImageDownloadErrorMessage(error));
|
||||
const response = await fetch(url);
|
||||
const blob = await response.blob();
|
||||
const blobUrl = URL.createObjectURL(blob);
|
||||
const link = document.createElement("a");
|
||||
link.href = blobUrl;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
setTimeout(() => URL.revokeObjectURL(blobUrl), 100);
|
||||
} catch {
|
||||
window.open(url, "_blank");
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* - qwen-image-edit-plus 系列:支持 1-6 张输出,自定义分辨率,提示词优化
|
||||
* - qwen-image-edit:仅支持 1 张输出
|
||||
*
|
||||
* API 文档:https://help.aliyun.com/en/model-studio/qwen-image-generation-and-editing-api-reference
|
||||
* API 文档:https://help.aliyun.com/zh/model-studio/developer-reference/qwen-vl-plus
|
||||
*/
|
||||
|
||||
import sharp from "sharp";
|
||||
@ -46,8 +46,6 @@ interface AlibabaImageResponse {
|
||||
output?: {
|
||||
task_id?: string;
|
||||
task_status?: string;
|
||||
code?: string;
|
||||
message?: string;
|
||||
choices?: Array<{
|
||||
finish_reason: string;
|
||||
message: {
|
||||
@ -124,11 +122,6 @@ async function fetchImageBufferWithRetry(
|
||||
timeoutMs = ALIBABA_IMAGE_FETCH_TIMEOUT_MS,
|
||||
attempts = 2
|
||||
): Promise<{ buffer: Buffer; contentType: string | null }> {
|
||||
if (imageUrl.startsWith("data:image/")) {
|
||||
const match = imageUrl.match(/^data:(image\/[a-z0-9.+-]+);base64,(.+)$/i);
|
||||
if (!match) throw new Error("输入图片数据格式无效");
|
||||
return { buffer: Buffer.from(match[2], "base64"), contentType: match[1] };
|
||||
}
|
||||
let lastError: unknown = null;
|
||||
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
||||
const ctx = createAbortSignal(timeoutMs);
|
||||
@ -175,7 +168,7 @@ function calculateAlibabaSizeByAspect(aspectValue: number, baseSize: number): st
|
||||
}
|
||||
|
||||
async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number | null> {
|
||||
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||
|
||||
try {
|
||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||
@ -193,7 +186,7 @@ async function inferAspectRatioFromImageUrl(imageUrl: string): Promise<number |
|
||||
async function inferImageDimensionsFromImageUrl(
|
||||
imageUrl: string,
|
||||
): Promise<{ width: number; height: number; aspectRatio: number } | null> {
|
||||
if (!/^(?:https?:\/\/|data:image\/)/i.test(imageUrl)) return null;
|
||||
if (!/^https?:\/\//i.test(imageUrl)) return null;
|
||||
|
||||
try {
|
||||
const { buffer } = await fetchImageBufferWithRetry(imageUrl);
|
||||
@ -309,14 +302,6 @@ async function prepareAlibabaInputImageUrl(
|
||||
maxSide: ALIBABA_MAX_INPUT_SIDE,
|
||||
modelId,
|
||||
});
|
||||
if (isAlibabaQwenImage3ModelId(modelId)) {
|
||||
if (prepared.buffer.length > maxInputBytes) {
|
||||
throw new Error("输入图片超过模型大小限制,请先压缩图片后重试");
|
||||
}
|
||||
const format = prepared.transformed ? prepared.extension : metadata.format;
|
||||
const mime = format === "jpg" || format === "jpeg" ? "image/jpeg" : `image/${format}`;
|
||||
return `data:${mime};base64,${prepared.buffer.toString("base64")}`;
|
||||
}
|
||||
if (!prepared.transformed) {
|
||||
return normalizedUrl;
|
||||
}
|
||||
@ -580,10 +565,8 @@ async function sendAlibabaImageAsyncRequest(params: {
|
||||
return task;
|
||||
}
|
||||
if (["FAILED", "CANCELED", "UNKNOWN"].includes(taskStatus)) {
|
||||
const reason = task.output?.message || task.message || `阿里图片任务${taskStatus || "失败"}`;
|
||||
const code = task.output?.code || task.code;
|
||||
throw new AlibabaImageTerminalTaskError(
|
||||
`${reason}${code ? `(${code})` : ""}(task_id: ${taskId})`,
|
||||
task.message || `阿里图片任务${taskStatus || "失败"}(task_id: ${taskId})`,
|
||||
);
|
||||
}
|
||||
lastPollError = null;
|
||||
|
||||
@ -1,31 +0,0 @@
|
||||
export type DownloadFailure = {
|
||||
code: string;
|
||||
message: string;
|
||||
recommendation: string;
|
||||
status: number;
|
||||
retryable: boolean;
|
||||
};
|
||||
|
||||
export function getDownloadFailure(error: unknown): DownloadFailure {
|
||||
const text = error && typeof error === "object" && "message" in error
|
||||
? `${"name" in error ? error.name : "Error"}: ${error.message}` : String(error || "");
|
||||
const failure = (code: string, message: string, recommendation: string, status: number, retryable = false) =>
|
||||
({ code, message, recommendation, status, retryable });
|
||||
if (/旧存储已停用|storage_retired/.test(text)) return failure("STORAGE_RETIRED", "原图所在的旧存储已停用。", "请重新上传原图;重试下载无法恢复该文件。", 410);
|
||||
if (/HTTP (?:404|410)|SOURCE_NOT_FOUND/i.test(text)) return failure("SOURCE_NOT_FOUND", "原图链接已失效或源文件已被删除。", "请重新上传原图,或联系管理员核对归档副本;无需重新生成图片。", 404);
|
||||
if (/HTTP (?:401|403)|SOURCE_FORBIDDEN/i.test(text)) return failure("SOURCE_FORBIDDEN", "图片源站拒绝访问原图。", "请联系管理员检查原图访问权限或链接有效期,并提供下载编号。", 403);
|
||||
if (/queue.*busy|正在准备的原图较多|DOWNLOAD_BUSY/i.test(text)) return failure("DOWNLOAD_BUSY", "正在准备的下载较多。", "请等待当前下载完成后再试,避免同时重复点击。", 429, true);
|
||||
if (/TimeoutError|AbortError|timeout|timed out|deadline|超时|HTTP (?:408|504|524)/i.test(text)) return failure("SOURCE_TIMEOUT", "读取原图或准备下载文件超时。", "请稍后再次下载;持续失败时联系管理员并提供下载编号,已归档的原图无需重新生成。", 504, true);
|
||||
if (/fetch failed|failed to fetch|ECONN|ENOTFOUND|EAI_AGAIN|network|HTTP (?:429|5\d\d)/i.test(text)) return failure("SOURCE_UNAVAILABLE", "暂时无法连接图片源站。", "请稍后重试下载;持续失败时联系管理员检查取图线路,并提供下载编号。", 503, true);
|
||||
if (/not.*image|error document|不是图片|无法识别图片|empty response|image is empty|Invalid prepared original/i.test(text)) return failure("INVALID_IMAGE", "源地址没有返回可用的图片文件。", "请重新上传可打开的原图;若原图正常,请联系管理员并提供下载编号。", 422);
|
||||
if (/sharp|pngload|jpegload|unsupported image|无法解析图片|convert|转换/i.test(text)) return failure("IMAGE_CONVERSION_FAILED", "原图无法转换为下载所需的 PNG 文件。", "请保留原图并联系管理员提供下载编号,或重新上传 PNG/JPEG 图片。", 422);
|
||||
if (/upload|存储桶|保存下载|STORAGE_WRITE_FAILED/i.test(text)) return failure("DOWNLOAD_STORAGE_FAILED", "下载文件保存失败,已归档的原图仍然保留。", "请稍后重试;持续失败时联系管理员检查存储,并提供下载编号。", 503, true);
|
||||
if (/未登录/.test(text)) return failure("LOGIN_REQUIRED", "登录状态已失效。", "请重新登录后下载,当前图片不会因此删除。", 401);
|
||||
if (/无法确认原图/.test(text)) return failure("ORIGINAL_IMAGE_UNCONFIRMED", "图片链接包含处理参数,无法确认原图。", "请选择未裁剪、未缩放的原图链接,或重新上传原图。", 400);
|
||||
if (/图片地址无效/.test(text)) return failure("INVALID_IMAGE_URL", "图片地址无效。", "请刷新画布后重新选择图片;仍然失败时请重新上传原图。", 400);
|
||||
return failure("DOWNLOAD_FAILED", "下载准备未能完成。", "请联系管理员并提供下载编号,以便核对原图、转换和存储的具体错误;无需重新生成。", 500, true);
|
||||
}
|
||||
|
||||
export function formatDownloadFailure(failure: DownloadFailure, requestId?: string) {
|
||||
return `${failure.message}${failure.recommendation}${requestId ? `\n下载编号:${requestId}` : ""}`;
|
||||
}
|
||||
@ -1,10 +1,6 @@
|
||||
import "server-only";
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
import { createHash } from "node:crypto";
|
||||
import { cacheImageBytes, readImageBytes, type ImageBytes } from "@/lib/storage/image-byte-cache";
|
||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "./download-error";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
|
||||
const logger = createLogger("canvas.download");
|
||||
|
||||
export type DownloadPreparationInput = {
|
||||
userId: string;
|
||||
@ -13,18 +9,17 @@ export type DownloadPreparationInput = {
|
||||
cookie: string;
|
||||
};
|
||||
type Result =
|
||||
| { status: "preparing"; requestId: string }
|
||||
| { status: "preparing" }
|
||||
| { status: "ready"; asset: ImageBytes }
|
||||
| { status: "failed"; error: string; failure: DownloadFailure; requestId: string };
|
||||
| { status: "failed"; error: string };
|
||||
type Job = {
|
||||
requestId: string;
|
||||
userId: string;
|
||||
input: DownloadPreparationInput;
|
||||
status: "queued" | "running" | "ready" | "failed";
|
||||
updatedAt: number;
|
||||
asset?: ImageBytes;
|
||||
failure?: DownloadFailure;
|
||||
};
|
||||
const FAILURE_MESSAGE = "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||
const MAX_MEMORY_BYTES = 128 * 1024 * 1024;
|
||||
|
||||
// A download is independent of node autosave and of the archive worker's batch.
|
||||
@ -74,7 +69,7 @@ export function createDownloadPreparationQueue(deps: {
|
||||
timer = setTimeout(() => {
|
||||
controller.abort();
|
||||
reject(new Error("Download preparation deadline"));
|
||||
}, deps.timeoutMs || 150_000);
|
||||
}, deps.timeoutMs || 90_000);
|
||||
}),
|
||||
]);
|
||||
if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) {
|
||||
@ -86,10 +81,8 @@ export function createDownloadPreparationQueue(deps: {
|
||||
const stored = await deps.read(key).catch(() => null);
|
||||
if (stored?.contentType === "image/png" && stored.buffer.length) job.asset = undefined;
|
||||
job.status = "ready";
|
||||
} catch (error) {
|
||||
} catch {
|
||||
job.status = "failed";
|
||||
job.failure = getDownloadFailure(error);
|
||||
logger.warn("preparation_failed", error, { requestId: job.requestId, code: job.failure.code });
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
// Do not retain authentication data after a preparation finishes.
|
||||
@ -112,11 +105,8 @@ export function createDownloadPreparationQueue(deps: {
|
||||
prune();
|
||||
const current = jobs.get(key);
|
||||
if (current?.status === "ready" && current.asset) return { status: "ready", asset: current.asset };
|
||||
if (current?.status === "failed") {
|
||||
const failure = current.failure || getDownloadFailure(null);
|
||||
return { status: "failed", error: formatDownloadFailure(failure, current.requestId), failure, requestId: current.requestId };
|
||||
}
|
||||
if (current && current.status !== "ready") return { status: "preparing", requestId: current.requestId };
|
||||
if (current?.status === "failed") return { status: "failed", error: FAILURE_MESSAGE };
|
||||
if (current && current.status !== "ready") return { status: "preparing" };
|
||||
if (jobs.size >= 64) {
|
||||
for (const [oldKey, job] of jobs) {
|
||||
if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey);
|
||||
@ -125,14 +115,11 @@ export function createDownloadPreparationQueue(deps: {
|
||||
}
|
||||
if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId &&
|
||||
(job.status === "queued" || job.status === "running")).length >= 4) {
|
||||
const requestId = randomUUID();
|
||||
const failure = getDownloadFailure("DOWNLOAD_BUSY");
|
||||
return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId };
|
||||
return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" };
|
||||
}
|
||||
const requestId = randomUUID();
|
||||
jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() });
|
||||
jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() });
|
||||
deps.schedule(drain);
|
||||
return { status: "preparing", requestId };
|
||||
return { status: "preparing" };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -2,7 +2,6 @@ import { createHash } from "node:crypto";
|
||||
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage";
|
||||
import { fetchImageBytes, onceImageBytes } from "@/lib/storage/image-byte-cache";
|
||||
import { extractImageProxySourceUrl, inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { getDownloadFailure } from "./download-error";
|
||||
|
||||
type DownloadFileItem = {
|
||||
url: string;
|
||||
@ -32,7 +31,6 @@ function sleep(ms: number) {
|
||||
|
||||
function normalizeDownloadError(error: unknown): string {
|
||||
if (error instanceof Error) return error.message || "download failed";
|
||||
if (error && typeof error === "object" && "message" in error && typeof error.message === "string") return error.message;
|
||||
if (typeof error === "string") return error;
|
||||
return "download failed";
|
||||
}
|
||||
@ -332,7 +330,6 @@ export async function fetchCanvasDownloadAsset(params: {
|
||||
try {
|
||||
const data = await fetchImageBytes(candidateUrl, {
|
||||
timeoutMs,
|
||||
signal: params.signal,
|
||||
headers: buildRemoteFetchHeaders(candidateUrl, params.requestOrigin, params.cookie),
|
||||
// Authenticated local assets must never share a cache across users.
|
||||
scope: new URL(candidateUrl).origin === params.requestOrigin && params.cookie
|
||||
@ -347,13 +344,11 @@ export async function fetchCanvasDownloadAsset(params: {
|
||||
});
|
||||
} catch (error) {
|
||||
params.signal?.throwIfAborted();
|
||||
const message = normalizeDownloadError(error);
|
||||
if (!lastError || getDownloadFailure(message).retryable || !getDownloadFailure(lastError).retryable) lastError = message;
|
||||
lastError = normalizeDownloadError(error);
|
||||
}
|
||||
}
|
||||
// Archiving may have completed during the failed fetch. Resolve aliases
|
||||
// again before retrying, rather than spending every attempt on the old URL.
|
||||
if (!getDownloadFailure(lastError).retryable) break;
|
||||
if (attempt < retryCount) await sleep(350 * attempt);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { buildImageProxyUrl } from "@/lib/image-delivery";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error";
|
||||
|
||||
export type CanvasImageDownloadPhase = "preparing" | "downloading" | "saving";
|
||||
|
||||
@ -27,8 +26,6 @@ export class CanvasImageDownloadError extends Error {
|
||||
code: CanvasImageDownloadErrorCode;
|
||||
retryAfterSeconds?: number;
|
||||
status?: number;
|
||||
failure?: DownloadFailure;
|
||||
requestId?: string;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
@ -36,8 +33,6 @@ export class CanvasImageDownloadError extends Error {
|
||||
code: CanvasImageDownloadErrorCode;
|
||||
retryAfterSeconds?: number;
|
||||
status?: number;
|
||||
failure?: DownloadFailure;
|
||||
requestId?: string;
|
||||
}
|
||||
) {
|
||||
super(message);
|
||||
@ -45,14 +40,11 @@ export class CanvasImageDownloadError extends Error {
|
||||
this.code = options.code;
|
||||
this.retryAfterSeconds = options.retryAfterSeconds;
|
||||
this.status = options.status;
|
||||
this.failure = options.failure;
|
||||
this.requestId = options.requestId;
|
||||
}
|
||||
}
|
||||
|
||||
export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
||||
if (error instanceof CanvasImageDownloadError) {
|
||||
if (error.failure) return formatDownloadFailure(error.failure, error.requestId);
|
||||
if (error.code === "rate_limited") {
|
||||
return error.retryAfterSeconds
|
||||
? `图片下载请求过于频繁,请 ${error.retryAfterSeconds} 秒后重试`
|
||||
@ -62,10 +54,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) {
|
||||
return "原图暂时无法读取,图片仍保留在画布中,请稍后重试";
|
||||
}
|
||||
if (error.code === "preparation_timeout") {
|
||||
return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`;
|
||||
return "原图仍在后台准备中,请稍后再次点击下载";
|
||||
}
|
||||
}
|
||||
return formatDownloadFailure(getDownloadFailure(error));
|
||||
return error instanceof Error ? error.message : "图片下载失败,请稍后重试";
|
||||
}
|
||||
|
||||
type DownloadCanvasImageParams = {
|
||||
@ -79,8 +71,6 @@ type DownloadCanvasImageParams = {
|
||||
type DownloadErrorPayload = {
|
||||
error?: string;
|
||||
retryAfter?: number;
|
||||
failure?: DownloadFailure;
|
||||
requestId?: string;
|
||||
};
|
||||
|
||||
const MAX_RATE_LIMIT_RETRIES = 1;
|
||||
@ -176,63 +166,6 @@ function triggerBlobDownload(blob: Blob, filename: string) {
|
||||
window.setTimeout(() => URL.revokeObjectURL(blobUrl), 1000);
|
||||
}
|
||||
|
||||
function triggerCdnDownload(result: { downloadUrl: string; filename: string; bytes?: number }, signal?: AbortSignal) {
|
||||
if (typeof result.downloadUrl !== "string" || new URL(result.downloadUrl).protocol !== "https:") {
|
||||
throw new Error("下载链接无效");
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const frame = document.createElement("iframe");
|
||||
frame.hidden = true;
|
||||
frame.src = result.downloadUrl;
|
||||
document.body.appendChild(frame);
|
||||
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
||||
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||
}
|
||||
|
||||
/** Keep the source format and bytes for chat, gallery and reference downloads. */
|
||||
export async function downloadOriginalImage({ url, name, signal, onProgress }: DownloadCanvasImageParams): Promise<CanvasImageDownloadResult> {
|
||||
const source = String(url || "").trim();
|
||||
if (!source) throw new Error("图片地址无效");
|
||||
const proxy = buildImageProxyUrl(source, { delivery: "original" });
|
||||
const filenameBase = sanitizeDownloadName(name || inferDownloadNameFromUrl(source), "image");
|
||||
const target = new URL(proxy, window.location.origin);
|
||||
onProgress?.({ phase: "preparing", message: "正在准备原图..." });
|
||||
const checkedFetch = async (input: string) => {
|
||||
let response: Response;
|
||||
try { response = await fetch(input, { signal }); }
|
||||
catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const failure = getDownloadFailure(error);
|
||||
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||
}
|
||||
if (!response.ok) {
|
||||
const payload = await response.json().catch(() => null) as DownloadErrorPayload | null;
|
||||
throw new CanvasImageDownloadError(payload?.error || "原图下载失败", { code: "request_failed",
|
||||
status: response.status, failure: payload?.failure || getDownloadFailure(`HTTP ${response.status}`), requestId: payload?.requestId });
|
||||
}
|
||||
return response;
|
||||
};
|
||||
if (target.origin === window.location.origin && target.pathname === "/api/image-proxy") {
|
||||
target.searchParams.set("download", "resolve");
|
||||
target.searchParams.set("name", filenameBase);
|
||||
const metadata = await checkedFetch(target.pathname + target.search);
|
||||
const delivery = await metadata.json();
|
||||
if (delivery.delivery === "cdn") {
|
||||
const result = triggerCdnDownload(delivery, signal);
|
||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||
return result;
|
||||
}
|
||||
if (delivery.delivery !== "proxy") throw new Error("下载链接无效");
|
||||
}
|
||||
const response = await checkedFetch(proxy);
|
||||
const blob = await readResponseBlobWithProgress(response, onProgress);
|
||||
if (!blob.type.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||
const extension = getImageDownloadExtension(blob.type);
|
||||
const filename = extension ? `${filenameBase}.${extension}` : filenameBase;
|
||||
triggerBlobDownload(blob, filename);
|
||||
return { filename, bytes: blob.size };
|
||||
}
|
||||
|
||||
function toDownloadCandidates(url: string, candidates: string[] = []) {
|
||||
const raw = String(url || "").trim();
|
||||
const originalUrl = String(inferOriginalImageUrl(raw) || raw).trim();
|
||||
@ -324,8 +257,7 @@ export async function downloadCanvasImageAsPng({
|
||||
let preparationPolls = 0;
|
||||
for (;;) {
|
||||
signal?.throwIfAborted();
|
||||
try {
|
||||
response = await fetch("/api/canvas/download-image", {
|
||||
response = await fetch("/api/canvas/download-image", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@ -336,12 +268,7 @@ export async function downloadCanvasImageAsPng({
|
||||
preparation: "async",
|
||||
}),
|
||||
signal,
|
||||
});
|
||||
} catch (error) {
|
||||
if (signal?.aborted) throw error;
|
||||
const failure = getDownloadFailure(error);
|
||||
throw new CanvasImageDownloadError(failure.message, { code: "request_failed", failure });
|
||||
}
|
||||
});
|
||||
|
||||
if (response.status === 202) {
|
||||
rateLimitRetries = 0;
|
||||
@ -351,7 +278,7 @@ export async function downloadCanvasImageAsPng({
|
||||
throw new CanvasImageDownloadError("下载准备状态无效", { code: "request_failed", status: 202 });
|
||||
}
|
||||
if (Date.now() >= preparationDeadline || preparationPolls >= 60) {
|
||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId });
|
||||
throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202 });
|
||||
}
|
||||
onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" });
|
||||
const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3));
|
||||
@ -372,8 +299,6 @@ export async function downloadCanvasImageAsPng({
|
||||
code: "rate_limited",
|
||||
retryAfterSeconds,
|
||||
status: response.status,
|
||||
failure: errorPayload?.failure,
|
||||
requestId: errorPayload?.requestId,
|
||||
}
|
||||
);
|
||||
}
|
||||
@ -396,18 +321,22 @@ export async function downloadCanvasImageAsPng({
|
||||
{
|
||||
code: status === 502 ? "source_unavailable" : "request_failed",
|
||||
status,
|
||||
failure: errorPayload?.failure,
|
||||
requestId: errorPayload?.requestId,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
if (response.headers.get("content-type")?.includes("application/json")) {
|
||||
const result = await response.json();
|
||||
if (result.delivery !== "cdn") throw new Error("下载链接无效");
|
||||
const downloaded = triggerCdnDownload(result, signal);
|
||||
if (result.delivery !== "cdn" || typeof result.downloadUrl !== "string"
|
||||
|| new URL(result.downloadUrl).protocol !== "https:") throw new Error("下载链接无效");
|
||||
if (signal?.aborted) throw signal.reason || new DOMException("已取消下载", "AbortError");
|
||||
const frame = document.createElement("iframe");
|
||||
frame.hidden = true;
|
||||
frame.src = result.downloadUrl;
|
||||
document.body.appendChild(frame);
|
||||
window.setTimeout(() => frame.remove(), 10 * 60_000);
|
||||
onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" });
|
||||
return downloaded;
|
||||
return { filename: result.filename, bytes: result.bytes || 0 };
|
||||
}
|
||||
|
||||
const filename =
|
||||
|
||||
@ -20,8 +20,6 @@ export function getImageDownloadExtension(contentType: string) {
|
||||
"image/avif": "avif",
|
||||
"image/gif": "gif",
|
||||
"image/svg+xml": "svg",
|
||||
"image/bmp": "bmp",
|
||||
"image/tiff": "tiff",
|
||||
};
|
||||
return extensions[mime] || "";
|
||||
}
|
||||
@ -185,8 +183,8 @@ export function buildImageProxyUrl(url: string, options: ImageProxyOptions = {})
|
||||
return source;
|
||||
}
|
||||
|
||||
// The same-origin endpoint can resolve a native CDN download without relying
|
||||
// on cross-origin fetch, while retaining a byte-preserving proxy fallback.
|
||||
// An original download must go through the same-origin byte-preserving
|
||||
// proxy even for trusted storage hosts: a direct fetch may fail CORS.
|
||||
if (options.delivery === "original") {
|
||||
const params = new URLSearchParams({ url: source, original: "1" });
|
||||
return `${IMAGE_PROXY_PATH}?${params.toString()}`;
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation";
|
||||
import { preparePendingImageDownloads } from "@/lib/storage/image-download-preparation";
|
||||
import "server-only";
|
||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||
|
||||
@ -32,7 +32,6 @@ export async function runImageArchiveJob() {
|
||||
console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`);
|
||||
|
||||
await recoverStaleImageArchiveTasks();
|
||||
scheduleImageDownloadPreparation();
|
||||
const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize });
|
||||
let storeSucceeded = 0;
|
||||
let storeFailed = 0;
|
||||
@ -46,7 +45,6 @@ export async function runImageArchiveJob() {
|
||||
id: task.id,
|
||||
targetUrl: archived.url,
|
||||
});
|
||||
scheduleImageDownloadPreparation(task.id);
|
||||
storeSucceeded += 1;
|
||||
console.log("[ImageArchiveJob] stored", {
|
||||
taskId: task.id,
|
||||
@ -110,6 +108,7 @@ export async function runImageArchiveJob() {
|
||||
}
|
||||
}
|
||||
|
||||
const downloadTasks = await preparePendingImageDownloads();
|
||||
const stats = await getImageArchiveTaskStats();
|
||||
return {
|
||||
success: true,
|
||||
@ -125,8 +124,8 @@ export async function runImageArchiveJob() {
|
||||
failed: replaceFailed,
|
||||
},
|
||||
stats,
|
||||
downloadTasks: 0,
|
||||
downloadTasks,
|
||||
pendingDownloads: await hasPendingImageDownloads(),
|
||||
};
|
||||
}, { durable: true });
|
||||
});
|
||||
}
|
||||
|
||||
@ -3,14 +3,13 @@ import { unlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number; __aiChatbotDurableWork?: number };
|
||||
const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number };
|
||||
const path = join(process.platform === "win32" ? tmpdir() : "/tmp", `ai-chatbot-active-work-${process.pid}.json`);
|
||||
|
||||
function update(delta: number, durable: boolean) {
|
||||
function update(delta: number) {
|
||||
state.__aiChatbotActiveWork = (state.__aiChatbotActiveWork || 0) + delta;
|
||||
if (durable) state.__aiChatbotDurableWork = (state.__aiChatbotDurableWork || 0) + delta;
|
||||
try {
|
||||
writeFileSync(path, JSON.stringify({ pid: process.pid, active: state.__aiChatbotActiveWork, durable: state.__aiChatbotDurableWork || 0 }), { mode: 0o600 });
|
||||
writeFileSync(path, JSON.stringify({ pid: process.pid, active: state.__aiChatbotActiveWork }), { mode: 0o600 });
|
||||
} catch (error) {
|
||||
try { unlinkSync(path); } catch {}
|
||||
console.warn("[Active Work] Failed to record running work:", error);
|
||||
@ -18,7 +17,7 @@ function update(delta: number, durable: boolean) {
|
||||
}
|
||||
|
||||
/** Includes generation that continues after the client connection closes. */
|
||||
export async function trackRuntimeWork<T>(execute: () => T | Promise<T>, options: { durable?: boolean } = {}): Promise<T> {
|
||||
update(1, options.durable === true);
|
||||
try { return await execute(); } finally { update(-1, options.durable === true); }
|
||||
export async function trackRuntimeWork<T>(execute: () => T | Promise<T>): Promise<T> {
|
||||
update(1);
|
||||
try { return await execute(); } finally { update(-1); }
|
||||
}
|
||||
|
||||
@ -246,9 +246,6 @@ export function getRequestFailureDetails(
|
||||
if (/上传到存储桶失败|存储.*失败|归档.*失败|archive.*failed|storage.*failed/i.test(text)) {
|
||||
return detail("RESULT_STORAGE_FAILED", "处理结果保存失败,生成结果可能已经存在。", "请保留当前页面并联系管理员提供请求编号,先不要重复生成。", "unknown");
|
||||
}
|
||||
if (/failed to download (?:the )?image url|failed to download.*reference.*image/i.test(text)) {
|
||||
return detail("PROVIDER_REFERENCE_FETCH_FAILED", "模型服务未能读取参考图。", "请重新上传参考图或切换模型后再提交;若原图仍可打开,请联系管理员检查模型取图线路,并提供请求编号。");
|
||||
}
|
||||
if (/参考图.*(?:下载失败|不可|失效)|无法获取图片|图片(?:源文件|链接).*不可|获取图片.*失败|get image url failed/i.test(text)) {
|
||||
return detail("SOURCE_IMAGE_UNAVAILABLE", "请求中的图片无法读取,链接可能已失效或没有访问权限。", "请重新上传原图后再提交;若原图仍可打开,请联系管理员检查取图服务。");
|
||||
}
|
||||
|
||||
@ -10,21 +10,17 @@ export async function resolveCanvasCdnDownload(urls: string[], filename: string)
|
||||
})));
|
||||
const task = await findImageArchiveDownloadTask(sources, true);
|
||||
const asset = (task?.metadata as { downloadAsset?: { status?: string; url?: string; contentType?: string; bytes?: number } } | null)?.downloadAsset;
|
||||
if (!task) return { task, direct: null, canPrepareDirect: false };
|
||||
if (asset?.status !== "ready" || !asset.url || asset.contentType !== "image/png") return { task, direct: null };
|
||||
const config = await getDefaultStorageConfig();
|
||||
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null, canPrepareDirect: false };
|
||||
const ready = asset?.status === "ready" && asset.url && asset.contentType === "image/png";
|
||||
const source = ready ? asset.url : task.targetUrl;
|
||||
if (!source) return { task, direct: null, canPrepareDirect: false };
|
||||
const url = new URL(source);
|
||||
if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null };
|
||||
const url = new URL(asset.url);
|
||||
const base = new URL(config.publicUrl);
|
||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||
if (origin && url.origin === new URL(origin).origin) url.host = base.host;
|
||||
if (base.protocol !== "https:" || url.origin !== base.origin
|
||||
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false };
|
||||
if (!ready) return { task, direct: null, canPrepareDirect: true };
|
||||
|| !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null };
|
||||
// attname is interpreted by Kodo/CDN as Content-Disposition, including Chinese
|
||||
// filenames. Keep all processing/auth parameters intact for correct cache keys.
|
||||
url.searchParams.set("attname", filename);
|
||||
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset?.bytes || 0 }, canPrepareDirect: true };
|
||||
return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset.bytes || 0 } };
|
||||
}
|
||||
|
||||
@ -81,7 +81,7 @@ function isMediaResponse(data: ImageBytes) {
|
||||
if (/^<\?xml/i.test(prefix) && !/<svg[\s>]/i.test(prefix)) return false;
|
||||
return data.buffer.length > 0;
|
||||
}
|
||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) {
|
||||
export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) {
|
||||
if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
|
||||
return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => {
|
||||
if (url.startsWith("data:")) {
|
||||
@ -92,9 +92,7 @@ export async function fetchImageBytes(url: string, options: { headers?: HeadersI
|
||||
const response = await fetch(url, {
|
||||
headers: options.headers, cache: "no-store", redirect: "follow",
|
||||
// The deadline covers the body as well as the response headers.
|
||||
signal: options.signal
|
||||
? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs || 60_000)])
|
||||
: AbortSignal.timeout(options.timeoutMs || 60_000),
|
||||
signal: AbortSignal.timeout(options.timeoutMs || 60_000),
|
||||
});
|
||||
if (!response.ok) { await response.body?.cancel(); throw new Error(`HTTP ${response.status}`); }
|
||||
const buffer = Buffer.from(await response.arrayBuffer());
|
||||
|
||||
@ -2,21 +2,14 @@ import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { fetchImageBytes, cacheImageBytes } from "./image-byte-cache";
|
||||
import { normalizeCanvasDownloadAsset } from "@/lib/canvas/photoshop-safe-download";
|
||||
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } from "./image-archive-db";
|
||||
import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks } from "./image-archive-db";
|
||||
import { uploadFile } from "./service";
|
||||
import { getDownloadFailure } from "@/lib/canvas/download-error";
|
||||
import { createLogger } from "@/lib/observability/logger";
|
||||
import { trackRuntimeWork } from "@/lib/observability/active-work";
|
||||
|
||||
const logger = createLogger("canvas.download");
|
||||
|
||||
export async function prepareImageArchiveDownload(id: string) {
|
||||
const task = await claimImageDownloadTask(id);
|
||||
if (!task?.targetUrl) return;
|
||||
let phase: "read" | "convert" | "store" = "read";
|
||||
try {
|
||||
const source = await fetchImageBytes(task.targetUrl);
|
||||
phase = "convert";
|
||||
const asset = await normalizeCanvasDownloadAsset({
|
||||
item: { url: task.targetUrl }, index: 0, bytes: source.buffer,
|
||||
contentType: source.contentType, includeSequencePrefix: false,
|
||||
@ -24,22 +17,15 @@ export async function prepareImageArchiveDownload(id: string) {
|
||||
if (!asset.ok) throw new Error(asset.error);
|
||||
// An already compatible PNG shares the original object; other formats get
|
||||
// one prepared PNG, without changing the original dimensions or transparency.
|
||||
phase = "store";
|
||||
const url = source.buffer.equals(asset.bytes) && asset.contentType === "image/png"
|
||||
? task.targetUrl
|
||||
: (await uploadFile(`canvas-download-${randomUUID()}.${asset.filename.split(".").pop() || "png"}`, Uint8Array.from(asset.bytes).buffer)).url;
|
||||
await saveImageDownloadAsset(id, { status: "ready", url, bytes: asset.bytes.length,
|
||||
contentType: asset.contentType, preparedAt: new Date().toISOString() });
|
||||
await cacheImageBytes(`source::${url}`, { buffer: asset.bytes, contentType: asset.contentType });
|
||||
} catch (error) {
|
||||
} catch {
|
||||
const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1);
|
||||
const failure = phase === "store"
|
||||
? getDownloadFailure("STORAGE_WRITE_FAILED")
|
||||
: phase === "convert" && getDownloadFailure(error).code === "DOWNLOAD_FAILED"
|
||||
? getDownloadFailure("图片转换失败") : getDownloadFailure(error);
|
||||
logger.warn("archive_download_preparation_failed", error, { requestId: id, code: failure.code });
|
||||
await saveImageDownloadAsset(id, { status: !failure.retryable || attempts >= 12 ? "abandoned" : "failed", attempts,
|
||||
failure, retryAt: new Date(Date.now() + 60_000).toISOString() });
|
||||
await saveImageDownloadAsset(id, { status: attempts >= 12 ? "abandoned" : "failed", attempts, retryAt: new Date(Date.now() + 10 * 60_000).toISOString() });
|
||||
}
|
||||
}
|
||||
export async function preparePendingImageDownloads() {
|
||||
@ -50,45 +36,3 @@ export async function preparePendingImageDownloads() {
|
||||
}));
|
||||
return tasks.length;
|
||||
}
|
||||
|
||||
const state = globalThis as typeof globalThis & {
|
||||
__imageDownloadWorker?: { running: boolean; scanPending: boolean; priority: Set<string>; retry?: ReturnType<typeof setTimeout> };
|
||||
};
|
||||
|
||||
/** Download preparation runs independently of the archive URL replacement batch. */
|
||||
export function scheduleImageDownloadPreparation(id?: string) {
|
||||
const worker = state.__imageDownloadWorker ??= { running: false, scanPending: false, priority: new Set() };
|
||||
if (id) worker.priority.add(id);
|
||||
else worker.scanPending = true;
|
||||
if (worker.running) return;
|
||||
if (worker.retry) { clearTimeout(worker.retry); worker.retry = undefined; }
|
||||
worker.running = true;
|
||||
setImmediate(() => {
|
||||
void trackRuntimeWork(async () => {
|
||||
try {
|
||||
for (;;) {
|
||||
const priority = [...worker.priority].slice(0, 2);
|
||||
for (const taskId of priority) worker.priority.delete(taskId);
|
||||
if (priority.length) {
|
||||
await Promise.all(priority.map(prepareImageArchiveDownload));
|
||||
} else if (!worker.scanPending || await preparePendingImageDownloads() === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error("download_worker_failed", error);
|
||||
} finally {
|
||||
worker.running = false;
|
||||
// Eligible retry times live in the database, including interrupted leases.
|
||||
if (worker.priority.size) {
|
||||
scheduleImageDownloadPreparation([...worker.priority][0]);
|
||||
} else if (worker.scanPending && await hasPendingImageDownloads().catch(() => true)) {
|
||||
worker.retry = setTimeout(() => scheduleImageDownloadPreparation(), 60_000);
|
||||
worker.retry.unref?.();
|
||||
} else {
|
||||
worker.scanPending = false;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
import "server-only";
|
||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||
import { findImageArchiveDownloadTask } from "./image-archive-db";
|
||||
import { inferOriginalImageUrl } from "@/lib/image-url";
|
||||
import { getImageDownloadExtension } from "@/lib/image-delivery";
|
||||
import { sanitizeCanvasDownloadFileName } from "@/lib/canvas/photoshop-safe-download";
|
||||
|
||||
/** Resolve the archived original without waiting for a separate PNG conversion. */
|
||||
export async function resolveOriginalCdnDownload(source: string, name: string, signal: AbortSignal) {
|
||||
const config = await getDefaultStorageConfig();
|
||||
if (config?.type !== "qiniu-kodo" || !config.isEnabled || !config.publicUrl) return null;
|
||||
const base = new URL(config.publicUrl);
|
||||
const origin = process.env.CANVAS_QINIU_ORIGIN_URL;
|
||||
let target = new URL(source);
|
||||
if (target.origin !== base.origin && (!origin || target.origin !== new URL(origin).origin)) {
|
||||
const task = await findImageArchiveDownloadTask([...new Set([source, inferOriginalImageUrl(source)])]);
|
||||
target = new URL(task?.targetUrl || source);
|
||||
}
|
||||
if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) {
|
||||
target.host = base.host;
|
||||
target.protocol = base.protocol;
|
||||
}
|
||||
if (base.protocol !== "https:" || target.origin !== base.origin ||
|
||||
!target.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/")) return null;
|
||||
// A Kodo download token signs the complete URL; adding attname would invalidate it.
|
||||
if (target.searchParams.has("token")) return null;
|
||||
|
||||
// Only metadata crosses the application server. The browser receives the file
|
||||
// from the CDN, including when cross-origin fetch is disabled by storage CORS.
|
||||
const response = await fetch(target, {
|
||||
method: "HEAD", cache: "no-store",
|
||||
signal: AbortSignal.any([signal, AbortSignal.timeout(10_000)]),
|
||||
});
|
||||
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
||||
const contentType = response.headers.get("content-type") || "";
|
||||
if (!contentType.toLowerCase().startsWith("image/")) throw new Error("Source is not an image");
|
||||
const extension = getImageDownloadExtension(contentType);
|
||||
if (!extension) return null;
|
||||
const basename = sanitizeCanvasDownloadFileName(name || target.pathname.split("/").pop() || "image", "image")
|
||||
.replace(/\.(?:png|jpe?g|webp|avif|gif|svg|bmp|tiff?)$/i, "");
|
||||
const filename = `${basename}.${extension}`;
|
||||
target.searchParams.set("attname", filename);
|
||||
return { delivery: "cdn" as const, downloadUrl: target.toString(), filename,
|
||||
bytes: Number(response.headers.get("content-length")) || 0 };
|
||||
}
|
||||
@ -3,12 +3,6 @@ import { defineConfig } from "@playwright/test";
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
testMatch: [
|
||||
/routes\/original-image-download\.test\.ts/,
|
||||
/routes\/canvas-download-speed\.test\.ts/,
|
||||
/routes\/canvas-download-recovery\.test\.ts/,
|
||||
/routes\/canvas-download-rate-limit\.test\.ts/,
|
||||
/routes\/canvas-zip-download\.test\.ts/,
|
||||
/routes\/image-archive-flow\.test\.ts/,
|
||||
/routes\/runtime-active-work\.test\.ts/,
|
||||
/routes\/canvas-capability-task-recovery\.test\.ts/,
|
||||
/routes\/canvas-toolbar-capability\.test\.ts/,
|
||||
@ -51,7 +45,6 @@ export default defineConfig({
|
||||
/routes\/replacement-product-identity\.test\.ts/,
|
||||
/routes\/detail-replacement-batch\.test\.ts/,
|
||||
/routes\/alibaba-image-input\.test\.ts/,
|
||||
/routes\/alibaba-task-errors\.test\.ts/,
|
||||
/routes\/model3d-style-pro-regressions\.test\.ts/,
|
||||
/routes\/clawd-request-lifecycle\.test\.ts/,
|
||||
/routes\/canvas-skill-tag-order\.test\.ts/,
|
||||
|
||||
@ -1,59 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
import sharp from "sharp";
|
||||
|
||||
test("Alibaba terminal task errors preserve the nested provider reason and do not resubmit", async () => {
|
||||
const methods: string[] = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": {},
|
||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||
fetch: async (_url: string, init: RequestInit) => {
|
||||
methods.push(init.method || "GET");
|
||||
return Response.json(methods.length === 1
|
||||
? { output: { task_id: "original-task", task_status: "PENDING" } }
|
||||
: { output: { task_id: "original-task", task_status: "FAILED", code: "InvalidParameter", message: "Failed to download image URL after retries." } });
|
||||
},
|
||||
});
|
||||
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example/api/v1/services/aigc/multimodal-generation/generation" });
|
||||
let error: unknown;
|
||||
try { await provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: "fixture" }], imageParams: { aspectRatio: "1:1" } }); } catch (caught) { error = caught; }
|
||||
expect(String(error)).toContain("Failed to download image URL after retries.");
|
||||
expect(String(error)).toContain("InvalidParameter");
|
||||
expect(String(error)).toContain("original-task");
|
||||
expect(methods).toEqual(["POST", "GET"]);
|
||||
const failure = loader("lib/request-error.ts").getRequestFailureDetails(error);
|
||||
expect(failure).toMatchObject({ code: "PROVIDER_REFERENCE_FETCH_FAILED", message: "模型服务未能读取参考图。" });
|
||||
expect(failure.recommendation).toContain("模型取图线路");
|
||||
expect(failure.recommendation).not.toContain("稍后重试");
|
||||
});
|
||||
|
||||
test("Qwen 3 sends reference bytes directly with the right MIME and keeps auto resolution", async () => {
|
||||
const png = await sharp({ create: { width: 32, height: 64, channels: 3, background: "blue" } }).png().toBuffer();
|
||||
const jpeg = await sharp(png).jpeg().toBuffer();
|
||||
const webp = await sharp(png).webp().toBuffer();
|
||||
const originals = [png, jpeg, webp];
|
||||
let submitted: any;
|
||||
let posts = 0;
|
||||
const loader = serverLoader({
|
||||
"@/lib/storage/image-archive": {}, "@/lib/storage/service": { uploadFile() { throw new Error("Reference transport must not upload an intermediate file"); } },
|
||||
setTimeout: (callback: () => void, ms: number) => setTimeout(callback, ms === 5000 ? 0 : ms),
|
||||
fetch: async (url: string, init: RequestInit = {}) => {
|
||||
if (init.method === "POST") { posts++; submitted = JSON.parse(String(init.body)); return Response.json({ output: { task_id: "inline-task", task_status: "PENDING" } }); }
|
||||
if (url.includes("/tasks/")) return Response.json({ output: { task_status: "FAILED", message: "transport-inspection-complete" } });
|
||||
const index = Number(new URL(url).pathname.slice(1));
|
||||
return new Response(originals[index], { headers: { "content-type": ["image/png", "image/jpeg", "image/webp"][index] } });
|
||||
},
|
||||
});
|
||||
const provider = loader("lib/ai/providers/alibaba-image.ts").createAlibabaImage({ apiKey: "fixture-key", baseURL: "https://dashscope.example" });
|
||||
await expect(provider.chat("qwen-image-3.0").doGenerate({ messages: [{ role: "user", content: [
|
||||
{ type: "text", text: "fixture" }, ...originals.map((_bytes, index) => ({ type: "image", image: `https://source.example/${index}` })),
|
||||
] }], imageParams: { aspectRatio: "auto", imageSize: "2K" } })).rejects.toThrow("transport-inspection-complete");
|
||||
expect(posts).toBe(1);
|
||||
expect(submitted.parameters.size).toBe("1024*2048");
|
||||
const images = submitted.input.messages[0].content.filter((part: any) => part.image).map((part: any) => part.image);
|
||||
expect(images).toHaveLength(3);
|
||||
images.forEach((value: string, index: number) => {
|
||||
expect(value.split(";", 1)[0]).toBe(["data:image/png", "data:image/jpeg", "data:image/webp"][index]);
|
||||
expect(Buffer.from(value.split(",")[1], "base64").equals(originals[index])).toBe(true);
|
||||
});
|
||||
});
|
||||
@ -1,159 +0,0 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
const auth = { auth: async () => ({ user: { id: "owner" } }) };
|
||||
const request = () => ({
|
||||
url: "https://app.example/api/canvas/download-image", headers: new Headers(),
|
||||
json: async () => ({ url: "https://provider.example/original.jpg", preferDirect: true, preparation: "async" }),
|
||||
});
|
||||
|
||||
test("download failures distinguish permanent sources, transport, conversion and storage with actionable advice", () => {
|
||||
const { getDownloadFailure, formatDownloadFailure } = serverLoader({})("lib/canvas/download-error.ts");
|
||||
for (const [error, code, status] of [
|
||||
["HTTP 404", "SOURCE_NOT_FOUND", 404], ["HTTP 403", "SOURCE_FORBIDDEN", 403],
|
||||
["TimeoutError: slow body", "SOURCE_TIMEOUT", 504], ["fetch failed", "SOURCE_UNAVAILABLE", 503],
|
||||
["source returned an error document", "INVALID_IMAGE", 422], ["unsupported image format", "IMAGE_CONVERSION_FAILED", 422],
|
||||
["STORAGE_WRITE_FAILED", "DOWNLOAD_STORAGE_FAILED", 503], ["DOWNLOAD_BUSY", "DOWNLOAD_BUSY", 429],
|
||||
]) {
|
||||
const failure = getDownloadFailure(new Error(error as string));
|
||||
expect(failure).toMatchObject({ code, status });
|
||||
expect(failure.recommendation).toBeTruthy();
|
||||
expect(formatDownloadFailure(failure, "feedback-id")).toContain("下载编号:feedback-id");
|
||||
if (status === 404) expect(failure.recommendation).not.toContain("稍后重试");
|
||||
}
|
||||
});
|
||||
|
||||
test("failed preparation polls retain the same cause and feedback ID without rerunning the load", async () => {
|
||||
const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts");
|
||||
const runs: Array<() => Promise<void>> = [];
|
||||
let loads = 0;
|
||||
const queue = createDownloadPreparationQueue({
|
||||
load: async () => { loads++; throw new Error("HTTP 404 https://source.example/private?token=private"); },
|
||||
read: async () => null, write: async () => {}, schedule: (run: () => Promise<void>) => runs.push(run),
|
||||
});
|
||||
const input = { userId: "owner", urls: ["https://source.example/original"], requestOrigin: "https://app.example", cookie: "session=private" };
|
||||
const accepted = await queue.request(input);
|
||||
await runs[0]();
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const failed = await queue.request(input);
|
||||
expect(failed).toMatchObject({ status: "failed", requestId: accepted.requestId, failure: { code: "SOURCE_NOT_FOUND", status: 404 } });
|
||||
expect(failed.error).not.toContain("token=private");
|
||||
}
|
||||
expect(loads).toBe(1);
|
||||
});
|
||||
|
||||
test("download queue saturation returns a retryable 429 instead of a source 502", async () => {
|
||||
const { createDownloadPreparationQueue } = serverLoader({})("lib/canvas/download-preparation.ts");
|
||||
const queue = createDownloadPreparationQueue({ load: async () => { throw new Error("not started"); }, read: async () => null, write: async () => {}, schedule() {} });
|
||||
for (let i = 0; i < 4; i++) await queue.request({ userId: "owner", urls: [`https://source.example/${i}`], requestOrigin: "https://app.example", cookie: "" });
|
||||
expect(await queue.request({ userId: "owner", urls: ["https://source.example/fifth"], requestOrigin: "https://app.example", cookie: "" }))
|
||||
.toMatchObject({ status: "failed", failure: { code: "DOWNLOAD_BUSY", status: 429, retryable: true } });
|
||||
});
|
||||
|
||||
test("cold archived downloads wait for CDN preparation and never buffer the file through the app", async () => {
|
||||
const queued: string[] = [];
|
||||
let ready = false;
|
||||
const route = serverLoader({
|
||||
"@/app/(auth)/auth": auth,
|
||||
"next/server": { NextResponse: Response, after() {} },
|
||||
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({
|
||||
task: { id: "archive-task", targetUrl: "https://cdn.example/original.jpg", metadata: { downloadAsset: { status: "pending" } } },
|
||||
canPrepareDirect: true,
|
||||
direct: ready ? { delivery: "cdn", downloadUrl: "https://cdn.example/prepared.png", filename: "original.png", bytes: 4000000 } : null,
|
||||
}) },
|
||||
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation: (id: string) => queued.push(id) },
|
||||
"@/lib/canvas/photoshop-safe-download": { sanitizeCanvasDownloadFileName: () => "original", fetchCanvasDownloadAsset: () => { throw new Error("Must use CDN delivery"); } },
|
||||
})("app/api/canvas/download-image/route.ts");
|
||||
const first = await route.POST(request());
|
||||
expect(first.status).toBe(202);
|
||||
expect(await first.json()).toMatchObject({ delivery: "preparing", requestId: "archive-task" });
|
||||
ready = true;
|
||||
const second = await route.POST(request());
|
||||
expect(second.status).toBe(200);
|
||||
expect(await second.json()).toMatchObject({ delivery: "cdn", bytes: 4000000 });
|
||||
expect(queued).toEqual(["archive-task"]);
|
||||
});
|
||||
|
||||
test("an expired archived source returns its permanent cause and download task ID", async () => {
|
||||
const errors = serverLoader({})("lib/canvas/download-error.ts");
|
||||
const route = serverLoader({
|
||||
"@/app/(auth)/auth": auth, "next/server": { NextResponse: Response, after() {} },
|
||||
"@/lib/storage/canvas-cdn-download": { resolveCanvasCdnDownload: async () => ({ task: {
|
||||
id: "failed-archive", targetUrl: "https://cdn.example/missing.png",
|
||||
metadata: { downloadAsset: { status: "abandoned", failure: errors.getDownloadFailure("HTTP 404") } },
|
||||
}, canPrepareDirect: true, direct: null }) },
|
||||
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() { throw new Error("Do not retry a permanent failure"); } },
|
||||
})("app/api/canvas/download-image/route.ts");
|
||||
const response = await route.POST(request());
|
||||
expect(response.status).toBe(404);
|
||||
expect(await response.json()).toMatchObject({ requestId: "failed-archive", failure: { code: "SOURCE_NOT_FOUND" } });
|
||||
});
|
||||
|
||||
test("the download browser keeps the structured cause, advice and feedback ID", async () => {
|
||||
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
|
||||
const client = serverLoader({ fetch: async () => Response.json({ failure, requestId: "real-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
|
||||
let caught: unknown;
|
||||
try { await client.downloadCanvasImageAsPng({ url: "https://source.example/original" }); } catch (error) { caught = error; }
|
||||
expect(client.getCanvasImageDownloadErrorMessage(caught)).toBe("图片源站拒绝访问原图。请联系管理员检查访问权限。\n下载编号:real-feedback");
|
||||
});
|
||||
|
||||
test("the preparation deadline cancels the source body as well as its headers", async () => {
|
||||
const cache = serverLoader({ fetch: async (_url: string, options: { signal: AbortSignal }) => ({
|
||||
ok: true, headers: new Headers({ "content-type": "image/png" }),
|
||||
arrayBuffer: () => new Promise((_resolve, reject) => options.signal.addEventListener("abort", () => reject(options.signal.reason), { once: true })),
|
||||
}) })("lib/storage/image-byte-cache.ts");
|
||||
const controller = new AbortController();
|
||||
const fetching = cache.fetchImageBytes(`https://source.example/${randomUUID()}`, { timeoutMs: 10000, signal: controller.signal });
|
||||
setTimeout(() => controller.abort(new Error("Preparation canceled")), 20);
|
||||
await expect(fetching).rejects.toThrow("Preparation canceled");
|
||||
});
|
||||
|
||||
test("requested downloads are prioritized with two workers before scanning background assets", async () => {
|
||||
let active = 0; let maximum = 0; let scans = 0;
|
||||
const releases: Array<() => void> = [];
|
||||
const workRecords: Array<{ active: number; durable: number }> = [];
|
||||
const claimed: string[] = []; const saved: string[] = [];
|
||||
const bytes = Buffer.from("compatible PNG fixture");
|
||||
const worker = serverLoader({
|
||||
"node:fs": { writeFileSync: (_path: string, value: string) => workRecords.push(JSON.parse(value)), unlinkSync() {} },
|
||||
"./image-archive-db": {
|
||||
claimImageDownloadTask: async (id: string) => { claimed.push(id); return { id, targetUrl: `https://cdn.example/${id}.png`, metadata: {} }; },
|
||||
saveImageDownloadAsset: async (id: string) => saved.push(id),
|
||||
getImageDownloadTasks: async () => { scans++; return []; }, hasPendingImageDownloads: async () => false,
|
||||
},
|
||||
"./image-byte-cache": { fetchImageBytes: async () => { active++; maximum = Math.max(maximum, active); await new Promise<void>(resolve => releases.push(resolve)); active--; return { buffer: bytes, contentType: "image/png" }; }, cacheImageBytes: async () => {} },
|
||||
"@/lib/canvas/photoshop-safe-download": { normalizeCanvasDownloadAsset: async () => ({ ok: true, bytes, contentType: "image/png" }) },
|
||||
"./service": { uploadFile() { throw new Error("Compatible PNG must retain its object"); } },
|
||||
})("lib/storage/image-download-preparation.ts");
|
||||
worker.scheduleImageDownloadPreparation("one"); worker.scheduleImageDownloadPreparation("two"); worker.scheduleImageDownloadPreparation("three");
|
||||
await expect.poll(() => active).toBe(2);
|
||||
expect(workRecords.at(-1)).toMatchObject({ active: 1, durable: 0 });
|
||||
expect(scans).toBe(0);
|
||||
releases.splice(0).forEach(release => release());
|
||||
await expect.poll(() => claimed.length).toBe(3);
|
||||
releases.splice(0).forEach(release => release());
|
||||
await expect.poll(() => saved.length).toBe(3);
|
||||
expect(maximum).toBe(2); expect(scans).toBe(0);
|
||||
});
|
||||
|
||||
test("archive URL replacement cannot block the download worker wakeup", async () => {
|
||||
let scheduled = false; let finish!: () => void; let replacing = false;
|
||||
const gate = new Promise<void>(resolve => { finish = resolve; });
|
||||
const job = serverLoader({
|
||||
"@/lib/observability/active-work": { trackRuntimeWork: (execute: () => Promise<unknown>) => execute() },
|
||||
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() { scheduled = true; } },
|
||||
"@/lib/storage/image-archive-db": { recoverStaleImageArchiveTasks: async () => {}, getImageArchiveTasksToStore: async () => [],
|
||||
getImageArchiveTasksToReplace: async () => [{ id: "archive", sourceUrl: "https://source.example/a", targetUrl: "https://cdn.example/a" }],
|
||||
markImageArchiveTaskReplacing: async () => true, markImageArchiveTaskCompleted: async () => {}, hasPendingImageDownloads: async () => false,
|
||||
getImageArchiveTaskStats: async () => ({ pendingStore: 0, pendingReplace: 0 }),
|
||||
},
|
||||
"@/lib/storage/image-url-replacement": { replaceImageUrlInDatabase: async () => { replacing = true; await gate; return { changed: 1 }; } },
|
||||
"@/lib/storage/image-archive": {},
|
||||
})("lib/jobs/image-archive-job.ts");
|
||||
const running = job.runImageArchiveJob();
|
||||
await expect.poll(() => replacing).toBe(true);
|
||||
expect(scheduled).toBe(true);
|
||||
finish();
|
||||
expect((await running).replace.succeeded).toBe(1);
|
||||
});
|
||||
@ -224,7 +224,7 @@ test("bucket failure retains the provider URL and retry task", async () => {
|
||||
let stored = 0;
|
||||
let replaced = 0;
|
||||
const module = serverLoader({
|
||||
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} },
|
||||
"@/lib/storage/image-download-preparation": { preparePendingImageDownloads: async () => 0 },
|
||||
"@/lib/storage/image-archive": {
|
||||
archiveTemporaryImage: async () => {
|
||||
throw new Error("Bucket unavailable");
|
||||
@ -414,7 +414,7 @@ test("successful background archive replaces saved references immediately", asyn
|
||||
let completed = false;
|
||||
const calls: string[][] = [];
|
||||
const module = serverLoader({
|
||||
"@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} },
|
||||
"@/lib/storage/image-download-preparation": { preparePendingImageDownloads: async () => 0 },
|
||||
"@/lib/storage/image-archive": {
|
||||
archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }),
|
||||
},
|
||||
|
||||
@ -154,7 +154,7 @@ test.describe("Qiniu image delivery", () => {
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=http%3A%2F%2F192.168.1.1%2Fsecret&original=1"))).status).toBe(403);
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1&w=200"))).status).toBe(400);
|
||||
expect(fetchCount).toBe(0);
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(422);
|
||||
expect((await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fa.jpg&original=1"))).status).toBe(400);
|
||||
} finally {
|
||||
globalThis.fetch = previousFetch;
|
||||
}
|
||||
|
||||
@ -1,114 +0,0 @@
|
||||
import { test, expect } from "@playwright/test";
|
||||
import { serverLoader } from "../helpers/canvas-download-server-loader";
|
||||
|
||||
const config = { type: "qiniu-kodo", isEnabled: true, publicUrl: "https://cdn.example" };
|
||||
|
||||
test("original downloads use the archived JPEG rather than the prepared PNG and transfer only HEAD metadata", async () => {
|
||||
const calls: Array<{ url: string; method: string }> = [];
|
||||
const loader = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => ({ targetUrl: "https://cdn.example/original.jpg", metadata: {
|
||||
downloadAsset: { status: "ready", url: "https://cdn.example/converted.png", contentType: "image/png" },
|
||||
} }) },
|
||||
fetch: async (url: URL, options: { method: string }) => {
|
||||
calls.push({ url: url.toString(), method: options.method });
|
||||
return { ok: true, headers: new Headers({ "content-type": "image/jpeg", "content-length": "12345" }),
|
||||
arrayBuffer() { throw new Error("Image bytes must bypass the application"); } };
|
||||
},
|
||||
});
|
||||
const result = await loader("lib/storage/original-image-download.ts").resolveOriginalCdnDownload("https://upstream.example/temporary.png", "产品.png", new AbortController().signal);
|
||||
expect(calls).toEqual([{ url: "https://cdn.example/original.jpg", method: "HEAD" }]);
|
||||
expect(result).toMatchObject({ delivery: "cdn", filename: "产品.jpg", bytes: 12345 });
|
||||
expect(new URL(result.downloadUrl).pathname).toBe("/original.jpg");
|
||||
});
|
||||
|
||||
test("signed original URLs retain the byte-preserving proxy without changing their signature", async () => {
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||||
fetch() { throw new Error("Do not add filename parameters to a signed URL"); },
|
||||
})("lib/storage/original-image-download.ts");
|
||||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.jpg?token=signature%2Fvalue&e=123", "image.jpg", new AbortController().signal);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("existing CDN objects bypass the archive scan and retain the MIME-derived format", async () => {
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask() { throw new Error("An existing CDN object needs no archive scan"); } },
|
||||
fetch: async () => ({ ok: true, headers: new Headers({ "content-type": "image/webp" }) }),
|
||||
})("lib/storage/original-image-download.ts");
|
||||
const result = await module.resolveOriginalCdnDownload("https://cdn.example/source.png", "source.png", new AbortController().signal);
|
||||
expect(result.filename).toBe("source.webp");
|
||||
expect(new URL(result.downloadUrl).pathname).toBe("/source.png");
|
||||
});
|
||||
|
||||
test("temporary third-party URLs are never checked with HEAD when no archive is ready", async () => {
|
||||
const module = serverLoader({
|
||||
"@/lib/db/queries": { getDefaultStorageConfig: async () => config },
|
||||
"./image-archive-db": { findImageArchiveDownloadTask: async () => null },
|
||||
fetch() { throw new Error("The upstream file service does not support HEAD"); },
|
||||
})("lib/storage/original-image-download.ts");
|
||||
expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull();
|
||||
});
|
||||
|
||||
test("original download metadata reports missing sources with cause, advice and a feedback ID", async () => {
|
||||
const route = serverLoader({
|
||||
"next/server": { NextResponse: Response },
|
||||
"@/lib/storage/original-image-download": { resolveOriginalCdnDownload: async () => { throw new Error("HTTP 404"); } },
|
||||
})("app/api/image-proxy/route.ts");
|
||||
const request = { nextUrl: new URL("https://app.example/api/image-proxy?url=https%3A%2F%2Fcdn.example%2Fmissing.jpg&original=1&download=resolve"), signal: new AbortController().signal, headers: new Headers() };
|
||||
const response = await route.GET(request);
|
||||
const data = await response.json();
|
||||
expect(response.status).toBe(404);
|
||||
expect(data.failure).toMatchObject({ code: "SOURCE_NOT_FOUND", retryable: false });
|
||||
expect(data.failure.recommendation).toBeTruthy();
|
||||
expect(data.error).toContain(data.requestId);
|
||||
});
|
||||
|
||||
function browserMocks() {
|
||||
const frames: any[] = []; const links: any[] = [];
|
||||
return { frames, links,
|
||||
window: { location: { origin: "https://app.example" }, setTimeout() {} },
|
||||
document: { createElement: (tag: string) => ({ tag, click() { links.push(this); }, remove() {} }),
|
||||
body: { appendChild: (el: any) => { if (el.tag === "iframe") frames.push(el); }, removeChild() {} } },
|
||||
};
|
||||
}
|
||||
|
||||
test("chat original downloads use a native CDN download without fetching the image through CORS", async () => {
|
||||
const dom = browserMocks(); const calls: string[] = [];
|
||||
const client = serverLoader({ ...dom, fetch: async (url: string) => {
|
||||
calls.push(url);
|
||||
return Response.json({ delivery: "cdn", downloadUrl: "https://cdn.example/image.jpg?attname=original.jpg", filename: "original.jpg", bytes: 5000000 });
|
||||
} })("lib/client/canvas-image-download.ts");
|
||||
const result = await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" });
|
||||
expect(result).toEqual({ filename: "original.jpg", bytes: 5000000 });
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(new URL(calls[0], "https://app.example").searchParams.get("download")).toBe("resolve");
|
||||
expect(dom.frames[0].src).toBe("https://cdn.example/image.jpg?attname=original.jpg");
|
||||
expect(dom.links).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("original fallback preserves JPEG bytes and the MIME-derived filename", async () => {
|
||||
const dom = browserMocks(); const bytes = Buffer.from([255, 216, 255, 224, 0, 16, 74, 70, 73, 70, 255, 217]); let calls = 0;
|
||||
const client = serverLoader({ ...dom, fetch: async () => ++calls === 1 ? Response.json({ delivery: "proxy" }) : new Response(bytes, { headers: { "content-type": "image/jpeg" } }) })("lib/client/canvas-image-download.ts");
|
||||
expect(await client.downloadOriginalImage({ url: "https://external.example/image.png", name: "original.png" })).toEqual({ filename: "original.jpg", bytes: bytes.length });
|
||||
expect(calls).toBe(2);
|
||||
const actual = await fetch(dom.links[0].href).then(r=>r.arrayBuffer());
|
||||
expect(Buffer.from(actual).equals(bytes)).toBe(true);
|
||||
URL.revokeObjectURL(dom.links[0].href);
|
||||
});
|
||||
|
||||
test("original downloads reject processed references before network access", async () => {
|
||||
const client = serverLoader({ ...browserMocks(), fetch() { throw new Error("Must not fetch a processed image as an original"); } })("lib/client/canvas-image-download.ts");
|
||||
await expect(client.downloadOriginalImage({ url: "https://cdn.example/image.jpg?imageView2/2/w/320&token=signed" })).rejects.toThrow("无法确认原图");
|
||||
});
|
||||
|
||||
test("chat downloads display the server cause and advice without losing its feedback ID", async () => {
|
||||
const failure = { code: "SOURCE_FORBIDDEN", message: "图片源站拒绝访问原图。", recommendation: "请联系管理员检查访问权限。", status: 403, retryable: false };
|
||||
const client = serverLoader({ ...browserMocks(), fetch: async () => Response.json({ failure, requestId: "original-feedback" }, { status: 403 }) })("lib/client/canvas-image-download.ts");
|
||||
let error: unknown;
|
||||
try { await client.downloadOriginalImage({ url: "https://cdn.example/image.jpg" }); } catch(e) { error=e; }
|
||||
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain("下载编号:original-feedback");
|
||||
expect(client.getCanvasImageDownloadErrorMessage(error)).toContain(failure.recommendation);
|
||||
});
|
||||
@ -17,14 +17,3 @@ test("release draining includes background work until its original promise settl
|
||||
await expect(work.trackRuntimeWork(async () => { throw new Error("expected failure"); })).rejects.toThrow("expected failure");
|
||||
expect(counts.at(-1)).toBe(0);
|
||||
});
|
||||
|
||||
test("durable archive handoff is separate from unfinished original generation", async () => {
|
||||
const records: Array<{ active: number; durable: number }> = [];
|
||||
const work = serverLoader({ "node:fs": { writeFileSync: (_path: string, value: string) => records.push(JSON.parse(value)), unlinkSync() {} } })("lib/observability/active-work.ts");
|
||||
let complete!: () => void;
|
||||
const original = work.trackRuntimeWork(() => new Promise<void>(resolve => { complete = resolve; }));
|
||||
await work.trackRuntimeWork(async () => "archive", { durable: true });
|
||||
expect(records.map(x => [x.active, x.durable])).toEqual([[1, 0], [2, 1], [1, 0]]);
|
||||
complete(); await original;
|
||||
expect(records.at(-1)).toMatchObject({ active: 0, durable: 0 });
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user