This commit is contained in:
2026-08-03 18:28:35 +08:00
parent eac3da9dd8
commit fdb1e8af1c
43 changed files with 1707 additions and 69 deletions
@@ -13,9 +13,14 @@ import com.baiyue.iam.dto.OnboardingDtos.PlatformQualificationRegistrationRespon
import com.baiyue.iam.service.MerchantOnboardingService;
import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantService;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import jakarta.annotation.PostConstruct;
import jakarta.annotation.Resource;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.Pattern;
import org.springframework.validation.annotation.Validated;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.web.bind.annotation.*;
@@ -24,6 +29,7 @@ import java.security.MessageDigest;
@RestController
@RequestMapping("/internal/iam")
@Validated
public class InternalTenantController {
@Resource private MerchantOnboardingService onboardingService;
@Resource private ProfileResourceService profileResourceService;
@@ -66,6 +72,18 @@ public class InternalTenantController {
return ApiResponse.success(tenantService.orderEligibility(tenantId));
}
@GetMapping("/exports/customers")
public ApiResponse<CustomerExportBatch> exportCustomers(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestParam(required = false) Long cursor,
@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") String status) {
verify(token);
return ApiResponse.success(tenantService.exportMerchants(cursor, limit, keyword, status));
}
private void verify(String token) {
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
if (!MessageDigest.isEqual(internalToken, supplied)) {
@@ -70,6 +70,13 @@ public class MerchantTenantController {
return ApiResponse.success(tenantService.update(TenantGuard.tenant(user, "MERCHANT"), request));
}
@PostMapping("/tenant/qualifications/submit-review")
@PreAuthorize("hasAuthority('merchant:qualification:upload')")
@Operation(summary = "提交当前企业资质审核", description = "校验四类必填资质齐全后创建审核单。上传或替换文件不会自动送审。")
public ApiResponse<TenantResponse> submitQualificationReview(@AuthenticationPrincipal AuthenticatedUser user) {
return ApiResponse.success(tenantService.submitMerchantReview(TenantGuard.tenant(user, "MERCHANT")));
}
@GetMapping("/members")
@Operation(summary = "List enterprise members / 查询企业成员", description = "List members belonging to the current JWT tenant. / 查询属于当前 JWT 租户的成员。")
@ApiResponses({@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Member list returned / 查询成功")})
@@ -0,0 +1,20 @@
package com.baiyue.iam.dto;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.List;
public final class CustomerExportDtos {
private CustomerExportDtos() { }
public record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { }
public record CustomerExportRow(
String id, String tenantCode, String tenantName, String legalName,
String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate,
BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress,
String contactName, String contactPhone, String contactEmail, String contactWechat,
String salesOwnerName, String servicePackage, Integer paymentTermDays,
String status, LocalDateTime createdAt, LocalDateTime approvedAt) { }
}
@@ -24,12 +24,15 @@ public final class OnboardingDtos {
public record QualificationSubmissionRequest(
@NotNull Long tenantId,
@NotBlank String qualificationType,
@NotBlank @Pattern(regexp = "BUSINESS_LICENSE|LEGAL_PERSON_ID|FREIGHT_FORWARDER_CERTIFICATE|BANK_ACCOUNT_LICENSE") String qualificationType,
String certificateNo,
@NotBlank String documentUrl
@NotBlank String documentUrl,
LocalDate validTo,
boolean replace
) { }
public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus) { }
public record QualificationSubmissionResponse(Long qualificationId, Long reviewId, String tenantStatus,
String replacedDocumentUrl) { }
public record PlatformQualificationRegistrationRequest(
@NotNull Long tenantId,
@@ -31,6 +31,16 @@ public class TenantRepository {
}
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
/**
* Serializes the draft qualification workflow for one enterprise. The file service and the
* merchant submit action run in separate requests, so qualification-row locks alone cannot
* protect the empty-set case.
*/
public IamTenant findTenantForUpdate(Long tenantId) {
return tenantMapper.selectOne(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getId, tenantId)
.last("FOR UPDATE"));
}
public List<IamTenant> listMerchants() {
return tenantMapper.selectList(new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
@@ -71,4 +81,24 @@ public class TenantRepository {
}
return PageResult.from(tenantMapper.selectPage(new Page<>(page, size), wrapper));
}
public List<IamTenant> exportMerchants(Long cursor, int limit, String keyword, String status) {
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
.eq(IamTenant::getTenantType, "MERCHANT")
.gt(cursor != null, IamTenant::getId, cursor)
.eq(StringUtils.hasText(status), IamTenant::getStatus, status)
.orderByAsc(IamTenant::getId)
.last("LIMIT " + (limit + 1));
if (StringUtils.hasText(keyword)) {
wrapper.and(condition -> condition
.like(IamTenant::getTenantCode, keyword)
.or().like(IamTenant::getTenantName, keyword)
.or().like(IamTenant::getLegalName, keyword)
.or().like(IamTenant::getUnifiedSocialCreditCode, keyword)
.or().like(IamTenant::getContactName, keyword)
.or().like(IamTenant::getContactPhone, keyword)
.or().like(IamTenant::getSalesOwnerName, keyword));
}
return tenantMapper.selectList(wrapper);
}
}
@@ -54,64 +54,69 @@ public class ProfileResourceService {
}
@Transactional
@BusinessAudit(event = "TENANT_QUALIFICATION_SUBMIT", resourceType = "TENANT_QUALIFICATION",
resourceId = "#request.tenantId", description = "提交企业资质", captureAfter = false)
@BusinessAudit(event = "TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
resourceId = "#request.tenantId", description = "上传企业资质", captureAfter = false)
public QualificationSubmissionResponse submitQualification(QualificationSubmissionRequest request) {
IamTenant tenant = tenantRepository.findTenant(request.tenantId());
IamTenant tenant = tenantRepository.findTenantForUpdate(request.tenantId());
if (tenant == null || !"MERCHANT".equals(tenant.getTenantType())) {
throw new BizException(ResultCode.NOT_FOUND, "Enterprise not found");
}
if ("ACTIVE".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Enterprise is already approved");
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT,
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
}
if (tenantRepository.findPendingReviewByTenant(tenant.getId()) != null) {
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review");
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
.filter(item -> request.qualificationType().equals(item.getQualificationType())
&& isCurrentQualification(item))
.toList();
if (!activeQualifications.isEmpty() && !request.replace()) {
throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传");
}
if (repository.findQualifications(tenant.getId()).stream()
.anyMatch(item -> !"REJECTED".equals(item.getStatus()))) {
throw new BizException(ResultCode.CONFLICT, "Phase one permits one enterprise qualification image");
String replacedDocumentUrl = null;
for (IamTenantQualification existing : activeQualifications) {
if (replacedDocumentUrl == null) {
replacedDocumentUrl = existing.getDocumentUrl();
}
existing.setStatus("SUPERSEDED");
existing.setReviewComment("商户在送审前替换文件");
repository.saveQualification(existing);
}
IamTenantQualification qualification = createQualification(tenant.getId(),
request.qualificationType(), request.certificateNo(), request.documentUrl());
qualification.setValidTo(request.validTo());
repository.saveQualification(qualification);
if (StringUtils.hasText(request.certificateNo())
&& request.certificateNo().trim().toUpperCase().matches("[0-9A-Z]{18}")) {
tenant.setUnifiedSocialCreditCode(request.certificateNo().trim().toUpperCase());
tenantRepository.saveTenant(tenant);
}
tenant.setStatus("PENDING");
tenant.setRejectReason(null);
tenantRepository.saveTenant(tenant);
IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId()); review.setTenantId(tenant.getId());
review.setReviewType("REGISTER"); review.setStatus("PENDING");
review.setCreatedAt(LocalDateTime.now());
tenantRepository.insertReview(review);
return new QualificationSubmissionResponse(qualification.getId(), review.getId(), tenant.getStatus());
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
}
@Transactional
@BusinessAudit(event = "PLATFORM_TENANT_QUALIFICATION_UPLOAD", resourceType = "TENANT_QUALIFICATION",
resourceId = "#request.tenantId", description = "平台代客户上传资质", captureAfter = false)
public PlatformQualificationRegistrationResponse registerPlatformQualification(PlatformQualificationRegistrationRequest request) {
IamTenant tenant = tenantRepository.findTenant(request.tenantId());
IamTenant tenant = tenantRepository.findTenantForUpdate(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 / 仅待完善或已驳回客户可上传资质");
}
IamTenantQualification existing = repository.findQualificationsForUpdate(tenant.getId()).stream()
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
.filter(item -> request.qualificationType().equals(item.getQualificationType())
&& !"REJECTED".equals(item.getStatus()))
.findFirst().orElse(null);
if (existing != null && !request.replace()) {
&& isCurrentQualification(item))
.toList();
if (!activeQualifications.isEmpty() && !request.replace()) {
throw new BizException(ResultCode.CONFLICT, "Qualification type already uploaded / 该类资质已上传");
}
String replacedDocumentUrl = null;
if (existing != null) {
replacedDocumentUrl = existing.getDocumentUrl();
existing.setStatus("REJECTED");
for (IamTenantQualification existing : activeQualifications) {
if (replacedDocumentUrl == null) {
replacedDocumentUrl = existing.getDocumentUrl();
}
existing.setStatus("SUPERSEDED");
existing.setReviewComment("平台在送审前替换文件");
repository.saveQualification(existing);
}
@@ -130,6 +135,11 @@ public class ProfileResourceService {
repository.insertQualification(x); return x;
}
private boolean isCurrentQualification(IamTenantQualification qualification) {
return !"REJECTED".equals(qualification.getStatus())
&& !"SUPERSEDED".equals(qualification.getStatus());
}
private void requireMerchant(long tenantId) {
IamTenant tenant = tenantRepository.findTenant(tenantId);
if (tenant == null || tenant.getId() == null || tenant.getId().longValue() != tenantId
@@ -6,6 +6,7 @@ import com.baiyue.common.core.api.PageResult;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
@@ -16,6 +17,8 @@ import com.baiyue.iam.dto.TenantDtos.TenantResponse;
import com.baiyue.iam.dto.TenantDtos.UpdateProfileRequest;
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import org.springframework.stereotype.Service;
@@ -23,12 +26,15 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.List;
import java.util.Set;
/**
* 租户生命周期应用服务,编排商户入驻、资料维护、审核流转和租户状态变更事务。
*/
@Service
public class TenantService {
private static final Set<String> REQUIRED_QUALIFICATION_TYPES = Set.of(
"BUSINESS_LICENSE", "LEGAL_PERSON_ID", "FREIGHT_FORWARDER_CERTIFICATE", "BANK_ACCOUNT_LICENSE");
@Resource
private TenantRepository repository;
@Resource
@@ -84,6 +90,26 @@ public class TenantService {
source.page(), source.size(), source.total(), source.pages());
}
public CustomerExportBatch exportMerchants(Long cursor, int limit, String keyword, String status) {
int boundedLimit = Math.min(Math.max(limit, 1), 500);
List<IamTenant> source = repository.exportMerchants(cursor, boundedLimit, keyword, status);
boolean hasMore = source.size() > boundedLimit;
List<IamTenant> page = hasMore ? source.subList(0, boundedLimit) : source;
List<CustomerExportRow> rows = page.stream().map(this::exportRow).toList();
Long nextCursor = page.isEmpty() ? cursor : page.get(page.size() - 1).getId();
return new CustomerExportBatch(rows, nextCursor, hasMore);
}
private CustomerExportRow exportRow(IamTenant tenant) {
return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(),
tenant.getTenantName(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(),
tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(),
tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
tenant.getSalesOwnerName(), tenant.getServicePackage(), tenant.getPaymentTermDays(),
tenant.getStatus(), tenant.getCreatedAt(), tenant.getApprovedAt());
}
@BusinessAudit(
event = "TENANT_REVIEW",
resourceType = "TENANT_REVIEW",
@@ -104,9 +130,7 @@ public class TenantService {
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) {
if (!hasAllPendingQualifications(qualifications)) {
throw new BizException(ResultCode.CONFLICT, "Required qualifications are incomplete / 必填资质不完整,不能审核通过");
}
}
@@ -206,7 +230,7 @@ public class TenantService {
@BusinessAudit(event = "PLATFORM_TENANT_SUBMIT_REVIEW", resourceType = "TENANT", resourceId = "#tenantId",
description = "平台提交企业客户审核")
public TenantResponse submitMerchantReview(long tenantId) {
IamTenant tenant = requireMerchant(tenantId);
IamTenant tenant = requireMerchantForUpdate(tenantId);
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
throw new BizException(ResultCode.CONFLICT, "Only draft or rejected enterprise can be submitted / 仅待完善或已驳回客户可提交审核");
}
@@ -214,10 +238,9 @@ public class TenantService {
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 / 营业执照和法人身份证为必填资质");
if (!hasAllPendingQualifications(qualifications)) {
throw new BizException(ResultCode.BAD_REQUEST,
"Business license, legal-person ID, freight-forwarder certificate and bank account license are required / 营业执照、法人身份证、货代资质证书和银行开户许可证均为必填资质");
}
IamTenantReview review = new IamTenantReview();
review.setId(IdWorker.getId()); review.setTenantId(tenantId);
@@ -250,6 +273,11 @@ public class TenantService {
return tenant.getId() != null && tenant.getId().longValue() == id;
}
private boolean hasAllPendingQualifications(List<IamTenantQualification> qualifications) {
return REQUIRED_QUALIFICATION_TYPES.stream().allMatch(type -> qualifications.stream()
.anyMatch(item -> type.equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())));
}
private IamTenant requireMerchant(long tenantId) {
IamTenant tenant = repository.findTenant(tenantId);
if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
@@ -258,6 +286,14 @@ public class TenantService {
return tenant;
}
private IamTenant requireMerchantForUpdate(long tenantId) {
IamTenant tenant = repository.findTenantForUpdate(tenantId);
if (tenant == null || !tenantIdEquals(tenant, tenantId) || !"MERCHANT".equals(tenant.getTenantType())) {
throw new BizException(ResultCode.NOT_FOUND, "Merchant tenant not found / 企业客户不存在");
}
return tenant;
}
private TenantResponse response(IamTenant tenant) {
return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
@@ -88,10 +88,13 @@ VALUES
(5103010, 0, 'PLATFORM', 'MENU', 5103000, 'platform:warehouse:list', '国内仓/FBA仓', '/master-data/warehouses', NULL, 10, 'ACTIVE', 0),
(5103020, 0, 'PLATFORM', 'MENU', 5103000, 'platform:express:list', '快递公司', '/master-data/express', NULL, 20, 'ACTIVE', 0),
(5103030, 0, 'PLATFORM', 'MENU', 5103000, 'platform:counterparty:list', '往来单位', '/master-data/counterparties', NULL, 30, 'ACTIVE', 0),
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心', '/downloads', NULL, 40, 'ACTIVE', 0),
(5104010, 0, 'PLATFORM', 'MENU', 5104000, 'platform:audit:list', '操作日志', '/audit/logs', NULL, 10, 'ACTIVE', 0),
-- Platform: buttons.
(5100021, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:approve', '审核通过', NULL, NULL, 10, 'ACTIVE', 0),
(5100022, 0, 'PLATFORM', 'BUTTON', 5100020, 'platform:customer:reject', '审核驳回', NULL, NULL, 20, 'ACTIVE', 0),
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', NULL, NULL, 40, 'ACTIVE', 0),
(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', NULL, NULL, 10, 'ACTIVE', 0),
(5101011, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:create', '新增用户', NULL, NULL, 10, 'ACTIVE', 0),
(5101012, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:edit', '编辑用户', NULL, NULL, 20, 'ACTIVE', 0),
(5101013, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:status', '启停用户', NULL, NULL, 30, 'ACTIVE', 0),
@@ -198,7 +201,8 @@ INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT 4000002, id, 0 FROM iam_permission
WHERE permission_code IN (
'platform:customer', 'platform:customer:list', 'platform:customer:review',
'platform:customer:approve', 'platform:customer:reject'
'platform:customer:approve', 'platform:customer:reject', 'platform:customer:export',
'platform:master-data', 'platform:download:list'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
@@ -18,6 +18,16 @@ UPDATE iam_permission
SET built_in = 1
WHERE id BETWEEN 5000000 AND 5399999 AND built_in = 0;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
route_path, sort_no, status, built_in, deleted)
VALUES
(5103040, 0, 'PLATFORM', 'MENU', 5103000, 'platform:download:list', '下载中心',
'/downloads', 40, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), route_path = VALUES(route_path), sort_no = VALUES(sort_no),
status = 'ACTIVE', built_in = 1, deleted = 0;
INSERT INTO iam_permission
(id, tenant_id, app_code, permission_type, parent_id, permission_code, permission_name,
sort_no, status, built_in, deleted)
@@ -25,6 +35,7 @@ VALUES
(5100014, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:view', '查看企业客户档案', 15, 'ACTIVE', 1, 0),
(5100012, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:edit', '编辑企业客户', 20, 'ACTIVE', 1, 0),
(5100013, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:status', '启停企业客户', 30, 'ACTIVE', 1, 0),
(5100015, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:export', '导出企业客户', 40, 'ACTIVE', 1, 0),
(5101015, 0, 'PLATFORM', 'BUTTON', 5101010, 'platform:user:grant-role', '分配内部用户角色', 50, 'ACTIVE', 1, 0),
(5101032, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:edit', '编辑权限资源', 20, 'ACTIVE', 1, 0),
(5101033, 0, 'PLATFORM', 'BUTTON', 5101030, 'platform:permission:status', '启停权限资源', 30, 'ACTIVE', 1, 0),
@@ -35,7 +46,8 @@ VALUES
(5103024, 0, 'PLATFORM', 'BUTTON', 5103020, 'platform:express:import', '批量导入快递公司', 40, 'ACTIVE', 1, 0),
(5103031, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:create', '新增往来单位', 10, 'ACTIVE', 1, 0),
(5103032, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:edit', '编辑往来单位', 20, 'ACTIVE', 1, 0),
(5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0)
(5103033, 0, 'PLATFORM', 'BUTTON', 5103030, 'platform:counterparty:status', '启停往来单位', 30, 'ACTIVE', 1, 0),
(5103041, 0, 'PLATFORM', 'BUTTON', 5103040, 'platform:download:all', '查看全部下载任务', 10, 'ACTIVE', 1, 0)
ON DUPLICATE KEY UPDATE
permission_name = VALUES(permission_name), sort_no = VALUES(sort_no), status = 'ACTIVE',
built_in = 1, deleted = 0;
@@ -49,10 +61,24 @@ WHERE tenant_id = 0 AND app_code = 'PLATFORM'
'platform:customer:edit', 'platform:customer:status', 'platform:user:grant-role',
'platform:permission:edit', 'platform:permission:status', 'platform:permission:delete',
'platform:express:list', 'platform:express:create', 'platform:express:edit', 'platform:express:status', 'platform:express:import',
'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status'
'platform:counterparty:list', 'platform:counterparty:create', 'platform:counterparty:edit', 'platform:counterparty:status',
'platform:customer:export', 'platform:download:list', 'platform:download:all'
)
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- 已有客户运营角色继续具备客户导出与本人下载任务查询能力,但不授予查看全部任务。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT DISTINCT existing_grant.role_id, added_permission.id, existing_grant.tenant_id
FROM iam_role_permission existing_grant
JOIN iam_permission existing_permission ON existing_permission.id = existing_grant.permission_id
JOIN iam_permission added_permission
ON added_permission.tenant_id = 0 AND added_permission.app_code = 'PLATFORM'
AND added_permission.permission_code IN ('platform:customer:export', 'platform:download:list')
WHERE existing_permission.tenant_id = 0
AND existing_permission.app_code = 'PLATFORM'
AND existing_permission.permission_code IN ('platform:customer:list', 'platform:customer:review')
ON DUPLICATE KEY UPDATE tenant_id = VALUES(tenant_id);
-- 详情查看从列表和审核权限中拆分;升级时保留既有运营角色的查询能力。
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
SELECT DISTINCT existing_grant.role_id, view_permission.id, existing_grant.tenant_id
+11 -1
View File
@@ -464,6 +464,16 @@ paths:
responses:
'200': {$ref: '#/components/responses/Success'}
'403': {$ref: '#/components/responses/Forbidden'}
/api/merchant/v1/iam/tenant/qualifications/submit-review:
post:
tags: [IAM Resources / IAM 资源]
summary: Submit enterprise qualifications for review / 提交企业资质审核
description: Uploading or replacing a qualification keeps the tenant in draft state. This endpoint creates the review only after all four required qualification types have been provided. / 上传或替换资质不会自动送审;四类必填资质齐全后由此接口创建审核单。
responses:
'200': {$ref: '#/components/responses/TenantSuccess'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
/api/{app}/v1/iam/roles:
parameters:
- {$ref: '#/components/parameters/App'}
@@ -749,7 +759,7 @@ components:
documentUrl: {type: string}
validFrom: {type: string, format: date}
validTo: {type: string, format: date, nullable: true}
status: {type: string, enum: [PENDING, APPROVED, REJECTED]}
status: {type: string, enum: [PENDING, APPROVED, REJECTED, SUPERSEDED]}
reviewComment: {type: string}
RegisterRequest:
type: object
@@ -0,0 +1,41 @@
package com.baiyue.iam.controller;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
import com.baiyue.iam.service.MerchantOnboardingService;
import com.baiyue.iam.service.ProfileResourceService;
import com.baiyue.iam.service.TenantService;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class InternalCustomerExportContractTest {
@Test
void rejectsMissingInternalTokenAndAcceptsConfiguredToken() {
TenantService service = mock(TenantService.class);
InternalTenantController controller = new InternalTenantController();
ReflectionTestUtils.setField(controller, "onboardingService", mock(MerchantOnboardingService.class));
ReflectionTestUtils.setField(controller, "profileResourceService", mock(ProfileResourceService.class));
ReflectionTestUtils.setField(controller, "tenantService", service);
ReflectionTestUtils.setField(controller, "internalTokenValue", "internal-token");
controller.initializeToken();
BizException denied = assertThrows(BizException.class,
() -> controller.exportCustomers(null, null, 100, null, null));
assertEquals(ResultCode.FORBIDDEN, denied.getResultCode());
when(service.exportMerchants(null, 100, "柏悦", "ACTIVE"))
.thenReturn(new CustomerExportBatch(List.of(), null, false));
var response = controller.exportCustomers("internal-token", null, 100, "柏悦", "ACTIVE");
assertEquals(ResultCode.SUCCESS.code(), response.code());
verify(service).exportMerchants(null, 100, "柏悦", "ACTIVE");
}
}
@@ -35,6 +35,7 @@ class QualificationUploadContractTest {
assertFalse(pathContract.contains("\n post:"));
assertFalse(yaml.contains(" QualificationRequest:"));
assertTrue(yaml.contains(" /api/merchant/v1/iam/tenant/qualifications/submit-review:"));
}
}
}
@@ -48,9 +48,11 @@ class CustomerProfileMigrationContractTest {
.contains("CALL regression_assert_table('iam_tenant')")
.contains("CREATE PROCEDURE regression_add_iam_column")
.contains("platform:customer:view")
.contains("platform:customer:export")
.contains("platform:download:list")
.contains("'IAM客户档案字段' AS check_item")
.contains("9 AS expected_count")
.contains("44 AS expected_count")
.contains("47 AS expected_count")
.contains("MIGRATION_COMPLETED");
assertThat(instructions)
.contains("phase1-platform-master-data-and-permission-management.sql")
@@ -0,0 +1,46 @@
package com.baiyue.iam.service;
import com.baiyue.iam.dto.OnboardingDtos.QualificationSubmissionRequest;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.TenantRepository;
import org.junit.jupiter.api.Test;
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.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class MerchantQualificationUploadTest {
@Test
void uploadsOneDraftQualificationWithoutCreatingAReview() {
TenantRepository tenantRepository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
ProfileResourceService service = new ProfileResourceService();
ReflectionTestUtils.setField(service, "tenantRepository", tenantRepository);
ReflectionTestUtils.setField(service, "repository", profileRepository);
IamTenant tenant = new IamTenant();
tenant.setId(10001L);
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
var response = service.submitQualification(new QualificationSubmissionRequest(
10001L, "LEGAL_PERSON_ID", null,
"/api/merchant/v1/files/qualifications/legal-person.png", LocalDate.of(2030, 1, 1), false));
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
assertNull(response.reviewId());
verify(profileRepository).insertQualification(any());
verify(tenantRepository, never()).insertReview(any());
}
}
@@ -29,7 +29,7 @@ class PlatformQualificationRegistrationTest {
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(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of());
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
@@ -59,7 +59,7 @@ class PlatformQualificationRegistrationTest {
existing.setId(88L); existing.setTenantId(10001L); existing.setQualificationType("LEGAL_PERSON_ID");
existing.setStatus("PENDING");
existing.setDocumentUrl("/api/merchant/v1/files/qualifications/old-file.pdf");
when(tenantRepository.findTenant(10001L)).thenReturn(tenant);
when(tenantRepository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(repository.findQualificationsForUpdate(10001L)).thenReturn(List.of(existing));
var response = service.registerPlatformQualification(new PlatformQualificationRegistrationRequest(
@@ -68,7 +68,7 @@ class PlatformQualificationRegistrationTest {
ArgumentCaptor<IamTenantQualification> captor = ArgumentCaptor.forClass(IamTenantQualification.class);
verify(repository, times(2)).saveQualification(captor.capture());
assertEquals("REJECTED", existing.getStatus());
assertEquals("SUPERSEDED", existing.getStatus());
assertEquals("平台在送审前替换文件", existing.getReviewComment());
assertEquals(existing.getDocumentUrl(), response.replacedDocumentUrl());
assertEquals("PENDING", captor.getAllValues().get(1).getStatus());
@@ -0,0 +1,42 @@
package com.baiyue.iam.service;
import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.TenantRepository;
import org.junit.jupiter.api.Test;
import org.springframework.test.util.ReflectionTestUtils;
import java.util.List;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
class TenantCustomerExportTest {
@Test
void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
TenantRepository repository = mock(TenantRepository.class);
TenantService service = new TenantService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "profileRepository", mock(ProfileResourceRepository.class));
IamTenant tenant = new IamTenant();
tenant.setId(101L);
tenant.setTenantCode("TEN101");
tenant.setTenantName("柏悦物流");
tenant.setContactPhone("13800138000");
tenant.setContactEmail("ops@example.com");
tenant.setStatus("ACTIVE");
when(repository.exportMerchants(100L, 50, "柏悦", "ACTIVE")).thenReturn(List.of(tenant));
var result = service.exportMerchants(100L, 50, "柏悦", "ACTIVE");
assertEquals("101", result.records().get(0).id());
assertEquals("13800138000", result.records().get(0).contactPhone());
assertEquals("ops@example.com", result.records().get(0).contactEmail());
assertEquals(101L, result.nextCursor());
assertFalse(result.hasMore());
verify(repository).exportMerchants(100L, 50, "柏悦", "ACTIVE");
}
}
@@ -125,16 +125,17 @@ class TenantServiceTest {
}
@Test
void explicitSubmissionRequiresBothMandatoryQualificationsAndCreatesReview() {
void explicitSubmissionRequiresAllPrototypeQualificationsAndCreatesReview() {
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(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"),
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
var submitted = service.submitMerchantReview(10001L);
@@ -143,6 +144,23 @@ class TenantServiceTest {
verify(repository).saveTenant(tenant);
}
@Test
void explicitSubmissionRejectsAnIncompleteQualificationSet() {
TenantRepository repository = mock(TenantRepository.class);
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
TenantService service = new TenantService();
ReflectionTestUtils.setField(service, "repository", repository);
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
when(repository.findTenantForUpdate(10001L)).thenReturn(merchant(10001L, "PROFILE_REQUIRED"));
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID")));
BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L));
assertEquals(ResultCode.BAD_REQUEST, exception.getResultCode());
verify(repository, never()).insertReview(any());
}
@Test
void 企业激活不等于具备下单资格仍需已审核资质() {
TenantRepository repository = mock(TenantRepository.class);