打印服务

This commit is contained in:
2026-08-04 18:54:22 +08:00
parent b7d36d4766
commit dc2f844467
44 changed files with 2576 additions and 110 deletions
+19 -7
View File
@@ -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
View File
@@ -0,0 +1,188 @@
# Print PDF 服务接入指南
本文面向需要生成入仓标签 PDF 的业务服务。Print 是无数据库的内部渲染服务:调用方提交完整标签数据,Print 同步返回一次性 PDF,不保存任务、文件或业务数据。
完整机器可读契约见 [`src/main/resources/openapi/print-internal-v1.yaml`](src/main/resources/openapi/print-internal-v1.yaml),版式与技术决策见 [`../docs/2026-08-04-无状态PDF打印服务设计.md`](../docs/2026-08-04-无状态PDF打印服务设计.md)。
## 1. 调用边界
- 服务发现名:`print-service`。
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
- 本地直连:`http://127.0.0.1:9207`。
- 请求必须携带 `X-Internal-Service-Token`,值使用各服务已有的 `baiyue.security.internal-service-token` 配置。
- 不把该接口加入 Gateway,也不允许 Admin 浏览器直接携带内部令牌访问 Print。
- Admin 请求业务服务;业务服务完成 JWT、租户、权限和数据范围校验,组装完整 `labels[]` 后调用 Print,再把 PDF 响应转发给浏览器。
调用方不得要求 Print 查询订单、补箱号、排序或修正数据。数组顺序就是 PDF 页序,每个数组元素生成一页。
## 2. 请求契约
```http
POST /internal/print/v1/pdfs/inbound-marks HTTP/1.1
Content-Type: application/json
Accept: application/pdf, application/json
X-Internal-Service-Token: <shared-token>
X-Request-Id: <optional-correlation-id>
```
```json
{
"templateCode": "INBOUND_MARK_V1",
"pageSpec": {
"widthMm": 100,
"heightMm": 100
},
"downloadFileName": "PCHK2608040174-19件-MCC1.pdf",
"labels": [
{
"topCode": "PCHK2608040174",
"warehouseText": "MCC1",
"cartonText": "CTNS:19-1",
"routeText": "洛杉矶普船海卡",
"qrContent": "PCHK2608040174#19-1"
},
{
"topCode": "PCHK2608040174",
"warehouseText": "MCC1",
"cartonText": "CTNS:19-2",
"routeText": "洛杉矶普船海卡",
"qrContent": "PCHK2608040174#19-2"
}
]
}
```
| 字段 | 必填 | 规则 |
|---|---|---|
| `templateCode` | 是 | 固定 `INBOUND_MARK_V1` |
| `pageSpec.widthMm` | 是 | 固定数值 `100` |
| `pageSpec.heightMm` | 是 | 固定数值 `100` |
| `downloadFileName` | 否 | 最多 120 字符,必须以 `.pdf` 结尾,不得含路径或文件系统保留字符 |
| `labels` | 是 | 1-2000 项;一个元素对应一页,按数组顺序生成 |
| `labels[].topCode` | 是 | 非空、单行、无控制字符 |
| `labels[].warehouseText` | 是 | 非空、单行、无控制字符 |
| `labels[].cartonText` | 是 | 最终展示文案;Print 不解析箱号 |
| `labels[].routeText` | 是 | 最终展示文案;非空、单行、无控制字符 |
| `labels[].qrContent` | 是 | 1-34 个可打印 ASCII 字符 |
普通展示字段首版不限制业务字符数,也不会自动换行、缩小或截断。调用方负责保证文案适合固定版式。
未传 `downloadFileName` 时,服务使用 `inbound-marks-{requestId}.pdf`。请求体最大 2 MiB,PDF 最大 32 MiB。
## 3. 响应处理
调用方必须同时判断 HTTP 状态和 `Content-Type`,不能只判断 HTTP 200:
| 场景 | HTTP | Content-Type | 处理方式 |
|---|---:|---|---|
| 生成成功 | 200 | `application/pdf` | 读取二进制并转发或下载 |
| 参数、鉴权、并发或生成失败 | 200 | `application/json` | 按 `ApiResponse.code` 处理 |
| 请求体超过 2 MiB | 413 | `application/json` | 缩小请求,不要重试相同内容 |
成功响应包含:
- `Content-Disposition`:下载文件名;
- `Content-Length`:PDF 字节数;
- `X-Print-Page-Count`:实际页数;
- `X-Request-Id`:请求链路标识;
- `Cache-Control: no-store`、`Pragma: no-cache`。
常见错误码:
| code | 含义 | 调用方建议 |
|---|---|---|
| `A0400` | JSON 无法解析或请求体过大 | 修正请求;HTTP 413 时缩小请求体 |
| `A0403` | 内部令牌缺失或错误 | 检查共享令牌配置,不向浏览器暴露令牌 |
| `A0422` | 字段、模板、尺寸、页数或字符规则不合法 | 修正业务服务组装的数据 |
| `A0429` | PDF 生成并发槽位已满 | 短暂退避后有限重试,避免立即并发重放 |
| `S0500` | PDF 生成失败或超过输出上限 | 记录 `requestId/traceId` 并告警,不返回半个 PDF |
## 4. OpenFeign 接入示例
调用方自行维护请求 DTO,不依赖 Print 模块的 Java 实现类。跨服务事实来源是 OpenAPI。
```java
@FeignClient(
name = "print-service",
contextId = "printPdfClient",
url = "${baiyue.clients.print.url:}",
path = "/internal/print/v1/pdfs")
public interface PrintPdfClient {
@PostMapping(value = "/inbound-marks", consumes = "application/json")
ResponseEntity<byte[]> generate(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestHeader(value = "X-Request-Id", required = false) String requestId,
@RequestBody InboundMarkPdfRequest request);
record InboundMarkPdfRequest(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<Label> labels) {}
record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
record Label(
String topCode,
String warehouseText,
String cartonText,
String routeText,
String qrContent) {}
}
```
调用封装必须区分 PDF 与 JSON:
```java
ResponseEntity<byte[]> response = printPdfClient.generate(internalToken, requestId, request);
MediaType contentType = response.getHeaders().getContentType();
byte[] body = response.getBody() == null ? new byte[0] : response.getBody();
if (contentType != null && MediaType.APPLICATION_PDF.isCompatibleWith(contentType)) {
return ResponseEntity.ok()
.headers(copySafeDownloadHeaders(response.getHeaders()))
.contentType(MediaType.APPLICATION_PDF)
.body(body);
}
ApiResponse<?> error = objectMapper.readValue(body, ApiResponse.class);
throw mapPrintError(error, requestId);
```
`copySafeDownloadHeaders` 只转发 `Content-Disposition`、`Content-Length`、`Cache-Control`、`Pragma`、`X-Print-Page-Count` 和链路标识,不转发内部令牌。由于业务错误也使用 HTTP 200,Feign fallback 不能替代 `Content-Type + ApiResponse.code` 判断。HTTP 413 会进入 Feign 的非 2xx 异常流程,调用方应在 `FeignException` 中保留状态码、JSON body、`requestId` 和 `traceId`。
调用方建议为该客户端单独配置超时:
```yaml
baiyue:
clients:
print:
url: ${PRINT_SERVICE_URL:}
spring:
cloud:
openfeign:
client:
config:
printPdfClient:
connect-timeout: 3000
read-timeout: 30000
```
`url` 为空时通过 Nacos 的 `print-service` 发现;本地单独联调可设置 `PRINT_SERVICE_URL=http://127.0.0.1:9207`。
## 5. 重试、并发与日志
- Print 默认最多同时生成 4 份 PDF,等待槽位 1 秒后返回 `A0429`。
- 不建议对该 POST 做无限或无退避自动重试;超时或 `A0429` 最多有限重试,并加入退避和抖动。
- 单次最多 2000 页。大批量请求应设置不低于 30 秒的读取超时,并避免调用方自身重复复制 PDF 字节。
- 浏览器下载场景由业务服务原样转发 PDF,不要把 PDF Base64 塞入 JSON,也不要为了“一次性下载”写入数据库。
- 日志记录模板、页数、耗时、PDF 字节数、`requestId`、`traceId` 和错误码;不要记录内部令牌、完整 `labels[]`、二维码内容或 PDF 二进制。
## 6. 联调检查清单
1. `GET /actuator/health` 返回 `UP`。
2. 使用正确内部令牌提交 1 页,响应为 `application/pdf`。
3. 检查页面为严格 `100mm x 100mm`,`X-Print-Page-Count` 与 `labels.length` 一致。
4. 提交多页并检查页序与数组顺序一致。
5. 分别验证错误令牌、非法尺寸、空数组、超过 2000 页和超过 2 MiB 请求体。
6. Admin 下载链路不得在浏览器请求或日志中出现 `X-Internal-Service-Token`。
+10 -6
View File
@@ -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}}
+12
View File
@@ -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:
-62
View File
@@ -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.
+93
View File
@@ -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);
}
}
@@ -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.
}
}
}