fix(file): support optional legal ID and order import files

This commit is contained in:
2026-09-01 19:52:42 +08:00
parent 2ec8fcb886
commit b4e496e9cb
13 changed files with 530 additions and 57 deletions
@@ -0,0 +1,37 @@
package com.baiyue.file.config;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class QualificationAndOrderFileOpenApiContractTest {
@Test
void qualificationPackageRequiresOnlyTheBusinessLicenseFile() throws Exception {
String yaml = Files.readString(Path.of(
"src/main/resources/openapi/qualification-required-package-v1.yaml"));
assertThat(yaml)
.contains("required: [businessLicense]")
.contains("legalPersonId:", "法人身份证,选填")
.contains("legalPersonIdQualificationId:", "legalPersonIdDocumentUrl:")
.doesNotContain("required: [businessLicense, legalPersonId]");
}
@Test
void orderBatchCategoriesAreCustomerOwnedXlsxObjectsWithStableBatchNumber() throws Exception {
String yaml = Files.readString(Path.of(
"src/main/resources/openapi/order-batch-import-file-v1.yaml"));
assertThat(yaml)
.contains("ORDER_BATCH_IMPORT_SOURCE", "ORDER_BATCH_IMPORT_ERROR_REPORT")
.contains("ownerScope: {type: string, enum: [CUSTOMER]}")
.contains("pattern: '^OBI-[0-9A-F]{20}$'")
.contains("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
.contains("maximum: 5242880")
.contains("Cache-Control", "no-store")
.doesNotContain("ORDER_IMPORT_SOURCE", "ORDER_IMPORT_ERROR");
}
}
@@ -18,6 +18,7 @@ import org.springframework.test.context.TestPropertySource;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestPart;
import java.nio.file.Path;
import java.time.LocalDate;
@@ -81,6 +82,12 @@ class QualificationFileContractTest {
"/{tenantId}/required-package"));
}
@Test
void requiredPackageKeepsBusinessLicenseRequiredAndMakesLegalPersonIdOptional() {
assertRequiredPackageParts(QualificationFileController.class);
assertRequiredPackageParts(PlatformQualificationFileController.class);
}
@Test
void platformQualificationPreviewUsesTheCustomerListPermission() throws Exception {
var method = PlatformQualificationFileController.class.getDeclaredMethod(
@@ -301,6 +308,18 @@ class QualificationFileContractTest {
&& Arrays.asList(mapping.consumes()).contains(MediaType.MULTIPART_FORM_DATA_VALUE));
}
private static void assertRequiredPackageParts(Class<?> controllerType) {
var method = Arrays.stream(controllerType.getDeclaredMethods())
.filter(candidate -> candidate.getName().equals("uploadRequiredPackage"))
.findFirst().orElseThrow();
var parts = Arrays.stream(method.getParameters())
.map(parameter -> parameter.getAnnotation(RequestPart.class))
.filter(java.util.Objects::nonNull)
.collect(java.util.stream.Collectors.toMap(RequestPart::value, RequestPart::required));
assertEquals(Boolean.TRUE, parts.get("businessLicense"));
assertEquals(Boolean.FALSE, parts.get("legalPersonId"));
}
private QualificationFileController merchantController(
LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = merchantController;
@@ -86,6 +86,36 @@ class BusinessFileStorageTest {
.isInstanceOf(BizException.class).hasMessageContaining("仅支持");
}
@Test void orderBatchCategoriesAcceptOnlyRealXlsxWithinFiveMegabytes() {
BusinessFileStorage storage = storage();
var xlsx = new MockMultipartFile("file", "orders.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
new byte[]{0x50, 0x4b, 0x03, 0x04, 1, 2});
var stored = storage.store(10001L, "OBI-0123456789ABCDEF0123", xlsx,
BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE);
assertThat(stored.objectKey()).endsWith(".xlsx");
assertThat(stored.contentType()).isEqualTo(xlsx.getContentType());
var wrongContent = new MockMultipartFile("file", "orders.xlsx", "application/octet-stream",
new byte[]{0x50, 0x4b, 0x03, 0x04});
assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", wrongContent,
BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE))
.isInstanceOf(BizException.class).hasMessageContaining("仅支持XLSX");
var fakeXlsx = new MockMultipartFile("file", "orders.xlsx", xlsx.getContentType(), new byte[]{1, 2, 3, 4});
assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", fakeXlsx,
BusinessFileCategory.ORDER_BATCH_IMPORT_ERROR_REPORT))
.isInstanceOf(BizException.class).hasMessageContaining("不是有效XLSX");
MultipartFile tooLarge = new SparseMultipartFile("orders.xlsx", xlsx.getContentType(),
BusinessFileCategory.ORDER_IMPORT_MAX_SIZE + 1);
assertThatThrownBy(() -> storage.store(10001L, "OBI-0123456789ABCDEF0123", tooLarge,
BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE))
.isInstanceOf(BizException.class).hasMessageContaining("5MB");
}
private BusinessFileStorage storage() {
BusinessFileStorage storage = new BusinessFileStorage();
ReflectionTestUtils.setField(storage, "configuredRoot", root);
@@ -25,7 +25,7 @@ class InternalBusinessFileServiceTest {
@Test void customsDocumentUploadPersistsExactBusinessOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "invoice.txt", "text/plain", "invoice".getBytes());
when(storage.store(10001L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
when(storage.store(10001L, "41", file, BusinessFileCategory.CUSTOMS_DOCUMENT)).thenReturn(new BusinessFileStorage.Stored(
"business-files/10001/41/key.txt", "text/plain", 7, "a".repeat(64), "invoice.txt"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(71L); return 1;
@@ -41,10 +41,10 @@ class InternalBusinessFileServiceTest {
@Test void platformContainerAndLoadingDocumentsSupportZeroTenantScope() {
MockMultipartFile file = new MockMultipartFile("file", "customs.pdf", "application/pdf", "%PDF-1.4".getBytes());
when(storage.store(eq(0L), anyLong(), same(file), eq(BusinessFileCategory.STANDARD_MAX_SIZE), eq(false))).thenAnswer(invocation ->
when(storage.store(eq(0L), anyString(), same(file), any(BusinessFileCategory.class))).thenAnswer(invocation ->
new BusinessFileStorage.Stored("business-files/0/" + invocation.getArgument(1) + "/customs.pdf",
"application/pdf", file.getSize(), "b".repeat(64), "customs.pdf"));
when(storage.store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
when(storage.store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO)).thenReturn(new BusinessFileStorage.Stored(
"business-files/11/51/unload.jpg", "image/jpeg", file.getSize(), "c".repeat(64), "unload.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(81L); return 1;
@@ -68,15 +68,17 @@ class InternalBusinessFileServiceTest {
InternalBusinessFileService.Metadata::businessType,
InternalBusinessFileService.Metadata::businessId)
.containsExactly(11L, "OVERSEAS_UNLOAD_PHOTO", "51");
verify(storage, times(3)).store(0L, 41L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).store(0L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).store(11L, 51L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false);
verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_CLEARANCE_DOCUMENT);
verify(storage).store(0L, "51", file, BusinessFileCategory.LOADING_CUSTOMS_DECLARATION);
verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_DELIVERY_ORDER);
verify(storage).store(0L, "41", file, BusinessFileCategory.CONTAINER_PICKUP_ORDER);
verify(storage).store(11L, "51", file, BusinessFileCategory.OVERSEAS_UNLOAD_PHOTO);
}
@Test void oceanBookingDocumentUsesCustomerAndBookingOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "booking.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "xlsx".getBytes());
when(storage.store(10001L, 61L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/booking",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
file.getSize(), "f".repeat(64), "booking.xlsx"));
@@ -126,7 +128,7 @@ class InternalBusinessFileServiceTest {
@Test void overseasRelabelPhotoPersistsExactTaskOwnership() {
MockMultipartFile file = new MockMultipartFile("file", "relabel.jpg", "image/jpeg", "photo".getBytes());
when(storage.store(100L, 81L, file, BusinessFileCategory.STANDARD_MAX_SIZE, false)).thenReturn(new BusinessFileStorage.Stored(
when(storage.store(100L, "81", file, BusinessFileCategory.OVERSEAS_RELABEL_PHOTO)).thenReturn(new BusinessFileStorage.Stored(
"business-files/100/81/relabel.jpg", "image/jpeg", file.getSize(), "d".repeat(64), "relabel.jpg"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(91L); return 1;
@@ -142,7 +144,7 @@ class InternalBusinessFileServiceTest {
@Test void loadingCustomsSourceAndResultAreCustomerOwnedArbitraryFormatObjects() {
MockMultipartFile file = new MockMultipartFile("file", "资料包.zip", "application/zip", "zip".getBytes());
when(storage.store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true))
when(storage.store(eq(10001L), eq("51"), same(file), any(BusinessFileCategory.class)))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/51/key", "application/zip",
file.getSize(), "e".repeat(64), "资料包.zip"));
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
@@ -157,7 +159,8 @@ class InternalBusinessFileServiceTest {
InternalBusinessFileService.Metadata::businessId)
.containsExactly(10001L, "LOADING_LIST_CUSTOMS_SOURCE", "51");
assertThat(result.businessType()).isEqualTo("LOADING_LIST_CUSTOMS_RESULT");
verify(storage, times(2)).store(10001L, 51L, file, BusinessFileCategory.CUSTOMS_MAX_SIZE, true);
verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_SOURCE);
verify(storage).store(10001L, "51", file, BusinessFileCategory.LOADING_LIST_CUSTOMS_RESULT);
}
@Test void loadingCustomsCategoriesRejectPlatformOwnerScope() {
@@ -177,6 +180,57 @@ class InternalBusinessFileServiceTest {
verifyNoInteractions(files, storage);
}
@Test void orderBatchSourceAndErrorReportUseCustomerAndBatchNumberOwnership() {
String batchNo = "OBI-0123456789ABCDEF0123";
var file = new MockMultipartFile("file", "orders.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
new byte[]{0x50, 0x4b, 0x03, 0x04, 1});
when(storage.store(eq(10001L), eq(batchNo), same(file), any(BusinessFileCategory.class)))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/" + batchNo + "/object.xlsx",
file.getContentType(), file.getSize(), "9".repeat(64), file.getOriginalFilename()));
AtomicLong ids = new AtomicLong(300);
when(files.insert(any(FileObjectEntity.class))).thenAnswer(invocation -> {
((FileObjectEntity) invocation.getArgument(0)).setId(ids.incrementAndGet());
return 1;
});
var source = service.upload(10001L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 7L, file);
var error = service.upload(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 7L, file);
assertThat(source).extracting(InternalBusinessFileService.Metadata::ownerScope,
InternalBusinessFileService.Metadata::tenantId, InternalBusinessFileService.Metadata::businessId)
.containsExactly("CUSTOMER", 10001L, batchNo);
assertThat(error.businessType()).isEqualTo("ORDER_BATCH_IMPORT_ERROR_REPORT");
verify(storage).store(10001L, batchNo, file, BusinessFileCategory.ORDER_BATCH_IMPORT_SOURCE);
verify(storage).store(10001L, batchNo, file, BusinessFileCategory.ORDER_BATCH_IMPORT_ERROR_REPORT);
}
@Test void orderBatchFilesRejectPlatformOwnerMalformedBatchAndWrongCompositeOwnership() {
String batchNo = "OBI-0123456789ABCDEF0123";
var file = new MockMultipartFile("file", "orders.xlsx",
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
new byte[]{0x50, 0x4b, 0x03, 0x04});
assertThatThrownBy(() -> service.upload(0L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("明确客户");
assertThatThrownBy(() -> service.upload(10001L, "ORDER_BATCH_IMPORT_SOURCE", "41", 7L, file))
.isInstanceOf(BizException.class).hasMessageContaining("批量导入业务编号");
FileObjectEntity value = new FileObjectEntity(); value.setId(401L); value.setTenantId(10001L);
value.setBusinessType("ORDER_BATCH_IMPORT_SOURCE"); value.setBusinessId(batchNo);
value.setOriginalName("orders.xlsx"); value.setObjectKey("business-files/10001/" + batchNo + "/object.xlsx");
value.setContentType(file.getContentType()); value.setSha256("8".repeat(64)); value.setSizeBytes(file.getSize());
when(files.selectOne(any())).thenReturn(value, null, null, null);
assertThat(service.metadata(10001L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 401L).fileId()).isEqualTo(401L);
assertThatThrownBy(() -> service.metadata(10002L, "ORDER_BATCH_IMPORT_SOURCE", batchNo, 401L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(10001L, "ORDER_BATCH_IMPORT_ERROR_REPORT", batchNo, 401L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
assertThatThrownBy(() -> service.metadata(10001L, "ORDER_BATCH_IMPORT_SOURCE",
"OBI-FFFFFFFFFFFFFFFFFFFF", 401L))
.isInstanceOf(BizException.class).hasMessageContaining("归属不匹配");
}
@Test void downloadUsesValidatedMetadataObjectKey() {
FileObjectEntity value = new FileObjectEntity(); value.setId(71L); value.setTenantId(10001L);
value.setBusinessType("CUSTOMS_RECEIPT"); value.setBusinessId("61"); value.setOriginalName("release.txt");
@@ -215,7 +269,7 @@ class InternalBusinessFileServiceTest {
@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))
when(storage.store(10001L, "61", file, BusinessFileCategory.OCEAN_BOOKING_DOCUMENT))
.thenReturn(new BusinessFileStorage.Stored("business-files/10001/61/object",
"application/x-booking", 3, "a".repeat(64), "booking.bin"));
AtomicLong ids = new AtomicLong(200);
@@ -14,8 +14,10 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import java.nio.file.Path;
import java.time.LocalDate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@@ -59,6 +61,39 @@ class QualificationPackageServiceTest {
assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl());
}
@Test
void legalPersonIdMayBeOmittedWithoutRelaxingTheBusinessLicense() {
var fixture = fixture();
when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored);
when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.RequiredPackageResponse(31L, null, "PROFILE_REQUIRED", null, null)));
var result = fixture.service.upload(TENANT_ID, fixture.businessLicense, null,
"91310101abcde12345", null, null, false, false, false);
var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class);
verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture());
assertNull(captor.getValue().legalPersonIdDocumentUrl());
assertNull(captor.getValue().legalPersonIdValidTo());
assertNull(result.legalPersonIdQualificationId());
assertNull(result.legalPersonIdDocumentUrl());
verify(fixture.storage, never()).store(TENANT_ID, fixture.legalPersonId);
}
@Test
void missingBusinessLicenseAndOrphanedLegalPersonParametersStillFailClosed() {
var fixture = fixture();
assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, null, null,
"91310101ABCDE12345", null, null, false, false, false));
assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, fixture.businessLicense, null,
"91310101ABCDE12345", null, LocalDate.of(2030, 1, 1), false, false, false));
assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, fixture.businessLicense, null,
"91310101ABCDE12345", null, null, false, true, false));
verify(fixture.iamClient, never()).submitRequiredPackage(any(), any());
}
@Test
void secondStorageFailureDeletesTheFirstFileAndNeverCallsIam() {
var fixture = fixture();