关联记录模型名称并处理预览下载超时
生成记录同时包含模型内部 ID 和上游模型 ID,名称显示和筛选统一关联模型目录。图片代理处理源响应体超时与传输中断,返回明确的 504/502;浏览器取消请求同步停止上游下载。小尺寸预览允许 45 秒读取窗口,前端保留 4 路并发和可见区域加载,失败提示展示接口原因。原图下载保持字节不变。 验证:模型 ID 筛选的生产只读查询返回正确记录,浏览器夹具验证内部 ID 名称和峰值并发 4;15 项相关测试覆盖取消、响应体超时、原图字节与 ICC 保留。TypeScript 与改动文件 lint 通过。
This commit is contained in:
parent
af8189908d
commit
5371ae2617
@ -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) });
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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}%`}))`,
|
||||
|
||||
@ -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", {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user