diff --git a/app/(admin)/settings/admin-settings-client.tsx b/app/(admin)/settings/admin-settings-client.tsx index 0410e849e..2cf8c4495 100644 --- a/app/(admin)/settings/admin-settings-client.tsx +++ b/app/(admin)/settings/admin-settings-client.tsx @@ -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() { {/* 存储配置 */} - +
@@ -6566,6 +6603,87 @@ export function AdminSettingsClient() {
+ + + + 存储故障迁移 + + 主存储桶上传失败时,自动切换到备用 MinIO/S3 存储桶。 + + + +
+
+ +

+ 建议仅在主桶不稳定时开启,避免无效重试。 +

+
+ + setSiteSettings((prev) => ({ + ...prev, + storageFailoverConfig: { + ...prev.storageFailoverConfig, + enabled: checked, + }, + })) + } + /> +
+ +
+ + +

+ 仅可选择已启用的 S3 兼容配置作为备用桶。 +

+
+ + {siteSettings.storageFailoverConfig.enabled && + siteSettings.storageFailoverConfig.backupStorageConfigId && + (storageData?.configs || []).some( + (config) => + config.id === siteSettings.storageFailoverConfig.backupStorageConfigId && + config.isDefault + ) && ( +
+ 备用桶不能与默认桶相同,请选择另一条存储配置。 +
+ )} + +
+ +
+
+
{/* 本地AI设置 */} diff --git a/app/api/site-settings/route.ts b/app/api/site-settings/route.ts index 4205702d5..6ba0e8023 100644 --- a/app/api/site-settings/route.ts +++ b/app/api/site-settings/route.ts @@ -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) + : {}; + + 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) { diff --git a/lib/storage/service.ts b/lib/storage/service.ts index 1fac59d45..c40137fe0 100644 --- a/lib/storage/service.ts +++ b/lib/storage/service.ts @@ -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) + : {}; + 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");