Merge pull request #4593 from StarryKira/fix/image-storage-env-unreachable

fix: 异步生图开关配了却不生效(环境变量被静默丢弃 + 迁移到后台开关)
This commit is contained in:
Wesley Liddick
2026-07-20 09:20:15 +08:00
committed by GitHub
18 changed files with 1324 additions and 35 deletions
+49
View File
@@ -60,6 +60,52 @@ export async function testS3Connection(config: BackupS3Config): Promise<TestS3Re
return data
}
// Async image object storage
//
// Shares the S3 client with backups, so `reuse_backup_s3` borrows the endpoint and
// credentials configured above and only keeps its own bucket/prefix.
export interface ImageStorageConfig {
enabled: boolean
reuse_backup_s3: boolean
bucket: string
prefix: string
public_base_url: string
presign_expiry_hours: number
max_download_bytes: number
endpoint: string
region: string
access_key_id: string
secret_access_key?: string
force_path_style: boolean
}
export interface ImageStorageConfigResponse {
config: ImageStorageConfig
secret_configured: boolean
}
export async function getImageStorageConfig(): Promise<ImageStorageConfigResponse> {
const { data } = await apiClient.get<ImageStorageConfigResponse>('/admin/backups/image-storage')
return data
}
export async function updateImageStorageConfig(
config: ImageStorageConfig,
): Promise<ImageStorageConfig> {
const { data } = await apiClient.put<ImageStorageConfig>('/admin/backups/image-storage', config)
return data
}
export async function testImageStorageConnection(
config: ImageStorageConfig,
): Promise<TestS3Response> {
const { data } = await apiClient.post<TestS3Response>(
'/admin/backups/image-storage/test',
config,
)
return data
}
// Schedule
export async function getSchedule(): Promise<BackupScheduleConfig> {
const { data } = await apiClient.get<BackupScheduleConfig>('/admin/backups/schedule')
@@ -106,6 +152,9 @@ export const backupAPI = {
getS3Config,
updateS3Config,
testS3Connection,
getImageStorageConfig,
updateImageStorageConfig,
testImageStorageConnection,
getSchedule,
updateSchedule,
createBackup,
@@ -102,6 +102,19 @@ export default {
testFailed: 'S3 connection test failed',
saved: 'S3 configuration saved'
},
imageStorage: {
title: 'Async image object storage',
description: 'Enables the asynchronous image endpoints and offloads generated images to object storage, keeping only short links in Redis. Shares the S3 client with backups and takes effect on save — no restart needed.',
enabled: 'Enable async image tasks',
reuseBackupS3: 'Reuse the backup S3 configuration above (different bucket/prefix only)',
bucket: 'Bucket',
bucketInherited: 'Leave empty to use the backup bucket',
prefix: 'Key prefix',
publicBaseUrl: 'Public base URL',
publicBaseUrlPlaceholder: 'Leave empty to return presigned links',
presignExpiryHours: 'Presigned link TTL (hours)',
saved: 'Async image object storage saved'
},
schedule: {
title: 'Scheduled Backup',
description: 'Configure automatic scheduled backups',
@@ -102,6 +102,19 @@ export default {
testFailed: 'S3 连接测试失败',
saved: 'S3 配置已保存'
},
imageStorage: {
title: '异步生图对象存储',
description: '开启后,异步生图接口可用,生成结果转存到对象存储,只把短链接写入 Redis。与备份共用同一套 S3 客户端,保存后立即生效,无需重启。',
enabled: '启用异步生图',
reuseBackupS3: '复用上方备份的 S3 配置(只用不同的存储桶/前缀)',
bucket: '存储桶',
bucketInherited: '留空则沿用备份存储桶',
prefix: 'Key 前缀',
publicBaseUrl: '公开访问域名',
publicBaseUrlPlaceholder: '留空则返回预签名临时链接',
presignExpiryHours: '预签名链接有效期(小时)',
saved: '异步生图对象存储配置已保存'
},
schedule: {
title: '定时备份',
description: '配置自动定时备份',
+150 -2
View File
@@ -54,6 +54,81 @@
</div>
</div>
<!-- Async image object storage -->
<div class="card p-6">
<div class="mb-4 flex flex-wrap items-center justify-between gap-3">
<div>
<h3 class="text-base font-semibold text-gray-900 dark:text-white">
{{ t('admin.backup.imageStorage.title') }}
</h3>
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.backup.imageStorage.description') }}
</p>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="imageStorageForm.enabled" type="checkbox" />
<span>{{ t('admin.backup.imageStorage.enabled') }}</span>
</label>
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300">
<input v-model="imageStorageForm.reuse_backup_s3" type="checkbox" />
<span>{{ t('admin.backup.imageStorage.reuseBackupS3') }}</span>
</label>
<div class="mt-3 grid grid-cols-1 gap-3 md:grid-cols-2">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.bucket') }}</label>
<input v-model="imageStorageForm.bucket" class="input w-full" :placeholder="imageStorageForm.reuse_backup_s3 ? t('admin.backup.imageStorage.bucketInherited') : ''" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.prefix') }}</label>
<input v-model="imageStorageForm.prefix" class="input w-full" placeholder="images/" />
</div>
<template v-if="!imageStorageForm.reuse_backup_s3">
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.endpoint') }}</label>
<input v-model="imageStorageForm.endpoint" class="input w-full" placeholder="https://<account_id>.r2.cloudflarestorage.com" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.region') }}</label>
<input v-model="imageStorageForm.region" class="input w-full" placeholder="auto" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.accessKeyId') }}</label>
<input v-model="imageStorageForm.access_key_id" class="input w-full" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.s3.secretAccessKey') }}</label>
<input v-model="imageStorageForm.secret_access_key" type="password" class="input w-full" :placeholder="imageStorageSecretConfigured ? t('admin.backup.s3.secretConfigured') : ''" />
</div>
<label class="inline-flex items-center gap-2 text-sm text-gray-700 dark:text-gray-300 md:col-span-2">
<input v-model="imageStorageForm.force_path_style" type="checkbox" />
<span>{{ t('admin.backup.s3.forcePathStyle') }}</span>
</label>
</template>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.publicBaseUrl') }}</label>
<input v-model="imageStorageForm.public_base_url" class="input w-full" :placeholder="t('admin.backup.imageStorage.publicBaseUrlPlaceholder')" />
</div>
<div>
<label class="mb-1 block text-xs font-medium text-gray-600 dark:text-gray-400">{{ t('admin.backup.imageStorage.presignExpiryHours') }}</label>
<input v-model.number="imageStorageForm.presign_expiry_hours" type="number" min="1" class="input w-full" />
</div>
</div>
<div class="mt-4 flex flex-wrap gap-2">
<button type="button" class="btn btn-secondary btn-sm" :disabled="testingImageStorage" @click="testImageStorage">
{{ testingImageStorage ? t('common.loading') : t('admin.backup.s3.testConnection') }}
</button>
<button type="button" class="btn btn-primary btn-sm" :disabled="savingImageStorage" @click="saveImageStorageConfig">
{{ savingImageStorage ? t('common.loading') : t('common.save') }}
</button>
</div>
</div>
<!-- Schedule Config -->
<div class="card p-6">
<div class="mb-4">
@@ -284,7 +359,12 @@ import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
import type { BackupS3Config, BackupScheduleConfig, BackupRecord } from '@/api/admin/backup'
import type {
BackupS3Config,
BackupScheduleConfig,
BackupRecord,
ImageStorageConfig,
} from '@/api/admin/backup'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
import TotpStepUpDialog from '@/components/auth/TotpStepUpDialog.vue'
@@ -317,6 +397,26 @@ const s3SecretConfigured = ref(false)
const savingS3 = ref(false)
const testingS3 = ref(false)
// Async image object storage. Shares the S3 client with backups, so the default is
// to reuse the credentials configured above and only differ by prefix.
const imageStorageForm = ref<ImageStorageConfig>({
enabled: false,
reuse_backup_s3: true,
bucket: '',
prefix: 'images/',
public_base_url: '',
presign_expiry_hours: 24,
max_download_bytes: 33554432,
endpoint: '',
region: 'auto',
access_key_id: '',
secret_access_key: '',
force_path_style: false,
})
const imageStorageSecretConfigured = ref(false)
const savingImageStorage = ref(false)
const testingImageStorage = ref(false)
// Schedule config
const scheduleForm = ref<BackupScheduleConfig>({
enabled: false,
@@ -485,6 +585,54 @@ async function saveS3Config() {
}
}
async function loadImageStorageConfig() {
try {
const { config, secret_configured } = await adminAPI.backup.getImageStorageConfig()
imageStorageForm.value = {
...config,
prefix: config.prefix || 'images/',
region: config.region || 'auto',
secret_access_key: '',
}
imageStorageSecretConfigured.value = secret_configured
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
}
}
async function saveImageStorageConfig() {
savingImageStorage.value = true
try {
await backupStepUp.run(() => adminAPI.backup.updateImageStorageConfig(imageStorageForm.value))
appStore.showSuccess(t('admin.backup.imageStorage.saved'))
await loadImageStorageConfig()
} catch (error) {
if (isStepUpCancelled(error)) {
savingImageStorage.value = false
return
}
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
savingImageStorage.value = false
}
}
async function testImageStorage() {
testingImageStorage.value = true
try {
const result = await adminAPI.backup.testImageStorageConnection(imageStorageForm.value)
if (result.ok) {
appStore.showSuccess(result.message || t('admin.backup.s3.testSuccess'))
} else {
appStore.showError(result.message || t('admin.backup.s3.testFailed'))
}
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
testingImageStorage.value = false
}
}
async function testS3() {
testingS3.value = true
try {
@@ -642,7 +790,7 @@ function formatDate(value?: string): string {
onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
await Promise.all([loadS3Config(), loadSchedule(), loadBackups()])
await Promise.all([loadS3Config(), loadImageStorageConfig(), loadSchedule(), loadBackups()])
// 如果有正在 running 的备份,恢复轮询
const runningBackup = backups.value.find(r => r.status === 'running')