From 42e1175830280b14f521a9090b88d871f5edcf14 Mon Sep 17 00:00:00 2001 From: 134 Date: Sat, 22 Aug 2026 17:25:34 +0800 Subject: [PATCH] fix: persist generated images before gallery save --- app/api/gallery/route.ts | 26 ++++++++-- lib/storage/image-archive.ts | 99 ++++++++++++++++++++++++++++++------ 2 files changed, 106 insertions(+), 19 deletions(-) diff --git a/app/api/gallery/route.ts b/app/api/gallery/route.ts index 88ccadb02..4b6711d8e 100644 --- a/app/api/gallery/route.ts +++ b/app/api/gallery/route.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { db } from "@/lib/db"; -import { generatedImage } from "@/lib/db/schema"; -import { eq, desc, sql } from "drizzle-orm"; +import { generatedImage, imageArchiveTask } from "@/lib/db/schema"; +import { and, desc, eq, isNull, sql } from "drizzle-orm"; import { auth } from "@/app/(auth)/auth"; export async function GET(request: NextRequest) { @@ -19,12 +19,23 @@ export async function GET(request: NextRequest) { const page = Number.parseInt(searchParams.get("page") || "1"); const pageSize = Number.parseInt(searchParams.get("pageSize") || "24"); const offset = (page - 1) * pageSize; + const visibleImageFilter = and( + eq(generatedImage.userId, userId), + isNull(imageArchiveTask.id) + ); // 查询总数 const countResult = await db .select({ count: sql`count(*)::int` }) .from(generatedImage) - .where(eq(generatedImage.userId, userId)); + .leftJoin( + imageArchiveTask, + and( + eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl), + eq(imageArchiveTask.status, "abandoned") + ) + ) + .where(visibleImageFilter); const total = countResult[0]?.count || 0; @@ -42,7 +53,14 @@ export async function GET(request: NextRequest) { modelId: generatedImage.modelId, // 添加 modelId 字段 }) .from(generatedImage) - .where(eq(generatedImage.userId, userId)) + .leftJoin( + imageArchiveTask, + and( + eq(imageArchiveTask.sourceUrl, generatedImage.originalUrl), + eq(imageArchiveTask.status, "abandoned") + ) + ) + .where(visibleImageFilter) .orderBy(desc(generatedImage.createdAt)) .limit(pageSize) .offset(offset); diff --git a/lib/storage/image-archive.ts b/lib/storage/image-archive.ts index 0c5ef4c84..b0ea3416f 100644 --- a/lib/storage/image-archive.ts +++ b/lib/storage/image-archive.ts @@ -1,5 +1,7 @@ import "server-only"; +import { randomUUID } from "node:crypto"; +import sharp from "sharp"; import { createLogger } from "@/lib/observability/logger"; import { enqueueImageArchiveTasks, @@ -31,6 +33,10 @@ export function isDeferredImageArchiveEnabled() { return process.env.AI_IMAGE_DEFERRED_ARCHIVE !== "false"; } +export function isImmediateImageArchiveEnabled() { + return process.env.AI_IMAGE_IMMEDIATE_ARCHIVE !== "false"; +} + export async function registerGeneratedImageTemporaryUrls(params: { urls: string[]; metadata?: DeferredImageArchiveMetadata | null; @@ -80,18 +86,19 @@ export async function formatDeferredGeneratedImageMarkdown(params: { throw new Error("AI 未返回可用图片地址"); } - await registerGeneratedImageTemporaryUrls({ - urls, - metadata: { - providerType: params.providerType, - modelId: params.modelId, - imageSize: params.imageSize, - ...(params.metadata || {}), - }, - }); + const metadata = { + providerType: params.providerType, + modelId: params.modelId, + imageSize: params.imageSize, + ...(params.metadata || {}), + }; + const persisted = await persistGeneratedImageUrls({ urls, metadata }); - return urls - .map((url) => `![thumb|||${url}|||${params.imageSize}](${url})`) + return persisted + .map( + ({ originalUrl, thumbnailUrl }) => + `![thumb|||${originalUrl}|||${params.imageSize}](${thumbnailUrl})` + ) .join("\n\n"); } @@ -102,17 +109,61 @@ export async function createDeferredUploadedImage( const url = String(sourceUrl || "").trim(); if (!isDeferredImageArchiveEnabled() || !isHttpUrl(url)) return null; - await registerGeneratedImageTemporaryUrls({ + const [persisted] = await persistGeneratedImageUrls({ urls: [url], metadata: metadata || null, }); return { - thumbnail: url, - original: url, + thumbnail: persisted?.thumbnailUrl || url, + original: persisted?.originalUrl || url, }; } +async function persistGeneratedImageUrls(params: { + urls: string[]; + metadata?: DeferredImageArchiveMetadata | null; +}) { + if (!isImmediateImageArchiveEnabled()) { + await registerGeneratedImageTemporaryUrls(params); + return params.urls.map((url) => ({ + originalUrl: url, + thumbnailUrl: url, + })); + } + + return await Promise.all( + params.urls.map(async (url) => { + try { + const archived = await archiveTemporaryImage(url); + logger.info("generated image archived before response", { + providerType: params.metadata?.providerType, + modelId: params.metadata?.modelId, + bytes: archived.bytes, + }); + return { + originalUrl: archived.url, + thumbnailUrl: archived.thumbnailUrl || archived.url, + }; + } catch (error) { + logger.error("immediate generated image archive failed; queued for retry", { + providerType: params.metadata?.providerType, + modelId: params.metadata?.modelId, + error: error instanceof Error ? error.message : String(error), + }); + await registerGeneratedImageTemporaryUrls({ + urls: [url], + metadata: params.metadata || null, + }); + return { + originalUrl: url, + thumbnailUrl: url, + }; + } + }) + ); +} + function getImageExtension(contentType: string | null, sourceUrl: string) { const normalized = String(contentType || "").split(";")[0].trim().toLowerCase(); const byType: Record = { @@ -187,10 +238,28 @@ export async function downloadTemporaryImage(params: { export async function archiveTemporaryImage(sourceUrl: string) { const downloaded = await downloadTemporaryImage({ sourceUrl }); - const filename = `ai-generated-archive-${Date.now()}.${downloaded.extension}`; + const objectSuffix = `${Date.now()}-${randomUUID()}`; + const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`; const uploaded = await uploadFile(filename, toArrayBuffer(downloaded.buffer)); + const thumbnailBuffer = await sharp(downloaded.buffer, { failOn: "none" }) + .resize(640, 640, { fit: "inside", withoutEnlargement: true }) + .webp({ quality: 82 }) + .toBuffer() + .catch(() => null); + const thumbnailUploaded = thumbnailBuffer + ? await uploadFile( + `ai-generated-archive-thumb-${objectSuffix}.webp`, + toArrayBuffer(thumbnailBuffer) + ).catch((error) => { + logger.warn("generated image thumbnail upload failed", { + error: error instanceof Error ? error.message : String(error), + }); + return null; + }) + : null; return { url: uploaded.url, + thumbnailUrl: thumbnailUploaded?.url || uploaded.url, pathname: uploaded.pathname, contentType: uploaded.contentType, bytes: downloaded.buffer.length,