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

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

文章目录

  • Java中PDF文件导出,生成链路与实现
    • 阅读前先建立三个判断
      • [1. PDF 页数不是数据库页数](#1. PDF 页数不是数据库页数)
      • [2. 测试通过不是生产端到端完成](#2. 测试通过不是生产端到端完成)
      • [3. 当前金额权威来源是订单头 SQL](#3. 当前金额权威来源是订单头 SQL)
      • [4. 证据矩阵](#4. 证据矩阵)
    • 目录
    • 一、功能定位与最终产物
      • [1.1 全链路架构](#1.1 全链路架构)
      • [1.2 时序流程](#1.2 时序流程)
    • [二、环境与 Maven 依赖](#二、环境与 Maven 依赖)
      • [2.1 版本基线](#2.1 版本基线)
      • [2.2 根 POM 的版本与依赖管理](#2.2 根 POM 的版本与依赖管理)
      • [2.3 服务模块依赖](#2.3 服务模块依赖)
    • 三、请求入口与任务分发
      • [3.1 HTTP 接口](#3.1 HTTP 接口)
      • [3.2 DPF 模板码与参数 DTO](#3.2 DPF 模板码与参数 DTO)
      • [3.3 `exportFile` 的分发逻辑](#3.3 exportFile 的分发逻辑)
    • 四、数据查询层:订单头、分页明细与模型转换
      • [4.1 查询边界接口](#4.1 查询边界接口)
      • [4.2 Provider 实现](#4.2 Provider 实现)
      • [4.3 MyBatis DAO 与 SQL](#4.3 MyBatis DAO 与 SQL)
      • [4.4 PDF 文档模型](#4.4 PDF 文档模型)
    • 五、异步任务编排:从分页到文件落盘
      • [5.1 核心方法完整代码](#5.1 核心方法完整代码)
      • [5.2 全量分页读取](#5.2 全量分页读取)
    • [六、PDF 渲染器:模板、条码、印章和中文字体](#六、PDF 渲染器:模板、条码、印章和中文字体)
      • [6.1 渲染器职责](#6.1 渲染器职责)
      • [6.2 关键渲染代码](#6.2 关键渲染代码)
      • [6.3 FreeMarker 模板](#6.3 FreeMarker 模板)
      • [6.4 运行时资源](#6.4 运行时资源)
    • [七、重点:PDF 模板到底是怎么生成的](#七、重点:PDF 模板到底是怎么生成的)
      • [7.1 四种表示形态](#7.1 四种表示形态)
        • [7.1.1 用一条商品记录追踪两次转换](#7.1.1 用一条商品记录追踪两次转换)
      • [7.2 实现顺序和依赖关系](#7.2 实现顺序和依赖关系)
      • [7.3 第一步:组装 Renderer 的唯一输入](#7.3 第一步:组装 Renderer 的唯一输入)
      • [7.4 第二步:Renderer 入口如何串起模板和 PDF](#7.4 第二步:Renderer 入口如何串起模板和 PDF)
      • [7.5 第三步:FreeMarker 如何读取模板](#7.5 第三步:FreeMarker 如何读取模板)
      • [7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型](#7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型)
      • [7.7 第五步:如何阅读 `.ftl` 模板](#7.7 第五步:如何阅读 .ftl 模板)
      • [7.8 第六步:动态价格列为什么要改两处](#7.8 第六步:动态价格列为什么要改两处)
      • [7.9 第七步:条码为什么要在模板处理前生成](#7.9 第七步:条码为什么要在模板处理前生成)
      • [7.10 第八步:印章和字体资源为何必须自包含](#7.10 第八步:印章和字体资源为何必须自包含)
      • [7.11 第九步:OpenHTMLToPDF 如何真正排版](#7.11 第九步:OpenHTMLToPDF 如何真正排版)
      • [7.12 第十步:文件发布和任务完成](#7.12 第十步:文件发布和任务完成)
      • [7.13 模板生成的最小验证闭环](#7.13 模板生成的最小验证闭环)
    • 八、最容易出错的三个跨层契约
      • [8.1 契约一:订单号是唯一业务输入,但不是唯一安全边界](#8.1 契约一:订单号是唯一业务输入,但不是唯一安全边界)
      • [8.2 契约二:`PageResult` 的 `hasNextPage` 必须和 `totalCount` 同源](#8.2 契约二:PageResulthasNextPage 必须和 totalCount 同源)
      • [8.3 契约三:文件路径必须在"内容成功"之后发布](#8.3 契约三:文件路径必须在“内容成功”之后发布)
    • [九、渲染器的内部数据流:不是一条 API 调用](#九、渲染器的内部数据流:不是一条 API 调用)
      • [9.1 四种表示形态](#9.1 四种表示形态)
      • [9.2 为什么资源必须来自 classpath](#9.2 为什么资源必须来自 classpath)
      • [9.3 字体注册的两个名字](#9.3 字体注册的两个名字)
    • 十、版式为什么选表格,而不是浏览器页面复制
      • [10.1 OpenHTMLToPDF 的能力边界](#10.1 OpenHTMLToPDF 的能力边界)
      • [10.2 长文本是版式测试,不是边角案例](#10.2 长文本是版式测试,不是边角案例)
      • [10.3 价格列隐藏时为什么要同步改 `colgroup`](#10.3 价格列隐藏时为什么要同步改 colgroup)
    • 十一、错误处理的可观测性:现在能知道什么,不能知道什么
      • [11.1 当前错误信息的定位价值](#11.1 当前错误信息的定位价值)
      • [11.2 仍需要补的监控字段](#11.2 仍需要补的监控字段)
    • 十二、测试证据应该如何解读
    • 十三、建议的下一步改造顺序
    • [十四、下载阶段与 MIME 类型](#十四、下载阶段与 MIME 类型)
    • 十五、验证、测试与预期结果
      • [15.1 自动化测试](#15.1 自动化测试)
      • [15.2 PDF 二进制与人工检查](#15.2 PDF 二进制与人工检查)
      • [15.3 真实环境验收边界](#15.3 真实环境验收边界)
    • 十六、故障排查与边界
      • [16.1 不应混淆的两个分页](#16.1 不应混淆的两个分页)
      • [16.2 价格隐藏不是安全边界](#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 页数不是数据库页数](#1. PDF 页数不是数据库页数)
      • [2. 测试通过不是生产端到端完成](#2. 测试通过不是生产端到端完成)
      • [3. 当前金额权威来源是订单头 SQL](#3. 当前金额权威来源是订单头 SQL)
      • [4. 证据矩阵](#4. 证据矩阵)
    • 目录
    • 一、功能定位与最终产物
      • [1.1 全链路架构](#1.1 全链路架构)
      • [1.2 时序流程](#1.2 时序流程)
    • [二、环境与 Maven 依赖](#二、环境与 Maven 依赖)
      • [2.1 版本基线](#2.1 版本基线)
      • [2.2 根 POM 的版本与依赖管理](#2.2 根 POM 的版本与依赖管理)
      • [2.3 服务模块依赖](#2.3 服务模块依赖)
    • 三、请求入口与任务分发
      • [3.1 HTTP 接口](#3.1 HTTP 接口)
      • [3.2 DPF 模板码与参数 DTO](#3.2 DPF 模板码与参数 DTO)
      • [3.3 `exportFile` 的分发逻辑](#3.3 exportFile 的分发逻辑)
    • 四、数据查询层:订单头、分页明细与模型转换
      • [4.1 查询边界接口](#4.1 查询边界接口)
      • [4.2 Provider 实现](#4.2 Provider 实现)
      • [4.3 MyBatis DAO 与 SQL](#4.3 MyBatis DAO 与 SQL)
      • [4.4 PDF 文档模型](#4.4 PDF 文档模型)
    • 五、异步任务编排:从分页到文件落盘
      • [5.1 核心方法完整代码](#5.1 核心方法完整代码)
      • [5.2 全量分页读取](#5.2 全量分页读取)
    • [六、PDF 渲染器:模板、条码、印章和中文字体](#六、PDF 渲染器:模板、条码、印章和中文字体)
      • [6.1 渲染器职责](#6.1 渲染器职责)
      • [6.2 关键渲染代码](#6.2 关键渲染代码)
      • [6.3 FreeMarker 模板](#6.3 FreeMarker 模板)
      • [6.4 运行时资源](#6.4 运行时资源)
    • [七、重点:PDF 模板到底是怎么生成的](#七、重点:PDF 模板到底是怎么生成的)
      • [7.1 四种表示形态](#7.1 四种表示形态)
        • [7.1.1 用一条商品记录追踪两次转换](#7.1.1 用一条商品记录追踪两次转换)
      • [7.2 实现顺序和依赖关系](#7.2 实现顺序和依赖关系)
      • [7.3 第一步:组装 Renderer 的唯一输入](#7.3 第一步:组装 Renderer 的唯一输入)
      • [7.4 第二步:Renderer 入口如何串起模板和 PDF](#7.4 第二步:Renderer 入口如何串起模板和 PDF)
      • [7.5 第三步:FreeMarker 如何读取模板](#7.5 第三步:FreeMarker 如何读取模板)
      • [7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型](#7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型)
      • [7.7 第五步:如何阅读 `.ftl` 模板](#7.7 第五步:如何阅读 .ftl 模板)
      • [7.8 第六步:动态价格列为什么要改两处](#7.8 第六步:动态价格列为什么要改两处)
      • [7.9 第七步:条码为什么要在模板处理前生成](#7.9 第七步:条码为什么要在模板处理前生成)
      • [7.10 第八步:印章和字体资源为何必须自包含](#7.10 第八步:印章和字体资源为何必须自包含)
      • [7.11 第九步:OpenHTMLToPDF 如何真正排版](#7.11 第九步:OpenHTMLToPDF 如何真正排版)
      • [7.12 第十步:文件发布和任务完成](#7.12 第十步:文件发布和任务完成)
      • [7.13 模板生成的最小验证闭环](#7.13 模板生成的最小验证闭环)
    • 八、最容易出错的三个跨层契约
      • [8.1 契约一:订单号是唯一业务输入,但不是唯一安全边界](#8.1 契约一:订单号是唯一业务输入,但不是唯一安全边界)
      • [8.2 契约二:`PageResult` 的 `hasNextPage` 必须和 `totalCount` 同源](#8.2 契约二:PageResulthasNextPage 必须和 totalCount 同源)
      • [8.3 契约三:文件路径必须在"内容成功"之后发布](#8.3 契约三:文件路径必须在“内容成功”之后发布)
    • [九、渲染器的内部数据流:不是一条 API 调用](#九、渲染器的内部数据流:不是一条 API 调用)
      • [9.1 四种表示形态](#9.1 四种表示形态)
      • [9.2 为什么资源必须来自 classpath](#9.2 为什么资源必须来自 classpath)
      • [9.3 字体注册的两个名字](#9.3 字体注册的两个名字)
    • 十、版式为什么选表格,而不是浏览器页面复制
      • [10.1 OpenHTMLToPDF 的能力边界](#10.1 OpenHTMLToPDF 的能力边界)
      • [10.2 长文本是版式测试,不是边角案例](#10.2 长文本是版式测试,不是边角案例)
      • [10.3 价格列隐藏时为什么要同步改 `colgroup`](#10.3 价格列隐藏时为什么要同步改 colgroup)
    • 十一、错误处理的可观测性:现在能知道什么,不能知道什么
      • [11.1 当前错误信息的定位价值](#11.1 当前错误信息的定位价值)
      • [11.2 仍需要补的监控字段](#11.2 仍需要补的监控字段)
    • 十二、测试证据应该如何解读
    • 十三、建议的下一步改造顺序
    • [十四、下载阶段与 MIME 类型](#十四、下载阶段与 MIME 类型)
    • 十五、验证、测试与预期结果
      • [15.1 自动化测试](#15.1 自动化测试)
      • [15.2 PDF 二进制与人工检查](#15.2 PDF 二进制与人工检查)
      • [15.3 真实环境验收边界](#15.3 真实环境验收边界)
    • 十六、故障排查与边界
      • [16.1 不应混淆的两个分页](#16.1 不应混淆的两个分页)
      • [16.2 价格隐藏不是安全边界](#16.2 价格隐藏不是安全边界)
    • 十七、扩展与维护建议
    • 十八、文件索引(完整引用清单)
    • 十九、结论

一、功能定位与最终产物

本章只定义范围,不把文件列表当作主体。读者真正要理解的是:客户端提交的是"导出意图",后台生成的是"可观察任务",最终交付的是"可下载文件"。三者不是同一个对象。

该功能接收一个业务订单号和"是否隐藏配销价格"开关,立即返回异步任务 ID。后台完成订单头、全部商品明细查询和 PDF 渲染后,将相对文件路径写入 PuTask.successUrl,客户端再调用既有下载接口取得 PDF。

最终文件名格式为:

text 复制代码
发货清单_<订单号>_<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

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

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-basePuTask、DAO、DTO)、common-redis(Redisson 锁)、common-threadIoIntensiveExecutor)和 Spring JDBC/MyBatis 基础设施。

三、请求入口与任务分发

本章的判断是:模板校验和并发控制必须发生在任务创建之前;否则错误会以"已创建但永远失败"的任务形式泄漏给调用方。

3.1 HTTP 接口

文件:service/excel-service/src/main/java/com/kkd/excel/controller/ExcelFileController.java

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

java 复制代码
@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

java 复制代码
SHIPPING_LIST_PDF_EXPORT("shippingListPdfExport", "发货清单PDF导出");

文件:common/common-base/src/main/java/com/kkd/common/base/model/req/ShippingListPdfExportDTO.java

java 复制代码
@Data
@Schema(description = "发货清单 PDF 导出参数")
public class ShippingListPdfExportDTO {

    @NotBlank(message = "订单号不能为空")
    private String orderNo;

    @NotNull(message = "是否隐藏配销价格不能为空")
    private Boolean hideDistributionPrice;
}

实际请求示例:

json 复制代码
{
  "templateCode": "shippingListPdfExport",
  "userId": 10001,
  "paramJson": "{\"orderNo\":\"P2608221420109816\",\"hideDistributionPrice\":false}"
}

返回值只包含任务 ID,例如:

json 复制代码
{"code":200,"data":12345}

3.3 exportFile 的分发逻辑

文件:service/excel-service/src/main/java/com/kkd/excel/service/impl/ExcelFileServiceImpl.java

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

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

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

java 复制代码
ShippingListPdfHeaderBO pdfLoadHeader(@Param("orderNo") String orderNo);

List<ShippingListPdfItemBO> pdfLoadItems(@Param("orderNo") String orderNo);

SQL 文件:common/common-base/src/main/resources/mapper/PuTemplateDao.xml

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/

java 复制代码
@Data
public class ShippingListPdfDocument {
    private ShippingListPdfHeader header;
    private List<ShippingListPdfItem> items = new ArrayList<>();
    private boolean hideDistributionPrice;
}
java 复制代码
@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;
}
java 复制代码
@Data
public class ShippingListPdfItem {
    private String upc;
    private Long quantity;
    private String productName;
    private String specification;
    private BigDecimal distributionPrice;
    private BigDecimal distributionAmount;
}

!WARNING

当前 SQL 已提供订单头的 totalQuantitytotalAmount,导出编排代码直接使用这两个查询结果。若业务要求"总金额必须由明细实时重算",应在 handlerExportShippingListPdf 中显式 BigDecimal 汇总,并同步更新模板和测试;不能仅依赖注释推断已经重算。

五、异步任务编排:从分页到文件落盘

本章的判断是:COMPLETED 必须晚于文件发布,而不是晚于线程启动或 Renderer 调用返回。

5.1 核心方法完整代码

java 复制代码
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 会根据行高和纸张尺寸自然分页。

java 复制代码
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

java 复制代码
public interface ShippingListPdfRenderer {
    void render(ShippingListPdfDocument document, OutputStream outputStream);
}

实现文件:service/excel-service/src/main/java/com/kkd/excel/service/impl/ShippingListPdfRendererImpl.java。完整实现包含以下阶段:

  1. Configuration.VERSION_2_3_32 从 classpath pdf 目录加载 shipping-list-pdf.ftl
  2. renderHtmldocument、条码 data URI、印章 data URI 放入 FreeMarker 模型;
  3. MultiFormatWriter 使用 BarcodeFormat.CODE_128 生成订单条码,并裁剪左右白边;
  4. pdf/shipping-list-stamp.png 读取印章并编码为 data:image/png;base64,...
  5. pdf/font/msyhbd.ttc 复制到临时文件,使用 PDFBox TrueTypeCollection 获取 MicrosoftYaHei-Bold
  6. 通过 PdfRendererBuilder.useFont 注册"Microsoft YaHei",调用 builder.run() 写入输出流;
  7. 删除临时字体文件,异常统一转换为 ServiceException

6.2 关键渲染代码

java 复制代码
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);
}

条码生成的核心代码:

java 复制代码
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

html 复制代码
@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 和表头/单元格,避免隐藏列后留下空白列:

html 复制代码
<#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 运行时资源

text 复制代码
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 四种表示形态

text 复制代码
ShippingListPdfDocument  Java 内存对象
        │
        │ document / barcodeDataUri / stampDataUri
        ▼
shipping-list-pdf.ftl    XHTML + FreeMarker 指令
        │
        │ template.process(model, writer)
        ▼
完整 XHTML 字符串        ${}、<#if>、<#list> 已展开
        │
        │ PdfRendererBuilder.withHtmlContent(...)
        ▼
PDF 布局结果             A4、表格、换行、真实页码
        │
        ▼
PDF 二进制输出            `%PDF-` 开头的文件流

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

  1. Java 文档模型 → XHTML 字符串;
  2. XHTML/CSS → PDF 页面对象。

前一次主要会失败于变量、FreeMarker 指令或 data URI;后一次主要会失败于 CSS 能力、字体、分页和 PDFBox 资源。只测试 HTML 字符串,不能证明 PDF 版式;只检查 %PDF-,也不能证明中文和页码正确。

7.1.1 用一条商品记录追踪两次转换

假设查询层交给 Renderer 的对象只有一条明细:

java 复制代码
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:

html 复制代码
<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 才会根据 @pagecolgroup、字体度量和可用宽度决定:这一行放在哪一页、单元格是否换行、表头是否重复、页脚显示什么页码。也就是说:

text 复制代码
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 的唯一输入

任务编排先完成查询和分页校验,再组装模型:

java 复制代码
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

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 目录:

java 复制代码
public ShippingListPdfRendererImpl() {
    freemarkerConfiguration =
            new Configuration(Configuration.VERSION_2_3_32);
    freemarkerConfiguration.setClassLoaderForTemplateLoading(
            getClass().getClassLoader(), "pdf");
    freemarkerConfiguration.setDefaultEncoding("UTF-8");
}

因此下面的调用读取的是打包资源:

java 复制代码
Template template = freemarkerConfiguration
        .getTemplate("shipping-list-pdf.ftl");

不是读取开发机的绝对路径。JAR 中必须存在:

text 复制代码
/pdf/shipping-list-pdf.ftl
/pdf/shipping-list-stamp.png
/pdf/font/msyhbd.ttc

7.6 第四步:把模型、条码、印章放入 FreeMarker 数据模型

java 复制代码
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());
    }
}

模板拿到的变量只有三个:

text 复制代码
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> 循环输出 商品明细行

最小数据绑定示例:

html 复制代码
<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 和表格内容:

html 复制代码
<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>
html 复制代码
<#if !document.hideDistributionPrice>
    <th>配销单价</th>
    <th>配货金额</th>
</#if>

只隐藏 <th>/<td> 会留下原价格列宽,商品名称和规格列会变窄。列集合和列宽必须使用同一个布尔语义。

7.9 第七步:条码为什么要在模板处理前生成

java 复制代码
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());
    }
}

模板只引用结果:

html 复制代码
<img class="barcode" src="${barcodeDataUri}" alt="订单条码" />

这样模板不需要知道 ZXing 的 BitMatrix。裁剪左右白边是为了让黑色条纹的真实右边界与右侧日期、订单号对齐,不只是视觉装饰。

7.10 第八步:印章和字体资源为何必须自包含

印章通过 ClassPathResource 读取后编码为:

java 复制代码
return "data:" + mediaType + ";base64,"
        + Base64.getEncoder().encodeToString(
                inputStream.readAllBytes());

模板只看到:

html 复制代码
<img class="stamp" src="${stampDataUri}" alt="印章" />

字体则在 builder.run() 之前注册。TTC 内部名称和 CSS 逻辑名称必须同时正确:

java 复制代码
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 如何真正排版

java 复制代码
builder.withHtmlContent(html, "classpath:/");
builder.toStream(outputStream);
builder.run();

run() 会解析 XHTML、匹配 CSS、加载已注册字体、计算表格布局、处理长文本换行、创建多页 PDF,并在页脚计算 counter(page)/counter(pages)。它不是浏览器截图,也不是把 HTML 原文存进 PDF。

模板必须使用渲染器支持的 CSS 子集:

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 写完流后,任务编排才检查文件并更新任务:

java 复制代码
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 分支:

java 复制代码
String html = renderer.renderHtml(document);
assertThat(html)
        .contains("配销单价", "配货金额")
        .contains("table-header-group", "counter(pages)")
        .contains("font-family: \"Microsoft YaHei\"");

再验证 PDF 二进制和页数:

java 复制代码
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);
}

验证命令:

bash 复制代码
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 契约二:PageResulthasNextPage 必须和 totalCount 同源

调用方依赖两个字段:

java 复制代码
long currentTotal = page.getTotalCount();
if (!Boolean.TRUE.equals(page.getHasNextPage())) {
    break;
}

如果 hasNextPage 来自一个查询、totalCount 来自另一个时间点,或 SQL 过滤条件不一致,就会出现三类错误:

错误 表现
总数偏大 最后一页为空,任务失败
总数偏小 收集数量超总数,任务失败
hasNext 错误 少读一页或无穷翻页

当前 Provider 用同一个 PageInfo 计算两者,这是正确方向;如果未来改成手写 SQL,必须保持同一查询快照和稳定排序。

8.3 契约三:文件路径必须在"内容成功"之后发布

当前顺序是:

text 复制代码
render -> 检查 isFile && length > 0 -> setSuccessUrl -> COMPLETED

不能提前写 successUrlsuccessUrl 一旦可见,下载端就会把它当成可用文件。对对象存储改造时同样要遵守这个顺序:上传成功并得到稳定对象键后才更新任务记录。

九、渲染器的内部数据流:不是一条 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 字体注册的两个名字

当前实现同时依赖:

java 复制代码
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-breakoverflow 或固定高度,必须重新生成多页 PDF 检查,而不是只看 HTML 快照。

10.3 价格列隐藏时为什么要同步改 colgroup

只隐藏 <th><td> 会留下原有列宽,造成商品名称和规格区域变窄。当前模板在 colgroup 内用同一个开关重分配宽度,这保证"列不存在"和"宽度不存在"同时成立。

十一、错误处理的可观测性:现在能知道什么,不能知道什么

11.1 当前错误信息的定位价值

以下错误已经能定位到阶段:

text 复制代码
请求参数解析失败
预订单信息不存在
发货清单分页总数发生变化
PDF 资源不存在: pdf/font/msyhbd.ttc
生成发货清单 PDF 失败: 文件为空

任务失败时 PuTask.handleResult 保存异常消息,日志额外记录 taskId 和任务描述,因此可以用任务 ID 将数据库记录和应用日志串起来。

11.2 仍需要补的监控字段

当前没有看到以下指标:

  • 每页 SQL 耗时和总页数;
  • HTML 渲染耗时、PDF 文件大小和最终页数;
  • 线程池排队时长;
  • 失败阶段的结构化错误码。

如果要做生产运维,建议增加结构化事件:PDF_QUERY_HEADERPDF_QUERY_PAGEPDF_RENDERPDF_PERSIST,但不要把订单地址、价格等业务数据写入日志。

十二、测试证据应该如何解读

当前定向命令:

bash 复制代码
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;
  • 浏览器端一定能正确下载。

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

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

  1. 先补真实数据库验收 :使用隔离库执行两条 SQL,检查 EXPLAIN、总数口径和订单权限;
  2. 再补任务级失败测试 :mock renderer 抛异常,断言最终状态为 FAILED 且不写 successUrl
  3. 再决定金额口径 :订单头 o.amount 与明细求和只能选一个权威来源;
  4. 再处理存储扩展:多实例部署优先抽象共享对象存储,保留稳定对象键;
  5. 最后做大单压测:记录商品数量、HTML 长度、PDF 页数、内存峰值和线程池排队。

十四、下载阶段与 MIME 类型

downloadFile 根据 PuTask.successUrl 取出真实文件名,并按扩展名设置响应类型:

java 复制代码
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 下载请求示例:

text 复制代码
GET /api/excelFile/downloadFile
    ?taskId=12345
    &functionType=1
    &downloadType=0

其中 functionType=1 表示导出,downloadType=0 表示下载成功文件。响应头使用真实文件名,不再无条件追加 .xlsx

十五、验证、测试与预期结果

本章不只列命令,而是明确每条证据能推出什么、不能推出什么。

15.1 自动化测试

当前已验证的命令:

bash 复制代码
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 文件头:

java 复制代码
assertThat(outputStream.toByteArray())
        .startsWith("%PDF-".getBytes(StandardCharsets.US_ASCII));

显式生成版式示例:

bash 复制代码
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 数据,不代表生产数据库一定可导出。上线前仍需在隔离数据环境验证:

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

十六、故障排查与边界

现象 根因 处理
模板不存在 pu_template 没有 shippingListPdfExport 补充模板数据并确认 code 完全一致
预订单信息不存在 订单号查不到 boc_procure_order 核对订单号和数据源
发货清单商品数据不完整 分页总数变化、空页或条数不足 检查并发修改、稳定排序和 SQL 条件
PDF 资源不存在 FTL、PNG 或 TTC 未打包 检查 src/main/resources/pdf 和 JAR 内容
中文乱码/方框 字体未嵌入或字体名称不一致 检查 FONT_NAMEFONT_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 仍会查询 distributionPricedistributionAmount,并且订单头仍携带 totalAmount。如果调用方不应获得价格相关数据,应在查询层按权限裁剪或拆分 DTO,不能只依赖模板隐藏。

十七、扩展与维护建议

  1. 新增字段:先扩展 BO、领域模型和 FTL,再补 Provider 映射与渲染测试。
  2. 新增字体字重 :修改 TrueTypeCollection 的字体名称和 useFont 注册,不要只把字体文件放进资源目录。
  3. 改金额口径 :明确是订单头金额还是明细实时求和,补充 BigDecimal 汇总测试,避免数据库字段和页面合计不一致。
  4. 改存储方式 :若迁移对象存储,保持 PuTask.successUrl 的下载契约不变,并同步修改下载服务的 MIME、鉴权和临时 URL 策略。
  5. 提高大单性能 :当前实现会把全部商品汇集到 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.javaShippingListPdfExportDTO.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.pngfont/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、运行时资源和文件目录权限均满足,当前实现已经具备可测试的端到端功能;生产验收仍必须使用隔离数据库验证真实订单、多页数据和文件下载。

相关推荐
跨境技工小黎17 分钟前
YouTube联盟营销如何变现?如何利用IP代理提高流量变现效果?
服务器·网络·tcp/ip
瓦学妹23 分钟前
HTTP代理性能分析:如何利用 IPFoxy 快速灵活切换协议?
网络·网络协议·http
Behaviour25 分钟前
Unity 手游网络同步技术
网络·unity·c#·游戏引擎
Doraemomo34 分钟前
Linux编程-并发TCP服务器实现与IO多路复用
linux·服务器·tcp/ip
深念Y1 小时前
# CC-Switch + Claude/Codex 折腾教训记录
运维·服务器·网络·ai·agent·web·ccsiwtch
OpenCloudOS1 小时前
高危|Linux 内核 XFS reflink 本地提权漏洞修复指南
linux·网络·安全
Sherotree1 小时前
Agent 工程笔记①:工具调用失败时,先查哪三层
网络·数据库·笔记
黑泽明*1 小时前
云计算与服务器基础入门指南
服务器·云计算·perl
小HANN1 小时前
保姆级实战:CentOS7 搭建 LAMP 环境部署 WordPress 个人博客
linux·运维·服务器·经验分享