This commit is contained in:
2026-09-16 12:02:38 +08:00
parent de29e639e4
commit 912feca56f
11 changed files with 275 additions and 17 deletions
@@ -4,6 +4,8 @@ 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.service.FbaPalletPdfService;
import com.baiyue.print.dto.DispatchBolPdfDtos;
import com.baiyue.print.dto.DropshipPalletA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
@@ -36,8 +38,22 @@ public class InternalPdfController {
@Resource private FreightA4PdfService freightA4PdfService;
@Resource private DispatchBolPdfService dispatchBolPdfService;
@Resource private DropshipPalletA4PdfService dropshipPalletA4PdfService;
@Resource private FbaPalletPdfService fbaPalletPdfService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping(value = "/fba-pallet-labels", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generateFbaPalletLabels(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@Valid @RequestBody FbaPalletPdfDtos.Request request) {
tokenGuard.verify(internalToken);
FbaPalletPdfService.GeneratedPdf pdf = fbaPalletPdfService.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,
@@ -30,6 +30,7 @@ import org.springframework.web.filter.OncePerRequestFilter;
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/freight-a4-documents");
@Resource private PrintPdfProperties properties;
@@ -0,0 +1,14 @@
package com.baiyue.print.dto;
import java.math.BigDecimal;
import java.util.List;
public final class FbaPalletPdfDtos {
public static final String TEMPLATE = "FBA_PALLET_A4_LANDSCAPE_V1";
private FbaPalletPdfDtos() {}
public record Request(String templateCode, PageSpec pageSpec, String downloadFileName, List<Label> labels) {}
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {}
/** Barcode and visible pallet number always use the same authoritative code. */
public record Label(String fbaWarehouseCode, String palletCode) {}
}
@@ -0,0 +1,44 @@
package com.baiyue.print.renderer;
import com.baiyue.print.dto.FbaPalletPdfDtos;
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;
/** Shared domestic/overseas FBA label: destination, Code 128, full pallet code. */
@Component
public class FbaPalletRenderer {
@Resource private PdfBarcodeRenderer barcodeRenderer;
public void renderPage(PDDocument pdf, PDFont font, FbaPalletPdfDtos.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)) {
stream.setNonStrokingColor(0, 0, 0);
centered(stream, units, font, label.fbaWarehouseCode(), 96, 49);
barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(140),
PdfUnits.mmToPt(230), PdfUnits.mmToPt(55));
centered(stream, units, font, label.palletCode(), 64, 174);
}
}
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,68 @@
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.FbaPalletPdfDtos;
import com.baiyue.print.renderer.FbaPalletRenderer;
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 FbaPalletPdfService {
@Resource private PrintPdfProperties properties;
@Resource private PdfGenerationLimiter limiter;
@Resource private FbaPalletRenderer renderer;
@Resource private PdfBarcodeRenderer barcodeRenderer;
public GeneratedPdf generate(FbaPalletPdfDtos.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(FbaPalletPdfDtos.Request request) {
if (request == null || !FbaPalletPdfDtos.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) invalid("label is required");
// Reject malformed identifiers instead of trimming, truncating or substituting scan identities.
if (label.fbaWarehouseCode() == null || !label.fbaWarehouseCode().matches("[A-Z0-9][A-Z0-9-]{0,31}")) invalid("fbaWarehouseCode must be an FBA warehouse code such as SCK8");
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 void invalid(String message) { throw new BizException(ResultCode.VALIDATION_FAILED, message); }
public record GeneratedPdf(byte[] content, String fileName, int pageCount) {}
}