This commit is contained in:
2026-08-04 09:05:28 +08:00
parent fdb1e8af1c
commit 3afcff320a
116 changed files with 6749 additions and 749 deletions
+2 -2
View File
@@ -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` 指定的统一迁移为准。
+8
View File
@@ -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; }
}
}
@@ -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(),
+1
View File
@@ -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。
+63 -6
View File
@@ -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}
@@ -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);
}
@@ -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));
}
}
@@ -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);
}
}
@@ -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;
}
}
@@ -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
);