优化
This commit is contained in:
@@ -12,6 +12,8 @@ import java.time.LocalDateTime;
|
||||
public interface AuthAccountClient {
|
||||
@PostMapping ApiResponse<AccountSummary> create(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestBody CreateAccountRequest request);
|
||||
@GetMapping("/availability") ApiResponse<Boolean> availability(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@RequestParam String username);
|
||||
@GetMapping("/{id}") ApiResponse<AccountSummary> summary(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
@PathVariable Long id);
|
||||
@PutMapping("/{id}/status") ApiResponse<Void> status(@RequestHeader("X-Internal-Service-Token") String token,
|
||||
|
||||
@@ -73,6 +73,17 @@ public class PlatformTenantController {
|
||||
return ApiResponse.success(service.merchant(tenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants/contact-phone-availability")
|
||||
@PreAuthorize("hasAnyAuthority('platform:customer:create','platform:customer:edit')")
|
||||
@Operation(summary = "校验客户联系电话唯一性")
|
||||
public ApiResponse<Boolean> contactPhoneAvailability(
|
||||
@RequestParam String contactPhone,
|
||||
@RequestParam(required = false) Long excludeTenantId,
|
||||
@AuthenticationPrincipal AuthenticatedUser user) {
|
||||
TenantGuard.tenant(user, "PLATFORM");
|
||||
return ApiResponse.success(service.contactPhoneAndUsernameAvailable(contactPhone, excludeTenantId));
|
||||
}
|
||||
|
||||
@GetMapping("/tenants/{tenantId:\\d+}/members")
|
||||
@PreAuthorize("hasAuthority('platform:customer:view')")
|
||||
@Operation(summary = "查询企业客户成员及账号", description = "平台只读查询指定企业的全部成员,包含 OWNER 企业管理员,并返回登录账号。")
|
||||
|
||||
@@ -27,7 +27,8 @@ public final class TenantDtos {
|
||||
@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 = "13800138000") @NotBlank String contactPhone,
|
||||
@Schema(description = "Contact phone and administrator username / 联系电话及管理员用户名", example = "13800138000")
|
||||
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
|
||||
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") @Email String contactEmail,
|
||||
@NotBlank @Size(max = 64) String servicePackage,
|
||||
@Size(max = 64) String contactWechat,
|
||||
@@ -58,7 +59,7 @@ public final class TenantDtos {
|
||||
@NotBlank String legalName,
|
||||
@NotBlank @Size(max = 64) String legalRepresentative,
|
||||
@NotBlank String contactName,
|
||||
@NotBlank String contactPhone,
|
||||
@NotBlank @Pattern(regexp = "[0-9-]{4,32}") String contactPhone,
|
||||
@Email String contactEmail,
|
||||
@NotBlank @Size(max = 64) String servicePackage,
|
||||
@Size(max = 64) String contactWechat,
|
||||
@@ -86,7 +87,7 @@ public final class TenantDtos {
|
||||
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 = "Contact phone used as 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
|
||||
) { }
|
||||
|
||||
|
||||
@@ -34,6 +34,19 @@ public class TenantRepository {
|
||||
.ne(IamTenant::getId, tenantId)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByContactPhone(String contactPhone) {
|
||||
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(IamTenant::getContactPhone, contactPhone)) > 0;
|
||||
}
|
||||
|
||||
public boolean existsByContactPhoneForOtherTenant(String contactPhone, long tenantId) {
|
||||
return tenantMapper.selectCount(new LambdaQueryWrapper<IamTenant>()
|
||||
.eq(IamTenant::getTenantType, "MERCHANT")
|
||||
.eq(IamTenant::getContactPhone, contactPhone)
|
||||
.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
|
||||
|
||||
+6
-19
@@ -34,8 +34,6 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
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;
|
||||
|
||||
@Resource
|
||||
private MemberRepository members;
|
||||
@@ -179,18 +177,9 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
|
||||
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");
|
||||
String username = username(tenant.getContactPhone());
|
||||
createAuthAccount(authUserId, username, password, tenant, membershipId);
|
||||
return username;
|
||||
}
|
||||
|
||||
private void createAuthAccount(long authUserId, String username, String password,
|
||||
@@ -208,7 +197,7 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
if (response.data() == null) throw unavailable();
|
||||
} catch (FeignException exception) {
|
||||
if (exception.status() == 409) {
|
||||
throw new BizException(ResultCode.CONFLICT, "自动生成的企业管理员登录账号已存在");
|
||||
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他账号使用,不能作为企业管理员登录用户名");
|
||||
}
|
||||
throw unavailable();
|
||||
} catch (BizException exception) {
|
||||
@@ -244,10 +233,8 @@ public class TenantAdministratorProvisioningService implements TenantApprovalIni
|
||||
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 String username(String contactPhone) {
|
||||
return contactPhone == null ? "" : contactPhone.trim();
|
||||
}
|
||||
|
||||
private char pick(char[] source) { return source[random.nextInt(source.length)]; }
|
||||
|
||||
@@ -3,6 +3,7 @@ import jakarta.annotation.Resource;
|
||||
|
||||
import com.baiyue.common.audit.BusinessAudit;
|
||||
import com.baiyue.common.core.api.PageResult;
|
||||
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.entity.IamTenant;
|
||||
@@ -10,6 +11,7 @@ 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 com.baiyue.iam.client.AuthAccountClient;
|
||||
import com.baiyue.iam.dto.OnboardingDtos.OrderEligibilityResponse;
|
||||
|
||||
import com.baiyue.iam.dto.TenantDtos.RegisterRequest;
|
||||
@@ -23,7 +25,9 @@ import com.baiyue.iam.dto.CustomerExportDtos.CustomerExportRow;
|
||||
|
||||
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import feign.FeignException;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
@@ -41,6 +45,10 @@ public class TenantService {
|
||||
@Resource
|
||||
private ProfileResourceRepository profileRepository;
|
||||
@Resource
|
||||
private AuthAccountClient authClient;
|
||||
@Value("${baiyue.security.internal-service-token}")
|
||||
private String internalToken;
|
||||
@Resource
|
||||
private TenantApprovalInitializationCoordinator approvalInitialization;
|
||||
|
||||
|
||||
@@ -53,6 +61,8 @@ public class TenantService {
|
||||
)
|
||||
@Transactional
|
||||
public TenantResponse register(RegisterRequest request, long operatorId) {
|
||||
String contactPhone = normalizeContactPhone(request.contactPhone());
|
||||
ensureContactPhoneAvailable(contactPhone, null);
|
||||
IamTenant tenant = new IamTenant();
|
||||
tenant.setId(IdWorker.getId());
|
||||
tenant.setTenantCode("TEN" + tenant.getId());
|
||||
@@ -63,7 +73,7 @@ public class TenantService {
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setUnifiedSocialCreditCode(null);
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactPhone(contactPhone);
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
tenant.setServicePackage(request.servicePackage());
|
||||
tenant.setContactWechat(request.contactWechat());
|
||||
@@ -208,16 +218,46 @@ public class TenantService {
|
||||
return response(requireMerchant(tenantId));
|
||||
}
|
||||
|
||||
public boolean contactPhoneAvailable(String contactPhone, Long tenantId) {
|
||||
String normalized = normalizeContactPhone(contactPhone);
|
||||
return tenantId == null
|
||||
? !repository.existsByContactPhone(normalized)
|
||||
: !repository.existsByContactPhoneForOtherTenant(normalized, tenantId);
|
||||
}
|
||||
|
||||
public boolean contactPhoneAndUsernameAvailable(String contactPhone, Long tenantId) {
|
||||
String normalized = normalizeContactPhone(contactPhone);
|
||||
if (!contactPhoneAvailable(normalized, tenantId)) return false;
|
||||
ApiResponse<Boolean> response;
|
||||
try {
|
||||
response = authClient.availability(internalToken, normalized);
|
||||
} catch (FeignException exception) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话");
|
||||
}
|
||||
if (response == null || !response.successful() || response.data() == null) {
|
||||
throw new BizException(ResultCode.SERVICE_UNAVAILABLE, "认证账号服务暂时不可用,不能校验联系电话");
|
||||
}
|
||||
return response.data();
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@BusinessAudit(event = "PLATFORM_TENANT_UPDATE", resourceType = "TENANT", resourceId = "#tenantId",
|
||||
description = "平台编辑企业客户档案")
|
||||
public TenantResponse updateMerchant(long tenantId, UpdateTenantRequest request) {
|
||||
IamTenant tenant = requireMerchant(tenantId);
|
||||
String contactPhone = normalizeContactPhone(request.contactPhone());
|
||||
String existingContactPhone = normalizeContactPhone(tenant.getContactPhone());
|
||||
if (!contactPhone.equals(existingContactPhone)) {
|
||||
if ("ACTIVE".equals(tenant.getStatus()) || "DISABLED".equals(tenant.getStatus())) {
|
||||
throw new BizException(ResultCode.CONFLICT, "客户已开户,联系电话作为登录用户名不能直接修改");
|
||||
}
|
||||
ensureContactPhoneAvailable(contactPhone, tenantId);
|
||||
}
|
||||
tenant.setTenantName(request.tenantName());
|
||||
tenant.setLegalName(request.legalName());
|
||||
tenant.setLegalRepresentative(request.legalRepresentative());
|
||||
tenant.setContactName(request.contactName());
|
||||
tenant.setContactPhone(request.contactPhone());
|
||||
tenant.setContactPhone(contactPhone);
|
||||
tenant.setContactEmail(request.contactEmail());
|
||||
tenant.setServicePackage(request.servicePackage());
|
||||
tenant.setContactWechat(request.contactWechat());
|
||||
@@ -258,6 +298,7 @@ public class TenantService {
|
||||
if (repository.findPendingReviewByTenant(tenantId) != null) {
|
||||
throw new BizException(ResultCode.CONFLICT, "Enterprise information is already under review / 客户资料已在审核中");
|
||||
}
|
||||
ensureContactPhoneAvailable(normalizeContactPhone(tenant.getContactPhone()), tenantId);
|
||||
if (tenant.getUnifiedSocialCreditCode() == null
|
||||
|| !tenant.getUnifiedSocialCreditCode().matches("[0-9A-Z]{18}")) {
|
||||
throw new BizException(ResultCode.BAD_REQUEST,
|
||||
@@ -297,6 +338,16 @@ public class TenantService {
|
||||
return tenant.getId() != null && tenant.getId().longValue() == id;
|
||||
}
|
||||
|
||||
private String normalizeContactPhone(String contactPhone) {
|
||||
return contactPhone == null ? "" : contactPhone.trim();
|
||||
}
|
||||
|
||||
private void ensureContactPhoneAvailable(String contactPhone, Long tenantId) {
|
||||
if (!contactPhoneAvailable(contactPhone, tenantId)) {
|
||||
throw new BizException(ResultCode.CONFLICT, "联系电话已被其他客户使用,不能作为登录用户名");
|
||||
}
|
||||
}
|
||||
|
||||
private boolean hasAllPendingQualifications(List<IamTenantQualification> qualifications) {
|
||||
return hasAllQualificationsWithStatus(qualifications, "PENDING");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,25 @@ tags:
|
||||
- name: RBAC
|
||||
description: Platform and merchant role-permission management / 平台端与商户端角色权限管理
|
||||
paths:
|
||||
/api/platform/v1/iam/tenants/contact-phone-availability:
|
||||
get:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
summary: 校验客户联系电话唯一性
|
||||
parameters:
|
||||
- {name: contactPhone, in: query, required: true, schema: {type: string}}
|
||||
- {name: excludeTenantId, in: query, required: false, schema: {type: integer, format: int64}}
|
||||
responses:
|
||||
'200':
|
||||
description: 返回联系电话是否可作为登录用户名
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string}
|
||||
data: {type: boolean}
|
||||
'403': {$ref: '#/components/responses/Forbidden'}
|
||||
/api/platform/v1/iam/tenants:
|
||||
get:
|
||||
tags: [Platform Tenant / 平台租户]
|
||||
@@ -798,7 +817,7 @@ components:
|
||||
properties:
|
||||
tenantId: {type: string, pattern: '^\d+$', example: '190000000000000001'}
|
||||
administratorCreated: {type: boolean, description: 本次审核是否新建企业管理员}
|
||||
administratorUsername: {type: string, nullable: true, example: merchant_190000000000000001_k3mnpqrs}
|
||||
administratorUsername: {type: string, nullable: true, example: '13800138000'}
|
||||
temporaryPassword: {type: string, format: password, nullable: true, description: 一次性初始密码,仅新建管理员时返回}
|
||||
TenantApiResponse:
|
||||
allOf:
|
||||
|
||||
+8
-15
@@ -76,7 +76,7 @@ class TenantAdministratorProvisioningServiceTest {
|
||||
var result = service.initialize(context());
|
||||
|
||||
assertTrue(result.created());
|
||||
assertTrue(result.username().matches("^merchant_190000000000000001_[a-z]{8}$"));
|
||||
assertEquals("13800138000", result.username());
|
||||
assertNotNull(result.temporaryPassword());
|
||||
assertEquals(16, result.temporaryPassword().length());
|
||||
assertTrue(result.temporaryPassword().matches("^(?=.*[A-Za-z])(?=.*\\d)[A-Za-z0-9]+$"));
|
||||
@@ -208,23 +208,16 @@ class TenantAdministratorProvisioningServiceTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void usernameConflictGeneratesAnotherUsernameBeforeCreatingOwner() {
|
||||
void usernameConflictStopsProvisioningInsteadOfChangingTheContactPhoneUsername() {
|
||||
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());
|
||||
"登录账号已存在", null, null)).when(auth).create(anyString(), any());
|
||||
|
||||
var result = service.initialize(context());
|
||||
var exception = assertThrows(com.baiyue.common.core.exception.BizException.class,
|
||||
() -> 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());
|
||||
assertEquals(com.baiyue.common.core.api.ResultCode.CONFLICT, exception.getResultCode());
|
||||
verify(auth).create(eq("internal-token"), any());
|
||||
verify(members, never()).insertUser(any());
|
||||
}
|
||||
|
||||
private TenantApprovalInitializer.ApprovalContext context() {
|
||||
|
||||
@@ -92,7 +92,7 @@ class TenantServiceTest {
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
|
||||
var updated = service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", "alice@example.com",
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "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"));
|
||||
@@ -113,6 +113,29 @@ class TenantServiceTest {
|
||||
verify(repository, org.mockito.Mockito.times(2)).saveTenant(tenant);
|
||||
}
|
||||
|
||||
@Test
|
||||
void activeCustomerCannotChangeTheContactPhoneUsedAsItsLoginUsername() {
|
||||
IamTenant tenant = merchant(10001L, "ACTIVE");
|
||||
when(repository.findTenant(10001L)).thenReturn(tenant);
|
||||
|
||||
BizException exception = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
|
||||
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null,
|
||||
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
|
||||
|
||||
assertEquals(ResultCode.CONFLICT, exception.getResultCode());
|
||||
assertEquals("13800138000", tenant.getContactPhone());
|
||||
verify(repository, never()).saveTenant(tenant);
|
||||
}
|
||||
|
||||
@Test
|
||||
void contactPhoneAvailabilityAlsoChecksTheAuthUsernameFactSource() {
|
||||
when(authClient.availability("internal-token", "13800138000"))
|
||||
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(false));
|
||||
|
||||
assertEquals(false, service.contactPhoneAndUsernameAvailable("13800138000", null));
|
||||
verify(authClient).availability("internal-token", "13800138000");
|
||||
}
|
||||
|
||||
@Test
|
||||
void platformCannotBypassReviewToActivatePendingTenant() {
|
||||
when(repository.findTenant(10001L)).thenReturn(merchant(10001L, "PENDING"));
|
||||
|
||||
Reference in New Issue
Block a user