mirror of
https://gitee.com/wanwujie/sub2api
synced 2026-04-03 23:12:14 +08:00
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:
@@ -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 = {
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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: '定时'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user