feat(selfop): add bounded independent quantity diagnostic capture

This commit is contained in:
2026-10-13 23:40:44 +08:00
parent 54f79b3902
commit 3d3507a770
18 changed files with 1190 additions and 1 deletions
@@ -0,0 +1,8 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(QuantityDiagnosticProperties.class)
public class QuantityDiagnosticConfiguration { }
@@ -0,0 +1,22 @@
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) { }
}
@@ -0,0 +1,52 @@
package com.baiyue.lastleg.selfoperated.analytics.diagnostic;
import com.baiyue.common.core.api.ApiResponse;
import com.baiyue.common.core.api.ResultCode;
import com.baiyue.lastleg.security.InternalServiceTokenGuard;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.io.IOException;
import org.springframework.dao.DataAccessException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.transaction.TransactionException;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
@RestController
@RequestMapping("/internal/lastleg/self-operated-analytics/quantity-diagnostics")
public class QuantityDiagnosticController {
private final InternalServiceTokenGuard guard;
private final QuantityDiagnosticService service;
private final ObjectMapper json;
public QuantityDiagnosticController(InternalServiceTokenGuard guard, QuantityDiagnosticService service, ObjectMapper json) {
this.guard = guard; this.service = service;
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) {
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");
}
}
@ExceptionHandler(ResponseStatusException.class)
public ResponseEntity<ApiResponse<Void>> rejected(ResponseStatusException error) {
ResultCode code = switch (error.getStatusCode().value()) {
case 401 -> ResultCode.UNAUTHORIZED;
case 409 -> ResultCode.CONFLICT;
case 503 -> ResultCode.SERVICE_UNAVAILABLE;
default -> ResultCode.BAD_REQUEST;
};
return ResponseEntity.status(error.getStatusCode()).body(ApiResponse.failure(code, error.getReason(), null, null));
}
}
@@ -0,0 +1,89 @@
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 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;
}
}
@@ -0,0 +1,11 @@
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) { }
@@ -0,0 +1,125 @@
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.fasterxml.jackson.databind.ObjectMapper;
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 org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.core.JdbcTemplate;
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. */
@Service
public class QuantityDiagnosticService {
private final JdbcTemplate jdbc;
private final TransactionTemplate tx;
private final ObjectMapper json;
private final QuantityDiagnosticProperties properties;
private final Clock clock;
@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());
}
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);
}
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);
}
private String encode(Object value) {
try { return json.writeValueAsString(value); }
catch (JsonProcessingException e) { throw invalid("DIAGNOSTIC_ENCODING_FAILED"); }
}
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 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);
}
}
}