This commit is contained in:
2026-09-11 16:17:16 +08:00
parent 2d928d667b
commit 51ce609ab1
57 changed files with 942 additions and 139 deletions
@@ -59,6 +59,14 @@ public class InternalTenantController {
return ApiResponse.success(onboardingService.register(request));
}
@GetMapping("/merchant-registrations/{authUserId}")
public ApiResponse<MerchantRegistrationResponse> registration(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@PathVariable long authUserId) {
verify(token);
return ApiResponse.success(onboardingService.registration(authUserId));
}
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
public ApiResponse<Void> compensateRegistration(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@@ -127,7 +135,7 @@ public class InternalTenantController {
@RequestParam(defaultValue = "500") @Min(1) @Max(500) int limit,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status) {
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status) {
verify(token);
return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status));
}
@@ -63,7 +63,7 @@ public class PlatformTenantController {
@RequestParam(defaultValue = "20") @Min(1) @Max(200) long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false)
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED") String status,
@Pattern(regexp = "PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED") String status,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.pageMerchants(page, size, keyword, status));
@@ -3,9 +3,10 @@ package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse;
import com.baiyue.iam.dto.ProfileDtos.QualificationResponse;
import com.baiyue.iam.dto.PlatformInternalUserDtos.*;
import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.PlatformInternalUserService;
import com.baiyue.iam.service.TenantGuard;
@@ -108,21 +109,48 @@ public class ProfileResourceController {
}
@GetMapping("/api/merchant/v1/iam/tenant/qualifications")
@PreAuthorize("hasAuthority('merchant:profile:view')")
@Operation(summary = "List enterprise qualifications / 查询企业资质", description = "List qualification files owned by the current tenant. / 查询当前租户的资质文件。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Qualification list returned / 查询成功")})
public ApiResponse<List<IamTenantQualification>> qualifications(@AuthenticationPrincipal AuthenticatedUser user) {
public ApiResponse<List<QualificationResponse>> qualifications(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.qualifications(TenantGuard.tenant(user, "MERCHANT")));
}
@GetMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}")
@PreAuthorize("hasAuthority('merchant:profile:view')")
@Operation(summary = "查询企业资质详情", description = "客户范围来自 JWT,跨客户资质按不存在处理。")
public ApiResponse<QualificationResponse> qualification(
@PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.qualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId));
}
@DeleteMapping("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}")
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
@Operation(summary = "删除送审前企业资质", description = "仅待完善或已驳回客户可删除;提交审核后服务端拒绝。重复删除幂等成功。")
public ApiResponse<QualificationDeletionResponse> deleteQualification(
@PathVariable long qualificationId, @AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(service.deleteQualification(TenantGuard.tenant(user, "MERCHANT"), qualificationId));
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户资质", description = "只读查询企业客户资质元数据,不授予审核能力。")
public ApiResponse<List<IamTenantQualification>> platformQualifications(
public ApiResponse<List<QualificationResponse>> platformQualifications(
@PathVariable long tenantId, @AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.platformQualifications(tenantId));
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户资质详情", description = "需要客户列表权限,且同时校验客户与资质归属。")
public ApiResponse<QualificationResponse> platformQualification(
@PathVariable long tenantId, @PathVariable long qualificationId,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.platformQualification(tenantId, qualificationId));
}
@GetMapping("/api/platform/v1/iam/tenants/{tenantId}/addresses")
@PreAuthorize("hasAuthority('platform:customer:list')")
@Operation(summary = "查询企业客户地址", description = "只读查询企业客户地址,不开放平台端写入。")
@@ -3,6 +3,7 @@ package com.baiyue.iam.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
@@ -13,7 +14,7 @@ public final class OnboardingDtos {
@NotNull Long authUserId,
@NotBlank String displayName,
@NotBlank String companyName,
@NotBlank String legalPerson,
@Size(max = 64) String legalPerson,
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String administratorPhone,
String contactEmail,
String countryCode,
@@ -28,6 +29,7 @@ public final class OnboardingDtos {
@NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
String certificateNo,
@NotBlank String documentUrl,
@NotBlank @Size(max = 255) String originalFileName,
LocalDate validTo,
boolean replace
) { }
@@ -40,6 +42,7 @@ public final class OnboardingDtos {
@NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
String certificateNo,
@NotBlank String documentUrl,
@NotBlank @Size(max = 255) String originalFileName,
LocalDate validTo,
boolean replace
) { }
@@ -55,9 +58,11 @@ public final class OnboardingDtos {
@NotNull Long tenantId,
@NotBlank @Pattern(regexp = "[0-9A-Za-z]{18}") String unifiedSocialCreditCode,
@NotBlank String businessLicenseDocumentUrl,
@NotBlank @Size(max = 255) String businessLicenseOriginalFileName,
LocalDate businessLicenseValidTo,
boolean replaceBusinessLicense,
String legalPersonIdDocumentUrl,
@Size(max = 255) String legalPersonIdOriginalFileName,
LocalDate legalPersonIdValidTo,
Boolean replaceLegalPersonId
) { }
@@ -5,6 +5,9 @@ import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import java.time.LocalDate;
import java.time.LocalDateTime;
/** 用户档案、地址和资质的数据契约。 */
public final class ProfileDtos {
private ProfileDtos() { }
@@ -24,4 +27,12 @@ public final class ProfileDtos {
String city, String district, @NotBlank String detail,
String postalCode, Boolean isDefault) { }
public record QualificationResponse(Long id, Long tenantId, String qualificationType,
String certificateNo, String documentUrl, String originalFileName,
LocalDate validFrom, LocalDate validTo, String status,
String reviewComment, LocalDateTime createdAt,
LocalDateTime updatedAt) { }
public record QualificationDeletionResponse(Long qualificationId, String deletedDocumentUrl) { }
}
@@ -25,7 +25,7 @@ public final class TenantDtos {
public record RegisterRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
@Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
@Schema(description = "Company contact phone; also initializes the first administrator login phone at creation, then maintained independently / 公司联系电话;创建时同时初始化首个管理员登录手机号,之后独立维护", example = "13800138000")
@NotBlank @Pattern(regexp = "^1[3-9]\\d{9}$") String contactPhone,
@@ -46,7 +46,7 @@ public final class TenantDtos {
public record UpdateProfileRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative / 法定代表人", example = "张三") @NotBlank @Size(max = 64) String legalRepresentative,
@Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "李四") @NotBlank String contactName,
@Schema(description = "Company contact phone, never used to resolve login identity / 公司联系电话,不作为登录标识", example = "0755-12345678")
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
@@ -58,7 +58,7 @@ public final class TenantDtos {
public record UpdateTenantRequest(
@NotBlank String tenantName,
@NotBlank String legalName,
@NotBlank @Size(max = 64) String legalRepresentative,
@Size(max = 64) String legalRepresentative,
@NotBlank String contactName,
@Schema(description = "Company contact phone, independent from every member login phone / 公司联系电话,与所有成员登录手机号独立")
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
@@ -2,10 +2,10 @@ package com.baiyue.iam.entity;
import lombok.Getter;
import lombok.Setter;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate;
import com.baomidou.mybatisplus.annotation.TableId; import com.baomidou.mybatisplus.annotation.TableLogic; import com.baomidou.mybatisplus.annotation.TableName; import java.time.LocalDate; import java.time.LocalDateTime;
/**
* 租户资质实体,对应表 {@code iam_tenant_qualification};按租户保存营业执照等证照、有效期、文件位置和审核状态。
*/
@Getter
@Setter
@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; }
@TableName("iam_tenant_qualification") public class IamTenantQualification { @TableId private Long id; private Long tenantId; private String qualificationType; private String certificateNo; private String documentUrl; private String originalFileName; private LocalDate validFrom; private LocalDate validTo; private String status; private String reviewComment; private LocalDateTime createdAt; private LocalDateTime updatedAt; @TableLogic private Boolean deleted; }
@@ -31,6 +31,18 @@ public class ProfileResourceRepository {
public void saveQualification(IamTenantQualification qualification) { qualificationMapper.updateById(qualification); }
public List<IamTenantAddress> findAddresses(long tenantId) { return addressMapper.selectList(new LambdaQueryWrapper<IamTenantAddress>().eq(IamTenantAddress::getTenantId, tenantId)); }
public List<IamTenantQualification> findQualifications(long tenantId) { return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>().eq(IamTenantQualification::getTenantId, tenantId)); }
public IamTenantQualification findQualification(long tenantId, long qualificationId) {
return qualificationMapper.selectOne(new LambdaQueryWrapper<IamTenantQualification>()
.eq(IamTenantQualification::getTenantId, tenantId)
.eq(IamTenantQualification::getId, qualificationId));
}
public IamTenantQualification findQualificationForUpdate(long tenantId, long qualificationId) {
return qualificationMapper.selectOne(new LambdaQueryWrapper<IamTenantQualification>()
.eq(IamTenantQualification::getTenantId, tenantId)
.eq(IamTenantQualification::getId, qualificationId)
.last("FOR UPDATE"));
}
public void deleteQualification(IamTenantQualification qualification) { qualificationMapper.deleteById(qualification); }
public List<IamTenantQualification> findQualificationsForUpdate(long tenantId) {
return qualificationMapper.selectList(new LambdaQueryWrapper<IamTenantQualification>()
.eq(IamTenantQualification::getTenantId, tenantId)
@@ -83,6 +83,7 @@ public class TenantRepository {
public PageResult<IamTenant> pageMerchants(long page, long size, String keyword, String status) {
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.ne(IamTenant::getStatus, "ARCHIVED")
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
.orderByDesc(IamTenant::getCreatedAt);
if (StringUtils.hasText(keyword)) {
@@ -114,6 +115,7 @@ public class TenantRepository {
public List<IamTenant> exportMerchants(Long cursor, int limit, String keyword, String status) {
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.ne(IamTenant::getStatus, "ARCHIVED")
.gt(cursor != null, IamTenant::getId, cursor)
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
.orderByAsc(IamTenant::getId)
@@ -74,7 +74,7 @@ public class MerchantOnboardingService {
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
tenant.setLegalName(request.companyName());
tenant.setLegalRepresentative(request.legalPerson());
tenant.setLegalRepresentative(normalizeOptional(request.legalPerson()));
tenant.setContactName(request.displayName());
tenant.setContactPhone(request.administratorPhone());
tenant.setContactEmail(request.contactEmail());
@@ -133,6 +133,32 @@ public class MerchantOnboardingService {
tenant.getDimensionInitializationStatus());
}
public MerchantRegistrationResponse registration(long authUserId) {
IamUser user = userMapper.selectOne(new LambdaQueryWrapper<IamUser>()
.eq(IamUser::getAuthUserId, authUserId)
.last("LIMIT 1"));
if (user == null) {
throw new BizException(ResultCode.NOT_FOUND, "Registration not found");
}
IamMembership membership = membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
.eq(IamMembership::getUserId, user.getId())
.eq(IamMembership::getMemberType, "OWNER")
.last("LIMIT 1"));
if (membership == null) {
throw new BizException(ResultCode.NOT_FOUND, "Registration not found");
}
IamTenant tenant = tenantMapper.selectById(membership.getTenantId());
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
throw new BizException(ResultCode.NOT_FOUND, "Registration not found");
}
return new MerchantRegistrationResponse(tenant.getId(), membership.getId(), tenant.getStatus(),
tenant.getDimensionInitializationStatus());
}
private String normalizeOptional(String value) {
return StringUtils.hasText(value) ? value.trim() : null;
}
/**
* Removes a just-created IAM registration when Auth could not persist the paired login account.
* Repeated calls are safe, while progressed onboarding data is deliberately protected.
@@ -10,6 +10,8 @@ import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRespon
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageRequest;
import com.baiyue.iam.dto.OnboardingDtos.RequiredQualificationPackageResponse;
import com.baiyue.iam.dto.ProfileDtos.AddressRequest;
import com.baiyue.iam.dto.ProfileDtos.QualificationDeletionResponse;
import com.baiyue.iam.dto.ProfileDtos.QualificationResponse;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantAddress;
import com.baiyue.iam.entity.IamTenantQualification;
@@ -43,16 +45,50 @@ public class ProfileResourceService {
repository.insertAddress(a); return a;
}
public List<IamTenantQualification> qualifications(long tenant) { return repository.findQualifications(tenant); }
public List<QualificationResponse> qualifications(long tenant) {
return repository.findQualifications(tenant).stream().map(this::toQualificationResponse).toList();
}
public QualificationResponse qualification(long tenant, long qualificationId) {
IamTenantQualification qualification = repository.findQualification(tenant, qualificationId);
if (qualification == null) throw new BizException(ResultCode.NOT_FOUND, "Qualification not found / 企业资质不存在");
return toQualificationResponse(qualification);
}
public List<IamTenantAddress> platformAddresses(long tenantId) {
requireMerchant(tenantId);
return repository.findAddresses(tenantId);
}
public List<IamTenantQualification> platformQualifications(long tenantId) {
public List<QualificationResponse> platformQualifications(long tenantId) {
requireMerchant(tenantId);
return repository.findQualifications(tenantId);
return qualifications(tenantId);
}
public QualificationResponse platformQualification(long tenantId, long qualificationId) {
requireMerchant(tenantId);
return qualification(tenantId, qualificationId);
}
@Transactional
@BusinessAudit(event = "TENANT_QUALIFICATION_DELETE", resourceType = "TENANT_QUALIFICATION",
resourceId = "#qualificationId", description = "删除送审前企业资质", captureAfter = false)
public QualificationDeletionResponse deleteQualification(long tenantId, long qualificationId) {
IamTenant tenant = tenantRepository.findTenantForUpdate(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 cannot be deleted after review submission / 资质提交审核后不可删除");
}
IamTenantQualification qualification = repository.findQualificationForUpdate(tenantId, qualificationId);
if (qualification == null) {
return new QualificationDeletionResponse(qualificationId, null);
}
String documentUrl = qualification.getDocumentUrl();
repository.deleteQualification(qualification);
return new QualificationDeletionResponse(qualificationId, documentUrl);
}
@Transactional
@@ -86,7 +122,7 @@ public class ProfileResourceService {
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(),
request.qualificationType(), certificateNo, request.documentUrl());
request.qualificationType(), certificateNo, request.documentUrl(), request.originalFileName());
qualification.setValidTo(request.validTo());
repository.saveQualification(qualification);
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
@@ -122,7 +158,7 @@ public class ProfileResourceService {
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
certificateNo, request.documentUrl());
certificateNo, request.documentUrl(), request.originalFileName());
qualification.setValidTo(request.validTo());
repository.saveQualification(qualification);
return new PlatformQualificationRegistrationResponse(
@@ -162,6 +198,7 @@ public class ProfileResourceService {
requireReplaceAllowed(businessLicenses, request.replaceBusinessLicense(), "营业执照");
boolean hasLegalPersonId = StringUtils.hasText(request.legalPersonIdDocumentUrl());
if (hasLegalPersonId) {
normalizeOriginalFileName(request.legalPersonIdOriginalFileName());
requireReplaceAllowed(legalPersonIds, Boolean.TRUE.equals(request.replaceLegalPersonId()), "法人身份证");
} else if (Boolean.TRUE.equals(request.replaceLegalPersonId())) {
throw new BizException(ResultCode.BAD_REQUEST, "未上传法人身份证时不能选择替换");
@@ -170,13 +207,13 @@ public class ProfileResourceService {
String replacedBusinessLicenseUrl = supersede(businessLicenses, replacementComment);
String replacedLegalPersonIdUrl = hasLegalPersonId ? supersede(legalPersonIds, replacementComment) : null;
IamTenantQualification businessLicense = createQualification(tenant.getId(), "BUSINESS_LICENSE",
creditCode, request.businessLicenseDocumentUrl());
creditCode, request.businessLicenseDocumentUrl(), request.businessLicenseOriginalFileName());
businessLicense.setValidTo(request.businessLicenseValidTo());
repository.saveQualification(businessLicense);
IamTenantQualification legalPersonId = null;
if (hasLegalPersonId) {
legalPersonId = createQualification(tenant.getId(), "LEGAL_PERSON_ID",
null, request.legalPersonIdDocumentUrl().trim());
null, request.legalPersonIdDocumentUrl().trim(), request.legalPersonIdOriginalFileName());
legalPersonId.setValidTo(request.legalPersonIdValidTo());
repository.saveQualification(legalPersonId);
}
@@ -243,13 +280,32 @@ public class ProfileResourceService {
return certificateNo;
}
private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url) {
private IamTenantQualification createQualification(long tenant, String type, String certificateNo, String url,
String originalFileName) {
IamTenantQualification x = new IamTenantQualification();
x.setId(IdWorker.getId()); x.setTenantId(tenant); x.setQualificationType(type);
x.setCertificateNo(certificateNo); x.setDocumentUrl(url); x.setStatus("PENDING");
x.setCertificateNo(certificateNo); x.setDocumentUrl(url);
x.setOriginalFileName(normalizeOriginalFileName(originalFileName)); x.setStatus("PENDING");
repository.insertQualification(x); return x;
}
private String normalizeOriginalFileName(String originalFileName) {
if (!StringUtils.hasText(originalFileName)) {
throw new BizException(ResultCode.BAD_REQUEST, "Original file name is required / 必须提供上传时原始文件名");
}
String normalized = originalFileName.trim();
if (normalized.length() > 255) {
throw new BizException(ResultCode.BAD_REQUEST, "Original file name is too long / 原始文件名不能超过255个字符");
}
return normalized;
}
private QualificationResponse toQualificationResponse(IamTenantQualification item) {
return new QualificationResponse(item.getId(), item.getTenantId(), item.getQualificationType(),
item.getCertificateNo(), item.getDocumentUrl(), item.getOriginalFileName(), item.getValidFrom(),
item.getValidTo(), item.getStatus(), item.getReviewComment(), item.getCreatedAt(), item.getUpdatedAt());
}
private boolean isCurrentQualification(IamTenantQualification qualification) {
return !"REJECTED".equals(qualification.getStatus())
&& !"SUPERSEDED".equals(qualification.getStatus());
@@ -174,7 +174,7 @@ public class TenantService {
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setUnifiedSocialCreditCode(null);
tenant.setContactName(request.contactName());
tenant.setContactPhone(contactPhone);
@@ -391,7 +391,7 @@ public class TenantService {
String contactPhone = normalizeContactPhone(request.contactPhone());
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setContactName(request.contactName());
tenant.setContactPhone(contactPhone);
tenant.setContactEmail(request.contactEmail());
@@ -463,7 +463,7 @@ public class TenantService {
ensureNotArchived(tenant);
tenant.setTenantName(request.tenantName());
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(request.legalRepresentative());
tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setContactName(request.contactName());
tenant.setContactPhone(normalizeContactPhone(request.contactPhone()));
tenant.setContactEmail(request.contactEmail());
@@ -485,6 +485,10 @@ public class TenantService {
return contactPhone == null ? "" : contactPhone.trim();
}
private String normalizeOptional(String value) {
return value == null || value.isBlank() ? null : value.trim();
}
private void ensureAdministratorPhoneAvailable(String phone) {
if (!administratorPhoneAvailable(phone)) {
throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用");
+1
View File
@@ -112,6 +112,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant_qualification (
qualification_type VARCHAR(32) NOT NULL COMMENT '客户资质类型',
certificate_no VARCHAR(128) NULL COMMENT '证书编号',
document_url VARCHAR(500) NULL COMMENT '资质文件存储地址',
original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名',
valid_from DATE NULL COMMENT '有效期开始日期',
valid_to DATE NULL COMMENT '有效期结束日期',
status VARCHAR(20) NOT NULL DEFAULT 'PENDING' COMMENT '资质审核状态:PENDING待审核、APPROVED已通过、REJECTED已驳回',
+154 -10
View File
@@ -95,7 +95,7 @@ paths:
- {name: page, in: query, required: false, schema: {type: integer, minimum: 1, default: 1}}
- {name: size, in: query, required: false, schema: {type: integer, minimum: 1, maximum: 200, default: 20}}
- {name: keyword, in: query, required: false, schema: {type: string}, description: 客户名称关键字,不支持客户编号、信用代码或联系人搜索}
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}}
- {name: status, in: query, required: false, schema: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}}
responses:
'200':
description: 企业客户分页查询成功
@@ -248,6 +248,22 @@ paths:
schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}:
parameters:
- {$ref: '#/components/parameters/TenantId'}
- {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}}
get:
tags: [Platform Tenant / 平台租户]
summary: 查询企业客户资质详情
description: 需要 `platform:customer:list` 权限,并同时校验客户与资质归属。
responses:
'200':
description: 企业客户资质详情查询成功
content:
application/json:
schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/api/platform/v1/iam/tenants/{tenantId}/status:
parameters:
- {$ref: '#/components/parameters/TenantId'}
@@ -677,8 +693,39 @@ paths:
tags: [IAM Resources / IAM 资源]
summary: List tenant qualifications / 查询企业资质
responses:
'200': {$ref: '#/components/responses/Success'}
'200':
description: 当前客户资质列表
content:
application/json:
schema: {$ref: '#/components/schemas/TenantQualificationListApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/merchant/v1/iam/tenant/qualifications/{qualificationId}:
parameters:
- {name: qualificationId, in: path, required: true, schema: {type: integer, format: int64}}
get:
tags: [IAM Resources / IAM 资源]
summary: 查询当前客户资质详情
description: 需要 `merchant:profile:view` 权限;客户范围来自 JWT,跨客户资质按不存在处理。
responses:
'200':
description: 当前客户资质详情
content:
application/json:
schema: {$ref: '#/components/schemas/TenantQualificationApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
delete:
tags: [IAM Resources / IAM 资源]
summary: 删除送审前企业资质
description: 需要 `merchant:qualification:upload` 权限;仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许,`PENDING`、`ACTIVE` 等提交审核后的状态由服务端拒绝。客户范围来自 JWT;重复删除或跨客户 ID 返回相同幂等结果,不泄露归属。
responses:
'200':
description: 删除成功或目标已不存在
content:
application/json:
schema: {$ref: '#/components/schemas/QualificationDeletionApiResponse'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/merchant/v1/iam/tenant/qualifications/submit-review:
post:
tags: [IAM Resources / IAM 资源]
@@ -810,6 +857,69 @@ paths:
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/internal/iam/merchant-registrations/{authUserId}:
get:
tags: [IAM Resources / IAM 资源]
summary: 查询已创建的企业注册状态
description: 仅供 Auth Service 对相同账号、手机号和密码的注册重试进行幂等确认,不创建或修改企业资料。
security: []
parameters:
- {$ref: '#/components/parameters/InternalServiceToken'}
- {name: authUserId, in: path, required: true, schema: {type: integer, format: int64}}
responses:
'200':
description: 注册状态查询成功
content:
application/json:
schema:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: object
required: [tenantId, membershipId, tenantStatus, dimensionInitializationStatus]
properties:
tenantId: {type: integer, format: int64}
membershipId: {type: integer, format: int64}
tenantStatus: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED]}
'403': {$ref: '#/components/responses/Forbidden'}
'404': {$ref: '#/components/responses/NotFound'}
/internal/iam/qualifications:
post:
tags: [IAM Resources / IAM 资源]
summary: 登记客户单个选填资质
description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。
security: []
parameters:
- {$ref: '#/components/parameters/InternalServiceToken'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/internal/iam/platform-qualifications:
post:
tags: [IAM Resources / IAM 资源]
summary: 平台代客户登记单个选填资质
description: 仅供 File Service 调用;原始文件名必须由上传请求传入,不得从存储 URL 推断。
security: []
parameters:
- {$ref: '#/components/parameters/InternalServiceToken'}
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/QualificationSubmissionRequest'}
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/internal/iam/required-qualification-packages:
post:
tags: [IAM Resources / IAM 资源]
@@ -1204,7 +1314,7 @@ components:
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
legalRepresentative: {type: string, maxLength: 64, nullable: true}
unifiedSocialCreditCode: {type: string}
contactName: {type: string}
contactPhone: {type: string}
@@ -1258,6 +1368,23 @@ components:
data:
type: array
items: {$ref: '#/components/schemas/TenantQualification'}
TenantQualificationApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/TenantQualification'}
QualificationDeletionApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data:
type: object
required: [qualificationId]
properties:
qualificationId: {type: string, pattern: '^\d+$'}
deletedDocumentUrl: {type: string, nullable: true, description: 首次删除时返回,供 File Service 回收文件;重复删除为空}
TenantAddress:
type: object
properties:
@@ -1273,26 +1400,43 @@ components:
isDefault: {type: boolean}
TenantQualification:
type: object
required: [id, tenantId, qualificationType, documentUrl, originalFileName, status, createdAt, updatedAt]
properties:
id: {type: string, pattern: '^\d+$'}
tenantId: {type: string, pattern: '^\d+$'}
qualificationType: {type: string, enum: [BUSINESS_LICENSE, LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]}
certificateNo: {type: string}
documentUrl: {type: string}
originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断}
validFrom: {type: string, format: date}
validTo: {type: string, format: date, nullable: true}
status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]}
reviewComment: {type: string}
createdAt: {type: string, format: date-time}
updatedAt: {type: string, format: date-time}
QualificationSubmissionRequest:
type: object
required: [tenantId, qualificationType, documentUrl, originalFileName, replace]
properties:
tenantId: {type: string, pattern: '^\d+$'}
qualificationType: {type: string, enum: [LEGAL_PERSON_ID, FREIGHT_FORWARDER_CERTIFICATE, BANK_ACCOUNT_LICENSE]}
certificateNo: {type: string, nullable: true}
documentUrl: {type: string, minLength: 1}
originalFileName: {type: string, minLength: 1, maxLength: 255}
validTo: {type: string, format: date, nullable: true}
replace: {type: boolean}
RequiredQualificationPackageRequest:
type: object
required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, replaceBusinessLicense]
required: [tenantId, unifiedSocialCreditCode, businessLicenseDocumentUrl, businessLicenseOriginalFileName, replaceBusinessLicense]
properties:
tenantId: {type: string, pattern: '^\d+$'}
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Za-z]{18}$'}
businessLicenseDocumentUrl: {type: string, minLength: 1}
businessLicenseOriginalFileName: {type: string, minLength: 1, maxLength: 255}
businessLicenseValidTo: {type: string, format: date, nullable: true}
replaceBusinessLicense: {type: boolean}
legalPersonIdDocumentUrl: {type: string, minLength: 1, nullable: true, description: 法人身份证选填}
legalPersonIdOriginalFileName: {type: string, maxLength: 255, nullable: true, description: 上传法人身份证时必传}
legalPersonIdValidTo: {type: string, format: date, nullable: true}
replaceLegalPersonId: {type: boolean, nullable: true, default: false}
RequiredQualificationPackageResponse:
@@ -1312,11 +1456,11 @@ components:
data: {$ref: '#/components/schemas/RequiredQualificationPackageResponse'}
RegisterRequest:
type: object
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone, servicePackage]
required: [tenantName, legalName, contactName, contactPhone, servicePackage]
properties:
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
legalRepresentative: {type: string, maxLength: 64, example: 张三}
legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填}
contactName: {type: string, example: 李四}
contactPhone: {type: string, pattern: '^1[3-9]\d{9}$', example: '13800138000', description: 创建时同时初始化公司联系电话与首个管理员登录手机号;创建后二者独立维护}
contactEmail: {type: string, format: email, example: contact@example.com}
@@ -1332,21 +1476,21 @@ components:
internalRemark: {type: string, maxLength: 1000}
UpdateProfileRequest:
type: object
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
required: [tenantName, legalName, contactName, contactPhone]
properties:
tenantName: {type: string}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填}
contactName: {type: string}
contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,不作为登录标识且不要求跨企业唯一}
contactEmail: {type: string, format: email}
UpdateTenantRequest:
type: object
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
required: [tenantName, legalName, contactName, contactPhone]
properties:
tenantName: {type: string}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64}
legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填}
contactName: {type: string}
contactPhone: {type: string, pattern: '^[0-9-]{4,32}$', description: 公司联系电话,与管理员及成员登录手机号独立且不要求跨企业唯一}
contactEmail: {type: string, format: email}
@@ -76,6 +76,12 @@ class IamOpenApiContractTest {
.contains("id: {type: string, pattern: '^\\d+$'")
.contains("/api/platform/v1/iam/tenants/{tenantId}/addresses:")
.contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications:")
.contains("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}:")
.contains("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}:")
.contains("originalFileName: {type: string, description: 上传时原始文件名;前端不得从 URL 或内部 ID 推断}")
.contains("updatedAt: {type: string, format: date-time}")
.contains("仅客户状态为 `PROFILE_REQUIRED` 或 `REJECTED` 时允许")
.contains("merchant:qualification:upload")
.contains("platform:customer:list")
.doesNotContain("platform:customer:view")
.contains("TenantAddressListApiResponse:")
@@ -4,6 +4,7 @@ import com.baiyue.common.security.AuthenticatedUser;
import org.junit.jupiter.api.Test;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import java.lang.reflect.Method;
@@ -17,6 +18,22 @@ class PlatformCustomerResourceContractTest {
"/api/platform/v1/iam/tenants/{tenantId}/addresses");
assertListPermissionContract("platformQualifications",
"/api/platform/v1/iam/tenants/{tenantId}/qualifications");
Method platformDetail = ProfileResourceController.class.getMethod(
"platformQualification", long.class, long.class, AuthenticatedUser.class);
assertEquals("/api/platform/v1/iam/tenants/{tenantId}/qualifications/{qualificationId}",
platformDetail.getAnnotation(GetMapping.class).value()[0]);
assertEquals("hasAuthority('platform:customer:list')",
platformDetail.getAnnotation(PreAuthorize.class).value());
Method merchantDetail = ProfileResourceController.class.getMethod(
"qualification", long.class, AuthenticatedUser.class);
assertEquals("hasAuthority('merchant:profile:view')",
merchantDetail.getAnnotation(PreAuthorize.class).value());
Method merchantDelete = ProfileResourceController.class.getMethod(
"deleteQualification", long.class, AuthenticatedUser.class);
assertEquals("/api/merchant/v1/iam/tenant/qualifications/{qualificationId}",
merchantDelete.getAnnotation(DeleteMapping.class).value()[0]);
assertEquals("hasAuthority('merchant:qualification:upload')",
merchantDelete.getAnnotation(PreAuthorize.class).value());
}
private void assertListPermissionContract(String methodName, String path) throws Exception {
@@ -31,7 +31,7 @@ class PlatformTenantControllerContractTest {
assertNotNull(annotation(method, 0, Min.class));
assertNotNull(annotation(method, 1, Min.class));
assertEquals(200, annotation(method, 1, Max.class).value());
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED|ARCHIVED",
assertEquals("PROFILE_REQUIRED|PENDING|ACTIVE|REJECTED|DISABLED",
annotation(method, 3, Pattern.class).regexp());
Set<String> statuses = Arrays.stream(method.getAnnotation(ApiResponses.class).value())
@@ -0,0 +1,19 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class ArchivedCustomerListContractTest {
@Test
void customerListAndExportExcludeArchivedTenants() throws Exception {
String source = Files.readString(Path.of(
"src/main/java/com/baiyue/iam/repository/TenantRepository.java"));
assertThat(source.split("\\.ne\\(IamTenant::getStatus, \\\"ARCHIVED\\\"\\)", -1))
.hasSizeGreaterThanOrEqualTo(3);
}
}
@@ -0,0 +1,26 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class QualificationSchemaContractTest {
@Test
void baselineAndUnifiedSqlPersistTheUploadOriginalFileName() throws Exception {
String baseline = new ClassPathResource("db/iam.sql")
.getContentAsString(StandardCharsets.UTF_8);
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
if (!Files.exists(unifiedPath)) {
unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
assertThat(baseline).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
assertThat(unified).contains("original_file_name VARCHAR(255) NOT NULL COMMENT '上传时原始文件名'");
}
}
@@ -25,6 +25,7 @@ import jakarta.annotation.Resource;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
@@ -103,6 +104,52 @@ class MerchantOnboardingServiceTest {
assertNotNull(result.tenantId());
}
@Test
void clientRegistrationAcceptsAnOmittedLegalRepresentative() {
IamRole ownerRole = new IamRole();
ownerRole.setId(4000100L);
ownerRole.setTenantId(0L);
ownerRole.setAppCode("MERCHANT");
ownerRole.setRoleCode("MERCHANT_OWNER");
ownerRole.setStatus("ACTIVE");
when(roles.selectOne(any())).thenReturn(ownerRole);
service.register(new MerchantRegistrationRequest(
9001L, "注册人", "测试企业", null, "13800138000",
"owner@example.com", "CN", "深圳市宝安区"));
ArgumentCaptor<IamTenant> tenant = ArgumentCaptor.forClass(IamTenant.class);
verify(tenants).insert(tenant.capture());
assertNull(tenant.getValue().getLegalRepresentative());
}
@Test
void registrationLookupReturnsTheExistingQualificationDraftState() {
IamUser user = new IamUser();
user.setId(20001L);
user.setAuthUserId(9001L);
IamMembership membership = new IamMembership();
membership.setId(30001L);
membership.setTenantId(10001L);
membership.setUserId(20001L);
membership.setMemberType("OWNER");
IamTenant tenant = new IamTenant();
tenant.setId(10001L);
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("READY");
when(users.selectOne(any())).thenReturn(user);
when(memberships.selectOne(any())).thenReturn(membership);
when(tenants.selectById(10001L)).thenReturn(tenant);
var result = service.registration(9001L);
assertEquals(10001L, result.tenantId());
assertEquals(30001L, result.membershipId());
assertEquals("PROFILE_REQUIRED", result.tenantStatus());
assertEquals("READY", result.dimensionInitializationStatus());
}
@Test
void compensationRemovesOnlyAnUnstartedRegistration() {
@@ -15,6 +15,7 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import jakarta.annotation.Resource;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -43,7 +44,8 @@ class MerchantQualificationUploadTest {
var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "FREIGHT_FORWARDER_CERTIFICATE", null,
"/api/merchant/v1/files/qualifications/freight-forwarder.png", LocalDate.of(2030, 1, 1), false));
"/api/merchant/v1/files/qualifications/freight-forwarder.png", "freight-forwarder.png",
LocalDate.of(2030, 1, 1), false));
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertNull(response.reviewId());
@@ -68,6 +70,7 @@ class MerchantQualificationUploadTest {
var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "LEGAL_PERSON_ID", null,
"/api/merchant/v1/files/qualifications/new-legal-person.png",
"new-legal-person.png",
LocalDate.of(2035, 12, 31), true));
assertEquals("/api/merchant/v1/files/qualifications/old-legal-person.png",
@@ -92,7 +95,7 @@ class MerchantQualificationUploadTest {
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest(
10001L, "BUSINESS_LICENSE", "91310101abcde12345",
"/api/merchant/v1/files/qualifications/license.png", null, false)));
"/api/merchant/v1/files/qualifications/license.png", "license.png", null, false)));
verify(profileRepository, never()).insertQualification(any());
}
@@ -108,8 +111,8 @@ class MerchantQualificationUploadTest {
var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest(
10001L, "91310101abcde12345",
"/api/merchant/v1/files/qualifications/license.png", null, false,
"/api/merchant/v1/files/qualifications/legal-person.png", null, false));
"/api/merchant/v1/files/qualifications/license.png", "license.png", null, false,
"/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, false));
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode());
@@ -128,8 +131,8 @@ class MerchantQualificationUploadTest {
var response = service.submitRequiredQualificationPackage(new RequiredQualificationPackageRequest(
10001L, "91310101abcde12345",
"/api/merchant/v1/files/qualifications/license.png", null, false,
null, null, null));
"/api/merchant/v1/files/qualifications/license.png", "license.png", null, false,
null, null, null, null));
assertNull(response.legalPersonIdQualificationId());
verify(profileRepository, times(1)).insertQualification(any());
@@ -147,12 +150,66 @@ class MerchantQualificationUploadTest {
assertThrows(BizException.class, () -> service.submitRequiredQualificationPackage(
new RequiredQualificationPackageRequest(10001L, "91310101ABCDE12345",
"/api/merchant/v1/files/qualifications/license.png", null, true,
"/api/merchant/v1/files/qualifications/legal-person.png", null, true)));
"/api/merchant/v1/files/qualifications/license.png", "license.png", null, true,
"/api/merchant/v1/files/qualifications/legal-person.png", "legal-person.png", null, true)));
verify(profileRepository, never()).insertQualification(any());
}
@Test
void qualificationListReturnsPersistedOriginalFileNameAndUpdatedTime() {
IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1");
item.setOriginalFileName("法人身份证.png");
item.setUpdatedAt(LocalDateTime.of(2026, 9, 11, 10, 30));
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(item));
var response = service.qualifications(10001L);
assertEquals("法人身份证.png", response.get(0).originalFileName());
assertEquals(LocalDateTime.of(2026, 9, 11, 10, 30), response.get(0).updatedAt());
}
@Test
void deletesDraftQualificationWithTenantLock() {
IamTenant tenant = new IamTenant();
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PROFILE_REQUIRED");
IamTenantQualification item = qualification("LEGAL_PERSON_ID", "/files/qualification/1");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualificationForUpdate(10001L, 1L)).thenReturn(item);
var response = service.deleteQualification(10001L, 1L);
assertEquals(1L, response.qualificationId());
assertEquals("/files/qualification/1", response.deletedDocumentUrl());
verify(profileRepository).deleteQualification(item);
}
@Test
void repeatedOrCrossTenantDeleteIsIdempotentAndDoesNotLeakOwnership() {
IamTenant tenant = new IamTenant();
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("REJECTED");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualificationForUpdate(10001L, 99L)).thenReturn(null);
var response = service.deleteQualification(10001L, 99L);
assertEquals(99L, response.qualificationId());
assertNull(response.deletedDocumentUrl());
verify(profileRepository, never()).deleteQualification(any());
}
@Test
void rejectsDeleteAfterReviewSubmission() {
IamTenant tenant = new IamTenant();
tenant.setId(10001L); tenant.setTenantType("MERCHANT"); tenant.setStatus("PENDING");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
assertThrows(BizException.class, () -> service.deleteQualification(10001L, 1L));
verify(profileRepository, never()).findQualificationForUpdate(10001L, 1L);
verify(profileRepository, never()).deleteQualification(any());
}
private static IamTenantQualification qualification(String type, String documentUrl) {
IamTenantQualification qualification = new IamTenantQualification();
qualification.setId(1L);
@@ -35,7 +35,7 @@ class PlatformQualificationRegistrationTest {
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/file.pdf",
LocalDate.of(2030, 12, 31), false));
"file.pdf", LocalDate.of(2030, 12, 31), false));
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository).insertQualification(captor.capture());
@@ -60,7 +60,7 @@ class PlatformQualificationRegistrationTest {
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
10001L, "BANK_ACCOUNT_LICENSE", null, "/api/merchant/v1/files/qualifications/new-file.pdf",
LocalDate.of(2031, 12, 31), true));
"new-file.pdf", LocalDate.of(2031, 12, 31), true));
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository, times(2)).saveQualification(captor.capture());