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) } }() // 所有表的数据必须来自同一个可重复读事务,不能按表从连接池独立读取。 // 元数据变更不属于 MVCC 快照,运维期间仍需禁止外部并发 DDL。 tx, err := db.DB.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelRepeatableRead, ReadOnly: true}) if err != nil { return fmt.Errorf("创建 MySQL 一致性备份事务失败:%w", err) } defer tx.Rollback() // 客户端取消会关闭连接,但服务端等待元数据锁时未必立即感知断开。 // 仅限制本次备份会话的单次锁等待;不限制总导出时间,也不修改全局配置。 if _, err := tx.ExecContext(ctx, "SET SESSION lock_wait_timeout = 5"); err != nil { return fmt.Errorf("设置 MySQL 备份元数据锁等待上限失败:%w", err) } 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) } if _, err := fmt.Fprint(file, "-- 恢复前须进入维护窗口,停止事件调度并等待工作线程退出;恢复后先核验对象及警告,再由管理员恢复调度。\n\n"); err != nil { return err } rows, err := tx.QueryContext(ctx, "SELECT TABLE_NAME, ENGINE, TABLE_TYPE FROM information_schema.TABLES WHERE TABLE_SCHEMA = DATABASE() ORDER BY TABLE_NAME") if err != nil { return fmt.Errorf("读取 MySQL 表列表失败:%w", err) } var tableNames []string for rows.Next() { var tableName, tableType string var engine sql.NullString if err := rows.Scan(&tableName, &engine, &tableType); err != nil { _ = rows.Close() return fmt.Errorf("读取 MySQL 表名失败:%w", err) } if tableType != "BASE TABLE" || !engine.Valid || !strings.EqualFold(engine.String, "InnoDB") { _ = rows.Close() return fmt.Errorf("MySQL 一致性备份当前仅支持 InnoDB 基础表,不能静默导出其他对象:%s", tableName) } 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) } if err := requireMySQLTriggerVisibility(ctx, tx, tableNames); err != nil { return err } routines, err := listMySQLRoutines(ctx, tx) if err != nil { return err } events, err := listMySQLEvents(ctx, tx) if err != nil { return err } for _, tableName := range tableNames { if err := dumpMySQLTable(ctx, tx, file, tableName); err != nil { return err } } // 例程先于依赖它的触发器创建;例程定义不执行其中的业务语句。 if err := dumpMySQLRoutines(ctx, tx, file, routines); err != nil { return err } // 必须先恢复所有基础数据,再创建触发器,避免导入时重放业务副作用。 if err := dumpMySQLTriggers(ctx, tx, file); 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 := dumpMySQLEvents(ctx, tx, file, events); err != nil { return err } if err := tx.Commit(); err != nil { return fmt.Errorf("结束 MySQL 一致性备份事务失败:%w", err) } if err := file.Sync(); 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 := ctx.Err(); err != nil { return 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.Tx, 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) } columns, err := mysqlInsertableColumns(ctx, db, tableName) if err != nil { return err } // 显式投影可写列:SELECT * 会遗漏不可见列,并错误地包含生成列的计算结果。 projection := quoteMySQLColumns(columns) if len(columns) == 0 { // 只有生成列的表仍有行数,使用占位读取并导出空列 INSERT 保留每一行。 projection = "1" } rows, err := db.QueryContext(ctx, "SELECT "+projection+" FROM "+quotedTable) if err != nil { return fmt.Errorf("读取 MySQL 表数据失败(%s):%w", tableName, err) } defer rows.Close() selectedColumns, err := rows.Columns() if err != nil { return fmt.Errorf("读取 MySQL 表字段失败(%s):%w", tableName, err) } columnTypes, err := rows.ColumnTypes() if err != nil { return fmt.Errorf("读取 MySQL 表字段类型失败(%s):%w", tableName, err) } for rows.Next() { values := make([]any, len(selectedColumns)) pointers := make([]any, len(values)) 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(columns)) for index := range columns { literals[index] = mysqlColumnSQLLiteral(values[index], columnTypes[index].DatabaseTypeName()) } 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 mysqlInsertableColumns(ctx context.Context, db *sql.Tx, tableName string) ([]string, error) { rows, err := db.QueryContext(ctx, "SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = ? AND COALESCE(GENERATION_EXPRESSION, '') = '' ORDER BY ORDINAL_POSITION", tableName) if err != nil { return nil, fmt.Errorf("读取 MySQL 可写字段失败(%s):%w", tableName, err) } defer rows.Close() var columns []string for rows.Next() { var name string if err := rows.Scan(&name); err != nil { return nil, fmt.Errorf("读取 MySQL 可写字段名失败(%s):%w", tableName, err) } columns = append(columns, name) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("遍历 MySQL 可写字段失败(%s):%w", tableName, err) } return columns, nil } 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 mysqlColumnSQLLiteral(value any, columnType string) string { // 驱动将 DECIMAL 返回为文本字节;十六进制字面量在数值列中会被当作整数。 // 使用文本字面量保留任意精度,不经浮点转换;真正的二进制列仍按原样导出。 if columnType == "DECIMAL" { switch typed := value.(type) { case sql.RawBytes: return mysqlStringLiteral(string(typed)) case []byte: return mysqlStringLiteral(string(typed)) } } return mysqlSQLLiteral(value) } 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() // 会话变量与外键开关必须留在同一条连接上;失败后返回,不切换连接重放。 connection, err := db.DB.Conn(ctx) if err != nil { return fmt.Errorf("获取 MySQL 恢复会话失败:%w", err) } defer connection.Close() statements, err := splitMySQLStatements(string(script)) if err != nil { return err } if err := preflightMySQLEventRestore(ctx, connection, statements); err != nil { return err } for _, statement := range statements { if _, err := connection.ExecContext(ctx, statement); err != nil { return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err) } if err := checkMySQLEventRestoreWarnings(ctx, connection, statement); err != nil { return err } } return nil } func splitMySQLStatements(script string) ([]string, error) { script = strings.TrimPrefix(script, "\uFEFF") var statements []string var statement strings.Builder delimiter := ";" 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 (index == 0 || script[index-1] == '\n') && strings.TrimSpace(statement.String()) == "" { end := strings.IndexByte(script[index:], '\n') if end < 0 { end = len(script) - index } line := strings.TrimSpace(script[index : index+end]) if len(line) >= len("delimiter") && strings.EqualFold(line[:len("delimiter")], "delimiter") && (len(line) == len("delimiter") || line[len("delimiter")] == ' ' || line[len("delimiter")] == '\t') { value := strings.TrimSpace(line[len("delimiter"):]) if len(value) >= 2 && (value[0] == '\'' || value[0] == '"') && value[len(value)-1] == value[0] { value = value[1 : len(value)-1] } if value == "" || strings.ContainsAny(value, " \t\r\n") || strings.ContainsRune(value, '\\') || strings.ContainsRune(value, '\'') || strings.ContainsRune(value, '"') || strings.ContainsRune(value, '`') { return nil, fmt.Errorf("MySQL 备份中的 DELIMITER 指令无效") } delimiter = value statement.Reset() index += end continue } } if strings.HasPrefix(script[index:], delimiter) { flush() index += len(delimiter) - 1 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 } statement.WriteByte(current) } if quote != 0 || blockComment || executableComment { return nil, fmt.Errorf("MySQL 备份包含未闭合的字符串、标识符或注释") } flush() return statements, nil }