Scert/backend/internal/cli/mysql_backup.go

350 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package cli
import (
"context"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
)
// backupMySQL 将 MySQL 的表结构和数据导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。
func backupMySQL(ctx context.Context, cfg config.Config, output string) error {
resolved, err := resolveMaintenanceDatabase(ctx, cfg.Database, cfg.Paths.Config)
if err != nil {
return err
}
return backupMySQLDatabase(ctx, resolved, output)
}
func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output string) error {
db, err := database.Open(ctx, cfg)
if err != nil {
return fmt.Errorf("打开 MySQL 备份连接失败:%w", err)
}
defer db.Close()
temporaryPath := output + ".tmp"
file, err := os.OpenFile(temporaryPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
if err != nil {
return fmt.Errorf("创建 MySQL 备份文件失败:%w", err)
}
removeTemporary := true
defer func() {
_ = file.Close()
if removeTemporary {
_ = os.Remove(temporaryPath)
}
}()
if _, err := fmt.Fprintf(file, "-- Scert MySQL 数据备份\n-- 生成时间:%s\n\nSET FOREIGN_KEY_CHECKS=0;\n\n", time.Now().UTC().Format(time.RFC3339)); err != nil {
return fmt.Errorf("写入 MySQL 备份头失败:%w", err)
}
rows, err := db.DB.QueryContext(ctx, "SHOW TABLES")
if err != nil {
return fmt.Errorf("读取 MySQL 表列表失败:%w", err)
}
var tableNames []string
for rows.Next() {
var tableName string
if err := rows.Scan(&tableName); err != nil {
_ = rows.Close()
return fmt.Errorf("读取 MySQL 表名失败:%w", err)
}
tableNames = append(tableNames, tableName)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return fmt.Errorf("遍历 MySQL 表列表失败:%w", err)
}
if err := rows.Close(); err != nil {
return fmt.Errorf("关闭 MySQL 表列表失败:%w", err)
}
for _, tableName := range tableNames {
if err := dumpMySQLTable(ctx, db.DB, file, tableName); err != nil {
return err
}
}
if _, err := fmt.Fprint(file, "SET FOREIGN_KEY_CHECKS=1;\n"); err != nil {
return fmt.Errorf("写入 MySQL 备份尾失败:%w", err)
}
if err := file.Close(); err != nil {
return fmt.Errorf("关闭 MySQL 备份文件失败:%w", err)
}
if err := os.Chmod(temporaryPath, 0o600); err != nil {
return fmt.Errorf("设置 MySQL 备份文件权限失败:%w", err)
}
if err := os.Rename(temporaryPath, output); err != nil {
return fmt.Errorf("保存 MySQL 备份文件失败:%w", err)
}
removeTemporary = false
return nil
}
// createMySQLRestoreBackup 在恢复前保留目标数据库快照,供失败时回滚使用。
// 快照目录不会在恢复成功后自动删除,便于管理员在需要时继续排查或手动恢复。
func createMySQLRestoreBackup(ctx context.Context, cfg config.Config, connection config.DatabaseConfig) (string, error) {
backupRoot := filepath.Clean(strings.TrimSpace(cfg.Paths.Backup))
if backupRoot == "." || backupRoot == "" {
return "", fmt.Errorf("MySQL 恢复前备份目录不能为空")
}
if err := os.MkdirAll(backupRoot, 0o700); err != nil {
return "", fmt.Errorf("创建 MySQL 恢复前备份目录失败:%w", err)
}
info, err := os.Lstat(backupRoot)
if err != nil {
return "", fmt.Errorf("检查 MySQL 恢复前备份目录失败:%w", err)
}
if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
return "", fmt.Errorf("MySQL 恢复前备份目录必须是普通目录")
}
if err := os.Chmod(backupRoot, 0o700); err != nil {
return "", fmt.Errorf("设置 MySQL 恢复前备份目录权限失败:%w", err)
}
rollbackDirectory, err := os.MkdirTemp(backupRoot, "scert-before-restore-")
if err != nil {
return "", fmt.Errorf("创建 MySQL 恢复快照目录失败:%w", err)
}
rollbackPath := filepath.Join(rollbackDirectory, "scert.sql")
if err := backupMySQLDatabase(ctx, connection, rollbackPath); err != nil {
return "", fmt.Errorf("创建 MySQL 恢复前快照失败:%w", err)
}
return rollbackPath, nil
}
func dumpMySQLTable(ctx context.Context, db *sql.DB, output *os.File, tableName string) error {
quotedTable := quoteMySQLIdentifier(tableName)
var name, createSQL string
if err := db.QueryRowContext(ctx, "SHOW CREATE TABLE "+quotedTable).Scan(&name, &createSQL); err != nil {
return fmt.Errorf("读取 MySQL 表结构失败(%s):%w", tableName, err)
}
if _, err := fmt.Fprintf(output, "DROP TABLE IF EXISTS %s;\n%s;\n\n", quotedTable, createSQL); err != nil {
return fmt.Errorf("写入 MySQL 表结构失败(%s):%w", tableName, err)
}
rows, err := db.QueryContext(ctx, "SELECT * FROM "+quotedTable)
if err != nil {
return fmt.Errorf("读取 MySQL 表数据失败(%s):%w", tableName, err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return fmt.Errorf("读取 MySQL 表字段失败(%s):%w", tableName, err)
}
for rows.Next() {
values := make([]any, len(columns))
pointers := make([]any, len(columns))
for index := range values {
pointers[index] = &values[index]
}
if err := rows.Scan(pointers...); err != nil {
return fmt.Errorf("读取 MySQL 表行失败(%s):%w", tableName, err)
}
literals := make([]string, len(values))
for index, value := range values {
literals[index] = mysqlSQLLiteral(value)
}
if _, err := fmt.Fprintf(output, "INSERT INTO %s (%s) VALUES (%s);\n", quotedTable, quoteMySQLColumns(columns), strings.Join(literals, ", ")); err != nil {
return fmt.Errorf("写入 MySQL 表数据失败(%s):%w", tableName, err)
}
}
if err := rows.Err(); err != nil {
return fmt.Errorf("遍历 MySQL 表数据失败(%s):%w", tableName, err)
}
_, err = fmt.Fprintln(output)
return err
}
func quoteMySQLIdentifier(value string) string {
return "`" + strings.ReplaceAll(value, "`", "``") + "`"
}
func quoteMySQLColumns(columns []string) string {
quoted := make([]string, len(columns))
for index, column := range columns {
quoted[index] = quoteMySQLIdentifier(column)
}
return strings.Join(quoted, ", ")
}
func mysqlSQLLiteral(value any) string {
switch typed := value.(type) {
case nil:
return "NULL"
case sql.RawBytes:
return "X'" + hex.EncodeToString(typed) + "'"
case []byte:
return "X'" + hex.EncodeToString(typed) + "'"
case string:
return mysqlStringLiteral(typed)
case time.Time:
return "'" + typed.UTC().Format("2006-01-02 15:04:05.999999") + "'"
case bool:
if typed {
return "1"
}
return "0"
case int64:
return strconv.FormatInt(typed, 10)
case uint64:
return strconv.FormatUint(typed, 10)
case float64:
return strconv.FormatFloat(typed, 'g', -1, 64)
case float32:
return strconv.FormatFloat(float64(typed), 'g', -1, 32)
default:
return mysqlStringLiteral(fmt.Sprint(typed))
}
}
func mysqlStringLiteral(value string) string {
var builder strings.Builder
builder.Grow(len(value) + 2)
builder.WriteByte('\'')
for index := 0; index < len(value); index++ {
switch value[index] {
case '\\':
builder.WriteString(`\\`)
case '\x00':
builder.WriteString(`\0`)
case '\n':
builder.WriteString(`\n`)
case '\r':
builder.WriteString(`\r`)
case '\x1a':
builder.WriteString(`\Z`)
case '\'':
builder.WriteString(`''`)
default:
builder.WriteByte(value[index])
}
}
builder.WriteByte('\'')
return builder.String()
}
// restoreMySQL 执行 Scert 生成的 MySQL SQL 备份。
func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string) error {
script, err := os.ReadFile(input)
if err != nil {
return fmt.Errorf("读取 MySQL 备份文件失败:%w", err)
}
db, err := database.Open(ctx, cfg)
if err != nil {
return fmt.Errorf("打开 MySQL 恢复连接失败:%w", err)
}
defer db.Close()
for _, statement := range splitMySQLStatements(string(script)) {
if _, err := db.DB.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err)
}
}
return nil
}
func splitMySQLStatements(script string) []string {
var statements []string
var statement strings.Builder
var quote byte
lineComment := false
blockComment := false
executableComment := false
escaped := false
flush := func() {
value := strings.TrimSpace(statement.String())
if value != "" {
statements = append(statements, value)
}
statement.Reset()
}
for index := 0; index < len(script); index++ {
current := script[index]
if lineComment {
if current == '\n' {
lineComment = false
statement.WriteByte('\n')
}
continue
}
if blockComment {
if current == '*' && index+1 < len(script) && script[index+1] == '/' {
blockComment = false
index++
statement.WriteByte(' ')
}
continue
}
if executableComment {
statement.WriteByte(current)
if current == '*' && index+1 < len(script) && script[index+1] == '/' {
statement.WriteByte(script[index+1])
index++
executableComment = false
}
continue
}
if quote != 0 {
statement.WriteByte(current)
if escaped {
escaped = false
continue
}
if current == '\\' {
escaped = true
continue
}
if current == quote {
if index+1 < len(script) && script[index+1] == quote {
statement.WriteByte(script[index+1])
index++
continue
}
quote = 0
}
continue
}
if current == '\'' || current == '"' || current == '`' {
quote = current
statement.WriteByte(current)
continue
}
if current == '#' {
lineComment = true
continue
}
if current == '-' && index+2 < len(script) && script[index+1] == '-' && (script[index+2] == ' ' || script[index+2] == '\t' || script[index+2] == '\n') {
lineComment = true
index++
continue
}
if current == '/' && index+1 < len(script) && script[index+1] == '*' {
if index+2 < len(script) && (script[index+2] == '!' || script[index+2] == '+') {
statement.WriteByte(current)
statement.WriteByte(script[index+1])
index++
executableComment = true
continue
}
blockComment = true
index++
continue
}
if current == ';' {
flush()
continue
}
statement.WriteByte(current)
}
flush()
return statements
}