| | |
| | | import java.util.concurrent.ScheduledExecutorService; |
| | | import java.util.concurrent.ScheduledFuture; |
| | | import java.util.concurrent.TimeUnit; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | import java.util.concurrent.ConcurrentMap; |
| | | import jakarta.annotation.PreDestroy; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; |
| | |
| | | import com.ruoyi.device.model.Measurement; |
| | | import com.ruoyi.system.domain.TTest; |
| | | import com.ruoyi.system.domain.TTestBattery; |
| | | import com.ruoyi.system.domain.TTestCollectorExceptionInterval; |
| | | import com.ruoyi.system.mapper.TTestMapper; |
| | | 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; |
| | | |
| | | /** 将设备最新快照转换为测试视角,并托管 SSE 连接生命周期。 */ |
| | |
| | | private final ITTestService tests; |
| | | private final DeviceDiscoveryCandidateStore candidates; |
| | | private final BtsDevelopmentSimulator simulator; |
| | | private final TTestMapper testMapper; |
| | | private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(2); |
| | | private final ConcurrentMap<String, LiveSource> lastSources = new ConcurrentHashMap<>(); |
| | | |
| | | public TTestLiveServiceImpl(ITTestService tests, DeviceDiscoveryCandidateStore candidates, |
| | | BtsDevelopmentSimulator simulator) |
| | | { this.tests = tests; this.candidates = candidates; this.simulator = simulator; } |
| | | BtsDevelopmentSimulator simulator, TTestMapper testMapper) |
| | | { this.tests = tests; this.candidates = candidates; this.simulator = simulator; this.testMapper = testMapper; } |
| | | |
| | | @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 freezeSnapshots = "PAUSED".equals(test.getReceptionStatus()) |
| | | || "ENDED".equals(test.getReceptionStatus()); |
| | | for (TTestBattery battery : test.getBatteries() == null ? List.<TTestBattery>of() : test.getBatteries()) |
| | | { |
| | | if (battery.getBinding() != null) sources.add(cabinetSource(battery, cabinets)); |
| | | if (battery.getCollectorId() != null) sources.add(collectorSource(battery)); |
| | | if (battery.getBinding() != null) |
| | | sources.add(snapshotFor(testId, cabinetSource(battery, cabinets), freezeSnapshots)); |
| | | if (battery.getCollectorId() != null) |
| | | sources.add(snapshotFor(testId, collectorSource(battery), freezeSnapshots)); |
| | | } |
| | | List<Map<String, Object>> exceptions = sources.stream().filter(source -> !"ONLINE".equals(source.deviceStatus()) |
| | | List<TTestCollectorExceptionInterval> persisted = testMapper.selectCollectorExceptionIntervals(testId); |
| | | if (persisted == null) persisted = List.of(); |
| | | List<Map<String, Object>> exceptions = new ArrayList<>(persisted.stream().map(interval -> { |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("sourceType", "COLLECTOR"); |
| | | item.put("sourceId", interval.getCollectorId()); |
| | | item.put("collectorCode", interval.getCollectorCode()); |
| | | item.put("collectorName", interval.getCollectorName()); |
| | | item.put("channelCode", interval.getCollectorChannelCode()); |
| | | item.put("measurementName", interval.getMeasurementName()); |
| | | item.put("exceptionType", interval.getExceptionType()); |
| | | item.put("status", interval.getIntervalStatus()); |
| | | item.put("openedAt", interval.getOpenedAtUtc()); |
| | | item.put("missingFrom", interval.getMissingFromUtc()); |
| | | item.put("recoveredAt", interval.getRecoveredAtUtc()); |
| | | item.put("missingTo", interval.getMissingToUtc()); |
| | | item.put("detail", interval.getDetail()); |
| | | return item; |
| | | }).toList()); |
| | | exceptions.addAll(sources.stream().filter(source -> !"COLLECTOR".equals(source.sourceType()) |
| | | && !"ONLINE".equals(source.deviceStatus()) |
| | | && !"WORKING".equals(source.deviceStatus()) && !"PAUSED".equals(source.deviceStatus())) |
| | | .map(source -> Map.<String, Object>of("sourceType", source.sourceType(), "sourceId", source.sourceId(), |
| | | "batteryNo", source.batteryNo(), "status", source.deviceStatus(), "message", "数据来源当前不可观测")) |
| | | .toList(); |
| | | return new LiveState(testId, test.getTestStatus(), test.getReceptionStatus(), Instant.now(), sources, exceptions); |
| | | .toList()); |
| | | boolean abnormal = persisted.stream().anyMatch(item -> "OPEN".equals(item.getIntervalStatus())); |
| | | String testStatus = abnormal && "RUNNING".equals(test.getTestStatus()) ? "RUNNING_ABNORMAL" : test.getTestStatus(); |
| | | List<com.ruoyi.system.domain.TTestIntegrityIssue> integrity = new ArrayList<>(); |
| | | List<com.ruoyi.system.domain.TTestIntegrityIssue> persistedIntegrity = testMapper.selectIntegrityIssues(testId); |
| | | if (persistedIntegrity != null) integrity.addAll(persistedIntegrity); |
| | | List<com.ruoyi.system.domain.TTestIntegrityIssue> persistedExceptions = testMapper.selectTestExceptionFacts(testId); |
| | | if (persistedExceptions != null) integrity.addAll(persistedExceptions); |
| | | TestPlanSnapshot testPlan = detectorTestPlan(test); |
| | | return new LiveState(testId, testStatus, test.getReceptionStatus(), Instant.now(), sources, exceptions, |
| | | integrity == null ? List.of() : integrity, 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<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(Long testId, LiveSource fresh, boolean freeze) |
| | | { |
| | | String key = testId + ":" + fresh.sourceType() + ":" + fresh.sourceId() + ":" + fresh.batteryNo(); |
| | | if (freeze) return lastSources.getOrDefault(key, fresh); |
| | | lastSources.put(key, fresh); |
| | | return fresh; |
| | | } |
| | | |
| | | @Override public SseEmitter stream(Long testId) |
| | | { |
| | | SseEmitter emitter = new SseEmitter(0L); |
| | | SseEmitter emitter = createEmitter(); |
| | | try { sendAll(emitter, testId); } |
| | | catch (RuntimeException | IOException exception) { emitter.completeWithError(exception); return emitter; } |
| | | 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 (RuntimeException | IOException exception) { emitter.completeWithError(exception); } |
| | | 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 |
| | |
| | | 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("integrity").data(state.integrityIssues())); |
| | | emitter.send(SseEmitter.event().name("heartbeat").data(Map.of("at", state.updatedAt()))); |
| | | } |
| | | |
| | |
| | | if (channel.latestSnapshot() == null) return value; |
| | | value.put("source", "检测柜"); |
| | | value.put("observedAt", channel.latestSnapshot().receivedAt()); |
| | | // 实时方案需要设备当前循环和实际工步;这些字段只用于快照展示,不形成历史采集事实。 |
| | | 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()); |