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: any = 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 { 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 = {}): Promise { 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', filePath: 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 = {}): Promise { const config = { ...this.getDbConfig(), ...options } try { if (!await this.backupExists(backupFile)) { throw new Error(`备份文件不存在: ${backupFile}`) } logger.db(`开始恢复数据库备份: ${backupFile}`) await pgRestore({ 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 { 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 { 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 |cleanup|list]') process.exit(1) } } main().catch(console.error) }