// server/utils/lsky.ts - Lsky 图床上传工具:下载上游图片、重命名、打标签并上传归档。 import { createError } from "h3"; interface ILskyUploadInput { imageUrl: string; userId: number; username?: string | null; recordId: bigint; createdAt: Date; } interface ILskyUploadResponse { status?: string | boolean; message?: string; data?: { public_url?: string; filename?: string; mimetype?: string; extension?: string; [key: string]: unknown; } | null; time?: number; [key: string]: unknown; } export interface ILskyUploadedImage { publicUrl: string; filename: string; mimetype: string; byteLength: number; response: ILskyUploadResponse; } /** 单张归档图片默认最大 20MB,避免多用户并发时把图片二进制堆满进程内存 */ const DEFAULT_ARCHIVE_MAX_BYTES = 20 * 1024 * 1024; /** 单次下载或上传默认 60 秒超时,避免慢连接长期占用归档 worker 槽位 */ const DEFAULT_ARCHIVE_TIMEOUT_MS = 60 * 1000; /** * 从远程图片 URL 下载图片并上传到 Lsky。 * * 注意: * - Lsky 地址、Token、storage_id 都来自环境变量,代码不写死密钥。 * - 文件名包含 userId、username、时间戳、recordId,方便图床侧追踪来源。 * - 返回完整 Lsky 响应供数据库保存,但调用方不要把 token 写入日志。 */ export const uploadImageFromUrl = async ( input: ILskyUploadInput ): Promise => { const config = getLskyConfig(); const protection = getArchiveProtectionConfig(); const downloadedImage = await downloadImage(input.imageUrl, protection); const extension = getImageExtension(downloadedImage.mimeType, input.imageUrl); const filename = buildArchiveFilename({ userId: input.userId, username: input.username, recordId: input.recordId, createdAt: input.createdAt, extension }); const formData = new FormData(); formData.append( "file", new Blob([downloadedImage.bytes], { type: downloadedImage.mimeType }), filename ); formData.append("storage_id", String(config.storageId)); formData.append("is_remove_exif", "true"); formData.append("intro", `AIArtStudio image generation ${input.recordId}`); for (const tag of buildArchiveTags(input.userId, input.recordId)) { formData.append("tags[]", tag); } const response = await uploadToLsky({ baseUrl: config.baseUrl, token: config.token, timeoutMs: protection.timeoutMs, formData }); const publicUrl = response.data?.public_url; if (!isSuccessStatus(response.status) || !publicUrl) { throw createError({ statusCode: 502, statusMessage: response.message || "图床上传失败" }); } return { publicUrl, filename: response.data?.filename || filename, mimetype: response.data?.mimetype || downloadedImage.mimeType, byteLength: downloadedImage.byteLength, response }; }; /** 读取并校验 Lsky 环境变量配置 */ const getLskyConfig = () => { const baseUrl = process.env.LSKY_BASE_URL?.replace(/\/+$/, ""); const token = process.env.LSKY_TOKEN; const storageId = Number.parseInt(process.env.LSKY_STORAGE_ID ?? "", 10); if (!baseUrl) { throw new Error("LSKY_BASE_URL 未配置"); } if (!token) { throw new Error("LSKY_TOKEN 未配置"); } if (!Number.isInteger(storageId) || storageId <= 0) { throw new Error("LSKY_STORAGE_ID 未配置或不是有效数字"); } return { baseUrl, token, storageId }; }; /** 读取归档保护参数,限制单次下载/上传的大小和耗时 */ const getArchiveProtectionConfig = () => { return { maxBytes: getPositiveIntegerEnv( "IMAGE_ARCHIVE_MAX_BYTES", DEFAULT_ARCHIVE_MAX_BYTES ), timeoutMs: getPositiveIntegerEnv( "IMAGE_ARCHIVE_TIMEOUT_MS", DEFAULT_ARCHIVE_TIMEOUT_MS ) }; }; /** 调用 Lsky 上传接口,超时后主动取消请求,避免 worker 长时间占位 */ const uploadToLsky = async ({ baseUrl, token, timeoutMs, formData }: { baseUrl: string; token: string; timeoutMs: number; formData: FormData; }) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); try { return await $fetch("/upload", { baseURL: baseUrl, method: "POST", body: formData, signal: controller.signal, headers: { Accept: "application/json", Authorization: `Bearer ${token}` } }); } catch (error) { if (isAbortError(error)) { throw new Error("图床上传超时"); } throw error; } finally { clearTimeout(timeout); } }; /** 下载上游图片二进制,保留 content-type 供上传和入库使用 */ const downloadImage = async ( imageUrl: string, protection: ReturnType ) => { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), protection.timeoutMs); try { const response = await fetch(imageUrl, { signal: controller.signal }); if (!response.ok) { throw new Error(`图片下载失败:${response.status}`); } const contentLength = getContentLength(response.headers); if (contentLength !== null && contentLength > protection.maxBytes) { throw new Error("图片文件过大,归档已停止"); } const bytes = await response.arrayBuffer(); if (bytes.byteLength > protection.maxBytes) { throw new Error("图片文件过大,归档已停止"); } return { bytes, byteLength: bytes.byteLength, mimeType: response.headers.get("content-type") || "image/png" }; } catch (error) { if (isAbortError(error)) { throw new Error("图片下载超时"); } throw error; } finally { clearTimeout(timeout); } }; /** content-length 存在且合法时用于提前拒绝超大图片 */ const getContentLength = (headers: Headers) => { const value = headers.get("content-length"); if (!value) return null; const parsed = Number.parseInt(value, 10); return Number.isInteger(parsed) && parsed >= 0 ? parsed : null; }; /** 构造图床文件名:userId_username_timestamp_recordId.ext */ const buildArchiveFilename = ({ userId, username, recordId, createdAt, extension }: { userId: number; username?: string | null; recordId: bigint; createdAt: Date; extension: string; }) => { const safeUsername = sanitizeFilenamePart(username || "user"); const timestamp = formatTimestamp(createdAt); return `${userId}_${safeUsername}_${timestamp}_${recordId.toString()}.${extension}`; }; /** 给图片打固定标签,便于后续在图床里按项目、用户、记录和模型筛选 */ const buildArchiveTags = (userId: number, recordId: bigint) => { return [ "AIArtStudio", `user:${userId}`, `record:${recordId.toString()}`, "model:gpt-image-2" ]; }; /** 清理文件名片段,避免中文/英文/数字/下划线/短横线以外的字符影响上传 */ const sanitizeFilenamePart = (value: string) => { const sanitized = value .trim() .replace(/[^\p{L}\p{N}_-]+/gu, "_") .replace(/_+/g, "_") .replace(/^_+|_+$/g, ""); return sanitized || "user"; }; /** 文件名使用紧凑 UTC 时间戳,避免冒号等字符影响跨平台兼容性 */ const formatTimestamp = (date: Date) => { return date.toISOString().replace(/\D/g, "").slice(0, 14); }; /** 优先按 MIME 推断扩展名,MIME 不认识时再从 URL 路径兜底 */ const getImageExtension = (mimeType: string, imageUrl: string) => { const fromMimeType = mimeType.split(";")[0]?.trim().toLowerCase(); if (fromMimeType === "image/jpeg") return "jpg"; if (fromMimeType === "image/png") return "png"; if (fromMimeType === "image/webp") return "webp"; if (fromMimeType === "image/gif") return "gif"; const pathname = new URL(imageUrl).pathname; const extension = pathname.split(".").pop()?.toLowerCase(); return extension && /^[a-z0-9]+$/.test(extension) ? extension : "png"; }; /** 兼容 Lsky 可能返回的布尔或字符串成功状态 */ const isSuccessStatus = (status: unknown) => { return status === true || status === "success" || status === "ok"; }; const getPositiveIntegerEnv = (name: string, fallback: number) => { const value = Number.parseInt(process.env[name] ?? "", 10); return Number.isInteger(value) && value > 0 ? value : fallback; }; const isAbortError = (error: unknown) => { return error instanceof Error && error.name === "AbortError"; };