feat(backup): 备份/恢复异步化,解决 504 超时

POST /backups 和 POST /backups/:id/restore 改为异步:立即返回 HTTP 202,
后台 goroutine 独立执行 pg_dump → gzip → S3 上传,前端每 2s 轮询状态。

后端:
- 新增 StartBackup/StartRestore 方法,后台 goroutine 不依赖 HTTP 连接
- Graceful shutdown 等待活跃操作完成,启动时清理孤立 running 记录
- BackupRecord 新增 progress/restore_status 字段支持进度和恢复状态追踪

前端:
- 创建备份/恢复后轮询 GET /backups/:id 直到完成或失败
- 标签页切换暂停/恢复轮询,组件卸载清理定时器
- 正确处理 409(备份进行中)和轮询超时

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
QTom
2026-03-16 20:03:08 +08:00
parent f42c8f2abe
commit c1fab7f8d8
9 changed files with 780 additions and 68 deletions

View File

@@ -29,6 +29,10 @@ export interface BackupRecord {
started_at: string
finished_at?: string
expires_at?: string
progress?: string
restore_status?: string
restore_error?: string
restored_at?: string
}
export interface CreateBackupRequest {
@@ -69,7 +73,7 @@ export async function updateSchedule(config: BackupScheduleConfig): Promise<Back
// Backup operations
export async function createBackup(req?: CreateBackupRequest): Promise<BackupRecord> {
const { data } = await apiClient.post<BackupRecord>('/admin/backups', req || {}, { timeout: 600000 })
const { data } = await apiClient.post<BackupRecord>('/admin/backups', req || {})
return data
}
@@ -93,8 +97,9 @@ export async function getDownloadURL(id: string): Promise<{ url: string }> {
}
// Restore
export async function restoreBackup(id: string, password: string): Promise<void> {
await apiClient.post(`/admin/backups/${id}/restore`, { password }, { timeout: 600000 })
export async function restoreBackup(id: string, password: string): Promise<BackupRecord> {
const { data } = await apiClient.post<BackupRecord>(`/admin/backups/${id}/restore`, { password })
return data
}
export const backupAPI = {

View File

@@ -1025,7 +1025,12 @@ export default {
createBackup: 'Create Backup',
backing: 'Backing up...',
backupCreated: 'Backup created successfully',
expireDays: 'Expire Days'
expireDays: 'Expire Days',
alreadyInProgress: 'A backup is already in progress',
backupRunning: 'Backup in progress...',
backupFailed: 'Backup failed',
restoreRunning: 'Restore in progress...',
restoreFailed: 'Restore failed',
},
columns: {
status: 'Status',
@@ -1042,6 +1047,11 @@ export default {
completed: 'Completed',
failed: 'Failed'
},
progress: {
pending: 'Preparing',
dumping: 'Dumping database',
uploading: 'Uploading',
},
trigger: {
manual: 'Manual',
scheduled: 'Scheduled'

View File

@@ -1047,7 +1047,12 @@ export default {
createBackup: '创建备份',
backing: '备份中...',
backupCreated: '备份创建成功',
expireDays: '过期天数'
expireDays: '过期天数',
alreadyInProgress: '已有备份正在进行中',
backupRunning: '备份进行中...',
backupFailed: '备份失败',
restoreRunning: '恢复进行中...',
restoreFailed: '恢复失败',
},
columns: {
status: '状态',
@@ -1064,6 +1069,11 @@ export default {
completed: '已完成',
failed: '失败'
},
progress: {
pending: '准备中',
dumping: '导出数据库',
uploading: '上传中',
},
trigger: {
manual: '手动',
scheduled: '定时'

View File

@@ -139,7 +139,9 @@
class="rounded px-2 py-0.5 text-xs"
:class="statusClass(record.status)"
>
{{ t(`admin.backup.status.${record.status}`) }}
{{ record.status === 'running' && record.progress
? t(`admin.backup.progress.${record.progress}`)
: t(`admin.backup.status.${record.status}`) }}
</span>
</td>
<td class="py-3 pr-4 text-xs">{{ record.file_name }}</td>
@@ -277,7 +279,7 @@
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { adminAPI } from '@/api'
import { useAppStore } from '@/stores'
@@ -316,6 +318,111 @@ const creatingBackup = ref(false)
const restoringId = ref('')
const manualExpireDays = ref(14)
// Polling
const pollingTimer = ref<ReturnType<typeof setInterval> | null>(null)
const restoringPollingTimer = ref<ReturnType<typeof setInterval> | null>(null)
const MAX_POLL_COUNT = 900
function updateRecordInList(updated: BackupRecord) {
const idx = backups.value.findIndex(r => r.id === updated.id)
if (idx >= 0) {
backups.value[idx] = updated
}
}
function startPolling(backupId: string) {
stopPolling()
let count = 0
pollingTimer.value = setInterval(async () => {
if (count++ >= MAX_POLL_COUNT) {
stopPolling()
creatingBackup.value = false
appStore.showWarning(t('admin.backup.operations.backupRunning'))
return
}
try {
const record = await adminAPI.backup.getBackup(backupId)
updateRecordInList(record)
if (record.status === 'completed' || record.status === 'failed') {
stopPolling()
creatingBackup.value = false
if (record.status === 'completed') {
appStore.showSuccess(t('admin.backup.operations.backupCreated'))
} else {
appStore.showError(record.error_message || t('admin.backup.operations.backupFailed'))
}
await loadBackups()
}
} catch {
// 轮询失败时不中断
}
}, 2000)
}
function stopPolling() {
if (pollingTimer.value) {
clearInterval(pollingTimer.value)
pollingTimer.value = null
}
}
function startRestorePolling(backupId: string) {
stopRestorePolling()
let count = 0
restoringPollingTimer.value = setInterval(async () => {
if (count++ >= MAX_POLL_COUNT) {
stopRestorePolling()
restoringId.value = ''
appStore.showWarning(t('admin.backup.operations.restoreRunning'))
return
}
try {
const record = await adminAPI.backup.getBackup(backupId)
updateRecordInList(record)
if (record.restore_status === 'completed' || record.restore_status === 'failed') {
stopRestorePolling()
restoringId.value = ''
if (record.restore_status === 'completed') {
appStore.showSuccess(t('admin.backup.actions.restoreSuccess'))
} else {
appStore.showError(record.restore_error || t('admin.backup.operations.restoreFailed'))
}
await loadBackups()
}
} catch {
// 轮询失败时不中断
}
}, 2000)
}
function stopRestorePolling() {
if (restoringPollingTimer.value) {
clearInterval(restoringPollingTimer.value)
restoringPollingTimer.value = null
}
}
function handleVisibilityChange() {
if (document.hidden) {
stopPolling()
stopRestorePolling()
} else {
// 标签页恢复时刷新列表,检查是否仍有活跃操作
loadBackups().then(() => {
const running = backups.value.find(r => r.status === 'running')
if (running) {
creatingBackup.value = true
startPolling(running.id)
}
const restoring = backups.value.find(r => r.restore_status === 'running')
if (restoring) {
restoringId.value = restoring.id
startRestorePolling(restoring.id)
}
})
}
}
// R2 guide
const showR2Guide = ref(false)
const r2ConfigRows = computed(() => [
@@ -416,12 +523,16 @@ async function loadBackups() {
async function createBackup() {
creatingBackup.value = true
try {
await adminAPI.backup.createBackup({ expire_days: manualExpireDays.value })
appStore.showSuccess(t('admin.backup.operations.backupCreated'))
await loadBackups()
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
const record = await adminAPI.backup.createBackup({ expire_days: manualExpireDays.value })
// 插入到列表顶部
backups.value.unshift(record)
startPolling(record.id)
} catch (error: any) {
if (error?.response?.status === 409) {
appStore.showWarning(t('admin.backup.operations.alreadyInProgress'))
} else {
appStore.showError(error?.message || t('errors.networkError'))
}
creatingBackup.value = false
}
}
@@ -441,11 +552,15 @@ async function restoreBackup(id: string) {
if (!password) return
restoringId.value = id
try {
await adminAPI.backup.restoreBackup(id, password)
appStore.showSuccess(t('admin.backup.actions.restoreSuccess'))
} catch (error) {
appStore.showError((error as { message?: string })?.message || t('errors.networkError'))
} finally {
const record = await adminAPI.backup.restoreBackup(id, password)
updateRecordInList(record)
startRestorePolling(id)
} catch (error: any) {
if (error?.response?.status === 409) {
appStore.showWarning(t('admin.backup.operations.restoreRunning'))
} else {
appStore.showError(error?.message || t('errors.networkError'))
}
restoringId.value = ''
}
}
@@ -489,7 +604,26 @@ function formatDate(value?: string): string {
}
onMounted(async () => {
document.addEventListener('visibilitychange', handleVisibilityChange)
await Promise.all([loadS3Config(), loadSchedule(), loadBackups()])
// 如果有正在 running 的备份,恢复轮询
const runningBackup = backups.value.find(r => r.status === 'running')
if (runningBackup) {
creatingBackup.value = true
startPolling(runningBackup.id)
}
const restoringBackup = backups.value.find(r => r.restore_status === 'running')
if (restoringBackup) {
restoringId.value = restoringBackup.id
startRestorePolling(restoringBackup.id)
}
})
onBeforeUnmount(() => {
stopPolling()
stopRestorePolling()
document.removeEventListener('visibilitychange', handleVisibilityChange)
})
</script>