完善大文件备份分卷上传与恢复

This commit is contained in:
lyen1688
2026-08-09 20:58:07 +08:00
committed by lyen1688
parent 48eb3766d2
commit bbc8b6e906
12 changed files with 1420 additions and 156 deletions
+21 -2
View File
@@ -23,6 +23,7 @@ export interface BackupRecord {
backup_type: string
file_name: string
s3_key: string
parts?: BackupPart[]
size_bytes: number
triggered_by: string
error_message?: string
@@ -35,6 +36,24 @@ export interface BackupRecord {
restored_at?: string
}
export interface BackupPart {
index: number
s3_key: string
size_bytes: number
sha256?: string
}
export interface BackupDownloadPart {
index: number
size_bytes: number
url: string
}
export interface BackupDownloadResponse {
url?: string
parts?: BackupDownloadPart[]
}
export interface CreateBackupRequest {
expire_days?: number
}
@@ -137,8 +156,8 @@ export async function deleteBackup(id: string): Promise<void> {
await apiClient.delete(`/admin/backups/${id}`)
}
export async function getDownloadURL(id: string): Promise<{ url: string }> {
const { data } = await apiClient.get<{ url: string }>(`/admin/backups/${id}/download-url`)
export async function getDownloadURL(id: string): Promise<BackupDownloadResponse> {
const { data } = await apiClient.get<BackupDownloadResponse>(`/admin/backups/${id}/download-url`)
return data
}
@@ -144,6 +144,7 @@ export default {
status: 'Status',
fileName: 'File Name',
size: 'Size',
parts: 'Parts',
expiresAt: 'Expires At',
triggeredBy: 'Triggered By',
startedAt: 'Started At',
@@ -168,6 +169,10 @@ export default {
empty: 'No backup records',
actions: {
download: 'Download',
downloadParts: 'Download Parts',
downloadPartsHint: 'Download every part in order and concatenate the gzip bytes: on Linux/macOS run cat payload.part-* > backup.sql.gz; on Windows run copy /b payload.part-000001+payload.part-000002 backup.sql.gz.',
partLabel: 'Part {index}',
downloadFailed: 'Download URL is empty',
restore: 'Restore',
restoreConfirm: 'Are you sure you want to restore from this backup? This will overwrite the current database!',
restorePasswordPrompt: 'Please enter your admin password to confirm the restore operation',
@@ -144,6 +144,7 @@ export default {
status: '状态',
fileName: '文件名',
size: '大小',
parts: '分卷数',
expiresAt: '过期时间',
triggeredBy: '触发方式',
startedAt: '开始时间',
@@ -168,6 +169,10 @@ export default {
empty: '暂无备份记录',
actions: {
download: '下载',
downloadParts: '下载分卷',
downloadPartsHint: '请按顺序下载全部分卷后拼接 gzip 字节流:Linux/macOS 使用 cat payload.part-* > backup.sql.gz;Windows 使用 copy /b payload.part-000001+payload.part-000002 backup.sql.gz。',
partLabel: '第 {index} 卷',
downloadFailed: '下载地址为空',
restore: '恢复',
restoreConfirm: '确定要从此备份恢复吗?这将覆盖当前数据库!',
restorePasswordPrompt: '请输入管理员密码以确认恢复操作',
+62 -1
View File
@@ -200,6 +200,7 @@
<th class="py-2 pr-4">{{ t('admin.backup.columns.status') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.fileName') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.size') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.parts') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.expiresAt') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.triggeredBy') }}</th>
<th class="py-2 pr-4">{{ t('admin.backup.columns.startedAt') }}</th>
@@ -221,6 +222,7 @@
</td>
<td class="py-3 pr-4 text-xs">{{ record.file_name }}</td>
<td class="py-3 pr-4 text-xs">{{ formatSize(record.size_bytes) }}</td>
<td class="py-3 pr-4 text-xs">{{ record.parts?.length || (record.status === 'running' ? '-' : 1) }}</td>
<td class="py-3 pr-4 text-xs">
{{ record.expires_at ? formatDate(record.expires_at) : t('admin.backup.neverExpire') }}
</td>
@@ -248,6 +250,7 @@
{{ restoringId === record.id ? t('common.loading') : t('admin.backup.actions.restore') }}
</button>
<button
v-if="record.status !== 'running'"
type="button"
class="btn btn-danger btn-xs"
@click="removeBackup(record.id)"
@@ -258,7 +261,7 @@
</td>
</tr>
<tr v-if="backups.length === 0">
<td colspan="8" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
<td colspan="9" class="py-6 text-center text-sm text-gray-500 dark:text-gray-400">
{{ t('admin.backup.empty') }}
</td>
</tr>
@@ -351,6 +354,48 @@
</div>
</transition>
</teleport>
<!-- 分卷下载链接 -->
<teleport to="body">
<transition name="modal">
<div
v-if="downloadPartsModalOpen"
class="fixed inset-0 z-50 flex items-center justify-center p-4"
@mousedown.self="closeDownloadParts"
>
<div class="fixed inset-0 bg-black/50" @click="closeDownloadParts"></div>
<div class="relative max-h-[85vh] w-full max-w-lg overflow-y-auto rounded-xl bg-white p-6 shadow-2xl dark:bg-dark-800">
<button
type="button"
class="absolute right-4 top-4 text-gray-400 hover:text-gray-600 dark:hover:text-gray-200"
:aria-label="t('common.close')"
@click="closeDownloadParts"
>
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
<h2 class="mb-1 text-lg font-bold text-gray-900 dark:text-white">{{ t('admin.backup.actions.downloadParts') }}</h2>
<p class="mb-4 text-sm text-gray-500 dark:text-gray-400">{{ t('admin.backup.actions.downloadPartsHint') }}</p>
<div class="space-y-2">
<div
v-for="part in downloadParts"
:key="part.index"
class="flex items-center justify-between gap-3 rounded-lg border border-gray-200 px-3 py-2 dark:border-dark-600"
>
<span class="text-sm text-gray-700 dark:text-gray-300">
{{ t('admin.backup.actions.partLabel', { index: part.index }) }}
<span class="ml-2 text-xs text-gray-500 dark:text-gray-400">{{ formatSize(part.size_bytes) }}</span>
</span>
<a :href="part.url" class="btn btn-secondary btn-xs" rel="noopener">
{{ t('admin.backup.actions.download') }}
</a>
</div>
</div>
<div class="mt-4 text-right">
<button type="button" class="btn btn-primary btn-sm" @click="closeDownloadParts">{{ t('common.close') }}</button>
</div>
</div>
</div>
</transition>
</teleport>
<TotpStepUpDialog :controller="backupStepUp" />
</template>
@@ -363,6 +408,7 @@ import type {
BackupS3Config,
BackupScheduleConfig,
BackupRecord,
BackupDownloadPart,
ImageStorageConfig,
} from '@/api/admin/backup'
import { useStepUp, isStepUpBlocked, isStepUpCancelled, stepUpBlockReason } from '@/composables/useStepUp'
@@ -432,6 +478,8 @@ const loadingBackups = ref(false)
const creatingBackup = ref(false)
const restoringId = ref('')
const manualExpireDays = ref(14)
const downloadParts = ref<BackupDownloadPart[]>([])
const downloadPartsModalOpen = ref(false)
// Polling
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null)
@@ -715,6 +763,14 @@ async function createBackup() {
async function downloadBackup(id: string) {
try {
const result = await backupStepUp.run(() => adminAPI.backup.getDownloadURL(id))
if (result.parts && result.parts.length > 0) {
downloadParts.value = result.parts
downloadPartsModalOpen.value = true
return
}
if (!result.url) {
throw new Error(t('admin.backup.actions.downloadFailed'))
}
// 预签名 URL 带 attachment disposition,同页 anchor 导航直接触发下载;
// 不用 window.open:step-up 弹窗 await 会耗尽瞬态用户激活,新标签页会被浏览器拦截。
const link = document.createElement('a')
@@ -728,6 +784,11 @@ async function downloadBackup(id: string) {
}
}
function closeDownloadParts() {
downloadPartsModalOpen.value = false
downloadParts.value = []
}
async function restoreBackup(id: string) {
if (!window.confirm(t('admin.backup.actions.restoreConfirm'))) return
const password = window.prompt(t('admin.backup.actions.restorePasswordPrompt'))
@@ -0,0 +1,154 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import BackupView from '../BackupView.vue'
const {
getS3Config,
getImageStorageConfig,
getSchedule,
listBackups,
getDownloadURL,
} = vi.hoisted(() => ({
getS3Config: vi.fn(),
getImageStorageConfig: vi.fn(),
getSchedule: vi.fn(),
listBackups: vi.fn(),
getDownloadURL: vi.fn(),
}))
vi.mock('@/api', () => ({
adminAPI: {
backup: {
getS3Config,
updateS3Config: vi.fn(),
testS3Connection: vi.fn(),
getImageStorageConfig,
updateImageStorageConfig: vi.fn(),
testImageStorageConnection: vi.fn(),
getSchedule,
updateSchedule: vi.fn(),
createBackup: vi.fn(),
listBackups,
getBackup: vi.fn(),
deleteBackup: vi.fn(),
getDownloadURL,
restoreBackup: vi.fn(),
},
},
}))
vi.mock('@/stores', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
showWarning: vi.fn(),
}),
}))
vi.mock('@/composables/useStepUp', () => ({
useStepUp: () => ({ run: (fn: () => unknown) => fn() }),
isStepUpBlocked: () => false,
isStepUpCancelled: () => false,
stepUpBlockReason: () => '',
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string, params?: Record<string, unknown>) =>
params?.index === undefined ? key : `${key}:${params.index}`,
}),
}))
const baseRecord = (id: string, parts?: unknown[]) => ({
id,
status: 'completed',
backup_type: 'postgres',
file_name: `${id}.sql.gz`,
s3_key: `backups/${id}.sql.gz`,
parts,
size_bytes: 10,
triggered_by: 'manual',
started_at: '2026-08-09T00:00:00Z',
})
function mountBackupView() {
return mount(BackupView, {
global: {
stubs: {
TotpStepUpDialog: true,
},
},
})
}
describe('admin BackupView 分卷备份', () => {
beforeEach(() => {
getS3Config.mockResolvedValue({})
getImageStorageConfig.mockResolvedValue({ config: {}, secret_configured: false })
getSchedule.mockResolvedValue({ enabled: false, cron_expr: '', retain_days: 14, retain_count: 10 })
getDownloadURL.mockReset()
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
})
afterEach(() => {
vi.restoreAllMocks()
document.body.innerHTML = ''
})
it('显示分卷数并在下载时列出每个分卷链接', async () => {
listBackups.mockResolvedValue({
items: [baseRecord('split', [{ index: 1 }, { index: 2 }, { index: 3 }])],
})
getDownloadURL.mockResolvedValue({
parts: [
{ index: 1, size_bytes: 5, url: 'https://example.test/part-1' },
{ index: 2, size_bytes: 6, url: 'https://example.test/part-2' },
{ index: 3, size_bytes: 7, url: 'https://example.test/part-3' },
],
})
const wrapper = mountBackupView()
await flushPromises()
expect(wrapper.text()).toContain('3')
const downloadButton = wrapper.findAll('button').find(button =>
button.text().includes('admin.backup.actions.download'),
)
expect(downloadButton).toBeDefined()
await downloadButton!.trigger('click')
await flushPromises()
expect(document.body.textContent).toContain('admin.backup.actions.partLabel:1')
expect(document.body.textContent).toContain('admin.backup.actions.partLabel:3')
expect(document.body.querySelector('a[href="https://example.test/part-2"]')).not.toBeNull()
})
it('旧单文件记录仍使用单个下载地址', async () => {
listBackups.mockResolvedValue({ items: [baseRecord('legacy')] })
getDownloadURL.mockResolvedValue({ url: 'https://example.test/legacy.sql.gz' })
const wrapper = mountBackupView()
await flushPromises()
const downloadButton = wrapper.findAll('button').find(button =>
button.text().includes('admin.backup.actions.download'),
)
await downloadButton!.trigger('click')
await flushPromises()
expect(getDownloadURL).toHaveBeenCalledWith('legacy')
expect(document.body.textContent).not.toContain('admin.backup.actions.downloadParts')
})
it('运行中的备份不显示删除入口', async () => {
listBackups.mockResolvedValue({
items: [{ ...baseRecord('running'), status: 'running', progress: 'uploading' }],
})
const wrapper = mountBackupView()
await flushPromises()
expect(wrapper.find('tbody tr td:nth-child(5)').text()).toBe('-')
expect(wrapper.findAll('button').some(button => button.text() === 'common.delete')).toBe(false)
})
})