优化
This commit is contained in:
+2
-2
@@ -35,7 +35,7 @@
|
||||
- 平台客户档案维护使用 `PlatformTenantController` 的列表、详情、新建、编辑、启停接口;详情和维护仅接受 `tenant_type=MERCHANT`。PENDING/REJECTED 只能由审核流程变更,不得由启停接口绕过。
|
||||
- 平台代客户上传资质的公开 multipart 入口在 File 服务;IAM 的 `/internal/iam/platform-qualifications` 只登记元数据。当前仅 `PROFILE_REQUIRED/REJECTED` 可上传,同类型重复需显式 `replace=true`;替换时旧记录标记 REJECTED 并把旧 `documentUrl` 返回 File 清理。
|
||||
- 平台资质登记在事务内通过 `ProfileResourceRepository.findQualificationsForUpdate` 锁定目标租户现有资质,避免并发上传或替换产生多个有效同类型记录;该查询不得改回普通无锁列表。
|
||||
- 企业送审和审核通过都要求 `BUSINESS_LICENSE` 与 `LEGAL_PERSON_ID`;审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,条件更新负责并发防重。
|
||||
- 企业送审和审核通过都要求 `BUSINESS_LICENSE`、`LEGAL_PERSON_ID`、`FREIGHT_FORWARDER_CERTIFICATE` 与 `BANK_ACCOUNT_LICENSE` 四类资质均为 `PENDING`;审核由 `TenantService.review()` 同事务更新审核单、租户和所有 PENDING 资质,行锁负责并发防重。
|
||||
- 平台客户列表使用 `platform:customer:list` 并脱敏电话、邮箱;完整详情、平台地址和平台资质只读查询统一使用独立的 `platform:customer:view`,不得复用 `platform:customer:review`。
|
||||
- 权限资源的 code/type 不可变;只有 `built_in=0` 的自定义资源可编辑、启停或删除,删除前必须无子节点且无角色绑定。
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
- 平台内部用户的公开主键是 `membershipId`;列表必须回显 `roleIds`,密码只允许通过 DTO 转交 Auth,禁止进入 IAM 实体、日志或审计快照。
|
||||
- 平台内部用户角色分配使用 `PUT /api/platform/v1/iam/internal-users/{membershipId}/roles`,仅允许平台租户下已启用的平台角色;空 `roleIds` 表示清空。
|
||||
- 商户成员新增、编辑采用字段级授权:基础资料分别校验 `merchant:member:create`、`merchant:member:edit`;创建时非空角色、编辑时角色集合发生任何变化(含空数组清空)还必须校验 `merchant:member:grant-role`。详情和列表实时回显当前 `roleIds`,比较和写入均限定当前租户。
|
||||
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须至少存在一份 `APPROVED` 资质,因此允许“已激活但暂不可下单”的中间业务状态。
|
||||
- 企业 `ACTIVE` 只表示平台审核已通过;下单资格还必须四类必填资质全部为 `APPROVED`。任意一类缺失、驳回、已替换或过期都必须由服务端阻止提交订单。
|
||||
- SQL 增量保持可重复部署顺序,修改 Entity 时同步检查 Mapper、Repository、DTO 与种子数据。`phase1-platform-master-data-and-permission-management.sql` 必须在 `phase1-admin-rbac.sql` 和 `rbac-role-management-v2.sql` 之后执行;它标记既有系统资源并补齐平台客户、快递公司、往来单位与权限资源管理权限。
|
||||
- 存量库新增一期客户档案字段时执行 `phase1-customer-profile.sql`;该脚本只扩展 `iam_tenant`,可重复执行,不得把运单数、财务金额、套餐计费、代客登录或物理删除能力并入 IAM。
|
||||
- `deploy/scripts/initialize-test-databases.sh` 的 IAM 客户档案和 Warehouse 主数据脚本顺序必须与当前文件保持一致;测试环境正式增量仍以 `../deploy/sql/README.md` 指定的统一迁移为准。
|
||||
|
||||
@@ -31,6 +31,14 @@ Merge the packages under `src/main/java` into `D:/baiyue/iam/src/main/java`; exe
|
||||
| GET | `/api/{app}/v1/iam/authorization/permissions` | PLATFORM/MERCHANT | Current user's active permission codes / 当前用户有效权限编码 |
|
||||
| GET/POST | `/api/{app}/v1/iam/permissions` | PLATFORM/MERCHANT | Permission tree / 权限树 |
|
||||
|
||||
## 审核通过初始化扩展
|
||||
|
||||
企业审核通过后的默认数据统一通过 `TenantApprovalInitializer` 扩展链初始化,`TenantService` 不直接依赖具体初始化事项。客户端自主注册时,注册账号已经创建 OWNER 成员并绑定 `MERCHANT_OWNER`,审核通过后继续沿用该账号;只有 Admin 平台代建且没有 OWNER 的企业,才由 `TenantAdministratorProvisioningService` 创建管理员账号。后续规则、费用方案或其他默认角色应各自新增初始化器,并通过 `@Order` 声明依赖顺序。
|
||||
|
||||
初始化器及其补偿必须幂等。任一初始化器失败时,协调器按完成顺序的逆序执行补偿;IAM 本地事务最终回滚时也会触发同一补偿链。初始化器名称必须非空且全局唯一,初始密码等敏感结果只能通过审核响应一次性返回,不得持久化到 IAM 或写入日志。
|
||||
|
||||
管理员初始化会验证既有 OWNER 成员、IAM 用户及 Auth 账号均处于可用状态且租户、成员和应用范围一致。历史数据只有 OWNER/IAM 身份而缺少 Auth 账号时,复用原身份补建认证账号;停用或范围冲突的数据会阻止审核通过,避免激活后没有可登录管理员。部署前必须执行 `phase1-onboarding.sql`;测试环境统一执行 `deploy/sql/2026-07-31一期回归测试环境增量迁移.sql`,确保全局 `MERCHANT_OWNER` 模板及权限完整。
|
||||
|
||||
OpenAPI is synchronized in `openapi/iam-v1.yaml`.
|
||||
|
||||
## 商户成员角色字段级授权
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.iam.service.CustomerListExportProviderService;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestHeader;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
|
||||
/** Internal provider endpoint consumed only by File Service. */
|
||||
@RestController
|
||||
@RequestMapping("/internal/exports/v1/CUSTOMER_LIST")
|
||||
public class CustomerListExportProviderController {
|
||||
private final CustomerListExportProviderService service;
|
||||
private final byte[] internalToken;
|
||||
|
||||
public CustomerListExportProviderController(
|
||||
CustomerListExportProviderService service,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this.service = service;
|
||||
this.internalToken = internalToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
@PostMapping("/batches")
|
||||
public ApiResponse<ExportBatchResponse> batch(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestBody ExportBatchRequest request) {
|
||||
verifyToken(token);
|
||||
return ApiResponse.success(service.exportBatch(request));
|
||||
}
|
||||
|
||||
private void verifyToken(String token) {
|
||||
byte[] supplied = token == null ? new byte[0] : token.getBytes(StandardCharsets.UTF_8);
|
||||
if (!MessageDigest.isEqual(internalToken, supplied)) {
|
||||
throw new BizException(ResultCode.FORBIDDEN, "Invalid internal service token");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -48,6 +48,15 @@ public class InternalTenantController {
|
||||
return ApiResponse.success(onboardingService.register(request));
|
||||
}
|
||||
|
||||
@DeleteMapping("/merchant-registrations/{authUserId}/compensation")
|
||||
public ApiResponse<Void> compensateRegistration(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@PathVariable long authUserId) {
|
||||
verify(token);
|
||||
onboardingService.compensateRegistration(authUserId);
|
||||
return ApiResponse.success(null);
|
||||
}
|
||||
|
||||
@PostMapping("/qualifications")
|
||||
public ApiResponse<QualificationSubmissionResponse> submitQualification(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.baiyue.iam.dto.TenantDtos.ReviewRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.TenantResponse;
|
||||
import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
|
||||
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.service.TenantGuard;
|
||||
import com.baiyue.iam.service.TenantService;
|
||||
@@ -80,7 +81,7 @@ public class PlatformTenantController {
|
||||
public ApiResponse<TenantResponse> register(@Valid @RequestBody RegisterRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.register(request));
|
||||
return ApiResponse.success(service.register(request, user.userId()));
|
||||
}
|
||||
|
||||
@PutMapping("/tenants/{tenantId:\\d+}")
|
||||
@@ -126,18 +127,17 @@ public class PlatformTenantController {
|
||||
|
||||
@PostMapping("/reviews/{id}/approve")
|
||||
@PreAuthorize("hasAuthority('platform:customer:approve')")
|
||||
@Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve a pending review and activate the tenant. The operation is audited. / 审核通过并激活租户,操作写入审计日志。")
|
||||
@Operation(summary = "Approve enterprise registration / 审核通过企业注册", description = "Approve and activate the tenant. Client registrations keep their registration account as owner; only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活租户;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动开户并返回一次性凭据。")
|
||||
@ApiResponses({
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Approved / 审核通过"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "Review not found / 审核单不存在"),
|
||||
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Review already completed / 审核已完成")
|
||||
})
|
||||
public ApiResponse<Void> approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
|
||||
public ApiResponse<ApprovalResponse> approve(@Parameter(description = "Review ticket ID / 审核单 ID", required = true, example = "190000000000000001") @PathVariable long id,
|
||||
@RequestBody ReviewRequest request,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
service.review(id, user.userId(), true, request.comment());
|
||||
return ApiResponse.success(null);
|
||||
return ApiResponse.success(service.review(id, user.userId(), true, request.comment()));
|
||||
}
|
||||
|
||||
@PostMapping("/reviews/{id}/reject")
|
||||
|
||||
@@ -25,6 +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 = "18-character unified social credit code / 18 位统一社会信用代码", example = "91440300MA5EXAMPLE") @NotBlank @Pattern(regexp = "[0-9A-Z]{18}") String unifiedSocialCreditCode,
|
||||
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "13800138000") @NotBlank String contactPhone,
|
||||
@@ -45,6 +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 = "Primary contact / 联系人", example = "李四") @NotBlank String contactName,
|
||||
@Schema(description = "Contact phone / 联系电话", example = "13900139000") @NotBlank String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "service@example.com") @Email String contactEmail
|
||||
@@ -55,6 +57,7 @@ public final class TenantDtos {
|
||||
public record UpdateTenantRequest(
|
||||
@NotBlank String tenantName,
|
||||
@NotBlank String legalName,
|
||||
@NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@NotBlank String contactName,
|
||||
@NotBlank String contactPhone,
|
||||
@Email String contactEmail,
|
||||
@@ -80,9 +83,17 @@ public final class TenantDtos {
|
||||
@Schema(description = "Approval or rejection comment / 审核意见", example = "资料完整,审核通过") String comment
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantApprovalResponse", description = "Enterprise approval result and one-time administrator credentials / 企业审核结果及一次性管理员凭据")
|
||||
public record ApprovalResponse(
|
||||
@Schema(description = "Tenant ID / 企业租户 ID", example = "190000000000000001") Long tenantId,
|
||||
@Schema(description = "Whether this approval created the administrator / 本次审核是否新建管理员") boolean administratorCreated,
|
||||
@Schema(description = "Generated login username; null when an owner already exists / 自动生成的登录账号,已有所有者时为空", nullable = true) String administratorUsername,
|
||||
@Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword
|
||||
) { }
|
||||
|
||||
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
|
||||
public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
|
||||
String legalName, String unifiedSocialCreditCode, String contactName,
|
||||
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
|
||||
String contactPhone, String contactEmail, String contactWechat,
|
||||
String servicePackage, String salesOwnerName, Integer paymentTermDays, String enterpriseType,
|
||||
LocalDate establishedDate, BigDecimal registeredCapital,
|
||||
|
||||
@@ -23,6 +23,7 @@ public class IamTenant extends BaseEntity {
|
||||
private String tenantType;
|
||||
private String status;
|
||||
private String legalName;
|
||||
private String legalRepresentative;
|
||||
private String unifiedSocialCreditCode;
|
||||
private String contactName;
|
||||
private String contactPhone;
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.mapper.IamTenantMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import org.springframework.stereotype.Repository;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/** Read-only customer queries used by the generic export provider. */
|
||||
@Repository
|
||||
public class CustomerListExportProviderRepository {
|
||||
private final IamTenantMapper tenantMapper;
|
||||
|
||||
public CustomerListExportProviderRepository(IamTenantMapper tenantMapper) {
|
||||
this.tenantMapper = tenantMapper;
|
||||
}
|
||||
|
||||
public long maximumId(String keyword, String status) {
|
||||
LambdaQueryWrapper<IamTenant> wrapper = filters(keyword, status)
|
||||
.select(IamTenant::getId)
|
||||
.orderByDesc(IamTenant::getId)
|
||||
.last("LIMIT 1");
|
||||
IamTenant tenant = tenantMapper.selectOne(wrapper);
|
||||
return tenant == null || tenant.getId() == null ? 0L : tenant.getId();
|
||||
}
|
||||
|
||||
public long estimateRows(long maximumId, String keyword, String status) {
|
||||
if (maximumId <= 0) return 0L;
|
||||
return tenantMapper.selectCount(filters(keyword, status).le(IamTenant::getId, maximumId));
|
||||
}
|
||||
|
||||
public List<IamTenant> findBatch(Long cursor, long maximumId, int fetchSize,
|
||||
String keyword, String status) {
|
||||
if (maximumId <= 0) return List.of();
|
||||
return tenantMapper.selectList(filters(keyword, status)
|
||||
.gt(cursor != null, IamTenant::getId, cursor)
|
||||
.le(IamTenant::getId, maximumId)
|
||||
.orderByAsc(IamTenant::getId)
|
||||
.last("LIMIT " + fetchSize));
|
||||
}
|
||||
|
||||
private LambdaQueryWrapper<IamTenant> filters(String keyword, String status) {
|
||||
LambdaQueryWrapper<IamTenant> wrapper = new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(StringUtils.hasText(status), IamTenant::getStatus, status);
|
||||
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 wrapper;
|
||||
}
|
||||
}
|
||||
@@ -39,6 +39,12 @@ public class MemberRepository {
|
||||
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, tenantId).eq(IamMembership::getId, id).last("LIMIT 1"));
|
||||
}
|
||||
public IamMembership findOwnerByTenant(long tenantId) {
|
||||
return membershipMapper.selectOne(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getTenantId, tenantId)
|
||||
.eq(IamMembership::getMemberType, "OWNER")
|
||||
.last("LIMIT 1"));
|
||||
}
|
||||
public IamMembership findByTenantAndAuthUser(long tenantId, Long authUserId) {
|
||||
IamUser user = findUserByAuthId(authUserId);
|
||||
if (user == null) return null;
|
||||
|
||||
@@ -30,6 +30,12 @@ public class TenantRepository {
|
||||
.eq(IamTenant::getUnifiedSocialCreditCode, creditCode)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByCreditCodeForOtherTenant(String creditCode, long tenantId) {
|
||||
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getUnifiedSocialCreditCode, creditCode)
|
||||
.ne(IamTenant::getId, tenantId)) > 0;
|
||||
}
|
||||
|
||||
public IamTenant findTenant(Long tenantId) { return tenantMapper.selectById(tenantId); }
|
||||
/**
|
||||
* Serializes the draft qualification workflow for one enterprise. The file service and the
|
||||
@@ -47,6 +53,11 @@ public class TenantRepository {
|
||||
.orderByAsc(IamTenant::getTenantName));
|
||||
}
|
||||
public IamTenantReview findReview(Long reviewId) { return reviewMapper.selectById(reviewId); }
|
||||
public IamTenantReview findReviewForUpdate(Long reviewId) {
|
||||
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
|
||||
.eq(IamTenantReview::getId, reviewId)
|
||||
.last("FOR UPDATE"));
|
||||
}
|
||||
public IamTenantReview findPendingReviewByTenant(Long tenantId) {
|
||||
return reviewMapper.selectOne(new LambdaQueryWrapper<IamTenantReview>()
|
||||
.eq(IamTenantReview::getTenantId, tenantId)
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumn;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.repository.CustomerListExportProviderRepository;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.GeneralSecurityException;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
/** Implements the generic {@code CUSTOMER_LIST} export contract without writing export files. */
|
||||
@Service
|
||||
public class CustomerListExportProviderService {
|
||||
static final int SCHEMA_VERSION = 1;
|
||||
private static final int MAX_BATCH_SIZE = 500;
|
||||
private static final int MAX_SNAPSHOT_TOKEN_LENGTH = 512;
|
||||
private static final Set<String> STATUSES = Set.of(
|
||||
"PROFILE_REQUIRED", "PENDING", "ACTIVE", "REJECTED", "DISABLED");
|
||||
private static final Set<String> PARAMETER_KEYS = Set.of("keyword", "status");
|
||||
private static final List<ExportColumn> COLUMNS = List.of(
|
||||
column("id", "客户ID", ExportColumnType.STRING, 20),
|
||||
column("tenantCode", "客户编码", ExportColumnType.STRING, 18),
|
||||
column("tenantName", "公司名称", ExportColumnType.STRING, 28),
|
||||
column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28),
|
||||
column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24),
|
||||
column("enterpriseType", "企业类型", ExportColumnType.STRING, 16),
|
||||
column("establishedDate", "成立日期", ExportColumnType.DATE, 14),
|
||||
column("registeredCapital", "注册资本", ExportColumnType.DECIMAL, 16),
|
||||
column("registeredCapitalCurrency", "币种", ExportColumnType.STRING, 10),
|
||||
column("registeredAddress", "注册地址", ExportColumnType.STRING, 32),
|
||||
column("contactName", "联系人", ExportColumnType.STRING, 16),
|
||||
column("contactPhone", "联系电话", ExportColumnType.STRING, 18),
|
||||
column("contactEmail", "联系邮箱", ExportColumnType.STRING, 24),
|
||||
column("contactWechat", "联系微信", ExportColumnType.STRING, 18),
|
||||
column("salesOwnerName", "销售人员", ExportColumnType.STRING, 16),
|
||||
column("servicePackage", "客户套餐", ExportColumnType.STRING, 16),
|
||||
column("paymentTermDays", "账期(天)", ExportColumnType.INTEGER, 12),
|
||||
column("status", "状态", ExportColumnType.STRING, 14),
|
||||
column("createdAt", "入驻时间", ExportColumnType.DATETIME, 21),
|
||||
column("approvedAt", "审批时间", ExportColumnType.DATETIME, 21));
|
||||
private static final String SCHEMA_HASH = schemaHash(COLUMNS);
|
||||
|
||||
private final CustomerListExportProviderRepository repository;
|
||||
private final byte[] snapshotSigningKey;
|
||||
|
||||
public CustomerListExportProviderService(
|
||||
CustomerListExportProviderRepository repository,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalServiceToken) {
|
||||
this.repository = repository;
|
||||
this.snapshotSigningKey = internalServiceToken.getBytes(StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
public ExportBatchResponse exportBatch(ExportBatchRequest request) {
|
||||
ValidatedRequest validated = validate(request);
|
||||
boolean firstBatch = validated.snapshotToken() == null;
|
||||
long maximumId = firstBatch
|
||||
? repository.maximumId(validated.keyword(), validated.status())
|
||||
: verifySnapshot(validated);
|
||||
String snapshotToken = firstBatch ? signSnapshot(validated, maximumId) : validated.snapshotToken();
|
||||
long totalRows = firstBatch
|
||||
? repository.estimateRows(maximumId, validated.keyword(), validated.status())
|
||||
: 0L;
|
||||
|
||||
List<IamTenant> source = repository.findBatch(validated.cursor(), maximumId,
|
||||
validated.limit() + 1, validated.keyword(), validated.status());
|
||||
boolean hasMore = source.size() > validated.limit();
|
||||
List<IamTenant> page = hasMore ? source.subList(0, validated.limit()) : source;
|
||||
String nextCursor = page.isEmpty()
|
||||
? validated.cursorText()
|
||||
: String.valueOf(page.get(page.size() - 1).getId());
|
||||
|
||||
return new ExportBatchResponse(SCHEMA_VERSION, SCHEMA_HASH, snapshotToken,
|
||||
firstBatch ? COLUMNS : List.of(), page.stream().map(this::row).toList(),
|
||||
nextCursor, hasMore, firstBatch ? totalRows : null);
|
||||
}
|
||||
|
||||
private ValidatedRequest validate(ExportBatchRequest request) {
|
||||
if (request == null) throw invalid("Export batch request is required");
|
||||
requirePositiveId(request.taskId(), "taskId");
|
||||
if (!"0".equals(request.tenantId())) {
|
||||
throw invalid("CUSTOMER_LIST only supports platform tenant 0");
|
||||
}
|
||||
if (!"PLATFORM".equals(request.appCode())) {
|
||||
throw invalid("CUSTOMER_LIST only supports PLATFORM appCode");
|
||||
}
|
||||
requirePositiveId(request.requestedBy(), "requestedBy");
|
||||
if (request.dispatchVersion() <= 0) throw invalid("dispatchVersion must be positive");
|
||||
if (request.limit() < 1 || request.limit() > MAX_BATCH_SIZE) {
|
||||
throw invalid("limit must be between 1 and 500");
|
||||
}
|
||||
|
||||
Map<String, Object> parameters = request.parameters() == null ? Map.of() : request.parameters();
|
||||
if (!PARAMETER_KEYS.containsAll(parameters.keySet())) {
|
||||
throw invalid("CUSTOMER_LIST contains unsupported parameters");
|
||||
}
|
||||
String keyword = optionalString(parameters.get("keyword"), "keyword");
|
||||
if (keyword != null && keyword.length() > 128) throw invalid("keyword must not exceed 128 characters");
|
||||
String status = optionalString(parameters.get("status"), "status");
|
||||
if (status != null && !STATUSES.contains(status)) throw invalid("CUSTOMER_LIST status is invalid");
|
||||
|
||||
String snapshotToken = normalize(request.snapshotToken());
|
||||
if (snapshotToken != null && snapshotToken.length() > MAX_SNAPSHOT_TOKEN_LENGTH) {
|
||||
throw invalid("snapshotToken must not exceed 512 characters");
|
||||
}
|
||||
Long cursor = parseCursor(request.cursor());
|
||||
if (snapshotToken == null && cursor != null) {
|
||||
throw invalid("cursor requires a snapshotToken");
|
||||
}
|
||||
return new ValidatedRequest(request, keyword, status, cursor, normalize(request.cursor()), snapshotToken);
|
||||
}
|
||||
|
||||
private long verifySnapshot(ValidatedRequest request) {
|
||||
String[] parts = request.snapshotToken().split("\\.", -1);
|
||||
if (parts.length != 3 || !"v1".equals(parts[0])) throw invalid("snapshotToken is invalid");
|
||||
long maximumId;
|
||||
byte[] suppliedSignature;
|
||||
try {
|
||||
maximumId = Long.parseLong(parts[1]);
|
||||
suppliedSignature = Base64.getUrlDecoder().decode(parts[2]);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw invalid("snapshotToken is invalid");
|
||||
}
|
||||
if (maximumId < 0) throw invalid("snapshotToken is invalid");
|
||||
byte[] expectedSignature = hmac(snapshotSubject(request, maximumId));
|
||||
if (!MessageDigest.isEqual(expectedSignature, suppliedSignature)) {
|
||||
throw invalid("snapshotToken does not match this export task");
|
||||
}
|
||||
if (request.cursor() != null && request.cursor() > maximumId) {
|
||||
throw invalid("cursor exceeds the export snapshot");
|
||||
}
|
||||
return maximumId;
|
||||
}
|
||||
|
||||
private String signSnapshot(ValidatedRequest request, long maximumId) {
|
||||
String signature = Base64.getUrlEncoder().withoutPadding()
|
||||
.encodeToString(hmac(snapshotSubject(request, maximumId)));
|
||||
return "v1." + maximumId + "." + signature;
|
||||
}
|
||||
|
||||
private String snapshotSubject(ValidatedRequest request, long maximumId) {
|
||||
ExportBatchRequest source = request.source();
|
||||
return String.join("\n", "CUSTOMER_LIST", source.taskId(), source.tenantId(), source.appCode(),
|
||||
source.requestedBy(), String.valueOf(source.dispatchVersion()),
|
||||
request.keyword() == null ? "" : request.keyword(),
|
||||
request.status() == null ? "" : request.status(), String.valueOf(maximumId));
|
||||
}
|
||||
|
||||
private byte[] hmac(String value) {
|
||||
try {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(snapshotSigningKey, "HmacSHA256"));
|
||||
return mac.doFinal(value.getBytes(StandardCharsets.UTF_8));
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new IllegalStateException("HmacSHA256 is unavailable", exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Map<String, Object> row(IamTenant tenant) {
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("id", String.valueOf(tenant.getId()));
|
||||
row.put("tenantCode", tenant.getTenantCode());
|
||||
row.put("tenantName", tenant.getTenantName());
|
||||
row.put("legalName", tenant.getLegalName());
|
||||
row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode());
|
||||
row.put("enterpriseType", tenant.getEnterpriseType());
|
||||
row.put("establishedDate", tenant.getEstablishedDate());
|
||||
row.put("registeredCapital", tenant.getRegisteredCapital());
|
||||
row.put("registeredCapitalCurrency", tenant.getRegisteredCapitalCurrency());
|
||||
row.put("registeredAddress", tenant.getRegisteredAddress());
|
||||
row.put("contactName", tenant.getContactName());
|
||||
row.put("contactPhone", tenant.getContactPhone());
|
||||
row.put("contactEmail", tenant.getContactEmail());
|
||||
row.put("contactWechat", tenant.getContactWechat());
|
||||
row.put("salesOwnerName", tenant.getSalesOwnerName());
|
||||
row.put("servicePackage", tenant.getServicePackage());
|
||||
row.put("paymentTermDays", tenant.getPaymentTermDays());
|
||||
row.put("status", statusLabel(tenant.getStatus()));
|
||||
row.put("createdAt", tenant.getCreatedAt());
|
||||
row.put("approvedAt", tenant.getApprovedAt());
|
||||
return row;
|
||||
}
|
||||
|
||||
private String statusLabel(String status) {
|
||||
if (status == null) return null;
|
||||
return switch (status) {
|
||||
case "PROFILE_REQUIRED" -> "待完善";
|
||||
case "PENDING" -> "待审核";
|
||||
case "ACTIVE" -> "启用";
|
||||
case "REJECTED" -> "已驳回";
|
||||
case "DISABLED" -> "已停用";
|
||||
default -> status;
|
||||
};
|
||||
}
|
||||
|
||||
private static ExportColumn column(String key, String title, ExportColumnType type, int width) {
|
||||
return new ExportColumn(key, title, type, width);
|
||||
}
|
||||
|
||||
private static String schemaHash(List<ExportColumn> columns) {
|
||||
try {
|
||||
MessageDigest digest = MessageDigest.getInstance("SHA-256");
|
||||
for (ExportColumn column : columns) {
|
||||
digest.update((column.key() + "\u001f" + column.title() + "\u001f" + column.type()
|
||||
+ "\u001f" + column.width() + "\n").getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
return "sha256:" + java.util.HexFormat.of().formatHex(digest.digest());
|
||||
} catch (GeneralSecurityException exception) {
|
||||
throw new ExceptionInInitializerError(exception);
|
||||
}
|
||||
}
|
||||
|
||||
private Long parseCursor(String value) {
|
||||
String normalized = normalize(value);
|
||||
if (normalized == null) return null;
|
||||
try {
|
||||
long parsed = Long.parseLong(normalized);
|
||||
if (parsed < 0) throw invalid("cursor must not be negative");
|
||||
return parsed;
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid("cursor must be a numeric ID");
|
||||
}
|
||||
}
|
||||
|
||||
private void requirePositiveId(String value, String field) {
|
||||
try {
|
||||
if (value == null || Long.parseLong(value) <= 0) throw invalid(field + " must be a positive ID");
|
||||
} catch (NumberFormatException exception) {
|
||||
throw invalid(field + " must be a positive ID");
|
||||
}
|
||||
}
|
||||
|
||||
private String optionalString(Object value, String field) {
|
||||
if (value == null) return null;
|
||||
if (!(value instanceof String text)) throw invalid(field + " must be a string");
|
||||
return normalize(text);
|
||||
}
|
||||
|
||||
private String normalize(String value) {
|
||||
if (value == null || value.isBlank()) return null;
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
private BizException invalid(String message) {
|
||||
return new BizException(ResultCode.VALIDATION_FAILED, message);
|
||||
}
|
||||
|
||||
private record ValidatedRequest(ExportBatchRequest source, String keyword, String status,
|
||||
Long cursor, String cursorText, String snapshotToken) {
|
||||
int limit() { return source.limit(); }
|
||||
}
|
||||
}
|
||||
@@ -9,12 +9,16 @@ import com.baiyue.iam.entity.IamMembershipRole;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamTenantAddress;
|
||||
import com.baiyue.iam.entity.IamTenantQualification;
|
||||
import com.baiyue.iam.entity.IamTenantReview;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamMembershipMapper;
|
||||
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantAddressMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantQualificationMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantReviewMapper;
|
||||
import com.baiyue.iam.mapper.IamUserMapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
@@ -24,6 +28,7 @@ import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Service
|
||||
public class MerchantOnboardingService {
|
||||
@@ -33,6 +38,8 @@ public class MerchantOnboardingService {
|
||||
@Resource private IamRoleMapper roleMapper;
|
||||
@Resource private IamMembershipRoleMapper membershipRoleMapper;
|
||||
@Resource private IamTenantAddressMapper addressMapper;
|
||||
@Resource private IamTenantQualificationMapper qualificationMapper;
|
||||
@Resource private IamTenantReviewMapper reviewMapper;
|
||||
|
||||
@Transactional
|
||||
public MerchantRegistrationResponse register(MerchantRegistrationRequest request) {
|
||||
@@ -61,7 +68,8 @@ public class MerchantOnboardingService {
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
tenant.setLegalName(request.companyName());
|
||||
tenant.setContactName(request.legalPerson());
|
||||
tenant.setLegalRepresentative(request.legalPerson());
|
||||
tenant.setContactName(request.displayName());
|
||||
tenant.setContactPhone(request.legalPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
tenant.setDeleted(false);
|
||||
@@ -112,4 +120,51 @@ public class MerchantOnboardingService {
|
||||
}
|
||||
return new MerchantRegistrationResponse(tenantId, membershipId, tenant.getStatus());
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
@Transactional
|
||||
public void compensateRegistration(long authUserId) {
|
||||
IamUser user = userMapper.selectOne(new LambdaQueryWrapper<IamUser>()
|
||||
.eq(IamUser::getAuthUserId, authUserId)
|
||||
.last("LIMIT 1 FOR UPDATE"));
|
||||
if (user == null) {
|
||||
return;
|
||||
}
|
||||
List<IamMembership> memberships = membershipMapper.selectList(new LambdaQueryWrapper<IamMembership>()
|
||||
.eq(IamMembership::getUserId, user.getId())
|
||||
.orderByAsc(IamMembership::getId)
|
||||
.last("FOR UPDATE"));
|
||||
if (memberships.size() != 1) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"IAM registration compensation is unsafe: membership state changed");
|
||||
}
|
||||
IamMembership membership = memberships.get(0);
|
||||
IamTenant tenant = tenantMapper.selectOne(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getId, membership.getTenantId())
|
||||
.last("FOR UPDATE"));
|
||||
if (tenant == null || !"PROFILE_REQUIRED".equals(tenant.getStatus())
|
||||
|| !"OWNER".equals(membership.getMemberType())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"IAM registration compensation is unsafe: tenant state changed");
|
||||
}
|
||||
long qualificationCount = qualificationMapper.selectCount(new LambdaQueryWrapper<IamTenantQualification>()
|
||||
.eq(IamTenantQualification::getTenantId, tenant.getId()));
|
||||
long reviewCount = reviewMapper.selectCount(new LambdaQueryWrapper<IamTenantReview>()
|
||||
.eq(IamTenantReview::getTenantId, tenant.getId()));
|
||||
if (qualificationCount > 0 || reviewCount > 0) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"IAM registration compensation is unsafe: onboarding has progressed");
|
||||
}
|
||||
|
||||
membershipRoleMapper.delete(new LambdaQueryWrapper<IamMembershipRole>()
|
||||
.eq(IamMembershipRole::getMembershipId, membership.getId()));
|
||||
membershipMapper.deleteById(membership.getId());
|
||||
addressMapper.delete(new LambdaQueryWrapper<IamTenantAddress>()
|
||||
.eq(IamTenantAddress::getTenantId, tenant.getId()));
|
||||
userMapper.deleteById(user.getId());
|
||||
tenantMapper.deleteById(tenant.getId());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ public class ProfileResourceService {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
|
||||
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||
.filter(item -> request.qualificationType().equals(item.getQualificationType())
|
||||
&& isCurrentQualification(item))
|
||||
@@ -82,12 +83,12 @@ public class ProfileResourceService {
|
||||
repository.saveQualification(existing);
|
||||
}
|
||||
IamTenantQualification qualification = createQualification(tenant.getId(),
|
||||
request.qualificationType(), request.certificateNo(), request.documentUrl());
|
||||
request.qualificationType(), 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());
|
||||
if ("BUSINESS_LICENSE".equals(request.qualificationType())
|
||||
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
tenant.setUnifiedSocialCreditCode(certificateNo);
|
||||
tenantRepository.saveTenant(tenant);
|
||||
}
|
||||
return new QualificationSubmissionResponse(qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||
@@ -104,6 +105,7 @@ public class ProfileResourceService {
|
||||
if (!"PROFILE_REQUIRED".equals(tenant.getStatus()) && !"REJECTED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Qualifications can only be uploaded before review / 仅待完善或已驳回客户可上传资质");
|
||||
}
|
||||
String certificateNo = resolveCertificateNo(tenant, request.qualificationType(), request.certificateNo());
|
||||
List<IamTenantQualification> activeQualifications = repository.findQualificationsForUpdate(tenant.getId()).stream()
|
||||
.filter(item -> request.qualificationType().equals(item.getQualificationType())
|
||||
&& isCurrentQualification(item))
|
||||
@@ -121,13 +123,36 @@ public class ProfileResourceService {
|
||||
repository.saveQualification(existing);
|
||||
}
|
||||
IamTenantQualification qualification = createQualification(tenant.getId(), request.qualificationType(),
|
||||
request.certificateNo(), request.documentUrl());
|
||||
certificateNo, request.documentUrl());
|
||||
qualification.setValidTo(request.validTo());
|
||||
repository.saveQualification(qualification);
|
||||
if ("BUSINESS_LICENSE".equals(request.qualificationType())
|
||||
&& !certificateNo.equals(tenant.getUnifiedSocialCreditCode())) {
|
||||
tenant.setUnifiedSocialCreditCode(certificateNo);
|
||||
tenantRepository.saveTenant(tenant);
|
||||
}
|
||||
return new PlatformQualificationRegistrationResponse(
|
||||
qualification.getId(), null, tenant.getStatus(), replacedDocumentUrl);
|
||||
}
|
||||
|
||||
private String resolveCertificateNo(IamTenant tenant, String qualificationType, String submittedCertificateNo) {
|
||||
if (!"BUSINESS_LICENSE".equals(qualificationType)) {
|
||||
return submittedCertificateNo;
|
||||
}
|
||||
String certificateNo = StringUtils.hasText(submittedCertificateNo)
|
||||
? submittedCertificateNo.trim().toUpperCase()
|
||||
: tenant.getUnifiedSocialCreditCode();
|
||||
if (!StringUtils.hasText(certificateNo) || !certificateNo.matches("[0-9A-Z]{18}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
"A valid 18-character unified social credit code is required / 营业执照必须填写有效的18位统一社会信用代码");
|
||||
}
|
||||
if (tenantRepository.existsByCreditCodeForOtherTenant(certificateNo, tenant.getId())) {
|
||||
throw new BizException(ResultCode.CONFLICT,
|
||||
"Unified social credit code already exists / 统一社会信用代码已存在");
|
||||
}
|
||||
return certificateNo;
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.iam.client.AuthAccountClient;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import feign.FeignException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.core.annotation.Order;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/** Provisions the first enterprise owner while keeping credentials in Auth only. */
|
||||
@Service
|
||||
@Order(100)
|
||||
public class TenantAdministratorProvisioningService implements TenantApprovalInitializer {
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantAdministratorProvisioningService.class);
|
||||
private static final char[] UPPER = "ABCDEFGHJKLMNPQRSTUVWXYZ".toCharArray();
|
||||
private static final char[] LOWER = "abcdefghijkmnopqrstuvwxyz".toCharArray();
|
||||
private static final char[] DIGITS = "23456789".toCharArray();
|
||||
private static final char[] ALL = "ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789".toCharArray();
|
||||
private static final int PASSWORD_LENGTH = 16;
|
||||
private static final int USERNAME_SUFFIX_LENGTH = 8;
|
||||
private static final int USERNAME_ATTEMPTS = 3;
|
||||
|
||||
private final MemberRepository members;
|
||||
private final IamRoleMapper roles;
|
||||
private final AuthAccountClient authClient;
|
||||
private final String internalToken;
|
||||
private final SecureRandom random;
|
||||
|
||||
public TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles,
|
||||
AuthAccountClient authClient,
|
||||
@Value("${baiyue.security.internal-service-token}") String internalToken) {
|
||||
this(members, roles, authClient, internalToken, new SecureRandom());
|
||||
}
|
||||
|
||||
TenantAdministratorProvisioningService(MemberRepository members, IamRoleMapper roles,
|
||||
AuthAccountClient authClient, String internalToken,
|
||||
SecureRandom random) {
|
||||
this.members = members;
|
||||
this.roles = roles;
|
||||
this.authClient = authClient;
|
||||
this.internalToken = internalToken;
|
||||
this.random = random;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String name() { return "tenant-administrator"; }
|
||||
|
||||
@Override
|
||||
public ProvisioningResult initialize(ApprovalContext context) {
|
||||
IamTenant tenant = context.tenant();
|
||||
long operatorId = context.reviewerId();
|
||||
IamRole ownerRole = requiredOwnerRole();
|
||||
IamMembership existingOwner = members.findOwnerByTenant(tenant.getId());
|
||||
if (existingOwner != null) return ensureExistingOwnerCanLogin(tenant, existingOwner, ownerRole);
|
||||
if (!isPlatformCreatedTenant(tenant)) {
|
||||
throw inconsistentOwner("客户端注册企业缺少原注册所有者,不能自动创建第二个管理员,请先修复注册身份");
|
||||
}
|
||||
|
||||
long authUserId = IdWorker.getId();
|
||||
long userId = IdWorker.getId();
|
||||
long membershipId = IdWorker.getId();
|
||||
String password = temporaryPassword();
|
||||
String username = createAuthAccountWithUniqueUsername(authUserId, password, tenant, membershipId);
|
||||
ProvisioningResult result = ProvisioningResult.created(authUserId, username, password);
|
||||
|
||||
try {
|
||||
IamUser user = new IamUser();
|
||||
user.setId(userId);
|
||||
user.setAuthUserId(authUserId);
|
||||
user.setUserType("ENTERPRISE");
|
||||
user.setDisplayName(tenant.getContactName());
|
||||
user.setPhone(tenant.getContactPhone());
|
||||
user.setEmail(tenant.getContactEmail());
|
||||
user.setStatus("ACTIVE");
|
||||
members.insertUser(user);
|
||||
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(membershipId);
|
||||
membership.setTenantId(tenant.getId());
|
||||
membership.setUserId(userId);
|
||||
membership.setMemberType("OWNER");
|
||||
membership.setMemberNo("OWNER-" + membershipId);
|
||||
membership.setStatus("ACTIVE");
|
||||
membership.setJoinedAt(LocalDateTime.now());
|
||||
membership.setCreatedBy(operatorId);
|
||||
membership.setUpdatedBy(operatorId);
|
||||
members.insert(membership);
|
||||
members.replaceRoles(tenant.getId(), membershipId, List.of(ownerRole.getId()));
|
||||
return result;
|
||||
} catch (RuntimeException localFailure) {
|
||||
compensate(context, result);
|
||||
throw localFailure;
|
||||
}
|
||||
}
|
||||
|
||||
private IamRole requiredOwnerRole() {
|
||||
IamRole ownerRole = roles.selectOne(new LambdaQueryWrapper<IamRole>()
|
||||
.eq(IamRole::getTenantId, 0L)
|
||||
.eq(IamRole::getAppCode, "MERCHANT")
|
||||
.eq(IamRole::getRoleCode, "MERCHANT_OWNER")
|
||||
.eq(IamRole::getStatus, "ACTIVE")
|
||||
.last("LIMIT 1"));
|
||||
if (ownerRole == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业管理员角色模板未初始化,不能审核通过");
|
||||
}
|
||||
return ownerRole;
|
||||
}
|
||||
|
||||
private boolean isPlatformCreatedTenant(IamTenant tenant) {
|
||||
// 只有平台创建接口会记录操作人;客户端注册后也会补齐信用代码,不能据此推断来源。
|
||||
return tenant.getCreatedBy() != null;
|
||||
}
|
||||
|
||||
private ProvisioningResult ensureExistingOwnerCanLogin(IamTenant tenant, IamMembership owner,
|
||||
IamRole ownerRole) {
|
||||
if (!"ACTIVE".equals(owner.getStatus())) {
|
||||
throw inconsistentOwner("企业所有者成员已停用,不能审核通过");
|
||||
}
|
||||
IamUser user = members.findUser(owner.getUserId());
|
||||
if (user == null || user.getAuthUserId() == null || !"ACTIVE".equals(user.getStatus())) {
|
||||
throw inconsistentOwner("企业所有者身份不完整或已停用,不能审核通过");
|
||||
}
|
||||
|
||||
AuthAccountClient.AccountSummary account = findAuthAccount(user.getAuthUserId());
|
||||
if (account == null) {
|
||||
String password = temporaryPassword();
|
||||
String username = createAuthAccountWithUniqueUsername(user.getAuthUserId(), password,
|
||||
tenant, owner.getId());
|
||||
return ProvisioningResult.created(user.getAuthUserId(), username, password);
|
||||
}
|
||||
if (!"ACTIVE".equals(account.status())
|
||||
|| !user.getAuthUserId().equals(account.accountId())
|
||||
|| !tenant.getId().equals(account.tenantId())
|
||||
|| !owner.getId().equals(account.membershipId())
|
||||
|| !"MERCHANT".equals(account.appCode())) {
|
||||
throw inconsistentOwner("企业所有者认证账号已停用或账号范围不一致,不能审核通过");
|
||||
}
|
||||
List<Long> roleIds = members.findRoleIds(tenant.getId(), owner.getId());
|
||||
if (!roleIds.contains(ownerRole.getId())) {
|
||||
List<Long> repairedRoleIds = new java.util.ArrayList<>(roleIds);
|
||||
repairedRoleIds.add(ownerRole.getId());
|
||||
members.replaceRoles(tenant.getId(), owner.getId(), repairedRoleIds.stream().distinct().toList());
|
||||
}
|
||||
return ProvisioningResult.existing();
|
||||
}
|
||||
|
||||
private AuthAccountClient.AccountSummary findAuthAccount(long authUserId) {
|
||||
try {
|
||||
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.summary(internalToken, authUserId);
|
||||
if (response == null) throw unavailable();
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
if (code == ResultCode.NOT_FOUND) return null;
|
||||
throw new BizException(code, response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message());
|
||||
}
|
||||
if (response.data() == null) throw unavailable();
|
||||
return response.data();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 404) return null;
|
||||
throw unavailable();
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void compensate(ApprovalContext context, InitializationResult initialization) {
|
||||
ProvisioningResult result = (ProvisioningResult) initialization;
|
||||
if (result.created()) compensate(result.authUserId());
|
||||
}
|
||||
|
||||
private String createAuthAccountWithUniqueUsername(long authUserId, String password,
|
||||
IamTenant tenant, long membershipId) {
|
||||
for (int attempt = 0; attempt < USERNAME_ATTEMPTS; attempt++) {
|
||||
String username = username(tenant.getId());
|
||||
try {
|
||||
createAuthAccount(authUserId, username, password, tenant, membershipId);
|
||||
return username;
|
||||
} catch (BizException exception) {
|
||||
if (exception.getResultCode() != ResultCode.CONFLICT || attempt == USERNAME_ATTEMPTS - 1) {
|
||||
throw exception;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new IllegalStateException("Username generation attempts exhausted");
|
||||
}
|
||||
|
||||
private void createAuthAccount(long authUserId, String username, String password,
|
||||
IamTenant tenant, long membershipId) {
|
||||
try {
|
||||
ApiResponse<AuthAccountClient.AccountSummary> response = authClient.create(internalToken,
|
||||
new AuthAccountClient.CreateAccountRequest(authUserId, username, password,
|
||||
tenant.getContactName(), tenant.getId(), membershipId, "MERCHANT", "ACTIVE"));
|
||||
if (response == null) throw unavailable();
|
||||
if (!response.successful()) {
|
||||
ResultCode code = ResultCode.of(response.code());
|
||||
throw new BizException(code, response.message() == null || response.message().isBlank()
|
||||
? code.message() : response.message());
|
||||
}
|
||||
if (response.data() == null) throw unavailable();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 409) {
|
||||
throw new BizException(ResultCode.CONFLICT, "自动生成的企业管理员登录账号已存在");
|
||||
}
|
||||
throw unavailable();
|
||||
} catch (BizException exception) {
|
||||
throw exception;
|
||||
} catch (RuntimeException exception) {
|
||||
throw unavailable();
|
||||
}
|
||||
}
|
||||
|
||||
private void compensate(long authUserId) {
|
||||
try {
|
||||
ApiResponse<Void> response = authClient.compensate(internalToken, authUserId);
|
||||
if (response == null || !response.successful()) throw unavailable();
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
log.error("企业管理员创建失败且认证账号补偿失败,authUserId={}", authUserId, compensationFailure);
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"企业管理员创建未完成,认证账号补偿失败,请按 authUserId=" + authUserId + " 修复一致性");
|
||||
}
|
||||
}
|
||||
|
||||
private String temporaryPassword() {
|
||||
char[] value = new char[PASSWORD_LENGTH];
|
||||
value[0] = pick(UPPER);
|
||||
value[1] = pick(LOWER);
|
||||
value[2] = pick(DIGITS);
|
||||
for (int index = 3; index < value.length; index++) value[index] = pick(ALL);
|
||||
for (int index = value.length - 1; index > 0; index--) {
|
||||
int swap = random.nextInt(index + 1);
|
||||
char current = value[index];
|
||||
value[index] = value[swap];
|
||||
value[swap] = current;
|
||||
}
|
||||
return new String(value);
|
||||
}
|
||||
|
||||
private String username(long tenantId) {
|
||||
char[] suffix = new char[USERNAME_SUFFIX_LENGTH];
|
||||
for (int index = 0; index < suffix.length; index++) suffix[index] = pick(LOWER);
|
||||
return "merchant_" + tenantId + "_" + new String(suffix);
|
||||
}
|
||||
|
||||
private char pick(char[] source) { return source[random.nextInt(source.length)]; }
|
||||
private BizException unavailable() {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能审核通过");
|
||||
}
|
||||
private BizException inconsistentOwner(String message) {
|
||||
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
|
||||
}
|
||||
|
||||
public static final class ProvisioningResult implements InitializationResult {
|
||||
private final boolean created;
|
||||
private final Long authUserId;
|
||||
private final String username;
|
||||
private final String temporaryPassword;
|
||||
|
||||
private ProvisioningResult(boolean created, Long authUserId, String username, String temporaryPassword) {
|
||||
this.created = created;
|
||||
this.authUserId = authUserId;
|
||||
this.username = username;
|
||||
this.temporaryPassword = temporaryPassword;
|
||||
}
|
||||
|
||||
static ProvisioningResult existing() { return new ProvisioningResult(false, null, null, null); }
|
||||
static ProvisioningResult created(long authUserId, String username, String password) {
|
||||
return new ProvisioningResult(true, authUserId, username, password);
|
||||
}
|
||||
|
||||
public boolean created() { return created; }
|
||||
public Long authUserId() { return authUserId; }
|
||||
public String username() { return username; }
|
||||
public String temporaryPassword() { return temporaryPassword; }
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.core.annotation.AnnotationAwareOrderComparator;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.support.TransactionSynchronization;
|
||||
import org.springframework.transaction.support.TransactionSynchronizationManager;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.atomic.AtomicBoolean;
|
||||
|
||||
/** Runs all tenant approval initializers and owns their reverse-order compensation lifecycle. */
|
||||
@Service
|
||||
public class TenantApprovalInitializationCoordinator {
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantApprovalInitializationCoordinator.class);
|
||||
private final List<TenantApprovalInitializer> initializers;
|
||||
|
||||
public TenantApprovalInitializationCoordinator(List<TenantApprovalInitializer> initializers) {
|
||||
List<TenantApprovalInitializer> ordered = new ArrayList<>(initializers);
|
||||
AnnotationAwareOrderComparator.sort(ordered);
|
||||
Set<String> names = new HashSet<>();
|
||||
for (TenantApprovalInitializer initializer : ordered) {
|
||||
String name = initializer.name();
|
||||
if (name == null || name.isBlank() || !names.add(name)) {
|
||||
throw new IllegalArgumentException("Tenant approval initializer names must be nonblank and unique: " + name);
|
||||
}
|
||||
}
|
||||
this.initializers = List.copyOf(ordered);
|
||||
}
|
||||
|
||||
public InitializationBatch initialize(TenantApprovalInitializer.ApprovalContext context) {
|
||||
InitializationBatch batch = new InitializationBatch(context);
|
||||
registerRollbackCompensation(batch);
|
||||
try {
|
||||
for (TenantApprovalInitializer initializer : initializers) {
|
||||
TenantApprovalInitializer.InitializationResult result = initializer.initialize(context);
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("Tenant approval initializer returned null: " + initializer.name());
|
||||
}
|
||||
batch.add(initializer, result);
|
||||
}
|
||||
return batch;
|
||||
} catch (RuntimeException initializationFailure) {
|
||||
try {
|
||||
compensate(batch);
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
compensationFailure.addSuppressed(initializationFailure);
|
||||
throw compensationFailure;
|
||||
}
|
||||
throw initializationFailure;
|
||||
}
|
||||
}
|
||||
|
||||
public InitializationBatch empty(TenantApprovalInitializer.ApprovalContext context) {
|
||||
return new InitializationBatch(context);
|
||||
}
|
||||
|
||||
public void compensate(InitializationBatch batch) {
|
||||
if (!batch.markCompensated()) return;
|
||||
RuntimeException firstFailure = null;
|
||||
List<CompletedInitialization> completed = new ArrayList<>(batch.completed());
|
||||
Collections.reverse(completed);
|
||||
for (CompletedInitialization item : completed) {
|
||||
try {
|
||||
item.initializer().compensate(batch.context(), item.result());
|
||||
} catch (RuntimeException exception) {
|
||||
log.error("企业审核初始化补偿失败,initializer={}, tenantId={}",
|
||||
item.initializer().name(), batch.context().tenant().getId(), exception);
|
||||
if (firstFailure == null) firstFailure = exception;
|
||||
else firstFailure.addSuppressed(exception);
|
||||
}
|
||||
}
|
||||
if (firstFailure != null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE,
|
||||
"企业审核初始化补偿失败,请检查服务日志并修复数据一致性");
|
||||
}
|
||||
}
|
||||
|
||||
private void registerRollbackCompensation(InitializationBatch batch) {
|
||||
if (!TransactionSynchronizationManager.isSynchronizationActive()) return;
|
||||
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
|
||||
@Override
|
||||
public void afterCompletion(int status) {
|
||||
if (status == STATUS_ROLLED_BACK) compensate(batch);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private record CompletedInitialization(TenantApprovalInitializer initializer,
|
||||
TenantApprovalInitializer.InitializationResult result) { }
|
||||
|
||||
public static final class InitializationBatch {
|
||||
private final TenantApprovalInitializer.ApprovalContext context;
|
||||
private final List<CompletedInitialization> completed = new ArrayList<>();
|
||||
private final AtomicBoolean compensated = new AtomicBoolean(false);
|
||||
|
||||
private InitializationBatch(TenantApprovalInitializer.ApprovalContext context) {
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private void add(TenantApprovalInitializer initializer,
|
||||
TenantApprovalInitializer.InitializationResult result) {
|
||||
completed.add(new CompletedInitialization(initializer, result));
|
||||
}
|
||||
|
||||
private TenantApprovalInitializer.ApprovalContext context() { return context; }
|
||||
private List<CompletedInitialization> completed() { return List.copyOf(completed); }
|
||||
private boolean markCompensated() { return compensated.compareAndSet(false, true); }
|
||||
|
||||
public <T extends TenantApprovalInitializer.InitializationResult> Optional<T> result(Class<T> type) {
|
||||
return completed.stream().map(CompletedInitialization::result)
|
||||
.filter(type::isInstance).map(type::cast).findFirst();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
|
||||
/**
|
||||
* Extension point for provisioning defaults after an enterprise is approved.
|
||||
* Implementations may initialize accounts, roles, rules, fee plans or other tenant-scoped defaults.
|
||||
* Implementations and their compensation must be idempotent. A failed implementation must clean
|
||||
* up its own partial work before throwing; the coordinator compensates every initializer that
|
||||
* completed successfully, in reverse order. Use {@code @Order} for explicit dependencies.
|
||||
*/
|
||||
public interface TenantApprovalInitializer {
|
||||
String name();
|
||||
|
||||
InitializationResult initialize(ApprovalContext context);
|
||||
|
||||
default void compensate(ApprovalContext context, InitializationResult result) { }
|
||||
|
||||
record ApprovalContext(IamTenant tenant, long reviewId, long reviewerId, String comment) { }
|
||||
|
||||
interface InitializationResult { }
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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.TenantDtos.ApprovalResponse;
|
||||
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
|
||||
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
|
||||
|
||||
@@ -39,6 +40,8 @@ public class TenantService {
|
||||
private TenantRepository repository;
|
||||
@Resource
|
||||
private ProfileResourceRepository profileRepository;
|
||||
@Resource
|
||||
private TenantApprovalInitializationCoordinator approvalInitialization;
|
||||
|
||||
|
||||
@BusinessAudit(
|
||||
@@ -49,7 +52,7 @@ public class TenantService {
|
||||
captureAfter = false
|
||||
)
|
||||
@Transactional
|
||||
public TenantResponse register(RegisterRequest request) {
|
||||
public TenantResponse register(RegisterRequest request, long operatorId) {
|
||||
if (repository.existsByCreditCode(request.unifiedSocialCreditCode())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Enterprise already exists / 企业已存在");
|
||||
}
|
||||
@@ -61,6 +64,7 @@ public class TenantService {
|
||||
tenant.setTenantType("MERCHANT");
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setUnifiedSocialCreditCode(request.unifiedSocialCreditCode());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
@@ -75,6 +79,8 @@ public class TenantService {
|
||||
tenant.setRegisteredCapitalCurrency(request.registeredCapitalCurrency());
|
||||
tenant.setRegisteredAddress(request.registeredAddress());
|
||||
tenant.setInternalRemark(request.internalRemark());
|
||||
tenant.setCreatedBy(operatorId);
|
||||
tenant.setUpdatedBy(operatorId);
|
||||
repository.insertTenant(tenant);
|
||||
|
||||
return response(tenant);
|
||||
@@ -119,8 +125,8 @@ public class TenantService {
|
||||
captureAfter = false
|
||||
)
|
||||
@Transactional
|
||||
public void review(long reviewId, long reviewerId, boolean approve, String comment) {
|
||||
IamTenantReview review = repository.findReview(reviewId);
|
||||
public ApprovalResponse review(long reviewId, long reviewerId, boolean approve, String comment) {
|
||||
IamTenantReview review = repository.findReviewForUpdate(reviewId);
|
||||
if (review == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Review not found / 审核单不存在");
|
||||
}
|
||||
@@ -135,28 +141,49 @@ public class TenantService {
|
||||
}
|
||||
}
|
||||
|
||||
review.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
review.setReviewerId(reviewerId);
|
||||
review.setReviewComment(comment);
|
||||
review.setReviewedAt(LocalDateTime.now());
|
||||
repository.saveReview(review);
|
||||
|
||||
IamTenant tenant = repository.findTenant(review.getTenantId());
|
||||
if (tenant == null) {
|
||||
throw new BizException(ResultCode.NOT_FOUND, "Tenant not found / 企业不存在");
|
||||
}
|
||||
tenant.setStatus(approve ? "ACTIVE" : "REJECTED");
|
||||
tenant.setRejectReason(approve ? null : comment);
|
||||
tenant.setApprovedAt(approve ? LocalDateTime.now() : null);
|
||||
tenant.setApprovedBy(approve ? reviewerId : null);
|
||||
repository.saveTenant(tenant);
|
||||
for (var qualification : profileRepository.findQualifications(tenant.getId())) {
|
||||
if ("PENDING".equals(qualification.getStatus())) {
|
||||
qualification.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
qualification.setReviewComment(comment);
|
||||
profileRepository.saveQualification(qualification);
|
||||
|
||||
var initializationContext = new TenantApprovalInitializer.ApprovalContext(
|
||||
tenant, reviewId, reviewerId, comment);
|
||||
var initialization = approve
|
||||
? approvalInitialization.initialize(initializationContext)
|
||||
: approvalInitialization.empty(initializationContext);
|
||||
var administrator = initialization
|
||||
.result(TenantAdministratorProvisioningService.ProvisioningResult.class)
|
||||
.orElseGet(TenantAdministratorProvisioningService.ProvisioningResult::existing);
|
||||
try {
|
||||
review.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
review.setReviewerId(reviewerId);
|
||||
review.setReviewComment(comment);
|
||||
review.setReviewedAt(LocalDateTime.now());
|
||||
repository.saveReview(review);
|
||||
|
||||
tenant.setStatus(approve ? "ACTIVE" : "REJECTED");
|
||||
tenant.setRejectReason(approve ? null : comment);
|
||||
tenant.setApprovedAt(approve ? LocalDateTime.now() : null);
|
||||
tenant.setApprovedBy(approve ? reviewerId : null);
|
||||
repository.saveTenant(tenant);
|
||||
for (var qualification : profileRepository.findQualifications(tenant.getId())) {
|
||||
if ("PENDING".equals(qualification.getStatus())) {
|
||||
qualification.setStatus(approve ? "APPROVED" : "REJECTED");
|
||||
qualification.setReviewComment(comment);
|
||||
profileRepository.saveQualification(qualification);
|
||||
}
|
||||
}
|
||||
} catch (RuntimeException localFailure) {
|
||||
try {
|
||||
approvalInitialization.compensate(initialization);
|
||||
} catch (RuntimeException compensationFailure) {
|
||||
compensationFailure.addSuppressed(localFailure);
|
||||
throw compensationFailure;
|
||||
}
|
||||
throw localFailure;
|
||||
}
|
||||
return new ApprovalResponse(tenant.getId(), administrator.created(),
|
||||
administrator.username(), administrator.temporaryPassword());
|
||||
}
|
||||
|
||||
public OrderEligibilityResponse orderEligibility(long tenantId) {
|
||||
@@ -168,9 +195,9 @@ public class TenantService {
|
||||
return new OrderEligibilityResponse(false, tenant.getStatus(),
|
||||
"Enterprise information must be approved before ordering");
|
||||
}
|
||||
if (profileRepository.countApprovedQualifications(tenantId) < 1) {
|
||||
if (!hasAllQualificationsWithStatus(profileRepository.findQualifications(tenantId), "APPROVED")) {
|
||||
return new OrderEligibilityResponse(false, tenant.getStatus(),
|
||||
"At least one approved enterprise qualification is required");
|
||||
"All required enterprise qualifications must be approved");
|
||||
}
|
||||
return new OrderEligibilityResponse(true, tenant.getStatus(), null);
|
||||
}
|
||||
@@ -195,6 +222,7 @@ public class TenantService {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
@@ -237,6 +265,11 @@ public class TenantService {
|
||||
if (repository.findPendingReviewByTenant(tenantId) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
|
||||
}
|
||||
if (tenant.getUnifiedSocialCreditCode() == null
|
||||
|| !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
"A valid unified social credit code is required / 请先填写有效的18位统一社会信用代码");
|
||||
}
|
||||
var qualifications = profileRepository.findQualifications(tenantId);
|
||||
if (!hasAllPendingQualifications(qualifications)) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
@@ -262,6 +295,7 @@ public class TenantService {
|
||||
}
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
@@ -274,8 +308,12 @@ public class TenantService {
|
||||
}
|
||||
|
||||
private boolean hasAllPendingQualifications(List<IamTenantQualification> qualifications) {
|
||||
return hasAllQualificationsWithStatus(qualifications, "PENDING");
|
||||
}
|
||||
|
||||
private boolean hasAllQualificationsWithStatus(List<IamTenantQualification> qualifications, String status) {
|
||||
return REQUIRED_QUALIFICATION_TYPES.stream().allMatch(type -> qualifications.stream()
|
||||
.anyMatch(item -> type.equals(item.getQualificationType()) && "PENDING".equals(item.getStatus())));
|
||||
.anyMatch(item -> type.equals(item.getQualificationType()) && status.equals(item.getStatus())));
|
||||
}
|
||||
|
||||
private IamTenant requireMerchant(long tenantId) {
|
||||
@@ -297,7 +335,7 @@ public class TenantService {
|
||||
private TenantResponse response(IamTenant tenant) {
|
||||
return new TenantResponse(
|
||||
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
|
||||
tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
|
||||
tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
|
||||
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
|
||||
tenant.getServicePackage(), tenant.getSalesOwnerName(), tenant.getPaymentTermDays(), tenant.getEnterpriseType(),
|
||||
tenant.getEstablishedDate(), tenant.getRegisteredCapital(), tenant.getRegisteredCapitalCurrency(),
|
||||
@@ -308,7 +346,7 @@ public class TenantService {
|
||||
private TenantResponse summaryResponse(IamTenant tenant) {
|
||||
TenantResponse response = response(tenant);
|
||||
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
|
||||
response.legalName(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(),
|
||||
response.servicePackage(), response.salesOwnerName(), response.paymentTermDays(), response.enterpriseType(),
|
||||
response.establishedDate(), response.registeredCapital(), response.registeredCapitalCurrency(),
|
||||
@@ -318,7 +356,7 @@ public class TenantService {
|
||||
private TenantResponse merchantVisibleResponse(IamTenant tenant) {
|
||||
TenantResponse response = response(tenant);
|
||||
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
|
||||
response.legalName(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
|
||||
response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(),
|
||||
response.paymentTermDays(), response.enterpriseType(), response.establishedDate(),
|
||||
response.registeredCapital(), response.registeredCapitalCurrency(), response.registeredAddress(),
|
||||
|
||||
@@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
|
||||
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '租户类型',
|
||||
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '租户状态:PROFILE_REQUIRED、PENDING、ACTIVE、REJECTED、DISABLED',
|
||||
legal_name VARCHAR(128) NULL COMMENT '企业法定名称',
|
||||
legal_representative VARCHAR(64) NULL COMMENT '法定代表人',
|
||||
unified_social_credit_code VARCHAR(32) NULL COMMENT '统一社会信用代码',
|
||||
contact_name VARCHAR(64) NULL COMMENT '企业联系人',
|
||||
contact_phone VARCHAR(32) NULL COMMENT '联系电话',
|
||||
|
||||
@@ -79,7 +79,6 @@ VALUES
|
||||
(5104000, 0, 'PLATFORM', 'MENU', 0, 'platform:audit', '审计管理', '/audit', NULL, 50, 'ACTIVE', 0),
|
||||
-- Platform: level-two menus.
|
||||
(5100010, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:list', '企业客户', '/customers/list', NULL, 10, 'ACTIVE', 0),
|
||||
(5100020, 0, 'PLATFORM', 'MENU', 5100000, 'platform:customer:review', '企业审核', '/customers/reviews', NULL, 20, 'ACTIVE', 0),
|
||||
(5101010, 0, 'PLATFORM', 'MENU', 5101000, 'platform:user:list', '内部用户', '/system/users', NULL, 10, 'ACTIVE', 0),
|
||||
(5101020, 0, 'PLATFORM', 'MENU', 5101000, 'platform:role:list', '角色管理', '/system/roles', NULL, 20, 'ACTIVE', 0),
|
||||
(5101030, 0, 'PLATFORM', 'MENU', 5101000, 'platform:permission:list', '菜单按钮', '/system/permissions', NULL, 30, 'ACTIVE', 0),
|
||||
@@ -91,6 +90,7 @@ VALUES
|
||||
(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.
|
||||
(5100020, 0, 'PLATFORM', 'BUTTON', 5100010, 'platform:customer:review', '客户审核', NULL, NULL, 50, 'ACTIVE', 0),
|
||||
(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),
|
||||
|
||||
@@ -25,6 +25,7 @@ DELIMITER ;
|
||||
CALL phase1_iam_add_column('iam_tenant', 'approved_at', "DATETIME(3) NULL COMMENT 'Approval time'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'approved_by', "BIGINT NULL COMMENT 'Reviewer ID'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'reject_reason', "VARCHAR(500) NULL COMMENT 'Rejection reason'");
|
||||
CALL phase1_iam_add_column('iam_tenant', 'legal_representative', "VARCHAR(64) NULL COMMENT '法定代表人' AFTER legal_name");
|
||||
CALL phase1_iam_add_column('iam_tenant_qualification', 'review_comment', "VARCHAR(500) NULL COMMENT 'Review comment'");
|
||||
CALL phase1_iam_add_column('iam_membership', 'created_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Creator ID'");
|
||||
CALL phase1_iam_add_column('iam_membership', 'updated_by', "BIGINT UNSIGNED NOT NULL DEFAULT 0 COMMENT 'Updater ID'");
|
||||
@@ -55,11 +56,17 @@ ON DUPLICATE KEY UPDATE
|
||||
permission_name=VALUES(permission_name), route_path=VALUES(route_path),
|
||||
parent_id=VALUES(parent_id), sort_no=VALUES(sort_no), status='ACTIVE', deleted=0;
|
||||
|
||||
INSERT IGNORE INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT 4000100, id, 0
|
||||
FROM iam_permission
|
||||
WHERE app_code='MERCHANT' AND tenant_id=0
|
||||
AND permission_type IN ('MENU','BUTTON') AND status='ACTIVE' AND deleted=0;
|
||||
INSERT INTO iam_role_permission (role_id, permission_id, tenant_id)
|
||||
SELECT owner_role.id, permission.id, 0
|
||||
FROM iam_role owner_role
|
||||
JOIN iam_permission permission
|
||||
ON permission.app_code='MERCHANT' AND permission.tenant_id=0
|
||||
AND permission.permission_type IN ('MENU','BUTTON')
|
||||
AND permission.status='ACTIVE' AND permission.deleted=0
|
||||
WHERE owner_role.tenant_id=0 AND owner_role.app_code='MERCHANT'
|
||||
AND owner_role.role_code='MERCHANT_OWNER'
|
||||
AND owner_role.status='ACTIVE' AND owner_role.deleted=0
|
||||
ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id);
|
||||
|
||||
-- 不再写入伪造的资质 URL。资质必须先由 File 服务保存真实图片,再由 IAM 记录文件地址和审核结果。
|
||||
-- 已部署环境中的 /phase1-seed/ 历史记录按 docs/企业资质文件迁移说明.md 处理,本脚本不得将其改成不存在的 UUID。
|
||||
|
||||
@@ -173,7 +173,7 @@ paths:
|
||||
post:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: Approve enterprise registration / 审核通过企业注册
|
||||
description: Approve a pending review and activate the tenant. / 通过待审核申请并激活企业租户。
|
||||
description: Approve and activate the tenant. Client registrations keep the registration account as administrator. Only platform-created tenants without an owner receive a generated administrator and one-time credentials. / 审核通过并激活企业;客户端注册账号继续作为企业管理员,仅平台代建且没有所有者的企业自动创建管理员并返回一次性凭据。
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/Id'}
|
||||
requestBody:
|
||||
@@ -182,7 +182,11 @@ paths:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ReviewRequest'}
|
||||
responses:
|
||||
'200': {$ref: '#/components/responses/Success'}
|
||||
'200':
|
||||
description: 审核通过;data 返回开户结果及一次性初始凭据
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/TenantApprovalApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'404': {$ref: '#/components/responses/NotFound'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
@@ -595,6 +599,41 @@ paths:
|
||||
'400': {$ref: '#/components/responses/BadRequest'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
'409': {$ref: '#/components/responses/Conflict'}
|
||||
/internal/exports/v1/CUSTOMER_LIST/batches:
|
||||
post:
|
||||
tags: [Internal Export Provider]
|
||||
summary: 分批提供企业客户导出数据
|
||||
description: 仅供 File Service 调用;按平台访问域、签名最大 ID 快照和游标只读查询商户客户。
|
||||
security: []
|
||||
parameters:
|
||||
- name: X-Internal-Service-Token
|
||||
in: header
|
||||
required: true
|
||||
schema: {type: string}
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [taskId, tenantId, appCode, requestedBy, dispatchVersion, parameters, limit]
|
||||
properties:
|
||||
taskId: {type: string, pattern: '^\d+$'}
|
||||
tenantId: {type: string, pattern: '^\d+$'}
|
||||
appCode: {type: string, enum: [PLATFORM]}
|
||||
requestedBy: {type: string, pattern: '^\d+$'}
|
||||
dispatchVersion: {type: integer, minimum: 1}
|
||||
parameters: {type: object}
|
||||
cursor: {type: string, nullable: true}
|
||||
snapshotToken: {type: string, nullable: true, maxLength: 512}
|
||||
limit: {type: integer, minimum: 1, maximum: 500}
|
||||
responses:
|
||||
'200':
|
||||
description: 统一响应;data 为 ExportBatchResponse,Long 值以字符串传输
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/ApiResponse'}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
components:
|
||||
securitySchemes:
|
||||
bearerAuth:
|
||||
@@ -677,6 +716,20 @@ components:
|
||||
size: {type: integer, format: int64, minimum: 1}
|
||||
total: {type: integer, format: int64, minimum: 0}
|
||||
pages: {type: integer, format: int64, minimum: 0}
|
||||
TenantApprovalApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {$ref: '#/components/schemas/TenantApprovalResponse'}
|
||||
TenantApprovalResponse:
|
||||
type: object
|
||||
required: [tenantId, administratorCreated]
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^\d+$', example: '190000000000000001'}
|
||||
administratorCreated: {type: boolean, description: 本次审核是否新建企业管理员}
|
||||
administratorUsername: {type: string, nullable: true, example: merchant_190000000000000001_k3mnpqrs}
|
||||
temporaryPassword: {type: string, format: password, nullable: true, description: 一次性初始密码,仅新建管理员时返回}
|
||||
TenantApiResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/ApiResponse'}
|
||||
@@ -704,6 +757,7 @@ components:
|
||||
tenantName: {type: string}
|
||||
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED]}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
unifiedSocialCreditCode: {type: string}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
@@ -763,10 +817,11 @@ components:
|
||||
reviewComment: {type: string}
|
||||
RegisterRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, unifiedSocialCreditCode, contactName, contactPhone, servicePackage]
|
||||
required: [tenantName, legalName, legalRepresentative, unifiedSocialCreditCode, contactName, contactPhone, servicePackage]
|
||||
properties:
|
||||
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalName: {type: string, example: 张三}
|
||||
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
|
||||
legalRepresentative: {type: string, maxLength: 64, example: 张三}
|
||||
unifiedSocialCreditCode: {type: string, pattern: '^[0-9A-Z]{18}$', example: 91440300MA5ABCDE12}
|
||||
contactName: {type: string, example: 李四}
|
||||
contactPhone: {type: string, example: '13800138000'}
|
||||
@@ -783,19 +838,21 @@ components:
|
||||
internalRemark: {type: string, maxLength: 1000}
|
||||
UpdateProfileRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, contactName, contactPhone, servicePackage]
|
||||
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
|
||||
properties:
|
||||
tenantName: {type: string}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactEmail: {type: string, format: email}
|
||||
UpdateTenantRequest:
|
||||
type: object
|
||||
required: [tenantName, legalName, contactName, contactPhone]
|
||||
required: [tenantName, legalName, legalRepresentative, contactName, contactPhone]
|
||||
properties:
|
||||
tenantName: {type: string}
|
||||
legalName: {type: string}
|
||||
legalRepresentative: {type: string, maxLength: 64}
|
||||
contactName: {type: string}
|
||||
contactPhone: {type: string}
|
||||
contactEmail: {type: string, format: email}
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
package com.baiyue.iam.controller;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchResponse;
|
||||
import com.baiyue.iam.service.CustomerListExportProviderService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
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 CustomerListExportProviderControllerTest {
|
||||
@Test
|
||||
void exposesTheGenericCustomerListProviderPath() throws Exception {
|
||||
assertEquals("/internal/exports/v1/CUSTOMER_LIST",
|
||||
CustomerListExportProviderController.class.getAnnotation(RequestMapping.class).value()[0]);
|
||||
assertEquals("/batches", CustomerListExportProviderController.class
|
||||
.getMethod("batch", String.class, ExportBatchRequest.class)
|
||||
.getAnnotation(PostMapping.class).value()[0]);
|
||||
}
|
||||
|
||||
@Test
|
||||
void requiresInternalTokenBeforeCallingProvider() {
|
||||
CustomerListExportProviderService service = mock(CustomerListExportProviderService.class);
|
||||
CustomerListExportProviderController controller =
|
||||
new CustomerListExportProviderController(service, "internal-token");
|
||||
ExportBatchRequest request = request();
|
||||
|
||||
BizException missing = assertThrows(BizException.class, () -> controller.batch(null, request));
|
||||
BizException wrong = assertThrows(BizException.class, () -> controller.batch("wrong", request));
|
||||
assertEquals(ResultCode.FORBIDDEN, missing.getResultCode());
|
||||
assertEquals(ResultCode.FORBIDDEN, wrong.getResultCode());
|
||||
|
||||
ExportBatchResponse batch = new ExportBatchResponse(1, "sha256:test", "snapshot",
|
||||
List.of(), List.of(), null, false, 0L);
|
||||
when(service.exportBatch(request)).thenReturn(batch);
|
||||
var response = controller.batch("internal-token", request);
|
||||
|
||||
assertEquals(ResultCode.SUCCESS.code(), response.code());
|
||||
assertEquals(batch, response.data());
|
||||
verify(service).exportBatch(request);
|
||||
}
|
||||
|
||||
private ExportBatchRequest request() {
|
||||
return new ExportBatchRequest("190000000000000001", "0", "PLATFORM",
|
||||
"190000000000000009", 1, Map.of(), null, null, 500);
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import org.springframework.web.bind.annotation.GetMapping;
|
||||
|
||||
import java.lang.annotation.Annotation;
|
||||
import java.lang.reflect.Method;
|
||||
import java.lang.reflect.ParameterizedType;
|
||||
import java.util.Arrays;
|
||||
import java.util.Set;
|
||||
import java.util.stream.Collectors;
|
||||
@@ -55,6 +56,16 @@ class PlatformTenantControllerContractTest {
|
||||
method.getAnnotation(PreAuthorize.class).value());
|
||||
}
|
||||
|
||||
@Test
|
||||
void 审核通过接口返回一次性管理员凭据结果() throws Exception {
|
||||
Method method = PlatformTenantController.class.getMethod("approve", long.class,
|
||||
com.baiyue.iam.dto.TenantDtos.ReviewRequest.class, AuthenticatedUser.class);
|
||||
|
||||
ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType();
|
||||
assertEquals(com.baiyue.iam.dto.TenantDtos.ApprovalResponse.class,
|
||||
returnType.getActualTypeArguments()[0]);
|
||||
}
|
||||
|
||||
private <T extends Annotation> T annotation(Method method, int parameter, Class<T> type) {
|
||||
return method.getParameters()[parameter].getAnnotation(type);
|
||||
}
|
||||
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package com.baiyue.iam.repository;
|
||||
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.mapper.IamTenantMapper;
|
||||
import com.baomidou.mybatisplus.core.MybatisConfiguration;
|
||||
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
|
||||
import com.baomidou.mybatisplus.core.conditions.Wrapper;
|
||||
import com.baomidou.mybatisplus.core.metadata.TableInfoHelper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.apache.ibatis.builder.MapperBuilderAssistant;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CustomerListExportProviderRepositoryTest {
|
||||
@Test
|
||||
void batchQueryAlwaysConstrainsMerchantSnapshotAndAscendingCursor() {
|
||||
TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"),
|
||||
IamTenant.class);
|
||||
IamTenantMapper mapper = mock(IamTenantMapper.class);
|
||||
when(mapper.selectList(any())).thenReturn(List.of());
|
||||
CustomerListExportProviderRepository repository =
|
||||
new CustomerListExportProviderRepository(mapper);
|
||||
|
||||
repository.findBatch(100L, 200L, 501, "柏悦", "ACTIVE");
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
ArgumentCaptor<Wrapper<IamTenant>> captor = ArgumentCaptor.forClass(Wrapper.class);
|
||||
verify(mapper).selectList(captor.capture());
|
||||
Wrapper<IamTenant> query = captor.getValue();
|
||||
assertThat(query.getSqlSegment())
|
||||
.contains("tenant_type")
|
||||
.contains("status")
|
||||
.contains("tenant_code")
|
||||
.contains("tenant_name")
|
||||
.contains("id >")
|
||||
.contains("id <=")
|
||||
.contains("ORDER BY id ASC")
|
||||
.endsWith("LIMIT 501");
|
||||
assertThat(((AbstractWrapper<?, ?, ?>) query).getParamNameValuePairs().values())
|
||||
.contains("MERCHANT", "ACTIVE", 100L, 200L)
|
||||
.anyMatch(value -> "%柏悦%".equals(value));
|
||||
}
|
||||
}
|
||||
+31
-1
@@ -10,6 +10,21 @@ import java.nio.file.Path;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class CustomerProfileMigrationContractTest {
|
||||
@Test
|
||||
void 企业审核初始化按角色自然键为全局管理员模板授权() throws IOException {
|
||||
String sql;
|
||||
try (var stream = getClass().getResourceAsStream("/db/phase1-onboarding.sql")) {
|
||||
assertThat(stream).isNotNull();
|
||||
sql = new String(stream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
assertThat(sql)
|
||||
.contains("'MERCHANT_OWNER'")
|
||||
.contains("SELECT owner_role.id, permission.id, 0")
|
||||
.contains("owner_role.role_code='MERCHANT_OWNER'")
|
||||
.doesNotContain("SELECT 4000100, id, 0");
|
||||
}
|
||||
|
||||
@Test
|
||||
void 客户档案迁移覆盖全部字段并通过元数据判断保持可重复执行() throws IOException {
|
||||
String sql;
|
||||
@@ -46,17 +61,32 @@ class CustomerProfileMigrationContractTest {
|
||||
|
||||
assertThat(sql)
|
||||
.contains("CALL regression_assert_table('iam_tenant')")
|
||||
.contains("CALL regression_assert_table('iam_tenant_review')")
|
||||
.contains("CALL regression_assert_table('iam_tenant_qualification')")
|
||||
.contains("DEFAULT 'PROFILE_REQUIRED'")
|
||||
.contains("CREATE PROCEDURE regression_add_iam_column")
|
||||
.contains("regression_add_iam_column('approved_at'")
|
||||
.contains("regression_add_iam_column('approved_by'")
|
||||
.contains("regression_add_iam_column('reject_reason'")
|
||||
.contains("regression_add_iam_related_column('iam_tenant_qualification', 'review_comment'")
|
||||
.contains("'IAM注册审核字段'")
|
||||
.contains("'IAM资质与成员审计字段'")
|
||||
.contains("platform:customer:view")
|
||||
.contains("platform:customer:export")
|
||||
.contains("platform:download:list")
|
||||
.contains("'MERCHANT_OWNER'")
|
||||
.contains("IAM全局企业管理员模板")
|
||||
.contains("owner_role.id, permission.id, 0")
|
||||
.contains("'IAM客户档案字段' AS check_item")
|
||||
.contains("9 AS expected_count")
|
||||
.contains("10 AS expected_count")
|
||||
.contains("47 AS expected_count")
|
||||
.contains("MIGRATION_COMPLETED");
|
||||
assertThat(instructions)
|
||||
.contains("phase1-platform-master-data-and-permission-management.sql")
|
||||
.contains("phase1-customer-profile.sql")
|
||||
.contains("phase1-onboarding.sql")
|
||||
.contains("IAM 注册审核字段")
|
||||
.contains("全局 `MERCHANT_OWNER` 角色模板")
|
||||
.contains("测试环境只执行统一入口");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,161 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportBatchRequest;
|
||||
import com.baiyue.common.core.export.ExportContracts.ExportColumnType;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.repository.CustomerListExportProviderRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.math.BigDecimal;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
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.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class CustomerListExportProviderServiceTest {
|
||||
private CustomerListExportProviderRepository repository;
|
||||
private CustomerListExportProviderService service;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
repository = mock(CustomerListExportProviderRepository.class);
|
||||
service = new CustomerListExportProviderService(repository,
|
||||
"test-internal-service-token-at-least-32-bytes");
|
||||
}
|
||||
|
||||
@Test
|
||||
void firstBatchReturnsTypedSchemaSignedMaximumIdAndBoundedRows() {
|
||||
when(repository.maximumId("柏悦", "ACTIVE")).thenReturn(103L);
|
||||
when(repository.estimateRows(103L, "柏悦", "ACTIVE")).thenReturn(3L);
|
||||
when(repository.findBatch(null, 103L, 3, "柏悦", "ACTIVE"))
|
||||
.thenReturn(List.of(tenant(101L), tenant(102L), tenant(103L)));
|
||||
|
||||
var response = service.exportBatch(request(Map.of("keyword", " 柏悦 ", "status", "ACTIVE"),
|
||||
null, null, 2));
|
||||
|
||||
assertEquals(1, response.schemaVersion());
|
||||
assertThat(response.schemaHash()).matches("sha256:[0-9a-f]{64}");
|
||||
assertThat(response.snapshotToken()).matches("v1\\.103\\.[A-Za-z0-9_-]+");
|
||||
assertEquals(20, response.columns().size());
|
||||
assertEquals("tenantCode", response.columns().get(1).key());
|
||||
assertEquals(ExportColumnType.DECIMAL,
|
||||
response.columns().stream().filter(column -> "registeredCapital".equals(column.key()))
|
||||
.findFirst().orElseThrow().type());
|
||||
assertEquals(2, response.rows().size());
|
||||
assertEquals("101", response.rows().get(0).get("id"));
|
||||
assertEquals(new BigDecimal("1000000.00"), response.rows().get(0).get("registeredCapital"));
|
||||
assertEquals("启用", response.rows().get(0).get("status"));
|
||||
assertEquals("102", response.nextCursor());
|
||||
assertEquals(true, response.hasMore());
|
||||
assertEquals(3L, response.totalRows());
|
||||
}
|
||||
|
||||
@Test
|
||||
void laterBatchReusesVerifiedSnapshotAndOmitsSchemaAndEstimate() {
|
||||
when(repository.maximumId("柏悦", "ACTIVE")).thenReturn(103L);
|
||||
when(repository.estimateRows(103L, "柏悦", "ACTIVE")).thenReturn(3L);
|
||||
when(repository.findBatch(null, 103L, 3, "柏悦", "ACTIVE"))
|
||||
.thenReturn(List.of(tenant(101L), tenant(102L), tenant(103L)));
|
||||
var first = service.exportBatch(request(Map.of("keyword", "柏悦", "status", "ACTIVE"),
|
||||
null, null, 2));
|
||||
|
||||
when(repository.findBatch(102L, 103L, 3, "柏悦", "ACTIVE"))
|
||||
.thenReturn(List.of(tenant(103L)));
|
||||
var later = service.exportBatch(request(Map.of("keyword", "柏悦", "status", "ACTIVE"),
|
||||
"102", first.snapshotToken(), 2));
|
||||
|
||||
assertThat(later.columns()).isEmpty();
|
||||
assertEquals(first.schemaHash(), later.schemaHash());
|
||||
assertEquals(first.snapshotToken(), later.snapshotToken());
|
||||
assertEquals(1, later.rows().size());
|
||||
assertEquals("103", later.nextCursor());
|
||||
assertEquals(false, later.hasMore());
|
||||
assertEquals(null, later.totalRows());
|
||||
verify(repository).findBatch(102L, 103L, 3, "柏悦", "ACTIVE");
|
||||
}
|
||||
|
||||
@Test
|
||||
void snapshotIsBoundToTaskIdentityAndFilters() {
|
||||
when(repository.maximumId("柏悦", "ACTIVE")).thenReturn(103L);
|
||||
when(repository.estimateRows(103L, "柏悦", "ACTIVE")).thenReturn(0L);
|
||||
when(repository.findBatch(null, 103L, 2, "柏悦", "ACTIVE")).thenReturn(List.of());
|
||||
String token = service.exportBatch(request(Map.of("keyword", "柏悦", "status", "ACTIVE"),
|
||||
null, null, 1)).snapshotToken();
|
||||
|
||||
BizException changedFilter = assertThrows(BizException.class, () -> service.exportBatch(
|
||||
request(Map.of("keyword", "其他", "status", "ACTIVE"), "0", token, 1)));
|
||||
String tampered = token.substring(0, token.length() - 1)
|
||||
+ (token.endsWith("A") ? "B" : "A");
|
||||
BizException tamperedToken = assertThrows(BizException.class, () -> service.exportBatch(
|
||||
request(Map.of("keyword", "柏悦", "status", "ACTIVE"), "0", tampered, 1)));
|
||||
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, changedFilter.getResultCode());
|
||||
assertEquals(ResultCode.VALIDATION_FAILED, tamperedToken.getResultCode());
|
||||
}
|
||||
|
||||
@Test
|
||||
void rejectsMissingScopeInvalidLimitsAndUntrustedParametersBeforeQuerying() {
|
||||
BizException tenant = assertThrows(BizException.class, () -> service.exportBatch(new ExportBatchRequest(
|
||||
"190000000000000001", "9", "PLATFORM", "190000000000000009",
|
||||
1, Map.of(), null, null, 500)));
|
||||
BizException app = assertThrows(BizException.class, () -> service.exportBatch(new ExportBatchRequest(
|
||||
"190000000000000001", "0", "MERCHANT", "190000000000000009",
|
||||
1, Map.of(), null, null, 500)));
|
||||
BizException actor = assertThrows(BizException.class, () -> service.exportBatch(new ExportBatchRequest(
|
||||
"190000000000000001", "0", "PLATFORM", null,
|
||||
1, Map.of(), null, null, 500)));
|
||||
BizException limit = assertThrows(BizException.class, () -> service.exportBatch(
|
||||
request(Map.of(), null, null, 501)));
|
||||
BizException untrusted = assertThrows(BizException.class, () -> service.exportBatch(
|
||||
request(Map.of("tenantId", "9"), null, null, 500)));
|
||||
BizException cursor = assertThrows(BizException.class, () -> service.exportBatch(
|
||||
request(Map.of(), "1", null, 500)));
|
||||
|
||||
assertThat(List.of(tenant, app, actor, limit, untrusted, cursor))
|
||||
.allMatch(error -> error.getResultCode() == ResultCode.VALIDATION_FAILED);
|
||||
verify(repository, never()).maximumId(org.mockito.ArgumentMatchers.any(),
|
||||
org.mockito.ArgumentMatchers.any());
|
||||
}
|
||||
|
||||
private ExportBatchRequest request(Map<String, Object> parameters, String cursor,
|
||||
String snapshotToken, int limit) {
|
||||
return new ExportBatchRequest("190000000000000001", "0", "PLATFORM",
|
||||
"190000000000000009", 1, parameters, cursor, snapshotToken, limit);
|
||||
}
|
||||
|
||||
private IamTenant tenant(long id) {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(id);
|
||||
tenant.setTenantCode("TEN" + id);
|
||||
tenant.setTenantName("柏悦物流" + id);
|
||||
tenant.setLegalName("柏悦物流有限公司");
|
||||
tenant.setUnifiedSocialCreditCode("91440000123456789X");
|
||||
tenant.setEnterpriseType("有限责任公司");
|
||||
tenant.setEstablishedDate(LocalDate.of(2020, 1, 2));
|
||||
tenant.setRegisteredCapital(new BigDecimal("1000000.00"));
|
||||
tenant.setRegisteredCapitalCurrency("CNY");
|
||||
tenant.setRegisteredAddress("深圳市");
|
||||
tenant.setContactName("张三");
|
||||
tenant.setContactPhone("13800138000");
|
||||
tenant.setContactEmail("customer@example.com");
|
||||
tenant.setContactWechat("wechat");
|
||||
tenant.setSalesOwnerName("销售甲");
|
||||
tenant.setServicePackage("基础版");
|
||||
tenant.setPaymentTermDays(30);
|
||||
tenant.setStatus("ACTIVE");
|
||||
tenant.setCreatedAt(LocalDateTime.of(2026, 8, 1, 10, 0));
|
||||
tenant.setApprovedAt(LocalDateTime.of(2026, 8, 2, 10, 0));
|
||||
return tenant;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.iam.dto.OnboardingDtos.MerchantRegistrationRequest;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamMembershipRole;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamMembershipMapper;
|
||||
import com.baiyue.iam.mapper.IamMembershipRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantAddressMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantQualificationMapper;
|
||||
import com.baiyue.iam.mapper.IamTenantReviewMapper;
|
||||
import com.baiyue.iam.mapper.IamUserMapper;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.springframework.test.util.ReflectionTestUtils;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
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 MerchantOnboardingServiceTest {
|
||||
@Test
|
||||
void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() {
|
||||
IamTenantMapper tenants = mock(IamTenantMapper.class);
|
||||
IamUserMapper users = mock(IamUserMapper.class);
|
||||
IamMembershipMapper memberships = mock(IamMembershipMapper.class);
|
||||
IamRoleMapper roles = mock(IamRoleMapper.class);
|
||||
IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class);
|
||||
IamTenantAddressMapper addresses = mock(IamTenantAddressMapper.class);
|
||||
IamTenantQualificationMapper qualifications = mock(IamTenantQualificationMapper.class);
|
||||
IamTenantReviewMapper reviews = mock(IamTenantReviewMapper.class);
|
||||
MerchantOnboardingService service = new MerchantOnboardingService();
|
||||
ReflectionTestUtils.setField(service, "tenantMapper", tenants);
|
||||
ReflectionTestUtils.setField(service, "userMapper", users);
|
||||
ReflectionTestUtils.setField(service, "membershipMapper", memberships);
|
||||
ReflectionTestUtils.setField(service, "roleMapper", roles);
|
||||
ReflectionTestUtils.setField(service, "membershipRoleMapper", membershipRoles);
|
||||
ReflectionTestUtils.setField(service, "addressMapper", addresses);
|
||||
ReflectionTestUtils.setField(service, "qualificationMapper", qualifications);
|
||||
ReflectionTestUtils.setField(service, "reviewMapper", reviews);
|
||||
|
||||
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);
|
||||
|
||||
var result = service.register(new MerchantRegistrationRequest(
|
||||
9001L, "注册人", "测试企业", "张三", "13800138000",
|
||||
"owner@example.com", "CN", "深圳市宝安区"));
|
||||
|
||||
ArgumentCaptor<IamTenant> tenant = ArgumentCaptor.forClass(IamTenant.class);
|
||||
ArgumentCaptor<IamUser> user = ArgumentCaptor.forClass(IamUser.class);
|
||||
ArgumentCaptor<IamMembership> membership = ArgumentCaptor.forClass(IamMembership.class);
|
||||
ArgumentCaptor<IamMembershipRole> roleGrant = ArgumentCaptor.forClass(IamMembershipRole.class);
|
||||
verify(tenants).insert(tenant.capture());
|
||||
verify(users).insert(user.capture());
|
||||
verify(memberships).insert(membership.capture());
|
||||
verify(membershipRoles).insert(roleGrant.capture());
|
||||
|
||||
assertEquals("PROFILE_REQUIRED", result.tenantStatus());
|
||||
assertEquals(result.tenantId(), tenant.getValue().getId());
|
||||
assertEquals("测试企业", tenant.getValue().getLegalName());
|
||||
assertEquals("张三", tenant.getValue().getLegalRepresentative());
|
||||
assertEquals("注册人", tenant.getValue().getContactName());
|
||||
assertEquals(9001L, user.getValue().getAuthUserId());
|
||||
assertEquals("ACTIVE", user.getValue().getStatus());
|
||||
assertEquals(result.tenantId(), membership.getValue().getTenantId());
|
||||
assertEquals(user.getValue().getId(), membership.getValue().getUserId());
|
||||
assertEquals("OWNER", membership.getValue().getMemberType());
|
||||
assertEquals("ACTIVE", membership.getValue().getStatus());
|
||||
assertEquals(result.membershipId(), membership.getValue().getId());
|
||||
assertEquals(result.membershipId(), roleGrant.getValue().getMembershipId());
|
||||
assertEquals(4000100L, roleGrant.getValue().getRoleId());
|
||||
assertEquals(result.tenantId(), roleGrant.getValue().getTenantId());
|
||||
assertNotNull(result.tenantId());
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensationRemovesOnlyAnUnstartedRegistration() {
|
||||
IamTenantMapper tenants = mock(IamTenantMapper.class);
|
||||
IamUserMapper users = mock(IamUserMapper.class);
|
||||
IamMembershipMapper memberships = mock(IamMembershipMapper.class);
|
||||
IamRoleMapper roles = mock(IamRoleMapper.class);
|
||||
IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class);
|
||||
IamTenantAddressMapper addresses = mock(IamTenantAddressMapper.class);
|
||||
IamTenantQualificationMapper qualifications = mock(IamTenantQualificationMapper.class);
|
||||
IamTenantReviewMapper reviews = mock(IamTenantReviewMapper.class);
|
||||
MerchantOnboardingService service = new MerchantOnboardingService();
|
||||
ReflectionTestUtils.setField(service, "tenantMapper", tenants);
|
||||
ReflectionTestUtils.setField(service, "userMapper", users);
|
||||
ReflectionTestUtils.setField(service, "membershipMapper", memberships);
|
||||
ReflectionTestUtils.setField(service, "roleMapper", roles);
|
||||
ReflectionTestUtils.setField(service, "membershipRoleMapper", membershipRoles);
|
||||
ReflectionTestUtils.setField(service, "addressMapper", addresses);
|
||||
ReflectionTestUtils.setField(service, "qualificationMapper", qualifications);
|
||||
ReflectionTestUtils.setField(service, "reviewMapper", reviews);
|
||||
|
||||
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.setStatus("PROFILE_REQUIRED");
|
||||
when(users.selectOne(any())).thenReturn(user);
|
||||
when(memberships.selectList(any())).thenReturn(java.util.List.of(membership));
|
||||
when(tenants.selectOne(any())).thenReturn(tenant);
|
||||
when(qualifications.selectCount(any())).thenReturn(0L);
|
||||
when(reviews.selectCount(any())).thenReturn(0L);
|
||||
|
||||
service.compensateRegistration(9001L);
|
||||
|
||||
verify(membershipRoles).delete(any());
|
||||
verify(memberships).deleteById(30001L);
|
||||
verify(addresses).delete(any());
|
||||
verify(users).deleteById((java.io.Serializable) Long.valueOf(20001L));
|
||||
verify(tenants).deleteById((java.io.Serializable) Long.valueOf(10001L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void compensationRefusesAfterQualificationUpload() {
|
||||
IamTenantMapper tenants = mock(IamTenantMapper.class);
|
||||
IamUserMapper users = mock(IamUserMapper.class);
|
||||
IamMembershipMapper memberships = mock(IamMembershipMapper.class);
|
||||
IamRoleMapper roles = mock(IamRoleMapper.class);
|
||||
IamMembershipRoleMapper membershipRoles = mock(IamMembershipRoleMapper.class);
|
||||
IamTenantAddressMapper addresses = mock(IamTenantAddressMapper.class);
|
||||
IamTenantQualificationMapper qualifications = mock(IamTenantQualificationMapper.class);
|
||||
IamTenantReviewMapper reviews = mock(IamTenantReviewMapper.class);
|
||||
MerchantOnboardingService service = new MerchantOnboardingService();
|
||||
ReflectionTestUtils.setField(service, "tenantMapper", tenants);
|
||||
ReflectionTestUtils.setField(service, "userMapper", users);
|
||||
ReflectionTestUtils.setField(service, "membershipMapper", memberships);
|
||||
ReflectionTestUtils.setField(service, "roleMapper", roles);
|
||||
ReflectionTestUtils.setField(service, "membershipRoleMapper", membershipRoles);
|
||||
ReflectionTestUtils.setField(service, "addressMapper", addresses);
|
||||
ReflectionTestUtils.setField(service, "qualificationMapper", qualifications);
|
||||
ReflectionTestUtils.setField(service, "reviewMapper", reviews);
|
||||
|
||||
IamUser user = new IamUser();
|
||||
user.setId(20001L);
|
||||
IamMembership membership = new IamMembership();
|
||||
membership.setId(30001L);
|
||||
membership.setTenantId(10001L);
|
||||
membership.setUserId(20001L);
|
||||
membership.setMemberType("OWNER");
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L);
|
||||
tenant.setStatus("PROFILE_REQUIRED");
|
||||
when(users.selectOne(any())).thenReturn(user);
|
||||
when(memberships.selectList(any())).thenReturn(java.util.List.of(membership));
|
||||
when(tenants.selectOne(any())).thenReturn(tenant);
|
||||
when(qualifications.selectCount(any())).thenReturn(1L);
|
||||
when(reviews.selectCount(any())).thenReturn(0L);
|
||||
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||
() -> service.compensateRegistration(9001L));
|
||||
verify(membershipRoles, never()).delete(any());
|
||||
verify(users, never()).deleteById((java.io.Serializable) any());
|
||||
verify(tenants, never()).deleteById((java.io.Serializable) any());
|
||||
}
|
||||
}
|
||||
@@ -12,11 +12,13 @@ import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
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.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
|
||||
class MerchantQualificationUploadTest {
|
||||
|
||||
@@ -43,4 +45,31 @@ class MerchantQualificationUploadTest {
|
||||
verify(profileRepository).insertQualification(any());
|
||||
verify(tenantRepository, never()).insertReview(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void businessLicenseRequiresAndPersistsAUniqueUnifiedSocialCreditCode() {
|
||||
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());
|
||||
|
||||
assertThrows(BizException.class, () -> service.submitQualification(new QualificationSubmissionRequest(
|
||||
10001L, "BUSINESS_LICENSE", null,
|
||||
"/api/merchant/v1/files/qualifications/license.png", null, false)));
|
||||
|
||||
var response = service.submitQualification(new QualificationSubmissionRequest(
|
||||
10001L, "BUSINESS_LICENSE", "91310101abcde12345",
|
||||
"/api/merchant/v1/files/qualifications/license.png", null, false));
|
||||
|
||||
assertEquals("PROFILE_REQUIRED", response.tenantStatus());
|
||||
assertEquals("91310101ABCDE12345", tenant.getUnifiedSocialCreditCode());
|
||||
verify(tenantRepository).saveTenant(tenant);
|
||||
}
|
||||
}
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.common.core.api.ApiResponse;
|
||||
import com.baiyue.iam.client.AuthAccountClient;
|
||||
import com.baiyue.iam.entity.IamMembership;
|
||||
import com.baiyue.iam.entity.IamRole;
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import com.baiyue.iam.entity.IamUser;
|
||||
import com.baiyue.iam.mapper.IamRoleMapper;
|
||||
import com.baiyue.iam.repository.MemberRepository;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
|
||||
import java.security.SecureRandom;
|
||||
import java.util.List;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyLong;
|
||||
import static org.mockito.ArgumentMatchers.anyString;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.doThrow;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TenantAdministratorProvisioningServiceTest {
|
||||
private final MemberRepository members = mock(MemberRepository.class);
|
||||
private final IamRoleMapper roles = mock(IamRoleMapper.class);
|
||||
private final AuthAccountClient auth = mock(AuthAccountClient.class);
|
||||
private final TenantAdministratorProvisioningService service =
|
||||
new TenantAdministratorProvisioningService(members, roles, auth, "internal-token", new SecureRandom());
|
||||
private IamTenant tenant;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
tenant = new IamTenant();
|
||||
tenant.setId(190000000000000001L);
|
||||
tenant.setCreatedBy(7000L);
|
||||
tenant.setContactName("张三");
|
||||
tenant.setContactPhone("13800138000");
|
||||
tenant.setContactEmail("owner@example.com");
|
||||
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);
|
||||
when(auth.create(anyString(), any())).thenAnswer(invocation -> {
|
||||
AuthAccountClient.CreateAccountRequest request = invocation.getArgument(1);
|
||||
return ApiResponse.success(new AuthAccountClient.AccountSummary(
|
||||
request.accountId(), request.username(), request.displayName(), "ACTIVE"));
|
||||
});
|
||||
when(auth.compensate(anyString(), anyLong())).thenReturn(ApiResponse.success(null));
|
||||
}
|
||||
|
||||
@Test
|
||||
void missingOwnerCreatesAccountProfileMembershipAndOwnerRole() {
|
||||
var result = service.initialize(context());
|
||||
|
||||
assertTrue(result.created());
|
||||
assertTrue(result.username().matches("^merchant_190000000000000001_[a-z]{8}$"));
|
||||
assertNotNull(result.temporaryPassword());
|
||||
assertEquals(16, result.temporaryPassword().length());
|
||||
assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$"));
|
||||
|
||||
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> account =
|
||||
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
|
||||
verify(auth).create(eq("internal-token"), account.capture());
|
||||
assertEquals(result.username(), account.getValue().username());
|
||||
assertEquals(result.temporaryPassword(), account.getValue().password());
|
||||
assertEquals("MERCHANT", account.getValue().appCode());
|
||||
assertEquals(tenant.getId(), account.getValue().tenantId());
|
||||
verify(members).insertUser(any(IamUser.class));
|
||||
verify(members).insert(any(IamMembership.class));
|
||||
verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L)));
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingOwnerSkipsProvisioningAndDoesNotReturnCredentials() {
|
||||
IamMembership owner = activeOwner();
|
||||
IamUser user = activeUser();
|
||||
when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner);
|
||||
when(members.findUser(owner.getUserId())).thenReturn(user);
|
||||
when(auth.summary("internal-token", user.getAuthUserId())).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(user.getAuthUserId(), "existing.owner", "张三", "ACTIVE",
|
||||
null, tenant.getId(), owner.getId(), "MERCHANT")));
|
||||
when(members.findRoleIds(tenant.getId(), owner.getId())).thenReturn(List.of(4000100L));
|
||||
|
||||
var result = service.initialize(context());
|
||||
|
||||
assertFalse(result.created());
|
||||
assertEquals(null, result.username());
|
||||
assertEquals(null, result.temporaryPassword());
|
||||
verify(auth, never()).create(anyString(), any());
|
||||
verify(members, never()).replaceRoles(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void existingRegisteredOwnerKeepsAccountAndRepairsMissingAdministratorRole() {
|
||||
IamMembership owner = activeOwner();
|
||||
IamUser user = activeUser();
|
||||
when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner);
|
||||
when(members.findUser(owner.getUserId())).thenReturn(user);
|
||||
when(auth.summary("internal-token", user.getAuthUserId())).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(user.getAuthUserId(), "registered.owner", "张三", "ACTIVE",
|
||||
null, tenant.getId(), owner.getId(), "MERCHANT")));
|
||||
when(members.findRoleIds(tenant.getId(), owner.getId())).thenReturn(List.of(4000200L));
|
||||
|
||||
var result = service.initialize(context());
|
||||
|
||||
assertFalse(result.created());
|
||||
verify(auth, never()).create(anyString(), any());
|
||||
verify(members).replaceRoles(tenant.getId(), owner.getId(), List.of(4000200L, 4000100L));
|
||||
}
|
||||
|
||||
@Test
|
||||
void clientRegistrationWithoutOriginalOwnerIsRejectedInsteadOfCreatingAnotherAdministrator() {
|
||||
tenant.setCreatedBy(null);
|
||||
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
|
||||
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||
() -> service.initialize(context()));
|
||||
|
||||
verify(auth, never()).create(anyString(), any());
|
||||
verify(members, never()).insertUser(any());
|
||||
verify(members, never()).insert(any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void ownerWithoutAuthAccountRepairsAccountUsingExistingIdentity() {
|
||||
IamMembership owner = activeOwner();
|
||||
IamUser user = activeUser();
|
||||
when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner);
|
||||
when(members.findUser(owner.getUserId())).thenReturn(user);
|
||||
when(auth.summary("internal-token", user.getAuthUserId())).thenReturn(
|
||||
ApiResponse.failure(com.baiyue.common.core.api.ResultCode.NOT_FOUND,
|
||||
"账号不存在", null, null));
|
||||
|
||||
var result = service.initialize(context());
|
||||
|
||||
assertTrue(result.created());
|
||||
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> account =
|
||||
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
|
||||
verify(auth).create(eq("internal-token"), account.capture());
|
||||
assertEquals(user.getAuthUserId(), account.getValue().accountId());
|
||||
assertEquals(owner.getId(), account.getValue().membershipId());
|
||||
assertEquals(tenant.getId(), account.getValue().tenantId());
|
||||
verify(members, never()).insertUser(any());
|
||||
verify(members, never()).insert(any());
|
||||
verify(members, never()).replaceRoles(anyLong(), anyLong(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledOwnerPreventsApprovalInsteadOfSilentlySkippingProvisioning() {
|
||||
IamMembership owner = activeOwner();
|
||||
owner.setStatus("DISABLED");
|
||||
when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner);
|
||||
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||
() -> service.initialize(context()));
|
||||
|
||||
verify(auth, never()).summary(anyString(), anyLong());
|
||||
verify(auth, never()).create(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void mismatchedAuthScopePreventsApproval() {
|
||||
IamMembership owner = activeOwner();
|
||||
IamUser user = activeUser();
|
||||
when(members.findOwnerByTenant(tenant.getId())).thenReturn(owner);
|
||||
when(members.findUser(owner.getUserId())).thenReturn(user);
|
||||
when(auth.summary("internal-token", user.getAuthUserId())).thenReturn(ApiResponse.success(
|
||||
new AuthAccountClient.AccountSummary(user.getAuthUserId(), "existing.owner", "张三", "ACTIVE",
|
||||
null, tenant.getId() + 1, owner.getId(), "MERCHANT")));
|
||||
|
||||
assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||
() -> service.initialize(context()));
|
||||
|
||||
verify(auth, never()).create(anyString(), any());
|
||||
}
|
||||
|
||||
@Test
|
||||
void localWriteFailureCompensatesCreatedAuthAccount() {
|
||||
doThrow(new RuntimeException("IAM write failed")).when(members).insertUser(any());
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.initialize(context()));
|
||||
|
||||
verify(auth).compensate(eq("internal-token"), anyLong());
|
||||
verify(members, never()).insert(any(IamMembership.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void usernameConflictGeneratesAnotherUsernameBeforeCreatingOwner() {
|
||||
org.mockito.Mockito.doReturn(ApiResponse.failure(com.baiyue.common.core.api.ResultCode.CONFLICT,
|
||||
"登录账号已存在", null, null))
|
||||
.doAnswer(invocation -> {
|
||||
AuthAccountClient.CreateAccountRequest request = invocation.getArgument(1);
|
||||
return ApiResponse.success(new AuthAccountClient.AccountSummary(
|
||||
request.accountId(), request.username(), request.displayName(), "ACTIVE"));
|
||||
}).when(auth).create(anyString(), any());
|
||||
|
||||
var result = service.initialize(context());
|
||||
|
||||
assertTrue(result.created());
|
||||
ArgumentCaptor<AuthAccountClient.CreateAccountRequest> accounts =
|
||||
ArgumentCaptor.forClass(AuthAccountClient.CreateAccountRequest.class);
|
||||
verify(auth, times(2)).create(eq("internal-token"), accounts.capture());
|
||||
assertFalse(accounts.getAllValues().get(0).username().equals(accounts.getAllValues().get(1).username()));
|
||||
assertEquals(accounts.getAllValues().get(1).username(), result.username());
|
||||
}
|
||||
|
||||
private TenantApprovalInitializer.ApprovalContext context() {
|
||||
return new TenantApprovalInitializer.ApprovalContext(tenant, 71L, 9001L, "资料完整");
|
||||
}
|
||||
|
||||
private IamMembership activeOwner() {
|
||||
IamMembership owner = new IamMembership();
|
||||
owner.setId(7001L);
|
||||
owner.setTenantId(tenant.getId());
|
||||
owner.setUserId(8001L);
|
||||
owner.setMemberType("OWNER");
|
||||
owner.setStatus("ACTIVE");
|
||||
return owner;
|
||||
}
|
||||
|
||||
private IamUser activeUser() {
|
||||
IamUser user = new IamUser();
|
||||
user.setId(8001L);
|
||||
user.setAuthUserId(9001L);
|
||||
user.setStatus("ACTIVE");
|
||||
return user;
|
||||
}
|
||||
}
|
||||
+95
@@ -0,0 +1,95 @@
|
||||
package com.baiyue.iam.service;
|
||||
|
||||
import com.baiyue.iam.entity.IamTenant;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.InOrder;
|
||||
import org.springframework.core.annotation.Order;
|
||||
|
||||
import java.util.ArrayList;
|
||||
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.inOrder;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class TenantApprovalInitializationCoordinatorTest {
|
||||
@Test
|
||||
void initializerFailureCompensatesCompletedStepsInReverseOrder() {
|
||||
TenantApprovalInitializer first = initializer("rules");
|
||||
TenantApprovalInitializer second = initializer("fees");
|
||||
TenantApprovalInitializer failing = initializer("roles");
|
||||
TenantApprovalInitializer.InitializationResult firstResult = new TestResult("rules");
|
||||
TenantApprovalInitializer.InitializationResult secondResult = new TestResult("fees");
|
||||
var context = context();
|
||||
when(first.initialize(context)).thenReturn(firstResult);
|
||||
when(second.initialize(context)).thenReturn(secondResult);
|
||||
when(failing.initialize(context)).thenThrow(new RuntimeException("role initialization failed"));
|
||||
var coordinator = new TenantApprovalInitializationCoordinator(List.of(first, second, failing));
|
||||
|
||||
assertThrows(RuntimeException.class, () -> coordinator.initialize(context));
|
||||
|
||||
InOrder compensationOrder = inOrder(second, first);
|
||||
compensationOrder.verify(second).compensate(context, secondResult);
|
||||
compensationOrder.verify(first).compensate(context, firstResult);
|
||||
}
|
||||
|
||||
@Test
|
||||
void completedBatchExposesTypedResultsForApprovalResponseAssembly() {
|
||||
TenantApprovalInitializer initializer = initializer("administrator");
|
||||
TestResult result = new TestResult("credential-result");
|
||||
var context = context();
|
||||
when(initializer.initialize(context)).thenReturn(result);
|
||||
var coordinator = new TenantApprovalInitializationCoordinator(List.of(initializer));
|
||||
|
||||
var batch = coordinator.initialize(context);
|
||||
|
||||
assertEquals(result, batch.result(TestResult.class).orElseThrow());
|
||||
}
|
||||
|
||||
@Test
|
||||
void springOrderControlsInitializerExecutionSequence() {
|
||||
List<String> calls = new ArrayList<>();
|
||||
var coordinator = new TenantApprovalInitializationCoordinator(
|
||||
List.of(new LateInitializer(calls), new EarlyInitializer(calls)));
|
||||
|
||||
coordinator.initialize(context());
|
||||
|
||||
assertEquals(List.of("early", "late"), calls);
|
||||
}
|
||||
|
||||
private TenantApprovalInitializer initializer(String name) {
|
||||
TenantApprovalInitializer initializer = mock(TenantApprovalInitializer.class);
|
||||
when(initializer.name()).thenReturn(name);
|
||||
return initializer;
|
||||
}
|
||||
|
||||
private TenantApprovalInitializer.ApprovalContext context() {
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(10001L);
|
||||
return new TenantApprovalInitializer.ApprovalContext(tenant, 71L, 9001L, "资料完整");
|
||||
}
|
||||
|
||||
private record TestResult(String value) implements TenantApprovalInitializer.InitializationResult { }
|
||||
|
||||
@Order(10)
|
||||
private static final class EarlyInitializer implements TenantApprovalInitializer {
|
||||
private final List<String> calls;
|
||||
private EarlyInitializer(List<String> calls) { this.calls = calls; }
|
||||
public String name() { return "early"; }
|
||||
public InitializationResult initialize(ApprovalContext context) {
|
||||
calls.add("early"); return new TestResult("early");
|
||||
}
|
||||
}
|
||||
|
||||
@Order(200)
|
||||
private static final class LateInitializer implements TenantApprovalInitializer {
|
||||
private final List<String> calls;
|
||||
private LateInitializer(List<String> calls) { this.calls = calls; }
|
||||
public String name() { return "late"; }
|
||||
public InitializationResult initialize(ApprovalContext context) {
|
||||
calls.add("late"); return new TestResult("late");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ 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.entity.IamTenantReview;
|
||||
import com.baiyue.iam.repository.TenantRepository;
|
||||
import com.baiyue.iam.repository.ProfileResourceRepository;
|
||||
import org.junit.jupiter.api.Test;
|
||||
@@ -21,7 +22,10 @@ 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.Mockito.doThrow;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import com.baiyue.common.core.exception.BizException;
|
||||
import com.baiyue.common.core.api.ResultCode;
|
||||
@@ -75,12 +79,13 @@ class TenantServiceTest {
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
|
||||
var updated = service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Alice", "13900139000", "alice@example.com",
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", "alice@example.com",
|
||||
"专业版", "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("Bob", updated.legalRepresentative());
|
||||
assertEquals("alice_wechat", updated.contactWechat());
|
||||
assertEquals("专业版", updated.servicePackage());
|
||||
assertEquals("Sales Zhang", updated.salesOwnerName());
|
||||
@@ -115,12 +120,16 @@ class TenantServiceTest {
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
|
||||
var created = service.register(new RegisterRequest(
|
||||
"Example Merchant", "Example Merchant Ltd", "91330100EXAMPLE001", "Alice", "13800138000",
|
||||
"Example Merchant", "Example Merchant Ltd", "Bob", "91330100EXAMPLE001", "Alice", "13800138000",
|
||||
"alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY",
|
||||
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"));
|
||||
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
|
||||
9001L);
|
||||
|
||||
assertEquals("PROFILE_REQUIRED", created.status());
|
||||
assertEquals("Bob", created.legalRepresentative());
|
||||
assertEquals("基础版", created.servicePackage());
|
||||
verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy())
|
||||
&& Long.valueOf(9001L).equals(tenant.getUpdatedBy())));
|
||||
verify(repository, never()).insertReview(any());
|
||||
}
|
||||
|
||||
@@ -162,14 +171,132 @@ class TenantServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void 企业激活不等于具备下单资格仍需已审核资质() {
|
||||
void explicitSubmissionRejectsMissingUnifiedSocialCreditCodeBeforeCreatingReview() {
|
||||
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");
|
||||
tenant.setUnifiedSocialCreditCode(null);
|
||||
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
|
||||
|
||||
BizException exception = assertThrows(BizException.class, () -> service.submitMerchantReview(10001L));
|
||||
|
||||
assertEquals(ResultCode.BAD_REQUEST, exception.getResultCode());
|
||||
verify(repository, never()).insertReview(any());
|
||||
verify(profileRepository, never()).findQualifications(10001L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalProvisionsMissingAdministratorAndReturnsCredentialsOnce() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
TenantAdministratorProvisioningService provisioning = mock(TenantAdministratorProvisioningService.class);
|
||||
when(provisioning.name()).thenReturn("tenant-administrator");
|
||||
TenantApprovalInitializationCoordinator initialization =
|
||||
new TenantApprovalInitializationCoordinator(List.of(provisioning));
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
|
||||
ReflectionTestUtils.setField(service, "approvalInitialization", initialization);
|
||||
IamTenantReview review = review(71L, 10001L);
|
||||
IamTenant tenant = merchant(10001L, "PENDING");
|
||||
when(repository.findReviewForUpdate(71L)).thenReturn(review);
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"),
|
||||
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
|
||||
when(provisioning.initialize(any())).thenReturn(
|
||||
TenantAdministratorProvisioningService.ProvisioningResult.created(
|
||||
81L, "merchant_10001", "Password1234"));
|
||||
|
||||
var result = service.review(71L, 9001L, true, "资料完整");
|
||||
|
||||
assertEquals(10001L, result.tenantId());
|
||||
assertEquals(true, result.administratorCreated());
|
||||
assertEquals("merchant_10001", result.administratorUsername());
|
||||
assertEquals("Password1234", result.temporaryPassword());
|
||||
assertEquals("APPROVED", review.getStatus());
|
||||
assertEquals("ACTIVE", tenant.getStatus());
|
||||
verify(repository).saveReview(review);
|
||||
verify(repository).saveTenant(tenant);
|
||||
}
|
||||
|
||||
@Test
|
||||
void approvalKeepsClientRegisteredAccountAsAdministratorWithoutNewCredentials() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
TenantAdministratorProvisioningService provisioning = mock(TenantAdministratorProvisioningService.class);
|
||||
when(provisioning.name()).thenReturn("tenant-administrator");
|
||||
TenantApprovalInitializationCoordinator initialization =
|
||||
new TenantApprovalInitializationCoordinator(List.of(provisioning));
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
|
||||
ReflectionTestUtils.setField(service, "approvalInitialization", initialization);
|
||||
IamTenantReview review = review(71L, 10001L);
|
||||
IamTenant tenant = merchant(10001L, "PENDING");
|
||||
when(repository.findReviewForUpdate(71L)).thenReturn(review);
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"),
|
||||
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
|
||||
when(provisioning.initialize(any())).thenReturn(
|
||||
TenantAdministratorProvisioningService.ProvisioningResult.existing());
|
||||
|
||||
var result = service.review(71L, 9001L, true, "资料完整");
|
||||
|
||||
assertEquals(false, result.administratorCreated());
|
||||
assertEquals(null, result.administratorUsername());
|
||||
assertEquals(null, result.temporaryPassword());
|
||||
assertEquals("APPROVED", review.getStatus());
|
||||
assertEquals("ACTIVE", tenant.getStatus());
|
||||
}
|
||||
|
||||
@Test
|
||||
void failureAfterProvisioningCompensatesAdministratorAndKeepsApprovalFailed() {
|
||||
TenantRepository repository = mock(TenantRepository.class);
|
||||
ProfileResourceRepository profileRepository = mock(ProfileResourceRepository.class);
|
||||
TenantAdministratorProvisioningService provisioning = mock(TenantAdministratorProvisioningService.class);
|
||||
when(provisioning.name()).thenReturn("tenant-administrator");
|
||||
TenantApprovalInitializationCoordinator initialization =
|
||||
new TenantApprovalInitializationCoordinator(List.of(provisioning));
|
||||
TenantService service = new TenantService();
|
||||
ReflectionTestUtils.setField(service, "repository", repository);
|
||||
ReflectionTestUtils.setField(service, "profileRepository", profileRepository);
|
||||
ReflectionTestUtils.setField(service, "approvalInitialization", initialization);
|
||||
IamTenantReview review = review(71L, 10001L);
|
||||
IamTenant tenant = merchant(10001L, "PENDING");
|
||||
var administrator = TenantAdministratorProvisioningService.ProvisioningResult.created(
|
||||
81L, "merchant_10001", "Password1234");
|
||||
when(repository.findReviewForUpdate(71L)).thenReturn(review);
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(List.of(
|
||||
qualification("BUSINESS_LICENSE"), qualification("LEGAL_PERSON_ID"),
|
||||
qualification("FREIGHT_FORWARDER_CERTIFICATE"), qualification("BANK_ACCOUNT_LICENSE")));
|
||||
when(provisioning.initialize(any())).thenReturn(administrator);
|
||||
doThrow(new RuntimeException("review write failed")).when(repository).saveReview(review);
|
||||
|
||||
assertThrows(RuntimeException.class, () -> service.review(71L, 9001L, true, "资料完整"));
|
||||
|
||||
verify(provisioning).compensate(any(), eq(administrator));
|
||||
verify(repository, never()).saveTenant(tenant);
|
||||
}
|
||||
|
||||
@Test
|
||||
void 企业激活不等于具备下单资格仍需四类资质全部审核通过() {
|
||||
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.findTenant(10001L)).thenReturn(merchant(10001L, "ACTIVE"));
|
||||
when(profileRepository.countApprovedQualifications(10001L)).thenReturn(0L, 1L);
|
||||
when(profileRepository.findQualifications(10001L)).thenReturn(
|
||||
List.of(approvedQualification("BUSINESS_LICENSE")),
|
||||
List.of(approvedQualification("BUSINESS_LICENSE"), approvedQualification("LEGAL_PERSON_ID"),
|
||||
approvedQualification("FREIGHT_FORWARDER_CERTIFICATE"),
|
||||
approvedQualification("BANK_ACCOUNT_LICENSE")));
|
||||
|
||||
var withoutQualification = service.orderEligibility(10001L);
|
||||
var withQualification = service.orderEligibility(10001L);
|
||||
@@ -198,4 +325,16 @@ class TenantServiceTest {
|
||||
item.setQualificationType(type); item.setStatus("PENDING");
|
||||
return item;
|
||||
}
|
||||
|
||||
private IamTenantQualification approvedQualification(String type) {
|
||||
IamTenantQualification item = qualification(type);
|
||||
item.setStatus("APPROVED");
|
||||
return item;
|
||||
}
|
||||
|
||||
private IamTenantReview review(long id, long tenantId) {
|
||||
IamTenantReview review = new IamTenantReview();
|
||||
review.setId(id); review.setTenantId(tenantId); review.setStatus("PENDING");
|
||||
return review;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,4 +36,24 @@ WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 40000
|
||||
UNION ALL
|
||||
SELECT 'merchant_owner_not_fully_granted'
|
||||
WHERE (SELECT COUNT(*) FROM baiyue_iam.iam_role_permission WHERE role_id = 4000101)
|
||||
<> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON'));
|
||||
<> (SELECT COUNT(*) FROM baiyue_iam.iam_permission WHERE tenant_id = 10001 AND app_code = 'MERCHANT' AND permission_type IN ('MENU', 'BUTTON'))
|
||||
UNION ALL
|
||||
SELECT 'missing_global_merchant_owner_template'
|
||||
WHERE NOT EXISTS (
|
||||
SELECT 1 FROM baiyue_iam.iam_role
|
||||
WHERE tenant_id = 0 AND app_code = 'MERCHANT' AND role_code = 'MERCHANT_OWNER'
|
||||
AND role_type = 'SYSTEM' AND status = 'ACTIVE' AND deleted = 0
|
||||
)
|
||||
UNION ALL
|
||||
SELECT 'global_merchant_owner_not_fully_granted'
|
||||
WHERE (
|
||||
SELECT COUNT(*)
|
||||
FROM baiyue_iam.iam_role_permission role_permission
|
||||
JOIN baiyue_iam.iam_role owner_role ON owner_role.id = role_permission.role_id
|
||||
WHERE owner_role.tenant_id = 0 AND owner_role.app_code = 'MERCHANT'
|
||||
AND owner_role.role_code = 'MERCHANT_OWNER'
|
||||
) <> (
|
||||
SELECT COUNT(*) FROM baiyue_iam.iam_permission
|
||||
WHERE tenant_id = 0 AND app_code = 'MERCHANT'
|
||||
AND permission_type IN ('MENU', 'BUTTON') AND status = 'ACTIVE' AND deleted = 0
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user