feat(admin): expose s3 backup as module

This commit is contained in:
fawney19
2026-05-27 00:30:37 +08:00
parent 42693c2c52
commit 614c999871
11 changed files with 193 additions and 36 deletions
+1
View File
@@ -6,4 +6,5 @@ pub(crate) mod store;
pub(crate) mod task;
pub(crate) mod worker;
pub(crate) const S3_BACKUP_ENABLED_KEY: &str = "backup_s3_enabled";
pub(crate) const S3_BACKUP_LAST_SLOT_KEY: &str = "backup_s3_last_slot";
@@ -1,3 +1,4 @@
use crate::backup::config::S3BackupConfig;
use crate::bark_push::bark_push_configured;
use crate::handlers::admin::request::AdminAppState;
use crate::handlers::shared::{module_available_from_env, system_config_bool};
@@ -121,6 +122,18 @@ pub(crate) const ADMIN_MODULE_DEFINITIONS: &[AdminModuleDefinition] = &[
admin_menu_group: None,
admin_menu_order: 59,
},
AdminModuleDefinition {
name: "s3_backup",
display_name: "S3 备份",
description: "将配置、用户或完整数据定期备份到 S3-compatible 对象存储",
category: "integration",
env_key: "S3_BACKUP_AVAILABLE",
default_available: true,
admin_route: Some("/admin/modules/s3-backup"),
admin_menu_icon: Some("CloudUpload"),
admin_menu_group: None,
admin_menu_order: 60,
},
AdminModuleDefinition {
name: "gemini_files",
display_name: "文件缓存",
@@ -183,6 +196,7 @@ pub(crate) struct AdminModuleRuntimeState {
important_notification_configured: bool,
server_chan_push_configured: bool,
bark_push_configured: bool,
s3_backup_configured: bool,
}
pub(crate) fn admin_module_by_name(name: &str) -> Option<&'static AdminModuleDefinition> {
@@ -209,6 +223,8 @@ pub(crate) fn admin_module_enabled_config_key(module: &AdminModuleDefinition) ->
ENABLE_MODEL_DIRECTIVES_CONFIG_KEY.to_string()
} else if module.name == "important_notification" {
IMPORTANT_NOTIFICATION_ENABLED_KEY.to_string()
} else if module.name == "s3_backup" {
crate::backup::S3_BACKUP_ENABLED_KEY.to_string()
} else {
format!("module.{}.enabled", module.name)
}
@@ -272,6 +288,7 @@ pub(crate) async fn build_admin_module_runtime_state(
let notification_configured = important_notification_configured(state.app()).await?;
let server_chan_configured = server_chan_push_configured(state.app()).await?;
let bark_configured = bark_push_configured(state.app()).await?;
let backup_configured = s3_backup_configured(state.app()).await;
Ok(AdminModuleRuntimeState {
oauth_providers,
@@ -280,9 +297,21 @@ pub(crate) async fn build_admin_module_runtime_state(
important_notification_configured: notification_configured,
server_chan_push_configured: server_chan_configured,
bark_push_configured: bark_configured,
s3_backup_configured: backup_configured,
})
}
async fn s3_backup_configured(app: &crate::AppState) -> bool {
let Ok(mut values) = crate::backup::task::load_s3_backup_config_values(app).await else {
return false;
};
values.insert(
crate::backup::S3_BACKUP_ENABLED_KEY.to_string(),
json!(true),
);
S3BackupConfig::from_json_map(&values).is_ok()
}
pub(crate) fn build_admin_module_validation_result(
module: &AdminModuleDefinition,
runtime: &AdminModuleRuntimeState,
@@ -295,6 +324,7 @@ pub(crate) fn build_admin_module_validation_result(
runtime.important_notification_configured,
runtime.server_chan_push_configured,
runtime.bark_push_configured,
runtime.s3_backup_configured,
)
}
@@ -897,6 +897,15 @@ async fn gateway_handles_admin_modules_status_locally_with_trusted_admin_princip
);
assert_eq!(payload["bark_push"]["display_name"], "Bark 推送");
assert_eq!(payload["bark_push"]["admin_route"], "/admin/modules/bark");
assert_eq!(payload["s3_backup"]["display_name"], "S3 备份");
assert_eq!(
payload["s3_backup"]["admin_route"],
"/admin/modules/s3-backup"
);
assert_eq!(
payload["s3_backup"]["admin_menu_group"],
serde_json::Value::Null
);
assert_eq!(*upstream_hits.lock().expect("mutex should lock"), 0);
gateway_handle.abort();
+10 -1
View File
@@ -1292,6 +1292,7 @@ pub fn build_admin_module_validation_result(
important_notification_configured: bool,
server_chan_push_configured: bool,
bark_push_configured: bool,
s3_backup_configured: bool,
) -> (bool, Option<String>) {
match module_name {
"oauth" => {
@@ -1383,6 +1384,13 @@ pub fn build_admin_module_validation_result(
(false, Some("请先配置 Bark Device Key".to_string()))
}
}
"s3_backup" => {
if s3_backup_configured {
(true, None)
} else {
(false, Some("请先完成 S3 备份配置".to_string()))
}
}
"gemini_files" => {
if gemini_files_has_capable_key {
(true, None)
@@ -1408,7 +1416,8 @@ pub fn build_admin_module_health(
| "proxy_nodes"
| "important_notification"
| "bark_push"
| "server_chan_push" => "healthy",
| "server_chan_push"
| "s3_backup" => "healthy",
"gemini_files" => {
if gemini_files_has_capable_key {
"healthy"
+31
View File
@@ -960,6 +960,23 @@ export const MOCK_SYSTEM_CONFIGS: Array<{ key: string; value: unknown; descripti
{ key: 'module.bark_push.device_key', value: null, description: 'Bark Device Key' },
{ key: 'module.bark_push.server_url', value: 'https://api.day.app', description: 'Bark 服务器地址' },
{ key: 'module.bark_push.template', value: '', description: 'Bark 推送模板' },
{ key: 'backup_s3_enabled', value: false, description: 'S3 自动备份开关' },
{ key: 'backup_s3_scope', value: 'data', description: 'S3 备份范围' },
{ key: 'backup_s3_endpoint', value: null, description: 'S3 Endpoint' },
{ key: 'backup_s3_region', value: 'auto', description: 'S3 Region' },
{ key: 'backup_s3_bucket', value: null, description: 'S3 Bucket' },
{ key: 'backup_s3_prefix', value: 'aether/backups/', description: 'S3 备份前缀' },
{ key: 'backup_s3_access_key_id', value: null, description: 'S3 Access Key ID' },
{ key: 'backup_s3_secret_access_key', value: null, description: 'S3 Secret Access Key' },
{ key: 'backup_s3_path_style', value: true, description: 'S3 Path Style' },
{ key: 'backup_s3_compression', value: 'zstd', description: 'S3 备份压缩格式' },
{ key: 'backup_s3_schedule_unit', value: 'days', description: 'S3 备份周期单位' },
{ key: 'backup_s3_schedule_interval', value: 1, description: 'S3 备份周期间隔' },
{ key: 'backup_s3_schedule_minute', value: 0, description: 'S3 备份分钟' },
{ key: 'backup_s3_schedule_hour', value: 3, description: 'S3 备份小时' },
{ key: 'backup_s3_schedule_weekday', value: 1, description: 'S3 备份星期' },
{ key: 'backup_s3_schedule_month_day', value: 1, description: 'S3 备份月日' },
{ key: 'backup_s3_retention_count', value: 7, description: 'S3 备份保留份数' },
{ key: 'proxy_node_metrics_1m_retention_days', value: 30, description: '代理节点 1m 指标保留天数' },
{ key: 'proxy_node_metrics_1h_retention_days', value: 180, description: '代理节点 1h 指标保留天数' },
{ key: 'proxy_node_metrics_cleanup_batch_size', value: 5000, description: '代理节点指标每批次清理条数' }
@@ -1078,6 +1095,20 @@ const MOCK_MODULE_DEFINITIONS: Array<Omit<ModuleStatus, 'active' | 'health'> & {
admin_menu_group: null,
admin_menu_order: 59,
},
{
name: 's3_backup',
display_name: 'S3 备份',
description: '将配置、用户或完整数据定期备份到 S3-compatible 对象存储',
category: 'integration',
available: true,
enabled: false,
config_validated: false,
config_error: '请先完成 S3 备份配置',
admin_route: '/admin/modules/s3-backup',
admin_menu_icon: 'CloudUpload',
admin_menu_group: null,
admin_menu_order: 60,
},
{
name: 'gemini_files',
display_name: '文件缓存',
+69 -1
View File
@@ -1796,6 +1796,42 @@ function generateMockModelsForProvider(providerId: string) {
// ========== 注册动态路由 ==========
const WRITE_ONLY_SYSTEM_CONFIG_KEYS = new Set([
'module.server_chan_push.send_key',
'module.bark_push.device_key',
'backup_s3_secret_access_key',
])
function mockSystemConfigValue(key: string) {
return MOCK_SYSTEM_CONFIGS.find(item => item.key === key)?.value
}
function mockS3BackupConfigValidated() {
return [
'backup_s3_endpoint',
'backup_s3_bucket',
'backup_s3_access_key_id',
'backup_s3_secret_access_key',
].every(key => {
const value = mockSystemConfigValue(key)
return typeof value === 'string' && value.trim() !== ''
})
}
function refreshMockS3BackupModuleStatus() {
const moduleStatus = MOCK_MODULE_STATUSES.s3_backup
if (!moduleStatus) return
const enabled = mockSystemConfigValue('backup_s3_enabled') === true
const configValidated = mockS3BackupConfigValidated()
MOCK_MODULE_STATUSES.s3_backup = {
...moduleStatus,
enabled,
config_validated: configValidated,
config_error: configValidated ? null : '请先完成 S3 备份配置',
active: moduleStatus.available && enabled && configValidated,
}
}
// 系统配置详情
registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_config, params) => {
await delay()
@@ -1805,7 +1841,7 @@ registerDynamicRoute('GET', '/api/admin/system/configs/:configKey', async (_conf
if (!entry) {
throw { response: createMockResponse({ detail: `配置项 '${key}' 不存在` }, 404) }
}
if (key === 'module.server_chan_push.send_key' || key === 'module.bark_push.device_key') {
if (WRITE_ONLY_SYSTEM_CONFIG_KEYS.has(key)) {
return createMockResponse({
key: entry.key,
value: null,
@@ -1836,9 +1872,26 @@ registerDynamicRoute('PUT', '/api/admin/system/configs/:configKey', async (confi
...entry,
}
}
if (key.startsWith('backup_s3_')) {
refreshMockS3BackupModuleStatus()
}
return createMockResponse(entry)
})
registerDynamicRoute('POST', '/api/admin/system/backups/s3/run', async () => {
await delay()
requireAdmin()
return createMockResponse({
message: 'S3 备份任务已提交',
task: {
id: `mock-s3-backup-${Date.now()}`,
task_key: 'system.s3.backup',
status: 'queued',
progress_message: 'S3 备份任务已提交',
},
})
})
// 模块状态详情
registerDynamicRoute('GET', '/api/admin/modules/status/:moduleName', async (_config, params) => {
await delay()
@@ -1860,6 +1913,21 @@ registerDynamicRoute('PUT', '/api/admin/modules/status/:moduleName/enabled', asy
}
const body = JSON.parse(config.data || '{}') as { enabled?: boolean }
const enabled = body.enabled === true
if (params.moduleName === 's3_backup') {
const index = MOCK_SYSTEM_CONFIGS.findIndex(item => item.key === 'backup_s3_enabled')
const entry = {
key: 'backup_s3_enabled',
value: enabled,
description: 'S3 自动备份开关',
}
if (index === -1) {
MOCK_SYSTEM_CONFIGS.push(entry)
} else {
MOCK_SYSTEM_CONFIGS[index] = { ...MOCK_SYSTEM_CONFIGS[index], ...entry }
}
refreshMockS3BackupModuleStatus()
return createMockResponse(MOCK_MODULE_STATUSES.s3_backup)
}
const updated = {
...moduleStatus,
enabled,
+6
View File
@@ -274,6 +274,12 @@ const routes: RouteRecordRaw[] = [
component: () => importWithRetry(() => import('@/views/admin/modules/ChatPiiRedaction.vue')),
meta: { module: 'chat_pii_redaction' }
},
{
path: 'modules/s3-backup',
name: 'S3BackupSettings',
component: () => importWithRetry(() => import('@/views/admin/modules/S3BackupSettings.vue')),
meta: { module: 's3_backup' }
},
{
path: 'modules/important-notification',
redirect: '/admin/notification-service'
@@ -46,9 +46,6 @@
@file-select="handleDataFileSelect"
/>
<!-- S3 备份 -->
<S3BackupSection id="section-s3-backup" />
<!-- 网络代理 -->
<ProxyConfigSection
id="section-proxy"
@@ -270,7 +267,6 @@ 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'
@@ -289,7 +285,6 @@ 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: '请求记录' },
@@ -1,31 +1,38 @@
<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>
<PageContainer>
<PageHeader
title="S3 备份"
description="按导出范围备份到 S3-compatible 存储"
/>
<div class="space-y-5">
<CardSection
title="备份配置"
description="配置自动备份周期、对象存储连接和保留策略"
class="mt-6"
>
<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">
@@ -395,8 +402,9 @@
</div>
</div>
</div>
</div>
</CardSection>
</div>
</CardSection>
</PageContainer>
</template>
<script setup lang="ts">
@@ -418,7 +426,7 @@ 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 { CardSection, PageContainer, PageHeader } from '@/components/layout'
import {
useS3BackupConfig,
type S3BackupConfig,