From 4941f09dfa73d1ea485843b5cb810398bbecc9f8 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 27 May 2026 21:37:21 +0800 Subject: [PATCH] feat: add qiniu kodo storage configuration --- .../settings/admin-settings-client.tsx | 82 +++++++++++++++---- app/api/admin/storage/route.ts | 4 +- lib/db/queries.ts | 4 +- lib/db/schema.ts | 4 +- lib/storage/service.ts | 10 ++- 5 files changed, 78 insertions(+), 26 deletions(-) diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 3cf97aeab..38a2e25ff 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -939,7 +939,7 @@ type AiModelHealthEvent = { type StorageConfig = { id: string; name: string; - type: "s3" | "local" | "aliyun-oss"; + type: "s3" | "local" | "aliyun-oss" | "qiniu-kodo"; endpoint?: string; region?: string; bucket?: string; @@ -3785,7 +3785,7 @@ export function AdminSettingsClient() { const [storageForm, setStorageForm] = useState({ id: "", name: "", - type: "s3" as "s3" | "local" | "aliyun-oss", + type: "s3" as "s3" | "local" | "aliyun-oss" | "qiniu-kodo", endpoint: "", region: "", bucket: "", @@ -7252,6 +7252,10 @@ export function AdminSettingsClient() { body.secretAccessKey = String(body.secretAccessKey || "").trim(); body.publicUrl = normalizeUrl(body.publicUrl); + if (body.type === "qiniu-kodo" && !body.publicUrl) { + throw new Error("七牛云 Kodo 需要配置公开访问域名"); + } + // 兼容处理:如果是阿里云 OSS,转换为 s3 类型提交 if (body.type === "aliyun-oss") { body.type = "s3"; @@ -7275,7 +7279,7 @@ export function AdminSettingsClient() { setShowStorageDialog(false); resetStorageForm(); } catch (error) { - toast.error("保存存储配置失败"); + toast.error(error instanceof Error ? error.message : "保存存储配置失败"); } }; @@ -16498,7 +16502,9 @@ export function AdminSettingsClient() {
存储配置 - 配置 S3 兼容对象存储 + + 配置 S3 兼容对象存储、阿里云 OSS 或七牛云 Kodo +
S3 兼容 阿里云 OSS + 七牛云 Kodo 本地存储 {(storageForm.type === "s3" || - storageForm.type === "aliyun-oss") && ( + storageForm.type === "aliyun-oss" || + storageForm.type === "qiniu-kodo") && ( <> {storageForm.type === "aliyun-oss" && (
@@ -16566,8 +16574,28 @@ export function AdminSettingsClient() {

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

)} + {storageForm.type === "qiniu-kodo" && ( +
+

+ 七牛云 Kodo 配置说明: +

+

+ • Endpoint 填写七牛 S3 兼容访问端点,例如:https://s3.cn-east-1.qiniucs.com +

+

+ • Region 填写七牛 S3 Region ID,例如:cn-east-1 +

+

+ • Public URL 必填公开访问域名,例如:https://img.example.com +

+
+ )}
- + setStorageForm({ @@ -16591,7 +16619,11 @@ export function AdminSettingsClient() { />
- + setStorageForm({ @@ -16604,7 +16636,11 @@ export function AdminSettingsClient() { />
- + setStorageForm({ @@ -16618,7 +16654,11 @@ export function AdminSettingsClient() { />
- + setStorageForm({ @@ -16630,7 +16670,11 @@ export function AdminSettingsClient() { />
- + setStorageForm({ @@ -16708,7 +16752,9 @@ export function AdminSettingsClient() { ? (config.endpoint?.includes("aliyuncs.com") ? "阿里云 OSS" : "S3") + ` | Bucket: ${config.bucket}` - : "本地存储"} + : config.type === "qiniu-kodo" + ? `七牛云 Kodo | Bucket: ${config.bucket}` + : "本地存储"}
@@ -16718,10 +16764,12 @@ export function AdminSettingsClient() { id: config.id, name: config.name, type: - config.type === "s3" && - config.endpoint?.includes("aliyuncs.com") - ? "aliyun-oss" - : (config.type as any), + config.type === "qiniu-kodo" + ? "qiniu-kodo" + : config.type === "s3" && + config.endpoint?.includes("aliyuncs.com") + ? "aliyun-oss" + : (config.type as any), endpoint: config.endpoint || "", region: config.region || "", bucket: config.bucket || "", @@ -16757,8 +16805,8 @@ export function AdminSettingsClient() { 存储故障迁移 - 主存储桶上传失败时,自动切换到备用 MinIO/S3/阿里云 OSS 等 S3 - 兼容存储桶。 + 主存储桶上传失败时,自动切换到备用 MinIO/S3/阿里云 + OSS/七牛云 Kodo 等对象存储桶。 diff --git a/app/api/admin/storage/route.ts b/app/api/admin/storage/route.ts index aca34ad53..ac52dd8f1 100644 --- a/app/api/admin/storage/route.ts +++ b/app/api/admin/storage/route.ts @@ -11,7 +11,7 @@ import { ChatSDKError } from "@/lib/errors"; const createStorageSchema = z.object({ name: z.string().trim().min(1).max(128), - type: z.enum(["s3", "local"]), + type: z.enum(["s3", "local", "qiniu-kodo"]), endpoint: z.string().trim().optional(), region: z.string().trim().max(64).optional(), bucket: z.string().trim().max(128).optional(), @@ -25,7 +25,7 @@ const createStorageSchema = z.object({ const updateStorageSchema = z.object({ id: z.string().uuid(), name: z.string().trim().min(1).max(128).optional(), - type: z.enum(["s3", "local"]).optional(), + type: z.enum(["s3", "local", "qiniu-kodo"]).optional(), endpoint: z.string().trim().optional(), region: z.string().trim().max(64).optional(), bucket: z.string().trim().max(128).optional(), diff --git a/lib/db/queries.ts b/lib/db/queries.ts index 510c1d6c3..d37ea9621 100644 --- a/lib/db/queries.ts +++ b/lib/db/queries.ts @@ -4423,7 +4423,7 @@ export async function createStorageConfig({ updatedBy, }: { name: string; - type: "s3" | "local"; + type: "s3" | "local" | "qiniu-kodo"; endpoint?: string; region?: string; bucket?: string; @@ -4483,7 +4483,7 @@ export async function updateStorageConfig({ }: { id: string; name?: string; - type?: "s3" | "local"; + type?: "s3" | "local" | "qiniu-kodo"; endpoint?: string; region?: string; bucket?: string; diff --git a/lib/db/schema.ts b/lib/db/schema.ts index 51c2bd366..1dcde85d1 100644 --- a/lib/db/schema.ts +++ b/lib/db/schema.ts @@ -392,8 +392,8 @@ export type AiModelHealthEvent = InferSelectModel; export const storageConfig = pgTable("StorageConfig", { id: uuid("id").primaryKey().notNull().defaultRandom(), name: varchar("name", { length: 128 }).notNull(), // 配置名称 - type: varchar("type", { enum: ["s3", "local"] }).notNull().default("s3"), - endpoint: text("endpoint"), // S3 端点 + type: varchar("type", { enum: ["s3", "local", "qiniu-kodo"] }).notNull().default("s3"), + endpoint: text("endpoint"), // S3/七牛 Kodo 兼容端点 region: varchar("region", { length: 64 }), // 区域 bucket: varchar("bucket", { length: 128 }), // 存储桶名称 accessKeyId: text("accessKeyId"), // Access Key ID diff --git a/lib/storage/service.ts b/lib/storage/service.ts index 2c4c3e824..be918c550 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -29,6 +29,7 @@ export type S3UploadConfig = { accessKeyId: string; secretAccessKey: string; publicUrl?: string; + provider?: "s3" | "qiniu-kodo"; }; export type UploadedFileResult = { @@ -116,7 +117,7 @@ export function createS3Client(config: S3UploadConfig): S3Client { accessKeyId: config.accessKeyId, secretAccessKey: config.secretAccessKey, }, - forcePathStyle: !isCloudS3, + forcePathStyle: config.provider === "qiniu-kodo" ? true : !isCloudS3, }); } @@ -269,6 +270,8 @@ export async function deleteS3ObjectByPath(pathname: string, config: S3UploadCon export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null { const value = raw as Record | null | undefined; + const provider = + value?.type === "qiniu-kodo" ? "qiniu-kodo" : value?.type === "s3" ? "s3" : null; const endpoint = value?.endpoint ? normalizeEndpoint(String(value.endpoint)) : ""; const bucket = value?.bucket ? String(value.bucket).trim() : ""; const accessKeyId = value?.accessKeyId ? String(value.accessKeyId).trim() : ""; @@ -278,7 +281,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null { if ( !value || - value.type !== "s3" || + !provider || !value.isEnabled || !endpoint || !bucket || @@ -295,6 +298,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null { accessKeyId, secretAccessKey, publicUrl: normalizePublicUrl(value.publicUrl ? String(value.publicUrl) : undefined), + provider, }; } @@ -622,7 +626,7 @@ export async function getStorageType(): Promise< const storageConfig = await getDefaultStorageConfig(); if ( storageConfig && - storageConfig.type === "s3" && + (storageConfig.type === "s3" || storageConfig.type === "qiniu-kodo") && storageConfig.endpoint && storageConfig.bucket ) {