Files
baiyue-firstleg/docs/2026-08-04-无状态PDF打印服务设计.md
2026-08-04 18:54:22 +08:00

21 KiB
Raw Permalink Blame History

无状态 PDF 打印服务设计

日期:2026-08-04
状态:待评审
适用模块:print-service
首版模板:INBOUND_MARK_V1
参考文件:PCHK2608040174-19件-MCC1.pdf 及同名标签截图

1. 结论

print-service 调整为无状态 PDF 渲染服务:调用方传入完整的 labels[],服务校验后在内存中生成一个多页 PDF,并在同一次 HTTP 响应中返回 PDF 二进制。

首版边界如下:

  • 不连接数据库,不保存模板、任务、历史或文件。
  • 不调用 Firstleg、Order、File、IAM 等业务服务,不反查或修正业务数据。
  • 不连接打印机,不处理驱动、打印队列、重试、补打或设备状态。
  • 不上传 File 服务,不写本地临时文件,不提供二次下载地址。
  • 一次请求生成一个 PDF;labels[] 中一个元素对应一页,数组顺序就是 PDF 页序。
  • 每页默认且首版只支持 100mm x 100mm,所有内容水平居中。
  • 最多支持 2000 页;超限时整次请求失败,不生成部分结果。
  • 成功直接返回 application/pdf;失败返回统一 JSON 错误响应。

现有 print_template、print_job、print_job_history 数据库草案不再属于本方案。实施时应移除 Print 模块的数据源、MyBatis、MySQL 依赖及环境配置,数据库脚本不进入部署流程。

2. 需求依据

2.1 已确认规则

  1. 调用方传入完整 labels[],Print 不自行按箱数扩展页面。
  2. CTNS:218-1 表示总计 218 箱中的第 1 箱。
  3. 二维码内容由调用方生成,例如 PCHK2607211476#218-1。
  4. 入仓码只在页面顶部展示一次;MADE IN CHINA 是固定文案。
  5. 仓库、渠道等字段由调用方传最终展示文案,Print 不维护代码映射。
  6. 首版不处理普通展示文字的长度适配;调用方保证文案合理,Print 使用固定字号直接居中绘制。
  7. 页面尺寸作为请求参数显式传入,首版只接受 100mm x 100mm。
  8. PDF 仅临时使用;需要再次下载时由调用方重新生成。
  9. 单次最多约 2000 箱,即最多 2000 页。
  10. 调用方对页面数据的真实性、页序、唯一性和业务权限负责。
  11. 中文字体随服务打包并嵌入 PDF,不依赖容器或宿主机字体。
  12. 首版只实现固定模板,不建设模板管理系统。

2.2 参考 PDF 核验结果

参考 PDF 共 19 页:

  • 每页 MediaBox 为 282.96pt x 282.96pt,约 99.822mm x 99.822mm。
  • 页码内容从 CTNS:19-1 连续变化到 CTNS:19-19。
  • 每页依次展示顶部入仓码、仓库文案、箱号文案、渠道文案、二维码、二维码明文和固定产地文案。
  • 参考文件使用 Microsoft YaHei 子集字体。

本服务以明确的 100mm x 100mm 契约为准,输出 MediaBox 使用 mm * 72 / 25.4 换算,即约 283.465pt x 283.465pt,不复制参考文件约 0.178mm 的尺寸误差。

3. 架构与调用边界

3.1 推荐调用方式

Admin 浏览器不直接调用 Print。公开业务接口由 Firstleg 或其他业务归属服务提供;业务服务完成登录态、按钮权限、租户、企业、仓库和业务数据校验,组装最终 labels[] 后,通过服务发现调用 Print 内部接口,再把 PDF 响应透传给浏览器。

sequenceDiagram
    participant A as "Admin 浏览器"
    participant B as "业务归属服务"
    participant P as "Print Service"

    A->>B: 请求生成业务标签 PDF
    B->>B: 校验 JWT、权限和数据范围
    B->>B: 组装完整 labels[]
    B->>P: 内部令牌 + PDF 请求
    P->>P: 全量校验并在内存渲染
    P-->>B: application/pdf
    B-->>A: application/pdf

采用内部调用的原因:

  • Print 不持有业务数据,无法判断浏览器提交的入仓码、仓库或箱数是否真实。
  • 若直接暴露给 Admin,Print 必须复制业务权限和数据范围规则,破坏服务边界。
  • 内部接口能避免任意已登录用户把 Print 当作通用 PDF/二维码生成器滥用。
  • 各业务服务可以复用同一个 Print 渲染能力,同时继续拥有自己的公开接口和权限码。

3.2 网络与鉴权

  • 内部接口路径不加入 Gateway 公网路由。
  • 调用方通过 Nacos 服务发现访问 print-service。
  • 请求必须携带共享的 X-Internal-Service-Token。
  • Print 使用与现有内部接口一致的 INTERNAL_SERVICE_TOKEN 配置,令牌至少 32 字节。
  • 令牌缺失或不匹配返回 A0403,不得在日志中打印令牌。
  • Print 不接受浏览器 JWT,也不新增 Print 业务权限码;权限由调用方业务服务校验。

4. 接口契约

4.1 生成接口

POST /internal/print/v1/pdfs/inbound-marks
Content-Type: application/json
Accept: application/pdf, application/json
X-Internal-Service-Token: ***
X-Request-Id: 可选

Accept 必须同时列出两种类型:成功响应是 PDF,失败响应是 JSON ApiResponse。若只声明 application/pdf,Spring MVC 在异常解析阶段重新做内容协商时会因为无法接受 application/json 而返回 406 空响应体,调用方拿不到本设计约定的错误码。原因与实现约束见 4.4。

请求示例:

{
  "templateCode": "INBOUND_MARK_V1",
  "pageSpec": {
    "widthMm": 100,
    "heightMm": 100
  },
  "downloadFileName": "PCHK2608040174-19件-MCC1.pdf",
  "labels": [
    {
      "topCode": "PCHK2608040174",
      "warehouseText": "MCC1",
      "cartonText": "CTNS:19-1",
      "routeText": "洛杉矶普船海卡",
      "qrContent": "PCHK2608040174#19-1"
    },
    {
      "topCode": "PCHK2608040174",
      "warehouseText": "MCC1",
      "cartonText": "CTNS:19-2",
      "routeText": "洛杉矶普船海卡",
      "qrContent": "PCHK2608040174#19-2"
    }
  ]
}

qrContent 同时用于生成二维码和二维码下方的明文,避免两处内容不一致。MADE IN CHINA 由模板固定输出,不作为请求字段。

4.2 请求字段

字段 必填 首版规则
templateCode 是 只能为 INBOUND_MARK_V1
pageSpec.widthMm 是 只能为数值 100
pageSpec.heightMm 是 只能为数值 100
downloadFileName 否 1-120 个 Unicode 码点;只允许文件基本名,必须以 .pdf 结尾
labels 是 1-2000 项;数组顺序即页序
labels[].topCode 是 非空;单行;禁止控制字符
labels[].warehouseText 是 非空;单行;禁止控制字符
labels[].cartonText 是 非空;单行;禁止控制字符
labels[].routeText 是 非空;单行;禁止控制字符
labels[].qrContent 是 1-34 个可打印 ASCII 字符;单行

普通展示字段不设置业务字符数上限,也不因实际文字宽度拒绝请求。调用方负责保证文案适合固定版式;若文字超过页面安全区,Print 仍按固定字号和中心点绘制,不换行、不缩小、不截断,也不把版面越界判定为接口错误。qrContent 的限制继续保留,因为它同时影响固定尺寸二维码的扫描密度和二维码下方明文的可读性。

补充规则:

  • 所有字符串先做 Unicode NFC 规范化,再按各字段规则进行非空、长度和字符校验;普通展示字段不增加长度校验。
  • 不允许 \r、\n、制表符、双向控制符、NUL 或其他不可见控制字符。该规则适用于全部请求字符串,downloadFileName 同样适用。
  • pageSpec.widthMm 和 heightMm 按数值比较,100 与 100.0 都接受,非整数或其他数值一律 A0422。
  • Print 不解析 cartonText,不校验箱号是否连续,也不校验 qrContent 与其他字段的业务关系。
  • 调用方必须保证页面没有重复、缺失或乱序;Print 只保证按数组顺序渲染。
  • 文件名剥离路径语义,禁止 /、\、: 及其他文件系统保留字符,避免调用方把带路径的名字直接落到磁盘。
  • 未传文件名时使用 inbound-marks-{requestId}.pdf。requestId 取自 CorrelationContext,实施前需确认请求未带 X-Request-Id 时该组件会生成一个,否则文件名会出现 null。

4.3 成功响应

HTTP/1.1 200 OK
Content-Type: application/pdf
Content-Disposition: attachment; filename="inbound-marks.pdf"; filename*=UTF-8''PCHK2608040174-19%E4%BB%B6-MCC1.pdf
Content-Length: ...
Cache-Control: no-store, max-age=0
Pragma: no-cache
X-Content-Type-Options: nosniff
X-Print-Page-Count: 19
X-Request-Id: ...

响应头实现约定:

  • 复用项目现有下载写法 ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8)(见 file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java:66),由它统一完成 RFC 5987 编码并同时输出 ASCII filename= 回退与 filename*=,不手工拼接响应头。
  • 控制器方法不得声明 produces = "application/pdf",否则会把该方法的可产出类型钉死为 PDF,异常处理阶段无法写出 JSON 错误体。成功分支显式调用 .contentType(MediaType.APPLICATION_PDF) 设置类型。
  • Cache-Control 使用 CacheControl.noStore(),与现有下载接口一致。

PDF 完整生成并通过大小检查后才写入 HTTP 响应,禁止边生成边返回半个 PDF。

4.4 失败响应

成功响应是 PDF,失败响应是项目统一的 JSON ApiResponse。调用方必须同时检查 HTTP 状态、Content-Type 和 JSON code,不能只判断 HTTP 200。

场景 响应约定
JSON、字段或字符校验失败 HTTP 200 + application/json + A0422
模板或页面尺寸不支持 HTTP 200 + application/json + A0422
内部令牌缺失或错误 HTTP 200 + application/json + A0403
请求体超过容器限制 HTTP 413 + application/json
并发生成槽位已满 HTTP 200 + application/json + A0429
PDF 生成失败或超过输出上限 HTTP 200 + application/json + S0500

错误信息只指出字段位置和规则,例如 labels[18].routeText contains a line break,不回显完整标签数组或内部异常堆栈。

5. PDF 渲染契约

5.1 页面与坐标

  • 页面物理尺寸:严格 100mm x 100mm。
  • PDF 坐标换算:points = millimeters * 72 / 25.4。
  • 页面旋转:0。
  • 背景:纯白。
  • 前景:纯黑,不使用灰色、透明度、渐变或彩色元素。
  • 内容安全区:四周至少 5mm;所有文本和二维码水平居中。
  • 每个字段只占一行,不自动换行,不截断,不输出省略号。

首版布局基线。下表所有 y 均为「自页面顶部向下量取」的中心线位置,与阅读顺序一致,不是 PDF 原生坐标:

区域 顶部向下中心或范围 初始字号/尺寸 规则
顶部入仓码 中心 y = 11mm 24pt 粗体 单行居中
仓库文案 中心 y = 26mm 36pt 粗体 单行居中
箱号文案 中心 y = 37mm 22pt 粗体 单行居中
渠道文案 中心 y = 47mm 21pt 粗体 单行居中
二维码画布 48.72mm-84mm 35.28mm x 35.28mm(100pt) 水平居中,含 4 modules quiet zone
二维码明文 中心 y = 85.5mm 12pt 粗体 内容取 qrContent
固定产地 中心 y = 92mm 14pt 粗体 固定 MADE IN CHINA

PDF 原生坐标原点在左下角,y 轴向上,与上表方向相反。二者混用会让整张标签上下颠倒,因此坐标换算必须收敛到 PdfUnits 一处:

  • PdfUnits 只对外暴露 topDownMmToPdfY(double mmFromTop) 与 mmToPt(double mm) 两个入口,前者内部完成 pageHeightPt - mmToPt(mmFromTop) 翻转。
  • 渲染代码一律以「自顶向下的毫米值」思考和传参,禁止在绘制方法中出现手写 point 常量,也禁止绕过 PdfUnits 直接使用 PDF 原生 y。
  • 文本按中心线垂直居中时,基线由字体上升距离和字号在 PdfUnits 内换算,绘制方法不自行加减半个字号。

上述位置是依据参考 PDF 得到的首版基线,最终以渲染后的金样验收为准。

5.2 文本绘制

  • 所有普通展示文字使用 5.1 约定的固定字号和中心点直接绘制。
  • 不测量文字是否超出安全区,不动态缩小字号,不换行、不截断、不输出省略号,也不横向压缩字体。
  • Print 只保证请求中的文字被交给 PDFBox 绘制,不保证任意长度文字都能落在页面范围内;版式内容质量由调用方负责。
  • 固定字号或位置需要调整时,通过新增模板版本完成,不得静默改变 INBOUND_MARK_V1。

5.3 字体

  • 随服务打包 Noto Sans SC Bold 静态 TTF 字体及对应 OFL 许可证。
  • 每个 PDF 文档只加载一次字体对象,启用字体子集并嵌入 PDF。
  • 不使用 Windows 的 Microsoft YaHei,不从系统字体目录动态加载字体。
  • 启动时校验字体资源存在且可解析;缺失时 readiness 失败,不能静默回退到不支持中文的字体。

5.4 二维码

  • 使用 ZXing 根据 qrContent 生成 QR Code。
  • 字符集固定为 UTF-8;首版字段限制为可打印 ASCII。
  • 纠错等级使用 M,保留 4 modules quiet zone。
  • 二维码在 PDF 中按模块直接绘制黑色矢量矩形,不先生成低分辨率 PNG,不进行插值缩放。
  • 二维码下方明文与编码内容来自同一个 qrContent 值。
  • 每页二维码必须可由 ZXing 反向解码,并与请求值完全一致。

6. Java 实现方案

6.1 依赖选型

  • PDF:Apache PDFBox。
  • QR Code:ZXing Core。
  • 输入校验:Jakarta Validation 加服务层全量校验。
  • HTTP:现有 Spring Boot MVC、Common Web 和内部令牌模式。

不采用 HTML/CSS 转 PDF 或无头浏览器:标签要求精确物理尺寸,HTML 打印受 DPI、浏览器分页和字体环境影响更大。不采用 iText:当前需求不需要其高级能力,同时需要避免额外商业许可边界。

6.2 建议包结构

com.baiyue.print
├─ controller
│  └─ InternalPdfController.java
├─ dto
│  └─ InboundMarkPdfDtos.java
├─ service
│  ├─ InboundMarkPdfService.java
│  ├─ InboundMarkRequestValidator.java
│  └─ PdfGenerationLimiter.java
├─ renderer
│  ├─ InboundMarkV1Renderer.java
│  ├─ PdfQrRenderer.java
│  └─ PdfUnits.java
└─ config
   └─ PrintPdfProperties.java

建议资源:

print/src/main/resources/fonts/NotoSansSC-Bold.ttf
print/src/main/resources/fonts/OFL.txt
print/src/main/resources/openapi/print-internal-v1.yaml

6.3 处理流程

  1. 校验内部令牌。
  2. 校验请求体大小、模板、页面尺寸、文件名和页数。
  3. 对全部页面字符串进行规范化、非空和控制字符校验。
  4. 获取并发生成槽位。
  5. 在并发保护范围内生成并校验全部 QR BitMatrix;任一失败则终止。
  6. 创建一个 PDF 文档,加载一次嵌入字体。
  7. 按 labels[] 顺序创建页面并渲染。
  8. 保存到受上限保护的内存输出流。
  9. 校验 PDF 字节数、页数和基本结构。
  10. 构造下载响应,释放文档、字体和内存对象。

全过程不创建数据库事务、任务 ID、磁盘文件或异步队列。

7. 容量与资源保护

首版默认配置建议:

print:
  pdf:
    max-pages: 2000
    max-request-bytes: 2097152
    max-output-bytes: 33554432
    max-concurrent-generations: 4
    acquire-timeout: 1s
    template-code: INBOUND_MARK_V1
    supported-width-mm: 100
    supported-height-mm: 100
  • max-pages 是硬上限,不能通过请求覆盖。
  • max-request-bytes 同时在应用和入口代理配置,防止超大 JSON 在反序列化前占用内存。
  • 内存输出流在超过 max-output-bytes 时立即抛出受控异常。
  • 使用进程内 Semaphore 限制并发生成数;槽位满时快速失败,不在 JVM 内形成无界等待队列。
  • 字体只在单个 PDF 文档中加载一次,不得每页创建一份字体子集。
  • 服务保持无状态,可以水平扩容;单实例并发数应根据容器内存压测后调整。

发布前容量验收至少覆盖 1、19、218 和 2000 页。2000 页请求的目标是 30 秒内完成且 PDF 小于 32MiB;实际 P95、峰值堆内存和容器 CPU 以测试环境压测结果回填,不在未经测量时承诺更激进指标。

8. 日志、监控与隐私

允许记录:

  • requestId、调用服务名、模板编码、页面尺寸、页数。
  • 生成耗时、PDF 字节数、成功/失败结果和有限错误码。
  • 当前并发数、等待槽位时间和内存上限拒绝次数。

禁止记录:

  • 完整 labels[]、二维码内容、内部令牌和 PDF 字节。
  • 将生成的 PDF 写入日志、异常附件或故障目录。

建议指标:

  • print_pdf_requests_total{result,template}
  • print_pdf_generation_seconds{template}
  • print_pdf_pages_total{template}
  • print_pdf_output_bytes{template}
  • print_pdf_rejections_total{reason}
  • print_pdf_active_generations

9. 测试与验收

9.1 自动化测试

  • DTO 校验:空数组、1001 页、空字段、控制字符、超长二维码内容、非法文件名和错误页面尺寸。
  • 接口契约:内部令牌、成功 PDF、JSON 错误、响应头和 Cache-Control: no-store。
  • PDF 结构:页数等于 labels.size(),每页精确 100mm x 100mm,旋转为 0。
  • 文本内容:每页六个文本区完整,顶部入仓码不在底部重复。
  • 页序:输入第 N 项必须成为 PDF 第 N 页。
  • 二维码:渲染页面后用 ZXing 解码,结果必须等于该页 qrContent。
  • 字体:PDF 中存在嵌入字体,中文渲染无方框、乱码或系统字体依赖。
  • 金样渲染:对固定业务样本渲染 PNG,检查内容边界、居中和布局容差;不比较易受元数据影响的完整 PDF 二进制。
  • 容量:1、19、218、2000 页生成成功,输出不超过配置上限。
  • 并发:超过生成槽位时快速返回受控错误,完成后槽位必定释放。
  • 失败原子性:任一页无效时不返回部分 PDF,也不产生磁盘文件。

9.2 人工验收

  1. 用参考文件的 19 页数据生成 PDF,逐页比对页面内容和页序。
  2. 渲染第一页、中间页和最后一页,确认全部元素水平居中且无重叠、裁切或乱码。
  3. 在实际打印环境按 100% 比例打印,确认成品尺寸为 100mm x 100mm,关闭“适合页面/缩放”选项。
  4. 使用现场扫码设备扫描首、中、末页二维码。
  5. 使用真实业务中的典型较长文案确认固定字号下的展示结果可接受。
  6. 生成 2000 页并记录耗时、PDF 大小、峰值堆内存和 CPU。

10. 实施范围

10.1 本次实现应包含

  • 移除 Print 数据库、MyBatis 和 MySQL 依赖及配置。
  • 新增 PDFBox、ZXing 和合法授权的中文字体资源。
  • 实现内部 PDF 接口、请求校验、固定模板渲染和资源保护。
  • 增加内部 OpenAPI、单元测试、契约测试、金样渲染测试和 2000 页容量测试。
  • 保留 Actuator 健康检查;现有逻辑状态接口可继续用于平台运维,但不承担业务能力。
  • 更新 Print 模块指引、部署配置和 Gateway 说明,明确内部业务接口不公开。

10.2 本次不包含

  • Admin 页面、业务服务公开下载接口及业务权限码。
  • 打印模板 CRUD、模板版本或数据库表。
  • 文件中心、对象存储、下载中心或下载次数管理。
  • 打印机发现、USB/网络打印、本地代理、驱动和设备状态。
  • 打印任务、失败重试、补打、换标、作废和审计历史。
  • 任意页面尺寸、任意字段布局或通用报表生成。

11. 后续兼容策略

  • templateCode 和 pageSpec 从首版进入契约,但首版只接受一个固定组合。
  • 新尺寸或新版布局必须新增模板编码,例如 INBOUND_MARK_V2,不得静默改变 V1 的位置、字号或固定文案。
  • 若未来需要直接从 Admin 调用,必须先定义独立的授权与数据真实性方案,不能直接开放本内部接口。
  • 若未来需要暂存或重复下载,应由 File/下载中心承担文件生命周期,不能在当前纯渲染接口中逐步加入数据库状态。
  • 若未来需要控制佳博等热敏打印机,应新增本地打印代理或设备服务,本服务仍只负责生成可打印内容。

12. 待评审项

以下事项不阻塞设计评审,但应在编码或上线前确认:

  1. Noto Sans SC Bold 的最终字体文件版本、仓库体积和许可证归档位置。
  2. 业务归属服务的公开下载接口路径与按钮权限码。
  3. 测试环境单实例 CPU、内存限制及 max-concurrent-generations 最终值。
  4. 现场打印驱动是否把 100mm x 100mm PDF 保持为 100% 比例,是否存在硬件不可打印边缘。