backup.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242
  1. import { pgDump, pgRestore } from 'pg-dump-restore'
  2. import { promises as fs } from 'fs'
  3. import path from 'path'
  4. import cron from 'node-cron'
  5. import { logger } from './logger'
  6. import process from 'node:process'
  7. const BACKUP_DIR = process.env.BACKUP_DIR || './backups'
  8. const RETENTION_DAYS = parseInt(process.env.BACKUP_RETENTION_DAYS || '7')
  9. const SCHEDULE = process.env.BACKUP_SCHEDULE || '0 2 * * *'
  10. export interface BackupOptions {
  11. host?: string
  12. port?: number
  13. database?: string
  14. username?: string
  15. password?: string
  16. format?: 'custom' | 'plain' | 'tar'
  17. outputFile?: string
  18. }
  19. export class DatabaseBackup {
  20. private static instance: DatabaseBackup
  21. private cronJob: cron.ScheduledTask | null = null
  22. private constructor() {}
  23. static getInstance(): DatabaseBackup {
  24. if (!DatabaseBackup.instance) {
  25. DatabaseBackup.instance = new DatabaseBackup()
  26. }
  27. return DatabaseBackup.instance
  28. }
  29. private getDbConfig() {
  30. return {
  31. host: process.env.DB_HOST || 'localhost',
  32. port: parseInt(process.env.DB_PORT || '5432'),
  33. database: process.env.DB_DATABASE || 'postgres',
  34. username: process.env.DB_USERNAME || 'postgres',
  35. password: process.env.DB_PASSWORD || '',
  36. }
  37. }
  38. async ensureBackupDir(): Promise<void> {
  39. try {
  40. await fs.mkdir(BACKUP_DIR, { recursive: true })
  41. await fs.chmod(BACKUP_DIR, 0o700)
  42. logger.db(`备份目录已创建: ${BACKUP_DIR}`)
  43. } catch (error) {
  44. logger.error(`创建备份目录失败: ${error}`)
  45. throw error
  46. }
  47. }
  48. async createBackup(options: Partial<BackupOptions> = {}): Promise<string> {
  49. const config = { ...this.getDbConfig(), ...options }
  50. const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
  51. const backupFile = options.outputFile || path.join(BACKUP_DIR, `backup-${timestamp}.dump`)
  52. try {
  53. await this.ensureBackupDir()
  54. logger.db(`开始创建数据库备份: ${config.database}`)
  55. await pgDump({
  56. host: config.host,
  57. port: config.port,
  58. database: config.database,
  59. username: config.username,
  60. password: config.password,
  61. format: 'custom' as const,
  62. file: backupFile,
  63. })
  64. await fs.chmod(backupFile, 0o600)
  65. const stats = await fs.stat(backupFile)
  66. logger.db(`备份创建成功: ${backupFile} (${this.formatFileSize(stats.size)})`)
  67. return backupFile
  68. } catch (error) {
  69. logger.error(`备份创建失败: ${error}`)
  70. throw error
  71. }
  72. }
  73. async restoreBackup(backupFile: string, options: Partial<BackupOptions> = {}): Promise<void> {
  74. const config = { ...this.getDbConfig(), ...options }
  75. try {
  76. if (!await this.backupExists(backupFile)) {
  77. throw new Error(`备份文件不存在: ${backupFile}`)
  78. }
  79. logger.db(`开始恢复数据库备份: ${backupFile}`)
  80. await restore({
  81. host: config.host,
  82. port: config.port,
  83. database: config.database,
  84. username: config.username,
  85. password: config.password,
  86. file: backupFile,
  87. })
  88. logger.db(`数据库恢复成功: ${config.database}`)
  89. } catch (error) {
  90. logger.error(`数据库恢复失败: ${error}`)
  91. throw error
  92. }
  93. }
  94. async cleanupOldBackups(): Promise<void> {
  95. try {
  96. const files = await fs.readdir(BACKUP_DIR)
  97. const now = Date.now()
  98. const retentionTime = RETENTION_DAYS * 24 * 60 * 60 * 1000
  99. for (const file of files) {
  100. if (file.endsWith('.dump')) {
  101. const filePath = path.join(BACKUP_DIR, file)
  102. const stats = await fs.stat(filePath)
  103. if (now - stats.mtimeMs > retentionTime) {
  104. await fs.unlink(filePath)
  105. logger.db(`删除旧备份文件: ${file}`)
  106. }
  107. }
  108. }
  109. } catch (error) {
  110. logger.error(`清理旧备份失败: ${error}`)
  111. }
  112. }
  113. async backupExists(backupFile: string): Promise<boolean> {
  114. try {
  115. await fs.access(backupFile)
  116. return true
  117. } catch {
  118. return false
  119. }
  120. }
  121. async getBackupInfo(backupFile: string): Promise<{
  122. size: number
  123. mtime: Date
  124. formattedSize: string
  125. }> {
  126. try {
  127. const stats = await fs.stat(backupFile)
  128. return {
  129. size: stats.size,
  130. mtime: stats.mtime,
  131. formattedSize: this.formatFileSize(stats.size)
  132. }
  133. } catch (error) {
  134. throw new Error(`获取备份信息失败: ${error}`)
  135. }
  136. }
  137. private formatFileSize(bytes: number): string {
  138. const sizes = ['B', 'KB', 'MB', 'GB']
  139. if (bytes === 0) return '0 B'
  140. const i = Math.floor(Math.log(bytes) / Math.log(1024))
  141. return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]
  142. }
  143. startScheduledBackups(): void {
  144. if (this.cronJob) {
  145. this.cronJob.stop()
  146. }
  147. this.cronJob = cron.schedule(SCHEDULE, async () => {
  148. try {
  149. logger.db('开始定时备份任务')
  150. await this.createBackup()
  151. await this.cleanupOldBackups()
  152. logger.db('定时备份任务完成')
  153. } catch (error) {
  154. logger.error(`定时备份任务失败: ${error}`)
  155. }
  156. })
  157. logger.db(`备份调度已启动: ${SCHEDULE}`)
  158. }
  159. stopScheduledBackups(): void {
  160. if (this.cronJob) {
  161. this.cronJob.stop()
  162. logger.db('备份调度已停止')
  163. }
  164. }
  165. getBackupStatus(): {
  166. scheduled: boolean
  167. nextRun: Date | null
  168. lastRun: Date | null
  169. } {
  170. return {
  171. scheduled: this.cronJob !== null,
  172. nextRun: this.cronJob?.nextDate() || null,
  173. lastRun: null
  174. }
  175. }
  176. }
  177. export const databaseBackup = DatabaseBackup.getInstance()
  178. if (import.meta.url === `file://${process.argv[1]}`) {
  179. async function main() {
  180. const command = process.argv[2]
  181. switch (command) {
  182. case 'backup':
  183. await databaseBackup.createBackup()
  184. break
  185. case 'restore':
  186. const backupFile = process.argv[3]
  187. if (!backupFile) {
  188. console.error('请指定要恢复的备份文件')
  189. process.exit(1)
  190. }
  191. await databaseBackup.restoreBackup(backupFile)
  192. break
  193. case 'cleanup':
  194. await databaseBackup.cleanupOldBackups()
  195. break
  196. case 'list':
  197. const files = await fs.readdir(BACKUP_DIR)
  198. const backupFiles = files.filter(f => f.endsWith('.dump'))
  199. console.log('可用备份文件:')
  200. backupFiles.forEach(file => console.log(` - ${file}`))
  201. break
  202. default:
  203. console.log('用法: node backup.ts [backup|restore <file>|cleanup|list]')
  204. process.exit(1)
  205. }
  206. }
  207. main().catch(console.error)
  208. }