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