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 = { type StorageConfig = {
id: string; id: string;
name: string; name: string;
type: "s3" | "local" | "aliyun-oss"; type: "s3" | "local" | "aliyun-oss" | "qiniu-kodo";
endpoint?: string; endpoint?: string;
region?: string; region?: string;
bucket?: string; bucket?: string;
@ -3785,7 +3785,7 @@ export function AdminSettingsClient() {
const [storageForm, setStorageForm] = useState({ const [storageForm, setStorageForm] = useState({
id: "", id: "",
name: "", name: "",
type: "s3" as "s3" | "local" | "aliyun-oss", type: "s3" as "s3" | "local" | "aliyun-oss" | "qiniu-kodo",
endpoint: "", endpoint: "",
region: "", region: "",
bucket: "", bucket: "",
@ -7252,6 +7252,10 @@ export function AdminSettingsClient() {
body.secretAccessKey = String(body.secretAccessKey || "").trim(); body.secretAccessKey = String(body.secretAccessKey || "").trim();
body.publicUrl = normalizeUrl(body.publicUrl); body.publicUrl = normalizeUrl(body.publicUrl);
if (body.type === "qiniu-kodo" && !body.publicUrl) {
throw new Error("七牛云 Kodo 需要配置公开访问域名");
}
// 兼容处理:如果是阿里云 OSS,转换为 s3 类型提交 // 兼容处理:如果是阿里云 OSS,转换为 s3 类型提交
if (body.type === "aliyun-oss") { if (body.type === "aliyun-oss") {
body.type = "s3"; body.type = "s3";
@ -7275,7 +7279,7 @@ export function AdminSettingsClient() {
setShowStorageDialog(false); setShowStorageDialog(false);
resetStorageForm(); resetStorageForm();
} catch (error) { } 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"> <CardHeader className="flex flex-row items-center justify-between">
<div> <div>
<CardTitle>存储配置</CardTitle> <CardTitle>存储配置</CardTitle>
<CardDescription>配置 S3 兼容对象存储</CardDescription> <CardDescription>
配置 S3 兼容对象存储、阿里云 OSS 或七牛云 Kodo
</CardDescription>
</div> </div>
<Dialog <Dialog
onOpenChange={setShowStorageDialog} onOpenChange={setShowStorageDialog}
@ -16547,12 +16553,14 @@ export function AdminSettingsClient() {
<SelectContent className="!bg-white !dark:bg-zinc-950"> <SelectContent className="!bg-white !dark:bg-zinc-950">
<SelectItem value="s3">S3 兼容</SelectItem> <SelectItem value="s3">S3 兼容</SelectItem>
<SelectItem value="aliyun-oss">阿里云 OSS</SelectItem> <SelectItem value="aliyun-oss">阿里云 OSS</SelectItem>
<SelectItem value="qiniu-kodo">七牛云 Kodo</SelectItem>
<SelectItem value="local">本地存储</SelectItem> <SelectItem value="local">本地存储</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</div> </div>
{(storageForm.type === "s3" || {(storageForm.type === "s3" ||
storageForm.type === "aliyun-oss") && ( storageForm.type === "aliyun-oss" ||
storageForm.type === "qiniu-kodo") && (
<> <>
{storageForm.type === "aliyun-oss" && ( {storageForm.type === "aliyun-oss" && (
<div className="mb-4 rounded-md border border-blue-100 bg-blue-50 p-3 text-blue-700 text-xs"> <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> <p>• 请确保 AccessKey 拥有对应 Bucket 的读写权限</p>
</div> </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"> <div className="space-y-2">
<Label>端点 (Endpoint)</Label> <Label>
{storageForm.type === "qiniu-kodo"
? "七牛 S3 Endpoint"
: "端点 (Endpoint)"}
</Label>
<Input <Input
onChange={(e) => onChange={(e) =>
setStorageForm({ setStorageForm({
@ -16591,7 +16619,11 @@ export function AdminSettingsClient() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Access Key</Label> <Label>
{storageForm.type === "qiniu-kodo"
? "AccessKey"
: "Access Key"}
</Label>
<Input <Input
onChange={(e) => onChange={(e) =>
setStorageForm({ setStorageForm({
@ -16604,7 +16636,11 @@ export function AdminSettingsClient() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Secret Key</Label> <Label>
{storageForm.type === "qiniu-kodo"
? "SecretKey"
: "Secret Key"}
</Label>
<Input <Input
onChange={(e) => onChange={(e) =>
setStorageForm({ setStorageForm({
@ -16618,7 +16654,11 @@ export function AdminSettingsClient() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Region</Label> <Label>
{storageForm.type === "qiniu-kodo"
? "Region ID"
: "Region"}
</Label>
<Input <Input
onChange={(e) => onChange={(e) =>
setStorageForm({ setStorageForm({
@ -16630,7 +16670,11 @@ export function AdminSettingsClient() {
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label>Public URL</Label> <Label>
{storageForm.type === "qiniu-kodo"
? "公开访问域名 (Public URL)"
: "Public URL"}
</Label>
<Input <Input
onChange={(e) => onChange={(e) =>
setStorageForm({ setStorageForm({
@ -16708,7 +16752,9 @@ export function AdminSettingsClient() {
? (config.endpoint?.includes("aliyuncs.com") ? (config.endpoint?.includes("aliyuncs.com")
? "阿里云 OSS" ? "阿里云 OSS"
: "S3") + ` | Bucket: ${config.bucket}` : "S3") + ` | Bucket: ${config.bucket}`
: "本地存储"} : config.type === "qiniu-kodo"
? `七牛云 Kodo | Bucket: ${config.bucket}`
: "本地存储"}
</div> </div>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -16718,10 +16764,12 @@ export function AdminSettingsClient() {
id: config.id, id: config.id,
name: config.name, name: config.name,
type: type:
config.type === "s3" && config.type === "qiniu-kodo"
config.endpoint?.includes("aliyuncs.com") ? "qiniu-kodo"
? "aliyun-oss" : config.type === "s3" &&
: (config.type as any), config.endpoint?.includes("aliyuncs.com")
? "aliyun-oss"
: (config.type as any),
endpoint: config.endpoint || "", endpoint: config.endpoint || "",
region: config.region || "", region: config.region || "",
bucket: config.bucket || "", bucket: config.bucket || "",
@ -16757,8 +16805,8 @@ export function AdminSettingsClient() {
<CardHeader> <CardHeader>
<CardTitle>存储故障迁移</CardTitle> <CardTitle>存储故障迁移</CardTitle>
<CardDescription> <CardDescription>
主存储桶上传失败时,自动切换到备用 MinIO/S3/阿里云 OSS 等 S3 主存储桶上传失败时,自动切换到备用 MinIO/S3/阿里云
兼容存储桶。 OSS/七牛云 Kodo 等对象存储桶。
</CardDescription> </CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-4"> <CardContent className="space-y-4">

View File

@ -11,7 +11,7 @@ import { ChatSDKError } from "@/lib/errors";
const createStorageSchema = z.object({ const createStorageSchema = z.object({
name: z.string().trim().min(1).max(128), 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(), endpoint: z.string().trim().optional(),
region: z.string().trim().max(64).optional(), region: z.string().trim().max(64).optional(),
bucket: z.string().trim().max(128).optional(), bucket: z.string().trim().max(128).optional(),
@ -25,7 +25,7 @@ const createStorageSchema = z.object({
const updateStorageSchema = z.object({ const updateStorageSchema = z.object({
id: z.string().uuid(), id: z.string().uuid(),
name: z.string().trim().min(1).max(128).optional(), 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(), endpoint: z.string().trim().optional(),
region: z.string().trim().max(64).optional(), region: z.string().trim().max(64).optional(),
bucket: z.string().trim().max(128).optional(), bucket: z.string().trim().max(128).optional(),

View File

@ -4423,7 +4423,7 @@ export async function createStorageConfig({
updatedBy, updatedBy,
}: { }: {
name: string; name: string;
type: "s3" | "local"; type: "s3" | "local" | "qiniu-kodo";
endpoint?: string; endpoint?: string;
region?: string; region?: string;
bucket?: string; bucket?: string;
@ -4483,7 +4483,7 @@ export async function updateStorageConfig({
}: { }: {
id: string; id: string;
name?: string; name?: string;
type?: "s3" | "local"; type?: "s3" | "local" | "qiniu-kodo";
endpoint?: string; endpoint?: string;
region?: string; region?: string;
bucket?: string; bucket?: string;

View File

@ -392,8 +392,8 @@ export type AiModelHealthEvent = InferSelectModel<typeof aiModelHealthEvent>;
export const storageConfig = pgTable("StorageConfig", { export const storageConfig = pgTable("StorageConfig", {
id: uuid("id").primaryKey().notNull().defaultRandom(), id: uuid("id").primaryKey().notNull().defaultRandom(),
name: varchar("name", { length: 128 }).notNull(), // 配置名称 name: varchar("name", { length: 128 }).notNull(), // 配置名称
type: varchar("type", { enum: ["s3", "local"] }).notNull().default("s3"), type: varchar("type", { enum: ["s3", "local", "qiniu-kodo"] }).notNull().default("s3"),
endpoint: text("endpoint"), // S3 端点 endpoint: text("endpoint"), // S3/七牛 Kodo 兼容端点
region: varchar("region", { length: 64 }), // 区域 region: varchar("region", { length: 64 }), // 区域
bucket: varchar("bucket", { length: 128 }), // 存储桶名称 bucket: varchar("bucket", { length: 128 }), // 存储桶名称
accessKeyId: text("accessKeyId"), // Access Key ID accessKeyId: text("accessKeyId"), // Access Key ID

View File

@ -29,6 +29,7 @@ export type S3UploadConfig = {
accessKeyId: string; accessKeyId: string;
secretAccessKey: string; secretAccessKey: string;
publicUrl?: string; publicUrl?: string;
provider?: "s3" | "qiniu-kodo";
}; };
export type UploadedFileResult = { export type UploadedFileResult = {
@ -116,7 +117,7 @@ export function createS3Client(config: S3UploadConfig): S3Client {
accessKeyId: config.accessKeyId, accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey, 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 { export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
const value = raw as Record<string, unknown> | null | undefined; 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 endpoint = value?.endpoint ? normalizeEndpoint(String(value.endpoint)) : "";
const bucket = value?.bucket ? String(value.bucket).trim() : ""; const bucket = value?.bucket ? String(value.bucket).trim() : "";
const accessKeyId = value?.accessKeyId ? String(value.accessKeyId).trim() : ""; const accessKeyId = value?.accessKeyId ? String(value.accessKeyId).trim() : "";
@ -278,7 +281,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
if ( if (
!value || !value ||
value.type !== "s3" || !provider ||
!value.isEnabled || !value.isEnabled ||
!endpoint || !endpoint ||
!bucket || !bucket ||
@ -295,6 +298,7 @@ export function toDbS3UploadConfig(raw: unknown): S3UploadConfig | null {
accessKeyId, accessKeyId,
secretAccessKey, secretAccessKey,
publicUrl: normalizePublicUrl(value.publicUrl ? String(value.publicUrl) : undefined), publicUrl: normalizePublicUrl(value.publicUrl ? String(value.publicUrl) : undefined),
provider,
}; };
} }
@ -622,7 +626,7 @@ export async function getStorageType(): Promise<
const storageConfig = await getDefaultStorageConfig(); const storageConfig = await getDefaultStorageConfig();
if ( if (
storageConfig && storageConfig &&
storageConfig.type === "s3" && (storageConfig.type === "s3" || storageConfig.type === "qiniu-kodo") &&
storageConfig.endpoint && storageConfig.endpoint &&
storageConfig.bucket storageConfig.bucket
) { ) {