This commit is contained in:
2026-08-07 14:46:55 +08:00
parent 830575cc92
commit b0ea1a0c5c
109 changed files with 5774 additions and 339 deletions
@@ -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
@@ -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");
}
+20 -1
View File
@@ -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:
@@ -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"));