SpringBoot 通过自定义注解 + AOP 实现数据字典自动翻译(通用
文章目录
- [SpringBoot 通过自定义注解 + AOP 实现数据字典自动翻译(通用](#SpringBoot 通过自定义注解 + AOP 实现数据字典自动翻译(通用)
-
- 一、背景与痛点
- 二、效果演示
- 三、整体架构
- 四、核心代码实现
-
- [4.1 `@Dict` 注解](#4.1
@Dict注解) - [4.2 `DictDataProvider` 接口(数据加载抽象)](#4.2
DictDataProvider接口(数据加载抽象)) - [4.3 具体项目的 Provider 实现](#4.3 具体项目的 Provider 实现)
- [4.4 `DictCacheService` 缓存服务](#4.4
DictCacheService缓存服务) - [4.5 `DictAspectConfig` 配置化切面(关键)](#4.5
DictAspectConfig配置化切面(关键)) - [4.6 `DictTranslateInterceptor` 核心翻译逻辑](#4.6
DictTranslateInterceptor核心翻译逻辑) - [4.7 缓存管理接口](#4.7 缓存管理接口)
- [4.1 `@Dict` 注解](#4.1
- 五、配置文件说明
- 六、使用方式
-
- [6.1 在 VO 上标注注解](#6.1 在 VO 上标注注解)
- [6.2 接口返回示例](#6.2 接口返回示例)
- 七、多项目复用
- 八、性能优化细节
- 九、缺点与性能优化(待实现)
- 十、文件清单
- 十一、总结
- 十二、具体代码实现
一、背景与痛点
在企业级开发中,数据字典翻译是绕不开的需求:数据库存的是 code(如性别 1),前端要展示文本(如 男)。
常见做法:
- 前端自己做字典映射 → 维护成本高,字典变更要改前端
- 后端 Service 层手动查字典赋值 → 代码侵入性强,到处重复
- 全局 AOP 自动翻译 → 一次配置,处处生效
本文实现一套基于自定义注解 @Dict + Spring AOP 的字典自动翻译方案,特点如下:
| 特性 | 说明 |
|---|---|
| 不修改原始值 | 追加 _dictStr 后缀字段,前端同时拿到 code 和文本 |
| 支持多种返回类型 | PageInfo、PageResult、List、单对象 |
| 递归嵌套翻译 | 嵌套对象和 List 内部的字典字段也能自动处理 |
| 多值支持 | 逗号分隔的值(如 "1,2,3")自动逐个翻译 |
| 降级容错 | 翻译异常不阻断业务,返回原始数据 |
| Redis 缓存 | 按 dictCode 细粒度缓存,主动清除保证一致性 |
| 切面路径可配置 | 不同项目通过 yml 配置不同的拦截包路径 |
| 多项目复用 | 接口化数据加载,不绑定具体字典表 |
二、效果演示
VO 字段标注 @Dict:
java
@Dict(dictCode = "gender")
private String gender; // 数据库值: "1"
接口返回 JSON:
json
{
"gender": "1",
"gender_dictStr": "男"
}
前端可以直接用 gender_dictStr 展示,也可以用 gender 做筛选条件,互不影响。
三、整体架构
Controller 返回 Result
↓
DictAspectConfig(配置化切面,读取 yml 中的 pointcut 表达式)
↓
DictTranslateInterceptor(核心翻译逻辑)
↓
解析返回数据类型(PageInfo / PageResult / List / 单对象)
↓
递归遍历字段,收集 @Dict 注解的 dictCode
↓
DictCacheService(Redis 缓存)
↓
DictDataProvider(接口,各项目实现自己的数据加载)
↓
追加 "字段名_dictStr" 到 JSONObject 返回
四、核心代码实现
4.1 @Dict 注解
java
package com.shinho.eccp.entry.api.dto.annotation;
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Dict {
/**
* 字典类型编码(对应字典表的类型字段)
* 例如: "gender", "area", "education_background"
*/
String dictCode();
/**
* 翻译结果存放的字段名(可选)
* 默认为 "原字段名_dictStr",指定后使用自定义字段名
*/
String dictText() default "";
}
4.2 DictDataProvider 接口(数据加载抽象)
各项目实现此接口,查自己的字典表,不做任何绑定:
java
package com.shinho.eccp.entry.api.dto.annotation;
import java.util.List;
public interface DictDataProvider {
/**
* 根据字典类型编码查询字典项列表
*/
List<DictItem> getDictItemsByCode(String dictCode);
/**
* 字典项数据结构
*/
class DictItem {
private String value; // 字典编码值
private String label; // 字典显示文本
public DictItem() {}
public DictItem(String value, String label) {
this.value = value;
this.label = label;
}
// getter / setter 省略
}
}
4.3 具体项目的 Provider 实现
java
@Service
public class EntryDictDataProvider implements DictDataProvider {
@Autowired
private DataDictionaryDao dataDictionaryDao;
@Override
public List<DictItem> getDictItemsByCode(String dictCode) {
List<DropDownValuesVo> voList = dataDictionaryDao.getValuesByField(dictCode);
if (voList == null || voList.isEmpty()) {
return Collections.emptyList();
}
List<DictItem> items = new ArrayList<>(voList.size());
for (DropDownValuesVo vo : voList) {
items.add(new DictItem(vo.getValue(), vo.getName()));
}
return items;
}
}
其他项目只需实现
DictDataProvider,可以查不同的表、调 Feign 接口、读配置文件等,不受限制。
4.4 DictCacheService 缓存服务
java
@Service
public class DictCacheService {
private static final Logger log = LoggerFactory.getLogger(DictCacheService.class);
@Value("${eccp.dict.redis-key-prefix:eccp:dict:}")
private String redisKeyPrefix;
@Value("${eccp.dict.redis-expire-seconds:2592000}")
private int redisExpireSeconds;
@Autowired
private JedisUtil jedisUtil;
@Autowired
private DictDataProvider dictDataProvider;
public List<DictItem> getDictItems(String dictCode) {
if (StringUtils.isBlank(dictCode)) {
return Collections.emptyList();
}
// 1. 优先从 Redis 获取
try {
String redisKey = redisKeyPrefix + dictCode;
String json = jedisUtil.getCurrentService(redisKey);
if (StringUtils.isNotBlank(json)) {
List<DictItem> items = JSON.parseArray(json, DictItem.class);
if (items != null) {
return items;
}
}
} catch (Exception e) {
log.warn("Redis获取字典缓存异常, dictCode={}", dictCode);
}
// 2. 缓存未命中,通过 Provider 加载
List<DictItem> items;
try {
items = dictDataProvider.getDictItemsByCode(dictCode);
} catch (Exception e) {
log.error("查询字典数据异常, dictCode={}", dictCode, e);
return Collections.emptyList();
}
if (items == null) {
items = Collections.emptyList();
}
// 3. 回写 Redis
try {
String redisKey = redisKeyPrefix + dictCode;
jedisUtil.setCurrentService(redisKey, JSON.toJSONString(items), redisExpireSeconds);
} catch (Exception e) {
log.warn("回写Redis异常, dictCode={}", dictCode);
}
return items;
}
public Map<String, List<DictItem>> batchGetDictItems(Set<String> dictCodes) {
Map<String, List<DictItem>> result = new HashMap<>(dictCodes.size());
for (String code : dictCodes) {
result.put(code, getDictItems(code));
}
return result;
}
/** 清除缓存(字典变更时调用) */
public void evict(String dictCode) {
jedisUtil.delCurrentService(redisKeyPrefix + dictCode);
}
/** 清除并重新加载 */
public void reload(String dictCode) {
evict(dictCode);
getDictItems(dictCode);
}
}
4.5 DictAspectConfig 配置化切面(关键)
这是本方案的核心亮点之一 :切面拦截路径通过 application.yml 配置,不同项目无需修改代码,只改配置即可。
java
@Configuration
public class DictAspectConfig {
private static final Logger log = LoggerFactory.getLogger(DictAspectConfig.class);
/**
* 切面拦截表达式,各项目通过配置自定义
* 默认拦截 com.shinho.eccp 下所有 web 包的 Controller 方法
*/
@Value("${eccp.dict.pointcut-expression:execution(* com.shinho.eccp..web.*.*(..))}")
private String pointcutExpression;
@Autowired
private DictTranslateInterceptor dictTranslateInterceptor;
@Bean
@Order(2)
public DefaultPointcutAdvisor dictAdvisor() {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression(pointcutExpression);
log.info("字典翻译切面已注册, pointcut={}", pointcutExpression);
return new DefaultPointcutAdvisor(pointcut, dictTranslateInterceptor);
}
}
为什么不用
@Aspect+@Pointcut?因为
@Pointcut的表达式必须是编译时常量,无法通过@Value注入。使用
DefaultPointcutAdvisor+AspectJExpressionPointcut是 Spring AOP 的编程式方式,pointcut 表达式可以在运行时从配置文件读取,真正做到零代码改动,不同项目只改 yml。
4.6 DictTranslateInterceptor 核心翻译逻辑
java
@Component
public class DictTranslateInterceptor implements MethodInterceptor {
private static final String DICT_SUFFIX = "_dictStr";
private static final Map<Class<?>, Field[]> FIELD_CACHE = new ConcurrentHashMap<>();
private static final Map<Class<?>, List<Field>> DICT_FIELD_CACHE = new ConcurrentHashMap<>();
@Autowired
private DictCacheService dictCacheService;
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Object result = invocation.proceed();
try {
result = translateDict(result);
} catch (Exception e) {
log.warn("字典翻译异常,返回原始数据: {}", e.getMessage());
}
return result;
}
private Object translateDict(Object result) {
if (!(result instanceof Result)) return result;
Object data = ((Result<?>) result).getData();
if (data == null) {
return result;
}
if (data instanceof PageInfo) {
PageInfo pageInfo = (PageInfo) data;
if (CollectionUtils.isNotEmpty(pageInfo.getList())) {
pageInfo.setList(processRecords(pageInfo.getList()));
}
} else if (data instanceof PageResult) {
PageResult pageResult = (PageResult) data;
if (CollectionUtils.isNotEmpty(pageResult.getRows())) {
pageResult.setRows(processRecords(pageResult.getRows()));
}
} else if (data instanceof List) {
List<?> list = (List<?>) data;
if (CollectionUtils.isNotEmpty(list)) {
((Result) result).setData(processRecords(list));
}
} else if (!isJavaBasicType(data.getClass())) {
List<Object> single = processRecords(Collections.singletonList(data));
if (CollectionUtils.isNotEmpty(single)) {
((Result) result).setData(single.get(0));
}
}
return result;
}
}
递归构建带 _dictStr 的 JSONObject:
java
private JSONObject buildJsonWithDict(Object obj, Map<String, List<DictItem>> dictDataMap) {
JSONObject json = new JSONObject(true); // 保持字段顺序
Field[] allFields = getAllFields(obj.getClass());
for (Field field : allFields) {
field.setAccessible(true);
Object value = getFieldValueSafe(obj, field);
// 嵌套对象递归处理(List / 单对象)
if (value != null && !isJavaBasicType(field.getType())) {
if (value instanceof List) {
List<?> listValue = (List<?>) value;
if (CollectionUtils.isNotEmpty(listValue) && !isJavaBasicType(listValue.get(0).getClass())) {
Set<String> nestedCodes = collectAllDictCodes(listValue.get(0).getClass());
if (!nestedCodes.isEmpty()) {
Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
List<Object> nestedResult = new ArrayList<>();
for (Object item : listValue) {
nestedResult.add(buildJsonWithDict(item, mergedMap));
}
json.put(field.getName(), nestedResult);
} else {
json.put(field.getName(), value);
}
} else {
json.put(field.getName(), value);
}
} else if (!field.getType().isEnum()) {
Set<String> nestedCodes = collectAllDictCodes(field.getType());
if (!nestedCodes.isEmpty()) {
Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
json.put(field.getName(), buildJsonWithDict(value, mergedMap));
} else {
json.put(field.getName(), value);
}
} else {
json.put(field.getName(), value);
}
} else {
json.put(field.getName(), value);
}
// 追加 _dictStr 字段
Dict dict = field.getAnnotation(Dict.class);
if (dict != null && value != null) {
String textValue = translateValue(dictDataMap, dict.dictCode(), value.toString());
String dictFieldName = StringUtils.isNotBlank(dict.dictText())
? dict.dictText()
: (field.getName() + DICT_SUFFIX);
json.put(dictFieldName, textValue);
}
}
return json;
}
字典值翻译(支持逗号分隔多值 + 降级):
java
private String translateValue(Map<String, List<DictItem>> dictDataMap, String dictCode, String value) {
List<DictItem> items = dictDataMap.get(dictCode);
// 降级
if (items == null || items.isEmpty()) {
return value;
}
Map<String, String> valueToLabelMap = new HashMap<>(items.size());
for (DictItem item : items) {
if (item.getValue() != null) {
valueToLabelMap.put(item.getValue(), item.getLabel());
}
}
String[] codes = value.split(",");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < codes.length; i++) {
String code = codes[i].trim();
String text = valueToLabelMap.getOrDefault(code, code); // 找不到返回原值
if (i > 0) sb.append(",");
sb.append(text);
}
return sb.toString();
}
4.7 缓存管理接口
字典数据变更后需要主动刷新缓存:
java
@RestController
@RequestMapping("/dict/cache")
@Api(tags = "字典缓存管理")
public class DictCacheController {
@Autowired
private DictCacheService dictCacheService;
@PostMapping("/evict")
public Result<String> evict(@RequestParam String dictCode) {
dictCacheService.evict(dictCode);
return Result.ok("缓存已清除: " + dictCode);
}
@PostMapping("/reload")
public Result<String> reload(@RequestParam String dictCode) {
dictCacheService.reload(dictCode);
return Result.ok("缓存已重新加载: " + dictCode);
}
}
五、配置文件说明
yaml
eccp:
dict:
# Redis 缓存 key 前缀(不同项目配置不同值,避免冲突)
redis-key-prefix: eccp:entry:dict:
# 缓存过期时间(秒),默认30天
redis-expire-seconds: 2592000
# 切面拦截的包路径表达式(不同项目配置不同的 Controller 包路径)
pointcut-expression: execution(* com.shinho.eccp.entry.web.*.*(..))
六、使用方式
6.1 在 VO 上标注注解
java
@Data
public class MemberListVo {
@Dict(dictCode = "gender")
private String gender;
@Dict(dictCode = "legal_person_company")
private String legalPersonCompany;
@Dict(dictCode = "status")
private String status;
// 自定义翻译字段名
@Dict(dictCode = "recruitment_source", dictText = "recruitmentSourceText")
private String recruitmentSource;
}
6.2 接口返回示例
json
{
"code": 200,
"data": {
"list": [
{
"gender": "1",
"gender_dictStr": "男",
"legalPersonCompany": "01",
"legalPersonCompany_dictStr": "XX食品有限公司",
"status": "2",
"status_dictStr": "待办理入职",
"recruitmentSource": "3",
"recruitmentSourceText": "内部推荐"
}
]
}
}
七、多项目复用
不同项目只需三步:
1. 实现 DictDataProvider 接口(查自己的字典表):
java
@Service
public class BffDictDataProvider implements DictDataProvider {
@Autowired
private SomeBffDictDao bffDictDao;
@Override
public List<DictItem> getDictItemsByCode(String dictCode) {
return bffDictDao.queryByType(dictCode).stream()
.map(e -> new DictItem(e.getCode(), e.getName()))
.collect(Collectors.toList());
}
}
2. 配置 yml(不同前缀 + 不同切面路径):
yaml
# 项目A - entry 服务
eccp:
dict:
redis-key-prefix: eccp:entry:dict:
pointcut-expression: execution(* com.shinho.eccp.entry.web.*.*(..))
# 项目B - bff 服务
eccp:
dict:
redis-key-prefix: eccp:bff:dict:
pointcut-expression: execution(* com.shinho.eccp.bff.web.*.*(..))
# 项目C - 多个包路径
eccp:
dict:
redis-key-prefix: eccp:hr:dict:
pointcut-expression: execution(* com.shinho.eccp.hr.controller.*.*(..)) || execution(* com.shinho.eccp.hr.api.*.*(..))
3. 在 VO 字段上加 @Dict 注解即可。
各项目字典缓存隔离,切面路径独立,互不干扰。
八、性能优化细节
| 优化点 | 做法 |
|---|---|
| 避免重复反射 | ConcurrentHashMap 缓存 Class → Field\[\] 映射 |
| 避免 N+1 查询 | 先收集所有 dictCode,批量从缓存获取 |
| 翻译效率 | List<DictItem> 转 HashMap<value, label>,O(1) 查找 |
| 嵌套按需加载 | 外层已加载的字典传递给内层,缺失时才补充 |
| 无注解短路 | 没有 @Dict 注解的对象直接跳过,零开销 |
| Redis 持久化 | 按 dictCode 细粒度缓存,变更时主动清除 |
| 可改造方向 | 如果各个项目字典表结构一致,查询字典数据可以传一个tableName参数即可,但会涉及到多数据源 |
九、缺点与性能优化(待实现)
- 性能相关
JSON 序列化开销 每次请求都把返回对象通过反射构建 JSONObject,相比直接返回 Java 对象多了一次序列化过程。数据量小时无感,但如果是大分页(比如一次返回几百条,每条几十个字段+嵌套),开销会明显。反射遍历所有字段 即使对象只有 1 个 @Dict 字段,也要遍历全部字段(含父类链)。虽然有 FIELD_CACHE 缓存 Field 数组,但每次还是要逐字段 field.get() 取值并放入 JSONObject。无注解对象也被处理 当前逻辑对所有走过切面的 Controller 方法都会触发 translateDict,即使返回的 VO 上没有任何 @Dict 注解,也会执行 instanceof 判断和类型检查。 - 解决方案 目前有两种解决思路
- 通过在方法或类级别加开关注解来彻底短路, 在 VO 类中上增加
@DictEnable注解判断是否需要提前短路。 - 在
DictTranslateInterceptor.java这个类中增加一个Map<Class<?>, Boolean>用来缓存 VO 判断是否需要提前短路
十、文件清单
| 文件 | 位置 | 说明 |
|---|---|---|
Dict.java |
api 模块 | 注解定义 |
DictDataProvider.java |
api 模块 | 数据加载接口 + DictItem |
DictAspectConfig.java |
service 模块 | 配置化切面注册 |
DictTranslateInterceptor.java |
service 模块 | 核心翻译逻辑 |
DictCacheService.java |
service 模块 | Redis 缓存服务 |
EntryDictDataProvider.java |
service 模块 | 本项目的 Provider 实现 |
DictCacheController.java |
service 模块 | 缓存管理接口 |
十一、总结
本方案的核心设计思想:
一套代码,多项目复用,不同的表、不同的包路径,只改配置文件。
- 注解驱动 --- 加
@Dict即生效,零侵入业务代码 - 接口隔离 ---
DictDataProvider让数据来源与翻译逻辑彻底解耦 - 全面配置化 --- Redis 前缀、过期时间、切面拦截路径均通过 yml 配置
- 防御性编程 --- 任何环节异常都降级处理,绝不阻断业务
- 缺点 ---性能相关:
--- 解决方案
十二、具体代码实现
- Dict.java
java
import java.lang.annotation.*;
/**
* 数据字典翻译注解
* <p>
* 标注在 VO/DTO 字段上,AOP 切面会自动将字典 code 翻译为文本,
* 追加到返回 JSON 中(默认字段名: 原字段名_dictStr),原始值保持不变。
* </p>
*
* <pre>
* 使用示例:
* {@code @Dict(dictCode = "gender")}
* private String gender; // 数据库值: "1"
*
* 返回 JSON:
* { "gender": "1", "gender_dictStr": "男" }
* </pre>
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Dict {
/**
* 字典类型编码(对应字典表的类型字段)
* 例如: "gender", "area", "education_background"
*/
String dictCode();
/**
* 翻译结果存放的字段名(可选)
* 默认为 "原字段名_dictStr",指定后使用自定义字段名
*/
String dictText() default "";
}
- DictDataProvider.java
java
import java.util.List;
/**
* 字典数据加载接口
* <p>
* 各项目实现此接口来提供字典数据,不绑定具体字典表结构。
* 可以查本地数据库、调 Feign 接口、读配置文件等。
* </p>
*/
public interface DictDataProvider {
/**
* 根据字典类型编码查询字典项列表
*
* @param dictCode 字典类型编码
* @return 字典项列表
*/
List<DictItem> getDictItemsByCode(String dictCode);
/**
* 字典项数据结构 后面可以单独抽出去,不通过内部类实现
*/
class DictItem {
/** 字典编码值 */
private String value;
/** 字典显示文本 */
private String label;
public DictItem() {
}
public DictItem(String value, String label) {
this.value = value;
this.label = label;
}
public String getValue() {
return value;
}
public void setValue(String value) {
this.value = value;
}
public String getLabel() {
return label;
}
public void setLabel(String label) {
this.label = label;
}
}
}
- EntryDictDataProvider
java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
/**
* eccp-entry 项目的字典数据 Provider 实现
* <p>
* 通过 DataDictionaryDao 查询本项目字典表,
* 将 DropDownValuesVo 转换为通用的 DictItem。
* </p>
*/
@Service
public class EntryDictDataProvider implements DictDataProvider {
@Autowired
private DataDictionaryDao dataDictionaryDao;
@Override
public List<DictItem> getDictItemsByCode(String dictCode) {
List<DropDownValuesVo> voList = dataDictionaryDao.getValuesByField(dictCode);
if (voList == null || voList.isEmpty()) {
return Collections.emptyList();
}
List<DictItem> items = new ArrayList<>(voList.size());
for (DropDownValuesVo vo : voList) {
items.add(new DictItem(vo.getValue(), vo.getName()));
}
return items;
}
}
- DictCacheService
java
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.util.*;
/**
* 字典缓存服务
* <p>
* 按 dictCode 细粒度缓存字典数据到 Redis,
* 缓存未命中时通过 DictDataProvider 加载并回写。
* </p>
*/
@Service
public class DictCacheService {
private static final Logger log = LoggerFactory.getLogger(DictCacheService.class);
@Value("${eccp.dict.redis-key-prefix:eccp:dict}")
private String redisKeyPrefix;
@Value("${eccp.dict.redis-expire-seconds:2592000}")
private int redisExpireSeconds;
@Autowired
private JedisUtil jedisUtil;
@Autowired
private DictDataProvider dictDataProvider;
/**
* 获取指定 dictCode 的字典项列表
*
* @param dictCode 字典类型编码
* @return 字典项列表,不会返回 null
*/
public List<DictItem> getDictItems(String dictCode) {
if (StringUtils.isBlank(dictCode)) {
return Collections.emptyList();
}
// 1. 优先从 Redis 获取
try {
String redisKey = redisKeyPrefix + ":" + dictCode;
String json = jedisUtil.getCurrentService(redisKey);
if (StringUtils.isNotBlank(json)) {
List<DictItem> items = JSON.parseArray(json, DictItem.class);
if (items != null) {
return items;
}
}
} catch (Exception e) {
log.warn("Redis获取字典缓存异常, dictCode={}", dictCode);
}
// 2. 缓存未命中,通过 Provider 加载
List<DictItem> items;
try {
items = dictDataProvider.getDictItemsByCode(dictCode);
} catch (Exception e) {
log.error("查询字典数据异常, dictCode={}", dictCode, e);
return Collections.emptyList();
}
if (items == null) {
items = Collections.emptyList();
}
// 3. 回写 Redis
try {
String redisKey = redisKeyPrefix + ":" + dictCode;
jedisUtil.setCurrentService(redisKey, JSON.toJSONString(items), redisExpireSeconds);
} catch (Exception e) {
log.warn("回写Redis异常, dictCode={}", dictCode);
}
return items;
}
/**
* 批量获取多个 dictCode 的字典数据
*
* @param dictCodes 字典编码集合
* @return dictCode -> 字典项列表 的映射
*/
public Map<String, List<DictItem>> batchGetDictItems(Set<String> dictCodes) {
Map<String, List<DictItem>> result = new HashMap<>(dictCodes.size());
for (String code : dictCodes) {
result.put(code, getDictItems(code));
}
return result;
}
/**
* 清除指定 dictCode 的缓存
*
* @param dictCode 字典类型编码
*/
public void evict(String dictCode) {
try {
jedisUtil.delCurrentService(redisKeyPrefix + ":" + dictCode);
} catch (Exception e) {
log.warn("清除字典缓存异常, dictCode={}", dictCode);
}
}
/**
* 清除并重新加载指定 dictCode 的缓存
*
* @param dictCode 字典类型编码
*/
public void reload(String dictCode) {
evict(dictCode);
getDictItems(dictCode);
}
}
- DictCacheController
java
import com.shinho.common.api.base.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;
/**
* 字典缓存管理接口
* <p>
* 字典数据变更后,通过此接口主动刷新 Redis 缓存,保证一致性。
* </p>
*/
@RestController
@RequestMapping("/dict/cache")
@Api(tags = "字典缓存管理")
public class DictCacheController {
@Autowired
private DictCacheService dictCacheService;
@PostMapping("/evict")
@ApiOperation("清除指定字典类型的缓存")
public Result<String> evict(@ApiParam("字典类型编码") @RequestParam String dictCode) {
dictCacheService.evict(dictCode);
return Result.ok("缓存已清除: " + dictCode);
}
@PostMapping("/reload")
@ApiOperation("清除并重新加载指定字典类型的缓存")
public Result<String> reload(@ApiParam("字典类型编码") @RequestParam String dictCode) {
dictCacheService.reload(dictCode);
return Result.ok("缓存已重新加载: " + dictCode);
}
}
- DictTranslateInterceptor
java
import com.github.pagehelper.PageInfo;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
/**
* 字典翻译核心拦截器
* <p>
* 拦截 Controller 方法的返回值,自动将标注了 {@link Dict} 注解的字段
* 翻译为文本,追加 "_dictStr" 后缀字段到返回的 JSON 中。
* </p>
* <p>
* 特性:
* <ul>
* <li>支持 PageInfo、PageResult、List、单对象等多种返回类型, PageInfo、PageResult、Result 换成自己项目中使用的</li>
* <li>递归处理嵌套对象和 List 内部的字典字段</li>
* <li>支持逗号分隔的多值翻译</li>
* <li>降级容错:翻译异常不阻断业务,返回原始数据</li>
* <li>ConcurrentHashMap 缓存反射结果,避免重复反射</li>
* </ul>
* </p>
*/
@Component
public class DictTranslateInterceptor implements MethodInterceptor {
private static final Logger log = LoggerFactory.getLogger(DictTranslateInterceptor.class);
private static final String DICT_SUFFIX = "_dictStr";
/** Class -> 所有字段(含父类)缓存 */
private static final Map<Class<?>, Field[]> FIELD_CACHE = new ConcurrentHashMap<>();
@Autowired
private DictCacheService dictCacheService;
@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
Object result = invocation.proceed();
try {
result = translateDict(result);
} catch (Exception e) {
log.warn("字典翻译异常,返回原始数据: {}", e.getMessage());
}
return result;
}
/**
* 翻译字典入口
*/
@SuppressWarnings("unchecked")
private Object translateDict(Object result) {
if (!(result instanceof Result)) {
return result;
}
Result<?> resultObj = (Result<?>) result;
Object data = resultObj.getData();
if (data == null) {
return result;
}
if (data instanceof PageInfo) {
PageInfo<?> pageInfo = (PageInfo<?>) data;
if (CollectionUtils.isNotEmpty(pageInfo.getList())) {
List<Object> translated = processRecords(pageInfo.getList());
((PageInfo) pageInfo).setList(translated);
}
} else if (data instanceof PageResult) {
PageResult pageResult = (PageResult) data;
if (CollectionUtils.isNotEmpty(pageResult.getRows())) {
List<Object> translated = processRecords(pageResult.getRows());
pageResult.setRows(translated);
}
} else if (data instanceof List) {
List<?> list = (List<?>) data;
if (CollectionUtils.isNotEmpty(list) && !isJavaBasicType(list.get(0).getClass())) {
((Result) resultObj).setData(processRecords(list));
}
} else if (!isJavaBasicType(data.getClass())) {
List<Object> single = processRecords(Collections.singletonList(data));
if (CollectionUtils.isNotEmpty(single)) {
((Result) resultObj).setData(single.get(0));
}
}
return result;
}
/**
* 批量处理记录列表
*/
private List<Object> processRecords(List<?> records) {
if (CollectionUtils.isEmpty(records)) {
return Collections.emptyList();
}
// 1. 收集所有需要翻译的 dictCode
Object firstItem = records.get(0);
if (isJavaBasicType(firstItem.getClass())) {
return new ArrayList<>(records);
}
Set<String> allDictCodes = collectAllDictCodes(firstItem.getClass());
if (allDictCodes.isEmpty()) {
// 没有 @Dict 注解,检查是否有嵌套对象需要处理
if (!hasNestedDictFields(firstItem.getClass())) {
return new ArrayList<>(records);
}
}
// 2. 批量获取字典数据
Map<String, List<DictItem>> dictDataMap = dictCacheService.batchGetDictItems(allDictCodes);
// 3. 逐条翻译
List<Object> resultList = new ArrayList<>(records.size());
for (Object record : records) {
resultList.add(buildJsonWithDict(record, dictDataMap));
}
return resultList;
}
/**
* 递归构建带 _dictStr 的 JSONObject
*/
private JSONObject buildJsonWithDict(Object obj, Map<String, List<DictItem>> dictDataMap) {
JSONObject json = new JSONObject(true); // 保持字段顺序
Field[] allFields = getAllFields(obj.getClass());
for (Field field : allFields) {
field.setAccessible(true);
Object value = getFieldValueSafe(obj, field);
// 处理嵌套对象
if (value != null && !isJavaBasicType(field.getType())) {
if (value instanceof List) {
List<?> listValue = (List<?>) value;
if (CollectionUtils.isNotEmpty(listValue) && !isJavaBasicType(listValue.get(0).getClass())) {
Set<String> nestedCodes = collectAllDictCodes(listValue.get(0).getClass());
Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
List<Object> nestedResult = new ArrayList<>();
for (Object item : listValue) {
nestedResult.add(buildJsonWithDict(item, mergedMap));
}
json.put(field.getName(), nestedResult);
} else {
json.put(field.getName(), value);
}
} else if (!field.getType().isEnum()) {
Set<String> nestedCodes = collectAllDictCodes(field.getType());
if (!nestedCodes.isEmpty() || hasNestedDictFields(field.getType())) {
Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
json.put(field.getName(), buildJsonWithDict(value, mergedMap));
} else {
json.put(field.getName(), value);
}
} else {
json.put(field.getName(), value);
}
} else {
json.put(field.getName(), value);
}
// 追加 _dictStr 字段
Dict dict = field.getAnnotation(Dict.class);
if (dict != null && value != null) {
String textValue = translateValue(dictDataMap, dict.dictCode(), value.toString());
String dictFieldName = StringUtils.isNotBlank(dict.dictText())
? dict.dictText()
: (field.getName() + DICT_SUFFIX);
json.put(dictFieldName, textValue);
}
}
return json;
}
/**
* 字典值翻译(支持逗号分隔多值)
*/
private String translateValue(Map<String, List<DictItem>> dictDataMap, String dictCode, String value) {
List<DictItem> items = dictDataMap.get(dictCode);
// 降级:返回原值
if (items == null || items.isEmpty()) {
return value;
}
// 构建 value -> label 映射
Map<String, String> valueToLabelMap = new HashMap<>(items.size());
for (DictItem item : items) {
if (item.getValue() != null) {
valueToLabelMap.put(item.getValue(), item.getLabel());
}
}
// 支持逗号分隔的多值
String[] codes = value.split(",");
StringBuilder sb = new StringBuilder();
for (int i = 0; i < codes.length; i++) {
String code = codes[i].trim();
// 找不到返回原值
String text = valueToLabelMap.getOrDefault(code, code);
if (i > 0) {
sb.append(",");
}
sb.append(text);
}
return sb.toString();
}
/**
* 收集指定类及其嵌套类中所有 @Dict 注解的 dictCode
*/
private Set<String> collectAllDictCodes(Class<?> clazz) {
Set<String> codes = new HashSet<>();
Field[] fields = getAllFields(clazz);
for (Field field : fields) {
Dict dict = field.getAnnotation(Dict.class);
if (dict != null) {
codes.add(dict.dictCode());
}
}
return codes;
}
/**
* 检查类是否有嵌套对象中包含 @Dict 字段
*/
private boolean hasNestedDictFields(Class<?> clazz) {
Field[] fields = getAllFields(clazz);
for (Field field : fields) {
if (!isJavaBasicType(field.getType()) && !field.getType().isEnum()
&& field.getType() != List.class) {
Set<String> nestedCodes = collectAllDictCodes(field.getType());
if (!nestedCodes.isEmpty()) {
return true;
}
}
// 检查 List 泛型内容太复杂,此处简化处理
}
return false;
}
/**
* 确保所有需要的字典数据都已加载
*/
private Map<String, List<DictItem>> ensureDictLoaded(Map<String, List<DictItem>> existing, Set<String> neededCodes) {
Set<String> missingCodes = new HashSet<>();
for (String code : neededCodes) {
if (!existing.containsKey(code)) {
missingCodes.add(code);
}
}
if (missingCodes.isEmpty()) {
return existing;
}
// 加载缺失的字典数据
Map<String, List<DictItem>> additional = dictCacheService.batchGetDictItems(missingCodes);
Map<String, List<DictItem>> merged = new HashMap<>(existing);
merged.putAll(additional);
return merged;
}
/**
* 获取类的所有字段(含父类),使用缓存
*/
private Field[] getAllFields(Class<?> clazz) {
return FIELD_CACHE.computeIfAbsent(clazz, c -> {
List<Field> fieldList = new ArrayList<>();
Class<?> tempClass = c;
while (tempClass != null && tempClass != Object.class) {
fieldList.addAll(Arrays.asList(tempClass.getDeclaredFields()));
tempClass = tempClass.getSuperclass();
}
return fieldList.toArray(new Field[0]);
});
}
/**
* 安全获取字段值
*/
private Object getFieldValueSafe(Object obj, Field field) {
try {
return field.get(obj);
} catch (Exception e) {
return null;
}
}
/**
* 判断是否是 Java 基本类型或常见类型(不需要递归处理的类型)
*/
private boolean isJavaBasicType(Class<?> clazz) {
return clazz.isPrimitive()
|| clazz == String.class
|| clazz == Integer.class
|| clazz == Long.class
|| clazz == Double.class
|| clazz == Float.class
|| clazz == Boolean.class
|| clazz == Short.class
|| clazz == Byte.class
|| clazz == Character.class
|| clazz == java.math.BigDecimal.class
|| clazz == java.math.BigInteger.class
|| clazz == java.util.Date.class
|| clazz == java.time.LocalDate.class
|| clazz == java.time.LocalDateTime.class
|| Number.class.isAssignableFrom(clazz)
|| clazz.getName().startsWith("java.time.")
|| clazz == Map.class
|| Map.class.isAssignableFrom(clazz);
}
}
- DictAspectConfig
java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
/**
* 字典翻译切面配置
* <p>
* 核心亮点:切面拦截路径通过 application.yml 配置,
* 不同项目无需修改代码,只改配置即可复用。
* </p>
* <p>
* 使用 {@link DefaultPointcutAdvisor} + {@link AspectJExpressionPointcut} 编程式方式,
* 而非 @Aspect + @Pointcut,因为后者的表达式必须是编译时常量,无法通过 @Value 注入。
* </p>
*/
@Configuration
public class DictAspectConfig {
private static final Logger log = LoggerFactory.getLogger(DictAspectConfig.class);
/**
* 切面拦截表达式,各项目通过配置自定义
* 默认拦截 com.shinho.eccp 下所有 web 包的 Controller 方法
*/
@Value("${eccp.dict.pointcut-expression:execution(* com.shinho.eccp..web.*.*(..))}")
private String pointcutExpression;
@Autowired
private DictTranslateInterceptor dictTranslateInterceptor;
@Bean
public DefaultPointcutAdvisor dictAdvisor() {
AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
pointcut.setExpression(pointcutExpression);
log.info("字典翻译切面已注册, pointcut={}", pointcutExpression);
return new DefaultPointcutAdvisor(pointcut, dictTranslateInterceptor);
}
}