ai-chatbot/lib/storage/service.ts
Your Name 9ce3076438
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat: add storage failover backup bucket support
2026-02-23 11:36:47 +08:00

322 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import "server-only";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { put } from "@vercel/blob";
import {
getDefaultStorageConfig,
getStorageConfigById,
getSystemSetting,
} from "@/lib/db/queries";
type S3UploadConfig = {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
publicUrl?: string;
};
let lastKnownGoodDbS3Config: S3UploadConfig | null = null;
const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config";
// 获取文件的 Content-Type
function getContentType(filename: string): string {
const ext = filename.toLowerCase().split(".").pop();
const types: Record<string, string> = {
jpg: "image/jpeg",
jpeg: "image/jpeg",
png: "image/png",
gif: "image/gif",
webp: "image/webp",
svg: "image/svg+xml",
pdf: "application/pdf",
json: "application/json",
txt: "text/plain",
html: "text/html",
css: "text/css",
js: "application/javascript",
};
return types[ext ?? ""] ?? "application/octet-stream";
}
// S3/MinIO 上传函数(使用 AWS SDK)
async function uploadToS3(
filename: string,
buffer: ArrayBuffer,
config: S3UploadConfig
) {
const { endpoint, region, bucket, accessKeyId, secretAccessKey, publicUrl } = config;
// 生成唯一的文件名
const timestamp = Date.now();
const randomString = Math.random().toString(36).slice(2, 8);
const ext = filename.split(".").pop();
const key = `uploads/${timestamp}-${randomString}.${ext}`;
const contentType = getContentType(filename);
try {
// 创建 S3 客户端(支持 MinIO)
const s3Client = new S3Client({
endpoint: endpoint,
region: region || "auto",
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
},
// 关键:MinIO 需要使用 path-style URL
forcePathStyle: true,
});
console.log(`[S3 Upload] Uploading to: ${endpoint}/${bucket}/${key}`);
// 上传文件
const command = new PutObjectCommand({
Bucket: bucket,
Key: key,
Body: Buffer.from(buffer),
ContentType: contentType,
// 设置为公开可读(如果您的 MinIO 桶支持)
ACL: "public-read",
});
await s3Client.send(command);
console.log(`[S3 Upload] Upload successful: ${key}`);
// 返回文件的公开 URL
// MinIO 的公开 URL 格式通常是: http://endpoint:port/bucket/key
let fileUrl: string;
if (publicUrl) {
// 如果用户配置了自定义公开 URL,使用它
fileUrl = `${publicUrl}/${key}`;
} else {
// 否则使用 endpoint + bucket + key
const cleanEndpoint = endpoint.replace(/\/$/, ""); // 移除末尾的斜杠
fileUrl = `${cleanEndpoint}/${bucket}/${key}`;
}
console.log(`[S3 Upload] File URL: ${fileUrl}`);
return {
url: fileUrl,
pathname: key,
contentType,
};
} catch (error) {
console.error("[S3 Upload] Error:", error);
throw error;
}
}
function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
const value = raw as Record<string, unknown> | null | undefined;
if (
!value ||
value.type !== "s3" ||
!value.isEnabled ||
!value.endpoint ||
!value.bucket ||
!value.accessKeyId ||
!value.secretAccessKey
) {
return null;
}
return {
endpoint: String(value.endpoint),
region: String(value.region || "auto"),
bucket: String(value.bucket),
accessKeyId: String(value.accessKeyId),
secretAccessKey: String(value.secretAccessKey),
publicUrl: value.publicUrl ? String(value.publicUrl) : undefined,
};
}
function buildEnvS3Config(): S3UploadConfig | null {
if (
process.env.S3_ENDPOINT &&
process.env.S3_BUCKET &&
process.env.S3_ACCESS_KEY_ID &&
process.env.S3_SECRET_ACCESS_KEY
) {
return {
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION ?? "auto",
bucket: process.env.S3_BUCKET,
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
publicUrl: process.env.S3_PUBLIC_URL,
};
}
return null;
}
function normalizeStorageFailoverConfig(value: unknown): {
enabled: boolean;
backupStorageConfigId: string;
} {
const raw =
value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: {};
return {
enabled: raw.enabled === true,
backupStorageConfigId: String(raw.backupStorageConfigId || "").trim(),
};
}
async function uploadWithBackupStorageConfig(
filename: string,
buffer: ArrayBuffer,
primaryStorageConfigId?: string
): Promise<{ url: string; pathname: string; contentType: string } | null> {
try {
const setting = await getSystemSetting({ key: STORAGE_FAILOVER_CONFIG_KEY });
const failover = normalizeStorageFailoverConfig(setting?.value);
if (!failover.enabled || !failover.backupStorageConfigId) {
return null;
}
if (
primaryStorageConfigId &&
failover.backupStorageConfigId === primaryStorageConfigId
) {
console.warn(
"[Upload] Backup storage config equals default storage config, skipping failover retry"
);
return null;
}
const backupStorageConfig = await getStorageConfigById({
id: failover.backupStorageConfigId,
});
const backupS3Config = toDbS3UploadConfig(backupStorageConfig);
if (!backupS3Config) {
console.warn(
"[Upload] Backup storage config is invalid, disabled, or not S3 compatible"
);
return null;
}
console.log(
`[Upload] Retrying with backup S3 config: ${backupStorageConfig?.name ?? failover.backupStorageConfigId}`
);
const uploaded = await uploadToS3(filename, buffer, backupS3Config);
lastKnownGoodDbS3Config = backupS3Config;
return uploaded;
} catch (error) {
console.error("[Upload] Backup storage config retry failed:", error);
return null;
}
}
// 上传文件到存储服务
export async function uploadFile(
filename: string,
buffer: ArrayBuffer
): Promise<{ url: string; pathname: string; contentType: string }> {
console.log(`[Upload] Starting upload for file: ${filename}`);
let dbS3Config: S3UploadConfig | null = null;
let primaryStorageConfigId: string | undefined;
try {
const storageConfig = await getDefaultStorageConfig();
primaryStorageConfigId = storageConfig?.id;
dbS3Config = toDbS3UploadConfig(storageConfig);
if (dbS3Config) {
console.log(`[Upload] Using database S3 config: ${storageConfig?.name}`);
console.log(`[Upload] Endpoint: ${dbS3Config.endpoint}`);
console.log(`[Upload] Bucket: ${dbS3Config.bucket}`);
const uploaded = await uploadToS3(filename, buffer, dbS3Config);
lastKnownGoodDbS3Config = dbS3Config;
return uploaded;
}
console.warn("[Upload] No valid S3 config in database");
} catch (error) {
console.error("[Upload] Database storage path failed:", error);
const uploadedFromBackup = await uploadWithBackupStorageConfig(
filename,
buffer,
primaryStorageConfigId
);
if (uploadedFromBackup) {
return uploadedFromBackup;
}
if (lastKnownGoodDbS3Config) {
try {
console.log("[Upload] Retrying with last known good database S3 config");
return await uploadToS3(filename, buffer, lastKnownGoodDbS3Config);
} catch (retryError) {
console.error("[Upload] Last known S3 config upload failed:", retryError);
}
}
}
if (!dbS3Config) {
const uploadedFromBackup = await uploadWithBackupStorageConfig(
filename,
buffer,
primaryStorageConfigId
);
if (uploadedFromBackup) {
return uploadedFromBackup;
}
}
const envS3Config = buildEnvS3Config();
if (envS3Config) {
console.log("[Upload] Using environment variable S3 config");
return await uploadToS3(filename, buffer, envS3Config);
}
if (!process.env.BLOB_READ_WRITE_TOKEN) {
throw new Error(
"No available storage backend: database S3 failed, env S3 missing, and BLOB_READ_WRITE_TOKEN is not configured."
);
}
// 最终回退到 Vercel Blob
console.log("[Upload] Falling back to Vercel Blob");
const data = await put(filename, buffer, {
access: "public",
});
return {
url: data.url,
pathname: data.pathname,
contentType: getContentType(filename),
};
}
// 获取当前使用的存储类型
export async function getStorageType(): Promise<
"database-s3" | "env-s3" | "vercel-blob"
> {
try {
const storageConfig = await getDefaultStorageConfig();
if (
storageConfig &&
storageConfig.type === "s3" &&
storageConfig.endpoint &&
storageConfig.bucket
) {
return "database-s3";
}
} catch (error) {
// ignore
}
if (process.env.S3_ENDPOINT && process.env.S3_BUCKET) {
return "env-s3";
}
return "vercel-blob";
}