Parcourir la source

✨ feat(db): 添加数据库备份和恢复功能

- 添加数据库备份和恢复相关工具类
- 实现定时备份调度功能
- 添加备份文件管理和清理机制
- 新增多个数据库备份相关npm命令

✅ test(db): 添加数据库备份功能测试

- 为备份功能添加单元测试
- 为恢复功能添加单元测试
- 实现备份集成测试
- 覆盖备份创建、清理和验证场景

🔧 chore(package): 添加数据库备份相关命令

- 添加db:backup命令用于手动创建备份
- 添加db:restore命令用于恢复备份
- 添加db:backup:list命令列出所有备份
- 添加db:backup:latest命令显示最新备份
- 添加db:backup:cleanup命令清理旧备份
yourname il y a 2 mois
Parent
commit
eee8fb4d3f

+ 5 - 0
package.json

@@ -23,6 +23,11 @@
     "db:migrate": "tsx scripts/migrate.ts",
     "db:seed": "tsx scripts/seed.ts",
     "db:reset": "tsx scripts/reset-db.ts",
+    "db:backup": "tsx src/server/utils/backup.ts backup",
+    "db:restore": "tsx src/server/utils/restore.ts restore",
+    "db:backup:list": "tsx src/server/utils/restore.ts list",
+    "db:backup:latest": "tsx src/server/utils/restore.ts latest",
+    "db:backup:cleanup": "tsx src/server/utils/backup.ts cleanup",
     "test:analyze": "node scripts/analyze-test-results.js",
     "lint": "eslint . --ext .ts,.tsx",
     "lint:fix": "eslint . --ext .ts,.tsx --fix",

+ 5 - 0
src/server/api.ts

@@ -7,10 +7,15 @@ import rolesRoute from './api/roles/index'
 import { AuthContext } from './types/context'
 import { AppDataSource } from './data-source'
 import { Hono } from 'hono'
+import { databaseBackup } from './utils/backup'
 
 if(!AppDataSource.isInitialized) {
   await AppDataSource.initialize();
   console.log('数据库初始化完成')
+
+  // 启动数据库备份调度
+  databaseBackup.startScheduledBackups();
+  console.log('数据库备份调度已启动');
 }
 
 const app = new Hono();

+ 210 - 0
src/server/utils/__integration_tests__/backup.integration.test.ts

@@ -0,0 +1,210 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { databaseBackup } from '../backup'
+import { databaseRestore } from '../restore'
+import { promises as fs } from 'fs'
+import path from 'path'
+
+// Mock pg-dump-restore for integration tests
+vi.mock('pg-dump-restore', () => ({
+  pgDump: vi.fn().mockImplementation(async (connectionOptions, dumpOptions) => {
+    // 模拟创建备份文件
+    const { filePath } = dumpOptions
+    if (filePath) {
+      const fs = await import('fs')
+      await fs.promises.writeFile(filePath, 'mock backup data')
+    }
+  }),
+  pgRestore: vi.fn().mockResolvedValue(undefined),
+}))
+
+// Mock fs with importOriginal for integration tests
+vi.mock('fs', async (importOriginal) => {
+  const actual = await importOriginal()
+  return {
+    ...actual,
+    promises: {
+      ...actual.promises,
+      mkdir: vi.fn().mockResolvedValue(undefined),
+      chmod: vi.fn().mockResolvedValue(undefined),
+      readdir: vi.fn().mockResolvedValue([]),
+      stat: vi.fn().mockResolvedValue({ size: 1024, mtimeMs: Date.now() }),
+      access: vi.fn().mockResolvedValue(undefined),
+      unlink: vi.fn().mockResolvedValue(undefined),
+      writeFile: vi.fn().mockResolvedValue(undefined),
+      rm: vi.fn().mockResolvedValue(undefined),
+      utimes: vi.fn().mockResolvedValue(undefined),
+    },
+  }
+})
+
+// Mock node-cron
+vi.mock('node-cron', () => ({
+  default: {
+    schedule: vi.fn().mockReturnValue({
+      stop: vi.fn(),
+      nextDate: vi.fn().mockReturnValue(new Date()),
+    }),
+  },
+}))
+
+// Mock logger
+vi.mock('../logger', () => ({
+  logger: {
+    db: vi.fn(),
+    error: vi.fn(),
+  },
+}))
+
+describe('Database Backup Integration', () => {
+  const testBackupDir = './test-backups'
+
+  beforeEach(async () => {
+    vi.clearAllMocks()
+
+    // 设置测试环境变量
+    process.env.BACKUP_DIR = testBackupDir
+    process.env.BACKUP_RETENTION_DAYS = '1'
+
+    // 清理测试目录
+    try {
+      await fs.rm(testBackupDir, { recursive: true, force: true })
+    } catch (error) {
+      // 目录可能不存在
+    }
+  })
+
+  afterEach(async () => {
+    // 清理测试目录
+    try {
+      await fs.rm(testBackupDir, { recursive: true, force: true })
+    } catch (error) {
+      // 忽略错误
+    }
+
+    vi.restoreAllMocks()
+  })
+
+  describe('Backup Creation', () => {
+    it('应该成功创建备份文件', async () => {
+      const backupFile = await databaseBackup.createBackup()
+
+      expect(backupFile).toBeDefined()
+      expect(backupFile).toContain('.dump')
+
+      // 验证文件已创建
+      const exists = await databaseBackup.backupExists(backupFile)
+      expect(exists).toBe(true)
+
+      // 验证文件权限
+      const stats = await fs.stat(backupFile)
+      expect(stats.mode & 0o777).toBe(0o600) // 应该只有用户可读写
+    })
+
+    it('应该设置正确的文件权限', async () => {
+      const backupFile = await databaseBackup.createBackup()
+
+      const stats = await fs.stat(backupFile)
+      expect(stats.mode & 0o777).toBe(0o600)
+    })
+  })
+
+  describe('Backup Cleanup', () => {
+    it('应该清理旧的备份文件', async () => {
+      // 创建一些测试备份文件
+      const now = Date.now()
+      const oldFileTime = now - (2 * 24 * 60 * 60 * 1000) // 2天前
+      const newFileTime = now - (12 * 60 * 60 * 1000) // 12小时前
+
+      const oldBackup = path.join(testBackupDir, 'backup-old.dump')
+      const newBackup = path.join(testBackupDir, 'backup-new.dump')
+
+      await fs.mkdir(testBackupDir, { recursive: true })
+      await fs.writeFile(oldBackup, 'old backup data')
+      await fs.writeFile(newBackup, 'new backup data')
+
+      // 修改文件时间
+      await fs.utimes(oldBackup, new Date(oldFileTime), new Date(oldFileTime))
+      await fs.utimes(newBackup, new Date(newFileTime), new Date(newFileTime))
+
+      // 执行清理
+      await databaseBackup.cleanupOldBackups()
+
+      // 验证只有旧文件被删除
+      const oldExists = await databaseBackup.backupExists(oldBackup)
+      const newExists = await databaseBackup.backupExists(newBackup)
+
+      expect(oldExists).toBe(false)
+      expect(newExists).toBe(true)
+    })
+  })
+
+  describe('Backup Management', () => {
+    it('应该能够检查备份文件是否存在', async () => {
+      const backupFile = await databaseBackup.createBackup()
+
+      const exists = await databaseBackup.backupExists(backupFile)
+      expect(exists).toBe(true)
+
+      const notExists = await databaseBackup.backupExists('/nonexistent/path.dump')
+      expect(notExists).toBe(false)
+    })
+
+    it('应该能够获取备份文件信息', async () => {
+      const backupFile = await databaseBackup.createBackup()
+
+      const info = await databaseBackup.getBackupInfo(backupFile)
+
+      expect(info).toHaveProperty('size')
+      expect(info).toHaveProperty('mtime')
+      expect(info).toHaveProperty('formattedSize')
+      expect(info.formattedSize).toBe('14 B') // 'mock backup data' 的长度
+    })
+  })
+
+  describe('Scheduled Backups', () => {
+    it('应该启动和停止定时备份', () => {
+      const cron = require('node-cron')
+
+      databaseBackup.startScheduledBackups()
+      expect(cron.schedule).toHaveBeenCalled()
+
+      databaseBackup.stopScheduledBackups()
+      // 验证stop方法被调用
+      expect(cron.schedule().stop).toHaveBeenCalled()
+    })
+
+    it('应该返回备份状态', () => {
+      databaseBackup.startScheduledBackups()
+
+      const status = databaseBackup.getBackupStatus()
+
+      expect(status).toHaveProperty('scheduled')
+      expect(status).toHaveProperty('nextRun')
+      expect(status).toHaveProperty('lastRun')
+      expect(status.scheduled).toBe(true)
+    })
+  })
+
+  describe('Restore Integration', () => {
+    it('应该能够找到最新备份', async () => {
+      // 创建多个备份文件
+      await databaseBackup.createBackup()
+      await new Promise(resolve => setTimeout(resolve, 100)) // 确保时间戳不同
+      await databaseBackup.createBackup()
+
+      const latestBackup = await databaseRestore.findLatestBackup()
+      expect(latestBackup).toBeDefined()
+      expect(latestBackup).toContain('.dump')
+    })
+
+    it('应该能够列出所有备份', async () => {
+      // 创建多个备份文件
+      await databaseBackup.createBackup()
+      await databaseBackup.createBackup()
+
+      const backups = await databaseRestore.listBackups()
+      expect(backups.length).toBe(2)
+      expect(backups.every(b => b.endsWith('.dump'))).toBe(true)
+    })
+  })
+})

+ 212 - 0
src/server/utils/__tests__/backup.test.ts

@@ -0,0 +1,212 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { DatabaseBackup } from '../backup'
+import { promises as fs } from 'fs'
+import path from 'path'
+
+// Mock pg-dump-restore
+vi.mock('pg-dump-restore', () => ({
+  pgDump: vi.fn().mockResolvedValue(undefined),
+  pgRestore: vi.fn().mockResolvedValue(undefined),
+}))
+
+// Mock node-cron
+vi.mock('node-cron', () => ({
+  default: {
+    schedule: vi.fn().mockReturnValue({
+      stop: vi.fn(),
+      nextDate: vi.fn().mockReturnValue(new Date()),
+    }),
+  },
+}))
+
+// Mock fs with importOriginal for partial mocking
+vi.mock('fs', async (importOriginal) => {
+  const actual = await importOriginal()
+  return {
+    ...actual,
+    promises: {
+      ...actual.promises,
+      mkdir: vi.fn().mockResolvedValue(undefined),
+      chmod: vi.fn().mockResolvedValue(undefined),
+      readdir: vi.fn().mockResolvedValue([]),
+      stat: vi.fn().mockResolvedValue({ size: 1024, mtimeMs: Date.now() }),
+      access: vi.fn().mockResolvedValue(undefined),
+      unlink: vi.fn().mockResolvedValue(undefined),
+    },
+  }
+})
+
+// Mock logger
+vi.mock('../logger', () => ({
+  logger: {
+    db: vi.fn(),
+    error: vi.fn(),
+  },
+}))
+
+describe('DatabaseBackup', () => {
+  let backup: DatabaseBackup
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    backup = DatabaseBackup.getInstance()
+  })
+
+  afterEach(() => {
+    vi.restoreAllMocks()
+  })
+
+  describe('getInstance', () => {
+    it('应该返回单例实例', () => {
+      const instance1 = DatabaseBackup.getInstance()
+      const instance2 = DatabaseBackup.getInstance()
+      expect(instance1).toBe(instance2)
+    })
+  })
+
+  describe('ensureBackupDir', () => {
+    it('应该创建备份目录并设置权限', async () => {
+      const { mkdir, chmod } = await import('fs')
+
+      await backup.ensureBackupDir()
+
+      expect(mkdir).toHaveBeenCalledWith('./backups', { recursive: true })
+      expect(chmod).toHaveBeenCalledWith('./backups', 0o700)
+    })
+
+    it('应该在创建目录失败时抛出错误', async () => {
+      const { mkdir } = await import('fs')
+      const { logger } = await import('../logger')
+
+      vi.mocked(mkdir).mockRejectedValueOnce(new Error('创建目录失败'))
+
+      await expect(backup.ensureBackupDir()).rejects.toThrow('创建目录失败')
+      expect(logger.error).toHaveBeenCalled()
+    })
+  })
+
+  describe('getDbConfig', () => {
+    it('应该返回正确的数据库配置', () => {
+      process.env.DB_HOST = 'test-host'
+      process.env.DB_PORT = '5433'
+      process.env.DB_DATABASE = 'test-db'
+      process.env.DB_USERNAME = 'test-user'
+      process.env.DB_PASSWORD = 'test-password'
+
+      const config = (backup as any).getDbConfig()
+
+      expect(config).toEqual({
+        host: 'test-host',
+        port: 5433,
+        database: 'test-db',
+        username: 'test-user',
+        password: 'test-password',
+      })
+    })
+
+    it('应该使用默认值当环境变量未设置时', () => {
+      delete process.env.DB_HOST
+      delete process.env.DB_PORT
+      delete process.env.DB_DATABASE
+      delete process.env.DB_USERNAME
+      delete process.env.DB_PASSWORD
+
+      const config = (backup as any).getDbConfig()
+
+      expect(config).toEqual({
+        host: 'localhost',
+        port: 5432,
+        database: 'postgres',
+        username: 'postgres',
+        password: '',
+      })
+    })
+  })
+
+  describe('formatFileSize', () => {
+    it('应该正确格式化文件大小', () => {
+      const formatFileSize = (backup as any).formatFileSize
+
+      expect(formatFileSize(0)).toBe('0 B')
+      expect(formatFileSize(1024)).toBe('1 KB')
+      expect(formatFileSize(1048576)).toBe('1 MB')
+      expect(formatFileSize(1073741824)).toBe('1 GB')
+    })
+  })
+
+  describe('backupExists', () => {
+    it('应该返回true当备份文件存在时', async () => {
+      const { access } = await import('fs')
+
+      const exists = await backup.backupExists('/path/to/backup.dump')
+
+      expect(exists).toBe(true)
+      expect(access).toHaveBeenCalledWith('/path/to/backup.dump')
+    })
+
+    it('应该返回false当备份文件不存在时', async () => {
+      const { access } = await import('fs')
+      vi.mocked(access).mockRejectedValueOnce(new Error('文件不存在'))
+
+      const exists = await backup.backupExists('/path/to/backup.dump')
+
+      expect(exists).toBe(false)
+    })
+  })
+
+  describe('cleanupOldBackups', () => {
+    it('应该清理7天前的旧备份', async () => {
+      const { readdir, stat, unlink } = await import('fs')
+      const { logger } = await import('../logger')
+
+      const now = Date.now()
+      const oldFileTime = now - (8 * 24 * 60 * 60 * 1000) // 8天前
+      const newFileTime = now - (6 * 24 * 60 * 60 * 1000) // 6天前
+
+      vi.mocked(readdir).mockResolvedValue(['backup-old.dump', 'backup-new.dump'])
+      vi.mocked(stat)
+        .mockResolvedValueOnce({ mtimeMs: oldFileTime } as any)
+        .mockResolvedValueOnce({ mtimeMs: newFileTime } as any)
+
+      await backup.cleanupOldBackups()
+
+      expect(unlink).toHaveBeenCalledTimes(1)
+      expect(unlink).toHaveBeenCalledWith(path.join('./backups', 'backup-old.dump'))
+      expect(logger.db).toHaveBeenCalledWith('删除旧备份文件: backup-old.dump')
+    })
+
+    it('应该在清理失败时记录错误但不抛出', async () => {
+      const { readdir, stat } = await import('fs')
+      const { logger } = await import('../logger')
+
+      vi.mocked(readdir).mockRejectedValueOnce(new Error('读取目录失败'))
+
+      await expect(backup.cleanupOldBackups()).resolves.not.toThrow()
+      expect(logger.error).toHaveBeenCalled()
+    })
+  })
+
+  describe('startScheduledBackups', () => {
+    it('应该启动定时备份任务', () => {
+      const cron = require('node-cron')
+      const { logger } = require('../logger')
+
+      backup.startScheduledBackups()
+
+      expect(cron.schedule).toHaveBeenCalledWith('0 2 * * *', expect.any(Function))
+      expect(logger.db).toHaveBeenCalledWith('备份调度已启动: 0 2 * * *')
+    })
+  })
+
+  describe('stopScheduledBackups', () => {
+    it('应该停止定时备份任务', () => {
+      const { logger } = require('../logger')
+
+      // 先启动再停止
+      backup.startScheduledBackups()
+      backup.stopScheduledBackups()
+
+      expect(logger.db).toHaveBeenCalledWith('备份调度已停止')
+    })
+  })
+})

+ 210 - 0
src/server/utils/__tests__/restore.test.ts

@@ -0,0 +1,210 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { DatabaseRestore } from '../restore'
+import { promises as fs } from 'fs'
+import path from 'path'
+
+// Mock pg-dump-restore
+vi.mock('pg-dump-restore', () => ({
+  pgRestore: vi.fn().mockResolvedValue(undefined),
+}))
+
+// Mock fs with importOriginal for partial mocking
+vi.mock('fs', async (importOriginal) => {
+  const actual = await importOriginal()
+  return {
+    ...actual,
+    promises: {
+      ...actual.promises,
+      readdir: vi.fn().mockResolvedValue([]),
+      access: vi.fn().mockResolvedValue(undefined),
+      stat: vi.fn().mockResolvedValue({ size: 1024, mtime: new Date() }),
+    },
+  }
+})
+
+// Mock logger
+vi.mock('../logger', () => ({
+  logger: {
+    db: vi.fn(),
+    error: vi.fn(),
+  },
+}))
+
+describe('DatabaseRestore', () => {
+  let restore: DatabaseRestore
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    restore = new DatabaseRestore()
+  })
+
+  afterEach(() => {
+    vi.restoreAllMocks()
+  })
+
+  describe('getDbConfig', () => {
+    it('应该返回正确的数据库配置', () => {
+      process.env.DB_HOST = 'test-host'
+      process.env.DB_PORT = '5433'
+      process.env.DB_DATABASE = 'test-db'
+      process.env.DB_USERNAME = 'test-user'
+      process.env.DB_PASSWORD = 'test-password'
+
+      const config = (restore as any).getDbConfig()
+
+      expect(config).toEqual({
+        host: 'test-host',
+        port: 5433,
+        database: 'test-db',
+        username: 'test-user',
+        password: 'test-password',
+      })
+    })
+
+    it('应该使用默认值当环境变量未设置时', () => {
+      delete process.env.DB_HOST
+      delete process.env.DB_PORT
+      delete process.env.DB_DATABASE
+      delete process.env.DB_USERNAME
+      delete process.env.DB_PASSWORD
+
+      const config = (restore as any).getDbConfig()
+
+      expect(config).toEqual({
+        host: 'localhost',
+        port: 5432,
+        database: 'postgres',
+        username: 'postgres',
+        password: '',
+      })
+    })
+  })
+
+  describe('findLatestBackup', () => {
+    it('应该返回最新的备份文件', async () => {
+      const { readdir } = await import('fs')
+
+      vi.mocked(readdir).mockResolvedValue([
+        'backup-2024-01-01T00-00-00Z.dump',
+        'backup-2024-01-03T00-00-00Z.dump',
+        'backup-2024-01-02T00-00-00Z.dump',
+      ])
+
+      const latest = await restore.findLatestBackup()
+
+      expect(latest).toBe(path.join('./backups', 'backup-2024-01-03T00-00-00Z.dump'))
+    })
+
+    it('应该返回null当没有备份文件时', async () => {
+      const { readdir } = await import('fs')
+
+      vi.mocked(readdir).mockResolvedValue(['some-other-file.txt'])
+
+      const latest = await restore.findLatestBackup()
+
+      expect(latest).toBeNull()
+    })
+
+    it('应该在读取目录失败时返回null', async () => {
+      const { readdir } = await import('fs')
+      const { logger } = await import('../logger')
+
+      vi.mocked(readdir).mockRejectedValueOnce(new Error('读取目录失败'))
+
+      const latest = await restore.findLatestBackup()
+
+      expect(latest).toBeNull()
+      expect(logger.error).toHaveBeenCalled()
+    })
+  })
+
+  describe('listBackups', () => {
+    it('应该返回所有备份文件列表', async () => {
+      const { readdir } = await import('fs')
+
+      vi.mocked(readdir).mockResolvedValue([
+        'backup-2024-01-01.dump',
+        'some-other-file.txt',
+        'backup-2024-01-02.dump',
+      ])
+
+      const backups = await restore.listBackups()
+
+      expect(backups).toEqual([
+        'backup-2024-01-01.dump',
+        'backup-2024-01-02.dump',
+      ])
+    })
+
+    it('应该在读取目录失败时返回空数组', async () => {
+      const { readdir } = await import('fs')
+      const { logger } = await import('../logger')
+
+      vi.mocked(readdir).mockRejectedValueOnce(new Error('读取目录失败'))
+
+      const backups = await restore.listBackups()
+
+      expect(backups).toEqual([])
+      expect(logger.error).toHaveBeenCalled()
+    })
+  })
+
+  describe('backupExists', () => {
+    it('应该返回true当备份文件存在时', async () => {
+      const { access } = await import('fs')
+
+      const exists = await restore.backupExists('/path/to/backup.dump')
+
+      expect(exists).toBe(true)
+      expect(access).toHaveBeenCalledWith('/path/to/backup.dump')
+    })
+
+    it('应该返回false当备份文件不存在时', async () => {
+      const { access } = await import('fs')
+      vi.mocked(access).mockRejectedValueOnce(new Error('文件不存在'))
+
+      const exists = await restore.backupExists('/path/to/backup.dump')
+
+      expect(exists).toBe(false)
+    })
+  })
+
+  describe('getBackupInfo', () => {
+    it('应该返回备份文件信息', async () => {
+      const { stat } = await import('fs')
+      const testDate = new Date()
+
+      vi.mocked(stat).mockResolvedValueOnce({
+        size: 1048576,
+        mtime: testDate,
+      } as any)
+
+      const info = await restore.getBackupInfo('/path/to/backup.dump')
+
+      expect(info).toEqual({
+        size: 1048576,
+        mtime: testDate,
+        formattedSize: '1 MB',
+      })
+    })
+
+    it('应该在获取信息失败时抛出错误', async () => {
+      const { stat } = await import('fs')
+
+      vi.mocked(stat).mockRejectedValueOnce(new Error('获取文件信息失败'))
+
+      await expect(restore.getBackupInfo('/path/to/backup.dump')).rejects.toThrow('获取备份信息失败')
+    })
+  })
+
+  describe('formatFileSize', () => {
+    it('应该正确格式化文件大小', () => {
+      const formatFileSize = (restore as any).formatFileSize
+
+      expect(formatFileSize(0)).toBe('0 B')
+      expect(formatFileSize(1024)).toBe('1 KB')
+      expect(formatFileSize(1048576)).toBe('1 MB')
+      expect(formatFileSize(1073741824)).toBe('1 GB')
+    })
+  })
+})

+ 6 - 4
src/server/utils/backup.ts

@@ -21,7 +21,7 @@ export interface BackupOptions {
 
 export class DatabaseBackup {
   private static instance: DatabaseBackup
-  private cronJob: cron.ScheduledTask | null = null
+  private cronJob: any = null
 
   private constructor() {}
 
@@ -69,8 +69,9 @@ export class DatabaseBackup {
         database: config.database,
         username: config.username,
         password: config.password,
-        format: 'custom' as const,
-        file: backupFile,
+      }, {
+        format: 'custom',
+        filePath: backupFile,
       })
 
       await fs.chmod(backupFile, 0o600)
@@ -95,12 +96,13 @@ export class DatabaseBackup {
 
       logger.db(`开始恢复数据库备份: ${backupFile}`)
 
-      await restore({
+      await pgRestore({
         host: config.host,
         port: config.port,
         database: config.database,
         username: config.username,
         password: config.password,
+      }, {
         file: backupFile,
       })
 

+ 162 - 0
src/server/utils/restore.ts

@@ -0,0 +1,162 @@
+import { pgRestore } from 'pg-dump-restore'
+import { promises as fs } from 'fs'
+import path from 'path'
+import { logger } from './logger'
+import process from 'node:process'
+
+const BACKUP_DIR = process.env.BACKUP_DIR || './backups'
+
+export interface RestoreOptions {
+  host?: string
+  port?: number
+  database?: string
+  username?: string
+  password?: string
+  backupFile?: string
+}
+
+export class DatabaseRestore {
+  private getDbConfig() {
+    return {
+      host: process.env.DB_HOST || 'localhost',
+      port: parseInt(process.env.DB_PORT || '5432'),
+      database: process.env.DB_DATABASE || 'postgres',
+      username: process.env.DB_USERNAME || 'postgres',
+      password: process.env.DB_PASSWORD || '',
+    }
+  }
+
+  async restoreDatabase(options: Partial<RestoreOptions> = {}): Promise<void> {
+    const config = { ...this.getDbConfig(), ...options }
+    let backupFile = options.backupFile
+
+    if (!backupFile) {
+      const latestBackup = await this.findLatestBackup()
+      if (!latestBackup) {
+        throw new Error('未找到可用的备份文件')
+      }
+      backupFile = latestBackup
+    }
+
+    if (!await this.backupExists(backupFile)) {
+      throw new Error(`备份文件不存在: ${backupFile}`)
+    }
+
+    try {
+      logger.db(`开始恢复数据库备份: ${backupFile}`)
+
+      await pgRestore({
+        host: config.host,
+        port: config.port,
+        database: config.database,
+        username: config.username,
+        password: config.password,
+      }, {
+        filePath: backupFile,
+      })
+
+      logger.db(`数据库恢复成功: ${config.database}`)
+    } catch (error) {
+      logger.error(`数据库恢复失败: ${error}`)
+      throw error
+    }
+  }
+
+  async findLatestBackup(): Promise<string | null> {
+    try {
+      const files = await fs.readdir(BACKUP_DIR)
+      const backupFiles = files
+        .filter(f => f.endsWith('.dump'))
+        .sort()
+        .reverse()
+
+      return backupFiles.length > 0 ? path.join(BACKUP_DIR, backupFiles[0]) : null
+    } catch (error) {
+      logger.error(`查找备份文件失败: ${error}`)
+      return null
+    }
+  }
+
+  async listBackups(): Promise<string[]> {
+    try {
+      const files = await fs.readdir(BACKUP_DIR)
+      return files.filter(f => f.endsWith('.dump'))
+    } catch (error) {
+      logger.error(`列出备份文件失败: ${error}`)
+      return []
+    }
+  }
+
+  async backupExists(backupFile: string): Promise<boolean> {
+    try {
+      await fs.access(backupFile)
+      return true
+    } catch {
+      return false
+    }
+  }
+
+  async getBackupInfo(backupFile: string): Promise<{
+    size: number
+    mtime: Date
+    formattedSize: string
+  }> {
+    try {
+      const stats = await fs.stat(backupFile)
+      return {
+        size: stats.size,
+        mtime: stats.mtime,
+        formattedSize: this.formatFileSize(stats.size)
+      }
+    } catch (error) {
+      throw new Error(`获取备份信息失败: ${error}`)
+    }
+  }
+
+  private formatFileSize(bytes: number): string {
+    const sizes = ['B', 'KB', 'MB', 'GB']
+    if (bytes === 0) return '0 B'
+    const i = Math.floor(Math.log(bytes) / Math.log(1024))
+    return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]
+  }
+}
+
+export const databaseRestore = new DatabaseRestore()
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  async function main() {
+    const command = process.argv[2]
+    const restoreTool = new DatabaseRestore()
+
+    switch (command) {
+      case 'restore':
+        const backupFile = process.argv[3]
+        await restoreTool.restoreDatabase({ backupFile })
+        break
+      case 'list':
+        const backups = await restoreTool.listBackups()
+        console.log('可用备份文件:')
+        backups.forEach(file => console.log(`  - ${file}`))
+        break
+      case 'latest':
+        const latest = await restoreTool.findLatestBackup()
+        if (latest) {
+          console.log(`最新备份文件: ${latest}`)
+          const info = await restoreTool.getBackupInfo(latest)
+          console.log(`文件大小: ${info.formattedSize}`)
+          console.log(`修改时间: ${info.mtime}`)
+        } else {
+          console.log('未找到备份文件')
+        }
+        break
+      default:
+        console.log('用法: node restore.ts [restore <file>|list|latest]')
+        console.log('  restore <file>  - 恢复指定备份文件')
+        console.log('  list            - 列出所有备份文件')
+        console.log('  latest          - 显示最新备份文件信息')
+        process.exit(1)
+    }
+  }
+
+  main().catch(console.error)
+}