From 5f90bf3bc847cfda093e1c55a70ceb0773a759b2 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 10:09:09 +0800 Subject: [PATCH 1/2] fix(order): inherit cargo bindings for remainder orders --- .../InternalOrderCargoController.java | 8 ++--- .../order/repository/OrderRepository.java | 30 +++++++++++++++++++ .../InternalOrderCargoControllerTest.java | 26 ++++++++++++---- .../OrderCargoInheritanceContractTest.java | 27 +++++++++++++++++ 4 files changed, 81 insertions(+), 10 deletions(-) create mode 100644 order/src/test/java/com/baiyue/order/repository/OrderCargoInheritanceContractTest.java diff --git a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java index 36e7a3651..43b1f2b83 100644 --- a/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java +++ b/order/src/main/java/com/baiyue/order/controller/InternalOrderCargoController.java @@ -42,11 +42,11 @@ public class InternalOrderCargoController { if (tenantId <= 0 || orderId <= 0 || cargoId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "租户、订单和货物明细ID必须大于0"); } - OrderCargoEntity cargo = orders.findCargo(tenantId, orderId, cargoId); + OrderCargoEntity cargo = orders.findCargoInheritedByOrder(tenantId, orderId, cargoId); if (cargo == null) { throw new BizException(ResultCode.NOT_FOUND, "订单货物明细不存在或不属于该订单"); } - return ApiResponse.success(new CargoBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(), + return ApiResponse.success(new CargoBinding(cargo.getId(), orderId, cargo.getItemNo(), cargo.getCargoName(), cargo.getPackageCount(), cargo.getGrossWeightKg(), cargo.getVolumeCbm(), cargo.getSkuNo(), cargo.getCargoNameEn(), cargo.getUnitsPerPackage(), cargo.getTotalQuantity(), cargo.getNetWeightKg(), cargo.getCargoRemark())); @@ -61,8 +61,8 @@ public class InternalOrderCargoController { if (tenantId <= 0 || orderId <= 0) { throw new BizException(ResultCode.BAD_REQUEST, "租户和订单ID必须大于0"); } - return ApiResponse.success(orders.findCargo(tenantId, orderId).stream() - .map(cargo -> new CargoLabelBinding(cargo.getId(), cargo.getOrderId(), cargo.getItemNo(), + return ApiResponse.success(orders.findCargoInheritedByOrder(tenantId, orderId).stream() + .map(cargo -> new CargoLabelBinding(cargo.getId(), orderId, cargo.getItemNo(), cargo.getCargoName(), cargo.getPackageCount(), cargo.getLengthCm(), cargo.getWidthCm(), cargo.getHeightCm())) .toList()); diff --git a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java index a8e1e885b..925338837 100644 --- a/order/src/main/java/com/baiyue/order/repository/OrderRepository.java +++ b/order/src/main/java/com/baiyue/order/repository/OrderRepository.java @@ -262,6 +262,36 @@ public class OrderRepository { .eq(OrderCargoEntity::getDeleted, false)); } + /** + * 子订单尚未物化独立货物行时,按同客户根订单继承货物明细;返回实体的 orderId + * 仍是根单,调用方必须用请求中的子订单 ID 生成绑定快照,避免跨客户或跨根单借用。 + */ + public OrderCargoEntity findCargoInheritedByOrder(long tenantId, long orderId, long cargoId) { + OrderCargoEntity direct = findCargo(tenantId, orderId, cargoId); + if (direct != null) return direct; + OrderEntity order = orderMapper.selectOne(new LambdaQueryWrapper() + .eq(OrderEntity::getTenantId, tenantId) + .eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getDeleted, false)); + if (order == null || !"CHILD".equals(order.getOrderRole()) || order.getRootOrderId() == null) return null; + return cargoMapper.selectOne(new LambdaQueryWrapper() + .eq(OrderCargoEntity::getTenantId, tenantId) + .eq(OrderCargoEntity::getOrderId, order.getRootOrderId()) + .eq(OrderCargoEntity::getId, cargoId) + .eq(OrderCargoEntity::getDeleted, false)); + } + + public List findCargoInheritedByOrder(long tenantId, long orderId) { + List direct = findCargo(tenantId, orderId); + if (!direct.isEmpty()) return direct; + OrderEntity order = orderMapper.selectOne(new LambdaQueryWrapper() + .eq(OrderEntity::getTenantId, tenantId) + .eq(OrderEntity::getId, orderId) + .eq(OrderEntity::getDeleted, false)); + if (order == null || !"CHILD".equals(order.getOrderRole()) || order.getRootOrderId() == null) return List.of(); + return findCargo(tenantId, order.getRootOrderId()); + } + public List findParties(long tenantId, long orderId) { return partyMapper.selectList(new LambdaQueryWrapper() .eq(OrderPartyEntity::getTenantId, tenantId).eq(OrderPartyEntity::getOrderId, orderId)); diff --git a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java index 83cc76d23..c29974a56 100644 --- a/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java +++ b/order/src/test/java/com/baiyue/order/controller/InternalOrderCargoControllerTest.java @@ -39,7 +39,7 @@ class InternalOrderCargoControllerTest { cargo.setTotalQuantity(200); cargo.setNetWeightKg(new java.math.BigDecimal("18.500")); cargo.setCargoRemark("内部货物备注"); - when(orders.findCargo(11L, 21L, 31L)).thenReturn(cargo); + when(orders.findCargoInheritedByOrder(11L, 21L, 31L)).thenReturn(cargo); var response = controller.detail("internal-token", 11L, 21L, 31L); @@ -52,7 +52,7 @@ class InternalOrderCargoControllerTest { assertThat(response.data().totalQuantity()).isEqualTo(200); assertThat(response.data().netWeightKg()).isEqualByComparingTo("18.500"); assertThat(response.data().cargoRemark()).isEqualTo("内部货物备注"); - verify(orders).findCargo(11L, 21L, 31L); + verify(orders).findCargoInheritedByOrder(11L, 21L, 31L); } @Test @@ -64,13 +64,13 @@ class InternalOrderCargoControllerTest { @Test void cargoFromAnotherCustomerIsNotExposed() { - when(orders.findCargo(12L, 21L, 31L)).thenReturn(null); + when(orders.findCargoInheritedByOrder(12L, 21L, 31L)).thenReturn(null); assertThatThrownBy(() -> controller.detail("internal-token", 12L, 21L, 31L)) .isInstanceOf(BizException.class) .hasMessageContaining("不存在或不属于该订单"); - verify(orders).findCargo(12L, 21L, 31L); + verify(orders).findCargoInheritedByOrder(12L, 21L, 31L); verifyNoMoreInteractions(orders); } @@ -78,7 +78,7 @@ class InternalOrderCargoControllerTest { void listsCargoInRepositoryOrderForStableCartonAllocation() { OrderCargoEntity first = cargo(31L, 1, 3, "60", "40", "30"); OrderCargoEntity second = cargo(32L, 2, 2, "50", "30", "20"); - when(orders.findCargo(11L, 21L)).thenReturn(List.of(first, second)); + when(orders.findCargoInheritedByOrder(11L, 21L)).thenReturn(List.of(first, second)); var response = controller.listForCartonLabels("internal-token", 11L, 21L); @@ -87,7 +87,21 @@ class InternalOrderCargoControllerTest { .containsExactly(31L, 32L); assertThat(response.data().get(0).packageCount()).isEqualTo(3); assertThat(response.data().get(0).lengthCm()).isEqualByComparingTo("60"); - verify(orders).findCargo(11L, 21L); + verify(orders).findCargoInheritedByOrder(11L, 21L); + } + + @Test + void childOrderCanInheritRootCargoBindingWithoutCrossCustomerLeak() { + OrderCargoEntity cargo = cargo(31L, 1, 3, "60", "40", "30"); + cargo.setOrderId(21L); + when(orders.findCargoInheritedByOrder(11L, 201L, 31L)).thenReturn(cargo); + + var response = controller.detail("internal-token", 11L, 201L, 31L); + + assertThat(response.successful()).isTrue(); + assertThat(response.data().cargoId()).isEqualTo(31L); + assertThat(response.data().orderId()).isEqualTo(201L); + verify(orders).findCargoInheritedByOrder(11L, 201L, 31L); } private OrderCargoEntity cargo(long id, int itemNo, int packageCount, diff --git a/order/src/test/java/com/baiyue/order/repository/OrderCargoInheritanceContractTest.java b/order/src/test/java/com/baiyue/order/repository/OrderCargoInheritanceContractTest.java new file mode 100644 index 000000000..68e153040 --- /dev/null +++ b/order/src/test/java/com/baiyue/order/repository/OrderCargoInheritanceContractTest.java @@ -0,0 +1,27 @@ +package com.baiyue.order.repository; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; + +class OrderCargoInheritanceContractTest { + @Test + void childCargoLookupIsRootScopedAndTenantScoped() throws Exception { + Path source = Path.of("src/main/java/com/baiyue/order/repository/OrderRepository.java"); + if (!Files.exists(source)) source = Path.of("order/src/main/java/com/baiyue/order/repository/OrderRepository.java"); + String text = Files.readString(source, StandardCharsets.UTF_8); + + assertThat(text) + .contains("findCargoInheritedByOrder") + .contains("OrderEntity::getTenantId") + .contains("OrderEntity::getId") + .contains("!\"CHILD\".equals(order.getOrderRole())") + .contains("order.getRootOrderId()") + .contains("OrderCargoEntity::getTenantId") + .contains("OrderCargoEntity::getOrderId"); + } +} From 2de3fb36e9af5977a051cd11ea4104577a59ad27 Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Tue, 11 Aug 2026 10:35:01 +0800 Subject: [PATCH 2/2] fix(gateway): include stable request and trace ids in security errors --- .../gateway/config/GatewaySecurityConfig.java | 8 ++- .../filter/CorrelationIdWebFilter.java | 29 ++++++++--- .../GatewaySecurityErrorResponseTest.java | 49 +++++++++++++++++++ .../filter/CorrelationIdWebFilterTest.java | 27 ++++++++++ 4 files changed, 106 insertions(+), 7 deletions(-) create mode 100644 gateway/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorResponseTest.java create mode 100644 gateway/src/test/java/com/baiyue/gateway/filter/CorrelationIdWebFilterTest.java diff --git a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java index f6b2546a4..8d61b7730 100644 --- a/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java +++ b/gateway/src/main/java/com/baiyue/gateway/config/GatewaySecurityConfig.java @@ -1,6 +1,7 @@ package com.baiyue.gateway.config; import com.fasterxml.jackson.databind.ObjectMapper; +import com.baiyue.gateway.filter.CorrelationIdWebFilter; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -69,9 +70,14 @@ public class GatewaySecurityConfig { ObjectMapper mapper, HttpStatus status, String code, String message) { exchange.getResponse().setStatusCode(status); exchange.getResponse().getHeaders().setContentType(MediaType.APPLICATION_JSON); + String requestId = CorrelationIdWebFilter.ensureRequestId(exchange); + String traceId = CorrelationIdWebFilter.ensureTraceId(exchange); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.HEADER, requestId); + exchange.getResponse().getHeaders().set(CorrelationIdWebFilter.TRACE_HEADER, traceId); Map body = new LinkedHashMap<>(); body.put("code", code); body.put("message", message); body.put("data", null); - body.put("requestId", exchange.getRequest().getId()); body.put("timestamp", Instant.now().toString()); + body.put("requestId", requestId); body.put("traceId", traceId); + body.put("timestamp", Instant.now().toString()); try { byte[] bytes = mapper.writeValueAsBytes(body); return exchange.getResponse().writeWith(Mono.just(exchange.getResponse().bufferFactory().wrap(bytes))); diff --git a/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java index 28db1fc37..d29bf982f 100644 --- a/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java +++ b/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java @@ -14,19 +14,36 @@ import java.util.UUID; @Component public class CorrelationIdWebFilter implements WebFilter, Ordered { public static final String HEADER = "X-Request-Id"; + public static final String TRACE_HEADER = "X-Trace-Id"; + public static final String REQUEST_ID_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".requestId"; + public static final String TRACE_ID_ATTRIBUTE = CorrelationIdWebFilter.class.getName() + ".traceId"; @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { - String requestId = validRequestId(exchange.getRequest().getHeaders()) - ? exchange.getRequest().getHeaders().getFirst(HEADER) - : UUID.randomUUID().toString(); + String requestId = ensureRequestId(exchange); + String traceId = ensureTraceId(exchange); exchange.getResponse().getHeaders().set(HEADER, requestId); + exchange.getResponse().getHeaders().set(TRACE_HEADER, traceId); return chain.filter(exchange); } - private static boolean validRequestId(HttpHeaders headers) { - String value = headers.getFirst(HEADER); - return value != null && !value.isBlank() && value.length() <= 128 + public static String ensureRequestId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(REQUEST_ID_ATTRIBUTE, key -> { + HttpHeaders headers = exchange.getRequest().getHeaders(); + return validRequestId(headers.getFirst(HEADER)) + ? headers.getFirst(HEADER) : UUID.randomUUID().toString(); + }); + } + + public static String ensureTraceId(ServerWebExchange exchange) { + return (String) exchange.getAttributes().computeIfAbsent(TRACE_ID_ATTRIBUTE, key -> { + String incoming = exchange.getRequest().getHeaders().getFirst(TRACE_HEADER); + return validRequestId(incoming) ? incoming : UUID.randomUUID().toString(); + }); + } + + private static boolean validRequestId(String value) { + return value != null && !value.isBlank() && value.length() >= 16 && value.length() <= 128 && value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); } diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorResponseTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorResponseTest.java new file mode 100644 index 000000000..ab3324863 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewaySecurityErrorResponseTest.java @@ -0,0 +1,49 @@ +package com.baiyue.gateway.config; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.CsvSource; +import org.springframework.http.HttpStatus; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferUtils; + +import java.nio.charset.StandardCharsets; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; + +class GatewaySecurityErrorResponseTest { + private final ObjectMapper objectMapper = new ObjectMapper(); + + @ParameterizedTest + @CsvSource({ + "UNAUTHORIZED, A0401", + "FORBIDDEN, A0403" + }) + void securityErrorsExposeStableCompleteCorrelationIds(String statusName, String code) throws Exception { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/orders").build()); + HttpStatus status = HttpStatus.valueOf(statusName); + + GatewaySecurityConfig.writeError(exchange, objectMapper, status, code, "安全响应").block(); + + DataBuffer buffer = DataBufferUtils.join(exchange.getResponse().getBody()).block(); + assertThat(buffer).isNotNull(); + byte[] bytes = new byte[buffer.readableByteCount()]; + buffer.read(bytes); + DataBufferUtils.release(buffer); + String body = new String(bytes, StandardCharsets.UTF_8); + JsonNode json = objectMapper.readTree(body); + String requestId = json.get("requestId").asText(); + String traceId = json.get("traceId").asText(); + + assertThat(exchange.getResponse().getStatusCode()).isEqualTo(status); + assertThat(json.get("code").asText()).isEqualTo(code); + assertThat(UUID.fromString(requestId)).isNotNull(); + assertThat(UUID.fromString(traceId)).isNotNull(); + assertThat(exchange.getResponse().getHeaders().getFirst("X-Request-Id")).isEqualTo(requestId); + assertThat(exchange.getResponse().getHeaders().getFirst("X-Trace-Id")).isEqualTo(traceId); + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/filter/CorrelationIdWebFilterTest.java b/gateway/src/test/java/com/baiyue/gateway/filter/CorrelationIdWebFilterTest.java new file mode 100644 index 000000000..1c95246a2 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/filter/CorrelationIdWebFilterTest.java @@ -0,0 +1,27 @@ +package com.baiyue.gateway.filter; + +import org.junit.jupiter.api.Test; +import org.springframework.mock.http.server.reactive.MockServerHttpRequest; +import org.springframework.mock.web.server.MockServerWebExchange; +import reactor.core.publisher.Mono; + +import static org.assertj.core.api.Assertions.assertThat; + +class CorrelationIdWebFilterTest { + @Test + void replacesShortIncomingRequestIdAndCreatesTraceId() { + var exchange = MockServerWebExchange.from(MockServerHttpRequest.get("/api/platform/v1/orders") + .header(CorrelationIdWebFilter.HEADER, "a7612a59-6211") + .build()); + + new CorrelationIdWebFilter().filter(exchange, ignored -> Mono.empty()).block(); + + String requestId = exchange.getResponse().getHeaders().getFirst(CorrelationIdWebFilter.HEADER); + String traceId = exchange.getResponse().getHeaders().getFirst(CorrelationIdWebFilter.TRACE_HEADER); + assertThat((String) requestId).matches("[0-9a-fA-F-]{36}"); + assertThat((String) traceId).matches("[0-9a-fA-F-]{36}"); + assertThat(requestId).isNotEqualTo("a7612a59-6211"); + assertThat((String) exchange.getAttribute(CorrelationIdWebFilter.REQUEST_ID_ATTRIBUTE)).isEqualTo(requestId); + assertThat((String) exchange.getAttribute(CorrelationIdWebFilter.TRACE_ID_ATTRIBUTE)).isEqualTo(traceId); + } +}