添加 i8n 国际化模块

This commit is contained in:
smallchill 2025-10-11 17:54:19 +08:00
parent 929761ac34
commit 2bb0dc2073
17 changed files with 1535 additions and 0 deletions

View File

@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springblade</groupId>
<artifactId>blade-tool</artifactId>
<version>${revision}</version>
</parent>
<artifactId>blade-starter-i18n</artifactId>
<name>${project.artifactId}</name>
<packaging>jar</packaging>
<properties>
<module.name>org.springblade.blade.starter.i18n</module.name>
</properties>
<dependencies>
<!-- Blade -->
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-core-tool</artifactId>
</dependency>
<!-- Spring Boot Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,129 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.i18n.config;
import jakarta.annotation.Nonnull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.i18n.interceptor.I18nInterceptor;
import org.springblade.core.i18n.props.I18nProperties;
import org.springblade.core.i18n.resolver.I18nLocaleResolver;
import org.springblade.core.i18n.service.I18nService;
import org.springblade.core.tool.utils.Func;
import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.MessageSource;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Primary;
import org.springframework.context.support.ReloadableResourceBundleMessageSource;
import org.springframework.web.servlet.LocaleResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* I18n自动配置类
*
* @author BladeX
*/
@Slf4j
@AutoConfiguration
@RequiredArgsConstructor
@EnableConfigurationProperties(I18nProperties.class)
@ConditionalOnProperty(prefix = I18nProperties.PREFIX, name = "enabled", havingValue = "true", matchIfMissing = true)
public class I18nAutoConfiguration {
/**
* 类路径前缀常量
*/
private static final String CLASSPATH_PREFIX = "classpath:";
/**
* 国际化配置类
*/
private final I18nProperties properties;
/**
* 覆盖Spring配置的MessageSource
*/
@Bean
@Primary
public MessageSource messageSource() {
ReloadableResourceBundleMessageSource messageSource = new ReloadableResourceBundleMessageSource();
I18nProperties.MessageSource config = properties.getMessageSource();
// 设置基础名列表
String[] baseNames = config.getBaseNames().stream()
.map(name -> name.startsWith(CLASSPATH_PREFIX) ? name : CLASSPATH_PREFIX + name)
.toArray(String[]::new);
messageSource.setBasenames(baseNames);
// 设置编码
messageSource.setDefaultEncoding(config.getEncoding());
// 设置缓存时间
if (config.getCacheDuration() != null) {
messageSource.setCacheSeconds(Func.toInt(config.getCacheDuration().getSeconds()));
}
// 设置是否使用代码作为默认消息
messageSource.setUseCodeAsDefaultMessage(config.isUseCodeAsDefaultMessage());
return messageSource;
}
/**
* 覆盖Spring配置的Locale解析器
*/
@Bean
@Primary
public LocaleResolver localeResolver() {
return new I18nLocaleResolver(properties);
}
/**
* 配置I18n服务
*/
@Bean
@ConditionalOnMissingBean
public I18nService i18nService(MessageSource messageSource, LocaleResolver localeResolver) {
return new I18nService(messageSource, localeResolver, properties);
}
/**
* 配置I18n拦截器
*/
@Bean
@ConditionalOnMissingBean
public I18nInterceptor i18nInterceptor() {
return new I18nInterceptor(properties);
}
/**
* 注册I18n拦截器
*/
@Bean
@ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.SERVLET)
public WebMvcConfigurer i18nWebMvcConfigurer(I18nInterceptor i18nInterceptor) {
return new WebMvcConfigurer() {
@Override
public void addInterceptors(@Nonnull InterceptorRegistry registry) {
registry.addInterceptor(i18nInterceptor)
.addPathPatterns("/**")
.order(0);
}
};
}
}

View File

@ -0,0 +1,110 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.i18n.interceptor;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.i18n.props.I18nProperties;
import org.springblade.core.i18n.utils.LocaleParseUtil;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.lang.NonNull;
import org.springframework.web.servlet.HandlerInterceptor;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* I18n拦截器
*
* @author BladeX
*/
@Slf4j
@RequiredArgsConstructor
public class I18nInterceptor implements HandlerInterceptor {
private static final String LOCALE_ATTRIBUTE = "locale";
private static final String LANG_ATTRIBUTE = "lang";
private static final String CONTENT_LANGUAGE_HEADER = "Content-Language";
private final I18nProperties properties;
private final Locale defaultLocale;
private final Set<String> supportedLocales;
public I18nInterceptor(I18nProperties properties) {
this.properties = properties;
this.defaultLocale = LocaleParseUtil.parseLocale(properties.getDefaultLocale());
this.supportedLocales = properties.getSupportLocales().stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
}
/**
* 请求处理前设置Locale
*
* @param request 请求
* @param response 响应
* @param handler 处理器
* @return 是否继续处理
*/
@Override
public boolean preHandle(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull Object handler) {
try {
// 解析Locale
Locale locale = LocaleParseUtil.resolveFromRequest(
request,
properties.getHeaderName(),
properties.getParamName(),
supportedLocales,
defaultLocale
);
// 设置到LocaleContextHolder
LocaleContextHolder.setLocale(locale);
// 设置到请求属性
request.setAttribute(LOCALE_ATTRIBUTE, locale);
request.setAttribute(LANG_ATTRIBUTE, locale.toString());
// 添加响应头告知客户端当前使用的语言
response.setHeader(CONTENT_LANGUAGE_HEADER, locale.toLanguageTag());
} catch (Exception e) {
// 设置默认Locale
LocaleContextHolder.setLocale(this.defaultLocale);
}
return true;
}
/**
* 请求完成后清理Locale上下文
*
* @param request 请求
* @param response 响应
* @param handler 处理器
* @param ex 异常
*/
@Override
public void afterCompletion(@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler,
Exception ex) {
// 清理LocaleContextHolder
LocaleContextHolder.resetLocaleContext();
// 清理请求属性
request.removeAttribute(LOCALE_ATTRIBUTE);
request.removeAttribute(LANG_ATTRIBUTE);
}
}

View File

@ -0,0 +1,93 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.i18n.props;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
/**
* I18n配置属性
*
* @author BladeX
*/
@Data
@ConfigurationProperties(prefix = I18nProperties.PREFIX)
public class I18nProperties {
public static final String PREFIX = "blade.i18n";
/**
* 是否启用i18n
*/
private boolean enabled = true;
/**
* 默认locale
*/
private String defaultLocale = "zh_CN";
/**
* 支持的locales
*/
private List<String> supportLocales = new ArrayList<>();
/**
* HTTP头名称
*/
private String headerName = "Accept-Language";
/**
* 请求参数名称
*/
private String paramName = "lang";
/**
* 消息源配置
*/
private MessageSource messageSource = new MessageSource();
/**
* 消息源配置
*/
@Data
public static class MessageSource {
/**
* 国际化基础名列表
* 示例
* - i18n/errors
* - i18n/messages
*/
private List<String> baseNames = List.of("i18n/errors", "i18n/messages");
/**
* 编码
*/
private String encoding = "UTF-8";
/**
* 缓存过期时间
*/
private Duration cacheDuration = Duration.ofMinutes(30);
/**
* 是否使用代码作为默认消息
*/
private boolean useCodeAsDefaultMessage = true;
}
}

View File

@ -0,0 +1,78 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.i18n.resolver;
import jakarta.annotation.Nonnull;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.i18n.props.I18nProperties;
import org.springblade.core.i18n.utils.LocaleParseUtil;
import org.springframework.web.servlet.LocaleResolver;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
/**
* I18nLocale解析器
*
* @author BladeX
*/
@Slf4j
@RequiredArgsConstructor
public class I18nLocaleResolver implements LocaleResolver {
private final I18nProperties properties;
private final Locale defaultLocale;
private final Set<String> supportedLocales;
public I18nLocaleResolver(I18nProperties properties) {
this.properties = properties;
this.defaultLocale = LocaleParseUtil.parseLocale(properties.getDefaultLocale());
this.supportedLocales = properties.getSupportLocales().stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
}
@Nonnull
@Override
public Locale resolveLocale(@Nonnull HttpServletRequest request) {
return LocaleParseUtil.resolveFromRequest(
request,
properties.getHeaderName(),
properties.getParamName(),
supportedLocales,
defaultLocale
);
}
@Override
public void setLocale(@Nonnull HttpServletRequest request, HttpServletResponse response, Locale locale) {
if (response != null && locale != null) {
// 检查是否支持该locale
boolean isSupported = supportedLocales.isEmpty() ||
supportedLocales.contains(locale.toString().toLowerCase()) ||
supportedLocales.contains(locale.getLanguage().toLowerCase());
if (isSupported) {
// 可选设置响应头提示客户端当前的 Locale
response.setHeader(properties.getHeaderName(), locale.toLanguageTag());
}
}
}
}

View File

@ -0,0 +1,243 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.i18n.service;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.i18n.props.I18nProperties;
import org.springblade.core.i18n.utils.LocaleParseUtil;
import org.springblade.core.tool.utils.StringPool;
import org.springblade.core.tool.utils.StringUtil;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceResolvable;
import org.springframework.context.NoSuchMessageException;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.util.StringUtils;
import org.springframework.web.servlet.LocaleResolver;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* I18n服务实现类
*
* @author BladeX
*/
@Slf4j
@RequiredArgsConstructor
public class I18nService {
private final MessageSource messageSource;
private final LocaleResolver localeResolver;
private final I18nProperties properties;
/**
* 获取消息 - 核心方法
*
* @param code 消息代码
* @return 国际化消息
*/
public String getMessage(String code) {
return getMessage(code, null, null, getCurrentLocale());
}
/**
* 获取消息
*
* @param code 消息代码
* @param args 消息参数
* @return 国际化消息
*/
public String getMessage(String code, Object[] args) {
return getMessage(code, args, null, getCurrentLocale());
}
/**
* 获取消息带默认值
*
* @param code 消息代码
* @param args 消息参数
* @param defaultMessage 默认消息
* @return 国际化消息
*/
public String getMessage(String code, Object[] args, String defaultMessage) {
return getMessage(code, args, defaultMessage, getCurrentLocale());
}
/**
* 获取消息 - 完整版本
*
* @param code 消息代码
* @param args 消息参数
* @param defaultMessage 默认消息
* @param locale 区域设置
* @return 国际化消息
*/
public String getMessage(String code, Object[] args, String defaultMessage, Locale locale) {
if (StringUtil.isBlank(code)) {
return defaultMessage != null ? defaultMessage : StringPool.EMPTY;
}
final Locale finalLocale = Optional.ofNullable(locale).orElseGet(this::getCurrentLocale);
try {
return messageSource.getMessage(code, args, finalLocale);
} catch (NoSuchMessageException e) {
if (log.isDebugEnabled()) {
log.debug("No message found for code '{}' with locale '{}'", code, finalLocale);
}
if (defaultMessage != null) {
return defaultMessage;
}
return properties.getMessageSource().isUseCodeAsDefaultMessage() ? code : StringPool.EMPTY;
} catch (Exception e) {
log.error("Error retrieving message for code '{}': {}", code, e.getMessage());
return defaultMessage != null ? defaultMessage : code;
}
}
/**
* 使用MessageSourceResolvable获取消息
*
* @param resolvable MessageSourceResolvable对象
* @param locale 区域设置
* @return 国际化消息
*/
public String getMessage(MessageSourceResolvable resolvable, Locale locale) {
final Locale finalLocale = Optional.ofNullable(locale).orElseGet(this::getCurrentLocale);
try {
return messageSource.getMessage(resolvable, finalLocale);
} catch (NoSuchMessageException e) {
log.debug("No message found for resolvable with locale '{}'", finalLocale);
return resolvable.getDefaultMessage() != null ? resolvable.getDefaultMessage() : "";
}
}
/**
* 批量获取消息
*
* @param codes 消息代码数组
* @return 消息映射
*/
public Map<String, String> getMessages(String... codes) {
return getMessages(getCurrentLocale(), codes);
}
/**
* 批量获取消息指定Locale
*
* @param locale 区域设置
* @param codes 消息代码数组
* @return 消息映射
*/
public Map<String, String> getMessages(Locale locale, String... codes) {
if (codes == null || codes.length == 0) {
return Collections.emptyMap();
}
final Locale finalLocale = Optional.ofNullable(locale).orElseGet(this::getCurrentLocale);
return Stream.of(codes)
.distinct()
.collect(Collectors.toMap(
code -> code,
code -> getMessage(code, null, null, finalLocale),
(v1, v2) -> v1,
LinkedHashMap::new
));
}
/**
* 向后兼容的getBatch方法
*/
public Map<String, String> getBatch(String... codes) {
return getMessages(codes);
}
/**
* 获取当前Locale
*
* @return 当前区域设置
*/
public Locale getCurrentLocale() {
return LocaleContextHolder.getLocale();
}
/**
* 获取当前Locale从HttpServletRequest解析
*
* @param request HTTP请求对象
* @return 当前区域设置
*/
public Locale getCurrentLocale(HttpServletRequest request) {
if (request == null) {
return getCurrentLocale();
}
return localeResolver.resolveLocale(request);
}
/**
* 检查消息是否存在
*
* @param code 消息代码
* @return 如果消息存在返回true否则返回false
*/
public boolean hasMessage(String code) {
return hasMessage(code, getCurrentLocale());
}
/**
* 检查消息是否存在指定Locale
*
* @param code 消息代码
* @param locale 区域设置
* @return 如果消息存在返回true否则返回false
*/
public boolean hasMessage(String code, Locale locale) {
if (!StringUtils.hasText(code)) {
return false;
}
final Locale finalLocale = Optional.ofNullable(locale).orElseGet(this::getCurrentLocale);
try {
messageSource.getMessage(code, null, finalLocale);
return true;
} catch (NoSuchMessageException e) {
return false;
}
}
/**
* 获取支持的Locales
*
* @return 支持的区域设置列表
*/
public List<Locale> getSupportLocales() {
List<String> supportedLocaleStrings = properties.getSupportLocales();
if (supportedLocaleStrings == null || supportedLocaleStrings.isEmpty()) {
return Collections.singletonList(LocaleParseUtil.parseLocale(properties.getDefaultLocale()));
}
return supportedLocaleStrings.stream()
.map(LocaleParseUtil::parseLocale)
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
}
}

View File

@ -0,0 +1,402 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.i18n.utils;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.springblade.core.i18n.service.I18nService;
import org.springblade.core.tool.utils.SpringUtil;
import org.springblade.core.tool.utils.StringPool;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
/**
* I18n静态工具类
*
* @author BladeX
*/
@Slf4j
@UtilityClass
public class I18nUtil {
/**
* I18nService服务类
*/
private static volatile I18nService i18nService;
/**
* 获取I18nService实例
*
* @return I18nService实例
*/
private static I18nService getI18nService() {
if (i18nService == null) {
synchronized (I18nUtil.class) {
if (i18nService == null) {
i18nService = SpringUtil.getBean(I18nService.class);
if (i18nService == null) {
throw new IllegalStateException("I18nService not available. Please ensure I18n is properly configured.");
}
}
}
}
return i18nService;
}
// ==================== 基础获取方法 ====================
/**
* 获取消息最常用
*
* @param code 消息代码
* @return 国际化消息
*/
public static String get(String code) {
return Optional.ofNullable(code)
.map(c -> getI18nService().getMessage(c))
.orElse(StringPool.EMPTY);
}
/**
* 获取消息带参数
*
* @param code 消息代码
* @param args 消息参数数组
* @return 国际化消息
*/
public static String get(String code, Object[] args) {
return Optional.ofNullable(code)
.map(c -> getI18nService().getMessage(c, args))
.orElse(StringPool.EMPTY);
}
/**
* 获取消息带参数List形式
*
* @param code 消息代码
* @param args 消息参数列表
* @return 国际化消息
*/
public static String get(String code, List<Object> args) {
return Optional.ofNullable(code)
.map(c -> getI18nService().getMessage(c, args != null ? args.toArray() : null))
.orElse(StringPool.EMPTY);
}
// ==================== 指定Locale的获取方法 ====================
/**
* 获取消息指定Locale
*
* @param code 消息代码
* @param locale 区域设置
* @return 国际化消息
*/
public static String get(String code, Locale locale) {
return Optional.ofNullable(code)
.map(c -> getI18nService().getMessage(c, null, null, locale))
.orElse(StringPool.EMPTY);
}
/**
* 获取消息带参数和Locale
*
* @param code 消息代码
* @param args 消息参数数组
* @param locale 区域设置
* @return 国际化消息
*/
public static String get(String code, Object[] args, Locale locale) {
return Optional.ofNullable(code)
.map(c -> getI18nService().getMessage(c, args, null, locale))
.orElse(StringPool.EMPTY);
}
/**
* 获取消息带参数和LocaleList形式
*
* @param code 消息代码
* @param args 消息参数列表
* @param locale 区域设置
* @return 国际化消息
*/
public static String get(String code, List<Object> args, Locale locale) {
return Optional.ofNullable(code)
.map(c -> getI18nService().getMessage(c, args != null ? args.toArray() : null, null, locale))
.orElse(StringPool.EMPTY);
}
// ==================== 带默认值的获取方法 ====================
/**
* 获取消息如果未找到则返回默认值
*
* @param code 消息代码
* @param defaultValue 默认值
* @return 国际化消息或默认值
*/
public static String getOrDefault(String code, String defaultValue) {
return Optional.ofNullable(code)
.map(c -> {
String message = getI18nService().getMessage(c);
return (message != null && !message.equals(c)) ? message : defaultValue;
})
.orElse(defaultValue);
}
/**
* 获取消息如果未找到则返回默认值带参数
*
* @param code 消息代码
* @param args 消息参数数组
* @param defaultValue 默认值
* @return 国际化消息或默认值
*/
public static String getOrDefault(String code, Object[] args, String defaultValue) {
return Optional.ofNullable(code)
.map(c -> {
String message = getI18nService().getMessage(c, args);
return (message != null && !message.equals(c)) ? message : defaultValue;
})
.orElse(defaultValue);
}
/**
* 获取消息如果未找到则返回默认值带参数List形式
*
* @param code 消息代码
* @param args 消息参数列表
* @param defaultValue 默认值
* @return 国际化消息或默认值
*/
public static String getOrDefault(String code, List<Object> args, String defaultValue) {
return Optional.ofNullable(code)
.map(c -> {
String message = getI18nService().getMessage(c, args != null ? args.toArray() : null);
return (message != null && !message.equals(c)) ? message : defaultValue;
})
.orElse(defaultValue);
}
/**
* 获取消息如果未找到则返回默认值指定Locale
*
* @param code 消息代码
* @param locale 区域设置
* @param defaultValue 默认值
* @return 国际化消息或默认值
*/
public static String getOrDefault(String code, Locale locale, String defaultValue) {
return Optional.ofNullable(code)
.map(c -> {
String message = getI18nService().getMessage(c, null, null, locale);
return (message != null && !message.equals(c)) ? message : defaultValue;
})
.orElse(defaultValue);
}
/**
* 获取消息如果未找到则返回默认值带参数和Locale
*
* @param code 消息代码
* @param args 消息参数数组
* @param locale 区域设置
* @param defaultValue 默认值
* @return 国际化消息或默认值
*/
public static String getOrDefault(String code, Object[] args, Locale locale, String defaultValue) {
return Optional.ofNullable(code)
.map(c -> {
String message = getI18nService().getMessage(c, args, null, locale);
return (message != null && !message.equals(c)) ? message : defaultValue;
})
.orElse(defaultValue);
}
/**
* 获取消息如果未找到则返回默认值带参数和LocaleList形式
*
* @param code 消息代码
* @param args 消息参数列表
* @param locale 区域设置
* @param defaultValue 默认值
* @return 国际化消息或默认值
*/
public static String getOrDefault(String code, List<Object> args, Locale locale, String defaultValue) {
return Optional.ofNullable(code)
.map(c -> {
String message = getI18nService().getMessage(c, args != null ? args.toArray() : null, null, locale);
return (message != null && !message.equals(c)) ? message : defaultValue;
})
.orElse(defaultValue);
}
// ==================== 检查方法 ====================
/**
* 检查消息是否存在
*
* @param code 消息代码
* @return 如果消息存在返回true否则返回false
*/
public static boolean exists(String code) {
if (code == null || code.trim().isEmpty()) {
return false;
}
return getI18nService().hasMessage(code);
}
/**
* 检查消息是否存在指定Locale
*
* @param code 消息代码
* @param locale 区域设置
* @return 如果消息存在返回true否则返回false
*/
public static boolean exists(String code, Locale locale) {
if (code == null || code.trim().isEmpty()) {
return false;
}
return getI18nService().hasMessage(code, locale);
}
// ==================== 批量获取方法 ====================
/**
* 批量获取消息
*
* @param codes 消息代码数组
* @return 消息代码与消息内容的映射
*/
public static Map<String, String> getBatch(String[] codes) {
return getI18nService().getMessages(codes);
}
/**
* 批量获取消息指定Locale
*
* @param codes 消息代码数组
* @param locale 区域设置
* @return 消息代码与消息内容的映射
*/
public static Map<String, String> getBatch(String[] codes, Locale locale) {
return getI18nService().getMessages(locale, codes);
}
/**
* 批量获取消息List形式
*
* @param codes 消息代码列表
* @return 消息代码与消息内容的映射
*/
public static Map<String, String> getBatch(List<String> codes) {
return getI18nService().getMessages(codes != null ? codes.toArray(new String[0]) : null);
}
/**
* 批量获取消息指定LocaleList形式
*
* @param codes 消息代码列表
* @param locale 区域设置
* @return 消息代码与消息内容的映射
*/
public static Map<String, String> getBatch(List<String> codes, Locale locale) {
return getI18nService().getMessages(locale, codes != null ? codes.toArray(new String[0]) : null);
}
// ==================== 工具方法 ====================
/**
* 获取当前Locale
*
* @return 当前区域设置
*/
public static Locale getCurrentLocale() {
return getI18nService().getCurrentLocale();
}
// ==================== 简化方法 ====================
/**
* 简化的消息获取方法
*
* @param code 消息代码
* @return 国际化消息
*/
public static String $(String code) {
return get(code);
}
/**
* 简化的消息获取方法带参数
*
* @param code 消息代码
* @param args 消息参数数组
* @return 国际化消息
*/
public static String $(String code, Object[] args) {
return get(code, args);
}
/**
* 简化的消息获取方法带参数List形式
*
* @param code 消息代码
* @param args 消息参数列表
* @return 国际化消息
*/
public static String $(String code, List<Object> args) {
return get(code, args);
}
/**
* 简化的消息获取方法指定Locale
*
* @param code 消息代码
* @param locale 区域设置
* @return 国际化消息
*/
public static String $(String code, Locale locale) {
return get(code, locale);
}
/**
* 简化的消息获取方法带参数和Locale
*
* @param code 消息代码
* @param args 消息参数数组
* @param locale 区域设置
* @return 国际化消息
*/
public static String $(String code, Object[] args, Locale locale) {
return get(code, args, locale);
}
/**
* 简化的消息获取方法带参数和LocaleList形式
*
* @param code 消息代码
* @param args 消息参数列表
* @param locale 区域设置
* @return 国际化消息
*/
public static String $(String code, List<Object> args, Locale locale) {
return get(code, args, locale);
}
}

View File

@ -0,0 +1,289 @@
/**
* Copyright (c) 2018-2099, Chill Zhuang 庄骞 (bladejava@qq.com).
* <p>
* Licensed under the GNU LESSER GENERAL PUBLIC LICENSE 3.0;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* <p>
* http://www.gnu.org/licenses/lgpl.html
* <p>
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.springblade.core.i18n.utils;
import jakarta.servlet.http.HttpServletRequest;
import lombok.experimental.UtilityClass;
import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import java.util.Locale;
import java.util.Optional;
import java.util.Set;
import java.util.regex.Pattern;
/**
* Locale解析工具类
*
* @author BladeX
*/
@Slf4j
@UtilityClass
public class LocaleParseUtil {
/**
* Accept-Language header pattern
* 用于匹配和验证locale格式zh-CN, en-US
*/
private static final Pattern LOCALE_PATTERN = Pattern.compile("^[a-z]{2}(-[A-Z]{2})?$");
/**
* 权重分隔符
*/
private static final String QUALITY_VALUE_SEPARATOR = ";";
/**
* Locale分隔符
*/
private static final String LOCALE_SEPARATOR = ",";
/**
* 下划线分隔符
*/
private static final String UNDERSCORE = "_";
/**
* 中划线分隔符
*/
private static final String DASH = "-";
/**
* 解析Locale字符串
* 支持多种格式
* 1. 简单格式zh_CN, zh-CN, en, en-US
* 2. Accept-Language格式zh-CN,zh;q=0.9,en;q=0.8
* 3. 多个locale逗号分隔zh-CN,en-US,ja-JP
*
* @param localeStr locale字符串
* @return 解析后的Locale对象如果无法解析则返回默认Locale
*/
public static Locale parseLocale(String localeStr) {
if (!StringUtils.hasText(localeStr)) {
return Locale.getDefault();
}
try {
// 提取第一个有效的locale
String firstLocale = extractFirstLocale(localeStr);
// 标准化locale格式将下划线替换为中划线
String normalizedLocale = normalizeLocaleString(firstLocale);
// 验证locale格式
if (isValidLocaleFormat(normalizedLocale)) {
return Locale.forLanguageTag(normalizedLocale);
}
// 如果格式不完全匹配尝试直接解析
return Locale.forLanguageTag(normalizedLocale);
} catch (Exception e) {
log.debug("Failed to parse locale string '{}', using default locale. Error: {}",
localeStr, e.getMessage());
return Locale.getDefault();
}
}
/**
* 从Accept-Language或类似格式中提取第一个locale
* 处理格式如zh-CN,zh;q=0.9,en;q=0.8
*
* @param localeStr 原始locale字符串
* @return 第一个locale字符串
*/
private static String extractFirstLocale(String localeStr) {
// 去除首尾空格
String trimmed = localeStr.trim();
// 如果包含逗号说明可能是多个locale
if (trimmed.contains(LOCALE_SEPARATOR)) {
// 获取第一个逗号前的内容
trimmed = trimmed.substring(0, trimmed.indexOf(LOCALE_SEPARATOR)).trim();
}
// 如果包含分号权重值去除权重部分
if (trimmed.contains(QUALITY_VALUE_SEPARATOR)) {
trimmed = trimmed.substring(0, trimmed.indexOf(QUALITY_VALUE_SEPARATOR)).trim();
}
return trimmed;
}
/**
* 标准化locale字符串格式
* 将下划线替换为中划线符合BCP 47标准
*
* @param localeStr locale字符串
* @return 标准化后的locale字符串
*/
private static String normalizeLocaleString(String localeStr) {
if (!StringUtils.hasText(localeStr)) {
return localeStr;
}
// 将下划线替换为中划线统一格式
return localeStr.replace(UNDERSCORE, DASH).trim();
}
/**
* 验证locale格式是否有效
* 支持格式en, zh-CN, en-US等
*
* @param localeStr 标准化后的locale字符串
* @return 是否为有效格式
*/
private static boolean isValidLocaleFormat(String localeStr) {
if (!StringUtils.hasText(localeStr)) {
return false;
}
// 对于简单的语言代码zh, en直接认为有效
if (localeStr.length() == 2 && localeStr.matches("^[a-z]{2}$")) {
return true;
}
// 对于完整格式zh-CN进行格式验证
// 转换为小写-大写格式进行验证
String[] parts = localeStr.split(DASH);
if (parts.length == 2) {
String normalized = parts[0].toLowerCase() + DASH + parts[1].toUpperCase();
return LOCALE_PATTERN.matcher(normalized).matches();
}
return false;
}
/**
* 安全地解析Locale永不返回null
*
* @param localeStr locale字符串
* @param defaultLocale 默认Locale
* @return 解析后的Locale对象解析失败则返回defaultLocale
*/
public static Locale parseLocaleSafely(String localeStr, Locale defaultLocale) {
if (defaultLocale == null) {
defaultLocale = Locale.getDefault();
}
if (!StringUtils.hasText(localeStr)) {
return defaultLocale;
}
try {
Locale parsed = parseLocale(localeStr);
return parsed != null ? parsed : defaultLocale;
} catch (Exception e) {
log.debug("Safe parse failed for locale '{}', returning default", localeStr);
return defaultLocale;
}
}
/**
* 从HttpServletRequest中解析Locale
* 优先级Header > Parameter > Default
*
* @param request HTTP请求对象
* @param headerName Header参数名
* @param paramName Query参数名
* @param supportedLocales 支持的Locale列表
* @param defaultLocale 默认Locale
* @return 解析后的Locale对象
*/
public static Locale resolveFromRequest(HttpServletRequest request,
String headerName,
String paramName,
Set<String> supportedLocales,
Locale defaultLocale) {
if (request == null) {
return defaultLocale != null ? defaultLocale : Locale.getDefault();
}
// 优先从Header中解析
Locale locale = resolveFromHeader(request, headerName);
// 如果Header中没有从Parameter中解析
if (locale == null) {
locale = resolveFromParameter(request, paramName);
}
// 检查是否在支持列表中
if (isSupported(locale, supportedLocales)) {
return locale;
}
return defaultLocale != null ? defaultLocale : Locale.getDefault();
}
/**
* 从HTTP Header中解析Locale
*
* @param request HTTP请求对象
* @param headerName Header参数名
* @return 解析后的Locale对象如果无法解析则返回null
*/
public static Locale resolveFromHeader(HttpServletRequest request, String headerName) {
if (request == null || !StringUtils.hasText(headerName)) {
return null;
}
return Optional.ofNullable(request.getHeader(headerName))
.filter(StringUtils::hasText)
.map(LocaleParseUtil::parseLocale)
.orElse(null);
}
/**
* 从请求参数中解析Locale
*
* @param request HTTP请求对象
* @param paramName 参数名
* @return 解析后的Locale对象如果无法解析则返回null
*/
public static Locale resolveFromParameter(HttpServletRequest request, String paramName) {
if (request == null || !StringUtils.hasText(paramName)) {
return null;
}
return Optional.ofNullable(request.getParameter(paramName))
.filter(StringUtils::hasText)
.map(LocaleParseUtil::parseLocale)
.orElse(null);
}
/**
* 检查Locale是否在支持列表中
*
* @param locale 待检查的Locale
* @param supportedLocales 支持的Locale列表小写
* @return 是否支持
*/
public static boolean isSupported(Locale locale, Set<String> supportedLocales) {
if (locale == null) {
return false;
}
// 如果支持列表为空表示支持所有locale
if (supportedLocales == null || supportedLocales.isEmpty()) {
return true;
}
String localeStr = locale.toString().toLowerCase();
String language = locale.getLanguage().toLowerCase();
// 检查完整locale或仅语言代码是否在支持列表中
return supportedLocales.contains(localeStr) || supportedLocales.contains(language);
}
}

View File

@ -0,0 +1,24 @@
# 验证错误
error.validation.default=参数校验失败。
user.name.required=请输入姓名。
user.name.size=姓名长度需在{min}到{max}个字符之间。
user.email.invalid=请输入有效的邮箱地址。
user.description.size=描述不能超过{max}个字符。
# 业务错误
error.biz.denied=不允许的操作:{0}。
error.auth.unauthorized=请先登录。
error.permission.denied=您没有访问{0}的权限。
error.resource.notfound={0} ID {1} 不存在。
error.user.delete.admin=不能删除管理员用户。
error.unknown=未知错误类型:{0}
# 服务器错误
error.server.internal=服务器内部错误,请稍后重试。
error.server.busy=服务器繁忙,请稍后重试。
error.server.maintenance=系统维护中。
# 数据错误
error.data.duplicate=数据已存在。
error.data.invalid=数据格式无效。
error.data.notfound=数据不存在。

View File

@ -0,0 +1,24 @@
# Validation errors
error.validation.default=Validation failed.
user.name.required=Name is required.
user.name.size=Name length must be between {min} and {max} characters.
user.email.invalid=Please provide a valid email address.
user.description.size=Description cannot exceed {max} characters.
# Business errors
error.biz.denied=Operation {0} is not allowed.
error.auth.unauthorized=Authentication required.
error.permission.denied=You don't have permission to access {0}.
error.resource.notfound={0} with ID {1} not found.
error.user.delete.admin=Cannot delete admin user.
error.unknown=Unknown error type: {0}
# Server errors
error.server.internal=Internal server error. Please try again later.
error.server.busy=Server is busy. Please try again later.
error.server.maintenance=System is under maintenance.
# Data errors
error.data.duplicate=Duplicate data found.
error.data.invalid=Invalid data format.
error.data.notfound=Data not found.

View File

@ -0,0 +1,24 @@
# 验证错误
error.validation.default=参数校验失败。
user.name.required=请输入姓名。
user.name.size=姓名长度需在{min}到{max}个字符之间。
user.email.invalid=请输入有效的邮箱地址。
user.description.size=描述不能超过{max}个字符。
# 业务错误
error.biz.denied=不允许的操作:{0}。
error.auth.unauthorized=请先登录。
error.permission.denied=您没有访问{0}的权限。
error.resource.notfound={0} ID {1} 不存在。
error.user.delete.admin=不能删除管理员用户。
error.unknown=未知错误类型:{0}
# 服务器错误
error.server.internal=服务器内部错误,请稍后重试。
error.server.busy=服务器繁忙,请稍后重试。
error.server.maintenance=系统维护中。
# 数据错误
error.data.duplicate=数据已存在。
error.data.invalid=数据格式无效。
error.data.notfound=数据不存在。

View File

@ -0,0 +1,24 @@
# 驗證錯誤
error.validation.default=參數校驗失敗。
user.name.required=請輸入姓名。
user.name.size=姓名長度需在{min}到{max}個字元之間。
user.email.invalid=請輸入有效的電子郵件地址。
user.description.size=描述不能超過{max}個字元。
# 業務錯誤
error.biz.denied=不允許的操作:{0}。
error.auth.unauthorized=請先登入。
error.permission.denied=您沒有存取{0}的權限。
error.resource.notfound={0} ID {1} 不存在。
error.user.delete.admin=不能刪除管理員使用者。
error.unknown=未知錯誤類型:{0}
# 伺服器錯誤
error.server.internal=伺服器內部錯誤,請稍後重試。
error.server.busy=伺服器忙碌中,請稍後重試。
error.server.maintenance=系統維護中。
# 資料錯誤
error.data.duplicate=資料已存在。
error.data.invalid=資料格式無效。
error.data.notfound=資料不存在。

View File

@ -0,0 +1,14 @@
# 问候消息
greeting.message=你好,{0}
greeting.welcome=欢迎使用我们的应用
# 用户消息
user.create.success=用户 {0} 创建成功。
user.update.success=ID为 {1} 的用户 {0} 更新成功。
user.delete.success=ID为 {0} 的用户删除成功。
user.notfound=用户未找到
# 成功消息
operation.success=操作成功完成
save.success=数据保存成功
delete.success=数据删除成功

View File

@ -0,0 +1,14 @@
# Greeting messages
greeting.message=Hello, {0}!
greeting.welcome=Welcome to our application
# User messages
user.create.success=User {0} created successfully.
user.update.success=User {0} with ID {1} updated successfully.
user.delete.success=User with ID {0} deleted successfully.
user.notfound=User not found
# Success messages
operation.success=Operation completed successfully
save.success=Data saved successfully
delete.success=Data deleted successfully

View File

@ -0,0 +1,14 @@
# 问候消息
greeting.message=你好,{0}
greeting.welcome=欢迎使用我们的应用
# 用户消息
user.create.success=用户 {0} 创建成功。
user.update.success=ID为 {1} 的用户 {0} 更新成功。
user.delete.success=ID为 {0} 的用户删除成功。
user.notfound=用户未找到
# 成功消息
operation.success=操作成功完成
save.success=数据保存成功
delete.success=数据删除成功

View File

@ -0,0 +1,14 @@
# 問候訊息
greeting.message=你好,{0}
greeting.welcome=歡迎使用我們的應用
# 使用者訊息
user.create.success=使用者 {0} 建立成功。
user.update.success=ID為 {1} 的使用者 {0} 更新成功。
user.delete.success=ID為 {0} 的使用者刪除成功。
user.notfound=使用者未找到
# 成功訊息
operation.success=操作成功完成
save.success=資料儲存成功
delete.success=資料刪除成功

View File

@ -86,6 +86,7 @@
<module>blade-starter-datascope</module>
<module>blade-starter-develop</module>
<module>blade-starter-excel</module>
<module>blade-starter-i18n</module>
<module>blade-starter-loadbalancer</module>
<module>blade-starter-log</module>
<module>blade-starter-mybatis</module>
@ -223,6 +224,11 @@
<artifactId>blade-starter-transaction</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-starter-i18n</artifactId>
<version>${revision}</version>
</dependency>
<dependency>
<groupId>org.springblade</groupId>
<artifactId>blade-starter-loadbalancer</artifactId>