mirror of
https://github.com/fawney19/Aether.git
synced 2026-10-05 00:47:48 +08:00
feat(admin): add configurable S3 backups
This commit is contained in:
@@ -46,6 +46,9 @@
|
||||
@file-select="handleDataFileSelect"
|
||||
/>
|
||||
|
||||
<!-- S3 备份 -->
|
||||
<S3BackupSection id="section-s3-backup" />
|
||||
|
||||
<!-- 网络代理 -->
|
||||
<ProxyConfigSection
|
||||
id="section-proxy"
|
||||
@@ -267,6 +270,7 @@ import { useScheduledTasks } from './system-settings/composables/useScheduledTas
|
||||
// Section components
|
||||
import SiteInfoSection from './system-settings/SiteInfoSection.vue'
|
||||
import DataManagementSection from './system-settings/DataManagementSection.vue'
|
||||
import S3BackupSection from './system-settings/S3BackupSection.vue'
|
||||
import ProxyConfigSection from './system-settings/ProxyConfigSection.vue'
|
||||
import BasicConfigSection from './system-settings/BasicConfigSection.vue'
|
||||
import RequestLogSection from './system-settings/RequestLogSection.vue'
|
||||
@@ -285,6 +289,7 @@ const proxyNodesStore = useProxyNodesStore()
|
||||
const tocItems = [
|
||||
{ id: 'section-site-info', label: '站点信息' },
|
||||
{ id: 'section-data-mgmt', label: '数据管理' },
|
||||
{ id: 'section-s3-backup', label: 'S3 备份' },
|
||||
{ id: 'section-proxy', label: '网络代理' },
|
||||
{ id: 'section-basic', label: '基础配置' },
|
||||
{ id: 'section-request-log', label: '请求记录' },
|
||||
|
||||
@@ -0,0 +1,476 @@
|
||||
<template>
|
||||
<CardSection
|
||||
title="S3 备份"
|
||||
description="按导出范围备份到 S3-compatible 存储"
|
||||
>
|
||||
<template #actions>
|
||||
<div class="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
:disabled="backupRunDisabled"
|
||||
@click="backup.runS3BackupNow"
|
||||
>
|
||||
<Play class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ backup.running.value ? '提交中...' : '立即备份' }}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
:disabled="backup.saving.value || !backup.hasChanges.value"
|
||||
@click="backup.saveS3BackupConfig"
|
||||
>
|
||||
<Save class="w-3.5 h-3.5 mr-1.5" />
|
||||
{{ backup.saving.value ? '保存中...' : '保存' }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div class="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<div class="flex items-center gap-3 min-w-0">
|
||||
<div class="w-9 h-9 rounded-lg bg-primary/10 text-primary flex items-center justify-center shrink-0">
|
||||
<CloudUpload class="w-4.5 h-4.5" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="text-sm font-medium">
|
||||
自动备份
|
||||
</h4>
|
||||
<p class="text-xs text-muted-foreground mt-0.5">
|
||||
{{ backup.config.value.enabled ? '已启用' : '未启用' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
:model-value="backup.config.value.enabled"
|
||||
@update:model-value="backup.config.value.enabled = $event"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label
|
||||
for="backup-scope"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
备份范围
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="backup.config.value.scope"
|
||||
@update:model-value="setScope"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-scope"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="config">
|
||||
配置数据
|
||||
</SelectItem>
|
||||
<SelectItem value="users">
|
||||
用户数据
|
||||
</SelectItem>
|
||||
<SelectItem value="data">
|
||||
完整备份
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-bucket"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Bucket
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-bucket"
|
||||
:model-value="backup.config.value.bucket"
|
||||
class="mt-1"
|
||||
placeholder="aether-backups"
|
||||
@update:model-value="backup.config.value.bucket = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-endpoint"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Endpoint
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-endpoint"
|
||||
:model-value="backup.config.value.endpoint"
|
||||
class="mt-1"
|
||||
placeholder="https://s3.example.com"
|
||||
@update:model-value="backup.config.value.endpoint = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-access-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Access Key ID
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-access-key"
|
||||
:model-value="backup.config.value.accessKeyId"
|
||||
class="mt-1"
|
||||
autocomplete="off"
|
||||
@update:model-value="backup.config.value.accessKeyId = String($event)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="flex items-center justify-between gap-2">
|
||||
<Label
|
||||
for="backup-secret-key"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Secret Access Key
|
||||
</Label>
|
||||
<button
|
||||
v-if="backup.config.value.secretAccessKeyIsSet"
|
||||
type="button"
|
||||
class="text-xs text-muted-foreground hover:text-foreground"
|
||||
:disabled="backup.saving.value"
|
||||
@click="backup.clearS3SecretAccessKey"
|
||||
>
|
||||
清除
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative mt-1">
|
||||
<Input
|
||||
id="backup-secret-key"
|
||||
:model-value="backup.config.value.secretAccessKey"
|
||||
masked
|
||||
disable-autofill
|
||||
autocomplete="new-password"
|
||||
:placeholder="backup.config.value.secretAccessKeyIsSet ? '已配置,留空不变' : ''"
|
||||
@update:model-value="backup.config.value.secretAccessKey = String($event)"
|
||||
/>
|
||||
<KeyRound
|
||||
v-if="backup.config.value.secretAccessKeyIsSet && !backup.config.value.secretAccessKey"
|
||||
class="absolute right-10 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground pointer-events-none"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Label
|
||||
for="backup-retention"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
最多保留备份数
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-retention"
|
||||
:model-value="backup.config.value.retentionCount"
|
||||
type="number"
|
||||
min="1"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('retentionCount', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-[1fr_1fr] gap-4">
|
||||
<div>
|
||||
<Label
|
||||
for="backup-interval"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
周期间隔
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-interval"
|
||||
:model-value="backup.config.value.scheduleInterval"
|
||||
type="number"
|
||||
min="1"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleInterval', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-unit"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
周期单位
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="backup.config.value.scheduleUnit"
|
||||
@update:model-value="setScheduleUnit"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-unit"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="hours">
|
||||
小时
|
||||
</SelectItem>
|
||||
<SelectItem value="days">
|
||||
天
|
||||
</SelectItem>
|
||||
<SelectItem value="weeks">
|
||||
周
|
||||
</SelectItem>
|
||||
<SelectItem value="months">
|
||||
月
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
class="px-0"
|
||||
@click="backup.advancedOpen.value = !backup.advancedOpen.value"
|
||||
>
|
||||
<ChevronDown
|
||||
v-if="backup.advancedOpen.value"
|
||||
class="w-3.5 h-3.5 mr-1.5"
|
||||
/>
|
||||
<ChevronRight
|
||||
v-else
|
||||
class="w-3.5 h-3.5 mr-1.5"
|
||||
/>
|
||||
高级选项
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="backup.advancedOpen.value"
|
||||
class="mt-3 grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-region"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Region
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-region"
|
||||
:model-value="backup.config.value.region"
|
||||
class="mt-1"
|
||||
placeholder="auto"
|
||||
@update:model-value="backup.config.value.region = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-prefix"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
Prefix
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-prefix"
|
||||
:model-value="backup.config.value.prefix"
|
||||
class="mt-1"
|
||||
placeholder="aether/backups/"
|
||||
@update:model-value="backup.config.value.prefix = String($event)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-hour"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
执行小时
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-hour"
|
||||
:model-value="backup.config.value.scheduleHour"
|
||||
type="number"
|
||||
min="0"
|
||||
max="23"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleHour', $event, 0)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-minute"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
执行分钟
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-minute"
|
||||
:model-value="backup.config.value.scheduleMinute"
|
||||
type="number"
|
||||
min="0"
|
||||
max="59"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleMinute', $event, 0)"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="backup.config.value.scheduleUnit === 'weeks'">
|
||||
<Label
|
||||
for="backup-weekday"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
星期
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="String(backup.config.value.scheduleWeekday)"
|
||||
@update:model-value="setNumber('scheduleWeekday', $event, 1)"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-weekday"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem
|
||||
v-for="item in weekdays"
|
||||
:key="item.value"
|
||||
:value="String(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div v-if="backup.config.value.scheduleUnit === 'months'">
|
||||
<Label
|
||||
for="backup-month-day"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
每月日期
|
||||
</Label>
|
||||
<Input
|
||||
id="backup-month-day"
|
||||
:model-value="backup.config.value.scheduleMonthDay"
|
||||
type="number"
|
||||
min="1"
|
||||
max="31"
|
||||
class="mt-1"
|
||||
@update:model-value="setNumber('scheduleMonthDay', $event, 1)"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center justify-between rounded-lg border border-border p-4">
|
||||
<span class="text-sm font-medium">Path Style</span>
|
||||
<Switch
|
||||
:model-value="backup.config.value.pathStyle"
|
||||
@update:model-value="backup.config.value.pathStyle = $event"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label
|
||||
for="backup-compression"
|
||||
class="block text-sm font-medium"
|
||||
>
|
||||
压缩格式
|
||||
</Label>
|
||||
<Select
|
||||
:model-value="backup.config.value.compression"
|
||||
@update:model-value="backup.config.value.compression = $event"
|
||||
>
|
||||
<SelectTrigger
|
||||
id="backup-compression"
|
||||
class="mt-1"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="zstd">
|
||||
zstd
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardSection>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted } from 'vue'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
CloudUpload,
|
||||
KeyRound,
|
||||
Play,
|
||||
Save,
|
||||
} from 'lucide-vue-next'
|
||||
import Button from '@/components/ui/button.vue'
|
||||
import Input from '@/components/ui/input.vue'
|
||||
import Label from '@/components/ui/label.vue'
|
||||
import Select from '@/components/ui/select.vue'
|
||||
import SelectContent from '@/components/ui/select-content.vue'
|
||||
import SelectItem from '@/components/ui/select-item.vue'
|
||||
import SelectTrigger from '@/components/ui/select-trigger.vue'
|
||||
import SelectValue from '@/components/ui/select-value.vue'
|
||||
import Switch from '@/components/ui/switch.vue'
|
||||
import { CardSection } from '@/components/layout'
|
||||
import {
|
||||
useS3BackupConfig,
|
||||
type S3BackupConfig,
|
||||
type S3BackupScheduleUnit,
|
||||
} from './composables/useS3BackupConfig'
|
||||
import type { S3BackupScope } from '@/api/admin'
|
||||
|
||||
const backup = useS3BackupConfig()
|
||||
|
||||
const backupRunDisabled = computed(() =>
|
||||
backup.running.value ||
|
||||
backup.saving.value ||
|
||||
backup.loading.value ||
|
||||
backup.hasChanges.value
|
||||
)
|
||||
|
||||
const weekdays = [
|
||||
{ value: 1, label: '周一' },
|
||||
{ value: 2, label: '周二' },
|
||||
{ value: 3, label: '周三' },
|
||||
{ value: 4, label: '周四' },
|
||||
{ value: 5, label: '周五' },
|
||||
{ value: 6, label: '周六' },
|
||||
{ value: 7, label: '周日' },
|
||||
]
|
||||
|
||||
function setScope(value: string) {
|
||||
if (value === 'config' || value === 'users' || value === 'data') {
|
||||
backup.config.value.scope = value as S3BackupScope
|
||||
}
|
||||
}
|
||||
|
||||
function setScheduleUnit(value: string) {
|
||||
if (value === 'hours' || value === 'days' || value === 'weeks' || value === 'months') {
|
||||
backup.config.value.scheduleUnit = value as S3BackupScheduleUnit
|
||||
}
|
||||
}
|
||||
|
||||
function setNumber(field: keyof Pick<
|
||||
S3BackupConfig,
|
||||
| 'retentionCount'
|
||||
| 'scheduleInterval'
|
||||
| 'scheduleMinute'
|
||||
| 'scheduleHour'
|
||||
| 'scheduleWeekday'
|
||||
| 'scheduleMonthDay'
|
||||
>, value: string | number, fallback: number) {
|
||||
const next = Number(value)
|
||||
backup.config.value[field] = Number.isFinite(next) ? next : fallback
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
backup.loadS3BackupConfig()
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,108 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { getSystemConfigMock, updateSystemConfigMock, runS3BackupMock, errorMock, successMock } =
|
||||
vi.hoisted(() => ({
|
||||
getSystemConfigMock: vi.fn(),
|
||||
updateSystemConfigMock: vi.fn(),
|
||||
runS3BackupMock: vi.fn(),
|
||||
errorMock: vi.fn(),
|
||||
successMock: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/admin', () => ({
|
||||
adminApi: {
|
||||
getSystemConfig: getSystemConfigMock,
|
||||
updateSystemConfig: updateSystemConfigMock,
|
||||
runS3Backup: runS3BackupMock,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useToast', () => ({
|
||||
useToast: () => ({
|
||||
error: errorMock,
|
||||
success: successMock,
|
||||
}),
|
||||
}))
|
||||
|
||||
import { useS3BackupConfig } from '../composables/useS3BackupConfig'
|
||||
|
||||
describe('useS3BackupConfig', () => {
|
||||
beforeEach(() => {
|
||||
getSystemConfigMock.mockReset()
|
||||
updateSystemConfigMock.mockReset()
|
||||
runS3BackupMock.mockReset()
|
||||
errorMock.mockReset()
|
||||
successMock.mockReset()
|
||||
})
|
||||
|
||||
it('loads write-only secret as configured without exposing the value', async () => {
|
||||
getSystemConfigMock.mockImplementation(async (key: string) => {
|
||||
if (key === 'backup_s3_secret_access_key') {
|
||||
return { key, value: null, is_set: true }
|
||||
}
|
||||
if (key === 'backup_s3_scope') {
|
||||
return { key, value: 'data' }
|
||||
}
|
||||
return { key, value: null }
|
||||
})
|
||||
|
||||
const backup = useS3BackupConfig()
|
||||
await backup.loadS3BackupConfig()
|
||||
|
||||
expect(backup.config.value.scope).toBe('data')
|
||||
expect(backup.config.value.secretAccessKey).toBe('')
|
||||
expect(backup.config.value.secretAccessKeyIsSet).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps an existing secret when saving with an empty secret field', async () => {
|
||||
getSystemConfigMock.mockImplementation(async (key: string) => {
|
||||
if (key === 'backup_s3_secret_access_key') {
|
||||
return { key, value: null, is_set: true }
|
||||
}
|
||||
return { key, value: null }
|
||||
})
|
||||
updateSystemConfigMock.mockResolvedValue({})
|
||||
|
||||
const backup = useS3BackupConfig()
|
||||
await backup.loadS3BackupConfig()
|
||||
backup.config.value.bucket = 'aether-backups'
|
||||
await backup.saveS3BackupConfig()
|
||||
|
||||
const savedKeys = updateSystemConfigMock.mock.calls.map(([key]) => key)
|
||||
expect(savedKeys).toContain('backup_s3_bucket')
|
||||
expect(savedKeys).not.toContain('backup_s3_secret_access_key')
|
||||
expect(backup.config.value.secretAccessKeyIsSet).toBe(true)
|
||||
})
|
||||
|
||||
it('reloads server state when saving fails before writing a new secret', async () => {
|
||||
let loadRound = 0
|
||||
getSystemConfigMock.mockImplementation(async (key: string) => {
|
||||
if (key === 'backup_s3_secret_access_key') {
|
||||
return { key, value: null, is_set: false }
|
||||
}
|
||||
if (key === 'backup_s3_bucket') {
|
||||
return { key, value: loadRound === 0 ? 'old-bucket' : 'server-bucket' }
|
||||
}
|
||||
return { key, value: null }
|
||||
})
|
||||
updateSystemConfigMock.mockImplementation(async (key: string) => {
|
||||
if (key === 'backup_s3_bucket') {
|
||||
loadRound += 1
|
||||
throw new Error('save failed')
|
||||
}
|
||||
return {}
|
||||
})
|
||||
|
||||
const backup = useS3BackupConfig()
|
||||
await backup.loadS3BackupConfig()
|
||||
backup.config.value.bucket = 'new-bucket'
|
||||
backup.config.value.secretAccessKey = 'new-secret'
|
||||
await backup.saveS3BackupConfig()
|
||||
|
||||
const savedKeys = updateSystemConfigMock.mock.calls.map(([key]) => key)
|
||||
expect(savedKeys).toContain('backup_s3_bucket')
|
||||
expect(savedKeys).not.toContain('backup_s3_secret_access_key')
|
||||
expect(backup.config.value.bucket).toBe('server-bucket')
|
||||
expect(backup.config.value.secretAccessKey).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import { adminApi, type S3BackupScope } from '@/api/admin'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { parseApiError } from '@/utils/errorParser'
|
||||
import { log } from '@/utils/logger'
|
||||
|
||||
export type S3BackupScheduleUnit = 'hours' | 'days' | 'weeks' | 'months'
|
||||
|
||||
export interface S3BackupConfig {
|
||||
enabled: boolean
|
||||
scope: S3BackupScope
|
||||
endpoint: string
|
||||
region: string
|
||||
bucket: string
|
||||
prefix: string
|
||||
accessKeyId: string
|
||||
secretAccessKey: string
|
||||
secretAccessKeyIsSet: boolean
|
||||
pathStyle: boolean
|
||||
compression: string
|
||||
scheduleUnit: S3BackupScheduleUnit
|
||||
scheduleInterval: number
|
||||
scheduleMinute: number
|
||||
scheduleHour: number
|
||||
scheduleWeekday: number
|
||||
scheduleMonthDay: number
|
||||
retentionCount: number
|
||||
}
|
||||
|
||||
type ConfigField = Exclude<keyof S3BackupConfig, 'secretAccessKeyIsSet'>
|
||||
|
||||
const CONFIG_KEY_BY_FIELD: Record<ConfigField, string> = {
|
||||
enabled: 'backup_s3_enabled',
|
||||
scope: 'backup_s3_scope',
|
||||
endpoint: 'backup_s3_endpoint',
|
||||
region: 'backup_s3_region',
|
||||
bucket: 'backup_s3_bucket',
|
||||
prefix: 'backup_s3_prefix',
|
||||
accessKeyId: 'backup_s3_access_key_id',
|
||||
secretAccessKey: 'backup_s3_secret_access_key',
|
||||
pathStyle: 'backup_s3_path_style',
|
||||
compression: 'backup_s3_compression',
|
||||
scheduleUnit: 'backup_s3_schedule_unit',
|
||||
scheduleInterval: 'backup_s3_schedule_interval',
|
||||
scheduleMinute: 'backup_s3_schedule_minute',
|
||||
scheduleHour: 'backup_s3_schedule_hour',
|
||||
scheduleWeekday: 'backup_s3_schedule_weekday',
|
||||
scheduleMonthDay: 'backup_s3_schedule_month_day',
|
||||
retentionCount: 'backup_s3_retention_count',
|
||||
}
|
||||
|
||||
const FIELD_BY_CONFIG_KEY = Object.fromEntries(
|
||||
Object.entries(CONFIG_KEY_BY_FIELD).map(([field, key]) => [key, field])
|
||||
) as Record<string, ConfigField>
|
||||
|
||||
const CONFIG_KEYS = Object.values(CONFIG_KEY_BY_FIELD)
|
||||
const VALID_SCOPES: S3BackupScope[] = ['config', 'users', 'data']
|
||||
const VALID_UNITS: S3BackupScheduleUnit[] = ['hours', 'days', 'weeks', 'months']
|
||||
|
||||
function defaultS3BackupConfig(): S3BackupConfig {
|
||||
return {
|
||||
enabled: false,
|
||||
scope: 'data',
|
||||
endpoint: '',
|
||||
region: 'auto',
|
||||
bucket: '',
|
||||
prefix: 'aether/backups/',
|
||||
accessKeyId: '',
|
||||
secretAccessKey: '',
|
||||
secretAccessKeyIsSet: false,
|
||||
pathStyle: true,
|
||||
compression: 'zstd',
|
||||
scheduleUnit: 'days',
|
||||
scheduleInterval: 1,
|
||||
scheduleMinute: 0,
|
||||
scheduleHour: 3,
|
||||
scheduleWeekday: 1,
|
||||
scheduleMonthDay: 1,
|
||||
retentionCount: 7,
|
||||
}
|
||||
}
|
||||
|
||||
function stringValue(value: unknown, fallback = ''): string {
|
||||
return typeof value === 'string' ? value : fallback
|
||||
}
|
||||
|
||||
function numberValue(value: unknown, fallback: number): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) return value
|
||||
if (typeof value === 'string' && value.trim() !== '') {
|
||||
const parsed = Number(value)
|
||||
if (Number.isFinite(parsed)) return parsed
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
function booleanValue(value: unknown, fallback: boolean): boolean {
|
||||
if (typeof value === 'boolean') return value
|
||||
if (value === 'true') return true
|
||||
if (value === 'false') return false
|
||||
return fallback
|
||||
}
|
||||
|
||||
function scopeValue(value: unknown, fallback: S3BackupScope): S3BackupScope {
|
||||
return typeof value === 'string' && VALID_SCOPES.includes(value as S3BackupScope)
|
||||
? value as S3BackupScope
|
||||
: fallback
|
||||
}
|
||||
|
||||
function scheduleUnitValue(
|
||||
value: unknown,
|
||||
fallback: S3BackupScheduleUnit,
|
||||
): S3BackupScheduleUnit {
|
||||
return typeof value === 'string' && VALID_UNITS.includes(value as S3BackupScheduleUnit)
|
||||
? value as S3BackupScheduleUnit
|
||||
: fallback
|
||||
}
|
||||
|
||||
function cloneConfig(value: S3BackupConfig): S3BackupConfig {
|
||||
return JSON.parse(JSON.stringify(value)) as S3BackupConfig
|
||||
}
|
||||
|
||||
export function useS3BackupConfig() {
|
||||
const { success, error } = useToast()
|
||||
const config = ref<S3BackupConfig>(defaultS3BackupConfig())
|
||||
const originalConfig = ref<S3BackupConfig | null>(null)
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const running = ref(false)
|
||||
const advancedOpen = ref(false)
|
||||
|
||||
const hasChanges = computed(() => {
|
||||
if (!originalConfig.value) return false
|
||||
return JSON.stringify({
|
||||
...config.value,
|
||||
secretAccessKey: config.value.secretAccessKey.trim(),
|
||||
}) !== JSON.stringify({
|
||||
...originalConfig.value,
|
||||
secretAccessKey: '',
|
||||
})
|
||||
})
|
||||
|
||||
async function loadS3BackupConfig() {
|
||||
loading.value = true
|
||||
try {
|
||||
const next = defaultS3BackupConfig()
|
||||
await Promise.all(CONFIG_KEYS.map(async (key) => {
|
||||
try {
|
||||
const response = await adminApi.getSystemConfig(key)
|
||||
const field = FIELD_BY_CONFIG_KEY[key]
|
||||
if (field === 'secretAccessKey') {
|
||||
next.secretAccessKey = ''
|
||||
next.secretAccessKeyIsSet = !!response.is_set
|
||||
return
|
||||
}
|
||||
if (response.value === null || response.value === undefined) return
|
||||
if (field === 'enabled' || field === 'pathStyle') {
|
||||
next[field] = booleanValue(response.value, next[field])
|
||||
} else if (
|
||||
field === 'scheduleInterval' ||
|
||||
field === 'scheduleMinute' ||
|
||||
field === 'scheduleHour' ||
|
||||
field === 'scheduleWeekday' ||
|
||||
field === 'scheduleMonthDay' ||
|
||||
field === 'retentionCount'
|
||||
) {
|
||||
next[field] = numberValue(response.value, next[field])
|
||||
} else if (field === 'scope') {
|
||||
next.scope = scopeValue(response.value, next.scope)
|
||||
} else if (field === 'scheduleUnit') {
|
||||
next.scheduleUnit = scheduleUnitValue(response.value, next.scheduleUnit)
|
||||
} else {
|
||||
next[field] = stringValue(response.value, next[field] as string)
|
||||
}
|
||||
} catch {
|
||||
// 单个配置缺失时使用默认值
|
||||
}
|
||||
}))
|
||||
config.value = next
|
||||
originalConfig.value = cloneConfig(next)
|
||||
} catch (err) {
|
||||
error('加载 S3 备份配置失败')
|
||||
log.error('加载 S3 备份配置失败:', err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveS3BackupConfig() {
|
||||
saving.value = true
|
||||
try {
|
||||
const entries: Array<{ key: string; value: unknown; description: string }> = [
|
||||
{ key: 'backup_s3_enabled', value: config.value.enabled, description: 'S3 自动备份开关' },
|
||||
{ key: 'backup_s3_scope', value: config.value.scope, description: 'S3 备份范围' },
|
||||
{ key: 'backup_s3_endpoint', value: config.value.endpoint.trim() || null, description: 'S3 Endpoint' },
|
||||
{ key: 'backup_s3_region', value: config.value.region.trim() || 'auto', description: 'S3 Region' },
|
||||
{ key: 'backup_s3_bucket', value: config.value.bucket.trim() || null, description: 'S3 Bucket' },
|
||||
{ key: 'backup_s3_prefix', value: config.value.prefix.trim() || 'aether/backups/', description: 'S3 备份前缀' },
|
||||
{ key: 'backup_s3_access_key_id', value: config.value.accessKeyId.trim() || null, description: 'S3 Access Key ID' },
|
||||
{ key: 'backup_s3_path_style', value: config.value.pathStyle, description: 'S3 Path Style' },
|
||||
{ key: 'backup_s3_compression', value: config.value.compression || 'zstd', description: 'S3 备份压缩格式' },
|
||||
{ key: 'backup_s3_schedule_unit', value: config.value.scheduleUnit, description: 'S3 备份周期单位' },
|
||||
{ key: 'backup_s3_schedule_interval', value: Math.max(1, Math.floor(config.value.scheduleInterval || 1)), description: 'S3 备份周期间隔' },
|
||||
{ key: 'backup_s3_schedule_minute', value: Math.min(59, Math.max(0, Math.floor(config.value.scheduleMinute || 0))), description: 'S3 备份分钟' },
|
||||
{ key: 'backup_s3_schedule_hour', value: Math.min(23, Math.max(0, Math.floor(config.value.scheduleHour || 0))), description: 'S3 备份小时' },
|
||||
{ key: 'backup_s3_schedule_weekday', value: Math.min(7, Math.max(1, Math.floor(config.value.scheduleWeekday || 1))), description: 'S3 备份星期' },
|
||||
{ key: 'backup_s3_schedule_month_day', value: Math.min(31, Math.max(1, Math.floor(config.value.scheduleMonthDay || 1))), description: 'S3 备份月日' },
|
||||
{ key: 'backup_s3_retention_count', value: Math.max(1, Math.floor(config.value.retentionCount || 1)), description: 'S3 备份保留份数' },
|
||||
]
|
||||
const secret = config.value.secretAccessKey.trim()
|
||||
if (secret) {
|
||||
entries.push({
|
||||
key: 'backup_s3_secret_access_key',
|
||||
value: secret,
|
||||
description: 'S3 Secret Access Key',
|
||||
})
|
||||
}
|
||||
for (const item of entries) {
|
||||
await adminApi.updateSystemConfig(item.key, item.value, item.description)
|
||||
}
|
||||
if (secret) {
|
||||
config.value.secretAccessKey = ''
|
||||
config.value.secretAccessKeyIsSet = true
|
||||
}
|
||||
originalConfig.value = cloneConfig(config.value)
|
||||
success('S3 备份配置已保存')
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '保存 S3 备份配置失败'))
|
||||
log.error('保存 S3 备份配置失败:', err)
|
||||
await loadS3BackupConfig()
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function clearS3SecretAccessKey() {
|
||||
saving.value = true
|
||||
try {
|
||||
await adminApi.updateSystemConfig(
|
||||
'backup_s3_secret_access_key',
|
||||
'',
|
||||
'S3 Secret Access Key',
|
||||
)
|
||||
config.value.secretAccessKey = ''
|
||||
config.value.secretAccessKeyIsSet = false
|
||||
if (originalConfig.value) {
|
||||
originalConfig.value.secretAccessKey = ''
|
||||
originalConfig.value.secretAccessKeyIsSet = false
|
||||
}
|
||||
success('S3 访问密钥已清除')
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '清除 S3 访问密钥失败'))
|
||||
log.error('清除 S3 访问密钥失败:', err)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function runS3BackupNow() {
|
||||
running.value = true
|
||||
try {
|
||||
const response = await adminApi.runS3Backup()
|
||||
success(response.message || 'S3 备份任务已提交')
|
||||
} catch (err) {
|
||||
error(parseApiError(err, '提交 S3 备份任务失败'))
|
||||
log.error('提交 S3 备份任务失败:', err)
|
||||
} finally {
|
||||
running.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
loading,
|
||||
saving,
|
||||
running,
|
||||
advancedOpen,
|
||||
hasChanges,
|
||||
loadS3BackupConfig,
|
||||
saveS3BackupConfig,
|
||||
clearS3SecretAccessKey,
|
||||
runS3BackupNow,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user