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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user