// Package fixture 实现 AC-15 阶段 2 的**前置件**:把 legacy 资金域 4 张表的一条链路 // 搬到目标库 yuansiwei_2026,并逐列核对。 // // 定位与边界(务必先读): // - 本包只做**资金域 4 表**:t_user_expense / t_user_expense_school / t_user_expense_log, // 以及解析用户所需的 t_user + t_legacy_mapping。其余 7 张资金/活动表与 8 个写 SP 的 // 双跑比对**不在本包范围**(下一批)。 // - 本包**不改** internal/academic/{service,repository,handler}:映射规则全部在此独立实现, // 避免为了「能跑通」去污染生产路径。 // - 本包唯一的写目标是 yuansiwei_2026(fixture 数据,用完 DELETE)与 legacy 侧只读连接。 // finance_bk **只 SELECT**。 // // 三条落地口径(来自已拍板结论,不再讨论): // 1. 用户 id 走 t_legacy_mapping:legacy int user_id → canonical_user_id(VARCHAR(26) ULID)。 // 2. 主键:t_user_expense 的键是 user_id(无自增 id);t_user_expense_log 用 D1 // ——**显式插入 legacy id**,双跑按 id 直接对齐。不试图从 legacy id 反算 target id。 // 3. 金额 DECIMAL(12,2):**逐行 ROUND(x,2)**,不是 ROUND(SUM,2);且不用 float 运算, // 用「十进制字符串 → 整数分」精确换算(见 decimal.go)。 package fixture import ( "database/sql" "encoding/json" "fmt" "math" "os" "path/filepath" "regexp" "sort" "strconv" "strings" "time" "gorm.io/driver/mysql" "gorm.io/gorm" "gorm.io/gorm/logger" ) // 允许连接的 host(allow-list,非 deny-list):只允许本机。 var allowedHosts = map[string]bool{ "127.0.0.1": true, "localhost": true, "::1": true, } // 允许连接的库。finance_bk 只读;yuansiwei_2026 是本包唯一写目标。 var ( allowedDatabases = map[string]bool{ "finance_bk": true, "legacy_oracle": true, "yuansiwei_2026": true, // org 域(机构/校区)迁移源,**只读**。见 readOnlyDatabases。 "yuansiwei_i": true, "yuansiwei": true, } // writableDatabases 明确「哪些库允许写」:目标库只白名单 yuansiwei_2026。 writableDatabases = map[string]bool{ "yuansiwei_2026": true, } // readOnlyDatabases 明确「哪些库绝不允许写」——写语句一律在 guard 层拒绝。 readOnlyDatabases = map[string]bool{ "finance_bk": true, "legacy_oracle": true, "yuansiwei_i": true, "yuansiwei": true, // e2e_test 有独立的建表逻辑,本包不碰。 "e2e_test": true, } ) // DefaultLegacyUserID 是 fixture 选中的 legacy 用户(选择依据见 README/报告): // t_user_expense 1 行 + t_user_expense_school 1 行 + t_user_expense_log 6 条, // 且 balance=0.788 / spend=4999.212 带 3 位小数——覆盖「逐行 ROUND 到分」的取值边界。 const DefaultLegacyUserID int64 = 53691 // Config 是 fixture 的两条连接参数。 // // 口令**只经环境变量或已有配置文件读取,不打印、不落盘、不进报告**。 type Config struct { Host string Port int TargetDB string TargetUser string TargetPass string LegacyDB string LegacyUser string LegacyPass string // OrgSourceDB 是 org 域(t_school / t_school_campus)的迁移源库,只读。 OrgSourceDB string OrgSourceUser string OrgSourcePass string } // dsnFile 是仓库里已存在的 dev 配置(口令已在磁盘上,本包只读取、不新增副本)。 type dsnFile struct { DB struct { DSN string `json:"dsn"` } `json:"db"` } var dsnPattern = regexp.MustCompile(`^([^:]+):([^@]+)@tcp\(([^:]+):(\d+)\)/([^?]+)`) // distMasked 返回不含口令的 DSN 形态,用于日志与报告。 func (c Config) TargetMasked() string { return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.TargetUser, c.Host, c.Port, c.TargetDB) } // OrgMasked 返回 org 源库不含口令的 DSN 形态。 func (c Config) OrgMasked() string { return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.OrgSourceUser, c.Host, c.Port, c.OrgSourceDB) } // LegacyMasked 返回 legacy 侧不含口令的 DSN 形态。 func (c Config) LegacyMasked() string { return fmt.Sprintf("%s@tcp(%s:%d)/%s", c.LegacyUser, c.Host, c.Port, c.LegacyDB) } // LoadConfig 读取连接参数。 // // 优先级:环境变量(AC15_*)> 仓库内 config/dev.json 的 db.dsn。 // 之所以保留文件回退:dev 环境里 AC15_DB_PASS 未必已导出,而 dev.json 是仓库既有的 // 配置载体(不新增口令副本、不打印口令)。生产/CI 走环境变量即可。 func LoadConfig(repoRoot string) (Config, error) { cfg := Config{ Host: envOr("AC15_DB_HOST", "127.0.0.1"), Port: envIntOr("AC15_DB_PORT", 3306), TargetDB: envOr("AC15_TARGET_DB", "yuansiwei_2026"), TargetUser: envOr("AC15_DB_USER", "dev"), TargetPass: os.Getenv("AC15_DB_PASS"), LegacyDB: envOr("AC15_LEGACY_DB", "finance_bk"), LegacyUser: envOr("AC15_LEGACY_USER", ""), LegacyPass: os.Getenv("AC15_LEGACY_PASS"), OrgSourceDB: envOr("AC15_ORG_DB", "yuansiwei_i"), OrgSourceUser: os.Getenv("AC15_ORG_USER"), OrgSourcePass: os.Getenv("AC15_ORG_PASS"), } // 目标库口令缺失时回退到仓库既有 dev.json(只读其 db.dsn,不写回)。 if cfg.TargetPass == "" || cfg.TargetUser == "dev" { path := filepath.Join(repoRoot, "config", "dev.json") if raw, err := os.ReadFile(path); err == nil { var f dsnFile if err := json.Unmarshal(raw, &f); err == nil { if m := dsnPattern.FindStringSubmatch(f.DB.DSN); m != nil { if cfg.TargetPass == "" { cfg.TargetPass = m[2] } if os.Getenv("AC15_DB_USER") == "" { cfg.TargetUser = m[1] } } } } } // legacy 侧默认复用同一账号(本机 dev 账号对 finance_bk 有只读权限); // 也可用 AC15_LEGACY_USER / AC15_LEGACY_PASS 单独指定。 if cfg.LegacyUser == "" { cfg.LegacyUser = cfg.TargetUser } if cfg.LegacyPass == "" { cfg.LegacyPass = cfg.TargetPass } if cfg.OrgSourceUser == "" { cfg.OrgSourceUser = cfg.TargetUser } if cfg.OrgSourcePass == "" { cfg.OrgSourcePass = cfg.TargetPass } if cfg.TargetPass == "" { return Config{}, fmt.Errorf("目标库口令不可得:请设置 AC15_DB_PASS 或在 %s/config/dev.json 提供 db.dsn", repoRoot) } return cfg, nil } func envOr(key, def string) string { if v := os.Getenv(key); v != "" { return v } return def } func envIntOr(key string, def int) int { if v := os.Getenv(key); v != "" { if n, err := strconv.Atoi(v); err == nil && n > 0 { return n } } return def } // Guard 校验 host 与库名白名单,并对**写目标**做更严的判定。 // // 返回 error 而非 panic:本包由 CLI 串成多步,单步失败要能定位到是「守卫」还是「数据」, // 因此这里不做 fail-fast panic(与 dualrun 包阶段 1 的 panic 语义不同,后者是单次运行)。 func (c Config) Guard() error { if !allowedHosts[c.Host] { return fmt.Errorf("数据源守卫失败:host %q 不在允许清单内(只允许本机)", maskHost(c.Host)) } if !allowedDatabases[c.TargetDB] { return fmt.Errorf("数据源守卫失败:target 库 %q 不在允许清单内", c.TargetDB) } if !writableDatabases[c.TargetDB] { return fmt.Errorf("数据源守卫失败:%q 不是本包允许写的库(只允许 yuansiwei_2026)", c.TargetDB) } if !allowedDatabases[c.LegacyDB] { return fmt.Errorf("数据源守卫失败:legacy 库 %q 不在允许清单内", c.LegacyDB) } if c.OrgSourceDB != "" && !allowedDatabases[c.OrgSourceDB] { return fmt.Errorf("数据源守卫失败:org 源库 %q 不在允许清单内", c.OrgSourceDB) } if readOnlyDatabases[c.LegacyDB] == false { // legacy_oracle 虽在 allowedDatabases 内,但本包对 legacy 侧一律只读; // 这里只做提示性约束:真正保证由 extract.go 只发 SELECT 实现。 _ = c.LegacyDB } return nil } // maskHost 把 host 后两段遮掉,保证日志/报告不出现完整地址。 func maskHost(host string) string { parts := strings.Split(host, ".") if len(parts) == 4 { return parts[0] + "." + parts[1] + ".***.**" } return "" } func (c Config) targetDSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s", c.TargetUser, c.TargetPass, c.Host, c.Port, c.TargetDB) } func (c Config) orgSourceDSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s", c.OrgSourceUser, c.OrgSourcePass, c.Host, c.Port, c.OrgSourceDB) } // OpenOrgSource 打开 org 域迁移源(`yuansiwei_i`)的**只读**连接。 func (c Config) OpenOrgSource() (*sql.DB, error) { db, err := openGorm(c.orgSourceDSN()) if err != nil { return nil, fmt.Errorf("连接 org 源库失败:%w", err) } if err := assertDatabase(db, c.OrgSourceDB); err != nil { _ = db.Close() return nil, err } if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil { _ = db.Close() return nil, fmt.Errorf("org 源库会话只读设置失败:%w", err) } return db, nil } func (c Config) legacyDSN() string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s", c.LegacyUser, c.LegacyPass, c.Host, c.Port, c.LegacyDB) } // dsnFor 组出「同一账号、指定库」的 DSN(只读用途;写权限由 assertDatabase + 会话只读兜底)。 func (c Config) dsnFor(dbName string) string { return fmt.Sprintf("%s:%s@tcp(%s:%d)/%s?charset=utf8mb4&parseTime=true&loc=Local&timeout=5s", c.TargetUser, c.TargetPass, c.Host, c.Port, dbName) } // OpenReadOnlyDB 打开 `allowedDatabases` 中**任一**库的只读连接。 // // 用途:核对旧库的定义(`SHOW CREATE PROCEDURE` / `information_schema` / 跨库视图), // 也就是"读定义而不是照着名字猜行为"这件事的落点。**不参与迁移**,也不产出任何写操作: // 连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`,服务端会拒绝本会话的任何写语句。 // // 入参:dbName 库名(必须在 allowedDatabases 内,否则直接拒绝)。 // 出参:*sql.DB(只读);error——库名不在白名单 / 连接失败 / 库名与 DATABASE() 不符。 func (c Config) OpenReadOnlyDB(dbName string) (*sql.DB, error) { if !allowedDatabases[dbName] { return nil, fmt.Errorf("数据源守卫失败:库 %q 不在允许清单内(只允许 %v)", dbName, sortedKeys(allowedDatabases)) } if !allowedHosts[c.Host] { return nil, fmt.Errorf("数据源守卫失败:host %q 不在允许清单内(只允许本机)", maskHost(c.Host)) } db, err := openGorm(c.dsnFor(dbName)) if err != nil { return nil, fmt.Errorf("连接库 %q 失败:%w", dbName, err) } if err := assertDatabase(db, dbName); err != nil { _ = db.Close() return nil, err } if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil { _ = db.Close() return nil, fmt.Errorf("库 %q 会话只读设置失败:%w", dbName, err) } return db, nil } // sortedKeys 返回白名单的稳定顺序(错误信息里可复现,不随 map 遍历顺序抖动)。 func sortedKeys(m map[string]bool) []string { out := make([]string, 0, len(m)) for k := range m { out = append(out, k) } sort.Strings(out) return out } // openGorm 用 gorm 的 mysql driver 打开连接(复用仓库既有依赖,不新增 driver)。 func openGorm(dsn string) (*sql.DB, error) { gdb, err := gorm.Open(mysql.Open(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(2) sqlDB.SetMaxIdleConns(2) sqlDB.SetConnMaxLifetime(5 * time.Minute) return sqlDB, nil } // OpenTarget 打开目标库连接,并二次核对 SELECT DATABASE()。 func (c Config) OpenTarget() (*sql.DB, error) { db, err := openGorm(c.targetDSN()) if err != nil { return nil, fmt.Errorf("连接目标库失败:%w", err) } if err := assertDatabase(db, c.TargetDB); err != nil { _ = db.Close() return nil, err } return db, nil } // OpenLegacy 打开 legacy **只读**连接。 // // 只读不是口头纪律:连接建立后立刻 `SET SESSION TRANSACTION READ ONLY`, // 服务端会把本会话的写语句直接拒绝(MySQL 5.6+ 支持 session 级 read only)。 func (c Config) OpenLegacy() (*sql.DB, error) { db, err := openGorm(c.legacyDSN()) if err != nil { return nil, fmt.Errorf("连接 legacy 库失败:%w", err) } if err := assertDatabase(db, c.LegacyDB); err != nil { _ = db.Close() return nil, err } if _, err := db.Exec("SET SESSION TRANSACTION READ ONLY"); err != nil { _ = db.Close() return nil, fmt.Errorf("legacy 会话只读设置失败(拒绝在可写会话上跑比对):%w", err) } return db, nil } // assertDatabase 让服务器自己报出当前库再比对(只写 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 !got.Valid || got.String != want { return fmt.Errorf("当前库 %q 与期望 %q 不一致", got.String, want) } return nil } // moneyToleranceCents 是金额比对的硬容差:0 分——逐分必须相等。 const moneyToleranceCents int64 = 0 // absInt64 取绝对值(仅用于报告展示差值)。 func absInt64(v int64) int64 { if v < 0 { return -v } return v } // float64Of 把十进制字符串解析成 float64——**仅用于「float 陷阱」演示**, // 生产映射路径禁止用 float 做金额运算(见 decimal.go 的整数分换算)。 func float64Of(s string) float64 { v, err := strconv.ParseFloat(strings.TrimSpace(s), 64) if err != nil { return math.NaN() } return v }