mirror of
https://github.com/jxxghp/MoviePilot-Frontend.git
synced 2026-08-29 03:56:51 +08:00
feat: 增加数据库备份策略设置 (#686)
This commit is contained in:
@@ -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',
|
||||
|
||||
@@ -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: '请输入清理周期',
|
||||
|
||||
@@ -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: '請輸入清理週期',
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user