打印服务
This commit is contained in:
+19
-7
@@ -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`;渲染变更同时核对页面尺寸、页数、页序、二维码反向解码和字体嵌入。
|
||||
|
||||
+188
@@ -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`。
|
||||
+10
-6
@@ -5,16 +5,20 @@
|
||||
<parent><groupId>com.baiyue.firstleg</groupId><artifactId>baiyue-firstleg-platform</artifactId><version>1.0.0-SNAPSHOT</version><relativePath>../pom.xml</relativePath></parent>
|
||||
<artifactId>print-service</artifactId>
|
||||
<dependencies>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-audit</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baomidou</groupId><artifactId>mybatis-plus-spring-boot3-starter</artifactId></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency><groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-security</artifactId><version>${project.version}</version></dependency>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-openapi</artifactId><version>${project.version}</version>
|
||||
<exclusions><exclusion><groupId>com.baomidou</groupId><artifactId>*</artifactId></exclusion></exclusions>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.baiyue.shared</groupId><artifactId>baiyue-common-web</artifactId><version>${project.version}</version>
|
||||
<exclusions><exclusion><groupId>com.baomidou</groupId><artifactId>*</artifactId></exclusion></exclusions>
|
||||
</dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
|
||||
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-sentinel</artifactId></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
|
||||
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
|
||||
<dependency><groupId>org.apache.pdfbox</groupId><artifactId>pdfbox</artifactId><version>3.0.7</version></dependency>
|
||||
<dependency><groupId>com.google.zxing</groupId><artifactId>core</artifactId><version>3.5.3</version></dependency>
|
||||
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
|
||||
</dependencies>
|
||||
<build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId></plugin></plugins></build>
|
||||
|
||||
@@ -6,7 +6,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
/**
|
||||
* 打印服务启动入口。
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = "com.baiyue")
|
||||
@SpringBootApplication
|
||||
public class PrintServiceApplication {
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(PrintServiceApplication.class, args);
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.baiyue.print.config;
|
||||
|
||||
import java.io.InputStream;
|
||||
import org.apache.fontbox.ttf.TTFParser;
|
||||
import org.apache.fontbox.ttf.TrueTypeFont;
|
||||
import org.springframework.boot.actuate.health.Health;
|
||||
import org.springframework.boot.actuate.health.HealthIndicator;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/** Exposes font packaging/parsing failures through readiness health. */
|
||||
@Component("printFont")
|
||||
public class PrintFontHealthIndicator implements HealthIndicator {
|
||||
private final Health validationResult;
|
||||
|
||||
public PrintFontHealthIndicator(PrintPdfProperties properties) {
|
||||
this.validationResult = validate(properties.getFontResource());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Health health() {
|
||||
return validationResult;
|
||||
}
|
||||
|
||||
private Health validate(String fontResource) {
|
||||
try (InputStream input = new ClassPathResource(fontResource).getInputStream();
|
||||
TrueTypeFont ignored = new TTFParser().parseEmbedded(input)) {
|
||||
return Health.up().withDetail("fontResource", fontResource).build();
|
||||
} catch (Exception exception) {
|
||||
return Health.down(exception).withDetail("fontResource", fontResource).build();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.print.config;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.Duration;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 无状态 PDF 生成的资源与固定模板配置。
|
||||
*/
|
||||
@Component
|
||||
@ConfigurationProperties(prefix = "print.pdf")
|
||||
public class PrintPdfProperties {
|
||||
private int maxPages = 2000;
|
||||
private long maxRequestBytes = 2L * 1024 * 1024;
|
||||
private long maxOutputBytes = 32L * 1024 * 1024;
|
||||
private int maxConcurrentGenerations = 4;
|
||||
private Duration acquireTimeout = Duration.ofSeconds(1);
|
||||
private String templateCode = "INBOUND_MARK_V1";
|
||||
private BigDecimal supportedWidthMm = new BigDecimal("100");
|
||||
private BigDecimal supportedHeightMm = new BigDecimal("100");
|
||||
private String fontResource = "fonts/NotoSansSC-Bold.ttf";
|
||||
|
||||
public int getMaxPages() {
|
||||
return maxPages;
|
||||
}
|
||||
|
||||
public void setMaxPages(int maxPages) {
|
||||
this.maxPages = maxPages;
|
||||
}
|
||||
|
||||
public long getMaxOutputBytes() {
|
||||
return maxOutputBytes;
|
||||
}
|
||||
|
||||
public long getMaxRequestBytes() {
|
||||
return maxRequestBytes;
|
||||
}
|
||||
|
||||
public void setMaxRequestBytes(long maxRequestBytes) {
|
||||
this.maxRequestBytes = maxRequestBytes;
|
||||
}
|
||||
|
||||
public void setMaxOutputBytes(long maxOutputBytes) {
|
||||
this.maxOutputBytes = maxOutputBytes;
|
||||
}
|
||||
|
||||
public int getMaxConcurrentGenerations() {
|
||||
return maxConcurrentGenerations;
|
||||
}
|
||||
|
||||
public void setMaxConcurrentGenerations(int maxConcurrentGenerations) {
|
||||
this.maxConcurrentGenerations = maxConcurrentGenerations;
|
||||
}
|
||||
|
||||
public Duration getAcquireTimeout() {
|
||||
return acquireTimeout;
|
||||
}
|
||||
|
||||
public void setAcquireTimeout(Duration acquireTimeout) {
|
||||
this.acquireTimeout = acquireTimeout;
|
||||
}
|
||||
|
||||
public String getTemplateCode() {
|
||||
return templateCode;
|
||||
}
|
||||
|
||||
public void setTemplateCode(String templateCode) {
|
||||
this.templateCode = templateCode;
|
||||
}
|
||||
|
||||
public BigDecimal getSupportedWidthMm() {
|
||||
return supportedWidthMm;
|
||||
}
|
||||
|
||||
public void setSupportedWidthMm(BigDecimal supportedWidthMm) {
|
||||
this.supportedWidthMm = supportedWidthMm;
|
||||
}
|
||||
|
||||
public BigDecimal getSupportedHeightMm() {
|
||||
return supportedHeightMm;
|
||||
}
|
||||
|
||||
public void setSupportedHeightMm(BigDecimal supportedHeightMm) {
|
||||
this.supportedHeightMm = supportedHeightMm;
|
||||
}
|
||||
|
||||
public String getFontResource() {
|
||||
return fontResource;
|
||||
}
|
||||
|
||||
public void setFontResource(String fontResource) {
|
||||
this.fontResource = fontResource;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.print.controller;
|
||||
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import org.springframework.http.CacheControl;
|
||||
import org.springframework.http.ContentDisposition;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/internal/print/v1/pdfs")
|
||||
public class InternalPdfController {
|
||||
static final String INTERNAL_TOKEN_HEADER = "X-Internal-Service-Token";
|
||||
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
|
||||
|
||||
private final InboundMarkPdfService pdfService;
|
||||
private final InternalServiceTokenGuard tokenGuard;
|
||||
|
||||
public InternalPdfController(InboundMarkPdfService pdfService, InternalServiceTokenGuard tokenGuard) {
|
||||
this.pdfService = pdfService;
|
||||
this.tokenGuard = tokenGuard;
|
||||
}
|
||||
|
||||
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
|
||||
public ResponseEntity<byte[]> generateInboundMarks(
|
||||
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
|
||||
@Valid @RequestBody InboundMarkPdfDtos.Request request,
|
||||
HttpServletRequest servletRequest) {
|
||||
tokenGuard.verify(internalToken);
|
||||
String requestId = CorrelationContext.requestId(servletRequest);
|
||||
InboundMarkPdfDtos.Request effectiveRequest = request;
|
||||
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
|
||||
effectiveRequest = new InboundMarkPdfDtos.Request(request.templateCode(), request.pageSpec(),
|
||||
"inbound-marks-" + requestId + ".pdf", request.labels());
|
||||
}
|
||||
InboundMarkPdfService.GeneratedPdf pdf = pdfService.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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.print.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Verifies the shared credential used by internal service callers. */
|
||||
@Component
|
||||
public class InternalServiceTokenGuard {
|
||||
private final byte[] expectedToken;
|
||||
|
||||
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) {
|
||||
if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) {
|
||||
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN must contain at least 32 bytes");
|
||||
}
|
||||
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public void verify(String actualToken) {
|
||||
byte[] actual = actualToken == null
|
||||
? new byte[0]
|
||||
: actualToken.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(expectedToken, actual)) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package com.baiyue.print.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.ServletInputStream;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.BufferedReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
/** Caps the JSON request before Spring/Jackson materializes the request DTO. */
|
||||
@Component
|
||||
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
|
||||
public class PrintRequestSizeFilter extends OncePerRequestFilter {
|
||||
private static final String PATH = "/internal/print/v1/pdfs/inbound-marks";
|
||||
|
||||
private final PrintPdfProperties properties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
public PrintRequestSizeFilter(PrintPdfProperties properties, ObjectMapper objectMapper) {
|
||||
this.properties = properties;
|
||||
this.objectMapper = objectMapper;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
long max = properties.getMaxRequestBytes();
|
||||
if (max < 1 || max > Integer.MAX_VALUE) {
|
||||
throw new IllegalStateException("print.pdf.max-request-bytes must be between 1 and Integer.MAX_VALUE");
|
||||
}
|
||||
if (request.getContentLengthLong() > max) {
|
||||
writeTooLarge(request, response, max);
|
||||
return;
|
||||
}
|
||||
byte[] body = readAtMost(request, max);
|
||||
if (body == null) {
|
||||
writeTooLarge(request, response, max);
|
||||
return;
|
||||
}
|
||||
filterChain.doFilter(new BodyRequestWrapper(request, body), response);
|
||||
}
|
||||
|
||||
private byte[] readAtMost(HttpServletRequest request, long max) throws IOException {
|
||||
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(max, 8192));
|
||||
ServletInputStream input = request.getInputStream();
|
||||
byte[] buffer = new byte[8192];
|
||||
long total = 0;
|
||||
int read;
|
||||
while ((read = input.read(buffer)) != -1) {
|
||||
total += read;
|
||||
if (total > max) {
|
||||
return null;
|
||||
}
|
||||
output.write(buffer, 0, read);
|
||||
}
|
||||
return output.toByteArray();
|
||||
}
|
||||
|
||||
private void writeTooLarge(HttpServletRequest request, HttpServletResponse response, long max)
|
||||
throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
|
||||
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
|
||||
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
|
||||
ApiResponse<Void> body = ApiResponse.failure(ResultCode.BAD_REQUEST,
|
||||
"Request body exceeds " + max + " bytes",
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
|
||||
objectMapper.writeValue(response.getOutputStream(), body);
|
||||
}
|
||||
|
||||
private static final class BodyRequestWrapper extends HttpServletRequestWrapper {
|
||||
private final byte[] body;
|
||||
|
||||
private BodyRequestWrapper(HttpServletRequest request, byte[] body) {
|
||||
super(request);
|
||||
this.body = body;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getContentLength() { return body.length; }
|
||||
|
||||
@Override
|
||||
public long getContentLengthLong() { return body.length; }
|
||||
|
||||
@Override
|
||||
public ServletInputStream getInputStream() {
|
||||
ByteArrayInputStream input = new ByteArrayInputStream(body);
|
||||
return new ServletInputStream() {
|
||||
@Override public int read() { return input.read(); }
|
||||
@Override public int read(byte[] b, int off, int len) { return input.read(b, off, len); }
|
||||
@Override public boolean isFinished() { return input.available() == 0; }
|
||||
@Override public boolean isReady() { return true; }
|
||||
@Override public void setReadListener(jakarta.servlet.ReadListener listener) { }
|
||||
};
|
||||
}
|
||||
|
||||
@Override
|
||||
public BufferedReader getReader() {
|
||||
return new BufferedReader(new java.io.InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.print.dto;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 入仓唛头 PDF 的内部请求契约。
|
||||
*/
|
||||
public final class InboundMarkPdfDtos {
|
||||
private InboundMarkPdfDtos() {
|
||||
}
|
||||
|
||||
public record Request(
|
||||
String templateCode,
|
||||
PageSpec pageSpec,
|
||||
String downloadFileName,
|
||||
List<Label> labels
|
||||
) {
|
||||
}
|
||||
|
||||
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
|
||||
}
|
||||
|
||||
public record Label(
|
||||
String topCode,
|
||||
String warehouseText,
|
||||
String cartonText,
|
||||
String routeText,
|
||||
String qrContent
|
||||
) {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import java.io.IOException;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 固定的 100mm x 100mm 入仓唛头 V1 模板。
|
||||
*/
|
||||
@Component
|
||||
public class InboundMarkV1Renderer {
|
||||
public static final double PAGE_WIDTH_MM = 100d;
|
||||
public static final double PAGE_HEIGHT_MM = 100d;
|
||||
|
||||
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 QR_SIZE_PT = PdfUnits.mmToPt(35.28);
|
||||
private static final float QR_LEFT_PT = (PAGE_WIDTH_PT - QR_SIZE_PT) / 2f;
|
||||
private static final float QR_BOTTOM_PT = PAGE_HEIGHT_PT - PdfUnits.mmToPt(84);
|
||||
|
||||
private final PdfQrRenderer qrRenderer;
|
||||
|
||||
public InboundMarkV1Renderer(PdfQrRenderer qrRenderer) {
|
||||
this.qrRenderer = qrRenderer;
|
||||
}
|
||||
|
||||
public void renderPage(PDDocument document, PDFont font, InboundMarkPdfDtos.Label label,
|
||||
BitMatrix qrMatrix) 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);
|
||||
drawCenteredText(stream, units, font, label.topCode(), 24, 11);
|
||||
drawCenteredText(stream, units, font, label.warehouseText(), 36, 26);
|
||||
drawCenteredText(stream, units, font, label.cartonText(), 22, 37);
|
||||
drawCenteredText(stream, units, font, label.routeText(), 21, 47);
|
||||
qrRenderer.draw(stream, qrMatrix, QR_LEFT_PT, QR_BOTTOM_PT, QR_SIZE_PT);
|
||||
drawCenteredText(stream, units, font, label.qrContent(), 12, 85.5);
|
||||
drawCenteredText(stream, units, font, "MADE IN CHINA", 14, 92);
|
||||
}
|
||||
}
|
||||
|
||||
private void drawCenteredText(PDPageContentStream stream, PdfUnits units, PDFont font,
|
||||
String text, float fontSize, double centerMmFromTop)
|
||||
throws IOException {
|
||||
float textWidth = font.getStringWidth(text) / 1000f * fontSize;
|
||||
float x = (PAGE_WIDTH_PT - textWidth) / 2f;
|
||||
float baseline = units.centeredTextBaseline(font, fontSize, centerMmFromTop);
|
||||
stream.beginText();
|
||||
stream.setFont(font, fontSize);
|
||||
stream.newLineAtOffset(x, baseline);
|
||||
stream.showText(text);
|
||||
stream.endText();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import com.google.zxing.BarcodeFormat;
|
||||
import com.google.zxing.EncodeHintType;
|
||||
import com.google.zxing.WriterException;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import com.google.zxing.qrcode.QRCodeWriter;
|
||||
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Map;
|
||||
import org.apache.pdfbox.pdmodel.PDPageContentStream;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 生成 QR 模块并以 PDF 矢量矩形绘制,避免位图缩放造成扫描损失。
|
||||
*/
|
||||
@Component
|
||||
public class PdfQrRenderer {
|
||||
private static final Map<EncodeHintType, ?> HINTS = Map.of(
|
||||
EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name(),
|
||||
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M,
|
||||
EncodeHintType.MARGIN, 4
|
||||
);
|
||||
|
||||
public BitMatrix encode(String content) {
|
||||
try {
|
||||
return new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 1, 1, HINTS);
|
||||
} catch (WriterException exception) {
|
||||
throw new IllegalArgumentException("qrContent cannot be encoded", exception);
|
||||
}
|
||||
}
|
||||
|
||||
public void draw(PDPageContentStream stream, BitMatrix matrix,
|
||||
float leftPt, float bottomPt, float sizePt) throws IOException {
|
||||
float moduleSize = sizePt / matrix.getWidth();
|
||||
stream.setNonStrokingColor(0, 0, 0);
|
||||
for (int y = 0; y < matrix.getHeight(); y++) {
|
||||
for (int x = 0; x < matrix.getWidth(); x++) {
|
||||
if (matrix.get(x, y)) {
|
||||
float pdfY = bottomPt + (matrix.getHeight() - 1 - y) * moduleSize;
|
||||
stream.addRect(leftPt + x * moduleSize, pdfY, moduleSize, moduleSize);
|
||||
}
|
||||
}
|
||||
}
|
||||
stream.fill();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import java.io.IOException;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
|
||||
/**
|
||||
* 将模板使用的自顶向下毫米坐标集中转换为 PDF point 坐标。
|
||||
*/
|
||||
public final class PdfUnits {
|
||||
public static final double POINTS_PER_MM = 72.0d / 25.4d;
|
||||
|
||||
private final float pageHeightPt;
|
||||
|
||||
public PdfUnits(double pageHeightMm) {
|
||||
this.pageHeightPt = mmToPt(pageHeightMm);
|
||||
}
|
||||
|
||||
public static float mmToPt(double millimeters) {
|
||||
return (float) (millimeters * POINTS_PER_MM);
|
||||
}
|
||||
|
||||
public float topDownMmToPdfY(double millimetersFromTop) {
|
||||
return pageHeightPt - mmToPt(millimetersFromTop);
|
||||
}
|
||||
|
||||
float centeredTextBaseline(PDFont font, float fontSize, double centerMmFromTop)
|
||||
throws IOException {
|
||||
float ascent = font.getFontDescriptor().getAscent() / 1000f * fontSize;
|
||||
float descent = font.getFontDescriptor().getDescent() / 1000f * fontSize;
|
||||
return topDownMmToPdfY(centerMmFromTop) - (ascent + descent) / 2f;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
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.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.renderer.InboundMarkV1Renderer;
|
||||
import com.baiyue.print.renderer.PdfQrRenderer;
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
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.List;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 校验完整标签数组,并一次性在内存中生成多页 PDF。
|
||||
*/
|
||||
@Service
|
||||
public class InboundMarkPdfService {
|
||||
private static final int MAX_QR_CODE_POINTS = 34;
|
||||
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
|
||||
private static final Set<Character> FORBIDDEN_FILE_NAME_CHARACTERS =
|
||||
Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
|
||||
|
||||
private final PrintPdfProperties properties;
|
||||
private final PdfGenerationLimiter limiter;
|
||||
private final InboundMarkV1Renderer renderer;
|
||||
private final PdfQrRenderer qrRenderer;
|
||||
|
||||
public InboundMarkPdfService(PrintPdfProperties properties, PdfGenerationLimiter limiter,
|
||||
InboundMarkV1Renderer renderer, PdfQrRenderer qrRenderer) {
|
||||
this.properties = properties;
|
||||
this.limiter = limiter;
|
||||
this.renderer = renderer;
|
||||
this.qrRenderer = qrRenderer;
|
||||
}
|
||||
|
||||
public GeneratedPdf generate(InboundMarkPdfDtos.Request request) {
|
||||
NormalizedRequest normalized = validateAndNormalize(request);
|
||||
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
|
||||
List<BitMatrix> matrices = normalized.labels().stream()
|
||||
.map(label -> qrRenderer.encode(label.qrContent()))
|
||||
.toList();
|
||||
return render(normalized, matrices);
|
||||
}
|
||||
}
|
||||
|
||||
private GeneratedPdf render(NormalizedRequest request, List<BitMatrix> matrices) {
|
||||
try (PDDocument document = new PDDocument();
|
||||
InputStream fontStream = openFont();
|
||||
LimitedByteArrayOutputStream output =
|
||||
new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) {
|
||||
PDType0Font font = PDType0Font.load(document, fontStream, true);
|
||||
for (int index = 0; index < request.labels().size(); index++) {
|
||||
renderer.renderPage(document, font, request.labels().get(index), matrices.get(index));
|
||||
}
|
||||
document.save(output);
|
||||
byte[] content = output.toByteArray();
|
||||
return new GeneratedPdf(content, request.fileName(), request.labels().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(InboundMarkPdfDtos.Request request) {
|
||||
if (request == null) {
|
||||
invalid("request is required");
|
||||
}
|
||||
String templateCode = normalizeRequired(request.templateCode(), "templateCode");
|
||||
if (!properties.getTemplateCode().equals(templateCode)) {
|
||||
invalid("templateCode is not supported");
|
||||
}
|
||||
if (request.pageSpec() == null) {
|
||||
invalid("pageSpec is required");
|
||||
}
|
||||
requireSupportedDimension(request.pageSpec().widthMm(),
|
||||
properties.getSupportedWidthMm(), "pageSpec.widthMm");
|
||||
requireSupportedDimension(request.pageSpec().heightMm(),
|
||||
properties.getSupportedHeightMm(), "pageSpec.heightMm");
|
||||
if (request.labels() == null || request.labels().isEmpty()) {
|
||||
invalid("labels must contain at least one item");
|
||||
}
|
||||
if (request.labels().size() > properties.getMaxPages()) {
|
||||
invalid("labels must not contain more than " + properties.getMaxPages() + " items");
|
||||
}
|
||||
|
||||
List<InboundMarkPdfDtos.Label> labels = new ArrayList<>(request.labels().size());
|
||||
for (int index = 0; index < request.labels().size(); index++) {
|
||||
InboundMarkPdfDtos.Label label = request.labels().get(index);
|
||||
if (label == null) {
|
||||
invalid("labels[" + index + "] is required");
|
||||
}
|
||||
labels.add(new InboundMarkPdfDtos.Label(
|
||||
normalizeRequired(label.topCode(), field(index, "topCode")),
|
||||
normalizeRequired(label.warehouseText(), field(index, "warehouseText")),
|
||||
normalizeRequired(label.cartonText(), field(index, "cartonText")),
|
||||
normalizeRequired(label.routeText(), field(index, "routeText")),
|
||||
normalizeQr(label.qrContent(), field(index, "qrContent"))));
|
||||
}
|
||||
return new NormalizedRequest(normalizeFileName(request.downloadFileName()), List.copyOf(labels));
|
||||
}
|
||||
|
||||
private String normalizeFileName(String value) {
|
||||
if (value == null) {
|
||||
return "inbound-marks.pdf";
|
||||
}
|
||||
String normalized = normalizeRequired(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 normalizeQr(String value, String field) {
|
||||
String normalized = normalizeRequired(value, field);
|
||||
int codePoints = normalized.codePointCount(0, normalized.length());
|
||||
if (codePoints > MAX_QR_CODE_POINTS) {
|
||||
invalid(field + " must not exceed 34 characters");
|
||||
}
|
||||
for (int offset = 0; offset < normalized.length(); offset++) {
|
||||
char character = normalized.charAt(offset);
|
||||
if (character < 0x20 || character > 0x7e) {
|
||||
invalid(field + " must contain printable ASCII only");
|
||||
}
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private String normalizeRequired(String value, String field) {
|
||||
if (value == null) {
|
||||
invalid(field + " is required");
|
||||
}
|
||||
String normalized = Normalizer.normalize(value, Normalizer.Form.NFC);
|
||||
if (normalized.isBlank()) {
|
||||
invalid(field + " must not be blank");
|
||||
}
|
||||
normalized.codePoints().forEach(codePoint -> {
|
||||
if (isForbiddenControl(codePoint)) {
|
||||
invalid(field + " contains a control character");
|
||||
}
|
||||
});
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private boolean isForbiddenControl(int codePoint) {
|
||||
int type = Character.getType(codePoint);
|
||||
return 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 "labels[" + 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 fileName, List<InboundMarkPdfDtos.Label> labels) {
|
||||
}
|
||||
|
||||
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 {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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 java.time.Duration;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
/**
|
||||
* 进程内 PDF 生成并发保护。许可必须通过 try-with-resources 释放。
|
||||
*/
|
||||
@Component
|
||||
public class PdfGenerationLimiter {
|
||||
private final Semaphore semaphore;
|
||||
private final Duration acquireTimeout;
|
||||
|
||||
public PdfGenerationLimiter(PrintPdfProperties properties) {
|
||||
if (properties.getMaxConcurrentGenerations() < 1) {
|
||||
throw new IllegalArgumentException("print.pdf.max-concurrent-generations must be positive");
|
||||
}
|
||||
this.semaphore = new Semaphore(properties.getMaxConcurrentGenerations(), true);
|
||||
this.acquireTimeout = properties.getAcquireTimeout();
|
||||
}
|
||||
|
||||
public Permit acquire() {
|
||||
boolean acquired;
|
||||
try {
|
||||
acquired = semaphore.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
|
||||
} catch (InterruptedException exception) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "等待 PDF 生成槽位时线程被中断");
|
||||
}
|
||||
if (!acquired) {
|
||||
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "PDF 生成繁忙,请稍后重试");
|
||||
}
|
||||
return new Permit(semaphore);
|
||||
}
|
||||
|
||||
public static final class Permit implements AutoCloseable {
|
||||
private final Semaphore semaphore;
|
||||
private boolean closed;
|
||||
|
||||
private Permit(Semaphore semaphore) {
|
||||
this.semaphore = semaphore;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
semaphore.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
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}
|
||||
@@ -1,6 +0,0 @@
|
||||
spring:
|
||||
datasource:
|
||||
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||
url: ${PRINT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
|
||||
username: ${PRINT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
|
||||
password: ${PRINT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
|
||||
@@ -23,6 +23,18 @@ spring:
|
||||
baiyue:
|
||||
security:
|
||||
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
|
||||
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
|
||||
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
|
||||
management.endpoints.web.exposure.include: health,info,prometheus
|
||||
|
||||
logging:
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
-- 打印服务独占数据库,业务服务只保存打印任务逻辑ID。
|
||||
CREATE DATABASE IF NOT EXISTS baiyue_print
|
||||
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
|
||||
USE baiyue_print;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS print_template (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '模板ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID,平台模板使用平台租户ID',
|
||||
template_code VARCHAR(64) NOT NULL COMMENT '模板编码',
|
||||
template_name VARCHAR(128) NOT NULL COMMENT '模板名称',
|
||||
template_type VARCHAR(32) NOT NULL COMMENT '模板类型',
|
||||
content TEXT NOT NULL COMMENT '模板内容',
|
||||
version_no INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '模板版本号',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '模板状态',
|
||||
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
|
||||
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
|
||||
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_print_template_code (tenant_id, template_code, version_no),
|
||||
KEY idx_print_template_status (tenant_id, status)
|
||||
) ENGINE=InnoDB COMMENT='打印模板表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS print_job (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '打印任务ID(雪花ID)',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
job_no VARCHAR(40) NOT NULL COMMENT '打印任务编号',
|
||||
template_id BIGINT UNSIGNED NOT NULL COMMENT '打印模板逻辑ID',
|
||||
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
|
||||
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
|
||||
printer_code VARCHAR(64) NULL COMMENT '目标打印机编码',
|
||||
copies INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '打印份数',
|
||||
payload_json JSON NOT NULL COMMENT '模板数据快照',
|
||||
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
|
||||
output_file_id BIGINT UNSIGNED NULL COMMENT '生成文件的逻辑ID',
|
||||
error_message VARCHAR(1000) NULL COMMENT '失败原因',
|
||||
requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请人ID',
|
||||
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '申请时间',
|
||||
started_at DATETIME(3) NULL COMMENT '开始时间',
|
||||
completed_at DATETIME(3) NULL COMMENT '完成时间',
|
||||
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
|
||||
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_print_job_no (tenant_id, job_no),
|
||||
KEY idx_print_job_status (tenant_id, status, requested_at),
|
||||
KEY idx_print_job_business (tenant_id, business_type, business_id)
|
||||
) ENGINE=InnoDB COMMENT='打印任务表';
|
||||
|
||||
CREATE TABLE IF NOT EXISTS print_job_history (
|
||||
id BIGINT UNSIGNED NOT NULL COMMENT '任务历史ID',
|
||||
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
|
||||
print_job_id BIGINT UNSIGNED NOT NULL COMMENT '打印任务逻辑ID',
|
||||
from_status VARCHAR(24) NULL COMMENT '变更前状态',
|
||||
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
|
||||
message VARCHAR(500) NULL COMMENT '状态说明',
|
||||
operator_id BIGINT UNSIGNED NULL COMMENT '操作人ID',
|
||||
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
|
||||
PRIMARY KEY (id),
|
||||
KEY idx_print_history_job (tenant_id, print_job_id, occurred_at)
|
||||
) ENGINE=InnoDB COMMENT='打印任务状态历史表';
|
||||
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,131 @@
|
||||
openapi: 3.0.3
|
||||
info:
|
||||
title: Baiyue Print Internal API
|
||||
version: 1.0.0
|
||||
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
|
||||
paths:
|
||||
/internal/print/v1/pdfs/inbound-marks:
|
||||
post:
|
||||
summary: Generate an inbound-mark PDF
|
||||
operationId: generateInboundMarkPdf
|
||||
security:
|
||||
- internalServiceToken: []
|
||||
parameters:
|
||||
- $ref: '#/components/parameters/RequestId'
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/InboundMarkPdfRequest'
|
||||
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:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
parameters:
|
||||
RequestId:
|
||||
name: X-Request-Id
|
||||
in: header
|
||||
required: false
|
||||
schema:
|
||||
type: string
|
||||
schemas:
|
||||
InboundMarkPdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, labels]
|
||||
properties:
|
||||
templateCode:
|
||||
type: string
|
||||
enum: [INBOUND_MARK_V1]
|
||||
pageSpec:
|
||||
$ref: '#/components/schemas/PageSpec'
|
||||
downloadFileName:
|
||||
type: string
|
||||
maxLength: 120
|
||||
example: PCHK2608040174-19件-MCC1.pdf
|
||||
labels:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 2000
|
||||
items:
|
||||
$ref: '#/components/schemas/InboundMarkLabel'
|
||||
PageSpec:
|
||||
type: object
|
||||
required: [widthMm, heightMm]
|
||||
properties:
|
||||
widthMm:
|
||||
type: number
|
||||
enum: [100]
|
||||
heightMm:
|
||||
type: number
|
||||
enum: [100]
|
||||
InboundMarkLabel:
|
||||
type: object
|
||||
required: [topCode, warehouseText, cartonText, routeText, qrContent]
|
||||
properties:
|
||||
topCode:
|
||||
type: string
|
||||
warehouseText:
|
||||
type: string
|
||||
cartonText:
|
||||
type: string
|
||||
routeText:
|
||||
type: string
|
||||
qrContent:
|
||||
type: string
|
||||
minLength: 1
|
||||
maxLength: 34
|
||||
pattern: '^[\\x20-\\x7E]+$'
|
||||
ApiError:
|
||||
type: object
|
||||
required: [code, message]
|
||||
properties:
|
||||
code:
|
||||
type: string
|
||||
enum: [A0400, A0403, A0422, A0429, S0500]
|
||||
message:
|
||||
type: string
|
||||
data:
|
||||
nullable: true
|
||||
requestId:
|
||||
type: string
|
||||
nullable: true
|
||||
traceId:
|
||||
type: string
|
||||
nullable: true
|
||||
@@ -0,0 +1,18 @@
|
||||
package com.baiyue.print;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
|
||||
@SpringBootTest(
|
||||
classes = PrintServiceApplication.class,
|
||||
webEnvironment = SpringBootTest.WebEnvironment.NONE,
|
||||
properties = {
|
||||
"spring.cloud.nacos.config.enabled=false",
|
||||
"spring.cloud.nacos.discovery.enabled=false",
|
||||
"baiyue.security.internal-service-token=test-internal-token-at-least-32-bytes"
|
||||
})
|
||||
class PrintServiceApplicationTest {
|
||||
@Test
|
||||
void startsWithoutDatabaseRedisOrNacos() {
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package com.baiyue.print.acceptance;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assumptions.assumeTrue;
|
||||
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.renderer.InboundMarkV1Renderer;
|
||||
import com.baiyue.print.renderer.PdfQrRenderer;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import com.baiyue.print.service.PdfGenerationLimiter;
|
||||
import com.google.zxing.BinaryBitmap;
|
||||
import com.google.zxing.MultiFormatReader;
|
||||
import com.google.zxing.RGBLuminanceSource;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import java.awt.image.BufferedImage;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.math.BigDecimal;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.stream.Stream;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.pdmodel.font.PDFont;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
||||
/** End-to-end renderer acceptance checks. These intentionally exercise the real service, not mocks. */
|
||||
class InboundMarkPdfAcceptanceTest {
|
||||
private static final String TEMPLATE = "INBOUND_MARK_V1";
|
||||
private static final BigDecimal MM_100 = new BigDecimal("100");
|
||||
|
||||
@Test
|
||||
void generatesOnePageWithExactSizeTextOrderEmbeddedChineseFontAndNoDiskArtifact(@TempDir Path tempDir)
|
||||
throws Exception {
|
||||
InboundMarkPdfService service = service();
|
||||
InboundMarkPdfDtos.Label label = label(1);
|
||||
String originalTmp = System.getProperty("java.io.tmpdir");
|
||||
System.setProperty("java.io.tmpdir", tempDir.toString());
|
||||
try {
|
||||
byte[] pdf = service.generate(request(List.of(label))).content();
|
||||
assertPdf(pdf, List.of(label));
|
||||
try (Stream<Path> files = Files.list(tempDir)) {
|
||||
assertThat(files.filter(Files::isRegularFile).toList())
|
||||
.as("PDF rendering must not create temporary files")
|
||||
.isEmpty();
|
||||
}
|
||||
} finally {
|
||||
if (originalTmp == null) {
|
||||
System.clearProperty("java.io.tmpdir");
|
||||
} else {
|
||||
System.setProperty("java.io.tmpdir", originalTmp);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void preservesPageOrderAndDecodableQrOnFirstMiddleAndLastPages() throws Exception {
|
||||
List<InboundMarkPdfDtos.Label> labels = labels(19);
|
||||
assertPdf(service().generate(request(labels)).content(), labels);
|
||||
}
|
||||
|
||||
@Test
|
||||
void generatesTwoHundredEighteenPagesWithExactDimensions() throws Exception {
|
||||
List<InboundMarkPdfDtos.Label> labels = labels(218);
|
||||
byte[] pdf = service().generate(request(labels)).content();
|
||||
try (PDDocument document = Loader.loadPDF(pdf)) {
|
||||
assertThat(document.getNumberOfPages()).isEqualTo(218);
|
||||
for (int i : List.of(0, 108, 217)) {
|
||||
assertExactPageSize(document, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void maximumCapacityIsCoveredWhenExplicitlyEnabled() {
|
||||
assumeTrue(Boolean.getBoolean("print.capacity.tests"),
|
||||
"Enable with -Dprint.capacity.tests=true for the 2000-page capacity acceptance test");
|
||||
List<InboundMarkPdfDtos.Label> labels = labels(2000);
|
||||
byte[] pdf = service().generate(request(labels)).content();
|
||||
assertThat(pdf).startsWith("%PDF".getBytes(StandardCharsets.US_ASCII));
|
||||
assertThat(pdf.length).isLessThan(32 * 1024 * 1024);
|
||||
try (PDDocument document = Loader.loadPDF(pdf)) {
|
||||
assertThat(document.getNumberOfPages()).isEqualTo(2000);
|
||||
} catch (Exception exception) {
|
||||
throw new AssertionError("Generated 2000-page PDF must be readable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private void assertPdf(byte[] bytes, List<InboundMarkPdfDtos.Label> labels) throws Exception {
|
||||
try (PDDocument document = Loader.loadPDF(bytes)) {
|
||||
assertThat(document.getNumberOfPages()).isEqualTo(labels.size());
|
||||
PDFTextStripper stripper = new PDFTextStripper();
|
||||
for (int i : representativeIndexes(labels.size())) {
|
||||
assertExactPageSize(document, i);
|
||||
stripper.setStartPage(i + 1);
|
||||
stripper.setEndPage(i + 1);
|
||||
String text = stripper.getText(document);
|
||||
InboundMarkPdfDtos.Label label = labels.get(i);
|
||||
assertThat(text).contains(label.topCode(), label.warehouseText(), label.cartonText(),
|
||||
label.routeText(), label.qrContent(), "MADE IN CHINA");
|
||||
assertThat(text.indexOf(label.topCode())).isLessThan(text.indexOf(label.warehouseText()));
|
||||
assertThat(text.indexOf(label.warehouseText())).isLessThan(text.indexOf(label.cartonText()));
|
||||
assertThat(text.indexOf(label.cartonText())).isLessThan(text.indexOf(label.routeText()));
|
||||
assertThat(text.indexOf(label.routeText())).isLessThan(text.indexOf(label.qrContent()));
|
||||
assertThat(text.lines().filter(label.topCode()::equals).count())
|
||||
.as("top code must appear as exactly one standalone line")
|
||||
.isEqualTo(1);
|
||||
assertEmbeddedFont(document, i);
|
||||
}
|
||||
decodeQrs(document, labels);
|
||||
}
|
||||
}
|
||||
|
||||
private void decodeQrs(PDDocument document, List<InboundMarkPdfDtos.Label> labels) throws Exception {
|
||||
PDFRenderer renderer = new PDFRenderer(document);
|
||||
for (int i : representativeIndexes(labels.size())) {
|
||||
BufferedImage image = renderer.renderImageWithDPI(i, 300);
|
||||
int[] pixels = image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
|
||||
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(
|
||||
new RGBLuminanceSource(image.getWidth(), image.getHeight(), pixels)));
|
||||
String decoded = new MultiFormatReader().decode(bitmap).getText();
|
||||
assertThat(decoded).isEqualTo(labels.get(i).qrContent());
|
||||
}
|
||||
}
|
||||
|
||||
private void assertEmbeddedFont(PDDocument document, int pageIndex) throws Exception {
|
||||
boolean embedded = false;
|
||||
for (org.apache.pdfbox.cos.COSName name : document.getPage(pageIndex).getResources().getFontNames()) {
|
||||
PDFont font = document.getPage(pageIndex).getResources().getFont(name);
|
||||
embedded |= font.isEmbedded();
|
||||
}
|
||||
assertThat(embedded).as("page %s must contain an embedded font", pageIndex + 1).isTrue();
|
||||
}
|
||||
|
||||
private void assertExactPageSize(PDDocument document, int index) {
|
||||
float expected = 100f * 72f / 25.4f;
|
||||
assertThat(document.getPage(index).getMediaBox().getWidth()).isCloseTo(expected,
|
||||
org.assertj.core.data.Offset.offset(0.002f));
|
||||
assertThat(document.getPage(index).getMediaBox().getHeight()).isCloseTo(expected,
|
||||
org.assertj.core.data.Offset.offset(0.002f));
|
||||
assertThat(document.getPage(index).getRotation()).isZero();
|
||||
}
|
||||
|
||||
private List<Integer> representativeIndexes(int size) {
|
||||
if (size == 1) return List.of(0);
|
||||
return List.of(0, size / 2, size - 1);
|
||||
}
|
||||
|
||||
private InboundMarkPdfService service() {
|
||||
PrintPdfProperties properties = new PrintPdfProperties();
|
||||
return new InboundMarkPdfService(properties, new PdfGenerationLimiter(properties),
|
||||
new InboundMarkV1Renderer(new PdfQrRenderer()), new PdfQrRenderer());
|
||||
}
|
||||
|
||||
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
|
||||
return new InboundMarkPdfDtos.Request(TEMPLATE,
|
||||
new InboundMarkPdfDtos.PageSpec(MM_100, MM_100), "acceptance.pdf", labels);
|
||||
}
|
||||
|
||||
private List<InboundMarkPdfDtos.Label> labels(int total) {
|
||||
List<InboundMarkPdfDtos.Label> labels = new ArrayList<>(total);
|
||||
for (int i = 1; i <= total; i++) labels.add(label(i, total));
|
||||
return labels;
|
||||
}
|
||||
|
||||
private InboundMarkPdfDtos.Label label(int box) { return label(box, 1); }
|
||||
|
||||
private InboundMarkPdfDtos.Label label(int box, int total) {
|
||||
String suffix = total + "-" + box;
|
||||
return new InboundMarkPdfDtos.Label("PCHK2608040174", "MCC1", "CTNS:" + suffix,
|
||||
"洛杉矶普船海卡", "PCHK2608040174#" + suffix);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package com.baiyue.print.acceptance;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/** Structural guardrails for the stateless service boundary. */
|
||||
class PrintStatelessArchitectureTest {
|
||||
@Test
|
||||
void moduleDoesNotDeclareDatabaseOrMybatisDependencies() throws Exception {
|
||||
String pom = Files.readString(moduleRoot().resolve("pom.xml"), StandardCharsets.UTF_8);
|
||||
assertThat(pom).doesNotContain("mybatis-plus-spring-boot3-starter")
|
||||
.doesNotContain("mysql-connector-j")
|
||||
.doesNotContain("spring-jdbc")
|
||||
.doesNotContain("spring-boot-starter-jdbc");
|
||||
}
|
||||
|
||||
@Test
|
||||
void sourceAndConfigurationDoNotDeclareDatasourceOrDatabaseAccess() throws Exception {
|
||||
Path root = moduleRoot();
|
||||
String source;
|
||||
try (Stream<Path> paths = Files.walk(root.resolve("src/main"))) {
|
||||
source = paths.filter(Files::isRegularFile)
|
||||
.filter(path -> path.toString().endsWith(".java") || path.toString().endsWith(".yml")
|
||||
|| path.toString().endsWith(".yaml"))
|
||||
.map(this::read)
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
assertThat(source).doesNotContain("DataSource").doesNotContain("JdbcTemplate")
|
||||
.doesNotContain("@Mapper").doesNotContain("spring.datasource")
|
||||
.doesNotContain("jdbc:");
|
||||
}
|
||||
|
||||
@Test
|
||||
void obsoleteDatabaseScriptIsNotPackaged() {
|
||||
assertThat(Files.exists(moduleRoot().resolve("src/main/resources/db/print.sql"))).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() {
|
||||
ClassLoader loader = getClass().getClassLoader();
|
||||
assertThat(isPresent("org.springframework.jdbc.core.JdbcTemplate", loader))
|
||||
.as("Print must exclude the JDBC starter inherited through common-core")
|
||||
.isFalse();
|
||||
assertThat(isPresent("com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration", loader))
|
||||
.as("Print must exclude MyBatis inherited through common-core")
|
||||
.isFalse();
|
||||
assertThat(isPresent("org.springframework.data.redis.core.StringRedisTemplate", loader))
|
||||
.as("Print must not require Redis for authentication or health")
|
||||
.isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void configuredRequestByteLimitHasAnEnforcementConsumer() throws Exception {
|
||||
String consumers = productionJavaExcept("PrintPdfProperties.java");
|
||||
assertThat(consumers).as("max-request-bytes must be enforced before JSON deserialization")
|
||||
.contains("getMaxRequestBytes()");
|
||||
}
|
||||
|
||||
@Test
|
||||
void fontResourceIsValidatedAtStartupOrReadiness() throws Exception {
|
||||
String consumers = productionJavaExcept("PrintPdfProperties.java");
|
||||
assertThat(consumers).contains("getFontResource()");
|
||||
assertThat(consumers)
|
||||
.as("a missing or invalid font must make startup/readiness fail, not wait for a PDF request")
|
||||
.containsAnyOf("HealthIndicator", "ApplicationRunner", "SmartInitializingSingleton", "@PostConstruct");
|
||||
}
|
||||
|
||||
private Path moduleRoot() {
|
||||
Path cwd = Path.of("").toAbsolutePath().normalize();
|
||||
for (Path candidate : new Path[]{cwd, cwd.resolve("print"), cwd.resolve("baiyue-firstleg/print")}) {
|
||||
if (Files.exists(candidate.resolve("pom.xml")) && Files.exists(candidate.resolve("src/main"))) {
|
||||
return candidate;
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Cannot locate the print module from " + cwd);
|
||||
}
|
||||
|
||||
private String read(Path path) {
|
||||
try {
|
||||
return Files.readString(path, StandardCharsets.UTF_8);
|
||||
} catch (Exception exception) {
|
||||
throw new IllegalStateException(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private String productionJavaExcept(String excludedFileName) throws Exception {
|
||||
try (Stream<Path> paths = Files.walk(moduleRoot().resolve("src/main/java"))) {
|
||||
return paths.filter(Files::isRegularFile)
|
||||
.filter(path -> path.toString().endsWith(".java"))
|
||||
.filter(path -> !path.getFileName().toString().equals(excludedFileName))
|
||||
.map(this::read)
|
||||
.collect(Collectors.joining("\n"));
|
||||
}
|
||||
}
|
||||
|
||||
private boolean isPresent(String className, ClassLoader loader) {
|
||||
try {
|
||||
Class.forName(className, false, loader);
|
||||
return true;
|
||||
} catch (ClassNotFoundException ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package com.baiyue.print.config;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.boot.actuate.health.Status;
|
||||
|
||||
class PrintFontHealthIndicatorTest {
|
||||
@Test
|
||||
void reportsUpForPackagedParseableFont() {
|
||||
PrintPdfProperties properties = new PrintPdfProperties();
|
||||
|
||||
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.UP);
|
||||
}
|
||||
|
||||
@Test
|
||||
void reportsDownForMissingFont() {
|
||||
PrintPdfProperties properties = new PrintPdfProperties();
|
||||
properties.setFontResource("fonts/missing.ttf");
|
||||
|
||||
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.DOWN);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
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.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.ClassPathResource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||
|
||||
class InternalPdfControllerTest {
|
||||
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||
private InboundMarkPdfService service;
|
||||
private MockMvc mockMvc;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
service = mock(InboundMarkPdfService.class);
|
||||
InternalPdfController controller = new InternalPdfController(
|
||||
service, new InternalServiceTokenGuard(TOKEN));
|
||||
mockMvc = MockMvcBuilders.standaloneSetup(controller)
|
||||
.setControllerAdvice(new GlobalExceptionHandler())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsPdfWithDownloadHeaders() throws Exception {
|
||||
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
|
||||
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
|
||||
pdf, "PCHK2608040174-19件-MCC1.pdf", 19));
|
||||
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
|
||||
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
|
||||
.requestAttr("requestId", "request-123")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
|
||||
.content(validRequestJson()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
.andExpect(content().bytes(pdf))
|
||||
.andExpect(header().string("Cache-Control", "no-store"))
|
||||
.andExpect(header().string("Pragma", "no-cache"))
|
||||
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
|
||||
.andExpect(header().string("X-Print-Page-Count", "19"))
|
||||
.andExpect(header().string("Content-Disposition",
|
||||
org.hamcrest.Matchers.containsString("attachment")))
|
||||
.andExpect(header().string("Content-Disposition",
|
||||
org.hamcrest.Matchers.containsString("filename*=")));
|
||||
|
||||
verify(service).generate(any(InboundMarkPdfDtos.Request.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingTokenAsJsonBeforeCallingService() throws Exception {
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
|
||||
.content(validRequestJson()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||
.andExpect(jsonPath("$.code").value("A0403"));
|
||||
|
||||
verify(service, never()).generate(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsServiceValidationFailureAsJson() throws Exception {
|
||||
when(service.generate(any())).thenThrow(
|
||||
new BizException(ResultCode.VALIDATION_FAILED, "labels must contain at least one item"));
|
||||
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
|
||||
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
|
||||
.content(validRequestJson()))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
|
||||
.andExpect(jsonPath("$.code").value("A0422"))
|
||||
.andExpect(jsonPath("$.message").value("labels must contain at least one item"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void derivesDefaultFileNameFromRequestId() throws Exception {
|
||||
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
|
||||
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
|
||||
pdf, "inbound-marks-request-123.pdf", 1));
|
||||
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
|
||||
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
|
||||
.requestAttr("requestId", "request-123")
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.content(validRequestJson().replace(
|
||||
"\"downloadFileName\": \"PCHK2608040174-19件-MCC1.pdf\",", "")))
|
||||
.andExpect(status().isOk())
|
||||
.andExpect(header().string("Content-Disposition",
|
||||
org.hamcrest.Matchers.containsString("inbound-marks-request-123.pdf")));
|
||||
|
||||
ArgumentCaptor<InboundMarkPdfDtos.Request> captor =
|
||||
ArgumentCaptor.forClass(InboundMarkPdfDtos.Request.class);
|
||||
verify(service).generate(captor.capture());
|
||||
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
|
||||
}
|
||||
|
||||
@Test
|
||||
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
|
||||
PostMapping mapping = InternalPdfController.class
|
||||
.getMethod("generateInboundMarks", String.class, InboundMarkPdfDtos.Request.class,
|
||||
jakarta.servlet.http.HttpServletRequest.class)
|
||||
.getAnnotation(PostMapping.class);
|
||||
assertThat(mapping.produces()).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiDocumentsBothSuccessAndErrorRepresentations() throws Exception {
|
||||
String contract = new ClassPathResource("openapi/print-internal-v1.yaml")
|
||||
.getContentAsString(StandardCharsets.UTF_8);
|
||||
assertThat(contract)
|
||||
.contains("/internal/print/v1/pdfs/inbound-marks:")
|
||||
.contains("application/pdf:")
|
||||
.contains("application/json:")
|
||||
.contains("name: X-Internal-Service-Token")
|
||||
.contains("maximum: 2000");
|
||||
}
|
||||
|
||||
private String validRequestJson() {
|
||||
return """
|
||||
{
|
||||
"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"
|
||||
}]
|
||||
}
|
||||
""";
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.baiyue.print.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
|
||||
class InternalServiceTokenGuardTest {
|
||||
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
@Test
|
||||
void acceptsOnlyConfiguredToken() {
|
||||
InternalServiceTokenGuard guard = new InternalServiceTokenGuard(TOKEN);
|
||||
|
||||
guard.verify(TOKEN);
|
||||
assertThatThrownBy(() -> guard.verify(null))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.FORBIDDEN);
|
||||
assertThatThrownBy(() -> guard.verify("wrong"))
|
||||
.isInstanceOf(BizException.class)
|
||||
.extracting(exception -> ((BizException) exception).getResultCode())
|
||||
.isEqualTo(ResultCode.FORBIDDEN);
|
||||
}
|
||||
|
||||
@Test
|
||||
void refusesUnsafeConfiguration() {
|
||||
assertThatThrownBy(() -> new InternalServiceTokenGuard("too-short"))
|
||||
.isInstanceOf(IllegalStateException.class)
|
||||
.hasMessageContaining("at least 32 bytes");
|
||||
assertThatThrownBy(() -> new InternalServiceTokenGuard(null))
|
||||
.isInstanceOf(IllegalStateException.class);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
package com.baiyue.print.controller;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
class PrintRequestSizeFilterTest {
|
||||
private PrintRequestSizeFilter filter;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
PrintPdfProperties properties = new PrintPdfProperties();
|
||||
properties.setMaxRequestBytes(10);
|
||||
filter = new PrintRequestSizeFilter(properties, new ObjectMapper().findAndRegisterModules());
|
||||
}
|
||||
|
||||
@Test
|
||||
void passesBoundedBodyToDownstream() throws Exception {
|
||||
MockHttpServletRequest request = request("1234567890", false);
|
||||
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||
AtomicReference<String> received = new AtomicReference<>();
|
||||
|
||||
filter.doFilter(request, response, (wrapped, ignored) -> received.set(
|
||||
new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8)));
|
||||
|
||||
assertThat(received).hasValue("1234567890");
|
||||
assertThat(response.getStatus()).isEqualTo(200);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest() {
|
||||
@Override public int getContentLength() { return 11; }
|
||||
@Override public long getContentLengthLong() { return 11; }
|
||||
};
|
||||
initialize(request, "{}");
|
||||
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 rejectsOversizedBodyWhenLengthIsUnknown() throws Exception {
|
||||
MockHttpServletRequest request = request("12345678901", true);
|
||||
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);
|
||||
}
|
||||
|
||||
private MockHttpServletRequest request(String content, boolean unknownLength) {
|
||||
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
|
||||
@Override public int getContentLength() { return -1; }
|
||||
@Override public long getContentLengthLong() { return -1; }
|
||||
} : new MockHttpServletRequest();
|
||||
initialize(request, content);
|
||||
return request;
|
||||
}
|
||||
|
||||
private void initialize(MockHttpServletRequest request, String content) {
|
||||
request.setMethod("POST");
|
||||
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
|
||||
request.setContent(content.getBytes(StandardCharsets.UTF_8));
|
||||
request.setAttribute("requestId", "request-123");
|
||||
request.setAttribute("traceId", "trace-123");
|
||||
}
|
||||
|
||||
private void assertTooLarge(MockHttpServletResponse response) throws Exception {
|
||||
assertThat(response.getStatus()).isEqualTo(413);
|
||||
assertThat(response.getContentType()).startsWith("application/json");
|
||||
assertThat(response.getContentAsString()).contains("\"code\":\"A0400\"")
|
||||
.contains("Request body exceeds 10 bytes")
|
||||
.contains("\"requestId\":\"request-123\"");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.google.zxing.common.BitMatrix;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PdfQrRendererTest {
|
||||
@Test
|
||||
void encodesSameContentDeterministicallyWithQuietZone() {
|
||||
PdfQrRenderer renderer = new PdfQrRenderer();
|
||||
|
||||
BitMatrix first = renderer.encode("PCHK2608040174#19-1");
|
||||
BitMatrix second = renderer.encode("PCHK2608040174#19-1");
|
||||
|
||||
assertThat(first).isEqualTo(second);
|
||||
assertThat(first.getWidth()).isEqualTo(first.getHeight()).isGreaterThan(21);
|
||||
assertThat(first.getEnclosingRectangle()).isNotNull();
|
||||
assertThat(first.getEnclosingRectangle()[0]).isGreaterThanOrEqualTo(4);
|
||||
assertThat(first.getEnclosingRectangle()[1]).isGreaterThanOrEqualTo(4);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package com.baiyue.print.renderer;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PdfUnitsTest {
|
||||
@Test
|
||||
void convertsOneHundredMillimetersToExactPdfPoints() {
|
||||
assertThat(PdfUnits.mmToPt(100)).isCloseTo(283.46457f, within(0.0001f));
|
||||
}
|
||||
|
||||
@Test
|
||||
void convertsTopDownCoordinatesToPdfCoordinates() {
|
||||
PdfUnits units = new PdfUnits(100);
|
||||
|
||||
assertThat(units.topDownMmToPdfY(0)).isCloseTo(PdfUnits.mmToPt(100), within(0.0001f));
|
||||
assertThat(units.topDownMmToPdfY(100)).isCloseTo(0f, within(0.0001f));
|
||||
}
|
||||
|
||||
private static org.assertj.core.data.Offset<Float> within(float value) {
|
||||
return org.assertj.core.data.Offset.offset(value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.baiyue.print.dto.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.renderer.InboundMarkV1Renderer;
|
||||
import com.baiyue.print.renderer.PdfQrRenderer;
|
||||
import com.baiyue.print.renderer.PdfUnits;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.pdmodel.PDDocument;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class InboundMarkPdfServiceGenerationTest {
|
||||
@Test
|
||||
void generatesOneStrictHundredMillimeterPagePerLabelInRequestOrder() throws IOException {
|
||||
PrintPdfProperties properties = new PrintPdfProperties();
|
||||
PdfQrRenderer qrRenderer = new PdfQrRenderer();
|
||||
InboundMarkPdfService service = new InboundMarkPdfService(
|
||||
properties,
|
||||
new PdfGenerationLimiter(properties),
|
||||
new InboundMarkV1Renderer(qrRenderer),
|
||||
qrRenderer);
|
||||
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
|
||||
"INBOUND_MARK_V1",
|
||||
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100.0")),
|
||||
"PCHK2608040174.pdf",
|
||||
List.of(label(1), label(2)));
|
||||
|
||||
InboundMarkPdfService.GeneratedPdf generated = service.generate(request);
|
||||
|
||||
assertThat(generated.fileName()).isEqualTo("PCHK2608040174.pdf");
|
||||
assertThat(generated.pageCount()).isEqualTo(2);
|
||||
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
|
||||
try (PDDocument document = Loader.loadPDF(generated.content())) {
|
||||
assertThat(document.getNumberOfPages()).isEqualTo(2);
|
||||
document.getPages().forEach(page -> {
|
||||
assertThat(page.getRotation()).isZero();
|
||||
assertThat(page.getMediaBox().getWidth())
|
||||
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
|
||||
assertThat(page.getMediaBox().getHeight())
|
||||
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
|
||||
});
|
||||
|
||||
PDFTextStripper stripper = new PDFTextStripper();
|
||||
stripper.setStartPage(1);
|
||||
stripper.setEndPage(1);
|
||||
assertThat(stripper.getText(document))
|
||||
.contains("PCHK2608040174", "MCC1", "CTNS:2-1", "洛杉矶普船海卡",
|
||||
"PCHK2608040174#2-1", "MADE IN CHINA");
|
||||
stripper.setStartPage(2);
|
||||
stripper.setEndPage(2);
|
||||
assertThat(stripper.getText(document)).contains("CTNS:2-2", "PCHK2608040174#2-2");
|
||||
}
|
||||
}
|
||||
|
||||
private InboundMarkPdfDtos.Label label(int sequence) {
|
||||
return new InboundMarkPdfDtos.Label(
|
||||
"PCHK2608040174",
|
||||
"MCC1",
|
||||
"CTNS:2-" + sequence,
|
||||
"洛杉矶普船海卡",
|
||||
"PCHK2608040174#2-" + sequence);
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
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.InboundMarkPdfDtos;
|
||||
import com.baiyue.print.renderer.InboundMarkV1Renderer;
|
||||
import com.baiyue.print.renderer.PdfQrRenderer;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class InboundMarkPdfServiceValidationTest {
|
||||
private InboundMarkPdfService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
PrintPdfProperties properties = new PrintPdfProperties();
|
||||
service = new InboundMarkPdfService(
|
||||
properties,
|
||||
mock(PdfGenerationLimiter.class),
|
||||
mock(InboundMarkV1Renderer.class),
|
||||
mock(PdfQrRenderer.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsUnsupportedPageSizeBeforeRendering() {
|
||||
InboundMarkPdfDtos.Request request = request(label("PCHK2608040174#19-1"));
|
||||
request = new InboundMarkPdfDtos.Request(
|
||||
request.templateCode(),
|
||||
new InboundMarkPdfDtos.PageSpec(new BigDecimal("99"), new BigDecimal("100")),
|
||||
request.downloadFileName(), request.labels());
|
||||
|
||||
assertValidationFailure(request, "pageSpec.widthMm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMoreThanTwoThousandPages() {
|
||||
InboundMarkPdfDtos.Label label = label("PCHK2608040174#19-1");
|
||||
InboundMarkPdfDtos.Request request = request(java.util.Collections.nCopies(2001, label));
|
||||
|
||||
assertValidationFailure(request, "2000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsControlCharactersInDisplayText() {
|
||||
InboundMarkPdfDtos.Label label = new InboundMarkPdfDtos.Label(
|
||||
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶\n普船海卡", "PCHK2608040174#19-1");
|
||||
|
||||
assertValidationFailure(request(label), "labels[0].routeText");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsQrContentLongerThanThirtyFourAsciiCharacters() {
|
||||
assertValidationFailure(request(label("A".repeat(35))), "34");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsNonAsciiQrContent() {
|
||||
assertValidationFailure(request(label("PCHK#中文")), "printable ASCII");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPathLikeDownloadFileName() {
|
||||
InboundMarkPdfDtos.Request base = request(label("PCHK2608040174#19-1"));
|
||||
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
|
||||
base.templateCode(), base.pageSpec(), "folder/labels.pdf", base.labels());
|
||||
|
||||
assertValidationFailure(request, "reserved character");
|
||||
}
|
||||
|
||||
private void assertValidationFailure(InboundMarkPdfDtos.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 InboundMarkPdfDtos.Request request(InboundMarkPdfDtos.Label label) {
|
||||
return request(List.of(label));
|
||||
}
|
||||
|
||||
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
|
||||
return new InboundMarkPdfDtos.Request(
|
||||
"INBOUND_MARK_V1",
|
||||
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100.0"), new BigDecimal("100")),
|
||||
"labels.pdf",
|
||||
labels);
|
||||
}
|
||||
|
||||
private InboundMarkPdfDtos.Label label(String qrContent) {
|
||||
return new InboundMarkPdfDtos.Label(
|
||||
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡", qrContent);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
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 java.time.Duration;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
class PdfGenerationLimiterTest {
|
||||
@Test
|
||||
void rejectsWhenAllPermitsAreInUseAndReleasesPermitOnClose() {
|
||||
PrintPdfProperties properties = new PrintPdfProperties();
|
||||
properties.setMaxConcurrentGenerations(1);
|
||||
properties.setAcquireTimeout(Duration.ZERO);
|
||||
PdfGenerationLimiter limiter = new PdfGenerationLimiter(properties);
|
||||
|
||||
PdfGenerationLimiter.Permit permit = limiter.acquire();
|
||||
assertThatThrownBy(limiter::acquire)
|
||||
.isInstanceOfSatisfying(BizException.class,
|
||||
exception -> org.assertj.core.api.Assertions.assertThat(exception.getResultCode())
|
||||
.isEqualTo(ResultCode.TOO_MANY_REQUESTS));
|
||||
|
||||
permit.close();
|
||||
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
|
||||
// A second acquisition proves that close returned the only permit.
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user