This commit is contained in:
2026-08-25 16:40:50 +08:00
parent 87c5e18ca6
commit eb1a8895ac
14 changed files with 170 additions and 42 deletions
@@ -35,7 +35,7 @@ class MerchantRegistrationServiceTest {
void registeredAuthAccountUsesTheOwnerIdentityReturnedByIamOnboarding() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
var result = service.register(new MerchantRegisterRequest(
"registered_owner", "Password123", "注册人", "测试企业", "张三",
@@ -56,13 +56,14 @@ class MerchantRegistrationServiceTest {
assertEquals("ACTIVE", storedAccount.getValue().getAccountStatus());
assertEquals("encoded-password", storedAccount.getValue().getPasswordHash());
assertEquals("LOGIN_AND_COMPLETE_QUALIFICATIONS", result.nextStep());
assertEquals("PENDING", result.dimensionInitializationStatus());
}
@Test
void failedAuthInsertCompensatesTheIamRegistration() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
doThrow(new IllegalStateException("auth database unavailable")).when(accounts).insert(any());
@@ -82,7 +83,7 @@ class MerchantRegistrationServiceTest {
void rolledBackAuthTransactionCompensatesAfterTheLocalTransactionCompletes() {
when(encoder.encode("Password123")).thenReturn("encoded-password");
when(onboarding.register(eq("internal-token"), any())).thenReturn(ApiResponse.success(
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED")));
new IamOnboardingClient.OnboardingResponse(10001L, 30001L, "PROFILE_REQUIRED", "PENDING")));
when(onboarding.compensate(eq("internal-token"), any())).thenReturn(ApiResponse.success(null));
TransactionSynchronizationManager.initSynchronization();
+3 -1
View File
@@ -7,8 +7,10 @@ spring:
baiyue:
security-event:
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
notification:
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
mq-consumer-enabled: ${NOTIFICATION_MQ_CONSUMER_ENABLED:false}
+3
View File
@@ -4,6 +4,9 @@ spring.datasource:
username: ${IAM_DB_USERNAME:root}
password: ${IAM_DB_PASSWORD:123456}
baiyue:
customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
notification:
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
@@ -14892,6 +14892,21 @@ BEGIN
'baiyue_firstleg','baiyue_file','baiyue_audit','baiyue_event_bridge','baiyue_finance'))<>9 THEN
SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = '统一迁移失败:九个业务数据库未完整创建';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_iam' AND table_name='iam_tenant'
AND column_name='dimension_initialization_status') <> 1
OR (SELECT COUNT(*) FROM information_schema.tables
WHERE table_schema='baiyue_iam'
AND table_name='iam_customer_dimension_initialization_task') <> 1
OR (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_iam'
AND table_name='iam_customer_dimension_initialization_task'
AND column_name IN ('tenant_id','initialization_key','action','status','attempt_count',
'next_retry_at','lease_owner','lease_until','last_error_code','last_error_message',
'task_version')) <> 11 THEN
SIGNAL SQLSTATE '45000'
SET MESSAGE_TEXT = '统一迁移失败:IAM客户默认尺寸后台初始化状态或可靠任务结构不完整';
END IF;
IF (SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema='baiyue_firstleg' AND table_name='fl_carrier_schedule_query'
AND column_name IN ('trigger_source','latest_standard_node','latest_node_observed_at',
@@ -16579,17 +16594,17 @@ CREATE TABLE IF NOT EXISTS iam_customer_dimension_initialization_task (
initialization_key VARCHAR(128) NOT NULL COMMENT '稳定初始化幂等键:iam-customer-create:{tenantId}',
action VARCHAR(16) NOT NULL DEFAULT 'INITIALIZE' COMMENT '目标动作:INITIALIZE初始化、COMPENSATE补偿',
status VARCHAR(16) NOT NULL DEFAULT 'PENDING' COMMENT '投递状态:PENDING、SENDING、READY、FAILED、CANCELLED',
attempt_count INT NOT NULL DEFAULT 0,
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
lease_owner VARCHAR(128) NULL,
lease_until DATETIME(3) NULL,
last_error_code VARCHAR(32) NULL,
last_error_message VARCHAR(1000) NULL,
remote_config_id BIGINT NULL,
task_version INT NOT NULL DEFAULT 0,
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3),
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3),
completed_at DATETIME(3) NULL,
attempt_count INT NOT NULL DEFAULT 0 COMMENT '当前动作已失败尝试次数',
next_retry_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '下次允许投递时间',
lease_owner VARCHAR(128) NULL COMMENT '当前任务租约持有者',
lease_until DATETIME(3) NULL COMMENT '当前任务租约截止时间',
last_error_code VARCHAR(32) NULL COMMENT '最后下游错误码',
last_error_message VARCHAR(1000) NULL COMMENT '最后诊断错误,不保存敏感凭证',
remote_config_id BIGINT NULL COMMENT 'Firstleg返回的首条默认尺寸配置ID',
task_version INT NOT NULL DEFAULT 0 COMMENT '任务乐观版本;切换动作或认领时递增',
created_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) COMMENT '任务创建时间',
updated_at DATETIME(3) NOT NULL DEFAULT CURRENT_TIMESTAMP(3) ON UPDATE CURRENT_TIMESTAMP(3) COMMENT '任务更新时间',
completed_at DATETIME(3) NULL COMMENT 'READY或CANCELLED完成时间',
PRIMARY KEY (tenant_id),
KEY idx_customer_dimension_task_dispatch (status,next_retry_at,lease_until),
KEY idx_customer_dimension_task_diagnosis (status,updated_at)
@@ -2,7 +2,7 @@
## 目的与口径
本基线覆盖后端服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。它用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。
本基线覆盖当前官方 22 个 Maven 模块、15 个可执行服务之间的 OpenFeign、`RestClient`、`WebClient` 与 JDK `HttpClient` 调用。海外安全 Inbox/投影属于 Lastleg 内部子域,不再作为独立服务节点。基线用于识别同步依赖方向、在线请求深度、后台中继、超时、重试、熔断、幂等、错误映射和失败传播,不改变 Finance 或四期业务状态机。
“存在双向服务边”不等于“一次在线请求会递归调用”。每条边必须继续区分:
@@ -21,7 +21,7 @@
|---|---|---:|---|
| Auth | IAM | 2 | 登录授权、注册,`REQUEST` |
| IAM | Auth | 1 | 账号开通/管理,`REQUEST` |
| IAM | Firstleg | 1 | 客户尺寸规则,`REQUEST` |
| IAM | Firstleg | 1 | 客户尺寸默认规则可靠任务投递,`BACKGROUND` |
| IAM | Warehouse | 1 | 海外仓储费协作,`REQUEST` |
| IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` |
| Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` |
@@ -45,10 +45,10 @@
| 调用方 | 被调用方 | 实现 | 上下文与当前策略 |
|---|---|---|---|
| Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 |
| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 |
| Gateway Overseas | Lastleg Security 子域 | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;Lastleg 内部安全投影实时校验,异常失败关闭,不进入在线业务图 |
| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、5 秒读取超时,配置可覆盖,不自动重试 |
| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;分别使用 3 秒连接、30 秒读取超时,配置可覆盖;校验响应 code 且失败关闭,不自动重试 |
| Event Bridge | Firstleg / Lastleg / Overseas Security | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Security 投影不进入在线业务图 |
| Event Bridge | Firstleg / Lastleg(业务事件与安全投影两个入口) | 三个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理;Lastleg Security 投影不进入在线业务图 |
| 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 |
| Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 |
@@ -67,13 +67,13 @@
### 在线 REQUEST 图
在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。
在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和 Gateway→Lastleg Security 基础设施校验均不计入在线深度。
当前在线强连通分量为:
`Auth ↔ IAM`;`Firstleg ↔ Order`
`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。
`IAM → Firstleg` 默认尺寸初始化已退出请求线程,合同现冻结 21 条在线服务边;新增在线边或新增强连通分量直接失败。File 不再属于在线强连通分量;Finance、Lastleg 只指向上述分量或叶子服务,不属于在线环;Gateway Overseas→Lastleg Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。
### 后台 BACKGROUND 图
@@ -81,7 +81,7 @@
`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg`
File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。
File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。
### 全图
@@ -84,6 +84,8 @@ public class CustomerDimensionInitializationRelay {
String value = failure.getMessage();
if (value == null || value.isBlank()) value = failure.getClass().getSimpleName();
value = value.replaceAll("[\\r\\n\\t]+", " ");
value = value.replaceAll("(?i)Bearer\\s+[^\\s,;]+", "Bearer ***")
.replaceAll("(?i)(token|password)=([^\\s&,;]+)", "$1=***");
return value.length() > 1000 ? value.substring(0, 1000) : value;
}
+3 -1
View File
@@ -25,6 +25,9 @@ spring:
baiyue:
security-event:
event-bridge-url: ${SECURITY_EVENT_BRIDGE_URL:http://event-bridge-overseas:9312}
customer-dimension-initialization:
fixed-delay-ms: ${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}
max-attempts: ${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}
notification:
scheduler-enabled: ${NOTIFICATION_SCHEDULER_ENABLED:false}
dispatch-timeout-seconds: ${NOTIFICATION_DISPATCH_TIMEOUT_SECONDS:300}
@@ -47,4 +50,3 @@ logging:
max-history: "${LOGGING_ROLLING_MAX_HISTORY:7}"
total-size-cap: "${LOGGING_ROLLING_TOTAL_SIZE_CAP:5GB}"
clean-history-on-start: "${LOGGING_ROLLING_CLEAN_HISTORY_ON_START:false}"
@@ -0,0 +1,64 @@
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 CustomerDimensionInitializationSqlContractTest {
@Test
void latestIamSqlContainsStatusAndDurableTask() throws Exception {
String sql = new ClassPathResource("db/iam.sql").getContentAsString(StandardCharsets.UTF_8);
assertContract(sql);
}
@Test
void idempotentStandaloneAndUnifiedSqlContainTheSameTaskContract() throws Exception {
String standalone = new ClassPathResource("db/customer-dimension-initialization-task.sql")
.getContentAsString(StandardCharsets.UTF_8);
Path direct = Path.of("deploy", "sql", "头程数据库统一增量-MySQL8.sql");
Path unifiedPath = Files.exists(direct) ? direct
: Path.of("..", "deploy", "sql", "头程数据库统一增量-MySQL8.sql");
String unified = Files.readString(unifiedPath, StandardCharsets.UTF_8);
assertContract(standalone);
assertContract(unified);
assertThat(standalone).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS");
assertThat(unified).contains("information_schema.columns", "CREATE TABLE IF NOT EXISTS");
}
@Test
void schedulerRetryBudgetHasSafeOverridableDefaultsInAllDomesticProfiles() throws Exception {
String application = new ClassPathResource("application.yml").getContentAsString(StandardCharsets.UTF_8);
assertSchedulerConfig(application);
for (String profile : new String[] {"local", "cn"}) {
Path direct = Path.of("config", "nacos", profile, "iam-service.yml");
Path config = Files.exists(direct) ? direct
: Path.of("..", "config", "nacos", profile, "iam-service.yml");
assertSchedulerConfig(Files.readString(config, StandardCharsets.UTF_8));
}
}
private static void assertContract(String sql) {
assertThat(sql)
.contains("dimension_initialization_status")
.contains("iam_customer_dimension_initialization_task")
.contains("initialization_key")
.contains("attempt_count")
.contains("next_retry_at")
.contains("lease_until")
.contains("last_error_code")
.contains("task_version");
}
private static void assertSchedulerConfig(String yaml) {
assertThat(yaml)
.contains("customer-dimension-initialization:")
.contains("${CUSTOMER_DIMENSION_INITIALIZATION_FIXED_DELAY_MS:5000}")
.contains("${CUSTOMER_DIMENSION_INITIALIZATION_MAX_ATTEMPTS:12}");
}
}
@@ -20,6 +20,7 @@ import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -47,7 +48,7 @@ class CustomerDimensionInitializationRelayTest {
relay.deliver(10001L);
verify(repository).markReady(task, workerId(), 91L);
verify(repository, never()).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
verify(repository, never()).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
anyString(), any(Boolean.class));
}
@@ -60,7 +61,7 @@ class CustomerDimensionInitializationRelayTest {
relay.deliver(10001L);
ArgumentCaptor<Boolean> exhausted = ArgumentCaptor.forClass(Boolean.class);
verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
anyString(), exhausted.capture());
assertFalse(exhausted.getValue());
}
@@ -75,7 +76,7 @@ class CustomerDimensionInitializationRelayTest {
relay.deliver(10001L);
ArgumentCaptor<Boolean> exhausted = ArgumentCaptor.forClass(Boolean.class);
verify(repository).recordFailure(any(), anyString(), anyLong(), any(), anyString(),
verify(repository).recordFailure(any(), anyString(), anyInt(), any(), anyString(),
anyString(), exhausted.capture());
assertTrue(exhausted.getValue());
verify(repository, never()).markReady(any(), anyString(), any());
@@ -74,6 +74,8 @@ class MerchantOnboardingServiceTest {
"INITIAL_AUTHORIZATION");
assertEquals("PROFILE_REQUIRED", result.tenantStatus());
assertEquals("PENDING", result.dimensionInitializationStatus());
assertEquals("PENDING", tenant.getValue().getDimensionInitializationStatus());
assertEquals(result.tenantId(), tenant.getValue().getId());
assertEquals("测试企业", tenant.getValue().getLegalName());
assertEquals("张三", tenant.getValue().getLegalRepresentative());
@@ -122,7 +124,7 @@ class MerchantOnboardingServiceTest {
}
@Test
void clientRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() {
void clientRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() {
IamRole ownerRole = new IamRole();
ownerRole.setId(4000100L);
ownerRole.setTenantId(0L);
@@ -199,4 +201,3 @@ class MerchantOnboardingServiceTest {
verify(tenants, never()).deleteById((java.io.Serializable) any());
}
}
@@ -313,6 +313,7 @@ class TenantServiceTest {
9001L);
assertEquals("PROFILE_REQUIRED", created.tenant().status());
assertEquals("PENDING", created.tenant().dimensionInitializationStatus());
assertEquals("Bob", created.tenant().legalRepresentative());
assertEquals("基础版", created.tenant().servicePackage());
assertEquals("13800138000", created.administratorUsername());
@@ -326,7 +327,7 @@ class TenantServiceTest {
}
@Test
void platformRegistrationFailsWhenFourDefaultBoxDimensionsCannotBeInitialized() {
void platformRegistrationFailsWhenDurableInitializationIntentCannotBePersisted() {
doThrow(new BizException(ResultCode.SERVICE_UNAVAILABLE, "初始化失败"))
.when(dimensionRuleInitialization).initialize(any(Long.class), any(Long.class));
@@ -491,12 +492,26 @@ class TenantServiceTest {
assertEquals(true, withQualification.eligible());
}
@Test
void activeCustomerCannotOrderUntilDefaultDimensionsAreReady() {
IamTenant tenant = merchant(10001L, "ACTIVE");
tenant.setDimensionInitializationStatus("FAILED");
when(repository.findTenant(10001L)).thenReturn(tenant);
var result = service.orderEligibility(10001L);
assertEquals(false, result.eligible());
assertEquals("Customer default dimensions are not ready", result.reason());
verify(profileRepository, never()).findQualifications(10001L);
}
private IamTenant merchant(long id, String status) {
IamTenant tenant = new IamTenant();
tenant.setId(id);
tenant.setTenantCode("TEN" + id);
tenant.setTenantType("MERCHANT");
tenant.setStatus(status);
tenant.setDimensionInitializationStatus("READY");
tenant.setTenantName("Example Merchant");
tenant.setLegalName("Example Merchant Ltd");
tenant.setUnifiedSocialCreditCode("91330100EXAMPLE001");
@@ -529,4 +544,3 @@ class TenantServiceTest {
return review;
}
}
@@ -2,7 +2,7 @@
## 1. 范围与生成方式
本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。
本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务流程、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在其他专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。模块内 POM 只增加由根 POM 统一版本管理的 ArchUnit 测试依赖。
可重复命令:
@@ -16,15 +16,15 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph slice
```
分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 67 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。
分析器解析显式内部 `import`、静态 `import` 及同包类型引用,输出类依赖、物理包图、逻辑切片图、Tarjan SCC、Entity/DTO/Model 泄漏与 Mapper 泄漏。`test` 单独输出测试包节点及测试类之间的边;当前测试主要引用生产类型,因此 test-only 图有 68 个包节点但没有测试类之间的内部边。`all` 把 `src/main/java` 和 `src/test/java` 合并解析,是观察测试对生产类型耦合的主图。Spring 扫描字符串、反射、MyBatis SQL 表引用和跨服务运行时调用不属于 Java 类型图。
## 2. 当前规模与 SCC
| 源集 | Java 文件 | 内部类依赖 | 物理包 | 跨切片类依赖 | 模型泄漏 | Mapper 泄漏 |
|---|---:|---:|---:|---:|---:|---:|
| main | 347 | 672 | 157 | 137 | 60 | 38 |
| test only | 98 | 0 | 67 | 0 | 0 | 0 |
| main + test | 445 | 1,195 | 161 | 256 | 108 | 76 |
| main | 353 | 679 | 160 | 138 | 60 | 38 |
| test only | 103 | 0 | 68 | 0 | 0 | 0 |
| main + test | 456 | 1,206 | 164 | 257 | 108 | 76 |
主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC:
@@ -57,7 +57,8 @@ com.baiyue.lastleg.transfer.service
| 换标/转运/废弃 `relabel-transfer-discard` | `relabel/`、`transfer/`、`discard/`,其 PDA 子包保持本域归属 | 61 | 31/0 | 直接使用尾程、库存和一件代发持久化模型;业务事务边界复杂 |
| 一件代发 `dropship` | `dropship/` | 12 | 4/11 | 与尾程消费及废弃库存双向耦合,位于主 SCC |
| 采购/卡车/散板 `procurement-trucking-looseboard` | `procurement/`、`trucking/`、`looseboard/` | 35 | 11/0 | 依赖尾程核心,采购另读取报价目录;仍由四期业务专项维护 |
| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 26 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 |
| 海外主数据 `overseas-master-data` | `masterdata/quote`、`exchange`、`vehicle` | 28 | 3/1 | 不在任何 SCC;仅出向共享权限常量,入向来自采购对报价目录的使用 |
| 海外安全投影 `security-projection` | `security/controller`、`security/dto`、`security/service` | 6 | 1/0 | 独立于业务切片与主 SCC;唯一出向为内部 Controller 复用根 `InternalServiceTokenGuard`,无业务切片入向依赖 |
另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。
@@ -86,12 +87,13 @@ com.baiyue.lastleg.transfer.service
| relabel-transfer-discard | inventory | 6 |
| relabel-transfer-discard | shared-technical | 3 |
| relabel-transfer-discard | tailleg-handoff | 14 |
| security-projection | shared-technical | 1 |
| tailleg-handoff | dropship | 2 |
| tailleg-handoff | inbound-pda | 6 |
| tailleg-handoff | inventory | 2 |
| tailleg-handoff | shared-technical | 2 |
当前 25 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向。
当前 26 条切片边冻结在 `src/test/resources/architecture/lastleg-slice-dependencies.json`。合同允许删除历史边,不允许增加新边;海外主数据继续锁定为只允许出向 `shared-technical`,只允许采购/卡车/散板切片入向;安全投影只允许出向 `shared-technical` 且禁止任何业务切片入向依赖。
## 5. Entity/DTO/Mapper 泄漏基线
@@ -175,8 +177,9 @@ Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermission
## 9. 当前只读合同
- `scripts/analyze-lastleg-dependencies.mjs`:生成 JSON、Markdown、逻辑切片 DOT、main/test/all 物理包 DOT 和 Tarjan SCC。
- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 25 条 main 切片边;历史边可减少,不可增加。
- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出,以及车辆候选的 5 类、零入向、唯一权限出向、零 Client/RestClient 和不进入 SCC。
- `src/test/resources/architecture/lastleg-slice-dependencies.json`:冻结 26 条 main 切片边;历史边可减少,不可增加,并锁定安全投影 6 个类的位置、单一出向与零入向。
- `scripts/analyze-lastleg-dependencies.test.mjs`:校验基线、main/test/all 包图输出、车辆切片边界及安全投影隔离。
- `src/test/java/com/baiyue/lastleg/security/SecurityProjectionArchitectureTest.java`:用 ArchUnit 锁定投影内部只依赖本子域和根安全技术类、业务包不得反向依赖投影、DTO/Service 不依赖内部 Web Adapter。
最小验证:
@@ -197,3 +200,11 @@ node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all -
- 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。
首切片完成后停止,不进入第二切片。
## 11. 海外安全投影合入后的边界
- 用户确认安全投影保留在 `lastleg-service` 部署单元内,不拆新微服务;逻辑上独立为 `security-projection` 切片。
- Controller 仅映射 `/internal/lastleg/security/v1/events` 与 `/validate`,使用 `@Hidden` 并在进入投影或校验服务前调用根 `InternalServiceTokenGuard`。
- MySQL 表固定使用 Lastleg 前缀 `ll_security_event_inbox`、`ll_security_projection`;没有 `overseas_security_*` 表或独立数据库归属。
- `loading`、`shipping`、`inboundplan` 及其他业务切片不得直接依赖 `security/controller|dto|service`;当前 Java 类型图零入向,Node 与 ArchUnit 双重锁定。
- 安全投影不进入主逻辑 SCC,唯一出向为内部 Controller 复用共享令牌守卫,不注册为 loading/shipping/inbound 的模型、Mapper 或应用服务。
+2 -2
View File
@@ -31,8 +31,8 @@ node scripts/create-pending-review-orders.mjs
| Admin/Client 本地测试与类型检查 | `scripts/frontend-full-regression.ps1` |
| PDA 本地页面和状态测试 | `../apps/pda-uniapp/tests/*.test.mjs` |
| 本地数据库 SQL 静态/受控验收 | `scripts/local-db-full-acceptance.ps1` |
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、海外安全服务部署映射、关键入口与排除目录 |
| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 仅用显式 `--require-spotbugs` 检查报告完整性且当前允许失败 |
| AGENTS 快速索引一致性 | `scripts/agents-index-contract.ps1`;只读核对根快速表唯一性、部署单元、业务模块、Client active 路由、海外 Gateway 覆盖、PDA 注册页面、Maven 模块、Lastleg 海外安全投影映射、关键入口与排除目录 |
| Java 质量聚合摘要 | `node --test scripts/java-quality-summary.test.mjs`、`node scripts/java-quality-summary.mjs --output target/java-quality-summary.json`;聚合 Maven/服务拓扑、OpenAPI 覆盖、JaCoCo 和 SpotBugs 报告,默认只报告存量缺口,CI 用显式 `--require-jacoco --require-spotbugs` 检查报告完整性且当前允许失败 |
| 单服务部署失败回滚合同 | `scripts/deploy-built-service-rollback.test.mjs`;模拟成功、健康失败和启动失败,确认仅恢复同服务先前健康镜像且部署任务仍失败 |
Admin/Client HTTP 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境:
@@ -130,6 +130,19 @@ test('every reviewed synchronous client has a complete and exclusive executable
}
})
test('IAM customer dimension Feign is reachable only from the background relay', async () => {
const references = []
for (const file of await javaFiles(path.join(root, 'iam', 'src', 'main', 'java'))) {
const source = await readFile(file, 'utf8')
if (source.includes('CustomerDimensionRuleClient')) {
references.push(path.relative(root, file).replaceAll('\\', '/'))
}
}
assert.deepEqual(references.sort(), [
'iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java',
'iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java'
])
})
test('all four Lastleg RestClient gateways use explicit bounded transports', async () => {
const clients = new Map([
@@ -242,4 +255,3 @@ test('shared Feign timeout defaults and long-running export overrides do not dri
const fileConfig = await readFile(path.join(root, 'file', 'src', 'main', 'resources', 'application.yml'), 'utf8')
assert.equal((fileConfig.match(/read-timeout:\s*15000/g) ?? []).length, 5)
})