amount.go 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326
  1. // Package money 金额值对象(int64「分」定点数,全程零 float64 路径)。
  2. //
  3. // 设计依据(unified/03-migration/academic-domain-design.md §1.5 / §9):
  4. // - DDL 金额列统一 decimal(12,2)(旧 float(10,2/3)/double(10,3)/decimal(10,3) 全收敛);
  5. // - Go 侧自研 Amount(单位:分),**零新增第三方依赖**(不引入 shopspring/decimal);
  6. // - 禁止 float64 参与任何资金累加:解析/落库/序列化全程走字符串,
  7. // decimal(12,2) ↔ Amount 的互转只经十进制定点运算。
  8. //
  9. // 为什么是「分」而不是「元」:
  10. // - int64 分在 decimal(12,2) 全域(±9,999,999,999.99 元 = ±999,999,999,999 分)内精确,
  11. // 加/减/乘整数无误差累积;旧库 3 位小数已在迁移期 ROUND(x,2) 收敛(设计 §11-7 台账)。
  12. //
  13. // 溢出策略(显式,不静默):
  14. // - 解析(NewFromYuanString / UnmarshalJSON / Scan)越界 → ErrOverflow;
  15. // - 运算(Add/Sub/MulInt/Neg)为性能敏感路径,**不做每步溢出检查**,由调用方保证
  16. // 业务金额量级(资金域天然受限,单笔 ≤ 亿元级);写库前若需断言,用 Validate()。
  17. //
  18. // 典型用法:
  19. //
  20. // a, err := money.NewFromYuanString("100.00") // 入参字符串(前端契约,设计 §10.6)
  21. // if err != nil { return nil, apperr.ErrBadAmount }
  22. // b := a.Add(fee).Sub(refund)
  23. // if b.IsZero() { ... }
  24. // s := b.String() // "99.99"(两位小数)
  25. package money
  26. import (
  27. "database/sql"
  28. "database/sql/driver"
  29. "fmt"
  30. "strconv"
  31. "strings"
  32. )
  33. // Scale 元 → 分的换算基数(1 元 = 100 分,对应 decimal 的 2 位小数)。
  34. const Scale int64 = 100
  35. // Amount 金额(单位:分,int64 定点数)。
  36. //
  37. // 取值域:decimal(12,2) 全域 = [-999_999_999_999, 999_999_999_999] 分
  38. // (即 ±9,999,999,999.99 元)。
  39. //
  40. // 约定:
  41. // - 零值 = 0 元(IsZero 为 true),可直接用于 struct 字段默认值;
  42. // - JSON 传输恒为**字符串**("100.00",避免前端 JS float 精度问题,设计 §10.6);
  43. // - 落库经 driver.Valuer 输出字符串,MySQL decimal 无损接收。
  44. type Amount int64
  45. // MaxAmount 允许的最大金额(999,999,999,999 分 = 9,999,999,999.99 元,decimal(12,2) 上限)。
  46. const MaxAmount Amount = 999_999_999_999
  47. // MinAmount 允许的最小金额(-999,999,999,999 分,与 MaxAmount 对称;负数=欠费/冲正)。
  48. const MinAmount Amount = -999_999_999_999
  49. // 解析/扫描阶段的哨兵错误(Handler 统一映射为 2002 参数格式错误,设计 §10.1)。
  50. var (
  51. // ErrInvalidFormat 金额字符串格式非法(空串/含非数字/多个小数点/"12."/".5"/JSON 非字符串)。
  52. ErrInvalidFormat = errString("money: 金额格式非法(期望十进制字符串,如 \"12.34\"/\"12\"/\"-0.01\")")
  53. // ErrOverflow 金额越界(超出 ±9,999,999,999.99 元或 int64 中间计算溢出)。
  54. ErrOverflow = errString("money: 金额越界(decimal(12,2) 上限 ±9,999,999,999.99 元)")
  55. // ErrScanNull 扫描到 NULL(金额列在 DDL 中均为 NOT NULL;NULL 多为 SQL 聚合/LEFT JOIN 未命中)。
  56. ErrScanNull = errString("money: 不能扫描 NULL 金额")
  57. // ErrUnsupportedScanType 扫描到不支持的类型(**明确拒绝 float64**,避免浮点进入资金链)。
  58. ErrUnsupportedScanType = errString("money: 不支持的扫描类型(只接受 string/[]byte,拒绝 float64)")
  59. // ErrNilReceiver JSON 反序列化接收者为 nil。
  60. ErrNilReceiver = errString("money: 反序列化接收者为 nil")
  61. )
  62. // errString 实现 error 的最小字符串类型(避免为哨兵错误引入额外依赖)。
  63. type errString string
  64. // Error 实现 error 接口(返回固定文案)。
  65. func (e errString) Error() string { return string(e) }
  66. // 编译期接口合规断言(sql.Scanner / driver.Valuer 是落库互转的硬契约)。
  67. var (
  68. _ driver.Valuer = Amount(0)
  69. _ sql.Scanner = (*Amount)(nil)
  70. )
  71. // NewFromYuanString 把「元」十进制字符串严格解析为 Amount(分)。
  72. //
  73. // 入参:
  74. // - s string:十进制金额字符串;允许前后空格、可选正负号;
  75. // 合法形态:"12.34" / "12" / "-0.01" / "+100.5"(小数位 1~N 位,>2 位按四舍五入)。
  76. //
  77. // 出参:
  78. // - Amount:解析结果(分);"-0.004" 归一为 0(不产生负零)。
  79. // - error:ErrInvalidFormat(空串/非数字/多小数点/"12."/".5")或 ErrOverflow(越界)。
  80. //
  81. // 精确性:全程字符串 + int64 整数运算,**不经 float64**。
  82. // 舍入规则:第 3 位小数 ≥5 则进位(远离零方向),即 0.005 → 0.01、-0.005 → -0.01。
  83. func NewFromYuanString(s string) (Amount, error) {
  84. s = strings.TrimSpace(s)
  85. if s == "" {
  86. return 0, ErrInvalidFormat
  87. }
  88. neg := false
  89. switch s[0] {
  90. case '-':
  91. neg = true
  92. s = s[1:]
  93. case '+':
  94. s = s[1:]
  95. }
  96. if s == "" {
  97. return 0, ErrInvalidFormat // 仅剩符号:"-" / "+"
  98. }
  99. intPart, fracPart, hasDot := strings.Cut(s, ".")
  100. if hasDot && fracPart == "" {
  101. return 0, ErrInvalidFormat // "12." 拒绝(严格,避免前端半成品输入)
  102. }
  103. if intPart == "" {
  104. return 0, ErrInvalidFormat // ".5" 拒绝(强制整数位,避免歧义)
  105. }
  106. if !allDigits(intPart) {
  107. return 0, ErrInvalidFormat
  108. }
  109. if hasDot && !allDigits(fracPart) {
  110. return 0, ErrInvalidFormat // 小数位非数字("12.a")
  111. }
  112. yuan, err := strconv.ParseInt(intPart, 10, 64)
  113. if err != nil {
  114. return 0, ErrOverflow // 整数位已超 int64(远超 decimal(12,2))
  115. }
  116. // 先做量级预检,避免 yuan*Scale 溢出(超过上限 1 元即必然越界)
  117. if yuan > int64(MaxAmount)/Scale+1 {
  118. return 0, ErrOverflow
  119. }
  120. // 取小数前 2 位 + 第 3 位决定是否进位
  121. two := fracPart
  122. roundUp := false
  123. if len(fracPart) > 2 {
  124. roundUp = fracPart[2] >= '5'
  125. two = fracPart[:2]
  126. }
  127. var frac int64
  128. for i := 0; i < len(two); i++ {
  129. frac = frac*10 + int64(two[i]-'0')
  130. }
  131. if len(two) == 1 {
  132. frac *= 10 // "0.5" → 50 分
  133. }
  134. if roundUp {
  135. frac++ // 99 → 100 由 int64 相加自然进位
  136. }
  137. cents := yuan*Scale + frac
  138. if neg {
  139. cents = -cents
  140. }
  141. if cents > int64(MaxAmount) || cents < int64(MinAmount) {
  142. return 0, ErrOverflow
  143. }
  144. return Amount(cents), nil
  145. }
  146. // Parse 语义等同 NewFromYuanString(短名别名,供 service/handler 里高频调用点使用)。
  147. //
  148. // 入参:s 十进制"元"字符串。出参:Amount + error(同 NewFromYuanString)。
  149. func Parse(s string) (Amount, error) { return NewFromYuanString(s) }
  150. // FromCents 由「分」构造 Amount(供 SQL SUM(decimal) 结果已换算为分的场景)。
  151. //
  152. // 入参:cents int64——分的整数表示。出参:Amount(不校验越界,调用方按量级保证)。
  153. func FromCents(cents int64) Amount { return Amount(cents) }
  154. // Cents 返回「分」的 int64 表示(写 SQL 参数/聚合计算时用;不产生浮点)。
  155. func (a Amount) Cents() int64 { return int64(a) }
  156. // String 输出两位小数的"元"字符串("12.34" / "-0.01" / "0.00")。
  157. //
  158. // 实现说明:用 uint64 取绝对值,规避 int64 最小值取负溢出(防御性,业务域不会出现)。
  159. func (a Amount) String() string {
  160. neg := a < 0
  161. var u uint64
  162. if neg {
  163. u = -uint64(a) // 二进制补码取绝对值(对 MinInt64 亦安全)
  164. } else {
  165. u = uint64(a)
  166. }
  167. s := fmt.Sprintf("%d.%02d", u/uint64(Scale), u%uint64(Scale))
  168. if neg && u != 0 {
  169. return "-" + s
  170. }
  171. return s
  172. }
  173. // Add 返回 a+b(不检查溢出,见包注释「溢出策略」)。
  174. func (a Amount) Add(b Amount) Amount { return a + b }
  175. // Sub 返回 a-b(不检查溢出)。
  176. func (a Amount) Sub(b Amount) Amount { return a - b }
  177. // MulInt 返回 a*n(整数倍:课次 × 单课费、数量 × 单价等场景;不检查溢出)。
  178. func (a Amount) MulInt(n int64) Amount { return a * Amount(n) }
  179. // Neg 返回 -a(反向流水/冲正场景;不检查溢出)。
  180. func (a Amount) Neg() Amount { return -a }
  181. // Cmp 比较大小:返回 -1(a<b)/ 0(a==b)/ 1(a>b)。
  182. func (a Amount) Cmp(b Amount) int {
  183. switch {
  184. case a < b:
  185. return -1
  186. case a > b:
  187. return 1
  188. default:
  189. return 0
  190. }
  191. }
  192. // IsZero 是否为零值(0 元)。
  193. func (a Amount) IsZero() bool { return a == 0 }
  194. // LessThan 是否小于 b(读性优先于 Cmp 的比较场景)。
  195. func (a Amount) LessThan(b Amount) bool { return a < b }
  196. // GreaterThan 是否大于 b。
  197. func (a Amount) GreaterThan(b Amount) bool { return a > b }
  198. // IsNegative 是否为负数(余额欠费/冲正方向判定)。
  199. func (a Amount) IsNegative() bool { return a < 0 }
  200. // Validate 校验是否落在 decimal(12,2) 可表示区间内(写库前的显式断言)。
  201. //
  202. // 出参:nil 合法;ErrOverflow 越界。
  203. func (a Amount) Validate() error {
  204. if a > MaxAmount || a < MinAmount {
  205. return ErrOverflow
  206. }
  207. return nil
  208. }
  209. // MarshalJSON 序列化为 JSON 字符串("12.34"),**不输出数字**,规避前端 float 精度损失。
  210. func (a Amount) MarshalJSON() ([]byte, error) {
  211. return []byte(`"` + a.String() + `"`), nil
  212. }
  213. // UnmarshalJSON 严格反序列化(入参必须是 JSON 字符串,拒绝 number/null/bool)。
  214. //
  215. // 入参:b []byte——JSON 原始字节(如 `"100.00"`)。
  216. // 出参:ErrInvalidFormat(非字符串或格式非法)/ ErrOverflow(越界)/ ErrNilReceiver(接收者 nil)。
  217. func (a *Amount) UnmarshalJSON(b []byte) error {
  218. if a == nil {
  219. return ErrNilReceiver
  220. }
  221. s := strings.TrimSpace(string(b))
  222. if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
  223. return ErrInvalidFormat // 只接受字符串;数字/布尔/null 一律拒绝(前端契约)
  224. }
  225. v, err := NewFromYuanString(s[1 : len(s)-1])
  226. if err != nil {
  227. return err
  228. }
  229. *a = v
  230. return nil
  231. }
  232. // Value 实现 driver.Valuer:输出字符串("12.34"),MySQL decimal(12,2) 无损接收。
  233. //
  234. // 说明:**不输出 float64**——MySQL 驱动会把 float64 转成浮点字面量,产生精度隐患。
  235. func (a Amount) Value() (driver.Value, error) {
  236. if err := a.Validate(); err != nil {
  237. return nil, err
  238. }
  239. return a.String(), nil
  240. }
  241. // Scan 实现 sql.Scanner:把数据库 decimal(12,2) 值解析为 Amount(分)。
  242. //
  243. // 入参:
  244. // - src any:驱动返回的原始值;decimal 列在 MySQL 驱动下为 []byte("123.45"),部分驱动/单测为 string。
  245. //
  246. // 出参:
  247. // - ErrScanNull:src 为 nil(金额列 NOT NULL;NULL 多来自聚合未命中或 LEFT JOIN);
  248. // - ErrUnsupportedScanType:src 为 float64/float32 等(**拒绝浮点入资金链**)或其他未知类型;
  249. // - ErrInvalidFormat / ErrOverflow:解析失败。
  250. func (a *Amount) Scan(src any) error {
  251. if a == nil {
  252. return ErrNilReceiver
  253. }
  254. switch v := src.(type) {
  255. case nil:
  256. return ErrScanNull
  257. case []byte:
  258. amt, err := NewFromYuanString(string(v))
  259. if err != nil {
  260. return err
  261. }
  262. *a = amt
  263. return nil
  264. case string:
  265. amt, err := NewFromYuanString(v)
  266. if err != nil {
  267. return err
  268. }
  269. *a = amt
  270. return nil
  271. default:
  272. // float64/float32/int64 等:类型语义不明(分还是元?)且浮点有精度风险 → 显式拒绝
  273. return ErrUnsupportedScanType
  274. }
  275. }
  276. // GormDataType 供 GORM 推断列类型(对齐 DDL decimal(12,2),设计 §1.3)。
  277. //
  278. // 本批 model 的金额列暂用 string 落库(AC-01 约定),本方法是后续切换
  279. // Amount 直接落 model 时的兼容钩子(不引入 gorm 依赖,仅返回类型字符串)。
  280. func (Amount) GormDataType() string { return "decimal(12,2)" }
  281. // allDigits 判断字符串是否全为 ASCII 数字(空串返回 false)。
  282. func allDigits(s string) bool {
  283. if s == "" {
  284. return false
  285. }
  286. for i := 0; i < len(s); i++ {
  287. if s[i] < '0' || s[i] > '9' {
  288. return false
  289. }
  290. }
  291. return true
  292. }