decimal_format.go 8.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. // 十进制「定点格式化」纯函数(**不碰数据库、不碰时钟**)——AC-13d 追加。
  2. //
  3. // ---------------------------------------------------------------------------
  4. // 为什么必须有这一层(不是"多此一举的漂亮化")
  5. // ---------------------------------------------------------------------------
  6. //
  7. // 库里三类"小数字符串"列(`decimal(5,1)` 比例 / `decimal(10,3)` 教龄 / `decimal(12,2)` 金额)
  8. // 都以**字符串**进出 Go,于是同一个值在不同环境里长成不同样子:
  9. //
  10. // MySQL 8 读 decimal(5,1) 的 85 → "85.0"
  11. // SQLite(单测) 读 numeric 亲和列 → "85" ← 尾部 0 被吃掉
  12. // 前端刚才写入 "85.00"(多打一位) → "85.00"
  13. //
  14. // 三种形态**数值相等、字节不等**。后果不是"不好看",而是三条实打实的故障:
  15. //
  16. // 1. **前端 diff 误判**:编辑弹窗把接口回的值原样回填,第二次提交时"85" ≠ "85.0",
  17. // 被认为用户改过 ⇒ 生成一条脏变更(配置类页面最常见)。
  18. // 2. **单测在两个方言之间摇摆**:断言 `"85.0"` 在 SQLite 上必红、断言 `"85"` 在 MySQL 上必红 ⇒
  19. // 要么写 `mustDecimal` 把断言弱化成"数值对就行"(那就丢了"量纲/位数"这条防线),
  20. // 要么测试骨架随数据库变。**格式化到列声明的位数,两端都稳。**
  21. // 3. **金额与比例的"尺寸"混淆无人察觉**:`percentage` 如果被按金额格式化(1 位 vs 2 位),
  22. // `"85.0" → "85.00"` 恰好数值不变 ⇒ 量纲被污染的 bug 静默通过。**位数本身就是量纲指纹。**
  23. //
  24. // 因此:**出口统一按"列声明的 scale"定点化**,入口只做区间校验。本文件是出口的唯一实现点。
  25. //
  26. // ---------------------------------------------------------------------------
  27. // 实现约束
  28. // ---------------------------------------------------------------------------
  29. //
  30. // - **零 float64**:百分比/教龄虽不是资金,但"用浮点格式化再拼字符串"在 0.1 这种十进制
  31. // 不可表示值上会产生 `84.99999999999999` 之类的残渣;本文件全程字符串 + int64。
  32. // - **纯函数**:无 IO、无全局状态、无时区依赖 ⇒ 可以脱离数据库做单测(`decimal_format_test.go`)。
  33. // - **不静默吞错**:非法文本返回错误,由调用方映射(Handler 侧统一 5002「数据不一致」,
  34. // 因为走到格式化时数据已经出库,格式坏 = 库里写坏了,不是用户参数问题,**不能回 2002**)。
  35. package service
  36. import "strings"
  37. // 各类列的 scale(小数位数)——**与 DDL 声明一致,不要在这里"顺手统一"**。
  38. const (
  39. // ScalePercentage `t_course_payment_detail.percentage` 是 `decimal(5,1)` ⇒ 1 位。
  40. ScalePercentage = 1
  41. // ScaleYears `t_course_payment_detail.teacher_exp_min/max` 是 `decimal(10,3)` ⇒ 3 位。
  42. ScaleYears = 3
  43. // ScaleYuan 金额列统一 `decimal(12,2)` ⇒ 2 位。
  44. ScaleYuan = 2
  45. )
  46. // FormatPercentage 把百分数字符串定点化到 1 位小数("85" → "85.0","85.00" → "85.0")。
  47. //
  48. // 入参:text 库里的百分数("85" / "85.0" / "85.00")。
  49. // 出参:(定点化字符串, error);空串/非数字/超过 int64 量级 → ErrInvalidFormat。
  50. //
  51. // ⚠ **只格式化,不改量纲**:`"85"` 是 85%,不是 0.85。需要比率时由 `applyPercentage` 除 1000,
  52. // 不要在这里动手脚(否则"格式化了"会被误认为"归一化了")。
  53. func FormatPercentage(text string) (string, error) {
  54. return FormatFixed(text, ScalePercentage)
  55. }
  56. // FormatYears 把教龄字符串定点化到 3 位小数("1" → "1.000","5" → "5.000")。
  57. //
  58. // 入参:text 库里的教龄区间端点("0" / "1.000" / "5")。
  59. // 出参:(定点化字符串, error);非法 → ErrInvalidFormat。
  60. //
  61. // 为什么教龄也要固定位数:它是**区间比较的展示值**(前端拿它判断"我这一档是不是到了"),
  62. // `0` 与 `0.000` 在 JS 里一个是 number 一个是 string,跨端比较会出现 `"0" > "0.000"` 之类的字符串比较坑。
  63. func FormatYears(text string) (string, error) {
  64. return FormatFixed(text, ScaleYears)
  65. }
  66. // FormatYuan 把金额字符串定点化到 2 位小数("100" → "100.00","12.3" → "12.30")。
  67. //
  68. // 入参:text 库里的金额("0" / "100" / "300.00";空串按 0 处理,与 `parseAmount` 一致)。
  69. // 出参:(定点化字符串, error);非法 → ErrDBError(**不是 2002**:出库数据坏 = 库写坏了)。
  70. //
  71. // 内部走 `parseAmount(...).String()`:解析与输出都在定点整数域(`money` 包已禁 float64),
  72. // 这里不重复实现一遍舍入,**只有一处舍入规则**(第 3 位 ≥5 进位)。
  73. func FormatYuan(text string) (string, error) {
  74. amt, err := parseAmount(text)
  75. if err != nil {
  76. return "", err
  77. }
  78. return amt.String(), nil
  79. }
  80. // FormatFixed 把十进制字符串四舍五入到固定小数位数(本包定点化的唯一实现)。
  81. //
  82. // 入参:
  83. // - text 十进制字符串,允许前后空格与正负号("85" / "-0.01" / "1.23456");
  84. // - scale 目标小数位(0~3,见 Scale* 常量)。
  85. //
  86. // 出参:(定点化字符串, error);以下情形返回 ErrInvalidFormat:
  87. // 空串、只有符号、整数位非数字(含 ".5")、小数位非数字(含 "12.")、整数位超过 12 位(量级异常)。
  88. //
  89. // 舍入:第 scale+1 位 ≥5 时进位(远离零方向),与 `money.NewFromYuanString` 同规则。
  90. // 负零归一:`"-0.0"` → `"0.0"`(不产生 `-0.0`,否则前端 `Number("-0.0")` 得到 -0,diff 又会误判)。
  91. func FormatFixed(text string, scale int) (string, error) {
  92. s := strings.TrimSpace(text)
  93. if s == "" {
  94. return "", errString("decimal: 空字符串无法定点化")
  95. }
  96. neg := false
  97. switch s[0] {
  98. case '-':
  99. neg = true
  100. s = s[1:]
  101. case '+':
  102. s = s[1:]
  103. }
  104. if s == "" {
  105. return "", errString("decimal: 只有符号")
  106. }
  107. intPart, fracPart, hasDot := strings.Cut(s, ".")
  108. if intPart == "" || !allDigits(intPart) {
  109. return "", errString("decimal: 整数位非法 " + text)
  110. }
  111. if hasDot && (fracPart == "" || !allDigits(fracPart)) {
  112. return "", errString("decimal: 小数位非法 " + text) // "12." 与 "12.a" 都拒(库里的值不该长这样)
  113. }
  114. // 量级护栏:整数位超过 12 位即超出本项目任何 decimal 列(decimal(12,2) 上限 10 位整数),
  115. // 继续算 int64 会静默溢出 ⇒ 明确拒绝(宁可 5002,也不要吐出错误金额)。
  116. if len(intPart) > 12 {
  117. return "", errString("decimal: 整数位过长(超过 12 位)" + text)
  118. }
  119. // 保留 scale 位,看下一位决定进位
  120. kept := fracPart
  121. roundUp := false
  122. if len(kept) > scale {
  123. roundUp = kept[scale] >= '5'
  124. kept = kept[:scale]
  125. }
  126. for len(kept) < scale {
  127. kept += "0"
  128. }
  129. var units int64 // 放大 10^scale 后的整数
  130. for _, ch := range intPart {
  131. units = units*10 + int64(ch-'0')
  132. }
  133. for i := 0; i < scale; i++ {
  134. units *= 10
  135. }
  136. var frac int64
  137. for i := 0; i < len(kept); i++ {
  138. frac = frac*10 + int64(kept[i]-'0')
  139. }
  140. if roundUp {
  141. frac++ // 99…9 + 1 由 int64 相加自然向整数位进位
  142. }
  143. units += frac
  144. return renderFixed(units, scale, neg), nil
  145. }
  146. // renderFixed 把"放大 10^scale 后的整数"渲染成定点字符串(FormatFixed 的输出侧)。
  147. //
  148. // 入参:units 放大值(非负);scale 小数位;neg 是否为负。
  149. // 出参:定点字符串(`units=850, scale=1` → "85.0";`units=10000, scale=2` → "100.00")。
  150. func renderFixed(units int64, scale int, neg bool) string {
  151. if scale <= 0 {
  152. text := formatInt64(units)
  153. if neg && units != 0 {
  154. return "-" + text
  155. }
  156. return text
  157. }
  158. div := int64(1)
  159. for i := 0; i < scale; i++ {
  160. div *= 10
  161. }
  162. whole := units / div
  163. frac := units % div
  164. fracText := formatInt64(frac)
  165. for len(fracText) < scale {
  166. fracText = "0" + fracText
  167. }
  168. text := formatInt64(whole) + "." + fracText
  169. if neg && units != 0 {
  170. return "-" + text
  171. }
  172. return text
  173. }
  174. // formatInt64 把非负 int64 转十进制(避免为本文件拉 strconv 进热路径的语感,也便于统一)。
  175. func formatInt64(v int64) string {
  176. if v == 0 {
  177. return "0"
  178. }
  179. var buf [20]byte
  180. i := len(buf)
  181. for v > 0 {
  182. i--
  183. buf[i] = byte('0' + v%10)
  184. v /= 10
  185. }
  186. return string(buf[i:])
  187. }