diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java index d4546cae3..70836ade5 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteController.java @@ -23,6 +23,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import java.nio.charset.StandardCharsets; @@ -54,9 +55,11 @@ public class PlatformOverseasQuoteController { @GetMapping("/warehouse-options") @PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')") @Operation(summary = "List cached active overseas partner warehouse options", - description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs") - public ApiResponse> warehouseOptions() { - return ApiResponse.success(service.warehouseOptions()); + description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs; " + + "refresh=true bypasses the positive cache after CN partner master data changes") + public ApiResponse> warehouseOptions( + @RequestParam(defaultValue = "false") boolean refresh) { + return ApiResponse.success(service.warehouseOptions(refresh)); } @PostMapping diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java index b9b3fb73c..766b5163f 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/OverseasQuoteService.java @@ -73,8 +73,8 @@ public class OverseasQuoteService { quote.getCreatedBy(), quote.getCreatedAt()); } - public List warehouseOptions() { - return warehouses.options(); + public List warehouseOptions(boolean refresh) { + return refresh ? warehouses.refresh() : warehouses.options(); } @Transactional diff --git a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java index 24e4734d9..de59cd12b 100644 --- a/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java +++ b/lastleg-service/src/main/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectory.java @@ -6,6 +6,7 @@ import jakarta.annotation.Resource; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; import org.springframework.web.server.ResponseStatusException; import java.time.Clock; @@ -23,26 +24,39 @@ public class PartnerWarehouseDirectory { private volatile Snapshot snapshot; public List options() { + return options(false); + } + + /** Bypasses a still-valid positive snapshot after CN partner master data changes. */ + public List refresh() { + return options(true); + } + + private List options(boolean forceRefresh) { Snapshot current = snapshot; Instant now = clock.instant(); - if (current != null && now.isBefore(current.expiresAt())) return current.options(); + if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options(); synchronized (this) { current = snapshot; now = clock.instant(); - if (current != null && now.isBefore(current.expiresAt())) return current.options(); + if (!forceRefresh && current != null && now.isBefore(current.expiresAt())) return current.options(); var response = client.activePartners(internalToken); if (response == null || !response.successful() || response.data() == null) { throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "国内海外合作仓候选暂不可用"); } List options = response.data().stream() - .filter(value -> value != null && value.id() != null) + .filter(this::valid) .map(value -> new OverseasQuoteResponses.PartnerWarehouseOption(value.id().toString(), - value.warehouseCode(), value.warehouseName(), value.countryCode())) + value.warehouseCode().trim(), value.warehouseName().trim(), + value.countryCode().trim().toUpperCase(java.util.Locale.ROOT))) .toList(); // An empty directory can be a short-lived cross-region visibility gap after a partner // warehouse is created. Do not preserve that absence for the normal 30-minute TTL. - if (options.isEmpty()) return List.of(); + if (options.isEmpty()) { + snapshot = null; + return List.of(); + } snapshot = new Snapshot(options, now.plus(ttl)); return snapshot.options(); } @@ -54,6 +68,14 @@ public class PartnerWarehouseDirectory { "所选海外合作仓不存在或已停用")); } + private boolean valid(DomesticPartnerWarehouseClient.Option value) { + return value != null && value.id() != null + && StringUtils.hasText(value.warehouseCode()) + && StringUtils.hasText(value.warehouseName()) + && StringUtils.hasText(value.countryCode()) + && value.countryCode().trim().matches("(?i)[A-Z]{2}"); + } + private record Snapshot(List options, Instant expiresAt) { private Snapshot { options = List.copyOf(options); } } diff --git a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml index f67455ca1..06a85196f 100644 --- a/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml +++ b/lastleg-service/src/main/resources/openapi/overseas-master-data-v1.yaml @@ -25,8 +25,25 @@ paths: /api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options: get: summary: 查询缓存的有效海外合作仓选项 - description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口' - responses: { '200': { description: 业务响应 } } + description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口;非空结果缓存30分钟,空结果不缓存;国内合作仓变更后可使用refresh=true强制刷新' + parameters: + - name: refresh + in: query + required: false + description: 是否绕过仍有效的非空缓存并重新读取国内海外合作仓 + schema: { type: boolean, default: false } + responses: + '200': + description: 业务响应,data为海外合作仓候选数组 + content: + application/json: + schema: + type: object + properties: + code: { type: string, example: '200' } + data: + type: array + items: { $ref: '#/components/schemas/PartnerWarehouseOption' } /api/platform/v1/last-leg/master-data/overseas-quotes/export: get: summary: 导出海外仓报价 @@ -161,6 +178,14 @@ components: Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } } schemas: ActiveStatus: { type: string, enum: [ACTIVE, INACTIVE] } + PartnerWarehouseOption: + type: object + required: [warehouseId, warehouseCode, warehouseName, countryCode] + properties: + warehouseId: { type: string, description: 平台公共海外合作仓雪花ID字符串 } + warehouseCode: { type: string, description: 仓库编码 } + warehouseName: { type: string, description: 仓库名称 } + countryCode: { type: string, pattern: '^[A-Z]{2}$', description: ISO国家二字码 } QuoteFee: type: object required: [item, billingUnit, unitPrice] diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java index b9b072a47..fee858866 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/OverseasMasterDataOpenApiContractTest.java @@ -13,6 +13,9 @@ class OverseasMasterDataOpenApiContractTest { String yaml = Files.readString(Path.of("src/main/resources/openapi/overseas-master-data-v1.yaml")); assertThat(yaml).contains("/api/platform/v1/last-leg/master-data/overseas-quotes:") .contains("缓存合作仓目录30分钟") + .contains("空结果不缓存") + .contains("refresh=true强制刷新") + .contains("PartnerWarehouseOption:") .contains("/api/platform/v1/last-leg/master-data/exchange-rates:") .contains("/api/platform/v1/last-leg/master-data/vehicles:") .contains("platform:overseas-master-data:exchange-rate:export") diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java new file mode 100644 index 000000000..0961c5313 --- /dev/null +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/client/DomesticPartnerWarehouseClientContractTest.java @@ -0,0 +1,50 @@ +package com.baiyue.lastleg.masterdata.quote.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 java.nio.file.Files; +import java.nio.file.Path; +import java.util.Arrays; + +import static org.assertj.core.api.Assertions.assertThat; + +class DomesticPartnerWarehouseClientContractTest { + @Test + void freezesCnInternalUrlTokenHeaderAndFourFieldDto() throws Exception { + FeignClient feign = DomesticPartnerWarehouseClient.class.getAnnotation(FeignClient.class); + assertThat(feign.name()).isEqualTo("warehouse-service"); + assertThat(feign.url()).isEqualTo("${baiyue.clients.domestic-warehouse.url:}"); + + Method method = DomesticPartnerWarehouseClient.class.getDeclaredMethod("activePartners", String.class); + assertThat(method.getAnnotation(GetMapping.class).value()) + .containsExactly("/internal/warehouses/overseas-partners"); + assertThat(method.getParameters()[0].getAnnotation(RequestHeader.class).value()) + .isEqualTo("X-Internal-Service-Token"); + assertThat(Arrays.stream(DomesticPartnerWarehouseClient.Option.class.getRecordComponents()) + .map(component -> component.getName() + ":" + component.getType().getSimpleName())) + .containsExactly("id:Long", "warehouseCode:String", "warehouseName:String", "countryCode:String"); + } + + @Test + void developmentDeploymentRequiresAndPassesTheCnUrlAndSharedInternalToken() throws Exception { + String application = Files.readString(Path.of("src/main/resources/application.yml")); + String nacos = Files.readString(Path.of("../config/nacos/overseas/lastleg-service.yml")); + String common = Files.readString(Path.of("../config/nacos/overseas/baiyue-common.yml")); + String compose = Files.readString(Path.of("../deploy/docker-compose.overseas.yml")); + String deploy = Files.readString(Path.of("../deploy/scripts/deploy-services.sh")); + + assertThat(application).contains("url: ${DOMESTIC_WAREHOUSE_URL:}") + .contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}"); + assertThat(nacos).contains("url: ${DOMESTIC_WAREHOUSE_URL}") + .contains("cache-ttl: ${DOMESTIC_WAREHOUSE_CACHE_TTL:30m}"); + assertThat(common).contains("internal-service-token: ${INTERNAL_SERVICE_TOKEN}"); + assertThat(compose).contains("INTERNAL_SERVICE_TOKEN: ${INTERNAL_SERVICE_TOKEN}") + .contains("DOMESTIC_WAREHOUSE_URL: ${DOMESTIC_WAREHOUSE_URL}"); + assertThat(deploy).contains("FIRSTLEG_EVENT_BRIDGE_URL DOMESTIC_WAREHOUSE_URL") + .contains("NACOS_SERVER_ADDR OVERSEAS_NACOS_NAMESPACE INTERNAL_SERVICE_TOKEN"); + } +} diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java index 6692c7359..63595bb05 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/controller/PlatformOverseasQuoteControllerContractTest.java @@ -3,7 +3,9 @@ package com.baiyue.lastleg.masterdata.quote.controller; import org.junit.jupiter.api.Test; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import java.lang.reflect.Method; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -26,5 +28,13 @@ class PlatformOverseasQuoteControllerContractTest { assertThat(authorities.get("update")).contains("platform:overseas-master-data:quote:edit"); assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status"); assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export"); + + Method options = Stream.of(PlatformOverseasQuoteController.class.getDeclaredMethods()) + .filter(method -> method.getName().equals("warehouseOptions")) + .findFirst().orElseThrow(); + RequestParam refresh = options.getParameters()[0].getAnnotation(RequestParam.class); + assertThat(options.getParameterTypes()).containsExactly(boolean.class); + assertThat(refresh).isNotNull(); + assertThat(refresh.defaultValue()).isEqualTo("false"); } } diff --git a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java index b226068b3..20196b976 100644 --- a/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java +++ b/lastleg-service/src/test/java/com/baiyue/lastleg/masterdata/quote/service/PartnerWarehouseDirectoryTest.java @@ -66,4 +66,35 @@ class PartnerWarehouseDirectoryTest { assertThat(option.warehouseId()).isEqualTo("101")); verify(client, times(2)).activePartners("internal-token"); } + + @Test + void forcedRefreshBypassesAStillValidPositiveSnapshot() { + when(client.activePartners("internal-token")) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 101L, "US-ONT", "安大略合作仓", "US")))) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 102L, "US-LAX", "洛杉矶合作仓", "us")))); + + assertThat(directory.options()).singleElement().satisfies(option -> + assertThat(option.warehouseId()).isEqualTo("101")); + assertThat(directory.refresh()).singleElement().satisfies(option -> { + assertThat(option.warehouseId()).isEqualTo("102"); + assertThat(option.countryCode()).isEqualTo("US"); + }); + verify(client, times(2)).activePartners("internal-token"); + } + + @Test + void malformedDomesticRowsAreFilteredAndAnEmptyFilteredResultIsNotCached() { + when(client.activePartners("internal-token")) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 101L, " ", "无编码仓", "US")))) + .thenReturn(ApiResponse.success(List.of(new DomesticPartnerWarehouseClient.Option( + 102L, "US-LAX", "洛杉矶合作仓", "US")))); + + assertThat(directory.options()).isEmpty(); + assertThat(directory.options()).singleElement().satisfies(option -> + assertThat(option.warehouseId()).isEqualTo("102")); + verify(client, times(2)).activePartners("internal-token"); + } }