package service import ( "archive/tar" "compress/gzip" "fmt" "io" "os" "os/exec" "path/filepath" "strings" "time" "seeyon-filesystem/config" "seeyon-filesystem/dto" "seeyon-filesystem/model" ) // BackupService 备份服务 type BackupService struct{} func NewBackupService() *BackupService { return &BackupService{} } // ========== 策略管理 ========== // CreatePolicy 创建备份策略 func (s *BackupService) CreatePolicy(req *dto.BackupPolicyCreateRequest) error { targetConfig := req.TargetConfig if targetConfig == "" { targetConfig = "{}" } policy := &model.BackupPolicy{ Name: req.Name, BackupType: req.BackupType, TargetType: req.TargetType, TargetConfig: targetConfig, IncludeDB: req.IncludeDB, IncludeFiles: req.IncludeFiles, CronExpr: req.CronExpr, MaxBackups: req.MaxBackups, Status: 1, } if policy.MaxBackups <= 0 { policy.MaxBackups = 7 } return config.DB.Create(policy).Error } // ListPolicies 获取所有备份策略 func (s *BackupService) ListPolicies() ([]dto.BackupPolicyVO, error) { var policies []model.BackupPolicy if err := config.DB.Order("created_at DESC").Find(&policies).Error; err != nil { return nil, err } vos := make([]dto.BackupPolicyVO, 0, len(policies)) for _, p := range policies { vo := dto.BackupPolicyVO{ ID: p.ID, Name: p.Name, BackupType: p.BackupType, TargetType: p.TargetType, TargetConfig: p.TargetConfig, IncludeDB: p.IncludeDB, IncludeFiles: p.IncludeFiles, CronExpr: p.CronExpr, MaxBackups: p.MaxBackups, Status: p.Status, CreatedAt: p.CreatedAt.Format("2006-01-02 15:04:05"), } if p.LastRunAt != nil { t := p.LastRunAt.Format("2006-01-02 15:04:05") vo.LastRunAt = &t } if p.NextRunAt != nil { t := p.NextRunAt.Format("2006-01-02 15:04:05") vo.NextRunAt = &t } vos = append(vos, vo) } return vos, nil } // DeletePolicy 删除备份策略 func (s *BackupService) DeletePolicy(id uint64) error { return config.DB.Delete(&model.BackupPolicy{}, id).Error } // TogglePolicy 启用/禁用备份策略 func (s *BackupService) TogglePolicy(id uint64, status int8) error { return config.DB.Model(&model.BackupPolicy{}).Where("id = ?", id).Update("status", status).Error } // ========== 执行备份 ========== // ExecuteBackup 手动执行备份 func (s *BackupService) ExecuteBackup(policyID uint64) (*model.BackupLog, error) { var policy model.BackupPolicy if err := config.DB.First(&policy, policyID).Error; err != nil { return nil, fmt.Errorf("备份策略不存在") } // 创建备份日志 log := &model.BackupLog{ PolicyID: policyID, PolicyName: policy.Name, BackupType: policy.BackupType, Status: "running", StartedAt: time.Now(), } config.DB.Create(log) // 异步执行备份 go s.doBackup(&policy, log) return log, nil } // doBackup 执行实际备份 func (s *BackupService) doBackup(policy *model.BackupPolicy, log *model.BackupLog) { startTime := time.Now() // 创建备份目录 backupDir := filepath.Join(config.AppConfig.Storage.LocalBasePath, ".backups") os.MkdirAll(backupDir, 0755) timestamp := time.Now().Format("20060102_150405") backupFileName := fmt.Sprintf("backup_%s_%s.tar.gz", policy.BackupType, timestamp) backupPath := filepath.Join(backupDir, backupFileName) // 创建tar.gz文件 tarFile, err := os.Create(backupPath) if err != nil { s.finishLog(log, "failed", "", 0, 0, err.Error()) return } defer tarFile.Close() gzWriter := gzip.NewWriter(tarFile) defer gzWriter.Close() tarWriter := tar.NewWriter(gzWriter) defer tarWriter.Close() totalSize := int64(0) filesCount := 0 // 备份数据库 if policy.IncludeDB == 1 { dbSize, err := s.backupDatabase(tarWriter, timestamp) if err != nil { s.finishLog(log, "failed", "", 0, 0, "数据库备份失败: "+err.Error()) return } totalSize += dbSize } // 备份文件 if policy.IncludeFiles == 1 { fileSize, count, err := s.backupFiles(tarWriter) if err != nil { s.finishLog(log, "failed", "", 0, 0, "文件备份失败: "+err.Error()) return } totalSize += fileSize filesCount = count } tarWriter.Close() gzWriter.Close() tarFile.Close() // 获取备份文件大小 backupInfo, _ := os.Stat(backupPath) backupSize := int64(0) if backupInfo != nil { backupSize = backupInfo.Size() } duration := int(time.Since(startTime).Seconds()) // 更新策略最后执行时间 now := time.Now() config.DB.Model(policy).Updates(map[string]interface{}{ "last_run_at": now, }) s.finishLog(log, "success", backupPath, backupSize, filesCount, "") log.DBSize = totalSize log.Duration = duration config.DB.Save(log) // 清理旧备份 s.cleanOldBackups(policy) } // backupDatabase 备份数据库 func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string) (int64, error) { dbCfg := config.AppConfig.Database dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp)) mysqldumpPath := s.findTool("mysqldump", config.AppConfig.Backup.MysqlDumpPath) cmd := exec.Command(mysqldumpPath, "-h", dbCfg.Host, "-P", fmt.Sprintf("%d", dbCfg.Port), "-u", dbCfg.Username, "-p"+dbCfg.Password, "--single-transaction", "--routines", "--triggers", dbCfg.DBName, ) outFile, err := os.Create(dumpFile) if err != nil { return 0, err } cmd.Stdout = outFile cmd.Stderr = os.Stderr if err := cmd.Run(); err != nil { outFile.Close() os.Remove(dumpFile) return 0, fmt.Errorf("mysqldump执行失败: %w", err) } outFile.Close() // 获取文件大小 info, _ := os.Stat(dumpFile) dbSize := int64(0) if info != nil { dbSize = info.Size() } // 写入tar file, err := os.Open(dumpFile) if err != nil { os.Remove(dumpFile) return 0, err } defer file.Close() header := &tar.Header{ Name: "database/" + dbCfg.DBName + ".sql", Size: dbSize, Mode: 0644, ModTime: time.Now(), } if err := tarWriter.WriteHeader(header); err != nil { os.Remove(dumpFile) return 0, err } if _, err := io.Copy(tarWriter, file); err != nil { os.Remove(dumpFile) return 0, err } os.Remove(dumpFile) return dbSize, nil } // backupFiles 备份文件 func (s *BackupService) backupFiles(tarWriter *tar.Writer) (int64, int, error) { basePath := config.AppConfig.Storage.LocalBasePath totalSize := int64(0) count := 0 err := filepath.Walk(basePath, func(path string, info os.FileInfo, err error) error { if err != nil { return nil // 跳过错误 } // 跳过备份目录和临时目录 if info.IsDir() && (info.Name() == ".backups" || info.Name() == ".tmp") { return filepath.SkipDir } if info.IsDir() { return nil } relPath, _ := filepath.Rel(basePath, path) file, err := os.Open(path) if err != nil { return nil } defer file.Close() header := &tar.Header{ Name: "files/" + relPath, Size: info.Size(), Mode: int64(info.Mode()), ModTime: info.ModTime(), } if err := tarWriter.WriteHeader(header); err != nil { return nil } if _, err := io.Copy(tarWriter, file); err != nil { return nil } totalSize += info.Size() count++ return nil }) return totalSize, count, err } // finishLog 更新备份日志状态 func (s *BackupService) finishLog(log *model.BackupLog, status, filePath string, fileSize int64, filesCount int, errMsg string) { now := time.Now() log.Status = status log.FilePath = filePath log.FileSize = fileSize log.FilesCount = filesCount log.FinishedAt = &now log.ErrorMsg = errMsg if !now.IsZero() && !log.StartedAt.IsZero() { log.Duration = int(now.Sub(log.StartedAt).Seconds()) } config.DB.Save(log) } // cleanOldBackups 清理旧备份 func (s *BackupService) cleanOldBackups(policy *model.BackupPolicy) { var logs []model.BackupLog config.DB.Where("policy_id = ? AND status = 'success'", policy.ID). Order("started_at DESC").Offset(policy.MaxBackups).Find(&logs) for _, log := range logs { // 删除备份文件 if log.FilePath != "" { os.Remove(log.FilePath) } config.DB.Delete(&log) } } // ========== 查询 ========== // ListLogs 查询备份日志 func (s *BackupService) ListLogs(policyID uint64, page, size int) ([]dto.BackupLogVO, int64, error) { query := config.DB.Model(&model.BackupLog{}) if policyID > 0 { query = query.Where("policy_id = ?", policyID) } var total int64 query.Count(&total) var logs []model.BackupLog query.Order("started_at DESC").Offset((page - 1) * size).Limit(size).Find(&logs) vos := make([]dto.BackupLogVO, 0, len(logs)) for _, l := range logs { vo := dto.BackupLogVO{ ID: l.ID, PolicyID: l.PolicyID, PolicyName: l.PolicyName, BackupType: l.BackupType, Status: l.Status, FilePath: l.FilePath, FileSize: l.FileSize, DBSize: l.DBSize, FilesCount: l.FilesCount, Duration: l.Duration, ErrorMsg: l.ErrorMsg, StartedAt: l.StartedAt.Format("2006-01-02 15:04:05"), } if l.FinishedAt != nil { t := l.FinishedAt.Format("2006-01-02 15:04:05") vo.FinishedAt = &t } vos = append(vos, vo) } return vos, total, nil } // ========== 恢复 ========== // RestoreResult 恢复结果 type RestoreResult struct { Success bool `json:"success"` Message string `json:"message"` DBRestored bool `json:"db_restored"` FilesCount int `json:"files_count"` Duration int `json:"duration_ms"` } // RestoreFromBackup 从备份文件恢复 // logID: 备份日志ID // restoreDB: 是否恢复数据库 // restoreFiles: 是否恢复文件 func (s *BackupService) RestoreFromBackup(logID uint64, restoreDB, restoreFiles bool) (*RestoreResult, error) { // 获取备份日志 var log model.BackupLog if err := config.DB.First(&log, logID).Error; err != nil { return nil, fmt.Errorf("备份记录不存在") } if log.Status != "success" { return nil, fmt.Errorf("只能恢复成功的备份") } if log.FilePath == "" { return nil, fmt.Errorf("备份文件路径为空") } // 检查备份文件是否存在 if _, err := os.Stat(log.FilePath); os.IsNotExist(err) { return nil, fmt.Errorf("备份文件不存在: %s", log.FilePath) } startTime := time.Now() result := &RestoreResult{} // 解压备份文件 tempDir := filepath.Join(os.TempDir(), "restore_"+fmt.Sprintf("%d", logID)) os.MkdirAll(tempDir, 0755) defer os.RemoveAll(tempDir) if err := s.extractTarGz(log.FilePath, tempDir); err != nil { return nil, fmt.Errorf("解压备份文件失败: %w", err) } // 恢复数据库 if restoreDB { dbFile := filepath.Join(tempDir, "database") if _, err := os.Stat(dbFile); err == nil { // 找到SQL文件 entries, _ := os.ReadDir(dbFile) for _, entry := range entries { if !entry.IsDir() && filepath.Ext(entry.Name()) == ".sql" { sqlPath := filepath.Join(dbFile, entry.Name()) if err := s.restoreDatabase(sqlPath); err != nil { result.Message = "数据库恢复失败: " + err.Error() result.Duration = int(time.Since(startTime).Milliseconds()) return result, nil } result.DBRestored = true break } } } } // 恢复文件 if restoreFiles { filesDir := filepath.Join(tempDir, "files") if _, err := os.Stat(filesDir); err == nil { basePath := config.AppConfig.Storage.LocalBasePath count, err := s.restoreFiles(filesDir, basePath) if err != nil { result.Message = "文件恢复失败: " + err.Error() result.Duration = int(time.Since(startTime).Milliseconds()) return result, nil } result.FilesCount = count } } result.Success = true result.Duration = int(time.Since(startTime).Milliseconds()) parts := []string{} if result.DBRestored { parts = append(parts, "数据库已恢复") } if result.FilesCount > 0 { parts = append(parts, fmt.Sprintf("%d个文件已恢复", result.FilesCount)) } if len(parts) == 0 { result.Message = "没有可恢复的内容" } else { result.Message = "恢复成功: " + strings.Join(parts, ", ") } return result, nil } // extractTarGz 解压tar.gz文件 func (s *BackupService) extractTarGz(tarGzPath, destDir string) error { file, err := os.Open(tarGzPath) if err != nil { return err } defer file.Close() gzReader, err := gzip.NewReader(file) if err != nil { return err } defer gzReader.Close() tarReader := tar.NewReader(gzReader) for { header, err := tarReader.Next() if err == io.EOF { break } if err != nil { return err } targetPath := filepath.Join(destDir, header.Name) switch header.Typeflag { case tar.TypeDir: os.MkdirAll(targetPath, 0755) case tar.TypeReg: os.MkdirAll(filepath.Dir(targetPath), 0755) outFile, err := os.Create(targetPath) if err != nil { return err } io.Copy(outFile, tarReader) outFile.Close() os.Chmod(targetPath, os.FileMode(header.Mode)) } } return nil } // restoreDatabase 从SQL文件恢复数据库 func (s *BackupService) restoreDatabase(sqlFilePath string) error { dbCfg := config.AppConfig.Database mysqlPath := s.findTool("mysql", config.AppConfig.Backup.MysqlPath) cmd := exec.Command(mysqlPath, "-h", dbCfg.Host, "-P", fmt.Sprintf("%d", dbCfg.Port), "-u", dbCfg.Username, "-p"+dbCfg.Password, dbCfg.DBName, ) sqlFile, err := os.Open(sqlFilePath) if err != nil { return err } defer sqlFile.Close() cmd.Stdin = sqlFile cmd.Stderr = os.Stderr return cmd.Run() } // restoreFiles 恢复文件到目标目录 func (s *BackupService) restoreFiles(srcDir, destDir string) (int, error) { count := 0 err := filepath.Walk(srcDir, func(path string, info os.FileInfo, err error) error { if err != nil { return nil } relPath, _ := filepath.Rel(srcDir, path) targetPath := filepath.Join(destDir, relPath) if info.IsDir() { os.MkdirAll(targetPath, 0755) return nil } // 复制文件 srcFile, err := os.Open(path) if err != nil { return nil } defer srcFile.Close() os.MkdirAll(filepath.Dir(targetPath), 0755) dstFile, err := os.Create(targetPath) if err != nil { return nil } defer dstFile.Close() io.Copy(dstFile, srcFile) count++ return nil }) return count, err } // ListBackups 列出所有可恢复的备份文件 func (s *BackupService) ListBackups() ([]dto.BackupLogVO, error) { var logs []model.BackupLog config.DB.Where("status = 'success' AND file_path != ''"). Order("started_at DESC").Find(&logs) vos := make([]dto.BackupLogVO, 0, len(logs)) for _, l := range logs { vo := dto.BackupLogVO{ ID: l.ID, PolicyName: l.PolicyName, BackupType: l.BackupType, FileSize: l.FileSize, FilesCount: l.FilesCount, StartedAt: l.StartedAt.Format("2006-01-02 15:04:05"), } vos = append(vos, vo) } return vos, nil } // findTool 查找可执行工具路径 // 优先使用配置路径, 否则搜索常见路径 func (s *BackupService) findTool(name, configPath string) string { if configPath != "" { if _, err := os.Stat(configPath); err == nil { return configPath } } if _, err := exec.LookPath(name); err == nil { return name } commonPaths := []string{ fmt.Sprintf(`C:\Program Files\MySQL\MySQL Server 8.0\bin\%s.exe`, name), fmt.Sprintf(`/usr/bin/%s`, name), } for _, p := range commonPaths { if _, err := os.Stat(p); err == nil { return p } } return name }