feat: add self-operated analytics and enforce Lastleg module boundaries

This commit is contained in:
2026-10-12 20:27:35 +08:00
parent c06e788b0f
commit a1f1d93902
174 changed files with 13514 additions and 490 deletions
+2 -2
View File
@@ -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=
+1
View File
@@ -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`
+102
View File
@@ -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)。
@@ -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 + "]";
}
}
@@ -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)
@@ -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) { }
}
@@ -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) { }
}
@@ -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); }
}
}
@@ -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);
}
@@ -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; }
}
@@ -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); }
}
@@ -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);
}
}
@@ -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);
}
@@ -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'}
@@ -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"))
@@ -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");
}
}
}
@@ -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);
}
}
}
@@ -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();
}
}
@@ -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);
}