feat: 增加数据库备份策略设置 (#686)

This commit is contained in:
InfinityPacer
2026-08-19 17:32:27 +08:00
committed by GitHub
parent 104a314c4c
commit fcc6f68519
5 changed files with 339 additions and 2 deletions
+15
View File
@@ -2219,6 +2219,21 @@ export default {
logBackupCountMin: 'Maximum number of log file backups must be greater than or equal to 1',
logFileFormat: 'Log File Format',
logFileFormatHint: 'Set the output format of log files to customize the displayed content of logs',
dbBackupEnable: 'Enable Data Backup',
dbBackupEnableHint: 'Master switch for automatic database backups; scheduled backups require a backup schedule',
dbBackupCron: 'Backup Schedule',
dbBackupCronHint: 'Cron expression; leave empty to disable scheduled backups',
dbBackupCronInvalid: 'Backup schedule must be empty or a valid Cron expression',
dbBackupPath: 'Backup Directory',
dbBackupPathPlaceholder: '/config/database_backup',
dbBackupPathHint:
'Leave empty to use database_backup under the configuration directory; local paths are supported',
dbBackupRetentionDays: 'Backup Retention Days',
dbBackupRetentionDaysHint: 'Unit: days. Set to 0 for no limit',
dbBackupRetentionDaysInvalid: 'Backup retention days must be an integer greater than or equal to 0',
dbBackupMaxCount: 'Maximum Backup Count',
dbBackupMaxCountHint: 'Set to 0 for no limit',
dbBackupMaxCountInvalid: 'Maximum backup count must be an integer greater than or equal to 0',
dataCleanupEnable: 'Enable Data Cleanup',
dataCleanupEnableHint: 'When disabled, scheduled data cleanup tasks will be skipped',
dataCleanupDaysRequired: 'Please enter a cleanup retention period',
+14
View File
@@ -2189,6 +2189,20 @@ export default {
logBackupCountMin: '日志文件最大备份数量必须大于等于1',
logFileFormat: '日志文件格式',
logFileFormatHint: '设置日志文件的输出格式,用于自定义日志的显示内容',
dbBackupEnable: '启用数据备份',
dbBackupEnableHint: '数据库自动备份总开关,定时备份仅在配置备份周期后启用',
dbBackupCron: '备份周期',
dbBackupCronHint: 'Cron 表达式,留空不启用定时备份',
dbBackupCronInvalid: '备份周期必须留空或使用有效的 Cron 表达式',
dbBackupPath: '备份目录',
dbBackupPathPlaceholder: '/config/database_backup',
dbBackupPathHint: '留空使用配置目录下的 database_backup,支持本地存储路径',
dbBackupRetentionDays: '备份过期天数',
dbBackupRetentionDaysHint: '单位:天,0 表示不限制',
dbBackupRetentionDaysInvalid: '备份过期天数必须是大于等于 0 的整数',
dbBackupMaxCount: '最大保留份数',
dbBackupMaxCountHint: '0 表示不限制',
dbBackupMaxCountInvalid: '最大保留份数必须是大于等于 0 的整数',
dataCleanupEnable: '启用数据清理',
dataCleanupEnableHint: '总开关关闭时将跳过定时数据清理任务',
dataCleanupDaysRequired: '请输入清理周期',
+14
View File
@@ -2188,6 +2188,20 @@ export default {
logBackupCountMin: '日誌文件最大備份數量必須大於等於1',
logFileFormat: '日誌文件格式',
logFileFormatHint: '設置日誌文件的輸出格式,用於自定義日誌的顯示內容',
dbBackupEnable: '啟用數據備份',
dbBackupEnableHint: '數據庫自動備份總開關,定時備份僅在配置備份週期後啟用',
dbBackupCron: '備份週期',
dbBackupCronHint: 'Cron 表達式,留空不啟用定時備份',
dbBackupCronInvalid: '備份週期必須留空或使用有效的 Cron 表達式',
dbBackupPath: '備份目錄',
dbBackupPathPlaceholder: '/config/database_backup',
dbBackupPathHint: '留空使用配置目錄下的 database_backup,支持本地存儲路徑',
dbBackupRetentionDays: '備份過期天數',
dbBackupRetentionDaysHint: '單位:天,0 表示不限制',
dbBackupRetentionDaysInvalid: '備份過期天數必須是大於等於 0 的整數',
dbBackupMaxCount: '最大保留份數',
dbBackupMaxCountHint: '0 表示不限制',
dbBackupMaxCountInvalid: '最大保留份數必須是大於等於 0 的整數',
dataCleanupEnable: '啟用數據清理',
dataCleanupEnableHint: '總開關關閉時將跳過定時數據清理任務',
dataCleanupDaysRequired: '請輸入清理週期',
+125
View File
@@ -100,6 +100,12 @@ const SystemSettings = ref<any>({
DATA_CLEANUP_DOWNLOAD_HISTORY_DAYS: 180,
DATA_CLEANUP_SITE_USERDATA_DAYS: 180,
DATA_CLEANUP_TRANSFER_HISTORY_DAYS: 365 * 3,
// 本地数据库备份策略同时适用于 SQLite 与 PostgreSQL。
DB_BACKUP_ENABLE: false,
DB_BACKUP_CRON: '0 3 * * *',
DB_BACKUP_PATH: null,
DB_BACKUP_RETENTION_DAYS: 30,
DB_BACKUP_MAX_COUNT: 30,
// 媒体
RECOGNIZE_PLUGIN_FIRST: false,
MEDIA_RECOGNIZE_SHARE: true,
@@ -610,6 +616,36 @@ const dataCleanupFieldRules = [
(value: unknown) => Number(value) >= 0 || t('setting.system.dataCleanupDaysMin'),
]
const dbBackupPath = computed({
get: () => String(SystemSettings.value.Advanced.DB_BACKUP_PATH ?? ''),
set: (value: string) => {
SystemSettings.value.Advanced.DB_BACKUP_PATH = value
},
})
function hasValidCronFieldCount(value: string) {
return value.trim().split(/\s+/).length === 5
}
function isNonNegativeInteger(value: unknown) {
if (value === '' || value === null || value === undefined) return false
const numberValue = Number(value)
return Number.isInteger(numberValue) && numberValue >= 0
}
const dbBackupCronRules = [
(value: unknown) => {
const cron = String(value ?? '').trim()
return !cron || hasValidCronFieldCount(cron) || t('setting.system.dbBackupCronInvalid')
},
]
const dbBackupRetentionRules = [
(value: unknown) => isNonNegativeInteger(value) || t('setting.system.dbBackupRetentionDaysInvalid'),
]
const dbBackupMaxCountRules = [
(value: unknown) => isNonNegativeInteger(value) || t('setting.system.dbBackupMaxCountInvalid'),
]
// 安全域名添加变量
const newSecurityDomain = ref('')
// 图片代理允许非公网网段添加变量
@@ -859,6 +895,7 @@ async function testLlmConnection() {
// 保存高级设置
async function saveAdvancedSettings() {
if (!normalizeDbBackupSettings()) return
if (!rustAccelAvailable.value) SystemSettings.value.Advanced.RUST_ACCEL = false
cleanEmptyFields(SystemSettings.value.Advanced, ['LOG_FILE_FORMAT'])
@@ -876,6 +913,35 @@ async function saveAdvancedSettings() {
}
}
/** 规范化备份策略,并在请求前阻止后端无法执行的配置。 */
function normalizeDbBackupSettings() {
const settings = SystemSettings.value.Advanced
const path = String(settings.DB_BACKUP_PATH ?? '').trim()
settings.DB_BACKUP_PATH = path || null
const cron = String(settings.DB_BACKUP_CRON ?? '').trim()
const retentionDays = Number(settings.DB_BACKUP_RETENTION_DAYS)
const maxCount = Number(settings.DB_BACKUP_MAX_COUNT)
if (cron && !hasValidCronFieldCount(cron)) {
$toast.error(t('setting.system.dbBackupCronInvalid'))
return false
}
if (!isNonNegativeInteger(settings.DB_BACKUP_RETENTION_DAYS)) {
$toast.error(t('setting.system.dbBackupRetentionDaysInvalid'))
return false
}
if (!isNonNegativeInteger(settings.DB_BACKUP_MAX_COUNT)) {
$toast.error(t('setting.system.dbBackupMaxCountInvalid'))
return false
}
settings.DB_BACKUP_CRON = cron
settings.DB_BACKUP_RETENTION_DAYS = retentionDays
settings.DB_BACKUP_MAX_COUNT = maxCount
return true
}
// 当字段为空时,将其设置为 null 提交,以便后端恢复为默认值
function cleanEmptyFields(settings: Record<string, unknown>, fields: string[]) {
fields.forEach(field => {
@@ -2413,6 +2479,65 @@ watch(currentLlmSnapshotKey, (snapshotKey, previousSnapshotKey) => {
<VWindowItem value="data">
<div>
<VRow>
<VCol cols="12">
<VSwitch
v-model="SystemSettings.Advanced.DB_BACKUP_ENABLE"
:label="t('setting.system.dbBackupEnable')"
:hint="t('setting.system.dbBackupEnableHint')"
persistent-hint
/>
</VCol>
<template v-if="SystemSettings.Advanced.DB_BACKUP_ENABLE">
<VCol cols="12" md="6">
<VCronField
v-model="SystemSettings.Advanced.DB_BACKUP_CRON"
:label="t('setting.system.dbBackupCron')"
:hint="t('setting.system.dbBackupCronHint')"
persistent-hint
clearable
:rules="dbBackupCronRules"
prepend-inner-icon="mdi-clock-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VPathField
v-model="dbBackupPath"
storage="local"
:label="t('setting.system.dbBackupPath')"
:placeholder="t('setting.system.dbBackupPathPlaceholder')"
:hint="t('setting.system.dbBackupPathHint')"
persistent-hint
prepend-inner-icon="mdi-folder-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="SystemSettings.Advanced.DB_BACKUP_RETENTION_DAYS"
:label="t('setting.system.dbBackupRetentionDays')"
:hint="t('setting.system.dbBackupRetentionDaysHint')"
persistent-hint
min="0"
step="1"
type="number"
:suffix="t('setting.system.day')"
:rules="dbBackupRetentionRules"
prepend-inner-icon="mdi-calendar-remove-outline"
/>
</VCol>
<VCol cols="12" md="6">
<VTextField
v-model.number="SystemSettings.Advanced.DB_BACKUP_MAX_COUNT"
:label="t('setting.system.dbBackupMaxCount')"
:hint="t('setting.system.dbBackupMaxCountHint')"
persistent-hint
min="0"
step="1"
type="number"
:rules="dbBackupMaxCountRules"
prepend-inner-icon="mdi-backup-restore"
/>
</VCol>
</template>
<VCol cols="12">
<VSwitch
v-model="SystemSettings.Advanced.DATA_CLEANUP_ENABLE"
@@ -4,7 +4,7 @@ import { useGlobalSettingsStore } from '@/stores'
import { fireEvent, screen, waitFor, within } from '@testing-library/vue'
import userEvent from '@testing-library/user-event'
import { renderWithProviders } from '@tests/support/render'
import { nextTick, ref } from 'vue'
import { defineComponent, h, nextTick, ref } from 'vue'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({
@@ -278,10 +278,44 @@ function enableLlmSettings() {
)
}
/** 为异步表单字段提供可真实回写 v-model 的测试边界。 */
function createModelFieldStub(name: string) {
return defineComponent({
name,
inheritAttrs: false,
props: {
label: { type: String, default: '' },
modelValue: { default: '' },
placeholder: { type: String, default: '' },
storage: { type: String, default: undefined },
},
emits: ['update:modelValue'],
setup(props, { emit }) {
return () =>
h('label', [
h('span', props.label),
h('input', {
'aria-label': props.label,
'data-storage': props.storage,
placeholder: props.placeholder,
value: props.modelValue ?? '',
onInput: (event: Event) => emit('update:modelValue', (event.target as HTMLInputElement).value),
}),
])
},
})
}
const CronFieldStub = createModelFieldStub('VCronFieldStub')
const PathFieldStub = createModelFieldStub('VPathFieldStub')
async function renderSettings(props: { active?: boolean } = {}) {
return renderWithProviders(AccountSettingSystem, {
props,
global: { stubs: { VDialogCloseBtn: true } },
global: {
components: { VCronField: CronFieldStub, VPathField: PathFieldStub },
stubs: { VDialogCloseBtn: true },
},
stubActions: false,
})
}
@@ -1015,6 +1049,141 @@ describe('AccountSettingSystem', () => {
)
})
it.each(['sqlite', 'postgresql'])('shows database backup defaults for %s', async databaseType => {
systemEnv.DB_TYPE = databaseType
await renderSettings()
const dialog = await openAdvancedTab('数据')
expect(dialog.getByLabelText('启用数据备份')).not.toBeChecked()
expect(dialog.queryByLabelText('备份周期')).not.toBeInTheDocument()
await fireEvent.click(dialog.getByLabelText('启用数据备份'))
expect(dialog.getByLabelText('备份周期')).toHaveValue('0 3 * * *')
expect(dialog.getByLabelText('备份目录')).toHaveValue('')
expect(dialog.getByLabelText('备份目录')).toHaveAttribute('placeholder', '/config/database_backup')
expect(dialog.getByLabelText('备份目录')).toHaveAttribute('data-storage', 'local')
expect(dialog.getByLabelText('备份过期天数')).toHaveValue(30)
expect(dialog.getByLabelText('最大保留份数')).toHaveValue(30)
})
it('loads, edits, and saves the database backup policy', async () => {
systemEnv = {
...systemEnv,
DB_BACKUP_CRON: '15 2 * * 1',
DB_BACKUP_ENABLE: true,
DB_BACKUP_MAX_COUNT: 12,
DB_BACKUP_PATH: '/data/backup',
DB_BACKUP_RETENTION_DAYS: 45,
}
await renderSettings()
const dialog = await openAdvancedTab('数据')
expect(dialog.getByLabelText('备份周期')).toHaveValue('15 2 * * 1')
expect(dialog.getByLabelText('备份目录')).toHaveValue('/data/backup')
await fireEvent.update(dialog.getByLabelText('备份周期'), '30 4 * * *')
await fireEvent.update(dialog.getByLabelText('备份目录'), ' relative/backup ')
await fireEvent.update(dialog.getByLabelText('备份过期天数'), '60')
await fireEvent.update(dialog.getByLabelText('最大保留份数'), '20')
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
DB_BACKUP_CRON: '30 4 * * *',
DB_BACKUP_ENABLE: true,
DB_BACKUP_MAX_COUNT: 20,
DB_BACKUP_PATH: 'relative/backup',
DB_BACKUP_RETENTION_DAYS: 60,
}),
)
})
it('hides disabled backup fields while preserving their values', async () => {
systemEnv = {
...systemEnv,
DB_BACKUP_CRON: ' 15 2 * * 1 ',
DB_BACKUP_ENABLE: true,
DB_BACKUP_MAX_COUNT: '12',
DB_BACKUP_PATH: ' /data/backup ',
DB_BACKUP_RETENTION_DAYS: '45',
}
await renderSettings()
const dialog = await openAdvancedTab('数据')
await fireEvent.click(dialog.getByLabelText('启用数据备份'))
expect(dialog.queryByLabelText('备份周期')).not.toBeInTheDocument()
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
DB_BACKUP_CRON: '15 2 * * 1',
DB_BACKUP_ENABLE: false,
DB_BACKUP_MAX_COUNT: 12,
DB_BACKUP_PATH: '/data/backup',
DB_BACKUP_RETENTION_DAYS: 45,
}),
)
})
it('rejects invalid hidden values while backup is disabled', async () => {
systemEnv = { ...systemEnv, DB_BACKUP_ENABLE: true, DB_BACKUP_PATH: '/data/backup' }
await renderSettings()
const dialog = await openAdvancedTab('数据')
await fireEvent.update(dialog.getByLabelText('备份周期'), '* * * *')
await fireEvent.update(dialog.getByLabelText('备份目录'), ' ')
await fireEvent.update(dialog.getByLabelText('备份过期天数'), '-1')
await fireEvent.update(dialog.getByLabelText('最大保留份数'), '1.5')
await fireEvent.click(dialog.getByLabelText('启用数据备份'))
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
expect(mocks.apiPost).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith('备份周期必须留空或使用有效的 Cron 表达式')
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('accepts zero backup limits and normalizes a blank path to the backend default', async () => {
systemEnv = { ...systemEnv, DB_BACKUP_ENABLE: true, DB_BACKUP_PATH: null }
await renderSettings()
const dialog = await openAdvancedTab('数据')
expect(dialog.getByLabelText('备份目录')).toHaveValue('')
await fireEvent.update(dialog.getByLabelText('备份周期'), '')
await fireEvent.update(dialog.getByLabelText('备份目录'), ' ')
await fireEvent.update(dialog.getByLabelText('备份过期天数'), '0')
await fireEvent.update(dialog.getByLabelText('最大保留份数'), '0')
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
await waitFor(() => expect(screen.queryByRole('dialog')).not.toBeInTheDocument())
expect(findPost('system/env')?.[1]).toEqual(
expect.objectContaining({
DB_BACKUP_CRON: '',
DB_BACKUP_MAX_COUNT: 0,
DB_BACKUP_PATH: null,
DB_BACKUP_RETENTION_DAYS: 0,
}),
)
})
it.each([
['备份周期', '* * * *', '备份周期必须留空或使用有效的 Cron 表达式'],
['备份过期天数', '-1', '备份过期天数必须是大于等于 0 的整数'],
['最大保留份数', '1.5', '最大保留份数必须是大于等于 0 的整数'],
])('rejects invalid database backup field %s before posting', async (label, value, message) => {
systemEnv.DB_BACKUP_ENABLE = true
await renderSettings()
const dialog = await openAdvancedTab('数据')
await fireEvent.update(dialog.getByLabelText(label), value)
await fireEvent.click(dialog.getByRole('button', { name: '保存' }))
expect(mocks.apiPost).not.toHaveBeenCalled()
expect(mocks.toastError).toHaveBeenCalledWith(message)
expect(screen.getByRole('dialog')).toBeInTheDocument()
})
it('round-trips every advanced laboratory control when Rust is available', async () => {
systemEnv.RUST_ACCEL_AVAILABLE = true
await renderSettings()