feat(storage): harden aliyun oss failover config handling
This commit is contained in:
parent
d7d6c2bbea
commit
1342c2a069
@ -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() {
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent className="!bg-white !dark:bg-zinc-950">
|
||||
<SelectItem value="s3">S3 兼容</SelectItem>
|
||||
<SelectItem value="aliyun-oss">阿里云 OSS</SelectItem>
|
||||
<SelectItem value="local">本地存储</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
{storageForm.type === "s3" && (
|
||||
{(storageForm.type === "s3" || storageForm.type === "aliyun-oss") && (
|
||||
<>
|
||||
{storageForm.type === "aliyun-oss" && (
|
||||
<div className="rounded-md bg-blue-50 p-3 mb-4 text-xs text-blue-700 border border-blue-100">
|
||||
<p className="font-semibold mb-1">阿里云 OSS 配置说明:</p>
|
||||
<p>• Endpoint 示例:https://oss-cn-shanghai.aliyuncs.com</p>
|
||||
<p>• 请确保 AccessKey 拥有对应 Bucket 的读写权限</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
<Label>端点 (Endpoint)</Label>
|
||||
<Input
|
||||
@ -6666,7 +6703,9 @@ export function AdminSettingsClient() {
|
||||
{!config.isEnabled && <span className="text-xs bg-gray-200 px-2 py-0.5 rounded">已禁用</span>}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{config.type === "s3" ? `S3 | Bucket: ${config.bucket}` : "本地存储"}
|
||||
{config.type === "s3"
|
||||
? (config.endpoint?.includes("aliyuncs.com") ? "阿里云 OSS" : "S3") + ` | Bucket: ${config.bucket}`
|
||||
: "本地存储"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@ -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 || "",
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<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 ||
|
||||
!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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user