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
@@ -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'}