This commit is contained in:
2026-09-14 15:33:29 +08:00
parent febe265c50
commit 46b4d14898
30 changed files with 502 additions and 200 deletions
+1
View File
@@ -16,6 +16,7 @@
<dependency><groupId>com.alibaba.cloud</groupId><artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-openfeign</artifactId></dependency>
<dependency><groupId>org.springframework.cloud</groupId><artifactId>spring-cloud-starter-loadbalancer</artifactId></dependency>
<dependency><groupId>com.belerweb</groupId><artifactId>pinyin4j</artifactId><version>2.5.1</version></dependency>
<dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-actuator</artifactId></dependency>
<dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId><scope>runtime</scope></dependency>
<dependency><groupId>com.h2database</groupId><artifactId>h2</artifactId><scope>test</scope></dependency>
@@ -11,7 +11,7 @@ public final class CustomerExportDtos {
public record CustomerExportBatch(List<CustomerExportRow> records, Long nextCursor, boolean hasMore) { }
public record CustomerExportRow(
String id, String tenantCode, String tenantName, String legalName,
String id, String tenantCode, String tenantName, String customerNickname, String legalName,
String unifiedSocialCreditCode, String enterpriseType, LocalDate establishedDate,
BigDecimal registeredCapital, String registeredCapitalCurrency, String registeredAddress,
String contactName, String contactPhone, String contactEmail, String contactWechat,
@@ -24,6 +24,7 @@ public final class TenantDtos {
@Schema(name = "TenantRegisterRequest", description = "Enterprise tenant registration / 企业租户注册资料")
public record RegisterRequest(
@Schema(description = "Enterprise display name / 企业名称", example = "深圳虫虫出海科技有限公司") @NotBlank String tenantName,
@Schema(description = "Customer nickname; defaults to uppercase pinyin initials of Chinese characters in tenantName / 客户昵称,不填时默认取公司名称各中文字符的拼音大写首字母", example = "SZWL") @Size(max = 32) String customerNickname,
@Schema(description = "Legal entity name / 企业法定名称", example = "深圳虫虫出海科技有限公司") @NotBlank String legalName,
@Schema(description = "Legal representative (optional) / 法定代表人(选填)", example = "张三") @Size(max = 64) String legalRepresentative,
@Schema(description = "Primary contact / 联系人", example = "张三") @NotBlank String contactName,
@@ -57,6 +58,7 @@ public final class TenantDtos {
@Schema(name = "PlatformTenantUpdateRequest", description = "Platform maintained merchant tenant profile")
public record UpdateTenantRequest(
@NotBlank String tenantName,
@Schema(description = "Customer nickname used by non-legal displays and printing / 非法定展示及打印使用的客户昵称") @NotBlank @Size(max = 32) String customerNickname,
@NotBlank String legalName,
@Size(max = 64) String legalRepresentative,
@NotBlank String contactName,
@@ -134,7 +136,7 @@ public final class TenantDtos {
) { }
@Schema(name = "TenantResponse", description = "Enterprise tenant summary / 企业租户概要")
public record TenantResponse(Long id, String tenantCode, String tenantName, String status,
public record TenantResponse(Long id, String tenantCode, String tenantName, String customerNickname, String status,
String dimensionInitializationStatus,
String legalName, String legalRepresentative, String unifiedSocialCreditCode, String contactName,
String contactPhone, String contactEmail, String contactWechat,
@@ -144,6 +146,6 @@ public final class TenantDtos {
String internalRemark, LocalDateTime createdAt, LocalDateTime approvedAt) { }
/** Full customer contact summary for trusted internal finance read models. */
public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String status,
public record FinanceCustomerSummary(Long id, String tenantCode, String tenantName, String customerNickname, String status,
String contactName, String contactPhone) { }
}
@@ -20,6 +20,7 @@ public class IamTenant extends BaseEntity {
@TableId private Long id;
private String tenantCode;
private String tenantName;
private String customerNickname;
private String tenantType;
private String status;
private String dimensionInitializationStatus;
@@ -37,6 +37,7 @@ public class CustomerListExportProviderService {
column("id", "客户ID", ExportColumnType.STRING, 20),
column("tenantCode", "客户编码", ExportColumnType.STRING, 18),
column("tenantName", "公司名称", ExportColumnType.STRING, 28),
column("customerNickname", "客户昵称", ExportColumnType.STRING, 14),
column("legalName", "营业执照企业名称", ExportColumnType.STRING, 28),
column("unifiedSocialCreditCode", "统一社会信用代码", ExportColumnType.STRING, 24),
column("enterpriseType", "企业类型", ExportColumnType.STRING, 16),
@@ -177,6 +178,7 @@ public class CustomerListExportProviderService {
row.put("id", String.valueOf(tenant.getId()));
row.put("tenantCode", tenant.getTenantCode());
row.put("tenantName", tenant.getTenantName());
row.put("customerNickname", tenant.getCustomerNickname());
row.put("legalName", tenant.getLegalName());
row.put("unifiedSocialCreditCode", tenant.getUnifiedSocialCreditCode());
row.put("enterpriseType", tenant.getEnterpriseType());
@@ -70,6 +70,7 @@ public class MerchantOnboardingService {
tenant.setId(tenantId);
tenant.setTenantCode(customerCodes.nextCode());
tenant.setTenantName(request.companyName());
tenant.setCustomerNickname(TenantService.resolveCustomerNickname(null, request.companyName()));
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
@@ -40,6 +40,7 @@ import org.springframework.stereotype.Service;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.transaction.annotation.Transactional;
import feign.FeignException;
import net.sourceforge.pinyin4j.PinyinHelper;
import java.time.LocalDateTime;
import java.util.List;
@@ -170,6 +171,7 @@ public class TenantService {
tenant.setId(IdWorker.getId());
tenant.setTenantCode(customerCodes.nextCode());
tenant.setTenantName(request.tenantName());
tenant.setCustomerNickname(resolveCustomerNickname(request.customerNickname(), request.tenantName()));
tenant.setTenantType("MERCHANT");
tenant.setStatus("PROFILE_REQUIRED");
tenant.setDimensionInitializationStatus("PENDING");
@@ -222,7 +224,7 @@ public class TenantService {
public PageResult<FinanceCustomerSummary> pageFinanceCustomers(long page, long size, String keyword, String status, Long customerId) {
PageResult<IamTenant> source = repository.pageFinanceCustomers(page, size, keyword, status, customerId);
return new PageResult<>(source.records().stream().map(tenant -> new FinanceCustomerSummary(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getStatus(),
tenant.getContactName(), tenant.getContactPhone())).toList(),
source.page(), source.size(), source.total(), source.pages());
}
@@ -239,7 +241,7 @@ public class TenantService {
private CustomerExportRow exportRow(IamTenant tenant) {
return new CustomerExportRow(String.valueOf(tenant.getId()), tenant.getTenantCode(),
tenant.getTenantName(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(),
tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getLegalName(), tenant.getUnifiedSocialCreditCode(),
tenant.getEnterpriseType(), tenant.getEstablishedDate(), tenant.getRegisteredCapital(),
tenant.getRegisteredCapitalCurrency(), tenant.getRegisteredAddress(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
@@ -390,6 +392,7 @@ public class TenantService {
ensureNotArchived(tenant);
String contactPhone = normalizeContactPhone(request.contactPhone());
tenant.setTenantName(request.tenantName());
tenant.setCustomerNickname(requireCustomerNickname(request.customerNickname()));
tenant.setLegalName(request.legalName());
tenant.setLegalRepresentative(normalizeOptional(request.legalRepresentative()));
tenant.setContactName(request.contactName());
@@ -489,6 +492,33 @@ public class TenantService {
return value == null || value.isBlank() ? null : value.trim();
}
static String resolveCustomerNickname(String nickname, String tenantName) {
if (nickname != null && !nickname.isBlank()) return nickname.trim();
String normalizedName = tenantName == null ? "" : tenantName.trim();
if (normalizedName.isEmpty()) {
throw new BizException(ResultCode.BAD_REQUEST, "公司名称不能为空");
}
StringBuilder initials = new StringBuilder();
normalizedName.codePoints()
.filter(codePoint -> Character.UnicodeScript.of(codePoint) == Character.UnicodeScript.HAN)
.filter(Character::isBmpCodePoint)
.forEach(codePoint -> {
String[] pinyin = PinyinHelper.toHanyuPinyinStringArray((char) codePoint);
if (pinyin != null && pinyin.length > 0 && !pinyin[0].isBlank()) {
initials.append(Character.toUpperCase(pinyin[0].charAt(0)));
}
});
if (!initials.isEmpty()) return initials.substring(0, Math.min(initials.length(), 32));
return new String(Character.toChars(normalizedName.codePointAt(0))).toUpperCase(java.util.Locale.ROOT);
}
private String requireCustomerNickname(String nickname) {
if (nickname == null || nickname.isBlank()) {
throw new BizException(ResultCode.BAD_REQUEST, "客户昵称不能为空");
}
return nickname.trim();
}
private void ensureAdministratorPhoneAvailable(String phone) {
if (!administratorPhoneAvailable(phone)) {
throw new BizException(ResultCode.CONFLICT, "管理员登录手机号已被其他账号使用");
@@ -522,7 +552,7 @@ public class TenantService {
private TenantResponse response(IamTenant tenant) {
return new TenantResponse(
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getStatus(),
tenant.getId(), tenant.getTenantCode(), tenant.getTenantName(), tenant.getCustomerNickname(), tenant.getStatus(),
tenant.getDimensionInitializationStatus(),
tenant.getLegalName(), tenant.getLegalRepresentative(), tenant.getUnifiedSocialCreditCode(), tenant.getContactName(),
tenant.getContactPhone(), tenant.getContactEmail(), tenant.getContactWechat(),
@@ -545,7 +575,7 @@ public class TenantService {
private TenantResponse summaryResponse(IamTenant tenant) {
TenantResponse response = response(tenant);
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), response.status(),
response.dimensionInitializationStatus(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
maskPhone(response.contactPhone()), maskEmail(response.contactEmail()), response.contactWechat(),
@@ -556,7 +586,7 @@ public class TenantService {
private TenantResponse merchantVisibleResponse(IamTenant tenant) {
TenantResponse response = response(tenant);
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.status(),
return new TenantResponse(response.id(), response.tenantCode(), response.tenantName(), response.customerNickname(), response.status(),
response.dimensionInitializationStatus(),
response.legalName(), response.legalRepresentative(), response.unifiedSocialCreditCode(), response.contactName(),
response.contactPhone(), response.contactEmail(), response.contactWechat(), response.servicePackage(), response.salesOwnerName(),
+1
View File
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS iam_tenant (
id BIGINT NOT NULL COMMENT '客户主档ID(雪花ID);0保留为平台技术作用域',
tenant_code VARCHAR(8) NOT NULL COMMENT '客户编号:企业客户固定五位数字,平台技术主体为PLATFORM',
tenant_name VARCHAR(128) NOT NULL COMMENT '客户企业名称',
customer_nickname VARCHAR(32) 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已归档',
dimension_initialization_status VARCHAR(16) NOT NULL DEFAULT 'READY' COMMENT '默认尺寸初始化状态:PENDING待处理、READY已就绪、FAILED失败',
+7 -3
View File
@@ -1306,11 +1306,12 @@ components:
pages: {type: integer, minimum: 0}
TenantResponse:
type: object
required: [id, tenantCode, tenantName, status, dimensionInitializationStatus, servicePackage]
required: [id, tenantCode, tenantName, customerNickname, status, dimensionInitializationStatus, servicePackage]
properties:
id: {type: string, pattern: '^\d+$', example: '190000000000000001'}
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'}
tenantName: {type: string}
customerNickname: {type: string, maxLength: 32, description: 客户昵称;非法定展示及打印使用}
status: {type: string, enum: [PROFILE_REQUIRED, PENDING, ACTIVE, REJECTED, DISABLED, ARCHIVED]}
dimensionInitializationStatus: {type: string, enum: [PENDING, READY, FAILED], description: 客户默认尺寸初始化状态;未 READY 时依赖该配置的业务失败关闭}
legalName: {type: string}
@@ -1344,11 +1345,12 @@ components:
updatedAt: {type: string, format: date-time}
FinanceCustomerSummary:
type: object
required: [id, tenantCode, tenantName, status]
required: [id, tenantCode, tenantName, customerNickname, status]
properties:
id: {type: string, pattern: '^\d+$'}
tenantCode: {type: string, pattern: '^\d{5}$', example: '00001'}
tenantName: {type: string}
customerNickname: {type: string, maxLength: 32, description: 非法定展示及打印使用的客户昵称}
status: {type: string, enum: [ACTIVE, DISABLED]}
contactName: {type: string, nullable: true}
contactPhone: {type: string, nullable: true}
@@ -1459,6 +1461,7 @@ components:
required: [tenantName, legalName, contactName, contactPhone, servicePackage]
properties:
tenantName: {type: string, example: 深圳虫虫出海科技有限公司}
customerNickname: {type: string, maxLength: 32, example: SZWL, description: 选填;不填时默认取公司名称各中文字符的拼音大写首字母,没有中文时取首个非空字符并转大写}
legalName: {type: string, example: 深圳虫虫出海科技有限公司}
legalRepresentative: {type: string, maxLength: 64, nullable: true, example: 张三, description: 选填}
contactName: {type: string, example: 李四}
@@ -1486,9 +1489,10 @@ components:
contactEmail: {type: string, format: email}
UpdateTenantRequest:
type: object
required: [tenantName, legalName, contactName, contactPhone]
required: [tenantName, customerNickname, legalName, contactName, contactPhone]
properties:
tenantName: {type: string}
customerNickname: {type: string, maxLength: 32, description: 客户昵称;非法定展示及打印使用}
legalName: {type: string}
legalRepresentative: {type: string, maxLength: 64, nullable: true, description: 选填}
contactName: {type: string}
@@ -60,6 +60,8 @@ class CustomerProfileMigrationContractTest {
String instructions = Files.readString(readme, StandardCharsets.UTF_8);
assertThat(sql)
.contains("customer_nickname VARCHAR(32)")
.contains("ALTER TABLE iam_tenant ADD COLUMN customer_nickname VARCHAR(32)")
.contains("ALTER TABLE iam_tenant MODIFY COLUMN status")
.contains("DEFAULT 'PROFILE_REQUIRED'")
.contains("CALL phase1_iam_add_column('iam_tenant', 'approved_at'")
@@ -50,13 +50,14 @@ class CustomerListExportProviderServiceTest {
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(21, 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("柏", response.rows().get(0).get("customerNickname"));
assertEquals(new BigDecimal("1000000.00"), response.rows().get(0).get("registeredCapital"));
assertEquals("启用", response.rows().get(0).get("status"));
assertEquals("102", response.nextCursor());
@@ -142,6 +143,7 @@ class CustomerListExportProviderServiceTest {
tenant.setId(id);
tenant.setTenantCode(String.format("%05d", id));
tenant.setTenantName("柏悦物流" + id);
tenant.setCustomerNickname("柏");
tenant.setLegalName("柏悦物流有限公司");
tenant.setUnifiedSocialCreditCode("91440000123456789X");
tenant.setEnterpriseType("有限责任公司");
@@ -160,7 +160,7 @@ class TenantServiceTest {
when(repository.findTenant(10001L)).thenReturn(tenant);
var updated = service.updateMerchant(10001L,
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", "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"));
@@ -187,7 +187,7 @@ class TenantServiceTest {
when(repository.findTenant(10001L)).thenReturn(tenant);
var updated = service.updateMerchant(10001L,
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13900139000", null,
new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13900139000", null,
"基础版", null, null, 30, null, null, null, "CNY", null, null));
assertEquals("13900139000", updated.contactPhone());
@@ -282,7 +282,7 @@ class TenantServiceTest {
when(repository.findTenant(10001L)).thenReturn(archived);
BizException platformFailure = assertThrows(BizException.class, () -> service.updateMerchant(10001L,
new UpdateTenantRequest("New name", "New legal", "Bob", "Alice", "13800138000", null,
new UpdateTenantRequest("New name", "新", "New legal", "Bob", "Alice", "13800138000", null,
"基础版", null, null, 30, null, null, null, "CNY", null, null)));
BizException merchantFailure = assertThrows(BizException.class, () -> service.update(10001L,
new UpdateProfileRequest("New name", "New legal", "Bob", "Alice",
@@ -317,7 +317,7 @@ class TenantServiceTest {
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"alice@example.com", "基础版", "alice_wechat", "Sales Zhang", 30, "LIMITED_COMPANY",
LocalDate.of(2020, 1, 2), new BigDecimal("5000000.00"), "CNY", "深圳市南山区", "草稿客户"),
9001L);
@@ -325,6 +325,7 @@ class TenantServiceTest {
assertEquals("PROFILE_REQUIRED", created.tenant().status());
assertEquals("00001", created.tenant().tenantCode());
assertEquals("PENDING", created.tenant().dimensionInitializationStatus());
assertEquals("E", created.tenant().customerNickname());
assertEquals("Bob", created.tenant().legalRepresentative());
assertEquals("基础版", created.tenant().servicePackage());
assertEquals("merchant_81", created.administratorUsername());
@@ -337,13 +338,21 @@ class TenantServiceTest {
verify(repository, never()).insertReview(any());
}
@Test
void customerNicknameDefaultsToFirstChineseCharacterAndAcceptsManualValue() {
assertEquals("SZWLYXGS", TenantService.resolveCustomerNickname(null, "ABC 深圳物流有限公司"));
assertEquals("SZWL", TenantService.resolveCustomerNickname(null, "深圳物流"));
assertEquals("A", TenantService.resolveCustomerNickname(" ", "ABC Logistics"));
assertEquals("虫虫", TenantService.resolveCustomerNickname(" 虫虫 ", "深圳物流有限公司"));
}
@Test
void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() {
doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败"))
.when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class));
BizException failure = assertThrows(BizException.class, () -> service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L));
@@ -357,7 +366,7 @@ class TenantServiceTest {
.when(provisioning).provisionPlatformCreatedTenant(any(), eq(9001L));
var created = service.register(new RegisterRequest(
"Example Merchant", "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"Example Merchant", null, "Example Merchant Ltd", "Bob", "Alice", "13800138000",
"alice@example.com", "基础版", null, null, 30, null,
null, null, "CNY", null, null), 9001L);
@@ -524,6 +533,7 @@ class TenantServiceTest {
tenant.setStatus(status);
tenant.setDimensionInitializationStatus("READY");
tenant.setTenantName("Example Merchant");
tenant.setCustomerNickname("E");
tenant.setLegalName("Example Merchant Ltd");
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
tenant.setContactName("Alice");