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