package fixture import ( "database/sql" "time" ) // 本文件只放**数据形状**:legacy 读出来的行 + 目标态要写的行。 // 转换规则一律放在 mapping.go(纯函数,可单测),落库一律放 load.go。 // ---------- legacy 侧(读出来即定型,金额统一用十进制字符串) ---------- // LegacyExpense 对应 legacy `t_user_expense`(按 userid 1 行)。 type LegacyExpense struct { UserID int64 Balance sql.NullString // float(10,3) Spend sql.NullString // float(10,3) TotalIncome sql.NullString // float(10,3) TotalExpenditure sql.NullString // float(10,3) TotalRecommendedAmount sql.NullString // decimal(10,3) SpecialExpenses sql.NullString // decimal(10,3) } // LegacyExpenseSchool 对应 legacy `t_user_expense_school`(userid + schoolid + campusid)。 type LegacyExpenseSchool struct { ID int64 UserID int64 SchoolID int64 // → institution_id CampusID int64 // → campus_id(legacy 可空,读时 IFNULL 0) Balance sql.NullString Spend sql.NullString TotalIncome sql.NullString TotalExpenditure sql.NullString TotalRecommendedAmount sql.NullString SpecialExpenses sql.NullString } // LegacyExpenseLog 对应 legacy `t_user_expense_log`(int 自增 id)。 type LegacyExpenseLog struct { ID int64 UserID int64 Type int32 LogID sql.NullString // varchar(32) 旧 UUID → target log_id varchar(64) Money sql.NullString // decimal(10,3) CreateUserID sql.NullInt64 // int:0 或系统 → target NULL CreateTime sql.NullTime // datetime(0) → target datetime(3)(补 .000) Remark sql.NullString // text → varchar(1000) } // ---------- 目标侧(金额一律整数分,写入前才格式化成十进制字符串) ---------- // TargetExpense 对应目标 `t_user_expense`(主键 user_id,无自增 id)。 type TargetExpense struct { UserID string BalanceCents int64 SpendCents int64 TotalIncomeCents int64 TotalExpenditureCents int64 TotalRecommendedCents int64 SpecialExpensesCents int64 } // TargetExpenseSchool 对应目标 `t_user_expense_school`。 // // id 策略:**不保留** legacy id——该表在目标态的对齐键是复合业务键 // (user_id, institution_id, campus_id),与流水表的 D1 口径不同(见 README §3)。 type TargetExpenseSchool struct { UserID string InstitutionID int64 CampusID int64 BalanceCents int64 SpendCents int64 TotalIncomeCents int64 TotalExpenditureCents int64 TotalRecommendedCents int64 SpecialExpensesCents int64 } // TargetExpenseLog 对应目标 `t_user_expense_log`(D1:显式插 legacy id)。 type TargetExpenseLog struct { ID int64 // = legacy id(D1) UserID string Type int32 LogID sql.NullString // legacy logid RequestID sql.NullString // legacy 无此列 → 恒 NULL MoneyCents int64 BalanceAfter sql.NullString // legacy 无此列 → 恒 NULL(decimal(12,2) 用字符串承载) CreateUserID sql.NullString // legacy int create_userid → ULID;0/系统 → NULL CreateTime time.Time // 秒级(DATETIME(3) 落 .000) Remark sql.NullString } // MappingEntry 是一条 t_legacy_mapping 记录(fixture 自己插)。 type MappingEntry struct { CanonicalUserID string LegacyAcademicUserID int64 InstitutionID int64 CampusID int64 Role string MatchSource string MatchConfidence int } // ---------- 报告用结构 ---------- // ColumnCheck 是核对表的一行:一列的 legacy 值 / target 值 / 是否相等。 type ColumnCheck struct { Table string RowKey string Column string Legacy string Target string Equal bool Note string } // VerifyReport 是一轮核对的完整结果。 type VerifyReport struct { Checks []ColumnCheck Assertions []Assertion LoadedRows map[string]int TargetRows map[string]int FloatTrap FloatTrapDemo AutoIncTest AutoIncrementDemo } // Assertion 是一条语义断言(如「balance_after 必须为 NULL」)。 type Assertion struct { Name string Pass bool Detail string } // FloatTrapDemo 记录「float 直接相加」与「逐行取整到分再相加」的差异证据。 type FloatTrapDemo struct { LegacyValues []string // 逐条 legacy money PerRowRounded []string // 逐条 ROUND(x,2) SumOfRoundedCents int64 // Σ ROUND(x,2) SumRoundedStr string // 上面那个值的十进制表示 FloatSumRaw float64 // 直接用 float64 相加 RoundOfSumStr string // ROUND(Σx, 2)(先加后取整) DeltaVsPerRow int64 // ROUND(Σ) 与 ΣROUND 的分差 FloatAccumErr float64 // float64 累加误差(FloatSumRaw - 精确和) ExactSumThousandths int64 // 精确和(千分位整数,用于展示 float 累加误差量级) } // AutoIncrementDemo 记录「显式插 id 后 AUTO_INCREMENT 不倒退」的证据。 // // 两段证据: // // A. 真实表 t_user_expense_log:显式插了 legacy id 之后,**不带 id** 插入的新行 id // 必须等于即时读到的 AUTO_INCREMENT,且 >= max(id)+1 —— 只要它不是 1 附近的小数, // 就说明计数器没有被显式插 id 弄回起点。 // B. 临时表镜像(CREATE TEMPORARY TABLE ... LIKE,会话结束自动消失): // 显式插 id=100,再不带 id 插,必须得到 101,把「max+1」这条语义**可重复地**演示出来。 // // 为什么要 B:MySQL 的 AUTO_INCREMENT 计数器**DELETE 之后不回退**, // 所以 fixture 反复跑时真实表的计数器会单调增长,真实表上「恰好 max+1」只有首次能观测到; // 临时表镜像每次都从空表起步,因此每次都能给出一模一样的 max+1 结论。 type AutoIncrementDemo struct { MaxIDBefore int64 AutoIncrementVal int64 ProbeID int64 ExpectedProbeID int64 EqualsMaxPlusOne bool Pass bool MirrorExplicitID int64 MirrorProbeID int64 MirrorExpected int64 MirrorPass bool } // FailCount 统计核对表中不相等的列数。 func (r *VerifyReport) FailCount() int { n := 0 for _, c := range r.Checks { if !c.Equal { n++ } } for _, a := range r.Assertions { if !a.Pass { n++ } } return n } // OK 表示整轮核对通过。 func (r *VerifyReport) OK() bool { return r.FailCount() == 0 }