一、 需求分析
多语言需求包括:
● 界面文本:按钮、标题、页面、提示等。
● 格式化本地化:日期
● 运行时切换语言:顶部切换语言,并刷新页面即可切换。
● 多语言包管理:产品线按需加载,避免一次性加载全部语言文件。
● 可扩展性:方便新增语言、更新翻译内容。
● 语言类型:中文、英文
● 登陆界面选择多语言,选择租户多语言支持
二、 联建架构
1. MBM 负责统一语言切换入口,并把语言状态写入 localStorage;格式为:
imesLang:FOREVER_{"data":"zh_CN","expire":0}
2. 行业包(子应用)在启动或语言变更时读取 localStorage 中的 imesLang,并自行拉取或加载对应语言包,注入自身的国际化实现(例如 vue-i18n)。
3. 后端各自维护语言包(DB),支持模块化加载、按模块/按键查询、缓存(Redis)和版本控制。
目标:低耦合、按需加载、体验一致、性能可控。
三、 iMOM架构图
四、 后端设计(语言包服务)
1、 功能点
一、 提供语言包查询接口(按/locale/版本)
二、 支持一次性获取整模块语言包,
三、 支持缓存(Redis),并在语言包更新时清缓存或通过消息总线下发刷新指令
四、 支持版本管理、审计、回滚
五、 种子数据支持。
六、 翻译工具API
2、 数据库存储结构
CREATE TABLE language_pack (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
-- 语言标识,如 zh_CN、en_US,建议统一规范使用
locale VARCHAR(10) NOT NULL,
-- 模块或分类标识,比如 qms、aps、mpm
module VARCHAR(50) NOT NULL,
-- 多语言键,建议包含前缀,如 imom_input_name
lang_key_en VARCHAR(255) NOT NULL,
-- 多语言键,建议包含前缀,如 请输入姓名
lang_key VARCHAR(255) NOT NULL,
-- 对应语言的翻译内容
lang_value TEXT NOT NULL,
-- 记录最近一次更新时间,方便缓存刷新和同步
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
-- 更新人,可记录翻译者或维护人员
updated_by VARCHAR(50) DEFAULT NULL,
-- 状态标识,比如 1=启用,0=禁用,方便翻译审核管理
status TINYINT DEFAULT 1,
-- 备注,支持存放额外信息
remark VARCHAR(255) DEFAULT NULL,
-- 唯一索引,避免重复条目
UNIQUE KEY uniq_locale_key (locale, lang_key_en),
-- 唯一索引,避免重复条目
UNIQUE KEY uniq_locale_key (locale, lang_key),
-- 查询索引,提高查询效率
INDEX idx_locale_module (locale)
);
3、 Controller
// LanguageController.java
package com.example.i18n.controller;
import com.example.i18n.service.LanguageService;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@RestController
@RequestMapping("/lang")
@RequiredArgsConstructor
public class LanguageController {
private final LanguageService languageService;
@GetMapping("/{locale}")
public Map<String, String> getLangPack(
@PathVariable String locale,
@RequestParam(required = false) String prefix
) {
return languageService.getLanguagePack(locale, prefix);
}
}
4、 REST API 设计
一、 GET /api/lang/{locale} — 返回整服务语言包
二、 GET /api/lang/{service}/{locale}/module/{module} — 返回 module 语言包
三、 POST /api/lang/{service}/{locale} — 上传/更新语言包(受权限控制)
//中文语言
{
"data": [{
"key": "仪表盘",
"key_en": "imom_dash_board",
"value": "仪表盘"
}]
}
//英文语言
{
"data": [{
"key": "仪表盘",
"key_en": "imom_dash_board",
"value": "dash board"
}]
}
5、 缓存策略
一、 首选 Redis 做缓存(hash方式),Key 规则:lang:locale}:{lang_key}
二、 缓存 TTL:永不过期,更新数据,同时更新缓存并通过变更事件主动清除
三、 更新流程:当管理员更新语言(POST),服务写 DB -> 清理/更新 Redis -> 可选发消息到 MQ(如 Kafka/RabbitMQ/Redis)通知各实例
6、 创建语言 & 时区上下文
public class {
private final ThreadLocal<UserInfo> USER_INFO_THREAD_LOCAL = new TransmittableThreadLocal<>();
public static void setLanguage(String lang) {
LANGUAGE.set(lang);
}
public static String getLanguage() {
return LANGUAGE.get();
}
public static void setTimeZone(String timeZone) {
TIME_ZONE.set(timeZone);
}
public static String getTimeZone() {
return TIME_ZONE.get();
}
public static void clear() {
LANGUAGE.remove();
TIME_ZONE.remove();
}
}
7、 创建请求拦截器
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Component
public class ContextHolderFilter implements GenericFilterBean {
@Override
public boolean doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain) {
String lang = request.getHeader("Imom-Language");
String tz = request.getHeader("Time-Zone");
.setLanguage(lang != null ? lang : "en");
ThreadContextHolder.setTimeZone(tz != null ? tz : "UTC");
}
}
8、 场景使用
自定义 MessageSource 实现
package com.example.i18n;
import org.springframework.context.MessageSource;
import org.springframework.context.support.AbstractMessageSource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
import java.text.MessageFormat;
import java.util.Locale;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Component("messageSource") // 覆盖默认Bean
public class DatabaseMessageSource extends AbstractMessageSource {
// 缓存所有语言包,Key = locale,Value = Map<lang_key, lang_value>
private final Map<String, Map<String, String>> messagesCache = new ConcurrentHashMap<>();
private final LanguageRepository languageRepository; // 你自己的数据库访问层
public DatabaseMessageSource(LanguageRepository languageRepository) {
this.languageRepository = languageRepository;
}
@PostConstruct
public void loadMessages() {
// 启动时加载所有语言包,缓存起来
var allEntries = languageRepository.findAll();
for (var entry : allEntries) {
messagesCache.computeIfAbsent(entry.getLocale(), k -> new ConcurrentHashMap<>())
.put(entry.getLangKey(), entry.getLangValue());
}
}
@Override
protected MessageFormat resolveCode(String code, Locale locale) {
String localeKey = locale.toString();
Map<String, String> localeMessages = messagesCache.get(localeKey);
String msg = null;
if (localeMessages != null) {
msg = localeMessages.get(code);
}
if (msg == null) {
msg = code; // 找不到时返回 key
}
return new MessageFormat(msg, locale);
}
}
全局异常处理(结合 MessageSource 返回本地化信息)
@RestControllerAdvice
@RequiredArgsConstructor
public class GlobalExceptionHandler {
private final MessageSource messageSource;
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiError> handleBusinessException(BusinessException ex, Locale locale) {
String localizedMsg = messageSource.getMessage(ex.getMessageKey(), ex.getArgs(), locale);
ApiError error = new ApiError("BUSINESS_ERROR", localizedMsg);
return ResponseEntity.badRequest().body(error);
}
// 统一异常返回结构
public static class ApiError {
private String code;
private String message;
// getters/setters/constructor
public ApiError(String code, String message) {
this.code = code;
this.message = message;
}
// getter/setter omitted
}
}
public class I18nUtils {
/**
* 获取国际化消息
*/
public static String get(String key, Object... args) {
Locale locale = LocaleContextHolder.getLocale();
return messageSource.getMessage(key, args, locale);
}
}
String msg = I18nUtils.get("登陆");
//
String msg2 = I18nUtils.get("购物车中有商品数量", 5);
// 抛出带多语言 key 的异常
throw new BusinessException("用户为空", id);
// 运行时转为多语言
public class BusinessException extends RuntimeException {
public BusinessException(String key, Object... args) {
super(I18nUtils.get(key, args)); // key 对应 "用户为空"
}
}
PS:调用时,会从数据库缓存里取值。
采用语言包ke
@Component
public class I18nKeyCollector implements CommandLineRunner {
@Override
public void run(String... args) throws Exception {
long startTime = System.nanoTime(); // 开始计时
// 取 monorepo 根目录,例如 D:/source/dme/mbm-mom-kernel
Path projectRoot = Paths.get(System.getProperty("user.dir"));
// 遍历根目录下所有子目录(模块)
try (Stream<Path> modules = Files.list(projectRoot)) {
modules.filter(Files::isDirectory)
.forEach(modulePath -> {
Path srcDir = modulePath.resolve("src").resolve("main").resolve("java");
if (!Files.exists(srcDir)) {
return; // 跳过没有源码目录的模块
}
System.out.println("开始扫描模块: " + modulePath.getFileName());
try (Stream<Path> paths = Files.walk(srcDir)) {
paths.filter(path -> path.toString().endsWith(".java"))
.forEach(path -> {
try {
CompilationUnit cu = StaticJavaParser.parse(path);
// 收集 I18nUtils.get(...)
cu.findAll(MethodCallExpr.class).forEach(mce -> {
if ("get".equals(mce.getNameAsString()) &&
mce.getScope().map(s -> s.toString().equals("I18nUtils")).orElse(false)) {
String key = mce.getArgument(0).toString().replace("\"", "");
System.out.println("发现 i18n key: " + key);
}
});
// 收集 BusinessException("中文异常")
cu.findAll(ObjectCreationExpr.class).forEach(oce -> {
if ("BusinessException".equals(oce.getType().getNameAsString())) {
oce.getArguments().forEach(arg -> {
if (arg.isStringLiteralExpr()) {
String msg = arg.asStringLiteralExpr().asString();
System.out.println("发现异常 i18n key: " + msg);
}
});
}
});
} catch (Exception e) {
System.err.println("解析文件失败:" + path);
e.printStackTrace();
}
});
} catch (IOException e) {
e.printStackTrace();
}
}
);
}
long endTime = System.nanoTime(); // 结束计时
long durationMs = (endTime - startTime) / 1_000_000;
System.out.println("扫描完成,总耗时: " + durationMs + " ms");
}
}
开始扫描模块: kernel-api
开始扫描模块: kernel-biz
发现 i18n key: 登陆成功
发现 i18n key: 明好
发现异常 i18n key: 登陆异常
发现异常 i18n key: md5不能为空
发现异常 i18n key: baseUrl不能为空
发现异常 i18n key: MD5无法获取对应的文件信息
发现异常 i18n key: id不能为空
发现异常 i18n key: baseUrl不能为空
发现异常 i18n key: id无法获取对应的文件信息
发现异常 i18n key: token无效
发现异常 i18n key: token已失败
发现异常 i18n key: 文件标识不能为空
发现异常 i18n key: 请配置租户renterId和站点siteId
发现异常 i18n key: 请配置账号和密码
五、 前端设计
1、 初始化 i18n(从 localStorage 获取语言)
// src/locales/index.ts
import { createI18n } from 'vue-i18n'
export function getCurrentLang() {
const raw = localStorage.getItem('imesLang')
if (!raw) return 'zh_CN'
try {
const parsed = JSON.parse(raw.replace(/^FOREVER_/, ''))
return parsed.data || 'zh_CN'
} catch {
return 'zh_CN'
}
}
export const i18n = createI18n({
legacy: false,
locale: getCurrentLang(),
messages: {}, // 先空,后面动态加载
})
2、 语言包加载服务(从后端拉取)
// src/services/langService.ts
import axios from 'axios'
async function loadLang(locale: string) {
const res = await axios.get(`/lang/${locale}`, )
i18n.global.setLocaleMessage(locale, res.data)
i18n.global.locale.value = locale
}
3、 请求后端接口
// 添加请求拦截器,设置语言和时区请求头
apiClient.interceptors.request.use(config => {
config.headers['Imom-Language'] = getLocaleFromLocalStorage();
config.headers['Time-Zone'] = getTimeZone();
return config;
}, error => Promise.reject(error));
4、 使用示例
基础文本翻译
<template>
<div>
<!-- 普通文本 -->
<h1>{{ $t('登录系统') }}</h1>
<!-- 按钮 -->
<button>{{ $t('确定') }}</button>
</div>
</template>
// zh_CN.json
{
"登录系统": "登录系统",
"确定": "确定"
}
// en.json
{
"登录系统": "Login System",
"确定": "Confirm"
}
动态插值(变量替换)
<template>
<p>{{ $t('欢迎回来', { name: username }) }}</p>
</template>
<script setup>
const username = '张三'
</script>
// zh_CN.json
{
"欢迎回来": "欢迎回来,{name}!"
}
// en.json
{
"欢迎回来": "Welcome back, {name}!"
}
复数处理
<template>
<p>{{ $tc('购物车中有商品数量', itemCount, { count: itemCount }) }}</p>
</template>
<script setup>
const itemCount = 3
</script>
cn.json
{
"登录系统": "登录系统",
"确定": "确定",
"购物车中有商品数量": "购物车中有 {count} 件商品"
}
en.json
{
"登录系统": "Login System",
"确定": "Confirm",
"购物车中有商品数量": "You have {count} item | You have {count} items"
}
组件属性 & 占位符多语言
<template>
<input :placeholder="$t('表单.用户名输入提示')" />
<button :aria-label="$t('通用.确认')">
{{ $t('通用.确认') }}
</button>
</template>
// zh_CN.json
{
"表单.用户名输入提示": "请输入用户名",
"通用.确认": "确认"
}
// en.json
{
"表单.用户名输入提示": "Enter username",
"通用.确认": "Confirm"
}
六、 时区处理
1、 核心思路
后端统一返回 UTC 时间(建议 ISO 8601 格式,例如 2025-08-13T02:15:00Z),不做时区偏移。
前端根据用户选择/浏览器时区进行格式化展示。
时区信息随每次请求在请求头中传递,后端仅用于业务逻辑(如跨时区计算、排班等),不负责最终显示格式。
2、 前端时间处理规则
接口返回时间统一为 UTC。
前端收到后,通过 Time-Zone 或浏览器时区 (Intl.DateTimeFormat().resolvedOptions().timeZone) 进行转换。
展示格式可以由用户设置(例如 YYYY-MM-DD HH:mm:ss 或 MM/DD/YYYY HH:mm)。
3、 数据交互
前端处理为 UTC 再提交《采用》
优点
● 数据到后端时已经是统一的 UTC,不用担心不同调用方的时区差异。
● 多个后端语言/服务接入时,不需要重复写时区转换逻辑(因为前端已经统一)。
● 对 REST API 接口来说更直观(直接传 ISO8601 UTC 格式)。
缺点
● 前端需要额外的时区处理逻辑,尤其要考虑用户浏览器时区、夏令时(DST)、手动输入时间等复杂情况。
● 如果前端和后端都处理不一致,容易导致“二次转换”错误(多加/少加时差)。
● 不同前端(Web、APP、小程序)都得各自实现一次逻辑,增加维护成本。
方案考虑

● 前端接入 → 前端处理为 UTC 也行(可以减轻后端负担)。
● 建议:
○ 数据库存储 UTC(DATETIME 或 TIMESTAMP)
○ 部署时各层统一UTC时间
○ 接口传输统一用 ISO 8601 UTC 格式(如 2025-08-13T10:15:30Z)
○ 展示时根据用户时区转换成本地时间。
七、 风险项:
需与华为确认入参出参格式,时区不统一(UTC、东八区)。已确定用东八区
目前由于改造成本及风险太大,且无实际项目,先不做修改