// 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(ab)。 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 }