一、为什么多语言架构要从后端开始?

很多做跨境电商独立站的团队,初期为了快速上线,往往选择在前端用 JS 脚本抓取页面文本、调用第三方翻译接口做"实时翻译"。这套方案看起来轻量,但埋下的坑会随着业务扩张逐一暴露。

最大的问题出在 SEO 上:前端翻译方案下,所有语种的页面共用同一个 URL 地址、同一份数据源。搜索引擎爬虫抓取时,只能索引到一套页面内容,不同语种会被判定为重复内容,无法独立收录和排名。这对于依赖自然流量获取海外买家的独立站来说,几乎是致命的。

真正的"建站级多语言",必须从后端做起,让每个语种拥有独立的数据存储、独立的 URL 路由、独立的 SEO 配置。这才是支撑跨境电商长期运营的技术底座。

二、核心架构:四层代码设计搞定多语言

基于 Spring 生态,整个后端国际化方案拆解为四层:

核心思路:每张核心业务表,对应一张独立的多语言表。

举个例子:分类实体 A 有一张主表 a,存储 idsorticon 等非语言相关字段。而 namedescriptionkeywords 这类需要多语言的字段,存在一张独立的 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_zhname_enname_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;
}

如果目标语言的翻译数据不存在,查询层自动降级到英文(默认语言),保证用户始终能看到内容。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐