300 lines
8.4 KiB
TypeScript
300 lines
8.4 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;
|
|
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<ILskyUploadedImage> => {
|
|
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<ILskyUploadResponse>("/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<typeof getArchiveProtectionConfig>
|
|
) => {
|
|
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";
|
|
};
|