From 9e70f4339a449741e0672e155ebca81a07a2b615 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Wed, 2 Sep 2026 12:07:32 +0800 Subject: [PATCH] fix(file): allow standalone legal-person ID upload --- .../QualificationFileController.java | 2 +- .../file/controller/QualificationTypes.java | 13 ++- .../qualification-required-package-v1.yaml | 94 +++++++++++++++++++ ...cationAndOrderFileOpenApiContractTest.java | 13 +++ .../QualificationFileContractTest.java | 53 ++++++++--- .../MerchantQualificationUploadTest.java | 46 ++++++++- 6 files changed, 203 insertions(+), 18 deletions(-) diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java index b4cf838f0..d47487852 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -40,7 +40,7 @@ public class QualificationFileController { @RequestParam(required = false) LocalDate validTo, @RequestParam(defaultValue = "false") boolean replace) { long tenantId = merchantTenant(user); - qualificationType = QualificationTypes.requireOptional(qualificationType); + qualificationType = QualificationTypes.requireSingleUpload(qualificationType); var stored = storage.store(tenantId, file); try { var response = iamClient.submit(internalToken, diff --git a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java index 2d59ccc1c..ce5363724 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationTypes.java @@ -21,11 +21,22 @@ final class QualificationTypes { return qualificationType; } + static String requireSingleUpload(String qualificationType) { + String type = requireSupported(qualificationType); + if ("BUSINESS_LICENSE".equals(type)) { + throw new BizException(ResultCode.BAD_REQUEST, + "The business license must be uploaded with its unified social credit code" + + " / 营业执照必须与统一社会信用代码一并上传"); + } + return type; + } + 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 / 营业执照、统一社会信用代码和法人身份证必须整包上传"); + "Business license and legal-person ID use the platform qualification package" + + " / 平台端营业执照与法人身份证通过资质包上传"); } return type; } diff --git a/file/src/main/resources/openapi/qualification-required-package-v1.yaml b/file/src/main/resources/openapi/qualification-required-package-v1.yaml index 482a0e339..1316db9eb 100644 --- a/file/src/main/resources/openapi/qualification-required-package-v1.yaml +++ b/file/src/main/resources/openapi/qualification-required-package-v1.yaml @@ -4,6 +4,43 @@ info: version: 1.0.0 description: 营业执照为必填资质;法人身份证为选填资质,缺省时不创建、不替换法人身份证记录。 paths: + /api/merchant/v1/files/qualifications: + post: + summary: 客户单独上传或替换选填资质 + description: >- + 需要 merchant:qualification:upload;客户范围只取登录 Token,不接受客户ID。 + LEGAL_PERSON_ID 可在已有营业执照后单独新增或替换,不要求重新上传营业执照。 + BUSINESS_LICENSE 不允许走此入口,必须与18位统一社会信用代码通过 required-package 一并上传。 + security: [{bearerAuth: []}] + parameters: + - {$ref: '#/components/parameters/QualificationType'} + - {$ref: '#/components/parameters/CertificateNo'} + - {$ref: '#/components/parameters/ValidTo'} + - {$ref: '#/components/parameters/Replace'} + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + required: [file] + properties: + file: + type: string + format: binary + maxLength: 5242880 + description: PDF/JPG/PNG,最大5MB,声明媒体类型必须与文件签名一致 + responses: + '200': + description: 成功或业务失败,失败时按 code 判断并保留 requestId/traceId + content: + application/json: + schema: + oneOf: + - {$ref: '#/components/schemas/QualificationUploadSuccess'} + - {$ref: '#/components/schemas/ErrorResponse'} + '401': {$ref: '#/components/responses/Error'} + '403': {$ref: '#/components/responses/Error'} /api/merchant/v1/files/qualifications/required-package: post: summary: 客户上传必需资质包 @@ -37,6 +74,27 @@ components: securitySchemes: bearerAuth: {type: http, scheme: bearer, bearerFormat: JWT} parameters: + QualificationType: + name: qualificationType + in: query + required: true + schema: {type: string, enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]} + CertificateNo: + name: certificateNo + in: query + required: false + schema: {type: string, maxLength: 128} + ValidTo: + name: validTo + in: query + required: false + schema: {type: string, format: date} + Replace: + name: replace + in: query + required: false + description: 同类有当前资质时必须显式为true;成功后旧文件元数据标记为已替代 + schema: {type: boolean, default: false} TenantId: name: tenantId in: path @@ -88,6 +146,11 @@ components: nullable: true description: 法人身份证,选填;PDF/JPG/PNG,最大5MB responses: + Error: + description: 认证或授权失败,响应包含请求关联元数据 + content: + application/json: + schema: {$ref: '#/components/schemas/ErrorResponse'} PackageResult: description: 资质包上传结果 content: @@ -100,6 +163,37 @@ components: data: {$ref: '#/components/schemas/PackageUploadResult'} schemas: Id: {type: string, pattern: '^[1-9][0-9]*$'} + QualificationUploadSuccess: + type: object + required: [code, message, data, timestamp] + properties: + code: {type: string, enum: ['200']} + message: {type: string} + data: {$ref: '#/components/schemas/QualificationUploadResult'} + requestId: {type: string, nullable: true} + traceId: {type: string, nullable: true} + timestamp: {type: string, format: date-time} + ErrorResponse: + type: object + required: [code, message, requestId, traceId, timestamp] + properties: + code: {type: string, example: A0403} + message: {type: string} + data: {nullable: true} + requestId: {type: string, minLength: 1} + traceId: {type: string, minLength: 1} + timestamp: {type: string, format: date-time} + QualificationUploadResult: + type: object + required: [qualificationId, tenantStatus, documentUrl] + properties: + qualificationId: {$ref: '#/components/schemas/Id'} + reviewId: + allOf: [{$ref: '#/components/schemas/Id'}] + nullable: true + description: 上传不自动送审,当前固定为null + tenantStatus: {type: string, enum: [PROFILE_REQUIRED, REJECTED]} + documentUrl: {type: string, minLength: 1} PackageUploadResult: type: object required: [businessLicenseQualificationId, tenantStatus, businessLicenseDocumentUrl] diff --git a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java index 3d365911d..948b3eef3 100644 --- a/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java +++ b/file/src/test/java/com/baiyue/file/config/QualificationAndOrderFileOpenApiContractTest.java @@ -8,6 +8,19 @@ import java.nio.file.Path; import static org.assertj.core.api.Assertions.assertThat; class QualificationAndOrderFileOpenApiContractTest { + @Test + void legalPersonIdHasADedicatedSingleFileContractWithErrorMetadata() throws Exception { + String yaml = Files.readString(Path.of( + "src/main/resources/openapi/qualification-required-package-v1.yaml")); + + assertThat(yaml) + .contains("/api/merchant/v1/files/qualifications:") + .contains("enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]") + .contains("merchant:qualification:upload", "maxLength: 5242880") + .contains("name: replace", "requestId:", "traceId:") + .doesNotContain("enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]"); + } + @Test void qualificationPackageRequiresOnlyTheBusinessLicenseFile() throws Exception { String yaml = Files.readString(Path.of( diff --git a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java index 297cfd856..1ea6be0ab 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -96,6 +96,16 @@ class QualificationFileContractTest { assertEquals("hasAuthority('platform:customer:list')", method.getAnnotation(PreAuthorize.class).value()); } + @Test + void merchantSingleQualificationUploadRequiresTheUploadPermission() throws Exception { + var method = QualificationFileController.class.getDeclaredMethod( + "upload", AuthenticatedUser.class, org.springframework.web.multipart.MultipartFile.class, + String.class, String.class, LocalDate.class, boolean.class); + + assertEquals("hasAuthority('merchant:qualification:upload')", + method.getAnnotation(PreAuthorize.class).value()); + } + @Test void platformQualificationRejectsCustomerScopedPlatformToken() { var storage = mock(LocalQualificationStorage.class); @@ -219,7 +229,7 @@ class QualificationFileContractTest { } @Test - void merchantUploadForwardsItsQualificationTypeAndCleansTheReplacedFile() { + void merchantLegalPersonIdReplacementUsesTheAuthenticatedTenantAndCleansTheReplacedFile() { var storage = mock(LocalQualificationStorage.class); var iamClient = mock(IamQualificationClient.class); var controller = merchantController(storage, iamClient); @@ -230,15 +240,30 @@ class QualificationFileContractTest { when(iamClient.submit(eq("internal-token"), any())).thenReturn(ApiResponse.success( new IamQualificationClient.QualificationResponse(31L, null, "PROFILE_REQUIRED", oldUrl))); - controller.upload(merchantUser(), file, "FREIGHT_FORWARDER_CERTIFICATE", null, LocalDate.of(2030, 1, 1), true); + controller.upload(merchantUser(), file, "LEGAL_PERSON_ID", 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("FREIGHT_FORWARDER_CERTIFICATE", requestCaptor.getValue().qualificationType()); + assertEquals(10001L, requestCaptor.getValue().tenantId()); + assertEquals("LEGAL_PERSON_ID", requestCaptor.getValue().qualificationType()); assertTrue(requestCaptor.getValue().replace()); } + @Test + void merchantQualificationRejectsAPlatformIdentityBeforeWritingTheFile() { + var storage = mock(LocalQualificationStorage.class); + var iamClient = mock(IamQualificationClient.class); + var controller = merchantController(storage, iamClient); + var file = new MockMultipartFile("file", "legal-person.pdf", "application/pdf", "%PDF-1.4".getBytes()); + + assertThrows(BizException.class, + () -> controller.upload(platformUser(), file, "LEGAL_PERSON_ID", null, null, true)); + + verify(storage, never()).store(any(Long.class), any()); + verify(iamClient, never()).submit(any(), any()); + } + @Test void merchantUploadRejectsMissingOrUnsupportedTypesBeforeWritingTheFile() { var storage = mock(LocalQualificationStorage.class); @@ -258,7 +283,7 @@ class QualificationFileContractTest { @Test void everyOptionalQualificationTypeIsAcceptedAndForwarded() { for (String qualificationType : Set.of( - "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) { + "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE")) { var storage = mock(LocalQualificationStorage.class); var iamClient = mock(IamQualificationClient.class); var controller = merchantController(storage, iamClient); @@ -277,19 +302,17 @@ 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()); + void singleUploadStillRejectsTheBusinessLicenseBeforeWritingTheFile() { + 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)); + assertThrows(BizException.class, + () -> controller.upload(merchantUser(), file, "BUSINESS_LICENSE", null, null, false)); - verify(storage, never()).store(any(Long.class), any()); - verify(iamClient, never()).submit(any(), any()); - } + verify(storage, never()).store(any(Long.class), any()); + verify(iamClient, never()).submit(any(), any()); } private PlatformQualificationFileController platformController( diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java index 24429a27a..80173ee17 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantQualificationUploadTest.java @@ -1,11 +1,14 @@ package com.baiyue.iam.service; +import com.baiyue.common.core.exception.BizException; import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest; import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest; import com.baiyue.iam.entity.IamTenant; +import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.repository.ProfileResourceRepository; import com.baiyue.iam.repository.TenantRepository; import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; @@ -22,7 +25,6 @@ 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; @SpringJUnitConfig(ProfileResourceService.class) class MerchantQualificationUploadTest { @@ -49,6 +51,38 @@ class MerchantQualificationUploadTest { verify(tenantRepository, never()).insertReview(any()); } + @Test + void replacesOnlyTheLegalPersonIdWithoutTouchingTheBusinessLicense() { + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); + tenant.setTenantType("MERCHANT"); + tenant.setStatus("REJECTED"); + when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant); + IamTenantQualification businessLicense = qualification( + "BUSINESS_LICENSE", "/api/merchant/v1/files/qualifications/license.png"); + IamTenantQualification oldLegalPersonId = qualification( + "LEGAL_PERSON_ID", "/api/merchant/v1/files/qualifications/old-legal-person.png"); + when(profileRepository.findQualificationsForUpdate(10001L)) + .thenReturn(List.of(businessLicense, oldLegalPersonId)); + + var response = service.submitQualification(new QualificationSubmissionRequest( + 10001L, "LEGAL_PERSON_ID", null, + "/api/merchant/v1/files/qualifications/new-legal-person.png", + LocalDate.of(2035, 12, 31), true)); + + assertEquals("/api/merchant/v1/files/qualifications/old-legal-person.png", + response.replacedDocumentUrl()); + assertEquals("SUPERSEDED", oldLegalPersonId.getStatus()); + assertEquals("PENDING", businessLicense.getStatus()); + ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); + verify(profileRepository, times(2)).saveQualification(captor.capture()); + IamTenantQualification replacement = captor.getAllValues().get(1); + assertEquals("LEGAL_PERSON_ID", replacement.getQualificationType()); + assertEquals(LocalDate.of(2035, 12, 31), replacement.getValidTo()); + assertNull(replacement.getCertificateNo()); + verify(tenantRepository, never()).saveTenant(any()); + } + @Test void requiredQualificationsRejectTheLegacySingleFilePath() { IamTenant tenant = new IamTenant(); @@ -118,4 +152,14 @@ class MerchantQualificationUploadTest { verify(profileRepository, never()).insertQualification(any()); } + + private static IamTenantQualification qualification(String type, String documentUrl) { + IamTenantQualification qualification = new IamTenantQualification(); + qualification.setId(1L); + qualification.setTenantId(10001L); + qualification.setQualificationType(type); + qualification.setDocumentUrl(documentUrl); + qualification.setStatus("PENDING"); + return qualification; + } }