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

This commit is contained in:
2026-10-12 20:27:35 +08:00
parent c06e788b0f
commit a1f1d93902
174 changed files with 13514 additions and 490 deletions
@@ -0,0 +1,35 @@
package com.baiyue.integration.config;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import java.net.URI;
import java.time.Duration;
import java.time.Instant;
/** An operator-attested exclusive window; it is not a distributed account lock. */
@ConfigurationProperties(prefix = "baiyue.integration.lingxing-wms.egress")
public record LingxingEgressProperties(@DefaultValue("OFF") CollectionMode collectionMode,
@DefaultValue("test") String environmentLabel, String sourceAccount, String externalExclusiveGrantId,
Instant externalExclusiveUntil, URI budgetAuthorityBaseUri, @DefaultValue("5s") Duration minStartInterval,
@DefaultValue("5m") Duration cooldownFallback) {
public LingxingEgressProperties {
collectionMode = collectionMode == null ? CollectionMode.OFF : collectionMode;
environmentLabel = environmentLabel == null ? "test" : environmentLabel;
minStartInterval = minStartInterval == null ? Duration.ofSeconds(5) : minStartInterval;
cooldownFallback = cooldownFallback == null ? Duration.ofMinutes(5) : cooldownFallback;
if (minStartInterval.compareTo(Duration.ofSeconds(5)) < 0 || minStartInterval.compareTo(Duration.ofMinutes(5)) > 0
|| cooldownFallback.compareTo(Duration.ofSeconds(5)) < 0 || cooldownFallback.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("领星出口间隔至少5秒,冷却配置须为有界正值");
}
if (budgetAuthorityBaseUri != null && (budgetAuthorityBaseUri.getHost() == null
|| !("http".equals(budgetAuthorityBaseUri.getScheme()) || "https".equals(budgetAuthorityBaseUri.getScheme()))
|| budgetAuthorityBaseUri.getUserInfo() != null || budgetAuthorityBaseUri.getQuery() != null
|| budgetAuthorityBaseUri.getFragment() != null || !budgetAuthorityBaseUri.getPath().isEmpty())) {
throw new IllegalArgumentException("领星预算授权地址必须为受控内部服务源地址");
}
}
@Override public String toString() {
return "LingxingEgressProperties[collectionMode=" + collectionMode + ", environmentLabel=" + environmentLabel + "]";
}
}
@@ -13,18 +13,15 @@ public record LingxingWmsProperties(
String appKey,
String appSecret,
@DefaultValue("5s") Duration connectTimeout,
@DefaultValue("15s") Duration requestTimeout,
@DefaultValue("1s") Duration minRequestInterval
@DefaultValue("15s") Duration requestTimeout
) {
public LingxingWmsProperties {
baseUri = baseUri == null ? URI.create("https://api.xlwms.com") : baseUri;
connectTimeout = connectTimeout == null ? Duration.ofSeconds(5) : connectTimeout;
requestTimeout = requestTimeout == null ? Duration.ofSeconds(15) : requestTimeout;
minRequestInterval = minRequestInterval == null ? Duration.ofSeconds(1) : minRequestInterval;
if (connectTimeout.isZero() || connectTimeout.isNegative()
|| requestTimeout.isZero() || requestTimeout.isNegative()
|| minRequestInterval.isNegative() || minRequestInterval.compareTo(Duration.ofDays(1)) > 0) {
throw new IllegalArgumentException("领星 WMS 超时必须为正数,最小请求间隔须在 0 至 1 天之间");
|| requestTimeout.isZero() || requestTimeout.isNegative()) {
throw new IllegalArgumentException("领星 WMS 超时必须为正数");
}
String host = baseUri.getHost();
boolean loopback = "localhost".equalsIgnoreCase(host) || "127.0.0.1".equals(host)
@@ -5,6 +5,11 @@ import com.baiyue.common.core.api.ResultCode;
import com.baiyue.common.web.CorrelationContext;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.core.JsonParser;
import com.baiyue.integration.provider.lingxing.LingxingWmsClient;
import com.baiyue.integration.provider.lingxing.LingxingWmsException;
import com.baiyue.integration.security.InternalServiceTokenGuard;
@@ -25,19 +30,76 @@ import org.springframework.web.bind.annotation.RestController;
public class LingxingInternalController {
private final InternalServiceTokenGuard tokenGuard;
private final LingxingWmsClient client;
private final ObjectMapper mapper;
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client) {
public LingxingInternalController(InternalServiceTokenGuard tokenGuard, LingxingWmsClient client, ObjectMapper mapper) {
this.tokenGuard = tokenGuard;
this.client = client;
this.mapper = mapper;
}
@Operation(summary = "按出库时间分页只读查询 WMS 已出库摘要")
@PostMapping("/outbound-orders/query")
public ApiResponse<PageResult> query(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody QueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryPage(request));
return ApiResponse.success(client.queryPage(request, context(context)));
}
@Operation(summary = "读取正常仓库目录单页")
@PostMapping("/warehouses/query")
public ApiResponse<QueryPage<Warehouse>> warehouses(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody WarehouseQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryWarehouses(request, context(context)));
}
@Operation(summary = "按单号定位单页,不附加时间或状态筛选")
@PostMapping("/outbound-orders/locate")
public ApiResponse<PageResult> locate(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody LocateRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.locate(request, context(context)));
}
@Operation(summary = "按仓库、货主、发货单号读取完整 SKU 详情")
@PostMapping("/outbound-orders/detail")
public ApiResponse<OrderDetail> detail(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody DetailRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryDetail(request, context(context)));
}
@Operation(summary = "读取单仓全部货主、全部品质且保留零库存的产品库存单页")
@PostMapping("/product-inventory/query")
public ApiResponse<QueryPage<ProductStock>> inventory(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token,
@RequestHeader(value = "X-Lingxing-Egress-Context", required = false) String context,
@Valid @RequestBody InventoryQueryRequest request) {
tokenGuard.verify(token);
return ApiResponse.success(client.queryInventory(request, context(context)));
}
private EgressContext context(String header) {
if (header == null || header.isBlank() || header.length() > 2048 || !header.chars().allMatch(c -> c >= 32 && c <= 126))
throw invalidContext();
try {
EgressContext context = mapper.readerFor(EgressContext.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES,
DeserializationFeature.FAIL_ON_TRAILING_TOKENS).with(JsonParser.Feature.STRICT_DUPLICATE_DETECTION).readValue(header);
if (context == null || !context.wellFormed()) throw invalidContext();
return context;
} catch (Exception ignored) { throw invalidContext(); }
}
private static LingxingWmsException invalidContext() {
return new LingxingWmsException("LINGXING_WMS_EGRESS_CONTEXT_INVALID", "领星安全出口控制上下文缺失或无效", false, null, null);
}
@ExceptionHandler(LingxingWmsException.class)
@@ -46,10 +108,11 @@ public class LingxingInternalController {
ResultCode code = exception.retryable() ? ResultCode.SERVICE_UNAVAILABLE : ResultCode.BUSINESS_ERROR;
return ApiResponse.of(code, exception.getMessage(),
new ProviderFailure(exception.code(), exception.retryable(),
exception.upstreamStatus(), exception.sourceCode()),
exception.upstreamStatus(), exception.sourceCode(), exception.egress() == null
? new AttemptMetadata(null, 0, false, null) : exception.egress(), exception.denial()),
CorrelationContext.requestId(request), CorrelationContext.traceId(request));
}
public record ProviderFailure(String errorCode, boolean retryable,
Integer upstreamStatus, Integer sourceCode) { }
Integer upstreamStatus, Integer sourceCode, AttemptMetadata egress, AuthorizationDenial denial) { }
}
@@ -0,0 +1,31 @@
package com.baiyue.integration.dto.lingxing;
import java.time.Instant;
/** Control identities are supplied by the collector and revalidated by its durable authority. */
public final class LingxingEgressDtos {
private LingxingEgressDtos() { }
public enum CollectionMode { OFF, VALIDATION, BUSINESS }
public enum Operation { WAREHOUSES, OUTBOUND_QUERY, OUTBOUND_LOCATE, OUTBOUND_DETAIL, PRODUCT_INVENTORY }
public record EgressContext(String sourceAccount, CollectionMode executionPurpose, String rootTaskId,
String sessionId, String attemptId, String exclusiveGrantId) {
public boolean wellFormed() {
return (executionPurpose == CollectionMode.VALIDATION || executionPurpose == CollectionMode.BUSINESS)
&& identifier(sourceAccount, 64) && identifier(rootTaskId, 128) && identifier(sessionId, 128)
&& identifier(attemptId, 128) && identifier(exclusiveGrantId, 128);
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
}
public record AuthorizationRequest(EgressContext context, Operation operation, String requestHash) { }
public record AttemptPermit(EgressContext context, Operation operation, String requestHash,
int allowedAttempts, Instant expiresAt, boolean exclusiveConfirmed,
boolean budgetConsumed) { }
public record CompletionRequest(EgressContext context, Operation operation, String requestHash,
int actualHttpAttempts, String errorCode, Integer upstreamStatus,
Integer sourceCode, boolean accountPaused, Instant cooldownUntil) { }
public record AttemptMetadata(String attemptId, int actualHttpAttempts, boolean accountPaused,
Instant cooldownUntil) { }
public record AuthorizationDenial(String errorCode, boolean sampleDone, Instant waitUntil) { }
}
@@ -3,10 +3,14 @@ package com.baiyue.integration.dto.lingxing;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import java.math.BigDecimal;
import java.time.Instant;
import java.time.LocalDateTime;
import java.util.List;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
public final class LingxingWmsDtos {
private LingxingWmsDtos() { }
@@ -14,12 +18,38 @@ public final class LingxingWmsDtos {
public record QueryRequest(@NotNull LocalDateTime startTime, @NotNull LocalDateTime endTime,
@Min(1) int current, @Min(1) @Max(100) int size) { }
// Preserve source time strings until the account's source timezone has been verified.
public record WarehouseQueryRequest(@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record LocateRequest(@NotBlank @Size(max=128) String deliveryNo,
@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record DetailRequest(@NotBlank @Size(max=64) String warehouseCode,
@NotBlank @Size(max=64) String customerCode,
@NotBlank @Size(max=128) String deliveryNo) { }
public record InventoryQueryRequest(@NotBlank @Size(max=64) String warehouseCode,
@Min(1) int current, @Min(1) @Max(1000) int size) { }
public record Warehouse(String whCode, String whNameCn, Integer warehouseType,
Integer status, String timeZone) { }
public record SkuLine(String productSku, String productName, BigDecimal actualDeliveryQty,
BigDecimal deliveryQty) { }
public record OrderDetail(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String orderCreateTime, String outboundTime,
List<SkuLine> skuVOList, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public OrderDetail { skuVOList = List.copyOf(skuVOList); }
}
public record ProductStock(String customerCode, String whCode, String productSku, String productName,
Integer productQuality, BigDecimal stkQty, BigDecimal freezeQty,
BigDecimal useStkQty, BigDecimal transitQty, String innerBatchNo,
String outBatchNo, String uniqueKey4tenant) { }
public record QueryPage<T>(List<T> records, Long total, int current, int size, int pages,
int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public QueryPage { records = List.copyOf(records); }
}
// Preserve source strings; the collector interprets confirmed Asia/Shanghai source time.
public record OrderSummary(String deliveryNo, String sourceNo, String customerCode, String whCode,
Integer status, String createTime, String outboundTime) { }
public record PageResult(List<OrderSummary> records, Long total, int current, int size,
int sourceCode, Instant observedAt) {
int pages, int sourceCurrent, int sourceSize, int sourceCode, Instant observedAt, AttemptMetadata egress) {
public PageResult { records = List.copyOf(records); }
}
}
@@ -0,0 +1,9 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
/** authorize must consume a persisted single-use attempt before returning; never refund on lost response. */
public interface LingxingBudgetAuthority {
AttemptPermit authorize(AuthorizationRequest request);
void complete(CompletionRequest request);
}
@@ -0,0 +1,11 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
/** A known, authenticated authority decision; distinct from a lost/ambiguous response. */
public final class LingxingBudgetDeniedException extends RuntimeException {
private final AuthorizationDenial denial;
public LingxingBudgetDeniedException(AuthorizationDenial denial) {
super("领星持久预算授权拒绝"); this.denial = denial;
}
public AuthorizationDenial denial() { return denial; }
}
@@ -0,0 +1,131 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.time.Clock;
import java.time.Instant;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
/** Local serialization supplements, never replaces, the externally attested window and durable budget. */
@Component
public final class LingxingEgressGate {
private final LingxingEgressProperties properties;
private final LingxingBudgetAuthority authority;
private final Clock clock;
private final Waiter waiter;
private final Semaphore inFlight = new Semaphore(1, true);
private volatile boolean paused;
private volatile boolean recoverableRatePause;
private Instant lastStart;
private Instant cooldownUntil;
@Autowired public LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority) {
this(properties, authority, Clock.systemUTC(), Thread::sleep);
}
LingxingEgressGate(LingxingEgressProperties properties, LingxingBudgetAuthority authority, Clock clock, Waiter waiter) {
this.properties = properties; this.authority = authority; this.clock = clock; this.waiter = waiter;
}
public <T> T execute(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
try { return guarded(context, operation, hash, call); }
catch (LingxingWmsException error) {
if (error.egress() == null) error.withEgress(new AttemptMetadata(
context != null && identifier(context.attemptId(), 128) ? context.attemptId() : null, 0, paused, cooldownUntil));
throw error;
}
}
private <T> T guarded(EgressContext context, Operation operation, String hash, AuthorizedCall<T> call) {
check(context);
if (operation == null || hash == null || !hash.matches("[a-f0-9]{64}")) throw failure("EGRESS_CONTEXT_INVALID");
boolean acquired = false;
try {
while (!(acquired = inFlight.tryAcquire(100, TimeUnit.MILLISECONDS))) check(context);
check(context);
if (lastStart != null) {
Instant next = lastStart.plus(properties.minStartInterval());
long wait = java.time.Duration.between(clock.instant(), next).toMillis();
if (wait > 0) waiter.sleep(wait + 1);
}
check(context);
AuthorizationRequest request = new AuthorizationRequest(context, operation, hash);
AttemptPermit permit;
try { permit = authority.authorize(request); }
catch (LingxingBudgetDeniedException knownDecision) {
throw failure("BUDGET_AUTHORIZATION_DENIED").withDenial(knownDecision.denial());
}
catch (RuntimeException error) { recoverableRatePause = false; paused = true; throw failure("BUDGET_AUTHORITY_UNAVAILABLE"); }
if (permit == null || !context.equals(permit.context()) || operation != permit.operation()
|| !hash.equals(permit.requestHash()) || permit.allowedAttempts() != 1 || !permit.exclusiveConfirmed()
|| !permit.budgetConsumed() || permit.expiresAt() == null || !clock.instant().isBefore(permit.expiresAt())
|| permit.expiresAt().isAfter(clock.instant().plusSeconds(30))
|| permit.expiresAt().isAfter(properties.externalExclusiveUntil())) {
recoverableRatePause = false; paused = true; throw failure("BUDGET_PERMIT_INVALID");
}
// A new persisted permit proves the coordinator accepted the account's cooldown/budget/root state.
// Time alone never clears the pause. VALIDATION and uncertain callbacks cannot reach this branch.
if (paused && recoverableRatePause && properties.collectionMode() == CollectionMode.BUSINESS) {
paused = false; recoverableRatePause = false; cooldownUntil = null;
}
AttemptHandle handle = new AttemptHandle(context, permit.expiresAt());
T result = null;
LingxingWmsException failure = null;
try { result = call.run(handle); }
catch (LingxingWmsException error) { failure = error; }
catch (RuntimeException error) { failure = failure("LOCAL_PROCESSING_FAILED"); }
CompletionRequest completion = new CompletionRequest(context, operation, hash, handle.actualHttpAttempts,
failure == null ? null : failure.code(), failure == null ? Integer.valueOf(200) : failure.upstreamStatus(),
failure == null ? Integer.valueOf(200) : failure.sourceCode(), paused, cooldownUntil);
try { authority.complete(completion); }
catch (RuntimeException error) {
recoverableRatePause = false; paused = true;
// Unconfirmed completion must remain distinguishable even when the provider already failed.
failure = failure("BUDGET_COMPLETION_UNCONFIRMED");
}
if (failure != null) throw failure.withEgress(handle.metadata());
return result;
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); throw failure("INTERRUPTED");
} finally { if (acquired) inFlight.release(); }
}
private void check(EgressContext context) {
if (properties.collectionMode() == CollectionMode.OFF) throw failure("EGRESS_OFF");
if (properties.collectionMode() == CollectionMode.BUSINESS && !"production".equals(properties.environmentLabel()))
throw failure("BUSINESS_ENVIRONMENT_REJECTED");
if (paused && (!recoverableRatePause || properties.collectionMode() != CollectionMode.BUSINESS
|| cooldownUntil == null || clock.instant().isBefore(cooldownUntil))) throw failure("ACCOUNT_PAUSED");
if (context == null || context.executionPurpose() != properties.collectionMode()
|| !context.wellFormed()
|| !context.sourceAccount().equals(properties.sourceAccount())) throw failure("EGRESS_CONTEXT_INVALID");
if (properties.externalExclusiveGrantId() == null || !properties.externalExclusiveGrantId().equals(context.exclusiveGrantId())
|| properties.externalExclusiveUntil() == null || !clock.instant().isBefore(properties.externalExclusiveUntil())
|| properties.budgetAuthorityBaseUri() == null) throw failure("EXCLUSIVE_WINDOW_UNCONFIRMED");
}
private static boolean identifier(String value, int maximum) {
return value != null && value.length() <= maximum && value.matches("[A-Za-z0-9][A-Za-z0-9._:-]*");
}
private static LingxingWmsException failure(String code) {
return new LingxingWmsException("LINGXING_WMS_" + code, "领星安全出口拒绝请求(" + code + ")", false, null, null);
}
public final class AttemptHandle {
private final EgressContext context;
private final Instant expiresAt;
private int actualHttpAttempts;
private AttemptHandle(EgressContext context, Instant expiresAt) { this.context = context; this.expiresAt = expiresAt; }
/** Immediately before send, after signing. One permit can never send twice. */
public void startHttp() {
check(context);
if (actualHttpAttempts != 0 || !clock.instant().isBefore(expiresAt)) throw failure("ATTEMPT_NOT_SENDABLE");
actualHttpAttempts = 1; lastStart = clock.instant();
}
public void rateLimited(Instant retryAfter) {
Instant fallback = clock.instant().plus(properties.cooldownFallback());
cooldownUntil = retryAfter != null && retryAfter.isAfter(fallback) ? retryAfter : fallback;
recoverableRatePause = properties.collectionMode() == CollectionMode.BUSINESS;
paused = true;
}
public AttemptMetadata metadata() { return new AttemptMetadata(context.attemptId(), actualHttpAttempts, paused, cooldownUntil); }
}
@FunctionalInterface interface Waiter { void sleep(long millis) throws InterruptedException; }
@FunctionalInterface public interface AuthorizedCall<T> { T run(AttemptHandle handle); }
}
@@ -0,0 +1,73 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
/** Fixed server-configured authority only. No retries, redirects or user-selected callback URLs. */
@Component
public final class LingxingHttpBudgetAuthority implements LingxingBudgetAuthority {
private static final String PREFIX = "/internal/lastleg/self-operated-analytics/egress/";
private final LingxingEgressProperties properties;
private final ObjectMapper mapper;
private final String internalToken;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER).build();
public LingxingHttpBudgetAuthority(LingxingEgressProperties properties, ObjectMapper mapper,
@Value("${baiyue.security.internal-service-token:}") String internalToken) {
this.properties = properties; this.mapper = mapper; this.internalToken = internalToken;
}
@Override public AttemptPermit authorize(AuthorizationRequest request) {
JsonNode data = post("authorize", request);
if (!data.isObject() || !data.path("allowedAttempts").isIntegralNumber()
|| data.path("allowedAttempts").intValue() != 1 || !data.path("exclusiveConfirmed").isBoolean()
|| !data.path("exclusiveConfirmed").booleanValue() || !data.path("budgetConsumed").isBoolean()
|| !data.path("budgetConsumed").booleanValue() || !data.path("context").isObject()
|| !data.path("expiresAt").isTextual()) throw unavailable();
try { return mapper.readerFor(AttemptPermit.class).with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper)); }
catch (Exception ignored) { throw unavailable(); }
}
@Override public void complete(CompletionRequest request) {
JsonNode data = post("complete", request);
if (!data.isBoolean() || !data.booleanValue()) throw unavailable();
}
private JsonNode post(String suffix, Object payload) {
if (properties.budgetAuthorityBaseUri() == null || internalToken == null || internalToken.isBlank()
|| internalToken.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable();
try {
HttpRequest request = HttpRequest.newBuilder(properties.budgetAuthorityBaseUri().resolve(PREFIX + suffix))
.timeout(Duration.ofSeconds(5)).header("X-Internal-Service-Token", internalToken)
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(mapper.writeValueAsString(payload), StandardCharsets.UTF_8)).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200) throw unavailable();
JsonNode root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(response.body());
if (root == null || !root.isObject() || !root.path("code").isTextual()
|| !root.hasNonNull("data")) throw unavailable();
if ("authorize".equals(suffix) && "B0001".equals(root.path("code").textValue())) {
JsonNode data = root.get("data");
if (!data.isObject() || !data.path("errorCode").isTextual()
|| !data.path("errorCode").textValue().matches("[A-Z][A-Z0-9_]{0,63}")
|| !data.path("sampleDone").isBoolean() || !data.has("waitUntil")) throw unavailable();
AuthorizationDenial denial = mapper.readerFor(AuthorizationDenial.class)
.with(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES).readValue(data.traverse(mapper));
throw new LingxingBudgetDeniedException(denial);
}
if (!"200".equals(root.path("code").textValue())) throw unavailable();
return root.get("data");
} catch (LingxingBudgetDeniedException knownDecision) { throw knownDecision;
} catch (InterruptedException ignored) {
Thread.currentThread().interrupt(); throw unavailable();
} catch (Exception ignored) { throw unavailable(); }
}
private static LingxingWmsException unavailable() {
return new LingxingWmsException("LINGXING_WMS_BUDGET_AUTHORITY_UNAVAILABLE", "领星持久预算授权不可用,出口已拒绝", false, null, null);
}
}
@@ -4,10 +4,21 @@ import com.baiyue.integration.config.LingxingWmsProperties;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.OrderSummary;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.PageResult;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.QueryRequest;
import com.baiyue.integration.dto.lingxing.LingxingWmsDtos.*;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import java.util.function.BiFunction;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.time.Instant;
import java.time.ZonedDateTime;
import java.math.BigDecimal;
import java.util.HashSet;
import java.util.function.Function;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@@ -25,7 +36,6 @@ import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.Semaphore;
@Component
public final class LingxingWmsClient {
@@ -37,49 +47,174 @@ public final class LingxingWmsClient {
private final LingxingWmsSigner signer;
private final HttpClient httpClient;
private final Clock clock;
private final Semaphore concurrency = new Semaphore(1);
private long lastRequestNanos;
private boolean hasRequested;
private final LingxingEgressGate egressGate;
@Autowired
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper) {
this(properties, mapper, Clock.systemUTC());
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) {
this(properties, mapper, Clock.systemUTC(), egressGate);
}
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock) {
LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, Clock clock, LingxingEgressGate egressGate) {
this.properties = properties;
this.mapper = mapper;
this.mapper = mapper.copy().disable(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS);
this.clock = clock;
signer = new LingxingWmsSigner(mapper);
this.egressGate = egressGate;
signer = new LingxingWmsSigner(this.mapper);
httpClient = HttpClient.newBuilder().connectTimeout(properties.connectTimeout())
.followRedirects(HttpClient.Redirect.NEVER).build();
}
public PageResult queryPage(QueryRequest request) {
public PageResult queryPage(QueryRequest request, EgressContext context) {
validate(request);
Map<String, Object> data = pagination(request.current(), request.size());
data.put("timeType", "outboundTime");
data.put("startTime", SOURCE_TIME.format(request.startTime()));
data.put("endTime", SOURCE_TIME.format(request.endTime()));
data.put("status", 100);
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_QUERY,
(json, metadata) -> orders(json, request.current(), request.size(), null, metadata));
}
public PageResult locate(LocateRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.deliveryNo(), 128);
validatePagination(request.current(), request.size(), 1000);
Map<String, Object> data = pagination(request.current(), request.size());
data.put("deliveryNo", request.deliveryNo());
return execute(PAGE_PATH, data, context, Operation.OUTBOUND_LOCATE,
(json, metadata) -> orders(json, request.current(), request.size(), request.deliveryNo(), metadata));
}
public QueryPage<Warehouse> queryWarehouses(WarehouseQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
validatePagination(request.current(), request.size(), 1000);
return execute("/openapi/v2/warehouse/page", pagination(request.current(), request.size()), context, Operation.WAREHOUSES,
(data, metadata) -> mappedPage(data, request.current(), request.size(), "page", "pageSize", false, metadata, row ->
new Warehouse(requiredText(row, "whCode", 64), optionalText(row, "whNameCn"),
requiredInt(row.get("warehouseType"), "WAREHOUSE_TYPE"), requiredInt(row.get("status"), "STATUS"),
optionalText(row, "timeZone"))));
}
public OrderDetail queryDetail(DetailRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64);
requireInput(request.customerCode(), 64);
requireInput(request.deliveryNo(), 128);
Map<String, Object> body = new LinkedHashMap<>();
body.put("whCode", request.warehouseCode());
body.put("customerCode", request.customerCode());
body.put("deliveryNo", request.deliveryNo());
return execute("/openapi/v2/order/delivery/detail", body, context, Operation.OUTBOUND_DETAIL,
(data, metadata) -> detail(data, request, metadata));
}
private OrderDetail detail(JsonNode data, DetailRequest request, AttemptMetadata metadata) {
String deliveryNo = requiredText(data, "deliveryNo", 128);
String customer = requiredText(data, "customerCode", 64);
String warehouse = requiredText(data, "whCode", 64);
if (!deliveryNo.equals(request.deliveryNo()) || !customer.equals(request.customerCode())
|| !warehouse.equals(request.warehouseCode())) throw invalidResponse("DETAIL_IDENTITY_MISMATCH", 200);
JsonNode rows = data.get("skuVOList");
if (rows == null || !rows.isArray() || rows.isEmpty()) throw invalidResponse("SKU_LIST_INVALID", 200);
List<SkuLine> lines = new ArrayList<>();
for (JsonNode row : rows) {
if (!row.isObject()) throw invalidResponse("SKU_ROW_INVALID", 200);
lines.add(new SkuLine(requiredText(row, "productSku", 128), optionalText(row, "productName"),
quantity(row.get("actualDeliveryQty")), quantity(row.get("deliveryQty"))));
}
return new OrderDetail(deliveryNo, optionalText(data, "sourceNo"), customer, warehouse,
requiredInt(data.get("status"), "STATUS"), optionalText(data, "orderCreateTime"),
optionalText(data, "outboundTime"), lines, 200, clock.instant(), metadata);
}
public QueryPage<ProductStock> queryInventory(InventoryQueryRequest request, EgressContext context) {
if (request == null) throw badRequest();
requireInput(request.warehouseCode(), 64);
validatePagination(request.current(), request.size(), 1000);
Map<String, Object> body = pagination(request.current(), request.size());
body.put("whCode", request.warehouseCode());
body.put("isHideInventory", 0);
return execute("/openapi/v2/stock/stockSku/page", body, context, Operation.PRODUCT_INVENTORY,
(data, metadata) -> inventory(data, request, metadata));
}
private QueryPage<ProductStock> inventory(JsonNode data, InventoryQueryRequest request, AttemptMetadata metadata) {
Set<List<Object>> keys = new HashSet<>();
return mappedPage(data, request.current(), request.size(), "current", "size", false, metadata, row -> {
String customer = requiredText(row, "customerCode", 64);
String warehouse = requiredText(row, "whCode", 64);
String sku = requiredText(row, "productSku", 128);
int quality = requiredInt(row.get("productQuality"), "PRODUCT_QUALITY");
if (!warehouse.equals(request.warehouseCode())) throw invalidResponse("INVENTORY_WAREHOUSE_MISMATCH", 200);
if (!keys.add(List.of(customer, warehouse, sku, quality))) throw invalidResponse("DUPLICATE_INVENTORY_KEY", 200);
return new ProductStock(customer, warehouse, sku, optionalText(row, "productName"), quality,
quantity(row.get("stkQty")), quantity(row.get("freezeQty")), quantity(row.get("useStkQty")),
quantity(row.get("transitQty")), optionalText(row, "innerBatchNo"), optionalText(row, "outBatchNo"),
optionalText(row, "uniqueKey4tenant"));
});
}
private <T> T execute(String path, Map<String, Object> data, EgressContext context, Operation operation,
BiFunction<JsonNode, AttemptMetadata, T> projection) {
if (!properties.enabled()) throw failure("DISABLED", "领星 WMS 接入未启用", false);
if (!properties.hasCredentials()) throw failure("MISSING_CREDENTIALS", "领星 WMS 授权配置不完整", false);
validate(request);
if (!concurrency.tryAcquire()) throw failure("LOCAL_RATE_LIMITED", "领星 WMS 查询正在执行,请稍后重试", true);
if (context != null && context.executionPurpose() == CollectionMode.VALIDATION
&& data.containsKey("size") && (int)data.get("size") > 10) throw badRequest();
String hash;
try {
reserveRequest();
Map<String, Object> data = new LinkedHashMap<>();
data.put("current", request.current());
data.put("size", request.size());
data.put("timeType", "outboundTime");
data.put("startTime", SOURCE_TIME.format(request.startTime()));
data.put("endTime", SOURCE_TIME.format(request.endTime()));
data.put("status", 100);
var signed = signer.sign(properties.appKey(), properties.appSecret(), PAGE_PATH, data, clock.instant());
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(PAGE_PATH))
hash = HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(
(path + "\n" + mapper.writer().without(SerializationFeature.INDENT_OUTPUT,
SerializationFeature.WRAP_ROOT_VALUE).writeValueAsString(data)).getBytes(StandardCharsets.UTF_8)));
} catch (Exception ignored) { throw failure("REQUEST_HASH_FAILED", "领星请求授权摘要生成失败", false); }
return egressGate.execute(context, operation, hash, handle -> {
var signed = signer.sign(properties.appKey(), properties.appSecret(), path, data, clock.instant());
HttpRequest httpRequest = HttpRequest.newBuilder(properties.baseUri().resolve(path))
.timeout(properties.requestTimeout()).header("Accept", "application/json")
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(signed.body(), StandardCharsets.UTF_8)).build();
handle.startHttp();
HttpResponse<String> response = send(httpRequest);
if (response.statusCode() == 429) handle.rateLimited(retryAfter(response));
validateStatus(response.statusCode());
return page(response.body(), request);
} finally {
concurrency.release();
}
JsonNode json;
try { json = envelope(response.body()); }
catch (LingxingWmsException error) {
if (Integer.valueOf(31001).equals(error.sourceCode()) || Integer.valueOf(429).equals(error.sourceCode())) {
handle.rateLimited(retryAfter(response));
}
throw error;
}
return projection.apply(json, handle.metadata());
});
}
private Instant retryAfter(HttpResponse<?> response) {
String value = response.headers().firstValue("Retry-After").orElse(null);
if (value == null || value.length() > 128) return null;
try {
if (value.matches("[0-9]{1,9}")) return clock.instant().plusSeconds(Long.parseLong(value));
Instant time = ZonedDateTime.parse(value, DateTimeFormatter.RFC_1123_DATE_TIME).toInstant();
return time.isAfter(clock.instant()) ? time : null;
} catch (RuntimeException ignored) { return null; }
}
private static Map<String, Object> pagination(int current, int size) {
Map<String, Object> data = new LinkedHashMap<>();
data.put("current", current);
data.put("size", size);
return data;
}
private static void validatePagination(int current, int size, int maximum) {
if (current < 1 || size < 1 || size > maximum) throw badRequest();
}
private static void requireInput(String value, int maximum) {
if (value == null || value.isBlank() || value.length() > maximum) throw badRequest();
}
private static LingxingWmsException badRequest() {
return failure("INVALID_REQUEST", "领星 WMS 查询参数不符合内部契约", false);
}
private void validate(QueryRequest request) {
@@ -92,14 +227,6 @@ public final class LingxingWmsClient {
}
}
private void reserveRequest() {
long now = System.nanoTime();
if (hasRequested && now - lastRequestNanos < properties.minRequestInterval().toNanos()) {
throw failure("LOCAL_RATE_LIMITED", "领星 WMS 请求过于频繁,请稍后重试", true);
}
hasRequested = true;
lastRequestNanos = now;
}
private HttpResponse<String> send(HttpRequest request) {
try {
@@ -115,7 +242,7 @@ public final class LingxingWmsClient {
}
private void validateStatus(int status) {
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流", true, status, null);
if (status == 429) throw upstreamFailure("HTTP_RATE_LIMITED", "领星 WMS 请求受到限流,账号已暂停", false, status, null);
if (status == 401 || status == 403) {
throw upstreamFailure("AUTHORIZATION_REJECTED", "领星 WMS 拒绝授权,请核对凭据与账号权限", false, status, null);
}
@@ -124,10 +251,11 @@ public final class LingxingWmsClient {
}
}
private PageResult page(String body, QueryRequest request) {
private JsonNode envelope(String body) {
JsonNode root;
try {
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS).readTree(body);
root = mapper.reader().with(DeserializationFeature.FAIL_ON_TRAILING_TOKENS,
DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS).readTree(body);
} catch (JsonProcessingException exception) {
throw invalidResponse("JSON", null);
}
@@ -153,23 +281,65 @@ public final class LingxingWmsClient {
}
JsonNode data = root.get("data");
if (data == null || !data.isObject()) throw invalidResponse("DATA_TYPE_" + nodeType(data), 200);
return data;
}
private PageResult orders(JsonNode data, int current, int size, String exactDeliveryNo, AttemptMetadata metadata) {
QueryPage<OrderSummary> page = mappedPage(data, current, size, "current", "size", true, metadata, record -> {
String deliveryNo = requiredText(record, "deliveryNo", 128);
if (exactDeliveryNo != null && !exactDeliveryNo.equals(deliveryNo)) throw invalidResponse("LOCATE_DELIVERY_NO_MISMATCH", 200);
int status = requiredInt(record.get("status"), "STATUS");
if (exactDeliveryNo == null && status != 100) throw invalidResponse("DISCOVERY_STATUS_MISMATCH", 200);
return new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), requiredText(record, "customerCode", 64),
requiredText(record, "whCode", 64), status, optionalText(record, "createTime"), optionalText(record, "outboundTime"));
});
return new PageResult(page.records(), page.total(), current, size, page.pages(), page.sourceCurrent(),
page.sourceSize(), 200, page.observedAt(), metadata);
}
private <T> QueryPage<T> mappedPage(JsonNode data, int current, int size, String currentField,
String sizeField, boolean outbound, AttemptMetadata metadata, Function<JsonNode, T> projection) {
JsonNode records = data.get("records");
if (records == null || !records.isArray()) throw invalidResponse("RECORDS_TYPE_" + nodeType(records), 200);
if (records.size() > request.size()) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
if (records.size() > size) throw invalidResponse("RECORDS_OVER_PAGE_SIZE", 200);
Long total = optionalTotal(data.get("total"));
if (total != null && total < records.size()) throw invalidResponse("TOTAL_BELOW_RECORD_COUNT", 200);
verifyPage(data.get("current"), request.current(), "CURRENT");
verifyPage(data.get("size"), request.size(), "SIZE");
List<OrderSummary> summaries = new ArrayList<>();
if (total == null) throw invalidResponse("TOTAL_MISSING", 200);
int sourceCurrent = requiredInt(data.get(currentField), "CURRENT");
int sourceSize = requiredInt(data.get(sizeField), "SIZE");
int pages = requiredInt(data.get("pages"), "PAGES");
if (sourceCurrent != current) throw invalidResponse("CURRENT_MISMATCH", 200);
boolean emptyException = outbound && current == 1 && size == 100 && sourceCurrent == 1
&& sourceSize == 10 && total == 0 && pages == 0 && records.isEmpty();
if (sourceSize != size && !emptyException) throw invalidResponse("SIZE_MISMATCH", 200);
long expectedPages = total / size + (total % size == 0 ? 0 : 1);
if (pages < 0 || pages != expectedPages || (total == 0 && current != 1)
|| (total > 0 && current > pages)) throw invalidResponse("PAGES_MISMATCH", 200);
long expectedRows = total == 0 ? 0 : Math.min(size, total - (long)(current - 1) * size);
if (records.size() != expectedRows) throw invalidResponse("PAGE_ROW_COUNT_MISMATCH", 200);
List<T> summaries = new ArrayList<>();
for (JsonNode record : records) {
if (!record.isObject()) throw invalidResponse("ORDER_TYPE_" + nodeType(record), 200);
String deliveryNo = optionalText(record, "deliveryNo");
if (deliveryNo == null || deliveryNo.isBlank()) throw invalidResponse("DELIVERY_NO_EMPTY", 200);
summaries.add(new OrderSummary(deliveryNo, optionalText(record, "sourceNo"), optionalText(record, "customerCode"),
optionalText(record, "whCode"), optionalStatus(record.get("status")),
optionalText(record, "createTime"), optionalText(record, "outboundTime")));
if (!record.isObject()) throw invalidResponse("ROW_TYPE_" + nodeType(record), 200);
summaries.add(projection.apply(record));
}
return new PageResult(summaries, total, request.current(), request.size(), 200, clock.instant());
return new QueryPage<>(summaries, total, current, size, pages, sourceCurrent, sourceSize, 200, clock.instant(), metadata);
}
private static String requiredText(JsonNode row, String field, int maximum) {
String text = optionalText(row, field);
if (text == null || text.isBlank() || text.length() > maximum) throw invalidResponse("IDENTITY_INVALID", 200);
return text;
}
private static int requiredInt(JsonNode value, String stage) {
if (value == null || !value.isIntegralNumber() || !value.canConvertToInt()) throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
return value.intValue();
}
private static BigDecimal quantity(JsonNode value) {
if (missingNumber(value)) return null;
if (value.isNumber()) return value.decimalValue();
// Nonempty malformed quantities are a contract failure, never invented zero.
throw invalidResponse("QUANTITY_TYPE_" + nodeType(value), 200);
}
private static String optionalText(JsonNode record, String field) {
@@ -190,14 +360,6 @@ public final class LingxingWmsClient {
return value.textValue();
}
private static Integer optionalStatus(JsonNode value) {
if (missingNumber(value)) return null;
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalidResponse("STATUS_TYPE_" + nodeType(value), 200);
}
return value.intValue();
}
private static Long optionalTotal(JsonNode value) {
if (missingNumber(value)) return null;
if (!value.isIntegralNumber() || !value.canConvertToLong()) {
@@ -207,14 +369,6 @@ public final class LingxingWmsClient {
return value.longValue();
}
private static void verifyPage(JsonNode value, int expected, String stage) {
if (missingNumber(value)) return;
if (!value.isIntegralNumber() || !value.canConvertToInt()) {
throw invalidResponse(stage + "_TYPE_" + nodeType(value), 200);
}
if (value.intValue() != expected) throw invalidResponse(stage + "_MISMATCH", 200);
}
private static Integer diagnosticCode(JsonNode value) {
if (value == null) return null;
if (value.isIntegralNumber() && value.canConvertToInt()) return value.intValue();
@@ -237,6 +391,7 @@ public final class LingxingWmsClient {
}
private static LingxingWmsException sourceRejected(int code) {
if (code == 31001 || code == 429) return upstreamFailure("SOURCE_RATE_LIMITED", "领星 WMS 来源限流,账号已暂停", false, 200, code);
if (code == 11008) {
return upstreamFailure("AUTHORIZATION_REJECTED",
"领星 WMS 未授予查询接口权限,请核对管理后台自研系统授权", false, 200, code);
@@ -244,11 +399,11 @@ public final class LingxingWmsClient {
if (code == 11006) {
return upstreamFailure("SIGNATURE_REJECTED", "领星 WMS 请求验签未通过", false, 200, code);
}
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", code == 31001, 200, code);
return upstreamFailure("SOURCE_REJECTED", "领星 WMS 业务请求失败,请核对来源错误码", false, 200, code);
}
private static LingxingWmsException invalidResponse(String stage, Integer sourceCode) {
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合出库分页接口契约(" + stage + ")",
return upstreamFailure("INVALID_RESPONSE", "领星 WMS 返回内容不符合接口契约(" + stage + ")",
false, 200, sourceCode);
}
@@ -1,4 +1,6 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AttemptMetadata;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.AuthorizationDenial;
/** Only fixed messages and numeric upstream metadata may leave the adapter. */
public final class LingxingWmsException extends RuntimeException {
@@ -6,6 +8,8 @@ public final class LingxingWmsException extends RuntimeException {
private final boolean retryable;
private final Integer upstreamStatus;
private final Integer sourceCode;
private AttemptMetadata egress;
private AuthorizationDenial denial;
public LingxingWmsException(String code, String message, boolean retryable,
Integer upstreamStatus, Integer sourceCode) {
@@ -21,4 +25,8 @@ public final class LingxingWmsException extends RuntimeException {
public boolean retryable() { return retryable; }
public Integer upstreamStatus() { return upstreamStatus; }
public Integer sourceCode() { return sourceCode; }
public AttemptMetadata egress() { return egress; }
public LingxingWmsException withEgress(AttemptMetadata metadata) { this.egress = metadata; return this; }
public AuthorizationDenial denial() { return denial; }
public LingxingWmsException withDenial(AuthorizationDenial metadata) { this.denial = metadata; return this; }
}