diff --git a/backend/internal/modules/listing/presenter.go b/backend/internal/modules/listing/presenter.go index 3756c05..6524976 100644 --- a/backend/internal/modules/listing/presenter.go +++ b/backend/internal/modules/listing/presenter.go @@ -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 } diff --git a/backend/internal/modules/order/pricing.go b/backend/internal/modules/order/pricing.go index c26dc4f..d1f637c 100644 --- a/backend/internal/modules/order/pricing.go +++ b/backend/internal/modules/order/pricing.go @@ -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 { diff --git a/backend/internal/modules/wallet/repository.go b/backend/internal/modules/wallet/repository.go index c4197e4..6cd5cb5 100644 --- a/backend/internal/modules/wallet/repository.go +++ b/backend/internal/modules/wallet/repository.go @@ -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, diff --git a/backend/internal/modules/wallet/repository_test.go b/backend/internal/modules/wallet/repository_test.go index eca6021..dfb2c1d 100644 --- a/backend/internal/modules/wallet/repository_test.go +++ b/backend/internal/modules/wallet/repository_test.go @@ -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) { diff --git a/backend/pkg/money/format.go b/backend/pkg/money/format.go index 98e0ec8..606afb9 100644 --- a/backend/pkg/money/format.go +++ b/backend/pkg/money/format.go @@ -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 diff --git a/backend/pkg/money/format_test.go b/backend/pkg/money/format_test.go index 4489c99..a8fc93f 100644 --- a/backend/pkg/money/format_test.go +++ b/backend/pkg/money/format_test.go @@ -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) } } diff --git a/docs/Phase2-金额分与精度统一验收.md b/docs/Phase2-金额分与精度统一验收.md new file mode 100644 index 0000000..66188cd --- /dev/null +++ b/docs/Phase2-金额分与精度统一验收.md @@ -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` 这类格式,这是有意修正。