diff --git a/app/api/canvas/download-image/route.ts b/app/api/canvas/download-image/route.ts index 592878af1..a17b6d858 100644 --- a/app/api/canvas/download-image/route.ts +++ b/app/api/canvas/download-image/route.ts @@ -1,6 +1,9 @@ import { resolveCanvasCdnDownload } from "@/lib/storage/canvas-cdn-download"; import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "@/lib/storage/retired-storage"; -import { prepareImageArchiveDownload } from "@/lib/storage/image-download-preparation"; +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 { after, NextRequest, NextResponse } from "next/server"; import { auth } from "@/app/(auth)/auth"; import { getDownloadPreparationQueue, type DownloadPreparationInput } from "@/lib/canvas/download-preparation"; @@ -13,6 +16,22 @@ 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 @@ -21,22 +40,25 @@ async function prepareDownload(input: DownloadPreparationInput, signal: AbortSig const source = extractImageProxySourceUrl(url) || url; return inferOriginalImageUrl(source) || source; }))]; - for (let attempt = 0; attempt < 3; attempt++) { + let lastError: unknown; + for (let attempt = 0; attempt < 2; 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: 12_000, retryCount: 1, signal, + includeSequencePrefix: false, timeoutMs: 60_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 < 2) await new Promise(resolve => setTimeout(resolve, 1_000)); + if (attempt < 1 && getDownloadFailure(lastError).retryable) await new Promise(resolve => setTimeout(resolve, 1_000)); + else break; } - throw new Error("Original image sources unavailable"); + throw new Error(String(lastError || "Original image sources unavailable")); } function getRequestOrigin(request: NextRequest): string { @@ -76,31 +98,43 @@ function resolveDownloadUrls(body: Record, origin: string): str } export async function POST(request: NextRequest) { + const requestId = randomUUID(); try { const session = await auth(); if (!session?.user?.id) { - return NextResponse.json({ success: false, error: "未登录" }, { status: 401 }); + return failed(getDownloadFailure("未登录"), requestId); } const body = (await request.json().catch(() => ({}))) as Record; const requestOrigin = getRequestOrigin(request); const candidates = resolveDownloadUrls(body, requestOrigin); if (candidates.length && candidates.every(isRetiredStorageUrl)) { - return NextResponse.json({ success: false, error: RETIRED_STORAGE_MESSAGE, code: "storage_retired" }, { status: 410 }); + return failed(getDownloadFailure(RETIRED_STORAGE_MESSAGE), requestId); } const urls = candidates.filter(url => !isRetiredStorageUrl(url)); const requestedUrls = [...urls]; if (urls.length === 0) { - return NextResponse.json({ success: false, error: "图片地址无效" }, { status: 400 }); + return failed(getDownloadFailure("图片地址无效"), requestId); } const name = sanitizeCanvasDownloadFileName(String(body.name || ""), "canvas-image"); if (body.preferDirect === true) { - const { task, direct } = await resolveCanvasCdnDownload(urls, `${name}.png`).catch(() => ({ task: null, direct: null })); + 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 }; + }); 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); - after(() => prepareImageArchiveDownload(task.id)); + scheduleImageDownloadPreparation(task.id); } } const cookie = request.headers.get("cookie") || ""; @@ -109,13 +143,10 @@ export async function POST(request: NextRequest) { userId: session.user.id, urls: requestedUrls, requestOrigin, cookie, }); if (result.status === "preparing") { - return NextResponse.json({ delivery: "preparing", retryAfter: 3, - message: "正在准备原图,完成后将自动下载" }, { - status: 202, headers: { "Retry-After": "3", "Cache-Control": "no-store" }, - }); + return preparing(result.requestId); } if (result.status === "failed") { - return NextResponse.json({ success: false, error: result.error }, { status: 502 }); + return failed(result.failure, result.requestId); } return new NextResponse(new Uint8Array(result.asset.buffer), { headers: { "Content-Type": result.asset.contentType, @@ -136,17 +167,14 @@ export async function POST(request: NextRequest) { includeSequencePrefix: false, }); if (result.ok) break; - failures.push(`${url}: ${result.error}`); + failures.push(result.error); } if (!result?.ok) { if (failures.length > 0) { - console.warn("[CanvasDownloadImage] All candidates failed:", failures); + logger.warn("all_sources_failed", { requestId, failures }); } - return NextResponse.json( - { success: false, error: result?.error || "图片源文件暂时不可访问" }, - { status: 502 } - ); + return failed(getDownloadFailure(result?.error), requestId); } return new NextResponse(result.bytes, { @@ -159,7 +187,7 @@ export async function POST(request: NextRequest) { }, }); } catch (error) { - console.error("[CanvasDownloadImage] Failed:", error); - return NextResponse.json({ success: false, error: "图片下载失败" }, { status: 500 }); + logger.error("download_failed", error, { requestId }); + return failed(getDownloadFailure(error), requestId); } } diff --git a/lib/canvas/download-error.ts b/lib/canvas/download-error.ts new file mode 100644 index 000000000..e7298b460 --- /dev/null +++ b/lib/canvas/download-error.ts @@ -0,0 +1,30 @@ +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("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}` : ""}`; +} diff --git a/lib/canvas/download-preparation.ts b/lib/canvas/download-preparation.ts index a77ce42be..811d9acdd 100644 --- a/lib/canvas/download-preparation.ts +++ b/lib/canvas/download-preparation.ts @@ -1,6 +1,10 @@ import "server-only"; -import { createHash } from "node:crypto"; +import { createHash, randomUUID } 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; @@ -9,17 +13,18 @@ export type DownloadPreparationInput = { cookie: string; }; type Result = - | { status: "preparing" } + | { status: "preparing"; requestId: string } | { status: "ready"; asset: ImageBytes } - | { status: "failed"; error: string }; + | { status: "failed"; error: string; failure: DownloadFailure; requestId: 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. @@ -69,7 +74,7 @@ export function createDownloadPreparationQueue(deps: { timer = setTimeout(() => { controller.abort(); reject(new Error("Download preparation deadline")); - }, deps.timeoutMs || 90_000); + }, deps.timeoutMs || 150_000); }), ]); if (asset.contentType !== "image/png" || !asset.buffer.length || asset.buffer.length > MAX_MEMORY_BYTES) { @@ -81,8 +86,10 @@ 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 { + } catch (error) { 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. @@ -105,8 +112,11 @@ 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") return { status: "failed", error: FAILURE_MESSAGE }; - if (current && current.status !== "ready") return { status: "preparing" }; + 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 (jobs.size >= 64) { for (const [oldKey, job] of jobs) { if (job.status === "ready" || job.status === "failed") jobs.delete(oldKey); @@ -115,11 +125,14 @@ export function createDownloadPreparationQueue(deps: { } if (jobs.size >= 64 || [...jobs.values()].filter(job => job.userId === input.userId && (job.status === "queued" || job.status === "running")).length >= 4) { - return { status: "failed", error: "正在准备的原图较多,请等待当前下载完成后再试" }; + const requestId = randomUUID(); + const failure = getDownloadFailure("DOWNLOAD_BUSY"); + return { status: "failed", error: formatDownloadFailure(failure, requestId), failure, requestId }; } - jobs.set(key, { userId: input.userId, input, status: "queued", updatedAt: now() }); + const requestId = randomUUID(); + jobs.set(key, { requestId, userId: input.userId, input, status: "queued", updatedAt: now() }); deps.schedule(drain); - return { status: "preparing" }; + return { status: "preparing", requestId }; }, }; } diff --git a/lib/canvas/photoshop-safe-download.ts b/lib/canvas/photoshop-safe-download.ts index f6881c471..8bd240dcd 100644 --- a/lib/canvas/photoshop-safe-download.ts +++ b/lib/canvas/photoshop-safe-download.ts @@ -2,6 +2,7 @@ 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; @@ -31,6 +32,7 @@ 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"; } @@ -330,6 +332,7 @@ 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 @@ -344,11 +347,13 @@ export async function fetchCanvasDownloadAsset(params: { }); } catch (error) { params.signal?.throwIfAborted(); - lastError = normalizeDownloadError(error); + const message = normalizeDownloadError(error); + if (!lastError || getDownloadFailure(message).retryable || !getDownloadFailure(lastError).retryable) lastError = message; } } // 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); } diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts index 0e948afad..22b8309fa 100644 --- a/lib/client/canvas-image-download.ts +++ b/lib/client/canvas-image-download.ts @@ -1,5 +1,6 @@ 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"; @@ -26,6 +27,8 @@ export class CanvasImageDownloadError extends Error { code: CanvasImageDownloadErrorCode; retryAfterSeconds?: number; status?: number; + failure?: DownloadFailure; + requestId?: string; constructor( message: string, @@ -33,6 +36,8 @@ export class CanvasImageDownloadError extends Error { code: CanvasImageDownloadErrorCode; retryAfterSeconds?: number; status?: number; + failure?: DownloadFailure; + requestId?: string; } ) { super(message); @@ -40,11 +45,14 @@ 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} 秒后重试` @@ -54,10 +62,10 @@ export function getCanvasImageDownloadErrorMessage(error: unknown) { return "原图暂时无法读取,图片仍保留在画布中,请稍后重试"; } if (error.code === "preparation_timeout") { - return "原图仍在后台准备中,请稍后再次点击下载"; + return `原图仍在后台准备中,请稍后再次点击下载${error.requestId ? `\n下载编号:${error.requestId}` : ""}`; } } - return error instanceof Error ? error.message : "图片下载失败,请稍后重试"; + return formatDownloadFailure(getDownloadFailure(error)); } type DownloadCanvasImageParams = { @@ -71,6 +79,8 @@ type DownloadCanvasImageParams = { type DownloadErrorPayload = { error?: string; retryAfter?: number; + failure?: DownloadFailure; + requestId?: string; }; const MAX_RATE_LIMIT_RETRIES = 1; @@ -257,7 +267,8 @@ export async function downloadCanvasImageAsPng({ let preparationPolls = 0; for (;;) { signal?.throwIfAborted(); - response = await fetch("/api/canvas/download-image", { + try { + response = await fetch("/api/canvas/download-image", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -268,7 +279,12 @@ 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; @@ -278,7 +294,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 }); + throw new CanvasImageDownloadError("原图仍在准备中", { code: "preparation_timeout", status: 202, requestId: preparing.requestId }); } onProgress?.({ phase: "preparing", message: preparing.message || "正在准备原图,完成后将自动下载" }); const delaySeconds = Math.max(preparationPolls > 5 ? 5 : 1, Math.min(5, Number(preparing.retryAfter) || 3)); @@ -299,6 +315,8 @@ export async function downloadCanvasImageAsPng({ code: "rate_limited", retryAfterSeconds, status: response.status, + failure: errorPayload?.failure, + requestId: errorPayload?.requestId, } ); } @@ -321,6 +339,8 @@ export async function downloadCanvasImageAsPng({ { code: status === 502 ? "source_unavailable" : "request_failed", status, + failure: errorPayload?.failure, + requestId: errorPayload?.requestId, } ); } diff --git a/lib/jobs/image-archive-job.ts b/lib/jobs/image-archive-job.ts index 10ce21f32..c8fe5779a 100644 --- a/lib/jobs/image-archive-job.ts +++ b/lib/jobs/image-archive-job.ts @@ -1,4 +1,4 @@ -import { preparePendingImageDownloads } from "@/lib/storage/image-download-preparation"; +import { scheduleImageDownloadPreparation } from "@/lib/storage/image-download-preparation"; import "server-only"; import { trackRuntimeWork } from "@/lib/observability/active-work"; @@ -32,6 +32,7 @@ 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; @@ -45,6 +46,7 @@ export async function runImageArchiveJob() { id: task.id, targetUrl: archived.url, }); + scheduleImageDownloadPreparation(task.id); storeSucceeded += 1; console.log("[ImageArchiveJob] stored", { taskId: task.id, @@ -108,7 +110,6 @@ export async function runImageArchiveJob() { } } - const downloadTasks = await preparePendingImageDownloads(); const stats = await getImageArchiveTaskStats(); return { success: true, @@ -124,8 +125,8 @@ export async function runImageArchiveJob() { failed: replaceFailed, }, stats, - downloadTasks, + downloadTasks: 0, pendingDownloads: await hasPendingImageDownloads(), }; - }); + }, { durable: true }); } diff --git a/lib/observability/active-work.ts b/lib/observability/active-work.ts index 8e5f3c093..9e7816c7e 100644 --- a/lib/observability/active-work.ts +++ b/lib/observability/active-work.ts @@ -3,13 +3,14 @@ import { unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number }; +const state = globalThis as typeof globalThis & { __aiChatbotActiveWork?: number; __aiChatbotDurableWork?: number }; const path = join(process.platform === "win32" ? tmpdir() : "/tmp", `ai-chatbot-active-work-${process.pid}.json`); -function update(delta: number) { +function update(delta: number, durable: boolean) { 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 }), { mode: 0o600 }); + writeFileSync(path, JSON.stringify({ pid: process.pid, active: state.__aiChatbotActiveWork, durable: state.__aiChatbotDurableWork || 0 }), { mode: 0o600 }); } catch (error) { try { unlinkSync(path); } catch {} console.warn("[Active Work] Failed to record running work:", error); @@ -17,7 +18,7 @@ function update(delta: number) { } /** Includes generation that continues after the client connection closes. */ -export async function trackRuntimeWork(execute: () => T | Promise): Promise { - update(1); - try { return await execute(); } finally { update(-1); } +export async function trackRuntimeWork(execute: () => T | Promise, options: { durable?: boolean } = {}): Promise { + update(1, options.durable === true); + try { return await execute(); } finally { update(-1, options.durable === true); } } diff --git a/lib/storage/canvas-cdn-download.ts b/lib/storage/canvas-cdn-download.ts index f2f744f1d..ed6b2d03f 100644 --- a/lib/storage/canvas-cdn-download.ts +++ b/lib/storage/canvas-cdn-download.ts @@ -10,17 +10,21 @@ 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 (asset?.status !== "ready" || !asset.url || asset.contentType !== "image/png") return { task, direct: null }; + if (!task) return { task, direct: null, canPrepareDirect: false }; const config = await getDefaultStorageConfig(); - if (config?.type !== "qiniu-kodo" || !config.publicUrl || !config.isEnabled) return { task, direct: null }; - const url = new URL(asset.url); + 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); 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 }; + || !(url.pathname.startsWith(base.pathname.replace(/\/$/, "") + "/"))) return { task, direct: null, canPrepareDirect: false }; + if (!ready) return { task, direct: null, canPrepareDirect: true }; // 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 } }; + return { task, direct: { delivery: "cdn", downloadUrl: url.toString(), filename, bytes: asset?.bytes || 0 }, canPrepareDirect: true }; } diff --git a/lib/storage/image-byte-cache.ts b/lib/storage/image-byte-cache.ts index 50db11b92..f387d92b1 100644 --- a/lib/storage/image-byte-cache.ts +++ b/lib/storage/image-byte-cache.ts @@ -81,7 +81,7 @@ function isMediaResponse(data: ImageBytes) { if (/^<\?xml/i.test(prefix) && !/]/i.test(prefix)) return false; return data.buffer.length > 0; } -export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string } = {}) { +export async function fetchImageBytes(url: string, options: { headers?: HeadersInit; timeoutMs?: number; scope?: string; signal?: AbortSignal } = {}) { if (isRetiredStorageUrl(url)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`); return onceImageBytes(`source:${options.scope || ""}:${url}`, async () => { if (url.startsWith("data:")) { @@ -92,7 +92,9 @@ 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: AbortSignal.timeout(options.timeoutMs || 60_000), + signal: options.signal + ? AbortSignal.any([options.signal, AbortSignal.timeout(options.timeoutMs || 60_000)]) + : 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()); diff --git a/lib/storage/image-download-preparation.ts b/lib/storage/image-download-preparation.ts index 7dce71bb7..46da2d450 100644 --- a/lib/storage/image-download-preparation.ts +++ b/lib/storage/image-download-preparation.ts @@ -2,14 +2,21 @@ 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 } from "./image-archive-db"; +import { claimImageDownloadTask, saveImageDownloadAsset, getImageDownloadTasks, hasPendingImageDownloads } 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, @@ -17,15 +24,22 @@ 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 { + } catch (error) { const attempts = Number((task.metadata as { downloadAsset?: { attempts?: number } } | null)?.downloadAsset?.attempts || 1); - await saveImageDownloadAsset(id, { status: attempts >= 12 ? "abandoned" : "failed", attempts, retryAt: new Date(Date.now() + 10 * 60_000).toISOString() }); + 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() }); } } export async function preparePendingImageDownloads() { @@ -36,3 +50,45 @@ export async function preparePendingImageDownloads() { })); return tasks.length; } + +const state = globalThis as typeof globalThis & { + __imageDownloadWorker?: { running: boolean; scanPending: boolean; priority: Set; retry?: ReturnType }; +}; + +/** 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; + } + } + }, { durable: true }); + }); +} diff --git a/playwright.helpers.config.ts b/playwright.helpers.config.ts index c949b6a34..c5ab48c86 100644 --- a/playwright.helpers.config.ts +++ b/playwright.helpers.config.ts @@ -3,6 +3,11 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: "./tests", testMatch: [ + /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/, diff --git a/tests/routes/canvas-download-recovery.test.ts b/tests/routes/canvas-download-recovery.test.ts new file mode 100644 index 000000000..6468113d0 --- /dev/null +++ b/tests/routes/canvas-download-recovery.test.ts @@ -0,0 +1,157 @@ +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> = []; + 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) => 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 claimed: string[] = []; const saved: string[] = []; + const bytes = Buffer.from("compatible PNG fixture"); + const worker = serverLoader({ + "@/lib/observability/active-work": { trackRuntimeWork: (execute: () => Promise) => execute() }, + "./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(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(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(resolve => { finish = resolve; }); + const job = serverLoader({ + "@/lib/observability/active-work": { trackRuntimeWork: (execute: () => Promise) => 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); +}); diff --git a/tests/routes/image-archive-flow.test.ts b/tests/routes/image-archive-flow.test.ts index 4d9a3a090..db9881fb0 100644 --- a/tests/routes/image-archive-flow.test.ts +++ b/tests/routes/image-archive-flow.test.ts @@ -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": { preparePendingImageDownloads: async () => 0 }, + "@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} }, "@/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": { preparePendingImageDownloads: async () => 0 }, + "@/lib/storage/image-download-preparation": { scheduleImageDownloadPreparation() {} }, "@/lib/storage/image-archive": { archiveTemporaryImage: async () => ({ url: permanent, bytes: png.length }), }, diff --git a/tests/routes/runtime-active-work.test.ts b/tests/routes/runtime-active-work.test.ts index 795f49384..beccc46a6 100644 --- a/tests/routes/runtime-active-work.test.ts +++ b/tests/routes/runtime-active-work.test.ts @@ -17,3 +17,14 @@ 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(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 }); +});