This commit is contained in:
2026-08-03 14:16:10 +08:00
parent 595a51ab79
commit fe5e3de9b1
20 changed files with 331 additions and 44 deletions
Vendored
-1
View File
@@ -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),
+13 -4
View File
@@ -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
@@ -14,7 +14,15 @@ public interface IamQualificationClient {
@RequestHeader("X-Internal-Service-Token") String internalToken,
@RequestBody QualificationRequest request);
@PostMapping("/platform-qualifications")
ApiResponse<QualificationResponse> 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) { }
}
@@ -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<QualificationFileController.UploadResponse> 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<org.springframework.core.io.Resource> 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");
}
}
}
@@ -65,7 +65,8 @@ public class QualificationFileController {
public ResponseEntity<org.springframework.core.io.Resource> 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);
}
@@ -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<String> TYPES = Set.of("image/jpeg", "image/png");
private static final long MAX_SIZE = 10L * 1024 * 1024;
private static final Set<String> 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");
+2 -2
View File
@@ -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}
@@ -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);
}
}
@@ -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());
@@ -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<QualificationSubmissionResponse> 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<OrderEligibilityResponse> orderEligibility(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -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<TenantResponse> 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. / 按创建顺序返回待审核企业注册单。")
@@ -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) { }
}
@@ -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) { }
@@ -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;
@@ -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);
@@ -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());
+1
View File
@@ -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 '账期天数',
+24 -8
View File
@@ -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}
@@ -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<IamTenantQualification> 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());
}
}
@@ -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;
}
}