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:
DaxPay Dev
2026-07-04 15:27:12 +08:00
parent cc3a9f87e1
commit 6bc8db497b
13 changed files with 209 additions and 8 deletions

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;
}
/// 删除

View File

@@ -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;
}
/// 删除

View File

@@ -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="金额超限"

View File

@@ -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"
}

View File

@@ -0,0 +1,12 @@
{
"other": "其它",
"add": "新增",
"update": "修改",
"delete": "删除",
"grant": "授权",
"sync": "同步",
"export": "导出",
"import": "导入",
"force": "强退",
"clean": "清空"
}

View File

@@ -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-Hansuni-app 中文 locale无对应目录时应反向匹配到 zh-CN 资源
@Test
void shouldReverseMatchZhHansToZhCn() {
String message = messageSource.getMessage("pay.route.error.noMatch", null, Locale.forLanguageTag("zh-Hans"));
assertEquals("未匹配到可用支付产品", message);
}
}

View File

@@ -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>

View File

@@ -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" → 显式指定了语言,按指定字段名取值,不切换

View File

@@ -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 "";
/// 缓存存活时间(秒)
/// 翻译结果在缓存中的存活时长,超过后自动过期重新查询

View File

@@ -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";
}
}