Files
SeeyonFileSystem/server/service/backup_service.go

720 lines
18 KiB
Go
Raw Normal View History

2026-07-03 16:01:08 +08:00
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"
2026-07-18 10:52:32 +08:00
"seeyon-filesystem/utils"
2026-07-03 16:01:08 +08:00
)
// 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{
2026-07-18 10:52:32 +08:00
ID: utils.GenID(),
2026-07-03 16:01:08 +08:00
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{
2026-07-18 10:52:32 +08:00
ID: utils.GenID(),
2026-07-03 16:01:08 +08:00
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)
}
2026-07-10 17:33:33 +08:00
// backupDatabase 备份数据库(根据数据库类型选择工具)
2026-07-03 16:01:08 +08:00
func (s *BackupService) backupDatabase(tarWriter *tar.Writer, timestamp string) (int64, error) {
dbCfg := config.AppConfig.Database
2026-07-10 17:33:33 +08:00
var dumpFile string
var cmd *exec.Cmd
var err error
2026-07-03 16:01:08 +08:00
2026-07-10 17:33:33 +08:00
switch dbCfg.Type {
case "sqlserver":
dumpFile, cmd, err = s.buildSQLServerDump(dbCfg, timestamp)
case "postgres":
dumpFile, cmd, err = s.buildPostgresDump(dbCfg, timestamp)
default:
dumpFile, cmd, err = s.buildMySQLDump(dbCfg, timestamp)
}
if err != nil {
return 0, err
}
2026-07-03 16:01:08 +08:00
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)
2026-07-10 17:33:33 +08:00
return 0, fmt.Errorf("数据库备份命令执行失败: %w", err)
2026-07-03 16:01:08 +08:00
}
outFile.Close()
info, _ := os.Stat(dumpFile)
dbSize := int64(0)
if info != nil {
dbSize = info.Size()
}
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
}
2026-07-10 17:33:33 +08:00
// buildMySQLDump 构建 mysqldump 命令
func (s *BackupService) buildMySQLDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) {
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,
)
return dumpFile, cmd, nil
}
// buildPostgresDump 构建 pg_dump 命令
func (s *BackupService) buildPostgresDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) {
dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp))
pgDumpPath := s.findTool("pg_dump", config.AppConfig.Backup.PgDumpPath)
cmd := exec.Command(pgDumpPath,
"-h", dbCfg.Host,
"-p", fmt.Sprintf("%d", dbCfg.Port),
"-U", dbCfg.Username,
"-F", "p", // plain text SQL
"--no-owner",
dbCfg.DBName,
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password)
return dumpFile, cmd, nil
}
// buildSQLServerDump 构建 sqlcmd 导出命令
func (s *BackupService) buildSQLServerDump(dbCfg config.DatabaseConfig, timestamp string) (string, *exec.Cmd, error) {
dumpFile := filepath.Join(os.TempDir(), fmt.Sprintf("db_dump_%s.sql", timestamp))
sqlCmdPath := s.findTool("sqlcmd", config.AppConfig.Backup.SqlCmdPath)
cmd := exec.Command(sqlCmdPath,
"-S", fmt.Sprintf("%s,%d", dbCfg.Host, dbCfg.Port),
"-U", dbCfg.Username,
"-P", dbCfg.Password,
"-d", dbCfg.DBName,
"-Q", fmt.Sprintf("BACKUP DATABASE [%s] TO DISK='%s' WITH FORMAT", dbCfg.DBName, dumpFile),
)
return dumpFile, cmd, nil
}
2026-07-03 16:01:08 +08:00
// 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
}
2026-07-10 17:33:33 +08:00
// restoreDatabase 从SQL文件恢复数据库(根据数据库类型选择工具)
2026-07-03 16:01:08 +08:00
func (s *BackupService) restoreDatabase(sqlFilePath string) error {
dbCfg := config.AppConfig.Database
2026-07-10 17:33:33 +08:00
switch dbCfg.Type {
case "postgres":
return s.restorePostgres(dbCfg, sqlFilePath)
case "sqlserver":
return s.restoreSQLServer(dbCfg, sqlFilePath)
default:
return s.restoreMySQL(dbCfg, sqlFilePath)
}
}
2026-07-03 16:01:08 +08:00
2026-07-10 17:33:33 +08:00
// restoreMySQL mysql恢复
func (s *BackupService) restoreMySQL(dbCfg config.DatabaseConfig, sqlFilePath string) error {
mysqlPath := s.findTool("mysql", config.AppConfig.Backup.MysqlPath)
2026-07-03 16:01:08 +08:00
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
2026-07-10 17:33:33 +08:00
return cmd.Run()
}
// restorePostgres psql恢复
func (s *BackupService) restorePostgres(dbCfg config.DatabaseConfig, sqlFilePath string) error {
psqlPath := s.findTool("psql", config.AppConfig.Backup.PsqlPath)
cmd := exec.Command(psqlPath,
"-h", dbCfg.Host,
"-p", fmt.Sprintf("%d", dbCfg.Port),
"-U", dbCfg.Username,
"-d", dbCfg.DBName,
"-f", sqlFilePath,
)
cmd.Env = append(os.Environ(), "PGPASSWORD="+dbCfg.Password)
cmd.Stderr = os.Stderr
return cmd.Run()
}
2026-07-03 16:01:08 +08:00
2026-07-10 17:33:33 +08:00
// restoreSQLServer sqlcmd恢复
func (s *BackupService) restoreSQLServer(dbCfg config.DatabaseConfig, sqlFilePath string) error {
sqlCmdPath := s.findTool("sqlcmd", config.AppConfig.Backup.SqlCmdPath)
cmd := exec.Command(sqlCmdPath,
"-S", fmt.Sprintf("%s,%d", dbCfg.Host, dbCfg.Port),
"-U", dbCfg.Username,
"-P", dbCfg.Password,
"-d", dbCfg.DBName,
"-i", sqlFilePath,
)
cmd.Stderr = os.Stderr
2026-07-03 16:01:08 +08:00
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
}