修复海外报价合作仓候选缓存刷新
This commit is contained in:
+6
-3
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+27
-5
@@ -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]
|
||||
|
||||
+3
@@ -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")
|
||||
|
||||
+50
@@ -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");
|
||||
}
|
||||
}
|
||||
+10
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
+31
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user