mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-29 16:20:27 +00:00
完善大文件备份分卷上传与恢复
This commit is contained in:
@@ -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: '请输入管理员密码以确认恢复操作',
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user