diff --git a/forgeboot-i18n/.gitattributes b/forgeboot-i18n/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/forgeboot-i18n/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/forgeboot-i18n/.gitignore b/forgeboot-i18n/.gitignore new file mode 100644 index 0000000..5a979af --- /dev/null +++ b/forgeboot-i18n/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Kotlin ### +.kotlin diff --git a/forgeboot-i18n/build.gradle.kts b/forgeboot-i18n/build.gradle.kts new file mode 100644 index 0000000..4bc6def --- /dev/null +++ b/forgeboot-i18n/build.gradle.kts @@ -0,0 +1,11 @@ +extra { + // 需要SpringBootBom + setProperty(ProjectFlags.USE_SPRING_BOOT_BOM, true) +} +dependencies { + implementation(project(Modules.Core.EXTENSION)) + + compileOnly(libs.springBootStarter.web) + // Spring Boot WebFlux + compileOnly(libs.springBootStarter.webflux) +} diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/I18nAutoConfiguration.kt b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/I18nAutoConfiguration.kt new file mode 100644 index 0000000..a1216f7 --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/I18nAutoConfiguration.kt @@ -0,0 +1,155 @@ +package com.gewuyou.forgeboot.i18n.config + +import com.gewuyou.forgeboot.i18n.filter.ReactiveLocaleResolver +import com.gewuyou.forgeboot.core.extension.log +import com.gewuyou.forgeboot.i18n.config.entity.I18nProperties +import jakarta.servlet.http.HttpServletRequest +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.boot.context.properties.EnableConfigurationProperties +import org.springframework.context.MessageSource +import org.springframework.context.annotation.Bean +import org.springframework.context.annotation.Configuration +import org.springframework.context.support.ReloadableResourceBundleMessageSource +import org.springframework.core.io.support.PathMatchingResourcePatternResolver +import org.springframework.util.StringUtils +import org.springframework.web.servlet.LocaleResolver +import org.springframework.web.servlet.i18n.AcceptHeaderLocaleResolver +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor +import java.io.IOException +import java.util.* + +/** + * 本地化配置 + * + * @author gewuyou + * @since 2024-11-11 00:46:01 + */ +@Configuration +@EnableConfigurationProperties(I18nProperties::class) +open class I18nAutoConfiguration ( + private val i18nProperties: I18nProperties +){ + /** + * 配置并创建一个国际化的消息源 + * + * 此方法首先会扫描指定路径下所有的国际化属性文件,然后将这些文件路径设置到消息源中 + * 如果项目中还没有名为 [MESSAGE_SOURCE_BEAN_NAME] 的消息源 bean,则会创建一个 + * + * @return MessageSource 国际化消息源 + */ + @Bean(name = [MESSAGE_SOURCE_BEAN_NAME]) + @ConditionalOnMissingBean(name = [MESSAGE_SOURCE_BEAN_NAME]) + open fun messageSource(): MessageSource { + log.info("开始加载 I18n 配置...") + val messageSource = ReloadableResourceBundleMessageSource() + // 动态扫描所有 i18n 子目录下的 messages.properties 文件 + val baseNames = scanBaseNames(i18nProperties.wildPathForLanguageFiles) + + // 设置文件路径到 messageSource + messageSource.setBasenames(*baseNames.toTypedArray()) + messageSource.setDefaultEncoding("UTF-8") + log.info("I18n 配置加载完成...") + return messageSource + } + + /** + * 扫描指定路径下的所有国际化属性文件路径 + * + * 此方法会根据提供的基础路径,查找所有匹配的国际化属性文件,并将其路径添加到列表中返回 + * 主要用于动态加载项目中的国际化配置文件 + * + * @param basePath 国际化属性文件所在的基路径 + * @return List 包含所有找到的国际化属性文件路径的列表 + */ + private fun scanBaseNames(basePath: String): List { + val baseNames: MutableList = ArrayList() + log.info("开始扫描 I18n 文件 {}", basePath) + try { + val resources = PathMatchingResourcePatternResolver().getResources( + "$basePath*.properties" + ) + for (resource in resources) { + val path = resource.uri.toString() + log.info("找到 I18n 文件路径: {}", path) + // 转换路径为 Spring 的 basename 格式(去掉 .properties 后缀) + val baseName = path.substring(0, path.lastIndexOf(".properties")) + if (!baseNames.contains(baseName)) { + baseNames.add(baseName) + } + } + } catch (e: IOException) { + log.error("无法扫描 I18n 文件", e) + } + return baseNames + } + + /** + * 配置并创建一个区域设置解析器 + * + * 此方法创建一个自定义的区域设置解析器,它首先尝试从请求参数中解析语言设置, + * 如果参数不存在,则使用请求头中的语言设置 + * + * @param i18nProperties 本地化属性配置 + * @return LocaleResolver 区域设置解析器 + */ + @Bean + @ConditionalOnClass(name = ["org.springframework.web.servlet.DispatcherServlet"]) + open fun localeResolver(i18nProperties: I18nProperties): LocaleResolver { + return object : AcceptHeaderLocaleResolver() { + override fun resolveLocale(request: HttpServletRequest): Locale { + log.info("开始解析URL参数 lang 语言配置...") + // 先检查URL参数 ?lang=xx + val lang = request.getParameter(i18nProperties.langRequestParameter) + if (StringUtils.hasText(lang)) { + return Locale.forLanguageTag(lang) + } + // 设置默认语言为简体中文 + this.defaultLocale = Locale.forLanguageTag(i18nProperties.defaultLocale) + // 返回请求头 Accept-Language 的语言配置 + return super.resolveLocale(request) + } + } + } + + /** + * 创建一个区域设置更改拦截器 + * + * 此方法在项目为 Servlet 类型且配置了相应的属性时被调用,创建的拦截器用于处理 URL 参数中的语言变更请求 + * + * @param i18nProperties 本地化属性配置 + * @return LocaleChangeInterceptor 区域设置更改拦截器 + */ + @Bean + @ConditionalOnProperty(name = ["spring.main.web-application-type"], havingValue = "servlet", matchIfMissing = true) + open fun localeChangeInterceptor(i18nProperties: I18nProperties): LocaleChangeInterceptor { + log.info("创建区域设置更改拦截器...") + val interceptor = LocaleChangeInterceptor() + // 设置 URL 参数名,例如 ?lang=en 或 ?lang=zh + interceptor.paramName = i18nProperties.langRequestParameter + return interceptor + } + + /** + * 创建一个适用于 WebFlux 的区域设置解析器 + * + * 当项目配置为 Reactive 类型时,此方法会被调用,用于创建一个 Reactive 类型的区域设置解析器 + * + * @param i18nProperties 本地化属性配置 + * @return ReactiveLocaleResolver 适用于 WebFlux 的区域设置解析器 + */ + @Bean + @ConditionalOnProperty(name = ["spring.main.web-application-type"], havingValue = "reactive") + open fun createReactiveLocaleResolver(i18nProperties: I18nProperties): ReactiveLocaleResolver { + log.info("创建 WebFlux 区域设置解析器...") + return ReactiveLocaleResolver(i18nProperties) + } + + companion object { + /** + * 消息源 bean 的名称 + */ + const val MESSAGE_SOURCE_BEAN_NAME: String = "i18nMessageSource" + } +} diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/I18nWebConfiguration.kt b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/I18nWebConfiguration.kt new file mode 100644 index 0000000..e073bfd --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/I18nWebConfiguration.kt @@ -0,0 +1,35 @@ +package com.gewuyou.forgeboot.i18n.config + +import com.gewuyou.forgeboot.core.extension.log +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty +import org.springframework.context.annotation.Configuration +import org.springframework.web.servlet.config.annotation.InterceptorRegistry +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer +import org.springframework.web.servlet.i18n.LocaleChangeInterceptor + +/** + *i18n web 配置 + * + * @since 2025-02-18 23:33:39 + * @author gewuyou + */ +@Configuration +@ConditionalOnProperty(name = ["spring.main.web-application-type"], havingValue = "servlet", matchIfMissing = true) +open class I18nWebConfiguration( + @Autowired + private val localeChangeInterceptor: LocaleChangeInterceptor +) : WebMvcConfigurer { + + /** + * Add Spring MVC lifecycle interceptors for pre- and post-processing of + * controller method invocations and resource handler requests. + * Interceptors can be registered to apply to all requests or be limited + * to a subset of URL patterns. + */ + override fun addInterceptors(registry: InterceptorRegistry) { + // Add the locale change interceptor to the registry + log.info("注册语言切换拦截器...") + registry.addInterceptor(localeChangeInterceptor) + } +} \ No newline at end of file diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/entity/I18nProperties.kt b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/entity/I18nProperties.kt new file mode 100644 index 0000000..d95ec8a --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/config/entity/I18nProperties.kt @@ -0,0 +1,26 @@ +package com.gewuyou.forgeboot.i18n.config.entity + +import org.springframework.boot.context.properties.ConfigurationProperties + +/** + * i18n属性 + * + * @author gewuyou + * @since 2025-02-18 23:59:57 + */ +@ConfigurationProperties(prefix = "base-forge.i18n") +class I18nProperties { + /** + * 默认语言 + */ + var defaultLocale = "zh_CN" + + /** + * 语言请求参数名 + */ + var langRequestParameter = "lang" + /** + * 语言文件路径 + */ + var wildPathForLanguageFiles = "classpath*:i18n/**/messages" +} diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/entity/InternalInformation.kt b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/entity/InternalInformation.kt new file mode 100644 index 0000000..6e1de6e --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/entity/InternalInformation.kt @@ -0,0 +1,15 @@ +package com.gewuyou.forgeboot.i18n.entity + +/** + * 内部信息(用于扩展项目内的i18n信息) + * + * @author gewuyou + * @since 2024-11-26 17:14:15 + */ +interface InternalInformation { + /** + * 获取i18n响应信息code + * @return 响应信息 code + */ + val responseI8nMessageCode: String? +} diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/entity/ResponseInformation.kt b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/entity/ResponseInformation.kt new file mode 100644 index 0000000..0704c1d --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/entity/ResponseInformation.kt @@ -0,0 +1,21 @@ +package com.gewuyou.forgeboot.i18n.entity + +/** + * 响应信息(用于对外提供i18n响应信息) + * + * @author gewuyou + * @since 2024-11-26 15:43:06 + */ +interface ResponseInformation { + /** + * 获取响应码 + * @return 响应码 + */ + val responseCode: Int + + /** + * 获取i18n响应信息code + * @return 响应信息 code + */ + val responseI8nMessageCode: String +} diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/exception/I18nBaseException.java b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/exception/I18nBaseException.java new file mode 100644 index 0000000..96fdb55 --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/exception/I18nBaseException.java @@ -0,0 +1,56 @@ +package com.gewuyou.forgeboot.i18n.exception; + + +import com.gewuyou.forgeboot.i18n.entity.ResponseInformation; +/** + * i18n异常 + * + * @author gewuyou + * @since 2024-11-12 00:11:32 + */ +public class I18nBaseException extends RuntimeException { + /** + * 响应信息对象,用于存储错误代码和国际化消息代码 + */ + protected final transient ResponseInformation responseInformation; + + /** + * 构造函数 + * + * @param responseInformation 响应信息对象,包含错误代码和国际化消息代码 + */ + public I18nBaseException(ResponseInformation responseInformation) { + super(); + this.responseInformation = responseInformation; + } + + /** + * 构造函数 + * + * @param responseInformation 响应信息对象,包含错误代码和国际化消息代码 + * @param cause 异常原因 + */ + public I18nBaseException(ResponseInformation responseInformation, Throwable cause) { + super(cause); + this.responseInformation = responseInformation; + } + + /** + * 获取错误代码 + * + * @return 错误代码 + */ + public int getErrorCode() { + return responseInformation.getResponseCode(); + } + + /** + * 获取国际化消息代码 + * + * @return 国际化消息代码 + */ + public String getErrorI18nMessageCode() { + return responseInformation.getResponseI8nMessageCode(); + } + +} diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/filter/ReactiveLocaleResolver.kt b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/filter/ReactiveLocaleResolver.kt new file mode 100644 index 0000000..f8550cf --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/filter/ReactiveLocaleResolver.kt @@ -0,0 +1,51 @@ +package com.gewuyou.forgeboot.i18n.filter + + +import com.gewuyou.forgeboot.i18n.config.entity.I18nProperties +import org.slf4j.LoggerFactory +import org.springframework.context.i18n.LocaleContextHolder +import org.springframework.util.StringUtils +import org.springframework.web.server.ServerWebExchange +import org.springframework.web.server.WebFilterChain +import reactor.core.publisher.Mono +import java.util.* + + +/** + *反应式 locale 解析器 + * + * @since 2025-02-19 00:06:45 + * @author gewuyou + */ +class ReactiveLocaleResolver( + private val i18nProperties: I18nProperties +): WebFluxLocaleResolver { + private val log = LoggerFactory.getLogger(ReactiveLocaleResolver::class.java) + /** + * Process the Web request and (optionally) delegate to the next + * `WebFilter` through the given [WebFilterChain]. + * @param exchange the current server exchange + * @param chain provides a way to delegate to the next filter + * @return `Mono` to indicate when request processing is complete + */ + override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono { + val lang = exchange.request.queryParams.getFirst(i18nProperties.langRequestParameter) + if (StringUtils.hasText(lang)) { + log.info("解析到 lang 参数:$lang") + LocaleContextHolder.setLocale(Locale.forLanguageTag(lang)) // 设置语言环境 + } else { + // 如果没有 lang 参数,使用 Accept-Language 请求头 + val acceptLanguage = exchange.request.headers.getFirst("Accept-Language") + log.info("解析到 Accept-Language 请求头:$acceptLanguage") + if (StringUtils.hasText(acceptLanguage)) { + // 设置语言环境 + LocaleContextHolder.setLocale(Locale.forLanguageTag(acceptLanguage)) + }else { + // 如果没有 Accept-Language 请求头,使用默认语言环境 + LocaleContextHolder.setLocale(Locale.forLanguageTag(i18nProperties.defaultLocale)) + } + } + // 继续处理请求 + return chain.filter(exchange) + } +} \ No newline at end of file diff --git a/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/filter/WebFluxLocaleResolver.kt b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/filter/WebFluxLocaleResolver.kt new file mode 100644 index 0000000..1d3e0de --- /dev/null +++ b/forgeboot-i18n/src/main/kotlin/com/gewuyou/forgeboot/i18n/filter/WebFluxLocaleResolver.kt @@ -0,0 +1,5 @@ +package com.gewuyou.forgeboot.i18n.filter + +import org.springframework.web.server.WebFilter + +interface WebFluxLocaleResolver:WebFilter diff --git a/forgeboot-i18n/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/forgeboot-i18n/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..5370d5e --- /dev/null +++ b/forgeboot-i18n/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1,2 @@ +com.gewuyou.forgeboot.i18n.config.I18nAutoConfiguration +com.gewuyou.forgeboot.i18n.config.I18nWebConfiguration \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 91fabd9..dfd7636 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,7 @@ slf4j-api = { module = "org.slf4j:slf4j-api", version.ref = "slf4j-version" } springBootStarter-aop = { group = "org.springframework.boot", name = "spring-boot-starter-aop" } springBootStarter-web = { group = "org.springframework.boot", name = "spring-boot-starter-web" } +springBootStarter-webflux = { group = "org.springframework.boot", name = "spring-boot-starter-webflux" } springBootDependencies-bom = { module = "org.springframework.boot:spring-boot-dependencies", version.ref = "spring-boot-version" } # Libraries can be bundled together for easier import diff --git a/settings.gradle.kts b/settings.gradle.kts index 87e4697..a29f8de 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -41,4 +41,11 @@ include( ) project(":forgeboot-core").name = "forgeboot-core" project(":forgeboot-core:forgeboot-core-extension").name = "forgeboot-core-extension" +//endregion + +//region i18n +include( + "forgeboot-i18n" +) +project(":forgeboot-i18n").name = "forgeboot-i18n-spring-boot-starter" //endregion \ No newline at end of file