diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 6bc3e8acc..78c07e5f4 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -166,7 +166,7 @@ type AiModel = { type StorageConfig = { id: string; name: string; - type: "s3" | "local"; + type: "s3" | "local" | "aliyun-oss"; endpoint?: string; region?: string; bucket?: string; @@ -1178,7 +1178,7 @@ export function AdminSettingsClient() { const [storageForm, setStorageForm] = useState({ id: "", name: "", - type: "s3" as "s3" | "local", + type: "s3" as "s3" | "local" | "aliyun-oss", endpoint: "", region: "", bucket: "", @@ -2291,7 +2291,36 @@ export function AdminSettingsClient() { const handleSaveStorage = async () => { try { const method = editingStorage ? "PUT" : "POST"; - const body = editingStorage ? { ...storageForm, id: editingStorage } : storageForm; + const body: any = editingStorage + ? { ...storageForm, id: editingStorage } + : { ...storageForm }; + + const normalizeUrl = (value?: string) => { + if (!value) return ""; + return value.trim().replace(/\/+$/, ""); + }; + + const endpoint = normalizeUrl(body.endpoint); + body.endpoint = /aliyuncs\.com/i.test(endpoint) + ? `https://${endpoint.replace(/^https?:\/\//i, "")}` + : endpoint; + body.region = String(body.region || "").trim(); + body.bucket = String(body.bucket || "").trim(); + body.accessKeyId = String(body.accessKeyId || "").trim(); + body.secretAccessKey = String(body.secretAccessKey || "").trim(); + body.publicUrl = normalizeUrl(body.publicUrl); + + // 兼容处理:如果是阿里云 OSS,转换为 s3 类型提交 + if (body.type === "aliyun-oss") { + body.type = "s3"; + } + + // 编辑状态下,留空密钥代表不修改 + if (editingStorage) { + if (!body.accessKeyId) delete body.accessKeyId; + if (!body.secretAccessKey) delete body.secretAccessKey; + } + const response = await fetch("/api/admin/storage", { method, headers: { "Content-Type": "application/json" }, @@ -6578,12 +6607,20 @@ export function AdminSettingsClient() { S3 兼容 + 阿里云 OSS 本地存储 - {storageForm.type === "s3" && ( + {(storageForm.type === "s3" || storageForm.type === "aliyun-oss") && ( <> + {storageForm.type === "aliyun-oss" && ( +
+

阿里云 OSS 配置说明:

+

• Endpoint 示例:https://oss-cn-shanghai.aliyuncs.com

+

• 请确保 AccessKey 拥有对应 Bucket 的读写权限

+
+ )}
已禁用}
- {config.type === "s3" ? `S3 | Bucket: ${config.bucket}` : "本地存储"} + {config.type === "s3" + ? (config.endpoint?.includes("aliyuncs.com") ? "阿里云 OSS" : "S3") + ` | Bucket: ${config.bucket}` + : "本地存储"}
@@ -6674,7 +6713,7 @@ export function AdminSettingsClient() { setStorageForm({ id: config.id, name: config.name, - type: config.type, + type: config.type === "s3" && config.endpoint?.includes("aliyuncs.com") ? "aliyun-oss" : config.type as any, endpoint: config.endpoint || "", region: config.region || "", bucket: config.bucket || "", diff --git a/app/api/admin/storage/route.ts b/app/api/admin/storage/route.ts index 007328f7a..aca34ad53 100644 --- a/app/api/admin/storage/route.ts +++ b/app/api/admin/storage/route.ts @@ -10,28 +10,28 @@ import { import { ChatSDKError } from "@/lib/errors"; const createStorageSchema = z.object({ - name: z.string().min(1).max(128), + name: z.string().trim().min(1).max(128), type: z.enum(["s3", "local"]), - endpoint: z.string().optional(), - region: z.string().max(64).optional(), - bucket: z.string().max(128).optional(), - accessKeyId: z.string().optional(), - secretAccessKey: z.string().optional(), - publicUrl: z.string().optional(), + endpoint: z.string().trim().optional(), + region: z.string().trim().max(64).optional(), + bucket: z.string().trim().max(128).optional(), + accessKeyId: z.string().trim().optional(), + secretAccessKey: z.string().trim().optional(), + publicUrl: z.string().trim().optional(), isDefault: z.boolean().optional(), isEnabled: z.boolean().optional(), }); const updateStorageSchema = z.object({ id: z.string().uuid(), - name: z.string().min(1).max(128).optional(), + name: z.string().trim().min(1).max(128).optional(), type: z.enum(["s3", "local"]).optional(), - endpoint: z.string().optional(), - region: z.string().max(64).optional(), - bucket: z.string().max(128).optional(), - accessKeyId: z.string().optional(), - secretAccessKey: z.string().optional(), - publicUrl: z.string().optional(), + endpoint: z.string().trim().optional(), + region: z.string().trim().max(64).optional(), + bucket: z.string().trim().max(128).optional(), + accessKeyId: z.string().trim().optional(), + secretAccessKey: z.string().trim().optional(), + publicUrl: z.string().trim().optional(), isDefault: z.boolean().optional(), isEnabled: z.boolean().optional(), }); @@ -150,4 +150,3 @@ export async function DELETE(request: Request) { return new ChatSDKError("offline:admin").toResponse(); } } - diff --git a/lib/storage/service.ts b/lib/storage/service.ts index c40137fe0..5dcbea391 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -20,6 +20,25 @@ type S3UploadConfig = { 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(); @@ -46,7 +65,16 @@ async function uploadToS3( buffer: ArrayBuffer, config: S3UploadConfig ) { - const { endpoint, region, bucket, accessKeyId, secretAccessKey, publicUrl } = config; + const { + endpoint, + region, + bucket, + accessKeyId, + secretAccessKey, + publicUrl, + } = config; + const normalizedEndpoint = normalizeEndpoint(endpoint); + const normalizedPublicUrl = normalizePublicUrl(publicUrl); // 生成唯一的文件名 const timestamp = Date.now(); @@ -57,19 +85,26 @@ async function uploadToS3( const contentType = getContentType(filename); try { - // 创建 S3 客户端(支持 MinIO) + // 创建 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: endpoint, + endpoint: normalizedEndpoint, region: region || "auto", credentials: { accessKeyId: accessKeyId, secretAccessKey: secretAccessKey, }, - // 关键:MinIO 需要使用 path-style URL - forcePathStyle: true, + // MinIO 需要使用 path-style URL (true) + // 阿里云 OSS/AWS S3 推荐使用 virtual-hosted-style URL (false) + forcePathStyle: !isCloudS3, }); - console.log(`[S3 Upload] Uploading to: ${endpoint}/${bucket}/${key}`); + console.log(`[S3 Upload] Uploading to: ${normalizedEndpoint}/${bucket}/${key}`); // 上传文件 const command = new PutObjectCommand({ @@ -88,13 +123,12 @@ async function uploadToS3( // 返回文件的公开 URL // MinIO 的公开 URL 格式通常是: http://endpoint:port/bucket/key let fileUrl: string; - if (publicUrl) { + if (normalizedPublicUrl) { // 如果用户配置了自定义公开 URL,使用它 - fileUrl = `${publicUrl}/${key}`; + fileUrl = `${normalizedPublicUrl}/${key}`; } else { // 否则使用 endpoint + bucket + key - const cleanEndpoint = endpoint.replace(/\/$/, ""); // 移除末尾的斜杠 - fileUrl = `${cleanEndpoint}/${bucket}/${key}`; + fileUrl = `${normalizedEndpoint}/${bucket}/${key}`; } console.log(`[S3 Upload] File URL: ${fileUrl}`); @@ -112,42 +146,51 @@ async function uploadToS3( function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null { const value = raw as Record | 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 || - !value.endpoint || - !value.bucket || - !value.accessKeyId || - !value.secretAccessKey + !endpoint || + !bucket || + !accessKeyId || + !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, + endpoint, + region: String(value.region || "auto").trim() || "auto", + bucket, + accessKeyId, + secretAccessKey, + publicUrl: normalizePublicUrl(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 - ) { + 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: 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, + endpoint, + region: process.env.S3_REGION?.trim() || "auto", + bucket, + accessKeyId, + secretAccessKey, + publicUrl: normalizePublicUrl(process.env.S3_PUBLIC_URL), }; } return null;