feat: add storage failover backup bucket support
This commit is contained in:
parent
b5e001dfc8
commit
9ce3076438
@ -208,6 +208,7 @@ type SiteSettings = {
|
||||
lovartComposerPreviewTexts: string[];
|
||||
lovartPromptTagVisibility: LovartPromptTagVisibility;
|
||||
contactConfig: ContactConfig;
|
||||
storageFailoverConfig: StorageFailoverConfig;
|
||||
pointsSystemConfig: PointsSystemConfig;
|
||||
pointsPackages: PointsPackage[];
|
||||
dulupayConfig: DulupayConfig;
|
||||
@ -235,6 +236,11 @@ type ContactConfig = {
|
||||
wechatQrUrl: string;
|
||||
};
|
||||
|
||||
type StorageFailoverConfig = {
|
||||
enabled: boolean;
|
||||
backupStorageConfigId: string;
|
||||
};
|
||||
|
||||
const defaultLovartPromptTagVisibility: LovartPromptTagVisibility = {
|
||||
nanoBananaPro: true,
|
||||
design: true,
|
||||
@ -249,6 +255,11 @@ const defaultContactConfig: ContactConfig = {
|
||||
wechatQrUrl: "",
|
||||
};
|
||||
|
||||
const defaultStorageFailoverConfig: StorageFailoverConfig = {
|
||||
enabled: false,
|
||||
backupStorageConfigId: "",
|
||||
};
|
||||
|
||||
const lovartPromptTagOptions: Array<{
|
||||
key: keyof LovartPromptTagVisibility;
|
||||
label: string;
|
||||
@ -535,6 +546,7 @@ export function AdminSettingsClient() {
|
||||
],
|
||||
lovartPromptTagVisibility: defaultLovartPromptTagVisibility,
|
||||
contactConfig: defaultContactConfig,
|
||||
storageFailoverConfig: defaultStorageFailoverConfig,
|
||||
pointsSystemConfig: defaultPointsSystemConfig,
|
||||
pointsPackages: defaultPointsPackages,
|
||||
dulupayConfig: defaultDulupayConfig,
|
||||
@ -622,6 +634,12 @@ export function AdminSettingsClient() {
|
||||
phone: String((siteSettingsData as any).contactConfig?.phone || "").trim(),
|
||||
wechatQrUrl: String((siteSettingsData as any).contactConfig?.wechatQrUrl || "").trim(),
|
||||
},
|
||||
storageFailoverConfig: {
|
||||
enabled: (siteSettingsData as any).storageFailoverConfig?.enabled === true,
|
||||
backupStorageConfigId: String(
|
||||
(siteSettingsData as any).storageFailoverConfig?.backupStorageConfigId || ""
|
||||
).trim(),
|
||||
},
|
||||
pointsSystemConfig: normalizedPointsSystemConfig,
|
||||
pointsPackages: normalizedPointsPackages,
|
||||
dulupayConfig: normalizedDulupayConfig,
|
||||
@ -1273,6 +1291,25 @@ export function AdminSettingsClient() {
|
||||
// --- 网站设置逻辑 ---
|
||||
const handleSaveSiteSettings = async () => {
|
||||
try {
|
||||
if (
|
||||
siteSettings.storageFailoverConfig.enabled &&
|
||||
!siteSettings.storageFailoverConfig.backupStorageConfigId
|
||||
) {
|
||||
toast.error("请先选择备用存储配置");
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedBackupConfig = (storageData?.configs || []).find(
|
||||
(config) => config.id === siteSettings.storageFailoverConfig.backupStorageConfigId
|
||||
);
|
||||
if (
|
||||
siteSettings.storageFailoverConfig.enabled &&
|
||||
selectedBackupConfig?.isDefault
|
||||
) {
|
||||
toast.error("备用存储配置不能与默认配置相同");
|
||||
return;
|
||||
}
|
||||
|
||||
const parsedMembershipConfig = normalizeMembershipConfig(siteSettings.membershipConfig);
|
||||
const parsedMembershipOverrides = normalizeMembershipUserOverrides(
|
||||
siteSettings.membershipUserOverrides
|
||||
@ -6412,7 +6449,7 @@ export function AdminSettingsClient() {
|
||||
</TabsContent>
|
||||
|
||||
{/* 存储配置 */}
|
||||
<TabsContent value="storage" className="mt-6">
|
||||
<TabsContent value="storage" className="mt-6 space-y-6">
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<div>
|
||||
@ -6566,6 +6603,87 @@ export function AdminSettingsClient() {
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>存储故障迁移</CardTitle>
|
||||
<CardDescription>
|
||||
主存储桶上传失败时,自动切换到备用 MinIO/S3 存储桶。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex items-center justify-between rounded-lg border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-sm font-medium">启用备用桶故障迁移</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
建议仅在主桶不稳定时开启,避免无效重试。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={siteSettings.storageFailoverConfig.enabled}
|
||||
onCheckedChange={(checked) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
storageFailoverConfig: {
|
||||
...prev.storageFailoverConfig,
|
||||
enabled: checked,
|
||||
},
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>备用存储配置</Label>
|
||||
<Select
|
||||
value={siteSettings.storageFailoverConfig.backupStorageConfigId || "none"}
|
||||
onValueChange={(value) =>
|
||||
setSiteSettings((prev) => ({
|
||||
...prev,
|
||||
storageFailoverConfig: {
|
||||
...prev.storageFailoverConfig,
|
||||
backupStorageConfigId: value === "none" ? "" : value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择备用 S3/MinIO 配置" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不使用备用配置</SelectItem>
|
||||
{(storageData?.configs || [])
|
||||
.filter((config) => config.type === "s3" && config.isEnabled)
|
||||
.map((config) => (
|
||||
<SelectItem key={config.id} value={config.id}>
|
||||
{config.name}
|
||||
{config.isDefault ? "(当前默认)" : ""}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
仅可选择已启用的 S3 兼容配置作为备用桶。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{siteSettings.storageFailoverConfig.enabled &&
|
||||
siteSettings.storageFailoverConfig.backupStorageConfigId &&
|
||||
(storageData?.configs || []).some(
|
||||
(config) =>
|
||||
config.id === siteSettings.storageFailoverConfig.backupStorageConfigId &&
|
||||
config.isDefault
|
||||
) && (
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700">
|
||||
备用桶不能与默认桶相同,请选择另一条存储配置。
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleSaveSiteSettings}>保存故障迁移设置</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</TabsContent>
|
||||
|
||||
{/* 本地AI设置 */}
|
||||
|
||||
@ -35,6 +35,7 @@ const LOVART_PROMPT_TAG_VISIBILITY_KEY = "lovart_prompt_tag_visibility";
|
||||
const CONTACT_CONFIG_KEY = "contact_config";
|
||||
const MEMBERSHIP_CONFIG_KEY = "membership_config";
|
||||
const MEMBERSHIP_USER_OVERRIDES_KEY = "membership_user_plan_overrides";
|
||||
const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config";
|
||||
const REALTIME_CANVAS_CONFIG_KEY = "realtime_canvas_config";
|
||||
|
||||
function normalizeHomeVariant(value: unknown): "classic" | "lovart" {
|
||||
@ -133,6 +134,23 @@ function normalizeContactConfig(value: unknown): {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStorageFailoverConfig(value: unknown): {
|
||||
enabled: boolean;
|
||||
backupStorageConfigId: string;
|
||||
} {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
|
||||
const backupStorageConfigId = String(raw.backupStorageConfigId || "").trim();
|
||||
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
backupStorageConfigId,
|
||||
};
|
||||
}
|
||||
|
||||
// GET /api/site-settings - 获取网站设置 (公开)
|
||||
export async function GET() {
|
||||
try {
|
||||
@ -154,6 +172,7 @@ export async function GET() {
|
||||
pointsPackagesSetting,
|
||||
dulupayConfigSetting,
|
||||
realtimeCanvasConfigSetting,
|
||||
storageFailoverConfigSetting,
|
||||
] = await Promise.all([
|
||||
db.select().from(siteSettings).limit(1).then(rows => rows[0]),
|
||||
db
|
||||
@ -240,6 +259,12 @@ export async function GET() {
|
||||
.where(eq(systemSettings.key, REALTIME_CANVAS_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
db
|
||||
.select()
|
||||
.from(systemSettings)
|
||||
.where(eq(systemSettings.key, STORAGE_FAILOVER_CONFIG_KEY))
|
||||
.limit(1)
|
||||
.then(rows => rows[0]),
|
||||
]);
|
||||
|
||||
const notification = notificationSetting?.value || { enabled: false, title: "", content: "" };
|
||||
@ -269,6 +294,9 @@ export async function GET() {
|
||||
: normalizeDulupayForPublic(dulupayConfigSetting?.value);
|
||||
const realtimeCanvasServiceEnabled =
|
||||
(realtimeCanvasConfigSetting?.value as { enabled?: boolean } | undefined)?.enabled !== false;
|
||||
const storageFailoverConfig = normalizeStorageFailoverConfig(
|
||||
storageFailoverConfigSetting?.value
|
||||
);
|
||||
|
||||
// 如果没有设置记录,返回默认值
|
||||
const baseSettings = settings || {
|
||||
@ -295,6 +323,7 @@ export async function GET() {
|
||||
canvasEnabled: true,
|
||||
realtimeCanvasEnabled: true,
|
||||
realtimeCanvasServiceEnabled: true,
|
||||
storageFailoverConfig,
|
||||
canvasDefaultModelId: null,
|
||||
canvasAutoModeDefaultModelId: null,
|
||||
canvasDetailDefaultModelId: null,
|
||||
@ -338,6 +367,7 @@ export async function GET() {
|
||||
pointsPackages,
|
||||
dulupayConfig,
|
||||
realtimeCanvasServiceEnabled,
|
||||
storageFailoverConfig,
|
||||
});
|
||||
|
||||
// 禁用缓存,确保总是获取最新设置
|
||||
@ -411,6 +441,12 @@ const updateSchema = z.object({
|
||||
pointsSystemConfig: z.unknown().optional(),
|
||||
pointsPackages: z.unknown().optional(),
|
||||
dulupayConfig: z.unknown().optional(),
|
||||
storageFailoverConfig: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
backupStorageConfigId: z.string().max(128).optional(),
|
||||
})
|
||||
.optional(),
|
||||
});
|
||||
|
||||
// PUT /api/site-settings - 更新网站设置(仅管理员)
|
||||
@ -441,6 +477,7 @@ export async function PUT(request: NextRequest) {
|
||||
pointsSystemConfig,
|
||||
pointsPackages,
|
||||
dulupayConfig,
|
||||
storageFailoverConfig,
|
||||
...siteSettingsPayload
|
||||
} = data;
|
||||
|
||||
@ -697,6 +734,26 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (storageFailoverConfig !== undefined) {
|
||||
const normalized = normalizeStorageFailoverConfig(storageFailoverConfig);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: STORAGE_FAILOVER_CONFIG_KEY,
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...updated,
|
||||
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
||||
@ -741,6 +798,10 @@ export async function PUT(request: NextRequest) {
|
||||
dulupayConfig !== undefined
|
||||
? normalizeDulupayConfig(dulupayConfig)
|
||||
: defaultDulupayConfig,
|
||||
storageFailoverConfig:
|
||||
storageFailoverConfig !== undefined
|
||||
? normalizeStorageFailoverConfig(storageFailoverConfig)
|
||||
: normalizeStorageFailoverConfig(undefined),
|
||||
});
|
||||
} else {
|
||||
// 创建新设置
|
||||
@ -1020,6 +1081,26 @@ export async function PUT(request: NextRequest) {
|
||||
});
|
||||
}
|
||||
|
||||
if (storageFailoverConfig !== undefined) {
|
||||
const normalized = normalizeStorageFailoverConfig(storageFailoverConfig);
|
||||
await db
|
||||
.insert(systemSettings)
|
||||
.values({
|
||||
key: STORAGE_FAILOVER_CONFIG_KEY,
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: systemSettings.key,
|
||||
set: {
|
||||
value: normalized,
|
||||
updatedAt: new Date(),
|
||||
updatedBy: session.user.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
...created,
|
||||
homeVariant: homeVariant ?? normalizeHomeVariant(undefined),
|
||||
@ -1064,6 +1145,10 @@ export async function PUT(request: NextRequest) {
|
||||
dulupayConfig !== undefined
|
||||
? normalizeDulupayConfig(dulupayConfig)
|
||||
: defaultDulupayConfig,
|
||||
storageFailoverConfig:
|
||||
storageFailoverConfig !== undefined
|
||||
? normalizeStorageFailoverConfig(storageFailoverConfig)
|
||||
: normalizeStorageFailoverConfig(undefined),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@ -2,7 +2,11 @@ import "server-only";
|
||||
|
||||
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
|
||||
import { put } from "@vercel/blob";
|
||||
import { getDefaultStorageConfig } from "@/lib/db/queries";
|
||||
import {
|
||||
getDefaultStorageConfig,
|
||||
getStorageConfigById,
|
||||
getSystemSetting,
|
||||
} from "@/lib/db/queries";
|
||||
|
||||
type S3UploadConfig = {
|
||||
endpoint: string;
|
||||
@ -14,6 +18,7 @@ type S3UploadConfig = {
|
||||
};
|
||||
|
||||
let lastKnownGoodDbS3Config: S3UploadConfig | null = null;
|
||||
const STORAGE_FAILOVER_CONFIG_KEY = "storage_failover_config";
|
||||
|
||||
// 获取文件的 Content-Type
|
||||
function getContentType(filename: string): string {
|
||||
@ -148,6 +153,68 @@ function buildEnvS3Config(): S3UploadConfig | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeStorageFailoverConfig(value: unknown): {
|
||||
enabled: boolean;
|
||||
backupStorageConfigId: string;
|
||||
} {
|
||||
const raw =
|
||||
value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
return {
|
||||
enabled: raw.enabled === true,
|
||||
backupStorageConfigId: String(raw.backupStorageConfigId || "").trim(),
|
||||
};
|
||||
}
|
||||
|
||||
async function uploadWithBackupStorageConfig(
|
||||
filename: string,
|
||||
buffer: ArrayBuffer,
|
||||
primaryStorageConfigId?: string
|
||||
): Promise<{ url: string; pathname: string; contentType: string } | null> {
|
||||
try {
|
||||
const setting = await getSystemSetting({ key: STORAGE_FAILOVER_CONFIG_KEY });
|
||||
const failover = normalizeStorageFailoverConfig(setting?.value);
|
||||
|
||||
if (!failover.enabled || !failover.backupStorageConfigId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
primaryStorageConfigId &&
|
||||
failover.backupStorageConfigId === primaryStorageConfigId
|
||||
) {
|
||||
console.warn(
|
||||
"[Upload] Backup storage config equals default storage config, skipping failover retry"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const backupStorageConfig = await getStorageConfigById({
|
||||
id: failover.backupStorageConfigId,
|
||||
});
|
||||
const backupS3Config = toDbS3UploadConfig(backupStorageConfig);
|
||||
|
||||
if (!backupS3Config) {
|
||||
console.warn(
|
||||
"[Upload] Backup storage config is invalid, disabled, or not S3 compatible"
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log(
|
||||
`[Upload] Retrying with backup S3 config: ${backupStorageConfig?.name ?? failover.backupStorageConfigId}`
|
||||
);
|
||||
|
||||
const uploaded = await uploadToS3(filename, buffer, backupS3Config);
|
||||
lastKnownGoodDbS3Config = backupS3Config;
|
||||
return uploaded;
|
||||
} catch (error) {
|
||||
console.error("[Upload] Backup storage config retry failed:", error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// 上传文件到存储服务
|
||||
export async function uploadFile(
|
||||
filename: string,
|
||||
@ -156,8 +223,10 @@ export async function uploadFile(
|
||||
console.log(`[Upload] Starting upload for file: ${filename}`);
|
||||
|
||||
let dbS3Config: S3UploadConfig | null = null;
|
||||
let primaryStorageConfigId: string | undefined;
|
||||
try {
|
||||
const storageConfig = await getDefaultStorageConfig();
|
||||
primaryStorageConfigId = storageConfig?.id;
|
||||
dbS3Config = toDbS3UploadConfig(storageConfig);
|
||||
if (dbS3Config) {
|
||||
console.log(`[Upload] Using database S3 config: ${storageConfig?.name}`);
|
||||
@ -170,6 +239,16 @@ export async function uploadFile(
|
||||
console.warn("[Upload] No valid S3 config in database");
|
||||
} catch (error) {
|
||||
console.error("[Upload] Database storage path failed:", error);
|
||||
|
||||
const uploadedFromBackup = await uploadWithBackupStorageConfig(
|
||||
filename,
|
||||
buffer,
|
||||
primaryStorageConfigId
|
||||
);
|
||||
if (uploadedFromBackup) {
|
||||
return uploadedFromBackup;
|
||||
}
|
||||
|
||||
if (lastKnownGoodDbS3Config) {
|
||||
try {
|
||||
console.log("[Upload] Retrying with last known good database S3 config");
|
||||
@ -180,6 +259,17 @@ export async function uploadFile(
|
||||
}
|
||||
}
|
||||
|
||||
if (!dbS3Config) {
|
||||
const uploadedFromBackup = await uploadWithBackupStorageConfig(
|
||||
filename,
|
||||
buffer,
|
||||
primaryStorageConfigId
|
||||
);
|
||||
if (uploadedFromBackup) {
|
||||
return uploadedFromBackup;
|
||||
}
|
||||
}
|
||||
|
||||
const envS3Config = buildEnvS3Config();
|
||||
if (envS3Config) {
|
||||
console.log("[Upload] Using environment variable S3 config");
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user