decimal.go 3.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
  1. package fixture
  2. import (
  3. "fmt"
  4. "strconv"
  5. "strings"
  6. )
  7. // 本文件是金额换算的唯一通道。
  8. //
  9. // 为什么不用 float:
  10. // legacy 的 t_user_expense.balance 是 `float(10,3)`(二进制浮点),
  11. // legacy 的 t_user_expense_log.money 是 `decimal(10,3)`(精确小数),
  12. // 目标态两列都是 `decimal(12,2)`。
  13. //
  14. // 若用 float64 读 —— 0.788 读进来是 0.78800000000000003,乘 100 再 Round 会
  15. // 在恰好 x.xx5 的边界上随机偏向(12.015*100 = 1201.4999999999998 → 落成 12.01,
  16. // 而 MySQL 的 DECIMAL ROUND 给 12.02)。**双跑对账不能用「大概相等」蒙过去**,
  17. // 所以这里全部走「十进制字符串 → 整数分」的精确整数运算:
  18. // - 读取侧一律 `CAST(col AS DECIMAL(20,3))`,让 MySQL 先把浮点按 3 位小数的
  19. // 十进制表示交给我们(该列定义本来就是 3 位),再用整数运算降到 2 位。
  20. // - 写入侧一律把「整数分」格式化成 "-2160.49" 这样的十进制字符串交给驱动,
  21. // 让 DECIMAL 列精确接收,全程不经过 float64。
  22. // DecimalToCents 把十进制字符串精确换算成「分」,小数第 3 位四舍五入(half away from zero)。
  23. //
  24. // 入参形如 "0.788" / "-2160.489" / "5000.000" / "0" / "12"。
  25. // 约束:最多 3 位小数(legacy 精度就是 3 位);超过 3 位视为调用方错误,
  26. // 直接报错而不是静默截断——静默截断正是「看起来对、其实差一分」的来源。
  27. func DecimalToCents(s string) (int64, error) {
  28. raw := s
  29. s = strings.TrimSpace(s)
  30. if s == "" {
  31. return 0, fmt.Errorf("DecimalToCents: 空字符串(raw=%q)", raw)
  32. }
  33. neg := false
  34. switch s[0] {
  35. case '-':
  36. neg = true
  37. s = s[1:]
  38. case '+':
  39. s = s[1:]
  40. }
  41. if s == "" {
  42. return 0, fmt.Errorf("DecimalToCents: 只有符号(raw=%q)", raw)
  43. }
  44. intPart := s
  45. fracPart := ""
  46. if i := strings.IndexByte(s, '.'); i >= 0 {
  47. intPart, fracPart = s[:i], s[i+1:]
  48. }
  49. if len(fracPart) > 3 {
  50. return 0, fmt.Errorf("DecimalToCents: 小数位超过 3 位(raw=%q),拒绝静默截断", raw)
  51. }
  52. // 右补 0 到 3 位,便于统一按「分 + 千分位」处理。
  53. for len(fracPart) < 3 {
  54. fracPart += "0"
  55. }
  56. var intVal int64
  57. if intPart != "" {
  58. v, err := strconv.ParseInt(intPart, 10, 64)
  59. if err != nil {
  60. return 0, fmt.Errorf("DecimalToCents: 整数部分非法(raw=%q):%w", raw, err)
  61. }
  62. intVal = v
  63. }
  64. frac3, err := strconv.Atoi(fracPart)
  65. if err != nil {
  66. return 0, fmt.Errorf("DecimalToCents: 小数部分非法(raw=%q):%w", raw, err)
  67. }
  68. cents := intVal*100 + int64(frac3/10)
  69. // 第 3 位小数 >= 5 进位(对绝对值进位,负数同向,即 half away from zero,
  70. // 与 MySQL DECIMAL 的 ROUND 行为一致)。
  71. if frac3%10 >= 5 {
  72. cents++
  73. }
  74. if neg {
  75. cents = -cents
  76. }
  77. return cents, nil
  78. }
  79. // CentsToDecimal 把「分」格式化成 2 位小数的十进制字符串(写入 DECIMAL(12,2) 用)。
  80. func CentsToDecimal(cents int64) string {
  81. neg := cents < 0
  82. if neg {
  83. cents = -cents
  84. }
  85. sign := ""
  86. if neg {
  87. sign = "-"
  88. }
  89. return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
  90. }
  91. // MustCents 用于测试与 fixture 内部常量,出错即 panic。
  92. func MustCents(s string) int64 {
  93. c, err := DecimalToCents(s)
  94. if err != nil {
  95. panic(err)
  96. }
  97. return c
  98. }
  99. // RoundDecimalString 把十进制字符串四舍五入到 2 位并返回 2 位小数字符串。
  100. // 等价于 SQL 的 `ROUND(x, 2)`,用于核对表里「legacy 值 → 迁移后期望值」这一列。
  101. func RoundDecimalString(s string) (string, error) {
  102. c, err := DecimalToCents(s)
  103. if err != nil {
  104. return "", err
  105. }
  106. return CentsToDecimal(c), nil
  107. }