feat: 新增 Excel 多值字典转换器

- 新增 MultiDictConvert,支持半角逗号存储的多值字典字段
- 支持 String、Collection、Set、具体集合类和数组字段
- 未知字典 label/value 时整体转换失败,避免返回部分结果导致数据缺失
- 补充单值、多值、空值、顺序保持和失败场景单元测试
This commit is contained in:
YunaiV
2026-07-04 23:21:32 +08:00
parent 7b35b16240
commit e001bbbdbb
2 changed files with 502 additions and 0 deletions

View File

@ -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<Object> {
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<String> 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<String> convertToExcelData(Object object, ExcelContentProperty contentProperty,
GlobalConfiguration globalConfiguration) {
// 空时,返回空
if (object == null) {
return new WriteCellData<>("");
}
// 使用字典格式化
String type = getType(contentProperty);
List<String> values = convertToStringList(object);
List<String> 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<String> 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<String> 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<String> values) {
Class<?> elementClazz = getCollectionElementClazz(field);
Collection<Object> result = createCollection(field.getType());
for (String value : values) {
result.add(Convert.convert(elementClazz, value));
}
return result;
}
@SuppressWarnings("unchecked")
private static Collection<Object> createCollection(Class<?> fieldClazz) {
if (!fieldClazz.isInterface() && !Modifier.isAbstract(fieldClazz.getModifiers())) {
try {
return (Collection<Object>) 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<String> convertToStringList(Object object) {
List<String> 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<String> 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();
}
}

View File

@ -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<String> result = convert.convertToExcelData("1,2", mockContentProperty("stringValues"), null);
// 断言
assertEquals("男、女", result.getStringValue());
}
@Test
public void testConvertToExcelData_single() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData("1", mockContentProperty("stringValues"), null);
// 断言
assertEquals("", result.getStringValue());
}
@Test
public void testConvertToExcelData_order() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData("2,1", mockContentProperty("stringValues"), null);
// 断言
assertEquals("女、男", result.getStringValue());
}
@Test
public void testConvertToExcelData_null() {
// 调用
WriteCellData<String> result = convert.convertToExcelData(null, null, null);
// 断言
assertEquals("", result.getStringValue());
}
@Test
public void testConvertToExcelData_collection() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData(ListUtil.of(1, 2),
mockContentProperty("listValues"), null);
// 断言
assertEquals("男、女", result.getStringValue());
}
@Test
public void testConvertToExcelData_array() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> result = convert.convertToExcelData(new int[]{1, 2},
mockContentProperty("stringValues"), null);
// 断言
assertEquals("男、女", result.getStringValue());
}
@Test
public void testConvertToExcelData_unknownValue() throws NoSuchFieldException {
// mock 数据
mockDictDatas();
// 调用
WriteCellData<String> 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<Integer>) 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<Long>) 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<DictDataRespDTO> 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<Integer> listValues;
@DictFormat(DICT_TYPE)
private Set<Long> setValues;
@DictFormat(DICT_TYPE)
private HashSet<Long> hashSetValues;
@DictFormat(DICT_TYPE)
private Integer[] arrayValues;
}
}