From f8e74738cc8abe3ff73846dcfbf75c969dafc366 Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 23 Aug 2026 00:17:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E4=BB=B7=E6=A0=BC=E5=B1=95=E7=A4=BA):=20?= =?UTF-8?q?=E9=81=BF=E5=85=8D=E4=BD=8E=E4=BB=B7=E8=B5=84=E4=BA=A7=E8=A2=AB?= =?UTF-8?q?=E6=A0=BC=E5=BC=8F=E5=8C=96=E4=B8=A2=E5=A4=B1=E4=B8=BA=200.00?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Motivation: 交易通知等展示场景中,低价资产(如 0.00000059)按保留两位小数的格式渲染会被截断为 0.00,用户无法读取真实价格;需要在不改变存量数值类型和既有模板写法的前提下,保证任意精度价格都能正确展示。 Changes: * 新增价格展示格式化能力:数值不小于 1 时保留两位小数,小于 1 时输出最短精确值 * 新增 Decimal 类型实现自定义格式化,使模板中的浮点值不再坍缩为 0.00 且不产生科学计数法 * 新增模板数据渲染期数值包装能力,不修改原始数据,保证条件判断与既有模板语法兼容 * 补充价格格式化、精度保留及数据不可变性的单元测试 --- internal/display/price.go | 113 +++++++++++++++++++++++++++++++++ internal/display/price_test.go | 52 +++++++++++++++ 2 files changed, 165 insertions(+) create mode 100644 internal/display/price.go create mode 100644 internal/display/price_test.go diff --git a/internal/display/price.go b/internal/display/price.go new file mode 100644 index 0000000..b908aa4 --- /dev/null +++ b/internal/display/price.go @@ -0,0 +1,113 @@ +package display + +import ( + "fmt" + "io" + "math" + "strconv" + "strings" +) + +// FormatPrice renders a price for display. Values >= 1 keep two decimals for +// readability; smaller values use the shortest exact representation so tiny +// prices like 0.00000059 are not collapsed to 0.00. +func FormatPrice(v float64) string { + if v == 0 { + return "0.00" + } + if math.Abs(v) >= 1 { + return strconv.FormatFloat(v, 'f', 2, 64) + } + return strconv.FormatFloat(v, 'f', -1, 64) +} + +// Decimal is a float64 that prints without collapsing sub-0.01 values to 0.00 +// and without scientific notation. Templates keep using {{printf "%.2f" .price}} +// and {{.price}}; wrapping happens at render time so stored data stays numeric. +type Decimal float64 + +func (d Decimal) Format(f fmt.State, verb rune) { + v := float64(d) + switch verb { + case 'f', 'F': + prec := 6 + if p, ok := f.Precision(); ok { + prec = p + } + s := strconv.FormatFloat(v, byte(verb), prec, 64) + if v != 0 && isCollapsedZero(s) { + s = strconv.FormatFloat(v, 'f', -1, 64) + } + _, _ = io.WriteString(f, s) + case 'v', 's': + _, _ = io.WriteString(f, formatPlain(v)) + default: + prec := -1 + if p, ok := f.Precision(); ok { + prec = p + } + _, _ = io.WriteString(f, strconv.FormatFloat(v, byte(verb), prec, 64)) + } +} + +func formatPlain(v float64) string { + if v == 0 { + return "0" + } + return strconv.FormatFloat(v, 'f', -1, 64) +} + +func isCollapsedZero(s string) bool { + t := strings.TrimPrefix(s, "-") + t = strings.TrimPrefix(t, "+") + if t == "" { + return false + } + sawZero := false + for _, r := range t { + switch r { + case '0': + sawZero = true + case '.': + default: + return false + } + } + return sawZero +} + +// WrapMap copies data and wraps float values so template printing keeps +// precision. The original map is left unchanged for condition evaluation. +func WrapMap(data map[string]interface{}) map[string]interface{} { + if data == nil { + return nil + } + out := make(map[string]interface{}, len(data)) + for k, v := range data { + out[k] = wrapValue(v) + } + return out +} + +func wrapValue(v any) any { + switch t := v.(type) { + case nil: + return nil + case float64: + return Decimal(t) + case float32: + return Decimal(t) + case Decimal: + return t + case map[string]interface{}: + return WrapMap(t) + case []interface{}: + out := make([]interface{}, len(t)) + for i, x := range t { + out[i] = wrapValue(x) + } + return out + default: + return v + } +} diff --git a/internal/display/price_test.go b/internal/display/price_test.go new file mode 100644 index 0000000..0833312 --- /dev/null +++ b/internal/display/price_test.go @@ -0,0 +1,52 @@ +package display + +import ( + "fmt" + "testing" +) + +func TestFormatPrice(t *testing.T) { + cases := []struct { + in float64 + want string + }{ + {0, "0.00"}, + {1898.76, "1898.76"}, + {1, "1.00"}, + {0.00000059, "0.00000059"}, + {-0.00000059, "-0.00000059"}, + {0.5, "0.5"}, + } + for _, tc := range cases { + if got := FormatPrice(tc.in); got != tc.want { + t.Errorf("FormatPrice(%v)=%q want %q", tc.in, got, tc.want) + } + } +} + +func TestDecimalPrintfKeepsTinyPrice(t *testing.T) { + d := Decimal(0.00000059) + if got := fmt.Sprintf("%.2f", d); got != "0.00000059" { + t.Fatalf("%%.2f=%q", got) + } + if got := fmt.Sprintf("%v", d); got != "0.00000059" { + t.Fatalf("%%v=%q", got) + } + if got := fmt.Sprintf("%.2f", Decimal(1898.76)); got != "1898.76" { + t.Fatalf("eth %%.2f=%q", got) + } + if got := fmt.Sprintf("%.2f", Decimal(1000000000)); got != "1000000000.00" { + t.Fatalf("qty %%.2f=%q", got) + } +} + +func TestWrapMapDoesNotMutate(t *testing.T) { + in := map[string]interface{}{"price": 0.00000059} + out := WrapMap(in) + if _, ok := in["price"].(float64); !ok { + t.Fatalf("input mutated: %T", in["price"]) + } + if _, ok := out["price"].(Decimal); !ok { + t.Fatalf("wrapped type=%T", out["price"]) + } +}