From 210663040bf65ee00b5f86545fdc31bd0356fddf Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 27 八月 2026 16:31:53 +0800
Subject: [PATCH] feat(device): persist simulator discovery in redis

---
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java |  266 ++++++++++++++++++++++++++++++++++++++++++----------
 1 files changed, 214 insertions(+), 52 deletions(-)

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 4d78627..c299502 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
@@ -3,22 +3,29 @@
 import java.io.IOException;
 import java.io.StringReader;
 import java.time.Instant;
-import java.util.ArrayDeque;
 import java.util.ArrayList;
-import java.util.Deque;
-import java.util.LinkedHashMap;
 import java.util.List;
-import java.util.Map;
 import javax.xml.stream.XMLInputFactory;
 import javax.xml.stream.XMLStreamConstants;
 import javax.xml.stream.XMLStreamReader;
 import com.ruoyi.device.model.DeviceWorkStatus;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelTombstone;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.CollectorState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
+import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
 
 /** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
 public final class BtsDevelopmentSimulator implements AutoCloseable
 {
     public static final String HOST = "127.0.0.1";
     public static final int DEFAULT_PORT = 15020;
+    public static final String ADAPTER_CODE = "dev-sim";
+    public static final String ADAPTER_NAME = "dev-sim BTS 模拟器";
+    public static final int POLL_INTERVAL_MS = 5000;
 
     private static final int DEVICE_TYPE = 22;
     private static final int SUB_DEVICE_ID = 1;
@@ -27,16 +34,22 @@
     private static final int TOMBSTONE_DISCOVERY_COUNT = 3;
 
     private final ScriptedBtsTcpSimulator simulator;
-    private final Map<Integer, DeviceState> devices = new LinkedHashMap<>();
-    private final Map<String, ChannelTombstone> tombstones = new LinkedHashMap<>();
-    private final Deque<RecentRequest> recentRequests = new ArrayDeque<>();
-    private int nextDeviceId = 1;
+    private final BtsSimulatorStateStore stateStore;
+    private SimulatorState state;
     private boolean running = true;
 
     /** 启动仅监听 IPv4 本机回环地址的开发模拟器;传入 0 时由系统分配测试端口。 */
     public BtsDevelopmentSimulator(int port) throws IOException
     {
-        addDeviceInternal("127.0.0.2", 4);
+        this(port, new InMemoryBtsSimulatorStateStore());
+    }
+
+    /** 从长期状态恢复后再启动 TCP 服务,避免服务先暴露默认拓扑。 */
+    public BtsDevelopmentSimulator(int port, BtsSimulatorStateStore stateStore) throws IOException
+    {
+        this.stateStore = stateStore;
+        state = stateStore.loadOrInitialize(defaultState());
+        validateRestoredState();
         simulator = ScriptedBtsTcpSimulator.responding(port, this::respond);
     }
 
@@ -49,7 +62,12 @@
     public synchronized int addDevice(String ip, int channelCount)
     {
         validateDeviceConfiguration(ip, channelCount, null);
-        return addDeviceInternal(ip, channelCount);
+        int deviceId = state.nextDeviceId();
+        List<DeviceState> devices = new ArrayList<>(state.devices());
+        devices.add(newDevice(deviceId, ip, channelCount));
+        persist(new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION, devices, state.collectors(),
+                deviceId + 1, state.nextCollectorId(), state.tombstones()));
+        return deviceId;
     }
 
     /** 修改开发设备的虚拟地址、通道数和在线状态。 */
@@ -64,20 +82,26 @@
                     ? current.channels().get(channelId - 1)
                     : new ChannelState(channelId, true, DeviceWorkStatus.STOPPED));
         }
+        List<DeviceState> devices = replaceDevice(new DeviceState(deviceId, ip.trim(), online, channels));
+        List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
         for (int channelId = channelCount + 1; channelId <= current.channels().size(); channelId++)
-            addTombstone(current.deviceId(), current.ip(), channelId);
-        for (int channelId = 1; channelId <= channelCount; channelId++)
-            tombstones.remove(tombstoneKey(deviceId, channelId));
-        devices.put(deviceId, new DeviceState(deviceId, ip.trim(), online, List.copyOf(channels)));
+            upsertTombstone(tombstones, current.deviceId(), current.ip(), channelId);
+        tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() <= channelCount);
+        persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
+                state.nextCollectorId(), tombstones));
     }
 
     /** 删除指定开发设备;后续发现响应不再返回它。 */
     public synchronized void deleteDevice(int deviceId)
     {
-        DeviceState removed = devices.remove(deviceId);
-        if (removed == null) throw new IllegalArgumentException("Unknown simulator device: " + deviceId);
+        DeviceState removed = requireDevice(deviceId);
+        List<DeviceState> devices = new ArrayList<>(state.devices());
+        devices.removeIf(item -> item.deviceId() == deviceId);
+        List<ChannelTombstone> tombstones = new ArrayList<>(state.tombstones());
         for (ChannelState channel : removed.channels())
-            addTombstone(removed.deviceId(), removed.ip(), channel.channelId());
+            upsertTombstone(tombstones, removed.deviceId(), removed.ip(), channel.channelId());
+        persist(new SimulatorState(state.version(), devices, state.collectors(), state.nextDeviceId(),
+                state.nextCollectorId(), tombstones));
     }
 
     /** 修改指定开发设备的单个通道状态。 */
@@ -90,30 +114,94 @@
             throw new IllegalArgumentException("channelId must be between 1 and " + device.channels().size());
         List<ChannelState> channels = new ArrayList<>(device.channels());
         channels.set(channelId - 1, new ChannelState(channelId, online, workStatus));
-        devices.put(deviceId, new DeviceState(deviceId, device.ip(), device.online(), List.copyOf(channels)));
+        persist(new SimulatorState(state.version(),
+                replaceDevice(new DeviceState(deviceId, device.ip(), device.online(), channels)), state.collectors(),
+                state.nextDeviceId(), state.nextCollectorId(), state.tombstones()));
+    }
+
+    /** 新增一台与 BTS 检测柜拓扑隔离的开发采集器。 */
+    public synchronized int addCollector(String ip, int channelCount)
+    {
+        validateCollectorConfiguration(ip, channelCount, null);
+        int collectorId = state.nextCollectorId();
+        List<CollectorState> collectors = new ArrayList<>(state.collectors());
+        collectors.add(newCollector(collectorId, ip, channelCount));
+        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
+                collectorId + 1, state.tombstones()));
+        return collectorId;
+    }
+
+    /** 修改开发采集器的虚拟地址、通道数和在线状态。 */
+    public synchronized void updateCollector(int collectorId, String ip, int channelCount, boolean online)
+    {
+        validateCollectorConfiguration(ip, channelCount, collectorId);
+        CollectorState current = requireCollector(collectorId);
+        List<CollectorState> collectors = new ArrayList<>(state.collectors());
+        collectors.replaceAll(item -> item.collectorId() == collectorId
+                ? new CollectorState(collectorId, current.deviceCode(), ip.trim(), current.mac(), current.name(),
+                        current.manufacturer(), current.model(), channelCount, online)
+                : item);
+        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
+                state.nextCollectorId(), state.tombstones()));
+    }
+
+    /** 删除指定开发采集器,不影响检测柜 TCP 发现结果。 */
+    public synchronized void deleteCollector(int collectorId)
+    {
+        requireCollector(collectorId);
+        List<CollectorState> collectors = new ArrayList<>(state.collectors());
+        collectors.removeIf(item -> item.collectorId() == collectorId);
+        persist(new SimulatorState(state.version(), state.devices(), collectors, state.nextDeviceId(),
+                state.nextCollectorId(), state.tombstones()));
     }
 
     /** 返回控制页需要的运行状态快照。 */
     public synchronized StateSnapshot snapshot()
     {
-        List<DeviceSnapshot> deviceSnapshots = devices.values().stream()
+        List<DeviceSnapshot> deviceSnapshots = state.devices().stream()
                 .map(device -> new DeviceSnapshot(device.deviceId(), device.ip(), device.online(),
                         device.channels().stream()
                                 .map(channel -> new ChannelSnapshot(channel.channelId(), channel.online(),
                                         channel.workStatus()))
                                 .toList()))
                 .toList();
-        return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, List.copyOf(recentRequests));
+        List<CollectorSnapshot> collectorSnapshots = state.collectors().stream()
+                .map(collector -> new CollectorSnapshot(collector.collectorId(), collector.deviceCode(),
+                        collector.ip(), collector.mac(), collector.name(), collector.manufacturer(),
+                        collector.model(), collector.channelCount(), collector.online()))
+                .toList();
+        return new StateSnapshot(running, HOST + ":" + port(), deviceSnapshots, collectorSnapshots,
+                stateStore.loadRecentRequests());
     }
 
-    private int addDeviceInternal(String ip, int channelCount)
+    /** 人工恢复默认拓扑;这是正常运行中唯一清空长期模拟状态的入口。 */
+    public synchronized void reset()
     {
-        int deviceId = nextDeviceId++;
+        SimulatorState defaultState = defaultState();
+        stateStore.reset(defaultState);
+        state = defaultState;
+    }
+
+    private SimulatorState defaultState()
+    {
+        return new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
+                List.of(newDevice(1, "127.0.0.2", 4)),
+                List.of(newCollector(1, "127.0.1.2", 4)), 2, 2, List.of());
+    }
+
+    private DeviceState newDevice(int deviceId, String ip, int channelCount)
+    {
         List<ChannelState> channels = new ArrayList<>(channelCount);
         for (int channelId = 1; channelId <= channelCount; channelId++)
             channels.add(new ChannelState(channelId, true, DeviceWorkStatus.STOPPED));
-        devices.put(deviceId, new DeviceState(deviceId, ip.trim(), true, List.copyOf(channels)));
-        return deviceId;
+        return new DeviceState(deviceId, ip.trim(), true, channels);
+    }
+
+    private CollectorState newCollector(int collectorId, String ip, int channelCount)
+    {
+        return new CollectorState(collectorId, "dev-sim-collector-" + collectorId,
+                ip.trim(), simulatorCollectorMac(collectorId), "dev-sim 采集器 " + collectorId,
+                "开发模拟器", "Virtual Collector", channelCount, true);
     }
 
     private String respond(String request)
@@ -130,8 +218,11 @@
 
     private synchronized void recordRequest(String command, String request)
     {
-        recentRequests.addFirst(new RecentRequest(Instant.now(), command, request));
-        while (recentRequests.size() > MAX_RECENT_REQUESTS) recentRequests.removeLast();
+        List<RecentRequest> recentRequests = new ArrayList<>(stateStore.loadRecentRequests());
+        recentRequests.add(0, new RecentRequest(Instant.now(), command, request));
+        if (recentRequests.size() > MAX_RECENT_REQUESTS)
+            recentRequests = new ArrayList<>(recentRequests.subList(0, MAX_RECENT_REQUESTS));
+        stateStore.saveRecentRequests(recentRequests);
     }
 
     private String readCommand(String request)
@@ -157,11 +248,11 @@
 
     private synchronized String deviceInfoResponse()
     {
-        int channelCount = devices.values().stream().mapToInt(device -> device.channels().size()).sum()
-                + tombstones.size();
+        int channelCount = state.devices().stream().mapToInt(device -> device.channels().size()).sum()
+                + state.tombstones().size();
         StringBuilder response = new StringBuilder("<bts version=\"1.0\"><cmd>getdevinfo_resp</cmd>")
                 .append("<serverip count=\"0\"/><middle count=\"").append(channelCount).append("\">");
-        for (DeviceState device : devices.values())
+        for (DeviceState device : state.devices())
         {
             for (ChannelState channel : device.channels())
             {
@@ -169,14 +260,17 @@
                 appendDiscoveredChannel(response, device.deviceId(), device.ip(), channel.channelId(), available);
             }
         }
-        for (Map.Entry<String, ChannelTombstone> entry : new ArrayList<>(tombstones.entrySet()))
+        List<ChannelTombstone> remaining = new ArrayList<>();
+        for (ChannelTombstone tombstone : state.tombstones())
         {
-            ChannelTombstone tombstone = entry.getValue();
             appendDiscoveredChannel(response, tombstone.deviceId(), tombstone.ip(), tombstone.channelId(), false);
-            if (tombstone.remainingDiscoveries() <= 1) tombstones.remove(entry.getKey());
-            else tombstones.put(entry.getKey(), new ChannelTombstone(tombstone.deviceId(), tombstone.ip(),
-                    tombstone.channelId(), tombstone.remainingDiscoveries() - 1));
+            if (tombstone.remainingDiscoveries() > 1)
+                remaining.add(new ChannelTombstone(tombstone.deviceId(), tombstone.ip(), tombstone.channelId(),
+                        tombstone.remainingDiscoveries() - 1));
         }
+        if (!remaining.equals(state.tombstones()))
+            persist(new SimulatorState(state.version(), state.devices(), state.collectors(), state.nextDeviceId(),
+                    state.nextCollectorId(), remaining));
         return response.append("</middle></bts>").toString();
     }
 
@@ -195,7 +289,7 @@
     private synchronized String observationResponse()
     {
         List<ObservedChannel> available = new ArrayList<>();
-        for (DeviceState device : devices.values())
+        for (DeviceState device : state.devices())
             for (ChannelState channel : device.channels())
                 if (device.online() && channel.online()) available.add(new ObservedChannel(device.deviceId(), channel));
 
@@ -231,10 +325,23 @@
         if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator device IP must be a valid IPv4 address");
         if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT)
             throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT);
-        boolean duplicate = devices.values().stream()
+        boolean duplicate = state.devices().stream()
                 .anyMatch(device -> device.ip().equals(ip.trim())
                         && device.deviceId() != (currentDeviceId == null ? -1 : currentDeviceId));
+        duplicate = duplicate || state.collectors().stream().anyMatch(collector -> collector.ip().equals(ip.trim()));
         if (duplicate) throw new IllegalArgumentException("Simulator device IP must be unique");
+    }
+
+    private void validateCollectorConfiguration(String ip, int channelCount, Integer currentCollectorId)
+    {
+        if (!isIpv4(ip)) throw new IllegalArgumentException("Simulator collector IP must be a valid IPv4 address");
+        if (channelCount < 1 || channelCount > MAX_CHANNEL_COUNT)
+            throw new IllegalArgumentException("channelCount must be between 1 and " + MAX_CHANNEL_COUNT);
+        boolean duplicate = state.collectors().stream()
+                .anyMatch(collector -> collector.ip().equals(ip.trim())
+                        && collector.collectorId() != (currentCollectorId == null ? -1 : currentCollectorId));
+        duplicate = duplicate || state.devices().stream().anyMatch(device -> device.ip().equals(ip.trim()));
+        if (duplicate) throw new IllegalArgumentException("Simulator collector IP must be unique");
     }
 
     private boolean isIpv4(String ip)
@@ -259,26 +366,45 @@
 
     private DeviceState requireDevice(int deviceId)
     {
-        DeviceState device = devices.get(deviceId);
-        if (device == null) throw new IllegalArgumentException("Unknown simulator device: " + deviceId);
-        return device;
+        return state.devices().stream().filter(item -> item.deviceId() == deviceId).findFirst()
+                .orElseThrow(() -> new IllegalArgumentException("Unknown simulator device: " + deviceId));
     }
 
-    private void addTombstone(int deviceId, String ip, int channelId)
+    private CollectorState requireCollector(int collectorId)
     {
-        tombstones.put(tombstoneKey(deviceId, channelId),
-                new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT));
+        return state.collectors().stream().filter(item -> item.collectorId() == collectorId).findFirst()
+                .orElseThrow(() -> new IllegalArgumentException("Unknown simulator collector: " + collectorId));
     }
 
-    private String tombstoneKey(int deviceId, int channelId)
+    private List<DeviceState> replaceDevice(DeviceState replacement)
     {
-        return deviceId + ":" + channelId;
+        List<DeviceState> devices = new ArrayList<>(state.devices());
+        devices.replaceAll(item -> item.deviceId() == replacement.deviceId() ? replacement : item);
+        return devices;
+    }
+
+    private void upsertTombstone(List<ChannelTombstone> tombstones, int deviceId, String ip, int channelId)
+    {
+        tombstones.removeIf(item -> item.deviceId() == deviceId && item.channelId() == channelId);
+        tombstones.add(new ChannelTombstone(deviceId, ip, channelId, TOMBSTONE_DISCOVERY_COUNT));
+    }
+
+    private void persist(SimulatorState nextState)
+    {
+        // 写穿失败时保留当前内存副本,避免控制页显示未持久化状态。
+        stateStore.save(nextState);
+        state = nextState;
     }
 
     private String simulatorMac(int devtype, int devid)
     {
         return String.format("02:44:%02X:%02X:%02X:%02X",
                 (devtype >>> 8) & 0xFF, devtype & 0xFF, (devid >>> 8) & 0xFF, devid & 0xFF);
+    }
+
+    private String simulatorCollectorMac(int collectorId)
+    {
+        return String.format("02:43:00:00:%02X:%02X", (collectorId >>> 8) & 0xFF, collectorId & 0xFF);
     }
 
     private void requireControllableStatus(DeviceWorkStatus status)
@@ -288,6 +414,46 @@
             throw new IllegalArgumentException("Simulator work status must be STOPPED, WORKING, PAUSED or FINISHED");
     }
 
+    private void validateRestoredState()
+    {
+        java.util.Set<Integer> deviceIds = new java.util.HashSet<>();
+        java.util.Set<Integer> collectorIds = new java.util.HashSet<>();
+        java.util.Set<String> ips = new java.util.HashSet<>();
+        int maximumDeviceId = 0;
+        for (DeviceState device : state.devices())
+        {
+            if (device.deviceId() < 1 || !deviceIds.add(device.deviceId()) || !isIpv4(device.ip())
+                    || !ips.add(device.ip()) || device.channels().isEmpty()
+                    || device.channels().size() > MAX_CHANNEL_COUNT)
+                throw new IllegalStateException("Invalid BTS simulator device state in Redis");
+            maximumDeviceId = Math.max(maximumDeviceId, device.deviceId());
+            for (int index = 0; index < device.channels().size(); index++)
+            {
+                ChannelState channel = device.channels().get(index);
+                if (channel.channelId() != index + 1)
+                    throw new IllegalStateException("Invalid BTS simulator channel state in Redis");
+                requireControllableStatus(channel.workStatus());
+            }
+        }
+        int maximumCollectorId = 0;
+        for (CollectorState collector : state.collectors())
+        {
+            if (collector.collectorId() < 1 || !collectorIds.add(collector.collectorId())
+                    || !isIpv4(collector.ip()) || !ips.add(collector.ip())
+                    || collector.channelCount() < 1 || collector.channelCount() > MAX_CHANNEL_COUNT)
+                throw new IllegalStateException("Invalid BTS simulator collector state in Redis");
+            maximumCollectorId = Math.max(maximumCollectorId, collector.collectorId());
+        }
+        java.util.Set<String> tombstoneIds = new java.util.HashSet<>();
+        boolean invalidTombstone = state.tombstones().stream().anyMatch(item -> item.deviceId() < 1
+                || !isIpv4(item.ip()) || item.channelId() < 1 || item.channelId() > MAX_CHANNEL_COUNT
+                || item.remainingDiscoveries() < 1 || item.remainingDiscoveries() > TOMBSTONE_DISCOVERY_COUNT
+                || !tombstoneIds.add(item.deviceId() + ":" + item.channelId()));
+        if (state.nextDeviceId() <= maximumDeviceId || state.nextCollectorId() <= maximumCollectorId
+                || invalidTombstone)
+            throw new IllegalStateException("Invalid BTS simulator numbering or tombstone state in Redis");
+    }
+
     @Override
     public synchronized void close() throws Exception
     {
@@ -295,20 +461,16 @@
         simulator.close();
     }
 
-    private record DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels) { }
-
-    private record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { }
-
     private record ObservedChannel(int deviceId, ChannelState channel) { }
 
-    private record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { }
-
     public record StateSnapshot(boolean running, String listenAddress, List<DeviceSnapshot> devices,
-            List<RecentRequest> recentRequests) { }
+            List<CollectorSnapshot> collectors, List<RecentRequest> recentRequests) { }
 
     public record DeviceSnapshot(int deviceId, String ip, boolean online, List<ChannelSnapshot> channels) { }
 
     public record ChannelSnapshot(int channelId, boolean online, DeviceWorkStatus workStatus) { }
 
-    public record RecentRequest(Instant receivedAt, String command, String request) { }
+    public record CollectorSnapshot(int collectorId, String deviceCode, String ip, String mac, String name,
+            String manufacturer, String model, int channelCount, boolean online) { }
+
 }

--
Gitblit v1.7.1