2026-08-06 11:49:51 +08:00
# Print PDF 内部调用契约
2026-08-04 18:54:22 +08:00
2026-08-06 11:49:51 +08:00
本文是其他 Java 业务模块及代码生成 AI 接入 Print 的实现约束,不是终端用户操作手册。Print 是无数据库的内部渲染服务:调用方提交完整打印数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
2026-08-04 18:54:22 +08:00
2026-08-06 11:49:51 +08:00
机器可读 HTTP 契约以 [`src/main/resources/openapi/print-internal-v1.yaml` ](src/main/resources/openapi/print-internal-v1.yaml ) 为准;本文件补充调用方必须实现、但 OpenAPI 无法完整表达的权限、字段映射、响应分支和日志约束。调用方必须自行声明 DTO,不得依赖 Print 模块的 Java 实现类。
2026-08-04 18:54:22 +08:00
## 1. 调用边界
- 服务发现名:`print-service` 。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks` 。
2026-08-06 11:49:51 +08:00
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents` 。
2026-08-04 18:54:22 +08:00
- 本地直连:`http://127.0.0.1:9207` 。
- 请求必须携带 `X-Internal-Service-Token` ,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
2026-08-06 11:49:51 +08:00
- Admin 请求业务服务;业务服务完成 JWT、客户归属、权限和数据范围校验,组装完整 `labels[]` 或 `documents[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。
2026-08-04 18:54:22 +08:00
2026-08-06 11:49:51 +08:00
调用方必须从本模块拥有的数据自动组装打印字段,不得把 Print 请求 DTO 暴露成要求用户逐项填写的表单。调用方不得要求 Print 查询订单、补箱号、排序、推导或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
2026-08-04 18:54:22 +08:00
## 2. 请求契约
2026-08-06 11:49:51 +08:00
### 2.1 入仓标签
2026-08-04 18:54:22 +08:00
```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。
2026-08-06 11:49:51 +08:00
### 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 项;一个元素对应一页,按数组顺序生成 |
2026-08-31 19:30:37 +08:00
| `documents[].fields` | 是 | 只能使用所选模板登记的键;未知键按 `A0422` 拒绝,字段值允许换行,单值最多 4000 字符 |
| `documents[].cargoRows` | 是 | 恰好 1 项;六个货物字段均必填,空值或第二行按 `A0422` 拒绝 |
2026-08-06 11:49:51 +08:00
托运书模板支持的完整 `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` 。
2026-08-31 19:30:37 +08:00
托运书必填键固定为:`toCompany` 、`shipper` 、`consignee` 、`notifyParty` 、`jobNumber` 、`cargoQuantity` 、`portOfLoading` 、`portOfDischarge` 、`placeOfDelivery` 、`totalPackagesInWords` ;其余登记键可选。
2026-08-06 11:49:51 +08:00
提单确认件模板支持的完整 `fields` 键:`shipper` 、`consignee` 、`notifyParty` 、`bookingNumber` 、`ensAmsCutoffDate` 、`customsCutoffDate` 、`expectedDepartureDate` 、`oceanVesselVoyage` 、`portOfLoading` 、`portOfDischarge` 、`placeOfDelivery` 、`finalDestination` 、`serviceTerm` 、`freightTerm` 、`shipperLoadCountSeal` 、`containerSpec` 、`shippedOnBoard` 、`draftText` 、`totalPackagesInWords` 、`containerSummary` 、`notes` 、`issuePlace` 、`issueDate` 。
2026-08-31 19:30:37 +08:00
提单确认件必填键固定为:`shipper` 、`consignee` 、`notifyParty` 、`bookingNumber` 、`portOfLoading` 、`portOfDischarge` 、`placeOfDelivery` 、`serviceTerm` 、`freightTerm` 、`totalPackagesInWords` ;其余登记键可选。
2026-08-06 11:49:51 +08:00
2026-08-31 19:30:37 +08:00
调用方 MUST 只发送所选模板支持的键;Print 会拒绝未知键、缺少必填键、空白必填值及不完整货物行,避免拼写错误生成缺字段 PDF。可选空值可以省略;不得为了填满模板要求用户重复录入订单、客户、航线或货物中已经存在的数据。
未传 `downloadFileName` 时,托运书使用 `{jobNumber}-出口货物托运单.pdf` ,提单确认件使用 `{bookingNumber}-提单确认件.pdf` 。请求体最大 2 MiB,PDF 最大 32 MiB,成功 `Content-Type` 固定为 `application/pdf` 。
2026-08-06 11:49:51 +08:00
2026-08-04 18:54:22 +08:00
## 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 );
2026-08-06 11:49:51 +08:00
@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 );
2026-08-04 18:54:22 +08:00
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 ) {}
2026-08-06 11:49:51 +08:00
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 ) {}
2026-08-04 18:54:22 +08:00
}
```
调用封装必须区分 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,也不要为了“一次性下载”写入数据库。
2026-08-06 11:49:51 +08:00
- 日志记录模板、页数、耗时、PDF 字节数、`requestId` 、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]` 、完整 `documents[]` 、二维码内容或 PDF 二进制。
2026-08-04 18:54:22 +08:00
## 6. 联调检查清单
1. `GET /actuator/health` 返回 `UP` 。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf` 。
2026-08-06 11:49:51 +08:00
3. 检查入仓标签页面为严格 `100mm x 100mm` , A4 单据页面为严格 `210mm x 297mm` , `X-Print-Page-Count` 与数组长度一致。
2026-08-04 18:54:22 +08:00
4. 提交多页并检查页序与数组顺序一致。
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token` 。
2026-08-06 11:49:51 +08:00
## 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` 作为失败处理。