| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- 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 "<masked>"
- }
- // 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
- }
|