package fixture import ( "fmt" "strconv" "strings" ) // 本文件是金额换算的唯一通道。 // // 为什么不用 float: // legacy 的 t_user_expense.balance 是 `float(10,3)`(二进制浮点), // legacy 的 t_user_expense_log.money 是 `decimal(10,3)`(精确小数), // 目标态两列都是 `decimal(12,2)`。 // // 若用 float64 读 —— 0.788 读进来是 0.78800000000000003,乘 100 再 Round 会 // 在恰好 x.xx5 的边界上随机偏向(12.015*100 = 1201.4999999999998 → 落成 12.01, // 而 MySQL 的 DECIMAL ROUND 给 12.02)。**双跑对账不能用「大概相等」蒙过去**, // 所以这里全部走「十进制字符串 → 整数分」的精确整数运算: // - 读取侧一律 `CAST(col AS DECIMAL(20,3))`,让 MySQL 先把浮点按 3 位小数的 // 十进制表示交给我们(该列定义本来就是 3 位),再用整数运算降到 2 位。 // - 写入侧一律把「整数分」格式化成 "-2160.49" 这样的十进制字符串交给驱动, // 让 DECIMAL 列精确接收,全程不经过 float64。 // DecimalToCents 把十进制字符串精确换算成「分」,小数第 3 位四舍五入(half away from zero)。 // // 入参形如 "0.788" / "-2160.489" / "5000.000" / "0" / "12"。 // 约束:最多 3 位小数(legacy 精度就是 3 位);超过 3 位视为调用方错误, // 直接报错而不是静默截断——静默截断正是「看起来对、其实差一分」的来源。 func DecimalToCents(s string) (int64, error) { raw := s s = strings.TrimSpace(s) if s == "" { return 0, fmt.Errorf("DecimalToCents: 空字符串(raw=%q)", raw) } neg := false switch s[0] { case '-': neg = true s = s[1:] case '+': s = s[1:] } if s == "" { return 0, fmt.Errorf("DecimalToCents: 只有符号(raw=%q)", raw) } intPart := s fracPart := "" if i := strings.IndexByte(s, '.'); i >= 0 { intPart, fracPart = s[:i], s[i+1:] } if len(fracPart) > 3 { return 0, fmt.Errorf("DecimalToCents: 小数位超过 3 位(raw=%q),拒绝静默截断", raw) } // 右补 0 到 3 位,便于统一按「分 + 千分位」处理。 for len(fracPart) < 3 { fracPart += "0" } var intVal int64 if intPart != "" { v, err := strconv.ParseInt(intPart, 10, 64) if err != nil { return 0, fmt.Errorf("DecimalToCents: 整数部分非法(raw=%q):%w", raw, err) } intVal = v } frac3, err := strconv.Atoi(fracPart) if err != nil { return 0, fmt.Errorf("DecimalToCents: 小数部分非法(raw=%q):%w", raw, err) } cents := intVal*100 + int64(frac3/10) // 第 3 位小数 >= 5 进位(对绝对值进位,负数同向,即 half away from zero, // 与 MySQL DECIMAL 的 ROUND 行为一致)。 if frac3%10 >= 5 { cents++ } if neg { cents = -cents } return cents, nil } // CentsToDecimal 把「分」格式化成 2 位小数的十进制字符串(写入 DECIMAL(12,2) 用)。 func CentsToDecimal(cents int64) string { neg := cents < 0 if neg { cents = -cents } sign := "" if neg { sign = "-" } return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100) } // MustCents 用于测试与 fixture 内部常量,出错即 panic。 func MustCents(s string) int64 { c, err := DecimalToCents(s) if err != nil { panic(err) } return c } // RoundDecimalString 把十进制字符串四舍五入到 2 位并返回 2 位小数字符串。 // 等价于 SQL 的 `ROUND(x, 2)`,用于核对表里「legacy 值 → 迁移后期望值」这一列。 func RoundDecimalString(s string) (string, error) { c, err := DecimalToCents(s) if err != nil { return "", err } return CentsToDecimal(c), nil }