Files
2026-08-06 11:49:51 +08:00

343 lines
16 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Print PDF 内部调用契约
本文是其他 Java 业务模块及代码生成 AI 接入 Print 的实现约束,不是终端用户操作手册。Print 是无数据库的内部渲染服务:调用方提交完整打印数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
机器可读 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[]` 或 `documents[]` 后调用 Print,再把 PDF 响应转发给浏览器。JWT 中的 `tenant_id` 当前是客户隔离兼容字段,Print 不解析或引入真实租户层。
调用方必须从本模块拥有的数据自动组装打印字段,不得把 Print 请求 DTO 暴露成要求用户逐项填写的表单。调用方不得要求 Print 查询订单、补箱号、排序、推导或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
## 2. 请求契约
### 2.1 入仓标签
```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。
### 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:
| 场景 | 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);
@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,
String downloadFileName,
List<Label> labels) {}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
record Label(
String topCode,
String warehouseText,
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) {}
}
```
调用封装必须区分 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[]`、完整 `documents[]`、二维码内容或 PDF 二进制。
## 6. 联调检查清单
1. `GET /actuator/health` 返回 `UP`。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
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` 作为失败处理。