问题:在管理系统开发中,经常会遇到这样一类需求:
页面展示的是数据库中的业务数据
用户希望把这些数据导出成正式的 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**查询。
正确做法是:
- 一次查所有主表数据
- 一次查所有记录数据
- 内存里按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,而不是直接拿实体渲染
不要直接拿 ReagentBean 和 ReagentRecordBean 去画 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;
}
优点:
- 实体和打印结构解耦
- 字段格式化集中处理
- 后续 PDF 工具就不依赖具体业务表
- 以后可以复用到别的模块
十、用 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