This commit is contained in:
2026-09-11 11:51:26 +08:00
parent d60689ce02
commit d2cff49b9e
45 changed files with 569 additions and 66 deletions
+1 -1
View File
@@ -12,7 +12,7 @@
| 目标 | 入口 |
|---|---|
| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 |
| 客户资料/审核与企业联系信息 | `controller/PlatformTenantController.java`、`service/TenantService.java`;企业客户编号由 `CustomerCodeRepository` 在创建事务内从 `00001` 起分配固定五位数字,平台技术主体仍为 `PLATFORM`;公司联系电话/邮箱属于企业资料,不作为登录标识且不要求跨企业唯一;审核必填资质仅营业执照与统一社会信用代码,法人身份证选填 |
| 客户默认箱规与权重系数初始化、重试及补偿 | `service/CustomerDimensionRuleInitializationService.java`、`CustomerDimensionInitializationRelay.java`、`repository/CustomerDimensionInitializationRepository.java`、`client/CustomerDimensionRuleClient.java`、`model/CustomerDimensionInitializationTask.java`;持久任务异步调用 Firstleg `/internal/firstleg/v1/customer-dimension-configs/defaults`,尺寸配置和默认权重系数规则均返回成功后才标记 `READY` |
| 商户成员、登录手机号与个人资料 | `controller/MerchantTenantController.java`、`service/MemberService.java`、`service/PersonalProfileService.java`、`client/AuthAccountClient.java`;成员手机号是可选登录标识,变更时同步 Auth、撤销该账号全部会话并审计,不与公司联系电话联动 |
| 平台用户与海外 PDA 仓绑定 | `controller/PlatformUserManagementController.java`、`service/PlatformUserManagementService.java`、`dto/PlatformUserManagementDtos.java`、`entity/IamMembership.java`;`OVERSEAS_WAREHOUSE_PDA_OPERATOR` 必须且只能绑定一个 `overseasWarehouseId` |
@@ -110,7 +110,7 @@ public final class TenantDtos {
@Schema(description = "Review type / 审核类型", example = "REGISTER") String reviewType,
@Schema(description = "Review status / 审核状态", example = "PENDING") String status,
@Schema(description = "Customer name / 客户名称", example = "深圳虫虫出海科技有限公司") String tenantName,
@Schema(description = "Customer code / 客户编号", example = "TEN190000000000000002") String tenantCode,
@Schema(description = "Customer code / 五位客户编号", example = "00001") String tenantCode,
@Schema(description = "Primary contact / 联系人", example = "张三") String contactName,
@Schema(description = "Contact phone / 联系电话", example = "13800138000") String contactPhone,
@Schema(description = "Contact email / 联系邮箱", example = "ops@example.com") String contactEmail,
@@ -0,0 +1,51 @@
package com.baiyue.iam.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import jakarta.annotation.Resource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.Locale;
/** 在客户创建事务内串行分配固定五位客户编号。 */
@Repository
public class CustomerCodeRepository {
private static final String SEQUENCE_KEY = "CUSTOMER_CODE";
private static final long MAX_CUSTOMER_CODE = 99_999L;
@Resource
private JdbcTemplate jdbc;
@Transactional(propagation = Propagation.MANDATORY)
public String nextCode() {
jdbc.update("INSERT IGNORE INTO iam_number_sequence(sequence_key,next_value) VALUES (?,1)",
SEQUENCE_KEY);
Long value = jdbc.queryForObject(
"SELECT next_value FROM iam_number_sequence WHERE sequence_key=? FOR UPDATE",
Long.class, SEQUENCE_KEY);
if (value == null) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常");
}
String code = format(value);
int updated = jdbc.update(
"UPDATE iam_number_sequence SET next_value=next_value+1 WHERE sequence_key=? AND next_value=?",
SEQUENCE_KEY, value);
if (updated != 1) {
throw new BizException(ResultCode.CONFLICT, "客户编号分配冲突,请重试");
}
return code;
}
static String format(long value) {
if (value < 1) {
throw new BizException(ResultCode.INTERNAL_ERROR, "客户编号序列状态异常");
}
if (value > MAX_CUSTOMER_CODE) {
throw new BizException(ResultCode.CONFLICT, "五位客户编号已用完");
}
return String.format(Locale.ROOT, "%05d", value);
}
}
@@ -20,6 +20,7 @@ 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.baiyue.iam.repository.CustomerCodeRepository;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.toolkit.IdWorker;
import jakarta.annotation.Resource;
@@ -43,6 +44,7 @@ public class MerchantOnboardingService {
@Resource private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@Resource private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@Resource private SecurityEventOutboxService securityEvents;
@Resource private CustomerCodeRepository customerCodes;
@Transactional
public MerchantRegistrationResponse register(MerchantRegistrationRequest request) {
@@ -66,7 +68,7 @@ public class MerchantOnboardingService {
IamTenant tenant = new IamTenant();
tenant.setId(tenantId);
tenant.setTenantCode("TEN" + tenantId);
tenant.setTenantCode(customerCodes.nextCode());
tenant.setTenantName(request.companyName());
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
@@ -11,6 +11,7 @@ import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.entity.IamTenantQualification;
import com.baiyue.iam.entity.IamTenantReview;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.CustomerCodeRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.client.AuthAccountClient;
import com.baiyue.iam.repository.MemberRepository;
@@ -56,6 +57,8 @@ public class TenantService {
@Resource
private TenantRepository repository;
@Resource
private CustomerCodeRepository customerCodes;
@Resource
private ProfileResourceRepository profileRepository;
@Resource
private AuthAccountClient authClient;
@@ -165,7 +168,7 @@ public class TenantService {
ensureAdministratorPhoneAvailable(contactPhone);
IamTenant tenant = new IamTenant();
tenant.setId(IdWorker.getId());
tenant.setTenantCode("TEN" + tenant.getId());
tenant.setTenantCode(customerCodes.nextCode());
tenant.setTenantName(request.tenantName());
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
+10 -1
View File
@@ -7,7 +7,7 @@ USE baiyue_iam;
CREATE TABLE IF NOT EXISTS iam_tenant (
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
tenant_code VARCHAR(64) NOT NULL COMMENT '全局唯一客户编码',
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
tenant_type VARCHAR(20) NOT NULL DEFAULT 'MERCHANT' COMMENT '主体类型:MERCHANT企业客户',
status VARCHAR(20) NOT NULL DEFAULT 'PROFILE_REQUIRED' COMMENT '客户状态:PROFILE_REQUIRED待完善资料、PENDING待审核、ACTIVE正常、REJECTED已驳回、DISABLED已停用、ARCHIVED已归档',
@@ -43,6 +43,15 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
KEY idx_iam_tenant_status (status, created_at)
) ENGINE=InnoDB COMMENT='客户企业主档表:保存企业资料、审核结果和启停状态;ID同时作为客户数据隔离键';
CREATE TABLE IF NOT EXISTS iam_number_sequence (
sequence_key VARCHAR(64) NOT NULL COMMENT '业务编号类型',
next_value BIGINT UNSIGNED NOT NULL COMMENT '下一待分配数字',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '更新时间',
PRIMARY KEY (sequence_key)
) ENGINE=InnoDB COMMENT='IAM业务编号序列表';
INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1);
CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task (
tenant_id BIGINT NOT NULL COMMENT '客户ID及任务聚合ID',
operator_id BIGINT NOT NULL COMMENT '触发初始化的操作人ID;系统补偿为0',
+3 -3
View File
@@ -1113,7 +1113,7 @@ components:
reviewType: {type: string, example: REGISTER}
status: {type: string, enum: [PENDING]}
tenantName: {type: string, description: 客户名称}
tenantCode: {type: string, description: 客户编号}
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001', description: 五位客户编号}
contactName: {type: string, description: 联系人}
contactPhone: {type: string, nullable: true, description: 联系电话}
contactEmail: {type: string, format: email, nullable: true, description: 联系邮箱}
@@ -1199,7 +1199,7 @@ components:
required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage]
properties:
id: {type: string, pattern: '^\d+$', example: '190000000000000001'}
tenantCode: {type: string}
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'}
tenantName: {type: string}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭}
@@ -1237,7 +1237,7 @@ components:
required: [id, tenantCode, tenantName, status]
properties:
id: {type: string, pattern: '^\d+$'}
tenantCode: {type: string}
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'}
tenantName: {type: string}
status: {type: string, enum: [ACTIVE, DISABLED]}
contactName: {type: string, nullable: true}
@@ -0,0 +1,23 @@
package com.baiyue.iam.repository;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.core.exception.BizException;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class CustomerCodeRepositoryTest {
@Test
void allocatesFiveDigitCodeFromOne() {
assertEquals("00001", CustomerCodeRepository.format(1));
assertEquals("99999", CustomerCodeRepository.format(99_999));
}
@Test
void rejectsAllocationAfterFiveDigitCapacityIsExhausted() {
BizException failure = assertThrows(BizException.class,
() -> CustomerCodeRepository.format(100_000));
assertEquals(ResultCode.CONFLICT, failure.getResultCode());
}
}
@@ -0,0 +1,34 @@
package com.baiyue.iam.repository;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import static org.assertj.core.api.Assertions.assertThat;
class CustomerCodeSchemaContractTest {
@Test
void baselineAndUnifiedSqlInitializeCustomerCodeSequenceAtOne() throws Exception {
String baseline = new ClassPathResource("db/iam.sql")
.getContentAsString(StandardCharsets.UTF_8);
Path unifiedPath = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
if (!Files.exists(unifiedPath)) {
unifiedPath = Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
}
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
assertSequenceDefinition(baseline);
assertSequenceDefinition(unified);
}
private static void assertSequenceDefinition(String sql) {
assertThat(sql)
.contains("tenant_code VARCHAR(8) NOT NULL")
.contains("CREATE TABLE IF NOT EXISTS iam_number_sequence")
.contains("PRIMARY KEY (sequence_key)")
.contains("INSERT IGNORE INTO iam_number_sequence(sequence_key, next_value) VALUES ('CUSTOMER_CODE', 1)");
}
}
@@ -140,7 +140,7 @@ class CustomerListExportProviderServiceTest {
private IamTenant tenant(long id) {
IamTenant tenant = new IamTenant();
tenant.setId(id);
tenant.setTenantCode("TEN" + id);
tenant.setTenantCode(String.format("%05d", id));
tenant.setTenantName("柏悦物流" + id);
tenant.setLegalName("柏悦物流有限公司");
tenant.setUnifiedSocialCreditCode("91440000123456789X");
@@ -14,7 +14,9 @@ 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.baiyue.iam.repository.CustomerCodeRepository;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.BeforeEach;
import org.mockito.ArgumentCaptor;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -42,7 +44,14 @@ class MerchantOnboardingServiceTest {
@MockBean private CustomerDimensionRuleInitializationService dimensionRuleInitialization;
@MockBean private OverseasStorageFeeInitializationService overseasStorageFeeInitialization;
@MockBean private SecurityEventOutboxService securityEvents;
@MockBean private CustomerCodeRepository customerCodes;
@Resource private MerchantOnboardingService service;
@BeforeEach
void allocateCustomerCode() {
when(customerCodes.nextCode()).thenReturn("00001");
}
@Test
void clientRegistrationCreatesOwnerAndAssignsGlobalMerchantOwnerRole() {
@@ -76,6 +85,7 @@ class MerchantOnboardingServiceTest {
assertEquals("PROFILE_REQUIRED", result.tenantStatus());
assertEquals("PENDING", result.dimensionInitializationStatus());
assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus());
assertEquals("00001", tenant.getValue().getTenantCode());
assertEquals(result.tenantId(), tenant.getValue().getId());
assertEquals("测试企业", tenant.getValue().getLegalName());
assertEquals("张三", tenant.getValue().getLegalRepresentative());
@@ -5,6 +5,7 @@ import com.baiyue.iam.entity.IamTenant;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.CustomerCodeRepository;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.test.context.junit.jupiter.SpringJUnitConfig;
@@ -21,6 +22,7 @@ import static org.mockito.Mockito.when;
@SpringJUnitConfig(TenantService.class)
class TenantCustomerExportTest {
@MockBean private TenantRepository repository;
@MockBean private CustomerCodeRepository customerCodes;
@MockBean private ProfileResourceRepository profileRepository;
@MockBean private MemberRepository memberRepository;
@MockBean private AuthAccountClient authAccountClient;
@@ -34,7 +36,7 @@ class TenantCustomerExportTest {
void returnsUnmaskedCustomerRowsAndAdvancingCursor() {
IamTenant tenant = new IamTenant();
tenant.setId(101L);
tenant.setTenantCode("TEN101");
tenant.setTenantCode("00101");
tenant.setTenantName("柏悦物流");
tenant.setContactPhone("13800138000");
tenant.setContactEmail("ops@example.com");
@@ -14,6 +14,7 @@ import com.baiyue.iam.dto.TenantDtos.ResetCustomerOwnerPasswordRequest;
import com.baiyue.iam.dto.TenantDtos.CustomerImpersonationRequest;
import com.baiyue.common.security.AuthenticatedUser;
import com.baiyue.iam.repository.TenantRepository;
import com.baiyue.iam.repository.CustomerCodeRepository;
import com.baiyue.iam.repository.ProfileResourceRepository;
import com.baiyue.iam.repository.MemberRepository;
import com.baiyue.iam.mapper.IamRoleMapper;
@@ -51,6 +52,7 @@ import com.baiyue.common.core.api.ResultCode;
@TestPropertySource(properties = "baiyue.security.internal-service-token=internal-token")
class TenantServiceTest {
@MockBean private TenantRepository repository;
@MockBean private CustomerCodeRepository customerCodes;
@MockBean private ProfileResourceRepository profileRepository;
@MockBean private MemberRepository members;
@MockBean private IamRoleMapper roles;
@@ -63,6 +65,7 @@ class TenantServiceTest {
@BeforeEach
void allowUnusedAdministratorPhone() {
when(customerCodes.nextCode()).thenReturn("00001");
when(authClient.availability(eq("internal-token"), any()))
.thenReturn(com.baiyue.common.core.api.ApiResponse.success(true));
}
@@ -72,7 +75,7 @@ class TenantServiceTest {
IamTenant tenant = new IamTenant();
tenant.setId(10001L);
tenant.setTenantCode("TEN10001");
tenant.setTenantCode("10001");
tenant.setTenantName("Example Merchant");
tenant.setStatus("ACTIVE");
tenant.setLegalName("Example Merchant Ltd");
@@ -91,7 +94,7 @@ class TenantServiceTest {
var result = service.pageMerchants(1, 10, "Example", "ACTIVE");
assertEquals(1, result.total());
assertEquals("TEN10001", result.records().get(0).tenantCode());
assertEquals("10001", result.records().get(0).tenantCode());
assertEquals("Alice", result.records().get(0).contactName());
assertEquals("Sales Zhang", result.records().get(0).salesOwnerName());
assertEquals(30, result.records().get(0).paymentTermDays());
@@ -108,7 +111,7 @@ class TenantServiceTest {
review.setReviewType("REGISTER");
review.setCreatedAt(LocalDateTime.of(2026, 8, 17, 10, 27));
IamTenant tenant = merchant(10001L, "PENDING");
tenant.setTenantCode("TEN10001");
tenant.setTenantCode("10001");
tenant.setTenantName("Example Merchant");
tenant.setContactName("Alice");
tenant.setContactPhone("13800138000");
@@ -124,7 +127,7 @@ class TenantServiceTest {
assertEquals("REGISTER", result.get(0).reviewType());
assertEquals("PENDING", result.get(0).status());
assertEquals("Example Merchant", result.get(0).tenantName());
assertEquals("TEN10001", result.get(0).tenantCode());
assertEquals("10001", result.get(0).tenantCode());
assertEquals("Alice", result.get(0).contactName());
assertEquals("13800138000", result.get(0).contactPhone());
assertEquals("alice@example.com", result.get(0).contactEmail());
@@ -136,7 +139,7 @@ class TenantServiceTest {
@Test
void pageFinanceCustomersKeepsFullContactSummaryForInternalFinance() {
IamTenant tenant = merchant(10001L, "ACTIVE");
tenant.setTenantCode("TEN10001");
tenant.setTenantCode("10001");
tenant.setTenantName("Example Merchant");
tenant.setContactName("Alice");
tenant.setContactPhone("13800138000");
@@ -145,7 +148,7 @@ class TenantServiceTest {
var result = service.pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null);
assertEquals("TEN10001", result.records().get(0).tenantCode());
assertEquals("10001", result.records().get(0).tenantCode());
assertEquals("Alice", result.records().get(0).contactName());
assertEquals("13800138000", result.records().get(0).contactPhone());
verify(repository).pageFinanceCustomers(1, 10, "Alice", "ACTIVE", null);
@@ -320,6 +323,7 @@ class TenantServiceTest {
9001L);
assertEquals("PROFILE_REQUIRED", created.tenant().status());
assertEquals("00001", created.tenant().tenantCode());
assertEquals("PENDING", created.tenant().dimensionInitializationStatus());
assertEquals("Bob", created.tenant().legalRepresentative());
assertEquals("基础版", created.tenant().servicePackage());
@@ -515,7 +519,7 @@ class TenantServiceTest {
private IamTenant merchant(long id, String status) {
IamTenant tenant = new IamTenant();
tenant.setId(id);
tenant.setTenantCode("TEN" + id);
tenant.setTenantCode(String.format("%05d", id));
tenant.setTenantType("MERCHANT");
tenant.setStatus(status);
tenant.setDimensionInitializationStatus("READY");