| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326 |
- // Package money 金额值对象(int64「分」定点数,全程零 float64 路径)。
- //
- // 设计依据(unified/03-migration/academic-domain-design.md §1.5 / §9):
- // - DDL 金额列统一 decimal(12,2)(旧 float(10,2/3)/double(10,3)/decimal(10,3) 全收敛);
- // - Go 侧自研 Amount(单位:分),**零新增第三方依赖**(不引入 shopspring/decimal);
- // - 禁止 float64 参与任何资金累加:解析/落库/序列化全程走字符串,
- // decimal(12,2) ↔ Amount 的互转只经十进制定点运算。
- //
- // 为什么是「分」而不是「元」:
- // - int64 分在 decimal(12,2) 全域(±9,999,999,999.99 元 = ±999,999,999,999 分)内精确,
- // 加/减/乘整数无误差累积;旧库 3 位小数已在迁移期 ROUND(x,2) 收敛(设计 §11-7 台账)。
- //
- // 溢出策略(显式,不静默):
- // - 解析(NewFromYuanString / UnmarshalJSON / Scan)越界 → ErrOverflow;
- // - 运算(Add/Sub/MulInt/Neg)为性能敏感路径,**不做每步溢出检查**,由调用方保证
- // 业务金额量级(资金域天然受限,单笔 ≤ 亿元级);写库前若需断言,用 Validate()。
- //
- // 典型用法:
- //
- // a, err := money.NewFromYuanString("100.00") // 入参字符串(前端契约,设计 §10.6)
- // if err != nil { return nil, apperr.ErrBadAmount }
- // b := a.Add(fee).Sub(refund)
- // if b.IsZero() { ... }
- // s := b.String() // "99.99"(两位小数)
- package money
- import (
- "database/sql"
- "database/sql/driver"
- "fmt"
- "strconv"
- "strings"
- )
- // Scale 元 → 分的换算基数(1 元 = 100 分,对应 decimal 的 2 位小数)。
- const Scale int64 = 100
- // Amount 金额(单位:分,int64 定点数)。
- //
- // 取值域:decimal(12,2) 全域 = [-999_999_999_999, 999_999_999_999] 分
- // (即 ±9,999,999,999.99 元)。
- //
- // 约定:
- // - 零值 = 0 元(IsZero 为 true),可直接用于 struct 字段默认值;
- // - JSON 传输恒为**字符串**("100.00",避免前端 JS float 精度问题,设计 §10.6);
- // - 落库经 driver.Valuer 输出字符串,MySQL decimal 无损接收。
- type Amount int64
- // MaxAmount 允许的最大金额(999,999,999,999 分 = 9,999,999,999.99 元,decimal(12,2) 上限)。
- const MaxAmount Amount = 999_999_999_999
- // MinAmount 允许的最小金额(-999,999,999,999 分,与 MaxAmount 对称;负数=欠费/冲正)。
- const MinAmount Amount = -999_999_999_999
- // 解析/扫描阶段的哨兵错误(Handler 统一映射为 2002 参数格式错误,设计 §10.1)。
- var (
- // ErrInvalidFormat 金额字符串格式非法(空串/含非数字/多个小数点/"12."/".5"/JSON 非字符串)。
- ErrInvalidFormat = errString("money: 金额格式非法(期望十进制字符串,如 \"12.34\"/\"12\"/\"-0.01\")")
- // ErrOverflow 金额越界(超出 ±9,999,999,999.99 元或 int64 中间计算溢出)。
- ErrOverflow = errString("money: 金额越界(decimal(12,2) 上限 ±9,999,999,999.99 元)")
- // ErrScanNull 扫描到 NULL(金额列在 DDL 中均为 NOT NULL;NULL 多为 SQL 聚合/LEFT JOIN 未命中)。
- ErrScanNull = errString("money: 不能扫描 NULL 金额")
- // ErrUnsupportedScanType 扫描到不支持的类型(**明确拒绝 float64**,避免浮点进入资金链)。
- ErrUnsupportedScanType = errString("money: 不支持的扫描类型(只接受 string/[]byte,拒绝 float64)")
- // ErrNilReceiver JSON 反序列化接收者为 nil。
- ErrNilReceiver = errString("money: 反序列化接收者为 nil")
- )
- // errString 实现 error 的最小字符串类型(避免为哨兵错误引入额外依赖)。
- type errString string
- // Error 实现 error 接口(返回固定文案)。
- func (e errString) Error() string { return string(e) }
- // 编译期接口合规断言(sql.Scanner / driver.Valuer 是落库互转的硬契约)。
- var (
- _ driver.Valuer = Amount(0)
- _ sql.Scanner = (*Amount)(nil)
- )
- // NewFromYuanString 把「元」十进制字符串严格解析为 Amount(分)。
- //
- // 入参:
- // - s string:十进制金额字符串;允许前后空格、可选正负号;
- // 合法形态:"12.34" / "12" / "-0.01" / "+100.5"(小数位 1~N 位,>2 位按四舍五入)。
- //
- // 出参:
- // - Amount:解析结果(分);"-0.004" 归一为 0(不产生负零)。
- // - error:ErrInvalidFormat(空串/非数字/多小数点/"12."/".5")或 ErrOverflow(越界)。
- //
- // 精确性:全程字符串 + int64 整数运算,**不经 float64**。
- // 舍入规则:第 3 位小数 ≥5 则进位(远离零方向),即 0.005 → 0.01、-0.005 → -0.01。
- func NewFromYuanString(s string) (Amount, error) {
- s = strings.TrimSpace(s)
- if s == "" {
- return 0, ErrInvalidFormat
- }
- neg := false
- switch s[0] {
- case '-':
- neg = true
- s = s[1:]
- case '+':
- s = s[1:]
- }
- if s == "" {
- return 0, ErrInvalidFormat // 仅剩符号:"-" / "+"
- }
- intPart, fracPart, hasDot := strings.Cut(s, ".")
- if hasDot && fracPart == "" {
- return 0, ErrInvalidFormat // "12." 拒绝(严格,避免前端半成品输入)
- }
- if intPart == "" {
- return 0, ErrInvalidFormat // ".5" 拒绝(强制整数位,避免歧义)
- }
- if !allDigits(intPart) {
- return 0, ErrInvalidFormat
- }
- if hasDot && !allDigits(fracPart) {
- return 0, ErrInvalidFormat // 小数位非数字("12.a")
- }
- yuan, err := strconv.ParseInt(intPart, 10, 64)
- if err != nil {
- return 0, ErrOverflow // 整数位已超 int64(远超 decimal(12,2))
- }
- // 先做量级预检,避免 yuan*Scale 溢出(超过上限 1 元即必然越界)
- if yuan > int64(MaxAmount)/Scale+1 {
- return 0, ErrOverflow
- }
- // 取小数前 2 位 + 第 3 位决定是否进位
- two := fracPart
- roundUp := false
- if len(fracPart) > 2 {
- roundUp = fracPart[2] >= '5'
- two = fracPart[:2]
- }
- var frac int64
- for i := 0; i < len(two); i++ {
- frac = frac*10 + int64(two[i]-'0')
- }
- if len(two) == 1 {
- frac *= 10 // "0.5" → 50 分
- }
- if roundUp {
- frac++ // 99 → 100 由 int64 相加自然进位
- }
- cents := yuan*Scale + frac
- if neg {
- cents = -cents
- }
- if cents > int64(MaxAmount) || cents < int64(MinAmount) {
- return 0, ErrOverflow
- }
- return Amount(cents), nil
- }
- // Parse 语义等同 NewFromYuanString(短名别名,供 service/handler 里高频调用点使用)。
- //
- // 入参:s 十进制"元"字符串。出参:Amount + error(同 NewFromYuanString)。
- func Parse(s string) (Amount, error) { return NewFromYuanString(s) }
- // FromCents 由「分」构造 Amount(供 SQL SUM(decimal) 结果已换算为分的场景)。
- //
- // 入参:cents int64——分的整数表示。出参:Amount(不校验越界,调用方按量级保证)。
- func FromCents(cents int64) Amount { return Amount(cents) }
- // Cents 返回「分」的 int64 表示(写 SQL 参数/聚合计算时用;不产生浮点)。
- func (a Amount) Cents() int64 { return int64(a) }
- // String 输出两位小数的"元"字符串("12.34" / "-0.01" / "0.00")。
- //
- // 实现说明:用 uint64 取绝对值,规避 int64 最小值取负溢出(防御性,业务域不会出现)。
- func (a Amount) String() string {
- neg := a < 0
- var u uint64
- if neg {
- u = -uint64(a) // 二进制补码取绝对值(对 MinInt64 亦安全)
- } else {
- u = uint64(a)
- }
- s := fmt.Sprintf("%d.%02d", u/uint64(Scale), u%uint64(Scale))
- if neg && u != 0 {
- return "-" + s
- }
- return s
- }
- // Add 返回 a+b(不检查溢出,见包注释「溢出策略」)。
- func (a Amount) Add(b Amount) Amount { return a + b }
- // Sub 返回 a-b(不检查溢出)。
- func (a Amount) Sub(b Amount) Amount { return a - b }
- // MulInt 返回 a*n(整数倍:课次 × 单课费、数量 × 单价等场景;不检查溢出)。
- func (a Amount) MulInt(n int64) Amount { return a * Amount(n) }
- // Neg 返回 -a(反向流水/冲正场景;不检查溢出)。
- func (a Amount) Neg() Amount { return -a }
- // Cmp 比较大小:返回 -1(a<b)/ 0(a==b)/ 1(a>b)。
- func (a Amount) Cmp(b Amount) int {
- switch {
- case a < b:
- return -1
- case a > b:
- return 1
- default:
- return 0
- }
- }
- // IsZero 是否为零值(0 元)。
- func (a Amount) IsZero() bool { return a == 0 }
- // LessThan 是否小于 b(读性优先于 Cmp 的比较场景)。
- func (a Amount) LessThan(b Amount) bool { return a < b }
- // GreaterThan 是否大于 b。
- func (a Amount) GreaterThan(b Amount) bool { return a > b }
- // IsNegative 是否为负数(余额欠费/冲正方向判定)。
- func (a Amount) IsNegative() bool { return a < 0 }
- // Validate 校验是否落在 decimal(12,2) 可表示区间内(写库前的显式断言)。
- //
- // 出参:nil 合法;ErrOverflow 越界。
- func (a Amount) Validate() error {
- if a > MaxAmount || a < MinAmount {
- return ErrOverflow
- }
- return nil
- }
- // MarshalJSON 序列化为 JSON 字符串("12.34"),**不输出数字**,规避前端 float 精度损失。
- func (a Amount) MarshalJSON() ([]byte, error) {
- return []byte(`"` + a.String() + `"`), nil
- }
- // UnmarshalJSON 严格反序列化(入参必须是 JSON 字符串,拒绝 number/null/bool)。
- //
- // 入参:b []byte——JSON 原始字节(如 `"100.00"`)。
- // 出参:ErrInvalidFormat(非字符串或格式非法)/ ErrOverflow(越界)/ ErrNilReceiver(接收者 nil)。
- func (a *Amount) UnmarshalJSON(b []byte) error {
- if a == nil {
- return ErrNilReceiver
- }
- s := strings.TrimSpace(string(b))
- if len(s) < 2 || s[0] != '"' || s[len(s)-1] != '"' {
- return ErrInvalidFormat // 只接受字符串;数字/布尔/null 一律拒绝(前端契约)
- }
- v, err := NewFromYuanString(s[1 : len(s)-1])
- if err != nil {
- return err
- }
- *a = v
- return nil
- }
- // Value 实现 driver.Valuer:输出字符串("12.34"),MySQL decimal(12,2) 无损接收。
- //
- // 说明:**不输出 float64**——MySQL 驱动会把 float64 转成浮点字面量,产生精度隐患。
- func (a Amount) Value() (driver.Value, error) {
- if err := a.Validate(); err != nil {
- return nil, err
- }
- return a.String(), nil
- }
- // Scan 实现 sql.Scanner:把数据库 decimal(12,2) 值解析为 Amount(分)。
- //
- // 入参:
- // - src any:驱动返回的原始值;decimal 列在 MySQL 驱动下为 []byte("123.45"),部分驱动/单测为 string。
- //
- // 出参:
- // - ErrScanNull:src 为 nil(金额列 NOT NULL;NULL 多来自聚合未命中或 LEFT JOIN);
- // - ErrUnsupportedScanType:src 为 float64/float32 等(**拒绝浮点入资金链**)或其他未知类型;
- // - ErrInvalidFormat / ErrOverflow:解析失败。
- func (a *Amount) Scan(src any) error {
- if a == nil {
- return ErrNilReceiver
- }
- switch v := src.(type) {
- case nil:
- return ErrScanNull
- case []byte:
- amt, err := NewFromYuanString(string(v))
- if err != nil {
- return err
- }
- *a = amt
- return nil
- case string:
- amt, err := NewFromYuanString(v)
- if err != nil {
- return err
- }
- *a = amt
- return nil
- default:
- // float64/float32/int64 等:类型语义不明(分还是元?)且浮点有精度风险 → 显式拒绝
- return ErrUnsupportedScanType
- }
- }
- // GormDataType 供 GORM 推断列类型(对齐 DDL decimal(12,2),设计 §1.3)。
- //
- // 本批 model 的金额列暂用 string 落库(AC-01 约定),本方法是后续切换
- // Amount 直接落 model 时的兼容钩子(不引入 gorm 依赖,仅返回类型字符串)。
- func (Amount) GormDataType() string { return "decimal(12,2)" }
- // allDigits 判断字符串是否全为 ASCII 数字(空串返回 false)。
- func allDigits(s string) bool {
- if s == "" {
- return false
- }
- for i := 0; i < len(s); i++ {
- if s[i] < '0' || s[i] > '9' {
- return false
- }
- }
- return true
- }
|