feat: add qiniu kodo storage configuration

This commit is contained in:
Your Name 2026-05-27 21:37:21 +08:00
parent d2e282030b
commit 4941f09dfa
5 changed files with 78 additions and 26 deletions

View File

@ -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() {
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle>存储配置</CardTitle>
<CardDescription>配置 S3 兼容对象存储</CardDescription>
<CardDescription>
配置 S3 兼容对象存储、阿里云 OSS 或七牛云 Kodo
</CardDescription>
</div>
<Dialog
onOpenChange={setShowStorageDialog}
@ -16547,12 +16553,14 @@ export function AdminSettingsClient() {
<SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="s3">S3 兼容</SelectItem>
<SelectItem value="aliyun-oss">阿里云 OSS</SelectItem>
<SelectItem value="qiniu-kodo">七牛云 Kodo</SelectItem>
<SelectItem value="local">本地存储</SelectItem>
</SelectContent>
</Select>
</div>
{(storageForm.type === "s3" ||
storageForm.type === "aliyun-oss") && (
storageForm.type === "aliyun-oss" ||
storageForm.type === "qiniu-kodo") && (
<>
{storageForm.type === "aliyun-oss" && (
<div className="mb-4 rounded-md border border-blue-100 bg-blue-50 p-3 text-blue-700 text-xs">
@ -16566,8 +16574,28 @@ export function AdminSettingsClient() {
<p>• 请确保 AccessKey 拥有对应 Bucket 的读写权限</p>
</div>
)}
{storageForm.type === "qiniu-kodo" && (
<div className="mb-4 rounded-md border border-emerald-100 bg-emerald-50 p-3 text-emerald-700 text-xs">
<p className="mb-1 font-semibold">
七牛云 Kodo 配置说明:
</p>
<p>
• Endpoint 填写七牛 S3 兼容访问端点,例如:https://s3.cn-east-1.qiniucs.com
</p>
<p>
• Region 填写七牛 S3 Region ID,例如:cn-east-1
</p>
<p>
• Public URL 必填公开访问域名,例如:https://img.example.com
</p>
</div>
)}
<div className="space-y-2">
<Label>端点 (Endpoint)</Label>
<Label>
{storageForm.type === "qiniu-kodo"
? "七牛 S3 Endpoint"
: "端点 (Endpoint)"}
</Label>
<Input
onChange={(e) =>
setStorageForm({
@ -16591,7 +16619,11 @@ export function AdminSettingsClient() {
/>
</div>
<div className="space-y-2">
<Label>Access Key</Label>
<Label>
{storageForm.type === "qiniu-kodo"
? "AccessKey"
: "Access Key"}
</Label>
<Input
onChange={(e) =>
setStorageForm({
@ -16604,7 +16636,11 @@ export function AdminSettingsClient() {
/>
</div>
<div className="space-y-2">
<Label>Secret Key</Label>
<Label>
{storageForm.type === "qiniu-kodo"
? "SecretKey"
: "Secret Key"}
</Label>
<Input
onChange={(e) =>
setStorageForm({
@ -16618,7 +16654,11 @@ export function AdminSettingsClient() {
/>
</div>
<div className="space-y-2">
<Label>Region</Label>
<Label>
{storageForm.type === "qiniu-kodo"
? "Region ID"
: "Region"}
</Label>
<Input
onChange={(e) =>
setStorageForm({
@ -16630,7 +16670,11 @@ export function AdminSettingsClient() {
/>
</div>
<div className="space-y-2">
<Label>Public URL</Label>
<Label>
{storageForm.type === "qiniu-kodo"
? "公开访问域名 (Public URL)"
: "Public URL"}
</Label>
<Input
onChange={(e) =>
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}`
: "本地存储"}
</div>
</div>
<div className="flex items-center gap-2">
@ -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() {
<CardHeader>
<CardTitle>存储故障迁移</CardTitle>
<CardDescription>
主存储桶上传失败时,自动切换到备用 MinIO/S3/阿里云 OSS 等 S3
兼容存储桶。
主存储桶上传失败时,自动切换到备用 MinIO/S3/阿里云
OSS/七牛云 Kodo 等对象存储桶。
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">

View File

@ -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(),

View File

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

View File

@ -392,8 +392,8 @@ export type AiModelHealthEvent = InferSelectModel<typeof aiModelHealthEvent>;
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

View File

@ -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<string, unknown> | 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
) {