保存MySQL例程备份整改检查点(验证未完成)

保留存储过程与函数导出、权限检查、编码处理、共享存储对象逻辑及正式测试,同步两份任务进度文档。

已知阻塞:已有MySQL 5.7实库记录中,两种恢复入口均存在Latin-1例程注释乱码;已有8.0编码与权限专项通过,但不能代替最终双版本全量复测。本次仅重新运行例程单元测试及相关包静态检查,均通过。

这是未完成整改的保存检查点,不是发布版本,不表示P0-05完成或生产验收通过。未纳入运行数据、构建产物、原始实库日志或凭据。
This commit is contained in:
xuans 2026-09-27 22:16:23 +08:00
parent db7d1322b0
commit c4051c9166
11 changed files with 674 additions and 69 deletions

View File

@ -15,7 +15,7 @@ import (
"git.pcux.cn/xuans/Scert/backend/internal/database"
)
// backupMySQL 将 MySQL 的基础表、数据和触发器导出为可在 MySQL 5.7/8.0 导入的 SQL 文件。
// 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 {
@ -88,12 +88,20 @@ func backupMySQLDatabase(ctx context.Context, cfg config.DatabaseConfig, output
if err := requireMySQLTriggerVisibility(ctx, tx, tableNames); err != nil {
return err
}
routines, err := listMySQLRoutines(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

View File

@ -27,27 +27,35 @@ func requireMySQLTriggerVisibility(ctx context.Context, tx *sql.Tx, tables []str
return value
}
name = normalize(name)
grants, err := readMySQLEffectiveGrants(ctx, tx, version)
if err != nil {
return err
}
return requireMySQLTableTriggerGrants(ctx, tx, name, tables, grants, normalize)
}
func readMySQLEffectiveGrants(ctx context.Context, tx *sql.Tx, version string) ([]string, error) {
query := "SHOW GRANTS FOR CURRENT_USER"
if strings.HasPrefix(version, "8.0.") {
rows, err := tx.QueryContext(ctx, "SELECT ROLE_NAME, ROLE_HOST FROM information_schema.ENABLED_ROLES ORDER BY ROLE_NAME, ROLE_HOST")
if err != nil {
return fmt.Errorf("读取 MySQL 当前角色失败:%w", err)
return nil, fmt.Errorf("读取 MySQL 当前角色失败:%w", err)
}
var roles []string
for rows.Next() {
var role, host string
if err := rows.Scan(&role, &host); err != nil {
_ = rows.Close()
return err
return nil, err
}
roles = append(roles, quoteMySQLIdentifier(role)+"@"+quoteMySQLIdentifier(host))
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
return nil, err
}
if err := rows.Close(); err != nil {
return err
return nil, err
}
if len(roles) > 0 {
query += " USING " + strings.Join(roles, ", ")
@ -55,27 +63,31 @@ func requireMySQLTriggerVisibility(ctx context.Context, tx *sql.Tx, tables []str
}
rows, err := tx.QueryContext(ctx, query)
if err != nil {
return fmt.Errorf("读取 MySQL 有效权限失败:%w", err)
return nil, fmt.Errorf("读取 MySQL 有效权限失败:%w", err)
}
var grants []string
for rows.Next() {
var grant string
if err := rows.Scan(&grant); err != nil {
_ = rows.Close()
return err
return nil, err
}
grants = append(grants, grant)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
return err
return nil, err
}
if err := rows.Close(); err != nil {
return err
return nil, err
}
return grants, nil
}
func requireMySQLTableTriggerGrants(ctx context.Context, tx *sql.Tx, name string, tables, grants []string, normalize func(string) string) error {
// 5.7 返回空列表;8.0 部分撤销模式下,数据库授权中的百分号和下划线为字面量。
partialRevokes := false
rows, err = tx.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'partial_revokes'")
rows, err := tx.QueryContext(ctx, "SHOW GLOBAL VARIABLES LIKE 'partial_revokes'")
if err != nil {
return err
}

View File

@ -0,0 +1,86 @@
package cli
import (
"context"
"database/sql"
"fmt"
"strings"
"unicode/utf8"
)
// MySQL 8.0 将 UTF-8 目录中的声明头和注释与原客户端编码的正文拼接,
// 再按客户端字符集返回 SHOW CREATE;非 UTF-8 例程必须分段还原,不能整体反解正文。
func normalizeMySQLRoutineMetadata(ctx context.Context, tx *sql.Tx, routine mysqlRoutine, definition map[string]string, column string) error {
charset := definition["character_set_client"]
if charset == "utf8" || charset == "utf8mb3" || charset == "utf8mb4" {
return nil
}
original := definition[column]
end, err := mysqlRoutineHeaderEnd(original)
if err != nil {
return err
}
decode := func(value string) (string, error) {
var encoded []byte
query := "SELECT CAST(CONVERT(? USING " + quoteMySQLIdentifier(charset) + ") AS BINARY)"
if err := tx.QueryRowContext(ctx, query, value).Scan(&encoded); err != nil {
return "", err
}
if !utf8.Valid(encoded) {
return "", fmt.Errorf("MySQL 例程目录元数据无法无损还原为 UTF-8:%s", routine.name)
}
return string(encoded), nil
}
header, err := decode(original[:end])
if err != nil {
return err
}
var comment string
var body sql.NullString
if err := tx.QueryRowContext(ctx, `SELECT ROUTINE_COMMENT, ROUTINE_DEFINITION FROM information_schema.ROUTINES
WHERE ROUTINE_SCHEMA = DATABASE() AND ROUTINE_NAME = ? AND ROUTINE_TYPE = ?`, routine.name, routine.kind).Scan(&comment, &body); err != nil {
return fmt.Errorf("读取 MySQL 例程编码元数据失败:%w", err)
}
rest := original[end:]
if comment != "" {
if !body.Valid || body.String == "" || !strings.HasSuffix(rest, body.String) {
return fmt.Errorf("MySQL 例程正文与目录元数据不一致,拒绝猜测编码:%s", routine.name)
}
metadata := rest[:len(rest)-len(body.String)]
// 注释位于声明选项的末尾;先隔离正文,避免误改业务 SQL 中的 COMMENT 文本。
start := strings.LastIndex(metadata, "\n COMMENT ")
if start < 0 {
return fmt.Errorf("MySQL 例程注释声明不完整:%s", routine.name)
}
decoded, err := decode(metadata[start:])
if err != nil {
return err
}
rest = metadata[:start] + decoded + body.String
}
definition[column] = header + rest
return nil
}
func mysqlRoutineHeaderEnd(statement string) (int, error) {
var quote byte
for i := 0; i < len(statement); i++ {
current := statement[i]
if quote != 0 {
if current == quote {
if i+1 < len(statement) && statement[i+1] == quote {
i++
} else {
quote = 0
}
}
continue
}
if current == '`' || current == '"' || current == '\'' {
quote = current
} else if current == '(' {
return i, nil
}
}
return 0, fmt.Errorf("MySQL 例程声明缺少完整参数列表")
}

View File

@ -0,0 +1,131 @@
package cli
import (
"context"
"database/sql"
"fmt"
"io"
"strings"
)
type mysqlRoutine struct {
name string
kind string
utf8MetadataHeader bool
}
func listMySQLRoutines(ctx context.Context, tx *sql.Tx) ([]mysqlRoutine, error) {
var name, version string
var lowerCaseNames int
if err := tx.QueryRowContext(ctx, "SELECT DATABASE(), VERSION(), @@lower_case_table_names").Scan(&name, &version, &lowerCaseNames); err != nil {
return nil, fmt.Errorf("读取 MySQL 例程权限环境失败:%w", err)
}
query := "SELECT ROUTINE_NAME, ROUTINE_TYPE FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_TYPE, ROUTINE_NAME"
switch {
case strings.HasPrefix(version, "5.7."):
// 直接证明系统目录的完整读取权限,不能依赖会静默隐藏他人例程的元数据列表。
rows, err := tx.QueryContext(ctx, "SELECT * FROM mysql.proc LIMIT 0")
if err != nil {
return nil, fmt.Errorf("MySQL 例程备份需要 mysql.proc 的完整 SELECT 权限:%w", err)
}
if err := rows.Close(); err != nil {
return nil, err
}
query = "SELECT name, type FROM mysql.proc WHERE db = DATABASE() ORDER BY type, name"
case strings.HasPrefix(version, "8.0."):
grants, err := readMySQLEffectiveGrants(ctx, tx, version)
if err != nil {
return nil, err
}
if !mysqlRoutineVisibilityGranted(grants, name, lowerCaseNames != 0) {
return nil, fmt.Errorf("MySQL 例程备份需要有效 SHOW_ROUTINE 或未被撤销的全局 SELECT 权限,不能把隐藏的例程当成不存在")
}
default:
return nil, fmt.Errorf("当前 MySQL 版本的完整例程可见权限尚未验证:%s", version)
}
rows, err := tx.QueryContext(ctx, query)
if err != nil {
return nil, fmt.Errorf("读取 MySQL 例程列表失败:%w", err)
}
defer rows.Close()
var routines []mysqlRoutine
for rows.Next() {
var routine mysqlRoutine
if err := rows.Scan(&routine.name, &routine.kind); err != nil {
return nil, err
}
if routine.kind != "PROCEDURE" && routine.kind != "FUNCTION" {
return nil, fmt.Errorf("MySQL 例程类型不受支持:%s", routine.kind)
}
routine.utf8MetadataHeader = strings.HasPrefix(version, "8.0.")
routines = append(routines, routine)
}
return routines, rows.Err()
}
// SHOW_ROUTINE 是独立动态权限;库级 EXECUTE 或 CREATE ROUTINE 不能证明所有定义都可见。
func mysqlRoutineVisibilityGranted(grants []string, name string, foldCase bool) bool {
showRoutine, globalSelect, revoked := false, false, false
for _, grant := range grants {
match := mysqlPrivilegeGrant.FindStringSubmatch(grant)
if len(match) == 0 {
continue
}
selectPrivilege, showPrivilege := false, false
for _, privilege := range strings.Split(match[2], ",") {
switch strings.TrimSpace(privilege) {
case "SELECT", "ALL PRIVILEGES":
selectPrivilege = true
case "SHOW_ROUTINE":
showPrivilege = true
}
}
if match[1] == "GRANT" && match[3] == "*" && match[4] == "*" {
showRoutine = showRoutine || showPrivilege
globalSelect = globalSelect || selectPrivilege
}
schema := unquoteMySQLGrantIdentifier(match[3])
sameSchema := schema == name || (foldCase && strings.EqualFold(schema, name))
if match[1] == "REVOKE" && sameSchema && match[4] == "*" {
revoked = revoked || selectPrivilege
}
}
return showRoutine || (globalSelect && !revoked)
}
func dumpMySQLRoutines(ctx context.Context, tx *sql.Tx, output io.Writer, routines []mysqlRoutine) error {
if len(routines) == 0 {
return nil
}
var databaseCollation string
if err := tx.QueryRowContext(ctx, "SELECT @@collation_database").Scan(&databaseCollation); err != nil {
return fmt.Errorf("读取 MySQL 数据库排序规则失败:%w", err)
}
if _, err := fmt.Fprintln(output, `SET @scert_routine_sql_mode = @@SESSION.sql_mode;
SET @scert_routine_charset = @@SESSION.character_set_client;
SET @scert_routine_collation = @@SESSION.collation_connection;`); err != nil {
return err
}
for _, routine := range routines {
column := "Create Procedure"
if routine.kind == "FUNCTION" {
column = "Create Function"
}
definition, err := readMySQLStoredDefinition(ctx, tx, routine.kind, routine.name, column)
if err != nil {
return err
}
if routine.utf8MetadataHeader {
if err := normalizeMySQLRoutineMetadata(ctx, tx, routine, definition, column); err != nil {
return err
}
}
// 同名例程必须替换;定义者和权限语义来自原始 SHOW CREATE,不擅自改写。
drop := "DROP " + routine.kind + " IF EXISTS " + quoteMySQLIdentifier(routine.name)
if err := writeMySQLStoredDefinition(ctx, tx, output, definition, column, drop); err != nil {
return fmt.Errorf("写入 MySQL 例程失败(%s):%w", routine.name, err)
}
}
_, err := fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = @scert_routine_sql_mode;\nSET SESSION character_set_client = @scert_routine_charset;\nSET SESSION collation_connection = @scert_routine_collation;\n\n", quoteMySQLIdentifier(databaseCollation))
return err
}

View File

@ -0,0 +1,256 @@
package cli
import (
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"github.com/go-sql-driver/mysql"
"git.pcux.cn/xuans/Scert/backend/internal/database"
"git.pcux.cn/xuans/Scert/backend/internal/testsupport/mysqltest"
)
func TestMySQLRealNativeRoutines(t *testing.T) {
server := mysqltest.New(t)
source := server.Database(t)
parsed, err := mysql.ParseDSN(source.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLProgramAccess(t, server, parsed.User, parsed.DBName)
// 只调整独立实例专用账户,不能修改共享服务的函数信任开关。
account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'"
grant := "GRANT SELECT ON mysql.proc TO " + account
if server.IsMySQL8() {
grant = "GRANT SHOW_ROUTINE, SUPER ON *.* TO " + account
}
if _, err := server.Admin().ExecContext(t.Context(), grant); err != nil {
t.Fatal(err)
}
db, err := database.Open(t.Context(), source)
if err != nil {
t.Fatal(err)
}
defer db.Close()
connection, err := db.DB.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
defer connection.Close()
for _, statement := range []string{
"SET SESSION sql_mode = 'STRICT_TRANS_TABLES,NO_ENGINE_SUBSTITUTION'",
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
"CREATE TABLE interop (id INT PRIMARY KEY, value INT NOT NULL, label VARCHAR(100)) ENGINE=InnoDB",
"INSERT INTO interop VALUES (1, 10, '原始值')",
"CREATE FUNCTION format_value(input_value DECIMAL(20,4)) RETURNS VARCHAR(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DETERMINISTIC NO SQL SQL SECURITY INVOKER COMMENT '函数说明' RETURN CONCAT('函数;', input_value)",
"CREATE PROCEDURE `change``value`(IN target_id INT, INOUT step_value INT, OUT result_label VARCHAR(100)) SQL SECURITY INVOKER COMMENT '过程说明' BEGIN UPDATE interop SET value = value + step_value WHERE id = target_id; SET step_value = step_value + 1; SET result_label = format_value(step_value); END",
"CREATE TRIGGER routine_trigger BEFORE INSERT ON interop FOR EACH ROW SET NEW.label = format_value(NEW.value)",
"ALTER DATABASE COLLATE latin1_swedish_ci",
"SET NAMES latin1 COLLATE latin1_bin",
"CREATE FUNCTION `legacy_caf" + string([]byte{0xe9}) + "`() RETURNS VARCHAR(100) DETERMINISTIC NO SQL COMMENT 'caf" + string([]byte{0xe9}) + ";;' RETURN 'caf" + string([]byte{0xe9}) + ";;'",
"SET NAMES utf8mb4 COLLATE utf8mb4_bin",
"ALTER DATABASE COLLATE utf8mb4_unicode_ci",
} {
if _, err := connection.ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
definition := readMySQLRoutineDefinitions(t, db.DB)
output := filepath.Join(server.Root(), "native-routines.sql")
if err := backupMySQLDatabase(t.Context(), source, output); err != nil {
t.Fatal(err)
}
for _, direction := range []string{"scert_to_scert", "scert_to_official"} {
t.Run(direction, func(t *testing.T) {
target := server.Database(t)
targetDSN, err := mysql.ParseDSN(target.DSN)
if err != nil {
t.Fatal(err)
}
grantMySQLProgramAccess(t, server, parsed.User, targetDSN.DBName)
targetDSN.User = parsed.User
target.DSN = targetDSN.FormatDSN()
verify, err := database.Open(t.Context(), target)
if err != nil {
t.Fatal(err)
}
defer verify.Close()
// 恢复到已有同名例程的数据库,不能只验证空库创建。
if _, err := verify.DB.ExecContext(t.Context(), "CREATE FUNCTION format_value(input_value INT) RETURNS INT DETERMINISTIC NO SQL RETURN -1"); err != nil {
t.Fatal(err)
}
for attempt := 0; attempt < 2; attempt++ {
if direction == "scert_to_official" {
server.Import(t, target, output)
} else if err := restoreMySQL(t.Context(), target, output); err != nil {
t.Fatal(err)
}
if restored := readMySQLRoutineDefinitions(t, verify.DB); restored != definition {
t.Fatalf("例程定义、参数、权限上下文或字符集不一致:\n源:%s\n恢复:%s", definition, restored)
}
checkMySQLInteropValue(t, target, "SELECT CONCAT(value, '|', label) FROM interop WHERE id = 1", "10|原始值")
checkMySQLInteropValue(t, target, "SELECT format_value(7)", "函数;7.0000")
checkMySQLInteropValue(t, target, "SELECT `legacy_café`()", "café;;")
checkMySQLInteropValue(t, target, "SELECT @@collation_database", "utf8mb4_unicode_ci")
conn, err := verify.DB.Conn(t.Context())
if err != nil {
t.Fatal(err)
}
for _, statement := range []string{"SET @step = 5", "CALL `change``value`(1, @step, @label)", "INSERT INTO interop VALUES (2, 4, '新行')"} {
if _, err := conn.ExecContext(t.Context(), statement); err != nil {
_ = conn.Close()
t.Fatal(err)
}
}
var actual string
err = conn.QueryRowContext(t.Context(), "SELECT CONCAT(@step, '|', @label)").Scan(&actual)
_ = conn.Close()
if err != nil || actual != "6|函数;6.0000" {
t.Fatalf("过程输入输出参数错误:%q,%v", actual, err)
}
checkMySQLInteropValue(t, target, "SELECT CAST(value AS CHAR) FROM interop WHERE id = 1", "15")
checkMySQLInteropValue(t, target, "SELECT label FROM interop WHERE id = 2", "函数;4.0000")
}
})
}
}
func TestMySQLRealRoutineVisibility(t *testing.T) {
server := mysqltest.New(t)
scenarios := []string{"visible", "hidden", "empty_schema_without_permission", "global_select", "execute_only"}
if server.IsMySQL8() {
scenarios = append(scenarios, "active_role", "nested_role", "inactive_role", "partial_revoke", "partial_revoke_with_show")
} else {
scenarios = append(scenarios, "column_only")
}
for _, scenario := range scenarios {
t.Run(scenario, func(t *testing.T) {
cfg := server.Database(t)
parsed, err := mysql.ParseDSN(cfg.DSN)
if err != nil {
t.Fatal(err)
}
account := mysqlStringLiteral(parsed.User) + "@'127.0.0.1'"
permission := "SELECT ON mysql.proc"
if server.IsMySQL8() {
permission = "SHOW_ROUTINE ON *.*"
}
if _, err := server.Admin().ExecContext(t.Context(), "GRANT "+permission+" TO "+account); err != nil {
t.Fatal(err)
}
if scenario != "empty_schema_without_permission" {
statement := "CREATE PROCEDURE " + quoteMySQLIdentifier(parsed.DBName) + ".hidden_routine() SELECT 123"
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
allowed := scenario == "visible" || scenario == "global_select" || scenario == "active_role" || scenario == "nested_role" || scenario == "partial_revoke_with_show"
if scenario != "visible" && scenario != "partial_revoke_with_show" {
if _, err := server.Admin().ExecContext(t.Context(), "REVOKE "+permission+" FROM "+account); err != nil {
t.Fatal(err)
}
}
var statements []string
switch scenario {
case "global_select":
statements = append(statements, "GRANT SELECT ON *.* TO "+account)
case "execute_only":
statements = append(statements, "GRANT EXECUTE ON "+quoteMySQLIdentifier(parsed.DBName)+".* TO "+account)
case "column_only":
statements = append(statements, "GRANT SELECT (db, name, type) ON mysql.proc TO "+account)
case "active_role", "nested_role", "inactive_role":
role := quoteMySQLIdentifier("routine_"+scenario) + "@'%'"
statements = append(statements, "CREATE ROLE "+role, "GRANT SHOW_ROUTINE ON *.* TO "+role, "GRANT "+role+" TO "+account)
if scenario == "active_role" {
statements = append(statements, "SET DEFAULT ROLE "+role+" TO "+account)
}
if scenario == "nested_role" {
parent := "'routine_parent'@'%'"
statements = append(statements, "REVOKE "+role+" FROM "+account, "CREATE ROLE "+parent, "GRANT "+role+" TO "+parent, "GRANT "+parent+" TO "+account, "SET DEFAULT ROLE "+parent+" TO "+account)
}
case "partial_revoke", "partial_revoke_with_show":
// 先移除夹具的库级授权,否则一次 REVOKE 只移除该授权而不撤销全局权限。
statements = append(statements, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account,
"SET GLOBAL partial_revokes = ON", "GRANT SELECT ON *.* TO "+account, "REVOKE SELECT ON "+quoteMySQLIdentifier(parsed.DBName)+".* FROM "+account)
}
for _, statement := range statements {
if _, err := server.Admin().ExecContext(t.Context(), statement); err != nil {
t.Fatal(err)
}
}
if scenario == "hidden" || scenario == "empty_schema_without_permission" || scenario == "inactive_role" {
checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "0")
}
if allowed || scenario == "execute_only" {
checkMySQLInteropValue(t, cfg, "SELECT CAST(COUNT(*) AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1")
}
if scenario == "execute_only" {
checkMySQLInteropValue(t, cfg, "SELECT CAST(ROUTINE_DEFINITION IS NULL AS CHAR) FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE()", "1")
}
output := filepath.Join(server.Root(), "routines-"+scenario+".sql")
if err := os.WriteFile(output, []byte("existing backup"), 0o600); err != nil {
t.Fatal(err)
}
err = backupMySQLDatabase(t.Context(), cfg, output)
if allowed {
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(output)
if err != nil || !strings.Contains(string(data), "hidden_routine") {
t.Fatal("可见例程未完整导出")
}
} else {
if err == nil || !strings.Contains(err.Error(), "例程") {
t.Fatalf("缺少完整例程可见权限必须拒绝:%v", err)
}
data, readErr := os.ReadFile(output)
if readErr != nil || string(data) != "existing backup" {
t.Fatal("权限不足破坏了原有备份")
}
if _, err := os.Stat(output + ".tmp"); !os.IsNotExist(err) {
t.Fatal("权限不足遗留临时文件")
}
}
})
}
}
func readMySQLRoutineDefinitions(t *testing.T, db *sql.DB) string {
t.Helper()
queries := []string{
`SELECT JSON_ARRAY(ROUTINE_NAME, ROUTINE_TYPE, DTD_IDENTIFIER, ROUTINE_BODY, ROUTINE_DEFINITION,
IS_DETERMINISTIC, SQL_DATA_ACCESS, SECURITY_TYPE, SQL_MODE, ROUTINE_COMMENT, DEFINER,
CHARACTER_SET_CLIENT, COLLATION_CONNECTION, DATABASE_COLLATION)
FROM information_schema.ROUTINES WHERE ROUTINE_SCHEMA = DATABASE() ORDER BY ROUTINE_TYPE, ROUTINE_NAME`,
`SELECT JSON_ARRAY(SPECIFIC_NAME, ORDINAL_POSITION, PARAMETER_MODE, PARAMETER_NAME,
DTD_IDENTIFIER, CHARACTER_SET_NAME, COLLATION_NAME, ROUTINE_TYPE)
FROM information_schema.PARAMETERS WHERE SPECIFIC_SCHEMA = DATABASE() ORDER BY SPECIFIC_NAME, ORDINAL_POSITION`,
}
var definitions []string
for _, query := range queries {
rows, err := db.QueryContext(t.Context(), query)
if err != nil {
t.Fatal(err)
}
for rows.Next() {
var definition string
if err := rows.Scan(&definition); err != nil {
_ = rows.Close()
t.Fatal(err)
}
definitions = append(definitions, definition)
}
if err := rows.Err(); err != nil {
_ = rows.Close()
t.Fatal(err)
}
if err := rows.Close(); err != nil {
t.Fatal(err)
}
}
return strings.Join(definitions, "\n")
}

View File

@ -0,0 +1,50 @@
package cli
import (
"strings"
"testing"
)
func TestMySQLRoutineHeaderEnd(t *testing.T) {
for _, header := range []string{
"CREATE DEFINER=`user`@`host` FUNCTION `name`",
"CREATE DEFINER=`u(s``er`@`host` PROCEDURE `na(me`",
`CREATE DEFINER="u(ser"@"host" FUNCTION "na""me"`,
} {
end, err := mysqlRoutineHeaderEnd(header + "(IN value INT) BEGIN SELECT 1; END")
if err != nil || end != len(header) {
t.Fatalf("声明头边界不正确:%d,%v", end, err)
}
}
if _, err := mysqlRoutineHeaderEnd("CREATE FUNCTION `unterminated()"); err == nil || !strings.Contains(err.Error(), "参数列表") {
t.Fatal("不完整声明头必须失败")
}
}
func TestMySQLRoutineVisibilityGrants(t *testing.T) {
for _, item := range []struct {
name string
grants []string
foldCase bool
want bool
}{
{"show_routine", []string{"GRANT SHOW_ROUTINE ON *.* TO 'u'@'h'"}, false, true},
{"global_select", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "GRANT BACKUP_ADMIN ON *.* TO 'u'@'h'"}, false, true},
{"static_all", []string{"GRANT ALL PRIVILEGES ON *.* TO 'u'@'h'"}, false, true},
{"schema_select", []string{"GRANT SELECT ON `sample`.* TO 'u'@'h'"}, false, false},
{"schema_all", []string{"GRANT ALL PRIVILEGES ON `sample`.* TO 'u'@'h'"}, false, false},
{"role_name", []string{"GRANT `SHOW_ROUTINE`@`%` TO 'u'@'h'"}, false, false},
{"execute_only", []string{"GRANT EXECUTE, CREATE ROUTINE ON *.* TO 'u'@'h'"}, false, false},
{"partial_revoke", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `sample`.* FROM 'u'@'h'"}, false, false},
{"revoke_other_schema", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `other`.* FROM 'u'@'h'"}, false, true},
{"show_not_revoked", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `sample`.* FROM 'u'@'h'", "GRANT SHOW_ROUTINE ON *.* TO 'u'@'h'"}, false, true},
{"fold_revoke", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `SAMPLE`.* FROM 'u'@'h'"}, true, false},
{"case_sensitive", []string{"GRANT SELECT ON *.* TO 'u'@'h'", "REVOKE SELECT ON `SAMPLE`.* FROM 'u'@'h'"}, false, true},
} {
t.Run(item.name, func(t *testing.T) {
if got := mysqlRoutineVisibilityGranted(item.grants, "sample", item.foldCase); got != item.want {
t.Fatalf("例程可见权限判定错误:得到 %t,期望 %t", got, item.want)
}
})
}
}

View File

@ -0,0 +1,94 @@
package cli
import (
"context"
"database/sql"
"fmt"
"io"
"strings"
)
func readMySQLStoredDefinition(ctx context.Context, tx *sql.Tx, kind, name, statementColumn string) (map[string]string, error) {
rows, err := tx.QueryContext(ctx, "SHOW CREATE "+kind+" "+quoteMySQLIdentifier(name))
if err != nil {
return nil, fmt.Errorf("读取 MySQL 存储对象定义失败(%s %s):%w", kind, name, err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, err
}
values := make([]sql.NullString, len(columns))
pointers := make([]any, len(columns))
for i := range values {
pointers[i] = &values[i]
}
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, err
}
return nil, fmt.Errorf("MySQL 存储对象定义不可见(%s %s)", kind, name)
}
if err := rows.Scan(pointers...); err != nil {
return nil, err
}
definition := make(map[string]string, len(columns))
for i, column := range columns {
if values[i].Valid {
definition[column] = values[i].String
}
}
for _, column := range []string{"sql_mode", statementColumn, "character_set_client", "collation_connection", "Database Collation"} {
value, exists := definition[column]
if !exists || (value == "" && column != "sql_mode") {
return nil, fmt.Errorf("MySQL 存储对象定义字段不完整(%s %s):%s", kind, name, column)
}
}
if rows.Next() {
return nil, fmt.Errorf("MySQL 存储对象定义返回了多行(%s %s)", kind, name)
}
return definition, rows.Err()
}
func writeMySQLStoredDefinition(ctx context.Context, tx *sql.Tx, output io.Writer, definition map[string]string, column, drop string) error {
charset := quoteMySQLIdentifier(definition["character_set_client"])
encode := func(original string) (string, error) {
var encoded, roundTrip []byte
query := "SELECT CAST(CONVERT(? USING " + charset + ") AS BINARY), CAST(CONVERT(CONVERT(? USING " + charset + ") USING utf8mb4) AS BINARY)"
if err := tx.QueryRowContext(ctx, query, original, original).Scan(&encoded, &roundTrip); err != nil {
return "", fmt.Errorf("转换 MySQL 存储对象字符集失败:%w", err)
}
if string(roundTrip) != original {
return "", fmt.Errorf("MySQL 存储对象无法无损转换到创建字符集")
}
return string(encoded), nil
}
// 删除和创建语句均使用原客户端编码,非 ASCII 标识符不能混用 UTF-8 字节。
encoded, err := encode(definition[column])
if err != nil {
return err
}
encodedDrop := ""
if drop != "" {
encodedDrop, err = encode(drop)
if err != nil {
return err
}
}
delimiter := ";;"
for strings.Contains(encoded, delimiter) || strings.Contains(encodedDrop, delimiter) {
delimiter += ";"
}
if _, err := fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = %s;\nSET SESSION character_set_client = %s;\nSET SESSION collation_connection = %s;\nDELIMITER %s\n",
quoteMySQLIdentifier(definition["Database Collation"]), mysqlStringLiteral(definition["sql_mode"]),
mysqlStringLiteral(definition["character_set_client"]), mysqlStringLiteral(definition["collation_connection"]), delimiter); err != nil {
return err
}
if encodedDrop != "" {
if _, err := fmt.Fprintf(output, "%s%s\n", encodedDrop, delimiter); err != nil {
return err
}
}
_, err = fmt.Fprintf(output, "%s%s\nDELIMITER ;\n", encoded, delimiter)
return err
}

View File

@ -5,7 +5,6 @@ import (
"database/sql"
"fmt"
"io"
"strings"
)
func dumpMySQLTriggers(ctx context.Context, tx *sql.Tx, output io.Writer) error {
@ -49,25 +48,7 @@ SET @scert_trigger_collation = @@SESSION.collation_connection;`); err != nil {
if err != nil {
return err
}
// 文件中的创建语句使用原客户端字符集,恢复时才能同时保留正文和创建元数据。
var encoded, roundTrip []byte
charset := quoteMySQLIdentifier(definition["character_set_client"])
query := "SELECT CAST(CONVERT(? USING " + charset + ") AS BINARY), CAST(CONVERT(CONVERT(? USING " + charset + ") USING utf8mb4) AS BINARY)"
original := definition["SQL Original Statement"]
if err := tx.QueryRowContext(ctx, query, original, original).Scan(&encoded, &roundTrip); err != nil {
return fmt.Errorf("转换 MySQL 触发器字符集失败(%s):%w", name, err)
}
if string(roundTrip) != original {
return fmt.Errorf("MySQL 触发器无法无损转换到创建字符集(%s)", name)
}
delimiter := ";;"
for strings.Contains(string(encoded), delimiter) {
delimiter += ";"
}
_, err = fmt.Fprintf(output, "ALTER DATABASE COLLATE %s;\nSET SESSION sql_mode = %s;\nSET SESSION character_set_client = %s;\nSET SESSION collation_connection = %s;\nDELIMITER %s\n%s%s\nDELIMITER ;\n",
quoteMySQLIdentifier(definition["Database Collation"]), mysqlStringLiteral(definition["sql_mode"]),
mysqlStringLiteral(definition["character_set_client"]), mysqlStringLiteral(definition["collation_connection"]), delimiter, encoded, delimiter)
if err != nil {
if err := writeMySQLStoredDefinition(ctx, tx, output, definition, "SQL Original Statement", ""); err != nil {
return fmt.Errorf("写入 MySQL 触发器失败(%s):%w", name, err)
}
}
@ -76,43 +57,5 @@ SET @scert_trigger_collation = @@SESSION.collation_connection;`); err != nil {
}
func readMySQLTrigger(ctx context.Context, tx *sql.Tx, name string) (map[string]string, error) {
rows, err := tx.QueryContext(ctx, "SHOW CREATE TRIGGER "+quoteMySQLIdentifier(name))
if err != nil {
return nil, fmt.Errorf("读取 MySQL 触发器定义失败(%s):%w", name, err)
}
defer rows.Close()
columns, err := rows.Columns()
if err != nil {
return nil, err
}
values := make([]sql.NullString, len(columns))
pointers := make([]any, len(columns))
for i := range values {
pointers[i] = &values[i]
}
if !rows.Next() {
if err := rows.Err(); err != nil {
return nil, err
}
return nil, fmt.Errorf("MySQL 触发器定义不可见(%s)", name)
}
if err := rows.Scan(pointers...); err != nil {
return nil, err
}
definition := make(map[string]string, len(columns))
for i, column := range columns {
if values[i].Valid {
definition[column] = values[i].String
}
}
for _, column := range []string{"sql_mode", "SQL Original Statement", "character_set_client", "collation_connection", "Database Collation"} {
value, exists := definition[column]
if !exists || (value == "" && column != "sql_mode") {
return nil, fmt.Errorf("MySQL 触发器定义字段不完整(%s):%s", name, column)
}
}
if rows.Next() {
return nil, fmt.Errorf("MySQL 触发器定义返回了多行(%s)", name)
}
return definition, rows.Err()
return readMySQLStoredDefinition(ctx, tx, "TRIGGER", name, "SQL Original Statement")
}

View File

@ -218,6 +218,14 @@ func (f *Server) Database(t *testing.T) config.DatabaseConfig {
t.Fatal("创建隔离数据库及库级账户失败")
}
}
// 备份必须能发现其他定义者的例程,不能将过滤后的空列表当作完整目录。
grant := "GRANT SELECT ON mysql.proc TO '" + user + "'@'127.0.0.1'"
if f.IsMySQL8() {
grant = "GRANT SHOW_ROUTINE ON *.* TO '" + user + "'@'127.0.0.1'"
}
if _, err := f.admin.ExecContext(t.Context(), grant); err != nil {
t.Fatal("授予隔离账户例程可见权限失败")
}
return config.DatabaseConfig{Driver: "mysql", DSN: f.DSN(user, name), MaxOpenConns: 2, MaxIdleConns: 1}
}

View File

@ -347,3 +347,13 @@ P0-05 从 `[!]` 调整为 `[~]`,并未标记完成;P0-04 剩余门禁和全
- `[ ]` 后续:例程、事件、视图、完整对象清单权限、其他 SQL 模式、复杂授权组合、定义者迁移、独立发行命令、Linux 实库、PostgreSQL、SQLite 及正式业务部署。
证据目录:`artifacts/mysql-native-triggers-20260927/`,最终结果为 `mysql57-final.jsonl`、`mysql80-final.jsonl`;详见支持矩阵第十一节。全部 44 项范围不变,P0-05 保持 `[~]`,本批不作生产放行。
### 2026-09-27:P0-05 自有存储过程与函数备份
修改前检查点 `db7d132` 已推送。本批接续触发器子任务,全部 44 项和原验收标准不变。
- `[x]` 在独立 MySQL 5.7 实例复现例程遗漏,以及权限不足时仍成功覆盖原备份;复现证据为 `artifacts/mysql-native-routines-20260927/mysql57-before.jsonl`。
- `[x]` 增加原始例程定义导出、同名替换、创建元数据与编码保留;复用触发器的存储对象读写逻辑,先恢复数据和例程,再创建触发器。
- `[x]` 增加完整例程可见权限检查;5.7 检查 `mysql.proc` 完整读取权限,8.0 检查有效 `SHOW_ROUTINE` 或全局 `SELECT`,拒绝把隐藏对象当作不存在。
- `[~]` 正式回归覆盖两种恢复入口、重复恢复、输入输出参数、Latin-1 名称/正文、元数据一致性、角色和部分撤销。Windows 双版本全专项、后端完整回归和交叉编译正在验证,结果以支持矩阵后续记录为准。
- `[ ]` 事件、视图、完整表清单权限、其他 SQL 模式及剩余数据库/平台/生产门禁继续整改。

View File

@ -81,6 +81,13 @@
- `[x]` 本批实现、最终复测、收尾与文档证据已核对;后端全量测试、vet、模块校验及 Linux 测试包交叉编译通过。细节见支持矩阵第十一节,不提升 P0-05 总状态,也不将交叉编译记为 Linux 实库通过。
- `[ ]` 例程、事件、视图、其他 SQL 模式、完整表清单权限及剩余三库/双平台矩阵;歧义通配授权目前保守失败,不能视为所有权限组合均完整支持。
### P0-05 例程自有备份子任务进度
- `[x]` 复现并修复存储过程、函数遗漏;保留定义者、参数、SQL 模式、创建字符集与排序规则,支持替换同名例程。
- `[x]` 不依赖过滤后的元数据空列表;完整查看权限不足时失败,保留已有备份并清理临时文件。
- `[~]` 双版本正式实库回归、角色和部分撤销验证、完整后端回归及 Linux 测试包交叉编译。
- `[ ]` 事件、视图、完整表清单权限、其他 SQL 模式、跨服务器定义者迁移及生产恢复验收。本子任务不提升 P0-05 总状态。
## P0-06 建立统一领域模型
- 任务编号:P0-06;任务名称:建立统一领域模型;优先级:P0。