Java中PDF文件导出,生成链路与实现
文章目录
- Java中PDF文件导出,生成链路与实现
-
- 阅读前先建立三个判断
-
- 1. PDF 页数不是数据库页数
- 2. 测试通过不是生产端到端完成
- 3. 当前金额权威来源是订单头 SQL
- 4. 证据矩阵
- 目录
- 一、功能定位与最终产物
-
- 1.1 全链路架构
- 1.2 时序流程
- 二、环境与 Maven 依赖
-
- 2.1 版本基线
- 2.2 根 POM 的版本与依赖管理
- 2.3 服务模块依赖
- 三、请求入口与任务分发
-
- 3.1 HTTP 接口
- 3.2 DPF 模板码与参数 DTO
- 3.3 `exportFile` 的分发逻辑
- 四、数据查询层:订单头、分页明细与模型转换
-
- 4.1 查询边界接口
- 4.2 Provider 实现
- 4.3 MyBatis DAO 与 SQL
- 4.4 PDF 文档模型
- 五、异步任务编排:从分页到文件落盘
-
- 5.1 核心方法完整代码
- 5.2 全量分页读取
- 六、PDF 渲染器:模板、条码、印章和中文字体
-
- 6.1 渲染器职责
- 6.2 关键渲染代码
- 6.3 FreeMarker 模板
- 6.4 运行时资源
- 七、重点:PDF 模板到底是怎么生成的
-
- 7.1 四种表示形态
-
- 7.1.1 用一条商品记录追踪两次转换
- 7.2 实现顺序和依赖关系
- 7.3 第一步:组装 Renderer 的唯一输入
- 7.4 第二步:Renderer 入口如何串起模板和 PDF
- 7.5 第三步:FreeMarker 如何读取模板
- 7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型
- 7.7 第五步:如何阅读 `.ftl` 模板
- 7.8 第六步:动态价格列为什么要改两处
- 7.9 第七步:条码为什么要在模板处理前生成
- 7.10 第八步:印章和字体资源为何必须自包含
- 7.11 第九步:OpenHTMLToPDF 如何真正排版
- 7.12 第十步:文件发布和任务完成
- 7.13 模板生成的最小验证闭环
- 八、最容易出错的三个跨层契约
-
- 8.1 契约一:订单号是唯一业务输入,但不是唯一安全边界
- 8.2 契约二:`PageResult` 的 `hasNextPage` 必须和 `totalCount` 同源
- 8.3 契约三:文件路径必须在“内容成功”之后发布
- 九、渲染器的内部数据流:不是一条 API 调用
-
- 9.1 四种表示形态
- 9.2 为什么资源必须来自 classpath
- 9.3 字体注册的两个名字
- 十、版式为什么选表格,而不是浏览器页面复制
-
- 10.1 OpenHTMLToPDF 的能力边界
- 10.2 长文本是版式测试,不是边角案例
- 10.3 价格列隐藏时为什么要同步改 `colgroup`
- 十一、错误处理的可观测性:现在能知道什么,不能知道什么
-
- 11.1 当前错误信息的定位价值
- 11.2 仍需要补的监控字段
- 十二、测试证据应该如何解读
- 十三、建议的下一步改造顺序
- 十四、下载阶段与 MIME 类型
- 十五、验证、测试与预期结果
-
- 15.1 自动化测试
- 15.2 PDF 二进制与人工检查
- 15.3 真实环境验收边界
- 十六、故障排查与边界
-
- 16.1 不应混淆的两个分页
- 16.2 价格隐藏不是安全边界
- 十七、扩展与维护建议
- 十八、文件索引(完整引用清单)
- 十九、结论
主结论:这套发货清单 PDF 的可靠性不由某一个 PDF 库决定,而由三个跨层契约共同决定:分页查询必须证明数据拿全,渲染器必须把字体/条码/印章变成自包含资源,异步任务必须只在文件真正可下载后进入完成状态。
[!NOTE] 本文按“源码事实 → 设计原因 → 运行时行为 → 失败边界 → 验证证据”的顺序阅读。文档中的“当前实现”只表示已经在仓库源码中确认的行为;“建议改造”会单独标注,不会把方案设想写成已完成能力。
阅读前先建立三个判断
1. PDF 页数不是数据库页数
数据库每页读取 100 条,只是控制单次查询结果集;PDF 页数还取决于 A4 纸张、字体、行高和长文本换行。pageNo=2 不能被写成“PDF 第 2 页”。
2. 测试通过不是生产端到端完成
当前定向测试证明了 mock 数据能生成多页 PDF、模板分支和分页负例有效,但没有证明真实数据库、订单权限、多实例共享磁盘和大订单容量。因此本文会把“已证明”和“未证明”分开写。
3. 当前金额权威来源是订单头 SQL
ShippingListPdfHeader 的注释提到导出任务会覆盖合计,但当前 handlerExportShippingListPdf 没有重新累加明细金额。执行代码实际使用 pdfLoadHeader 返回的 o.amount;这属于源码事实与注释不一致,不能用注释替代执行行为。
4. 证据矩阵
| 分页缺失会失败 | loadAllShippingListItems、Flow 测试 | 空页和数量边界被拦截 | 数据库强一致快照 |
| 中文 PDF 可生成 | TTC 资源、Renderer、PDFBox 文本测试 | 模拟字符可嵌入并读取 | 所有生产字符集 |
| PDF 下载 MIME 正确 | getDownloadContentType | .pdf 分支返回 application/pdf | 浏览器真实下载 |
| 生产端到端可用 | 当前无真实 DB/权限/多节点验收 | 不能宣称 | 需隔离环境补验 |
目录
文章目录
- Java中PDF文件导出,生成链路与实现
-
- 阅读前先建立三个判断
-
- 1. PDF 页数不是数据库页数
- 2. 测试通过不是生产端到端完成
- 3. 当前金额权威来源是订单头 SQL
- 4. 证据矩阵
- 目录
- 一、功能定位与最终产物
-
- 1.1 全链路架构
- 1.2 时序流程
- 二、环境与 Maven 依赖
-
- 2.1 版本基线
- 2.2 根 POM 的版本与依赖管理
- 2.3 服务模块依赖
- 三、请求入口与任务分发
-
- 3.1 HTTP 接口
- 3.2 DPF 模板码与参数 DTO
- 3.3 `exportFile` 的分发逻辑
- 四、数据查询层:订单头、分页明细与模型转换
-
- 4.1 查询边界接口
- 4.2 Provider 实现
- 4.3 MyBatis DAO 与 SQL
- 4.4 PDF 文档模型
- 五、异步任务编排:从分页到文件落盘
-
- 5.1 核心方法完整代码
- 5.2 全量分页读取
- 六、PDF 渲染器:模板、条码、印章和中文字体
-
- 6.1 渲染器职责
- 6.2 关键渲染代码
- 6.3 FreeMarker 模板
- 6.4 运行时资源
- 七、重点:PDF 模板到底是怎么生成的
-
- 7.1 四种表示形态
-
- 7.1.1 用一条商品记录追踪两次转换
- 7.2 实现顺序和依赖关系
- 7.3 第一步:组装 Renderer 的唯一输入
- 7.4 第二步:Renderer 入口如何串起模板和 PDF
- 7.5 第三步:FreeMarker 如何读取模板
- 7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型
- 7.7 第五步:如何阅读 `.ftl` 模板
- 7.8 第六步:动态价格列为什么要改两处
- 7.9 第七步:条码为什么要在模板处理前生成
- 7.10 第八步:印章和字体资源为何必须自包含
- 7.11 第九步:OpenHTMLToPDF 如何真正排版
- 7.12 第十步:文件发布和任务完成
- 7.13 模板生成的最小验证闭环
- 八、最容易出错的三个跨层契约
-
- 8.1 契约一:订单号是唯一业务输入,但不是唯一安全边界
- 8.2 契约二:`PageResult` 的 `hasNextPage` 必须和 `totalCount` 同源
- 8.3 契约三:文件路径必须在“内容成功”之后发布
- 九、渲染器的内部数据流:不是一条 API 调用
-
- 9.1 四种表示形态
- 9.2 为什么资源必须来自 classpath
- 9.3 字体注册的两个名字
- 十、版式为什么选表格,而不是浏览器页面复制
-
- 10.1 OpenHTMLToPDF 的能力边界
- 10.2 长文本是版式测试,不是边角案例
- 10.3 价格列隐藏时为什么要同步改 `colgroup`
- 十一、错误处理的可观测性:现在能知道什么,不能知道什么
-
- 11.1 当前错误信息的定位价值
- 11.2 仍需要补的监控字段
- 十二、测试证据应该如何解读
- 十三、建议的下一步改造顺序
- 十四、下载阶段与 MIME 类型
- 十五、验证、测试与预期结果
-
- 15.1 自动化测试
- 15.2 PDF 二进制与人工检查
- 15.3 真实环境验收边界
- 十六、故障排查与边界
-
- 16.1 不应混淆的两个分页
- 16.2 价格隐藏不是安全边界
- 十七、扩展与维护建议
- 十八、文件索引(完整引用清单)
- 十九、结论
一、功能定位与最终产物
本章只定义范围,不把文件列表当作主体。读者真正要理解的是:客户端提交的是“导出意图”,后台生成的是“可观察任务”,最终交付的是“可下载文件”。三者不是同一个对象。
该功能接收一个业务订单号和“是否隐藏配销价格”开关,立即返回异步任务 ID。后台完成订单头、全部商品明细查询和 PDF 渲染后,将相对文件路径写入 PuTask.successUrl,客户端再调用既有下载接口取得 PDF。
最终文件名格式为:
发货清单_<订单号>_<yyyyMMddHHmmss>.pdf
PDF 包含:
- 发货仓库、收货门店、门店地址、打单日期;
- 订单号对应的 Code 128 条码;
- 仓库区域覆盖的印章图片;
- UPC、数量、商品名称、规格;
- 可选的配销单价、配货金额;
- 合计行和真实 PDF 页码(第 N 页 / 共 M 页)。
1.1 全链路架构
#mermaid-svg-r7UXyV06qJb8buRw{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-r7UXyV06qJb8buRw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-r7UXyV06qJb8buRw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-r7UXyV06qJb8buRw .error-icon{fill:#552222;}#mermaid-svg-r7UXyV06qJb8buRw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-r7UXyV06qJb8buRw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-r7UXyV06qJb8buRw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-r7UXyV06qJb8buRw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-r7UXyV06qJb8buRw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-r7UXyV06qJb8buRw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-r7UXyV06qJb8buRw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-r7UXyV06qJb8buRw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-r7UXyV06qJb8buRw .marker.cross{stroke:#333333;}#mermaid-svg-r7UXyV06qJb8buRw svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-r7UXyV06qJb8buRw p{margin:0;}#mermaid-svg-r7UXyV06qJb8buRw .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-r7UXyV06qJb8buRw .cluster-label text{fill:#333;}#mermaid-svg-r7UXyV06qJb8buRw .cluster-label span{color:#333;}#mermaid-svg-r7UXyV06qJb8buRw .cluster-label span p{background-color:transparent;}#mermaid-svg-r7UXyV06qJb8buRw .label text,#mermaid-svg-r7UXyV06qJb8buRw span{fill:#333;color:#333;}#mermaid-svg-r7UXyV06qJb8buRw .node rect,#mermaid-svg-r7UXyV06qJb8buRw .node circle,#mermaid-svg-r7UXyV06qJb8buRw .node ellipse,#mermaid-svg-r7UXyV06qJb8buRw .node polygon,#mermaid-svg-r7UXyV06qJb8buRw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-r7UXyV06qJb8buRw .rough-node .label text,#mermaid-svg-r7UXyV06qJb8buRw .node .label text,#mermaid-svg-r7UXyV06qJb8buRw .image-shape .label,#mermaid-svg-r7UXyV06qJb8buRw .icon-shape .label{text-anchor:middle;}#mermaid-svg-r7UXyV06qJb8buRw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-r7UXyV06qJb8buRw .rough-node .label,#mermaid-svg-r7UXyV06qJb8buRw .node .label,#mermaid-svg-r7UXyV06qJb8buRw .image-shape .label,#mermaid-svg-r7UXyV06qJb8buRw .icon-shape .label{text-align:center;}#mermaid-svg-r7UXyV06qJb8buRw .node.clickable{cursor:pointer;}#mermaid-svg-r7UXyV06qJb8buRw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-r7UXyV06qJb8buRw .arrowheadPath{fill:#333333;}#mermaid-svg-r7UXyV06qJb8buRw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-r7UXyV06qJb8buRw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-r7UXyV06qJb8buRw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r7UXyV06qJb8buRw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-r7UXyV06qJb8buRw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r7UXyV06qJb8buRw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-r7UXyV06qJb8buRw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-r7UXyV06qJb8buRw .cluster text{fill:#333;}#mermaid-svg-r7UXyV06qJb8buRw .cluster span{color:#333;}#mermaid-svg-r7UXyV06qJb8buRw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-r7UXyV06qJb8buRw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-r7UXyV06qJb8buRw rect.text{fill:none;stroke-width:0;}#mermaid-svg-r7UXyV06qJb8buRw .icon-shape,#mermaid-svg-r7UXyV06qJb8buRw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-r7UXyV06qJb8buRw .icon-shape p,#mermaid-svg-r7UXyV06qJb8buRw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-r7UXyV06qJb8buRw .icon-shape .label rect,#mermaid-svg-r7UXyV06qJb8buRw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-r7UXyV06qJb8buRw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-r7UXyV06qJb8buRw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-r7UXyV06qJb8buRw :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
POST /api/excelFile/exportFile
GET /api/excelFile/downloadFile
客户端
ExcelFileController
ExcelFileServiceImpl.exportFile
Redisson 同用户同模板锁
创建 PuTask: IN_PROGRESS
线程池异步任务
ShippingListPdfDataProvider.loadHeader
loadItems: PageHelper 分页
MySQL: PuTemplateDao.xml
ShippingListPdfDocument
ShippingListPdfRendererImpl
FreeMarker XHTML
ZXing Code 128
印章 Base64 data URI
OpenHTMLToPDF + PDFBox
本地文件系统
PuTask.successUrl + COMPLETED/FAILED
下载 PDF
读图要点:查询层只负责业务数据,渲染层只接收内存中的文档模型;两层通过 ShippingListPdfDocument 解耦。
这张图揭示的设计原因是:如果模板直接查库,版式测试就必须依赖数据库;如果任务直接把 HTML 逻辑写进查询层,字段口径和排版规则会互相污染。当前实现用文档模型把两个变化速度不同的边界隔开。
1.2 时序流程
文件系统
PDF Renderer
PDF DataProvider
PuTaskService
ExcelFileServiceImpl
ExcelFileController
客户端
文件系统
PDF Renderer
PDF DataProvider
PuTaskService
ExcelFileServiceImpl
ExcelFileController
客户端
#mermaid-svg-MsiOtgdYgSEqnCx2{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-MsiOtgdYgSEqnCx2 .error-icon{fill:#552222;}#mermaid-svg-MsiOtgdYgSEqnCx2 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-MsiOtgdYgSEqnCx2 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-MsiOtgdYgSEqnCx2 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-MsiOtgdYgSEqnCx2 .marker.cross{stroke:#333333;}#mermaid-svg-MsiOtgdYgSEqnCx2 svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-MsiOtgdYgSEqnCx2 p{margin:0;}#mermaid-svg-MsiOtgdYgSEqnCx2 .actor{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MsiOtgdYgSEqnCx2 text.actor>tspan{fill:black;stroke:none;}#mermaid-svg-MsiOtgdYgSEqnCx2 .actor-line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-MsiOtgdYgSEqnCx2 .innerArc{stroke-width:1.5;stroke-dasharray:none;}#mermaid-svg-MsiOtgdYgSEqnCx2 .messageLine0{stroke-width:1.5;stroke-dasharray:none;stroke:#333;}#mermaid-svg-MsiOtgdYgSEqnCx2 .messageLine1{stroke-width:1.5;stroke-dasharray:2,2;stroke:#333;}#mermaid-svg-MsiOtgdYgSEqnCx2 #arrowhead path{fill:#333;stroke:#333;}#mermaid-svg-MsiOtgdYgSEqnCx2 .sequenceNumber{fill:white;}#mermaid-svg-MsiOtgdYgSEqnCx2 #sequencenumber{fill:#333;}#mermaid-svg-MsiOtgdYgSEqnCx2 #crosshead path{fill:#333;stroke:#333;}#mermaid-svg-MsiOtgdYgSEqnCx2 .messageText{fill:#333;stroke:none;}#mermaid-svg-MsiOtgdYgSEqnCx2 .labelBox{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MsiOtgdYgSEqnCx2 .labelText,#mermaid-svg-MsiOtgdYgSEqnCx2 .labelText>tspan{fill:black;stroke:none;}#mermaid-svg-MsiOtgdYgSEqnCx2 .loopText,#mermaid-svg-MsiOtgdYgSEqnCx2 .loopText>tspan{fill:black;stroke:none;}#mermaid-svg-MsiOtgdYgSEqnCx2 .loopLine{stroke-width:2px;stroke-dasharray:2,2;stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);}#mermaid-svg-MsiOtgdYgSEqnCx2 .note{stroke:#aaaa33;fill:#fff5ad;}#mermaid-svg-MsiOtgdYgSEqnCx2 .noteText,#mermaid-svg-MsiOtgdYgSEqnCx2 .noteText>tspan{fill:black;stroke:none;}#mermaid-svg-MsiOtgdYgSEqnCx2 .activation0{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MsiOtgdYgSEqnCx2 .activation1{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MsiOtgdYgSEqnCx2 .activation2{fill:#f4f4f4;stroke:#666;}#mermaid-svg-MsiOtgdYgSEqnCx2 .actorPopupMenu{position:absolute;}#mermaid-svg-MsiOtgdYgSEqnCx2 .actorPopupMenuPanel{position:absolute;fill:#ECECFF;box-shadow:0px 8px 16px 0px rgba(0,0,0,0.2);filter:drop-shadow(3px 5px 2px rgb(0 0 0 / 0.4));}#mermaid-svg-MsiOtgdYgSEqnCx2 .actor-man line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;}#mermaid-svg-MsiOtgdYgSEqnCx2 .actor-man circle,#mermaid-svg-MsiOtgdYgSEqnCx2 line{stroke:hsl(259.6261682243, 59.7765363128%, 87.9019607843%);fill:#ECECFF;stroke-width:2px;}#mermaid-svg-MsiOtgdYgSEqnCx2 :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
loop
[pageNo = 1..N]
POST /api/excelFile/exportFile
exportFile(ExportFileDTO)
save(IN_PROGRESS)
taskId
异步 loadHeader(orderNo)
loadItems(orderNo, pageNo, 100)
PageResult(items,totalCount,hasNextPage)
render(document, outputStream)
写入 .pdf
successUrl + COMPLETED
GET /api/excelFile/downloadFile
application/pdf 文件流
二、环境与 Maven 依赖
2.1 版本基线
根 pom.xml 当前基线为 Java 17、Spring Boot 3.0.2。PDF 链路使用以下直接依赖:
| FreeMarker | 2.3.32 | XHTML 模板变量替换 |
| OpenHTMLToPDF PDFBox | 1.0.10 | HTML/CSS 转 PDF |
| ZXing core/javase | 3.5.3 | Code 128 条码生成 |
| PDFBox | 由 OpenHTMLToPDF 传递 | 字体嵌入、PDF 文档输出 |
2.2 根 POM 的版本与依赖管理
文件:pom.xml
<properties>
<java.version>17</java.version>
<freemarker.version>2.3.32</freemarker.version>
<openhtmltopdf.version>1.0.10</openhtmltopdf.version>
<zxing.version>3.5.3</zxing.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
<version>${freemarker.version}</version>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
<version>${openhtmltopdf.version}</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
<version>${zxing.version}</version>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
<version>${zxing.version}</version>
</dependency>
</dependencies>
</dependencyManagement>
2.3 服务模块依赖
文件:service/excel-service/pom.xml
<dependencies>
<dependency>
<groupId>org.freemarker</groupId>
<artifactId>freemarker</artifactId>
</dependency>
<dependency>
<groupId>com.openhtmltopdf</groupId>
<artifactId>openhtmltopdf-pdfbox</artifactId>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>core</artifactId>
</dependency>
<dependency>
<groupId>com.google.zxing</groupId>
<artifactId>javase</artifactId>
</dependency>
</dependencies>
模块同时依赖 common-base(PuTask、DAO、DTO)、common-redis(Redisson 锁)、common-thread(IoIntensiveExecutor)和 Spring JDBC/MyBatis 基础设施。
三、请求入口与任务分发
本章的判断是:模板校验和并发控制必须发生在任务创建之前;否则错误会以“已创建但永远失败”的任务形式泄漏给调用方。
3.1 HTTP 接口
文件:service/excel-service/src/main/java/com/kkd/excel/controller/ExcelFileController.java
@PostMapping("/exportFile")
public Result<Long> exportFile(@RequestBody @Valid ExportFileDTO res) {
Long taskId = excelFileService.exportFile(res);
return Result.ok(taskId);
}
@GetMapping("/downloadFile")
public Result<String> downloadFile(
HttpServletResponse response,
@RequestParam Long taskId,
@RequestParam Integer functionType,
@RequestParam Integer downloadType) {
excelFileService.downloadFile(response, taskId, functionType, downloadType);
return Result.ok();
}
通用请求对象是 ExportFileDTO:
@Data
public class ExportFileDTO {
@NotBlank(message = "模版templateCode不能为空")
private String templateCode;
@NotNull(message = "登录人UserId不能为空")
private Long userId;
@NotBlank(message = "请求参数paramJson不能为空")
private String paramJson;
}
3.2 DPF 模板码与参数 DTO
文件:common/common-model/src/main/java/com/kkd/common/model/enums/ExcelExportTaskEnum.java
SHIPPING_LIST_PDF_EXPORT("shippingListPdfExport", "发货清单PDF导出");
文件:common/common-base/src/main/java/com/kkd/common/base/model/req/ShippingListPdfExportDTO.java
@Data
@Schema(description = "发货清单 PDF 导出参数")
public class ShippingListPdfExportDTO {
@NotBlank(message = "订单号不能为空")
private String orderNo;
@NotNull(message = "是否隐藏配销价格不能为空")
private Boolean hideDistributionPrice;
}
实际请求示例:
{
"templateCode": "shippingListPdfExport",
"userId": 10001,
"paramJson": "{\\"orderNo\\":\\"P2608221420109816\\",\\"hideDistributionPrice\\":false}"
}
返回值只包含任务 ID,例如:
{"code":200,"data":12345}
3.3 exportFile 的分发逻辑
文件:service/excel-service/src/main/java/com/kkd/excel/service/impl/ExcelFileServiceImpl.java
String lockKey = Constants.EXCEL_EXPORT_LOCK + res.getUserId() + ":" + templateCode;
RLock lock = redissonClient.getLock(lockKey);
boolean tryLock = lock.tryLock(1, 30, TimeUnit.SECONDS);
if (!tryLock) {
throw new ServiceException("系统繁忙,请稍后重试");
}
PuTemplate template = puTemplateService.getByCode(templateCode);
ExcelExportTaskEnum taskCodeEnum = ExcelExportTaskEnum.getEnum(template.getCode());
switch (taskCodeEnum) {
case SHIPPING_LIST_PDF_EXPORT:
taskId = handlerExportShippingListPdf(
paramJson, 0L, currentUserId, template,
fileFolderPath, taskCodeEnum);
break;
// 其他 Excel 导出分支…
}
这里的锁粒度是“用户 + 模板码”,因此同一用户不能同时重复提交同一种导出,但不同用户或不同模板可以并行。
它不是订单级幂等锁。用户先提交订单 A,任务创建后锁释放,再提交订单 A,当前实现仍可能生成第二个任务;若业务要求重复请求复用任务,需要增加订单级幂等键。
四、数据查询层:订单头、分页明细与模型转换
本章的核心问题不是“DAO 查询了哪些列”,而是“哪个字段在最终 PDF 中拥有权威性,以及分页元数据能否支撑完整性判断”。
4.1 查询边界接口
文件:service/excel-service/src/main/java/com/kkd/excel/service/ShippingListPdfDataProvider.java
public interface ShippingListPdfDataProvider {
ShippingListPdfHeader loadHeader(String orderNo);
PageResult<ShippingListPdfItem> loadItems(
String orderNo, int pageNo, int pageSize);
}
接口有意不暴露 DAO、MyBatis Page 或 PDF 细节,保证渲染器可以脱离数据库单元测试。
4.2 Provider 实现
文件:service/excel-service/src/main/java/com/kkd/excel/service/impl/ShippingListPdfDataProviderImpl.java
@Service
public class ShippingListPdfDataProviderImpl
implements ShippingListPdfDataProvider {
@Resource
private PuTemplateDao puTemplateDao;
@Override
public ShippingListPdfHeader loadHeader(String orderNo) {
ShippingListPdfHeaderBO source = puTemplateDao.pdfLoadHeader(orderNo);
if (ObjectUtil.isEmpty(source)) {
throw new ServiceException("预订单信息不存在");
}
ShippingListPdfHeader target = new ShippingListPdfHeader();
target.setOrderNo(source.getOrderNo());
target.setPrintDate(source.getPrintDate());
target.setWarehouseName(source.getWarehouseName());
target.setStoreName(source.getStoreName());
target.setStoreAddress(source.getStoreAddress());
target.setTotalQuantity(source.getTotalQuantity());
target.setTotalAmount(source.getTotalAmount());
return target;
}
@Override
public PageResult<ShippingListPdfItem> loadItems(
String orderNo, int pageNo, int pageSize) {
PageHelper.startPage(pageNo, pageSize);
List<ShippingListPdfItemBO> source =
puTemplateDao.pdfLoadItems(orderNo);
PageInfo<ShippingListPdfItemBO> pageInfo = new PageInfo<>(source);
List<ShippingListPdfItem> items = pageInfo.getList().stream()
.map(itemData -> {
ShippingListPdfItem item = new ShippingListPdfItem();
BeanUtil.copyProperties(itemData, item);
return item;
})
.collect(Collectors.toList());
PageResult<ShippingListPdfItem> result = new PageResult<>();
result.setTotalCount(pageInfo.getTotal());
result.setHasNextPage(PagingUtil.hasNext(
pageInfo.getTotal(), pageInfo.getPageSize(), pageInfo.getPageNum()));
result.setList(items);
return result;
}
}
4.3 MyBatis DAO 与 SQL
DAO 文件:common/common-base/src/main/java/com/kkd/common/base/dao/PuTemplateDao.java
ShippingListPdfHeaderBO pdfLoadHeader(@Param("orderNo") String orderNo);
List<ShippingListPdfItemBO> pdfLoadItems(@Param("orderNo") String orderNo);
SQL 文件:common/common-base/src/main/resources/mapper/PuTemplateDao.xml
<select id="pdfLoadHeader"
resultType="com.kkd.common.base.model.resp.ShippingListPdfHeaderBO">
SELECT
o.order_sn AS orderNo,
DATE_FORMAT(CURDATE(), '%Y-%m-%d') AS printDate,
'x' AS warehouseName,
COALESCE(store.title, '') AS storeName,
CONCAT_WS('',
NULLIF(store.province_name, ''),
NULLIF(store.city_name, ''),
NULLIF(store.area_name, ''),
NULLIF(store.address, '')) AS storeAddress,
COALESCE((
SELECT SUM(d.quantity)
FROM boc_procure_order_detail d
WHERE d.order_id = o.id AND d.quantity > 0
), 0) AS totalQuantity,
o.amount AS totalAmount
FROM boc_procure_order o
LEFT JOIN boc_category store ON store.id = o.store_id
WHERE o.order_sn = #{orderNo}
LIMIT 1
</select>
<select id="pdfLoadItems"
resultType="com.kkd.common.base.model.resp.ShippingListPdfItemBO">
SELECT
COALESCE(c.sunny_upc, '') AS upc,
d.quantity AS quantity,
d.commodity_name AS productName,
d.sku_name AS specification,
d.commodity_price AS distributionPrice,
ROUND(d.quantity * d.commodity_price, 2) AS distributionAmount
FROM boc_procure_order_detail d
INNER JOIN boc_procure_order o ON o.id = d.order_id
LEFT JOIN boc_procure_commodity c ON c.id = d.commodity_id
WHERE o.order_sn = #{orderNo}
AND d.quantity > 0
ORDER BY d.id ASC
</select>
ORDER BY d.id ASC 是分页稳定性的关键;没有稳定排序,翻页期间可能出现重复或遗漏。
需要特别区分两个金额:明细 SQL 计算 ROUND(d.quantity * d.commodity_price, 2) 作为行金额;订单头 SQL 直接返回 o.amount AS totalAmount。当前任务编排没有用明细逐行金额重新求和,所以最终合计金额的权威来源是订单主表。若产品要求“合计严格等于明细行求和”,必须另行改造并固定舍入规则。
4.4 PDF 文档模型
文件目录:service/excel-service/src/main/java/com/kkd/excel/model/pdf/
@Data
public class ShippingListPdfDocument {
private ShippingListPdfHeader header;
private List<ShippingListPdfItem> items = new ArrayList<>();
private boolean hideDistributionPrice;
}
@Data
public class ShippingListPdfHeader {
private String orderNo;
private String printDate;
private String warehouseName;
private String storeName;
private String storeAddress;
private Long totalQuantity;
private BigDecimal totalAmount;
}
@Data
public class ShippingListPdfItem {
private String upc;
private Long quantity;
private String productName;
private String specification;
private BigDecimal distributionPrice;
private BigDecimal distributionAmount;
}
[!WARNING] 当前 SQL 已提供订单头的 totalQuantity 和 totalAmount,导出编排代码直接使用这两个查询结果。若业务要求“总金额必须由明细实时重算”,应在 handlerExportShippingListPdf 中显式 BigDecimal 汇总,并同步更新模板和测试;不能仅依赖注释推断已经重算。
五、异步任务编排:从分页到文件落盘
本章的判断是:COMPLETED 必须晚于文件发布,而不是晚于线程启动或 Renderer 调用返回。
5.1 核心方法完整代码
private Long handlerExportShippingListPdf(
String paramJson,
Long currentOrgId,
Long currentUserId,
PuTemplate template,
String fileFolderPath,
ExcelExportTaskEnum taskCodeEnum) {
String taskDesc = taskCodeEnum.getDesc();
ShippingListPdfExportDTO request;
try {
request = JSON.parseObject(paramJson, ShippingListPdfExportDTO.class);
} catch (Exception e) {
throw new ServiceException("请求参数解析失败");
}
if (ObjectUtil.isEmpty(request)
|| ObjectUtil.isEmpty(request.getOrderNo())
|| request.getHideDistributionPrice() == null) {
throw new ServiceException("订单号和是否隐藏配销价格不能为空");
}
PuTask puTask = new PuTask();
puTask.setFunctionType(ExcelFunctionTypeEnum.EXPORT_FILE.getCode());
puTask.setOrgId(currentOrgId);
puTask.setOperatorId(currentUserId);
puTask.setTemplateCode(template.getCode());
puTask.setStatus(ExcelHandlerStatusEnum.IN_PROGRESS.getCode());
puTaskService.save(puTask);
Long taskId = puTask.getId();
CompletableFuture.runAsync(() -> {
puTaskService.updateTaskStatus(
taskId, ExcelHandlerStatusEnum.IN_PROGRESS.getCode());
ShippingListPdfHeader header =
shippingListPdfDataProvider.loadHeader(request.getOrderNo());
List<ShippingListPdfItem> items = loadAllShippingListItems(
request.getOrderNo(), taskId, taskDesc);
ShippingListPdfDocument document = new ShippingListPdfDocument();
document.setHeader(header);
document.setItems(items);
document.setHideDistributionPrice(
Boolean.TRUE.equals(request.getHideDistributionPrice()));
String fileName = "发货清单_" + request.getOrderNo() + "_"
+ DateUtils.getNowDateTime1() + ".pdf";
String fileExportPath = fileFolderPath + File.separator + fileName;
String relativeExportPath = getRelativeFileFolderPath()
+ File.separator + fileName;
try (OutputStream outputStream =
new FileOutputStream(fileExportPath)) {
shippingListPdfRenderer.render(document, outputStream);
} catch (Exception e) {
throw new ServiceException("生成发货清单 PDF 失败: " + e.getMessage());
}
File exportedFile = new File(fileExportPath);
if (!exportedFile.isFile() || exportedFile.length() == 0) {
throw new ServiceException("生成发货清单 PDF 失败: 文件为空");
}
PuTask completedTask = puTaskService.getPuTask(taskId);
completedTask.setHandleResult(
"总条数:" + items.size() + ",实际处理条数:" + items.size());
completedTask.setSuccessUrl(relativeExportPath);
puTaskService.updateById(completedTask);
}, poolExecutor).whenComplete((unused, throwable) -> {
if (throwable == null) {
puTaskService.updateTaskStatus(
taskId, ExcelHandlerStatusEnum.COMPLETED.getCode());
return;
}
Throwable cause = throwable.getCause() == null
? throwable : throwable.getCause();
log.error("{},处理失败,taskId:{},失败原因:{}",
taskDesc, taskId, cause.getMessage(), cause);
puTaskService.updateTaskStatusError(
taskId, ExcelHandlerStatusEnum.FAILED.getCode(), cause.getMessage());
});
return taskId;
}
5.2 全量分页读取
数据库分页大小固定为 100,仅用于控制单次查询内存,与 PDF 的物理页数无关。OpenHTMLToPDF 会根据行高和纸张尺寸自然分页。
private List<ShippingListPdfItem> loadAllShippingListItems(
String orderNo, Long taskId, String taskDesc) {
final int pageSize = 100;
int pageNo = 1;
Long expectedTotal = null;
List<ShippingListPdfItem> items = new ArrayList<>();
while (true) {
PageResult<ShippingListPdfItem> page =
shippingListPdfDataProvider.loadItems(orderNo, pageNo, pageSize);
if (ObjectUtil.isEmpty(page)) {
throw new ServiceException("发货清单分页查询结果为空");
}
long currentTotal = page.getTotalCount();
if (expectedTotal == null) {
expectedTotal = currentTotal;
} else if (expectedTotal != currentTotal) {
throw new ServiceException("发货清单分页总数发生变化");
}
List<ShippingListPdfItem> pageItems = page.getList();
if (ObjectUtil.isEmpty(pageItems)) {
throw new ServiceException(expectedTotal == 0
? "预订单暂无可发货商品" : "发货清单商品数据不完整");
}
items.addAll(pageItems);
if (items.size() > expectedTotal) {
throw new ServiceException("发货清单商品数量超出总数");
}
if (!Boolean.TRUE.equals(page.getHasNextPage())) {
break;
}
pageNo++;
}
if (expectedTotal == null || items.size() != expectedTotal) {
throw new ServiceException("发货清单商品数据不完整");
}
return items;
}
这些检查解决了三个常见问题:分页期间数据变化、空页导致的伪成功、返回条数超过快照总数。
它们提供的是应用层发现机制,不是数据库强一致快照。并发修改仍可能在两次查询之间发生;若业务禁止这种情况,应增加事务隔离、订单版本号或业务锁。
六、PDF 渲染器:模板、条码、印章和中文字体
本章按四种中间表示解释渲染:Java 文档模型 → FreeMarker 数据模型 → XHTML/CSS 布局 → PDFBox 页面对象。每一层的失败方式不同,不能用“输出文件非空”替代全部验证。
6.1 渲染器职责
接口文件:service/excel-service/src/main/java/com/kkd/excel/service/ShippingListPdfRenderer.java
public interface ShippingListPdfRenderer {
void render(ShippingListPdfDocument document, OutputStream outputStream);
}
实现文件:service/excel-service/src/main/java/com/kkd/excel/service/impl/ShippingListPdfRendererImpl.java。完整实现包含以下阶段:
6.2 关键渲染代码
String html = renderHtml(document);
Path fontFile = copyResourceToTemporaryFile(FONT_RESOURCE, ".ttc");
try (PDDocument pdfDocument = new PDDocument();
TrueTypeCollection fontCollection =
new TrueTypeCollection(fontFile.toFile())) {
TrueTypeFont trueTypeFont = fontCollection.getFontByName(FONT_NAME);
if (trueTypeFont == null) {
throw new ServiceException("未找到微软雅黑粗体字体: " + FONT_NAME);
}
PDType0Font pdfFont = PDType0Font.load(pdfDocument, trueTypeFont, true);
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.usePDDocument(pdfDocument);
builder.useFont(new PDFontSupplier(pdfFont), FONT_FAMILY, 700,
BaseRendererBuilder.FontStyle.NORMAL, true);
builder.withHtmlContent(html, "classpath:/");
builder.toStream(outputStream);
builder.run();
} finally {
Files.deleteIfExists(fontFile);
}
条码生成的核心代码:
Map<EncodeHintType, Object> hints = new EnumMap<>(EncodeHintType.class);
hints.put(EncodeHintType.MARGIN, 0);
BitMatrix matrix = new MultiFormatWriter().encode(
orderNo, BarcodeFormat.CODE_128, 360, 64, hints);
BufferedImage image = MatrixToImageWriter.toBufferedImage(matrix);
BufferedImage cropped = cropHorizontalWhitespace(image);
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(cropped, "PNG", output);
return "data:image/png;base64," +
Base64.getEncoder().encodeToString(output.toByteArray());
6.3 FreeMarker 模板
文件:service/excel-service/src/main/resources/pdf/shipping-list-pdf.ftl
@page {
size: A4 landscape;
margin: 6mm 7mm 11mm;
@bottom-right {
content: "第 " counter(page) " 页 / 共 " counter(pages) " 页";
font-family: "Microsoft YaHei";
font-size: 9pt;
font-weight: 700;
}
}
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
价格开关通过同一个条件控制 colgroup 和表头/单元格,避免隐藏列后留下空白列:
<#if document.hideDistributionPrice>
<col style="width: 47%;" />
<col style="width: 23%;" />
<#else>
<col style="width: 39%;" />
<col style="width: 17%;" />
<col style="width: 7%;" />
<col style="width: 7%;" />
</#if>
<#if !document.hideDistributionPrice>
<td class="right">${(item.distributionPrice!0)?string("0.00")}</td>
<td class="right">${(item.distributionAmount!0)?string("0.00")}</td>
</#if>
thead { display: table-header-group; } 让跨页表格重复表头;counter(page) 和 counter(pages) 是渲染阶段的真实页码,不能用数据库查询页号代替。
OpenHTMLToPDF 是 HTML/CSS 子集渲染器,不是 Chrome。浏览器预览正常不能推出 PDF 正常,布局修改必须查看实际生成页面。
6.4 运行时资源
service/excel-service/src/main/resources/pdf/
├── shipping-list-pdf.ftl
├── shipping-list-stamp.png
├── font/msyhbd.ttc
└── demo/shipping-list-pdf-demo.pdf
字体必须随 JAR 打包,否则服务器没有对应中文字体时会出现乱码、方框或字体回退。印章使用 data URI,避免生成过程依赖外部 URL。
七、重点:PDF 模板到底是怎么生成的
如果只记住一句话:.ftl 不是 PDF 文件,而是带 FreeMarker 变量的 XHTML 模板;PDF 是 Renderer 把 Java 对象填入 XHTML 后,再交给 OpenHTMLToPDF/PDFBox 排版生成的二进制文件。
7.1 四种表示形态
ShippingListPdfDocument Java 内存对象
│
│ document / barcodeDataUri / stampDataUri
▼
shipping-list-pdf.ftl XHTML + FreeMarker 指令
│
│ template.process(model, writer)
▼
完整 XHTML 字符串 ${}、<#if>、<#list> 已展开
│
│ PdfRendererBuilder.withHtmlContent(…)
▼
PDF 布局结果 A4、表格、换行、真实页码
│
▼
PDF 二进制输出 `%PDF-` 开头的文件流
因此模板生成包含两次转换:
前一次主要会失败于变量、FreeMarker 指令或 data URI;后一次主要会失败于 CSS 能力、字体、分页和 PDFBox 资源。只测试 HTML 字符串,不能证明 PDF 版式;只检查 %PDF-,也不能证明中文和页码正确。
7.1.1 用一条商品记录追踪两次转换
假设查询层交给 Renderer 的对象只有一条明细:
ShippingListPdfItem item = new ShippingListPdfItem();
item.setUpc("6901234567890");
item.setQuantity(2L);
item.setProductName("无糖乌龙茶");
item.setSpecification("500 ml × 15 瓶");
item.setDistributionPrice(new BigDecimal("3.50"));
item.setDistributionAmount(new BigDecimal("7.00"));
document.setItems(List.of(item));
document.setHideDistributionPrice(false);
这段 Java 对象不会直接变成 PDF。template.process(model, writer) 先把它展开为 XHTML:
<tr>
<td class="center">1</td>
<td class="center">6901234567890</td>
<td class="center">2</td>
<td>无糖乌龙茶</td>
<td>500 ml × 15 瓶</td>
<td class="right">3.50</td>
<td class="right">7.00</td>
</tr>
随后 OpenHTMLToPDF 才会根据 @page、colgroup、字体度量和可用宽度决定:这一行放在哪一页、单元格是否换行、表头是否重复、页脚显示什么页码。也就是说:
Java 字段值决定“写什么”
FTL 条件/循环决定“生成哪些 HTML 节点”
CSS 和字体度量决定“这些节点如何占据纸面”
PDFBox 决定“如何把纸面结果编码成 PDF”
如果把 hideDistributionPrice 改为 true,FreeMarker 会在 XHTML 阶段删除两个价格单元格,并同时采用隐藏价格分支的 colgroup;这不是 PDFBox 在运行时“隐藏列”,而是模板根本没有生成这些节点。这个例子也解释了为什么排查时必须先看 HTML,再看 PDF:HTML 能证明变量和节点是否正确,只有 PDF 才能证明实际排版。
7.2 实现顺序和依赖关系
| 1 | 组装文档 | 订单头、全部明细、价格开关 | ShippingListPdfDocument | 模板不查数据库 |
| 2 | 生成条码 | header.orderNo | PNG data URI | 模板只引用图片 |
| 3 | 读取印章 | classpath PNG | 图片 data URI | 不依赖外部 URL |
| 4 | FreeMarker 处理 | 文档和资源 | 完整 XHTML | 渲染器不认识 ${} |
| 5 | 加载 TTC 字体 | msyhbd.ttc | PDType0Font | 布局需要中文字形宽度 |
| 6 | OpenHTMLToPDF 排版 | XHTML、CSS、字体 | PDF 页面 | 这一步才决定物理分页 |
| 7 | 发布文件 | PDF 输出流 | 非空 .pdf + successUrl | 下载只能读取已完成文件 |
如果把字体注册放到 builder.run() 之后,布局已经完成,注册不会改变中文换行;如果在渲染前写 successUrl,下载端可能拿到半成品。
7.3 第一步:组装 Renderer 的唯一输入
任务编排先完成查询和分页校验,再组装模型:
ShippingListPdfHeader header =
shippingListPdfDataProvider.loadHeader(request.getOrderNo());
List<ShippingListPdfItem> items = loadAllShippingListItems(
request.getOrderNo(), taskId, taskDesc);
ShippingListPdfDocument document = new ShippingListPdfDocument();
document.setHeader(header);
document.setItems(items);
document.setHideDistributionPrice(
Boolean.TRUE.equals(request.getHideDistributionPrice()));
Renderer 的输入契约:
| header.orderNo | 订单头 SQL | 文本和条码 | 非空 |
| header.storeName | 门店关联查询 | 表头 | 允许空值 |
| header.totalAmount | 当前为 o.amount | 合计金额 | 当前未由明细重算 |
| items | 全量分页结果 | <#list> | 必须通过数量校验 |
| hideDistributionPrice | 请求参数 | 列开关 | 只影响展示 |
设计原因:模板接收完整文档,不接收 DAO、订单号查询器或用户上下文。这样 Renderer 可以用 mock 数据测试,模板也不会绕过查询层权限和一致性检查。
7.4 第二步:Renderer 入口如何串起模板和 PDF
文件:service/excel-service/src/main/java/com/kkd/excel/service/impl/ShippingListPdfRendererImpl.java
@Override
public void render(ShippingListPdfDocument document,
OutputStream outputStream) {
try {
// 1. Java 模型先变成完整 XHTML
String html = renderHtml(document);
// 2. TTC 复制到临时文件,供 PDFBox 读取字体集合
Path fontFile = copyResourceToTemporaryFile(
FONT_RESOURCE, ".ttc");
try (PDDocument pdfDocument = new PDDocument();
TrueTypeCollection fontCollection =
new TrueTypeCollection(fontFile.toFile())) {
// 3. 从 TTC 集合中取出指定字体
TrueTypeFont trueTypeFont =
fontCollection.getFontByName(FONT_NAME);
if (trueTypeFont == null) {
throw new ServiceException(
"未找到微软雅黑粗体字体: " + FONT_NAME);
}
// 4. 将字体嵌入当前 PDF 文档
PDType0Font pdfFont =
PDType0Font.load(pdfDocument, trueTypeFont, true);
// 5. 注册 CSS 要使用的逻辑字体族
PdfRendererBuilder builder = new PdfRendererBuilder();
builder.useFastMode();
builder.usePDDocument(pdfDocument);
builder.useFont(
new PDFontSupplier(pdfFont),
FONT_FAMILY,
700,
BaseRendererBuilder.FontStyle.NORMAL,
true);
// 6. XHTML + CSS 排版并写入输出流
builder.withHtmlContent(html, "classpath:/");
builder.toStream(outputStream);
builder.run();
} finally {
Files.deleteIfExists(fontFile);
}
} catch (Exception e) {
throw new ServiceException(
"生成发货清单 PDF 失败: " + e.getMessage());
}
}
这段代码不是“一个库调用”,而是六个有先后依赖的动作:模板展开、字体读取、字体嵌入、字体注册、HTML 排版、输出 PDF。Renderer 不查询数据库,也不关闭调用方传入的输出流。
7.5 第三步:FreeMarker 如何读取模板
构造器把模板根固定在 classpath 的 pdf 目录:
public ShippingListPdfRendererImpl() {
freemarkerConfiguration =
new Configuration(Configuration.VERSION_2_3_32);
freemarkerConfiguration.setClassLoaderForTemplateLoading(
getClass().getClassLoader(), "pdf");
freemarkerConfiguration.setDefaultEncoding("UTF-8");
}
因此下面的调用读取的是打包资源:
Template template = freemarkerConfiguration
.getTemplate("shipping-list-pdf.ftl");
不是读取开发机的绝对路径。JAR 中必须存在:
/pdf/shipping-list-pdf.ftl
/pdf/shipping-list-stamp.png
/pdf/font/msyhbd.ttc
7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型
String renderHtml(ShippingListPdfDocument document) {
if (document == null || document.getHeader() == null) {
throw new ServiceException("发货清单 PDF 数据不能为空");
}
try {
Template template = freemarkerConfiguration
.getTemplate(TEMPLATE_NAME);
Map<String, Object> model = new HashMap<>();
model.put("document", document);
model.put("barcodeDataUri", createBarcodeDataUri(
document.getHeader().getOrderNo()));
model.put("stampDataUri", createResourceDataUri(
STAMP_RESOURCE, "image/png"));
StringWriter writer = new StringWriter();
template.process(model, writer);
return writer.toString();
} catch (IOException e) {
throw new ServiceException(
"加载 PDF 模板或资源失败: " + e.getMessage());
} catch (TemplateException e) {
throw new ServiceException(
"渲染 PDF 模板失败: " + e.getMessage());
}
}
模板拿到的变量只有三个:
document Java 文档对象
barcodeDataUri 条码 PNG data URI
stampDataUri 印章 PNG data URI
template.process 的返回值仍是字符串。此时还没有 PDF 页面,也没有最终页数。
7.7 第五步:如何阅读 .ftl 模板
文件:service/excel-service/src/main/resources/pdf/shipping-list-pdf.ftl
模板中的三类语法:
| ${…} | 读取 Java 对象字段 | ${document.header.orderNo!} |
| <#if> | 条件输出 | 价格列开关 |
| <#list> | 循环输出 | 商品明细行 |
最小数据绑定示例:
<h1 class="title">发货清单</h1>
<div>订单号:${document.header.orderNo!}</div>
<img class="barcode" src="${barcodeDataUri}" alt="订单条码" />
<#list document.items as item>
<tr>
<td>${item?index + 1}</td>
<td>${item.upc!}</td>
<td>${item.quantity!}</td>
<td>${item.productName!}</td>
</tr>
</#list>
FreeMarker 只负责变量替换和循环/条件展开,不负责理解数据库分页,也不负责把 HTML 变成 PDF。
7.8 第六步:动态价格列为什么要改两处
当前模板同时修改 colgroup 和表格内容:
<colgroup>
<col style="width: 5%;" />
<col style="width: 17%;" />
<col style="width: 8%;" />
<#if document.hideDistributionPrice>
<col style="width: 47%;" />
<col style="width: 23%;" />
<#else>
<col style="width: 39%;" />
<col style="width: 17%;" />
<col style="width: 7%;" />
<col style="width: 7%;" />
</#if>
</colgroup>
<#if !document.hideDistributionPrice>
<th>配销单价</th>
<th>配货金额</th>
</#if>
只隐藏 <th>/<td> 会留下原价格列宽,商品名称和规格列会变窄。列集合和列宽必须使用同一个布尔语义。
7.9 第七步:条码为什么要在模板处理前生成
private String createBarcodeDataUri(String orderNo) {
if (orderNo == null || orderNo.isBlank()) {
throw new ServiceException("订单号不能为空");
}
try {
Map<EncodeHintType, Object> hints =
new EnumMap<>(EncodeHintType.class);
hints.put(EncodeHintType.MARGIN, 0);
BitMatrix matrix = new MultiFormatWriter().encode(
orderNo, BarcodeFormat.CODE_128, 360, 64, hints);
BufferedImage image =
MatrixToImageWriter.toBufferedImage(matrix);
BufferedImage cropped = cropHorizontalWhitespace(image);
ByteArrayOutputStream output = new ByteArrayOutputStream();
ImageIO.write(cropped, "PNG", output);
return "data:image/png;base64,"
+ Base64.getEncoder().encodeToString(output.toByteArray());
} catch (Exception e) {
throw new ServiceException("生成订单条码失败: " + e.getMessage());
}
}
模板只引用结果:
<img class="barcode" src="${barcodeDataUri}" alt="订单条码" />
这样模板不需要知道 ZXing 的 BitMatrix。裁剪左右白边是为了让黑色条纹的真实右边界与右侧日期、订单号对齐,不只是视觉装饰。
7.10 第八步:印章和字体资源为何必须自包含
印章通过 ClassPathResource 读取后编码为:
return "data:" + mediaType + ";base64,"
+ Base64.getEncoder().encodeToString(
inputStream.readAllBytes());
模板只看到:
<img class="stamp" src="${stampDataUri}" alt="印章" />
字体则在 builder.run() 之前注册。TTC 内部名称和 CSS 逻辑名称必须同时正确:
TrueTypeFont font = fontCollection
.getFontByName("MicrosoftYaHei-Bold");
builder.useFont(
new PDFontSupplier(pdfFont),
"Microsoft YaHei",
700,
BaseRendererBuilder.FontStyle.NORMAL,
true);
MicrosoftYaHei-Bold 是 TTC 内部字体名,Microsoft YaHei 是模板 CSS 的 font-family。字体注册晚于布局没有意义,因为字体宽度会影响中文换行和最终页数。
7.11 第九步:OpenHTMLToPDF 如何真正排版
builder.withHtmlContent(html, "classpath:/");
builder.toStream(outputStream);
builder.run();
run() 会解析 XHTML、匹配 CSS、加载已注册字体、计算表格布局、处理长文本换行、创建多页 PDF,并在页脚计算 counter(page)/counter(pages)。它不是浏览器截图,也不是把 HTML 原文存进 PDF。
模板必须使用渲染器支持的 CSS 子集:
@page {
size: A4 landscape;
margin: 6mm 7mm 11mm;
@bottom-right {
content: "第 " counter(page) " 页 / 共 " counter(pages) " 页";
}
}
table { width: 100%; border-collapse: collapse; table-layout: fixed; }
thead { display: table-header-group; }
tr { page-break-inside: avoid; }
td { white-space: normal; }
浏览器预览正常不能推出 PDF 正常;布局修改必须打开生成的 PDF 检查。
7.12 第十步:文件发布和任务完成
Renderer 写完流后,任务编排才检查文件并更新任务:
try (OutputStream outputStream =
new FileOutputStream(fileExportPath)) {
shippingListPdfRenderer.render(document, outputStream);
}
File exportedFile = new File(fileExportPath);
if (!exportedFile.isFile() || exportedFile.length() == 0) {
throw new ServiceException("生成发货清单 PDF 失败: 文件为空");
}
completedTask.setSuccessUrl(relativeExportPath);
puTaskService.updateById(completedTask);
文件非空检查和 successUrl 发布是交付契约,不属于排版库职责。只有成功路径才会在 whenComplete 中更新 COMPLETED;异常路径更新 FAILED。
7.13 模板生成的最小验证闭环
先验证 HTML 分支:
String html = renderer.renderHtml(document);
assertThat(html)
.contains("配销单价", "配货金额")
.contains("table-header-group", "counter(pages)")
.contains("font-family: \\"Microsoft YaHei\\"");
再验证 PDF 二进制和页数:
ByteArrayOutputStream output = new ByteArrayOutputStream();
renderer.render(document, output);
assertThat(output.toByteArray())
.startsWith("%PDF-".getBytes(StandardCharsets.US_ASCII));
try (PDDocument pdf = PDDocument.load(output.toByteArray())) {
assertThat(pdf.getNumberOfPages()).isGreaterThan(1);
}
验证命令:
mvn -pl service/excel-service -am \\
-DfailIfNoTests=false \\
-Dtest=ShippingListPdfRendererImplTest \\
test
| HTML 字符串 | FTL 变量、条件、CSS 规则已展开 | 页面实际位置 |
| %PDF- | PDF 二进制已输出 | 中文、页码、版式 |
| PDFBox 文本/页数 | 测试数据下文本和多页成立 | 真实 SQL 和权限 |
| 栅格化/人工查看 | 印章、对齐、换行、页脚视觉正确 | 高并发容量 |
八、最容易出错的三个跨层契约
上一节讲的是“怎么渲染”。真正决定这个功能是否可靠的,是跨层契约有没有闭环。
8.1 契约一:订单号是唯一业务输入,但不是唯一安全边界
入口 DTO 只接收 orderNo,DAO 的两个 SQL 也都按 #{orderNo} 查询。这保证了前端不能直接提交订单金额或门店名称,但它没有自动解决授权问题:当前 ShippingListPdfDataProvider 的接口没有 currentUserId、组织 ID 或权限参数。
因此必须区分两种结论:
- 已确认:服务端不信任请求体中的金额和门店信息;
- 未确认:当前代码是否在更上游完成了订单归属校验;仅凭本链路源码不能证明“只允许用户下载自己的订单”。
如果要补充授权,建议在 loadHeader 之前增加订单访问检查,或让 Provider 接收明确的组织/用户作用域,而不是把权限条件偷偷拼在模板层。
8.2 契约二:PageResult 的 hasNextPage 必须和 totalCount 同源
调用方依赖两个字段:
long currentTotal = page.getTotalCount();
if (!Boolean.TRUE.equals(page.getHasNextPage())) {
break;
}
如果 hasNextPage 来自一个查询、totalCount 来自另一个时间点,或 SQL 过滤条件不一致,就会出现三类错误:
| 总数偏大 | 最后一页为空,任务失败 |
| 总数偏小 | 收集数量超总数,任务失败 |
| hasNext 错误 | 少读一页或无穷翻页 |
当前 Provider 用同一个 PageInfo 计算两者,这是正确方向;如果未来改成手写 SQL,必须保持同一查询快照和稳定排序。
8.3 契约三:文件路径必须在“内容成功”之后发布
当前顺序是:
render -> 检查 isFile && length > 0 -> setSuccessUrl -> COMPLETED
不能提前写 successUrl。successUrl 一旦可见,下载端就会把它当成可用文件。对对象存储改造时同样要遵守这个顺序:上传成功并得到稳定对象键后才更新任务记录。
九、渲染器的内部数据流:不是一条 API 调用
9.1 四种表示形态
#mermaid-svg-EKtzdpLn0vhmMc8t{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-EKtzdpLn0vhmMc8t .error-icon{fill:#552222;}#mermaid-svg-EKtzdpLn0vhmMc8t .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-EKtzdpLn0vhmMc8t .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-EKtzdpLn0vhmMc8t .marker{fill:#333333;stroke:#333333;}#mermaid-svg-EKtzdpLn0vhmMc8t .marker.cross{stroke:#333333;}#mermaid-svg-EKtzdpLn0vhmMc8t svg{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-EKtzdpLn0vhmMc8t p{margin:0;}#mermaid-svg-EKtzdpLn0vhmMc8t .label{font-family:\”trebuchet ms\”,verdana,arial,sans-serif;color:#333;}#mermaid-svg-EKtzdpLn0vhmMc8t .cluster-label text{fill:#333;}#mermaid-svg-EKtzdpLn0vhmMc8t .cluster-label span{color:#333;}#mermaid-svg-EKtzdpLn0vhmMc8t .cluster-label span p{background-color:transparent;}#mermaid-svg-EKtzdpLn0vhmMc8t .label text,#mermaid-svg-EKtzdpLn0vhmMc8t span{fill:#333;color:#333;}#mermaid-svg-EKtzdpLn0vhmMc8t .node rect,#mermaid-svg-EKtzdpLn0vhmMc8t .node circle,#mermaid-svg-EKtzdpLn0vhmMc8t .node ellipse,#mermaid-svg-EKtzdpLn0vhmMc8t .node polygon,#mermaid-svg-EKtzdpLn0vhmMc8t .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-EKtzdpLn0vhmMc8t .rough-node .label text,#mermaid-svg-EKtzdpLn0vhmMc8t .node .label text,#mermaid-svg-EKtzdpLn0vhmMc8t .image-shape .label,#mermaid-svg-EKtzdpLn0vhmMc8t .icon-shape .label{text-anchor:middle;}#mermaid-svg-EKtzdpLn0vhmMc8t .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-EKtzdpLn0vhmMc8t .rough-node .label,#mermaid-svg-EKtzdpLn0vhmMc8t .node .label,#mermaid-svg-EKtzdpLn0vhmMc8t .image-shape .label,#mermaid-svg-EKtzdpLn0vhmMc8t .icon-shape .label{text-align:center;}#mermaid-svg-EKtzdpLn0vhmMc8t .node.clickable{cursor:pointer;}#mermaid-svg-EKtzdpLn0vhmMc8t .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-EKtzdpLn0vhmMc8t .arrowheadPath{fill:#333333;}#mermaid-svg-EKtzdpLn0vhmMc8t .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-EKtzdpLn0vhmMc8t .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-EKtzdpLn0vhmMc8t .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EKtzdpLn0vhmMc8t .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-EKtzdpLn0vhmMc8t .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EKtzdpLn0vhmMc8t .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-EKtzdpLn0vhmMc8t .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-EKtzdpLn0vhmMc8t .cluster text{fill:#333;}#mermaid-svg-EKtzdpLn0vhmMc8t .cluster span{color:#333;}#mermaid-svg-EKtzdpLn0vhmMc8t div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:\”trebuchet ms\”,verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-EKtzdpLn0vhmMc8t .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-EKtzdpLn0vhmMc8t rect.text{fill:none;stroke-width:0;}#mermaid-svg-EKtzdpLn0vhmMc8t .icon-shape,#mermaid-svg-EKtzdpLn0vhmMc8t .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-EKtzdpLn0vhmMc8t .icon-shape p,#mermaid-svg-EKtzdpLn0vhmMc8t .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-EKtzdpLn0vhmMc8t .icon-shape .label rect,#mermaid-svg-EKtzdpLn0vhmMc8t .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-EKtzdpLn0vhmMc8t .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-EKtzdpLn0vhmMc8t .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-EKtzdpLn0vhmMc8t :root{–mermaid-font-family:\”trebuchet ms\”,verdana,arial,sans-serif;}
Java 文档模型
FreeMarker 数据模型
UTF-8 XHTML 字符串
OpenHTMLToPDF CSS 布局树
PDFBox 字体/图片/页面对象
PDF 二进制输出流
每一步都有不同的失败方式:
| Java 模型 | header 为 null | ServiceException |
| FreeMarker | 变量名拼错 | 模板处理异常 |
| XHTML | 不闭合标签、不可支持 CSS | 渲染器异常/版式异常 |
| PDFBox | TTC 字体名不存在 | 字体加载异常 |
| 文件流 | 目录无权限 | IOException |
9.2 为什么资源必须来自 classpath
setClassLoaderForTemplateLoading(…, "pdf") 和 ClassPathResource 让模板、图片、字体的寻址基于 JAR 内部资源,而不是开发机工作目录。这样本地、Docker 和发布包使用同一寻址规则。代价是资源更新需要重新打包;如果业务要求动态换印章,应该引入受控资源存储接口,而不是回到任意 URL。
9.3 字体注册的两个名字
当前实现同时依赖:
private static final String FONT_NAME = "MicrosoftYaHei-Bold";
private static final String FONT_FAMILY = "Microsoft YaHei";
前者用于从 TTC 集合中选字体,后者必须和 FTL 的 font-family 相同。这是一个典型的“文件资源名”和“CSS 逻辑名”双契约,修改字体时必须同时验证。
十、版式为什么选表格,而不是浏览器页面复制
10.1 OpenHTMLToPDF 的能力边界
设计文档明确限制为 XHTML、表格布局和 OpenHTMLToPDF 支持的 CSS。原因是 PDF 渲染器没有完整浏览器的 JavaScript、Flex/Grid 和现代布局实现。表格的 thead { display: table-header-group; } 能让表头跨页重复,tr { page-break-inside: avoid; } 尽量避免商品行被切开。
这也是为什么不能只在 Chrome 中确认样式:Chrome 预览通过不代表 PDF 页面计数、字体嵌入和表头重复都正确。
10.2 长文本是版式测试,不是边角案例
测试故意把商品名称和规格写成长文本。它验证的是布局约束:单元格允许换行、行高随内容增长、行尽量不跨页。若未来新增 word-break、overflow 或固定高度,必须重新生成多页 PDF 检查,而不是只看 HTML 快照。
10.3 价格列隐藏时为什么要同步改 colgroup
只隐藏 <th> 和 <td> 会留下原有列宽,造成商品名称和规格区域变窄。当前模板在 colgroup 内用同一个开关重分配宽度,这保证“列不存在”和“宽度不存在”同时成立。
十一、错误处理的可观测性:现在能知道什么,不能知道什么
11.1 当前错误信息的定位价值
以下错误已经能定位到阶段:
请求参数解析失败
预订单信息不存在
发货清单分页总数发生变化
PDF 资源不存在: pdf/font/msyhbd.ttc
生成发货清单 PDF 失败: 文件为空
任务失败时 PuTask.handleResult 保存异常消息,日志额外记录 taskId 和任务描述,因此可以用任务 ID 将数据库记录和应用日志串起来。
11.2 仍需要补的监控字段
当前没有看到以下指标:
- 每页 SQL 耗时和总页数;
- HTML 渲染耗时、PDF 文件大小和最终页数;
- 线程池排队时长;
- 失败阶段的结构化错误码。
如果要做生产运维,建议增加结构化事件:PDF_QUERY_HEADER、PDF_QUERY_PAGE、PDF_RENDER、PDF_PERSIST,但不要把订单地址、价格等业务数据写入日志。
十二、测试证据应该如何解读
当前定向命令:
mvn -pl service/excel-service -am \\
-DfailIfNoTests=false \\
-Dtest=ShippingListPdfExportDTOTest,ShippingListPdfDataProviderImplTest,ShippingListPdfRendererImplTest,ShippingListPdfExportFlowTest \\
test
结果是 7 个测试通过。这个结果可以支持以下结论:
- 代码能在 Java 17/Maven reactor 中编译;
- DTO 约束生效;
- Provider 的对象转换逻辑生效;
- 120 条模拟明细能产生多页 %PDF- 文件;
- 隐藏价格列的模板分支生效;
- 空页和空订单会失败,而不是伪造成功。
但它不能支持以下结论:
- 生产 MySQL SQL 一定返回正确业务数据;
- 真实权限一定阻止越权订单导出;
- 多实例部署一定能共享文件;
- 万级明细一定不会 OOM;
- 浏览器端一定能正确下载。
十三、建议的下一步改造顺序
按风险而不是按“看起来最容易”排序:
十四、下载阶段与 MIME 类型
downloadFile 根据 PuTask.successUrl 取出真实文件名,并按扩展名设置响应类型:
private static String getDownloadContentType(String path) {
if (path != null && path.toLowerCase(Locale.ROOT).endsWith(".pdf")) {
return "application/pdf";
}
return "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
}
PDF 下载请求示例:
GET /api/excelFile/downloadFile
?taskId=12345
&functionType=1
&downloadType=0
其中 functionType=1 表示导出,downloadType=0 表示下载成功文件。响应头使用真实文件名,不再无条件追加 .xlsx。
十五、验证、测试与预期结果
本章不只列命令,而是明确每条证据能推出什么、不能推出什么。
15.1 自动化测试
当前已验证的命令:
mvn -pl service/excel-service -am \\
-DfailIfNoTests=false \\
-Dtest=ShippingListPdfExportDTOTest,ShippingListPdfDataProviderImplTest,ShippingListPdfRendererImplTest,ShippingListPdfExportFlowTest \\
test
验证结果:BUILD SUCCESS,共运行 7 个测试,失败 0、错误 0。
这只支持“隔离测试范围内的行为成立”,不支持“生产端到端已经完成”。
覆盖范围:
| ShippingListPdfExportDTOTest | 订单号、价格开关校验 |
| ShippingListPdfDataProviderImplTest | 订单头和分页 BO 到模型转换 |
| ShippingListPdfRendererImplTest | 多页 PDF、中文文本、条码、价格列和页码 |
| ShippingListPdfExportFlowTest | 异步编排、分页边界和失败分支 |
15.2 PDF 二进制与人工检查
渲染器测试至少应检查 PDF 文件头:
assertThat(outputStream.toByteArray())
.startsWith("%PDF-".getBytes(StandardCharsets.US_ASCII));
显式生成版式示例:
mvn -pl service/excel-service -am -DfailIfNoTests=false \\
-Dtest=ShippingListPdfDemoGeneratorTest \\
-DgeneratePdfDemo=true test
生成文件:service/excel-service/src/main/resources/pdf/demo/shipping-list-pdf-demo.pdf。应人工确认:横向 A4、印章位置、长文本换行、跨页重复表头、页脚页码和两种价格列布局。
15.3 真实环境验收边界
| 已证明 | Renderer 是否能输出多页 PDF? | mock 数据测试通过 |
| 未证明 | 真实 SQL、权限、多实例文件是否正确? | 当前未覆盖 |
| 下一步 | 如何完成生产验收? | 隔离库真实订单 + 下载链路 + 压测 |
自动化测试使用 mock 数据,不代表生产数据库一定可导出。上线前仍需在隔离数据环境验证:
十六、故障排查与边界
| 模板不存在 | pu_template 没有 shippingListPdfExport | 补充模板数据并确认 code 完全一致 |
| 预订单信息不存在 | 订单号查不到 boc_procure_order | 核对订单号和数据源 |
| 发货清单商品数据不完整 | 分页总数变化、空页或条数不足 | 检查并发修改、稳定排序和 SQL 条件 |
| PDF 资源不存在 | FTL、PNG 或 TTC 未打包 | 检查 src/main/resources/pdf 和 JAR 内容 |
| 中文乱码/方框 | 字体未嵌入或字体名称不一致 | 检查 FONT_NAME、FONT_FAMILY 和 TTC 内容 |
| 下载为 .xlsx 或无法预览 | 下载 MIME 或文件名逻辑错误 | 确认路径以 .pdf 结尾并返回 application/pdf |
| 任务一直处理中 | 异步线程异常未收敛或线程池不可用 | 检查 whenComplete 日志、线程池和任务表 |
16.1 不应混淆的两个分页
- 数据库分页:pageSize=100,用于降低 SQL 单次结果集和内存压力;
- PDF 分页:由 A4 横向纸张、字体、行高和 OpenHTMLToPDF 自动决定。
因此“查询第 2 页”不等于“PDF 第 2 页”,也不能据此预先计算最终页数。
16.2 价格隐藏不是安全边界
hideDistributionPrice=true 只控制 PDF 表格是否输出价格列;Provider 仍会查询 distributionPrice 和 distributionAmount,并且订单头仍携带 totalAmount。如果调用方不应获得价格相关数据,应在查询层按权限裁剪或拆分 DTO,不能只依赖模板隐藏。
十七、扩展与维护建议
十八、文件索引(完整引用清单)
| HTTP | service/excel-service/src/main/java/com/kkd/excel/controller/ExcelFileController.java |
| 任务编排 | service/excel-service/src/main/java/com/kkd/excel/service/impl/ExcelFileServiceImpl.java |
| 请求 DTO | common/common-base/src/main/java/com/kkd/common/base/model/req/ExportFileDTO.java、ShippingListPdfExportDTO.java |
| 模板枚举 | common/common-model/src/main/java/com/kkd/common/model/enums/ExcelExportTaskEnum.java |
| 查询接口 | service/excel-service/src/main/java/com/kkd/excel/service/ShippingListPdfDataProvider.java |
| 查询实现 | service/excel-service/src/main/java/com/kkd/excel/service/impl/ShippingListPdfDataProviderImpl.java |
| DAO | common/common-base/src/main/java/com/kkd/common/base/dao/PuTemplateDao.java |
| SQL | common/common-base/src/main/resources/mapper/PuTemplateDao.xml |
| PDF 模型 | service/excel-service/src/main/java/com/kkd/excel/model/pdf/*.java |
| 渲染接口 | service/excel-service/src/main/java/com/kkd/excel/service/ShippingListPdfRenderer.java |
| 渲染实现 | service/excel-service/src/main/java/com/kkd/excel/service/impl/ShippingListPdfRendererImpl.java |
| 模板 | service/excel-service/src/main/resources/pdf/shipping-list-pdf.ftl |
| 静态资源 | service/excel-service/src/main/resources/pdf/shipping-list-stamp.png、font/msyhbd.ttc |
| 测试 | service/excel-service/src/test/java/com/kkd/excel/service/impl/ShippingListPdf*Test.java |
十九、结论
DPF(本文按项目实际含义解释为发货清单 PDF)不是一个单独的 PDF 工具类,而是一条完整的异步导出流水线:ExportFileDTO 选择模板 → ExcelFileServiceImpl 加锁并创建 PuTask → Provider 查询订单头和分页明细 → 组装 ShippingListPdfDocument → FreeMarker 生成 XHTML → ZXing 生成条码、资源转 Base64 → OpenHTMLToPDF/PDFBox 嵌入字体并输出 PDF → 保存 successUrl → 下载接口按 .pdf 返回文件流。
只要模板数据、订单 SQL、运行时资源和文件目录权限均满足,当前实现已经具备可测试的端到端功能;生产验收仍必须使用隔离数据库验证真实订单、多页数据和文件下载。



