Aop + 注解实现数据字典类型转换 EasyExcel导出

news2026/4/8 3:11:37

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)

✅它可以处理以下情况:

  1. 单个对象(如 UserVO
  2. 嵌套集合(如 List<StudentVO>List<PostVO>
  3. 嵌套 IPage<T>(MyBatis-Plus 分页对象)
  4. 嵌套 Map<String, Object>
  5. 嵌套的任意自定义 VO(只要它们有 @Dict 注解或者内部还包含可遍历的集合/对象)

✅核心思路是:

  • 对传入的 data(可能是单个 VO、List、Page 等)做一次递归:
    • 若是基本类型(String、包装类、枚举等)就跳过
    • 若是 CollectionListSet、数组)就对每个元素递归
    • 若是 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(例如 sexDescstatusDescstuTypeDescpostTypeDesc)来接收转换之后的中文。


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

✅讲解

  1. 切面拦截
    • @Around("execution(* com.hy..controller..*.*(..))"):只要你 Controller 中返回 R<T>,就会进到 aroundController()
    • 先执行 proceed() 拿到原始返回结果,接着取出 R<?>data 字段,开始做递归处理。
  2. processValue(Object value)
    • 这是一个万能递归入口
      • 如果 valueCollection(例如 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)
  3. 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 检测。
  4. 递归终止条件
    • 如果 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": "图文帖"
      }
    ]
  }
}
  • sexDescstatusDescstuTypeDescpostTypeDesc 都由 AOP 自动调用 DictService 从数据库查询并赋值。
  • 嵌套在 students 列表里的 StudentVOposts 列表里的 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)

✅ 该方法适应以下场景:

  1. 单个对象,如 R<UserVO>
  2. 嵌套集合,如 List<StudentVO>List<PostVO>
  3. 嵌套 MyBatis-Plus 分页对象 IPage<UserVO>
  4. 嵌套 Map<String, Object>
  5. 任意深度的自定义 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.sqldata.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. ✅ 说明与可扩展点

  1. 按需查询
    • DictServiceImpl 使用 JdbcTemplatesys_dict_data 表按需查找 dict_label,不会把全部字典加载到内存。
    • 如果生产环境需要加缓存,可在 getLabel(...) 方法上加 @Cacheable 或自行使用 Redis/Caffeine 缓存。
  2. 嵌套处理
    • DictValueConverter.convert(Object) 方法会递归检测传入的值:
      • 如果是 Collection → 遍历每个元素
      • 如果是 数组 → 遍历每个元素
      • 如果是 IPage<?> → 遍历 page.getRecords()
      • 如果是 Map<?,?> → 遍历 map.values()
      • 如果是简单类型(基本类型、包装类型、String、枚举) → 跳过
      • 否则视为普通 POJO → 反射遍历它的所有字段:
        • 先对带 @Dict 注解的字段,调用 DictService.getLabel(...) 查库并写入对应的 targetField
        • 再对该字段值本身做递归(处理更深层的嵌套)
  3. 接口级开关
    • 只有在 Controller 方法上加了 @EnableDictConvert 注解时,才会触发 AOP 切面 DictAspect
    • 其他方法不受影响,也不会触发字典转换。
  4. 分页示例
    • 引入了 MyBatis-Plus 依赖,仅为了使用 Page<T> / IPage<T> 类,便于演示分页场景。
    • 真实项目里可配合 MyBatis-Plus 或任意实现 IPage<T> 的分页插件。
  5. 数据源
    • 使用 H2 内存数据库,启动时执行 schema.sqldata.sql 建表并插入字典示例。
    • 你可以将其替换为 MySQL/Oracle,并自行修改 application.properties 和数据库初始化脚本。

至此,整个示例项目已经完备:

  • 按路径创建所有文件
  • 启动后可在 /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 定义

  1. 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;
}
  1. 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);
}

上述服务不预加载所有字典值,而是按需查询数据库。它能够递归处理对象内部的嵌套结构(包括 ListMap 或自定义 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 文件。


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.coloradmin.cn/o/2399043.html

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈,一经查实,立即删除!

相关文章

Xilinx超过256m bit flash固件跳转失败问题

问题描述 按照 链接: Xilinx 7系列fpga在线升级和跳转 这个方式跳转失败 问题排查 进一步排查现象如下 上面这个现象呈现出明显的以16m为周期的规律。感觉很大概率是因为flash超过了16m&#xff08;256bit&#xff09;导致的地址越界问题。另外我在CSDN上也找到类似的问题…

SpringCloud 分布式锁Redisson锁的重入性与看门狗机制 高并发 可重入

可重入 Redisson 的锁支持 可重入性&#xff0c;这意味着同一个线程在获取锁后&#xff0c;如果再次尝试获取该锁&#xff0c;它可以成功地获得锁&#xff0c;而不会被阻塞。 每次一个线程成功获取锁后&#xff0c;它的持有次数会增加。当线程再次获取该锁时&#xff0c;Redi…

02 APP 自动化-Appium 运行原理详解

环境搭建见 01 APP 自动化-环境搭建 文章目录 一、Appium及Appium自动化测试原理二、Appium 自动化配置项三、常见 ADB 命令四、第一个 app 自动化脚本 一、Appium及Appium自动化测试原理 Appium 跨平台、开源的 app 自动化测试框架&#xff0c;用来测试 app 应用程序&#x…

由docker引入架构简单展开说说技术栈学习之路

想象一下&#xff0c;你开了一家线上小卖部&#xff08;单机版&#xff09;&#xff0c;突然爆单了怎么办&#xff1f;别急&#xff0c;技术架构的升级打怪之路&#xff0c;可比哆啦A梦的口袋还神奇&#xff01; 第1关&#xff1a;单枪匹马的创业初期&#xff08;单机架构&…

linux 1.0.5

环境变量到底是什么 也就是windows上面的环境变量 就是这个东东&#xff0c;用户变量和系统变量&#xff0c;那这些到底是啥呢&#xff1f; 主包只是用过&#xff0c;配置来配置去的&#xff0c;就是不知道是啥意思 windows上面的环境变量 windows的ls命令是dir 输入calc可有…

强化学习的前世今生(五)— SAC算法

书接前四篇 强化学习的前世今生&#xff08;一&#xff09; 强化学习的前世今生&#xff08;二&#xff09; 强化学习的前世今生&#xff08;三&#xff09;— PPO算法 强化学习的前世今生&#xff08;四&#xff09;— DDPG算法 本文为大家介绍SAC算法 7 SAC 7.1 最大熵强化…

生成对抗网络(GAN)基础原理深度解析:从直观理解到形式化表达

摘要 本文详细解析 生成对抗网络&#xff08;GAN&#xff09; 的 核心原理&#xff0c;从通俗类比入手&#xff0c;结合印假钞与警察博弈的案例阐述生成器 与 判别器 的对抗机制&#xff1b;通过模型结构示意图&#xff0c;解析 噪声采样、样本生成 及判别流程&#xff1b;基于…

【GitHub开源AI精选】WhisperX:70倍实时语音转录、革命性词级时间戳与多说话人分离技术

系列篇章&#x1f4a5; No.文章1【GitHub开源AI精选】LLM 驱动的影视解说工具&#xff1a;Narrato AI 一站式高效创作实践2【GitHub开源AI精选】德国比勒费尔德大学TryOffDiff——高保真服装重建的虚拟试穿技术新突破3【GitHub开源AI精选】哈工大&#xff08;深圳&#xff09;…

华为OD机试真题——文件目录大小(2025 A卷:100分)Java/python/JavaScript/C++/C语言/GO六种语言最佳实现

2025 A卷 100分 题型 本文涵盖详细的问题分析、解题思路、代码实现、代码详解、测试用例以及综合分析; 并提供Java、python、JavaScript、C++、C语言、GO六种语言的最佳实现方式! 2025华为OD真题目录+全流程解析/备考攻略/经验分享 华为OD机试真题《文件目录大小》: 目录 题…

消费者行为变革下开源AI智能名片与链动2+1模式S2B2C商城小程序的协同创新路径

摘要&#xff1a;在信息爆炸与消费理性化趋势下&#xff0c;消费者从被动接受转向主动筛选&#xff0c;企业营销模式面临重构挑战。本文提出开源AI智能名片与链动21模式S2B2C商城小程序的协同创新框架&#xff0c;通过AI驱动的精准触达、链动裂变机制与S2B2C生态赋能&#xff0…

软考 系统架构设计师系列知识点之杂项集萃(78)

接前一篇文章&#xff1a;软考 系统架构设计师系列知识点之杂项集萃&#xff08;77&#xff09; 第139题 以下关于软件测试工具的叙述&#xff0c;错误的是&#xff08;&#xff09;。 A. 静态测试工具可用于对软件需求、结构设计、详细设计和代码进行评审、走查和审查 B. 静…

如何解决MySQL Workbench中的错误Error Code: 1175

错误描述&#xff1a; 在MySQL Workbench8.0中练习SQL语句时&#xff0c;执行一条update语句&#xff0c;总是提示如下错误&#xff1a; Error Code: 1175. You are using safe update mode and you tried to update a table without a WHERE that uses a KEY columnTo disab…

Docker 镜像(或 Docker 容器)中查找文件命令

在 Docker 镜像&#xff08;或 Docker 容器&#xff09;中运行如下两个命令时&#xff1a; cd / find . -name generate.py它们的含义如下&#xff0c;我们来一行一行详细拆解&#xff0c;并结合例子讲解&#xff1a; ✅ 第一行&#xff1a;cd / ✅ 含义 cd 是“change dire…

MySQL进阶篇(存储引擎、索引、视图、SQL性能优化、存储过程、触发器、锁)

MySQL进阶篇 存储引擎篇MySQL体系结构存储引擎简介常用存储引擎简介存储引擎的选择 索引篇索引简介索引结构(1)BTree索引(2)hash索引 索引分类索引语法SQL性能分析指标(1)SQL执行频率(2)慢查询日志(3)profile详情(4)explain或desc执行计划 索引使用引起索引的失效行为SQL提示覆…

BugKu Web渗透之game1

启动场景&#xff0c;打开网页如下&#xff1a; 是一个游戏。 步骤一&#xff1a; 右键查看源代码也没有发现异常。 步骤二&#xff1a; 点击开始游戏来看看。 结果他是这种搭高楼的游戏。我玩了一下子&#xff0c;玩到350分就game over。 之后就显示游戏结束&#xff0c;如…

Axure设计案例——科技感渐变柱状图

想让你的数据展示瞬间脱颖而出&#xff0c;成为众人瞩目的焦点吗&#xff1f;快来看看这个 Axure 设计的科技感渐变柱状图案例&#xff01;科技感设计风格以炫酷的渐变色彩打破传统柱状图的单调&#xff0c;营造出一种令人惊叹的视觉盛宴。每一个柱状体都仿佛蕴含着无限能量&am…

互联网大厂智能体平台体验笔记字节扣子罗盘、阿里云百炼、百度千帆 、腾讯元器、TI-ONE平台、云智能体开发平台

互联网大厂 字节扣子、阿里云百炼、百度千帆 、腾讯元器、TI-ONE平台、云智能体开发平台 体验 开始动手 了解 智能体&#xff0c;发现已经落后时代太远 光头部互联网大厂对开 公开的平台就已经这么多&#xff0c;可以学习和了解&#xff0c;相关的信息 整理了对应的平台地址…

深入解析ReactJS中JSX的底层工作原理

&#x1f49d;&#x1f49d;&#x1f49d;欢迎莅临我的博客&#xff0c;很高兴能够在这里和您见面&#xff01;希望您在这里可以感受到一份轻松愉快的氛围&#xff0c;不仅可以获得有趣的内容和知识&#xff0c;也可以畅所欲言、分享您的想法和见解。 推荐&#xff1a;「storms…

NodeMediaEdge任务管理

NodeMediaEdge任务管理 简介 NodeMediaEdge是一款部署在监控摄像机网络前端中&#xff0c;拉取Onvif或者rtsp/rtmp/http视频流并使用rtmp/kmp推送到公网流媒体服务器的工具。 在未使用NodeMediaServer的情况下&#xff0c;或是对部分视频流需要单独推送的需求&#xff0c;也可…

SpringBoot集成第三方jar的完整指南

原文地址&#xff1a;https://blog.csdn.net/weixin_43826336/article/details/141640152?ops_request_misc%257B%2522request%255Fid%2522%253A%25227d4118ef2d572ba4428caf83f1d2bb28%2522%252C%2522scm%2522%253A%252220140713.130102334…%2522%257D&request_id7d4118…