package data import ( "errors" "fmt" "os" "path/filepath" "slices" "strings" "time" "github.com/gookit/color" "github.com/shirou/gopsutil/disk" "github.com/TheTNB/panel/internal/app" "github.com/TheTNB/panel/internal/biz" "github.com/TheTNB/panel/pkg/db" "github.com/TheTNB/panel/pkg/io" "github.com/TheTNB/panel/pkg/shell" "github.com/TheTNB/panel/pkg/str" "github.com/TheTNB/panel/pkg/types" ) type backupRepo struct { setting biz.SettingRepo website biz.WebsiteRepo } func NewBackupRepo() biz.BackupRepo { return &backupRepo{ setting: NewSettingRepo(), website: NewWebsiteRepo(), } } // List 备份列表 func (r *backupRepo) List(typ biz.BackupType) ([]*types.BackupFile, error) { path, err := r.GetPath(typ) if err != nil { return nil, err } files, err := io.ReadDir(path) if err != nil { return nil, err } list := make([]*types.BackupFile, 0) for _, file := range files { info, err := file.Info() if err != nil { continue } list = append(list, &types.BackupFile{ Name: file.Name(), Path: filepath.Join(path, file.Name()), Size: str.FormatBytes(float64(info.Size())), Time: info.ModTime(), }) } return list, nil } // Create 创建备份 // typ 备份类型 // target 目标名称 // path 可选备份保存路径 func (r *backupRepo) Create(typ biz.BackupType, target string, path ...string) error { defPath, err := r.GetPath(typ) if err != nil { return err } if len(path) > 0 && path[0] != "" { defPath = path[0] } switch typ { case biz.BackupTypeWebsite: return r.createWebsite(defPath, target) case biz.BackupTypeMySQL: return r.createMySQL(defPath, target) case biz.BackupTypePostgres: return r.createPostgres(defPath, target) case biz.BackupTypePanel: return r.createPanel(defPath) } return errors.New("未知备份类型") } // Delete 删除备份 func (r *backupRepo) Delete(typ biz.BackupType, name string) error { path, err := r.GetPath(typ) if err != nil { return err } file := filepath.Join(path, name) return io.Remove(file) } // Restore 恢复备份 // typ 备份类型 // backup 备份压缩包,可以是绝对路径或者相对路径 // target 目标名称 func (r *backupRepo) Restore(typ biz.BackupType, backup, target string) error { if !io.Exists(backup) { path, err := r.GetPath(typ) if err != nil { return err } backup = filepath.Join(path, backup) } switch typ { case biz.BackupTypeWebsite: return r.restoreWebsite(backup, target) case biz.BackupTypeMySQL: return r.restoreMySQL(backup, target) case biz.BackupTypePostgres: return r.restorePostgres(backup, target) } return errors.New("未知备份类型") } // CutoffLog 切割日志 // path 保存目录绝对路径 // target 待切割日志文件绝对路径 func (r *backupRepo) CutoffLog(path, target string) error { if !io.Exists(target) { return errors.New("日志文件不存在") } to := filepath.Join(path, fmt.Sprintf("%s_%s.zip", time.Now().Format("20060102150405"), filepath.Base(target))) if err := io.Compress([]string{target}, to, io.Zip); err != nil { return err } return io.Remove(target) } // ClearExpired 清理过期备份 // path 备份目录绝对路径 // prefix 目标文件前缀 // save 保存份数 func (r *backupRepo) ClearExpired(path, prefix string, save int) error { files, err := io.ReadDir(path) if err != nil { return err } var filtered []os.FileInfo for _, file := range files { if strings.HasPrefix(file.Name(), prefix) && strings.HasSuffix(file.Name(), ".zip") { info, err := os.Stat(filepath.Join(path, file.Name())) if err != nil { continue } filtered = append(filtered, info) } } // 排序所有备份文件,从新到旧 slices.SortFunc(filtered, func(a, b os.FileInfo) int { if a.ModTime().After(b.ModTime()) { return -1 } if a.ModTime().Before(b.ModTime()) { return 1 } return 0 }) if len(filtered) <= save { return nil } // 切片保留 save 份,删除剩余 toDelete := filtered[save:] for _, file := range toDelete { filePath := filepath.Join(path, file.Name()) if app.IsCli { color.Greenln(fmt.Sprintf("|-清理过期文件:%s", filePath)) } if err = os.Remove(filePath); err != nil { if app.IsCli { color.Redln(fmt.Sprintf("|-清理失败:%v", err)) } else { return fmt.Errorf("清理失败:%v", err) } } } return nil } // GetPath 获取备份路径 func (r *backupRepo) GetPath(typ biz.BackupType) (string, error) { backupPath, err := r.setting.Get(biz.SettingKeyBackupPath) if err != nil { return "", err } if !slices.Contains([]biz.BackupType{biz.BackupTypePath, biz.BackupTypeWebsite, biz.BackupTypeMySQL, biz.BackupTypePostgres, biz.BackupTypeRedis, biz.BackupTypePanel}, typ) { return "", errors.New("未知备份类型") } backupPath = filepath.Join(backupPath, string(typ)) if !io.Exists(backupPath) { if err = io.Mkdir(backupPath, 0644); err != nil { return "", err } } return backupPath, nil } // createWebsite 创建网站备份 func (r *backupRepo) createWebsite(to string, name string) error { website, err := r.website.GetByName(name) if err != nil { return err } if err = r.preCheckPath(to, website.Path); err != nil { return err } var paths []string dirs, err := io.ReadDir(website.Path) if err != nil { return err } for _, item := range dirs { paths = append(paths, filepath.Join(website.Path, item.Name())) } start := time.Now() backup := filepath.Join(to, fmt.Sprintf("%s_%s.zip", website.Name, time.Now().Format("20060102150405"))) if err = io.Compress(paths, backup, io.Zip); err != nil { return err } if app.IsCli { color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String())) color.Greenln(fmt.Sprintf("|-已备份至文件:%s", filepath.Base(backup))) } return nil } // createMySQL 创建 MySQL 备份 func (r *backupRepo) createMySQL(to string, name string) error { rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword) if err != nil { return err } mysql, err := db.NewMySQL("root", rootPassword, "/tmp/mysql.sock") if err != nil { return err } if exist, _ := mysql.DatabaseExists(name); !exist { return fmt.Errorf("数据库不存在:%s", name) } size, err := mysql.DatabaseSize(name) if err != nil { return err } if err = r.preCheckDB(to, size); err != nil { return err } if err = os.Setenv("MYSQL_PWD", rootPassword); err != nil { return err } start := time.Now() backup := filepath.Join(to, fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405"))) if _, err = shell.Execf(`mysqldump -u root '%s' > '%s'`, name, backup); err != nil { return err } if err = os.Unsetenv("MYSQL_PWD"); err != nil { return err } if err = io.Compress([]string{backup}, backup+".zip", io.Zip); err != nil { return err } if err = io.Remove(backup); err != nil { return err } if app.IsCli { color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String())) color.Greenln(fmt.Sprintf("|-已备份至文件:%s", filepath.Base(backup+".zip"))) } return nil } // createPostgres 创建 PostgreSQL 备份 func (r *backupRepo) createPostgres(to string, name string) error { postgres, err := db.NewPostgres("postgres", "", "127.0.0.1", fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", app.Root), 5432) if err != nil { return err } if exist, _ := postgres.DatabaseExist(name); !exist { return fmt.Errorf("数据库不存在:%s", name) } size, err := postgres.DatabaseSize(name) if err != nil { return err } if err = r.preCheckDB(to, size); err != nil { return err } start := time.Now() backup := filepath.Join(to, fmt.Sprintf("%s_%s.sql", name, time.Now().Format("20060102150405"))) if _, err = shell.Execf(`su - postgres -c "pg_dump '%s'" > '%s'`, name, backup); err != nil { return err } if err = io.Compress([]string{backup}, backup+".zip", io.Zip); err != nil { return err } if err = io.Remove(backup); err != nil { return err } if app.IsCli { color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String())) color.Greenln(fmt.Sprintf("|-已备份至文件:%s", filepath.Base(backup+".zip"))) } return nil } // createPanel 创建面板备份 func (r *backupRepo) createPanel(to string) error { backup := filepath.Join(to, fmt.Sprintf("panel_%s.zip", time.Now().Format("20060102150405"))) if err := r.preCheckPath(to, filepath.Join(app.Root, "panel")); err != nil { return err } start := time.Now() if err := io.Compress([]string{ filepath.Join(app.Root, "panel"), "/usr/local/sbin/panel-cli", "/usr/local/etc/panel/config.yml", }, backup, io.Zip); err != nil { return err } if err := io.Chmod(backup, 0600); err != nil { return err } if app.IsCli { color.Greenln(fmt.Sprintf("|-备份耗时:%s", time.Since(start).String())) color.Greenln(fmt.Sprintf("|-已备份至文件:%s", filepath.Base(backup))) } return nil } // restoreWebsite 恢复网站备份 func (r *backupRepo) restoreWebsite(backup, target string) error { if !io.Exists(backup) { return errors.New("备份文件不存在") } website, err := r.website.GetByName(target) if err != nil { return err } format, err := io.FormatArchiveByPath(backup) if err != nil { return err } if err = io.Remove(website.Path); err != nil { return err } if err = io.UnCompress(backup, website.Path, format); err != nil { return err } if err = io.Chmod(website.Path, 0755); err != nil { return err } if err = io.Chown(website.Path, "www", "www"); err != nil { return err } return nil } // restoreMySQL 恢复 MySQL 备份 func (r *backupRepo) restoreMySQL(backup, target string) error { if !io.Exists(backup) { return errors.New("备份文件不存在") } rootPassword, err := r.setting.Get(biz.SettingKeyMySQLRootPassword) if err != nil { return err } mysql, err := db.NewMySQL("root", rootPassword, "/tmp/mysql.sock") if err != nil { return err } if exist, _ := mysql.DatabaseExists(target); !exist { return fmt.Errorf("数据库不存在:%s", target) } if err = os.Setenv("MYSQL_PWD", rootPassword); err != nil { return err } if !strings.HasSuffix(backup, ".sql") { backup, err = r.autoUnCompressSQL(backup) if err != nil { return err } } if _, err = shell.Execf(`mysql -u root '%s' < '%s'`, target, backup); err != nil { return err } if err = os.Unsetenv("MYSQL_PWD"); err != nil { return err } _ = io.Remove(filepath.Dir(backup)) return nil } // restorePostgres 恢复 PostgreSQL 备份 func (r *backupRepo) restorePostgres(backup, target string) error { if !io.Exists(backup) { return errors.New("备份文件不存在") } postgres, err := db.NewPostgres("postgres", "", "127.0.0.1", fmt.Sprintf("%s/server/postgresql/data/pg_hba.conf", app.Root), 5432) if err != nil { return err } if exist, _ := postgres.DatabaseExist(target); !exist { return fmt.Errorf("数据库不存在:%s", target) } if !strings.HasSuffix(backup, ".sql") { backup, err = r.autoUnCompressSQL(backup) if err != nil { return err } } if _, err = shell.Execf(`su - postgres -c "psql '%s'" < '%s'`, target, backup); err != nil { return err } _ = io.Remove(filepath.Dir(backup)) return nil } // preCheckPath 预检空间和 inode 是否足够 // to 备份保存目录 // path 待备份目录 func (r *backupRepo) preCheckPath(to, path string) error { size, err := io.SizeX(path) if err != nil { return err } files, err := io.CountX(path) if err != nil { return err } usage, err := disk.Usage(to) if err != nil { return err } if app.IsCli { color.Greenln(fmt.Sprintf("|-目标大小:%s", str.FormatBytes(float64(size)))) color.Greenln(fmt.Sprintf("|-目标文件数:%d", files)) color.Greenln(fmt.Sprintf("|-备份目录可用空间:%s", str.FormatBytes(float64(usage.Free)))) color.Greenln(fmt.Sprintf("|-备份目录可用Inode:%d", usage.InodesFree)) } if uint64(size) > usage.Free { return errors.New("备份目录空间不足") } if uint64(files) > usage.InodesFree { return errors.New("备份目录Inode不足") } return nil } // preCheckDB 预检空间和 inode 是否足够 // to 备份保存目录 // size 数据库大小 func (r *backupRepo) preCheckDB(to string, size int64) error { usage, err := disk.Usage(to) if err != nil { return err } if app.IsCli { color.Greenln(fmt.Sprintf("|-目标大小:%s", str.FormatBytes(float64(size)))) color.Greenln(fmt.Sprintf("|-备份目录可用空间:%s", str.FormatBytes(float64(usage.Free)))) color.Greenln(fmt.Sprintf("|-备份目录可用Inode:%d", usage.InodesFree)) } if uint64(size) > usage.Free { return errors.New("备份目录空间不足") } return nil } // autoUnCompressSQL 自动处理压缩文件 func (r *backupRepo) autoUnCompressSQL(backup string) (string, error) { temp, err := io.TempDir(backup) if err != nil { return "", err } format, err := io.FormatArchiveByPath(backup) if err != nil { return "", err } if err = io.UnCompress(backup, temp, format); err != nil { return "", err } backup = "" // 置空,防止干扰后续判断 if files, err := os.ReadDir(temp); err == nil { if len(files) != 1 { return "", fmt.Errorf("压缩文件中包含的文件数量不为1,实际为%d", len(files)) } if strings.HasSuffix(files[0].Name(), ".sql") { backup = filepath.Join(temp, files[0].Name()) } } if backup == "" { return "", errors.New("无法找到.sql备份文件") } return backup, nil }