package com.ruoyi.system.service.impl; import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.time.Instant; import java.util.Date; import java.util.HexFormat; import java.util.List; import java.util.Objects; import com.alibaba.fastjson2.JSON; import org.springframework.stereotype.Service; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.support.TransactionTemplate; import org.springframework.transaction.annotation.Transactional; import com.ruoyi.common.exception.ServiceException; import com.ruoyi.device.api.DeviceAdapter; import com.ruoyi.device.model.ChannelIdentity; import com.ruoyi.device.model.HistoryAvailability; import com.ruoyi.device.model.HistoryPage; import com.ruoyi.device.model.HistorySample; import com.ruoyi.device.simulator.BtsSimulatorStateStore; import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorMeasurementRecord; import com.ruoyi.device.registry.DeviceAdapterRegistry; import com.ruoyi.system.domain.TTest; import com.ruoyi.system.domain.TTestBackfillCursor; import com.ruoyi.system.domain.TTestBattery; import com.ruoyi.system.domain.TTestChannelBinding; import com.ruoyi.system.domain.TTestCollectorChannel; import com.ruoyi.system.domain.TTestReceptionInterval; import com.ruoyi.system.domain.TTestIntegrityIssue; import com.ruoyi.system.mapper.TTestMapper; import com.ruoyi.system.service.ITestBackfillService; import com.ruoyi.system.service.ITTestService; /** 以测试动作边界筛选来源历史,并以数据库游标保证补采可恢复。 */ public class TestBackfillServiceImpl implements ITestBackfillService { private static final int PAGE_SIZE = 1000; private static final int MAX_INTEGRITY_RETRIES = 3; private final TTestMapper mapper; private final DeviceAdapterRegistry registry; private final BtsSimulatorStateStore collectorHistoryStore; private final ITTestService tests; private final TransactionTemplate transactionTemplate; public TestBackfillServiceImpl(TTestMapper mapper, DeviceAdapterRegistry registry, BtsSimulatorStateStore collectorHistoryStore, ITTestService tests, PlatformTransactionManager transactionManager) { this.mapper = mapper; this.registry = registry; this.collectorHistoryStore = collectorHistoryStore; this.tests = tests; this.transactionTemplate = new TransactionTemplate(transactionManager); } /** 将已自然结束的设备事实转为待补采测试,并关闭其开放接收区间。 */ @Override @Transactional(rollbackFor = Exception.class) public void reconcileNaturalEnd() { List summaries = mapper.selectTestsForNaturalEnd(); if (summaries == null) return; for (TTest summary : summaries) { TTest test = tests.selectTestById(summary.getTestId()); if (test == null || test.getBatteries() == null || test.getBatteries().isEmpty()) continue; boolean allFinished = true; for (TTestBattery battery : test.getBatteries()) { TTestChannelBinding binding = battery.getBinding(); if (binding == null) { allFinished = false; break; } var channel = mapper.selectReceptionChannelForUpdate(binding.getChannelId()); if (channel == null || !"FINISHED".equalsIgnoreCase(channel.getWorkStatus())) { allFinished = false; break; } } if (allFinished) endNaturally(test.getTestId(), "NATURAL_FINISH"); } } /** 恢复等待中的历史补采;完整性未确认时继续保持完成确认中。 */ @Override public void resumePendingBackfill() { List pending = mapper.selectTestsForBackfill(); if (pending == null) return; for (TTest test : pending) { try { transactionTemplate.executeWithoutResult(status -> backfill(test)); } catch (RuntimeException exception) { mapper.updateTestBackfillWaiting(test.getTestId(), new Date(), exception.getMessage()); } } } /** 记录自然结束并释放租约;测试完成状态由后续补采完整性门禁确认。 */ @Override @Transactional(rollbackFor = Exception.class) public void endNaturally(Long testId, String reason) { if (testId == null) throw new ServiceException("测试ID不能为空"); Date endedAt = new Date(); if (mapper.updateTestNaturallyEnded(testId, endedAt) != 1) return; mapper.closeOpenReceptionIntervals(testId, endedAt, reason == null ? "NATURAL_FINISH" : reason); mapper.deleteChannelLeasesByTestId(testId); mapper.deleteCollectorChannelLeasesByTestId(testId); mapper.insertTestAudit(testId, "RECEPTION_NATURALLY_ENDED", null, "system", null, JSON.toJSONString(java.util.Map.of("testStatus", "COMPLETION_PENDING", "receptionStatus", "ENDED", "reason", reason == null ? "NATURAL_FINISH" : reason))); } /** 补采全部来源历史,只有完整性和异常检查通过后才写入检测完成。 */ @Transactional(rollbackFor = Exception.class) protected void backfill(TTest test) { if (mapper.updateTestBackfillStarted(test.getTestId(), new Date()) == 0) return; // 来源按检测柜、采集器分别提交;任何一页失败都不推进该来源游标。 for (TTestBattery battery : test.getBatteries() == null ? List.of() : test.getBatteries()) { TTestChannelBinding binding = battery.getBinding(); if (binding != null) backfillCabinet(test, battery, binding); for (TTestCollectorChannel mapping : battery.getCollectorChannels() == null ? List.of() : battery.getCollectorChannels()) backfillCollector(test, battery, mapping); } Date checkedAt = new Date(); boolean complete = checkIntegrity(test, checkedAt); if (complete) { mapper.updateTestCompleted(test.getTestId(), checkedAt); mapper.insertTestAudit(test.getTestId(), "TEST_COMPLETED", null, "system", null, JSON.toJSONString(java.util.Map.of("backfillStatus", "COMPLETED", "integrity", "PASSED"))); } else { mapper.updateTestBackfillWaiting(test.getTestId(), checkedAt, "区间完整性检查未通过"); mapper.insertTestAudit(test.getTestId(), "INTEGRITY_CHECK_BLOCKED", null, "system", null, JSON.toJSONString(java.util.Map.of("backfillStatus", "WAITING", "integrity", "OPEN"))); } } private void backfillCabinet(TTest test, TTestBattery battery, TTestChannelBinding binding) { String adapterCode = binding.getAdapterCode(); if (adapterCode == null || adapterCode.isBlank()) throw new ServiceException("检测柜适配器身份缺失"); ChannelIdentity identity = new ChannelIdentity(adapterCode, binding.getDevtype(), binding.getDevid(), binding.getSubdevid(), binding.getChlid()); List intervals = intervals(test.getTestId(), binding.getBindingId(), "CABINET_CHANNEL", binding.getChannelId(), null); if (intervals.isEmpty()) return; HistoryAvailability availability = registry.execute(adapterCode, adapter -> adapter.inquireHistory(java.util.Set.of(identity)).stream().findFirst() .orElseThrow(() -> new ServiceException("检测柜未返回历史清单"))); TTestBackfillCursor cursor = cursor(test, binding.getBindingId(), "CABINET_CHANNEL", binding.getChannelId(), null, availability.btsTestId()); cursor.setProtocolMetadata(JSON.toJSONString(java.util.Map.of("availableRecordCount", availability.availableRecordCount(), "uploadComplete", availability.uploadComplete(), "adapterCode", adapterCode))); cursor.setUpdatedAtUtc(new Date()); mapper.updateBackfillCursor(cursor); long position = Math.max(1L, cursor.getNextPosition() == null ? 1L : cursor.getNextPosition()); while (true) { final long requestPosition = position; HistoryPage page = registry.execute(adapterCode, adapter -> adapter.downloadHistory(identity, availability.btsTestId(), requestPosition, PAGE_SIZE)); for (HistorySample sample : page.samples()) { if (!within(sample.deviceTime(), intervals)) continue; saveBtsSample(test, binding, intervals, availability.btsTestId(), sample); } long next = page.nextPosition() > position ? page.nextPosition() : position + page.samples().size(); cursor.setNextPosition(next); cursor.setSourceStatus(page.samples().isEmpty() && page.nextPosition() <= position ? "COMPLETED" : "RUNNING"); cursor.setUpdatedAtUtc(new Date()); mapper.updateBackfillCursor(cursor); if (page.samples().isEmpty() || page.nextPosition() <= position) break; position = next; } cursor.setSourceStatus("COMPLETED"); cursor.setUpdatedAtUtc(new Date()); mapper.updateBackfillCursor(cursor); } private void saveBtsSample(TTest test, TTestChannelBinding binding, List intervals, String btsTestId, HistorySample sample) { byte[] hash = hash(sample.sequenceId() + "|" + sample.stepNo() + "|" + sample.cycleNo() + "|" + sample.stepType() + "|" + sample.deviceTimeRaw() + "|" + sample.voltageV() + "|" + sample.currentA() + "|" + sample.capacityAh() + "|" + sample.energyWh()); String testScoped = mapper.selectTestSampleContentHash(test.getTestId(), binding.getChannelId(), sample.sequenceId()); if (testScoped != null) { if (!testScoped.equalsIgnoreCase(HexFormat.of().formatHex(hash))) mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(), "测试内采样内容冲突 seqid=" + sample.sequenceId()); return; } String old = mapper.selectSampleContentHash(binding.getChannelId(), btsTestId, sample.sequenceId()); if (old != null && !old.equalsIgnoreCase(HexFormat.of().formatHex(hash))) { mapper.insertSampleConflict(test.getTestId(), binding.getChannelId(), "BTS testid=" + btsTestId + ", seqid=" + sample.sequenceId()); return; } mapper.insertBtsSample(test.getTestId(), binding.getBindingId(), binding.getChannelId(), intervalId(intervals, sample.deviceTime()), btsTestId, sample.sequenceId(), Date.from(sample.deviceTime()), sample.deviceTimeRaw(), new Date(), sample.cycleNo(), sample.stepNo(), sample.stepType(), stepNameForType(sample.stepType()), sample.testTimeMs(), sample.voltageV(), sample.currentA(), sample.capacityAh(), sample.energyWh(), hash); Long sampleId = mapper.selectBtsSampleId(binding.getChannelId(), btsTestId, sample.sequenceId()); if (sampleId != null) sample.measurements().forEach((code, measurement) -> mapper.insertBtsMeasurement(sampleId, code, measurement.value(), measurement.unit() == null ? "" : measurement.unit(), measurement.sourceType())); } private void backfillCollector(TTest test, TTestBattery battery, TTestCollectorChannel mapping) { if (battery.getCollectorId() == null) return; if (battery.getBinding() == null) throw new ServiceException("采集器绑定缺少检测柜通道"); String collectorCode = mapper.selectCollectorCode(battery.getCollectorId()); if (collectorCode == null || collectorCode.isBlank()) throw new ServiceException("采集器来源不存在"); List intervals = intervals(test.getTestId(), battery.getBinding().getBindingId(), "COLLECTOR_CHANNEL", battery.getCollectorId(), mapping.getChannelCode()); if (intervals.isEmpty()) return; TTestBackfillCursor cursor = cursor(test, battery.getBinding().getBindingId(), "COLLECTOR_CHANNEL", battery.getCollectorId(), mapping.getChannelCode(), collectorCode); long offset = Math.max(0L, (cursor.getNextPosition() == null ? 1L : cursor.getNextPosition()) - 1L); while (true) { var page = collectorHistoryStore.loadCollectorHistoryPage(collectorCode, offset, PAGE_SIZE, false); for (CollectorMeasurementRecord record : page.records()) { if (!mapping.getMeasurementCode().equalsIgnoreCase(record.metricCode()) || !within(record.deviceTime(), intervals)) continue; saveCollectorSample(test, battery, mapping, intervals, record); } offset = page.nextOffset(); cursor.setNextPosition(offset + 1L); cursor.setSourceStatus(page.hasMore() ? "RUNNING" : "COMPLETED"); cursor.setUpdatedAtUtc(new Date()); mapper.updateBackfillCursor(cursor); if (!page.hasMore()) break; } } private void saveCollectorSample(TTest test, TTestBattery battery, TTestCollectorChannel mapping, List intervals, CollectorMeasurementRecord record) { byte[] hash = hash(record.sequenceId() + "|" + record.deviceTimeRaw() + "|" + record.metricCode() + "|" + record.value() + "|" + record.unit()); String old = mapper.selectCollectorSampleContentHash(battery.getCollectorId(), record.channelCode(), record.sequenceId(), record.metricCode()); if (old != null && !old.equalsIgnoreCase(HexFormat.of().formatHex(hash))) { mapper.insertSampleConflict(test.getTestId(), null, "采集器=" + record.collectorCode() + ", seqid=" + record.sequenceId()); return; } mapper.insertCollectorSample(test.getTestId(), battery.getBinding().getBindingId(), intervalId(intervals, record.deviceTime()), battery.getCollectorId(), record.channelCode(), record.sequenceId(), Date.from(record.deviceTime()), record.deviceTimeRaw(), Date.from(record.serverReceivedAt()), record.metricCode(), record.value(), record.unit(), hash); } /** BTS download 协议只提供类型码;演示协议按已确认的类型语义补出名称。 */ private String stepNameForType(String stepType) { return switch (stepType == null ? "" : stepType.toLowerCase(java.util.Locale.ROOT)) { case "cc" -> "恒流充电"; case "dc" -> "恒流放电"; case "rest" -> "搁置"; default -> stepType; }; } private boolean checkIntegrity(TTest test, Date checkedAt) { // 序号只在同一来源、同一接收区间内比较;区间边界和暂停间隔不构成缺口。 boolean complete = true; for (TTestReceptionInterval interval : mapper.selectTestReceptionIntervals(test.getTestId())) { String sourceType = interval.getSourceType(); List sequenceIds = "CABINET_CHANNEL".equals(sourceType) ? mapper.selectBtsSequencesByInterval(interval.getIntervalId()) : mapper.selectCollectorSequencesByInterval(interval.getIntervalId()); List gaps = IntervalIntegrityChecker.gaps(sequenceIds); if (sequenceIds == null || sequenceIds.isEmpty()) { complete = false; TTestIntegrityIssue old = mapper.selectOpenIntegrityIssue(interval.getIntervalId(), sourceType, interval.getSourceId(), interval.getSourceChannelCode(), null, null); if (old == null) { TTestIntegrityIssue issue = new TTestIntegrityIssue(); issue.setTestId(test.getTestId()); issue.setIntervalId(interval.getIntervalId()); issue.setBindingId(interval.getBindingId()); issue.setSourceType(sourceType); issue.setSourceId(interval.getSourceId()); issue.setSourceChannelCode(interval.getSourceChannelCode()); issue.setIssueType("NO_SAMPLES"); issue.setRetryCount(1); issue.setDetail("接收区间没有可确认的历史采样"); issue.setFirstDetectedAtUtc(checkedAt); issue.setLastDetectedAtUtc(checkedAt); mapper.insertIntegrityIssue(issue); mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_DETECTED", null, "system", null, JSON.toJSONString(java.util.Map.of("intervalId", interval.getIntervalId(), "sourceType", sourceType, "issueType", "NO_SAMPLES"))); } else { int retry = old.getRetryCount() == null ? 1 : old.getRetryCount() + 1; int cappedRetry = Math.min(retry, MAX_INTEGRITY_RETRIES); mapper.touchIntegrityIssue(old.getIssueId(), cappedRetry, old.getDetail(), checkedAt); mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RETRIED", null, "system", null, JSON.toJSONString(java.util.Map.of("issueId", old.getIssueId(), "retryCount", cappedRetry))); if (cappedRetry < MAX_INTEGRITY_RETRIES) mapper.rewindBackfillCursor(test.getTestId(), interval.getBindingId(), sourceType, interval.getSourceId(), interval.getSourceChannelCode(), 1L); } continue; } if (gaps.isEmpty()) { int resolved = mapper.resolveIntegrityIssues(test.getTestId(), interval.getIntervalId(), sourceType, interval.getSourceId(), interval.getSourceChannelCode(), checkedAt); if (resolved > 0) mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RESOLVED", null, "system", null, JSON.toJSONString(java.util.Map.of("intervalId", interval.getIntervalId()))); continue; } complete = false; for (TTestIntegrityIssue open : mapper.selectOpenIntegrityIssues(interval.getIntervalId(), sourceType, interval.getSourceId(), interval.getSourceChannelCode())) { boolean stillOpen = gaps.stream().anyMatch(gap -> Objects.equals(gap.from(), open.getMissingFromSequence()) && Objects.equals(gap.to(), open.getMissingToSequence())); if (!stillOpen && mapper.resolveIntegrityIssue(open.getIssueId(), checkedAt) > 0) mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RESOLVED", null, "system", null, JSON.toJSONString(java.util.Map.of("issueId", open.getIssueId()))); } for (IntervalIntegrityChecker.Gap gap : gaps) { TTestIntegrityIssue old = mapper.selectOpenIntegrityIssue(interval.getIntervalId(), sourceType, interval.getSourceId(), interval.getSourceChannelCode(), gap.from(), gap.to()); if (old == null) { TTestIntegrityIssue issue = new TTestIntegrityIssue(); issue.setTestId(test.getTestId()); issue.setIntervalId(interval.getIntervalId()); issue.setBindingId(interval.getBindingId()); issue.setSourceType(sourceType); issue.setSourceId(interval.getSourceId()); issue.setSourceChannelCode(interval.getSourceChannelCode()); issue.setIssueType("SEQUENCE_GAP"); issue.setMissingFromSequence(gap.from()); issue.setMissingToSequence(gap.to()); issue.setRetryCount(1); issue.setDetail("接收区间内存在未解释序号缺口"); issue.setFirstDetectedAtUtc(checkedAt); issue.setLastDetectedAtUtc(checkedAt); mapper.insertIntegrityIssue(issue); mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_DETECTED", null, "system", null, JSON.toJSONString(java.util.Map.of("intervalId", interval.getIntervalId(), "sourceType", sourceType, "missingFrom", gap.from(), "missingTo", gap.to()))); } else { int retry = old.getRetryCount() == null ? 1 : old.getRetryCount() + 1; int cappedRetry = Math.min(retry, MAX_INTEGRITY_RETRIES); mapper.touchIntegrityIssue(old.getIssueId(), cappedRetry, old.getDetail(), checkedAt); mapper.insertTestAudit(test.getTestId(), "INTEGRITY_GAP_RETRIED", null, "system", null, JSON.toJSONString(java.util.Map.of("issueId", old.getIssueId(), "retryCount", cappedRetry))); if (cappedRetry >= MAX_INTEGRITY_RETRIES) continue; } // next_position 是分页偏移而非 seqid;从安全起点重放,依靠幂等键避免覆盖原始事实。 mapper.rewindBackfillCursor(test.getTestId(), interval.getBindingId(), sourceType, interval.getSourceId(), interval.getSourceChannelCode(), 1L); } } return complete && mapper.countOpenIntegrityIssues(test.getTestId()) == 0 && mapper.countOpenExceptions(test.getTestId()) == 0; } private TTestBackfillCursor cursor(TTest test, Long bindingId, String sourceType, Long sourceId, String channelCode, String protocolTestId) { TTestBackfillCursor value = mapper.selectBackfillCursorForUpdate(test.getTestId(), bindingId, sourceType, sourceId, channelCode); if (value != null) { if (value.getProtocolTestId() != null && !Objects.equals(value.getProtocolTestId(), protocolTestId)) throw new ServiceException("历史协议测试标识发生变化"); return value; } value = new TTestBackfillCursor(); value.setTestId(test.getTestId()); value.setBindingId(bindingId); value.setSourceType(sourceType); value.setSourceId(sourceId); value.setSourceChannelCode(channelCode); value.setProtocolTestId(protocolTestId); value.setNextPosition(1L); value.setSourceStatus("PENDING"); value.setProtocolMetadata("{}"); value.setUpdatedAtUtc(new Date()); mapper.insertBackfillCursor(value); return value; } private List intervals(Long testId, Long bindingId, String sourceType, Long sourceId, String channelCode) { return mapper.selectTestReceptionIntervals(testId).stream() .filter(item -> Objects.equals(item.getBindingId(), bindingId) && Objects.equals(item.getSourceType(), sourceType) && Objects.equals(item.getSourceId(), sourceId) && Objects.equals(item.getSourceChannelCode(), channelCode)).toList(); } private boolean within(Instant time, List intervals) { return time != null && intervals.stream().anyMatch(item -> !time.isBefore(item.getStartBoundaryAtUtc().toInstant()) && (item.getEndBoundaryAtUtc() == null || time.isBefore(item.getEndBoundaryAtUtc().toInstant()))); } private Long intervalId(List intervals, Instant time) { return intervals.stream().filter(item -> !time.isBefore(item.getStartBoundaryAtUtc().toInstant()) && (item.getEndBoundaryAtUtc() == null || time.isBefore(item.getEndBoundaryAtUtc().toInstant()))) .map(TTestReceptionInterval::getIntervalId).findFirst().orElse(null); } private byte[] hash(String value) { try { return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8)); } catch (Exception exception) { throw new IllegalStateException("SHA-256不可用", exception); } } }