feat(ft546): persist shadow comparison evidence
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user