fix(selfop): preserve exact diagnostic decimals in JSON evidence
This commit is contained in:
+34
-2
@@ -5,6 +5,14 @@ import static com.baiyue.lastleg.selfoperated.analytics.diagnostic.QuantityDiagn
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopSyncTask;
|
||||
import com.baiyue.lastleg.selfoperated.analytics.model.SelfopCollectionPolicy;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
|
||||
import com.fasterxml.jackson.databind.deser.std.StdDeserializer;
|
||||
import com.fasterxml.jackson.core.JsonGenerator;
|
||||
import com.fasterxml.jackson.core.JsonParser;
|
||||
import com.fasterxml.jackson.core.JsonToken;
|
||||
import java.io.IOException;
|
||||
import java.math.BigDecimal;
|
||||
import com.zaxxer.hikari.HikariDataSource;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import javax.sql.DataSource;
|
||||
@@ -37,7 +45,7 @@ public class QuantityDiagnosticService {
|
||||
|
||||
@Autowired
|
||||
public QuantityDiagnosticService(DataSource source, ObjectMapper json, QuantityDiagnosticProperties config) {
|
||||
this.json=json.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
|
||||
this.json=storageMapper(json);
|
||||
this.config=config; this.clock=Clock.systemUTC();
|
||||
// Separate, bounded connection acquisition. Diagnostics cannot exhaust the collection pool.
|
||||
if(config.enabled()) {
|
||||
@@ -55,7 +63,7 @@ public class QuantityDiagnosticService {
|
||||
}
|
||||
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.jdbc=jdbc;this.json=storageMapper(json);
|
||||
this.config=config;this.clock=clock;tx=new TransactionTemplate(manager);tx.setTimeout(3);
|
||||
}
|
||||
@PreDestroy public void close(){if(pool!=null)pool.close();}
|
||||
@@ -204,6 +212,30 @@ public class QuantityDiagnosticService {
|
||||
}
|
||||
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");}}
|
||||
/** Storage-only codec. The controller and Integration wire codec continue using numeric BigDecimal. */
|
||||
private static ObjectMapper storageMapper(ObjectMapper source) {
|
||||
var module=new SimpleModule("quantity-diagnostic-storage-decimals");
|
||||
module.addSerializer(BigDecimal.class,new StdSerializer<BigDecimal>(BigDecimal.class) {
|
||||
@Override public void serialize(BigDecimal value,JsonGenerator generator,
|
||||
com.fasterxml.jackson.databind.SerializerProvider provider)throws IOException {
|
||||
generator.writeString(value.stripTrailingZeros().toPlainString());
|
||||
}
|
||||
});
|
||||
module.addDeserializer(BigDecimal.class,new StdDeserializer<BigDecimal>(BigDecimal.class) {
|
||||
@Override public BigDecimal deserialize(JsonParser parser,
|
||||
com.fasterxml.jackson.databind.DeserializationContext context)throws IOException {
|
||||
// New grants never read historical numeric evidence; no numeric fallback/coercion.
|
||||
if(!parser.hasToken(JsonToken.VALUE_STRING))throw new IOException("DIAGNOSTIC_STORAGE_DECIMAL_REQUIRED");
|
||||
String text=parser.getText();
|
||||
if(!text.matches("(?:0|[1-9][0-9]{0,31})(?:\\.[0-9]{1,6})?"))throw new IOException("DIAGNOSTIC_STORAGE_DECIMAL_INVALID");
|
||||
BigDecimal value=new BigDecimal(text);
|
||||
if(!text.equals(value.stripTrailingZeros().toPlainString()))throw new IOException("DIAGNOSTIC_STORAGE_DECIMAL_NOT_CANONICAL");
|
||||
return value;
|
||||
}
|
||||
});
|
||||
return source.copy().disable(com.fasterxml.jackson.databind.SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
|
||||
.registerModule(module);
|
||||
}
|
||||
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){}
|
||||
|
||||
+7
@@ -24,4 +24,11 @@ class QuantityDiagnosticControllerTest {
|
||||
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);
|
||||
}
|
||||
@Test void normalCallbackNumbersStillDeserializeToExactBigDecimal(){
|
||||
var service=mock(QuantityDiagnosticService.class);var controller=new QuantityDiagnosticController(new InternalServiceTokenGuard("test-token"),service,new ObjectMapper().findAndRegisterModules());
|
||||
var request=new MockHttpServletRequest();request.setContent("{\"side\":\"LIST\",\"skus\":[{\"sku\":\"BIG\",\"listQty\":100000000000000000000.000001},{\"sku\":\"MICRO\",\"listQty\":0.000001}]}".getBytes(java.nio.charset.StandardCharsets.UTF_8));
|
||||
controller.observe("test-token",request);var argument=org.mockito.ArgumentCaptor.forClass(QuantityDiagnosticContract.Event.class);verify(service).observe(argument.capture());
|
||||
assertThat(argument.getValue().skus().get(0).listQty()).isEqualByComparingTo("100000000000000000000.000001");assertThat(argument.getValue().skus().get(1).listQty()).isEqualByComparingTo("0.000001");
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
+50
@@ -139,5 +139,55 @@ class QuantityDiagnosticServiceTest {
|
||||
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");
|
||||
}
|
||||
@Test void highPrecisionStorageUsesStringsAndSurvivesJsonNormalizationAndTerminalReplay()throws Exception{
|
||||
String large="100000000000000000000.000001",micro="0.000001",maximum="99999999999999999999999999999999.999999";
|
||||
Event base=list("precision");
|
||||
Event l=withSkus(base,List.of(new Sku("BIG",new BigDecimal(large),null,null),new Sku("MICRO",new BigDecimal(micro),null,null),new Sku("MAX",new BigDecimal(maximum),null,null)));
|
||||
Event d=withSkus(detail("precision"),List.of(new Sku("BIG",null,new BigDecimal(large),new BigDecimal(large)),new Sku("MICRO",null,new BigDecimal(micro),new BigDecimal("0.000002")),new Sku("MAX",null,new BigDecimal(maximum),new BigDecimal(maximum))));
|
||||
service.observe(l);
|
||||
var pending=json.readTree(jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_pending",String.class));
|
||||
for(var sku:pending.path("skus")){assertThat(sku.path("listQty").isTextual()).isTrue();assertThat(sku.path("forecastQty").isNull()).isTrue();assertThat(sku.path("actualQty").isNull()).isTrue();}
|
||||
assertThat(service.observe(d).session().stopReason()).isEqualTo("QUANTITY_DIFFERENCE");
|
||||
String evidence=jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_order",String.class);
|
||||
String storedHash=jdbc.queryForObject("SELECT payload_hash FROM ll_selfop_quantity_diagnostic_order",String.class);
|
||||
var tree=json.readTree(evidence);
|
||||
for(String side:List.of("list","detail"))for(var sku:tree.path(side).path("skus"))for(String field:List.of("listQty","forecastQty","actualQty"))assertThat(sku.path(field).isNull()||sku.path(field).isTextual()).isTrue();
|
||||
assertThat(tree.path("list").path("skus").get(0).path("listQty").textValue()).isEqualTo(large);
|
||||
// Reproduces the precision hazard of untyped JSON numeric normalization, without a MySQL service.
|
||||
Map<?,?> unsafe=json.readValue("{\"qty\":"+large+"}",Map.class);
|
||||
assertThat(new BigDecimal(unsafe.get("qty").toString())).isNotEqualByComparingTo(large);
|
||||
String reordered=json.copy().enable(com.fasterxml.jackson.databind.SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS).writeValueAsString(json.readValue(evidence,Map.class));
|
||||
assertThat(reordered).isNotEqualTo(evidence);
|
||||
// Exercise an actual H2 JSON column too, rather than relying only on the portable LONGTEXT fixture.
|
||||
jdbc.execute("CREATE TABLE qdiag_json_roundtrip(evidence JSON)");jdbc.update("INSERT INTO qdiag_json_roundtrip VALUES (? FORMAT JSON)",reordered);
|
||||
String persisted=jdbc.queryForObject("SELECT evidence FROM qdiag_json_roundtrip",String.class);
|
||||
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_order SET evidence_json=?",persisted);
|
||||
assertThat(instance().observe(l).outcome()).isEqualTo("DUPLICATE");assertThat(instance().observe(d).outcome()).isEqualTo("DUPLICATE");
|
||||
assertThat(jdbc.queryForObject("SELECT payload_hash FROM ll_selfop_quantity_diagnostic_order",String.class)).isEqualTo(storedHash);
|
||||
assertThat(storedHash).isEqualTo(java.util.HexFormat.of().formatHex(java.security.MessageDigest.getInstance("SHA-256").digest(evidence.getBytes(StandardCharsets.UTF_8))));
|
||||
assertThat(status().pairedOrders()).isEqualTo(1);
|
||||
// Private storage mapper must not mutate the normal numeric wire serializer.
|
||||
assertThat(json.readTree(json.writeValueAsString(l)).path("skus").get(0).path("listQty").isNumber()).isTrue();
|
||||
}
|
||||
@Test void trailingZerosAndNegativeZeroShareCanonicalStorageAndReplay()throws Exception{
|
||||
Event l=event("2","a","LIST",new BigDecimal("12.340000"),null,null);service.observe(l);
|
||||
assertThat(service.observe(event("2","a","LIST",new BigDecimal("12.34"),null,null)).outcome()).isEqualTo("DUPLICATE");
|
||||
Event d=event("2","a","DETAIL",null,new BigDecimal("12.3400"),new BigDecimal("-0.000000"));service.observe(d);
|
||||
var stored=json.readTree(jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_order",String.class));
|
||||
assertThat(stored.path("list").path("skus").get(0).path("listQty").textValue()).isEqualTo("12.34");assertThat(stored.path("detail").path("skus").get(0).path("actualQty").textValue()).isEqualTo("0");
|
||||
assertThat(instance().observe(event("2","a","DETAIL",null,new BigDecimal("12.34"),BigDecimal.ZERO)).outcome()).isEqualTo("DUPLICATE");
|
||||
}
|
||||
@org.junit.jupiter.params.ParameterizedTest
|
||||
@org.junit.jupiter.params.provider.ValueSource(strings={"-1","-0.000001"})
|
||||
void negativeQuantitiesAreRejectedBeforeEvidencePersistence(String quantity){
|
||||
assertThatThrownBy(()->service.observe(event("2","a","LIST",new BigDecimal(quantity),null,null))).hasMessageContaining("QUANTITY_INVALID");
|
||||
assertThat(status().candidates()).isZero();assertThat(jdbc.queryForObject("SELECT COUNT(*) FROM ll_selfop_quantity_diagnostic_pending",Integer.class)).isZero();
|
||||
}
|
||||
@Test void newGrantStorageRejectsNumericEvidenceInsteadOfAddingLegacyCoercion(){
|
||||
service.observe(list("a"));String stored=jdbc.queryForObject("SELECT evidence_json FROM ll_selfop_quantity_diagnostic_pending",String.class);
|
||||
jdbc.update("UPDATE ll_selfop_quantity_diagnostic_pending SET evidence_json=?",stored.replace("\"listQty\":\"10\"","\"listQty\":10"));
|
||||
assertThatThrownBy(()->instance().observe(detail("a"))).hasMessageContaining("DIAGNOSTIC_STORAGE_INVALID");
|
||||
}
|
||||
private Event withSkus(Event e,List<Sku> skus){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(),skus);}
|
||||
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;}}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user