yeg
2026-09-05 dd1af98f7e679290e5280e2aff58d7ff5237c5fb
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TestDataReceptionServiceImpl.java
@@ -3,108 +3,221 @@
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Instant;
import java.util.Date;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Objects;
import java.util.Optional;
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.ruoyi.device.bts.BtsDevelopmentSimulator;
import com.ruoyi.device.simulator.BtsSimulatorStateStore.BtsHistoryRecord;
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.TTestReceptionInterval;
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<>();
    @Autowired
    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, @Nullable TestDataObservationAdapter adapter,
            TestWarningExceptionService 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, new LegacySimulatorAdapter(simulator), new TestWarningExceptionService(mapper), Clock.systemUTC()); }
    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()
    {
        this.mapper = mapper;
        this.tests = tests;
        this.simulator = simulator;
        for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) { saveRealtime(test, battery); saveDeviceAggregates(test, battery); }
    }
    @Override
    @Transactional(rollbackFor = Exception.class)
    /** 接收检测柜直接返回的实际工步和循环记录,不从实时数据推导。 */
    @Override @Transactional(rollbackFor = Exception.class)
    public void captureDevelopmentCabinetRecords()
    { for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) saveDeviceAggregates(test, battery); }
    /** 接收采集器实时指标并并入测试电池轮询槽位,不读取历史补采。 */
    @Override @Transactional(rollbackFor = Exception.class)
    public void captureDevelopmentCollectorMeasurements()
    { for (TTest test : receivingTests()) for (TTestBattery battery : batteries(test)) saveRealtime(test, battery); }
    private List<TTest> receivingTests()
    {
        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 test = tests.selectTestById(testId);
            if (test == null || !"RECEIVING".equals(test.getReceptionStatus())) continue;
            captureTest(test);
            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)
    {
        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;
            Optional<TTestReceptionInterval> open = mapper.selectTestReceptionIntervals(test.getTestId()).stream()
                    .filter(item -> Objects.equals(item.getBindingId(), binding.getBindingId())
                            && "CABINET_CHANNEL".equals(item.getSourceType())
                            && Objects.equals(item.getSourceId(), binding.getChannelId())
                            && "OPEN".equals(item.getIntervalStatus()))
                    .findFirst();
            if (open.isEmpty()) continue;
            Date start = open.get().getStartBoundaryAtUtc();
            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 (start != null && record.deviceTime().isBefore(start.toInstant())) continue;
                save(test, battery, binding, open.get(), record);
                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 save(TTest test, TTestBattery battery, TTestChannelBinding binding,
            TTestReceptionInterval interval, BtsHistoryRecord record)
    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)
    {
        // 模拟器历史 testid 按通道固定;接收事实必须再带测试作用域,避免顺序测试共用全局幂等键。
        String btsTestId = "SIM-" + binding.getDevid() + "-" + binding.getChlid() + "-T" + test.getTestId();
        byte[] hash = hash(record);
        String old = mapper.selectSampleContentHash(binding.getChannelId(), btsTestId, record.sequenceId());
        if (old != null)
        {
            if (!old.equalsIgnoreCase(HexFormat.of().formatHex(hash)))
                mapper.insertBackfillConflict(test.getTestId(), binding.getChannelId(),
                        "实时接收内容冲突 testid=" + btsTestId + ", seqid=" + record.sequenceId());
            return;
        }
        mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(), interval.getIntervalId(),
                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);
        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 byte[] hash(BtsHistoryRecord record)
    /** 将开发模拟器的历史扩展映射到业务观测契约,仅供兼容测试构造使用。 */
    private static final class LegacySimulatorAdapter implements TestDataObservationAdapter
    {
        try
        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)
        {
            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));
            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);
        }
        catch (Exception exception) { throw new IllegalStateException("无法计算采样内容摘要", exception); }
        @Override public CollectorRealtime latestCollector(long collectorId)
        {
            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());
        }
        @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 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); } }
}