优化
This commit is contained in:
@@ -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")));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user