修复海外报价合作仓候选缓存刷新
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.PutMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
@@ -54,9 +55,11 @@ public class PlatformOverseasQuoteController {
|
|||||||
@GetMapping("/warehouse-options")
|
@GetMapping("/warehouse-options")
|
||||||
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
@PreAuthorize("hasAuthority('" + OverseasPermissions.QUOTE_LIST + "')")
|
||||||
@Operation(summary = "List cached active overseas partner warehouse options",
|
@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")
|
description = "Required authority: platform:overseas-master-data:quote:list; the browser never calls CN internal APIs; "
|
||||||
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions() {
|
+ "refresh=true bypasses the positive cache after CN partner master data changes")
|
||||||
return ApiResponse.success(service.warehouseOptions());
|
public ApiResponse<List<OverseasQuoteResponses.PartnerWarehouseOption>> warehouseOptions(
|
||||||
|
@RequestParam(defaultValue = "false") boolean refresh) {
|
||||||
|
return ApiResponse.success(service.warehouseOptions(refresh));
|
||||||
}
|
}
|
||||||
|
|
||||||
@PostMapping
|
@PostMapping
|
||||||
|
|||||||
+2
-2
@@ -73,8 +73,8 @@ public class OverseasQuoteService {
|
|||||||
quote.getCreatedBy(), quote.getCreatedAt());
|
quote.getCreatedBy(), quote.getCreatedAt());
|
||||||
}
|
}
|
||||||
|
|
||||||
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions() {
|
public List<OverseasQuoteResponses.PartnerWarehouseOption> warehouseOptions(boolean refresh) {
|
||||||
return warehouses.options();
|
return refresh ? warehouses.refresh() : warehouses.options();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
+27
-5
@@ -6,6 +6,7 @@ import jakarta.annotation.Resource;
|
|||||||
import org.springframework.beans.factory.annotation.Value;
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.server.ResponseStatusException;
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.time.Clock;
|
import java.time.Clock;
|
||||||
@@ -23,26 +24,39 @@ public class PartnerWarehouseDirectory {
|
|||||||
private volatile Snapshot snapshot;
|
private volatile Snapshot snapshot;
|
||||||
|
|
||||||
public List<OverseasQuoteResponses.PartnerWarehouseOption> options() {
|
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;
|
Snapshot current = snapshot;
|
||||||
Instant now = clock.instant();
|
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) {
|
synchronized (this) {
|
||||||
current = snapshot;
|
current = snapshot;
|
||||||
now = clock.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();
|
||||||
var response = client.activePartners(internalToken);
|
var response = client.activePartners(internalToken);
|
||||||
if (response == null || !response.successful() || response.data() == null) {
|
if (response == null || !response.successful() || response.data() == null) {
|
||||||
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,
|
||||||
"国内海外合作仓候选暂不可用");
|
"国内海外合作仓候选暂不可用");
|
||||||
}
|
}
|
||||||
List<OverseasQuoteResponses.PartnerWarehouseOption> options = response.data().stream()
|
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(),
|
.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();
|
.toList();
|
||||||
// An empty directory can be a short-lived cross-region visibility gap after a partner
|
// 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.
|
// 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));
|
snapshot = new Snapshot(options, now.plus(ttl));
|
||||||
return snapshot.options();
|
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 record Snapshot(List<OverseasQuoteResponses.PartnerWarehouseOption> options, Instant expiresAt) {
|
||||||
private Snapshot { options = List.copyOf(options); }
|
private Snapshot { options = List.copyOf(options); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,8 +25,25 @@ paths:
|
|||||||
/api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options:
|
/api/platform/v1/last-leg/master-data/overseas-quotes/warehouse-options:
|
||||||
get:
|
get:
|
||||||
summary: 查询缓存的有效海外合作仓选项
|
summary: 查询缓存的有效海外合作仓选项
|
||||||
description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口'
|
description: '权限:platform:overseas-master-data:quote:list;浏览器不直接调用国内内部接口;非空结果缓存30分钟,空结果不缓存;国内合作仓变更后可使用refresh=true强制刷新'
|
||||||
responses: { '200': { description: 业务响应 } }
|
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:
|
/api/platform/v1/last-leg/master-data/overseas-quotes/export:
|
||||||
get:
|
get:
|
||||||
summary: 导出海外仓报价
|
summary: 导出海外仓报价
|
||||||
@@ -161,6 +178,14 @@ components:
|
|||||||
Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
Size: { name: size, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 20 } }
|
||||||
schemas:
|
schemas:
|
||||||
ActiveStatus: { type: string, enum: [ACTIVE, INACTIVE] }
|
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:
|
QuoteFee:
|
||||||
type: object
|
type: object
|
||||||
required: [item, billingUnit, unitPrice]
|
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"));
|
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:")
|
assertThat(yaml).contains("/api/platform/v1/last-leg/master-data/overseas-quotes:")
|
||||||
.contains("缓存合作仓目录30分钟")
|
.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/exchange-rates:")
|
||||||
.contains("/api/platform/v1/last-leg/master-data/vehicles:")
|
.contains("/api/platform/v1/last-leg/master-data/vehicles:")
|
||||||
.contains("platform:overseas-master-data:exchange-rate:export")
|
.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.junit.jupiter.api.Test;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
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.Map;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import java.util.stream.Stream;
|
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("update")).contains("platform:overseas-master-data:quote:edit");
|
||||||
assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status");
|
assertThat(authorities.get("status")).contains("platform:overseas-master-data:quote:status");
|
||||||
assertThat(authorities.get("export")).contains("platform:overseas-master-data:quote:export");
|
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"));
|
assertThat(option.warehouseId()).isEqualTo("101"));
|
||||||
verify(client, times(2)).activePartners("internal-token");
|
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