diff --git a/.gitignore b/.gitignore index d937c0abb..2df9c1aa1 100644 --- a/.gitignore +++ b/.gitignore @@ -24,3 +24,4 @@ reports/runtime*/ reports/uploads/ reports/db-backup*/ uploads/ +.codex-temp* diff --git a/AGENTS.md b/AGENTS.md index 8f0e93a83..19c07a38b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,7 +24,7 @@ | `firstleg/` | 履约任务与分配;二期入库单、测量/OCR 结果、审核、FBA 计费和自动完成入库 | 履约:`PlatformFulfillmentAssignmentController.java`、`FulfillmentTaskService.java`;入库:`controller/PlatformInbound*.java`、`service/Inbound*.java`、`service/Fba*.java` | | `warehouse/` | 仓库、地址、服务能力、往来单位、快递公司;二期托盘、库位、预占和占用 | 主数据:`controller/`、`PlatformPartnerQueryService.java`;二期:`PlatformPalletController.java`、`PlatformLocationController.java`、`PalletLocationAllocationService.java` | | `file/` | 企业资质文件、公共导出/下载中心;入库测量图片与重量 OCR 协作 | 资质:`QualificationFileController.java`;导出:`PlatformExportTaskController.java`、`ExportStateService.java`;测量图片:`PlatformMeasurementImageController.java`、`InternalMeasurementImageController.java`、`MeasurementImageService.java` | -| `print/` | 打印中心边界 | 当前为应用、健康检查和 `print/src/main/resources/db/print.sql` 骨架 | +| `print/` | 无数据库的无状态 PDF 渲染;固定入仓标签模板、二维码、字体嵌入和资源保护 | `InternalPdfController.java`、`InboundMarkPdfService.java`、`InboundMarkV1Renderer.java`、`src/main/resources/openapi/print-internal-v1.yaml` | | `audit-service/` | 审计事件接收与持久化 | `AuditIngestController.java`、`AuditIngestService.java`;当前接收接口未实现内部令牌保护 | | `integration-service/` | 重量 OCR、百度 OCR、Amazon 等第三方适配 | `controller/WeightOcrController.java`、`service/ocr/`、`provider/ocr/`、`IntegrationProperties.java` | | `event-bridge-service/` | 跨区域事件接收、校验与 Inbox 幂等 | `EventBridgeController.java`、`EventInboxService.java` | @@ -39,6 +39,7 @@ | 平台代客户上传或替换资质 | `file/.../controller/PlatformQualificationFileController.java` → `client/IamQualificationClient.java` → `iam/.../controller/InternalTenantController.java` 的 `/platform-qualifications` | | 往来单位多类型 | `warehouse/.../service/PlatformPartnerQueryService.java`、`entity/PartnerTypeEntity.java`、`src/main/resources/db/warehouse-partner.sql`,消费者看 `order/.../client/PartnerClient.java` | | 往来单位/快递公司批量导入 | `warehouse/.../service/PartnerImportService.java`、`ExpressCompanyImportService.java`、两个平台 Controller 的 `/import-template` 与 `/import` | +| 入仓标签 PDF 生成 | 调用方先读 `print/README.md`;实现为 `print/.../controller/InternalPdfController.java` → `service/InboundMarkPdfService.java` → `renderer/InboundMarkV1Renderer.java`;机器契约见 `print/src/main/resources/openapi/print-internal-v1.yaml`,仅供携带内部令牌的服务调用,不加入 Gateway | | HTTP 200、body code、成功码 `"200"` | `docs/统一响应约定改造方案-HTTP200.md`、`common-core/ResultCode.java`、`common-web/GlobalExceptionHandler.java` | | 单服务 Jenkins 构建或部署 | 总控 `Jenkinsfile`、子 Job `Jenkinsfile.service`、模块/JAR 映射 `deploy/service-map.yml` | diff --git a/README.md b/README.md index 35b0b0a48..3f883e5c6 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ - `order`:订单主数据及订单状态编排。 - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 -- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力。 +- `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 - `event-bridge-service`:跨区域事件接收幂等和投递记录基础骨架;真实跨区发送、重试调度和死信补投仍需完成。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:发布 `com.baiyue.shared:baiyue-platform-bom:1.0.0-SNAPSHOT`,尾程仓只通过固定版本引用。 diff --git a/config/nacos/cn/print-service.yml b/config/nacos/cn/print-service.yml index 613433e3f..c3fc80ed8 100644 --- a/config/nacos/cn/print-service.yml +++ b/config/nacos/cn/print-service.yml @@ -1,6 +1,11 @@ -spring: - datasource: - url: ${PRINT_DB_URL} - username: ${PRINT_DB_USERNAME} - password: ${PRINT_DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver +print: + pdf: + max-pages: ${PRINT_PDF_MAX_PAGES:2000} + max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152} + max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432} + max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4} + acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s} + template-code: INBOUND_MARK_V1 + supported-width-mm: 100 + supported-height-mm: 100 + font-resource: fonts/NotoSansSC-Bold.ttf diff --git a/config/nacos/local/print-service.yml b/config/nacos/local/print-service.yml index 0a3a09dc5..c3fc80ed8 100644 --- a/config/nacos/local/print-service.yml +++ b/config/nacos/local/print-service.yml @@ -1,5 +1,11 @@ -spring.datasource: - driver-class-name: com.mysql.cj.jdbc.Driver - url: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true} - username: ${PRINT_DB_USERNAME:root} - password: ${PRINT_DB_PASSWORD:123456} +print: + pdf: + max-pages: ${PRINT_PDF_MAX_PAGES:2000} + max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152} + max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432} + max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4} + acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s} + template-code: INBOUND_MARK_V1 + supported-width-mm: 100 + supported-height-mm: 100 + font-resource: fonts/NotoSansSC-Bold.ttf diff --git a/deploy/docker-compose.yml b/deploy/docker-compose.yml index 46dd39d9a..5f1dc09a9 100644 --- a/deploy/docker-compose.yml +++ b/deploy/docker-compose.yml @@ -142,9 +142,7 @@ services: environment: <<: *common-environment SERVER_PORT: 9207 - PRINT_DB_URL: ${PRINT_DB_URL} - PRINT_DB_USERNAME: ${PRINT_DB_USERNAME} - PRINT_DB_PASSWORD: ${PRINT_DB_PASSWORD} + JAVA_TOOL_OPTIONS: "-Xms512m -Xmx1g -XX:+ExitOnOutOfMemoryError -Dfile.encoding=UTF-8" integration-service: <<: *common diff --git a/docs/2026-08-04-无状态PDF打印服务设计.md b/docs/2026-08-04-无状态PDF打印服务设计.md new file mode 100644 index 000000000..691081deb --- /dev/null +++ b/docs/2026-08-04-无状态PDF打印服务设计.md @@ -0,0 +1,417 @@ +# 无状态 PDF 打印服务设计 + +> 日期:2026-08-04 +> 状态:待评审 +> 适用模块:`print-service` +> 首版模板:`INBOUND_MARK_V1` +> 参考文件:`PCHK2608040174-19件-MCC1.pdf` 及同名标签截图 + +## 1. 结论 + +`print-service` 调整为无状态 PDF 渲染服务:调用方传入完整的 `labels[]`,服务校验后在内存中生成一个多页 PDF,并在同一次 HTTP 响应中返回 PDF 二进制。 + +首版边界如下: + +- 不连接数据库,不保存模板、任务、历史或文件。 +- 不调用 Firstleg、Order、File、IAM 等业务服务,不反查或修正业务数据。 +- 不连接打印机,不处理驱动、打印队列、重试、补打或设备状态。 +- 不上传 File 服务,不写本地临时文件,不提供二次下载地址。 +- 一次请求生成一个 PDF;`labels[]` 中一个元素对应一页,数组顺序就是 PDF 页序。 +- 每页默认且首版只支持 `100mm x 100mm`,所有内容水平居中。 +- 最多支持 2000 页;超限时整次请求失败,不生成部分结果。 +- 成功直接返回 `application/pdf`;失败返回统一 JSON 错误响应。 + +现有 `print_template`、`print_job`、`print_job_history` 数据库草案不再属于本方案。实施时应移除 Print 模块的数据源、MyBatis、MySQL 依赖及环境配置,数据库脚本不进入部署流程。 + +## 2. 需求依据 + +### 2.1 已确认规则 + +1. 调用方传入完整 `labels[]`,Print 不自行按箱数扩展页面。 +2. `CTNS:218-1` 表示总计 218 箱中的第 1 箱。 +3. 二维码内容由调用方生成,例如 `PCHK2607211476#218-1`。 +4. 入仓码只在页面顶部展示一次;`MADE IN CHINA` 是固定文案。 +5. 仓库、渠道等字段由调用方传最终展示文案,Print 不维护代码映射。 +6. 首版不处理普通展示文字的长度适配;调用方保证文案合理,Print 使用固定字号直接居中绘制。 +7. 页面尺寸作为请求参数显式传入,首版只接受 `100mm x 100mm`。 +8. PDF 仅临时使用;需要再次下载时由调用方重新生成。 +9. 单次最多约 2000 箱,即最多 2000 页。 +10. 调用方对页面数据的真实性、页序、唯一性和业务权限负责。 +11. 中文字体随服务打包并嵌入 PDF,不依赖容器或宿主机字体。 +12. 首版只实现固定模板,不建设模板管理系统。 + +### 2.2 参考 PDF 核验结果 + +参考 PDF 共 19 页: + +- 每页 MediaBox 为 `282.96pt x 282.96pt`,约 `99.822mm x 99.822mm`。 +- 页码内容从 `CTNS:19-1` 连续变化到 `CTNS:19-19`。 +- 每页依次展示顶部入仓码、仓库文案、箱号文案、渠道文案、二维码、二维码明文和固定产地文案。 +- 参考文件使用 Microsoft YaHei 子集字体。 + +本服务以明确的 `100mm x 100mm` 契约为准,输出 MediaBox 使用 `mm * 72 / 25.4` 换算,即约 `283.465pt x 283.465pt`,不复制参考文件约 `0.178mm` 的尺寸误差。 + +## 3. 架构与调用边界 + +### 3.1 推荐调用方式 + +Admin 浏览器不直接调用 Print。公开业务接口由 Firstleg 或其他业务归属服务提供;业务服务完成登录态、按钮权限、租户、企业、仓库和业务数据校验,组装最终 `labels[]` 后,通过服务发现调用 Print 内部接口,再把 PDF 响应透传给浏览器。 + +```mermaid +sequenceDiagram + participant A as "Admin 浏览器" + participant B as "业务归属服务" + participant P as "Print Service" + + A->>B: 请求生成业务标签 PDF + B->>B: 校验 JWT、权限和数据范围 + B->>B: 组装完整 labels[] + B->>P: 内部令牌 + PDF 请求 + P->>P: 全量校验并在内存渲染 + P-->>B: application/pdf + B-->>A: application/pdf +``` + +采用内部调用的原因: + +- Print 不持有业务数据,无法判断浏览器提交的入仓码、仓库或箱数是否真实。 +- 若直接暴露给 Admin,Print 必须复制业务权限和数据范围规则,破坏服务边界。 +- 内部接口能避免任意已登录用户把 Print 当作通用 PDF/二维码生成器滥用。 +- 各业务服务可以复用同一个 Print 渲染能力,同时继续拥有自己的公开接口和权限码。 + +### 3.2 网络与鉴权 + +- 内部接口路径不加入 Gateway 公网路由。 +- 调用方通过 Nacos 服务发现访问 `print-service`。 +- 请求必须携带共享的 `X-Internal-Service-Token`。 +- Print 使用与现有内部接口一致的 `INTERNAL_SERVICE_TOKEN` 配置,令牌至少 32 字节。 +- 令牌缺失或不匹配返回 `A0403`,不得在日志中打印令牌。 +- Print 不接受浏览器 JWT,也不新增 Print 业务权限码;权限由调用方业务服务校验。 + +## 4. 接口契约 + +### 4.1 生成接口 + +```http +POST /internal/print/v1/pdfs/inbound-marks +Content-Type: application/json +Accept: application/pdf, application/json +X-Internal-Service-Token: *** +X-Request-Id: 可选 +``` + +`Accept` 必须同时列出两种类型:成功响应是 PDF,失败响应是 JSON `ApiResponse`。若只声明 `application/pdf`,Spring MVC 在异常解析阶段重新做内容协商时会因为无法接受 `application/json` 而返回 406 空响应体,调用方拿不到本设计约定的错误码。原因与实现约束见 4.4。 + +请求示例: + +```json +{ + "templateCode": "INBOUND_MARK_V1", + "pageSpec": { + "widthMm": 100, + "heightMm": 100 + }, + "downloadFileName": "PCHK2608040174-19件-MCC1.pdf", + "labels": [ + { + "topCode": "PCHK2608040174", + "warehouseText": "MCC1", + "cartonText": "CTNS:19-1", + "routeText": "洛杉矶普船海卡", + "qrContent": "PCHK2608040174#19-1" + }, + { + "topCode": "PCHK2608040174", + "warehouseText": "MCC1", + "cartonText": "CTNS:19-2", + "routeText": "洛杉矶普船海卡", + "qrContent": "PCHK2608040174#19-2" + } + ] +} +``` + +`qrContent` 同时用于生成二维码和二维码下方的明文,避免两处内容不一致。`MADE IN CHINA` 由模板固定输出,不作为请求字段。 + +### 4.2 请求字段 + +| 字段 | 必填 | 首版规则 | +|---|---|---| +| `templateCode` | 是 | 只能为 `INBOUND_MARK_V1` | +| `pageSpec.widthMm` | 是 | 只能为数值 `100` | +| `pageSpec.heightMm` | 是 | 只能为数值 `100` | +| `downloadFileName` | 否 | 1-120 个 Unicode 码点;只允许文件基本名,必须以 `.pdf` 结尾 | +| `labels` | 是 | 1-2000 项;数组顺序即页序 | +| `labels[].topCode` | 是 | 非空;单行;禁止控制字符 | +| `labels[].warehouseText` | 是 | 非空;单行;禁止控制字符 | +| `labels[].cartonText` | 是 | 非空;单行;禁止控制字符 | +| `labels[].routeText` | 是 | 非空;单行;禁止控制字符 | +| `labels[].qrContent` | 是 | 1-34 个可打印 ASCII 字符;单行 | + +普通展示字段不设置业务字符数上限,也不因实际文字宽度拒绝请求。调用方负责保证文案适合固定版式;若文字超过页面安全区,Print 仍按固定字号和中心点绘制,不换行、不缩小、不截断,也不把版面越界判定为接口错误。`qrContent` 的限制继续保留,因为它同时影响固定尺寸二维码的扫描密度和二维码下方明文的可读性。 + +补充规则: + +- 所有字符串先做 Unicode NFC 规范化,再按各字段规则进行非空、长度和字符校验;普通展示字段不增加长度校验。 +- 不允许 `\r`、`\n`、制表符、双向控制符、NUL 或其他不可见控制字符。该规则适用于全部请求字符串,`downloadFileName` 同样适用。 +- `pageSpec.widthMm` 和 `heightMm` 按数值比较,`100` 与 `100.0` 都接受,非整数或其他数值一律 `A0422`。 +- Print 不解析 `cartonText`,不校验箱号是否连续,也不校验 `qrContent` 与其他字段的业务关系。 +- 调用方必须保证页面没有重复、缺失或乱序;Print 只保证按数组顺序渲染。 +- 文件名剥离路径语义,禁止 `/`、`\`、`:` 及其他文件系统保留字符,避免调用方把带路径的名字直接落到磁盘。 +- 未传文件名时使用 `inbound-marks-{requestId}.pdf`。`requestId` 取自 `CorrelationContext`,实施前需确认请求未带 `X-Request-Id` 时该组件会生成一个,否则文件名会出现 `null`。 + +### 4.3 成功响应 + +```http +HTTP/1.1 200 OK +Content-Type: application/pdf +Content-Disposition: attachment; filename="inbound-marks.pdf"; filename*=UTF-8''PCHK2608040174-19%E4%BB%B6-MCC1.pdf +Content-Length: ... +Cache-Control: no-store, max-age=0 +Pragma: no-cache +X-Content-Type-Options: nosniff +X-Print-Page-Count: 19 +X-Request-Id: ... +``` + +响应头实现约定: + +- 复用项目现有下载写法 `ContentDisposition.attachment().filename(name, StandardCharsets.UTF_8)`(见 `file/src/main/java/com/baiyue/file/controller/PlatformExportTaskController.java:66`),由它统一完成 RFC 5987 编码并同时输出 ASCII `filename=` 回退与 `filename*=`,不手工拼接响应头。 +- 控制器方法**不得**声明 `produces = "application/pdf"`,否则会把该方法的可产出类型钉死为 PDF,异常处理阶段无法写出 JSON 错误体。成功分支显式调用 `.contentType(MediaType.APPLICATION_PDF)` 设置类型。 +- `Cache-Control` 使用 `CacheControl.noStore()`,与现有下载接口一致。 + +PDF 完整生成并通过大小检查后才写入 HTTP 响应,禁止边生成边返回半个 PDF。 + +### 4.4 失败响应 + +成功响应是 PDF,失败响应是项目统一的 JSON `ApiResponse`。调用方必须同时检查 HTTP 状态、`Content-Type` 和 JSON `code`,不能只判断 HTTP 200。 + +| 场景 | 响应约定 | +|---|---| +| JSON、字段或字符校验失败 | HTTP 200 + `application/json` + `A0422` | +| 模板或页面尺寸不支持 | HTTP 200 + `application/json` + `A0422` | +| 内部令牌缺失或错误 | HTTP 200 + `application/json` + `A0403` | +| 请求体超过容器限制 | HTTP 413 + `application/json` | +| 并发生成槽位已满 | HTTP 200 + `application/json` + `A0429` | +| PDF 生成失败或超过输出上限 | HTTP 200 + `application/json` + `S0500` | + +错误信息只指出字段位置和规则,例如 `labels[18].routeText contains a line break`,不回显完整标签数组或内部异常堆栈。 + +## 5. PDF 渲染契约 + +### 5.1 页面与坐标 + +- 页面物理尺寸:严格 `100mm x 100mm`。 +- PDF 坐标换算:`points = millimeters * 72 / 25.4`。 +- 页面旋转:`0`。 +- 背景:纯白。 +- 前景:纯黑,不使用灰色、透明度、渐变或彩色元素。 +- 内容安全区:四周至少 `5mm`;所有文本和二维码水平居中。 +- 每个字段只占一行,不自动换行,不截断,不输出省略号。 + +首版布局基线。**下表所有 `y` 均为「自页面顶部向下量取」的中心线位置**,与阅读顺序一致,不是 PDF 原生坐标: + +| 区域 | 顶部向下中心或范围 | 初始字号/尺寸 | 规则 | +|---|---:|---:|---| +| 顶部入仓码 | 中心 `y = 11mm` | `24pt` 粗体 | 单行居中 | +| 仓库文案 | 中心 `y = 26mm` | `36pt` 粗体 | 单行居中 | +| 箱号文案 | 中心 `y = 37mm` | `22pt` 粗体 | 单行居中 | +| 渠道文案 | 中心 `y = 47mm` | `21pt` 粗体 | 单行居中 | +| 二维码画布 | `48.72mm-84mm` | `35.28mm x 35.28mm`(100pt) | 水平居中,含 4 modules quiet zone | +| 二维码明文 | 中心 `y = 85.5mm` | `12pt` 粗体 | 内容取 `qrContent` | +| 固定产地 | 中心 `y = 92mm` | `14pt` 粗体 | 固定 `MADE IN CHINA` | + +PDF 原生坐标原点在**左下角**,y 轴向上,与上表方向相反。二者混用会让整张标签上下颠倒,因此坐标换算必须收敛到 `PdfUnits` 一处: + +- `PdfUnits` 只对外暴露 `topDownMmToPdfY(double mmFromTop)` 与 `mmToPt(double mm)` 两个入口,前者内部完成 `pageHeightPt - mmToPt(mmFromTop)` 翻转。 +- 渲染代码一律以「自顶向下的毫米值」思考和传参,禁止在绘制方法中出现手写 point 常量,也禁止绕过 `PdfUnits` 直接使用 PDF 原生 y。 +- 文本按中心线垂直居中时,基线由字体上升距离和字号在 `PdfUnits` 内换算,绘制方法不自行加减半个字号。 + +上述位置是依据参考 PDF 得到的首版基线,最终以渲染后的金样验收为准。 + +### 5.2 文本绘制 + +- 所有普通展示文字使用 5.1 约定的固定字号和中心点直接绘制。 +- 不测量文字是否超出安全区,不动态缩小字号,不换行、不截断、不输出省略号,也不横向压缩字体。 +- Print 只保证请求中的文字被交给 PDFBox 绘制,不保证任意长度文字都能落在页面范围内;版式内容质量由调用方负责。 +- 固定字号或位置需要调整时,通过新增模板版本完成,不得静默改变 `INBOUND_MARK_V1`。 + +### 5.3 字体 + +- 随服务打包 `Noto Sans SC Bold` 静态 TTF 字体及对应 OFL 许可证。 +- 每个 PDF 文档只加载一次字体对象,启用字体子集并嵌入 PDF。 +- 不使用 Windows 的 Microsoft YaHei,不从系统字体目录动态加载字体。 +- 启动时校验字体资源存在且可解析;缺失时 readiness 失败,不能静默回退到不支持中文的字体。 + +### 5.4 二维码 + +- 使用 ZXing 根据 `qrContent` 生成 QR Code。 +- 字符集固定为 UTF-8;首版字段限制为可打印 ASCII。 +- 纠错等级使用 `M`,保留 4 modules quiet zone。 +- 二维码在 PDF 中按模块直接绘制黑色矢量矩形,不先生成低分辨率 PNG,不进行插值缩放。 +- 二维码下方明文与编码内容来自同一个 `qrContent` 值。 +- 每页二维码必须可由 ZXing 反向解码,并与请求值完全一致。 + +## 6. Java 实现方案 + +### 6.1 依赖选型 + +- PDF:Apache PDFBox。 +- QR Code:ZXing Core。 +- 输入校验:Jakarta Validation 加服务层全量校验。 +- HTTP:现有 Spring Boot MVC、Common Web 和内部令牌模式。 + +不采用 HTML/CSS 转 PDF 或无头浏览器:标签要求精确物理尺寸,HTML 打印受 DPI、浏览器分页和字体环境影响更大。不采用 iText:当前需求不需要其高级能力,同时需要避免额外商业许可边界。 + +### 6.2 建议包结构 + +```text +com.baiyue.print +├─ controller +│ └─ InternalPdfController.java +├─ dto +│ └─ InboundMarkPdfDtos.java +├─ service +│ ├─ InboundMarkPdfService.java +│ ├─ InboundMarkRequestValidator.java +│ └─ PdfGenerationLimiter.java +├─ renderer +│ ├─ InboundMarkV1Renderer.java +│ ├─ PdfQrRenderer.java +│ └─ PdfUnits.java +└─ config + └─ PrintPdfProperties.java +``` + +建议资源: + +```text +print/src/main/resources/fonts/NotoSansSC-Bold.ttf +print/src/main/resources/fonts/OFL.txt +print/src/main/resources/openapi/print-internal-v1.yaml +``` + +### 6.3 处理流程 + +1. 校验内部令牌。 +2. 校验请求体大小、模板、页面尺寸、文件名和页数。 +3. 对全部页面字符串进行规范化、非空和控制字符校验。 +4. 获取并发生成槽位。 +5. 在并发保护范围内生成并校验全部 QR BitMatrix;任一失败则终止。 +6. 创建一个 PDF 文档,加载一次嵌入字体。 +7. 按 `labels[]` 顺序创建页面并渲染。 +8. 保存到受上限保护的内存输出流。 +9. 校验 PDF 字节数、页数和基本结构。 +10. 构造下载响应,释放文档、字体和内存对象。 + +全过程不创建数据库事务、任务 ID、磁盘文件或异步队列。 + +## 7. 容量与资源保护 + +首版默认配置建议: + +```yaml +print: + pdf: + max-pages: 2000 + max-request-bytes: 2097152 + max-output-bytes: 33554432 + max-concurrent-generations: 4 + acquire-timeout: 1s + template-code: INBOUND_MARK_V1 + supported-width-mm: 100 + supported-height-mm: 100 +``` + +- `max-pages` 是硬上限,不能通过请求覆盖。 +- `max-request-bytes` 同时在应用和入口代理配置,防止超大 JSON 在反序列化前占用内存。 +- 内存输出流在超过 `max-output-bytes` 时立即抛出受控异常。 +- 使用进程内 Semaphore 限制并发生成数;槽位满时快速失败,不在 JVM 内形成无界等待队列。 +- 字体只在单个 PDF 文档中加载一次,不得每页创建一份字体子集。 +- 服务保持无状态,可以水平扩容;单实例并发数应根据容器内存压测后调整。 + +发布前容量验收至少覆盖 1、19、218 和 2000 页。2000 页请求的目标是 30 秒内完成且 PDF 小于 32MiB;实际 P95、峰值堆内存和容器 CPU 以测试环境压测结果回填,不在未经测量时承诺更激进指标。 + +## 8. 日志、监控与隐私 + +允许记录: + +- `requestId`、调用服务名、模板编码、页面尺寸、页数。 +- 生成耗时、PDF 字节数、成功/失败结果和有限错误码。 +- 当前并发数、等待槽位时间和内存上限拒绝次数。 + +禁止记录: + +- 完整 `labels[]`、二维码内容、内部令牌和 PDF 字节。 +- 将生成的 PDF 写入日志、异常附件或故障目录。 + +建议指标: + +- `print_pdf_requests_total{result,template}` +- `print_pdf_generation_seconds{template}` +- `print_pdf_pages_total{template}` +- `print_pdf_output_bytes{template}` +- `print_pdf_rejections_total{reason}` +- `print_pdf_active_generations` + +## 9. 测试与验收 + +### 9.1 自动化测试 + +- DTO 校验:空数组、1001 页、空字段、控制字符、超长二维码内容、非法文件名和错误页面尺寸。 +- 接口契约:内部令牌、成功 PDF、JSON 错误、响应头和 `Cache-Control: no-store`。 +- PDF 结构:页数等于 `labels.size()`,每页精确 `100mm x 100mm`,旋转为 0。 +- 文本内容:每页六个文本区完整,顶部入仓码不在底部重复。 +- 页序:输入第 N 项必须成为 PDF 第 N 页。 +- 二维码:渲染页面后用 ZXing 解码,结果必须等于该页 `qrContent`。 +- 字体:PDF 中存在嵌入字体,中文渲染无方框、乱码或系统字体依赖。 +- 金样渲染:对固定业务样本渲染 PNG,检查内容边界、居中和布局容差;不比较易受元数据影响的完整 PDF 二进制。 +- 容量:1、19、218、2000 页生成成功,输出不超过配置上限。 +- 并发:超过生成槽位时快速返回受控错误,完成后槽位必定释放。 +- 失败原子性:任一页无效时不返回部分 PDF,也不产生磁盘文件。 + +### 9.2 人工验收 + +1. 用参考文件的 19 页数据生成 PDF,逐页比对页面内容和页序。 +2. 渲染第一页、中间页和最后一页,确认全部元素水平居中且无重叠、裁切或乱码。 +3. 在实际打印环境按 100% 比例打印,确认成品尺寸为 `100mm x 100mm`,关闭“适合页面/缩放”选项。 +4. 使用现场扫码设备扫描首、中、末页二维码。 +5. 使用真实业务中的典型较长文案确认固定字号下的展示结果可接受。 +6. 生成 2000 页并记录耗时、PDF 大小、峰值堆内存和 CPU。 + +## 10. 实施范围 + +### 10.1 本次实现应包含 + +- 移除 Print 数据库、MyBatis 和 MySQL 依赖及配置。 +- 新增 PDFBox、ZXing 和合法授权的中文字体资源。 +- 实现内部 PDF 接口、请求校验、固定模板渲染和资源保护。 +- 增加内部 OpenAPI、单元测试、契约测试、金样渲染测试和 2000 页容量测试。 +- 保留 Actuator 健康检查;现有逻辑状态接口可继续用于平台运维,但不承担业务能力。 +- 更新 Print 模块指引、部署配置和 Gateway 说明,明确内部业务接口不公开。 + +### 10.2 本次不包含 + +- Admin 页面、业务服务公开下载接口及业务权限码。 +- 打印模板 CRUD、模板版本或数据库表。 +- 文件中心、对象存储、下载中心或下载次数管理。 +- 打印机发现、USB/网络打印、本地代理、驱动和设备状态。 +- 打印任务、失败重试、补打、换标、作废和审计历史。 +- 任意页面尺寸、任意字段布局或通用报表生成。 + +## 11. 后续兼容策略 + +- `templateCode` 和 `pageSpec` 从首版进入契约,但首版只接受一个固定组合。 +- 新尺寸或新版布局必须新增模板编码,例如 `INBOUND_MARK_V2`,不得静默改变 V1 的位置、字号或固定文案。 +- 若未来需要直接从 Admin 调用,必须先定义独立的授权与数据真实性方案,不能直接开放本内部接口。 +- 若未来需要暂存或重复下载,应由 File/下载中心承担文件生命周期,不能在当前纯渲染接口中逐步加入数据库状态。 +- 若未来需要控制佳博等热敏打印机,应新增本地打印代理或设备服务,本服务仍只负责生成可打印内容。 + +## 12. 待评审项 + +以下事项不阻塞设计评审,但应在编码或上线前确认: + +1. `Noto Sans SC Bold` 的最终字体文件版本、仓库体积和许可证归档位置。 +2. 业务归属服务的公开下载接口路径与按钮权限码。 +3. 测试环境单实例 CPU、内存限制及 `max-concurrent-generations` 最终值。 +4. 现场打印驱动是否把 `100mm x 100mm` PDF 保持为 100% 比例,是否存在硬件不可打印边缘。 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index aafdd8de5..426631b3e 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -92,7 +92,7 @@ baiyue-firstleg/ | `order` | `order-service` | 9204 | `baiyue_order` | 订单草稿、编辑、提交、详情、状态历史、货物、往来单位快照、内部备注和 Outbox | | `warehouse` | `warehouse-service` | 9205 | `baiyue_warehouse` | 仓库、地址、服务能力、往来单位、快递公司,以及二期托盘、库位、预占和占用;往来单位不单独拆服务 | | `file` | `file-service` | 9206 | `baiyue_file` | 企业资质文件、公共异步导出下载、入库测量图片及重量 OCR 调用协作 | -| `print` | `print-service` | 9207 | `baiyue_print` | 打印中心边界已经建立;当前主要是服务骨架、健康检查和数据库结构,打印模板与任务仍需继续完善 | +| `print` | `print-service` | 9207 | 无 | 无状态 PDF 渲染服务;调用方传入完整标签数据,服务在内存中生成一次性多页 PDF | | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `event-bridge-service` | `event-bridge-service` | 9300 | `baiyue_event_bridge` | 国内外事件接收、签名/内部令牌保护、幂等 Inbox 和接收结果 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;当前配置默认关闭业务审计发送,可按环境开启 | @@ -139,7 +139,9 @@ File 负责企业资质文件、公共异步导出与下载、入库称重图片 ### 5.8 `print/`:打印中心 -Print 已建立独立服务、端口和数据库边界,当前主要是应用骨架、健康检查和目标表结构。打印模板版本、打印任务、PDF/标签生成、失败重试和与 File 的文件引用仍需按阶段需求继续实现;Order 只提交打印请求,不直接生成面单或操作打印机。 +Print 是无数据库的无状态 PDF 渲染服务。业务归属服务完成权限和数据真实性校验,组装完整 `labels[]` 后通过内部令牌调用 Print;Print 只在内存中生成一次性多页 PDF 并同步返回。本服务不保存模板、任务、历史或文件,不调用 File,不连接打印机,也不提供二次下载地址。 + +业务服务接入时先读 `print/README.md`,机器可读契约以 `print/src/main/resources/openapi/print-internal-v1.yaml` 为准。 ### 5.9 `audit-service/`:审计日志中心 diff --git a/docs/数据库环境切换说明.md b/docs/数据库环境切换说明.md index a147065b8..4a985c6f7 100644 --- a/docs/数据库环境切换说明.md +++ b/docs/数据库环境切换说明.md @@ -53,11 +53,10 @@ TEST_DB_PASSWORD=测试数据库密码 | 仓库服务 | `baiyue_warehouse` | | 头程履约服务 | `baiyue_firstleg` | | 文件服务 | `baiyue_file` | -| 打印服务 | `baiyue_print` | | 审计服务 | `baiyue_audit` | | 事件桥接服务 | `baiyue_event_bridge` | -网关和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。 +网关、打印和集成服务没有业务数据库,但同样支持 `dev/test` Profile。网关在两种环境下都会自动加载 `cn` 路由配置。 ## 注意事项 diff --git a/print/AGENTS.md b/print/AGENTS.md index 2129aaecb..d0276dc6c 100644 --- a/print/AGENTS.md +++ b/print/AGENTS.md @@ -2,20 +2,32 @@ ## 职责边界 -- 本模块定义打印中心独立服务边界;当前只包含应用入口、状态接口、配置和数据库骨架。 -- 打印模板版本、任务状态机、PDF/标签生成、重试和幂等尚未完整实现;开始扩展前必须先核对阶段需求,不从原型自动扩大范围。 +- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]`,模块在内存中生成一次性多页 PDF 并同步返回。 +- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。 +- 首版只支持 `INBOUND_MARK_V1` 和 `100mm x 100mm`;新尺寸或布局必须使用新模板编码,不得静默改变 V1。 ## 快速索引 | 功能 | 入口 | |---|---| +| 调用方接入指南 | `README.md` | | 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` | -| 当前状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) | -| 数据库骨架 | `src/main/resources/db/print.sql` | +| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) | +| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`) | +| 内部令牌校验 | `controller/InternalServiceTokenGuard.java` | +| 请求与响应模型 | `dto/InboundMarkPdfDtos.java` | +| 固定模板与二维码渲染 | `renderer/InboundMarkV1Renderer.java`、`renderer/PdfQrRenderer.java`、`renderer/PdfUnits.java` | +| 请求校验与资源保护 | `controller/PrintRequestSizeFilter.java`、`service/InboundMarkPdfService.java`、`service/PdfGenerationLimiter.java` | +| PDF 参数配置 | `config/PrintPdfProperties.java` | +| 接口契约 | `src/main/resources/openapi/print-internal-v1.yaml` | +| 字体及授权 | `src/main/resources/fonts/NotoSansSC-Bold.ttf`、`src/main/resources/fonts/OFL.txt` | | 本地/Nacos 配置 | `src/main/resources/application.yml`、`../config/nacos/{local,cn}/print-service.yml` | +| 端到端与架构约束测试 | `src/test/java/com/baiyue/print/acceptance/InboundMarkPdfAcceptanceTest.java`、`src/test/java/com/baiyue/print/acceptance/PrintStatelessArchitectureTest.java` | ## 修改与验证 -- 订单服务只应提交打印请求,不自行生成面单或操作打印机;生成文件通过 File 服务引用。 -- 新增任务处理时设计明确的状态机、幂等键、失败重试、模板版本与权限边界。 -- 当前无专属测试时,不为小改动引入整套新测试框架;至少从父目录运行 `mvn -pl print -am test` 并记录接口验证。 +- 内部 PDF 接口不加入 Gateway 公网路由;调用方必须先完成业务权限和数据范围校验,再携带共享内部令牌调用。 +- 成功响应为 `application/pdf`,业务校验、鉴权和生成失败响应为 `application/json`;Controller 不得固定声明只产生 PDF,具体契约以 OpenAPI 为准。 +- 不得重新引入已删除的 `src/main/resources/db/print.sql`,也不得引入数据源、MyBatis、MySQL、本地临时文件或异步任务队列;失败时整次请求失败,不返回部分 PDF。 +- 中文字体必须从 classpath 加载并嵌入 PDF,不得依赖宿主机或容器系统字体;更新字体时同步归档授权文件。 +- 修改后至少从父目录运行 `mvn -pl print -am test`;渲染变更同时核对页面尺寸、页数、页序、二维码反向解码和字体嵌入。 diff --git a/print/README.md b/print/README.md new file mode 100644 index 000000000..0d2d5abf9 --- /dev/null +++ b/print/README.md @@ -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: +X-Request-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 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