打印服务

This commit is contained in:
2026-08-04 18:54:22 +08:00
parent b7d36d4766
commit dc2f844467
44 changed files with 2576 additions and 110 deletions
+1
View File
@@ -24,3 +24,4 @@ reports/runtime*/
reports/uploads/
reports/db-backup*/
uploads/
.codex-temp*
+2 -1
View File
@@ -24,7 +24,7 @@
| `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` |
| `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` |
| `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` |
| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 |
| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` |
| `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 |
| `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` |
| `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` |
@@ -39,6 +39,7 @@
| 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` |
| 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` |
| 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` |
| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway |
| HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` |
| 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` |
+1 -1
View File
@@ -9,7 +9,7 @@
- `order`:订单主数据及订单状态编排。
- `firstleg`:头程揽收、入仓、装载、发运和交接。
- `warehouse`:国内仓储能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力。
- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。
- `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。
- `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。
- `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。
+11 -6
View File
@@ -1,6 +1,11 @@
spring:
datasource:
url: ${PRINT_DB_URL}
username: ${PRINT_DB_USERNAME}
password: ${PRINT_DB_PASSWORD}
driver-class-name: com.mysql.cj.jdbc.Driver
print:
pdf:
max-pages: ${PRINT_PDF_MAX_PAGES:2000}
max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152}
max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432}
max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4}
acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s}
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
+11 -5
View File
@@ -1,5 +1,11 @@
spring.datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${PRINT_DB_USERNAME:root}
password: ${PRINT_DB_PASSWORD:123456}
print:
pdf:
max-pages: ${PRINT_PDF_MAX_PAGES:2000}
max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152}
max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432}
max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4}
acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s}
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
+1 -3
View File
@@ -142,9 +142,7 @@ services:
environment:
<<: *common-environment
SERVER_PORT: 9207
PRINT_DB_URL: ${PRINT_DB_URL}
PRINT_DB_USERNAME: ${PRINT_DB_USERNAME}
PRINT_DB_PASSWORD: ${PRINT_DB_PASSWORD}
JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8"
integration-service:
<<: *common
@@ -0,0 +1,417 @@
# 无状态 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 响应透传给浏览器。
```mermaid
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 生成接口
```http
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。
请求示例:
```json
{
"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
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 建议包结构
```text
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
```
建议资源:
```text
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. 容量与资源保护
首版默认配置建议:
```yaml
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% 比例,是否存在硬件不可打印边缘。
@@ -92,7 +92,7 @@ baiyue-firstleg/
| `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox |
| `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、地址、服务能力、往来单位、快递公司,以及二期托盘、库位、预占和占用;往来单位不单独拆服务 |
| `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 |
| `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 |
| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF |
| `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 |
| `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 |
| `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;当前配置默认关闭业务审计发送,可按环境开启 |
@@ -139,7 +139,9 @@ File 负责企业资质文件、公共异步导出与下载、入库称重图片
### 5.8 `print/`:打印中心
Print 已建立独立服务、端口和数据库边界,当前主要是应用骨架、健康检查和目标表结构。打印模板版本、打印任务、PDF/标签生成、失败重试和与 File 的文件引用仍需按阶段需求继续实现;Order 只提交打印请求,不直接生成面单或操作打印机。
Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权限和数据真实性校验,组装完整 `labels[]` 后通过内部令牌调用 Print;Print 只在内存中生成一次性多页 PDF 并同步返回。本服务不保存模板、任务、历史或文件,不调用 File,不连接打印机,也不提供二次下载地址。
业务服务接入时先读 `print/README.md`,机器可读契约以 `print/src/main/resources/openapi/print-internal-v1.yaml` 为准。
### 5.9 `audit-service/`:审计日志中心
+1 -2
View File
@@ -53,11 +53,10 @@ TEST_DB_PASSWORD=测试数据库密码
| 仓库服务 | `baiyue_warehouse` |
| 头程履约服务 | `baiyue_firstleg` |
| 文件服务 | `baiyue_file` |
| 打印服务 | `baiyue_print` |
| 审计服务 | `baiyue_audit` |
| 事件桥接服务 | `baiyue_event_bridge` |
网关和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。
网关、打印和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。
## 注意事项
+19 -7
View File
@@ -2,20 +2,32 @@
## 职责边界
- 本模块定义打印中心独立服务边界;当前只包含应用入口、状态接口、配置和数据库骨架。
- 打印模板版本、任务状态机、PDF/标签生成、重试和幂等尚未完整实现;开始扩展前必须先核对阶段需求,不从原型自动扩大范围。
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
- 首版只支持 `INBOUND_MARK_V1` 和 `100mm x 100mm`;新尺寸或布局必须使用新模板编码,不得静默改变 V1。
## 快速索引
| 功能 | 入口 |
|---|---|
| 调用方接入指南 | `README.md` |
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
| 当前状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
| 数据库骨架 | `src/main/resources/db/print.sql` |
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`) |
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java` |
| 固定模板与二维码渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/PdfGenerationLimiter.java` |
| PDF 参数配置 | `config/PrintPdfProperties.java` |
| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` |
| 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` |
| 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` |
| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` |
## 修改与验证
- 订单服务只应提交打印请求,不自行生成面单或操作打印机;生成文件通过 File 服务引用。
- 新增任务处理时设计明确的状态机、幂等键、失败重试、模板版本与权限边界。
- 当前无专属测试时,不为小改动引入整套新测试框架;至少从父目录运行 `mvn -pl print -am test` 并记录接口验证。
- 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
- 修改后至少从父目录运行 `mvn -pl print -am test`;渲染变更同时核对页面尺寸、页数、页序、二维码反向解码和字体嵌入。
+188
View File
@@ -0,0 +1,188 @@
# Print PDF 服务接入指南
本文面向需要生成入仓标签 PDF 的业务服务。Print 是无数据库的内部渲染服务:调用方提交完整标签数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
完整机器可读契约见 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml),版式与技术决策见 [`../docs/2026-08-04-无状态PDF打印服务设计.md`](../docs/2026-08-04-无状态PDF打印服务设计.md)。
## 1. 调用边界
- 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
- 本地直连:`http://127.0.0.1:9207`。
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
- Admin 请求业务服务;业务服务完成 JWT、租户、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。
调用方不得要求 Print 查询订单、补箱号、排序或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
## 2. 请求契约
```http
POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1
Content-Type: application/json
Accept: application/pdf, application/json
X-Internal-Service-Token: <shared-token>
X-Request-Id: <optional-correlation-id>
```
```json
{
"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"
}
]
}
```
| 字段 | 必填 | 规则 |
|---|---|---|
| `templateCode` | 是 | 固定 `INBOUND_MARK_V1` |
| `pageSpec.widthMm` | 是 | 固定数值 `100` |
| `pageSpec.heightMm` | 是 | 固定数值 `100` |
| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 |
| `labels` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 |
| `labels[].topCode` | 是 | 非空、单行、无控制字符 |
| `labels[].warehouseText` | 是 | 非空、单行、无控制字符 |
| `labels[].cartonText` | 是 | 最终展示文案;Print 不解析箱号 |
| `labels[].routeText` | 是 | 最终展示文案;非空、单行、无控制字符 |
| `labels[].qrContent` | 是 | 1-34 个可打印 ASCII 字符 |
普通展示字段首版不限制业务字符数,也不会自动换行、缩小或截断。调用方负责保证文案适合固定版式。
未传 `downloadFileName` 时,服务使用 `inbound-marks-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
## 3. 响应处理
调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200:
| 场景 | HTTP | Content-Type | 处理方式 |
|---|---:|---|---|
| 生成成功 | 200 | `application/pdf` | 读取二进制并转发或下载 |
| 参数、鉴权、并发或生成失败 | 200 | `application/json` | 按 `ApiResponse.code` 处理 |
| 请求体超过 2 MiB | 413 | `application/json` | 缩小请求,不要重试相同内容 |
成功响应包含:
- `Content-Disposition`:下载文件名;
- `Content-Length`:PDF 字节数;
- `X-Print-Page-Count`:实际页数;
- `X-Request-Id`:请求链路标识;
- `Cache-Control: no-store`、`Pragma: no-cache`。
常见错误码:
| code | 含义 | 调用方建议 |
|---|---|---|
| `A0400` | JSON 无法解析或请求体过大 | 修正请求;HTTP 413 时缩小请求体 |
| `A0403` | 内部令牌缺失或错误 | 检查共享令牌配置,不向浏览器暴露令牌 |
| `A0422` | 字段、模板、尺寸、页数或字符规则不合法 | 修正业务服务组装的数据 |
| `A0429` | PDF 生成并发槽位已满 | 短暂退避后有限重试,避免立即并发重放 |
| `S0500` | PDF 生成失败或超过输出上限 | 记录 `requestId/traceId` 并告警,不返回半个 PDF |
## 4. OpenFeign 接入示例
调用方自行维护请求 DTO,不依赖 Print 模块的 Java 实现类。跨服务事实来源是 OpenAPI。
```java
@FeignClient(
name = "print-service",
contextId = "printPdfClient",
url = "${baiyue.clients.print.url:}",
path = "/internal/print/v1/pdfs")
public interface PrintPdfClient {
@PostMapping(value = "/inbound-marks", consumes = "application/json")
ResponseEntity<byte[]> generate(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody InboundMarkPdfRequest request);
record InboundMarkPdfRequest(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<Label> labels) {}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
record Label(
String topCode,
String warehouseText,
String cartonText,
String routeText,
String qrContent) {}
}
```
调用封装必须区分 PDF 与 JSON:
```java
ResponseEntity<byte[]> response = printPdfClient.generate(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
byte[] body = response.getBody() == null ? new byte[0] : response.getBody();
if (contentType != null && MediaType.APPLICATION_PDF.isCompatibleWith(contentType)) {
return ResponseEntity.ok()
.headers(copySafeDownloadHeaders(response.getHeaders()))
.contentType(MediaType.APPLICATION_PDF)
.body(body);
}
ApiResponse<?> error = objectMapper.readValue(body, ApiResponse.class);
throw mapPrintError(error, requestId);
```
`copySafeDownloadHeaders` 只转发 `Content-Disposition`、`Content-Length`、`Cache-Control`、`Pragma`、`X-Print-Page-Count` 和链路标识,不转发内部令牌。由于业务错误也使用 HTTP 200,Feign fallback 不能替代 `Content-Type + ApiResponse.code` 判断。HTTP 413 会进入 Feign 的非 2xx 异常流程,调用方应在 `FeignException` 中保留状态码、JSON body、`requestId` 和 `traceId`。
调用方建议为该客户端单独配置超时:
```yaml
baiyue:
clients:
print:
url: ${PRINT_SERVICE_URL:}
spring:
cloud:
openfeign:
client:
config:
printPdfClient:
connect-timeout: 3000
read-timeout: 30000
```
`url` 为空时通过 Nacos 的 `print-service` 发现;本地单独联调可设置 `PRINT_SERVICE_URL=http://127.0.0.1:9207`。
## 5. 重试、并发与日志
- Print 默认最多同时生成 4 份 PDF,等待槽位 1 秒后返回 `A0429`。
- 不建议对该 POST 做无限或无退避自动重试;超时或 `A0429` 最多有限重试,并加入退避和抖动。
- 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。
- 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、二维码内容或 PDF 二进制。
## 6. 联调检查清单
1. `GET /actuator/health` 返回 `UP`。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
3. 检查页面为严格 `100mm x 100mm`,`X-Print-Page-Count` 与 `labels.length` 一致。
4. 提交多页并检查页序与数组顺序一致。
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。
+10 -6
View File
@@ -5,16 +5,20 @@
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
<artifactId>print-service</artifactId>
<dependencies>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
<dependency>
<groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version>
<exclusions><exclusion><groupId>com.baomidou</groupId><artifactId>*</artifactId></exclusion></exclusions>
</dependency>
<dependency>
<groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version>
<exclusions><exclusion><groupId>com.baomidou</groupId><artifactId>*</artifactId></exclusion></exclusions>
</dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>3.0.7</version></dependency>
<dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.3</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
</dependencies>
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
@@ -6,7 +6,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 打印服务启动入口。
*/
@SpringBootApplication(scanBasePackages = "com.baiyue")
@SpringBootApplication
public class PrintServiceApplication {
public static void main(String[] args) {
SpringApplication.run(PrintServiceApplication.class, args);
@@ -0,0 +1,33 @@
package com.baiyue.print.config;
import java.io.InputStream;
import org.apache.fontbox.ttf.TTFParser;
import org.apache.fontbox.ttf.TrueTypeFont;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
/** Exposes font packaging/parsing failures through readiness health. */
@Component("printFont")
public class PrintFontHealthIndicator implements HealthIndicator {
private final Health validationResult;
public PrintFontHealthIndicator(PrintPdfProperties properties) {
this.validationResult = validate(properties.getFontResource());
}
@Override
public Health health() {
return validationResult;
}
private Health validate(String fontResource) {
try (InputStream input = new ClassPathResource(fontResource).getInputStream();
TrueTypeFont ignored = new TTFParser().parseEmbedded(input)) {
return Health.up().withDetail("fontResource", fontResource).build();
} catch (Exception exception) {
return Health.down(exception).withDetail("fontResource", fontResource).build();
}
}
}
@@ -0,0 +1,95 @@
package com.baiyue.print.config;
import java.math.BigDecimal;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 无状态 PDF 生成的资源与固定模板配置。
*/
@Component
@ConfigurationProperties(prefix = "print.pdf")
public class PrintPdfProperties {
private int maxPages = 2000;
private long maxRequestBytes = 2L * 1024 * 1024;
private long maxOutputBytes = 32L * 1024 * 1024;
private int maxConcurrentGenerations = 4;
private Duration acquireTimeout = Duration.ofSeconds(1);
private String templateCode = "INBOUND_MARK_V1";
private BigDecimal supportedWidthMm = new BigDecimal("100");
private BigDecimal supportedHeightMm = new BigDecimal("100");
private String fontResource = "fonts/NotoSansSC-Bold.ttf";
public int getMaxPages() {
return maxPages;
}
public void setMaxPages(int maxPages) {
this.maxPages = maxPages;
}
public long getMaxOutputBytes() {
return maxOutputBytes;
}
public long getMaxRequestBytes() {
return maxRequestBytes;
}
public void setMaxRequestBytes(long maxRequestBytes) {
this.maxRequestBytes = maxRequestBytes;
}
public void setMaxOutputBytes(long maxOutputBytes) {
this.maxOutputBytes = maxOutputBytes;
}
public int getMaxConcurrentGenerations() {
return maxConcurrentGenerations;
}
public void setMaxConcurrentGenerations(int maxConcurrentGenerations) {
this.maxConcurrentGenerations = maxConcurrentGenerations;
}
public Duration getAcquireTimeout() {
return acquireTimeout;
}
public void setAcquireTimeout(Duration acquireTimeout) {
this.acquireTimeout = acquireTimeout;
}
public String getTemplateCode() {
return templateCode;
}
public void setTemplateCode(String templateCode) {
this.templateCode = templateCode;
}
public BigDecimal getSupportedWidthMm() {
return supportedWidthMm;
}
public void setSupportedWidthMm(BigDecimal supportedWidthMm) {
this.supportedWidthMm = supportedWidthMm;
}
public BigDecimal getSupportedHeightMm() {
return supportedHeightMm;
}
public void setSupportedHeightMm(BigDecimal supportedHeightMm) {
this.supportedHeightMm = supportedHeightMm;
}
public String getFontResource() {
return fontResource;
}
public void setFontResource(String fontResource) {
this.fontResource = fontResource;
}
}
@@ -0,0 +1,64 @@
package com.baiyue.print.controller;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/internal/print/v1/pdfs")
public class InternalPdfController {
static final String INTERNAL_TOKEN_HEADER = "X-Internal-Service-Token";
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
private final InboundMarkPdfService pdfService;
private final InternalServiceTokenGuard tokenGuard;
public InternalPdfController(InboundMarkPdfService pdfService, InternalServiceTokenGuard tokenGuard) {
this.pdfService = pdfService;
this.tokenGuard = tokenGuard;
}
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generateInboundMarks(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@Valid @RequestBody InboundMarkPdfDtos.Request request,
HttpServletRequest servletRequest) {
tokenGuard.verify(internalToken);
String requestId = CorrelationContext.requestId(servletRequest);
InboundMarkPdfDtos.Request effectiveRequest = request;
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
effectiveRequest = new InboundMarkPdfDtos.Request(request.templateCode(), request.pageSpec(),
"inbound-marks-" + requestId + ".pdf", request.labels());
}
InboundMarkPdfService.GeneratedPdf pdf = pdfService.generate(effectiveRequest);
byte[] content = pdf.content();
ContentDisposition disposition = ContentDisposition.attachment()
.filename(pdf.fileName(), StandardCharsets.UTF_8)
.build();
ResponseEntity.BodyBuilder response = ResponseEntity.ok()
.cacheControl(CacheControl.noStore())
.header(HttpHeaders.PRAGMA, "no-cache")
.header(HttpHeaders.CONTENT_DISPOSITION, disposition.toString())
.header("X-Content-Type-Options", "nosniff")
.header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
.contentType(MediaType.APPLICATION_PDF)
.contentLength(content.length);
return response.body(content);
}
}
@@ -0,0 +1,31 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Verifies the shared credential used by internal service callers. */
@Component
public class InternalServiceTokenGuard {
private final byte[] expectedToken;
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) {
if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN must contain at least 32 bytes");
}
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
public void verify(String actualToken) {
byte[] actual = actualToken == null
? new byte[0]
: actualToken.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, actual)) {
throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
}
}
}
@@ -0,0 +1,120 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.config.PrintPdfProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Caps the JSON request before Spring/Jackson materializes the request DTO. */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class PrintRequestSizeFilter extends OncePerRequestFilter {
private static final String PATH = "/internal/print/v1/pdfs/inbound-marks";
private final PrintPdfProperties properties;
private final ObjectMapper objectMapper;
public PrintRequestSizeFilter(PrintPdfProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
long max = properties.getMaxRequestBytes();
if (max < 1 || max > Integer.MAX_VALUE) {
throw new IllegalStateException("print.pdf.max-request-bytes must be between 1 and Integer.MAX_VALUE");
}
if (request.getContentLengthLong() > max) {
writeTooLarge(request, response, max);
return;
}
byte[] body = readAtMost(request, max);
if (body == null) {
writeTooLarge(request, response, max);
return;
}
filterChain.doFilter(new BodyRequestWrapper(request, body), response);
}
private byte[] readAtMost(HttpServletRequest request, long max) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(max, 8192));
ServletInputStream input = request.getInputStream();
byte[] buffer = new byte[8192];
long total = 0;
int read;
while ((read = input.read(buffer)) != -1) {
total += read;
if (total > max) {
return null;
}
output.write(buffer, 0, read);
}
return output.toByteArray();
}
private void writeTooLarge(HttpServletRequest request, HttpServletResponse response, long max)
throws IOException {
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
ApiResponse<Void> body = ApiResponse.failure(ResultCode.BAD_REQUEST,
"Request body exceeds " + max + " bytes",
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
objectMapper.writeValue(response.getOutputStream(), body);
}
private static final class BodyRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
private BodyRequestWrapper(HttpServletRequest request, byte[] body) {
super(request);
this.body = body;
}
@Override
public int getContentLength() { return body.length; }
@Override
public long getContentLengthLong() { return body.length; }
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream input = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override public int read() { return input.read(); }
@Override public int read(byte[] b, int off, int len) { return input.read(b, off, len); }
@Override public boolean isFinished() { return input.available() == 0; }
@Override public boolean isReady() { return true; }
@Override public void setReadListener(jakarta.servlet.ReadListener listener) { }
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new java.io.InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
}
}
@@ -0,0 +1,32 @@
package com.baiyue.print.dto;
import java.math.BigDecimal;
import java.util.List;
/**
* 入仓唛头 PDF 的内部请求契约。
*/
public final class InboundMarkPdfDtos {
private InboundMarkPdfDtos() {
}
public record Request(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<Label> labels
) {
}
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
}
public record Label(
String topCode,
String warehouseText,
String cartonText,
String routeText,
String qrContent
) {
}
}
@@ -0,0 +1,64 @@
package com.baiyue.print.renderer;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.google.zxing.common.BitMatrix;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Component;
/**
* 固定的 100mm x 100mm 入仓唛头 V1 模板。
*/
@Component
public class InboundMarkV1Renderer {
public static final double PAGE_WIDTH_MM = 100d;
public static final double PAGE_HEIGHT_MM = 100d;
private static final float PAGE_WIDTH_PT = PdfUnits.mmToPt(PAGE_WIDTH_MM);
private static final float PAGE_HEIGHT_PT = PdfUnits.mmToPt(PAGE_HEIGHT_MM);
private static final float QR_SIZE_PT = PdfUnits.mmToPt(35.28);
private static final float QR_LEFT_PT = (PAGE_WIDTH_PT - QR_SIZE_PT) / 2f;
private static final float QR_BOTTOM_PT = PAGE_HEIGHT_PT - PdfUnits.mmToPt(84);
private final PdfQrRenderer qrRenderer;
public InboundMarkV1Renderer(PdfQrRenderer qrRenderer) {
this.qrRenderer = qrRenderer;
}
public void renderPage(PDDocument document, PDFont font, InboundMarkPdfDtos.Label label,
BitMatrix qrMatrix) throws IOException {
PDPage page = new PDPage(new PDRectangle(PAGE_WIDTH_PT, PAGE_HEIGHT_PT));
page.setRotation(0);
document.addPage(page);
PdfUnits units = new PdfUnits(PAGE_HEIGHT_MM);
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
stream.setNonStrokingColor(0, 0, 0);
drawCenteredText(stream, units, font, label.topCode(), 24, 11);
drawCenteredText(stream, units, font, label.warehouseText(), 36, 26);
drawCenteredText(stream, units, font, label.cartonText(), 22, 37);
drawCenteredText(stream, units, font, label.routeText(), 21, 47);
qrRenderer.draw(stream, qrMatrix, QR_LEFT_PT, QR_BOTTOM_PT, QR_SIZE_PT);
drawCenteredText(stream, units, font, label.qrContent(), 12, 85.5);
drawCenteredText(stream, units, font, "MADE IN CHINA", 14, 92);
}
}
private void drawCenteredText(PDPageContentStream stream, PdfUnits units, PDFont font,
String text, float fontSize, double centerMmFromTop)
throws IOException {
float textWidth = font.getStringWidth(text) / 1000f * fontSize;
float x = (PAGE_WIDTH_PT - textWidth) / 2f;
float baseline = units.centeredTextBaseline(font, fontSize, centerMmFromTop);
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(x, baseline);
stream.showText(text);
stream.endText();
}
}
@@ -0,0 +1,48 @@
package com.baiyue.print.renderer;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.springframework.stereotype.Component;
/**
* 生成 QR 模块并以 PDF 矢量矩形绘制,避免位图缩放造成扫描损失。
*/
@Component
public class PdfQrRenderer {
private static final Map<EncodeHintType, ?> HINTS = Map.of(
EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name(),
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M,
EncodeHintType.MARGIN, 4
);
public BitMatrix encode(String content) {
try {
return new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 1, 1, HINTS);
} catch (WriterException exception) {
throw new IllegalArgumentException("qrContent cannot be encoded", exception);
}
}
public void draw(PDPageContentStream stream, BitMatrix matrix,
float leftPt, float bottomPt, float sizePt) throws IOException {
float moduleSize = sizePt / matrix.getWidth();
stream.setNonStrokingColor(0, 0, 0);
for (int y = 0; y < matrix.getHeight(); y++) {
for (int x = 0; x < matrix.getWidth(); x++) {
if (matrix.get(x, y)) {
float pdfY = bottomPt + (matrix.getHeight() - 1 - y) * moduleSize;
stream.addRect(leftPt + x * moduleSize, pdfY, moduleSize, moduleSize);
}
}
}
stream.fill();
}
}
@@ -0,0 +1,32 @@
package com.baiyue.print.renderer;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.font.PDFont;
/**
* 将模板使用的自顶向下毫米坐标集中转换为 PDF point 坐标。
*/
public final class PdfUnits {
public static final double POINTS_PER_MM = 72.0d / 25.4d;
private final float pageHeightPt;
public PdfUnits(double pageHeightMm) {
this.pageHeightPt = mmToPt(pageHeightMm);
}
public static float mmToPt(double millimeters) {
return (float) (millimeters * POINTS_PER_MM);
}
public float topDownMmToPdfY(double millimetersFromTop) {
return pageHeightPt - mmToPt(millimetersFromTop);
}
float centeredTextBaseline(PDFont font, float fontSize, double centerMmFromTop)
throws IOException {
float ascent = font.getFontDescriptor().getAscent() / 1000f * fontSize;
float descent = font.getFontDescriptor().getDescent() / 1000f * fontSize;
return topDownMmToPdfY(centerMmFromTop) - (ascent + descent) / 2f;
}
}
@@ -0,0 +1,233 @@
package com.baiyue.print.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.google.zxing.common.BitMatrix;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.text.Normalizer;
import java.util.ArrayList;
import java.util.List;
import java.util.Set;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
/**
* 校验完整标签数组,并一次性在内存中生成多页 PDF。
*/
@Service
public class InboundMarkPdfService {
private static final int MAX_QR_CODE_POINTS = 34;
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
private static final Set<Character> FORBIDDEN_FILE_NAME_CHARACTERS =
Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
private final PrintPdfProperties properties;
private final PdfGenerationLimiter limiter;
private final InboundMarkV1Renderer renderer;
private final PdfQrRenderer qrRenderer;
public InboundMarkPdfService(PrintPdfProperties properties, PdfGenerationLimiter limiter,
InboundMarkV1Renderer renderer, PdfQrRenderer qrRenderer) {
this.properties = properties;
this.limiter = limiter;
this.renderer = renderer;
this.qrRenderer = qrRenderer;
}
public GeneratedPdf generate(InboundMarkPdfDtos.Request request) {
NormalizedRequest normalized = validateAndNormalize(request);
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
List<BitMatrix> matrices = normalized.labels().stream()
.map(label -> qrRenderer.encode(label.qrContent()))
.toList();
return render(normalized, matrices);
}
}
private GeneratedPdf render(NormalizedRequest request, List<BitMatrix> matrices) {
try (PDDocument document = new PDDocument();
InputStream fontStream = openFont();
LimitedByteArrayOutputStream output =
new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) {
PDType0Font font = PDType0Font.load(document, fontStream, true);
for (int index = 0; index < request.labels().size(); index++) {
renderer.renderPage(document, font, request.labels().get(index), matrices.get(index));
}
document.save(output);
byte[] content = output.toByteArray();
return new GeneratedPdf(content, request.fileName(), request.labels().size());
} catch (OutputLimitExceededException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "生成的 PDF 超过输出大小上限");
} catch (IOException | RuntimeException exception) {
if (exception instanceof BizException bizException) {
throw bizException;
}
throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败");
}
}
private InputStream openFont() throws IOException {
ClassPathResource resource = new ClassPathResource(properties.getFontResource());
if (!resource.exists()) {
throw new IOException("configured PDF font resource does not exist");
}
return resource.getInputStream();
}
private NormalizedRequest validateAndNormalize(InboundMarkPdfDtos.Request request) {
if (request == null) {
invalid("request is required");
}
String templateCode = normalizeRequired(request.templateCode(), "templateCode");
if (!properties.getTemplateCode().equals(templateCode)) {
invalid("templateCode is not supported");
}
if (request.pageSpec() == null) {
invalid("pageSpec is required");
}
requireSupportedDimension(request.pageSpec().widthMm(),
properties.getSupportedWidthMm(), "pageSpec.widthMm");
requireSupportedDimension(request.pageSpec().heightMm(),
properties.getSupportedHeightMm(), "pageSpec.heightMm");
if (request.labels() == null || request.labels().isEmpty()) {
invalid("labels must contain at least one item");
}
if (request.labels().size() > properties.getMaxPages()) {
invalid("labels must not contain more than " + properties.getMaxPages() + " items");
}
List<InboundMarkPdfDtos.Label> labels = new ArrayList<>(request.labels().size());
for (int index = 0; index < request.labels().size(); index++) {
InboundMarkPdfDtos.Label label = request.labels().get(index);
if (label == null) {
invalid("labels[" + index + "] is required");
}
labels.add(new InboundMarkPdfDtos.Label(
normalizeRequired(label.topCode(), field(index, "topCode")),
normalizeRequired(label.warehouseText(), field(index, "warehouseText")),
normalizeRequired(label.cartonText(), field(index, "cartonText")),
normalizeRequired(label.routeText(), field(index, "routeText")),
normalizeQr(label.qrContent(), field(index, "qrContent"))));
}
return new NormalizedRequest(normalizeFileName(request.downloadFileName()), List.copyOf(labels));
}
private String normalizeFileName(String value) {
if (value == null) {
return "inbound-marks.pdf";
}
String normalized = normalizeRequired(value, "downloadFileName");
if (normalized.codePointCount(0, normalized.length()) > MAX_FILE_NAME_CODE_POINTS) {
invalid("downloadFileName must not exceed 120 characters");
}
if (!normalized.toLowerCase(java.util.Locale.ROOT).endsWith(".pdf")) {
invalid("downloadFileName must end with .pdf");
}
for (int offset = 0; offset < normalized.length(); offset++) {
if (FORBIDDEN_FILE_NAME_CHARACTERS.contains(normalized.charAt(offset))) {
invalid("downloadFileName contains a reserved character");
}
}
return normalized;
}
private String normalizeQr(String value, String field) {
String normalized = normalizeRequired(value, field);
int codePoints = normalized.codePointCount(0, normalized.length());
if (codePoints > MAX_QR_CODE_POINTS) {
invalid(field + " must not exceed 34 characters");
}
for (int offset = 0; offset < normalized.length(); offset++) {
char character = normalized.charAt(offset);
if (character < 0x20 || character > 0x7e) {
invalid(field + " must contain printable ASCII only");
}
}
return normalized;
}
private String normalizeRequired(String value, String field) {
if (value == null) {
invalid(field + " is required");
}
String normalized = Normalizer.normalize(value, Normalizer.Form.NFC);
if (normalized.isBlank()) {
invalid(field + " must not be blank");
}
normalized.codePoints().forEach(codePoint -> {
if (isForbiddenControl(codePoint)) {
invalid(field + " contains a control character");
}
});
return normalized;
}
private boolean isForbiddenControl(int codePoint) {
int type = Character.getType(codePoint);
return Character.isISOControl(codePoint)
|| type == Character.FORMAT
|| type == Character.LINE_SEPARATOR
|| type == Character.PARAGRAPH_SEPARATOR;
}
private void requireSupportedDimension(BigDecimal actual, BigDecimal expected, String field) {
if (actual == null || actual.compareTo(expected) != 0) {
invalid(field + " is not supported");
}
}
private String field(int index, String name) {
return "labels[" + index + "]." + name;
}
private void invalid(String message) {
throw new BizException(ResultCode.VALIDATION_FAILED, message);
}
public record GeneratedPdf(byte[] content, String fileName, int pageCount) {
}
private record NormalizedRequest(String fileName, List<InboundMarkPdfDtos.Label> labels) {
}
private static final class LimitedByteArrayOutputStream extends ByteArrayOutputStream {
private final long maxBytes;
private LimitedByteArrayOutputStream(long maxBytes) {
if (maxBytes < 1 || maxBytes > Integer.MAX_VALUE) {
throw new IllegalArgumentException("max-output-bytes must be between 1 and Integer.MAX_VALUE");
}
this.maxBytes = maxBytes;
}
@Override
public synchronized void write(int value) {
ensureCapacityWithinLimit(1);
super.write(value);
}
@Override
public synchronized void write(byte[] bytes, int offset, int length) {
ensureCapacityWithinLimit(length);
super.write(bytes, offset, length);
}
private void ensureCapacityWithinLimit(int addedBytes) {
if ((long) count + addedBytes > maxBytes) {
throw new OutputLimitExceededException();
}
}
}
private static final class OutputLimitExceededException extends RuntimeException {
}
}
@@ -0,0 +1,57 @@
package com.baiyue.print.service;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import java.time.Duration;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;
/**
* 进程内 PDF 生成并发保护。许可必须通过 try-with-resources 释放。
*/
@Component
public class PdfGenerationLimiter {
private final Semaphore semaphore;
private final Duration acquireTimeout;
public PdfGenerationLimiter(PrintPdfProperties properties) {
if (properties.getMaxConcurrentGenerations() < 1) {
throw new IllegalArgumentException("print.pdf.max-concurrent-generations must be positive");
}
this.semaphore = new Semaphore(properties.getMaxConcurrentGenerations(), true);
this.acquireTimeout = properties.getAcquireTimeout();
}
public Permit acquire() {
boolean acquired;
try {
acquired = semaphore.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "等待 PDF 生成槽位时线程被中断");
}
if (!acquired) {
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "PDF 生成繁忙,请稍后重试");
}
return new Permit(semaphore);
}
public static final class Permit implements AutoCloseable {
private final Semaphore semaphore;
private boolean closed;
private Permit(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void close() {
if (!closed) {
closed = true;
semaphore.release();
}
}
}
}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${PRINT_DB_USERNAME:root}
password: ${PRINT_DB_PASSWORD:123456}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${PRINT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${PRINT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${PRINT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
+12
View File
@@ -23,6 +23,18 @@ spring:
baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
print:
pdf:
max-pages: ${PRINT_PDF_MAX_PAGES:2000}
max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152}
max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432}
max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4}
acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s}
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
management.endpoints.web.exposure.include: health,info,prometheus
logging:
-62
View File
@@ -1,62 +0,0 @@
-- 打印服务独占数据库,业务服务只保存打印任务逻辑ID。
CREATE DATABASE IF NOT EXISTS baiyue_print
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_print;
CREATE TABLE IF NOT EXISTS print_template (
id BIGINT UNSIGNED NOT NULL COMMENT '模板ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID,平台模板使用平台租户ID',
template_code VARCHAR(64) NOT NULL COMMENT '模板编码',
template_name VARCHAR(128) NOT NULL COMMENT '模板名称',
template_type VARCHAR(32) NOT NULL COMMENT '模板类型',
content TEXT NOT NULL COMMENT '模板内容',
version_no INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '模板版本号',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '模板状态',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_print_template_code (tenant_id, template_code, version_no),
KEY idx_print_template_status (tenant_id, status)
) ENGINE=InnoDB COMMENT='打印模板表';
CREATE TABLE IF NOT EXISTS print_job (
id BIGINT UNSIGNED NOT NULL COMMENT '打印任务ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
job_no VARCHAR(40) NOT NULL COMMENT '打印任务编号',
template_id BIGINT UNSIGNED NOT NULL COMMENT '打印模板逻辑ID',
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
printer_code VARCHAR(64) NULL COMMENT '目标打印机编码',
copies INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '打印份数',
payload_json JSON NOT NULL COMMENT '模板数据快照',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
output_file_id BIGINT UNSIGNED NULL COMMENT '生成文件的逻辑ID',
error_message VARCHAR(1000) NULL COMMENT '失败原因',
requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请人ID',
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '申请时间',
started_at DATETIME(3) NULL COMMENT '开始时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_print_job_no (tenant_id, job_no),
KEY idx_print_job_status (tenant_id, status, requested_at),
KEY idx_print_job_business (tenant_id, business_type, business_id)
) ENGINE=InnoDB COMMENT='打印任务表';
CREATE TABLE IF NOT EXISTS print_job_history (
id BIGINT UNSIGNED NOT NULL COMMENT '任务历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
print_job_id BIGINT UNSIGNED NOT NULL COMMENT '打印任务逻辑ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
message VARCHAR(500) NULL COMMENT '状态说明',
operator_id BIGINT UNSIGNED NULL COMMENT '操作人ID',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
PRIMARY KEY (id),
KEY idx_print_history_job (tenant_id, print_job_id, occurred_at)
) ENGINE=InnoDB COMMENT='打印任务状态历史表';
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,131 @@
openapi: 3.0.3
info:
title: Baiyue Print Internal API
version: 1.0.0
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
paths:
/internal/print/v1/pdfs/inbound-marks:
post:
summary: Generate an inbound-mark PDF
operationId: generateInboundMarkPdf
security:
- internalServiceToken: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/InboundMarkPdfRequest'
responses:
'200':
description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
headers:
Content-Disposition:
description: UTF-8 attachment filename. Present only for a successful PDF.
schema:
type: string
Cache-Control:
schema:
type: string
example: no-store
X-Print-Page-Count:
schema:
type: integer
minimum: 1
maximum: 2000
X-Request-Id:
schema:
type: string
content:
application/pdf:
schema:
type: string
format: binary
application/json:
schema:
$ref: '#/components/schemas/ApiError'
'413':
description: Request body exceeds the configured limit.
content:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
components:
securitySchemes:
internalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
parameters:
RequestId:
name: X-Request-Id
in: header
required: false
schema:
type: string
schemas:
InboundMarkPdfRequest:
type: object
required: [templateCode, pageSpec, labels]
properties:
templateCode:
type: string
enum: [INBOUND_MARK_V1]
pageSpec:
$ref: '#/components/schemas/PageSpec'
downloadFileName:
type: string
maxLength: 120
example: PCHK2608040174-19件-MCC1.pdf
labels:
type: array
minItems: 1
maxItems: 2000
items:
$ref: '#/components/schemas/InboundMarkLabel'
PageSpec:
type: object
required: [widthMm, heightMm]
properties:
widthMm:
type: number
enum: [100]
heightMm:
type: number
enum: [100]
InboundMarkLabel:
type: object
required: [topCode, warehouseText, cartonText, routeText, qrContent]
properties:
topCode:
type: string
warehouseText:
type: string
cartonText:
type: string
routeText:
type: string
qrContent:
type: string
minLength: 1
maxLength: 34
pattern: '^[\\x20-\\x7E]+$'
ApiError:
type: object
required: [code, message]
properties:
code:
type: string
enum: [A0400, A0403, A0422, A0429, S0500]
message:
type: string
data:
nullable: true
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
@@ -0,0 +1,18 @@
package com.baiyue.print;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(
classes = PrintServiceApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.nacos.discovery.enabled=false",
"baiyue.security.internal-service-token=test-internal-token-at-least-32-bytes"
})
class PrintServiceApplicationTest {
@Test
void startsWithoutDatabaseRedisOrNacos() {
}
}
@@ -0,0 +1,179 @@
package com.baiyue.print.acceptance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.baiyue.print.service.InboundMarkPdfService;
import com.baiyue.print.service.PdfGenerationLimiter;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.RGBLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** End-to-end renderer acceptance checks. These intentionally exercise the real service, not mocks. */
class InboundMarkPdfAcceptanceTest {
private static final String TEMPLATE = "INBOUND_MARK_V1";
private static final BigDecimal MM_100 = new BigDecimal("100");
@Test
void generatesOnePageWithExactSizeTextOrderEmbeddedChineseFontAndNoDiskArtifact(@TempDir Path tempDir)
throws Exception {
InboundMarkPdfService service = service();
InboundMarkPdfDtos.Label label = label(1);
String originalTmp = System.getProperty("java.io.tmpdir");
System.setProperty("java.io.tmpdir", tempDir.toString());
try {
byte[] pdf = service.generate(request(List.of(label))).content();
assertPdf(pdf, List.of(label));
try (Stream<Path> files = Files.list(tempDir)) {
assertThat(files.filter(Files::isRegularFile).toList())
.as("PDF rendering must not create temporary files")
.isEmpty();
}
} finally {
if (originalTmp == null) {
System.clearProperty("java.io.tmpdir");
} else {
System.setProperty("java.io.tmpdir", originalTmp);
}
}
}
@Test
void preservesPageOrderAndDecodableQrOnFirstMiddleAndLastPages() throws Exception {
List<InboundMarkPdfDtos.Label> labels = labels(19);
assertPdf(service().generate(request(labels)).content(), labels);
}
@Test
void generatesTwoHundredEighteenPagesWithExactDimensions() throws Exception {
List<InboundMarkPdfDtos.Label> labels = labels(218);
byte[] pdf = service().generate(request(labels)).content();
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(218);
for (int i : List.of(0, 108, 217)) {
assertExactPageSize(document, i);
}
}
}
@Test
void maximumCapacityIsCoveredWhenExplicitlyEnabled() {
assumeTrue(Boolean.getBoolean("print.capacity.tests"),
"Enable with -Dprint.capacity.tests=true for the 2000-page capacity acceptance test");
List<InboundMarkPdfDtos.Label> labels = labels(2000);
byte[] pdf = service().generate(request(labels)).content();
assertThat(pdf).startsWith("%PDF".getBytes(StandardCharsets.US_ASCII));
assertThat(pdf.length).isLessThan(32 * 1024 * 1024);
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(2000);
} catch (Exception exception) {
throw new AssertionError("Generated 2000-page PDF must be readable", exception);
}
}
private void assertPdf(byte[] bytes, List<InboundMarkPdfDtos.Label> labels) throws Exception {
try (PDDocument document = Loader.loadPDF(bytes)) {
assertThat(document.getNumberOfPages()).isEqualTo(labels.size());
PDFTextStripper stripper = new PDFTextStripper();
for (int i : representativeIndexes(labels.size())) {
assertExactPageSize(document, i);
stripper.setStartPage(i + 1);
stripper.setEndPage(i + 1);
String text = stripper.getText(document);
InboundMarkPdfDtos.Label label = labels.get(i);
assertThat(text).contains(label.topCode(), label.warehouseText(), label.cartonText(),
label.routeText(), label.qrContent(), "MADE IN CHINA");
assertThat(text.indexOf(label.topCode())).isLessThan(text.indexOf(label.warehouseText()));
assertThat(text.indexOf(label.warehouseText())).isLessThan(text.indexOf(label.cartonText()));
assertThat(text.indexOf(label.cartonText())).isLessThan(text.indexOf(label.routeText()));
assertThat(text.indexOf(label.routeText())).isLessThan(text.indexOf(label.qrContent()));
assertThat(text.lines().filter(label.topCode()::equals).count())
.as("top code must appear as exactly one standalone line")
.isEqualTo(1);
assertEmbeddedFont(document, i);
}
decodeQrs(document, labels);
}
}
private void decodeQrs(PDDocument document, List<InboundMarkPdfDtos.Label> labels) throws Exception {
PDFRenderer renderer = new PDFRenderer(document);
for (int i : representativeIndexes(labels.size())) {
BufferedImage image = renderer.renderImageWithDPI(i, 300);
int[] pixels = image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(
new RGBLuminanceSource(image.getWidth(), image.getHeight(), pixels)));
String decoded = new MultiFormatReader().decode(bitmap).getText();
assertThat(decoded).isEqualTo(labels.get(i).qrContent());
}
}
private void assertEmbeddedFont(PDDocument document, int pageIndex) throws Exception {
boolean embedded = false;
for (org.apache.pdfbox.cos.COSName name : document.getPage(pageIndex).getResources().getFontNames()) {
PDFont font = document.getPage(pageIndex).getResources().getFont(name);
embedded |= font.isEmbedded();
}
assertThat(embedded).as("page %s must contain an embedded font", pageIndex + 1).isTrue();
}
private void assertExactPageSize(PDDocument document, int index) {
float expected = 100f * 72f / 25.4f;
assertThat(document.getPage(index).getMediaBox().getWidth()).isCloseTo(expected,
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(index).getMediaBox().getHeight()).isCloseTo(expected,
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(index).getRotation()).isZero();
}
private List<Integer> representativeIndexes(int size) {
if (size == 1) return List.of(0);
return List.of(0, size / 2, size - 1);
}
private InboundMarkPdfService service() {
PrintPdfProperties properties = new PrintPdfProperties();
return new InboundMarkPdfService(properties, new PdfGenerationLimiter(properties),
new InboundMarkV1Renderer(new PdfQrRenderer()), new PdfQrRenderer());
}
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
return new InboundMarkPdfDtos.Request(TEMPLATE,
new InboundMarkPdfDtos.PageSpec(MM_100, MM_100), "acceptance.pdf", labels);
}
private List<InboundMarkPdfDtos.Label> labels(int total) {
List<InboundMarkPdfDtos.Label> labels = new ArrayList<>(total);
for (int i = 1; i <= total; i++) labels.add(label(i, total));
return labels;
}
private InboundMarkPdfDtos.Label label(int box) { return label(box, 1); }
private InboundMarkPdfDtos.Label label(int box, int total) {
String suffix = total + "-" + box;
return new InboundMarkPdfDtos.Label("PCHK2608040174", "MCC1", "CTNS:" + suffix,
"洛杉矶普船海卡", "PCHK2608040174#" + suffix);
}
}
@@ -0,0 +1,110 @@
package com.baiyue.print.acceptance;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/** Structural guardrails for the stateless service boundary. */
class PrintStatelessArchitectureTest {
@Test
void moduleDoesNotDeclareDatabaseOrMybatisDependencies() throws Exception {
String pom = Files.readString(moduleRoot().resolve("pom.xml"), StandardCharsets.UTF_8);
assertThat(pom).doesNotContain("mybatis-plus-spring-boot3-starter")
.doesNotContain("mysql-connector-j")
.doesNotContain("spring-jdbc")
.doesNotContain("spring-boot-starter-jdbc");
}
@Test
void sourceAndConfigurationDoNotDeclareDatasourceOrDatabaseAccess() throws Exception {
Path root = moduleRoot();
String source;
try (Stream<Path> paths = Files.walk(root.resolve("src/main"))) {
source = paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java") || path.toString().endsWith(".yml")
|| path.toString().endsWith(".yaml"))
.map(this::read)
.collect(Collectors.joining("\n"));
}
assertThat(source).doesNotContain("DataSource").doesNotContain("JdbcTemplate")
.doesNotContain("@Mapper").doesNotContain("spring.datasource")
.doesNotContain("jdbc:");
}
@Test
void obsoleteDatabaseScriptIsNotPackaged() {
assertThat(Files.exists(moduleRoot().resolve("src/main/resources/db/print.sql"))).isFalse();
}
@Test
void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() {
ClassLoader loader = getClass().getClassLoader();
assertThat(isPresent("org.springframework.jdbc.core.JdbcTemplate", loader))
.as("Print must exclude the JDBC starter inherited through common-core")
.isFalse();
assertThat(isPresent("com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration", loader))
.as("Print must exclude MyBatis inherited through common-core")
.isFalse();
assertThat(isPresent("org.springframework.data.redis.core.StringRedisTemplate", loader))
.as("Print must not require Redis for authentication or health")
.isFalse();
}
@Test
void configuredRequestByteLimitHasAnEnforcementConsumer() throws Exception {
String consumers = productionJavaExcept("PrintPdfProperties.java");
assertThat(consumers).as("max-request-bytes must be enforced before JSON deserialization")
.contains("getMaxRequestBytes()");
}
@Test
void fontResourceIsValidatedAtStartupOrReadiness() throws Exception {
String consumers = productionJavaExcept("PrintPdfProperties.java");
assertThat(consumers).contains("getFontResource()");
assertThat(consumers)
.as("a missing or invalid font must make startup/readiness fail, not wait for a PDF request")
.containsAnyOf("HealthIndicator", "ApplicationRunner", "SmartInitializingSingleton", "@PostConstruct");
}
private Path moduleRoot() {
Path cwd = Path.of("").toAbsolutePath().normalize();
for (Path candidate : new Path[]{cwd, cwd.resolve("print"), cwd.resolve("baiyue-firstleg/print")}) {
if (Files.exists(candidate.resolve("pom.xml")) && Files.exists(candidate.resolve("src/main"))) {
return candidate;
}
}
throw new IllegalStateException("Cannot locate the print module from " + cwd);
}
private String read(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
private String productionJavaExcept(String excludedFileName) throws Exception {
try (Stream<Path> paths = Files.walk(moduleRoot().resolve("src/main/java"))) {
return paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java"))
.filter(path -> !path.getFileName().toString().equals(excludedFileName))
.map(this::read)
.collect(Collectors.joining("\n"));
}
}
private boolean isPresent(String className, ClassLoader loader) {
try {
Class.forName(className, false, loader);
return true;
} catch (ClassNotFoundException ignored) {
return false;
}
}
}
@@ -0,0 +1,23 @@
package com.baiyue.print.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Status;
class PrintFontHealthIndicatorTest {
@Test
void reportsUpForPackagedParseableFont() {
PrintPdfProperties properties = new PrintPdfProperties();
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.UP);
}
@Test
void reportsDownForMissingFont() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setFontResource("fonts/missing.ttf");
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.DOWN);
}
}
@@ -0,0 +1,162 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.web.GlobalExceptionHandler;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.InboundMarkPdfService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.mockito.ArgumentCaptor;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class InternalPdfControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
private InboundMarkPdfService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(InboundMarkPdfService.class);
InternalPdfController controller = new InternalPdfController(
service, new InternalServiceTokenGuard(TOKEN));
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsPdfWithDownloadHeaders() throws Exception {
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
pdf, "PCHK2608040174-19件-MCC1.pdf", 19));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.requestAttr("requestId", "request-123")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes(pdf))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("Pragma", "no-cache"))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("X-Print-Page-Count", "19"))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("filename*=")));
verify(service).generate(any(InboundMarkPdfDtos.Request.class));
}
@Test
void rejectsMissingTokenAsJsonBeforeCallingService() throws Exception {
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("A0403"));
verify(service, never()).generate(any());
}
@Test
void returnsServiceValidationFailureAsJson() throws Exception {
when(service.generate(any())).thenThrow(
new BizException(ResultCode.VALIDATION_FAILED, "labels must contain at least one item"));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("A0422"))
.andExpect(jsonPath("$.message").value("labels must contain at least one item"));
}
@Test
void derivesDefaultFileNameFromRequestId() throws Exception {
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
pdf, "inbound-marks-request-123.pdf", 1));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.requestAttr("requestId", "request-123")
.contentType(MediaType.APPLICATION_JSON)
.content(validRequestJson().replace(
"\"downloadFileName\": \"PCHK2608040174-19件-MCC1.pdf\",", "")))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("inbound-marks-request-123.pdf")));
ArgumentCaptor<InboundMarkPdfDtos.Request> captor =
ArgumentCaptor.forClass(InboundMarkPdfDtos.Request.class);
verify(service).generate(captor.capture());
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
}
@Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class
.getMethod("generateInboundMarks", String.class, InboundMarkPdfDtos.Request.class,
jakarta.servlet.http.HttpServletRequest.class)
.getAnnotation(PostMapping.class);
assertThat(mapping.produces()).isEmpty();
}
@Test
void openApiDocumentsBothSuccessAndErrorRepresentations() throws Exception {
String contract = new ClassPathResource("openapi/print-internal-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("application/pdf:")
.contains("application/json:")
.contains("name: X-Internal-Service-Token")
.contains("maximum: 2000");
}
private String validRequestJson() {
return """
{
"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"
}]
}
""";
}
}
@@ -0,0 +1,36 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InternalServiceTokenGuardTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@Test
void acceptsOnlyConfiguredToken() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard(TOKEN);
guard.verify(TOKEN);
assertThatThrownBy(() -> guard.verify(null))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void refusesUnsafeConfiguration() {
assertThatThrownBy(() -> new InternalServiceTokenGuard("too-short"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("at least 32 bytes");
assertThatThrownBy(() -> new InternalServiceTokenGuard(null))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -0,0 +1,89 @@
package com.baiyue.print.controller;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class PrintRequestSizeFilterTest {
private PrintRequestSizeFilter filter;
@BeforeEach
void setUp() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setMaxRequestBytes(10);
filter = new PrintRequestSizeFilter(properties, new ObjectMapper().findAndRegisterModules());
}
@Test
void passesBoundedBodyToDownstream() throws Exception {
MockHttpServletRequest request = request("1234567890", false);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<String> received = new AtomicReference<>();
filter.doFilter(request, response, (wrapped, ignored) -> received.set(
new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8)));
assertThat(received).hasValue("1234567890");
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest() {
@Override public int getContentLength() { return 11; }
@Override public long getContentLengthLong() { return 11; }
};
initialize(request, "{}");
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<Boolean> called = new AtomicReference<>(false);
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> called.set(true));
assertThat(called).hasValue(false);
assertTooLarge(response);
}
@Test
void rejectsOversizedBodyWhenLengthIsUnknown() throws Exception {
MockHttpServletRequest request = request("12345678901", true);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<Boolean> called = new AtomicReference<>(false);
filter.doFilter(request, response, (ignoredRequest, ignoredResponse) -> called.set(true));
assertThat(called).hasValue(false);
assertTooLarge(response);
}
private MockHttpServletRequest request(String content, boolean unknownLength) {
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
@Override public int getContentLength() { return -1; }
@Override public long getContentLengthLong() { return -1; }
} : new MockHttpServletRequest();
initialize(request, content);
return request;
}
private void initialize(MockHttpServletRequest request, String content) {
request.setMethod("POST");
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
request.setContent(content.getBytes(StandardCharsets.UTF_8));
request.setAttribute("requestId", "request-123");
request.setAttribute("traceId", "trace-123");
}
private void assertTooLarge(MockHttpServletResponse response) throws Exception {
assertThat(response.getStatus()).isEqualTo(413);
assertThat(response.getContentType()).startsWith("application/json");
assertThat(response.getContentAsString()).contains("\"code\":\"A0400\"")
.contains("Request body exceeds 10 bytes")
.contains("\"requestId\":\"request-123\"");
}
}
@@ -0,0 +1,22 @@
package com.baiyue.print.renderer;
import static org.assertj.core.api.Assertions.assertThat;
import com.google.zxing.common.BitMatrix;
import org.junit.jupiter.api.Test;
class PdfQrRendererTest {
@Test
void encodesSameContentDeterministicallyWithQuietZone() {
PdfQrRenderer renderer = new PdfQrRenderer();
BitMatrix first = renderer.encode("PCHK2608040174#19-1");
BitMatrix second = renderer.encode("PCHK2608040174#19-1");
assertThat(first).isEqualTo(second);
assertThat(first.getWidth()).isEqualTo(first.getHeight()).isGreaterThan(21);
assertThat(first.getEnclosingRectangle()).isNotNull();
assertThat(first.getEnclosingRectangle()[0]).isGreaterThanOrEqualTo(4);
assertThat(first.getEnclosingRectangle()[1]).isGreaterThanOrEqualTo(4);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.print.renderer;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class PdfUnitsTest {
@Test
void convertsOneHundredMillimetersToExactPdfPoints() {
assertThat(PdfUnits.mmToPt(100)).isCloseTo(283.46457f, within(0.0001f));
}
@Test
void convertsTopDownCoordinatesToPdfCoordinates() {
PdfUnits units = new PdfUnits(100);
assertThat(units.topDownMmToPdfY(0)).isCloseTo(PdfUnits.mmToPt(100), within(0.0001f));
assertThat(units.topDownMmToPdfY(100)).isCloseTo(0f, within(0.0001f));
}
private static org.assertj.core.data.Offset<Float> within(float value) {
return org.assertj.core.data.Offset.offset(value);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.baiyue.print.renderer.PdfUnits;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
class InboundMarkPdfServiceGenerationTest {
@Test
void generatesOneStrictHundredMillimeterPagePerLabelInRequestOrder() throws IOException {
PrintPdfProperties properties = new PrintPdfProperties();
PdfQrRenderer qrRenderer = new PdfQrRenderer();
InboundMarkPdfService service = new InboundMarkPdfService(
properties,
new PdfGenerationLimiter(properties),
new InboundMarkV1Renderer(qrRenderer),
qrRenderer);
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
"INBOUND_MARK_V1",
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100.0")),
"PCHK2608040174.pdf",
List.of(label(1), label(2)));
InboundMarkPdfService.GeneratedPdf generated = service.generate(request);
assertThat(generated.fileName()).isEqualTo("PCHK2608040174.pdf");
assertThat(generated.pageCount()).isEqualTo(2);
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertThat(document.getNumberOfPages()).isEqualTo(2);
document.getPages().forEach(page -> {
assertThat(page.getRotation()).isZero();
assertThat(page.getMediaBox().getWidth())
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
assertThat(page.getMediaBox().getHeight())
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
});
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(1);
stripper.setEndPage(1);
assertThat(stripper.getText(document))
.contains("PCHK2608040174", "MCC1", "CTNS:2-1", "洛杉矶普船海卡",
"PCHK2608040174#2-1", "MADE IN CHINA");
stripper.setStartPage(2);
stripper.setEndPage(2);
assertThat(stripper.getText(document)).contains("CTNS:2-2", "PCHK2608040174#2-2");
}
}
private InboundMarkPdfDtos.Label label(int sequence) {
return new InboundMarkPdfDtos.Label(
"PCHK2608040174",
"MCC1",
"CTNS:2-" + sequence,
"洛杉矶普船海卡",
"PCHK2608040174#2-" + sequence);
}
}
@@ -0,0 +1,101 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class InboundMarkPdfServiceValidationTest {
private InboundMarkPdfService service;
@BeforeEach
void setUp() {
PrintPdfProperties properties = new PrintPdfProperties();
service = new InboundMarkPdfService(
properties,
mock(PdfGenerationLimiter.class),
mock(InboundMarkV1Renderer.class),
mock(PdfQrRenderer.class));
}
@Test
void rejectsUnsupportedPageSizeBeforeRendering() {
InboundMarkPdfDtos.Request request = request(label("PCHK2608040174#19-1"));
request = new InboundMarkPdfDtos.Request(
request.templateCode(),
new InboundMarkPdfDtos.PageSpec(new BigDecimal("99"), new BigDecimal("100")),
request.downloadFileName(), request.labels());
assertValidationFailure(request, "pageSpec.widthMm");
}
@Test
void rejectsMoreThanTwoThousandPages() {
InboundMarkPdfDtos.Label label = label("PCHK2608040174#19-1");
InboundMarkPdfDtos.Request request = request(java.util.Collections.nCopies(2001, label));
assertValidationFailure(request, "2000");
}
@Test
void rejectsControlCharactersInDisplayText() {
InboundMarkPdfDtos.Label label = new InboundMarkPdfDtos.Label(
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶\n普船海卡", "PCHK2608040174#19-1");
assertValidationFailure(request(label), "labels[0].routeText");
}
@Test
void rejectsQrContentLongerThanThirtyFourAsciiCharacters() {
assertValidationFailure(request(label("A".repeat(35))), "34");
}
@Test
void rejectsNonAsciiQrContent() {
assertValidationFailure(request(label("PCHK#中文")), "printable ASCII");
}
@Test
void rejectsPathLikeDownloadFileName() {
InboundMarkPdfDtos.Request base = request(label("PCHK2608040174#19-1"));
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
base.templateCode(), base.pageSpec(), "folder/labels.pdf", base.labels());
assertValidationFailure(request, "reserved character");
}
private void assertValidationFailure(InboundMarkPdfDtos.Request request, String messagePart) {
assertThatThrownBy(() -> service.generate(request))
.isInstanceOfSatisfying(BizException.class, exception -> {
assertThat(exception.getResultCode()).isEqualTo(ResultCode.VALIDATION_FAILED);
assertThat(exception.getMessage()).contains(messagePart);
});
}
private InboundMarkPdfDtos.Request request(InboundMarkPdfDtos.Label label) {
return request(List.of(label));
}
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
return new InboundMarkPdfDtos.Request(
"INBOUND_MARK_V1",
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100.0"), new BigDecimal("100")),
"labels.pdf",
labels);
}
private InboundMarkPdfDtos.Label label(String qrContent) {
return new InboundMarkPdfDtos.Label(
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡", qrContent);
}
}
@@ -0,0 +1,30 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.print.config.PrintPdfProperties;
import java.time.Duration;
import org.junit.jupiter.api.Test;
class PdfGenerationLimiterTest {
@Test
void rejectsWhenAllPermitsAreInUseAndReleasesPermitOnClose() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setMaxConcurrentGenerations(1);
properties.setAcquireTimeout(Duration.ZERO);
PdfGenerationLimiter limiter = new PdfGenerationLimiter(properties);
PdfGenerationLimiter.Permit permit = limiter.acquire();
assertThatThrownBy(limiter::acquire)
.isInstanceOfSatisfying(BizException.class,
exception -> org.assertj.core.api.Assertions.assertThat(exception.getResultCode())
.isEqualTo(ResultCode.TOO_MANY_REQUESTS));
permit.close();
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
// A second acquisition proves that close returned the only permit.
}
}
}
+1 -2
View File
@@ -12,7 +12,6 @@ $env:ORDER_DB_PASSWORD = '123456'
$env:FIRSTLEG_DB_PASSWORD = '123456'
$env:WAREHOUSE_DB_PASSWORD = '123456'
$env:FILE_DB_PASSWORD = '123456'
$env:PRINT_DB_PASSWORD = '123456'
$env:IAM_SERVICE_URL = 'http://127.0.0.1:9201'
$env:WAREHOUSE_SERVICE_URL = 'http://127.0.0.1:9205'
$env:JWT_SECRET = 'phase1-local-test-jwt-secret-at-least-32-bytes'
@@ -20,4 +19,4 @@ $env:INTERNAL_SERVICE_TOKEN = 'phase1-local-internal-token-at-least-32-bytes'
$env:SWAGGER_ENABLED = 'true'
$env:CORS_ALLOWED_ORIGINS = 'http://localhost:5173,http://localhost:5174,http://127.0.0.1:5173,http://127.0.0.1:5174,http://192.168.110.149:5173,http://192.168.110.149:5174'
$env:FILE_STORAGE_ROOT = 'D:/baiyue-firstleg-runtime/uploads'
Write-Host '头程本地环境已加载:Nacos 8848、Redis 6379、MySQL 密码、JWT、Swagger 和本地跨域白名单'
Write-Host '头程本地环境已加载:Nacos 8848、Redis 6379、有数据库服务的 MySQL 密码、JWT、Swagger 和本地跨域白名单'