ai-chatbot/lib/storage/service.ts
Your Name 1342c2a069
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(storage): harden aliyun oss failover config handling
2026-02-25 17:32:32 +08:00

365 lines
10 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";
function normalizeEndpoint(endpoint: string): string {
const trimmed = endpoint.trim().replace(/\/+$/, "");
if (!trimmed) return "";
// 阿里云 OSS 统一强制 HTTPS,避免误填 http 或带空格导致连接失败
if (/aliyuncs\.com/i.test(trimmed)) {
const withoutProtocol = trimmed.replace(/^https?:\/\//i, "");
return `https://${withoutProtocol}`;
}
return trimmed;
}
function normalizePublicUrl(publicUrl?: string): string | undefined {
if (!publicUrl) return undefined;
const trimmed = publicUrl.trim().replace(/\/+$/, "");
return trimmed || undefined;
}
// 获取文件的 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 normalizedEndpoint = normalizeEndpoint(endpoint);
const normalizedPublicUrl = normalizePublicUrl(publicUrl);
// 生成唯一的文件名
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/Aliyun OSS/AWS S3)
// 自动检测是否为云厂商S3(阿里云/AWS),如果是则使用 Virtual Hosted Style,否则默认为 Path Style (MinIO)
const lowerEndpoint = normalizedEndpoint.toLowerCase();
const isCloudS3 =
lowerEndpoint.includes("aliyuncs.com") ||
lowerEndpoint.includes("amazonaws.com");
const s3Client = new S3Client({
endpoint: normalizedEndpoint,
region: region || "auto",
credentials: {
accessKeyId: accessKeyId,
secretAccessKey: secretAccessKey,
},
// MinIO 需要使用 path-style URL (true)
// 阿里云 OSS/AWS S3 推荐使用 virtual-hosted-style URL (false)
forcePathStyle: !isCloudS3,
});
console.log(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${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 (normalizedPublicUrl) {
// 如果用户配置了自定义公开 URL,使用它
fileUrl = `${normalizedPublicUrl}/${key}`;
} else {
// 否则使用 endpoint + bucket + key
fileUrl = `${normalizedEndpoint}/${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;
const endpoint = value?.endpoint ? normalizeEndpoint(String(value.endpoint)) : "";
const bucket = value?.bucket ? String(value.bucket).trim() : "";
const accessKeyId = value?.accessKeyId ? String(value.accessKeyId).trim() : "";
const secretAccessKey = value?.secretAccessKey
? String(value.secretAccessKey).trim()
: "";
if (
!value ||
value.type !== "s3" ||
!value.isEnabled ||
!endpoint ||
!bucket ||
!accessKeyId ||
!secretAccessKey
) {
return null;
}
return {
endpoint,
region: String(value.region || "auto").trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
publicUrl: normalizePublicUrl(value.publicUrl ? String(value.publicUrl) : undefined),
};
}
function buildEnvS3Config(): S3UploadConfig | null {
const endpoint = process.env.S3_ENDPOINT
? normalizeEndpoint(process.env.S3_ENDPOINT)
: "";
const bucket = process.env.S3_BUCKET?.trim() ?? "";
const accessKeyId = process.env.S3_ACCESS_KEY_ID?.trim() ?? "";
const secretAccessKey = process.env.S3_SECRET_ACCESS_KEY?.trim() ?? "";
if (endpoint && bucket && accessKeyId && secretAccessKey) {
return {
endpoint,
region: process.env.S3_REGION?.trim() || "auto",
bucket,
accessKeyId,
secretAccessKey,
publicUrl: normalizePublicUrl(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";
}