欢迎光临
我们一直在努力

Java中PDF文件导出,生成链路与实现

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。完整实现包含以下阶段:

  • Configuration.VERSION_2_3_32 从 classpath pdf 目录加载 shipping-list-pdf.ftl;
  • renderHtml 将 document、条码 data URI、印章 data URI 放入 FreeMarker 模型;
  • MultiFormatWriter 使用 BarcodeFormat.CODE_128 生成订单条码,并裁剪左右白边;
  • 从 pdf/shipping-list-stamp.png 读取印章并编码为 data:image/png;base64,…;
  • 将 pdf/font/msyhbd.ttc 复制到临时文件,使用 PDFBox TrueTypeCollection 获取 MicrosoftYaHei-Bold;
  • 通过 PdfRendererBuilder.useFont 注册“Microsoft YaHei”,调用 builder.run() 写入输出流;
  • 删除临时字体文件,异常统一转换为 ServiceException。
  • 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-` 开头的文件流

    因此模板生成包含两次转换:

  • Java 文档模型 → XHTML 字符串;
  • XHTML/CSS → 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;
    • 浏览器端一定能正确下载。

    十三、建议的下一步改造顺序

    按风险而不是按“看起来最容易”排序:

  • 先补真实数据库验收:使用隔离库执行两条 SQL,检查 EXPLAIN、总数口径和订单权限;
  • 再补任务级失败测试:mock renderer 抛异常,断言最终状态为 FAILED 且不写 successUrl;
  • 再决定金额口径:订单头 o.amount 与明细求和只能选一个权威来源;
  • 再处理存储扩展:多实例部署优先抽象共享对象存储,保留稳定对象键;
  • 最后做大单压测:记录商品数量、HTML 长度、PDF 页数、内存峰值和线程池排队。
  • 十四、下载阶段与 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 数据,不代表生产数据库一定可导出。上线前仍需在隔离数据环境验证:

  • boc_procure_order、boc_procure_order_detail 和商品/门店关联数据完整;
  • 订单明细分页期间不会被并发修改,或业务接受快照不一致错误;
  • 应用进程对 saveFilePath.* 目录有读写权限;
  • JAR 中存在 FTL、印章和 TTC 字体;
  • 大订单的内存占用和单任务耗时符合线程池容量。
  • 十六、故障排查与边界

    现象根因处理
    模板不存在 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,不能只依赖模板隐藏。

    十七、扩展与维护建议

  • 新增字段:先扩展 BO、领域模型和 FTL,再补 Provider 映射与渲染测试。
  • 新增字体字重:修改 TrueTypeCollection 的字体名称和 useFont 注册,不要只把字体文件放进资源目录。
  • 改金额口径:明确是订单头金额还是明细实时求和,补充 BigDecimal 汇总测试,避免数据库字段和页面合计不一致。
  • 改存储方式:若迁移对象存储,保持 PuTask.successUrl 的下载契约不变,并同步修改下载服务的 MIME、鉴权和临时 URL 策略。
  • 提高大单性能:当前实现会把全部商品汇集到 List 后一次渲染;若订单规模上升到数万行,应评估流式 HTML、分片 PDF 合并或异步对象存储,而不是简单增大线程池。
  • 十八、文件索引(完整引用清单)

    层次文件
    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、运行时资源和文件目录权限均满足,当前实现已经具备可测试的端到端功能;生产验收仍必须使用隔离数据库验证真实订单、多页数据和文件下载。

    赞(0)
    未经允许不得转载:171主机测评 » Java中PDF文件导出,生成链路与实现
    分享到: 更多 (0)

    评论 抢沙发

    • 昵称 (必填)
    • 邮箱 (必填)
    • 网址