refactor(money): 统一金额分为纯整数工具

This commit is contained in:
yml2213
2026-10-30 14:33:35 +08:00
parent 85bff95042
commit 37e1980d2d
7 changed files with 156 additions and 179 deletions
@@ -8,7 +8,6 @@ import (
"strings"
"hfb_sys/backend/internal/model"
"hfb_sys/backend/pkg/money"
"gorm.io/datatypes"
)
@@ -462,7 +461,11 @@ func extractListingObjectKey(fileURL string) string {
return key
}
// roundMoney 使用统一的角精度(0.1元)
// roundMoney 是历史外部上传/审核链路的兼容实现,仍按 0.1 元精度工作。
// 卖家发布链路已改走整数分/基点计算,不再调用该函数。
func roundMoney(value float64) float64 {
return money.Round(value)
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return math.Round(value*10) / 10
}
+15 -7
View File
@@ -8,7 +8,6 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/modules/rentergrowth"
"hfb_sys/backend/pkg/money"
"gorm.io/datatypes"
)
@@ -486,23 +485,32 @@ func readOrderSnapshotAssetNumber(raw datatypes.JSON, key string) float64 {
return readJSONNumber(snapshot[key])
}
// roundMoney 使用统一的角精度(0.1元)
// roundMoney 是订单历史链路的角精度兼容实现;新金额链路应直接使用整数分。
func roundMoney(value float64) float64 {
return money.Round(value)
if value <= 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return 0
}
return math.Round(value*10) / 10
}
func roundQuantity(value float64) float64 {
return math.Round(value*100) / 100
}
// minMoney 返回较小金额(角精度)
// minMoney 返回较小金额(角精度兼容)。
func minMoney(a float64, b float64) float64 {
return money.Min(a, b)
if a < b {
return a
}
return b
}
// maxMoney 返回较大金额(角精度)
// maxMoney 返回较大金额(角精度兼容)。
func maxMoney(a float64, b float64) float64 {
return money.Max(a, b)
if a > b {
return a
}
return b
}
func minCent(a int64, b int64) int64 {
@@ -10,7 +10,6 @@ import (
"hfb_sys/backend/internal/model"
"hfb_sys/backend/internal/timeutil"
"hfb_sys/backend/pkg/money"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -264,10 +263,6 @@ func applyEntry(account *model.WalletAccount, entry Entry) (int64, error) {
}
}
func roundWalletMoney(yuan float64) float64 {
return money.Round(yuan)
}
func toAccountDTO(account model.WalletAccount) *AccountDTO {
return &AccountDTO{
UserID: account.UserID,
@@ -53,10 +53,6 @@ func TestApplyEntryKeepsWalletMoneyAtJiaoPrecision(t *testing.T) {
if account.AvailableBalanceCent != 230 {
t.Fatalf("AvailableBalanceCent = %d, want 230", account.AvailableBalanceCent)
}
if rounded := roundWalletMoney(2.36); rounded != 2.4 {
t.Fatalf("roundWalletMoney(2.36) = %v, want 2.4", rounded)
}
}
func TestNewLedgerNoUsesReadableFormat(t *testing.T) {
+65 -41
View File
@@ -1,58 +1,83 @@
package money
import (
"errors"
"fmt"
"math"
"strconv"
"strings"
)
// Round 将金额四舍五入到角(0.1元),统一全项目金额精度
// 例如:12.34 -> 12.3, 12.36 -> 12.4, 12.35 -> 12.4
func Round(value float64) float64 {
return math.Round(value*10) / 10
// CentsPerYuan 是人民币元与分的固定换算比例。
const CentsPerYuan int64 = 100
var ErrInvalidYuan = errors.New("金额格式不正确")
// ParseYuan 将用户输入的元字符串精确解析为分,不经过 float64。
// 支持 "12"、"12.3"、"12.34"、"-1.20";超过两位小数直接拒绝。
func ParseYuan(value string) (int64, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return 0, ErrInvalidYuan
}
sign := int64(1)
if strings.HasPrefix(trimmed, "-") {
sign = -1
trimmed = strings.TrimPrefix(trimmed, "-")
} else if strings.HasPrefix(trimmed, "+") {
trimmed = strings.TrimPrefix(trimmed, "+")
}
if trimmed == "" {
return 0, ErrInvalidYuan
}
parts := strings.Split(trimmed, ".")
if len(parts) > 2 {
return 0, ErrInvalidYuan
}
wholeText := parts[0]
if wholeText == "" {
wholeText = "0"
}
whole, err := strconv.ParseInt(wholeText, 10, 64)
if err != nil || whole < 0 {
return 0, ErrInvalidYuan
}
fraction := int64(0)
if len(parts) == 2 {
fracText := parts[1]
if len(fracText) > 2 {
return 0, ErrInvalidYuan
}
fracText = fracText + strings.Repeat("0", 2-len(fracText))
if fracText == "" {
fracText = "00"
}
fraction, err = strconv.ParseInt(fracText, 10, 64)
if err != nil || fraction < 0 || fraction >= CentsPerYuan {
return 0, ErrInvalidYuan
}
}
if whole > (1<<62)/CentsPerYuan {
return 0, ErrInvalidYuan
}
return sign * (whole*CentsPerYuan + fraction), nil
}
// ToCent 将元转换为分(整数),用于存储。
// 12.34 元 -> 1234 分
func ToCent(yuan float64) int64 {
return int64(math.Round(yuan * 100))
}
// ToDisplayYuan 将分转换为按角精度展示的元值。
// 1234 分 -> 12.3 元
func ToDisplayYuan(cent int64) float64 {
return Round(float64(cent) / 100)
}
// Format 格式化分为字符串(角精度,保留1位小数)
// 1234 分 -> "12.3"
// 1230 分 -> "12.3"
// Format 将分精确格式化为两位小数,例如 1234 -> "12.34"。
// 全程使用整数运算,不经过 float64,也不做四舍五入。
func Format(cent int64) string {
return fmt.Sprintf("%.1f", ToDisplayYuan(cent))
sign := ""
value := cent
if value < 0 {
sign = "-"
value = -value
}
return fmt.Sprintf("%s%d.%02d", sign, value/CentsPerYuan, value%CentsPerYuan)
}
// FormatWithSymbol 格式化分为带符号的字符串
// 123分 -> "¥12.3"
func FormatWithSymbol(cent int64) string {
return "¥" + Format(cent)
}
// Min 返回两个金额中较小的值(角精度,兼容旧代码)
func Min(a, b float64) float64 {
if a < b {
return Round(a)
}
return Round(b)
}
// Max 返回两个金额中较大的值(角精度,兼容旧代码)
func Max(a, b float64) float64 {
if a > b {
return Round(a)
}
return Round(b)
}
// MinCent 返回两个分值中较小的值
func MinCent(a, b int64) int64 {
if a < b {
return a
@@ -60,7 +85,6 @@ func MinCent(a, b int64) int64 {
return b
}
// MaxCent 返回两个分值中较大的值
func MaxCent(a, b int64) int64 {
if a > b {
return a
+35 -119
View File
@@ -2,49 +2,37 @@ package money
import "testing"
func TestToCent(t *testing.T) {
func TestParseYuan(t *testing.T) {
tests := []struct {
name string
yuan float64
want int64
name string
value string
want int64
}{
{"12.3元转1230分", 12.3, 1230},
{"12.34元转1234分", 12.34, 1234},
{"12.36元转1236分", 12.36, 1236},
{"0.1元转10分", 0.1, 10},
{"100元转10000分", 100.0, 10000},
{"零值", 0.0, 0},
{"整数", "100", 10000},
{"一位小数", "12.3", 1230},
{"两位小数", "12.34", 1234},
{"前导零", "0.05", 5},
{"负金额", "-1.20", -120},
{"正号", "+0.1", 10},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ToCent(tt.yuan)
got, err := ParseYuan(tt.value)
if err != nil {
t.Fatalf("ParseYuan(%q) error: %v", tt.value, err)
}
if got != tt.want {
t.Errorf("ToCent(%v) = %v, want %v", tt.yuan, got, tt.want)
t.Fatalf("ParseYuan(%q) = %d, want %d", tt.value, got, tt.want)
}
})
}
}
func TestToDisplayYuan(t *testing.T) {
tests := []struct {
name string
cent int64
want float64
}{
{"123分展示1.2元", 123, 1.2},
{"1234分展示12.3元", 1234, 12.3},
{"1236分展示12.4元", 1236, 12.4},
{"10分展示0.1元", 10, 0.1},
{"1000分展示10.0元", 1000, 10.0},
{"零值", 0, 0.0},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := ToDisplayYuan(tt.cent)
if got != tt.want {
t.Errorf("ToDisplayYuan(%v) = %v, want %v", tt.cent, got, tt.want)
}
})
func TestParseYuanRejectsInvalid(t *testing.T) {
for _, value := range []string{"", "12.345", "abc", "1.2.3", "1.-2"} {
if _, err := ParseYuan(value); err == nil {
t.Fatalf("ParseYuan(%q) 应返回错误", value)
}
}
}
@@ -54,105 +42,33 @@ func TestFormat(t *testing.T) {
cent int64
want string
}{
{"123分格式化", 123, "1.2"},
{"1234分格式化", 1234, "12.3"},
{"1236分格式化", 1236, "12.4"},
{"10分格式化", 10, "0.1"},
{"1000分格式化", 1000, "10.0"},
{"零值格式化", 0, "0.0"},
{"两位小数", 1234, "12.34"},
{"末尾零", 1230, "12.30"},
{"五分", 5, "0.05"},
{"整数", 10000, "100.00"},
{"负数", -120, "-1.20"},
{"零值", 0, "0.00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Format(tt.cent)
if got != tt.want {
t.Errorf("Format(%v) = %v, want %v", tt.cent, got, tt.want)
if got := Format(tt.cent); got != tt.want {
t.Fatalf("Format(%d) = %q, want %q", tt.cent, got, tt.want)
}
})
}
}
func TestFormatWithSymbol(t *testing.T) {
tests := []struct {
name string
cent int64
want string
}{
{"123分带符号", 123, "¥1.2"},
{"1000分带符号", 1000, "¥10.0"},
{"零值带符号", 0, "¥0.0"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := FormatWithSymbol(tt.cent)
if got != tt.want {
t.Errorf("FormatWithSymbol(%v) = %v, want %v", tt.cent, got, tt.want)
}
})
if got := FormatWithSymbol(1234); got != "¥12.34" {
t.Fatalf("FormatWithSymbol(1234) = %q", got)
}
}
func TestRound(t *testing.T) {
tests := []struct {
name string
value float64
want float64
}{
{"12.34舍入到12.3", 12.34, 12.3},
{"12.36舍入到12.4", 12.36, 12.4},
{"12.35舍入到12.4", 12.35, 12.4},
{"0.05舍入到0.1", 0.05, 0.1},
{"0.04舍入到0.0", 0.04, 0.0},
func TestMinMaxCent(t *testing.T) {
if got := MinCent(100, 200); got != 100 {
t.Fatalf("MinCent = %d", got)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := Round(tt.value)
if got != tt.want {
t.Errorf("Round(%v) = %v, want %v", tt.value, got, tt.want)
}
})
}
}
func TestMinMax(t *testing.T) {
t.Run("Min", func(t *testing.T) {
if got := Min(100, 200); got != 100 {
t.Errorf("Min(100, 200) = %v, want 100", got)
}
if got := Min(200, 100); got != 100 {
t.Errorf("Min(200, 100) = %v, want 100", got)
}
})
t.Run("Max", func(t *testing.T) {
if got := Max(100, 200); got != 200 {
t.Errorf("Max(100, 200) = %v, want 200", got)
}
if got := Max(200, 100); got != 200 {
t.Errorf("Max(200, 100) = %v, want 200", got)
}
})
}
// 测试双向转换的精度
func TestRoundTrip(t *testing.T) {
tests := []struct {
name string
originalYuan float64
expectDisplay float64
}{
{"12.3元往返", 12.3, 12.3},
{"12.34元往返(展示到角)", 12.34, 12.3},
{"100.0元往返", 100.0, 100.0},
{"0.1元往返", 0.1, 0.1},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cent := ToCent(tt.originalYuan)
got := ToDisplayYuan(cent)
if got != tt.expectDisplay {
t.Errorf("往返转换:%v元 -> %v分 -> %v元, 期望 %v元",
tt.originalYuan, cent, got, tt.expectDisplay)
}
})
if got := MaxCent(100, 200); got != 200 {
t.Fatalf("MaxCent = %d", got)
}
}
@@ -0,0 +1,35 @@
# 金额分与精度统一(第 1 步)验收
> 日期:2026-10-30
> 范围:`backend/pkg/money` 去浮点、精确分解析与格式化。
## 1. 本次完成
- `backend/pkg/money/format.go`
- 新增 `ParseYuan(string) (int64, error)`:按字符串精确解析元到分,不经过 `float64`。
- `Format(cent)` 改为精确两位小数,整数除法和余数补零,不再四舍五入到角。
- `FormatWithSymbol` 复用精确格式化。
- 保留 `MinCent`、`MaxCent`。
- 移除 `Round`、`ToCent`、`ToDisplayYuan`、`Min/Max` 等浮点 API。
- `backend/pkg/money/format_test.go`
- 覆盖整数、一位/两位小数、负数、非法输入。
- 覆盖精确格式化与末位零保留。
- 历史链路兼容
- 钱包移除未使用的 `roundWalletMoney` 浮点封装。
- 订单与商品外部上传/审核链路保留局部角精度兼容函数,不再依赖 `money` 包浮点 API;后续按业务链路继续迁移。
## 2. 验证
| 检查 | 结果 |
|---|---|
| `go build ./...` | 通过 |
| `go test ./pkg/money` | 通过 |
| `go test ./internal/modules/wallet` | 通过 |
| `go test ./internal/modules/order` | 通过 |
| `go test ./internal/modules/listing` | 通过 |
## 3. 边界
- 第 1 步只统一了金额工具包;订单历史链路仍保留 0.1 元兼容逻辑,避免一次性改变结算结果。
- 第 2 步(发布定价整数分/基点、旧字段双读、前端同步)尚未包含在本次提交中。
- `Format` 现在输出两位小数,历史消息中依赖一位小数的文案会显示为 `12.30` 这类格式,这是有意修正。