打印服务

This commit is contained in:
2026-08-04 18:54:22 +08:00
parent b7d36d4766
commit dc2f844467
44 changed files with 2576 additions and 110 deletions
@@ -6,7 +6,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 打印服务启动入口。
*/
@SpringBootApplication(scanBasePackages = "com.baiyue")
@SpringBootApplication
public class PrintServiceApplication {
public static void main(String[] args) {
SpringApplication.run(PrintServiceApplication.class, args);
@@ -0,0 +1,33 @@
package com.baiyue.print.config;
import java.io.InputStream;
import org.apache.fontbox.ttf.TTFParser;
import org.apache.fontbox.ttf.TrueTypeFont;
import org.springframework.boot.actuate.health.Health;
import org.springframework.boot.actuate.health.HealthIndicator;
import org.springframework.core.io.ClassPathResource;
import org.springframework.stereotype.Component;
/** Exposes font packaging/parsing failures through readiness health. */
@Component("printFont")
public class PrintFontHealthIndicator implements HealthIndicator {
private final Health validationResult;
public PrintFontHealthIndicator(PrintPdfProperties properties) {
this.validationResult = validate(properties.getFontResource());
}
@Override
public Health health() {
return validationResult;
}
private Health validate(String fontResource) {
try (InputStream input = new ClassPathResource(fontResource).getInputStream();
TrueTypeFont ignored = new TTFParser().parseEmbedded(input)) {
return Health.up().withDetail("fontResource", fontResource).build();
} catch (Exception exception) {
return Health.down(exception).withDetail("fontResource", fontResource).build();
}
}
}
@@ -0,0 +1,95 @@
package com.baiyue.print.config;
import java.math.BigDecimal;
import java.time.Duration;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
/**
* 无状态 PDF 生成的资源与固定模板配置。
*/
@Component
@ConfigurationProperties(prefix = "print.pdf")
public class PrintPdfProperties {
private int maxPages = 2000;
private long maxRequestBytes = 2L * 1024 * 1024;
private long maxOutputBytes = 32L * 1024 * 1024;
private int maxConcurrentGenerations = 4;
private Duration acquireTimeout = Duration.ofSeconds(1);
private String templateCode = "INBOUND_MARK_V1";
private BigDecimal supportedWidthMm = new BigDecimal("100");
private BigDecimal supportedHeightMm = new BigDecimal("100");
private String fontResource = "fonts/NotoSansSC-Bold.ttf";
public int getMaxPages() {
return maxPages;
}
public void setMaxPages(int maxPages) {
this.maxPages = maxPages;
}
public long getMaxOutputBytes() {
return maxOutputBytes;
}
public long getMaxRequestBytes() {
return maxRequestBytes;
}
public void setMaxRequestBytes(long maxRequestBytes) {
this.maxRequestBytes = maxRequestBytes;
}
public void setMaxOutputBytes(long maxOutputBytes) {
this.maxOutputBytes = maxOutputBytes;
}
public int getMaxConcurrentGenerations() {
return maxConcurrentGenerations;
}
public void setMaxConcurrentGenerations(int maxConcurrentGenerations) {
this.maxConcurrentGenerations = maxConcurrentGenerations;
}
public Duration getAcquireTimeout() {
return acquireTimeout;
}
public void setAcquireTimeout(Duration acquireTimeout) {
this.acquireTimeout = acquireTimeout;
}
public String getTemplateCode() {
return templateCode;
}
public void setTemplateCode(String templateCode) {
this.templateCode = templateCode;
}
public BigDecimal getSupportedWidthMm() {
return supportedWidthMm;
}
public void setSupportedWidthMm(BigDecimal supportedWidthMm) {
this.supportedWidthMm = supportedWidthMm;
}
public BigDecimal getSupportedHeightMm() {
return supportedHeightMm;
}
public void setSupportedHeightMm(BigDecimal supportedHeightMm) {
this.supportedHeightMm = supportedHeightMm;
}
public String getFontResource() {
return fontResource;
}
public void setFontResource(String fontResource) {
this.fontResource = fontResource;
}
}
@@ -0,0 +1,64 @@
package com.baiyue.print.controller;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.service.InboundMarkPdfService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.http.CacheControl;
import org.springframework.http.ContentDisposition;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.nio.charset.StandardCharsets;
@RestController
@RequestMapping("/internal/print/v1/pdfs")
public class InternalPdfController {
static final String INTERNAL_TOKEN_HEADER = "X-Internal-Service-Token";
static final String PAGE_COUNT_HEADER = "X-Print-Page-Count";
private final InboundMarkPdfService pdfService;
private final InternalServiceTokenGuard tokenGuard;
public InternalPdfController(InboundMarkPdfService pdfService, InternalServiceTokenGuard tokenGuard) {
this.pdfService = pdfService;
this.tokenGuard = tokenGuard;
}
@PostMapping(value = "/inbound-marks", consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity<byte[]> generateInboundMarks(
@RequestHeader(value = INTERNAL_TOKEN_HEADER, required = false) String internalToken,
@Valid @RequestBody InboundMarkPdfDtos.Request request,
HttpServletRequest servletRequest) {
tokenGuard.verify(internalToken);
String requestId = CorrelationContext.requestId(servletRequest);
InboundMarkPdfDtos.Request effectiveRequest = request;
if (request.downloadFileName() == null && requestId != null && !requestId.isBlank()) {
effectiveRequest = new InboundMarkPdfDtos.Request(request.templateCode(), request.pageSpec(),
"inbound-marks-" + requestId + ".pdf", request.labels());
}
InboundMarkPdfService.GeneratedPdf pdf = pdfService.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);
}
}
@@ -0,0 +1,31 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
/** Verifies the shared credential used by internal service callers. */
@Component
public class InternalServiceTokenGuard {
private final byte[] expectedToken;
public InternalServiceTokenGuard(@Value("${baiyue.security.internal-service-token}") String token) {
if (token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) {
throw new IllegalStateException("INTERNAL_SERVICE_TOKEN must contain at least 32 bytes");
}
this.expectedToken = token.getBytes(StandardCharsets.UTF_8);
}
public void verify(String actualToken) {
byte[] actual = actualToken == null
? new byte[0]
: actualToken.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(expectedToken, actual)) {
throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
}
}
}
@@ -0,0 +1,120 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.print.config.PrintPdfProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.BufferedReader;
import java.nio.charset.StandardCharsets;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.http.MediaType;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
/** Caps the JSON request before Spring/Jackson materializes the request DTO. */
@Component
@Order(Ordered.HIGHEST_PRECEDENCE + 10)
public class PrintRequestSizeFilter extends OncePerRequestFilter {
private static final String PATH = "/internal/print/v1/pdfs/inbound-marks";
private final PrintPdfProperties properties;
private final ObjectMapper objectMapper;
public PrintRequestSizeFilter(PrintPdfProperties properties, ObjectMapper objectMapper) {
this.properties = properties;
this.objectMapper = objectMapper;
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
if (!"POST".equalsIgnoreCase(request.getMethod()) || !PATH.equals(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
long max = properties.getMaxRequestBytes();
if (max < 1 || max > Integer.MAX_VALUE) {
throw new IllegalStateException("print.pdf.max-request-bytes must be between 1 and Integer.MAX_VALUE");
}
if (request.getContentLengthLong() > max) {
writeTooLarge(request, response, max);
return;
}
byte[] body = readAtMost(request, max);
if (body == null) {
writeTooLarge(request, response, max);
return;
}
filterChain.doFilter(new BodyRequestWrapper(request, body), response);
}
private byte[] readAtMost(HttpServletRequest request, long max) throws IOException {
ByteArrayOutputStream output = new ByteArrayOutputStream((int) Math.min(max, 8192));
ServletInputStream input = request.getInputStream();
byte[] buffer = new byte[8192];
long total = 0;
int read;
while ((read = input.read(buffer)) != -1) {
total += read;
if (total > max) {
return null;
}
output.write(buffer, 0, read);
}
return output.toByteArray();
}
private void writeTooLarge(HttpServletRequest request, HttpServletResponse response, long max)
throws IOException {
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
ApiResponse<Void> body = ApiResponse.failure(ResultCode.BAD_REQUEST,
"Request body exceeds " + max + " bytes",
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
objectMapper.writeValue(response.getOutputStream(), body);
}
private static final class BodyRequestWrapper extends HttpServletRequestWrapper {
private final byte[] body;
private BodyRequestWrapper(HttpServletRequest request, byte[] body) {
super(request);
this.body = body;
}
@Override
public int getContentLength() { return body.length; }
@Override
public long getContentLengthLong() { return body.length; }
@Override
public ServletInputStream getInputStream() {
ByteArrayInputStream input = new ByteArrayInputStream(body);
return new ServletInputStream() {
@Override public int read() { return input.read(); }
@Override public int read(byte[] b, int off, int len) { return input.read(b, off, len); }
@Override public boolean isFinished() { return input.available() == 0; }
@Override public boolean isReady() { return true; }
@Override public void setReadListener(jakarta.servlet.ReadListener listener) { }
};
}
@Override
public BufferedReader getReader() {
return new BufferedReader(new java.io.InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
}
}
}
@@ -0,0 +1,32 @@
package com.baiyue.print.dto;
import java.math.BigDecimal;
import java.util.List;
/**
* 入仓唛头 PDF 的内部请求契约。
*/
public final class InboundMarkPdfDtos {
private InboundMarkPdfDtos() {
}
public record Request(
String templateCode,
PageSpec pageSpec,
String downloadFileName,
List<Label> labels
) {
}
public record PageSpec(BigDecimal widthMm, BigDecimal heightMm) {
}
public record Label(
String topCode,
String warehouseText,
String cartonText,
String routeText,
String qrContent
) {
}
}
@@ -0,0 +1,64 @@
package com.baiyue.print.renderer;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.google.zxing.common.BitMatrix;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.springframework.stereotype.Component;
/**
* 固定的 100mm x 100mm 入仓唛头 V1 模板。
*/
@Component
public class InboundMarkV1Renderer {
public static final double PAGE_WIDTH_MM = 100d;
public static final double PAGE_HEIGHT_MM = 100d;
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 QR_SIZE_PT = PdfUnits.mmToPt(35.28);
private static final float QR_LEFT_PT = (PAGE_WIDTH_PT - QR_SIZE_PT) / 2f;
private static final float QR_BOTTOM_PT = PAGE_HEIGHT_PT - PdfUnits.mmToPt(84);
private final PdfQrRenderer qrRenderer;
public InboundMarkV1Renderer(PdfQrRenderer qrRenderer) {
this.qrRenderer = qrRenderer;
}
public void renderPage(PDDocument document, PDFont font, InboundMarkPdfDtos.Label label,
BitMatrix qrMatrix) 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);
drawCenteredText(stream, units, font, label.topCode(), 24, 11);
drawCenteredText(stream, units, font, label.warehouseText(), 36, 26);
drawCenteredText(stream, units, font, label.cartonText(), 22, 37);
drawCenteredText(stream, units, font, label.routeText(), 21, 47);
qrRenderer.draw(stream, qrMatrix, QR_LEFT_PT, QR_BOTTOM_PT, QR_SIZE_PT);
drawCenteredText(stream, units, font, label.qrContent(), 12, 85.5);
drawCenteredText(stream, units, font, "MADE IN CHINA", 14, 92);
}
}
private void drawCenteredText(PDPageContentStream stream, PdfUnits units, PDFont font,
String text, float fontSize, double centerMmFromTop)
throws IOException {
float textWidth = font.getStringWidth(text) / 1000f * fontSize;
float x = (PAGE_WIDTH_PT - textWidth) / 2f;
float baseline = units.centeredTextBaseline(font, fontSize, centerMmFromTop);
stream.beginText();
stream.setFont(font, fontSize);
stream.newLineAtOffset(x, baseline);
stream.showText(text);
stream.endText();
}
}
@@ -0,0 +1,48 @@
package com.baiyue.print.renderer;
import com.google.zxing.BarcodeFormat;
import com.google.zxing.EncodeHintType;
import com.google.zxing.WriterException;
import com.google.zxing.common.BitMatrix;
import com.google.zxing.qrcode.QRCodeWriter;
import com.google.zxing.qrcode.decoder.ErrorCorrectionLevel;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.springframework.stereotype.Component;
/**
* 生成 QR 模块并以 PDF 矢量矩形绘制,避免位图缩放造成扫描损失。
*/
@Component
public class PdfQrRenderer {
private static final Map<EncodeHintType, ?> HINTS = Map.of(
EncodeHintType.CHARACTER_SET, StandardCharsets.UTF_8.name(),
EncodeHintType.ERROR_CORRECTION, ErrorCorrectionLevel.M,
EncodeHintType.MARGIN, 4
);
public BitMatrix encode(String content) {
try {
return new QRCodeWriter().encode(content, BarcodeFormat.QR_CODE, 1, 1, HINTS);
} catch (WriterException exception) {
throw new IllegalArgumentException("qrContent cannot be encoded", exception);
}
}
public void draw(PDPageContentStream stream, BitMatrix matrix,
float leftPt, float bottomPt, float sizePt) throws IOException {
float moduleSize = sizePt / matrix.getWidth();
stream.setNonStrokingColor(0, 0, 0);
for (int y = 0; y < matrix.getHeight(); y++) {
for (int x = 0; x < matrix.getWidth(); x++) {
if (matrix.get(x, y)) {
float pdfY = bottomPt + (matrix.getHeight() - 1 - y) * moduleSize;
stream.addRect(leftPt + x * moduleSize, pdfY, moduleSize, moduleSize);
}
}
}
stream.fill();
}
}
@@ -0,0 +1,32 @@
package com.baiyue.print.renderer;
import java.io.IOException;
import org.apache.pdfbox.pdmodel.font.PDFont;
/**
* 将模板使用的自顶向下毫米坐标集中转换为 PDF point 坐标。
*/
public final class PdfUnits {
public static final double POINTS_PER_MM = 72.0d / 25.4d;
private final float pageHeightPt;
public PdfUnits(double pageHeightMm) {
this.pageHeightPt = mmToPt(pageHeightMm);
}
public static float mmToPt(double millimeters) {
return (float) (millimeters * POINTS_PER_MM);
}
public float topDownMmToPdfY(double millimetersFromTop) {
return pageHeightPt - mmToPt(millimetersFromTop);
}
float centeredTextBaseline(PDFont font, float fontSize, double centerMmFromTop)
throws IOException {
float ascent = font.getFontDescriptor().getAscent() / 1000f * fontSize;
float descent = font.getFontDescriptor().getDescent() / 1000f * fontSize;
return topDownMmToPdfY(centerMmFromTop) - (ascent + descent) / 2f;
}
}
@@ -0,0 +1,233 @@
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.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.google.zxing.common.BitMatrix;
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.List;
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;
/**
* 校验完整标签数组,并一次性在内存中生成多页 PDF。
*/
@Service
public class InboundMarkPdfService {
private static final int MAX_QR_CODE_POINTS = 34;
private static final int MAX_FILE_NAME_CODE_POINTS = 120;
private static final Set<Character> FORBIDDEN_FILE_NAME_CHARACTERS =
Set.of('/', '\\', ':', '*', '?', '"', '<', '>', '|');
private final PrintPdfProperties properties;
private final PdfGenerationLimiter limiter;
private final InboundMarkV1Renderer renderer;
private final PdfQrRenderer qrRenderer;
public InboundMarkPdfService(PrintPdfProperties properties, PdfGenerationLimiter limiter,
InboundMarkV1Renderer renderer, PdfQrRenderer qrRenderer) {
this.properties = properties;
this.limiter = limiter;
this.renderer = renderer;
this.qrRenderer = qrRenderer;
}
public GeneratedPdf generate(InboundMarkPdfDtos.Request request) {
NormalizedRequest normalized = validateAndNormalize(request);
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
List<BitMatrix> matrices = normalized.labels().stream()
.map(label -> qrRenderer.encode(label.qrContent()))
.toList();
return render(normalized, matrices);
}
}
private GeneratedPdf render(NormalizedRequest request, List<BitMatrix> matrices) {
try (PDDocument document = new PDDocument();
InputStream fontStream = openFont();
LimitedByteArrayOutputStream output =
new LimitedByteArrayOutputStream(properties.getMaxOutputBytes())) {
PDType0Font font = PDType0Font.load(document, fontStream, true);
for (int index = 0; index < request.labels().size(); index++) {
renderer.renderPage(document, font, request.labels().get(index), matrices.get(index));
}
document.save(output);
byte[] content = output.toByteArray();
return new GeneratedPdf(content, request.fileName(), request.labels().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(InboundMarkPdfDtos.Request request) {
if (request == null) {
invalid("request is required");
}
String templateCode = normalizeRequired(request.templateCode(), "templateCode");
if (!properties.getTemplateCode().equals(templateCode)) {
invalid("templateCode is not supported");
}
if (request.pageSpec() == null) {
invalid("pageSpec is required");
}
requireSupportedDimension(request.pageSpec().widthMm(),
properties.getSupportedWidthMm(), "pageSpec.widthMm");
requireSupportedDimension(request.pageSpec().heightMm(),
properties.getSupportedHeightMm(), "pageSpec.heightMm");
if (request.labels() == null || request.labels().isEmpty()) {
invalid("labels must contain at least one item");
}
if (request.labels().size() > properties.getMaxPages()) {
invalid("labels must not contain more than " + properties.getMaxPages() + " items");
}
List<InboundMarkPdfDtos.Label> labels = new ArrayList<>(request.labels().size());
for (int index = 0; index < request.labels().size(); index++) {
InboundMarkPdfDtos.Label label = request.labels().get(index);
if (label == null) {
invalid("labels[" + index + "] is required");
}
labels.add(new InboundMarkPdfDtos.Label(
normalizeRequired(label.topCode(), field(index, "topCode")),
normalizeRequired(label.warehouseText(), field(index, "warehouseText")),
normalizeRequired(label.cartonText(), field(index, "cartonText")),
normalizeRequired(label.routeText(), field(index, "routeText")),
normalizeQr(label.qrContent(), field(index, "qrContent"))));
}
return new NormalizedRequest(normalizeFileName(request.downloadFileName()), List.copyOf(labels));
}
private String normalizeFileName(String value) {
if (value == null) {
return "inbound-marks.pdf";
}
String normalized = normalizeRequired(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 normalizeQr(String value, String field) {
String normalized = normalizeRequired(value, field);
int codePoints = normalized.codePointCount(0, normalized.length());
if (codePoints > MAX_QR_CODE_POINTS) {
invalid(field + " must not exceed 34 characters");
}
for (int offset = 0; offset < normalized.length(); offset++) {
char character = normalized.charAt(offset);
if (character < 0x20 || character > 0x7e) {
invalid(field + " must contain printable ASCII only");
}
}
return normalized;
}
private String normalizeRequired(String value, String field) {
if (value == null) {
invalid(field + " is required");
}
String normalized = Normalizer.normalize(value, Normalizer.Form.NFC);
if (normalized.isBlank()) {
invalid(field + " must not be blank");
}
normalized.codePoints().forEach(codePoint -> {
if (isForbiddenControl(codePoint)) {
invalid(field + " contains a control character");
}
});
return normalized;
}
private boolean isForbiddenControl(int codePoint) {
int type = Character.getType(codePoint);
return 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 "labels[" + 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 fileName, List<InboundMarkPdfDtos.Label> labels) {
}
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 {
}
}
@@ -0,0 +1,57 @@
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 java.time.Duration;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import org.springframework.stereotype.Component;
/**
* 进程内 PDF 生成并发保护。许可必须通过 try-with-resources 释放。
*/
@Component
public class PdfGenerationLimiter {
private final Semaphore semaphore;
private final Duration acquireTimeout;
public PdfGenerationLimiter(PrintPdfProperties properties) {
if (properties.getMaxConcurrentGenerations() < 1) {
throw new IllegalArgumentException("print.pdf.max-concurrent-generations must be positive");
}
this.semaphore = new Semaphore(properties.getMaxConcurrentGenerations(), true);
this.acquireTimeout = properties.getAcquireTimeout();
}
public Permit acquire() {
boolean acquired;
try {
acquired = semaphore.tryAcquire(acquireTimeout.toMillis(), TimeUnit.MILLISECONDS);
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "等待 PDF 生成槽位时线程被中断");
}
if (!acquired) {
throw new BizException(ResultCode.TOO_MANY_REQUESTS, "PDF 生成繁忙,请稍后重试");
}
return new Permit(semaphore);
}
public static final class Permit implements AutoCloseable {
private final Semaphore semaphore;
private boolean closed;
private Permit(Semaphore semaphore) {
this.semaphore = semaphore;
}
@Override
public void close() {
if (!closed) {
closed = true;
semaphore.release();
}
}
}
}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${PRINT_DB_URL:jdbc:mysql://127.0.0.1:3306/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&rewriteBatchedStatements=true}
username: ${PRINT_DB_USERNAME:root}
password: ${PRINT_DB_PASSWORD:123456}
@@ -1,6 +0,0 @@
spring:
datasource:
driver-class-name: com.mysql.cj.jdbc.Driver
url: ${PRINT_TEST_DB_URL:jdbc:mysql://${TEST_DB_HOST:8.163.30.128}:${TEST_DB_PORT:3306}/baiyue_print?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true&rewriteBatchedStatements=true}
username: ${PRINT_TEST_DB_USERNAME:${TEST_DB_USERNAME:root}}
password: ${PRINT_TEST_DB_PASSWORD:${TEST_DB_PASSWORD}}
+12
View File
@@ -23,6 +23,18 @@ spring:
baiyue:
security:
jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start}
internal-service-token: ${INTERNAL_SERVICE_TOKEN:}
print:
pdf:
max-pages: ${PRINT_PDF_MAX_PAGES:2000}
max-request-bytes: ${PRINT_PDF_MAX_REQUEST_BYTES:2097152}
max-output-bytes: ${PRINT_PDF_MAX_OUTPUT_BYTES:33554432}
max-concurrent-generations: ${PRINT_PDF_MAX_CONCURRENT_GENERATIONS:4}
acquire-timeout: ${PRINT_PDF_ACQUIRE_TIMEOUT:1s}
template-code: INBOUND_MARK_V1
supported-width-mm: 100
supported-height-mm: 100
font-resource: fonts/NotoSansSC-Bold.ttf
management.endpoints.web.exposure.include: health,info,prometheus
logging:
-62
View File
@@ -1,62 +0,0 @@
-- 打印服务独占数据库,业务服务只保存打印任务逻辑ID。
CREATE DATABASE IF NOT EXISTS baiyue_print
DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci;
USE baiyue_print;
CREATE TABLE IF NOT EXISTS print_template (
id BIGINT UNSIGNED NOT NULL COMMENT '模板ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID,平台模板使用平台租户ID',
template_code VARCHAR(64) NOT NULL COMMENT '模板编码',
template_name VARCHAR(128) NOT NULL COMMENT '模板名称',
template_type VARCHAR(32) NOT NULL COMMENT '模板类型',
content TEXT NOT NULL COMMENT '模板内容',
version_no INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '模板版本号',
status VARCHAR(24) NOT NULL DEFAULT 'ACTIVE' COMMENT '模板状态',
created_by BIGINT UNSIGNED NOT NULL COMMENT '创建人ID',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '创建时间',
updated_by BIGINT UNSIGNED NOT NULL COMMENT '最后修改人ID',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
deleted TINYINT(1) NOT NULL DEFAULT 0 COMMENT '逻辑删除标记',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_print_template_code (tenant_id, template_code, version_no),
KEY idx_print_template_status (tenant_id, status)
) ENGINE=InnoDB COMMENT='打印模板表';
CREATE TABLE IF NOT EXISTS print_job (
id BIGINT UNSIGNED NOT NULL COMMENT '打印任务ID(雪花ID)',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
job_no VARCHAR(40) NOT NULL COMMENT '打印任务编号',
template_id BIGINT UNSIGNED NOT NULL COMMENT '打印模板逻辑ID',
business_type VARCHAR(32) NULL COMMENT '关联业务类型',
business_id VARCHAR(64) NULL COMMENT '关联业务逻辑ID',
printer_code VARCHAR(64) NULL COMMENT '目标打印机编码',
copies INT UNSIGNED NOT NULL DEFAULT 1 COMMENT '打印份数',
payload_json JSON NOT NULL COMMENT '模板数据快照',
status VARCHAR(24) NOT NULL DEFAULT 'PENDING' COMMENT '任务状态',
output_file_id BIGINT UNSIGNED NULL COMMENT '生成文件的逻辑ID',
error_message VARCHAR(1000) NULL COMMENT '失败原因',
requested_by BIGINT UNSIGNED NOT NULL COMMENT '申请人ID',
requested_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '申请时间',
started_at DATETIME(3) NULL COMMENT '开始时间',
completed_at DATETIME(3) NULL COMMENT '完成时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
version INT NOT NULL DEFAULT 0 COMMENT '乐观锁版本号',
PRIMARY KEY (id),
UNIQUE KEY uk_print_job_no (tenant_id, job_no),
KEY idx_print_job_status (tenant_id, status, requested_at),
KEY idx_print_job_business (tenant_id, business_type, business_id)
) ENGINE=InnoDB COMMENT='打印任务表';
CREATE TABLE IF NOT EXISTS print_job_history (
id BIGINT UNSIGNED NOT NULL COMMENT '任务历史ID',
tenant_id BIGINT UNSIGNED NOT NULL COMMENT '数据所属租户ID',
print_job_id BIGINT UNSIGNED NOT NULL COMMENT '打印任务逻辑ID',
from_status VARCHAR(24) NULL COMMENT '变更前状态',
to_status VARCHAR(24) NOT NULL COMMENT '变更后状态',
message VARCHAR(500) NULL COMMENT '状态说明',
operator_id BIGINT UNSIGNED NULL COMMENT '操作人ID',
occurred_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '发生时间',
PRIMARY KEY (id),
KEY idx_print_history_job (tenant_id, print_job_id, occurred_at)
) ENGINE=InnoDB COMMENT='打印任务状态历史表';
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2014-2021 Adobe (http://www.adobe.com/), with Reserved Font Name 'Source'
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,131 @@
openapi: 3.0.3
info:
title: Baiyue Print Internal API
version: 1.0.0
description: Internal stateless PDF rendering API. This path must not be exposed through Gateway.
paths:
/internal/print/v1/pdfs/inbound-marks:
post:
summary: Generate an inbound-mark PDF
operationId: generateInboundMarkPdf
security:
- internalServiceToken: []
parameters:
- $ref: '#/components/parameters/RequestId'
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/InboundMarkPdfRequest'
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:
type: apiKey
in: header
name: X-Internal-Service-Token
parameters:
RequestId:
name: X-Request-Id
in: header
required: false
schema:
type: string
schemas:
InboundMarkPdfRequest:
type: object
required: [templateCode, pageSpec, labels]
properties:
templateCode:
type: string
enum: [INBOUND_MARK_V1]
pageSpec:
$ref: '#/components/schemas/PageSpec'
downloadFileName:
type: string
maxLength: 120
example: PCHK2608040174-19件-MCC1.pdf
labels:
type: array
minItems: 1
maxItems: 2000
items:
$ref: '#/components/schemas/InboundMarkLabel'
PageSpec:
type: object
required: [widthMm, heightMm]
properties:
widthMm:
type: number
enum: [100]
heightMm:
type: number
enum: [100]
InboundMarkLabel:
type: object
required: [topCode, warehouseText, cartonText, routeText, qrContent]
properties:
topCode:
type: string
warehouseText:
type: string
cartonText:
type: string
routeText:
type: string
qrContent:
type: string
minLength: 1
maxLength: 34
pattern: '^[\\x20-\\x7E]+$'
ApiError:
type: object
required: [code, message]
properties:
code:
type: string
enum: [A0400, A0403, A0422, A0429, S0500]
message:
type: string
data:
nullable: true
requestId:
type: string
nullable: true
traceId:
type: string
nullable: true
@@ -0,0 +1,18 @@
package com.baiyue.print;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest(
classes = PrintServiceApplication.class,
webEnvironment = SpringBootTest.WebEnvironment.NONE,
properties = {
"spring.cloud.nacos.config.enabled=false",
"spring.cloud.nacos.discovery.enabled=false",
"baiyue.security.internal-service-token=test-internal-token-at-least-32-bytes"
})
class PrintServiceApplicationTest {
@Test
void startsWithoutDatabaseRedisOrNacos() {
}
}
@@ -0,0 +1,179 @@
package com.baiyue.print.acceptance;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assumptions.assumeTrue;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.baiyue.print.service.InboundMarkPdfService;
import com.baiyue.print.service.PdfGenerationLimiter;
import com.google.zxing.BinaryBitmap;
import com.google.zxing.MultiFormatReader;
import com.google.zxing.RGBLuminanceSource;
import com.google.zxing.common.HybridBinarizer;
import java.awt.image.BufferedImage;
import java.io.ByteArrayInputStream;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Stream;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
/** End-to-end renderer acceptance checks. These intentionally exercise the real service, not mocks. */
class InboundMarkPdfAcceptanceTest {
private static final String TEMPLATE = "INBOUND_MARK_V1";
private static final BigDecimal MM_100 = new BigDecimal("100");
@Test
void generatesOnePageWithExactSizeTextOrderEmbeddedChineseFontAndNoDiskArtifact(@TempDir Path tempDir)
throws Exception {
InboundMarkPdfService service = service();
InboundMarkPdfDtos.Label label = label(1);
String originalTmp = System.getProperty("java.io.tmpdir");
System.setProperty("java.io.tmpdir", tempDir.toString());
try {
byte[] pdf = service.generate(request(List.of(label))).content();
assertPdf(pdf, List.of(label));
try (Stream<Path> files = Files.list(tempDir)) {
assertThat(files.filter(Files::isRegularFile).toList())
.as("PDF rendering must not create temporary files")
.isEmpty();
}
} finally {
if (originalTmp == null) {
System.clearProperty("java.io.tmpdir");
} else {
System.setProperty("java.io.tmpdir", originalTmp);
}
}
}
@Test
void preservesPageOrderAndDecodableQrOnFirstMiddleAndLastPages() throws Exception {
List<InboundMarkPdfDtos.Label> labels = labels(19);
assertPdf(service().generate(request(labels)).content(), labels);
}
@Test
void generatesTwoHundredEighteenPagesWithExactDimensions() throws Exception {
List<InboundMarkPdfDtos.Label> labels = labels(218);
byte[] pdf = service().generate(request(labels)).content();
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(218);
for (int i : List.of(0, 108, 217)) {
assertExactPageSize(document, i);
}
}
}
@Test
void maximumCapacityIsCoveredWhenExplicitlyEnabled() {
assumeTrue(Boolean.getBoolean("print.capacity.tests"),
"Enable with -Dprint.capacity.tests=true for the 2000-page capacity acceptance test");
List<InboundMarkPdfDtos.Label> labels = labels(2000);
byte[] pdf = service().generate(request(labels)).content();
assertThat(pdf).startsWith("%PDF".getBytes(StandardCharsets.US_ASCII));
assertThat(pdf.length).isLessThan(32 * 1024 * 1024);
try (PDDocument document = Loader.loadPDF(pdf)) {
assertThat(document.getNumberOfPages()).isEqualTo(2000);
} catch (Exception exception) {
throw new AssertionError("Generated 2000-page PDF must be readable", exception);
}
}
private void assertPdf(byte[] bytes, List<InboundMarkPdfDtos.Label> labels) throws Exception {
try (PDDocument document = Loader.loadPDF(bytes)) {
assertThat(document.getNumberOfPages()).isEqualTo(labels.size());
PDFTextStripper stripper = new PDFTextStripper();
for (int i : representativeIndexes(labels.size())) {
assertExactPageSize(document, i);
stripper.setStartPage(i + 1);
stripper.setEndPage(i + 1);
String text = stripper.getText(document);
InboundMarkPdfDtos.Label label = labels.get(i);
assertThat(text).contains(label.topCode(), label.warehouseText(), label.cartonText(),
label.routeText(), label.qrContent(), "MADE IN CHINA");
assertThat(text.indexOf(label.topCode())).isLessThan(text.indexOf(label.warehouseText()));
assertThat(text.indexOf(label.warehouseText())).isLessThan(text.indexOf(label.cartonText()));
assertThat(text.indexOf(label.cartonText())).isLessThan(text.indexOf(label.routeText()));
assertThat(text.indexOf(label.routeText())).isLessThan(text.indexOf(label.qrContent()));
assertThat(text.lines().filter(label.topCode()::equals).count())
.as("top code must appear as exactly one standalone line")
.isEqualTo(1);
assertEmbeddedFont(document, i);
}
decodeQrs(document, labels);
}
}
private void decodeQrs(PDDocument document, List<InboundMarkPdfDtos.Label> labels) throws Exception {
PDFRenderer renderer = new PDFRenderer(document);
for (int i : representativeIndexes(labels.size())) {
BufferedImage image = renderer.renderImageWithDPI(i, 300);
int[] pixels = image.getRGB(0, 0, image.getWidth(), image.getHeight(), null, 0, image.getWidth());
BinaryBitmap bitmap = new BinaryBitmap(new HybridBinarizer(
new RGBLuminanceSource(image.getWidth(), image.getHeight(), pixels)));
String decoded = new MultiFormatReader().decode(bitmap).getText();
assertThat(decoded).isEqualTo(labels.get(i).qrContent());
}
}
private void assertEmbeddedFont(PDDocument document, int pageIndex) throws Exception {
boolean embedded = false;
for (org.apache.pdfbox.cos.COSName name : document.getPage(pageIndex).getResources().getFontNames()) {
PDFont font = document.getPage(pageIndex).getResources().getFont(name);
embedded |= font.isEmbedded();
}
assertThat(embedded).as("page %s must contain an embedded font", pageIndex + 1).isTrue();
}
private void assertExactPageSize(PDDocument document, int index) {
float expected = 100f * 72f / 25.4f;
assertThat(document.getPage(index).getMediaBox().getWidth()).isCloseTo(expected,
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(index).getMediaBox().getHeight()).isCloseTo(expected,
org.assertj.core.data.Offset.offset(0.002f));
assertThat(document.getPage(index).getRotation()).isZero();
}
private List<Integer> representativeIndexes(int size) {
if (size == 1) return List.of(0);
return List.of(0, size / 2, size - 1);
}
private InboundMarkPdfService service() {
PrintPdfProperties properties = new PrintPdfProperties();
return new InboundMarkPdfService(properties, new PdfGenerationLimiter(properties),
new InboundMarkV1Renderer(new PdfQrRenderer()), new PdfQrRenderer());
}
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
return new InboundMarkPdfDtos.Request(TEMPLATE,
new InboundMarkPdfDtos.PageSpec(MM_100, MM_100), "acceptance.pdf", labels);
}
private List<InboundMarkPdfDtos.Label> labels(int total) {
List<InboundMarkPdfDtos.Label> labels = new ArrayList<>(total);
for (int i = 1; i <= total; i++) labels.add(label(i, total));
return labels;
}
private InboundMarkPdfDtos.Label label(int box) { return label(box, 1); }
private InboundMarkPdfDtos.Label label(int box, int total) {
String suffix = total + "-" + box;
return new InboundMarkPdfDtos.Label("PCHK2608040174", "MCC1", "CTNS:" + suffix,
"洛杉矶普船海卡", "PCHK2608040174#" + suffix);
}
}
@@ -0,0 +1,110 @@
package com.baiyue.print.acceptance;
import static org.assertj.core.api.Assertions.assertThat;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.junit.jupiter.api.Test;
/** Structural guardrails for the stateless service boundary. */
class PrintStatelessArchitectureTest {
@Test
void moduleDoesNotDeclareDatabaseOrMybatisDependencies() throws Exception {
String pom = Files.readString(moduleRoot().resolve("pom.xml"), StandardCharsets.UTF_8);
assertThat(pom).doesNotContain("mybatis-plus-spring-boot3-starter")
.doesNotContain("mysql-connector-j")
.doesNotContain("spring-jdbc")
.doesNotContain("spring-boot-starter-jdbc");
}
@Test
void sourceAndConfigurationDoNotDeclareDatasourceOrDatabaseAccess() throws Exception {
Path root = moduleRoot();
String source;
try (Stream<Path> paths = Files.walk(root.resolve("src/main"))) {
source = paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java") || path.toString().endsWith(".yml")
|| path.toString().endsWith(".yaml"))
.map(this::read)
.collect(Collectors.joining("\n"));
}
assertThat(source).doesNotContain("DataSource").doesNotContain("JdbcTemplate")
.doesNotContain("@Mapper").doesNotContain("spring.datasource")
.doesNotContain("jdbc:");
}
@Test
void obsoleteDatabaseScriptIsNotPackaged() {
assertThat(Files.exists(moduleRoot().resolve("src/main/resources/db/print.sql"))).isFalse();
}
@Test
void runtimeClasspathDoesNotBringBackJdbcOrMybatisTransitively() {
ClassLoader loader = getClass().getClassLoader();
assertThat(isPresent("org.springframework.jdbc.core.JdbcTemplate", loader))
.as("Print must exclude the JDBC starter inherited through common-core")
.isFalse();
assertThat(isPresent("com.baomidou.mybatisplus.autoconfigure.MybatisPlusAutoConfiguration", loader))
.as("Print must exclude MyBatis inherited through common-core")
.isFalse();
assertThat(isPresent("org.springframework.data.redis.core.StringRedisTemplate", loader))
.as("Print must not require Redis for authentication or health")
.isFalse();
}
@Test
void configuredRequestByteLimitHasAnEnforcementConsumer() throws Exception {
String consumers = productionJavaExcept("PrintPdfProperties.java");
assertThat(consumers).as("max-request-bytes must be enforced before JSON deserialization")
.contains("getMaxRequestBytes()");
}
@Test
void fontResourceIsValidatedAtStartupOrReadiness() throws Exception {
String consumers = productionJavaExcept("PrintPdfProperties.java");
assertThat(consumers).contains("getFontResource()");
assertThat(consumers)
.as("a missing or invalid font must make startup/readiness fail, not wait for a PDF request")
.containsAnyOf("HealthIndicator", "ApplicationRunner", "SmartInitializingSingleton", "@PostConstruct");
}
private Path moduleRoot() {
Path cwd = Path.of("").toAbsolutePath().normalize();
for (Path candidate : new Path[]{cwd, cwd.resolve("print"), cwd.resolve("baiyue-firstleg/print")}) {
if (Files.exists(candidate.resolve("pom.xml")) && Files.exists(candidate.resolve("src/main"))) {
return candidate;
}
}
throw new IllegalStateException("Cannot locate the print module from " + cwd);
}
private String read(Path path) {
try {
return Files.readString(path, StandardCharsets.UTF_8);
} catch (Exception exception) {
throw new IllegalStateException(exception);
}
}
private String productionJavaExcept(String excludedFileName) throws Exception {
try (Stream<Path> paths = Files.walk(moduleRoot().resolve("src/main/java"))) {
return paths.filter(Files::isRegularFile)
.filter(path -> path.toString().endsWith(".java"))
.filter(path -> !path.getFileName().toString().equals(excludedFileName))
.map(this::read)
.collect(Collectors.joining("\n"));
}
}
private boolean isPresent(String className, ClassLoader loader) {
try {
Class.forName(className, false, loader);
return true;
} catch (ClassNotFoundException ignored) {
return false;
}
}
}
@@ -0,0 +1,23 @@
package com.baiyue.print.config;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
import org.springframework.boot.actuate.health.Status;
class PrintFontHealthIndicatorTest {
@Test
void reportsUpForPackagedParseableFont() {
PrintPdfProperties properties = new PrintPdfProperties();
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.UP);
}
@Test
void reportsDownForMissingFont() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setFontResource("fonts/missing.ttf");
assertThat(new PrintFontHealthIndicator(properties).health().getStatus()).isEqualTo(Status.DOWN);
}
}
@@ -0,0 +1,162 @@
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.InboundMarkPdfDtos;
import com.baiyue.print.service.InboundMarkPdfService;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.http.MediaType;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;
import org.springframework.web.bind.annotation.PostMapping;
import java.nio.charset.StandardCharsets;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import org.mockito.ArgumentCaptor;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class InternalPdfControllerTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
private InboundMarkPdfService service;
private MockMvc mockMvc;
@BeforeEach
void setUp() {
service = mock(InboundMarkPdfService.class);
InternalPdfController controller = new InternalPdfController(
service, new InternalServiceTokenGuard(TOKEN));
mockMvc = MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new GlobalExceptionHandler())
.build();
}
@Test
void returnsPdfWithDownloadHeaders() throws Exception {
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
pdf, "PCHK2608040174-19件-MCC1.pdf", 19));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.requestAttr("requestId", "request-123")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentType(MediaType.APPLICATION_PDF))
.andExpect(content().bytes(pdf))
.andExpect(header().string("Cache-Control", "no-store"))
.andExpect(header().string("Pragma", "no-cache"))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().string("X-Print-Page-Count", "19"))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("attachment")))
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("filename*=")));
verify(service).generate(any(InboundMarkPdfDtos.Request.class));
}
@Test
void rejectsMissingTokenAsJsonBeforeCallingService() throws Exception {
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("A0403"));
verify(service, never()).generate(any());
}
@Test
void returnsServiceValidationFailureAsJson() throws Exception {
when(service.generate(any())).thenThrow(
new BizException(ResultCode.VALIDATION_FAILED, "labels must contain at least one item"));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON)
.content(validRequestJson()))
.andExpect(status().isOk())
.andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON))
.andExpect(jsonPath("$.code").value("A0422"))
.andExpect(jsonPath("$.message").value("labels must contain at least one item"));
}
@Test
void derivesDefaultFileNameFromRequestId() throws Exception {
byte[] pdf = "%PDF-test".getBytes(StandardCharsets.US_ASCII);
when(service.generate(any())).thenReturn(new InboundMarkPdfService.GeneratedPdf(
pdf, "inbound-marks-request-123.pdf", 1));
mockMvc.perform(post("/internal/print/v1/pdfs/inbound-marks")
.header(InternalPdfController.INTERNAL_TOKEN_HEADER, TOKEN)
.requestAttr("requestId", "request-123")
.contentType(MediaType.APPLICATION_JSON)
.content(validRequestJson().replace(
"\"downloadFileName\": \"PCHK2608040174-19件-MCC1.pdf\",", "")))
.andExpect(status().isOk())
.andExpect(header().string("Content-Disposition",
org.hamcrest.Matchers.containsString("inbound-marks-request-123.pdf")));
ArgumentCaptor<InboundMarkPdfDtos.Request> captor =
ArgumentCaptor.forClass(InboundMarkPdfDtos.Request.class);
verify(service).generate(captor.capture());
assertThat(captor.getValue().downloadFileName()).isEqualTo("inbound-marks-request-123.pdf");
}
@Test
void endpointDoesNotPinProducedMediaTypeToPdf() throws Exception {
PostMapping mapping = InternalPdfController.class
.getMethod("generateInboundMarks", String.class, InboundMarkPdfDtos.Request.class,
jakarta.servlet.http.HttpServletRequest.class)
.getAnnotation(PostMapping.class);
assertThat(mapping.produces()).isEmpty();
}
@Test
void openApiDocumentsBothSuccessAndErrorRepresentations() throws Exception {
String contract = new ClassPathResource("openapi/print-internal-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8);
assertThat(contract)
.contains("/internal/print/v1/pdfs/inbound-marks:")
.contains("application/pdf:")
.contains("application/json:")
.contains("name: X-Internal-Service-Token")
.contains("maximum: 2000");
}
private String validRequestJson() {
return """
{
"templateCode": "INBOUND_MARK_V1",
"pageSpec": {"widthMm": 100, "heightMm": 100},
"downloadFileName": "PCHK2608040174-19件-MCC1.pdf",
"labels": [{
"topCode": "PCHK2608040174",
"warehouseText": "MCC1",
"cartonText": "CTNS:19-1",
"routeText": "洛杉矶普船海卡",
"qrContent": "PCHK2608040174#19-1"
}]
}
""";
}
}
@@ -0,0 +1,36 @@
package com.baiyue.print.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
class InternalServiceTokenGuardTest {
private static final String TOKEN = "0123456789abcdef0123456789abcdef";
@Test
void acceptsOnlyConfiguredToken() {
InternalServiceTokenGuard guard = new InternalServiceTokenGuard(TOKEN);
guard.verify(TOKEN);
assertThatThrownBy(() -> guard.verify(null))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
assertThatThrownBy(() -> guard.verify("wrong"))
.isInstanceOf(BizException.class)
.extracting(exception -> ((BizException) exception).getResultCode())
.isEqualTo(ResultCode.FORBIDDEN);
}
@Test
void refusesUnsafeConfiguration() {
assertThatThrownBy(() -> new InternalServiceTokenGuard("too-short"))
.isInstanceOf(IllegalStateException.class)
.hasMessageContaining("at least 32 bytes");
assertThatThrownBy(() -> new InternalServiceTokenGuard(null))
.isInstanceOf(IllegalStateException.class);
}
}
@@ -0,0 +1,89 @@
package com.baiyue.print.controller;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.mock.web.MockHttpServletRequest;
import org.springframework.mock.web.MockHttpServletResponse;
class PrintRequestSizeFilterTest {
private PrintRequestSizeFilter filter;
@BeforeEach
void setUp() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setMaxRequestBytes(10);
filter = new PrintRequestSizeFilter(properties, new ObjectMapper().findAndRegisterModules());
}
@Test
void passesBoundedBodyToDownstream() throws Exception {
MockHttpServletRequest request = request("1234567890", false);
MockHttpServletResponse response = new MockHttpServletResponse();
AtomicReference<String> received = new AtomicReference<>();
filter.doFilter(request, response, (wrapped, ignored) -> received.set(
new String(wrapped.getInputStream().readAllBytes(), StandardCharsets.UTF_8)));
assertThat(received).hasValue("1234567890");
assertThat(response.getStatus()).isEqualTo(200);
}
@Test
void rejectsOversizedDeclaredContentLengthWithoutCallingDownstream() throws Exception {
MockHttpServletRequest request = new MockHttpServletRequest() {
@Override public int getContentLength() { return 11; }
@Override public long getContentLengthLong() { return 11; }
};
initialize(request, "{}");
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 rejectsOversizedBodyWhenLengthIsUnknown() throws Exception {
MockHttpServletRequest request = request("12345678901", true);
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);
}
private MockHttpServletRequest request(String content, boolean unknownLength) {
MockHttpServletRequest request = unknownLength ? new MockHttpServletRequest() {
@Override public int getContentLength() { return -1; }
@Override public long getContentLengthLong() { return -1; }
} : new MockHttpServletRequest();
initialize(request, content);
return request;
}
private void initialize(MockHttpServletRequest request, String content) {
request.setMethod("POST");
request.setRequestURI("/internal/print/v1/pdfs/inbound-marks");
request.setContent(content.getBytes(StandardCharsets.UTF_8));
request.setAttribute("requestId", "request-123");
request.setAttribute("traceId", "trace-123");
}
private void assertTooLarge(MockHttpServletResponse response) throws Exception {
assertThat(response.getStatus()).isEqualTo(413);
assertThat(response.getContentType()).startsWith("application/json");
assertThat(response.getContentAsString()).contains("\"code\":\"A0400\"")
.contains("Request body exceeds 10 bytes")
.contains("\"requestId\":\"request-123\"");
}
}
@@ -0,0 +1,22 @@
package com.baiyue.print.renderer;
import static org.assertj.core.api.Assertions.assertThat;
import com.google.zxing.common.BitMatrix;
import org.junit.jupiter.api.Test;
class PdfQrRendererTest {
@Test
void encodesSameContentDeterministicallyWithQuietZone() {
PdfQrRenderer renderer = new PdfQrRenderer();
BitMatrix first = renderer.encode("PCHK2608040174#19-1");
BitMatrix second = renderer.encode("PCHK2608040174#19-1");
assertThat(first).isEqualTo(second);
assertThat(first.getWidth()).isEqualTo(first.getHeight()).isGreaterThan(21);
assertThat(first.getEnclosingRectangle()).isNotNull();
assertThat(first.getEnclosingRectangle()[0]).isGreaterThanOrEqualTo(4);
assertThat(first.getEnclosingRectangle()[1]).isGreaterThanOrEqualTo(4);
}
}
@@ -0,0 +1,24 @@
package com.baiyue.print.renderer;
import static org.assertj.core.api.Assertions.assertThat;
import org.junit.jupiter.api.Test;
class PdfUnitsTest {
@Test
void convertsOneHundredMillimetersToExactPdfPoints() {
assertThat(PdfUnits.mmToPt(100)).isCloseTo(283.46457f, within(0.0001f));
}
@Test
void convertsTopDownCoordinatesToPdfCoordinates() {
PdfUnits units = new PdfUnits(100);
assertThat(units.topDownMmToPdfY(0)).isCloseTo(PdfUnits.mmToPt(100), within(0.0001f));
assertThat(units.topDownMmToPdfY(100)).isCloseTo(0f, within(0.0001f));
}
private static org.assertj.core.data.Offset<Float> within(float value) {
return org.assertj.core.data.Offset.offset(value);
}
}
@@ -0,0 +1,69 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import com.baiyue.print.config.PrintPdfProperties;
import com.baiyue.print.dto.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import com.baiyue.print.renderer.PdfUnits;
import java.io.IOException;
import java.math.BigDecimal;
import java.util.List;
import org.apache.pdfbox.Loader;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.text.PDFTextStripper;
import org.junit.jupiter.api.Test;
class InboundMarkPdfServiceGenerationTest {
@Test
void generatesOneStrictHundredMillimeterPagePerLabelInRequestOrder() throws IOException {
PrintPdfProperties properties = new PrintPdfProperties();
PdfQrRenderer qrRenderer = new PdfQrRenderer();
InboundMarkPdfService service = new InboundMarkPdfService(
properties,
new PdfGenerationLimiter(properties),
new InboundMarkV1Renderer(qrRenderer),
qrRenderer);
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
"INBOUND_MARK_V1",
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100"), new BigDecimal("100.0")),
"PCHK2608040174.pdf",
List.of(label(1), label(2)));
InboundMarkPdfService.GeneratedPdf generated = service.generate(request);
assertThat(generated.fileName()).isEqualTo("PCHK2608040174.pdf");
assertThat(generated.pageCount()).isEqualTo(2);
assertThat(generated.content()).startsWith("%PDF-".getBytes(java.nio.charset.StandardCharsets.US_ASCII));
try (PDDocument document = Loader.loadPDF(generated.content())) {
assertThat(document.getNumberOfPages()).isEqualTo(2);
document.getPages().forEach(page -> {
assertThat(page.getRotation()).isZero();
assertThat(page.getMediaBox().getWidth())
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
assertThat(page.getMediaBox().getHeight())
.isCloseTo(PdfUnits.mmToPt(100), org.assertj.core.data.Offset.offset(0.001f));
});
PDFTextStripper stripper = new PDFTextStripper();
stripper.setStartPage(1);
stripper.setEndPage(1);
assertThat(stripper.getText(document))
.contains("PCHK2608040174", "MCC1", "CTNS:2-1", "洛杉矶普船海卡",
"PCHK2608040174#2-1", "MADE IN CHINA");
stripper.setStartPage(2);
stripper.setEndPage(2);
assertThat(stripper.getText(document)).contains("CTNS:2-2", "PCHK2608040174#2-2");
}
}
private InboundMarkPdfDtos.Label label(int sequence) {
return new InboundMarkPdfDtos.Label(
"PCHK2608040174",
"MCC1",
"CTNS:2-" + sequence,
"洛杉矶普船海卡",
"PCHK2608040174#2-" + sequence);
}
}
@@ -0,0 +1,101 @@
package com.baiyue.print.service;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.Mockito.mock;
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.InboundMarkPdfDtos;
import com.baiyue.print.renderer.InboundMarkV1Renderer;
import com.baiyue.print.renderer.PdfQrRenderer;
import java.math.BigDecimal;
import java.util.List;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
class InboundMarkPdfServiceValidationTest {
private InboundMarkPdfService service;
@BeforeEach
void setUp() {
PrintPdfProperties properties = new PrintPdfProperties();
service = new InboundMarkPdfService(
properties,
mock(PdfGenerationLimiter.class),
mock(InboundMarkV1Renderer.class),
mock(PdfQrRenderer.class));
}
@Test
void rejectsUnsupportedPageSizeBeforeRendering() {
InboundMarkPdfDtos.Request request = request(label("PCHK2608040174#19-1"));
request = new InboundMarkPdfDtos.Request(
request.templateCode(),
new InboundMarkPdfDtos.PageSpec(new BigDecimal("99"), new BigDecimal("100")),
request.downloadFileName(), request.labels());
assertValidationFailure(request, "pageSpec.widthMm");
}
@Test
void rejectsMoreThanTwoThousandPages() {
InboundMarkPdfDtos.Label label = label("PCHK2608040174#19-1");
InboundMarkPdfDtos.Request request = request(java.util.Collections.nCopies(2001, label));
assertValidationFailure(request, "2000");
}
@Test
void rejectsControlCharactersInDisplayText() {
InboundMarkPdfDtos.Label label = new InboundMarkPdfDtos.Label(
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶\n普船海卡", "PCHK2608040174#19-1");
assertValidationFailure(request(label), "labels[0].routeText");
}
@Test
void rejectsQrContentLongerThanThirtyFourAsciiCharacters() {
assertValidationFailure(request(label("A".repeat(35))), "34");
}
@Test
void rejectsNonAsciiQrContent() {
assertValidationFailure(request(label("PCHK#中文")), "printable ASCII");
}
@Test
void rejectsPathLikeDownloadFileName() {
InboundMarkPdfDtos.Request base = request(label("PCHK2608040174#19-1"));
InboundMarkPdfDtos.Request request = new InboundMarkPdfDtos.Request(
base.templateCode(), base.pageSpec(), "folder/labels.pdf", base.labels());
assertValidationFailure(request, "reserved character");
}
private void assertValidationFailure(InboundMarkPdfDtos.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 InboundMarkPdfDtos.Request request(InboundMarkPdfDtos.Label label) {
return request(List.of(label));
}
private InboundMarkPdfDtos.Request request(List<InboundMarkPdfDtos.Label> labels) {
return new InboundMarkPdfDtos.Request(
"INBOUND_MARK_V1",
new InboundMarkPdfDtos.PageSpec(new BigDecimal("100.0"), new BigDecimal("100")),
"labels.pdf",
labels);
}
private InboundMarkPdfDtos.Label label(String qrContent) {
return new InboundMarkPdfDtos.Label(
"PCHK2608040174", "MCC1", "CTNS:19-1", "洛杉矶普船海卡", qrContent);
}
}
@@ -0,0 +1,30 @@
package com.baiyue.print.service;
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 java.time.Duration;
import org.junit.jupiter.api.Test;
class PdfGenerationLimiterTest {
@Test
void rejectsWhenAllPermitsAreInUseAndReleasesPermitOnClose() {
PrintPdfProperties properties = new PrintPdfProperties();
properties.setMaxConcurrentGenerations(1);
properties.setAcquireTimeout(Duration.ZERO);
PdfGenerationLimiter limiter = new PdfGenerationLimiter(properties);
PdfGenerationLimiter.Permit permit = limiter.acquire();
assertThatThrownBy(limiter::acquire)
.isInstanceOfSatisfying(BizException.class,
exception -> org.assertj.core.api.Assertions.assertThat(exception.getResultCode())
.isEqualTo(ResultCode.TOO_MANY_REQUESTS));
permit.close();
try (PdfGenerationLimiter.Permit ignored = limiter.acquire()) {
// A second acquisition proves that close returned the only permit.
}
}
}