关联记录模型名称并处理预览下载超时

生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。

验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。
This commit is contained in:
WuYongJie 2026-11-03 18:04:14 +08:00
parent af8189908d
commit 5371ae2617
6 changed files with 64 additions and 12 deletions

View File

@ -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) });
}

View File

@ -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,

View File

@ -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<string, string>();
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[] }) {
</td>
<td className="max-w-64 px-4 py-3">
<div className="truncate" title={item.modelId || ""}>
{modelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
{recordModelNames.get(item.modelId || "") || item.modelId || "未记录模型"}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{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]) => (
<div key={k}>

View File

@ -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) {

View File

@ -30,14 +30,14 @@ export function mediaRecordsQuery(params: ReturnType<typeof parseMediaQuery>) {
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}%`}))`,

View File

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