ai-chatbot/lib/storage/service.ts
Your Name ac76f31878
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled
feat(agent): support dedicated coding model id and detail pipeline hardening
2026-02-22 18:19:31 +08:00

232 lines
6.4 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 } from "@/lib/db/queries";
type S3UploadConfig = {
endpoint: string;
region: string;
bucket: string;
accessKeyId: string;
secretAccessKey: string;
publicUrl?: string;
};
let lastKnownGoodDbS3Config: S3UploadConfig | null = null;
// 获取文件的 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;
}
// 上传文件到存储服务
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;
try {
const storageConfig = await getDefaultStorageConfig();
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);
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);
}
}
}
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";
}