This commit is contained in:
2026-08-25 19:07:34 +08:00
parent 65c01f8b3d
commit 7d36759f3f
28 changed files with 613 additions and 16 deletions
+2
View File
@@ -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 在浏览器中丢失精度;禁止业务模块重复添加局部转换器。
@@ -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();
}
}
@@ -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;
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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.
*
* <p>Unlike an untrusted HTTP header, a canonical UUID is accepted and compacted because
* existing event envelopes historically persisted UUID trace IDs.</p>
*/
public static Optional<String> 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);
@@ -1,3 +1,4 @@
com.baiyue.common.web.LongIdJacksonConfiguration
com.baiyue.common.web.CommonWebAutoConfiguration
com.baiyue.common.web.FeignTracePropagationAutoConfiguration
com.baiyue.common.web.RestClientTracePropagationAutoConfiguration
@@ -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}");
}
}
@@ -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<org.springframework.http.HttpHeaders> 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");
}
}
@@ -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));
}
}
@@ -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();
}
}
@@ -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 失败关闭,以及同步依赖合同。
@@ -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`、读写语义、幂等键和失败映射。这个阶段不改变业务状态。
+2
View File
@@ -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 字段。
## 修改与验证
@@ -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、信封字段、哈希规则或可靠性状态机。
@@ -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<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode responseBody;
@@ -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<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
@@ -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<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
JsonNode body;
@@ -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);
@@ -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());
}
}
@@ -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<String, String> previous;
private EventBridgeTraceScope(Map<String, String> previous) {
this.previous = previous;
}
public static EventBridgeTraceScope open(BridgeInbox event) {
Map<String, String> 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);
}
}
}
@@ -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
@@ -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();
}
}
+59
View File
@@ -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 }
]
}
+1 -1
View File
@@ -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 {
+10
View File
@@ -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("<BugCollection><BugInstance priority='2' category='MALICIOUS_CODE'/></BugCollection>"), {
findings: 1,
byPriority: { 2: 1 },
byCategory: { MALICIOUS_CODE: 1 }
})
assert.throws(() => parseJacocoReport('<report/>'), /not a JaCoCo report/)
assert.throws(() => parseSpotbugsReport('<BugCollection/>'), /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] ?? ''
+2 -1
View File
@@ -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',
@@ -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)