feat(selfop): add bounded independent quantity diagnostic capture

This commit is contained in:
2026-10-13 23:40:44 +08:00
parent 54f79b3902
commit 3d3507a770
18 changed files with 1190 additions and 1 deletions
@@ -0,0 +1,19 @@
package com.baiyue.integration.config;
import java.time.Instant;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
/** Fixed one-shot scope. No refresh/restart may extend the configured absolute window. */
@ConfigurationProperties("baiyue.integration.lingxing-wms.quantity-capture")
public record LingxingQuantityCaptureProperties(@DefaultValue("false") boolean enabled,
String captureId, String sourceAccount, String rootTaskId, String sessionId,
String warehouseCode, String customerCode, Instant startsAt, Instant expiresAt) {
public boolean valid() {
return text(captureId) && text(sourceAccount) && text(rootTaskId) && text(sessionId)
&& text(warehouseCode) && text(customerCode) && startsAt != null && expiresAt != null
&& expiresAt.isAfter(startsAt) && !expiresAt.isAfter(startsAt.plusSeconds(1800));
}
private static boolean text(String value) { return value != null && !value.isBlank() && value.length() <= 128; }
@Override public String toString() { return "LingxingQuantityCaptureProperties[enabled=" + enabled + "]"; }
}
@@ -0,0 +1,171 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingQuantityCaptureProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.EgressContext;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.annotation.PreDestroy;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import java.math.BigDecimal;
import java.time.Clock;
import java.time.Instant;
import java.util.*;
import java.util.concurrent.*;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
/** Passive bounded observer. It has no supplier client and never changes collection decisions. */
@Component
public final class LingxingQuantityCapture {
private static final Logger LOG = LoggerFactory.getLogger(LingxingQuantityCapture.class);
private static final int MAX_ORDERS = 100, MAX_ROWS = 2000, MAX_BYTES = 2 * 1024 * 1024;
private final LingxingQuantityCaptureProperties config;
private final ObjectMapper mapper;
private final Sink sink;
private final Clock clock;
private final ThreadPoolExecutor worker;
private final Map<Key, Pending> pending = new HashMap<>();
private final Set<Key> completed = new HashSet<>();
private volatile String stopReason;
private int records, rows, bytes;
@Autowired
public LingxingQuantityCapture(LingxingQuantityCaptureProperties config, ObjectMapper mapper,
ObjectProvider<Sink> sink) {
this(config, mapper, sink.getIfAvailable(), Clock.systemUTC());
}
LingxingQuantityCapture(LingxingQuantityCaptureProperties config, ObjectMapper mapper, Sink sink, Clock clock) {
this.config = config; this.mapper = mapper; this.sink = sink; this.clock = clock;
worker = new ThreadPoolExecutor(1, 1, 0, TimeUnit.SECONDS, new ArrayBlockingQueue<>(MAX_ORDERS), task -> {
Thread thread = new Thread(task, "lingxing-quantity-capture"); thread.setDaemon(true); return thread;
}, new ThreadPoolExecutor.AbortPolicy());
if (!config.enabled()) stopReason = "DISABLED";
else if (!config.valid() || sink == null) stopReason = "CONFIGURATION_UNAVAILABLE";
}
/** Called only after the normal projection has successfully validated a supplier response. */
public synchronized void observe(JsonNode data, boolean detail, EgressContext context, Instant observedAt) {
try {
if (!active() || context == null || !context.wellFormed()
|| !Objects.equals(config.sourceAccount(), context.sourceAccount())
|| !Objects.equals(config.rootTaskId(), context.rootTaskId())
|| !Objects.equals(config.sessionId(), context.sessionId())) return;
if (detail) enqueue(data, true, context, observedAt);
else for (JsonNode row : data.path("records")) {
if (!active()) break;
enqueue(row, false, context, observedAt);
}
} catch (RuntimeException error) { stop("OBSERVATION_FAILED"); }
}
private void enqueue(JsonNode data, boolean detail, EgressContext context, Instant time) {
String warehouse = text(data, "whCode", 64), customer = text(data, "customerCode", 64);
if (!("*".equals(config.warehouseCode()) || config.warehouseCode().equals(warehouse))
|| !("*".equals(config.customerCode()) || config.customerCode().equals(customer))) return;
Key key = new Key(warehouse, customer, text(data, "deliveryNo", 128));
if (completed.contains(key)) return;
// Freeze the first 100 list candidates. Extra page rows and unselected details cost no retained memory.
if (detail && !pending.containsKey(key)) return;
if (!detail && (pending.containsKey(key) || pending.size() + completed.size() >= MAX_ORDERS)) return;
JsonNode items = data.path(detail ? "skuVOList" : "productList");
if (!items.isArray() || items.isEmpty() || items.size() > 200) { stop("SKU_RECORD_LIMIT_OR_MISSING"); return; }
Map<String, Quantities> quantities = new TreeMap<>();
for (JsonNode item : items) {
String sku = text(item, "productSku", 128);
Quantities next = new Quantities(detail ? null : quantity(item.get("qty")),
detail ? quantity(item.get("deliveryQty")) : null,
detail ? quantity(item.get("actualDeliveryQty")) : null);
quantities.merge(sku, next, Quantities::add);
}
Observation observation = new Observation(data.path("status").isInt() ? data.path("status").asText() : null,
optionalText(data, "outboundTime", 64), time, context.attemptId(), true);
Sample sample = new Sample(key, detail, observation, quantities, items.size());
process(sample);
}
private void process(Sample sample) {
try {
if (!active()) return;
if (++records > 2000 || (rows += sample.rowCount()) > MAX_ROWS
|| (bytes += mapper.writeValueAsBytes(sample).length) > MAX_BYTES) { stop("CAPTURE_LIMIT"); return; }
if (completed.contains(sample.key())) return;
// Directory and detail stages can be far apart: candidates live until the fixed session expiry.
Pending pair = pending.computeIfAbsent(sample.key(), ignored -> new Pending());
// Keep the first observation on each side; retries cannot silently replace its generation.
if (sample.detail()) { if (pair.detail == null) pair.detail = sample; }
else if (pair.list == null) pair.list = sample;
if (pair.list == null || pair.detail == null) return;
Set<String> skus = new TreeSet<>(pair.list.quantities().keySet()); skus.addAll(pair.detail.quantities().keySet());
if (skus.size() > 200) { stop("ORDER_SKU_LIMIT"); return; }
List<Line> lines = new ArrayList<>(); boolean difference = false;
for (String sku : skus) {
Quantities list = pair.list.quantities().get(sku), detail = pair.detail.quantities().get(sku);
BigDecimal forecast = detail == null ? null : detail.delivery, actual = detail == null ? null : detail.actual;
difference |= forecast != null && actual != null && forecast.compareTo(actual) != 0;
lines.add(new Line(sku, list != null, detail != null, list == null ? null : list.list, forecast, actual));
}
Key key = sample.key();
LingxingQuantityEvidence evidence = new LingxingQuantityEvidence(config.captureId(), config.sourceAccount(),
config.rootTaskId(), config.sessionId(), key.warehouse(), key.customer(), key.order(),
pair.list.observation(), pair.detail.observation(), List.copyOf(lines));
worker.execute(() -> {
try {
if (!clock.instant().isBefore(config.expiresAt())) { abortTransmission("EXPIRED"); return; }
sink.accept(evidence);
} catch (ReceiverStopped normalStop) { abortTransmission(normalStop.reason()); }
catch (Exception failure) { abortTransmission("DIAGNOSTIC_FAILED"); }
});
completed.add(key); pending.remove(key);
if (difference) stop("FORECAST_ACTUAL_DIFFERENCE");
else if (completed.size() >= MAX_ORDERS) stop("ORDER_LIMIT");
} catch (Exception error) { stop("DIAGNOSTIC_FAILED"); }
}
private boolean active() {
if (stopReason != null) return false;
if (!clock.instant().isBefore(config.expiresAt())) { stop("EXPIRED"); return false; }
return !clock.instant().isBefore(config.startsAt());
}
private synchronized void stop(String reason) {
if (stopReason != null) return;
stopReason = reason;
if (!"ORDER_LIMIT".equals(reason) && !"FORECAST_ACTUAL_DIFFERENCE".equals(reason)) worker.getQueue().clear();
pending.clear();
LOG.info("Lingxing quantity capture stopped: {}", reason);
}
private synchronized void abortTransmission(String reason) {
worker.getQueue().clear(); pending.clear();
if (!reason.equals(stopReason)) {
stopReason = reason; LOG.info("Lingxing quantity capture stopped: {}", reason);
}
}
public String stopReason() { return stopReason; }
@PreDestroy public void close() { stop("SHUTDOWN"); worker.shutdownNow(); }
private static String text(JsonNode data, String field, int max) {
String value = optionalText(data, field, max);
if (value == null || value.isBlank()) throw new IllegalArgumentException("Missing diagnostic identity");
return value;
}
private static String optionalText(JsonNode data, String field, int max) {
JsonNode value = data.get(field);
if (value == null || value.isNull()) return null;
if (!value.isTextual() || value.textValue().length() > max) throw new IllegalArgumentException("Invalid diagnostic text");
return value.textValue();
}
private static BigDecimal quantity(JsonNode value) {
if (value == null || value.isNull() || value.isTextual() && value.textValue().isBlank()) return null;
if (!value.isNumber()) throw new IllegalArgumentException("Invalid diagnostic quantity");
BigDecimal result = value.decimalValue();
if (result.precision() > 30 || Math.abs(result.scale()) > 10) throw new IllegalArgumentException("Quantity limit");
return result;
}
private record Key(String warehouse, String customer, String order) { }
private record Quantities(BigDecimal list, BigDecimal delivery, BigDecimal actual) {
Quantities add(Quantities other) { return new Quantities(sum(list, other.list), sum(delivery, other.delivery), sum(actual, other.actual)); }
private static BigDecimal sum(BigDecimal a, BigDecimal b) { return a == null || b == null ? null : a.add(b); }
}
private record Sample(Key key, boolean detail, Observation observation, Map<String, Quantities> quantities, int rowCount) { }
private static final class Pending {
private Sample list, detail;
}
}
@@ -0,0 +1,34 @@
package com.baiyue.integration.provider.lingxing;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/** Whitelist only; never includes source payload, addresses, labels or credentials. */
public record LingxingQuantityEvidence(String diagnosticSessionId, String sourceAccount, String rootTaskId,
String collectionSessionId, String warehouseCode, String ownerCode, String orderNo,
Observation listObservation, Observation detailObservation, List<Line> skus) {
public record Observation(String status, String outboundTime, Instant observedAt, String attemptId,
boolean skuSetComplete) { }
public record Line(String sku, boolean listPresent, boolean detailPresent, BigDecimal listQty,
BigDecimal forecastQty, BigDecimal actualQty) { }
public boolean skuSetEqual() { return skus.stream().allMatch(line -> line.listPresent() && line.detailPresent()); }
public boolean forecastActualDifference() { return skus.stream().anyMatch(line -> line.forecastQty() != null
&& line.actualQty() != null && line.forecastQty().compareTo(line.actualQty()) != 0); }
/** Receiver must durably enforce window, deduplication and cumulative bounds across restarts. */
public interface Sink { void accept(LingxingQuantityEvidence evidence); }
/** Normal durable receiver decision, including an accepted final order; not a transport failure. */
public static final class ReceiverStopped extends RuntimeException {
private final String reason;
private final boolean orderStored;
public ReceiverStopped(String reason, boolean orderStored) {
super("Diagnostic receiver stopped");
if (reason == null || !reason.matches("[A-Z][A-Z0-9_]{0,63}")) {
throw new IllegalArgumentException("Invalid diagnostic stop reason");
}
this.reason = reason; this.orderStored = orderStored;
}
public String reason() { return reason; }
public boolean orderStored() { return orderStored; }
}
}
@@ -0,0 +1,57 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
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.URI;
import java.net.http.*;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Set;
/** Async worker calls this once per paired order. No retry and no supplier requests. */
@Component
public final class LingxingQuantityHttpSink implements LingxingQuantityEvidence.Sink {
private static final String PATH = "/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders";
private final URI baseUri;
private final ObjectMapper mapper;
private final String token;
private final HttpClient client = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2))
.followRedirects(HttpClient.Redirect.NEVER).build();
public LingxingQuantityHttpSink(LingxingEgressProperties config, ObjectMapper mapper,
@Value("${baiyue.security.internal-service-token:}") String token) {
this.baseUri = config.budgetAuthorityBaseUri();
this.mapper = mapper.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
this.token = token;
}
@Override public void accept(LingxingQuantityEvidence evidence) {
try {
if (baseUri == null || token == null || token.getBytes(StandardCharsets.UTF_8).length < 32) throw unavailable();
byte[] body = mapper.writeValueAsBytes(evidence);
if (body.length > 256 * 1024 || evidence.skus().size() > 200) throw unavailable();
HttpRequest request = HttpRequest.newBuilder(baseUri.resolve(PATH)).timeout(Duration.ofSeconds(3))
.header("X-Internal-Service-Token", token).header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofByteArray(body)).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
if (response.statusCode() != 200 || response.body().length() > 16384) throw unavailable();
JsonNode root = mapper.readTree(response.body());
if (root == null || !"200".equals(root.path("code").textValue())) throw unavailable();
String outcome = root.path("data").path("outcome").asText();
if (!Set.of("ACCEPTED", "DUPLICATE", "STOPPED").contains(outcome)) throw unavailable();
// Receiver may accept the final order and atomically close its durable session.
JsonNode reason = root.path("data").path("stopReason");
if (!reason.isMissingNode() && !reason.isNull()) {
if (!reason.isTextual() || !reason.textValue().matches("[A-Z][A-Z0-9_]{0,63}")) throw unavailable();
throw new LingxingQuantityEvidence.ReceiverStopped(reason.textValue(), !"STOPPED".equals(outcome));
}
if ("STOPPED".equals(outcome)) throw unavailable();
} catch (LingxingQuantityEvidence.ReceiverStopped normalStop) {
throw normalStop;
} catch (InterruptedException interrupted) {
Thread.currentThread().interrupt(); throw unavailable();
} catch (Exception failed) { throw unavailable(); }
}
private static IllegalStateException unavailable() { return new IllegalStateException("DIAGNOSTIC_RECEIVER_UNAVAILABLE"); }
}
@@ -48,6 +48,12 @@ public final class LingxingWmsClient {
private final HttpClient httpClient;
private final Clock clock;
private final LingxingEgressGate egressGate;
private LingxingQuantityCapture quantityCapture;
@Autowired
void setQuantityCapture(LingxingQuantityCapture quantityCapture) {
this.quantityCapture = quantityCapture;
}
@Autowired
public LingxingWmsClient(LingxingWmsProperties properties, ObjectMapper mapper, LingxingEgressGate egressGate) {
@@ -184,7 +190,14 @@ public final class LingxingWmsClient {
}
throw error;
}
return projection.apply(json, handle.metadata());
T result = projection.apply(json, handle.metadata());
if (quantityCapture != null && (operation == Operation.OUTBOUND_QUERY
|| operation == Operation.OUTBOUND_LOCATE || operation == Operation.OUTBOUND_DETAIL)) {
// Diagnostics must never alter the normal projection or the egress completion result.
try { quantityCapture.observe(json, operation == Operation.OUTBOUND_DETAIL, context, clock.instant()); }
catch (RuntimeException ignored) { /* Passive diagnostic failure is isolated. */ }
}
return result;
});
}