Featured image of post 数据字典表与菜单的国际化方案

数据字典表与菜单的国际化方案

枚举展示文本、菜单标题、错误消息——后台系统的多语言支持远比前端 i18n 复杂。本文给出生产级方案

写在前面

国内系统做国际化常常掉进一个误区——以为前端搞个 i18n 文件就完事了。但当业务真要支持多语言(出海、跨境电商、海外 SaaS)时,你会发现:

  • 错误消息从后端来——前端 i18n 没用
  • 数据字典(订单状态、产品类型)从 DB 来——前端写死翻译会脱节
  • 菜单和按钮文本从配置中心来——前端 i18n 不能覆盖
  • 营销内容/邮件模板/短信模板都要多语言

真正的国际化是『后端数据 + 前端展示』的协同。本文围绕数据字典和菜单这两个最难的场景,给出生产级方案。


一、为什么数据字典国际化最难

普通业务表的国际化简单——加个 _en 字段就完事。但数据字典特殊:

  • 字典本身可配置——产品/运营会随时新增字典项
  • 多语言字段动态——今天支持中英,明天加日韩西
  • 菜单层级深——一级菜单/二级菜单都要翻译
  • 跨业务共享——同一个字典被多个模块用

这种场景下"加 _en 字段"会导致:

1
status_zh, status_en, status_ja, status_ko, status_es, ...

字段无限增长——根本没法管


二、方案 1:i18n 副表

最常见的做法——主表存核心数据,副表存翻译

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CREATE TABLE dict_item (
    id BIGINT PRIMARY KEY,
    type VARCHAR(50),          -- order_status / product_type ...
    code VARCHAR(50),           -- PAID / SHIPPED ...
    sort INT,
    enabled BOOLEAN,
    UNIQUE KEY uk_type_code (type, code)
);

CREATE TABLE dict_item_i18n (
    id BIGINT PRIMARY KEY,
    dict_item_id BIGINT,
    locale VARCHAR(10),         -- zh-CN / en-US / ja-JP / ...
    label VARCHAR(255),          -- 翻译后的名称
    description VARCHAR(500),    -- 翻译后的描述
    UNIQUE KEY uk_item_locale (dict_item_id, locale)
);

查询时 JOIN:

1
2
3
4
5
SELECT i.code, t.label
FROM dict_item i
LEFT JOIN dict_item_i18n t ON t.dict_item_id = i.id AND t.locale = ?
WHERE i.type = 'order_status' AND i.enabled = TRUE
ORDER BY i.sort;

优点

  • 加新语言只增数据不改 schema
  • 字典项本身和翻译解耦
  • 易于配后台管理

缺点

  • 每次查都 JOIN——大量字典查询有性能开销
  • 缺少某语言时 fallback 要自己处理

Fallback 策略

请求 locale = ja-JP,但只有 zh-CNen-US 翻译——怎么办?两种策略:

  1. 回退到默认语言(通常是 en-US)——返回英文
  2. 回退到 code 本身——比如返回 “PAID”
1
2
3
4
5
6
7
8
SELECT i.code,
       COALESCE(
         t.label,
         (SELECT label FROM dict_item_i18n WHERE dict_item_id = i.id AND locale = 'en-US'),
         i.code
       ) AS label
FROM dict_item i
LEFT JOIN dict_item_i18n t ON t.dict_item_id = i.id AND t.locale = ?

三、方案 2:JSON 字段

主表用 JSON 字段存所有语言:

1
2
3
4
5
6
7
CREATE TABLE dict_item (
    id BIGINT PRIMARY KEY,
    type VARCHAR(50),
    code VARCHAR(50),
    labels JSON,              -- {"zh-CN": "已付款", "en-US": "Paid", "ja-JP": "支払済み"}
    sort INT
);

查询:

1
2
SELECT code, JSON_EXTRACT(labels, '$."zh-CN"') AS label
FROM dict_item WHERE type = 'order_status';

优点

  • 不用 JOIN,单表查询快
  • schema 极简
  • 取数据时一次拿到所有语言

缺点

  • JSON 字段查询/索引能力差
  • 后台编辑更新某个语言要 JSON 操作
  • 字典量大时 JSON 体积膨胀

适合字典量不大、改动不频繁的场景


四、方案 3:消息文件 + DB 关联

把翻译完全放在配置文件(properties / yaml)里:

1
2
3
4
5
# messages_zh_CN.properties
dict.order_status.PAID=已付款
dict.order_status.SHIPPED=已发货

dict.product_type.SAAS=SaaS 服务
1
2
3
# messages_en_US.properties
dict.order_status.PAID=Paid
dict.order_status.SHIPPED=Shipped

DB 只存 code,业务里用 MessageSource 翻译:

1
2
3
4
5
6
7
8
@Autowired private MessageSource messageSource;

public String getLabel(String type, String code, Locale locale) {
    return messageSource.getMessage(
        "dict." + type + "." + code,
        null, code, locale
    );
}

优点

  • 走标准 Spring i18n——成熟可靠
  • 文件易于版本管理
  • 性能极好(启动时全加载到内存)

缺点

  • 不能在后台动态改翻译——必须发版
  • 配置文件长了不好维护
  • 多语言文件容易忘改

适合翻译相对稳定的场景——但是少数。多数业务希望运营能自己改翻译,这种方案不行。


五、推荐:副表 + 多级缓存

对一个生产级国际化系统,我推荐这套架构:

设计要点:

  1. DB 用副表方案(方案 1)
  2. Redis 缓存全量字典——几 MB 数据,启动加载
  3. 应用本地 Caffeine 缓存——避免每次查 Redis
  4. 管理后台改翻译时主动失效缓存——所有实例同步刷新(用 Redis pub/sub 或 MQ)
  5. 前端 Service Worker / CDN 缓存按 locale 区分

六、菜单的国际化

菜单是另一个棘手场景——菜单结构是动态的,每个用户看到的菜单可能不同

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
CREATE TABLE menu (
    id BIGINT PRIMARY KEY,
    code VARCHAR(100) UNIQUE,
    parent_code VARCHAR(100),
    icon VARCHAR(100),
    path VARCHAR(255),
    sort INT,
    permission VARCHAR(100),
    enabled BOOLEAN
);

CREATE TABLE menu_i18n (
    menu_id BIGINT,
    locale VARCHAR(10),
    title VARCHAR(255),
    PRIMARY KEY (menu_id, locale)
);

查询:

1
2
3
4
5
6
7
SELECT m.id, m.code, m.parent_code, m.icon, m.path, m.sort,
       COALESCE(t.title, t_default.title, m.code) AS title
FROM menu m
LEFT JOIN menu_i18n t ON t.menu_id = m.id AND t.locale = ?
LEFT JOIN menu_i18n t_default ON t_default.menu_id = m.id AND t_default.locale = 'en-US'
WHERE m.enabled = TRUE
ORDER BY m.sort;

返回前端时已经是带翻译的菜单树——前端只需渲染。


七、错误消息的国际化

后端错误也要多语言——但错误信息往往动态

1
throw new BusinessException("user_not_found", "User " + userId + " not found");

不能写死翻译——要用模板:

1
2
3
# messages_zh_CN.properties
error.user_not_found=用户 {0} 不存在
error.insufficient_balance=余额不足,需要 {0} 但只有 {1}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
public class I18nException extends RuntimeException {
    private final String code;
    private final Object[] args;

    public I18nException(String code, Object... args) {
        super(code);
        this.code = code;
        this.args = args;
    }
}

@RestControllerAdvice
public class GlobalExceptionHandler {
    @Autowired private MessageSource messageSource;

    @ExceptionHandler(I18nException.class)
    public Result handle(I18nException e, Locale locale) {
        String msg = messageSource.getMessage(
            "error." + e.getCode(),
            e.getArgs(),
            "Unknown error",
            locale
        );
        return Result.fail(e.getCode(), msg);
    }
}

业务里:

1
throw new I18nException("user_not_found", userId);

八、Locale 的传递

前端怎么告诉后端"我要中文"?三种方式:

1. HTTP Header

1
Accept-Language: zh-CN, en;q=0.9

Spring 默认能识别。

2. URL 参数

1
GET /api/dict/order_status?locale=zh-CN

3. 用户偏好

用户登录后,从用户配置里读 locale——存在 user 表或 JWT claim 里。

1
2
3
LocaleResolver resolver = new SessionLocaleResolver();
// 或
LocaleResolver resolver = new AcceptHeaderLocaleResolver();

生产推荐:用户偏好优先 → URL 参数次之 → Header 兜底


九、几个工程实践

1. 翻译流程标准化

研发 / 产品 / 翻译方的协作流程:

  1. 研发提交新字典项(默认中英两语)
  2. 翻译方在管理后台补其他语言
  3. 缓存自动刷新
  4. 上线前 review 是否所有 active 字典项都有翻译

2. 缺翻译的报警

定期跑一个脚本——任何 enabled 字典项缺少某种 locale 翻译就告警

1
2
3
4
5
6
SELECT i.type, i.code
FROM dict_item i
WHERE i.enabled = TRUE
  AND NOT EXISTS (
    SELECT 1 FROM dict_item_i18n WHERE dict_item_id = i.id AND locale = 'en-US'
  );

3. 翻译质量保证

机器翻译只是初稿——专业翻译必须人工 review。某些行业术语机器翻得很离谱。

4. RTL 语言(阿拉伯语 / 希伯来语)

不只是文字翻译——整个 UI 要从右到左。CSS 用 dir="rtl" 切换。

5. 数字 / 日期 / 货币本地化

1
2
3
4
NumberFormat.getCurrencyInstance(locale).format(123456.78);
// zh-CN: ¥123,456.78
// en-US: $123,456.78
// de-DE: 123.456,78 €

不要自己写——用 ICU / java.text 标准库。

6. 时区

不同 locale 通常配不同时区——时间字段统一存 UTC,展示时按用户 timezone 转换


十、常见踩坑

1. 把 locale 和 currency 混淆

zh-CN 是 locale,CNY 是 currency——两个完全独立的概念。中国大陆用户也可能想看 USD 报价。

2. 字典项加了但缓存没刷新

新加 dict_item必须主动失效缓存——否则要等 Redis TTL 到才能看到。

3. 字典 ID 和 code 都泄漏

返回前端时——只用 code,不要把 ID 暴露。后端内部关联用 ID,前端展示用 code。

4. 移动端 / Web 缓存策略不一致

移动 App 缓存了字典,Web 没缓存——改翻译后 App 用户要等很久才看到。要么 App 也定期同步,要么强制版本检查。

5. SEO 友好

国际化网站——每种语言一个 URL 路径/zh-cn/.../en-us/...)——而不是用同一个 URL 切换语言。这样搜索引擎能正确索引。


小结

把全文压一句:

后台系统的国际化不是『前端 i18n 文件』那么简单——数据字典、菜单、错误消息这些"动态文本"需要一套完整的『DB + 缓存 + 标准协议』方案。

工程要点:

  • 数据字典用 i18n 副表 + 缓存
  • 菜单同样的姿势
  • 错误消息用 MessageSource + 占位符
  • 缓存改时主动失效
  • 缺翻译要监控告警
  • locale ≠ currency ≠ timezone,三者独立

把这些做对,你的系统能真正承载"出海到任意国家"——而不只是"加个英文翻译"。

使用 Hugo 构建
主题 StackJimmy 设计