feat(storage): harden aliyun oss failover config handling
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-25 17:32:32 +08:00
parent d7d6c2bbea
commit 1342c2a069
3 changed files with 134 additions and 53 deletions

View File

@ -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 || "",

View File

@ -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();
}
}

View File

@ -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;