SpringBoot 通过自定义注解 + AOP 实现数据字典自动翻译(通用方案)

SpringBoot 通过自定义注解 + AOP 实现数据字典自动翻译(通用

文章目录

一、背景与痛点

在企业级开发中,数据字典翻译是绕不开的需求:数据库存的是 code(如性别 1),前端要展示文本(如 )。

常见做法:

  • 前端自己做字典映射 → 维护成本高,字典变更要改前端
  • 后端 Service 层手动查字典赋值 → 代码侵入性强,到处重复
  • 全局 AOP 自动翻译 → 一次配置,处处生效

本文实现一套基于自定义注解 @Dict + Spring AOP 的字典自动翻译方案,特点如下:

特性 说明
不修改原始值 追加 _dictStr 后缀字段,前端同时拿到 code 和文本
支持多种返回类型 PageInfoPageResultList、单对象
递归嵌套翻译 嵌套对象和 List 内部的字典字段也能自动处理
多值支持 逗号分隔的值(如 "1,2,3")自动逐个翻译
降级容错 翻译异常不阻断业务,返回原始数据
Redis 缓存 按 dictCode 细粒度缓存,主动清除保证一致性
切面路径可配置 不同项目通过 yml 配置不同的拦截包路径
多项目复用 接口化数据加载,不绑定具体字典表

二、效果演示

VO 字段标注 @Dict

java 复制代码
@Dict(dictCode = "gender")
private String gender;  // 数据库值: "1"

接口返回 JSON:

json 复制代码
{
  "gender": "1",
  "gender_dictStr": "男"
}

前端可以直接用 gender_dictStr 展示,也可以用 gender 做筛选条件,互不影响。


三、整体架构

复制代码
Controller 返回 Result
        ↓
   DictAspectConfig(配置化切面,读取 yml 中的 pointcut 表达式)
        ↓
   DictTranslateInterceptor(核心翻译逻辑)
        ↓
   解析返回数据类型(PageInfo / PageResult / List / 单对象)
        ↓
   递归遍历字段,收集 @Dict 注解的 dictCode
        ↓
   DictCacheService(Redis 缓存)
        ↓
   DictDataProvider(接口,各项目实现自己的数据加载)
        ↓
   追加 "字段名_dictStr" 到 JSONObject 返回

四、核心代码实现

4.1 @Dict 注解

java 复制代码
package com.shinho.eccp.entry.api.dto.annotation;

import java.lang.annotation.*;

@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Dict {

    /**
     * 字典类型编码(对应字典表的类型字段)
     * 例如: "gender", "area", "education_background"
     */
    String dictCode();

    /**
     * 翻译结果存放的字段名(可选)
     * 默认为 "原字段名_dictStr",指定后使用自定义字段名
     */
    String dictText() default "";
}

4.2 DictDataProvider 接口(数据加载抽象)

各项目实现此接口,查自己的字典表,不做任何绑定:

java 复制代码
package com.shinho.eccp.entry.api.dto.annotation;

import java.util.List;

public interface DictDataProvider {

    /**
     * 根据字典类型编码查询字典项列表
     */
    List<DictItem> getDictItemsByCode(String dictCode);

    /**
     * 字典项数据结构
     */
    class DictItem {
        private String value; // 字典编码值
        private String label; // 字典显示文本

        public DictItem() {}

        public DictItem(String value, String label) {
            this.value = value;
            this.label = label;
        }

        // getter / setter 省略
    }
}

4.3 具体项目的 Provider 实现

java 复制代码
@Service
public class EntryDictDataProvider implements DictDataProvider {

    @Autowired
    private DataDictionaryDao dataDictionaryDao;

    @Override
    public List<DictItem> getDictItemsByCode(String dictCode) {
        List<DropDownValuesVo> voList = dataDictionaryDao.getValuesByField(dictCode);
        if (voList == null || voList.isEmpty()) {
            return Collections.emptyList();
        }
        List<DictItem> items = new ArrayList<>(voList.size());
        for (DropDownValuesVo vo : voList) {
            items.add(new DictItem(vo.getValue(), vo.getName()));
        }
        return items;
    }
}

其他项目只需实现 DictDataProvider,可以查不同的表、调 Feign 接口、读配置文件等,不受限制。


4.4 DictCacheService 缓存服务

java 复制代码
@Service
public class DictCacheService {

    private static final Logger log = LoggerFactory.getLogger(DictCacheService.class);

    @Value("${eccp.dict.redis-key-prefix:eccp:dict:}")
    private String redisKeyPrefix;

    @Value("${eccp.dict.redis-expire-seconds:2592000}")
    private int redisExpireSeconds;

    @Autowired
    private JedisUtil jedisUtil;

    @Autowired
    private DictDataProvider dictDataProvider;

    public List<DictItem> getDictItems(String dictCode) {
        if (StringUtils.isBlank(dictCode)) {
            return Collections.emptyList();
        }

        // 1. 优先从 Redis 获取
        try {
            String redisKey = redisKeyPrefix + dictCode;
            String json = jedisUtil.getCurrentService(redisKey);
            if (StringUtils.isNotBlank(json)) {
                List<DictItem> items = JSON.parseArray(json, DictItem.class);
                if (items != null) {
                		return items;
                }
            }
        } catch (Exception e) {
            log.warn("Redis获取字典缓存异常, dictCode={}", dictCode);
        }

        // 2. 缓存未命中,通过 Provider 加载
        List<DictItem> items;
        try {
            items = dictDataProvider.getDictItemsByCode(dictCode);
        } catch (Exception e) {
            log.error("查询字典数据异常, dictCode={}", dictCode, e);
            return Collections.emptyList();
        }
        if (items == null) {
        		items = Collections.emptyList();
        }

        // 3. 回写 Redis
        try {
            String redisKey = redisKeyPrefix + dictCode;
            jedisUtil.setCurrentService(redisKey, JSON.toJSONString(items), redisExpireSeconds);
        } catch (Exception e) {
            log.warn("回写Redis异常, dictCode={}", dictCode);
        }

        return items;
    }

    public Map<String, List<DictItem>> batchGetDictItems(Set<String> dictCodes) {
        Map<String, List<DictItem>> result = new HashMap<>(dictCodes.size());
        for (String code : dictCodes) {
            result.put(code, getDictItems(code));
        }
        return result;
    }

    /** 清除缓存(字典变更时调用) */
    public void evict(String dictCode) {
        jedisUtil.delCurrentService(redisKeyPrefix + dictCode);
    }

    /** 清除并重新加载 */
    public void reload(String dictCode) {
        evict(dictCode);
        getDictItems(dictCode);
    }
}

4.5 DictAspectConfig 配置化切面(关键)

这是本方案的核心亮点之一 :切面拦截路径通过 application.yml 配置,不同项目无需修改代码,只改配置即可。

java 复制代码
@Configuration
public class DictAspectConfig {

    private static final Logger log = LoggerFactory.getLogger(DictAspectConfig.class);

    /**
     * 切面拦截表达式,各项目通过配置自定义
     * 默认拦截 com.shinho.eccp 下所有 web 包的 Controller 方法
     */
    @Value("${eccp.dict.pointcut-expression:execution(* com.shinho.eccp..web.*.*(..))}")
    private String pointcutExpression;

    @Autowired
    private DictTranslateInterceptor dictTranslateInterceptor;

    @Bean
    @Order(2)
    public DefaultPointcutAdvisor dictAdvisor() {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(pointcutExpression);
        log.info("字典翻译切面已注册, pointcut={}", pointcutExpression);
        return new DefaultPointcutAdvisor(pointcut, dictTranslateInterceptor);
    }
}

为什么不用 @Aspect + @Pointcut

因为 @Pointcut 的表达式必须是编译时常量,无法通过 @Value 注入。

使用 DefaultPointcutAdvisor + AspectJExpressionPointcut 是 Spring AOP 的编程式方式,

pointcut 表达式可以在运行时从配置文件读取,真正做到零代码改动,不同项目只改 yml


4.6 DictTranslateInterceptor 核心翻译逻辑

java 复制代码
@Component
public class DictTranslateInterceptor implements MethodInterceptor {

    private static final String DICT_SUFFIX = "_dictStr";
    private static final Map<Class<?>, Field[]> FIELD_CACHE = new ConcurrentHashMap<>();
    private static final Map<Class<?>, List<Field>> DICT_FIELD_CACHE = new ConcurrentHashMap<>();

    @Autowired
    private DictCacheService dictCacheService;

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        Object result = invocation.proceed();
        try {
            result = translateDict(result);
        } catch (Exception e) {
            log.warn("字典翻译异常,返回原始数据: {}", e.getMessage());
        }
        return result;
    }

    private Object translateDict(Object result) {
        if (!(result instanceof Result)) return result;

        Object data = ((Result<?>) result).getData();
        if (data == null) {
        		return result;
        }

        if (data instanceof PageInfo) {
            PageInfo pageInfo = (PageInfo) data;
            if (CollectionUtils.isNotEmpty(pageInfo.getList())) {
                pageInfo.setList(processRecords(pageInfo.getList()));
            }
        } else if (data instanceof PageResult) {
            PageResult pageResult = (PageResult) data;
            if (CollectionUtils.isNotEmpty(pageResult.getRows())) {
                pageResult.setRows(processRecords(pageResult.getRows()));
            }
        } else if (data instanceof List) {
            List<?> list = (List<?>) data;
            if (CollectionUtils.isNotEmpty(list)) {
                ((Result) result).setData(processRecords(list));
            }
        } else if (!isJavaBasicType(data.getClass())) {
            List<Object> single = processRecords(Collections.singletonList(data));
            if (CollectionUtils.isNotEmpty(single)) {
                ((Result) result).setData(single.get(0));
            }
        }
        return result;
    }
}

递归构建带 _dictStr 的 JSONObject:

java 复制代码
private JSONObject buildJsonWithDict(Object obj, Map<String, List<DictItem>> dictDataMap) {
    JSONObject json = new JSONObject(true); // 保持字段顺序
    Field[] allFields = getAllFields(obj.getClass());

    for (Field field : allFields) {
        field.setAccessible(true);
        Object value = getFieldValueSafe(obj, field);

        // 嵌套对象递归处理(List / 单对象)
        if (value != null && !isJavaBasicType(field.getType())) {
            if (value instanceof List) {
                List<?> listValue = (List<?>) value;
                if (CollectionUtils.isNotEmpty(listValue) && !isJavaBasicType(listValue.get(0).getClass())) {
                    Set<String> nestedCodes = collectAllDictCodes(listValue.get(0).getClass());
                    if (!nestedCodes.isEmpty()) {
                        Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
                        List<Object> nestedResult = new ArrayList<>();
                        for (Object item : listValue) {
                            nestedResult.add(buildJsonWithDict(item, mergedMap));
                        }
                        json.put(field.getName(), nestedResult);
                    } else {
                        json.put(field.getName(), value);
                    }
                } else {
                    json.put(field.getName(), value);
                }
            } else if (!field.getType().isEnum()) {
                Set<String> nestedCodes = collectAllDictCodes(field.getType());
                if (!nestedCodes.isEmpty()) {
                    Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
                    json.put(field.getName(), buildJsonWithDict(value, mergedMap));
                } else {
                    json.put(field.getName(), value);
                }
            } else {
                json.put(field.getName(), value);
            }
        } else {
            json.put(field.getName(), value);
        }

        // 追加 _dictStr 字段
        Dict dict = field.getAnnotation(Dict.class);
        if (dict != null && value != null) {
            String textValue = translateValue(dictDataMap, dict.dictCode(), value.toString());
            String dictFieldName = StringUtils.isNotBlank(dict.dictText())
                    ? dict.dictText()
                    : (field.getName() + DICT_SUFFIX);
            json.put(dictFieldName, textValue);
        }
    }
    return json;
}

字典值翻译(支持逗号分隔多值 + 降级):

java 复制代码
private String translateValue(Map<String, List<DictItem>> dictDataMap, String dictCode, String value) {
    List<DictItem> items = dictDataMap.get(dictCode);
    // 降级
    if (items == null || items.isEmpty()) {
    		return value; 
    }

    Map<String, String> valueToLabelMap = new HashMap<>(items.size());
    for (DictItem item : items) {
        if (item.getValue() != null) {
            valueToLabelMap.put(item.getValue(), item.getLabel());
        }
    }

    String[] codes = value.split(",");
    StringBuilder sb = new StringBuilder();
    for (int i = 0; i < codes.length; i++) {
        String code = codes[i].trim();
        String text = valueToLabelMap.getOrDefault(code, code); // 找不到返回原值
        if (i > 0) sb.append(",");
        sb.append(text);
    }
    return sb.toString();
}

4.7 缓存管理接口

字典数据变更后需要主动刷新缓存:

java 复制代码
@RestController
@RequestMapping("/dict/cache")
@Api(tags = "字典缓存管理")
public class DictCacheController {

    @Autowired
    private DictCacheService dictCacheService;

    @PostMapping("/evict")
    public Result<String> evict(@RequestParam String dictCode) {
        dictCacheService.evict(dictCode);
        return Result.ok("缓存已清除: " + dictCode);
    }

    @PostMapping("/reload")
    public Result<String> reload(@RequestParam String dictCode) {
        dictCacheService.reload(dictCode);
        return Result.ok("缓存已重新加载: " + dictCode);
    }
}

五、配置文件说明

yaml 复制代码
eccp:
  dict:
    # Redis 缓存 key 前缀(不同项目配置不同值,避免冲突)
    redis-key-prefix: eccp:entry:dict:
    # 缓存过期时间(秒),默认30天
    redis-expire-seconds: 2592000
    # 切面拦截的包路径表达式(不同项目配置不同的 Controller 包路径)
    pointcut-expression: execution(* com.shinho.eccp.entry.web.*.*(..))

六、使用方式

6.1 在 VO 上标注注解

java 复制代码
@Data
public class MemberListVo {

    @Dict(dictCode = "gender")
    private String gender;

    @Dict(dictCode = "legal_person_company")
    private String legalPersonCompany;

    @Dict(dictCode = "status")
    private String status;

    // 自定义翻译字段名
    @Dict(dictCode = "recruitment_source", dictText = "recruitmentSourceText")
    private String recruitmentSource;
}

6.2 接口返回示例

json 复制代码
{
  "code": 200,
  "data": {
    "list": [
      {
        "gender": "1",
        "gender_dictStr": "男",
        "legalPersonCompany": "01",
        "legalPersonCompany_dictStr": "XX食品有限公司",
        "status": "2",
        "status_dictStr": "待办理入职",
        "recruitmentSource": "3",
        "recruitmentSourceText": "内部推荐"
      }
    ]
  }
}

七、多项目复用

不同项目只需三步:

1. 实现 DictDataProvider 接口(查自己的字典表):

java 复制代码
@Service
public class BffDictDataProvider implements DictDataProvider {
    @Autowired
    private SomeBffDictDao bffDictDao;

    @Override
    public List<DictItem> getDictItemsByCode(String dictCode) {
        return bffDictDao.queryByType(dictCode).stream()
            .map(e -> new DictItem(e.getCode(), e.getName()))
            .collect(Collectors.toList());
    }
}

2. 配置 yml(不同前缀 + 不同切面路径):

yaml 复制代码
# 项目A - entry 服务
eccp:
  dict:
    redis-key-prefix: eccp:entry:dict:
    pointcut-expression: execution(* com.shinho.eccp.entry.web.*.*(..))

# 项目B - bff 服务
eccp:
  dict:
    redis-key-prefix: eccp:bff:dict:
    pointcut-expression: execution(* com.shinho.eccp.bff.web.*.*(..))

# 项目C - 多个包路径
eccp:
  dict:
    redis-key-prefix: eccp:hr:dict:
    pointcut-expression: execution(* com.shinho.eccp.hr.controller.*.*(..)) || execution(* com.shinho.eccp.hr.api.*.*(..))

3. 在 VO 字段上加 @Dict 注解即可。

各项目字典缓存隔离,切面路径独立,互不干扰。


八、性能优化细节

优化点 做法
避免重复反射 ConcurrentHashMap 缓存 Class → Field\[\] 映射
避免 N+1 查询 先收集所有 dictCode,批量从缓存获取
翻译效率 List<DictItem>HashMap<value, label>,O(1) 查找
嵌套按需加载 外层已加载的字典传递给内层,缺失时才补充
无注解短路 没有 @Dict 注解的对象直接跳过,零开销
Redis 持久化 按 dictCode 细粒度缓存,变更时主动清除
可改造方向 如果各个项目字典表结构一致,查询字典数据可以传一个tableName参数即可,但会涉及到多数据源

九、缺点与性能优化(待实现)

  1. 性能相关
    JSON 序列化开销 每次请求都把返回对象通过反射构建 JSONObject,相比直接返回 Java 对象多了一次序列化过程。数据量小时无感,但如果是大分页(比如一次返回几百条,每条几十个字段+嵌套),开销会明显。反射遍历所有字段 即使对象只有 1 个 @Dict 字段,也要遍历全部字段(含父类链)。虽然有 FIELD_CACHE 缓存 Field 数组,但每次还是要逐字段 field.get() 取值并放入 JSONObject。无注解对象也被处理 当前逻辑对所有走过切面的 Controller 方法都会触发 translateDict,即使返回的 VO 上没有任何 @Dict 注解,也会执行 instanceof 判断和类型检查。
  2. 解决方案 目前有两种解决思路
  • 通过在方法或类级别加开关注解来彻底短路, 在 VO 类中上增加 @DictEnable 注解判断是否需要提前短路。
  • DictTranslateInterceptor.java 这个类中增加一个Map<Class<?>, Boolean> 用来缓存 VO 判断是否需要提前短路

十、文件清单

文件 位置 说明
Dict.java api 模块 注解定义
DictDataProvider.java api 模块 数据加载接口 + DictItem
DictAspectConfig.java service 模块 配置化切面注册
DictTranslateInterceptor.java service 模块 核心翻译逻辑
DictCacheService.java service 模块 Redis 缓存服务
EntryDictDataProvider.java service 模块 本项目的 Provider 实现
DictCacheController.java service 模块 缓存管理接口

十一、总结

本方案的核心设计思想:

一套代码,多项目复用,不同的表、不同的包路径,只改配置文件。

  1. 注解驱动 --- 加 @Dict 即生效,零侵入业务代码
  2. 接口隔离 --- DictDataProvider 让数据来源与翻译逻辑彻底解耦
  3. 全面配置化 --- Redis 前缀、过期时间、切面拦截路径均通过 yml 配置
  4. 防御性编程 --- 任何环节异常都降级处理,绝不阻断业务
  5. 缺点 ---性能相关:

--- 解决方案

十二、具体代码实现

  1. Dict.java
java 复制代码
import java.lang.annotation.*;

/**
 * 数据字典翻译注解
 * <p>
 * 标注在 VO/DTO 字段上,AOP 切面会自动将字典 code 翻译为文本,
 * 追加到返回 JSON 中(默认字段名: 原字段名_dictStr),原始值保持不变。
 * </p>
 *
 * <pre>
 * 使用示例:
 * {@code @Dict(dictCode = "gender")}
 * private String gender;  // 数据库值: "1"
 *
 * 返回 JSON:
 * { "gender": "1", "gender_dictStr": "男" }
 * </pre>
 */
@Target(ElementType.FIELD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Dict {

    /**
     * 字典类型编码(对应字典表的类型字段)
     * 例如: "gender", "area", "education_background"
     */
    String dictCode();

    /**
     * 翻译结果存放的字段名(可选)
     * 默认为 "原字段名_dictStr",指定后使用自定义字段名
     */
    String dictText() default "";
}
  1. DictDataProvider.java
java 复制代码
import java.util.List;

/**
 * 字典数据加载接口
 * <p>
 * 各项目实现此接口来提供字典数据,不绑定具体字典表结构。
 * 可以查本地数据库、调 Feign 接口、读配置文件等。
 * </p>
 */
public interface DictDataProvider {

    /**
     * 根据字典类型编码查询字典项列表
     *
     * @param dictCode 字典类型编码
     * @return 字典项列表
     */
    List<DictItem> getDictItemsByCode(String dictCode);

    /**
     * 字典项数据结构 后面可以单独抽出去,不通过内部类实现
     */
    class DictItem {
        /** 字典编码值 */
        private String value;
        /** 字典显示文本 */
        private String label;

        public DictItem() {
        }

        public DictItem(String value, String label) {
            this.value = value;
            this.label = label;
        }

        public String getValue() {
            return value;
        }

        public void setValue(String value) {
            this.value = value;
        }

        public String getLabel() {
            return label;
        }

        public void setLabel(String label) {
            this.label = label;
        }
    }
}
  1. EntryDictDataProvider
java 复制代码
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

/**
 * eccp-entry 项目的字典数据 Provider 实现
 * <p>
 * 通过 DataDictionaryDao 查询本项目字典表,
 * 将 DropDownValuesVo 转换为通用的 DictItem。
 * </p>
 */
@Service
public class EntryDictDataProvider implements DictDataProvider {

    @Autowired
    private DataDictionaryDao dataDictionaryDao;

    @Override
    public List<DictItem> getDictItemsByCode(String dictCode) {
        List<DropDownValuesVo> voList = dataDictionaryDao.getValuesByField(dictCode);
        if (voList == null || voList.isEmpty()) {
            return Collections.emptyList();
        }
        List<DictItem> items = new ArrayList<>(voList.size());
        for (DropDownValuesVo vo : voList) {
            items.add(new DictItem(vo.getValue(), vo.getName()));
        }
        return items;
    }
}
  1. DictCacheService
java 复制代码
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;

import java.util.*;

/**
 * 字典缓存服务
 * <p>
 * 按 dictCode 细粒度缓存字典数据到 Redis,
 * 缓存未命中时通过 DictDataProvider 加载并回写。
 * </p>
 */
@Service
public class DictCacheService {

    private static final Logger log = LoggerFactory.getLogger(DictCacheService.class);

    @Value("${eccp.dict.redis-key-prefix:eccp:dict}")
    private String redisKeyPrefix;

    @Value("${eccp.dict.redis-expire-seconds:2592000}")
    private int redisExpireSeconds;

    @Autowired
    private JedisUtil jedisUtil;

    @Autowired
    private DictDataProvider dictDataProvider;

    /**
     * 获取指定 dictCode 的字典项列表
     *
     * @param dictCode 字典类型编码
     * @return 字典项列表,不会返回 null
     */
    public List<DictItem> getDictItems(String dictCode) {
        if (StringUtils.isBlank(dictCode)) {
            return Collections.emptyList();
        }

        // 1. 优先从 Redis 获取
        try {
            String redisKey = redisKeyPrefix + ":" + dictCode;
            String json = jedisUtil.getCurrentService(redisKey);
            if (StringUtils.isNotBlank(json)) {
                List<DictItem> items = JSON.parseArray(json, DictItem.class);
                if (items != null) {
                    return items;
                }
            }
        } catch (Exception e) {
            log.warn("Redis获取字典缓存异常, dictCode={}", dictCode);
        }

        // 2. 缓存未命中,通过 Provider 加载
        List<DictItem> items;
        try {
            items = dictDataProvider.getDictItemsByCode(dictCode);
        } catch (Exception e) {
            log.error("查询字典数据异常, dictCode={}", dictCode, e);
            return Collections.emptyList();
        }
        if (items == null) {
            items = Collections.emptyList();
        }

        // 3. 回写 Redis
        try {
            String redisKey = redisKeyPrefix + ":" + dictCode;
            jedisUtil.setCurrentService(redisKey, JSON.toJSONString(items), redisExpireSeconds);
        } catch (Exception e) {
            log.warn("回写Redis异常, dictCode={}", dictCode);
        }

        return items;
    }

    /**
     * 批量获取多个 dictCode 的字典数据
     *
     * @param dictCodes 字典编码集合
     * @return dictCode -> 字典项列表 的映射
     */
    public Map<String, List<DictItem>> batchGetDictItems(Set<String> dictCodes) {
        Map<String, List<DictItem>> result = new HashMap<>(dictCodes.size());
        for (String code : dictCodes) {
            result.put(code, getDictItems(code));
        }
        return result;
    }

    /**
     * 清除指定 dictCode 的缓存
     *
     * @param dictCode 字典类型编码
     */
    public void evict(String dictCode) {
        try {
            jedisUtil.delCurrentService(redisKeyPrefix + ":" + dictCode);
        } catch (Exception e) {
            log.warn("清除字典缓存异常, dictCode={}", dictCode);
        }
    }

    /**
     * 清除并重新加载指定 dictCode 的缓存
     *
     * @param dictCode 字典类型编码
     */
    public void reload(String dictCode) {
        evict(dictCode);
        getDictItems(dictCode);
    }
}
  1. DictCacheController
java 复制代码
import com.shinho.common.api.base.Result;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

/**
 * 字典缓存管理接口
 * <p>
 * 字典数据变更后,通过此接口主动刷新 Redis 缓存,保证一致性。
 * </p>
 */
@RestController
@RequestMapping("/dict/cache")
@Api(tags = "字典缓存管理")
public class DictCacheController {

    @Autowired
    private DictCacheService dictCacheService;

    @PostMapping("/evict")
    @ApiOperation("清除指定字典类型的缓存")
    public Result<String> evict(@ApiParam("字典类型编码") @RequestParam String dictCode) {
        dictCacheService.evict(dictCode);
        return Result.ok("缓存已清除: " + dictCode);
    }

    @PostMapping("/reload")
    @ApiOperation("清除并重新加载指定字典类型的缓存")
    public Result<String> reload(@ApiParam("字典类型编码") @RequestParam String dictCode) {
        dictCacheService.reload(dictCode);
        return Result.ok("缓存已重新加载: " + dictCode);
    }
}
  1. DictTranslateInterceptor
java 复制代码
import com.github.pagehelper.PageInfo;
import org.aopalliance.intercept.MethodInterceptor;
import org.aopalliance.intercept.MethodInvocation;
import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;

import java.lang.reflect.Field;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 字典翻译核心拦截器
 * <p>
 * 拦截 Controller 方法的返回值,自动将标注了 {@link Dict} 注解的字段
 * 翻译为文本,追加 "_dictStr" 后缀字段到返回的 JSON 中。
 * </p>
 * <p>
 * 特性:
 * <ul>
 *   <li>支持 PageInfo、PageResult、List、单对象等多种返回类型, PageInfo、PageResult、Result 换成自己项目中使用的</li>
 *   <li>递归处理嵌套对象和 List 内部的字典字段</li>
 *   <li>支持逗号分隔的多值翻译</li>
 *   <li>降级容错:翻译异常不阻断业务,返回原始数据</li>
 *   <li>ConcurrentHashMap 缓存反射结果,避免重复反射</li>
 * </ul>
 * </p>
 */
@Component
public class DictTranslateInterceptor implements MethodInterceptor {

    private static final Logger log = LoggerFactory.getLogger(DictTranslateInterceptor.class);

    private static final String DICT_SUFFIX = "_dictStr";

    /** Class -> 所有字段(含父类)缓存 */
    private static final Map<Class<?>, Field[]> FIELD_CACHE = new ConcurrentHashMap<>();

    @Autowired
    private DictCacheService dictCacheService;

    @Override
    public Object invoke(MethodInvocation invocation) throws Throwable {
        Object result = invocation.proceed();
        try {
            result = translateDict(result);
        } catch (Exception e) {
            log.warn("字典翻译异常,返回原始数据: {}", e.getMessage());
        }
        return result;
    }

    /**
     * 翻译字典入口
     */
    @SuppressWarnings("unchecked")
    private Object translateDict(Object result) {
        if (!(result instanceof Result)) {
            return result;
        }

        Result<?> resultObj = (Result<?>) result;
        Object data = resultObj.getData();
        if (data == null) {
            return result;
        }

        if (data instanceof PageInfo) {
            PageInfo<?> pageInfo = (PageInfo<?>) data;
            if (CollectionUtils.isNotEmpty(pageInfo.getList())) {
                List<Object> translated = processRecords(pageInfo.getList());
                ((PageInfo) pageInfo).setList(translated);
            }
        } else if (data instanceof PageResult) {
            PageResult pageResult = (PageResult) data;
            if (CollectionUtils.isNotEmpty(pageResult.getRows())) {
                List<Object> translated = processRecords(pageResult.getRows());
                pageResult.setRows(translated);
            }
        } else if (data instanceof List) {
            List<?> list = (List<?>) data;
            if (CollectionUtils.isNotEmpty(list) && !isJavaBasicType(list.get(0).getClass())) {
                ((Result) resultObj).setData(processRecords(list));
            }
        } else if (!isJavaBasicType(data.getClass())) {
            List<Object> single = processRecords(Collections.singletonList(data));
            if (CollectionUtils.isNotEmpty(single)) {
                ((Result) resultObj).setData(single.get(0));
            }
        }

        return result;
    }

    /**
     * 批量处理记录列表
     */
    private List<Object> processRecords(List<?> records) {
        if (CollectionUtils.isEmpty(records)) {
            return Collections.emptyList();
        }

        // 1. 收集所有需要翻译的 dictCode
        Object firstItem = records.get(0);
        if (isJavaBasicType(firstItem.getClass())) {
            return new ArrayList<>(records);
        }

        Set<String> allDictCodes = collectAllDictCodes(firstItem.getClass());
        if (allDictCodes.isEmpty()) {
            // 没有 @Dict 注解,检查是否有嵌套对象需要处理
            if (!hasNestedDictFields(firstItem.getClass())) {
                return new ArrayList<>(records);
            }
        }

        // 2. 批量获取字典数据
        Map<String, List<DictItem>> dictDataMap = dictCacheService.batchGetDictItems(allDictCodes);

        // 3. 逐条翻译
        List<Object> resultList = new ArrayList<>(records.size());
        for (Object record : records) {
            resultList.add(buildJsonWithDict(record, dictDataMap));
        }
        return resultList;
    }

    /**
     * 递归构建带 _dictStr 的 JSONObject
     */
    private JSONObject buildJsonWithDict(Object obj, Map<String, List<DictItem>> dictDataMap) {
        JSONObject json = new JSONObject(true); // 保持字段顺序
        Field[] allFields = getAllFields(obj.getClass());

        for (Field field : allFields) {
            field.setAccessible(true);
            Object value = getFieldValueSafe(obj, field);

            // 处理嵌套对象
            if (value != null && !isJavaBasicType(field.getType())) {
                if (value instanceof List) {
                    List<?> listValue = (List<?>) value;
                    if (CollectionUtils.isNotEmpty(listValue) && !isJavaBasicType(listValue.get(0).getClass())) {
                        Set<String> nestedCodes = collectAllDictCodes(listValue.get(0).getClass());
                        Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
                        List<Object> nestedResult = new ArrayList<>();
                        for (Object item : listValue) {
                            nestedResult.add(buildJsonWithDict(item, mergedMap));
                        }
                        json.put(field.getName(), nestedResult);
                    } else {
                        json.put(field.getName(), value);
                    }
                } else if (!field.getType().isEnum()) {
                    Set<String> nestedCodes = collectAllDictCodes(field.getType());
                    if (!nestedCodes.isEmpty() || hasNestedDictFields(field.getType())) {
                        Map<String, List<DictItem>> mergedMap = ensureDictLoaded(dictDataMap, nestedCodes);
                        json.put(field.getName(), buildJsonWithDict(value, mergedMap));
                    } else {
                        json.put(field.getName(), value);
                    }
                } else {
                    json.put(field.getName(), value);
                }
            } else {
                json.put(field.getName(), value);
            }

            // 追加 _dictStr 字段
            Dict dict = field.getAnnotation(Dict.class);
            if (dict != null && value != null) {
                String textValue = translateValue(dictDataMap, dict.dictCode(), value.toString());
                String dictFieldName = StringUtils.isNotBlank(dict.dictText())
                        ? dict.dictText()
                        : (field.getName() + DICT_SUFFIX);
                json.put(dictFieldName, textValue);
            }
        }
        return json;
    }

    /**
     * 字典值翻译(支持逗号分隔多值)
     */
    private String translateValue(Map<String, List<DictItem>> dictDataMap, String dictCode, String value) {
        List<DictItem> items = dictDataMap.get(dictCode);
        // 降级:返回原值
        if (items == null || items.isEmpty()) {
            return value;
        }

        // 构建 value -> label 映射
        Map<String, String> valueToLabelMap = new HashMap<>(items.size());
        for (DictItem item : items) {
            if (item.getValue() != null) {
                valueToLabelMap.put(item.getValue(), item.getLabel());
            }
        }

        // 支持逗号分隔的多值
        String[] codes = value.split(",");
        StringBuilder sb = new StringBuilder();
        for (int i = 0; i < codes.length; i++) {
            String code = codes[i].trim();
            // 找不到返回原值
            String text = valueToLabelMap.getOrDefault(code, code);
            if (i > 0) {
                sb.append(",");
            }
            sb.append(text);
        }
        return sb.toString();
    }

    /**
     * 收集指定类及其嵌套类中所有 @Dict 注解的 dictCode
     */
    private Set<String> collectAllDictCodes(Class<?> clazz) {
        Set<String> codes = new HashSet<>();
        Field[] fields = getAllFields(clazz);
        for (Field field : fields) {
            Dict dict = field.getAnnotation(Dict.class);
            if (dict != null) {
                codes.add(dict.dictCode());
            }
        }
        return codes;
    }

    /**
     * 检查类是否有嵌套对象中包含 @Dict 字段
     */
    private boolean hasNestedDictFields(Class<?> clazz) {
        Field[] fields = getAllFields(clazz);
        for (Field field : fields) {
            if (!isJavaBasicType(field.getType()) && !field.getType().isEnum()
                    && field.getType() != List.class) {
                Set<String> nestedCodes = collectAllDictCodes(field.getType());
                if (!nestedCodes.isEmpty()) {
                    return true;
                }
            }
            // 检查 List 泛型内容太复杂,此处简化处理
        }
        return false;
    }

    /**
     * 确保所有需要的字典数据都已加载
     */
    private Map<String, List<DictItem>> ensureDictLoaded(Map<String, List<DictItem>> existing, Set<String> neededCodes) {
        Set<String> missingCodes = new HashSet<>();
        for (String code : neededCodes) {
            if (!existing.containsKey(code)) {
                missingCodes.add(code);
            }
        }

        if (missingCodes.isEmpty()) {
            return existing;
        }

        // 加载缺失的字典数据
        Map<String, List<DictItem>> additional = dictCacheService.batchGetDictItems(missingCodes);
        Map<String, List<DictItem>> merged = new HashMap<>(existing);
        merged.putAll(additional);
        return merged;
    }

    /**
     * 获取类的所有字段(含父类),使用缓存
     */
    private Field[] getAllFields(Class<?> clazz) {
        return FIELD_CACHE.computeIfAbsent(clazz, c -> {
            List<Field> fieldList = new ArrayList<>();
            Class<?> tempClass = c;
            while (tempClass != null && tempClass != Object.class) {
                fieldList.addAll(Arrays.asList(tempClass.getDeclaredFields()));
                tempClass = tempClass.getSuperclass();
            }
            return fieldList.toArray(new Field[0]);
        });
    }

    /**
     * 安全获取字段值
     */
    private Object getFieldValueSafe(Object obj, Field field) {
        try {
            return field.get(obj);
        } catch (Exception e) {
            return null;
        }
    }

    /**
     * 判断是否是 Java 基本类型或常见类型(不需要递归处理的类型)
     */
    private boolean isJavaBasicType(Class<?> clazz) {
        return clazz.isPrimitive()
                || clazz == String.class
                || clazz == Integer.class
                || clazz == Long.class
                || clazz == Double.class
                || clazz == Float.class
                || clazz == Boolean.class
                || clazz == Short.class
                || clazz == Byte.class
                || clazz == Character.class
                || clazz == java.math.BigDecimal.class
                || clazz == java.math.BigInteger.class
                || clazz == java.util.Date.class
                || clazz == java.time.LocalDate.class
                || clazz == java.time.LocalDateTime.class
                || Number.class.isAssignableFrom(clazz)
                || clazz.getName().startsWith("java.time.")
                || clazz == Map.class
                || Map.class.isAssignableFrom(clazz);
    }
}
  1. DictAspectConfig
java 复制代码
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.aop.aspectj.AspectJExpressionPointcut;
import org.springframework.aop.support.DefaultPointcutAdvisor;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;

/**
 * 字典翻译切面配置
 * <p>
 * 核心亮点:切面拦截路径通过 application.yml 配置,
 * 不同项目无需修改代码,只改配置即可复用。
 * </p>
 * <p>
 * 使用 {@link DefaultPointcutAdvisor} + {@link AspectJExpressionPointcut} 编程式方式,
 * 而非 @Aspect + @Pointcut,因为后者的表达式必须是编译时常量,无法通过 @Value 注入。
 * </p>
 */
@Configuration
public class DictAspectConfig {

    private static final Logger log = LoggerFactory.getLogger(DictAspectConfig.class);

    /**
     * 切面拦截表达式,各项目通过配置自定义
     * 默认拦截 com.shinho.eccp 下所有 web 包的 Controller 方法
     */
    @Value("${eccp.dict.pointcut-expression:execution(* com.shinho.eccp..web.*.*(..))}")
    private String pointcutExpression;

    @Autowired
    private DictTranslateInterceptor dictTranslateInterceptor;

    @Bean
    public DefaultPointcutAdvisor dictAdvisor() {
        AspectJExpressionPointcut pointcut = new AspectJExpressionPointcut();
        pointcut.setExpression(pointcutExpression);
        log.info("字典翻译切面已注册, pointcut={}", pointcutExpression);
        return new DefaultPointcutAdvisor(pointcut, dictTranslateInterceptor);
    }
}
相关推荐
今天AI了吗1 小时前
【AI智能体】Hermes Agent 从部署到项目实战操作详解
java·人工智能·python·milvus
生信星球1 小时前
空间转录组常规分析(大白话教程)
后端
青山木1 小时前
Hot 100 --- 搜索二维矩阵
java·算法·leetcode·矩阵
CoderYanger2 小时前
Java EE:9.JVM(课件内容-上篇)
java·jvm·程序人生·面试·职场和发展·java-ee·程序员创富
xiaoqiMikko2 小时前
Spring Cloud Config 这个 CVE 的补丁,Maven Central 上根本没有
java·spring boot
izhaorui2 小时前
【无标题】
后端
长栎2 小时前
AI 写的接口能用,但永远差点意思——这不是 prompt 的问题,是接口设计的品控规则它没装
后端
小p2 小时前
nextjs学习9: Next.js 渲染与缓存
前端·后端
残月心殇 请珍惜枸2 小时前
.NET陷阱之五:奇怪的OutOfMemoryException——大对象堆引起的问题与对策
java·jvm·.net