【Springboot9】将业务模块数据导出为PDF

问题:在管理系统开发中,经常会遇到这样一类需求:

  • 页面展示的是数据库中的业务数据

  • 用户希望把这些数据导出成正式的 PDF 打印单

  • 一条业务数据是一张单据

  • 列表页可以勾选多条,导出成一个 PDF 文件

  • PDF 上半部分展示主表唯一信息

  • PDF 下半部分展示这条业务对应的多条明细记录

  • 导出后还希望保存到附件表,便于追溯和下载

一、需求拆解

这次实现的业务背景是:

主表:t_reagent
明细表:t_reagent_record
领用接口:/control/reagent/receiveReagents
新增导出接口:/control/reagent/exportPrintPdf

导出的 PDF 页面结构如下:

上半部分展示试剂唯一信息:物料名称、物料货号、物料分类、物料类型、规格、型号、批号、生产商、品牌、存储条件、现库存量、保管人、备注

**下半部分展示这条试剂对应的多条操作记录:**操作类型、操作时间、数量、单位、单价、部门操作人、批号、备注、审核人

实现: 单条导出、批量导出多条数据、合并到同一个 PDF 中、可选保存到附件表 t_base_file

二、如何设计

拆成四步,为了可复用不可合并一步

复制代码
Controller
-> Service
-> Assembler(组装器)
-> PDF Render Tool(渲染工具)
-> Optional Attachment Save(可选附件保存)

三、整体架构图

复制代码
前端 / ApiPost
    |
    | POST /control/reagent/exportPrintPdf
    v
ReagentController
    |
    v
ReagentServiceImpl.exportPrintPdf(...)
    |
    |-- 查询 t_reagent 主表
    |-- 查询 t_reagent_record 明细表
    |-- 按 reagentId 分组
    v
ReagentPrintAssembler
    |
    |-- 组装 ReagentPrintDTO
    v
PdfRenderUtil
    |
    |-- 生成 PDF byte[]
    |
    |-- 可选:保存到 t_base_file
    v
HttpServletResponse 输出 PDF 下载

四、加 PDF 依赖

java 复制代码
<dependency>
    <groupId>com.github.librepdf</groupId>
    <artifactId>openpdf</artifactId>
    <version>1.3.39</version>
</dependency>

五、接口设计

basemanage/platform/main/reagent/controller/**ReagentController.**java

新增接口

java 复制代码
@PostMapping("/exportPrintPdf")
void exportPrintPdf(@RequestBody ReagentPrintRequest request, HttpServletResponse response);

请求对象

文件位置:

basemanage/platform/main/reagent/bean/ReagentPrintRequest.java

java 复制代码
@Data
public class ReagentPrintRequest {
    private List<String> ids;
    private Boolean saveAttachment;
}

请求示例

java 复制代码
{
  "ids": ["试剂主表ID1", "试剂主表ID2"],
  "saveAttachment": true
}

这里传的是t_reagent.id,不是t_reagent_record.id。

六、为什么接口返回 void

一开始很容易写成这样:

java 复制代码
JsonResult exportPrintPdf(...)

但这个接口本质是文件下载接口 ,而不是普通 JSON 接口

如果你一边往HttpServletResponse输出 PDF,一边又返回JsonResult,在@RestController环境下很容易出现:

  • 二次写响应
  • 文件流和 JSON 混在一起
  • 前端下载失败

所以最后改成:

java 复制代码
void exportPrintPdf(...)

Controller 实现如下。

文件位置

basemanage/platform/main/reagent/controller/ReagentControllerImpl.java

java 复制代码
@Override
public void exportPrintPdf(ReagentPrintRequest request, HttpServletResponse response) {
    getService().exportPrintPdf(request, response);
}

七、Service 接口定义

basemanage/platform/main/reagent/service/**ReagentService.**java

java 复制代码
void exportPrintPdf(ReagentPrintRequest request, HttpServletResponse response);

八、批量查询明细记录

因为是批量导出,所以一定不要一条试剂查一次记录,那样很容易变成 **N+1**查询。

正确做法是:

  1. 一次查所有主表数据
  2. 一次查所有记录数据
  3. 内存里按reagentId分组

所以先给ReagentRecordMapper增加一个批量查询方法。

1. Mapper 接口

文件位置:

basemanage/platform/main/reagent/dao/ReagentRecordMapper.java

java 复制代码
List<ReagentRecordBean> selectListByReagentIds(@Param("reagentIds") List<String> reagentIds);

2. Mapper XML

文件位置:

src/main/resources/mapper/ReagentRecordMapper.xml

XML 复制代码
<select id="selectListByReagentIds" resultType="com.px.basemanage.platform.main.reagent.bean.ReagentRecordBean">
    select t.*, r.batchNo
    from t_reagent_record t
    left join t_reagent r on r.id = t.reagentId
    where t.reagentId in
    <foreach collection="reagentIds" item="reagentId" open="(" close=")" separator=",">
        #{reagentId}
    </foreach>
    order by t.reagentId, t.operateTime desc, t.createTime desc
</select>

为什么这里联查了 t_reagent

因为ReagentRecordBean里有一个非持久化字段:

java 复制代码
@TableField(exist = false)
private String batchNo;

这样在打印记录时,可以直接展示批号。

九、定义打印用 DTO,而不是直接拿实体渲染

不要直接拿 ReagentBeanReagentRecordBean 去画 PDF,而应该先定义打印模型。

1. 打印主 DTO

basemanage/platform/main/reagent/service/ReagentPrintAssembler.java

java 复制代码
@Data
public class ReagentPrintDTO {
    private String title;
    private String reagentId;
    private List<Map.Entry<String, String>> summaryFields = new ArrayList<>();
    private List<ReagentPrintRecordDTO> records = new ArrayList<>();
}

2. 打印明细 DTO

文件位置:

basemanage/platform/main/reagent/bean/ReagentPrintRecordDTO.java

java 复制代码
@Data
public class ReagentPrintRecordDTO {
    private String operateType;
    private String operateTime;
    private String reagentNumber;
    private String reagentUnit;
    private String reagentPrice;
    private String deptName;
    private String operater;
    private String batchNo;
    private String remark;
    private String handledUserName;
    private String auditUserName;
}

优点:

  1. 实体和打印结构解耦
  2. 字段格式化集中处理
  3. 后续 PDF 工具就不依赖具体业务表
  4. 以后可以复用到别的模块

十、用 Assembler 组装打印字段

这一层专门负责把数据库对象转成打印对象。

basemanage/core/pdf/PdfRenderUtil.java

java 复制代码
@Component
public class ReagentPrintAssembler {
    private static final DateTimeFormatter DATETIME_FORMATTER =
            DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    public ReagentPrintDTO assemble(ReagentBean reagentBean, List<ReagentRecordBean> recordBeans) {
        ReagentPrintDTO dto = new ReagentPrintDTO();
        dto.setTitle("试剂领用打印单");
        dto.setReagentId(reagentBean.getId());

        addField(dto, "物料名称", reagentBean.getReagentName());
        addField(dto, "物料货号", reagentBean.getReagentNo());
        addField(dto, "物料分类", reagentBean.getReagentClass());
        addField(dto, "物料类型", reagentBean.getReagentType());
        addField(dto, "规格型号", reagentBean.getSpecification());
        addField(dto, "批号", reagentBean.getBatchNo());
        addField(dto, "生产商", reagentBean.getManufacturer());
        addField(dto, "品牌", reagentBean.getReagentBrand());
        addField(dto, "存储条件", reagentBean.getStorageCondition());
        addField(dto, "试剂形态", reagentBean.getReagentShape());
        addField(dto, "供应商", reagentBean.getSupplier());
        addField(dto, "有效期", reagentBean.getValidityDate());
        addField(dto, "现库存量", formatNumber(reagentBean.getInventoryCount()));
        addField(dto, "数量单位", reagentBean.getInventoryUnit());
        addField(dto, "保管人", reagentBean.getCustodianName());
        addField(dto, "备注", reagentBean.getRemark());

        dto.setRecords(recordBeans.stream().map(this::toRecordDto).collect(Collectors.toList()));
        return dto;
    }

    private ReagentPrintRecordDTO toRecordDto(ReagentRecordBean bean) {
        ReagentPrintRecordDTO dto = new ReagentPrintRecordDTO();
        dto.setOperateType(bean.getOperateType());
        dto.setOperateTime(bean.getOperateTime() == null ? "--" : bean.getOperateTime().format(DATETIME_FORMATTER));
        dto.setReagentNumber(formatNumber(bean.getReagentNumber()));
        dto.setReagentUnit(defaultText(bean.getReagentUnit()));
        dto.setReagentPrice(formatNumber(bean.getReagentPrice()));
        dto.setDeptName(defaultText(bean.getDeptName()));
        dto.setOperater(defaultText(bean.getOperater()));
        dto.setBatchNo(defaultText(bean.getBatchNo()));
        dto.setRemark(defaultText(bean.getRemark()));
        dto.setHandledUserName(defaultText(bean.getHandledUserName()));
        dto.setAuditUserName(defaultText(bean.getAuditUserName()));
        return dto;
    }

    private void addField(ReagentPrintDTO dto, String label, String value) {
        dto.getSummaryFields().add(new AbstractMap.SimpleEntry<>(label, defaultText(value)));
    }

    private String formatNumber(Double value) {
        return value == null ? "--" : String.valueOf(value);
    }

    private String defaultText(String value) {
        return value == null || value.trim().isEmpty() ? "--" : value;
    }
}

十一、封装通用 PDF 渲染工具

这是整套功能里最值得沉淀复用的部分

basemanage/core/pdf/PdfRenderUtil.java

java 复制代码
public final class PdfRenderUtil {
    private PdfRenderUtil() {
    }

    public static byte[] renderReagentPrintPdf(List<ReagentPrintDTO> documents) {
        try {
            ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
            Document document = new Document(PageSize.A4, 32, 32, 36, 36);
            PdfWriter.getInstance(document, outputStream);
            document.open();

            BaseFont baseFont = BaseFont.createFont("STSong-Light", "UniGB-UCS2-H", BaseFont.NOT_EMBEDDED);
            Font titleFont = new Font(baseFont, 16, Font.BOLD);
            Font labelFont = new Font(baseFont, 10, Font.BOLD);
            Font valueFont = new Font(baseFont, 10, Font.NORMAL);
            Font tableHeadFont = new Font(baseFont, 9, Font.BOLD);
            Font tableBodyFont = new Font(baseFont, 9, Font.NORMAL);

            for (int i = 0; i < documents.size(); i++) {
                ReagentPrintDTO doc = documents.get(i);
                if (i > 0) {
                    document.newPage();
                }

                Paragraph title = new Paragraph(doc.getTitle(), titleFont);
                title.setAlignment(Element.ALIGN_CENTER);
                title.setSpacingAfter(10f);
                document.add(title);

                PdfPTable summaryTable = new PdfPTable(4);
                summaryTable.setWidthPercentage(100);
                summaryTable.setWidths(new float[]{15f, 35f, 15f, 35f});
                summaryTable.setSpacingAfter(12f);

                for (Map.Entry<String, String> entry : doc.getSummaryFields()) {
                    summaryTable.addCell(createCell(entry.getKey(), labelFont, new Color(242, 242, 242)));
                    summaryTable.addCell(createCell(entry.getValue(), valueFont, Color.WHITE));
                }

                if (doc.getSummaryFields().size() % 2 != 0) {
                    summaryTable.addCell(createCell("", labelFont, Color.WHITE));
                    summaryTable.addCell(createCell("", valueFont, Color.WHITE));
                }

                document.add(summaryTable);

                Paragraph recordTitle = new Paragraph("领用记录", labelFont);
                recordTitle.setSpacingAfter(8f);
                document.add(recordTitle);

                PdfPTable recordTable = new PdfPTable(10);
                recordTable.setWidthPercentage(100);
                recordTable.setWidths(new float[]{10f, 16f, 9f, 7f, 8f, 12f, 10f, 9f, 12f, 7f});

                addHeader(recordTable, tableHeadFont,
                        "操作类型", "操作时间", "数量", "单位", "单价", "部门", "操作人", "批号", "备注", "审核人");

                if (doc.getRecords().isEmpty()) {
                    PdfPCell emptyCell = createCell("暂无领用记录", tableBodyFont, Color.WHITE);
                    emptyCell.setColspan(10);
                    emptyCell.setHorizontalAlignment(Element.ALIGN_CENTER);
                    emptyCell.setMinimumHeight(28f);
                    recordTable.addCell(emptyCell);
                } else {
                    for (ReagentPrintRecordDTO record : doc.getRecords()) {
                        recordTable.addCell(createCell(record.getOperateType(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getOperateTime(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getReagentNumber(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getReagentUnit(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getReagentPrice(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getDeptName(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getOperater(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getBatchNo(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getRemark(), tableBodyFont, Color.WHITE));
                        recordTable.addCell(createCell(record.getAuditUserName(), tableBodyFont, Color.WHITE));
                    }
                }

                document.add(recordTable);
            }

            document.close();
            return outputStream.toByteArray();
        } catch (Exception e) {
            throw new RuntimeException("生成PDF失败:" + e.getMessage(), e);
        }
    }

    private static void addHeader(PdfPTable table, Font font, String... headers) {
        for (String header : headers) {
            PdfPCell cell = createCell(header, font, new Color(230, 230, 230));
            cell.setHorizontalAlignment(Element.ALIGN_CENTER);
            table.addCell(cell);
        }
    }

    private static PdfPCell createCell(String text, Font font, Color backgroundColor) {
        PdfPCell cell = new PdfPCell(new Phrase(text == null ? "--" : text, font));
        cell.setPadding(6f);
        cell.setVerticalAlignment(Element.ALIGN_MIDDLE);
        cell.setBackgroundColor(backgroundColor);
        return cell;
    }
}

十二、核心 Service 实现

把查询、组装、渲染、附件保存串起来。

basemanage/platform/main/reagent/service/ReagentServiceImpl.java

1. 注入新增依赖

java 复制代码
@Resource
private ReagentPrintAssembler reagentPrintAssembler;

@Resource
private BaseFileService baseFileService;

private static final String EXPORT_TABLE_NAME = "t_reagent_export";
private static final DateTimeFormatter EXPORT_TIME_FORMATTER =
        DateTimeFormatter.ofPattern("yyyyMMddHHmmss");

2. 导出核心方法

java 复制代码
@Override
public void exportPrintPdf(ReagentPrintRequest request, HttpServletResponse response) {
    if (request == null || PxListUtils.isEmpty(request.getIds())) {
        throw new RuntimeException("参数错误,试剂id不能为空!");
    }

    List<String> ids = request.getIds().stream()
            .filter(PxStringUtils::isNotEmpty)
            .distinct()
            .collect(Collectors.toList());

    if (PxListUtils.isEmpty(ids)) {
        throw new RuntimeException("参数错误,试剂id不能为空!");
    }

    List<ReagentBean> reagentBeans = getMapper().selectBatchIds(ids);
    if (PxListUtils.isEmpty(reagentBeans)) {
        throw new RuntimeException("未找到对应的试剂信息!");
    }

    List<ReagentRecordBean> recordBeans = reagentRecordMapper.selectListByReagentIds(ids);
    Map<String, List<ReagentRecordBean>> recordMap = recordBeans.stream()
            .collect(Collectors.groupingBy(ReagentRecordBean::getReagentId));

    List<ReagentPrintDTO> printDocs = new ArrayList<>();
    for (String id : ids) {
        ReagentBean reagentBean = reagentBeans.stream()
                .filter(t -> id.equals(t.getId()))
                .findFirst()
                .orElse(null);
        if (reagentBean == null) {
            continue;
        }

        printDocs.add(reagentPrintAssembler.assemble(
                reagentBean,
                recordMap.getOrDefault(id, Collections.emptyList())
        ));
    }

    if (PxListUtils.isEmpty(printDocs)) {
        throw new RuntimeException("未找到可导出的试剂信息!");
    }

    byte[] pdfBytes = PdfRenderUtil.renderReagentPrintPdf(printDocs);
    String fileName = "试剂领用打印单_" + LocalDateTime.now().format(EXPORT_TIME_FORMATTER) + ".pdf";

    if (Boolean.TRUE.equals(request.getSaveAttachment())) {
        String batchId = PxBeanUtils.newStrId();

        BaseFileBean fileBean = new BaseFileBean();
        fileBean.setObjId(batchId);
        fileBean.setTableName(EXPORT_TABLE_NAME);
        fileBean.setFileType("pdf");
        fileBean.setOriginalName(fileName);
        fileBean.setRemark(String.join(",", ids));

        BaseFileBean savedFile = baseFileService.saveGeneratedFile(pdfBytes, fileBean);

        response.setHeader("X-Export-File-Id", savedFile.getId());
        response.setHeader("X-Export-Obj-Id", batchId);
    }

    writePdfResponse(response, pdfBytes, fileName);
}

3. 输出 PDF 到浏览器

java 复制代码
private void writePdfResponse(HttpServletResponse response, byte[] pdfBytes, String fileName) {
    try {
        response.setContentType("application/pdf");
        response.setHeader("Content-Disposition",
                "attachment;filename=" + URLEncoder.encode(fileName, "utf-8"));
        response.setContentLength(pdfBytes.length);
        response.getOutputStream().write(pdfBytes);
        response.getOutputStream().flush();
    } catch (IOException e) {
        throw new RuntimeException("PDF下载失败!");
    }
}

十三、批量导出时使用"导出批次号"保存附件

当用户勾选多个试剂时,导出的是个 PDF 文件,那这个文件应该挂在哪个业务对象下?

错误做法:直接挂在某一个试剂 ID 下。

问题:

  • 这个 PDF 实际包含多条试剂
  • 只挂在一个试剂下面语义不准确

更合理的做法

生成一个"导出批次号"作为objId。

java 复制代码
String batchId = PxBeanUtils.newStrId();
fileBean.setObjId(batchId);
fileBean.setTableName("t_reagent_export");

这样保存到附件表时,语义是:

  • 这是一份"试剂导出批次文件"
  • 不是某一条单独试剂的从属文件

这在批量导出场景下更清晰。

十四、扩展附件服务:支持保存生成文件

项目原来的BaseFileService#upload只支持MultipartFile上传,不适合保存程序动态生成的 PDF。

所以需要扩展一个新方法:

文件位置

basemanage/platform/main/basefile/service/BaseFileService.java

java 复制代码
BaseFileBean saveGeneratedFile(byte[] fileBytes, BaseFileBean fileBean);

实现类

文件位置:basemanage/platform/main/basefile/service/BaseFileServiceImpl.java

java 复制代码
@Override
public BaseFileBean saveGeneratedFile(byte[] fileBytes, BaseFileBean fileBean) {
    if (fileBytes == null || fileBytes.length == 0) {
        throw new RuntimeException("文件内容不能为空!");
    }
    if (PxObjectUtils.isEmpty(fileBean)
            || PxObjectUtils.isEmpty(fileBean.getTableName())
            || PxObjectUtils.isEmpty(fileBean.getObjId())) {
        throw new RuntimeException("参数错误,【tableName,objId】不能为空!");
    }

    String originalName = fileBean.getOriginalName();
    if (PxObjectUtils.isEmpty(originalName)) {
        originalName = "generated-file.pdf";
        fileBean.setOriginalName(originalName);
    }

    String suffix = originalName.contains(".")
            ? originalName.substring(originalName.lastIndexOf("."))
            : ".pdf";

    fileBean.setSuffix(suffix);

    String uuidName = getUuid() + suffix;
    String filePath = System.getProperty("user.dir")
            + File.separator + "UPLOADFILE"
            + File.separator + fileBean.getTableName()
            + File.separator + getYearMonth()
            + File.separator + uuidName;

    fileBean.setFileName(uuidName);
    fileBean.setFilePath(filePath);

    File file = new File(filePath);
    if (!file.getParentFile().exists()) {
        boolean mkdirs = file.getParentFile().mkdirs();
        if (!mkdirs) {
            throw new RuntimeException("创建文件夹失败!");
        }
    }

    try (FileOutputStream fos = new FileOutputStream(file)) {
        fos.write(fileBytes);

        UserBean loginUser = PxLocalContextHelper.getLoginUser();
        fileBean.setFileSize(Math.round(file.length() / 1024.0 * 100.0) / 100.0);

        if (fileBean.getSort() == null) {
            fileBean.setSort(1);
        }

        if (loginUser != null) {
            fileBean.setCreatedById(loginUser.getId());
            fileBean.setCreatedByName(loginUser.getRealName());
        }

        fileBean.setCreateTime(LocalDateTime.now());
        fileBean.setId(PxBeanUtils.newStrId());
        getMapper().insert(fileBean);
        return fileBean;
    } catch (IOException e) {
        throw new RuntimeException("文件存储失败!");
    }
}

十五、ApiPost 测试方式

请求地址

java 复制代码
POST /control/reagent/exportPrintPdf

请求头

java 复制代码
Content-Type: application/json
Authorization: token

请求体

java 复制代码
{
  "ids": ["试剂主表ID1", "试剂主表ID2"],
  "saveAttachment": true
}

预期结果

正常时:

  • 返回文件流
  • Content-Type: application/pdf
  • 浏览器或 ApiPost 可直接保存为 PDF

保存后的文件位置

如果保存附件成功,文件会落到本地目录:

复制代码
项目根目录/UPLOADFILE/t_reagent_export/yyyyMM/

数据库记录会写到:

复制代码
t_base_file

新增总结:

java 复制代码
src/main/java
├── com/px/basemanage/core/pdf
│   └── PdfRenderUtil.java
│
├── com/px/basemanage/platform/main/reagent/bean
│   ├── ReagentPrintRequest.java
│   ├── ReagentPrintDTO.java
│   └── ReagentPrintRecordDTO.java
│
├── com/px/basemanage/platform/main/reagent/controller
│   ├── ReagentController.java
│   └── ReagentControllerImpl.java
│
├── com/px/basemanage/platform/main/reagent/service
│   ├── ReagentService.java
│   ├── ReagentServiceImpl.java
│   └── ReagentPrintAssembler.java
│
├── com/px/basemanage/platform/main/reagent/dao
│   └── ReagentRecordMapper.java
│
└── com/px/basemanage/platform/main/basefile/service
    ├── BaseFileService.java
    └── BaseFileServiceImpl.java
相关推荐
优化控制仿真模型3 小时前
【26六级】英语六级历年真题及答案解析PDF电子版(2015-2025年12月)
经验分享·pdf
其实秋天的枫3 小时前
【社工】初级社会工作者历年真题及答案解析PDF电子版(2010-2025年)
经验分享·pdf
ComPDFKit6 小时前
Adobe vs ComPDF Conversion SDK V4.0.0 - PDF 转 Word 转档效果对比
adobe·pdf·格式工厂
开开心心就好6 小时前
支持批量处理的视频分割工具推荐
安全·智能手机·rust·pdf·电脑·1024程序员节·lavarel
其实防守也摸鱼6 小时前
MarkText:开源免费的 Markdown 编辑器新星
笔记·pdf·编辑器·免费·工具·调试·可下载
T^T尚6 小时前
h5实现pdf预览
vue.js·pdf
优化控制仿真模型10 小时前
【26年考研408】考研计算机408统考历年真题及答案解析PDF电子版(2009-2026年)
经验分享·pdf
阿冰冰呀16 小时前
互联网大厂Java求职面试实录:谢飞机的“水货”之路
java·mybatis·dubbo·springboot·线程池·多线程·hashmap
小脑斧1231 天前
Adobe PDF 编辑器 破截一键激火
adobe·pdf