货币显示、汇率策略与时区处理
High Contrast
Dark Mode
Light Mode
Sepia
Forest
3 min read522 words

货币显示、汇率策略与时区处理

核心问题:如何正确显示多货币价格?汇率数据应该怎么获取和更新?时区处理有哪些常见陷阱?


真实场景

你的跨境电商平台在中国显示 CNY 价格,在美国显示 USD 价格,在欧洲显示 EUR 价格。价格需要根据实时汇率换算,还要考虑时区:美国用户看到的促销结束时间应该是他所在时区的午夜,而不是 UTC。


货币显示的完整方案

精度问题:永远不用浮点数存储金额

// ❌ 错误:浮点数精度问题
0.1 + 0.2 === 0.3   // false!
0.1 + 0.2            // 0.30000000000000004
// ✅ 正确方案1:以"最小货币单位"(分)存储整数
// 数据库存储:10000 分 = ¥100.00
// 传输:integer(分)
// 显示:÷ 100 → 使用 Intl.NumberFormat 格式化
// ✅ 正确方案2:使用 decimal 库
import Decimal from 'decimal.js';
new Decimal('0.1').plus('0.2').toString()  // "0.3" ✅

数据库货币存储设计

CREATE TABLE products (
id UUID PRIMARY KEY,
-- 存储最小货币单位(分、分)
price_amount INTEGER NOT NULL,          -- 如 12999(代表 ¥129.99)
price_currency CHAR(3) NOT NULL,        -- ISO 4217 货币代码:CNY, USD, EUR
price_display_decimal SMALLINT DEFAULT 2 -- 货币小数位数(日元为0)
);
-- 多货币价格
CREATE TABLE product_prices (
product_id UUID REFERENCES products(id),
currency CHAR(3) NOT NULL,
amount INTEGER NOT NULL,  -- 最小单位
PRIMARY KEY (product_id, currency)
);

货币展示层

// 货币信息表(来自 ISO 4217)
const CURRENCY_INFO: Record<string, { decimals: number; name: string }> = {
CNY: { decimals: 2, name: '人民币' },
USD: { decimals: 2, name: '美元' },
EUR: { decimals: 2, name: '欧元' },
JPY: { decimals: 0, name: '日元' },       // 日元无小数
KWD: { decimals: 3, name: '科威特第纳尔' }, // 3位小数
BTC: { decimals: 8, name: '比特币' },
};
// 将最小单位转换为展示金额
function fromMinorUnit(amount: number, currency: string): number {
const decimals = CURRENCY_INFO[currency]?.decimals ?? 2;
return amount / Math.pow(10, decimals);
}
// 货币格式化(前端)
function formatCurrency(
amountInMinorUnit: number,
currency: string,
locale: string
): string {
const amount = fromMinorUnit(amountInMinorUnit, currency);
const decimals = CURRENCY_INFO[currency]?.decimals ?? 2;
return new Intl.NumberFormat(locale, {
style: 'currency',
currency,
minimumFractionDigits: decimals,
maximumFractionDigits: decimals,
}).format(amount);
}
// 使用示例
formatCurrency(12999, 'CNY', 'zh-CN');  // "¥129.99"
formatCurrency(1999,  'USD', 'en-US');  // "$19.99"
formatCurrency(1999,  'EUR', 'de-DE');  // "19,99 €"
formatCurrency(1500,  'JPY', 'ja-JP');  // "¥1,500"

汇率策略

汇率数据来源对比

来源 精度 免费额度 更新频率 适用场景
Open Exchange Rates 1000 次/月 小时级 中小型项目
Fixer.io 100 次/月 小时级 个人项目
ExchangeRate-API 中等 1500 次/月 每日 中小型项目
央行/官方数据 最高 免费 每日 合规要求场景
Stripe / Paddle 支付服务内置 实时 使用支付平台时

汇率缓存架构

// src/services/exchangeRate.ts
import Redis from 'ioredis';
const redis = new Redis(process.env.REDIS_URL);
const RATE_TTL = 3600; // 缓存 1 小时
const BASE_CURRENCY = 'CNY';
interface ExchangeRates {
base: string;
timestamp: number;
rates: Record<string, number>;
}
// 从 API 获取最新汇率
async function fetchRatesFromAPI(): Promise<ExchangeRates> {
const response = await fetch(
`https://openexchangerates.org/api/latest.json?app_id=${process.env.OER_APP_ID}&base=USD&symbols=CNY,EUR,GBP,JPY,KRW,AUD,CAD,SGD`
);
if (!response.ok) throw new Error('Failed to fetch exchange rates');
const data = await response.json();
// 转换为以 CNY 为基准
const usdToCny = data.rates['CNY'];
const rates: Record<string, number> = { CNY: 1 };
for (const [currency, rate] of Object.entries(data.rates as Record<string, number>)) {
if (currency !== 'CNY') {
rates[currency] = usdToCny / rate; // CNY 到其他货币的汇率
}
}
return {
base: BASE_CURRENCY,
timestamp: data.timestamp,
rates,
};
}
// 获取汇率(带缓存)
export async function getExchangeRates(): Promise<ExchangeRates> {
const cached = await redis.get('exchange_rates');
if (cached) return JSON.parse(cached);
const rates = await fetchRatesFromAPI();
await redis.setex('exchange_rates', RATE_TTL, JSON.stringify(rates));
return rates;
}
// 定期刷新(使用 cron job)
// cron: 0 * * * * → 每小时整点刷新
export async function refreshExchangeRates() {
const rates = await fetchRatesFromAPI();
await redis.setex('exchange_rates', RATE_TTL, JSON.stringify(rates));
console.log(`Exchange rates refreshed at ${new Date().toISOString()}`);
}
// 货币转换服务
export async function convertCurrency(
amount: number,       // 最小单位金额
fromCurrency: string,
toCurrency: string
): Promise<number> {
if (fromCurrency === toCurrency) return amount;
const { rates } = await getExchangeRates();
// amount 是 fromCurrency 的最小单位
const fromDecimals = CURRENCY_INFO[fromCurrency]?.decimals ?? 2;
const toDecimals = CURRENCY_INFO[toCurrency]?.decimals ?? 2;
// 转换为标准金额
const amountInBase = amount / Math.pow(10, fromDecimals);
// 先转换为基准货币(CNY),再转换为目标货币
const amountInCNY = fromCurrency === 'CNY' ? amountInBase : amountInBase / rates[fromCurrency];
const amountInTarget = toCurrency === 'CNY' ? amountInCNY : amountInCNY * rates[toCurrency];
// 转回最小单位(向上取整,避免亏损)
return Math.ceil(amountInTarget * Math.pow(10, toDecimals));
}

显示价格的时机策略

graph TD A[用户访问] --> B{已登录?} B -->|是| C[使用账户语言/货币设置] B -->|否| D[检测 locale + GeoIP 国家] D --> E[显示对应货币的参考价格] E --> F[注明"参考价格,以结算时为准"] C --> G[显示精确的本地货币价格]
// API:返回价格信息
app.get('/api/products/:id/price', async (req, res) => {
const { locale } = req;
const { currency } = req.query as { currency?: string };
const product = await db.products.findUnique({ where: { id: req.params.id } });
const targetCurrency = currency ?? getCurrencyForLocale(locale);
// 如果有预计算的目标货币价格(推荐)
const localPrice = await db.productPrices.findFirst({
where: { productId: product.id, currency: targetCurrency },
});
if (localPrice) {
return res.json({
amount: localPrice.amount,
currency: targetCurrency,
isExact: true,         // 精确价格(不是汇率换算)
});
}
// 动态汇率换算(带免责声明)
const convertedAmount = await convertCurrency(
product.priceAmount,
product.priceCurrency,
targetCurrency
);
res.json({
amount: convertedAmount,
currency: targetCurrency,
isExact: false,          // 汇率换算,仅供参考
exchangeRateTimestamp: Date.now(),
});
});
// locale 到默认货币的映射
function getCurrencyForLocale(locale: string): string {
const localeMap: Record<string, string> = {
'zh-CN': 'CNY', 'zh-TW': 'TWD', 'zh-HK': 'HKD',
'en-US': 'USD', 'en-GB': 'GBP', 'en-AU': 'AUD',
'de-DE': 'EUR', 'fr-FR': 'EUR', 'ja-JP': 'JPY',
'ko-KR': 'KRW', 'ar-SA': 'SAR',
};
return localeMap[locale] ?? 'USD';
}

时区处理

时区的常见陷阱

// ❌ 陷阱1:new Date() 使用服务器时区
const now = new Date();
// 服务器在 UTC,now 是 UTC 时间
// 中国用户看到的"今天"和服务器不同
// ❌ 陷阱2:直接格式化日期字符串
const date = '2026-03-22';
new Date(date).toISOString();
// 浏览器行为不一致!部分浏览器解析为 UTC,部分解析为本地时间
// ✅ 正确:始终使用 ISO 8601 包含时区信息的格式
const dateWithTZ = '2026-03-22T00:00:00+08:00'; // 明确指定 UTC+8
new Date(dateWithTZ).toISOString();  // "2026-03-21T16:00:00.000Z"(UTC)

时区感知的日期格式化

// 不同时区的用户看到不同的"当地时间"
function formatDateInTimezone(
utcDate: Date,
timezone: string,
locale: string
): string {
return new Intl.DateTimeFormat(locale, {
year: 'numeric',
month: 'long',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
timeZone: timezone,
timeZoneName: 'short',
}).format(utcDate);
}
const utcDate = new Date('2026-03-22T08:00:00Z');
formatDateInTimezone(utcDate, 'Asia/Shanghai', 'zh-CN');
// "2026年3月22日 16:00 GMT+8"
formatDateInTimezone(utcDate, 'America/New_York', 'en-US');
// "March 22, 2026 at 4:00 AM EDT"
formatDateInTimezone(utcDate, 'Europe/London', 'en-GB');
// "22 March 2026 at 08:00 GMT"

常用时区 IANA 标识符

地区 IANA 时区 UTC 偏移
中国(标准时) Asia/Shanghai UTC+8
日本 Asia/Tokyo UTC+9
韩国 Asia/Seoul UTC+9
美东(纽约) America/New_York UTC-5/-4(DST)
美西(洛杉矶) America/Los_Angeles UTC-8/-7(DST)
英国 Europe/London UTC+0/+1(DST)
中欧(巴黎/柏林) Europe/Paris UTC+1/+2(DST)
印度 Asia/Kolkata UTC+5:30
迪拜 Asia/Dubai UTC+4
沙特 Asia/Riyadh UTC+3
澳洲东部 Australia/Sydney UTC+10/+11(DST)

促销倒计时的时区处理

// 场景:促销结束时间为"北京时间 3月23日 23:59:59"
// 不同时区的用户看到的倒计时时长不同,但结束的绝对时刻相同
// 存储:总是存储 UTC 时间
const promotionEndUTC = new Date('2026-03-23T15:59:59Z'); // UTC(对应北京时间 23:59:59)
// 展示:转换为用户时区
function displayCountdown(endTimeUTC: Date, userTimezone: string, locale: string) {
const localEndTime = new Intl.DateTimeFormat(locale, {
dateStyle: 'short',
timeStyle: 'medium',
timeZone: userTimezone,
}).format(endTimeUTC);
return `促销结束时间(当地时间):${localEndTime}`;
}
// 上海用户看到:2026/3/23 23:59:59
// 纽约用户看到:2026/3/23 10:59:59 AM
// 两者指向同一绝对时刻

推荐库(处理复杂时区场景)

# Temporal API(下一代原生 API,目前需 polyfill)
npm install @js-temporal/polyfill
# date-fns-tz(轻量)
npm install date-fns date-fns-tz
# Luxon(功能全面)
npm install luxon
// 使用 date-fns-tz
import { formatInTimeZone, zonedTimeToUtc, utcToZonedTime } from 'date-fns-tz';
import { zhCN } from 'date-fns/locale';
// 将 UTC 时间格式化为北京时间
formatInTimeZone(
new Date('2026-03-22T08:00:00Z'),
'Asia/Shanghai',
'yyyy年M月d日 HH:mm',
{ locale: zhCN }
);
// "2026年3月22日 16:00"

前端时区检测

// 检测用户时区
const userTimezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
// "Asia/Shanghai"(Chrome/Firefox 返回 IANA 时区)
// 存储到 Cookie 供服务端使用
document.cookie = `timezone=${encodeURIComponent(userTimezone)}; path=/; SameSite=Lax`;

进入第 05 章:数字、日期、货币的格式化搞定了,现在解决翻译文件的组织和版本管理问题——如何避免"翻译债"越积越多。

翻译文件格式对比