打印服务

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
+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`。