fix: 按 review 意见重构数据库备份服务(安全性 + 架构 + 健壮性)

1. S3 凭证加密存储:使用 SecretEncryptor (AES-256-GCM) 加密 SecretAccessKey,
   防止备份文件中泄露 S3 凭证,兼容旧的未加密数据
2. 修复 saveRecord 竞态条件:添加 recordsMu 互斥锁保护 records 的 load/save
3. 恢复操作增加服务端验证:handler 层要求重新输入管理员密码,通过 bcrypt
   校验,前端弹出密码输入框
4. pg_dump/psql/S3 操作抽象为接口:定义 DBDumper 和 BackupObjectStore 接口,
   实现放入 repository 层,遵循项目依赖注入架构规范
5. 改为流式处理避免大数据库 OOM:备份时 pg_dump stdout -> gzip -> io.Pipe ->
   S3 upload;恢复时 S3 download -> gzip reader -> psql stdin,不再全量加载
6. loadRecords 区分"无数据"和"数据损坏"场景:JSON 解析失败返回明确错误
7. 添加 18 个核心逻辑单元测试:覆盖加密、并发、流式备份/恢复、错误处理等

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Rose Ding
2026-03-14 17:48:21 +08:00
parent f7177be3b6
commit 1047f973d5
12 changed files with 961 additions and 207 deletions

View File

@@ -93,8 +93,8 @@ export async function getDownloadURL(id: string): Promise<{ url: string }> {
}
// Restore
export async function restoreBackup(id: string): Promise<void> {
await apiClient.post(`/admin/backups/${id}/restore`, {}, { timeout: 600000 })
export async function restoreBackup(id: string, password: string): Promise<void> {
await apiClient.post(`/admin/backups/${id}/restore`, { password }, { timeout: 600000 })
}
export const backupAPI = {

View File

@@ -1034,6 +1034,7 @@ export default {
download: 'Download',
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',
restoreSuccess: 'Database restored successfully',
deleteConfirm: 'Are you sure you want to delete this backup?',
deleted: 'Backup deleted'

View File

@@ -1056,6 +1056,7 @@ export default {
download: '下载',
restore: '恢复',
restoreConfirm: '确定要从此备份恢复吗?这将覆盖当前数据库!',
restorePasswordPrompt: '请输入管理员密码以确认恢复操作',
restoreSuccess: '数据库恢复成功',
deleteConfirm: '确定要删除此备份吗?',
deleted: '备份已删除'

View File

@@ -440,9 +440,11 @@ async function downloadBackup(id: string) {
async function restoreBackup(id: string) {
if (!window.confirm(t('admin.backup.actions.restoreConfirm'))) return
const password = window.prompt(t('admin.backup.actions.restorePasswordPrompt'))
if (!password) return
restoringId.value = id
try {
await adminAPI.backup.restoreBackup(id)
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'))