This commit is contained in:
2026-08-05 09:11:26 +08:00
parent d544bbd0c5
commit 3791fee285
45 changed files with 1187 additions and 126 deletions
@@ -19,6 +19,16 @@ public interface IamQualificationClient {
@RequestHeader("X-Internal-Service-Token") String internalToken, @RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody PlatformQualificationRequest request); @RequestBody PlatformQualificationRequest request);
@PostMapping("/required-qualification-packages")
ApiResponse<RequiredPackageResponse> submitRequiredPackage(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody RequiredPackageRequest request);
@PostMapping("/platform-required-qualification-packages")
ApiResponse<RequiredPackageResponse> submitPlatformRequiredPackage(
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody RequiredPackageRequest request);
record QualificationRequest(Long tenantId, String qualificationType, record QualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl, String certificateNo, String documentUrl,
java.time.LocalDate validTo, boolean replace) { } java.time.LocalDate validTo, boolean replace) { }
@@ -29,4 +39,16 @@ public interface IamQualificationClient {
record PlatformQualificationRequest(Long tenantId, String qualificationType, record PlatformQualificationRequest(Long tenantId, String qualificationType,
String certificateNo, String documentUrl, String certificateNo, String documentUrl,
java.time.LocalDate validTo, boolean replace) { } 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) { }
} }
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage; import com.baiyue.file.service.LocalQualificationStorage;
import com.baiyue.file.service.QualificationPackageService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -32,6 +33,7 @@ public class PlatformQualificationFileController {
private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class); private static final Logger LOGGER = LoggerFactory.getLogger(PlatformQualificationFileController.class);
@Resource private LocalQualificationStorage storage; @Resource private LocalQualificationStorage storage;
@Resource private IamQualificationClient iamClient; @Resource private IamQualificationClient iamClient;
@Resource private QualificationPackageService packageService;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
@PostMapping(value = "/{tenantId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(value = "/{tenantId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -45,7 +47,7 @@ public class PlatformQualificationFileController {
@RequestParam(required = false) LocalDate validTo, @RequestParam(required = false) LocalDate validTo,
@RequestParam(defaultValue = "false") boolean replace) { @RequestParam(defaultValue = "false") boolean replace) {
requirePlatform(user); requirePlatform(user);
qualificationType = QualificationTypes.requireSupported(qualificationType); qualificationType = QualificationTypes.requireOptional(qualificationType);
var stored = storage.store(tenantId, file); var stored = storage.store(tenantId, file);
try { try {
var response = iamClient.submitPlatform(internalToken, 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<QualificationPackageService.PackageUploadResult> 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}") @GetMapping("/{tenantId}/{fileName}")
@PreAuthorize("hasAuthority('platform:customer:view')") @PreAuthorize("hasAuthority('platform:customer:view')")
public ResponseEntity<org.springframework.core.io.Resource> download( public ResponseEntity<org.springframework.core.io.Resource> download(
@@ -6,6 +6,7 @@ import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.security.AuthenticatedUser; import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.file.client.IamQualificationClient; import com.baiyue.file.client.IamQualificationClient;
import com.baiyue.file.service.LocalQualificationStorage; import com.baiyue.file.service.LocalQualificationStorage;
import com.baiyue.file.service.QualificationPackageService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@@ -26,6 +27,7 @@ public class QualificationFileController {
private static final Logger LOGGER = LoggerFactory.getLogger(QualificationFileController.class); private static final Logger LOGGER = LoggerFactory.getLogger(QualificationFileController.class);
@Resource private LocalQualificationStorage storage; @Resource private LocalQualificationStorage storage;
@Resource private IamQualificationClient iamClient; @Resource private IamQualificationClient iamClient;
@Resource private QualificationPackageService packageService;
@Value("${baiyue.security.internal-service-token}") private String internalToken; @Value("${baiyue.security.internal-service-token}") private String internalToken;
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE) @PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
@@ -38,7 +40,7 @@ public class QualificationFileController {
@RequestParam(required = false) LocalDate validTo, @RequestParam(required = false) LocalDate validTo,
@RequestParam(defaultValue = "false") boolean replace) { @RequestParam(defaultValue = "false") boolean replace) {
long tenantId = merchantTenant(user); long tenantId = merchantTenant(user);
qualificationType = QualificationTypes.requireSupported(qualificationType); qualificationType = QualificationTypes.requireOptional(qualificationType);
var stored = storage.store(tenantId, file); var stored = storage.store(tenantId, file);
try { try {
var response = iamClient.submit(internalToken, 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<QualificationPackageService.PackageUploadResult> 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}") @GetMapping("/{fileName}")
public ResponseEntity<org.springframework.core.io.Resource> download( public ResponseEntity<org.springframework.core.io.Resource> download(
@AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) { @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) {
@@ -1,6 +1,7 @@
package com.baiyue.file.controller; package com.baiyue.file.controller;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
import com.baiyue.common.core.api.ResultCode;
import java.util.Set; import java.util.Set;
@@ -19,4 +20,13 @@ final class QualificationTypes {
} }
return qualificationType; 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;
}
} }
@@ -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) { }
}
@@ -61,6 +61,13 @@ class QualificationFileContractTest {
assertTrue(hasMultipartPost); assertTrue(hasMultipartPost);
} }
@Test
void merchantAndPlatformExposeDedicatedRequiredPackageRoutes() {
assertTrue(hasMultipartRoute(QualificationFileController.class, "/required-package"));
assertTrue(hasMultipartRoute(PlatformQualificationFileController.class,
"/{tenantId}/required-package"));
}
@Test @Test
void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception { void platformQualificationPreviewUsesTheCustomerViewPermission() throws Exception {
var method = PlatformQualificationFileController.class.getDeclaredMethod( var method = PlatformQualificationFileController.class.getDeclaredMethod(
@@ -82,7 +89,7 @@ class QualificationFileContractTest {
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success( when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); 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); LocalDate.of(2030, 12, 31), true);
var ordered = inOrder(storage, iamClient); var ordered = inOrder(storage, iamClient);
@@ -108,7 +115,7 @@ class QualificationFileContractTest {
when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success( when(iamClient.submitPlatform(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.PlatformQualificationResponse(31L, null, "PROFILE_REQUIRED", currentUrl))); 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); verify(storage, never()).deleteByDocumentUrl(10001L, currentUrl);
} }
@@ -124,7 +131,7 @@ class QualificationFileContractTest {
when(iamClient.submitPlatform(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout")); when(iamClient.submitPlatform(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class, 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()); assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored); verify(storage, never()).delete(stored);
@@ -142,7 +149,7 @@ class QualificationFileContractTest {
ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null)); ApiResponse.failure(ResultCode.CONFLICT, "duplicate", null, null));
var exception = assertThrows(BizException.class, 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()); assertEquals(ResultCode.CONFLICT, exception.getResultCode());
verify(storage).delete(stored); verify(storage).delete(stored);
@@ -159,7 +166,7 @@ class QualificationFileContractTest {
when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout")); when(iamClient.submit(eq("internal-token"), any())).thenThrow(new RuntimeException("timeout"));
var exception = assertThrows(BizException.class, 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()); assertEquals(ResultCode.SERVICE_UNAVAILABLE, exception.getResultCode());
verify(storage, never()).delete(stored); verify(storage, never()).delete(stored);
@@ -177,12 +184,12 @@ class QualificationFileContractTest {
when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); 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); var requestCaptor = org.mockito.ArgumentCaptor.forClass(IamQualificationClient.QualificationRequest.class);
verify(iamClient).submit(eq("internal-token"), requestCaptor.capture()); verify(iamClient).submit(eq("internal-token"), requestCaptor.capture());
verify(storage).deleteByDocumentUrl(10001L, oldUrl); verify(storage).deleteByDocumentUrl(10001L, oldUrl);
assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType()); assertEquals("FREIGHT_FORWARDER_CERTIFICATE", requestCaptor.getValue().qualificationType());
assertTrue(requestCaptor.getValue().replace()); assertTrue(requestCaptor.getValue().replace());
} }
@@ -203,9 +210,8 @@ class QualificationFileContractTest {
} }
@Test @Test
void everyRequiredQualificationTypeIsAcceptedAndForwarded() { void everyOptionalQualificationTypeIsAcceptedAndForwarded() {
for (String qualificationType : Set.of( for (String qualificationType : Set.of(
"BUSINESS_LICENSE", "LEGAL_PERSON_ID",
"FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) { "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) {
var storage = mock(LocalQualificationStorage.class); var storage = mock(LocalQualificationStorage.class);
var iamClient = mock(IamQualificationClient.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( private static PlatformQualificationFileController platformController(
LocalQualificationStorage storage, IamQualificationClient iamClient) { LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new PlatformQualificationFileController(); var controller = new PlatformQualificationFileController();
@@ -233,6 +255,14 @@ class QualificationFileContractTest {
return controller; 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( private static QualificationFileController merchantController(
LocalQualificationStorage storage, IamQualificationClient iamClient) { LocalQualificationStorage storage, IamQualificationClient iamClient) {
var controller = new QualificationFileController(); var controller = new QualificationFileController();
@@ -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) { }
}
@@ -2,6 +2,8 @@ package com.baiyue.firstleg.controller;
import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.audit.BusinessAudit;
import com.baiyue.common.core.api.ApiResponse; 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.common.security.AuthenticatedUser;
import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CarrierRateVersionRequest;
import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest; import com.baiyue.firstleg.dto.FbaPricingDtos.CustomerRuleVersionRequest;
@@ -36,10 +38,12 @@ public class PlatformFbaMasterDataController {
@GetMapping("/customer-rules") @GetMapping("/customer-rules")
@PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')") @PreAuthorize("hasAuthority('firstleg:fba-pricing:rule:view')")
@Operation(summary = "查询客户计费规则版本", description = "默认查询全部企业;tenantId 可用于筛选目标企业")
public ApiResponse<List<CustomerChargeRuleEntity>> rules( public ApiResponse<List<CustomerChargeRuleEntity>> rules(
@AuthenticationPrincipal AuthenticatedUser user, @RequestParam long tenantId, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) String ruleCode) { @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}") @GetMapping("/customer-rules/{id}")
@@ -105,4 +109,11 @@ public class PlatformFbaMasterDataController {
service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId()); service.retireRate(FirstlegScopeGuard.platform(user, tenantId), id, user.userId());
return ApiResponse.success(null); 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);
}
} }
@@ -10,10 +10,13 @@ import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.dto.InboundOrderSummary; import com.baiyue.firstleg.dto.InboundOrderSummary;
import com.baiyue.firstleg.service.InboundOrderQueryService; import com.baiyue.firstleg.service.InboundOrderQueryService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
import jakarta.validation.constraints.Min;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal; 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.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
@@ -22,6 +25,7 @@ import org.springframework.web.bind.annotation.RestController;
/** 平台端入库单列表和详情只读接口。 */ /** 平台端入库单列表和详情只读接口。 */
@Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情") @Tag(name = "平台入库单", description = "二期入库单列表、页签、筛选和只读聚合详情")
@Validated
@RestController @RestController
@RequestMapping("/api/platform/inbounds") @RequestMapping("/api/platform/inbounds")
public class PlatformInboundOrderController { public class PlatformInboundOrderController {
@@ -31,14 +35,16 @@ public class PlatformInboundOrderController {
this.service = service; this.service = service;
} }
@Operation(summary = "分页查询入库单", description = "按目标租户和二期状态页签分页查询入库单快照。") @Operation(summary = "分页查询入库单",
description = "默认分页查询全部企业入库单;可按目标租户和二期状态页签筛选入库单快照。")
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:firstleg:inbound:list')") @PreAuthorize("hasAuthority('platform:firstleg:inbound:list')")
public ApiResponse<PageResult<InboundOrderSummary>> page( public ApiResponse<PageResult<InboundOrderSummary>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam long tenantId, @Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@Valid InboundOrderQuery query) { @Valid InboundOrderQuery query) {
requirePlatformTenant(user, tenantId); requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query)); return ApiResponse.success(service.page(tenantId, query));
} }
@@ -57,11 +63,15 @@ public class PlatformInboundOrderController {
} }
private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) { private static void requirePlatformTenant(AuthenticatedUser user, long tenantId) {
if (user == null || !"PLATFORM".equals(user.appCode())) { requirePlatform(user);
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
if (tenantId <= 0) { if (tenantId <= 0) {
throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户"); throw new BizException(ResultCode.BAD_REQUEST, "必须指定有效目标租户");
} }
} }
private static void requirePlatform(AuthenticatedUser user) {
if (user == null || !"PLATFORM".equals(user.appCode())) {
throw new BizException(ResultCode.FORBIDDEN, "仅允许平台端访问");
}
}
} }
@@ -8,7 +8,7 @@ import java.time.LocalDateTime;
/** 入库列表行,所有名称和运输信息均为业务快照。 */ /** 入库列表行,所有名称和运输信息均为业务快照。 */
public record InboundOrderSummary( 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 processStatus, String orderStatusSnapshot, String measureReviewStatus,
String feeStatus, String feeReviewStatus, String notificationStatus, String feeStatus, String feeReviewStatus, String notificationStatus,
Integer version, Integer version,
@@ -33,7 +33,7 @@ public record InboundOrderSummary(
public static InboundOrderSummary from( public static InboundOrderSummary from(
InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) { InboundOrderEntity order, InboundTransportSnapshotEntity transport, String locationCodes) {
return new InboundOrderSummary( 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.getProcessStatus(), order.getOrderStatusSnapshot(), order.getMeasureReviewStatus(),
order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(), order.getFeeStatus(), order.getFeeReviewStatus(), order.getNotificationStatus(),
order.getVersion(), order.getVersion(),
@@ -21,7 +21,7 @@ import org.springframework.stereotype.Repository;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
/** FBA 计费仓储,所有业务查询都强制带租户条件。 */ /** FBA 计费仓储;平台客户规则列表可跨企业读取,其他业务操作强制带租户条件。 */
@Repository @Repository
public class FbaPricingRepository { public class FbaPricingRepository {
private final CustomerChargeRuleMapper rules; private final CustomerChargeRuleMapper rules;
@@ -61,9 +61,9 @@ public class FbaPricingRepository {
.eq(CarrierFbaRateEntity::getId, id)); .eq(CarrierFbaRateEntity::getId, id));
} }
public List<CustomerChargeRuleEntity> listRules(long tenantId, String ruleCode) { public List<CustomerChargeRuleEntity> listRules(Long tenantId, String ruleCode) {
return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>() return rules.selectList(new LambdaQueryWrapper<CustomerChargeRuleEntity>()
.eq(CustomerChargeRuleEntity::getTenantId, tenantId) .eq(tenantId != null, CustomerChargeRuleEntity::getTenantId, tenantId)
.eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode) .eq(ruleCode != null && !ruleCode.isBlank(), CustomerChargeRuleEntity::getRuleCode, ruleCode)
.orderByAsc(CustomerChargeRuleEntity::getRuleCode) .orderByAsc(CustomerChargeRuleEntity::getRuleCode)
.orderByDesc(CustomerChargeRuleEntity::getRuleVersion)); .orderByDesc(CustomerChargeRuleEntity::getRuleVersion));
@@ -26,7 +26,7 @@ import java.util.Map;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** 入库单只读仓储,所有查询均以租户作为首要隔离条件。 */ /** 入库单只读仓储;平台分页可跨企业查询,详情继续强制租户隔离。 */
@Repository @Repository
public class InboundOrderRepository { public class InboundOrderRepository {
private final InboundOrderMapper orders; private final InboundOrderMapper orders;
@@ -51,9 +51,9 @@ public class InboundOrderRepository {
this.histories = histories; this.histories = histories;
} }
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) { public PageResult<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>() LambdaQueryWrapper<InboundOrderEntity> wrapper = new LambdaQueryWrapper<InboundOrderEntity>()
.eq(InboundOrderEntity::getTenantId, tenantId) .eq(tenantId != null, InboundOrderEntity::getTenantId, tenantId)
.likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode, .likeRight(StringUtils.hasText(query.inboundCode()), InboundOrderEntity::getInboundCode,
query.inboundCode()) query.inboundCode())
.likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo()) .likeRight(StringUtils.hasText(query.orderNo()), InboundOrderEntity::getOrderNo, query.orderNo())
@@ -85,9 +85,10 @@ public class InboundOrderRepository {
.orderByDesc(InboundOrderEntity::getCreatedAt) .orderByDesc(InboundOrderEntity::getCreatedAt)
.orderByDesc(InboundOrderEntity::getId); .orderByDesc(InboundOrderEntity::getId);
Page<InboundOrderEntity> result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper); Page<InboundOrderEntity> result = orders.selectPage(new Page<>(query.page(), query.size()), wrapper);
List<Long> ids = result.getRecords().stream().map(InboundOrderEntity::getId).toList(); Map<Long, Long> tenantByInbound = result.getRecords().stream()
Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, ids); .collect(Collectors.toMap(InboundOrderEntity::getId, InboundOrderEntity::getTenantId));
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, ids); Map<Long, InboundTransportSnapshotEntity> transportByInbound = transportMap(tenantId, tenantByInbound);
Map<Long, String> locationCodesByInbound = locationCodesMap(tenantId, tenantByInbound);
List<InboundOrderSummary> records = result.getRecords().stream() List<InboundOrderSummary> records = result.getRecords().stream()
.map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()), .map(order -> InboundOrderSummary.from(order, transportByInbound.get(order.getId()),
locationCodesByInbound.get(order.getId()))) locationCodesByInbound.get(order.getId())))
@@ -156,27 +157,31 @@ public class InboundOrderRepository {
palletSummaries, fileSummaries, noteSummaries, historySummaries); palletSummaries, fileSummaries, noteSummaries, historySummaries);
} }
private Map<Long, InboundTransportSnapshotEntity> transportMap(long tenantId, List<Long> inboundIds) { private Map<Long, InboundTransportSnapshotEntity> transportMap(
if (inboundIds.isEmpty()) { Long tenantId, Map<Long, Long> tenantByInbound) {
if (tenantByInbound.isEmpty()) {
return Map.of(); return Map.of();
} }
return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>() return transports.selectList(new LambdaQueryWrapper<InboundTransportSnapshotEntity>()
.eq(InboundTransportSnapshotEntity::getTenantId, tenantId) .eq(tenantId != null, InboundTransportSnapshotEntity::getTenantId, tenantId)
.in(InboundTransportSnapshotEntity::getInboundId, inboundIds)) .in(InboundTransportSnapshotEntity::getInboundId, tenantByInbound.keySet()))
.stream().collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId, .stream()
.filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.collect(Collectors.toMap(InboundTransportSnapshotEntity::getInboundId,
Function.identity(), (left, right) -> left)); Function.identity(), (left, right) -> left));
} }
private Map<Long, String> locationCodesMap(long tenantId, List<Long> inboundIds) { private Map<Long, String> locationCodesMap(Long tenantId, Map<Long, Long> tenantByInbound) {
if (inboundIds.isEmpty()) { if (tenantByInbound.isEmpty()) {
return Map.of(); return Map.of();
} }
return pallets.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>() return pallets.selectList(new LambdaQueryWrapper<InboundPalletAllocationEntity>()
.eq(InboundPalletAllocationEntity::getTenantId, tenantId) .eq(tenantId != null, InboundPalletAllocationEntity::getTenantId, tenantId)
.in(InboundPalletAllocationEntity::getInboundId, inboundIds) .in(InboundPalletAllocationEntity::getInboundId, tenantByInbound.keySet())
.orderByAsc(InboundPalletAllocationEntity::getInboundId) .orderByAsc(InboundPalletAllocationEntity::getInboundId)
.orderByAsc(InboundPalletAllocationEntity::getAllocationNo)) .orderByAsc(InboundPalletAllocationEntity::getAllocationNo))
.stream() .stream()
.filter(item -> owns(tenantByInbound, item.getInboundId(), item.getTenantId()))
.filter(item -> StringUtils.hasText(item.getLocationCode())) .filter(item -> StringUtils.hasText(item.getLocationCode()))
.collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId, .collect(Collectors.groupingBy(InboundPalletAllocationEntity::getInboundId,
java.util.LinkedHashMap::new, java.util.LinkedHashMap::new,
@@ -184,6 +189,10 @@ public class InboundOrderRepository {
Collectors.joining("、")))); Collectors.joining("、"))));
} }
private static boolean owns(Map<Long, Long> tenantByInbound, Long inboundId, Long tenantId) {
return inboundId != null && tenantId != null && tenantId.equals(tenantByInbound.get(inboundId));
}
private static <T> LambdaQueryWrapper<T> owned( private static <T> LambdaQueryWrapper<T> owned(
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> tenantColumn, com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> tenantColumn,
com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> inboundColumn, com.baomidou.mybatisplus.core.toolkit.support.SFunction<T, ?> inboundColumn,
@@ -98,7 +98,7 @@ public class FbaMasterDataService {
return value; return value;
} }
public List<CustomerChargeRuleEntity> listRules(long tenantId, String code) { public List<CustomerChargeRuleEntity> listRules(Long tenantId, String code) {
return repository.listRules(tenantId, code); return repository.listRules(tenantId, code);
} }
@@ -21,7 +21,7 @@ public class InboundOrderQueryService {
this.repository = repository; this.repository = repository;
} }
public PageResult<InboundOrderSummary> page(long tenantId, InboundOrderQuery query) { public PageResult<InboundOrderSummary> page(Long tenantId, InboundOrderQuery query) {
validateQuery(query); validateQuery(query);
return repository.page(tenantId, query); return repository.page(tenantId, query);
} }
@@ -10,7 +10,9 @@ paths:
get: get:
summary: 查询客户计费规则版本 summary: 查询客户计费规则版本
description: 需要 firstleg:fba-pricing:rule:view 权限 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: 无权限}} responses: {'200': {description: 成功或业务失败}, '403': {description: 无权限}}
/api/platform/v1/fba-pricing/customer-rules/versions: /api/platform/v1/fba-pricing/customer-rules/versions:
post: post:
@@ -87,9 +89,10 @@ components:
securitySchemes: securitySchemes:
bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT}
parameters: parameters:
TenantId: {name: tenantId, in: query, required: true, schema: {type: integer, format: int64, minimum: 1}} OptionalTenantId: {name: tenantId, in: query, required: false, description: 不传时查询全部企业, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
Id: {name: id, in: path, required: true, schema: {type: integer, format: int64}} TenantId: {name: tenantId, in: query, required: true, schema: {type: string, pattern: '^[1-9][0-9]*$'}}
InboundId: {name: inboundId, in: path, required: true, schema: {type: integer, format: int64}} 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}} IdempotencyKey: {name: Idempotency-Key, in: header, required: true, schema: {type: string, maxLength: 128}}
schemas: schemas:
AdjustFeeRequest: AdjustFeeRequest:
@@ -107,7 +110,7 @@ components:
required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom] required: [ruleCode, customerId, customerName, businessType, chargeMode, taxMode, conversionFactor, roundingScale, roundingMode, currency, effectiveFrom]
properties: properties:
ruleCode: {type: string, maxLength: 50} ruleCode: {type: string, maxLength: 50}
customerId: {type: integer, format: int64} customerId: {type: string, pattern: '^[1-9][0-9]*$'}
customerName: {type: string, maxLength: 128} customerName: {type: string, maxLength: 128}
businessType: {type: string, maxLength: 32} businessType: {type: string, maxLength: 32}
chargeMode: {type: string, enum: [KG, CBM]} 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] required: [rateCode, originWarehouseId, originWarehouseName, businessType, channelCode, carrierId, carrierName, destinationCode, chargeUnit, unitPrice, currency, additionalFee, effectiveFrom]
properties: properties:
rateCode: {type: string, maxLength: 50} rateCode: {type: string, maxLength: 50}
originWarehouseId: {type: integer, format: int64} originWarehouseId: {type: string, pattern: '^[1-9][0-9]*$'}
originWarehouseName: {type: string} originWarehouseName: {type: string}
businessType: {type: string} businessType: {type: string}
channelCode: {type: string} channelCode: {type: string}
carrierId: {type: integer, format: int64} carrierId: {type: string, pattern: '^[1-9][0-9]*$'}
carrierName: {type: string} carrierName: {type: string}
destinationCode: {type: string} destinationCode: {type: string}
chargeUnit: {type: string, enum: [KG, CBM]} chargeUnit: {type: string, enum: [KG, CBM]}
@@ -127,12 +127,16 @@ paths:
/api/platform/inbounds: /api/platform/inbounds:
get: get:
summary: 分页查询入库单 summary: 分页查询入库单
description: 需要 platform:firstleg:inbound:list 权限;仅使用 V2 四个页签 description: 需要 platform:firstleg:inbound:list 权限;默认查询全部企业,可选 tenantId 筛选;仅使用 V2 四个页签
parameters: 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]}} - {name: tab, in: query, schema: {type: string, enum: [WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]}}
responses: responses:
'200': {description: 成功或业务失败,按响应体 code 判断} '200':
description: 成功或业务失败,按响应体 code 判断;每条记录携带后续租户隔离操作所需的 tenantId
content:
application/json:
schema: {$ref: '#/components/schemas/InboundOrderPageEnvelope'}
'403': {description: 无查询权限} '403': {description: 无查询权限}
/api/platform/inbounds/{inboundId}: /api/platform/inbounds/{inboundId}:
get: get:
@@ -251,6 +255,34 @@ components:
in: query in: query
schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20} schema: {type: integer, format: int64, minimum: 1, maximum: 200, default: 20}
schemas: 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: InboundMeasurementCallbackRequest:
type: object type: object
required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm] required: [eventNo, externalDetailNo, warehouseCode, inboundCode, inboundDetailId, deviceCode, measuredAt, boxSpecification, packageCount, lengthCm, widthCm, heightCm]
@@ -3,13 +3,30 @@ package com.baiyue.firstleg.controller;
import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.web.bind.annotation.RequestParam;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
import java.lang.reflect.Method;
import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("FBA计费OpenAPI契约") @DisplayName("FBA计费OpenAPI契约")
class FbaPricingContractTest { 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 @Test
@DisplayName("契约声明自动发布、已发布费用调整和幂等头") @DisplayName("契约声明自动发布、已发布费用调整和幂等头")
void documentsPricingLifecycle() throws Exception { void documentsPricingLifecycle() throws Exception {
@@ -28,5 +45,26 @@ class FbaPricingContractTest {
.doesNotContain("应收") .doesNotContain("应收")
.doesNotContain("应付") .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();
} }
} }
@@ -5,6 +5,7 @@ import com.baiyue.firstleg.dto.InboundOrderQuery;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.ClassPathResource;
import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.RequestParam;
import java.lang.reflect.Method; import java.lang.reflect.Method;
import java.nio.charset.StandardCharsets; import java.nio.charset.StandardCharsets;
@@ -15,21 +16,40 @@ class InboundOrderControllerContractTest {
@Test @Test
void shouldDeclareReadOnlyPermissions() throws Exception { void shouldDeclareReadOnlyPermissions() throws Exception {
assertAuthority("page", "hasAuthority('platform:firstleg:inbound:list')", 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')", assertAuthority("detail", "hasAuthority('platform:firstleg:inbound:view')",
AuthenticatedUser.class, long.class, long.class); 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 @Test
void shouldDocumentOnlyV2TabsAndStringLongIds() throws Exception { void shouldDocumentOnlyV2TabsAndStringLongIds() throws Exception {
String openapi = new ClassPathResource("openapi/firstleg-v1.yaml") String openapi = new ClassPathResource("openapi/firstleg-v1.yaml")
.getContentAsString(StandardCharsets.UTF_8); .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) assertThat(openapi)
.contains("/api/platform/inbounds:") .contains("/api/platform/inbounds:")
.contains("/api/platform/inbounds/{inboundId}:") .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("[WAIT_MEASURE, WAIT_REVIEW, WAIT_FEE_SEND, INBOUNDED]")
.contains("platform:firstleg:inbound:list") .contains("platform:firstleg:inbound:list")
.doesNotContain("WAIT_CUSTOMER_CONFIRM"); .doesNotContain("WAIT_CUSTOMER_CONFIRM");
assertThat(inboundList).contains("#/components/schemas/InboundOrderPageEnvelope");
assertThat(taskList).doesNotContain("InboundOrderPageEnvelope");
} }
private static void assertAuthority(String methodName, String value, Class<?>... types) private static void assertAuthority(String methodName, String value, Class<?>... types)
@@ -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<LambdaQueryWrapper<CustomerChargeRuleEntity>> 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<Object> 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<LambdaQueryWrapper<CustomerChargeRuleEntity>> 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));
}
}
@@ -8,6 +8,7 @@ import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.baiyue.firstleg.dto.InboundOrderQuery; import com.baiyue.firstleg.dto.InboundOrderQuery;
import com.baiyue.firstleg.entity.InboundOrderEntity; import com.baiyue.firstleg.entity.InboundOrderEntity;
import com.baiyue.firstleg.entity.InboundPalletAllocationEntity; import com.baiyue.firstleg.entity.InboundPalletAllocationEntity;
import com.baiyue.firstleg.entity.InboundTransportSnapshotEntity;
import com.baiyue.firstleg.mapper.InboundFileMapper; import com.baiyue.firstleg.mapper.InboundFileMapper;
import com.baiyue.firstleg.mapper.InboundInternalNoteMapper; import com.baiyue.firstleg.mapper.InboundInternalNoteMapper;
import com.baiyue.firstleg.mapper.InboundOrderMapper; import com.baiyue.firstleg.mapper.InboundOrderMapper;
@@ -73,8 +74,10 @@ class InboundOrderRepositoryTest {
Page<InboundOrderEntity> page = new Page<>(1, 20); Page<InboundOrderEntity> page = new Page<>(1, 20);
InboundOrderEntity first = new InboundOrderEntity(); InboundOrderEntity first = new InboundOrderEntity();
first.setId(1L); first.setId(1L);
first.setTenantId(10001L);
InboundOrderEntity second = new InboundOrderEntity(); InboundOrderEntity second = new InboundOrderEntity();
second.setId(2L); second.setId(2L);
second.setTenantId(10001L);
page.setRecords(java.util.List.of(first, second)); page.setRecords(java.util.List.of(first, second));
when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page); when(orders.selectPage(any(Page.class), any(Wrapper.class))).thenReturn(page);
when(pallets.selectList(any(Wrapper.class))).thenReturn(java.util.List.of( 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"); .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<InboundOrderEntity> 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<Wrapper<InboundOrderEntity>> orderCaptor = ArgumentCaptor.forClass(Wrapper.class);
verify(orders).selectPage(any(Page.class), orderCaptor.capture());
assertThat(orderCaptor.getValue().getCustomSqlSegment()).doesNotContain("tenant_id");
ArgumentCaptor<Wrapper<InboundTransportSnapshotEntity>> 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( private static InboundPalletAllocationEntity allocation(
long inboundId, int allocationNo, String locationCode) { 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(); InboundPalletAllocationEntity entity = new InboundPalletAllocationEntity();
entity.setInboundId(inboundId); entity.setInboundId(inboundId);
entity.setTenantId(tenantId);
entity.setAllocationNo(allocationNo); entity.setAllocationNo(allocationNo);
entity.setLocationCode(locationCode); entity.setLocationCode(locationCode);
return entity; 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;
}
} }
@@ -16,12 +16,27 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doAnswer; import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.mock; import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@DisplayName("FBA计费基础资料版本") @DisplayName("FBA计费基础资料版本")
class FbaMasterDataServiceTest { class FbaMasterDataServiceTest {
private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0); private static final LocalDateTime AUGUST = LocalDateTime.of(2026, 8, 1, 0, 0);
@Test
@DisplayName("平台列表不传企业时委托仓储查询全部企业规则")
void listsRulesAcrossTenantsWithoutTenantFilter() {
FbaPricingRepository repository = mock(FbaPricingRepository.class);
List<CustomerChargeRuleEntity> expected = List.of(activeRule(AUGUST, null));
when(repository.listRules(null, "CR-1")).thenReturn(expected);
List<CustomerChargeRuleEntity> result =
new FbaMasterDataService(repository).listRules(null, "CR-1");
assertThat(result).isSameAs(expected);
verify(repository).listRules(null, "CR-1");
}
@Test @Test
@DisplayName("半开有效期首尾相接不视为重叠") @DisplayName("半开有效期首尾相接不视为重叠")
void acceptsAdjacentHalfOpenPeriods() { void acceptsAdjacentHalfOpenPeriods() {
@@ -21,6 +21,17 @@ import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
class InboundOrderQueryServiceTest { class InboundOrderQueryServiceTest {
@Test
void shouldQueryAllPlatformTenantsWhenTenantFilterIsMissing() {
InboundOrderRepository repository = mock(InboundOrderRepository.class);
InboundOrderQuery query = query(null, null, null, null);
PageResult<InboundOrderSummary> 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 @Test
void shouldKeepV2TabMappingWithoutCustomerConfirmation() { void shouldKeepV2TabMappingWithoutCustomerConfirmation() {
assertThat(InboundListTab.WAIT_MEASURE.processStatuses()) assertThat(InboundListTab.WAIT_MEASURE.processStatuses())
@@ -10,6 +10,8 @@ import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse; 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.MerchantOnboardingService;
import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantService; import com.baiyue.iam.service.TenantService;
@@ -73,6 +75,22 @@ public class InternalTenantController {
return ApiResponse.success(profileResourceService.registerPlatformQualification(request)); return ApiResponse.success(profileResourceService.registerPlatformQualification(request));
} }
@PostMapping("/required-qualification-packages")
public ApiResponse<RequiredQualificationPackageResponse> 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<RequiredQualificationPackageResponse> 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") @GetMapping("/tenants/{tenantId}/order-eligibility")
public ApiResponse<OrderEligibilityResponse> orderEligibility( public ApiResponse<OrderEligibilityResponse> orderEligibility(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -50,5 +50,24 @@ public final class OnboardingDtos {
String replacedDocumentUrl 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) { } public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { }
} }
@@ -26,7 +26,6 @@ public final class TenantDtos {
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName, @Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName, @Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative, @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 = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
@Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone, @Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail, @Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail,
@@ -7,6 +7,8 @@ import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionResponse;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest;
import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationResponse; 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.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantAddress; import com.baiyue.iam.entity.IamTenantAddress;
@@ -65,6 +67,7 @@ public class ProfileResourceService {
throw new BizException(ResultCode.CONFLICT, throw new BizException(ResultCode.CONFLICT,
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
} }
requireOptionalQualificationType(request.qualificationType());
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo()); String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
.filter(item -> request.qualificationType().equals(item.getQualificationType()) .filter(item -> request.qualificationType().equals(item.getQualificationType())
@@ -86,11 +89,6 @@ public class ProfileResourceService {
request.qualificationType(), certificateNo, request.documentUrl()); request.qualificationType(), certificateNo, request.documentUrl());
qualification.setValidTo(request.validTo()); qualification.setValidTo(request.validTo());
repository.saveQualification(qualification); 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); 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())) { if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质"); throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
} }
requireOptionalQualificationType(request.qualificationType());
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo()); String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream() List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
.filter(item -> request.qualificationType().equals(item.getQualificationType()) .filter(item -> request.qualificationType().equals(item.getQualificationType())
@@ -126,15 +125,93 @@ public class ProfileResourceService {
certificateNo, request.documentUrl()); certificateNo, request.documentUrl());
qualification.setValidTo(request.validTo()); qualification.setValidTo(request.validTo());
repository.saveQualification(qualification); repository.saveQualification(qualification);
if ("BUSINESS_LICENSE".equals(request.qualificationType())
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
tenant.setUnifiedSocialCreditCode(certificateNo);
tenantRepository.saveTenant(tenant);
}
return new PlatformQualificationRegistrationResponse( return new PlatformQualificationRegistrationResponse(
qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl); 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<IamTenantQualification> locked = repository.findQualificationsForUpdate(tenant.getId());
List<IamTenantQualification> businessLicenses = currentQualifications(locked, "BUSINESS_LICENSE");
List<IamTenantQualification> 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<IamTenantQualification> currentQualifications(
List<IamTenantQualification> qualifications, String type) {
return qualifications.stream()
.filter(item -> type.equals(item.getQualificationType()) && isCurrentQualification(item))
.toList();
}
private void requireReplaceAllowed(List<IamTenantQualification> current, boolean replace, String label) {
if (!current.isEmpty() && !replace) {
throw new BizException(ResultCode.CONFLICT, label + "已上传,请明确选择替换");
}
}
private String supersede(List<IamTenantQualification> 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) { private String resolveCertificateNo(IamTenant tenant, String qualificationType, String submittedCertificateNo) {
if (!"BUSINESS_LICENSE".equals(qualificationType)) { if (!"BUSINESS_LICENSE".equals(qualificationType)) {
return submittedCertificateNo; return submittedCertificateNo;
@@ -24,7 +24,6 @@ import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
import com.baomidou.mybatisplus.core.toolkit.IdWorker; import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
@@ -54,11 +53,6 @@ public class TenantService {
) )
@Transactional @Transactional
public TenantResponse register(RegisterRequest request, long operatorId) { 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(); IamTenant tenant = new IamTenant();
tenant.setId(IdWorker.getId()); tenant.setId(IdWorker.getId());
tenant.setTenantCode("TEN" + tenant.getId()); tenant.setTenantCode("TEN" + tenant.getId());
@@ -67,7 +61,7 @@ public class TenantService {
tenant.setStatus("PROFILE_REQUIRED"); tenant.setStatus("PROFILE_REQUIRED");
tenant.setLegalName(request.legalName()); tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative()); tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setUnifiedSocialCreditCode(creditCode); tenant.setUnifiedSocialCreditCode(null);
tenant.setContactName(request.contactName()); tenant.setContactName(request.contactName());
tenant.setContactPhone(request.contactPhone()); tenant.setContactPhone(request.contactPhone());
tenant.setContactEmail(request.contactEmail()); tenant.setContactEmail(request.contactEmail());
@@ -334,10 +328,6 @@ public class TenantService {
return tenant; return tenant;
} }
private String normalizeCreditCode(String creditCode) {
return StringUtils.hasText(creditCode) ? creditCode.trim().toUpperCase() : null;
}
private TenantResponse response(IamTenant tenant) { private TenantResponse response(IamTenant tenant) {
return new TenantResponse( return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
+72 -1
View File
@@ -608,6 +608,46 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'} '400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'} '403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'} '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: /internal/exports/v1/CUSTOMER_LIST/batches:
post: post:
tags: [Internal Export Provider] tags: [Internal Export Provider]
@@ -650,6 +690,11 @@ components:
scheme: bearer scheme: bearer
bearerFormat: JWT bearerFormat: JWT
parameters: parameters:
InternalServiceToken:
name: X-Internal-Service-Token
in: header
required: true
schema: {type: string}
Id: Id:
name: id name: id
in: path in: path
@@ -838,6 +883,33 @@ components:
validTo: {type: string, format: date, nullable: true} validTo: {type: string, format: date, nullable: true}
status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]} status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]}
reviewComment: {type: string} 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: RegisterRequest:
type: object type: object
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone, servicePackage] required: [tenantName, legalName, legalRepresentative, contactName, contactPhone, servicePackage]
@@ -845,7 +917,6 @@ components:
tenantName: {type: string, example: 深圳虫虫出海科技有限公司} tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
legalName: {type: string, example: 深圳虫虫出海科技有限公司} legalName: {type: string, example: 深圳虫虫出海科技有限公司}
legalRepresentative: {type: string, maxLength: 64, 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: 李四} contactName: {type: string, example: 李四}
contactPhone: {type: string, example: '13800138000'} contactPhone: {type: string, example: '13800138000'}
contactEmail: {type: string, format: email, example: contact@example.com} contactEmail: {type: string, format: email, example: contact@example.com}
@@ -38,4 +38,28 @@ class QualificationUploadContractTest {
assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:")); 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"));
}
}
} }
@@ -1,6 +1,7 @@
package com.baiyue.iam.service; package com.baiyue.iam.service;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest;
import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.TenantRepository; 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.never;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
import static org.mockito.Mockito.times;
import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.exception.BizException;
class MerchantQualificationUploadTest { class MerchantQualificationUploadTest {
@Test @Test
void uploadsOneDraftQualificationWithoutCreatingAReview() { void uploadsOneOptionalDraftQualificationWithoutCreatingAReview() {
TenantRepository tenantRepository = mock(TenantRepository.class); TenantRepository tenantRepository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
ProfileResourceService service = new ProfileResourceService(); ProfileResourceService service = new ProfileResourceService();
@@ -37,8 +39,8 @@ class MerchantQualificationUploadTest {
when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
var response = service.submitQualification(new QualificationSubmissionRequest( var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "LEGAL_PERSON_ID", null, 10001L, "FREIGHT_FORWARDER_CERTIFICATE", null,
"/api/merchant/v1/files/qualifications/legal-person.png", LocalDate.of(2030, 1, 1), false)); "/api/merchant/v1/files/qualifications/freight-forwarder.png", LocalDate.of(2030, 1, 1), false));
assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertNull(response.reviewId()); assertNull(response.reviewId());
@@ -47,7 +49,26 @@ class MerchantQualificationUploadTest {
} }
@Test @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); TenantRepository tenantRepository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
ProfileResourceService service = new ProfileResourceService(); ProfileResourceService service = new ProfileResourceService();
@@ -60,21 +81,19 @@ class MerchantQualificationUploadTest {
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest( var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest(
10001L, "BUSINESS_LICENSE", null, 10001L, "91310101abcde12345",
"/api/merchant/v1/files/qualifications/license.png", null, false))); "/api/merchant/v1/files/qualifications/license.png", null, false,
"/api/merchant/v1/files/qualifications/legal-person.png", null, false));
var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "BUSINESS_LICENSE", "91310101abcde12345",
"/api/merchant/v1/files/qualifications/license.png", null, false));
assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode()); assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode());
verify(profileRepository, times(2)).insertQualification(any());
verify(tenantRepository).saveTenant(tenant); verify(tenantRepository).saveTenant(tenant);
} }
@Test @Test
void businessLicenseCannotOverwriteAnEstablishedCreditCode() { void requiredPackageCannotOverwriteAnEstablishedCreditCode() {
TenantRepository tenantRepository = mock(TenantRepository.class); TenantRepository tenantRepository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
ProfileResourceService service = new ProfileResourceService(); ProfileResourceService service = new ProfileResourceService();
@@ -87,11 +106,11 @@ class MerchantQualificationUploadTest {
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001"); tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest( assertThrows(BizException.class, () -> service.submitRequiredQualificationPackage(
10001L, "BUSINESS_LICENSE", "91310101ABCDE12345", new RequiredQualificationPackageRequest(10001L, "91310101ABCDE12345",
"/api/merchant/v1/files/qualifications/license.png", null, true))); "/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(profileRepository, never()).insertQualification(any());
verify(tenantRepository, never()).saveTenant(tenant);
} }
} }
@@ -21,7 +21,7 @@ import static org.mockito.Mockito.when;
class PlatformQualificationRegistrationTest { class PlatformQualificationRegistrationTest {
@Test @Test
void platformUploadRegistersMetadataWithoutCreatingReviewTicket() { void platformUploadRegistersOptionalMetadataWithoutCreatingReviewTicket() {
ProfileResourceRepository repository = mock(ProfileResourceRepository.class); ProfileResourceRepository repository = mock(ProfileResourceRepository.class);
TenantRepository tenantRepository = mock(TenantRepository.class); TenantRepository tenantRepository = mock(TenantRepository.class);
ProfileResourceService service = new ProfileResourceService(); ProfileResourceService service = new ProfileResourceService();
@@ -33,13 +33,13 @@ class PlatformQualificationRegistrationTest {
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of()); when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( 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)); LocalDate.of(2030, 12, 31), false));
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class); ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository).insertQualification(captor.capture()); verify(repository).insertQualification(captor.capture());
verify(repository).saveQualification(captor.getValue()); 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(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo());
assertEquals("PROFILE_REQUIRED", response.tenantStatus()); assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertNull(response.reviewId()); assertNull(response.reviewId());
@@ -56,14 +56,14 @@ class PlatformQualificationRegistrationTest {
IamTenant tenant = new IamTenant(); IamTenant tenant = new IamTenant();
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
IamTenantQualification existing = new IamTenantQualification(); 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.setStatus("PENDING");
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf"); existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing)); when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( 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)); LocalDate.of(2031, 12, 31), true));
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class); ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
@@ -120,7 +120,7 @@ class TenantServiceTest {
ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "repository", repository);
var created = service.register(new RegisterRequest( 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", "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY",
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"), LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
9001L); 9001L);
@@ -134,13 +134,13 @@ class TenantServiceTest {
} }
@Test @Test
void platformRegistrationAllowsCreditCodeToBeCompletedWithQualificationsLater() { void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() {
TenantRepository repository = mock(TenantRepository.class); TenantRepository repository = mock(TenantRepository.class);
TenantService service = new TenantService(); TenantService service = new TenantService();
ReflectionTestUtils.setField(service, "repository", repository); ReflectionTestUtils.setField(service, "repository", repository);
var created = service.register(new RegisterRequest( 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, "alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L); null, null, "CNY", null, null), 9001L);
@@ -13,6 +13,7 @@ import com.baiyue.warehouse.entity.ExpressCompanyEntity;
import com.baiyue.warehouse.service.ExpressCompanyQueryService; import com.baiyue.warehouse.service.ExpressCompanyQueryService;
import com.baiyue.warehouse.service.ExpressCompanyImportService; import com.baiyue.warehouse.service.ExpressCompanyImportService;
import io.swagger.v3.oas.annotations.Operation; 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.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -42,7 +43,7 @@ import java.nio.charset.StandardCharsets;
/** 平台快递公司管理接口。 */ /** 平台快递公司管理接口。 */
@Validated @Validated
@Tag(name = "平台快递公司", description = "按目标租户分页查询快递公司主数据") @Tag(name = "平台快递公司", description = "跨企业分页查询和按目标租户维护快递公司主数据")
@RestController @RestController
@RequestMapping("/api/platform/v1/express-companies") @RequestMapping("/api/platform/v1/express-companies")
public class PlatformExpressCompanyController { public class PlatformExpressCompanyController {
@@ -56,7 +57,7 @@ public class PlatformExpressCompanyController {
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:express:list')") @PreAuthorize("hasAuthority('platform:express:list')")
@Operation(summary = "分页查询快递公司", description = "按目标租户、关键字和状态分页查询快递公司") @Operation(summary = "分页查询快递公司", description = "默认查询全部企业;可按目标租户、关键字和状态筛选")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
@@ -65,7 +66,8 @@ public class PlatformExpressCompanyController {
}) })
public ApiResponse<PageResult<ExpressCompanyEntity>> page( public ApiResponse<PageResult<ExpressCompanyEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId, @Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@Valid @ParameterObject ExpressCompanyPageQuery query) { @Valid @ParameterObject ExpressCompanyPageQuery query) {
requirePlatform(user); requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query)); return ApiResponse.success(service.page(tenantId, query));
@@ -13,6 +13,7 @@ import com.baiyue.warehouse.entity.PartnerEntity;
import com.baiyue.warehouse.service.PlatformPartnerQueryService; import com.baiyue.warehouse.service.PlatformPartnerQueryService;
import com.baiyue.warehouse.service.PartnerImportService; import com.baiyue.warehouse.service.PartnerImportService;
import io.swagger.v3.oas.annotations.Operation; 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.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid; import jakarta.validation.Valid;
@@ -42,7 +43,7 @@ import java.nio.charset.StandardCharsets;
/** 平台往来单位管理接口。 */ /** 平台往来单位管理接口。 */
@Validated @Validated
@Tag(name = "平台往来单位", description = "按目标租户分页查询往来单位主数据") @Tag(name = "平台往来单位", description = "跨企业分页查询和按目标租户维护往来单位主数据")
@RestController @RestController
@RequestMapping("/api/platform/v1/partners") @RequestMapping("/api/platform/v1/partners")
public class PlatformPartnerController { public class PlatformPartnerController {
@@ -83,7 +84,8 @@ public class PlatformPartnerController {
@GetMapping @GetMapping
@PreAuthorize("hasAuthority('platform:counterparty:list')") @PreAuthorize("hasAuthority('platform:counterparty:list')")
@Operation(summary = "分页查询往来单位", description = "按目标租户、关键字、类型和状态分页查询往来单位") @Operation(summary = "分页查询往来单位",
description = "默认分页查询全部企业往来单位;可按目标租户、关键字、类型和状态筛选")
@ApiResponses({ @ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "查询成功"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "查询条件无效"),
@@ -92,7 +94,8 @@ public class PlatformPartnerController {
}) })
public ApiResponse<PageResult<PartnerEntity>> page( public ApiResponse<PageResult<PartnerEntity>> page(
@AuthenticationPrincipal AuthenticatedUser user, @AuthenticationPrincipal AuthenticatedUser user,
@RequestParam @Min(1) long tenantId, @Parameter(description = "可选目标企业租户ID;不传时查询全部企业")
@RequestParam(required = false) @Min(1) Long tenantId,
@Valid @ParameterObject PartnerPageQuery query) { @Valid @ParameterObject PartnerPageQuery query) {
requirePlatform(user); requirePlatform(user);
return ApiResponse.success(service.page(tenantId, query)); return ApiResponse.success(service.page(tenantId, query));
@@ -1,5 +1,6 @@
package com.baiyue.warehouse.mapper; package com.baiyue.warehouse.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper; 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.PartnerEntity;
import com.baiyue.warehouse.entity.PartnerTypeEntity; import com.baiyue.warehouse.entity.PartnerTypeEntity;
import org.apache.ibatis.annotations.Delete; import org.apache.ibatis.annotations.Delete;
@@ -25,6 +26,56 @@ public interface PartnerMapper extends BaseMapper<PartnerEntity> {
@Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}") @Select("SELECT partner_id FROM wh_partner_type WHERE tenant_id = #{tenantId} AND partner_type = #{type}")
List<Long> selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type); List<Long> selectPartnerIdsByType(@Param("tenantId") long tenantId, @Param("type") String type);
@Select("""
<script>
SELECT p.*
FROM wh_partner p
WHERE p.deleted = 0
<if test="tenantId != null">
AND p.tenant_id = #{tenantId}
</if>
<choose>
<when test="type == 'BOTH'">
AND EXISTS (
SELECT 1 FROM wh_partner_type customer_type
WHERE customer_type.partner_id = p.id
AND customer_type.partner_type = 'CUSTOMER'
AND customer_type.tenant_id = p.tenant_id
)
AND EXISTS (
SELECT 1 FROM wh_partner_type supplier_type
WHERE supplier_type.partner_id = p.id
AND supplier_type.partner_type = 'SUPPLIER'
AND supplier_type.tenant_id = p.tenant_id
)
</when>
<otherwise>
AND EXISTS (
SELECT 1 FROM wh_partner_type pt
WHERE pt.partner_id = p.id
AND pt.partner_type = #{type}
AND pt.tenant_id = p.tenant_id
)
</otherwise>
</choose>
<if test="status != null and status != ''">
AND p.status = #{status}
</if>
<if test="keyword != null and keyword != ''">
AND (p.partner_code LIKE CONCAT('%', #{keyword}, '%')
OR p.partner_name LIKE CONCAT('%', #{keyword}, '%')
OR p.contact_name LIKE CONCAT('%', #{keyword}, '%')
OR p.contact_phone LIKE CONCAT('%', #{keyword}, '%'))
</if>
ORDER BY p.partner_name ASC, p.id ASC
</script>
""")
Page<PartnerEntity> selectPartnerPageByType(Page<PartnerEntity> 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}") @Delete("DELETE FROM wh_partner_type WHERE partner_id = #{partnerId}")
void deleteTypes(@Param("partnerId") long partnerId); void deleteTypes(@Param("partnerId") long partnerId);
@@ -9,7 +9,7 @@ import com.baiyue.warehouse.mapper.ExpressCompanyMapper;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import org.springframework.util.StringUtils; import org.springframework.util.StringUtils;
/** 快递公司主数据仓储。 */ /** 快递公司主数据仓储;平台分页租户条件可选,资源读写仍强制租户边界。 */
@Repository @Repository
public class ExpressCompanyRepository { public class ExpressCompanyRepository {
private final ExpressCompanyMapper mapper; private final ExpressCompanyMapper mapper;
@@ -18,9 +18,9 @@ public class ExpressCompanyRepository {
this.mapper = mapper; this.mapper = mapper;
} }
public PageResult<ExpressCompanyEntity> page(long tenantId, ExpressCompanyPageQuery query) { public PageResult<ExpressCompanyEntity> page(Long tenantId, ExpressCompanyPageQuery query) {
LambdaQueryWrapper<ExpressCompanyEntity> wrapper = new LambdaQueryWrapper<ExpressCompanyEntity>() LambdaQueryWrapper<ExpressCompanyEntity> wrapper = new LambdaQueryWrapper<ExpressCompanyEntity>()
.eq(ExpressCompanyEntity::getTenantId, tenantId) .eq(tenantId != null, ExpressCompanyEntity::getTenantId, tenantId)
.eq(StringUtils.hasText(query.status()), ExpressCompanyEntity::getStatus, query.status()) .eq(StringUtils.hasText(query.status()), ExpressCompanyEntity::getStatus, query.status())
.and(StringUtils.hasText(query.keyword()), condition -> condition .and(StringUtils.hasText(query.keyword()), condition -> condition
.like(ExpressCompanyEntity::getCompanyCode, query.keyword()) .like(ExpressCompanyEntity::getCompanyCode, query.keyword())
@@ -41,14 +41,17 @@ public class PartnerRepository {
return entity; return entity;
} }
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery request) { public PageResult<PartnerEntity> page(Long tenantId, PartnerPageQuery request) {
List<Long> typedIds = typedIds(tenantId, request.type()); if (StringUtils.hasText(request.type())) {
if (StringUtils.hasText(request.type()) && typedIds.isEmpty()) { Page<PartnerEntity> page = mapper.selectPartnerPageByType(
return new PageResult<>(List.of(), request.page(), request.size(), 0, 0); new Page<>(request.page(), request.size()), tenantId, request.type(),
request.status(), request.keyword());
PageResult<PartnerEntity> result = PageResult.from(page);
hydrateTypes(result.records());
return result;
} }
LambdaQueryWrapper<PartnerEntity> wrapper = new LambdaQueryWrapper<PartnerEntity>() LambdaQueryWrapper<PartnerEntity> wrapper = new LambdaQueryWrapper<PartnerEntity>()
.eq(PartnerEntity::getTenantId, tenantId) .eq(tenantId != null, PartnerEntity::getTenantId, tenantId)
.in(StringUtils.hasText(request.type()), PartnerEntity::getId, typedIds)
.eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status()) .eq(StringUtils.hasText(request.status()), PartnerEntity::getStatus, request.status())
.and(StringUtils.hasText(request.keyword()), query -> query .and(StringUtils.hasText(request.keyword()), query -> query
.like(PartnerEntity::getPartnerCode, request.keyword()) .like(PartnerEntity::getPartnerCode, request.keyword())
@@ -21,7 +21,7 @@ public class ExpressCompanyQueryService {
this.repository = repository; this.repository = repository;
} }
public PageResult<ExpressCompanyEntity> page(long tenantId, ExpressCompanyPageQuery query) { public PageResult<ExpressCompanyEntity> page(Long tenantId, ExpressCompanyPageQuery query) {
return repository.page(tenantId, query); return repository.page(tenantId, query);
} }
@@ -25,7 +25,7 @@ public class PlatformPartnerQueryService {
this.repository = repository; this.repository = repository;
} }
public PageResult<PartnerEntity> page(long tenantId, PartnerPageQuery query) { public PageResult<PartnerEntity> page(Long tenantId, PartnerPageQuery query) {
return repository.page(tenantId, query); return repository.page(tenantId, query);
} }
@@ -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();
}
}
@@ -75,7 +75,7 @@ class PlatformMasterDataControllerContractTest {
Method page = controller.getDeclaredMethod("page", Method page = controller.getDeclaredMethod("page",
com.baiyue.common.security.AuthenticatedUser.class, com.baiyue.common.security.AuthenticatedUser.class,
long.class, Long.class,
controller == PlatformPartnerController.class controller == PlatformPartnerController.class
? com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery.class ? com.baiyue.warehouse.dto.MasterDataQueries.PartnerPageQuery.class
: com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery.class); : com.baiyue.warehouse.dto.MasterDataQueries.ExpressCompanyPageQuery.class);
@@ -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("<if test=\"tenantId != null\">")
.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();
}
}
@@ -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("<when test=\"type == 'BOTH'\">")
.contains("customer_type.partner_type = 'CUSTOMER'")
.contains("supplier_type.partner_type = 'SUPPLIER'")
.contains("<otherwise>")
.contains("pt.partner_type = #{type}");
}
}
@@ -25,7 +25,7 @@ class ExpressCompanyRepositoryTest {
} }
@Test @Test
void 平台分页强制租户并支持筛选() { void 平台分页传入租户时按租户并支持筛选() {
ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class); ExpressCompanyMapper mapper = mock(ExpressCompanyMapper.class);
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0));
@@ -40,4 +40,22 @@ class ExpressCompanyRepositoryTest {
assertThat(wrapper.getValue().getParamNameValuePairs().values()) assertThat(wrapper.getValue().getParamNameValuePairs().values())
.contains(10001L, "ACTIVE", "%顺丰%"); .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<LambdaQueryWrapper<ExpressCompanyEntity>> 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);
}
} }
@@ -56,17 +56,27 @@ class PartnerRepositoryTest {
@Test void 平台分页强制租户并支持筛选() { @Test void 平台分页强制租户并支持筛选() {
PartnerMapper mapper=mock(PartnerMapper.class); PartnerMapper mapper=mock(PartnerMapper.class);
when(mapper.selectPartnerIdsByType(10001L, "CUSTOMER")).thenReturn(List.of(9L)); when(mapper.selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"),
when(mapper.selectPage(any(Page.class), any())).thenAnswer(invocation -> invocation.getArgument(0)); eq("ACTIVE"), eq("示例"))).thenAnswer(invocation -> invocation.getArgument(0));
new PartnerRepository(mapper).page(10001L, new PartnerRepository(mapper).page(10001L,
new PartnerPageQuery(1, 20, "示例", "CUSTOMER", "ACTIVE")); new PartnerPageQuery(1, 20, "示例", "CUSTOMER", "ACTIVE"));
@SuppressWarnings("unchecked") verify(mapper).selectPartnerPageByType(any(Page.class), eq(10001L), eq("CUSTOMER"),
ArgumentCaptor<LambdaQueryWrapper<PartnerEntity>> wrapper=ArgumentCaptor.forClass(LambdaQueryWrapper.class); eq("ACTIVE"), eq("示例"));
verify(mapper).selectPage(any(Page.class), wrapper.capture()); verify(mapper, never()).selectPartnerIdsByType(anyLong(), anyString());
wrapper.getValue().getSqlSegment(); }
assertThat(wrapper.getValue().getParamNameValuePairs().values())
.contains(10001L, 9L, "ACTIVE", "%示例%"); @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());
} }
} }