EasyExcel实现图片导出功能(记录)

背景:在旧系统的基础上,导出一些工单信息时,现需要新添加处理人的签名或者签章,这就涉及图片的上传、下载、写入等几个操作。

1、EasyExcel工具类

(1)支持下拉框的导出。

java 复制代码
import com.alibaba.excel.EasyExcelFactory;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.builder.ExcelWriterBuilder;
import com.alibaba.excel.write.handler.SheetWriteHandler;
import com.alibaba.excel.write.handler.WriteHandler;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.excel.write.metadata.holder.WriteSheetHolder;
import com.alibaba.excel.write.metadata.holder.WriteWorkbookHolder;
import com..common.handler.DropDownHandler;
import com..modules.device.convert.ImageCellWriteHandler;
import com..modules.device.domain.vo.DeviceAndTypeVO;
import lombok.extern.slf4j.Slf4j;
import lombok.var;
import org.apache.poi.ss.usermodel.*;
import org.apache.poi.xssf.usermodel.XSSFClientAnchor;

import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.List;

@Slf4j
public class ExcelExportUtil {

    private static final String CHARACTER = "UTF-8";

    private static final String CONTENT_TYPE = "application/vnd.ms-excel;charset=utf-8";

    private static final String CONTENT_DISPOSITION = "Content-Disposition";

    private static final String CACHE_CONTROL = "Cache-Control";

    private static final String NO_STORE = "no-store";

    private static final String MAX_AGE = "max-age=0";

    private static final String PASSWORD = "excel_unlock";

    private static final Integer colSplit = 0;

    private static final Integer rowSplit = 1;

    private static final Integer leftmostColumn = 0;

    private static final Integer topRow = 1; 
/**
     * 通用导出 Excel 方法(支持下拉框和每行图片插入)
     *
     * @param response         HttpServletResponse
     * @param data             导出数据,数据行顺序与 Excel 中顺序一致(表头在第0行,第一条数据为第1行)
     * @param clazz            数据模型 Class
     * @param fileName         导出文件名(不包含后缀)
     * @param dropDownHandler  下拉框处理器(可选,可传 null)
     * @param imagesList       每条记录对应的图片数据列表,List中每个元素为 byte[2] 数组,
     *                         第0位为签名图片, 第1位为签章图片(可有可无)
     * @param signCol          签名图片插入列(0-based)
     * @param signatureCol     签章图片插入列(0-based)
     * @throws IOException     I/O 异常时抛出
     */
    public static void exportExcel(HttpServletResponse response, List<?> data, Class<?> clazz, String fileName,
                                   Object dropDownHandler, List<byte[][]> imagesList,
                                   int signCol, int signatureCol) throws IOException {
        String fullFileName = fileName + ".xlsx";
        String encodedFileName = URLEncoder.encode(fullFileName, StandardCharsets.UTF_8.toString());

        response.setCharacterEncoding(CHARACTER);
        response.setContentType(CONTENT_TYPE);
        response.setHeader(CONTENT_DISPOSITION, "attachment; filename=" + encodedFileName);
        response.setHeader(CACHE_CONTROL, NO_STORE);
        response.addHeader(CACHE_CONTROL, MAX_AGE);

        ServletOutputStream out = null;
        ExcelWriter excelWriter = null;
        try {
            out = response.getOutputStream();
            ExcelWriterBuilder writerBuilder = EasyExcelFactory.write(out, clazz);

            // 注册下拉框处理器(需确保 dropDownHandler 是 WriteHandler 类型)
            if (dropDownHandler != null && dropDownHandler instanceof WriteHandler) {
                writerBuilder.registerWriteHandler((WriteHandler) dropDownHandler);
            }

            excelWriter = writerBuilder.build();
            WriteSheet writeSheet = EasyExcelFactory.writerSheet(fileName).build();
            excelWriter.write(data, writeSheet);

            // 获取 Workbook 和 Sheet
            Workbook workbook = excelWriter.writeContext().writeWorkbookHolder().getWorkbook();
            Sheet sheet = workbook.getSheetAt(0);

            // 遍历数据行插入图片(从第1行开始)
            if (imagesList != null && !imagesList.isEmpty()) {
                for (int i = 0; i < data.size(); i++) {
                    int targetRowIndex = i + 1; // 数据行从第1行开始
                    if (i < imagesList.size()) {
                        byte[][] images = imagesList.get(i);
                        if (images != null) {
                            insertImages(sheet, workbook, targetRowIndex, images, signCol, signatureCol);
                        }
                    }
                }
            }

            // 必须显式调用 finish 确保数据写入流
            excelWriter.finish();
            out.flush();
        } catch (Exception e) {
            throw new IOException("导出 Excel 失败: " + e.getMessage(), e);
        } finally {
            // 手动关闭资源(注意顺序:先关闭 excelWriter,再关闭流)
            if (excelWriter != null) {
                excelWriter.finish(); // finish() 包含关闭操作
            }
            if (out != null) {
                try {
                    out.close();
                } catch (IOException e) {
                    log.error("关闭输出流异常: ", e);
                }
            }
        }
    }

 /**
     * 通用的图片插入方法,在指定的 Excel 行中插入签名和签章图片
     *
     * @param sheet         目标 Sheet
     * @param workbook      Excel Workbook
     * @param targetRow     目标行索引(0-based;表头为0,第一条数据为1)
     * @param images        byte[2] 数组,其中 images[0] 为签名图片,images[1] 为签章图片
     * @param signCol       签名图片插入列(0-based)
     * @param signatureCol  签章图片插入列(0-based)
     */
    private static void insertImages(Sheet sheet, Workbook workbook, int targetRow, byte[][] images,
                                     int signCol, int signatureCol) {
        if (sheet == null || workbook == null) {
            return;
        }
        Row row = sheet.getRow(targetRow);
        if (row == null) {
            row = sheet.createRow(targetRow);
        }
        Drawing<?> drawing = sheet.createDrawingPatriarch();

        if (images[0] != null && images[0].length > 0) {
            int pictureIdx = workbook.addPicture(images[0], Workbook.PICTURE_TYPE_PNG);
            ClientAnchor anchor = new XSSFClientAnchor(0, 0, 1023, 255, signCol, targetRow, signCol + 1, targetRow + 1);
            drawing.createPicture(anchor, pictureIdx);
        }
        if (images[1] != null && images[1].length > 0) {
            int pictureIdx = workbook.addPicture(images[1], Workbook.PICTURE_TYPE_PNG);
            ClientAnchor anchor = new XSSFClientAnchor(0, 0, 1023, 255, signatureCol, targetRow, signatureCol + 1, targetRow + 1);
            drawing.createPicture(anchor, pictureIdx);
        }
    }
}

(2)无下拉框实现

java 复制代码
/**
     * 配合insertImages(Sheet sheet, Workbook workbook, byte[] signImage, byte[] signatureImage,
     *                                      int targetRowIndex, int signCol, int signatureCol) 方法使用
     * @param response
     * @param data
     * @param clazz
     * @param fileName
     * @param signImage
     * @param signatureImage
     * @param targetRow
     * @param signCol
     * @param signatureCol
     * @throws IOException
     */
    public static void exportExcel(HttpServletResponse response, List<?> data, Class<?> clazz, String fileName,
                                   byte[] signImage, byte[] signatureImage,
                                   int targetRow, int signCol, int signatureCol) throws IOException {
        String encodedFileName = URLEncoder.encode(fileName + ".xlsx", "UTF-8");

        response.setCharacterEncoding("UTF-8");
        response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
        response.setHeader("Content-Disposition", "attachment; filename=" + encodedFileName);
        response.setHeader("Cache-Control", "no-store");
        response.addHeader("Cache-Control", "max-age=0");

        ExcelWriter excelWriter = null;
        ServletOutputStream out = null;
        try {
            out = response.getOutputStream();
            excelWriter = EasyExcelFactory.write(out, clazz).build();

            WriteSheet writeSheet = EasyExcelFactory.writerSheet(fileName).build();
            excelWriter.write(data, writeSheet);

            // 获取 Workbook 和 Sheet
            Workbook workbook = excelWriter.writeContext().writeWorkbookHolder().getWorkbook();
            Sheet sheet = workbook.getSheetAt(0);

            // 插入图片
            insertImages(sheet, workbook, signImage, signatureImage, targetRow, signCol, signatureCol);

            // 必须显式调用 finish 确保写入完成
            excelWriter.finish();
            out.flush();
        } finally {
            // 手动关闭资源(EasyExcel 3.x 中 finish() 已包含关闭操作)
            if (excelWriter != null) {
                excelWriter.finish(); // 确保资源释放
            }
            if (out != null) {
                out.close();
            }
        }
    }
 /**
     * 通用inserImages方法,和没有的DropDownHandler dropDownHandler参数的exportExcel方法一起配合使用
     * @param sheet
     * @param workbook
     * @param signImage
     * @param signatureImage
     * @param targetRowIndex
     * @param signCol
     * @param signatureCol
     */
    private static void insertImages(Sheet sheet, Workbook workbook, byte[] signImage, byte[] signatureImage,
                                     int targetRowIndex, int signCol, int signatureCol) {
        if (sheet == null || workbook == null) {
            log.warn("插入图片失败: sheet 或 workbook 为空");
            return;
        }

        // 确保目标行存在
        Row row = sheet.getRow(targetRowIndex);
        if (row == null) {
            row = sheet.createRow(targetRowIndex);
        }
        Drawing<?> drawing = sheet.createDrawingPatriarch();

        // 插入签名图片
        if (signImage != null && signImage.length > 0) {
            int pictureIdx = workbook.addPicture(signImage, Workbook.PICTURE_TYPE_PNG);
            ClientAnchor anchor = new XSSFClientAnchor(0, 0, 1023, 255, signCol, targetRowIndex, signCol + 1, targetRowIndex + 1);
            drawing.createPicture(anchor, pictureIdx);
        }

        // 插入签章图片
        if (signatureImage != null && signatureImage.length > 0) {
            int pictureIdx = workbook.addPicture(signatureImage, Workbook.PICTURE_TYPE_PNG);
            ClientAnchor anchor = new XSSFClientAnchor(0, 0, 1023, 255, signatureCol, targetRowIndex, signatureCol + 1, targetRowIndex + 1);
            drawing.createPicture(anchor, pictureIdx);
        }
    }

2、实体类和自定义转换类

java 复制代码
//使用自定义转换器,写入空内容,但表头显示"签名"
    @ExcelProperty(value = "处理人签名", converter = ImageHeaderConverter.class, index = 15)
    private byte[] signImage;


    // 使用自定义转换器,写入空内容,但表头显示"签名"
    @ExcelProperty(value = "处理人签章", converter = ImageHeaderConverter.class, index = 16)
    private byte[] signatureImage;

/**
 * 自定义Converter
 */
public class ImageHeaderConverter implements Converter<byte[]> {

    @Override
    public Class supportJavaTypeKey() {
        return byte[].class;
    }

    @Override
    public WriteCellData<?> convertToExcelData(byte[] value, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
        // 返回空数据,防止自动转换报错,但表头信息仍会根据@ExcelProperty的value显示
        return new WriteCellData<>("");
    }

    @Override
    public CellDataTypeEnum supportExcelTypeKey() {
        return null;
    }


    public byte[] convertToJavaData(CellData cellData, ExcelContentProperty contentProperty, GlobalConfiguration globalConfiguration) {
        return new byte[0];
    }
}

3、根据图片路径下载图片工具类

java 复制代码
public class ImagesUtils {

    public static byte[] downloadImage(String imageUrl) {
        if (imageUrl == null || imageUrl.trim().isEmpty()) {
            log.warn("下载图片失败: imageUrl 为空");
            return null;
        }

        HttpURLConnection connection = null;
        try {
            URL url = new URL(imageUrl);
            connection = (HttpURLConnection) url.openConnection();
            connection.setRequestMethod("GET");
            connection.setConnectTimeout(10000);  // 增加超时时间到10秒
            connection.setReadTimeout(10000);
            connection.setInstanceFollowRedirects(true); // 允许自动重定向

            // 检查 HTTP 响应码
            int statusCode = connection.getResponseCode();
            if (statusCode != HttpURLConnection.HTTP_OK) {
                log.warn("下载图片失败: {},HTTP状态码: {},响应消息: {}",
                        imageUrl, statusCode, connection.getResponseMessage());
                return null;
            }

            try (InputStream inputStream = connection.getInputStream();
                 ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) {

                byte[] buffer = new byte[4096];  // 增大缓冲区提升读取效率
                int bytesRead;
                while ((bytesRead = inputStream.read(buffer)) != -1) {
                    outputStream.write(buffer, 0, bytesRead);
                }
                return outputStream.toByteArray();
            }
        } catch (Exception e) {
            log.warn("下载图片失败: {},错误类型: {},详细信息: {}",
                    imageUrl, e.getClass().getSimpleName(), e.getMessage(), e); // 输出完整异常
            return null;
        } finally {
            if (connection != null) {
                connection.disconnect(); // 显式断开连接
            }
        }
    }
}

4、业务逻辑实现

java 复制代码
// 1. 查询 Channel_SIGN_SIGNATURE 表,获取所有处理记录
        List<ChannelSignSignature> processList = channelSignSignatureMapper.selectList(
                new LambdaQueryWrapper<ChannelSignSignature>().in(ChannelSignSignature::getDisposeChannelId, ids)
        );
        if (CollectionUtils.isEmpty(processList)) {
            return ReposeResult.failed(ResultCode.CHANNEL_EXPORT_NULL);
        }
        //根据前端传入的ids顺序排序处理记录
        Map<Long, ChannelSignSignature> processMap = processList.stream()
                .collect(Collectors.toMap(ChannelSignSignature::getDisposeChannelId, Function.identity()));
        List<ChannelSignSignature> sortedProcessList =  new ArrayList<>();
        for (Long id :
                ids) {
            if (!processMap.containsKey(id)) {
                return ReposeResult.failed("部分导出ID未保存处理记录,请先处理记录再导出");
            }
            sortedProcessList.add(processMap.get(id));
        }

        // 2. 获取所有处理人姓名(保持原顺序)
        List<String> userNames = sortedProcessList.stream()
                .map(ChannelSignSignature::getChannelRecordName)
                .collect(Collectors.toList());

        // 3. 批量查询用户信息(userId 映射表)
        Map<String, Long> userIdMap;
        if (CollectionUtils.isEmpty(userNames)) {
            userIdMap = new HashMap<>();
        } else {
            List<User> users = userMapper.selectList(
                    new LambdaQueryWrapper<User>().in(User::getUsername, userNames)
            );
            userIdMap = users.stream()
                    .collect(Collectors.toMap(User::getUsername, User::getId, (u1, u2) -> u1));
        }

        // 4. 查询用户签名 & 签章
        Map<Long, List<String>> signMap = new HashMap<>();
        Map<Long, List<String>> signatureMap = new HashMap<>();

        if (!userIdMap.isEmpty()) {
            // 4.1 批量查询用户签名(userId -> 签名列表)
            List<UserSign> userSigns = userSignMapper.selectList(
                    new LambdaQueryWrapper<UserSign>().in(UserSign::getUserId, userIdMap.values())
            );
            signMap = userSigns.stream()
                    .collect(Collectors.groupingBy(
                            UserSign::getUserId,
                            LinkedHashMap::new,
                            Collectors.mapping(UserSign::getContent, Collectors.toList())
                    ));

            // 4.2 批量查询用户签章(userId -> 签章列表)
            List<UserSignature> userSignatures = userSignatureMapper.selectList(
                    new LambdaQueryWrapper<UserSignature>().in(UserSignature::getUserId, userIdMap.values())
            );
            signatureMap = userSignatures.stream()
                    .collect(Collectors.groupingBy(
                            UserSignature::getUserId,
                            LinkedHashMap::new,
                            Collectors.mapping(UserSignature::getContent, Collectors.toList())
                    ));
        }
        Integer grading = UserUtils.getUser().getGrading();
        HashMap<Integer, String[]> dropDownMap = new HashMap<>();
        String[] results = Arrays.stream(ChannelResultEnum.values()).map(ChannelResultEnum::getDesc).toArray(String[]::new);
        dropDownMap.put(13, results);
        List<ChannelVO> channels = channelMapper.selectJoinList(ChannelVO.class, new MPJLambdaWrapper<>(DataChannel.class)
                .selectAll(DataChannel.class)
                .select(Dept::getDeptName)
                .selectAs(Period::getDescription, "period")
                .select(BenchInfo::getBench)
                .selectCollection(MachineInfo.class, ChannelVO::getMachineInfos)
                .leftJoin(ChannelModel.class, ChannelModel::getChannelId, DataChannel::getId)
                .leftJoin(MachineInfo.class, MachineInfo::getId, ChannelModel::getModelId)
                .leftJoin(Dept.class, Dept::getId, DataChannel::getDeptId)
                .leftJoin(Period.class, Period::getId, DataChannel::getPeriodId)
                .leftJoin(BenchInfo.class, BenchInfo::getId, DataChannel::getBenchId)
                .ge(ObjectUtils.isNotEmpty(grading), DataChannel::getGrading, grading)
                .in(DataChannel::getId, ids));
        channels.forEach(channelVO -> {
            List<String> models = channelVO.getMachineInfos().stream().map(MachineInfo::getModel).collect(Collectors.toList());
            channelVO.setModel(String.join(",", models));
        });

        //根据前端传入的ids顺序排序channelVO
        Map<Long, ChannelVO> channelVoMap= channels.stream()
                .collect(Collectors.toMap(ChannelVO::getId, Function.identity()));
        List<ChannelVO> sortedChannelVOs = new ArrayList<>();
        for (Long id:
                ids
             ) {
            if (!channelVoMap.containsKey(id)) {
                return ReposeResult.failed("部分导出ID未保存检查记录,请先保存处理记录再导出");
            }
            sortedChannelVOs.add(channelVoMap.get(id));
        }

        List<ExportChannelDTO> exportChannelDTOS = CopyUtils.copyList(sortedChannelVOs, ExportChannelDTO.class);

        // 5. 按 `userNames` 生成 `signUrls` 和 `signatureUrls`(保证顺序)
        List<String> signUrls = new ArrayList<>();
        List<String> signatureUrls = new ArrayList<>();
        for (String userName : userNames) {
            Long userId = userIdMap.get(userName);
            if (userId != null) {
                List<String> signs = signMap.getOrDefault(userId, Collections.emptyList());
                List<String> signatures = signatureMap.getOrDefault(userId, Collections.emptyList());

                // 如果有签名,添加签名;如果有签章,添加签章;如果都有,则都添加
                if (!signs.isEmpty()) {
                    signUrls.addAll(signs);
                } else {
                    signUrls.add(null);
                }

                if (!signatures.isEmpty()) {
                    signatureUrls.addAll(signatures);
                } else {
                    signatureUrls.add(null);
                }
            } else {
                // 处理人无签名/签章,则填充 null
                signUrls.add(null);
                signatureUrls.add(null);
            }
        }
        // 7. 赋值到 DTO
        for (int i = 0; i <  exportChannelDTOS.size(); i++) {
            ExportChannelDTO dto =  exportChannelDTOS.get(i);

            // 取签名图片
            if (signUrls.get(i) != null) {
                dto.setSignImage(downloadImage(BASE_IMAGE_URL + signUrls.get(i)));
            } else {
                dto.setSignImage(null);
            }
            // 取签章图片
            if (signatureUrls.get(i) != null) {
                dto.setSignatureImage(downloadImage(BASE_IMAGE_URL + signatureUrls.get(i)));
            } else {
                dto.setSignatureImage(null);
            }
        }

        // 8. 构造图片数据列表,与 checkDTOS 顺序一致
        List<byte[][]> imagesList = exportChannelDTOS.stream()
                .map(dto -> new byte[][]{
                        dto.getSignImage() != null ? dto.getSignImage() : new byte[0],
                        dto.getSignatureImage() != null ? dto.getSignatureImage() : new byte[0]
                })
                .collect(Collectors.toList());
        ExcelExportUtil.exportExcel(
                response,
                exportChannelDTOS,
                ExportChannelDTO.class,
                ConstantUtil.DATA_CHANNEL,
                new DropDownHandler(dropDownMap),
                imagesList,
                15,
                16
        );
        log.info("---导出数据通道信息完成---");         

代码不可直接使用,只是自己记录一下,方便下次使用,你完全可以使用AI来帮你写这些部分。