Aop +注解 实现数据字典类型转换
文章目录
- Aop +注解 实现数据字典类型转换
- 一、基础方式
- ✅字典转换简介
- 👉实现步骤
- ✅ 1. 定义自定义注解`@Dict `
- ✅ 2. 定义查询字典项的两个方法
- ✅ 3. 定义Aop拦截我们查询的方法
- ✅ 4. VO映射类
- ✅ 5. Controller层
- ✅ 6. serviceImpl
- ✅ 7. 对象转换工具
- Mapper
- BeanCopierUtil
- 二、优化改成符合多种类型转换
- ✅ 步骤二:封装一个通用字典转换工具类改进方案结构图:
- ✅ 步骤二:封装一个通用字典转换工具类步骤一:注解保留不变
- ✅ 步骤二:封装一个查询字典工具类
- Mapper.xml
- Mapper 接口
- ServiceImpl
- ✅ 步骤三:AOP 对返回数据进行增强处理
- 👉 环绕通知处理 Controller 的返回数据
- ✅ 步骤四:VO 示例
- ✅ 示例 Controller
- ✅ 示例返回 JSON:
- ✅ 总结要点
- 三、通用的递归遍历版本(最终版 v1)
- ✅它可以处理以下情况:
- ✅核心思路是:
- 1. `@Dict` 注解
- 2. 示例 VO(你的“复杂数据”场景)
- 3. `DictService` 接口及实现(从数据库查询字典值)
- 4. 核心:通用的 `DictAspect`(AOP 切面)
- ✅讲解
- 5. 最终在 Controller 中的使用示例
- 6. ✅ 总结
- 四、只拦截特定 Controller 类(最终版 v2)
- ✅ 1. 自定义注解标记 Controller
- ✅ 2. 自定义字段注解 `@Dict`
- ✅ 3. 在 Controller 上使用标记注解
- ✅ 4. 字典查询接口(模拟数据库)
- ✅ 5. 通用字典转换工具类(支持递归、List、Page、Map 等)
- ✅ 6. AOP 拦截特定 Controller 并自动转换
- ✅ 7. 测试返回结构
- ✅ 效果示例
- 🔍原始:
- ✅ 完整代码
- 五、只拦截特定 Controller 类中指定的方法(最终版 v3)
- ✅ 该方法适应以下场景:
- 1. `pom.xml`
- 2. `src/main/resources/application.properties`
- 3. `src/main/resources/schema.sql`
- 4. `src/main/resources/data.sql`
- ✅5. 目录结构
- 5.1 `DemoApplication.java`
- 5.2 注解:`Dict.java`
- 5.3 注解:`EnableDictConvert.java`
- 5.4 切面:`DictAspect.java`
- 5.5 工具类:`DictValueConverter.java`
- 5.6 服务层接口:`DictService.java`
- 5.7 服务层实现:`DictServiceImpl.java`
- 5.8 通用响应体:`R.java`
- 5.9 域对象:`UserVO.java`
- 5.10 域对象:`StudentVO.java`
- 5.11 域对象:`PostVO.java`
- 5.12 控制器:`UserController.java`
- 6. 启动与验证
- 7. ✅ 说明与可扩展点
- 六、EasyExcel 导出嵌套数据并动态映射字典
- 1. 在导出前通过自定义注解和 AOP 机制对字段进行数据字典转换。
- ✅主要功能如下:
- 2. 项目结构
- 3. 数据库与字典表
- 4. 实体/VO 定义
- 5. 自定义注解 @Dict
- 6. 字典解析服务(DictResolverService)
- 7. AOP 切面(可选)
- 8. 导出接口 Controller
- 9. 前端页面(Thymeleaf)
一、基础方式
✅字典转换简介
以前我们从数据字典里面取值,拿到的都是一堆状态码,我们需要在前台进行判断,然后转义成中文,这样是十分麻烦的,这又是每个字典字段不可少的一个地方,所以我就想到了利用AOP+切面 来帮我们实现中文的转义。
首先直接看下最后实现的效果吧,一般我们的数据字典接口就之后返回我们的状态码,在aop处理过之后,它把我们的一些性别、岗位等状态码转成了中文多加了几个字段一并返回给我们,这样我们在前台绑定的时候就能直接通过 XXText的字段进行绑定了,前端在映射的时候直接取XXText字段,我们的不需要进行转义了
{
"code": 0,
"msg": null,
"data": [
{
"id": null,
"userNickname": "郭德纲",
"userPhone": "13800138001",
"userEmail": "guodegang@xxx.com",
"userGender": 1,
"userBirth": "1973-01-18T00:00:00",
"userScore": 21,
"userReward": 100.21,
"sex": "0",
"sexText": "女",
"postType": "3",
"postTypeText": "运维"
},
{
"id": null,
"userNickname": "于谦",
"userPhone": "13800138002",
"userEmail": "yuqian@xxx.com",
"userGender": 2,
"userBirth": "1969-01-24T00:00:00",
"userScore": 24,
"userReward": 100.24,
"sex": "2",
"sexText": "未知",
"postType": "2",
"postTypeText": "管理"
},
{
"id": null,
"userNickname": "栾云平",
"userPhone": "13800138003",
"userEmail": "luanyunping@xxx.com",
"userGender": 0,
"userBirth": "1984-05-20T00:00:00",
"userScore": 35,
"userReward": 100.35,
"sex": "1",
"sexText": "男",
"postType": "1",
"postTypeText": "维修"
},
{
"id": null,
"userNickname": "岳云鹏",
"userPhone": "13800138004",
"userEmail": "yueyunpeng@xxx.com",
"userGender": 1,
"userBirth": "1985-04-15T00:00:00",
"userScore": 20,
"userReward": 100.20,
"sex": "0",
"sexText": "女",
"postType": "4",
"postTypeText": "开发"
},
{
"id": null,
"userNickname": "孙越",
"userPhone": "13800138005",
"userEmail": "sunyue@xxx.com",
"userGender": 2,
"userBirth": "1979-10-13T00:00:00",
"userScore": 22,
"userReward": 100.22,
"sex": "2",
"sexText": "未知",
"postType": "4",
"postTypeText": "开发"
},
{
"id": null,
"userNickname": "郭麒麟",
"userPhone": "13800138006",
"userEmail": "guoqilin@xxx.com",
"userGender": 0,
"userBirth": "1996-02-08T00:00:00",
"userScore": 24,
"userReward": 100.24,
"sex": "0",
"sexText": "女",
"postType": "3",
"postTypeText": "运维"
},
{
"id": null,
"userNickname": "阎鹤祥",
"userPhone": "13800138007",
"userEmail": "yanhexiang@xxx.com",
"userGender": 1,
"userBirth": "1981-09-14T00:00:00",
"userScore": 25,
"userReward": 100.25,
"sex": "1",
"sexText": "男",
"postType": "1",
"postTypeText": "维修"
},
{
"id": null,
"userNickname": "张云雷",
"userPhone": "13800138008",
"userEmail": "zhangyunlei@xxx.com",
"userGender": 2,
"userBirth": "1992-01-11T00:00:00",
"userScore": 29,
"userReward": 100.29,
"sex": "0",
"sexText": "女",
"postType": "2",
"postTypeText": "管理"
},
{
"id": null,
"userNickname": "杨九郎",
"userPhone": "13800138009",
"userEmail": "yangjiulang@xxx.com",
"userGender": 0,
"userBirth": "1989-07-17T00:00:00",
"userScore": 35,
"userReward": 100.35,
"sex": "1",
"sexText": "男",
"postType": "3",
"postTypeText": "运维"
},
{
"id": null,
"userNickname": "孟鹤堂",
"userPhone": "13800138010",
"userEmail": "mengetang@xxx.com",
"userGender": 1,
"userBirth": "1988-04-26T00:00:00",
"userScore": 30,
"userReward": 100.30,
"sex": "0",
"sexText": "女",
"postType": "2",
"postTypeText": "管理"
},
{
"id": null,
"userNickname": "周九良",
"userPhone": "13800138011",
"userEmail": "zhoujiuliang@xxx.com",
"userGender": 2,
"userBirth": "1994-09-14T00:00:00",
"userScore": 26,
"userReward": 100.26,
"sex": "2",
"sexText": "未知",
"postType": "4",
"postTypeText": "开发"
}
]
}
##✅ 实现思路:
用
aop 后置通知(handleDictConversion)拦截我们的查询数据的方法,我们遍历查询出的code,然后通过code去调用方法查询出它对应的text文本值,添加了注解@Dict(dicDataSource = “stu_sex”) 会在字典服务立马查出来对应的text 然后在请求list的时候将这个字典text,已字段名称加Text形式返回到前端(例如:我的性别字段是sex,然后我们通过拿到这个字段的值2去查询出它对应的未知,然后把这个字段赋值给我们的sexText 然后一并添加到方法中返回),然后前端就可以直接取这个字段进行赋值
👉实现步骤
✅ 1. 定义自定义注解@Dict
package com.js.archive.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Dict {
/**
* 方法描述: 数据dataSource
* @return 返回类型: String
*/
String dicDataSource();
/**
* 方法描述: 这是返回后Put到josn中的文本 key 值
* @return 返回类型: String
*/
String dicText() default "";
}
✅ 2. 定义查询字典项的两个方法
/**
* dto条件查询获取字典项List
*
* @author Jshuai
* @date 2025-06-01
* @param searchDto 查询实体参数
* @return List<SysDictDataVO>
*/
@Override
public List<SysDictDataVO> getList(SysDictDataSearchDTO searchDto){
return Mapper.map(getLambdaWrapper(searchDto, null)
.list(), SysDictDataVO.class);
}
/**
* 根据字典类型查询单个字典项
* @param type 字典类型 字典值
* @return
*/
public SysDictDataVO getQueryOne(String type ,String value){
return Mapper.map(lambdaQuery()
.eq(StringTools.isNotBlank(type), SysDictData::getDictType, type)
.eq(StringTools.isNotBlank(value), SysDictData::getDictValue, value)
.one(), SysDictDataVO.class);
}
✅ 3. 定义Aop拦截我们查询的方法
核心代码,利用aop对我们的查询人员方法进行拦截,并且查询出我们转义的中文发送到前端
package com.js.archive.aspect;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.js.archive.annotation.Dict;
import com.js.archive.service.SysDictDataService;
import com.js.archive.util.ObjConvertUtils;
import com.js.core.domain.R;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.JoinPoint;
import org.aspectj.lang.annotation.AfterReturning;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.annotation.Pointcut;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.lang.reflect.Field;
import java.text.SimpleDateFormat;
import java.util.*;
@Aspect
@Component
@Slf4j
public class DictAspect {
private static final String DICT_TEXT_SUFFIX = "Text";
@Autowired
private SysDictDataService sysDictDataService;
// 定义切点:拦截controller包下所有方法
@Pointcut("execution(* com.js.archive.controller..*.*(..))")
public void controllerPointcut() {
log.debug("拦截Controller方法: {}");
}
// 后置通知:处理响应结果
@AfterReturning(pointcut = "controllerPointcut()", returning = "result")
public void handleDictConversion(JoinPoint joinPoint, Object result) {
try {
// 非R类型响应直接跳过
if (!(result instanceof R)) {
log.debug("响应类型非R,跳过处理");
return;
}
// 使用通配符泛型,兼容所有R<T>
R r = (R) result;
Object data = r.getData();
// 空值判断(包含null、空字符串、"null"字符串)
if (ObjConvertUtils.isEmpty(data)) {
log.debug("响应数据为空,跳过处理");
return;
}
// 处理数据并更新到R对象中
if (data instanceof Page<?>) {
// 分页数据,只处理记录列表部分
List records = ((Page) data).getRecords();
Object o = processData(records);
((Page) data).setRecords((List) o);
return;
}else {
r.setData(processData(data));
}
log.debug("字典转换完成,方法: {},数据类型: {}",
joinPoint.getSignature().toShortString(),
data.getClass().getName());
} catch (Exception e) {
log.error("字典转换异常: 方法={}, 原因={}",
joinPoint.getSignature().toShortString(),
e.getMessage(), e);
}
}
// 数据处理入口
private Object processData(Object data) {
// 处理JSON对象
if (data instanceof JSONObject) {
return processJSONObject((JSONObject) data);
}
// 处理JSON数组
else if (data instanceof JSONArray) {
return processJSONArray((JSONArray) data);
}
// 处理List集合(可能包含JSON对象或实体类)
else if (data instanceof List) {
return processList((List<?>) data);
}
// 处理Map集合
else if (data instanceof Map) {
return processMap((Map<?, ?>) data);
}
// 处理普通实体类(转为JSON后处理)
else {
return processBean(data);
}
}
// 处理JSONObject
private JSONObject processJSONObject(JSONObject json) {
JSONObject result = new JSONObject(true); // 保留字段顺序
for (Map.Entry<String, Object> entry : json.entrySet()) {
String key = entry.getKey();
Object value = entry.getValue();
result.put(key, processData(value)); // 递归处理嵌套值
}
// 处理当前对象的字段注解
return processAnnotatedFields(result, null);
}
// 处理JSONArray
private JSONArray processJSONArray(JSONArray array) {
JSONArray result = new JSONArray();
for (int i = 0; i < array.size(); i++) {
result.add(processData(array.get(i))); // 递归处理每个元素
}
return result;
}
// 处理List集合
private List<Object> processList(List<?> list) {
List<Object> result = new ArrayList<>();
for (Object item : list) {
result.add(processData(item));
}
return result;
}
// 处理Map集合
private Map<Object, Object> processMap(Map<?, ?> map) {
Map<Object, Object> result = new LinkedHashMap<>(); // 保留插入顺序
for (Map.Entry<?, ?> entry : map.entrySet()) {
result.put(entry.getKey(), processData(entry.getValue()));
}
return result;
}
// 处理普通实体类(转为JSON后处理)
private Object processBean(Object bean) {
if (ObjConvertUtils.isEmpty(bean)) {
return null;
}
String jsonStr = null;
try {
// 将实体类转为JSON字符串
jsonStr = JSON.toJSONString(bean);
JSONObject json = JSON.parseObject(jsonStr);
// 处理字段注解
processAnnotatedFields(json, bean);
// 尝试反序列化为原类型(保持泛型类型一致性)
return JSON.toJavaObject(json, bean.getClass());
} catch (Exception e) {
log.error("实体类处理失败: {},转为JSON: {}",
bean.getClass().getName(), jsonStr, e);
return bean; // 转换失败返回原始对象
}
}
// 处理字段注解(字典和日期)
private JSONObject processAnnotatedFields(JSONObject json, Object originalObject) {
try {
// 获取所有字段(适用于实体类)
Field[] fields = originalObject != null ?
ObjConvertUtils.getAllFields(originalObject) :
new Field[0];
for (Field field : fields) {
processField(json, field, originalObject);
}
// 处理JSON原生字段(无实体类场景,通过字段名判断)
if (originalObject == null) {
for (String fieldName : json.keySet()) {
handleDateFormatForJsonField(json, fieldName);
}
}
} catch (Exception e) {
log.error("字段注解处理失败: {}", e.getMessage(), e);
}
return json;
}
// 处理单个字段(适用于实体类)
private void processField(JSONObject json, Field field, Object originalObject) throws IllegalAccessException {
String fieldName = field.getName();
if (!json.containsKey(fieldName)) {
return;
}
field.setAccessible(true);
Object fieldValue = json.get(fieldName);
// 处理字典注解
handleDictAnnotation(json, field, fieldName, fieldValue);
// 处理日期格式化
handleDateFormat(json, field, fieldName, fieldValue);
}
// 处理字典注解
private void handleDictAnnotation(JSONObject json, Field field, String fieldName, Object fieldValue) {
Dict dict = field.getAnnotation(Dict.class);
if (dict == null || ObjConvertUtils.isEmpty(fieldValue)) {
return;
}
String dictType = dict.dicDataSource();
String dictText = dict.dicText();
String key = String.valueOf(fieldValue);
String translatedValue = translateDictValue(dictType, key);
if (!ObjConvertUtils.isEmpty(translatedValue)) {
String targetField = StringUtils.hasText(dictText) ? dictText : fieldName + DICT_TEXT_SUFFIX;
json.put(targetField, translatedValue);
}
}
// 处理日期格式化
private void handleDateFormat(JSONObject json, Field field, String fieldName, Object fieldValue) {
if (field.getType() == Date.class &&
field.getAnnotation(JsonFormat.class) == null &&
!ObjConvertUtils.isEmpty(fieldValue)) {
try {
long timestamp;
if (fieldValue instanceof Long) {
timestamp = (Long) fieldValue;
} else if (fieldValue instanceof String) {
timestamp = Long.parseLong((String) fieldValue);
} else {
return; // 不支持的时间类型
}
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
json.put(fieldName, sdf.format(new Date(timestamp)));
} catch (Exception e) {
log.debug("日期格式化失败: {},值: {}", fieldName, fieldValue);
}
}
}
// 处理JSON原生字段的日期格式化(无实体类场景)
private void handleDateFormatForJsonField(JSONObject json, String fieldName) {
Object value = json.get(fieldName);
if (value instanceof Long || (value instanceof String && value.toString().matches("\\d+"))) {
try {
long timestamp = Long.parseLong(value.toString());
SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss");
json.put(fieldName, sdf.format(new Date(timestamp)));
} catch (Exception e) {
log.debug("非时间戳字段: {}", fieldName);
}
}
}
// 翻译字典值
private String translateDictValue(String dictType, String key) {
if (ObjConvertUtils.isEmpty(dictType) || ObjConvertUtils.isEmpty(key)) {
return null;
}
StringBuilder result = new StringBuilder();
String[] keys = key.split(",");
for (String k : keys) {
String trimmedKey = k.trim();
if (ObjConvertUtils.isEmpty(trimmedKey)) {
continue;
}
try {
String label = sysDictDataService.getQueryOne(dictType, trimmedKey).getDictLabel();
if (!ObjConvertUtils.isEmpty(label)) {
if (result.length() > 0) {
result.append(",");
}
result.append(label);
}
} catch (Exception e) {
log.warn("字典查询失败: type={}, key={}, 原因: {}", dictType, trimmedKey, e.getMessage());
}
}
return result.length() > 0 ? result.toString() : null;
}
}
✅ 4. VO映射类
package com.js.archive.domain.vo;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import com.js.archive.annotation.Dict;
import com.js.core.domain.vo.BaseVO;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* <p>
* VO
* </p>
*
* @author JiangShuai
* @date 2025-06-01
* @since JDK 1.8
*/
@ApiModel(value="TbUserInfo视图对象", description="")
@EqualsAndHashCode(callSuper = true)
@Data
public class TbUserInfoVO extends BaseVO {
private static final long serialVersionUID = 1L;
@ApiModelProperty(value = "用户昵称")
private String userNickname;
@ApiModelProperty(value = "用户手机")
private String userPhone;
@ApiModelProperty(value = "用户邮箱")
private String userEmail;
@ApiModelProperty(value = "用户性别")
private Integer userGender;
@ApiModelProperty(value = "用户生日")
private LocalDateTime userBirth;
@ApiModelProperty(value = "用户积分")
private Integer userScore;
@ApiModelProperty(value = "用户佣金")
private BigDecimal userReward;
@ApiModelProperty(value = "性别")
@Dict(dicDataSource = "sex", dicText = "sexText")
private String sex;
@ApiModelProperty(value = "性别文本")
private String sexText;
@ApiModelProperty(value = "岗位类型")
@Dict(dicDataSource = "post_type", dicText = "postTypeText")
private String postType;
@ApiModelProperty(value = "岗位文本")
private String postTypeText;
}
✅ 5. Controller层
package com.js.archive.controller;
import com.js.archive.domain.vo.SysDictTypeVO;
import com.js.archive.domain.dto.SysDictTypeAddDTO;
import com.js.archive.domain.dto.SysDictTypeUpdateDTO;
import com.js.archive.domain.dto.SysDictTypeSearchDTO;
import com.js.archive.domain.dto.SysDictTypePageSearchDTO;
import com.js.archive.service.SysDictTypeService;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.js.core.domain.R;
import io.swagger.annotations.*;
import org.springframework.web.bind.annotation.*;
import lombok.AllArgsConstructor;
import javax.validation.constraints.NotNull;
import java.io.IOException;
import java.util.List;
/**
* <p>
* TODO
* </p>
*
* @author JiangShuai
* @date 2025-06-01
* @since JDK 1.8
*/
@AllArgsConstructor
@Api(value = "", tags = "字典类型管理")
@RestController
@RequestMapping("/sysDictType")
public class SysDictTypeController {
private SysDictTypeService sysDictTypeService;
@ApiOperation("id获取单条字典类型")
@GetMapping("/one/{id}")
public R<SysDictTypeVO> getSysDictType(@NotNull @PathVariable("id") Long id ) {
return R.ok(sysDictTypeService.getOne(id));
}
@ApiOperation("dto条件查询获取字典类型List")
@GetMapping("/list")
public R<List<SysDictTypeVO>> get(SysDictTypeSearchDTO searchDto) {
return R.ok(sysDictTypeService.getList(searchDto));
}
@ApiOperation("dto条件查询获取字典类型Page")
@GetMapping("/page")
public R<Page<SysDictTypeVO>> get(SysDictTypePageSearchDTO pageSearchDto) {
return R.ok(sysDictTypeService.getPage(pageSearchDto));
}
}
✅ 6. serviceImpl
package com.js.archive.service.impl;
import com.js.archive.domain.entity.TbUserInfo;
import com.js.archive.domain.vo.TbUserInfoVO;
import com.js.archive.domain.dto.TbUserInfoAddDTO;
import com.js.archive.domain.dto.TbUserInfoUpdateDTO;
import com.js.archive.domain.dto.TbUserInfoSearchDTO;
import com.js.archive.domain.dto.TbUserInfoPageSearchDTO;
import com.js.archive.mapper.TbUserInfoMapper;
import com.js.archive.service.TbUserInfoService;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.js.core.service.impl.BaseServiceImpl;
import com.js.core.toolkit.StringPool;
import com.js.core.toolkit.Assert;
import com.js.core.toolkit.Mapper;
import org.springframework.stereotype.Service;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
/**
* <p>
* 服务实现类
* </p>
*
* @author JiangShuai
* @date 2025-06-01
* @since JDK 1.8
*/
@Service
public class TbUserInfoServiceImpl extends BaseServiceImpl<TbUserInfoMapper, TbUserInfo> implements TbUserInfoService {
/**
* 添加单条人员信息
*
* @author Jshuai
* @date 2025-06-01
* @param addDto 添加实体参数
* @return TbUserInfoVO
*/
@Override
public TbUserInfoVO addOne(TbUserInfoAddDTO addDto){
TbUserInfo entity = Mapper.map(addDto, TbUserInfo.class);
save(entity);
return Mapper.map(entity, TbUserInfoVO.class);
}
/**
* id删除单条人员信息
*
* @author Jshuai
* @date 2025-06-01
* @param id 主键id
* @return Boolean
*/
@Override
public Boolean deleteOne(Long id){
return removeById(id);
}
/**
* ids删除多条人员信息
*
* @author Jshuai
* @date 2025-06-01
* @param ids 主键id字符长串,逗号分隔
* @return Boolean
*/
@Override
public Boolean deleteBatch(String ids){
Assert.notEmpty(ids, "ids参数不能为空!");
List<Long> idList = Arrays.stream(ids.split(StringPool.COMMA))
.mapToLong(Long::parseLong)
.boxed()
.collect(Collectors.toList());
return removeByIds(idList);
}
/**
* id修改单条人员信息
*
* @author Jshuai
* @date 2025-06-01
* @param updateDto 修改实体参数
* @return TbUserInfoVO
*/
@Override
public TbUserInfoVO updateOne(TbUserInfoUpdateDTO updateDto){
TbUserInfo entity = Mapper.map(updateDto, TbUserInfo.class);
updateById(entity);
return Mapper.map(entity, TbUserInfoVO.class);
}
/**
* id查询单条人员信息
*
* @author Jshuai
* @date 2025-06-01
* @param id 主键id
* @return TbUserInfoVO
*/
@Override
public TbUserInfoVO getOne(Long id){
return Mapper.map(getById(id), TbUserInfoVO.class);
}
/**
* dto条件查询获取人员信息List
*
* @author Jshuai
* @date 2025-06-01
* @param searchDto 查询实体参数
* @return List<TbUserInfoVO>
*/
@Override
public List<TbUserInfoVO> getList(TbUserInfoSearchDTO searchDto){
List<TbUserInfo> list = getLambdaWrapper(searchDto, null)
.list();
List<TbUserInfoVO> infoVOList = Mapper.map(list, TbUserInfoVO.class);
return infoVOList;
}
/**
* dto条件查询获取人员信息Page
*
* @author Jshuai
* @date 2025-06-01
* @param pageSearchDto 分页查询实体参数
* @return Page<TbUserInfoVO>
*/
@Override
public Page<TbUserInfoVO> getPage(TbUserInfoPageSearchDTO pageSearchDto){
return Mapper.map(getLambdaWrapper(pageSearchDto, null)
.page(new Page<>(pageSearchDto.getCurrent(), pageSearchDto.getSize())), TbUserInfoVO.class);
}
}
✅ 7. 对象转换工具
Mapper
package com.js.core.toolkit;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.js.core.toolkit.util.BeanCopierUtil;
import com.js.core.toolkit.util.CollectionUtil;
import com.js.core.toolkit.util.ReflectUtil;
import lombok.extern.slf4j.Slf4j;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/**
* <p>
* 实体类转换工具类
* </p>
*
* @author Jshuai
* @date 2025/6/4 9:41
* @since JDK 1.8
*/
@Slf4j
public class Mapper {
/**
* 实体转换
*
* @param object 实体
* @param cls 转换目标类型
*/
public static <T> T map(Object object, Class<T> cls) {
log.debug("Mapping from {} to {}",
object != null ? object.getClass().getSimpleName() : "null",
cls.getSimpleName());
if (object == null) {
return null;
}
try {
T result = cls.newInstance();
Field[] fields = object.getClass().getDeclaredFields();
for (Field field : fields) {
int modifiers = field.getModifiers();
if (Modifier.isFinal(modifiers) || Modifier.isStatic(modifiers)) {
continue;
}
field.setAccessible(true);
Object value = field.get(object);
Field targetField;
try {
targetField = cls.getDeclaredField(field.getName());
} catch (NoSuchFieldException e) {
continue;
}
targetField.setAccessible(true);
targetField.set(result, value);
}
log.debug("Mapping result: {}", result);
return result;
} catch (Exception e) {
log.error("Mapping failed", e);
return null;
}
}
/**
* 实体转换(带枚举属性)
*
* @param object 实体
* @param cls 转换目标类型(带枚举属性)
* @return T
* @author Jshuai
* @date 2021/12/17 20:34
*/
public static <T> T mapWithEnumProp(Object object, Class<T> cls) throws IllegalAccessException {
Map<String, Object> propertyMap = map(object);
return map(propertyMap, cls, false);
}
/**
* 分页转换
*
* @param objectPage 分页数据
* @param cls 转换目标类型
*/
public static <T> Page<T> map(IPage<?> objectPage, Class<T> cls) {
Page<T> page = new Page<>();
List<T> list = objectPage.getRecords().stream().map(o -> map(o, cls)).collect(Collectors.toList());
page.setRecords(list);
page.setSize(objectPage.getSize());
page.setTotal(objectPage.getTotal());
return page;
}
/**
* 集合转换
*
* @param objects 实体
* @param cls 转换目标类型
*/
public static <T> List<T> map(List<?> objects, Class<T> cls) {
log.debug("Mapping list of size {} to {}", objects.size(), cls.getSimpleName());
List<T> result = objects.stream()
.peek(o -> log.debug("Source object: {}", o))
.map(o -> map(o, cls))
.peek(r -> log.debug("Mapped object: {}", r))
.collect(Collectors.toList());
log.debug("Mapped list result size: {}", result.size());
return result;
}
/**
* map转对象
*
* @param beanMap 属性map
* @param beanClazz 目标对象类
* @return T
* @author Jshuai
* @date 2021/9/24 15:55
*/
public static <T> T map(Map<String, Object> beanMap, Class<T> beanClazz) {
return map(beanMap, beanClazz, true);
}
/**
* map转对象
*
* @param beanMap 属性map
* @param beanClazz 目标对象类
* @param isUnderline 是否需要下划线命名
* @return T
* @author Jshuai
* @date 2021/9/24 15:55
*/
public static <T> T map(Map<String, Object> beanMap, Class<T> beanClazz, boolean isUnderline) {
if (CollectionUtil.isEmpty(beanMap)) {
return null;
}
T bean = null;
try {
Field[] fields = ReflectUtil.getFields(beanClazz);
bean = beanClazz.newInstance();
for (Field field : fields) {
int modifiers = field.getModifiers();
if (Modifier.isFinal(modifiers) || Modifier.isStatic(modifiers)) {
continue;
}
field.setAccessible(true);
if (isUnderline) {
ReflectUtil.setFieldValue(field, bean, beanMap.get(StringTools.fromCamelCase(field.getName())));
} else {
ReflectUtil.setFieldValue(field, bean, beanMap.get(StringTools.firstToLowerCase(field.getName())));
}
}
} catch (IllegalAccessException | InstantiationException e) {
LogTools.error(log, e, "map:%s转->对象:%s失败。", beanMap, beanClazz);
}
return bean;
}
/**
* map转对象
*
* @param beanMap 属性map
* @param beanClazz 目标对象类
* @param isUnderline 是否需要下划线命名
* @return T
* @author Jshuai
* @date 2021/9/24 15:55
*/
public static <T> T mapError(Map<String, Object> beanMap, Class<T> beanClazz, boolean isUnderline) throws InstantiationException, IllegalAccessException {
if (CollectionUtil.isEmpty(beanMap)) {
return null;
}
T bean = null;
Field[] fields = ReflectUtil.getFields(beanClazz);
bean = beanClazz.newInstance();
for (Field field : fields) {
int modifiers = field.getModifiers();
if (Modifier.isFinal(modifiers) || Modifier.isStatic(modifiers)) {
continue;
}
field.setAccessible(true);
if (isUnderline) {
ReflectUtil.setFieldValue(field, bean, beanMap.get(StringTools.fromCamelCase(field.getName())));
} else {
ReflectUtil.setFieldValue(field, bean, beanMap.get(StringTools.firstToLowerCase(field.getName())));
}
}
return bean;
}
/**
* 对象转属性map
*
* @param bean 带转换对象
* @param isAll 是否包含父类私有属性
* @return java.util.Map<java.lang.String, java.lang.Object>
* @author Jshuai
* @date 2021/9/24 16:00
*/
public static Map<String, Object> map(Object bean, boolean isAll) throws IllegalAccessException {
if (bean == null) {
return new HashMap<>(24);
}
Map<String, Object> map = new HashMap<>(24);
Field[] declaredFields = isAll ? ReflectUtil.getFields(bean.getClass())
: ReflectUtil.getAccessibleFields(bean.getClass());
for (Field field : declaredFields) {
field.setAccessible(true);
map.put(field.getName(), field.get(bean));
}
return map;
}
/**
* 对象转属性map(包含父类私有属性)
*
* @param bean 带转换对象
* @return java.util.Map<java.lang.String, java.lang.Object>
* @author Jshuai
* @date 2021/9/24 16:00
*/
public static Map<String, Object> map(Object bean) throws IllegalAccessException {
return map(bean, true);
}
/**
* object类型转换成数组
**/
public static <T> List<T> castList(Object obj, Class<T> clazz) {
List<T> result = new ArrayList<>();
if (obj instanceof List<?>) {
for (Object o : (List<?>) obj) {
result.add(clazz.cast(o));
}
return result;
}
return new ArrayList<>();
}
}
BeanCopierUtil
package com.js.core.toolkit.util;
import com.baomidou.mybatisplus.extension.activerecord.Model;
import com.js.core.toolkit.StringPool;
import lombok.extern.slf4j.Slf4j;
import org.springframework.cglib.beans.BeanCopier;
import org.springframework.cglib.core.Converter;
import java.lang.reflect.Field;
import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.time.LocalDateTime;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* <p>
* BeanCopier工具类
* </p>
*
* @author Jshuai
* @date 2025/6/4 9:41
* @since JDK 1.8
*/
@Slf4j
public class BeanCopierUtil {
/**
* 以String为Key存放BeanCopier对象HashMap集合
*/
private static Map<String, BeanCopier> beanCopierMap = new ConcurrentHashMap<>();
/**
* 对象属性复制
*
* @param source 资源对象
* @param target 目标对象类
*/
public static void copy(Object source, Object target) {
if (source == null || target == null) {
log.warn("Bean copy failed: source or target is null");
return;
}
log.debug("Copying from {} to {}", source.getClass().getSimpleName(), target.getClass().getSimpleName());
log.debug("Source object: {}", source);
String beanKey = generateKey(source.getClass(), target.getClass());
if (!beanCopierMap.containsKey(beanKey)) {
log.debug("Creating new BeanCopier for {}", beanKey);
BeanCopier beanCopier = BeanCopier.create(source.getClass(), target.getClass(), false);
beanCopierMap.put(beanKey, beanCopier);
}
beanCopierMap.get(beanKey).copy(source, target, new BeanConverter());
log.debug("Target object after copy: {}", target);
log.debug("Copy completed");
}
/**
* 对象属性复制
*
* @param source 资源对象
* @param targetCls 目标对象类
*/
@SuppressWarnings("rawtypes")
public static <T> T copy(Object source, Class<T> targetCls) {
if (source == null || targetCls == null) {
return null;
}
try {
T target = targetCls.newInstance();
copy(source, target);
return target;
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
/**
* 生成唯一key
*
* @param sourceCls 资源类
* @param targetCls 目标类
* @return 资源类路径名称 + "_" + 目标类路径名称
*/
private static String generateKey(Class<?> sourceCls, Class<?> targetCls) {
return sourceCls.getName() + "_" + targetCls.getName();
}
private static class BeanConverter implements Converter {
@Override
public Object convert(Object sourceValue, Class targetType, Object context) {
log.debug("Converting value: {} from {} to {}", sourceValue,
sourceValue != null ? sourceValue.getClass().getSimpleName() : "null",
targetType.getSimpleName());
if (sourceValue == null) {
return null;
}
// 处理基本类型和包装类型转换
if (targetType.isPrimitive() ||
Number.class.isAssignableFrom(targetType) ||
targetType == String.class ||
targetType == Boolean.class) {
return sourceValue;
}
// 处理日期时间类型转换
if (sourceValue instanceof LocalDateTime && targetType == LocalDateTime.class) {
return sourceValue;
}
// 处理Model类型转换
if (Model.class.isAssignableFrom(sourceValue.getClass()) && Model.class.isAssignableFrom(targetType)) {
return copy(sourceValue, targetType);
}
log.warn("Unsupported type conversion from {} to {}",
sourceValue.getClass().getName(), targetType.getName());
return null;
}
}
/**
* 用于源对象中部分字段是String,而目标对象是枚举类型时的copy
*
* @param source 源对象
* @param target 目标对象
* @throws NoSuchMethodException
* @throws InvocationTargetException
* @throws IllegalAccessException
*/
public static void differentTypeCopy(Object source, Object target) throws NoSuchMethodException, InvocationTargetException, IllegalAccessException {
Class sourceClass = source.getClass();
Class targetClass = target.getClass();
Field[] sourceFields = sourceClass.getDeclaredFields();
Field[] targetFields = targetClass.getDeclaredFields();
for (Field targetField : targetFields) {
String propertyName = targetField.getName();
boolean isNotExist = false;
for (Field sourceField : sourceFields) {
if (sourceField.getName().equals(targetField.getName())) {
isNotExist = true;
}
}
if (isNotExist) {
if (StringPool.ENUM_DATA.contains(propertyName)) {
String methodName = propertyName.substring(0, 1).toUpperCase() + propertyName.substring(1);
Method getMethod = sourceClass.getMethod(StringPool.GET + methodName);
Object value = getMethod.invoke(source);
if (null != value) {
Class type = (Class) targetField.getGenericType();
Method setMethod = targetClass.getMethod(StringPool.SET + methodName, type);
setMethod.invoke(target, Enum.valueOf(type, String.valueOf(value)));
}
}
}
}
}
}

二、优化改成符合多种类型转换
✅ 步骤二:封装一个通用字典转换工具类改进方案结构图:
返回数据结构:
R<T> -> T 可能是:
- 普通对象(VO)
- List<VO>
- Page<VO>
- 复杂对象(包含 List、Map、嵌套对象)
我们要做:
✅ 找到所有标了 @Dict 的字段
✅ 获取对应字段的 value 值
✅ 查找字典值
✅ 写入目标 text 字段(如 sexText)
✅ 步骤二:封装一个通用字典转换工具类步骤一:注解保留不变
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Dict {
String type(); // 字典类型
String targetField() default ""; // 转换结果输出字段,如 sexText
}
✅ 步骤二:封装一个查询字典工具类
Mapper.xml
<select id="getLabel" resultType="String">
SELECT dict_label
FROM sys_dict_data
WHERE dict_type = #{type} AND dict_value = #{value}
LIMIT 1
</select>
Mapper 接口
@Mapper
public interface DictMapper {
String getLabel(@Param("type") String type, @Param("value") String value);
}
ServiceImpl
@Service
public class DictServiceImpl implements DictService {
@Autowired
private DictMapper dictMapper;
@Override
public String getLabel(String dictType, String dictValue) {
return dictMapper.getLabel(dictType, dictValue);
}
}
✅ 步骤三:AOP 对返回数据进行增强处理
👉 环绕通知处理 Controller 的返回数据
@Aspect
@Component
public class DictAspect {
@Autowired
private DictService dictService;
@Around("execution(* com.hy..controller..*.*(..))")
public Object doAround(ProceedingJoinPoint point) throws Throwable {
Object result = point.proceed();
if (result instanceof R<?>) {
R<?> response = (R<?>) result;
Object data = response.getData();
handleDict(data);
}
return result;
}
private void handleDict(Object data) {
if (data == null) return;
if (data instanceof List<?>) {
for (Object item : (List<?>) data) {
processFields(item);
}
} else if (data instanceof IPage<?>) {
List<?> records = ((IPage<?>) data).getRecords();
for (Object item : records) {
processFields(item);
}
} else {
processFields(data);
}
}
private void processFields(Object obj) {
if (obj == null) return;
for (Field field : obj.getClass().getDeclaredFields()) {
Dict dict = field.getAnnotation(Dict.class);
if (dict != null) {
try {
field.setAccessible(true);
Object value = field.get(obj);
if (value != null) {
// 查询数据库获取中文标签
String label = dictService.getLabel(dict.type(), value.toString());
// 自动找目标字段(默认为 fieldName + "Text")
String targetFieldName = dict.targetField().isEmpty()
? field.getName() + "Text"
: dict.targetField();
try {
Field targetField = obj.getClass().getDeclaredField(targetFieldName);
targetField.setAccessible(true);
targetField.set(obj, label);
} catch (NoSuchFieldException ignored) {
// 如果没有目标字段,不抛异常,继续
}
}
} catch (IllegalAccessException ignored) {
}
}
}
}
}
✅ 步骤四:VO 示例
@Data
public class UserVO {
private Long id;
@Dict(type = "sex")
private String sex;
private String sexText;
@Dict(type = "status", targetField = "statusDesc")
private String status;
private String statusDesc;
}
✅ 示例 Controller
@GetMapping("/user")
public R<UserVO> getUser() {
UserVO user = new UserVO();
user.setId(1L);
user.setSex("1");
user.setStatus("1");
return R.ok(user);
}
@GetMapping("/users")
public R<List<UserVO>> getUsers() {
List<UserVO> users = new ArrayList<>();
// 添加多个用户
return R.ok(users);
}
@GetMapping("/users/page")
public R<IPage<UserVO>> getPagedUsers() {
IPage<UserVO> page = new Page<>();
// 设置分页记录
return R.ok(page);
}
✅ 示例返回 JSON:
json复制编辑{
"code": 0,
"msg": "success",
"data": {
"id": 1,
"sex": "1",
"sexText": "男",
"status": "1",
"statusDesc": "正常"
}
}
✅ 总结要点
| 模块 | 功能 |
|---|---|
@Dict 注解 | 标注字段需要字典转换 |
| AOP 切面 | 拦截 Controller 返回,动态补充字段值 |
| 工具类 | 提供字典类型到文本的映射逻辑 |
| 支持结构 | 普通 VO、List、IPage、R 嵌套结构 |
三、通用的递归遍历版本(最终版 v1)
✅它可以处理以下情况:
- 单个对象(如
UserVO) - 嵌套集合(如
List<StudentVO>、List<PostVO>) - 嵌套
IPage<T>(MyBatis-Plus 分页对象) - 嵌套
Map<String, Object> - 嵌套的任意自定义 VO(只要它们有
@Dict注解或者内部还包含可遍历的集合/对象)
✅核心思路是:
- 对传入的
data(可能是单个 VO、List、Page 等)做一次递归:- 若是基本类型(
String、包装类、枚举等)就跳过 - 若是
Collection(List、Set、数组)就对每个元素递归 - 若是
Map就对每个value递归 - 若是
IPage<?>就对它的getRecords()(一个List<VO>)递归 - 若是普通对象(POJO),先把自己身上所有带
@Dict注解的字段做一次转换(调用DictService查询数据库拿到中文),再对“所有字段的值”再递归一层(防止嵌套 VO 或集合)
- 若是基本类型(
下面给出完整的实现示例(假设你已经有了之前提到的 @Dict 注解、DictService.getLabel(type, value)、以及通用响应类 R<T>)。请把它放在 Spring Boot 项目的某个 @Component 包下,并确保你的 Controller 都返回 R<...>。
1. @Dict 注解
package com.hy.core.annotation;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 用于数据字典类型转换:
* - type: 字典类型(对应数据库中 sys_dict_data.dict_type)
* - targetField: 转换后要赋值的字段名(如果不填,默认就是 “原字段名 + Text”)
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Dict {
String type();
String targetField() default "";
}
2. 示例 VO(你的“复杂数据”场景)
package com.hy.core.domain;
import com.hy.core.annotation.Dict;
import lombok.Data;
import java.util.List;
@Data
public class UserVO {
private Long id;
@Dict(type = "sex", targetField = "sexDesc")
private String sex;
private String sexDesc;
@Dict(type = "status", targetField = "statusDesc")
private String status;
private String statusDesc;
private List<StudentVO> students;
private List<PostVO> posts;
}
@Data
public class StudentVO {
private Long id;
private String name;
private Integer age;
@Dict(type = "stuType", targetField = "stuTypeDesc")
private String stuType;
private String stuTypeDesc;
}
@Data
public class PostVO {
private String userNickname;
private String userPhone;
private String userEmail;
private Integer userGender;
@Dict(type = "post_type", targetField = "postTypeDesc")
private String postType;
private String postTypeDesc;
}
注意:
所有需要字典转换的字段都要加上
@Dict(type="...", targetField="..."),并在类里预留一个对应的targetField(例如sexDesc、statusDesc、stuTypeDesc、postTypeDesc)来接收转换之后的中文。
3. DictService 接口及实现(从数据库查询字典值)
package com.hy.core.service;
public interface DictService {
/**
* 根据 dict_type 和 dict_value 查库返回 dict_label
* @param dictType 比如 "sex"、"status"、"post_type" 等
* @param dictValue 比如 "1"、"0" 等
* @return 对应的中文标签,如果查不到就返回 null 或者原值
*/
String getLabel(String dictType, String dictValue);
}
package com.hy.core.service.impl;
import com.hy.core.mapper.DictMapper;
import com.hy.core.service.DictService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
@Service
public class DictServiceImpl implements DictService {
@Autowired
private DictMapper dictMapper;
@Override
public String getLabel(String dictType, String dictValue) {
// 从数据库 sys_dict_data 表里查 dict_label
String label = dictMapper.getLabel(dictType, dictValue);
return label != null ? label : dictValue;
}
}
package com.hy.core.mapper;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
/**
* 用 MyBatis Mapper 直接写简单 SQL
*/
@Mapper
public interface DictMapper {
@Select("SELECT dict_label FROM sys_dict_data WHERE dict_type = #{type} AND dict_value = #{value} LIMIT 1")
String getLabel(@Param("type") String type, @Param("value") String value);
}
4. 核心:通用的 DictAspect(AOP 切面)
package com.hy.core.aspect;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hy.core.annotation.Dict;
import com.hy.core.domain.R;
import com.hy.core.service.DictService;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;
@Aspect
@Component
public class DictAspect {
@Autowired
private DictService dictService;
/**
* 拦截所有 controller 层返回 R<...> 的方法
*/
@Around("execution(* com.hy..controller..*.*(..))")
public Object aroundController(ProceedingJoinPoint pjp) throws Throwable {
// 先执行业务方法,得到返回值
Object result = pjp.proceed();
if (result instanceof R<?>) {
R<?> r = (R<?>) result;
Object data = r.getData();
if (data != null) {
// 从 data 开始做递归处理
processValue(data);
}
}
return result;
}
/**
* 入口:根据值的类型做不同处理
*/
private void processValue(Object value) {
if (value == null) {
return;
}
// 1. 如果是集合(List、Set,都实现了 Collection)
if (value instanceof Collection<?>) {
for (Object item : (Collection<?>) value) {
processValue(item);
}
return;
}
// 2. 如果是数组
if (value.getClass().isArray()) {
int len = Array.getLength(value);
for (int i = 0; i < len; i++) {
Object element = Array.get(value, i);
processValue(element);
}
return;
}
// 3. 如果是 MyBatis-Plus 的分页对象 IPage<?>
if (value instanceof IPage<?>) {
IPage<?> page = (IPage<?>) value;
if (page.getRecords() != null) {
for (Object item : page.getRecords()) {
processValue(item);
}
}
return;
}
// 4. 如果是 Map<String, ?>,就对 value 集合做递归
if (value instanceof Map<?, ?>) {
for (Object obj : ((Map<?, ?>) value).values()) {
processValue(obj);
}
return;
}
// 5. 如果是“基本类型”或者 String、枚举、包装类,直接跳过
if (isPrimitiveOrWrapper(value.getClass()) || value instanceof String || value.getClass().isEnum()) {
return;
}
// 6. 剩下的就是普通 VO/POJO 对象,去处理它的字段
processObjectFields(value);
}
/**
* 处理对象的所有字段:
* (1) 先把带 @Dict 注解的字段转换(调用 dictService 查库填充对应的 targetField)
* (2) 再对每个字段的值继续递归 processValue(...)(以防嵌套更多 VO 或集合)
*/
private void processObjectFields(Object obj) {
if (obj == null) return;
Class<?> clazz = obj.getClass();
Field[] fields = clazz.getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
// —— Step1: 如果字段上标注了 @Dict,就先做字典转换 ——
Dict dictAnno = field.getAnnotation(Dict.class);
if (dictAnno != null) {
try {
Object rawVal = field.get(obj);
if (rawVal != null) {
String type = dictAnno.type();
String dictVal = rawVal.toString();
// 从数据库查询中文标签
String label = dictService.getLabel(type, dictVal);
// 目标字段名:如果 targetField 不为空则用它,否则默认 “原字段名 + Text”
String targetFieldName = dictAnno.targetField().isEmpty()
? field.getName() + "Text"
: dictAnno.targetField();
// 给目标字段赋值(如果找得到)
try {
Field targetField = clazz.getDeclaredField(targetFieldName);
targetField.setAccessible(true);
targetField.set(obj, label);
} catch (NoSuchFieldException ignored) {
// 没有找到 targetField,就跳过
}
}
} catch (IllegalAccessException ignored) {
}
}
// —— Step2: 对该字段的值继续递归 ——
try {
Object fieldValue = field.get(obj);
if (fieldValue != null) {
processValue(fieldValue);
}
} catch (IllegalAccessException ignored) {
}
}
}
/**
* 判断是否是 Java 基本类型或者它的包装类
*/
private boolean isPrimitiveOrWrapper(Class<?> clz) {
return clz.isPrimitive()
|| clz == Boolean.class
|| clz == Byte.class
|| clz == Character.class
|| clz == Short.class
|| clz == Integer.class
|| clz == Long.class
|| clz == Float.class
|| clz == Double.class
|| clz == Void.class;
}
}
✅讲解
- 切面拦截
@Around("execution(* com.hy..controller..*.*(..))"):只要你 Controller 中返回R<T>,就会进到aroundController()。- 先执行
proceed()拿到原始返回结果,接着取出R<?>的data字段,开始做递归处理。
processValue(Object value)- 这是一个万能递归入口:
- 如果
value是Collection(例如List<UserVO>、Set<XXX>),就对每个元素再调用processValue; - 如果是 数组(
Object[]、UserVO[]等),用Array.get(...)一一展开递归; - 如果是
IPage<?>(MyBatis-Plus 分页对象),拿到IPage#getRecords()(返回的是List<VO>)再递归; - 如果是
Map<?,?>,遍历valueSet(),对每个 map 值递归; - 如果是 “Java 原始类型” 或者
String、枚举、包装类型,就不处理(因为不会有@Dict); - 剩下就是“自定义 VO/POJO”了,调用
processObjectFields(obj)。
- 如果
- 这是一个万能递归入口:
processObjectFields(Object obj)- 遍历对象
obj的 所有字段(使用反射clazz.getDeclaredFields())。 - Step1: 如果字段上有
@Dict注解,就取出它的 “type” 和原始值(field.get(obj)),再调用dictService.getLabel(type, rawVal)一次数据库查询,拿到中文标签。- 然后算出目标字段名:如果
targetField不为空,就用它;否则默认原字段名 + "Text"。 - 找到这个 “目标字段” 并赋值(
targetField.set(obj, label))。
- 然后算出目标字段名:如果
- Step2: 拿到字段的原始值
fieldValue = field.get(obj),如果不为空,继续走processValue(fieldValue)—— 这样可以向下深入:比如UserVO里有一个List<StudentVO>,就会继续进入那个集合,对每个StudentVO再做同样的@Dict检测。
- 遍历对象
- 递归终止条件
- 如果
value是基本类型、String、枚举,就不会再深入。 - 如果是
Collection/数组/IPage/Map,最终会拆到它们的元素或值,继续查 Unicode。如果遇到自定义 VO,就在processObjectFields中一次性把自己身上的注解给干了,然后再继续往字段里钻。
- 如果
5. 最终在 Controller 中的使用示例
package com.hy.controller;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hy.core.domain.R;
import com.hy.core.domain.UserVO;
import com.hy.core.domain.PostVO;
import com.hy.core.domain.StudentVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Arrays;
import java.util.List;
@RestController
public class UserController {
@GetMapping("/user")
public R<UserVO> getUser() {
UserVO user = new UserVO();
user.setId(1L);
user.setSex("1"); // 对应字典表中:dict_type="sex", dict_value="1" → dict_label="男"
user.setStatus("0"); // dict_type="status", dict_value="0" → dict_label="停用"
// 还可以给 students 和 posts 准备测试数据
StudentVO stu = new StudentVO();
stu.setId(100L);
stu.setName("小明");
stu.setAge(18);
stu.setStuType("A"); // 假设 dict_type="stuType", dict_value="A" → dict_label="一类学生"
PostVO post = new PostVO();
post.setUserNickname("张三");
post.setUserPhone("13800000000");
post.setUserEmail("zhangsan@example.com");
post.setUserGender(1);
post.setPostType("X"); // 假设 dict_type="post_type", dict_value="X" → dict_label="图文帖"
user.setStudents(List.of(stu));
user.setPosts(List.of(post));
return R.ok(user);
}
@GetMapping("/users")
public R<List<UserVO>> getUsers() {
UserVO u1 = new UserVO();
u1.setId(1L);
u1.setSex("2"); // dict_type="sex", dict_value="2" → dict_label="女"
u1.setStatus("1"); // dict_type="status", dict_value="1" → dict_label="正常"
u1.setStudents(List.<StudentVO>of());
u1.setPosts(List.<PostVO>of());
UserVO u2 = new UserVO();
u2.setId(2L);
u2.setSex("1");
u2.setStatus("1");
u2.setStudents(List.<StudentVO>of());
u2.setPosts(List.<PostVO>of());
return R.ok(Arrays.asList(u1, u2));
}
@GetMapping("/users/page")
public R<IPage<UserVO>> getPagedUsers() {
// 构造 MyBatis-Plus 的 Page 对象
IPage<UserVO> page = new Page<>(1, 10);
UserVO u = new UserVO();
u.setId(3L);
u.setSex("2");
u.setStatus("0");
u.setStudents(List.<StudentVO>of());
u.setPosts(List.<PostVO>of());
page.setRecords(List.of(u));
page.setTotal(1);
return R.ok(page);
}
}
请求 GET /user,返回示例(假设数据库中已有对应字典数据):
{
"code": 0,
"msg": "success",
"data": {
"id": 1,
"sex": "1",
"sexDesc": "男",
"status": "0",
"statusDesc": "停用",
"students": [
{
"id": 100,
"name": "小明",
"age": 18,
"stuType": "A",
"stuTypeDesc": "一类学生"
}
],
"posts": [
{
"userNickname": "张三",
"userPhone": "13800000000",
"userEmail": "zhangsan@example.com",
"userGender": 1,
"postType": "X",
"postTypeDesc": "图文帖"
}
]
}
}
sexDesc、statusDesc、stuTypeDesc、postTypeDesc都由 AOP 自动调用DictService从数据库查询并赋值。- 嵌套在
students列表里的StudentVO、posts列表里的PostVO同样自动被递归处理。 - 如果你改成返回
R<List<UserVO>>或R<IPage<UserVO>>,同样适配,因为最外层processValue(...)会识别并把列表/分页拆开,进而递归。
6. ✅ 总结
@Around切面:只要你的 Controller 方法返回类型是R<T>,就会被切面拦截。processValue(...):万能递归分发,可自动识别到List<?>、数组、IPage<?>、Map<?,?>,以及普通 VO。processObjectFields(...):先把当前对象上所有的@Dict注解字段取值并查库赋予对应的“翻译”到targetField,然后再继续对“对象内部的其他字段”做递归。- 数据库查询:由
DictService.getLabel(type, value)实现,尽量在sys_dict_data中做SELECT dict_label。 - 无需手动遍历每一层嵌套,只要 VO/嵌套 VO 里再出现带
@Dict注解的字段,都会自动生效。
这样就完成了一个“通用、可扩展、支持任意深度嵌套集合/分页/Map/对象”的数据字典 AOP 转换方案。 只要在每个需要字典转换的字段上加上注解,就能自动从数据库查询并把“中文标签”写到指定的 targetField。
四、只拦截特定 Controller 类(最终版 v2)
要实现通用的数据字典转换,并且只拦截特定 Controller 类(如使用
@DictConvertTarget注解标记的),又能自动解析嵌套的List<>、Page<>、复杂嵌套结构等,以下是一个 完整方案:
✅ 1. 自定义注解标记 Controller
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DictConvertTarget {
}
✅ 2. 自定义字段注解 @Dict
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Dict {
String type(); // 字典类型
String targetField() default ""; // 字典文本字段
}
✅ 3. 在 Controller 上使用标记注解
@DictConvertTarget
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/detail")
public R<UserVO> detail() {
// 模拟数据
return R.ok(userVO);
}
}
✅ 4. 字典查询接口(模拟数据库)
public interface DictService {
String getLabel(String type, String value);
}
实现类可以连接数据库或缓存:
@Service
public class DictServiceImpl implements DictService {
@Override
public String getLabel(String type, String value) {
// 伪代码 - 实际应从数据库中查
if ("sex".equals(type)) {
return "1".equals(value) ? "男" : "女";
}
if ("status".equals(type)) {
return "0".equals(value) ? "启用" : "禁用";
}
// ...其他类型
return null;
}
}
✅ 5. 通用字典转换工具类(支持递归、List、Page、Map 等)
public class DictValueConverter {
private static DictService dictService;
public static void setDictService(DictService service) {
dictService = service;
}
public static void convert(Object obj) {
if (obj == null) return;
if (obj instanceof Collection) {
((Collection<?>) obj).forEach(DictValueConverter::convert);
} else if (obj instanceof Page) {
((Page<?>) obj).getRecords().forEach(DictValueConverter::convert);
} else if (obj instanceof Map) {
((Map<?, ?>) obj).values().forEach(DictValueConverter::convert);
} else {
processSingleObject(obj);
}
}
private static void processSingleObject(Object obj) {
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
try {
// 处理 @Dict 字段
if (field.isAnnotationPresent(Dict.class)) {
Dict dict = field.getAnnotation(Dict.class);
String value = String.valueOf(field.get(obj));
String label = dictService.getLabel(dict.type(), value);
if (StringUtils.isNotBlank(dict.targetField())) {
Field targetField = obj.getClass().getDeclaredField(dict.targetField());
targetField.setAccessible(true);
targetField.set(obj, label);
}
}
// 递归嵌套 List / VO 类型字段
Object fieldValue = field.get(obj);
if (fieldValue != null) {
if (fieldValue instanceof Collection || fieldValue instanceof Page || fieldValue instanceof Map) {
convert(fieldValue);
} else if (field.getType().getPackageName().startsWith("com.hy")) {
convert(fieldValue);
}
}
} catch (Exception e) {
// 忽略异常
}
}
}
}
⚠️
com.hy是你自己的业务包路径,用于排除系统类。
✅ 6. AOP 拦截特定 Controller 并自动转换
@Aspect
@Component
public class DictAspect {
@Autowired
public void setDictService(DictService dictService) {
DictValueConverter.setDictService(dictService);
}
@Around("@within(com.hy.annotation.DictConvertTarget)")
public Object handleDictConversion(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (result instanceof R<?>) {
Object data = ((R<?>) result).getData();
DictValueConverter.convert(data);
}
return result;
}
}
✅ 7. 测试返回结构
支持以下结构自动转换:
R<UserVO>R<List<UserVO>>R<Page<UserVO>>- 嵌套 List、VO、Page、Map 都支持。
✅ 效果示例
🔍原始:
{
"data": {
"sex": "1",
"sexDesc": null,
"status": "0",
"statusDesc": null,
"students": [
{
"stuType": "1",
"stuTypeDesc": null
}
],
"posts": [
{
"postType": "news",
"postTypeDesc": null
}
]
}
}
自动转换后输出:
{
"data": {
"sex": "1",
"sexDesc": "男",
"status": "0",
"statusDesc": "启用",
"students": [
{
"stuType": "1",
"stuTypeDesc": "全日制"
}
],
"posts": [
{
"postType": "news",
"postTypeDesc": "新闻类"
}
]
}
}
✅ 完整代码
src
├── main
│ ├── java
│ │ └── com
│ │ └── hy
│ │ ├── DictConversionDemoApplication.java
│ │ ├── annotation
│ │ │ ├── Dict.java
│ │ │ └── DictConvertTarget.java
│ │ ├── aop
│ │ │ └── DictAspect.java
│ │ ├── controller
│ │ │ └── UserController.java
│ │ ├── domain
│ │ │ ├── R.java
│ │ │ ├── UserVO.java
│ │ │ ├── PostVO.java
│ │ │ └── StudentVO.java
│ │ ├── service
│ │ │ ├── DictService.java
│ │ │ └── impl
│ │ │ └── DictServiceImpl.java
│ │ └── util
│ │ └── DictValueConverter.java
│ └── resources
│ └── application.yml
# DictConversionDemoApplication.java
package com.hy;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DictConversionDemoApplication {
public static void main(String[] args) {
SpringApplication.run(DictConversionDemoApplication.class, args);
}
}
# annotation/Dict.java
package com.hy.annotation;
import java.lang.annotation.*;
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Dict {
String type();
String targetField() default "";
}
# annotation/DictConvertTarget.java
package com.hy.annotation;
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface DictConvertTarget {
}
# aop/DictAspect.java
package com.hy.aop;
import com.hy.annotation.DictConvertTarget;
import com.hy.domain.R;
import com.hy.service.DictService;
import com.hy.util.DictValueConverter;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Aspect
@Component
public class DictAspect {
@Autowired
public void setDictService(DictService dictService) {
DictValueConverter.setDictService(dictService);
}
@Around("@within(com.hy.annotation.DictConvertTarget)")
public Object handleDictConversion(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
if (result instanceof R<?>) {
Object data = ((R<?>) result).getData();
DictValueConverter.convert(data);
}
return result;
}
}
# controller/UserController.java
package com.hy.controller;
import com.hy.annotation.DictConvertTarget;
import com.hy.domain.*;
import com.hy.domain.R;
import org.springframework.web.bind.annotation.*;
import java.util.Arrays;
@DictConvertTarget
@RestController
@RequestMapping("/user")
public class UserController {
@GetMapping("/detail")
public R<UserVO> detail() {
UserVO user = new UserVO();
user.setId(1L);
user.setSex("1");
user.setStatus("0");
user.setStudents(Arrays.asList(new StudentVO(1L, "小明", 18, "1")));
user.setPosts(Arrays.asList(new PostVO("张三", "123456", "zs@example.com", 1, "news")));
return R.ok(user);
}
}
# domain/R.java
package com.hy.domain;
import lombok.Data;
@Data
public class R<T> {
private int code;
private String msg;
private T data;
public static <T> R<T> ok(T data) {
R<T> r = new R<>();
r.setCode(0);
r.setMsg("success");
r.setData(data);
return r;
}
}
# domain/UserVO.java
package com.hy.domain;
import com.hy.annotation.Dict;
import lombok.Data;
import java.util.List;
@Data
public class UserVO {
private Long id;
@Dict(type = "sex", targetField = "sexDesc")
private String sex;
private String sexDesc;
@Dict(type = "status", targetField = "statusDesc")
private String status;
private String statusDesc;
private List<StudentVO> students;
private List<PostVO> posts;
}
# domain/PostVO.java
package com.hy.domain;
import com.hy.annotation.Dict;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PostVO {
private String userNickname;
private String userPhone;
private String userEmail;
private Integer userGender;
@Dict(type = "post_type", targetField = "postTypeDesc")
private String postType;
private String postTypeDesc;
}
# domain/StudentVO.java
package com.hy.domain;
import com.hy.annotation.Dict;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
@Data
@NoArgsConstructor
@AllArgsConstructor
public class StudentVO {
private Long id;
private String name;
private Integer age;
@Dict(type = "stuType", targetField = "stuTypeDesc")
private String stuType;
private String stuTypeDesc;
}
# service/DictService.java
package com.hy.service;
public interface DictService {
String getLabel(String type, String value);
}
# service/impl/DictServiceImpl.java
package com.hy.service.impl;
import com.hy.service.DictService;
import org.springframework.stereotype.Service;
@Service
public class DictServiceImpl implements DictService {
@Override
public String getLabel(String type, String value) {
return switch (type) {
case "sex" -> "1".equals(value) ? "男" : "女";
case "status" -> "0".equals(value) ? "启用" : "禁用";
case "stuType" -> "1".equals(value) ? "全日制" : "非全日制";
case "post_type" -> "news".equals(value) ? "新闻类" : "其他";
default -> null;
};
}
}
# util/DictValueConverter.java
package com.hy.util;
import com.hy.annotation.Dict;
import com.hy.service.DictService;
import org.springframework.util.StringUtils;
import java.lang.reflect.Field;
import java.util.*;
public class DictValueConverter {
private static DictService dictService;
public static void setDictService(DictService service) {
dictService = service;
}
public static void convert(Object obj) {
if (obj == null) return;
if (obj instanceof Collection<?>) {
((Collection<?>) obj).forEach(DictValueConverter::convert);
} else if (obj instanceof Map<?, ?>) {
((Map<?, ?>) obj).values().forEach(DictValueConverter::convert);
} else {
processSingleObject(obj);
}
}
private static void processSingleObject(Object obj) {
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
try {
if (field.isAnnotationPresent(Dict.class)) {
Dict dict = field.getAnnotation(Dict.class);
Object value = field.get(obj);
if (value != null && StringUtils.hasText(dict.targetField())) {
String label = dictService.getLabel(dict.type(), value.toString());
Field target = obj.getClass().getDeclaredField(dict.targetField());
target.setAccessible(true);
target.set(obj, label);
}
}
Object val = field.get(obj);
if (val != null && (val instanceof Collection || val.getClass().getPackageName().startsWith("com.hy"))) {
convert(val);
}
} catch (Exception ignored) {
}
}
}
}
# resources/application.yml
server
port: 8080
五、只拦截特定 Controller 类中指定的方法(最终版 v3)
✅ 该方法适应以下场景:
- 单个对象,如
R<UserVO> - 嵌套集合,如
List<StudentVO>、List<PostVO> - 嵌套 MyBatis-Plus 分页对象
IPage<UserVO> - 嵌套
Map<String, Object> - 任意深度的自定义 VO 嵌套
并且所有字典数据都 不预加载到内存,而是通过 JdbcTemplate 从 H2 内存数据库的 sys_dict_data 表按需查询。
提示:以下代码片段请按路径逐一创建,并保持目录结构不变。创建完后,直接用 IDE 或命令行启动 Spring Boot 即可。
1. pom.xml
<?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>
<groupId>com.hy.dictconvert</groupId>
<artifactId>dict-convert-demo</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>字典转换示例</name>
<description>Spring Boot 示例:AOP+注解按需从数据库查询字典并处理嵌套结构</description>
<properties>
<java.version>11</java.version>
<spring.boot.version>2.7.5</spring.boot.version>
<mybatis.plus.version>3.5.1</mybatis.plus.version>
</properties>
<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<!-- Spring AOP -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<!-- Spring JDBC & H2 Database -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
<version>${spring.boot.version}</version>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- MyBatis-Plus (仅用于 IPage、Page 对象演示) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-boot-starter</artifactId>
<version>${mybatis.plus.version}</version>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.24</version>
<scope>provided</scope>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>${spring.boot.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<!-- Spring Boot Maven Plugin -->
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>${spring.boot.version}</version>
</plugin>
</plugins>
</build>
</project>
2. src/main/resources/application.properties
# H2 内存数据库配置
spring.datasource.url=jdbc:h2:mem:dictdb;DB_CLOSE_DELAY=-1;MODE=MYSQL
spring.datasource.username=sa
spring.datasource.password=
spring.datasource.driver-class-name=org.h2.Driver
# 显示 H2 控制台(可选)
spring.h2.console.enabled=true
spring.h2.console.path=/h2-console
# MyBatis-Plus 应用包扫描(仅为了 Page 对象可用,无实际 Mapper)
mybatis-plus.configuration.map-underscore-to-camel-case=true
# 日志级别(可选)
logging.level.org.springframework.jdbc.core.JdbcTemplate=DEBUG
3. src/main/resources/schema.sql
-- 创建 sys_dict_data 表
CREATE TABLE sys_dict_data (
id BIGINT AUTO_INCREMENT PRIMARY KEY,
dict_type VARCHAR(64) NOT NULL,
dict_value VARCHAR(64) NOT NULL,
dict_label VARCHAR(128) NOT NULL
);
4. src/main/resources/data.sql
-- 插入示例字典数据
INSERT INTO sys_dict_data(dict_type, dict_value, dict_label) VALUES
('sex', '1', '男'),
('sex', '2', '女'),
('status', '0', '停用'),
('status', '1', '正常'),
('stuType', 'A', '一类学生'),
('stuType', 'B', '二类学生'),
('post_type', 'news', '新闻帖'),
('post_type', 'article', '文章帖');
H2 会在应用启动后自动执行
schema.sql和data.sql,将sys_dict_data表建立并插入示例数据。
✅5. 目录结构
src
└─ main
├─ java
│ └─ com
│ └─ hy
│ └─ dictconvert
│ ├─ DemoApplication.java
│ ├─ annotation
│ │ ├─ Dict.java
│ │ └─ EnableDictConvert.java
│ ├─ aspect
│ │ └─ DictAspect.java
│ ├─ controller
│ │ └─ UserController.java
│ ├─ domain
│ │ ├─ R.java
│ │ ├─ UserVO.java
│ │ ├─ StudentVO.java
│ │ └─ PostVO.java
│ ├─ service
│ │ ├─ DictService.java
│ │ └─ DictServiceImpl.java
│ └─ util
│ └─ DictValueConverter.java
└─ resources
├─ application.properties
├─ data.sql
└─ schema.sql
5.1 DemoApplication.java
package com.hy.dictconvert;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
public class DemoApplication {
public static void main(String[] args) {
SpringApplication.run(DemoApplication.class, args);
}
}
5.2 注解:Dict.java
package com.hy.dictconvert.annotation;
import java.lang.annotation.*;
/**
* 字典转换注解,标记在需要转换的字段上
* type: 对应 sys_dict_data 表里的 dict_type
* targetField: 转换后要写入的字段名。如果为空,则默认 “原字段名 + Text”
*/
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
public @interface Dict {
String type();
String targetField() default "";
}
5.3 注解:EnableDictConvert.java
package com.hy.dictconvert.annotation;
import java.lang.annotation.*;
/**
* 方法级注解:标记在 Controller 的接口方法上,表示该接口方法返回值需要进行字典转换
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface EnableDictConvert {
}
5.4 切面:DictAspect.java
package com.hy.dictconvert.aspect;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hy.dictconvert.annotation.EnableDictConvert;
import com.hy.dictconvert.domain.R;
import com.hy.dictconvert.service.DictService;
import com.hy.dictconvert.util.DictValueConverter;
import lombok.RequiredArgsConstructor;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.springframework.stereotype.Component;
/**
* 切面:只拦截标注了 @EnableDictConvert 的方法
*/
@Aspect
@Component
@RequiredArgsConstructor
public class DictAspect {
private final DictService dictService;
@Around("@annotation(enableDictConvert)")
public Object aroundEnableDict(ProceedingJoinPoint pjp, EnableDictConvert enableDictConvert) throws Throwable {
// 先执行业务逻辑
Object result = pjp.proceed();
// 只有当返回值是 R<?> 时才进一步处理
if (result instanceof R<?>) {
R<?> r = (R<?>) result;
Object data = r.getData();
if (data != null) {
// 设置 DictService 到工具类
DictValueConverter.setDictService(dictService);
// 递归转换 data 中所有标了 @Dict 的字段
DictValueConverter.convert(data);
}
}
return result;
}
}
5.5 工具类:DictValueConverter.java
package com.hy.dictconvert.util;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hy.dictconvert.annotation.Dict;
import com.hy.dictconvert.service.DictService;
import org.springframework.util.ClassUtils;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;
/**
* 通用字典值转换工具类:
* - 支持递归:Collection、数组、IPage、Map、普通对象
* - 对所有带 @Dict 注解的字段,从数据库按需查询 label 写入指定字段
*/
public class DictValueConverter {
private static DictService dictService;
public static void setDictService(DictService service) {
dictService = service;
}
/**
* 入口:递归处理传入的 value
*/
@SuppressWarnings("unchecked")
public static void convert(Object value) {
if (value == null) {
return;
}
// 1. 如果是 Collection(List、Set)
if (value instanceof Collection<?>) {
for (Object item : (Collection<?>) value) {
convert(item);
}
return;
}
// 2. 如果是 数组
if (value.getClass().isArray()) {
int len = Array.getLength(value);
for (int i = 0; i < len; i++) {
convert(Array.get(value, i));
}
return;
}
// 3. 如果是 MyBatis-Plus 分页对象 IPage<?>
if (value instanceof IPage<?>) {
IPage<?> page = (IPage<?>) value;
if (page.getRecords() != null) {
for (Object item : page.getRecords()) {
convert(item);
}
}
return;
}
// 4. 如果是 Map<?, ?>
if (value instanceof Map<?, ?>) {
for (Object mapVal : ((Map<?, ?>) value).values()) {
convert(mapVal);
}
return;
}
// 5. 如果是“Java 基本类型” 或者 String、枚举,则跳过
Class<?> clazz = value.getClass();
if (ClassUtils.isPrimitiveOrWrapper(clazz) || clazz == String.class || clazz.isEnum()) {
return;
}
// 6. 普通 POJO 对象,处理它的字段
processFields(value);
}
/**
* 遍历对象的所有字段,先对带 @Dict 的字段进行转换,再对所有字段的值递归
*/
private static void processFields(Object obj) {
if (obj == null) return;
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
// 6.1 如果当前字段有 @Dict 注解,先从数据库查询 label,写入目标字段
Dict dictAnno = field.getAnnotation(Dict.class);
if (dictAnno != null) {
try {
Object rawVal = field.get(obj);
if (rawVal != null) {
String dictType = dictAnno.type();
String dictValue = rawVal.toString();
// 按需查询数据库,获取 label
String label = dictService.getLabel(dictType, dictValue);
// 目标字段名:如果 annotation 中指定了 targetField,则用它;否则用“字段名+Text”
String targetFieldName = dictAnno.targetField().isEmpty()
? field.getName() + "Text"
: dictAnno.targetField();
try {
Field targetField = obj.getClass().getDeclaredField(targetFieldName);
targetField.setAccessible(true);
targetField.set(obj, label);
} catch (NoSuchFieldException ignored) {
// 如果找不到 targetField,就跳过
}
}
} catch (Exception ignored) {
}
}
// 6.2 对该字段的值做递归(以处理嵌套结构)
try {
Object fieldVal = field.get(obj);
if (fieldVal != null) {
convert(fieldVal);
}
} catch (Exception ignored) {
}
}
}
}
上面递归过深容易堆栈溢出,改一下
package com.hy.archive.util;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.hy.archive.annotation.Dict;
import com.hy.archive.service.SysDictDataService;
import org.springframework.util.ClassUtils;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.util.Collection;
import java.util.Map;
import java.util.IdentityHashMap;
/**
* 通用字典值转换工具类:
* - 支持递归:Collection、数组、IPage、Map、普通对象
* - 对所有带 @Dict 注解的字段,从数据库按需查询 label 写入指定字段
*/
public class DictValueConverter {
private static SysDictDataService dictService;
public static void setDictService(SysDictDataService service) {
dictService = service;
}
private static final int MAX_RECURSION_DEPTH = 20;
private static final ThreadLocal<IdentityHashMap<Object, Boolean>> processedObjects =
ThreadLocal.withInitial(IdentityHashMap::new);
/**
* 入口:递归处理传入的 value
*/
@SuppressWarnings("unchecked")
public static void convert(Object value) {
convert(value, 0);
}
private static void convert(Object value, int depth) {
if (value == null || depth > MAX_RECURSION_DEPTH) {
return;
}
// 检查是否已处理过该对象
if (processedObjects.get().putIfAbsent(value, Boolean.TRUE) != null) {
return;
}
// 1. 如果是 Collection(List、Set)
if (value instanceof Collection<?>) {
for (Object item : (Collection<?>) value) {
convert(item);
}
return;
}
// 2. 如果是 数组
if (value.getClass().isArray()) {
int len = Array.getLength(value);
for (int i = 0; i < len; i++) {
convert(Array.get(value, i));
}
return;
}
// 3. 如果是 MyBatis-Plus 分页对象 IPage<?>
if (value instanceof IPage<?>) {
IPage<?> page = (IPage<?>) value;
if (page.getRecords() != null) {
for (Object item : page.getRecords()) {
convert(item);
}
}
return;
}
// 4. 如果是 Map<?, ?>
if (value instanceof Map<?, ?>) {
for (Object mapVal : ((Map<?, ?>) value).values()) {
convert(mapVal);
}
return;
}
// 5. 如果是“Java 基本类型” 或者 String、枚举,则跳过
Class<?> clazz = value.getClass();
if (ClassUtils.isPrimitiveOrWrapper(clazz) || clazz == String.class || clazz.isEnum()) {
return;
}
// 6. 普通 POJO 对象,处理它的字段
processFields(value, depth);
}
/**
* 遍历对象的所有字段,处理带 @Dict 注解的字段并对字段值递归处理
*/
private static void processFields(Object obj, int depth) {
if (obj == null) return;
Field[] fields = obj.getClass().getDeclaredFields();
for (Field field : fields) {
field.setAccessible(true);
// 处理带有@Dict注解的字段
Dict dictAnno = field.getAnnotation(Dict.class);
if (dictAnno != null) {
try {
Object rawVal = field.get(obj);
if (rawVal != null) {
String dictType = dictAnno.type();
String dictValue = rawVal.toString();
// 按需查询数据库,获取 label
String label = dictService.getLabel(dictType, dictValue);
// 目标字段名:如果 annotation 中指定了 targetField,则用它;否则用"字段名+Text"
String targetFieldName = dictAnno.targetField().isEmpty()
? field.getName() + "Text"
: dictAnno.targetField();
try {
Field targetField = obj.getClass().getDeclaredField(targetFieldName);
targetField.setAccessible(true);
targetField.set(obj, label);
} catch (NoSuchFieldException ignored) {
// 如果找不到 targetField,就跳过
}
}
} catch (Exception ignored) {
}
}
// 对该字段的值做递归处理(以处理嵌套结构)
try {
Object fieldVal = field.get(obj);
if (fieldVal != null) {
convert(fieldVal, depth + 1);
}
} catch (Exception ignored) {
}
}
// 清理当前对象的处理记录,避免内存泄漏
if (depth == 0) {
processedObjects.get().clear();
}
}
}
5.6 服务层接口:DictService.java
package com.hy.dictconvert.service;
/**
* 字典服务:按需从数据库查询 dict_type + dict_value 对应的 dict_label
*/
public interface DictService {
/**
* @param dictType 字典类型(sys_dict_data.dict_type)
* @param dictValue 字典值(sys_dict_data.dict_value)
* @return 对应的 dict_label;如果查不到,返回 null
*/
String getLabel(String dictType, String dictValue);
}
5.7 服务层实现:DictServiceImpl.java
package com.hy.dictconvert.service;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
/**
* 使用 JdbcTemplate 从 H2 数据库的 sys_dict_data 表按需查询字典标签
*/
@Service
public class DictServiceImpl implements DictService {
@Autowired
private JdbcTemplate jdbcTemplate;
@Override
public String getLabel(String dictType, String dictValue) {
String sql = "SELECT dict_label FROM sys_dict_data WHERE dict_type = ? AND dict_value = ? LIMIT 1";
try {
return jdbcTemplate.queryForObject(
sql,
new Object[]{dictType, dictValue},
String.class
);
} catch (Exception e) {
// 查不到或出错时,返回 null
return null;
}
}
}
5.8 通用响应体:R.java
package com.hy.dictconvert.domain;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import lombok.Data;
import java.io.Serializable;
/**
* 通用响应封装类 R<T>
*/
@Data
@ApiModel("响应信息主体")
public class R<T> implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("返回标记:成功标记=0,失败标记=1")
private int code;
@ApiModelProperty("返回信息")
private String msg;
@ApiModelProperty("数据")
private T data;
public static <T> R<T> ok() {
return restResult(null, 0, null);
}
public static <T> R<T> ok(T data) {
return restResult(data, 0, null);
}
public static <T> R<T> ok(T data, String msg) {
return restResult(data, 0, msg);
}
public static <T> R<T> failed() {
return restResult(null, 1, null);
}
public static <T> R<T> failed(String msg) {
return restResult(null, 1, msg);
}
public static <T> R<T> failed(T data) {
return restResult(data, 1, null);
}
public static <T> R<T> failed(T data, String msg) {
return restResult(data, 1, msg);
}
private static <T> R<T> restResult(T data, int code, String msg) {
R<T> r = new R<>();
r.setCode(code);
r.setData(data);
r.setMsg(msg);
return r;
}
}
5.9 域对象:UserVO.java
package com.hy.dictconvert.domain;
import com.hy.dictconvert.annotation.Dict;
import lombok.Data;
import java.util.List;
@Data
public class UserVO {
private Long id;
@Dict(type = "sex", targetField = "sexDesc")
private String sex;
private String sexDesc;
@Dict(type = "status", targetField = "statusDesc")
private String status;
private String statusDesc;
private List<StudentVO> students;
private List<PostVO> posts;
}
5.10 域对象:StudentVO.java
package com.hy.dictconvert.domain;
import com.hy.dictconvert.annotation.Dict;
import lombok.Data;
@Data
public class StudentVO {
private Long id;
private String name;
private Integer age;
@Dict(type = "stuType", targetField = "stuTypeDesc")
private String stuType;
private String stuTypeDesc;
}
5.11 域对象:PostVO.java
package com.hy.dictconvert.domain;
import com.hy.dictconvert.annotation.Dict;
import lombok.Data;
@Data
public class PostVO {
private String userNickname;
private String userPhone;
private String userEmail;
private Integer userGender;
@Dict(type = "post_type", targetField = "postTypeDesc")
private String postType;
private String postTypeDesc;
}
5.12 控制器:UserController.java
package com.hy.dictconvert.controller;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.hy.dictconvert.annotation.EnableDictConvert;
import com.hy.dictconvert.domain.R;
import com.hy.dictconvert.domain.UserVO;
import com.hy.dictconvert.domain.StudentVO;
import com.hy.dictconvert.domain.PostVO;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
import java.util.Map;
/**
* 演示 Controller:只对标注了 @EnableDictConvert 的接口方法进行字典转换
*/
@RestController
@RequestMapping("/user")
public class UserController {
/**
* 1. 单个 UserVO 对象演示
*/
@EnableDictConvert
@GetMapping("/detail")
public R<UserVO> detail() {
// 模拟数据
UserVO user = new UserVO();
user.setId(1L);
user.setSex("1"); // 对应 sys_dict_data(dict_type='sex', dict_value='1') -> '男'
user.setStatus("0"); // 对应 sys_dict_data('status','0')->'停用'
// 嵌套 students 列表
StudentVO s1 = new StudentVO();
s1.setId(101L);
s1.setName("小明");
s1.setAge(18);
s1.setStuType("A"); // 对应 '一类学生'
user.setStudents(List.of(s1));
// 嵌套 posts 列表
PostVO p1 = new PostVO();
p1.setUserNickname("张三");
p1.setUserPhone("13800000000");
p1.setUserEmail("zhangsan@example.com");
p1.setUserGender(1);
p1.setPostType("news"); // 对应 '新闻帖'
user.setPosts(List.of(p1));
return R.ok(user);
}
/**
* 2. List<UserVO> 演示
*/
@EnableDictConvert
@GetMapping("/list")
public R<List<UserVO>> list() {
UserVO u1 = new UserVO();
u1.setId(2L);
u1.setSex("2");
u1.setStatus("1"); // 正常
u1.setStudents(List.of());
u1.setPosts(List.of());
UserVO u2 = new UserVO();
u2.setId(3L);
u2.setSex("1");
u2.setStatus("1");
u2.setStudents(List.of());
u2.setPosts(List.of());
return R.ok(List.of(u1, u2));
}
/**
* 3. IPage<UserVO> 分页演示(手动构造 Page 对象)
*/
@EnableDictConvert
@GetMapping("/page")
public R<Page<UserVO>> page() {
// MyBatis-Plus 的 Page<T> 对象,第二个参数是 pageSize
Page<UserVO> page = new Page<>(1, 2);
UserVO u1 = new UserVO();
u1.setId(4L);
u1.setSex("2");
u1.setStatus("0"); // 停用
u1.setStudents(List.of());
u1.setPosts(List.of());
page.setRecords(List.of(u1));
page.setTotal(1);
return R.ok(page);
}
/**
* 4. Map<String, Object> 演示
*/
@EnableDictConvert
@GetMapping("/map")
public R<Map<String, Object>> map() {
UserVO user = new UserVO();
user.setId(5L);
user.setSex("1");
user.setStatus("1");
user.setStudents(List.of());
user.setPosts(List.of());
// 放到 map 里再返回
return R.ok(Map.of("userKey", user));
}
/**
* 5. 不加 @EnableDictConvert 的接口不会被拦截
*/
@GetMapping("/raw")
public R<UserVO> raw() {
UserVO u = new UserVO();
u.setId(6L);
u.setSex("2");
u.setStatus("0");
u.setStudents(List.of());
u.setPosts(List.of());
return R.ok(u);
}
}
6. 启动与验证
测试各个接口
-
GET http://localhost:8080/user/detail{ "code": 0, "msg": null, "data": { "id": 1, "sex": "1", "sexDesc": "男", "status": "0", "statusDesc": "停用", "students": [ { "id": 101, "name": "小明", "age": 18, "stuType": "A", "stuTypeDesc": "一类学生" } ], "posts": [ { "userNickname": "张三", "userPhone": "13800000000", "userEmail": "zhangsan@example.com", "userGender": 1, "postType": "news", "postTypeDesc": "新闻帖" } ] } } -
GET http://localhost:8080/user/list{ "code": 0, "msg": null, "data": [ { "id": 2, "sex": "2", "sexDesc": "女", "status": "1", "statusDesc": "正常", "students": [], "posts": [] }, { "id": 3, "sex": "1", "sexDesc": "男", "status": "1", "statusDesc": "正常", "students": [], "posts": [] } ] } -
GET http://localhost:8080/user/page{ "code": 0, "msg": null, "data": { "records": [ { "id": 4, "sex": "2", "sexDesc": "女", "status": "0", "statusDesc": "停用", "students": [], "posts": [] } ], "total": 1, "size": 2, "current": 1, "orders": [], "optimizeCountSql": true, "searchCount": true, "countId": null, "maxLimit": null, "pages": 1, "ordersBy": null } } -
GET http://localhost:8080/user/map{ "code": 0, "msg": null, "data": { "userKey": { "id": 5, "sex": "1", "sexDesc": "男", "status": "1", "statusDesc": "正常", "students": [], "posts": [] } } } -
GET http://localhost:8080/user/raw
(没有加@EnableDictConvert,因此不会被切面拦截,返回原始字段){ "code": 0, "msg": null, "data": { "id": 6, "sex": "2", "sexDesc": null, "status": "0", "statusDesc": null, "students": [], "posts": [] } }
7. ✅ 说明与可扩展点
- 按需查询
DictServiceImpl使用JdbcTemplate从sys_dict_data表按需查找dict_label,不会把全部字典加载到内存。- 如果生产环境需要加缓存,可在
getLabel(...)方法上加@Cacheable或自行使用 Redis/Caffeine 缓存。
- 嵌套处理
DictValueConverter.convert(Object)方法会递归检测传入的值:- 如果是
Collection→ 遍历每个元素 - 如果是 数组 → 遍历每个元素
- 如果是
IPage<?>→ 遍历page.getRecords() - 如果是
Map<?,?>→ 遍历map.values() - 如果是简单类型(基本类型、包装类型、String、枚举) → 跳过
- 否则视为普通 POJO → 反射遍历它的所有字段:
- 先对带
@Dict注解的字段,调用DictService.getLabel(...)查库并写入对应的targetField - 再对该字段值本身做递归(处理更深层的嵌套)
- 先对带
- 如果是
- 接口级开关
- 只有在 Controller 方法上加了
@EnableDictConvert注解时,才会触发 AOP 切面DictAspect。 - 其他方法不受影响,也不会触发字典转换。
- 只有在 Controller 方法上加了
- 分页示例
- 引入了 MyBatis-Plus 依赖,仅为了使用
Page<T>/IPage<T>类,便于演示分页场景。 - 真实项目里可配合 MyBatis-Plus 或任意实现
IPage<T>的分页插件。
- 引入了 MyBatis-Plus 依赖,仅为了使用
- 数据源
- 使用 H2 内存数据库,启动时执行
schema.sql和data.sql建表并插入字典示例。 - 你可以将其替换为 MySQL/Oracle,并自行修改
application.properties和数据库初始化脚本。
- 使用 H2 内存数据库,启动时执行
至此,整个示例项目已经完备:
- 按路径创建所有文件
- 启动后可在
/user/detail,/user/list,/user/page,/user/map等接口测试“数据字典转换”功能 - 无需预加载所有字典数据,仅按需从数据库查询
六、EasyExcel 导出嵌套数据并动态映射字典
1. 在导出前通过自定义注解和 AOP 机制对字段进行数据字典转换。
✅主要功能如下:
- 使用 EasyExcel 将数据写入
HttpServletResponse,实现浏览器下载。 - VO 对象中含有嵌套的
List<PostVO>结构,导出时需要对嵌套列表进行平铺输出(EasyExcel 默认只支持平铺结构))。 - 定义自定义注解
@Dict(type, targetField)标注需要做字典转换的字段,type对应字典类型,targetField指定转换结果要写入的字段名。 - 不预加载字典值,转换时动态查询 MySQL 表
sys_dict_data(dict_type, dict_value, dict_label)获得标签。 - 通过 AOP 切面结合注解扫描,递归处理嵌套对象(集合、Map、自定义 VO 等)中标注
@Dict的字段。 - 在导出 Controller 中手动调用字典解析服务(如
dictResolverService.translate(dataList))对数据进行转换。 - 前端使用 Thymeleaf 渲染页面,提供一个“导出”按钮,点击后触发导出接口。
2. 项目结构
项目目录结构示例:
springboot-easyexcel-dict-demo/
├── src/
│ ├── main/
│ │ ├── java/com/example/demo/
│ │ │ ├── DemoApplication.java # Spring Boot 主类
│ │ │ ├── entity/User.java # 数据库实体(可选)
│ │ │ ├── vo/UserExportVO.java # 导出用 VO(含嵌套列表)
│ │ │ ├── vo/PostVO.java # 嵌套列表对象 VO
│ │ │ ├── annotation/Dict.java # 自定义 @Dict 注解
│ │ │ ├── service/DictResolverService.java# 字典解析服务(执行转换)
│ │ │ ├── aspect/DictAspect.java # AOP 切面(扫描并触发转换)
│ │ │ ├── controller/ExportController.java# 导出接口 Controller
│ │ │ └── mapper/SysDictDataMapper.java # MyBatis Mapper 查询字典表
│ │ └── resources/
│ │ ├── application.yml # Spring Boot 配置(数据库连接等)
│ │ └── templates/export.html # Thymeleaf 前端页面
│ └── test/...
└── sql/
└── sys_dict_data.sql # 建表与初始数据脚本
其中 vo/ 包存放导出数据的 VO 类,支持通过注解标注字段。annotation/ 包为自定义注解定义,aspect/ 包为 AOP 切面处理,mapper/ 包为 MyBatis 接口查询字典表。templates/export.html 为前端页面,包含一个“导出”按钮。
3. 数据库与字典表
示例使用 MySQL 存储字典数据,假设有数据字典表 sys_dict_data,结构包括 dict_type(类型)、dict_value(键)、dict_label(标签)等字段。示例建表及插入脚本:
-- sys_dict_data 表,用于存储字典映射
CREATE TABLE sys_dict_data (
id INT AUTO_INCREMENT PRIMARY KEY,
dict_type VARCHAR(100) NOT NULL,
dict_value VARCHAR(100) NOT NULL,
dict_label VARCHAR(100) NOT NULL
);
-- 插入示例字典数据
INSERT INTO sys_dict_data(dict_type, dict_value, dict_label) VALUES
('user_gender', 'M', '男'),
('user_gender', 'F', '女'),
('post_type', 'BLOG', '博客'),
('post_type', 'NEWS', '新闻');
在 application.yml 中配置 MySQL 数据源,例如:
spring:
datasource:
url: jdbc:mysql://localhost:3306/demo_db?useSSL=false&serverTimezone=UTC
username: your_user
password: your_password
driver-class-name: com.mysql.cj.jdbc.Driver
4. 实体/VO 定义
- UserExportVO.java:导出用的用户信息 VO,包含基本字段和嵌套的帖子列表。对需要字典转换的字段添加
@Dict注解,并指定dictType和要写入的目标字段名称。使用 EasyExcel 注解(如@ExcelProperty)指定 Excel 列名。示例:
@Data
public class UserExportVO {
@ExcelProperty("用户名")
private String username;
@Dict(type = "user_gender", targetField = "genderLabel") // 标注性别需转换
@ExcelProperty("性别")
private String gender; // 存储字典值(代码)
@ExcelProperty("性别标签")
private String genderLabel; // 用于输出性别文本
@ExcelProperty("年龄")
private Integer age;
// 嵌套列表,一个用户的多个帖子
@ExcelProperty("帖子列表")
private List<PostVO> posts;
}
- PostVO.java:用户帖子信息 VO,同样可以对需要转换的字段加注解。示例:
@Data
public class PostVO {
@ExcelProperty("帖子标题")
private String title;
@ExcelProperty("帖子内容")
private String content;
@Dict(type = "post_type", targetField = "typeLabel")
@ExcelProperty("帖子类型")
private String type; // 存储字典值,如 "BLOG","NEWS"
@ExcelProperty("帖子类型标签")
private String typeLabel;
}
在导出时,EasyExcel 会将 UserExportVO 中的每个字段映射为 Excel 列,并对嵌套 posts 列表中的元素进行行级输出。注意 EasyExcel 默认只支持平铺结构,所以需要自行处理嵌套列表的行展开(可在业务层将嵌套数据平铺成多行,或编写复杂的自定义表头逻辑)。
5. 自定义注解 @Dict
在 annotation/Dict.java 中定义字典注解,用于标识需要进行字典转换的字段,例如:
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Dict {
/** 字典类型,对应 sys_dict_data.dict_type */
String type();
/** 目标字段名,用于存储转换后的标签值 */
String targetField();
}
该注解添加到字段上后,后续通过 AOP 或工具类扫描时,可以知道需要将此字段的值(如性别代码)替换成字典表中的标签,并将结果写入指定的目标字段上。
6. 字典解析服务(DictResolverService)
实现一个服务类,用反射递归遍历对象,对所有标有 @Dict 的字段进行值替换。示例逻辑:
@Service
public class DictResolverService {
@Autowired
private SysDictDataMapper dictMapper; // MyBatis Mapper 查询 sys_dict_data
/**
* 对给定数据(实体、列表或分页等)中的字典字段进行值翻译。
*/
public void translate(Object data) {
if (data == null) return;
// 如果是列表,则遍历每个元素翻译
if (data instanceof Iterable<?>) {
for (Object item : (Iterable<?>) data) {
translate(item);
}
return;
}
// 如果是Map,则遍历值进行翻译
if (data instanceof Map<?, ?>) {
for (Object value : ((Map<?, ?>) data).values()) {
translate(value);
}
return;
}
// 对象类型:遍历其字段
Class<?> clazz = data.getClass();
for (Field field : clazz.getDeclaredFields()) {
field.setAccessible(true);
try {
// 如果字段有 @Dict 注解,则进行转换
if (field.isAnnotationPresent(Dict.class)) {
Dict dict = field.getAnnotation(Dict.class);
Object key = field.get(data);
if (key != null) {
// 查询字典表获取标签
String dictType = dict.type();
String dictValue = key.toString();
String label = dictMapper.selectLabel(dictType, dictValue);
// 将结果写入目标字段
String targetFieldName = dict.targetField();
Field targetField = clazz.getDeclaredField(targetFieldName);
targetField.setAccessible(true);
targetField.set(data, label);
}
}
// 如果字段本身是集合或自定义对象,递归处理
else if (field.get(data) != null &&
!(field.getType().isPrimitive() || field.getType() == String.class)) {
translate(field.get(data));
}
} catch (Exception e) {
// 忽略或记录错误
}
}
}
}
其中 SysDictDataMapper.selectLabel(type, value) 为自定义 MyBatis 查询方法,用于从数据库查出对应 dict_label。如:
@Mapper
public interface SysDictDataMapper {
@Select("SELECT dict_label FROM sys_dict_data WHERE dict_type = #{type} AND dict_value = #{value} LIMIT 1")
String selectLabel(@Param("type") String type, @Param("value") String value);
}
上述服务不预加载所有字典值,而是按需查询数据库。它能够递归处理对象内部的嵌套结构(包括 List、Map 或自定义 VO),在找到 @Dict 注解字段时执行查询替换。这种思路与常见的 AOP 切面方式相似。
7. AOP 切面(可选)
可以使用 AOP 切面结合自定义注解,在方法返回时自动触发字典解析。比如定义一个方法注解 @TranslateDict,在切面中拦截该注解并调用 dictResolverService.translate(result)。示例:
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface TranslateDict {}
java复制编辑@Aspect
@Component
public class DictAspect {
@Autowired
private DictResolverService dictResolverService;
@Around("@annotation(TranslateDict)")
public Object aroundDict(ProceedingJoinPoint joinPoint) throws Throwable {
Object result = joinPoint.proceed();
dictResolverService.translate(result);
return result;
}
}
这样标注了 @TranslateDict 的 Controller 或 Service 方法会在返回结果后自动进行字典转换。在本示例中,导出接口也可使用此注解自动转换,或者直接在代码中手动调用转换。
8. 导出接口 Controller
在 Controller 中定义导出接口,接收 HTTP 请求并将生成的 Excel 文件写入响应流。例如:
@RestController
@RequestMapping("/export")
public class ExportController {
@Autowired
private DictResolverService dictResolverService;
// 获取要导出的数据,通常从数据库查询
private List<UserExportVO> fetchData() {
// 示例数据
List<UserExportVO> list = new ArrayList<>();
// ...填充示例用户及帖子数据
return list;
}
@GetMapping("/users")
public void exportUsers(HttpServletResponse response) {
List<UserExportVO> users = fetchData();
// 执行字典转换(性别、帖子类型等)
dictResolverService.translate(users);
// 设置响应头
response.setContentType("application/vnd.ms-excel");
response.setCharacterEncoding("utf-8");
response.setHeader("Content-Disposition", "attachment;filename=users.xlsx");
// 使用 EasyExcel 写出
try {
EasyExcel.write(response.getOutputStream(), UserExportVO.class)
.sheet("用户信息").doWrite(users);
} catch (IOException e) {
// 处理异常
}
}
}
上例中,首先调用 fetchData() 获取待导出的用户列表,然后调用 dictResolverService.translate(users) 进行字典值转换。最后通过 EasyExcel.write(response.getOutputStream(), UserExportVO.class)...doWrite(users) 将数据写入响应[blog.csdn.net](https://blog.csdn.net/qq_65597930/article/details/144252958#:~:text=%2F%2F 使用EasyExcel进行数据写入操作 EasyExcel.write(response.getOutputStream()%2C ExportPlanInformationVo.class) .sheet(,指定工作表名称)。响应头需设置为下载文件格式,前端浏览器即可接收并下载生成的 Excel。
9. 前端页面(Thymeleaf)
创建一个简单的 Thymeleaf 页面 export.html,提供“导出”按钮触发下载:
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
<title>导出示例</title>
</head>
<body>
<h2>用户数据导出</h2>
<!-- 点击按钮向 /export/users 发送请求,浏览器将下载 Excel -->
<form th:action="@{/export/users}" method="get">
<button type="submit">导出用户信息</button>
</form>
</body>
</html>
访问页面后,点击“导出用户信息”按钮,即会调用 GET /export/users 接口,浏览器自动开始下载 Excel 文件。



















