圣保罗的凌晨,莫斯科的退款
2026 年 11 月 1 日 00:30,圣保罗的用户下了一笔订单。服务端用 UTC 存下 2026-11-01T03:30:00Z,客户端按 America/Sao_Paulo 展示为「11 月 1 日 00:30」。同一时刻,莫斯科的客服看到的却是「11 月 1 日 06:30」。两个时间都对,因为它们是同一个瞬间在不同区域的墙上时间。
问题出在退款页。财务导出 CSV 时把时间写成了 2026-11-01 00:30,没有时区。另一个系统按本地时区解析,结果这笔退款被记到了 10 月 31 日。日终对账少了一笔,值班同学查了三个小时。
同一笔订单还暴露了两个问题。商品文案在俄语里显示成「3 товара」,开发同学用 count === 1 ? "товар" : "товаров" 判断,5 件时变成了错误的「5 товара」。优惠券金额 10.00 美元按三件商品分摊,代码用浮点数除以三再四舍五入,三份加起来是 9.99,账上少了 1 美分。
这三个故障没有一个是「翻译没做完」。它们分别是文案的复数规则、时间的存储口径、金额的最小单位。国际化不是把中文换成英文,而是把这三层各自的表示形式固定下来。
本节的所有区域、时区与金额数字都是为说明问题构造的示例,不是对本机环境的实测。代码只依赖 Node 内置的 Intl,不引入任何第三方库。
三层模型:文案、时间、金额
先看一张表。国际化的核心不是语言包数量,而是每一层「存什么、传什么、展示什么」。
| 层 | 存储形态 | 传输形态 | 展示形态 | 典型故障 |
|---|---|---|---|---|
| 文案 | 稳定的 message key | key + 命名参数 + 数量 | 按 locale 与复数分类选模板 | 拼接句子、按 count === 1 判断复数 |
| 时间 | UTC 瞬间 | ISO 8601 带 Z 或偏移 | 按 IANA 时区渲染 | 存墙上时间、用固定偏移代替时区名 |
| 金额 | 整数最小单位 | 金额 + 币种代码 | 按币种小数位格式化 | 浮点运算、所有币种都保留两位 |
三层有一个共同点:存储形态必须与展示形态解耦。一旦把展示结果写回存储,后面所有区域都会继承第一次展示时的假设。
文案层的单位是「一条消息」,不是「一个句子」。一条消息由 key、命名参数和可选的复数模板组成。时间层的单位是「一个瞬间」,不是「一个日期字符串」。金额层的单位是「最小货币单位」,不是「一个浮点数」。
把这三句话写进接口规范,比增加十种语言更能减少线上问题。
文案:参数化与复数
先看复数。不同语言的复数分类不是「单数/复数」两档。英语是 one/other,中文只有 other,阿拉伯语有 zero/one/two/few/many/other,俄语在 2 到 4 时用 few、5 到 20 时用 many、21 又回到 one。
这意味着 count === 1 这个判断在任何非英语市场都可能是错的。正确做法是让 Intl.PluralRules 给出分类,再按分类取模板。模板里用命名占位符,不做字符串拼接:
item.one = "{count} 件商品"
item.other = "{count} 件商品"
中文的两条模板可以相同,但 key 仍然要保留复数结构。这样当产品新增俄语时,翻译只需要填 few 与 many,不需要改代码。
参数化还有一条硬规则:句子结构属于模板,不属于调用方。调用方只传 { name, count, deadline },不能传「前半句」和「后半句」再拼接。不同语言的语序不同,拼接一定会在某个语言里读起来像机器翻译。
时间:UTC 存储,区域展示
时间只有三种合法表示:
- 瞬间:UTC 时间戳或带
Z的 ISO 字符串,用于存储与传输。 - 时区:IANA 名称,例如
Asia/Shanghai、America/New_York,用于决定怎么展示。 - 墙上时间:某个人在某个时区看到的年月日时分,只用于展示与用户输入。
常见的错误是只存墙上时间,或者只存一个固定偏移。固定偏移在夏令时切换时会错一小时;墙上时间则丢失了「这是哪个瞬间」的信息。纽约在 2026 年 3 月 8 日切换夏令时,同一天 02:30 这个墙上时间甚至不存在。
展示时也不应该手写 +8。Intl.DateTimeFormat 接收 IANA 时区名,能处理夏令时、历史偏移与半小时时区。加德满都是 UTC+5:45,手写整数偏移的代码通常会在半小时时区上出错。
如果业务需要「按用户所在时区的自然日」做统计,必须先明确自然日的边界,再把边界换算成 UTC 瞬间。不要在数据库里存一个没有时区的 date 字段,然后在每个查询里各自猜测。
金额:最小单位与按币种舍入
金额的第一条规则是用整数存最小单位。美元、欧元、人民币的最小单位是分,日元和智利比索没有小数位,科威特第纳尔有三位小数。把金额统一成「两位小数」会在日元上多出两位,在科威特第纳尔上少一位。
第二条规则是币种随金额一起走。只传 1999 没有意义,必须传 { amount: 1999, currency: "USD" } 或等价的类型。缺少币种的金额在跨区域汇总时一定会被误读。
第三条规则是解析与舍入分开。用户输入 1.005 时,先按币种的小数位做十进制定点舍入,再转成最小单位。直接用 Math.round(1.005 * 100) 会得到 100,因为 1.005 在二进制浮点里略小于 1.005。下面的模块用字符串解析避开这个问题。
第四条规则是分摊不能丢最小单位。把 100 分按 1:1:1 分给三件商品,正确结果是 34、33、33,而不是三个 33.33 分。最大余数法能保证每一份都是整数,且总和等于原金额。
现金场景还有一层按币种舍入。瑞士法郎的现金最小增量是 0.05,瑞典克朗的现金舍入到 1 克朗。这类规则只影响现金支付,不应该影响电子支付与账务记账,所以它必须是独立函数,而不是格式化时顺手做的四舍五入。
把三层写成可测试模块
下面这个模块把复数选择、UTC 展示、金额解析、现金舍入与比例分摊都变成纯函数。它不访问网络、不读系统时区,所有时区都显式传入,因此测试可以在任何机器上复现。
// file: src/i18n/locale-format.ts
// tier: L1
// verify: node --test test/i18n/locale-format.test.ts
export type PluralCategory = "zero" | "one" | "two" | "few" | "many" | "other";
export type PluralForms = Partial<Record<PluralCategory, string>> & { other: string };
export type MessageBundle = Record<string, PluralForms | string>;
export type FormatParams = Record<string, string | number>;
export type ZonedParts = {
year: number;
month: number;
day: number;
hour: number;
minute: number;
second: number;
};
export type CurrencySpec = {
code: string;
minorUnits: number;
cashIncrement: number;
};
export const CURRENCIES: Record<string, CurrencySpec> = {
USD: { code: "USD", minorUnits: 2, cashIncrement: 1 },
EUR: { code: "EUR", minorUnits: 2, cashIncrement: 1 },
CNY: { code: "CNY", minorUnits: 2, cashIncrement: 1 },
JPY: { code: "JPY", minorUnits: 0, cashIncrement: 1 },
KWD: { code: "KWD", minorUnits: 3, cashIncrement: 1 },
CHF: { code: "CHF", minorUnits: 2, cashIncrement: 5 },
SEK: { code: "SEK", minorUnits: 2, cashIncrement: 100 },
CLP: { code: "CLP", minorUnits: 0, cashIncrement: 1 },
};
export function currencySpec(code: string): CurrencySpec {
const spec = CURRENCIES[code.toUpperCase()];
if (!spec) throw new Error(`unknown currency: ${code}`);
return spec;
}
export function selectPluralCategory(locale: string, count: number): PluralCategory {
try {
return new Intl.PluralRules(locale).select(count) as PluralCategory;
} catch {
return count === 1 ? "one" : "other";
}
}
export function formatMessage(template: string, params: FormatParams): string {
return template.replace(/\{([A-Za-z0-9_]+)\}/g, (match: string, key: string): string => {
const value = params[key];
return value === undefined ? match : String(value);
});
}
export function missingPlaceholders(template: string, params: FormatParams): string[] {
const found: string[] = [];
for (const match of template.matchAll(/\{([A-Za-z0-9_]+)\}/g)) {
const key = match[1];
if (params[key] === undefined && !found.includes(key)) found.push(key);
}
return found;
}
export function formatPluralMessage(
locale: string,
bundle: MessageBundle,
key: string,
count: number,
params: FormatParams = {},
): string {
const entry = bundle[key];
if (entry === undefined) throw new Error(`missing message: ${key}`);
const forms: PluralForms = typeof entry === "string" ? { other: entry } : entry;
const category = selectPluralCategory(locale, count);
const template = forms[category] ?? forms.other;
return formatMessage(template, { ...params, count });
}
function toDate(instant: string | number | Date): Date {
const date = instant instanceof Date ? new Date(instant.getTime()) : new Date(instant);
if (Number.isNaN(date.getTime())) throw new Error("invalid instant");
return date;
}
export function toUtcIso(instant: string | number | Date): string {
return toDate(instant).toISOString();
}
export function zonedParts(instant: string | number | Date, timeZone: string): ZonedParts {
const date = toDate(instant);
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const pick = (type: string): number => {
const part = parts.find((item) => item.type === type);
return Number(part?.value ?? "0");
};
return {
year: pick("year"),
month: pick("month"),
day: pick("day"),
hour: pick("hour"),
minute: pick("minute"),
second: pick("second"),
};
}
export function zoneOffsetMinutes(instant: string | number | Date, timeZone: string): number {
const date = toDate(instant);
const parts = zonedParts(date, timeZone);
const wall = Date.UTC(parts.year, parts.month - 1, parts.day, parts.hour, parts.minute, parts.second);
const epochSeconds = Math.floor(date.getTime() / 1000) * 1000;
return Math.round((wall - epochSeconds) / 60000);
}
export function zonedIsoLike(instant: string | number | Date, timeZone: string): string {
const parts = zonedParts(instant, timeZone);
const pad = (value: number, width = 2): string => String(value).padStart(width, "0");
return `${pad(parts.year, 4)}-${pad(parts.month)}-${pad(parts.day)} ${pad(parts.hour)}:${pad(parts.minute)}:${pad(parts.second)}`;
}
export function formatZoned(
instant: string | number | Date,
timeZone: string,
locale: string,
): string {
const date = toDate(instant);
return new Intl.DateTimeFormat(locale, {
timeZone,
dateStyle: "medium",
timeStyle: "short",
}).format(date);
}
export function parseMajorToMinor(input: string | number, code: string): number {
const spec = currencySpec(code);
const text = typeof input === "number" ? input.toString() : input.trim();
if (!/^-?\d+(?:\.\d+)?$/.test(text)) throw new Error(`invalid amount: ${text}`);
const negative = text.startsWith("-");
const digitsOnly = negative ? text.slice(1) : text;
const [whole, fraction = ""] = digitsOnly.split(".");
const scale = 10 ** spec.minorUnits;
let minor = Number(whole) * scale;
if (fraction.length <= spec.minorUnits) {
const padded = fraction.padEnd(spec.minorUnits, "0");
minor += Number(padded || "0");
} else {
minor += Number(fraction.slice(0, spec.minorUnits) || "0");
const next = Number(fraction.charAt(spec.minorUnits));
if (next >= 5) minor += 1;
}
return negative && minor !== 0 ? -minor : minor;
}
export function formatMinorAsDecimal(minor: number, code: string): string {
const spec = currencySpec(code);
const sign = minor < 0 ? "-" : "";
const abs = Math.abs(Math.trunc(minor));
if (spec.minorUnits === 0) return `${sign}${abs}`;
const padded = String(abs).padStart(spec.minorUnits + 1, "0");
return `${sign}${padded.slice(0, -spec.minorUnits)}.${padded.slice(-spec.minorUnits)}`;
}
export function roundCash(minor: number, code: string): number {
const spec = currencySpec(code);
const increment = spec.cashIncrement;
if (increment <= 1) return minor;
const sign = minor < 0 ? -1 : 1;
const abs = Math.abs(Math.trunc(minor));
const remainder = abs % increment;
const down = abs - remainder;
const rounded = remainder * 2 >= increment ? down + increment : down;
return sign * rounded;
}
export function formatCurrency(minor: number, code: string, locale = "en-US"): string {
const spec = currencySpec(code);
const value = minor / 10 ** spec.minorUnits;
return new Intl.NumberFormat(locale, {
style: "currency",
currency: spec.code,
minimumFractionDigits: spec.minorUnits,
maximumFractionDigits: spec.minorUnits,
}).format(value);
}
export function allocateProportionally(totalMinor: number, weights: number[]): number[] {
if (!Number.isInteger(totalMinor) || totalMinor < 0) {
throw new Error("totalMinor must be a non-negative integer");
}
if (weights.length === 0) throw new Error("weights must not be empty");
if (weights.some((weight) => !(weight >= 0)) || weights.every((weight) => weight === 0)) {
throw new Error("weights must be non-negative and not all zero");
}
const sum = weights.reduce((acc, weight) => acc + weight, 0);
const exact = weights.map((weight) => (totalMinor * weight) / sum);
const base = exact.map((value) => Math.floor(value));
const remaining = totalMinor - base.reduce((acc, value) => acc + value, 0);
const order = exact
.map((value, index) => ({ index, fraction: value - Math.floor(value) }))
.sort((a, b) => b.fraction - a.fraction || a.index - b.index);
const out = base.slice();
for (let step = 0; step < remaining; step += 1) {
out[order[step % order.length].index] += 1;
}
return out;
}
模块里有两个设计选择值得说明。
第一,zonedParts 只接受显式时区名,不读取运行环境的默认时区。默认时区在不同机器、不同容器里可能不同,一旦进入测试就会变成不稳定的来源。
第二,parseMajorToMinor 先做字符串校验,再做整数运算。它不接受 "1,000" 这种带分隔符的输入,因为分隔符本身是区域相关的:1,000 在英语里是一千,在德语里可能是一点零。金额输入应该在进入这个函数之前就完成区域解析。
判据与反例
判断一套国际化实现是否可靠,看五条判据。
判据一:文案 key 稳定且不含句子片段。 调用方传 key 与命名参数,不传拼接后的字符串。新增语言时只增加资源,不改调用点。
判据二:复数分类由标准规则给出。 代码里不出现 count === 1 这类判断,除非该语言确实只有两档且产品明确接受。Intl.PluralRules 的分类结果要能被测试覆盖。
判据三:存储的时间一定是 UTC 瞬间。 数据库、日志、消息队列里出现的都是带 Z 或明确偏移的时间。墙上时间只存在于展示层与用户输入层。
判据四:金额以整数最小单位传输。 接口里没有浮点金额,币种与金额成对出现。所有币种的小数位来自一张显式配置表,而不是统一写死为 2。
判据五:分摊与舍入有守恒断言。 分摊后的各份之和必须等于原金额;现金舍入后的金额必须仍是该币种的合法最小单位倍数。
反例一:用 toFixed(2) 格式化日元。1980 日元会显示成 1980.00,用户会以为自己看错了币种。
反例二:把 2026-11-01 00:30 直接交给 new Date()。不同运行环境会按不同默认时区解析,同一份数据在两台机器上得到两个瞬间。
反例三:用浮点数累加优惠券分摊。10 / 3 保留两位后三份相加是 9.99,财务对账时找不到那 1 分钱。
反例四:只存时区偏移 +08:00。偏移不能表达夏令时规则,也不能表达「这个用户现在在哪个区域」。
反例五:把所有语言的复数都写成两条模板。俄语、阿拉伯语、波兰语的用户会看到语法错误的文案,而测试如果只覆盖英语,永远发现不了。
小结
国际化是三层表示形式的工程,不是翻译流程的附属品。
- 文案存 key、命名参数与复数模板,复数分类交给
Intl.PluralRules,调用方不拼接句子。 - 时间只存 UTC 瞬间,展示时显式传入 IANA 时区,不读默认时区,不手写固定偏移。
- 金额存整数最小单位,币种与金额成对出现,小数位按币种配置,分摊用最大余数法保证守恒。
- 现金舍入是独立规则,只影响现金场景,不影响账务记账。
- 所有区域相关的输入都要在进入核心逻辑前完成解析,核心逻辑只处理结构化数据。
先把这三层拆开,再谈支持多少种语言。顺序反了,语言越多,故障越多。
阅读导航:上一节:8.3 隐私合规的工程落地 · 下一节:9.2 RTL、排版与本地内容 。
常见错误与边界
- 用
count === 1判断复数。 俄语、阿拉伯语、波兰语的分类不止两档,必须使用Intl.PluralRules。 - 把句子拆成片段再拼接。 不同语言语序不同,拼接后的文案在某个语言里一定不通顺。
- 存墙上时间而不是 UTC 瞬间。 没有时区的时间字符串无法唯一确定一个瞬间,跨区域对账必然出错。
- 用固定偏移代替 IANA 时区。 固定偏移无法处理夏令时与历史规则变化,
+08:00也不代表用户所在区域。 - 所有币种统一保留两位小数。 日元没有小数位,科威特第纳尔有三位,统一两位会制造虚假精度或丢失精度。
- 用浮点数计算金额与分摊。 浮点误差会在累加与分摊时放大,必须使用整数最小单位。
- 分摊后不校验总和。 四舍五入会把余数丢掉,必须用最大余数法并断言各份之和等于原金额。
- 把现金舍入混进账务金额。 现金舍入是支付方式规则,不应该改变记账金额,否则退款与对账会出现差额。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。