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", {