This commit is contained in:
2026-08-06 11:49:51 +08:00
parent a11e47fe3b
commit 7dd92f1c84
91 changed files with 6233 additions and 174 deletions
@@ -3,7 +3,9 @@ package com.baiyue.print.controller;
import jakarta.annotation.Resource;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
@@ -27,6 +29,7 @@ public class InternalPdfController {
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
@Resource private InboundMarkPdfService pdfService;
@Resource private FreightA4PdfService freightA4PdfService;
@Resource private InternalServiceTokenGuard tokenGuard;
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
@@ -58,4 +61,34 @@ public class InternalPdfController {
return response.body(content);
}
@PostMapping(value = "/freight-a4-documents", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generateFreightA4Documents(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@Valid @RequestBody FreightA4PdfDtos.Request request,
HttpServletRequest servletRequest) {
tokenGuard.verify(internalToken);
String requestId = CorrelationContext.requestId(servletRequest);
FreightA4PdfDtos.Request effectiveRequest = request;
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
effectiveRequest = new FreightA4PdfDtos.Request(request.templateCode(), request.pageSpec(),
"freight-a4-documents-" + requestId + ".pdf", request.documents());
}
FreightA4PdfService.GeneratedPdf pdf = freightA4PdfService.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);
}
}
@@ -28,7 +28,9 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class PrintRequestSizeFilter extends OncePerRequestFilter {
private static final String PATH = "/internal/print/v1/pdfs/inbound-marks";
private static final java.util.Set<String> PATHS = java.util.Set.of(
"/internal/print/v1/pdfs/inbound-marks",
"/internal/print/v1/pdfs/freight-a4-documents");
@Resource private PrintPdfProperties properties;
@Resource private ObjectMapper objectMapper;
@@ -36,7 +38,7 @@ public class PrintRequestSizeFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATHS.contains(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
@@ -0,0 +1,47 @@
package com.baiyue.print.dto;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
/** A4 sea-freight document PDF request contract. */
public final class FreightA4PdfDtos {
public static final String SHIPPING_INSTRUCTION_TEMPLATE = "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1";
public static final String BILL_CONFIRMATION_TEMPLATE = "BILL_OF_LADING_CONFIRMATION_A4_V1";
private FreightA4PdfDtos() {
}
public record Request(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<Document> documents
) {
}
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
}
public record Document(
Map<String, String> fields,
List<CargoRow> cargoRows
) {
public String field(String key) {
if (fields == null) {
return "";
}
return fields.getOrDefault(key, "");
}
}
public record CargoRow(
String marksAndNos,
String quantity,
String packaging,
String descriptionOfGoods,
String grossWeight,
String measurement
) {
}
}
@@ -0,0 +1,333 @@
package com.baiyue.print.renderer;
import com.baiyue.print.dto.FreightA4PdfDtos;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
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;
/** Fixed A4 renderers for sea-freight documents. */
@Component
public class FreightA4Renderer {
public static final double PAGE_WIDTH_MM = 210d;
public static final double PAGE_HEIGHT_MM = 297d;
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 SHIPPING_TEXT = 6.2f;
private static final float SHIPPING_SMALL = 5.8f;
private static final float BILL_TEXT = 7.2f;
private static final float BILL_SMALL = 6.8f;
public void renderPage(PDDocument document, PDFont font, String templateCode,
FreightA4PdfDtos.Document data) 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);
stream.setStrokingColor(0, 0, 0);
if (FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE.equals(templateCode)) {
renderShippingInstruction(stream, units, font, data);
} else if (FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE.equals(templateCode)) {
renderBillConfirmation(stream, units, font, data);
} else {
throw new IllegalArgumentException("unsupported A4 freight template");
}
}
}
private void renderShippingInstruction(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
text(stream, units, font, "Sea Freight Shipping Instructions", 12, 15, 18);
text(stream, units, font, "To: " + data.field("toCompany"), SHIPPING_TEXT, 15, 24);
rect(stream, 15, 29, 180, 250);
line(stream, 113, 29, 113, 155);
line(stream, 15, 64, 113, 64);
line(stream, 15, 99, 113, 99);
line(stream, 15, 134, 113, 134);
line(stream, 15, 147, 113, 147);
line(stream, 64, 134, 64, 155);
wrapped(stream, units, font, "Shipper(Full Name & Address)(托运人):", SHIPPING_TEXT, 17, 33, 92, 4, 5);
wrapped(stream, units, font, data.field("shipper"), SHIPPING_TEXT, 17, 39, 92, 5, 5);
wrapped(stream, units, font, "Consignee(收货人):", SHIPPING_TEXT, 17, 68, 92, 4, 5);
wrapped(stream, units, font, data.field("consignee"), SHIPPING_TEXT, 17, 74, 92, 5, 5);
wrapped(stream, units, font, "Notify Party(通知人):", SHIPPING_TEXT, 17, 103, 92, 4, 5);
wrapped(stream, units, font, data.field("notifyParty"), SHIPPING_TEXT, 17, 109, 92, 4, 5);
text(stream, units, font, "Ocean Vessel/Voy(船名航次):", SHIPPING_TEXT, 17, 139);
text(stream, units, font, data.field("oceanVesselVoyage"), SHIPPING_TEXT, 17, 145.5);
text(stream, units, font, "Port of Loading(起运港):", SHIPPING_TEXT, 66, 139);
text(stream, units, font, data.field("portOfLoading"), SHIPPING_TEXT, 66, 145.5);
text(stream, units, font, "Part of Discharge(卸货港):", SHIPPING_TEXT, 17, 150);
text(stream, units, font, data.field("portOfDischarge"), SHIPPING_TEXT, 17, 154);
text(stream, units, font, "Place of Delivery(交货地):", SHIPPING_TEXT, 66, 150);
text(stream, units, font, data.field("placeOfDelivery"), SHIPPING_TEXT, 66, 154);
renderShippingRightPanel(stream, units, font, data);
renderShippingCargo(stream, units, font, data);
}
private void renderShippingRightPanel(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
line(stream, 113, 76, 195, 76);
line(stream, 113, 89, 195, 89);
line(stream, 113, 102, 195, 102);
for (double y : new double[]{109, 116, 123, 130, 137, 144, 151}) {
line(stream, 113, y, 195, y);
}
line(stream, 135, 76, 135, 102);
line(stream, 135, 82.5, 195, 82.5);
line(stream, 135, 95.5, 195, 95.5);
centered(stream, units, font, data.field("issuerNameCn"), 12, 154, 37);
centered(stream, units, font, data.field("issuerNameEn"), SHIPPING_TEXT, 154, 45);
wrapped(stream, units, font, data.field("issuerAddress"), SHIPPING_TEXT, 114, 52, 78, 2, 4.5);
text(stream, units, font, "邮编:" + data.field("issuerPostalCode"), SHIPPING_TEXT, 114, 59);
text(stream, units, font, "TEL:" + data.field("issuerTel") + " | FAX:" + data.field("issuerFax"),
5.2f, 114, 63.5);
centered(stream, units, font, "出口货物托运单", 12, 154, 72);
text(stream, units, font, "PIC(负责人):", SHIPPING_TEXT, 114, 80);
text(stream, units, font, data.field("picName"), SHIPPING_TEXT, 114, 86);
text(stream, units, font, "TEL(电话): " + data.field("picTel"), SHIPPING_TEXT, 136, 80);
text(stream, units, font, "FAX(传真): " + data.field("picFax"), SHIPPING_TEXT, 136, 87);
text(stream, units, font, "Document:", SHIPPING_TEXT, 114, 94);
text(stream, units, font, data.field("documentName"), SHIPPING_TEXT, 114, 100);
text(stream, units, font, "TEL(电话): " + data.field("documentTel"), SHIPPING_TEXT, 136, 94);
text(stream, units, font, "FAX(传真): " + data.field("documentFax"), SHIPPING_TEXT, 136, 101);
text(stream, units, font, "Job Nbr(委托单号): " + data.field("jobNumber"), SHIPPING_TEXT, 114, 107);
text(stream, units, font, "ETD(开航日期): " + data.field("etdDate"), SHIPPING_TEXT, 114, 114);
text(stream, units, font, "Service Term(运输条款):" + data.field("serviceTerm"), SHIPPING_TEXT, 114, 121);
text(stream, units, font, "Freight Term(运费条款):" + data.field("freightTerm"), SHIPPING_TEXT, 114, 128);
text(stream, units, font, "AP Code: " + data.field("apCode"), SHIPPING_TEXT, 114, 135);
text(stream, units, font, "AP Name: " + data.field("apName"), SHIPPING_TEXT, 114, 142);
text(stream, units, font, "Cargo Quantity(货量描述): " + data.field("cargoQuantity"), SHIPPING_TEXT, 114, 149);
text(stream, units, font, "T/S(可否转船): " + data.field("transshipment")
+ " Batch(可否分批): " + data.field("partialShipment"), SHIPPING_TEXT, 114, 153.5);
}
private void renderShippingCargo(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
double y = 155;
line(stream, 15, y, 195, y);
line(stream, 15, 166, 195, 166);
line(stream, 15, 229, 195, 229);
line(stream, 15, 237, 195, 237);
line(stream, 130, 237, 130, 279);
line(stream, 130, 259, 195, 259);
for (double x : new double[]{50, 88, 137, 166}) {
line(stream, x, 155, x, 166);
}
centered(stream, units, font, "Marks & Nos\n(标志和号码)", SHIPPING_TEXT, 32.5, 160);
centered(stream, units, font, "Quantity & Packaging\n(件数及包装)", SHIPPING_TEXT, 69, 160);
centered(stream, units, font, "Description of Goods(品名)", SHIPPING_TEXT, 112.5, 161);
centered(stream, units, font, "Gross Weight(Kilos)\n毛重(公斤)", SHIPPING_SMALL, 151.5, 160);
centered(stream, units, font, "Measurement(CBM)\n体积(立方米)", SHIPPING_SMALL, 180.5, 160);
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
text(stream, units, font, value(row.marksAndNos()), SHIPPING_TEXT, 17, 171);
text(stream, units, font, value(row.quantity()), SHIPPING_TEXT, 55, 171);
text(stream, units, font, value(row.packaging()), SHIPPING_TEXT, 64, 171);
wrapped(stream, units, font, value(row.descriptionOfGoods()), SHIPPING_TEXT, 88, 171, 47, 5, 5);
text(stream, units, font, value(row.grossWeight()), SHIPPING_TEXT, 147, 171);
text(stream, units, font, value(row.measurement()), SHIPPING_TEXT, 177, 171);
text(stream, units, font, "Total Packages(in words)(件数大写):" + data.field("totalPackagesInWords"),
SHIPPING_TEXT, 17, 234);
text(stream, units, font, "Ramark(备注):", SHIPPING_TEXT, 20, 245);
wrapped(stream, units, font, data.field("remark"), SHIPPING_TEXT, 20, 252, 105, 5, 5);
text(stream, units, font, "Carrier(船公司):", SHIPPING_TEXT, 135, 247);
wrapped(stream, units, font, data.field("carrier"), SHIPPING_TEXT, 135, 254, 55, 2, 5);
text(stream, units, font, "Booking Agent(订舱代理):", SHIPPING_TEXT, 135, 266);
wrapped(stream, units, font, data.field("bookingAgent"), SHIPPING_TEXT, 135, 273, 55, 2, 5);
text(stream, units, font, "打印于: " + data.field("printDate"), SHIPPING_TEXT, 149, 287);
}
private void renderBillConfirmation(PDPageContentStream stream, PdfUnits units, PDFont font,
FreightA4PdfDtos.Document data) throws IOException {
centered(stream, units, font, "提单确认件", 15, 105, 40);
wrapped(stream, units, font, data.field("shipper"), BILL_TEXT, 10, 64, 72, 5, 5);
wrapped(stream, units, font, data.field("consignee"), BILL_TEXT, 10, 95, 84, 5, 5);
wrapped(stream, units, font, data.field("notifyParty"), BILL_TEXT, 10, 126, 72, 3, 5);
text(stream, units, font, data.field("bookingNumber"), BILL_TEXT, 148, 64);
text(stream, units, font, "ENS/AMS 截单: " + data.field("ensAmsCutoffDate"), BILL_TEXT, 148, 73);
text(stream, units, font, "截关日期: " + data.field("customsCutoffDate"), BILL_TEXT, 148, 82);
text(stream, units, font, "预计开航日期: " + data.field("expectedDepartureDate"), BILL_TEXT, 148, 91);
text(stream, units, font, data.field("oceanVesselVoyage"), BILL_TEXT, 10, 155);
text(stream, units, font, data.field("portOfLoading"), BILL_TEXT, 61, 161);
text(stream, units, font, data.field("portOfDischarge"), BILL_TEXT, 10, 170);
text(stream, units, font, data.field("placeOfDelivery"), BILL_TEXT, 61, 170);
text(stream, units, font, data.field("finalDestination"), BILL_TEXT, 114, 155);
text(stream, units, font, data.field("serviceTerm"), BILL_TEXT, 128, 170);
FreightA4PdfDtos.CargoRow row = firstCargoRow(data);
text(stream, units, font, value(row.quantity()) + " " + value(row.packaging()), BILL_TEXT, 44, 179);
text(stream, units, font, "SAID TO CONTAIN(S)", BILL_TEXT, 74, 179);
text(stream, units, font, value(row.grossWeight()), BILL_TEXT, 156, 179);
text(stream, units, font, value(row.measurement()), BILL_TEXT, 183, 179);
text(stream, units, font, value(row.marksAndNos()), BILL_TEXT, 9, 187);
wrapped(stream, units, font, value(row.descriptionOfGoods()), BILL_SMALL, 73, 186, 83, 8, 4.25);
text(stream, units, font, data.field("freightTerm"), BILL_SMALL, 87, 221);
text(stream, units, font, data.field("shipperLoadCountSeal"), BILL_SMALL, 72, 227);
text(stream, units, font, data.field("containerSpec"), BILL_SMALL, 72, 232);
text(stream, units, font, "SHIPPED ON BOARD:", BILL_SMALL, 163, 195);
text(stream, units, font, data.field("shippedOnBoard"), BILL_SMALL, 169, 202);
centered(stream, units, font, data.field("draftText"), 16, 158, 225);
centered(stream, units, font, data.field("totalPackagesInWords"), BILL_SMALL, 105, 238);
text(stream, units, font, data.field("containerSummary"), BILL_SMALL, 10, 247);
wrapped(stream, units, font, data.field("notes"), BILL_SMALL, 10, 278, 130, 3, 5);
text(stream, units, font, data.field("issuePlace"), BILL_SMALL, 140, 288);
text(stream, units, font, data.field("issueDate"), BILL_SMALL, 168, 288);
}
private FreightA4PdfDtos.CargoRow firstCargoRow(FreightA4PdfDtos.Document data) {
if (data.cargoRows() == null || data.cargoRows().isEmpty() || data.cargoRows().get(0) == null) {
return new FreightA4PdfDtos.CargoRow("", "", "", "", "", "");
}
return data.cargoRows().get(0);
}
private void text(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
float fontSize, double xMm, double baselineMmFromTop) throws IOException {
String value = value(text).replace('\n', ' ');
if (value.isEmpty()) {
return;
}
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(PdfUnits.mmToPt(xMm), units.topDownMmToPdfY(baselineMmFromTop));
stream.showText(value);
stream.endText();
}
private void centered(PDPageContentStream stream, PdfUnits units, PDFont font, String text,
float fontSize, double centerXmm, double baselineMmFromTop) throws IOException {
String value = value(text);
if (value.isEmpty()) {
return;
}
String[] lines = value.split("\\n", -1);
double y = baselineMmFromTop - (lines.length - 1) * 2.2;
for (String line : lines) {
float width = font.getStringWidth(line) / 1000f * fontSize;
float x = PdfUnits.mmToPt(centerXmm) - width / 2f;
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(x, units.topDownMmToPdfY(y));
stream.showText(line);
stream.endText();
y += 5;
}
}
private void wrapped(PDPageContentStream stream, PdfUnits units, PDFont font, String text, float fontSize,
double xMm, double firstBaselineMmFromTop, double widthMm, int maxLines,
double lineHeightMm) throws IOException {
List<String> lines = wrap(font, fontSize, value(text), PdfUnits.mmToPt(widthMm), maxLines);
double y = firstBaselineMmFromTop;
for (String line : lines) {
text(stream, units, font, line, fontSize, xMm, y);
y += lineHeightMm;
}
}
private List<String> wrap(PDFont font, float fontSize, String text, float maxWidthPt, int maxLines)
throws IOException {
List<String> lines = new ArrayList<>();
if (text.isEmpty() || maxLines < 1) {
return lines;
}
for (String sourceLine : text.split("\\n", -1)) {
StringBuilder current = new StringBuilder();
for (String token : sourceLine.split("(?<=\\s)|(?=\\s)")) {
if (token.isEmpty()) {
continue;
}
String candidate = current + token;
if (!current.isEmpty() && width(font, fontSize, candidate) > maxWidthPt) {
lines.add(current.toString().stripTrailing());
if (lines.size() >= maxLines) {
return lines;
}
current.setLength(0);
candidate = token.stripLeading();
}
if (width(font, fontSize, candidate) > maxWidthPt && current.isEmpty()) {
for (String broken : breakLongToken(font, fontSize, candidate, maxWidthPt)) {
lines.add(broken);
if (lines.size() >= maxLines) {
return lines;
}
}
} else {
current.setLength(0);
current.append(candidate);
}
}
if (!current.isEmpty()) {
lines.add(current.toString().stripTrailing());
if (lines.size() >= maxLines) {
return lines;
}
}
}
return lines;
}
private List<String> breakLongToken(PDFont font, float fontSize, String token, float maxWidthPt)
throws IOException {
List<String> lines = new ArrayList<>();
StringBuilder current = new StringBuilder();
for (int offset = 0; offset < token.length(); ) {
int codePoint = token.codePointAt(offset);
String next = current + new String(Character.toChars(codePoint));
if (!current.isEmpty() && width(font, fontSize, next) > maxWidthPt) {
lines.add(current.toString());
current.setLength(0);
}
current.appendCodePoint(codePoint);
offset += Character.charCount(codePoint);
}
if (!current.isEmpty()) {
lines.add(current.toString());
}
return lines;
}
private float width(PDFont font, float fontSize, String text) throws IOException {
return font.getStringWidth(text) / 1000f * fontSize;
}
private void rect(PDPageContentStream stream, double xMm, double yMm, double widthMm, double heightMm)
throws IOException {
stream.setLineWidth(0.5f);
stream.addRect(PdfUnits.mmToPt(xMm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - yMm - heightMm),
PdfUnits.mmToPt(widthMm), PdfUnits.mmToPt(heightMm));
stream.stroke();
}
private void line(PDPageContentStream stream, double x1Mm, double y1Mm, double x2Mm, double y2Mm)
throws IOException {
stream.setLineWidth(0.45f);
stream.moveTo(PdfUnits.mmToPt(x1Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y1Mm));
stream.lineTo(PdfUnits.mmToPt(x2Mm), PdfUnits.mmToPt(PAGE_HEIGHT_MM - y2Mm));
stream.stroke();
}
private String value(String text) {
return text == null ? "" : text;
}
}
@@ -0,0 +1,257 @@
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.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import jakarta.annotation.Resource;
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.LinkedHashMap;
import java.util.List;
import java.util.Map;
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;
/** Validates A4 freight documents and renders one A4 page per request document. */
@Service
public class FreightA4PdfService {
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
private static final int MAX_FIELD_KEY_CODE_POINTS = 80;
private static final int MAX_FIELD_VALUE_CODE_POINTS = 4000;
private static final int MAX_CARGO_ROWS = 50;
private static final BigDecimal A4_WIDTH_MM = new BigDecimal("210");
private static final BigDecimal A4_HEIGHT_MM = new BigDecimal("297");
private static final Set<String> SUPPORTED_TEMPLATES = Set.of(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE);
private static final Set<Character> FORBIDDEN_FILE_NAME_CHARACTERS =
Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
@Resource private PrintPdfProperties properties;
@Resource private PdfGenerationLimiter limiter;
@Resource private FreightA4Renderer renderer;
public GeneratedPdf generate(FreightA4PdfDtos.Request request) {
NormalizedRequest normalized = validateAndNormalize(request);
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
return render(normalized);
}
}
private GeneratedPdf render(NormalizedRequest request) {
try (PDDocument document = new PDDocument();
InputStream fontStream = openFont();
LimitedByteArrayOutputStream output =
new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) {
PDType0Font font = PDType0Font.load(document, fontStream, true);
for (FreightA4PdfDtos.Document documentData : request.documents()) {
renderer.renderPage(document, font, request.templateCode(), documentData);
}
document.save(output);
byte[] content = output.toByteArray();
return new GeneratedPdf(content, request.fileName(), request.documents().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(FreightA4PdfDtos.Request request) {
if (request == null) {
invalid("request is required");
}
String templateCode = normalizeRequiredLine(request.templateCode(), "templateCode");
if (!SUPPORTED_TEMPLATES.contains(templateCode)) {
invalid("templateCode is not supported");
}
if (request.pageSpec() == null) {
invalid("pageSpec is required");
}
requireSupportedDimension(request.pageSpec().widthMm(), A4_WIDTH_MM, "pageSpec.widthMm");
requireSupportedDimension(request.pageSpec().heightMm(), A4_HEIGHT_MM, "pageSpec.heightMm");
if (request.documents() == null || request.documents().isEmpty()) {
invalid("documents must contain at least one item");
}
if (request.documents().size() > properties.getMaxPages()) {
invalid("documents must not contain more than " + properties.getMaxPages() + " items");
}
List<FreightA4PdfDtos.Document> documents = new ArrayList<>(request.documents().size());
for (int index = 0; index < request.documents().size(); index++) {
documents.add(normalizeDocument(request.documents().get(index), index));
}
return new NormalizedRequest(templateCode, normalizeFileName(request.downloadFileName()),
List.copyOf(documents));
}
private FreightA4PdfDtos.Document normalizeDocument(FreightA4PdfDtos.Document document, int index) {
if (document == null) {
invalid("documents[" + index + "] is required");
}
Map<String, String> fields = new LinkedHashMap<>();
if (document.fields() != null) {
for (Map.Entry<String, String> entry : document.fields().entrySet()) {
String key = normalizeRequiredLine(entry.getKey(), field(index, "fields key"));
if (key.codePointCount(0, key.length()) > MAX_FIELD_KEY_CODE_POINTS) {
invalid(field(index, "fields." + key) + " key is too long");
}
fields.put(key, normalizeOptionalBlock(entry.getValue(), field(index, "fields." + key)));
}
}
List<FreightA4PdfDtos.CargoRow> rows = new ArrayList<>();
if (document.cargoRows() != null) {
if (document.cargoRows().size() > MAX_CARGO_ROWS) {
invalid(field(index, "cargoRows") + " must not contain more than " + MAX_CARGO_ROWS + " items");
}
for (int rowIndex = 0; rowIndex < document.cargoRows().size(); rowIndex++) {
rows.add(normalizeCargoRow(document.cargoRows().get(rowIndex), index, rowIndex));
}
}
return new FreightA4PdfDtos.Document(Map.copyOf(fields), List.copyOf(rows));
}
private FreightA4PdfDtos.CargoRow normalizeCargoRow(FreightA4PdfDtos.CargoRow row, int documentIndex,
int rowIndex) {
if (row == null) {
invalid("documents[" + documentIndex + "].cargoRows[" + rowIndex + "] is required");
}
String prefix = "documents[" + documentIndex + "].cargoRows[" + rowIndex + "].";
return new FreightA4PdfDtos.CargoRow(
normalizeOptionalBlock(row.marksAndNos(), prefix + "marksAndNos"),
normalizeOptionalBlock(row.quantity(), prefix + "quantity"),
normalizeOptionalBlock(row.packaging(), prefix + "packaging"),
normalizeOptionalBlock(row.descriptionOfGoods(), prefix + "descriptionOfGoods"),
normalizeOptionalBlock(row.grossWeight(), prefix + "grossWeight"),
normalizeOptionalBlock(row.measurement(), prefix + "measurement"));
}
private String normalizeFileName(String value) {
if (value == null) {
return "freight-a4-documents.pdf";
}
String normalized = normalizeRequiredLine(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 normalizeRequiredLine(String value, String field) {
String normalized = normalizeOptionalBlock(value, field);
if (normalized.isBlank()) {
invalid(field + " must not be blank");
}
if (normalized.contains("\n")) {
invalid(field + " must be a single line");
}
return normalized;
}
private String normalizeOptionalBlock(String value, String field) {
if (value == null) {
return "";
}
String normalized = Normalizer.normalize(value, Normalizer.Form.NFC)
.replace("\r\n", "\n")
.replace('\r', '\n');
if (normalized.codePointCount(0, normalized.length()) > MAX_FIELD_VALUE_CODE_POINTS) {
invalid(field + " must not exceed " + MAX_FIELD_VALUE_CODE_POINTS + " characters");
}
normalized.codePoints().forEach(codePoint -> {
if (isForbiddenControl(codePoint)) {
invalid(field + " contains a control character");
}
});
return normalized.strip();
}
private boolean isForbiddenControl(int codePoint) {
int type = Character.getType(codePoint);
return codePoint != '\n' && codePoint != '\t'
&& (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 "documents[" + 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 templateCode, String fileName, List<FreightA4PdfDtos.Document> documents) {
}
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 {
}
}
@@ -52,6 +52,54 @@ paths:
application/json:
schema:
$ref: '#/components/schemas/ApiError'
/internal/print/v1/pdfs/freight-a4-documents:
post:
summary: Generate an A4 sea-freight document PDF
operationId: generateFreightA4DocumentPdf
security:
- internalServiceToken: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/FreightA4PdfRequest'
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:
@@ -112,6 +160,66 @@ components:
minLength: 1
maxLength: 34
pattern: '^[\\x20-\\x7E]+$'
FreightA4PdfRequest:
type: object
required: [templateCode, pageSpec, documents]
properties:
templateCode:
type: string
enum: [SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1, BILL_OF_LADING_CONFIRMATION_A4_V1]
pageSpec:
$ref: '#/components/schemas/FreightA4PageSpec'
downloadFileName:
type: string
maxLength: 120
example: BYSE2607044-托运书.pdf
documents:
type: array
minItems: 1
maxItems: 2000
description: One document item renders one A4 page in request order.
items:
$ref: '#/components/schemas/FreightA4Document'
FreightA4PageSpec:
type: object
required: [widthMm, heightMm]
properties:
widthMm:
type: number
enum: [210]
heightMm:
type: number
enum: [297]
FreightA4Document:
type: object
properties:
fields:
type: object
additionalProperties:
type: string
maxLength: 4000
description: Template field map. Known keys are documented in the print module README.
cargoRows:
type: array
maxItems: 1
description: V1 templates render at most one cargo row. Callers must not send additional rows.
items:
$ref: '#/components/schemas/FreightA4CargoRow'
FreightA4CargoRow:
type: object
properties:
marksAndNos:
type: string
quantity:
type: string
packaging:
type: string
descriptionOfGoods:
type: string
grossWeight:
type: string
measurement:
type: string
ApiError:
type: object
required: [code, message]
@@ -3,7 +3,9 @@ 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.FreightA4PdfDtos;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.FreightA4PdfService;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.annotation.Resource;
import org.junit.jupiter.api.BeforeEach;
@@ -38,6 +40,8 @@ class InternalPdfControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@MockBean
private InboundMarkPdfService service;
@MockBean
private FreightA4PdfService freightA4PdfService;
@Resource
private InternalPdfController controller;
private MockMvc mockMvc;
@@ -127,6 +131,28 @@ class InternalPdfControllerTest {
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
}
@Test
void returnsFreightA4PdfWithDownloadHeaders() throws Exception {
byte[] pdf = "%PDF-a4-test".getBytes(StandardCharsets.US_ASCII);
when(freightA4PdfService.generate(any())).thenReturn(new FreightA4PdfService.GeneratedPdf(
pdf, "BYSE2607044-托运书.pdf", 1));
mockMvc.perform(post("/internal/print/v1/pdfs/freight-a4-documents")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validFreightA4RequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes(pdf))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("X-Print-Page-Count", "1"))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("filename*=")));
verify(freightA4PdfService).generate(any(FreightA4PdfDtos.Request.class));
}
@Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class
@@ -142,6 +168,7 @@ class InternalPdfControllerTest {
.getContentAsString(StandardCharsets.UTF_8);
assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("/internal/print/v1/pdfs/freight-a4-documents:")
.contains("application/pdf:")
.contains("application/json:")
.contains("name: X-Internal-Service-Token")
@@ -165,4 +192,29 @@ class InternalPdfControllerTest {
""";
}
private String validFreightA4RequestJson() {
return """
{
"templateCode": "SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1",
"pageSpec": {"widthMm": 210, "heightMm": 297},
"downloadFileName": "BYSE2607044-托运书.pdf",
"documents": [{
"fields": {
"toCompany": "宁波胜泽远航国际物流有限公司",
"shipper": "ZHEJIANG BAIYUE TRADE CO.,LIMITED",
"jobNumber": "BYSE2607044"
},
"cargoRows": [{
"marksAndNos": "N/M",
"quantity": "700",
"packaging": "PACKAGES",
"descriptionOfGoods": "CAR ACCESSORIES",
"grossWeight": "28000.00 KGS",
"measurement": "28.000 CBM"
}]
}]
}
""";
}
}
@@ -39,6 +39,19 @@ class PrintRequestSizeFilterTest {
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void alsoCapsFreightA4DocumentRequests() throws Exception {
MockHttpServletRequest request = request("12345678901", true,
"/internal/print/v1/pdfs/freight-a4-documents");
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 rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest() {
@@ -68,17 +81,25 @@ class PrintRequestSizeFilterTest {
}
private MockHttpServletRequest request(String content, boolean unknownLength) {
return request(content, unknownLength, "/internal/print/v1/pdfs/inbound-marks");
}
private MockHttpServletRequest request(String content, boolean unknownLength, String path) {
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
@Override public int getContentLength() { return -1; }
@Override public long getContentLengthLong() { return -1; }
} : new MockHttpServletRequest();
initialize(request, content);
initialize(request, content, path);
return request;
}
private void initialize(MockHttpServletRequest request, String content) {
initialize(request, content, "/internal/print/v1/pdfs/inbound-marks");
}
private void initialize(MockHttpServletRequest request, String content, String path) {
request.setMethod("POST");
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
request.setRequestURI(path);
request.setContent(content.getBytes(StandardCharsets.UTF_8));
request.setAttribute("requestId", "request-123");
request.setAttribute("traceId", "trace-123");
@@ -0,0 +1,134 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import com.baiyue.print.renderer.PdfUnits;
import jakarta.annotation.Resource;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
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 = {FreightA4PdfService.class, PrintPdfProperties.class,
PdfGenerationLimiter.class, FreightA4Renderer.class})
class FreightA4PdfServiceGenerationTest {
@Resource
private FreightA4PdfService service;
@Test
void generatesShippingInstructionAsStrictA4Pdf() throws IOException {
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
"BYSE2607044-shipping-instruction.pdf",
shippingDocument()));
assertThat(generated.fileName()).isEqualTo("BYSE2607044-shipping-instruction.pdf");
assertThat(generated.pageCount()).isEqualTo(1);
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertA4(document);
String text = new PDFTextStripper().getText(document);
assertThat(text).contains("Sea Freight Shipping Instructions", "Job Nbr",
"BYSE2607044", "CAR ACCESSORIES", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY.");
}
}
@Test
void generatesBillConfirmationAsStrictA4Pdf() throws IOException {
FreightA4PdfService.GeneratedPdf generated = service.generate(request(
FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE,
"BYSE2607044-bill-confirmation.pdf",
billConfirmationDocument()));
assertThat(generated.pageCount()).isEqualTo(1);
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertA4(document);
String text = new PDFTextStripper().getText(document);
assertThat(text).contains("WHY074G504054", "D R A F T", "SAID TO CONTAIN(S)",
"WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM");
}
}
private FreightA4PdfDtos.Request request(String templateCode, String fileName,
FreightA4PdfDtos.Document document) {
return new FreightA4PdfDtos.Request(templateCode,
new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297.0")),
fileName, List.of(document));
}
private FreightA4PdfDtos.Document shippingDocument() {
return new FreightA4PdfDtos.Document(Map.ofEntries(
Map.entry("toCompany", "宁波胜泽远航国际物流有限公司"),
Map.entry("issuerNameCn", "浙江柏悦贸易有限公司(香港)"),
Map.entry("issuerNameEn", "ZHEJIANG BAIYUE TRADE CO.,LIMITED"),
Map.entry("issuerAddress", "地址:浙江省义乌市稠州北路 1399 号新光国际 8613 室"),
Map.entry("issuerPostalCode", "322000"),
Map.entry("issuerTel", "0579-85875557"),
Map.entry("issuerFax", "0579-85601277"),
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN STREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
Map.entry("picName", "苏天翔"),
Map.entry("jobNumber", "BYSE2607044"),
Map.entry("etdDate", "2026/07/24"),
Map.entry("serviceTerm", "CY-CY"),
Map.entry("freightTerm", "FREIGHT PREPAID"),
Map.entry("cargoQuantity", "1*20GP"),
Map.entry("portOfLoading", "NINGBO"),
Map.entry("portOfDischarge", "EL DEKHEILA"),
Map.entry("placeOfDelivery", "EL DEKHEILA"),
Map.entry("totalPackagesInWords", "SAY SIX HUNDRED AND FIFTY NINE PACKAGES ONLY."),
Map.entry("carrier", "万海航运(香港)股份有限公司"),
Map.entry("bookingAgent", "宁波胜泽远航国际物流有限公司"),
Map.entry("printDate", "2026 年 07 月 16 日")
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "700", "PACKAGES",
"CAR ACCESSORIES\nACID: 4120661061019510011\n申请目的港 21 天免用箱",
"28000.00 KGS", "28.000 CBM")));
}
private FreightA4PdfDtos.Document billConfirmationDocument() {
return new FreightA4PdfDtos.Document(Map.ofEntries(
Map.entry("shipper", "ZHEJIANG BAIYUE TRADE CO.,LIMITED\nROOM 8613,NO.1399,CHOUZHOU NORTH ROAD,FUTIAN\nSTREET,YIWU CITY,ZHEJIANG PROVINCE\nTEL:0579-85695557"),
Map.entry("consignee", "SANATRK FOR IMPORT AND TRADE OF AUTO SPARE PARTS\n3 ATIA ELZEKRY ST- OMAR EBN ELKHTAB CITY SHOBRA\nELKHIMA QALYUBIA SECOND-OWNER/ MENA MATTA GABALLA\nMATTA\nTEL:01225672354"),
Map.entry("notifyParty", "SAME AS CONSIGNEE"),
Map.entry("bookingNumber", "WHY074G504054"),
Map.entry("ensAmsCutoffDate", "2026/07/20"),
Map.entry("expectedDepartureDate", "2026/07/24"),
Map.entry("oceanVesselVoyage", "WAN HAI 622 W027"),
Map.entry("portOfLoading", "NINGBO"),
Map.entry("portOfDischarge", "EL DEKHEILA"),
Map.entry("placeOfDelivery", "EL DEKHEILA"),
Map.entry("finalDestination", "EL DEKHEILA"),
Map.entry("serviceTerm", "CY-CY"),
Map.entry("freightTerm", "FREIGHT PREPAID"),
Map.entry("shipperLoadCountSeal", "SHPR'S LOAD,COUNT AND SEAL"),
Map.entry("containerSpec", "1*20GP"),
Map.entry("shippedOnBoard", "24-JUL-2026"),
Map.entry("draftText", "D R A F T"),
Map.entry("totalPackagesInWords", "SAY ONE THOUSAND AND TWENTY EIGHT PACKAGES ONLY."),
Map.entry("containerSummary", "WHSU2884669/WHLX730243/20GP/1028PACKAGES/28000KGS/ 28CBM"),
Map.entry("notes", "备注:1.多个箱子请分别提供分箱数,分箱品名,若未指出默认品名混装.\n2.请在 ENS/AMS 规定时间内确认好提单内容,否则有上了不船的风险."),
Map.entry("issuePlace", "NINGBO"),
Map.entry("issueDate", "24-JUL-2026")
), List.of(new FreightA4PdfDtos.CargoRow("N/M", "1028", "PACKAGES",
"AUTO SPARE PARTS\nHS CODE 870899\nACID: 4120661061019510011\nEGYPTIAN IMPORTER VAT NUMBER:412066106\nEXPORTER REGISTRATION\nNUMBER:CN-02-91330782MA2E62HK3Y\n申请目的港 14 天免用箱",
"28000.00 KGS", "28.000 CBM")));
}
private void assertA4(PDDocument document) {
assertThat(document.getNumberOfPages()).isEqualTo(1);
assertThat(document.getPage(0).getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(210),
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(0).getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(297),
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(0).getRotation()).isZero();
}
}
@@ -0,0 +1,64 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatCode;
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 com.baiyue.print.dto.FreightA4PdfDtos;
import com.baiyue.print.renderer.FreightA4Renderer;
import java.math.BigDecimal;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class})
class FreightA4PdfServiceValidationTest {
@MockBean private PdfGenerationLimiter limiter;
@MockBean private FreightA4Renderer renderer;
@jakarta.annotation.Resource private FreightA4PdfService service;
@Test
void rejectsUnsupportedTemplateCode() {
assertValidationFailure(new FreightA4PdfDtos.Request("UNKNOWN",
pageSpec(), "a4.pdf", List.of(document())), "templateCode");
}
@Test
void rejectsNonA4PageSize() {
assertValidationFailure(new FreightA4PdfDtos.Request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
new FreightA4PdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100")),
"a4.pdf", List.of(document())), "pageSpec.widthMm");
}
@Test
void allowsMultilineDocumentFields() {
FreightA4PdfDtos.Request request = new FreightA4PdfDtos.Request(
FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE,
pageSpec(), "a4.pdf", List.of(document()));
assertThatCode(() -> service.generate(request)).doesNotThrowAnyException();
}
private void assertValidationFailure(FreightA4PdfDtos.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 FreightA4PdfDtos.PageSpec pageSpec() {
return new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297"));
}
private FreightA4PdfDtos.Document document() {
return new FreightA4PdfDtos.Document(Map.of("shipper", "A\nB"),
List.of(new FreightA4PdfDtos.CargoRow("N/M", "1", "PACKAGES", "GOODS", "1 KGS", "1 CBM")));
}
}