fix(file): allow standalone legal-person ID upload

This commit is contained in:
2026-09-02 12:07:32 +08:00
parent f0da6e5110
commit 9e70f4339a
6 changed files with 203 additions and 18 deletions
@@ -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]
@@ -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;
}
}