feat(ft546): persist shadow comparison evidence

This commit is contained in:
zgw
2026-08-14 17:25:52 +08:00
parent 6864010832
commit 240f5b0808
33 changed files with 3228 additions and 26 deletions

View File

@@ -0,0 +1,351 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546RegisterWrite;
import org.springframework.dao.DataAccessException;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.transaction.support.TransactionTemplate;
import java.sql.SQLException;
import java.sql.Timestamp;
import java.time.Instant;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
import java.util.function.Supplier;
public final class JdbcShadowEvidenceRepository
implements ShadowEvidenceRepository {
private final JdbcTemplate jdbc;
private final TransactionTemplate transaction;
private final ObjectMapper objectMapper;
private final ShadowRunEvidence run;
public JdbcShadowEvidenceRepository(JdbcTemplate jdbc,
TransactionTemplate transaction,
ObjectMapper objectMapper,
ShadowRunEvidence run) {
this.jdbc = Objects.requireNonNull(jdbc, "jdbc");
this.transaction = Objects.requireNonNull(transaction, "transaction");
this.objectMapper = Objects.requireNonNull(objectMapper,
"objectMapper");
this.run = Objects.requireNonNull(run, "run");
}
@Override
public SavedCycle save(ShadowCycleEvidence evidence) {
return saveAll(Collections.singletonList(evidence)).get(0);
}
@Override
public List<SavedCycle> saveAll(List<ShadowCycleEvidence> evidence) {
Objects.requireNonNull(evidence, "evidence");
if (evidence.isEmpty()) {
return Collections.emptyList();
}
for (ShadowCycleEvidence item : evidence) {
if (item == null) {
throw new IllegalArgumentException(
"evidence must not contain null");
}
if (!run.getRunId().equals(item.getRunId())) {
throw new ShadowEvidenceException(
"EVIDENCE_RUN_ID_MISMATCH");
}
}
try {
return transaction.execute(status ->
saveAllInTransaction(evidence));
} catch (DataAccessException race) {
if (!isDuplicateKey(race)) {
throw race;
}
List<SavedCycle> existing = resolveReplays(evidence);
if (existing != null) {
return existing;
}
if (runMatches()) {
return transaction.execute(
status -> saveAllInTransaction(evidence));
}
throw race;
}
}
static boolean isDuplicateKey(DataAccessException failure) {
if (failure instanceof DuplicateKeyException) {
return true;
}
Throwable cause = failure;
while (cause != null) {
if (cause instanceof SQLException) {
for (SQLException sql = (SQLException) cause;
sql != null; sql = sql.getNextException()) {
if (sql.getErrorCode() == 2601
|| sql.getErrorCode() == 2627) {
return true;
}
}
}
Throwable next = cause.getCause();
cause = next == cause ? null : next;
}
return false;
}
@Override
public <T> T inTransaction(Supplier<T> work) {
Objects.requireNonNull(work, "work");
return transaction.execute(status -> work.get());
}
@Override
public SavedCycle findCycle(UUID runId, String inputBatchHash,
String parentTaskId, String phase,
long routeVersion) {
List<SavedCycle> values = jdbc.query(
"SELECT cycle_id,evidence_hash,status,reason_code "
+ "FROM shadow_cycle WHERE run_id=? "
+ "AND input_batch_hash=? AND parent_task_id=? "
+ "AND phase=? AND route_version=?",
(rs, row) -> new SavedCycle(
uuid(rs.getObject("cycle_id")),
rs.getString("evidence_hash"),
rs.getString("status"),
rs.getString("reason_code")),
runId, inputBatchHash, parentTaskId, phase, routeVersion);
return values.isEmpty() ? null : values.get(0);
}
@Override
public ShadowEvidenceDigest dailyDigest(LocalDate date, ZoneId zoneId) {
Objects.requireNonNull(date, "date");
Objects.requireNonNull(zoneId, "zoneId");
Instant start = date.atStartOfDay(zoneId).toInstant();
Instant end = date.plusDays(1).atStartOfDay(zoneId).toInstant();
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT c.cycle_id,c.evidence_hash,d.category "
+ "FROM shadow_cycle c LEFT JOIN shadow_difference d "
+ "ON d.cycle_id=c.cycle_id WHERE c.run_id=? "
+ "AND c.completed_at>=? AND c.completed_at<? "
+ "ORDER BY c.input_batch_hash,c.parent_task_id,"
+ "c.phase,c.route_version,d.difference_id",
run.getRunId(), Timestamp.from(start), Timestamp.from(end));
Map<UUID, String> cycles = new LinkedHashMap<UUID, String>();
Map<ShadowDifferenceCategory, Long> categories =
new EnumMap<ShadowDifferenceCategory, Long>(
ShadowDifferenceCategory.class);
for (Map<String, Object> row : rows) {
cycles.put(uuid(row.get("cycle_id")),
string(row.get("evidence_hash")));
String category = string(row.get("category"));
if (category != null) {
ShadowDifferenceCategory key;
try {
key = ShadowDifferenceCategory.valueOf(category);
} catch (IllegalArgumentException corrupt) {
throw new ShadowEvidenceException(
"UNKNOWN_EVIDENCE_DIFFERENCE_CATEGORY", corrupt);
}
Long count = categories.get(key);
categories.put(key, count == null ? 1L : count + 1L);
}
}
return ShadowEvidenceDigest.create(
new ArrayList<String>(cycles.values()), categories,
objectMapper);
}
@Override
public boolean isHealthy() {
try {
Integer value = jdbc.queryForObject("SELECT 1", Integer.class);
return Integer.valueOf(1).equals(value);
} catch (DataAccessException failure) {
return false;
}
}
private List<SavedCycle> saveAllInTransaction(
List<ShadowCycleEvidence> evidence) {
ensureRun();
List<SavedCycle> result = new ArrayList<SavedCycle>(evidence.size());
List<ShadowCycleEvidence> inserts =
new ArrayList<ShadowCycleEvidence>();
for (ShadowCycleEvidence item : evidence) {
SavedCycle existing = find(item);
if (existing == null) {
inserts.add(item);
result.add(null);
} else {
result.add(verifyReplay(existing, item));
}
}
for (ShadowCycleEvidence item : inserts) {
insert(item);
}
for (int index = 0; index < evidence.size(); index++) {
if (result.get(index) == null) {
result.set(index, saved(evidence.get(index)));
}
}
return Collections.unmodifiableList(result);
}
private void insert(ShadowCycleEvidence evidence) {
jdbc.update("INSERT INTO shadow_cycle "
+ "(cycle_id,run_id,input_batch_hash,parent_task_id,"
+ "phase,route_version,status,reason_code,"
+ "database_captured_at,completed_at,evidence_hash) "
+ "VALUES (?,?,?,?,?,?,?,?,?,?,?)",
evidence.getCycleId(), evidence.getRunId(),
evidence.getInputBatchHash(), evidence.getParentTaskId(),
evidence.getPhase(), evidence.getRouteVersion(),
evidence.getStatus(), evidence.getReasonCode(),
timestamp(evidence.getDatabaseCapturedAt()),
Timestamp.from(evidence.getCompletedAt()),
evidence.getEvidenceHash());
jdbc.update("INSERT INTO shadow_input_batch "
+ "(cycle_id,content_hash,captured_json) VALUES (?,?,?)",
evidence.getCycleId(), evidence.getInputBatchHash(),
evidence.getInputJson());
jdbc.update("INSERT INTO shadow_legacy_queue "
+ "(cycle_id,vehicle_id,path_info,result_status,"
+ "result_json,content_hash) VALUES (?,?,?,?,?,?)",
evidence.getCycleId(), evidence.getLegacyVehicleId(),
evidence.getLegacyPathInfo(), evidence.getLegacyStatus(),
evidence.getLegacyJson(), evidence.getLegacyHash());
jdbc.update("INSERT INTO shadow_decision "
+ "(cycle_id,decision,vehicle_id,reason_code,"
+ "decision_json,content_hash) VALUES (?,?,?,?,?,?)",
evidence.getCycleId(), evidence.getDecision(),
evidence.getDecisionVehicleId(),
evidence.getDecisionReasonCode(), evidence.getDecisionJson(),
evidence.getDecisionHash());
for (Ft546RegisterWrite write : evidence.getEncodedWrites()) {
jdbc.update("INSERT INTO shadow_encoded_write "
+ "(cycle_id,write_sequence,register_address,"
+ "data_type,values_json,trigger_value) "
+ "VALUES (?,?,?,?,?,?)",
evidence.getCycleId(), write.getWriteSequence(),
write.getAddress(), write.getDataType().name(),
ShadowEvidenceDigest.canonicalJson(objectMapper,
write.getValues()),
triggerValue(write));
}
for (ShadowDifference difference : evidence.getDifferences()) {
jdbc.update("INSERT INTO shadow_difference "
+ "(cycle_id,category,reason_code,expected_json,"
+ "actual_json) VALUES (?,?,?,?,?)",
evidence.getCycleId(), difference.getCategory().name(),
difference.getReasonCode(), difference.getExpectedJson(),
difference.getActualJson());
}
}
private void ensureRun() {
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT artifact_sha256,configuration_sha256,source_server,"
+ "source_database,source_login FROM shadow_run "
+ "WHERE run_id=?", run.getRunId());
if (rows.isEmpty()) {
jdbc.update("INSERT INTO shadow_run "
+ "(run_id,started_at,artifact_sha256,"
+ "configuration_sha256,source_server,"
+ "source_database,source_login,status) "
+ "VALUES (?,?,?,?,?,?,?,?)",
run.getRunId(), Timestamp.from(run.getStartedAt()),
run.getArtifactSha256(), run.getConfigurationSha256(),
run.getSourceServer(), run.getSourceDatabase(),
run.getSourceLogin(), run.getStatus());
return;
}
if (!matchesRun(rows.get(0))) {
throw new ShadowEvidenceException("EVIDENCE_RUN_COLLISION");
}
}
private boolean runMatches() {
List<Map<String, Object>> rows = jdbc.queryForList(
"SELECT artifact_sha256,configuration_sha256,source_server,"
+ "source_database,source_login FROM shadow_run "
+ "WHERE run_id=?", run.getRunId());
return rows.size() == 1 && matchesRun(rows.get(0));
}
private boolean matchesRun(Map<String, Object> row) {
return run.getArtifactSha256().equals(
string(row.get("artifact_sha256")))
&& run.getConfigurationSha256().equals(
string(row.get("configuration_sha256")))
&& run.getSourceServer().equals(
string(row.get("source_server")))
&& run.getSourceDatabase().equals(
string(row.get("source_database")))
&& run.getSourceLogin().equals(
string(row.get("source_login")));
}
private SavedCycle find(ShadowCycleEvidence evidence) {
return findCycle(evidence.getRunId(), evidence.getInputBatchHash(),
evidence.getParentTaskId(), evidence.getPhase(),
evidence.getRouteVersion());
}
private List<SavedCycle> resolveReplays(
List<ShadowCycleEvidence> evidence) {
List<SavedCycle> result = new ArrayList<SavedCycle>(evidence.size());
for (ShadowCycleEvidence item : evidence) {
SavedCycle existing = find(item);
if (existing == null) {
return null;
}
result.add(verifyReplay(existing, item));
}
return Collections.unmodifiableList(result);
}
private static SavedCycle verifyReplay(
SavedCycle existing, ShadowCycleEvidence evidence) {
if (!existing.getEvidenceHash().equals(evidence.getEvidenceHash())) {
throw new ShadowEvidenceException("EVIDENCE_KEY_COLLISION");
}
return existing;
}
private static SavedCycle saved(ShadowCycleEvidence evidence) {
return new SavedCycle(evidence.getCycleId(),
evidence.getEvidenceHash(), evidence.getStatus(),
evidence.getReasonCode());
}
private static Long triggerValue(Ft546RegisterWrite write) {
if (!write.isTrigger() || write.getValues().isEmpty()) {
return null;
}
return Long.valueOf(write.getValues().get(0).longValue());
}
private static Timestamp timestamp(Instant value) {
return value == null ? null : Timestamp.from(value);
}
private static UUID uuid(Object value) {
if (value instanceof UUID) {
return (UUID) value;
}
return UUID.fromString(value.toString());
}
private static String string(Object value) {
return value == null ? null : value.toString().trim();
}
}

View File

@@ -0,0 +1,43 @@
package com.ruoyi.shadow.evidence;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Objects;
public final class JdbcShadowEvidenceStorageHealth
implements ShadowEvidenceStorageHealth {
private final ShadowEvidenceRepository repository;
private final Path outputDirectory;
private final long minimumFreeBytes;
public JdbcShadowEvidenceStorageHealth(
ShadowEvidenceRepository repository, Path outputDirectory,
long minimumFreeBytes) {
this.repository = Objects.requireNonNull(repository, "repository");
this.outputDirectory = Objects.requireNonNull(outputDirectory,
"outputDirectory").toAbsolutePath().normalize();
if (minimumFreeBytes < 0L) {
throw new IllegalArgumentException(
"minimumFreeBytes must not be negative");
}
this.minimumFreeBytes = minimumFreeBytes;
}
@Override
public Check verify() {
if (!repository.isHealthy()) {
return Check.unavailable("EVIDENCE_DATABASE_UNAVAILABLE");
}
try {
long usableBytes = Files.getFileStore(outputDirectory)
.getUsableSpace();
if (usableBytes < minimumFreeBytes) {
return Check.unavailable("FREE_SPACE_BELOW_MINIMUM");
}
return Check.available(usableBytes);
} catch (IOException | RuntimeException failure) {
return Check.unavailable("EVIDENCE_FREE_SPACE_CHECK_FAILED");
}
}
}

View File

@@ -0,0 +1,264 @@
package com.ruoyi.shadow.evidence;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546RegisterWrite;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult.LegacyRegisterWrite;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
public final class ShadowComparisonService {
public List<ShadowDifference> compare(
ShadowLegacyQueueResult legacy,
ShadowDecision decision,
Ft546EncodedCommand encoded) {
Objects.requireNonNull(legacy, "legacy");
Objects.requireNonNull(decision, "decision");
List<ShadowDifference> differences =
new ArrayList<ShadowDifference>();
if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) {
differences.add(difference(
ShadowDifferenceCategory.INSUFFICIENT_FACTS,
decision.getReasonCode(), null, null));
return immutableSorted(differences);
}
if (decision.getType() == ShadowDecision.Type.SOURCE_GAP
|| legacy.getStatus()
== ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE) {
differences.add(difference(ShadowDifferenceCategory.SOURCE_GAP,
legacy.getStatus()
== ShadowLegacyQueueResult.Status.LEGACY_RESULT_UNAVAILABLE
? legacy.getReasonCode() : decision.getReasonCode(),
null, null));
return immutableSorted(differences);
}
if (decision.getType() == ShadowDecision.Type.HELD
|| decision.getType() == ShadowDecision.Type.YIELDED) {
differences.add(difference(ShadowDifferenceCategory.ALGORITHM_HOLD,
"SHADOW_" + decision.getType().name() + ':'
+ decision.getReasonCode(),
json("LEGACY_COMMAND"), json(decision.getType().name())));
return immutableSorted(differences);
}
if (encoded == null) {
differences.add(difference(
ShadowDifferenceCategory.INSUFFICIENT_FACTS,
"MISSING_ENCODED_COMMAND", null, null));
return immutableSorted(differences);
}
compareAlgorithm(legacy, decision, differences);
compareQueueFields(legacy, encoded, differences);
compareProtocol(legacy.getWrites(), encoded.getWrites(), differences);
if (differences.isEmpty()) {
differences.add(difference(ShadowDifferenceCategory.MATCH,
"LEGACY_AND_SHADOW_MATCH", null, null));
}
return immutableSorted(differences);
}
private static void compareAlgorithm(
ShadowLegacyQueueResult legacy, ShadowDecision decision,
List<ShadowDifference> differences) {
if (!Objects.equals(legacy.getVehicleId(), decision.getVehicleId())) {
differences.add(difference(
ShadowDifferenceCategory.ALGORITHM_VEHICLE,
"SELECTED_VEHICLE_MISMATCH", json(legacy.getVehicleId()),
json(decision.getVehicleId())));
}
if (!legacy.getPathPoints().equals(decision.getRoutePoints())) {
differences.add(difference(
ShadowDifferenceCategory.ALGORITHM_ROUTE,
"SELECTED_ROUTE_MISMATCH", json(legacy.getPathPoints()),
json(decision.getRoutePoints())));
}
}
private static void compareQueueFields(
ShadowLegacyQueueResult legacy, Ft546EncodedCommand encoded,
List<ShadowDifference> differences) {
compareQueueField("taskNo", legacy.getQueueFields().get("taskNo"),
Integer.toString(encoded.getTaskNo()), differences);
compareQueueField("taskType", legacy.getQueueFields().get("taskType"),
Short.toString(encoded.getTaskType()), differences);
}
private static void compareQueueField(
String field, String expected, String actual,
List<ShadowDifference> differences) {
if (expected != null && !expected.equals(actual)) {
differences.add(difference(ShadowDifferenceCategory.QUEUE_FIELD,
"QUEUE_FIELD_MISMATCH:" + field, json(expected),
json(actual)));
}
}
private static void compareProtocol(
List<LegacyRegisterWrite> legacy,
List<Ft546RegisterWrite> encoded,
List<ShadowDifference> differences) {
if (legacy.size() != encoded.size()) {
differences.add(difference(ShadowDifferenceCategory.PROTOCOL_ORDER,
"REGISTER_WRITE_COUNT_MISMATCH", json(legacy.size()),
json(encoded.size())));
return;
}
List<String> legacyAddresses = legacyAddresses(legacy);
List<String> encodedAddresses = encodedAddresses(encoded);
boolean sameAddressOrder = legacyAddresses.equals(encodedAddresses);
boolean sameAddressSet = counts(legacyAddresses).equals(
counts(encodedAddresses));
if (!sameAddressOrder && sameAddressSet) {
differences.add(difference(ShadowDifferenceCategory.PROTOCOL_ORDER,
"REGISTER_ADDRESS_ORDER_MISMATCH", json(legacyAddresses),
json(encodedAddresses)));
}
Map<String, Integer> addressOccurrences =
new LinkedHashMap<String, Integer>();
for (int index = 0; index < legacy.size(); index++) {
LegacyRegisterWrite expected = legacy.get(index);
int occurrence = nextOccurrence(addressOccurrences,
expected.getAddress());
Ft546RegisterWrite actual = sameAddressSet
? occurrence(encoded, expected.getAddress(), occurrence)
: encoded.get(index);
if (actual == null
|| !expected.getAddress().equals(actual.getAddress())) {
differences.add(difference(
ShadowDifferenceCategory.PROTOCOL_ADDRESS,
"REGISTER_ADDRESS_MISMATCH:" + (index + 1),
json(expected.getAddress()),
json(actual == null ? null : actual.getAddress())));
continue;
}
if (sameAddressOrder
&& expected.getWriteSequence()
!= actual.getWriteSequence()) {
differences.add(difference(
ShadowDifferenceCategory.PROTOCOL_ORDER,
"REGISTER_SEQUENCE_MISMATCH:" + expected.getAddress(),
json(expected.getWriteSequence()),
json(actual.getWriteSequence())));
}
if (!expected.getDataType().name().equals(
actual.getDataType().name())) {
differences.add(difference(
ShadowDifferenceCategory.PROTOCOL_DATA_TYPE,
"REGISTER_DATA_TYPE_MISMATCH:" + expected.getAddress(),
json(expected.getDataType().name()),
json(actual.getDataType().name())));
}
if (!expected.getValues().equals(actual.getValues())
|| expected.isTrigger() != actual.isTrigger()) {
differences.add(difference(
ShadowDifferenceCategory.PROTOCOL_VALUE,
"REGISTER_VALUE_MISMATCH:" + expected.getAddress(),
valueJson(expected.getValues(), expected.isTrigger()),
valueJson(actual.getValues(), actual.isTrigger())));
}
}
}
private static List<String> legacyAddresses(
List<LegacyRegisterWrite> writes) {
List<String> values = new ArrayList<String>();
for (LegacyRegisterWrite write : writes) {
values.add(write.getAddress());
}
return values;
}
private static List<String> encodedAddresses(
List<Ft546RegisterWrite> writes) {
List<String> values = new ArrayList<String>();
for (Ft546RegisterWrite write : writes) {
values.add(write.getAddress());
}
return values;
}
private static Map<String, Integer> counts(List<String> values) {
Map<String, Integer> counts = new LinkedHashMap<String, Integer>();
for (String value : values) {
Integer count = counts.get(value);
counts.put(value, count == null ? 1 : count + 1);
}
return counts;
}
private static int nextOccurrence(Map<String, Integer> occurrences,
String address) {
Integer previous = occurrences.get(address);
int current = previous == null ? 1 : previous.intValue() + 1;
occurrences.put(address, Integer.valueOf(current));
return current;
}
private static Ft546RegisterWrite occurrence(
List<Ft546RegisterWrite> values, String address, int occurrence) {
int current = 0;
for (Ft546RegisterWrite value : values) {
if (address.equals(value.getAddress()) && ++current == occurrence) {
return value;
}
}
return null;
}
private static ShadowDifference difference(
ShadowDifferenceCategory category, String reason,
String expected, String actual) {
return new ShadowDifference(category, reason, expected, actual);
}
private static List<ShadowDifference> immutableSorted(
List<ShadowDifference> source) {
Collections.sort(source, Comparator
.comparing((ShadowDifference value) ->
value.getCategory().ordinal())
.thenComparing(ShadowDifference::getReasonCode)
.thenComparing(value -> nullToEmpty(value.getExpectedJson()))
.thenComparing(value -> nullToEmpty(value.getActualJson())));
return Collections.unmodifiableList(
new ArrayList<ShadowDifference>(source));
}
private static String valueJson(List<Integer> values, boolean trigger) {
return "{\"trigger\":" + trigger + ",\"values\":"
+ json(values) + '}';
}
private static String json(Object value) {
if (value == null) {
return "null";
}
if (value instanceof Number || value instanceof Boolean) {
return value.toString();
}
if (value instanceof List) {
StringBuilder result = new StringBuilder("[");
for (Object item : (List<?>) value) {
if (result.length() > 1) {
result.append(',');
}
result.append(json(item));
}
return result.append(']').toString();
}
return '"' + value.toString().replace("\\", "\\\\")
.replace("\"", "\\\"") + '"';
}
private static String nullToEmpty(String value) {
return value == null ? "" : value;
}
}

View File

@@ -0,0 +1,381 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.ft546.protocol.Ft546RegisterWrite;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
import com.ruoyi.shadow.facts.ShadowMapFact;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.shadow.facts.ShadowVehicleFact;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.UUID;
public final class ShadowCycleEvidence {
private final UUID cycleId;
private final UUID runId;
private final String inputBatchHash;
private final String parentTaskId;
private final String phase;
private final long routeVersion;
private final String status;
private final String reasonCode;
private final Instant databaseCapturedAt;
private final Instant completedAt;
private final String evidenceHash;
private final String inputJson;
private final String legacyVehicleId;
private final String legacyPathInfo;
private final String legacyStatus;
private final String legacyJson;
private final String legacyHash;
private final String decision;
private final String decisionVehicleId;
private final String decisionReasonCode;
private final String decisionJson;
private final String decisionHash;
private final List<Ft546RegisterWrite> encodedWrites;
private final String encodedHash;
private final List<ShadowDifference> differences;
private ShadowCycleEvidence(UUID runId, ShadowFactBatch batch,
ShadowLegacyQueueResult legacy,
ShadowDecision decision,
Ft546EncodedCommand encoded,
List<ShadowDifference> differences,
Instant completedAt,
ObjectMapper objectMapper) {
this.cycleId = UUID.randomUUID();
this.runId = Objects.requireNonNull(runId, "runId");
this.parentTaskId = decision.getParentTaskId();
this.phase = decision.getPhase();
this.routeVersion = decision.getRouteVersion();
this.status = status(decision);
this.reasonCode = decision.getReasonCode();
this.databaseCapturedAt = batch == null
? null : batch.getDatabaseCapturedAt();
this.completedAt = Objects.requireNonNull(completedAt, "completedAt");
this.inputJson = ShadowEvidenceDigest.canonicalJson(objectMapper,
inputPayload(batch, decision));
this.inputBatchHash = ShadowEvidenceDigest.sha256(inputJson);
this.legacyVehicleId = legacy.getVehicleId();
this.legacyPathInfo = join(legacy.getPathPoints());
this.legacyStatus = legacy.getStatus().name();
this.legacyJson = ShadowEvidenceDigest.canonicalJson(objectMapper,
legacyPayload(legacy));
this.legacyHash = ShadowEvidenceDigest.sha256(legacyJson);
this.decision = decision.getType().name();
this.decisionVehicleId = decision.getVehicleId();
this.decisionReasonCode = decision.getReasonCode();
this.decisionJson = ShadowEvidenceDigest.canonicalJson(objectMapper,
decisionPayload(decision, encoded));
this.decisionHash = ShadowEvidenceDigest.sha256(decisionJson);
this.encodedWrites = encoded == null
? Collections.<Ft546RegisterWrite>emptyList()
: immutable(encoded.getWrites());
this.encodedHash = ShadowEvidenceDigest.sha256(
ShadowEvidenceDigest.canonicalJson(objectMapper,
encodedPayload(this.encodedWrites)));
this.differences = immutableDifferences(differences);
Map<String, Object> digest = new LinkedHashMap<String, Object>();
digest.put("decisionHash", decisionHash);
digest.put("differenceHash", ShadowEvidenceDigest.sha256(
ShadowEvidenceDigest.canonicalJson(objectMapper,
differencePayload(this.differences))));
digest.put("encodedHash", encodedHash);
digest.put("inputBatchHash", inputBatchHash);
digest.put("legacyHash", legacyHash);
digest.put("parentTaskId", parentTaskId);
digest.put("phase", phase);
digest.put("routeVersion", routeVersion);
digest.put("status", status);
this.evidenceHash = ShadowEvidenceDigest.sha256(
ShadowEvidenceDigest.canonicalJson(objectMapper, digest));
}
public static ShadowCycleEvidence complete(
UUID runId, ShadowFactBatch batch,
ShadowLegacyQueueResult legacy, ShadowDecision decision,
Ft546EncodedCommand encoded, List<ShadowDifference> differences,
Instant completedAt, ObjectMapper objectMapper) {
return new ShadowCycleEvidence(runId,
Objects.requireNonNull(batch, "batch"),
Objects.requireNonNull(legacy, "legacy"),
Objects.requireNonNull(decision, "decision"), encoded,
differences, completedAt,
Objects.requireNonNull(objectMapper, "objectMapper"));
}
public static ShadowCycleEvidence sourceGap(
UUID runId, String reasonCode, Instant completedAt,
ObjectMapper objectMapper) {
ShadowDecision decision = ShadowDecision.sourceGap(reasonCode);
ShadowLegacyQueueResult legacy =
ShadowLegacyQueueResult.unavailable(reasonCode);
return new ShadowCycleEvidence(runId, null, legacy, decision, null,
Collections.singletonList(new ShadowDifference(
ShadowDifferenceCategory.SOURCE_GAP, reasonCode,
null, null)), completedAt, objectMapper);
}
public UUID getCycleId() { return cycleId; }
public UUID getRunId() { return runId; }
public String getInputBatchHash() { return inputBatchHash; }
public String getParentTaskId() { return parentTaskId; }
public String getPhase() { return phase; }
public long getRouteVersion() { return routeVersion; }
public String getStatus() { return status; }
public String getReasonCode() { return reasonCode; }
public Instant getDatabaseCapturedAt() { return databaseCapturedAt; }
public Instant getCompletedAt() { return completedAt; }
public String getEvidenceHash() { return evidenceHash; }
public String getInputJson() { return inputJson; }
public String getLegacyVehicleId() { return legacyVehicleId; }
public String getLegacyPathInfo() { return legacyPathInfo; }
public String getLegacyStatus() { return legacyStatus; }
public String getLegacyJson() { return legacyJson; }
public String getLegacyHash() { return legacyHash; }
public String getDecision() { return decision; }
public String getDecisionVehicleId() { return decisionVehicleId; }
public String getDecisionReasonCode() { return decisionReasonCode; }
public String getDecisionJson() { return decisionJson; }
public String getDecisionHash() { return decisionHash; }
public List<Ft546RegisterWrite> getEncodedWrites() {
return encodedWrites;
}
public String getEncodedHash() { return encodedHash; }
public List<ShadowDifference> getDifferences() { return differences; }
private static Map<String, Object> inputPayload(
ShadowFactBatch batch, ShadowDecision decision) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
if (batch == null) {
payload.put("sourceGap", decision.getReasonCode());
return payload;
}
payload.put("databaseCaptureStartedAt",
batch.getDatabaseCaptureStartedAt());
payload.put("databaseCapturedAt", batch.getDatabaseCapturedAt());
payload.put("maps", maps(batch.getMaps()));
payload.put("queues", queues(batch.getSourceQueues()));
payload.put("vehicles", vehicles(batch.getVehicles()));
return payload;
}
private static List<Map<String, Object>> queues(
List<ShadowQueueFact> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowQueueFact value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
row.put("agvId", value.getAgvId());
row.put("cargoPickSite", value.getCargoPickSite());
row.put("cargoPlaceSite", value.getCargoPlaceSite());
row.put("contentHash", value.getContentHash());
row.put("createdAt", value.getCreatedAt());
row.put("default02", value.getDefault02());
row.put("default03", value.getDefault03());
row.put("default04", value.getDefault04());
row.put("default05", value.getDefault05());
row.put("default06", value.getDefault06());
row.put("default07", value.getDefault07());
row.put("default08", value.getDefault08());
row.put("endSite", value.getEndSite());
row.put("guid", value.getGuid());
row.put("pathInfo", value.getPathInfo());
row.put("priority", value.getPriority());
row.put("startSite", value.getStartSite());
row.put("tableName", value.getTableName());
row.put("taskNo", value.getTaskNo());
row.put("taskType", value.getTaskType());
row.put("turnPath", value.getTurnPath());
result.add(row);
}
return result;
}
private static List<Map<String, Object>> vehicles(
List<ShadowVehicleFact> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowVehicleFact value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
row.put("agvStatus", value.getAgvStatus());
row.put("alarm", value.getAlarm());
row.put("allowSend", value.getAllowSend());
row.put("battery", value.getBattery());
row.put("capturedAt", value.getCapturedAt());
row.put("cargoFlag", value.getCargoFlag());
row.put("changeLayer", value.getChangeLayer());
row.put("chargeFlag", value.getChargeFlag());
row.put("contentHash", value.getContentHash());
row.put("currentFloor", value.getCurrentFloor());
row.put("currentSite", value.getCurrentSite());
row.put("deviceId", value.getDeviceId());
row.put("errorCode", value.getErrorCode());
row.put("finish", value.getFinish());
row.put("guid", value.getGuid());
row.put("locked", value.getLocked());
row.put("path", value.getPath());
row.put("sourceSite", value.getSourceSite());
row.put("spare2", value.getSpare2());
row.put("spare5", value.getSpare5());
row.put("status", value.getStatus());
row.put("targetSite", value.getTargetSite());
row.put("taskNo", value.getTaskNo());
row.put("taskType", value.getTaskType());
row.put("workMode", value.getWorkMode());
row.put("workPhase", value.getWorkPhase());
result.add(row);
}
return result;
}
private static List<Map<String, Object>> maps(List<ShadowMapFact> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowMapFact value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
row.put("guid", value.getGuid());
row.put("siteId", value.getSiteId());
row.put("value", value.getValue());
row.put("x", value.getX());
row.put("y", value.getY());
row.put("z", value.getZ());
result.add(row);
}
return result;
}
private static Map<String, Object> legacyPayload(
ShadowLegacyQueueResult legacy) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("pathPoints", legacy.getPathPoints());
payload.put("queueFields", legacy.getQueueFields());
payload.put("reasonCode", legacy.getReasonCode());
payload.put("status", legacy.getStatus().name());
payload.put("vehicleId", legacy.getVehicleId());
List<Map<String, Object>> writes =
new ArrayList<Map<String, Object>>();
for (ShadowLegacyQueueResult.LegacyRegisterWrite write
: legacy.getWrites()) {
writes.add(writePayload(write.getAddress(),
write.getDataType().name(), write.getValues(),
write.getWriteSequence(), write.isTrigger()));
}
payload.put("writes", writes);
return payload;
}
private static Map<String, Object> decisionPayload(
ShadowDecision decision, Ft546EncodedCommand encoded) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("decision", decision.getType().name());
payload.put("parentTaskId", decision.getParentTaskId());
payload.put("phase", decision.getPhase());
payload.put("reasonCode", decision.getReasonCode());
payload.put("routePoints", decision.getRoutePoints());
payload.put("routeVersion", decision.getRouteVersion());
payload.put("vehicleId", decision.getVehicleId());
if (encoded != null) {
Map<String, Object> command = new LinkedHashMap<String, Object>();
command.put("commandKey", encoded.getCommandKey());
command.put("deviceId", encoded.getDeviceId());
command.put("parentTaskId", encoded.getParentTaskId());
command.put("pathPoints", encoded.getPathPoints());
command.put("phase", encoded.getPhase());
command.put("routeVersion", encoded.getRouteVersion());
command.put("taskNo", encoded.getTaskNo());
command.put("taskType", encoded.getTaskType());
command.put("triggerValue", encoded.getTriggerValue());
payload.put("encodedCommand", command);
}
return payload;
}
private static List<Map<String, Object>> differencePayload(
List<ShadowDifference> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (ShadowDifference value : values) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
row.put("actualJson", value.getActualJson());
row.put("category", value.getCategory().name());
row.put("expectedJson", value.getExpectedJson());
row.put("reasonCode", value.getReasonCode());
result.add(row);
}
return result;
}
private static List<Map<String, Object>> encodedPayload(
List<Ft546RegisterWrite> values) {
List<Map<String, Object>> result =
new ArrayList<Map<String, Object>>();
for (Ft546RegisterWrite value : values) {
result.add(writePayload(value.getAddress(),
value.getDataType().name(), value.getValues(),
value.getWriteSequence(), value.isTrigger()));
}
return result;
}
private static Map<String, Object> writePayload(
String address, String dataType, List<Integer> values,
int sequence, boolean trigger) {
Map<String, Object> row = new LinkedHashMap<String, Object>();
row.put("address", address);
row.put("dataType", dataType);
row.put("trigger", trigger);
row.put("values", values);
row.put("writeSequence", sequence);
return row;
}
private static String status(ShadowDecision decision) {
if (decision.getType() == ShadowDecision.Type.INSUFFICIENT_FACTS) {
return "INSUFFICIENT_FACTS";
}
if (decision.getType() == ShadowDecision.Type.SOURCE_GAP) {
return "SOURCE_GAP";
}
return "COMPARABLE";
}
private static String join(List<Integer> values) {
if (values.isEmpty()) {
return null;
}
StringBuilder result = new StringBuilder();
for (Integer value : values) {
if (result.length() > 0) {
result.append(',');
}
result.append(value);
}
return result.toString();
}
private static List<Ft546RegisterWrite> immutable(
List<Ft546RegisterWrite> values) {
return Collections.unmodifiableList(
new ArrayList<Ft546RegisterWrite>(values));
}
private static List<ShadowDifference> immutableDifferences(
List<ShadowDifference> values) {
if (values == null || values.isEmpty()) {
throw new IllegalArgumentException(
"differences must not be empty");
}
return Collections.unmodifiableList(
new ArrayList<ShadowDifference>(values));
}
}

View File

@@ -0,0 +1,177 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.ruoyi.ft546.protocol.Ft546EncodedCommand;
import com.ruoyi.shadow.facts.LegacyQueueProtocolProjector;
import com.ruoyi.shadow.facts.ShadowFactBatch;
import com.ruoyi.shadow.facts.ShadowLegacyQueueResult;
import com.ruoyi.shadow.facts.ShadowQueueFact;
import com.ruoyi.wcs.application.dispatch.DispatchCycleResult;
import com.ruoyi.wcs.application.dispatch.GrantedDispatch;
import com.ruoyi.wcs.application.dispatch.HeldDispatch;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
import java.util.UUID;
public final class ShadowCycleEvidenceCollector {
private static final String ACTIVE_QUEUE =
"shadow_ro.v_wcs_srgv_queue";
private final UUID runId;
private final long routeVersion;
private final ObjectMapper objectMapper;
private final LegacyQueueProtocolProjector legacyProjector;
private final ShadowComparisonService comparison;
public ShadowCycleEvidenceCollector(UUID runId, long routeVersion,
ObjectMapper objectMapper) {
this(runId, routeVersion, objectMapper,
new LegacyQueueProtocolProjector(),
new ShadowComparisonService());
}
ShadowCycleEvidenceCollector(
UUID runId, long routeVersion, ObjectMapper objectMapper,
LegacyQueueProtocolProjector legacyProjector,
ShadowComparisonService comparison) {
this.runId = Objects.requireNonNull(runId, "runId");
if (routeVersion <= 0L) {
throw new IllegalArgumentException(
"routeVersion must be positive");
}
this.routeVersion = routeVersion;
this.objectMapper = Objects.requireNonNull(objectMapper,
"objectMapper");
this.legacyProjector = Objects.requireNonNull(legacyProjector,
"legacyProjector");
this.comparison = Objects.requireNonNull(comparison, "comparison");
}
public List<ShadowCycleEvidence> collect(
ShadowFactBatch batch, DispatchCycleResult decisions,
List<Ft546EncodedCommand> encodedCommands, Instant completedAt) {
Objects.requireNonNull(batch, "batch");
Objects.requireNonNull(decisions, "decisions");
Objects.requireNonNull(encodedCommands, "encodedCommands");
if (decisions.getGranted().size() != encodedCommands.size()) {
throw new ShadowEvidenceException(
"ENCODED_COMMAND_COUNT_MISMATCH");
}
List<ShadowCycleEvidence> evidence =
new ArrayList<ShadowCycleEvidence>();
for (int index = 0; index < decisions.getGranted().size(); index++) {
GrantedDispatch grant = decisions.getGranted().get(index);
Ft546EncodedCommand encoded = encodedCommands.get(index);
ShadowQueueFact queue = batch.queue(grant.getStepId());
ShadowLegacyQueueResult legacy = legacy(queue);
ShadowDecision decision = ShadowDecision.granted(
encoded.getParentTaskId(), encoded.getPhase(),
encoded.getRouteVersion(), grant.getVehicleId(),
encoded.getPathPoints());
evidence.add(complete(batch, legacy, decision, encoded,
completedAt));
}
addHeld(evidence, batch, decisions.getHeld(), false, completedAt);
addHeld(evidence, batch, decisions.getYielded(), true, completedAt);
return Collections.unmodifiableList(evidence);
}
public List<ShadowCycleEvidence> insufficient(
ShadowFactBatch batch, String reasonCode, Instant completedAt) {
Objects.requireNonNull(batch, "batch");
List<ShadowCycleEvidence> result =
new ArrayList<ShadowCycleEvidence>();
for (ShadowQueueFact queue : batch.getQueues()) {
if (!ACTIVE_QUEUE.equals(queue.getTableName())) {
continue;
}
ShadowDecision decision = ShadowDecision.insufficient(
parent(queue), phase(queue), routeVersion, reasonCode);
result.add(complete(batch, legacy(queue), decision, null,
completedAt));
}
if (result.isEmpty()) {
ShadowDecision decision = ShadowDecision.insufficient(
"BATCH", "VALIDATION", 0L, reasonCode);
result.add(complete(batch,
ShadowLegacyQueueResult.unavailable("MISSING_ACTIVE_QUEUE"),
decision, null, completedAt));
}
return Collections.unmodifiableList(result);
}
private void addHeld(List<ShadowCycleEvidence> evidence,
ShadowFactBatch batch, List<HeldDispatch> values,
boolean yielded, Instant completedAt) {
for (HeldDispatch held : values) {
ShadowQueueFact queue = batch.queue(held.getStepId());
String parent = queue == null ? held.getOrderId() : parent(queue);
String phase = queue == null ? "UNKNOWN_PHASE" : phase(queue);
ShadowDecision decision;
if (yielded) {
decision = ShadowDecision.yielded(parent, phase,
routeVersion, held.getVehicleId(),
route(held), held.getReasonCode());
} else {
decision = ShadowDecision.held(parent, phase, routeVersion,
held.getVehicleId(), held.getReasonCode());
}
evidence.add(complete(batch, legacy(queue), decision, null,
completedAt));
}
}
private ShadowCycleEvidence complete(
ShadowFactBatch batch, ShadowLegacyQueueResult legacy,
ShadowDecision decision, Ft546EncodedCommand encoded,
Instant completedAt) {
List<ShadowDifference> differences = comparison.compare(legacy,
decision, encoded);
return ShadowCycleEvidence.complete(runId, batch, legacy, decision,
encoded, differences, completedAt, objectMapper);
}
private ShadowLegacyQueueResult legacy(ShadowQueueFact queue) {
return queue == null
? ShadowLegacyQueueResult.unavailable("MISSING_QUEUE_FOR_DECISION")
: legacyProjector.project(queue);
}
private static List<Integer> route(HeldDispatch held) {
if (held.getYieldRoute() == null) {
return Collections.emptyList();
}
List<Integer> result = new ArrayList<Integer>();
for (String node : held.getYieldRoute().getNodeIds()) {
try {
result.add(Integer.valueOf(node));
} catch (NumberFormatException invalid) {
throw new ShadowEvidenceException(
"NON_NUMERIC_YIELD_ROUTE_NODE", invalid);
}
}
return result;
}
private static String parent(ShadowQueueFact queue) {
String taskNo = queue.getTaskNo();
return taskNo == null || taskNo.trim().isEmpty()
? queue.getGuid() : taskNo.trim();
}
private static String phase(ShadowQueueFact queue) {
String taskType = queue.getTaskType();
if ("1".equals(taskType)) {
return "EMPTY_TO_PICKUP";
}
if ("2".equals(taskType)) {
return "LOADED_TO_PLACE";
}
return taskType == null || taskType.trim().isEmpty()
? "UNKNOWN_PHASE" : "LEGACY_PHASE_" + taskType.trim();
}
}

View File

@@ -0,0 +1,103 @@
package com.ruoyi.shadow.evidence;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public final class ShadowDecision {
public enum Type { GRANTED, HELD, YIELDED, INSUFFICIENT_FACTS, SOURCE_GAP }
private final Type type;
private final String parentTaskId;
private final String phase;
private final long routeVersion;
private final String vehicleId;
private final List<Integer> routePoints;
private final String reasonCode;
private ShadowDecision(Type type, String parentTaskId, String phase,
long routeVersion, String vehicleId,
List<Integer> routePoints, String reasonCode) {
if (type == null) {
throw new IllegalArgumentException("type must not be null");
}
if (routeVersion < 0L) {
throw new IllegalArgumentException(
"routeVersion must not be negative");
}
this.type = type;
this.parentTaskId = required(parentTaskId, "parentTaskId");
this.phase = required(phase, "phase");
this.routeVersion = routeVersion;
this.vehicleId = optional(vehicleId);
this.routePoints = immutable(routePoints);
this.reasonCode = optional(reasonCode);
}
public static ShadowDecision granted(String parentTaskId, String phase,
long routeVersion, String vehicleId,
List<Integer> routePoints) {
return new ShadowDecision(Type.GRANTED, parentTaskId, phase,
routeVersion, required(vehicleId, "vehicleId"), routePoints,
null);
}
public static ShadowDecision held(String parentTaskId, String phase,
long routeVersion, String vehicleId,
String reasonCode) {
return new ShadowDecision(Type.HELD, parentTaskId, phase, routeVersion,
vehicleId, Collections.<Integer>emptyList(),
required(reasonCode, "reasonCode"));
}
public static ShadowDecision yielded(String parentTaskId, String phase,
long routeVersion, String vehicleId,
List<Integer> routePoints,
String reasonCode) {
return new ShadowDecision(Type.YIELDED, parentTaskId, phase,
routeVersion, vehicleId, routePoints,
required(reasonCode, "reasonCode"));
}
public static ShadowDecision insufficient(String parentTaskId,
String phase,
long routeVersion,
String reasonCode) {
return new ShadowDecision(Type.INSUFFICIENT_FACTS, parentTaskId,
phase, routeVersion, null, Collections.<Integer>emptyList(),
required(reasonCode, "reasonCode"));
}
public static ShadowDecision sourceGap(String reasonCode) {
return new ShadowDecision(Type.SOURCE_GAP, "SOURCE", "SOURCE_READ",
0L, null, Collections.<Integer>emptyList(),
required(reasonCode, "reasonCode"));
}
public Type getType() { return type; }
public String getParentTaskId() { return parentTaskId; }
public String getPhase() { return phase; }
public long getRouteVersion() { return routeVersion; }
public String getVehicleId() { return vehicleId; }
public List<Integer> getRoutePoints() { return routePoints; }
public String getReasonCode() { return reasonCode; }
private static List<Integer> immutable(List<Integer> values) {
if (values == null) {
throw new IllegalArgumentException("routePoints must not be null");
}
return Collections.unmodifiableList(new ArrayList<Integer>(values));
}
private static String required(String value, String name) {
String normalized = optional(value);
if (normalized == null) {
throw new IllegalArgumentException(name + " must not be blank");
}
return normalized;
}
private static String optional(String value) {
return value == null || value.trim().isEmpty() ? null : value.trim();
}
}

View File

@@ -0,0 +1,32 @@
package com.ruoyi.shadow.evidence;
import java.util.Objects;
public final class ShadowDifference {
private final ShadowDifferenceCategory category;
private final String reasonCode;
private final String expectedJson;
private final String actualJson;
public ShadowDifference(ShadowDifferenceCategory category,
String reasonCode,
String expectedJson,
String actualJson) {
this.category = Objects.requireNonNull(category, "category");
this.reasonCode = required(reasonCode, "reasonCode");
this.expectedJson = expectedJson;
this.actualJson = actualJson;
}
public ShadowDifferenceCategory getCategory() { return category; }
public String getReasonCode() { return reasonCode; }
public String getExpectedJson() { return expectedJson; }
public String getActualJson() { return actualJson; }
private static String required(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
}

View File

@@ -0,0 +1,15 @@
package com.ruoyi.shadow.evidence;
public enum ShadowDifferenceCategory {
MATCH,
PROTOCOL_ADDRESS,
PROTOCOL_DATA_TYPE,
PROTOCOL_VALUE,
PROTOCOL_ORDER,
QUEUE_FIELD,
ALGORITHM_VEHICLE,
ALGORITHM_ROUTE,
ALGORITHM_HOLD,
INSUFFICIENT_FACTS,
SOURCE_GAP
}

View File

@@ -0,0 +1,87 @@
package com.ruoyi.shadow.evidence;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.MapperFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.EnumMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
public final class ShadowEvidenceDigest {
private final long cycleCount;
private final Map<ShadowDifferenceCategory, Long> categoryCounts;
private final String evidenceHash;
ShadowEvidenceDigest(long cycleCount,
Map<ShadowDifferenceCategory, Long> categoryCounts,
String evidenceHash) {
this.cycleCount = cycleCount;
this.categoryCounts = Collections.unmodifiableMap(
new EnumMap<ShadowDifferenceCategory, Long>(categoryCounts));
this.evidenceHash = evidenceHash;
}
public long getCycleCount() { return cycleCount; }
public Map<ShadowDifferenceCategory, Long> getCategoryCounts() {
return categoryCounts;
}
public String getEvidenceHash() { return evidenceHash; }
static ShadowEvidenceDigest create(
List<String> cycleHashes,
Map<ShadowDifferenceCategory, Long> categoryCounts,
ObjectMapper objectMapper) {
Map<String, Object> payload = new LinkedHashMap<String, Object>();
payload.put("categoryCounts", namedCounts(categoryCounts));
payload.put("cycleHashes", cycleHashes);
return new ShadowEvidenceDigest(cycleHashes.size(), categoryCounts,
sha256(canonicalJson(objectMapper, payload)));
}
static String canonicalJson(ObjectMapper source, Object value) {
ObjectMapper mapper = source.copy();
mapper.configure(MapperFeature.SORT_PROPERTIES_ALPHABETICALLY, true);
mapper.configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
try {
return mapper.writeValueAsString(value);
} catch (JsonProcessingException failure) {
throw new ShadowEvidenceException(
"EVIDENCE_SERIALIZATION_FAILED", failure);
}
}
static String sha256(String value) {
try {
byte[] digest = MessageDigest.getInstance("SHA-256").digest(
value.getBytes(StandardCharsets.UTF_8));
StringBuilder hex = new StringBuilder(64);
for (byte item : digest) {
hex.append(String.format("%02x", item & 0xff));
}
return hex.toString();
} catch (NoSuchAlgorithmException impossible) {
throw new IllegalStateException("SHA-256 is unavailable", impossible);
}
}
private static Map<String, Long> namedCounts(
Map<ShadowDifferenceCategory, Long> values) {
Map<String, Long> result = new LinkedHashMap<String, Long>();
for (ShadowDifferenceCategory category
: ShadowDifferenceCategory.values()) {
Long count = values.get(category);
if (count != null) {
result.put(category.name(), count);
}
}
return result;
}
}

View File

@@ -0,0 +1,24 @@
package com.ruoyi.shadow.evidence;
public final class ShadowEvidenceException extends IllegalStateException {
private final String reasonCode;
public ShadowEvidenceException(String reasonCode) {
super(required(reasonCode));
this.reasonCode = reasonCode.trim();
}
public ShadowEvidenceException(String reasonCode, Throwable cause) {
super(required(reasonCode), cause);
this.reasonCode = reasonCode.trim();
}
public String getReasonCode() { return reasonCode; }
private static String required(String value) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("reasonCode must not be blank");
}
return value.trim();
}
}

View File

@@ -0,0 +1,43 @@
package com.ruoyi.shadow.evidence;
import java.time.LocalDate;
import java.time.ZoneId;
import java.util.List;
import java.util.UUID;
import java.util.function.Supplier;
public interface ShadowEvidenceRepository {
SavedCycle save(ShadowCycleEvidence evidence);
List<SavedCycle> saveAll(List<ShadowCycleEvidence> evidence);
<T> T inTransaction(Supplier<T> work);
SavedCycle findCycle(UUID runId, String inputBatchHash,
String parentTaskId, String phase,
long routeVersion);
ShadowEvidenceDigest dailyDigest(LocalDate date, ZoneId zoneId);
boolean isHealthy();
final class SavedCycle {
private final UUID cycleId;
private final String evidenceHash;
private final String status;
private final String reasonCode;
public SavedCycle(UUID cycleId, String evidenceHash,
String status, String reasonCode) {
this.cycleId = cycleId;
this.evidenceHash = evidenceHash;
this.status = status;
this.reasonCode = reasonCode;
}
public UUID getCycleId() { return cycleId; }
public String getEvidenceHash() { return evidenceHash; }
public String getStatus() { return status; }
public String getReasonCode() { return reasonCode; }
}
}

View File

@@ -0,0 +1,39 @@
package com.ruoyi.shadow.evidence;
public interface ShadowEvidenceStorageHealth {
Check verify();
final class Check {
private final boolean available;
private final String reasonCode;
private final long usableBytes;
private Check(boolean available, String reasonCode,
long usableBytes) {
this.available = available;
this.reasonCode = reasonCode;
this.usableBytes = usableBytes;
}
public static Check available(long usableBytes) {
if (usableBytes < 0L) {
throw new IllegalArgumentException(
"usableBytes must not be negative");
}
return new Check(true, "EVIDENCE_STORAGE_AVAILABLE",
usableBytes);
}
public static Check unavailable(String reasonCode) {
if (reasonCode == null || reasonCode.trim().isEmpty()) {
throw new IllegalArgumentException(
"reasonCode must not be blank");
}
return new Check(false, reasonCode.trim(), -1L);
}
public boolean isAvailable() { return available; }
public String getReasonCode() { return reasonCode; }
public long getUsableBytes() { return usableBytes; }
}
}

View File

@@ -0,0 +1,63 @@
package com.ruoyi.shadow.evidence;
import java.time.Instant;
import java.util.UUID;
public final class ShadowRunEvidence {
private final UUID runId;
private final Instant startedAt;
private final String artifactSha256;
private final String configurationSha256;
private final String sourceServer;
private final String sourceDatabase;
private final String sourceLogin;
private final String status;
public ShadowRunEvidence(UUID runId, Instant startedAt,
String artifactSha256,
String configurationSha256,
String sourceServer, String sourceDatabase,
String sourceLogin, String status) {
this.runId = required(runId, "runId");
this.startedAt = required(startedAt, "startedAt");
this.artifactSha256 = hash(artifactSha256, "artifactSha256");
this.configurationSha256 = hash(configurationSha256,
"configurationSha256");
this.sourceServer = text(sourceServer, "sourceServer");
this.sourceDatabase = text(sourceDatabase, "sourceDatabase");
this.sourceLogin = text(sourceLogin, "sourceLogin");
this.status = text(status, "status");
}
public UUID getRunId() { return runId; }
public Instant getStartedAt() { return startedAt; }
public String getArtifactSha256() { return artifactSha256; }
public String getConfigurationSha256() { return configurationSha256; }
public String getSourceServer() { return sourceServer; }
public String getSourceDatabase() { return sourceDatabase; }
public String getSourceLogin() { return sourceLogin; }
public String getStatus() { return status; }
private static String hash(String value, String name) {
String normalized = text(value, name).toLowerCase();
if (!normalized.matches("[0-9a-f]{64}")) {
throw new IllegalArgumentException(
name + " must be a 64-character SHA-256 value");
}
return normalized;
}
private static String text(String value, String name) {
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException(name + " must not be blank");
}
return value.trim();
}
private static <T> T required(T value, String name) {
if (value == null) {
throw new IllegalArgumentException(name + " must not be null");
}
return value;
}
}