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.web.servlet.mvc.method.annotation.SseEmitter; import com.ruoyi.device.bts.BtsDevelopmentSimulator; import com.ruoyi.device.bts.BtsDevelopmentSimulator.CollectorSnapshot; import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore; import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate; import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate; import com.ruoyi.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, 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 cabinets = candidates.snapshot(Instant.now()).cabinets(); List sources = new ArrayList<>(); boolean receiving = "RECEIVING".equals(test.getReceptionStatus()); for (TTestBattery battery : test.getBatteries() == null ? List.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> exceptions = new ArrayList<>(); if (receiving) exceptions.addAll(sources.stream().filter(source -> !"COLLECTOR".equals(source.sourceType()) && "OFFLINE".equals(source.deviceStatus())) .map(source -> Map.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(), sources, exceptions, testPlan); } private TestPlanSnapshot detectorTestPlan(TTest test) { 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 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 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 snapshot(ChannelCandidate channel) { Map value = new LinkedHashMap<>(); if (channel.latestSnapshot() == null) return value; value.put("source", "检测柜"); value.put("observedAt", channel.latestSnapshot().receivedAt()); // 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 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) { for (CollectorSnapshot collector : simulator.snapshot().collectors()) if (collector.collectorId() == battery.getCollectorId()) { Map value = new LinkedHashMap<>(); value.put("source", "采集器"); value.put("sampleIntervalMs", collector.sampleIntervalMs()); value.put("measurements", collector.latestMeasurements()); 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)); 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(); } }