diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index f05dade2c..865a9e4b2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -29,6 +29,18 @@ OpenAPI有效性: - npm test --prefix scripts/openapi-validation - npm run validate --prefix scripts/openapi-validation +静态分析报告: + stage: verify + allow_failure: true + script: + - mvn $MAVEN_CLI_OPTS -Pstatic-analysis-report -DskipTests verify + artifacts: + when: always + expire_in: 7 days + paths: + - "**/target/spotbugs*.xml" + - "**/target/site/spotbugs.html" + 校验与测试: stage: verify script: @@ -64,6 +76,7 @@ OpenAPI有效性: - finance/target/*.jar - warehouse/target/*.jar - warehouse-overseas-service/target/*.jar + - overseas-security-service/target/*.jar - lastleg-service/target/*.jar - file/target/*.jar - print/target/*.jar diff --git a/AGENTS.md b/AGENTS.md index 2b612f218..5a0872a4a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,6 +36,7 @@ | 路由、CORS、JWT 入口、访问域 | `gateway/` | `gateway/AGENTS.md` | | 百度/Amazon/OCR/第三方适配 | `integration-service/` | `integration-service/AGENTS.md` | | 海外 API 路由、海外 Token、Audience/JWKS | `gateway-overseas/` | `gateway-overseas/AGENTS.md` | +| 海外 IAM 状态与权限版本投影 | `overseas-security-service/` | `overseas-security-service/AGENTS.md`;消费三类 IAM 安全事件并为海外 Gateway 提供内部 fail-closed 校验 | | 尾程订单、海外入仓计划、换标、库存、采购、本土卡车 | `lastleg-service/` | `lastleg-service/AGENTS.md`,再按 `inboundplan/`、`relabel/`、`inventory/`、`procurement/`、`trucking/` 定位 | | 海外入仓分页、FBA 预约、PDA 柜号签收与入仓 | `lastleg-service/` | 平台入口 `inboundplan/controller/InboundPlanController.java`、`InboundPlanQueryMapper.java`、`InboundFbaAppointmentService.java`;PDA 公网入口 `inboundplan/pda/controller/ReceivingContainerController.java`、`ReceivingContainerService.java`,旧 planNo/allocationId Controller 已移除 | | 海外仓排车查询、候选与智能预排 | `lastleg-service/` | `dispatch/controller/DispatchQueryController.java`、`SmartDispatchController.java`;草稿柜、托盘锁和预排归 `dispatch/`,只读排车和预排创建独立开关,不自动开放编辑、取消、发车、PDA 出库或派送 | diff --git a/Jenkinsfile b/Jenkinsfile index a06ba1017..be2593cce 100644 --- a/Jenkinsfile +++ b/Jenkinsfile @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceCheckboxes = [ @@ -17,6 +17,7 @@ def serviceCheckboxes = [ 'integration-service': 'SERVICE_INTEGRATION', 'lastleg-service': 'SERVICE_LASTLEG', 'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS', + 'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY', 'event-bridge-service': 'SERVICE_EVENT_BRIDGE', 'audit-service': 'SERVICE_AUDIT' ] @@ -68,6 +69,7 @@ pipeline { booleanParam(name: 'SERVICE_INTEGRATION', defaultValue: false, description: '第三方集成服务(integration-service)') booleanParam(name: 'SERVICE_LASTLEG', defaultValue: false, description: '海外尾程与入仓服务(lastleg-service)') booleanParam(name: 'SERVICE_WAREHOUSE_OVERSEAS', defaultValue: false, description: '海外仓资源服务(warehouse-overseas-service)') + booleanParam(name: 'SERVICE_OVERSEAS_SECURITY', defaultValue: false, description: '海外IAM安全投影服务(overseas-security-service)') booleanParam(name: 'SERVICE_EVENT_BRIDGE', defaultValue: false, description: '跨区域事件桥(event-bridge-service;默认同时部署国内和海外实例)') choice(name: 'EVENT_BRIDGE_REGION', choices: ['BOTH', 'CN', 'OVERSEAS'], description: 'BOTH 同时部署国内和海外实例;CN/OVERSEAS 用于单区故障重跑') booleanParam(name: 'SERVICE_AUDIT', defaultValue: false, description: '审计服务(audit-service)') diff --git a/Jenkinsfile.service b/Jenkinsfile.service index bd81515d2..88c8bac39 100644 --- a/Jenkinsfile.service +++ b/Jenkinsfile.service @@ -1,6 +1,6 @@ def serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] def serviceJars = [ @@ -17,6 +17,7 @@ def serviceJars = [ 'integration-service': 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar', 'lastleg-service': 'lastleg-service/target/lastleg-service-1.0.0-SNAPSHOT.jar', 'warehouse-overseas-service': 'warehouse-overseas-service/target/warehouse-overseas-service-1.0.0-SNAPSHOT.jar', + 'overseas-security-service': 'overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar', 'event-bridge-service': 'event-bridge-service/target/event-bridge-service-1.0.0-SNAPSHOT.jar', 'audit-service': 'audit-service/target/audit-service-1.0.0-SNAPSHOT.jar' ] diff --git a/README.md b/README.md index d8202d879..604e465ae 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ - `firstleg`:头程揽收、入仓、装载、发运和交接。 - `warehouse`:国内仓储能力。 - `file`、`print`、`audit-service`、`integration-service`:国内公共业务能力;Print 调用方接入见 `print/README.md`。 -- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源。 +- `gateway-overseas`、`lastleg-service`、`warehouse-overseas-service`、`overseas-security-service`:海外公网入口、海外尾程与入仓业务、海外仓储资源及 IAM 安全投影。 - `event-bridge-service`:按国内、海外双实例部署的跨区域事件桥。 - `common-*`、`common-event-contract`:由本仓维护并以 `com.baiyue.shared` 发布的公共制品。 - `platform-bom`:统一管理仓库内公共依赖版本。 diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index 19fdeacb0..d2312b7cf 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -12,6 +12,7 @@ | 链路上下文 | `src/main/java/com/baiyue/common/web/CorrelationContext.java` | | 请求 ID 过滤器 | `src/main/java/com/baiyue/common/web/CorrelationIdFilter.java` | | W3C Trace Context | `src/main/java/com/baiyue/common/web/TraceParent.java`;严格接收 version 00,响应 `traceparent`、`X-Trace-Id` 与 MDC `traceId` 使用同一 trace ID | +| Feign Trace 传播 | `FeignTraceRequestInterceptor.java`、`FeignTracePropagationAutoConfiguration.java`;仅 Feign 存在时装配,optional 依赖不向无 Feign 服务传递 | | 自动配置入口 | `src/main/java/com/baiyue/common/web/CommonWebAutoConfiguration.java`;为只扫描自身包的服务注册异常处理器和 Correlation 过滤器 | | 全局异常映射与业务失败指标 | `src/main/java/com/baiyue/common/web/GlobalExceptionHandler.java`;指标 `baiyue.api.business.failures` 按有限 `code` 标记 | | 过滤器测试 | `src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java` | @@ -25,5 +26,6 @@ - 修改自动配置时必须验证 `audit-service`、`event-bridge-service` 等只扫描自身包的应用仍能加载 `GlobalExceptionHandler` 和 `CorrelationIdFilter`。 - 修改 Correlation ID 时同步检查当前请求头契约、线程上下文清理和日志链路。 - 入站 Trace 优先级固定为:合法单值 `traceparent` > 完全缺失 `traceparent` 时的 32 位非零十六进制 `X-Trace-Id` > 新 trace。只要 `traceparent` 已出现但重复、超长、格式非法或含全零 ID,就必须生成新 trace,不得回退信任 `X-Trace-Id`;请求 ID 独立按 `X-Request-Id` 校验或生成。 +- 同步 Feign 出站固定先移除调用模板中的 `X-Request-Id`、`X-Trace-Id`、`traceparent`,再仅从当前 MDC 的合法请求 ID 和 trace ID 重建;`traceparent` 沿用当前 trace ID、每次调用生成新非零 span ID、flags 固定 `01`。不得从业务参数、远端返回头或无效 MDC 建立上下文;本能力不处理 Reactor、异步线程、重试、超时或 fallback。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/pom.xml b/common-web/pom.xml index fa892aee9..3a5a14627 100644 --- a/common-web/pom.xml +++ b/common-web/pom.xml @@ -39,6 +39,11 @@ io.micrometer micrometer-registry-prometheus + + io.github.openfeign + feign-core + true + org.springframework.boot spring-boot-starter-test diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..e4223e528 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTracePropagationAutoConfiguration.java @@ -0,0 +1,18 @@ +package com.baiyue.common.web; + +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; + +/** Enables synchronous Feign trace propagation without making Feign a required dependency. */ +@AutoConfiguration(afterName = "org.springframework.cloud.openfeign.FeignAutoConfiguration") +@ConditionalOnClass(name = "feign.RequestInterceptor") +public class FeignTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(FeignTraceRequestInterceptor.class) + FeignTraceRequestInterceptor feignTraceRequestInterceptor() { + return new FeignTraceRequestInterceptor(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java new file mode 100644 index 000000000..88303c069 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/FeignTraceRequestInterceptor.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import feign.RequestTemplate; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import java.util.regex.Pattern; + +/** Propagates the trusted synchronous request trace context to every Feign invocation. */ +public final class FeignTraceRequestInterceptor implements RequestInterceptor, Ordered { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + @Override + public void apply(RequestTemplate template) { + removeManagedHeaders(template); + + String requestId = MDC.get(CorrelationContext.REQUEST_ID); + if (requestId != null && SAFE_REQUEST_ID.matcher(requestId).matches()) { + template.header(CorrelationContext.REQUEST_ID_HEADER, requestId); + } + + String traceId = MDC.get(CorrelationContext.TRACE_ID); + if (TraceParent.validLegacyTraceId(traceId)) { + TraceParent outbound = TraceParent.outboundChild(traceId); + template.header(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + template.header(CorrelationContext.TRACE_PARENT_HEADER, outbound.value()); + } + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } + + private void removeManagedHeaders(RequestTemplate template) { + template.removeHeader(CorrelationContext.REQUEST_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_ID_HEADER); + template.removeHeader(CorrelationContext.TRACE_PARENT_HEADER); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java index 2fbd3a1ab..2ece6f081 100644 --- a/common-web/src/main/java/com/baiyue/common/web/TraceParent.java +++ b/common-web/src/main/java/com/baiyue/common/web/TraceParent.java @@ -55,6 +55,14 @@ public record TraceParent(String traceId, String parentId, String traceFlags) { return new TraceParent(traceId, randomNonZero(8), "00"); } + /** Creates a sampled outbound child for a trusted current trace ID. */ + public static TraceParent outboundChild(String currentTraceId) { + if (!validLegacyTraceId(currentTraceId)) { + throw new IllegalArgumentException("Current trace ID is invalid"); + } + return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); + } + public static boolean validLegacyTraceId(String value) { return value != null && LEGACY_TRACE_ID.matcher(value).matches() && !ZERO_TRACE_ID.equalsIgnoreCase(value); diff --git a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index 66426422f..06a277bae 100644 --- a/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/common-web/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,2 +1,3 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration +com.baiyue.common.web.FeignTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java index c73051fbe..67d4bd3be 100644 --- a/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/CorrelationIdFilterTest.java @@ -33,6 +33,8 @@ class CorrelationIdFilterTest { .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); assertThat(chain.traceId).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); assertThat(chain.spanId).matches("[0-9a-f]{16}").isNotEqualTo("0000000000000000"); + assertThat(org.slf4j.MDC.get(CorrelationContext.TRACE_ID)).isNull(); + assertThat(org.slf4j.MDC.get(CorrelationContext.SPAN_ID)).isNull(); } @Test diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..2aeec044f --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import feign.RequestInterceptor; +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(FeignTracePropagationAutoConfiguration.class)); + + @Test + void registersTraceInterceptorWhenFeignIsPresent() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class); + assertThat(context.getBeansOfType(RequestInterceptor.class)).hasSize(1); + }); + } + + @Test + void doesNotLoadFeignConfigurationWhenFeignIsAbsent() { + contextRunner.withClassLoader(new FilteredClassLoader(RequestInterceptor.class)) + .run(context -> { + assertThat(context).hasNotFailed(); + assertThat(context).doesNotHaveBean("feignTraceRequestInterceptor"); + }); + } + + @Test + void backsOffOnlyForAnApplicationProvidedTraceInterceptor() { + contextRunner.withBean(FeignTraceRequestInterceptor.class, FeignTraceRequestInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(FeignTraceRequestInterceptor.class)); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java new file mode 100644 index 000000000..d87b3d445 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/FeignTraceRequestInterceptorTest.java @@ -0,0 +1,80 @@ +package com.baiyue.common.web; + +import feign.RequestTemplate; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.core.Ordered; + +import static org.assertj.core.api.Assertions.assertThat; + +class FeignTraceRequestInterceptorTest { + private final FeignTraceRequestInterceptor interceptor = new FeignTraceRequestInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void runsLastSoTrustedContextHasFinalHeaderAuthority() { + assertThat(interceptor.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + } + + @Test + void overwritesCallerHeadersFromTrustedMdcAndCreatesNewSampledSpan() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted-123"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers().get(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-trusted-123"); + assertThat(template.headers().get(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(template.headers().get(CorrelationContext.TRACE_PARENT_HEADER)) + .singleElement().asString() + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void createsDifferentNonZeroSpanForEachSynchronousInvocation() { + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + RequestTemplate first = new RequestTemplate(); + RequestTemplate second = new RequestTemplate(); + + interceptor.apply(first); + interceptor.apply(second); + + assertThat(singleHeader(first, CorrelationContext.TRACE_PARENT_HEADER)) + .isNotEqualTo(singleHeader(second, CorrelationContext.TRACE_PARENT_HEADER)); + } + + @Test + void stripsManagedCallerHeadersWhenTrustedContextIsMissingOrInvalid() { + MDC.put(CorrelationContext.REQUEST_ID, "invalid request id"); + MDC.put(CorrelationContext.TRACE_ID, "not-a-w3c-trace-id"); + RequestTemplate template = templateWithSpoofedHeaders(); + + interceptor.apply(template); + + assertThat(template.headers()).doesNotContainKeys( + CorrelationContext.REQUEST_ID_HEADER, + CorrelationContext.TRACE_ID_HEADER, + CorrelationContext.TRACE_PARENT_HEADER); + } + + private RequestTemplate templateWithSpoofedHeaders() { + RequestTemplate template = new RequestTemplate(); + template.header(CorrelationContext.REQUEST_ID_HEADER, "req-business-parameter"); + template.header(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + template.header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + return template; + } + + private String singleHeader(RequestTemplate template, String name) { + return template.headers().get(name).iterator().next(); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java index c97edfa34..e59be28aa 100644 --- a/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java +++ b/common-web/src/test/java/com/baiyue/common/web/TraceParentTest.java @@ -43,4 +43,12 @@ class TraceParentTest { assertThat(root.value()).matches( "00-abcdef0123456789abcdef0123456789-[0-9a-f]{16}-00"); } + + @Test + void createsSampledOutboundChildForTrustedCurrentTrace() { + TraceParent child = TraceParent.outboundChild("4BF92F3577B34DA6A3CE929D0E0E4736"); + + assertThat(child.value()).matches( + "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } } diff --git a/config/nacos/overseas/event-bridge-service.yml b/config/nacos/overseas/event-bridge-service.yml index 27c492a4b..bbc198d5c 100644 --- a/config/nacos/overseas/event-bridge-service.yml +++ b/config/nacos/overseas/event-bridge-service.yml @@ -10,6 +10,7 @@ baiyue: data-residency: ${DATA_RESIDENCY:OVERSEAS} event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:http://lastleg-service:9303/internal/lastleg/events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://overseas-security-service:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/config/nacos/overseas/gateway-overseas.yml b/config/nacos/overseas/gateway-overseas.yml index e16f76e10..11e2deb34 100644 --- a/config/nacos/overseas/gateway-overseas.yml +++ b/config/nacos/overseas/gateway-overseas.yml @@ -1,3 +1,9 @@ springdoc: api-docs.enabled: ${SWAGGER_ENABLED:false} swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL:http://overseas-security-service:9307} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} diff --git a/config/nacos/overseas/overseas-security-service.yml b/config/nacos/overseas/overseas-security-service.yml new file mode 100644 index 000000000..80565c445 --- /dev/null +++ b/config/nacos/overseas/overseas-security-service.yml @@ -0,0 +1,7 @@ +springdoc: + api-docs.enabled: ${SWAGGER_ENABLED:false} + swagger-ui.enabled: ${SWAGGER_ENABLED:false} +baiyue: + security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} diff --git a/deploy/docker-compose.overseas.yml b/deploy/docker-compose.overseas.yml index 5eb84191d..f41f67f15 100644 --- a/deploy/docker-compose.overseas.yml +++ b/deploy/docker-compose.overseas.yml @@ -47,6 +47,8 @@ services: CORS_ALLOW_CREDENTIALS: ${CORS_ALLOW_CREDENTIALS:-false} AUTH_SESSION_VALIDATION_URL: ${AUTH_SESSION_VALIDATION_URL} PDA_SESSION_VALIDATION_TIMEOUT: ${PDA_SESSION_VALIDATION_TIMEOUT:-2s} + OVERSEAS_SECURITY_SERVICE_URL: ${OVERSEAS_SECURITY_SERVICE_URL:-http://overseas-security-service:9307} + OVERSEAS_SECURITY_VALIDATION_TIMEOUT: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:-2s} lastleg-service: <<: *overseas-common @@ -92,11 +94,25 @@ services: EVENT_BRIDGE_DB_USERNAME: ${EVENT_BRIDGE_OVERSEAS_DB_USERNAME} EVENT_BRIDGE_DB_PASSWORD: ${EVENT_BRIDGE_OVERSEAS_DB_PASSWORD} EVENT_CONSUMER_URL: ${EVENT_CONSUMER_URL:-http://lastleg-service:9303/internal/lastleg/events} + OVERSEAS_SECURITY_EVENT_CONSUMER_URL: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:-http://overseas-security-service:9307/internal/overseas-security/v1/events} networks: firstleg-overseas-network: infra-network: aliases: [event-bridge-overseas] + overseas-security-service: + <<: *overseas-common + image: ${IMAGE_REPOSITORY}/overseas-security-service:${IMAGE_TAG} + container_name: firstleg-${DEPLOY_ENV:-test}-overseas-security + ports: ["${PUBLISHED_BIND_ADDRESS:-127.0.0.1}:${OVERSEAS_SECURITY_PORT:-9307}:9307"] + volumes: ["${LOG_ROOT:-/data/logs/baiyue-firstleg}/overseas-security-service:/app/logs"] + environment: + <<: *overseas-environment + SERVER_PORT: 9307 + OVERSEAS_SECURITY_DB_URL: ${OVERSEAS_SECURITY_DB_URL} + OVERSEAS_SECURITY_DB_USERNAME: ${OVERSEAS_SECURITY_DB_USERNAME} + OVERSEAS_SECURITY_DB_PASSWORD: ${OVERSEAS_SECURITY_DB_PASSWORD} + networks: firstleg-overseas-network: name: firstleg-${DEPLOY_ENV:-test}-overseas-network diff --git a/deploy/scripts/deploy-built-service.sh b/deploy/scripts/deploy-built-service.sh index f20ca9be2..b8d703d8c 100644 --- a/deploy/scripts/deploy-built-service.sh +++ b/deploy/scripts/deploy-built-service.sh @@ -67,7 +67,7 @@ env_file_value() { } case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) COMPOSE_FILE="deploy/docker-compose.overseas.yml" PROJECT_NAME="firstleg-$DEPLOY_ENV-overseas" ;; @@ -101,6 +101,15 @@ if [ "$SERVICE" = "gateway-overseas" ]; then fi fi +if [ "$SERVICE" = "overseas-security-service" ]; then + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done +fi + for file in "$ENV_FILE" "$COMPOSE_FILE"; do if [ ! -f "$file" ]; then echo "必需文件不存在: $file" >&2 @@ -118,7 +127,7 @@ if [ "$SERVICE" = "finance" ]; then fi case "$SERVICE" in - gateway-overseas|lastleg-service|warehouse-overseas-service) + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) for key in NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN JWT_ISSUER OVERSEAS_JWT_AUDIENCE JWT_JWK_SET_URI; do if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then echo "部署海外服务时,环境文件必须包含非空 $key" >&2 diff --git a/deploy/scripts/deploy-services.sh b/deploy/scripts/deploy-services.sh index 7c9983f82..abdbafef5 100644 --- a/deploy/scripts/deploy-services.sh +++ b/deploy/scripts/deploy-services.sh @@ -39,7 +39,7 @@ env_file_value() { DEPLOY_REGION=CN for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service) DEPLOY_REGION=OVERSEAS ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service) DEPLOY_REGION=OVERSEAS ;; event-bridge-service) BRIDGE_REGION="${EVENT_BRIDGE_REGION_OVERRIDE:-}" if [ -z "$BRIDGE_REGION" ] && env_file_has_key REGION_CODE; then @@ -60,7 +60,7 @@ done if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then for service in $SERVICES; do case "$service" in - gateway-overseas|lastleg-service|warehouse-overseas-service|event-bridge-service) ;; + gateway-overseas|lastleg-service|warehouse-overseas-service|overseas-security-service|event-bridge-service) ;; *) echo "一次部署不得混合国内与海外服务: $SERVICE_CSV" >&2; exit 2 ;; esac done @@ -80,6 +80,17 @@ if [ "$DEPLOY_REGION" = "OVERSEAS" ]; then done fi +case " $SERVICES " in + *" overseas-security-service "*) + for key in OVERSEAS_SECURITY_DB_URL OVERSEAS_SECURITY_DB_USERNAME OVERSEAS_SECURITY_DB_PASSWORD; do + if ! env_file_has_key "$key" || [ -z "$(env_file_value "$key")" ]; then + echo "部署 overseas-security-service 时,环境文件必须包含非空 $key" >&2 + exit 2 + fi + done + ;; +esac + case " $SERVICES " in *" gateway-overseas "*) if ! env_file_has_key AUTH_SESSION_VALIDATION_URL || [ -z "$(env_file_value AUTH_SESSION_VALIDATION_URL)" ]; then diff --git a/deploy/service-map.yml b/deploy/service-map.yml index e22eec804..e78b8a6e5 100644 --- a/deploy/service-map.yml +++ b/deploy/service-map.yml @@ -8,6 +8,11 @@ services: jar: gateway-overseas/target/gateway-overseas-1.0.0-SNAPSHOT.jar port: 9300 regions: [OVERSEAS] + overseas-security-service: + artifactId: overseas-security-service + jar: overseas-security-service/target/overseas-security-service-1.0.0-SNAPSHOT.jar + port: 9307 + regions: [OVERSEAS] auth: artifactId: auth-service jar: auth/target/auth-service-1.0.0-SNAPSHOT.jar diff --git a/deploy/sql/README.md b/deploy/sql/README.md index b81a56b25..dc681dba9 100644 --- a/deploy/sql/README.md +++ b/deploy/sql/README.md @@ -3,7 +3,7 @@ ## 唯一数据库初始化与发布入口 - 国内区域固定入口:`头程数据库统一增量-MySQL8.sql`。文件名保留历史兼容,内容维护为当前完整数据库基线和存量环境兼容升级,不再按日期拆分发布脚本。 -- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql` 与 `warehouse-overseas-service/src/main/resources/db/warehouse.sql` 基线,再执行该入口。固定入口同时升级 `baiyue_lastleg` 与 `baiyue_lastlag_warehouse`,当前包含海外主数据、业务、入仓库存与海外仓托盘兼容结构,后续继续追加到同一入口,不按日期拆分。 +- 海外区域固定增量入口:`海外区域统一增量-MySQL8.sql`;空库先分别执行 `lastleg-service/src/main/resources/db/lastleg.sql`、`warehouse-overseas-service/src/main/resources/db/warehouse.sql` 与 `overseas-security-service/src/main/resources/db/overseas-security.sql` 基线,再执行该入口。固定入口升级 `baiyue_lastleg`、`baiyue_lastlag_warehouse` 与 `baiyue_overseas_security`,后者保存 IAM 安全事件消费者 Inbox 和可恢复投影;Redis 只作可重建镜像。 - 国内、海外 Compose 部署默认设置 `SPRING_SQL_INIT_MODE=never`,执行任一 SQL 均需用户单独授权。 - 该脚本汇总一期、二期及已实现三期出仓能力的数据库基线、存量升级、IAM 权限资源和最终结构校验,适用于 MySQL 8.0。 - 正式上线前的数据库变更直接维护该文件中的当前基线、存量兼容处理和最终校验,不再创建按日期拆分的发布 SQL;正式上线后的迁移策略在生产发布规范中另行冻结。 diff --git a/deploy/sql/海外区域统一增量-MySQL8.sql b/deploy/sql/海外区域统一增量-MySQL8.sql index afb0c1055..41f10d449 100644 --- a/deploy/sql/海外区域统一增量-MySQL8.sql +++ b/deploy/sql/海外区域统一增量-MySQL8.sql @@ -919,4 +919,34 @@ END$$ DELIMITER ; CALL assert_phase4_pda_operations_ready(); DROP PROCEDURE assert_phase4_pda_operations_ready; -SELECT 'PASS' AS migration_status, '海外仓报价、月度汇率、车辆、海外业务、入仓库存与海外仓托盘结构已就绪' AS message; + +CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_overseas_security; +CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( + event_id VARCHAR(64) NOT NULL, event_type VARCHAR(96) NOT NULL, payload_hash CHAR(64) NOT NULL, + payload_json JSON NOT NULL, status VARCHAR(16) NOT NULL, result VARCHAR(32) NULL, + received_at DATETIME(3) NOT NULL, processed_at DATETIME(3) NULL, + PRIMARY KEY (event_id), KEY idx_security_inbox_type_time (event_type,received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; +CREATE TABLE IF NOT EXISTS overseas_security_projection ( + aggregate_type VARCHAR(32) NOT NULL, aggregate_id VARCHAR(64) NOT NULL, tenant_id BIGINT NOT NULL, + security_status VARCHAR(24) NULL, permission_version BIGINT NULL, aggregate_version BIGINT NOT NULL, + payload_hash CHAR(64) NOT NULL, last_event_id VARCHAR(64) NOT NULL, + changed_at DATETIME(3) NOT NULL, updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (aggregate_type,aggregate_id), KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id,permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; + +DROP PROCEDURE IF EXISTS assert_overseas_security_projection_ready; +DELIMITER $$ +CREATE PROCEDURE assert_overseas_security_projection_ready() +BEGIN + IF (SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='baiyue_overseas_security' + AND table_name IN ('overseas_security_event_inbox','overseas_security_projection')) <> 2 THEN + SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT='海外IAM安全Inbox或投影表未完整部署'; + END IF; +END$$ +DELIMITER ; +CALL assert_overseas_security_projection_ready(); +DROP PROCEDURE assert_overseas_security_projection_ready; +SELECT 'PASS' AS migration_status, '海外仓业务、海外仓资源与IAM安全投影结构已就绪' AS message; diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index 5755b0d21..0562b97bb 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -11,7 +11,7 @@ - `INFRASTRUCTURE`:审计、网关会话校验等基础设施调用; - `EXTERNAL`:Integration 对第三方供应商的调用。 -离线清单合同为 `scripts/synchronous-dependency-contract.test.mjs`。新增客户端、目标服务、原始 HTTP 实现、强连通分量或超时配置时,必须先更新本基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 +逐客户端可执行清单位于 `scripts/synchronous-dependency-baseline.mjs`,离线合同为 `scripts/synchronous-dependency-contract.test.mjs`。清单逐项冻结 44 个 Feign 与 16 个原始 HTTP 客户端的执行上下文、`READ/WRITE/READ_WRITE`、幂等类型和说明,并从同一事实源输出 REQUEST、BACKGROUND、INFRASTRUCTURE、EXTERNAL 与全图。新增客户端、目标服务、在线边、强连通分量或超时配置时,必须先更新基线并完成调用方与被调用方评审,不能仅修改测试期望以放行。 ## 当前服务边 @@ -34,7 +34,7 @@ | Finance | File / IAM / Warehouse | 各 1 | 凭证、客户、供应商校验,`REQUEST` | | File | IAM | 2 | 资质登记为 `REQUEST`;导出 Provider 为 `BACKGROUND` | | File | Order / Firstleg / Finance / Warehouse | 各 1 | 导出 Provider,`BACKGROUND` | -| File | Integration | 1 | OCR,`BACKGROUND` 或上传处理线程 | +| File | Integration | 1 | OCR 在上传处理请求线程执行,`REQUEST` | | Lastleg | Event Bridge | 1 | 跨区事件 Outbox 中继,`BACKGROUND` | | Lastleg | 国内 Warehouse | 1 | 合作仓报价快照,`REQUEST` | @@ -45,28 +45,73 @@ | 调用方 | 被调用方 | 实现 | 上下文与当前策略 | |---|---|---|---| | Gateway Overseas | Auth | `HttpPdaSessionValidationClient` | `INFRASTRUCTURE`;总超时 2 秒,异常由网关失败关闭 | -| Lastleg | Warehouse Overseas | 两个 `RestClient` Gateway | `REQUEST`;当前未看到客户端级连接/读取超时,是高优先级缺口 | -| Lastleg | File | 两个照片 `RestClient` Gateway | `REQUEST`;校验响应 code 且失败关闭,但当前未看到客户端级连接/读取超时 | -| Event Bridge | Firstleg / Lastleg | 两个 JDK `HttpClient` | `BACKGROUND`;连接 3 秒、请求 10 秒,持久化租约和退避由 Event Bridge 专项治理 | +| Gateway Overseas | Overseas Security | `HttpOverseasSecurityValidationClient` | `INFRASTRUCTURE`;安全投影实时校验,异常失败关闭,不进入在线业务图 | +| 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 投影不进入在线业务图 | | 启用 Common Audit 的服务 | Audit | `HttpAuditSink` | `INFRASTRUCTURE`;当前捕获异常后仅告警,可能让调用方误以为审计已成功,是高优先级失败传播缺口 | | Integration | 外部 OCR/船司 | 六个 JDK `HttpClient` | `EXTERNAL`;OCR 默认 10/60 秒,船司默认 10/30 秒,业务重试必须服从供应商和调度契约 | -## 依赖环基线 +#### Lastleg 四个有界 RestClient -按服务边计算,当前唯一非平凡强连通分量包含 8 个服务: +四个客户端统一从 `LastlegRestClientFactory` 构建 JDK HTTP 传输,由 `LastlegRestClientProperties` 绑定独立超时。读取超时是单次请求的硬边界;客户端没有 Retry、fallback 或默认成功配置。 + +| 客户端 | 构建与调用路径 | 写操作幂等性 | 默认超时 | +|---|---|---|---| +| `HttpUnloadPhotoFileGateway` | `ReceivingContainerController → ReceivingContainerService → UnloadPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 每次写文件和元数据,没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpRelabelPhotoFileGateway` | `RelabelPdaController/Service → RelabelPhotoFileGateway → /internal/file/v1/business-files/**` | `validate` GET 可重复;`upload` 没有幂等键,禁止自动重试 | 连接 3 秒、读取 30 秒 | +| `HttpWarehouseResourceGateway` | `InboundPdaWorkflowService`、PDA 仓内查询/盘点及 Dropship → Warehouse Resource/Pallet 内部接口 | 查询可重复;`reserve` 使用 `reservationKey`,`ensureDropship` 使用业务绑定,release/scrap 由服务端状态收敛;尽管服务端具备业务幂等,客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | +| `HttpPalletPhysicalGateway` | `PdaPalletOperationService`、`PdaInventoryMoveService` → `/internal/v1/pda-pallet-transitions/**` | 查询可重复;apply/compensate 使用稳定 `operationKey` 与请求哈希,服务端幂等;客户端仍不自动重试 | 连接 3 秒、读取 5 秒 | + +## 分上下文依赖图 + +### 在线 REQUEST 图 + +在线图只包含用户或设备请求线程中实际执行的服务边。Event Bridge、Outbox、船节点定时任务、导出 Provider 和海外 Security 校验均不计入在线深度。 + +当前在线强连通分量为: + +`Auth ↔ IAM ↔ Firstleg ↔ Order ↔ File` + +合同冻结 22 条在线服务边;新增在线边或新增强连通分量直接失败。Finance、Lastleg 只指向该分量或叶子服务,不属于在线环;Gateway Overseas→Security 是 `INFRASTRUCTURE`,不是 REQUEST 边。 + +### 后台 BACKGROUND 图 + +后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: + +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` + +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Overseas Security 是单向后台叶子边,不形成在线业务环。 + +### 全图 + +全上下文合并后,非平凡强连通分量仍为 8 个服务: `Auth ↔ IAM ↔ Order ↔ Firstleg ↔ Finance ↔ File ↔ Event Bridge ↔ Lastleg` -其中优先关注的直接双向边为: +全图只用于部署影响和故障域观察,不能再用于推导在线调用“无限深度”。 -1. `Auth ↔ IAM`:账号写模型与授权读模型相互同步依赖; -2. `Order ↔ Firstleg`:订单交易与履约同时存在查询、命令和 Outbox 投递; -3. `IAM ↔ Firstleg`:客户规则与客户快照双向查询; -4. `Firstleg ↔ Finance`:费用 Outbox 投递后 Finance 同步回读不可变快照; -5. `Firstleg ↔ File`:业务文件请求边与 File 导出 Worker 回调边叠加; -6. `File ↔ Finance`:凭证请求边与导出 Worker 回调边叠加。 +### 用户入口在线最长简单路径 -强连通分量说明静态图无法给出有限的“最大服务跳数”。在线深度治理必须以入口用例为单位:默认不新增超过 2 个下游服务跳数的在线链;后台 Outbox/导出链单独计算,不得计入在线调用预算,也不得在下游处理期间同步回调原始调用方形成嵌套闭环。 +默认在线预算为最多 2 个下游服务跳数。合同对每个已知用户入口枚举全部最长简单路径;下表列出的具体路径均超过预算: + +| 用户入口 | 最长跳数 | 全部长路径 | +|---|---:|---| +| 认证 API | 5 | `Auth → IAM → Firstleg → Order → File → Integration` | +| IAM API | 4 | `IAM → Firstleg → Order → File → Integration` | +| 订单 API | 4 | `Order → File → IAM → Firstleg → Print`;`Order → File → IAM → Firstleg → Warehouse`;`Order → Firstleg → File → IAM → Auth`;`Order → Firstleg → File → IAM → Warehouse`;`Order → IAM → Firstleg → File → Integration` | +| 头程 API/PDA | 4 | `Firstleg → Order → File → IAM → Auth`;`Firstleg → Order → File → IAM → Warehouse` | +| 财务 API | 5 | `Finance → File → IAM → Firstleg → Order → Warehouse`;`Finance → Firstleg → Order → File → IAM → Auth`;`Finance → Firstleg → Order → File → IAM → Warehouse`;`Finance → IAM → Firstleg → Order → File → Integration` | +| 文件 API | 4 | `File → IAM → Firstleg → Order → Warehouse` | +| 海外尾程 API/PDA | 5 | `Lastleg → File → IAM → Firstleg → Order → Warehouse` | +| 国内仓储 API | 0 | 无下游同步调用 | +| 海外仓储 API | 0 | 无下游同步调用 | + +这些是静态服务边可形成的最长简单路径,不表示每个具体 Controller 都会走完整链;后续入口级调用点治理必须继续以实际 Service 调用证据缩小路径,不能用静态可能性宣称线上已经发生。 + +### 第一个建议退出的 REQUEST 边 + +建议优先将 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化从 REQUEST WRITE 改为可靠后台编排。该命令已经携带稳定 `initializationKey` 并提供补偿接口,适合使用持久化 Outbox、显式 `PENDING/FAILED` 结果和人工可诊断重试;不得在失败时返回默认成功。退出此边可把当前五服务在线强连通分量拆为更小的 Auth↔IAM 与 Order↔Firstleg 子环,同时不触碰财务、Event Bridge 传输或四期业务状态机。实施前仍需 IAM/Firstleg 业务负责人确认“客户创建成功”与“默认尺寸已就绪”的产品时点。 ## 退出顺序 diff --git a/docs/头程项目目录结构与服务说明.md b/docs/头程项目目录结构与服务说明.md index 0fad3c9d3..2ed49423a 100644 --- a/docs/头程项目目录结构与服务说明.md +++ b/docs/头程项目目录结构与服务说明.md @@ -10,7 +10,7 @@ ## 1. 项目定位 -`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 22 个 Maven 模块,其中 7 个是公共/依赖模块,15 个是可独立启动、构建、制作镜像的微服务。 +`baiyue-firstleg` 是国内头程与海外仓后端仓库,采用 Maven 多模块单仓结构。根 POM 聚合 23 个 Maven 模块,其中 7 个是公共/依赖模块,16 个是可独立启动、构建、制作镜像的微服务。 当前技术基线: @@ -48,6 +48,7 @@ baiyue-firstleg/ ├─ finance/ # 财务账期、账单、收付款与核销 ├─ lastleg-service/ # 海外尾程、入仓、库存与本土业务 ├─ warehouse-overseas-service/ # 海外仓库、库位、托盘与资源锁 +├─ overseas-security-service/ # 海外IAM安全事件Inbox、投影与实时校验 ├─ file/ # 资质、导出与测量图片文件中心 ├─ print/ # 打印中心 ├─ audit-service/ # 审计日志中心 @@ -101,6 +102,7 @@ baiyue-firstleg/ | `integration-service` | `integration-service` | 9210 | 无 | 百度 OCR、Amazon SP-API 等第三方适配器和启停状态;默认关闭外部调用 | | `lastleg-service` | `lastleg-service` | 9303 | `baiyue_lastleg` | 海外尾程订单、接柜入仓、库存与海外本土业务;不含海外出库和派送 | | `warehouse-overseas-service` | `warehouse-overseas-service` | 9305 | 海外仓资源库 | 海外仓库、库位、托盘和资源锁 | +| `overseas-security-service` | `overseas-security-service` | 9307 | `baiyue_overseas_security` | IAM安全事件可靠Inbox、单调投影及海外Token即时失效校验 | | `event-bridge-service` | `event-bridge-service` | 9312 | `baiyue_event_bridge` | 按 CN/OVERSEAS 双实例部署的跨区事件 Inbox、重试和目标服务投递 | | `audit-service` | `audit-service` | 9700 | `baiyue_audit` | 接收公共审计事件并持久化;平台端可按权限查询已脱敏日志,业务审计发送默认开启 | @@ -281,6 +283,6 @@ HTTP 401 / `A0401`,不得再按 HTTP 200 + 空权限处理;只有使用新 - 平台端和商户端权限分别管理,接口与按钮权限都必须由后端校验。 - Controller 负责协议转换,Service 负责业务编排,Repository/Mapper 负责持久化。 - 服务之间只传 DTO 或事件契约,不能依赖其他服务的 Entity、Mapper。 -- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 15 个服务执行受影响构建。 +- 公共模块变化会影响多个服务,Jenkins 会按 `service-map.yml` 覆盖的全部 16 个服务执行受影响构建。 - 单一 Git 仓库不等于单一部署单元;每个服务仍生成独立 JAR、镜像和容器。 - 国内头程与海外仓共享 Git 仓库,但保持两套数据库、Nacos 命名空间和部署环境。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 75fe1f706..14ed05d26 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -12,11 +12,12 @@ |---|---| | 接收、状态、人工补投 API | `src/main/java/com/baiyue/eventbridge/controller/EventBridgeController.java` | | Inbox 接收与幂等 | `service/EventInboxService.java` | -| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=OVERSEAS` 投 Lastleg,`targetRegion=CN` 回投 Firstleg | +| 按目标区域调度与重试 | `service/OverseasEventDispatcher.java`;`targetRegion=CN` 回投 Firstleg;海外业务事件投 Lastleg,三类 IAM 安全事件按 eventType 投 `overseas-security-service`,传输层不解析 payload | | 尾程 HTTP 客户端 | `service/LastlegEventClient.java`、`HttpLastlegEventClient.java` | | 国内头程 HTTP 客户端 | `service/FirstlegEventClient.java`、`HttpFirstlegEventClient.java`;默认内部入口 `/internal/firstleg/v1/container-events` | | 人工补投 | `service/EventReplayService.java`;只允许 `DEAD` 转回 `RETRY`,记录操作人与原因,并保留原 `eventId`、payload 与 `payloadHash` | | Inbox 与投递记录 | `domain/BridgeInbox.java`、`BridgeDeliveryAttempt.java` 及对应 Mapper | +| 传输指标与 Inbox 采样 | `metrics/EventBridgeMetrics.java`、`EventBridgeInboxMetricsSampler.java`;SLO 与告警合同 `docs/development-observability-slo.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -27,6 +28,7 @@ - 每次投递保存 attempt、错误和时间;达到上限进入 `DEAD`,经授权人工补投后重新进入重试队列。 - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 +- 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 ## 修改与验证 diff --git a/event-bridge-service/docs/development-observability-slo.md b/event-bridge-service/docs/development-observability-slo.md new file mode 100644 index 000000000..1c05c13ce --- /dev/null +++ b/event-bridge-service/docs/development-observability-slo.md @@ -0,0 +1,67 @@ +# Event Bridge 开发阶段可观测性与 SLO 合同 + +## 状态与边界 + +本文冻结代码侧指标、SLI/SLO 和告警表达式模板。它不表示任何环境已经部署、接入或启用了 +Prometheus、Alertmanager 或 Grafana,也不授权部署、访问环境或修改环境配置。 + +指标标签只允许 `target_region`、`event_type`、`result`: + +- `target_region` 仅为 `CN`、`OVERSEAS`、`OTHER`。 +- `event_type` 仅为 `EventTypes` 已登记类型或 `OTHER`。 +- `result` 仅为代码中冻结的接收或传输结果枚举。 +- 严禁把 `eventId`、`customerId`、聚合 ID、错误消息、请求 URL 或操作人写入指标标签。 + +## 指标合同 + +| Prometheus 指标 | 含义 | +|---|---| +| `baiyue_event_bridge_receive_total` | 接收结果;`result=RECEIVED|DUPLICATE|CONFLICT` | +| `baiyue_event_bridge_transport_total` | 传输结果;`result=DELIVERED|RETRY|DEAD|ACKNOWLEDGED_LEASE_LOST|FAILED_LEASE_LOST|MANUAL_REPLAY` | +| `baiyue_event_bridge_delivery_latency_seconds_bucket` | 从 Event Bridge `received_at` 到下游首次成功确认;固定包含 60 秒和 900 秒 SLO 桶 | +| `baiyue_event_bridge_inbox_backlog` | 当前 `RECEIVED`、`RETRY`、`PROCESSING` 数量 | +| `baiyue_event_bridge_inbox_oldest_age_seconds` | 当前最老待处理事件自 `received_at` 起的年龄 | +| `baiyue_event_bridge_inbox_dead` | 当前 `DEAD` 数量 | + +`DELIVERED` 与 `ACKNOWLEDGED_LEASE_LOST` 都表示下游已经确认,因此均记录确认延迟;后者仍单独计数, +不得冒充本地 Inbox 已成功更新。`RETRY`、`DEAD`、`FAILED_LEASE_LOST` 不记录成功延迟。 + +Inbox Gauge 由服务每 30 秒执行一次受控聚合查询并缓存;Prometheus scrape 只读取 Micrometer Gauge, +不得触发数据库查询。查询仅处理 `RECEIVED`、`RETRY`、`PROCESSING`、`DEAD`,状态过滤以现有 +`idx_bridge_inbox_consume(status, next_retry_at, lease_until)` 为索引依据;本合同不新增或修改 SQL 结构。 + +## 开发阶段 SLI 与 SLO + +SLI 从事件首次得到 `result=RECEIVED` 开始,到下游首次成功确认结束;`DUPLICATE` 和 `CONFLICT` +不重复进入分母。滚动 30 天目标: + +- 99% 的新接收跨区事件在 60 秒内得到下游确认。 +- 99.9% 的新接收跨区事件在 15 分钟内得到下游确认。 + +代码暴露固定延迟桶,目标查询模板如下;按 `target_region` 汇总,必要时可保留有限 `event_type` 分组: + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="60.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.99 +``` + +```promql +sum(rate(baiyue_event_bridge_delivery_latency_seconds_bucket{le="900.0"}[30d])) +/ +sum(rate(baiyue_event_bridge_receive_total{result="RECEIVED"}[30d])) >= 0.999 +``` + +## 告警合同 + +规则由实际监控平台按 30 秒或更短评估周期配置;以下只是表达式合同: + +- 新增 DEAD:下一次评估立即告警,不设置持续等待。 + `sum(increase(baiyue_event_bridge_transport_total{result="DEAD"}[1m])) > 0` +- 最老待处理年龄预警:任一有限标签序列超过 120 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 120` +- 最老待处理年龄严重:任一有限标签序列超过 900 秒。 + `max(baiyue_event_bridge_inbox_oldest_age_seconds) > 900` +- 当前 DEAD 存量用于持续可见性:`sum(baiyue_event_bridge_inbox_dead) > 0`;它不替代“新增 DEAD”告警。 + +告警通知中可通过日志和业务查询再定位 event ID;禁止为了通知便利把高基数标识加入指标标签。 diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java index 568192832..287a5faa0 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/mapper/BridgeInboxMapper.java @@ -2,6 +2,7 @@ package com.baiyue.eventbridge.mapper; import com.baomidou.mybatisplus.core.mapper.BaseMapper; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.InboxMetricsSnapshot; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @@ -60,4 +61,21 @@ public interface BridgeInboxMapper extends BaseMapper { WHERE event_id=#{eventId} AND status='DEAD' """) int replayDead(@Param("eventId") String eventId, @Param("reason") String reason); + + /** + * Scheduled observability aggregation. The status predicate uses idx_bridge_inbox_consume; + * Prometheus collection never invokes this query directly. + */ + @Select(""" + SELECT target_region AS targetRegion, event_type AS eventType, + SUM(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN 1 ELSE 0 END) AS backlogCount, + COALESCE(TIMESTAMPDIFF(MICROSECOND, + MIN(CASE WHEN status IN ('RECEIVED','RETRY','PROCESSING') THEN received_at END), + CURRENT_TIMESTAMP(3)) / 1000000.0, 0) AS oldestPendingAgeSeconds, + SUM(CASE WHEN status='DEAD' THEN 1 ELSE 0 END) AS deadCount + FROM bridge_inbox + WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD') + GROUP BY target_region, event_type + """) + List observabilitySnapshots(); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java new file mode 100644 index 000000000..667aaadb2 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSampler.java @@ -0,0 +1,103 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.MultiGauge; +import io.micrometer.core.instrument.Tags; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +/** Periodically samples indexed Inbox aggregates; Prometheus scrapes only the cached gauges. */ +@Component +@Slf4j +public class EventBridgeInboxMetricsSampler { + public static final String BACKLOG_METRIC = "baiyue.event.bridge.inbox.backlog"; + public static final String OLDEST_AGE_METRIC = "baiyue.event.bridge.inbox.oldest.age.seconds"; + public static final String DEAD_METRIC = "baiyue.event.bridge.inbox.dead"; + + private final BridgeInboxMapper inboxMapper; + private final MultiGauge backlog; + private final MultiGauge oldestAge; + private final MultiGauge dead; + + public EventBridgeInboxMetricsSampler(BridgeInboxMapper inboxMapper, MeterRegistry registry) { + this.inboxMapper = inboxMapper; + this.backlog = MultiGauge.builder(BACKLOG_METRIC) + .description("Events waiting for downstream acknowledgement").register(registry); + this.oldestAge = MultiGauge.builder(OLDEST_AGE_METRIC) + .description("Age in seconds of the oldest pending Inbox event").register(registry); + this.dead = MultiGauge.builder(DEAD_METRIC) + .description("Current number of DEAD Inbox events").register(registry); + } + + @Scheduled(initialDelayString = "${baiyue.event-bridge.metrics.initial-delay:5000}", + fixedDelayString = "${baiyue.event-bridge.metrics.sample-delay:30000}") + public void sample() { + final List snapshots; + try { + snapshots = inboxMapper.observabilitySnapshots(); + } catch (RuntimeException exception) { + log.warn("Event Bridge Inbox 指标采样失败,保留上次成功快照", exception); + return; + } + Map aggregates = aggregate(snapshots); + backlog.register(rows(aggregates, MetricValue.BACKLOG), true); + oldestAge.register(rows(aggregates, MetricValue.OLDEST_AGE), true); + dead.register(rows(aggregates, MetricValue.DEAD), true); + } + + private Map aggregate(List snapshots) { + Map result = new LinkedHashMap<>(); + if (snapshots == null) { + return result; + } + for (InboxMetricsSnapshot snapshot : snapshots) { + MetricKey key = new MetricKey(EventBridgeMetrics.targetRegion(snapshot.getTargetRegion()), + EventBridgeMetrics.eventType(snapshot.getEventType())); + result.computeIfAbsent(key, ignored -> new Aggregate()).add(snapshot); + } + return result; + } + + private List> rows(Map aggregates, MetricValue value) { + List> rows = new ArrayList<>(); + aggregates.forEach((key, aggregate) -> rows.add(MultiGauge.Row.of( + Tags.of("target_region", key.targetRegion, "event_type", key.eventType), + value.value(aggregate)))); + return rows; + } + + private record MetricKey(String targetRegion, String eventType) { } + + private static final class Aggregate { + private long backlog; + private double oldestAge; + private long dead; + + void add(InboxMetricsSnapshot snapshot) { + backlog += snapshot.getBacklogCount(); + oldestAge = Math.max(oldestAge, snapshot.getOldestPendingAgeSeconds()); + dead += snapshot.getDeadCount(); + } + } + + private enum MetricValue { + BACKLOG { + @Override Number value(Aggregate aggregate) { return aggregate.backlog; } + }, + OLDEST_AGE { + @Override Number value(Aggregate aggregate) { return aggregate.oldestAge; } + }, + DEAD { + @Override Number value(Aggregate aggregate) { return aggregate.dead; } + }; + + abstract Number value(Aggregate aggregate); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java new file mode 100644 index 000000000..50a3bbe66 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/EventBridgeMetrics.java @@ -0,0 +1,121 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.MeterRegistry; +import io.micrometer.core.instrument.Timer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.time.Clock; +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +/** Low-cardinality transport metrics for the cross-region event bridge. */ +@Component +@Slf4j +public class EventBridgeMetrics { + public static final String RECEIVE_METRIC = "baiyue.event.bridge.receive"; + public static final String TRANSPORT_METRIC = "baiyue.event.bridge.transport"; + public static final String DELIVERY_LATENCY_METRIC = "baiyue.event.bridge.delivery.latency"; + + private static final Set KNOWN_EVENT_TYPES = Set.of( + EventTypes.ORDER_APPROVED, + EventTypes.ORDER_CANCELLED, + EventTypes.FIRST_LEG_SHIPMENT_DEPARTED, + EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED, + EventTypes.OVERSEAS_CONTAINER_UNLOADED, + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED, + EventTypes.LAST_MILE_HANDOFF_ACCEPTED, + EventTypes.LAST_MILE_HANDOFF_REJECTED, + EventTypes.LAST_MILE_MILESTONE_CHANGED, + EventTypes.LAST_MILE_COMPLETED, + EventTypes.TRACKING_MILESTONE_PROJECTED); + + private final MeterRegistry registry; + private final Clock clock; + + public EventBridgeMetrics(MeterRegistry registry) { + this(registry, Clock.systemDefaultZone()); + } + + EventBridgeMetrics(MeterRegistry registry, Clock clock) { + this.registry = registry; + this.clock = clock; + } + + public void recordReceive(EventEnvelope envelope, ReceiveResult result) { + try { + registry.counter(RECEIVE_METRIC, + "target_region", targetRegion(envelope.targetRegion().name()), + "event_type", eventType(envelope.eventType()), + "result", result.name()).increment(); + } catch (RuntimeException exception) { + log.warn("Event Bridge 接收指标记录失败,不影响事件事务", exception); + } + } + + public void recordTransport(BridgeInbox event, TransportResult result) { + try { + String targetRegion = targetRegion(event.getTargetRegion()); + String eventType = eventType(event.getEventType()); + registry.counter(TRANSPORT_METRIC, + "target_region", targetRegion, + "event_type", eventType, + "result", result.name()).increment(); + if (result.acknowledged() && event.getReceivedAt() != null) { + Duration latency = Duration.between(event.getReceivedAt(), LocalDateTime.now(clock)); + if (latency.isNegative()) { + latency = Duration.ZERO; + } + Timer.builder(DELIVERY_LATENCY_METRIC) + .description("Time from Event Bridge receipt to downstream acknowledgement") + .serviceLevelObjectives(Duration.ofSeconds(60), Duration.ofMinutes(15)) + .tags("target_region", targetRegion, "event_type", eventType) + .register(registry).record(latency); + } + } catch (RuntimeException exception) { + log.warn("Event Bridge 传输指标记录失败,不影响可靠性状态机", exception); + } + } + + public static String targetRegion(String value) { + String normalized = value == null ? "" : value.trim().toUpperCase(Locale.ROOT); + return "CN".equals(normalized) || "OVERSEAS".equals(normalized) ? normalized : "OTHER"; + } + + public static String eventType(String value) { + return value != null && KNOWN_EVENT_TYPES.contains(value) ? value : "OTHER"; + } + + public enum ReceiveResult { + RECEIVED, + DUPLICATE, + CONFLICT + } + + public enum TransportResult { + DELIVERED(true), + RETRY(false), + DEAD(false), + ACKNOWLEDGED_LEASE_LOST(true), + FAILED_LEASE_LOST(false), + MANUAL_REPLAY(false); + + private final boolean acknowledged; + + TransportResult(boolean acknowledged) { + this.acknowledged = acknowledged; + } + + boolean acknowledged() { + return acknowledged; + } + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java new file mode 100644 index 000000000..24e89b303 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/metrics/InboxMetricsSnapshot.java @@ -0,0 +1,50 @@ +package com.baiyue.eventbridge.metrics; + +/** Result row of the bounded, scheduled Inbox observability aggregation. */ +public class InboxMetricsSnapshot { + private String targetRegion; + private String eventType; + private long backlogCount; + private double oldestPendingAgeSeconds; + private long deadCount; + + public String getTargetRegion() { + return targetRegion; + } + + public void setTargetRegion(String targetRegion) { + this.targetRegion = targetRegion; + } + + public String getEventType() { + return eventType; + } + + public void setEventType(String eventType) { + this.eventType = eventType; + } + + public long getBacklogCount() { + return backlogCount; + } + + public void setBacklogCount(long backlogCount) { + this.backlogCount = backlogCount; + } + + public double getOldestPendingAgeSeconds() { + return oldestPendingAgeSeconds; + } + + public void setOldestPendingAgeSeconds(double oldestPendingAgeSeconds) { + this.oldestPendingAgeSeconds = oldestPendingAgeSeconds; + } + + public long getDeadCount() { + return deadCount; + } + + public void setDeadCount(long deadCount) { + this.deadCount = deadCount; + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java index 25b7fc7c3..f4adf8f3d 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventInboxService.java @@ -5,6 +5,7 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.dto.EventReceiveResult; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -23,6 +24,7 @@ import java.util.Objects; public class EventInboxService { @Resource private InboxStore inboxStore; @Resource private ObjectMapper objectMapper; + @Resource private EventBridgeMetrics metrics; @Value("${baiyue.region:CN}") private String localRegion; @@ -44,6 +46,7 @@ public class EventInboxService { BridgeInbox inbox = toInbox(envelope); try { inboxStore.insert(inbox); + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.RECEIVED); return new EventReceiveResult(envelope.eventId(), "已接收", inbox.getReceivedAt()); } catch (DuplicateKeyException exception) { BridgeInbox concurrent = inboxStore.findByEventId(envelope.eventId()); @@ -56,8 +59,10 @@ public class EventInboxService { private EventReceiveResult duplicateOrConflict(BridgeInbox existing, EventEnvelope envelope) { if (!sameEnvelope(existing, envelope)) { + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.CONFLICT); throw new IllegalArgumentException("相同 eventId 对应的事件信封或 payloadHash 不一致,拒绝覆盖既有事件"); } + metrics.recordReceive(envelope, EventBridgeMetrics.ReceiveResult.DUPLICATE); return new EventReceiveResult(envelope.eventId(), "重复", existing.getReceivedAt()); } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java index bc8b76dea..632672e95 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/EventReplayService.java @@ -1,13 +1,23 @@ package com.baiyue.eventbridge.service; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import jakarta.annotation.Resource; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; @Service public class EventReplayService { @Resource private BridgeInboxMapper inboxMapper; + @Resource private BridgeDeliveryAttemptMapper attemptMapper; + @Resource private EventBridgeMetrics metrics; + @Transactional public void replayDead(String eventId, String operatorId, String reason) { if (operatorId == null || operatorId.isBlank()) { throw new IllegalArgumentException("人工补投必须提供操作人"); @@ -19,6 +29,22 @@ public class EventReplayService { if (inboxMapper.replayDead(eventId, auditReason) != 1) { throw new IllegalStateException("事件不存在或未处于 DEAD 状态"); } + BridgeInbox event = inboxMapper.selectById(eventId); + if (event == null) { + throw new IllegalStateException("事件补投状态已更新但事件记录不存在"); + } + LocalDateTime now = LocalDateTime.now(); + BridgeDeliveryAttempt audit = new BridgeDeliveryAttempt(); + audit.setEventId(eventId); + audit.setSourceRegion(event.getSourceRegion()); + audit.setTargetRegion(event.getTargetRegion()); + audit.setAttemptNo(inboxMapper.nextAttemptNo(eventId)); + audit.setStatus("MANUAL_REPLAY"); + audit.setErrorMessage(auditReason); + audit.setStartedAt(now); + audit.setFinishedAt(now); + attemptMapper.insert(audit); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.MANUAL_REPLAY); } private static String abbreviate(String value, int maxLength) { diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java index 0aacdc3f3..fe26409f2 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java @@ -42,11 +42,31 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { .header("X-Internal-Service-Token", serviceToken) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); - JsonNode responseBody = objectMapper.readTree(response.body()); + JsonNode responseBody; + try { + responseBody = objectMapper.readTree(response.body()); + } catch (Exception ignored) { + responseBody = null; + } if (response.statusCode() / 100 != 2 || responseBody == null || !"200".equals(responseBody.path("code").asText())) { - throw new IllegalStateException("头程消费失败: HTTP " + response.statusCode()); + throw new IllegalStateException(failureMessage(response.statusCode(), responseBody)); } return String.valueOf(response.statusCode()); } + + static String failureMessage(int statusCode, JsonNode body) { + String code = body == null ? "missing" : body.path("code").asText("missing"); + String message = body == null ? "non-json response" : body.path("message").asText("missing"); + String requestId = body == null ? "missing" : body.path("requestId").asText("missing"); + String traceId = body == null ? "missing" : body.path("traceId").asText("missing"); + return "头程消费失败: HTTP " + statusCode + ", code=" + bounded(code) + + ", message=" + bounded(message) + ", requestId=" + bounded(requestId) + + ", traceId=" + bounded(traceId); + } + + private static String bounded(String value) { + String safe = value == null || value.isBlank() ? "missing" : value.replaceAll("[\\r\\n]+", " "); + return safe.length() <= 240 ? safe : safe.substring(0, 240); + } } diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java new file mode 100644 index 000000000..e18cf8714 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java @@ -0,0 +1,56 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.RegionCode; +import com.baiyue.eventbridge.config.EventDispatcherProperties; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; + +@Component +public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { + private final ObjectMapper objectMapper; + private final EventDispatcherProperties properties; + @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; + @Value("${baiyue.security.internal-service-token:}") private String serviceToken; + private HttpClient httpClient; + + public HttpSecurityProjectionEventClient(ObjectMapper objectMapper, EventDispatcherProperties properties) { + this.objectMapper = objectMapper; + this.properties = properties; + } + + @PostConstruct void initialize() { + httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout()).build(); + } + + @Override + public String deliver(BridgeInbox row) throws Exception { + EventEnvelope envelope = new EventEnvelope(row.getEventId(), row.getEventType(), row.getEventVersion(), + row.getOccurredAt(), RegionCode.valueOf(row.getSourceRegion()), RegionCode.valueOf(row.getTargetRegion()), + row.getCustomerId(), row.getAggregateType(), row.getAggregateId(), row.getAggregateVersion(), + row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), + objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); + HttpRequest request = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) + .header("Content-Type", "application/json") + .header("X-Internal-Service-Token", serviceToken) + .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); + HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + JsonNode body; + try { body = objectMapper.readTree(response.body()); } + catch (Exception exception) { body = null; } + if (response.statusCode() / 100 != 2 || body == null || !"200".equals(body.path("code").asText()) + || !row.getEventId().equals(body.path("data").path("eventId").asText())) { + throw new IllegalStateException("海外安全投影消费失败: HTTP " + response.statusCode()); + } + return String.valueOf(response.statusCode()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java index b795a7019..54cd77867 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java @@ -1,10 +1,12 @@ package com.baiyue.eventbridge.service; +import com.baiyue.common.event.EventTypes; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; @@ -12,6 +14,7 @@ import org.springframework.stereotype.Component; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; +import java.util.Set; import java.util.UUID; @Component @@ -21,7 +24,9 @@ public class OverseasEventDispatcher { @Resource private BridgeDeliveryAttemptMapper attemptMapper; @Resource private LastlegEventClient eventClient; @Resource private FirstlegEventClient firstlegEventClient; + @Resource private SecurityProjectionEventClient securityProjectionEventClient; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeMetrics metrics; private final String leaseOwner = ManagementFactory.getRuntimeMXBean().getName() + ":" + UUID.randomUUID(); @Scheduled(fixedDelayString = "${baiyue.event-dispatcher.fixed-delay:3000}") @@ -40,26 +45,52 @@ public class OverseasEventDispatcher { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); + String responseCode; try { - String responseCode = deliver(event); - if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { - throw new IllegalStateException("事件租约已失效,无法确认投递结果"); - } - finishAttempt(attempt, "DELIVERED", responseCode, null); + responseCode = deliver(event); } catch (Exception exception) { String error = abbreviate(exception.getMessage(), 1000); - inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), backoffSeconds(retryAttempt), error); - finishAttempt(attempt, retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY", null, error); + int marked = inboxMapper.markFailed(eventId, leaseOwner, properties.maxAttempts(), + backoffSeconds(retryAttempt), error); + String attemptStatus = marked == 1 + ? (retryAttempt >= properties.maxAttempts() ? "DEAD" : "RETRY") + : "FAILED_LEASE_LOST"; + finishAttempt(attempt, attemptStatus, null, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.valueOf(attemptStatus)); log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, reason={}", eventId, retryAttempt, deliveryAttemptNo, error); + return; } + + if (inboxMapper.markProcessed(eventId, leaseOwner) != 1) { + String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; + finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo); + return; + } + finishAttempt(attempt, "DELIVERED", responseCode, null); + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.DELIVERED); } private String deliver(BridgeInbox event) throws Exception { - return "CN".equals(event.getTargetRegion()) - ? firstlegEventClient.deliver(event) : eventClient.deliver(event); + String eventType = event.getEventType(); + if (eventType == null || eventType.isBlank()) { + throw new IllegalArgumentException("事件类型不能为空,无法选择下游消费者"); + } + return switch (event.getTargetRegion()) { + case "CN" -> firstlegEventClient.deliver(event); + case "OVERSEAS" -> SECURITY_EVENT_TYPES.contains(eventType) + ? securityProjectionEventClient.deliver(event) : eventClient.deliver(event); + default -> throw new IllegalArgumentException("不支持的事件目标区域: " + event.getTargetRegion()); + }; } + private static final Set SECURITY_EVENT_TYPES = Set.of( + EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, + EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private BridgeDeliveryAttempt startAttempt(BridgeInbox event, int attemptNo) { BridgeDeliveryAttempt attempt = new BridgeDeliveryAttempt(); attempt.setEventId(event.getEventId()); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java new file mode 100644 index 000000000..87fd9e3eb --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/SecurityProjectionEventClient.java @@ -0,0 +1,7 @@ +package com.baiyue.eventbridge.service; + +import com.baiyue.eventbridge.domain.BridgeInbox; + +public interface SecurityProjectionEventClient { + String deliver(BridgeInbox event) throws Exception; +} diff --git a/event-bridge-service/src/main/resources/application.yml b/event-bridge-service/src/main/resources/application.yml index e80450011..fc5524546 100644 --- a/event-bridge-service/src/main/resources/application.yml +++ b/event-bridge-service/src/main/resources/application.yml @@ -33,6 +33,7 @@ baiyue: event-dispatcher: consumer-url: ${EVENT_CONSUMER_URL:${LASTLEG_CONSUMER_URL:http://127.0.0.1:9303/internal/lastleg/events}} firstleg-consumer-url: ${FIRSTLEG_CONTAINER_EVENT_CONSUMER_URL:http://127.0.0.1:9202/internal/firstleg/v1/container-events} + security-consumer-url: ${OVERSEAS_SECURITY_EVENT_CONSUMER_URL:http://127.0.0.1:9307/internal/overseas-security/v1/events} fixed-delay: ${EVENT_DISPATCH_FIXED_DELAY:3000} batch-size: ${EVENT_DISPATCH_BATCH_SIZE:50} max-attempts: ${EVENT_DISPATCH_MAX_ATTEMPTS:10} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java new file mode 100644 index 000000000..a18746bd5 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeInboxMetricsSamplerTest.java @@ -0,0 +1,54 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.common.event.EventTypes; +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoMoreInteractions; +import static org.mockito.Mockito.when; + +class EventBridgeInboxMetricsSamplerTest { + + @Test + void samplesDatabaseOnceAndServesCachedLowCardinalityGauges() { + BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + InboxMetricsSnapshot known = snapshot("OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED, + 7, 121.5, 2); + InboxMetricsSnapshot unknown = snapshot("region-123", "dynamic-event-123", 3, 900.0, 1); + when(mapper.observabilitySnapshots()).thenReturn(List.of(known, unknown)); + EventBridgeInboxMetricsSampler sampler = new EventBridgeInboxMetricsSampler(mapper, registry); + + sampler.sample(); + + verify(mapper).observabilitySnapshots(); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.BACKLOG_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(7.0); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.OLDEST_AGE_METRIC, + "OVERSEAS", EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED)).isEqualTo(121.5); + assertThat(gauge(registry, EventBridgeInboxMetricsSampler.DEAD_METRIC, + "OTHER", "OTHER")).isEqualTo(1.0); + verifyNoMoreInteractions(mapper); + } + + private InboxMetricsSnapshot snapshot(String region, String eventType, long backlog, + double oldestAge, long dead) { + InboxMetricsSnapshot snapshot = new InboxMetricsSnapshot(); + snapshot.setTargetRegion(region); + snapshot.setEventType(eventType); + snapshot.setBacklogCount(backlog); + snapshot.setOldestPendingAgeSeconds(oldestAge); + snapshot.setDeadCount(dead); + return snapshot; + } + + private double gauge(SimpleMeterRegistry registry, String name, String region, String eventType) { + return registry.get(name).tags("target_region", region, "event_type", eventType).gauge().value(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java new file mode 100644 index 000000000..39a229241 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeMetricsTest.java @@ -0,0 +1,58 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.domain.BridgeInbox; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; +import io.micrometer.core.instrument.MeterRegistry; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Modifier; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.mock; + +class EventBridgeMetricsTest { + + @Test + void collapsesUnboundedRegionAndEventTypeLabelsToOther() { + SimpleMeterRegistry registry = new SimpleMeterRegistry(); + EventBridgeMetrics metrics = new EventBridgeMetrics(registry); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("customer-controlled-region"); + event.setEventType("dynamic-event-123456"); + + metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY); + + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OTHER", "event_type", "OTHER", "result", "RETRY") + .counter().count()).isEqualTo(1.0); + assertThat(registry.getMeters()).allSatisfy(meter -> assertThat(meter.getId().getTags()) + .noneMatch(tag -> tag.getValue().contains("dynamic-event"))); + assertThat(EventBridgeMetrics.eventType(null)).isEqualTo("OTHER"); + } + + @Test + void everyFrozenEventTypeHasABoundedTagValue() { + assertThat(Arrays.stream(com.baiyue.common.event.EventTypes.class.getFields()) + .filter(field -> Modifier.isStatic(field.getModifiers()) && field.getType() == String.class) + .map(field -> { + try { + return (String) field.get(null); + } catch (IllegalAccessException exception) { + throw new AssertionError(exception); + } + })).allMatch(value -> EventBridgeMetrics.eventType(value).equals(value)); + } + + @Test + void metricsFailureDoesNotChangeTransportControlFlow() { + EventBridgeMetrics metrics = new EventBridgeMetrics(mock(MeterRegistry.class)); + BridgeInbox event = new BridgeInbox(); + event.setTargetRegion("CN"); + event.setEventType(com.baiyue.common.event.EventTypes.OVERSEAS_CONTAINER_UNLOADED); + + assertThatCode(() -> metrics.recordTransport(event, EventBridgeMetrics.TransportResult.RETRY)) + .doesNotThrowAnyException(); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java new file mode 100644 index 000000000..5c106df15 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/metrics/EventBridgeObservabilityContractTest.java @@ -0,0 +1,41 @@ +package com.baiyue.eventbridge.metrics; + +import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import org.apache.ibatis.annotations.Select; +import org.junit.jupiter.api.Test; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeObservabilityContractTest { + + @Test + void aggregationIsScheduledCacheInputAndUsesExistingStatusIndexBasis() throws Exception { + Select annotation = BridgeInboxMapper.class.getMethod("observabilitySnapshots") + .getAnnotation(Select.class); + String query = String.join(" ", annotation.value()).replaceAll("\\s+", " "); + String schema = Files.readString(Path.of("src/main/resources/db/event-bridge.sql")); + + assertThat(query) + .contains("WHERE status IN ('RECEIVED','RETRY','PROCESSING','DEAD')") + .contains("GROUP BY target_region, event_type") + .doesNotContain("event_id AS", "customer_id AS", "last_error AS"); + assertThat(schema).contains( + "KEY idx_bridge_inbox_consume (status, next_retry_at, lease_until)"); + } + + @Test + void developmentSloAndAlertThresholdsAreFrozenWithoutDeploymentClaim() throws Exception { + String contract = Files.readString(Path.of("docs/development-observability-slo.md")); + + assertThat(contract) + .contains("99% 的新接收跨区事件在 60 秒内") + .contains("99.9% 的新接收跨区事件在 15 分钟内") + .contains("result=\"DEAD\"", "> 120", "> 900") + .contains("不表示任何环境已经部署") + .contains("idx_bridge_inbox_consume(status, next_retry_at, lease_until)") + .doesNotContain("customer_id\"", "event_id\""); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java index 9ae12a6dc..c8ee93232 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventInboxServiceTest.java @@ -5,7 +5,9 @@ import com.baiyue.common.event.EventPayloadCanonicalizer; import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.RegionCode; import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; import com.fasterxml.jackson.databind.ObjectMapper; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.springframework.dao.DuplicateKeyException; import org.springframework.test.util.ReflectionTestUtils; @@ -24,6 +26,8 @@ import static org.mockito.Mockito.when; class EventInboxServiceTest { private final ObjectMapper objectMapper = new ObjectMapper(); private final InboxStore store = mock(InboxStore.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); private final EventInboxService service = service("OVERSEAS"); @Test @@ -33,6 +37,7 @@ class EventInboxServiceTest { assertThat(service.receive(envelope).result()).isEqualTo("已接收"); verify(store).insert(any(BridgeInbox.class)); + assertThat(receiveCount("RECEIVED")).isEqualTo(1.0); } @Test @@ -57,6 +62,7 @@ class EventInboxServiceTest { when(store.findByEventId(envelope.eventId())).thenReturn(existing); assertThat(service.receive(envelope).result()).isEqualTo("重复"); + assertThat(receiveCount("DUPLICATE")).isEqualTo(1.0); } @Test @@ -67,6 +73,7 @@ class EventInboxServiceTest { assertThatThrownBy(() -> service.receive(envelope)) .isInstanceOf(IllegalArgumentException.class) .hasMessageContaining("拒绝覆盖"); + assertThat(receiveCount("CONFLICT")).isEqualTo(1.0); } @Test @@ -113,10 +120,17 @@ class EventInboxServiceTest { EventInboxService service = new EventInboxService(); ReflectionTestUtils.setField(service, "inboxStore", store); ReflectionTestUtils.setField(service, "objectMapper", objectMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); ReflectionTestUtils.setField(service, "localRegion", region); return service; } + private double receiveCount(String result) { + return registry.get(EventBridgeMetrics.RECEIVE_METRIC) + .tags("target_region", "OVERSEAS", "event_type", "OrderApproved", "result", result) + .counter().count(); + } + private EventEnvelope withHash(EventEnvelope source) { String hash = EventPayloadHash.sha256(EventPayloadCanonicalizer.canonicalize(source.payload())); return new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), source.occurredAt(), diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java index 9bd52210b..49c00c1c8 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/EventReplayServiceTest.java @@ -1,6 +1,11 @@ package com.baiyue.eventbridge.service; +import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; +import com.baiyue.eventbridge.domain.BridgeInbox; +import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.Test; import org.springframework.test.util.ReflectionTestUtils; import org.apache.ibatis.annotations.Update; @@ -11,15 +16,31 @@ import static org.mockito.Mockito.*; class EventReplayServiceTest { private final BridgeInboxMapper mapper = mock(BridgeInboxMapper.class); + private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); + private final SimpleMeterRegistry registry = new SimpleMeterRegistry(); + private final EventBridgeMetrics metrics = new EventBridgeMetrics(registry); private final EventReplayService service = service(); @Test void requeuesDeadEventWithAuditReason() { when(mapper.replayDead(eq("event-1"), contains("incident-42"))).thenReturn(1); + BridgeInbox event = new BridgeInbox(); + event.setEventId("event-1"); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); + event.setEventType(com.baiyue.common.event.EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); + when(mapper.selectById("event-1")).thenReturn(event); + when(mapper.nextAttemptNo("event-1")).thenReturn(4); service.replayDead("event-1", "operator-7", "incident-42 approved"); verify(mapper).replayDead(eq("event-1"), contains("operator-7")); + var captor = org.mockito.ArgumentCaptor.forClass(BridgeDeliveryAttempt.class); + verify(attemptMapper).insert(captor.capture()); + assertThat(captor.getValue().getAttemptNo()).isEqualTo(4); + assertThat(captor.getValue().getStatus()).isEqualTo("MANUAL_REPLAY"); + assertThat(captor.getValue().getErrorMessage()).contains("operator-7", "incident-42"); + assertThat(registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", "OVERSEAS", "event_type", event.getEventType(), + "result", "MANUAL_REPLAY").counter().count()).isEqualTo(1.0); } @Test @@ -43,6 +64,8 @@ class EventReplayServiceTest { private EventReplayService service() { EventReplayService service = new EventReplayService(); ReflectionTestUtils.setField(service, "inboxMapper", mapper); + ReflectionTestUtils.setField(service, "attemptMapper", attemptMapper); + ReflectionTestUtils.setField(service, "metrics", metrics); return service; } } diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java new file mode 100644 index 000000000..69ed8424a --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/HttpFirstlegEventClientTest.java @@ -0,0 +1,26 @@ +package com.baiyue.eventbridge.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class HttpFirstlegEventClientTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void businessFailureIsDiagnosableAndMustNotBeTreatedAsAcknowledged() throws Exception { + var body = objectMapper.readTree(""" + {"code":"A0409","message":"事件摘要冲突","requestId":"request-cn-1","traceId":"trace-cn-1"} + """); + + assertThat(HttpFirstlegEventClient.failureMessage(200, body)) + .isEqualTo("头程消费失败: HTTP 200, code=A0409, message=事件摘要冲突, requestId=request-cn-1, traceId=trace-cn-1"); + } + + @Test + void nonJsonFailureIsStillDiagnosableWithoutRawBody() { + assertThat(HttpFirstlegEventClient.failureMessage(503, null)) + .isEqualTo("头程消费失败: HTTP 503, code=missing, message=non-json response, requestId=missing, traceId=missing"); + } +} diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java index 01468d95a..aa9f11d14 100644 --- a/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/service/OverseasEventDispatcherTest.java @@ -1,10 +1,13 @@ package com.baiyue.eventbridge.service; +import com.baiyue.common.event.EventTypes; import com.baiyue.eventbridge.config.EventDispatcherProperties; import com.baiyue.eventbridge.domain.BridgeDeliveryAttempt; import com.baiyue.eventbridge.domain.BridgeInbox; import com.baiyue.eventbridge.mapper.BridgeDeliveryAttemptMapper; import com.baiyue.eventbridge.mapper.BridgeInboxMapper; +import com.baiyue.eventbridge.metrics.EventBridgeMetrics; +import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; @@ -12,6 +15,7 @@ import org.springframework.test.util.ReflectionTestUtils; import java.net.URI; import java.time.Duration; +import java.time.LocalDateTime; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; @@ -21,10 +25,14 @@ import static org.mockito.Mockito.*; class OverseasEventDispatcherTest { private final BridgeInboxMapper inboxMapper = mock(BridgeInboxMapper.class); private final BridgeDeliveryAttemptMapper attemptMapper = mock(BridgeDeliveryAttemptMapper.class); - private final LastlegEventClient eventClient = mock(LastlegEventClient.class); - private final FirstlegEventClient firstlegEventClient = mock(FirstlegEventClient.class); + private final com.baiyue.eventbridge.service.LastlegEventClient eventClient = + mock(com.baiyue.eventbridge.service.LastlegEventClient.class); + private final com.baiyue.eventbridge.service.FirstlegEventClient firstlegEventClient = + mock(com.baiyue.eventbridge.service.FirstlegEventClient.class); + private final SecurityProjectionEventClient securityProjectionEventClient = mock(SecurityProjectionEventClient.class); private OverseasEventDispatcher dispatcher; private BridgeInbox event; + private SimpleMeterRegistry registry; @BeforeEach void setUp() { @@ -33,16 +41,21 @@ class OverseasEventDispatcherTest { Duration.ofSeconds(30), Duration.ofSeconds(5), Duration.ofMinutes(1), Duration.ofSeconds(3), Duration.ofSeconds(10)); dispatcher = new OverseasEventDispatcher(); + registry = new SimpleMeterRegistry(); ReflectionTestUtils.setField(dispatcher, "inboxMapper", inboxMapper); ReflectionTestUtils.setField(dispatcher, "attemptMapper", attemptMapper); ReflectionTestUtils.setField(dispatcher, "eventClient", eventClient); ReflectionTestUtils.setField(dispatcher, "firstlegEventClient", firstlegEventClient); + ReflectionTestUtils.setField(dispatcher, "securityProjectionEventClient", securityProjectionEventClient); ReflectionTestUtils.setField(dispatcher, "properties", properties); + ReflectionTestUtils.setField(dispatcher, "metrics", new EventBridgeMetrics(registry)); event = new BridgeInbox(); event.setEventId("event-1"); + event.setEventType(EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED); event.setSourceRegion("CN"); event.setTargetRegion("OVERSEAS"); event.setRetryCount(0); + event.setReceivedAt(LocalDateTime.now().minusSeconds(5)); when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); when(inboxMapper.selectById("event-1")).thenReturn(event); when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); @@ -56,27 +69,50 @@ class OverseasEventDispatcherTest { dispatcher.dispatchOne("event-1"); verify(inboxMapper).markProcessed(eq("event-1"), anyString()); + verify(eventClient).deliver(event); + verifyNoInteractions(securityProjectionEventClient); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); + assertThat(transportCount("DELIVERED")).isEqualTo(1.0); + assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); } @Test void failedDeliveryIsScheduledForRetry() throws Exception { when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(1); dispatcher.dispatchOne("event-1"); - verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down")); + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(5L), eq("network down")); assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + assertThat(transportCount("RETRY")).isEqualTo(1.0); } @Test void finalFailureIsMovedToDeadState() throws Exception { event.setRetryCount(2); when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); dispatcher.dispatchOne("event-1"); + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(20L), eq("still down")); assertThat(updatedAttempt().getStatus()).isEqualTo("DEAD"); + assertThat(transportCount("DEAD")).isEqualTo(1.0); + } + + @Test + void exponentialBackoffIsCappedAtConfiguredMaximum() throws Exception { + event.setRetryCount(4); + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("still down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("still down"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper).markFailed(eq("event-1"), anyString(), eq(3), eq(60L), eq("still down")); } @Test @@ -92,6 +128,7 @@ class OverseasEventDispatcherTest { @Test void cnTargetIsDeliveredToFirstleg() throws Exception { + event.setEventType(EventTypes.OVERSEAS_CONTAINER_UNLOADED); event.setSourceRegion("OVERSEAS"); event.setTargetRegion("CN"); when(firstlegEventClient.deliver(event)).thenReturn("200"); @@ -101,9 +138,87 @@ class OverseasEventDispatcherTest { verify(firstlegEventClient).deliver(event); verifyNoInteractions(eventClient); + verifyNoInteractions(securityProjectionEventClient); assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); } + @Test + void iamSecurityEventsAreDeliveredToSecurityProjectionAndNeverLastleg() throws Exception { + for (String eventType : List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED)) { + reset(inboxMapper, attemptMapper, eventClient, firstlegEventClient, securityProjectionEventClient); + event.setEventType(eventType); + when(inboxMapper.claim(eq("event-1"), anyString(), anyLong())).thenReturn(1); + when(inboxMapper.selectById("event-1")).thenReturn(event); + when(inboxMapper.nextAttemptNo("event-1")).thenReturn(1); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); + when(securityProjectionEventClient.deliver(event)).thenReturn("200"); + + dispatcher.dispatchOne("event-1"); + + verify(securityProjectionEventClient).deliver(event); + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + } + } + + @Test + void unsupportedTargetRegionIsNeverMisroutedToLastleg() { + event.setTargetRegion("UNKNOWN"); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), contains("不支持"))) + .thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + assertThat(updatedAttempt().getStatus()).isEqualTo("RETRY"); + } + + @Test + void missingEventTypeFailsWithExplicitDiagnosticAndIsNeverDelivered() { + event.setEventType(null); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), + eq("事件类型不能为空,无法选择下游消费者"))).thenReturn(1); + + dispatcher.dispatchOne("event-1"); + + verifyNoInteractions(eventClient); + verifyNoInteractions(firstlegEventClient); + verifyNoInteractions(securityProjectionEventClient); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("RETRY"); + assertThat(attempt.getErrorMessage()).isEqualTo("事件类型不能为空,无法选择下游消费者"); + } + + @Test + void acknowledgedDeliveryWithLostLeaseIsNotRecordedAsRetryOrDead() throws Exception { + when(eventClient.deliver(event)).thenReturn("200"); + when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + verify(inboxMapper, never()).markFailed(anyString(), anyString(), anyInt(), anyLong(), anyString()); + BridgeDeliveryAttempt attempt = updatedAttempt(); + assertThat(attempt.getStatus()).isEqualTo("ACKNOWLEDGED_LEASE_LOST"); + assertThat(attempt.getResponseCode()).isEqualTo("200"); + assertThat(attempt.getErrorMessage()).contains("租约已失效"); + assertThat(transportCount("ACKNOWLEDGED_LEASE_LOST")).isEqualTo(1.0); + } + + @Test + void failedDeliveryAfterLeaseTakeoverDoesNotOverwriteNewOwnerState() throws Exception { + when(eventClient.deliver(event)).thenThrow(new IllegalStateException("network down")); + when(inboxMapper.markFailed(eq("event-1"), anyString(), eq(3), anyLong(), eq("network down"))) + .thenReturn(0); + + dispatcher.dispatchOne("event-1"); + + assertThat(updatedAttempt().getStatus()).isEqualTo("FAILED_LEASE_LOST"); + assertThat(transportCount("FAILED_LEASE_LOST")).isEqualTo(1.0); + } + @Test void downstream503RetriesThenDeliversWithDiagnosticAttemptsInBothDirections() throws Exception { for (String target : List.of("OVERSEAS", "CN")) { @@ -162,4 +277,11 @@ class OverseasEventDispatcherTest { verify(attemptMapper).updateById(captor.capture()); return captor.getValue(); } + + private double transportCount(String result) { + return registry.get(EventBridgeMetrics.TRANSPORT_METRIC) + .tags("target_region", event.getTargetRegion(), + "event_type", event.getEventType(), "result", result) + .counter().count(); + } } diff --git a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java index 08ba4d734..a39cba50e 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/mapper/LoadingPickItemMapper.java @@ -72,11 +72,25 @@ public interface LoadingPickItemMapper extends BaseMapper @Param("operatorName") String operatorName); @Select(""" - SELECT COALESCE(SUM(plan_item.package_count),0) expectedCount, + SELECT COALESCE(SUM(CASE + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count + THEN pick_item.picked_package_count + ELSE plan_item.package_count + END),0) expectedCount, COALESCE(SUM(pick_item.picked_package_count),0) pickedCount, COUNT(DISTINCT pick_task.id) taskCount, COUNT(DISTINCT CASE WHEN pick_task.task_status='COMPLETED' THEN pick_task.id END) completedTaskCount, - SUM(CASE WHEN pick_item.id IS NULL OR pick_item.picked_package_count<>plan_item.package_count THEN 1 ELSE 0 END) unresolvedCount + SUM(CASE + WHEN pick_item.id IS NULL THEN 1 + WHEN pick_item.item_status='PICKED' + AND pick_item.picked_package_count=plan_item.package_count THEN 0 + WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0 + AND pick_item.picked_package_count<=plan_item.package_count THEN 0 + WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0 + ELSE 1 + END) unresolvedCount FROM fl_loading_plan_item plan_item JOIN fl_inbound_order inbound_order ON inbound_order.id=plan_item.inbound_id AND inbound_order.tenant_id=#{tenantId} AND inbound_order.deleted=0 diff --git a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java index 89d6f427f..403f5a5c1 100644 --- a/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java +++ b/firstleg/src/main/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepository.java @@ -34,6 +34,8 @@ public class LoadingCompletedOutboxRepository { .eq(InboundOutboxEntity::getId, id) .in(InboundOutboxEntity::getEventType, CROSS_REGION_EVENT_TYPES) .and(status -> status.eq(InboundOutboxEntity::getPublishStatus, "PENDING") + .and(retry -> retry.isNull(InboundOutboxEntity::getNextRetryAt) + .or().le(InboundOutboxEntity::getNextRetryAt, now)) .or(stale -> stale.eq(InboundOutboxEntity::getPublishStatus, "PROCESSING") .le(InboundOutboxEntity::getLockedAt, expiredLeaseBefore))) .set(InboundOutboxEntity::getPublishStatus, "PROCESSING").set(InboundOutboxEntity::getLockedBy, worker) diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..4ee884bc3 --- /dev/null +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.firstleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.firstleg.entity.InboundOutboxEntity; +import com.baiyue.firstleg.mapper.InboundOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class LoadingCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + InboundOutboxEntity.class); + InboundOutboxMapper mapper = mock(InboundOutboxMapper.class); + LoadingCompletedOutboxRepository repository = new LoadingCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(InboundOutboxEntity.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "publish_status", "locked_at"); + } +} diff --git a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java index 0810397ec..6a91c556b 100644 --- a/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java +++ b/firstleg/src/test/java/com/baiyue/firstleg/repository/LoadingPickItemMapperContractTest.java @@ -29,6 +29,31 @@ class LoadingPickItemMapperContractTest { assertThat(progress.getUnresolvedCount()).isEqualTo(1); } + @Test + void inboundProgressUsesSettledTerminalQuantitiesAndRejectsNonTerminalItems() throws Exception { + Method method = LoadingPickItemMapper.class.getDeclaredMethod( + "selectInboundProgress", long.class, long.class); + String sql = method.getAnnotation(Select.class).value()[0]; + + assertThat(sql) + .contains("WHEN pick_item.item_status='SKIPPED' AND pick_item.picked_package_count=0 THEN 0") + .contains("WHEN pick_item.item_status='PARTIAL' AND pick_item.picked_package_count>0") + .contains("AND pick_item.picked_package_count<=plan_item.package_count") + .contains("THEN pick_item.picked_package_count") + .contains("ELSE plan_item.package_count") + .contains("WHEN pick_item.id IS NULL THEN 1") + .contains("WHEN pick_item.item_status='PICKED'") + .contains("AND pick_item.picked_package_count=plan_item.package_count THEN 0") + .contains("ELSE 1") + .doesNotContain("pick_item.picked_package_count<>plan_item.package_count"); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='PARTIAL'")).isEqualTo(2); + assertThat(org.springframework.util.StringUtils.countOccurrencesOf(sql, + "pick_item.item_status='SKIPPED'")).isEqualTo(2); + assertThat(sql).doesNotContain("pick_item.item_status='PENDING' THEN 0") + .doesNotContain("pick_item.item_status='PICKING' THEN 0"); + } + @Test void completionCheckCoversAllActivePlanItems() throws Exception { Method method = LoadingPickItemMapper.class.getDeclaredMethod("unfinishedCount", long.class); diff --git a/gateway-overseas/AGENTS.md b/gateway-overseas/AGENTS.md index 72487d3aa..ddd4f2c28 100644 --- a/gateway-overseas/AGENTS.md +++ b/gateway-overseas/AGENTS.md @@ -14,6 +14,7 @@ | Audience 校验 | `config/AudienceJwtValidator.java` | | 必要 Claims 校验 | `config/RequiredClaimsJwtValidator.java` | | PDA 换仓即时失效 | `filter/PdaSessionValidationFilter.java`、`HttpPdaSessionValidationClient.java`、`config/PdaSessionValidationProperties.java`;仅 `/api/pda/**` 本地验签后同步核对 Auth,任何异常 fail-closed | +| 海外安全事件即时失效 | `filter/OverseasSecurityProjectionFilter.java`、`HttpOverseasSecurityValidationClient.java`;全部 `/api/**` 本地验签后核对海外安全投影,用户/客户停用与权限版本落后立即拒绝 | | 安全配置项 | `config/GatewaySecurityProperties.java` | | 可信身份头传递 | `filter/TrustedIdentityHeaderFilter.java` | | 环境配置 | `src/main/resources/application.yml`、`../config/nacos/` | diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index f9191717c..a23d519e5 100644 --- a/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -24,7 +24,8 @@ import java.util.Map; * 海外网关响应式安全配置,使用国内认证中心公开的 JWKS 验证 JWT。 */ @Configuration -@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class}) +@EnableConfigurationProperties({GatewaySecurityProperties.class, PdaSessionValidationProperties.class, + OverseasSecurityProjectionProperties.class}) public class GatewaySecurityConfig { @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java new file mode 100644 index 000000000..9edcfad81 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/config/OverseasSecurityProjectionProperties.java @@ -0,0 +1,17 @@ +package com.baiyue.gateway.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.time.Duration; + +@ConfigurationProperties(prefix = "baiyue.security.overseas-projection") +public record OverseasSecurityProjectionProperties(String serviceUrl, String internalServiceToken, Duration timeout) { + public OverseasSecurityProjectionProperties { + if (serviceUrl == null || serviceUrl.isBlank()) throw new IllegalArgumentException("必须配置 OVERSEAS_SECURITY_SERVICE_URL"); + if (internalServiceToken == null || internalServiceToken.getBytes(java.nio.charset.StandardCharsets.UTF_8).length < 32) { + throw new IllegalArgumentException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + timeout = timeout == null || timeout.isNegative() || timeout.isZero() ? Duration.ofSeconds(2) : timeout; + serviceUrl = serviceUrl.replaceAll("/+$", ""); + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java new file mode 100644 index 000000000..6d75282df --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java @@ -0,0 +1,37 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.OverseasSecurityProjectionProperties; +import com.fasterxml.jackson.databind.JsonNode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Component +public class HttpOverseasSecurityValidationClient implements OverseasSecurityValidationClient { + private static final String PATH = "/internal/overseas-security/v1/validate"; + private final WebClient client; + private final OverseasSecurityProjectionProperties properties; + + public HttpOverseasSecurityValidationClient(WebClient.Builder builder, OverseasSecurityProjectionProperties properties) { + this.client = builder.build(); + this.properties = properties; + } + + @Override + public Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion) { + return client.post().uri(properties.serviceUrl() + PATH).contentType(MediaType.APPLICATION_JSON) + .header("X-Internal-Service-Token", properties.internalServiceToken()) + .bodyValue(new Request(userId, tenantId, tokenVersion, permissionVersion)) + .retrieve().bodyToMono(JsonNode.class).timeout(properties.timeout()).flatMap(body -> { + JsonNode data = body == null ? null : body.path("data"); + if (data == null || data.isMissingNode() || !data.has("valid")) { + return Mono.error(new IllegalStateException("海外安全投影响应无效")); + } + return Mono.just(new Result(data.path("valid").asBoolean(false), + data.path("failureReason").isNull() ? null : data.path("failureReason").asText(null))); + }); + } + + private record Request(long userId, long tenantId, long tokenVersion, long permissionVersion) { } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java new file mode 100644 index 000000000..cdbb4e727 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilter.java @@ -0,0 +1,74 @@ +package com.baiyue.gateway.filter; + +import com.baiyue.gateway.config.GatewaySecurityConfig; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +@Component +public class OverseasSecurityProjectionFilter implements GlobalFilter, Ordered { + private final OverseasSecurityValidationClient client; + private final ObjectMapper objectMapper; + + public OverseasSecurityProjectionFilter(OverseasSecurityValidationClient client, ObjectMapper objectMapper) { + this.client = client; + this.objectMapper = objectMapper; + } + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + if (!exchange.getRequest().getURI().getPath().startsWith("/api/")) return chain.filter(exchange); + return exchange.getPrincipal().cast(Authentication.class).filter(Authentication::isAuthenticated) + .ofType(JwtAuthenticationToken.class) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "未登录或令牌已失效"))) + .flatMap(authentication -> validate(exchange, chain, authentication)) + .onErrorResume(SecurityProjectionFailure.class, + failure -> write(exchange, failure.code, failure.getMessage())) + .onErrorResume(failure -> write(exchange, "A0401", "海外安全状态校验不可用")); + } + + private Mono validate(ServerWebExchange exchange, GatewayFilterChain chain, + JwtAuthenticationToken authentication) { + Long userId = number(authentication.getToken().getSubject()); + Long tenantId = number(authentication.getToken().getClaim("tenant_id")); + Long tokenVersion = number(authentication.getToken().getClaim("token_version")); + Long permissionVersion = number(authentication.getToken().getClaim("permission_version")); + if (userId == null || tenantId == null || tokenVersion == null || permissionVersion == null) { + return Mono.error(new SecurityProjectionFailure("A0401", "海外令牌安全版本声明缺失")); + } + return client.validate(userId, tenantId, tokenVersion, permissionVersion) + .switchIfEmpty(Mono.error(new SecurityProjectionFailure("A0401", "海外安全状态校验不可用"))) + .flatMap(result -> { + if (result.valid()) return chain.filter(exchange); + if ("USER_DISABLED".equals(result.failureReason())) { + return Mono.error(new SecurityProjectionFailure("A0401", "账号已停用")); + } + return Mono.error(new SecurityProjectionFailure("A0403", "客户或权限状态已变化")); + }); + } + + private Mono write(ServerWebExchange exchange, String code, String message) { + return GatewaySecurityConfig.writeError(exchange, objectMapper, + "A0403".equals(code) ? HttpStatus.FORBIDDEN : HttpStatus.UNAUTHORIZED, code, message); + } + + private static Long number(Object value) { + if (value instanceof Number number) return number.longValue(); + try { return value == null ? null : Long.valueOf(value.toString()); } + catch (NumberFormatException exception) { return null; } + } + + @Override public int getOrder() { return -95; } + + private static final class SecurityProjectionFailure extends RuntimeException { + private final String code; + private SecurityProjectionFailure(String code, String message) { super(message); this.code = code; } + } +} diff --git a/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java new file mode 100644 index 000000000..2ce22a244 --- /dev/null +++ b/gateway-overseas/src/main/java/com/baiyue/gateway/filter/OverseasSecurityValidationClient.java @@ -0,0 +1,8 @@ +package com.baiyue.gateway.filter; + +import reactor.core.publisher.Mono; + +public interface OverseasSecurityValidationClient { + Mono validate(long userId, long tenantId, long tokenVersion, long permissionVersion); + record Result(boolean valid, String failureReason) { } +} diff --git a/gateway-overseas/src/main/resources/application.yml b/gateway-overseas/src/main/resources/application.yml index 200280692..3e7ce51f2 100644 --- a/gateway-overseas/src/main/resources/application.yml +++ b/gateway-overseas/src/main/resources/application.yml @@ -46,6 +46,10 @@ baiyue: auth-url: ${AUTH_SESSION_VALIDATION_URL} internal-service-token: ${INTERNAL_SERVICE_TOKEN} timeout: ${PDA_SESSION_VALIDATION_TIMEOUT:2s} + overseas-projection: + service-url: ${OVERSEAS_SECURITY_SERVICE_URL} + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + timeout: ${OVERSEAS_SECURITY_VALIDATION_TIMEOUT:2s} permit-paths: - /actuator/health - /swagger-ui/** diff --git a/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java new file mode 100644 index 000000000..ff9f935f0 --- /dev/null +++ b/gateway-overseas/src/test/java/com/baiyue/gateway/filter/OverseasSecurityProjectionFilterTest.java @@ -0,0 +1,83 @@ +package com.baiyue.gateway.filter; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class OverseasSecurityProjectionFilterTest { + private final OverseasSecurityValidationClient client = mock(OverseasSecurityValidationClient.class); + private final OverseasSecurityProjectionFilter filter = new OverseasSecurityProjectionFilter(client, new ObjectMapper()); + + @Test void disabledUserFailsWithA0401() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, "USER_DISABLED"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + assertThat(body(exchange)).contains("A0401"); + } + + @Test void disabledTenantAndStalePermissionFailWithA0403() { + for (String reason : new String[]{"TENANT_DISABLED", "PERMISSION_VERSION_STALE"}) { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(false, reason))); + var exchange = exchange("/api/merchant/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.FORBIDDEN); + assertThat(body(exchange)).contains("A0403"); + } + } + + @Test void projectionUnavailableFailsClosed() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.error(new IllegalStateException("down"))); + var exchange = exchange("/api/platform/v1/last-leg/orders"); + StepVerifier.create(filter.filter(exchange, ignored -> Mono.empty())).verifyComplete(); + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED); + } + + @Test void acceptedSecurityStateContinuesAndNonApiPathsDoNotCallProjection() { + when(client.validate(42, 0, 3, 8)).thenReturn(Mono.just( + new OverseasSecurityValidationClient.Result(true, null))); + AtomicBoolean routed = new AtomicBoolean(); + StepVerifier.create(filter.filter(exchange("/api/platform/v1/last-leg/orders"), ignored -> { + routed.set(true); return Mono.empty(); + })).verifyComplete(); + assertThat(routed).isTrue(); + + var isolatedClient = mock(OverseasSecurityValidationClient.class); + var isolatedFilter = new OverseasSecurityProjectionFilter(isolatedClient, new ObjectMapper()); + StepVerifier.create(isolatedFilter.filter(exchange("/actuator/health"), ignored -> Mono.empty())).verifyComplete(); + verifyNoInteractions(isolatedClient); + } + + private static ServerWebExchange exchange(String path) { + Jwt jwt = Jwt.withTokenValue("verified").header("alg", "RS256").subject("42") + .issuedAt(Instant.now()).expiresAt(Instant.now().plusSeconds(300)) + .claim("tenant_id", 0L).claim("token_version", 3L).claim("permission_version", 8L).build(); + MockServerWebExchange exchange = MockServerWebExchange.from(MockServerHttpRequest.get(path).build()); + return exchange.mutate().principal(Mono.just(new JwtAuthenticationToken(jwt, + List.of(new SimpleGrantedAuthority("ROLE_PLATFORM_USER"))))).build(); + } + + private static String body(ServerWebExchange exchange) { + return ((org.springframework.mock.http.server.reactive.MockServerHttpResponse) exchange.getResponse()) + .getBodyAsString().block(); + } +} diff --git a/lastleg-service/docs/lastleg-internal-modularization-baseline.md b/lastleg-service/docs/lastleg-internal-modularization-baseline.md new file mode 100644 index 000000000..0acced4c7 --- /dev/null +++ b/lastleg-service/docs/lastleg-internal-modularization-baseline.md @@ -0,0 +1,199 @@ +# Lastleg 内部模块化依赖基线与首切片方案 + +## 1. 范围与生成方式 + +本基线只分析 `lastleg-service` 同一部署单元,不拆微服务,不修改业务代码、POM、数据库、OpenAPI、权限、接口或跨服务契约。统计时工作区同时存在 RestClient 超时专项的在途修改;本专项不修改 `client/`、`config/*RestClient*` 或各业务 HTTP Gateway。 + +可重复命令: + +```powershell +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format dot --graph package +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format dot --graph package +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 类型图。 + +## 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 | + +主源码物理包图没有多节点 SCC。主源码逻辑切片图存在一个四节点 SCC: + +```text +dropship +inbound-pda +inventory +tailleg-handoff +``` + +合并测试源码后,出现一个由测试直接绑定横向实现包形成的物理包 SCC: + +```text +com.baiyue.lastleg.looseboard.service +com.baiyue.lastleg.relabel.service +com.baiyue.lastleg.service +com.baiyue.lastleg.transfer.service +``` + +这不是生产运行时包环。合并图的逻辑 SCC 扩大为 `dispatch-delivery`、`dropship`、`inbound-pda`、`inventory`、`procurement-trucking-looseboard`、`relabel-transfer-discard`、`tailleg-handoff` 七个切片,原因是跨域验收测试引用多个业务实现包。 + +## 3. 逻辑切片映射 + +| 逻辑切片 | 当前识别范围 | main 类数 | 跨切片出/入类边 | 当前结论 | +|---|---|---:|---:|---| +| 尾程交接 `tailleg-handoff` | 根 `controller/dto/entity/mapper/model/repository/service` 与 `shipping/` | 33 | 12/48 | 核心尾程单、事件 Inbox/Outbox 和船节点门禁被入仓、库存、换标等直接引用,位于主 SCC | +| 入仓/PDA `inbound-pda` | `inboundplan/`、`palletops/`、根 `pda/` | 100 | 53/14 | 最大切片;与尾程核心、库存和一件代发双向耦合,含大量库存 Entity/Mapper 泄漏 | +| 库存 `inventory` | `inventory/` | 26 | 3/37 | 自身出向较少,但作为多个作业域共享的 Entity/Mapper 实现被直接消费,位于主 SCC | +| 排车/派车 `dispatch-delivery` | `dispatch/`、`delivery/`、未来 `outbound/` | 46 | 20/0 | 主源码无入向 Java 类型边,但涉及明确排除的出库/派送范围,不作为首切片 | +| 换标/转运/废弃 `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;仅出向共享权限常量,入向来自采购对报价目录的使用 | + +另有 6 个 `shared-technical` 类:应用启动类、Event Bridge Client、RestClient 配置、内部令牌保护和海外权限常量。该分类不授权移动这些共享类。 + +## 4. 主源码逻辑切片边 + +| 来源 | 目标 | 类边数 | +|---|---|---:| +| dispatch-delivery | inbound-pda | 6 | +| dispatch-delivery | inventory | 4 | +| dispatch-delivery | shared-technical | 2 | +| dispatch-delivery | tailleg-handoff | 8 | +| dropship | inbound-pda | 1 | +| dropship | shared-technical | 1 | +| dropship | tailleg-handoff | 2 | +| inbound-pda | dropship | 1 | +| inbound-pda | inventory | 25 | +| inbound-pda | shared-technical | 8 | +| inbound-pda | tailleg-handoff | 19 | +| inventory | inbound-pda | 1 | +| inventory | shared-technical | 2 | +| overseas-master-data | shared-technical | 3 | +| procurement-trucking-looseboard | overseas-master-data | 1 | +| procurement-trucking-looseboard | shared-technical | 5 | +| procurement-trucking-looseboard | tailleg-handoff | 5 | +| relabel-transfer-discard | dropship | 8 | +| relabel-transfer-discard | inventory | 6 | +| relabel-transfer-discard | shared-technical | 3 | +| relabel-transfer-discard | tailleg-handoff | 14 | +| 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`,只允许采购/卡车/散板切片入向。 + +## 5. Entity/DTO/Mapper 泄漏基线 + +主源码共有 60 条跨切片 Entity/DTO/Model 泄漏、38 条跨切片 Mapper 泄漏。高频边如下: + +| 来源 → 目标 | 模型 | Mapper | 代表性证据 | +|---|---:|---:|---| +| inbound-pda → inventory | 15 | 9 | 托盘、移库、盘点 Service/Mapper 直接使用 `InventoryBalance`、`InventoryPosition`、`InventoryLedger` 及其 Mapper | +| inbound-pda → tailleg-handoff | 9 | 9 | 入仓实收、卸柜完成直接使用根 `LastlegOrder*`、事件 Outbox Entity/Mapper | +| relabel-transfer-discard → dropship | 7 | 1 | 废弃直接读取一件代发箱/SKU 库存 Entity,调整服务写箱流水 Mapper | +| relabel-transfer-discard → tailleg-handoff | 6 | 6 | 换标、转运、废弃直接使用根尾程订单、明细、状态历史 Entity/Mapper | +| dispatch-delivery → tailleg-handoff | 4 | 4 | 拣货归属和派送转移直接使用根尾程订单与状态 Mapper;该范围不进入本专项首切片 | +| dispatch-delivery → inbound-pda | 4 | 2 | 派送转移直接使用入仓计划行、托盘分配 Entity/Mapper;该范围不进入本专项首切片 | +| relabel-transfer-discard → inventory | 4 | 1 | 转运直接使用库存余额/库位模型及库存命令 Mapper | + +当前泄漏体现的是业务核心对其他切片持久化实现和表模型的直接依赖。后续治理应以应用 Port 和切片自有投影替换,不共享领域 Entity;本轮不做替换。 + +## 6. 首切片选择:车辆档案 + +选择 `masterdata.vehicle` 作为首个低耦合切片候选,依据当前主源码图: + +1. 只有 5 个生产类,已经集中在单一子域;现有测试 3 个。 +2. 零跨切片入向类依赖;唯一出向类依赖为 `PlatformVehicleController -> OverseasPermissions`。 +3. 海外主数据不在 main 或 main+test 的逻辑 SCC 内,车辆子域也没有 Entity/DTO/Mapper 跨切片泄漏。 +4. 不包含跨服务 Client、RestClient 或 HTTP Gateway,不受同步依赖超时专项影响。 +5. 车辆档案明确不保存派送履约历史;首切片不触碰 `dispatch/`、`delivery/`、出库或派送代码。 +6. 当前业务面固定为平台车辆档案列表、详情、创建、编辑、状态和 CSV 导出,不需要补齐四期业务缺口。 + +报价子域虽然也不在 SCC,但被采购服务通过 `PartnerWarehouseDirectory` 入向依赖,并包含 `DomesticPartnerWarehouseClient`,因此避让。月度汇率有 8 个生产类,也比车辆档案更大。 + +## 7. 已实施目标目录与精确文件 + +首切片已按下列结构实施: + +```text +masterdata/vehicle/ + adapter/in/web/ + PlatformVehicleController.java + application/ + VehicleApplicationService.java + model/VehicleModels.java + port/out/VehicleRepository.java + adapter/out/persistence/ + VehiclePersistenceAdapter.java + VehicleMapper.java + VehicleRecord.java +``` + +精确动作草案: + +| 原文件 | 已实施动作 | +|---|---| +| `masterdata/vehicle/controller/PlatformVehicleController.java` | 移至 `adapter/in/web/`;URI、权限、HTTP/CSV 响应不变 | +| `masterdata/vehicle/dto/VehicleDtos.java` | 移至 `application/model/VehicleModels.java`;JSON 字段、校验和响应语义不变 | +| `masterdata/vehicle/service/VehicleService.java` | 移至 `application/VehicleApplicationService.java`;业务校验、乐观锁和状态语义不变,只依赖 Out Port | +| 新增 `application/port/out/VehicleRepository.java` | 定义分页、详情、锁定、创建、乐观锁更新、状态更新和导出查询所需的切片内投影;不暴露 MyBatis 类型 | +| `masterdata/vehicle/entity/Vehicle.java` | 移至 `adapter/out/persistence/VehicleRecord.java`;仅作为 `ll_vehicle` 持久化记录 | +| `masterdata/vehicle/mapper/VehicleMapper.java` | 移至 `adapter/out/persistence/VehicleMapper.java`;保持现有 SQL 与 MyBatis 语义 | +| 新增 `adapter/out/persistence/VehiclePersistenceAdapter.java` | 实现 `VehicleRepository`,隔离 MyBatis `Page`、Wrapper、Mapper 和 Record | + +允许依赖方向: + +```text +adapter.in.web -> application -> application.model + application.port.out +adapter.out.persistence -> application.port.out + application.model +``` + +Web Adapter 可继续依赖 Spring Web、common-security 和 `OverseasPermissions`。`application` 禁止依赖 Spring MVC、MyBatis/MyBatis-Plus、全局 Mapper/Entity、RestClient、其他业务切片或出库/派送类型。 + +## 8. 测试与回滚边界 + +本次迁移只修改车辆子域 5 个原生产文件、新增 2 个车辆子域文件,并定向更新: + +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java`:改为 Mock `VehicleRepository`,继续锁定公里码、乐观锁和状态规则。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java`:继续锁定 URI 与六项权限。 +- `src/test/java/com/baiyue/lastleg/masterdata/vehicle/VehicleSqlContractTest.java`:继续锁定 `ll_vehicle` SQL、唯一索引和字段注释。 +- `scripts/analyze-lastleg-dependencies.test.mjs`:已锁定目标 7 类与 application 禁止依赖规则。 + +回滚只涉及上述车辆 Java 包移动、两个新增抽象及对应测试引用。不得修改或回滚共享 POM、`AGENTS.md`、RestClient 配置、数据库 SQL、OpenAPI、权限 SQL、接口路径、字段、状态、CSV 格式、Nacos、部署或其他业务域。若 Mapper 扫描或事务代理合同不满足,整体恢复车辆包内变更,不通过扩大全局扫描、兼容 Bean 或跨切片共享 Entity 绕过。 + +## 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。 + +最小验证: + +```powershell +node --test lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set main --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set test --format markdown +node lastleg-service/scripts/analyze-lastleg-dependencies.mjs --source-set all --format markdown +``` + +## 10. 首切片实施结果 + +- 旧 5 类及旧测试包已删除,没有转发兼容层;目标 7 类已由 Node 合同锁定。 +- `VehicleApplicationService` 只依赖 `VehicleModels` 与 `VehicleRepository`,不依赖 MyBatis/MyBatis-Plus、Spring MVC、RestClient、全局 Mapper/Entity 或其他 Lastleg 切片。 +- `VehiclePersistenceAdapter` 独占 MyBatis `Page`、Wrapper、Mapper 和 `VehicleRecord`,只向 Port 返回 Java 中立投影。 +- 车辆切片保持零入向依赖,唯一出向仍是 Web Adapter 使用 `OverseasPermissions`;全局 25 条切片边、60 条模型泄漏、38 条 Mapper 泄漏及主 SCC 均未增加。 +- 接口路径、六项权限、JSON 字段、校验、乐观锁、状态、CSV、SQL、表和索引保持不变。 +- 验证结果:Node 合同 4/4;`mvn -pl lastleg-service -am test-compile` 成功;车辆定向测试 5/5;`mvn -pl lastleg-service -am test` 中 lastleg-service 370 个测试,0 失败、0 错误、2 个环境验收跳过,构建成功。 + +首切片完成后停止,不进入第二切片。 diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs index 03146b819..c639c1bcd 100644 --- a/lastleg-service/scripts/analyze-lastleg-dependencies.mjs +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.mjs @@ -23,20 +23,22 @@ const sliceRules = [ ]; function parseArgs(argv) { - const options = { sourceSet: 'main', format: 'json', output: null, check: null }; + const options = { sourceSet: 'main', format: 'json', graph: 'slice', output: null, check: null }; for (let index = 0; index < argv.length; index += 1) { const argument = argv[index]; if (argument === '--source-set') options.sourceSet = argv[++index]; else if (argument === '--format') options.format = argv[++index]; + else if (argument === '--graph') options.graph = argv[++index]; else if (argument === '--output') options.output = argv[++index]; else if (argument === '--check') options.check = argv[++index]; else if (argument === '--help') { - process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--output FILE] [--check CONTRACT.json]\n'); + process.stdout.write('Usage: node scripts/analyze-lastleg-dependencies.mjs [--source-set main|test|all] [--format json|markdown|dot] [--graph slice|package] [--output FILE] [--check CONTRACT.json]\n'); process.exit(0); } else throw new Error(`Unknown argument: ${argument}`); } if (!['main', 'test', 'all'].includes(options.sourceSet)) throw new Error(`Invalid source set: ${options.sourceSet}`); if (!['json', 'markdown', 'dot'].includes(options.format)) throw new Error(`Invalid format: ${options.format}`); + if (!['slice', 'package'].includes(options.graph)) throw new Error(`Invalid graph: ${options.graph}`); return options; } @@ -254,9 +256,14 @@ function renderMarkdown(report) { ].join('\n'); } -function renderDot(report) { - const lines = ['digraph lastleg_slices {', ' rankdir=LR;']; - for (const edge of report.sliceEdges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); +function renderDot(report, graph) { + const edges = graph === 'package' ? report.packageEdges : report.sliceEdges; + const nodes = graph === 'package' + ? [...new Set(report.classes.map(entry => entry.packageName))].sort() + : [...new Set(report.classes.map(entry => entry.slice))].sort(); + const lines = [`digraph lastleg_${graph}s {`, ' rankdir=LR;']; + for (const node of nodes) lines.push(` "${node}";`); + for (const edge of edges) lines.push(` "${edge.source}" -> "${edge.target}" [label="${edge.count}"];`); lines.push('}', ''); return lines.join('\n'); } @@ -267,6 +274,30 @@ function checkContract(report, contractPath) { const allowed = new Set(contract.allowedSliceEdges ?? []); const actual = report.sliceEdges.map(edge => `${edge.source}->${edge.target}`); const violations = actual.filter(edge => !allowed.has(edge)).map(edge => `new slice dependency: ${edge}`); + const classesByName = new Map(report.classes.map(entry => [entry.fqcn, entry])); + for (const [fqcn, expectedFile] of Object.entries(contract.requiredClassLocations ?? {})) { + const actualClass = classesByName.get(fqcn); + if (!actualClass) violations.push(`required class missing: ${fqcn}`); + else if (actualClass.file !== expectedFile) violations.push(`class location changed: ${fqcn} (${actualClass.file})`); + } + const applicationRule = contract.applicationDependencyRule; + if (applicationRule) { + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(applicationRule.packagePrefix)); + for (const entry of applicationClasses) { + for (const imported of entry.imports) { + if ((applicationRule.forbiddenImportPrefixes ?? []).some(prefix => imported.startsWith(prefix))) { + violations.push(`forbidden application import: ${entry.fqcn} -> ${imported}`); + } + } + } + for (const edge of report.classDependencies) { + if (edge.source.startsWith(applicationRule.packagePrefix) + && edge.target.startsWith(internalPrefix) + && !edge.target.startsWith(applicationRule.allowedInternalPrefix)) { + violations.push(`forbidden application dependency: ${edge.source} -> ${edge.target}`); + } + } + } for (const [slice, rule] of Object.entries(contract.sliceSpecificRules ?? {})) { const outgoing = report.sliceEdges.filter(edge => edge.source === slice).map(edge => edge.target).sort(); const incoming = report.sliceEdges.filter(edge => edge.target === slice).map(edge => edge.source).sort(); @@ -290,6 +321,6 @@ const rendered = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : options.format === 'markdown' ? renderMarkdown(report) - : renderDot(report); + : renderDot(report, options.graph); if (options.output) fs.writeFileSync(path.resolve(process.cwd(), options.output), rendered, 'utf8'); else process.stdout.write(rendered); diff --git a/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs new file mode 100644 index 000000000..bb8a6a945 --- /dev/null +++ b/lastleg-service/scripts/analyze-lastleg-dependencies.test.mjs @@ -0,0 +1,76 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import path from 'node:path'; +import test from 'node:test'; + +const moduleRoot = path.resolve(import.meta.dirname, '..'); +const analyzer = path.join(moduleRoot, 'scripts', 'analyze-lastleg-dependencies.mjs'); +const contract = path.join(moduleRoot, 'src', 'test', 'resources', 'architecture', 'lastleg-slice-dependencies.json'); +const vehiclePrefix = 'com.baiyue.lastleg.masterdata.vehicle'; + +function analyze(sourceSet = 'main') { + return JSON.parse(execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'json'], { + cwd: moduleRoot, + encoding: 'utf8', + })); +} + +test('current source does not add slice dependencies beyond the Lastleg baseline', () => { + execFileSync(process.execPath, [analyzer, '--source-set', 'main', '--format', 'dot', '--check', contract], { + cwd: moduleRoot, + encoding: 'utf8', + }); +}); + +test('main, test, and combined source sets expose package dependency graphs', () => { + for (const sourceSet of ['main', 'test', 'all']) { + const dot = execFileSync(process.execPath, [analyzer, '--source-set', sourceSet, '--format', 'dot', '--graph', 'package'], { + cwd: moduleRoot, + encoding: 'utf8', + }); + assert.match(dot, /^digraph lastleg_packages \{/); + assert.match(dot, /com\.baiyue\.lastleg/); + } +}); + +test('vehicle master data remains the reviewed low-coupling first-slice candidate', () => { + const report = analyze(); + const classes = report.classes.filter(entry => entry.fqcn.startsWith(vehiclePrefix)); + assert.deepEqual(classes.map(entry => entry.fqcn).sort(), [ + 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter', + 'com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord', + 'com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService', + 'com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels', + 'com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository', + ]); + const outgoing = report.classDependencies.filter(edge => edge.source.startsWith(vehiclePrefix) + && !edge.target.startsWith(vehiclePrefix)); + const incoming = report.classDependencies.filter(edge => edge.target.startsWith(vehiclePrefix) + && !edge.source.startsWith(vehiclePrefix)); + assert.deepEqual(outgoing, [{ + source: 'com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController', + target: 'com.baiyue.lastleg.security.OverseasPermissions', + }]); + assert.deepEqual(incoming, []); + assert.equal(report.sliceSccs.some(component => component.includes('overseas-master-data')), false); + assert.equal(classes.some(entry => entry.kind === 'cross-service-adapter'), false); + assert.equal(classes.some(entry => entry.imports.some(imported => /RestClient|\.client\./.test(imported))), false); +}); + +test('vehicle application only depends inward and stays free of adapter frameworks', () => { + const report = analyze(); + const prefix = `${vehiclePrefix}.application`; + const applicationClasses = report.classes.filter(entry => entry.packageName.startsWith(prefix)); + assert.equal(applicationClasses.length, 3); + for (const entry of applicationClasses) { + assert.equal(entry.imports.some(imported => imported.startsWith('org.springframework.web')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('org.apache.ibatis')), false); + assert.equal(entry.imports.some(imported => imported.startsWith('com.baomidou.mybatisplus')), false); + assert.equal(entry.imports.some(imported => /RestClient|\.client\./.test(imported)), false); + } + const outwardDependencies = report.classDependencies.filter(edge => edge.source.startsWith(prefix) + && edge.target.startsWith('com.baiyue.lastleg.') && !edge.target.startsWith(prefix)); + assert.deepEqual(outwardDependencies, []); +}); diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java index 27a8b5cf0..298124a59 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java @@ -10,12 +10,19 @@ import java.net.http.HttpClient; public class LastlegRestClientFactory { public RestClient build(RestClient.Builder builder, String baseUrl, LastlegRestClientProperties.ClientTimeouts timeouts) { - HttpClient httpClient = HttpClient.newBuilder() - .connectTimeout(timeouts.getConnectTimeout()) - .build(); - JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient); - requestFactory.setReadTimeout(timeouts.getReadTimeout()); + JdkClientHttpRequestFactory requestFactory = requestFactory(timeouts); return builder.requestFactory(requestFactory).baseUrl(baseUrl).build(); } -} + HttpClient httpClient(LastlegRestClientProperties.ClientTimeouts timeouts) { + return HttpClient.newBuilder() + .connectTimeout(timeouts.getConnectTimeout()) + .build(); + } + + JdkClientHttpRequestFactory requestFactory(LastlegRestClientProperties.ClientTimeouts timeouts) { + JdkClientHttpRequestFactory requestFactory = new JdkClientHttpRequestFactory(httpClient(timeouts)); + requestFactory.setReadTimeout(timeouts.getReadTimeout()); + return requestFactory; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java index 28c46ba7a..7fd3375d5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java @@ -50,8 +50,14 @@ public class LastlegOrderController { @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody EventEnvelope event) { tokenGuard.verify(token); - int consumed = EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType()) - ? shippingNodeConsumer.consume(event) : consumer.consume(event); + int consumed; + if (EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(event.eventType())) { + consumed = shippingNodeConsumer.consume(event); + } else if (EventTypes.FIRST_LEG_LOADING_PLAN_COMPLETED.equals(event.eventType())) { + consumed = consumer.consume(event); + } else { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Lastleg 不消费该事件类型: " + event.eventType()); + } return ApiResponse.success(consumed); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java new file mode 100644 index 000000000..f2d570a9a --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java @@ -0,0 +1,103 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.core.api.PageResult; +import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.security.OverseasPermissions; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.charset.StandardCharsets; +import java.util.function.Supplier; + +@RestController +@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles") +@Tag(name = "Vehicle master data / 车辆主数据") +public class PlatformVehicleController { + private final VehicleApplicationService service; + + public PlatformVehicleController(VehicleApplicationService service) { + this.service = service; + } + + @GetMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list") + public ApiResponse> page(@Valid VehicleModels.Query query) { + return execute(() -> ApiResponse.success(service.page(query))); + } + + @GetMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") + @Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history") + public ApiResponse detail(@PathVariable long id) { + return execute(() -> ApiResponse.success(service.detail(id))); + } + + @PostMapping + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')") + public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, + @Valid @RequestBody VehicleModels.Create request) { + return execute(() -> ApiResponse.success(service.create(operator(user), request))); + } + + @PutMapping("/{id}") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')") + public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.Update request) { + return execute(() -> ApiResponse.success(service.update(id, operator(user), request))); + } + + @PatchMapping("/{id}/status") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')") + public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, + @PathVariable long id, + @Valid @RequestBody VehicleModels.ChangeStatus request) { + return execute(() -> ApiResponse.success(service.changeStatus(id, operator(user), request))); + } + + @GetMapping(value = "/export", produces = "text/csv") + @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')") + public ResponseEntity export(@Valid VehicleModels.Query query) { + return execute(() -> ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) + .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query))); + } + + private String operator(AuthenticatedUser user) { + if (user == null) return "UNKNOWN"; + return user.userId() == null ? user.username() : String.valueOf(user.userId()); + } + + private T execute(Supplier action) { + try { + return action.get(); + } catch (VehicleFailure failure) { + HttpStatus status = switch (failure.kind()) { + case BAD_REQUEST -> HttpStatus.BAD_REQUEST; + case NOT_FOUND -> HttpStatus.NOT_FOUND; + case CONFLICT -> HttpStatus.CONFLICT; + }; + throw new ResponseStatusException(status, failure.getMessage(), failure); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java similarity index 55% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java index 71198fc52..10a96df7f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/mapper/VehicleMapper.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java @@ -1,13 +1,12 @@ -package com.baiyue.lastleg.masterdata.vehicle.mapper; +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; import com.baomidou.mybatisplus.core.mapper.BaseMapper; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; import org.apache.ibatis.annotations.Mapper; import org.apache.ibatis.annotations.Param; import org.apache.ibatis.annotations.Select; @Mapper -public interface VehicleMapper extends BaseMapper { +public interface VehicleMapper extends BaseMapper { @Select("SELECT * FROM ll_vehicle WHERE id=#{id} LIMIT 1 FOR UPDATE") - Vehicle lockById(@Param("id") long id); + VehicleRecord lockById(@Param("id") long id); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java new file mode 100644 index 000000000..cb5252cf7 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java @@ -0,0 +1,121 @@ +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.toolkit.IdWorker; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.stereotype.Repository; +import org.springframework.util.StringUtils; + +import java.util.List; +import java.util.Optional; + +@Repository +public class VehiclePersistenceAdapter implements VehicleRepository { + private final VehicleMapper vehicles; + + public VehiclePersistenceAdapter(VehicleMapper vehicles) { + this.vehicles = vehicles; + } + + @Override + public PageSlice page(SearchCriteria criteria) { + Page page = vehicles.selectPage(Page.of(criteria.page(), criteria.size()), wrapper(criteria)); + return new PageSlice(page.getRecords().stream().map(this::snapshot).toList(), page.getCurrent(), + page.getSize(), page.getTotal(), page.getPages()); + } + + @Override + public List list(SearchCriteria criteria) { + return vehicles.selectList(wrapper(criteria)).stream().map(this::snapshot).toList(); + } + + @Override + public Optional findById(long id) { + return Optional.ofNullable(vehicles.selectById(id)).map(this::snapshot); + } + + @Override + public Optional lockById(long id) { + return Optional.ofNullable(vehicles.lockById(id)).map(this::snapshot); + } + + @Override + public long create(NewVehicle vehicle) { + VehicleRecord record = new VehicleRecord(); + record.setId(IdWorker.getId()); + record.setPlateNumber(vehicle.plateNumber()); + record.setModel(vehicle.model()); + record.setFuelConsumptionPer100Km(vehicle.fuelConsumptionPer100Km()); + record.setMileageKm(vehicle.mileageKm()); + record.setDriverOrDispatchCompany(vehicle.driverOrDispatchCompany()); + record.setPhone(vehicle.phone()); + record.setRegion(vehicle.region()); + record.setOwnership(vehicle.ownership()); + record.setStatus(vehicle.status()); + record.setRemark(vehicle.remark()); + record.setRecordVersion(0L); + record.setCreatedBy(vehicle.operator()); + record.setUpdatedBy(vehicle.operator()); + record.setCreatedAt(vehicle.createdAt()); + record.setUpdatedAt(vehicle.createdAt()); + try { + vehicles.insert(record); + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + return record.getId(); + } + + @Override + public boolean update(long id, long expectedVersion, VehicleChanges changes) { + try { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getPlateNumber, changes.plateNumber()) + .set(VehicleRecord::getModel, changes.model()) + .set(VehicleRecord::getFuelConsumptionPer100Km, changes.fuelConsumptionPer100Km()) + .set(VehicleRecord::getMileageKm, changes.mileageKm()) + .set(VehicleRecord::getDriverOrDispatchCompany, changes.driverOrDispatchCompany()) + .set(VehicleRecord::getPhone, changes.phone()).set(VehicleRecord::getRegion, changes.region()) + .set(VehicleRecord::getOwnership, changes.ownership()).set(VehicleRecord::getRemark, changes.remark()) + .set(VehicleRecord::getUpdatedBy, changes.operator()).set(VehicleRecord::getUpdatedAt, changes.updatedAt()) + .setSql("record_version = record_version + 1")) == 1; + } catch (DuplicateKeyException exception) { + throw new DuplicatePlateException(exception); + } + } + + @Override + public boolean updateStatus(long id, long expectedVersion, String status, String operator, + java.time.LocalDateTime updatedAt) { + return vehicles.update(null, new LambdaUpdateWrapper() + .eq(VehicleRecord::getId, id).eq(VehicleRecord::getRecordVersion, expectedVersion) + .set(VehicleRecord::getStatus, status).set(VehicleRecord::getUpdatedBy, operator) + .set(VehicleRecord::getUpdatedAt, updatedAt).setSql("record_version = record_version + 1")) == 1; + } + + private LambdaQueryWrapper wrapper(SearchCriteria criteria) { + return new LambdaQueryWrapper() + .and(StringUtils.hasText(criteria.keyword()), wrapper -> wrapper.like(VehicleRecord::getPlateNumber, criteria.keyword()) + .or().like(VehicleRecord::getModel, criteria.keyword()) + .or().like(VehicleRecord::getDriverOrDispatchCompany, criteria.keyword())) + .eq(criteria.ownership() != null, VehicleRecord::getOwnership, criteria.ownership()) + .eq(criteria.region() != null, VehicleRecord::getRegion, criteria.region()) + .eq(criteria.status() != null, VehicleRecord::getStatus, criteria.status()) + .orderByDesc(VehicleRecord::getUpdatedAt).orderByDesc(VehicleRecord::getId); + } + + private VehicleSnapshot snapshot(VehicleRecord value) { + return new VehicleSnapshot(value.getId(), value.getPlateNumber(), value.getModel(), + value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), + value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), + value.getUpdatedBy(), value.getUpdatedAt()); + } + + private static long version(Long value) { + return value == null ? 0L : value; + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java similarity index 88% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java index 82a303ac5..9518e94e4 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/entity/Vehicle.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.entity; +package com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence; import com.baomidou.mybatisplus.annotation.IdType; import com.baomidou.mybatisplus.annotation.TableId; @@ -10,7 +10,7 @@ import java.time.LocalDateTime; @Data @TableName("ll_vehicle") -public class Vehicle { +public class VehicleRecord { @TableId(type = IdType.INPUT) private Long id; private String plateNumber; private String model; diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java new file mode 100644 index 000000000..79fbb24d1 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java @@ -0,0 +1,165 @@ +package com.baiyue.lastleg.masterdata.vehicle.application; + +import com.baiyue.common.core.api.PageResult; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.DuplicatePlateException; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.NewVehicle; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.SearchCriteria; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleChanges; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.nio.charset.StandardCharsets; +import java.time.LocalDateTime; +import java.util.Locale; +import java.util.Set; + +@Service +public class VehicleApplicationService { + private static final Set OWNERSHIPS = Set.of("OWNED", "DISPATCHED"); + private static final Set REGIONS = Set.of("DOMESTIC", "OVERSEAS"); + private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); + + private final VehicleRepository vehicles; + + public VehicleApplicationService(VehicleRepository vehicles) { + this.vehicles = vehicles; + } + + public PageResult page(VehicleModels.Query query) { + var page = vehicles.page(criteria(query)); + return new PageResult<>(page.records().stream().map(this::response).toList(), page.page(), + page.size(), page.total(), page.pages()); + } + + public VehicleModels.Response detail(long id) { + return response(required(id)); + } + + @Transactional + public VehicleModels.Response create(String operator, VehicleModels.Create request) { + LocalDateTime now = LocalDateTime.now(); + long id; + try { + id = vehicles.create(new NewVehicle( + requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), + requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), + trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), + ownership(request.ownership()), status(request.status()), trim(request.remark()), operator, now)); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + return detail(id); + } + + @Transactional + public VehicleModels.Response update(long id, String operator, VehicleModels.Update request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + if (request.mileageKm() < current.mileageKm()) throw badRequest("当前公里码不能小于已有公里码"); + boolean updated; + try { + updated = vehicles.update(id, request.recordVersion(), new VehicleChanges( + requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT), + requiredText(request.model(), "车型"), request.fuelConsumptionPer100Km(), request.mileageKm(), + trim(request.driverOrDispatchCompany()), trim(request.phone()), region(request.region()), + ownership(request.ownership()), trim(request.remark()), operator, LocalDateTime.now())); + } catch (DuplicatePlateException exception) { + throw conflict("车牌号已存在"); + } + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + @Transactional + public VehicleModels.Response changeStatus(long id, String operator, VehicleModels.ChangeStatus request) { + VehicleSnapshot current = locked(id); + requireVersion(current, request.recordVersion()); + String status = status(request.status()); + if (status.equals(current.status())) return response(current); + boolean updated = vehicles.updateStatus(id, request.recordVersion(), status, operator, LocalDateTime.now()); + if (!updated) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + return detail(id); + } + + public byte[] exportCsv(VehicleModels.Query query) { + var rows = vehicles.list(criteria(query)).stream().map(this::response).toList(); + StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); + for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') + .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') + .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') + .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') + .append(row.updatedAt()).append("\r\n"); + return csv.toString().getBytes(StandardCharsets.UTF_8); + } + + private SearchCriteria criteria(VehicleModels.Query query) { + return new SearchCriteria(query.page(), query.size(), trim(query.keyword()), + optional(query.ownership(), OWNERSHIPS, "车辆归属"), + optional(query.region(), REGIONS, "车辆区域"), + optional(query.status(), STATUSES, "车辆状态")); + } + + private VehicleSnapshot required(long id) { + return vehicles.findById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleSnapshot locked(long id) { + return vehicles.lockById(id).orElseThrow(VehicleApplicationService::notFound); + } + + private VehicleModels.Response response(VehicleSnapshot value) { + return new VehicleModels.Response(String.valueOf(value.id()), value.plateNumber(), value.model(), + value.fuelConsumptionPer100Km(), value.mileageKm(), value.driverOrDispatchCompany(), value.phone(), + value.region(), value.ownership(), value.status(), value.remark(), value.recordVersion(), + value.updatedBy(), value.updatedAt()); + } + + private void requireVersion(VehicleSnapshot value, long expected) { + if (value.recordVersion() != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); + } + + private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); } + private static String region(String value) { return required(value, REGIONS, "车辆区域"); } + private static String status(String value) { return required(value, STATUSES, "车辆状态"); } + + private static String optional(String value, Set allowed, String label) { + return StringUtils.hasText(value) ? required(value, allowed, label) : null; + } + + private static String required(String value, Set allowed, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + String normalized = value.trim().toUpperCase(Locale.ROOT); + if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); + return normalized; + } + + private static String requiredText(String value, String label) { + if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); + return value.trim(); + } + + private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } + private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } + private static VehicleFailure notFound() { return new VehicleFailure(FailureKind.NOT_FOUND, "车辆档案不存在"); } + private static VehicleFailure badRequest(String message) { return new VehicleFailure(FailureKind.BAD_REQUEST, message); } + private static VehicleFailure conflict(String message) { return new VehicleFailure(FailureKind.CONFLICT, message); } + + public enum FailureKind { BAD_REQUEST, NOT_FOUND, CONFLICT } + + public static final class VehicleFailure extends RuntimeException { + private final FailureKind kind; + + public VehicleFailure(FailureKind kind, String message) { + super(message); + this.kind = kind; + } + + public FailureKind kind() { + return kind; + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java similarity index 94% rename from lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java rename to lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java index 4c19ceb3e..36a307164 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/dto/VehicleDtos.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.dto; +package com.baiyue.lastleg.masterdata.vehicle.application.model; import jakarta.validation.constraints.DecimalMin; import jakarta.validation.constraints.Digits; @@ -11,8 +11,8 @@ import jakarta.validation.constraints.Size; import java.math.BigDecimal; import java.time.LocalDateTime; -public final class VehicleDtos { - private VehicleDtos() { } +public final class VehicleModels { + private VehicleModels() { } public record Query(@Min(1) Integer page, @Min(1) @Max(200) Integer size, @Size(max = 128) String keyword, String ownership, String region, String status) { diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java new file mode 100644 index 000000000..31f0b89a9 --- /dev/null +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java @@ -0,0 +1,45 @@ +package com.baiyue.lastleg.masterdata.vehicle.application.port.out; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +public interface VehicleRepository { + PageSlice page(SearchCriteria criteria); + + List list(SearchCriteria criteria); + + Optional findById(long id); + + Optional lockById(long id); + + long create(NewVehicle vehicle); + + boolean update(long id, long expectedVersion, VehicleChanges changes); + + boolean updateStatus(long id, long expectedVersion, String status, String operator, LocalDateTime updatedAt); + + record SearchCriteria(int page, int size, String keyword, String ownership, String region, String status) { } + + record PageSlice(List records, long page, long size, long total, long pages) { } + + record NewVehicle(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, + String driverOrDispatchCompany, String phone, String region, String ownership, String status, + String remark, String operator, LocalDateTime createdAt) { } + + record VehicleChanges(String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, long mileageKm, + String driverOrDispatchCompany, String phone, String region, String ownership, String remark, + String operator, LocalDateTime updatedAt) { } + + record VehicleSnapshot(long id, String plateNumber, String model, BigDecimal fuelConsumptionPer100Km, + long mileageKm, String driverOrDispatchCompany, String phone, String region, + String ownership, String status, String remark, long recordVersion, + String updatedBy, LocalDateTime updatedAt) { } + + final class DuplicatePlateException extends RuntimeException { + public DuplicatePlateException(Throwable cause) { + super(cause); + } + } +} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java deleted file mode 100644 index 67dd55ff4..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleController.java +++ /dev/null @@ -1,58 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.controller; - -import com.baiyue.common.core.api.ApiResponse; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.common.security.AuthenticatedUser; -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.service.VehicleService; -import com.baiyue.lastleg.security.OverseasPermissions; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; -import jakarta.annotation.Resource; -import jakarta.validation.Valid; -import org.springframework.http.HttpHeaders; -import org.springframework.http.MediaType; -import org.springframework.http.ResponseEntity; -import org.springframework.security.access.prepost.PreAuthorize; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.*; - -import java.nio.charset.StandardCharsets; - -@RestController -@RequestMapping("/api/platform/v1/last-leg/master-data/vehicles") -@Tag(name = "Vehicle master data / 车辆主数据") -public class PlatformVehicleController { - @Resource private VehicleService service; - - @GetMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") - @Operation(summary = "Page vehicle master data", description = "Required authority: platform:overseas-master-data:vehicle:list") - public ApiResponse> page(@Valid VehicleDtos.Query query) { return ApiResponse.success(service.page(query)); } - - @GetMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_LIST + "')") - @Operation(summary = "Get vehicle master data detail", description = "Required authority: platform:overseas-master-data:vehicle:list; excludes delivery fulfillment history") - public ApiResponse detail(@PathVariable long id) { return ApiResponse.success(service.detail(id)); } - - @PostMapping @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_CREATE + "')") - public ApiResponse create(@AuthenticationPrincipal AuthenticatedUser user, - @Valid @RequestBody VehicleDtos.Create request) { return ApiResponse.success(service.create(operator(user), request)); } - - @PutMapping("/{id}") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EDIT + "')") - public ApiResponse update(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, - @Valid @RequestBody VehicleDtos.Update request) { return ApiResponse.success(service.update(id, operator(user), request)); } - - @PatchMapping("/{id}/status") @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_STATUS + "')") - public ApiResponse status(@AuthenticationPrincipal AuthenticatedUser user, @PathVariable long id, - @Valid @RequestBody VehicleDtos.ChangeStatus request) { return ApiResponse.success(service.changeStatus(id, operator(user), request)); } - - @GetMapping(value = "/export", produces = "text/csv") - @PreAuthorize("hasAuthority('" + OverseasPermissions.VEHICLE_EXPORT + "')") - public ResponseEntity export(@Valid VehicleDtos.Query query) { - return ResponseEntity.ok().contentType(new MediaType("text", "csv", StandardCharsets.UTF_8)) - .header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=vehicles.csv").body(service.exportCsv(query)); - } - - private String operator(AuthenticatedUser user) { - if (user == null) return "UNKNOWN"; return user.userId() == null ? user.username() : String.valueOf(user.userId()); - } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java deleted file mode 100644 index 191cae17b..000000000 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleService.java +++ /dev/null @@ -1,143 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.service; - -import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; -import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; -import com.baomidou.mybatisplus.core.toolkit.IdWorker; -import com.baomidou.mybatisplus.extension.plugins.pagination.Page; -import com.baiyue.common.core.api.PageResult; -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; -import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; -import jakarta.annotation.Resource; -import org.springframework.dao.DuplicateKeyException; -import org.springframework.http.HttpStatus; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.nio.charset.StandardCharsets; -import java.time.LocalDateTime; -import java.util.Locale; -import java.util.Set; - -@Service -public class VehicleService { - private static final Set OWNERSHIPS = Set.of("OWNED", "DISPATCHED"); - private static final Set REGIONS = Set.of("DOMESTIC", "OVERSEAS"); - private static final Set STATUSES = Set.of("IN_SERVICE", "DISPATCHED", "MAINTENANCE", "INACTIVE", "RETURNED"); - @Resource private VehicleMapper vehicles; - - public PageResult page(VehicleDtos.Query query) { - Page page = vehicles.selectPage(Page.of(query.page(), query.size()), wrapper(query)); - return new PageResult<>(page.getRecords().stream().map(this::response).toList(), page.getCurrent(), - page.getSize(), page.getTotal(), page.getPages()); - } - - public VehicleDtos.Response detail(long id) { return response(required(id)); } - - @Transactional - public VehicleDtos.Response create(String operator, VehicleDtos.Create request) { - LocalDateTime now = LocalDateTime.now(); - Vehicle value = new Vehicle(); value.setId(IdWorker.getId()); - value.setPlateNumber(requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)); - value.setModel(requiredText(request.model(), "车型")); value.setFuelConsumptionPer100Km(request.fuelConsumptionPer100Km()); - value.setMileageKm(request.mileageKm()); value.setDriverOrDispatchCompany(trim(request.driverOrDispatchCompany())); - value.setPhone(trim(request.phone())); value.setRegion(region(request.region())); value.setOwnership(ownership(request.ownership())); - value.setStatus(status(request.status())); value.setRemark(trim(request.remark())); value.setRecordVersion(0L); - value.setCreatedBy(operator); value.setUpdatedBy(operator); value.setCreatedAt(now); value.setUpdatedAt(now); - try { vehicles.insert(value); } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); } - return detail(value.getId()); - } - - @Transactional - public VehicleDtos.Response update(long id, String operator, VehicleDtos.Update request) { - Vehicle current = locked(id); requireVersion(current, request.recordVersion()); - if (request.mileageKm() < current.getMileageKm()) throw badRequest("当前公里码不能小于已有公里码"); - int updated; - try { - updated = vehicles.update(null, new LambdaUpdateWrapper() - .eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion()) - .set(Vehicle::getPlateNumber, requiredText(request.plateNumber(), "车牌号").toUpperCase(Locale.ROOT)) - .set(Vehicle::getModel, requiredText(request.model(), "车型")) - .set(Vehicle::getFuelConsumptionPer100Km, request.fuelConsumptionPer100Km()) - .set(Vehicle::getMileageKm, request.mileageKm()) - .set(Vehicle::getDriverOrDispatchCompany, trim(request.driverOrDispatchCompany())) - .set(Vehicle::getPhone, trim(request.phone())).set(Vehicle::getRegion, region(request.region())) - .set(Vehicle::getOwnership, ownership(request.ownership())).set(Vehicle::getRemark, trim(request.remark())) - .set(Vehicle::getUpdatedBy, operator).set(Vehicle::getUpdatedAt, LocalDateTime.now()) - .setSql("record_version = record_version + 1")); - } catch (DuplicateKeyException exception) { throw conflict("车牌号已存在"); } - if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - return detail(id); - } - - @Transactional - public VehicleDtos.Response changeStatus(long id, String operator, VehicleDtos.ChangeStatus request) { - Vehicle current = locked(id); requireVersion(current, request.recordVersion()); - String status = status(request.status()); - if (status.equals(current.getStatus())) return response(current); - int updated = vehicles.update(null, new LambdaUpdateWrapper() - .eq(Vehicle::getId, id).eq(Vehicle::getRecordVersion, request.recordVersion()) - .set(Vehicle::getStatus, status).set(Vehicle::getUpdatedBy, operator) - .set(Vehicle::getUpdatedAt, LocalDateTime.now()).setSql("record_version = record_version + 1")); - if (updated != 1) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - return detail(id); - } - - public byte[] exportCsv(VehicleDtos.Query query) { - var rows = vehicles.selectList(wrapper(query)).stream().map(this::response).toList(); - StringBuilder csv = new StringBuilder("\uFEFF车辆ID,车牌号,车型,油耗(L/100km),当前公里码(km),司机或外派单位,联系电话,区域,车辆归属,状态,更新时间\r\n"); - for (var row : rows) csv.append(row.id()).append(',').append(cell(row.plateNumber())).append(',') - .append(cell(row.model())).append(',').append(row.fuelConsumptionPer100Km()).append(',').append(row.mileageKm()).append(',') - .append(cell(row.driverOrDispatchCompany())).append(',').append(cell(row.phone())).append(',') - .append(row.region()).append(',').append(row.ownership()).append(',').append(row.status()).append(',') - .append(row.updatedAt()).append("\r\n"); - return csv.toString().getBytes(StandardCharsets.UTF_8); - } - - private LambdaQueryWrapper wrapper(VehicleDtos.Query query) { - String keyword = trim(query.keyword()); String ownership = optional(query.ownership(), OWNERSHIPS, "车辆归属"); - String region = optional(query.region(), REGIONS, "车辆区域"); String status = optional(query.status(), STATUSES, "车辆状态"); - return new LambdaQueryWrapper() - .and(StringUtils.hasText(keyword), w -> w.like(Vehicle::getPlateNumber, keyword) - .or().like(Vehicle::getModel, keyword).or().like(Vehicle::getDriverOrDispatchCompany, keyword)) - .eq(ownership != null, Vehicle::getOwnership, ownership).eq(region != null, Vehicle::getRegion, region) - .eq(status != null, Vehicle::getStatus, status).orderByDesc(Vehicle::getUpdatedAt).orderByDesc(Vehicle::getId); - } - private Vehicle required(long id) { - Vehicle value = vehicles.selectById(id); if (value == null) throw notFound(); return value; - } - private Vehicle locked(long id) { - Vehicle value = vehicles.lockById(id); if (value == null) throw notFound(); return value; - } - private VehicleDtos.Response response(Vehicle value) { - return new VehicleDtos.Response(String.valueOf(value.getId()), value.getPlateNumber(), value.getModel(), - value.getFuelConsumptionPer100Km(), value.getMileageKm(), value.getDriverOrDispatchCompany(), value.getPhone(), - value.getRegion(), value.getOwnership(), value.getStatus(), value.getRemark(), version(value.getRecordVersion()), - value.getUpdatedBy(), value.getUpdatedAt()); - } - private void requireVersion(Vehicle value, long expected) { - if (version(value.getRecordVersion()) != expected) throw conflict("车辆档案已被其他操作修改,请刷新后重试"); - } - private static String ownership(String value) { return required(value, OWNERSHIPS, "车辆归属"); } - private static String region(String value) { return required(value, REGIONS, "车辆区域"); } - private static String status(String value) { return required(value, STATUSES, "车辆状态"); } - private static String optional(String value, Set allowed, String label) { - return StringUtils.hasText(value) ? required(value, allowed, label) : null; - } - private static String required(String value, Set allowed, String label) { - if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); - String normalized = value.trim().toUpperCase(Locale.ROOT); - if (!allowed.contains(normalized)) throw badRequest(label + "不合法"); return normalized; - } - private static String requiredText(String value, String label) { - if (!StringUtils.hasText(value)) throw badRequest(label + "不能为空"); return value.trim(); - } - private static long version(Long value) { return value == null ? 0L : value; } - private static String trim(String value) { return StringUtils.hasText(value) ? value.trim() : null; } - private static String cell(Object value) { return "\"" + String.valueOf(value == null ? "" : value).replace("\"", "\"\"") + "\""; } - private static ResponseStatusException notFound() { return new ResponseStatusException(HttpStatus.NOT_FOUND, "车辆档案不存在"); } - private static ResponseStatusException badRequest(String message) { return new ResponseStatusException(HttpStatus.BAD_REQUEST, message); } - private static ResponseStatusException conflict(String message) { return new ResponseStatusException(HttpStatus.CONFLICT, message); } -} diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java index 72fd13cd9..53e110c8a 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepository.java @@ -32,6 +32,8 @@ public class UnloadCompletedOutboxRepository { .eq(LastlegEventOutbox::getId, id) .eq(LastlegEventOutbox::getEventType, EventTypes.OVERSEAS_CONTAINER_UNLOADED) .and(status -> status.eq(LastlegEventOutbox::getStatus, "PENDING") + .and(retry -> retry.isNull(LastlegEventOutbox::getNextRetryAt) + .or().le(LastlegEventOutbox::getNextRetryAt, now)) .or(stale -> stale.eq(LastlegEventOutbox::getStatus, "PROCESSING") .le(LastlegEventOutbox::getLockedAt, expiredLeaseBefore))) .set(LastlegEventOutbox::getStatus, "PROCESSING") diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java index 0896e5b77..fd8c1383f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/service/LoadingCompletedConsumer.java @@ -54,6 +54,8 @@ public class LoadingCompletedConsumer { throw new IllegalArgumentException("尾程交接事件区域无效"); if (!"LOADING_PLAN".equals(envelope.aggregateType())) throw new IllegalArgumentException("尾程交接事件聚合类型无效"); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) throw new IllegalArgumentException("payloadHash 与尾程事件负载不一致"); FirstLegLoadingPlanCompletedV1 payload = parsePayload(envelope); if(payload.containerNo()==null||payload.containerNo().isBlank()) throw new IllegalArgumentException("事件没有实际集装箱号 containerNo"); @@ -70,6 +72,11 @@ public class LoadingCompletedConsumer { inbox.setEventVersion(envelope.eventVersion()); inbox.setPayloadHash(envelope.payloadHash()); inbox.setPayloadJson(envelope.payload().toString()); inbox.setStatus("RECEIVED"); inbox.setReceivedAt(LocalDateTime.now()); try { inboxMapper.insert(inbox); } catch(DuplicateKeyException exception) { + LastlegEventInbox raced = inboxMapper.selectById(envelope.eventId()); + if (raced == null || raced.getPayloadHash() == null + || !raced.getPayloadHash().equalsIgnoreCase(envelope.payloadHash())) { + throw new IllegalArgumentException("事件摘要冲突", exception); + } return countOrders(envelope,payload.containerNo()); } diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java index 938bc4404..b641c1e46 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumer.java @@ -1,6 +1,8 @@ package com.baiyue.lastleg.shipping.service; import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventPayloadCanonicalizer; +import com.baiyue.common.event.EventPayloadHash; import com.baiyue.common.event.EventTypes; import com.baiyue.common.event.FirstLegContainerShippingNodeRecordedV1; import com.baiyue.common.event.RegionCode; @@ -91,6 +93,10 @@ public class ContainerShippingNodeConsumer { private void validate(EventEnvelope envelope) { envelope.requireValid(); + if (!EventPayloadHash.matches(EventPayloadCanonicalizer.canonicalize(envelope.payload()), + envelope.payloadHash())) { + throw new IllegalArgumentException("payloadHash 与船节点事件负载不一致"); + } if (!EventTypes.FIRST_LEG_CONTAINER_SHIPPING_NODE_RECORDED.equals(envelope.eventType()) || envelope.eventVersion() != 1 || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java new file mode 100644 index 000000000..94c1758ab --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/config/LastlegRestClientFactoryTest.java @@ -0,0 +1,84 @@ +package com.baiyue.lastleg.config; + +import com.sun.net.httpserver.HttpServer; +import org.junit.jupiter.api.Test; +import org.springframework.boot.context.properties.bind.Bindable; +import org.springframework.boot.context.properties.bind.Binder; +import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; +import org.springframework.web.client.ResourceAccessException; +import org.springframework.web.client.RestClient; + +import java.net.InetSocketAddress; +import java.time.Duration; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class LastlegRestClientFactoryTest { + private final LastlegRestClientFactory factory = new LastlegRestClientFactory(); + + @Test + void defaultsArePositiveAndSeparateFileUploadsFromWarehouseCalls() { + LastlegRestClientProperties properties = new LastlegRestClientProperties(); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofSeconds(3)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getRelabelPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(30)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void rejectsUnboundedOrNonPositiveTimeouts() { + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(null, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ZERO, Duration.ofSeconds(1))) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> new LastlegRestClientProperties.ClientTimeouts(Duration.ofSeconds(1), Duration.ZERO)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void bindsPerClientTimeoutOverridesFromConfiguration() { + var source = new MapConfigurationPropertySource(Map.of( + "baiyue.http.clients.unload-photo-file.connect-timeout", "125ms", + "baiyue.http.clients.unload-photo-file.read-timeout", "9s", + "baiyue.http.clients.pallet-physical.connect-timeout", "250ms", + "baiyue.http.clients.pallet-physical.read-timeout", "2s")); + + LastlegRestClientProperties properties = new Binder(source) + .bind("baiyue.http.clients", Bindable.of(LastlegRestClientProperties.class)) + .orElseThrow(() -> new AssertionError("Lastleg HTTP client properties were not bound")); + + assertThat(properties.getUnloadPhotoFile().getConnectTimeout()).isEqualTo(Duration.ofMillis(125)); + assertThat(properties.getUnloadPhotoFile().getReadTimeout()).isEqualTo(Duration.ofSeconds(9)); + assertThat(properties.getPalletPhysical().getConnectTimeout()).isEqualTo(Duration.ofMillis(250)); + assertThat(properties.getPalletPhysical().getReadTimeout()).isEqualTo(Duration.ofSeconds(2)); + assertThat(properties.getWarehouseResource().getReadTimeout()).isEqualTo(Duration.ofSeconds(5)); + } + + @Test + void appliesConfiguredConnectAndReadTimeoutsWithoutRetryOrFallback() throws Exception { + var timeouts = new LastlegRestClientProperties.ClientTimeouts( + Duration.ofMillis(250), Duration.ofMillis(75)); + assertThat(factory.httpClient(timeouts).connectTimeout()).contains(Duration.ofMillis(250)); + + HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0); + server.createContext("/slow", exchange -> { + try { Thread.sleep(500); } + catch (InterruptedException interrupted) { Thread.currentThread().interrupt(); } + exchange.sendResponseHeaders(200, 0); + exchange.close(); + }); + server.start(); + try { + RestClient client = factory.build(RestClient.builder(), + "http://127.0.0.1:" + server.getAddress().getPort(), timeouts); + assertThatThrownBy(() -> client.get().uri("/slow").retrieve().toBodilessEntity()) + .isInstanceOf(ResourceAccessException.class); + } finally { + server.stop(0); + } + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java index aeee3ebe5..abb7991c9 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/controller/LastlegOrderControllerTest.java @@ -3,6 +3,8 @@ package com.baiyue.lastleg.controller; import com.baiyue.common.core.api.ApiResponse; import com.baiyue.common.core.api.PageResult; import com.baiyue.common.security.AuthenticatedUser; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; import com.baiyue.lastleg.dto.LastlegOrderRequests; import com.baiyue.lastleg.dto.LastlegOrderSummary; import com.baiyue.lastleg.security.InternalServiceTokenGuard; @@ -21,6 +23,8 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; class LastlegOrderControllerTest { private final LastlegOrderService service = mock(LastlegOrderService.class); @@ -46,6 +50,17 @@ class LastlegOrderControllerTest { verify(service).page(eq("200"), eq(query)); } + @Test + void iamSecurityEventsAreRejectedBeforeLoadingCompletedConsumer() { + EventEnvelope event = mock(EventEnvelope.class); + when(event.eventType()).thenReturn(EventTypes.IAM_USER_STATUS_CHANGED); + LoadingCompletedConsumer loading = (LoadingCompletedConsumer) ReflectionTestUtils.getField(controller, "consumer"); + + assertThrows(ResponseStatusException.class, () -> controller.receive("internal-token", event)); + + verify(loading, never()).consume(event); + } + private static AuthenticatedUser user(long customerId, String appCode) { return new AuthenticatedUser(1L, customerId, customerId, 1L, "tester", appCode, "WEB", Set.of()); } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java similarity index 96% rename from lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java rename to lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java index 719dcf8e3..ae1a8d3e1 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/controller/PlatformVehicleControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleControllerContractTest.java @@ -1,4 +1,4 @@ -package com.baiyue.lastleg.masterdata.vehicle.controller; +package com.baiyue.lastleg.masterdata.vehicle.adapter.in.web; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java new file mode 100644 index 000000000..91fffa32d --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationServiceTest.java @@ -0,0 +1,64 @@ +package com.baiyue.lastleg.masterdata.vehicle.application; + +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.FailureKind; +import com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService.VehicleFailure; +import com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository; +import com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository.VehicleSnapshot; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class VehicleApplicationServiceTest { + private VehicleRepository repository; + private VehicleApplicationService service; + + @BeforeEach + void setUp() { + repository = mock(VehicleRepository.class); + service = new VehicleApplicationService(repository); + } + + @Test + void rejectsStaleVersionBeforeUpdating() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + VehicleModels.Update request = update(2L, 1000L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.CONFLICT)); + } + + @Test + void rejectsDecreasingOdometer() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + VehicleModels.Update request = update(3L, 999L); + assertThatThrownBy(() -> service.update(1L, "9", request)) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); + } + + @Test + void rejectsUnknownStatus() { + when(repository.lockById(1L)).thenReturn(Optional.of(vehicle(3L, 1000L))); + assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleModels.ChangeStatus("BUSY", 3L))) + .isInstanceOfSatisfying(VehicleFailure.class, + failure -> assertThat(failure.kind()).isEqualTo(FailureKind.BAD_REQUEST)); + } + + private static VehicleModels.Update update(long version, long mileage) { + return new VehicleModels.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", null); + } + + private static VehicleSnapshot vehicle(long version, long mileage) { + return new VehicleSnapshot(1L, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, + "Driver", "123", "OVERSEAS", "OWNED", "IN_SERVICE", null, version, "9", null); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java deleted file mode 100644 index b83562614..000000000 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/vehicle/service/VehicleServiceTest.java +++ /dev/null @@ -1,63 +0,0 @@ -package com.baiyue.lastleg.masterdata.vehicle.service; - -import com.baiyue.lastleg.masterdata.vehicle.dto.VehicleDtos; -import com.baiyue.lastleg.masterdata.vehicle.entity.Vehicle; -import com.baiyue.lastleg.masterdata.vehicle.mapper.VehicleMapper; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.test.util.ReflectionTestUtils; -import org.springframework.web.server.ResponseStatusException; - -import java.math.BigDecimal; - -import static org.assertj.core.api.Assertions.assertThatThrownBy; -import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.when; - -class VehicleServiceTest { - private VehicleMapper mapper; - private VehicleService service; - - @BeforeEach - void setUp() { - mapper = mock(VehicleMapper.class); - service = new VehicleService(); - ReflectionTestUtils.setField(service, "vehicles", mapper); - } - - @Test - void rejectsStaleVersionBeforeUpdating() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - VehicleDtos.Update request = update(2L, 1000L); - assertThatThrownBy(() -> service.update(1L, "9", request)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("409 CONFLICT"); - } - - @Test - void rejectsDecreasingOdometer() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - VehicleDtos.Update request = update(3L, 999L); - assertThatThrownBy(() -> service.update(1L, "9", request)) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST"); - } - - @Test - void rejectsUnknownStatus() { - when(mapper.lockById(1L)).thenReturn(vehicle(3L, 1000L)); - assertThatThrownBy(() -> service.changeStatus(1L, "9", new VehicleDtos.ChangeStatus("BUSY", 3L))) - .isInstanceOf(ResponseStatusException.class) - .hasMessageContaining("400 BAD_REQUEST"); - } - - private static VehicleDtos.Update update(long version, long mileage) { - return new VehicleDtos.Update(version, "ABC-001", "Box Truck", new BigDecimal("12.50"), mileage, - "Driver", "123", "OVERSEAS", "OWNED", null); - } - - private static Vehicle vehicle(long version, long mileage) { - Vehicle value = new Vehicle(); value.setId(1L); value.setRecordVersion(version); value.setMileageKm(mileage); - value.setStatus("IN_SERVICE"); return value; - } -} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java new file mode 100644 index 000000000..b2123ef9e --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/repository/UnloadCompletedOutboxRepositoryTest.java @@ -0,0 +1,38 @@ +package com.baiyue.lastleg.repository; + +import com.baomidou.mybatisplus.core.MybatisConfiguration; +import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; +import com.baomidou.mybatisplus.core.metadata.TableInfoHelper; +import com.baiyue.lastleg.entity.LastlegEventOutbox; +import com.baiyue.lastleg.mapper.LastlegEventOutboxMapper; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; +import org.apache.ibatis.builder.MapperBuilderAssistant; +import org.springframework.test.util.ReflectionTestUtils; + +import java.time.LocalDateTime; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class UnloadCompletedOutboxRepositoryTest { + @Test + @SuppressWarnings({"rawtypes", "unchecked"}) + void claimRechecksRetryWindowAfterCandidateSelection() { + TableInfoHelper.initTableInfo(new MapperBuilderAssistant(new MybatisConfiguration(), "test"), + LastlegEventOutbox.class); + LastlegEventOutboxMapper mapper = mock(LastlegEventOutboxMapper.class); + UnloadCompletedOutboxRepository repository = new UnloadCompletedOutboxRepository(); + ReflectionTestUtils.setField(repository, "mapper", mapper); + ArgumentCaptor wrapper = ArgumentCaptor.forClass(LambdaUpdateWrapper.class); + when(mapper.update(isNull(LastlegEventOutbox.class), wrapper.capture())).thenReturn(1); + LocalDateTime now = LocalDateTime.of(2026, 8, 25, 12, 0); + + assertThat(repository.claim(1L, "worker", now, now.minusMinutes(1))).isTrue(); + + assertThat(wrapper.getValue().getSqlSegment()) + .contains("next_retry_at", "<=", "status", "locked_at"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java index 23e55decb..c7ee14c86 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/service/LoadingCompletedConsumerTest.java @@ -8,6 +8,8 @@ class LoadingCompletedConsumerTest { @Test void loadingCreatesAwaitingPickupReadyGateWithoutPollutingShippingVersion(){ service.consume(event()); verify(shippingGates).initialize("book-1","100","","MSCU1",0L,1L); } @Test void repeatedEventReturnsExistingEffect(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash(event.payloadHash()); when(inbox.selectById(event.eventId())).thenReturn(row); when(orders.countContainer("100","MSCU1")).thenReturn(2L); assertEquals(2,service.consume(event)); verify(orders).countContainer("100","MSCU1"); verify(orders,never()).insert(any(LastlegOrder.class)); verify(details,never()).insert(any(LastlegOrderDetail.class)); } @Test void sameEventWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox row=new LastlegEventInbox(); row.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(row); assertThrows(IllegalArgumentException.class,()->service.consume(event)); } + @Test void tamperedPayloadHashIsRejectedBeforeInboxMutation(){ EventEnvelope source=event(); EventEnvelope tampered=new EventEnvelope(source.eventId(),source.eventType(),source.eventVersion(),source.occurredAt(),source.sourceRegion(),source.targetRegion(),source.tenantId(),source.aggregateType(),source.aggregateId(),source.aggregateVersion(),source.handoverVersion(),source.traceId(),source.correlationId(),source.causationId(),source.payload(),"sha256:"+"0".repeat(64)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(tampered)); assertTrue(error.getMessage().contains("payloadHash")); verify(inbox,never()).insert(any(LastlegEventInbox.class)); } + @Test void concurrentDuplicateKeyWithDifferentHashIsRejected(){ EventEnvelope event=event(); LastlegEventInbox raced=new LastlegEventInbox(); raced.setPayloadHash("sha256:"+"0".repeat(64)); when(inbox.selectById(event.eventId())).thenReturn(null,raced); doThrow(new org.springframework.dao.DuplicateKeyException("race")).when(inbox).insert(any(LastlegEventInbox.class)); IllegalArgumentException error=assertThrows(IllegalArgumentException.class,()->service.consume(event)); assertTrue(error.getMessage().contains("摘要冲突")); verify(orders,never()).countContainer(anyString(),anyString()); } @Test void inboundSnapshotUsesCustomerIdAndHasNoPhantomTenantColumn() throws Exception { assertThrows(NoSuchFieldException.class,()->LastlegInboundOrder.class.getDeclaredField("tenantId")); String schema=java.nio.file.Files.readString(java.nio.file.Path.of("src/main/resources/db/lastleg.sql")); diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java index 5e1b9c95d..30bec281f 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/shipping/service/ContainerShippingNodeConsumerTest.java @@ -96,6 +96,21 @@ class ContainerShippingNodeConsumerTest { .hasMessageContaining("摘要冲突"); } + @Test + void tamperedPayloadHashIsRejectedBeforeInboxMutation() { + EventEnvelope source = event("123e4567-e89b-12d3-a456-426614174105", "PICKUP_READY", 80, 5, + Instant.parse("2026-08-21T10:00:00Z")); + EventEnvelope tampered = new EventEnvelope(source.eventId(), source.eventType(), source.eventVersion(), + source.occurredAt(), source.sourceRegion(), source.targetRegion(), source.tenantId(), + source.aggregateType(), source.aggregateId(), source.aggregateVersion(), source.handoverVersion(), + source.traceId(), source.correlationId(), source.causationId(), source.payload(), + "sha256:" + "0".repeat(64)); + + assertThatThrownBy(() -> consumer.consume(tampered)).isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("payloadHash"); + verify(inboxes, never()).insert(any(LastlegEventInbox.class)); + } + private ContainerShippingGate gate(String status, int sequence, long version, Instant actualAt) { ContainerShippingGate gate = new ContainerShippingGate(); gate.setContainerBookingId("CB-1"); gate.setCustomerId("100"); gate.setOceanBookingId("OB-1"); gate.setContainerNo("CONT-1"); diff --git a/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json new file mode 100644 index 000000000..cc5660ecb --- /dev/null +++ b/lastleg-service/src/test/resources/architecture/lastleg-slice-dependencies.json @@ -0,0 +1,57 @@ +{ + "schemaVersion": 1, + "description": "Development-stage Lastleg slice dependency baseline. Existing edges may be removed; new edges require architecture review.", + "allowedSliceEdges": [ + "dispatch-delivery->inbound-pda", + "dispatch-delivery->inventory", + "dispatch-delivery->shared-technical", + "dispatch-delivery->tailleg-handoff", + "dropship->inbound-pda", + "dropship->shared-technical", + "dropship->tailleg-handoff", + "inbound-pda->dropship", + "inbound-pda->inventory", + "inbound-pda->shared-technical", + "inbound-pda->tailleg-handoff", + "inventory->inbound-pda", + "inventory->shared-technical", + "overseas-master-data->shared-technical", + "procurement-trucking-looseboard->overseas-master-data", + "procurement-trucking-looseboard->shared-technical", + "procurement-trucking-looseboard->tailleg-handoff", + "relabel-transfer-discard->dropship", + "relabel-transfer-discard->inventory", + "relabel-transfer-discard->shared-technical", + "relabel-transfer-discard->tailleg-handoff", + "tailleg-handoff->dropship", + "tailleg-handoff->inbound-pda", + "tailleg-handoff->inventory", + "tailleg-handoff->shared-technical" + ], + "requiredClassLocations": { + "com.baiyue.lastleg.masterdata.vehicle.adapter.in.web.PlatformVehicleController": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/in/web/PlatformVehicleController.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleMapper": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleMapper.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehiclePersistenceAdapter": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehiclePersistenceAdapter.java", + "com.baiyue.lastleg.masterdata.vehicle.adapter.out.persistence.VehicleRecord": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/adapter/out/persistence/VehicleRecord.java", + "com.baiyue.lastleg.masterdata.vehicle.application.VehicleApplicationService": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/VehicleApplicationService.java", + "com.baiyue.lastleg.masterdata.vehicle.application.model.VehicleModels": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/model/VehicleModels.java", + "com.baiyue.lastleg.masterdata.vehicle.application.port.out.VehicleRepository": "src/main/java/com/baiyue/lastleg/masterdata/vehicle/application/port/out/VehicleRepository.java" + }, + "applicationDependencyRule": { + "packagePrefix": "com.baiyue.lastleg.masterdata.vehicle.application", + "allowedInternalPrefix": "com.baiyue.lastleg.masterdata.vehicle.application", + "forbiddenImportPrefixes": [ + "org.springframework.web", + "org.apache.ibatis", + "com.baomidou.mybatisplus", + "com.baiyue.lastleg.client", + "com.baiyue.lastleg.config" + ] + }, + "sliceSpecificRules": { + "overseas-master-data": { + "allowedOutgoing": ["shared-technical"], + "allowedIncoming": ["procurement-trucking-looseboard"] + } + } +} diff --git a/overseas-security-service/AGENTS.md b/overseas-security-service/AGENTS.md new file mode 100644 index 000000000..01cb5953f --- /dev/null +++ b/overseas-security-service/AGENTS.md @@ -0,0 +1,14 @@ +# Overseas Security Service 模块指引 + +## 职责边界 + +- 消费 IAM 三类跨区安全事件,维护海外可靠 Inbox 与单调安全投影,并为海外 Gateway 提供内部实时校验。 +- MySQL 是恢复源;Redis 仅为可重建镜像,不能成为授权真相或在失败时默认放行。 +- 不签发 Token、不保存 raw Token/凭据、不承载 Lastleg 业务,也不解释 Event Bridge 的重试状态。 + +## 入口 + +- 内部事件与校验:`controller/OverseasSecurityController.java` +- 幂等、单调投影:`service/SecurityProjectionService.java` +- Token 校验:`service/OverseasSecurityValidationService.java` +- SQL:`src/main/resources/db/overseas-security.sql` diff --git a/overseas-security-service/pom.xml b/overseas-security-service/pom.xml new file mode 100644 index 000000000..a8c5cd7fd --- /dev/null +++ b/overseas-security-service/pom.xml @@ -0,0 +1,22 @@ + + + 4.0.0 + com.baiyue.firstlegbaiyue-firstleg-platform + 1.0.0-SNAPSHOT../pom.xml + overseas-security-service + + com.baiyue.sharedbaiyue-common-core${project.version} + com.baiyue.sharedbaiyue-common-web${project.version} + com.baiyue.sharedbaiyue-common-openapi${project.version} + com.baiyue.sharedcommon-event-contract${project.version} + org.springframework.bootspring-boot-starter-jdbc + org.springframework.bootspring-boot-starter-data-redis + com.mysqlmysql-connector-jruntime + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-discovery + com.alibaba.cloudspring-cloud-starter-alibaba-nacos-config + org.springframework.bootspring-boot-starter-actuator + org.springframework.bootspring-boot-starter-testtest + + org.springframework.bootspring-boot-maven-plugin + diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java new file mode 100644 index 000000000..cfe7c82a6 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/OverseasSecurityApplication.java @@ -0,0 +1,11 @@ +package com.baiyue.overseassecurity; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class OverseasSecurityApplication { + public static void main(String[] args) { + SpringApplication.run(OverseasSecurityApplication.class, args); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java new file mode 100644 index 000000000..05fe60b65 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/controller/OverseasSecurityController.java @@ -0,0 +1,52 @@ +package com.baiyue.overseassecurity.controller; + +import com.baiyue.common.core.api.ApiResponse; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.Acknowledgement; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; +import com.baiyue.overseassecurity.security.InternalServiceTokenGuard; +import com.baiyue.overseassecurity.service.OverseasSecurityValidationService; +import com.baiyue.overseassecurity.service.SecurityProjectionService; +import io.swagger.v3.oas.annotations.Hidden; +import jakarta.validation.Valid; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.time.Instant; + +@Hidden +@RestController +@RequestMapping("/internal/overseas-security/v1") +public class OverseasSecurityController { + private final InternalServiceTokenGuard tokenGuard; + private final SecurityProjectionService projections; + private final OverseasSecurityValidationService validations; + + public OverseasSecurityController(InternalServiceTokenGuard tokenGuard, SecurityProjectionService projections, + OverseasSecurityValidationService validations) { + this.tokenGuard = tokenGuard; + this.projections = projections; + this.validations = validations; + } + + @PostMapping("/events") + public ApiResponse receive( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @RequestBody EventEnvelope envelope) { + tokenGuard.verify(token); + var result = projections.consume(envelope); + return ApiResponse.success(new Acknowledgement(envelope.eventId(), result.result(), Instant.now())); + } + + @PostMapping("/validate") + public ApiResponse validate( + @RequestHeader(value = "X-Internal-Service-Token", required = false) String token, + @Valid @RequestBody ValidationRequest request) { + tokenGuard.verify(token); + return ApiResponse.success(validations.validate(request)); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java new file mode 100644 index 000000000..7560da9b2 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/dto/OverseasSecurityDtos.java @@ -0,0 +1,28 @@ +package com.baiyue.overseassecurity.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +import java.time.Instant; + +public final class OverseasSecurityDtos { + private OverseasSecurityDtos() { } + + public record Acknowledgement(String eventId, String result, Instant receivedAt) { } + + public record ValidationRequest(@NotNull @Min(1) Long userId, + @NotNull @Min(0) Long tenantId, + @NotNull @Min(0) Long tokenVersion, + @NotNull @Min(0) Long permissionVersion) { } + + public record ValidationResponse(boolean valid, FailureReason failureReason) { + public static ValidationResponse accepted() { return new ValidationResponse(true, null); } + public static ValidationResponse rejected(FailureReason reason) { return new ValidationResponse(false, reason); } + } + + public enum FailureReason { + USER_DISABLED, + TENANT_DISABLED, + PERMISSION_VERSION_STALE + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java new file mode 100644 index 000000000..1a6aa6b7c --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/security/InternalServiceTokenGuard.java @@ -0,0 +1,26 @@ +package com.baiyue.overseassecurity.security; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import jakarta.annotation.PostConstruct; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; + +@Component +public class InternalServiceTokenGuard { + @Value("${baiyue.security.internal-service-token:}") private String configuredToken; + private byte[] expected; + + @PostConstruct void initialize() { + expected = configuredToken == null ? new byte[0] : configuredToken.getBytes(StandardCharsets.UTF_8); + if (expected.length < 32) throw new IllegalStateException("INTERNAL_SERVICE_TOKEN 至少需要 32 字节"); + } + + public void verify(String supplied) { + byte[] actual = supplied == null ? new byte[0] : supplied.getBytes(StandardCharsets.UTF_8); + if (!MessageDigest.isEqual(expected, actual)) throw new BizException(ResultCode.FORBIDDEN, "内部服务令牌无效"); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java new file mode 100644 index 000000000..4b7ee66cf --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java @@ -0,0 +1,44 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationResponse; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +public class OverseasSecurityValidationService { + private final JdbcTemplate jdbc; + + public OverseasSecurityValidationService(JdbcTemplate jdbc) { + this.jdbc = jdbc; + } + + public ValidationResponse validate(ValidationRequest request) { + String userStatus = status("USER", String.valueOf(request.userId())); + if (userStatus != null && !"ACTIVE".equals(userStatus)) { + return ValidationResponse.rejected(FailureReason.USER_DISABLED); + } + String tenantStatus = status("TENANT", String.valueOf(request.tenantId())); + if (tenantStatus != null && !"ACTIVE".equals(tenantStatus)) { + return ValidationResponse.rejected(FailureReason.TENANT_DISABLED); + } + Long projectedVersion = jdbc.queryForObject( + "SELECT MAX(permission_version) FROM overseas_security_projection " + + "WHERE aggregate_id=? AND aggregate_type IN ('USER','AUTHORIZATION')", + Long.class, String.valueOf(request.userId())); + if (projectedVersion != null && request.permissionVersion() < projectedVersion) { + return ValidationResponse.rejected(FailureReason.PERMISSION_VERSION_STALE); + } + return ValidationResponse.accepted(); + } + + private String status(String type, String id) { + List values = jdbc.query("SELECT security_status FROM overseas_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=?", + (rs, row) -> rs.getString(1), type, id); + return values.isEmpty() ? null : values.get(0); + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java new file mode 100644 index 000000000..958e5e44b --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/ProjectionMonotonicity.java @@ -0,0 +1,19 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; + +import java.util.Objects; + +final class ProjectionMonotonicity { + private ProjectionMonotonicity() { } + + static Decision decide(Long currentVersion, String currentHash, long incomingVersion, String incomingHash) { + if (currentVersion == null || incomingVersion > currentVersion) return Decision.APPLY; + if (incomingVersion < currentVersion) return Decision.STALE; + if (Objects.equals(currentHash, incomingHash)) return Decision.DUPLICATE_VERSION; + throw new BizException(ResultCode.CONFLICT, "相同聚合版本的安全事件 payloadHash 冲突"); + } + + enum Decision { APPLY, STALE, DUPLICATE_VERSION } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java new file mode 100644 index 000000000..e022e01cb --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java @@ -0,0 +1,36 @@ +package com.baiyue.overseassecurity.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +/** 非授权真相的可重建 Redis 镜像;失败不影响已提交的 MySQL 投影。 */ +@Component +@Slf4j +public class SecurityProjectionRedisMirror { + private final StringRedisTemplate redis; + private final ObjectMapper objectMapper; + @Value("${baiyue.security.permission-snapshot-key-prefix:baiyue:permission-snapshot}") + private String keyPrefix; + + public SecurityProjectionRedisMirror(StringRedisTemplate redis, ObjectMapper objectMapper) { + this.redis = redis; + this.objectMapper = objectMapper; + } + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void mirror(SecurityProjectionService.ProjectionChanged event) { + var value = event.projection(); + try { + redis.opsForValue().set(keyPrefix + ':' + value.aggregateType() + ':' + value.aggregateId(), + objectMapper.writeValueAsString(value)); + } catch (Exception exception) { + log.warn("海外安全投影 Redis 镜像写入失败 aggregateType={}, aggregateId={}", + value.aggregateType(), value.aggregateId()); + } + } +} diff --git a/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java new file mode 100644 index 000000000..d76d77932 --- /dev/null +++ b/overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java @@ -0,0 +1,153 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.common.event.EventEnvelope; +import com.baiyue.common.event.EventTypes; +import com.baiyue.common.event.IamSecurityEventsV1; +import com.baiyue.common.event.RegionCode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DuplicateKeyException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Objects; + +@Service +public class SecurityProjectionService { + private static final List TYPES = List.of(EventTypes.IAM_USER_STATUS_CHANGED, + EventTypes.IAM_TENANT_STATUS_CHANGED, EventTypes.IAM_PERMISSION_VERSION_CHANGED); + private final JdbcTemplate jdbc; + private final ObjectMapper objectMapper; + private final ApplicationEventPublisher events; + + public SecurityProjectionService(JdbcTemplate jdbc, ObjectMapper objectMapper, ApplicationEventPublisher events) { + this.jdbc = jdbc; + this.objectMapper = objectMapper; + this.events = events; + } + + @Transactional + public ConsumeResult consume(EventEnvelope envelope) { + envelope.requireValid(); + requireSecurityEnvelope(envelope); + String payloadJson = envelope.payload().toString(); + try { + jdbc.update("INSERT INTO overseas_security_event_inbox(event_id,event_type,payload_hash,payload_json,status,received_at) " + + "VALUES (?,?,?,?, 'RECEIVED', NOW(3))", envelope.eventId(), envelope.eventType(), + envelope.payloadHash(), payloadJson); + } catch (DuplicateKeyException duplicate) { + InboxRow existing = jdbc.queryForObject("SELECT event_type,payload_hash,status FROM overseas_security_event_inbox " + + "WHERE event_id=? FOR UPDATE", + (rs, row) -> new InboxRow(rs.getString(1), rs.getString(2), rs.getString(3)), + envelope.eventId()); + if (existing == null || !Objects.equals(existing.eventType(), envelope.eventType()) + || !Objects.equals(existing.payloadHash(), envelope.payloadHash())) { + throw new BizException(ResultCode.CONFLICT, "相同 eventId 的 payloadHash 冲突"); + } + if ("PROCESSED".equals(existing.status())) return new ConsumeResult("DUPLICATE", null); + } + + ProjectionInput input = input(envelope); + ProjectionRow current = findForUpdate(input.aggregateType(), input.aggregateId()); + ProjectionMonotonicity.Decision decision = ProjectionMonotonicity.decide( + current == null ? null : current.aggregateVersion(), current == null ? null : current.payloadHash(), + input.aggregateVersion(), input.payloadHash()); + String result = decision.name(); + if (decision == ProjectionMonotonicity.Decision.APPLY) { + upsert(input); + result = "APPLIED"; + events.publishEvent(new ProjectionChanged(input)); + } + jdbc.update("UPDATE overseas_security_event_inbox SET status='PROCESSED',result=?,processed_at=NOW(3) " + + "WHERE event_id=?", result, envelope.eventId()); + return new ConsumeResult(result, input); + } + + private void requireSecurityEnvelope(EventEnvelope envelope) { + if (!TYPES.contains(envelope.eventType()) || envelope.eventVersion() != IamSecurityEventsV1.VERSION + || envelope.sourceRegion() != RegionCode.CN || envelope.targetRegion() != RegionCode.OVERSEAS) { + throw new BizException(ResultCode.BAD_REQUEST, "仅接受 CN→OVERSEAS IAM V1 安全事件"); + } + } + + private ProjectionInput input(EventEnvelope envelope) { + try { + return switch (envelope.eventType()) { + case EventTypes.IAM_USER_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.UserStatusChanged.class); + requireIdentity(envelope, "USER", value.authUserId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("USER", String.valueOf(value.authUserId()), value.tenantId(), + value.status(), value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + envelope.eventId(), value.changedAt()); + } + case EventTypes.IAM_TENANT_STATUS_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.TenantStatusChanged.class); + requireIdentity(envelope, "TENANT", value.tenantId(), value.tenantId()); + requirePayload(value.status(), value.changedAt(), envelope.aggregateVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("TENANT", String.valueOf(value.tenantId()), value.tenantId(), + value.status(), null, envelope.aggregateVersion(), envelope.payloadHash(), envelope.eventId(), + value.changedAt()); + } + case EventTypes.IAM_PERMISSION_VERSION_CHANGED -> { + var value = objectMapper.treeToValue(envelope.payload(), IamSecurityEventsV1.PermissionVersionChanged.class); + requireIdentity(envelope, "AUTHORIZATION", value.authUserId(), value.tenantId()); + requirePayload(value.appCode(), value.changedAt(), value.permissionVersion(), envelope.aggregateVersion()); + yield new ProjectionInput("AUTHORIZATION", String.valueOf(value.authUserId()), value.tenantId(), + null, value.permissionVersion(), envelope.aggregateVersion(), envelope.payloadHash(), + envelope.eventId(), value.changedAt()); + } + default -> throw new IllegalArgumentException("unsupported type"); + }; + } catch (BizException exception) { + throw exception; + } catch (Exception exception) { + throw new BizException(ResultCode.BAD_REQUEST, "IAM 安全事件 payload 无效"); + } + } + + private static void requireIdentity(EventEnvelope envelope, String aggregateType, long aggregateId, long tenantId) { + if (!aggregateType.equals(envelope.aggregateType()) + || !String.valueOf(aggregateId).equals(envelope.aggregateId()) + || !String.valueOf(tenantId).equals(envelope.tenantId())) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件信封与 payload 身份不一致"); + } + } + + private static void requirePayload(String requiredText, Instant changedAt, long payloadVersion, long aggregateVersion) { + if (requiredText == null || requiredText.isBlank() || changedAt == null || payloadVersion != aggregateVersion) { + throw new BizException(ResultCode.CONFLICT, "IAM 安全事件 payload 版本或必填字段无效"); + } + } + + private ProjectionRow findForUpdate(String type, String id) { + List rows = jdbc.query("SELECT aggregate_version,payload_hash FROM overseas_security_projection " + + "WHERE aggregate_type=? AND aggregate_id=? FOR UPDATE", + (rs, row) -> new ProjectionRow(rs.getLong(1), rs.getString(2)), type, id); + return rows.isEmpty() ? null : rows.get(0); + } + + private void upsert(ProjectionInput value) { + jdbc.update("INSERT INTO overseas_security_projection(aggregate_type,aggregate_id,tenant_id,security_status," + + "permission_version,aggregate_version,payload_hash,last_event_id,changed_at,updated_at) " + + "VALUES (?,?,?,?,?,?,?,?,?,NOW(3)) ON DUPLICATE KEY UPDATE tenant_id=VALUES(tenant_id)," + + "security_status=VALUES(security_status),permission_version=VALUES(permission_version)," + + "aggregate_version=VALUES(aggregate_version),payload_hash=VALUES(payload_hash)," + + "last_event_id=VALUES(last_event_id),changed_at=VALUES(changed_at),updated_at=NOW(3)", + value.aggregateType(), value.aggregateId(), value.tenantId(), value.status(), value.permissionVersion(), + value.aggregateVersion(), value.payloadHash(), value.eventId(), value.changedAt()); + } + + private record InboxRow(String eventType, String payloadHash, String status) { } + private record ProjectionRow(long aggregateVersion, String payloadHash) { } + public record ConsumeResult(String result, ProjectionInput projection) { } + public record ProjectionChanged(ProjectionInput projection) { } + public record ProjectionInput(String aggregateType, String aggregateId, long tenantId, String status, + Long permissionVersion, long aggregateVersion, String payloadHash, + String eventId, Instant changedAt) { } +} diff --git a/overseas-security-service/src/main/resources/application.yml b/overseas-security-service/src/main/resources/application.yml new file mode 100644 index 000000000..6817568d2 --- /dev/null +++ b/overseas-security-service/src/main/resources/application.yml @@ -0,0 +1,28 @@ +server: + port: ${OVERSEAS_SECURITY_PORT:9307} +spring: + application.name: overseas-security-service + config.import: + - optional:nacos:baiyue-common.yml?group=BAIYUE&refreshEnabled=true + - optional:nacos:${spring.application.name}.yml?group=BAIYUE&refreshEnabled=true + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: ${OVERSEAS_SECURITY_DB_URL} + username: ${OVERSEAS_SECURITY_DB_USERNAME} + password: ${OVERSEAS_SECURITY_DB_PASSWORD} + data.redis: + host: ${REDIS_HOST:127.0.0.1} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + database: ${REDIS_DATABASE:0} + cloud.nacos: + server-addr: ${NACOS_SERVER_ADDR:127.0.0.1:8848} + username: ${NACOS_USERNAME:nacos} + password: ${NACOS_PASSWORD:nacos} + discovery: + group: ${NACOS_GROUP:BAIYUE} + namespace: ${NACOS_NAMESPACE:prod-overseas} +baiyue.security: + internal-service-token: ${INTERNAL_SERVICE_TOKEN} + permission-snapshot-key-prefix: ${PERMISSION_SNAPSHOT_KEY_PREFIX:baiyue:permission-snapshot} +management.endpoints.web.exposure.include: health,info,prometheus diff --git a/overseas-security-service/src/main/resources/db/overseas-security.sql b/overseas-security-service/src/main/resources/db/overseas-security.sql new file mode 100644 index 000000000..7553fba93 --- /dev/null +++ b/overseas-security-service/src/main/resources/db/overseas-security.sql @@ -0,0 +1,31 @@ +CREATE DATABASE IF NOT EXISTS baiyue_overseas_security DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_ai_ci; +USE baiyue_overseas_security; + +CREATE TABLE IF NOT EXISTS overseas_security_event_inbox ( + event_id VARCHAR(64) NOT NULL COMMENT '跨区事件ID', + event_type VARCHAR(96) NOT NULL COMMENT 'IAM安全事件类型', + payload_hash CHAR(64) NOT NULL COMMENT '规范化Payload SHA-256', + payload_json JSON NOT NULL COMMENT '冻结事件Payload', + status VARCHAR(16) NOT NULL COMMENT 'RECEIVED/PROCESSED', + result VARCHAR(32) NULL COMMENT 'APPLIED/STALE/DUPLICATE_VERSION', + received_at DATETIME(3) NOT NULL, + processed_at DATETIME(3) NULL, + PRIMARY KEY (event_id), + KEY idx_security_inbox_type_time (event_type, received_at) +) ENGINE=InnoDB COMMENT='海外IAM安全事件消费者Inbox'; + +CREATE TABLE IF NOT EXISTS overseas_security_projection ( + aggregate_type VARCHAR(32) NOT NULL COMMENT 'USER/TENANT/AUTHORIZATION', + aggregate_id VARCHAR(64) NOT NULL COMMENT '用户或客户聚合ID', + tenant_id BIGINT NOT NULL COMMENT '平台0或客户ID', + security_status VARCHAR(24) NULL COMMENT 'ACTIVE/DISABLED等当前状态', + permission_version BIGINT NULL COMMENT '当前单调权限版本', + aggregate_version BIGINT NOT NULL COMMENT '事件聚合单调版本', + payload_hash CHAR(64) NOT NULL COMMENT '当前版本Payload哈希', + last_event_id VARCHAR(64) NOT NULL COMMENT '最后应用事件ID', + changed_at DATETIME(3) NOT NULL COMMENT '国内IAM事实发生时间', + updated_at DATETIME(3) NOT NULL, + PRIMARY KEY (aggregate_type, aggregate_id), + KEY idx_security_projection_tenant (tenant_id), + KEY idx_security_projection_permission (aggregate_id, permission_version) +) ENGINE=InnoDB COMMENT='海外IAM安全状态与权限版本投影'; diff --git a/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml new file mode 100644 index 000000000..e228e9386 --- /dev/null +++ b/overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml @@ -0,0 +1,26 @@ +openapi: 3.0.3 +info: + title: Overseas Security Projection Internal API + version: 1.0.0 +paths: + /internal/overseas-security/v1/events: + post: + summary: Consume one IAM security EventEnvelope + security: [{ internalServiceToken: [] }] + responses: + '200': + description: "Acknowledgement contains eventId, result and receivedAt" + /internal/overseas-security/v1/validate: + post: + summary: Validate locally verified overseas JWT claim versions + description: Accepts userId, tenantId, tokenVersion and permissionVersion; raw tokens and credentials are forbidden. + security: [{ internalServiceToken: [] }] + responses: + '200': + description: "valid plus optional USER_DISABLED, TENANT_DISABLED or PERMISSION_VERSION_STALE reason" +components: + securitySchemes: + internalServiceToken: + type: apiKey + in: header + name: X-Internal-Service-Token diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java new file mode 100644 index 000000000..b626bb89b --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/OverseasSecurityContractTest.java @@ -0,0 +1,51 @@ +package com.baiyue.overseassecurity; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OverseasSecurityContractTest { + @Test void sqlFreezesReliableInboxProjectionAndRedisRecoveryBoundary() throws Exception { + String sql = read("overseas-security-service/src/main/resources/db/overseas-security.sql"); + String service = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionService.java"); + String validation = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationService.java"); + String mirror = read("overseas-security-service/src/main/java/com/baiyue/overseassecurity/service/SecurityProjectionRedisMirror.java"); + assertThat(sql).contains("PRIMARY KEY (event_id)", "payload_hash CHAR(64)", + "PRIMARY KEY (aggregate_type, aggregate_id)", "aggregate_version BIGINT", "permission_version BIGINT"); + assertThat(service).contains("ProjectionMonotonicity.decide", "Decision.APPLY"); + assertThat(mirror).contains("TransactionPhase.AFTER_COMMIT", "Redis 镜像写入失败"); + assertThat(validation).contains("SELECT MAX(permission_version)", "USER_DISABLED", + "TENANT_DISABLED", "PERMISSION_VERSION_STALE"); + } + + @Test void eventBridgeRoutesIamTypesWithoutInspectingTheirPayload() throws Exception { + String dispatcher = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/OverseasEventDispatcher.java"); + String client = read("event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java"); + String lastleg = read("lastleg-service/src/main/java/com/baiyue/lastleg/controller/LastlegOrderController.java"); + assertThat(dispatcher).contains("IAM_USER_STATUS_CHANGED", "IAM_TENANT_STATUS_CHANGED", + "IAM_PERMISSION_VERSION_CHANGED", "securityProjectionEventClient.deliver(event)"); + assertThat(dispatcher).doesNotContain("IamSecurityEventsV1", "permissionVersion()", "status()"); + assertThat(client).contains("path(\"data\").path(\"eventId\")", "row.getEventId().equals") + .doesNotContain("IamSecurityEventsV1"); + assertThat(lastleg).contains("Lastleg 不消费该事件类型") + .doesNotContain("? shippingNodeConsumer.consume(event) : consumer.consume(event)"); + } + + @Test void internalOpenApiNeverAcceptsRawTokensOrCredentials() throws Exception { + String openapi = read("overseas-security-service/src/main/resources/openapi/internal-overseas-security-v1.yaml"); + assertThat(openapi).contains("/internal/overseas-security/v1/events:", + "/internal/overseas-security/v1/validate:", "X-Internal-Service-Token", + "userId, tenantId, tokenVersion and permissionVersion"); + assertThat(openapi).doesNotContain("accessToken", "refreshToken", "password:", "Authorization"); + } + + private static String read(String relative) throws Exception { + Path path = Path.of(relative); + if (!Files.exists(path)) path = Path.of("..").resolve(relative); + return Files.readString(path, StandardCharsets.UTF_8); + } +} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java new file mode 100644 index 000000000..f38834f99 --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/OverseasSecurityValidationServiceTest.java @@ -0,0 +1,36 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.FailureReason; +import com.baiyue.overseassecurity.dto.OverseasSecurityDtos.ValidationRequest; +import org.junit.jupiter.api.Test; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class OverseasSecurityValidationServiceTest { + private final JdbcTemplate jdbc = mock(JdbcTemplate.class); + private final OverseasSecurityValidationService service = new OverseasSecurityValidationService(jdbc); + + @Test void disabledUserAndTenantFailClosed() { + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenReturn(List.of("DISABLED")); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) + .isEqualTo(FailureReason.USER_DISABLED); + } + + @Test void stalePermissionVersionFailsAndCurrentVersionPasses() { + when(jdbc.query(anyString(), any(RowMapper.class), any(Object[].class))) + .thenReturn(List.of(), List.of()); + when(jdbc.queryForObject(anyString(), any(Class.class), any(Object[].class))).thenReturn(9L, 9L); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 8L)).failureReason()) + .isEqualTo(FailureReason.PERMISSION_VERSION_STALE); + assertThat(service.validate(new ValidationRequest(42L, 0L, 3L, 9L)).valid()).isTrue(); + } +} diff --git a/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java new file mode 100644 index 000000000..89560963c --- /dev/null +++ b/overseas-security-service/src/test/java/com/baiyue/overseassecurity/service/ProjectionMonotonicityTest.java @@ -0,0 +1,28 @@ +package com.baiyue.overseassecurity.service; + +import com.baiyue.common.core.exception.BizException; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class ProjectionMonotonicityTest { + @Test void appliesFirstAndNewerVersions() { + assertThat(ProjectionMonotonicity.decide(null, null, 1, "hash-1")) + .isEqualTo(ProjectionMonotonicity.Decision.APPLY); + assertThat(ProjectionMonotonicity.decide(1L, "hash-1", 2, "hash-2")) + .isEqualTo(ProjectionMonotonicity.Decision.APPLY); + } + + @Test void ignoresOlderAndSameHashVersions() { + assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 2, "hash-2")) + .isEqualTo(ProjectionMonotonicity.Decision.STALE); + assertThat(ProjectionMonotonicity.decide(3L, "hash-3", 3, "hash-3")) + .isEqualTo(ProjectionMonotonicity.Decision.DUPLICATE_VERSION); + } + + @Test void rejectsSameVersionWithDifferentHash() { + assertThatThrownBy(() -> ProjectionMonotonicity.decide(3L, "hash-a", 3, "hash-b")) + .isInstanceOf(BizException.class).hasMessageContaining("payloadHash 冲突"); + } +} diff --git a/pom.xml b/pom.xml index 0e706d52c..2cd812fb5 100644 --- a/pom.xml +++ b/pom.xml @@ -39,6 +39,7 @@ common-event-contract gateway gateway-overseas + overseas-security-service auth iam order @@ -66,6 +67,7 @@ 3.13.0 3.4.1 0.8.12 + 4.9.8.5 1.5.0 2.3.3 @@ -168,4 +170,31 @@ + + + static-analysis-report + + + + com.github.spotbugs + spotbugs-maven-plugin + ${spotbugs-maven-plugin.version} + + true + true + false + true + + + + 生成静态分析报告 + verify + spotbugs + + + + + + + diff --git a/scripts/java-topology-contract.test.mjs b/scripts/java-topology-contract.test.mjs index 371e7053f..6c094c6e3 100644 --- a/scripts/java-topology-contract.test.mjs +++ b/scripts/java-topology-contract.test.mjs @@ -62,13 +62,13 @@ const parseCompose = (yaml) => { })) } -test('root POM freezes 22 modules and service-map freezes 15 executable services', async () => { +test('root POM freezes 23 modules and service-map freezes 16 executable services', async () => { const modules = parseRootModules(await read('pom.xml')) const services = parseServiceMap(await read('deploy/service-map.yml')) - assert.equal(modules.length, 22) - assert.equal(new Set(modules).size, 22) - assert.equal(services.size, 15) + assert.equal(modules.length, 23) + assert.equal(new Set(modules).size, 23) + assert.equal(services.size, 16) const bootModules = [] for (const module of modules) { @@ -155,6 +155,32 @@ test('Maven verify publishes JaCoCo reports without enforcing a threshold', asyn assert.match(gitlab, /^ - "\*\*\/target\/jacoco\.exec"$/m) }) +test('SpotBugs remains an opt-in non-blocking report without quality thresholds', async () => { + const pom = await read('pom.xml') + const gitlab = await read('.gitlab-ci.yml') + const profile = pom.match(/\s*static-analysis-report<\/id>[\s\S]*?<\/profile>/)?.[0] ?? '' + const spotbugs = profile.match(/\s*com\.github\.spotbugs<\/groupId>[\s\S]*?<\/plugin>/)?.[0] ?? '' + const reportJob = gitlab.match(/^静态分析报告:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const verifyJob = gitlab.match(/^校验与测试:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' + const packageJob = gitlab.match(/^打包服务:\n([\s\S]*)/m)?.[1] ?? '' + + assert.match(pom, /4\.9\.8\.5<\/spotbugs-maven-plugin\.version>/) + assert.match(spotbugs, /spotbugs-maven-plugin<\/artifactId>/) + assert.match(spotbugs, /verify<\/phase>/) + assert.match(spotbugs, /spotbugs<\/goal>/) + assert.match(spotbugs, /true<\/xmlOutput>/) + assert.match(spotbugs, /true<\/htmlOutput>/) + assert.match(spotbugs, /false<\/failOnError>/) + assert.doesNotMatch(spotbugs, /check<\/goal>||/) + assert.doesNotMatch(pom.replace(profile, ''), /spotbugs-maven-plugin<\/artifactId>/) + assert.match(reportJob, /^ allow_failure: true$/m) + assert.match(reportJob, /-Pstatic-analysis-report/) + assert.match(reportJob, /^ - "\*\*\/target\/spotbugs\*\.xml"$/m) + assert.match(reportJob, /^ - "\*\*\/target\/site\/spotbugs\.html"$/m) + assert.doesNotMatch(verifyJob, /static-analysis-report|spotbugs/i) + assert.doesNotMatch(packageJob, /^ - 静态分析报告$/m) +}) + test('regional Compose and Nacos manifests cover the service-map topology', async () => { const services = parseServiceMap(await read('deploy/service-map.yml')) const composeByRegion = new Map([ diff --git a/scripts/overseas-deployment-contract.test.mjs b/scripts/overseas-deployment-contract.test.mjs index 82c4591fd..b1f89decb 100644 --- a/scripts/overseas-deployment-contract.test.mjs +++ b/scripts/overseas-deployment-contract.test.mjs @@ -8,16 +8,18 @@ const read = async (path) => (await readFile(new URL(`../${path}`, import.meta.u test('Jenkins pipelines recognize all overseas deployable modules', async () => { const controller = await read('Jenkinsfile') const service = await read('Jenkinsfile.service') - for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service']) { + for (const module of ['gateway-overseas', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service']) { assert.match(controller, new RegExp(`'${module}'`)) assert.match(service, new RegExp(`'${module}'`)) } assert.match(controller, /'gateway-overseas': 'SERVICE_GATEWAY_OVERSEAS'/) assert.match(controller, /'lastleg-service': 'SERVICE_LASTLEG'/) assert.match(controller, /'warehouse-overseas-service': 'SERVICE_WAREHOUSE_OVERSEAS'/) + assert.match(controller, /'overseas-security-service': 'SERVICE_OVERSEAS_SECURITY'/) assert.match(service, /gateway-overseas\/target\/gateway-overseas-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /lastleg-service\/target\/lastleg-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(service, /warehouse-overseas-service\/target\/warehouse-overseas-service-1\.0\.0-SNAPSHOT\.jar/) + assert.match(service, /overseas-security-service\/target\/overseas-security-service-1\.0\.0-SNAPSHOT\.jar/) assert.match(controller, /EVENT_BRIDGE_REGION/) assert.match(service, /EVENT_BRIDGE_REGION_OVERRIDE=\$\{bridgeRegion\}/) assert.match(controller, /choices: \['BOTH', 'CN', 'OVERSEAS'\]/) @@ -30,10 +32,12 @@ test('service map freezes unique internal ports and moves event bridge away from assert.match(map, /^ gateway-overseas:[\s\S]*?port: 9300$/m) assert.match(map, /^ lastleg-service:[\s\S]*?port: 9303$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?port: 9305$/m) + assert.match(map, /^ overseas-security-service:[\s\S]*?port: 9307$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?port: 9312$/m) assert.match(map, /^ gateway-overseas:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ lastleg-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ warehouse-overseas-service:[\s\S]*?regions: \[OVERSEAS\]$/m) + assert.match(map, /^ overseas-security-service:[\s\S]*?regions: \[OVERSEAS\]$/m) assert.match(map, /^ event-bridge-service:[\s\S]*?regions: \[CN, OVERSEAS\]$/m) }) @@ -46,6 +50,7 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /^ gateway-overseas:\s*$/m) assert.match(compose, /^ lastleg-service:\s*$/m) assert.match(compose, /^ warehouse-overseas-service:\s*$/m) + assert.match(compose, /^ overseas-security-service:\s*$/m) assert.match(compose, /^ event-bridge-service:\s*$/m) assert.match(compose, /EVENT_BRIDGE_OVERSEAS_PORT:-9313}:9312/) assert.match(compose, /INTERNAL_SERVICE_TOKEN: \$\{INTERNAL_SERVICE_TOKEN\}/) @@ -53,6 +58,8 @@ test('overseas compose is isolated from CN defaults and disables implicit schema assert.match(compose, /EVENT_BRIDGE_DB_URL: \$\{EVENT_BRIDGE_OVERSEAS_DB_URL\}/) assert.match(compose, /EVENT_BRIDGE_DB_USERNAME: \$\{EVENT_BRIDGE_OVERSEAS_DB_USERNAME\}/) assert.match(compose, /EVENT_BRIDGE_DB_PASSWORD: \$\{EVENT_BRIDGE_OVERSEAS_DB_PASSWORD\}/) + assert.match(compose, /OVERSEAS_SECURITY_DB_URL: \$\{OVERSEAS_SECURITY_DB_URL\}/) + assert.match(compose, /OVERSEAS_SECURITY_EVENT_CONSUMER_URL:/) assert.match(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.match(compose, /FIRSTLEG_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_TARGET_URL:-http:\/\/event-bridge-cn:9312\}/) assert.doesNotMatch(compose, /CN_EVENT_BRIDGE_URL: \$\{CN_EVENT_BRIDGE_URL:/) diff --git a/scripts/provision-development-service-jobs.groovy b/scripts/provision-development-service-jobs.groovy index e4c7ced35..baa57b467 100644 --- a/scripts/provision-development-service-jobs.groovy +++ b/scripts/provision-development-service-jobs.groovy @@ -6,7 +6,7 @@ boolean applyChanges = false String templateName = 'baiyue-firstleg-dev-print' List serviceModules = [ 'gateway', 'gateway-overseas', 'auth', 'iam', 'firstleg', 'finance', 'order', 'warehouse', 'file', 'print', - 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'event-bridge-service', 'audit-service' + 'integration-service', 'lastleg-service', 'warehouse-overseas-service', 'overseas-security-service', 'event-bridge-service', 'audit-service' ] Jenkins jenkins = Jenkins.get() diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs new file mode 100644 index 000000000..0e829bb6f --- /dev/null +++ b/scripts/synchronous-dependency-baseline.mjs @@ -0,0 +1,230 @@ +export const CONTEXTS = Object.freeze(['REQUEST', 'BACKGROUND', 'INFRASTRUCTURE', 'EXTERNAL']) +export const ACCESS_MODES = Object.freeze(['READ', 'WRITE', 'READ_WRITE']) +export const IDEMPOTENCY = Object.freeze([ + 'SAFE_READ', 'IDEMPOTENCY_KEY', 'BUSINESS_KEY', 'EVENT_ID', 'STATE_CONVERGENT', 'NONE', 'MIXED' +]) + +const feign = (path, source, target, contexts, access, idempotency, note) => ({ + id: `FEIGN:${path}`, + transport: 'FEIGN', path, source, target, contexts, access, idempotency, note +}) + +const http = (path, source, target, contexts, access, idempotency, note) => ({ + id: `HTTP:${path}`, + transport: 'HTTP', path, source, target, contexts, access, idempotency, note +}) + +export const SYNCHRONOUS_CLIENTS = Object.freeze([ + feign('auth/src/main/java/com/baiyue/auth/client/IamAuthorizationClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 读取授权快照,不写 IAM'), + feign('auth/src/main/java/com/baiyue/auth/client/IamOnboardingClient.java', + 'auth-service', 'iam-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '注册/补偿以 authUserId 收敛,不自动重试'), + + feign('file/src/main/java/com/baiyue/file/client/FinanceExportProviderClient.java', + 'file-service', 'finance-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/FirstlegExportProviderClient.java', + 'file-service', 'firstleg-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamExportProviderClient.java', + 'file-service', 'iam-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/IamQualificationClient.java', + 'file-service', 'iam-service', ['REQUEST'], 'WRITE', 'STATE_CONVERGENT', '资质登记/替换按客户与资质类型收敛,无通用幂等头'), + feign('file/src/main/java/com/baiyue/file/client/OrderExportProviderClient.java', + 'file-service', 'order-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WarehouseExportProviderClient.java', + 'file-service', 'warehouse-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '导出 Worker 分批读取'), + feign('file/src/main/java/com/baiyue/file/client/WeightOcrClient.java', + 'file-service', 'integration-service', ['REQUEST'], 'READ', 'NONE', '上传线程内调用 OCR;第三方调用不可假定免费幂等'), + + feign('finance/src/main/java/com/baiyue/finance/client/FinanceFileClient.java', + 'finance-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '元数据 GET 可重复,凭证上传无幂等键'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegCostSnapshotClient.java', + 'finance-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取整柜成本分摊快照'), + feign('finance/src/main/java/com/baiyue/finance/client/FirstlegFeeSourceClient.java', + 'finance-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'MIXED', + '事件消费回读快照;平台作废命令携带 Idempotency-Key 与请求哈希'), + feign('finance/src/main/java/com/baiyue/finance/client/IamCustomerClient.java', + 'finance-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户主档快照'), + feign('finance/src/main/java/com/baiyue/finance/client/WarehouseSupplierClient.java', + 'finance-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取并校验供应商快照'), + + feign('firstleg/src/main/java/com/baiyue/firstleg/client/CarrierScheduleIntegrationClient.java', + 'firstleg-service', 'integration-service', ['BACKGROUND'], 'READ', 'SAFE_READ', '船节点定时任务查询,不属于用户请求'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/EventBridgeClient.java', + 'firstleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '装柜/船节点 Outbox 按 eventId 投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FeePaymentVoucherFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '校验付款凭证元数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/FinanceFeeSourceEventClient.java', + 'firstleg-service', 'finance-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '费用 Outbox 按事件幂等键投递'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/IamCustomerSnapshotClient.java', + 'firstleg-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户名称与状态快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingDocumentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据/下载可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/LoadingEvidenceFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取装柜证据元数据/内容'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/MeasurementImageClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '图片上传/作废非安全重放,查询可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取订单货物/来源快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java', + 'firstleg-service', 'order-service', ['REQUEST'], 'WRITE', 'IDEMPOTENCY_KEY', 'PDA 结算拆单携带 idempotencyKey/operationId'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '价目文件上传无幂等键,读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PriceBookWarehouseClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库/渠道主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/PrintPdfClient.java', + 'firstleg-service', 'print-service', ['REQUEST'], 'READ', 'SAFE_READ', '无状态 PDF 渲染,POST 为计算型读取'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/TruckAppointmentFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '附件上传无幂等键,元数据读取可重复'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseMasterDataClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehousePalletLocationClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ_WRITE', 'MIXED', + '查询可重复;预占/选择释放/转移/结算使用业务幂等键,普通确认/释放按状态收敛'), + feign('firstleg/src/main/java/com/baiyue/firstleg/client/WarehouseSupplierClient.java', + 'firstleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取供应商快照'), + feign('firstleg/src/main/java/com/baiyue/firstleg/customs/client/CustomsFileClient.java', + 'firstleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '报关文件上传无幂等键,读取可重复'), + + feign('iam/src/main/java/com/baiyue/iam/client/AuthAccountClient.java', + 'iam-service', 'auth-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '账号查询可重复,写操作按账号/命令业务键收敛'), + feign('iam/src/main/java/com/baiyue/iam/client/CustomerDimensionRuleClient.java', + 'iam-service', 'firstleg-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/OverseasStorageFeeClient.java', + 'iam-service', 'warehouse-service', ['REQUEST'], 'WRITE', 'BUSINESS_KEY', '初始化/补偿使用 initializationKey'), + feign('iam/src/main/java/com/baiyue/iam/client/SecurityEventBridgeClient.java', + 'iam-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全 Outbox 按 eventId 投递'), + + feign('lastleg-service/src/main/java/com/baiyue/lastleg/client/EventBridgeClient.java', + 'lastleg-service', 'event-bridge-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', '卸柜完成 Outbox 按 eventId 投递'), + feign('lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClient.java', + 'lastleg-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '报价创建时读取国内合作仓快照'), + + feign('order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order-service', 'firstleg-service', ['REQUEST', 'BACKGROUND'], 'READ_WRITE', 'BUSINESS_KEY', + '列表查询/缺失修复在线执行;审核建单由 Outbox 投递,按 orderId/inboundCode 收敛'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 校验计价渠道,不写履约状态'), + feign('order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java', + 'order-service', 'firstleg-service', ['REQUEST'], 'READ', 'SAFE_READ', 'POST 查询统一订单投影'), + feign('order/src/main/java/com/baiyue/order/client/ProductImageClient.java', + 'order-service', 'file-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取商品图片元数据'), + feign('order/src/main/java/com/baiyue/order/client/TenantEligibilityClient.java', + 'order-service', 'iam-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取客户下单资格'), + feign('order/src/main/java/com/baiyue/order/client/WarehouseClient.java', + 'order-service', 'warehouse-service', ['REQUEST'], 'READ', 'SAFE_READ', '读取仓库主数据'), + + http('common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', + 'common-audit', 'audit-service', ['INFRASTRUCTURE'], 'WRITE', 'NONE', '同步审计投递失败当前仅记录告警'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', + 'event-bridge-service', 'firstleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', + 'event-bridge-service', 'lastleg-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'Inbox 租约投递,目标按 eventId 幂等'), + http('event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpSecurityProjectionEventClient.java', + 'event-bridge-service', 'overseas-security-service', ['BACKGROUND'], 'WRITE', 'EVENT_ID', 'IAM 安全事件投影按 eventId/版本单调幂等'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpOverseasSecurityValidationClient.java', + 'gateway-overseas', 'overseas-security-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST 实时校验,失败关闭'), + http('gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', + 'gateway-overseas', 'auth-service', ['INFRASTRUCTURE'], 'READ', 'SAFE_READ', 'POST PDA 会话校验,失败关闭'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '查询可重复;写操作按 business/reservation key 或状态收敛'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', + 'lastleg-service', 'warehouse-overseas-service', ['REQUEST'], 'READ_WRITE', 'IDEMPOTENCY_KEY', '托盘变更/补偿使用 operationKey 与请求哈希'), + http('lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', + 'lastleg-service', 'file-service', ['REQUEST'], 'READ_WRITE', 'MIXED', '上传无幂等键,元数据校验可重复'), + http('integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', + 'integration-service', 'external-evergreen', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', + 'integration-service', 'external-hede', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', + 'integration-service', 'external-matson', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', + 'integration-service', 'external-sml', ['EXTERNAL'], 'READ', 'SAFE_READ', '船司轨迹查询;遵守外部限流'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', + 'integration-service', 'external-zim', ['EXTERNAL'], 'READ', 'SAFE_READ', '令牌获取与轨迹查询不改变平台业务事实'), + http('integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java', + 'integration-service', 'external-baidu-ocr', ['EXTERNAL'], 'READ', 'NONE', 'OCR 可能计费,不由 HTTP 层自动重试') +]) + +export const USER_ENTRIES = Object.freeze([ + { name: '认证 API', service: 'auth-service' }, + { name: 'IAM API', service: 'iam-service' }, + { name: '订单 API', service: 'order-service' }, + { name: '头程 API/PDA', service: 'firstleg-service' }, + { name: '财务 API', service: 'finance-service' }, + { name: '文件 API', service: 'file-service' }, + { name: '海外尾程 API/PDA', service: 'lastleg-service' }, + { name: '国内仓储 API', service: 'warehouse-service' }, + { name: '海外仓储 API', service: 'warehouse-overseas-service' } +]) + +export function graphEdges(context) { + return [...new Set(SYNCHRONOUS_CLIENTS + .filter(client => context === 'ALL' || client.contexts.includes(context)) + .map(client => `${client.source}->${client.target}`))].sort() +} + +export function stronglyConnectedComponents(edgeNames) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + if (!adjacency.has(to)) adjacency.set(to, new Set()) + adjacency.get(from).add(to) + } + let index = 0 + const indexes = new Map() + const lowLinks = new Map() + const stack = [] + const onStack = new Set() + const components = [] + function connect(node) { + indexes.set(node, index) + lowLinks.set(node, index++) + stack.push(node) + onStack.add(node) + for (const target of adjacency.get(node)) { + if (!indexes.has(target)) { + connect(target) + lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) + } else if (onStack.has(target)) lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) + } + if (lowLinks.get(node) !== indexes.get(node)) return + const component = [] + let current + do { + current = stack.pop() + onStack.delete(current) + component.push(current) + } while (current !== node) + components.push(component.sort()) + } + for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) + return components.filter(component => component.length > 1) + .sort((left, right) => left.join().localeCompare(right.join())) +} + +export function longestSimplePaths(entryService, edgeNames = graphEdges('REQUEST')) { + const adjacency = new Map() + for (const edge of edgeNames) { + const [from, to] = edge.split('->') + if (!adjacency.has(from)) adjacency.set(from, new Set()) + adjacency.get(from).add(to) + } + let longest = [] + function visit(node, path) { + const targets = [...(adjacency.get(node) ?? [])].sort() + let extended = false + for (const target of targets) { + if (path.includes(target)) continue + extended = true + visit(target, [...path, target]) + } + if (extended) return + if (longest.length === 0 || path.length > longest[0].length) longest = [path] + else if (path.length === longest[0].length) longest.push(path) + } + visit(entryService, [entryService]) + return longest.sort((left, right) => left.join('->').localeCompare(right.join('->'))) +} diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index 60c2af78b..a36533f8c 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -3,72 +3,62 @@ import { readdir, readFile } from 'node:fs/promises' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { + ACCESS_MODES, CONTEXTS, IDEMPOTENCY, SYNCHRONOUS_CLIENTS, USER_ENTRIES, + graphEdges, longestSimplePaths, stronglyConnectedComponents +} from './synchronous-dependency-baseline.mjs' const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') const serviceModules = [ 'auth', 'iam', 'order', 'firstleg', 'finance', 'file', 'warehouse', 'integration-service', 'print', 'event-bridge-service', 'lastleg-service', - 'warehouse-overseas-service' + 'warehouse-overseas-service', 'overseas-security-service' ] const canonicalSource = new Map(serviceModules.map(name => [name, `${name}${name.endsWith('-service') ? '' : '-service'}`])) -const expectedFeignEdges = new Map(Object.entries({ - 'auth-service->iam-service': 2, - 'file-service->finance-service': 1, - 'file-service->firstleg-service': 1, - 'file-service->iam-service': 2, - 'file-service->integration-service': 1, - 'file-service->order-service': 1, - 'file-service->warehouse-service': 1, - 'finance-service->file-service': 1, - 'finance-service->firstleg-service': 2, - 'finance-service->iam-service': 1, - 'finance-service->warehouse-service': 1, - 'firstleg-service->event-bridge-service': 1, - 'firstleg-service->file-service': 7, - 'firstleg-service->finance-service': 1, - 'firstleg-service->iam-service': 1, - 'firstleg-service->integration-service': 1, - 'firstleg-service->order-service': 2, - 'firstleg-service->print-service': 1, - 'firstleg-service->warehouse-service': 4, - 'iam-service->auth-service': 1, - 'iam-service->event-bridge-service': 1, - 'iam-service->firstleg-service': 1, - 'iam-service->warehouse-service': 1, - 'lastleg-service->event-bridge-service': 1, - 'lastleg-service->warehouse-service': 1, - 'order-service->file-service': 1, - 'order-service->firstleg-service': 3, - 'order-service->iam-service': 1, - 'order-service->warehouse-service': 1 -})) - -const expectedRawHttpImplementations = [ - 'common-audit/src/main/java/com/baiyue/common/audit/HttpAuditSink.java', - 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpFirstlegEventClient.java', - 'event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java', - 'gateway-overseas/src/main/java/com/baiyue/gateway/filter/HttpPdaSessionValidationClient.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', - 'lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/EvergreenTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/HedeTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/MatsonTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/SmlTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/ZimTrackingClient.java', - 'integration-service/src/main/java/com/baiyue/integration/provider/ocr/BaiduMeterClient.java' +const expectedOnlineEdges = [ + 'auth-service->iam-service', + 'file-service->iam-service', + 'file-service->integration-service', + 'finance-service->file-service', + 'finance-service->firstleg-service', + 'finance-service->iam-service', + 'finance-service->warehouse-service', + 'firstleg-service->file-service', + 'firstleg-service->iam-service', + 'firstleg-service->order-service', + 'firstleg-service->print-service', + 'firstleg-service->warehouse-service', + 'iam-service->auth-service', + 'iam-service->firstleg-service', + 'iam-service->warehouse-service', + 'lastleg-service->file-service', + 'lastleg-service->warehouse-overseas-service', + 'lastleg-service->warehouse-service', + 'order-service->file-service', + 'order-service->firstleg-service', + 'order-service->iam-service', + 'order-service->warehouse-service' ] -const reviewedRawInternalEdges = [ +const expectedBackgroundEdges = [ 'event-bridge-service->firstleg-service', 'event-bridge-service->lastleg-service', - 'gateway-overseas->auth-service', - 'lastleg-service->file-service', - 'lastleg-service->warehouse-overseas-service' + 'event-bridge-service->overseas-security-service', + 'file-service->finance-service', + 'file-service->firstleg-service', + 'file-service->iam-service', + 'file-service->order-service', + 'file-service->warehouse-service', + 'finance-service->firstleg-service', + 'firstleg-service->event-bridge-service', + 'firstleg-service->finance-service', + 'firstleg-service->integration-service', + 'iam-service->event-bridge-service', + 'lastleg-service->event-bridge-service', + 'order-service->firstleg-service' ] const rawHttpPattern = /\b(?:RestClient|WebClient|HttpClient)\b/ @@ -87,7 +77,7 @@ async function javaFiles(directory) { } async function discoverFeignEdges() { - const edges = new Map() + const clients = [] for (const module of serviceModules) { const sourceRoot = path.join(root, module, 'src', 'main', 'java') for (const file of await javaFiles(sourceRoot)) { @@ -96,66 +86,24 @@ async function discoverFeignEdges() { const annotation = source.match(/@FeignClient\(([\s\S]*?)\)\s*(?:public\s+)?interface/) const target = annotation?.[1].match(/name\s*=\s*"([^"]+)"/)?.[1] assert.ok(target, `cannot resolve Feign target from ${path.relative(root, file)}`) - const edge = `${canonicalSource.get(module)}->${target}` - edges.set(edge, (edges.get(edge) ?? 0) + 1) + clients.push({ + path: path.relative(root, file).replaceAll('\\', '/'), + source: canonicalSource.get(module), target + }) } } - return edges + return clients.sort((left, right) => left.path.localeCompare(right.path)) } -function sortedEntries(map) { - return [...map.entries()].sort(([left], [right]) => left.localeCompare(right)) -} - -function stronglyConnectedComponents(edgeNames) { - const adjacency = new Map() - for (const edge of edgeNames) { - const [from, to] = edge.split('->') - if (!adjacency.has(from)) adjacency.set(from, new Set()) - if (!adjacency.has(to)) adjacency.set(to, new Set()) - adjacency.get(from).add(to) - } - let index = 0 - const indexes = new Map() - const lowLinks = new Map() - const stack = [] - const onStack = new Set() - const components = [] - - function connect(node) { - indexes.set(node, index) - lowLinks.set(node, index++) - stack.push(node) - onStack.add(node) - for (const target of adjacency.get(node)) { - if (!indexes.has(target)) { - connect(target) - lowLinks.set(node, Math.min(lowLinks.get(node), lowLinks.get(target))) - } else if (onStack.has(target)) { - lowLinks.set(node, Math.min(lowLinks.get(node), indexes.get(target))) - } - } - if (lowLinks.get(node) !== indexes.get(node)) return - const component = [] - let current - do { - current = stack.pop() - onStack.delete(current) - component.push(current) - } while (current !== node) - components.push(component.sort()) - } - - for (const node of adjacency.keys()) if (!indexes.has(node)) connect(node) - return components.filter(component => component.length > 1) - .sort((left, right) => left.join().localeCompare(right.join())) -} - -test('all Feign clients remain covered by the reviewed synchronous edge baseline', async () => { - assert.deepEqual(sortedEntries(await discoverFeignEdges()), sortedEntries(expectedFeignEdges)) +test('all 44 Feign clients have reviewed context, access and idempotency metadata', async () => { + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'FEIGN') + .map(({ path, source, target }) => ({ path, source, target })) + .sort((left, right) => left.path.localeCompare(right.path)) + assert.equal(reviewed.length, 44) + assert.deepEqual(await discoverFeignEdges(), reviewed) }) -test('raw HTTP transports cannot be added without an explicit graph review', async () => { +test('all 16 raw HTTP transports have reviewed context, access and idempotency metadata', async () => { const discovered = [] for (const module of ['common-audit', 'event-bridge-service', 'gateway-overseas', 'lastleg-service', 'integration-service']) { for (const file of await javaFiles(path.join(root, module, 'src', 'main', 'java'))) { @@ -165,18 +113,127 @@ test('raw HTTP transports cannot be added without an explicit graph review', asy discovered.push(path.relative(root, file).replaceAll('\\', '/')) } } - assert.deepEqual(discovered.sort(), [...expectedRawHttpImplementations].sort()) + const reviewed = SYNCHRONOUS_CLIENTS.filter(client => client.transport === 'HTTP') + .map(client => client.path).sort() + assert.equal(reviewed.length, 16) + assert.deepEqual(discovered.sort(), reviewed) }) -test('the current synchronous service graph keeps its reviewed strongly connected component visible', async () => { - const feignEdges = (await discoverFeignEdges()).keys() - const components = stronglyConnectedComponents([...feignEdges, ...reviewedRawInternalEdges]) - assert.deepEqual(components, [[ +test('every reviewed synchronous client has a complete and exclusive executable classification', () => { + assert.equal(new Set(SYNCHRONOUS_CLIENTS.map(client => client.id)).size, SYNCHRONOUS_CLIENTS.length) + for (const client of SYNCHRONOUS_CLIENTS) { + assert.ok(client.path && client.source && client.target && client.note, `${client.id} metadata is incomplete`) + assert.ok(client.contexts.length > 0, `${client.id} has no execution context`) + assert.equal(new Set(client.contexts).size, client.contexts.length, `${client.id} repeats a context`) + for (const context of client.contexts) assert.ok(CONTEXTS.includes(context), `${client.id} has invalid context ${context}`) + assert.ok(ACCESS_MODES.includes(client.access), `${client.id} has invalid access ${client.access}`) + assert.ok(IDEMPOTENCY.includes(client.idempotency), `${client.id} has invalid idempotency ${client.idempotency}`) + } +}) + +test('all four Lastleg RestClient gateways use explicit bounded transports', async () => { + const clients = new Map([ + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpUnloadPhotoFileGateway.java', 'getUnloadPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/relabel/pda/gateway/HttpRelabelPhotoFileGateway.java', 'getRelabelPhotoFile'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/inboundplan/pda/gateway/HttpWarehouseResourceGateway.java', 'getWarehouseResource'], + ['lastleg-service/src/main/java/com/baiyue/lastleg/palletops/gateway/HttpPalletPhysicalGateway.java', 'getPalletPhysical'] + ]) + for (const [file, propertyAccessor] of clients) { + const source = await readFile(path.join(root, file), 'utf8') + assert.match(source, /restClientFactory\.build\(restClientBuilder, baseUrl,/) + assert.match(source, new RegExp(`restClientProperties\\.${propertyAccessor}\\(\\)`)) + assert.doesNotMatch(source, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + } + + const factory = await readFile(path.join(root, + 'lastleg-service/src/main/java/com/baiyue/lastleg/config/LastlegRestClientFactory.java'), 'utf8') + assert.match(factory, /connectTimeout\(timeouts\.getConnectTimeout\(\)\)/) + assert.match(factory, /setReadTimeout\(timeouts\.getReadTimeout\(\)\)/) + assert.doesNotMatch(factory, /RetryTemplate|Retryer|@Retryable|\.retry\(|fallbackFactory|fallback\s*=/i) + + for (const configFile of [ + 'lastleg-service/src/main/resources/application.yml', + 'config/nacos/overseas/lastleg-service.yml' + ]) { + const source = await readFile(path.join(root, configFile), 'utf8') + for (const key of ['unload-photo-file', 'relabel-photo-file', 'warehouse-resource', 'pallet-physical']) { + assert.match(source, new RegExp( + `${key}:\\s*\\n\\s+connect-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}` + + `\\s*\\n\\s+read-timeout:\\s*\\$\\{[^}]+:[1-9][0-9]*(?:ms|s)\\}`)) + } + } +}) + +test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', () => { + assert.deepEqual(graphEdges('REQUEST'), expectedOnlineEdges) + assert.deepEqual(graphEdges('BACKGROUND'), expectedBackgroundEdges) + assert.deepEqual(graphEdges('INFRASTRUCTURE'), [ + 'common-audit->audit-service', + 'gateway-overseas->auth-service', + 'gateway-overseas->overseas-security-service' + ]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('REQUEST')), [[ + 'auth-service', 'file-service', 'firstleg-service', 'iam-service', 'order-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' + ]]) + assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', 'firstleg-service', 'iam-service', 'lastleg-service', 'order-service' ]]) }) +test('overseas security edges never become online business dependencies', () => { + assert.ok(!graphEdges('REQUEST').some(edge => edge.includes('overseas-security-service'))) + assert.ok(graphEdges('INFRASTRUCTURE').includes('gateway-overseas->overseas-security-service')) + assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->overseas-security-service')) +}) + +test('known user entries expose every longest simple REQUEST path over the two-hop budget', () => { + const summary = Object.fromEntries(USER_ENTRIES.map(entry => { + const paths = longestSimplePaths(entry.service) + return [entry.service, { hops: paths[0].length - 1, paths: paths.map(path => path.join('->')) }] + })) + assert.deepEqual(summary, { + 'auth-service': { hops: 5, paths: [ + 'auth-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'iam-service': { hops: 4, paths: [ + 'iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'order-service': { hops: 4, paths: [ + 'order-service->file-service->iam-service->firstleg-service->print-service', + 'order-service->file-service->iam-service->firstleg-service->warehouse-service', + 'order-service->firstleg-service->file-service->iam-service->auth-service', + 'order-service->firstleg-service->file-service->iam-service->warehouse-service', + 'order-service->iam-service->firstleg-service->file-service->integration-service' + ] }, + 'firstleg-service': { hops: 4, paths: [ + 'firstleg-service->order-service->file-service->iam-service->auth-service', + 'firstleg-service->order-service->file-service->iam-service->warehouse-service' + ] }, + 'finance-service': { hops: 5, paths: [ + 'finance-service->file-service->iam-service->firstleg-service->order-service->warehouse-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->auth-service', + 'finance-service->firstleg-service->order-service->file-service->iam-service->warehouse-service', + 'finance-service->iam-service->firstleg-service->order-service->file-service->integration-service' + ] }, + 'file-service': { hops: 4, paths: [ + 'file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'lastleg-service': { hops: 5, paths: [ + 'lastleg-service->file-service->iam-service->firstleg-service->order-service->warehouse-service' + ] }, + 'warehouse-service': { hops: 0, paths: ['warehouse-service'] }, + 'warehouse-overseas-service': { hops: 0, paths: ['warehouse-overseas-service'] } + }) + for (const entry of USER_ENTRIES) { + const result = summary[entry.service] + if (result.hops > 2) assert.ok(result.paths.every(path => path.split('->').length > 3)) + } +}) + test('shared Feign timeout defaults and long-running export overrides do not drift silently', async () => { for (const profile of ['local', 'cn', 'overseas']) { const source = await readFile(path.join(root, 'config', 'nacos', profile, 'baiyue-common.yml'), 'utf8')