diff --git a/backend/pom.xml b/backend/pom.xml
index 5e5ff6b40a..7500d12a39 100644
--- a/backend/pom.xml
+++ b/backend/pom.xml
@@ -86,6 +86,16 @@
org.apache.commons
commons-lang3
+
+ org.apache.commons
+ commons-collections4
+ 4.1
+
+
+ org.apache.commons
+ commons-text
+ 1.8
+
commons-codec
commons-codec
diff --git a/backend/src/main/java/io/metersphere/commons/constants/I18nConstants.java b/backend/src/main/java/io/metersphere/commons/constants/I18nConstants.java
new file mode 100644
index 0000000000..01a1bf13e8
--- /dev/null
+++ b/backend/src/main/java/io/metersphere/commons/constants/I18nConstants.java
@@ -0,0 +1,11 @@
+package io.metersphere.commons.constants;
+
+public class I18nConstants {
+
+ public static final String LANG_COOKIE_NAME = "MS_USER_LANG";
+
+ public static final String LOCAL = "local";
+ public static final String CLUSTER = "cluster";
+
+
+}
diff --git a/backend/src/main/java/io/metersphere/commons/constants/ParamConstants.java b/backend/src/main/java/io/metersphere/commons/constants/ParamConstants.java
new file mode 100644
index 0000000000..544201bdab
--- /dev/null
+++ b/backend/src/main/java/io/metersphere/commons/constants/ParamConstants.java
@@ -0,0 +1,190 @@
+package io.metersphere.commons.constants;
+
+/**
+ * Author: chunxing
+ * Date: 2018/6/26 下午3:44
+ * Description:
+ */
+public interface ParamConstants {
+
+ String getValue();
+
+ enum KeyCloak implements ParamConstants {
+
+ USERNAME("keycloak.username"),
+ PASSWORD("keycloak.password"),
+ REALM("keycloak.realm"),
+ AUTH_SERVER_URL("keycloak.auth-server-url"),
+ ADDRESS("keycloak-server-address");
+
+ private String value;
+
+ KeyCloak(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+ }
+
+ enum Type implements ParamConstants {
+
+ PASSWORD("password"),
+ TEXT("text"),
+ JSON("json");
+
+ private String value;
+
+ Type(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+ }
+
+ enum Classify implements ParamConstants {
+
+ KEYCLOAK("keycloak"),
+ MAIL("smtp"),
+ UI("ui"),
+ REGISTRY("registry");
+
+ private String value;
+
+ Classify(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+ }
+
+ enum UI implements ParamConstants {
+
+ LOGO("ui.logo"),
+ SYSTEM_NAME("ui.system.name"),
+ THEME_PRIMARY("ui.theme.primary"),
+ THEME_ACCENT("ui.theme.accent"),
+ FAVICON("ui.favicon"),
+ LOGIN_TITLE("ui.login.title"),
+ LOGIN_IMG("ui.login.img"),
+ SUPPORT_NAME("ui.support.name"),
+ SUPPORT_URL("ui.support.url"),
+ TITLE("ui.title");
+
+ private String value;
+
+ UI(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+ }
+
+ enum MAIL {
+ SERVER("smtp.server", 1),
+ PORT("smtp.port", 2),
+ ACCOUNT("smtp.account", 3),
+ PASSWORD("smtp.password", 4),
+ SSL("smtp.ssl", 5),
+ TLS("smtp.tls", 6);
+
+ private String key;
+ private Integer value;
+
+ MAIL(String key, Integer value) {
+ this.key = key;
+ this.value = value;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public Integer getValue() {
+ return value;
+ }
+ }
+
+ enum Log implements ParamConstants {
+ KEEP_MONTHS("log.keep.months");
+ private String value;
+
+ Log(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+ }
+
+ enum Registry implements ParamConstants {
+ URL("registry.url"),
+ REPO("registry.repo"),
+ USERNAME("registry.username"),
+ PASSWORD("registry.password");
+
+ private String value;
+
+ Registry(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+ }
+
+ enum I18n implements ParamConstants {
+
+ LANGUAGE("i18n.language");
+
+ private String value;
+
+ I18n(String value) {
+ this.value = value;
+ }
+
+ @Override
+ public String getValue() {
+ return value;
+ }
+
+ public void setValue(String value) {
+ this.value = value;
+ }
+ }
+}
diff --git a/backend/src/main/java/io/metersphere/config/I18nConfig.java b/backend/src/main/java/io/metersphere/config/I18nConfig.java
new file mode 100644
index 0000000000..be2fd6ab3f
--- /dev/null
+++ b/backend/src/main/java/io/metersphere/config/I18nConfig.java
@@ -0,0 +1,21 @@
+package io.metersphere.config;
+
+import io.metersphere.i18n.I18nManager;
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Configuration
+public class I18nConfig {
+
+ @Bean
+ @ConditionalOnMissingBean
+ public I18nManager i18nManager() {
+ List dirs = new ArrayList<>();
+ dirs.add("i18n/");
+ return new I18nManager(dirs);
+ }
+}
diff --git a/backend/src/main/java/io/metersphere/controller/I18nController.java b/backend/src/main/java/io/metersphere/controller/I18nController.java
new file mode 100644
index 0000000000..71c78dc3be
--- /dev/null
+++ b/backend/src/main/java/io/metersphere/controller/I18nController.java
@@ -0,0 +1,53 @@
+package io.metersphere.controller;
+
+
+import io.metersphere.commons.constants.I18nConstants;
+import io.metersphere.commons.exception.MSException;
+import io.metersphere.commons.utils.LogUtil;
+import io.metersphere.i18n.Lang;
+import io.metersphere.service.UserService;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.annotation.Resource;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * Created by liqiang on 2019/4/1.
+ */
+@RestController
+public class I18nController {
+
+ private static final int FOR_EVER = 3600 * 24 * 30 * 12 * 10; //10 years in second
+
+ @Value("${run.mode:release}")
+ private String runMode;
+
+ @Resource
+ private UserService userService;
+
+ @GetMapping("lang/change/{lang}")
+ public void changeLang(@PathVariable String lang, HttpServletResponse response) {
+ Lang targetLang = Lang.getLangWithoutDefault(lang);
+ if (targetLang == null) {
+ response.setStatus(HttpServletResponse.SC_NOT_ACCEPTABLE);
+ LogUtil.error("Invalid parameter: " + lang);
+ MSException.throwException("ERROR_LANG_INVALID");
+ }
+ userService.setLanguage(targetLang.getDesc());
+ Cookie cookie = new Cookie(I18nConstants.LANG_COOKIE_NAME, targetLang.getDesc());
+ cookie.setPath("/");
+ cookie.setMaxAge(FOR_EVER);
+ response.addCookie(cookie);
+ //重新登录
+ if ("release".equals(runMode)) {
+ Cookie f2cCookie = new Cookie("MS_COOKIE_ID", "deleteMe");
+ f2cCookie.setPath("/");
+ f2cCookie.setMaxAge(0);
+ response.addCookie(f2cCookie);
+ }
+ }
+}
diff --git a/backend/src/main/java/io/metersphere/dto/UserDTO.java b/backend/src/main/java/io/metersphere/dto/UserDTO.java
index 0caa5b4fba..bb0835c43c 100644
--- a/backend/src/main/java/io/metersphere/dto/UserDTO.java
+++ b/backend/src/main/java/io/metersphere/dto/UserDTO.java
@@ -21,6 +21,8 @@ public class UserDTO {
private Long updateTime;
+ private String language;
+
private String lastWorkspaceId;
private String lastOrganizationId;
@@ -103,6 +105,14 @@ public class UserDTO {
this.userRoles = userRoles;
}
+ public String getLanguage() {
+ return language;
+ }
+
+ public void setLanguage(String language) {
+ this.language = language;
+ }
+
public String getLastWorkspaceId() {
return lastWorkspaceId;
}
diff --git a/backend/src/main/java/io/metersphere/i18n/I18nManager.java b/backend/src/main/java/io/metersphere/i18n/I18nManager.java
new file mode 100644
index 0000000000..71802ee58d
--- /dev/null
+++ b/backend/src/main/java/io/metersphere/i18n/I18nManager.java
@@ -0,0 +1,86 @@
+package io.metersphere.i18n;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.TypeReference;
+import io.metersphere.commons.utils.IOUtils;
+import io.metersphere.commons.utils.LogUtil;
+import org.apache.commons.lang3.ArrayUtils;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.boot.ApplicationArguments;
+import org.springframework.boot.ApplicationRunner;
+import org.springframework.core.io.Resource;
+import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.util.ResourceUtils;
+
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.Charset;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class I18nManager implements ApplicationRunner {
+
+ private static Map> i18nMap = new HashMap<>();
+
+ private List dirs;
+
+ public I18nManager(List dirs) {
+ this.dirs = dirs;
+ }
+
+ public static Map> getI18nMap() {
+ return i18nMap;
+ }
+
+ private static Resource[] getResources(String dir, String suffix) throws IOException {
+ Resource[] result = new Resource[0];
+ PathMatchingResourcePatternResolver patternResolver = new PathMatchingResourcePatternResolver();
+ if (!patternResolver.getResource(ResourceUtils.CLASSPATH_URL_PREFIX + dir).exists()) {
+ return result;
+ }
+ Resource[] resources = patternResolver.getResources(ResourceUtils.CLASSPATH_URL_PREFIX + dir + "*");
+ for (Resource resource : resources) {
+ if (StringUtils.endsWithIgnoreCase(resource.getFilename(), suffix)) {
+ result = ArrayUtils.add(result, resource);
+ }
+ }
+ return result;
+ }
+
+ private void init() {
+ try {
+ for (Lang lang : Lang.values()) {
+ Resource[] resources = new Resource[0];
+ String i18nKey = lang.getDesc().toLowerCase();
+ for (String dir : dirs) {
+ resources = ArrayUtils.addAll(resources, getResources(dir, i18nKey + ".json"));
+ }
+ for (Resource resource : resources) {
+ if (resource.exists()) {
+ try (InputStream inputStream = resource.getInputStream()) {
+ String fileContent = IOUtils.toString(inputStream, Charset.defaultCharset());
+ Map langMap = JSON.parseObject(fileContent, new TypeReference>() {
+ });
+ i18nMap.computeIfAbsent(i18nKey, k -> new HashMap<>());
+ i18nMap.get(i18nKey).putAll(langMap);
+ } catch (Exception e) {
+ e.printStackTrace();
+ LogUtil.error("failed to load resource: " + resource.getURI());
+ }
+ }
+ }
+ }
+ } catch (Exception e) {
+ LogUtil.error("failed to load i18n.", e);
+ }
+ }
+
+ /**
+ * 国际化配置初始化
+ */
+ @Override
+ public void run(ApplicationArguments args) {
+ init();
+ }
+}
diff --git a/backend/src/main/java/io/metersphere/i18n/Lang.java b/backend/src/main/java/io/metersphere/i18n/Lang.java
new file mode 100644
index 0000000000..866befd21e
--- /dev/null
+++ b/backend/src/main/java/io/metersphere/i18n/Lang.java
@@ -0,0 +1,48 @@
+package io.metersphere.i18n;
+
+import org.apache.commons.lang3.StringUtils;
+
+public enum Lang {
+
+ zh_CN("zh-CN"), zh_TW("zh-TW"), en_US("en-US");
+
+ private String desc;
+
+ Lang(String desc) {
+ this.desc = desc;
+ }
+
+ public String getDesc() {
+ return this.desc;
+ }
+
+ public static Lang getLang(String lang) {
+ Lang result = getLangWithoutDefault(lang);
+ if (result == null) {
+ result = zh_CN;
+ }
+ return result;
+ }
+
+ public static Lang getLangWithoutDefault(String lang) {
+ if (StringUtils.isBlank(lang)) {
+ return null;
+ }
+ for (Lang lang1 : values()) {
+ if (StringUtils.equalsIgnoreCase(lang1.getDesc(), lang)) {
+ return lang1;
+ }
+ }
+ if (StringUtils.startsWithIgnoreCase(lang, "zh-CN")) {
+ return zh_CN;
+ }
+ if (StringUtils.startsWithIgnoreCase(lang, "zh-HK") || StringUtils.startsWithIgnoreCase(lang, "zh-TW")) {
+ return zh_TW;
+ }
+ if (StringUtils.startsWithIgnoreCase(lang, "en")) {
+ return en_US;
+ }
+ return null;
+ }
+
+}
diff --git a/backend/src/main/java/io/metersphere/i18n/Translator.java b/backend/src/main/java/io/metersphere/i18n/Translator.java
new file mode 100644
index 0000000000..0e87901eca
--- /dev/null
+++ b/backend/src/main/java/io/metersphere/i18n/Translator.java
@@ -0,0 +1,274 @@
+package io.metersphere.i18n;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.JavaBeanSerializer;
+import com.alibaba.fastjson.serializer.ObjectSerializer;
+import com.alibaba.fastjson.serializer.SerializeConfig;
+import io.metersphere.commons.constants.I18nConstants;
+import io.metersphere.commons.exception.MSException;
+import io.metersphere.commons.utils.BeanUtils;
+import io.metersphere.service.CommonBeanFactory;
+import io.metersphere.commons.utils.LogUtil;
+import io.metersphere.service.SystemParameterService;
+import io.metersphere.user.SessionUtils;
+import org.apache.commons.collections4.MapUtils;
+import org.apache.commons.collections4.map.PassiveExpiringMap;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.commons.lang3.exception.ExceptionUtils;
+import org.apache.commons.text.StringSubstitutor;
+import org.springframework.http.HttpHeaders;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+import java.lang.reflect.Array;
+import java.util.*;
+import java.util.concurrent.TimeUnit;
+
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+
+public class Translator {
+
+ public static final String PREFIX = "$[{";
+ public static final String SUFFIX = "}]";
+ private static final String JSON_SYMBOL = "\":";
+
+ private static final HashSet IGNORE_KEYS = new HashSet<>(Arrays.asList("id", "password", "passwd"));
+
+ private static Map langCache4Thread = Collections.synchronizedMap(new PassiveExpiringMap(1, TimeUnit.MINUTES));
+
+ public static String getLangDes() {
+ return getLang().getDesc();
+ }
+
+ public static Lang getLang() {
+ HttpServletRequest request = getRequest();
+ return getLang(request);
+ }
+
+ public static Object gets(Object keys) {
+ return gets(getLang(), keys);
+ }
+
+ public static Object gets(Lang lang, Object keys) {
+ Map context = I18nManager.getI18nMap().get(lang.getDesc().toLowerCase());
+ return translateObject(keys, context);
+ }
+
+ // 单Key翻译
+ public static String get(String key) {
+ return get(getLang(), key);
+ }
+
+ public static String get(Lang lang, String key) {
+ if (StringUtils.isBlank(key)) {
+ return StringUtils.EMPTY;
+ }
+ return translateKey(key, I18nManager.getI18nMap().get(lang.getDesc().toLowerCase()));
+ }
+
+ public static String toI18nKey(String key) {
+ return String.format("%s%s%s", PREFIX, key, SUFFIX);
+ }
+
+ private static HttpServletRequest getRequest() {
+ try {
+ return ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest();
+ } catch (NullPointerException npe) {
+ return null;
+ }
+ }
+
+ private static Lang getLang(HttpServletRequest request) {
+ String preferLang = Lang.zh_CN.getDesc();
+
+ try {
+
+ if (request != null) {
+ Object sessionLang = request.getSession(true).getAttribute(I18nConstants.LANG_COOKIE_NAME);
+ if (sessionLang != null && StringUtils.isNotBlank(sessionLang.toString())) {
+ return Lang.getLang(sessionLang.toString());
+ }
+ preferLang = getSystemParameterLanguage(preferLang);
+ if (StringUtils.isNotBlank(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE))) {
+ String preferLangWithComma = StringUtils.substringBefore(request.getHeader(HttpHeaders.ACCEPT_LANGUAGE), ";");
+ String acceptLanguage = StringUtils.replace(StringUtils.substringBefore(preferLangWithComma, ","), "-", "_");
+ if (Lang.getLangWithoutDefault(acceptLanguage) != null) {
+ preferLang = acceptLanguage;
+ }
+ }
+ if (request.getCookies() != null && request.getCookies().length > 0) {
+ for (Cookie cookie : request.getCookies()) {
+ if (StringUtils.equalsIgnoreCase(cookie.getName(), I18nConstants.LANG_COOKIE_NAME)) {
+ preferLang = cookie.getValue();
+ }
+ }
+ }
+ if (SessionUtils.getUser() != null && StringUtils.isNotBlank(SessionUtils.getUser().getLanguage())) {
+ preferLang = SessionUtils.getUser().getLanguage();
+ }
+ request.getSession(true).setAttribute(I18nConstants.LANG_COOKIE_NAME, preferLang);
+ } else {
+ preferLang = getSystemParameterLanguage(preferLang);
+ }
+
+ } catch (Exception e) {
+ LogUtil.error("Fail to getLang.", e);
+ }
+
+ return Lang.getLang(preferLang);
+ }
+
+ private static String getSystemParameterLanguage(String defaultLang) {
+ String result = defaultLang;
+ try {
+ String cachedLang = langCache4Thread.get(I18nConstants.LANG_COOKIE_NAME);
+ if (StringUtils.isNotBlank(cachedLang)) {
+ return cachedLang;
+ }
+ String systemLanguage = Objects.requireNonNull(CommonBeanFactory.getBean(SystemParameterService.class)).getSystemLanguage();
+ if (StringUtils.isNotBlank(systemLanguage)) {
+ result = systemLanguage;
+ }
+ langCache4Thread.put(I18nConstants.LANG_COOKIE_NAME, result);
+ } catch (Exception e) {
+ LogUtil.error(e);
+ }
+ return result;
+
+ }
+
+ private static Object translateObject(Object javaObject, final Map context) {
+ if (MapUtils.isEmpty(context)) {
+ return javaObject;
+ }
+ if (javaObject == null) {
+ return null;
+ }
+
+ try {
+ if (javaObject instanceof String) {
+ String rawString = javaObject.toString();
+ if (StringUtils.contains(rawString, JSON_SYMBOL)) {
+ try {
+ Object jsonObject = JSON.parse(rawString);
+ Object a = translateObject(jsonObject, context);
+ return JSON.toJSONString(a);
+ } catch (Exception e) {
+ LogUtil.warn("Failed to translate object " + rawString + ". Error: " + ExceptionUtils.getStackTrace(e));
+ return translateRawString(null, rawString, context);
+ }
+
+ } else {
+ return translateRawString(null, rawString, context);
+ }
+ }
+
+ if (javaObject instanceof Map) {
+ Map