《互联网产品的客户端架构·规模化与架构演进》9.1 文案、时区与货币

同一笔订单在圣保罗下单、在莫斯科退款时,文案复数、时区换算和货币最小单位会同时出错,三类故障常常互相掩盖。本节把参数化文案、UTC 存储与区域展示、按币种舍入写成可测试的 TypeScript 模块,并给出金额与时间在国际化场景下的判据与反例。

圣保罗的凌晨,莫斯科的退款

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 keykey + 命名参数 + 数量按 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、排版与本地内容 。

常见错误与边界

  1. 用 count === 1 判断复数。 俄语、阿拉伯语、波兰语的分类不止两档,必须使用 Intl.PluralRules。
  2. 把句子拆成片段再拼接。 不同语言语序不同,拼接后的文案在某个语言里一定不通顺。
  3. 存墙上时间而不是 UTC 瞬间。 没有时区的时间字符串无法唯一确定一个瞬间,跨区域对账必然出错。
  4. 用固定偏移代替 IANA 时区。 固定偏移无法处理夏令时与历史规则变化,+08:00 也不代表用户所在区域。
  5. 所有币种统一保留两位小数。 日元没有小数位,科威特第纳尔有三位,统一两位会制造虚假精度或丢失精度。
  6. 用浮点数计算金额与分摊。 浮点误差会在累加与分摊时放大,必须使用整数最小单位。
  7. 分摊后不校验总和。 四舍五入会把余数丢掉,必须用最大余数法并断言各份之和等于原金额。
  8. 把现金舍入混进账务金额。 现金舍入是支付方式规则,不应该改变记账金额,否则退款与对账会出现差额。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「books」更多文章

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