From eb1a8895ac4eeda80a56479ee324381d22a7b8a0 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 25 Aug 2026 16:40:50 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../MerchantRegistrationServiceTest.java | 7 +- config/nacos/cn/iam-service.yml | 4 +- config/nacos/local/iam-service.yml | 3 + deploy/sql/头程数据库统一增量-MySQL8.sql | 37 +++++++---- docs/同步调用依赖与失败传播治理基线.md | 14 ++-- .../CustomerDimensionInitializationRelay.java | 2 + iam/src/main/resources/application.yml | 4 +- ...imensionInitializationSqlContractTest.java | 64 +++++++++++++++++++ ...tomerDimensionInitializationRelayTest.java | 7 +- .../MerchantOnboardingServiceTest.java | 5 +- .../baiyue/iam/service/TenantServiceTest.java | 18 +++++- ...astleg-internal-modularization-baseline.md | 29 ++++++--- scripts/README.md | 4 +- .../synchronous-dependency-contract.test.mjs | 14 +++- 14 files changed, 170 insertions(+), 42 deletions(-) create mode 100644 iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java diff --git a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java index 5a4541699..fd61c5925 100644 --- a/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java +++ b/auth/src/test/java/com/baiyue/auth/service/MerchantRegistrationServiceTest.java @@ -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(); diff --git a/config/nacos/cn/iam-service.yml b/config/nacos/cn/iam-service.yml index 48a0d8795..f735164e7 100644 --- a/config/nacos/cn/iam-service.yml +++ b/config/nacos/cn/iam-service.yml @@ -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} - diff --git a/config/nacos/local/iam-service.yml b/config/nacos/local/iam-service.yml index 4639f7ca1..cc1ad14ab 100644 --- a/config/nacos/local/iam-service.yml +++ b/config/nacos/local/iam-service.yml @@ -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} diff --git a/deploy/sql/头程数据库统一增量-MySQL8.sql b/deploy/sql/头程数据库统一增量-MySQL8.sql index ca30df085..22b4dabb6 100644 --- a/deploy/sql/头程数据库统一增量-MySQL8.sql +++ b/deploy/sql/头程数据库统一增量-MySQL8.sql @@ -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) diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index cf7410e43..b9c4dde5f 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -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 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 ### 全图 diff --git a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java index 730b8908a..817d7eb81 100644 --- a/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java +++ b/iam/src/main/java/com/baiyue/iam/service/CustomerDimensionInitializationRelay.java @@ -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; } diff --git a/iam/src/main/resources/application.yml b/iam/src/main/resources/application.yml index c7f0fe556..5faf2522f 100644 --- a/iam/src/main/resources/application.yml +++ b/iam/src/main/resources/application.yml @@ -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}" - diff --git a/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java new file mode 100644 index 000000000..0c22f9ecf --- /dev/null +++ b/iam/src/test/java/com/baiyue/iam/repository/CustomerDimensionInitializationSqlContractTest.java @@ -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}"); + } +} diff --git a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java index 899ead0e7..bdd692767 100644 --- a/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/CustomerDimensionInitializationRelayTest.java @@ -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 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 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()); diff --git a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java index 51159c7d7..2b48686ef 100644 --- a/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/MerchantOnboardingServiceTest.java @@ -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()); } } - diff --git a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java index c9f7f82c6..eb7bb54dc 100644 --- a/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java +++ b/iam/src/test/java/com/baiyue/iam/service/TenantServiceTest.java @@ -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; } } - diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md index 0acced4c7..f0627b41b 100644 --- a/lastleg-service/docs/lastleg-internal-modularization-baseline.md +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -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 或应用服务。 diff --git a/scripts/README.md b/scripts/README.md index 4b1eb9629..fac0cd8b6 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -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 脚本的离线合同只验证脚本结构、参数和模块边界,不访问测试环境: diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 485ac8d42..fa082a7fd 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -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) }) -