feat: 添加图床归档任务支持,优化归档逻辑和错误处理
This commit is contained in:
+121
-20
@@ -27,9 +27,15 @@ 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。
|
||||
*
|
||||
@@ -42,7 +48,8 @@ export const uploadImageFromUrl = async (
|
||||
input: ILskyUploadInput
|
||||
): Promise<ILskyUploadedImage> => {
|
||||
const config = getLskyConfig();
|
||||
const downloadedImage = await downloadImage(input.imageUrl);
|
||||
const protection = getArchiveProtectionConfig();
|
||||
const downloadedImage = await downloadImage(input.imageUrl, protection);
|
||||
const extension = getImageExtension(downloadedImage.mimeType, input.imageUrl);
|
||||
const filename = buildArchiveFilename({
|
||||
userId: input.userId,
|
||||
@@ -68,14 +75,11 @@ export const uploadImageFromUrl = async (
|
||||
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 response = await uploadToLsky({
|
||||
baseUrl: config.baseUrl,
|
||||
token: config.token,
|
||||
timeoutMs: protection.timeoutMs,
|
||||
formData
|
||||
});
|
||||
|
||||
const publicUrl = response.data?.public_url;
|
||||
@@ -90,6 +94,7 @@ export const uploadImageFromUrl = async (
|
||||
publicUrl,
|
||||
filename: response.data?.filename || filename,
|
||||
mimetype: response.data?.mimetype || downloadedImage.mimeType,
|
||||
byteLength: downloadedImage.byteLength,
|
||||
response
|
||||
};
|
||||
};
|
||||
@@ -119,21 +124,108 @@ const getLskyConfig = () => {
|
||||
};
|
||||
};
|
||||
|
||||
/** 下载上游图片二进制,保留 content-type 供上传和入库使用 */
|
||||
const downloadImage = async (imageUrl: string) => {
|
||||
const response = await fetch(imageUrl);
|
||||
if (!response.ok) {
|
||||
throw new Error(`图片下载失败:${response.status}`);
|
||||
}
|
||||
|
||||
// TODO: 当前会把整张图片一次性读入当前 Node 进程内存,再转 Blob 上传到图床;
|
||||
// 如果图片尺寸或并发继续增大,建议改成流式转发或落临时文件,降低内存峰值和主线程抖动。
|
||||
/** 读取归档保护参数,限制单次下载/上传的大小和耗时 */
|
||||
const getArchiveProtectionConfig = () => {
|
||||
return {
|
||||
bytes: await response.arrayBuffer(),
|
||||
mimeType: response.headers.get("content-type") || "image/png"
|
||||
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,
|
||||
@@ -196,3 +288,12 @@ const getImageExtension = (mimeType: string, imageUrl: string) => {
|
||||
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";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user