修复海外报价合作仓候选缓存刷新

This commit is contained in:
2026-08-24 17:10:39 +08:00
parent 3019f5da77
commit 132d96a99b
8 changed files with 156 additions and 12 deletions
@@ -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<List<OverseasQuoteResponses.PartnerWarehouseOption>> 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<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions(
@RequestParam(defaultValue = "false") boolean refresh) {
return ApiResponse.success(service.warehouseOptions(refresh));
}
@PostMapping
@@ -73,8 +73,8 @@ public class OverseasQuoteService {
quote.getCreatedBy(), quote.getCreatedAt());
}
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions() {
return warehouses.options();
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions(boolean refresh) {
return refresh ? warehouses.refresh() : warehouses.options();
}
@Transactional
@@ -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<OverseasQuoteResponses.PartnerWarehouseOption> options() {
return options(false);
}
/** Bypasses a still-valid positive snapshot after CN partner master data changes. */
public List<OverseasQuoteResponses.PartnerWarehouseOption> refresh() {
return options(true);
}
private List<OverseasQuoteResponses.PartnerWarehouseOption> 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<OverseasQuoteResponses.PartnerWarehouseOption> 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<OverseasQuoteResponses.PartnerWarehouseOption> options, Instant expiresAt) {
private Snapshot { options = List.copyOf(options); }
}
@@ -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]
@@ -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")
@@ -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");
}
}
@@ -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");
}
}
@@ -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");
}
}