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) { } 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 { 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)) 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()) 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");