《互联网产品的服务端架构·交易、增长与平台化》11.1 语言、时区与货币

会员订阅卖出 19.90 美元,退款时却出现 19.899999。文章用一个跨币种结算的场景,说明金额为什么必须存最小单位、locale 与 currency 为什么是两个维度、时区为什么只影响展示,并给出 Money、MinorUnits、Format 的 Go 实现、负数与币种精度测试,以及真实运行输出。

一笔 19.90 美元的退款,变成了 19.899999

「刚好」是一个面向独立开发者的订阅工具。用户用美元付款,月费 19.90 美元,支持随时退款。某个周一,财务在退款流水里看到一笔 19.899999999999999 的退款,和原订单差了不到一厘钱。金额不大,但对账脚本按“退款合计等于原订单”做校验,这条记录直接把当天的对账任务卡住了。

研发先怀疑是支付渠道的问题,拉出渠道回执,渠道返回的退款金额是 1990 分,没有问题。问题出在自家服务:订单金额被存成了 float64,计算退款时先除以 30 再乘以剩余天数,浮点误差在中间步骤累积,最后写回数据库时就变成了 19.899999999999999。

同一天还有第二个投诉。一位在德国的用户把界面语言切成中文,看到价格显示为 $19.90,但他实际支付的是欧元账户扣款后的美元账单;客服解释说“你看到的是美元”,用户更困惑了:为什么中文界面不能显示人民币?这两个问题看起来都关于“国际化”,其实是三条不同的轴:语言、区域格式、货币。把它们混在一起,代码就会在某个边界上给出错误答案。

金额、locale 与币种是三个独立维度

先把概念拆开。语言决定文案,locale 决定数字和日期的呈现格式,币种决定金额本身的单位与精度。三者可以任意组合:中文界面可以标美元,英文界面可以标日元,德国 locale 可以用逗号做小数点。服务端不能假设“用户说中文就一定用人民币”。

做法存储是否精确跨币种是否安全展示是否可控典型问题
float64 存金额否否否0.1+0.2 不等于 0.3,退款出现长尾小数
字符串存 "19.90"是部分否参与计算要反复解析,比较和汇总容易出错
最小单位整数是是是必须显式记录币种与精度
整数 + 固定两位是否部分日元没有小数位,固定两位会多出 100 倍
整数 + 币种精度表是是是新增币种要维护精度,未知币种要报错

表里最后一行才是本章采用的模型:金额存 int64 的最小单位,币种单独存三位代码,格式化时再查精度表。人民币和美元的最小单位是分,日元是元,这个差异不能靠“统一乘 100”糊弄过去。

时区是第四条轴,但它只影响展示。订单的创建时间应该以 UTC 或带偏移的时间戳存储,展示时再按用户时区渲染。把本地时间直接存进数据库,会在夏令时切换、跨区查询和导出报表时制造重复或缺失的小时。本章的代码不处理时区,但后面讨论多区域部署时会反复遇到它。

失败场景:浮点金额在退款链路里累积误差

回到开头的事故。订单服务用 float64 存金额,退款服务按比例计算:refund := amount / float64(totalDays) * float64(usedDays)。单看一次计算,误差在小数点后十五位,打印出来是 19.899999999999999。财务系统把它四舍五入到两位,显示成 19.90,看起来没问题;但对账脚本读的是原始值,和渠道回执的 1990 分比较时不相等,任务失败。

更隐蔽的是汇总。日报把一万笔退款相加,每笔差几十亿分之一,累计误差可能让总额差出几分钱。排查时没人会怀疑加法,只会怀疑渠道少退了。修复方式不是“显示时四舍五入”,而是从写入那一刻起就用整数最小单位:1990 就是 1990,退款按整数分配,余数显式落到某一方。

还有一类错误发生在币种边界。商品目录用美元定价,结算时按汇率换成人民币,代码把 1990 直接当成人民币分处理,结果用户被扣了 19.90 元而不是约 145 元。金额的数值和币种必须一起传递,不能只传一个 int64 让下游猜。

用 Money、MinorUnits 与 Format 固定口径

代码把“金额是什么”和“金额怎么显示”分开。Money 同时携带最小单位数值和币种;MinorUnits 是币种精度的唯一查询入口;Format 只负责把两者渲染成字符串,不做任何汇率换算。

// file: internal/i18n/locale.go
// tier: L1
// verify: go test ./internal/i18n/

package i18n

import (
	"errors"
	"fmt"
	"strings"
)

// Money 表示一笔金额。AmountMinor 以最小货币单位存储:人民币和美元是分,
// 日元是元。金额永远不用 float64 表示,避免 0.1+0.2 之类的表示误差。
type Money struct {
	AmountMinor int64
	Currency    string
}

var (
	// ErrUnknownCurrency 表示币种不在支持列表内。
	ErrUnknownCurrency = errors.New("i18n: unknown currency")
	// ErrUnknownLocale 表示 locale 不在支持列表内。
	ErrUnknownLocale = errors.New("i18n: unknown locale")
	// ErrInvalidCurrency 表示币种不是三位大写字母。
	ErrInvalidCurrency = errors.New("i18n: invalid currency code")
)

type currencyInfo struct {
	minor  int
	symbol string
}

var currencies = map[string]currencyInfo{
	"CNY": {minor: 2, symbol: "CN¥"},
	"USD": {minor: 2, symbol: "$"},
	"JPY": {minor: 0, symbol: "¥"},
}

// localeInfo 只描述“怎么显示”,不决定“显示什么货币”。
// 同一个 USD 金额可以在 en-US 和 de-DE 下显示成不同的数字格式。
type localeInfo struct {
	suffix bool   // 货币符号是否放在数字后面,例如 de-DE。
	group  string // 千位分隔符。
	point  string // 小数点。
}

var locales = map[string]localeInfo{
	"zh-CN": {group: ",", point: "."},
	"en-US": {group: ",", point: "."},
	"ja-JP": {group: ",", point: "."},
	"de-DE": {suffix: true, group: ".", point: ","},
}

// MinorUnits 返回币种的最小单位位数。CNY 和 USD 是 2(分),JPY 是 0(元)。
func MinorUnits(currency string) (int, error) {
	if len(currency) != 3 {
		return 0, fmt.Errorf("%w: %q", ErrInvalidCurrency, currency)
	}
	for _, r := range currency {
		if r < 'A' || r > 'Z' {
			return 0, fmt.Errorf("%w: %q", ErrInvalidCurrency, currency)
		}
	}
	info, ok := currencies[currency]
	if !ok {
		return 0, fmt.Errorf("%w: %q", ErrUnknownCurrency, currency)
	}
	return info.minor, nil
}

// Format 把最小单位金额格式化成带符号与小数位的字符串。
// locale 只影响千位分隔符、小数点位置和货币符号前后;
// 币种决定小数位数,两者是彼此独立的两个维度。
func Format(m Money, locale string) (string, error) {
	if _, err := MinorUnits(m.Currency); err != nil {
		return "", err
	}
	loc, ok := locales[locale]
	if !ok {
		return "", fmt.Errorf("%w: %q", ErrUnknownLocale, locale)
	}
	info := currencies[m.Currency]

	sign := ""
	var amount uint64
	if m.AmountMinor < 0 {
		sign = "-"
		// 先加一再取反,避免 math.MinInt64 直接取反溢出。
		amount = uint64(-(m.AmountMinor + 1)) + 1
	} else {
		amount = uint64(m.AmountMinor)
	}

	scale := uint64(1)
	for i := 0; i < info.minor; i++ {
		scale *= 10
	}
	whole := amount / scale
	frac := amount % scale

	digits := groupDigits(whole, loc.group)
	if info.minor > 0 {
		digits += loc.point + leftPad(frac, info.minor)
	}
	if loc.suffix {
		return sign + digits + " " + info.symbol, nil
	}
	return sign + info.symbol + digits, nil
}

func groupDigits(v uint64, sep string) string {
	s := fmt.Sprintf("%d", v)
	if sep == "" {
		return s
	}
	var b strings.Builder
	for i, r := range s {
		if i > 0 && (len(s)-i)%3 == 0 {
			b.WriteString(sep)
		}
		b.WriteRune(r)
	}
	return b.String()
}

func leftPad(v uint64, width int) string {
	s := fmt.Sprintf("%d", v)
	if len(s) >= width {
		return s
	}
	return strings.Repeat("0", width-len(s)) + s
}

实现里有几个刻意的选择:

  • AmountMinor 用 int64,不用 float64。最小单位是整数,加减和比较都精确;负数是合法输入,表示退款或冲正。
  • MinorUnits 先校验长度和字符,再查表。"cny"、"C1Y"、"CN" 都会报 ErrInvalidCurrency,不会悄悄转成大写后继续。
  • 币种表把符号和精度放在一起,新增币种只改一处。日元精度是 0,格式化时不会补 .00。
  • Format 先校验币种再校验 locale。未知币种返回 ErrUnknownCurrency,未知 locale 返回 ErrUnknownLocale,调用方能区分是哪一类配置错误。
  • locale 只影响分组符、小数点位置和符号前后。de-DE 把符号放到数字后面,使用 . 分组、, 作小数点;同一个美元金额因此有两种合法写法。
  • 负数处理先把 MinInt64 加一取反,避免直接取反溢出。这个边界平时遇不到,但账务系统里“全额退款”很容易撞上极端值。

Format 不接收汇率,也不接收时区。汇率是交易链路的事,时区是时间展示的事,把它们塞进格式化函数会让单元测试变得不可控。一个函数只做一件事,出错时才知道该查哪里。

测试:精度、负数与 locale 的交叉组合

测试覆盖三类输入:已知币种的精度、非法币种代码、以及同一金额在不同 locale 下的输出。金额用例里特意放了负数和零,因为退款和赠品都会走到这两个分支。

// file: internal/i18n/locale_test.go
// tier: L1
// verify: go test ./internal/i18n/

package i18n

import (
	"errors"
	"testing"
)

func TestMinorUnits(t *testing.T) {
	cases := []struct {
		currency string
		want     int
	}{
		{"CNY", 2},
		{"USD", 2},
		{"JPY", 0},
	}
	for _, c := range cases {
		got, err := MinorUnits(c.currency)
		if err != nil {
			t.Fatalf("MinorUnits(%q) error = %v", c.currency, err)
		}
		if got != c.want {
			t.Fatalf("MinorUnits(%q) = %d, want %d", c.currency, got, c.want)
		}
	}
}

func TestMinorUnitsRejectsBadCode(t *testing.T) {
	cases := []struct {
		currency string
		want     error
	}{
		{"EUR", ErrUnknownCurrency},
		{"cny", ErrInvalidCurrency},
		{"C1Y", ErrInvalidCurrency},
		{"CN", ErrInvalidCurrency},
	}
	for _, c := range cases {
		if _, err := MinorUnits(c.currency); !errors.Is(err, c.want) {
			t.Fatalf("MinorUnits(%q) = %v, want %v", c.currency, err, c.want)
		}
	}
}

func TestFormat(t *testing.T) {
	cases := []struct {
		name   string
		money  Money
		locale string
		want   string
	}{
		{"cny", Money{AmountMinor: 123456, Currency: "CNY"}, "zh-CN", "CN¥1,234.56"},
		{"usd", Money{AmountMinor: 5, Currency: "USD"}, "en-US", "$0.05"},
		{"jpy-no-decimals", Money{AmountMinor: 1234, Currency: "JPY"}, "ja-JP", "¥1,234"},
		{"refund", Money{AmountMinor: -987, Currency: "USD"}, "en-US", "-$9.87"},
		{"suffix-locale", Money{AmountMinor: 123456, Currency: "USD"}, "de-DE", "1.234,56 $"},
		{"zero", Money{AmountMinor: 0, Currency: "JPY"}, "en-US", "¥0"},
	}
	for _, c := range cases {
		got, err := Format(c.money, c.locale)
		if err != nil {
			t.Fatalf("%s: Format error = %v", c.name, err)
		}
		if got != c.want {
			t.Fatalf("%s: Format = %q, want %q", c.name, got, c.want)
		}
	}
}

func TestFormatRejectsUnknown(t *testing.T) {
	if _, err := Format(Money{Currency: "EUR"}, "en-US"); !errors.Is(err, ErrUnknownCurrency) {
		t.Fatalf("EUR error = %v, want ErrUnknownCurrency", err)
	}
	if _, err := Format(Money{Currency: "USD"}, "fr-FR"); !errors.Is(err, ErrUnknownLocale) {
		t.Fatalf("fr-FR error = %v, want ErrUnknownLocale", err)
	}
	if _, err := Format(Money{Currency: "cny"}, "zh-CN"); !errors.Is(err, ErrInvalidCurrency) {
		t.Fatalf("lowercase error = %v, want ErrInvalidCurrency", err)
	}
}

func TestFormatKeepsFractionDigits(t *testing.T) {
	got, err := Format(Money{AmountMinor: 100, Currency: "CNY"}, "zh-CN")
	if err != nil {
		t.Fatalf("Format error = %v", err)
	}
	if got != "CN¥1.00" {
		t.Fatalf("Format = %q, want CN¥1.00", got)
	}
}

TestMinorUnits 断言 CNY/USD 返回 2、JPY 返回 0。TestMinorUnitsRejectsBadCode 把未知币种和格式错误分开:EUR 是合法代码但不在支持列表,cny 和 C1Y 连格式都不合法。TestFormat 用六个用例覆盖人民币、美元、日元、退款、德语 locale 和零值。TestFormatRejectsUnknown 确认未知币种与未知 locale 各自返回可识别的错误。TestFormatKeepsFractionDigits 专门盯住 100 分必须显示成 CN¥1.00,而不是 CN¥1。

这些测试不依赖当前时区、汇率或系统 locale,因此在 CI 里可以稳定重复。真正需要集成测试的是“金额从下单到退款是否始终以最小单位传递”,那属于交易链路的测试,不在这个包的范围内。

运行命令与真实输出

在仓库根目录执行:

cd .local/checks/webapp-lab && go test ./internal/i18n/ -run 'TestMinorUnits|TestFormat' -count=1 -v

一次本机运行的真实输出如下,耗时随机器变化:

=== RUN   TestMinorUnits
--- PASS: TestMinorUnits (0.00s)
=== RUN   TestMinorUnitsRejectsBadCode
--- PASS: TestMinorUnitsRejectsBadCode (0.00s)
=== RUN   TestFormat
--- PASS: TestFormat (0.00s)
=== RUN   TestFormatRejectsUnknown
--- PASS: TestFormatRejectsUnknown (0.00s)
=== RUN   TestFormatKeepsFractionDigits
--- PASS: TestFormatKeepsFractionDigits (0.00s)
PASS
ok  	webapp/internal/i18n	0.226s

PASS 表示 5 个金额与格式化用例全部通过。它证明的是本章这套简化模型:整数存储、币种精度查表、locale 只做渲染。

小结

金额永远以最小单位整数存储,币种随金额一起传递,格式化时才查精度并套用 locale。语言、locale、币种、时区是四条独立的轴,任何一条被当成另一条的代理,都会在跨币种或跨区场景里出错。下一节讨论一个更强的约束:数据本身能不能离开某个区域。

阅读导航:上一节:目录与学习路径 · 下一节:11.2 数据驻留与合规 。

常见错误与边界

  • 用 float64 存金额。单笔看起来正常,汇总或按比例退款时会出现长尾小数,对账脚本首当其冲。
  • 把金额统一乘 100。日元没有小数位,乘 100 后数值放大一百倍,展示和扣款都会错。
  • 只传数值不传币种。下游只能猜,猜错就是把 19.90 美元当成 19.90 元人民币。
  • 用 locale 推断币种。中文界面不等于人民币,德语界面也不等于欧元,locale 和 currency 必须分别传递。
  • 未知币种静默回退到两位小数。新增币种时错误会被掩盖,直到财务发现金额差了两个数量级。
  • 用本地时区存时间。夏令时切换会出现重复小时,跨区报表无法对齐。
  • 只在展示层四舍五入。存储里的误差仍然存在,参与后续计算时会继续放大。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《互联网产品的 AI 架构·平台化与治理》附录 C 平台化评审清单
  2. 《互联网产品的 AI 架构·平台化与治理》附录 B 能力目录与配额字段速查
  3. 《互联网产品的 AI 架构·平台化与治理》附录 A 治理角色与职责速查