mirror of
https://github.com/Awuqing/BackupX.git
synced 2026-08-28 19:47:27 +08:00
Remove obsolete implementations, centralize background task ownership and terminal-state recovery, consolidate frontend routing and log streaming, and enforce project-wide verification in CI.
180 lines
5.0 KiB
Go
180 lines
5.0 KiB
Go
package backup
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"fmt"
|
||
"os"
|
||
"path/filepath"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
type MySQLRunner struct {
|
||
executor CommandExecutor
|
||
}
|
||
|
||
func NewMySQLRunner(executor CommandExecutor) *MySQLRunner {
|
||
if executor == nil {
|
||
executor = NewOSCommandExecutor()
|
||
}
|
||
return &MySQLRunner{executor: executor}
|
||
}
|
||
|
||
func (r *MySQLRunner) Type() string {
|
||
return "mysql"
|
||
}
|
||
|
||
func (r *MySQLRunner) Run(ctx context.Context, task TaskSpec, writer LogWriter) (*RunResult, error) {
|
||
if _, err := r.executor.LookPath("mysqldump"); err != nil {
|
||
return nil, fmt.Errorf("未找到 mysqldump 命令 (请确保服务器已安装 mysql-client 或 mariadb-client)")
|
||
}
|
||
startedAt := task.StartedAt
|
||
if startedAt.IsZero() {
|
||
startedAt = time.Now().UTC()
|
||
}
|
||
tempDir, err := CreateTaskTempDir(task.Name, startedAt)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
fileName := BuildArtifactName(task.Name, startedAt, "sql")
|
||
artifactPath := filepath.Join(tempDir, fileName)
|
||
file, err := os.Create(artifactPath)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("create mysql dump file: %w", err)
|
||
}
|
||
defer file.Close()
|
||
dbNames := normalizeDatabaseNames(task.Database.Names)
|
||
if len(dbNames) == 0 {
|
||
return nil, fmt.Errorf("mysql database names are required")
|
||
}
|
||
args := []string{
|
||
"--host", task.Database.Host,
|
||
"--port", strconv.Itoa(task.Database.Port),
|
||
"--user", task.Database.User,
|
||
"--single-transaction",
|
||
"--quick",
|
||
"--routines",
|
||
"--triggers",
|
||
"--events",
|
||
"--no-tablespaces",
|
||
"--net-buffer-length=32768",
|
||
"--databases",
|
||
}
|
||
args = append(args, dbNames...)
|
||
|
||
writer.WriteLine(fmt.Sprintf("连接到 MySQL: %s:%d", task.Database.Host, task.Database.Port))
|
||
writer.WriteLine(fmt.Sprintf("备份数据库: %s", strings.Join(dbNames, ", ")))
|
||
|
||
stderrWriter := newLogLineWriter(writer, "mysqldump")
|
||
writer.WriteLine("开始执行 mysqldump")
|
||
runErr := r.executor.Run(ctx, "mysqldump", args, CommandOptions{Stdout: file, Stderr: stderrWriter, Env: mysqlEnv(task.Database.Password)})
|
||
stderrWriter.Flush()
|
||
if runErr != nil {
|
||
return nil, fmt.Errorf("run mysqldump: %w: %s", runErr, stderrWriter.collected())
|
||
}
|
||
info, err := file.Stat()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("stat mysql dump file: %w", err)
|
||
}
|
||
writer.WriteLine(fmt.Sprintf("MySQL 导出完成(文件大小: %s)", formatFileSize(info.Size())))
|
||
return &RunResult{ArtifactPath: artifactPath, FileName: fileName, TempDir: tempDir, Size: info.Size(), StorageKey: BuildStorageKey("mysql", startedAt, fileName)}, nil
|
||
}
|
||
|
||
func (r *MySQLRunner) Restore(ctx context.Context, task TaskSpec, artifactPath string, writer LogWriter) error {
|
||
if _, err := r.executor.LookPath("mysql"); err != nil {
|
||
return fmt.Errorf("未找到 mysql 命令 (请确保服务器已安装 mysql-client 或 mariadb-client)")
|
||
}
|
||
input, err := os.Open(filepath.Clean(artifactPath))
|
||
if err != nil {
|
||
return fmt.Errorf("open mysql restore file: %w", err)
|
||
}
|
||
defer input.Close()
|
||
stderr := &bytes.Buffer{}
|
||
args := []string{"--host", task.Database.Host, "--port", strconv.Itoa(task.Database.Port), "--user", task.Database.User}
|
||
writer.WriteLine("开始执行 mysql 恢复")
|
||
if err := r.executor.Run(ctx, "mysql", args, CommandOptions{Stdin: input, Stderr: stderr, Env: mysqlEnv(task.Database.Password)}); err != nil {
|
||
return fmt.Errorf("run mysql restore: %w: %s", err, strings.TrimSpace(stderr.String()))
|
||
}
|
||
writer.WriteLine("MySQL 恢复完成")
|
||
return nil
|
||
}
|
||
|
||
func mysqlEnv(password string) []string {
|
||
if strings.TrimSpace(password) == "" {
|
||
return nil
|
||
}
|
||
return []string{"MYSQL_PWD=" + password}
|
||
}
|
||
|
||
// logLineWriter streams each line of output to a LogWriter in real-time.
|
||
type logLineWriter struct {
|
||
writer LogWriter
|
||
prefix string
|
||
pending []byte
|
||
output []byte
|
||
}
|
||
|
||
func newLogLineWriter(w LogWriter, prefix string) *logLineWriter {
|
||
return &logLineWriter{writer: w, prefix: prefix}
|
||
}
|
||
|
||
func (w *logLineWriter) Write(p []byte) (int, error) {
|
||
w.output = append(w.output, p...)
|
||
w.pending = append(w.pending, p...)
|
||
consumed := 0
|
||
for {
|
||
newline := bytes.IndexByte(w.pending[consumed:], '\n')
|
||
if newline < 0 {
|
||
break
|
||
}
|
||
end := consumed + newline
|
||
w.emit(w.pending[consumed:end])
|
||
consumed = end + 1
|
||
}
|
||
if consumed > 0 {
|
||
copy(w.pending, w.pending[consumed:])
|
||
w.pending = w.pending[:len(w.pending)-consumed]
|
||
}
|
||
return len(p), nil
|
||
}
|
||
|
||
// Flush emits the final unterminated line. It is safe to call more than once.
|
||
func (w *logLineWriter) Flush() {
|
||
if len(w.pending) == 0 {
|
||
return
|
||
}
|
||
w.emit(w.pending)
|
||
w.pending = w.pending[:0]
|
||
}
|
||
|
||
func (w *logLineWriter) emit(raw []byte) {
|
||
line := strings.TrimSpace(string(raw))
|
||
if line != "" {
|
||
w.writer.WriteLine(fmt.Sprintf("[%s] %s", w.prefix, line))
|
||
}
|
||
}
|
||
|
||
func (w *logLineWriter) collected() string {
|
||
return strings.TrimSpace(string(w.output))
|
||
}
|
||
|
||
func formatFileSize(size int64) string {
|
||
const (
|
||
KB = 1024
|
||
MB = KB * 1024
|
||
GB = MB * 1024
|
||
)
|
||
switch {
|
||
case size >= GB:
|
||
return fmt.Sprintf("%.2f GB", float64(size)/float64(GB))
|
||
case size >= MB:
|
||
return fmt.Sprintf("%.2f MB", float64(size)/float64(MB))
|
||
case size >= KB:
|
||
return fmt.Sprintf("%.2f KB", float64(size)/float64(KB))
|
||
default:
|
||
return fmt.Sprintf("%d B", size)
|
||
}
|
||
}
|