diff --git a/common-web/AGENTS.md b/common-web/AGENTS.md index d2312b7cf..019c951df 100644 --- a/common-web/AGENTS.md +++ b/common-web/AGENTS.md @@ -13,6 +13,7 @@ | 请求 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 服务传递 | +| RestClient Trace 传播 | `RestClientTraceInterceptor.java`、`RestClientTracePropagationAutoConfiguration.java`;Spring 管理的 Builder 自动装配,原始 Builder 可显式复用拦截器 | | 自动配置入口 | `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` | @@ -27,5 +28,6 @@ - 修改 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。 +- 同步 RestClient 出站统一覆盖 `X-Request-Id`、`X-Trace-Id`、`traceparent`,从当前 MDC 生成 child span;MDC 缺失或非法时安全新建 trace。不得从业务参数或远端响应头建立上下文,生产代码应使用 Spring 管理的 `RestClient.Builder`,原始 Builder 必须显式安装同一拦截器。 - 从父目录运行 `mvn -pl common-web -am test`。 - 所有对外 `Long/long` 值统一序列化为 JSON 字符串,避免 19 位雪花 ID 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。 diff --git a/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java new file mode 100644 index 000000000..bbd1be9a4 --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/OutboundTraceHeaders.java @@ -0,0 +1,40 @@ +package com.baiyue.common.web; + +import org.slf4j.MDC; + +import java.util.UUID; +import java.util.regex.Pattern; + +/** Immutable standard headers for one synchronous outbound child span. */ +public record OutboundTraceHeaders(String requestId, String traceId, String traceParent) { + private static final Pattern SAFE_REQUEST_ID = Pattern.compile("[A-Za-z0-9._:-]{1,128}"); + + /** Builds a child span from the current MDC, safely starting a new trace when absent/invalid. */ + public static OutboundTraceHeaders fromCurrentOrNew() { + String requestId = safeRequestId(MDC.get(CorrelationContext.REQUEST_ID)); + String traceId = TraceParent.normalizeTrustedTraceId(MDC.get(CorrelationContext.TRACE_ID)) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(requestId, traceId); + } + + /** Builds a local child from a trusted persisted trace value, or safely starts a new trace. */ + public static OutboundTraceHeaders fromTrustedTraceOrNew(String trustedTraceId) { + String traceId = TraceParent.normalizeTrustedTraceId(trustedTraceId) + .orElseGet(() -> TraceParent.newRoot(null).traceId()); + return child(UUID.randomUUID().toString(), traceId); + } + + public String spanId() { + return TraceParent.parse(traceParent).orElseThrow().parentId(); + } + + private static OutboundTraceHeaders child(String requestId, String traceId) { + TraceParent child = TraceParent.outboundChild(traceId); + return new OutboundTraceHeaders(requestId, child.traceId(), child.value()); + } + + private static String safeRequestId(String value) { + return value != null && SAFE_REQUEST_ID.matcher(value).matches() + ? value : UUID.randomUUID().toString(); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java new file mode 100644 index 000000000..0fcf7ad9f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceCustomizer.java @@ -0,0 +1,24 @@ +package com.baiyue.common.web; + +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +/** Installs the trace interceptor after ordinary RestClient customizers. */ +public final class RestClientTraceCustomizer implements RestClientCustomizer, Ordered { + private final RestClientTraceInterceptor interceptor; + + public RestClientTraceCustomizer(RestClientTraceInterceptor interceptor) { + this.interceptor = interceptor; + } + + @Override + public void customize(RestClient.Builder builder) { + builder.requestInterceptor(interceptor); + } + + @Override + public int getOrder() { + return Ordered.LOWEST_PRECEDENCE; + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java new file mode 100644 index 000000000..488cd808f --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTraceInterceptor.java @@ -0,0 +1,21 @@ +package com.baiyue.common.web; + +import org.springframework.http.HttpRequest; +import org.springframework.http.client.ClientHttpRequestExecution; +import org.springframework.http.client.ClientHttpRequestInterceptor; +import org.springframework.http.client.ClientHttpResponse; + +import java.io.IOException; + +/** Applies the trusted synchronous trace context to Spring RestClient requests. */ +public final class RestClientTraceInterceptor implements ClientHttpRequestInterceptor { + @Override + public ClientHttpResponse intercept(HttpRequest request, byte[] body, + ClientHttpRequestExecution execution) throws IOException { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + request.getHeaders().set(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()); + request.getHeaders().set(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()); + request.getHeaders().set(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + return execution.execute(request, body); + } +} diff --git a/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java new file mode 100644 index 000000000..6acccad5d --- /dev/null +++ b/common-web/src/main/java/com/baiyue/common/web/RestClientTracePropagationAutoConfiguration.java @@ -0,0 +1,25 @@ +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.boot.web.client.RestClientCustomizer; +import org.springframework.context.annotation.Bean; +import org.springframework.web.client.RestClient; + +/** Adds trace propagation to Spring-managed RestClient builders. */ +@AutoConfiguration(afterName = "org.springframework.boot.autoconfigure.web.client.RestClientAutoConfiguration") +@ConditionalOnClass({RestClient.class, RestClientCustomizer.class}) +public class RestClientTracePropagationAutoConfiguration { + + @Bean + @ConditionalOnMissingBean(RestClientTraceInterceptor.class) + RestClientTraceInterceptor restClientTraceInterceptor() { + return new RestClientTraceInterceptor(); + } + + @Bean + RestClientTraceCustomizer baiyueTraceRestClientCustomizer(RestClientTraceInterceptor interceptor) { + return new RestClientTraceCustomizer(interceptor); + } +} 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 2ece6f081..88ae5829f 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 @@ -3,6 +3,7 @@ package com.baiyue.common.web; import java.security.SecureRandom; import java.util.HexFormat; import java.util.Optional; +import java.util.Locale; import java.util.regex.Matcher; import java.util.regex.Pattern; @@ -63,6 +64,24 @@ public record TraceParent(String traceId, String parentId, String traceFlags) { return new TraceParent(currentTraceId.toLowerCase(java.util.Locale.ROOT), randomNonZero(8), "01"); } + /** + * Normalizes a trace ID from a trusted persisted/internal context. + * + *

Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because + * existing event envelopes historically persisted UUID trace IDs.

+ */ + public static Optional normalizeTrustedTraceId(String value) { + if (validLegacyTraceId(value)) { + return Optional.of(value.toLowerCase(Locale.ROOT)); + } + if (value != null && value.matches( + "[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}")) { + String compact = value.replace("-", "").toLowerCase(Locale.ROOT); + return validLegacyTraceId(compact) ? Optional.of(compact) : Optional.empty(); + } + return Optional.empty(); + } + 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 06a277bae..2d417843c 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,3 +1,4 @@ com.baiyue.common.web.LongIdJacksonConfiguration com.baiyue.common.web.CommonWebAutoConfiguration com.baiyue.common.web.FeignTracePropagationAutoConfiguration +com.baiyue.common.web.RestClientTracePropagationAutoConfiguration diff --git a/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java new file mode 100644 index 000000000..c0f3c006a --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/OutboundTraceHeadersTest.java @@ -0,0 +1,41 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import static org.assertj.core.api.Assertions.assertThat; + +class OutboundTraceHeadersTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void currentContextCreatesAValidChildWithoutUsingBusinessValues() { + MDC.put(CorrelationContext.REQUEST_ID, "req-trusted"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + + OutboundTraceHeaders headers = OutboundTraceHeaders.fromCurrentOrNew(); + + assertThat(headers.requestId()).isEqualTo("req-trusted"); + assertThat(headers.traceId()).isEqualTo("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(headers.traceParent()) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void canonicalPersistedUuidIsCompactedAndInvalidValueStartsNewTrace() { + OutboundTraceHeaders uuid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "550e8400-e29b-41d4-a716-446655440000"); + OutboundTraceHeaders invalid = OutboundTraceHeaders.fromTrustedTraceOrNew( + "ORDER:customer-99:booking-123"); + + assertThat(uuid.traceId()).isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(invalid.traceId()).matches("[0-9a-f]{32}") + .isNotEqualTo("ORDER:customer-99:booking-123"); + assertThat(invalid.requestId()).matches("[0-9a-f-]{36}"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java new file mode 100644 index 000000000..b8c6e74e7 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTraceInterceptorTest.java @@ -0,0 +1,60 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; +import org.springframework.mock.http.client.MockClientHttpRequest; +import org.springframework.mock.http.client.MockClientHttpResponse; + +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; + +class RestClientTraceInterceptorTest { + private final RestClientTraceInterceptor interceptor = new RestClientTraceInterceptor(); + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void trustedContextOverwritesAllCallerProvidedTraceHeaders() throws Exception { + MDC.put(CorrelationContext.REQUEST_ID, "req-rest-client"); + MDC.put(CorrelationContext.TRACE_ID, "4bf92f3577b34da6a3ce929d0e0e4736"); + MockClientHttpRequest request = new MockClientHttpRequest(); + request.getHeaders().add(CorrelationContext.REQUEST_ID_HEADER, "business-request-id"); + request.getHeaders().add(CorrelationContext.TRACE_ID_HEADER, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"); + request.getHeaders().add(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + AtomicReference captured = new AtomicReference<>(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> { + captured.set(outbound.getHeaders()); + return new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK); + }); + + assertThat(captured.get().getValuesAsList(CorrelationContext.REQUEST_ID_HEADER)) + .containsExactly("req-rest-client"); + assertThat(captured.get().getValuesAsList(CorrelationContext.TRACE_ID_HEADER)) + .containsExactly("4bf92f3577b34da6a3ce929d0e0e4736"); + assertThat(captured.get().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); + } + + @Test + void missingOrInvalidMdcSafelyCreatesNewStandardHeaders() throws Exception { + MDC.put(CorrelationContext.TRACE_ID, "business-order-123"); + MockClientHttpRequest request = new MockClientHttpRequest(); + + interceptor.intercept(request, new byte[0], (outbound, body) -> + new MockClientHttpResponse(new byte[0], org.springframework.http.HttpStatus.OK)); + + assertThat(request.getHeaders().getFirst(CorrelationContext.REQUEST_ID_HEADER)) + .matches("[0-9a-f-]{36}"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_ID_HEADER)) + .matches("[0-9a-f]{32}").isNotEqualTo("business-order-123"); + assertThat(request.getHeaders().getFirst(CorrelationContext.TRACE_PARENT_HEADER)) + .matches("00-[0-9a-f]{32}-[0-9a-f]{16}-01"); + } +} diff --git a/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java new file mode 100644 index 000000000..f2edb83d2 --- /dev/null +++ b/common-web/src/test/java/com/baiyue/common/web/RestClientTracePropagationAutoConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.common.web; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.web.client.RestClientCustomizer; +import org.springframework.core.Ordered; +import org.springframework.web.client.RestClient; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class RestClientTracePropagationAutoConfigurationTest { + private final ApplicationContextRunner contextRunner = new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(RestClientTracePropagationAutoConfiguration.class)); + + @Test + void registersInterceptorAndIndependentCustomizer() { + contextRunner.run(context -> { + assertThat(context).hasSingleBean(RestClientTraceInterceptor.class); + assertThat(context).hasBean("baiyueTraceRestClientCustomizer"); + assertThat(context.getBeansOfType(RestClientCustomizer.class)).hasSize(1); + RestClientTraceCustomizer customizer = context.getBean(RestClientTraceCustomizer.class); + assertThat(customizer.getOrder()).isEqualTo(Ordered.LOWEST_PRECEDENCE); + RestClient.Builder builder = mock(RestClient.Builder.class); + customizer.customize(builder); + verify(builder).requestInterceptor(context.getBean(RestClientTraceInterceptor.class)); + }); + } + + @Test + void reusesApplicationProvidedTraceInterceptor() { + contextRunner.withBean(RestClientTraceInterceptor.class, RestClientTraceInterceptor::new) + .run(context -> assertThat(context).hasSingleBean(RestClientTraceInterceptor.class)); + } +} 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 e59be28aa..e67870a1c 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 @@ -51,4 +51,12 @@ class TraceParentTest { assertThat(child.value()).matches( "00-4bf92f3577b34da6a3ce929d0e0e4736-[0-9a-f]{16}-01"); } + + @Test + void trustedPersistedUuidNormalizationDoesNotBroadenHttpParsing() { + assertThat(TraceParent.normalizeTrustedTraceId("550E8400-E29B-41D4-A716-446655440000")) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(TraceParent.parse( + "00-550E8400E29B41D4A716446655440000-00f067aa0ba902b7-01")).isEmpty(); + } } diff --git a/docs/Order-Firstleg在线依赖解环设计.md b/docs/Order-Firstleg在线依赖解环设计.md new file mode 100644 index 000000000..d8828c29e --- /dev/null +++ b/docs/Order-Firstleg在线依赖解环设计.md @@ -0,0 +1,60 @@ +# Order↔Firstleg 在线依赖解环设计 + +## 结论 + +当前不能在不改变已确认产品成功时点的前提下直接实施解环,业务代码保持不变。在线图仍包含 +`Order→Firstleg` 与 `Firstleg→Order`,所以非平凡强连通分量仍是 `Firstleg↔Order`。 + +建议的最小退出方向是 **退出全部 `Firstleg→Order` REQUEST 边**。该方向只有 +`OrderCargoClient` 和 `OrderFulfillmentSplitClient` 两个客户端;Order 继续拥有订单、货物、父子订单和 +拆分台账,Firstleg 继续拥有入库、测量、分柜装柜、PDA 拣货和库位结算。完成后只保留 +`Order→Firstleg` 在线查询/门禁边以及双方后台边,在线 SCC 才会实际消失。 + +## 当前可执行调用 + +| 方向/客户端 | Controller→Service→Client→被调用入口 | 时点与调用者期望 | 失败传播 | 幂等性 | +|---|---|---|---|---| +| Order→Firstleg `FirstlegPricingClient` | `MerchantFirstLegOrderController`、`PlatformFirstLegOrderController` 的创建/编辑/提交 → `OrderApplicationService` → `OrderPricingChannelGuard` → `/internal/firstleg/v1/pricing/channels/validate` | 保存或提交前实时确认渠道有效;提交还必须取得入仓码前缀 | 业务错误原码上传;连接、空响应或不完整数据映射依赖不可用/冲突,订单不提交 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegUnifiedOrderClient` | `PlatformUnifiedOrderController`、`MerchantUnifiedOrderController` 列表/详情 → `UnifiedOrderQueryService` → Firstleg unified query/summaries/detail | 一个响应内组合 Order 主数据与 Firstleg 实时履约、测量、装柜和时间线 | 远端异常、失败码、空数据均失败关闭,不返回伪造履约摘要 | 纯读取 `SAFE_READ` | +| Order→Firstleg `FirstlegInboundClient` | `MerchantFirstLegOrderController.page` → `MerchantInboundOrderListService` → references/prepareBatch;已提交订单编辑提交后 → `OrderApplicationService.prepareDraftInbound` → prepare;审核 Outbox → `OrderInboundOutboxPublisher` → create | 列表需要入库引用;草稿/待审编辑尝试同步预入库;审核成功已与 Firstleg 建单解耦 | references 失败关闭;prepare/prepareBatch 当前捕获异常并允许列表缺少入库引用,是现有失败传播缺口;审核 Outbox 持久化重试 | `tenantId+orderId` 业务键;Firstleg 对订单唯一且入仓码一致时幂等 | +| Firstleg→Order `OrderCargoClient` | 箱号 Controller/Order 审核后台建单 → `InboundCartonLabelService`;测量 Controller/OpenAPI → `InboundMeasurementService`/`InboundMeasurementOpenApiService`;智能装柜 Controller → `SmartLoadingService` → Order cargo/snapshot endpoints | 首次箱号、测量明细、申报回填和装柜清单均要求当前订单货物事实 | 服务不可用、空数据、归属不一致均阻断当前操作;没有默认货物或空列表成功 | 纯读取 `SAFE_READ`;同时存在 REQUEST 和审核 Outbox 回调产生的 BACKGROUND 上下文 | +| Firstleg→Order `OrderFulfillmentSplitClient` | `PlatformLoadingPdaController.complete` → `LoadingPickService.complete @Transactional` → `PdaPickSettlementService.settle` → `/internal/order/v1/fulfillment-splits` → `OrderFulfillmentSplitService.split @Transactional` | PDA 完成请求必须先取得 Order 真实余量子单 ID/单号,再在同一 Firstleg 事务写分片、测量、库位、审计并推进装柜完成 | Order 不可用或响应数量/柜次/父子关系不一致时整个 Firstleg 完成事务回滚;不得返回完成成功 | Firstleg 使用 `idempotencyKey=pda-task-{taskId}`、`operationId=PDA-SPLIT-{taskId}-{inboundId}`;Order 保存请求哈希并以唯一键重放 | + +## 事实所有权 + +- Order:订单交易状态、订单货物及允许编辑的字段、父子订单 ID/单号、履约拆分台账和订单版本。 +- Firstleg:入库单、测量事实、分柜/装柜计划、PDA 拣货数量、库位结算、装柜证据和 `LOADING→LOADED` 门禁。 +- 允许复制的仅是带来源版本和哈希的 Order 快照;Firstleg 不得成为订单货物或父子订单的第二写入方。 +- 统一订单查询是组合读模型,不把 Firstleg 履约状态写回 Order 交易状态。 + +## 推荐退出方案 + +### 1. 先退出货物在线读取 + +复用 Order 已有 Outbox 和 Firstleg 已有订单入库接收入口,将完整货物快照、订单版本和规范化哈希随草稿准备、待审编辑和审核事件可靠投递;Firstleg 以 +`tenantId+orderId+orderVersion` 单调 Inbox/快照落库。箱号、测量和智能装柜只读本地快照,缺失、版本冲突或哈希冲突均失败关闭。 + +不能只在审核时复制一次:当前契约允许 `LOADED` 前按权限和状态修改业务资料。必须先冻结货物在哪个节点停止修改,或要求每次允许的 Order 编辑都可靠发布版本事件,并明确 Firstleg 在投影落后期间阻断哪些操作。 + +### 2. 再将履约拆分改为可靠后台命令 + +Firstleg 在本地持久化稳定 operation/idempotency key、数量行、柜次和请求哈希;后台 Relay 调用现有 Order 幂等拆分接口并保存完整响应。Order 现有拆分台账和唯一键可承担 Inbox/业务幂等,不需要新服务或 MQ。 + +Firstleg 只有在后台结果为 `READY` 且父子关系、数量和柜次全部校验通过后,才能消费余量子订单 ID并执行本地结算。失败进入可诊断重试/DEAD,禁止默认成功。远端成功、本地未完成时只允许向前恢复,不得自动删除 Order 子订单。 + +这一方案会引入技术态 `PENDING/READY/FAILED`,但不能擅自增加产品主状态。若在 PDA 首次点击完成时只创建任务并返回待处理,成功时点发生变化;若提前创建 Order 子单,则子单可能在装柜硬门禁完成前可见,同样改变现有语义。 + +## 必须由产品冻结的决策 + +1. **货物冻结点**:Order 货物在审核通过、首次生成箱号、确认测量还是确认预装后不可再改;若仍允许修改,哪些 Firstleg 操作必须等待最新 `orderVersion` 投影。 +2. **PDA 完成成功时点**:必须继续在一次请求中同时完成 Order 子单与 Firstleg 结算,还是允许首次请求返回“处理中”,后台完成后才成为 `LOADED`。同时确认后台已创建 Order 子单但 Firstleg 最终失败时采用持续向前修复,不提供在线冲正。 + +上述任一决策未冻结前,不实施业务解环。 + +## 最小实施与验证边界(决策冻结后) + +- Order:扩充版本化货物快照 Outbox;保留 `OrderFulfillmentSplitService` 事实所有权和现有幂等唯一键。 +- Firstleg:货物快照 Inbox/表、拆分命令 Outbox/Relay/诊断查询;替换两个在线 Client 的业务调用点。 +- SQL:仅更新当前模块基线与统一增量,不引入 Flyway/Liquibase,不执行 SQL。 +- 合同:证明生产请求路径不再引用两个客户端、`Firstleg→Order` 只剩 BACKGROUND,REQUEST SCC 中不再出现两者同一分量。 +- 测试:Order/Firstleg 全量直接测试、重复/异哈希事件、租约恢复、远端成功本地失败重放、DEAD 人工重试、PDA 未 READY 失败关闭,以及同步依赖合同。 diff --git a/docs/同步调用依赖与失败传播治理基线.md b/docs/同步调用依赖与失败传播治理基线.md index b9c4dde5f..f7cba31aa 100644 --- a/docs/同步调用依赖与失败传播治理基线.md +++ b/docs/同步调用依赖与失败传播治理基线.md @@ -26,7 +26,7 @@ | IAM | Event Bridge | 1 | 安全事件中继,`BACKGROUND` | | Order | Firstleg | 3 | 计价/统一订单查询为 `REQUEST`,入库协作为 Outbox `BACKGROUND` | | Order | IAM / File / Warehouse | 各 1 | 资格、图片、仓库守卫,`REQUEST` | -| Firstleg | Order | 2 | 货物查询与分拆回写,`REQUEST` | +| Firstleg | Order | 2 | 货物查询同时存在 `REQUEST/BACKGROUND`;PDA 履约分拆为 `REQUEST` | | Firstleg | IAM / Finance / Integration / Event Bridge / Print | 各 1 | IAM、Integration、Print 为 `REQUEST`;Finance 与 Event Bridge 为 `BACKGROUND` 中继 | | Firstleg | File | 7 | 测量、装柜、凭证、报关、拖车等,`REQUEST` | | Firstleg | Warehouse | 4 | 主数据、供应商、库位/托盘,`REQUEST` | @@ -79,9 +79,9 @@ 后台图包含 Outbox/Inbox、导出 Worker 和定时任务,当前强连通分量为: -`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg` +`Event Bridge ↔ Firstleg ↔ Finance ↔ Lastleg ↔ Order` -File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 +File 的五个导出 Provider、Order→Firstleg 入库 Outbox、该入库消费者为生成箱号而回读 Order 货物的后台回调、IAM 默认尺寸可靠任务、IAM 安全 Outbox、Firstleg/Lastleg 跨区 Outbox、Firstleg→Finance 费用 Outbox及 Event Bridge 三个消费者均只在后台图中计算。Event Bridge→Lastleg Security 与 Event Bridge→Lastleg 业务事件在服务图上收敛为同一条后台服务边,不形成新的在线业务边或独立安全节点。 ### 全图 @@ -108,6 +108,10 @@ File 的五个导出 Provider、Order→Firstleg 入库 Outbox、IAM 默认尺 `IAM → Firstleg` 的 `CustomerDimensionRuleClient` 默认尺寸初始化已经从 REQUEST WRITE 改为可靠后台编排。IAM 与客户主档同事务持久化稳定 `initializationKey` 的任务,显式维护 `PENDING/READY/FAILED`,由租约调度重试并提供诊断查询和人工重试;审批通过与下单资格在未 READY 时失败关闭。此边现在仅属于 BACKGROUND,在线五服务强连通分量实际拆为 Auth↔IAM 与 Order↔Firstleg 两个子环,未触碰财务、Event Bridge 或四期业务状态机。 +### Order↔Firstleg 第二阶段结论 + +具体入口、所有权与可执行退出方案见 `docs/Order-Firstleg在线依赖解环设计.md`。当前 `COMPLETE_LOADING` 在 Firstleg 本地事务中同步取得 Order 余量子单后才完成结算,且 Order 货物在 `LOADED` 前仍允许修改;成功时点和货物冻结点尚未冻结,因此本阶段不改业务代码。REQUEST SCC 仍为 `Firstleg↔Order`,合同明确阻止仅改变分类却未退出生产调用的伪解环。 + ## 退出顺序 1. **冻结与分层。** 先用清单合同阻止未评审新边;为每个客户端补齐 `REQUEST/BACKGROUND/INFRASTRUCTURE/EXTERNAL`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。 diff --git a/event-bridge-service/AGENTS.md b/event-bridge-service/AGENTS.md index 4b88bd092..99d0553fb 100644 --- a/event-bridge-service/AGENTS.md +++ b/event-bridge-service/AGENTS.md @@ -18,6 +18,7 @@ | 人工补投 | `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` | +| 事件 Trace 与原始 HTTP 传播 | `trace/EventBridgeTraceScope.java`、`EventBridgeHttpTracePropagation.java`;契约与已知缺口 `docs/event-trace-propagation.md` | | 内部服务令牌 | `security/InternalServiceTokenGuard.java` | | 数据库 | `src/main/resources/db/event-bridge.sql` | | 测试 | `src/test/java/com/baiyue/eventbridge/` | @@ -29,6 +30,7 @@ - 领取任务必须有数据库租约/并发保护,避免多实例重复发送;尾程幂等仍是最终保障。 - 内部接口使用 `X-Internal-Service-Token`,生产还应置于受信网络或 mTLS 后。 - 指标标签只允许有限 `target_region`、`event_type`、`result`,未知区域/类型折叠为 `OTHER`,禁止 eventId、customerId、错误消息等高基数标签。Inbox 聚合按现有状态索引定时采样,Prometheus scrape 不得查询数据库;开发阶段 SLO/告警只代表代码合同,不代表监控平台已部署。 +- 投递线程只从 Inbox 已冻结 `trace_id` 建立 MDC;合法 32 位 trace ID 或规范 UUID 可贯通,非法历史值安全重建。每次 JDK HttpClient 投递生成 child span,并覆盖三类标准 Trace 头;不得把 correlationId、eventId、customerId、聚合 ID、JWT 或内部令牌写入 Trace 字段。 ## 修改与验证 diff --git a/event-bridge-service/docs/event-trace-propagation.md b/event-bridge-service/docs/event-trace-propagation.md new file mode 100644 index 000000000..00c2e42c3 --- /dev/null +++ b/event-bridge-service/docs/event-trace-propagation.md @@ -0,0 +1,16 @@ +# Event Bridge Trace 传播基线 + +- Event Bridge 领取 Inbox 后,仅从事件信封已冻结的 `traceId` 建立投递线程 MDC。 +- 32 位非零十六进制 trace ID 直接沿用;历史规范 UUID 去除连字符后沿用。 +- 其他旧值不满足 W3C Trace ID 约束,投递时安全重建新 trace,但不覆盖或迁移原事件信封。 +- 每次 HTTP 投递生成新的 `X-Request-Id` 和 child span,并统一覆盖 `X-Trace-Id`、`traceparent`。 +- `correlationId`、`eventId`、customer ID、聚合 ID、JWT 和内部服务令牌不得写入 Trace 标准字段。 +- 投递日志在 MDC 和显式 `traceId` 字段中记录本次标准 trace;投递 attempt 仍通过 `event_id` + 关联 `bridge_inbox.trace_id`,本轮不增加数据库字段。 + +## 共享契约缺口 + +当前 `EventEnvelope.traceId` 仍是通用字符串契约,没有强制 32 位 W3C Trace ID;因此历史任意字符串不能 +无损映射为 W3C trace。若后续要求所有事件跨重试、跨语言绝对保持同一标准 trace,必须由 +`common-event-contract` 生产者、消费者和 Event Bridge 共同冻结字段校验后再收敛。本轮没有修改共享 +Payload、信封字段、哈希规则或可靠性状态机。 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 fe26409f2..f888cd04f 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 @@ -4,6 +4,7 @@ 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.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; @@ -20,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpFirstlegEventClient implements FirstlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.firstleg-consumer-url:http://127.0.0.1:9203/internal/firstleg/v1/container-events}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -37,9 +39,10 @@ public class HttpFirstlegEventClient implements FirstlegEventClient { 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()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode responseBody; diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java index e3b3dd5b8..2e01d3981 100644 --- a/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/service/HttpLastlegEventClient.java @@ -4,6 +4,7 @@ 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.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import org.springframework.beans.factory.annotation.Value; @@ -19,6 +20,7 @@ import java.net.http.HttpResponse; public class HttpLastlegEventClient implements LastlegEventClient { @Resource private ObjectMapper objectMapper; @Resource private EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; @@ -38,10 +40,11 @@ public class HttpLastlegEventClient implements LastlegEventClient { row.getHandoverVersion(), row.getTraceId(), row.getCorrelationId(), row.getCausationId(), objectMapper.readTree(row.getPayloadJson()), row.getPayloadHash()); String body = objectMapper.writeValueAsString(envelope); - HttpRequest request = HttpRequest.newBuilder(properties.consumerUrl()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(properties.consumerUrl()) .timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(body)) .build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); 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 index 6c4b7666e..9e55d56bd 100644 --- 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 @@ -4,9 +4,11 @@ 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.baiyue.eventbridge.trace.EventBridgeHttpTracePropagation; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.annotation.PostConstruct; +import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; @@ -19,6 +21,7 @@ import java.net.http.HttpResponse; public class HttpSecurityProjectionEventClient implements SecurityProjectionEventClient { private final ObjectMapper objectMapper; private final EventDispatcherProperties properties; + @Resource private EventBridgeHttpTracePropagation tracePropagation; @Value("${baiyue.event-dispatcher.security-consumer-url}") private URI consumerUrl; @Value("${baiyue.security.internal-service-token:}") private String serviceToken; private HttpClient httpClient; @@ -39,9 +42,10 @@ public class HttpSecurityProjectionEventClient implements SecurityProjectionEven 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()) + HttpRequest.Builder requestBuilder = HttpRequest.newBuilder(consumerUrl).timeout(properties.requestTimeout()) .header("Content-Type", "application/json") - .header("X-Internal-Service-Token", serviceToken) + .header("X-Internal-Service-Token", serviceToken); + HttpRequest request = tracePropagation.apply(requestBuilder) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(envelope))).build(); HttpResponse response = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); JsonNode body; 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 54cd77867..b20be547f 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 @@ -7,10 +7,13 @@ 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 com.baiyue.eventbridge.trace.EventBridgeTraceScope; +import com.baiyue.common.web.CorrelationContext; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.slf4j.MDC; import java.lang.management.ManagementFactory; import java.time.LocalDateTime; @@ -42,6 +45,12 @@ public class OverseasEventDispatcher { } BridgeInbox event = inboxMapper.selectById(eventId); + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + dispatchClaimed(eventId, event); + } + } + + private void dispatchClaimed(String eventId, BridgeInbox event) { int retryAttempt = event.getRetryCount() == null ? 1 : event.getRetryCount() + 1; int deliveryAttemptNo = inboxMapper.nextAttemptNo(eventId); BridgeDeliveryAttempt attempt = startAttempt(event, deliveryAttemptNo); @@ -57,8 +66,8 @@ public class OverseasEventDispatcher { : "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); + log.warn("跨区事件投递失败 eventId={}, retryAttempt={}, deliveryAttempt={}, traceId={}, reason={}", + eventId, retryAttempt, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID), error); return; } @@ -66,7 +75,8 @@ public class OverseasEventDispatcher { String error = "下游已确认成功,但事件租约已失效;保留当前 Inbox 状态并由新租约持有者幂等续投"; finishAttempt(attempt, "ACKNOWLEDGED_LEASE_LOST", responseCode, error); metrics.recordTransport(event, EventBridgeMetrics.TransportResult.ACKNOWLEDGED_LEASE_LOST); - log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}", eventId, deliveryAttemptNo); + log.warn("跨区事件成功确认时租约已失效 eventId={}, deliveryAttempt={}, traceId={}", + eventId, deliveryAttemptNo, MDC.get(CorrelationContext.TRACE_ID)); return; } finishAttempt(attempt, "DELIVERED", responseCode, null); diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java new file mode 100644 index 000000000..dc2c78bd3 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeHttpTracePropagation.java @@ -0,0 +1,19 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import org.springframework.stereotype.Component; + +import java.net.http.HttpRequest; + +/** Applies a fresh child span to one raw JDK HttpClient request. */ +@Component +public class EventBridgeHttpTracePropagation { + public HttpRequest.Builder apply(HttpRequest.Builder builder) { + OutboundTraceHeaders outbound = OutboundTraceHeaders.fromCurrentOrNew(); + return builder + .setHeader(CorrelationContext.REQUEST_ID_HEADER, outbound.requestId()) + .setHeader(CorrelationContext.TRACE_ID_HEADER, outbound.traceId()) + .setHeader(CorrelationContext.TRACE_PARENT_HEADER, outbound.traceParent()); + } +} diff --git a/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java new file mode 100644 index 000000000..82139ab51 --- /dev/null +++ b/event-bridge-service/src/main/java/com/baiyue/eventbridge/trace/EventBridgeTraceScope.java @@ -0,0 +1,35 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.common.web.OutboundTraceHeaders; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.slf4j.MDC; + +import java.util.Map; + +/** Establishes one trusted MDC context for an Event Bridge delivery attempt. */ +public final class EventBridgeTraceScope implements AutoCloseable { + private final Map previous; + + private EventBridgeTraceScope(Map previous) { + this.previous = previous; + } + + public static EventBridgeTraceScope open(BridgeInbox event) { + Map previous = MDC.getCopyOfContextMap(); + OutboundTraceHeaders local = OutboundTraceHeaders.fromTrustedTraceOrNew(event.getTraceId()); + MDC.put(CorrelationContext.REQUEST_ID, local.requestId()); + MDC.put(CorrelationContext.TRACE_ID, local.traceId()); + MDC.put(CorrelationContext.SPAN_ID, local.spanId()); + return new EventBridgeTraceScope(previous); + } + + @Override + public void close() { + if (previous == null) { + MDC.clear(); + } else { + MDC.setContextMap(previous); + } + } +} 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 aa9f11d14..5224a11e5 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 @@ -7,11 +7,13 @@ 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 com.baiyue.common.web.CorrelationContext; import io.micrometer.core.instrument.simple.SimpleMeterRegistry; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.ArgumentCaptor; import org.springframework.test.util.ReflectionTestUtils; +import org.slf4j.MDC; import java.net.URI; import java.time.Duration; @@ -63,7 +65,12 @@ class OverseasEventDispatcherTest { @Test void successfulDeliveryIsConfirmedAndAudited() throws Exception { - when(eventClient.deliver(event)).thenReturn("200"); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + when(eventClient.deliver(event)).thenAnswer(invocation -> { + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + return "200"; + }); when(inboxMapper.markProcessed(eq("event-1"), anyString())).thenReturn(1); dispatcher.dispatchOne("event-1"); @@ -74,6 +81,7 @@ class OverseasEventDispatcherTest { assertThat(updatedAttempt().getStatus()).isEqualTo("DELIVERED"); assertThat(transportCount("DELIVERED")).isEqualTo(1.0); assertThat(registry.get(EventBridgeMetrics.DELIVERY_LATENCY_METRIC).timer().count()).isEqualTo(1); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); } @Test diff --git a/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java new file mode 100644 index 000000000..1dbf17567 --- /dev/null +++ b/event-bridge-service/src/test/java/com/baiyue/eventbridge/trace/EventBridgeTracePropagationTest.java @@ -0,0 +1,65 @@ +package com.baiyue.eventbridge.trace; + +import com.baiyue.common.web.CorrelationContext; +import com.baiyue.eventbridge.domain.BridgeInbox; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.slf4j.MDC; + +import java.net.URI; +import java.net.http.HttpRequest; + +import static org.assertj.core.api.Assertions.assertThat; + +class EventBridgeTracePropagationTest { + + @AfterEach + void clearMdc() { + MDC.clear(); + } + + @Test + void envelopeUuidFlowsThroughMdcAndRawHttpAsAChildSpan() { + BridgeInbox event = new BridgeInbox(); + event.setTraceId("550e8400-e29b-41d4-a716-446655440000"); + event.setCorrelationId("ORDER:customer-99:booking-123"); + event.setEventId("event-business-id"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + String localSpan = MDC.get(CorrelationContext.SPAN_ID); + HttpRequest.Builder builder = HttpRequest.newBuilder(URI.create("http://127.0.0.1/internal")) + .header(CorrelationContext.REQUEST_ID_HEADER, event.getEventId()) + .header(CorrelationContext.TRACE_ID_HEADER, event.getCorrelationId()) + .header(CorrelationContext.TRACE_PARENT_HEADER, + "00-aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa-bbbbbbbbbbbbbbbb-00"); + HttpRequest request = new EventBridgeHttpTracePropagation().apply(builder).GET().build(); + + assertThat(MDC.get(CorrelationContext.TRACE_ID)) + .isEqualTo("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_ID_HEADER)) + .contains("550e8400e29b41d4a716446655440000"); + assertThat(request.headers().firstValue(CorrelationContext.REQUEST_ID_HEADER).orElseThrow()) + .matches("[0-9a-f-]{36}").isNotEqualTo(event.getEventId()); + assertThat(request.headers().firstValue(CorrelationContext.TRACE_PARENT_HEADER).orElseThrow()) + .matches("00-550e8400e29b41d4a716446655440000-[0-9a-f]{16}-01") + .doesNotContain(localSpan); + } + + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } + + @Test + void invalidLegacyEnvelopeTraceIsSafelyRebuiltAndPreviousMdcIsRestored() { + MDC.put("existing", "preserved"); + BridgeInbox event = new BridgeInbox(); + event.setTraceId("JWT-or-business-id-value"); + + try (EventBridgeTraceScope ignored = EventBridgeTraceScope.open(event)) { + assertThat(MDC.get(CorrelationContext.TRACE_ID)).matches("[0-9a-f]{32}") + .isNotEqualTo(event.getTraceId()); + } + + assertThat(MDC.get("existing")).isEqualTo("preserved"); + assertThat(MDC.get(CorrelationContext.TRACE_ID)).isNull(); + } +} diff --git a/scripts/java-quality-baseline.json b/scripts/java-quality-baseline.json new file mode 100644 index 000000000..1ed545842 --- /dev/null +++ b/scripts/java-quality-baseline.json @@ -0,0 +1,59 @@ +{ + "schemaVersion": 1, + "mode": "report-only", + "tools": { + "jacocoMavenPlugin": "0.8.12", + "spotbugsMavenPlugin": "4.9.8.5" + }, + "topology": { + "modules": 22, + "codeModules": 21, + "services": 15 + }, + "openapi": { + "documents": 26, + "coveredServices": 7, + "codeFirstServices": 8 + }, + "reports": { + "jacocoPresent": 21, + "spotbugsPresent": 21, + "spotbugsFindings": 780, + "spotbugsByPriority": { + "1": 1, + "2": 779 + }, + "spotbugsByCategory": { + "BAD_PRACTICE": 10, + "CORRECTNESS": 3, + "EXPERIMENTAL": 4, + "MALICIOUS_CODE": 699, + "MT_CORRECTNESS": 2, + "PERFORMANCE": 18, + "STYLE": 44 + } + }, + "modules": [ + { "module": "common-core", "jacocoCovered": 32, "jacocoTotal": 78, "jacocoPercentage": 41.03, "spotbugsFindings": 6 }, + { "module": "common-web", "jacocoCovered": 164, "jacocoTotal": 188, "jacocoPercentage": 87.23, "spotbugsFindings": 1 }, + { "module": "common-security", "jacocoCovered": 109, "jacocoTotal": 202, "jacocoPercentage": 53.96, "spotbugsFindings": 6 }, + { "module": "common-openapi", "jacocoCovered": 17, "jacocoTotal": 37, "jacocoPercentage": 45.95, "spotbugsFindings": 0 }, + { "module": "common-audit", "jacocoCovered": 168, "jacocoTotal": 244, "jacocoPercentage": 68.85, "spotbugsFindings": 8 }, + { "module": "common-event-contract", "jacocoCovered": 146, "jacocoTotal": 164, "jacocoPercentage": 89.02, "spotbugsFindings": 18 }, + { "module": "gateway", "jacocoCovered": 110, "jacocoTotal": 156, "jacocoPercentage": 70.51, "spotbugsFindings": 4 }, + { "module": "gateway-overseas", "jacocoCovered": 134, "jacocoTotal": 272, "jacocoPercentage": 49.26, "spotbugsFindings": 2 }, + { "module": "auth", "jacocoCovered": 372, "jacocoTotal": 619, "jacocoPercentage": 60.10, "spotbugsFindings": 5 }, + { "module": "iam", "jacocoCovered": 1872, "jacocoTotal": 3032, "jacocoPercentage": 61.74, "spotbugsFindings": 60 }, + { "module": "order", "jacocoCovered": 1116, "jacocoTotal": 1605, "jacocoPercentage": 69.53, "spotbugsFindings": 47 }, + { "module": "firstleg", "jacocoCovered": 7891, "jacocoTotal": 11592, "jacocoPercentage": 68.07, "spotbugsFindings": 223 }, + { "module": "warehouse", "jacocoCovered": 1767, "jacocoTotal": 2922, "jacocoPercentage": 60.47, "spotbugsFindings": 64 }, + { "module": "warehouse-overseas-service", "jacocoCovered": 403, "jacocoTotal": 778, "jacocoPercentage": 51.80, "spotbugsFindings": 8 }, + { "module": "lastleg-service", "jacocoCovered": 5088, "jacocoTotal": 7832, "jacocoPercentage": 64.96, "spotbugsFindings": 162 }, + { "module": "file", "jacocoCovered": 1046, "jacocoTotal": 1614, "jacocoPercentage": 64.81, "spotbugsFindings": 33 }, + { "module": "print", "jacocoCovered": 596, "jacocoTotal": 695, "jacocoPercentage": 85.76, "spotbugsFindings": 12 }, + { "module": "audit-service", "jacocoCovered": 76, "jacocoTotal": 153, "jacocoPercentage": 49.67, "spotbugsFindings": 0 }, + { "module": "finance", "jacocoCovered": 1665, "jacocoTotal": 2236, "jacocoPercentage": 74.46, "spotbugsFindings": 97 }, + { "module": "integration-service", "jacocoCovered": 1040, "jacocoTotal": 1420, "jacocoPercentage": 73.24, "spotbugsFindings": 23 }, + { "module": "event-bridge-service", "jacocoCovered": 318, "jacocoTotal": 419, "jacocoPercentage": 75.89, "spotbugsFindings": 1 } + ] +} diff --git a/scripts/java-quality-summary.mjs b/scripts/java-quality-summary.mjs index f54740dca..23c84d407 100644 --- a/scripts/java-quality-summary.mjs +++ b/scripts/java-quality-summary.mjs @@ -5,7 +5,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url' export const repositoryRoot = path.resolve(fileURLToPath(new URL('..', import.meta.url))) const normalize = (value) => value.replaceAll('\\', '/') -const attribute = (source, name) => source.match(new RegExp(`\\b${name}="([^"]*)"`))?.[1] +const attribute = (source, name) => source.match(new RegExp(`\\b${name}=(?:"([^"]*)"|'([^']*)')`))?.slice(1).find((value) => value !== undefined) async function exists(file) { try { diff --git a/scripts/java-quality-summary.test.mjs b/scripts/java-quality-summary.test.mjs index 8744660a1..989dcfe43 100644 --- a/scripts/java-quality-summary.test.mjs +++ b/scripts/java-quality-summary.test.mjs @@ -21,6 +21,11 @@ test('parses JaCoCo line coverage and SpotBugs findings without quality threshol byPriority: { 1: 1, 2: 1 }, byCategory: { CORRECTNESS: 1, STYLE: 1 } }) + assert.deepEqual(parseSpotbugsReport(""), { + findings: 1, + byPriority: { 2: 1 }, + byCategory: { MALICIOUS_CODE: 1 } + }) assert.throws(() => parseJacocoReport(''), /not a JaCoCo report/) assert.throws(() => parseSpotbugsReport(''), /not a SpotBugs report/) }) @@ -58,10 +63,15 @@ test('aggregates code modules, report presence and the OpenAPI service matrix', test('repository freezes the current topology and non-blocking aggregate summary wiring', async () => { const summary = await buildQualitySummary(repositoryRoot) + const baseline = JSON.parse(await readFile(path.join(repositoryRoot, 'scripts', 'java-quality-baseline.json'), 'utf8')) assert.deepEqual(summary.topology, { modules: 22, codeModules: 21, services: 15 }) assert.equal(summary.openapi.documents, 26) assert.equal(summary.openapi.coveredServices.length, 7) assert.equal(summary.openapi.codeFirstServices.length, 8) + assert.equal(baseline.mode, 'report-only') + assert.deepEqual(baseline.topology, summary.topology) + assert.equal(baseline.modules.length, 21) + assert.equal(baseline.modules.reduce((total, row) => total + row.spotbugsFindings, 0), baseline.reports.spotbugsFindings) const gitlab = (await readFile(path.join(repositoryRoot, '.gitlab-ci.yml'), 'utf8')).replaceAll('\r\n', '\n') const job = gitlab.match(/^质量摘要:\n([\s\S]*?)(?=^[^\s#].*:\n)/m)?.[1] ?? '' diff --git a/scripts/synchronous-dependency-baseline.mjs b/scripts/synchronous-dependency-baseline.mjs index 6c69f4df7..de60f65a6 100644 --- a/scripts/synchronous-dependency-baseline.mjs +++ b/scripts/synchronous-dependency-baseline.mjs @@ -64,7 +64,8 @@ export const SYNCHRONOUS_CLIENTS = Object.freeze([ 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', '读取订单货物/来源快照'), + 'firstleg-service', 'order-service', ['REQUEST', 'BACKGROUND'], 'READ', 'SAFE_READ', + '在线测量/箱号/排柜读取;Order 审核 Outbox 创建 Firstleg 入库及箱号时也会后台回读订单货物'), 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', diff --git a/scripts/synchronous-dependency-contract.test.mjs b/scripts/synchronous-dependency-contract.test.mjs index fa082a7fd..bb8fcb0e7 100644 --- a/scripts/synchronous-dependency-contract.test.mjs +++ b/scripts/synchronous-dependency-contract.test.mjs @@ -54,6 +54,7 @@ const expectedBackgroundEdges = [ 'firstleg-service->event-bridge-service', 'firstleg-service->finance-service', 'firstleg-service->integration-service', + 'firstleg-service->order-service', 'iam-service->event-bridge-service', 'iam-service->firstleg-service', 'lastleg-service->event-bridge-service', @@ -190,7 +191,7 @@ test('REQUEST, BACKGROUND and full graphs remain separate executable baselines', ['firstleg-service', 'order-service'] ]) assert.deepEqual(stronglyConnectedComponents(graphEdges('BACKGROUND')), [[ - 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service' + 'event-bridge-service', 'finance-service', 'firstleg-service', 'lastleg-service', 'order-service' ]]) assert.deepEqual(stronglyConnectedComponents(graphEdges('ALL')), [[ 'auth-service', 'event-bridge-service', 'file-service', 'finance-service', @@ -204,6 +205,25 @@ test('overseas security projection stays in Lastleg infrastructure and backgroun assert.ok(graphEdges('BACKGROUND').includes('event-bridge-service->lastleg-service')) }) +test('Order and Firstleg remain a bilateral REQUEST SCC until loading-complete timing is frozen', () => { + const requestClients = SYNCHRONOUS_CLIENTS.filter(client => client.contexts.includes('REQUEST')) + const orderToFirstleg = requestClients.filter(client => + client.source === 'order-service' && client.target === 'firstleg-service') + const firstlegToOrder = requestClients.filter(client => + client.source === 'firstleg-service' && client.target === 'order-service') + assert.deepEqual(orderToFirstleg.map(client => client.path).sort(), [ + 'order/src/main/java/com/baiyue/order/client/FirstlegInboundClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegPricingClient.java', + 'order/src/main/java/com/baiyue/order/client/FirstlegUnifiedOrderClient.java' + ]) + assert.deepEqual(firstlegToOrder.map(client => client.path).sort(), [ + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderCargoClient.java', + 'firstleg/src/main/java/com/baiyue/firstleg/client/OrderFulfillmentSplitClient.java' + ]) + assert.ok(stronglyConnectedComponents(graphEdges('REQUEST')).some(component => + component.includes('firstleg-service') && component.includes('order-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)