优化
This commit is contained in:
+9
-7
@@ -2,9 +2,9 @@
|
||||
|
||||
## 职责边界
|
||||
|
||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
|
||||
- 首版只支持 `INBOUND_MARK_V1` 和 `100mm x 100mm`;新尺寸或布局必须使用新模板编码,不得静默改变 V1。
|
||||
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(均为 `210mm x 297mm`);新尺寸或布局必须使用新模板编码,不得静默改变既有 V1。
|
||||
|
||||
## 快速索引
|
||||
|
||||
@@ -13,20 +13,22 @@
|
||||
| 调用方接入指南 | `README.md` |
|
||||
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
|
||||
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
|
||||
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`) |
|
||||
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/freight-a4-documents`) |
|
||||
| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` |
|
||||
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java` |
|
||||
| 固定模板与二维码渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
|
||||
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/PdfGenerationLimiter.java` |
|
||||
| 请求与响应模型 | `dto/InboundMarkPdfDtos.java`、`dto/FreightA4PdfDtos.java` |
|
||||
| 固定模板与渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/FreightA4Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` |
|
||||
| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/FreightA4PdfService.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` |
|
||||
| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/service/FreightA4PdfServiceGenerationTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` |
|
||||
|
||||
## 修改与验证
|
||||
|
||||
- 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。
|
||||
- 其他模块接入时先读 `README.md` 和 OpenAPI,在调用模块内声明 DTO 并从既有业务数据自动映射;不得依赖 Print 的 Java DTO,不得新增要求用户重复填写打印字段的表单。
|
||||
- A4 V1 每个 `document` 的 `cargoRows` 只允许 0 或 1 项;当前渲染器只消费首行,调用方不得发送会被静默忽略的后续行。
|
||||
- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。
|
||||
- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。
|
||||
- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。
|
||||
|
||||
+161
-7
@@ -1,22 +1,25 @@
|
||||
# Print PDF 服务接入指南
|
||||
# Print PDF 内部调用契约
|
||||
|
||||
本文面向需要生成入仓标签 PDF 的业务服务。Print 是无数据库的内部渲染服务:调用方提交完整标签数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
|
||||
本文是其他 Java 业务模块及代码生成 AI 接入 Print 的实现约束,不是终端用户操作手册。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)。
|
||||
机器可读 HTTP 契约以 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml) 为准;本文件补充调用方必须实现、但 OpenAPI 无法完整表达的权限、字段映射、响应分支和日志约束。调用方必须自行声明 DTO,不得依赖 Print 模块的 Java 实现类。
|
||||
|
||||
## 1. 调用边界
|
||||
|
||||
- 服务发现名:`print-service`。
|
||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
||||
- 本地直连:`http://127.0.0.1:9207`。
|
||||
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
|
||||
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
|
||||
- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。
|
||||
- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 或 `documents[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。
|
||||
|
||||
调用方不得要求 Print 查询订单、补箱号、排序或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
|
||||
调用方必须从本模块拥有的数据自动组装打印字段,不得把 Print 请求 DTO 暴露成要求用户逐项填写的表单。调用方不得要求 Print 查询订单、补箱号、排序、推导或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
|
||||
|
||||
## 2. 请求契约
|
||||
|
||||
### 2.1 入仓标签
|
||||
|
||||
```http
|
||||
POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1
|
||||
Content-Type: application/json
|
||||
@@ -69,6 +72,57 @@ X-Request-Id: <optional-correlation-id>
|
||||
|
||||
未传 `downloadFileName` 时,服务使用 `inbound-marks-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
|
||||
|
||||
### 2.2 海运 A4 单据
|
||||
|
||||
```json
|
||||
{
|
||||
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
|
||||
"pageSpec": {
|
||||
"widthMm": 210,
|
||||
"heightMm": 297
|
||||
},
|
||||
"downloadFileName": "BYSE2607044-托运书.pdf",
|
||||
"documents": [
|
||||
{
|
||||
"fields": {
|
||||
"toCompany": "宁波胜泽远航国际物流有限公司",
|
||||
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613...",
|
||||
"consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS",
|
||||
"jobNumber": "BYSE2607044"
|
||||
},
|
||||
"cargoRows": [
|
||||
{
|
||||
"marksAndNos": "N/M",
|
||||
"quantity": "700",
|
||||
"packaging": "PACKAGES",
|
||||
"descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011",
|
||||
"grossWeight": "28000.00 KGS",
|
||||
"measurement": "28.000 CBM"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
| 字段 | 必填 | 规则 |
|
||||
|---|---|---|
|
||||
| `templateCode` | 是 | `SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 或 `BILL_OF_LADING_CONFIRMATION_A4_V1` |
|
||||
| `pageSpec.widthMm` | 是 | 固定数值 `210` |
|
||||
| `pageSpec.heightMm` | 是 | 固定数值 `297` |
|
||||
| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 |
|
||||
| `documents` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 |
|
||||
| `documents[].fields` | 否 | 字符串字典,字段值允许换行,单值最多 4000 字符 |
|
||||
| `documents[].cargoRows` | 否 | 0-1 项;当前 V1 模板只渲染一行货物,调用方不得发送第二行 |
|
||||
|
||||
托运书模板支持的完整 `fields` 键:`toCompany`、`issuerNameCn`、`issuerNameEn`、`issuerAddress`、`issuerPostalCode`、`issuerTel`、`issuerFax`、`shipper`、`consignee`、`notifyParty`、`picName`、`picTel`、`picFax`、`documentName`、`documentTel`、`documentFax`、`jobNumber`、`etdDate`、`serviceTerm`、`freightTerm`、`apCode`、`apName`、`cargoQuantity`、`transshipment`、`partialShipment`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`totalPackagesInWords`、`remark`、`carrier`、`bookingAgent`、`printDate`。
|
||||
|
||||
提单确认件模板支持的完整 `fields` 键:`shipper`、`consignee`、`notifyParty`、`bookingNumber`、`ensAmsCutoffDate`、`customsCutoffDate`、`expectedDepartureDate`、`oceanVesselVoyage`、`portOfLoading`、`portOfDischarge`、`placeOfDelivery`、`finalDestination`、`serviceTerm`、`freightTerm`、`shipperLoadCountSeal`、`containerSpec`、`shippedOnBoard`、`draftText`、`totalPackagesInWords`、`containerSummary`、`notes`、`issuePlace`、`issueDate`。
|
||||
|
||||
调用方 MUST 只发送所选模板支持的键。当前服务会忽略未知键而不报错,因此调用方必须通过单元测试固定字段映射,避免拼写错误生成缺字段 PDF。空值字段可以省略;不得为了填满模板要求用户重复录入订单、客户、航线或货物中已经存在的数据。
|
||||
|
||||
未传 `downloadFileName` 时,服务使用 `freight-a4-documents-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
|
||||
|
||||
## 3. 响应处理
|
||||
|
||||
调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200:
|
||||
@@ -114,6 +168,12 @@ public interface PrintPdfClient {
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody InboundMarkPdfRequest request);
|
||||
|
||||
@PostMapping(value = "/freight-a4-documents", consumes = "application/json")
|
||||
ResponseEntity<byte[]> generateFreightA4Documents(
|
||||
@RequestHeader("X-Internal-Service-Token") String internalToken,
|
||||
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
|
||||
@RequestBody FreightA4PdfRequest request);
|
||||
|
||||
record InboundMarkPdfRequest(
|
||||
String templateCode,
|
||||
PageSpec pageSpec,
|
||||
@@ -128,6 +188,22 @@ public interface PrintPdfClient {
|
||||
String cartonText,
|
||||
String routeText,
|
||||
String qrContent) {}
|
||||
|
||||
record FreightA4PdfRequest(
|
||||
String templateCode,
|
||||
PageSpec pageSpec,
|
||||
String downloadFileName,
|
||||
List<FreightDocument> documents) {}
|
||||
|
||||
record FreightDocument(Map<String, String> fields, List<CargoRow> cargoRows) {}
|
||||
|
||||
record CargoRow(
|
||||
String marksAndNos,
|
||||
String quantity,
|
||||
String packaging,
|
||||
String descriptionOfGoods,
|
||||
String grossWeight,
|
||||
String measurement) {}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -176,13 +252,91 @@ spring:
|
||||
- 不建议对该 POST 做无限或无退避自动重试;超时或 `A0429` 最多有限重试,并加入退避和抖动。
|
||||
- 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。
|
||||
- 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。
|
||||
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、二维码内容或 PDF 二进制。
|
||||
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、完整 `documents[]`、二维码内容或 PDF 二进制。
|
||||
|
||||
## 6. 联调检查清单
|
||||
|
||||
1. `GET /actuator/health` 返回 `UP`。
|
||||
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
|
||||
3. 检查页面为严格 `100mm x 100mm`,`X-Print-Page-Count` 与 `labels.length` 一致。
|
||||
3. 检查入仓标签页面为严格 `100mm x 100mm`,A4 单据页面为严格 `210mm x 297mm`,`X-Print-Page-Count` 与数组长度一致。
|
||||
4. 提交多页并检查页序与数组顺序一致。
|
||||
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
|
||||
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。
|
||||
|
||||
## 7. AI 实施与验证清单
|
||||
|
||||
修改其他业务模块接入 Print 时,按以下顺序实施,不得从 Admin 浏览器直连 Print:
|
||||
|
||||
1. 从 OpenAPI 和本文件在调用模块内声明请求 DTO 与 Feign Client;不得新增对 `print-service` Java 包的编译依赖。
|
||||
2. 在调用模块的 Service 中完成权限、客户归属和数据范围校验,再从已有订单、客户、航线及货物数据自动映射打印字段。
|
||||
3. `labels[]`、`documents[]` 和 `cargoRows` 顺序必须由调用模块明确决定;A4 V1 的 `cargoRows` 只能包含 0 或 1 项。
|
||||
4. 同时处理 `application/pdf`、HTTP 200 的 `application/json` 业务错误及 HTTP 413;不得把任意 HTTP 200 当作成功 PDF。
|
||||
5. 只向浏览器转发安全响应头,不得转发或记录 `X-Internal-Service-Token`,不得记录完整打印请求或 PDF 二进制。
|
||||
6. 为字段映射、PDF/JSON 响应分支、`A0403`、`A0422`、`A0429`、HTTP 413 和超时增加调用模块测试。
|
||||
|
||||
只验证 Print 本身时,从 `baiyue-firstleg/` 构建并启动单个服务;不需要启动业务服务、Nacos 或数据库:
|
||||
|
||||
```powershell
|
||||
mvn -pl print -am package -DskipTests
|
||||
$env:PRINT_PORT="9207"
|
||||
$env:SPRING_PROFILES_ACTIVE="dev"
|
||||
$env:INTERNAL_SERVICE_TOKEN="print-local-contract-test-token-20260806"
|
||||
$env:SPRING_CLOUD_NACOS_CONFIG_ENABLED="false"
|
||||
$env:NACOS_DISCOVERY_ENABLED="false"
|
||||
$env:NACOS_REGISTER_ENABLED="false"
|
||||
java -jar print\target\print-service-1.0.0-SNAPSHOT.jar
|
||||
```
|
||||
|
||||
启动后向 `POST http://127.0.0.1:9207/internal/print/v1/pdfs/freight-a4-documents` 发送 `Content-Type: application/json`、`Accept: application/pdf, application/json`、上述内部令牌及任意 `X-Request-Id`。托运书验证请求体:
|
||||
|
||||
```json
|
||||
{
|
||||
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
|
||||
"pageSpec": {
|
||||
"widthMm": 210,
|
||||
"heightMm": 297
|
||||
},
|
||||
"downloadFileName": "BYSE2607044-托运书.pdf",
|
||||
"documents": [
|
||||
{
|
||||
"fields": {
|
||||
"toCompany": "宁波胜泽远航国际物流有限公司",
|
||||
"issuerNameCn": "浙江柏悦贸易有限公司(香港)",
|
||||
"issuerNameEn": "ZHEJIANG BAIYUE TRADE CO.,LIMITED",
|
||||
"issuerAddress": "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室",
|
||||
"issuerPostalCode": "322000",
|
||||
"issuerTel": "0579-85875557",
|
||||
"issuerFax": "0579-85601277",
|
||||
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557",
|
||||
"consignee": "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354",
|
||||
"notifyParty": "SAME AS CONSIGNEE",
|
||||
"picName": "苏天翔",
|
||||
"jobNumber": "BYSE2607044",
|
||||
"etdDate": "2026/07/24",
|
||||
"serviceTerm": "CY-CY",
|
||||
"freightTerm": "FREIGHT PREPAID",
|
||||
"cargoQuantity": "1*20GP",
|
||||
"portOfLoading": "NINGBO",
|
||||
"portOfDischarge": "EL DEKHEILA",
|
||||
"placeOfDelivery": "EL DEKHEILA",
|
||||
"totalPackagesInWords": "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.",
|
||||
"carrier": "万海航运(香港)股份有限公司",
|
||||
"bookingAgent": "宁波胜泽远航国际物流有限公司",
|
||||
"printDate": "2026 年 07 月 16 日"
|
||||
},
|
||||
"cargoRows": [
|
||||
{
|
||||
"marksAndNos": "N/M",
|
||||
"quantity": "700",
|
||||
"packaging": "PACKAGES",
|
||||
"descriptionOfGoods": "CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱",
|
||||
"grossWeight": "28000.00 KGS",
|
||||
"measurement": "28.000 CBM"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
提单确认件必须把 `templateCode` 改为 `BILL_OF_LADING_CONFIRMATION_A4_V1`,并仅传上一节列出的提单确认件字段。成功响应必须是 `Content-Type: application/pdf`;`application/json` 必须按响应体 `code/message` 作为失败处理。
|
||||
|
||||
@@ -3,7 +3,9 @@ package com.baiyue.print.controller;
|
||||
import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
@@ -27,6 +29,7 @@ public class InternalPdfController {
|
||||
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
|
||||
|
||||
@Resource private InboundMarkPdfService pdfService;
|
||||
@Resource private FreightA4PdfService freightA4PdfService;
|
||||
@Resource private InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
@@ -58,4 +61,34 @@ public class InternalPdfController {
|
||||
|
||||
return response.body(content);
|
||||
}
|
||||
|
||||
@PostMapping(value = "/freight-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<byte[]> generateFreightA4Documents(
|
||||
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
|
||||
@Valid @RequestBody FreightA4PdfDtos.Request request,
|
||||
HttpServletRequest servletRequest) {
|
||||
tokenGuard.verify(internalToken);
|
||||
String requestId = CorrelationContext.requestId(servletRequest);
|
||||
FreightA4PdfDtos.Request effectiveRequest = request;
|
||||
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
|
||||
effectiveRequest = new FreightA4PdfDtos.Request(request.templateCode(), request.pageSpec(),
|
||||
"freight-a4-documents-" + requestId + ".pdf", request.documents());
|
||||
}
|
||||
FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,9 @@ import org.springframework.web.filter.OncePerRequestFilter;
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
public class PrintRequestSizeFilter extends OncePerRequestFilter {
|
||||
private static final String PATH = "/internal/print/v1/pdfs/inbound-marks";
|
||||
private static final java.util.Set<String> PATHS = java.util.Set.of(
|
||||
"/internal/print/v1/pdfs/inbound-marks",
|
||||
"/internal/print/v1/pdfs/freight-a4-documents");
|
||||
|
||||
@Resource private PrintPdfProperties properties;
|
||||
@Resource private ObjectMapper objectMapper;
|
||||
@@ -36,7 +38,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter {
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATHS.contains(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.print.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/** A4 sea-freight document PDF request contract. */
|
||||
public final class FreightA4PdfDtos {
|
||||
public static final String SHIPPING_INSTRUCTION_TEMPLATE = "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1";
|
||||
public static final String BILL_CONFIRMATION_TEMPLATE = "BILL_OF_LADING_CONFIRMATION_A4_V1";
|
||||
|
||||
private FreightA4PdfDtos() {
|
||||
}
|
||||
|
||||
public record Request(
|
||||
String templateCode,
|
||||
PageSpec pageSpec,
|
||||
String downloadFileName,
|
||||
List<Document> documents
|
||||
) {
|
||||
}
|
||||
|
||||
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
|
||||
}
|
||||
|
||||
public record Document(
|
||||
Map<String, String> fields,
|
||||
List<CargoRow> cargoRows
|
||||
) {
|
||||
public String field(String key) {
|
||||
if (fields == null) {
|
||||
return "";
|
||||
}
|
||||
return fields.getOrDefault(key, "");
|
||||
}
|
||||
}
|
||||
|
||||
public record CargoRow(
|
||||
String marksAndNos,
|
||||
String quantity,
|
||||
String packaging,
|
||||
String descriptionOfGoods,
|
||||
String grossWeight,
|
||||
String measurement
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import java.io.IOException;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
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;
|
||||
|
||||
/** Fixed A4 renderers for sea-freight documents. */
|
||||
@Component
|
||||
public class FreightA4Renderer {
|
||||
public static final double PAGE_WIDTH_MM = 210d;
|
||||
public static final double PAGE_HEIGHT_MM = 297d;
|
||||
|
||||
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 SHIPPING_TEXT = 6.2f;
|
||||
private static final float SHIPPING_SMALL = 5.8f;
|
||||
private static final float BILL_TEXT = 7.2f;
|
||||
private static final float BILL_SMALL = 6.8f;
|
||||
|
||||
public void renderPage(PDDocument document, PDFont font, String templateCode,
|
||||
FreightA4PdfDtos.Document data) 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);
|
||||
stream.setStrokingColor(0, 0, 0);
|
||||
if (FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)) {
|
||||
renderShippingInstruction(stream, units, font, data);
|
||||
} else if (FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE.equals(templateCode)) {
|
||||
renderBillConfirmation(stream, units, font, data);
|
||||
} else {
|
||||
throw new IllegalArgumentException("unsupported A4 freight template");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void renderShippingInstruction(PDPageContentStream stream, PdfUnits units, PDFont font,
|
||||
FreightA4PdfDtos.Document data) throws IOException {
|
||||
text(stream, units, font, "Sea Freight Shipping Instructions", 12, 15, 18);
|
||||
text(stream, units, font, "To: " + data.field("toCompany"), SHIPPING_TEXT, 15, 24);
|
||||
|
||||
rect(stream, 15, 29, 180, 250);
|
||||
line(stream, 113, 29, 113, 155);
|
||||
line(stream, 15, 64, 113, 64);
|
||||
line(stream, 15, 99, 113, 99);
|
||||
line(stream, 15, 134, 113, 134);
|
||||
line(stream, 15, 147, 113, 147);
|
||||
line(stream, 64, 134, 64, 155);
|
||||
|
||||
wrapped(stream, units, font, "Shipper(Full Name & Address)(托运人):", SHIPPING_TEXT, 17, 33, 92, 4, 5);
|
||||
wrapped(stream, units, font, data.field("shipper"), SHIPPING_TEXT, 17, 39, 92, 5, 5);
|
||||
wrapped(stream, units, font, "Consignee(收货人):", SHIPPING_TEXT, 17, 68, 92, 4, 5);
|
||||
wrapped(stream, units, font, data.field("consignee"), SHIPPING_TEXT, 17, 74, 92, 5, 5);
|
||||
wrapped(stream, units, font, "Notify Party(通知人):", SHIPPING_TEXT, 17, 103, 92, 4, 5);
|
||||
wrapped(stream, units, font, data.field("notifyParty"), SHIPPING_TEXT, 17, 109, 92, 4, 5);
|
||||
|
||||
text(stream, units, font, "Ocean Vessel/Voy(船名航次):", SHIPPING_TEXT, 17, 139);
|
||||
text(stream, units, font, data.field("oceanVesselVoyage"), SHIPPING_TEXT, 17, 145.5);
|
||||
text(stream, units, font, "Port of Loading(起运港):", SHIPPING_TEXT, 66, 139);
|
||||
text(stream, units, font, data.field("portOfLoading"), SHIPPING_TEXT, 66, 145.5);
|
||||
text(stream, units, font, "Part of Discharge(卸货港):", SHIPPING_TEXT, 17, 150);
|
||||
text(stream, units, font, data.field("portOfDischarge"), SHIPPING_TEXT, 17, 154);
|
||||
text(stream, units, font, "Place of Delivery(交货地):", SHIPPING_TEXT, 66, 150);
|
||||
text(stream, units, font, data.field("placeOfDelivery"), SHIPPING_TEXT, 66, 154);
|
||||
|
||||
renderShippingRightPanel(stream, units, font, data);
|
||||
renderShippingCargo(stream, units, font, data);
|
||||
}
|
||||
|
||||
private void renderShippingRightPanel(PDPageContentStream stream, PdfUnits units, PDFont font,
|
||||
FreightA4PdfDtos.Document data) throws IOException {
|
||||
line(stream, 113, 76, 195, 76);
|
||||
line(stream, 113, 89, 195, 89);
|
||||
line(stream, 113, 102, 195, 102);
|
||||
for (double y : new double[]{109, 116, 123, 130, 137, 144, 151}) {
|
||||
line(stream, 113, y, 195, y);
|
||||
}
|
||||
line(stream, 135, 76, 135, 102);
|
||||
line(stream, 135, 82.5, 195, 82.5);
|
||||
line(stream, 135, 95.5, 195, 95.5);
|
||||
|
||||
centered(stream, units, font, data.field("issuerNameCn"), 12, 154, 37);
|
||||
centered(stream, units, font, data.field("issuerNameEn"), SHIPPING_TEXT, 154, 45);
|
||||
wrapped(stream, units, font, data.field("issuerAddress"), SHIPPING_TEXT, 114, 52, 78, 2, 4.5);
|
||||
text(stream, units, font, "邮编:" + data.field("issuerPostalCode"), SHIPPING_TEXT, 114, 59);
|
||||
text(stream, units, font, "TEL:" + data.field("issuerTel") + " | FAX:" + data.field("issuerFax"),
|
||||
5.2f, 114, 63.5);
|
||||
centered(stream, units, font, "出口货物托运单", 12, 154, 72);
|
||||
|
||||
text(stream, units, font, "PIC(负责人):", SHIPPING_TEXT, 114, 80);
|
||||
text(stream, units, font, data.field("picName"), SHIPPING_TEXT, 114, 86);
|
||||
text(stream, units, font, "TEL(电话): " + data.field("picTel"), SHIPPING_TEXT, 136, 80);
|
||||
text(stream, units, font, "FAX(传真): " + data.field("picFax"), SHIPPING_TEXT, 136, 87);
|
||||
text(stream, units, font, "Document:", SHIPPING_TEXT, 114, 94);
|
||||
text(stream, units, font, data.field("documentName"), SHIPPING_TEXT, 114, 100);
|
||||
text(stream, units, font, "TEL(电话): " + data.field("documentTel"), SHIPPING_TEXT, 136, 94);
|
||||
text(stream, units, font, "FAX(传真): " + data.field("documentFax"), SHIPPING_TEXT, 136, 101);
|
||||
|
||||
text(stream, units, font, "Job Nbr(委托单号): " + data.field("jobNumber"), SHIPPING_TEXT, 114, 107);
|
||||
text(stream, units, font, "ETD(开航日期): " + data.field("etdDate"), SHIPPING_TEXT, 114, 114);
|
||||
text(stream, units, font, "Service Term(运输条款):" + data.field("serviceTerm"), SHIPPING_TEXT, 114, 121);
|
||||
text(stream, units, font, "Freight Term(运费条款):" + data.field("freightTerm"), SHIPPING_TEXT, 114, 128);
|
||||
text(stream, units, font, "AP Code: " + data.field("apCode"), SHIPPING_TEXT, 114, 135);
|
||||
text(stream, units, font, "AP Name: " + data.field("apName"), SHIPPING_TEXT, 114, 142);
|
||||
text(stream, units, font, "Cargo Quantity(货量描述): " + data.field("cargoQuantity"), SHIPPING_TEXT, 114, 149);
|
||||
text(stream, units, font, "T/S(可否转船): " + data.field("transshipment")
|
||||
+ " Batch(可否分批): " + data.field("partialShipment"), SHIPPING_TEXT, 114, 153.5);
|
||||
}
|
||||
|
||||
private void renderShippingCargo(PDPageContentStream stream, PdfUnits units, PDFont font,
|
||||
FreightA4PdfDtos.Document data) throws IOException {
|
||||
double y = 155;
|
||||
line(stream, 15, y, 195, y);
|
||||
line(stream, 15, 166, 195, 166);
|
||||
line(stream, 15, 229, 195, 229);
|
||||
line(stream, 15, 237, 195, 237);
|
||||
line(stream, 130, 237, 130, 279);
|
||||
line(stream, 130, 259, 195, 259);
|
||||
for (double x : new double[]{50, 88, 137, 166}) {
|
||||
line(stream, x, 155, x, 166);
|
||||
}
|
||||
centered(stream, units, font, "Marks & Nos\n(标志和号码)", SHIPPING_TEXT, 32.5, 160);
|
||||
centered(stream, units, font, "Quantity & Packaging\n(件数及包装)", SHIPPING_TEXT, 69, 160);
|
||||
centered(stream, units, font, "Description of Goods(品名)", SHIPPING_TEXT, 112.5, 161);
|
||||
centered(stream, units, font, "Gross Weight(Kilos)\n毛重(公斤)", SHIPPING_SMALL, 151.5, 160);
|
||||
centered(stream, units, font, "Measurement(CBM)\n体积(立方米)", SHIPPING_SMALL, 180.5, 160);
|
||||
|
||||
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
|
||||
text(stream, units, font, value(row.marksAndNos()), SHIPPING_TEXT, 17, 171);
|
||||
text(stream, units, font, value(row.quantity()), SHIPPING_TEXT, 55, 171);
|
||||
text(stream, units, font, value(row.packaging()), SHIPPING_TEXT, 64, 171);
|
||||
wrapped(stream, units, font, value(row.descriptionOfGoods()), SHIPPING_TEXT, 88, 171, 47, 5, 5);
|
||||
text(stream, units, font, value(row.grossWeight()), SHIPPING_TEXT, 147, 171);
|
||||
text(stream, units, font, value(row.measurement()), SHIPPING_TEXT, 177, 171);
|
||||
|
||||
text(stream, units, font, "Total Packages(in words)(件数大写):" + data.field("totalPackagesInWords"),
|
||||
SHIPPING_TEXT, 17, 234);
|
||||
text(stream, units, font, "Ramark(备注):", SHIPPING_TEXT, 20, 245);
|
||||
wrapped(stream, units, font, data.field("remark"), SHIPPING_TEXT, 20, 252, 105, 5, 5);
|
||||
text(stream, units, font, "Carrier(船公司):", SHIPPING_TEXT, 135, 247);
|
||||
wrapped(stream, units, font, data.field("carrier"), SHIPPING_TEXT, 135, 254, 55, 2, 5);
|
||||
text(stream, units, font, "Booking Agent(订舱代理):", SHIPPING_TEXT, 135, 266);
|
||||
wrapped(stream, units, font, data.field("bookingAgent"), SHIPPING_TEXT, 135, 273, 55, 2, 5);
|
||||
text(stream, units, font, "打印于: " + data.field("printDate"), SHIPPING_TEXT, 149, 287);
|
||||
}
|
||||
|
||||
private void renderBillConfirmation(PDPageContentStream stream, PdfUnits units, PDFont font,
|
||||
FreightA4PdfDtos.Document data) throws IOException {
|
||||
centered(stream, units, font, "提单确认件", 15, 105, 40);
|
||||
wrapped(stream, units, font, data.field("shipper"), BILL_TEXT, 10, 64, 72, 5, 5);
|
||||
wrapped(stream, units, font, data.field("consignee"), BILL_TEXT, 10, 95, 84, 5, 5);
|
||||
wrapped(stream, units, font, data.field("notifyParty"), BILL_TEXT, 10, 126, 72, 3, 5);
|
||||
|
||||
text(stream, units, font, data.field("bookingNumber"), BILL_TEXT, 148, 64);
|
||||
text(stream, units, font, "ENS/AMS 截单: " + data.field("ensAmsCutoffDate"), BILL_TEXT, 148, 73);
|
||||
text(stream, units, font, "截关日期: " + data.field("customsCutoffDate"), BILL_TEXT, 148, 82);
|
||||
text(stream, units, font, "预计开航日期: " + data.field("expectedDepartureDate"), BILL_TEXT, 148, 91);
|
||||
|
||||
text(stream, units, font, data.field("oceanVesselVoyage"), BILL_TEXT, 10, 155);
|
||||
text(stream, units, font, data.field("portOfLoading"), BILL_TEXT, 61, 161);
|
||||
text(stream, units, font, data.field("portOfDischarge"), BILL_TEXT, 10, 170);
|
||||
text(stream, units, font, data.field("placeOfDelivery"), BILL_TEXT, 61, 170);
|
||||
text(stream, units, font, data.field("finalDestination"), BILL_TEXT, 114, 155);
|
||||
text(stream, units, font, data.field("serviceTerm"), BILL_TEXT, 128, 170);
|
||||
|
||||
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
|
||||
text(stream, units, font, value(row.quantity()) + " " + value(row.packaging()), BILL_TEXT, 44, 179);
|
||||
text(stream, units, font, "SAID TO CONTAIN(S)", BILL_TEXT, 74, 179);
|
||||
text(stream, units, font, value(row.grossWeight()), BILL_TEXT, 156, 179);
|
||||
text(stream, units, font, value(row.measurement()), BILL_TEXT, 183, 179);
|
||||
text(stream, units, font, value(row.marksAndNos()), BILL_TEXT, 9, 187);
|
||||
wrapped(stream, units, font, value(row.descriptionOfGoods()), BILL_SMALL, 73, 186, 83, 8, 4.25);
|
||||
|
||||
text(stream, units, font, data.field("freightTerm"), BILL_SMALL, 87, 221);
|
||||
text(stream, units, font, data.field("shipperLoadCountSeal"), BILL_SMALL, 72, 227);
|
||||
text(stream, units, font, data.field("containerSpec"), BILL_SMALL, 72, 232);
|
||||
text(stream, units, font, "SHIPPED ON BOARD:", BILL_SMALL, 163, 195);
|
||||
text(stream, units, font, data.field("shippedOnBoard"), BILL_SMALL, 169, 202);
|
||||
centered(stream, units, font, data.field("draftText"), 16, 158, 225);
|
||||
centered(stream, units, font, data.field("totalPackagesInWords"), BILL_SMALL, 105, 238);
|
||||
text(stream, units, font, data.field("containerSummary"), BILL_SMALL, 10, 247);
|
||||
wrapped(stream, units, font, data.field("notes"), BILL_SMALL, 10, 278, 130, 3, 5);
|
||||
text(stream, units, font, data.field("issuePlace"), BILL_SMALL, 140, 288);
|
||||
text(stream, units, font, data.field("issueDate"), BILL_SMALL, 168, 288);
|
||||
}
|
||||
|
||||
private FreightA4PdfDtos.CargoRow firstCargoRow(FreightA4PdfDtos.Document data) {
|
||||
if (data.cargoRows() == null || data.cargoRows().isEmpty() || data.cargoRows().get(0) == null) {
|
||||
return new FreightA4PdfDtos.CargoRow("", "", "", "", "", "");
|
||||
}
|
||||
return data.cargoRows().get(0);
|
||||
}
|
||||
|
||||
private void text(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
|
||||
float fontSize, double xMm, double baselineMmFromTop) throws IOException {
|
||||
String value = value(text).replace('\n', ' ');
|
||||
if (value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
stream.beginText();
|
||||
stream.setFont(font, fontSize);
|
||||
stream.newLineAtOffset(PdfUnits.mmToPt(xMm), units.topDownMmToPdfY(baselineMmFromTop));
|
||||
stream.showText(value);
|
||||
stream.endText();
|
||||
}
|
||||
|
||||
private void centered(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
|
||||
float fontSize, double centerXmm, double baselineMmFromTop) throws IOException {
|
||||
String value = value(text);
|
||||
if (value.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
String[] lines = value.split("\\n", -1);
|
||||
double y = baselineMmFromTop - (lines.length - 1) * 2.2;
|
||||
for (String line : lines) {
|
||||
float width = font.getStringWidth(line) / 1000f * fontSize;
|
||||
float x = PdfUnits.mmToPt(centerXmm) - width / 2f;
|
||||
stream.beginText();
|
||||
stream.setFont(font, fontSize);
|
||||
stream.newLineAtOffset(x, units.topDownMmToPdfY(y));
|
||||
stream.showText(line);
|
||||
stream.endText();
|
||||
y += 5;
|
||||
}
|
||||
}
|
||||
|
||||
private void wrapped(PDPageContentStream stream, PdfUnits units, PDFont font, String text, float fontSize,
|
||||
double xMm, double firstBaselineMmFromTop, double widthMm, int maxLines,
|
||||
double lineHeightMm) throws IOException {
|
||||
List<String> lines = wrap(font, fontSize, value(text), PdfUnits.mmToPt(widthMm), maxLines);
|
||||
double y = firstBaselineMmFromTop;
|
||||
for (String line : lines) {
|
||||
text(stream, units, font, line, fontSize, xMm, y);
|
||||
y += lineHeightMm;
|
||||
}
|
||||
}
|
||||
|
||||
private List<String> wrap(PDFont font, float fontSize, String text, float maxWidthPt, int maxLines)
|
||||
throws IOException {
|
||||
List<String> lines = new ArrayList<>();
|
||||
if (text.isEmpty() || maxLines < 1) {
|
||||
return lines;
|
||||
}
|
||||
for (String sourceLine : text.split("\\n", -1)) {
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (String token : sourceLine.split("(?<=\\s)|(?=\\s)")) {
|
||||
if (token.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
String candidate = current + token;
|
||||
if (!current.isEmpty() && width(font, fontSize, candidate) > maxWidthPt) {
|
||||
lines.add(current.toString().stripTrailing());
|
||||
if (lines.size() >= maxLines) {
|
||||
return lines;
|
||||
}
|
||||
current.setLength(0);
|
||||
candidate = token.stripLeading();
|
||||
}
|
||||
if (width(font, fontSize, candidate) > maxWidthPt && current.isEmpty()) {
|
||||
for (String broken : breakLongToken(font, fontSize, candidate, maxWidthPt)) {
|
||||
lines.add(broken);
|
||||
if (lines.size() >= maxLines) {
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
current.setLength(0);
|
||||
current.append(candidate);
|
||||
}
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
lines.add(current.toString().stripTrailing());
|
||||
if (lines.size() >= maxLines) {
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private List<String> breakLongToken(PDFont font, float fontSize, String token, float maxWidthPt)
|
||||
throws IOException {
|
||||
List<String> lines = new ArrayList<>();
|
||||
StringBuilder current = new StringBuilder();
|
||||
for (int offset = 0; offset < token.length(); ) {
|
||||
int codePoint = token.codePointAt(offset);
|
||||
String next = current + new String(Character.toChars(codePoint));
|
||||
if (!current.isEmpty() && width(font, fontSize, next) > maxWidthPt) {
|
||||
lines.add(current.toString());
|
||||
current.setLength(0);
|
||||
}
|
||||
current.appendCodePoint(codePoint);
|
||||
offset += Character.charCount(codePoint);
|
||||
}
|
||||
if (!current.isEmpty()) {
|
||||
lines.add(current.toString());
|
||||
}
|
||||
return lines;
|
||||
}
|
||||
|
||||
private float width(PDFont font, float fontSize, String text) throws IOException {
|
||||
return font.getStringWidth(text) / 1000f * fontSize;
|
||||
}
|
||||
|
||||
private void rect(PDPageContentStream stream, double xMm, double yMm, double widthMm, double heightMm)
|
||||
throws IOException {
|
||||
stream.setLineWidth(0.5f);
|
||||
stream.addRect(PdfUnits.mmToPt(xMm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - yMm - heightMm),
|
||||
PdfUnits.mmToPt(widthMm), PdfUnits.mmToPt(heightMm));
|
||||
stream.stroke();
|
||||
}
|
||||
|
||||
private void line(PDPageContentStream stream, double x1Mm, double y1Mm, double x2Mm, double y2Mm)
|
||||
throws IOException {
|
||||
stream.setLineWidth(0.45f);
|
||||
stream.moveTo(PdfUnits.mmToPt(x1Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y1Mm));
|
||||
stream.lineTo(PdfUnits.mmToPt(x2Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y2Mm));
|
||||
stream.stroke();
|
||||
}
|
||||
|
||||
private String value(String text) {
|
||||
return text == null ? "" : text;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
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.FreightA4PdfDtos;
|
||||
import com.baiyue.print.renderer.FreightA4Renderer;
|
||||
import jakarta.annotation.Resource;
|
||||
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.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
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;
|
||||
|
||||
/** Validates A4 freight documents and renders one A4 page per request document. */
|
||||
@Service
|
||||
public class FreightA4PdfService {
|
||||
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
|
||||
private static final int MAX_FIELD_KEY_CODE_POINTS = 80;
|
||||
private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000;
|
||||
private static final int MAX_CARGO_ROWS = 50;
|
||||
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
|
||||
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
|
||||
private static final Set<String> SUPPORTED_TEMPLATES = Set.of(
|
||||
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
||||
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE);
|
||||
private static final Set<Character> FORBIDDEN_FILE_NAME_CHARACTERS =
|
||||
Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
|
||||
|
||||
@Resource private PrintPdfProperties properties;
|
||||
@Resource private PdfGenerationLimiter limiter;
|
||||
@Resource private FreightA4Renderer renderer;
|
||||
|
||||
public GeneratedPdf generate(FreightA4PdfDtos.Request request) {
|
||||
NormalizedRequest normalized = validateAndNormalize(request);
|
||||
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
|
||||
return render(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
private GeneratedPdf render(NormalizedRequest request) {
|
||||
try (PDDocument document = new PDDocument();
|
||||
InputStream fontStream = openFont();
|
||||
LimitedByteArrayOutputStream output =
|
||||
new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) {
|
||||
PDType0Font font = PDType0Font.load(document, fontStream, true);
|
||||
for (FreightA4PdfDtos.Document documentData : request.documents()) {
|
||||
renderer.renderPage(document, font, request.templateCode(), documentData);
|
||||
}
|
||||
document.save(output);
|
||||
byte[] content = output.toByteArray();
|
||||
return new GeneratedPdf(content, request.fileName(), request.documents().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(FreightA4PdfDtos.Request request) {
|
||||
if (request == null) {
|
||||
invalid("request is required");
|
||||
}
|
||||
String templateCode = normalizeRequiredLine(request.templateCode(), "templateCode");
|
||||
if (!SUPPORTED_TEMPLATES.contains(templateCode)) {
|
||||
invalid("templateCode is not supported");
|
||||
}
|
||||
if (request.pageSpec() == null) {
|
||||
invalid("pageSpec is required");
|
||||
}
|
||||
requireSupportedDimension(request.pageSpec().widthMm(), A4_WIDTH_MM, "pageSpec.widthMm");
|
||||
requireSupportedDimension(request.pageSpec().heightMm(), A4_HEIGHT_MM, "pageSpec.heightMm");
|
||||
if (request.documents() == null || request.documents().isEmpty()) {
|
||||
invalid("documents must contain at least one item");
|
||||
}
|
||||
if (request.documents().size() > properties.getMaxPages()) {
|
||||
invalid("documents must not contain more than " + properties.getMaxPages() + " items");
|
||||
}
|
||||
|
||||
List<FreightA4PdfDtos.Document> documents = new ArrayList<>(request.documents().size());
|
||||
for (int index = 0; index < request.documents().size(); index++) {
|
||||
documents.add(normalizeDocument(request.documents().get(index), index));
|
||||
}
|
||||
return new NormalizedRequest(templateCode, normalizeFileName(request.downloadFileName()),
|
||||
List.copyOf(documents));
|
||||
}
|
||||
|
||||
private FreightA4PdfDtos.Document normalizeDocument(FreightA4PdfDtos.Document document, int index) {
|
||||
if (document == null) {
|
||||
invalid("documents[" + index + "] is required");
|
||||
}
|
||||
Map<String, String> fields = new LinkedHashMap<>();
|
||||
if (document.fields() != null) {
|
||||
for (Map.Entry<String, String> entry : document.fields().entrySet()) {
|
||||
String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key"));
|
||||
if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) {
|
||||
invalid(field(index, "fields." + key) + " key is too long");
|
||||
}
|
||||
fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key)));
|
||||
}
|
||||
}
|
||||
List<FreightA4PdfDtos.CargoRow> rows = new ArrayList<>();
|
||||
if (document.cargoRows() != null) {
|
||||
if (document.cargoRows().size() > MAX_CARGO_ROWS) {
|
||||
invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items");
|
||||
}
|
||||
for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) {
|
||||
rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex));
|
||||
}
|
||||
}
|
||||
return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows));
|
||||
}
|
||||
|
||||
private FreightA4PdfDtos.CargoRow normalizeCargoRow(FreightA4PdfDtos.CargoRow row, int documentIndex,
|
||||
int rowIndex) {
|
||||
if (row == null) {
|
||||
invalid("documents[" + documentIndex + "].cargoRows[" + rowIndex + "] is required");
|
||||
}
|
||||
String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "].";
|
||||
return new FreightA4PdfDtos.CargoRow(
|
||||
normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"),
|
||||
normalizeOptionalBlock(row.quantity(), prefix + "quantity"),
|
||||
normalizeOptionalBlock(row.packaging(), prefix + "packaging"),
|
||||
normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
|
||||
normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"),
|
||||
normalizeOptionalBlock(row.measurement(), prefix + "measurement"));
|
||||
}
|
||||
|
||||
private String normalizeFileName(String value) {
|
||||
if (value == null) {
|
||||
return "freight-a4-documents.pdf";
|
||||
}
|
||||
String normalized = normalizeRequiredLine(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 normalizeRequiredLine(String value, String field) {
|
||||
String normalized = normalizeOptionalBlock(value, field);
|
||||
if (normalized.isBlank()) {
|
||||
invalid(field + " must not be blank");
|
||||
}
|
||||
if (normalized.contains("\n")) {
|
||||
invalid(field + " must be a single line");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeOptionalBlock(String value, String field) {
|
||||
if (value == null) {
|
||||
return "";
|
||||
}
|
||||
String normalized = Normalizer.normalize(value, Normalizer.Form.NFC)
|
||||
.replace("\r\n", "\n")
|
||||
.replace('\r', '\n');
|
||||
if (normalized.codePointCount(0, normalized.length()) > MAX_FIELD_VALUE_CODE_POINTS) {
|
||||
invalid(field + " must not exceed " + MAX_FIELD_VALUE_CODE_POINTS + " characters");
|
||||
}
|
||||
normalized.codePoints().forEach(codePoint -> {
|
||||
if (isForbiddenControl(codePoint)) {
|
||||
invalid(field + " contains a control character");
|
||||
}
|
||||
});
|
||||
return normalized.strip();
|
||||
}
|
||||
|
||||
private boolean isForbiddenControl(int codePoint) {
|
||||
int type = Character.getType(codePoint);
|
||||
return codePoint != '\n' && codePoint != '\t'
|
||||
&& (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 "documents[" + 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 templateCode, String fileName, List<FreightA4PdfDtos.Document> documents) {
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
@@ -52,6 +52,54 @@ paths:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ApiError'
|
||||
/internal/print/v1/pdfs/freight-a4-documents:
|
||||
post:
|
||||
summary: Generate an A4 sea-freight document PDF
|
||||
operationId: generateFreightA4DocumentPdf
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/FreightA4PdfRequest'
|
||||
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:
|
||||
@@ -112,6 +160,66 @@ components:
|
||||
minLength: 1
|
||||
maxLength: 34
|
||||
pattern: '^[\\x20-\\x7E]+$'
|
||||
FreightA4PdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, documents]
|
||||
properties:
|
||||
templateCode:
|
||||
type: string
|
||||
enum: [SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1, BILL_OF_LADING_CONFIRMATION_A4_V1]
|
||||
pageSpec:
|
||||
$ref: '#/components/schemas/FreightA4PageSpec'
|
||||
downloadFileName:
|
||||
type: string
|
||||
maxLength: 120
|
||||
example: BYSE2607044-托运书.pdf
|
||||
documents:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 2000
|
||||
description: One document item renders one A4 page in request order.
|
||||
items:
|
||||
$ref: '#/components/schemas/FreightA4Document'
|
||||
FreightA4PageSpec:
|
||||
type: object
|
||||
required: [widthMm, heightMm]
|
||||
properties:
|
||||
widthMm:
|
||||
type: number
|
||||
enum: [210]
|
||||
heightMm:
|
||||
type: number
|
||||
enum: [297]
|
||||
FreightA4Document:
|
||||
type: object
|
||||
properties:
|
||||
fields:
|
||||
type: object
|
||||
additionalProperties:
|
||||
type: string
|
||||
maxLength: 4000
|
||||
description: Template field map. Known keys are documented in the print module README.
|
||||
cargoRows:
|
||||
type: array
|
||||
maxItems: 1
|
||||
description: V1 templates render at most one cargo row. Callers must not send additional rows.
|
||||
items:
|
||||
$ref: '#/components/schemas/FreightA4CargoRow'
|
||||
FreightA4CargoRow:
|
||||
type: object
|
||||
properties:
|
||||
marksAndNos:
|
||||
type: string
|
||||
quantity:
|
||||
type: string
|
||||
packaging:
|
||||
type: string
|
||||
descriptionOfGoods:
|
||||
type: string
|
||||
grossWeight:
|
||||
type: string
|
||||
measurement:
|
||||
type: string
|
||||
ApiError:
|
||||
type: object
|
||||
required: [code, message]
|
||||
|
||||
@@ -3,7 +3,9 @@ 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.FreightA4PdfDtos;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@@ -38,6 +40,8 @@ class InternalPdfControllerTest {
|
||||
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||
@MockBean
|
||||
private InboundMarkPdfService service;
|
||||
@MockBean
|
||||
private FreightA4PdfService freightA4PdfService;
|
||||
@Resource
|
||||
private InternalPdfController controller;
|
||||
private MockMvc mockMvc;
|
||||
@@ -127,6 +131,28 @@ class InternalPdfControllerTest {
|
||||
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsFreightA4PdfWithDownloadHeaders() throws Exception {
|
||||
byte[] pdf = "%PDF-a4-test".getBytes(StandardCharsets.US_ASCII);
|
||||
when(freightA4PdfService.generate(any())).thenReturn(new FreightA4PdfService.GeneratedPdf(
|
||||
pdf, "BYSE2607044-托运书.pdf", 1));
|
||||
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/freight-a4-documents")
|
||||
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
|
||||
.content(validFreightA4RequestJson()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
.andExpect(content().bytes(pdf))
|
||||
.andExpect(header().string("Cache-Control", "no-store"))
|
||||
.andExpect(header().string("X-Print-Page-Count", "1"))
|
||||
.andExpect(header().string("Content-Disposition",
|
||||
org.hamcrest.Matchers.containsString("filename*=")));
|
||||
|
||||
verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
|
||||
PostMapping mapping = InternalPdfController.class
|
||||
@@ -142,6 +168,7 @@ class InternalPdfControllerTest {
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(contract)
|
||||
.contains("/internal/print/v1/pdfs/inbound-marks:")
|
||||
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
|
||||
.contains("application/pdf:")
|
||||
.contains("application/json:")
|
||||
.contains("name: X-Internal-Service-Token")
|
||||
@@ -165,4 +192,29 @@ class InternalPdfControllerTest {
|
||||
""";
|
||||
}
|
||||
|
||||
private String validFreightA4RequestJson() {
|
||||
return """
|
||||
{
|
||||
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
|
||||
"pageSpec": {"widthMm": 210, "heightMm": 297},
|
||||
"downloadFileName": "BYSE2607044-托运书.pdf",
|
||||
"documents": [{
|
||||
"fields": {
|
||||
"toCompany": "宁波胜泽远航国际物流有限公司",
|
||||
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED",
|
||||
"jobNumber": "BYSE2607044"
|
||||
},
|
||||
"cargoRows": [{
|
||||
"marksAndNos": "N/M",
|
||||
"quantity": "700",
|
||||
"packaging": "PACKAGES",
|
||||
"descriptionOfGoods": "CAR ACCESSORIES",
|
||||
"grossWeight": "28000.00 KGS",
|
||||
"measurement": "28.000 CBM"
|
||||
}]
|
||||
}]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -39,6 +39,19 @@ class PrintRequestSizeFilterTest {
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void alsoCapsFreightA4DocumentRequests() throws Exception {
|
||||
MockHttpServletRequest request = request("12345678901", true,
|
||||
"/internal/print/v1/pdfs/freight-a4-documents");
|
||||
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 rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest() {
|
||||
@@ -68,17 +81,25 @@ class PrintRequestSizeFilterTest {
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request(String content, boolean unknownLength) {
|
||||
return request(content, unknownLength, "/internal/print/v1/pdfs/inbound-marks");
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request(String content, boolean unknownLength, String path) {
|
||||
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
|
||||
@Override public int getContentLength() { return -1; }
|
||||
@Override public long getContentLengthLong() { return -1; }
|
||||
} : new MockHttpServletRequest();
|
||||
initialize(request, content);
|
||||
initialize(request, content, path);
|
||||
return request;
|
||||
}
|
||||
|
||||
private void initialize(MockHttpServletRequest request, String content) {
|
||||
initialize(request, content, "/internal/print/v1/pdfs/inbound-marks");
|
||||
}
|
||||
|
||||
private void initialize(MockHttpServletRequest request, String content, String path) {
|
||||
request.setMethod("POST");
|
||||
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
|
||||
request.setRequestURI(path);
|
||||
request.setContent(content.getBytes(StandardCharsets.UTF_8));
|
||||
request.setAttribute("requestId", "request-123");
|
||||
request.setAttribute("traceId", "trace-123");
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.renderer.FreightA4Renderer;
|
||||
import com.baiyue.print.renderer.PdfUnits;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class,
|
||||
PdfGenerationLimiter.class, FreightA4Renderer.class})
|
||||
class FreightA4PdfServiceGenerationTest {
|
||||
@Resource
|
||||
private FreightA4PdfService service;
|
||||
|
||||
@Test
|
||||
void generatesShippingInstructionAsStrictA4Pdf() throws IOException {
|
||||
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
|
||||
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
||||
"BYSE2607044-shipping-instruction.pdf",
|
||||
shippingDocument()));
|
||||
|
||||
assertThat(generated.fileName()).isEqualTo("BYSE2607044-shipping-instruction.pdf");
|
||||
assertThat(generated.pageCount()).isEqualTo(1);
|
||||
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
|
||||
try (PDDocument document = Loader.loadPDF(generated.content())) {
|
||||
assertA4(document);
|
||||
String text = new PDFTextStripper().getText(document);
|
||||
assertThat(text).contains("Sea Freight Shipping Instructions", "Job Nbr",
|
||||
"BYSE2607044", "CAR ACCESSORIES", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.");
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesBillConfirmationAsStrictA4Pdf() throws IOException {
|
||||
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
|
||||
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE,
|
||||
"BYSE2607044-bill-confirmation.pdf",
|
||||
billConfirmationDocument()));
|
||||
|
||||
assertThat(generated.pageCount()).isEqualTo(1);
|
||||
try (PDDocument document = Loader.loadPDF(generated.content())) {
|
||||
assertA4(document);
|
||||
String text = new PDFTextStripper().getText(document);
|
||||
assertThat(text).contains("WHY074G504054", "D R A F T", "SAID TO CONTAIN(S)",
|
||||
"WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM");
|
||||
}
|
||||
}
|
||||
|
||||
private FreightA4PdfDtos.Request request(String templateCode, String fileName,
|
||||
FreightA4PdfDtos.Document document) {
|
||||
return new FreightA4PdfDtos.Request(templateCode,
|
||||
new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297.0")),
|
||||
fileName, List.of(document));
|
||||
}
|
||||
|
||||
private FreightA4PdfDtos.Document shippingDocument() {
|
||||
return new FreightA4PdfDtos.Document(Map.ofEntries(
|
||||
Map.entry("toCompany", "宁波胜泽远航国际物流有限公司"),
|
||||
Map.entry("issuerNameCn", "浙江柏悦贸易有限公司(香港)"),
|
||||
Map.entry("issuerNameEn", "ZHEJIANG BAIYUE TRADE CO.,LIMITED"),
|
||||
Map.entry("issuerAddress", "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室"),
|
||||
Map.entry("issuerPostalCode", "322000"),
|
||||
Map.entry("issuerTel", "0579-85875557"),
|
||||
Map.entry("issuerFax", "0579-85601277"),
|
||||
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
|
||||
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
|
||||
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
|
||||
Map.entry("picName", "苏天翔"),
|
||||
Map.entry("jobNumber", "BYSE2607044"),
|
||||
Map.entry("etdDate", "2026/07/24"),
|
||||
Map.entry("serviceTerm", "CY-CY"),
|
||||
Map.entry("freightTerm", "FREIGHT PREPAID"),
|
||||
Map.entry("cargoQuantity", "1*20GP"),
|
||||
Map.entry("portOfLoading", "NINGBO"),
|
||||
Map.entry("portOfDischarge", "EL DEKHEILA"),
|
||||
Map.entry("placeOfDelivery", "EL DEKHEILA"),
|
||||
Map.entry("totalPackagesInWords", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."),
|
||||
Map.entry("carrier", "万海航运(香港)股份有限公司"),
|
||||
Map.entry("bookingAgent", "宁波胜泽远航国际物流有限公司"),
|
||||
Map.entry("printDate", "2026 年 07 月 16 日")
|
||||
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "700", "PACKAGES",
|
||||
"CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱",
|
||||
"28000.00 KGS", "28.000 CBM")));
|
||||
}
|
||||
|
||||
private FreightA4PdfDtos.Document billConfirmationDocument() {
|
||||
return new FreightA4PdfDtos.Document(Map.ofEntries(
|
||||
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN\nSTREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
|
||||
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
|
||||
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
|
||||
Map.entry("bookingNumber", "WHY074G504054"),
|
||||
Map.entry("ensAmsCutoffDate", "2026/07/20"),
|
||||
Map.entry("expectedDepartureDate", "2026/07/24"),
|
||||
Map.entry("oceanVesselVoyage", "WAN HAI 622 W027"),
|
||||
Map.entry("portOfLoading", "NINGBO"),
|
||||
Map.entry("portOfDischarge", "EL DEKHEILA"),
|
||||
Map.entry("placeOfDelivery", "EL DEKHEILA"),
|
||||
Map.entry("finalDestination", "EL DEKHEILA"),
|
||||
Map.entry("serviceTerm", "CY-CY"),
|
||||
Map.entry("freightTerm", "FREIGHT PREPAID"),
|
||||
Map.entry("shipperLoadCountSeal", "SHPR'S LOAD,COUNT AND SEAL"),
|
||||
Map.entry("containerSpec", "1*20GP"),
|
||||
Map.entry("shippedOnBoard", "24-JUL-2026"),
|
||||
Map.entry("draftText", "D R A F T"),
|
||||
Map.entry("totalPackagesInWords", "SAY ONE THOUSAND AND TWENTY EIGHT PACKAGES ONLY."),
|
||||
Map.entry("containerSummary", "WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM"),
|
||||
Map.entry("notes", "备注:1.多个箱子请分别提供分箱数,分箱品名,若未指出默认品名混装.\n2.请在 ENS/AMS 规定时间内确认好提单内容,否则有上了不船的风险."),
|
||||
Map.entry("issuePlace", "NINGBO"),
|
||||
Map.entry("issueDate", "24-JUL-2026")
|
||||
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "1028", "PACKAGES",
|
||||
"AUTO SPARE PARTS\nHS CODE 870899\nACID: 4120661061019510011\nEGYPTIAN IMPORTER VAT NUMBER:412066106\nEXPORTER REGISTRATION\nNUMBER:CN-02-91330782MA2E62HK3Y\n申请目的港 14 天免用箱",
|
||||
"28000.00 KGS", "28.000 CBM")));
|
||||
}
|
||||
|
||||
private void assertA4(PDDocument document) {
|
||||
assertThat(document.getNumberOfPages()).isEqualTo(1);
|
||||
assertThat(document.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210),
|
||||
org.assertj.core.data.Offset.offset(0.002f));
|
||||
assertThat(document.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297),
|
||||
org.assertj.core.data.Offset.offset(0.002f));
|
||||
assertThat(document.getPage(0).getRotation()).isZero();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatCode;
|
||||
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 com.baiyue.print.dto.FreightA4PdfDtos;
|
||||
import com.baiyue.print.renderer.FreightA4Renderer;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.mock.mockito.MockBean;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class})
|
||||
class FreightA4PdfServiceValidationTest {
|
||||
@MockBean private PdfGenerationLimiter limiter;
|
||||
@MockBean private FreightA4Renderer renderer;
|
||||
@jakarta.annotation.Resource private FreightA4PdfService service;
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedTemplateCode() {
|
||||
assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN",
|
||||
pageSpec(), "a4.pdf", List.of(document())), "templateCode");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonA4PageSize() {
|
||||
assertValidationFailure(new FreightA4PdfDtos.Request(
|
||||
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
||||
new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")),
|
||||
"a4.pdf", List.of(document())), "pageSpec.widthMm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void allowsMultilineDocumentFields() {
|
||||
FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request(
|
||||
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
|
||||
pageSpec(), "a4.pdf", List.of(document()));
|
||||
|
||||
assertThatCode(() -> service.generate(request)).doesNotThrowAnyException();
|
||||
}
|
||||
|
||||
private void assertValidationFailure(FreightA4PdfDtos.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 FreightA4PdfDtos.PageSpec pageSpec() {
|
||||
return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
|
||||
}
|
||||
|
||||
private FreightA4PdfDtos.Document document() {
|
||||
return new FreightA4PdfDtos.Document(Map.of("shipper", "A\nB"),
|
||||
List.of(new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user