From 27428b31edee97703b529559146cdd7eef87a7a9 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 05 九月 2026 11:00:58 +0800
Subject: [PATCH] 完成测试数据接收持久化与查询
---
ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java | 42 ++
battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java | 41 +
ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql | 64 +++
ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml | 91 ++++
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java | 29 +
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java | 15
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java | 27 +
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java | 3
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java | 59 ++
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java | 15
ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java | 83 +++
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java | 323 ++++++++------
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java | 32 +
battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java | 3
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java | 422 +++++++------------
15 files changed, 840 insertions(+), 409 deletions(-)
diff --git a/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java b/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
new file mode 100644
index 0000000..849ad5a
--- /dev/null
+++ b/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
@@ -0,0 +1,41 @@
+package com.battery.device.api;
+
+import java.math.BigDecimal;
+import java.time.Instant;
+import java.util.List;
+import com.battery.device.model.ChannelIdentity;
+import com.battery.device.model.Measurement;
+
+/** 供业务持久化使用的厂商无关测试数据观测扩展。 */
+public interface TestDataObservationAdapter extends DeviceObservationAdapter
+{
+ CabinetRealtime latestCabinet(ChannelIdentity channel);
+
+ CollectorRealtime latestCollector(long collectorId);
+
+ List<StepRecord> stepRecords(ChannelIdentity channel);
+
+ List<CycleRecord> cycleRecords(ChannelIdentity channel);
+
+ long persistenceIntervalMillis(ChannelIdentity channel);
+
+ record CabinetRealtime(long sequenceId, Instant deviceTime, double currentA, double voltageV,
+ double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType,
+ Instant serverReceivedAt) { }
+
+ record CollectorRealtime(String collectorCode, boolean online, int sampleIntervalMs,
+ List<CollectorMeasurement> measurements)
+ {
+ public CollectorRealtime { measurements = measurements == null ? List.of() : List.copyOf(measurements); }
+ }
+
+ record CollectorMeasurement(String channelCode, long sequenceId, Instant deviceTime, String deviceTimeRaw,
+ Instant serverReceivedAt, String metricCode, BigDecimal value, String unit) { }
+
+ record StepRecord(String sourceRecordId, int cycleNo, int stepNo, String stepName, String stepType,
+ Instant startedAt, Instant endedAt, double endVoltageV, double capacityAh, double energyWh) { }
+
+ record CycleRecord(String sourceRecordId, int cycleNo, Instant startedAt, Instant endedAt,
+ double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh,
+ double dischargeEnergyWh, double efficiency) { }
+}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
index fdb900b..5479ccc 100644
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
+++ b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -20,6 +20,7 @@
import com.battery.device.model.ChannelIdentity;
import com.battery.device.model.AdapterCapabilities;
import com.battery.device.api.DeviceObservationAdapter;
+import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.simulator.BtsSimulatorStateStore;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelState;
import com.battery.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
@@ -43,7 +44,7 @@
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
-public final class BtsDevelopmentSimulator implements DeviceObservationAdapter
+public final class BtsDevelopmentSimulator implements TestDataObservationAdapter
{
public static final String HOST = "127.0.0.1";
public static final int DEFAULT_PORT = 15020;
@@ -494,6 +495,46 @@
BtsPlaybackState playback = playbackState(btsKey(device, channelId), Instant.now());
return Math.max(100L, 60000L / Math.max(1, playback.speedMultiplier()));
}
+
+ @Override
+ public synchronized CabinetRealtime latestCabinet(ChannelIdentity channel)
+ {
+ BtsHistoryRecord record = btsRecords(channel.devid(), channel.chlid()).stream()
+ .max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).orElse(null);
+ return record == null ? null : new CabinetRealtime(record.sequenceId(), record.deviceTime(), record.currentA(),
+ record.voltageV(), record.capacityAh(), record.energyWh(), record.cycleNo(), record.stepNo(),
+ record.stepName(), record.stepType(), record.serverReceivedAt());
+ }
+
+ @Override
+ public synchronized CollectorRealtime latestCollector(long collectorId)
+ {
+ CollectorRealtimeSnapshot snapshot = collectorRealtime((int) collectorId);
+ return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(),
+ snapshot.measurements().stream().map(item -> new CollectorMeasurement(item.channelCode(), item.sequenceId(),
+ item.deviceTime(), item.deviceTimeRaw(), item.serverReceivedAt(), item.metricCode(), item.value(),
+ item.unit())).toList());
+ }
+
+ @Override
+ public synchronized List<StepRecord> stepRecords(ChannelIdentity channel)
+ {
+ return btsSteps(channel.devid(), channel.chlid()).stream().map(item -> new StepRecord(item.recordNo(),
+ item.cycleNo(), item.stepNo(), item.stepName(), item.stepType(), item.startedAt(), item.endedAt(),
+ item.endVoltageV(), item.capacityAh(), item.energyWh())).toList();
+ }
+
+ @Override
+ public synchronized List<CycleRecord> cycleRecords(ChannelIdentity channel)
+ {
+ return btsCycles(channel.devid(), channel.chlid()).stream().map(item -> new CycleRecord(item.recordNo(),
+ item.cycleNo(), item.startedAt(), item.endedAt(), item.chargeCapacityAh(), item.dischargeCapacityAh(),
+ item.chargeEnergyWh(), item.dischargeEnergyWh(), item.efficiency())).toList();
+ }
+
+ @Override
+ public synchronized long persistenceIntervalMillis(ChannelIdentity channel)
+ { return persistenceIntervalMillis(channel.devid(), channel.chlid()); }
/** 按实际出现的循环和工步汇总 record,不补齐缺失行。 */
public synchronized List<BtsStepRecord> btsSteps(int deviceId, int channelId)
@@ -1004,7 +1045,19 @@
public record BtsStepRecord(int cycleNo, int stepNo, Integer cyclePosition, String stepName, String stepType, long recordCount,
Instant startedAt, Instant endedAt, double durationSeconds, double capacityAh, double energyWh,
- double startVoltageV, double endVoltageV, double chargePeakVoltageV, double dischargeValleyVoltageV) { }
+ double startVoltageV, double endVoltageV, double chargePeakVoltageV, double dischargeValleyVoltageV,
+ String recordNo)
+ {
+ public BtsStepRecord(int cycleNo, int stepNo, Integer cyclePosition, String stepName, String stepType,
+ long recordCount, Instant startedAt, Instant endedAt, double durationSeconds, double capacityAh,
+ double energyWh, double startVoltageV, double endVoltageV, double chargePeakVoltageV,
+ double dischargeValleyVoltageV)
+ {
+ this(cycleNo, stepNo, cyclePosition, stepName, stepType, recordCount, startedAt, endedAt,
+ durationSeconds, capacityAh, energyWh, startVoltageV, endVoltageV, chargePeakVoltageV,
+ dischargeValleyVoltageV, null);
+ }
+ }
public record BtsCycleRecord(String recordNo, int cycleNo, long recordCount, Instant startedAt, Instant endedAt,
double chargeCapacityAh, double dischargeCapacityAh, double chargeEnergyWh, double dischargeEnergyWh,
@@ -1031,7 +1084,7 @@
private double startVoltage; private double endVoltage; private double chargePeak = Double.NEGATIVE_INFINITY; private double dischargeValley = Double.POSITIVE_INFINITY;
BtsStepAccumulator(BtsHistoryRecord first, int position) { cycle = first.cycleNo(); step = first.stepNo(); this.position = position; name = first.stepName(); type = first.stepType(); }
void add(BtsHistoryRecord item) { count++; if (first == null) { first = item.deviceTime(); startVoltage = item.voltageV(); } last = item.deviceTime(); endVoltage = item.voltageV(); duration = first == null ? 0 : Math.max(0, java.time.Duration.between(first, last).toMillis() / 1000d); capacity = item.capacityAh(); energy = item.energyWh(); if (item.currentA() >= 0) chargePeak = Math.max(chargePeak, item.voltageV()); else dischargeValley = Math.min(dischargeValley, item.voltageV()); }
- BtsStepRecord finish() { return new BtsStepRecord(cycle, step, position, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley); }
+ BtsStepRecord finish() { return new BtsStepRecord(cycle, step, position, name, type, count, first, last, duration, capacity, energy, startVoltage, endVoltage, chargePeak == Double.NEGATIVE_INFINITY ? 0 : chargePeak, dischargeValley == Double.POSITIVE_INFINITY ? 0 : dischargeValley, "STEP-" + cycle + "-" + step + "-" + last.toEpochMilli()); }
}
private static final class BtsCycleAccumulator
diff --git a/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java b/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java
index 63b0910..9e9efee 100644
--- a/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java
+++ b/battery-device-simulator/src/test/java/com/battery/device/simulator/BtsDevelopmentSimulatorRecordStepCycleTest.java
@@ -31,7 +31,8 @@
assertEquals(1L, records.get(0).sequenceId());
assertEquals(60L, records.get(1).deviceTime().getEpochSecond() - records.get(0).deviceTime().getEpochSecond());
assertEquals(1, records.get(0).cycleNo());
- assertTrue(simulator.btsSteps(1, 1).stream().anyMatch(step -> step.stepNo() == 1));
+ assertTrue(simulator.btsSteps(1, 1).stream().anyMatch(step -> step.stepNo() == 1
+ && step.recordNo() != null && !step.recordNo().isBlank()));
var cycle = simulator.btsCycles(1, 1).stream().filter(item -> item.cycleNo() == 1).findFirst().orElseThrow();
assertTrue(cycle.chargeCapacityAh() >= 0d);
assertTrue(cycle.dischargeCapacityAh() >= 0d);
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql b/ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql
new file mode 100644
index 0000000..9170246
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V3__test_data_persistence.sql
@@ -0,0 +1,64 @@
+ALTER TABLE t_test_realtime_record
+ ADD COLUMN source_identity VARCHAR(256) NULL AFTER adapter_code,
+ ADD COLUMN source_record_id VARCHAR(128) NULL AFTER source_identity;
+
+UPDATE t_test_realtime_record
+SET source_identity = adapter_code,
+ source_record_id = CASE WHEN cabinet_sequence_id IS NOT NULL THEN CONCAT('cabinet:', cabinet_sequence_id)
+ WHEN collector_sequence_id IS NOT NULL THEN CONCAT('collector:', collector_sequence_id)
+ ELSE NULL END
+WHERE source_identity IS NULL;
+
+ALTER TABLE t_test_realtime_record
+ MODIFY COLUMN source_identity VARCHAR(256) NOT NULL,
+ ADD KEY idx_realtime_source (test_battery_id, source_identity, source_record_id);
+
+ALTER TABLE t_test_step_record
+ ADD COLUMN binding_id BIGINT NULL AFTER test_battery_id,
+ ADD COLUMN dedupe_key VARCHAR(512) NULL AFTER source_record_id,
+ ADD COLUMN server_received_at_utc DATETIME(3) NULL AFTER device_time_raw;
+
+UPDATE t_test_step_record record_fact
+JOIN t_test_channel_binding binding ON binding.test_id = record_fact.test_id
+ AND binding.test_battery_id = record_fact.test_battery_id
+SET record_fact.binding_id = binding.binding_id,
+ record_fact.server_received_at_utc = record_fact.device_time_utc,
+ record_fact.dedupe_key = CASE
+ WHEN record_fact.source_record_id IS NOT NULL AND TRIM(record_fact.source_record_id) <> ''
+ THEN CONCAT('record:', record_fact.source_record_id)
+ ELSE CONCAT('fallback:', DATE_FORMAT(record_fact.device_time_utc, '%Y-%m-%dT%H:%i:%s.%fZ'),
+ ':', record_fact.cycle_no, ':', record_fact.step_no)
+ END;
+
+ALTER TABLE t_test_step_record
+ MODIFY COLUMN binding_id BIGINT NOT NULL,
+ MODIFY COLUMN dedupe_key VARCHAR(512) NOT NULL,
+ MODIFY COLUMN server_received_at_utc DATETIME(3) NOT NULL,
+ DROP INDEX uk_step_identity,
+ ADD UNIQUE KEY uk_step_dedupe (test_battery_id, source_identity(128), dedupe_key(128)),
+ ADD KEY idx_step_binding_time (binding_id, server_received_at_utc);
+
+ALTER TABLE t_test_cycle_record
+ ADD COLUMN binding_id BIGINT NULL AFTER test_battery_id,
+ ADD COLUMN dedupe_key VARCHAR(512) NULL AFTER source_record_id,
+ ADD COLUMN server_received_at_utc DATETIME(3) NULL AFTER device_time_raw;
+
+UPDATE t_test_cycle_record record_fact
+JOIN t_test_channel_binding binding ON binding.test_id = record_fact.test_id
+ AND binding.test_battery_id = record_fact.test_battery_id
+SET record_fact.binding_id = binding.binding_id,
+ record_fact.server_received_at_utc = record_fact.device_time_utc,
+ record_fact.dedupe_key = CASE
+ WHEN record_fact.source_record_id IS NOT NULL AND TRIM(record_fact.source_record_id) <> ''
+ THEN CONCAT('record:', record_fact.source_record_id)
+ ELSE CONCAT('fallback:', DATE_FORMAT(record_fact.device_time_utc, '%Y-%m-%dT%H:%i:%s.%fZ'),
+ ':', record_fact.cycle_no)
+ END;
+
+ALTER TABLE t_test_cycle_record
+ MODIFY COLUMN binding_id BIGINT NOT NULL,
+ MODIFY COLUMN dedupe_key VARCHAR(512) NOT NULL,
+ MODIFY COLUMN server_received_at_utc DATETIME(3) NOT NULL,
+ DROP INDEX uk_cycle_identity,
+ ADD UNIQUE KEY uk_cycle_dedupe (test_battery_id, source_identity(128), dedupe_key(128)),
+ ADD KEY idx_cycle_binding_time (binding_id, server_received_at_utc);
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java
new file mode 100644
index 0000000..b984698
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/TestDataPersistenceMigrationContractTest.java
@@ -0,0 +1,42 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import org.junit.jupiter.api.Test;
+
+/** Ticket08 三类测试数据持久化结构契约。 */
+class TestDataPersistenceMigrationContractTest
+{
+ @Test
+ void stepAndCycleRecordsKeepBindingReceptionTimeAndStableDedupeKey()
+ {
+ String sql = resource("db/migration-final/V3__test_data_persistence.sql");
+
+ assertTrue(sql.contains("ALTER TABLE t_test_realtime_record"));
+ assertTrue(sql.contains("source_identity VARCHAR(256) NOT NULL"));
+ assertTrue(sql.contains("source_record_id VARCHAR(128) NULL"));
+ assertTrue(sql.contains("ALTER TABLE t_test_step_record"));
+ assertTrue(sql.contains("ALTER TABLE t_test_cycle_record"));
+ assertTrue(sql.contains("binding_id BIGINT NOT NULL"));
+ assertTrue(sql.contains("server_received_at_utc DATETIME(3) NOT NULL"));
+ assertTrue(sql.contains("dedupe_key VARCHAR(512) NOT NULL"));
+ assertTrue(sql.contains("UNIQUE KEY uk_step_dedupe"));
+ assertTrue(sql.contains("UNIQUE KEY uk_cycle_dedupe"));
+ }
+
+ private static String resource(String path)
+ {
+ try (InputStream stream = TestDataPersistenceMigrationContractTest.class.getClassLoader()
+ .getResourceAsStream(path))
+ {
+ if (stream == null) throw new AssertionError("缺少资源 " + path);
+ return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ catch (Exception exception)
+ {
+ throw new AssertionError("无法读取资源 " + path, exception);
+ }
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
index bc9aefa..8158ab4 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataQueryServiceEffectiveDataTest.java
@@ -67,6 +67,21 @@
}
@Test
+ void readsFinalPersistedTablesWhenAvailableAndReturnsEpochMillis()
+ {
+ when(tests.selectTestById(11L)).thenReturn(new TTest());
+ Map<String, Object> row = row("recordNo", "RT-1", "deviceTime", java.util.Date.from(Instant.parse("2026-09-02T00:00:00Z")),
+ "serverReceivedAt", java.util.Date.from(Instant.parse("2026-09-02T00:00:01Z")));
+ when(mapper.selectPersistedFinalRealtimeRecords(11L)).thenReturn(List.of(row));
+ when(mapper.selectDataOverrides(11L, "REALTIME")).thenReturn(List.of());
+
+ Map<String, Object> actual = new TestDataQueryServiceImpl(mapper, tests).records(11L).get(0);
+
+ assertEquals(Instant.parse("2026-09-02T00:00:00Z").toEpochMilli(), actual.get("deviceTime"));
+ assertEquals(Instant.parse("2026-09-02T00:00:01Z").toEpochMilli(), actual.get("serverReceivedAt"));
+ }
+
+ @Test
void assignsCollectorObservationToCabinetStepByDeviceTime()
{
when(tests.selectTestById(9L)).thenReturn(new TTest());
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java
index 5a14a85..16cab20 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImplTest.java
@@ -1,188 +1,241 @@
package com.ruoyi.system.service.impl;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.ArgumentMatchers.anyString;
-import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.eq;
-import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
-import java.time.Instant;
-import java.util.Date;
-import java.util.List;
import java.math.BigDecimal;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.ArgumentCaptor;
+import org.springframework.boot.test.system.CapturedOutput;
+import org.springframework.boot.test.system.OutputCaptureExtension;
import com.battery.device.simulator.BtsDevelopmentSimulator;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsCycleRecord;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsStepRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
-import com.ruoyi.system.domain.TTestCollectorChannel;
import com.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestBattery;
import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCollectorChannel;
+import com.ruoyi.system.domain.TTestCycleRecord;
+import com.ruoyi.system.domain.TTestRealtimeRecord;
+import com.ruoyi.system.domain.TTestStepRecord;
import com.ruoyi.system.mapper.TTestMapper;
import com.ruoyi.system.service.ITTestService;
-/** 接收服务只应把本次测试开始后的模拟器事实归入测试。 */
+/** Ticket08 通过接收入口验证三类持久化事实。 */
+@ExtendWith(OutputCaptureExtension.class)
class TestDataReceptionServiceImplTest
{
+ private static final Instant CAPTURED_AT = Instant.parse("2026-09-02T04:01:00Z");
+ private static final Clock CLOCK = Clock.fixed(CAPTURED_AT, ZoneOffset.UTC);
+
@Test
- void ignoresRecordsBeforeReceptionStart()
+ void savesOneCombinedRealtimeRowForOneBatteryPollingSlot()
+ {
+ Fixture fixture = fixture();
+ when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord()));
+ when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(true, List.of(
+ collector("S-01", 10L, "TEMPERATURE", "25.100", "℃"),
+ collector("S-02", 10L, "POSITIVE_POTENTIAL", "1.620", "V"),
+ collector("S-03", 10L, "NEGATIVE_POTENTIAL", "1.610", "V"),
+ collector("S-04", 10L, "AMBIENT_TEMPERATURE", "23.400", "℃"))));
+ when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
+
+ fixture.service().captureDevelopmentRealtimeSnapshots();
+
+ ArgumentCaptor<TTestRealtimeRecord> saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+ verify(fixture.mapper).insertRealtimeRecord(saved.capture());
+ TTestRealtimeRecord row = saved.getValue();
+ assertEquals(1L, row.getTestId());
+ assertEquals(4L, row.getTestBatteryId());
+ assertEquals(2L, row.getBindingId());
+ assertEquals("dev-sim:22:1:1:1", row.getSourceIdentity());
+ assertEquals("cabinet:7:collector:10", row.getSourceRecordId());
+ assertEquals(Date.from(Instant.parse("2026-09-02T04:00:30Z")), row.getCabinetDeviceTimeUtc());
+ assertEquals(Date.from(Instant.parse("2026-09-02T04:00:40Z")), row.getCollectorDeviceTimeUtc());
+ assertEquals(new BigDecimal("25.100"), row.getTemperature());
+ assertEquals(new BigDecimal("1.620"), row.getPositivePotential());
+ assertEquals(new BigDecimal("1.610"), row.getNegativePotential());
+ assertTrue(row.getExtensionMeasurementsJson().contains("AMBIENT_TEMPERATURE"));
+ assertTrue(!row.getExtensionMeasurementsJson().contains("POSITIVE_POTENTIAL"));
+ }
+
+ @Test
+ void savesCollectorFieldsWhenCabinetSourceFails()
+ {
+ Fixture fixture = fixture();
+ when(fixture.simulator.btsRecords(1, 1)).thenThrow(new IllegalArgumentException("offline"));
+ when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(true,
+ List.of(collector("S-01", 11L, "TEMPERATURE", "26.2", "℃"))));
+ when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
+
+ fixture.service().captureDevelopmentRealtimeSnapshots();
+
+ ArgumentCaptor<TTestRealtimeRecord> saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+ verify(fixture.mapper).insertRealtimeRecord(saved.capture());
+ assertNull(saved.getValue().getCabinetSequenceId());
+ assertNull(saved.getValue().getCabinetDevtype());
+ assertNull(saved.getValue().getCabinetAdapterTimezoneId());
+ assertNull(saved.getValue().getVoltageV());
+ assertEquals(11L, saved.getValue().getCollectorSequenceId());
+ assertEquals("collector:11", saved.getValue().getSourceRecordId());
+ assertEquals(new BigDecimal("26.2"), saved.getValue().getTemperature());
+ assertNull(saved.getValue().getPositivePotential());
+ }
+
+ @Test
+ void savesOnlyDeviceReturnedStepAndCycleRecords()
+ {
+ Fixture fixture = fixture();
+ Instant started = Instant.parse("2026-09-02T04:00:10Z");
+ Instant ended = Instant.parse("2026-09-02T04:00:50Z");
+ when(fixture.simulator.btsSteps(1, 1)).thenReturn(List.of(new BtsStepRecord(
+ 1, 2, 2, "恒流充电", "cc", 41, started, ended, 40,
+ 2.5, 8.4, 3.1, 3.4, 3.4, 0)));
+ when(fixture.simulator.btsCycles(1, 1)).thenReturn(List.of(new BtsCycleRecord(
+ "CYCLE-1-1-1", 1, 80, started, ended, 2.5, 2.4, 8.4, 8.0, 96)));
+ when(fixture.mapper.insertStepRecord(any())).thenReturn(1);
+ when(fixture.mapper.insertCycleRecord(any())).thenReturn(1);
+
+ fixture.service().captureDevelopmentCabinetRecords();
+
+ ArgumentCaptor<TTestStepRecord> step = ArgumentCaptor.forClass(TTestStepRecord.class);
+ ArgumentCaptor<TTestCycleRecord> cycle = ArgumentCaptor.forClass(TTestCycleRecord.class);
+ verify(fixture.mapper).insertStepRecord(step.capture());
+ verify(fixture.mapper).insertCycleRecord(cycle.capture());
+ assertEquals("fallback:2026-09-02T04:00:50Z:1:2", step.getValue().getDedupeKey());
+ assertEquals("CYCLE-1-1-1", cycle.getValue().getSourceRecordId());
+ assertEquals("record:CYCLE-1-1-1", cycle.getValue().getDedupeKey());
+ assertEquals(Date.from(CAPTURED_AT), step.getValue().getServerReceivedAtUtc());
+ assertEquals(Date.from(CAPTURED_AT), cycle.getValue().getServerReceivedAtUtc());
+ }
+
+ @Test
+ void ignoresSameRealtimeContentOnRepeatedReception()
+ {
+ Fixture fixture = fixture();
+ when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord()));
+ when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(false, List.of()));
+ when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
+
+ fixture.service().captureDevelopmentRealtimeSnapshots();
+ ArgumentCaptor<TTestRealtimeRecord> saved = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+ verify(fixture.mapper).insertRealtimeRecord(saved.capture());
+ when(fixture.mapper.selectRealtimeRecordContentHash(eq(1L), eq(4L), any(Date.class)))
+ .thenReturn(HexFormat.of().formatHex(saved.getValue().getContentSha256()));
+
+ fixture.service().captureDevelopmentRealtimeSnapshots();
+
+ verify(fixture.mapper, times(1)).insertRealtimeRecord(any());
+ }
+
+ @Test
+ void rejectsConflictingRealtimeContentAndWritesApplicationLog(CapturedOutput output)
+ {
+ Fixture fixture = fixture();
+ when(fixture.simulator.btsRecords(1, 1)).thenReturn(List.of(cabinetRecord()));
+ when(fixture.simulator.collectorRealtime(9)).thenReturn(collectorSnapshot(false, List.of()));
+ when(fixture.mapper.selectRealtimeRecordContentHash(eq(1L), eq(4L), any(Date.class)))
+ .thenReturn("different-content");
+
+ fixture.service().captureDevelopmentRealtimeSnapshots();
+
+ verify(fixture.mapper, never()).insertRealtimeRecord(any());
+ assertTrue(output.getOut().contains("实时采样内容冲突"));
+ }
+
+ private static Fixture fixture()
{
TTestMapper mapper = mock(TTestMapper.class);
ITTestService tests = mock(ITTestService.class);
BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
- TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
+ TTest test = receivingTest();
when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
when(tests.selectTestById(1L)).thenReturn(test);
- when(simulator.btsRecords(1, 1)).thenReturn(List.of(
- record(1L, "2026-09-02T03:59:59Z"), record(2L, "2026-09-02T04:00:01Z")));
- when(mapper.selectBtsSampleContentHash(any(), anyString(), any())).thenReturn(null);
- when(mapper.insertBtsSample(any(), any(), any(), anyString(), any(), any(Date.class), anyString(),
- any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(), any(BigDecimal.class),
- any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(byte[].class))).thenReturn(1);
-
- new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCabinetRecords();
-
- verify(mapper, times(1)).insertBtsSample(eq(1L), eq(2L), eq(3L), anyString(), eq(2L), any(Date.class),
- anyString(), any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(),
- any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class),
- any(byte[].class));
+ when(simulator.persistenceIntervalMillis(1, 1)).thenReturn(60000L);
+ return new Fixture(mapper, tests, simulator);
}
- @Test
- void doesNotWriteSamplesWhenNoSuccessfulRecordsAreAvailable()
- {
- TTestMapper mapper = mock(TTestMapper.class);
- ITTestService tests = mock(ITTestService.class);
- BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
- TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
- when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
- when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
- when(tests.selectTestById(1L)).thenReturn(test);
- when(simulator.btsRecords(1, 1)).thenReturn(List.of());
-
- new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCabinetRecords();
-
- verify(mapper, never()).insertBtsSample(any(), any(), any(), anyString(), any(), any(Date.class), anyString(),
- any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(), any(BigDecimal.class),
- any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(byte[].class));
- }
-
- @Test
- void ordinaryEditsDoNotMoveReceptionBoundary()
- {
- TTestMapper mapper = mock(TTestMapper.class);
- ITTestService tests = mock(ITTestService.class);
- BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
- TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
- test.setUpdateTime(Date.from(Instant.parse("2026-09-02T04:00:10Z")));
- when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
- when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
- when(tests.selectTestById(1L)).thenReturn(test);
- when(simulator.btsRecords(1, 1)).thenReturn(List.of(
- record(1L, "2026-09-02T04:00:05Z"), record(2L, "2026-09-02T04:00:11Z")));
- when(mapper.selectBtsSampleContentHash(any(), anyString(), any())).thenReturn(null);
- when(mapper.insertBtsSample(any(), any(), any(), anyString(), any(), any(Date.class), anyString(),
- any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(), any(BigDecimal.class),
- any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(byte[].class))).thenReturn(1);
-
- new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCabinetRecords();
-
- verify(mapper, times(2)).insertBtsSample(eq(1L), eq(2L), eq(3L), anyString(), any(), any(Date.class),
- anyString(), any(Date.class), anyInt(), anyInt(), anyString(), anyString(), isNull(),
- any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class), any(BigDecimal.class),
- any(byte[].class));
- }
-
- @Test
- void capturesOnlyMappedCollectorRealtimeMeasurementsAfterReceptionStart()
- {
- TTestMapper mapper = mock(TTestMapper.class);
- ITTestService tests = mock(ITTestService.class);
- BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
- TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
- test.setReceptionBoundaryAtUtc(Date.from(Instant.parse("2026-09-02T04:00:01.500Z")));
- test.getBatteries().get(0).setCollectorId(9L);
- TTestCollectorChannel mapping = new TTestCollectorChannel();
- mapping.setMeasurementCode("temperature"); mapping.setChannelCode("S-01");
- test.getBatteries().get(0).setCollectorChannels(List.of(mapping));
- when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
- when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
- when(tests.selectTestById(1L)).thenReturn(test);
- CollectorRealtimeMeasurement matched = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-01",
- 3L, Instant.parse("2026-09-02T04:00:01Z"), "2026-09-02 12:00:01.000",
- Instant.parse("2026-09-02T04:00:02Z"), "temperature", new BigDecimal("25.1"), "℃");
- CollectorRealtimeMeasurement ignored = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-02",
- 3L, matched.deviceTime(), matched.deviceTimeRaw(), matched.serverReceivedAt(), "temperature",
- new BigDecimal("25.2"), "℃");
- CollectorRealtimeMeasurement beforeBoundary = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-01",
- 2L, Instant.parse("2026-09-02T04:00:00Z"), "2026-09-02 12:00:00.000",
- Instant.parse("2026-09-02T04:00:01Z"), "temperature", new BigDecimal("24.9"), "℃");
- when(simulator.collectorRealtime(9)).thenReturn(new CollectorRealtimeSnapshot("collector-9", true, 1000,
- List.of(matched, ignored, beforeBoundary)));
- when(mapper.selectCollectorSampleContentHash(any(), anyString(), any(), anyString())).thenReturn(null);
- when(mapper.insertCollectorSample(any(), any(), any(), anyString(), anyString(), any(), any(Date.class), anyString(),
- any(Date.class), anyString(), any(BigDecimal.class), anyString(), any(byte[].class))).thenReturn(1);
-
- new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCollectorMeasurements();
-
- verify(mapper).insertCollectorSample(eq(1L), eq(2L), eq(9L), eq("dev-sim"), eq("S-01"), eq(3L), any(Date.class),
- eq("2026-09-02 12:00:01.000"), any(Date.class), eq("temperature"), eq(new BigDecimal("25.1")),
- eq("℃"), any(byte[].class));
- verify(mapper, never()).insertCollectorSample(eq(1L), eq(2L), eq(9L), anyString(), eq("S-02"), any(), any(Date.class),
- anyString(), any(Date.class), anyString(), any(BigDecimal.class), anyString(), any(byte[].class));
- }
-
- @Test
- void recordsCollectorContentConflictWithoutOverwritingOriginal()
- {
- TTestMapper mapper = mock(TTestMapper.class);
- ITTestService tests = mock(ITTestService.class);
- BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
- TTest test = test(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
- test.getBatteries().get(0).setCollectorId(9L);
- TTestCollectorChannel mapping = new TTestCollectorChannel(); mapping.setMeasurementCode("temperature"); mapping.setChannelCode("S-01");
- test.getBatteries().get(0).setCollectorChannels(List.of(mapping));
- CollectorRealtimeMeasurement measurement = new CollectorRealtimeMeasurement("dev-sim", "collector-9", "S-01", 3L,
- Instant.parse("2026-09-02T04:00:01Z"), "2026-09-02 12:00:01.000", Instant.now(), "temperature", new BigDecimal("25.1"), "℃");
- when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L)); when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
- when(tests.selectTestById(1L)).thenReturn(test); when(simulator.collectorRealtime(9)).thenReturn(new CollectorRealtimeSnapshot("collector-9", true, 1000, List.of(measurement)));
- when(mapper.selectCollectorSampleContentHash(9L, "S-01", 3L, "temperature")).thenReturn("different");
-
- new TestDataReceptionServiceImpl(mapper, tests, simulator).captureDevelopmentCollectorMeasurements();
-
- verify(mapper).insertSampleConflict(eq(1L), eq(3L), org.mockito.ArgumentMatchers.contains("采集器实时接收内容冲突"));
- verify(mapper, never()).insertCollectorSample(any(), any(), any(), anyString(), anyString(), any(), any(Date.class), anyString(), any(Date.class), anyString(), any(BigDecimal.class), anyString(), any(byte[].class));
- }
-
- private static TTest test(Date startedAt)
+ private static TTest receivingTest()
{
TTestChannelBinding binding = new TTestChannelBinding();
binding.setBindingId(2L);
binding.setChannelId(3L);
+ binding.setAdapterCode("dev-sim");
+ binding.setAdapterTimezoneId("Asia/Hong_Kong");
+ binding.setDevtype(22);
binding.setDevid(1);
+ binding.setSubdevid(1);
binding.setChlid(1);
TTestBattery battery = new TTestBattery();
+ battery.setTestBatteryId(4L);
+ battery.setCollectorId(9L);
battery.setBinding(binding);
+ battery.setCollectorChannels(List.of(
+ mapping("TEMPERATURE", "S-01"), mapping("POSITIVE_POTENTIAL", "S-02"),
+ mapping("NEGATIVE_POTENTIAL", "S-03"), mapping("AMBIENT_TEMPERATURE", "S-04")));
TTest test = new TTest();
test.setTestId(1L);
test.setTestStatus("RUNNING");
test.setReceptionStatus("RECEIVING");
- test.setStartedAtUtc(startedAt);
+ test.setStartedAtUtc(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
+ test.setReceptionBoundaryAtUtc(Date.from(Instant.parse("2026-09-02T04:00:00Z")));
test.setBatteries(List.of(battery));
return test;
}
- private static BtsHistoryRecord record(long sequenceId, String deviceTime)
+ private static TTestCollectorChannel mapping(String metric, String channel)
{
- Instant parsed = Instant.parse(deviceTime);
- return new BtsHistoryRecord(sequenceId, parsed, 1d, 2d, 3d, 4d,
- 1, 1, "恒流充电", "cc", parsed);
+ TTestCollectorChannel mapping = new TTestCollectorChannel();
+ mapping.setMeasurementCode(metric);
+ mapping.setChannelCode(channel);
+ return mapping;
+ }
+
+ private static BtsHistoryRecord cabinetRecord()
+ {
+ Instant deviceTime = Instant.parse("2026-09-02T04:00:30Z");
+ return new BtsHistoryRecord(7L, deviceTime, 1, 3.2, 2.5, 8.4,
+ 1, 2, "恒流充电", "cc", Instant.parse("2026-09-02T04:00:35Z"));
+ }
+
+ private static CollectorRealtimeMeasurement collector(String channel, long sequenceId, String metric,
+ String value, String unit)
+ {
+ return new CollectorRealtimeMeasurement("dev-sim", "collector-9", channel, sequenceId,
+ Instant.parse("2026-09-02T04:00:40Z"), "2026-09-02 12:00:40.000",
+ Instant.parse("2026-09-02T04:00:50Z"), metric, new BigDecimal(value), unit);
+ }
+
+ private static CollectorRealtimeSnapshot collectorSnapshot(boolean online,
+ List<CollectorRealtimeMeasurement> measurements)
+ {
+ return new CollectorRealtimeSnapshot("collector-9", online, 1000, measurements);
+ }
+
+ private record Fixture(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
+ {
+ TestDataReceptionServiceImpl service()
+ {
+ return new TestDataReceptionServiceImpl(mapper, tests, simulator, CLOCK);
+ }
}
}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
index 4aff58f..f4fe472 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestChannelBinding.java
@@ -28,6 +28,7 @@
private Integer subdevid;
private Integer chlid;
private String displayNo;
+ private String adapterTimezoneId;
public Long getBindingId() { return bindingId; }
public void setBindingId(Long value) { bindingId = value; }
@@ -61,4 +62,6 @@
public void setChlid(Integer value) { chlid = value; }
public String getDisplayNo() { return displayNo; }
public void setDisplayNo(String value) { displayNo = value; }
+ public String getAdapterTimezoneId() { return adapterTimezoneId; }
+ public void setAdapterTimezoneId(String value) { adapterTimezoneId = value; }
}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java
new file mode 100644
index 0000000..f6a72b9
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestCycleRecord.java
@@ -0,0 +1,27 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 检测柜直接返回的一条实际循环统计记录。 */
+public class TTestCycleRecord implements Serializable
+{
+ private Long cycleRecordId, testId, testBatteryId, bindingId;
+ private String adapterCode, sourceIdentity, sourceRecordId, adapterTimezoneId, dedupeKey, extensionMeasurementsJson;
+ private Date deviceTimeUtc, serverReceivedAtUtc;
+ private String deviceTimeRaw;
+ private Integer cycleNo;
+ private BigDecimal chargeCapacityAh, dischargeCapacityAh, chargeEnergyWh, dischargeEnergyWh, efficiency;
+ private byte[] contentSha256;
+ public Long getCycleRecordId(){return cycleRecordId;} public void setCycleRecordId(Long v){cycleRecordId=v;} public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;}
+ public Long getTestBatteryId(){return testBatteryId;} public void setTestBatteryId(Long v){testBatteryId=v;} public Long getBindingId(){return bindingId;} public void setBindingId(Long v){bindingId=v;}
+ public String getAdapterCode(){return adapterCode;} public void setAdapterCode(String v){adapterCode=v;} public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;}
+ public String getSourceRecordId(){return sourceRecordId;} public void setSourceRecordId(String v){sourceRecordId=v;} public String getAdapterTimezoneId(){return adapterTimezoneId;} public void setAdapterTimezoneId(String v){adapterTimezoneId=v;}
+ public Date getDeviceTimeUtc(){return deviceTimeUtc;} public void setDeviceTimeUtc(Date v){deviceTimeUtc=v;} public String getDeviceTimeRaw(){return deviceTimeRaw;} public void setDeviceTimeRaw(String v){deviceTimeRaw=v;}
+ public Date getServerReceivedAtUtc(){return serverReceivedAtUtc;} public void setServerReceivedAtUtc(Date v){serverReceivedAtUtc=v;} public Integer getCycleNo(){return cycleNo;} public void setCycleNo(Integer v){cycleNo=v;}
+ public BigDecimal getChargeCapacityAh(){return chargeCapacityAh;} public void setChargeCapacityAh(BigDecimal v){chargeCapacityAh=v;} public BigDecimal getDischargeCapacityAh(){return dischargeCapacityAh;} public void setDischargeCapacityAh(BigDecimal v){dischargeCapacityAh=v;}
+ public BigDecimal getChargeEnergyWh(){return chargeEnergyWh;} public void setChargeEnergyWh(BigDecimal v){chargeEnergyWh=v;} public BigDecimal getDischargeEnergyWh(){return dischargeEnergyWh;} public void setDischargeEnergyWh(BigDecimal v){dischargeEnergyWh=v;}
+ public BigDecimal getEfficiency(){return efficiency;} public void setEfficiency(BigDecimal v){efficiency=v;} public String getExtensionMeasurementsJson(){return extensionMeasurementsJson;} public void setExtensionMeasurementsJson(String v){extensionMeasurementsJson=v;}
+ public String getDedupeKey(){return dedupeKey;} public void setDedupeKey(String v){dedupeKey=v;} public byte[] getContentSha256(){return contentSha256;} public void setContentSha256(byte[] v){contentSha256=v;}
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java
new file mode 100644
index 0000000..af25d13
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestRealtimeRecord.java
@@ -0,0 +1,83 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 一次测试电池轮询槽位的检测柜与采集器组合观测。 */
+public class TTestRealtimeRecord implements Serializable
+{
+ private Long recordId;
+ private Long testId;
+ private Long testBatteryId;
+ private Long bindingId;
+ private String adapterCode;
+ private String sourceIdentity;
+ private String sourceRecordId;
+ private Integer cabinetDevtype;
+ private Integer cabinetDevid;
+ private Integer cabinetSubdevid;
+ private Integer cabinetChlid;
+ private Long cabinetSequenceId;
+ private Date cabinetDeviceTimeUtc;
+ private String cabinetDeviceTimeRaw;
+ private String cabinetAdapterTimezoneId;
+ private Long collectorId;
+ private String collectorChannelCode;
+ private Long collectorSequenceId;
+ private Date collectorDeviceTimeUtc;
+ private String collectorDeviceTimeRaw;
+ private String collectorAdapterTimezoneId;
+ private Date captureSlotUtc;
+ private Date serverReceivedAtUtc;
+ private Integer cycleNo;
+ private Integer stepNo;
+ private String stepType;
+ private String stepName;
+ private BigDecimal voltageV;
+ private BigDecimal currentA;
+ private BigDecimal capacityAh;
+ private BigDecimal energyWh;
+ private BigDecimal temperature;
+ private BigDecimal positivePotential;
+ private BigDecimal negativePotential;
+ private String extensionMeasurementsJson;
+ private byte[] contentSha256;
+
+ public Long getRecordId() { return recordId; } public void setRecordId(Long v) { recordId = v; }
+ public Long getTestId() { return testId; } public void setTestId(Long v) { testId = v; }
+ public Long getTestBatteryId() { return testBatteryId; } public void setTestBatteryId(Long v) { testBatteryId = v; }
+ public Long getBindingId() { return bindingId; } public void setBindingId(Long v) { bindingId = v; }
+ public String getAdapterCode() { return adapterCode; } public void setAdapterCode(String v) { adapterCode = v; }
+ public String getSourceIdentity() { return sourceIdentity; } public void setSourceIdentity(String v) { sourceIdentity = v; }
+ public String getSourceRecordId() { return sourceRecordId; } public void setSourceRecordId(String v) { sourceRecordId = v; }
+ public Integer getCabinetDevtype() { return cabinetDevtype; } public void setCabinetDevtype(Integer v) { cabinetDevtype = v; }
+ public Integer getCabinetDevid() { return cabinetDevid; } public void setCabinetDevid(Integer v) { cabinetDevid = v; }
+ public Integer getCabinetSubdevid() { return cabinetSubdevid; } public void setCabinetSubdevid(Integer v) { cabinetSubdevid = v; }
+ public Integer getCabinetChlid() { return cabinetChlid; } public void setCabinetChlid(Integer v) { cabinetChlid = v; }
+ public Long getCabinetSequenceId() { return cabinetSequenceId; } public void setCabinetSequenceId(Long v) { cabinetSequenceId = v; }
+ public Date getCabinetDeviceTimeUtc() { return cabinetDeviceTimeUtc; } public void setCabinetDeviceTimeUtc(Date v) { cabinetDeviceTimeUtc = v; }
+ public String getCabinetDeviceTimeRaw() { return cabinetDeviceTimeRaw; } public void setCabinetDeviceTimeRaw(String v) { cabinetDeviceTimeRaw = v; }
+ public String getCabinetAdapterTimezoneId() { return cabinetAdapterTimezoneId; } public void setCabinetAdapterTimezoneId(String v) { cabinetAdapterTimezoneId = v; }
+ public Long getCollectorId() { return collectorId; } public void setCollectorId(Long v) { collectorId = v; }
+ public String getCollectorChannelCode() { return collectorChannelCode; } public void setCollectorChannelCode(String v) { collectorChannelCode = v; }
+ public Long getCollectorSequenceId() { return collectorSequenceId; } public void setCollectorSequenceId(Long v) { collectorSequenceId = v; }
+ public Date getCollectorDeviceTimeUtc() { return collectorDeviceTimeUtc; } public void setCollectorDeviceTimeUtc(Date v) { collectorDeviceTimeUtc = v; }
+ public String getCollectorDeviceTimeRaw() { return collectorDeviceTimeRaw; } public void setCollectorDeviceTimeRaw(String v) { collectorDeviceTimeRaw = v; }
+ public String getCollectorAdapterTimezoneId() { return collectorAdapterTimezoneId; } public void setCollectorAdapterTimezoneId(String v) { collectorAdapterTimezoneId = v; }
+ public Date getCaptureSlotUtc() { return captureSlotUtc; } public void setCaptureSlotUtc(Date v) { captureSlotUtc = v; }
+ public Date getServerReceivedAtUtc() { return serverReceivedAtUtc; } public void setServerReceivedAtUtc(Date v) { serverReceivedAtUtc = v; }
+ public Integer getCycleNo() { return cycleNo; } public void setCycleNo(Integer v) { cycleNo = v; }
+ public Integer getStepNo() { return stepNo; } public void setStepNo(Integer v) { stepNo = v; }
+ public String getStepType() { return stepType; } public void setStepType(String v) { stepType = v; }
+ public String getStepName() { return stepName; } public void setStepName(String v) { stepName = v; }
+ public BigDecimal getVoltageV() { return voltageV; } public void setVoltageV(BigDecimal v) { voltageV = v; }
+ public BigDecimal getCurrentA() { return currentA; } public void setCurrentA(BigDecimal v) { currentA = v; }
+ public BigDecimal getCapacityAh() { return capacityAh; } public void setCapacityAh(BigDecimal v) { capacityAh = v; }
+ public BigDecimal getEnergyWh() { return energyWh; } public void setEnergyWh(BigDecimal v) { energyWh = v; }
+ public BigDecimal getTemperature() { return temperature; } public void setTemperature(BigDecimal v) { temperature = v; }
+ public BigDecimal getPositivePotential() { return positivePotential; } public void setPositivePotential(BigDecimal v) { positivePotential = v; }
+ public BigDecimal getNegativePotential() { return negativePotential; } public void setNegativePotential(BigDecimal v) { negativePotential = v; }
+ public String getExtensionMeasurementsJson() { return extensionMeasurementsJson; } public void setExtensionMeasurementsJson(String v) { extensionMeasurementsJson = v; }
+ public byte[] getContentSha256() { return contentSha256; } public void setContentSha256(byte[] v) { contentSha256 = v; }
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java
new file mode 100644
index 0000000..8b4fa46
--- /dev/null
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TTestStepRecord.java
@@ -0,0 +1,29 @@
+package com.ruoyi.system.domain;
+
+import java.io.Serializable;
+import java.math.BigDecimal;
+import java.util.Date;
+
+/** 检测柜直接返回的一条实际工步统计记录。 */
+public class TTestStepRecord implements Serializable
+{
+ private Long stepRecordId, testId, testBatteryId, bindingId;
+ private String adapterCode, sourceIdentity, sourceRecordId, adapterTimezoneId, dedupeKey, stepType, stepName, extensionMeasurementsJson;
+ private Date deviceTimeUtc, serverReceivedAtUtc;
+ private String deviceTimeRaw;
+ private Integer cycleNo, stepNo;
+ private BigDecimal voltageV, currentA, capacityAh, energyWh;
+ private byte[] contentSha256;
+ public Long getStepRecordId(){return stepRecordId;} public void setStepRecordId(Long v){stepRecordId=v;}
+ public Long getTestId(){return testId;} public void setTestId(Long v){testId=v;} public Long getTestBatteryId(){return testBatteryId;} public void setTestBatteryId(Long v){testBatteryId=v;}
+ public Long getBindingId(){return bindingId;} public void setBindingId(Long v){bindingId=v;} public String getAdapterCode(){return adapterCode;} public void setAdapterCode(String v){adapterCode=v;}
+ public String getSourceIdentity(){return sourceIdentity;} public void setSourceIdentity(String v){sourceIdentity=v;} public String getSourceRecordId(){return sourceRecordId;} public void setSourceRecordId(String v){sourceRecordId=v;}
+ public String getAdapterTimezoneId(){return adapterTimezoneId;} public void setAdapterTimezoneId(String v){adapterTimezoneId=v;} public Date getDeviceTimeUtc(){return deviceTimeUtc;} public void setDeviceTimeUtc(Date v){deviceTimeUtc=v;}
+ public String getDeviceTimeRaw(){return deviceTimeRaw;} public void setDeviceTimeRaw(String v){deviceTimeRaw=v;} public Date getServerReceivedAtUtc(){return serverReceivedAtUtc;} public void setServerReceivedAtUtc(Date v){serverReceivedAtUtc=v;}
+ public Integer getCycleNo(){return cycleNo;} public void setCycleNo(Integer v){cycleNo=v;} public Integer getStepNo(){return stepNo;} public void setStepNo(Integer v){stepNo=v;}
+ public String getStepType(){return stepType;} public void setStepType(String v){stepType=v;} public String getStepName(){return stepName;} public void setStepName(String v){stepName=v;}
+ public BigDecimal getVoltageV(){return voltageV;} public void setVoltageV(BigDecimal v){voltageV=v;} public BigDecimal getCurrentA(){return currentA;} public void setCurrentA(BigDecimal v){currentA=v;}
+ public BigDecimal getCapacityAh(){return capacityAh;} public void setCapacityAh(BigDecimal v){capacityAh=v;} public BigDecimal getEnergyWh(){return energyWh;} public void setEnergyWh(BigDecimal v){energyWh=v;}
+ public String getExtensionMeasurementsJson(){return extensionMeasurementsJson;} public void setExtensionMeasurementsJson(String v){extensionMeasurementsJson=v;} public String getDedupeKey(){return dedupeKey;} public void setDedupeKey(String v){dedupeKey=v;}
+ public byte[] getContentSha256(){return contentSha256;} public void setContentSha256(byte[] v){contentSha256=v;}
+}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
index b19f56f..d0e401b 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TTestMapper.java
@@ -17,6 +17,9 @@
import com.ruoyi.system.domain.TTestDataRevisionAction;
import com.ruoyi.system.domain.TTestConsoleImportRecord;
import com.ruoyi.system.domain.TTestDataOverride;
+import com.ruoyi.system.domain.TTestRealtimeRecord;
+import com.ruoyi.system.domain.TTestStepRecord;
+import com.ruoyi.system.domain.TTestCycleRecord;
import com.ruoyi.system.domain.vo.TestBatteryOptionVo;
import com.ruoyi.system.domain.vo.TestChannelOptionVo;
import com.ruoyi.system.domain.vo.TestCollectorOptionVo;
@@ -84,6 +87,18 @@
@Param("positivePotential") java.math.BigDecimal positivePotential,
@Param("negativePotential") java.math.BigDecimal negativePotential,
@Param("extensionMeasurementsJson") String extensionMeasurementsJson, @Param("contentSha256") byte[] contentSha256);
+ int insertRealtimeRecord(TTestRealtimeRecord record);
+ String selectRealtimeRecordContentHash(@Param("testId") Long testId, @Param("testBatteryId") Long testBatteryId,
+ @Param("captureSlotUtc") Date captureSlotUtc);
+ int insertStepRecord(TTestStepRecord record);
+ int insertCycleRecord(TTestCycleRecord record);
+ String selectStepRecordContentHash(@Param("testBatteryId") Long testBatteryId,
+ @Param("sourceIdentity") String sourceIdentity, @Param("dedupeKey") String dedupeKey);
+ String selectCycleRecordContentHash(@Param("testBatteryId") Long testBatteryId,
+ @Param("sourceIdentity") String sourceIdentity, @Param("dedupeKey") String dedupeKey);
+ List<java.util.Map<String, Object>> selectPersistedFinalRealtimeRecords(@Param("testId") Long testId);
+ List<java.util.Map<String, Object>> selectPersistedFinalStepRecords(@Param("testId") Long testId);
+ List<java.util.Map<String, Object>> selectPersistedFinalCycleRecords(@Param("testId") Long testId);
List<java.util.Map<String, Object>> selectPersistedRealtimeSnapshots(@Param("testId") Long testId);
List<java.util.Map<String, Object>> selectPersistedRecords(@org.apache.ibatis.annotations.Param("testId") Long testId);
List<java.util.Map<String, Object>> selectPersistedCollectorRecords(@org.apache.ibatis.annotations.Param("testId") Long testId);
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
index 06ee3c8..5d28386 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataQueryServiceImpl.java
@@ -37,7 +37,7 @@
{
requireTest(testId);
List<Map<String, Object>> source = new java.util.ArrayList<>();
- List<Map<String, Object>> snapshots = mapper.selectPersistedRealtimeSnapshots(testId);
+ List<Map<String, Object>> snapshots = mapper.selectPersistedFinalRealtimeRecords(testId);
if (snapshots != null && !snapshots.isEmpty()) source.addAll(snapshots);
else {
// 迁移完成前兼容旧数据;新采集只从统一快照读取。
@@ -48,19 +48,43 @@
}
List<Map<String, Object>> effective = applyEffectiveData(testId, "REALTIME", source);
attachCollectorStepContext(effective);
- return effective;
+ return normalizeApiTimes(effective);
}
@Override public List<Map<String, Object>> steps(Long testId)
{
requireTest(testId);
- return applyEffectiveData(testId, "STEP", mapper.selectPersistedSteps(testId));
+ List<Map<String, Object>> source = mapper.selectPersistedFinalStepRecords(testId);
+ if (source == null || source.isEmpty()) {
+ List<Map<String, Object>> legacy = mapper.selectPersistedSteps(testId);
+ if (legacy != null && !legacy.isEmpty()) source = legacy;
+ }
+ return normalizeApiTimes(applyEffectiveData(testId, "STEP", source));
}
@Override public List<Map<String, Object>> cycles(Long testId)
{
requireTest(testId);
- return applyEffectiveData(testId, "CYCLE", mapper.selectPersistedCycles(testId));
+ List<Map<String, Object>> source = mapper.selectPersistedFinalCycleRecords(testId);
+ if (source == null || source.isEmpty()) {
+ List<Map<String, Object>> legacy = mapper.selectPersistedCycles(testId);
+ if (legacy != null && !legacy.isEmpty()) source = legacy;
+ }
+ return normalizeApiTimes(applyEffectiveData(testId, "CYCLE", source));
+ }
+
+ /** 自研业务接口统一以 Unix epoch 毫秒返回时间,避免前端依赖数据库日期格式。 */
+ private List<Map<String, Object>> normalizeApiTimes(List<Map<String, Object>> rows)
+ {
+ if (rows == null) return List.of();
+ for (Map<String, Object> row : rows)
+ for (String key : List.of("deviceTime", "recordedAt", "absoluteTime", "serverReceivedAt"))
+ {
+ Object value = row.get(key);
+ if (value instanceof Date date) row.put(key, date.getTime());
+ else if (value instanceof Instant instant) row.put(key, instant.toEpochMilli());
+ }
+ return rows;
}
/** 将最新人工覆盖应用到设备事实,删除只影响当前有效视图,不抹除原始采样。 */
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
index aabbe55..7aa0abf 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
@@ -3,329 +3,219 @@
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
-import java.util.Date;
+import java.time.Clock;
import java.time.Instant;
-import java.util.Map;
-import java.util.LinkedHashMap;
+import java.util.Date;
import java.util.HexFormat;
+import java.util.LinkedHashMap;
import java.util.List;
-import com.alibaba.fastjson2.JSON;
-import org.springframework.stereotype.Service;
+import java.util.Locale;
+import java.util.Map;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
+import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import com.alibaba.fastjson2.JSON;
+import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
+import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
+import com.battery.device.api.TestDataObservationAdapter.CollectorMeasurement;
+import com.battery.device.api.TestDataObservationAdapter.CollectorRealtime;
+import com.battery.device.api.TestDataObservationAdapter.StepRecord;
+import com.battery.device.model.ChannelIdentity;
import com.battery.device.simulator.BtsDevelopmentSimulator;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsCycleRecord;
+import com.battery.device.simulator.BtsDevelopmentSimulator.BtsStepRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
import com.battery.device.simulator.BtsSimulatorStateStore.CollectorRealtimeMeasurement;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
import com.ruoyi.system.domain.TTest;
import com.ruoyi.system.domain.TTestBattery;
import com.ruoyi.system.domain.TTestChannelBinding;
+import com.ruoyi.system.domain.TTestCycleRecord;
+import com.ruoyi.system.domain.TTestRealtimeRecord;
+import com.ruoyi.system.domain.TTestStepRecord;
import com.ruoyi.system.mapper.TTestMapper;
import com.ruoyi.system.service.ITTestService;
import com.ruoyi.system.service.ITestDataReceptionService;
-/** 接收时确定测试归属,分别保留检测柜、采集器原始事实,并形成统一实时快照。 */
+/** 接收期间将成功观测直接归属测试,并写入实时、工步和循环事实表。 */
@Service
public class TestDataReceptionServiceImpl implements ITestDataReceptionService
{
+ private static final Logger log = LoggerFactory.getLogger(TestDataReceptionServiceImpl.class);
private final TTestMapper mapper;
private final ITTestService tests;
- private final BtsDevelopmentSimulator simulator;
+ private final TestDataObservationAdapter adapter;
private final TestWarningExceptionService warningExceptions;
+ private final Clock clock;
private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>();
- private final Map<Long, Instant> lastRawCaptureAt = new java.util.concurrent.ConcurrentHashMap<>();
@Autowired
- public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, @Nullable BtsDevelopmentSimulator simulator,
+ public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, @Nullable TestDataObservationAdapter adapter,
TestWarningExceptionService warningExceptions)
- {
- this.mapper = mapper;
- this.tests = tests;
- this.simulator = simulator;
- this.warningExceptions = warningExceptions;
- }
+ { this(mapper, tests, adapter, warningExceptions, Clock.systemUTC()); }
- /** 保留单元测试及旧调用方构造契约。 */
+ /** 允许测试固定接收时间,生产运行仍使用 UTC 系统时钟。 */
+ public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator, Clock clock)
+ { this(mapper, tests, new LegacySimulatorAdapter(simulator), new TestWarningExceptionService(mapper), clock); }
+
public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, BtsDevelopmentSimulator simulator)
- { this(mapper, tests, simulator, new TestWarningExceptionService(mapper)); }
+ { this(mapper, tests, new LegacySimulatorAdapter(simulator), new TestWarningExceptionService(mapper), Clock.systemUTC()); }
- @Override
- @Transactional(rollbackFor = Exception.class)
+ private TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, TestDataObservationAdapter adapter,
+ TestWarningExceptionService warningExceptions, Clock clock)
+ { this.mapper = mapper; this.tests = tests; this.adapter = adapter; this.warningExceptions = warningExceptions; this.clock = clock; }
+
+ /** 轮询接收中测试,只保存本次成功观测的实时槽位及设备直接返回汇总。 */
+ @Override @Transactional(rollbackFor = Exception.class)
public void captureDevelopmentRealtimeSnapshots()
{
- if (simulator == null) return;
- List<Long> testIds = mapper.selectReceivingTestIds();
- if (testIds == null) return;
- Date now = new Date();
- for (Long testId : testIds)
- {
- TTest locked = mapper.selectTestByIdForUpdate(testId);
- if (locked == null || !"RUNNING".equals(locked.getTestStatus())
- || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
- TTest test = tests.selectTestById(testId);
- if (test == null || !"RUNNING".equals(test.getTestStatus())
- || !"RECEIVING".equals(test.getReceptionStatus())) continue;
- // 完整快照与原始事实在同一接收事务内写入;原始事实按发现频率限流,避免 100ms 开发调度重复扫描历史。
- Instant lastRaw = lastRawCaptureAt.get(testId);
- if (lastRaw == null || !now.toInstant().isBefore(lastRaw.plusSeconds(5)))
- {
- captureTest(test);
- for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
- captureCollectorMeasurements(test, battery);
- lastRawCaptureAt.put(testId, now.toInstant());
- }
- for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
- captureSnapshot(test, battery, now);
- }
+ for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) { saveRealtime(test, battery); saveDeviceAggregates(test, battery); }
}
- private void captureSnapshot(TTest test, TTestBattery battery, Date now)
- {
- TTestChannelBinding binding = battery.getBinding();
- if (binding == null || binding.getDevid() == null || binding.getChlid() == null || battery.getCollectorId() == null) return;
- final long interval;
- try { interval = simulator.persistenceIntervalMillis(binding.getDevid(), binding.getChlid()); }
- catch (IllegalArgumentException ignored) { return; }
- String key = test.getTestId() + ":" + binding.getBindingId();
- Instant last = lastSnapshotAt.get(key);
- if (last != null && now.toInstant().isBefore(last.plusMillis(interval))) return;
- List<BtsHistoryRecord> records;
- try { records = simulator.btsRecords(binding.getDevid(), binding.getChlid()); } catch (IllegalArgumentException ignored) { return; }
- BtsHistoryRecord cabinet = records.stream().max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).orElse(null);
- CollectorRealtimeSnapshot collector;
- try { collector = simulator.collectorRealtime(battery.getCollectorId().intValue()); } catch (IllegalArgumentException ignored) { return; }
- if (cabinet == null || !collector.online()) return;
- if (test.getStartedAtUtc() != null && cabinet.deviceTime().isBefore(test.getStartedAtUtc().toInstant())) return;
- if (test.getReceptionBoundaryAtUtc() != null && cabinet.serverReceivedAt() != null
- && cabinet.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())) return;
- Map<String, CollectorRealtimeMeasurement> metrics = new LinkedHashMap<>();
- for (CollectorRealtimeMeasurement item : collector.measurements())
- if (battery.getCollectorChannels() != null && battery.getCollectorChannels().stream().anyMatch(mapping ->
- java.util.Objects.equals(mapping.getChannelCode(), item.channelCode())
- && metricMatches(mapping.getMeasurementCode(), item.metricCode())))
- metrics.put(item.metricCode().toUpperCase(java.util.Locale.ROOT), item);
- CollectorRealtimeMeasurement temp = metrics.get("TEMPERATURE");
- CollectorRealtimeMeasurement pos = metrics.get("POSITIVE_POTENTIAL");
- CollectorRealtimeMeasurement neg = metrics.get("NEGATIVE_POTENTIAL");
- if (temp == null || pos == null || neg == null) return;
- if (test.getStartedAtUtc() != null && (temp.deviceTime().isBefore(test.getStartedAtUtc().toInstant())
- || pos.deviceTime().isBefore(test.getStartedAtUtc().toInstant())
- || neg.deviceTime().isBefore(test.getStartedAtUtc().toInstant()))) return;
- if (test.getReceptionBoundaryAtUtc() != null
- && (temp.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())
- || pos.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())
- || neg.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant()))) return;
- Date slot = new Date((now.getTime() / interval) * interval);
- byte[] hash = snapshotHash(cabinet, temp, pos, neg, slot.toInstant());
- mapper.insertRealtimeSnapshot(test.getTestId(), binding.getBindingId(), binding.getChannelId(), battery.getCollectorId(), slot,
- cabinet.sequenceId(), Date.from(cabinet.deviceTime()), cabinet.deviceTime().toString(),
- temp.sequenceId(), Date.from(temp.deviceTime()), temp.deviceTimeRaw(), now,
- cabinet.cycleNo(), cabinet.stepNo(), cabinet.stepType(), cabinet.stepName(),
- BigDecimal.valueOf(cabinet.voltageV()), BigDecimal.valueOf(cabinet.currentA()), BigDecimal.valueOf(cabinet.capacityAh()),
- BigDecimal.valueOf(cabinet.energyWh()), temp.value(), pos.value(), neg.value(), JSON.toJSONString(metrics), hash);
- lastSnapshotAt.put(key, now.toInstant());
- }
-
- private byte[] snapshotHash(BtsHistoryRecord cabinet, CollectorRealtimeMeasurement temp,
- CollectorRealtimeMeasurement pos, CollectorRealtimeMeasurement neg, Instant slot)
- {
- try { return MessageDigest.getInstance("SHA-256").digest((slot + "|" + cabinet.sequenceId() + "|" + cabinet.deviceTime()
- + "|" + cabinet.voltageV() + "|" + cabinet.currentA() + "|" + temp.value() + "|" + pos.value() + "|" + neg.value())
- .getBytes(StandardCharsets.UTF_8)); }
- catch (Exception exception) { throw new IllegalStateException("无法计算实时快照内容摘要", exception); }
- }
-
- @Override
- @Transactional(rollbackFor = Exception.class)
+ /** 接收检测柜直接返回的实际工步和循环记录,不从实时数据推导。 */
+ @Override @Transactional(rollbackFor = Exception.class)
public void captureDevelopmentCabinetRecords()
- {
- if (simulator == null) return;
- List<Long> testIds = mapper.selectReceivingTestIds();
- if (testIds == null) return;
- for (Long testId : testIds)
- {
- // 锁住测试行直到本轮采样写入完成,暂停/停止动作只能在本轮接收提交后生效。
- TTest locked = mapper.selectTestByIdForUpdate(testId);
- if (locked == null || !"RUNNING".equals(locked.getTestStatus())
- || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
- TTest test = tests.selectTestById(testId);
- if (test == null || !"RUNNING".equals(test.getTestStatus())
- || !"RECEIVING".equals(test.getReceptionStatus())) continue;
- captureTest(test);
- }
- }
+ { for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) saveDeviceAggregates(test, battery); }
- @Override
- @Transactional(rollbackFor = Exception.class)
+ /** 接收采集器实时指标并并入测试电池轮询槽位,不读取历史补采。 */
+ @Override @Transactional(rollbackFor = Exception.class)
public void captureDevelopmentCollectorMeasurements()
+ { for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) saveRealtime(test, battery); }
+
+ private List<TTest> receivingTests()
{
- if (simulator == null) return;
- List<Long> testIds = mapper.selectReceivingTestIds();
- if (testIds == null) return;
- for (Long testId : testIds)
+ List<Long> ids = mapper.selectReceivingTestIds(); if (ids == null) return List.of();
+ return ids.stream().map(id -> {
+ TTest locked = mapper.selectTestByIdForUpdate(id);
+ if (locked == null || !"RUNNING".equals(locked.getTestStatus()) || !"RECEIVING".equals(locked.getReceptionStatus())) return null;
+ TTest current = tests.selectTestById(id);
+ return current != null && "RUNNING".equals(current.getTestStatus()) && "RECEIVING".equals(current.getReceptionStatus()) ? current : null;
+ }).filter(java.util.Objects::nonNull).toList();
+ }
+ private List<TTestBattery> batteries(TTest test) { return test.getBatteries() == null ? List.of() : test.getBatteries(); }
+
+ private void saveRealtime(TTest test, TTestBattery battery)
+ {
+ if (adapter == null) return;
+ TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
+ Instant now = clock.instant(); long interval = persistenceInterval(binding); String key = test.getTestId() + ":" + battery.getTestBatteryId();
+ Instant previous = lastSnapshotAt.get(key); if (previous != null && now.isBefore(previous.plusMillis(interval))) return;
+ CabinetRealtime cabinet = latestCabinet(binding);
+ if (cabinet != null && !accepted(test, cabinet.deviceTime(), cabinet.serverReceivedAt())) cabinet = null;
+ CollectorRealtime collector = latestCollector(battery);
+ Map<String, CollectorMeasurement> mapped = mappedCollectorMetrics(test, battery, collector);
+ if (cabinet == null && (collector == null || !collector.online() || mapped.isEmpty())) return;
+ Instant collectorTime = mapped.values().stream().map(CollectorMeasurement::deviceTime).max(Instant::compareTo).orElse(null);
+ Instant slot = Instant.ofEpochMilli((now.toEpochMilli() / interval) * interval);
+ TTestRealtimeRecord row = new TTestRealtimeRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId());
+ row.setAdapterCode(binding.getAdapterCode()); row.setCaptureSlotUtc(Date.from(slot)); row.setServerReceivedAtUtc(Date.from(now));
+ if (cabinet != null) { row.setCabinetDevtype(binding.getDevtype()); row.setCabinetDevid(binding.getDevid()); row.setCabinetSubdevid(binding.getSubdevid()); row.setCabinetChlid(binding.getChlid()); row.setCabinetAdapterTimezoneId(timezone(binding)); }
+ if (collector != null && collector.online() && !mapped.isEmpty()) { row.setCollectorId(battery.getCollectorId()); row.setCollectorAdapterTimezoneId(timezone(binding)); }
+ row.setCabinetSequenceId(cabinet == null ? null : cabinet.sequenceId()); row.setCabinetDeviceTimeUtc(cabinet == null ? null : Date.from(cabinet.deviceTime())); row.setCabinetDeviceTimeRaw(cabinet == null ? null : cabinet.deviceTime().toString());
+ row.setCollectorSequenceId(mapped.values().stream().map(CollectorMeasurement::sequenceId).max(Long::compareTo).orElse(null)); row.setCollectorDeviceTimeUtc(collectorTime == null ? null : Date.from(collectorTime)); row.setCollectorDeviceTimeRaw(collectorTime == null ? null : collectorTime.toString());
+ if (cabinet != null) { row.setCycleNo(cabinet.cycleNo()); row.setStepNo(cabinet.stepNo()); row.setStepType(cabinet.stepType()); row.setStepName(cabinet.stepName()); row.setVoltageV(BigDecimal.valueOf(cabinet.voltageV())); row.setCurrentA(BigDecimal.valueOf(cabinet.currentA())); row.setCapacityAh(BigDecimal.valueOf(cabinet.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(cabinet.energyWh())); }
+ row.setTemperature(metricValue(mapped, "TEMPERATURE")); row.setPositivePotential(metricValue(mapped, "POSITIVE_POTENTIAL")); row.setNegativePotential(metricValue(mapped, "NEGATIVE_POTENTIAL")); row.setCollectorChannelCode(mapped.values().stream().map(CollectorMeasurement::channelCode).findFirst().orElse(null));
+ Map<String, Object> extension = new LinkedHashMap<>();
+ for (Map.Entry<String, CollectorMeasurement> e : mapped.entrySet()) if (!isFixedMetric(e.getKey()))
{
- TTest locked = mapper.selectTestByIdForUpdate(testId);
- if (locked == null || !"RUNNING".equals(locked.getTestStatus())
- || !"RECEIVING".equals(locked.getReceptionStatus())) continue;
- TTest test = tests.selectTestById(testId);
- if (test == null || !"RUNNING".equals(test.getTestStatus())
- || !"RECEIVING".equals(test.getReceptionStatus())) continue;
- for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
- captureCollectorMeasurements(test, battery);
+ Map<String, Object> value = new LinkedHashMap<>(); value.put("value", e.getValue().value()); value.put("unit", e.getValue().unit()); value.put("channelCode", e.getValue().channelCode()); extension.put(e.getKey(), value);
}
+ row.setExtensionMeasurementsJson(JSON.toJSONString(extension)); row.setSourceIdentity(sourceIdentity(binding));
+ row.setSourceRecordId(sourceRecordId(cabinet, mapped));
+ row.setContentSha256(hash(row));
+ String old = mapper.selectRealtimeRecordContentHash(test.getTestId(), battery.getTestBatteryId(), row.getCaptureSlotUtc());
+ if (old != null) { if (!old.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256()))) log.warn("实时采样内容冲突,拒绝覆盖 testId={}, testBatteryId={}, captureSlotUtc={}", test.getTestId(), battery.getTestBatteryId(), slot); return; }
+ int inserted = mapper.insertRealtimeRecord(row);
+ if (inserted == 0)
+ {
+ String actual = mapper.selectRealtimeRecordContentHash(test.getTestId(), battery.getTestBatteryId(), row.getCaptureSlotUtc());
+ if (actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())))
+ log.warn("实时采样并发内容冲突,拒绝覆盖 testId={}, testBatteryId={}, captureSlotUtc={}", test.getTestId(), battery.getTestBatteryId(), slot);
+ }
+ lastSnapshotAt.put(key, now);
}
- private void captureTest(TTest test)
+ private void saveDeviceAggregates(TTest test, TTestBattery battery)
{
- Date startedAt = test.getStartedAtUtc();
- for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
+ if (adapter == null) return; TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
+ ChannelIdentity channel = new ChannelIdentity(binding.getAdapterCode(), binding.getDevtype(), binding.getDevid(), binding.getSubdevid(), binding.getChlid());
+ List<StepRecord> steps; List<CycleRecord> cycles; try { steps = adapter.stepRecords(channel); cycles = adapter.cycleRecords(channel); } catch (RuntimeException ex) { return; }
+ Date receivedAt = Date.from(clock.instant()); String identity = sourceIdentity(binding);
+ for (StepRecord source : steps == null ? List.<StepRecord>of() : steps)
{
- TTestChannelBinding binding = battery.getBinding();
- if (binding == null || binding.getDevid() == null || binding.getChlid() == null) continue;
- List<BtsHistoryRecord> records;
- try { records = simulator.btsRecords(binding.getDevid(), binding.getChlid()); }
- catch (IllegalArgumentException ignored) { continue; }
- for (BtsHistoryRecord record : records)
+ if (!accepted(test, source.endedAt(), null)) continue;
+ TTestStepRecord row = new TTestStepRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setAdapterTimezoneId(timezone(binding)); row.setCycleNo(source.cycleNo()); row.setStepNo(source.stepNo()); row.setStepType(source.stepType()); row.setStepName(source.stepName()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.endedAt().toString()); row.setServerReceivedAtUtc(receivedAt); row.setVoltageV(BigDecimal.valueOf(source.endVoltageV())); row.setCapacityAh(BigDecimal.valueOf(source.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(source.energyWh())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() + ":" + source.stepNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
+ if (mapper.insertStepRecord(row) == 0)
{
- // 设备时间隔离顺序测试,服务器接收边界排除暂停期间历史;两种时间不可交叉比较。
- if (startedAt != null && record.deviceTime().isBefore(startedAt.toInstant())) continue;
- if (test.getReceptionBoundaryAtUtc() != null && record.serverReceivedAt() != null
- && record.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())) continue;
- if (save(test, binding, record)) {
- String source = String.join(":", java.util.Objects.toString(binding.getAdapterCode(), ""),
- java.util.Objects.toString(binding.getDevtype(), ""), java.util.Objects.toString(binding.getDevid(), ""),
- java.util.Objects.toString(binding.getSubdevid(), ""), java.util.Objects.toString(binding.getChlid(), ""));
- warningExceptions.evaluate(test, "CABINET", source, "VOLTAGE", BigDecimal.valueOf(record.voltageV()), "V", Date.from(record.deviceTime()));
- warningExceptions.evaluate(test, "CABINET", source, "CURRENT", BigDecimal.valueOf(record.currentA()), "A", Date.from(record.deviceTime()));
- warningExceptions.evaluate(test, "CABINET", source, "CAPACITY", BigDecimal.valueOf(record.capacityAh()), "Ah", Date.from(record.deviceTime()));
- warningExceptions.evaluate(test, "CABINET", source, "ENERGY", BigDecimal.valueOf(record.energyWh()), "Wh", Date.from(record.deviceTime()));
- }
+ String actual = mapper.selectStepRecordContentHash(row.getTestBatteryId(), row.getSourceIdentity(), row.getDedupeKey());
+ log.warn(actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())) ? "工步记录内容冲突,拒绝覆盖 testId={}, dedupeKey={}" : "工步记录重复,保留首次事实 testId={}, dedupeKey={}", test.getTestId(), row.getDedupeKey());
+ }
+ }
+ for (CycleRecord source : cycles == null ? List.<CycleRecord>of() : cycles)
+ {
+ if (!accepted(test, source.endedAt(), null)) continue;
+ TTestCycleRecord row = new TTestCycleRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setAdapterTimezoneId(timezone(binding)); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setCycleNo(source.cycleNo()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.endedAt().toString()); row.setServerReceivedAtUtc(receivedAt); row.setChargeCapacityAh(BigDecimal.valueOf(source.chargeCapacityAh())); row.setDischargeCapacityAh(BigDecimal.valueOf(source.dischargeCapacityAh())); row.setChargeEnergyWh(BigDecimal.valueOf(source.chargeEnergyWh())); row.setDischargeEnergyWh(BigDecimal.valueOf(source.dischargeEnergyWh())); row.setEfficiency(BigDecimal.valueOf(source.efficiency())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
+ if (mapper.insertCycleRecord(row) == 0)
+ {
+ String actual = mapper.selectCycleRecordContentHash(row.getTestBatteryId(), row.getSourceIdentity(), row.getDedupeKey());
+ log.warn(actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())) ? "循环记录内容冲突,拒绝覆盖 testId={}, dedupeKey={}" : "循环记录重复,保留首次事实 testId={}, dedupeKey={}", test.getTestId(), row.getDedupeKey());
}
}
}
- private void captureCollectorMeasurements(TTest test, TTestBattery battery)
+ private CabinetRealtime latestCabinet(TTestChannelBinding b) { try { return adapter.latestCabinet(new ChannelIdentity(b.getAdapterCode(), b.getDevtype(), b.getDevid(), b.getSubdevid(), b.getChlid())); } catch (RuntimeException ex) { return null; } }
+ private CollectorRealtime latestCollector(TTestBattery b) { if (b.getCollectorId() == null) return null; try { return adapter.latestCollector(b.getCollectorId()); } catch (RuntimeException ex) { return null; } }
+ private Map<String, CollectorMeasurement> mappedCollectorMetrics(TTest test, TTestBattery b, CollectorRealtime s) { Map<String, CollectorMeasurement> out = new LinkedHashMap<>(); if (s == null || !s.online() || s.measurements() == null || b.getCollectorChannels() == null) return out; for (CollectorMeasurement m : s.measurements()) if (accepted(test, m.deviceTime(), m.serverReceivedAt()) && b.getCollectorChannels().stream().anyMatch(x -> java.util.Objects.equals(x.getChannelCode(), m.channelCode()) && metricMatches(x.getMeasurementCode(), m.metricCode()))) out.put(m.metricCode().toUpperCase(Locale.ROOT), m); return out; }
+ private boolean metricMatches(String c, String a) { if (c == null || a == null) return false; c = c.toUpperCase(Locale.ROOT); a = a.toUpperCase(Locale.ROOT); return c.equals(a) || (c.contains("TEMPERATURE") && a.equals("TEMPERATURE")) || (c.contains("POSITIVE_POTENTIAL") && a.equals("POSITIVE_POTENTIAL")) || (c.contains("NEGATIVE_POTENTIAL") && a.equals("NEGATIVE_POTENTIAL")); }
+ private static boolean isFixedMetric(String code) { return "TEMPERATURE".equals(code) || "POSITIVE_POTENTIAL".equals(code) || "NEGATIVE_POTENTIAL".equals(code); }
+ private static BigDecimal metricValue(Map<String, CollectorMeasurement> values, String code) { return values.get(code) == null ? null : values.get(code).value(); }
+ private static String sourceRecordId(CabinetRealtime cabinet, Map<String, CollectorMeasurement> measurements)
{
- if (battery.getCollectorId() == null || battery.getCollectorChannels() == null
- || battery.getCollectorChannels().isEmpty()) return;
- final CollectorRealtimeSnapshot realtime;
- try { realtime = simulator.collectorRealtime(battery.getCollectorId().intValue()); }
- catch (IllegalArgumentException ignored) { return; }
- if (!realtime.online()) return;
- Date startedAt = test.getStartedAtUtc();
- for (CollectorRealtimeMeasurement measurement : realtime.measurements())
- {
- if (startedAt != null && measurement.deviceTime().isBefore(startedAt.toInstant())) continue;
- // 继续接收只接受恢复动作之后服务器收到的采样;暂停期间采集器历史不回补。
- if (test.getReceptionBoundaryAtUtc() != null
- && measurement.serverReceivedAt().isBefore(test.getReceptionBoundaryAtUtc().toInstant())) continue;
- boolean mapped = battery.getCollectorChannels().stream().anyMatch(mapping ->
- java.util.Objects.equals(mapping.getChannelCode(), measurement.channelCode())
- && metricMatches(mapping.getMeasurementCode(), measurement.metricCode()));
- if (!mapped) continue;
- if (saveCollector(test, battery, measurement))
- warningExceptions.evaluate(test, "COLLECTOR", measurement.adapterCode() + ":" + battery.getCollectorId() + ":" + measurement.channelCode(), measurement.metricCode(), measurement.value(), measurement.unit(), Date.from(measurement.deviceTime()));
- }
+ List<String> parts = new java.util.ArrayList<>();
+ if (cabinet != null) parts.add("cabinet:" + cabinet.sequenceId());
+ measurements.values().stream().map(CollectorMeasurement::sequenceId).max(Long::compareTo)
+ .ifPresent(sequence -> parts.add("collector:" + sequence));
+ return String.join(":", parts);
+ }
+ private long persistenceInterval(TTestChannelBinding b) { try { return Math.max(100L, adapter.persistenceIntervalMillis(new ChannelIdentity(b.getAdapterCode(), b.getDevtype(), b.getDevid(), b.getSubdevid(), b.getChlid()))); } catch (RuntimeException ex) { return 60000L; } }
+ private static String sourceIdentity(TTestChannelBinding b) { return String.join(":", String.valueOf(b.getAdapterCode()), String.valueOf(b.getDevtype()), String.valueOf(b.getDevid()), String.valueOf(b.getSubdevid()), String.valueOf(b.getChlid())); }
+ private static String timezone(TTestChannelBinding b) { return b.getAdapterTimezoneId() == null || b.getAdapterTimezoneId().isBlank() ? "Asia/Hong_Kong" : b.getAdapterTimezoneId(); }
+ private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
+ private static boolean accepted(TTest test, Instant deviceTime, Instant serverReceivedAt)
+ {
+ if (deviceTime != null && test.getStartedAtUtc() != null && deviceTime.isBefore(test.getStartedAtUtc().toInstant())) return false;
+ return serverReceivedAt == null || test.getReceptionBoundaryAtUtc() == null || !serverReceivedAt.isBefore(test.getReceptionBoundaryAtUtc().toInstant());
}
- private boolean saveCollector(TTest test, TTestBattery battery, CollectorRealtimeMeasurement measurement)
+ /** 将开发模拟器的历史扩展映射到业务观测契约,仅供兼容测试构造使用。 */
+ private static final class LegacySimulatorAdapter implements TestDataObservationAdapter
{
- byte[] hash = hash(measurement);
- String old = mapper.selectCollectorSampleContentHash(battery.getCollectorId(), measurement.channelCode(),
- measurement.sequenceId(), measurement.metricCode());
- String expected = HexFormat.of().formatHex(hash);
- if (old != null)
+ private final BtsDevelopmentSimulator simulator;
+ private LegacySimulatorAdapter(BtsDevelopmentSimulator simulator) { this.simulator = simulator; }
+ @Override public String adapterCode() { return simulator.adapterCode(); }
+ @Override public com.battery.device.model.AdapterCapabilities capabilities() { return simulator.capabilities(); }
+ @Override public List<com.battery.device.model.DetectedChannel> discoverChannels() { return simulator.discoverChannels(); }
+ @Override public Map<ChannelIdentity, com.battery.device.model.DeviceObservation> observe(java.util.Set<ChannelIdentity> channels) { return simulator.observe(channels); }
+ @Override public CabinetRealtime latestCabinet(ChannelIdentity channel)
{
- if (!old.equalsIgnoreCase(expected))
- mapper.insertSampleConflict(test.getTestId(), battery.getBinding().getChannelId(),
- "采集器实时接收内容冲突 collector=" + battery.getCollectorId() + ", channel="
- + measurement.channelCode() + ", metric=" + measurement.metricCode()
- + ", seqid=" + measurement.sequenceId());
- return false;
+ return simulator.btsRecords(channel.devid(), channel.chlid()).stream()
+ .max(java.util.Comparator.comparing(BtsHistoryRecord::deviceTime)).map(r -> new CabinetRealtime(r.sequenceId(), r.deviceTime(), r.currentA(), r.voltageV(), r.capacityAh(), r.energyWh(), r.cycleNo(), r.stepNo(), r.stepName(), r.stepType(), r.serverReceivedAt())).orElse(null);
}
- Date receivedAt = new Date();
- int inserted = mapper.insertCollectorSample(test.getTestId(), battery.getBinding().getBindingId(), battery.getCollectorId(),
- measurement.adapterCode(),
- measurement.channelCode(), measurement.sequenceId(), Date.from(measurement.deviceTime()),
- measurement.deviceTimeRaw(), receivedAt, measurement.metricCode(),
- measurement.value(), measurement.unit(), hash);
- // 唯一键负责并发幂等;内容冲突保留首次事实,不覆盖原始记录。
- if (inserted == 0)
+ @Override public CollectorRealtime latestCollector(long collectorId)
{
- String actual = mapper.selectCollectorSampleContentHash(battery.getCollectorId(), measurement.channelCode(),
- measurement.sequenceId(), measurement.metricCode());
- if (actual != null && !actual.equalsIgnoreCase(expected))
- mapper.insertSampleConflict(test.getTestId(), battery.getBinding().getChannelId(),
- "采集器实时并发内容冲突 collector=" + battery.getCollectorId() + ", channel="
- + measurement.channelCode() + ", metric=" + measurement.metricCode()
- + ", seqid=" + measurement.sequenceId());
+ CollectorRealtimeSnapshot snapshot = simulator.collectorRealtime((int) collectorId);
+ return new CollectorRealtime(snapshot.collectorCode(), snapshot.online(), snapshot.sampleIntervalMs(), snapshot.measurements().stream().map(m -> new CollectorMeasurement(m.channelCode(), m.sequenceId(), m.deviceTime(), m.deviceTimeRaw(), m.serverReceivedAt(), m.metricCode(), m.value(), m.unit())).toList());
}
- return inserted > 0;
+ @Override public List<StepRecord> stepRecords(ChannelIdentity channel)
+ { return simulator.btsSteps(channel.devid(), channel.chlid()).stream().map(s -> new StepRecord(s.recordNo(), s.cycleNo(), s.stepNo(), s.stepName(), s.stepType(), s.startedAt(), s.endedAt(), s.endVoltageV(), s.capacityAh(), s.energyWh())).toList(); }
+ @Override public List<CycleRecord> cycleRecords(ChannelIdentity channel)
+ { return simulator.btsCycles(channel.devid(), channel.chlid()).stream().map(s -> new CycleRecord(s.recordNo(), s.cycleNo(), s.startedAt(), s.endedAt(), s.chargeCapacityAh(), s.dischargeCapacityAh(), s.chargeEnergyWh(), s.dischargeEnergyWh(), s.efficiency())).toList(); }
+ @Override public long persistenceIntervalMillis(ChannelIdentity channel) { return simulator.persistenceIntervalMillis(channel.devid(), channel.chlid()); }
}
- private boolean save(TTest test, TTestChannelBinding binding,
- BtsHistoryRecord record)
- {
- // 模拟器历史 testid 按通道固定;接收事实必须再带测试作用域,避免顺序测试共用全局幂等键。
- String btsTestId = "SIM-" + binding.getDevid() + "-" + binding.getChlid() + "-T" + test.getTestId();
- byte[] hash = hash(record);
- String old = mapper.selectBtsSampleContentHash(binding.getChannelId(), btsTestId, record.sequenceId());
- if (old != null)
- {
- if (!old.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
- mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
- "实时接收内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
- return false;
- }
- int inserted = mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(),
- btsTestId, record.sequenceId(), Date.from(record.deviceTime()),
- record.deviceTime().toString(), new Date(), record.cycleNo(), record.stepNo(), record.stepType(),
- record.stepName(), null,
- BigDecimal.valueOf(record.voltageV()), BigDecimal.valueOf(record.currentA()),
- BigDecimal.valueOf(record.capacityAh()), BigDecimal.valueOf(record.energyWh()), hash);
- if (inserted == 0)
- {
- String actual = mapper.selectBtsSampleContentHash(binding.getChannelId(), btsTestId, record.sequenceId());
- if (actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
- mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(),
- "实时接收并发内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
- }
- return inserted > 0;
- }
-
- private byte[] hash(BtsHistoryRecord record)
- {
- try
- {
- String value = record.sequenceId() + "|" + record.deviceTime() + "|" + record.currentA() + "|"
- + record.voltageV() + "|" + record.capacityAh() + "|" + record.energyWh() + "|"
- + record.cycleNo() + "|" + record.stepNo() + "|" + record.stepType();
- return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
- }
- catch (Exception exception) { throw new IllegalStateException("无法计算采样内容摘要", exception); }
- }
-
- private byte[] hash(CollectorRealtimeMeasurement measurement)
- {
- try
- {
- String value = measurement.adapterCode() + "|" + measurement.collectorCode() + "|"
- + measurement.channelCode() + "|" + measurement.sequenceId() + "|"
- + measurement.deviceTimeRaw() + "|" + measurement.metricCode() + "|"
- + measurement.value() + "|" + measurement.unit();
- return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
- }
- catch (Exception exception) { throw new IllegalStateException("无法计算采集器采样内容摘要", exception); }
- }
-
- private boolean metricMatches(String configured, String actual)
- {
- if (configured == null || actual == null) return false;
- if (configured.equals(actual)) return true;
- String normalized = configured.toUpperCase(java.util.Locale.ROOT);
- return (normalized.contains("TEMPERATURE") && "TEMPERATURE".equals(actual.toUpperCase(java.util.Locale.ROOT)))
- || (normalized.contains("POSITIVE_POTENTIAL") && "POSITIVE_POTENTIAL".equals(actual.toUpperCase(java.util.Locale.ROOT)))
- || (normalized.contains("NEGATIVE_POTENTIAL") && "NEGATIVE_POTENTIAL".equals(actual.toUpperCase(java.util.Locale.ROOT)));
- }
+ private byte[] hash(Object value) { try { return MessageDigest.getInstance("SHA-256").digest(JSON.toJSONString(value).getBytes(StandardCharsets.UTF_8)); } catch (Exception ex) { throw new IllegalStateException("无法计算测试数据内容摘要", ex); } }
}
diff --git a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
index 999f31d..ec8d3a0 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TTestMapper.xml
@@ -70,6 +70,7 @@
<result property="subdevid" column="subdevid" />
<result property="chlid" column="chlid" />
<result property="displayNo" column="display_no" />
+ <result property="adapterTimezoneId" column="adapter_timezone_id" />
</resultMap>
<resultMap id="TTestCollectorChannelResultMap" type="TTestCollectorChannel">
@@ -302,6 +303,7 @@
binding.expected_barcode, binding.claim_status, channel_fact.cabinet_id,
cabinet.cabinet_code, cabinet.cabinet_name,
adapter.adapter_code adapter_code,
+ adapter.timezone_id adapter_timezone_id,
channel_fact.devtype, channel_fact.devid, channel_fact.subdevid, channel_fact.chlid, channel_fact.display_no
from t_test_channel_binding binding
join t_detection_channel channel_fact on channel_fact.channel_id = binding.channel_id
@@ -370,6 +372,54 @@
#{voltageV}, #{currentA}, #{capacityAh}, #{energyWh}, #{temperature}, #{positivePotential},
#{negativePotential}, #{extensionMeasurementsJson}, #{contentSha256})
</insert>
+ <insert id="insertRealtimeRecord" parameterType="com.ruoyi.system.domain.TTestRealtimeRecord" useGeneratedKeys="true" keyProperty="recordId">
+ insert ignore into t_test_realtime_record
+ (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, cabinet_devtype, cabinet_devid, cabinet_subdevid, cabinet_chlid,
+ cabinet_sequence_id, cabinet_device_time_utc, cabinet_device_time_raw, cabinet_adapter_timezone_id,
+ collector_id, collector_channel_code, collector_sequence_id, collector_device_time_utc, collector_device_time_raw,
+ collector_adapter_timezone_id, capture_slot_utc, server_received_at_utc, cycle_no, step_no, step_type, step_name,
+ voltage_v, current_a, capacity_ah, energy_wh, temperature, positive_potential, negative_potential,
+ extension_measurements_json, content_sha256)
+ values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{cabinetDevtype}, #{cabinetDevid}, #{cabinetSubdevid}, #{cabinetChlid},
+ #{cabinetSequenceId}, #{cabinetDeviceTimeUtc}, #{cabinetDeviceTimeRaw}, #{cabinetAdapterTimezoneId},
+ #{collectorId}, #{collectorChannelCode}, #{collectorSequenceId}, #{collectorDeviceTimeUtc}, #{collectorDeviceTimeRaw},
+ #{collectorAdapterTimezoneId}, #{captureSlotUtc}, #{serverReceivedAtUtc}, #{cycleNo}, #{stepNo}, #{stepType}, #{stepName},
+ #{voltageV}, #{currentA}, #{capacityAh}, #{energyWh}, #{temperature}, #{positivePotential}, #{negativePotential},
+ cast(#{extensionMeasurementsJson} as json), #{contentSha256})
+ </insert>
+ <select id="selectRealtimeRecordContentHash" resultType="string">
+ select lower(hex(content_sha256)) from t_test_realtime_record
+ where test_id = #{testId} and test_battery_id = #{testBatteryId} and capture_slot_utc = #{captureSlotUtc}
+ limit 1
+ </select>
+ <insert id="insertStepRecord" parameterType="com.ruoyi.system.domain.TTestStepRecord" useGeneratedKeys="true" keyProperty="stepRecordId">
+ insert ignore into t_test_step_record
+ (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, dedupe_key, adapter_timezone_id,
+ device_time_utc, device_time_raw, server_received_at_utc, cycle_no, step_no, step_type, step_name,
+ voltage_v, current_a, capacity_ah, energy_wh, extension_measurements_json, content_sha256)
+ values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{dedupeKey}, #{adapterTimezoneId},
+ #{deviceTimeUtc}, #{deviceTimeRaw}, #{serverReceivedAtUtc}, #{cycleNo}, #{stepNo}, #{stepType}, #{stepName},
+ #{voltageV}, #{currentA}, #{capacityAh}, #{energyWh}, cast(#{extensionMeasurementsJson} as json), #{contentSha256})
+ </insert>
+ <insert id="insertCycleRecord" parameterType="com.ruoyi.system.domain.TTestCycleRecord" useGeneratedKeys="true" keyProperty="cycleRecordId">
+ insert ignore into t_test_cycle_record
+ (test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, dedupe_key, adapter_timezone_id,
+ device_time_utc, device_time_raw, server_received_at_utc, cycle_no, charge_capacity_ah, discharge_capacity_ah,
+ charge_energy_wh, discharge_energy_wh, efficiency, extension_measurements_json, content_sha256)
+ values (#{testId}, #{testBatteryId}, #{bindingId}, #{adapterCode}, #{sourceIdentity}, #{sourceRecordId}, #{dedupeKey}, #{adapterTimezoneId},
+ #{deviceTimeUtc}, #{deviceTimeRaw}, #{serverReceivedAtUtc}, #{cycleNo}, #{chargeCapacityAh}, #{dischargeCapacityAh},
+ #{chargeEnergyWh}, #{dischargeEnergyWh}, #{efficiency}, cast(#{extensionMeasurementsJson} as json), #{contentSha256})
+ </insert>
+ <select id="selectStepRecordContentHash" resultType="string">
+ select lower(hex(content_sha256)) from t_test_step_record
+ where test_battery_id = #{testBatteryId} and source_identity = #{sourceIdentity} and dedupe_key = #{dedupeKey}
+ limit 1
+ </select>
+ <select id="selectCycleRecordContentHash" resultType="string">
+ select lower(hex(content_sha256)) from t_test_cycle_record
+ where test_battery_id = #{testBatteryId} and source_identity = #{sourceIdentity} and dedupe_key = #{dedupeKey}
+ limit 1
+ </select>
<select id="selectPersistedRealtimeSnapshots" resultType="map">
select concat('snapshot:', snapshot.snapshot_id) id,
concat('snapshot:', snapshot.snapshot_id) stableIdentity,
@@ -396,6 +446,47 @@
where snapshot.test_id = #{testId}
order by snapshot.capture_slot_utc desc, snapshot.snapshot_id desc
</select>
+ <select id="selectPersistedFinalRealtimeRecords" resultType="map">
+ select concat('realtime:', r.record_id) id, concat('realtime:', r.record_id) stableIdentity,
+ concat('RT-', r.record_id) recordNo, 'SNAPSHOT' sourceType, b.battery_no batteryNo,
+ r.cycle_no cycleNo, r.step_no stepNo, r.step_name stepName, r.step_type stepType,
+ r.cabinet_sequence_id sequenceId, r.cabinet_device_time_utc deviceTime,
+ r.server_received_at_utc serverReceivedAt, r.voltage_v voltage, r.current_a current,
+ r.capacity_ah capacity, r.energy_wh energy, r.temperature temperature,
+ r.positive_potential positivePotential, r.negative_potential negativePotential,
+ cast(r.extension_measurements_json as char) extensionMeasurements,
+ r.adapter_code adapterCode, r.cabinet_devtype devtype, r.cabinet_devid deviceId,
+ r.cabinet_subdevid subdevid, r.cabinet_chlid channelId, r.collector_id collectorId,
+ r.collector_channel_code channelCode, r.source_identity sourceIdentity, r.source_record_id sourceRecordId
+ from t_test_realtime_record r join t_test_battery b on b.test_battery_id = r.test_battery_id
+ where r.test_id = #{testId} and b.del_flag = 0
+ order by r.capture_slot_utc desc, r.record_id desc
+ </select>
+ <select id="selectPersistedFinalStepRecords" resultType="map">
+ select concat('step:', r.step_record_id) id, concat('step:', r.step_record_id) stableIdentity,
+ concat('STEP-', r.step_record_id) recordNo, b.battery_no batteryNo, r.cycle_no cycleNo,
+ r.step_no stepNo, r.step_name stepName, r.step_type stepType,
+ r.device_time_utc recordedAt, r.device_time_utc absoluteTime,
+ r.server_received_at_utc serverReceivedAt, r.voltage_v voltage, r.current_a current,
+ r.capacity_ah capacity, r.energy_wh energy, r.adapter_code adapterCode,
+ r.source_identity sourceIdentity, r.source_record_id sourceRecordId
+ from t_test_step_record r join t_test_battery b on b.test_battery_id = r.test_battery_id
+ where r.test_id = #{testId} and b.del_flag = 0
+ order by r.cycle_no, r.step_no, r.device_time_utc, r.step_record_id
+ </select>
+ <select id="selectPersistedFinalCycleRecords" resultType="map">
+ select concat('cycle:', r.cycle_record_id) id, concat('cycle:', r.cycle_record_id) stableIdentity,
+ coalesce(r.source_record_id, concat('CYCLE-', r.cycle_record_id)) recordNo,
+ b.battery_no batteryNo, r.cycle_no cycleNo, r.device_time_utc recordedAt,
+ r.device_time_utc absoluteTime, r.server_received_at_utc serverReceivedAt,
+ r.charge_capacity_ah chargeCapacity, r.discharge_capacity_ah dischargeCapacity,
+ r.charge_energy_wh chargeEnergy, r.discharge_energy_wh dischargeEnergy,
+ r.efficiency efficiency, r.adapter_code adapterCode, r.source_identity sourceIdentity,
+ r.source_record_id sourceRecordId
+ from t_test_cycle_record r join t_test_battery b on b.test_battery_id = r.test_battery_id
+ where r.test_id = #{testId} and b.del_flag = 0
+ order by r.cycle_no, b.battery_no, r.cycle_record_id
+ </select>
<select id="selectPersistedRecords" resultType="map">
select sample.sample_id id,
concat('RD-', channel.devid, '-', channel.chlid, '-', lpad(sample.seq_id, 6, '0')) recordNo,
--
Gitblit v1.7.1