From e001bbbdbbd05659eb0df41f59e2231aeb9875bd Mon Sep 17 00:00:00 2001 From: YunaiV Date: Sat, 4 Jul 2026 23:21:32 +0800 Subject: [PATCH] feat: 新增 Excel 多值字典转换器 --- yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvert.java | 207 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvertTest.java | 295 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 502 insertions(+), 0 deletions(-) create mode 100644 yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvert.java create mode 100644 yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvertTest.java diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvert.java b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvert.java new file mode 100644 index 0000000..a0678a7 --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/main/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvert.java @@ -0,0 +1,207 @@ +package cn.iocoder.yudao.framework.excel.core.convert; + +import cn.hutool.core.convert.Convert; +import cn.hutool.core.util.StrUtil; +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.idev.excel.converters.Converter; +import cn.idev.excel.enums.CellDataTypeEnum; +import cn.idev.excel.metadata.GlobalConfiguration; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import lombok.extern.slf4j.Slf4j; + +import java.lang.reflect.Array; +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.lang.reflect.ParameterizedType; +import java.lang.reflect.Type; +import java.util.ArrayList; +import java.util.Collection; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +/** + * Excel 多值数据字典转换器 + * + * 数据库存储值使用半角逗号分隔,例如 {@code 1,2} + * Excel 展示使用顿号分隔,例如 {@code 男、女} + * 使用时,需要在字段上同时配置 + * {@code @ExcelProperty(converter = MultiDictConvert.class)} 和 {@link DictFormat} + * + * @author NaCl + */ +@Slf4j +public class MultiDictConvert implements Converter { + + private static final String EXCEL_SEPARATOR = "、"; + private static final String DB_SEPARATOR = ","; + private static final String EXCEL_SEPARATOR_REGEX = "[、,,]"; + + @Override + public Class supportJavaTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public CellDataTypeEnum supportExcelTypeKey() { + throw new UnsupportedOperationException("暂不支持,也不需要"); + } + + @Override + public Object convertToJavaData(ReadCellData readCellData, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 空时,返回空值 + Field field = contentProperty.getField(); + String labels = readCellData.getStringValue(); + if (StrUtil.isBlank(labels)) { + return convertToFieldValue(field, new ArrayList<>()); + } + + // 使用字典解析 + String type = getType(contentProperty); + String[] labelArray = labels.split(EXCEL_SEPARATOR_REGEX); + List values = new ArrayList<>(labelArray.length); + for (String item : labelArray) { + String label = item.trim(); + if (StrUtil.isBlank(label)) { + continue; + } + String value = DictFrameworkUtils.parseDictDataValue(type, label); + if (value == null) { + log.error("[convertToJavaData][type({}) 解析不掉 label({})]", type, label); + return null; + } + values.add(value); + } + // 将 String 的 value 转换成对应的属性 + return convertToFieldValue(field, values); + } + + @Override + public WriteCellData convertToExcelData(Object object, ExcelContentProperty contentProperty, + GlobalConfiguration globalConfiguration) { + // 空时,返回空 + if (object == null) { + return new WriteCellData<>(""); + } + + // 使用字典格式化 + String type = getType(contentProperty); + List values = convertToStringList(object); + List labels = new ArrayList<>(values.size()); + for (String value : values) { + String label = DictFrameworkUtils.parseDictDataLabel(type, value); + if (label == null) { + log.error("[convertToExcelData][type({}) 转换不了 value({})]", type, value); + return new WriteCellData<>(""); + } + labels.add(label); + } + // 生成 Excel 小表格 + return new WriteCellData<>(String.join(EXCEL_SEPARATOR, labels)); + } + + private static Object convertToFieldValue(Field field, List values) { + Class fieldClazz = field.getType(); + if (String.class == fieldClazz) { + return String.join(DB_SEPARATOR, values); + } + if (fieldClazz.isArray()) { + return convertToArray(fieldClazz.getComponentType(), values); + } + if (Collection.class.isAssignableFrom(fieldClazz)) { + return convertToCollection(field, values); + } + return Convert.convert(fieldClazz, String.join(DB_SEPARATOR, values)); + } + + private static Object convertToArray(Class componentType, List values) { + Object array = Array.newInstance(componentType, values.size()); + for (int i = 0; i < values.size(); i++) { + Array.set(array, i, Convert.convert(componentType, values.get(i))); + } + return array; + } + + private static Collection convertToCollection(Field field, List values) { + Class elementClazz = getCollectionElementClazz(field); + Collection result = createCollection(field.getType()); + for (String value : values) { + result.add(Convert.convert(elementClazz, value)); + } + return result; + } + + @SuppressWarnings("unchecked") + private static Collection createCollection(Class fieldClazz) { + if (!fieldClazz.isInterface() && !Modifier.isAbstract(fieldClazz.getModifiers())) { + try { + return (Collection) fieldClazz.getDeclaredConstructor().newInstance(); + } catch (Exception ignored) { + // 使用默认集合实现 + } + } + return Set.class.isAssignableFrom(fieldClazz) ? new LinkedHashSet<>() : new ArrayList<>(); + } + + private static Class getCollectionElementClazz(Field field) { + Type genericType = field.getGenericType(); + if (!(genericType instanceof ParameterizedType)) { + return String.class; + } + Type actualType = ((ParameterizedType) genericType).getActualTypeArguments()[0]; + if (actualType instanceof Class) { + return (Class) actualType; + } + if (actualType instanceof ParameterizedType + && ((ParameterizedType) actualType).getRawType() instanceof Class) { + return (Class) ((ParameterizedType) actualType).getRawType(); + } + return String.class; + } + + private static List convertToStringList(Object object) { + List values = new ArrayList<>(); + if (object instanceof String) { + String[] valueArray = ((String) object).split(DB_SEPARATOR); + for (String value : valueArray) { + addStringValue(values, value); + } + return values; + } + if (object instanceof Collection) { + for (Object item : (Collection) object) { + addStringValue(values, item); + } + return values; + } + if (object.getClass().isArray()) { + int length = Array.getLength(object); + for (int i = 0; i < length; i++) { + addStringValue(values, Array.get(object, i)); + } + return values; + } + addStringValue(values, object); + return values; + } + + private static void addStringValue(List values, Object value) { + if (value == null) { + return; + } + String str = String.valueOf(value).trim(); + if (StrUtil.isBlank(str)) { + return; + } + values.add(str); + } + + private static String getType(ExcelContentProperty contentProperty) { + return contentProperty.getField().getAnnotation(DictFormat.class).value(); + } + +} diff --git a/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvertTest.java b/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvertTest.java new file mode 100644 index 0000000..202695c --- /dev/null +++ b/yudao-framework/yudao-spring-boot-starter-excel/src/test/java/cn/iocoder/yudao/framework/excel/core/convert/MultiDictConvertTest.java @@ -0,0 +1,295 @@ +package cn.iocoder.yudao.framework.excel.core.convert; + +import cn.hutool.core.collection.ListUtil; +import cn.idev.excel.metadata.data.ReadCellData; +import cn.idev.excel.metadata.data.WriteCellData; +import cn.idev.excel.metadata.property.ExcelContentProperty; +import cn.iocoder.yudao.framework.common.biz.system.dict.DictDataCommonApi; +import cn.iocoder.yudao.framework.common.biz.system.dict.dto.DictDataRespDTO; +import cn.iocoder.yudao.framework.dict.core.DictFrameworkUtils; +import cn.iocoder.yudao.framework.excel.core.annotations.DictFormat; +import cn.iocoder.yudao.framework.test.core.ut.BaseMockitoUnitTest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mock; + +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.HashSet; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +import static cn.iocoder.yudao.framework.test.core.util.RandomUtils.randomPojo; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +/** + * {@link MultiDictConvert} 的单元测试 + * + * @author NaCl + */ +public class MultiDictConvertTest extends BaseMockitoUnitTest { + + private static final String DICT_TYPE = "test_dict"; + + @Mock + private DictDataCommonApi dictDataApi; + + private MultiDictConvert convert; + + @BeforeEach + public void setUp() { + convert = new MultiDictConvert(); + DictFrameworkUtils.init(dictDataApi); + DictFrameworkUtils.clearCache(); + } + + @Test + public void testConvertToExcelData_string() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + WriteCellData result = convert.convertToExcelData("1,2", mockContentProperty("stringValues"), null); + // 断言 + assertEquals("男、女", result.getStringValue()); + } + + @Test + public void testConvertToExcelData_single() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + WriteCellData result = convert.convertToExcelData("1", mockContentProperty("stringValues"), null); + // 断言 + assertEquals("男", result.getStringValue()); + } + + @Test + public void testConvertToExcelData_order() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + WriteCellData result = convert.convertToExcelData("2,1", mockContentProperty("stringValues"), null); + // 断言 + assertEquals("女、男", result.getStringValue()); + } + + @Test + public void testConvertToExcelData_null() { + // 调用 + WriteCellData result = convert.convertToExcelData(null, null, null); + // 断言 + assertEquals("", result.getStringValue()); + } + + @Test + public void testConvertToExcelData_collection() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + WriteCellData result = convert.convertToExcelData(ListUtil.of(1, 2), + mockContentProperty("listValues"), null); + // 断言 + assertEquals("男、女", result.getStringValue()); + } + + @Test + public void testConvertToExcelData_array() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + WriteCellData result = convert.convertToExcelData(new int[]{1, 2}, + mockContentProperty("stringValues"), null); + // 断言 + assertEquals("男、女", result.getStringValue()); + } + + @Test + public void testConvertToExcelData_unknownValue() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + WriteCellData result = convert.convertToExcelData("1,3", mockContentProperty("stringValues"), null); + // 断言 + assertEquals("", result.getStringValue()); + } + + @Test + public void testConvertToJavaData_string() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + Object result = convert.convertToJavaData(mockReadCellData("男、女"), + mockContentProperty("stringValues"), null); + // 断言 + assertEquals("1,2", result); + } + + @Test + public void testConvertToJavaData_single() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + Object result = convert.convertToJavaData(mockReadCellData("男"), + mockContentProperty("stringValues"), null); + // 断言 + assertEquals("1", result); + } + + @Test + public void testConvertToJavaData_blank() throws NoSuchFieldException { + // 调用 + Object result = convert.convertToJavaData(mockReadCellData(""), + mockContentProperty("stringValues"), null); + // 断言 + assertEquals("", result); + } + + @Test + public void testConvertToJavaData_blankList() throws NoSuchFieldException { + // 调用 + Object result = convert.convertToJavaData(mockReadCellData(""), + mockContentProperty("listValues"), null); + // 断言 + assertTrue(result instanceof List); + assertTrue(((List) result).isEmpty()); + } + + @Test + public void testConvertToJavaData_blankSet() throws NoSuchFieldException { + // 调用 + Object result = convert.convertToJavaData(mockReadCellData(""), + mockContentProperty("setValues"), null); + // 断言 + assertTrue(result instanceof LinkedHashSet); + assertTrue(((Set) result).isEmpty()); + } + + @Test + public void testConvertToJavaData_blankArray() throws NoSuchFieldException { + // 调用 + Object result = convert.convertToJavaData(mockReadCellData(""), + mockContentProperty("arrayValues"), null); + // 断言 + assertArrayEquals(new Integer[0], (Integer[]) result); + } + + @Test + @SuppressWarnings("unchecked") + public void testConvertToJavaData_list() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + Object result = convert.convertToJavaData(mockReadCellData("男、女"), + mockContentProperty("listValues"), null); + // 断言 + assertEquals(ListUtil.of(1, 2), (List) result); + } + + @Test + @SuppressWarnings("unchecked") + public void testConvertToJavaData_set() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + Object result = convert.convertToJavaData(mockReadCellData("男,女"), + mockContentProperty("setValues"), null); + // 断言 + assertEquals(new LinkedHashSet<>(Arrays.asList(1L, 2L)), (Set) result); + } + + @Test + @SuppressWarnings("SimplifiableAssertion") + public void testConvertToJavaData_hashSet() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + Object result = convert.convertToJavaData(mockReadCellData("男,女"), + mockContentProperty("hashSetValues"), null); + // 断言 + assertTrue(result instanceof HashSet); + assertEquals(new HashSet<>(Arrays.asList(1L, 2L)), result); + } + + @Test + public void testConvertToJavaData_array() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + Object result = convert.convertToJavaData(mockReadCellData("男,女"), + mockContentProperty("arrayValues"), null); + // 断言 + assertArrayEquals(new Integer[]{1, 2}, (Integer[]) result); + } + + @Test + public void testConvertToJavaData_unknownLabel() throws NoSuchFieldException { + // mock 数据 + mockDictDatas(); + + // 调用 + Object result = convert.convertToJavaData(mockReadCellData("男、未知"), + mockContentProperty("stringValues"), null); + // 断言 + assertNull(result); + } + + private void mockDictDatas() { + List dictDatas = ListUtil.of( + randomPojo(DictDataRespDTO.class, o -> o.setDictType(DICT_TYPE).setValue("1").setLabel("男")), + randomPojo(DictDataRespDTO.class, o -> o.setDictType(DICT_TYPE).setValue("2").setLabel("女")) + ); + when(dictDataApi.getDictDataList(eq(DICT_TYPE))).thenReturn(dictDatas); + } + + private static ReadCellData mockReadCellData(String value) { + ReadCellData readCellData = mock(ReadCellData.class); + when(readCellData.getStringValue()).thenReturn(value); + return readCellData; + } + + private static ExcelContentProperty mockContentProperty(String fieldName) throws NoSuchFieldException { + Field field = TestExcelVO.class.getDeclaredField(fieldName); + ExcelContentProperty contentProperty = mock(ExcelContentProperty.class); + when(contentProperty.getField()).thenReturn(field); + return contentProperty; + } + + static class TestExcelVO { + + @DictFormat(DICT_TYPE) + private String stringValues; + + @DictFormat(DICT_TYPE) + private List listValues; + + @DictFormat(DICT_TYPE) + private Set setValues; + + @DictFormat(DICT_TYPE) + private HashSet hashSetValues; + + @DictFormat(DICT_TYPE) + private Integer[] arrayValues; + + } + +} -- libgit2 0.21.4