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);
}
}
}
@@ -0,0 +1,38 @@
-- Independent diagnostic tables in the Lastleg schema. No business fact/queue writes.
-- 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摘要',
source_account VARCHAR(64) NOT NULL COMMENT '来源账号别名,不保存凭据',
collection_session_id VARCHAR(128) NOT NULL COMMENT '被观察的来源采集会话标识',
root_task_id VARCHAR(128) NOT NULL COMMENT '被观察的账号采集根任务标识',
starts_at VARCHAR(40) NOT NULL COMMENT '诊断开始时刻,ISO8601 UTC字符串',
expires_at VARCHAR(40) NOT NULL COMMENT '诊断截止时刻,距开始最多30分钟,ISO8601 UTC字符串',
accepted_orders INT NOT NULL DEFAULT 0 COMMENT '已接收完整配对订单数,跨重启累计',
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 '诊断终止原因,空值表示尚未终止',
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)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_bin COMMENT='独立数量对照诊断会话,重启不重置限额';
CREATE TABLE ll_selfop_quantity_diagnostic_order (
diagnostic_session_id VARCHAR(128) NOT NULL COMMENT '所属固定诊断会话标识',
order_key_hash CHAR(64) NOT NULL COMMENT '账号、仓库、货主、出库单业务键的SHA256摘要',
source_account VARCHAR(64) NOT NULL COMMENT '来源账号别名',
warehouse_code VARCHAR(64) NOT NULL COMMENT '来源仓库编码',
owner_code VARCHAR(128) NOT NULL COMMENT '来源货主编码',
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并集行数',
payload_bytes INT NOT NULL COMMENT '本单白名单规范JSON的UTF8字节数',
evidence_json JSON NOT NULL COMMENT '类型化列表与详情观测及SKU三量证据,不含原始响应或收件信息',
received_at VARCHAR(40) NOT NULL COMMENT '接收时刻,ISO8601 UTC字符串',
PRIMARY KEY (diagnostic_session_id, order_key_hash),
CONSTRAINT fk_selfop_qdiag_session FOREIGN KEY (diagnostic_session_id)
REFERENCES ll_selfop_quantity_diagnostic_session(diagnostic_session_id),
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='独立配对订单数量诊断证据,不参与销量发布';
@@ -0,0 +1,105 @@
openapi: 3.0.3
info:
title: Internal bounded self-operated quantity diagnostics
version: 1.0.0
paths:
/internal/lastleg/self-operated-analytics/quantity-diagnostics/orders:
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.
security:
- internalServiceToken: []
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/PairedOrder'
responses:
'200':
description: Diagnostic receipt in the standard ApiResponse wrapper; code is string 200.
content:
application/json:
schema:
type: object
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. }
components:
securitySchemes:
internalServiceToken:
type: apiKey
in: header
name: X-Internal-Service-Token
schemas:
PairedOrder:
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:
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' }
Observation:
type: object
additionalProperties: false
required: [observedAt, attemptId, skuSetComplete]
properties:
status: { type: string, nullable: true, maxLength: 32 }
outboundTime:
type: string
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 }
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:
type: string
nullable: true
enum: [SKU_SET_MISMATCH, QUANTITY_UNKNOWN, FORECAST_ACTUAL_DIFFERENCE, ALL_EQUAL, LIST_QUANTITY_DIFFERENCE]
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. }
@@ -0,0 +1,47 @@
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.*;
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);
}
}
@@ -0,0 +1,169 @@
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;
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.time.*;
import java.util.*;
import java.util.concurrent.*;
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;
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);
}
}