package dualrun import ( "database/sql" "fmt" "os" "strconv" "strings" "time" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // 数据源守卫的允许清单。 // // 这里是**允许列表**(allow-list),不是禁止列表:任何不在表里的 host 一律拒绝, // 因此不需要、也不允许把某个具体 IP 写进代码 —— 真实环境里那个地址属于生产级共享实例 // (具备 SUPER / DROP / GRANT OPTION),一旦被误连,后果不可逆。 var ( // allowedHosts 允许连接的 host(只允许本机)。 allowedHosts = map[string]bool{ "127.0.0.1": true, "localhost": true, "::1": true, } // allowedDatabases 允许操作的库(legacy 只读基线 / 两个可写副本 / target dev)。 allowedDatabases = map[string]bool{ "finance_bk": true, "legacy_finance": true, "legacy_oracle": true, "yuansiwei_2026": true, } // writableDatabases 允许写入的库(阶段 1 只在 legacy_oracle 上写)。 writableDatabases = map[string]bool{ "legacy_oracle": true, "legacy_finance": true, } ) // Config 是 runner 的连接与运行参数,全部来自环境变量,不落盘。 type Config struct { Host string Port int Name string User string Pass string LegacyRepo string LegacyDBName string // PHP oracle 侧连接的库(阶段 1 固定 legacy_oracle) PHPBinary string } // LoadConfigFromEnv 读取 AC15_* 环境变量。 // // 口令只经环境变量传入:不落盘、不进代码常量、不进报告。 func LoadConfigFromEnv() Config { port := 3306 if v := os.Getenv("AC15_DB_PORT"); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { port = n } } name := os.Getenv("AC15_DB_NAME") if name == "" { name = "legacy_oracle" } user := os.Getenv("AC15_DB_USER") if user == "" { user = "dev_legacy" } repo := os.Getenv("AC15_LEGACY_REPO") if repo == "" { repo = "E:/study/php/ysw-finance/yuansiwei-finance" } php := os.Getenv("AC15_PHP_BIN") if php == "" { php = "php" } host := os.Getenv("AC15_DB_HOST") if host == "" { host = "127.0.0.1" } return Config{ Host: host, Port: port, Name: name, User: user, Pass: os.Getenv("AC15_DB_PASS"), LegacyRepo: repo, LegacyDBName: name, PHPBinary: php, } } // maskHost 把 host 遮掉后两段,保证日志与报告里不出现完整地址。 func maskHost(host string) string { parts := strings.Split(host, ".") if len(parts) == 4 { return parts[0] + "." + parts[1] + ".***.**" } return "" } // Guard 执行**双条件数据源守卫**:host 必须在允许表内。 // // 违反时 **panic**(按验收要求"不满足 → panic 退出"),不返回 error, // 因为这类错误一旦被上层忽略,就会以"连到错误实例后的正常结果"的形式污染整轮结论。 func (c Config) Guard() { if !allowedHosts[c.Host] { panic(fmt.Sprintf("dualrun: 数据源守卫失败 —— host %q 不在允许清单内(只允许本机)", maskHost(c.Host))) } if !allowedDatabases[c.Name] { panic(fmt.Sprintf("dualrun: 数据源守卫失败 —— database %q 不在允许清单内", c.Name)) } if !writableDatabases[c.Name] { panic(fmt.Sprintf("dualrun: 阶段 1 只写 legacy_oracle / legacy_finance,当前是 %q", c.Name)) } if c.Pass == "" { panic("dualrun: AC15_DB_PASS 未设置(口令只经环境变量传入)") } } // DSN 拼装 gorm/go-sql-driver 使用的 DSN。 func (c Config) DSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s", c.User, c.Pass, c.Host, c.Port, c.Name) } // Open 打开一个 *sql.DB(每个 case 应各自 Open/Close,见 README §4)。 func (c Config) Open() (*sql.DB, error) { gdb, err := gorm.Open(mysql.Open(c.DSN()), &gorm.Config{ Logger: logger.Default.LogMode(logger.Silent), DisableAutomaticPing: false, }) if err != nil { return nil, err } sqlDB, err := gdb.DB() if err != nil { return nil, err } sqlDB.SetMaxOpenConns(1) sqlDB.SetMaxIdleConns(1) return sqlDB, nil } // AssertDatabase 是双条件守卫的**第二个条件**:连上之后核对 SELECT DATABASE()。 // // 只在连接串里写库名是不够的(DSN 可能被改、连接可能落到别的默认库), // 必须让服务器自己报出当前库再比对一次。 func AssertDatabase(db *sql.DB, want string) error { var got sql.NullString if err := db.QueryRow("SELECT DATABASE()").Scan(&got); err != nil { return err } if !allowedDatabases[got.String] { return fmt.Errorf("当前库 %q 不在允许清单内", got.String) } if got.String != want { return fmt.Errorf("当前库 %q 与期望 %q 不一致", got.String, want) } return nil } // AssertNoSysdate 是**启动静态断言**:目标库 31 个 routine 定义里 `sysdate(` 出现数必须为 0。 // // 依据:`SET SESSION timestamp` 冻结 NOW() 但不冻结 SYSDATE(),一旦有 SP 用 SYSDATE(), // 冻结时钟就会失效而结果看起来"正常"。9 组实测见 P0 结果文档 §6。 func AssertNoSysdate(db *sql.DB, schema string) (int, error) { var n int err := db.QueryRow( `SELECT COUNT(*) FROM information_schema.routines WHERE routine_schema = ? AND LOWER(routine_definition) LIKE '%sysdate(%'`, schema).Scan(&n) if err != nil { return 0, err } if n != 0 { return n, fmt.Errorf("库 %s 有 %d 个 routine 使用 SYSDATE(),冻结时钟不可靠,拒绝运行", schema, n) } return n, nil } // TruncateToSecond 是冻结口径:两侧都用整秒,避免 legacy 给 .123456 而 target 给 .000 的伪差异。 func TruncateToSecond(t time.Time) time.Time { return t.Truncate(time.Second) } // SetSessionClock 在给定连接上冻结时钟并统一时区。 // // 只影响本会话(timestamp 无全局作用域,SET GLOBAL 会报 1228),因此必须"每 case 新连接"。 func SetSessionClock(db *sql.DB, frozenAt time.Time) error { epoch := TruncateToSecond(frozenAt).Unix() if _, err := db.Exec("SET SESSION time_zone = '+08:00'"); err != nil { return err } if _, err := db.Exec(fmt.Sprintf("SET SESSION timestamp = %d", epoch)); err != nil { return err } return nil }