林昶
林昶
Published on 2025-09-11 / 27 Visits
0
0

iMOM多语言&多时区方案设计

一、 需求分析

多语言需求包括:

● 界面文本:按钮、标题、页面、提示等。

● 格式化本地化:日期

● 运行时切换语言:顶部切换语言,并刷新页面即可切换。

● 多语言包管理:产品线按需加载,避免一次性加载全部语言文件。

● 可扩展性:方便新增语言、更新翻译内容。

● 语言类型:中文、英文

● 登陆界面选择多语言,选择租户多语言支持

 

二、 联建架构

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、东八区)。已确定用东八区

目前由于改造成本及风险太大,且无实际项目,先不做修改

 


Comment