This commit is contained in:
2026-08-08 10:31:44 +08:00
parent ced35f1600
commit 2644718b5f
32 changed files with 9710 additions and 31 deletions
@@ -10,6 +10,7 @@ import com.baiyue.iam.dto.TenantDtos.UpdateTenantRequest;
import com.baiyue.iam.dto.TenantDtos.ChangeTenantStatusRequest;
import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.TenantDtos.ApprovalResponse;
import com.baiyue.iam.dto.TenantDtos.CreationResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.entity.IamTenantReview;
@@ -98,15 +99,15 @@ public class PlatformTenantController {
@PostMapping("/tenants")
@PreAuthorize("hasAuthority('platform:customer:create')")
@Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft only. Saving the customer does not create a review ticket. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。")
@Operation(summary = "Register enterprise tenant / 创建企业客户草稿", description = "Create a PROFILE_REQUIRED merchant draft, its OWNER account and one-time credentials. Saving the customer does not create a review ticket. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "Registration created / 注册已创建"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "Invalid enterprise data / 企业资料不合法"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "Platform scope required / 需要平台端权限域"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Enterprise already exists / 企业已存在")
})
public ApiResponse<TenantResponse> register(@Valid @RequestBody RegisterRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
public ApiResponse<CreationResponse> register(@Valid @RequestBody RegisterRequest request,
@AuthenticationPrincipal AuthenticatedUser user) {
TenantGuard.tenant(user, "PLATFORM");
return ApiResponse.success(service.register(request, user.userId()));
}
@@ -109,6 +109,13 @@ public final class TenantDtos {
@Schema(description = "One-time temporary password; only returned when created / 一次性初始密码,仅新建时返回", format = "password", nullable = true) String temporaryPassword
) { }
@Schema(name = "TenantCreationResponse", description = "Created enterprise draft and one-time administrator credentials / 新建企业草稿及一次性管理员凭据")
public record CreationResponse(
@Schema(description = "Created enterprise draft / 新建企业草稿") TenantResponse tenant,
@Schema(description = "Contact phone used as login username / 使用联系电话作为登录账号") String administratorUsername,
@Schema(description = "One-time temporary password / 一次性初始密码", format = "password") String temporaryPassword
) { }
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
@@ -217,7 +217,16 @@ public class RbacService {
throw new BizException(ResultCode.CONFLICT, "停用角色不能分配给成员");
}
}
Set<Long> currentRoleIds = new LinkedHashSet<>();
for (Long roleId : repository.findMemberRoleIds(tenantId, request.membershipId())) {
if (repository.findRole(roleId, tenantId, appCode) != null) currentRoleIds.add(roleId);
}
repository.replaceMemberRoles(tenantId, appCode, request.membershipId(), request.roleIds());
if (!currentRoleIds.equals(request.roleIds())) {
var user = memberRepository.findUser(membership.getUserId());
if (user == null || user.getAuthUserId() == null) throw sessionRevocationUnavailable();
revokeSessions(List.of(user.getAuthUserId()));
}
}
@BusinessAudit(event = "ROLE_PERMISSION_GRANT", resourceType = "ROLE",
@@ -238,7 +247,10 @@ public class RbacService {
}
private void revokeAssignedSessions(long tenantId, Long roleId) {
List<Long> accountIds = repository.findAssignedAuthUserIds(tenantId, roleId);
revokeSessions(repository.findAssignedAuthUserIds(tenantId, roleId));
}
private void revokeSessions(List<Long> accountIds) {
for (int start = 0; start < accountIds.size(); start += SESSION_REVOCATION_BATCH_SIZE) {
Set<Long> batch = Set.copyOf(accountIds.subList(start,
Math.min(start + SESSION_REVOCATION_BATCH_SIZE, accountIds.size())));
@@ -19,6 +19,8 @@ import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import java.security.SecureRandom;
import java.time.LocalDateTime;
@@ -48,6 +50,27 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
@Override
public String name() { return "tenant-administrator"; }
/** Creates the owner during platform customer creation and compensates Auth if the IAM transaction rolls back. */
public ProvisioningResult provisionPlatformCreatedTenant(IamTenant tenant, long operatorId) {
ApprovalContext context = new ApprovalContext(tenant, 0L, operatorId, "platform customer creation");
ProvisioningResult result = initialize(context);
if (result.created() && TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCompletion(int status) {
if (status != STATUS_ROLLED_BACK) return;
try {
compensate(context, result);
} catch (RuntimeException compensationFailure) {
log.error("平台创建客户事务回滚后认证账号补偿失败,tenantId={}, authUserId={}",
tenant.getId(), result.authUserId(), compensationFailure);
}
}
});
}
return result;
}
@Override
public ProvisioningResult initialize(ApprovalContext context) {
IamTenant tenant = context.tenant();
@@ -104,7 +127,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
.eq(IamRole::getStatus, "ACTIVE")
.last("LIMIT 1"));
if (ownerRole == null) {
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业管理员角色模板未初始化,不能审核通过");
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "企业管理员角色模板未初始化,不能创建或确认企业管理员");
}
return ownerRole;
}
@@ -239,7 +262,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
private char pick(char[] source) { return source[random.nextInt(source.length)]; }
private BizException unavailable() {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能审核通过");
return new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能创建或确认企业管理员");
}
private BizException inconsistentOwner(String message) {
return new BizException(ResultCode.SERVICE_UNAVAILABLE, message);
@@ -25,6 +25,7 @@ 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.TenantDtos.CreationResponse;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationResponse;
import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportBatch;
@@ -61,6 +62,8 @@ public class TenantService {
private TenantApprovalInitializationCoordinator approvalInitialization;
@Resource
private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource
private TenantAdministratorProvisioningService administratorProvisioning;
@Transactional
@BusinessAudit(event = "PLATFORM_CUSTOMER_OWNER_PASSWORD_RESET", resourceType = "TENANT",
@@ -148,7 +151,7 @@ public class TenantService {
captureAfter = false
)
@Transactional
public TenantResponse register(RegisterRequest request, long operatorId) {
public CreationResponse register(RegisterRequest request, long operatorId) {
String contactPhone = normalizeContactPhone(request.contactPhone());
ensureContactPhoneAvailable(contactPhone, null);
IamTenant tenant = new IamTenant();
@@ -177,8 +180,9 @@ public class TenantService {
tenant.setUpdatedBy(operatorId);
repository.insertTenant(tenant);
dimensionRuleInitialization.initialize(tenant.getId(), operatorId);
var administrator = administratorProvisioning.provisionPlatformCreatedTenant(tenant, operatorId);
return response(tenant);
return new CreationResponse(response(tenant), administrator.username(), administrator.temporaryPassword());
}
public List<IamTenantReview> pendingReviews() {
+16 -2
View File
@@ -59,14 +59,14 @@ paths:
post:
tags: [Platform Tenant / 平台租户]
summary: Register enterprise tenant / 创建企业客户草稿
description: Create a `PROFILE_REQUIRED` enterprise draft only. Saving the customer does not create a review task. / 仅创建待完善企业客户草稿,保存客户不会创建审核单。
description: Create a `PROFILE_REQUIRED` enterprise draft and its OWNER account, returning one-time credentials. Saving the customer does not create a review task. / 创建待完善企业客户草稿及 OWNER 账号并返回一次性凭据,保存客户不会创建审核单。
requestBody:
required: true
content:
application/json:
schema: {$ref: '#/components/schemas/RegisterRequest'}
responses:
'200': {$ref: '#/components/responses/TenantSuccess'}
'200': {$ref: '#/components/responses/TenantCreationSuccess'}
'400': {$ref: '#/components/responses/BadRequest'}
'403': {$ref: '#/components/responses/Forbidden'}
'409': {$ref: '#/components/responses/Conflict'}
@@ -822,6 +822,7 @@ components:
responses:
Success: {description: Operation succeeded / 操作成功, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
TenantSuccess: {description: Enterprise tenant returned / 返回企业客户档案, content: {application/json: {schema: {$ref: '#/components/schemas/TenantApiResponse'}}}}
TenantCreationSuccess: {description: Enterprise tenant and one-time owner credentials returned / 返回企业客户及一次性管理员凭据, content: {application/json: {schema: {$ref: '#/components/schemas/TenantCreationApiResponse'}}}}
BadRequest: {description: Invalid request / 请求参数错误, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Unauthorized: {description: Authentication required / 需要登录, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
Forbidden: {description: Permission, customer scope or platform technical scope rejected / 权限、客户范围或平台技术作用域不匹配, content: {application/json: {schema: {$ref: '#/components/schemas/ApiResponse'}}}}
@@ -869,6 +870,19 @@ components:
- type: object
properties:
data: {$ref: '#/components/schemas/TenantApprovalResponse'}
TenantCreationApiResponse:
allOf:
- {$ref: '#/components/schemas/ApiResponse'}
- type: object
properties:
data: {$ref: '#/components/schemas/TenantCreationResponse'}
TenantCreationResponse:
type: object
required: [tenant, administratorUsername, temporaryPassword]
properties:
tenant: {$ref: '#/components/schemas/TenantResponse'}
administratorUsername: {type: string, example: '13800138000'}
temporaryPassword: {type: string, format: password, description: 一次性初始密码,仅本次创建响应返回}
TenantApprovalResponse:
type: object
required: [tenantId, administratorCreated]
@@ -66,6 +66,16 @@ class PlatformTenantControllerContractTest {
returnType.getActualTypeArguments()[0]);
}
@Test
void platformCreationReturnsTenantAndOneTimeAdministratorCredentials() throws Exception {
Method method = PlatformTenantController.class.getMethod("register",
com.baiyue.iam.dto.TenantDtos.RegisterRequest.class, AuthenticatedUser.class);
ParameterizedType returnType = (ParameterizedType) method.getGenericReturnType();
assertEquals(com.baiyue.iam.dto.TenantDtos.CreationResponse.class,
returnType.getActualTypeArguments()[0]);
}
@Test
void shouldExposeCustomerOwnerPasswordResetWithDedicatedPermission() throws Exception {
Method method = PlatformTenantController.class.getMethod("resetOwnerPassword", long.class,
@@ -15,6 +15,7 @@ import com.baiyue.iam.dto.RbacDtos.ChangePermissionStatusRequest;
import com.baiyue.iam.entity.IamMembership;
import com.baiyue.iam.entity.IamPermission;
import com.baiyue.iam.entity.IamRole;
import com.baiyue.iam.entity.IamUser;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.RbacRepository;
import org.junit.jupiter.api.Test;
@@ -363,6 +364,47 @@ class RbacServiceTest {
assertEquals("认证会话撤销失败,角色变更未生效", exception.getMessage());
}
@Test
void clearingMemberRolesRevokesThatMembersExistingSessions() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
membership.setUserId(41L);
membership.setMemberType("EMPLOYEE");
IamUser user = new IamUser();
user.setId(41L);
user.setAuthUserId(501L);
when(memberRepository.findById(31L)).thenReturn(membership);
when(memberRepository.findUser(41L)).thenReturn(user);
when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L));
when(repository.findRole(11L, 100L, "MERCHANT"))
.thenReturn(role(11L, 100L, "MERCHANT", "CUSTOM"));
when(authClient.revokeSessions(any(), any())).thenReturn(ApiResponse.success(null));
service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of()));
verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of());
verify(authClient).revokeSessions(eq("test-internal-token"),
eq(new AuthAccountClient.RevokeAccountSessionsRequest(Set.of(501L))));
}
@Test
void savingUnchangedMemberRolesDoesNotRevokeSessions() {
IamMembership membership = new IamMembership();
membership.setId(31L);
membership.setTenantId(100L);
membership.setMemberType("EMPLOYEE");
IamRole role = role(11L, 100L, "MERCHANT", "CUSTOM");
when(memberRepository.findById(31L)).thenReturn(membership);
when(repository.findMemberRoleIds(100L, 31L)).thenReturn(Set.of(11L));
when(repository.findRole(11L, 100L, "MERCHANT")).thenReturn(role);
service.assignRoles(100L, "MERCHANT", new AssignRolesRequest(31L, Set.of(11L)));
verify(repository).replaceMemberRoles(100L, "MERCHANT", 31L, Set.of(11L));
verifyNoInteractions(authClient);
}
private IamRole role(Long id, Long tenantId, String appCode, String roleType) {
IamRole role = new IamRole();
role.setId(id);
@@ -9,6 +9,7 @@ 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.AfterEach;
import org.junit.jupiter.api.Test;
import org.mockito.ArgumentCaptor;
import jakarta.annotation.Resource;
@@ -18,6 +19,8 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
import java.security.SecureRandom;
import java.util.List;
import org.springframework.transaction.support.TransactionSynchronization;
import org.springframework.transaction.support.TransactionSynchronizationManager;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
@@ -43,6 +46,13 @@ class TenantAdministratorProvisioningServiceTest {
@Resource private TenantAdministratorProvisioningService service;
private IamTenant tenant;
@AfterEach
void clearSynchronization() {
if (TransactionSynchronizationManager.isSynchronizationActive()) {
TransactionSynchronizationManager.clearSynchronization();
}
}
@Test
void springContextCreatesBean() {
assertNotNull(service);
@@ -93,6 +103,20 @@ class TenantAdministratorProvisioningServiceTest {
verify(members).replaceRoles(eq(tenant.getId()), anyLong(), eq(List.of(4000100L)));
}
@Test
void platformCreationRollbackCompensatesTheNewAuthAccount() {
TransactionSynchronizationManager.initSynchronization();
var result = service.provisionPlatformCreatedTenant(tenant, 7000L);
List<TransactionSynchronization> synchronizations =
TransactionSynchronizationManager.getSynchronizations();
TransactionSynchronizationManager.clearSynchronization();
synchronizations.forEach(value -> value.afterCompletion(TransactionSynchronization.STATUS_ROLLED_BACK));
assertTrue(result.created());
verify(auth).compensate("internal-token", result.authUserId());
}
@Test
void existingOwnerSkipsProvisioningAndDoesNotReturnCredentials() {
IamMembership owner = activeOwner();
@@ -26,6 +26,7 @@ class TenantCustomerExportTest {
@MockBean private AuthAccountClient authAccountClient;
@MockBean private TenantApprovalInitializationCoordinator approvalInitialization;
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private TenantAdministratorProvisioningService administratorProvisioning;
@Resource private TenantService service;
@Test
void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
@@ -222,6 +222,9 @@ class TenantServiceTest {
@Test
void platformRegistrationCreatesDraftWithoutCreatingReviewTicket() {
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
81L, "13800138000", "Password1234"))
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
@@ -229,12 +232,15 @@ class TenantServiceTest {
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
9001L);
assertEquals("PROFILE_REQUIRED", created.status());
assertEquals("Bob", created.legalRepresentative());
assertEquals("基础版", created.servicePackage());
assertEquals("PROFILE_REQUIRED", created.tenant().status());
assertEquals("Bob", created.tenant().legalRepresentative());
assertEquals("基础版", created.tenant().servicePackage());
assertEquals("13800138000", created.administratorUsername());
assertEquals("Password1234", created.temporaryPassword());
verify(repository).insertTenant(argThat(tenant -> Long.valueOf(9001L).equals(tenant.getCreatedBy())
&& Long.valueOf(9001L).equals(tenant.getUpdatedBy())));
verify(dimensionRuleInitialization).initialize(created.id(), 9001L);
verify(dimensionRuleInitialization).initialize(created.tenant().id(), 9001L);
verify(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
verify(repository, never()).insertReview(any());
}
@@ -253,16 +259,33 @@ class TenantServiceTest {
@Test
void platformRegistrationAlwaysCreatesTheDraftWithoutACreditCode() {
doReturn(TenantAdministratorProvisioningService.ProvisioningResult.created(
81L, "13800138000", "Password1234"))
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L);
assertEquals(null, created.unifiedSocialCreditCode());
assertEquals(null, created.tenant().unifiedSocialCreditCode());
verify(repository).insertTenant(argThat(tenant -> tenant.getUnifiedSocialCreditCode() == null));
}
@Test
void profileRequiredDraftArchiveDisablesAuthAccountsAndCustomer() {
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");
when(repository.findTenantForUpdate(10001L)).thenReturn(tenant);
when(authClient.archiveTenant("internal-token", 10001L))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(null));
var archived = service.archiveMerchant(10001L);
assertEquals("DISABLED", archived.status());
verify(authClient).archiveTenant("internal-token", 10001L);
verify(repository).saveTenant(tenant);
}
@Test
void explicitSubmissionRequiresOnlyBusinessLicenseAndLegalPersonIdAndCreatesReview() {
IamTenant tenant = merchant(10001L, "PROFILE_REQUIRED");