feat: add self-operated analytics and enforce Lastleg module boundaries
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
# Copy to .env.local for local WMS connectivity verification.
|
||||
LINGXING_WMS_APP_KEY=
|
||||
LINGXING_WMS_APP_SECRET=
|
||||
LINGXING_OMS_APP_KEY=
|
||||
LINGXING_OMS_APP_SECRET=
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
|
||||
- 应用/配置:`IntegrationServiceApplication.java`、`config/IntegrationProperties.java`
|
||||
- 内部接口:`controller/IntegrationProviderController.java`、`WeightOcrController.java`
|
||||
- 领星 WMS 自研系统出库只读查询:`controller/LingxingInternalController.java`、`provider/lingxing/`、`dto/lingxing/`、`config/LingxingWmsProperties.java`;内部契约 `openapi/lingxing-wms-v1.yaml` 位于 `src/main/resources/`。内部提供出库单页摘要、独立按单定位、出库详情、仓库目录及单仓产品库存单页查询;统一账号节流、有限退避和严格分页校验,保留未知数量与源时间原值。不编排定时采集、多页库存同步或业务持久化;本地隔离启动与环境变量入口见 `README.md`,真实调用默认关闭。
|
||||
- OCR 编排:`service/ocr/WeightOcrBatchService.java`、`ImageCropper.java`、`WeightCandidateSelector.java`
|
||||
- 供应商适配:`provider/IntegrationProviderAdapter.java`、`BaiduOcrAdapter.java`、`AmazonLogisticsAdapter.java`
|
||||
- 海外仓业务船节点外部适配:内部契约 `controller/IntegrationProviderController.java` 的 `/internal/integrations/carrier-schedules/query`,统一十节点与四类异常 DTO `dto/CarrierScheduleDtos.java`,provider SPI `provider/CarrierScheduleProviderAdapter.java`,标准枚举 `domain/CarrierScheduleStandardNode.java`、`CarrierScheduleExceptionCode.java`;美森网页会话、动态 `ttr` 与公开查询代理适配见 `provider/MatsonTrackingClient.java`、`MatsonCarrierScheduleProviderAdapter.java`,由 `MATSON_TRACKING_ENABLED` 显式启用;SML 的 122→125→124→126 JSON 调用链与 A/E 时间解析见 `provider/SmlTrackingClient.java`、`SmlCarrierScheduleProviderAdapter.java`,由 `SML_TRACKING_ENABLED` 显式启用;ZIM 正式 Tracing API 的 OAuth2 token 缓存、401 单次刷新及柜号活动解析见 `provider/ZimTrackingClient.java`、`ZimCarrierScheduleProviderAdapter.java`,凭据只从外部 secret 注入并由 `ZIM_TRACKING_ENABLED` 显式启用;长荣柜号→关联提单两段 UTF-8 HTML 查询、表头定位与日粒度事实解析见 `provider/EvergreenTrackingClient.java`、`EvergreenCarrierScheduleProviderAdapter.java`,由 `EMC_TRACKING_ENABLED` 显式启用且不输出完整提单号;合德 UTF-8 HTML 主表解析见 `provider/HedeTrackingClient.java`、`HedeCarrierScheduleProviderAdapter.java`,由 `HEDE_TRACKING_ENABLED` 显式启用且当前只自动解析柜号;轮询任务、租约、订舱/柜次关联、最新实际节点、节点/异常事实和历史归 `firstleg/portschedule/`,标准见 `../../docs/海外仓业务/船节点标准化契约.md`
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Integration 本地领星 WMS 查询
|
||||
|
||||
本入口用于在现有 Java 服务内验证 WMS 只读调用。仅返回单页出库摘要,不启动月度采集、库存全量同步、定时任务或数据库写入。业务方案见[领星一件代发数据统计一期方案](../../docs/自营业务/领星一件代发数据统计一期方案.md)。
|
||||
|
||||
后续开发交接见[接口联调手册](../../docs/自营业务/领星WMS接口联调手册.md):已另行用只读 HTTP 验证仓库发现、一件代发详情和按仓产品库存全分页。此处现有 Java 接口仍只有出库摘要;库存内部接口、分仓任务和持久化尚待开发,不能以外部直连成功推定 Java 功能已经具备。
|
||||
|
||||
## 凭据与开关
|
||||
|
||||
从 WMS 管理后台的“个人中心 → API 信息 → 自研系统”获取 `appKey` 和 `appSecret`,填写本目录 Git 忽略的 `.env.local`,格式参考 [.env.example](.env.example)。这两个值分别加载为 `LINGXING_OMS_APP_KEY` 和 `LINGXING_OMS_APP_SECRET` 进程环境变量,不提交真实值。Spring 本身不会自动读取 `.env.local`,应在启动 Java 的同一终端加载。该凭据属于 OMP 服务商内部系统对接,不使用 OMS 货主或 WMS 智能设备协议。
|
||||
|
||||
正式应用配置默认 `LINGXING_WMS_ENABLED=false`。下面的本地命令显式启用适配器,为该本地实例生成内部调用令牌。不要复用共享环境令牌。
|
||||
|
||||
测试部署通过 `deploy/docker-compose.yml` 将 `LINGXING_OMS_APP_KEY`、`LINGXING_OMS_APP_SECRET` 传入 Integration 容器。现有 Jenkins 下游部署任务读取 `firstleg-test-env` Secret File;可将这两项加入该文件,或在实际执行部署的下游服务任务中以凭据绑定注入同名环境变量。只在上游总控任务设置变量不会自动传给下游任务。变量命名采用用户指定的 `OMS`,接口协议仍为当前 WMS 自研系统(OMP);真实凭据是否有该接口权限仍待联调核验。不要将密钥作为普通构建参数或打印完整 Compose 配置。
|
||||
|
||||
2026-10-12 用户确认唯一账号、所有源时间按北京时间、数量按 SKU 件数。Lastleg 应用配置使用 `source-account=lingxing-primary`(内部稳定标识,不是 appKey)、`source-query-timezone=Asia/Shanghai`、`quantity-unit=PCS`,分仓配置的源时区默认 `Asia/Shanghai`;数量仍保留十进制精度。仓库编码须在获得真实仓库目录后配置到 `baiyue.self-operated-analytics.warehouses`,不预填虚构仓库。`SELFOP_EXECUTION_ENABLED` 和 `SELFOP_SALES_PUBLICATION_ENABLED` 均默认关闭。
|
||||
|
||||
## 本地启动
|
||||
|
||||
先在后端仓库根目录执行构建,包含相关模块的编译与测试:
|
||||
|
||||
```powershell
|
||||
mvn -pl integration-service -am package
|
||||
```
|
||||
|
||||
然后在同一目录的 PowerShell 中执行。只加载明确允许的两个名称,不执行环境文件内的代码:
|
||||
|
||||
```powershell
|
||||
$credentialNames = @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET')
|
||||
foreach ($line in [IO.File]::ReadAllLines((Join-Path (Get-Location) 'integration-service/.env.local'))) {
|
||||
if ($line -match '^\s*(LINGXING_OMS_APP_KEY|LINGXING_OMS_APP_SECRET)\s*=(.*)$') {
|
||||
$credentialName = $matches[1]
|
||||
$credentialValue = $matches[2].Trim()
|
||||
if ($credentialValue.Length -ge 2 -and
|
||||
(($credentialValue.StartsWith('"') -and $credentialValue.EndsWith('"')) -or
|
||||
($credentialValue.StartsWith("'") -and $credentialValue.EndsWith("'")))) {
|
||||
$credentialValue = $credentialValue.Substring(1, $credentialValue.Length - 2)
|
||||
}
|
||||
[Environment]::SetEnvironmentVariable($credentialName, $credentialValue, 'Process')
|
||||
}
|
||||
}
|
||||
foreach ($credentialName in $credentialNames) {
|
||||
if ([string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($credentialName, 'Process'))) {
|
||||
throw "Missing credential: $credentialName"
|
||||
}
|
||||
}
|
||||
$env:INTERNAL_SERVICE_TOKEN = [guid]::NewGuid().ToString()
|
||||
$localInternalToken = $env:INTERNAL_SERVICE_TOKEN
|
||||
$env:INTEGRATION_LOCAL_PORT = '19210'
|
||||
$buildJar = (Resolve-Path 'integration-service/target/integration-service-1.0.0-SNAPSHOT.jar').Path
|
||||
$localJava = Start-Process -FilePath (Get-Command java).Source -WindowStyle Hidden -PassThru `
|
||||
-ArgumentList @('-jar', ('"' + $buildJar + '"'),
|
||||
'--spring.config.location=classpath:/application-lingxing-local.yml',
|
||||
'--baiyue.integration.lingxing-wms.enabled=true') `
|
||||
-RedirectStandardOutput 'integration-service/target/lingxing-local.stdout.log' `
|
||||
-RedirectStandardError 'integration-service/target/lingxing-local.stderr.log'
|
||||
```
|
||||
|
||||
该配置仅监听 `127.0.0.1`,关闭 Nacos Config、Discovery、服务注册及 Spring 定时任务,不导入共享环境配置。若端口占用,选择其他本地端口并同步替换后续 URL,不停止未知进程。确认 `http://127.0.0.1:19210/actuator/health` 返回 `UP` 后再查询。
|
||||
|
||||
## 读取一条出库单
|
||||
|
||||
在保留上述变量的同一 PowerShell 中执行一次:
|
||||
|
||||
```powershell
|
||||
$businessZone = [TimeZoneInfo]::FindSystemTimeZoneById('China Standard Time')
|
||||
$queryEnd = [TimeZoneInfo]::ConvertTimeFromUtc([DateTime]::UtcNow, $businessZone)
|
||||
$query = @{
|
||||
startTime = $queryEnd.AddMonths(-1).ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
endTime = $queryEnd.ToString('yyyy-MM-ddTHH:mm:ss')
|
||||
current = 1
|
||||
size = 1
|
||||
} | ConvertTo-Json -Compress
|
||||
$result = Invoke-RestMethod -Method Post `
|
||||
-Uri 'http://127.0.0.1:19210/internal/integrations/lingxing/wms/outbound-orders/query' `
|
||||
-Headers @{'X-Internal-Service-Token' = $localInternalToken} `
|
||||
-ContentType 'application/json' -Body $query
|
||||
$result | ConvertTo-Json -Depth 6
|
||||
```
|
||||
|
||||
返回摘要只包含出库单号、来源单号、货主编码、仓库编码、状态、创建时间和出库时间,不返回收件人或地址。业务 `code="200"` 且 `records` 包含一条才表示已读到样本;空页表示调用成功但未取得样本;`B0001`/`S0503` 表示明确失败,保留 `errorCode`、来源数值错误码及请求标识排查,不能当作零销量。
|
||||
|
||||
当前调用 OMP `POST /openapi/v2/order/delivery/page`,固定 `timeType=outboundTime`、`status=100` 已出库,窗口最多向前一个自然月。该本地保护不代表供应商历史上限;单页样本也不代表整月采集完成。订单详情、全量库存、增量任务和统计落库仍按业务方案后续实现。
|
||||
|
||||
测试完毕,在同一终端停止本次启动的进程,并清理当前终端注入的变量:
|
||||
|
||||
```powershell
|
||||
if (-not $localJava.HasExited) { Stop-Process -Id $localJava.Id }
|
||||
foreach ($credentialName in @('LINGXING_OMS_APP_KEY', 'LINGXING_OMS_APP_SECRET', 'INTERNAL_SERVICE_TOKEN')) {
|
||||
[Environment]::SetEnvironmentVariable($credentialName, $null, 'Process')
|
||||
}
|
||||
$localInternalToken = $null
|
||||
$credentialValue = $null
|
||||
```
|
||||
|
||||
## 接口与验证
|
||||
|
||||
- 2026-10-12 已通过本地 Java 实例调用 OMP WMS 出库分页,按最近 1 个月、`current=1`、`size=1` 成功返回 1 条已出库摘要;无内部令牌请求被拒绝。验证后本地进程已停止。该结果仅证明当前只读链路可用,不代表全量采集或库存同步完成。
|
||||
- 实测成功响应为数值 `code=200`,但缺少官方示例及 Schema 中的 `success`。客户端以成功码和有效分页结构共同判定;`success` 出现时仍须为布尔 `true`。此差异已加入回归测试,失败响应不转换为空页。
|
||||
- 内部前缀为 `POST /internal/integrations/lingxing/wms`,提供 `/outbound-orders/query`、`/outbound-orders/locate`、`/outbound-orders/detail`、`/warehouses/query`、`/product-inventory/query`;仅接受 `X-Internal-Service-Token`,不经公网 Gateway。各请求只返回一页或一单,多页与业务版本归 Lastleg 编排。定位不传时间/状态;产品库存必须指定仓库、保留全部品质和零库存。源时间原值与 BigDecimal 数量未知值保留,不推断时区或单位。
|
||||
- [OpenAPI](src/main/resources/openapi/lingxing-wms-v1.yaml)定义输入、分页摘要和失败结构。
|
||||
- 从后端根执行 `mvn -pl integration-service -am test` 与 `node --test scripts/gateway-route-contract.test.mjs`。
|
||||
- 本地模拟 HTTP 单测验证签名、请求体、敏感信息隔离和错误处理;不能代替真实领星样本验证。官方入口为[服务商内部系统对接](https://apidoc-omp.xlwms.com/)与[一件代发出库单 WMS 分页查询](https://apidoc-omp.xlwms.com/reference/post_openapi-v2-order-delivery-page-1)。
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.integration.config;
|
||||
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/** An operator-attested exclusive window; it is not a distributed account lock. */
|
||||
@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress")
|
||||
public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode,
|
||||
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
|
||||
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
|
||||
@DefaultValue("5m") Duration cooldownFallback) {
|
||||
public LingxingEgressProperties {
|
||||
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
|
||||
environmentLabel = environmentLabel == null ? "test" : environmentLabel;
|
||||
minStartInterval = minStartInterval == null ? Duration.ofSeconds(5) : minStartInterval;
|
||||
cooldownFallback = cooldownFallback == null ? Duration.ofMinutes(5) : cooldownFallback;
|
||||
if (minStartInterval.compareTo(Duration.ofSeconds(5)) < 0 || minStartInterval.compareTo(Duration.ofMinutes(5)) > 0
|
||||
|| cooldownFallback.compareTo(Duration.ofSeconds(5)) < 0 || cooldownFallback.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("领星出口间隔至少5秒,冷却配置须为有界正值");
|
||||
}
|
||||
if (budgetAuthorityBaseUri != null && (budgetAuthorityBaseUri.getHost() == null
|
||||
|| !("http".equals(budgetAuthorityBaseUri.getScheme()) || "https".equals(budgetAuthorityBaseUri.getScheme()))
|
||||
|| budgetAuthorityBaseUri.getUserInfo() != null || budgetAuthorityBaseUri.getQuery() != null
|
||||
|| budgetAuthorityBaseUri.getFragment() != null || !budgetAuthorityBaseUri.getPath().isEmpty())) {
|
||||
throw new IllegalArgumentException("领星预算授权地址必须为受控内部服务源地址");
|
||||
}
|
||||
}
|
||||
@Override public String toString() {
|
||||
return "LingxingEgressProperties[collectionMode=" + collectionMode + ", environmentLabel=" + environmentLabel + "]";
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -13,18 +13,15 @@ public record LingxingWmsProperties(
|
||||
String appKey,
|
||||
String appSecret,
|
||||
@DefaultValue("5s") Duration connectTimeout,
|
||||
@DefaultValue("15s") Duration requestTimeout,
|
||||
@DefaultValue("1s") Duration minRequestInterval
|
||||
@DefaultValue("15s") Duration requestTimeout
|
||||
) {
|
||||
public LingxingWmsProperties {
|
||||
baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri;
|
||||
connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout;
|
||||
requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout;
|
||||
minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval;
|
||||
if (connectTimeout.isZero() || connectTimeout.isNegative()
|
||||
|| requestTimeout.isZero() || requestTimeout.isNegative()
|
||||
|| minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间");
|
||||
|| requestTimeout.isZero() || requestTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("领星 WMS 超时必须为正数");
|
||||
}
|
||||
String host = baseUri.getHost();
|
||||
boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host)
|
||||
|
||||
+67
-4
@@ -5,6 +5,11 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
|
||||
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
|
||||
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||
@@ -25,19 +30,76 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class LingxingInternalController {
|
||||
private final InternalServiceTokenGuard tokenGuard;
|
||||
private final LingxingWmsClient client;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) {
|
||||
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client, ObjectMapper mapper) {
|
||||
this.tokenGuard = tokenGuard;
|
||||
this.client = client;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要")
|
||||
@PostMapping("/outbound-orders/query")
|
||||
public ApiResponse<PageResult> query(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody QueryRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryPage(request));
|
||||
return ApiResponse.success(client.queryPage(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取正常仓库目录单页")
|
||||
@PostMapping("/warehouses/query")
|
||||
public ApiResponse<QueryPage<Warehouse>> warehouses(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody WarehouseQueryRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryWarehouses(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "按单号定位单页,不附加时间或状态筛选")
|
||||
@PostMapping("/outbound-orders/locate")
|
||||
public ApiResponse<PageResult> locate(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody LocateRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.locate(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "按仓库、货主、发货单号读取完整 SKU 详情")
|
||||
@PostMapping("/outbound-orders/detail")
|
||||
public ApiResponse<OrderDetail> detail(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody DetailRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryDetail(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
|
||||
@PostMapping("/product-inventory/query")
|
||||
public ApiResponse<QueryPage<ProductStock>> inventory(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody InventoryQueryRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryInventory(request, context(context)));
|
||||
}
|
||||
|
||||
private EgressContext context(String header) {
|
||||
if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126))
|
||||
throw invalidContext();
|
||||
try {
|
||||
EgressContext context = mapper.readerFor(EgressContext.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
|
||||
DeserializationFeature.FAIL_ON_TRAILING_TOKENS).with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION).readValue(header);
|
||||
if (context == null || !context.wellFormed()) throw invalidContext();
|
||||
return context;
|
||||
} catch (Exception ignored) { throw invalidContext(); }
|
||||
}
|
||||
private static LingxingWmsException invalidContext() {
|
||||
return new LingxingWmsException("LINGXING_WMS_EGRESS_CONTEXT_INVALID", "领星安全出口控制上下文缺失或无效", false, null, null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(LingxingWmsException.class)
|
||||
@@ -46,10 +108,11 @@ public class LingxingInternalController {
|
||||
ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR;
|
||||
return ApiResponse.of(code, exception.getMessage(),
|
||||
new ProviderFailure(exception.code(), exception.retryable(),
|
||||
exception.upstreamStatus(), exception.sourceCode()),
|
||||
exception.upstreamStatus(), exception.sourceCode(), exception.egress() == null
|
||||
? new AttemptMetadata(null, 0, false, null) : exception.egress(), exception.denial()),
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
|
||||
}
|
||||
|
||||
public record ProviderFailure(String errorCode, boolean retryable,
|
||||
Integer upstreamStatus, Integer sourceCode) { }
|
||||
Integer upstreamStatus, Integer sourceCode, AttemptMetadata egress, AuthorizationDenial denial) { }
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.integration.dto.lingxing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Control identities are supplied by the collector and revalidated by its durable authority. */
|
||||
public final class LingxingEgressDtos {
|
||||
private LingxingEgressDtos() { }
|
||||
public enum CollectionMode { OFF, VALIDATION, BUSINESS }
|
||||
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY }
|
||||
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
|
||||
String sessionId, String attemptId, String exclusiveGrantId) {
|
||||
public boolean wellFormed() {
|
||||
return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS)
|
||||
&& identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128)
|
||||
&& identifier(attemptId, 128) && identifier(exclusiveGrantId, 128);
|
||||
}
|
||||
private static boolean identifier(String value, int maximum) {
|
||||
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
|
||||
}
|
||||
}
|
||||
public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { }
|
||||
public record AttemptPermit(EgressContext context, Operation operation, String requestHash,
|
||||
int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed,
|
||||
boolean budgetConsumed) { }
|
||||
public record CompletionRequest(EgressContext context, Operation operation, String requestHash,
|
||||
int actualHttpAttempts, String errorCode, Integer upstreamStatus,
|
||||
Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { }
|
||||
public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused,
|
||||
Instant cooldownUntil) { }
|
||||
public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { }
|
||||
}
|
||||
+32
-2
@@ -3,10 +3,14 @@ package com.baiyue.integration.dto.lingxing;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
|
||||
|
||||
public final class LingxingWmsDtos {
|
||||
private LingxingWmsDtos() { }
|
||||
@@ -14,12 +18,38 @@ public final class LingxingWmsDtos {
|
||||
public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime,
|
||||
@Min(1) int current, @Min(1) @Max(100) int size) { }
|
||||
|
||||
// Preserve source time strings until the account's source timezone has been verified.
|
||||
public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { }
|
||||
public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo,
|
||||
@Min(1) int current, @Min(1) @Max(1000) int size) { }
|
||||
public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode,
|
||||
@NotBlank @Size(max=64) String customerCode,
|
||||
@NotBlank @Size(max=128) String deliveryNo) { }
|
||||
public record InventoryQueryRequest(@NotBlank @Size(max=64) String warehouseCode,
|
||||
@Min(1) int current, @Min(1) @Max(1000) int size) { }
|
||||
public record Warehouse(String whCode, String whNameCn, Integer warehouseType,
|
||||
Integer status, String timeZone) { }
|
||||
public record SkuLine(String productSku, String productName, BigDecimal actualDeliveryQty,
|
||||
BigDecimal deliveryQty) { }
|
||||
public record OrderDetail(String deliveryNo, String sourceNo, String customerCode, String whCode,
|
||||
Integer status, String orderCreateTime, String outboundTime,
|
||||
List<SkuLine> skuVOList, int sourceCode, Instant observedAt, AttemptMetadata egress) {
|
||||
public OrderDetail { skuVOList = List.copyOf(skuVOList); }
|
||||
}
|
||||
public record ProductStock(String customerCode, String whCode, String productSku, String productName,
|
||||
Integer productQuality, BigDecimal stkQty, BigDecimal freezeQty,
|
||||
BigDecimal useStkQty, BigDecimal transitQty, String innerBatchNo,
|
||||
String outBatchNo, String uniqueKey4tenant) { }
|
||||
public record QueryPage<T>(List<T> records, Long total, int current, int size, int pages,
|
||||
int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
|
||||
public QueryPage { records = List.copyOf(records); }
|
||||
}
|
||||
|
||||
// Preserve source strings; the collector interprets confirmed Asia/Shanghai source time.
|
||||
public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode,
|
||||
Integer status, String createTime, String outboundTime) { }
|
||||
|
||||
public record PageResult(List<OrderSummary> records, Long total, int current, int size,
|
||||
int sourceCode, Instant observedAt) {
|
||||
int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
|
||||
public PageResult { records = List.copyOf(records); }
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
|
||||
/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */
|
||||
public interface LingxingBudgetAuthority {
|
||||
AttemptPermit authorize(AuthorizationRequest request);
|
||||
void complete(CompletionRequest request);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
|
||||
|
||||
/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */
|
||||
public final class LingxingBudgetDeniedException extends RuntimeException {
|
||||
private final AuthorizationDenial denial;
|
||||
public LingxingBudgetDeniedException(AuthorizationDenial denial) {
|
||||
super("领星持久预算授权拒绝"); this.denial = denial;
|
||||
}
|
||||
public AuthorizationDenial denial() { return denial; }
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Local serialization supplements, never replaces, the externally attested window and durable budget. */
|
||||
@Component
|
||||
public final class LingxingEgressGate {
|
||||
private final LingxingEgressProperties properties;
|
||||
private final LingxingBudgetAuthority authority;
|
||||
private final Clock clock;
|
||||
private final Waiter waiter;
|
||||
private final Semaphore inFlight = new Semaphore(1, true);
|
||||
private volatile boolean paused;
|
||||
private volatile boolean recoverableRatePause;
|
||||
private Instant lastStart;
|
||||
private Instant cooldownUntil;
|
||||
@Autowired public LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority) {
|
||||
this(properties, authority, Clock.systemUTC(), Thread::sleep);
|
||||
}
|
||||
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
|
||||
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
|
||||
}
|
||||
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
|
||||
try { return guarded(context, operation, hash, call); }
|
||||
catch (LingxingWmsException error) {
|
||||
if (error.egress() == null) error.withEgress(new AttemptMetadata(
|
||||
context != null && identifier(context.attemptId(), 128) ? context.attemptId() : null, 0, paused, cooldownUntil));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private <T> T guarded(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
|
||||
check(context);
|
||||
if (operation == null || hash == null || !hash.matches("[a-f0-9]{64}")) throw failure("EGRESS_CONTEXT_INVALID");
|
||||
boolean acquired = false;
|
||||
try {
|
||||
while (!(acquired = inFlight.tryAcquire(100, TimeUnit.MILLISECONDS))) check(context);
|
||||
check(context);
|
||||
if (lastStart != null) {
|
||||
Instant next = lastStart.plus(properties.minStartInterval());
|
||||
long wait = java.time.Duration.between(clock.instant(), next).toMillis();
|
||||
if (wait > 0) waiter.sleep(wait + 1);
|
||||
}
|
||||
check(context);
|
||||
AuthorizationRequest request = new AuthorizationRequest(context, operation, hash);
|
||||
AttemptPermit permit;
|
||||
try { permit = authority.authorize(request); }
|
||||
catch (LingxingBudgetDeniedException knownDecision) {
|
||||
throw failure("BUDGET_AUTHORIZATION_DENIED").withDenial(knownDecision.denial());
|
||||
}
|
||||
catch (RuntimeException error) { recoverableRatePause = false; paused = true; throw failure("BUDGET_AUTHORITY_UNAVAILABLE"); }
|
||||
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|
||||
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|
||||
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|
||||
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|
||||
|| permit.expiresAt().isAfter(properties.externalExclusiveUntil())) {
|
||||
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
|
||||
}
|
||||
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state.
|
||||
// Time alone never clears the pause. VALIDATION and uncertain callbacks cannot reach this branch.
|
||||
if (paused && recoverableRatePause && properties.collectionMode() == CollectionMode.BUSINESS) {
|
||||
paused = false; recoverableRatePause = false; cooldownUntil = null;
|
||||
}
|
||||
AttemptHandle handle = new AttemptHandle(context, permit.expiresAt());
|
||||
T result = null;
|
||||
LingxingWmsException failure = null;
|
||||
try { result = call.run(handle); }
|
||||
catch (LingxingWmsException error) { failure = error; }
|
||||
catch (RuntimeException error) { failure = failure("LOCAL_PROCESSING_FAILED"); }
|
||||
CompletionRequest completion = new CompletionRequest(context, operation, hash, handle.actualHttpAttempts,
|
||||
failure == null ? null : failure.code(), failure == null ? Integer.valueOf(200) : failure.upstreamStatus(),
|
||||
failure == null ? Integer.valueOf(200) : failure.sourceCode(), paused, cooldownUntil);
|
||||
try { authority.complete(completion); }
|
||||
catch (RuntimeException error) {
|
||||
recoverableRatePause = false; paused = true;
|
||||
// Unconfirmed completion must remain distinguishable even when the provider already failed.
|
||||
failure = failure("BUDGET_COMPLETION_UNCONFIRMED");
|
||||
}
|
||||
if (failure != null) throw failure.withEgress(handle.metadata());
|
||||
return result;
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt(); throw failure("INTERRUPTED");
|
||||
} finally { if (acquired) inFlight.release(); }
|
||||
}
|
||||
private void check(EgressContext context) {
|
||||
if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF");
|
||||
if (properties.collectionMode() == CollectionMode.BUSINESS && !"production".equals(properties.environmentLabel()))
|
||||
throw failure("BUSINESS_ENVIRONMENT_REJECTED");
|
||||
if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS
|
||||
|| cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED");
|
||||
if (context == null || context.executionPurpose() != properties.collectionMode()
|
||||
|| !context.wellFormed()
|
||||
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
|
||||
if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|
||||
|| properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())
|
||||
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED");
|
||||
}
|
||||
private static boolean identifier(String value, int maximum) {
|
||||
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
|
||||
}
|
||||
private static LingxingWmsException failure(String code) {
|
||||
return new LingxingWmsException("LINGXING_WMS_" + code, "领星安全出口拒绝请求(" + code + ")", false, null, null);
|
||||
}
|
||||
public final class AttemptHandle {
|
||||
private final EgressContext context;
|
||||
private final Instant expiresAt;
|
||||
private int actualHttpAttempts;
|
||||
private AttemptHandle(EgressContext context, Instant expiresAt) { this.context = context; this.expiresAt = expiresAt; }
|
||||
/** Immediately before send, after signing. One permit can never send twice. */
|
||||
public void startHttp() {
|
||||
check(context);
|
||||
if (actualHttpAttempts != 0 || !clock.instant().isBefore(expiresAt)) throw failure("ATTEMPT_NOT_SENDABLE");
|
||||
actualHttpAttempts = 1; lastStart = clock.instant();
|
||||
}
|
||||
public void rateLimited(Instant retryAfter) {
|
||||
Instant fallback = clock.instant().plus(properties.cooldownFallback());
|
||||
cooldownUntil = retryAfter != null && retryAfter.isAfter(fallback) ? retryAfter : fallback;
|
||||
recoverableRatePause = properties.collectionMode() == CollectionMode.BUSINESS;
|
||||
paused = true;
|
||||
}
|
||||
public AttemptMetadata metadata() { return new AttemptMetadata(context.attemptId(), actualHttpAttempts, paused, cooldownUntil); }
|
||||
}
|
||||
@FunctionalInterface interface Waiter { void sleep(long millis) throws InterruptedException; }
|
||||
@FunctionalInterface public interface AuthorizedCall<T> { T run(AttemptHandle handle); }
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.net.http.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */
|
||||
@Component
|
||||
public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority {
|
||||
private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/";
|
||||
private final LingxingEgressProperties properties;
|
||||
private final ObjectMapper mapper;
|
||||
private final String internalToken;
|
||||
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper,
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
|
||||
this.properties = properties; this.mapper = mapper; this.internalToken = internalToken;
|
||||
}
|
||||
@Override public AttemptPermit authorize(AuthorizationRequest request) {
|
||||
JsonNode data = post("authorize", request);
|
||||
if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber()
|
||||
|| data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean()
|
||||
|| !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean()
|
||||
|| !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject()
|
||||
|| !data.path("expiresAt").isTextual()) throw unavailable();
|
||||
try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); }
|
||||
catch (Exception ignored) { throw unavailable(); }
|
||||
}
|
||||
@Override public void complete(CompletionRequest request) {
|
||||
JsonNode data = post("complete", request);
|
||||
if (!data.isBoolean() || !data.booleanValue()) throw unavailable();
|
||||
}
|
||||
private JsonNode post(String suffix, Object payload) {
|
||||
if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank()
|
||||
|| internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable();
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix))
|
||||
.timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() != 200) throw unavailable();
|
||||
JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body());
|
||||
if (root == null || !root.isObject() || !root.path("code").isTextual()
|
||||
|| !root.hasNonNull("data")) throw unavailable();
|
||||
if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) {
|
||||
JsonNode data = root.get("data");
|
||||
if (!data.isObject() || !data.path("errorCode").isTextual()
|
||||
|| !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")
|
||||
|| !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable();
|
||||
AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class)
|
||||
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper));
|
||||
throw new LingxingBudgetDeniedException(denial);
|
||||
}
|
||||
if (!"200".equals(root.path("code").textValue())) throw unavailable();
|
||||
return root.get("data");
|
||||
} catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision;
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt(); throw unavailable();
|
||||
} catch (Exception ignored) { throw unavailable(); }
|
||||
}
|
||||
private static LingxingWmsException unavailable() {
|
||||
return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null);
|
||||
}
|
||||
}
|
||||
+222
-67
@@ -4,10 +4,21 @@ import com.baiyue.integration.config.LingxingWmsProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import java.util.function.BiFunction;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashSet;
|
||||
import java.util.function.Function;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -25,7 +36,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
@Component
|
||||
public final class LingxingWmsClient {
|
||||
@@ -37,49 +47,174 @@ public final class LingxingWmsClient {
|
||||
private final LingxingWmsSigner signer;
|
||||
private final HttpClient httpClient;
|
||||
private final Clock clock;
|
||||
private final Semaphore concurrency = new Semaphore(1);
|
||||
private long lastRequestNanos;
|
||||
private boolean hasRequested;
|
||||
private final LingxingEgressGate egressGate;
|
||||
|
||||
@Autowired
|
||||
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) {
|
||||
this(properties, mapper, Clock.systemUTC());
|
||||
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) {
|
||||
this(properties, mapper, Clock.systemUTC(), egressGate);
|
||||
}
|
||||
|
||||
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) {
|
||||
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock, LingxingEgressGate egressGate) {
|
||||
this.properties = properties;
|
||||
this.mapper = mapper;
|
||||
this.mapper = mapper.copy().disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
|
||||
this.clock = clock;
|
||||
signer = new LingxingWmsSigner(mapper);
|
||||
this.egressGate = egressGate;
|
||||
signer = new LingxingWmsSigner(this.mapper);
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
}
|
||||
|
||||
public PageResult queryPage(QueryRequest request) {
|
||||
public PageResult queryPage(QueryRequest request, EgressContext context) {
|
||||
validate(request);
|
||||
Map<String, Object> data = pagination(request.current(), request.size());
|
||||
data.put("timeType", "outboundTime");
|
||||
data.put("startTime", SOURCE_TIME.format(request.startTime()));
|
||||
data.put("endTime", SOURCE_TIME.format(request.endTime()));
|
||||
data.put("status", 100);
|
||||
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_QUERY,
|
||||
(json, metadata) -> orders(json, request.current(), request.size(), null, metadata));
|
||||
}
|
||||
|
||||
public PageResult locate(LocateRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
requireInput(request.deliveryNo(), 128);
|
||||
validatePagination(request.current(), request.size(), 1000);
|
||||
Map<String, Object> data = pagination(request.current(), request.size());
|
||||
data.put("deliveryNo", request.deliveryNo());
|
||||
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_LOCATE,
|
||||
(json, metadata) -> orders(json, request.current(), request.size(), request.deliveryNo(), metadata));
|
||||
}
|
||||
|
||||
public QueryPage<Warehouse> queryWarehouses(WarehouseQueryRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
validatePagination(request.current(), request.size(), 1000);
|
||||
return execute("/openapi/v2/warehouse/page", pagination(request.current(), request.size()), context, Operation.WAREHOUSES,
|
||||
(data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, row ->
|
||||
new Warehouse(requiredText(row, "whCode", 64), optionalText(row, "whNameCn"),
|
||||
requiredInt(row.get("warehouseType"), "WAREHOUSE_TYPE"), requiredInt(row.get("status"), "STATUS"),
|
||||
optionalText(row, "timeZone"))));
|
||||
}
|
||||
|
||||
public OrderDetail queryDetail(DetailRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
requireInput(request.warehouseCode(), 64);
|
||||
requireInput(request.customerCode(), 64);
|
||||
requireInput(request.deliveryNo(), 128);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("whCode", request.warehouseCode());
|
||||
body.put("customerCode", request.customerCode());
|
||||
body.put("deliveryNo", request.deliveryNo());
|
||||
return execute("/openapi/v2/order/delivery/detail", body, context, Operation.OUTBOUND_DETAIL,
|
||||
(data, metadata) -> detail(data, request, metadata));
|
||||
}
|
||||
|
||||
private OrderDetail detail(JsonNode data, DetailRequest request, AttemptMetadata metadata) {
|
||||
String deliveryNo = requiredText(data, "deliveryNo", 128);
|
||||
String customer = requiredText(data, "customerCode", 64);
|
||||
String warehouse = requiredText(data, "whCode", 64);
|
||||
if (!deliveryNo.equals(request.deliveryNo()) || !customer.equals(request.customerCode())
|
||||
|| !warehouse.equals(request.warehouseCode())) throw invalidResponse("DETAIL_IDENTITY_MISMATCH", 200);
|
||||
JsonNode rows = data.get("skuVOList");
|
||||
if (rows == null || !rows.isArray() || rows.isEmpty()) throw invalidResponse("SKU_LIST_INVALID", 200);
|
||||
List<SkuLine> lines = new ArrayList<>();
|
||||
for (JsonNode row : rows) {
|
||||
if (!row.isObject()) throw invalidResponse("SKU_ROW_INVALID", 200);
|
||||
lines.add(new SkuLine(requiredText(row, "productSku", 128), optionalText(row, "productName"),
|
||||
quantity(row.get("actualDeliveryQty")), quantity(row.get("deliveryQty"))));
|
||||
}
|
||||
return new OrderDetail(deliveryNo, optionalText(data, "sourceNo"), customer, warehouse,
|
||||
requiredInt(data.get("status"), "STATUS"), optionalText(data, "orderCreateTime"),
|
||||
optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata);
|
||||
}
|
||||
|
||||
public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
requireInput(request.warehouseCode(), 64);
|
||||
validatePagination(request.current(), request.size(), 1000);
|
||||
Map<String, Object> body = pagination(request.current(), request.size());
|
||||
body.put("whCode", request.warehouseCode());
|
||||
body.put("isHideInventory", 0);
|
||||
return execute("/openapi/v2/stock/stockSku/page", body, context, Operation.PRODUCT_INVENTORY,
|
||||
(data, metadata) -> inventory(data, request, metadata));
|
||||
}
|
||||
|
||||
private QueryPage<ProductStock> inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) {
|
||||
Set<List<Object>> keys = new HashSet<>();
|
||||
return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> {
|
||||
String customer = requiredText(row, "customerCode", 64);
|
||||
String warehouse = requiredText(row, "whCode", 64);
|
||||
String sku = requiredText(row, "productSku", 128);
|
||||
int quality = requiredInt(row.get("productQuality"), "PRODUCT_QUALITY");
|
||||
if (!warehouse.equals(request.warehouseCode())) throw invalidResponse("INVENTORY_WAREHOUSE_MISMATCH", 200);
|
||||
if (!keys.add(List.of(customer, warehouse, sku, quality))) throw invalidResponse("DUPLICATE_INVENTORY_KEY", 200);
|
||||
return new ProductStock(customer, warehouse, sku, optionalText(row, "productName"), quality,
|
||||
quantity(row.get("stkQty")), quantity(row.get("freezeQty")), quantity(row.get("useStkQty")),
|
||||
quantity(row.get("transitQty")), optionalText(row, "innerBatchNo"), optionalText(row, "outBatchNo"),
|
||||
optionalText(row, "uniqueKey4tenant"));
|
||||
});
|
||||
}
|
||||
|
||||
private <T> T execute(String path, Map<String, Object> data, EgressContext context, Operation operation,
|
||||
BiFunction<JsonNode, AttemptMetadata, T> projection) {
|
||||
if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false);
|
||||
if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false);
|
||||
validate(request);
|
||||
if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true);
|
||||
if (context != null && context.executionPurpose() == CollectionMode.VALIDATION
|
||||
&& data.containsKey("size") && (int)data.get("size") > 10) throw badRequest();
|
||||
String hash;
|
||||
try {
|
||||
reserveRequest();
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("current", request.current());
|
||||
data.put("size", request.size());
|
||||
data.put("timeType", "outboundTime");
|
||||
data.put("startTime", SOURCE_TIME.format(request.startTime()));
|
||||
data.put("endTime", SOURCE_TIME.format(request.endTime()));
|
||||
data.put("status", 100);
|
||||
var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant());
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH))
|
||||
hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
|
||||
(path + "\n" + mapper.writer().without(SerializationFeature.INDENT_OUTPUT,
|
||||
SerializationFeature.WRAP_ROOT_VALUE).writeValueAsString(data)).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception ignored) { throw failure("REQUEST_HASH_FAILED", "领星请求授权摘要生成失败", false); }
|
||||
return egressGate.execute(context, operation, hash, handle -> {
|
||||
var signed = signer.sign(properties.appKey(), properties.appSecret(), path, data, clock.instant());
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(path))
|
||||
.timeout(properties.requestTimeout()).header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build();
|
||||
handle.startHttp();
|
||||
HttpResponse<String> response = send(httpRequest);
|
||||
if (response.statusCode() == 429) handle.rateLimited(retryAfter(response));
|
||||
validateStatus(response.statusCode());
|
||||
return page(response.body(), request);
|
||||
} finally {
|
||||
concurrency.release();
|
||||
}
|
||||
JsonNode json;
|
||||
try { json = envelope(response.body()); }
|
||||
catch (LingxingWmsException error) {
|
||||
if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) {
|
||||
handle.rateLimited(retryAfter(response));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return projection.apply(json, handle.metadata());
|
||||
});
|
||||
}
|
||||
|
||||
private Instant retryAfter(HttpResponse<?> response) {
|
||||
String value = response.headers().firstValue("Retry-After").orElse(null);
|
||||
if (value == null || value.length() > 128) return null;
|
||||
try {
|
||||
if (value.matches("[0-9]{1,9}")) return clock.instant().plusSeconds(Long.parseLong(value));
|
||||
Instant time = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
|
||||
return time.isAfter(clock.instant()) ? time : null;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
private static Map<String, Object> pagination(int current, int size) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("current", current);
|
||||
data.put("size", size);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void validatePagination(int current, int size, int maximum) {
|
||||
if (current < 1 || size < 1 || size > maximum) throw badRequest();
|
||||
}
|
||||
|
||||
private static void requireInput(String value, int maximum) {
|
||||
if (value == null || value.isBlank() || value.length() > maximum) throw badRequest();
|
||||
}
|
||||
|
||||
private static LingxingWmsException badRequest() {
|
||||
return failure("INVALID_REQUEST", "领星 WMS 查询参数不符合内部契约", false);
|
||||
}
|
||||
|
||||
private void validate(QueryRequest request) {
|
||||
@@ -92,14 +227,6 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private void reserveRequest() {
|
||||
long now = System.nanoTime();
|
||||
if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) {
|
||||
throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true);
|
||||
}
|
||||
hasRequested = true;
|
||||
lastRequestNanos = now;
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest request) {
|
||||
try {
|
||||
@@ -115,7 +242,7 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
|
||||
private void validateStatus(int status) {
|
||||
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null);
|
||||
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流,账号已暂停", false, status, null);
|
||||
if (status == 401 || status == 403) {
|
||||
throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null);
|
||||
}
|
||||
@@ -124,10 +251,11 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private PageResult page(String body, QueryRequest request) {
|
||||
private JsonNode envelope(String body) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body);
|
||||
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS,
|
||||
DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw invalidResponse("JSON", null);
|
||||
}
|
||||
@@ -153,23 +281,65 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
JsonNode data = root.get("data");
|
||||
if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200);
|
||||
return data;
|
||||
}
|
||||
|
||||
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) {
|
||||
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", true, metadata, record -> {
|
||||
String deliveryNo = requiredText(record, "deliveryNo", 128);
|
||||
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
|
||||
int status = requiredInt(record.get("status"), "STATUS");
|
||||
if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200);
|
||||
return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64),
|
||||
requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"));
|
||||
});
|
||||
return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(),
|
||||
page.sourceSize(), 200, page.observedAt(), metadata);
|
||||
}
|
||||
|
||||
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
|
||||
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) {
|
||||
JsonNode records = data.get("records");
|
||||
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
|
||||
if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
|
||||
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
|
||||
Long total = optionalTotal(data.get("total"));
|
||||
if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200);
|
||||
verifyPage(data.get("current"), request.current(), "CURRENT");
|
||||
verifyPage(data.get("size"), request.size(), "SIZE");
|
||||
List<OrderSummary> summaries = new ArrayList<>();
|
||||
if (total == null) throw invalidResponse("TOTAL_MISSING", 200);
|
||||
int sourceCurrent = requiredInt(data.get(currentField), "CURRENT");
|
||||
int sourceSize = requiredInt(data.get(sizeField), "SIZE");
|
||||
int pages = requiredInt(data.get("pages"), "PAGES");
|
||||
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
|
||||
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1
|
||||
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
|
||||
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
|
||||
long expectedPages = total / size + (total % size == 0 ? 0 : 1);
|
||||
if (pages < 0 || pages != expectedPages || (total == 0 && current != 1)
|
||||
|| (total > 0 && current > pages)) throw invalidResponse("PAGES_MISMATCH", 200);
|
||||
long expectedRows = total == 0 ? 0 : Math.min(size, total - (long)(current - 1) * size);
|
||||
if (records.size() != expectedRows) throw invalidResponse("PAGE_ROW_COUNT_MISMATCH", 200);
|
||||
List<T> summaries = new ArrayList<>();
|
||||
for (JsonNode record : records) {
|
||||
if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200);
|
||||
String deliveryNo = optionalText(record, "deliveryNo");
|
||||
if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200);
|
||||
summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"),
|
||||
optionalText(record, "whCode"), optionalStatus(record.get("status")),
|
||||
optionalText(record, "createTime"), optionalText(record, "outboundTime")));
|
||||
if (!record.isObject()) throw invalidResponse("ROW_TYPE_" + nodeType(record), 200);
|
||||
summaries.add(projection.apply(record));
|
||||
}
|
||||
return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant());
|
||||
return new QueryPage<>(summaries, total, current, size, pages, sourceCurrent, sourceSize, 200, clock.instant(), metadata);
|
||||
}
|
||||
|
||||
private static String requiredText(JsonNode row, String field, int maximum) {
|
||||
String text = optionalText(row, field);
|
||||
if (text == null || text.isBlank() || text.length() > maximum) throw invalidResponse("IDENTITY_INVALID", 200);
|
||||
return text;
|
||||
}
|
||||
|
||||
private static int requiredInt(JsonNode value, String stage) {
|
||||
if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
|
||||
return value.intValue();
|
||||
}
|
||||
|
||||
private static BigDecimal quantity(JsonNode value) {
|
||||
if (missingNumber(value)) return null;
|
||||
if (value.isNumber()) return value.decimalValue();
|
||||
// Nonempty malformed quantities are a contract failure, never invented zero.
|
||||
throw invalidResponse("QUANTITY_TYPE_" + nodeType(value), 200);
|
||||
}
|
||||
|
||||
private static String optionalText(JsonNode record, String field) {
|
||||
@@ -190,14 +360,6 @@ public final class LingxingWmsClient {
|
||||
return value.textValue();
|
||||
}
|
||||
|
||||
private static Integer optionalStatus(JsonNode value) {
|
||||
if (missingNumber(value)) return null;
|
||||
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
|
||||
throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200);
|
||||
}
|
||||
return value.intValue();
|
||||
}
|
||||
|
||||
private static Long optionalTotal(JsonNode value) {
|
||||
if (missingNumber(value)) return null;
|
||||
if (!value.isIntegralNumber() || !value.canConvertToLong()) {
|
||||
@@ -207,14 +369,6 @@ public final class LingxingWmsClient {
|
||||
return value.longValue();
|
||||
}
|
||||
|
||||
private static void verifyPage(JsonNode value, int expected, String stage) {
|
||||
if (missingNumber(value)) return;
|
||||
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
|
||||
throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
|
||||
}
|
||||
if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200);
|
||||
}
|
||||
|
||||
private static Integer diagnosticCode(JsonNode value) {
|
||||
if (value == null) return null;
|
||||
if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue();
|
||||
@@ -237,6 +391,7 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
|
||||
private static LingxingWmsException sourceRejected(int code) {
|
||||
if (code == 31001 || code == 429) return upstreamFailure("SOURCE_RATE_LIMITED", "领星 WMS 来源限流,账号已暂停", false, 200, code);
|
||||
if (code == 11008) {
|
||||
return upstreamFailure("AUTHORIZATION_REJECTED",
|
||||
"领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code);
|
||||
@@ -244,11 +399,11 @@ public final class LingxingWmsClient {
|
||||
if (code == 11006) {
|
||||
return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code);
|
||||
}
|
||||
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code);
|
||||
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", false, 200, code);
|
||||
}
|
||||
|
||||
private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) {
|
||||
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")",
|
||||
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合接口契约(" + stage + ")",
|
||||
false, 200, sourceCode);
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -1,4 +1,6 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
|
||||
|
||||
/** Only fixed messages and numeric upstream metadata may leave the adapter. */
|
||||
public final class LingxingWmsException extends RuntimeException {
|
||||
@@ -6,6 +8,8 @@ public final class LingxingWmsException extends RuntimeException {
|
||||
private final boolean retryable;
|
||||
private final Integer upstreamStatus;
|
||||
private final Integer sourceCode;
|
||||
private AttemptMetadata egress;
|
||||
private AuthorizationDenial denial;
|
||||
|
||||
public LingxingWmsException(String code, String message, boolean retryable,
|
||||
Integer upstreamStatus, Integer sourceCode) {
|
||||
@@ -21,4 +25,8 @@ public final class LingxingWmsException extends RuntimeException {
|
||||
public boolean retryable() { return retryable; }
|
||||
public Integer upstreamStatus() { return upstreamStatus; }
|
||||
public Integer sourceCode() { return sourceCode; }
|
||||
public AttemptMetadata egress() { return egress; }
|
||||
public LingxingWmsException withEgress(AttemptMetadata metadata) { this.egress = metadata; return this; }
|
||||
public AuthorizationDenial denial() { return denial; }
|
||||
public LingxingWmsException withDenial(AuthorizationDenial metadata) { this.denial = metadata; return this; }
|
||||
}
|
||||
|
||||
@@ -37,11 +37,20 @@ baiyue:
|
||||
lingxing-wms:
|
||||
enabled: ${LINGXING_WMS_ENABLED:false}
|
||||
base-uri: https://api.xlwms.com
|
||||
app-key: ${LINGXING_WMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_WMS_APP_SECRET:}
|
||||
app-key: ${LINGXING_OMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_OMS_APP_SECRET:}
|
||||
connect-timeout: 5s
|
||||
request-timeout: 15s
|
||||
min-request-interval: 1s
|
||||
egress:
|
||||
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
|
||||
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
|
||||
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
|
||||
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
|
||||
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
|
||||
min-start-interval: 5s
|
||||
cooldown-fallback: 5m
|
||||
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
|
||||
# Missing proof/authority fails closed even when the old enabled switch is true.
|
||||
baidu-ocr:
|
||||
enabled: false
|
||||
amazon-sp-api:
|
||||
|
||||
@@ -38,11 +38,20 @@ baiyue:
|
||||
lingxing-wms:
|
||||
enabled: ${LINGXING_WMS_ENABLED:false}
|
||||
base-uri: https://api.xlwms.com
|
||||
app-key: ${LINGXING_WMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_WMS_APP_SECRET:}
|
||||
app-key: ${LINGXING_OMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_OMS_APP_SECRET:}
|
||||
connect-timeout: 5s
|
||||
request-timeout: 15s
|
||||
min-request-interval: 1s
|
||||
egress:
|
||||
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
|
||||
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
|
||||
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
|
||||
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
|
||||
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
|
||||
min-start-interval: 5s
|
||||
cooldown-fallback: 5m
|
||||
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
|
||||
# Missing proof/authority fails closed even when the old enabled switch is true.
|
||||
baidu-ocr:
|
||||
enabled: ${BAIDU_OCR_ENABLED:false}
|
||||
api-key: ${BAIDU_OCR_API_KEY:}
|
||||
|
||||
@@ -2,14 +2,77 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Lingxing WMS Internal Read API
|
||||
version: 1.0.0
|
||||
description: Read-only provider adapter. No business scheduling, persistence, inventory mutation or public Gateway route.
|
||||
description: >-
|
||||
Single-page or single-order adapter. No scheduling, business persistence or public Gateway route.
|
||||
Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone.
|
||||
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
|
||||
account window; BUSINESS requires environmentLabel exactly production. All five endpoints apply the same gate.
|
||||
Missing exclusive proof, persistent account budget authorization or control context fails closed before egress.
|
||||
At most one account request is in flight and HTTP request starts are separated by at least 5 seconds.
|
||||
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
|
||||
Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries;
|
||||
429 is never retried, immediately pauses the account and returns persistent cooldown metadata.
|
||||
VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
|
||||
External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil;
|
||||
the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is
|
||||
recoverable only within the original valid exclusive window, after both the persistently returned
|
||||
cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new
|
||||
strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state.
|
||||
Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost
|
||||
or malformed authorization and uncertain completion hold an unrecoverable local pause until
|
||||
operations verify persistent account state. Completion failure never automatically recovers.
|
||||
A well-formed explicit authorization denial is deterministic and does not cause a permanent pause.
|
||||
sourceAccount defaults to lingxing-primary and budgetAuthorityBaseUri defaults internally to
|
||||
http://lastleg-service:9303; neither default enables egress.
|
||||
x-lastleg-egress-coordination:
|
||||
description: >-
|
||||
Integration calls these Lastleg endpoints with X-Internal-Service-Token. These are dependencies,
|
||||
not Integration Controller paths. Lastleg prepares attemptId server-side and binds context and operation
|
||||
before authorization. authorize only consumes an already prepared attempt, first binds the opaque
|
||||
requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
|
||||
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings.
|
||||
A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true
|
||||
and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil,
|
||||
and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and
|
||||
AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose
|
||||
a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress;
|
||||
pending or ambiguous attempts remain conservatively consumed, including after restart.
|
||||
authorize:
|
||||
method: POST
|
||||
path: /internal/lastleg/self-operated-analytics/egress/authorize
|
||||
requestSchema: '#/components/schemas/AuthorizationRequest'
|
||||
responseSchema: '#/components/schemas/AttemptPermitResponse'
|
||||
complete:
|
||||
method: POST
|
||||
path: /internal/lastleg/self-operated-analytics/egress/complete
|
||||
requestSchema: '#/components/schemas/CompletionRequest'
|
||||
responseSchema: '#/components/schemas/CompletionResponse'
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/integrations/lingxing/wms/warehouses/query:
|
||||
post:
|
||||
summary: Query one page of normal warehouses
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Calls /openapi/v2/warehouse/page without filters. Maps page/pageSize into current/size; preserves original metadata.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/WarehouseQueryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with WarehousePage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/WarehouseResponse'}
|
||||
/internal/integrations/lingxing/wms/outbound-orders/query:
|
||||
post:
|
||||
summary: Query one page of completed WMS outbound summaries by outbound time
|
||||
description: Calls OMP /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. Source time strings are preserved. This is a single page, not a complete sales report. It uses the self-developed-system API authorization in the WMS management console.
|
||||
summary: Query one page of completed outbound orders by source outbound time
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Calls /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. No external warehouse filter. At current=1,size=100 only, records=[],total=0,pages=0,sourceCurrent=1,sourceSize=10 is accepted and internal size remains 100. All other pagination metadata is strictly required. No completeness claim across pages.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -17,65 +80,363 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/QueryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope; code 200 for success, A0401 for rejected internal identity, A0422 for bean validation, B0001 or S0503 for provider/configuration failures. Failures never return a successful empty page.
|
||||
description: Standard envelope with OrderPage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/Response'}
|
||||
schema: {$ref: '#/components/schemas/OrderResponse'}
|
||||
/internal/integrations/lingxing/wms/outbound-orders/locate:
|
||||
post:
|
||||
summary: Locate one page by delivery number without time or status filters
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Sends only current,size,deliveryNo to delivery/page. Every returned deliveryNo must match exactly. Caller reads all required pages and verifies original customer identity and uniqueness before accepting the current warehouse. Missing results do not prove cancellation.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/LocateRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with OrderPage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderResponse'}
|
||||
/internal/integrations/lingxing/wms/outbound-orders/detail:
|
||||
post:
|
||||
summary: Read one order using warehouse, customer and delivery identifiers
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Maps warehouseCode to whCode for delivery/detail. All three returned identifiers must match. Returns only skuVOList; packageList is never added to quantities. Duplicate SKU lines remain separate for caller normalization. Missing quantities remain null; no quantity unit is inferred.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/DetailRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with OrderDetail or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/DetailResponse'}
|
||||
/internal/integrations/lingxing/wms/product-inventory/query:
|
||||
post:
|
||||
summary: Query one page of all qualities and customers in a required warehouse
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Calls stock/stockSku/page with whCode and isHideInventory=0. No SKU, quality, customer or quantity filters. Warehouse mismatch and duplicate business keys within a page fail closed. Caller validates cross-page keys and counts. Unknown quantities stay null, negative quantities remain evidence, stockCompare is not accumulated.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InventoryQueryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with InventoryPage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InventoryResponse'}
|
||||
components:
|
||||
parameters:
|
||||
LingxingEgressContext:
|
||||
name: X-Lingxing-Egress-Context
|
||||
in: header
|
||||
required: true
|
||||
description: >-
|
||||
JSON control context; no credentials. Maximum 2048 characters, all printable ASCII (32 through 126).
|
||||
Unknown fields, duplicate fields and trailing JSON are rejected. Identifiers are strings supplied
|
||||
by the server-side coordinator and do not confer authorization by themselves.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EgressContext'}
|
||||
securitySchemes:
|
||||
InternalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
schemas:
|
||||
EgressContext:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
|
||||
properties:
|
||||
sourceAccount: {type: string, minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Server-configured account alias, never App Key or Secret.}
|
||||
executionPurpose: {type: string, enum: [VALIDATION, BUSINESS]}
|
||||
rootTaskId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable root control identifier; child tasks share root budget.}
|
||||
sessionId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable session control identifier; reopening or restart does not reset it.}
|
||||
attemptId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Prepared unique single-attempt control identifier; duplicate authorization is refused.}
|
||||
exclusiveGrantId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Must equal the valid externalExclusiveGrantId configured server-side.}
|
||||
EgressRequestHash:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{64}$'
|
||||
description: >-
|
||||
Lowercase hexadecimal SHA256 of UTF-8 bytes of OMP path, one LF character and compact
|
||||
insertion-ordered external business JSON. Lastleg treats this digest as opaque and binds
|
||||
it on the first authorization of an already prepared attempt.
|
||||
example: fa60a92c78b4803878930e5914e6db03591acc31b7c09abad53c91912fa3d627
|
||||
x-preimage-example: |-
|
||||
/openapi/v2/stock/stockSku/page
|
||||
{"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0}
|
||||
EgressOperation:
|
||||
type: string
|
||||
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY]
|
||||
EgressMetadata:
|
||||
type: object
|
||||
required: [attemptId, actualHttpAttempts, accountPaused]
|
||||
properties:
|
||||
attemptId: {type: string, nullable: true, description: Null only if no valid control context was accepted.}
|
||||
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1, description: Supplier HTTP attempts actually started, including failed calls; authorization callbacks do not count.}
|
||||
accountPaused: {type: boolean}
|
||||
cooldownUntil: {type: string, format: date-time, nullable: true, description: Account cooldown deadline to persist; valid supplier retry time must not be shortened.}
|
||||
AuthorizationRequest:
|
||||
type: object
|
||||
required: [context, operation, requestHash]
|
||||
properties:
|
||||
context: {$ref: '#/components/schemas/EgressContext'}
|
||||
operation: {$ref: '#/components/schemas/EgressOperation'}
|
||||
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
|
||||
AuthorizationDenial:
|
||||
type: object
|
||||
required: [errorCode, sampleDone]
|
||||
properties:
|
||||
errorCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'}
|
||||
sampleDone: {type: boolean, description: Strict JSON boolean; validation budget normal completion is distinct from a failure.}
|
||||
waitUntil: {type: string, format: date-time, nullable: true, description: Nullable ISO-8601 Instant from the persistent authority.}
|
||||
AuthorizationDenialResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
code: {type: string, enum: [B0001]}
|
||||
data: {$ref: '#/components/schemas/AuthorizationDenial'}
|
||||
AttemptPermit:
|
||||
type: object
|
||||
required: [context, operation, requestHash, allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed]
|
||||
properties:
|
||||
context: {$ref: '#/components/schemas/EgressContext'}
|
||||
operation: {$ref: '#/components/schemas/EgressOperation'}
|
||||
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
|
||||
allowedAttempts: {type: integer, enum: [1]}
|
||||
expiresAt: {type: string, format: date-time, description: Must be after now and no later than now plus 30 seconds and configured externalExclusiveUntil.}
|
||||
exclusiveConfirmed: {type: boolean, enum: [true]}
|
||||
budgetConsumed: {type: boolean, enum: [true], description: Lastleg has atomically consumed persistent account and root/session budget before returning this permit.}
|
||||
CompletionRequest:
|
||||
type: object
|
||||
required: [context, operation, requestHash, actualHttpAttempts, accountPaused]
|
||||
properties:
|
||||
context: {$ref: '#/components/schemas/EgressContext'}
|
||||
operation: {$ref: '#/components/schemas/EgressOperation'}
|
||||
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
|
||||
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1}
|
||||
errorCode: {type: string, nullable: true, description: Sanitized local error code; null on success.}
|
||||
upstreamStatus: {type: integer, nullable: true}
|
||||
sourceCode: {type: integer, nullable: true}
|
||||
accountPaused: {type: boolean, description: True on 429 or explicit provider rate limiting; all account request families must pause.}
|
||||
cooldownUntil: {type: string, format: date-time, nullable: true}
|
||||
AttemptPermitResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
code: {type: string, enum: ['200', B0001]}
|
||||
data:
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/AttemptPermit'}
|
||||
- {$ref: '#/components/schemas/AuthorizationDenial'}
|
||||
description: Code 200 requires AttemptPermit; code B0001 requires AuthorizationDenial. Lost or malformed responses are uncertain and fail closed with a local pause.
|
||||
CompletionResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: boolean, enum: [true], description: Successful acknowledgement confirms durable completion and cooldown persistence; any other result fails closed.}
|
||||
WarehouseQueryRequest:
|
||||
type: object
|
||||
required: [current, size]
|
||||
properties:
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 1000}
|
||||
QueryRequest:
|
||||
type: object
|
||||
required: [startTime, endTime, current, size]
|
||||
properties:
|
||||
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime; must precede endTime and must not be earlier than endTime minus one calendar month. This is a local guard.}
|
||||
endTime: {type: string, example: '2026-10-12T08:00:00', description: Source local datetime.}
|
||||
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime. Must precede endTime and be no earlier than endTime minus one calendar month. No source timezone inference.}
|
||||
endTime: {type: string, example: '2026-10-12T08:00:00'}
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 100, description: Local request cap; not a claim about the provider quota. Use 1 for connectivity verification.}
|
||||
size: {type: integer, minimum: 1, maximum: 100, description: Local request cap, not supplier quota.}
|
||||
LocateRequest:
|
||||
type: object
|
||||
required: [deliveryNo, current, size]
|
||||
properties:
|
||||
deliveryNo: {type: string, minLength: 1, maxLength: 128}
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 1000}
|
||||
DetailRequest:
|
||||
type: object
|
||||
required: [warehouseCode, customerCode, deliveryNo]
|
||||
properties:
|
||||
warehouseCode: {type: string, minLength: 1, maxLength: 64}
|
||||
customerCode: {type: string, minLength: 1, maxLength: 64}
|
||||
deliveryNo: {type: string, minLength: 1, maxLength: 128}
|
||||
InventoryQueryRequest:
|
||||
type: object
|
||||
required: [warehouseCode, current, size]
|
||||
properties:
|
||||
warehouseCode: {type: string, minLength: 1, maxLength: 64}
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 1000}
|
||||
Pagination:
|
||||
type: object
|
||||
required: [total, current, size, pages, sourceCurrent, sourceSize, sourceCode, observedAt, egress]
|
||||
properties:
|
||||
egress: {$ref: '#/components/schemas/EgressMetadata'}
|
||||
total: {type: string, pattern: '^[0-9]+$', description: Required provider total. Long follows platform string serialization.}
|
||||
current: {type: integer}
|
||||
size: {type: integer}
|
||||
pages: {type: integer, minimum: 0}
|
||||
sourceCurrent: {type: integer}
|
||||
sourceSize: {type: integer}
|
||||
sourceCode: {type: integer, enum: [200]}
|
||||
observedAt: {type: string, format: date-time}
|
||||
OrderSummary:
|
||||
type: object
|
||||
required: [deliveryNo]
|
||||
required: [deliveryNo, customerCode, whCode, status]
|
||||
properties:
|
||||
deliveryNo: {type: string}
|
||||
sourceNo: {type: string, nullable: true}
|
||||
customerCode: {type: string, nullable: true}
|
||||
whCode: {type: string, nullable: true}
|
||||
status: {type: integer, nullable: true}
|
||||
customerCode: {type: string}
|
||||
whCode: {type: string}
|
||||
status: {type: integer}
|
||||
createTime: {type: string, nullable: true}
|
||||
outboundTime: {type: string, nullable: true}
|
||||
PageResult:
|
||||
Warehouse:
|
||||
type: object
|
||||
required: [records, current, size, sourceCode, observedAt]
|
||||
required: [whCode, warehouseType, status]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}}
|
||||
total: {type: string, pattern: '^[0-9]+$', nullable: true, description: Provider total only when supplied. Java Long follows the platform JSON string convention.}
|
||||
current: {type: integer}
|
||||
size: {type: integer}
|
||||
sourceCode: {type: integer}
|
||||
whCode: {type: string}
|
||||
whNameCn: {type: string, nullable: true}
|
||||
warehouseType: {type: integer}
|
||||
status: {type: integer}
|
||||
timeZone: {type: string, nullable: true, description: Warehouse metadata only.}
|
||||
SkuLine:
|
||||
type: object
|
||||
required: [productSku]
|
||||
properties:
|
||||
productSku: {type: string}
|
||||
productName: {type: string, nullable: true}
|
||||
actualDeliveryQty: {type: number, nullable: true, description: BigDecimal actual quantity, unknown is null.}
|
||||
deliveryQty: {type: number, nullable: true, description: Forecast only; never a replacement for actual quantity.}
|
||||
OrderDetail:
|
||||
type: object
|
||||
required: [deliveryNo, customerCode, whCode, status, skuVOList, sourceCode, observedAt, egress]
|
||||
properties:
|
||||
egress: {$ref: '#/components/schemas/EgressMetadata'}
|
||||
deliveryNo: {type: string}
|
||||
sourceNo: {type: string, nullable: true}
|
||||
customerCode: {type: string}
|
||||
whCode: {type: string}
|
||||
status: {type: integer}
|
||||
orderCreateTime: {type: string, nullable: true}
|
||||
outboundTime: {type: string, nullable: true}
|
||||
skuVOList: {type: array, minItems: 1, items: {$ref: '#/components/schemas/SkuLine'}}
|
||||
sourceCode: {type: integer, enum: [200]}
|
||||
observedAt: {type: string, format: date-time}
|
||||
ProductStock:
|
||||
type: object
|
||||
required: [customerCode, whCode, productSku, productQuality]
|
||||
properties:
|
||||
customerCode: {type: string}
|
||||
whCode: {type: string}
|
||||
productSku: {type: string}
|
||||
productName: {type: string, nullable: true}
|
||||
productQuality: {type: integer}
|
||||
stkQty: {type: number, nullable: true}
|
||||
freezeQty: {type: number, nullable: true}
|
||||
useStkQty: {type: number, nullable: true}
|
||||
transitQty: {type: number, nullable: true}
|
||||
innerBatchNo: {type: string, nullable: true}
|
||||
outBatchNo: {type: string, nullable: true}
|
||||
uniqueKey4tenant: {type: string, nullable: true}
|
||||
OrderPage:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Pagination'}
|
||||
- type: object
|
||||
required: [records]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}}
|
||||
WarehousePage:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Pagination'}
|
||||
- type: object
|
||||
required: [records]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/Warehouse'}}
|
||||
InventoryPage:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Pagination'}
|
||||
- type: object
|
||||
required: [records]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ProductStock'}}
|
||||
ProviderFailure:
|
||||
type: object
|
||||
required: [errorCode, retryable]
|
||||
required: [errorCode, retryable, egress]
|
||||
properties:
|
||||
egress: {$ref: '#/components/schemas/EgressMetadata'}
|
||||
denial:
|
||||
nullable: true
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/AuthorizationDenial'}
|
||||
description: Well-formed deterministic authority denial, alongside egress; null for other failures.
|
||||
errorCode: {type: string}
|
||||
retryable: {type: boolean}
|
||||
retryable: {type: boolean, description: Caller scheduling hint only; never authorizes implicit retries or reuse of an attempt. False for 429 and validation session failure.}
|
||||
upstreamStatus: {type: integer, nullable: true}
|
||||
sourceCode: {type: integer, nullable: true}
|
||||
Response:
|
||||
Envelope:
|
||||
type: object
|
||||
required: [code, message, data, timestamp]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string, description: Sanitized local message; never the raw provider response.}
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/PageResult'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
code: {type: string, description: '200 success; A0401 identity; A0422 validation; B0001 or S0503 provider/configuration failure.'}
|
||||
message: {type: string, description: Sanitized fixed local message, never raw supplier content.}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
OrderResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/OrderPage'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
WarehouseResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/WarehousePage'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
DetailResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/OrderDetail'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
InventoryResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/InventoryPage'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
|
||||
+70
-7
@@ -9,6 +9,8 @@ import com.baiyue.integration.provider.lingxing.LingxingWmsException;
|
||||
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.CsvSource;
|
||||
import org.springframework.http.MediaType;
|
||||
import org.springframework.http.converter.json.Jackson2ObjectMapperBuilder;
|
||||
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
|
||||
@@ -35,6 +37,7 @@ class LingxingInternalControllerTest {
|
||||
private static final String BODY = """
|
||||
{"startTime":"2026-09-12T08:00:00","endTime":"2026-10-12T08:00:00","current":1,"size":1}
|
||||
""";
|
||||
private static final String CONTEXT = "{\"sourceAccount\":\"demo\",\"executionPurpose\":\"VALIDATION\",\"rootTaskId\":\"root-1\",\"sessionId\":\"session-1\",\"attemptId\":\"attempt-1\",\"exclusiveGrantId\":\"grant-1\"}";
|
||||
private final String token = UUID.randomUUID().toString();
|
||||
private LingxingWmsClient client;
|
||||
private MockMvc mvc;
|
||||
@@ -49,7 +52,7 @@ class LingxingInternalControllerTest {
|
||||
try (var context = new AnnotationConfigApplicationContext(LongIdJacksonConfiguration.class)) {
|
||||
context.getBean(Jackson2ObjectMapperBuilderCustomizer.class).customize(builder);
|
||||
}
|
||||
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client))
|
||||
mvc = MockMvcBuilders.standaloneSetup(new LingxingInternalController(guard, client, builder.build()))
|
||||
.setMessageConverters(new MappingJackson2HttpMessageConverter(builder.build()))
|
||||
.setControllerAdvice(new GlobalExceptionHandler()).build();
|
||||
}
|
||||
@@ -64,13 +67,73 @@ class LingxingInternalControllerTest {
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(value = {
|
||||
"/warehouses/query|{\"current\":1,\"size\":1000}",
|
||||
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
|
||||
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
|
||||
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":1000}"
|
||||
}, delimiter = '|')
|
||||
void everyNewEndpointRequiresInternalIdentity(String suffix, String request) throws Exception {
|
||||
String path = "/internal/integrations/lingxing/wms" + suffix;
|
||||
for (String invalidToken : new String[] {"", "wrong", "Bearer user-jwt"}) {
|
||||
mvc.perform(post(path).header("X-Internal-Service-Token", invalidToken)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(request))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0401"));
|
||||
}
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@CsvSource(value = {
|
||||
"/outbound-orders/query|{\"startTime\":\"2026-09-12T08:00:00\",\"endTime\":\"2026-10-12T08:00:00\",\"current\":1,\"size\":1}",
|
||||
"/warehouses/query|{\"current\":1,\"size\":10}",
|
||||
"/outbound-orders/locate|{\"deliveryNo\":\"DO-DEMO\",\"current\":1,\"size\":10}",
|
||||
"/outbound-orders/detail|{\"warehouseCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}",
|
||||
"/product-inventory/query|{\"warehouseCode\":\"W-DEMO\",\"current\":1,\"size\":10}"
|
||||
}, delimiter = '|')
|
||||
void everyEndpointRejectsMissingControlContextBeforeProvider(String suffix, String request) throws Exception {
|
||||
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix).header("X-Internal-Service-Token", token)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(request))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("B0001"))
|
||||
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"))
|
||||
.andExpect(jsonPath("$.data.egress.actualHttpAttempts").value(0));
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void controlHeaderRejectsUnknownDuplicateAndTrailingFields() throws Exception {
|
||||
for (String header : new String[] {
|
||||
CONTEXT.replace("\"sourceAccount\":", "\"extra\":1,\"sourceAccount\":"),
|
||||
CONTEXT.replace("\"sourceAccount\":", "\"sourceAccount\":\"other\",\"sourceAccount\":"),
|
||||
CONTEXT + " {}", " ", "x".repeat(2049), "{}", "null",
|
||||
CONTEXT.replace("VALIDATION", "OFF"), CONTEXT.replace("root-1", "root with spaces")
|
||||
}) {
|
||||
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", header)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(BODY))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_EGRESS_CONTEXT_INVALID"));
|
||||
}
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void inventoryRequiresWarehouseAndDetailRequiresAllThreeIdentifiers() throws Exception {
|
||||
for (String suffix : new String[] {"/product-inventory/query", "/outbound-orders/detail"}) {
|
||||
mvc.perform(post("/internal/integrations/lingxing/wms" + suffix)
|
||||
.header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT).contentType(MediaType.APPLICATION_JSON)
|
||||
.content("{\"current\":1,\"size\":1000,\"deliveryNo\":\"DO-DEMO\"}"))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
|
||||
}
|
||||
verifyNoInteractions(client);
|
||||
}
|
||||
|
||||
@Test
|
||||
void authenticatedReadReturnsOnlyOutboundSummary() throws Exception {
|
||||
when(client.queryPage(any())).thenReturn(new PageResult(
|
||||
when(client.queryPage(any(), any())).thenReturn(new PageResult(
|
||||
List.of(new OrderSummary("TEST-OUT-001", "TEST-SRC-001", "TEST-CUSTOMER", "TEST-WH", 100,
|
||||
"2026-10-01 08:00:00", "2026-10-02 08:00:00")),
|
||||
1L, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z")));
|
||||
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
|
||||
1L, 1, 1, 1, 1, 1, 200, Instant.parse("2026-10-12T00:00:00Z"), null));
|
||||
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(BODY))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("200"))
|
||||
.andExpect(jsonPath("$.data.total").isString()).andExpect(jsonPath("$.data.total").value("1"))
|
||||
@@ -80,7 +143,7 @@ class LingxingInternalControllerTest {
|
||||
|
||||
@Test
|
||||
void invalidPageCannotReachProvider() throws Exception {
|
||||
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
|
||||
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(BODY.replace("\"current\":1", "\"current\":0")))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("A0422"));
|
||||
verifyNoInteractions(client);
|
||||
@@ -88,9 +151,9 @@ class LingxingInternalControllerTest {
|
||||
|
||||
@Test
|
||||
void providerFailureRemainsAnExplicitBusinessFailure() throws Exception {
|
||||
when(client.queryPage(any())).thenThrow(new LingxingWmsException(
|
||||
when(client.queryPage(any(), any())).thenThrow(new LingxingWmsException(
|
||||
"LINGXING_WMS_HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, 429, null));
|
||||
mvc.perform(post(PATH).header("X-Internal-Service-Token", token)
|
||||
mvc.perform(post(PATH).header("X-Internal-Service-Token", token).header("X-Lingxing-Egress-Context", CONTEXT)
|
||||
.contentType(MediaType.APPLICATION_JSON).content(BODY))
|
||||
.andExpect(status().isOk()).andExpect(jsonPath("$.code").value("S0503"))
|
||||
.andExpect(jsonPath("$.data.errorCode").value("LINGXING_WMS_HTTP_RATE_LIMITED"))
|
||||
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import java.net.URI;
|
||||
import java.time.*;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.function.UnaryOperator;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class LingxingEgressGateTest {
|
||||
static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
|
||||
static final String HASH = "a".repeat(64);
|
||||
private final MutableClock clock = new MutableClock(NOW);
|
||||
private final LedgerAuthority ledger = new LedgerAuthority(clock);
|
||||
private LingxingEgressGate gate() { return new LingxingEgressGate(props(CollectionMode.VALIDATION, "test"), ledger, clock, clock::advanceMillis); }
|
||||
static EgressContext context(String attempt) { return new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", attempt, "demo-grant"); }
|
||||
static LingxingEgressProperties props(CollectionMode mode, String environment) {
|
||||
return new LingxingEgressProperties(mode, environment, "demo-account", "demo-grant", NOW.plusSeconds(3600), URI.create("http://127.0.0.1"), null, null);
|
||||
}
|
||||
@Test void failureCompletionPreservesNullableTransportAndSourceCodes() {
|
||||
for (Integer status : new Integer[] {null, 503}) {
|
||||
var gate = gate();
|
||||
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("nullable-" + status),
|
||||
Operation.WAREHOUSES, HASH, handle -> {
|
||||
handle.startHttp();
|
||||
throw new LingxingWmsException("LINGXING_WMS_IO_FAILED", "固定失败摘要", true, status, null);
|
||||
}));
|
||||
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
|
||||
var completion = ledger.completions.get(ledger.completions.size() - 1);
|
||||
assertThat(completion.actualHttpAttempts()).isEqualTo(1);
|
||||
assertThat(completion.upstreamStatus()).isEqualTo(status);
|
||||
assertThat(completion.sourceCode()).isNull();
|
||||
}
|
||||
}
|
||||
@Test void defaultOffAndNonProductionBusinessNeverAuthorize() {
|
||||
var defaults = new LingxingEgressProperties(null, null, null, null, null, null, null, null);
|
||||
assertThat(defaults.collectionMode()).isEqualTo(CollectionMode.OFF);
|
||||
assertThat(defaults.minStartInterval()).isEqualTo(Duration.ofSeconds(5));
|
||||
var off = new LingxingEgressGate(defaults, ledger, clock, clock::advanceMillis);
|
||||
assertThatThrownBy(() -> off.execute(context("a"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; })).hasMessageContaining("EGRESS_OFF");
|
||||
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "test"), ledger, clock, clock::advanceMillis);
|
||||
assertThatThrownBy(() -> business.execute(context("a"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("BUSINESS_ENVIRONMENT_REJECTED");
|
||||
assertThat(ledger.authorizations).isZero();
|
||||
}
|
||||
@Test void proofAndContextAreServerBoundBeforeAuthorization() {
|
||||
var contexts = Arrays.asList(null, new EgressContext("other", CollectionMode.VALIDATION, "root", "session", "a", "demo-grant"),
|
||||
new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", "a", "demo-grant"),
|
||||
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "session", "a", "client-claimed-grant"),
|
||||
new EgressContext("demo-account", CollectionMode.VALIDATION, "", "session", "a", "demo-grant"),
|
||||
new EgressContext("demo-account", CollectionMode.VALIDATION, "root", "bad session", "a", "demo-grant"));
|
||||
for (var c : contexts) assertThrows(LingxingWmsException.class, () -> gate().execute(c, Operation.WAREHOUSES, HASH, h -> true));
|
||||
clock.advanceMillis(3600000);
|
||||
assertThrows(LingxingWmsException.class, () -> gate().execute(context("a"), Operation.WAREHOUSES, HASH, h -> true));
|
||||
assertThat(ledger.authorizations).isZero();
|
||||
}
|
||||
@Test void budgetPermitMustBeOneConsumedExclusiveUnexpiredAndExactlyBound() {
|
||||
List<UnaryOperator<AttemptPermit>> mutations = List.of(
|
||||
p -> null,
|
||||
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 2, p.expiresAt(), true, true),
|
||||
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant(), true, true),
|
||||
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, clock.instant().plusSeconds(31), true, true),
|
||||
p -> new AttemptPermit(context("different"), p.operation(), p.requestHash(), 1, p.expiresAt(), true, true),
|
||||
p -> new AttemptPermit(p.context(), Operation.OUTBOUND_DETAIL, p.requestHash(), 1, p.expiresAt(), true, true),
|
||||
p -> new AttemptPermit(p.context(), p.operation(), "b".repeat(64), 1, p.expiresAt(), true, true),
|
||||
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), false, true),
|
||||
p -> new AttemptPermit(p.context(), p.operation(), p.requestHash(), 1, p.expiresAt(), true, false));
|
||||
var calls = new AtomicInteger(); int n = 0;
|
||||
for (var mutation : mutations) {
|
||||
ledger.mutation = mutation; var gate = gate(); var c = context("attempt-" + ++n);
|
||||
assertThatThrownBy(() -> gate.execute(c, Operation.WAREHOUSES, HASH, h -> calls.incrementAndGet())).hasMessageContaining("BUDGET_PERMIT_INVALID");
|
||||
assertThatThrownBy(() -> gate.execute(context("next-" + c.attemptId()), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
}
|
||||
assertThat(calls).hasValue(0); assertThat(ledger.completions).isEmpty();
|
||||
}
|
||||
@Test void everyAttemptIsPersistentlyConsumedAndCannotReplayEvenAcrossGateInstances() {
|
||||
var c = context("single-use");
|
||||
assertThat(gate().execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
|
||||
assertThat(ledger.consumed).containsExactly("single-use");
|
||||
var second = gate(); var sends = new AtomicInteger();
|
||||
assertThatThrownBy(() -> second.execute(c, Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return sends.incrementAndGet(); })).hasMessageContaining("BUDGET_AUTHORITY_UNAVAILABLE");
|
||||
assertThat(sends).hasValue(0); assertThat(ledger.completions).hasSize(1);
|
||||
}
|
||||
@Test void handleCanStartOnlyOnceAndExpiredHandleCannotSend() {
|
||||
var error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("once"), Operation.WAREHOUSES, HASH,
|
||||
h -> { h.startHttp(); h.startHttp(); return true; }));
|
||||
assertThat(error.code()).endsWith("ATTEMPT_NOT_SENDABLE");
|
||||
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
|
||||
error = assertThrows(LingxingWmsException.class, () -> gate().execute(context("expires"), Operation.WAREHOUSES, HASH,
|
||||
h -> { clock.advanceMillis(30000); h.startHttp(); return true; }));
|
||||
assertThat(error.egress().actualHttpAttempts()).isZero();
|
||||
assertThat(ledger.completions).extracting(CompletionRequest::actualHttpAttempts).containsExactly(1, 0);
|
||||
}
|
||||
@Test void authorizationAndCompletionFailuresPauseAllFurtherCalls() {
|
||||
ledger.failAuthorize = true; var gate = gate();
|
||||
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("unavailable"), Operation.WAREHOUSES, HASH, h -> true));
|
||||
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isTrue();
|
||||
assertThatThrownBy(() -> gate.execute(context("next"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
ledger.failAuthorize = false; ledger.failComplete = true; var fresh = gate();
|
||||
error = assertThrows(LingxingWmsException.class, () -> fresh.execute(context("callback"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
|
||||
assertThat(error.code()).endsWith("BUDGET_COMPLETION_UNCONFIRMED");
|
||||
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
|
||||
assertThatThrownBy(() -> fresh.execute(context("after-callback"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
}
|
||||
@Test void startsAreSerializedAndSpacedByAtLeastFiveSeconds() throws Exception {
|
||||
var gate = gate(); var executor = Executors.newFixedThreadPool(2);
|
||||
var entered = new CountDownLatch(1); var release = new CountDownLatch(1);
|
||||
var starts = new CopyOnWriteArrayList<Instant>();
|
||||
try {
|
||||
var first = executor.submit(() -> gate.execute(context("first"), Operation.WAREHOUSES, HASH, h -> {
|
||||
h.startHttp(); starts.add(clock.instant()); entered.countDown();
|
||||
try { if (!release.await(2, TimeUnit.SECONDS)) throw new IllegalStateException("fixture timeout"); }
|
||||
catch (InterruptedException e) { throw new IllegalStateException(e); }
|
||||
return true;
|
||||
}));
|
||||
assertThat(entered.await(2, TimeUnit.SECONDS)).isTrue();
|
||||
var secondReady = new CountDownLatch(1);
|
||||
var second = executor.submit(() -> { secondReady.countDown(); return gate.execute(context("second"), Operation.OUTBOUND_DETAIL, HASH,
|
||||
h -> { h.startHttp(); starts.add(clock.instant()); return true; }); });
|
||||
assertThat(secondReady.await(2, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(ledger.authorizations).isEqualTo(1); assertThat(second.isDone()).isFalse();
|
||||
release.countDown(); assertThat(first.get(2, TimeUnit.SECONDS)).isTrue(); assertThat(second.get(2, TimeUnit.SECONDS)).isTrue();
|
||||
assertThat(starts).hasSize(2); assertThat(Duration.between(starts.get(0), starts.get(1))).isGreaterThanOrEqualTo(Duration.ofSeconds(5));
|
||||
assertThat(ledger.completions).hasSize(2);
|
||||
} finally { release.countDown(); executor.shutdownNow(); }
|
||||
}
|
||||
@Test void intervalCannotBeConfiguredBelowFiveSecondsAndPropertiesHideGrant() {
|
||||
assertThatThrownBy(() -> new LingxingEgressProperties(null, null, null, null, null, null, Duration.ofMillis(4999), null)).isInstanceOf(IllegalArgumentException.class);
|
||||
assertThat(props(CollectionMode.VALIDATION, "test").toString()).doesNotContain("demo-grant", "demo-account", "127.0.0.1");
|
||||
}
|
||||
@Test void knownBudgetDenialDoesNotPermanentlyPauseOrConsumeHttp() {
|
||||
var gate = gate(); ledger.denial = new AuthorizationDenial("VALIDATION_TOTAL_LIMIT", true, null);
|
||||
var error = assertThrows(LingxingWmsException.class, () -> gate.execute(context("denied"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return true; }));
|
||||
assertThat(error.denial()).isEqualTo(ledger.denial);
|
||||
assertThat(error.egress().actualHttpAttempts()).isZero(); assertThat(error.egress().accountPaused()).isFalse();
|
||||
assertThat(ledger.consumed).isEmpty(); assertThat(ledger.completions).isEmpty();
|
||||
ledger.denial = null;
|
||||
assertThat(gate.execute(context("fresh-session-attempt"), Operation.WAREHOUSES, HASH, h -> { h.startHttp(); return h.metadata(); }).actualHttpAttempts()).isEqualTo(1);
|
||||
}
|
||||
private static EgressContext businessContext(String attempt) {
|
||||
return new EgressContext("demo-account", CollectionMode.BUSINESS, "root", "session", attempt, "demo-grant");
|
||||
}
|
||||
private LingxingWmsException rateLimit() { return new LingxingWmsException("RATE_LIMIT", "synthetic rate limit", false, 429, null); }
|
||||
@Test void businessCooldownRequiresFreshDurablePermitBeforeRecovery() {
|
||||
var gate = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
|
||||
assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("limited"), Operation.WAREHOUSES, HASH, h -> {
|
||||
h.startHttp(); h.rateLimited(clock.instant().plusSeconds(600)); throw rateLimit();
|
||||
}));
|
||||
int used = ledger.authorizations;
|
||||
assertThatThrownBy(() -> gate.execute(businessContext("too-early"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThat(ledger.authorizations).isEqualTo(used);
|
||||
clock.advanceMillis(600000); ledger.denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, clock.instant().plusSeconds(60));
|
||||
var denied = assertThrows(LingxingWmsException.class, () -> gate.execute(businessContext("still-denied"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return true; }));
|
||||
assertThat(denied.egress().actualHttpAttempts()).isZero(); assertThat(denied.egress().accountPaused()).isTrue();
|
||||
ledger.denial = null;
|
||||
var accepted = gate.execute(businessContext("accepted"), Operation.OUTBOUND_DETAIL, HASH, h -> { h.startHttp(); return h.metadata(); });
|
||||
assertThat(accepted.actualHttpAttempts()).isEqualTo(1); assertThat(accepted.accountPaused()).isFalse(); assertThat(accepted.cooldownUntil()).isNull();
|
||||
assertThat(ledger.completions).hasSize(2);
|
||||
}
|
||||
@Test void committedRateCompletionWithLostAckReturnsUncertaintyInsteadOfRecoverableRateError() {
|
||||
var gate=new LingxingEgressGate(props(CollectionMode.BUSINESS,"production"),ledger,clock,clock::advanceMillis);
|
||||
ledger.failComplete=true;
|
||||
var error=assertThrows(LingxingWmsException.class,()->gate.execute(businessContext("rate-lost-ack"),Operation.WAREHOUSES,HASH,handle->{
|
||||
handle.startHttp();handle.rateLimited(clock.instant().plusSeconds(600));throw rateLimit();
|
||||
}));
|
||||
assertThat(error.code()).isEqualTo("LINGXING_WMS_BUDGET_COMPLETION_UNCONFIRMED");
|
||||
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);assertThat(error.egress().accountPaused()).isTrue();
|
||||
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(600));
|
||||
assertThat(ledger.completions).singleElement().satisfies(result->{
|
||||
assertThat(result.upstreamStatus()).isEqualTo(429);assertThat(result.actualHttpAttempts()).isEqualTo(1);
|
||||
assertThat(result.accountPaused()).isTrue();
|
||||
});
|
||||
clock.advanceMillis(601000);ledger.failComplete=false;
|
||||
assertThatThrownBy(()->gate.execute(businessContext("after-uncertain-ack"),Operation.WAREHOUSES,HASH,handle->true)).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThat(ledger.authorizations).isEqualTo(1);
|
||||
}
|
||||
@Test void validationRatePauseAndUnconfirmedCallbackNeverRecoverByTime() {
|
||||
var gate = gate();
|
||||
assertThrows(LingxingWmsException.class, () -> gate.execute(context("limited"), Operation.WAREHOUSES, HASH, h -> {
|
||||
h.startHttp(); h.rateLimited(null); throw rateLimit();
|
||||
}));
|
||||
clock.advanceMillis(600000);
|
||||
assertThatThrownBy(() -> gate.execute(context("next"), Operation.WAREHOUSES, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThat(ledger.authorizations).isEqualTo(1);
|
||||
var business = new LingxingEgressGate(props(CollectionMode.BUSINESS, "production"), ledger, clock, clock::advanceMillis);
|
||||
ledger.failComplete = true;
|
||||
assertThrows(LingxingWmsException.class, () -> business.execute(businessContext("lost-complete"), Operation.WAREHOUSES, HASH, h -> {
|
||||
h.startHttp(); h.rateLimited(null); throw rateLimit();
|
||||
}));
|
||||
clock.advanceMillis(600000); ledger.failComplete = false;
|
||||
assertThatThrownBy(() -> business.execute(businessContext("no-recover"), Operation.OUTBOUND_DETAIL, HASH, h -> true)).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThat(ledger.authorizations).isEqualTo(2);
|
||||
}
|
||||
static final class MutableClock extends Clock {
|
||||
private Instant now;
|
||||
MutableClock(Instant now) { this.now = now; }
|
||||
synchronized void advanceMillis(long millis) { now = now.plusMillis(millis); }
|
||||
@Override public ZoneId getZone() { return ZoneOffset.UTC; }
|
||||
@Override public Clock withZone(ZoneId zone) { return this; }
|
||||
@Override public synchronized Instant instant() { return now; }
|
||||
}
|
||||
/** Synthetic durable single-use ledger, shared between independently constructed gates. */
|
||||
static class LedgerAuthority implements LingxingBudgetAuthority {
|
||||
final Clock clock;
|
||||
final Set<String> consumed = new LinkedHashSet<>();
|
||||
final List<CompletionRequest> completions = new ArrayList<>();
|
||||
volatile int authorizations;
|
||||
boolean failAuthorize, failComplete;
|
||||
AuthorizationDenial denial;
|
||||
UnaryOperator<AttemptPermit> mutation = UnaryOperator.identity();
|
||||
LedgerAuthority(Clock clock) { this.clock = clock; }
|
||||
@Override public synchronized AttemptPermit authorize(AuthorizationRequest request) {
|
||||
authorizations++;
|
||||
if (denial != null) throw new LingxingBudgetDeniedException(denial);
|
||||
if (failAuthorize || !consumed.add(request.context().attemptId())) throw new IllegalStateException("budget refused");
|
||||
return mutation.apply(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, clock.instant().plusSeconds(30), true, true));
|
||||
}
|
||||
@Override public synchronized void complete(CompletionRequest request) {
|
||||
completions.add(request); if (failComplete) throw new IllegalStateException("completion not durable");
|
||||
}
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
import java.io.IOException;
|
||||
import java.net.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.concurrent.atomic.*;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** Loopback authority only; the persisted collector ledger is tested by its own service. */
|
||||
class LingxingHttpBudgetAuthorityTest {
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule())
|
||||
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
private final AtomicReference<String> response = new AtomicReference<>();
|
||||
private final AtomicInteger status = new AtomicInteger(200);
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final AtomicReference<String> path = new AtomicReference<>(), token = new AtomicReference<>(), body = new AtomicReference<>();
|
||||
private HttpServer server;
|
||||
private static final String TOKEN = "synthetic-internal-token-0123456789";
|
||||
private final AuthorizationRequest request = new AuthorizationRequest(LingxingEgressGateTest.context("attempt"), Operation.WAREHOUSES, LingxingEgressGateTest.HASH);
|
||||
@BeforeEach void start() throws IOException {
|
||||
response.set("{\"code\":\"200\",\"data\":true}");
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext("/", exchange -> {
|
||||
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
|
||||
token.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
|
||||
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
exchange.getResponseHeaders().set("Location", "/redirect-must-not-follow");
|
||||
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(status.get(), bytes.length); exchange.getResponseBody().write(bytes); exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
@AfterEach void stop() { server.stop(0); }
|
||||
private LingxingHttpBudgetAuthority authority(String serviceToken) {
|
||||
return new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
|
||||
LingxingEgressGateTest.NOW.plusSeconds(3600), URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null), mapper, serviceToken);
|
||||
}
|
||||
private CompletionRequest completion() { return new CompletionRequest(request.context(), request.operation(), request.requestHash(), 1, null, 200, 200, false, null); }
|
||||
private String permit() throws Exception { return mapper.writeValueAsString(new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1,
|
||||
LingxingEgressGateTest.NOW.plusSeconds(30), true, true)); }
|
||||
@Test void fixedInternalEndpointsAndTokenCarryExactAuthorizationAndCompletionContext() throws Exception {
|
||||
response.set("{\"code\":\"200\",\"data\":" + permit() + "}");
|
||||
var permit = authority(TOKEN).authorize(request);
|
||||
assertThat(permit.context()).isEqualTo(request.context()); assertThat(permit.allowedAttempts()).isEqualTo(1);
|
||||
assertThat(path.get()).isEqualTo("/internal/lastleg/self-operated-analytics/egress/authorize");
|
||||
assertThat(token.get()).isEqualTo(TOKEN);
|
||||
assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(request));
|
||||
response.set("{\"code\":\"200\",\"data\":true}");
|
||||
authority(TOKEN).complete(completion());
|
||||
assertThat(path.get()).endsWith("/egress/complete"); assertThat(mapper.readTree(body.get())).isEqualTo(mapper.valueToTree(completion()));
|
||||
assertThat(calls.get()).isEqualTo(2);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {"false", "\"true\"", "1", "{}", "null"})
|
||||
void completionRequiresLiteralBooleanAcknowledgment(String data) {
|
||||
response.set("{\"code\":\"200\",\"data\":" + data + "}");
|
||||
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {"200", "\"201\"", "null"})
|
||||
void envelopeRequiresExactStringSuccessCode(String code) {
|
||||
response.set("{\"code\":" + code + ",\"data\":true}");
|
||||
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(ints = {302, 401, 429, 503})
|
||||
void statusErrorsNeverRetryOrFollowRedirectAndNeverEchoAuthorityBody(int code) {
|
||||
status.set(code); response.set("private-fixture@example.test");
|
||||
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
|
||||
assertThat(error.getCause()).isNull(); assertThat(error.getMessage()).doesNotContain("private-fixture", "synthetic-token");
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@Test void missingTokenPreventsAllAuthorityHttp() {
|
||||
assertThrows(LingxingWmsException.class, () -> authority("").authorize(request));
|
||||
assertThrows(LingxingWmsException.class, () -> authority(null).complete(completion()));
|
||||
assertThat(calls.get()).isZero();
|
||||
}
|
||||
@Test void missingServerConfiguredAuthorityPreventsHttp() {
|
||||
var authority = new LingxingHttpBudgetAuthority(new LingxingEgressProperties(CollectionMode.VALIDATION, "test", "demo-account", "demo-grant",
|
||||
LingxingEgressGateTest.NOW.plusSeconds(3600), null, null, null), mapper, "synthetic-token");
|
||||
assertThrows(LingxingWmsException.class, () -> authority.authorize(request)); assertThat(calls.get()).isZero();
|
||||
}
|
||||
@Test void permitRejectsCoercedBooleansExtraFieldsAndInvalidAttemptCount() throws Exception {
|
||||
String valid = permit();
|
||||
String[] invalid = {valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":2"),
|
||||
valid.replace("\"allowedAttempts\":1", "\"allowedAttempts\":\"1\""),
|
||||
valid.replace("\"exclusiveConfirmed\":true", "\"exclusiveConfirmed\":\"true\""),
|
||||
valid.replace("\"budgetConsumed\":true", "\"budgetConsumed\":false"),
|
||||
valid.substring(0, valid.length() - 1) + ",\"untrusted\":true}",
|
||||
valid.replace("\"operation\":\"WAREHOUSES\"", "\"operation\":\"UNKNOWN\"")};
|
||||
for (var data : invalid) {
|
||||
response.set("{\"code\":\"200\",\"data\":" + data + "}");
|
||||
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
|
||||
}
|
||||
assertThat(calls.get()).isEqualTo(invalid.length);
|
||||
}
|
||||
@Test void trailingTokensAndMalformedResponseFailClosed() {
|
||||
for (String value : new String[] {"{\"code\":\"200\",\"data\":true} {}", "[]", "{"}) {
|
||||
response.set(value); assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
|
||||
}
|
||||
assertThat(calls.get()).isEqualTo(3);
|
||||
}
|
||||
@Test void knownAuthorityDenialIsTypedAndDoesNotBecomeUnavailability() {
|
||||
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"SESSION_EXPIRED\",\"sampleDone\":true,\"waitUntil\":null}}");
|
||||
var error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
|
||||
assertThat(error.denial()).isEqualTo(new AuthorizationDenial("SESSION_EXPIRED", true, null));
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
response.set("{\"code\":\"B0001\",\"data\":{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":false,\"waitUntil\":\"2026-10-12T00:10:00Z\"}}");
|
||||
error = assertThrows(LingxingBudgetDeniedException.class, () -> authority(TOKEN).authorize(request));
|
||||
assertThat(error.denial().waitUntil()).isEqualTo(LingxingEgressGateTest.NOW.plusSeconds(600));
|
||||
assertThrows(LingxingWmsException.class, () -> authority(TOKEN).complete(completion()));
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {
|
||||
"{\"errorCode\":\"private@example.test\",\"sampleDone\":true,\"waitUntil\":null}",
|
||||
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":\"true\",\"waitUntil\":null}",
|
||||
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true}",
|
||||
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":\"invalid\"}",
|
||||
"{\"errorCode\":\"ACCOUNT_COOLDOWN\",\"sampleDone\":true,\"waitUntil\":null,\"extra\":true}"
|
||||
})
|
||||
void malformedDenialFailsAsUnavailability(String data) {
|
||||
response.set("{\"code\":\"B0001\",\"data\":" + data + "}");
|
||||
var error = assertThrows(LingxingWmsException.class, () -> authority(TOKEN).authorize(request));
|
||||
assertThat(error.code()).endsWith("BUDGET_AUTHORITY_UNAVAILABLE");
|
||||
assertThat(error.getMessage()).doesNotContain("private@example.test"); assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@Test void allControlDtosRoundTripWithProductionTimeSerialization() throws Exception {
|
||||
var permit = new AttemptPermit(request.context(), request.operation(), request.requestHash(), 1, LingxingEgressGateTest.NOW.plusSeconds(30), true, true);
|
||||
var metadata = new AttemptMetadata("attempt", 1, true, LingxingEgressGateTest.NOW.plusSeconds(600));
|
||||
var denial = new AuthorizationDenial("ACCOUNT_COOLDOWN", false, metadata.cooldownUntil());
|
||||
for (var dto : new Object[] {request.context(), request, permit, completion(), metadata, denial}) {
|
||||
assertThat(mapper.readValue(mapper.writeValueAsString(dto), dto.getClass())).isEqualTo(dto);
|
||||
}
|
||||
}
|
||||
}
|
||||
+242
-222
@@ -1,261 +1,281 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingWmsProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.*;
|
||||
import org.junit.jupiter.params.ParameterizedTest;
|
||||
import org.junit.jupiter.params.provider.ValueSource;
|
||||
|
||||
import javax.crypto.Mac;
|
||||
import javax.crypto.spec.SecretKeySpec;
|
||||
import java.io.IOException;
|
||||
import java.io.PrintWriter;
|
||||
import java.io.StringWriter;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
import java.util.HexFormat;
|
||||
import java.time.*;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.assertj.core.api.Assertions.*;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
/** Synthetic loopback transport only; no supplier or application services. */
|
||||
class LingxingWmsClientTest {
|
||||
private static final String APP_KEY = "local-test-app-key";
|
||||
private static final String APP_SECRET = "local-test-app-secret";
|
||||
private static final String PRIVATE_DATA = "customer-private@example.test";
|
||||
private static final String PATH = "/openapi/v2/order/delivery/page";
|
||||
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00.987Z");
|
||||
private static final String EMPTY = "{\"code\":200,\"success\":true,\"data\":{\"total\":0,\"current\":1,\"size\":1,\"records\":[]}}";
|
||||
private final ObjectMapper mapper = new ObjectMapper();
|
||||
private static final Instant NOW = Instant.parse("2026-10-12T00:00:00Z");
|
||||
private static final String PRIVATE = "private-secret@example.test";
|
||||
private static final String ORDER = """
|
||||
{"deliveryNo":"DO-DEMO","sourceNo":"SRC-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO",
|
||||
"status":100,"outboundTime":"2026-10-01 01:00:00"}
|
||||
""";
|
||||
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
|
||||
private final AtomicInteger calls = new AtomicInteger();
|
||||
private final AtomicInteger responseStatus = new AtomicInteger(200);
|
||||
private final AtomicReference<String> responseBody = new AtomicReference<>(EMPTY);
|
||||
private final AtomicReference<String> receivedBody = new AtomicReference<>();
|
||||
private final AtomicReference<URI> receivedUri = new AtomicReference<>();
|
||||
private final AtomicReference<String> receivedMethod = new AtomicReference<>();
|
||||
private final AtomicInteger status = new AtomicInteger(200);
|
||||
private final AtomicReference<String> response = new AtomicReference<>();
|
||||
private final AtomicReference<String> body = new AtomicReference<>();
|
||||
private final AtomicReference<String> path = new AtomicReference<>();
|
||||
private HttpServer server;
|
||||
|
||||
@BeforeEach
|
||||
void startServer() throws IOException {
|
||||
private final AtomicInteger attempts = new AtomicInteger();
|
||||
private final AtomicReference<String> retryAfter = new AtomicReference<>();
|
||||
private final AtomicReference<CompletionRequest> completion = new AtomicReference<>();
|
||||
@BeforeEach void start() throws IOException {
|
||||
response.set(page("current", "size", 1, 1, 1, 1, ORDER));
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
server.createContext(PATH, exchange -> {
|
||||
calls.incrementAndGet();
|
||||
receivedMethod.set(exchange.getRequestMethod());
|
||||
receivedUri.set(exchange.getRequestURI());
|
||||
receivedBody.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
byte[] bytes = responseBody.get().getBytes(StandardCharsets.UTF_8);
|
||||
exchange.getResponseHeaders().add("Content-Type", "application/json; charset=UTF-8");
|
||||
exchange.sendResponseHeaders(responseStatus.get(), bytes.length);
|
||||
exchange.getResponseBody().write(bytes);
|
||||
exchange.close();
|
||||
server.createContext("/openapi/v2/", exchange -> {
|
||||
calls.incrementAndGet(); path.set(exchange.getRequestURI().getPath());
|
||||
body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
|
||||
byte[] bytes = response.get().getBytes(StandardCharsets.UTF_8);
|
||||
if (retryAfter.get() != null) exchange.getResponseHeaders().set("Retry-After", retryAfter.get());
|
||||
exchange.sendResponseHeaders(status.get(), bytes.length);
|
||||
exchange.getResponseBody().write(bytes); exchange.close();
|
||||
});
|
||||
server.start();
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void stopServer() { server.stop(0); }
|
||||
|
||||
@Test
|
||||
void sendsOmpWmsSignatureAndOutboundTimeFilterAndOnlyReturnsWhitelist() throws Exception {
|
||||
responseBody.set("""
|
||||
{"code":200,"success":true,"data":{"total":12,"current":1,"size":1,"records":[{
|
||||
"deliveryNo":"DO-LOCAL-001","sourceNo":"OB-LOCAL-001","customerCode":"C-LOCAL",
|
||||
"whCode":"WH-LOCAL","status":100,"createTime":"2026-09-21 10:11:12",
|
||||
"outboundTime":"2026-09-22 20:21:22","email":"customer-private@example.test",
|
||||
"remark":"local-test-app-secret","receiver":"PRIVATE_RECEIVER"
|
||||
}]}}
|
||||
""");
|
||||
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
|
||||
|
||||
var body = mapper.readTree(receivedBody.get());
|
||||
String timestamp = Long.toString(NOW.getEpochSecond());
|
||||
String dataValue = "{current=1, size=1, timeType=outboundTime, startTime=2026-09-12 08:00:00, endTime=2026-10-12 08:00:00, status=100}";
|
||||
String input = APP_SECRET + PATH + "appKey" + APP_KEY + "data" + dataValue + "timestamp" + timestamp + APP_SECRET;
|
||||
assertThat(body.path("sign").textValue()).isEqualTo(hmac(input));
|
||||
assertThat(body.path("timestamp").textValue()).isEqualTo(timestamp).matches("\\d{10}");
|
||||
assertThat(body.path("appKey").textValue()).isEqualTo(APP_KEY);
|
||||
assertThat(body.path("data").toString()).isEqualTo("{\"current\":1,\"size\":1,\"timeType\":\"outboundTime\",\"startTime\":\"2026-09-12 08:00:00\",\"endTime\":\"2026-10-12 08:00:00\",\"status\":100}");
|
||||
assertThat(receivedUri.get().getPath()).isEqualTo(PATH);
|
||||
assertThat(receivedUri.get().getRawQuery()).isNull();
|
||||
assertThat(receivedMethod.get()).isEqualTo("POST");
|
||||
assertThat(receivedBody.get()).doesNotContain(APP_SECRET, "authcode", "reqTime", "access_token", "pageSize");
|
||||
assertThat(result.total()).isEqualTo(12L);
|
||||
@AfterEach void stop() { server.stop(0); }
|
||||
@Test void discoveryFiltersOutboundTimeAndUsesWhitelist() throws Exception {
|
||||
var result = client().queryPage(query(1, 1), context());
|
||||
var data = mapper.readTree(body.get()).path("data");
|
||||
assertThat(data.size()).isEqualTo(6);
|
||||
assertThat(data.path("timeType").asText()).isEqualTo("outboundTime");
|
||||
assertThat(data.path("status").intValue()).isEqualTo(100);
|
||||
assertThat(data.has("whCode")).isFalse();
|
||||
assertThat(path.get()).isEqualTo("/openapi/v2/order/delivery/page");
|
||||
assertThat(result.observedAt()).isEqualTo(NOW);
|
||||
assertThat(result.sourceCode()).isEqualTo(200);
|
||||
assertThat(result.records()).singleElement().satisfies(order -> {
|
||||
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
|
||||
assertThat(order.sourceNo()).isEqualTo("OB-LOCAL-001");
|
||||
assertThat(order.customerCode()).isEqualTo("C-LOCAL");
|
||||
assertThat(order.whCode()).isEqualTo("WH-LOCAL");
|
||||
assertThat(order.status()).isEqualTo(100);
|
||||
assertThat(order.createTime()).isEqualTo("2026-09-21 10:11:12");
|
||||
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
|
||||
});
|
||||
String summary = mapper.writeValueAsString(result.records().get(0));
|
||||
assertThat(mapper.readTree(summary).size()).isEqualTo(7);
|
||||
assertThat(summary).doesNotContain(PRIVATE_DATA, APP_SECRET, "PRIVATE_RECEIVER");
|
||||
assertThat(result.pages()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void emptySuccessfulPageHasNoInventedOrder() {
|
||||
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
|
||||
assertThat(result.records()).isEmpty();
|
||||
assertThat(result.total()).isZero();
|
||||
assertThat(calls).hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void verifiedWmsEnvelopeWithoutSuccessRequiresValidPage() {
|
||||
responseBody.set("""
|
||||
{"code":200,"data":{"total":1,"current":1,"size":1,"records":[{
|
||||
"deliveryNo":"DO-LOCAL-001","status":100,"outboundTime":"2026-09-22 20:21:22"
|
||||
}]}}
|
||||
""");
|
||||
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
|
||||
assertThat(result.sourceCode()).isEqualTo(200);
|
||||
assertThat(result.total()).isEqualTo(1L);
|
||||
assertThat(result.records()).singleElement().satisfies(order -> {
|
||||
assertThat(order.deliveryNo()).isEqualTo("DO-LOCAL-001");
|
||||
assertThat(order.status()).isEqualTo(100);
|
||||
assertThat(order.outboundTime()).isEqualTo("2026-09-22 20:21:22");
|
||||
});
|
||||
assertThat(calls).hasValue(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void documentedEmptyNumericValuesRemainUnknown() {
|
||||
responseBody.set("{\"code\":200,\"success\":true,\"data\":{\"total\":\"\",\"records\":[{\"deliveryNo\":\"DO-LOCAL\",\"status\":\"\",\"outboundTime\":\"\"}]}}");
|
||||
var result = client(true, APP_KEY, APP_SECRET, Duration.ZERO).queryPage(query());
|
||||
assertThat(result.total()).isNull();
|
||||
assertThat(result.records().get(0).status()).isNull();
|
||||
@Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception {
|
||||
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", "")));
|
||||
var result = client().locate(new LocateRequest("DO-DEMO", 1, 1), context());
|
||||
var data = mapper.readTree(body.get()).path("data");
|
||||
assertThat(data.size()).isEqualTo(3);
|
||||
assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse();
|
||||
assertThat(result.records().get(0).status()).isEqualTo(99);
|
||||
assertThat(result.records().get(0).outboundTime()).isEmpty();
|
||||
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("DO-DEMO", "DO-DEMO-X")));
|
||||
contract(() -> client().locate(new LocateRequest("DO-DEMO", 1, 1), context()), "LOCATE_DELIVERY_NO_MISMATCH");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledAndMissingCredentialsNeverSendRequest() {
|
||||
assertFailure(client(false, APP_KEY, APP_SECRET, Duration.ZERO), "DISABLED", false);
|
||||
assertFailure(client(true, "", APP_SECRET, Duration.ZERO), "MISSING_CREDENTIALS", false);
|
||||
assertFailure(client(true, APP_KEY, " ", Duration.ZERO), "MISSING_CREDENTIALS", false);
|
||||
assertThat(calls).hasValue(0);
|
||||
@Test void exactEmptyExceptionKeepsSourceSizeAndNeverAppliesToInventory() {
|
||||
response.set(page("current", "size", 1, 10, 0, 0, ""));
|
||||
var result = client().queryPage(query(1, 100), context());
|
||||
assertThat(result.records()).isEmpty(); assertThat(result.total()).isZero();
|
||||
assertThat(result.size()).isEqualTo(100); assertThat(result.sourceSize()).isEqualTo(10);
|
||||
contract(() -> client().queryPage(query(1, 99), context()), "SIZE_MISMATCH");
|
||||
contract(() -> client().queryPage(query(2, 100), context()), "CURRENT_MISMATCH");
|
||||
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 100), context()), "SIZE_MISMATCH");
|
||||
response.set(page("current", "size", 1, 10, 1, 1, ORDER));
|
||||
contract(() -> client().queryPage(query(1, 100), context()), "SIZE_MISMATCH");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {11002, 11003, 11004, 11005, 11006, 11007, 11008, 11010, 31001})
|
||||
void documentedStringAuthErrorsNeedNoDataAndDoNotEchoPayloadOrRetry(int sourceCode) {
|
||||
responseBody.set("{\"code\":\"" + sourceCode + "\",\"msg\":\"" + APP_KEY + APP_SECRET + PRIVATE_DATA + "\"}");
|
||||
String expected = sourceCode == 11008 ? "AUTHORIZATION_REJECTED"
|
||||
: sourceCode == 11006 ? "SIGNATURE_REJECTED" : "SOURCE_REJECTED";
|
||||
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), expected, sourceCode == 31001);
|
||||
assertThat(error.sourceCode()).isEqualTo(sourceCode);
|
||||
assertThat(error.upstreamStatus()).isEqualTo(200);
|
||||
assertThat(calls).hasValue(1);
|
||||
@Test void warehousesMapPageAndPageSizeWithoutTreatingTimezoneAsSource() throws Exception {
|
||||
response.set(page("page", "pageSize", 1, 1000, 1, 1, """
|
||||
{"whCode":"W-DEMO","whNameCn":"Demo","warehouseType":1,"status":0,"timeZone":"America/New_York","address":"private"}
|
||||
"""));
|
||||
var result = client().queryWarehouses(new WarehouseQueryRequest(1, 1000), context());
|
||||
assertThat(result.sourceSize()).isEqualTo(1000);
|
||||
assertThat(result.records().get(0).timeZone()).isEqualTo("America/New_York");
|
||||
assertThat(mapper.readTree(body.get()).path("data").size()).isEqualTo(2);
|
||||
assertThat(path.get()).isEqualTo("/openapi/v2/warehouse/page");
|
||||
assertThat(mapper.writeValueAsString(result)).doesNotContain("address");
|
||||
}
|
||||
|
||||
@Test
|
||||
void falseBusinessSuccessIsNotAnEmptySuccess() {
|
||||
responseBody.set("{\"code\":200,\"success\":false,\"msg\":\"" + PRIVATE_DATA + "\"}");
|
||||
assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), "SOURCE_REJECTED", false);
|
||||
@Test void detailPreservesDecimalLinesAndIgnoresPackageDuplicates() throws Exception {
|
||||
response.set("""
|
||||
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":100,
|
||||
"orderCreateTime":"2026-09-01 01:00:00","outboundTime":"2026-10-01 01:00:00",
|
||||
"skuVOList":[{"productSku":"SKU","actualDeliveryQty":2.123456,"deliveryQty":6},
|
||||
{"productSku":"SKU","actualDeliveryQty":3,"deliveryQty":6}],
|
||||
"packageList":[{"skuList":[{"actualDeliveryQty":"5"}]}],"email":"private"}}
|
||||
""");
|
||||
var result = client().queryDetail(detail(), context());
|
||||
assertThat(result.skuVOList()).hasSize(2);
|
||||
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isEqualByComparingTo("2.123456");
|
||||
assertThat(result.orderCreateTime()).isEqualTo("2026-09-01 01:00:00");
|
||||
assertThat(mapper.writeValueAsString(result.skuVOList())).doesNotContain("email", "packageList");
|
||||
assertThat(mapper.readTree(body.get()).path("data").toString())
|
||||
.isEqualTo("{\"whCode\":\"W-DEMO\",\"customerCode\":\"C-DEMO\",\"deliveryNo\":\"DO-DEMO\"}");
|
||||
response.set(response.get().replace("W-DEMO", "OTHER"));
|
||||
contract(() -> client().queryDetail(detail(), context()), "DETAIL_IDENTITY_MISMATCH");
|
||||
}
|
||||
|
||||
@ParameterizedTest
|
||||
@ValueSource(ints = {401, 403, 429, 500, 302})
|
||||
void httpFailuresDoNotEchoBodyOrRetry(int status) {
|
||||
responseStatus.set(status);
|
||||
responseBody.set(APP_KEY + APP_SECRET + PRIVATE_DATA);
|
||||
String code = status == 429 ? "HTTP_RATE_LIMITED" : status == 401 || status == 403 ? "AUTHORIZATION_REJECTED" : "HTTP_FAILED";
|
||||
var error = assertFailure(client(true, APP_KEY, APP_SECRET, Duration.ZERO), code, status == 429 || status >= 500);
|
||||
assertThat(error.upstreamStatus()).isEqualTo(status);
|
||||
assertThat(calls).hasValue(1);
|
||||
@Test void cancelledDetailRetainsUnknownQuantityAndEmptyTime() {
|
||||
response.set("""
|
||||
{"code":200,"data":{"deliveryNo":"DO-DEMO","customerCode":"C-DEMO","whCode":"W-DEMO","status":99,
|
||||
"outboundTime":"","skuVOList":[{"productSku":"SKU","actualDeliveryQty":"","deliveryQty":1}]}}
|
||||
""");
|
||||
var result = client().queryDetail(detail(), context());
|
||||
assertThat(result.status()).isEqualTo(99); assertThat(result.outboundTime()).isEmpty();
|
||||
assertThat(result.skuVOList().get(0).actualDeliveryQty()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void malformedResponsesExposeFixedContractStageOnly() {
|
||||
String[][] scenarios = {
|
||||
{"<html>" + PRIVATE_DATA + "</html>", "JSON"},
|
||||
{"[]", "ROOT_TYPE_ARRAY"},
|
||||
{"{}", "CODE_TYPE_MISSING"},
|
||||
{"{\"code\":\"200\"}", "CODE_TYPE_STRING"},
|
||||
{"{\"code\":\"99999\"}", "CODE_TYPE_STRING"},
|
||||
{"{\"code\":200}", "DATA_TYPE_MISSING"},
|
||||
{"{\"code\":200,\"data\":{}}", "RECORDS_TYPE_MISSING"},
|
||||
{"{\"code\":200,\"success\":null}", "SUCCESS_TYPE_NULL"},
|
||||
{"{\"code\":200,\"success\":\"true\"}", "SUCCESS_TYPE_STRING"},
|
||||
{"{\"code\":200,\"success\":true,\"data\":[]}", "DATA_TYPE_ARRAY"},
|
||||
{"{\"code\":200,\"success\":true,\"data\":{\"records\":{}}}", "RECORDS_TYPE_OBJECT"},
|
||||
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{},{}]}}", "RECORDS_OVER_PAGE_SIZE"},
|
||||
{"{\"code\":200,\"success\":true,\"data\":{\"total\":\"0\",\"records\":[]}}", "TOTAL_TYPE_STRING"},
|
||||
{"{\"code\":200,\"success\":true,\"data\":{\"current\":2,\"records\":[]}}", "CURRENT_MISMATCH"},
|
||||
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":1}]}}", "DELIVERY_NO_TYPE_NUMBER"},
|
||||
{"{\"code\":200,\"success\":true,\"data\":{\"records\":[{\"deliveryNo\":\"A\",\"status\":\"100\"}]}}", "STATUS_TYPE_STRING"}
|
||||
};
|
||||
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
|
||||
for (String[] scenario : scenarios) {
|
||||
responseBody.set(scenario[0]);
|
||||
var error = assertFailure(client, "INVALID_RESPONSE", false);
|
||||
assertThat(error.getMessage()).contains("(" + scenario[1] + ")");
|
||||
assertThat(error.upstreamStatus()).isEqualTo(200);
|
||||
@Test void inventoryPreservesIndependentNumbersAndUnknownsAndRejectsDuplicates() throws Exception {
|
||||
String stock = """
|
||||
{"customerCode":"C-DEMO","whCode":"W-DEMO","productSku":"SKU","productQuality":0,
|
||||
"stkQty":120.123456,"freezeQty":20,"useStkQty":100,"transitQty":30,"innerBatchNo":"",
|
||||
"stockCompare":{"stkQty":999},"phone":"private"}
|
||||
""";
|
||||
response.set(page("current", "size", 1, 1000, 1, 1, stock));
|
||||
var result = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context());
|
||||
assertThat(mapper.readTree(body.get()).path("data").toString())
|
||||
.isEqualTo("{\"current\":1,\"size\":1000,\"whCode\":\"W-DEMO\",\"isHideInventory\":0}");
|
||||
var row = result.records().get(0);
|
||||
assertThat(row.stkQty()).isEqualByComparingTo("120.123456");
|
||||
assertThat(row.useStkQty()).isEqualByComparingTo("100"); assertThat(row.transitQty()).isEqualByComparingTo("30");
|
||||
assertThat(row.innerBatchNo()).isEmpty();
|
||||
assertThat(mapper.writeValueAsString(result.records())).doesNotContain("phone", "stockCompare");
|
||||
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("120.123456", "0")
|
||||
.replace("\"freezeQty\":20", "\"freezeQty\":\"\"").replace("\"useStkQty\":100", "\"useStkQty\":null")
|
||||
.replace("\"transitQty\":30", "\"transitQty\":-1")));
|
||||
row = client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()).records().get(0);
|
||||
assertThat(row.stkQty()).isZero(); assertThat(row.freezeQty()).isNull(); assertThat(row.useStkQty()).isNull();
|
||||
assertThat(row.transitQty()).isEqualByComparingTo("-1");
|
||||
response.set(page("current", "size", 1, 1000, 2, 1, stock + "," + stock));
|
||||
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "DUPLICATE_INVENTORY_KEY");
|
||||
response.set(page("current", "size", 1, 1000, 1, 1, stock.replace("W-DEMO", "OTHER")));
|
||||
contract(() -> client().queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1000), context()), "INVENTORY_WAREHOUSE_MISMATCH");
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {"total", "pages", "current", "size"})
|
||||
void missingPaginationIsRejected(String field) {
|
||||
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"" + field + "\":1,", ""));
|
||||
contract(() -> client().queryPage(query(1, 1), context()), "");
|
||||
}
|
||||
@Test void shortPageAndWrongPageCountAreRejected() {
|
||||
response.set(page("current", "size", 1, 2, 2, 1, ORDER));
|
||||
contract(() -> client().queryPage(query(1, 2), context()), "PAGE_ROW_COUNT_MISMATCH");
|
||||
response.set(page("current", "size", 1, 1, 1, 2, ORDER));
|
||||
contract(() -> client().queryPage(query(1, 1), context()), "PAGES_MISMATCH");
|
||||
}
|
||||
@ParameterizedTest @ValueSource(ints = {401, 403, 302})
|
||||
void nonretryableHttpNeverEchoesOrRetries(int code) {
|
||||
status.set(code); response.set(PRIVATE);
|
||||
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
|
||||
assertThat(error.retryable()).isFalse(); assertThat(error.getCause()).isNull();
|
||||
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(ints = {429, 500, 503})
|
||||
void everyFailureHasOneHttpAttemptAndNeverImplicitRetry(int code) {
|
||||
status.set(code); response.set(PRIVATE);
|
||||
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
assertThat(error.getMessage()).doesNotContain(PRIVATE);
|
||||
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1);
|
||||
assertThat(completion.get().actualHttpAttempts()).isEqualTo(1);
|
||||
assertThat(completion.get().upstreamStatus()).isEqualTo(code);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {"600", "Mon, 12 Oct 2026 00:10:00 GMT", "invalid"})
|
||||
void rateLimitPausesAccountAcrossAllEntrypointsAndReportsCooldown(String header) {
|
||||
status.set(429); response.set(PRIVATE); retryAfter.set(header);
|
||||
var client = client();
|
||||
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query(1, 1), context()));
|
||||
assertThat(error.egress().accountPaused()).isTrue();
|
||||
assertThat(error.egress().cooldownUntil()).isEqualTo(NOW.plusSeconds(header.equals("invalid") ? 300 : 600));
|
||||
assertThat(completion.get().accountPaused()).isTrue();
|
||||
assertThat(completion.get().cooldownUntil()).isEqualTo(error.egress().cooldownUntil());
|
||||
assertThatThrownBy(() -> client.locate(new LocateRequest("DO-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThatThrownBy(() -> client.queryWarehouses(new WarehouseQueryRequest(1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThatThrownBy(() -> client.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThatThrownBy(() -> client.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context())).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@Test void validationRejectsLargePageBeforeHttpOrBudget() {
|
||||
var c = client(CollectionMode.VALIDATION, true, true);
|
||||
assertThatThrownBy(() -> c.queryPage(query(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
|
||||
assertThatThrownBy(() -> c.locate(new LocateRequest("DO-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
|
||||
assertThatThrownBy(() -> c.queryWarehouses(new WarehouseQueryRequest(1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
|
||||
assertThatThrownBy(() -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 11), context(CollectionMode.VALIDATION))).hasMessageContaining("参数");
|
||||
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
|
||||
}
|
||||
@Test void validationNeverRetriesEvenServerError() {
|
||||
status.set(503); response.set(PRIVATE);
|
||||
assertThrows(LingxingWmsException.class, () -> client(CollectionMode.VALIDATION, true, true)
|
||||
.queryPage(query(1, 1), context(CollectionMode.VALIDATION)));
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
@ParameterizedTest @ValueSource(strings = {"31001", "\"429\""})
|
||||
void businessRateLimitCodesPauseWithoutHttpRetry(String sourceCode) {
|
||||
response.set("{\"code\":" + sourceCode + ",\"msg\":\"" + PRIVATE + "\"}");
|
||||
var c = client();
|
||||
var error = assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
|
||||
assertThat(error.egress().actualHttpAttempts()).isEqualTo(1); assertThat(error.egress().accountPaused()).isTrue();
|
||||
assertThat(completion.get().sourceCode()).isIn(31001, 429);
|
||||
assertThatThrownBy(() -> c.queryDetail(detail(), context())).hasMessageContaining("ACCOUNT_PAUSED");
|
||||
assertThat(calls.get()).isEqualTo(1); assertThat(error.getMessage()).doesNotContain(PRIVATE);
|
||||
}
|
||||
@Test void allFiveEntrypointsRequireControlContext() {
|
||||
var c = client();
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), null));
|
||||
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), null));
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), null));
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), null));
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), null));
|
||||
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
|
||||
}
|
||||
@Test void allFiveEntrypointsFailClosedForOffMissingGrantOrBudget() {
|
||||
for (var c : new LingxingWmsClient[] {client(CollectionMode.OFF, true, true),
|
||||
client(CollectionMode.BUSINESS, false, true), client(CollectionMode.BUSINESS, true, false)}) {
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryPage(query(1, 1), context()));
|
||||
assertThrows(LingxingWmsException.class, () -> c.locate(new LocateRequest("DO-DEMO", 1, 1), context()));
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryWarehouses(new WarehouseQueryRequest(1, 1), context()));
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryDetail(detail(), context()));
|
||||
assertThrows(LingxingWmsException.class, () -> c.queryInventory(new InventoryQueryRequest("W-DEMO", 1, 1), context()));
|
||||
}
|
||||
assertThat(calls.get()).isZero(); assertThat(completion.get()).isNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void localRateLimitStopsSecondRequest() {
|
||||
var client = client(true, APP_KEY, APP_SECRET, Duration.ofMinutes(1));
|
||||
client.queryPage(query());
|
||||
assertFailure(client, "LOCAL_RATE_LIMITED", true);
|
||||
assertThat(calls).hasValue(1);
|
||||
@ParameterizedTest @ValueSource(strings = {"\"11006\"", "11008", "\"200\"", "null"})
|
||||
void rejectedEnvelopesNeverEchoRawMessage(String code) {
|
||||
response.set("{\"code\":" + code + ",\"msg\":\"" + PRIVATE + "\"}");
|
||||
var error = assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
|
||||
assertThat(error.getMessage()).doesNotContain(PRIVATE); assertThat(error.getCause()).isNull();
|
||||
assertThat(calls.get()).isEqualTo(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void validatesLocalPaginationAndMonthBeforeSending() {
|
||||
var client = client(true, APP_KEY, APP_SECRET, Duration.ZERO);
|
||||
var query = query();
|
||||
for (var invalid : new QueryRequest[] {
|
||||
new QueryRequest(query.startTime(), query.endTime(), 0, 1),
|
||||
new QueryRequest(query.startTime(), query.endTime(), 1, 101),
|
||||
new QueryRequest(query.endTime(), query.startTime(), 1, 1),
|
||||
new QueryRequest(query.startTime().minusSeconds(1), query.endTime(), 1, 1),
|
||||
new QueryRequest(null, query.endTime(), 1, 1)
|
||||
}) assertThatThrownBy(() -> client.queryPage(invalid)).isInstanceOf(LingxingWmsException.class);
|
||||
assertThat(calls).hasValue(0);
|
||||
@ParameterizedTest @ValueSource(strings = {"false", "null", "\"true\""})
|
||||
void presentSuccessMustBeBooleanTrue(String success) {
|
||||
response.set(page("current", "size", 1, 1, 1, 1, ORDER).replace("\"code\":200", "\"code\":200,\"success\":" + success));
|
||||
assertThrows(LingxingWmsException.class, () -> client().queryPage(query(1, 1), context()));
|
||||
}
|
||||
|
||||
private LingxingWmsException assertFailure(LingxingWmsClient client, String code, boolean retryable) {
|
||||
var error = assertThrows(LingxingWmsException.class, () -> client.queryPage(query()));
|
||||
assertThat(error.code()).isEqualTo("LINGXING_WMS_" + code);
|
||||
assertThat(error.retryable()).isEqualTo(retryable);
|
||||
assertThat(error.getCause()).isNull();
|
||||
StringWriter trace = new StringWriter();
|
||||
error.printStackTrace(new PrintWriter(trace));
|
||||
assertThat(trace.toString()).doesNotContain(APP_KEY, APP_SECRET, PRIVATE_DATA, "sign=", "http://");
|
||||
return error;
|
||||
private LingxingWmsClient client() { return client(CollectionMode.BUSINESS, true, true); }
|
||||
private LingxingWmsClient client(CollectionMode mode, boolean grant, boolean budget) {
|
||||
var clock = new LingxingEgressGateTest.MutableClock(NOW);
|
||||
var p = new LingxingWmsProperties(true, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
"synthetic-key", "synthetic-secret", Duration.ofSeconds(2), Duration.ofSeconds(2));
|
||||
var props = new LingxingEgressProperties(mode, "production", "demo-account", grant ? "demo-grant" : null,
|
||||
NOW.plusSeconds(3600), budget ? URI.create("http://127.0.0.1") : null, null, null);
|
||||
var ledger = new LingxingEgressGateTest.LedgerAuthority(clock) {
|
||||
@Override public void complete(CompletionRequest request) { super.complete(request); completion.set(request); }
|
||||
};
|
||||
var gate = new LingxingEgressGate(props, ledger, clock, clock::advanceMillis);
|
||||
return new LingxingWmsClient(p, mapper, clock, gate);
|
||||
}
|
||||
|
||||
private LingxingWmsClient client(boolean enabled, String key, String secret, Duration interval) {
|
||||
var properties = new LingxingWmsProperties(enabled, URI.create("http://127.0.0.1:" + server.getAddress().getPort()),
|
||||
key, secret, Duration.ofSeconds(2), Duration.ofSeconds(2), interval);
|
||||
return new LingxingWmsClient(properties, mapper, Clock.fixed(NOW, ZoneOffset.UTC));
|
||||
private EgressContext context() { return context(CollectionMode.BUSINESS); }
|
||||
private EgressContext context(CollectionMode mode) {
|
||||
return new EgressContext("demo-account", mode, "demo-root", "demo-session", "attempt-" + attempts.incrementAndGet(), "demo-grant");
|
||||
}
|
||||
|
||||
private static QueryRequest query() {
|
||||
LocalDateTime end = LocalDateTime.of(2026, 10, 12, 8, 0);
|
||||
return new QueryRequest(end.minusMonths(1), end, 1, 1);
|
||||
private static QueryRequest query(int current, int size) {
|
||||
var end = LocalDateTime.of(2026, 10, 12, 8, 0); return new QueryRequest(end.minusMonths(1), end, current, size);
|
||||
}
|
||||
|
||||
private static String hmac(String input) throws Exception {
|
||||
Mac mac = Mac.getInstance("HmacSHA256");
|
||||
mac.init(new SecretKeySpec(APP_SECRET.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
|
||||
return HexFormat.of().formatHex(mac.doFinal(input.getBytes(StandardCharsets.UTF_8)));
|
||||
private static DetailRequest detail() { return new DetailRequest("W-DEMO", "C-DEMO", "DO-DEMO"); }
|
||||
private static String page(String c, String s, int current, int size, long total, int pages, String rows) {
|
||||
return "{\"code\":200,\"data\":{\"" + c + "\":" + current + ",\"" + s + "\":" + size
|
||||
+ ",\"total\":" + total + ",\"pages\":" + pages + ",\"records\":[" + rows + "]}}";
|
||||
}
|
||||
private static void contract(Runnable action, String stage) {
|
||||
var error = assertThrows(LingxingWmsException.class, action::run);
|
||||
assertThat(error.code()).isEqualTo("LINGXING_WMS_INVALID_RESPONSE");
|
||||
assertThat(error.getMessage()).contains(stage).doesNotContain(PRIVATE); assertThat(error.retryable()).isFalse();
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -70,16 +70,15 @@ class LingxingWmsSignerTest {
|
||||
assertThat(properties.baseUri()).isEqualTo(URI.create("https://api.xlwms.com"));
|
||||
assertThat(properties.connectTimeout()).isEqualTo(Duration.ofSeconds(5));
|
||||
assertThat(properties.requestTimeout()).isEqualTo(Duration.ofSeconds(15));
|
||||
assertThat(properties.minRequestInterval()).isEqualTo(Duration.ofSeconds(1));
|
||||
assertThat(properties.toString()).doesNotContain("example-key", "example-secret");
|
||||
}
|
||||
|
||||
@Test
|
||||
void disabledIsDefaultAndPlainHttpOutsideLoopbackIsRejected() {
|
||||
var properties = new LingxingWmsProperties(false, null, null, null, null, null, null);
|
||||
var properties = new LingxingWmsProperties(false, null, null, null, null, null);
|
||||
assertThat(properties.enabled()).isFalse();
|
||||
assertThat(properties.hasCredentials()).isFalse();
|
||||
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null, null))
|
||||
assertThatThrownBy(() -> new LingxingWmsProperties(true, URI.create("http://example.test"), "key", "secret", null, null))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user