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;