ai-chatbot/lib/storage/image-archive.ts
WuYongJie 1daf08a964 停用旧图片存储并更新主题 Logo
s3.1080p.hk 已永久停用且文件不可恢复。首页查询排除旧源并统一分页统计,历史图册、聊天和画布保留记录并显示失效说明;下载和代理返回 410,停止旧源上传与归档。使用本地黑白 Logo 适配主题。

提供保存原配置后停用旧存储、取消失效精选、替换 Logo 的整理脚本。七牛 10 月 14 日上传核查记录见 docs/retired-storage.md。

验证:7 项旧存储检查、41 项下载和归档回归测试、TypeScript 检查通过。
2026-11-03 13:36:44 +08:00

207 lines
6.1 KiB
TypeScript

import { fetchImageBytes, cacheImageBytes } from "@/lib/storage/image-byte-cache";
import "server-only";
import { isRetiredStorageUrl, RETIRED_STORAGE_MESSAGE } from "./retired-storage";
import { randomUUID } from "node:crypto";
import { isGeneratedImageSource } from "./image-archive-urls";
import { scheduleImageArchiveWorker } from "./image-archive-worker";
import { createLogger } from "@/lib/observability/logger";
import {
enqueueImageArchiveTasks,
getImageArchiveDisplayHoldMs,
imageArchiveSourceKey,
} from "@/lib/storage/image-archive-db";
import { uploadFile } from "@/lib/storage/service";
const logger = createLogger("image-archive");
const DEFAULT_DOWNLOAD_TIMEOUT_MS = 60_000;
type DeferredImageArchiveMetadata = Record<string, unknown> & {
providerType?: string;
modelId?: string;
imageSize?: string;
};
function getDownloadTimeoutMs() {
const parsed = Number(process.env.IMAGE_ARCHIVE_DOWNLOAD_TIMEOUT_MS);
if (!Number.isFinite(parsed)) return DEFAULT_DOWNLOAD_TIMEOUT_MS;
return Math.max(5_000, Math.min(5 * 60_000, Math.round(parsed)));
}
export function isDeferredImageArchiveEnabled() {
return true;
}
export function isImmediateImageArchiveEnabled() {
return false;
}
export async function registerGeneratedImageTemporaryUrls(params: {
urls: string[];
metadata?: DeferredImageArchiveMetadata | null;
}) {
if (!isDeferredImageArchiveEnabled()) return [];
const urls = Array.from(
new Set(
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
);
if (urls.length === 0) return [];
try {
const tasks = await enqueueImageArchiveTasks({
urls,
metadata: {
...(params.metadata || {}),
displayHoldMs: getImageArchiveDisplayHoldMs(),
},
});
scheduleImageArchiveWorker();
return tasks;
} catch (error) {
logger.warn("enqueue temporary image archive failed", {
imageCount: urls.length,
error: error instanceof Error ? error.message : String(error),
});
return [];
}
}
export async function formatDeferredGeneratedImageMarkdown(params: {
urls: string[];
imageSize: string;
providerType?: string;
modelId?: string;
metadata?: DeferredImageArchiveMetadata | null;
}) {
const urls = Array.from(
new Set(
(params.urls || []).map((url) => String(url || "").trim()).filter(isGeneratedImageSource),
),
);
if (urls.length === 0) {
throw new Error("AI 未返回可用图片地址");
}
const metadata = {
providerType: params.providerType,
modelId: params.modelId,
imageSize: params.imageSize,
...(params.metadata || {}),
};
const persisted = await persistGeneratedImageUrls({ urls, metadata });
return persisted
.map(
({ originalUrl, thumbnailUrl }) =>
`![thumb|||${originalUrl}|||${params.imageSize}](${thumbnailUrl})`,
)
.join("\n\n");
}
export async function createDeferredUploadedImage(
sourceUrl: string,
metadata?: DeferredImageArchiveMetadata | null,
) {
const url = String(sourceUrl || "").trim();
if (!isDeferredImageArchiveEnabled() || !isGeneratedImageSource(url)) return null;
const [persisted] = await persistGeneratedImageUrls({
urls: [url],
metadata: metadata || null,
});
return {
thumbnail: persisted?.thumbnailUrl || url,
original: persisted?.originalUrl || url,
};
}
async function persistGeneratedImageUrls(params: {
urls: string[];
metadata?: DeferredImageArchiveMetadata | null;
}) {
// Only the durable queue insert is awaited. Downloading, thumbnailing and bucket
// uploads run after delivery and never extend the provider response time.
await registerGeneratedImageTemporaryUrls(params);
return params.urls.map((url) => {
const preview = imageArchiveSourceKey(url);
return { originalUrl: preview, thumbnailUrl: preview };
});
}
function getImageExtension(contentType: string | null, sourceUrl: string) {
const normalized = String(contentType || "")
.split(";")[0]
.trim()
.toLowerCase();
const byType: Record<string, string> = {
"image/jpeg": "jpg",
"image/jpg": "jpg",
"image/png": "png",
"image/webp": "webp",
"image/gif": "gif",
"image/svg+xml": "svg",
};
if (byType[normalized]) return byType[normalized];
try {
const pathname = new URL(sourceUrl).pathname.toLowerCase();
const ext =
pathname
.split(".")
.pop()
?.replace(/[^a-z0-9]+/g, "") || "";
if (["jpg", "jpeg", "png", "webp", "gif", "svg"].includes(ext)) {
return ext === "jpeg" ? "jpg" : ext;
}
} catch {
// keep fallback
}
return "png";
}
function toArrayBuffer(buffer: Buffer): ArrayBuffer {
return buffer.buffer.slice(
buffer.byteOffset,
buffer.byteOffset + buffer.byteLength,
) as ArrayBuffer;
}
export async function downloadTemporaryImage(params: { sourceUrl: string; retries?: number }) {
if (isRetiredStorageUrl(params.sourceUrl)) throw new Error(`HTTP 410: ${RETIRED_STORAGE_MESSAGE}`);
const retries = Math.max(0, Math.min(5, params.retries ?? 2));
for (let attempt = 0; attempt <= retries; attempt += 1) {
try {
const data = await fetchImageBytes(params.sourceUrl, {
timeoutMs: getDownloadTimeoutMs(),
headers: { Accept: "image/*,*/*;q=0.8" },
});
return { ...data, extension: getImageExtension(data.contentType, params.sourceUrl) };
} catch (error) {
if (attempt >= retries) throw error;
await new Promise((resolve) => setTimeout(resolve, 1000 * (attempt + 1)));
}
}
throw new Error("Image download failed");
}
export async function archiveTemporaryImage(sourceUrl: string) {
const downloaded = await downloadTemporaryImage({ sourceUrl });
const objectSuffix = `${Date.now()}-${randomUUID()}`;
const filename = `ai-generated-archive-${objectSuffix}.${downloaded.extension}`;
const uploaded = await uploadFile(filename, toArrayBuffer(downloaded.buffer));
await cacheImageBytes(`source::${uploaded.url}`, downloaded);
return {
url: uploaded.url,
thumbnailUrl: uploaded.url,
pathname: uploaded.pathname,
contentType: uploaded.contentType,
bytes: downloaded.buffer.length,
};
}