Files
aiartstudio/server/utils/lsky.ts
T

199 lines
5.8 KiB
TypeScript

// 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<ILskyUploadedImage> => {
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<ILskyUploadResponse>("/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";
};