Java跨境电商多语言架构实战:从后端做起,四层设计搞定国际化
一、为什么多语言架构要从后端开始?
很多做跨境电商独立站的团队,初期为了快速上线,往往选择在前端用 JS 脚本抓取页面文本、调用第三方翻译接口做"实时翻译"。这套方案看起来轻量,但埋下的坑会随着业务扩张逐一暴露。
最大的问题出在 SEO 上:前端翻译方案下,所有语种的页面共用同一个 URL 地址、同一份数据源。搜索引擎爬虫抓取时,只能索引到一套页面内容,不同语种会被判定为重复内容,无法独立收录和排名。这对于依赖自然流量获取海外买家的独立站来说,几乎是致命的。
真正的"建站级多语言",必须从后端做起,让每个语种拥有独立的数据存储、独立的 URL 路由、独立的 SEO 配置。这才是支撑跨境电商长期运营的技术底座。
二、核心架构:四层代码设计搞定多语言
基于 Spring 生态,整个后端国际化方案拆解为四层:
核心思路:每张核心业务表,对应一张独立的多语言表。
举个例子:分类实体 A 有一张主表 a,存储 id、sort、icon 等非语言相关字段。而 name、description、keywords 这类需要多语言的字段,存在一张独立的 a_language 表:
主表 a 多语言表 a_language
┌──────────────┐ ┌──────────────────────────────────┐
│ id │◄───────│ a_id (外键) │
│ sort │ │ language_code (en_US / ru_RU) │
│ icon │ │ name │
│ parent_id │ │ description │
│ del_flag │ │ keywords │
└──────────────┘ │ del_flag │
└──────────────────────────────────┘
这样做的好处:
- 查询高效:前台按语种查询时,直接 JOIN 对应语言的记录,不需要在主表中维护几十个
name_zh、name_en、name_ru字段
- 扩展方便:新增语种只需加几行配置,不用 ALTER TABLE 加列
- SEO 友好:每个语种的页面内容是真实存储在数据库中的,搜索引擎可以独立索引
-
2.2 注解驱动层——零侵入标记翻译字段
业务实体中,只需要在需要多语言的字段上打一个注解:
-
@TableName("a") public class A extends TenantEntity { private Long id; @MultiLangField(mapper = ALanguage.class) private String name; // 需要翻译 @MultiLangField(mapper = ALanguage.class) private String description; // 需要翻译 private Integer sort; // 不需要翻译,不加注解 }
注解的定义非常简洁:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MultiLangField {
String value() default ""; // 字段名称,默认取属性名
Class mapper() default Object.class; // 对应的 Language 实体类
}
mapper 参数指向对应语种的 Language 实体,框架通过它知道"翻译结果存到 a_language 表",通过 value 知道对应哪个字段。新增一个需要翻译的属性,只需加一行注解,无需修改任何翻译逻辑代码。
2.3 翻译调度层——统一入口,反射驱动
翻译调度器是整个系统的中枢。先看 Service 层调用有多简单——新增和编辑各两行:
// 新增
baseMapper.insert(entity);
multiLangHandler.updateLanguageFields(null, entity);
// 编辑
A old = baseMapper.selectById(newEntity.getId());
baseMapper.updateById(newEntity);
multiLangHandler.updateLanguageFields(old, newEntity);
调度器内部做了什么?下面是核心实现:
public void updateLanguageFields(Object oldEntity, Object newEntity) {
Long entityId = getIdFromEntity(newEntity);
if (entityId == null) return;
// 遍历所有字段,找到标记了 @MultiLangField 的
for (Field field : newEntity.getClass().getDeclaredFields()) {
MultiLangField annotation = field.getAnnotation(MultiLangField.class);
if (annotation == null) continue;
field.setAccessible(true);
Object newValue = field.get(newEntity);
if (newValue == null) continue;
// 增量识别:对比新旧值,没变化就跳过,避免重复翻译
Object oldValue = null;
if (oldEntity != null) {
oldValue = field.get(oldEntity);
if (Objects.equals(oldValue, newValue)) continue;
}
String fieldName = annotation.value().isEmpty()
? field.getName() : annotation.value();
String newValueString = (String) newValue;
// 遍历所有支持的语种,逐一翻译
for (LanguageCode languageCode : LanguageCode.values()) {
String translatedValue = newValueString;
if (!shouldKeepOriginalValue(newEntity, field.getName())) {
translatedValue = languageConvent.translate(
newValueString, languageCode);
}
saveOrUpdateLanguage(
annotation.mapper(), entityId,
languageCode.getCode(), fieldName, translatedValue);
}
}
}
关键设计点:
① 增量识别:Objects.equals(oldValue, newValue) 对比新旧值,只有变化的字段才触发翻译。比如管理员只改了名称,描述不会重复走翻译流水线。
② 遍历全部语种:一个字段变了,所有支持的语言都要重译一遍,保证多语言表的数据完整。
③ 反射推导 Mapper 和表名:不需要额外配置表名和 Mapper,全靠命名约定自动推导:
private BaseMapper getMapperByEntityClass(Class langEntityClass) {
// ALanguage (在 .domain. 包) → AMapper (在 .mapper. 包)
String mapperClassName = langEntityClass.getName()
.replace(".domain.", ".mapper.") + "Mapper";
Class mapperClass = Class.forName(mapperClassName);
return (BaseMapper) SpringUtils.getBean(mapperClass);
}
private String inferEntityIdFieldName(Class langEntityClass) {
// ALanguage → "a" → "aId"
String className = langEntityClass.getSimpleName();
String entityName = className.replace("Language", "");
return StrUtil.lowerFirst(entityName) + "Id";
}
再看 saveOrUpdateLanguage——写入语言表的核心方法:
@Lock4j(name = "multiLang:translate",
keys = {"#langEntityClass.simpleName", "#entityId",
"#langCode", "#fieldName"})
public void saveOrUpdateLanguage(Class langEntityClass, Long entityId,
String langCode, String fieldName, String translatedValue) {
BaseMapper langMapper = getMapperByEntityClass(langEntityClass);
String entityIdFieldName = inferEntityIdFieldName(langEntityClass);
String underlineCase = StrUtil.toUnderlineCase(entityIdFieldName);
// 查询是否已有该语种的记录
QueryWrapper wrapper = new QueryWrapper<>();
wrapper.eq(underlineCase, entityId)
.eq("language_code", langCode);
Object langEntity = langMapper.selectOne(wrapper);
if (langEntity == null) {
// 不存在 → 反射创建实例,设置外键、语言码、翻译值,INSERT
langEntity = langEntityClass.getDeclaredConstructor().newInstance();
setIdField(langEntityClass, langEntity, entityIdFieldName, entityId);
setField(langEntityClass, langEntity, "languageCode", langCode);
setField(langEntityClass, langEntity, fieldName, translatedValue);
langMapper.insert(langEntity);
} else {
// 已存在 → 只更新对应字段的值,UPDATE
setField(langEntityClass, langEntity, fieldName, translatedValue);
langMapper.updateById(langEntity);
}
}
saveOrUpdateLanguage 上挂了分布式锁,锁粒度是"实体类型 + 业务ID + 语言码 + 字段名",同一记录同一字段同一语言不会并发重复写入。
删除时同理,级联清理多语言数据:
public void deleteLanguageFields(Class<?> entityClass, Collection<Long> entityIds) {
// 扫描实体类上所有 @MultiLangField 注解,收集涉及的 Language 实体类
Set<Class<?>> langEntityClasses = new HashSet<>();
for (Field field : entityClass.getDeclaredFields()) {
MultiLangField annotation = field.getAnnotation(MultiLangField.class);
if (annotation != null) langEntityClasses.add(annotation.mapper());
}
// 逐一删
for (Class<?> langEntityClass : langEntityClasses) {
BaseMapper<Object> langMapper = getMapperByEntityClass(langEntityClass);
String idField = StrUtil.toUnderlineCase(inferEntityIdFieldName(langEntityClass));
QueryWrapper<Object> wrapper = new QueryWrapper<>();
wrapper.in(idField, entityIds);
langMapper.delete(wrapper);
}
}
2.4 翻译引擎层——智能路由 + 降级兜底
翻译引擎层封装了多家供应商,按内容类型自动路由:
public String translate(String text, LanguageCode languageCode) {
// 英文免翻译:管理后台输入的就是英文,目标也是英文时直接返回
if (Objects.equals(languageCode, LanguageCode.EN_US)) {
return text;
}
if (StringUtils.isEmpty(text)) {
return "";
}
// HTML 内容走专用 HTML 翻译服务,保留 DOM 结构
if (text.startsWith("<")) {
return htmlTranslateService.translateHtml(text, languageCode.getCode());
}
// 纯文本走文本翻译链路
return textTranslateService.translateText(
text, LanguageCode.EN_US.getCode(), languageCode.getCode());
}
文本翻译采用双引擎降级策略:
public String translateText(String text, String from, String to) {
try {
return primaryEngine.translateText(text, from, to); // 主力引擎
} catch (Exception e) {
if (!backupEngine.isConfigured()) {
throw new ServiceException("翻译失败,请重新保存");
}
log.warn("主力翻译引擎失败,降级到备选引擎");
try {
return backupEngine.translateText(text, from, to); // 备选引擎
} catch (Exception ex) {
throw new ServiceException("翻译失败,请重新保存");
}
}
}
路由策略总结:
┌─ 目标语言是英语? ──► 直接返回原文
│
翻译请求 ──► 内容以"<"开头? ──► HTML 翻译服务(保留标签结构)
│
└─ 纯文本 ──► 主力引擎(Engine P)
│
└─ 异常 ──► 备选引擎(Engine Q)
三、进阶挑战与可复用设计
3.1 保留原值策略——有些字段不该翻译
业务中有些字段虽然是多语言的,但内容不应该被机器翻译。比如商品属性值 ,翻译成俄语变成 会破坏与游戏内物品的对应关系。
系统内置了一个原值保留白名单:
private static final Map, Set> KEEP_ORIGINAL_VALUE_FIELDS = Map.of( // 属性值保留英文原文
A.class, Set.of("aName"), // A 的名称保留原文
B.class, Set.of("bName", "seoTitle"), // B 名称和 SEO 标题保留原文
);
private boolean shouldKeepOriginalValue(Object entity, String fieldName) {
Set fields = KEEP_ORIGINAL_VALUE_FIELDS.get(entity.getClass());
return fields != null && fields.contains(fieldName);
}
在调度器中判断:
if (!shouldKeepOriginalValue(newEntity, field.getName())) {
translatedValue = languageConvent.translate(newValueString, languageCode);
}
// 保留原值的字段:跳过翻译,直接将英文原文写入目标语言表
这样俄语站查询时也能返回对应的英文属性值,不会出现空字段,也不会出现错误的机器翻译。
3.2 翻译管理后台——人工校对
自动翻译不是万能的,系统还提供了一套后台翻译管理功能,支持:
- 翻译校对:列出英文原文 vs 各语种译文,支持人工逐条修正
- 单字段重译:管理员可以触发单个字段的机器翻译,覆盖已有译文
- 跨表统一查询:通过"业务类型 + 字段类型"两个维度枚举,动态路由到对应的 language 表,一个接口搞定所有翻译管理
3.3 用户语言偏好
前台查询时,系统通过请求头 Accept-Language 获取用户语言偏好:
public static String getCurrentLanguage() {
// 优先级:请求头 Accept-Language > 登录用户偏好 > 客户端设置 > 默认英文
String lang = getHeaderLanguage();
if (lang == null) lang = getUserPreferenceLanguage();
if (lang == null) lang = DEFAULT_LANGUAGE; // en_US
return lang;
}
如果目标语言的翻译数据不存在,查询层自动降级到英文(默认语言),保证用户始终能看到内容。
更多推荐




所有评论(0)