From fe5e3de9b180784d38034ad9af2144fa7d84364e Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 3 Aug 2026 14:16:10 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Jenkinsfile | 1 - Jenkinsfile.service | 17 ++++-- .../file/client/IamQualificationClient.java | 8 +++ .../PlatformQualificationFileController.java | 54 +++++++++++++++++-- .../QualificationFileController.java | 3 +- .../service/LocalQualificationStorage.java | 24 +++++---- file/src/main/resources/application.yml | 4 +- .../QualificationFileContractTest.java | 12 +++++ .../LocalQualificationStorageTest.java | 13 +++++ .../controller/InternalTenantController.java | 9 ++++ .../controller/PlatformTenantController.java | 11 +++- .../com/baiyue/iam/dto/OnboardingDtos.java | 11 ++++ .../java/com/baiyue/iam/dto/TenantDtos.java | 4 +- .../java/com/baiyue/iam/entity/IamTenant.java | 1 + .../iam/service/ProfileResourceService.java | 23 ++++++++ .../com/baiyue/iam/service/TenantService.java | 53 ++++++++++++++---- iam/src/main/resources/db/iam.sql | 1 + iam/src/main/resources/openapi/iam-v1.yaml | 32 ++++++++--- ...PlatformQualificationRegistrationTest.java | 46 ++++++++++++++++ .../baiyue/iam/service/TenantServiceTest.java | 48 ++++++++++++++++- 20 files changed, 331 insertions(+), 44 deletions(-) create mode 100644 iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java diff --git a/Jenkinsfile b/Jenkinsfile index 46630542b..72311d6db 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -165,7 +165,6 @@ pipeline { wait: true, propagate: false, parameters: [ - string(name: 'SERVICE', value: service), string(name: 'SOURCE_COMMIT', value: env.SOURCE_COMMIT), string(name: 'IMAGE_TAG', value: env.IMAGE_TAG), string(name: 'ENVIRONMENT', value: params.ENVIRONMENT), diff --git a/Jenkinsfile.service b/Jenkinsfile.service index d4a72a209..c7e78b586 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -33,7 +33,6 @@ pipeline { } parameters { - choice(name: 'SERVICE', choices: serviceModules, description: '本 Job 负责构建和部署的单个服务') string(name: 'SOURCE_COMMIT', defaultValue: '', description: '必须构建的 Git commit;通常由总控 Job 传入') string(name: 'IMAGE_TAG', defaultValue: '', description: '不可变镜像标签;通常使用 Git commit 前 12 位') choice(name: 'ENVIRONMENT', choices: ['test', 'staging', 'production'], description: '部署环境') @@ -52,14 +51,25 @@ pipeline { stages { stage('校验参数并检出指定提交') { steps { + checkout scm + script { - String service = params.SERVICE?.trim() + String service = serviceModules.find { candidate -> + env.JOB_BASE_NAME?.endsWith("-${candidate}") + } String sourceCommit = params.SOURCE_COMMIT?.trim() String imageTag = params.IMAGE_TAG?.trim() String imageRepository = params.IMAGE_REPOSITORY?.trim() + if (!sourceCommit) { + sourceCommit = sh(script: 'git rev-parse HEAD', returnStdout: true).trim() + } + if (!imageTag) { + imageTag = sh(script: 'git rev-parse --short=12 HEAD', returnStdout: true).trim() + } + if (!serviceModules.contains(service)) { - error("未知服务: ${service};允许值: ${serviceModules.join(', ')}") + error("无法从 Job 名称 ${env.JOB_BASE_NAME} 识别服务;名称必须以以下服务之一结尾: ${serviceModules.join(', ')}") } if (!sourceCommit || !(sourceCommit ==~ /(?i)[0-9a-f]{7,40}/)) { error('SOURCE_COMMIT 必须是 7 到 40 位十六进制 Git commit') @@ -80,7 +90,6 @@ pipeline { env.RESOLVED_IMAGE_REPOSITORY = imageRepository } - checkout scm sh ''' if ! git cat-file -e "${RESOLVED_SOURCE_COMMIT}^{commit}" 2>/dev/null; then git fetch --no-tags origin diff --git a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java index def2eeef7..46a1e3656 100644 --- a/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java +++ b/file/src/main/java/com/baiyue/file/client/IamQualificationClient.java @@ -14,7 +14,15 @@ public interface IamQualificationClient { @RequestHeader("X-Internal-Service-Token") String internalToken, @RequestBody QualificationRequest request); + @PostMapping("/platform-qualifications") + ApiResponse submitPlatform( + @RequestHeader("X-Internal-Service-Token") String internalToken, + @RequestBody PlatformQualificationRequest request); + record QualificationRequest(Long tenantId, String qualificationType, String certificateNo, String documentUrl) { } record QualificationResponse(Long qualificationId, Long reviewId, String tenantStatus) { } + record PlatformQualificationRequest(Long tenantId, String qualificationType, + String certificateNo, String documentUrl, + java.time.LocalDate validTo) { } } diff --git a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java index 9792adab9..00b1cb8d9 100644 --- a/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/PlatformQualificationFileController.java @@ -1,8 +1,10 @@ package com.baiyue.file.controller; import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.api.ApiResponse; 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 jakarta.annotation.Resource; import org.springframework.http.CacheControl; @@ -14,23 +16,67 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.multipart.MultipartFile; +import org.springframework.beans.factory.annotation.Value; + +import java.time.LocalDate; @RestController @RequestMapping("/api/platform/v1/files/qualifications") public class PlatformQualificationFileController { @Resource private LocalQualificationStorage storage; + @Resource private IamQualificationClient iamClient; + @Value("${baiyue.security.internal-service-token}") private String internalToken; + + @PostMapping(value = "/{tenantId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") + public ApiResponse upload( + @AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long tenantId, + @RequestPart("file") MultipartFile file, + @RequestParam String qualificationType, + @RequestParam(required = false) String certificateNo, + @RequestParam(required = false) LocalDate validTo) { + requirePlatform(user); + var stored = storage.store(tenantId, file); + try { + var response = iamClient.submitPlatform(internalToken, + new IamQualificationClient.PlatformQualificationRequest( + tenantId, qualificationType, certificateNo, stored.documentUrl(), validTo)); + if (response == null || !response.successful() || response.data() == null) { + String message = response == null || response.message() == null || response.message().isBlank() + ? "IAM qualification service is unavailable" : response.message(); + throw new BizException(response == null ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.of(response.code()), message); + } + var data = response.data(); + return ApiResponse.success(new QualificationFileController.UploadResponse( + data.qualificationId(), data.reviewId(), data.tenantStatus(), stored.documentUrl())); + } catch (RuntimeException exception) { + storage.delete(stored); + if (exception instanceof BizException business) throw business; + throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "IAM qualification service is unavailable"); + } + } @GetMapping("/{tenantId}/{fileName}") @PreAuthorize("hasAuthority('platform:customer:review')") public ResponseEntity download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable long tenantId, @PathVariable String fileName) { - if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) { - throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); - } + requirePlatform(user); var resource = storage.load(tenantId, fileName); - String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE; + String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE + : (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(mediaType)).body(resource); } + + private void requirePlatform(AuthenticatedUser user) { + if (user == null || !"PLATFORM".equalsIgnoreCase(user.appCode())) { + throw new BizException(ResultCode.FORBIDDEN, "Platform identity is required"); + } + } } 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 f383ebf55..9f9cc440e 100644 --- a/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java +++ b/file/src/main/java/com/baiyue/file/controller/QualificationFileController.java @@ -65,7 +65,8 @@ public class QualificationFileController { public ResponseEntity download( @AuthenticationPrincipal AuthenticatedUser user, @PathVariable String fileName) { var resource = storage.load(merchantTenant(user), fileName); - String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE : MediaType.IMAGE_JPEG_VALUE; + String mediaType = fileName.endsWith(".png") ? MediaType.IMAGE_PNG_VALUE + : (fileName.endsWith(".pdf") ? MediaType.APPLICATION_PDF_VALUE : MediaType.IMAGE_JPEG_VALUE); return ResponseEntity.ok().cacheControl(CacheControl.noStore()) .contentType(MediaType.parseMediaType(mediaType)).body(resource); } diff --git a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java index ef6371296..fa15642ac 100644 --- a/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java +++ b/file/src/main/java/com/baiyue/file/service/LocalQualificationStorage.java @@ -24,8 +24,8 @@ import java.util.UUID; @Service public class LocalQualificationStorage { private static final Logger LOGGER = LoggerFactory.getLogger(LocalQualificationStorage.class); - private static final long MAX_SIZE = 5L * 1024 * 1024; - private static final Set TYPES = Set.of("image/jpeg", "image/png"); + private static final long MAX_SIZE = 10L * 1024 * 1024; + private static final Set TYPES = Set.of("image/jpeg", "image/png", "application/pdf"); private final Path root; private final Path qualificationRoot; @@ -52,14 +52,14 @@ public class LocalQualificationStorage { } public StoredFile store(long tenantId, MultipartFile file) { - if (file == null || file.isEmpty()) throw new BizException("One business-license image is required"); - if (file.getSize() > MAX_SIZE) throw new BizException("Business-license image must not exceed 5 MB"); + if (file == null || file.isEmpty()) throw new BizException("One qualification file is required / 请选择资质文件"); + if (file.getSize() > MAX_SIZE) throw new BizException("Qualification file must not exceed 10 MB / 资质文件不能超过 10MB"); String contentType = file.getContentType() == null ? "" : file.getContentType().toLowerCase(Locale.ROOT); - if (!TYPES.contains(contentType)) throw new BizException("Only JPG and PNG images are supported"); - if (!matchesImageSignature(file, contentType)) { - throw new BizException("The uploaded file content is not a valid JPG or PNG image"); + if (!TYPES.contains(contentType)) throw new BizException("Only PDF, JPG and PNG files are supported / 仅支持 PDF、JPG、PNG 文件"); + if (!matchesSignature(file, contentType)) { + throw new BizException("The uploaded file content does not match its type / 文件内容与格式不匹配"); } - String extension = "image/png".equals(contentType) ? ".png" : ".jpg"; + String extension = "image/png".equals(contentType) ? ".png" : ("application/pdf".equals(contentType) ? ".pdf" : ".jpg"); String fileName = UUID.randomUUID() + extension; Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); @@ -82,7 +82,7 @@ public class LocalQualificationStorage { } } - private boolean matchesImageSignature(MultipartFile file, String contentType) { + private boolean matchesSignature(MultipartFile file, String contentType) { byte[] header = new byte[8]; try (InputStream input = file.getInputStream()) { int read = input.read(header); @@ -90,6 +90,10 @@ public class LocalQualificationStorage { return read >= 3 && (header[0] & 0xff) == 0xff && (header[1] & 0xff) == 0xd8 && (header[2] & 0xff) == 0xff; } + if ("application/pdf".equals(contentType)) { + return read >= 5 && header[0] == 0x25 && header[1] == 0x50 && header[2] == 0x44 + && header[3] == 0x46 && header[4] == 0x2d; + } return read >= 8 && (header[0] & 0xff) == 0x89 && header[1] == 0x50 && header[2] == 0x4e && header[3] == 0x47 && header[4] == 0x0d && header[5] == 0x0a && header[6] == 0x1a && header[7] == 0x0a; @@ -99,7 +103,7 @@ public class LocalQualificationStorage { } public Resource load(long tenantId, String fileName) { - if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png)")) throw new BizException("Invalid file name"); + if (!fileName.matches("[0-9a-fA-F-]{36}\\.(jpg|png|pdf)")) throw new BizException("Invalid file name"); Path directory = tenantDirectory(tenantId); Path target = directory.resolve(fileName).normalize(); if (!target.startsWith(directory) || !Files.isRegularFile(target)) throw new BizException("File not found"); diff --git a/file/src/main/resources/application.yml b/file/src/main/resources/application.yml index 8d08ce680..132249f7e 100644 --- a/file/src/main/resources/application.yml +++ b/file/src/main/resources/application.yml @@ -22,8 +22,8 @@ spring: config.namespace: ${NACOS_NAMESPACE:public} servlet: multipart: - max-file-size: 5MB - max-request-size: 6MB + max-file-size: 10MB + max-request-size: 11MB baiyue: security: jwt-secret: ${JWT_SECRET:change-me-to-at-least-32-bytes-long-before-start} 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 3cb468f2d..4bdb9bfde 100644 --- a/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java +++ b/file/src/test/java/com/baiyue/file/controller/QualificationFileContractTest.java @@ -27,4 +27,16 @@ class QualificationFileContractTest { assertTrue(hasMultipartPost, "企业资质真实上传入口必须保持 multipart/form-data 契约"); } + + @Test + void platformControllerExposesMultipartUploadForCustomerDrafts() { + RequestMapping mapping = PlatformQualificationFileController.class.getAnnotation(RequestMapping.class); + assertTrue(Arrays.asList(mapping.value()).contains("/api/platform/v1/files/qualifications")); + boolean hasMultipartPost = Arrays.stream(PlatformQualificationFileController.class.getDeclaredMethods()) + .map(method -> method.getAnnotation(PostMapping.class)) + .filter(java.util.Objects::nonNull) + .flatMap(post -> Arrays.stream(post.consumes())) + .anyMatch(MediaType.MULTIPART_FORM_DATA_VALUE::equals); + assertTrue(hasMultipartPost); + } } diff --git a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java index 522355169..3c628970b 100644 --- a/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java +++ b/file/src/test/java/com/baiyue/file/service/LocalQualificationStorageTest.java @@ -50,6 +50,19 @@ class LocalQualificationStorageTest { assertThrows(BizException.class, () -> storage.load(10002L, stored.fileName())); } + @Test + void storesPdfQualificationAfterVerifyingItsSignature() throws Exception { + var storage = new LocalQualificationStorage(storageRoot.toString()); + storage.initialize(); + var upload = new MockMultipartFile("file", "license.pdf", "application/pdf", "%PDF-1.4\ncontent".getBytes()); + + var stored = storage.store(10001L, upload); + + assertTrue(stored.fileName().endsWith(".pdf")); + assertEquals(stored.path().toAbsolutePath(), + storage.load(10001L, stored.fileName()).getFile().toPath().toAbsolutePath()); + } + @Test void legacySeedPathCannotBePretendedToBeAStoredFile() { var storage = new LocalQualificationStorage(storageRoot.toString()); diff --git a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java index 461a3c26a..dd768a2e1 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/InternalTenantController.java @@ -8,6 +8,7 @@ import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationResponse; import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse; 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.service.MerchantOnboardingService; import com.baiyue.iam.service.ProfileResourceService; import com.baiyue.iam.service.TenantService; @@ -48,6 +49,14 @@ public class InternalTenantController { return ApiResponse.success(profileResourceService.submitQualification(request)); } + @PostMapping("/platform-qualifications") + public ApiResponse registerPlatformQualification( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody PlatformQualificationRegistrationRequest request) { + verify(token); + return ApiResponse.success(profileResourceService.registerPlatformQualification(request)); + } + @GetMapping("/tenants/{tenantId}/order-eligibility") public ApiResponse orderEligibility( @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, diff --git a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java index a05c8aa05..18aeb1712 100644 --- a/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java +++ b/iam/src/main/java/com/baiyue/iam/controller/PlatformTenantController.java @@ -70,7 +70,7 @@ public class PlatformTenantController { @PostMapping("/tenants") @PreAuthorize("hasAuthority('platform:customer:create')") - @Operation(summary = "Register enterprise tenant / 提交企业注册", description = "Create a pending merchant tenant and its registration review ticket. Platform token required. / 创建待审核商户租户及注册审核单,需要平台端令牌。") + @Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft only. Saving the customer does not create a review ticket. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"), @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid enterprise data / 企业资料不合法"), @@ -103,6 +103,15 @@ public class PlatformTenantController { return ApiResponse.success(service.changeMerchantStatus(tenantId, request)); } + @PostMapping("/tenants/{tenantId:\\d+}/submit-review") + @PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')") + @Operation(summary = "提交企业客户审核", description = "校验客户基础资料与必填资质后创建审核单;保存客户或上传文件不会自动进入审核。") + public ApiResponse submitTenantReview(@PathVariable long tenantId, + @AuthenticationPrincipal AuthenticatedUser user) { + TenantGuard.tenant(user, "PLATFORM"); + return ApiResponse.success(service.submitMerchantReview(tenantId)); + } + @GetMapping("/reviews") @PreAuthorize("hasAuthority('platform:customer:review')") @Operation(summary = "List pending enterprise reviews / 查询待审核企业", description = "Return pending enterprise registration review tickets ordered by creation. / 按创建顺序返回待审核企业注册单。") diff --git a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java index e5180c013..a92559740 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/OnboardingDtos.java @@ -2,6 +2,9 @@ package com.baiyue.iam.dto; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.time.LocalDate; public final class OnboardingDtos { private OnboardingDtos() { } @@ -28,5 +31,13 @@ public final class OnboardingDtos { public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus) { } + public record PlatformQualificationRegistrationRequest( + @NotNull Long tenantId, + @NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType, + String certificateNo, + @NotBlank String documentUrl, + LocalDate validTo + ) { } + public record OrderEligibilityResponse(boolean eligible, String tenantStatus, String reason) { } } diff --git a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java index e68c4d0a5..796bd6c5a 100644 --- a/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java +++ b/iam/src/main/java/com/baiyue/iam/dto/TenantDtos.java @@ -29,6 +29,7 @@ public final class TenantDtos { @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, + @NotBlank @Size(max = 64) String servicePackage, @Size(max = 64) String contactWechat, @Size(max = 64) String salesOwnerName, @Min(0) @Max(365) Integer paymentTermDays, @@ -57,6 +58,7 @@ public final class TenantDtos { @NotBlank String contactName, @NotBlank String contactPhone, @Email String contactEmail, + @NotBlank @Size(max = 64) String servicePackage, @Size(max = 64) String contactWechat, @Size(max = 64) String salesOwnerName, @Min(0) @Max(365) Integer paymentTermDays, @@ -82,7 +84,7 @@ public final class TenantDtos { public record TenantResponse(Long id, String tenantCode, String tenantName, String status, String legalName, String unifiedSocialCreditCode, String contactName, String contactPhone, String contactEmail, String contactWechat, - String salesOwnerName, Integer paymentTermDays, String enterpriseType, + String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType, LocalDate establishedDate, BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress, String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { } diff --git a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java index 6dd362d72..95d0c2172 100644 --- a/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java +++ b/iam/src/main/java/com/baiyue/iam/entity/IamTenant.java @@ -27,6 +27,7 @@ public class IamTenant extends BaseEntity { private String contactName; private String contactPhone; private String contactEmail; + private String servicePackage; private String contactWechat; private String salesOwnerName; private Integer paymentTermDays; diff --git a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java index 127fff92a..0339c0386 100644 --- a/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java +++ b/iam/src/main/java/com/baiyue/iam/service/ProfileResourceService.java @@ -5,6 +5,7 @@ import com.baiyue.common.audit.BusinessAudit; import com.baiyue.common.core.exception.BizException; 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.ProfileDtos.AddressRequest; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.entity.IamTenantAddress; @@ -88,6 +89,28 @@ public class ProfileResourceService { return new QualificationSubmissionResponse(qualification.getId(), review.getId(), tenant.getStatus()); } + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION", + resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false) + public QualificationSubmissionResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) { + IamTenant tenant = tenantRepository.findTenant(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 / 仅待完善或已驳回客户可上传资质"); + } + if (repository.findQualifications(tenant.getId()).stream().anyMatch(item -> + request.qualificationType().equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus()))) { + throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传"); + } + IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(), + request.certificateNo(), request.documentUrl()); + qualification.setValidTo(request.validTo()); + repository.saveQualification(qualification); + return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus()); + } + private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) { IamTenantQualification x = new IamTenantQualification(); x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type); diff --git a/iam/src/main/java/com/baiyue/iam/service/TenantService.java b/iam/src/main/java/com/baiyue/iam/service/TenantService.java index 74cb5da21..4dac81e29 100644 --- a/iam/src/main/java/com/baiyue/iam/service/TenantService.java +++ b/iam/src/main/java/com/baiyue/iam/service/TenantService.java @@ -53,12 +53,13 @@ public class TenantService { tenant.setTenantCode("TEN" + tenant.getId()); tenant.setTenantName(request.tenantName()); tenant.setTenantType("MERCHANT"); - tenant.setStatus("PENDING"); + tenant.setStatus("PROFILE_REQUIRED"); tenant.setLegalName(request.legalName()); tenant.setUnifiedSocialCreditCode(request.unifiedSocialCreditCode()); tenant.setContactName(request.contactName()); tenant.setContactPhone(request.contactPhone()); tenant.setContactEmail(request.contactEmail()); + tenant.setServicePackage(request.servicePackage()); tenant.setContactWechat(request.contactWechat()); tenant.setSalesOwnerName(request.salesOwnerName()); tenant.setPaymentTermDays(request.paymentTermDays()); @@ -70,13 +71,6 @@ public class TenantService { tenant.setInternalRemark(request.internalRemark()); repository.insertTenant(tenant); - IamTenantReview review = new IamTenantReview(); - review.setId(IdWorker.getId()); - review.setTenantId(tenant.getId()); - review.setReviewType("REGISTER"); - review.setStatus("PENDING"); - review.setCreatedAt(LocalDateTime.now()); - repository.insertReview(review); return response(tenant); } @@ -108,6 +102,15 @@ public class TenantService { throw new BizException(ResultCode.CONFLICT, "Review already completed / 审核已完成"); } + if (approve) { + var qualifications = profileRepository.findQualifications(review.getTenantId()); + boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())); + boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())); + if (!hasLicense || !hasLegalId) { + throw new BizException(ResultCode.CONFLICT, "Required qualifications are incomplete / 必填资质不完整,不能审核通过"); + } + } + review.setStatus(approve ? "APPROVED" : "REJECTED"); review.setReviewerId(reviewerId); review.setReviewComment(comment); @@ -171,6 +174,7 @@ public class TenantService { tenant.setContactName(request.contactName()); tenant.setContactPhone(request.contactPhone()); tenant.setContactEmail(request.contactEmail()); + tenant.setServicePackage(request.servicePackage()); tenant.setContactWechat(request.contactWechat()); tenant.setSalesOwnerName(request.salesOwnerName()); tenant.setPaymentTermDays(request.paymentTermDays()); @@ -198,6 +202,33 @@ public class TenantService { return response(tenant); } + @Transactional + @BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId", + description = "平台提交企业客户审核") + public TenantResponse submitMerchantReview(long tenantId) { + IamTenant tenant = requireMerchant(tenantId); + if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) { + throw new BizException(ResultCode.CONFLICT, "Only draft or rejected enterprise can be submitted / 仅待完善或已驳回客户可提交审核"); + } + if (repository.findPendingReviewByTenant(tenantId) != null) { + throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中"); + } + var qualifications = profileRepository.findQualifications(tenantId); + boolean hasLicense = qualifications.stream().anyMatch(item -> "BUSINESS_LICENSE".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus())); + boolean hasLegalId = qualifications.stream().anyMatch(item -> "LEGAL_PERSON_ID".equals(item.getQualificationType()) && !"REJECTED".equals(item.getStatus())); + if (!hasLicense || !hasLegalId) { + throw new BizException(ResultCode.BAD_REQUEST, "Business license and legal-person ID are required / 营业执照和法人身份证为必填资质"); + } + IamTenantReview review = new IamTenantReview(); + review.setId(IdWorker.getId()); review.setTenantId(tenantId); + review.setReviewType("REGISTER"); review.setStatus("PENDING"); + review.setCreatedAt(LocalDateTime.now()); + repository.insertReview(review); + tenant.setStatus("PENDING"); tenant.setRejectReason(null); + repository.saveTenant(tenant); + return response(tenant); + } + @Transactional @BusinessAudit(event = "TENANT_PROFILE_UPDATE", resourceType = "TENANT", resourceId = "#tenantId", description = "编辑企业资料") @@ -232,7 +263,7 @@ public class TenantService { tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(), tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(), - tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(), + tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(), tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getInternalRemark(), tenant.getCreatedAt(), tenant.getApprovedAt() ); @@ -243,7 +274,7 @@ public class TenantService { return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), response.legalName(), response.unifiedSocialCreditCode(), response.contactName(), maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(), - response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), + response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), response.establishedDate(), response.registeredCapital(), response.registeredCapitalCurrency(), response.registeredAddress(), response.internalRemark(), response.createdAt(), response.approvedAt()); } @@ -252,7 +283,7 @@ public class TenantService { TenantResponse response = response(tenant); return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(), response.legalName(), response.unifiedSocialCreditCode(), response.contactName(), - response.contactPhone(), response.contactEmail(), response.contactWechat(), response.salesOwnerName(), + response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(), response.establishedDate(), response.registeredCapital(), response.registeredCapitalCurrency(), response.registeredAddress(), null, response.createdAt(), response.approvedAt()); diff --git a/iam/src/main/resources/db/iam.sql b/iam/src/main/resources/db/iam.sql index dc43c9043..0c9ea0287 100644 --- a/iam/src/main/resources/db/iam.sql +++ b/iam/src/main/resources/db/iam.sql @@ -16,6 +16,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant ( contact_name VARCHAR(64) NULL COMMENT '企业联系人', contact_phone VARCHAR(32) NULL COMMENT '联系电话', contact_email VARCHAR(128) NULL COMMENT '联系邮箱', + service_package VARCHAR(64) NOT NULL DEFAULT '基础版' COMMENT '客户服务套餐', contact_wechat VARCHAR(64) NULL COMMENT '联系微信', sales_owner_name VARCHAR(64) NULL COMMENT '销售负责人姓名', payment_term_days SMALLINT UNSIGNED NULL COMMENT '账期天数', diff --git a/iam/src/main/resources/openapi/iam-v1.yaml b/iam/src/main/resources/openapi/iam-v1.yaml index 7b59afd65..25c597710 100644 --- a/iam/src/main/resources/openapi/iam-v1.yaml +++ b/iam/src/main/resources/openapi/iam-v1.yaml @@ -39,8 +39,8 @@ paths: '422': {$ref: '#/components/responses/UnprocessableEntity'} post: tags: [Platform Tenant / 平台租户] - summary: Register enterprise tenant / 创建企业租户 - description: Create a pending enterprise tenant and review task. Platform identity is required. / 创建待审核企业租户及审核任务,要求平台端身份。 + summary: Register enterprise tenant / 创建企业客户草稿 + description: Create a `PROFILE_REQUIRED` enterprise draft only. Saving the customer does not create a review task. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。 requestBody: required: true content: @@ -88,6 +88,19 @@ paths: '400': {$ref: '#/components/responses/BadRequest'} '403': {$ref: '#/components/responses/Forbidden'} '404': {$ref: '#/components/responses/NotFound'} + /api/platform/v1/iam/tenants/{tenantId}/submit-review: + parameters: + - {$ref: '#/components/parameters/TenantId'} + post: + tags: [Platform Tenant / 平台租户] + summary: 提交企业客户审核 + description: 校验营业执照和法人身份证已上传后创建审核单,并把客户状态改为 `PENDING`;保存资料和上传文件均不会自动调用本接口。 + responses: + '200': {$ref: '#/components/responses/TenantSuccess'} + '400': {$ref: '#/components/responses/BadRequest'} + '403': {$ref: '#/components/responses/Forbidden'} + '404': {$ref: '#/components/responses/NotFound'} + '409': {$ref: '#/components/responses/Conflict'} /api/platform/v1/iam/tenants/{tenantId}/addresses: parameters: - {$ref: '#/components/parameters/TenantId'} @@ -674,7 +687,7 @@ components: data: {$ref: '#/components/schemas/InternalUserResponse'} TenantResponse: type: object - required: [id, tenantCode, tenantName, status] + required: [id, tenantCode, tenantName, status, servicePackage] properties: id: {type: string, pattern: '^\d+$', example: '190000000000000001'} tenantCode: {type: string} @@ -686,6 +699,7 @@ components: contactPhone: {type: string} contactEmail: {type: string, format: email} contactWechat: {type: string, maxLength: 64} + servicePackage: {type: string, maxLength: 64} salesOwnerName: {type: string, maxLength: 64} paymentTermDays: {type: integer, minimum: 0, maximum: 365} enterpriseType: {type: string, maxLength: 64} @@ -730,16 +744,16 @@ components: properties: id: {type: string, pattern: '^\d+$'} tenantId: {type: string, pattern: '^\d+$'} - qualificationType: {type: string} + qualificationType: {type: string, enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]} certificateNo: {type: string} documentUrl: {type: string} validFrom: {type: string, format: date} - validTo: {type: string, format: date} - status: {type: string} + validTo: {type: string, format: date, nullable: true} + status: {type: string, enum: [PENDING, APPROVED, REJECTED]} reviewComment: {type: string} RegisterRequest: type: object - required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone] + required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone, servicePackage] properties: tenantName: {type: string, example: 深圳虫虫出海科技有限公司} legalName: {type: string, example: 张三} @@ -748,6 +762,7 @@ components: contactPhone: {type: string, example: '13800138000'} contactEmail: {type: string, format: email, example: contact@example.com} contactWechat: {type: string, maxLength: 64} + servicePackage: {type: string, maxLength: 64, example: 基础版} salesOwnerName: {type: string, maxLength: 64} paymentTermDays: {type: integer, minimum: 0, maximum: 365} enterpriseType: {type: string, maxLength: 64} @@ -758,7 +773,7 @@ components: internalRemark: {type: string, maxLength: 1000} UpdateProfileRequest: type: object - required: [tenantName, legalName, contactName, contactPhone] + required: [tenantName, legalName, contactName, contactPhone, servicePackage] properties: tenantName: {type: string} legalName: {type: string} @@ -775,6 +790,7 @@ components: contactPhone: {type: string} contactEmail: {type: string, format: email} contactWechat: {type: string, maxLength: 64} + servicePackage: {type: string, maxLength: 64, example: 基础版} salesOwnerName: {type: string, maxLength: 64} paymentTermDays: {type: integer, minimum: 0, maximum: 365} enterpriseType: {type: string, maxLength: 64} diff --git a/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java new file mode 100644 index 000000000..6112d3b9c --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/service/PlatformQualificationRegistrationTest.java @@ -0,0 +1,46 @@ +package com.baiyue.iam.service; + +import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRequest; +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.test.util.ReflectionTestUtils; + +import java.time.LocalDate; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class PlatformQualificationRegistrationTest { + @Test + void platformUploadRegistersMetadataWithoutCreatingReviewTicket() { + ProfileResourceRepository repository = mock(ProfileResourceRepository.class); + TenantRepository tenantRepository = mock(TenantRepository.class); + ProfileResourceService service = new ProfileResourceService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository); + IamTenant tenant = new IamTenant(); + tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED"); + when(tenantRepository.findTenant(10001L)).thenReturn(tenant); + when(repository.findQualifications(10001L)).thenReturn(List.of()); + + var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest( + 10001L, "LEGAL_PERSON_ID", null, "/api/merchant/v1/files/qualifications/file.pdf", + LocalDate.of(2030, 12, 31))); + + ArgumentCaptor captor = ArgumentCaptor.forClass(IamTenantQualification.class); + verify(repository).insertQualification(captor.capture()); + verify(repository).saveQualification(captor.getValue()); + assertEquals("LEGAL_PERSON_ID", captor.getValue().getQualificationType()); + assertEquals(LocalDate.of(2030, 12, 31), captor.getValue().getValidTo()); + assertEquals("PROFILE_REQUIRED", response.tenantStatus()); + assertNull(response.reviewId()); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index 84c3af268..c3b20b274 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -4,6 +4,8 @@ import com.baiyue.common.core.api.PageResult; import com.baiyue.iam.entity.IamTenant; import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest; import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest; +import com.baiyue.iam.dto.TenantDtos.RegisterRequest; +import com.baiyue.iam.entity.IamTenantQualification; import com.baiyue.iam.repository.TenantRepository; import com.baiyue.iam.repository.ProfileResourceRepository; import org.junit.jupiter.api.Test; @@ -18,6 +20,8 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import static org.mockito.Mockito.never; +import static org.mockito.ArgumentMatchers.any; import static org.junit.jupiter.api.Assertions.assertThrows; import com.baiyue.common.core.exception.BizException; import com.baiyue.common.core.api.ResultCode; @@ -72,12 +76,13 @@ class TenantServiceTest { var updated = service.updateMerchant(10001L, new UpdateTenantRequest("New name", "New legal", "Alice", "13900139000", "alice@example.com", - "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), + "专业版", "alice_wechat", "Sales Zhang", 45, "LIMITED_COMPANY", LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区示例路 1 号", "一期重点客户")); var disabled = service.changeMerchantStatus(10001L, new ChangeTenantStatusRequest("DISABLED")); assertEquals("New name", updated.tenantName()); assertEquals("alice_wechat", updated.contactWechat()); + assertEquals("专业版", updated.servicePackage()); assertEquals("Sales Zhang", updated.salesOwnerName()); assertEquals(45, updated.paymentTermDays()); assertEquals("LIMITED_COMPANY", updated.enterpriseType()); @@ -103,6 +108,41 @@ class TenantServiceTest { assertEquals(ResultCode.CONFLICT, exception.getResultCode()); } + @Test + void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() { + 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", "91330100EXAMPLE001", "Alice", "13800138000", + "alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY", + LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户")); + + assertEquals("PROFILE_REQUIRED", created.status()); + assertEquals("基础版", created.servicePackage()); + verify(repository, never()).insertReview(any()); + } + + @Test + void explicitSubmissionRequiresBothMandatoryQualificationsAndCreatesReview() { + TenantRepository repository = mock(TenantRepository.class); + ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class); + TenantService service = new TenantService(); + ReflectionTestUtils.setField(service, "repository", repository); + ReflectionTestUtils.setField(service, "profileRepository", profileRepository); + IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED"); + when(repository.findTenant(10001L)).thenReturn(tenant); + when(profileRepository.findQualifications(10001L)).thenReturn(List.of( + qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"))); + + var submitted = service.submitMerchantReview(10001L); + + assertEquals("PENDING", submitted.status()); + verify(repository).insertReview(any()); + verify(repository).saveTenant(tenant); + } + @Test void 企业激活不等于具备下单资格仍需已审核资质() { TenantRepository repository = mock(TenantRepository.class); @@ -134,4 +174,10 @@ class TenantServiceTest { tenant.setContactPhone("13800138000"); return tenant; } + + private IamTenantQualification qualification(String type) { + IamTenantQualification item = new IamTenantQualification(); + item.setQualificationType(type); item.setStatus("PENDING"); + return item; + } }