2
0
Эх сурвалжийг харах

✨ feat(database): add database backup functionality

- add node-cron and pg-dump-restore dependencies for scheduled backups
- implement DatabaseBackup class with backup, restore and cleanup capabilities
- add scheduled backup task running daily at 2 AM by default
- add backup retention policy (7 days by default)
- create backup directory with proper permissions

🔧 chore(deps): add required type definitions and dependencies

- add @types/node-cron for type safety
- update package.json with new dependencies
- update pnpm-lock.yaml with dependency resolution

♻️ refactor(settings): add node command support in Claude settings

- add "Bash(node:*)" to allowed commands in Claude settings
- enable execution of node scripts through Claude interface
yourname 2 сар өмнө
parent
commit
fd5b892c8c

+ 2 - 1
.claude/settings.local.json

@@ -24,7 +24,8 @@
       "Bash(psql:*)",
       "Bash(npx playwright open:*)",
       "Bash(npx playwright codegen:*)",
-      "Bash(pnpm run)"
+      "Bash(pnpm run)",
+      "Bash(node:*)"
     ],
     "deny": [],
     "ask": []

+ 3 - 0
package.json

@@ -63,6 +63,7 @@
     "@radix-ui/react-toggle-group": "^1.1.10",
     "@radix-ui/react-tooltip": "^1.2.7",
     "@tanstack/react-query": "^5.83.0",
+    "@types/node-cron": "^3.0.11",
     "axios": "^1.11.0",
     "bcrypt": "^6.0.0",
     "class-variance-authority": "^0.7.1",
@@ -79,7 +80,9 @@
     "jsonwebtoken": "^9.0.2",
     "lucide-react": "^0.536.0",
     "next-themes": "^0.4.6",
+    "node-cron": "^4.2.1",
     "pg": "^8.16.3",
+    "pg-dump-restore": "1.0.13",
     "react": "^19.1.0",
     "react-day-picker": "^9.8.1",
     "react-dom": "^19.1.0",

+ 99 - 0
pnpm-lock.yaml

@@ -110,6 +110,9 @@ importers:
       '@tanstack/react-query':
         specifier: ^5.83.0
         version: 5.83.0(react@19.1.0)
+      '@types/node-cron':
+        specifier: ^3.0.11
+        version: 3.0.11
       axios:
         specifier: ^1.11.0
         version: 1.11.0(debug@4.4.1)
@@ -158,9 +161,15 @@ importers:
       next-themes:
         specifier: ^0.4.6
         version: 0.4.6(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+      node-cron:
+        specifier: ^4.2.1
+        version: 4.2.1
       pg:
         specifier: ^8.16.3
         version: 8.16.3
+      pg-dump-restore:
+        specifier: 1.0.13
+        version: 1.0.13
       react:
         specifier: ^19.1.0
         version: 19.1.0
@@ -1686,6 +1695,9 @@ packages:
   '@types/ms@2.1.0':
     resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==}
 
+  '@types/node-cron@3.0.11':
+    resolution: {integrity: sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==}
+
   '@types/node@20.19.14':
     resolution: {integrity: sha512-gqiKWld3YIkmtrrg9zDvg9jfksZCcPywXVN7IauUGhilwGV/yOyeUsvpR796m/Jye0zUzMXPKe8Ct1B79A7N5Q==}
 
@@ -2379,6 +2391,10 @@ packages:
   eventemitter3@4.0.7:
     resolution: {integrity: sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==}
 
+  execa@5.1.1:
+    resolution: {integrity: sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg==}
+    engines: {node: '>=10'}
+
   expect-type@1.2.2:
     resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==}
     engines: {node: '>=12.0.0'}
@@ -2490,6 +2506,10 @@ packages:
     resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==}
     engines: {node: '>= 0.4'}
 
+  get-stream@6.0.1:
+    resolution: {integrity: sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==}
+    engines: {node: '>=10'}
+
   get-symbol-description@1.1.0:
     resolution: {integrity: sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==}
     engines: {node: '>= 0.4'}
@@ -2581,6 +2601,10 @@ packages:
     resolution: {integrity: sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==}
     engines: {node: '>= 6'}
 
+  human-signals@2.1.0:
+    resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==}
+    engines: {node: '>=10.17.0'}
+
   iconv-lite@0.6.3:
     resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==}
     engines: {node: '>=0.10.0'}
@@ -2714,6 +2738,10 @@ packages:
     resolution: {integrity: sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==}
     engines: {node: '>= 0.4'}
 
+  is-stream@2.0.1:
+    resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==}
+    engines: {node: '>=8'}
+
   is-string@1.1.1:
     resolution: {integrity: sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==}
     engines: {node: '>= 0.4'}
@@ -2963,6 +2991,9 @@ packages:
     resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
     engines: {node: '>= 0.4'}
 
+  merge-stream@2.0.0:
+    resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==}
+
   merge2@1.4.1:
     resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
     engines: {node: '>= 8'}
@@ -2983,6 +3014,10 @@ packages:
     resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==}
     engines: {node: '>= 0.6'}
 
+  mimic-fn@2.1.0:
+    resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==}
+    engines: {node: '>=6'}
+
   min-indent@1.0.1:
     resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==}
     engines: {node: '>=4'}
@@ -3047,10 +3082,18 @@ packages:
     resolution: {integrity: sha512-/bRZty2mXUIFY/xU5HLvveNHlswNJej+RnxBjOMkidWfwZzgTbPG1E3K5TOxRLOR+5hX7bSofy8yf1hZevMS8A==}
     engines: {node: ^18 || ^20 || >= 21}
 
+  node-cron@4.2.1:
+    resolution: {integrity: sha512-lgimEHPE/QDgFlywTd8yTR61ptugX3Qer29efeyWw2rv259HtGBNn1vZVmp8lB9uo9wC0t/AT4iGqXxia+CJFg==}
+    engines: {node: '>=6.0.0'}
+
   node-gyp-build@4.8.4:
     resolution: {integrity: sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==}
     hasBin: true
 
+  npm-run-path@4.0.1:
+    resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==}
+    engines: {node: '>=8'}
+
   nwsapi@2.2.22:
     resolution: {integrity: sha512-ujSMe1OWVn55euT1ihwCI1ZcAaAU3nxUiDwfDQldc51ZXaB9m2AyOn6/jh1BLe2t/G8xd6uKG1UBF2aZJeg2SQ==}
 
@@ -3086,6 +3129,10 @@ packages:
     resolution: {integrity: sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==}
     engines: {node: '>= 0.8'}
 
+  onetime@5.1.2:
+    resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==}
+    engines: {node: '>=6'}
+
   openapi3-ts@4.5.0:
     resolution: {integrity: sha512-jaL+HgTq2Gj5jRcfdutgRGLosCy/hT8sQf6VOy+P+g36cZOjI1iukdPnijC+4CmeRzg/jEllJUboEic2FhxhtQ==}
 
@@ -3143,6 +3190,9 @@ packages:
   pg-connection-string@2.9.1:
     resolution: {integrity: sha512-nkc6NpDcvPVpZXxrreI/FOtX3XemeLl8E0qFr6F2Lrm/I8WOnaWNhIPK2Z7OHpw7gh5XJThi6j6ppgNoaT1w4w==}
 
+  pg-dump-restore@1.0.13:
+    resolution: {integrity: sha512-bsWb2arxdAwnlHPj04s6OQat9OG0bfFSo8J0a5k6dBg03Z2B8xS5oiVOa5xdeSaKqnoOABc8hSnH/L1vhed/fA==}
+
   pg-int8@1.0.1:
     resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==}
     engines: {node: '>=4.0.0'}
@@ -3477,6 +3527,9 @@ packages:
   siginfo@2.0.0:
     resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
 
+  signal-exit@3.0.7:
+    resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==}
+
   signal-exit@4.1.0:
     resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
     engines: {node: '>=14'}
@@ -3556,6 +3609,10 @@ packages:
     resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
     engines: {node: '>=12'}
 
+  strip-final-newline@2.0.0:
+    resolution: {integrity: sha512-BrpvfNAE3dcvq7ll3xVumzjKjZQ5tI1sEUIKr3Uoks0XUl45St3FlatVqef9prk4jRDzhW6WZg+3bk93y6pLjA==}
+    engines: {node: '>=6'}
+
   strip-indent@3.0.0:
     resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==}
     engines: {node: '>=8'}
@@ -5237,6 +5294,8 @@ snapshots:
 
   '@types/ms@2.1.0': {}
 
+  '@types/node-cron@3.0.11': {}
+
   '@types/node@20.19.14':
     dependencies:
       undici-types: 6.21.0
@@ -6131,6 +6190,18 @@ snapshots:
 
   eventemitter3@4.0.7: {}
 
+  execa@5.1.1:
+    dependencies:
+      cross-spawn: 7.0.6
+      get-stream: 6.0.1
+      human-signals: 2.1.0
+      is-stream: 2.0.1
+      merge-stream: 2.0.0
+      npm-run-path: 4.0.1
+      onetime: 5.1.2
+      signal-exit: 3.0.7
+      strip-final-newline: 2.0.0
+
   expect-type@1.2.2: {}
 
   fast-deep-equal@3.1.3: {}
@@ -6244,6 +6315,8 @@ snapshots:
       dunder-proto: 1.0.1
       es-object-atoms: 1.1.1
 
+  get-stream@6.0.1: {}
+
   get-symbol-description@1.1.0:
     dependencies:
       call-bound: 1.0.4
@@ -6340,6 +6413,8 @@ snapshots:
       - supports-color
     optional: true
 
+  human-signals@2.1.0: {}
+
   iconv-lite@0.6.3:
     dependencies:
       safer-buffer: 2.1.2
@@ -6466,6 +6541,8 @@ snapshots:
     dependencies:
       call-bound: 1.0.4
 
+  is-stream@2.0.1: {}
+
   is-string@1.1.1:
     dependencies:
       call-bound: 1.0.4
@@ -6728,6 +6805,8 @@ snapshots:
 
   math-intrinsics@1.1.0: {}
 
+  merge-stream@2.0.0: {}
+
   merge2@1.4.1: {}
 
   micromatch@4.0.8:
@@ -6743,6 +6822,8 @@ snapshots:
     dependencies:
       mime-db: 1.52.0
 
+  mimic-fn@2.1.0: {}
+
   min-indent@1.0.1: {}
 
   minimatch@3.1.2:
@@ -6798,8 +6879,14 @@ snapshots:
 
   node-addon-api@8.5.0: {}
 
+  node-cron@4.2.1: {}
+
   node-gyp-build@4.8.4: {}
 
+  npm-run-path@4.0.1:
+    dependencies:
+      path-key: 3.1.1
+
   nwsapi@2.2.22:
     optional: true
 
@@ -6841,6 +6928,10 @@ snapshots:
 
   on-headers@1.1.0: {}
 
+  onetime@5.1.2:
+    dependencies:
+      mimic-fn: 2.1.0
+
   openapi3-ts@4.5.0:
     dependencies:
       yaml: 2.8.0
@@ -6899,6 +6990,10 @@ snapshots:
 
   pg-connection-string@2.9.1: {}
 
+  pg-dump-restore@1.0.13:
+    dependencies:
+      execa: 5.1.1
+
   pg-int8@1.0.1: {}
 
   pg-pool@3.10.1(pg@8.16.3):
@@ -7271,6 +7366,8 @@ snapshots:
 
   siginfo@2.0.0: {}
 
+  signal-exit@3.0.7: {}
+
   signal-exit@4.1.0: {}
 
   sirv@3.0.1:
@@ -7369,6 +7466,8 @@ snapshots:
     dependencies:
       ansi-regex: 6.1.0
 
+  strip-final-newline@2.0.0: {}
+
   strip-indent@3.0.0:
     dependencies:
       min-indent: 1.0.1

+ 242 - 0
src/server/utils/backup.ts

@@ -0,0 +1,242 @@
+import { pgDump, pgRestore } from 'pg-dump-restore'
+import { promises as fs } from 'fs'
+import path from 'path'
+import cron from 'node-cron'
+import { logger } from './logger'
+import process from 'node:process'
+
+const BACKUP_DIR = process.env.BACKUP_DIR || './backups'
+const RETENTION_DAYS = parseInt(process.env.BACKUP_RETENTION_DAYS || '7')
+const SCHEDULE = process.env.BACKUP_SCHEDULE || '0 2 * * *'
+
+export interface BackupOptions {
+  host?: string
+  port?: number
+  database?: string
+  username?: string
+  password?: string
+  format?: 'custom' | 'plain' | 'tar'
+  outputFile?: string
+}
+
+export class DatabaseBackup {
+  private static instance: DatabaseBackup
+  private cronJob: cron.ScheduledTask | null = null
+
+  private constructor() {}
+
+  static getInstance(): DatabaseBackup {
+    if (!DatabaseBackup.instance) {
+      DatabaseBackup.instance = new DatabaseBackup()
+    }
+    return DatabaseBackup.instance
+  }
+
+  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 ensureBackupDir(): Promise<void> {
+    try {
+      await fs.mkdir(BACKUP_DIR, { recursive: true })
+      await fs.chmod(BACKUP_DIR, 0o700)
+      logger.db(`备份目录已创建: ${BACKUP_DIR}`)
+    } catch (error) {
+      logger.error(`创建备份目录失败: ${error}`)
+      throw error
+    }
+  }
+
+  async createBackup(options: Partial<BackupOptions> = {}): Promise<string> {
+    const config = { ...this.getDbConfig(), ...options }
+    const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
+    const backupFile = options.outputFile || path.join(BACKUP_DIR, `backup-${timestamp}.dump`)
+
+    try {
+      await this.ensureBackupDir()
+
+      logger.db(`开始创建数据库备份: ${config.database}`)
+
+      await pgDump({
+        host: config.host,
+        port: config.port,
+        database: config.database,
+        username: config.username,
+        password: config.password,
+        format: 'custom' as const,
+        file: backupFile,
+      })
+
+      await fs.chmod(backupFile, 0o600)
+
+      const stats = await fs.stat(backupFile)
+      logger.db(`备份创建成功: ${backupFile} (${this.formatFileSize(stats.size)})`)
+
+      return backupFile
+    } catch (error) {
+      logger.error(`备份创建失败: ${error}`)
+      throw error
+    }
+  }
+
+  async restoreBackup(backupFile: string, options: Partial<BackupOptions> = {}): Promise<void> {
+    const config = { ...this.getDbConfig(), ...options }
+
+    try {
+      if (!await this.backupExists(backupFile)) {
+        throw new Error(`备份文件不存在: ${backupFile}`)
+      }
+
+      logger.db(`开始恢复数据库备份: ${backupFile}`)
+
+      await restore({
+        host: config.host,
+        port: config.port,
+        database: config.database,
+        username: config.username,
+        password: config.password,
+        file: backupFile,
+      })
+
+      logger.db(`数据库恢复成功: ${config.database}`)
+    } catch (error) {
+      logger.error(`数据库恢复失败: ${error}`)
+      throw error
+    }
+  }
+
+  async cleanupOldBackups(): Promise<void> {
+    try {
+      const files = await fs.readdir(BACKUP_DIR)
+      const now = Date.now()
+      const retentionTime = RETENTION_DAYS * 24 * 60 * 60 * 1000
+
+      for (const file of files) {
+        if (file.endsWith('.dump')) {
+          const filePath = path.join(BACKUP_DIR, file)
+          const stats = await fs.stat(filePath)
+
+          if (now - stats.mtimeMs > retentionTime) {
+            await fs.unlink(filePath)
+            logger.db(`删除旧备份文件: ${file}`)
+          }
+        }
+      }
+    } catch (error) {
+      logger.error(`清理旧备份失败: ${error}`)
+    }
+  }
+
+  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]
+  }
+
+  startScheduledBackups(): void {
+    if (this.cronJob) {
+      this.cronJob.stop()
+    }
+
+    this.cronJob = cron.schedule(SCHEDULE, async () => {
+      try {
+        logger.db('开始定时备份任务')
+        await this.createBackup()
+        await this.cleanupOldBackups()
+        logger.db('定时备份任务完成')
+      } catch (error) {
+        logger.error(`定时备份任务失败: ${error}`)
+      }
+    })
+
+    logger.db(`备份调度已启动: ${SCHEDULE}`)
+  }
+
+  stopScheduledBackups(): void {
+    if (this.cronJob) {
+      this.cronJob.stop()
+      logger.db('备份调度已停止')
+    }
+  }
+
+  getBackupStatus(): {
+    scheduled: boolean
+    nextRun: Date | null
+    lastRun: Date | null
+  } {
+    return {
+      scheduled: this.cronJob !== null,
+      nextRun: this.cronJob?.nextDate() || null,
+      lastRun: null
+    }
+  }
+}
+
+export const databaseBackup = DatabaseBackup.getInstance()
+
+if (import.meta.url === `file://${process.argv[1]}`) {
+  async function main() {
+    const command = process.argv[2]
+
+    switch (command) {
+      case 'backup':
+        await databaseBackup.createBackup()
+        break
+      case 'restore':
+        const backupFile = process.argv[3]
+        if (!backupFile) {
+          console.error('请指定要恢复的备份文件')
+          process.exit(1)
+        }
+        await databaseBackup.restoreBackup(backupFile)
+        break
+      case 'cleanup':
+        await databaseBackup.cleanupOldBackups()
+        break
+      case 'list':
+        const files = await fs.readdir(BACKUP_DIR)
+        const backupFiles = files.filter(f => f.endsWith('.dump'))
+        console.log('可用备份文件:')
+        backupFiles.forEach(file => console.log(`  - ${file}`))
+        break
+      default:
+        console.log('用法: node backup.ts [backup|restore <file>|cleanup|list]')
+        process.exit(1)
+    }
+  }
+
+  main().catch(console.error)
+}