优化
This commit is contained in:
+2
-1
@@ -4,13 +4,14 @@
|
||||
|
||||
- 本模块是无数据库的无状态 PDF 渲染服务;调用方传入完整 `labels[]` 或 `documents[]`,模块在内存中生成一次性多页 PDF 并同步返回。
|
||||
- 不保存模板、任务、历史或文件,不连接数据库、File 服务或打印机,不反查或修正业务数据。
|
||||
- 当前支持 `INBOUND_MARK_V1`(`100mm x 100mm`)、`DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1` 和 `BILL_OF_LADING_CONFIRMATION_A4_V1`(后三者均为 `210mm x 297mm`);一件代发托盘模板采用黑白打印版,每托固定一页,展示托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示作业状态、重量或体积。新尺寸或布局必须使用新模板编码。
|
||||
- 当前支持 `INBOUND_MARK_V1`(100×100 mm)、`FBA_PALLET_A4_LANDSCAPE_V1`(297×210 mm),以及纵向 A4 的 `DROPSHIP_PALLET_A4_V2`、`SEA_FREIGHT_SHIPPING_INSTRUCTION_A4_V1`、`BILL_OF_LADING_CONFIRMATION_A4_V1`、`DISPATCH_BILL_OF_LADING_A4_V2`。FBA 模板由国内和海外统一使用,每托一页,只展示 SCK8 等 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号;141 等内部编号保留在托盘号中。一件代发每托一页,展示托盘 Code 128、目的仓、最多 10 行入仓码/客户昵称和最多 34 行 SKU,不展示状态、重量或体积。新尺寸或布局必须使用新模板编码。
|
||||
|
||||
## 快速索引
|
||||
|
||||
| 功能 | 入口 |
|
||||
|---|---|
|
||||
| 调用方接入指南 | `README.md` |
|
||||
| 国内/海外统一 FBA 托盘标 | `dto/FbaPalletPdfDtos.java`、`service/FbaPalletPdfService.java`、`renderer/FbaPalletRenderer.java`;内部 `/internal/print/v1/pdfs/fba-pallet-labels`,回归 `src/test/java/com/baiyue/print/service/FbaPalletPdfServiceTest.java` |
|
||||
| 应用入口 | `src/main/java/com/baiyue/print/PrintServiceApplication.java` |
|
||||
| 健康状态 API | `controller/PrintHealthController.java`(`/api/platform/v1/print/status`) |
|
||||
| 内部 PDF API | `controller/InternalPdfController.java`(`/internal/print/v1/pdfs/inbound-marks`、`/internal/print/v1/pdfs/dropship-pallet-a4-documents`、`/internal/print/v1/pdfs/freight-a4-documents`) |
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
|
||||
- 服务发现名:`print-service`。
|
||||
- 内部地址:`POST /internal/print/v1/pdfs/inbound-marks`。
|
||||
- FBA 亚马逊托盘标:`POST /internal/print/v1/pdfs/fba-pallet-labels`;国内散货打印与海外 FBA 单托补打统一使用 `FBA_PALLET_A4_LANDSCAPE_V1`。每托一页 297×210 mm 横向 A4,仅展示 FBA 仓码、完整托盘号的 Code 128 条码和完整托盘号文字。
|
||||
- 一件代发 A4 托盘信息单:`POST /internal/print/v1/pdfs/dropship-pallet-a4-documents`;模板固定为 `DROPSHIP_PALLET_A4_V2`,采用黑白打印版,每托一页 A4,包含托盘 Code 128 一维码、目的仓、最多 10 行入仓码/客户昵称汇总和最多 34 行 SKU 汇总,不展示托盘序号、作业状态、重量或体积。
|
||||
- A4 海运单据内部地址:`POST /internal/print/v1/pdfs/freight-a4-documents`。
|
||||
- 海外仓排车 BOL 内部地址:`POST /internal/print/v1/pdfs/dispatch-bol-documents`;模板固定为 `DISPATCH_BILL_OF_LADING_A4_V2`,每个 document 对应一张 A4、包含 1-26 行明细。调用方首张按 18 行、续页按 26 行稳定分页;同页连续相同柜号和托盘号由 Print 纵向合并显示。
|
||||
@@ -20,6 +21,21 @@
|
||||
|
||||
## 2. 请求契约
|
||||
|
||||
### FBA 亚马逊托盘标
|
||||
|
||||
```json
|
||||
{
|
||||
"templateCode": "FBA_PALLET_A4_LANDSCAPE_V1",
|
||||
"pageSpec": { "widthMm": 297, "heightMm": 210 },
|
||||
"downloadFileName": "FBA托盘标.pdf",
|
||||
"labels": [{ "fbaWarehouseCode": "SCK8", "palletCode": "MSCU6639870-141-001" }]
|
||||
}
|
||||
```
|
||||
|
||||
两个业务字段均必填:`fbaWarehouseCode` 为正式 FBA 仓码(1–32 位大写字母、数字或连字符,首位不得为连字符);`palletCode` 为已保存的完整托盘业务编码(1–80 位可打印 ASCII,无首尾空格),不另传条码内容以免文字与条码不一致。`141` 等地址内部编号保留在托盘号中,不取代顶部的 `SCK8`。调用方不重新编号,Print 不裁切、不补号、不反查仓库;缺失或不合法数据返回 `A0422`。每个请求支持 1–2000 张,沿用全局请求体、输出大小及并发上限。文件名必填,最多 120 字符,以 `.pdf` 结尾且无路径、保留字符或控制字符。
|
||||
|
||||
Firstleg 从冻结标签的 `destinationWarehouseCode/palletCode` 映射;海外资源服务从当前托盘的 `fbaWarehouseCode/palletCode` 映射。两个入口共用本接口、模板和校验;非 FBA 托盘与头程箱唛使用各自模板,不由 FBA 模板推断类型。
|
||||
|
||||
### 2.1 入仓标签
|
||||
|
||||
```http
|
||||
|
||||
@@ -23,9 +23,9 @@ public class FbaPalletRenderer {
|
||||
PdfUnits units = new PdfUnits(210);
|
||||
try (PDPageContentStream stream = new PDPageContentStream(pdf, page)) {
|
||||
stream.setNonStrokingColor(0, 0, 0);
|
||||
centered(stream, units, font, label.fbaWarehouseCode(), 96, 49);
|
||||
barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(140),
|
||||
PdfUnits.mmToPt(230), PdfUnits.mmToPt(55));
|
||||
centered(stream, units, font, label.fbaWarehouseCode(), 280, 60);
|
||||
barcodeRenderer.draw(stream, barcode, PdfUnits.mmToPt(33.5), units.topDownMmToPdfY(146),
|
||||
PdfUnits.mmToPt(230), PdfUnits.mmToPt(30));
|
||||
centered(stream, units, font, label.palletCode(), 64, 174);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,29 @@ info:
|
||||
version: 1.0.0
|
||||
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
|
||||
paths:
|
||||
/internal/print/v1/pdfs/fba-pallet-labels:
|
||||
post:
|
||||
summary: Generate shared domestic and overseas FBA pallet labels as landscape A4 pages
|
||||
operationId: generateFbaPalletLabels
|
||||
description: One label per page in input order. The destination is the FBA code such as SCK8; the stored pallet code (including internal warehouse number such as 141) is printed verbatim and encoded as Code 128. No business lookup or new numbering.
|
||||
security: [{ internalServiceToken: [] }]
|
||||
parameters: [{ $ref: '#/components/parameters/RequestId' }]
|
||||
requestBody:
|
||||
required: true
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/FbaPalletPdfRequest' } } }
|
||||
responses:
|
||||
'200':
|
||||
description: PDF generation succeeded, or a business error was returned as ApiResponse JSON.
|
||||
headers:
|
||||
Content-Disposition: { schema: { type: string } }
|
||||
Cache-Control: { schema: { type: string, example: no-store } }
|
||||
X-Print-Page-Count: { schema: { type: integer, minimum: 1, maximum: 2000 } }
|
||||
content:
|
||||
application/pdf: { schema: { type: string, format: binary } }
|
||||
application/json: { schema: { $ref: '#/components/schemas/ApiError' } }
|
||||
'413':
|
||||
description: Request body exceeds the configured limit.
|
||||
content: { application/json: { schema: { $ref: '#/components/schemas/ApiError' } } }
|
||||
/internal/print/v1/pdfs/inbound-marks:
|
||||
post:
|
||||
summary: Generate an inbound-mark PDF
|
||||
@@ -153,6 +176,28 @@ components:
|
||||
schema:
|
||||
type: string
|
||||
schemas:
|
||||
FbaPalletPdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, downloadFileName, labels]
|
||||
properties:
|
||||
templateCode: { type: string, enum: [FBA_PALLET_A4_LANDSCAPE_V1] }
|
||||
pageSpec:
|
||||
type: object
|
||||
required: [widthMm, heightMm]
|
||||
properties:
|
||||
widthMm: { type: number, enum: [297] }
|
||||
heightMm: { type: number, enum: [210] }
|
||||
downloadFileName: { type: string, minLength: 4, maxLength: 120, description: PDF filename without path separators or control characters }
|
||||
labels:
|
||||
type: array
|
||||
minItems: 1
|
||||
maxItems: 2000
|
||||
items:
|
||||
type: object
|
||||
required: [fbaWarehouseCode, palletCode]
|
||||
properties:
|
||||
fbaWarehouseCode: { type: string, pattern: '^[A-Z0-9][A-Z0-9-]{0,31}$', example: SCK8 }
|
||||
palletCode: { type: string, minLength: 1, maxLength: 80, description: Printable ASCII with no surrounding spaces; unchanged text and Code 128 payload, example: MSCU6639870-141-001 }
|
||||
InboundMarkPdfRequest:
|
||||
type: object
|
||||
required: [templateCode, pageSpec, labels]
|
||||
|
||||
@@ -10,6 +10,7 @@ import com.baiyue.print.service.FreightA4PdfService;
|
||||
import com.baiyue.print.service.DispatchBolPdfService;
|
||||
import com.baiyue.print.service.DropshipPalletA4PdfService;
|
||||
import com.baiyue.print.service.InboundMarkPdfService;
|
||||
import com.baiyue.print.service.FbaPalletPdfService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -49,6 +50,8 @@ class InternalPdfControllerTest {
|
||||
private DispatchBolPdfService dispatchBolPdfService;
|
||||
@MockBean
|
||||
private DropshipPalletA4PdfService dropshipPalletA4PdfService;
|
||||
@MockBean
|
||||
private FbaPalletPdfService fbaPalletPdfService;
|
||||
@Resource
|
||||
private InternalPdfController controller;
|
||||
private MockMvc mockMvc;
|
||||
@@ -60,6 +63,24 @@ class InternalPdfControllerTest {
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void protectsFbaLabelsAndReturnsPdfHeaders() throws Exception {
|
||||
String json = """
|
||||
{"templateCode":"FBA_PALLET_A4_LANDSCAPE_V1","pageSpec":{"widthMm":297,"heightMm":210},
|
||||
"downloadFileName":"fba.pdf","labels":[{"fbaWarehouseCode":"SCK8","palletCode":"MSCU6639870-141-001"}]}
|
||||
""";
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/fba-pallet-labels").contentType(MediaType.APPLICATION_JSON).content(json))
|
||||
.andExpect(jsonPath("$.code").value("A0403"));
|
||||
verify(fbaPalletPdfService, never()).generate(any());
|
||||
byte[] pdf = "%PDF-fba".getBytes(StandardCharsets.US_ASCII);
|
||||
when(fbaPalletPdfService.generate(any())).thenReturn(new FbaPalletPdfService.GeneratedPdf(pdf, "fba.pdf", 1));
|
||||
mockMvc.perform(post("/internal/print/v1/pdfs/fba-pallet-labels").header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(json))
|
||||
.andExpect(status().isOk()).andExpect(content().contentType(MediaType.APPLICATION_PDF))
|
||||
.andExpect(content().bytes(pdf)).andExpect(header().string("X-Print-Page-Count", "1"))
|
||||
.andExpect(header().string("Cache-Control", "no-store"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void returnsPdfWithDownloadHeaders() throws Exception {
|
||||
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
|
||||
|
||||
@@ -52,6 +52,16 @@ class PrintRequestSizeFilterTest {
|
||||
assertTooLarge(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void capsFbaPalletRequestsBeforeDeserialization() throws Exception {
|
||||
var response = new MockHttpServletResponse();
|
||||
AtomicReference<Boolean> called = new AtomicReference<>(false);
|
||||
filter.doFilter(request("12345678901", true, "/internal/print/v1/pdfs/fba-pallet-labels"), response,
|
||||
(ignoredRequest, ignoredResponse) -> called.set(true));
|
||||
assertThat(called).hasValue(false);
|
||||
assertTooLarge(response);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest() {
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
package com.baiyue.print.service;
|
||||
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.print.config.PrintPdfProperties;
|
||||
import com.baiyue.print.dto.FbaPalletPdfDtos;
|
||||
import com.baiyue.print.renderer.FbaPalletRenderer;
|
||||
import com.baiyue.print.renderer.PdfBarcodeRenderer;
|
||||
import com.baiyue.print.renderer.PdfUnits;
|
||||
import com.google.zxing.*;
|
||||
import com.google.zxing.common.HybridBinarizer;
|
||||
import jakarta.annotation.Resource;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import org.apache.pdfbox.Loader;
|
||||
import org.apache.pdfbox.rendering.PDFRenderer;
|
||||
import org.apache.pdfbox.text.PDFTextStripper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
|
||||
|
||||
@SpringJUnitConfig(classes = {FbaPalletPdfService.class, PrintPdfProperties.class,
|
||||
PdfGenerationLimiter.class, FbaPalletRenderer.class, PdfBarcodeRenderer.class})
|
||||
class FbaPalletPdfServiceTest {
|
||||
@Resource private FbaPalletPdfService service;
|
||||
|
||||
@Test
|
||||
void rendersLandscapePagesWithOnlyFbaCodeAndUnchangedDecodablePalletIdentity() throws Exception {
|
||||
var labels = List.of(new FbaPalletPdfDtos.Label("SCK8", "MSCU6639870-141-001"),
|
||||
new FbaPalletPdfDtos.Label("ONT8", "MSCU6639870-CADF-002"),
|
||||
new FbaPalletPdfDtos.Label("SCK8", "A".repeat(80)));
|
||||
var result = service.generate(request(labels));
|
||||
assertThat(result.pageCount()).isEqualTo(3);
|
||||
assertThat(result.fileName()).isEqualTo("FBA托盘标.pdf");
|
||||
try (var pdf = Loader.loadPDF(result.content())) {
|
||||
assertThat(pdf.getNumberOfPages()).isEqualTo(labels.size());
|
||||
var renderer = new PDFRenderer(pdf);
|
||||
for (int i = 0; i < labels.size(); i++) {
|
||||
var page = pdf.getPage(i);
|
||||
assertThat(page.getMediaBox().getWidth()).isCloseTo(PdfUnits.mmToPt(297), within(.002f));
|
||||
assertThat(page.getMediaBox().getHeight()).isCloseTo(PdfUnits.mmToPt(210), within(.002f));
|
||||
assertThat(page.getRotation()).isZero();
|
||||
var stripper = new PDFTextStripper();
|
||||
stripper.setStartPage(i + 1); stripper.setEndPage(i + 1);
|
||||
assertThat(stripper.getText(pdf).strip().lines().toList())
|
||||
.containsExactly(labels.get(i).fbaWarehouseCode(), labels.get(i).palletCode());
|
||||
for (var name : page.getResources().getFontNames()) {
|
||||
assertThat(page.getResources().getFont(name).isEmbedded()).isTrue();
|
||||
}
|
||||
var image = renderer.renderImageWithDPI(i, 300);
|
||||
var source = new RGBLuminanceSource(image.getWidth(), image.getHeight(),
|
||||
image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth()));
|
||||
var decoded = new MultiFormatReader().decode(new BinaryBitmap(new HybridBinarizer(source)),
|
||||
Map.of(DecodeHintType.POSSIBLE_FORMATS, List.of(BarcodeFormat.CODE_128)));
|
||||
assertThat(decoded.getText()).isEqualTo(labels.get(i).palletCode());
|
||||
assertThat(decoded.getBarcodeFormat()).isEqualTo(BarcodeFormat.CODE_128);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsInvalidIdentifiersWithoutChangingThem() {
|
||||
for (String code : new String[] {"", "托盘001", "A".repeat(81), " PALLET", "PALLET\n"}) {
|
||||
assertThatThrownBy(() -> service.generate(request(List.of(new FbaPalletPdfDtos.Label("SCK8", code)))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("palletCode");
|
||||
}
|
||||
assertThatThrownBy(() -> service.generate(request(List.of(new FbaPalletPdfDtos.Label(null, "MSCU6639870-141-001")))))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("fbaWarehouseCode");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsPortraitWrongTemplateEmptyBatchAndUnsafeFilename() {
|
||||
var valid = request(List.of(new FbaPalletPdfDtos.Label("SCK8", "MSCU6639870-141-001")));
|
||||
assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request(valid.templateCode(),
|
||||
new FbaPalletPdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), valid.downloadFileName(), valid.labels())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("landscape A4");
|
||||
assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request("INBOUND_MARK_V1", valid.pageSpec(), valid.downloadFileName(), valid.labels())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("templateCode");
|
||||
assertThatThrownBy(() -> service.generate(request(List.of())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("labels count");
|
||||
assertThatThrownBy(() -> service.generate(new FbaPalletPdfDtos.Request(valid.templateCode(), valid.pageSpec(), "../label.pdf", valid.labels())))
|
||||
.isInstanceOf(BizException.class).hasMessageContaining("downloadFileName");
|
||||
}
|
||||
|
||||
private FbaPalletPdfDtos.Request request(List<FbaPalletPdfDtos.Label> labels) {
|
||||
return new FbaPalletPdfDtos.Request(FbaPalletPdfDtos.TEMPLATE,
|
||||
new FbaPalletPdfDtos.PageSpec(new BigDecimal("297"), new BigDecimal("210")), "FBA托盘标.pdf", labels);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user