修复MySQL恢复会话切换与精确数值备份错误

固定恢复连接,按DECIMAL列类型保留精确文本。新增双版本实库备份恢复、错误停止、取消以及正式命令失败回滚回归;不将同进程命令测试视为独立发行验收。
This commit is contained in:
xuans 2026-09-27 19:32:46 +08:00
parent ac205e2a06
commit 7bfd400345
4 changed files with 374 additions and 2 deletions

View File

@ -140,6 +140,10 @@ func dumpMySQLTable(ctx context.Context, db *sql.DB, output *os.File, tableName
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(columns))
pointers := make([]any, len(columns))
@ -151,7 +155,7 @@ func dumpMySQLTable(ctx context.Context, db *sql.DB, output *os.File, tableName
}
literals := make([]string, len(values))
for index, value := range values {
literals[index] = mysqlSQLLiteral(value)
literals[index] = mysqlColumnSQLLiteral(value, 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)
@ -176,6 +180,20 @@ func quoteMySQLColumns(columns []string) string {
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:
@ -243,8 +261,14 @@ func restoreMySQL(ctx context.Context, cfg config.DatabaseConfig, input string)
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()
for _, statement := range splitMySQLStatements(string(script)) {
if _, err := db.DB.ExecContext(ctx, statement); err != nil {
if _, err := connection.ExecContext(ctx, statement); err != nil {
return fmt.Errorf("执行 MySQL 恢复语句失败:%w", err)
}
}

View File

@ -40,6 +40,28 @@ func TestQuoteMySQLColumns(t *testing.T) {
t.Fatalf("MySQL 字段列表不正确:%s", value)
}
}
func TestMySQLColumnSQLLiteral(t *testing.T) {
for _, item := range []struct {
name string
value any
columnType string
want string
}{
{name: "decimal", value: []byte("123456789.123456"), columnType: "DECIMAL", want: "'123456789.123456'"},
{name: "precision", value: sql.RawBytes("-12345678901234567890123456789012345.123456789012345678901234567890"), columnType: "DECIMAL", want: "'-12345678901234567890123456789012345.123456789012345678901234567890'"},
{name: "null", value: nil, columnType: "DECIMAL", want: "NULL"},
{name: "string", value: "0.000001", columnType: "DECIMAL", want: "'0.000001'"},
{name: "binary", value: []byte("12"), columnType: "BLOB", want: "X'3132'"},
} {
t.Run(item.name, func(t *testing.T) {
if got := mysqlColumnSQLLiteral(item.value, item.columnType); got != item.want {
t.Fatalf("字段字面量不正确:%s", got)
}
})
}
}
func TestSplitMySQLStatementsKeepsQuotedSemicolons(t *testing.T) {
script := "-- 注释\nSET FOREIGN_KEY_CHECKS=0;\nINSERT INTO `items` (`value`) VALUES ('a; b'), ('it''s');\n"
statements := splitMySQLStatements(script)

View File

@ -0,0 +1,198 @@
package cli
import (
"bytes"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"github.com/spf13/cobra"
"git.pcux.cn/xuans/Scert/backend/internal/config"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/secretstore"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
// 在同一进程调用正式命令;测试 TLS 注册名不能用于独立发行二进制验收。
func TestMySQLRealBackupRestoreCommands(t *testing.T) {
server := mysqltest.New(t)
for _, scenario := range []string{"success", "sql_failure", "certificate_failure"} {
t.Run(scenario, func(t *testing.T) {
connection := server.Database(t)
connection.MaxIdleConns = 0
db, err := database.Open(t.Context(), connection)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
if err := database.Migrate(t.Context(), db.DB, "mysql"); err != nil {
t.Fatal(err)
}
exec := func(statement string) {
t.Helper()
if _, err := db.DB.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
exec("INSERT INTO settings (`key`, value) VALUES ('integration', 'backup')")
exec("INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('backup', 'test', '1', 'ok', 'backup')")
root := filepath.Join(server.Root(), scenario)
cfg := config.Default(root)
cfg.Paths = config.PathsConfig{
Install: root, Config: filepath.Join(root, "conf"), Data: filepath.Join(root, "data"),
Certificate: filepath.Join(root, "ssl"), Log: filepath.Join(root, "log"), Backup: filepath.Join(root, "snapshots"),
}
parsed, err := mysql.ParseDSN(connection.DSN)
if err != nil {
t.Fatal("解析隔离数据库连接失败")
}
username, password := parsed.User, parsed.Passwd
parsed.User, parsed.Passwd = "", ""
cfg.Database = connection
cfg.Database.DSN = parsed.FormatDSN()
cfg.Database.CredentialRef = "secret://database/maintenance"
current := filepath.Join(cfg.Paths.Config, "scert.json")
if err := cfg.Save(current); err != nil {
t.Fatal(err)
}
store, err := secretstore.Open(cfg.Paths.Config)
if err != nil {
t.Fatal(err)
}
if err := store.Put(t.Context(), cfg.Database.CredentialRef, map[string]string{"username": username, "password": password}); err != nil {
t.Fatal(err)
}
write := func(path string, content []byte) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, content, 0o600); err != nil {
t.Fatal(err)
}
}
read := func(path string) []byte {
t.Helper()
content, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
return content
}
certificate := filepath.Join(cfg.Paths.Certificate, "integration.pem")
write(certificate, read(filepath.Join(server.Root(), "server.crt")))
previous := configPath
configPath = current
t.Cleanup(func() { configPath = previous })
run := func(command *cobra.Command, args ...string) (string, error) {
t.Helper()
var output bytes.Buffer
command.SetArgs(args)
command.SetContext(t.Context())
command.SetOut(&output)
command.SetErr(&output)
err := command.Execute()
if strings.Contains(output.String(), password) || (err != nil && strings.Contains(err.Error(), password)) {
t.Fatal("命令输出不得泄漏数据库密码")
}
return output.String(), err
}
input := filepath.Join(root, "backup")
if _, err := run(newBackupCommand(), "--output", input); err != nil {
t.Fatal(err)
}
if err := validateBackupManifest(input); err != nil {
t.Fatal(err)
}
exec("UPDATE settings SET value = 'current' WHERE `key` = 'integration'")
exec("INSERT INTO audit_logs (action, resource_type, resource_id, status, message) VALUES ('current', 'test', '2', 'ok', 'current')")
cfg.Server.Port++
if err := cfg.Save(current); err != nil {
t.Fatal(err)
}
if err := store.Put(t.Context(), "secret://integration/marker", map[string]string{"value": "current"}); err != nil {
t.Fatal(err)
}
write(certificate, []byte("current-certificate"))
before := make(map[string][]byte)
for _, name := range []string{"scert.json", "master.key", "secrets.json"} {
before[name] = read(filepath.Join(cfg.Paths.Config, name))
}
if scenario == "sql_failure" {
// 保持清单有效,以确认错误发生在实际导入阶段而非哈希校验阶段。
path := filepath.Join(input, "scert.sql")
write(path, append(read(path), []byte("\nINSERT INTO missing_restore_table VALUES (1);\n")...))
manifest, err := buildBackupManifestExcludingManifest(input)
if err != nil {
t.Fatal(err)
}
encoded, err := json.Marshal(manifest)
if err != nil {
t.Fatal(err)
}
write(filepath.Join(input, "manifest.json"), encoded)
if err := validateBackupManifest(input); err != nil {
t.Fatal(err)
}
}
if scenario == "certificate_failure" {
if err := os.Rename(cfg.Paths.Certificate, cfg.Paths.Certificate+"-preserved"); err != nil {
t.Fatal(err)
}
write(cfg.Paths.Certificate, []byte("blocked-target"))
}
output, err := run(newRestoreCommand(), "--input", input)
want, count := "backup", 1
if scenario == "success" {
if err != nil || !strings.Contains(output, "恢复已完成") {
t.Fatalf("正式命令恢复失败:%v", err)
}
for name := range before {
if !bytes.Equal(read(filepath.Join(cfg.Paths.Config, name)), read(filepath.Join(input, name))) {
t.Fatalf("未恢复备份配置:%s", name)
}
}
if !bytes.Equal(read(certificate), read(filepath.Join(input, "certificates", "integration.pem"))) {
t.Fatal("未恢复证书文件")
}
} else {
want, count = "current", 2
phase := "恢复 MySQL 数据库失败"
if scenario == "certificate_failure" {
phase = "恢复敏感配置或证书失败"
}
if err == nil || !strings.Contains(err.Error(), phase) || strings.Contains(output, "恢复已完成") {
t.Fatalf("未在指定阶段失败:%v", err)
}
for name, content := range before {
if !bytes.Equal(read(filepath.Join(cfg.Paths.Config, name)), content) {
t.Fatalf("失败后未恢复当前配置:%s", name)
}
}
preserved := certificate
if scenario == "certificate_failure" {
preserved = filepath.Join(cfg.Paths.Certificate+"-preserved", "integration.pem")
}
if string(read(preserved)) != "current-certificate" {
t.Fatal("失败后原证书内容被修改")
}
}
var value string
var rows int
if err := db.DB.QueryRowContext(t.Context(), "SELECT value FROM settings WHERE `key` = 'integration'").Scan(&value); err != nil || value != want {
t.Fatalf("恢复或回滚后的业务数据错误:%v", err)
}
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM audit_logs").Scan(&rows); err != nil || rows != count {
t.Fatalf("恢复或回滚后的审计记录错误:%v", err)
}
snapshots, err := filepath.Glob(filepath.Join(cfg.Paths.Backup, "scert-before-restore-*", "scert.sql"))
if err != nil || len(snapshots) != 1 {
t.Fatal("必须保留恢复前的数据库快照")
}
})
}
}

View File

@ -0,0 +1,128 @@
package cli
import (
"bytes"
"context"
"database/sql"
"os"
"path/filepath"
"testing"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealBackupRestore(t *testing.T) {
server := mysqltest.New(t)
t.Run("session_identity", func(t *testing.T) {
cfg := server.Database(t)
cfg.MaxIdleConns = 0
input := filepath.Join(server.Root(), "session.sql")
script := "CREATE TABLE session_probe (id BIGINT NOT NULL, original_id BIGINT NOT NULL); SET @restore_connection = CONNECTION_ID(); INSERT INTO session_probe VALUES (CONNECTION_ID(), @restore_connection);"
if err := os.WriteFile(input, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreMySQL(t.Context(), cfg, input); err != nil {
t.Fatalf("恢复必须保留会话变量:%v", err)
}
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
defer db.Close()
var same bool
if err := db.DB.QueryRowContext(t.Context(), "SELECT id = original_id FROM session_probe").Scan(&same); err != nil || !same {
t.Fatalf("恢复过程发生连接切换:%v", err)
}
})
for _, scenario := range []struct {
name string
idle int
}{
{name: "without_idle_connections", idle: 0},
{name: "with_idle_connections", idle: 1},
} {
t.Run(scenario.name, func(t *testing.T) {
cfg := server.Database(t)
cfg.MaxIdleConns = scenario.idle
db, err := database.Open(t.Context(), cfg)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = db.Close() })
exec := func(statement string, args ...any) sql.Result {
t.Helper()
result, err := db.DB.ExecContext(t.Context(), statement, args...)
if err != nil {
t.Fatal(err)
}
return result
}
// 子表排在父表前面,恢复需要在同一会话内暂时关闭外键检查。
exec("CREATE TABLE z_parent (id BIGINT PRIMARY KEY AUTO_INCREMENT, label VARCHAR(100) NOT NULL) ENGINE=InnoDB")
exec("CREATE TABLE a_child (id BIGINT PRIMARY KEY, parent_id BIGINT NOT NULL, payload BLOB, amount DECIMAL(20,6), optional_value TEXT NULL, created_at DATETIME(6), FOREIGN KEY (parent_id) REFERENCES z_parent(id)) ENGINE=InnoDB")
label := "证书恢复;单引号'与反斜线\\\n"
payload := []byte{0, 1, 39, 59, 92, 255}
exec("INSERT INTO z_parent (id, label) VALUES (7, ?)", label)
exec("INSERT INTO a_child VALUES (1, 7, ?, 123456789.123456, NULL, '2026-09-27 12:34:56.123456')", payload)
root := filepath.Join(server.Root(), scenario.name)
if err := os.MkdirAll(root, 0o700); err != nil {
t.Fatal(err)
}
backup := filepath.Join(root, "backup.sql")
if err := backupMySQLDatabase(t.Context(), cfg, backup); err != nil {
t.Fatal(err)
}
exec("DELETE FROM a_child")
exec("DELETE FROM z_parent")
exec("INSERT INTO z_parent (id, label) VALUES (8, 'changed')")
exec("INSERT INTO a_child (id, parent_id) VALUES (2, 8)")
if err := restoreMySQL(t.Context(), cfg, backup); err != nil {
t.Fatalf("恢复真实备份失败:%v", err)
}
var gotLabel, amount, created string
var gotPayload []byte
var optional sql.NullString
if err := db.DB.QueryRowContext(t.Context(), "SELECT p.label, c.payload, CAST(c.amount AS CHAR), c.optional_value, DATE_FORMAT(c.created_at, '%Y-%m-%d %H:%i:%s.%f') FROM a_child c JOIN z_parent p ON p.id = c.parent_id WHERE c.id = 1 AND p.id = 7").Scan(&gotLabel, &gotPayload, &amount, &optional, &created); err != nil {
t.Fatal(err)
}
if gotLabel != label || !bytes.Equal(gotPayload, payload) || amount != "123456789.123456" || optional.Valid || created != "2026-09-27 12:34:56.123456" {
t.Fatal("恢复后的文本、二进制、精确数值、空值或时间不一致")
}
id, err := exec("INSERT INTO z_parent (label) VALUES ('next')").LastInsertId()
if err != nil || id != 8 {
t.Fatalf("恢复后的自增序列不正确:%d,%v", id, err)
}
if _, err := db.DB.ExecContext(t.Context(), "INSERT INTO a_child (id, parent_id) VALUES (99, 999)"); err == nil {
t.Fatal("恢复后仍须强制检查外键约束")
}
t.Run("stop_on_error", func(t *testing.T) {
input := filepath.Join(root, "invalid.sql")
// 第一条写入已经成功时,不能隐式重放或继续执行失败后的语句。
script := "SET FOREIGN_KEY_CHECKS=0; INSERT INTO z_parent (id, label) VALUES (20, 'before'); INSERT INTO absent_table VALUES (1); INSERT INTO z_parent (id, label) VALUES (21, 'after');"
if err := os.WriteFile(input, []byte(script), 0o600); err != nil {
t.Fatal(err)
}
if err := restoreMySQL(t.Context(), cfg, input); err == nil {
t.Fatal("无效恢复语句应返回错误")
}
var count int
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM z_parent WHERE id IN (20, 21)").Scan(&count); err != nil || count != 1 {
t.Fatalf("失败后不应继续执行或隐式重放:%d,%v", count, err)
}
})
t.Run("cancel_before_restore", func(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
cancel()
if err := restoreMySQL(ctx, cfg, backup); err == nil {
t.Fatal("已取消的恢复不得执行")
}
var count int
if err := db.DB.QueryRowContext(t.Context(), "SELECT COUNT(*) FROM z_parent WHERE id = 20").Scan(&count); err != nil || count != 1 {
t.Fatalf("取消恢复不应修改数据:%d,%v", count, err)
}
})
})
}
}