mirror of
https://gitee.com/dromara/dax-pay
synced 2026-08-12 15:35:38 +08:00
feat(i18n): 翻译框架支持 i18n 消息类型 + locale 反向匹配修复
- @Trans 新增 i18n 模式: 字段值作 key 经 I18nUtil 翻译回填, source/result 加默认值 - JsonMessageSource 语言→地区反向匹配(en→en-US), 修复小程序纯语言码请求回退中文 - 登录/操作日志 msg/errorMsg/businessType 标注 @Trans, Service 返回前 translate - OperateLogType 实现 I18nSupport + operate_log_type.json(zh/en) - common-translate 依赖 common-i18n; capability-audit-log 依赖 common-translate
This commit is contained in:
@@ -65,6 +65,12 @@
|
||||
<artifactId>common-config</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
<!-- 字段翻译框架(@Trans 注解的 i18n / 实体 / 字典翻译) -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-translate</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.daxpay.open.platform.capability.audit.log.result;
|
||||
|
||||
import cn.daxpay.open.platform.core.annotation.Trans;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
@@ -43,6 +44,7 @@ public class LoginLogResult {
|
||||
@Schema(description = "操作系统")
|
||||
private String os;
|
||||
|
||||
@Trans(i18n = true)
|
||||
@Schema(description = "提示消息")
|
||||
private String msg;
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.daxpay.open.platform.capability.audit.log.result;
|
||||
|
||||
import cn.daxpay.open.platform.core.annotation.Trans;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.Data;
|
||||
import lombok.experimental.Accessors;
|
||||
@@ -18,6 +19,7 @@ public class OperateLogResult {
|
||||
@Schema(description = "操作模块")
|
||||
private String title;
|
||||
|
||||
@Trans(i18n = true, i18nPrefix = "enum.operate_log_type")
|
||||
@Schema(description = "业务类型")
|
||||
private String businessType;
|
||||
|
||||
@@ -60,6 +62,7 @@ public class OperateLogResult {
|
||||
@Schema(description = "操作状态(0正常 1异常)")
|
||||
private Boolean success;
|
||||
|
||||
@Trans(i18n = true)
|
||||
@Schema(description = "错误消息")
|
||||
private String errorMsg;
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.daxpay.open.platform.capability.audit.log.service.log;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
|
||||
import cn.daxpay.open.platform.common.translate.service.TransService;
|
||||
import cn.daxpay.open.platform.core.exception.DataNotExistException;
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import cn.daxpay.open.platform.core.rest.result.PageResult;
|
||||
@@ -38,6 +39,8 @@ public class LoginLogService {
|
||||
|
||||
private final LoginLogDbManager loginLogManager;
|
||||
|
||||
private final TransService transService;
|
||||
|
||||
/// 内存缓冲队列
|
||||
private final BlockingQueue<LoginLogParam> bufferQueue = new LinkedBlockingQueue<>(DEFAULT_QUEUE_CAPACITY);
|
||||
|
||||
@@ -118,12 +121,16 @@ public class LoginLogService {
|
||||
|
||||
/// 获取
|
||||
public LoginLogResult findById(Long id) {
|
||||
return loginLogManager.findById(id).map(LoginLogDb::toResult).orElseThrow(DataNotExistException::new);
|
||||
LoginLogResult result = loginLogManager.findById(id).map(LoginLogDb::toResult).orElseThrow(DataNotExistException::new);
|
||||
transService.translate(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 分页
|
||||
public PageResult<LoginLogResult> page(PageParam pageParam, LoginLogQuery query) {
|
||||
return MpUtil.toPageResult(loginLogManager.page(pageParam, query));
|
||||
PageResult<LoginLogResult> pageResult = MpUtil.toPageResult(loginLogManager.page(pageParam, query));
|
||||
transService.translate(pageResult);
|
||||
return pageResult;
|
||||
}
|
||||
|
||||
/// 删除
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package cn.daxpay.open.platform.capability.audit.log.service.log;
|
||||
|
||||
import cn.daxpay.open.platform.common.mybatisplus.util.MpUtil;
|
||||
import cn.daxpay.open.platform.common.translate.service.TransService;
|
||||
import cn.daxpay.open.platform.core.exception.DataNotExistException;
|
||||
import cn.daxpay.open.platform.core.rest.param.PageParam;
|
||||
import cn.daxpay.open.platform.core.rest.result.PageResult;
|
||||
@@ -40,6 +41,8 @@ public class OperateLogService {
|
||||
private final OperateLogDbManager operateLogManager;
|
||||
private final AuditLogMaskService maskService;
|
||||
|
||||
private final TransService transService;
|
||||
|
||||
/// 内存缓冲队列
|
||||
private final BlockingQueue<OperateLogParam> bufferQueue = new LinkedBlockingQueue<>(DEFAULT_QUEUE_CAPACITY);
|
||||
|
||||
@@ -143,12 +146,16 @@ public class OperateLogService {
|
||||
|
||||
/// 获取
|
||||
public OperateLogResult findById(Long id) {
|
||||
return operateLogManager.findById(id).map(OperateLogDb::toResult).orElseThrow(DataNotExistException::new);
|
||||
OperateLogResult result = operateLogManager.findById(id).map(OperateLogDb::toResult).orElseThrow(DataNotExistException::new);
|
||||
transService.translate(result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/// 分页
|
||||
public PageResult<OperateLogResult> page(PageParam pageParam, OperateLogQuery operateLogParam) {
|
||||
return MpUtil.toPageResult(operateLogManager.page(pageParam, operateLogParam));
|
||||
PageResult<OperateLogResult> pageResult = MpUtil.toPageResult(operateLogManager.page(pageParam, operateLogParam));
|
||||
transService.translate(pageResult);
|
||||
return pageResult;
|
||||
}
|
||||
|
||||
/// 删除
|
||||
|
||||
@@ -14,8 +14,10 @@ import java.net.URLDecoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.text.MessageFormat;
|
||||
import java.util.HashMap;
|
||||
import java.util.HashSet;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/// # 基于 JSON 文件的消息源, 支持目录嵌套和多文件拆分
|
||||
@@ -35,8 +37,16 @@ public class JsonMessageSource extends AbstractMessageSource {
|
||||
@Setter
|
||||
private Locale defaultLocale = Locale.CHINA;
|
||||
|
||||
/// 已加载的可用 locale 标签集合(如 en-US, zh-CN),启动时扫描
|
||||
private final Set<String> availableLocaleTags = new HashSet<>();
|
||||
|
||||
/// 语言码 → 首个匹配的地区 locale 标签(如 en → en-US, zh → zh-CN),用于反向回退
|
||||
/// 当请求纯语言码(如 en)而无对应资源目录时, 据此映射到同语言的地区资源
|
||||
private final Map<String, String> languageToRegionLocale = new HashMap<>();
|
||||
|
||||
public JsonMessageSource(ResourcePatternResolver resourceResolver) {
|
||||
this.resourceResolver = resourceResolver;
|
||||
this.scanAvailableLocales();
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -51,6 +61,20 @@ public class JsonMessageSource extends AbstractMessageSource {
|
||||
message = messages.get(code);
|
||||
}
|
||||
}
|
||||
// 仍未命中则按语言反向匹配地区: en -> en-US, zh -> zh-CN, zh-Hans -> zh-CN
|
||||
if (message == null) {
|
||||
String lang = locale.getLanguage();
|
||||
if (!lang.isEmpty()) {
|
||||
String regionTag = this.languageToRegionLocale.get(lang);
|
||||
if (regionTag != null) {
|
||||
Locale regionLocale = Locale.forLanguageTag(regionTag);
|
||||
if (!regionLocale.equals(locale)) {
|
||||
messages = this.loadMessages(regionLocale);
|
||||
message = messages.get(code);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// 仍未命中则用默认 locale 回退
|
||||
if (message == null && !locale.equals(this.defaultLocale)) {
|
||||
messages = this.loadMessages(this.defaultLocale);
|
||||
@@ -114,6 +138,42 @@ public class JsonMessageSource extends AbstractMessageSource {
|
||||
return relative.replace('/', '.').replace('\\', '.');
|
||||
}
|
||||
|
||||
/// 启动时扫描 i18n 目录下所有 locale 子目录,建立「语言码 → 地区 locale」反向映射
|
||||
/// 用于支持请求纯语言码(如 en)时反向匹配到地区资源(如 en-US)
|
||||
private void scanAvailableLocales() {
|
||||
try {
|
||||
Resource[] resources = this.resourceResolver.getResources("classpath*:i18n/**/*.json");
|
||||
for (Resource resource : resources) {
|
||||
String localeTag = this.extractLocaleTag(resource.getURL().toString());
|
||||
if (localeTag != null && this.availableLocaleTags.add(localeTag)) {
|
||||
// 首次发现该 locale, 记录语言 → 地区映射
|
||||
Locale locale = Locale.forLanguageTag(localeTag);
|
||||
String lang = locale.getLanguage();
|
||||
if (!lang.isEmpty()) {
|
||||
this.languageToRegionLocale.putIfAbsent(lang, localeTag);
|
||||
}
|
||||
}
|
||||
}
|
||||
log.debug("扫描到可用 locale: {}, 语言→地区映射: {}", this.availableLocaleTags, this.languageToRegionLocale);
|
||||
} catch (IOException e) {
|
||||
log.warn("扫描可用 locale 失败, 语言→地区反向回退将不可用", e);
|
||||
}
|
||||
}
|
||||
|
||||
/// 从资源 URL 提取 locale 标签
|
||||
/// .../i18n/en-US/enum/xxx.json → "en-US"
|
||||
private String extractLocaleTag(String fileUrl) {
|
||||
String decoded = URLDecoder.decode(fileUrl, StandardCharsets.UTF_8);
|
||||
String marker = "i18n/";
|
||||
int idx = decoded.lastIndexOf(marker);
|
||||
if (idx < 0) {
|
||||
return null;
|
||||
}
|
||||
String after = decoded.substring(idx + marker.length());
|
||||
int slash = after.indexOf('/');
|
||||
return slash > 0 ? after.substring(0, slash) : null;
|
||||
}
|
||||
|
||||
/// 递归展平 JSON 对象
|
||||
/// 例如: { "alipay": "支付宝", "amount": { "exceed": "金额超限" } }
|
||||
/// 前缀 "channel.enum" → channel.enum.alipay="支付宝", channel.enum.amount.exceed="金额超限"
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"other": "Other",
|
||||
"add": "Add",
|
||||
"update": "Update",
|
||||
"delete": "Delete",
|
||||
"grant": "Grant",
|
||||
"sync": "Sync",
|
||||
"export": "Export",
|
||||
"import": "Import",
|
||||
"force": "Force Logout",
|
||||
"clean": "Clear"
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"other": "其它",
|
||||
"add": "新增",
|
||||
"update": "修改",
|
||||
"delete": "删除",
|
||||
"grant": "授权",
|
||||
"sync": "同步",
|
||||
"export": "导出",
|
||||
"import": "导入",
|
||||
"force": "强退",
|
||||
"clean": "清空"
|
||||
}
|
||||
@@ -64,4 +64,25 @@ class JsonMessageSourceTest {
|
||||
String message = messageSource.getMessage("channel.error.channelNotFound", null, Locale.forLanguageTag("en-US"));
|
||||
assertEquals("Unsupported channel code", message);
|
||||
}
|
||||
|
||||
/// 请求纯语言码 en(无 en/ 目录)时应反向匹配到 en-US 资源,而非回退默认中文
|
||||
@Test
|
||||
void shouldReverseMatchEnToEnUs() {
|
||||
String message = messageSource.getMessage("pay.route.error.noMatch", null, Locale.forLanguageTag("en"));
|
||||
assertEquals("No available payment product matched", message);
|
||||
}
|
||||
|
||||
/// 请求纯语言码 zh(无 zh/ 目录)时应反向匹配到 zh-CN 资源
|
||||
@Test
|
||||
void shouldReverseMatchZhToZhCn() {
|
||||
String message = messageSource.getMessage("pay.route.error.noMatch", null, Locale.forLanguageTag("zh"));
|
||||
assertEquals("未匹配到可用支付产品", message);
|
||||
}
|
||||
|
||||
/// 请求 zh-Hans(uni-app 中文 locale,无对应目录)时应反向匹配到 zh-CN 资源
|
||||
@Test
|
||||
void shouldReverseMatchZhHansToZhCn() {
|
||||
String message = messageSource.getMessage("pay.route.error.noMatch", null, Locale.forLanguageTag("zh-Hans"));
|
||||
assertEquals("未匹配到可用支付产品", message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,6 +20,13 @@
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- i18n 消息源(TransService 的 i18n 翻译分支依赖 I18nUtil) -->
|
||||
<dependency>
|
||||
<groupId>cn.daxpay.open</groupId>
|
||||
<artifactId>common-i18n</artifactId>
|
||||
<version>${project.version}</version>
|
||||
</dependency>
|
||||
|
||||
<!-- mybatis-plus 核心(含 mybatis-spring) -->
|
||||
<dependency>
|
||||
<groupId>com.baomidou</groupId>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package cn.daxpay.open.platform.common.translate.service;
|
||||
|
||||
import cn.daxpay.open.platform.common.i18n.util.I18nUtil;
|
||||
import cn.daxpay.open.platform.common.translate.cache.TransCacheKey;
|
||||
import cn.daxpay.open.platform.common.translate.cache.TransCacheManager;
|
||||
import cn.daxpay.open.platform.common.translate.model.DictItemData;
|
||||
@@ -68,7 +69,8 @@ public class TransService {
|
||||
return;
|
||||
}
|
||||
|
||||
// 遍历所有对象,区分实体翻译和字典翻译
|
||||
// 遍历所有对象,区分 i18n 翻译、实体翻译和字典翻译
|
||||
List<TransFieldInfo> i18nFieldInfos = new ArrayList<>();
|
||||
Map<TransGroup, Set<Object>> entityGroupSourceValues = new LinkedHashMap<>();
|
||||
List<TransFieldInfo> entityFieldInfos = new ArrayList<>();
|
||||
Map<String, Set<Object>> dictGroupSourceValues = new LinkedHashMap<>();
|
||||
@@ -77,6 +79,11 @@ public class TransService {
|
||||
for (Object result : results) {
|
||||
List<TransMeta> metaList = scanTransFields(result);
|
||||
for (TransMeta meta : metaList) {
|
||||
// i18n 翻译: 字段值即 key, 独立收集, 不走 source / 实体 / 字典逻辑
|
||||
if (meta.annotation().i18n()) {
|
||||
i18nFieldInfos.add(new TransFieldInfo(result, meta.field(), meta.annotation()));
|
||||
continue;
|
||||
}
|
||||
Object sourceValue = getFieldValue(result, meta.source());
|
||||
if (sourceValue == null) {
|
||||
continue;
|
||||
@@ -104,6 +111,11 @@ public class TransService {
|
||||
if (!dictGroupSourceValues.isEmpty()) {
|
||||
processDictTranslations(dictGroupSourceValues, dictFieldInfos);
|
||||
}
|
||||
|
||||
// 处理 i18n 翻译
|
||||
if (!i18nFieldInfos.isEmpty()) {
|
||||
processI18nTranslations(i18nFieldInfos);
|
||||
}
|
||||
}
|
||||
|
||||
/// 翻译分页结果
|
||||
@@ -159,6 +171,23 @@ public class TransService {
|
||||
}
|
||||
}
|
||||
|
||||
/// 处理 i18n 消息翻译
|
||||
/// 注解字段本身的值(经 i18nPrefix 拼接后)作为 i18n key, 翻译后回填本字段
|
||||
/// 查不到 key 时 I18nUtil 返回 key 原值, 天然兼容历史脏数据与纯文本
|
||||
private void processI18nTranslations(List<TransFieldInfo> fieldInfos) {
|
||||
for (TransFieldInfo info : fieldInfos) {
|
||||
Object sourceValue = getFieldValue(info.result(), info.field().getName());
|
||||
if (sourceValue == null) {
|
||||
continue;
|
||||
}
|
||||
String raw = String.valueOf(sourceValue);
|
||||
String prefix = info.annotation().i18nPrefix();
|
||||
String key = prefix.isEmpty() ? raw : prefix + "." + raw;
|
||||
String translated = I18nUtil.get(key);
|
||||
setFieldValue(info.result(), info.field(), translated);
|
||||
}
|
||||
}
|
||||
|
||||
/// 根据当前请求语言解析多语言字段名
|
||||
/// 规则:
|
||||
/// - result="nameCn" 或 "nameEn" → 显式指定了语言,按指定字段名取值,不切换
|
||||
|
||||
@@ -42,10 +42,27 @@ public @interface Trans {
|
||||
/// 实体翻译时无需指定
|
||||
String dictCode() default "";
|
||||
|
||||
/// 是否作为 i18n messageKey 翻译(i18n 模式)
|
||||
/// 为 true 时,注解字段本身的值(经 i18nPrefix 拼接后)作为 i18n key,
|
||||
/// 通过 MessageSource 翻译后回填到本字段;entity / dictCode / source / on / result / cacheTtl 均忽略
|
||||
/// 查不到 key 时原样返回字段原值,天然兼容历史脏数据与纯文本
|
||||
/// 使用示例:
|
||||
/// ```java
|
||||
/// @Trans(i18n = true) // 字段值即完整 key
|
||||
/// @Trans(i18n = true, i18nPrefix = "enum.operate_log_type") // 字段值为枚举 code,需拼前缀
|
||||
/// ```
|
||||
boolean i18n() default false;
|
||||
|
||||
/// i18n key 前缀(仅 i18n = true 时生效)
|
||||
/// 拼接规则:prefix + "." + 字段值;为空则字段值即完整 key
|
||||
/// 用于枚举 code 翻译:如 businessType 字段值为 "add",i18nPrefix = "enum.operate_log_type" → key = "enum.operate_log_type.add"
|
||||
String i18nPrefix() default "";
|
||||
|
||||
/// 源字段名(当前 Result 中作为查询条件的字段名)
|
||||
/// 翻译时从当前 Result 对象中读取此字段的值,作为数据库查询的条件值
|
||||
/// 例如:source = "mchNo" 表示取当前对象的 mchNo 属性值
|
||||
String source();
|
||||
/// i18n 模式下可省略(直接翻译注解字段本身),实体/字典模式必填
|
||||
String source() default "";
|
||||
|
||||
/// 目标实体中的匹配字段名
|
||||
/// 在目标实体中按此字段匹配查询条件,不指定时默认与 source 相同
|
||||
@@ -56,7 +73,8 @@ public @interface Trans {
|
||||
/// 目标实体/字典项中要翻译出的字段名
|
||||
/// 从目标实体匹配到的记录中取出此字段的值,回填到当前被 @Trans 注解的字段
|
||||
/// 例如:result = "name" 表示从目标实体中取出 name 字段的值作为翻译结果
|
||||
String result();
|
||||
/// i18n 模式下可省略(直接翻译注解字段本身),实体/字典模式必填
|
||||
String result() default "";
|
||||
|
||||
/// 缓存存活时间(秒)
|
||||
/// 翻译结果在缓存中的存活时长,超过后自动过期重新查询
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
package cn.daxpay.open.platform.core.enums.common;
|
||||
|
||||
import cn.daxpay.open.platform.core.i18n.I18nSupport;
|
||||
|
||||
import java.util.Locale;
|
||||
|
||||
/// # 操作日志业务类型枚举
|
||||
///
|
||||
public enum OperateLogType {
|
||||
/// 实现 [I18nSupport], 翻译 key 前缀 `enum.operate_log_type`, 完整 key = 前缀 + "." + code
|
||||
public enum OperateLogType implements I18nSupport {
|
||||
|
||||
/// 其它
|
||||
OTHER("其它"),
|
||||
@@ -44,4 +49,16 @@ public enum OperateLogType {
|
||||
public String getDescription() {
|
||||
return description;
|
||||
}
|
||||
|
||||
/// 枚举编码, 与操作日志入库的 businessType 值一致(name 小写)
|
||||
@Override
|
||||
public String getCode() {
|
||||
return name().toLowerCase(Locale.ROOT);
|
||||
}
|
||||
|
||||
/// 翻译 key 前缀, 对应资源文件 enum/operate_log_type.json
|
||||
@Override
|
||||
public String getI18nPrefix() {
|
||||
return "enum.operate_log_type";
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user