diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java index 4d93ca066..a0c63a603 100644 --- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java +++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java @@ -19,6 +19,16 @@ public interface IamQualificationClient { @RequestHeader("X-Internal-Service-Token") String internalToken, @RequestBody PlatformQualificationRequest request); + @PostMapping("/required-qualification-packages") + ApiResponse submitRequiredPackage( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody RequiredPackageRequest request); + + @PostMapping("/platform-required-qualification-packages") + ApiResponse submitPlatformRequiredPackage( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody RequiredPackageRequest request); + record QualificationRequest(Long tenantId, String qualificationType, String certificateNo, String documentUrl, java.time.LocalDate validTo, boolean replace) { } @@ -29,4 +39,16 @@ public interface IamQualificationClient { record PlatformQualificationRequest(Long tenantId, String qualificationType, String certificateNo, String documentUrl, java.time.LocalDate validTo, boolean replace) { } + record RequiredPackageRequest(Long tenantId, String unifiedSocialCreditCode, + String businessLicenseDocumentUrl, + java.time.LocalDate businessLicenseValidTo, + boolean replaceBusinessLicense, + String legalPersonIdDocumentUrl, + java.time.LocalDate legalPersonIdValidTo, + boolean replaceLegalPersonId) { } + record RequiredPackageResponse(Long businessLicenseQualificationId, + Long legalPersonIdQualificationId, + String tenantStatus, + String replacedBusinessLicenseDocumentUrl, + String replacedLegalPersonIdDocumentUrl) { } } diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 0b574d73a..35342145c 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.service.LocalQualificationStorage; +import com.baiyue.file.service.QualificationPackageService; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -32,6 +33,7 @@ public class PlatformQualificationFileController { private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class); @Resource private LocalQualificationStorage storage; @Resource private IamQualificationClient iamClient; + @Resource private QualificationPackageService packageService; @Value("${baiyue.security.internal-service-token}") private String internalToken; @PostMapping(value = "/{tenantId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -45,7 +47,7 @@ public class PlatformQualificationFileController { @RequestParam(required = false) LocalDate validTo, @RequestParam(defaultValue = "false") boolean replace) { requirePlatform(user); - qualificationType = QualificationTypes.requireSupported(qualificationType); + qualificationType = QualificationTypes.requireOptional(qualificationType); var stored = storage.store(tenantId, file); try { var response = iamClient.submitPlatform(internalToken, @@ -79,6 +81,24 @@ public class PlatformQualificationFileController { } } + @PostMapping(value = "/{tenantId}/required-package", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") + public ApiResponse uploadRequiredPackage( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @RequestPart("businessLicense") MultipartFile businessLicense, + @RequestPart("legalPersonId") MultipartFile legalPersonId, + @RequestParam String unifiedSocialCreditCode, + @RequestParam(required = false) LocalDate businessLicenseValidTo, + @RequestParam(required = false) LocalDate legalPersonIdValidTo, + @RequestParam(defaultValue = "false") boolean replaceBusinessLicense, + @RequestParam(defaultValue = "false") boolean replaceLegalPersonId) { + requirePlatform(user); + return ApiResponse.success(packageService.upload(tenantId, businessLicense, legalPersonId, + unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo, + replaceBusinessLicense, replaceLegalPersonId, true)); + } + @GetMapping("/{tenantId}/{fileName}") @PreAuthorize("hasAuthority('platform:customer:view')") public ResponseEntity download( diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index cbda3cdbc..f55542e6c 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.service.LocalQualificationStorage; +import com.baiyue.file.service.QualificationPackageService; import jakarta.annotation.Resource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -26,6 +27,7 @@ public class QualificationFileController { private static final Logger LOGGER = LoggerFactory.getLogger(QualificationFileController.class); @Resource private LocalQualificationStorage storage; @Resource private IamQualificationClient iamClient; + @Resource private QualificationPackageService packageService; @Value("${baiyue.security.internal-service-token}") private String internalToken; @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @@ -38,7 +40,7 @@ public class QualificationFileController { @RequestParam(required = false) LocalDate validTo, @RequestParam(defaultValue = "false") boolean replace) { long tenantId = merchantTenant(user); - qualificationType = QualificationTypes.requireSupported(qualificationType); + qualificationType = QualificationTypes.requireOptional(qualificationType); var stored = storage.store(tenantId, file); try { var response = iamClient.submit(internalToken, @@ -72,6 +74,22 @@ public class QualificationFileController { } } + @PostMapping(value = "/required-package", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAuthority('merchant:qualification:upload')") + public ApiResponse uploadRequiredPackage( + @AuthenticationPrincipal AuthenticatedUser user, + @RequestPart("businessLicense") MultipartFile businessLicense, + @RequestPart("legalPersonId") MultipartFile legalPersonId, + @RequestParam String unifiedSocialCreditCode, + @RequestParam(required = false) LocalDate businessLicenseValidTo, + @RequestParam(required = false) LocalDate legalPersonIdValidTo, + @RequestParam(defaultValue = "false") boolean replaceBusinessLicense, + @RequestParam(defaultValue = "false") boolean replaceLegalPersonId) { + return ApiResponse.success(packageService.upload(merchantTenant(user), businessLicense, legalPersonId, + unifiedSocialCreditCode, businessLicenseValidTo, legalPersonIdValidTo, + replaceBusinessLicense, replaceLegalPersonId, false)); + } + @GetMapping("/{fileName}") public ResponseEntity download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) { diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java index 6062ca9c6..2d59ccc1c 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java @@ -1,6 +1,7 @@ package com.baiyue.file.controller; import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.core.api.ResultCode; import java.util.Set; @@ -19,4 +20,13 @@ final class QualificationTypes { } return qualificationType; } + + static String requireOptional(String qualificationType) { + String type = requireSupported(qualificationType); + if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) { + throw new BizException(ResultCode.BAD_REQUEST, + "Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传"); + } + return type; + } } diff --git a/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java new file mode 100644 index 000000000..9c26fecab --- /dev/null +++ b/file/src/main/java/com/baiyue/file/service/QualificationPackageService.java @@ -0,0 +1,82 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.client.IamQualificationClient; +import jakarta.annotation.Resource; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDate; + +@Service +public class QualificationPackageService { + private static final Logger LOGGER = LoggerFactory.getLogger(QualificationPackageService.class); + @Resource private LocalQualificationStorage storage; + @Resource private IamQualificationClient iamClient; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + public PackageUploadResult upload(long tenantId, MultipartFile businessLicense, + MultipartFile legalPersonId, String creditCode, + LocalDate businessLicenseValidTo, LocalDate legalPersonIdValidTo, + boolean replaceBusinessLicense, boolean replaceLegalPersonId, + boolean platform) { + String normalizedCreditCode = creditCode == null ? "" : creditCode.trim().toUpperCase(); + if (!normalizedCreditCode.matches("[0-9A-Z]{18}")) { + throw new BizException(ResultCode.BAD_REQUEST, "请输入有效的18位统一社会信用代码"); + } + LocalQualificationStorage.StoredFile storedBusinessLicense = storage.store(tenantId, businessLicense); + LocalQualificationStorage.StoredFile storedLegalPersonId; + try { + storedLegalPersonId = storage.store(tenantId, legalPersonId); + } catch (RuntimeException exception) { + storage.delete(storedBusinessLicense); + throw exception; + } + + var request = new IamQualificationClient.RequiredPackageRequest( + tenantId, normalizedCreditCode, storedBusinessLicense.documentUrl(), businessLicenseValidTo, + replaceBusinessLicense, storedLegalPersonId.documentUrl(), legalPersonIdValidTo, replaceLegalPersonId); + try { + var response = platform + ? iamClient.submitPlatformRequiredPackage(internalToken, request) + : iamClient.submitRequiredPackage(internalToken, request); + if (response == null) throw new IllegalStateException("IAM required qualification package response is empty"); + if (!response.successful()) { + ResultCode code = ResultCode.of(response.code()); + String message = response.message() == null || response.message().isBlank() + ? code.message() : response.message(); + storage.delete(storedBusinessLicense); + storage.delete(storedLegalPersonId); + throw new BizException(code, message); + } + if (response.data() == null) { + throw new IllegalStateException("IAM required qualification package response data is empty"); + } + var data = response.data(); + deleteReplacedFile(tenantId, data.replacedBusinessLicenseDocumentUrl(), storedBusinessLicense.documentUrl()); + deleteReplacedFile(tenantId, data.replacedLegalPersonIdDocumentUrl(), storedLegalPersonId.documentUrl()); + return new PackageUploadResult(data.businessLicenseQualificationId(), data.legalPersonIdQualificationId(), + data.tenantStatus(), storedBusinessLicense.documentUrl(), storedLegalPersonId.documentUrl()); + } catch (BizException exception) { + throw exception; + } catch (RuntimeException exception) { + LOGGER.error("IAM 必填资质包登记结果未知,保留已写入文件等待重试或对账,tenantId={}, businessLicenseUrl={}, legalPersonIdUrl={}", + tenantId, storedBusinessLicense.documentUrl(), storedLegalPersonId.documentUrl(), exception); + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); + } + } + + private void deleteReplacedFile(long tenantId, String replacedUrl, String currentUrl) { + if (!currentUrl.equals(replacedUrl)) storage.deleteByDocumentUrl(tenantId, replacedUrl); + } + + public record PackageUploadResult(Long businessLicenseQualificationId, + Long legalPersonIdQualificationId, + String tenantStatus, + String businessLicenseDocumentUrl, + String legalPersonIdDocumentUrl) { } +} diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 950d3ac81..51687cf81 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -61,6 +61,13 @@ class QualificationFileContractTest { assertTrue(hasMultipartPost); } + @Test + void merchantAndPlatformExposeDedicatedRequiredPackageRoutes() { + assertTrue(hasMultipartRoute(QualificationFileController.class, "/required-package")); + assertTrue(hasMultipartRoute(PlatformQualificationFileController.class, + "/{tenantId}/required-package")); + } + @Test void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception { var method = PlatformQualificationFileController.class.getDeclaredMethod( @@ -82,7 +89,7 @@ class QualificationFileContractTest { when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success( new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); - var response = controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, + var response = controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, LocalDate.of(2030, 12, 31), true); var ordered = inOrder(storage, iamClient); @@ -108,7 +115,7 @@ class QualificationFileContractTest { when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success( new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", currentUrl))); - controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true); + controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, true); verify(storage, never()).deleteByDocumentUrl(10001L, currentUrl); } @@ -124,7 +131,7 @@ class QualificationFileContractTest { when(iamClient.submitPlatform(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout")); var exception = assertThrows(BizException.class, - () -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, true)); + () -> controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, true)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); verify(storage, never()).delete(stored); @@ -142,7 +149,7 @@ class QualificationFileContractTest { ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null)); var exception = assertThrows(BizException.class, - () -> controller.upload(platformUser(), 10001L, file, "LEGAL_PERSON_ID", null, null, false)); + () -> controller.upload(platformUser(), 10001L, file, "BANK_ACCOUNT_LICENSE", null, null, false)); assertEquals(ResultCode.CONFLICT, exception.getResultCode()); verify(storage).delete(stored); @@ -159,7 +166,7 @@ class QualificationFileContractTest { when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout")); var exception = assertThrows(BizException.class, - () -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false)); + () -> controller.upload(merchantUser(), file, "FREIGHT_FORWARDER_CERTIFICATE", null, null, false)); assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); verify(storage, never()).delete(stored); @@ -177,12 +184,12 @@ class QualificationFileContractTest { when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); - controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", null, LocalDate.of(2030, 1, 1), true); + controller.upload(merchantUser(), file, "FREIGHT_FORWARDER_CERTIFICATE", null, LocalDate.of(2030, 1, 1), true); var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class); verify(iamClient).submit(eq("internal-token"), requestCaptor.capture()); verify(storage).deleteByDocumentUrl(10001L, oldUrl); - assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType()); + assertEquals("FREIGHT_FORWARDER_CERTIFICATE", requestCaptor.getValue().qualificationType()); assertTrue(requestCaptor.getValue().replace()); } @@ -203,9 +210,8 @@ class QualificationFileContractTest { } @Test - void everyRequiredQualificationTypeIsAcceptedAndForwarded() { + void everyOptionalQualificationTypeIsAcceptedAndForwarded() { for (String qualificationType : Set.of( - "BUSINESS_LICENSE", "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) { var storage = mock(LocalQualificationStorage.class); var iamClient = mock(IamQualificationClient.class); @@ -224,6 +230,22 @@ class QualificationFileContractTest { } } + @Test + void legacySingleUploadRejectsBothRequiredTypesBeforeWritingTheFile() { + for (String qualificationType : Set.of("BUSINESS_LICENSE", "LEGAL_PERSON_ID")) { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "qualification.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + assertThrows(BizException.class, + () -> controller.upload(merchantUser(), file, qualificationType, null, null, false)); + + verify(storage, never()).store(any(Long.class), any()); + verify(iamClient, never()).submit(any(), any()); + } + } + private static PlatformQualificationFileController platformController( LocalQualificationStorage storage, IamQualificationClient iamClient) { var controller = new PlatformQualificationFileController(); @@ -233,6 +255,14 @@ class QualificationFileContractTest { return controller; } + private static boolean hasMultipartRoute(Class controllerType, String route) { + return Arrays.stream(controllerType.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .anyMatch(mapping -> Arrays.asList(mapping.value()).contains(route) + && Arrays.asList(mapping.consumes()).contains(MediaType.MULTIPART_FORM_DATA_VALUE)); + } + private static QualificationFileController merchantController( LocalQualificationStorage storage, IamQualificationClient iamClient) { var controller = new QualificationFileController(); diff --git a/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java new file mode 100644 index 000000000..41ba1d39c --- /dev/null +++ b/file/src/test/java/com/baiyue/file/service/QualificationPackageServiceTest.java @@ -0,0 +1,147 @@ +package com.baiyue.file.service; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.file.client.IamQualificationClient; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class QualificationPackageServiceTest { + private static final long TENANT_ID = 10001L; + private static final String BUSINESS_URL = + "/api/merchant/v1/files/qualifications/11111111-1111-1111-1111-111111111111.pdf"; + private static final String LEGAL_PERSON_URL = + "/api/merchant/v1/files/qualifications/22222222-2222-2222-2222-222222222222.png"; + + @Test + void storesBothFilesBeforeRegisteringOneIamPackage() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenReturn(fixture.legalPersonStored); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn(ApiResponse.success( + new IamQualificationClient.RequiredPackageResponse(31L, 32L, "PROFILE_REQUIRED", null, null))); + + var result = fixture.service.upload(TENANT_ID, fixture.businessLicense, fixture.legalPersonId, + "91310101abcde12345", null, null, false, false, false); + + var ordered = inOrder(fixture.storage, fixture.iamClient); + ordered.verify(fixture.storage).store(TENANT_ID, fixture.businessLicense); + ordered.verify(fixture.storage).store(TENANT_ID, fixture.legalPersonId); + var captor = ArgumentCaptor.forClass(IamQualificationClient.RequiredPackageRequest.class); + ordered.verify(fixture.iamClient).submitRequiredPackage(eq("internal-token"), captor.capture()); + assertEquals("91310101ABCDE12345", captor.getValue().unifiedSocialCreditCode()); + assertEquals(BUSINESS_URL, result.businessLicenseDocumentUrl()); + assertEquals(LEGAL_PERSON_URL, result.legalPersonIdDocumentUrl()); + } + + @Test + void secondStorageFailureDeletesTheFirstFileAndNeverCallsIam() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenThrow(new BizException("disk full")); + + assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, + fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345", + null, null, false, false, false)); + + verify(fixture.storage).delete(fixture.businessStored); + verify(fixture.iamClient, never()).submitRequiredPackage(any(), any()); + } + + @Test + void explicitIamRejectionDeletesBothUnreferencedFiles() { + var fixture = storedFixture(); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())).thenReturn( + ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null)); + + var exception = assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, + fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345", + null, null, false, false, false)); + + assertEquals(ResultCode.CONFLICT, exception.getResultCode()); + verify(fixture.storage).delete(fixture.businessStored); + verify(fixture.storage).delete(fixture.legalPersonStored); + } + + @Test + void unknownIamOutcomePreservesBothFilesForReconciliation() { + var fixture = storedFixture(); + when(fixture.iamClient.submitRequiredPackage(eq("internal-token"), any())) + .thenThrow(new RuntimeException("timeout")); + + var exception = assertThrows(BizException.class, () -> fixture.service.upload(TENANT_ID, + fixture.businessLicense, fixture.legalPersonId, "91310101ABCDE12345", + null, null, false, false, false)); + + assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode()); + verify(fixture.storage, never()).delete(fixture.businessStored); + verify(fixture.storage, never()).delete(fixture.legalPersonStored); + } + + @Test + void platformSuccessCleansReplacedFilesOnlyAfterIamCommit() { + var fixture = storedFixture(); + var oldBusinessUrl = "/api/merchant/v1/files/qualifications/33333333-3333-3333-3333-333333333333.pdf"; + var oldLegalPersonUrl = "/api/merchant/v1/files/qualifications/44444444-4444-4444-4444-444444444444.png"; + when(fixture.iamClient.submitPlatformRequiredPackage(eq("internal-token"), any())).thenReturn( + ApiResponse.success(new IamQualificationClient.RequiredPackageResponse( + 31L, 32L, "PROFILE_REQUIRED", oldBusinessUrl, oldLegalPersonUrl))); + + fixture.service.upload(TENANT_ID, fixture.businessLicense, fixture.legalPersonId, + "91310101ABCDE12345", null, null, true, true, true); + + var ordered = inOrder(fixture.iamClient, fixture.storage); + ordered.verify(fixture.iamClient).submitPlatformRequiredPackage(eq("internal-token"), any()); + ordered.verify(fixture.storage).deleteByDocumentUrl(TENANT_ID, oldBusinessUrl); + ordered.verify(fixture.storage).deleteByDocumentUrl(TENANT_ID, oldLegalPersonUrl); + } + + private static Fixture storedFixture() { + var fixture = fixture(); + when(fixture.storage.store(TENANT_ID, fixture.businessLicense)).thenReturn(fixture.businessStored); + when(fixture.storage.store(TENANT_ID, fixture.legalPersonId)).thenReturn(fixture.legalPersonStored); + return fixture; + } + + private static Fixture fixture() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var service = new QualificationPackageService(); + ReflectionTestUtils.setField(service, "storage", storage); + ReflectionTestUtils.setField(service, "iamClient", iamClient); + ReflectionTestUtils.setField(service, "internalToken", "internal-token"); + var businessLicense = new MockMultipartFile( + "businessLicense", "license.pdf", "application/pdf", "%PDF-1.4".getBytes()); + var legalPersonId = new MockMultipartFile( + "legalPersonId", "legal-person.png", "image/png", new byte[] {1, 2, 3}); + var businessStored = new LocalQualificationStorage.StoredFile( + Path.of("business.pdf"), "business.pdf", BUSINESS_URL); + var legalPersonStored = new LocalQualificationStorage.StoredFile( + Path.of("legal-person.png"), "legal-person.png", LEGAL_PERSON_URL); + return new Fixture(service, storage, iamClient, businessLicense, legalPersonId, + businessStored, legalPersonStored); + } + + private record Fixture(QualificationPackageService service, + LocalQualificationStorage storage, + IamQualificationClient iamClient, + MockMultipartFile businessLicense, + MockMultipartFile legalPersonId, + LocalQualificationStorage.StoredFile businessStored, + LocalQualificationStorage.StoredFile legalPersonStored) { } +} diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java index 6ee644800..a982e78dc 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformFbaMasterDataController.java @@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller; import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; @@ -36,10 +38,12 @@ public class PlatformFbaMasterDataController { @GetMapping("/customer-rules") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')") + @Operation(summary = "查询客户计费规则版本", description = "默认查询全部企业;tenantId 可用于筛选目标企业") public ApiResponse> rules( - @AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, + @AuthenticationPrincipal AuthenticatedUser user, + @RequestParam(required = false) Long tenantId, @RequestParam(required = false) String ruleCode) { - return ApiResponse.success(service.listRules(FirstlegScopeGuard.platform(user, tenantId), ruleCode)); + return ApiResponse.success(service.listRules(optionalPlatformTenant(user, tenantId), ruleCode)); } @GetMapping("/customer-rules/{id}") @@ -105,4 +109,11 @@ public class PlatformFbaMasterDataController { service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId()); return ApiResponse.success(null); } + + private static Long optionalPlatformTenant(AuthenticatedUser user, Long tenantId) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + return tenantId == null ? null : FirstlegScopeGuard.platform(user, tenantId); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java index 24e8b2b8d..513503544 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/controller/PlatformInboundOrderController.java @@ -10,10 +10,13 @@ import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderSummary; import com.baiyue.firstleg.service.InboundOrderQueryService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; @@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController; /** 平台端入库单列表和详情只读接口。 */ @Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情") +@Validated @RestController @RequestMapping("/api/platform/inbounds") public class PlatformInboundOrderController { @@ -31,14 +35,16 @@ public class PlatformInboundOrderController { this.service = service; } - @Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。") + @Operation(summary = "分页查询入库单", + description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。") @GetMapping @PreAuthorize("hasAuthority('platform:firstleg:inbound:list')") public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam long tenantId, + @Parameter(description = "可选目标企业租户ID;不传时查询全部企业") + @RequestParam(required = false) @Min(1) Long tenantId, @Valid InboundOrderQuery query) { - requirePlatformTenant(user, tenantId); + requirePlatform(user); return ApiResponse.success(service.page(tenantId, query)); } @@ -57,11 +63,15 @@ public class PlatformInboundOrderController { } private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) { - if (user == null || !"PLATFORM".equals(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); - } + requirePlatform(user); if (tenantId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); } } + + private static void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equals(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问"); + } + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java index 65280f4fb..f4a66e077 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/dto/InboundOrderSummary.java @@ -8,7 +8,7 @@ import java.time.LocalDateTime; /** 入库列表行,所有名称和运输信息均为业务快照。 */ public record InboundOrderSummary( - Long id, Long orderId, String orderNo, String inboundCode, + Long id, Long tenantId, Long orderId, String orderNo, String inboundCode, String processStatus, String orderStatusSnapshot, String measureReviewStatus, String feeStatus, String feeReviewStatus, String notificationStatus, Integer version, @@ -33,7 +33,7 @@ public record InboundOrderSummary( public static InboundOrderSummary from( InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) { return new InboundOrderSummary( - order.getId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(), + order.getId(), order.getTenantId(), order.getOrderId(), order.getOrderNo(), order.getInboundCode(), order.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(), order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(), order.getVersion(), diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java index 0cfa84006..e2d9b6238 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/FbaPricingRepository.java @@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository; import java.time.LocalDateTime; import java.util.List; -/** FBA 计费仓储,所有业务查询都强制带租户条件。 */ +/** FBA 计费仓储;平台客户规则列表可跨企业读取,其他业务操作强制带租户条件。 */ @Repository public class FbaPricingRepository { private final CustomerChargeRuleMapper rules; @@ -61,9 +61,9 @@ public class FbaPricingRepository { .eq(CarrierFbaRateEntity::getId, id)); } - public List listRules(long tenantId, String ruleCode) { + public List listRules(Long tenantId, String ruleCode) { return rules.selectList(new LambdaQueryWrapper() - .eq(CustomerChargeRuleEntity::getTenantId, tenantId) + .eq(tenantId != null, CustomerChargeRuleEntity::getTenantId, tenantId) .eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode) .orderByAsc(CustomerChargeRuleEntity::getRuleCode) .orderByDesc(CustomerChargeRuleEntity::getRuleVersion)); diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java index 01884e668..11018db93 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/InboundOrderRepository.java @@ -26,7 +26,7 @@ import java.util.Map; import java.util.function.Function; import java.util.stream.Collectors; -/** 入库单只读仓储,所有查询均以租户作为首要隔离条件。 */ +/** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */ @Repository public class InboundOrderRepository { private final InboundOrderMapper orders; @@ -51,9 +51,9 @@ public class InboundOrderRepository { this.histories = histories; } - public PageResult page(long tenantId, InboundOrderQuery query) { + public PageResult page(Long tenantId, InboundOrderQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(InboundOrderEntity::getTenantId, tenantId) + .eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId) .likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode, query.inboundCode()) .likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo()) @@ -85,9 +85,10 @@ public class InboundOrderRepository { .orderByDesc(InboundOrderEntity::getCreatedAt) .orderByDesc(InboundOrderEntity::getId); Page result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper); - List ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList(); - Map transportByInbound = transportMap(tenantId, ids); - Map locationCodesByInbound = locationCodesMap(tenantId, ids); + Map tenantByInbound = result.getRecords().stream() + .collect(Collectors.toMap(InboundOrderEntity::getId, InboundOrderEntity::getTenantId)); + Map transportByInbound = transportMap(tenantId, tenantByInbound); + Map locationCodesByInbound = locationCodesMap(tenantId, tenantByInbound); List records = result.getRecords().stream() .map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()), locationCodesByInbound.get(order.getId()))) @@ -156,27 +157,31 @@ public class InboundOrderRepository { palletSummaries, fileSummaries, noteSummaries, historySummaries); } - private Map transportMap(long tenantId, List inboundIds) { - if (inboundIds.isEmpty()) { + private Map transportMap( + Long tenantId, Map tenantByInbound) { + if (tenantByInbound.isEmpty()) { return Map.of(); } return transports.selectList(new LambdaQueryWrapper() - .eq(InboundTransportSnapshotEntity::getTenantId, tenantId) - .in(InboundTransportSnapshotEntity::getInboundId, inboundIds)) - .stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, + .eq(tenantId != null, InboundTransportSnapshotEntity::getTenantId, tenantId) + .in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet())) + .stream() + .filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) + .collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, Function.identity(), (left, right) -> left)); } - private Map locationCodesMap(long tenantId, List inboundIds) { - if (inboundIds.isEmpty()) { + private Map locationCodesMap(Long tenantId, Map tenantByInbound) { + if (tenantByInbound.isEmpty()) { return Map.of(); } return pallets.selectList(new LambdaQueryWrapper() - .eq(InboundPalletAllocationEntity::getTenantId, tenantId) - .in(InboundPalletAllocationEntity::getInboundId, inboundIds) + .eq(tenantId != null, InboundPalletAllocationEntity::getTenantId, tenantId) + .in(InboundPalletAllocationEntity::getInboundId, tenantByInbound.keySet()) .orderByAsc(InboundPalletAllocationEntity::getInboundId) .orderByAsc(InboundPalletAllocationEntity::getAllocationNo)) .stream() + .filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId())) .filter(item -> StringUtils.hasText(item.getLocationCode())) .collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId, java.util.LinkedHashMap::new, @@ -184,6 +189,10 @@ public class InboundOrderRepository { Collectors.joining("、")))); } + private static boolean owns(Map tenantByInbound, Long inboundId, Long tenantId) { + return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId)); + } + private static LambdaQueryWrapper owned( com.baomidou.mybatisplus.core.toolkit.support.SFunction tenantColumn, com.baomidou.mybatisplus.core.toolkit.support.SFunction inboundColumn, diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java index 417e1b94a..4970cbf3e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/FbaMasterDataService.java @@ -98,7 +98,7 @@ public class FbaMasterDataService { return value; } - public List listRules(long tenantId, String code) { + public List listRules(Long tenantId, String code) { return repository.listRules(tenantId, code); } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java index e292290d6..d8a20fe84 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/service/InboundOrderQueryService.java @@ -21,7 +21,7 @@ public class InboundOrderQueryService { this.repository = repository; } - public PageResult page(long tenantId, InboundOrderQuery query) { + public PageResult page(Long tenantId, InboundOrderQuery query) { validateQuery(query); return repository.page(tenantId, query); } diff --git a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml index 6ff65af5c..1ab2f4ca6 100644 --- a/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml +++ b/firstleg/src/main/resources/openapi/fba-pricing-v1.yaml @@ -10,7 +10,9 @@ paths: get: summary: 查询客户计费规则版本 description: 需要 firstleg:fba-pricing:rule:view 权限 - parameters: [{$ref: '#/components/parameters/TenantId'}] + parameters: + - {$ref: '#/components/parameters/OptionalTenantId'} + - {name: ruleCode, in: query, schema: {type: string, maxLength: 50}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}} /api/platform/v1/fba-pricing/customer-rules/versions: post: @@ -87,9 +89,10 @@ components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} parameters: - TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} - Id: {name: id, in: path, required: true, schema: {type: integer, format: int64}} - InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}} + OptionalTenantId: {name: tenantId, in: query, required: false, description: 不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + TenantId: {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + Id: {name: id, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + InboundId: {name: inboundId, in: path, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}} schemas: AdjustFeeRequest: @@ -107,7 +110,7 @@ components: required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom] properties: ruleCode: {type: string, maxLength: 50} - customerId: {type: integer, format: int64} + customerId: {type: string, pattern: '^[1-9][0-9]*$'} customerName: {type: string, maxLength: 128} businessType: {type: string, maxLength: 32} chargeMode: {type: string, enum: [KG, CBM]} @@ -123,11 +126,11 @@ components: required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationCode, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom] properties: rateCode: {type: string, maxLength: 50} - originWarehouseId: {type: integer, format: int64} + originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'} originWarehouseName: {type: string} businessType: {type: string} channelCode: {type: string} - carrierId: {type: integer, format: int64} + carrierId: {type: string, pattern: '^[1-9][0-9]*$'} carrierName: {type: string} destinationCode: {type: string} chargeUnit: {type: string, enum: [KG, CBM]} diff --git a/firstleg/src/main/resources/openapi/firstleg-v1.yaml b/firstleg/src/main/resources/openapi/firstleg-v1.yaml index 62e3ca8d5..e331db6e5 100644 --- a/firstleg/src/main/resources/openapi/firstleg-v1.yaml +++ b/firstleg/src/main/resources/openapi/firstleg-v1.yaml @@ -127,12 +127,16 @@ paths: /api/platform/inbounds: get: summary: 分页查询入库单 - description: 需要 platform:firstleg:inbound:list 权限;仅使用 V2 四个页签 + description: 需要 platform:firstleg:inbound:list 权限;默认查询全部企业,可选 tenantId 筛选;仅使用 V2 四个页签 parameters: - - {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}} + - {name: tenantId, in: query, required: false, schema: {type: string, pattern: '^[1-9][0-9]*$'}} - {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}} responses: - '200': {description: 成功或业务失败,按响应体 code 判断} + '200': + description: 成功或业务失败,按响应体 code 判断;每条记录携带后续租户隔离操作所需的 tenantId + content: + application/json: + schema: {$ref: '#/components/schemas/InboundOrderPageEnvelope'} '403': {description: 无查询权限} /api/platform/inbounds/{inboundId}: get: @@ -251,6 +255,34 @@ components: in: query schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} schemas: + InboundOrderPageEnvelope: + type: object + properties: + code: {type: string, example: '200'} + message: {type: string} + data: {$ref: '#/components/schemas/InboundOrderPage'} + InboundOrderPage: + type: object + properties: + records: + type: array + items: {$ref: '#/components/schemas/InboundOrderSummary'} + page: {type: integer, minimum: 1} + size: {type: integer, minimum: 1} + total: {type: integer, format: int64, minimum: 0} + pages: {type: integer, format: int64, minimum: 0} + InboundOrderSummary: + type: object + required: [id, tenantId] + properties: + id: {type: string, pattern: '^[1-9][0-9]*$'} + tenantId: {type: string, pattern: '^[1-9][0-9]*$', description: 记录所属企业租户 ID,用于详情和处理接口的租户隔离} + orderId: {type: string, nullable: true} + orderNo: {type: string, nullable: true} + inboundCode: {type: string, nullable: true} + processStatus: {type: string} + customerId: {type: string, nullable: true} + companyId: {type: string, nullable: true} InboundMeasurementCallbackRequest: type: object required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm] diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java index 535e927a2..efc2cadfb 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/FbaPricingContractTest.java @@ -3,13 +3,30 @@ package com.baiyue.firstleg.controller; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; +import org.springframework.web.bind.annotation.RequestParam; import java.nio.charset.StandardCharsets; +import java.lang.reflect.Method; import static org.assertj.core.api.Assertions.assertThat; @DisplayName("FBA计费OpenAPI契约") class FbaPricingContractTest { + @Test + @DisplayName("只有客户规则列表允许省略企业参数") + void onlyCustomerRuleListAllowsOptionalTenant() { + Method rules = method("rules"); + assertThat(rules.getParameterTypes()[1]).isEqualTo(Long.class); + assertThat(rules.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); + + for (String scopedMethod : new String[] {"rule", "createRule", "retireRule", "rates", + "rate", "createRate", "retireRate"}) { + Method method = method(scopedMethod); + assertThat(method.getParameterTypes()[1]).isEqualTo(long.class); + assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); + } + } + @Test @DisplayName("契约声明自动发布、已发布费用调整和幂等头") void documentsPricingLifecycle() throws Exception { @@ -28,5 +45,26 @@ class FbaPricingContractTest { .doesNotContain("应收") .doesNotContain("应付") .doesNotContain("待客户确认"); + String customerRules = openapi.substring( + openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules:"), + openapi.indexOf("/api/platform/v1/fba-pricing/customer-rules/versions:")); + assertThat(customerRules) + .contains("OptionalTenantId") + .contains("name: ruleCode") + .doesNotContain("#/components/parameters/Page") + .doesNotContain("#/components/parameters/Size") + .doesNotContain("name: customerName") + .doesNotContain("name: chargeMode") + .doesNotContain("name: status"); + assertThat(openapi) + .contains("OptionalTenantId: {name: tenantId, in: query, required: false") + .contains("schema: {type: string, pattern: '^[1-9][0-9]*$'}") + .doesNotContain("format: int64"); + } + + private static Method method(String name) { + return java.util.Arrays.stream(PlatformFbaMasterDataController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(name)) + .findFirst().orElseThrow(); } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java index 3737d5487..7ac0840c0 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/controller/InboundOrderControllerContractTest.java @@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundOrderQuery; import org.junit.jupiter.api.Test; import org.springframework.core.io.ClassPathResource; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RequestParam; import java.lang.reflect.Method; import java.nio.charset.StandardCharsets; @@ -15,21 +16,40 @@ class InboundOrderControllerContractTest { @Test void shouldDeclareReadOnlyPermissions() throws Exception { assertAuthority("page", "hasAuthority('platform:firstleg:inbound:list')", - AuthenticatedUser.class, long.class, InboundOrderQuery.class); + AuthenticatedUser.class, Long.class, InboundOrderQuery.class); assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')", AuthenticatedUser.class, long.class, long.class); + + Method page = PlatformInboundOrderController.class.getDeclaredMethod( + "page", AuthenticatedUser.class, Long.class, InboundOrderQuery.class); + assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); + Method detail = PlatformInboundOrderController.class.getDeclaredMethod( + "detail", AuthenticatedUser.class, long.class, long.class); + assertThat(detail.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); } @Test void shouldDocumentOnlyV2TabsAndStringLongIds() throws Exception { String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") .getContentAsString(StandardCharsets.UTF_8); + String inboundList = openapi.substring( + openapi.indexOf("/api/platform/inbounds:"), + openapi.indexOf("/api/platform/inbounds/{inboundId}:")); + String taskList = openapi.substring( + openapi.indexOf("/api/platform/v1/first-leg/tasks:"), + openapi.indexOf("/api/platform/v1/first-leg/tasks/{taskId}:")); assertThat(openapi) .contains("/api/platform/inbounds:") .contains("/api/platform/inbounds/{inboundId}:") + .contains("required: false") + .contains("InboundOrderSummary:") + .contains("required: [id, tenantId]") + .contains("tenantId: {type: string, pattern: '^[1-9][0-9]*$', description:") .contains("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]") .contains("platform:firstleg:inbound:list") .doesNotContain("WAIT_CUSTOMER_CONFIRM"); + assertThat(inboundList).contains("#/components/schemas/InboundOrderPageEnvelope"); + assertThat(taskList).doesNotContain("InboundOrderPageEnvelope"); } private static void assertAuthority(String methodName, String value, Class... types) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java new file mode 100644 index 000000000..de5cf90b1 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/FbaPricingRepositoryRuleListTest.java @@ -0,0 +1,84 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.CustomerChargeRuleEntity; +import com.baiyue.firstleg.mapper.CarrierFbaRateMapper; +import com.baiyue.firstleg.mapper.CustomerChargeRuleMapper; +import com.baiyue.firstleg.mapper.InboundFeeItemMapper; +import com.baiyue.firstleg.mapper.InboundFeeMapper; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import com.baiyue.firstleg.mapper.InboundPricingIdempotencyMapper; +import com.baiyue.firstleg.mapper.InboundPricingMapper; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.apache.ibatis.session.Configuration; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Collection; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class FbaPricingRepositoryRuleListTest { + @BeforeAll + static void initializeEntityMetadata() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new Configuration(), "test"), + CustomerChargeRuleEntity.class); + } + + @Test + void platformRuleListOmitsTenantConditionWhenTenantIsAbsent() { + assertThat(queryValues(null, null)).isEmpty(); + } + + @Test + void platformRuleListKeepsTenantAndRuleCodeFiltersWhenProvided() { + assertThat(queryValues(10001L, "CR-1")).containsExactlyInAnyOrder(10001L, "CR-1"); + } + + @Test + void internalVersionCalculationKeepsTenantAndRuleCodeFilters() { + CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class); + CustomerChargeRuleEntity current = new CustomerChargeRuleEntity(); + current.setRuleVersion(3); + when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of(current)); + FbaPricingRepository repository = repository(rules); + + int result = repository.nextRuleVersion(10001L, "CR-1"); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rules).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .containsExactlyInAnyOrder(10001L, "CR-1"); + assertThat(result).isEqualTo(4); + } + + private static Collection queryValues(Long tenantId, String ruleCode) { + CustomerChargeRuleMapper rules = mock(CustomerChargeRuleMapper.class); + when(rules.selectList(org.mockito.ArgumentMatchers.any())).thenReturn(List.of()); + FbaPricingRepository repository = repository(rules); + + repository.listRules(tenantId, ruleCode); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(rules).selectList(wrapper.capture()); + wrapper.getValue().getSqlSegment(); + return wrapper.getValue().getParamNameValuePairs().values(); + } + + private static FbaPricingRepository repository(CustomerChargeRuleMapper rules) { + return new FbaPricingRepository(rules, mock(CarrierFbaRateMapper.class), + mock(InboundPricingMapper.class), mock(InboundFeeMapper.class), mock(InboundFeeItemMapper.class), + mock(InboundPricingIdempotencyMapper.class), mock(InboundOutboxMapper.class)); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java index af833878b..b1f203a72 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/InboundOrderRepositoryTest.java @@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; +import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity; import com.baiyue.firstleg.mapper.InboundFileMapper; import com.baiyue.firstleg.mapper.InboundInternalNoteMapper; import com.baiyue.firstleg.mapper.InboundOrderMapper; @@ -73,8 +74,10 @@ class InboundOrderRepositoryTest { Page page = new Page<>(1, 20); InboundOrderEntity first = new InboundOrderEntity(); first.setId(1L); + first.setTenantId(10001L); InboundOrderEntity second = new InboundOrderEntity(); second.setId(2L); + second.setTenantId(10001L); page.setRecords(java.util.List.of(first, second)); when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( @@ -104,12 +107,85 @@ class InboundOrderRepositoryTest { .contains("ORDER BY inbound_id ASC,allocation_no ASC"); } + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void shouldQueryAndAggregateCrossTenantPageByInboundOwnership() { + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), InboundOrderEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundTransportSnapshotEntity.class); + TableInfoHelper.initTableInfo( + new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundPalletAllocationEntity.class); + InboundOrderMapper orders = mock(InboundOrderMapper.class); + InboundTransportSnapshotMapper transports = mock(InboundTransportSnapshotMapper.class); + InboundPalletAllocationMapper pallets = mock(InboundPalletAllocationMapper.class); + Page page = new Page<>(1, 20); + page.setRecords(java.util.List.of(order(1L, 10001L), order(2L, 10002L))); + when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); + when(transports.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( + transport(1L, 10001L, "T1"), + transport(2L, 10002L, "T2"), + transport(1L, 10002L, "WRONG"))); + when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( + allocation(1L, 10001L, 1, "A1"), + allocation(2L, 10002L, 1, "B1"), + allocation(1L, 10002L, 2, "WRONG"))); + InboundOrderRepository repository = new InboundOrderRepository( + orders, transports, pallets, mock(InboundFileMapper.class), + mock(InboundInternalNoteMapper.class), mock(InboundStatusHistoryMapper.class)); + + var result = repository.page(null, new InboundOrderQuery( + null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, 1, 20)); + + assertThat(result.records()).extracting(record -> record.tenantId()) + .containsExactly(10001L, 10002L); + assertThat(result.records()).extracting(record -> record.loadingStatus()) + .containsExactly("T1", "T2"); + assertThat(result.records()).extracting(record -> record.locationCodes()) + .containsExactly("A1", "B1"); + ArgumentCaptor> orderCaptor = ArgumentCaptor.forClass(Wrapper.class); + verify(orders).selectPage(any(Page.class), orderCaptor.capture()); + assertThat(orderCaptor.getValue().getCustomSqlSegment()).doesNotContain("tenant_id"); + + ArgumentCaptor> transportCaptor = + ArgumentCaptor.forClass(Wrapper.class); + verify(transports).selectList(transportCaptor.capture()); + assertThat(transportCaptor.getValue().getCustomSqlSegment()) + .doesNotContain("tenant_id") + .contains("inbound_id IN"); + } + private static InboundPalletAllocationEntity allocation( long inboundId, int allocationNo, String locationCode) { + return allocation(inboundId, 10001L, allocationNo, locationCode); + } + + private static InboundPalletAllocationEntity allocation( + long inboundId, long tenantId, int allocationNo, String locationCode) { InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity(); entity.setInboundId(inboundId); + entity.setTenantId(tenantId); entity.setAllocationNo(allocationNo); entity.setLocationCode(locationCode); return entity; } + + private static InboundOrderEntity order(long id, long tenantId) { + InboundOrderEntity entity = new InboundOrderEntity(); + entity.setId(id); + entity.setTenantId(tenantId); + return entity; + } + + private static InboundTransportSnapshotEntity transport( + long inboundId, long tenantId, String loadingStatus) { + InboundTransportSnapshotEntity entity = new InboundTransportSnapshotEntity(); + entity.setInboundId(inboundId); + entity.setTenantId(tenantId); + entity.setLoadingStatus(loadingStatus); + return entity; + } } diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java index c9c8cc28c..9d60dadc3 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/FbaMasterDataServiceTest.java @@ -16,12 +16,27 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @DisplayName("FBA计费基础资料版本") class FbaMasterDataServiceTest { private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0); + @Test + @DisplayName("平台列表不传企业时委托仓储查询全部企业规则") + void listsRulesAcrossTenantsWithoutTenantFilter() { + FbaPricingRepository repository = mock(FbaPricingRepository.class); + List expected = List.of(activeRule(AUGUST, null)); + when(repository.listRules(null, "CR-1")).thenReturn(expected); + + List result = + new FbaMasterDataService(repository).listRules(null, "CR-1"); + + assertThat(result).isSameAs(expected); + verify(repository).listRules(null, "CR-1"); + } + @Test @DisplayName("半开有效期首尾相接不视为重叠") void acceptsAdjacentHalfOpenPeriods() { diff --git a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java index 4d214fe48..fd015b9af 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/service/InboundOrderQueryServiceTest.java @@ -21,6 +21,17 @@ import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; class InboundOrderQueryServiceTest { + @Test + void shouldQueryAllPlatformTenantsWhenTenantFilterIsMissing() { + InboundOrderRepository repository = mock(InboundOrderRepository.class); + InboundOrderQuery query = query(null, null, null, null); + PageResult expected = new PageResult<>(List.of(), 1, 20, 0, 0); + when(repository.page(null, query)).thenReturn(expected); + + assertThat(new InboundOrderQueryService(repository).page(null, query)).isSameAs(expected); + verify(repository).page(null, query); + } + @Test void shouldKeepV2TabMappingWithoutCustomerConfirmation() { assertThat(InboundListTab.WAIT_MEASURE.processStatuses()) diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index 923ec2ded..7243ddecb 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -10,6 +10,8 @@ import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse; import com.baiyue.iam.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; @@ -73,6 +75,22 @@ public class InternalTenantController { return ApiResponse.success(profileResourceService.registerPlatformQualification(request)); } + @PostMapping("/required-qualification-packages") + public ApiResponse submitRequiredQualificationPackage( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody RequiredQualificationPackageRequest request) { + verify(token); + return ApiResponse.success(profileResourceService.submitRequiredQualificationPackage(request)); + } + + @PostMapping("/platform-required-qualification-packages") + public ApiResponse registerPlatformRequiredQualificationPackage( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody RequiredQualificationPackageRequest request) { + verify(token); + return ApiResponse.success(profileResourceService.registerPlatformRequiredQualificationPackage(request)); + } + @GetMapping("/tenants/{tenantId}/order-eligibility") public ApiResponse orderEligibility( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index 1933b48dd..b00df77b8 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -50,5 +50,24 @@ public final class OnboardingDtos { String replacedDocumentUrl ) { } + public record RequiredQualificationPackageRequest( + @NotNull Long tenantId, + @NotBlank @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode, + @NotBlank String businessLicenseDocumentUrl, + LocalDate businessLicenseValidTo, + boolean replaceBusinessLicense, + @NotBlank String legalPersonIdDocumentUrl, + LocalDate legalPersonIdValidTo, + boolean replaceLegalPersonId + ) { } + + public record RequiredQualificationPackageResponse( + Long businessLicenseQualificationId, + Long legalPersonIdQualificationId, + String tenantStatus, + String replacedBusinessLicenseDocumentUrl, + String replacedLegalPersonIdDocumentUrl + ) { } + public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index 3c948f97d..04cdce90b 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -26,7 +26,6 @@ public final class TenantDtos { @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, @Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, - @Schema(description = "Optional while creating a draft; required before review / 创建草稿时选填,送审前必填", example = "91440300MA5EXAMPLE", nullable = true) @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode, @Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName, @Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone, @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail, diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index 701dd3460..0d5f25b1f 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -7,6 +7,8 @@ import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse; import com.baiyue.iam.dto.ProfileDtos.AddressRequest; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantAddress; @@ -65,6 +67,7 @@ public class ProfileResourceService { throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); } + requireOptionalQualificationType(request.qualificationType()); String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo()); List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() .filter(item -> request.qualificationType().equals(item.getQualificationType()) @@ -86,11 +89,6 @@ public class ProfileResourceService { request.qualificationType(), certificateNo, request.documentUrl()); qualification.setValidTo(request.validTo()); repository.saveQualification(qualification); - if ("BUSINESS_LICENSE".equals(request.qualificationType()) - && !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) { - tenant.setUnifiedSocialCreditCode(certificateNo); - tenantRepository.saveTenant(tenant); - } return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); } @@ -105,6 +103,7 @@ public class ProfileResourceService { if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); } + requireOptionalQualificationType(request.qualificationType()); String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo()); List activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() .filter(item -> request.qualificationType().equals(item.getQualificationType()) @@ -126,15 +125,93 @@ public class ProfileResourceService { certificateNo, request.documentUrl()); qualification.setValidTo(request.validTo()); repository.saveQualification(qualification); - if ("BUSINESS_LICENSE".equals(request.qualificationType()) - && !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) { - tenant.setUnifiedSocialCreditCode(certificateNo); - tenantRepository.saveTenant(tenant); - } return new PlatformQualificationRegistrationResponse( qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); } + @Transactional + @BusinessAudit(event = "TENANT_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "上传企业必填资质包", captureAfter = false) + public RequiredQualificationPackageResponse submitRequiredQualificationPackage( + RequiredQualificationPackageRequest request) { + return registerRequiredQualificationPackage(request, "商户在送审前替换文件"); + } + + @Transactional + @BusinessAudit(event = "PLATFORM_REQUIRED_QUALIFICATION_PACKAGE_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "平台代客户上传必填资质包", captureAfter = false) + public RequiredQualificationPackageResponse registerPlatformRequiredQualificationPackage( + RequiredQualificationPackageRequest request) { + return registerRequiredQualificationPackage(request, "平台在送审前替换文件"); + } + + private RequiredQualificationPackageResponse registerRequiredQualificationPackage( + RequiredQualificationPackageRequest request, String replacementComment) { + IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId()); + if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) { + throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found / 企业客户不存在"); + } + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, + "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); + } + String creditCode = resolveCertificateNo(tenant, "BUSINESS_LICENSE", request.unifiedSocialCreditCode()); + List locked = repository.findQualificationsForUpdate(tenant.getId()); + List businessLicenses = currentQualifications(locked, "BUSINESS_LICENSE"); + List legalPersonIds = currentQualifications(locked, "LEGAL_PERSON_ID"); + requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照"); + requireReplaceAllowed(legalPersonIds, request.replaceLegalPersonId(), "法人身份证"); + + String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment); + String replacedLegalPersonIdUrl = supersede(legalPersonIds, replacementComment); + IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE", + creditCode, request.businessLicenseDocumentUrl()); + businessLicense.setValidTo(request.businessLicenseValidTo()); + repository.saveQualification(businessLicense); + IamTenantQualification legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID", + null, request.legalPersonIdDocumentUrl()); + legalPersonId.setValidTo(request.legalPersonIdValidTo()); + repository.saveQualification(legalPersonId); + if (!creditCode.equals(tenant.getUnifiedSocialCreditCode())) { + tenant.setUnifiedSocialCreditCode(creditCode); + tenantRepository.saveTenant(tenant); + } + return new RequiredQualificationPackageResponse( + businessLicense.getId(), legalPersonId.getId(), tenant.getStatus(), + replacedBusinessLicenseUrl, replacedLegalPersonIdUrl); + } + + private List currentQualifications( + List qualifications, String type) { + return qualifications.stream() + .filter(item -> type.equals(item.getQualificationType()) && isCurrentQualification(item)) + .toList(); + } + + private void requireReplaceAllowed(List current, boolean replace, String label) { + if (!current.isEmpty() && !replace) { + throw new BizException(ResultCode.CONFLICT, label + "已上传,请明确选择替换"); + } + } + + private String supersede(List current, String comment) { + String replacedUrl = null; + for (IamTenantQualification existing : current) { + if (replacedUrl == null) replacedUrl = existing.getDocumentUrl(); + existing.setStatus("SUPERSEDED"); + existing.setReviewComment(comment); + repository.saveQualification(existing); + } + return replacedUrl; + } + + private void requireOptionalQualificationType(String type) { + if ("BUSINESS_LICENSE".equals(type) || "LEGAL_PERSON_ID".equals(type)) { + throw new BizException(ResultCode.BAD_REQUEST, + "Required qualifications must be uploaded as one package / 营业执照、统一社会信用代码和法人身份证必须整包上传"); + } + } + private String resolveCertificateNo(IamTenant tenant, String qualificationType, String submittedCertificateNo) { if (!"BUSINESS_LICENSE".equals(qualificationType)) { return submittedCertificateNo; diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index fc4a2691a..f7aa8f4c1 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -24,7 +24,6 @@ import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow; import com.baomidou.mybatisplus.core.toolkit.IdWorker; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import java.time.LocalDateTime; import java.util.List; @@ -54,11 +53,6 @@ public class TenantService { ) @Transactional public TenantResponse register(RegisterRequest request, long operatorId) { - String creditCode = normalizeCreditCode(request.unifiedSocialCreditCode()); - if (creditCode != null && repository.existsByCreditCode(creditCode)) { - throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在"); - } - IamTenant tenant = new IamTenant(); tenant.setId(IdWorker.getId()); tenant.setTenantCode("TEN" + tenant.getId()); @@ -67,7 +61,7 @@ public class TenantService { tenant.setStatus("PROFILE_REQUIRED"); tenant.setLegalName(request.legalName()); tenant.setLegalRepresentative(request.legalRepresentative()); - tenant.setUnifiedSocialCreditCode(creditCode); + tenant.setUnifiedSocialCreditCode(null); tenant.setContactName(request.contactName()); tenant.setContactPhone(request.contactPhone()); tenant.setContactEmail(request.contactEmail()); @@ -334,10 +328,6 @@ public class TenantService { return tenant; } - private String normalizeCreditCode(String creditCode) { - return StringUtils.hasText(creditCode) ? creditCode.trim().toUpperCase() : null; - } - private TenantResponse response(IamTenant tenant) { return new TenantResponse( tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 1d9d3fd83..12071a205 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -608,6 +608,46 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '409': {$ref: '#/components/responses/Conflict'} + /internal/iam/required-qualification-packages: + post: + tags: [IAM Resources / IAM 资源] + summary: 商户必填资质包登记 + description: 仅供 File Service 调用;在一个 IAM 事务中登记营业执照、统一社会信用代码和法人身份证。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageRequest'} + responses: + '200': + description: 统一响应;仅 data 非空且 code 为 200 表示资质包已完整登记 + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} + /internal/iam/platform-required-qualification-packages: + post: + tags: [IAM Resources / IAM 资源] + summary: 平台代客户必填资质包登记 + description: 仅供 File Service 调用;与商户入口使用相同的三项事务保存规则。 + security: [] + parameters: + - {$ref: '#/components/parameters/InternalServiceToken'} + requestBody: + required: true + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageRequest'} + responses: + '200': + description: 统一响应;仅 data 非空且 code 为 200 表示资质包已完整登记 + content: + application/json: + schema: {$ref: '#/components/schemas/RequiredQualificationPackageApiResponse'} + '403': {$ref: '#/components/responses/Forbidden'} /internal/exports/v1/CUSTOMER_LIST/batches: post: tags: [Internal Export Provider] @@ -650,6 +690,11 @@ components: scheme: bearer bearerFormat: JWT parameters: + InternalServiceToken: + name: X-Internal-Service-Token + in: header + required: true + schema: {type: string} Id: name: id in: path @@ -838,6 +883,33 @@ components: validTo: {type: string, format: date, nullable: true} status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]} reviewComment: {type: string} + RequiredQualificationPackageRequest: + type: object + required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense, legalPersonIdDocumentUrl, replaceLegalPersonId] + properties: + tenantId: {type: string, pattern: '^\d+$'} + unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'} + businessLicenseDocumentUrl: {type: string, minLength: 1} + businessLicenseValidTo: {type: string, format: date, nullable: true} + replaceBusinessLicense: {type: boolean} + legalPersonIdDocumentUrl: {type: string, minLength: 1} + legalPersonIdValidTo: {type: string, format: date, nullable: true} + replaceLegalPersonId: {type: boolean} + RequiredQualificationPackageResponse: + type: object + required: [businessLicenseQualificationId, legalPersonIdQualificationId, tenantStatus] + properties: + businessLicenseQualificationId: {type: string, pattern: '^\d+$'} + legalPersonIdQualificationId: {type: string, pattern: '^\d+$'} + tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]} + replacedBusinessLicenseDocumentUrl: {type: string, nullable: true} + replacedLegalPersonIdDocumentUrl: {type: string, nullable: true} + RequiredQualificationPackageApiResponse: + allOf: + - {$ref: '#/components/schemas/ApiResponse'} + - type: object + properties: + data: {$ref: '#/components/schemas/RequiredQualificationPackageResponse'} RegisterRequest: type: object required: [tenantName, legalName, legalRepresentative, contactName, contactPhone, servicePackage] @@ -845,7 +917,6 @@ components: tenantName: {type: string, example: 深圳虫虫出海科技有限公司} legalName: {type: string, example: 深圳虫虫出海科技有限公司} legalRepresentative: {type: string, maxLength: 64, example: 张三} - unifiedSocialCreditCode: {type: string, nullable: true, pattern: '^[0-9A-Za-z]{18}$', example: 91440300MA5ABCDE12, description: 创建草稿时选填,送审前必填} contactName: {type: string, example: 李四} contactPhone: {type: string, example: '13800138000'} contactEmail: {type: string, format: email, example: contact@example.com} diff --git a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java index 9dbf1b24c..5c3ef9b84 100644 --- a/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java +++ b/iam/src/test/java/com/baiyue/iam/controller/QualificationUploadContractTest.java @@ -38,4 +38,28 @@ class QualificationUploadContractTest { assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:")); } } + + @Test + void internalControllerExposesBothRequiredPackageRoutes() { + var routes = java.util.Arrays.stream(InternalTenantController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(mapping -> java.util.Arrays.stream(mapping.value())) + .toList(); + assertTrue(routes.contains("/required-qualification-packages")); + assertTrue(routes.contains("/platform-required-qualification-packages")); + } + + @Test + void staticOpenApiDocumentsTheRequiredPackageContract() throws IOException { + try (var input = getClass().getClassLoader().getResourceAsStream("openapi/iam-v1.yaml")) { + assertNotNull(input); + String yaml = new String(input.readAllBytes(), StandardCharsets.UTF_8); + assertTrue(yaml.contains("/internal/iam/required-qualification-packages:")); + assertTrue(yaml.contains("/internal/iam/platform-required-qualification-packages:")); + assertTrue(yaml.contains("RequiredQualificationPackageRequest:")); + assertTrue(yaml.contains("replaceBusinessLicense")); + assertTrue(yaml.contains("replaceLegalPersonId")); + } + } } diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java index 87aec3b5d..b5180667c 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java @@ -1,6 +1,7 @@ package com.baiyue.iam.service; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; +import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; @@ -18,12 +19,13 @@ 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 static org.mockito.Mockito.times; import com.baiyue.common.core.exception.BizException; class MerchantQualificationUploadTest { @Test - void uploadsOneDraftQualificationWithoutCreatingAReview() { + void uploadsOneOptionalDraftQualificationWithoutCreatingAReview() { TenantRepository tenantRepository = mock(TenantRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceService service = new ProfileResourceService(); @@ -37,8 +39,8 @@ class MerchantQualificationUploadTest { when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); var response = service.submitQualification(new QualificationSubmissionRequest( - 10001L, "LEGAL_PERSON_ID", null, - "/api/merchant/v1/files/qualifications/legal-person.png", LocalDate.of(2030, 1, 1), false)); + 10001L, "FREIGHT_FORWARDER_CERTIFICATE", null, + "/api/merchant/v1/files/qualifications/freight-forwarder.png", LocalDate.of(2030, 1, 1), false)); assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertNull(response.reviewId()); @@ -47,7 +49,26 @@ class MerchantQualificationUploadTest { } @Test - void businessLicenseRequiresAndPersistsAUniqueUnifiedSocialCreditCode() { + void requiredQualificationsRejectTheLegacySingleFilePath() { + TenantRepository tenantRepository = mock(TenantRepository.class); + ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); + ProfileResourceService service = new ProfileResourceService(); + ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); + ReflectionTestUtils.setField(service, "repository", profileRepository); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest( + 10001L, "BUSINESS_LICENSE", "91310101abcde12345", + "/api/merchant/v1/files/qualifications/license.png", null, false))); + + verify(profileRepository, never()).insertQualification(any()); + } + + @Test + void requiredPackagePersistsBothFilesAndCreditCodeTogether() { TenantRepository tenantRepository = mock(TenantRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceService service = new ProfileResourceService(); @@ -60,21 +81,19 @@ class MerchantQualificationUploadTest { when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); - assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest( - 10001L, "BUSINESS_LICENSE", null, - "/api/merchant/v1/files/qualifications/license.png", null, false))); - - var response = service.submitQualification(new QualificationSubmissionRequest( - 10001L, "BUSINESS_LICENSE", "91310101abcde12345", - "/api/merchant/v1/files/qualifications/license.png", null, false)); + var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest( + 10001L, "91310101abcde12345", + "/api/merchant/v1/files/qualifications/license.png", null, false, + "/api/merchant/v1/files/qualifications/legal-person.png", null, false)); assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode()); + verify(profileRepository, times(2)).insertQualification(any()); verify(tenantRepository).saveTenant(tenant); } @Test - void businessLicenseCannotOverwriteAnEstablishedCreditCode() { + void requiredPackageCannotOverwriteAnEstablishedCreditCode() { TenantRepository tenantRepository = mock(TenantRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceService service = new ProfileResourceService(); @@ -87,11 +106,11 @@ class MerchantQualificationUploadTest { tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); - assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest( - 10001L, "BUSINESS_LICENSE", "91310101ABCDE12345", - "/api/merchant/v1/files/qualifications/license.png", null, true))); + assertThrows(BizException.class, () -> service.submitRequiredQualificationPackage( + new RequiredQualificationPackageRequest(10001L, "91310101ABCDE12345", + "/api/merchant/v1/files/qualifications/license.png", null, true, + "/api/merchant/v1/files/qualifications/legal-person.png", null, true))); verify(profileRepository, never()).insertQualification(any()); - verify(tenantRepository, never()).saveTenant(tenant); } } diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java index e31ec75a6..e3dcd9b74 100644 --- a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -21,7 +21,7 @@ import static org.mockito.Mockito.when; class PlatformQualificationRegistrationTest { @Test - void platformUploadRegistersMetadataWithoutCreatingReviewTicket() { + void platformUploadRegistersOptionalMetadataWithoutCreatingReviewTicket() { ProfileResourceRepository repository = mock(ProfileResourceRepository.class); TenantRepository tenantRepository = mock(TenantRepository.class); ProfileResourceService service = new ProfileResourceService(); @@ -33,13 +33,13 @@ class PlatformQualificationRegistrationTest { when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( - 10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/file.pdf", + 10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/file.pdf", LocalDate.of(2030, 12, 31), false)); ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); verify(repository).insertQualification(captor.capture()); verify(repository).saveQualification(captor.getValue()); - assertEquals("LEGAL_PERSON_ID", captor.getValue().getQualificationType()); + assertEquals("BANK_ACCOUNT_LICENSE", captor.getValue().getQualificationType()); assertEquals(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo()); assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertNull(response.reviewId()); @@ -56,14 +56,14 @@ class PlatformQualificationRegistrationTest { IamTenant tenant = new IamTenant(); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); IamTenantQualification existing = new IamTenantQualification(); - existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID"); + existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("BANK_ACCOUNT_LICENSE"); existing.setStatus("PENDING"); existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf"); when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing)); var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( - 10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/new-file.pdf", + 10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf", LocalDate.of(2031, 12, 31), true)); ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 979b8257c..9a1e5afd4 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -120,7 +120,7 @@ class TenantServiceTest { ReflectionTestUtils.setField(service, "repository", repository); var created = service.register(new RegisterRequest( - "Example Merchant", "Example Merchant Ltd", "Bob", "91330100EXAMPLE001", "Alice", "13800138000", + "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), 9001L); @@ -134,13 +134,13 @@ class TenantServiceTest { } @Test - void platformRegistrationAllowsCreditCodeToBeCompletedWithQualificationsLater() { + void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() { TenantRepository repository = mock(TenantRepository.class); TenantService service = new TenantService(); ReflectionTestUtils.setField(service, "repository", repository); var created = service.register(new RegisterRequest( - "Example Merchant", "Example Merchant Ltd", "Bob", null, "Alice", "13800138000", + "Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000", "alice@example.com", "基础版", null, null, 30, null, null, null, "CNY", null, null), 9001L); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java index aa4ac6d97..85b05d308 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformExpressCompanyController.java @@ -13,6 +13,7 @@ import com.baiyue.warehouse.entity.ExpressCompanyEntity; import com.baiyue.warehouse.service.ExpressCompanyQueryService; import com.baiyue.warehouse.service.ExpressCompanyImportService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -42,7 +43,7 @@ import java.nio.charset.StandardCharsets; /** 平台快递公司管理接口。 */ @Validated -@Tag(name = "平台快递公司", description = "按目标租户分页查询快递公司主数据") +@Tag(name = "平台快递公司", description = "跨企业分页查询和按目标租户维护快递公司主数据") @RestController @RequestMapping("/api/platform/v1/express-companies") public class PlatformExpressCompanyController { @@ -56,7 +57,7 @@ public class PlatformExpressCompanyController { @GetMapping @PreAuthorize("hasAuthority('platform:express:list')") - @Operation(summary = "分页查询快递公司", description = "按目标租户、关键字和状态分页查询快递公司") + @Operation(summary = "分页查询快递公司", description = "默认查询全部企业;可按目标租户、关键字和状态筛选") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @@ -65,7 +66,8 @@ public class PlatformExpressCompanyController { }) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @Parameter(description = "可选目标企业租户ID;不传时查询全部企业") + @RequestParam(required = false) @Min(1) Long tenantId, @Valid @ParameterObject ExpressCompanyPageQuery query) { requirePlatform(user); return ApiResponse.success(service.page(tenantId, query)); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java index ef183136f..d0bb4a40e 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/controller/PlatformPartnerController.java @@ -13,6 +13,7 @@ import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.service.PlatformPartnerQueryService; import com.baiyue.warehouse.service.PartnerImportService; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; @@ -42,7 +43,7 @@ import java.nio.charset.StandardCharsets; /** 平台往来单位管理接口。 */ @Validated -@Tag(name = "平台往来单位", description = "按目标租户分页查询往来单位主数据") +@Tag(name = "平台往来单位", description = "跨企业分页查询和按目标租户维护往来单位主数据") @RestController @RequestMapping("/api/platform/v1/partners") public class PlatformPartnerController { @@ -83,7 +84,8 @@ public class PlatformPartnerController { @GetMapping @PreAuthorize("hasAuthority('platform:counterparty:list')") - @Operation(summary = "分页查询往来单位", description = "按目标租户、关键字、类型和状态分页查询往来单位") + @Operation(summary = "分页查询往来单位", + description = "默认分页查询全部企业往来单位;可按目标租户、关键字、类型和状态筛选") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @@ -92,7 +94,8 @@ public class PlatformPartnerController { }) public ApiResponse> page( @AuthenticationPrincipal AuthenticatedUser user, - @RequestParam @Min(1) long tenantId, + @Parameter(description = "可选目标企业租户ID;不传时查询全部企业") + @RequestParam(required = false) @Min(1) Long tenantId, @Valid @ParameterObject PartnerPageQuery query) { requirePlatform(user); return ApiResponse.success(service.page(tenantId, query)); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java index 3d7795a22..354ed3672 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/mapper/PartnerMapper.java @@ -1,5 +1,6 @@ package com.baiyue.warehouse.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; import com.baiyue.warehouse.entity.PartnerEntity; import com.baiyue.warehouse.entity.PartnerTypeEntity; import org.apache.ibatis.annotations.Delete; @@ -25,6 +26,56 @@ public interface PartnerMapper extends BaseMapper { @Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}") List selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type); + @Select(""" + + """) + Page selectPartnerPageByType(Page page, + @Param("tenantId") Long tenantId, + @Param("type") String type, + @Param("status") String status, + @Param("keyword") String keyword); + @Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}") void deleteTypes(@Param("partnerId") long partnerId); diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java index 3c8ecc38c..e60fe5d09 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/ExpressCompanyRepository.java @@ -9,7 +9,7 @@ import com.baiyue.warehouse.mapper.ExpressCompanyMapper; import org.springframework.stereotype.Repository; import org.springframework.util.StringUtils; -/** 快递公司主数据仓储。 */ +/** 快递公司主数据仓储;平台分页租户条件可选,资源读写仍强制租户边界。 */ @Repository public class ExpressCompanyRepository { private final ExpressCompanyMapper mapper; @@ -18,9 +18,9 @@ public class ExpressCompanyRepository { this.mapper = mapper; } - public PageResult page(long tenantId, ExpressCompanyPageQuery query) { + public PageResult page(Long tenantId, ExpressCompanyPageQuery query) { LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(ExpressCompanyEntity::getTenantId, tenantId) + .eq(tenantId != null, ExpressCompanyEntity::getTenantId, tenantId) .eq(StringUtils.hasText(query.status()), ExpressCompanyEntity::getStatus, query.status()) .and(StringUtils.hasText(query.keyword()), condition -> condition .like(ExpressCompanyEntity::getCompanyCode, query.keyword()) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java index ee2fb292e..0e7691aff 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/repository/PartnerRepository.java @@ -41,14 +41,17 @@ public class PartnerRepository { return entity; } - public PageResult page(long tenantId, PartnerPageQuery request) { - List typedIds = typedIds(tenantId, request.type()); - if (StringUtils.hasText(request.type()) && typedIds.isEmpty()) { - return new PageResult<>(List.of(), request.page(), request.size(), 0, 0); + public PageResult page(Long tenantId, PartnerPageQuery request) { + if (StringUtils.hasText(request.type())) { + Page page = mapper.selectPartnerPageByType( + new Page<>(request.page(), request.size()), tenantId, request.type(), + request.status(), request.keyword()); + PageResult result = PageResult.from(page); + hydrateTypes(result.records()); + return result; } LambdaQueryWrapper wrapper = new LambdaQueryWrapper() - .eq(PartnerEntity::getTenantId, tenantId) - .in(StringUtils.hasText(request.type()), PartnerEntity::getId, typedIds) + .eq(tenantId != null, PartnerEntity::getTenantId, tenantId) .eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status()) .and(StringUtils.hasText(request.keyword()), query -> query .like(PartnerEntity::getPartnerCode, request.keyword()) diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java index 2c435905a..e800a4188 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/ExpressCompanyQueryService.java @@ -21,7 +21,7 @@ public class ExpressCompanyQueryService { this.repository = repository; } - public PageResult page(long tenantId, ExpressCompanyPageQuery query) { + public PageResult page(Long tenantId, ExpressCompanyPageQuery query) { return repository.page(tenantId, query); } diff --git a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java index 226997c58..7fbb6d58a 100644 --- a/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java +++ b/warehouse/src/main/java/com/baiyue/warehouse/service/PlatformPartnerQueryService.java @@ -25,7 +25,7 @@ public class PlatformPartnerQueryService { this.repository = repository; } - public PageResult page(long tenantId, PartnerPageQuery query) { + public PageResult page(Long tenantId, PartnerPageQuery query) { return repository.page(tenantId, query); } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformExpressCompanyAllTenantQueryContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformExpressCompanyAllTenantQueryContractTest.java new file mode 100644 index 000000000..eb5c71d1a --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformExpressCompanyAllTenantQueryContractTest.java @@ -0,0 +1,31 @@ +package com.baiyue.warehouse.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformExpressCompanyAllTenantQueryContractTest { + @Test + void 分页租户参数可选但详情写入启停和导入仍然必填() { + Method page = method("page"); + assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class); + assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); + + for (String scopedMethod : new String[] {"detail", "create", "update", "changeStatus", "importCompanies"}) { + Method method = method(scopedMethod); + assertThat(method.getParameterTypes()[1]).isEqualTo(long.class); + assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); + } + } + + private static Method method(String name) { + return Arrays.stream(PlatformExpressCompanyController.class.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(name)) + .findFirst() + .orElseThrow(); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java index c96761909..39ada00c4 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformMasterDataControllerContractTest.java @@ -75,7 +75,7 @@ class PlatformMasterDataControllerContractTest { Method page = controller.getDeclaredMethod("page", com.baiyue.common.security.AuthenticatedUser.class, - long.class, + Long.class, controller == PlatformPartnerController.class ? com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery.class : com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery.class); diff --git a/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPartnerAllTenantQueryContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPartnerAllTenantQueryContractTest.java new file mode 100644 index 000000000..cd4351ea9 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/controller/PlatformPartnerAllTenantQueryContractTest.java @@ -0,0 +1,64 @@ +package com.baiyue.warehouse.controller; + +import com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery; +import com.baiyue.warehouse.mapper.PartnerMapper; +import com.baiyue.warehouse.repository.PartnerRepository; +import com.baiyue.warehouse.service.PlatformPartnerQueryService; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; +import org.springframework.web.bind.annotation.RequestParam; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class PlatformPartnerAllTenantQueryContractTest { + @Test + void 平台分页租户可选但详情写入和导入仍要求租户() { + Method page = method(PlatformPartnerController.class, "page"); + assertThat(page.getParameterTypes()[1]).isEqualTo(Long.class); + assertThat(page.getParameters()[1].getAnnotation(RequestParam.class).required()).isFalse(); + + for (String scopedMethod : new String[] {"importPartners", "detail", "create", "update", "changeStatus"}) { + Method method = method(PlatformPartnerController.class, scopedMethod); + assertThat(method.getParameterTypes()[1]).isEqualTo(long.class); + assertThat(method.getParameters()[1].getAnnotation(RequestParam.class).required()).isTrue(); + } + } + + @Test + void 分页链路使用可选租户而其他仓储方法保持原有强租户边界() throws Exception { + assertThat(PlatformPartnerQueryService.class.getMethod("page", Long.class, PartnerPageQuery.class)) + .isNotNull(); + assertThat(PartnerRepository.class.getMethod("page", Long.class, PartnerPageQuery.class)) + .isNotNull(); + assertThat(PartnerRepository.class.getMethod("findOwned", long.class, long.class)) + .isNotNull(); + assertThat(PartnerRepository.class.getMethod("existsCode", long.class, String.class, Long.class)) + .isNotNull(); + } + + @Test + void 类型筛选在数据库内关联分页且租户条件可选() throws Exception { + Method method = PartnerMapper.class.getMethod("selectPartnerPageByType", + Page.class, Long.class, String.class, String.class, String.class); + String sql = String.join("\n", method.getAnnotation(Select.class).value()); + + assertThat(sql) + .contains("AND EXISTS (") + .contains("pt.partner_id = p.id") + .contains("pt.partner_type = #{type}") + .contains("pt.tenant_id = p.tenant_id") + .contains("") + .contains("AND p.tenant_id = #{tenantId}") + .doesNotContain("partner_id IN"); + } + + private static Method method(Class type, String name) { + return java.util.Arrays.stream(type.getDeclaredMethods()) + .filter(candidate -> candidate.getName().equals(name)) + .findFirst() + .orElseThrow(); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/mapper/PartnerMapperContractTest.java b/warehouse/src/test/java/com/baiyue/warehouse/mapper/PartnerMapperContractTest.java new file mode 100644 index 000000000..1a2f75cba --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/mapper/PartnerMapperContractTest.java @@ -0,0 +1,24 @@ +package com.baiyue.warehouse.mapper; + +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class PartnerMapperContractTest { + @Test + void bothTypeRequiresCustomerAndSupplierAssociations() throws Exception { + Select select = PartnerMapper.class.getDeclaredMethod("selectPartnerPageByType", + Page.class, Long.class, String.class, String.class, String.class) + .getAnnotation(Select.class); + String sql = String.join("\n", select.value()); + + assertThat(sql) + .contains("") + .contains("customer_type.partner_type = 'CUSTOMER'") + .contains("supplier_type.partner_type = 'SUPPLIER'") + .contains("") + .contains("pt.partner_type = #{type}"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java index e27391153..6e47cc898 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/ExpressCompanyRepositoryTest.java @@ -25,7 +25,7 @@ class ExpressCompanyRepositoryTest { } @Test - void 平台分页强制租户并支持筛选() { + void 平台分页传入租户时按租户并支持筛选() { ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); @@ -40,4 +40,22 @@ class ExpressCompanyRepositoryTest { assertThat(wrapper.getValue().getParamNameValuePairs().values()) .contains(10001L, "ACTIVE", "%顺丰%"); } + + @Test + void 平台分页不传租户时查询全部企业并保留其他筛选() { + ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); + when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); + + new ExpressCompanyRepository(mapper).page(null, + new ExpressCompanyPageQuery(1, 20, "顺丰", "ACTIVE")); + + @SuppressWarnings("unchecked") + ArgumentCaptor> wrapper = + ArgumentCaptor.forClass(LambdaQueryWrapper.class); + verify(mapper).selectPage(any(Page.class), wrapper.capture()); + wrapper.getValue().getSqlSegment(); + assertThat(wrapper.getValue().getParamNameValuePairs().values()) + .contains("ACTIVE", "%顺丰%") + .doesNotContain(10001L); + } } diff --git a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java index 2f73f69ec..d55620696 100644 --- a/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java +++ b/warehouse/src/test/java/com/baiyue/warehouse/repository/PartnerRepositoryTest.java @@ -56,17 +56,27 @@ class PartnerRepositoryTest { @Test void 平台分页强制租户并支持筛选() { PartnerMapper mapper=mock(PartnerMapper.class); - when(mapper.selectPartnerIdsByType(10001L, "CUSTOMER")).thenReturn(List.of(9L)); - when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); + when(mapper.selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"), + eq("ACTIVE"), eq("示例"))).thenAnswer(invocation -> invocation.getArgument(0)); new PartnerRepository(mapper).page(10001L, new PartnerPageQuery(1, 20, "示例", "CUSTOMER", "ACTIVE")); - @SuppressWarnings("unchecked") - ArgumentCaptor> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class); - verify(mapper).selectPage(any(Page.class), wrapper.capture()); - wrapper.getValue().getSqlSegment(); - assertThat(wrapper.getValue().getParamNameValuePairs().values()) - .contains(10001L, 9L, "ACTIVE", "%示例%"); + verify(mapper).selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"), + eq("ACTIVE"), eq("示例")); + verify(mapper, never()).selectPartnerIdsByType(anyLong(), anyString()); + } + + @Test void 平台分页不传租户时查询全部企业并支持承运商类型筛选() { + PartnerMapper mapper=mock(PartnerMapper.class); + when(mapper.selectPartnerPageByType(any(Page.class), isNull(), eq("CARRIER"), + eq("ACTIVE"), isNull())).thenAnswer(invocation -> invocation.getArgument(0)); + + new PartnerRepository(mapper).page(null, + new PartnerPageQuery(1, 20, null, "CARRIER", "ACTIVE")); + + verify(mapper).selectPartnerPageByType(any(Page.class), isNull(), eq("CARRIER"), + eq("ACTIVE"), isNull()); + verify(mapper, never()).selectPartnerIdsByType(anyLong(), anyString()); } }