fix: keep pallet labels on A4 templates

This commit is contained in:
2026-09-20 14:05:00 +08:00
parent ee97565f67
commit 493d62e04c
15 changed files with 269 additions and 27 deletions
+2 -1
View File
@@ -5,7 +5,7 @@
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
- `INBOUND_MARK_V1` 箱唛不接收或展示 SKU(包括一件代发箱唛),二维码仅编码稳定箱码;SKU 明细由一件代发 A4 托盘信息单展示,不能重新加入箱唛版面。
- 当前支持 `INBOUND_MARK_V1`(100×100 mm)、`FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm),以及纵向 A4 的 `DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1`、`BILL_OF_LADING_CONFIRMATION_A4_V1`、`DISPATCH_BILL_OF_LADING_A4_V2`。FBA 模板由国内和海外统一使用,每托一页,只展示 SCK8 等 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号;141 等内部编号保留在托盘号中。一件代发每托一页,展示托盘 Code 128、目的仓、最多 10 行入仓码/客户昵称和最多 34 行 SKU,不展示状态、重量或体积。新尺寸或布局必须使用新模板编码。
- 当前只有箱唛 `INBOUND_MARK_V1` 使用 100×100 mm;托盘标不得复用该模板。横向 A4 托盘模板包括 `FBA_PALLET_A4_LANDSCAPE_V1` 与 `PALLET_A4_LANDSCAPE_V1`,纵向 A4 包括 `DROPSHIP_PALLET_A4_V2`,其他 A4 单据为 `SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1`、`BILL_OF_LADING_CONFIRMATION_A4_V1`、`DISPATCH_BILL_OF_LADING_A4_V2`。FBA 模板由国内和海外统一使用,每托一页,只展示 SCK8 等 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号;141 等内部编号保留在托盘号中。一件代发展示托盘 Code 128、目的仓、每页最多 10 行入仓码/客户昵称和 34 行 SKU,超量由调用方按同一托盘自动续页,不展示状态、重量或体积。新尺寸或布局必须使用新模板编码。
## 快速索引
@@ -13,6 +13,7 @@
|---|---|
| 调用方接入指南 | `README.md` |
| 国内/海外统一 FBA 托盘标 | `dto/FbaPalletPdfDtos.java`、`service/FbaPalletPdfService.java`、`renderer/FbaPalletRenderer.java`;内部 `/internal/print/v1/pdfs/fba-pallet-labels`,回归 `src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java` |
| 非 FBA 通用托盘标 | `dto/GenericPalletPdfDtos.java`、`service/GenericPalletPdfService.java`、`renderer/GenericPalletRenderer.java`;内部 `/internal/print/v1/pdfs/pallet-a4-labels` |
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) |
@@ -5,7 +5,9 @@ import jakarta.annotation.Resource;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.FbaPalletPdfDtos;
import com.baiyue.print.dto.GenericPalletPdfDtos;
import com.baiyue.print.service.FbaPalletPdfService;
import com.baiyue.print.service.GenericPalletPdfService;
import com.baiyue.print.dto.DispatchBolPdfDtos;
import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
@@ -39,6 +41,7 @@ public class InternalPdfController {
@Resource private DispatchBolPdfService dispatchBolPdfService;
@Resource private DropshipPalletA4PdfService dropshipPalletA4PdfService;
@Resource private FbaPalletPdfService fbaPalletPdfService;
@Resource private GenericPalletPdfService genericPalletPdfService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping(value = "/fba-pallet-labels", consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -54,6 +57,19 @@ public class InternalPdfController {
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
}
@PostMapping(value = "/pallet-a4-labels", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generatePalletA4Labels(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@Valid @RequestBody GenericPalletPdfDtos.Request request) {
tokenGuard.verify(internalToken);
GenericPalletPdfService.GeneratedPdf pdf = genericPalletPdfService.generate(request);
byte[] content = pdf.content();
return ResponseEntity.ok().cacheControl(CacheControl.noStore()).header(HttpHeaders.PRAGMA, "no-cache")
.header(HttpHeaders.CONTENT_DISPOSITION, ContentDisposition.attachment().filename(pdf.fileName(), StandardCharsets.UTF_8).build().toString())
.header("X-Content-Type-Options", "nosniff").header(PAGE_COUNT_HEADER, Integer.toString(pdf.pageCount()))
.contentType(MediaType.APPLICATION_PDF).contentLength(content.length).body(content);
}
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generateInboundMarks(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@@ -31,6 +31,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter {
private static final java.util.Set<String> PATHS = java.util.Set.of(
"/internal/print/v1/pdfs/inbound-marks",
"/internal/print/v1/pdfs/fba-pallet-labels",
"/internal/print/v1/pdfs/pallet-a4-labels",
"/internal/print/v1/pdfs/dropship-pallet-a4-documents",
"/internal/print/v1/pdfs/freight-a4-documents");
@@ -0,0 +1,13 @@
package com.baiyue.print.dto;
import java.math.BigDecimal;
import java.util.List;
public final class GenericPalletPdfDtos {
public static final String TEMPLATE = "PALLET_A4_LANDSCAPE_V1";
private GenericPalletPdfDtos() {}
public record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Label> labels) {}
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
public record Label(String heading, String reference, String palletCode) {}
}
@@ -0,0 +1,43 @@
package com.baiyue.print.renderer;
import com.baiyue.print.dto.GenericPalletPdfDtos;
import com.google.zxing.common.BitMatrix;
import jakarta.annotation.Resource;
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;
@Component
public class GenericPalletRenderer {
@Resource private PdfBarcodeRenderer barcodeRenderer;
public void renderPage(PDDocument pdf, PDFont font, GenericPalletPdfDtos.Label label, BitMatrix barcode)
throws IOException {
PDPage page = new PDPage(new PDRectangle(PdfUnits.mmToPt(297), PdfUnits.mmToPt(210)));
pdf.addPage(page);
PdfUnits units = new PdfUnits(210);
try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
centered(stream, units, font, label.heading(), 80, 43);
centered(stream, units, font, label.reference(), 30, 76);
barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(145),
PdfUnits.mmToPt(230), PdfUnits.mmToPt(34));
centered(stream, units, font, label.palletCode(), 58, 175);
}
}
private void centered(PDPageContentStream stream, PdfUnits units, PDFont font, String value,
float preferredSize, double centerY) throws IOException {
float unitWidth = font.getStringWidth(value) / 1000;
float size = Math.min(preferredSize, PdfUnits.mmToPt(267) / unitWidth);
stream.beginText();
stream.setFont(font, size);
stream.newLineAtOffset((PdfUnits.mmToPt(297) - unitWidth * size) / 2,
units.centeredTextBaseline(font, size, centerY));
stream.showText(value);
stream.endText();
}
}
@@ -0,0 +1,70 @@
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.GenericPalletPdfDtos;
import com.baiyue.print.renderer.GenericPalletRenderer;
import com.baiyue.print.renderer.PdfBarcodeRenderer;
import jakarta.annotation.Resource;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.math.BigDecimal;
import java.util.Locale;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Service;
@Service
public class GenericPalletPdfService {
@Resource private PrintPdfProperties properties;
@Resource private PdfGenerationLimiter limiter;
@Resource private GenericPalletRenderer renderer;
@Resource private PdfBarcodeRenderer barcodeRenderer;
public GeneratedPdf generate(GenericPalletPdfDtos.Request request) {
validate(request);
try (PdfGenerationLimiter.Permit ignored = limiter.acquire(); PDDocument pdf = new PDDocument();
InputStream fontStream = new ClassPathResource(properties.getFontResource()).getInputStream();
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
PDType0Font font = PDType0Font.load(pdf, fontStream, true);
for (var label : request.labels()) {
renderer.renderPage(pdf, font, label, barcodeRenderer.encode(label.palletCode()));
}
pdf.save(output);
byte[] content = output.toByteArray();
if (content.length > properties.getMaxOutputBytes()) invalid("生成的 PDF 超过输出大小上限");
return new GeneratedPdf(content, request.downloadFileName(), request.labels().size());
} catch (IOException exception) {
throw new BizException(ResultCode.INTERNAL_ERROR, "PDF 生成失败");
}
}
private void validate(GenericPalletPdfDtos.Request request) {
if (request == null || !GenericPalletPdfDtos.TEMPLATE.equals(request.templateCode())) invalid("templateCode is not supported");
if (request.pageSpec() == null || request.pageSpec().widthMm() == null || request.pageSpec().heightMm() == null
|| request.pageSpec().widthMm().compareTo(new BigDecimal("297")) != 0
|| request.pageSpec().heightMm().compareTo(new BigDecimal("210")) != 0) invalid("pageSpec must be landscape A4 (297 x 210 mm)");
String fileName = request.downloadFileName();
if (fileName == null || fileName.isBlank() || fileName.length() > 120
|| !fileName.toLowerCase(Locale.ROOT).endsWith(".pdf")
|| fileName.matches(".*[\\\\/:*?\"<>|].*")
|| fileName.codePoints().anyMatch(Character::isISOControl)) invalid("downloadFileName is invalid");
if (request.labels() == null || request.labels().isEmpty() || request.labels().size() > properties.getMaxPages()) invalid("labels count is invalid");
for (var label : request.labels()) {
if (label == null || !text(label.heading(), 80) || !text(label.reference(), 80)) invalid("heading and reference are required");
if (label.palletCode() == null || label.palletCode().isBlank()
|| !label.palletCode().equals(label.palletCode().strip())
|| !label.palletCode().matches("[\\x20-\\x7E]{1,80}")) invalid("palletCode must contain 1-80 printable ASCII characters without surrounding spaces");
}
}
private boolean text(String value, int max) {
return value != null && !value.isBlank() && value.codePointCount(0, value.length()) <= max
&& value.codePoints().noneMatch(Character::isISOControl);
}
private void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); }
public record GeneratedPdf(byte[] content, String fileName, int pageCount) {}
}
@@ -4,6 +4,24 @@ info:
version: 1.0.0
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
paths:
/internal/print/v1/pdfs/pallet-a4-labels:
post:
summary: Generate non-FBA pallet labels as landscape A4 pages
operationId: generatePalletA4Labels
security: [{ internalServiceToken: [] }]
parameters: [{ $ref: '#/components/parameters/RequestId' }]
requestBody:
required: true
content: { application/json: { schema: { $ref: '#/components/schemas/GenericPalletPdfRequest' } } }
responses:
'200':
description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
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' } } }
/internal/print/v1/pdfs/fba-pallet-labels:
post:
summary: Generate shared domestic and overseas FBA pallet labels as landscape A4 pages
@@ -176,6 +194,29 @@ components:
schema:
type: string
schemas:
GenericPalletPdfRequest:
type: object
required: [templateCode, pageSpec, downloadFileName, labels]
properties:
templateCode: { type: string, enum: [PALLET_A4_LANDSCAPE_V1] }
pageSpec:
type: object
required: [widthMm, heightMm]
properties:
widthMm: { type: number, enum: [297] }
heightMm: { type: number, enum: [210] }
downloadFileName: { type: string, minLength: 4, maxLength: 120 }
labels:
type: array
minItems: 1
maxItems: 2000
items:
type: object
required: [heading, reference, palletCode]
properties:
heading: { type: string, minLength: 1, maxLength: 80 }
reference: { type: string, minLength: 1, maxLength: 80 }
palletCode: { type: string, minLength: 1, maxLength: 80, pattern: '^[\x20-\x7E]+$' }
FbaPalletPdfRequest:
type: object
required: [templateCode, pageSpec, downloadFileName, labels]
@@ -11,6 +11,7 @@ import com.baiyue.print.service.DispatchBolPdfService;
import com.baiyue.print.service.DropshipPalletA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import com.baiyue.print.service.FbaPalletPdfService;
import com.baiyue.print.service.GenericPalletPdfService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@@ -52,6 +53,8 @@ class InternalPdfControllerTest {
private DropshipPalletA4PdfService dropshipPalletA4PdfService;
@MockBean
private FbaPalletPdfService fbaPalletPdfService;
@MockBean
private GenericPalletPdfService genericPalletPdfService;
@Resource
private InternalPdfController controller;
private MockMvc mockMvc;
@@ -232,12 +235,14 @@ class InternalPdfControllerTest {
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
.contains("/internal/print/v1/pdfs/dropship-pallet-a4-documents:")
.contains("/internal/print/v1/pdfs/pallet-a4-labels:")
.contains("application/pdf:")
.contains("application/json:")
.contains("name: X-Internal-Service-Token")
.contains("SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1:")
.contains("BILL_OF_LADING_CONFIRMATION_A4_V1:")
.contains("DROPSHIP_PALLET_A4_V2")
.contains("PALLET_A4_LANDSCAPE_V1")
.contains("additionalProperties: false")
.contains("minItems: 1")
.contains("required: [marksAndNos, quantity, packaging, descriptionOfGoods, grossWeight, measurement]")
@@ -73,6 +73,16 @@ class PrintRequestSizeFilterTest {
assertTooLarge(response);
}
@Test
void capsGenericPalletRequestsBeforeDeserialization() throws Exception {
var response = new MockHttpServletResponse();
AtomicReference<Boolean> called = new AtomicReference<>(false);
filter.doFilter(request("12345678901", true, "/internal/print/v1/pdfs/pallet-a4-labels"),
response, (ignoredRequest, ignoredResponse) -> called.set(true));
assertThat(called).hasValue(false);
assertTooLarge(response);
}
@Test
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest() {
@@ -0,0 +1,43 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.GenericPalletPdfDtos;
import com.baiyue.print.renderer.GenericPalletRenderer;
import com.baiyue.print.renderer.PdfBarcodeRenderer;
import com.baiyue.print.renderer.PdfUnits;
import java.math.BigDecimal;
import java.nio.file.Files;
import java.nio.file.Path;
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;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(classes = {GenericPalletPdfService.class, PrintPdfProperties.class,
PdfGenerationLimiter.class, GenericPalletRenderer.class, PdfBarcodeRenderer.class})
class GenericPalletPdfServiceGenerationTest {
@jakarta.annotation.Resource private GenericPalletPdfService service;
@Test
void generatesLandscapeA4WithoutUsingTheCartonMarkTemplate() throws Exception {
var generated = service.generate(new GenericPalletPdfDtos.Request(
GenericPalletPdfDtos.TEMPLATE,
new GenericPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")),
"pallet.pdf", List.of(new GenericPalletPdfDtos.Label(
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001"))));
Files.createDirectories(Path.of("target"));
Files.write(Path.of("target", "generic-pallet-a4-preview.pdf"), generated.content());
try (PDDocument pdf = Loader.loadPDF(generated.content())) {
assertThat(pdf.getNumberOfPages()).isEqualTo(1);
assertThat(pdf.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), org.assertj.core.data.Offset.offset(.002f));
assertThat(pdf.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), org.assertj.core.data.Offset.offset(.002f));
assertThat(new PDFTextStripper().getText(pdf)).contains(
"Los Angeles Warehouse", "Transfer WTR-1", "PALLET-001");
}
}
}