diff --git a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java index 4327ca591..e0a8b2c87 100644 --- a/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java +++ b/file/src/main/java/com/baiyue/file/service/BusinessFileStorage.java @@ -105,7 +105,9 @@ public class BusinessFileStorage { normalized = normalized.substring(normalized.lastIndexOf('/') + 1) .replaceAll("[\\p{Cntrl}]", "").trim(); if (normalized.isBlank()) return "attachment"; - return normalized.length() <= 255 ? normalized : normalized.substring(normalized.length() - 255); + int codePoints = normalized.codePointCount(0, normalized.length()); + if (codePoints <= 255) return normalized; + return normalized.substring(normalized.offsetByCodePoints(0, codePoints - 255)); } private static String sha256(Path path) throws IOException { diff --git a/file/src/main/resources/openapi/ocean-booking-file-v1.yaml b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml index 97797d0ce..d3fcd8b42 100644 --- a/file/src/main/resources/openapi/ocean-booking-file-v1.yaml +++ b/file/src/main/resources/openapi/ocean-booking-file-v1.yaml @@ -7,6 +7,8 @@ info: businessType=OCEAN_BOOKING_DOCUMENT、businessId=oceanBookingId。BOOKING_CONFIRMATION与 BILL_OF_LADING文档类型、版本和有效状态归Firstleg;File不判断订舱业务状态。 x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING] + x-lifecycle-owner: FIRSTLEG + x-file-object-semantics: IMMUTABLE_CREATE_ONLY paths: /internal/file/v1/business-files/{tenantId}/OCEAN_BOOKING_DOCUMENT/{oceanBookingId}: post: diff --git a/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java index 8a08ea3ee..33b12f2d8 100644 --- a/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/OceanBookingFileContractTest.java @@ -18,9 +18,12 @@ class OceanBookingFileContractTest { .contains("description: 订舱customerId") .contains("description: oceanBookingId") .contains("x-firstleg-document-types: [BOOKING_CONFIRMATION, BILL_OF_LADING]") + .contains("x-lifecycle-owner: FIRSTLEG") + .contains("x-file-object-semantics: IMMUTABLE_CREATE_ONLY") .contains("任意格式,最大50MiB") .contains("maximum: 52428800") .contains("name: X-Internal-Service-Token", "Cache-Control") - .doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/"); + .doesNotContain("TRUCK_APPOINTMENT_ATTACHMENT", "/api/platform/", "/api/merchant/", + "Idempotency-Key", "expectedVersion", "replace"); } } diff --git a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java index 7e6c38380..a75da72c6 100644 --- a/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java +++ b/file/src/test/java/com/baiyue/file/controller/InternalBusinessFileControllerTest.java @@ -57,6 +57,22 @@ class InternalBusinessFileControllerTest { assertThat(response.getHeaders().getContentType()).isEqualTo(org.springframework.http.MediaType.APPLICATION_OCTET_STREAM); } + @Test void unicodeSpecialFileNameUsesUtf8AttachmentDisposition() { + String fileName = "订舱 确认 🚢 #[]()+,最终版.xlsx"; + when(service.download(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 93L)) + .thenReturn(new InternalBusinessFileService.Download(fileName, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{4})); + + var response = controller.download("internal-token", 10001L, + "OCEAN_BOOKING_DOCUMENT", "61", 93L); + + assertThat(response.getHeaders().getContentDisposition().getType()).isEqualTo("attachment"); + assertThat(response.getHeaders().getContentDisposition().getFilename()).isEqualTo(fileName); + assertThat(response.getHeaders().getFirst(org.springframework.http.HttpHeaders.CONTENT_DISPOSITION)) + .contains("filename*="); + assertThat(response.getHeaders().getCacheControl()).contains("no-store"); + } + @Test void rejectsInvalidTokenAndNegativeTenantBeforeServiceAccess() { var file = new MockMultipartFile("file", "x.txt", "text/plain", "x".getBytes()); assertThatThrownBy(() -> controller.upload("wrong", 7L, 0L, @@ -65,6 +81,9 @@ class InternalBusinessFileControllerTest { assertThatThrownBy(() -> controller.metadata("internal-token", -1L, "CONTAINER_CLEARANCE_DOCUMENT", "41", 81L)) .isInstanceOf(BizException.class).hasMessageContaining("不能为负数"); + assertThatThrownBy(() -> controller.download("wrong", 10001L, + "OCEAN_BOOKING_DOCUMENT", "61", 81L)) + .isInstanceOf(BizException.class).hasMessageContaining("令牌无效"); verifyNoInteractions(service); } } diff --git a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java index 6b612a8dc..390584ecf 100644 --- a/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/BusinessFileStorageTest.java @@ -7,6 +7,12 @@ import org.springframework.mock.web.MockMultipartFile; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStream; +import java.io.RandomAccessFile; +import java.nio.file.Files; import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; @@ -39,6 +45,38 @@ class BusinessFileStorageTest { .isInstanceOf(BizException.class).hasMessageContaining("50MB"); } + @Test void rejectsZeroByteAndAcceptsExactFiftyMibArbitraryBinary() throws Exception { + BusinessFileStorage storage = storage(); + var empty = new MockMultipartFile("file", "empty.bin", "application/octet-stream", new byte[0]); + assertThatThrownBy(() -> storage.store(10001L, 61L, empty, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + .isInstanceOf(BizException.class).hasMessageContaining("不能为空"); + + MultipartFile boundary = new SparseMultipartFile("exact-50m.bin", "application/x-booking-binary", + BusinessFileCategory.CUSTOMS_MAX_SIZE); + var stored = storage.store(10001L, 61L, boundary, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true); + + assertThat(stored.size()).isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE); + assertThat(stored.contentType()).isEqualTo("application/x-booking-binary"); + assertThat(Files.size(root.resolve(stored.objectKey()))) + .isEqualTo(BusinessFileCategory.CUSTOMS_MAX_SIZE); + } + + @Test void preservesUnicodeSpacesAndSpecialCharactersWithoutSplittingSurrogatePairs() { + BusinessFileStorage storage = storage(); + String original = "folder\\" + "超".repeat(280) + " 中文 空格 #[]()+,🚢.xlsx "; + var file = new MockMultipartFile("file", original, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", new byte[]{1, 2}); + + var stored = storage.store(10001L, 61L, file, + BusinessFileCategory.CUSTOMS_MAX_SIZE, true); + + assertThat(stored.originalName()).endsWith(" 中文 空格 #[]()+,🚢.xlsx").contains("🚢"); + assertThat(stored.originalName()).doesNotContain("/", "\\", "\r", "\n"); + assertThat(stored.originalName().codePointCount(0, stored.originalName().length())).isEqualTo(255); + } + @Test void legacyBusinessFilesRetainRestrictedFormatsAndTenMegabyteLimit() { BusinessFileStorage storage = storage(); var file = new MockMultipartFile("file", "archive.zip", "application/zip", new byte[]{1}); @@ -54,4 +92,25 @@ class BusinessFileStorageTest { storage.initializeRoot(); return storage; } + + private record SparseMultipartFile(String originalFilename, String contentType, long size) + implements MultipartFile { + @Override public String getName() { return "file"; } + @Override public String getOriginalFilename() { return originalFilename; } + @Override public String getContentType() { return contentType; } + @Override public boolean isEmpty() { return size == 0; } + @Override public long getSize() { return size; } + @Override public byte[] getBytes() { throw new UnsupportedOperationException(); } + @Override public InputStream getInputStream() { + return new ByteArrayInputStream(new byte[0]); + } + @Override public void transferTo(File destination) throws IOException { + try (RandomAccessFile output = new RandomAccessFile(destination, "rw")) { + output.setLength(size); + } + } + @Override public void transferTo(Path destination) throws IOException { + transferTo(destination.toFile()); + } + } } diff --git a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java index 5f493379e..91cef3a2e 100644 --- a/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java +++ b/file/src/test/java/com/baiyue/file/service/InternalBusinessFileServiceTest.java @@ -8,6 +8,10 @@ import org.junit.jupiter.api.Test; import org.mockito.*; import org.springframework.mock.web.MockMultipartFile; +import java.util.List; +import java.util.concurrent.Executors; +import java.util.concurrent.atomic.AtomicLong; + import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.*; import static org.mockito.Mockito.*; @@ -208,4 +212,34 @@ class InternalBusinessFileServiceTest { verify(storage).read(value.getObjectKey()); } + @Test void concurrentRepeatedUploadsCreateDistinctImmutableObjectsForFirstlegToVersion() throws Exception { + MockMultipartFile file = new MockMultipartFile("file", "booking.bin", + "application/x-booking", new byte[]{1, 2, 3}); + when(storage.store(10001L, 61L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true)) + .thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object", + "application/x-booking", 3, "a".repeat(64), "booking.bin")); + AtomicLong ids = new AtomicLong(200); + when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> { + ((FileObjectEntity) invocation.getArgument(0)).setId(ids.incrementAndGet()); + return 1; + }); + var executor = Executors.newFixedThreadPool(2); + try { + var firstFuture = executor.submit(() -> + service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); + var secondFuture = executor.submit(() -> + service.upload(10001L, "OCEAN_BOOKING_DOCUMENT", "61", 7L, file)); + + var first = firstFuture.get(); + var second = secondFuture.get(); + assertThat(first.fileId()).isNotEqualTo(second.fileId()); + assertThat(first.businessId()).isEqualTo("61"); + assertThat(second.businessId()).isEqualTo("61"); + verify(files, times(2)).insert(any(FileObjectEntity.class)); + verify(files, never()).updateById(any(FileObjectEntity.class)); + } finally { + executor.shutdownNow(); + } + } + } diff --git a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java index 4a5cd5f93..f0d45499e 100644 --- a/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java +++ b/print/src/test/java/com/baiyue/print/controller/InternalPdfControllerTest.java @@ -93,6 +93,19 @@ class InternalPdfControllerTest { verify(service, never()).generate(any()); } + @Test + void rejectsFrontendStyleFreightPrintWithoutInternalToken() throws Exception { + mockMvc.perform(post("/internal/print/v1/pdfs/freight-a4-documents") + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_PDF, MediaType.APPLICATION_JSON) + .content(validFreightA4RequestJson())) + .andExpect(status().isOk()) + .andExpect(content().contentTypeCompatibleWith(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.code").value("A0403")); + + verify(freightA4PdfService, never()).generate(any()); + } + @Test void returnsServiceValidationFailureAsJson() throws Exception { when(service.generate(any())).thenThrow( diff --git a/print/src/test/java/com/baiyue/print/service/FreightA4ConcurrentGenerationTest.java b/print/src/test/java/com/baiyue/print/service/FreightA4ConcurrentGenerationTest.java new file mode 100644 index 000000000..cf2ce1191 --- /dev/null +++ b/print/src/test/java/com/baiyue/print/service/FreightA4ConcurrentGenerationTest.java @@ -0,0 +1,78 @@ +package com.baiyue.print.service; + +import com.baiyue.print.config.PrintPdfProperties; +import com.baiyue.print.dto.FreightA4PdfDtos; +import com.baiyue.print.renderer.FreightA4Renderer; +import jakarta.annotation.Resource; +import org.apache.pdfbox.Loader; +import org.junit.jupiter.api.Test; +import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; + +import java.math.BigDecimal; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Executors; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringJUnitConfig(classes = {FreightA4PdfService.class, PrintPdfProperties.class, + PdfGenerationLimiter.class, FreightA4Renderer.class}) +class FreightA4ConcurrentGenerationTest { + @Resource private FreightA4PdfService service; + + @Test + void generatesBothTemplatesConcurrentlyAsIndependentParseableA4Pdfs() throws Exception { + var executor = Executors.newFixedThreadPool(2); + try { + var shipping = CompletableFuture.supplyAsync(() -> service.generate(shippingRequest()), executor); + var bill = CompletableFuture.supplyAsync(() -> service.generate(billRequest()), executor); + CompletableFuture.allOf(shipping, bill).get(); + + assertPdf(shipping.get(), "OB-260831-0001-出口货物托运单.pdf"); + assertPdf(bill.get(), "BL-260831-0001-提单确认件.pdf"); + } finally { + executor.shutdownNow(); + } + } + + private static void assertPdf(FreightA4PdfService.GeneratedPdf generated, String fileName) throws Exception { + assertThat(generated.fileName()).isEqualTo(fileName); + assertThat(generated.pageCount()).isEqualTo(1); + assertThat(generated.content()).startsWith("%PDF-".getBytes(StandardCharsets.US_ASCII)); + try (var pdf = Loader.loadPDF(generated.content())) { + assertThat(pdf.getNumberOfPages()).isEqualTo(1); + assertThat(pdf.getPage(0).getMediaBox().getWidth()).isBetween(595f, 596f); + assertThat(pdf.getPage(0).getMediaBox().getHeight()).isBetween(841f, 843f); + } + } + + private static FreightA4PdfDtos.Request shippingRequest() { + return request(FreightA4PdfDtos.SHIPPING_INSTRUCTION_TEMPLATE, + "OB-260831-0001-出口货物托运单.pdf", Map.of( + "toCompany", "订舱代理", "shipper", "SHIPPER", "consignee", "CONSIGNEE", + "notifyParty", "NOTIFY", "jobNumber", "OB-260831-0001", "cargoQuantity", "1*40HQ", + "portOfLoading", "NINGBO", "portOfDischarge", "LOS ANGELES", + "placeOfDelivery", "LOS ANGELES", "totalPackagesInWords", "SAY ONE CONTAINER ONLY.")); + } + + private static FreightA4PdfDtos.Request billRequest() { + return request(FreightA4PdfDtos.BILL_CONFIRMATION_TEMPLATE, + "BL-260831-0001-提单确认件.pdf", Map.of( + "shipper", "SHIPPER", "consignee", "CONSIGNEE", "notifyParty", "NOTIFY", + "bookingNumber", "BL-260831-0001", "portOfLoading", "NINGBO", + "portOfDischarge", "LOS ANGELES", "placeOfDelivery", "LOS ANGELES", + "serviceTerm", "CY-CY", "freightTerm", "FREIGHT PREPAID", + "totalPackagesInWords", "SAY ONE CONTAINER ONLY.")); + } + + private static FreightA4PdfDtos.Request request(String template, String fileName, + Map fields) { + var cargo = new FreightA4PdfDtos.CargoRow("N/M", "1", "CONTAINER", "GENERAL CARGO", + "1000 KGS", "10 CBM"); + return new FreightA4PdfDtos.Request(template, + new FreightA4PdfDtos.PageSpec(new BigDecimal("210"), new BigDecimal("297")), fileName, + List.of(new FreightA4PdfDtos.Document(fields, List.of(cargo)))); + } +}