fix: keep pallet labels on A4 templates
This commit is contained in:
+2
-1
@@ -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() {
|
||||
|
||||
+43
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user