feat: add storage failover backup bucket support
Some checks failed
Lint / build (20) (push) Has been cancelled
Playwright Tests / test (push) Has been cancelled

This commit is contained in:
Your Name 2026-02-23 11:36:47 +08:00
parent b5e001dfc8
commit 9ce3076438
3 changed files with 295 additions and 2 deletions

View File

@ -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设置 */}

View File

@ -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) {

View File

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