From 14ac9c2a2384f1e55b4c01eb7a104033b589d1e0 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期六, 29 八月 2026 16:47:05 +0800
Subject: [PATCH] 增加模拟器设备总开关

---
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java                         |   27 +++++++++
 ruoyi-admin/src/main/resources/static/dev/bts-simulator.html                                         |   41 ++++++++++++-
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java |   42 ++++++++++++++
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java     |   12 ++++
 ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java                |   37 ++++++++++++
 5 files changed, 155 insertions(+), 4 deletions(-)

diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
index 7563d63..855f830 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java
@@ -63,6 +63,16 @@
         return simulator.snapshot();
     }
 
+    /** 模拟现场人工统一开关全部检测柜和采集器,不发送任何设备控制命令。 */
+    @PostMapping("/dev/bts-simulator/power")
+    public StateSnapshot setPower(@RequestBody SimulatorPowerCommand command)
+    {
+        if (command == null || command.poweredOn() == null)
+            throw badRequest(new IllegalArgumentException("poweredOn must be true or false"));
+        simulator.setAllDevicesPoweredOn(command.poweredOn());
+        return simulator.snapshot();
+    }
+
     /** 添加一台虚拟设备。 */
     @PostMapping("/dev/bts-simulator/devices")
     public StateSnapshot createDevice(@RequestBody CreateDeviceCommand command)
@@ -243,6 +253,8 @@
 
     public record CreateDeviceCommand(String ip, int channelCount) { }
 
+    public record SimulatorPowerCommand(Boolean poweredOn) { }
+
     public record DeviceStateCommand(String ip, int channelCount, boolean online) { }
 
     public record ChannelStateCommand(boolean online, DeviceWorkStatus workStatus) { }
diff --git a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
index 57a89b5..23fca3a 100644
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -15,7 +15,7 @@
     section { margin-bottom: 20px; background: #fff; border: 1px solid #d5dde3; }
     .section-title, .device-title { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 44px; padding: 0 16px; background: #edf2f5; border-bottom: 1px solid #d5dde3; }
     h2, h3 { margin: 0; font-size: 14px; font-weight: 600; letter-spacing: 0; }
-    .summary { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
+    .summary { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); }
     .summary > div { min-height: 76px; padding: 15px 16px; border-right: 1px solid #e2e8ec; }
     .summary > div:last-child { border-right: 0; }
     .label { display: block; margin-bottom: 7px; color: #6b7780; font-size: 12px; }
@@ -36,8 +36,10 @@
     .switch-label > span { margin: 0; }
     button { height: 34px; padding: 0 13px; color: #fff; background: #2877a9; border: 1px solid #2877a9; border-radius: 3px; cursor: pointer; }
     button:hover { background: #1f638e; }
+    button:disabled { cursor: not-allowed; opacity: .5; }
     button.danger { color: #a03946; background: #fff; border-color: #d8a6ad; }
     button.danger:hover { background: #fff3f4; }
+    .power-actions { display: flex; flex-wrap: wrap; gap: 8px; }
     .device { margin: 16px; border: 1px solid #d5dde3; }
     .device-title { background: #f7f9fa; }
     table { width: 100%; border-collapse: collapse; table-layout: fixed; }
@@ -73,16 +75,24 @@
   <main>
     <div id="error" class="error"></div>
     <section>
-      <div class="section-title"><h2>运行状态</h2></div>
+      <div class="section-title">
+        <h2>运行状态</h2>
+        <div class="power-actions">
+          <button id="power-on" type="button">一键模拟开机</button>
+          <button id="power-off" class="danger" type="button">一键模拟关机</button>
+          <button id="reset-simulator" class="danger" type="button">重置模拟器</button>
+        </div>
+      </div>
       <div class="summary">
-        <div><span class="label">模拟器</span><span id="runtime" class="value status">未知</span></div>
+        <div><span class="label">模拟器服务</span><span id="runtime" class="value status">未知</span></div>
+        <div><span class="label">现场设备</span><span id="device-power" class="value status">未知</span></div>
         <div><span class="label">监听地址</span><span id="address" class="value">-</span></div>
         <div><span class="label">检测柜</span><span id="device-count" class="value">0 台</span></div>
         <div><span class="label">采集器</span><span id="collector-count" class="value">0 台</span></div>
       </div>
     </section>
     <section>
-      <div class="section-title"><h2>添加检测柜</h2><button id="reset-simulator" class="danger" type="button">重置模拟器</button></div>
+      <div class="section-title"><h2>添加检测柜</h2></div>
       <form id="add-device" class="add-form">
         <label><span>虚拟 IP</span><input id="new-ip" type="text" value="127.0.0.3" required></label>
         <label><span>通道数</span><input id="new-channel-count" type="number" min="1" max="99" value="4" required></label>
@@ -138,6 +148,18 @@
       const runtime = document.querySelector('#runtime');
       runtime.textContent = state.running ? '运行中' : '已停止';
       runtime.classList.toggle('online', state.running);
+      const devicesPoweredOn = state.devices.length + state.collectors.length > 0
+        && state.devices.every(device => device.online
+          && device.channels.every(channel => channel.online))
+        && state.collectors.every(collector => collector.online);
+      const devicesPoweredOff = state.devices.every(device => !device.online
+          && device.channels.every(channel => !channel.online && channel.workStatus === 'STOPPED'))
+        && state.collectors.every(collector => !collector.online);
+      const devicePower = document.querySelector('#device-power');
+      devicePower.textContent = devicesPoweredOn ? '已开机' : devicesPoweredOff ? '已关机' : '部分开启';
+      devicePower.classList.toggle('online', devicesPoweredOn);
+      document.querySelector('#power-on').disabled = devicesPoweredOn;
+      document.querySelector('#power-off').disabled = devicesPoweredOff;
       document.querySelector('#address').textContent = state.listenAddress;
       document.querySelector('#device-count').textContent = `${state.devices.length} 台`;
       document.querySelector('#collector-count').textContent = `${state.collectors.length} 台`;
@@ -448,6 +470,17 @@
       try { render(await request('/reset', { method: 'POST' })); } catch (error) { showError(error); }
     });
 
+    document.getElementById('power-on').addEventListener('click', async () => {
+      try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: true }) })); }
+      catch (error) { showError(error); }
+    });
+
+    document.getElementById('power-off').addEventListener('click', async () => {
+      if (!confirm('确认关闭全部模拟检测柜和采集器?已有历史会保留,停止增长。')) return;
+      try { render(await request('/power', { method: 'POST', body: JSON.stringify({ poweredOn: false }) })); }
+      catch (error) { showError(error); }
+    });
+
     document.querySelector('#add-device').addEventListener('submit', async event => {
       event.preventDefault();
       try {
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
index 720b47c..7cec568 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java
@@ -14,6 +14,7 @@
 import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
 import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.server.ResponseStatusException;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
 import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
@@ -124,6 +125,47 @@
     }
 
     @Test
+    void controllerPowersAllSimulatorDevicesWithoutResettingHistory() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            BtsDevelopmentSimulatorController controller = controller(simulator);
+            simulator.advanceCollectors(Instant.now());
+            long historyCount = controller.state().collectors().get(0).historyRecordCount();
+
+            var poweredOff = controller.setPower(
+                    new BtsDevelopmentSimulatorController.SimulatorPowerCommand(false));
+
+            assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online()
+                    || device.channels().stream().anyMatch(channel -> channel.online())));
+            assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online()));
+            assertEquals(historyCount, poweredOff.collectors().get(0).historyRecordCount());
+
+            var poweredOn = controller.setPower(
+                    new BtsDevelopmentSimulatorController.SimulatorPowerCommand(true));
+            assertTrue(poweredOn.devices().stream().allMatch(device -> device.online()
+                    && device.channels().stream().allMatch(channel -> channel.online()
+                            && channel.workStatus() == DeviceWorkStatus.STOPPED)));
+            assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online()));
+        }
+    }
+
+    @Test
+    void simulatorPowerCommandRequiresExplicitState() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            BtsDevelopmentSimulatorController controller = controller(simulator);
+
+            ResponseStatusException exception = assertThrows(ResponseStatusException.class,
+                    () -> controller.setPower(new BtsDevelopmentSimulatorController.SimulatorPowerCommand(null)));
+
+            assertEquals(400, exception.getStatusCode().value());
+            assertTrue(controller.state().collectors().get(0).online());
+        }
+    }
+
+    @Test
     void virtualIpMustBeUniqueAcrossCabinetsAndCollectors() throws Exception
     {
         try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
index 707cc65..751124a 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java
@@ -183,9 +183,36 @@
         replaceCollector(DevelopmentCollectorSimulation.configureChannel(current, channelCode, metrics));
     }
 
+    /** 模拟现场人工统一开关机;只改变模拟设备事实,不代表系统具备真实设备控制能力。 */
+    public synchronized void setAllDevicesPoweredOn(boolean poweredOn)
+    {
+        List<DeviceState> devices = state.devices().stream()
+                .map(device -> new DeviceState(device.deviceId(), device.ip(), poweredOn,
+                        device.channels().stream()
+                                .map(channel -> new ChannelState(channel.channelId(), poweredOn,
+                                        DeviceWorkStatus.STOPPED))
+                                .toList()))
+                .toList();
+        Instant changedAt = Instant.now();
+        List<CollectorState> collectors = state.collectors().stream()
+                .map(collector -> DevelopmentCollectorSimulation.resize(collector, collector.ip(),
+                        collector.channelCount(), poweredOn, changedAt))
+                .toList();
+        persist(new SimulatorState(state.version(), devices, collectors, state.nextDeviceId(),
+                state.nextCollectorId(), state.tombstones()));
+    }
+
     /** 按每台采集器自己的频率推进实时和历史采样。 */
     public synchronized void advanceCollectors(Instant now)
     {
+        boolean hasOnlineCollector = false;
+        for (CollectorState collector : state.collectors())
+        {
+            if (!collector.online()) continue;
+            hasOnlineCollector = true;
+            break;
+        }
+        if (!hasOnlineCollector) return;
         List<CollectorState> collectors = new ArrayList<>(state.collectors().size());
         List<CollectorHistoryAppend> historyAppends = new ArrayList<>();
         for (CollectorState collector : state.collectors())
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
index 4570d60..83c40aa 100644
--- a/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java
@@ -120,6 +120,43 @@
     }
 
     @Test
+    void simulatorPowerSwitchStopsSamplingAndRestoresWithoutDeletingHistory() throws Exception
+    {
+        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
+        long historyCount;
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, store))
+        {
+            simulator.advanceCollectors(Instant.now());
+            historyCount = simulator.snapshot().collectors().get(0).historyRecordCount();
+
+            simulator.setAllDevicesPoweredOn(false);
+            simulator.advanceCollectors(Instant.now().plusSeconds(10));
+
+            var poweredOff = simulator.snapshot();
+            assertTrue(poweredOff.devices().stream().noneMatch(device -> device.online()
+                    || device.channels().stream().anyMatch(channel -> channel.online()
+                            || channel.workStatus() != DeviceWorkStatus.STOPPED)));
+            assertTrue(poweredOff.collectors().stream().noneMatch(collector -> collector.online()));
+            assertEquals(historyCount, poweredOff.collectors().get(0).historyRecordCount());
+        }
+
+        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
+        {
+            assertFalse(restored.snapshot().collectors().get(0).online());
+            assertEquals(historyCount, restored.collectorHistory(1, 0, 1000).total());
+
+            restored.setAllDevicesPoweredOn(true);
+            var poweredOn = restored.snapshot();
+            assertTrue(poweredOn.devices().stream().allMatch(device -> device.online()
+                    && device.channels().stream().allMatch(channel -> channel.online()
+                            && channel.workStatus() == DeviceWorkStatus.STOPPED)));
+            assertTrue(poweredOn.collectors().stream().allMatch(collector -> collector.online()));
+            restored.advanceCollectors(Instant.now().plusSeconds(2));
+            assertTrue(restored.snapshot().collectors().get(0).historyRecordCount() > historyCount);
+        }
+    }
+
+    @Test
     void collectorKeepsAppendOnlyHistoryBeyondPreviousDisplayLimit() throws Exception
     {
         Instant startedAt = Instant.parse("2026-08-29T03:00:00Z");

--
Gitblit v1.7.1