// 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; response: ILskyUploadResponse; } /** * 从远程图片 URL 下载图片并上传到 Lsky。 * * 注意: * - Lsky 地址、Token、storage_id 都来自环境变量,代码不写死密钥。 * - 文件名包含 userId、username、时间戳、recordId,方便图床侧追踪来源。 * - 返回完整 Lsky 响应供数据库保存,但调用方不要把 token 写入日志。 */ export const uploadImageFromUrl = async ( input: ILskyUploadInput ): Promise => { const config = getLskyConfig(); const downloadedImage = await downloadImage(input.imageUrl); 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 $fetch("/upload", { baseURL: config.baseUrl, method: "POST", body: formData, headers: { Accept: "application/json", Authorization: `Bearer ${config.token}` } }); 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, 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 }; }; /** 下载上游图片二进制,保留 content-type 供上传和入库使用 */ const downloadImage = async (imageUrl: string) => { const response = await fetch(imageUrl); if (!response.ok) { throw new Error(`图片下载失败:${response.status}`); } // TODO: 当前会把整张图片一次性读入当前 Node 进程内存,再转 Blob 上传到图床; // 如果图片尺寸或并发继续增大,建议改成流式转发或落临时文件,降低内存峰值和主线程抖动。 return { bytes: await response.arrayBuffer(), mimeType: response.headers.get("content-type") || "image/png" }; }; /** 构造图床文件名: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"; };