一、 国际化不只是"翻译页面"
电商系统国际化(i18n)面临的核心挑战远不止界面翻译:
| 国际化维度 | 技术挑战 | 业务影响 |
|---|---|---|
| 多语言 | 商品信息、界面文案、搜索分词 | 不同地区用户看到不同语言内容 |
| 多币种 | 汇率实时换算、价格展示、支付结算 | 用户看到本地货币,商家收到本地货币 |
| 多时区 | 时间显示、活动开始/结束时间 | 大促活动按当地时间生效 |
| 多税制 | 不同国家/地区的税率计算 | 欧洲VAT、美国州税、中国增值税 |
| 多单位 | 重量(磅/公斤)、尺寸(英寸/厘米) | 物流和商品展示适配本地习惯 |
| 多支付 | 本地支付方式接入 | 不同地区用户使用习惯的支付方式 |
二、 整体架构
┌─────────────────────────────────────────────────────────────────┐
│ 用户请求层 │
│ 请求头:Accept-Language: zh-CN / Accept-Currency: USD │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 网关层(Locale路由) │
│ 解析用户地区 → 注入上下文(语言/货币/时区/税区) │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 业务服务层 │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ 内容服务:多语言文案检索 │ │
│ │ 价格服务:实时汇率换算 + 价格展示 │ │
│ │ 税务服务:根据地区计算税费 │ │
│ │ 时间服务:统一UTC存储 + 按本地时区展示 │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
三、 多语言:从硬编码到动态翻译
3.1 存储模型
sql
-- 商品基础表(语言无关)
CREATE TABLE `product` (
`id` bigint PRIMARY KEY,
`sku_code` varchar(64) NOT NULL,
`price` decimal(10,2) NOT NULL,
`stock` int NOT NULL,
`status` tinyint DEFAULT 1
);
-- 商品多语言表
CREATE TABLE `product_i18n` (
`id` bigint PRIMARY KEY,
`product_id` bigint NOT NULL,
`locale` varchar(10) NOT NULL, -- zh-CN / en-US / ja-JP
`name` varchar(200) NOT NULL,
`description` text,
`specification` json,
UNIQUE KEY `uk_product_locale` (`product_id`, `locale`)
);
-- 查询示例:获取商品的中文信息
SELECT p.*, i.name, i.description
FROM product p
JOIN product_i18n i ON p.id = i.product_id
WHERE p.id = 123 AND i.locale = 'zh-CN';
3.2 动态翻译策略
java
@Service
public class I18nContentService {
// 按优先级获取内容
public String getProductName(Long productId, String locale) {
// 1. 优先返回目标语言
String name = i18nRepository.getName(productId, locale);
if (name != null) {
return name;
}
// 2. 降级到默认语言(en-US)
name = i18nRepository.getName(productId, "en-US");
if (name != null) {
return name;
}
// 3. 最终降级到原始值
return productRepository.getById(productId).getDefaultName();
}
}
四、 多币种:实时汇率换算
4.1 汇率存储
sql
CREATE TABLE `exchange_rate` (
`id` bigint PRIMARY KEY,
`base_currency` varchar(3) NOT NULL, -- USD(基准货币)
`target_currency` varchar(3) NOT NULL, -- CNY
`rate` decimal(10,6) NOT NULL,
`update_time` datetime DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
UNIQUE KEY `uk_currency_pair` (`base_currency`, `target_currency`)
);
4.2 价格换算与展示
java
@Service
public class CurrencyService {
@Cacheable(value = "exchange_rate", key = "#targetCurrency")
public BigDecimal getRate(String targetCurrency) {
return exchangeRateRepository.findRate("USD", targetCurrency);
}
public BigDecimal convert(BigDecimal price, String fromCurrency, String toCurrency) {
if (fromCurrency.equals(toCurrency)) {
return price;
}
// 统一转USD再转目标币种
BigDecimal toUsd = price.divide(getRate(fromCurrency), 4, RoundingMode.HALF_UP);
return toUsd.multiply(getRate(toCurrency));
}
public String formatPrice(BigDecimal price, String currency) {
Currency cur = Currency.getInstance(currency);
NumberFormat formatter = NumberFormat.getCurrencyInstance(Locale.forLanguageTag(currency));
return formatter.format(price);
}
}
4.3 用户设置存储
java
// 用户偏好
public class UserPreference {
private Long userId;
private String language; // zh-CN
private String currency; // CNY
private String timezone; // Asia/Shanghai
}
// 服务端价格展示
public PriceDisplay getDisplayPrice(Long productId, String targetCurrency) {
Product product = productService.getById(productId);
// 商品定价货币(通常由商家设置)
String baseCurrency = "USD";
BigDecimal basePrice = product.getPrice();
// 按用户选择的货币换算
BigDecimal displayPrice = currencyService.convert(basePrice, baseCurrency, targetCurrency);
return PriceDisplay.builder()
.amount(displayPrice)
.currency(targetCurrency)
.formatted(currencyService.formatPrice(displayPrice, targetCurrency))
.build();
}
五、 多时区:统一UTC存储 + 本地化展示
核心原则:数据库存储统一使用UTC时间,前端展示时按用户时区转换。
sql
-- 统一存储UTC时间
CREATE TABLE `order` (
`id` bigint PRIMARY KEY,
`create_time_utc` datetime NOT NULL, -- 始终存UTC
`pay_time_utc` datetime DEFAULT NULL
);
-- 查询当前用户所在时区的今日订单
public List<Order> getTodayOrders(Long userId, String timezone) {
// 计算用户时区的"今天"对应的UTC时间范围
ZoneId userZone = ZoneId.of(timezone);
ZonedDateTime now = ZonedDateTime.now(userZone);
LocalDateTime startOfDay = now.toLocalDate().atStartOfDay();
LocalDateTime endOfDay = startOfDay.plusDays(1);
// 转换为UTC进行查询
Instant startUtc = startOfDay.atZone(userZone).toInstant();
Instant endUtc = endOfDay.atZone(userZone).toInstant();
return orderRepository.findByTimeRange(startUtc, endUtc);
}
六、 多税制:VAT/消费税/增值税
6.1 税制配置
sql
CREATE TABLE `tax_rule` (
`id` bigint PRIMARY KEY,
`country_code` varchar(3) NOT NULL,
`region_code` varchar(20) DEFAULT NULL, -- 州/省(如美国各州)
`tax_type` varchar(20) NOT NULL, -- VAT / SALES_TAX / GST
`tax_rate` decimal(5,4) NOT NULL,
`applicable_scenario` varchar(20) DEFAULT 'DEFAULT', -- DIGITAL / PHYSICAL
`is_active` tinyint DEFAULT 1
);
6.2 税务计算
java
@Service
public class TaxService {
public TaxResult calculateTax(BigDecimal amount, String countryCode, String regionCode) {
// 1. 查找对应的税率
TaxRule rule = taxRuleRepository.findRule(countryCode, regionCode);
if (rule == null) {
return TaxResult.zero();
}
BigDecimal tax = amount.multiply(rule.getTaxRate());
// 2. 不同国家计算逻辑差异
if ("VAT".equals(rule.getTaxType())) {
// 欧洲VAT:价外税,在价格基础上加上
return TaxResult.builder()
.taxRate(rule.getTaxRate())
.taxAmount(tax)
.totalAmount(amount.add(tax))
.taxType("VAT")
.build();
} else if ("SALES_TAX".equals(rule.getTaxType())) {
// 美国销售税:各州不同,有的州免税
return TaxResult.builder()
.taxRate(rule.getTaxRate())
.taxAmount(tax)
.totalAmount(amount.add(tax))
.taxType("SALES_TAX")
.build();
}
return TaxResult.zero();
}
}
七、 国际化搜索引擎适配
不同语言的分词器差异巨大,是国际化中最容易被忽视的环节:
| 语言 | 分词特点 | 推荐分词器 |
|---|---|---|
| 中文 | 无空格,组合词 | IK分词器(ik_max_word) |
| 英文 | 空格分隔,有词根 | Standard + Porter Stemming |
| 日文 | 无空格,混合文字 | Kuromoji |
json
// Elasticsearch Mapping 多语言配置
{
"mappings": {
"properties": {
"name": {
"type": "text",
"fields": {
"zh": { "type": "text", "analyzer": "ik_max_word" },
"en": { "type": "text", "analyzer": "english" },
"ja": { "type": "text", "analyzer": "kuromoji" }
}
}
}
}
}
八、 完整的国际化请求链路
[用户请求]
│ 请求头: Accept-Language=zh-CN
│ X-Currency=CNY
│ X-Timezone=Asia/Shanghai
▼
[网关层]
│ 解析 → 注入上下文
▼
[业务层]
├─ 商品详情查询
│ ├─ 产品信息:查 product_i18n WHERE locale='zh-CN'
│ ├─ 价格显示:USD 99.99 → CNY 720.00
│ └─ 库存:共用(商品本身不分语言)
│
├─ 下单
│ ├─ 订单时间:UTC存储
│ ├─ 订单货币:CNY(按用户选择)
│ └─ 税费计算:按收货地址国家/地区查 tax_rule
│
└─ 搜索结果
├─ 中文搜索:ik_max_word分词
└─ 英文搜索:english分词
九、 踩坑实录
坑1:翻译文案硬编码在代码中
现象:界面文案直接写在HTML或Java代码中,新增语言时需要修改代码并重新部署。
修复 :所有文案统一放在资源文件或数据库中,按locale动态读取。
properties
# messages_zh_CN.properties
order.paid.success=支付成功
# messages_en_US.properties
order.paid.success=Payment successful
坑2:汇率更新不及时导致价格偏差
现象:汇率每天更新一次,但市场波动大时展示价格与实时汇率偏差过大。
修复:
-
接入实时汇率API(每小时更新一次)
-
价格展示时标注"汇率更新于XX时间"
-
订单创建时锁定当刻汇率,作为历史记录
坑3:不同语言搜索关键词匹配不上
现象:中文用户搜索"手机"找不到"mobile phone"的商品。
修复:
-
建立多语言词库映射
-
建立同义词词典:手机=移动电话=mobile phone=cell phone
-
搜索时同时匹配多语言字段
十、 总结
| 国际化维度 | 核心原则 | 技术实现 |
|---|---|---|
| 多语言 | 内容与语言分离存储 | 业务主表 + _i18n扩展表 |
| 多币种 | 统一基准货币(USD)+ 实时汇率换算 | 汇率表 + 用户偏好 |
| 多时区 | 数据库统一UTC存储,展示时转换 | 用户时区偏好 |
| 多税制 | 规则驱动配置 | tax_rule表 + 税务引擎 |
| 多搜索 | 字段级多语言分词 | ES多analyzer配置 |
文末思考:
国际化不是"功能",而是电商系统的内置能力。建议在设计阶段就引入国际化框架,而不是等业务拓展到海外后再改造。好的国际化设计让代码中几乎没有硬编码的文字或格式,所有差异都由配置驱动。