diff --git a/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java b/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java new file mode 100644 index 000000000..28db1fc37 --- /dev/null +++ b/gateway/src/main/java/com/baiyue/gateway/filter/CorrelationIdWebFilter.java @@ -0,0 +1,37 @@ +package com.baiyue.gateway.filter; + +import org.springframework.core.Ordered; +import org.springframework.http.HttpHeaders; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebFilter; +import org.springframework.web.server.WebFilterChain; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +/** 确保跨域预检和安全失败响应也携带可追踪的请求标识。 */ +@Component +public class CorrelationIdWebFilter implements WebFilter, Ordered { + public static final String HEADER = "X-Request-Id"; + + @Override + public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { + String requestId = validRequestId(exchange.getRequest().getHeaders()) + ? exchange.getRequest().getHeaders().getFirst(HEADER) + : UUID.randomUUID().toString(); + exchange.getResponse().getHeaders().set(HEADER, requestId); + return chain.filter(exchange); + } + + private static boolean validRequestId(HttpHeaders headers) { + String value = headers.getFirst(HEADER); + return value != null && !value.isBlank() && value.length() <= 128 + && value.chars().allMatch(character -> character >= 0x21 && character <= 0x7e); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java new file mode 100644 index 000000000..81df69386 --- /dev/null +++ b/gateway/src/test/java/com/baiyue/gateway/config/GatewayFileRouteConfigurationTest.java @@ -0,0 +1,37 @@ +package com.baiyue.gateway.config; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.cloud.gateway.config.GatewayProperties; + +import static org.assertj.core.api.Assertions.assertThat; + +@SpringBootTest(properties = { + "spring.cloud.nacos.discovery.enabled=false", + "spring.cloud.nacos.discovery.register-enabled=false", + "spring.cloud.nacos.config.enabled=false", + "spring.cloud.gateway.discovery.locator.enabled=false", + "baiyue.security.jwt-secret=test-jwt-secret-at-least-32-bytes-long" +}) +class GatewayFileRouteConfigurationTest { + @Autowired + private GatewayProperties gatewayProperties; + + @Test + void qualificationRequestsRouteToRegisteredFileServiceName() { + var route = gatewayProperties.getRoutes().stream() + .filter(candidate -> "file-service".equals(candidate.getId())) + .findFirst() + .orElseThrow(); + + assertThat(route.getUri().toString()).isEqualTo("lb://file-service"); + assertThat(route.getPredicates()).singleElement().satisfies(predicate -> { + assertThat(predicate.getName()).isEqualTo("Path"); + String paths = String.join(",", predicate.getArgs().values()); + assertThat(paths) + .contains("/api/merchant/v1/files/**") + .contains("/api/platform/v1/files/**"); + }); + } +} diff --git a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java index 834e57b27..19f99837a 100644 --- a/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java +++ b/order/src/main/java/com/baiyue/order/service/OrderMasterDataGuard.java @@ -110,7 +110,9 @@ public class OrderMasterDataGuard { return new BizException(ResultCode.SERVICE_UNAVAILABLE, "主数据内部服务凭证不一致,请检查 INTERNAL_SERVICE_TOKEN"); } - log.warn("订单主数据内部调用失败,已转换为 S0503", exception); + log.warn("订单主数据内部调用失败,已转换为 S0503,异常类型={},原因={}", + exception.getClass().getSimpleName(), exception.getMessage()); + log.debug("订单主数据内部调用完整堆栈", exception); return unavailable("主数据"); } private FeignException findFeignException(Throwable exception) { diff --git a/order/src/test/java/com/baiyue/order/client/WarehouseFeignContractTest.java b/order/src/test/java/com/baiyue/order/client/WarehouseFeignContractTest.java new file mode 100644 index 000000000..012df84dc --- /dev/null +++ b/order/src/test/java/com/baiyue/order/client/WarehouseFeignContractTest.java @@ -0,0 +1,29 @@ +package com.baiyue.order.client; + +import org.junit.jupiter.api.Test; +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.lang.reflect.Method; + +import static org.assertj.core.api.Assertions.assertThat; + +class WarehouseFeignContractTest { + @Test void clientsUseNacosServiceNameAndInternalPathsByDefault() throws Exception { + assertClient(WarehouseClient.class, "/internal/warehouses"); + assertClient(PartnerClient.class, "/internal/warehouse-partners"); + } + + private void assertClient(Class type, String expectedPath) throws Exception { + FeignClient client = type.getAnnotation(FeignClient.class); + assertThat(client.name()).isEqualTo("warehouse-service"); + assertThat(client.url()).isEqualTo("${baiyue.clients.warehouse.url:}"); + assertThat(client.path()).isEqualTo(expectedPath); + + Method active = type.getMethod("active", String.class, long.class, long.class); + assertThat(active.getAnnotation(GetMapping.class).value()).containsExactly("/{tenantId}/{id}/active"); + RequestHeader header = active.getParameters()[0].getAnnotation(RequestHeader.class); + assertThat(header.value()).isEqualTo("X-Internal-Service-Token"); + } +} diff --git a/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java new file mode 100644 index 000000000..c7ea6da25 --- /dev/null +++ b/warehouse/src/test/java/com/baiyue/warehouse/service/PartnerQueryServiceTest.java @@ -0,0 +1,44 @@ +package com.baiyue.warehouse.service; + +import com.baiyue.common.core.api.ResultCode; +import com.baiyue.common.core.exception.BizException; +import com.baiyue.warehouse.entity.PartnerEntity; +import com.baiyue.warehouse.repository.PartnerRepository; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class PartnerQueryServiceTest { + @Test void missingOrCrossTenantPartnerReturnsNotFound() { + PartnerRepository repository = mock(PartnerRepository.class); + + assertThatThrownBy(() -> new PartnerQueryService(repository).active(10001L, 7L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.NOT_FOUND); + } + + @Test void inactivePartnerReturnsConflict() { + PartnerRepository repository = mock(PartnerRepository.class); + PartnerEntity partner = new PartnerEntity(); + partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("INACTIVE"); + when(repository.findOwned(10001L, 7L)).thenReturn(partner); + + assertThatThrownBy(() -> new PartnerQueryService(repository).active(10001L, 7L)) + .isInstanceOf(BizException.class) + .extracting(exception -> ((BizException) exception).getResultCode()) + .isEqualTo(ResultCode.CONFLICT); + } + + @Test void activePartnerIsReturned() { + PartnerRepository repository = mock(PartnerRepository.class); + PartnerEntity partner = new PartnerEntity(); + partner.setId(7L); partner.setTenantId(10001L); partner.setStatus("ACTIVE"); + when(repository.findOwned(10001L, 7L)).thenReturn(partner); + + assertThat(new PartnerQueryService(repository).active(10001L, 7L)).isSameAs(partner); + } +}