guard.go 6.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. package dualrun
  2. import (
  3. "database/sql"
  4. "fmt"
  5. "os"
  6. "strconv"
  7. "strings"
  8. "time"
  9. "gorm.io/driver/mysql"
  10. "gorm.io/gorm"
  11. "gorm.io/gorm/logger"
  12. )
  13. // 数据源守卫的允许清单。
  14. //
  15. // 这里是**允许列表**(allow-list),不是禁止列表:任何不在表里的 host 一律拒绝,
  16. // 因此不需要、也不允许把某个具体 IP 写进代码 —— 真实环境里那个地址属于生产级共享实例
  17. // (具备 SUPER / DROP / GRANT OPTION),一旦被误连,后果不可逆。
  18. var (
  19. // allowedHosts 允许连接的 host(只允许本机)。
  20. allowedHosts = map[string]bool{
  21. "127.0.0.1": true,
  22. "localhost": true,
  23. "::1": true,
  24. }
  25. // allowedDatabases 允许操作的库(legacy 只读基线 / 两个可写副本 / target dev)。
  26. allowedDatabases = map[string]bool{
  27. "finance_bk": true,
  28. "legacy_finance": true,
  29. "legacy_oracle": true,
  30. "yuansiwei_2026": true,
  31. }
  32. // writableDatabases 允许写入的库(阶段 1 只在 legacy_oracle 上写)。
  33. writableDatabases = map[string]bool{
  34. "legacy_oracle": true,
  35. "legacy_finance": true,
  36. }
  37. )
  38. // Config 是 runner 的连接与运行参数,全部来自环境变量,不落盘。
  39. type Config struct {
  40. Host string
  41. Port int
  42. Name string
  43. User string
  44. Pass string
  45. LegacyRepo string
  46. LegacyDBName string // PHP oracle 侧连接的库(阶段 1 固定 legacy_oracle)
  47. PHPBinary string
  48. }
  49. // LoadConfigFromEnv 读取 AC15_* 环境变量。
  50. //
  51. // 口令只经环境变量传入:不落盘、不进代码常量、不进报告。
  52. func LoadConfigFromEnv() Config {
  53. port := 3306
  54. if v := os.Getenv("AC15_DB_PORT"); v != "" {
  55. if n, err := strconv.Atoi(v); err == nil && n > 0 {
  56. port = n
  57. }
  58. }
  59. name := os.Getenv("AC15_DB_NAME")
  60. if name == "" {
  61. name = "legacy_oracle"
  62. }
  63. user := os.Getenv("AC15_DB_USER")
  64. if user == "" {
  65. user = "dev_legacy"
  66. }
  67. repo := os.Getenv("AC15_LEGACY_REPO")
  68. if repo == "" {
  69. repo = "E:/study/php/ysw-finance/yuansiwei-finance"
  70. }
  71. php := os.Getenv("AC15_PHP_BIN")
  72. if php == "" {
  73. php = "php"
  74. }
  75. host := os.Getenv("AC15_DB_HOST")
  76. if host == "" {
  77. host = "127.0.0.1"
  78. }
  79. return Config{
  80. Host: host, Port: port, Name: name, User: user,
  81. Pass: os.Getenv("AC15_DB_PASS"),
  82. LegacyRepo: repo, LegacyDBName: name, PHPBinary: php,
  83. }
  84. }
  85. // maskHost 把 host 遮掉后两段,保证日志与报告里不出现完整地址。
  86. func maskHost(host string) string {
  87. parts := strings.Split(host, ".")
  88. if len(parts) == 4 {
  89. return parts[0] + "." + parts[1] + ".***.**"
  90. }
  91. return "<masked>"
  92. }
  93. // Guard 执行**双条件数据源守卫**:host 必须在允许表内。
  94. //
  95. // 违反时 **panic**(按验收要求"不满足 → panic 退出"),不返回 error,
  96. // 因为这类错误一旦被上层忽略,就会以"连到错误实例后的正常结果"的形式污染整轮结论。
  97. func (c Config) Guard() {
  98. if !allowedHosts[c.Host] {
  99. panic(fmt.Sprintf("dualrun: 数据源守卫失败 —— host %q 不在允许清单内(只允许本机)", maskHost(c.Host)))
  100. }
  101. if !allowedDatabases[c.Name] {
  102. panic(fmt.Sprintf("dualrun: 数据源守卫失败 —— database %q 不在允许清单内", c.Name))
  103. }
  104. if !writableDatabases[c.Name] {
  105. panic(fmt.Sprintf("dualrun: 阶段 1 只写 legacy_oracle / legacy_finance,当前是 %q", c.Name))
  106. }
  107. if c.Pass == "" {
  108. panic("dualrun: AC15_DB_PASS 未设置(口令只经环境变量传入)")
  109. }
  110. }
  111. // DSN 拼装 gorm/go-sql-driver 使用的 DSN。
  112. func (c Config) DSN() string {
  113. return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
  114. c.User, c.Pass, c.Host, c.Port, c.Name)
  115. }
  116. // Open 打开一个 *sql.DB(每个 case 应各自 Open/Close,见 README §4)。
  117. func (c Config) Open() (*sql.DB, error) {
  118. gdb, err := gorm.Open(mysql.Open(c.DSN()), &gorm.Config{
  119. Logger: logger.Default.LogMode(logger.Silent),
  120. DisableAutomaticPing: false,
  121. })
  122. if err != nil {
  123. return nil, err
  124. }
  125. sqlDB, err := gdb.DB()
  126. if err != nil {
  127. return nil, err
  128. }
  129. sqlDB.SetMaxOpenConns(1)
  130. sqlDB.SetMaxIdleConns(1)
  131. return sqlDB, nil
  132. }
  133. // AssertDatabase 是双条件守卫的**第二个条件**:连上之后核对 SELECT DATABASE()。
  134. //
  135. // 只在连接串里写库名是不够的(DSN 可能被改、连接可能落到别的默认库),
  136. // 必须让服务器自己报出当前库再比对一次。
  137. func AssertDatabase(db *sql.DB, want string) error {
  138. var got sql.NullString
  139. if err := db.QueryRow("SELECT DATABASE()").Scan(&got); err != nil {
  140. return err
  141. }
  142. if !allowedDatabases[got.String] {
  143. return fmt.Errorf("当前库 %q 不在允许清单内", got.String)
  144. }
  145. if got.String != want {
  146. return fmt.Errorf("当前库 %q 与期望 %q 不一致", got.String, want)
  147. }
  148. return nil
  149. }
  150. // AssertNoSysdate 是**启动静态断言**:目标库 31 个 routine 定义里 `sysdate(` 出现数必须为 0。
  151. //
  152. // 依据:`SET SESSION timestamp` 冻结 NOW() 但不冻结 SYSDATE(),一旦有 SP 用 SYSDATE(),
  153. // 冻结时钟就会失效而结果看起来"正常"。9 组实测见 P0 结果文档 §6。
  154. func AssertNoSysdate(db *sql.DB, schema string) (int, error) {
  155. var n int
  156. err := db.QueryRow(
  157. `SELECT COUNT(*) FROM information_schema.routines
  158. WHERE routine_schema = ? AND LOWER(routine_definition) LIKE '%sysdate(%'`, schema).Scan(&n)
  159. if err != nil {
  160. return 0, err
  161. }
  162. if n != 0 {
  163. return n, fmt.Errorf("库 %s 有 %d 个 routine 使用 SYSDATE(),冻结时钟不可靠,拒绝运行", schema, n)
  164. }
  165. return n, nil
  166. }
  167. // TruncateToSecond 是冻结口径:两侧都用整秒,避免 legacy 给 .123456 而 target 给 .000 的伪差异。
  168. func TruncateToSecond(t time.Time) time.Time {
  169. return t.Truncate(time.Second)
  170. }
  171. // SetSessionClock 在给定连接上冻结时钟并统一时区。
  172. //
  173. // 只影响本会话(timestamp 无全局作用域,SET GLOBAL 会报 1228),因此必须"每 case 新连接"。
  174. func SetSessionClock(db *sql.DB, frozenAt time.Time) error {
  175. epoch := TruncateToSecond(frozenAt).Unix()
  176. if _, err := db.Exec("SET SESSION time_zone = '+08:00'"); err != nil {
  177. return err
  178. }
  179. if _, err := db.Exec(fmt.Sprintf("SET SESSION timestamp = %d", epoch)); err != nil {
  180. return err
  181. }
  182. return nil
  183. }