| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117 |
- 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
- }
|