保存MySQL例程备份整改检查点(验证未完成)
保留存储过程与函数导出、权限检查、编码处理、共享存储对象逻辑及正式测试,同步两份任务进度文档。 已知阻塞:已有MySQL 5.7实库记录中,两种恢复入口均存在Latin-1例程注释乱码;已有8.0编码与权限专项通过,但不能代替最终双版本全量复测。本次仅重新运行例程单元测试及相关包静态检查,均通过。 这是未完成整改的保存检查点,不是发布版本,不表示P0-05完成或生产验收通过。未纳入运行数据、构建产物、原始实库日志或凭据。
This commit is contained in:
parent
db7d1322b0
commit
c4051c9166
@ -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
|
||||
|
||||
@ -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
|
||||
}
|
||||
|
||||
86
backend/internal/cli/mysql_routine_encoding.go
Normal file
86
backend/internal/cli/mysql_routine_encoding.go
Normal 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 例程声明缺少完整参数列表")
|
||||
}
|
||||
131
backend/internal/cli/mysql_routines.go
Normal file
131
backend/internal/cli/mysql_routines.go
Normal 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
|
||||
}
|
||||
256
backend/internal/cli/mysql_routines_integration_test.go
Normal file
256
backend/internal/cli/mysql_routines_integration_test.go
Normal 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")
|
||||
}
|
||||
50
backend/internal/cli/mysql_routines_test.go
Normal file
50
backend/internal/cli/mysql_routines_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
94
backend/internal/cli/mysql_stored_definition.go
Normal file
94
backend/internal/cli/mysql_stored_definition.go
Normal 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
|
||||
}
|
||||
@ -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")
|
||||
}
|
||||
|
||||
@ -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}
|
||||
}
|
||||
|
||||
|
||||
@ -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 模式及剩余数据库/平台/生产门禁继续整改。
|
||||
|
||||
@ -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。
|
||||
|
||||
Loading…
Reference in New Issue
Block a user