package com.ruoyi.system.service.impl;
|
|
import java.io.IOException;
|
import java.time.Instant;
|
import java.util.ArrayList;
|
import java.util.LinkedHashMap;
|
import java.util.List;
|
import java.util.Map;
|
import java.util.Objects;
|
import java.util.concurrent.Executors;
|
import java.util.concurrent.ScheduledExecutorService;
|
import java.util.concurrent.ScheduledFuture;
|
import java.util.concurrent.TimeUnit;
|
import jakarta.annotation.PreDestroy;
|
import org.springframework.stereotype.Service;
|
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.lang.Nullable;
|
import org.springframework.web.servlet.mvc.method.annotation.SseEmitter;
|
import com.battery.device.simulator.BtsDevelopmentSimulator;
|
import com.battery.device.simulator.BtsDevelopmentSimulator.CollectorSnapshot;
|
import com.battery.device.discovery.DeviceDiscoveryCandidateStore;
|
import com.battery.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
|
import com.battery.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
|
import com.battery.device.model.Measurement;
|
import com.ruoyi.system.domain.TTest;
|
import com.ruoyi.system.domain.TTestBattery;
|
import com.ruoyi.system.service.ITestLiveService;
|
import com.ruoyi.system.service.ITestLiveService.TestPlanRow;
|
import com.ruoyi.system.service.ITestLiveService.TestPlanSnapshot;
|
import com.ruoyi.system.service.ITTestService;
|
import com.ruoyi.system.mapper.TTestMapper;
|
|
/** 将设备最新快照转换为测试视角,并托管 SSE 连接生命周期。 */
|
@Service
|
public class TTestLiveServiceImpl implements ITestLiveService
|
{
|
private final ITTestService tests;
|
private final DeviceDiscoveryCandidateStore candidates;
|
private final BtsDevelopmentSimulator simulator;
|
private final TTestMapper mapper;
|
private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2);
|
|
@Autowired
|
public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
|
@Nullable BtsDevelopmentSimulator simulator, TTestMapper mapper)
|
{ this.tests = tests; this.candidates = candidates; this.simulator = simulator; this.mapper = mapper; }
|
|
public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates,
|
BtsDevelopmentSimulator simulator)
|
{ this(tests, candidates, simulator, null); }
|
|
@Override public LiveState current(Long testId)
|
{
|
TTest test = tests.selectTestById(testId);
|
List<CabinetCandidate> cabinets = candidates.snapshot(Instant.now()).cabinets();
|
List<LiveSource> sources = new ArrayList<>();
|
boolean receiving = "RECEIVING".equals(test.getReceptionStatus());
|
for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries())
|
{
|
if (battery.getBinding() != null)
|
sources.add(snapshotFor(cabinetSource(battery, cabinets), receiving));
|
if (battery.getCollectorId() != null)
|
sources.add(snapshotFor(collectorSource(battery), receiving));
|
}
|
List<Map<String, Object>> exceptions = new ArrayList<>();
|
if (receiving)
|
exceptions.addAll(sources.stream().filter(source -> !"COLLECTOR".equals(source.sourceType())
|
&& "OFFLINE".equals(source.deviceStatus()))
|
.map(source -> Map.<String, Object>of("sourceType", source.sourceType(), "sourceId", source.sourceId(),
|
"batteryNo", source.batteryNo(), "status", source.deviceStatus(), "message", "数据来源当前不可观测"))
|
.toList());
|
// 完成或暂停后只保留历史异常事实,实时控制台不再展示开放预警。
|
if (receiving && mapper != null) {
|
mapper.selectWarningExceptions(testId, "OPEN").forEach(item -> exceptions.add(Map.of(
|
"exceptionId", item.getExceptionId(), "sourceType", item.getSourceType(),
|
"metricCode", item.getMetricCode(), "value", item.getAbnormalValue(), "unit", item.getUnit(),
|
"status", item.getExceptionStatus(), "message", "测试预警异常")));
|
}
|
String testStatus = test.getTestStatus();
|
TestPlanSnapshot testPlan = detectorTestPlan(test);
|
return new LiveState(testId, testStatus, test.getReceptionStatus(), Instant.now().toEpochMilli(), sources, exceptions, testPlan);
|
}
|
|
private TestPlanSnapshot detectorTestPlan(TTest test)
|
{
|
if (simulator == null) return null;
|
if (test.getBatteries() == null) return null;
|
for (TTestBattery battery : test.getBatteries())
|
{
|
if (battery.getBinding() == null || battery.getBinding().getDevid() == null
|
|| battery.getBinding().getChlid() == null) continue;
|
try
|
{
|
var source = "NOT_STARTED".equals(test.getReceptionStatus())
|
? simulator.btsTest(battery.getBinding().getDevid(), battery.getBinding().getChlid(), 1)
|
: simulator.btsTest(battery.getBinding().getDevid(), battery.getBinding().getChlid());
|
List<TestPlanRow> rows = source.rows().stream()
|
.map(row -> new TestPlanRow(row.rowType(), row.stepNo(), row.cyclePosition(), row.stepName(), row.durationSeconds(),
|
row.voltageV(), row.executionStatus(), row.cycleCount(), row.recordCount()))
|
.toList();
|
return new TestPlanSnapshot(source.currentCycleNo(), source.currentStepNo(),
|
source.plannedCycleCount(), rows);
|
}
|
catch (IllegalArgumentException ignored) { /* 登记设备尚未被开发模拟器提供时保持无方案事实。 */ }
|
}
|
return null;
|
}
|
|
private LiveSource snapshotFor(LiveSource fresh, boolean receiving)
|
{
|
// 暂停或停止只停止数据请求;设备状态仍以本次查询的最新事实为准,采集快照不继续展示。
|
if (!receiving)
|
return withoutSnapshot(fresh);
|
return fresh;
|
}
|
|
private LiveSource withoutSnapshot(LiveSource source)
|
{
|
return new LiveSource(source.sourceType(), source.sourceId(), source.batteryNo(), source.deviceStatus(), Map.of());
|
}
|
|
@Override public SseEmitter stream(Long testId)
|
{
|
SseEmitter emitter = createEmitter();
|
try { sendAll(emitter, testId); }
|
catch (IOException exception)
|
{
|
// 客户端刷新或离开页面会断开连接,不能再交给全局异常处理器写 SSE JSON。
|
emitter.complete();
|
return emitter;
|
}
|
catch (RuntimeException exception) { emitter.completeWithError(exception); return emitter; }
|
ScheduledFuture<?> future = scheduler.scheduleAtFixedRate(() -> {
|
try { sendAll(emitter, testId); }
|
catch (IOException exception)
|
{
|
// 写出失败表示 SSE 客户端已断开,静默结束连接。
|
emitter.complete();
|
}
|
catch (RuntimeException exception) { emitter.completeWithError(exception); }
|
}, 3, 3, TimeUnit.SECONDS);
|
emitter.onCompletion(() -> future.cancel(false));
|
emitter.onTimeout(() -> future.cancel(false));
|
emitter.onError(error -> future.cancel(false));
|
return emitter;
|
}
|
|
/** 创建实时流发射器,供断连生命周期测试替换。 */
|
protected SseEmitter createEmitter()
|
{
|
return new SseEmitter(0L);
|
}
|
|
private void sendAll(SseEmitter emitter, Long testId) throws IOException
|
{
|
LiveState state = current(testId);
|
emitter.send(SseEmitter.event().name("snapshot").data(state));
|
emitter.send(SseEmitter.event().name("device-status").data(state.sources()));
|
emitter.send(SseEmitter.event().name("reception-status").data(Map.of("testId", testId, "receptionStatus", state.receptionStatus())));
|
emitter.send(SseEmitter.event().name("test-status").data(Map.of("testId", testId, "testStatus", state.testStatus())));
|
emitter.send(SseEmitter.event().name("exception").data(state.exceptions()));
|
emitter.send(SseEmitter.event().name("heartbeat").data(Map.of("at", state.updatedAt())));
|
}
|
|
private LiveSource cabinetSource(TTestBattery battery, List<CabinetCandidate> cabinets)
|
{
|
var binding = battery.getBinding();
|
for (CabinetCandidate cabinet : cabinets) for (ChannelCandidate channel : cabinet.channels())
|
if (Objects.equals(channel.identity().devtype(), binding.getDevtype())
|
&& Objects.equals(channel.identity().devid(), binding.getDevid())
|
&& Objects.equals(channel.identity().subdevid(), binding.getSubdevid())
|
&& Objects.equals(channel.identity().chlid(), binding.getChlid()))
|
return new LiveSource("CABINET_CHANNEL", binding.getChannelId(), battery.getBatteryNo(),
|
channel.online() ? "ONLINE" : "OFFLINE",
|
channel.online() ? snapshot(channel) : Map.of());
|
return new LiveSource("CABINET_CHANNEL", binding.getChannelId(), battery.getBatteryNo(), "OFFLINE", Map.of());
|
}
|
|
private Map<String, Object> snapshot(ChannelCandidate channel)
|
{
|
Map<String, Object> value = new LinkedHashMap<>();
|
if (channel.latestSnapshot() == null) return value;
|
value.put("source", "检测柜");
|
value.put("observedAt", channel.latestSnapshot().receivedAt().toEpochMilli());
|
// BTS 核心采样字段是快照的一等事实,不能只依赖动态 measurements 展示。
|
value.put("currentA", channel.latestSnapshot().currentA());
|
value.put("voltageV", channel.latestSnapshot().voltageV());
|
value.put("capacityAh", channel.latestSnapshot().capacityAh());
|
value.put("energyWh", channel.latestSnapshot().energyWh());
|
// 实时方案需要设备当前循环和实际工步;这些字段只用于快照展示,不形成历史采集事实。
|
value.put("cycleNo", channel.latestSnapshot().cycleNo());
|
value.put("stepNo", channel.latestSnapshot().stepNo());
|
value.put("stepType", channel.latestSnapshot().stepType());
|
value.put("totalTimeSeconds", channel.latestSnapshot().totalTimeSeconds());
|
value.put("measurements", channel.latestSnapshot().measurements().entrySet().stream().map(entry -> {
|
Measurement measurement = entry.getValue();
|
return Map.of("metricCode", entry.getKey(), "value", measurement.value(), "unit", measurement.unit());
|
}).toList());
|
List<String> units = new ArrayList<>();
|
if (channel.latestSnapshot().currentA() != null) units.add("A");
|
if (channel.latestSnapshot().voltageV() != null) units.add("V");
|
if (channel.latestSnapshot().capacityAh() != null) units.add("Ah");
|
if (channel.latestSnapshot().energyWh() != null) units.add("Wh");
|
units.addAll(channel.latestSnapshot().measurements().values().stream().map(Measurement::unit).toList());
|
value.put("units", units.stream().filter(item -> item != null && !item.isBlank()).distinct().toList());
|
return value;
|
}
|
|
private LiveSource collectorSource(TTestBattery battery)
|
{
|
if (simulator == null) return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
|
"OFFLINE", Map.of());
|
for (CollectorSnapshot collector : simulator.snapshot().collectors()) if (collector.collectorId() == battery.getCollectorId())
|
{
|
Map<String, Object> value = new LinkedHashMap<>();
|
value.put("source", "采集器"); value.put("sampleIntervalMs", collector.sampleIntervalMs());
|
value.put("measurements", collector.latestMeasurements().stream().map(item -> {
|
Map<String, Object> measurement = new LinkedHashMap<>();
|
measurement.put("metricCode", item.metricCode());
|
measurement.put("channelCode", item.channelCode());
|
measurement.put("value", item.value());
|
measurement.put("unit", item.unit());
|
measurement.put("deviceTime", item.deviceTime().toEpochMilli());
|
measurement.put("serverReceivedAt", item.serverReceivedAt().toEpochMilli());
|
return measurement;
|
}).toList());
|
value.put("units", collector.latestMeasurements().stream().map(item -> item.unit()).distinct().toList());
|
collector.latestMeasurements().stream().map(item -> item.serverReceivedAt()).max(Instant::compareTo)
|
.ifPresent(observedAt -> value.put("observedAt", observedAt.toEpochMilli()));
|
return new LiveSource("COLLECTOR", (long) collector.collectorId(), battery.getBatteryNo(),
|
collector.online() ? "ONLINE" : "OFFLINE", collector.online() ? value : Map.of());
|
}
|
return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(), "OFFLINE", Map.of());
|
}
|
|
@PreDestroy void shutdown() { scheduler.shutdownNow(); }
|
}
|