From 5371ae26176ca1b9378b40ef8421c33c4aeaa3f7 Mon Sep 17 00:00:00 2001 From: WuYongJie <549727955@qq.com> Date: Tue, 3 Nov 2026 18:04:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=85=B3=E8=81=94=E8=AE=B0=E5=BD=95=E6=A8=A1?= =?UTF-8?q?=E5=9E=8B=E5=90=8D=E7=A7=B0=E5=B9=B6=E5=A4=84=E7=90=86=E9=A2=84?= =?UTF-8?q?=E8=A7=88=E4=B8=8B=E8=BD=BD=E8=B6=85=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。 验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。 --- app/api/admin/ai-models/route.ts | 2 +- app/api/image-proxy/route.ts | 25 ++++++++++++++++--- components/admin/media-records-panel.tsx | 8 +++--- components/admin/media-thumbnail.tsx | 7 ++++-- lib/admin/media-records.ts | 4 +-- tests/routes/image-delivery-qiniu.test.ts | 30 +++++++++++++++++++++++ 6 files changed, 64 insertions(+), 12 deletions(-) diff --git a/app/api/admin/ai-models/route.ts b/app/api/admin/ai-models/route.ts index 6bf9dd881..2e8953032 100644 --- a/app/api/admin/ai-models/route.ts +++ b/app/api/admin/ai-models/route.ts @@ -156,7 +156,7 @@ export async function GET(request: Request) { const providerId = searchParams.get("providerId"); if (searchParams.get("view") === "options") { - const models = await db.select({ modelId: aiModel.modelId, displayName: aiModel.displayName, + const models = await db.select({ id: aiModel.id, modelId: aiModel.modelId, displayName: aiModel.displayName, providerName: aiProvider.name }).from(aiModel).leftJoin(aiProvider, eq(aiModel.providerId, aiProvider.id)); return Response.json({ models: sortModelsForDisplay(models) }); } diff --git a/app/api/image-proxy/route.ts b/app/api/image-proxy/route.ts index b09508b9d..b4204b700 100644 --- a/app/api/image-proxy/route.ts +++ b/app/api/image-proxy/route.ts @@ -10,6 +10,7 @@ const MIN_WIDTH = 64; const MIN_QUALITY = 40; const MAX_QUALITY = 100; const FETCH_TIMEOUT_MS = 15000; +const SMALL_PREVIEW_FETCH_TIMEOUT_MS = 45000; const DEFAULT_MEMORY_CACHE_MAX_BYTES = 64 * 1024 * 1024; const DEFAULT_MEMORY_CACHE_MAX_ITEMS = 256; @@ -343,17 +344,28 @@ export async function GET(request: NextRequest) { }); } + const fetchSignal = AbortSignal.any([ + request.signal, + 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 }); + const timedOut = (error instanceof Error && error.name === "TimeoutError") || fetchSignal.reason?.name === "TimeoutError"; + return NextResponse.json( + { error: timedOut ? "读取源图片超时,请重试" : "源图片传输中断,请重试" }, + { status: timedOut ? 504 : 502 }, + ); + }; let upstreamResponse: Response; try { upstreamResponse = await fetch(target.toString(), { - signal: AbortSignal.timeout(FETCH_TIMEOUT_MS), + signal: fetchSignal, headers: { Accept: original ? "*/*" : "image/avif,image/webp,image/apng,image/*,*/*;q=0.8", }, }); } catch (error) { - console.error("[ImageProxy] Upstream fetch failed:", error); - return NextResponse.json({ error: "Failed to fetch upstream image" }, { status: 502 }); + return upstreamFailure(error); } if (!upstreamResponse.ok) { @@ -368,7 +380,12 @@ export async function GET(request: NextRequest) { return NextResponse.json({ error: "Upstream is not an image" }, { status: 400 }); } - const originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); + let originalBuffer: Buffer; + try { + originalBuffer = Buffer.from(await upstreamResponse.arrayBuffer()); + } catch (error) { + return upstreamFailure(error); + } if (original) { return buildImageResponse(originalBuffer, { contentType, diff --git a/components/admin/media-records-panel.tsx b/components/admin/media-records-panel.tsx index 71aa6c8af..b72e15945 100644 --- a/components/admin/media-records-panel.tsx +++ b/components/admin/media-records-panel.tsx @@ -89,11 +89,13 @@ export function MediaRecordsPanel({ categories }: { categories: string[] }) { return () => clearTimeout(timer); }, [search]); const { data: options } = useSWR<{ - models: Array<{ modelId: string; displayName: string; providerName: string }>; + models: Array<{ id: string; modelId: string; displayName: string; providerName: string }>; }>("/api/admin/ai-models?view=options", adminFetcher, { revalidateOnFocus: false }); const modelNames = new Map(); for (const model of options?.models || []) if (!modelNames.has(model.modelId)) modelNames.set(model.modelId, model.displayName); + const recordModelNames = new Map(modelNames); + for (const model of options?.models || []) recordModelNames.set(model.id, model.displayName); const openDetails = (item: MediaRecord) => { setSelected(item); setOriginalFailed(false); @@ -380,7 +382,7 @@ export function MediaRecordsPanel({ categories }: { categories: string[] }) {
- {modelNames.get(item.modelId || "") || item.modelId || "未记录模型"} + {recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
{item.recordType === "video" ? "视频" : "图片"} ·{" "} @@ -523,7 +525,7 @@ export function MediaRecordsPanel({ categories }: { categories: string[] }) { ["部门", selected.userDepartment], ["生成时间", formatRecordTime(selected.createdAt)], ["尺寸", selected.imageSize], - ["模型", selected.modelId], + ["模型", recordModelNames.get(selected.modelId || "") || selected.modelId], ["精选时间", selected.featuredAt ? formatRecordTime(selected.featuredAt) : "—"], ].map(([k, v]) => (
diff --git a/components/admin/media-thumbnail.tsx b/components/admin/media-thumbnail.tsx index 8ce8a8c7d..6d4193976 100644 --- a/components/admin/media-thumbnail.tsx +++ b/components/admin/media-thumbnail.tsx @@ -37,7 +37,7 @@ export function MediaThumbnail({ observer.disconnect(); dispose = queuePreview((release) => { const controller = new AbortController(); - const timeout = setTimeout(() => controller.abort(), 18000); + const timeout = setTimeout(() => controller.abort(), 48000); setState("loading"); // Fetch is abortable, so leaving a page also stops in-flight previews. const parsed = new URL(url, window.location.origin); @@ -52,7 +52,10 @@ export function MediaThumbnail({ }); fetch(target, { signal: controller.signal }) .then(async (response) => { - if (!response.ok) throw new Error(`预览请求失败(HTTP ${response.status})`); + if (!response.ok) { + const detail = await response.json().catch(() => null); + throw new Error(`${detail?.error || "预览请求失败"}(HTTP ${response.status})`); + } const blob = await response.blob(); if (!blob.type.startsWith("image/")) throw new Error("Not an image"); if (alive) { diff --git a/lib/admin/media-records.ts b/lib/admin/media-records.ts index d43f2fc2b..9f5ff043d 100644 --- a/lib/admin/media-records.ts +++ b/lib/admin/media-records.ts @@ -30,14 +30,14 @@ export function mediaRecordsQuery(params: ReturnType) { if (params.startDate) conditions.push(sql`m."createdAt" >= ${params.startDate}::date`); if (params.endDate) conditions.push(sql`m."createdAt" < ${params.endDate}::date + interval '1 day'`); - if (params.model) conditions.push(sql`m."modelId" = ${params.model}`); + if (params.model) conditions.push(sql`(m."modelId" = ${params.model} or m."modelId" in (select id::text from "AiModel" where "modelId"=${params.model}))`); const where = sql.join(conditions, sql` and `); const videoFilters: SQL[] = [sql`true`]; if (params.startDate) videoFilters.push(sql`v."createdAt">=${params.startDate}::date - interval '8 hours'`); if (params.endDate) videoFilters.push(sql`v."createdAt"<${params.endDate}::date+interval '16 hours'`); - if (params.model) videoFilters.push(sql`v."video_metadata"->>'model'=${params.model}`); + if (params.model) videoFilters.push(sql`(v."video_metadata"->>'model'=${params.model} or v."video_metadata"->>'model' in (select id::text from "AiModel" where "modelId"=${params.model}))`); if (params.search) videoFilters.push( sql`exists (select 1 from "User" u where u.id=c."userId" and (u.name ilike ${`%${params.search}%`} or u.email ilike ${`%${params.search}%`}))`, diff --git a/tests/routes/image-delivery-qiniu.test.ts b/tests/routes/image-delivery-qiniu.test.ts index c9b92c53e..87fb15bb7 100644 --- a/tests/routes/image-delivery-qiniu.test.ts +++ b/tests/routes/image-delivery-qiniu.test.ts @@ -5,6 +5,36 @@ import { GET } from "@/app/api/image-proxy/route"; import { buildImageProxyUrl, getImageDownloadExtension } from "@/lib/image-delivery"; test.describe("Qiniu image delivery", () => { + test("reports a timeout while reading image bytes as a retryable response", async () => { + const previousFetch = globalThis.fetch; + globalThis.fetch = async () => new Response(new ReadableStream({ + start(controller) { controller.error(new DOMException("source stalled", "TimeoutError")); }, + }), { headers: { "Content-Type": "image/png" } }); + try { + const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fslow.png&proxy=1&w=192")); + expect(response.status).toBe(504); + expect(await response.json()).toMatchObject({ error: "读取源图片超时,请重试" }); + } finally { globalThis.fetch = previousFetch; } + }); + + test("stops the upstream image body when the preview request is cancelled", async () => { + const previousFetch = globalThis.fetch; + const controller = new AbortController(); + let upstreamSignal: AbortSignal | undefined; + globalThis.fetch = async (_url, init) => { + upstreamSignal = init?.signal as AbortSignal; + return new Response(new ReadableStream({ start(stream) { + upstreamSignal!.addEventListener("abort", () => stream.error(upstreamSignal!.reason)); + queueMicrotask(() => controller.abort()); + } }), { headers: { "Content-Type": "image/png" } }); + }; + try { + const response = await GET(new NextRequest("http://localhost/api/image-proxy?url=https%3A%2F%2Fpublic.example%2Fcancel.png&proxy=1&w=192", { signal: controller.signal })); + expect(upstreamSignal?.aborted).toBe(true); + expect(response.status).toBe(499); + } finally { globalThis.fetch = previousFetch; } + }); + test("uses Qiniu image processing for direct canvas previews", () => { expect( buildImageProxyUrl("https://yybb.top/uploads/result.png", {