feat(价格展示): 避免低价资产被格式化丢失为 0.00
Motivation: 交易通知等展示场景中,低价资产(如 0.00000059)按保留两位小数的格式渲染会被截断为 0.00,用户无法读取真实价格;需要在不改变存量数值类型和既有模板写法的前提下,保证任意精度价格都能正确展示。 Changes: * 新增价格展示格式化能力:数值不小于 1 时保留两位小数,小于 1 时输出最短精确值 * 新增 Decimal 类型实现自定义格式化,使模板中的浮点值不再坍缩为 0.00 且不产生科学计数法 * 新增模板数据渲染期数值包装能力,不修改原始数据,保证条件判断与既有模板语法兼容 * 补充价格格式化、精度保留及数据不可变性的单元测试
This commit is contained in:
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user