fix(file): allow standalone legal-person ID upload
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]
|
||||
|
||||
+13
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<IamTenantQualification> 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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user