config.go 9.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298
  1. // Package fixture 实现 AC-15 阶段 2 的**前置件**:把 legacy 资金域 4 张表的一条链路
  2. // 搬到目标库 yuansiwei_2026,并逐列核对。
  3. //
  4. // 定位与边界(务必先读):
  5. // - 本包只做**资金域 4 表**:t_user_expense / t_user_expense_school / t_user_expense_log,
  6. // 以及解析用户所需的 t_user + t_legacy_mapping。其余 7 张资金/活动表与 8 个写 SP 的
  7. // 双跑比对**不在本包范围**(下一批)。
  8. // - 本包**不改** internal/academic/{service,repository,handler}:映射规则全部在此独立实现,
  9. // 避免为了「能跑通」去污染生产路径。
  10. // - 本包唯一的写目标是 yuansiwei_2026(fixture 数据,用完 DELETE)与 legacy 侧只读连接。
  11. // finance_bk **只 SELECT**。
  12. //
  13. // 三条落地口径(来自已拍板结论,不再讨论):
  14. // 1. 用户 id 走 t_legacy_mapping:legacy int user_id → canonical_user_id(VARCHAR(26) ULID)。
  15. // 2. 主键:t_user_expense 的键是 user_id(无自增 id);t_user_expense_log 用 D1
  16. // ——**显式插入 legacy id**,双跑按 id 直接对齐。不试图从 legacy id 反算 target id。
  17. // 3. 金额 DECIMAL(12,2):**逐行 ROUND(x,2)**,不是 ROUND(SUM,2);且不用 float 运算,
  18. // 用「十进制字符串 → 整数分」精确换算(见 decimal.go)。
  19. package fixture
  20. import (
  21. "database/sql"
  22. "encoding/json"
  23. "fmt"
  24. "math"
  25. "os"
  26. "path/filepath"
  27. "regexp"
  28. "strconv"
  29. "strings"
  30. "time"
  31. "gorm.io/driver/mysql"
  32. "gorm.io/gorm"
  33. "gorm.io/gorm/logger"
  34. )
  35. // 允许连接的 host(allow-list,非 deny-list):只允许本机。
  36. var allowedHosts = map[string]bool{
  37. "127.0.0.1": true,
  38. "localhost": true,
  39. "::1": true,
  40. }
  41. // 允许连接的库。finance_bk 只读;yuansiwei_2026 是本包唯一写目标。
  42. var (
  43. allowedDatabases = map[string]bool{
  44. "finance_bk": true,
  45. "legacy_oracle": true,
  46. "yuansiwei_2026": true,
  47. }
  48. // writableDatabases 明确「哪些库允许写」:目标库只白名单 yuansiwei_2026。
  49. writableDatabases = map[string]bool{
  50. "yuansiwei_2026": true,
  51. }
  52. // readOnlyDatabases 明确「哪些库绝不允许写」——写语句一律在 guard 层拒绝。
  53. readOnlyDatabases = map[string]bool{
  54. "finance_bk": true,
  55. "legacy_oracle": true,
  56. // e2e_test 有独立的建表逻辑,本包不碰。
  57. "e2e_test": true,
  58. }
  59. )
  60. // DefaultLegacyUserID 是 fixture 选中的 legacy 用户(选择依据见 README/报告):
  61. // t_user_expense 1 行 + t_user_expense_school 1 行 + t_user_expense_log 6 条,
  62. // 且 balance=0.788 / spend=4999.212 带 3 位小数——覆盖「逐行 ROUND 到分」的取值边界。
  63. const DefaultLegacyUserID int64 = 53691
  64. // Config 是 fixture 的两条连接参数。
  65. //
  66. // 口令**只经环境变量或已有配置文件读取,不打印、不落盘、不进报告**。
  67. type Config struct {
  68. Host string
  69. Port int
  70. TargetDB string
  71. TargetUser string
  72. TargetPass string
  73. LegacyDB string
  74. LegacyUser string
  75. LegacyPass string
  76. }
  77. // dsnFile 是仓库里已存在的 dev 配置(口令已在磁盘上,本包只读取、不新增副本)。
  78. type dsnFile struct {
  79. DB struct {
  80. DSN string `json:"dsn"`
  81. } `json:"db"`
  82. }
  83. var dsnPattern = regexp.MustCompile(`^([^:]+):([^@]+)@tcp\(([^:]+):(\d+)\)/([^?]+)`)
  84. // distMasked 返回不含口令的 DSN 形态,用于日志与报告。
  85. func (c Config) TargetMasked() string {
  86. return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.TargetUser, c.Host, c.Port, c.TargetDB)
  87. }
  88. // LegacyMasked 返回 legacy 侧不含口令的 DSN 形态。
  89. func (c Config) LegacyMasked() string {
  90. return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.LegacyUser, c.Host, c.Port, c.LegacyDB)
  91. }
  92. // LoadConfig 读取连接参数。
  93. //
  94. // 优先级:环境变量(AC15_*)> 仓库内 config/dev.json 的 db.dsn。
  95. // 之所以保留文件回退:dev 环境里 AC15_DB_PASS 未必已导出,而 dev.json 是仓库既有的
  96. // 配置载体(不新增口令副本、不打印口令)。生产/CI 走环境变量即可。
  97. func LoadConfig(repoRoot string) (Config, error) {
  98. cfg := Config{
  99. Host: envOr("AC15_DB_HOST", "127.0.0.1"),
  100. Port: envIntOr("AC15_DB_PORT", 3306),
  101. TargetDB: envOr("AC15_TARGET_DB", "yuansiwei_2026"),
  102. TargetUser: envOr("AC15_DB_USER", "dev"),
  103. TargetPass: os.Getenv("AC15_DB_PASS"),
  104. LegacyDB: envOr("AC15_LEGACY_DB", "finance_bk"),
  105. LegacyUser: envOr("AC15_LEGACY_USER", ""),
  106. LegacyPass: os.Getenv("AC15_LEGACY_PASS"),
  107. }
  108. // 目标库口令缺失时回退到仓库既有 dev.json(只读其 db.dsn,不写回)。
  109. if cfg.TargetPass == "" || cfg.TargetUser == "dev" {
  110. path := filepath.Join(repoRoot, "config", "dev.json")
  111. if raw, err := os.ReadFile(path); err == nil {
  112. var f dsnFile
  113. if err := json.Unmarshal(raw, &f); err == nil {
  114. if m := dsnPattern.FindStringSubmatch(f.DB.DSN); m != nil {
  115. if cfg.TargetPass == "" {
  116. cfg.TargetPass = m[2]
  117. }
  118. if os.Getenv("AC15_DB_USER") == "" {
  119. cfg.TargetUser = m[1]
  120. }
  121. }
  122. }
  123. }
  124. }
  125. // legacy 侧默认复用同一账号(本机 dev 账号对 finance_bk 有只读权限);
  126. // 也可用 AC15_LEGACY_USER / AC15_LEGACY_PASS 单独指定。
  127. if cfg.LegacyUser == "" {
  128. cfg.LegacyUser = cfg.TargetUser
  129. }
  130. if cfg.LegacyPass == "" {
  131. cfg.LegacyPass = cfg.TargetPass
  132. }
  133. if cfg.TargetPass == "" {
  134. return Config{}, fmt.Errorf("目标库口令不可得:请设置 AC15_DB_PASS 或在 %s/config/dev.json 提供 db.dsn", repoRoot)
  135. }
  136. return cfg, nil
  137. }
  138. func envOr(key, def string) string {
  139. if v := os.Getenv(key); v != "" {
  140. return v
  141. }
  142. return def
  143. }
  144. func envIntOr(key string, def int) int {
  145. if v := os.Getenv(key); v != "" {
  146. if n, err := strconv.Atoi(v); err == nil && n > 0 {
  147. return n
  148. }
  149. }
  150. return def
  151. }
  152. // Guard 校验 host 与库名白名单,并对**写目标**做更严的判定。
  153. //
  154. // 返回 error 而非 panic:本包由 CLI 串成多步,单步失败要能定位到是「守卫」还是「数据」,
  155. // 因此这里不做 fail-fast panic(与 dualrun 包阶段 1 的 panic 语义不同,后者是单次运行)。
  156. func (c Config) Guard() error {
  157. if !allowedHosts[c.Host] {
  158. return fmt.Errorf("数据源守卫失败:host %q 不在允许清单内(只允许本机)", maskHost(c.Host))
  159. }
  160. if !allowedDatabases[c.TargetDB] {
  161. return fmt.Errorf("数据源守卫失败:target 库 %q 不在允许清单内", c.TargetDB)
  162. }
  163. if !writableDatabases[c.TargetDB] {
  164. return fmt.Errorf("数据源守卫失败:%q 不是本包允许写的库(只允许 yuansiwei_2026)", c.TargetDB)
  165. }
  166. if !allowedDatabases[c.LegacyDB] {
  167. return fmt.Errorf("数据源守卫失败:legacy 库 %q 不在允许清单内", c.LegacyDB)
  168. }
  169. if readOnlyDatabases[c.LegacyDB] == false {
  170. // legacy_oracle 虽在 allowedDatabases 内,但本包对 legacy 侧一律只读;
  171. // 这里只做提示性约束:真正保证由 extract.go 只发 SELECT 实现。
  172. _ = c.LegacyDB
  173. }
  174. return nil
  175. }
  176. // maskHost 把 host 后两段遮掉,保证日志/报告不出现完整地址。
  177. func maskHost(host string) string {
  178. parts := strings.Split(host, ".")
  179. if len(parts) == 4 {
  180. return parts[0] + "." + parts[1] + ".***.**"
  181. }
  182. return "<masked>"
  183. }
  184. func (c Config) targetDSN() string {
  185. return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
  186. c.TargetUser, c.TargetPass, c.Host, c.Port, c.TargetDB)
  187. }
  188. func (c Config) legacyDSN() string {
  189. return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s",
  190. c.LegacyUser, c.LegacyPass, c.Host, c.Port, c.LegacyDB)
  191. }
  192. // openGorm 用 gorm 的 mysql driver 打开连接(复用仓库既有依赖,不新增 driver)。
  193. func openGorm(dsn string) (*sql.DB, error) {
  194. gdb, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
  195. Logger: logger.Default.LogMode(logger.Silent),
  196. DisableAutomaticPing: false,
  197. })
  198. if err != nil {
  199. return nil, err
  200. }
  201. sqlDB, err := gdb.DB()
  202. if err != nil {
  203. return nil, err
  204. }
  205. sqlDB.SetMaxOpenConns(2)
  206. sqlDB.SetMaxIdleConns(2)
  207. sqlDB.SetConnMaxLifetime(5 * time.Minute)
  208. return sqlDB, nil
  209. }
  210. // OpenTarget 打开目标库连接,并二次核对 SELECT DATABASE()。
  211. func (c Config) OpenTarget() (*sql.DB, error) {
  212. db, err := openGorm(c.targetDSN())
  213. if err != nil {
  214. return nil, fmt.Errorf("连接目标库失败:%w", err)
  215. }
  216. if err := assertDatabase(db, c.TargetDB); err != nil {
  217. _ = db.Close()
  218. return nil, err
  219. }
  220. return db, nil
  221. }
  222. // OpenLegacy 打开 legacy **只读**连接。
  223. //
  224. // 只读不是口头纪律:连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,
  225. // 服务端会把本会话的写语句直接拒绝(MySQL 5.6+ 支持 session 级 read only)。
  226. func (c Config) OpenLegacy() (*sql.DB, error) {
  227. db, err := openGorm(c.legacyDSN())
  228. if err != nil {
  229. return nil, fmt.Errorf("连接 legacy 库失败:%w", err)
  230. }
  231. if err := assertDatabase(db, c.LegacyDB); err != nil {
  232. _ = db.Close()
  233. return nil, err
  234. }
  235. if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil {
  236. _ = db.Close()
  237. return nil, fmt.Errorf("legacy 会话只读设置失败(拒绝在可写会话上跑比对):%w", err)
  238. }
  239. return db, nil
  240. }
  241. // assertDatabase 让服务器自己报出当前库再比对(只写 DSN 库名不够)。
  242. func assertDatabase(db *sql.DB, want string) error {
  243. var got sql.NullString
  244. if err := db.QueryRow("SELECT DATABASE()").Scan(&got); err != nil {
  245. return err
  246. }
  247. if !got.Valid || got.String != want {
  248. return fmt.Errorf("当前库 %q 与期望 %q 不一致", got.String, want)
  249. }
  250. return nil
  251. }
  252. // moneyToleranceCents 是金额比对的硬容差:0 分——逐分必须相等。
  253. const moneyToleranceCents int64 = 0
  254. // absInt64 取绝对值(仅用于报告展示差值)。
  255. func absInt64(v int64) int64 {
  256. if v < 0 {
  257. return -v
  258. }
  259. return v
  260. }
  261. // float64Of 把十进制字符串解析成 float64——**仅用于「float 陷阱」演示**,
  262. // 生产映射路径禁止用 float 做金额运算(见 decimal.go 的整数分换算)。
  263. func float64Of(s string) float64 {
  264. v, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
  265. if err != nil {
  266. return math.NaN()
  267. }
  268. return v
  269. }