feat: add self-operated analytics and enforce Lastleg module boundaries
This commit is contained in:
+35
@@ -0,0 +1,35 @@
|
||||
package com.baiyue.integration.config;
|
||||
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
import org.springframework.boot.context.properties.bind.DefaultValue;
|
||||
import java.net.URI;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
|
||||
/** An operator-attested exclusive window; it is not a distributed account lock. */
|
||||
@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress")
|
||||
public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode,
|
||||
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
|
||||
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
|
||||
@DefaultValue("5m") Duration cooldownFallback) {
|
||||
public LingxingEgressProperties {
|
||||
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
|
||||
environmentLabel = environmentLabel == null ? "test" : environmentLabel;
|
||||
minStartInterval = minStartInterval == null ? Duration.ofSeconds(5) : minStartInterval;
|
||||
cooldownFallback = cooldownFallback == null ? Duration.ofMinutes(5) : cooldownFallback;
|
||||
if (minStartInterval.compareTo(Duration.ofSeconds(5)) < 0 || minStartInterval.compareTo(Duration.ofMinutes(5)) > 0
|
||||
|| cooldownFallback.compareTo(Duration.ofSeconds(5)) < 0 || cooldownFallback.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("领星出口间隔至少5秒,冷却配置须为有界正值");
|
||||
}
|
||||
if (budgetAuthorityBaseUri != null && (budgetAuthorityBaseUri.getHost() == null
|
||||
|| !("http".equals(budgetAuthorityBaseUri.getScheme()) || "https".equals(budgetAuthorityBaseUri.getScheme()))
|
||||
|| budgetAuthorityBaseUri.getUserInfo() != null || budgetAuthorityBaseUri.getQuery() != null
|
||||
|| budgetAuthorityBaseUri.getFragment() != null || !budgetAuthorityBaseUri.getPath().isEmpty())) {
|
||||
throw new IllegalArgumentException("领星预算授权地址必须为受控内部服务源地址");
|
||||
}
|
||||
}
|
||||
@Override public String toString() {
|
||||
return "LingxingEgressProperties[collectionMode=" + collectionMode + ", environmentLabel=" + environmentLabel + "]";
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -13,18 +13,15 @@ public record LingxingWmsProperties(
|
||||
String appKey,
|
||||
String appSecret,
|
||||
@DefaultValue("5s") Duration connectTimeout,
|
||||
@DefaultValue("15s") Duration requestTimeout,
|
||||
@DefaultValue("1s") Duration minRequestInterval
|
||||
@DefaultValue("15s") Duration requestTimeout
|
||||
) {
|
||||
public LingxingWmsProperties {
|
||||
baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri;
|
||||
connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout;
|
||||
requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout;
|
||||
minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval;
|
||||
if (connectTimeout.isZero() || connectTimeout.isNegative()
|
||||
|| requestTimeout.isZero() || requestTimeout.isNegative()
|
||||
|| minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) {
|
||||
throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间");
|
||||
|| requestTimeout.isZero() || requestTimeout.isNegative()) {
|
||||
throw new IllegalArgumentException("领星 WMS 超时必须为正数");
|
||||
}
|
||||
String host = baseUri.getHost();
|
||||
boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host)
|
||||
|
||||
+67
-4
@@ -5,6 +5,11 @@ import com.baiyue.common.core.api.ResultCode;
|
||||
import com.baiyue.common.web.CorrelationContext;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
|
||||
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
|
||||
import com.baiyue.integration.security.InternalServiceTokenGuard;
|
||||
@@ -25,19 +30,76 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
public class LingxingInternalController {
|
||||
private final InternalServiceTokenGuard tokenGuard;
|
||||
private final LingxingWmsClient client;
|
||||
private final ObjectMapper mapper;
|
||||
|
||||
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) {
|
||||
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client, ObjectMapper mapper) {
|
||||
this.tokenGuard = tokenGuard;
|
||||
this.client = client;
|
||||
this.mapper = mapper;
|
||||
}
|
||||
|
||||
@Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要")
|
||||
@PostMapping("/outbound-orders/query")
|
||||
public ApiResponse<PageResult> query(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody QueryRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryPage(request));
|
||||
return ApiResponse.success(client.queryPage(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取正常仓库目录单页")
|
||||
@PostMapping("/warehouses/query")
|
||||
public ApiResponse<QueryPage<Warehouse>> warehouses(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody WarehouseQueryRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryWarehouses(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "按单号定位单页,不附加时间或状态筛选")
|
||||
@PostMapping("/outbound-orders/locate")
|
||||
public ApiResponse<PageResult> locate(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody LocateRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.locate(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "按仓库、货主、发货单号读取完整 SKU 详情")
|
||||
@PostMapping("/outbound-orders/detail")
|
||||
public ApiResponse<OrderDetail> detail(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody DetailRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryDetail(request, context(context)));
|
||||
}
|
||||
|
||||
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
|
||||
@PostMapping("/product-inventory/query")
|
||||
public ApiResponse<QueryPage<ProductStock>> inventory(
|
||||
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
|
||||
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
|
||||
@Valid @RequestBody InventoryQueryRequest request) {
|
||||
tokenGuard.verify(token);
|
||||
return ApiResponse.success(client.queryInventory(request, context(context)));
|
||||
}
|
||||
|
||||
private EgressContext context(String header) {
|
||||
if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126))
|
||||
throw invalidContext();
|
||||
try {
|
||||
EgressContext context = mapper.readerFor(EgressContext.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
|
||||
DeserializationFeature.FAIL_ON_TRAILING_TOKENS).with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION).readValue(header);
|
||||
if (context == null || !context.wellFormed()) throw invalidContext();
|
||||
return context;
|
||||
} catch (Exception ignored) { throw invalidContext(); }
|
||||
}
|
||||
private static LingxingWmsException invalidContext() {
|
||||
return new LingxingWmsException("LINGXING_WMS_EGRESS_CONTEXT_INVALID", "领星安全出口控制上下文缺失或无效", false, null, null);
|
||||
}
|
||||
|
||||
@ExceptionHandler(LingxingWmsException.class)
|
||||
@@ -46,10 +108,11 @@ public class LingxingInternalController {
|
||||
ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR;
|
||||
return ApiResponse.of(code, exception.getMessage(),
|
||||
new ProviderFailure(exception.code(), exception.retryable(),
|
||||
exception.upstreamStatus(), exception.sourceCode()),
|
||||
exception.upstreamStatus(), exception.sourceCode(), exception.egress() == null
|
||||
? new AttemptMetadata(null, 0, false, null) : exception.egress(), exception.denial()),
|
||||
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
|
||||
}
|
||||
|
||||
public record ProviderFailure(String errorCode, boolean retryable,
|
||||
Integer upstreamStatus, Integer sourceCode) { }
|
||||
Integer upstreamStatus, Integer sourceCode, AttemptMetadata egress, AuthorizationDenial denial) { }
|
||||
}
|
||||
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.baiyue.integration.dto.lingxing;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** Control identities are supplied by the collector and revalidated by its durable authority. */
|
||||
public final class LingxingEgressDtos {
|
||||
private LingxingEgressDtos() { }
|
||||
public enum CollectionMode { OFF, VALIDATION, BUSINESS }
|
||||
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY }
|
||||
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
|
||||
String sessionId, String attemptId, String exclusiveGrantId) {
|
||||
public boolean wellFormed() {
|
||||
return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS)
|
||||
&& identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128)
|
||||
&& identifier(attemptId, 128) && identifier(exclusiveGrantId, 128);
|
||||
}
|
||||
private static boolean identifier(String value, int maximum) {
|
||||
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
|
||||
}
|
||||
}
|
||||
public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { }
|
||||
public record AttemptPermit(EgressContext context, Operation operation, String requestHash,
|
||||
int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed,
|
||||
boolean budgetConsumed) { }
|
||||
public record CompletionRequest(EgressContext context, Operation operation, String requestHash,
|
||||
int actualHttpAttempts, String errorCode, Integer upstreamStatus,
|
||||
Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { }
|
||||
public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused,
|
||||
Instant cooldownUntil) { }
|
||||
public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { }
|
||||
}
|
||||
+32
-2
@@ -3,10 +3,14 @@ package com.baiyue.integration.dto.lingxing;
|
||||
import jakarta.validation.constraints.Max;
|
||||
import jakarta.validation.constraints.Min;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import java.math.BigDecimal;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
|
||||
|
||||
public final class LingxingWmsDtos {
|
||||
private LingxingWmsDtos() { }
|
||||
@@ -14,12 +18,38 @@ public final class LingxingWmsDtos {
|
||||
public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime,
|
||||
@Min(1) int current, @Min(1) @Max(100) int size) { }
|
||||
|
||||
// Preserve source time strings until the account's source timezone has been verified.
|
||||
public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { }
|
||||
public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo,
|
||||
@Min(1) int current, @Min(1) @Max(1000) int size) { }
|
||||
public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode,
|
||||
@NotBlank @Size(max=64) String customerCode,
|
||||
@NotBlank @Size(max=128) String deliveryNo) { }
|
||||
public record InventoryQueryRequest(@NotBlank @Size(max=64) String warehouseCode,
|
||||
@Min(1) int current, @Min(1) @Max(1000) int size) { }
|
||||
public record Warehouse(String whCode, String whNameCn, Integer warehouseType,
|
||||
Integer status, String timeZone) { }
|
||||
public record SkuLine(String productSku, String productName, BigDecimal actualDeliveryQty,
|
||||
BigDecimal deliveryQty) { }
|
||||
public record OrderDetail(String deliveryNo, String sourceNo, String customerCode, String whCode,
|
||||
Integer status, String orderCreateTime, String outboundTime,
|
||||
List<SkuLine> skuVOList, int sourceCode, Instant observedAt, AttemptMetadata egress) {
|
||||
public OrderDetail { skuVOList = List.copyOf(skuVOList); }
|
||||
}
|
||||
public record ProductStock(String customerCode, String whCode, String productSku, String productName,
|
||||
Integer productQuality, BigDecimal stkQty, BigDecimal freezeQty,
|
||||
BigDecimal useStkQty, BigDecimal transitQty, String innerBatchNo,
|
||||
String outBatchNo, String uniqueKey4tenant) { }
|
||||
public record QueryPage<T>(List<T> records, Long total, int current, int size, int pages,
|
||||
int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
|
||||
public QueryPage { records = List.copyOf(records); }
|
||||
}
|
||||
|
||||
// Preserve source strings; the collector interprets confirmed Asia/Shanghai source time.
|
||||
public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode,
|
||||
Integer status, String createTime, String outboundTime) { }
|
||||
|
||||
public record PageResult(List<OrderSummary> records, Long total, int current, int size,
|
||||
int sourceCode, Instant observedAt) {
|
||||
int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
|
||||
public PageResult { records = List.copyOf(records); }
|
||||
}
|
||||
}
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
|
||||
/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */
|
||||
public interface LingxingBudgetAuthority {
|
||||
AttemptPermit authorize(AuthorizationRequest request);
|
||||
void complete(CompletionRequest request);
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
|
||||
|
||||
/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */
|
||||
public final class LingxingBudgetDeniedException extends RuntimeException {
|
||||
private final AuthorizationDenial denial;
|
||||
public LingxingBudgetDeniedException(AuthorizationDenial denial) {
|
||||
super("领星持久预算授权拒绝"); this.denial = denial;
|
||||
}
|
||||
public AuthorizationDenial denial() { return denial; }
|
||||
}
|
||||
+131
@@ -0,0 +1,131 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.time.Clock;
|
||||
import java.time.Instant;
|
||||
import java.util.concurrent.Semaphore;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/** Local serialization supplements, never replaces, the externally attested window and durable budget. */
|
||||
@Component
|
||||
public final class LingxingEgressGate {
|
||||
private final LingxingEgressProperties properties;
|
||||
private final LingxingBudgetAuthority authority;
|
||||
private final Clock clock;
|
||||
private final Waiter waiter;
|
||||
private final Semaphore inFlight = new Semaphore(1, true);
|
||||
private volatile boolean paused;
|
||||
private volatile boolean recoverableRatePause;
|
||||
private Instant lastStart;
|
||||
private Instant cooldownUntil;
|
||||
@Autowired public LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority) {
|
||||
this(properties, authority, Clock.systemUTC(), Thread::sleep);
|
||||
}
|
||||
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
|
||||
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
|
||||
}
|
||||
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
|
||||
try { return guarded(context, operation, hash, call); }
|
||||
catch (LingxingWmsException error) {
|
||||
if (error.egress() == null) error.withEgress(new AttemptMetadata(
|
||||
context != null && identifier(context.attemptId(), 128) ? context.attemptId() : null, 0, paused, cooldownUntil));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
private <T> T guarded(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
|
||||
check(context);
|
||||
if (operation == null || hash == null || !hash.matches("[a-f0-9]{64}")) throw failure("EGRESS_CONTEXT_INVALID");
|
||||
boolean acquired = false;
|
||||
try {
|
||||
while (!(acquired = inFlight.tryAcquire(100, TimeUnit.MILLISECONDS))) check(context);
|
||||
check(context);
|
||||
if (lastStart != null) {
|
||||
Instant next = lastStart.plus(properties.minStartInterval());
|
||||
long wait = java.time.Duration.between(clock.instant(), next).toMillis();
|
||||
if (wait > 0) waiter.sleep(wait + 1);
|
||||
}
|
||||
check(context);
|
||||
AuthorizationRequest request = new AuthorizationRequest(context, operation, hash);
|
||||
AttemptPermit permit;
|
||||
try { permit = authority.authorize(request); }
|
||||
catch (LingxingBudgetDeniedException knownDecision) {
|
||||
throw failure("BUDGET_AUTHORIZATION_DENIED").withDenial(knownDecision.denial());
|
||||
}
|
||||
catch (RuntimeException error) { recoverableRatePause = false; paused = true; throw failure("BUDGET_AUTHORITY_UNAVAILABLE"); }
|
||||
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|
||||
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|
||||
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|
||||
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|
||||
|| permit.expiresAt().isAfter(properties.externalExclusiveUntil())) {
|
||||
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
|
||||
}
|
||||
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state.
|
||||
// Time alone never clears the pause. VALIDATION and uncertain callbacks cannot reach this branch.
|
||||
if (paused && recoverableRatePause && properties.collectionMode() == CollectionMode.BUSINESS) {
|
||||
paused = false; recoverableRatePause = false; cooldownUntil = null;
|
||||
}
|
||||
AttemptHandle handle = new AttemptHandle(context, permit.expiresAt());
|
||||
T result = null;
|
||||
LingxingWmsException failure = null;
|
||||
try { result = call.run(handle); }
|
||||
catch (LingxingWmsException error) { failure = error; }
|
||||
catch (RuntimeException error) { failure = failure("LOCAL_PROCESSING_FAILED"); }
|
||||
CompletionRequest completion = new CompletionRequest(context, operation, hash, handle.actualHttpAttempts,
|
||||
failure == null ? null : failure.code(), failure == null ? Integer.valueOf(200) : failure.upstreamStatus(),
|
||||
failure == null ? Integer.valueOf(200) : failure.sourceCode(), paused, cooldownUntil);
|
||||
try { authority.complete(completion); }
|
||||
catch (RuntimeException error) {
|
||||
recoverableRatePause = false; paused = true;
|
||||
// Unconfirmed completion must remain distinguishable even when the provider already failed.
|
||||
failure = failure("BUDGET_COMPLETION_UNCONFIRMED");
|
||||
}
|
||||
if (failure != null) throw failure.withEgress(handle.metadata());
|
||||
return result;
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt(); throw failure("INTERRUPTED");
|
||||
} finally { if (acquired) inFlight.release(); }
|
||||
}
|
||||
private void check(EgressContext context) {
|
||||
if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF");
|
||||
if (properties.collectionMode() == CollectionMode.BUSINESS && !"production".equals(properties.environmentLabel()))
|
||||
throw failure("BUSINESS_ENVIRONMENT_REJECTED");
|
||||
if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS
|
||||
|| cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED");
|
||||
if (context == null || context.executionPurpose() != properties.collectionMode()
|
||||
|| !context.wellFormed()
|
||||
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
|
||||
if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|
||||
|| properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())
|
||||
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED");
|
||||
}
|
||||
private static boolean identifier(String value, int maximum) {
|
||||
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
|
||||
}
|
||||
private static LingxingWmsException failure(String code) {
|
||||
return new LingxingWmsException("LINGXING_WMS_" + code, "领星安全出口拒绝请求(" + code + ")", false, null, null);
|
||||
}
|
||||
public final class AttemptHandle {
|
||||
private final EgressContext context;
|
||||
private final Instant expiresAt;
|
||||
private int actualHttpAttempts;
|
||||
private AttemptHandle(EgressContext context, Instant expiresAt) { this.context = context; this.expiresAt = expiresAt; }
|
||||
/** Immediately before send, after signing. One permit can never send twice. */
|
||||
public void startHttp() {
|
||||
check(context);
|
||||
if (actualHttpAttempts != 0 || !clock.instant().isBefore(expiresAt)) throw failure("ATTEMPT_NOT_SENDABLE");
|
||||
actualHttpAttempts = 1; lastStart = clock.instant();
|
||||
}
|
||||
public void rateLimited(Instant retryAfter) {
|
||||
Instant fallback = clock.instant().plus(properties.cooldownFallback());
|
||||
cooldownUntil = retryAfter != null && retryAfter.isAfter(fallback) ? retryAfter : fallback;
|
||||
recoverableRatePause = properties.collectionMode() == CollectionMode.BUSINESS;
|
||||
paused = true;
|
||||
}
|
||||
public AttemptMetadata metadata() { return new AttemptMetadata(context.attemptId(), actualHttpAttempts, paused, cooldownUntil); }
|
||||
}
|
||||
@FunctionalInterface interface Waiter { void sleep(long millis) throws InterruptedException; }
|
||||
@FunctionalInterface public interface AuthorizedCall<T> { T run(AttemptHandle handle); }
|
||||
}
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
|
||||
import com.baiyue.integration.config.LingxingEgressProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import java.net.http.*;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
|
||||
/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */
|
||||
@Component
|
||||
public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority {
|
||||
private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/";
|
||||
private final LingxingEgressProperties properties;
|
||||
private final ObjectMapper mapper;
|
||||
private final String internalToken;
|
||||
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper,
|
||||
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
|
||||
this.properties = properties; this.mapper = mapper; this.internalToken = internalToken;
|
||||
}
|
||||
@Override public AttemptPermit authorize(AuthorizationRequest request) {
|
||||
JsonNode data = post("authorize", request);
|
||||
if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber()
|
||||
|| data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean()
|
||||
|| !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean()
|
||||
|| !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject()
|
||||
|| !data.path("expiresAt").isTextual()) throw unavailable();
|
||||
try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); }
|
||||
catch (Exception ignored) { throw unavailable(); }
|
||||
}
|
||||
@Override public void complete(CompletionRequest request) {
|
||||
JsonNode data = post("complete", request);
|
||||
if (!data.isBoolean() || !data.booleanValue()) throw unavailable();
|
||||
}
|
||||
private JsonNode post(String suffix, Object payload) {
|
||||
if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank()
|
||||
|| internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable();
|
||||
try {
|
||||
HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix))
|
||||
.timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken)
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build();
|
||||
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
|
||||
if (response.statusCode() != 200) throw unavailable();
|
||||
JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body());
|
||||
if (root == null || !root.isObject() || !root.path("code").isTextual()
|
||||
|| !root.hasNonNull("data")) throw unavailable();
|
||||
if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) {
|
||||
JsonNode data = root.get("data");
|
||||
if (!data.isObject() || !data.path("errorCode").isTextual()
|
||||
|| !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")
|
||||
|| !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable();
|
||||
AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class)
|
||||
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper));
|
||||
throw new LingxingBudgetDeniedException(denial);
|
||||
}
|
||||
if (!"200".equals(root.path("code").textValue())) throw unavailable();
|
||||
return root.get("data");
|
||||
} catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision;
|
||||
} catch (InterruptedException ignored) {
|
||||
Thread.currentThread().interrupt(); throw unavailable();
|
||||
} catch (Exception ignored) { throw unavailable(); }
|
||||
}
|
||||
private static LingxingWmsException unavailable() {
|
||||
return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null);
|
||||
}
|
||||
}
|
||||
+222
-67
@@ -4,10 +4,21 @@ import com.baiyue.integration.config.LingxingWmsProperties;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
|
||||
import java.util.function.BiFunction;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HexFormat;
|
||||
import java.time.Instant;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.math.BigDecimal;
|
||||
import java.util.HashSet;
|
||||
import java.util.function.Function;
|
||||
import com.fasterxml.jackson.core.JsonProcessingException;
|
||||
import com.fasterxml.jackson.databind.DeserializationFeature;
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.SerializationFeature;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
@@ -25,7 +36,6 @@ import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Semaphore;
|
||||
|
||||
@Component
|
||||
public final class LingxingWmsClient {
|
||||
@@ -37,49 +47,174 @@ public final class LingxingWmsClient {
|
||||
private final LingxingWmsSigner signer;
|
||||
private final HttpClient httpClient;
|
||||
private final Clock clock;
|
||||
private final Semaphore concurrency = new Semaphore(1);
|
||||
private long lastRequestNanos;
|
||||
private boolean hasRequested;
|
||||
private final LingxingEgressGate egressGate;
|
||||
|
||||
@Autowired
|
||||
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) {
|
||||
this(properties, mapper, Clock.systemUTC());
|
||||
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) {
|
||||
this(properties, mapper, Clock.systemUTC(), egressGate);
|
||||
}
|
||||
|
||||
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) {
|
||||
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock, LingxingEgressGate egressGate) {
|
||||
this.properties = properties;
|
||||
this.mapper = mapper;
|
||||
this.mapper = mapper.copy().disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
|
||||
this.clock = clock;
|
||||
signer = new LingxingWmsSigner(mapper);
|
||||
this.egressGate = egressGate;
|
||||
signer = new LingxingWmsSigner(this.mapper);
|
||||
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout())
|
||||
.followRedirects(HttpClient.Redirect.NEVER).build();
|
||||
}
|
||||
|
||||
public PageResult queryPage(QueryRequest request) {
|
||||
public PageResult queryPage(QueryRequest request, EgressContext context) {
|
||||
validate(request);
|
||||
Map<String, Object> data = pagination(request.current(), request.size());
|
||||
data.put("timeType", "outboundTime");
|
||||
data.put("startTime", SOURCE_TIME.format(request.startTime()));
|
||||
data.put("endTime", SOURCE_TIME.format(request.endTime()));
|
||||
data.put("status", 100);
|
||||
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_QUERY,
|
||||
(json, metadata) -> orders(json, request.current(), request.size(), null, metadata));
|
||||
}
|
||||
|
||||
public PageResult locate(LocateRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
requireInput(request.deliveryNo(), 128);
|
||||
validatePagination(request.current(), request.size(), 1000);
|
||||
Map<String, Object> data = pagination(request.current(), request.size());
|
||||
data.put("deliveryNo", request.deliveryNo());
|
||||
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_LOCATE,
|
||||
(json, metadata) -> orders(json, request.current(), request.size(), request.deliveryNo(), metadata));
|
||||
}
|
||||
|
||||
public QueryPage<Warehouse> queryWarehouses(WarehouseQueryRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
validatePagination(request.current(), request.size(), 1000);
|
||||
return execute("/openapi/v2/warehouse/page", pagination(request.current(), request.size()), context, Operation.WAREHOUSES,
|
||||
(data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, row ->
|
||||
new Warehouse(requiredText(row, "whCode", 64), optionalText(row, "whNameCn"),
|
||||
requiredInt(row.get("warehouseType"), "WAREHOUSE_TYPE"), requiredInt(row.get("status"), "STATUS"),
|
||||
optionalText(row, "timeZone"))));
|
||||
}
|
||||
|
||||
public OrderDetail queryDetail(DetailRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
requireInput(request.warehouseCode(), 64);
|
||||
requireInput(request.customerCode(), 64);
|
||||
requireInput(request.deliveryNo(), 128);
|
||||
Map<String, Object> body = new LinkedHashMap<>();
|
||||
body.put("whCode", request.warehouseCode());
|
||||
body.put("customerCode", request.customerCode());
|
||||
body.put("deliveryNo", request.deliveryNo());
|
||||
return execute("/openapi/v2/order/delivery/detail", body, context, Operation.OUTBOUND_DETAIL,
|
||||
(data, metadata) -> detail(data, request, metadata));
|
||||
}
|
||||
|
||||
private OrderDetail detail(JsonNode data, DetailRequest request, AttemptMetadata metadata) {
|
||||
String deliveryNo = requiredText(data, "deliveryNo", 128);
|
||||
String customer = requiredText(data, "customerCode", 64);
|
||||
String warehouse = requiredText(data, "whCode", 64);
|
||||
if (!deliveryNo.equals(request.deliveryNo()) || !customer.equals(request.customerCode())
|
||||
|| !warehouse.equals(request.warehouseCode())) throw invalidResponse("DETAIL_IDENTITY_MISMATCH", 200);
|
||||
JsonNode rows = data.get("skuVOList");
|
||||
if (rows == null || !rows.isArray() || rows.isEmpty()) throw invalidResponse("SKU_LIST_INVALID", 200);
|
||||
List<SkuLine> lines = new ArrayList<>();
|
||||
for (JsonNode row : rows) {
|
||||
if (!row.isObject()) throw invalidResponse("SKU_ROW_INVALID", 200);
|
||||
lines.add(new SkuLine(requiredText(row, "productSku", 128), optionalText(row, "productName"),
|
||||
quantity(row.get("actualDeliveryQty")), quantity(row.get("deliveryQty"))));
|
||||
}
|
||||
return new OrderDetail(deliveryNo, optionalText(data, "sourceNo"), customer, warehouse,
|
||||
requiredInt(data.get("status"), "STATUS"), optionalText(data, "orderCreateTime"),
|
||||
optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata);
|
||||
}
|
||||
|
||||
public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) {
|
||||
if (request == null) throw badRequest();
|
||||
requireInput(request.warehouseCode(), 64);
|
||||
validatePagination(request.current(), request.size(), 1000);
|
||||
Map<String, Object> body = pagination(request.current(), request.size());
|
||||
body.put("whCode", request.warehouseCode());
|
||||
body.put("isHideInventory", 0);
|
||||
return execute("/openapi/v2/stock/stockSku/page", body, context, Operation.PRODUCT_INVENTORY,
|
||||
(data, metadata) -> inventory(data, request, metadata));
|
||||
}
|
||||
|
||||
private QueryPage<ProductStock> inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) {
|
||||
Set<List<Object>> keys = new HashSet<>();
|
||||
return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> {
|
||||
String customer = requiredText(row, "customerCode", 64);
|
||||
String warehouse = requiredText(row, "whCode", 64);
|
||||
String sku = requiredText(row, "productSku", 128);
|
||||
int quality = requiredInt(row.get("productQuality"), "PRODUCT_QUALITY");
|
||||
if (!warehouse.equals(request.warehouseCode())) throw invalidResponse("INVENTORY_WAREHOUSE_MISMATCH", 200);
|
||||
if (!keys.add(List.of(customer, warehouse, sku, quality))) throw invalidResponse("DUPLICATE_INVENTORY_KEY", 200);
|
||||
return new ProductStock(customer, warehouse, sku, optionalText(row, "productName"), quality,
|
||||
quantity(row.get("stkQty")), quantity(row.get("freezeQty")), quantity(row.get("useStkQty")),
|
||||
quantity(row.get("transitQty")), optionalText(row, "innerBatchNo"), optionalText(row, "outBatchNo"),
|
||||
optionalText(row, "uniqueKey4tenant"));
|
||||
});
|
||||
}
|
||||
|
||||
private <T> T execute(String path, Map<String, Object> data, EgressContext context, Operation operation,
|
||||
BiFunction<JsonNode, AttemptMetadata, T> projection) {
|
||||
if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false);
|
||||
if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false);
|
||||
validate(request);
|
||||
if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true);
|
||||
if (context != null && context.executionPurpose() == CollectionMode.VALIDATION
|
||||
&& data.containsKey("size") && (int)data.get("size") > 10) throw badRequest();
|
||||
String hash;
|
||||
try {
|
||||
reserveRequest();
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("current", request.current());
|
||||
data.put("size", request.size());
|
||||
data.put("timeType", "outboundTime");
|
||||
data.put("startTime", SOURCE_TIME.format(request.startTime()));
|
||||
data.put("endTime", SOURCE_TIME.format(request.endTime()));
|
||||
data.put("status", 100);
|
||||
var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant());
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH))
|
||||
hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
|
||||
(path + "\n" + mapper.writer().without(SerializationFeature.INDENT_OUTPUT,
|
||||
SerializationFeature.WRAP_ROOT_VALUE).writeValueAsString(data)).getBytes(StandardCharsets.UTF_8)));
|
||||
} catch (Exception ignored) { throw failure("REQUEST_HASH_FAILED", "领星请求授权摘要生成失败", false); }
|
||||
return egressGate.execute(context, operation, hash, handle -> {
|
||||
var signed = signer.sign(properties.appKey(), properties.appSecret(), path, data, clock.instant());
|
||||
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(path))
|
||||
.timeout(properties.requestTimeout()).header("Accept", "application/json")
|
||||
.header("Content-Type", "application/json; charset=UTF-8")
|
||||
.POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build();
|
||||
handle.startHttp();
|
||||
HttpResponse<String> response = send(httpRequest);
|
||||
if (response.statusCode() == 429) handle.rateLimited(retryAfter(response));
|
||||
validateStatus(response.statusCode());
|
||||
return page(response.body(), request);
|
||||
} finally {
|
||||
concurrency.release();
|
||||
}
|
||||
JsonNode json;
|
||||
try { json = envelope(response.body()); }
|
||||
catch (LingxingWmsException error) {
|
||||
if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) {
|
||||
handle.rateLimited(retryAfter(response));
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return projection.apply(json, handle.metadata());
|
||||
});
|
||||
}
|
||||
|
||||
private Instant retryAfter(HttpResponse<?> response) {
|
||||
String value = response.headers().firstValue("Retry-After").orElse(null);
|
||||
if (value == null || value.length() > 128) return null;
|
||||
try {
|
||||
if (value.matches("[0-9]{1,9}")) return clock.instant().plusSeconds(Long.parseLong(value));
|
||||
Instant time = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
|
||||
return time.isAfter(clock.instant()) ? time : null;
|
||||
} catch (RuntimeException ignored) { return null; }
|
||||
}
|
||||
|
||||
private static Map<String, Object> pagination(int current, int size) {
|
||||
Map<String, Object> data = new LinkedHashMap<>();
|
||||
data.put("current", current);
|
||||
data.put("size", size);
|
||||
return data;
|
||||
}
|
||||
|
||||
private static void validatePagination(int current, int size, int maximum) {
|
||||
if (current < 1 || size < 1 || size > maximum) throw badRequest();
|
||||
}
|
||||
|
||||
private static void requireInput(String value, int maximum) {
|
||||
if (value == null || value.isBlank() || value.length() > maximum) throw badRequest();
|
||||
}
|
||||
|
||||
private static LingxingWmsException badRequest() {
|
||||
return failure("INVALID_REQUEST", "领星 WMS 查询参数不符合内部契约", false);
|
||||
}
|
||||
|
||||
private void validate(QueryRequest request) {
|
||||
@@ -92,14 +227,6 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private void reserveRequest() {
|
||||
long now = System.nanoTime();
|
||||
if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) {
|
||||
throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true);
|
||||
}
|
||||
hasRequested = true;
|
||||
lastRequestNanos = now;
|
||||
}
|
||||
|
||||
private HttpResponse<String> send(HttpRequest request) {
|
||||
try {
|
||||
@@ -115,7 +242,7 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
|
||||
private void validateStatus(int status) {
|
||||
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null);
|
||||
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流,账号已暂停", false, status, null);
|
||||
if (status == 401 || status == 403) {
|
||||
throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null);
|
||||
}
|
||||
@@ -124,10 +251,11 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
}
|
||||
|
||||
private PageResult page(String body, QueryRequest request) {
|
||||
private JsonNode envelope(String body) {
|
||||
JsonNode root;
|
||||
try {
|
||||
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body);
|
||||
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS,
|
||||
DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body);
|
||||
} catch (JsonProcessingException exception) {
|
||||
throw invalidResponse("JSON", null);
|
||||
}
|
||||
@@ -153,23 +281,65 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
JsonNode data = root.get("data");
|
||||
if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200);
|
||||
return data;
|
||||
}
|
||||
|
||||
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) {
|
||||
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", true, metadata, record -> {
|
||||
String deliveryNo = requiredText(record, "deliveryNo", 128);
|
||||
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
|
||||
int status = requiredInt(record.get("status"), "STATUS");
|
||||
if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200);
|
||||
return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64),
|
||||
requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"));
|
||||
});
|
||||
return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(),
|
||||
page.sourceSize(), 200, page.observedAt(), metadata);
|
||||
}
|
||||
|
||||
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
|
||||
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) {
|
||||
JsonNode records = data.get("records");
|
||||
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
|
||||
if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
|
||||
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
|
||||
Long total = optionalTotal(data.get("total"));
|
||||
if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200);
|
||||
verifyPage(data.get("current"), request.current(), "CURRENT");
|
||||
verifyPage(data.get("size"), request.size(), "SIZE");
|
||||
List<OrderSummary> summaries = new ArrayList<>();
|
||||
if (total == null) throw invalidResponse("TOTAL_MISSING", 200);
|
||||
int sourceCurrent = requiredInt(data.get(currentField), "CURRENT");
|
||||
int sourceSize = requiredInt(data.get(sizeField), "SIZE");
|
||||
int pages = requiredInt(data.get("pages"), "PAGES");
|
||||
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
|
||||
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1
|
||||
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
|
||||
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
|
||||
long expectedPages = total / size + (total % size == 0 ? 0 : 1);
|
||||
if (pages < 0 || pages != expectedPages || (total == 0 && current != 1)
|
||||
|| (total > 0 && current > pages)) throw invalidResponse("PAGES_MISMATCH", 200);
|
||||
long expectedRows = total == 0 ? 0 : Math.min(size, total - (long)(current - 1) * size);
|
||||
if (records.size() != expectedRows) throw invalidResponse("PAGE_ROW_COUNT_MISMATCH", 200);
|
||||
List<T> summaries = new ArrayList<>();
|
||||
for (JsonNode record : records) {
|
||||
if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200);
|
||||
String deliveryNo = optionalText(record, "deliveryNo");
|
||||
if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200);
|
||||
summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"),
|
||||
optionalText(record, "whCode"), optionalStatus(record.get("status")),
|
||||
optionalText(record, "createTime"), optionalText(record, "outboundTime")));
|
||||
if (!record.isObject()) throw invalidResponse("ROW_TYPE_" + nodeType(record), 200);
|
||||
summaries.add(projection.apply(record));
|
||||
}
|
||||
return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant());
|
||||
return new QueryPage<>(summaries, total, current, size, pages, sourceCurrent, sourceSize, 200, clock.instant(), metadata);
|
||||
}
|
||||
|
||||
private static String requiredText(JsonNode row, String field, int maximum) {
|
||||
String text = optionalText(row, field);
|
||||
if (text == null || text.isBlank() || text.length() > maximum) throw invalidResponse("IDENTITY_INVALID", 200);
|
||||
return text;
|
||||
}
|
||||
|
||||
private static int requiredInt(JsonNode value, String stage) {
|
||||
if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
|
||||
return value.intValue();
|
||||
}
|
||||
|
||||
private static BigDecimal quantity(JsonNode value) {
|
||||
if (missingNumber(value)) return null;
|
||||
if (value.isNumber()) return value.decimalValue();
|
||||
// Nonempty malformed quantities are a contract failure, never invented zero.
|
||||
throw invalidResponse("QUANTITY_TYPE_" + nodeType(value), 200);
|
||||
}
|
||||
|
||||
private static String optionalText(JsonNode record, String field) {
|
||||
@@ -190,14 +360,6 @@ public final class LingxingWmsClient {
|
||||
return value.textValue();
|
||||
}
|
||||
|
||||
private static Integer optionalStatus(JsonNode value) {
|
||||
if (missingNumber(value)) return null;
|
||||
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
|
||||
throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200);
|
||||
}
|
||||
return value.intValue();
|
||||
}
|
||||
|
||||
private static Long optionalTotal(JsonNode value) {
|
||||
if (missingNumber(value)) return null;
|
||||
if (!value.isIntegralNumber() || !value.canConvertToLong()) {
|
||||
@@ -207,14 +369,6 @@ public final class LingxingWmsClient {
|
||||
return value.longValue();
|
||||
}
|
||||
|
||||
private static void verifyPage(JsonNode value, int expected, String stage) {
|
||||
if (missingNumber(value)) return;
|
||||
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
|
||||
throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
|
||||
}
|
||||
if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200);
|
||||
}
|
||||
|
||||
private static Integer diagnosticCode(JsonNode value) {
|
||||
if (value == null) return null;
|
||||
if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue();
|
||||
@@ -237,6 +391,7 @@ public final class LingxingWmsClient {
|
||||
}
|
||||
|
||||
private static LingxingWmsException sourceRejected(int code) {
|
||||
if (code == 31001 || code == 429) return upstreamFailure("SOURCE_RATE_LIMITED", "领星 WMS 来源限流,账号已暂停", false, 200, code);
|
||||
if (code == 11008) {
|
||||
return upstreamFailure("AUTHORIZATION_REJECTED",
|
||||
"领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code);
|
||||
@@ -244,11 +399,11 @@ public final class LingxingWmsClient {
|
||||
if (code == 11006) {
|
||||
return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code);
|
||||
}
|
||||
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code);
|
||||
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", false, 200, code);
|
||||
}
|
||||
|
||||
private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) {
|
||||
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")",
|
||||
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合接口契约(" + stage + ")",
|
||||
false, 200, sourceCode);
|
||||
}
|
||||
|
||||
|
||||
+8
@@ -1,4 +1,6 @@
|
||||
package com.baiyue.integration.provider.lingxing;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
|
||||
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
|
||||
|
||||
/** Only fixed messages and numeric upstream metadata may leave the adapter. */
|
||||
public final class LingxingWmsException extends RuntimeException {
|
||||
@@ -6,6 +8,8 @@ public final class LingxingWmsException extends RuntimeException {
|
||||
private final boolean retryable;
|
||||
private final Integer upstreamStatus;
|
||||
private final Integer sourceCode;
|
||||
private AttemptMetadata egress;
|
||||
private AuthorizationDenial denial;
|
||||
|
||||
public LingxingWmsException(String code, String message, boolean retryable,
|
||||
Integer upstreamStatus, Integer sourceCode) {
|
||||
@@ -21,4 +25,8 @@ public final class LingxingWmsException extends RuntimeException {
|
||||
public boolean retryable() { return retryable; }
|
||||
public Integer upstreamStatus() { return upstreamStatus; }
|
||||
public Integer sourceCode() { return sourceCode; }
|
||||
public AttemptMetadata egress() { return egress; }
|
||||
public LingxingWmsException withEgress(AttemptMetadata metadata) { this.egress = metadata; return this; }
|
||||
public AuthorizationDenial denial() { return denial; }
|
||||
public LingxingWmsException withDenial(AuthorizationDenial metadata) { this.denial = metadata; return this; }
|
||||
}
|
||||
|
||||
@@ -37,11 +37,20 @@ baiyue:
|
||||
lingxing-wms:
|
||||
enabled: ${LINGXING_WMS_ENABLED:false}
|
||||
base-uri: https://api.xlwms.com
|
||||
app-key: ${LINGXING_WMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_WMS_APP_SECRET:}
|
||||
app-key: ${LINGXING_OMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_OMS_APP_SECRET:}
|
||||
connect-timeout: 5s
|
||||
request-timeout: 15s
|
||||
min-request-interval: 1s
|
||||
egress:
|
||||
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
|
||||
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
|
||||
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
|
||||
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
|
||||
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
|
||||
min-start-interval: 5s
|
||||
cooldown-fallback: 5m
|
||||
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
|
||||
# Missing proof/authority fails closed even when the old enabled switch is true.
|
||||
baidu-ocr:
|
||||
enabled: false
|
||||
amazon-sp-api:
|
||||
|
||||
@@ -38,11 +38,20 @@ baiyue:
|
||||
lingxing-wms:
|
||||
enabled: ${LINGXING_WMS_ENABLED:false}
|
||||
base-uri: https://api.xlwms.com
|
||||
app-key: ${LINGXING_WMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_WMS_APP_SECRET:}
|
||||
app-key: ${LINGXING_OMS_APP_KEY:}
|
||||
app-secret: ${LINGXING_OMS_APP_SECRET:}
|
||||
connect-timeout: 5s
|
||||
request-timeout: 15s
|
||||
min-request-interval: 1s
|
||||
egress:
|
||||
collection-mode: ${LINGXING_COLLECTION_MODE:OFF}
|
||||
environment-label: ${LINGXING_ENVIRONMENT_LABEL:test}
|
||||
source-account: ${LINGXING_SOURCE_ACCOUNT:lingxing-primary}
|
||||
external-exclusive-grant-id: ${LINGXING_EXTERNAL_EXCLUSIVE_GRANT_ID:}
|
||||
budget-authority-base-uri: ${LINGXING_BUDGET_AUTHORITY_BASE_URI:http://lastleg-service:9303}
|
||||
min-start-interval: 5s
|
||||
cooldown-fallback: 5m
|
||||
# Explicitly inject external-exclusive-until (ISO Instant) and budget-authority-base-uri.
|
||||
# Missing proof/authority fails closed even when the old enabled switch is true.
|
||||
baidu-ocr:
|
||||
enabled: ${BAIDU_OCR_ENABLED:false}
|
||||
api-key: ${BAIDU_OCR_API_KEY:}
|
||||
|
||||
@@ -2,14 +2,77 @@ openapi: 3.0.3
|
||||
info:
|
||||
title: Lingxing WMS Internal Read API
|
||||
version: 1.0.0
|
||||
description: Read-only provider adapter. No business scheduling, persistence, inventory mutation or public Gateway route.
|
||||
description: >-
|
||||
Single-page or single-order adapter. No scheduling, business persistence or public Gateway route.
|
||||
Source times remain uninterpreted strings; warehouse timeZone does not certify source timezone.
|
||||
Server-side collection mode defaults to OFF. VALIDATION requires an externally confirmed exclusive
|
||||
account window; BUSINESS requires environmentLabel exactly production. All five endpoints apply the same gate.
|
||||
Missing exclusive proof, persistent account budget authorization or control context fails closed before egress.
|
||||
At most one account request is in flight and HTTP request starts are separated by at least 5 seconds.
|
||||
A JVM lock is only local protection and is not proof of cross-environment account exclusivity.
|
||||
Each invocation performs at most one supplier HTTP attempt. No mode uses implicit HTTP retries;
|
||||
429 is never retried, immediately pauses the account and returns persistent cooldown metadata.
|
||||
VALIDATION page size must not exceed 10. Lastleg owns root/session budgets and cooldown persistence.
|
||||
External server configuration supplies externalExclusiveGrantId and externalExclusiveUntil;
|
||||
the grant must match context.exclusiveGrantId and remain valid. A BUSINESS rate-limit pause is
|
||||
recoverable only within the original valid exclusive window, after both the persistently returned
|
||||
cooldown and the local minimum fallback cooldown have elapsed, and after the authority issues a new
|
||||
strictly valid single-attempt permit confirming account budget, cooldown, stop limits and root state.
|
||||
Time alone never permits egress. VALIDATION 429 does not automatically recover. Unavailable, lost
|
||||
or malformed authorization and uncertain completion hold an unrecoverable local pause until
|
||||
operations verify persistent account state. Completion failure never automatically recovers.
|
||||
A well-formed explicit authorization denial is deterministic and does not cause a permanent pause.
|
||||
sourceAccount defaults to lingxing-primary and budgetAuthorityBaseUri defaults internally to
|
||||
http://lastleg-service:9303; neither default enables egress.
|
||||
x-lastleg-egress-coordination:
|
||||
description: >-
|
||||
Integration calls these Lastleg endpoints with X-Internal-Service-Token. These are dependencies,
|
||||
not Integration Controller paths. Lastleg prepares attemptId server-side and binds context and operation
|
||||
before authorization. authorize only consumes an already prepared attempt, first binds the opaque
|
||||
requestHash and atomically consumes persistent account/root/session budget. Duplicate authorization
|
||||
is refused; arbitrary client-created attempts are refused. complete verifies the same bindings.
|
||||
A permit is bound to its full context, operation and requestHash, requires exclusiveConfirmed=true
|
||||
and budgetConsumed=true, expires after now but no later than now plus 30 seconds or externalExclusiveUntil,
|
||||
and cannot be reused. Explicit denial uses HTTP 200 with ApiResponse code string B0001 and
|
||||
AuthorizationDenial data. A valid denial is not an uncertain authorization and does not impose
|
||||
a permanent local pause. Unavailable, lost or malformed authorization or an unconfirmed callback blocks further egress;
|
||||
pending or ambiguous attempts remain conservatively consumed, including after restart.
|
||||
authorize:
|
||||
method: POST
|
||||
path: /internal/lastleg/self-operated-analytics/egress/authorize
|
||||
requestSchema: '#/components/schemas/AuthorizationRequest'
|
||||
responseSchema: '#/components/schemas/AttemptPermitResponse'
|
||||
complete:
|
||||
method: POST
|
||||
path: /internal/lastleg/self-operated-analytics/egress/complete
|
||||
requestSchema: '#/components/schemas/CompletionRequest'
|
||||
responseSchema: '#/components/schemas/CompletionResponse'
|
||||
security:
|
||||
- InternalServiceToken: []
|
||||
paths:
|
||||
/internal/integrations/lingxing/wms/warehouses/query:
|
||||
post:
|
||||
summary: Query one page of normal warehouses
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Calls /openapi/v2/warehouse/page without filters. Maps page/pageSize into current/size; preserves original metadata.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/WarehouseQueryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with WarehousePage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/WarehouseResponse'}
|
||||
/internal/integrations/lingxing/wms/outbound-orders/query:
|
||||
post:
|
||||
summary: Query one page of completed WMS outbound summaries by outbound time
|
||||
description: Calls OMP /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. Source time strings are preserved. This is a single page, not a complete sales report. It uses the self-developed-system API authorization in the WMS management console.
|
||||
summary: Query one page of completed outbound orders by source outbound time
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Calls /openapi/v2/order/delivery/page with timeType=outboundTime and status=100. No external warehouse filter. At current=1,size=100 only, records=[],total=0,pages=0,sourceCurrent=1,sourceSize=10 is accepted and internal size remains 100. All other pagination metadata is strictly required. No completeness claim across pages.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
@@ -17,65 +80,363 @@ paths:
|
||||
schema: {$ref: '#/components/schemas/QueryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope; code 200 for success, A0401 for rejected internal identity, A0422 for bean validation, B0001 or S0503 for provider/configuration failures. Failures never return a successful empty page.
|
||||
description: Standard envelope with OrderPage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/Response'}
|
||||
schema: {$ref: '#/components/schemas/OrderResponse'}
|
||||
/internal/integrations/lingxing/wms/outbound-orders/locate:
|
||||
post:
|
||||
summary: Locate one page by delivery number without time or status filters
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Sends only current,size,deliveryNo to delivery/page. Every returned deliveryNo must match exactly. Caller reads all required pages and verifies original customer identity and uniqueness before accepting the current warehouse. Missing results do not prove cancellation.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/LocateRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with OrderPage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/OrderResponse'}
|
||||
/internal/integrations/lingxing/wms/outbound-orders/detail:
|
||||
post:
|
||||
summary: Read one order using warehouse, customer and delivery identifiers
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Maps warehouseCode to whCode for delivery/detail. All three returned identifiers must match. Returns only skuVOList; packageList is never added to quantities. Duplicate SKU lines remain separate for caller normalization. Missing quantities remain null; no quantity unit is inferred.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/DetailRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with OrderDetail or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/DetailResponse'}
|
||||
/internal/integrations/lingxing/wms/product-inventory/query:
|
||||
post:
|
||||
summary: Query one page of all qualities and customers in a required warehouse
|
||||
parameters:
|
||||
- {$ref: '#/components/parameters/LingxingEgressContext'}
|
||||
description: Calls stock/stockSku/page with whCode and isHideInventory=0. No SKU, quality, customer or quantity filters. Warehouse mismatch and duplicate business keys within a page fail closed. Caller validates cross-page keys and counts. Unknown quantities stay null, negative quantities remain evidence, stockCompare is not accumulated.
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InventoryQueryRequest'}
|
||||
responses:
|
||||
'200':
|
||||
description: Standard envelope with InventoryPage or sanitized ProviderFailure.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/InventoryResponse'}
|
||||
components:
|
||||
parameters:
|
||||
LingxingEgressContext:
|
||||
name: X-Lingxing-Egress-Context
|
||||
in: header
|
||||
required: true
|
||||
description: >-
|
||||
JSON control context; no credentials. Maximum 2048 characters, all printable ASCII (32 through 126).
|
||||
Unknown fields, duplicate fields and trailing JSON are rejected. Identifiers are strings supplied
|
||||
by the server-side coordinator and do not confer authorization by themselves.
|
||||
content:
|
||||
application/json:
|
||||
schema: {$ref: '#/components/schemas/EgressContext'}
|
||||
securitySchemes:
|
||||
InternalServiceToken:
|
||||
type: apiKey
|
||||
in: header
|
||||
name: X-Internal-Service-Token
|
||||
schemas:
|
||||
EgressContext:
|
||||
type: object
|
||||
additionalProperties: false
|
||||
required: [sourceAccount, executionPurpose, rootTaskId, sessionId, attemptId, exclusiveGrantId]
|
||||
properties:
|
||||
sourceAccount: {type: string, minLength: 1, maxLength: 64, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Server-configured account alias, never App Key or Secret.}
|
||||
executionPurpose: {type: string, enum: [VALIDATION, BUSINESS]}
|
||||
rootTaskId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable root control identifier; child tasks share root budget.}
|
||||
sessionId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Stable session control identifier; reopening or restart does not reset it.}
|
||||
attemptId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Prepared unique single-attempt control identifier; duplicate authorization is refused.}
|
||||
exclusiveGrantId: {type: string, minLength: 1, maxLength: 128, pattern: '^[A-Za-z0-9][A-Za-z0-9._:-]*$', description: Must equal the valid externalExclusiveGrantId configured server-side.}
|
||||
EgressRequestHash:
|
||||
type: string
|
||||
pattern: '^[0-9a-f]{64}$'
|
||||
description: >-
|
||||
Lowercase hexadecimal SHA256 of UTF-8 bytes of OMP path, one LF character and compact
|
||||
insertion-ordered external business JSON. Lastleg treats this digest as opaque and binds
|
||||
it on the first authorization of an already prepared attempt.
|
||||
example: fa60a92c78b4803878930e5914e6db03591acc31b7c09abad53c91912fa3d627
|
||||
x-preimage-example: |-
|
||||
/openapi/v2/stock/stockSku/page
|
||||
{"current":1,"size":2,"whCode":"WH-DEMO","isHideInventory":0}
|
||||
EgressOperation:
|
||||
type: string
|
||||
enum: [WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY]
|
||||
EgressMetadata:
|
||||
type: object
|
||||
required: [attemptId, actualHttpAttempts, accountPaused]
|
||||
properties:
|
||||
attemptId: {type: string, nullable: true, description: Null only if no valid control context was accepted.}
|
||||
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1, description: Supplier HTTP attempts actually started, including failed calls; authorization callbacks do not count.}
|
||||
accountPaused: {type: boolean}
|
||||
cooldownUntil: {type: string, format: date-time, nullable: true, description: Account cooldown deadline to persist; valid supplier retry time must not be shortened.}
|
||||
AuthorizationRequest:
|
||||
type: object
|
||||
required: [context, operation, requestHash]
|
||||
properties:
|
||||
context: {$ref: '#/components/schemas/EgressContext'}
|
||||
operation: {$ref: '#/components/schemas/EgressOperation'}
|
||||
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
|
||||
AuthorizationDenial:
|
||||
type: object
|
||||
required: [errorCode, sampleDone]
|
||||
properties:
|
||||
errorCode: {type: string, pattern: '^[A-Z][A-Z0-9_]{0,63}$'}
|
||||
sampleDone: {type: boolean, description: Strict JSON boolean; validation budget normal completion is distinct from a failure.}
|
||||
waitUntil: {type: string, format: date-time, nullable: true, description: Nullable ISO-8601 Instant from the persistent authority.}
|
||||
AuthorizationDenialResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
code: {type: string, enum: [B0001]}
|
||||
data: {$ref: '#/components/schemas/AuthorizationDenial'}
|
||||
AttemptPermit:
|
||||
type: object
|
||||
required: [context, operation, requestHash, allowedAttempts, expiresAt, exclusiveConfirmed, budgetConsumed]
|
||||
properties:
|
||||
context: {$ref: '#/components/schemas/EgressContext'}
|
||||
operation: {$ref: '#/components/schemas/EgressOperation'}
|
||||
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
|
||||
allowedAttempts: {type: integer, enum: [1]}
|
||||
expiresAt: {type: string, format: date-time, description: Must be after now and no later than now plus 30 seconds and configured externalExclusiveUntil.}
|
||||
exclusiveConfirmed: {type: boolean, enum: [true]}
|
||||
budgetConsumed: {type: boolean, enum: [true], description: Lastleg has atomically consumed persistent account and root/session budget before returning this permit.}
|
||||
CompletionRequest:
|
||||
type: object
|
||||
required: [context, operation, requestHash, actualHttpAttempts, accountPaused]
|
||||
properties:
|
||||
context: {$ref: '#/components/schemas/EgressContext'}
|
||||
operation: {$ref: '#/components/schemas/EgressOperation'}
|
||||
requestHash: {$ref: '#/components/schemas/EgressRequestHash'}
|
||||
actualHttpAttempts: {type: integer, minimum: 0, maximum: 1}
|
||||
errorCode: {type: string, nullable: true, description: Sanitized local error code; null on success.}
|
||||
upstreamStatus: {type: integer, nullable: true}
|
||||
sourceCode: {type: integer, nullable: true}
|
||||
accountPaused: {type: boolean, description: True on 429 or explicit provider rate limiting; all account request families must pause.}
|
||||
cooldownUntil: {type: string, format: date-time, nullable: true}
|
||||
AttemptPermitResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
code: {type: string, enum: ['200', B0001]}
|
||||
data:
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/AttemptPermit'}
|
||||
- {$ref: '#/components/schemas/AuthorizationDenial'}
|
||||
description: Code 200 requires AttemptPermit; code B0001 requires AuthorizationDenial. Lost or malformed responses are uncertain and fail closed with a local pause.
|
||||
CompletionResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data: {type: boolean, enum: [true], description: Successful acknowledgement confirms durable completion and cooldown persistence; any other result fails closed.}
|
||||
WarehouseQueryRequest:
|
||||
type: object
|
||||
required: [current, size]
|
||||
properties:
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 1000}
|
||||
QueryRequest:
|
||||
type: object
|
||||
required: [startTime, endTime, current, size]
|
||||
properties:
|
||||
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime; must precede endTime and must not be earlier than endTime minus one calendar month. This is a local guard.}
|
||||
endTime: {type: string, example: '2026-10-12T08:00:00', description: Source local datetime.}
|
||||
startTime: {type: string, example: '2026-09-12T08:00:00', description: Source local datetime. Must precede endTime and be no earlier than endTime minus one calendar month. No source timezone inference.}
|
||||
endTime: {type: string, example: '2026-10-12T08:00:00'}
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 100, description: Local request cap; not a claim about the provider quota. Use 1 for connectivity verification.}
|
||||
size: {type: integer, minimum: 1, maximum: 100, description: Local request cap, not supplier quota.}
|
||||
LocateRequest:
|
||||
type: object
|
||||
required: [deliveryNo, current, size]
|
||||
properties:
|
||||
deliveryNo: {type: string, minLength: 1, maxLength: 128}
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 1000}
|
||||
DetailRequest:
|
||||
type: object
|
||||
required: [warehouseCode, customerCode, deliveryNo]
|
||||
properties:
|
||||
warehouseCode: {type: string, minLength: 1, maxLength: 64}
|
||||
customerCode: {type: string, minLength: 1, maxLength: 64}
|
||||
deliveryNo: {type: string, minLength: 1, maxLength: 128}
|
||||
InventoryQueryRequest:
|
||||
type: object
|
||||
required: [warehouseCode, current, size]
|
||||
properties:
|
||||
warehouseCode: {type: string, minLength: 1, maxLength: 64}
|
||||
current: {type: integer, minimum: 1}
|
||||
size: {type: integer, minimum: 1, maximum: 1000}
|
||||
Pagination:
|
||||
type: object
|
||||
required: [total, current, size, pages, sourceCurrent, sourceSize, sourceCode, observedAt, egress]
|
||||
properties:
|
||||
egress: {$ref: '#/components/schemas/EgressMetadata'}
|
||||
total: {type: string, pattern: '^[0-9]+$', description: Required provider total. Long follows platform string serialization.}
|
||||
current: {type: integer}
|
||||
size: {type: integer}
|
||||
pages: {type: integer, minimum: 0}
|
||||
sourceCurrent: {type: integer}
|
||||
sourceSize: {type: integer}
|
||||
sourceCode: {type: integer, enum: [200]}
|
||||
observedAt: {type: string, format: date-time}
|
||||
OrderSummary:
|
||||
type: object
|
||||
required: [deliveryNo]
|
||||
required: [deliveryNo, customerCode, whCode, status]
|
||||
properties:
|
||||
deliveryNo: {type: string}
|
||||
sourceNo: {type: string, nullable: true}
|
||||
customerCode: {type: string, nullable: true}
|
||||
whCode: {type: string, nullable: true}
|
||||
status: {type: integer, nullable: true}
|
||||
customerCode: {type: string}
|
||||
whCode: {type: string}
|
||||
status: {type: integer}
|
||||
createTime: {type: string, nullable: true}
|
||||
outboundTime: {type: string, nullable: true}
|
||||
PageResult:
|
||||
Warehouse:
|
||||
type: object
|
||||
required: [records, current, size, sourceCode, observedAt]
|
||||
required: [whCode, warehouseType, status]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}}
|
||||
total: {type: string, pattern: '^[0-9]+$', nullable: true, description: Provider total only when supplied. Java Long follows the platform JSON string convention.}
|
||||
current: {type: integer}
|
||||
size: {type: integer}
|
||||
sourceCode: {type: integer}
|
||||
whCode: {type: string}
|
||||
whNameCn: {type: string, nullable: true}
|
||||
warehouseType: {type: integer}
|
||||
status: {type: integer}
|
||||
timeZone: {type: string, nullable: true, description: Warehouse metadata only.}
|
||||
SkuLine:
|
||||
type: object
|
||||
required: [productSku]
|
||||
properties:
|
||||
productSku: {type: string}
|
||||
productName: {type: string, nullable: true}
|
||||
actualDeliveryQty: {type: number, nullable: true, description: BigDecimal actual quantity, unknown is null.}
|
||||
deliveryQty: {type: number, nullable: true, description: Forecast only; never a replacement for actual quantity.}
|
||||
OrderDetail:
|
||||
type: object
|
||||
required: [deliveryNo, customerCode, whCode, status, skuVOList, sourceCode, observedAt, egress]
|
||||
properties:
|
||||
egress: {$ref: '#/components/schemas/EgressMetadata'}
|
||||
deliveryNo: {type: string}
|
||||
sourceNo: {type: string, nullable: true}
|
||||
customerCode: {type: string}
|
||||
whCode: {type: string}
|
||||
status: {type: integer}
|
||||
orderCreateTime: {type: string, nullable: true}
|
||||
outboundTime: {type: string, nullable: true}
|
||||
skuVOList: {type: array, minItems: 1, items: {$ref: '#/components/schemas/SkuLine'}}
|
||||
sourceCode: {type: integer, enum: [200]}
|
||||
observedAt: {type: string, format: date-time}
|
||||
ProductStock:
|
||||
type: object
|
||||
required: [customerCode, whCode, productSku, productQuality]
|
||||
properties:
|
||||
customerCode: {type: string}
|
||||
whCode: {type: string}
|
||||
productSku: {type: string}
|
||||
productName: {type: string, nullable: true}
|
||||
productQuality: {type: integer}
|
||||
stkQty: {type: number, nullable: true}
|
||||
freezeQty: {type: number, nullable: true}
|
||||
useStkQty: {type: number, nullable: true}
|
||||
transitQty: {type: number, nullable: true}
|
||||
innerBatchNo: {type: string, nullable: true}
|
||||
outBatchNo: {type: string, nullable: true}
|
||||
uniqueKey4tenant: {type: string, nullable: true}
|
||||
OrderPage:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Pagination'}
|
||||
- type: object
|
||||
required: [records]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/OrderSummary'}}
|
||||
WarehousePage:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Pagination'}
|
||||
- type: object
|
||||
required: [records]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/Warehouse'}}
|
||||
InventoryPage:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Pagination'}
|
||||
- type: object
|
||||
required: [records]
|
||||
properties:
|
||||
records: {type: array, items: {$ref: '#/components/schemas/ProductStock'}}
|
||||
ProviderFailure:
|
||||
type: object
|
||||
required: [errorCode, retryable]
|
||||
required: [errorCode, retryable, egress]
|
||||
properties:
|
||||
egress: {$ref: '#/components/schemas/EgressMetadata'}
|
||||
denial:
|
||||
nullable: true
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/AuthorizationDenial'}
|
||||
description: Well-formed deterministic authority denial, alongside egress; null for other failures.
|
||||
errorCode: {type: string}
|
||||
retryable: {type: boolean}
|
||||
retryable: {type: boolean, description: Caller scheduling hint only; never authorizes implicit retries or reuse of an attempt. False for 429 and validation session failure.}
|
||||
upstreamStatus: {type: integer, nullable: true}
|
||||
sourceCode: {type: integer, nullable: true}
|
||||
Response:
|
||||
Envelope:
|
||||
type: object
|
||||
required: [code, message, data, timestamp]
|
||||
properties:
|
||||
code: {type: string}
|
||||
message: {type: string, description: Sanitized local message; never the raw provider response.}
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/PageResult'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
code: {type: string, description: '200 success; A0401 identity; A0422 validation; B0001 or S0503 provider/configuration failure.'}
|
||||
message: {type: string, description: Sanitized fixed local message, never raw supplier content.}
|
||||
requestId: {type: string, nullable: true}
|
||||
traceId: {type: string, nullable: true}
|
||||
timestamp: {type: string, format: date-time}
|
||||
OrderResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/OrderPage'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
WarehouseResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/WarehousePage'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
DetailResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/OrderDetail'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
InventoryResponse:
|
||||
allOf:
|
||||
- {$ref: '#/components/schemas/Envelope'}
|
||||
- type: object
|
||||
properties:
|
||||
data:
|
||||
nullable: true
|
||||
oneOf:
|
||||
- {$ref: '#/components/schemas/InventoryPage'}
|
||||
- {$ref: '#/components/schemas/ProviderFailure'}
|
||||
|
||||
Reference in New Issue
Block a user