From 74053490ccc886f98e2671250c3de7c44f8bb0da Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Wed, 11 Nov 2026 15:25:48 +0800 Subject: [PATCH] =?UTF-8?q?=E9=81=BF=E5=85=8D=E9=A1=B5=E9=9D=A2=E6=9B=B4?= =?UTF-8?q?=E6=96=B0=E6=89=93=E6=96=AD=E5=9B=BE=E7=89=87=E4=B8=8B=E8=BD=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/layout.tsx | 7 +++- components/app-update-notice.tsx | 7 ++-- lib/client/canvas-image-download.ts | 19 ++++++++--- lib/client/page-refresh.ts | 35 ++++++++++++++++++++ lib/jobs/image-archive-job.ts | 4 +-- lib/storage/image-archive-db.ts | 7 ++-- lib/storage/image-archive-worker.ts | 8 +++-- lib/storage/original-image-download.ts | 4 +++ tests/routes/image-archive-flow.test.ts | 14 ++++---- tests/routes/original-image-download.test.ts | 12 +++++++ 10 files changed, 95 insertions(+), 22 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 88e1262f3..dcd2550af 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -4,6 +4,7 @@ import { ThemeProvider } from "@/components/theme-provider"; import { FaviconUpdater } from "@/components/favicon-updater"; import { ImageDeliveryOptimizer } from "@/components/image-delivery-optimizer"; import { AppUpdateNotice } from "@/components/app-update-notice"; +import { PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE } from "@/lib/client/page-refresh"; import { getSiteSettings } from "@/lib/db/queries"; import "./globals.css"; @@ -142,7 +143,7 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\ showManualRecoveryNotice(); return; } - if (lastReloadAt) return; + if (lastReloadAt || document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) return; var now = Date.now(); // If storage is blocked, a reload cannot remember its attempt and must not run. if (!safeSetSessionStorage(RECOVERY_KEY, String(now))) return; @@ -153,6 +154,10 @@ const CHUNK_ERROR_RECOVERY_SCRIPT = `\ showManualRecoveryNotice(); return; } + if (document.documentElement.hasAttribute('${PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE}')) { + if (safeSetSessionStorage(RECOVERY_KEY, '0')) lastReloadAt = 0; + return; + } window.location.reload(); }, 800); } diff --git a/components/app-update-notice.tsx b/components/app-update-notice.tsx index e5b49bda7..3154d6efd 100644 --- a/components/app-update-notice.tsx +++ b/components/app-update-notice.tsx @@ -1,14 +1,15 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useSyncExternalStore } from "react"; import { Button } from "@/components/ui/button"; import { AlertDialog, AlertDialogContent, AlertDialogHeader, AlertDialogTitle, AlertDialogDescription, AlertDialogFooter } from "@/components/ui/alert-dialog"; -import { preparePageRefresh } from "@/lib/client/page-refresh"; +import { isPageDownloadActive, preparePageRefresh, subscribeToPageDownloadActivity } from "@/lib/client/page-refresh"; export function AppUpdateNotice() { const [availableVersion, setAvailableVersion] = useState(""); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(""); + const downloading = useSyncExternalStore(subscribeToPageDownloadActivity, isPageDownloadActive, () => false); useEffect(() => { let disposed = false; let controller: AbortController | undefined; @@ -60,7 +61,7 @@ export function AppUpdateNotice() { setRefreshing(false); } }; - return + return event.preventDefault()}> 平台已更新 diff --git a/lib/client/canvas-image-download.ts b/lib/client/canvas-image-download.ts index da6aeaafa..51a97e4c9 100644 --- a/lib/client/canvas-image-download.ts +++ b/lib/client/canvas-image-download.ts @@ -1,6 +1,7 @@ import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; import { inferOriginalImageUrl } from "@/lib/image-url"; import { formatDownloadFailure, getDownloadFailure, type DownloadFailure } from "@/lib/canvas/download-error"; +import { withActivePageDownload } from "@/lib/client/page-refresh"; export type CanvasImageDownloadPhase = "preparing" | "converting" | "downloading" | "saving"; @@ -192,7 +193,11 @@ function triggerCdnDownload(result: { downloadUrl: string; filename: string; byt const PHOTOSHOP_DOWNLOAD_EXTENSIONS = new Set(["jpg", "png", "gif", "bmp", "tiff"]); /** Preserve compatible originals and prepare a PNG for other stored images. */ -export async function downloadOriginalImage(params: DownloadCanvasImageParams): Promise { +export function downloadOriginalImage(params: DownloadCanvasImageParams): Promise { + return withActivePageDownload(() => downloadOriginalImageFile(params)); +} + +async function downloadOriginalImageFile(params: DownloadCanvasImageParams): Promise { const { url, name, signal, onProgress } = params; const source = String(url || "").trim(); if (!source) throw new Error("图片地址无效"); @@ -224,7 +229,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams): // The resolver names the attachment using the stored MIME type. const extension = delivery.filename.split(".").pop(); if (!PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) { - return downloadCanvasImageAsPng(params, "converting"); + return downloadPngFile(params, "converting"); } const result = triggerCdnDownload(delivery, signal); onProgress?.({ phase: "downloading", message: "已开始下载,可在浏览器下载列表查看进度" }); @@ -239,7 +244,7 @@ export async function downloadOriginalImage(params: DownloadCanvasImageParams): // A local blob can still be downloaded while its upload is in progress. if (/^https?:$/.test(target.protocol) && !PHOTOSHOP_DOWNLOAD_EXTENSIONS.has(extension)) { void response.body?.cancel().catch(() => {}); - return downloadCanvasImageAsPng(params, "converting"); + return downloadPngFile(params, "converting"); } const blob = await readResponseBlobWithProgress(response, onProgress); signal?.throwIfAborted(); @@ -307,13 +312,17 @@ async function readResponseBlobWithProgress( }); } -export async function downloadCanvasImageAsPng({ +export function downloadCanvasImageAsPng(params: DownloadCanvasImageParams): Promise { + return withActivePageDownload(() => downloadPngFile(params, "preparing")); +} + +async function downloadPngFile({ candidates, name, onProgress, signal, url, -}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting" = "preparing"): Promise { +}: DownloadCanvasImageParams, preparationPhase: "preparing" | "converting"): Promise { const raw = String(url || "").trim(); if (!raw) { throw new Error("图片地址无效"); diff --git a/lib/client/page-refresh.ts b/lib/client/page-refresh.ts index 36e5b1dae..48ed8c704 100644 --- a/lib/client/page-refresh.ts +++ b/lib/client/page-refresh.ts @@ -1,10 +1,44 @@ export const BEFORE_PAGE_REFRESH = "app:before-page-refresh"; +export const PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE = "data-page-download-active"; export type PageRefreshRequest = { save: (action: () => Promise) => void; block: (message: string) => void; }; +const downloadListeners = new Set<() => void>(); +let activePageDownloads = 0; + +export function isPageDownloadActive() { + return activePageDownloads > 0; +} + +export function subscribeToPageDownloadActivity(listener: () => void) { + downloadListeners.add(listener); + return () => { downloadListeners.delete(listener); }; +} + +function publishDownloadActivity() { + if (typeof document !== "undefined") { + document.documentElement?.toggleAttribute(PAGE_DOWNLOAD_ACTIVE_ATTRIBUTE, isPageDownloadActive()); + } + for (const listener of downloadListeners) listener(); +} + +export async function withActivePageDownload(download: () => Promise): Promise { + if (++activePageDownloads === 1) publishDownloadActivity(); + try { + return await download(); + } finally { + if (--activePageDownloads === 0) publishDownloadActivity(); + } +} + +function assertPageDownloadFinished() { + if (isPageDownloadActive()) throw new Error("文件正在下载,请等下载完成后再刷新。"); +} + export async function preparePageRefresh() { + assertPageDownloadFinished(); const actions: Array<() => Promise> = []; let blocked = ""; window.dispatchEvent(new CustomEvent(BEFORE_PAGE_REFRESH, { @@ -12,4 +46,5 @@ export async function preparePageRefresh() { })); if (blocked) throw new Error(blocked); for (const action of actions) await action(); + assertPageDownloadFinished(); } diff --git a/lib/jobs/image-archive-job.ts b/lib/jobs/image-archive-job.ts index 2d3329202..c826680da 100644 --- a/lib/jobs/image-archive-job.ts +++ b/lib/jobs/image-archive-job.ts @@ -26,14 +26,14 @@ function getBatchSize() { return Math.max(1, Math.min(50, Math.round(parsed))); } -export async function runImageArchiveJob() { +export async function runImageArchiveJob({ preferredTaskId }: { preferredTaskId?: string } = {}) { return trackRuntimeWork(async () => { const batchSize = getBatchSize(); console.log(`[ImageArchiveJob] Start image archive job (batchSize=${batchSize})`); await recoverStaleImageArchiveTasks(); scheduleImageDownloadPreparation(); - const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize }); + const storeTasks = await getImageArchiveTasksToStore({ limit: batchSize, preferredTaskId }); let storeSucceeded = 0; let storeFailed = 0; diff --git a/lib/storage/image-archive-db.ts b/lib/storage/image-archive-db.ts index f0517ba90..3ea06faef 100644 --- a/lib/storage/image-archive-db.ts +++ b/lib/storage/image-archive-db.ts @@ -98,8 +98,11 @@ export async function enqueueImageArchiveTasks(params: { return tasks; } -export async function getImageArchiveTasksToStore(params: { limit: number }) { +export async function getImageArchiveTasksToStore(params: { limit: number; preferredTaskId?: string }) { const now = new Date(); + const priority = params.preferredTaskId + ? [asc(sql`case when ${imageArchiveTask.id} = ${params.preferredTaskId} then 0 else 1 end`)] + : []; return await db .select() .from(imageArchiveTask) @@ -110,7 +113,7 @@ export async function getImageArchiveTasksToStore(params: { limit: number }) { lt(imageArchiveTask.attemptCount, MAX_IMAGE_ARCHIVE_ATTEMPTS), ), ) - .orderBy(asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id)) + .orderBy(...priority, asc(imageArchiveTask.createdAt), asc(imageArchiveTask.id)) .limit(Math.max(1, Math.min(50, params.limit))); } diff --git a/lib/storage/image-archive-worker.ts b/lib/storage/image-archive-worker.ts index 3f3fe2ace..51230b62a 100644 --- a/lib/storage/image-archive-worker.ts +++ b/lib/storage/image-archive-worker.ts @@ -6,6 +6,7 @@ type Worker = { running: boolean; scheduled: boolean; requested: boolean; + preferredTaskId?: string; retryTimer?: ReturnType; }; function getWorker(): Worker { @@ -13,8 +14,9 @@ function getWorker(): Worker { return (root[workerKey] ??= { running: false, scheduled: false, requested: false }); } -export function scheduleImageArchiveWorker(deliveryConfirmed = false) { +export function scheduleImageArchiveWorker(deliveryConfirmed = false, preferredTaskId?: string) { const worker = getWorker(); + worker.preferredTaskId ??= preferredTaskId; worker.requested = true; if (worker.running || (worker.scheduled && !deliveryConfirmed)) return; worker.scheduled = true; @@ -31,7 +33,9 @@ export function scheduleImageArchiveWorker(deliveryConfirmed = false) { const { runImageArchiveJob } = await import("@/lib/jobs/image-archive-job"); do { worker.requested = false; - const result = await runImageArchiveJob(); + const nextTaskId = worker.preferredTaskId; + worker.preferredTaskId = undefined; + const result = await runImageArchiveJob({ preferredTaskId: nextTaskId }); retryNeeded = result.stats.pendingStore > 0 || result.stats.pendingReplace > 0 || result.pendingDownloads === true; // Stop when only backoff tasks remain; do not spin while storage is down. if (!worker.requested && result.store.total === 0 && result.replace.total === 0 && result.downloadTasks === 0) break; diff --git a/lib/storage/original-image-download.ts b/lib/storage/original-image-download.ts index a75dfd194..e879bb60e 100644 --- a/lib/storage/original-image-download.ts +++ b/lib/storage/original-image-download.ts @@ -15,6 +15,10 @@ export async function resolveOriginalCdnDownload(source: string, name: string, s 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)])]); + if (task?.status === "pending" && !task.targetUrl) { + const { scheduleImageArchiveWorker } = await import("./image-archive-worker"); + scheduleImageArchiveWorker(false, task.id); + } target = new URL(task?.targetUrl || source); } if (origin && target.origin === new URL(origin).origin && !target.searchParams.has("token")) { diff --git a/tests/routes/image-archive-flow.test.ts b/tests/routes/image-archive-flow.test.ts index bf2012807..29f1a5ffc 100644 --- a/tests/routes/image-archive-flow.test.ts +++ b/tests/routes/image-archive-flow.test.ts @@ -242,7 +242,7 @@ test("inline outputs have short, stable preview identities and durable queue pay test("background storage starts after delivery and coalesces repeated wakeups", async () => { const callbacks: Array<() => Promise> = []; - let runs = 0; + const preferences: Array = []; const module = serverLoader({ "next/server": { after: (fn: () => Promise) => { @@ -250,10 +250,10 @@ test("background storage starts after delivery and coalesces repeated wakeups", }, }, "@/lib/jobs/image-archive-job": { - runImageArchiveJob: async () => { - runs += 1; + runImageArchiveJob: async ({ preferredTaskId }: { preferredTaskId?: string }) => { + preferences.push(preferredTaskId); return { - store: { total: runs === 1 ? 1 : 0 }, + store: { total: preferences.length === 1 ? 1 : 0 }, replace: { total: 0 }, stats: { pendingStore: 0, pendingReplace: 0 }, downloadTasks: 0, @@ -262,11 +262,11 @@ test("background storage starts after delivery and coalesces repeated wakeups", }, })("lib/storage/image-archive-worker.ts"); module.scheduleImageArchiveWorker(); - module.scheduleImageArchiveWorker(); + module.scheduleImageArchiveWorker(false, "requested-image"); expect(callbacks).toHaveLength(1); - expect(runs).toBe(0); + expect(preferences).toEqual([]); await callbacks[0](); - expect(runs).toBe(2); + expect(preferences).toEqual(["requested-image", undefined]); }); test("bucket failure retains the provider URL and retry task", async () => { diff --git a/tests/routes/original-image-download.test.ts b/tests/routes/original-image-download.test.ts index 14be81c52..25d64a09b 100644 --- a/tests/routes/original-image-download.test.ts +++ b/tests/routes/original-image-download.test.ts @@ -54,6 +54,18 @@ test("temporary third-party URLs are never checked with HEAD when no archive is expect(await module.resolveOriginalCdnDownload("https://upstream.example/image.png", "image", new AbortController().signal)).toBeNull(); }); +test("downloading a pending original prioritizes its existing archive task and keeps the proxy available", async () => { + const preferred: string[] = []; + const module = serverLoader({ + "@/lib/db/queries": { getDefaultStorageConfig: async () => config }, + "./image-archive-db": { findImageArchiveDownloadTask: async () => ({ id: "pending-image", status: "pending", targetUrl: null }) }, + "./image-archive-worker": { scheduleImageArchiveWorker: (_delivery: boolean, id: string) => preferred.push(id) }, + fetch() { throw new Error("The original must remain available through the proxy while it is queued"); }, + })("lib/storage/original-image-download.ts"); + expect(await module.resolveOriginalCdnDownload("https://upstream.example/queued.png", "image", new AbortController().signal)).toBeNull(); + expect(preferred).toEqual(["pending-image"]); +}); + test("a CDN metadata timeout falls back to the original Kodo object without reading its body", async () => { let closed = false; const module = serverLoader({