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();
@@ -1,22 +1,16 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import java.math.BigDecimal;
import java.time.Instant;
import java.util.List;
/** Diagnostic evidence only; never consumed by sales publication. */
public final class QuantityDiagnosticContract {
private QuantityDiagnosticContract() { }
public record Order(String diagnosticSessionId, String collectionSessionId, String rootTaskId,
String sourceAccount, String warehouseCode, String ownerCode, String orderNo,
Observation listObservation, Observation detailObservation, List<Sku> skus) { }
public record Observation(String status, String outboundTime, Instant observedAt, String attemptId,
Boolean skuSetComplete) { }
public record Sku(String sku, BigDecimal listQty, BigDecimal forecastQty, BigDecimal actualQty,
Boolean listPresent, Boolean detailPresent) { }
public enum Comparison {
SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE
}
public record Receipt(String outcome, Comparison comparison, String stopReason,
int acceptedOrders, int acceptedSkuRows, long acceptedBytes) { }
private QuantityDiagnosticContract() {}
public record Arm(String diagnosticSessionId,String sourceAccount,List<String> warehouseCodes,String ownerCode,String excludedRootTaskId) {}
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) {}
}
@@ -24,20 +24,35 @@ public class QuantityDiagnosticController {
this.json = json.copy().enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
.enable(DeserializationFeature.FAIL_ON_TRAILING_TOKENS);
}
@PostMapping(value = "/orders", consumes = "application/json")
public ApiResponse<QuantityDiagnosticContract.Receipt> accept(
@RequestHeader(value = "X-Internal-Service-Token", required = false) String token, @RequestBody byte[] body) {
@PostMapping(value="/arm",consumes="application/json")
public ApiResponse<QuantityDiagnosticContract.View> arm(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){
return run(()->service.arm(read(token,request,QuantityDiagnosticContract.Arm.class)));
}
@PostMapping(value="/status",consumes="application/json")
public ApiResponse<QuantityDiagnosticContract.View> status(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){
return run(()->service.status(read(token,request,QuantityDiagnosticContract.Status.class)));
}
@PostMapping(value="/observe",consumes="application/json")
public ApiResponse<QuantityDiagnosticContract.Receipt> observe(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){
return run(()->service.observe(read(token,request,QuantityDiagnosticContract.Event.class)));
}
@PostMapping(value="/stop",consumes="application/json")
public ApiResponse<QuantityDiagnosticContract.View> stop(@RequestHeader(value="X-Internal-Service-Token",required=false) String token,jakarta.servlet.http.HttpServletRequest request){
return run(()->service.stop(read(token,request,QuantityDiagnosticContract.Stop.class)));
}
private <T>T read(String token,jakarta.servlet.http.HttpServletRequest request,Class<T> type){
guard.verify(token);
if (body.length > QuantityDiagnosticPolicy.MAX_REQUEST_BYTES)
throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_REQUEST_TOO_LARGE");
QuantityDiagnosticContract.Order order;
try { order = json.readValue(body, QuantityDiagnosticContract.Order.class); }
catch (IOException e) { throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_JSON_INVALID"); }
try { return ApiResponse.success(service.accept(order)); }
catch (DataAccessException | TransactionException e) {
// No SQL, request payload or credentials are returned to the caller.
throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE, "DIAGNOSTIC_STORAGE_UNAVAILABLE");
}
int limit=QuantityDiagnosticPolicy.MAX_REQUEST_BYTES;
if(request.getContentLengthLong()>limit)throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_REQUEST_TOO_LARGE");
try {
byte[] body=request.getInputStream().readNBytes(limit+1);
if(body.length>limit)throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_REQUEST_TOO_LARGE");
return json.readValue(body,type);
}catch(IOException e){throw QuantityDiagnosticPolicy.invalid("DIAGNOSTIC_JSON_INVALID");}
}
private <T>ApiResponse<T> run(java.util.function.Supplier<T> action){
try{return ApiResponse.success(action.get());}
catch(DataAccessException|TransactionException e){throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"DIAGNOSTIC_STORAGE_UNAVAILABLE");}
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> rejected(ResponseStatusException error) {
@@ -1,89 +1,33 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.Instant;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.*;
import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException;
final class QuantityDiagnosticPolicy {
static final int MAX_ORDERS = 100, MAX_ROWS = 2000, MAX_ORDER_ROWS = 200;
static final int MAX_BYTES = 2 * 1024 * 1024, MAX_REQUEST_BYTES = 256 * 1024;
private QuantityDiagnosticPolicy() { }
static ResponseStatusException invalid(String code) {
return new ResponseStatusException(HttpStatus.BAD_REQUEST, code);
}
static void text(String value, int maximum) {
if (value == null || value.isBlank() || value.length() > maximum
|| value.codePoints().anyMatch(Character::isISOControl)) throw invalid("DIAGNOSTIC_FIELD_INVALID");
}
static void settings(QuantityDiagnosticProperties p) {
text(p.diagnosticSessionId(), 128); text(p.collectionSessionId(), 128);
text(p.rootTaskId(), 128); text(p.sourceAccount(), 64);
if (p.startsAt() == null || p.expiresAt() == null || !p.expiresAt().isAfter(p.startsAt())
|| Duration.between(p.startsAt(), p.expiresAt()).compareTo(Duration.ofMinutes(30)) > 0
|| p.warehouseCodes() == null || p.warehouseCodes().isEmpty() || p.warehouseCodes().size() > 20)
throw invalid("DIAGNOSTIC_CONFIGURATION_INVALID");
p.warehouseCodes().forEach(w -> text(w, 64));
}
static Order normalize(Order r, QuantityDiagnosticProperties p, Instant now) {
if (r == null) throw invalid("DIAGNOSTIC_ORDER_REQUIRED");
if (!p.diagnosticSessionId().equals(r.diagnosticSessionId())
|| !p.collectionSessionId().equals(r.collectionSessionId()) || !p.rootTaskId().equals(r.rootTaskId())
|| !p.sourceAccount().equals(r.sourceAccount()) || !p.warehouseCodes().contains(r.warehouseCode()))
throw invalid("DIAGNOSTIC_SCOPE_MISMATCH");
text(r.ownerCode(), 128); text(r.orderNo(), 128);
observation(r.listObservation(), p, now); observation(r.detailObservation(), p, now);
if (r.skus() == null || r.skus().isEmpty() || r.skus().size() > MAX_ORDER_ROWS)
throw invalid("DIAGNOSTIC_SKU_LIMIT");
var seen = new HashSet<String>();
for (Sku s : r.skus()) {
if (s == null) throw invalid("DIAGNOSTIC_SKU_REQUIRED");
text(s.sku(), 128);
if (!seen.add(s.sku())) throw invalid("DIAGNOSTIC_DUPLICATE_SKU");
if (s.listPresent() == null || s.detailPresent() == null || (!s.listPresent() && !s.detailPresent()))
throw invalid("DIAGNOSTIC_PRESENCE_INVALID");
if ((!s.listPresent() && s.listQty() != null)
|| (!s.detailPresent() && (s.forecastQty() != null || s.actualQty() != null)))
throw invalid("DIAGNOSTIC_ABSENT_QUANTITY");
quantity(s.listQty()); quantity(s.forecastQty()); quantity(s.actualQty());
}
List<Sku> skus = r.skus().stream().sorted(Comparator.comparing(Sku::sku))
.map(s -> new Sku(s.sku(), decimal(s.listQty()), decimal(s.forecastQty()), decimal(s.actualQty()),
s.listPresent(), s.detailPresent())).toList();
return new Order(r.diagnosticSessionId(), r.collectionSessionId(), r.rootTaskId(), r.sourceAccount(),
r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), r.detailObservation(), skus);
}
private static void observation(Observation o, QuantityDiagnosticProperties p, Instant now) {
if (o == null || o.observedAt() == null || o.skuSetComplete() == null)
throw invalid("DIAGNOSTIC_OBSERVATION_REQUIRED");
text(o.attemptId(), 128);
if (o.status() != null) text(o.status(), 32);
if (o.outboundTime() != null) text(o.outboundTime(), 32);
if (o.observedAt().isBefore(p.startsAt()) || o.observedAt().isAfter(p.expiresAt())
|| o.observedAt().isAfter(now)) throw invalid("DIAGNOSTIC_OBSERVATION_OUTSIDE_SESSION");
}
private static BigDecimal decimal(BigDecimal v) { return v == null ? null : v.stripTrailingZeros(); }
private static void quantity(BigDecimal v) {
if (v != null && (v.signum() < 0 || v.scale() > 6 || v.precision() - v.scale() > 32))
throw invalid("DIAGNOSTIC_QUANTITY_INVALID");
}
static boolean forecastDifference(Order r) {
return r.skus().stream().anyMatch(s -> s.forecastQty() != null && s.actualQty() != null
&& s.forecastQty().compareTo(s.actualQty()) != 0);
}
static Comparison compare(Order r) {
if (!r.listObservation().skuSetComplete() || !r.detailObservation().skuSetComplete()
|| r.skus().stream().anyMatch(s -> !s.listPresent() || !s.detailPresent()))
return Comparison.SKU_SET_MISMATCH;
if (r.skus().stream().anyMatch(s -> s.listQty() == null || s.forecastQty() == null || s.actualQty() == null))
return Comparison.QUANTITY_UNKNOWN;
if (forecastDifference(r)) return Comparison.FORECAST_ACTUAL_DIFFERENCE;
return r.skus().stream().allMatch(s -> s.listQty().compareTo(s.actualQty()) == 0)
? Comparison.ALL_EQUAL : Comparison.LIST_QUANTITY_DIFFERENCE;
}
static final int MAX_ORDERS=100,MAX_ROWS=2000,MAX_BYTES=2097152,MAX_REQUEST_BYTES=262144;
private QuantityDiagnosticPolicy() {}
static ResponseStatusException invalid(String code){return new ResponseStatusException(HttpStatus.BAD_REQUEST,code);}
static void require(boolean value,String code){if(!value)throw invalid(code);}
static void text(String s,int max){require(s!=null&&!s.isBlank()&&s.length()<=max&&s.codePoints().noneMatch(Character::isISOControl),"DIAGNOSTIC_FIELD_INVALID");}
static Event normalize(Event e,Instant now){
require(e!=null,"DIAGNOSTIC_EVENT_REQUIRED");text(e.diagnosticSessionId(),128);text(e.sourceAccount(),64);text(e.rootTaskId(),128);text(e.collectionSessionId(),128);text(e.warehouseCode(),64);text(e.ownerCode(),128);text(e.orderNo(),128);
text(e.producerId(),128);require("LIST".equals(e.side())||"DETAIL".equals(e.side()),"DIAGNOSTIC_SIDE_INVALID");require(("LIST".equals(e.side())?"OUTBOUND_QUERY":"OUTBOUND_DETAIL").equals(e.operation()),"DIAGNOSTIC_OPERATION_INVALID");require(e.observation()!=null&&e.observation().observedAt()!=null&&e.observation().skuSetComplete()!=null,"DIAGNOSTIC_OBSERVATION_REQUIRED");text(e.observation().attemptId(),128);
if(e.observation().status()!=null)text(e.observation().status(),32);if(e.observation().outboundTime()!=null)text(e.observation().outboundTime(),64);
require(!e.observation().observedAt().isAfter(now),"DIAGNOSTIC_FUTURE_OBSERVATION");require(e.skus()!=null&&(!e.skus().isEmpty()||!e.observation().skuSetComplete())&&e.skus().size()<=200,"DIAGNOSTIC_SKU_LIMIT");
var seen=new HashSet<String>();var skus=new ArrayList<Sku>();for(var s:e.skus()){require(s!=null,"DIAGNOSTIC_SKU_REQUIRED");text(s.sku(),128);require(seen.add(s.sku()),"DIAGNOSTIC_DUPLICATE_SKU");
for(var n:Arrays.asList(s.listQty(),s.forecastQty(),s.actualQty()))if(n!=null)require(n.signum()>=0&&n.scale()<=6&&n.precision()-n.scale()<=32,"DIAGNOSTIC_QUANTITY_INVALID");
require("LIST".equals(e.side())?s.forecastQty()==null&&s.actualQty()==null:s.listQty()==null,"DIAGNOSTIC_SIDE_FIELDS");skus.add(new Sku(s.sku(),decimal(s.listQty()),decimal(s.forecastQty()),decimal(s.actualQty())));
}skus.sort(Comparator.comparing(Sku::sku));return new Event(e.diagnosticSessionId(),e.sourceAccount(),e.producerId(),e.rootTaskId(),e.collectionSessionId(),e.operation(),e.warehouseCode(),e.ownerCode(),e.orderNo(),e.side(),e.observation(),List.copyOf(skus));
}
static BigDecimal decimal(BigDecimal n){return n==null?null:n.stripTrailingZeros();}
static boolean different(BigDecimal a,BigDecimal b){return a!=null&&b!=null&&a.compareTo(b)!=0;}
static String compare(Event list,Event detail){
var l=new TreeMap<String,Sku>();var d=new TreeMap<String,Sku>();list.skus().forEach(s->l.put(s.sku(),s));detail.skus().forEach(s->d.put(s.sku(),s));
boolean incomplete=!Boolean.TRUE.equals(list.observation().skuSetComplete())||!Boolean.TRUE.equals(detail.observation().skuSetComplete())||!l.keySet().equals(d.keySet());
var keys=new HashSet<String>(l.keySet());keys.addAll(d.keySet());boolean difference=false;
for(String key:keys){var a=l.get(key);var b=d.get(key);BigDecimal x=a==null?null:a.listQty(),y=b==null?null:b.forecastQty(),z=b==null?null:b.actualQty();difference|=different(x,y)||different(x,z)||different(y,z);incomplete|=x==null||y==null||z==null;}
return incomplete?"INCONCLUSIVE":difference?"QUANTITY_DIFFERENCE":"ALL_EQUAL";
}
}
@@ -1,11 +1,6 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import java.time.Instant;
import java.util.List;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
@ConfigurationProperties("baiyue.selfop.quantity-diagnostics")
public record QuantityDiagnosticProperties(@DefaultValue("false") boolean enabled,
String diagnosticSessionId, String collectionSessionId, String rootTaskId, String sourceAccount,
List<String> warehouseCodes, Instant startsAt, Instant expiresAt) { }
public record QuantityDiagnosticProperties(@DefaultValue("false") boolean enabled, String sourceAccount, List<String> warehouseCodes) {}
@@ -2,124 +2,218 @@ package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*;
import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticPolicy.*;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.zaxxer.hikari.HikariDataSource;
import jakarta.annotation.PreDestroy;
import javax.sql.DataSource;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Clock;
import java.time.Instant;
import java.util.HexFormat;
import java.util.List;
import java.time.*;
import java.util.*;
import java.util.concurrent.Semaphore;
import java.util.function.Supplier;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.core.BeanPropertyRowMapper;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.PlatformTransactionManager;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException;
/** One short transaction per paired order; only the two diagnostic tables are written. */
/** Serializes diagnostic state only; never locks or updates collection/business rows. */
@Service
public class QuantityDiagnosticService {
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
private final ObjectMapper json;
private final QuantityDiagnosticProperties properties;
private final QuantityDiagnosticProperties config;
private final Clock clock;
private final Semaphore admission = new Semaphore(1);
private HikariDataSource pool;
@Autowired
public QuantityDiagnosticService(JdbcTemplate jdbc, PlatformTransactionManager manager,
ObjectMapper json, QuantityDiagnosticProperties properties) {
this(jdbc, manager, json, properties, Clock.systemUTC());
}
QuantityDiagnosticService(JdbcTemplate jdbc, PlatformTransactionManager manager,
ObjectMapper json, QuantityDiagnosticProperties properties, Clock clock) {
this.jdbc = jdbc; this.json = json.copy(); this.properties = properties; this.clock = clock;
this.tx = new TransactionTemplate(manager); this.tx.setTimeout(3);
}
public Receipt accept(Order input) {
if (!properties.enabled()) return new Receipt("STOPPED", null, "DISABLED", 0, 0, 0);
settings(properties);
Order order = normalize(input, properties, clock.instant());
String payload = encode(order);
int bytes = payload.getBytes(StandardCharsets.UTF_8).length;
if (bytes > MAX_REQUEST_BYTES) throw invalid("DIAGNOSTIC_REQUEST_TOO_LARGE");
String key = hash(encode(List.of(order.sourceAccount(), order.warehouseCode(), order.ownerCode(), order.orderNo())));
String payloadHash = hash(payload);
String configuration = hash(encode(List.of("v1", properties.diagnosticSessionId(), properties.collectionSessionId(),
properties.rootTaskId(), properties.sourceAccount(), properties.warehouseCodes().stream().sorted().distinct().toList(),
properties.startsAt(), properties.expiresAt(), MAX_ORDERS, MAX_ROWS, MAX_BYTES)));
return tx.execute(status -> persist(order, key, payload, payloadHash, bytes, configuration));
}
private Receipt persist(Order order, String key, String payload, String payloadHash, int bytes, String configuration) {
String id = order.diagnosticSessionId();
// Duplicate insert acquires the same durable session lock, including first-request races.
jdbc.update("""
INSERT INTO ll_selfop_quantity_diagnostic_session
(diagnostic_session_id, configuration_hash, source_account, collection_session_id, root_task_id,
starts_at, expires_at, accepted_orders, accepted_sku_rows, accepted_bytes)
VALUES (?, ?, ?, ?, ?, ?, ?, 0, 0, 0)
ON DUPLICATE KEY UPDATE diagnostic_session_id = diagnostic_session_id
""", id, configuration, order.sourceAccount(), order.collectionSessionId(), order.rootTaskId(),
properties.startsAt().toString(), properties.expiresAt().toString());
Session session = jdbc.queryForObject("""
SELECT configuration_hash, accepted_orders, accepted_sku_rows, accepted_bytes, stop_reason
FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id = ? FOR UPDATE
""", (rs, n) -> new Session(rs.getString(1), rs.getInt(2), rs.getInt(3), rs.getLong(4), rs.getString(5)), id);
if (session == null || !configuration.equals(session.configurationHash()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "DIAGNOSTIC_SESSION_CONFIGURATION_CHANGED");
var existing = jdbc.query("""
SELECT payload_hash, comparison FROM ll_selfop_quantity_diagnostic_order
WHERE diagnostic_session_id = ? AND order_key_hash = ?
""", (rs, n) -> new Existing(rs.getString(1), Comparison.valueOf(rs.getString(2))), id, key);
if (!existing.isEmpty()) {
if (!payloadHash.equals(existing.get(0).payloadHash()))
throw new ResponseStatusException(HttpStatus.CONFLICT, "DIAGNOSTIC_ORDER_CONFLICT");
return session.receipt("DUPLICATE", existing.get(0).comparison(), session.stopReason());
public QuantityDiagnosticService(DataSource source, ObjectMapper json, QuantityDiagnosticProperties config) {
this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
this.config=config; this.clock=Clock.systemUTC();
// Separate, bounded connection acquisition. Diagnostics cannot exhaust the collection pool.
if(config.enabled()) {
try {
HikariDataSource base=source.unwrap(HikariDataSource.class);
pool=new HikariDataSource(); pool.setJdbcUrl(base.getJdbcUrl()); pool.setUsername(base.getUsername());
pool.setPassword(base.getPassword()); pool.setDataSourceProperties(base.getDataSourceProperties());
pool.setMaximumPoolSize(1); pool.setMinimumIdle(0); pool.setConnectionTimeout(250);
pool.setValidationTimeout(250); pool.setInitializationFailTimeout(-1); pool.setPoolName("quantity-diagnostic");
} catch(Exception unavailable) { if(pool!=null)pool.close(); pool=null; }
}
if (session.stopReason() != null) return session.receipt("STOPPED", null, session.stopReason());
Instant now = clock.instant();
if (now.isBefore(properties.startsAt())) return session.receipt("STOPPED", null, "NOT_STARTED");
if (!now.isBefore(properties.expiresAt())) return stop(id, session, "EXPIRED");
if (session.orders() >= MAX_ORDERS || session.rows() + order.skus().size() > MAX_ROWS
|| session.bytes() + bytes > MAX_BYTES) return stop(id, session, "LIMIT_REACHED");
Comparison comparison = compare(order);
jdbc.update("""
INSERT INTO ll_selfop_quantity_diagnostic_order
(diagnostic_session_id, order_key_hash, source_account, warehouse_code, owner_code, order_no,
payload_hash, comparison, sku_rows, payload_bytes, evidence_json, received_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", id, key, order.sourceAccount(), order.warehouseCode(), order.ownerCode(), order.orderNo(),
payloadHash, comparison.name(), order.skus().size(), bytes, payload, now.toString());
int orders = session.orders() + 1, rows = session.rows() + order.skus().size();
long totalBytes = session.bytes() + bytes;
String reason = forecastDifference(order) ? "FORECAST_ACTUAL_DIFFERENCE"
: orders == MAX_ORDERS || rows == MAX_ROWS || totalBytes == MAX_BYTES ? "LIMIT_REACHED" : null;
int updated = jdbc.update("""
UPDATE ll_selfop_quantity_diagnostic_session
SET accepted_orders = ?, accepted_sku_rows = ?, accepted_bytes = ?, stop_reason = ?
WHERE diagnostic_session_id = ?
""", orders, rows, totalBytes, reason, id);
if (updated != 1) throw new IllegalStateException("DIAGNOSTIC_SESSION_UPDATE_FAILED");
return new Receipt("ACCEPTED", comparison, reason, orders, rows, totalBytes);
jdbc=pool==null?null:new JdbcTemplate(pool);
tx=pool==null?null:new TransactionTemplate(new DataSourceTransactionManager(pool));
if(tx!=null) { tx.setTimeout(3); jdbc.setQueryTimeout(2); }
}
private Receipt stop(String id, Session session, String reason) {
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET stop_reason = ? WHERE diagnostic_session_id = ?", reason, id);
return session.receipt("STOPPED", null, reason);
QuantityDiagnosticService(JdbcTemplate jdbc,PlatformTransactionManager manager,ObjectMapper json,
QuantityDiagnosticProperties config,Clock clock) {
this.jdbc=jdbc;this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
this.config=config;this.clock=clock;tx=new TransactionTemplate(manager);tx.setTimeout(3);
}
private String encode(Object value) {
try { return json.writeValueAsString(value); }
catch (JsonProcessingException e) { throw invalid("DIAGNOSTIC_ENCODING_FAILED"); }
@PreDestroy public void close(){if(pool!=null)pool.close();}
private <T>T guarded(Supplier<T> action){
require(config.enabled(),"DIAGNOSTIC_DISABLED");
if(tx==null)throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"DIAGNOSTIC_DATASOURCE_UNAVAILABLE");
if(!admission.tryAcquire())throw new ResponseStatusException(HttpStatus.SERVICE_UNAVAILABLE,"DIAGNOSTIC_BUSY");
try{return tx.execute(s->action.get());}finally{admission.release();}
}
private static String hash(String value) {
try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8))); }
catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); }
private void account(String account){text(account,64);require(account.equals(config.sourceAccount()),"DIAGNOSTIC_ACCOUNT_REJECTED");}
public View arm(Arm request){
require(request!=null,"DIAGNOSTIC_ARM_REQUIRED");text(request.diagnosticSessionId(),128);account(request.sourceAccount());
text(request.ownerCode(),128);if(request.excludedRootTaskId()!=null)text(request.excludedRootTaskId(),128);
require(request.warehouseCodes()!=null&&!request.warehouseCodes().isEmpty()&&request.warehouseCodes().size()<=8,"DIAGNOSTIC_SCOPE_REJECTED");
request.warehouseCodes().forEach(w->text(w,64));
require(config.warehouseCodes()!=null&&config.warehouseCodes().containsAll(request.warehouseCodes()),"DIAGNOSTIC_SCOPE_REJECTED");
Arm scope=new Arm(request.diagnosticSessionId(),request.sourceAccount(),request.warehouseCodes().stream().sorted().distinct().toList(),request.ownerCode(),request.excludedRootTaskId());
return guarded(()->{
Session existing=load(scope.diagnosticSessionId());
if(existing!=null){require(existing.scope.equals(scope),"DIAGNOSTIC_ARM_CONFLICT");advance(existing);save(existing);return view(existing);}
// Read-only arm snapshot: unfinished automatic roots that have actually started.
var current=jdbc.queryForList("SELECT id FROM ll_selfop_sync_task WHERE source_account=? AND task_role='ACCOUNT_DISCOVERY' AND task_type='BACKFILL' AND execution_purpose='BUSINESS' AND requested_by='SELFOP_CONTINUOUS_PLANNER' AND (status IN ('RUNNING','RETRY_WAIT') OR (status='READY' AND attempt_count>0)) ORDER BY id LIMIT 2",String.class,scope.sourceAccount());
require(current.size()<=1,"DIAGNOSTIC_CURRENT_ROOT_AMBIGUOUS");
String excluded=current.isEmpty()?null:current.get(0);
require(Objects.equals(excluded,scope.excludedRootTaskId()),"DIAGNOSTIC_CURRENT_ROOT_CHANGED");
require(excluded==null||eligible(root(excluded),scope.sourceAccount()),"DIAGNOSTIC_EXCLUDED_ROOT_INVALID");
var active=jdbc.queryForList("SELECT diagnostic_session_id FROM ll_selfop_quantity_diagnostic_session WHERE source_account=? AND active_slot=1",String.class,scope.sourceAccount());
if(!active.isEmpty()){Session s=load(active.get(0));advance(s);save(s);require(terminal(s),"DIAGNOSTIC_ALREADY_ARMED");}
Session s=new Session();s.scope=scope;s.armedAt=clock.instant();s.bindBefore=s.armedAt.plusSeconds(21600);s.expiresAt=s.armedAt.plusSeconds(36000);s.state="ARMED";
// Account+active_slot unique constraint is the cross-instance arm arbitration point.
jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_session(diagnostic_session_id,configuration_hash,source_account,starts_at,expires_at,active_slot,state_json) VALUES(?,?,?,?,?,1,?)",scope.diagnosticSessionId(),hash(encode(scope)),scope.sourceAccount(),s.armedAt.toString(),s.expiresAt.toString(),encode(s));
return view(s);
});
}
private record Existing(String payloadHash, Comparison comparison) { }
private record Session(String configurationHash, int orders, int rows, long bytes, String stopReason) {
Receipt receipt(String outcome, Comparison comparison, String reason) {
return new Receipt(outcome, comparison, reason, orders, rows, bytes);
}
public View status(Status request){
require(request!=null,"DIAGNOSTIC_STATUS_REQUIRED");account(request.sourceAccount());text(request.producerId(),128);
return guarded(()->{
var ids=jdbc.queryForList("SELECT diagnostic_session_id FROM ll_selfop_quantity_diagnostic_session WHERE source_account=? AND state_json IS NOT NULL ORDER BY active_slot DESC, starts_at DESC LIMIT 1",String.class,request.sourceAccount());
if(ids.isEmpty())return null;
Session s=load(ids.get(0));advance(s);
if(!terminal(s)){
if(s.producerId==null)s.producerId=request.producerId();
else if(!s.producerId.equals(request.producerId())){
require(request.attach(),"DIAGNOSTIC_PRODUCER_FENCED");
// Recovery retains persisted candidates, but cannot claim the lost in-memory tail was complete.
s.deliveryUncertain=true;s.producerId=request.producerId();
}
if("DRAINING".equals(s.state)&&request.drained())finish(s);
}
save(s);return view(s);
});
}
public View stop(Stop request){
require(request!=null,"DIAGNOSTIC_STOP_REQUIRED");account(request.sourceAccount());text(request.diagnosticSessionId(),128);text(request.producerId(),128);
require(Set.of("INCOMPLETE","QUEUE_FULL","OBSERVER_FAILED","SHUTDOWN").contains(request.reason()),"DIAGNOSTIC_STOP_REASON");
return guarded(()->{Session s=load(request.diagnosticSessionId());require(s!=null&&s.scope.sourceAccount().equals(request.sourceAccount()),"DIAGNOSTIC_SESSION_UNKNOWN");
require(Objects.equals(s.producerId,request.producerId()),"DIAGNOSTIC_PRODUCER_FENCED");
if(!terminal(s)){s.deliveryUncertain=true;end(s,"INCOMPLETE");}save(s);return view(s);});
}
public Receipt observe(Event input){
Event e=normalize(input,clock.instant());account(e.sourceAccount());String payload=encode(e);int bytes=size(payload);
require(bytes<=MAX_REQUEST_BYTES,"DIAGNOSTIC_REQUEST_TOO_LARGE");
return guarded(()->{
Session s=load(e.diagnosticSessionId());require(s!=null&&s.scope.sourceAccount().equals(e.sourceAccount()),"DIAGNOSTIC_SESSION_UNKNOWN");
String key=hash(encode(List.of(e.sourceAccount(),e.warehouseCode(),e.ownerCode(),e.orderNo())));
var completed=jdbc.query("SELECT evidence_json,comparison FROM ll_selfop_quantity_diagnostic_order WHERE diagnostic_session_id=? AND order_key_hash=?",(r,n)->new String[]{r.getString(1),r.getString(2)},e.diagnosticSessionId(),key);
if(!completed.isEmpty()){
Pair pair=decode(completed.get(0)[0],Pair.class);Event original="LIST".equals(e.side())?pair.list:pair.detail;
require(encode(original).equals(payload),"DIAGNOSTIC_OBSERVATION_CONFLICT");
return new Receipt("DUPLICATE",completed.get(0)[1],view(s));
}
var pending=jdbc.query("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_pending WHERE diagnostic_session_id=? AND order_key_hash=?",(r,n)->decode(r.getString(1),Event.class),e.diagnosticSessionId(),key);
if("LIST".equals(e.side())&&!pending.isEmpty()){
require(encode(pending.get(0)).equals(payload),"DIAGNOSTIC_OBSERVATION_CONFLICT");return new Receipt("DUPLICATE",null,view(s));
}
// Session lock is the receiver acceptance boundary. Integration enqueue is not acceptance.
advance(s);
if(terminal(s)){save(s);return new Receipt("STOPPED",null,view(s));}
require(Objects.equals(s.producerId,e.producerId()),"DIAGNOSTIC_PRODUCER_FENCED");
require(!e.observation().observedAt().isBefore(s.armedAt),"DIAGNOSTIC_OLD_OBSERVATION");
require(s.scope.warehouseCodes().contains(e.warehouseCode())&&("*".equals(s.scope.ownerCode())||s.scope.ownerCode().equals(e.ownerCode())),"DIAGNOSTIC_SCOPE_REJECTED");
require(("selfop-"+e.rootTaskId()).equals(e.collectionSessionId()),"DIAGNOSTIC_COLLECTION_SESSION_REJECTED");
SelfopSyncTask task=root(e.rootTaskId());require(eligible(task,e.sourceAccount()),"DIAGNOSTIC_ROOT_REJECTED");
if(s.rootTaskId==null){
if(!"LIST".equals(e.side())||e.rootTaskId().equals(s.scope.excludedRootTaskId())||!clock.instant().isBefore(s.bindBefore)||!e.observation().observedAt().isBefore(s.bindBefore))return new Receipt("IGNORED",null,view(s));
s.rootTaskId=e.rootTaskId();s.collectionSessionId=e.collectionSessionId();s.boundAt=e.observation().observedAt();
s.expiresAt=s.boundAt.plusSeconds(14400).isBefore(s.expiresAt)?s.boundAt.plusSeconds(14400):s.expiresAt;s.state="BOUND";
advance(s);
if(terminal(s)){save(s);return new Receipt("STOPPED",null,view(s));}
}
require(s.rootTaskId.equals(e.rootTaskId())&&s.collectionSessionId.equals(e.collectionSessionId()),"DIAGNOSTIC_ROOT_FROZEN");
if(ended(task)&&!"DRAINING".equals(s.state)){s.state="DRAINING";s.rootEndedAt=clock.instant();}
if("LIST".equals(e.side())&&("DRAINING".equals(s.state)||s.candidates>=MAX_ORDERS)){save(s);return new Receipt("IGNORED",null,view(s));}
if("DETAIL".equals(e.side())&&pending.isEmpty()){save(s);return new Receipt("IGNORED",null,view(s));}
if(s.rootEndedAt!=null&&e.observation().observedAt().isAfter(s.rootEndedAt)){save(s);return new Receipt("IGNORED",null,view(s));}
// Retained bytes/rows are monotonic and count both observations; promotion never refunds quota.
int overhead="LIST".equals(e.side())?20:0;
if(s.rows+e.skus().size()>MAX_ROWS||s.bytes+bytes+overhead>MAX_BYTES){end(s,"INCOMPLETE");save(s);return new Receipt("STOPPED",null,view(s));}
s.rows+=e.skus().size();s.bytes+=bytes+overhead;
String comparison=null;
if("LIST".equals(e.side())){
s.candidates++;
jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_pending(diagnostic_session_id,order_key_hash,warehouse_code,owner_code,order_no,evidence_json) VALUES(?,?,?,?,?,?)",e.diagnosticSessionId(),key,e.warehouseCode(),e.ownerCode(),e.orderNo(),payload);
if(!Boolean.TRUE.equals(e.observation().skuSetComplete())||e.skus().stream().anyMatch(v->v.listQty()==null)){comparison="INCONCLUSIVE";end(s,comparison);}
}else{
Event list=pending.get(0);comparison=compare(list,e);String evidence=encode(new Pair(list,e));s.paired++;
require(size(evidence)<=MAX_REQUEST_BYTES,"DIAGNOSTIC_PAIR_TOO_LARGE");
jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_order(diagnostic_session_id,order_key_hash,source_account,warehouse_code,owner_code,order_no,payload_hash,comparison,sku_rows,payload_bytes,evidence_json,received_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)",e.diagnosticSessionId(),key,e.sourceAccount(),e.warehouseCode(),e.ownerCode(),e.orderNo(),hash(evidence),comparison,Math.max(list.skus().size(),e.skus().size()),size(evidence),evidence,clock.instant().toString());
jdbc.update("DELETE FROM ll_selfop_quantity_diagnostic_pending WHERE diagnostic_session_id=? AND order_key_hash=?",e.diagnosticSessionId(),key);
if(!"ALL_EQUAL".equals(comparison))end(s,comparison);
else if(s.paired==MAX_ORDERS)end(s,s.deliveryUncertain?"INCOMPLETE":"COMPLETED");
}
save(s);return new Receipt("ACCEPTED",comparison,view(s));
});
}
private void advance(Session s){
if(terminal(s))return;
Instant now=clock.instant();
if(!now.isBefore(s.expiresAt)){end(s,"INCOMPLETE");return;}
if(s.rootTaskId==null){if(!now.isBefore(s.bindBefore))end(s,"BIND_TIMEOUT");return;}
if(ended(root(s.rootTaskId))&&!"DRAINING".equals(s.state)){s.state="DRAINING";s.rootEndedAt=now;}
}
private void finish(Session s){end(s,s.deliveryUncertain||s.candidates!=s.paired?"INCOMPLETE":s.paired<MAX_ORDERS?"ROOT_ENDED_INSUFFICIENT":"COMPLETED");}
private static boolean terminal(Session s){return "TERMINAL".equals(s.state);}
private static void end(Session s,String reason){s.state="TERMINAL";s.reason=reason;}
private static boolean ended(SelfopSyncTask t){return t==null||Set.of("SUCCEEDED","DEAD","FAILED","ABORTED","CANCELLED").contains(t.getStatus());}
private static boolean eligible(SelfopSyncTask t,String account){return t!=null&&account.equals(t.getSourceAccount())&&"ACCOUNT_DISCOVERY".equals(t.getTaskRole())&&SelfopCollectionPolicy.continuousBusinessRoot(t);}
private SelfopSyncTask root(String id){
require(id!=null&&id.matches("[0-9]{1,19}"),"DIAGNOSTIC_ROOT_ID_INVALID");
var rows=jdbc.query("SELECT id,source_account,execution_purpose,upstream_task_id,validation_root_task_id,requested_by,scope_hash,request_key,task_role,task_type,discovery_scope_id,query_from_utc,query_until_utc,query_scope_json,status FROM ll_selfop_sync_task WHERE id=?",new BeanPropertyRowMapper<>(SelfopSyncTask.class),id);
return rows.isEmpty()?null:rows.get(0);
}
private Session load(String id){
var sessions=jdbc.query("SELECT state_json,root_task_id,collection_session_id,starts_at,expires_at,accepted_orders,accepted_sku_rows,accepted_bytes,stop_reason FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id=? FOR UPDATE",(r,n)->{
String metadata=r.getString(1);require(metadata!=null,"DIAGNOSTIC_HISTORICAL_ID_READ_ONLY");Session s=decode(metadata,Session.class);
// Existing columns remain the sole authority for counters, root, deadline and stop reason.
s.rootTaskId=r.getString(2);s.collectionSessionId=r.getString(3);s.boundAt=s.rootTaskId==null?null:Instant.parse(r.getString(4));s.expiresAt=Instant.parse(r.getString(5));
s.paired=r.getInt(6);s.rows=r.getInt(7);s.bytes=r.getLong(8);s.reason=r.getString(9);
s.state=s.reason!=null?"TERMINAL":s.rootTaskId==null?"ARMED":s.rootEndedAt!=null?"DRAINING":"BOUND";return s;
},id);return sessions.isEmpty()?null:sessions.get(0);
}
private void save(Session s){jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET state_json=?,active_slot=?,collection_session_id=?,root_task_id=?,starts_at=?,expires_at=?,accepted_orders=?,accepted_sku_rows=?,accepted_bytes=?,stop_reason=? WHERE diagnostic_session_id=?",encode(s),terminal(s)?null:1,s.collectionSessionId,s.rootTaskId,(s.boundAt==null?s.armedAt:s.boundAt).toString(),s.expiresAt.toString(),s.paired,s.rows,s.bytes,s.reason,s.scope.diagnosticSessionId());}
private View view(Session s){
var selected=jdbc.query("SELECT warehouse_code,owner_code,order_no,0 AS paired FROM ll_selfop_quantity_diagnostic_pending WHERE diagnostic_session_id=? UNION ALL SELECT warehouse_code,owner_code,order_no,1 AS paired FROM ll_selfop_quantity_diagnostic_order WHERE diagnostic_session_id=?",(r,n)->new Key(r.getString(1),r.getString(2),r.getString(3),r.getBoolean(4)),s.scope.diagnosticSessionId(),s.scope.diagnosticSessionId());
return new View(s.scope.diagnosticSessionId(),s.scope.sourceAccount(),s.rootTaskId,s.collectionSessionId,s.state,s.reason,s.boundAt==null?s.armedAt:s.boundAt,s.expiresAt,s.bindBefore,s.scope.warehouseCodes(),s.scope.ownerCode(),s.scope.excludedRootTaskId(),s.candidates,s.paired,s.rows,s.bytes,s.deliveryUncertain,selected);
}
private String encode(Object value){try{return json.writeValueAsString(value);}catch(Exception e){throw invalid("DIAGNOSTIC_ENCODING_FAILED");}}
private <T>T decode(String value,Class<T> type){try{return json.readValue(value,type);}catch(Exception e){throw new IllegalStateException("DIAGNOSTIC_STORAGE_INVALID");}}
private static int size(String s){return s.getBytes(StandardCharsets.UTF_8).length;}
private static String hash(String s){try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(s.getBytes(StandardCharsets.UTF_8)));}catch(Exception e){throw new IllegalStateException(e);}}
public record Pair(Event list,Event detail){}
public static class Session {
public Arm scope;public String producerId;
public Instant armedAt,bindBefore,rootEndedAt;
public int candidates;public boolean deliveryUncertain;
@com.fasterxml.jackson.annotation.JsonIgnore public String state,reason,rootTaskId,collectionSessionId;
@com.fasterxml.jackson.annotation.JsonIgnore public Instant boundAt,expiresAt;
@com.fasterxml.jackson.annotation.JsonIgnore public int paired,rows;
@com.fasterxml.jackson.annotation.JsonIgnore public long bytes;
}
}
@@ -2,16 +2,20 @@
-- Run only after explicit TEST SQL approval. Not executed by service startup.
CREATE TABLE ll_selfop_quantity_diagnostic_session (
diagnostic_session_id VARCHAR(128) NOT NULL COMMENT '固定有界数量诊断会话标识',
configuration_hash CHAR(64) NOT NULL COMMENT '冻结范围、UTC起止时间及限额的SHA256摘要',
configuration_hash CHAR(64) NOT NULL COMMENT '冻结授权范围的SHA256摘要',
source_account VARCHAR(64) NOT NULL COMMENT '来源账号别名,不保存凭据',
collection_session_id VARCHAR(128) NOT NULL COMMENT '被观察的来源采集会话标识',
root_task_id VARCHAR(128) NOT NULL COMMENT '被观察的账号采集根任务标识',
collection_session_id VARCHAR(128) NULL COMMENT '被观察的来源采集会话标识',
root_task_id VARCHAR(128) NULL COMMENT '被观察的账号采集根任务标识',
starts_at VARCHAR(40) NOT NULL COMMENT '诊断开始时刻,ISO8601 UTC字符串',
expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,距开始最多30分钟,ISO8601 UTC字符串',
expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,首个LIST观测后最多4小时且授权后最多10小时,ISO8601 UTC字符串',
accepted_orders INT NOT NULL DEFAULT 0 COMMENT '已接收完整配对订单数,跨重启累计',
accepted_sku_rows INT NOT NULL DEFAULT 0 COMMENT '已接收归并SKU行数,跨重启累计',
accepted_sku_rows INT NOT NULL DEFAULT 0 COMMENT '两侧已保留SKU行数,跨重启累计',
accepted_bytes BIGINT NOT NULL DEFAULT 0 COMMENT '已持久化白名单规范JSON的UTF8字节数',
stop_reason VARCHAR(64) NULL COMMENT '诊断终止原因,空值表示尚未终止',
active_slot INT NULL COMMENT '当前授权占位,终态释放;旧证据不自动授权',
state_json LONGTEXT NULL COMMENT '新授权的持久生命周期;NULL仅表示未参与此协议的历史证据',
UNIQUE KEY uq_qdiag_active (source_account, active_slot),
CONSTRAINT chk_qdiag_active CHECK (active_slot IS NULL OR active_slot = 1),
PRIMARY KEY (diagnostic_session_id),
CONSTRAINT chk_selfop_qdiag_limits CHECK (accepted_orders BETWEEN 0 AND 100
AND accepted_sku_rows BETWEEN 0 AND 2000 AND accepted_bytes BETWEEN 0 AND 2097152)
@@ -26,7 +30,7 @@ CREATE TABLE ll_selfop_quantity_diagnostic_order (
order_no VARCHAR(128) NOT NULL COMMENT '来源出库单号',
payload_hash CHAR(64) NOT NULL COMMENT '白名单规范载荷SHA256摘要,用于幂等校验',
comparison VARCHAR(40) NOT NULL COMMENT 'SKU集合与三类数量比较结果',
sku_rows INT NOT NULL COMMENT '本单归并后的SKU并集行数',
sku_rows INT NOT NULL COMMENT '本单两侧较大的SKU行数;两侧累计额度由会话约束',
payload_bytes INT NOT NULL COMMENT '本单白名单规范JSON的UTF8字节数',
evidence_json JSON NOT NULL COMMENT '类型化列表与详情观测及SKU三量证据,不含原始响应或收件信息',
received_at VARCHAR(40) NOT NULL COMMENT '接收时刻,ISO8601 UTC字符串',
@@ -36,3 +40,14 @@ CREATE TABLE ll_selfop_quantity_diagnostic_order (
CONSTRAINT chk_selfop_qdiag_order_limits CHECK (sku_rows BETWEEN 1 AND 200
AND payload_bytes BETWEEN 1 AND 262144)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='独立配对订单数量诊断证据,不参与销量发布';
CREATE TABLE ll_selfop_quantity_diagnostic_pending (
diagnostic_session_id VARCHAR(128) NOT NULL,
order_key_hash CHAR(64) NOT NULL,
warehouse_code VARCHAR(64) NOT NULL,
owner_code VARCHAR(128) NOT NULL,
order_no VARCHAR(128) NOT NULL,
evidence_json LONGTEXT NOT NULL,
PRIMARY KEY(diagnostic_session_id,order_key_hash),
FOREIGN KEY(diagnostic_session_id) REFERENCES ll_selfop_quantity_diagnostic_session(diagnostic_session_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='首次LIST白名单证据;配对后同事务移至最终证据表';
@@ -1,40 +1,168 @@
openapi: 3.0.3
info:
title: Internal bounded self-operated quantity diagnostics
version: 1.0.0
title: Internal next-root bounded quantity diagnostics
version: 2.0.0
description: Explicit arm only; no automatic rearm. Bind next normal automatic historical OUTBOUND_QUERY root
within 6h. Sampling expires 4h after first LIST observedAt, never later than armedAt+10h. Lastleg session lock
is receiver acceptance. Source root creation may predate arm. LOCATE, manual/validation roots, current excluded
root and different bound root are rejected/ignored. At most 100 distinct LIST candidates total, 2000 retained
SKU rows across both sides, 2MiB canonical evidence plus promotion overhead. Pending-to-final never refunds
quota. First unknown or SKU mismatch persists INCONCLUSIVE and stops; any known inequality among list/forecast/actual
persists QUANTITY_DIFFERENCE and stops. Original side timestamps/attempts are immutable; exact replay returns
stored receipt even after termination, changed same-side evidence is rejected. Root termination drains only
accepted candidates via an explicit Integration FIFO boundary without extending expiry. Fewer than 100 complete
pairs is ROOT_ENDED_INSUFFICIENT; unpaired/lost tail is INCOMPLETE. Producer restart retains persisted candidates
but marks delivery uncertain. Every request/response is stream-bounded to 262144 bytes. Never exposed through
Gateway, never creates supplier requests or changes business facts.
paths:
/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders:
/internal/lastleg/self-operated-analytics/quantity-diagnostics/arm:
post:
operationId: receiveSelfopQuantityDiagnosticOrder
description: >-
Internal service-token only; never routed through Gateway. One complete paired order per request.
Quantities are evidence only. Session scope and UTC time interval are frozen by server configuration.
Maximum 100 orders, 2000 SKU rows and 2097152 canonical UTF8 bytes per session;
200 SKU rows and 262144 wire/canonical bytes per request; at most 30 minutes.
First known forecast/actual difference closes capture regardless of comparison classification.
Same identity and canonical evidence is idempotent, including after closure; changed evidence is 409.
operationId: quantityDiagnosticArm
security:
- internalServiceToken: []
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PairedOrder'
$ref: '#/components/schemas/Arm'
responses:
'200':
description: Diagnostic receipt in the standard ApiResponse wrapper; code is string 200.
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code: { type: string, example: '200' }
data: { $ref: '#/components/schemas/Receipt' }
'400': { description: 'Invalid JSON, unknown fields, invalid scope/evidence, duplicate SKU or request limit.' }
'401': { description: Invalid internal service token. }
'409': { description: Session configuration changed or order identity reused with different evidence. }
'503': { description: Internal service token unconfigured or diagnostic storage unavailable. }
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/View'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
/internal/lastleg/self-operated-analytics/quantity-diagnostics/status:
post:
operationId: quantityDiagnosticStatus
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Status'
responses:
'200':
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/View'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
/internal/lastleg/self-operated-analytics/quantity-diagnostics/observe:
post:
operationId: quantityDiagnosticObserve
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Event'
responses:
'200':
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/Receipt'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
/internal/lastleg/self-operated-analytics/quantity-diagnostics/stop:
post:
operationId: quantityDiagnosticStop
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/Stop'
responses:
'200':
description: ApiResponse code 200. status data is null if no grant exists.
content:
application/json:
schema:
type: object
additionalProperties: false
properties:
code:
type: string
message:
type: string
nullable: true
data:
$ref: '#/components/schemas/View'
nullable: true
required:
- code
- data
'400':
description: Invalid scope, changed frozen evidence, invalid observation or disabled feature.
'401':
description: Missing or invalid internal service token.
'503':
description: Diagnostic admission, dedicated connection or storage unavailable; collection is unaffected.
components:
securitySchemes:
internalServiceToken:
@@ -42,64 +170,365 @@ components:
in: header
name: X-Internal-Service-Token
schemas:
PairedOrder:
Arm:
type: object
additionalProperties: false
required: [diagnosticSessionId, collectionSessionId, rootTaskId, sourceAccount, warehouseCode, ownerCode, orderNo, listObservation, detailObservation, skus]
properties:
diagnosticSessionId: { type: string, maxLength: 128 }
collectionSessionId: { type: string, maxLength: 128 }
rootTaskId: { type: string, maxLength: 128 }
sourceAccount: { type: string, maxLength: 64 }
warehouseCode: { type: string, maxLength: 64 }
ownerCode: { type: string, maxLength: 128 }
orderNo: { type: string, maxLength: 128 }
listObservation: { $ref: '#/components/schemas/Observation' }
detailObservation: { $ref: '#/components/schemas/Observation' }
skus:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
warehouseCodes:
type: array
minItems: 1
maxItems: 200
description: Union of list/detail SKUs; duplicate source lines aggregated, unknown never zero-filled.
items: { $ref: '#/components/schemas/Sku' }
maxItems: 8
uniqueItems: true
items:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
description: Explicit owner code, or * for all allowed owners.
excludedRootTaskId:
type: string
maxLength: 128
minLength: 1
nullable: true
description: Must match the actual started, unfinished automatic root at arm; null only when none exists.
required:
- diagnosticSessionId
- sourceAccount
- warehouseCodes
- ownerCode
Status:
type: object
additionalProperties: false
properties:
sourceAccount:
type: string
maxLength: 64
minLength: 1
producerId:
type: string
maxLength: 128
minLength: 1
attach:
type: boolean
description: True only on Integration process attachment; replacement marks delivery uncertain and fences
prior producer.
drained:
type: boolean
description: Only after observing DRAINING, closing intake and draining previously queued callbacks.
required:
- sourceAccount
- producerId
- attach
- drained
Stop:
type: object
additionalProperties: false
properties:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
producerId:
type: string
maxLength: 128
minLength: 1
reason:
type: string
enum:
- INCOMPLETE
- QUEUE_FULL
- OBSERVER_FAILED
- SHUTDOWN
required:
- diagnosticSessionId
- sourceAccount
- producerId
- reason
Observation:
type: object
additionalProperties: false
required: [observedAt, attemptId, skuSetComplete]
properties:
status: { type: string, nullable: true, maxLength: 32 }
status:
type: string
maxLength: 32
minLength: 1
nullable: true
outboundTime:
type: string
maxLength: 64
minLength: 1
nullable: true
maxLength: 32
description: Original Asia/Shanghai source text, null if absent; not reinterpreted as another timezone.
observedAt: { type: string, format: date-time }
attemptId: { type: string, maxLength: 128 }
skuSetComplete: { type: boolean }
observedAt:
type: string
format: date-time
attemptId:
type: string
maxLength: 128
minLength: 1
skuSetComplete:
type: boolean
required:
- status
- outboundTime
- observedAt
- attemptId
- skuSetComplete
Sku:
type: object
additionalProperties: false
required: [sku, listPresent, detailPresent]
properties:
sku: { type: string, maxLength: 128 }
listQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 }
forecastQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 }
actualQty: { type: number, nullable: true, minimum: 0, multipleOf: 0.000001 }
listPresent: { type: boolean }
detailPresent: { type: boolean }
Receipt:
type: object
required: [outcome, acceptedOrders, acceptedSkuRows, acceptedBytes]
properties:
outcome: { type: string, enum: [ACCEPTED, DUPLICATE, STOPPED] }
comparison:
sku:
type: string
maxLength: 128
minLength: 1
listQty:
type: number
nullable: true
enum: [SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE]
minimum: 0
description: Up to 32 integer digits, at most 6 decimal places; unknown stays null.
forecastQty:
type: number
nullable: true
minimum: 0
description: Up to 32 integer digits, at most 6 decimal places; unknown stays null.
actualQty:
type: number
nullable: true
minimum: 0
description: Up to 32 integer digits, at most 6 decimal places; unknown stays null.
required:
- sku
- listQty
- forecastQty
- actualQty
Key:
type: object
additionalProperties: false
properties:
warehouseCode:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
orderNo:
type: string
maxLength: 128
minLength: 1
paired:
type: boolean
required:
- warehouseCode
- ownerCode
- orderNo
- paired
Event:
type: object
additionalProperties: false
properties:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
producerId:
type: string
maxLength: 128
minLength: 1
rootTaskId:
type: string
maxLength: 128
minLength: 1
collectionSessionId:
type: string
maxLength: 128
minLength: 1
operation:
type: string
enum:
- OUTBOUND_QUERY
- OUTBOUND_DETAIL
warehouseCode:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
orderNo:
type: string
maxLength: 128
minLength: 1
side:
type: string
enum:
- LIST
- DETAIL
observation:
$ref: '#/components/schemas/Observation'
skus:
type: array
maxItems: 200
items:
$ref: '#/components/schemas/Sku'
description: Empty allowed only when skuSetComplete=false; persists inconclusive evidence.
required:
- diagnosticSessionId
- sourceAccount
- producerId
- rootTaskId
- collectionSessionId
- operation
- warehouseCode
- ownerCode
- orderNo
- side
- observation
- skus
View:
type: object
additionalProperties: false
properties:
diagnosticSessionId:
type: string
maxLength: 128
minLength: 1
sourceAccount:
type: string
maxLength: 64
minLength: 1
rootTaskId:
type: string
maxLength: 128
minLength: 1
nullable: true
collectionSessionId:
type: string
maxLength: 128
minLength: 1
nullable: true
state:
type: string
enum:
- ARMED
- BOUND
- DRAINING
- TERMINAL
stopReason:
type: string
nullable: true
enum: [DISABLED, NOT_STARTED, EXPIRED, LIMIT_REACHED, FORECAST_ACTUAL_DIFFERENCE]
acceptedOrders: { type: integer }
acceptedSkuRows: { type: integer }
acceptedBytes: { type: integer, format: int64, description: Canonical whitelist JSON UTF8 bytes persisted in this session. }
startsAt:
type: string
format: date-time
nullable: true
expiresAt:
type: string
format: date-time
nullable: true
bindBefore:
type: string
format: date-time
nullable: true
warehouseCodes:
type: array
maxItems: 8
items:
type: string
maxLength: 64
minLength: 1
ownerCode:
type: string
maxLength: 128
minLength: 1
excludedRootTaskId:
type: string
maxLength: 128
minLength: 1
nullable: true
candidates:
type: integer
minimum: 0
maximum: 100
pairedOrders:
type: integer
minimum: 0
maximum: 100
retainedRows:
type: integer
minimum: 0
maximum: 2000
retainedBytes:
type: integer
minimum: 0
maximum: 2097152
deliveryUncertain:
type: boolean
selected:
type: array
maxItems: 100
items:
$ref: '#/components/schemas/Key'
required:
- diagnosticSessionId
- sourceAccount
- rootTaskId
- collectionSessionId
- state
- stopReason
- startsAt
- expiresAt
- bindBefore
- warehouseCodes
- ownerCode
- excludedRootTaskId
- candidates
- pairedOrders
- retainedRows
- retainedBytes
- deliveryUncertain
- selected
Receipt:
type: object
additionalProperties: false
properties:
outcome:
type: string
enum:
- ACCEPTED
- IGNORED
- DUPLICATE
- STOPPED
comparison:
type: string
nullable: true
enum:
- ALL_EQUAL
- QUANTITY_DIFFERENCE
- INCONCLUSIVE
session:
$ref: '#/components/schemas/View'
required:
- outcome
- comparison
- session
@@ -1,47 +1,27 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.nio.charset.StandardCharsets;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
import org.springframework.mock.web.MockHttpServletRequest;
class QuantityDiagnosticControllerTest {
@Test void httpStatusContractSurvivesSharedBusinessErrorAdvice() throws Exception {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper());
var mvc = org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller)
.setControllerAdvice(new com.baiyue.common.web.GlobalExceptionHandler()).build();
String path = "/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders";
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path)
.contentType("application/json").content("{}"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized());
mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post(path)
.header("X-Internal-Service-Token", "test-token").contentType("application/json").content("{\"address\":1}"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isBadRequest());
}
@Test void rejectsInvalidTokenBeforeParsingOrStorage() {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper());
assertThatThrownBy(() -> controller.accept(null, new byte[0])).hasMessageContaining("401");
assertThatThrownBy(() -> controller.accept("wrong", new byte[0])).hasMessageContaining("401");
verifyNoInteractions(service);
}
@Test void missingTokenConfigurationFailsClosed() {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard(""), service, new ObjectMapper());
assertThatThrownBy(() -> controller.accept("any", new byte[0])).hasMessageContaining("503");
verifyNoInteractions(service);
}
@Test void rejectsNonWhitelistedFieldsTrailingDocumentsAndOversizedPayload() {
var service = mock(QuantityDiagnosticService.class);
var controller = new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"), service, new ObjectMapper());
assertThatThrownBy(() -> controller.accept("test-token", "{\"address\":\"not allowed\"}".getBytes(StandardCharsets.UTF_8)))
.hasMessageContaining("DIAGNOSTIC_JSON_INVALID");
assertThatThrownBy(() -> controller.accept("test-token", "{} {}".getBytes(StandardCharsets.UTF_8)))
.hasMessageContaining("DIAGNOSTIC_JSON_INVALID");
assertThatThrownBy(() -> controller.accept("test-token", new byte[262145])).hasMessageContaining("DIAGNOSTIC_REQUEST_TOO_LARGE");
verifyNoInteractions(service);
}
@Test void everyRouteRequiresInternalTokenBeforeParsing()throws Exception{
var service=mock(QuantityDiagnosticService.class);var controller=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper());
var mvc=org.springframework.test.web.servlet.setup.MockMvcBuilders.standaloneSetup(controller).setControllerAdvice(new com.baiyue.common.web.GlobalExceptionHandler()).build();
for(String action:new String[]{"arm","status","observe","stop"})mvc.perform(org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post("/internal/lastleg/self-operated-analytics/quantity-diagnostics/"+action).contentType("application/json").content("{}"))
.andExpect(org.springframework.test.web.servlet.result.MockMvcResultMatchers.status().isUnauthorized());
verifyNoInteractions(service);
}
@Test void strictWhitelistAndBoundedBody(){
var service=mock(QuantityDiagnosticService.class);var c=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper());
for(String body:new String[]{"{\"address\":1}","{} {}"}){var request=new MockHttpServletRequest();request.setContent(body.getBytes(java.nio.charset.StandardCharsets.UTF_8));assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("JSON_INVALID");}
var request=new MockHttpServletRequest();request.setContent(new byte[262145]);assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("REQUEST_TOO_LARGE");verifyNoInteractions(service);
}
@Test void streamIsLimitedEvenWithoutContentLength()throws Exception{
var service=mock(QuantityDiagnosticService.class);var c=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper());
var request=mock(jakarta.servlet.http.HttpServletRequest.class);when(request.getContentLengthLong()).thenReturn(-1L);
var count=new java.util.concurrent.atomic.AtomicInteger();var stream=new jakarta.servlet.ServletInputStream(){public int read(){count.incrementAndGet();return 32;}public boolean isFinished(){return false;}public boolean isReady(){return true;}public void setReadListener(jakarta.servlet.ReadListener listener){}};
when(request.getInputStream()).thenReturn(stream);assertThatThrownBy(()->c.observe("test-token",request)).hasMessageContaining("REQUEST_TOO_LARGE");assertThat(count.get()).isEqualTo(262145);verifyNoInteractions(service);
}
}
@@ -1,5 +1,4 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagnosticContract.*;
import static org.assertj.core.api.Assertions.*;
import com.fasterxml.jackson.databind.ObjectMapper;
@@ -12,158 +11,133 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.core.io.ClassPathResource;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.jdbc.datasource.DataSourceTransactionManager;
import org.springframework.jdbc.datasource.DriverManagerDataSource;
import org.springframework.web.server.ResponseStatusException;
import org.springframework.jdbc.datasource.*;
class QuantityDiagnosticServiceTest {
private final Instant start = Instant.parse("2026-10-13T15:00:00Z");
private final ObjectMapper json = new ObjectMapper().findAndRegisterModules();
private JdbcTemplate jdbc;
private DataSourceTransactionManager manager;
private QuantityDiagnosticService service;
private QuantityDiagnosticProperties properties;
@BeforeEach void prepare() throws Exception {
var ds = new DriverManagerDataSource("jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000", "sa", "");
jdbc = new JdbcTemplate(ds); manager = new DataSourceTransactionManager(ds);
// Exercise the dedicated production DDL, omitting only MySQL table options/comments.
String ddl;
try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) {
ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
ddl = ddl.replaceAll("(?m)^--.*$", "").replaceAll("COMMENT\\s*=?\\s*'[^']*'", "")
.replaceAll("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin", "");
for (String statement : ddl.split(";")) if (!statement.isBlank()) jdbc.execute(statement);
properties = config(start.plusSeconds(1800), true);
service = instance(properties, start.plusSeconds(10));
}
private QuantityDiagnosticProperties config(Instant expires, boolean enabled) {
return new QuantityDiagnosticProperties(enabled, "diag-1", "collection-1", "root-1", "account-1",
List.of("warehouse-1"), start, expires);
}
private QuantityDiagnosticService instance(QuantityDiagnosticProperties p, Instant now) {
return new QuantityDiagnosticService(jdbc, manager, json, p, Clock.fixed(now, ZoneOffset.UTC));
}
private Sku sku(String code, String list, String forecast, String actual) {
return new Sku(code, list == null ? null : new BigDecimal(list), forecast == null ? null : new BigDecimal(forecast),
actual == null ? null : new BigDecimal(actual), true, true);
}
private Order order(String no, Sku... skus) {
return new Order("diag-1", "collection-1", "root-1", "account-1", "warehouse-1", "owner-1", no,
new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(1), "list-1", true),
new Observation("100", "2026-10-06 12:00:00", start.plusSeconds(2), "detail-1", true), List.of(skus));
}
@Test void firstDifferencePersistsEvidenceAndStopsFurtherOrdersIncludingAfterRestart() {
var request = order("order-1", sku("sku-1", "10", "10", "8"));
var result = service.accept(request);
assertThat(result.outcome()).isEqualTo("ACCEPTED");
assertThat(result.comparison()).isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE);
assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE");
service = instance(properties, start.plusSeconds(20));
assertThat(service.accept(request).outcome()).isEqualTo("DUPLICATE");
assertThat(service.accept(order("order-2", sku("sku-1", "1", "1", "1"))).outcome()).isEqualTo("STOPPED");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1);
}
@Test void listMatchingActualStillRecordsForecastDifference() {
assertThat(service.accept(order("order-1", sku("sku", "8", "10", "8"))).comparison())
.isEqualTo(Comparison.FORECAST_ACTUAL_DIFFERENCE);
}
@Test void idempotencyCanonicalizesDecimalsAndRowOrderButRejectsChangedEvidence() {
service.accept(order("order-1", sku("b", "1.0", "1", "1"), sku("a", "2", "2", "2")));
var result = service.accept(order("order-1", sku("a", "2.00", "2", "2"), sku("b", "1", "1", "1")));
assertThat(result.outcome()).isEqualTo("DUPLICATE");
assertThat(result.acceptedOrders()).isEqualTo(1);
assertThatThrownBy(() -> service.accept(order("order-1", sku("a", "3", "3", "3"))))
.isInstanceOf(ResponseStatusException.class).hasMessageContaining("409");
}
@Test void skuReplacementCannotBeHiddenByEqualTotalsAndDifferenceStillStops() {
var result = service.accept(order("order-1",
new Sku("old", BigDecimal.TEN, null, null, true, false),
new Sku("new", null, BigDecimal.TEN, BigDecimal.valueOf(8), false, true)));
assertThat(result.comparison()).isEqualTo(Comparison.SKU_SET_MISMATCH);
assertThat(result.stopReason()).isEqualTo("FORECAST_ACTUAL_DIFFERENCE");
}
@Test void missingQuantityRemainsUnknown() {
var result = service.accept(order("order-1", sku("sku", null, "1", "1")));
assertThat(result.comparison()).isEqualTo(Comparison.QUANTITY_UNKNOWN);
assertThat(result.stopReason()).isNull();
}
@Test void duplicateSkusAndCrossGenerationAreRejectedWithoutWrites() {
assertThatThrownBy(() -> service.accept(order("order-1", sku("sku", "1", "1", "1"), sku("sku", "1", "1", "1"))))
.hasMessageContaining("DIAGNOSTIC_DUPLICATE_SKU");
Order r = order("order-1", sku("sku", "1", "1", "1"));
assertThatThrownBy(() -> service.accept(new Order(r.diagnosticSessionId(), "other-generation", r.rootTaskId(), r.sourceAccount(),
r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), r.detailObservation(), r.skus())))
.hasMessageContaining("DIAGNOSTIC_SCOPE_MISMATCH");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero();
}
@Test void expiredAndReconfiguredSessionsCannotResetCounters() {
var r = order("order-1", sku("sku", "1", "1", "1")); service.accept(r);
var expired = instance(properties, start.plusSeconds(1800));
assertThat(expired.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("EXPIRED");
assertThat(expired.accept(r).outcome()).isEqualTo("DUPLICATE");
assertThatThrownBy(() -> instance(config(start.plusSeconds(1799), true), start.plusSeconds(20)).accept(r))
.hasMessageContaining("DIAGNOSTIC_SESSION_CONFIGURATION_CHANGED");
}
@Test void disabledRequiresNoTablesOrValidSession() {
jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_order");
jdbc.execute("DROP TABLE ll_selfop_quantity_diagnostic_session");
assertThat(instance(config(start.plusSeconds(1800), false), start).accept(null).stopReason()).isEqualTo("DISABLED");
}
@Test void concurrentRequestsCannotExceedHundredOrders() throws Exception {
var pool = Executors.newFixedThreadPool(4);
try {
List<Callable<Receipt>> jobs = new ArrayList<>();
for (int i = 0; i < 104; i++) { final int n = i; jobs.add(() -> service.accept(order("order-" + n, sku("sku", "1", "1", "1")))); }
var results = pool.invokeAll(jobs);
int accepted = 0;
for (var result : results) if (result.get().outcome().equals("ACCEPTED")) accepted++;
assertThat(accepted).isEqualTo(100);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(100);
assertThat(instance(properties, start.plusSeconds(20)).accept(order("after-restart", sku("sku", "1", "1", "1"))).stopReason())
.isEqualTo("LIMIT_REACHED");
} finally { pool.shutdownNow(); }
}
@Test void rowLimitAndFailedOrderInsertDoNotPartiallyAdvanceCounters() {
service.accept(order("order-1", sku("sku", "1", "1", "1")));
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=2000");
assertThat(service.accept(order("order-2", sku("sku", "1", "1", "1"))).stopReason()).isEqualTo("LIMIT_REACHED");
assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isEqualTo(1);
}
@Test void byteLimitCannotBeResetByRestartOrPartiallySaveOrder() {
service.accept(order("order-1", sku("sku", "1", "1", "1")));
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_bytes=2097150");
var receipt = instance(properties, start.plusSeconds(20)).accept(order("order-2", sku("sku", "1", "1", "1")));
assertThat(receipt.stopReason()).isEqualTo("LIMIT_REACHED");
assertThat(receipt.acceptedBytes()).isEqualTo(2097150);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order", Integer.class)).isEqualTo(1);
}
@Test void dedicatedDdlHasChineseCommentsOnEveryTableAndOrdinaryColumn() throws Exception {
String ddl;
try (var in = new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()) {
ddl = new String(in.readAllBytes(), StandardCharsets.UTF_8);
}
var fields = java.util.regex.Pattern.compile("(?m)^ [a-z][a-z_]+ (?:VARCHAR|CHAR|INT|BIGINT|JSON)\\b[^\\n]*").matcher(ddl);
int count = 0;
while (fields.find()) {
assertThat(fields.group()).matches("(?s).*COMMENT '[^']*[\\u4e00-\\u9fff][^']*'.*"); count++;
}
assertThat(count).isEqualTo(23);
assertThat(java.util.regex.Pattern.compile("COMMENT='[^']*[\\u4e00-\\u9fff][^']*';").matcher(ddl).results().count()).isEqualTo(2);
}
@Test void storageFailureRollsBackSessionCounterAndSessionCreation() {
jdbc.execute("ALTER TABLE ll_selfop_quantity_diagnostic_order ADD CONSTRAINT reject_test_order CHECK(order_no <> 'bad-order')");
assertThatThrownBy(() -> service.accept(order("bad-order", sku("sku", "1", "1", "1"))))
.isInstanceOf(org.springframework.dao.DataIntegrityViolationException.class);
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_session", Integer.class)).isZero();
}
@Test void cancellationAndMissingOutboundTimeRemainEvidenceOnly() {
var r = order("cancelled", sku("sku", "1", "1", "1"));
var cancelled = new Observation("cancelled", null, start.plusSeconds(2), "detail-cancel", true);
var result = service.accept(new Order(r.diagnosticSessionId(), r.collectionSessionId(), r.rootTaskId(), r.sourceAccount(),
r.warehouseCode(), r.ownerCode(), r.orderNo(), r.listObservation(), cancelled, r.skus()));
assertThat(result.comparison()).isEqualTo(Comparison.ALL_EQUAL);
assertThat(result.acceptedOrders()).isEqualTo(1);
}
final Instant start=Instant.parse("2026-10-14T00:00:00Z");
final ObjectMapper json=new ObjectMapper().findAndRegisterModules();
JdbcTemplate jdbc;DataSourceTransactionManager manager;QuantityDiagnosticService service;MutableClock clock;
final QuantityDiagnosticProperties config=new QuantityDiagnosticProperties(true,"account",List.of("W1","W2"));
@BeforeEach void setup()throws Exception{
var ds=new DriverManagerDataSource("jdbc:h2:mem:"+UUID.randomUUID()+";MODE=MySQL;DB_CLOSE_DELAY=-1;LOCK_TIMEOUT=2000","sa","");jdbc=new JdbcTemplate(ds);manager=new DataSourceTransactionManager(ds);clock=new MutableClock(start);
String ddl;try(var in=new ClassPathResource("db/selfop-quantity-diagnostics.sql").getInputStream()){ddl=new String(in.readAllBytes(),StandardCharsets.UTF_8);}
ddl=ddl.replaceAll("(?m)^--.*$","").replaceAll("COMMENT\\s*=?\\s*'[^']*'", "").replace("evidence_json JSON", "evidence_json LONGTEXT").replace("ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin","");for(String sql:ddl.split(";"))if(!sql.isBlank())jdbc.execute(sql);
jdbc.execute("CREATE TABLE ll_selfop_sync_task(id BIGINT PRIMARY KEY,source_account VARCHAR(64),execution_purpose VARCHAR(32),upstream_task_id BIGINT,validation_root_task_id BIGINT,requested_by VARCHAR(64),scope_hash VARCHAR(64),request_key VARCHAR(128),task_role VARCHAR(32),task_type VARCHAR(32),discovery_scope_id BIGINT,query_from_utc TIMESTAMP,query_until_utc TIMESTAMP,query_scope_json VARCHAR(2000),status VARCHAR(32),attempt_count INT DEFAULT 0)");
for(long id=1;id<=3;id++)jdbc.update("INSERT INTO ll_selfop_sync_task VALUES(?,?,?,NULL,NULL,?,?,?,?,?,?,?, ?,?,?,0)",id,"account","BUSINESS","SELFOP_CONTINUOUS_PLANNER","a".repeat(64),"continuous:outbound:2026-10-10:"+"a".repeat(12),"ACCOUNT_DISCOVERY","BACKFILL",1L,LocalDateTime.parse("2026-10-09T16:00:00"),LocalDateTime.parse("2026-10-10T16:00:00"),"{\"startTime\":\"2026-10-10T00:00\",\"endTime\":\"2026-10-11T00:00\"}",id==1?"RUNNING":"READY");
service=instance();service.arm(arm("diag"));service.status(new Status("account","producer",true,false));clock.now=start.plusSeconds(10);
}
QuantityDiagnosticService instance(){return new QuantityDiagnosticService(jdbc,manager,json,config,clock);}
Arm arm(String id){return new Arm(id,"account",List.of("W1","W2"),"*","1");}
Event event(String root,String order,String side,BigDecimal list,BigDecimal forecast,BigDecimal actual){return new Event("diag","account","producer",root,"selfop-"+root,"LIST".equals(side)?"OUTBOUND_QUERY":"OUTBOUND_DETAIL","W1","owner",order,side,new Observation("100",null,clock.instant(),"attempt",true),List.of(new Sku("SKU",list,forecast,actual)));}
Event list(String order){return event("2",order,"LIST",BigDecimal.TEN,null,null);}
Event detail(String order){return event("2",order,"DETAIL",null,BigDecimal.TEN,BigDecimal.TEN);}
View status(){return service.status(new Status("account","producer",false,false));}
@Test void explicitArmIsIdempotentAndBindsOnlyNextEligibleList(){
assertThat(service.arm(arm("diag")).startsAt()).isEqualTo(start);
assertThat(service.observe(detail("early")).outcome()).isEqualTo("IGNORED");
assertThat(service.observe(event("1","old","LIST",BigDecimal.ONE,null,null)).outcome()).isEqualTo("IGNORED");
Event locate=list("locate");assertThatThrownBy(()->service.observe(new Event(locate.diagnosticSessionId(),locate.sourceAccount(),locate.producerId(),locate.rootTaskId(),locate.collectionSessionId(),"OUTBOUND_LOCATE",locate.warehouseCode(),locate.ownerCode(),locate.orderNo(),locate.side(),locate.observation(),locate.skus()))).hasMessageContaining("OPERATION");
View bound=service.observe(list("a")).session();assertThat(bound.rootTaskId()).isEqualTo("2");assertThat(bound.expiresAt()).isEqualTo(clock.instant().plusSeconds(14400));
assertThatThrownBy(()->service.observe(event("3","other","LIST",BigDecimal.ONE,null,null))).hasMessageContaining("ROOT_FROZEN");
assertThatThrownBy(()->service.arm(arm("second"))).hasMessageContaining("ALREADY_ARMED");
}
@Test void durablePendingSurvivesReceiverRestartAndPreservesOriginalAttempt(){
Event original=list("a");service.observe(original);service=instance();clock.now=start.plusSeconds(5000);
assertThat(service.observe(original).outcome()).isEqualTo("DUPLICATE");
var result=service.observe(detail("a"));assertThat(result.comparison()).isEqualTo("ALL_EQUAL");assertThat(result.session().candidates()).isEqualTo(1);
String payload=jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_order",String.class);assertThat(payload).contains("attempt").contains("00:00:10Z");
}
@Test void anyKnownInequalityStopsIncludingListDifferenceAndTerminalReplay(){
Event l=list("a");service.observe(l);Event d=event("2","a","DETAIL",null,BigDecimal.ONE,BigDecimal.ONE);
var result=service.observe(d);assertThat(result.comparison()).isEqualTo("QUANTITY_DIFFERENCE");assertThat(result.session().state()).isEqualTo("TERMINAL");
assertThat(instance().observe(d).outcome()).isEqualTo("DUPLICATE");assertThat(service.observe(list("b")).outcome()).isEqualTo("STOPPED");
}
@Test void unknownListStopsAndRetainsEvidence(){
var receipt=service.observe(event("2","a","LIST",null,null,null));assertThat(receipt.comparison()).isEqualTo("INCONCLUSIVE");assertThat(receipt.session().stopReason()).isEqualTo("INCONCLUSIVE");
assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isEqualTo(1);
}
@Test void missingSkuAndUnknownDetailStopAtomically(){
service.observe(list("a"));Event d=detail("a");var mismatch=new Event(d.diagnosticSessionId(),d.sourceAccount(),d.producerId(),d.rootTaskId(),d.collectionSessionId(),d.operation(),d.warehouseCode(),d.ownerCode(),d.orderNo(),d.side(),d.observation(),List.of(new Sku("OTHER",null,null,BigDecimal.TEN)));
assertThat(service.observe(mismatch).comparison()).isEqualTo("INCONCLUSIVE");assertThat(status().state()).isEqualTo("TERMINAL");assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_order",Integer.class)).isEqualTo(1);
}
@Test void lateCallbackCannotBindEvenWhenObservationWasBeforeDeadline(){
Event e=list("a");clock.now=start.plusSeconds(21600);assertThat(service.observe(e).session().stopReason()).isEqualTo("BIND_TIMEOUT");assertThat(status().rootTaskId()).isNull();
}
@Test void latestPossibleBindStillCannotExtendAbsoluteTenHours(){
clock.now=start.plusSeconds(21599);assertThat(service.observe(list("a")).session().expiresAt()).isEqualTo(start.plusSeconds(35999));
clock.now=start.plusSeconds(36000);assertThat(service.observe(detail("a")).session().stopReason()).isEqualTo("INCOMPLETE");
}
@Test void rootEndDrainsOnlyAcceptedCandidatesAndNeverExtendsDeadline(){
service.observe(list("a"));Instant expires=status().expiresAt();jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=2");
assertThat(status().state()).isEqualTo("DRAINING");assertThat(service.observe(list("new")).outcome()).isEqualTo("IGNORED");
assertThat(service.observe(detail("a")).comparison()).isEqualTo("ALL_EQUAL");
View done=service.status(new Status("account","producer",false,true));assertThat(done.stopReason()).isEqualTo("ROOT_ENDED_INSUFFICIENT");assertThat(done.expiresAt()).isEqualTo(expires);
}
@Test void producerRestartMarksLostTailUncertainButRestoresPending(){
service.observe(list("a"));View recovered=service.status(new Status("account","new-producer",true,false));assertThat(recovered.deliveryUncertain()).isTrue();assertThat(recovered.selected()).hasSize(1);
Event d=detail("a");var resumed=new Event(d.diagnosticSessionId(),d.sourceAccount(),"new-producer",d.rootTaskId(),d.collectionSessionId(),d.operation(),d.warehouseCode(),d.ownerCode(),d.orderNo(),d.side(),d.observation(),d.skus());assertThat(service.observe(resumed).comparison()).isEqualTo("ALL_EQUAL");
jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=2");assertThat(service.status(new Status("account","new-producer",false,true)).stopReason()).isEqualTo("INCOMPLETE");
}
@Test void candidatesAreCumulativeAndNeverReplaced(){
for(int i=0;i<100;i++)service.observe(list("o"+i));assertThat(service.observe(list("extra")).outcome()).isEqualTo("IGNORED");
for(int i=0;i<100;i++)service.observe(detail("o"+i));View done=status();assertThat(done.candidates()).isEqualTo(100);assertThat(done.pairedOrders()).isEqualTo(100);assertThat(done.retainedRows()).isEqualTo(200);assertThat(done.stopReason()).isEqualTo("COMPLETED");
}
@Test void changedAttemptCannotOverwriteFirstSide(){
Event a=list("a");service.observe(a);clock.now=start.plusSeconds(20);assertThatThrownBy(()->service.observe(list("a"))).hasMessageContaining("OBSERVATION_CONFLICT");assertThat(status().candidates()).isEqualTo(1);
}
@Test void concurrentReceiversSerializeSameCandidate()throws Exception{
var other=instance();Event e=list("a");var executor=Executors.newFixedThreadPool(2);try{var one=executor.submit(()->service.observe(e));var two=executor.submit(()->other.observe(e));assertThat(List.of(one.get().outcome(),two.get().outcome())).containsExactlyInAnyOrder("ACCEPTED","DUPLICATE");}finally{executor.shutdownNow();}assertThat(status().candidates()).isEqualTo(1);
}
@Test void businessRootPolicyIsAuthoritative(){
jdbc.update("UPDATE ll_selfop_sync_task SET requested_by='manual' WHERE id=2");assertThatThrownBy(()->service.observe(list("a"))).hasMessageContaining("ROOT_REJECTED");assertThat(status().rootTaskId()).isNull();
}
@Test void delayedFirstObservationDoesNotExtendSamplingWindow(){
Event first=list("a");clock.now=start.plusSeconds(5000);View bound=service.observe(first).session();assertThat(bound.startsAt()).isEqualTo(first.observation().observedAt());assertThat(bound.expiresAt()).isEqualTo(start.plusSeconds(14410));
}
@Test void endedOrChangedRootCannotBePresentedAsCurrent(){
service.stop(new Stop("diag","account","producer","INCOMPLETE"));jdbc.update("UPDATE ll_selfop_sync_task SET status='SUCCEEDED' WHERE id=1");
assertThatThrownBy(()->service.arm(arm("next"))).hasMessageContaining("CURRENT_ROOT_CHANGED");
assertThat(service.arm(new Arm("next","account",List.of("W1"),"*",null)).excludedRootTaskId()).isNull();
}
@Test void ambiguousCurrentRootsRefuseArm(){
service.stop(new Stop("diag","account","producer","INCOMPLETE"));jdbc.update("UPDATE ll_selfop_sync_task SET status='RUNNING' WHERE id=2");
assertThatThrownBy(()->service.arm(arm("next"))).hasMessageContaining("CURRENT_ROOT_AMBIGUOUS");
}
@Test void diagnosticDatasourceInitializationFailureDoesNotBreakServiceConstruction(){
var unavailable=new QuantityDiagnosticService(new DriverManagerDataSource(),json,config);
assertThatThrownBy(()->unavailable.status(new Status("account","p",true,false))).hasMessageContaining("DIAGNOSTIC_DATASOURCE_UNAVAILABLE");unavailable.close();
}
@Test void fourHourExpiredDelayedFirstListIsNotAccepted(){
Event first=list("a");clock.now=start.plusSeconds(15000);var r=service.observe(first);assertThat(r.outcome()).isEqualTo("STOPPED");assertThat(r.session().candidates()).isZero();
}
@Test void rowAndByteCapsNeverRefundOrAcceptOverLimit()throws Exception{
service.observe(list("a"));jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=2000");
assertThat(service.observe(detail("a")).session().stopReason()).isEqualTo("INCOMPLETE");assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isEqualTo(1);
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_session SET accepted_sku_rows=1,accepted_bytes=2097152,stop_reason=NULL,active_slot=1");
assertThat(service.observe(detail("a")).outcome()).isEqualTo("STOPPED");assertThat(status().retainedBytes()).isEqualTo(2097152);
}
@Test void concurrentLastCandidateCannotExceedOneHundred()throws Exception{
for(int i=0;i<99;i++)service.observe(list("o"+i));var other=instance();var pool=Executors.newFixedThreadPool(2);
try{var a=pool.submit(()->service.observe(list("last-a")));var b=pool.submit(()->other.observe(list("last-b")));assertThat(List.of(a.get().outcome(),b.get().outcome())).containsExactlyInAnyOrder("ACCEPTED","IGNORED");}finally{pool.shutdownNow();}assertThat(status().candidates()).isEqualTo(100);
}
@Test void admissionRejectsBeforeSecondConnectionAcquisition()throws Exception{
var tm=org.mockito.Mockito.mock(org.springframework.transaction.PlatformTransactionManager.class);var entered=new CountDownLatch(1);var release=new CountDownLatch(1);
org.mockito.Mockito.when(tm.getTransaction(org.mockito.ArgumentMatchers.any())).thenAnswer(call->{entered.countDown();release.await(2,TimeUnit.SECONDS);return new org.springframework.transaction.support.SimpleTransactionStatus();});
var bounded=new QuantityDiagnosticService(jdbc,tm,json,config,clock);var pool=Executors.newSingleThreadExecutor();
try{var first=pool.submit(()->bounded.status(new Status("account","producer",false,false)));assertThat(entered.await(1,TimeUnit.SECONDS)).isTrue();assertThatThrownBy(()->bounded.status(new Status("account","producer",false,false))).hasMessageContaining("DIAGNOSTIC_BUSY");release.countDown();first.get();org.mockito.Mockito.verify(tm,org.mockito.Mockito.times(1)).getTransaction(org.mockito.ArgumentMatchers.any());}finally{release.countDown();pool.shutdownNow();}
}
@org.junit.jupiter.params.ParameterizedTest
@org.junit.jupiter.params.provider.ValueSource(strings={"10","8"})
void forecastTenActualEightStopsRegardlessOfListMeaning(String listQuantity){
service.observe(event("2","a","LIST",new BigDecimal(listQuantity),null,null));assertThat(service.observe(event("2","a","DETAIL",null,BigDecimal.TEN,new BigDecimal("8"))).comparison()).isEqualTo("QUANTITY_DIFFERENCE");
}
@Test void historicalEvidenceRemainsUntouchedAndCannotRearmItsIdentifier(){
jdbc.update("INSERT INTO ll_selfop_quantity_diagnostic_session(diagnostic_session_id,configuration_hash,source_account,collection_session_id,root_task_id,starts_at,expires_at,accepted_orders,accepted_sku_rows,accepted_bytes,stop_reason) VALUES('old',?,'account','old-session','1',?,?,24,25,1234,'EXPIRED')","a".repeat(64),start.toString(),start.toString());
assertThatThrownBy(()->service.arm(arm("old"))).hasMessageContaining("HISTORICAL_ID_READ_ONLY");assertThat(status().diagnosticSessionId()).isEqualTo("diag");assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session WHERE diagnostic_session_id='old'",Integer.class)).isEqualTo(24);
}
@Test void existingStopReasonAndCountersAreTheOnlyPersistedFacts(){
service.observe(list("a"));service.observe(event("2","a","DETAIL",null,BigDecimal.TEN,BigDecimal.ONE));
assertThat(jdbc.queryForObject("SELECT stop_reason FROM ll_selfop_quantity_diagnostic_session",String.class)).isEqualTo("QUANTITY_DIFFERENCE");assertThat(jdbc.queryForObject("SELECT accepted_orders FROM ll_selfop_quantity_diagnostic_session",Integer.class)).isEqualTo(1);
String metadata=jdbc.queryForObject("SELECT state_json FROM ll_selfop_quantity_diagnostic_session",String.class);assertThat(metadata).doesNotContain("\"reason\"","\"paired\"","\"bytes\"","\"expiresAt\"");assertThat(instance().status(new Status("account","producer",false,false)).stopReason()).isEqualTo("QUANTITY_DIFFERENCE");
}
static class MutableClock extends Clock {Instant now;MutableClock(Instant now){this.now=now;}public ZoneId getZone(){return ZoneOffset.UTC;}public Clock withZone(ZoneId zone){return this;}public Instant instant(){return now;}}
}