2
0

backup.ts 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244
  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: any = 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. }, {
  62. format: 'custom',
  63. filePath: backupFile,
  64. })
  65. await fs.chmod(backupFile, 0o600)
  66. const stats = await fs.stat(backupFile)
  67. logger.db(`备份创建成功: ${backupFile} (${this.formatFileSize(stats.size)})`)
  68. return backupFile
  69. } catch (error) {
  70. logger.error(`备份创建失败: ${error}`)
  71. throw error
  72. }
  73. }
  74. async restoreBackup(backupFile: string, options: Partial<BackupOptions> = {}): Promise<void> {
  75. const config = { ...this.getDbConfig(), ...options }
  76. try {
  77. if (!await this.backupExists(backupFile)) {
  78. throw new Error(`备份文件不存在: ${backupFile}`)
  79. }
  80. logger.db(`开始恢复数据库备份: ${backupFile}`)
  81. await pgRestore({
  82. host: config.host,
  83. port: config.port,
  84. database: config.database,
  85. username: config.username,
  86. password: config.password,
  87. }, {
  88. file: backupFile,
  89. })
  90. logger.db(`数据库恢复成功: ${config.database}`)
  91. } catch (error) {
  92. logger.error(`数据库恢复失败: ${error}`)
  93. throw error
  94. }
  95. }
  96. async cleanupOldBackups(): Promise<void> {
  97. try {
  98. const files = await fs.readdir(BACKUP_DIR)
  99. const now = Date.now()
  100. const retentionTime = RETENTION_DAYS * 24 * 60 * 60 * 1000
  101. for (const file of files) {
  102. if (file.endsWith('.dump')) {
  103. const filePath = path.join(BACKUP_DIR, file)
  104. const stats = await fs.stat(filePath)
  105. if (now - stats.mtimeMs > retentionTime) {
  106. await fs.unlink(filePath)
  107. logger.db(`删除旧备份文件: ${file}`)
  108. }
  109. }
  110. }
  111. } catch (error) {
  112. logger.error(`清理旧备份失败: ${error}`)
  113. }
  114. }
  115. async backupExists(backupFile: string): Promise<boolean> {
  116. try {
  117. await fs.access(backupFile)
  118. return true
  119. } catch {
  120. return false
  121. }
  122. }
  123. async getBackupInfo(backupFile: string): Promise<{
  124. size: number
  125. mtime: Date
  126. formattedSize: string
  127. }> {
  128. try {
  129. const stats = await fs.stat(backupFile)
  130. return {
  131. size: stats.size,
  132. mtime: stats.mtime,
  133. formattedSize: this.formatFileSize(stats.size)
  134. }
  135. } catch (error) {
  136. throw new Error(`获取备份信息失败: ${error}`)
  137. }
  138. }
  139. private formatFileSize(bytes: number): string {
  140. const sizes = ['B', 'KB', 'MB', 'GB']
  141. if (bytes === 0) return '0 B'
  142. const i = Math.floor(Math.log(bytes) / Math.log(1024))
  143. return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i]
  144. }
  145. startScheduledBackups(): void {
  146. if (this.cronJob) {
  147. this.cronJob.stop()
  148. }
  149. this.cronJob = cron.schedule(SCHEDULE, async () => {
  150. try {
  151. logger.db('开始定时备份任务')
  152. await this.createBackup()
  153. await this.cleanupOldBackups()
  154. logger.db('定时备份任务完成')
  155. } catch (error) {
  156. logger.error(`定时备份任务失败: ${error}`)
  157. }
  158. })
  159. logger.db(`备份调度已启动: ${SCHEDULE}`)
  160. }
  161. stopScheduledBackups(): void {
  162. if (this.cronJob) {
  163. this.cronJob.stop()
  164. logger.db('备份调度已停止')
  165. }
  166. }
  167. getBackupStatus(): {
  168. scheduled: boolean
  169. nextRun: Date | null
  170. lastRun: Date | null
  171. } {
  172. return {
  173. scheduled: this.cronJob !== null,
  174. nextRun: this.cronJob?.nextDate() || null,
  175. lastRun: null
  176. }
  177. }
  178. }
  179. export const databaseBackup = DatabaseBackup.getInstance()
  180. if (import.meta.url === `file://${process.argv[1]}`) {
  181. async function main() {
  182. const command = process.argv[2]
  183. switch (command) {
  184. case 'backup':
  185. await databaseBackup.createBackup()
  186. break
  187. case 'restore':
  188. const backupFile = process.argv[3]
  189. if (!backupFile) {
  190. console.error('请指定要恢复的备份文件')
  191. process.exit(1)
  192. }
  193. await databaseBackup.restoreBackup(backupFile)
  194. break
  195. case 'cleanup':
  196. await databaseBackup.cleanupOldBackups()
  197. break
  198. case 'list':
  199. const files = await fs.readdir(BACKUP_DIR)
  200. const backupFiles = files.filter(f => f.endsWith('.dump'))
  201. console.log('可用备份文件:')
  202. backupFiles.forEach(file => console.log(` - ${file}`))
  203. break
  204. default:
  205. console.log('用法: node backup.ts [backup|restore <file>|cleanup|list]')
  206. process.exit(1)
  207. }
  208. }
  209. main().catch(console.error)
  210. }