fix(selfop): persist bounded next-root quantity diagnostics

This commit is contained in:
2026-10-14 04:59:05 +08:00
parent 765b6e6208
commit 913b9e9262
17 changed files with 1142 additions and 914 deletions
@@ -1,19 +1,6 @@
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. */
/** Feature gate only. Explicit, durable grants and deadlines are owned by Lastleg. */
@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 + "]"; }
}
public record LingxingQuantityCaptureProperties(@DefaultValue("false") boolean enabled,String sourceAccount) {}
@@ -1,171 +1,122 @@
package com.baiyue.integration.provider.lingxing;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
import com.baiyue.integration.config.LingxingQuantityCaptureProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.EgressContext;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.Operation;
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.time.*;
import java.util.*;
import java.util.concurrent.*;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.locks.ReentrantLock;
/** Passive bounded observer. It has no supplier client and never changes collection decisions. */
/** Passive whitelist snapshots only. No JSON serialization, HTTP or waiting locks on egress threads. */
@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;
}
private static final int MAX_BYTES=2097152,MAX_ROWS=2000,MAX_QUEUE=100;
private final LingxingQuantityCaptureProperties config;private final Sink sink;private final Clock clock;
private final String producer=UUID.randomUUID().toString();
private final ScheduledExecutorService worker;
private final ReentrantLock intake=new ReentrantLock();
private final ArrayDeque<Queued> queue=new ArrayDeque<>();
private final Set<String> selected=new HashSet<>(),details=new HashSet<>();
private final AtomicReference<String> failure=new AtomicReference<>();
private String observedRoot;
private volatile View grant;private volatile boolean closed;private int bytes,rows;private boolean attached;
@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,sink,clock,true);}
LingxingQuantityCapture(LingxingQuantityCaptureProperties config,Sink sink,Clock clock,boolean start){
this.config=config;this.sink=sink;this.clock=clock;
worker=Executors.newSingleThreadScheduledExecutor(r->{Thread t=new Thread(r,"lingxing-quantity-capture");t.setDaemon(true);return t;});
if(start&&config.enabled()&&sink!=null)worker.scheduleWithFixedDelay(this::pump,0,2,TimeUnit.SECONDS);
}
public void observe(JsonNode data,Operation operation,EgressContext context,Instant observedAt){
View current=grant;
if(closed||!config.enabled()||failure.get()!=null||current==null||"TERMINAL".equals(current.state())||"DRAINING".equals(current.state())||!clock.instant().isBefore(current.expiresAt()))return;
if(context==null||!context.wellFormed()||!"BUSINESS".equals(String.valueOf(context.executionPurpose()))||!Objects.equals(config.sourceAccount(),context.sourceAccount()))return;
if(operation!=Operation.OUTBOUND_QUERY&&operation!=Operation.OUTBOUND_DETAIL)return;
if(current.rootTaskId()!=null&&!current.rootTaskId().equals(context.rootTaskId())||Objects.equals(current.excludedRootTaskId(),context.rootTaskId()))return;
if(!intake.tryLock()){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
try{
if(current!=grant){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
if(operation==Operation.OUTBOUND_DETAIL)snapshot(data,true,context,observedAt,current);
else {JsonNode records=data.path("records");if(!records.isArray()||records.size()>100){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
for(JsonNode row:records){if(failure.get()!=null)break;snapshot(row,false,context,observedAt,current);}}
}catch(RuntimeException bad){failure.compareAndSet(null,"OBSERVER_FAILED");}finally{intake.unlock();}
}
private void snapshot(JsonNode data,boolean detail,EgressContext c,Instant time,View g){
String warehouse=text(data,"whCode",64),owner=text(data,"customerCode",128),order=text(data,"deliveryNo",128);
if(!g.warehouseCodes().contains(warehouse)||!("*".equals(g.ownerCode())||g.ownerCode().equals(owner)))return;
if(observedRoot!=null&&!observedRoot.equals(c.rootTaskId()))return;
String key=key(c.rootTaskId(),warehouse,owner,order);
if(detail&&(!selected.contains(key)||details.contains(key)))return;
if(!detail&&(selected.contains(key)||selected.size()>=100))return;
JsonNode items=data.path(detail?"skuVOList":"productList");boolean complete=items.isArray()&&!items.isEmpty();
if(items.size()>200){failure.compareAndSet(null,"OBSERVER_FAILED");return;}
// Conservative UTF-8 bound: identifiers <=128 chars, quantity <=38 digits, bounded metadata.
int retained=4096+items.size()*1024;
if(queue.size()>=MAX_QUEUE||rows+items.size()>MAX_ROWS||bytes+retained>MAX_BYTES){failure.compareAndSet(null,"QUEUE_FULL");return;}
var quantities=new TreeMap<String,Sku>();
if(items.isArray())for(JsonNode item:items){String sku=text(item,"productSku",128);
Sku value=new Sku(sku,detail?null:quantity(item.get("qty")),detail?quantity(item.get("deliveryQty")):null,detail?quantity(item.get("actualDeliveryQty")):null);
quantities.merge(sku,value,(a,b)->new Sku(sku,sum(a.listQty(),b.listQty()),sum(a.forecastQty(),b.forecastQty()),sum(a.actualQty(),b.actualQty())));
}
String status=data.path("status").isIntegralNumber()?data.path("status").asText():null;
if(status!=null&&status.length()>32)throw new IllegalArgumentException();
Event event=new Event(g.diagnosticSessionId(),c.sourceAccount(),producer,c.rootTaskId(),c.sessionId(),detail?"OUTBOUND_DETAIL":"OUTBOUND_QUERY",warehouse,owner,order,detail?"DETAIL":"LIST",new Observation(status,optional(data,"outboundTime",64),time,c.attemptId(),complete),List.copyOf(quantities.values()));
queue.addLast(new Queued(event,retained,items.size()));bytes+=retained;rows+=items.size();
if(detail)details.add(key);else {selected.add(key);observedRoot=c.rootTaskId();}
}
/** Single worker owns HTTP; status(drained) occurs only after previously queued observations finish. */
void pump(){
if(closed||!config.enabled()||sink==null)return;
try{
if(grant==null||"TERMINAL".equals(grant.state())){
View next=sink.status(new Status(config.sourceAccount(),producer,!attached,false));attached=true;
if(next==null)return;
if(grant==null||!grant.diagnosticSessionId().equals(next.diagnosticSessionId()))reset(next);else grant=next;
}
if(grant==null||"TERMINAL".equals(grant.state()))return;
if(failure.get()!=null){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;}
while(true){
if(failure.get()!=null)break;
Queued next=poll();if(next==null)break;
try{
if(!clock.instant().isBefore(grant.expiresAt())){failure.compareAndSet(null,"INCOMPLETE");break;}
Receipt receipt=sink.observe(next.event());if(receipt==null||receipt.session()==null)throw new IllegalStateException();
grant=receipt.session();
if("TERMINAL".equals(grant.state())){clearQueue();break;}
}finally{release(next);}
}
if(failure.get()!=null&&!"TERMINAL".equals(grant.state())){grant=sink.stop(new Stop(grant.diagnosticSessionId(),config.sourceAccount(),producer,failure.get()));clearQueue();return;}
if(!"TERMINAL".equals(grant.state())){
View next=sink.status(new Status(config.sourceAccount(),producer,false,false));if(next==null)throw new IllegalStateException();grant=next;
if("TERMINAL".equals(next.state()))clearQueue();
// After DRAINING is visible to intake, a later pump drains its tail before acknowledging.
if("DRAINING".equals(next.state())&&empty())grant=sink.status(new Status(config.sourceAccount(),producer,false,true));
}
}catch(Exception failed){failure.compareAndSet(null,"INCOMPLETE");}
}
private Queued poll(){intake.lock();try{return queue.pollFirst();}finally{intake.unlock();}}
private void release(Queued q){intake.lock();try{bytes-=q.bytes();rows-=q.rows();}finally{intake.unlock();}}
private boolean empty(){intake.lock();try{return queue.isEmpty();}finally{intake.unlock();}}
private void clearQueue(){intake.lock();try{for(Queued q:queue){bytes-=q.bytes();rows-=q.rows();}queue.clear();}finally{intake.unlock();}}
private void reset(View v){intake.lock();try{queue.clear();bytes=0;rows=0;selected.clear();details.clear();failure.set(null);observedRoot=v.rootTaskId();
for(Key k:v.selected()){String key=key(v.rootTaskId(),k.warehouseCode(),k.ownerCode(),k.orderNo());selected.add(key);if(k.paired())details.add(key);}grant=v;
}finally{intake.unlock();}}
private static String key(String root,String warehouse,String owner,String order){return root+"\u001f"+warehouse+"\u001f"+owner+"\u001f"+order;}
public String stopReason(){return failure.get()!=null?failure.get():grant==null?null:grant.stopReason();}
@PreDestroy public void close(){closed=true;worker.shutdownNow();clearQueue();}
private static String text(JsonNode n,String field,int max){String s=optional(n,field,max);if(s==null||s.isBlank())throw new IllegalArgumentException();return s;}
private static String optional(JsonNode n,String field,int max){JsonNode v=n.get(field);if(v==null||v.isNull())return null;if(!v.isTextual()||v.textValue().length()>max||v.textValue().codePoints().anyMatch(Character::isISOControl))throw new IllegalArgumentException();return v.textValue();}
private static BigDecimal quantity(JsonNode n){if(n==null||!n.isNumber())return null;BigDecimal value=n.decimalValue();return value.signum()<0||value.scale()>6||value.precision()-value.scale()>32?null:value.stripTrailingZeros();}
private static BigDecimal sum(BigDecimal a,BigDecimal b){return a==null||b==null?null:a.add(b);}
private record Queued(Event event,int bytes,int rows){}
}
@@ -1,34 +1,17 @@
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; }
}
/** Internal diagnostic whitelist; excludes raw payloads, addresses and supplier credentials. */
public final class LingxingQuantityEvidence {
private LingxingQuantityEvidence(){}
public record Status(String sourceAccount,String producerId,boolean attach,boolean drained){}
public record Stop(String diagnosticSessionId,String sourceAccount,String producerId,String reason){}
public record Observation(String status,String outboundTime,Instant observedAt,String attemptId,Boolean skuSetComplete){}
public record Sku(String sku,BigDecimal listQty,BigDecimal forecastQty,BigDecimal actualQty){}
public record Event(String diagnosticSessionId,String sourceAccount,String producerId,String rootTaskId,String collectionSessionId,String operation,String warehouseCode,String ownerCode,String orderNo,String side,Observation observation,List<Sku> skus){}
public record Key(String warehouseCode,String ownerCode,String orderNo,boolean paired){}
public record View(String diagnosticSessionId,String sourceAccount,String rootTaskId,String collectionSessionId,String state,String stopReason,Instant startsAt,Instant expiresAt,Instant bindBefore,List<String> warehouseCodes,String ownerCode,String excludedRootTaskId,int candidates,int pairedOrders,int retainedRows,long retainedBytes,boolean deliveryUncertain,List<Key> selected){}
public record Receipt(String outcome,String comparison,View session){}
public interface Sink {View status(Status request);Receipt observe(Event event);View stop(Stop request);}
}
@@ -1,7 +1,6 @@
package com.baiyue.integration.provider.lingxing;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
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;
@@ -9,49 +8,43 @@ 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. */
/** Invoked only by the bounded observer worker. Never retries or contacts the supplier. */
@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"); }
public final class LingxingQuantityHttpSink implements Sink {
private static final String PATH="/internal/lastleg/self-operated-analytics/quantity-diagnostics/";
private final URI baseUri;private final ObjectMapper json;private final String token;
private final HttpClient client=HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).followRedirects(HttpClient.Redirect.NEVER).build();
public LingxingQuantityHttpSink(LingxingEgressProperties config,ObjectMapper json,@Value("${baiyue.security.internal-service-token:}") String token){this.baseUri=config.budgetAuthorityBaseUri();this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);this.token=token;}
public View status(Status request){return call("status",request,View.class);}
public Receipt observe(Event event){return call("observe",event,Receipt.class);}
public View stop(Stop request){return call("stop",request,View.class);}
private <T>T call(String path,Object payload,Class<T> type){
try{
if(baseUri==null||token==null||token.getBytes(StandardCharsets.UTF_8).length<32)throw unavailable();
byte[] body=json.writeValueAsBytes(payload);if(body.length>262144)throw unavailable();
HttpRequest request=HttpRequest.newBuilder(baseUri.resolve(PATH+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();
// Limit while receiving, not after allocating an arbitrary response String.
HttpResponse<byte[]> response=client.send(request,info->new LimitedBody(262144));
if(response.statusCode()!=200)throw unavailable();
var root=json.readTree(response.body());if(root==null||!"200".equals(root.path("code").asText()))throw unavailable();
if(root.path("data").isNull())return null;
return json.treeToValue(root.path("data"),type);
}catch(InterruptedException e){Thread.currentThread().interrupt();throw unavailable();}
catch(Exception e){throw unavailable();}
}
private static IllegalStateException unavailable(){return new IllegalStateException("DIAGNOSTIC_RECEIVER_UNAVAILABLE");}
static final class LimitedBody implements HttpResponse.BodySubscriber<byte[]> {
private final int limit;private final java.io.ByteArrayOutputStream out=new java.io.ByteArrayOutputStream();
private final java.util.concurrent.CompletableFuture<byte[]> result=new java.util.concurrent.CompletableFuture<>();
private java.util.concurrent.Flow.Subscription subscription;
LimitedBody(int limit){this.limit=limit;}
public java.util.concurrent.CompletionStage<byte[]> getBody(){return result;}
public void onSubscribe(java.util.concurrent.Flow.Subscription s){subscription=s;s.request(1);}
public void onNext(java.util.List<java.nio.ByteBuffer> buffers){
for(var buffer:buffers){if(buffer.remaining()>limit-out.size()){subscription.cancel();result.completeExceptionally(unavailable());return;}byte[] bytes=new byte[buffer.remaining()];buffer.get(bytes);out.writeBytes(bytes);}subscription.request(1);
}
public void onError(Throwable error){result.completeExceptionally(error);}
public void onComplete(){result.complete(out.toByteArray());}
}
}
@@ -192,9 +192,9 @@ public final class LingxingWmsClient {
}
T result = projection.apply(json, handle.metadata());
if (quantityCapture != null && (operation == Operation.OUTBOUND_QUERY
|| operation == Operation.OUTBOUND_LOCATE || operation == Operation.OUTBOUND_DETAIL)) {
|| 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()); }
try { quantityCapture.observe(json, operation, context, clock.instant()); }
catch (RuntimeException ignored) { /* Passive diagnostic failure is isolated. */ }
}
return result;
@@ -1,146 +1,50 @@
package com.baiyue.integration.provider.lingxing;
import static com.baiyue.integration.provider.lingxing.LingxingQuantityEvidence.*;
import static org.assertj.core.api.Assertions.*;
import com.baiyue.integration.config.LingxingQuantityCaptureProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.*;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.ValueSource;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
import static org.assertj.core.api.Assertions.*;
import org.junit.jupiter.api.Test;
class LingxingQuantityCaptureTest {
private static final Instant NOW = Instant.parse("2026-10-13T01:00:00Z");
private final ObjectMapper mapper = new ObjectMapper().registerModule(new JavaTimeModule());
private final BlockingQueue<LingxingQuantityEvidence> saved = new LinkedBlockingQueue<>();
private EgressContext context(String session) { return new EgressContext("account", CollectionMode.BUSINESS,
"root", session, "attempt", "grant"); }
private LingxingQuantityCapture capture(Instant expiry, LingxingQuantityEvidence.Sink sink) {
return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true, "capture", "account",
"root", "session", "*", "*", NOW.minusSeconds(1), expiry), mapper, sink, Clock.fixed(NOW, ZoneOffset.UTC));
}
private void observe(LingxingQuantityCapture c, boolean detail, String items, String session, int status) throws Exception {
String order = "{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D\",\"status\":" + status
+ ",\"" + (detail ? "skuVOList" : "productList") + "\":[" + items + "]}";
c.observe(mapper.readTree(detail ? order : "{\"records\":[" + order + "]}"), detail, context(session), NOW);
}
@ParameterizedTest @ValueSource(ints = {10, 8})
void preservesBothSemanticOutcomesAndStopsAtDifference(int listQty) throws Exception {
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":" + listQty + "}", "session", 100);
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":8}", "session", 100);
var e = saved.poll(3, TimeUnit.SECONDS);
assertThat(e).isNotNull(); assertThat(e.forecastActualDifference()).isTrue();
assertThat(e.skus().get(0).listQty()).isEqualByComparingTo(Integer.toString(listQty));
assertThat(e.skuSetEqual()).isTrue();
awaitStop(c.value, "FORECAST_ACTUAL_DIFFERENCE");
}
}
@Test void duplicateDecimalsUnknownsAndSkuMismatchRemainVisibleForCancelledOrder() throws Exception {
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":0.1},{\"productSku\":\"S\",\"qty\":0.2},"
+ "{\"productSku\":\"LIST_ONLY\",\"qty\":1}", "session", 99);
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":null},"
+ "{\"productSku\":\"S\",\"deliveryQty\":2,\"actualDeliveryQty\":1}", "session", 99);
var e = saved.poll(3, TimeUnit.SECONDS);
assertThat(e).isNotNull(); assertThat(e.skuSetEqual()).isFalse();
var line = e.skus().stream().filter(v -> v.sku().equals("S")).findFirst().orElseThrow();
assertThat(line.listQty()).isEqualByComparingTo("0.3");
assertThat(line.forecastQty()).isEqualByComparingTo("3"); assertThat(line.actualQty()).isNull();
assertThat(e.detailObservation().status()).isEqualTo("99"); assertThat(e.detailObservation().outboundTime()).isNull();
assertThat(e.forecastActualDifference()).isFalse();
}
}
@Test void crossSessionCannotPairAndExpiredCaptureDoesNotWrite() throws Exception {
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "other", 100);
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100);
assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull();
}
try (var c = new CloseableCapture(capture(NOW, saved::add))) {
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100);
assertThat(c.value.stopReason()).isEqualTo("EXPIRED"); assertThat(saved).isEmpty();
}
}
@Test void sinkFailureStopsOnlyDiagnosticAndNeverEscapesCaller() throws Exception {
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> { throw new IllegalStateException("private"); }))) {
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100);
observe(c.value, true, "{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}", "session", 100);
awaitStop(c.value, "DIAGNOSTIC_FAILED");
}
}
@Test void hundredCompleteOrdersStopsFurtherCapture() throws Exception {
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), saved::add))) {
for (int i = 0; i < 100; i++) {
String head = "\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"D" + i + "\",\"status\":100,";
c.value.observe(mapper.readTree("{\"records\":[{" + head + "\"productList\":[{\"productSku\":\"S\",\"qty\":1}]}]}"), false, context("session"), NOW);
c.value.observe(mapper.readTree("{" + head + "\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":1,\"actualDeliveryQty\":1}]}"), true, context("session"), NOW);
assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull();
}
awaitStop(c.value, "ORDER_LIMIT");
observe(c.value, false, "{\"productSku\":\"S\",\"qty\":1}", "session", 100);
assertThat(saved).isEmpty();
}
}
@Test void thousandRowPageSelectsHundredAndSlowSinkDoesNotBlockDetails() throws Exception {
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> {
entered.countDown();
try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); }
saved.add(evidence);
}))) {
var page = mapper.createObjectNode(); var records = page.putArray("records");
for (int i = 0; i < 1000; i++) {
var order = records.addObject().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100);
order.putArray("productList").addObject().put("productSku", "S").put("qty", 1);
}
c.value.observe(page, false, context("session"), NOW);
assertThat(c.value.stopReason()).isNull();
// Unselected details arrive first: they must not consume queue or evidence capacity.
for (int i = 999; i >= 0; i--) {
var detail = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100);
detail.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1);
c.value.observe(detail, true, context("session"), NOW);
}
assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue();
assertThat(c.value.stopReason()).isEqualTo("ORDER_LIMIT");
release.countDown();
for (int i = 0; i < 100; i++) assertThat(saved.poll(3, TimeUnit.SECONDS)).isNotNull();
assertThat(saved).isEmpty();
} finally { release.countDown(); }
}
private static void awaitStop(LingxingQuantityCapture capture, String reason) throws Exception {
long end = System.nanoTime() + TimeUnit.SECONDS.toNanos(3);
while (capture.stopReason() == null && System.nanoTime() < end) Thread.sleep(5);
assertThat(capture.stopReason()).isEqualTo(reason);
}
@ParameterizedTest @ValueSource(strings = {"FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED"})
void savedFinalOrderRetainsNormalReceiverReasonAndDiscardsQueuedOrders(String reason) throws Exception {
CountDownLatch entered = new CountDownLatch(1), release = new CountDownLatch(1);
try (var c = new CloseableCapture(capture(NOW.plusSeconds(60), evidence -> {
entered.countDown();
try { release.await(3, TimeUnit.SECONDS); } catch (InterruptedException error) { Thread.currentThread().interrupt(); }
saved.add(evidence); // Receiver persisted the last accepted order before returning its stop decision.
throw new LingxingQuantityEvidence.ReceiverStopped(reason, true);
}))) {
for (int i = 0; i < 3; i++) {
var order = mapper.createObjectNode().put("whCode", "W").put("customerCode", "C").put("deliveryNo", "D" + i).put("status", 100);
order.putArray("productList").addObject().put("productSku", "S").put("qty", 1);
var page = mapper.createObjectNode(); page.putArray("records").add(order);
c.value.observe(page, false, context("session"), NOW);
order.putArray("skuVOList").addObject().put("productSku", "S").put("deliveryQty", 1).put("actualDeliveryQty", 1);
c.value.observe(order, true, context("session"), NOW);
}
assertThat(entered.await(3, TimeUnit.SECONDS)).isTrue();
release.countDown(); awaitStop(c.value, reason);
assertThat(saved).hasSize(1);
assertThat(saved.remove().orderNo()).isEqualTo("D0");
assertThat(saved.poll(100, TimeUnit.MILLISECONDS)).isNull();
} finally { release.countDown(); }
}
private record CloseableCapture(LingxingQuantityCapture value) implements AutoCloseable {
@Override public void close() { value.close(); }
}
final Instant now=Instant.parse("2026-10-14T00:00:00Z");final ObjectMapper json=new ObjectMapper();
final EgressContext context=new EgressContext("account",CollectionMode.BUSINESS,"2","selfop-2","attempt","grant");
View view(){return new View("diag","account",null,null,"ARMED",null,now,now.plusSeconds(36000),now.plusSeconds(21600),List.of("W"),"*","1",0,0,0,0,false,List.of());}
com.fasterxml.jackson.databind.JsonNode list(String id)throws Exception{return json.readTree("{\"records\":[{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\""+id+"\",\"productList\":[{\"productSku\":\"S\",\"qty\":10}]}]}");}
class Fake implements Sink {View state=view();List<Event> received=new ArrayList<>();String stopped;public View status(Status s){return state;}public Receipt observe(Event e){received.add(e);return new Receipt("ACCEPTED",null,state);}public View stop(Stop s){stopped=s.reason();return state;}}
LingxingQuantityCapture capture(Sink sink){return new LingxingQuantityCapture(new LingxingQuantityCaptureProperties(true,"account"),sink,Clock.fixed(now,ZoneOffset.UTC),false);}
@Test void observationDoesNotCallReceiverAndLocateNeverBinds()throws Exception{
Fake sink=new Fake();try(var c=new TestClose(capture(sink))){c.value.pump();c.value.observe(list("a"),Operation.OUTBOUND_LOCATE,context,now);c.value.pump();assertThat(sink.received).isEmpty();
c.value.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThat(sink.received).isEmpty();c.value.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).operation()).isEqualTo("OUTBOUND_QUERY");}
}
@Test void slowReceiverNeverBlocksNormalObservationAndFullQueueStopsOnlyDiagnosis()throws Exception{
CountDownLatch entered=new CountDownLatch(1),release=new CountDownLatch(1);Fake sink=new Fake(){public Receipt observe(Event e){entered.countDown();try{release.await(3,TimeUnit.SECONDS);}catch(InterruptedException x){Thread.currentThread().interrupt();}return super.observe(e);}};
var c=capture(sink);c.pump();c.observe(list("first"),Operation.OUTBOUND_QUERY,context,now);var worker=Executors.newSingleThreadExecutor();try{var pending=worker.submit(c::pump);assertThat(entered.await(1,TimeUnit.SECONDS)).isTrue();
assertThatCode(()->{for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now);}).doesNotThrowAnyException();
release.countDown();pending.get(5,TimeUnit.SECONDS);
}finally{release.countDown();worker.shutdownNow();c.close();}
}
@Test void failureIsolatedAndNoRearmOfSameGrant()throws Exception{
Fake sink=new Fake(){public Receipt observe(Event e){throw new IllegalStateException("receiver down");}};var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);assertThatCode(c::pump).doesNotThrowAnyException();assertThat(c.stopReason()).isEqualTo("INCOMPLETE");c.pump();assertThat(sink.stopped).isEqualTo("INCOMPLETE");}finally{c.close();}
}
@Test void selectedCandidatesRecoveredWithoutReobservingList()throws Exception{
Fake sink=new Fake();sink.state=new View("diag","account","2","selfop-2","BOUND",null,now,now.plusSeconds(14400),now.plusSeconds(21600),List.of("W"),"*","1",1,0,1,100,true,List.of(new Key("W","C","a",false)));
var c=capture(sink);try{c.pump();c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);c.observe(json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"a\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}"),Operation.OUTBOUND_DETAIL,context,now);c.pump();assertThat(sink.received).hasSize(1);assertThat(sink.received.get(0).side()).isEqualTo("DETAIL");}finally{c.close();}
}
@Test void malformedOrOversizedSnapshotsAreIsolated()throws Exception{
Fake sink=new Fake();var c=capture(sink);try{c.pump();var data=json.createObjectNode();var records=data.putArray("records");for(int i=0;i<101;i++)records.addObject();assertThatCode(()->c.observe(data,Operation.OUTBOUND_QUERY,context,now)).doesNotThrowAnyException();c.pump();assertThat(sink.stopped).isEqualTo("OBSERVER_FAILED");assertThat(sink.received).isEmpty();}finally{c.close();}
}
@Test void queueIsBoundedAndNoProducerWaitsForIt()throws Exception{
Fake sink=new Fake();var c=capture(sink);try{c.pump();for(int i=0;i<100;i++)c.observe(list("o"+i),Operation.OUTBOUND_QUERY,context,now);
var detail=json.readTree("{\"whCode\":\"W\",\"customerCode\":\"C\",\"deliveryNo\":\"o0\",\"skuVOList\":[{\"productSku\":\"S\",\"deliveryQty\":10,\"actualDeliveryQty\":10}]}");
c.observe(detail,Operation.OUTBOUND_DETAIL,context,now);assertThat(c.stopReason()).isEqualTo("QUEUE_FULL");c.pump();assertThat(sink.stopped).isEqualTo("QUEUE_FULL");assertThat(sink.received).isEmpty();
}finally{c.close();}
}
@Test void diagnosticLockContentionNeverWaitsOnCollector()throws Exception{
Fake sink=new Fake();var c=capture(sink);c.pump();var field=LingxingQuantityCapture.class.getDeclaredField("intake");field.setAccessible(true);var lock=(java.util.concurrent.locks.ReentrantLock)field.get(c);
var pool=Executors.newSingleThreadExecutor();lock.lock();try{var observation=pool.submit(()->{try{c.observe(list("a"),Operation.OUTBOUND_QUERY,context,now);}catch(Exception e){throw new RuntimeException(e);}});observation.get(500,TimeUnit.MILLISECONDS);assertThat(c.stopReason()).isEqualTo("OBSERVER_FAILED");}finally{lock.unlock();pool.shutdownNow();c.close();}
}
record TestClose(LingxingQuantityCapture value)implements AutoCloseable{public void close(){value.close();}}
}
@@ -1,65 +1,32 @@
package com.baiyue.integration.provider.lingxing;
import com.baiyue.integration.config.LingxingEgressProperties;
import com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
import com.sun.net.httpserver.HttpServer;
import org.junit.jupiter.api.Test;
import java.net.*;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.nio.ByteBuffer;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicReference;
import java.util.concurrent.Flow;
import static org.assertj.core.api.Assertions.*;
class LingxingQuantityHttpSinkTest {
@Test void exactContractAuthenticationAndNoRetryWhenReceiverStops() throws Exception {
var calls = new AtomicInteger(); var body = new AtomicReference<String>(); var header = new AtomicReference<String>();
var outcome = new AtomicReference<>("ACCEPTED");
var reason = new AtomicReference<>("null");
HttpServer server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders", exchange -> {
calls.incrementAndGet(); body.set(new String(exchange.getRequestBody().readAllBytes(), StandardCharsets.UTF_8));
header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));
byte[] response = ("{\"code\":\"200\",\"data\":{\"outcome\":\"" + outcome.get() + "\",\"stopReason\":" + reason.get() + "}}")
.getBytes(StandardCharsets.UTF_8);
exchange.sendResponseHeaders(200, response.length); exchange.getResponseBody().write(response); exchange.close();
}); server.start();
try {
var props = new LingxingEgressProperties(CollectionMode.OFF, "test", null, null, null,
URI.create("http://127.0.0.1:" + server.getAddress().getPort()), null, null, false);
var mapper = new ObjectMapper().registerModule(new JavaTimeModule())
.disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
String token = "synthetic-internal-token-000000000000";
var sink = new LingxingQuantityHttpSink(props, mapper, token);
var obs = new LingxingQuantityEvidence.Observation("99", null, Instant.parse("2026-10-13T00:00:00Z"), "attempt", true);
var evidence = new LingxingQuantityEvidence("diagnostic", "account", "root", "collection", "W", "C", "D", obs, obs,
List.of(new LingxingQuantityEvidence.Line("S", true, true, null, null, null)));
sink.accept(evidence);
var json = mapper.readTree(body.get());
assertThat(header.get()).isEqualTo(token); assertThat(json.path("diagnosticSessionId").asText()).isEqualTo("diagnostic");
assertThat(json.path("listObservation").path("observedAt").asText()).isEqualTo("2026-10-13T00:00:00Z");
assertThat(json.path("skus").get(0).path("forecastQty").isNull()).isTrue();
assertThat(json.has("skuSetEqual")).isFalse(); assertThat(json.has("forecastActualDifference")).isFalse();
outcome.set("STOPPED"); assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOf(IllegalStateException.class);
assertThat(calls.get()).isEqualTo(2);
for (String stopReason : List.of("FORECAST_ACTUAL_DIFFERENCE", "LIMIT_REACHED")) {
outcome.set("ACCEPTED"); reason.set("\"" + stopReason + "\"");
assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying(
LingxingQuantityEvidence.ReceiverStopped.class, stopped -> {
assertThat(stopped.reason()).isEqualTo(stopReason);
assertThat(stopped.orderStored()).isTrue();
});
}
outcome.set("STOPPED"); reason.set("\"DISABLED\"");
assertThatThrownBy(() -> sink.accept(evidence)).isInstanceOfSatisfying(
LingxingQuantityEvidence.ReceiverStopped.class, stopped -> {
assertThat(stopped.reason()).isEqualTo("DISABLED");
assertThat(stopped.orderStored()).isFalse();
});
assertThat(calls.get()).isEqualTo(5);
} finally { server.stop(0); }
}
@Test void internalWireContractAuthenticatesAndDoesNotRetry()throws Exception{
var server=com.sun.net.httpserver.HttpServer.create(new java.net.InetSocketAddress("127.0.0.1",0),0);var calls=new java.util.concurrent.atomic.AtomicInteger();var header=new java.util.concurrent.atomic.AtomicReference<String>();var path=new java.util.concurrent.atomic.AtomicReference<String>();
server.createContext("/internal/lastleg/self-operated-analytics/quantity-diagnostics/",exchange->{calls.incrementAndGet();header.set(exchange.getRequestHeaders().getFirst("X-Internal-Service-Token"));path.set(exchange.getRequestURI().getPath());exchange.getRequestBody().readAllBytes();byte[] payload="{\"code\":\"200\",\"data\":null}".getBytes(java.nio.charset.StandardCharsets.UTF_8);exchange.sendResponseHeaders(200,payload.length);exchange.getResponseBody().write(payload);exchange.close();});server.start();
try{
var props=new com.baiyue.integration.config.LingxingEgressProperties(com.baiyue.integration.dto.lingxing.LingxingEgressDtos.CollectionMode.OFF,"test",null,null,null,java.net.URI.create("http://127.0.0.1:"+server.getAddress().getPort()),null,null,false);
String token="synthetic-internal-token-000000000000";var sink=new LingxingQuantityHttpSink(props,new com.fasterxml.jackson.databind.ObjectMapper().findAndRegisterModules(),token);
assertThat(sink.status(new LingxingQuantityEvidence.Status("account","producer",true,false))).isNull();assertThat(header.get()).isEqualTo(token);assertThat(path.get()).endsWith("/status");assertThat(calls.get()).isEqualTo(1);
sink.stop(new LingxingQuantityEvidence.Stop("diag","account","producer","INCOMPLETE"));assertThat(path.get()).endsWith("/stop");assertThat(calls.get()).isEqualTo(2);
}finally{server.stop(0);}
}
@Test void bodyLimitCancelsBeforeRetainingOversizedResponse(){
var body=new LingxingQuantityHttpSink.LimitedBody(16);boolean[] cancelled={false};body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){cancelled[0]=true;}});
body.onNext(List.of(ByteBuffer.wrap(new byte[8])));body.onNext(List.of(ByteBuffer.wrap(new byte[9])));
assertThat(cancelled[0]).isTrue();assertThat(body.getBody().toCompletableFuture()).isCompletedExceptionally();
}
@Test void hundredFullUnicodeKeysFitTheBoundedResponse()throws Exception{
var keys=new java.util.ArrayList<LingxingQuantityEvidence.Key>();for(int i=0;i<100;i++)keys.add(new LingxingQuantityEvidence.Key("倉".repeat(64),"客".repeat(128),"単".repeat(125)+i,false));
byte[] payload=new com.fasterxml.jackson.databind.ObjectMapper().writeValueAsBytes(java.util.Map.of("code","200","data",java.util.Map.of("selected",keys)));
assertThat(payload.length).isGreaterThan(65536).isLessThan(262144);
var body=new LingxingQuantityHttpSink.LimitedBody(262144);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(payload)));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).isEqualTo(payload);
}
@Test void boundedSuccessfulBodyIsExact(){
var body=new LingxingQuantityHttpSink.LimitedBody(16);body.onSubscribe(new Flow.Subscription(){public void request(long n){}public void cancel(){}});body.onNext(List.of(ByteBuffer.wrap(new byte[]{1,2})));body.onComplete();assertThat(body.getBody().toCompletableFuture().join()).containsExactly(1,2);
}
}
@@ -55,7 +55,7 @@ class LingxingWmsClientTest {
@Test void observerFailureCannotChangeBusinessResponseOrIssueExtraRequests() {
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
org.mockito.Mockito.doThrow(new IllegalStateException("diagnostic failure")).when(observer)
.observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.anyBoolean(),
.observe(org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any(Operation.class),
org.mockito.ArgumentMatchers.any(), org.mockito.ArgumentMatchers.any());
var c = client(); c.setQuantityCapture(observer);
assertThat(c.queryPage(query(1, 1), context()).records()).hasSize(1);
@@ -83,7 +83,10 @@ class LingxingWmsClientTest {
}
@Test void cancelledLocateOmitsTimeAndStatusAndRejectsFuzzyMatch() throws Exception {
response.set(page("current", "size", 1, 1, 1, 1, ORDER.replace("100", "99").replace("2026-10-01 01:00:00", "")));
var result = client().locate(new LocateRequest("DO-DEMO", 1, 1), context());
var observer = org.mockito.Mockito.mock(LingxingQuantityCapture.class);
var c = client(); c.setQuantityCapture(observer);
var result = c.locate(new LocateRequest("DO-DEMO", 1, 1), context());
org.mockito.Mockito.verifyNoInteractions(observer);
var data = mapper.readTree(body.get()).path("data");
assertThat(data.size()).isEqualTo(3);
assertThat(data.has("status")).isFalse(); assertThat(data.has("startTime")).isFalse();