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/test/java/com/ruoyi/device/contract/DiscoveryCandidateStoreContractTest.java        |   51 +
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TCollectorMapper.java                             |    3 
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java               |   39 
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java               |   32 
 ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml                               |   51 -
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java                         |    8 
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TCollectorServiceImplTest.java               |   52 
 ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorDiscoveryService.java                 |   39 
 ruoyi-device/src/main/java/com/ruoyi/device/discovery/InMemoryDeviceDiscoveryCandidateStore.java     |   41 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TCollectorServiceImpl.java                  |   59 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITCollectorService.java                          |    4 
 ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java                                  |  155 +++
 ruoyi-admin/src/main/java/com/ruoyi/config/RedisDeviceDiscoveryCandidateStore.java                   |  176 +++
 ruoyi-admin/src/main/resources/db/migration/V8__complete_collector_page_contract.sql                 |    6 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java     |   65 +
 ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java                         |  266 ++++-
 ruoyi-device/src/main/java/com/ruoyi/device/discovery/DeviceDiscoveryCandidateStore.java             |   59 +
 ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java                      |    2 
 ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java                   |    4 
 ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java                       |   20 
 ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java                           |   34 
 ruoyi-admin/src/main/resources/static/dev/bts-simulator.html                                         |  107 ++
 ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java                          |   86 +
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java               |    2 
 ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java           |    4 
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java            |   59 
 ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java                    |   53 +
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java        |  137 +-
 ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql               |   82 +
 ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java                |  106 ++
 ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java           |   30 
 ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql             |    7 
 ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java                          |   24 
 .gitignore                                                                                           |    3 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java                            |    8 
 ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java |   79 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java      |  149 +-
 ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java                            |    3 
 ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql            |    9 
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCollectorVo.java                     |   60 +
 ruoyi-system/src/main/resources/mapper/system/TCollectorMapper.xml                                   |    6 
 ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImplTest.java       |   79 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java           |  127 +
 ruoyi-device/src/main/java/com/ruoyi/device/simulator/InMemoryBtsSimulatorStateStore.java            |   51 +
 ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java                       |    4 
 ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java                      |   14 
 /dev/null                                                                                            |   34 
 ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java               |   67 
 ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml                            |  155 +--
 ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java                    |    2 
 50 files changed, 2,146 insertions(+), 567 deletions(-)

diff --git a/.gitignore b/.gitignore
index 5bf40c2..f18146e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,7 @@
 /ruoyi-admin/src/main/resources/db/migration/*
 !/ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql
 !/ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql
+!/ruoyi-admin/src/main/resources/db/migration/V8__complete_collector_page_contract.sql
+!/ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql
 /.mvn-review-repository/
-/ruoyi-admin/src/main/resources/db/migration/
 /ruoyi-admin/logs/
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorDiscoveryService.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorDiscoveryService.java
new file mode 100644
index 0000000..01aab68
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorDiscoveryService.java
@@ -0,0 +1,39 @@
+package com.ruoyi.config;
+
+import java.util.List;
+import java.util.Set;
+import java.time.Instant;
+import org.springframework.stereotype.Service;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
+import com.ruoyi.system.domain.vo.DiscoveredCollectorVo;
+import com.ruoyi.system.service.ICollectorDiscoveryService;
+import com.ruoyi.system.service.ITCollectorService;
+
+/** 在应用装配边界将临时模拟采集器暴露给现有采集器页面。 */
+@Service
+public class DevelopmentCollectorDiscoveryService implements ICollectorDiscoveryService
+{
+    private final DeviceDiscoveryCandidateStore candidateStore;
+    private final ITCollectorService collectorService;
+
+    public DevelopmentCollectorDiscoveryService(DeviceDiscoveryCandidateStore candidateStore,
+            ITCollectorService collectorService)
+    {
+        this.candidateStore = candidateStore;
+        this.collectorService = collectorService;
+    }
+
+    /** 返回在线且未登记的独立采集器快照,不混入 BTS 检测柜设备。 */
+    @Override
+    public List<DiscoveredCollectorVo> selectDiscoveredCollectorList()
+    {
+        Set<String> registeredCodes = collectorService.selectRegisteredCollectorCodes();
+        return candidateStore.snapshot(Instant.now()).collectors().stream()
+                .filter(collector -> collector.online()
+                        && !registeredCodes.contains(collector.deviceCode()))
+                .map(collector -> new DiscoveredCollectorVo(collector.deviceCode(), collector.ip(), collector.mac(),
+                        collector.name(), collector.manufacturer(), collector.model(), collector.channelCount(),
+                        "在线", "空闲", "-"))
+                .toList();
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java
new file mode 100644
index 0000000..395f1fc
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java
@@ -0,0 +1,39 @@
+package com.ruoyi.config;
+
+import java.time.Duration;
+import java.time.Instant;
+import java.util.List;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
+
+/** 将模拟采集器的发现和实时状态写入 Redis,不回写正式 MySQL 记录。 */
+@Component
+public class DevelopmentCollectorStatusSynchronizer
+{
+    private final BtsDevelopmentSimulator simulator;
+    private final DeviceDiscoveryCandidateStore candidateStore;
+
+    public DevelopmentCollectorStatusSynchronizer(BtsDevelopmentSimulator simulator,
+            DeviceDiscoveryCandidateStore candidateStore)
+    {
+        this.simulator = simulator;
+        this.candidateStore = candidateStore;
+    }
+
+    /** 完整替换 dev-sim 采集器快照,删除配置后候选立即消失。 */
+    @Scheduled(fixedDelay = BtsDevelopmentSimulator.POLL_INTERVAL_MS)
+    public synchronized void synchronizeOnlineStatus()
+    {
+        Instant observedAt = Instant.now();
+        List<CollectorCandidate> candidates = simulator.snapshot().collectors().stream()
+                .map(item -> new CollectorCandidate(BtsDevelopmentSimulator.ADAPTER_CODE, item.deviceCode(),
+                        item.ip(), item.mac(), item.name(),
+                        item.manufacturer(), item.model(), item.channelCount(), item.online(), observedAt,
+                        observedAt.plus(Duration.ofSeconds(30))))
+                .toList();
+        candidateStore.replaceCollectors(BtsDevelopmentSimulator.ADAPTER_CODE, candidates);
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
new file mode 100644
index 0000000..3695d5c
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
@@ -0,0 +1,32 @@
+package com.ruoyi.config;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.registry.DeviceAdapterRegistry;
+import com.ruoyi.system.service.impl.DeviceDiscoveryPersistenceService;
+import com.ruoyi.system.service.impl.DeviceDiscoveryPersistenceService.DiscoverySource;
+
+/** 轮询代码固定装配的 dev-sim;该运行时不依赖数据库适配器记录。 */
+@Component
+public class DevelopmentSimulatorDiscoveryScheduler
+{
+    private final DeviceAdapterRegistry registry;
+    private final DeviceDiscoveryPersistenceService persistence;
+
+    public DevelopmentSimulatorDiscoveryScheduler(DeviceAdapterRegistry registry,
+            DeviceDiscoveryPersistenceService persistence)
+    {
+        this.registry = registry;
+        this.persistence = persistence;
+    }
+
+    /** 注册表已包含固定开发适配器时,将一轮检测柜观测写入 Redis 候选。 */
+    @Scheduled(fixedDelay = BtsDevelopmentSimulator.POLL_INTERVAL_MS)
+    public void discover()
+    {
+        if (!registry.contains(BtsDevelopmentSimulator.ADAPTER_CODE)) return;
+        persistence.discover(registry, new DiscoverySource(BtsDevelopmentSimulator.ADAPTER_CODE, null,
+                BtsDevelopmentSimulator.ADAPTER_NAME, BtsDevelopmentSimulator.POLL_INTERVAL_MS));
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
index a86366d..621bda0 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java
@@ -8,9 +8,10 @@
 import org.springframework.scheduling.annotation.EnableScheduling;
 import com.ruoyi.device.bts.BtsDeviceAdapterFactory;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.model.AdapterCapabilities;
+import com.ruoyi.device.registry.AdapterInstanceConfig;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
-import com.ruoyi.system.domain.TDeviceAdapter;
-import com.ruoyi.system.service.IDeviceAdapterService;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore;
 
 /** 装配单进程设备适配器运行时。 */
 @Configuration
@@ -24,32 +25,23 @@
     }
 
     @Bean(destroyMethod = "close")
-    public BtsDevelopmentSimulator btsDevelopmentSimulator() throws IOException
+    public BtsDevelopmentSimulator btsDevelopmentSimulator(BtsSimulatorStateStore stateStore) throws IOException
     {
-        return new BtsDevelopmentSimulator(BtsDevelopmentSimulator.DEFAULT_PORT);
+        return new BtsDevelopmentSimulator(BtsDevelopmentSimulator.DEFAULT_PORT, stateStore);
     }
 
     @Bean
-    public ApplicationRunner registerDevelopmentSimulatorAdapter(IDeviceAdapterService service)
+    public ApplicationRunner registerDevelopmentSimulatorAdapter(DeviceAdapterRegistry registry)
     {
-        // ApplicationRunner 先于 ApplicationReadyEvent,确保恢复启用适配器时 dev-sim 已存在。
-        return arguments -> service.ensureManagedAdapter(developmentSimulatorAdapter());
+        // 固定开发装配不进入 t_device_adapter,直接注册运行时实例。
+        return arguments -> registry.synchronize(developmentSimulatorRuntimeConfig());
     }
 
-    private TDeviceAdapter developmentSimulatorAdapter()
+    private AdapterInstanceConfig developmentSimulatorRuntimeConfig()
     {
-        TDeviceAdapter adapter = new TDeviceAdapter();
-        adapter.setAdapterCode("dev-sim");
-        adapter.setAdapterName("dev-sim BTS 模拟器");
-        adapter.setAdapterType("BTS");
-        adapter.setHost(BtsDevelopmentSimulator.HOST);
-        adapter.setPort(BtsDevelopmentSimulator.DEFAULT_PORT);
-        adapter.setPollIntervalMs(5000);
-        adapter.setTimezoneId("Asia/Hong_Kong");
-        adapter.setReadOnly(true);
-        adapter.setCapabilities("{\"discovery\":true,\"realtimeObservation\":true,"
-                + "\"historyDownload\":false,\"control\":false}");
-        adapter.setRemark("临时人工验收设备");
-        return adapter;
+        return new AdapterInstanceConfig(BtsDevelopmentSimulator.ADAPTER_CODE, "BTS", BtsDevelopmentSimulator.HOST,
+                BtsDevelopmentSimulator.DEFAULT_PORT, BtsDevelopmentSimulator.POLL_INTERVAL_MS,
+                "Asia/Hong_Kong", true,
+                new AdapterCapabilities(true, true, false, false));
     }
 }
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
new file mode 100644
index 0000000..fb14c00
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
@@ -0,0 +1,86 @@
+package com.ruoyi.config;
+
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore;
+
+/** 将开发模拟器完整状态写入 Redis,主状态不设置普通 TTL。 */
+@Component
+public class RedisBtsSimulatorStateStore implements BtsSimulatorStateStore
+{
+    static final String STATE_KEY = "dev:bts-simulator:state:v1";
+    static final String RECENT_REQUESTS_KEY = "dev:bts-simulator:recent-requests:v1";
+    private static final long RECENT_REQUESTS_TTL_HOURS = 1;
+
+    private final RedisTemplate<Object, Object> redisTemplate;
+
+    public RedisBtsSimulatorStateStore(RedisTemplate<Object, Object> redisTemplate)
+    {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Override
+    public SimulatorState loadOrInitialize(SimulatorState defaultState)
+    {
+        Object stored = redisTemplate.opsForValue().get(STATE_KEY);
+        if (stored != null) return parseState(stored);
+        String initial = JSON.toJSONString(defaultState);
+        if (Boolean.TRUE.equals(redisTemplate.opsForValue().setIfAbsent(STATE_KEY, initial))) return defaultState;
+        stored = redisTemplate.opsForValue().get(STATE_KEY);
+        if (stored == null) throw new IllegalStateException("BTS simulator state initialization lost in Redis");
+        return parseState(stored);
+    }
+
+    @Override
+    public void save(SimulatorState state)
+    {
+        redisTemplate.opsForValue().set(STATE_KEY, JSON.toJSONString(state));
+    }
+
+    @Override
+    public List<RecentRequest> loadRecentRequests()
+    {
+        Object stored = redisTemplate.opsForValue().get(RECENT_REQUESTS_KEY);
+        if (stored == null) return List.of();
+        return List.copyOf(JSON.parseArray(stored.toString(), RecentRequest.class));
+    }
+
+    @Override
+    public void saveRecentRequests(List<RecentRequest> requests)
+    {
+        redisTemplate.opsForValue().set(RECENT_REQUESTS_KEY, JSON.toJSONString(requests),
+                RECENT_REQUESTS_TTL_HOURS, TimeUnit.HOURS);
+    }
+
+    @Override
+    public void reset(SimulatorState defaultState)
+    {
+        redisTemplate.delete(RECENT_REQUESTS_KEY);
+        save(defaultState);
+    }
+
+    private SimulatorState parseState(Object stored)
+    {
+        try
+        {
+            String json = stored.toString();
+            JSONObject root = JSON.parseObject(json);
+            Integer version = root.getInteger("version");
+            if (version == null || version != CURRENT_VERSION)
+                throw new IllegalStateException("Unsupported BTS simulator state version: " + version);
+            return JSON.parseObject(json, SimulatorState.class);
+        }
+        catch (IllegalStateException exception)
+        {
+            throw exception;
+        }
+        catch (Exception exception)
+        {
+            throw new IllegalStateException("Invalid BTS simulator state in Redis", exception);
+        }
+    }
+}
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/config/RedisDeviceDiscoveryCandidateStore.java b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisDeviceDiscoveryCandidateStore.java
new file mode 100644
index 0000000..2ae2407
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/ruoyi/config/RedisDeviceDiscoveryCandidateStore.java
@@ -0,0 +1,176 @@
+package com.ruoyi.config;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.stereotype.Component;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONArray;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DeviceWorkStatus;
+
+/** 保存发现候选和实时状态;条目按 expiresAt 逻辑过期,Redis key 本身不设短 TTL。 */
+@Component
+public class RedisDeviceDiscoveryCandidateStore implements DeviceDiscoveryCandidateStore
+{
+    static final String STATE_KEY = "dev:device-discovery:candidates:v1";
+    private static final int CURRENT_VERSION = 1;
+    private final RedisTemplate<Object, Object> redisTemplate;
+
+    public RedisDeviceDiscoveryCandidateStore(RedisTemplate<Object, Object> redisTemplate)
+    {
+        this.redisTemplate = redisTemplate;
+    }
+
+    @Override
+    public synchronized void recordCabinets(List<CabinetCandidate> candidates)
+    {
+        StoredState current = load();
+        Map<String, CabinetCandidate> merged = new LinkedHashMap<>();
+        current.cabinets().forEach(item -> merged.put(item.deviceCode(), item));
+        candidates.forEach(item -> merged.put(item.deviceCode(), item));
+        save(new StoredState(CURRENT_VERSION, List.copyOf(merged.values()), current.collectors()));
+    }
+
+    @Override
+    public synchronized void replaceCollectors(String adapterCode, List<CollectorCandidate> candidates)
+    {
+        StoredState current = load();
+        List<CollectorCandidate> retained = current.collectors().stream()
+                .filter(item -> !item.adapterCode().equals(adapterCode)).toList();
+        java.util.ArrayList<CollectorCandidate> merged = new java.util.ArrayList<>(retained);
+        merged.addAll(candidates);
+        save(new StoredState(CURRENT_VERSION, current.cabinets(), merged));
+    }
+
+    @Override
+    public synchronized void removeAdapter(String adapterCode)
+    {
+        StoredState current = load();
+        List<CabinetCandidate> cabinets = current.cabinets().stream()
+                .filter(item -> !item.adapterCode().equals(adapterCode)).toList();
+        List<CollectorCandidate> collectors = current.collectors().stream()
+                .filter(item -> !item.adapterCode().equals(adapterCode)).toList();
+        save(new StoredState(CURRENT_VERSION, cabinets, collectors));
+    }
+
+    @Override
+    public synchronized CandidateSnapshot snapshot(Instant now)
+    {
+        StoredState current = load();
+        List<CabinetCandidate> cabinets = current.cabinets().stream()
+                .filter(item -> item.expiresAt().isAfter(now)).toList();
+        List<CollectorCandidate> collectors = current.collectors().stream()
+                .filter(item -> item.expiresAt().isAfter(now)).toList();
+        if (cabinets.size() != current.cabinets().size() || collectors.size() != current.collectors().size())
+            save(new StoredState(CURRENT_VERSION, cabinets, collectors));
+        return new CandidateSnapshot(cabinets, collectors);
+    }
+
+    private StoredState load()
+    {
+        Object stored = redisTemplate.opsForValue().get(STATE_KEY);
+        if (stored == null) return new StoredState(CURRENT_VERSION, List.of(), List.of());
+        try
+        {
+            // 通用 RedisTemplate 可能把泛型数组恢复为 JSONObject;在存储边界逐字段重建,禁止业务层强转。
+            JSONObject root = jsonObject(stored);
+            Integer version = root.getInteger("version");
+            if (version == null || version != CURRENT_VERSION)
+                throw new IllegalStateException("Unsupported device discovery state version: " + version);
+            List<CabinetCandidate> cabinets = new ArrayList<>();
+            requiredArray(root, "cabinets").forEach(item -> cabinets.add(cabinetCandidate(item)));
+            List<CollectorCandidate> collectors = new ArrayList<>();
+            requiredArray(root, "collectors").forEach(item -> collectors.add(collectorCandidate(item)));
+            return new StoredState(CURRENT_VERSION, cabinets, collectors);
+        }
+        catch (IllegalStateException exception)
+        {
+            throw exception;
+        }
+        catch (Exception exception)
+        {
+            throw new IllegalStateException("Invalid device discovery state in Redis", exception);
+        }
+    }
+
+    private JSONObject jsonObject(Object value)
+    {
+        if (value instanceof JSONObject object) return object;
+        if (value instanceof String text)
+        {
+            Object parsed = JSON.parse(text);
+            if (parsed instanceof String nested) parsed = JSON.parse(nested);
+            if (parsed instanceof JSONObject object) return object;
+            return JSON.parseObject(JSON.toJSONString(parsed));
+        }
+        return JSON.parseObject(JSON.toJSONString(value));
+    }
+
+    private JSONArray requiredArray(JSONObject object, String field)
+    {
+        JSONArray array = object.getJSONArray(field);
+        if (array == null) throw new IllegalStateException("Missing device discovery state field: " + field);
+        return array;
+    }
+
+    private CabinetCandidate cabinetCandidate(Object value)
+    {
+        JSONObject object = jsonObject(value);
+        List<ChannelCandidate> channels = new ArrayList<>();
+        requiredArray(object, "channels").forEach(item -> channels.add(channelCandidate(item)));
+        return new CabinetCandidate(object.getString("adapterCode"), object.getLong("adapterId"),
+                object.getString("deviceCode"), object.getIntValue("devtype"), object.getIntValue("devid"),
+                object.getString("ip"), object.getString("mac"), object.getString("name"),
+                object.getString("manufacturer"), object.getString("model"), channels,
+                requiredInstant(object, "observedAt"), requiredInstant(object, "expiresAt"));
+    }
+
+    private ChannelCandidate channelCandidate(Object value)
+    {
+        JSONObject object = jsonObject(value);
+        JSONObject identity = jsonObject(object.get("identity"));
+        ChannelIdentity channelIdentity = new ChannelIdentity(identity.getString("adapterCode"),
+                identity.getIntValue("devtype"), identity.getIntValue("devid"),
+                identity.getIntValue("subdevid"), identity.getIntValue("chlid"));
+        return new ChannelCandidate(channelIdentity, object.getBooleanValue("online"),
+                DeviceWorkStatus.valueOf(object.getString("workStatus")), requiredInstant(object, "observedAt"));
+    }
+
+    private CollectorCandidate collectorCandidate(Object value)
+    {
+        JSONObject object = jsonObject(value);
+        return new CollectorCandidate(object.getString("adapterCode"), object.getString("deviceCode"),
+                object.getString("ip"), object.getString("mac"), object.getString("name"),
+                object.getString("manufacturer"), object.getString("model"), object.getIntValue("channelCount"),
+                object.getBooleanValue("online"), requiredInstant(object, "observedAt"),
+                requiredInstant(object, "expiresAt"));
+    }
+
+    private Instant requiredInstant(JSONObject object, String field)
+    {
+        Instant value = object.getObject(field, Instant.class);
+        if (value == null) throw new IllegalStateException("Missing device discovery state field: " + field);
+        return value;
+    }
+
+    private void save(StoredState state)
+    {
+        redisTemplate.opsForValue().set(STATE_KEY, JSON.toJSONString(state));
+    }
+
+    private record StoredState(int version, List<CabinetCandidate> cabinets,
+            List<CollectorCandidate> collectors)
+    {
+        private StoredState
+        {
+            cabinets = List.copyOf(cabinets);
+            collectors = List.copyOf(collectors);
+        }
+    }
+}
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 0842a00..30fc2a7 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
@@ -15,6 +15,7 @@
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator.StateSnapshot;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
 import com.ruoyi.device.model.DeviceWorkStatus;
 
 /** 本机临时 BTS 模拟器控制 API。 */
@@ -23,10 +24,13 @@
 public class BtsDevelopmentSimulatorController
 {
     private final BtsDevelopmentSimulator simulator;
+    private final DeviceDiscoveryCandidateStore candidateStore;
 
-    public BtsDevelopmentSimulatorController(BtsDevelopmentSimulator simulator)
+    public BtsDevelopmentSimulatorController(BtsDevelopmentSimulator simulator,
+            DeviceDiscoveryCandidateStore candidateStore)
     {
         this.simulator = simulator;
+        this.candidateStore = candidateStore;
     }
 
     /** 返回临时控制页;匿名放行后仍由本机拦截器限制来源。 */
@@ -40,6 +44,15 @@
     @GetMapping("/dev/bts-simulator/state")
     public StateSnapshot state()
     {
+        return simulator.snapshot();
+    }
+
+    /** 人工清空长期状态并恢复一台默认检测柜和采集器。 */
+    @PostMapping("/dev/bts-simulator/reset")
+    public StateSnapshot reset()
+    {
+        simulator.reset();
+        candidateStore.removeAdapter(BtsDevelopmentSimulator.ADAPTER_CODE);
         return simulator.snapshot();
     }
 
@@ -88,6 +101,52 @@
         }
     }
 
+    /** 添加一台与检测柜分开配置的虚拟采集器。 */
+    @PostMapping("/dev/bts-simulator/collectors")
+    public StateSnapshot createCollector(@RequestBody CreateCollectorCommand command)
+    {
+        try
+        {
+            simulator.addCollector(command.ip(), command.channelCount());
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception)
+        {
+            throw badRequest(exception);
+        }
+    }
+
+    /** 修改虚拟采集器的地址、通道数和在线状态。 */
+    @PutMapping("/dev/bts-simulator/collectors/{collectorId}")
+    public StateSnapshot updateCollector(@PathVariable int collectorId,
+            @RequestBody CollectorStateCommand command)
+    {
+        try
+        {
+            simulator.updateCollector(collectorId, command.ip(), command.channelCount(), command.online());
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception)
+        {
+            throw badRequest(exception);
+        }
+    }
+
+    /** 删除指定虚拟采集器。 */
+    @DeleteMapping("/dev/bts-simulator/collectors/{collectorId}")
+    public StateSnapshot deleteCollector(@PathVariable int collectorId)
+    {
+        try
+        {
+            simulator.deleteCollector(collectorId);
+            return simulator.snapshot();
+        }
+        catch (IllegalArgumentException exception)
+        {
+            throw badRequest(exception);
+        }
+    }
+
     /** 修改指定虚拟设备通道的在线和工作状态。 */
     @PutMapping("/dev/bts-simulator/devices/{deviceId}/channels/{channelId}")
     public StateSnapshot updateChannel(@PathVariable int deviceId, @PathVariable int channelId,
@@ -114,4 +173,8 @@
     public record DeviceStateCommand(String ip, int channelCount, boolean online) { }
 
     public record ChannelStateCommand(boolean online, DeviceWorkStatus workStatus) { }
+
+    public record CreateCollectorCommand(String ip, int channelCount) { }
+
+    public record CollectorStateCommand(String ip, int channelCount, boolean online) { }
 }
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java
index b13e2df..15d4f26 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java
@@ -80,7 +80,7 @@
 
     @PreAuthorize("@ss.hasPermi('system:deviceAdapter:list')")
     @GetMapping("/{adapterId}/channels")
-    /** 查询适配器已持久化的通道事实,不因本轮缺失而删除。 */
+    /** 查询适配器当前未过期的 Redis 发现通道。 */
     public AjaxResult channels(@PathVariable Long adapterId) { return success(service.selectChannels(adapterId)); }
 
     public record StatusCommand(boolean enabled) { }
diff --git a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
index 019182d..830304b 100644
--- a/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
+++ b/ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java
@@ -61,7 +61,7 @@
     }
 
     /**
-     * 保留原设备选择弹窗使用的分页接口,数据来自已持久化检测柜。
+     * 保留原设备选择弹窗使用的分页接口,数据来自 Redis 发现候选。
      *
      * @return 可供选择的检测柜分页数据
      */
@@ -144,7 +144,7 @@
     }
 
     /**
-     * 批量逻辑删除检测柜,测试中的检测柜不能删除。
+     * 批量取消检测柜登记,测试中的检测柜不能操作。
      *
      * @param cabinetIds 检测柜主键数组
      * @return 操作结果
diff --git a/ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql b/ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql
new file mode 100644
index 0000000..4d2dbc9
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql
@@ -0,0 +1,7 @@
+-- 设备发现只形成候选事实;操作员保存后才进入正式检测柜列表。
+ALTER TABLE t_detection_cabinet
+    ADD COLUMN registration_status VARCHAR(16) NOT NULL DEFAULT 'DISCOVERED'
+        COMMENT 'DISCOVERED发现候选,REGISTERED已登记' AFTER last_seen_at_utc,
+    ADD CONSTRAINT chk_cabinet_registration_status
+        CHECK (registration_status IN ('DISCOVERED', 'REGISTERED')),
+    ADD KEY idx_cabinet_registration (registration_status, del_flag, last_seen_at_utc);
diff --git a/ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql b/ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql
new file mode 100644
index 0000000..8870b4c
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql
@@ -0,0 +1,9 @@
+-- V6 已在开发库执行;后续修正通过增量迁移完成,不修改既有迁移校验和。
+UPDATE t_detection_cabinet
+SET registration_status = CASE
+    WHEN create_by = 'system' THEN 'DISCOVERED'
+    ELSE 'REGISTERED'
+END;
+
+ALTER TABLE t_detection_cabinet
+    ADD UNIQUE KEY uk_cabinet_mac_del (mac_address, del_flag);
diff --git a/ruoyi-admin/src/main/resources/db/migration/V8__complete_collector_page_contract.sql b/ruoyi-admin/src/main/resources/db/migration/V8__complete_collector_page_contract.sql
new file mode 100644
index 0000000..a3592ed
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V8__complete_collector_page_contract.sql
@@ -0,0 +1,6 @@
+-- 补齐现有采集器管理页使用的运行展示字段;模拟器发现本身不写入正式采集器表。
+ALTER TABLE t_collector
+    ADD COLUMN test_status CHAR(1) NOT NULL DEFAULT '0' COMMENT '0待检测,1测试中' AFTER online_status,
+    ADD COLUMN current_test VARCHAR(100) NULL COMMENT '当前测试展示名称' AFTER test_status,
+    ADD COLUMN current_sample_name VARCHAR(100) NULL COMMENT '当前样品展示名称' AFTER current_test,
+    ADD CONSTRAINT chk_collector_test_status CHECK (test_status IN ('0', '1'));
diff --git a/ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql b/ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql
new file mode 100644
index 0000000..85b4745
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql
@@ -0,0 +1,82 @@
+-- 正式拓扑保存稳定适配器编码;代码固定装配的 dev-sim 不再需要数据库适配器主键。
+ALTER TABLE t_detection_cabinet
+    ADD COLUMN adapter_code VARCHAR(64) NULL AFTER adapter_id;
+
+ALTER TABLE t_detection_channel
+    ADD COLUMN adapter_code VARCHAR(64) NULL AFTER adapter_id;
+
+UPDATE t_detection_cabinet cabinet
+JOIN t_device_adapter adapter ON adapter.adapter_id = cabinet.adapter_id
+SET cabinet.adapter_code = adapter.adapter_code;
+
+UPDATE t_detection_channel channel_fact
+JOIN t_device_adapter adapter ON adapter.adapter_id = channel_fact.adapter_id
+SET channel_fact.adapter_code = adapter.adapter_code;
+
+-- 先移除没有测试、采样、快照、异常或运行事件引用的旧开发候选通道。
+DELETE channel_fact
+FROM t_detection_channel channel_fact
+JOIN t_detection_cabinet cabinet ON cabinet.cabinet_id = channel_fact.cabinet_id
+JOIN t_device_adapter adapter ON adapter.adapter_id = cabinet.adapter_id
+WHERE adapter.adapter_code = 'dev-sim'
+  AND cabinet.registration_status = 'DISCOVERED'
+  -- MySQL 禁止 DELETE 的子查询直接读取目标表,双层派生表强制先物化受保护柜体。
+  AND cabinet.cabinet_id NOT IN (
+      SELECT protected_cabinet.cabinet_id
+      FROM (
+          SELECT DISTINCT sibling.cabinet_id
+          FROM t_detection_channel sibling
+          WHERE
+          EXISTS (SELECT 1 FROM t_test_channel_binding binding WHERE binding.channel_id = sibling.channel_id)
+          OR EXISTS (SELECT 1 FROM t_channel_lease lease_fact WHERE lease_fact.channel_id = sibling.channel_id)
+          OR EXISTS (SELECT 1 FROM t_channel_snapshot snapshot_fact WHERE snapshot_fact.channel_id = sibling.channel_id)
+          OR EXISTS (SELECT 1 FROM t_test_sample sample_fact WHERE sample_fact.channel_id = sibling.channel_id)
+          OR EXISTS (SELECT 1 FROM t_device_run_event run_event WHERE run_event.channel_id = sibling.channel_id)
+          OR EXISTS (SELECT 1 FROM t_test_exception exception_fact WHERE exception_fact.channel_id = sibling.channel_id)
+      ) protected_cabinet
+  )
+  AND NOT EXISTS (SELECT 1 FROM t_test_channel_binding binding WHERE binding.channel_id = channel_fact.channel_id)
+  AND NOT EXISTS (SELECT 1 FROM t_channel_lease lease_fact WHERE lease_fact.channel_id = channel_fact.channel_id)
+  AND NOT EXISTS (SELECT 1 FROM t_channel_snapshot snapshot_fact WHERE snapshot_fact.channel_id = channel_fact.channel_id)
+  AND NOT EXISTS (SELECT 1 FROM t_test_sample sample_fact WHERE sample_fact.channel_id = channel_fact.channel_id)
+  AND NOT EXISTS (SELECT 1 FROM t_device_run_event run_event WHERE run_event.channel_id = channel_fact.channel_id)
+  AND NOT EXISTS (SELECT 1 FROM t_test_exception exception_fact WHERE exception_fact.channel_id = channel_fact.channel_id);
+
+-- 只删除已经没有通道和测试关联的旧开发候选柜体。
+DELETE cabinet
+FROM t_detection_cabinet cabinet
+JOIN t_device_adapter adapter ON adapter.adapter_id = cabinet.adapter_id
+WHERE adapter.adapter_code = 'dev-sim'
+  AND cabinet.registration_status = 'DISCOVERED'
+  AND NOT EXISTS (SELECT 1 FROM t_detection_channel channel_fact WHERE channel_fact.cabinet_id = cabinet.cabinet_id);
+
+ALTER TABLE t_detection_channel
+    DROP INDEX uk_channel_identity,
+    MODIFY adapter_id BIGINT NULL,
+    MODIFY adapter_code VARCHAR(64) NOT NULL,
+    ADD UNIQUE KEY uk_channel_stable_identity
+        (adapter_code, devtype, devid, subdevid, chlid, del_flag);
+
+ALTER TABLE t_detection_cabinet
+    ADD KEY idx_cabinet_adapter (adapter_id),
+    DROP INDEX uk_cabinet_vendor_identity,
+    MODIFY adapter_id BIGINT NULL,
+    MODIFY adapter_code VARCHAR(64) NOT NULL,
+    ADD UNIQUE KEY uk_cabinet_stable_identity
+        (adapter_code, vendor_device_type, vendor_device_id, del_flag);
+
+-- 已正式登记的 dev-sim 拓扑改用 adapter_code,解除对临时数据库配置行的依赖。
+UPDATE t_detection_channel SET adapter_id = NULL WHERE adapter_code = 'dev-sim';
+UPDATE t_detection_cabinet SET adapter_id = NULL WHERE adapter_code = 'dev-sim';
+
+-- 没有历史协议或采集器引用时清理旧 dev-sim 配置;有引用时保留历史行但运行时不再读取它。
+DELETE adapter
+FROM t_device_adapter adapter
+WHERE adapter.adapter_code = 'dev-sim'
+  AND NOT EXISTS (SELECT 1 FROM t_collector collector WHERE collector.adapter_id = adapter.adapter_id)
+  AND NOT EXISTS (SELECT 1 FROM t_protocol_message message_fact WHERE message_fact.adapter_id = adapter.adapter_id);
+
+-- 若历史引用要求保留行,则将其隐藏并禁用,运行时恢复和管理列表都不再读取。
+UPDATE t_device_adapter
+SET status = '1', del_flag = adapter_id, update_by = 'system', update_time = sysdate()
+WHERE adapter_code = 'dev-sim' AND del_flag = 0;
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 0ff4148..270d4c8 100644
--- a/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
+++ b/ruoyi-admin/src/main/resources/static/dev/bts-simulator.html
@@ -3,7 +3,7 @@
 <head>
   <meta charset="UTF-8">
   <meta name="viewport" content="width=device-width, initial-scale=1">
-  <title>BTS 开发模拟器</title>
+  <title>开发设备模拟器</title>
   <style>
     :root { color-scheme: light; font-family: "Microsoft YaHei", Arial, sans-serif; color: #26323d; background: #f3f6f8; }
     * { box-sizing: border-box; }
@@ -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(3, minmax(0, 1fr)); }
+    .summary { display: grid; grid-template-columns: repeat(4, 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; }
@@ -25,6 +25,7 @@
     .status.online::before { background: #218a5b; }
     .add-form, .device-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 150px auto; gap: 12px; align-items: end; padding: 16px; }
     .device-settings { grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; }
+    .collector-settings { display: grid; grid-template-columns: minmax(180px, 1fr) 130px 140px auto auto; gap: 12px; align-items: end; padding: 16px; }
     label { color: #5e6a73; font-size: 12px; }
     label > span { display: block; margin-bottom: 6px; }
     input[type="text"], input[type="number"], select { width: 100%; height: 34px; padding: 0 9px; color: #26323d; background: #fff; border: 1px solid #bdc8d0; border-radius: 3px; }
@@ -53,8 +54,8 @@
       main { width: calc(100% - 20px); margin-top: 12px; }
       .summary { grid-template-columns: 1fr; }
       .summary > div { border-right: 0; border-bottom: 1px solid #e2e8ec; }
-      .add-form, .device-settings { grid-template-columns: 1fr 1fr; }
-      .device-settings button { width: 100%; }
+      .add-form, .device-settings, .collector-settings { grid-template-columns: 1fr 1fr; }
+      .device-settings button, .collector-settings button { width: 100%; }
       .device { margin: 10px; overflow-x: auto; }
       .device table { min-width: 520px; }
       th, td { padding: 8px; }
@@ -64,7 +65,7 @@
   </style>
 </head>
 <body>
-  <header><h1>BTS 开发模拟器</h1><span>dev-sim / 仅限本机</span></header>
+  <header><h1>开发设备模拟器</h1><span>dev-sim / 仅限本机</span></header>
   <main>
     <div id="error" class="error"></div>
     <section>
@@ -72,20 +73,33 @@
       <div class="summary">
         <div><span class="label">模拟器</span><span id="runtime" 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="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><span class="label">虚拟 IP 必须唯一</span></div>
+      <div class="section-title"><h2>添加检测柜</h2><button id="reset-simulator" class="danger" type="button">重置模拟器</button></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>
-        <button type="submit">添加设备</button>
+        <button type="submit">添加检测柜</button>
       </form>
     </section>
     <section>
-      <div class="section-title"><h2>虚拟设备与通道</h2><span class="label">约在下一轮设备发现后同步至业务页面</span></div>
+      <div class="section-title"><h2>检测柜与通道</h2><span class="label">约在下一轮设备发现后同步至业务页面</span></div>
       <div id="devices"></div>
+    </section>
+    <section>
+      <div class="section-title"><h2>添加采集器</h2><span class="label">与检测柜分开配置</span></div>
+      <form id="add-collector" class="add-form">
+        <label><span>虚拟 IP</span><input id="new-collector-ip" type="text" value="127.0.1.3" required></label>
+        <label><span>通道数</span><input id="new-collector-channel-count" type="number" min="1" max="99" value="4" required></label>
+        <button type="submit">添加采集器</button>
+      </form>
+    </section>
+    <section>
+      <div class="section-title"><h2>采集器</h2><span class="label">在采集器页面“选择设备”中直接显示</span></div>
+      <div id="collectors"></div>
     </section>
     <section>
       <div class="section-title"><h2>最近请求</h2><span id="request-count" class="label">0 条</span></div>
@@ -122,7 +136,9 @@
       runtime.classList.toggle('online', state.running);
       document.querySelector('#address').textContent = state.listenAddress;
       document.querySelector('#device-count').textContent = `${state.devices.length} 台`;
+      document.querySelector('#collector-count').textContent = `${state.collectors.length} 台`;
       renderDevices(state.devices);
+      renderCollectors(state.collectors);
       renderRequests(state.recentRequests);
     }
 
@@ -141,7 +157,7 @@
         const title = document.createElement('div');
         title.className = 'device-title';
         const heading = document.createElement('h3');
-        heading.textContent = `设备 ${device.deviceId}`;
+        heading.textContent = `检测柜 ${device.deviceId}`;
         const identity = document.createElement('span');
         identity.className = 'label';
         identity.textContent = `devtype 22 / devid ${device.deviceId}`;
@@ -172,7 +188,7 @@
         const remove = document.createElement('button');
         remove.type = 'button';
         remove.className = 'danger';
-        remove.textContent = '删除设备';
+        remove.textContent = '删除检测柜';
         remove.addEventListener('click', async () => {
           try { render(await request(`/devices/${device.deviceId}`, { method: 'DELETE' })); }
           catch (error) { showError(error); }
@@ -185,6 +201,63 @@
         body.append(...device.channels.map(channel => channelRow(device.deviceId, channel)));
         table.append(body);
         panel.append(title, settings, table);
+        return panel;
+      }));
+    }
+
+    function renderCollectors(collectors) {
+      const container = document.querySelector('#collectors');
+      if (!collectors.length) {
+        const empty = document.createElement('div');
+        empty.className = 'empty';
+        empty.textContent = '暂无虚拟采集器';
+        container.replaceChildren(empty);
+        return;
+      }
+      container.replaceChildren(...collectors.map(collector => {
+        const panel = document.createElement('article');
+        panel.className = 'device';
+        const title = document.createElement('div');
+        title.className = 'device-title';
+        const heading = document.createElement('h3');
+        heading.textContent = collector.name;
+        const identity = document.createElement('span');
+        identity.className = 'label';
+        identity.textContent = `${collector.deviceCode} / ${collector.mac}`;
+        title.append(heading, identity);
+
+        const settings = document.createElement('div');
+        settings.className = 'collector-settings';
+        const ip = field('虚拟 IP', 'text', collector.ip);
+        const channelCount = field('通道数', 'number', collector.channelCount);
+        channelCount.input.min = '1';
+        channelCount.input.max = '99';
+        const onlineLabel = document.createElement('label');
+        onlineLabel.className = 'switch-label';
+        const online = document.createElement('input');
+        online.type = 'checkbox';
+        online.checked = collector.online;
+        const onlineText = document.createElement('span');
+        onlineText.textContent = collector.online ? '整机在线' : '整机离线';
+        online.addEventListener('change', () => { onlineText.textContent = online.checked ? '整机在线' : '整机离线'; });
+        onlineLabel.append(online, onlineText);
+        const save = document.createElement('button');
+        save.type = 'button';
+        save.textContent = '保存配置';
+        save.addEventListener('click', async () => {
+          try { render(await request(`/collectors/${collector.collectorId}`, { method: 'PUT', body: JSON.stringify({ ip: ip.input.value, channelCount: Number(channelCount.input.value), online: online.checked }) })); }
+          catch (error) { showError(error); }
+        });
+        const remove = document.createElement('button');
+        remove.type = 'button';
+        remove.className = 'danger';
+        remove.textContent = '删除采集器';
+        remove.addEventListener('click', async () => {
+          try { render(await request(`/collectors/${collector.collectorId}`, { method: 'DELETE' })); }
+          catch (error) { showError(error); }
+        });
+        settings.append(ip.label, channelCount.label, onlineLabel, save, remove);
+        panel.append(title, settings);
         return panel;
       }));
     }
@@ -264,6 +337,11 @@
       try { render(await request('/state')); } catch (error) { showError(error); }
     }
 
+    document.getElementById('reset-simulator').addEventListener('click', async () => {
+      if (!confirm('确认清空全部模拟配置和状态并恢复默认设备?')) return;
+      try { render(await request('/reset', { method: 'POST' })); } catch (error) { showError(error); }
+    });
+
     document.querySelector('#add-device').addEventListener('submit', async event => {
       event.preventDefault();
       try {
@@ -271,6 +349,13 @@
       } catch (error) { showError(error); }
     });
 
+    document.querySelector('#add-collector').addEventListener('submit', async event => {
+      event.preventDefault();
+      try {
+        render(await request('/collectors', { method: 'POST', body: JSON.stringify({ ip: document.querySelector('#new-collector-ip').value, channelCount: Number(document.querySelector('#new-collector-channel-count').value) }) }));
+      } catch (error) { showError(error); }
+    });
+
     refresh();
     setInterval(refresh, 2000);
   </script>
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java
new file mode 100644
index 0000000..cd05a02
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java
@@ -0,0 +1,30 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import org.junit.jupiter.api.Test;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
+
+class DevelopmentCollectorStatusSynchronizerTest
+{
+    @Test
+    void synchronizesOnlyConfiguredCollectorsWithoutRegisteringThem() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
+            DevelopmentCollectorStatusSynchronizer synchronizer =
+                    new DevelopmentCollectorStatusSynchronizer(simulator, store);
+            simulator.updateCollector(1, "127.0.1.2", 4, false);
+
+            synchronizer.synchronizeOnlineStatus();
+
+            assertEquals(false, store.snapshot(java.time.Instant.now()).collectors().get(0).online());
+
+            simulator.deleteCollector(1);
+            synchronizer.synchronizeOnlineStatus();
+
+            assertEquals(0, store.snapshot(java.time.Instant.now()).collectors().size());
+        }
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
index aa72eaa..cbceb70 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java
@@ -6,24 +6,24 @@
 import org.junit.jupiter.api.Test;
 import org.springframework.boot.DefaultApplicationArguments;
 import org.mockito.ArgumentCaptor;
-import com.ruoyi.system.domain.TDeviceAdapter;
-import com.ruoyi.system.service.IDeviceAdapterService;
+import com.ruoyi.device.registry.AdapterInstanceConfig;
+import com.ruoyi.device.registry.DeviceAdapterRegistry;
 
 class DeviceAdapterRuntimeConfigTest
 {
     @Test
     void registersDevelopmentSimulatorBeforeApplicationReady() throws Exception
     {
-        IDeviceAdapterService service = mock(IDeviceAdapterService.class);
-        var runner = new DeviceAdapterRuntimeConfig().registerDevelopmentSimulatorAdapter(service);
+        DeviceAdapterRegistry registry = mock(DeviceAdapterRegistry.class);
+        var runner = new DeviceAdapterRuntimeConfig().registerDevelopmentSimulatorAdapter(registry);
 
         runner.run(new DefaultApplicationArguments(new String[0]));
 
-        ArgumentCaptor<TDeviceAdapter> adapter = ArgumentCaptor.forClass(TDeviceAdapter.class);
-        verify(service).ensureManagedAdapter(adapter.capture());
-        assertEquals("dev-sim", adapter.getValue().getAdapterCode());
-        assertEquals("127.0.0.1", adapter.getValue().getHost());
-        assertEquals(15020, adapter.getValue().getPort());
-        assertEquals(5000, adapter.getValue().getPollIntervalMs());
+        ArgumentCaptor<AdapterInstanceConfig> adapter = ArgumentCaptor.forClass(AdapterInstanceConfig.class);
+        verify(registry).synchronize(adapter.capture());
+        assertEquals("dev-sim", adapter.getValue().adapterCode());
+        assertEquals("127.0.0.1", adapter.getValue().host());
+        assertEquals(15020, adapter.getValue().port());
+        assertEquals(5000, adapter.getValue().pollIntervalMs());
     }
 }
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
index 8d11c51..4d7ff68 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java
@@ -59,6 +59,30 @@
         assertTrue(v7.contains("ELSE 'REGISTERED'"));
         assertTrue(v7.contains("uk_cabinet_mac_del"));
         assertFalse(v7.matches("(?s).*\\bDELETE\\s+FROM\\b.*"));
+
+        String v8 = resource("db/migration/V8__complete_collector_page_contract.sql");
+        assertTrue(v8.contains("ALTER TABLE t_collector"));
+        assertTrue(v8.contains("test_status"));
+        assertTrue(v8.contains("current_test"));
+        assertTrue(v8.contains("current_sample_name"));
+        assertTrue(v8.contains("chk_collector_test_status"));
+        assertFalse(v8.matches("(?s).*\\bDELETE\\s+FROM\\b.*"));
+
+        String v9 = resource("db/migration/V9__move_discovery_candidates_to_redis.sql");
+        assertTrue(v9.contains("adapter_code VARCHAR(64)"));
+        assertTrue(v9.contains("adapter.adapter_code = 'dev-sim'"));
+        assertTrue(v9.contains("cabinet.registration_status = 'DISCOVERED'"));
+        assertTrue(v9.contains("t_test_channel_binding"));
+        assertTrue(v9.contains("t_test_sample"));
+        assertTrue(v9.contains("t_device_run_event"));
+        assertTrue(v9.contains("SELECT protected_cabinet.cabinet_id"));
+        assertTrue(v9.contains("SELECT DISTINCT sibling.cabinet_id"));
+        assertTrue(v9.contains("uk_channel_stable_identity"));
+        assertTrue(v9.contains("ADD KEY idx_cabinet_adapter (adapter_id)"));
+        assertTrue(v9.contains("SET adapter_id = NULL WHERE adapter_code = 'dev-sim'"));
+        assertFalse(v9.contains("DROP FOREIGN KEY fk_channel_adapter"));
+        assertFalse(v9.contains("DROP FOREIGN KEY fk_cabinet_adapter"));
+        assertFalse(v9.matches("(?s).*t_test_battery\\s+\\w+\\s+WHERE\\s+\\w+\\.cabinet_id.*"));
     }
 
     private static String sha256(String path)
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java b/ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java
new file mode 100644
index 0000000..b8884ef
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java
@@ -0,0 +1,155 @@
+package com.ruoyi.config;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import java.util.List;
+import java.util.concurrent.atomic.AtomicReference;
+import org.junit.jupiter.api.Test;
+import org.springframework.data.redis.core.RedisTemplate;
+import org.springframework.data.redis.core.ValueOperations;
+import com.alibaba.fastjson2.JSON;
+import com.alibaba.fastjson2.JSONObject;
+import com.ruoyi.device.model.DeviceWorkStatus;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.ChannelState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.DeviceState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.framework.config.FastJson2JsonRedisSerializer;
+
+class RedisStateStoreTest
+{
+    @Test
+    void initializesOnlyWhenStateIsAbsentAndRestoresSavedState()
+    {
+        AtomicReference<Object> stored = new AtomicReference<>();
+        RedisBtsSimulatorStateStore store = store(stored);
+        SimulatorState defaults = state(1, "127.0.0.2");
+
+        assertEquals(defaults, store.loadOrInitialize(defaults));
+        SimulatorState changed = state(7, "127.0.0.9");
+        store.save(changed);
+
+        assertEquals(changed, store.loadOrInitialize(defaults));
+    }
+
+    @Test
+    void rejectsIncompatibleVersionWithoutOverwritingIt()
+    {
+        AtomicReference<Object> stored = new AtomicReference<>("{\"version\":2,\"devices\":[],"
+                + "\"collectors\":[],\"nextDeviceId\":1,\"nextCollectorId\":1,\"tombstones\":[]}");
+        RedisBtsSimulatorStateStore store = store(stored);
+
+        IllegalStateException exception = assertThrows(IllegalStateException.class,
+                () -> store.loadOrInitialize(state(1, "127.0.0.2")));
+
+        assertEquals("Unsupported BTS simulator state version: 2", exception.getMessage());
+        assertEquals(true, stored.get().toString().contains("\"version\":2"));
+    }
+
+    @Test
+    void restoresNestedDiscoveryCandidatesFromJson()
+    {
+        AtomicReference<Object> stored = new AtomicReference<>();
+        RedisDeviceDiscoveryCandidateStore first = new RedisDeviceDiscoveryCandidateStore(redis(stored));
+        java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z");
+        CabinetCandidate candidate = new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1,
+                "127.0.0.2", null, "模拟柜", "开发模拟器", "BTS Virtual Cabinet",
+                List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true,
+                        DeviceWorkStatus.WORKING, observedAt)), observedAt, observedAt.plusSeconds(30));
+        first.recordCabinets(List.of(candidate));
+
+        RedisDeviceDiscoveryCandidateStore restored = new RedisDeviceDiscoveryCandidateStore(redis(stored));
+        CabinetCandidate actual = restored.snapshot(observedAt.plusSeconds(1)).cabinets().get(0);
+
+        assertEquals("dev-sim-22-1", actual.deviceCode());
+        assertEquals(DeviceWorkStatus.WORKING, actual.channels().get(0).workStatus());
+        assertEquals(observedAt, actual.channels().get(0).observedAt());
+    }
+
+    @Test
+    void removesDevelopmentCandidatesFromRedisState()
+    {
+        AtomicReference<Object> stored = new AtomicReference<>();
+        RedisDeviceDiscoveryCandidateStore store = new RedisDeviceDiscoveryCandidateStore(redis(stored));
+        java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z");
+        store.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1,
+                "127.0.0.2", null, "模拟柜", null, null,
+                List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true,
+                        DeviceWorkStatus.STOPPED, observedAt)), observedAt, observedAt.plusSeconds(30))));
+
+        store.removeAdapter("dev-sim");
+
+        assertEquals(List.of(), store.snapshot(observedAt).cabinets());
+    }
+
+    @Test
+    void restoresDiscoveryCandidatesFromLegacyAutoTypeJson()
+    {
+        AtomicReference<Object> stored = new AtomicReference<>();
+        RedisDeviceDiscoveryCandidateStore first = new RedisDeviceDiscoveryCandidateStore(redis(stored));
+        java.time.Instant observedAt = java.time.Instant.parse("2026-08-27T00:00:00Z");
+        first.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-1", 22, 1,
+                "127.0.0.2", null, "模拟柜", null, null,
+                List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 1, 1, 1), true,
+                        DeviceWorkStatus.STOPPED, observedAt)), observedAt, observedAt.plusSeconds(30))));
+        first.replaceCollectors("dev-sim", List.of(new CollectorCandidate("dev-sim", "dev-sim-collector-1",
+                "127.0.1.2", null, "模拟采集器", null, null, 8, true,
+                observedAt, observedAt.plusSeconds(30))));
+
+        JSONObject legacy = JSON.parseObject(stored.get().toString());
+        legacy.put("@type", "com.ruoyi.config.RedisDeviceDiscoveryCandidateStore$StoredState");
+        JSONObject cabinet = legacy.getJSONArray("cabinets").getJSONObject(0);
+        cabinet.put("@type", JSONObject.class.getName());
+        cabinet.getJSONArray("channels").getJSONObject(0)
+                .put("@type", JSONObject.class.getName());
+        legacy.getJSONArray("collectors").getJSONObject(0)
+                .put("@type", JSONObject.class.getName());
+        FastJson2JsonRedisSerializer<Object> serializer = new FastJson2JsonRedisSerializer<>(Object.class);
+        stored.set(serializer.deserialize(serializer.serialize(legacy)));
+
+        var snapshot = new RedisDeviceDiscoveryCandidateStore(redis(stored)).snapshot(observedAt.plusSeconds(1));
+        CabinetCandidate restored = snapshot.cabinets().get(0);
+
+        assertEquals("dev-sim-22-1", restored.deviceCode());
+        assertEquals(DeviceWorkStatus.STOPPED, restored.channels().get(0).workStatus());
+        assertEquals("dev-sim-collector-1", snapshot.collectors().get(0).deviceCode());
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private RedisBtsSimulatorStateStore store(AtomicReference<Object> stored)
+    {
+        return new RedisBtsSimulatorStateStore(redis(stored));
+    }
+
+    @SuppressWarnings({ "unchecked", "rawtypes" })
+    private RedisTemplate<Object, Object> redis(AtomicReference<Object> stored)
+    {
+        RedisTemplate<Object, Object> redis = mock(RedisTemplate.class);
+        ValueOperations<Object, Object> values = mock(ValueOperations.class);
+        when(redis.opsForValue()).thenReturn(values);
+        when(values.get(any())).thenAnswer(invocation -> stored.get());
+        when(values.setIfAbsent(any(), any())).thenAnswer(invocation -> {
+            if (stored.get() != null) return false;
+            stored.set(invocation.getArgument(1));
+            return true;
+        });
+        doAnswer(invocation -> { stored.set(invocation.getArgument(1)); return null; })
+                .when(values).set(any(), any());
+        return redis;
+    }
+
+    private SimulatorState state(int deviceId, String ip)
+    {
+        return new SimulatorState(1,
+                List.of(new DeviceState(deviceId, ip, true,
+                        List.of(new ChannelState(1, true, DeviceWorkStatus.STOPPED)))),
+                List.of(), deviceId + 1, 1, List.of());
+    }
+}
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImplTest.java
index 06b186e..4133bba 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImplTest.java
@@ -1,38 +1,87 @@
 package com.ruoyi.system.service.impl;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
 import java.util.List;
+import java.util.Set;
 import org.junit.jupiter.api.Test;
+import com.ruoyi.config.DevelopmentCollectorDiscoveryService;
+import com.ruoyi.config.DevelopmentCollectorStatusSynchronizer;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
 import com.ruoyi.system.domain.vo.DiscoveredCollectorVo;
+import com.ruoyi.system.service.ITCollectorService;
 
 /**
  * 采集器设备发现服务测试。
  */
 public class CollectorDiscoveryServiceImplTest
 {
-    private final CollectorDiscoveryServiceImpl service = new CollectorDiscoveryServiceImpl();
-
     @Test
-    public void shouldReturnExpectedTestDevices()
+    public void shouldReturnCollectorsWithoutMixingCabinets() throws Exception
     {
-        List<DiscoveredCollectorVo> result = service.selectDiscoveredCollectorList();
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            ITCollectorService collectorService = mock(ITCollectorService.class);
+            when(collectorService.selectRegisteredCollectorCodes()).thenReturn(Set.of());
+            InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
+            DevelopmentCollectorDiscoveryService service =
+                    new DevelopmentCollectorDiscoveryService(store, collectorService);
+            simulator.addDevice("127.0.0.8", 2);
+            simulator.addCollector("127.0.1.3", 6);
+            new DevelopmentCollectorStatusSynchronizer(simulator, store).synchronizeOnlineStatus();
 
-        assertEquals(6, result.size());
-        DiscoveredCollectorVo first = result.get(0);
-        assertEquals("45643176", first.getDeviceCode());
-        assertEquals("192.1.1.162", first.getIp());
-        assertEquals("00:1A:2B:3C:4D:01", first.getMac());
-        assertEquals("在线", first.getOnlineStatus());
+            List<DiscoveredCollectorVo> result = service.selectDiscoveredCollectorList();
+
+            assertEquals(2, result.size());
+            DiscoveredCollectorVo first = result.get(0);
+            assertEquals("dev-sim-collector-1", first.getDeviceCode());
+            assertEquals("127.0.1.2", first.getIp());
+            assertEquals("02:43:00:00:00:01", first.getMac());
+            assertEquals("dev-sim 采集器 1", first.getName());
+            assertEquals("开发模拟器", first.getManufacturer());
+            assertEquals("Virtual Collector", first.getModel());
+            assertEquals(4, first.getChannelCount());
+            assertEquals("在线", first.getOnlineStatus());
+        }
     }
 
     @Test
-    public void shouldReturnIndependentListContainers()
+    public void shouldHideOfflineCollectors() throws Exception
     {
-        List<DiscoveredCollectorVo> firstResult = service.selectDiscoveredCollectorList();
-        firstResult.clear();
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            ITCollectorService collectorService = mock(ITCollectorService.class);
+            when(collectorService.selectRegisteredCollectorCodes()).thenReturn(Set.of());
+            InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
+            DevelopmentCollectorDiscoveryService service =
+                    new DevelopmentCollectorDiscoveryService(store, collectorService);
+            simulator.updateCollector(1, "127.0.1.9", 8, false);
+            new DevelopmentCollectorStatusSynchronizer(simulator, store).synchronizeOnlineStatus();
 
-        List<DiscoveredCollectorVo> secondResult = service.selectDiscoveredCollectorList();
+            List<DiscoveredCollectorVo> result = service.selectDiscoveredCollectorList();
 
-        assertEquals(6, secondResult.size());
+            assertEquals(0, result.size());
+        }
+    }
+
+    @Test
+    public void shouldExcludeRegisteredCollectorAndReturnItAfterDeletion() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            ITCollectorService collectorService = mock(ITCollectorService.class);
+            InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
+            DevelopmentCollectorDiscoveryService service =
+                    new DevelopmentCollectorDiscoveryService(store, collectorService);
+            new DevelopmentCollectorStatusSynchronizer(simulator, store).synchronizeOnlineStatus();
+            when(collectorService.selectRegisteredCollectorCodes())
+                    .thenReturn(Set.of("dev-sim-collector-1"))
+                    .thenReturn(Set.of());
+
+            assertEquals(0, service.selectDiscoveredCollectorList().size());
+            assertEquals(1, service.selectDiscoveredCollectorList().size());
+        }
     }
 }
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
index 41ee209..8001b0d 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java
@@ -27,6 +27,8 @@
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.device.api.DeviceAdapter;
 import com.ruoyi.device.api.DeviceAdapterException;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
 import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DetectedChannel;
 import com.ruoyi.device.model.DetectedDeviceMetadata;
@@ -53,12 +55,14 @@
 
     private DeviceAdapterServiceImpl service;
     private TDeviceAdapter adapter;
+    private InMemoryDeviceDiscoveryCandidateStore candidateStore;
 
     @BeforeEach
     void setUp()
     {
+        candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
         service = new DeviceAdapterServiceImpl(adapterMapper, registry,
-                new DeviceDiscoveryPersistenceService(adapterMapper));
+                new DeviceDiscoveryPersistenceService(candidateStore), candidateStore);
         adapter = new TDeviceAdapter();
         adapter.setAdapterId(7L);
         adapter.setAdapterCode("lab-bts");
@@ -95,6 +99,8 @@
             context.registerBean(DeviceAdapterRegistry.class, () -> org.mockito.Mockito.mock(DeviceAdapterRegistry.class));
             context.registerBean(DeviceDiscoveryPersistenceService.class,
                     () -> org.mockito.Mockito.mock(DeviceDiscoveryPersistenceService.class));
+            context.registerBean(DeviceDiscoveryCandidateStore.class,
+                    () -> org.mockito.Mockito.mock(DeviceDiscoveryCandidateStore.class));
             context.register(DeviceAdapterServiceImpl.class);
 
             context.refresh();
@@ -111,14 +117,13 @@
                 new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 3), "192.168.1.35", true),
                 new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 2, 4), "192.168.1.35", false)));
         stubRegistryExecution();
-        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
-
         assertEquals(2, service.discover(7L).channelCount());
         assertEquals(2, service.discover(7L).channelCount());
 
-        verify(adapterMapper, times(4)).upsertDetectedChannel(any(TDetectionChannel.class));
+        var snapshot = candidateStore.snapshot(Instant.now());
+        assertEquals(1, snapshot.cabinets().size());
+        assertEquals(2, snapshot.cabinets().get(0).channels().size());
         verify(adapterMapper, times(2)).updateDiscoverySuccess(eq(7L), any());
-        verify(adapterMapper, times(2)).updateDetectedCabinet(any(TDetectionCabinet.class));
     }
 
     @Test
@@ -129,21 +134,15 @@
         when(deviceAdapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "127.0.0.8", true,
                 new DetectedDeviceMetadata("开发设备 8", "开发模拟器", "BTS Virtual Cabinet",
                         "02:44:00:16:00:08"))));
-        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 8)).thenReturn(null);
-        when(adapterMapper.insertDetectedCabinet(any(TDetectionCabinet.class))).thenAnswer(invocation -> {
-            invocation.getArgument(0, TDetectionCabinet.class).setCabinetId(18L);
-            return 1;
-        });
         stubRegistryExecution();
 
         service.discover(7L);
 
-        var cabinet = org.mockito.ArgumentCaptor.forClass(TDetectionCabinet.class);
-        verify(adapterMapper).insertDetectedCabinet(cabinet.capture());
-        assertEquals("开发设备 8", cabinet.getValue().getCabinetName());
-        assertEquals("开发模拟器", cabinet.getValue().getManufacturer());
-        assertEquals("BTS Virtual Cabinet", cabinet.getValue().getModel());
-        assertEquals("02:44:00:16:00:08", cabinet.getValue().getMacAddress());
+        var cabinet = candidateStore.snapshot(Instant.now()).cabinets().get(0);
+        assertEquals("开发设备 8", cabinet.name());
+        assertEquals("开发模拟器", cabinet.manufacturer());
+        assertEquals("BTS Virtual Cabinet", cabinet.model());
+        assertEquals("02:44:00:16:00:08", cabinet.mac());
     }
 
     @Test
@@ -157,14 +156,12 @@
                 new RealtimeSnapshot(identity, DeviceWorkStatus.WORKING, 1, 2, "cc", null,
                         BigDecimal.ONE, BigDecimal.TEN, null, null, 3L, 2L, null,
                         Instant.parse("2026-08-26T01:02:03Z"), Map.of())));
-        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
         stubRegistryExecution();
 
         service.discover(7L);
 
-        var channel = org.mockito.ArgumentCaptor.forClass(TDetectionChannel.class);
-        verify(adapterMapper).upsertDetectedChannel(channel.capture());
-        assertEquals("WORKING", channel.getValue().getWorkStatus());
+        var channel = candidateStore.snapshot(Instant.now()).cabinets().get(0).channels().get(0);
+        assertEquals(DeviceWorkStatus.WORKING, channel.workStatus());
     }
 
     @Test
@@ -174,19 +171,14 @@
         when(deviceAdapter.discoverChannels()).thenReturn(List.of(
                 new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 1), "127.0.0.1", false),
                 new DetectedChannel(new ChannelIdentity("lab-bts", 22, 1, 1, 2), "127.0.0.1", false)));
-        when(adapterMapper.selectCabinetIdByIdentity(7L, 22, 1)).thenReturn(10L);
         stubRegistryExecution();
 
         service.discover(7L);
 
-        var cabinet = org.mockito.ArgumentCaptor.forClass(TDetectionCabinet.class);
-        var channels = org.mockito.ArgumentCaptor.forClass(TDetectionChannel.class);
-        verify(adapterMapper).updateDetectedCabinet(cabinet.capture());
-        verify(adapterMapper, times(2)).upsertDetectedChannel(channels.capture());
+        var cabinet = candidateStore.snapshot(Instant.now()).cabinets().get(0);
         verify(deviceAdapter, never()).inquire(any());
-        assertEquals("OFFLINE", cabinet.getValue().getOnlineStatus());
-        assertTrue(channels.getAllValues().stream()
-                .allMatch(channel -> "OFFLINE".equals(channel.getOnlineStatus())));
+        assertEquals(false, cabinet.online());
+        assertTrue(cabinet.channels().stream().noneMatch(channel -> channel.online()));
     }
 
     @Test
@@ -218,6 +210,17 @@
     }
 
     @Test
+    void rejectsReservedDevelopmentSimulatorCodeFromDatabaseConfiguration()
+    {
+        adapter.setAdapterCode("dev-sim");
+
+        assertThrows(ServiceException.class, () -> service.insert(adapter));
+        assertThrows(ServiceException.class, () -> service.ensureManagedAdapter(adapter));
+        verify(adapterMapper, never()).insertDeviceAdapter(any());
+        verify(adapterMapper, never()).upsertManagedDeviceAdapter(any());
+    }
+
+    @Test
     void developmentSimulatorProvisioningIsIdempotentAndReadOnly()
     {
         String mapperXml;
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TCollectorServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TCollectorServiceImplTest.java
index b461b51..8529c48 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TCollectorServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TCollectorServiceImplTest.java
@@ -4,6 +4,7 @@
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 import java.io.IOException;
 import java.io.InputStream;
@@ -19,12 +20,13 @@
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
 import org.mockito.Mock;
 import org.mockito.junit.jupiter.MockitoExtension;
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.system.domain.TCollector;
 import com.ruoyi.system.mapper.TCollectorMapper;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
 
 /**
  * 采集器 Service 单元测试。
@@ -35,14 +37,20 @@
     @Mock
     private TCollectorMapper collectorMapper;
 
-    @InjectMocks
     private TCollectorServiceImpl collectorService;
+    private InMemoryDeviceDiscoveryCandidateStore candidateStore;
 
     private TCollector collector;
 
     @BeforeEach
     void setUp()
     {
+        candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
+        collectorService = new TCollectorServiceImpl(collectorMapper, candidateStore);
+        java.time.Instant now = java.time.Instant.now();
+        candidateStore.replaceCollectors("dev-sim", java.util.List.of(new CollectorCandidate(
+                "dev-sim", "COL-001", "192.168.1.10", "AA:BB:CC:DD:EE:FF", "一号采集器",
+                "开发模拟器", "Virtual Collector", 8, true, now, now.plusSeconds(60))));
         collector = new TCollector();
         collector.setCollectorId(1L);
         collector.setCollectorCode("COL-001");
@@ -69,6 +77,23 @@
 
             assertFalse(insertSql.contains("update_by"));
             assertFalse(insertSql.contains("update_time"));
+        }
+    }
+
+    @Test
+    void registeredCollectorCodeSqlShouldIgnoreDeletedRows() throws IOException
+    {
+        try (InputStream input = getClass().getResourceAsStream(
+                "/mapper/system/TCollectorMapper.xml"))
+        {
+            assertNotNull(input);
+            String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+            int selectStart = mapperXml.indexOf("<select id=\"selectRegisteredCollectorCodes\"");
+            assertTrue(selectStart >= 0);
+            int selectEnd = mapperXml.indexOf("</select>", selectStart);
+            String selectSql = mapperXml.substring(selectStart, selectEnd);
+
+            assertTrue(selectSql.contains("where del_flag = '0'"));
         }
     }
 
@@ -123,16 +148,21 @@
     }
 
     @Test
-    void insertCollectorShouldRejectInvalidIpAddress()
+    void insertCollectorShouldValidateAuthoritativeCandidateTopology()
     {
         collector.setCollectorId(null);
         collector.setIpAddress("invalid-host");
+        collector.setMacAddress("invalid-mac");
+        collector.setChannelCount(99);
+        when(collectorMapper.insertCollector(any(TCollector.class))).thenReturn(1);
 
-        ServiceException exception = assertThrows(ServiceException.class,
-                () -> collectorService.insertCollector(collector));
+        assertEquals(1, collectorService.insertCollector(collector));
 
-        assertEquals("IP地址格式不正确", exception.getMessage());
-        verify(collectorMapper, never()).insertCollector(any(TCollector.class));
+        ArgumentCaptor<TCollector> captor = ArgumentCaptor.forClass(TCollector.class);
+        verify(collectorMapper).insertCollector(captor.capture());
+        assertEquals("192.168.1.10", captor.getValue().getIpAddress());
+        assertEquals("AA:BB:CC:DD:EE:FF", captor.getValue().getMacAddress());
+        assertEquals(8, captor.getValue().getChannelCount());
     }
 
     @Test
@@ -217,6 +247,14 @@
     }
 
     @Test
+    void selectRegisteredCollectorCodesShouldReturnUndeletedCodes()
+    {
+        when(collectorMapper.selectRegisteredCollectorCodes()).thenReturn(java.util.List.of("COL-001"));
+
+        assertEquals(java.util.Set.of("COL-001"), collectorService.selectRegisteredCollectorCodes());
+    }
+
+    @Test
     void deleteCollectorByIdsShouldRejectTestingCollectorBeforeAnyDelete()
     {
         TCollector idle = new TCollector();
diff --git a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java
index 2305e44..9207f29 100644
--- a/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java
@@ -28,6 +28,11 @@
 import com.ruoyi.system.domain.TDetectionCabinet;
 import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
 import com.ruoyi.system.mapper.TDetectionCabinetMapper;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DeviceWorkStatus;
 
 /**
  * 检测柜业务层单元测试,验证字段规范化、状态限制及唯一性校验等关键规则。
@@ -38,8 +43,8 @@
     @Mock
     private TDetectionCabinetMapper detectionCabinetMapper;
 
-    @InjectMocks
     private TDetectionCabinetServiceImpl detectionCabinetService;
+    private InMemoryDeviceDiscoveryCandidateStore candidateStore;
 
     private TDetectionCabinet detectionCabinet;
 
@@ -49,6 +54,9 @@
     @BeforeEach
     void setUp()
     {
+        candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
+        detectionCabinetService = new TDetectionCabinetServiceImpl(detectionCabinetMapper, candidateStore);
+        candidateStore.recordCabinets(List.of(candidate("CABINET-001", true, DeviceWorkStatus.WORKING)));
         detectionCabinet = new TDetectionCabinet();
         detectionCabinet.setCabinetCode(" CABINET-001 ");
         detectionCabinet.setCabinetName(" 一号检测柜 ");
@@ -67,19 +75,15 @@
     @Test
     void insertDetectionCabinetShouldPromoteDiscoveredCandidate()
     {
-        TDetectionCabinet candidate = new TDetectionCabinet();
-        candidate.setCabinetId(7L);
-        candidate.setCabinetCode("CABINET-001");
-        candidate.setMacAddress("AA:BB:CC:DD:EE:FF");
-        candidate.setRegistrationStatus("DISCOVERED");
-        when(detectionCabinetMapper.selectByCabinetCode("CABINET-001")).thenReturn(candidate);
-        when(detectionCabinetMapper.selectByMacAddress("AA:BB:CC:DD:EE:FF")).thenReturn(candidate);
-        when(detectionCabinetMapper.registerDiscoveredCabinet(any(TDetectionCabinet.class))).thenReturn(1);
+        when(detectionCabinetMapper.insertDetectionCabinet(any(TDetectionCabinet.class))).thenAnswer(invocation -> {
+            invocation.getArgument(0, TDetectionCabinet.class).setCabinetId(7L);
+            return 1;
+        });
 
         int rows = detectionCabinetService.insertDetectionCabinet(detectionCabinet);
 
         ArgumentCaptor<TDetectionCabinet> captor = ArgumentCaptor.forClass(TDetectionCabinet.class);
-        verify(detectionCabinetMapper).registerDiscoveredCabinet(captor.capture());
+        verify(detectionCabinetMapper).insertDetectionCabinet(captor.capture());
         TDetectionCabinet savedCabinet = captor.getValue();
         assertEquals(1, rows);
         assertEquals(7L, savedCabinet.getCabinetId());
@@ -88,13 +92,13 @@
         assertEquals("192.168.1.10", savedCabinet.getIpAddress());
         assertEquals("AA:BB:CC:DD:EE:FF", savedCabinet.getMacAddress());
         assertEquals(new BigDecimal("120.50"), savedCabinet.getCapacity());
-        assertNull(savedCabinet.getOnlineStatus());
+        assertEquals("OFFLINE", savedCabinet.getOnlineStatus());
         assertEquals("0", savedCabinet.getStatus());
         assertNull(savedCabinet.getCurrentTest());
     }
 
     /**
-     * 验证设备选择查询不读取旧检测柜测试字段,并改由持久化通道状态聚合。
+     * 验证设备选择查询不再从 MySQL 读取发现候选。
      */
     @Test
     void discoveredCabinetSqlShouldNotReadLegacyTestColumns() throws IOException
@@ -104,17 +108,28 @@
         {
             assertNotNull(input);
             String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
-            int selectStart = mapperXml.indexOf("<select id=\"selectDiscoveredCabinetList\"");
-            int selectEnd = mapperXml.indexOf("</select>", selectStart);
-            String selectSql = mapperXml.substring(selectStart, selectEnd);
-            String activeChannelSql = sqlFragment(mapperXml, "activeCabinetChannelExists");
+            assertFalse(mapperXml.contains("selectDiscoveredCabinetList"));
+            assertFalse(mapperXml.contains("registration_status = 'DISCOVERED'"));
+        }
+    }
 
-            assertFalse(selectSql.contains("cabinet.test_status"));
-            assertFalse(selectSql.contains("cabinet.current_test"));
-            assertTrue(selectSql.contains("activeCabinetChannelExists"));
-            assertTrue(activeChannelSql.contains("t_detection_channel"));
-            assertTrue(activeChannelSql.contains("work_status"));
-            assertTrue(selectSql.contains("registration_status = 'DISCOVERED'"));
+    /**
+     * 验证删除正式检测柜只取消人工登记,不删除适配器持续维护的设备事实。
+     */
+    @Test
+    void deleteDetectionCabinetSqlShouldReturnOnlineDeviceToDiscoveryPool() throws IOException
+    {
+        try (InputStream input = getClass().getResourceAsStream(
+                "/mapper/system/TDetectionCabinetMapper.xml"))
+        {
+            assertNotNull(input);
+            String mapperXml = new String(input.readAllBytes(), StandardCharsets.UTF_8);
+            int deleteStart = mapperXml.indexOf("<update id=\"deleteDetectionCabinetById\"");
+            int deleteEnd = mapperXml.indexOf("</update>", deleteStart);
+            String deleteSql = mapperXml.substring(deleteStart, deleteEnd);
+
+            assertTrue(deleteSql.contains("set del_flag = cabinet_id"));
+            assertTrue(deleteSql.contains("registration_status = 'REGISTERED'"));
         }
     }
 
@@ -132,23 +147,24 @@
             int selectStart = mapperXml.indexOf("<sql id=\"selectDetectionCabinetVo\"");
             int selectEnd = mapperXml.indexOf("</sql>", selectStart);
             String selectSql = mapperXml.substring(selectStart, selectEnd);
-            String activeChannelSql = sqlFragment(mapperXml, "activeCabinetChannelExists");
-
             assertFalse(selectSql.contains("online_status, test_status"));
             assertFalse(selectSql.contains("current_test, status"));
-            assertTrue(selectSql.contains("activeCabinetChannelExists"));
-            assertTrue(activeChannelSql.contains("t_detection_channel"));
-            assertTrue(activeChannelSql.contains("work_status"));
             assertTrue(selectSql.contains("as test_status"));
             assertTrue(selectSql.contains("as current_test"));
+            String activeTestSql = sqlFragment(mapperXml, "activeCabinetTestExists");
+            assertTrue(activeTestSql.contains("t_test_channel_binding"));
+            assertTrue(activeTestSql.contains("t_detection_channel"));
+            assertTrue(activeTestSql.contains("test_fact.test_status"));
+            assertFalse(activeTestSql.contains("t_test_battery"));
+            assertFalse(activeTestSql.contains("battery_fact.cabinet_id"));
             int listStart = mapperXml.indexOf("<select id=\"selectDetectionCabinetList\"");
             int listEnd = mapperXml.indexOf("</select>", listStart);
             assertTrue(mapperXml.substring(listStart, listEnd).contains("registration_status = 'REGISTERED'"));
-            int registerStart = mapperXml.indexOf("<update id=\"registerDiscoveredCabinet\"");
-            int registerEnd = mapperXml.indexOf("</update>", registerStart);
+            int registerStart = mapperXml.indexOf("<insert id=\"insertDetectionCabinet\"");
+            int registerEnd = mapperXml.indexOf("</insert>", registerStart);
             String registerSql = mapperXml.substring(registerStart, registerEnd);
-            assertTrue(registerSql.contains("registration_status = 'REGISTERED'"));
-            assertTrue(registerSql.contains("create_by = #{createBy}"));
+            assertTrue(registerSql.contains("'REGISTERED'"));
+            assertTrue(registerSql.contains("#{createBy}"));
         }
     }
 
@@ -159,24 +175,25 @@
         return mapperXml.substring(start, end);
     }
 
+    private CabinetCandidate candidate(String deviceCode, boolean online, DeviceWorkStatus workStatus)
+    {
+        java.time.Instant observedAt = java.time.Instant.now();
+        List<ChannelCandidate> channels = java.util.stream.IntStream.rangeClosed(1, 8)
+                .mapToObj(channelId -> new ChannelCandidate(
+                        new ChannelIdentity("dev-sim", 22, 1, 1, channelId), online, workStatus, observedAt))
+                .toList();
+        return new CabinetCandidate("dev-sim", null, deviceCode, 22, 1, "192.168.1.10",
+                "AA:BB:CC:DD:EE:FF", "一号模拟柜", "开发模拟器", "BTS Virtual Cabinet",
+                channels, observedAt, observedAt.plusSeconds(60));
+    }
+
     /**
-     * 验证设备选择列表复用已持久化检测柜,并保持原前端字段和状态文本。
+     * 验证设备选择列表读取 Redis 候选,并保持原前端字段和状态文本。
      */
     @Test
     void selectDiscoveredCabinetListShouldMapPersistedCabinetFacts()
     {
-        TDetectionCabinet persisted = new TDetectionCabinet();
-        persisted.setCabinetCode("CABINET-001");
-        persisted.setIpAddress("192.168.1.10");
-        persisted.setMacAddress("AA:BB:CC:DD:EE:FF");
-        persisted.setCabinetName("一号模拟柜");
-        persisted.setManufacturer("开发模拟器");
-        persisted.setModel("BTS Virtual Cabinet");
-        persisted.setChannelCount(6);
-        persisted.setOnlineStatus("ONLINE");
-        persisted.setObservedWorkStatus("ACTIVE");
-        when(detectionCabinetMapper.selectDiscoveredCabinetList())
-                .thenReturn(List.of(persisted));
+        when(detectionCabinetMapper.selectRegisteredCabinetCodes()).thenReturn(List.of());
 
         List<DiscoveredCabinetVo> devices = detectionCabinetService.selectDiscoveredCabinetList();
 
@@ -190,7 +207,7 @@
         assertEquals("一号模拟柜", devices.get(0).getName());
         assertEquals("开发模拟器", devices.get(0).getManufacturer());
         assertEquals("BTS Virtual Cabinet", devices.get(0).getModel());
-        assertEquals(6, devices.get(0).getChannelCount());
+        assertEquals(8, devices.get(0).getChannelCount());
     }
 
     /**
@@ -208,34 +225,40 @@
                 () -> detectionCabinetService.insertDetectionCabinet(detectionCabinet));
 
         assertEquals("设备编码已存在", exception.getMessage());
-        verify(detectionCabinetMapper, never()).registerDiscoveredCabinet(any(TDetectionCabinet.class));
+        verify(detectionCabinetMapper, never()).insertDetectionCabinet(any(TDetectionCabinet.class));
     }
 
     @Test
     void insertDetectionCabinetShouldRequireDiscoveredCandidate()
     {
-        when(detectionCabinetMapper.selectByCabinetCode("CABINET-001")).thenReturn(null);
+        detectionCabinetService = new TDetectionCabinetServiceImpl(detectionCabinetMapper,
+                new InMemoryDeviceDiscoveryCandidateStore());
 
         ServiceException exception = assertThrows(ServiceException.class,
                 () -> detectionCabinetService.insertDetectionCabinet(detectionCabinet));
 
-        assertEquals("请先选择发现候选设备", exception.getMessage());
-        verify(detectionCabinetMapper, never()).registerDiscoveredCabinet(any());
+        assertEquals("请先选择在线的发现候选设备", exception.getMessage());
+        verify(detectionCabinetMapper, never()).insertDetectionCabinet(any());
     }
 
-    /**
-     * 验证非法 IP 地址会阻止新增。
-     */
     @Test
-    void insertDetectionCabinetShouldRejectInvalidIpAddress()
+    void insertDetectionCabinetShouldValidateAuthoritativeCandidateTopology()
     {
         detectionCabinet.setIpAddress("invalid-ip");
+        detectionCabinet.setMacAddress("invalid-mac");
+        detectionCabinet.setChannelCount(99);
+        when(detectionCabinetMapper.insertDetectionCabinet(any(TDetectionCabinet.class))).thenAnswer(invocation -> {
+            invocation.getArgument(0, TDetectionCabinet.class).setCabinetId(7L);
+            return 1;
+        });
 
-        ServiceException exception = assertThrows(ServiceException.class,
-                () -> detectionCabinetService.insertDetectionCabinet(detectionCabinet));
+        assertEquals(1, detectionCabinetService.insertDetectionCabinet(detectionCabinet));
 
-        assertEquals("IP地址格式不正确", exception.getMessage());
-        verify(detectionCabinetMapper, never()).registerDiscoveredCabinet(any(TDetectionCabinet.class));
+        ArgumentCaptor<TDetectionCabinet> captor = ArgumentCaptor.forClass(TDetectionCabinet.class);
+        verify(detectionCabinetMapper).insertDetectionCabinet(captor.capture());
+        assertEquals("192.168.1.10", captor.getValue().getIpAddress());
+        assertEquals("AA:BB:CC:DD:EE:FF", captor.getValue().getMacAddress());
+        assertEquals(8, captor.getValue().getChannelCount());
     }
 
     /**
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 8d60d0b..518fba3 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
@@ -4,14 +4,21 @@
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import java.lang.reflect.Method;
 import java.net.InetAddress;
+import java.time.Instant;
+import java.util.List;
 import org.junit.jupiter.api.Test;
 import org.springframework.mock.web.MockHttpServletRequest;
 import org.springframework.mock.web.MockHttpServletResponse;
 import org.springframework.web.bind.annotation.GetMapping;
 import com.ruoyi.common.annotation.Anonymous;
 import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
+import com.ruoyi.device.model.ChannelIdentity;
 import com.ruoyi.device.model.DeviceWorkStatus;
 import com.ruoyi.web.config.BtsDevelopmentSimulatorWebConfig;
 
@@ -22,7 +29,7 @@
     {
         try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
         {
-            BtsDevelopmentSimulatorController controller = new BtsDevelopmentSimulatorController(simulator);
+            BtsDevelopmentSimulatorController controller = controller(simulator);
 
             controller.updateDevice(1,
                     new BtsDevelopmentSimulatorController.DeviceStateCommand("127.0.0.2", 4, false));
@@ -40,7 +47,7 @@
     {
         try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
         {
-            BtsDevelopmentSimulatorController controller = new BtsDevelopmentSimulatorController(simulator);
+            BtsDevelopmentSimulatorController controller = controller(simulator);
 
             var created = controller.createDevice(
                     new BtsDevelopmentSimulatorController.CreateDeviceCommand("127.0.0.8", 6));
@@ -57,6 +64,49 @@
 
             controller.deleteDevice(deviceId);
             assertEquals(1, controller.state().devices().size());
+        }
+    }
+
+    @Test
+    void controllerConfiguresCollectorsSeparatelyFromCabinets() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            BtsDevelopmentSimulatorController controller = controller(simulator);
+            var created = controller.createCollector(
+                    new BtsDevelopmentSimulatorController.CreateCollectorCommand("127.0.1.8", 6));
+            int collectorId = created.collectors().stream()
+                    .filter(collector -> collector.ip().equals("127.0.1.8"))
+                    .findFirst().orElseThrow().collectorId();
+
+            controller.updateCollector(collectorId,
+                    new BtsDevelopmentSimulatorController.CollectorStateCommand("127.0.1.9", 3, false));
+
+            assertEquals(1, controller.state().devices().size());
+            assertEquals(2, controller.state().collectors().size());
+            assertFalse(controller.state().collectors().stream()
+                    .filter(collector -> collector.collectorId() == collectorId)
+                    .findFirst().orElseThrow().online());
+
+            controller.deleteCollector(collectorId);
+            assertEquals(1, controller.state().collectors().size());
+            assertEquals(1, controller.state().devices().size());
+        }
+    }
+
+    @Test
+    void virtualIpMustBeUniqueAcrossCabinetsAndCollectors() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            BtsDevelopmentSimulatorController controller = controller(simulator);
+
+            assertThrows(org.springframework.web.server.ResponseStatusException.class,
+                    () -> controller.createCollector(
+                            new BtsDevelopmentSimulatorController.CreateCollectorCommand("127.0.0.2", 4)));
+            assertThrows(org.springframework.web.server.ResponseStatusException.class,
+                    () -> controller.createDevice(
+                            new BtsDevelopmentSimulatorController.CreateDeviceCommand("127.0.1.2", 4)));
         }
     }
 
@@ -85,6 +135,31 @@
         assertTrue(BtsDevelopmentSimulatorController.class.isAnnotationPresent(Anonymous.class));
     }
 
+    @Test
+    void resettingSimulatorRemovesExistingDevelopmentCandidates() throws Exception
+    {
+        Instant now = Instant.parse("2026-08-27T00:00:00Z");
+        InMemoryDeviceDiscoveryCandidateStore candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
+        candidateStore.recordCabinets(List.of(new CabinetCandidate("dev-sim", null, "dev-sim-22-8", 22, 8,
+                "127.0.0.8", null, "旧模拟柜", null, null,
+                List.of(new ChannelCandidate(new ChannelIdentity("dev-sim", 22, 8, 1, 1), true,
+                        DeviceWorkStatus.STOPPED, now)), now, now.plusSeconds(30))));
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0))
+        {
+            BtsDevelopmentSimulatorController controller =
+                    new BtsDevelopmentSimulatorController(simulator, candidateStore);
+
+            controller.reset();
+
+            assertEquals(List.of(), candidateStore.snapshot(now).cabinets());
+        }
+    }
+
+    private BtsDevelopmentSimulatorController controller(BtsDevelopmentSimulator simulator)
+    {
+        return new BtsDevelopmentSimulatorController(simulator, new InMemoryDeviceDiscoveryCandidateStore());
+    }
+
     private MockHttpServletRequest requestFrom(String remoteAddress)
     {
         MockHttpServletRequest request = new MockHttpServletRequest("GET", "/dev/bts-simulator/state");
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) { }
+
 }
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/discovery/DeviceDiscoveryCandidateStore.java b/ruoyi-device/src/main/java/com/ruoyi/device/discovery/DeviceDiscoveryCandidateStore.java
new file mode 100644
index 0000000..93bbc2a
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/discovery/DeviceDiscoveryCandidateStore.java
@@ -0,0 +1,59 @@
+package com.ruoyi.device.discovery;
+
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DeviceWorkStatus;
+
+/** 保存未登记发现候选和正式设备的实时观测快照。 */
+public interface DeviceDiscoveryCandidateStore
+{
+    /** upsert 本轮检测柜观测,未返回设备保留到逻辑过期。 */
+    void recordCabinets(List<CabinetCandidate> candidates);
+
+    /** 替换指定适配器的采集器观测,使已删除模拟采集器立即消失。 */
+    void replaceCollectors(String adapterCode, List<CollectorCandidate> candidates);
+
+    /** 删除指定适配器的全部发现候选;仅供人工重置或实机联调清理。 */
+    void removeAdapter(String adapterCode);
+
+    /** 返回指定时刻尚未过期的候选和实时状态。 */
+    CandidateSnapshot snapshot(Instant now);
+
+    /** 按设备编码查询指定时刻未过期的检测柜观测;在线和登记状态由业务层校验。 */
+    default Optional<CabinetCandidate> findCabinet(String deviceCode, Instant now)
+    {
+        return snapshot(now).cabinets().stream().filter(item -> item.deviceCode().equals(deviceCode)).findFirst();
+    }
+
+    /** 按设备编码查询指定时刻未过期的采集器观测;在线和登记状态由业务层校验。 */
+    default Optional<CollectorCandidate> findCollector(String deviceCode, Instant now)
+    {
+        return snapshot(now).collectors().stream().filter(item -> item.deviceCode().equals(deviceCode)).findFirst();
+    }
+
+    record CandidateSnapshot(List<CabinetCandidate> cabinets, List<CollectorCandidate> collectors)
+    {
+        public CandidateSnapshot
+        {
+            cabinets = List.copyOf(cabinets);
+            collectors = List.copyOf(collectors);
+        }
+    }
+
+    record CabinetCandidate(String adapterCode, Long adapterId, String deviceCode, int devtype, int devid,
+            String ip, String mac, String name, String manufacturer, String model,
+            List<ChannelCandidate> channels, Instant observedAt, Instant expiresAt)
+    {
+        public CabinetCandidate { channels = List.copyOf(channels); }
+        public boolean online() { return channels.stream().anyMatch(ChannelCandidate::online); }
+    }
+
+    record ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
+            Instant observedAt) { }
+
+    record CollectorCandidate(String adapterCode, String deviceCode, String ip, String mac, String name,
+            String manufacturer, String model, int channelCount, boolean online,
+            Instant observedAt, Instant expiresAt) { }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/discovery/InMemoryDeviceDiscoveryCandidateStore.java b/ruoyi-device/src/main/java/com/ruoyi/device/discovery/InMemoryDeviceDiscoveryCandidateStore.java
new file mode 100644
index 0000000..1b31fe5
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/discovery/InMemoryDeviceDiscoveryCandidateStore.java
@@ -0,0 +1,41 @@
+package com.ruoyi.device.discovery;
+
+import java.time.Instant;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/** 测试使用的候选存储,实现与 Redis 适配器相同的逻辑过期语义。 */
+public final class InMemoryDeviceDiscoveryCandidateStore implements DeviceDiscoveryCandidateStore
+{
+    private final Map<String, CabinetCandidate> cabinets = new LinkedHashMap<>();
+    private final Map<String, CollectorCandidate> collectors = new LinkedHashMap<>();
+
+    @Override
+    public synchronized void recordCabinets(List<CabinetCandidate> candidates)
+    {
+        candidates.forEach(item -> cabinets.put(item.deviceCode(), item));
+    }
+
+    @Override
+    public synchronized void replaceCollectors(String adapterCode, List<CollectorCandidate> candidates)
+    {
+        collectors.values().removeIf(item -> item.adapterCode().equals(adapterCode));
+        candidates.forEach(item -> collectors.put(item.deviceCode(), item));
+    }
+
+    @Override
+    public synchronized void removeAdapter(String adapterCode)
+    {
+        cabinets.values().removeIf(item -> item.adapterCode().equals(adapterCode));
+        collectors.values().removeIf(item -> item.adapterCode().equals(adapterCode));
+    }
+
+    @Override
+    public synchronized CandidateSnapshot snapshot(Instant now)
+    {
+        cabinets.values().removeIf(item -> !item.expiresAt().isAfter(now));
+        collectors.values().removeIf(item -> !item.expiresAt().isAfter(now));
+        return new CandidateSnapshot(List.copyOf(cabinets.values()), List.copyOf(collectors.values()));
+    }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java b/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
index bda7ccf..29152b3 100644
--- a/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
@@ -24,7 +24,7 @@
         });
     }
 
-    /** 按持久化配置注册、替换或移除运行时实例。 */
+    /** 按数据库或代码配置注册、替换或移除运行时实例。 */
     public void synchronize(AdapterInstanceConfig config)
     {
         Objects.requireNonNull(config, "config");
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
new file mode 100644
index 0000000..c1525db
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
@@ -0,0 +1,53 @@
+package com.ruoyi.device.simulator;
+
+import java.time.Instant;
+import java.util.List;
+import com.ruoyi.device.model.DeviceWorkStatus;
+
+/** 持久化开发模拟器配置;实现必须在返回前确认写入成功。 */
+public interface BtsSimulatorStateStore
+{
+    int CURRENT_VERSION = 1;
+
+    /** 读取已有状态;仅在状态不存在时原子写入并返回默认状态。 */
+    SimulatorState loadOrInitialize(SimulatorState defaultState);
+
+    /** 完整保存下一版模拟器状态。 */
+    void save(SimulatorState state);
+
+    /** 读取短期保留的最近请求。 */
+    List<RecentRequest> loadRecentRequests();
+
+    /** 保存短期保留的最近请求。 */
+    void saveRecentRequests(List<RecentRequest> requests);
+
+    /** 人工重置主状态和最近请求。 */
+    void reset(SimulatorState defaultState);
+
+    record SimulatorState(int version, List<DeviceState> devices, List<CollectorState> collectors,
+            int nextDeviceId, int nextCollectorId, List<ChannelTombstone> tombstones)
+    {
+        public SimulatorState
+        {
+            if (version != CURRENT_VERSION)
+                throw new IllegalStateException("Unsupported BTS simulator state version: " + version);
+            devices = List.copyOf(devices);
+            collectors = List.copyOf(collectors);
+            tombstones = List.copyOf(tombstones);
+        }
+    }
+
+    record DeviceState(int deviceId, String ip, boolean online, List<ChannelState> channels)
+    {
+        public DeviceState { channels = List.copyOf(channels); }
+    }
+
+    record CollectorState(int collectorId, String deviceCode, String ip, String mac, String name,
+            String manufacturer, String model, int channelCount, boolean online) { }
+
+    record ChannelState(int channelId, boolean online, DeviceWorkStatus workStatus) { }
+
+    record ChannelTombstone(int deviceId, String ip, int channelId, int remainingDiscoveries) { }
+
+    record RecentRequest(Instant receivedAt, String command, String request) { }
+}
diff --git a/ruoyi-device/src/main/java/com/ruoyi/device/simulator/InMemoryBtsSimulatorStateStore.java b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/InMemoryBtsSimulatorStateStore.java
new file mode 100644
index 0000000..780ffe3
--- /dev/null
+++ b/ruoyi-device/src/main/java/com/ruoyi/device/simulator/InMemoryBtsSimulatorStateStore.java
@@ -0,0 +1,51 @@
+package com.ruoyi.device.simulator;
+
+import java.util.List;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.RecentRequest;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
+
+/** 测试和独立协议合同使用的内存状态存储。 */
+public final class InMemoryBtsSimulatorStateStore implements BtsSimulatorStateStore
+{
+    private SimulatorState state;
+    private List<RecentRequest> recentRequests = List.of();
+
+    public InMemoryBtsSimulatorStateStore() { }
+
+    public InMemoryBtsSimulatorStateStore(SimulatorState state)
+    {
+        this.state = state;
+    }
+
+    @Override
+    public synchronized SimulatorState loadOrInitialize(SimulatorState defaultState)
+    {
+        if (state == null) state = defaultState;
+        return state;
+    }
+
+    @Override
+    public synchronized void save(SimulatorState state)
+    {
+        this.state = state;
+    }
+
+    @Override
+    public synchronized List<RecentRequest> loadRecentRequests()
+    {
+        return recentRequests;
+    }
+
+    @Override
+    public synchronized void saveRecentRequests(List<RecentRequest> requests)
+    {
+        recentRequests = List.copyOf(requests);
+    }
+
+    @Override
+    public synchronized void reset(SimulatorState defaultState)
+    {
+        state = defaultState;
+        recentRequests = List.of();
+    }
+}
diff --git a/ruoyi-device/src/test/java/com/ruoyi/device/contract/DiscoveryCandidateStoreContractTest.java b/ruoyi-device/src/test/java/com/ruoyi/device/contract/DiscoveryCandidateStoreContractTest.java
new file mode 100644
index 0000000..721696a
--- /dev/null
+++ b/ruoyi-device/src/test/java/com/ruoyi/device/contract/DiscoveryCandidateStoreContractTest.java
@@ -0,0 +1,51 @@
+package com.ruoyi.device.contract;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import java.time.Instant;
+import java.util.List;
+import org.junit.jupiter.api.Test;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
+import com.ruoyi.device.discovery.InMemoryDeviceDiscoveryCandidateStore;
+import com.ruoyi.device.model.ChannelIdentity;
+import com.ruoyi.device.model.DeviceWorkStatus;
+
+class DiscoveryCandidateStoreContractTest
+{
+    @Test
+    void returnsOnlyLogicallyUnexpiredCandidates()
+    {
+        Instant now = Instant.parse("2026-08-27T00:00:00Z");
+        InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
+        store.recordCabinets(List.of(candidate("expired", now.minusSeconds(1)),
+                candidate("current", now.plusSeconds(1))));
+
+        assertEquals(List.of("current"), store.snapshot(now).cabinets().stream()
+                .map(CabinetCandidate::deviceCode).toList());
+    }
+
+    @Test
+    void removesAllCandidateTypesForOneAdapter()
+    {
+        Instant now = Instant.parse("2026-08-27T00:00:00Z");
+        InMemoryDeviceDiscoveryCandidateStore store = new InMemoryDeviceDiscoveryCandidateStore();
+        store.recordCabinets(List.of(candidate("dev-sim-cabinet", now.plusSeconds(30))));
+        store.replaceCollectors("dev-sim", List.of(new CollectorCandidate("dev-sim", "dev-sim-collector-1",
+                "127.0.1.2", null, "采集器", null, null, 4, true, now, now.plusSeconds(30))));
+
+        store.removeAdapter("dev-sim");
+
+        assertEquals(List.of(), store.snapshot(now).cabinets());
+        assertEquals(List.of(), store.snapshot(now).collectors());
+    }
+
+    private CabinetCandidate candidate(String code, Instant expiresAt)
+    {
+        Instant observedAt = Instant.parse("2026-08-26T23:59:55Z");
+        ChannelIdentity identity = new ChannelIdentity("dev-sim", 22, 1, 1, 1);
+        return new CabinetCandidate("dev-sim", null, code, 22, 1, "127.0.0.2", null,
+                code, null, null, List.of(new ChannelCandidate(identity, true,
+                        DeviceWorkStatus.STOPPED, observedAt)), observedAt, expiresAt);
+    }
+}
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 85887f6..de1a699 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
@@ -1,6 +1,7 @@
 package com.ruoyi.device.contract;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
 import java.time.Duration;
 import java.time.ZoneId;
 import java.util.Set;
@@ -13,6 +14,11 @@
 import com.ruoyi.device.bts.ScriptedBtsTcpSimulator;
 import com.ruoyi.device.model.DetectedChannel;
 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.DeviceState;
+import com.ruoyi.device.simulator.BtsSimulatorStateStore.SimulatorState;
+import com.ruoyi.device.simulator.InMemoryBtsSimulatorStateStore;
 
 class SimulatorPublicContractTest
 {
@@ -137,4 +143,104 @@
             assertEquals(4, adapter.discoverChannels().size());
         }
     }
+
+    @Test
+    void developmentCollectorsNeverEnterBtsCabinetDiscovery() throws Exception
+    {
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0);
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", simulator.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
+                     new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            assertEquals(4, adapter.discoverChannels().size());
+
+            simulator.addCollector("127.0.1.8", 12);
+
+            assertEquals(4, adapter.discoverChannels().size());
+            assertEquals(2, simulator.snapshot().collectors().size());
+        }
+    }
+
+    @Test
+    void developmentSimulatorRestoresConfigurationNumberingAndStatus() throws Exception
+    {
+        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
+        try (BtsDevelopmentSimulator first = new BtsDevelopmentSimulator(0, store))
+        {
+            first.updateDevice(1, "127.0.0.9", 2, false);
+            first.addDevice("127.0.0.10", 3);
+            first.updateCollector(1, "127.0.1.9", 7, false);
+        }
+
+        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store))
+        {
+            assertEquals(2, restored.snapshot().devices().size());
+            assertEquals("127.0.0.9", restored.snapshot().devices().get(0).ip());
+            assertEquals(false, restored.snapshot().devices().get(0).online());
+            assertEquals(7, restored.snapshot().collectors().get(0).channelCount());
+            assertEquals(3, restored.addDevice("127.0.0.11", 1));
+        }
+    }
+
+    @Test
+    void developmentSimulatorDoesNotExposeWriteThroughFailureInMemory() throws Exception
+    {
+        InMemoryBtsSimulatorStateStore delegate = new InMemoryBtsSimulatorStateStore();
+        BtsSimulatorStateStore failing = new BtsSimulatorStateStore()
+        {
+            @Override public SimulatorState loadOrInitialize(SimulatorState value)
+            { return delegate.loadOrInitialize(value); }
+            @Override public void save(SimulatorState value) { throw new IllegalStateException("redis unavailable"); }
+            @Override public java.util.List<RecentRequest> loadRecentRequests()
+            { return delegate.loadRecentRequests(); }
+            @Override public void saveRecentRequests(java.util.List<RecentRequest> requests)
+            { delegate.saveRecentRequests(requests); }
+            @Override public void reset(SimulatorState value) { delegate.reset(value); }
+        };
+
+        try (BtsDevelopmentSimulator simulator = new BtsDevelopmentSimulator(0, failing))
+        {
+            assertThrows(IllegalStateException.class, () -> simulator.addDevice("127.0.0.3", 2));
+            assertEquals(1, simulator.snapshot().devices().size());
+        }
+    }
+
+    @Test
+    void developmentSimulatorRestoresUnfinishedTombstones() throws Exception
+    {
+        InMemoryBtsSimulatorStateStore store = new InMemoryBtsSimulatorStateStore();
+        try (BtsDevelopmentSimulator first = new BtsDevelopmentSimulator(0, store);
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", first.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
+                     new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            first.deleteDevice(1);
+            assertEquals(4, adapter.discoverChannels().size());
+        }
+
+        try (BtsDevelopmentSimulator restored = new BtsDevelopmentSimulator(0, store);
+             BtsTcpTransport transport = new BtsTcpTransport("127.0.0.1", restored.port(),
+                     Duration.ofSeconds(1), Duration.ofSeconds(1));
+             DeviceAdapter adapter = new BtsDeviceAdapter("dev-sim", transport,
+                     new BtsXmlCodec("dev-sim", ZoneId.of("Asia/Hong_Kong"))))
+        {
+            assertEquals(4, adapter.discoverChannels().size());
+            assertEquals(4, adapter.discoverChannels().size());
+            assertEquals(0, adapter.discoverChannels().size());
+        }
+    }
+
+    @Test
+    void developmentSimulatorRejectsNonContiguousRestoredChannelTopology()
+    {
+        SimulatorState damaged = new SimulatorState(BtsSimulatorStateStore.CURRENT_VERSION,
+                java.util.List.of(new DeviceState(1, "127.0.0.2", true,
+                        java.util.List.of(new ChannelState(2, true, DeviceWorkStatus.STOPPED)))),
+                java.util.List.of(), 2, 1, java.util.List.of());
+
+        assertThrows(IllegalStateException.class,
+                () -> new BtsDevelopmentSimulator(0, new InMemoryBtsSimulatorStateStore(damaged)));
+    }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
index a7c6d26..82b9a37 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java
@@ -26,13 +26,13 @@
 
     /** 所属设备适配器实例。 */
     private Long adapterId;
+    private String adapterCode;
 
     private Integer vendorDeviceType;
     private Integer vendorDeviceId;
     private Instant lastSeenAtUtc;
-    private String observedWorkStatus;
 
-    /** 登记状态:DISCOVERED 发现候选,REGISTERED 已登记。 */
+    /** 登记状态:新记录只使用 REGISTERED;DISCOVERED 仅兼容有业务引用的历史记录。 */
     private String registrationStatus;
 
     /** 全局唯一的设备编码。 */
@@ -104,14 +104,14 @@
 
     public Long getAdapterId() { return adapterId; }
     public void setAdapterId(Long adapterId) { this.adapterId = adapterId; }
+    public String getAdapterCode() { return adapterCode; }
+    public void setAdapterCode(String adapterCode) { this.adapterCode = adapterCode; }
     public Integer getVendorDeviceType() { return vendorDeviceType; }
     public void setVendorDeviceType(Integer value) { vendorDeviceType = value; }
     public Integer getVendorDeviceId() { return vendorDeviceId; }
     public void setVendorDeviceId(Integer value) { vendorDeviceId = value; }
     public Instant getLastSeenAtUtc() { return lastSeenAtUtc; }
     public void setLastSeenAtUtc(Instant value) { lastSeenAtUtc = value; }
-    public String getObservedWorkStatus() { return observedWorkStatus; }
-    public void setObservedWorkStatus(String value) { observedWorkStatus = value; }
     public String getRegistrationStatus() { return registrationStatus; }
     public void setRegistrationStatus(String value) { registrationStatus = value; }
 
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java
index c0096bb..24546af 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java
@@ -10,6 +10,7 @@
 
     private Long channelId;
     private Long adapterId;
+    private String adapterCode;
     private Long cabinetId;
     private Integer devtype;
     private Integer devid;
@@ -25,6 +26,8 @@
     public void setChannelId(Long value) { channelId = value; }
     public Long getAdapterId() { return adapterId; }
     public void setAdapterId(Long value) { adapterId = value; }
+    public String getAdapterCode() { return adapterCode; }
+    public void setAdapterCode(String value) { adapterCode = value; }
     public Long getCabinetId() { return cabinetId; }
     public void setCabinetId(Long value) { cabinetId = value; }
     public Integer getDevtype() { return devtype; }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java
index 39a67fc..7dc352b 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java
@@ -2,7 +2,7 @@
 
 import java.time.Instant;
 
-/** 单次设备发现的持久化结果。 */
+/** 单次设备发现写入 Redis 候选后的结果。 */
 public record AdapterDiscoveryResult(int cabinetCount, int channelCount, Instant observedAtUtc)
 {
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCollectorVo.java b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCollectorVo.java
index 011a093..004f6bc 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCollectorVo.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCollectorVo.java
@@ -14,6 +14,18 @@
     /** MAC 地址 */
     private String mac;
 
+    /** 模拟器提供的采集器名称 */
+    private String name;
+
+    /** 制造商 */
+    private String manufacturer;
+
+    /** 型号 */
+    private String model;
+
+    /** 通道数 */
+    private Integer channelCount;
+
     /** 在线状态 */
     private String onlineStatus;
 
@@ -27,12 +39,16 @@
     {
     }
 
-    public DiscoveredCollectorVo(String deviceCode, String ip, String mac, String onlineStatus,
-            String testStatus, String currentTest)
+    public DiscoveredCollectorVo(String deviceCode, String ip, String mac, String name, String manufacturer,
+            String model, Integer channelCount, String onlineStatus, String testStatus, String currentTest)
     {
         this.deviceCode = deviceCode;
         this.ip = ip;
         this.mac = mac;
+        this.name = name;
+        this.manufacturer = manufacturer;
+        this.model = model;
+        this.channelCount = channelCount;
         this.onlineStatus = onlineStatus;
         this.testStatus = testStatus;
         this.currentTest = currentTest;
@@ -68,6 +84,46 @@
         this.mac = mac;
     }
 
+    public String getName()
+    {
+        return name;
+    }
+
+    public void setName(String name)
+    {
+        this.name = name;
+    }
+
+    public String getManufacturer()
+    {
+        return manufacturer;
+    }
+
+    public void setManufacturer(String manufacturer)
+    {
+        this.manufacturer = manufacturer;
+    }
+
+    public String getModel()
+    {
+        return model;
+    }
+
+    public void setModel(String model)
+    {
+        this.model = model;
+    }
+
+    public Integer getChannelCount()
+    {
+        return channelCount;
+    }
+
+    public void setChannelCount(Integer channelCount)
+    {
+        this.channelCount = channelCount;
+    }
+
     public String getOnlineStatus()
     {
         return onlineStatus;
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TCollectorMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TCollectorMapper.java
index 0fa7908..2ea8c9b 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TCollectorMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TCollectorMapper.java
@@ -17,6 +17,9 @@
     /** 查询采集器列表。 */
     List<TCollector> selectCollectorList(TCollector collector);
 
+    /** 查询全部未删除采集器的设备编码。 */
+    List<String> selectRegisteredCollectorCodes();
+
     /** 根据设备编码查询未删除采集器。 */
     TCollector selectCollectorByCode(String collectorCode);
 
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java
index 7f5409d..a53f24e 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java
@@ -30,9 +30,9 @@
     /**
      * 查询设备选择弹窗需要的已发现检测柜事实。
      *
-     * @return 已发现且未删除的检测柜
+     * @return 已发现、在线且未登记的检测柜
      */
-    List<TDetectionCabinet> selectDiscoveredCabinetList();
+    List<String> selectRegisteredCabinetCodes();
 
     /**
      * 根据设备编码查询未删除检测柜。
@@ -56,7 +56,13 @@
      * @param detectionCabinet 候选主键及操作员填写的信息
      * @return 影响行数
      */
-    int registerDiscoveredCabinet(TDetectionCabinet detectionCabinet);
+    int insertDetectionCabinet(TDetectionCabinet detectionCabinet);
+
+    /** 写入正式登记检测柜的稳定通道身份。 */
+    int insertDetectionChannel(com.ruoyi.system.domain.TDetectionChannel channel);
+
+    /** 软删除正式检测柜对应的通道拓扑。 */
+    int deleteDetectionChannelsByCabinetId(@Param("cabinetId") Long cabinetId);
 
     /**
      * 修改检测柜基础信息。
@@ -75,7 +81,7 @@
     int updateDetectionCabinetStatus(TDetectionCabinet detectionCabinet);
 
     /**
-     * 将检测柜标记为逻辑删除。
+     * 取消检测柜登记,使仍在线的设备重新进入发现候选列表。
      *
      * @param cabinetId 检测柜主键
      * @param updateBy 更新人
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
index e553cdc..d4c4998 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java
@@ -3,8 +3,6 @@
 import java.time.Instant;
 import java.util.List;
 import org.apache.ibatis.annotations.Param;
-import com.ruoyi.system.domain.TDetectionCabinet;
-import com.ruoyi.system.domain.TDetectionChannel;
 import com.ruoyi.system.domain.TDeviceAdapter;
 
 public interface TDeviceAdapterMapper
@@ -21,10 +19,4 @@
     int updateDiscoverySuccess(@Param("adapterId") Long adapterId, @Param("observedAt") Instant observedAt);
     int updateDiscoveryFailure(@Param("adapterId") Long adapterId, @Param("errorCode") String errorCode,
             @Param("errorMessage") String errorMessage);
-    Long selectCabinetIdByIdentity(@Param("adapterId") Long adapterId,
-            @Param("devtype") Integer devtype, @Param("devid") Integer devid);
-    int insertDetectedCabinet(TDetectionCabinet cabinet);
-    int updateDetectedCabinet(TDetectionCabinet cabinet);
-    int upsertDetectedChannel(TDetectionChannel channel);
-    List<TDetectionChannel> selectDetectionChannelsByAdapterId(Long adapterId);
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
index 6be5a52..dfd24b1 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java
@@ -25,10 +25,10 @@
     /** 启用或停用设备适配器,并同步运行时生命周期。 */
     int changeStatus(Long adapterId, boolean enabled, String updateBy);
 
-    /** 执行一次发现和实时观测,并持久化本轮设备事实。 */
+    /** 执行一次发现和实时观测,并将本轮设备事实写入 Redis 候选。 */
     AdapterDiscoveryResult discover(Long adapterId);
 
-    /** 查询指定适配器已发现且未被删除的通道。 */
+    /** 查询指定适配器当前未过期的发现通道。 */
     List<TDetectionChannel> selectChannels(Long adapterId);
 
     /** 启动时恢复所有已启用适配器的运行时实例。 */
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITCollectorService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITCollectorService.java
index e137a51..55f6d0c 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITCollectorService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITCollectorService.java
@@ -1,6 +1,7 @@
 package com.ruoyi.system.service;
 
 import java.util.List;
+import java.util.Set;
 import com.ruoyi.system.domain.TCollector;
 
 /**
@@ -25,6 +26,9 @@
     /** 启用或禁用采集器。 */
     int updateCollectorStatus(TCollector collector);
 
+    /** 查询全部未删除采集器的设备编码。 */
+    Set<String> selectRegisteredCollectorCodes();
+
     /** 批量逻辑删除采集器。 */
     int deleteCollectorByIds(Long[] collectorIds, String updateBy);
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java
index a58babd..2fc46fb 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java
@@ -28,7 +28,7 @@
     List<TDetectionCabinet> selectDetectionCabinetList(TDetectionCabinet detectionCabinet);
 
     /**
-     * 将已持久化的检测柜转换为原设备选择弹窗的数据合同。
+     * 查询 Redis 中在线、未过期且尚未正式登记的检测柜候选。
      *
      * @return 可供选择的检测柜设备摘要
      */
@@ -59,7 +59,7 @@
     int updateDetectionCabinetStatus(TDetectionCabinet detectionCabinet);
 
     /**
-     * 批量逻辑删除检测柜,测试中的检测柜不允许删除。
+     * 批量取消检测柜登记,测试中的检测柜不允许操作。
      *
      * @param cabinetIds 检测柜主键数组
      * @param updateBy 更新人
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImpl.java
deleted file mode 100644
index ed46690..0000000
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImpl.java
+++ /dev/null
@@ -1,34 +0,0 @@
-package com.ruoyi.system.service.impl;
-
-import java.util.ArrayList;
-import java.util.Arrays;
-import java.util.List;
-import org.springframework.stereotype.Service;
-import com.ruoyi.system.domain.vo.DiscoveredCollectorVo;
-import com.ruoyi.system.service.ICollectorDiscoveryService;
-
-/**
- * 采集器设备发现服务实现。
- *
- * 当前返回测试数据;硬件接入后在此处替换为实时在线设备查询。
- */
-@Service
-public class CollectorDiscoveryServiceImpl implements ICollectorDiscoveryService
-{
-    private static final List<DiscoveredCollectorVo> TEST_DEVICES = Arrays.asList(
-            new DiscoveredCollectorVo("45643176", "192.1.1.162", "00:1A:2B:3C:4D:01", "在线", "空闲", "-"),
-            new DiscoveredCollectorVo("45643177", "192.1.1.163", "00:1A:2B:3C:4D:02", "在线", "测试中", "电池循环测试"),
-            new DiscoveredCollectorVo("45643178", "192.1.1.164", "00:1A:2B:3C:4D:03", "离线", "空闲", "-"),
-            new DiscoveredCollectorVo("45643179", "192.1.1.165", "00:1A:2B:3C:4D:04", "在线", "空闲", "-"),
-            new DiscoveredCollectorVo("45643180", "192.1.1.166", "00:1A:2B:3C:4D:05", "在线", "测试中", "容量测试"),
-            new DiscoveredCollectorVo("45643181", "192.1.1.167", "00:1A:2B:3C:4D:06", "在线", "空闲", "-"));
-
-    /**
-     * 返回新的列表容器,避免调用方改变测试数据集合。
-     */
-    @Override
-    public List<DiscoveredCollectorVo> selectDiscoveredCollectorList()
-    {
-        return new ArrayList<>(TEST_DEVICES);
-    }
-}
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
index cc74b8f..dd57211 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java
@@ -3,12 +3,8 @@
 import java.time.Duration;
 import java.time.Instant;
 import java.time.ZoneId;
-import java.util.LinkedHashSet;
 import java.util.List;
 import java.util.Locale;
-import java.util.Map;
-import java.util.Set;
-import java.util.stream.Collectors;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import com.alibaba.fastjson2.JSON;
@@ -16,10 +12,9 @@
 import com.ruoyi.common.exception.ServiceException;
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.device.api.DeviceAdapterException;
+import com.ruoyi.device.bts.BtsDevelopmentSimulator;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
 import com.ruoyi.device.model.AdapterCapabilities;
-import com.ruoyi.device.model.ChannelIdentity;
-import com.ruoyi.device.model.DetectedChannel;
-import com.ruoyi.device.model.RealtimeSnapshot;
 import com.ruoyi.device.registry.AdapterInstanceConfig;
 import com.ruoyi.device.registry.AdapterRegistryException;
 import com.ruoyi.device.registry.DeviceAdapterRegistry;
@@ -29,6 +24,7 @@
 import com.ruoyi.system.mapper.TDeviceAdapterMapper;
 import com.ruoyi.system.service.DeviceAdapterErrorCodes;
 import com.ruoyi.system.service.IDeviceAdapterService;
+import com.ruoyi.system.service.impl.DeviceDiscoveryPersistenceService.DiscoverySource;
 
 @Service
 public class DeviceAdapterServiceImpl implements IDeviceAdapterService
@@ -41,13 +37,15 @@
     private final TDeviceAdapterMapper mapper;
     private final DeviceAdapterRegistry registry;
     private final DeviceDiscoveryPersistenceService persistence;
+    private final DeviceDiscoveryCandidateStore candidateStore;
 
     public DeviceAdapterServiceImpl(TDeviceAdapterMapper mapper, DeviceAdapterRegistry registry,
-            DeviceDiscoveryPersistenceService persistence)
+            DeviceDiscoveryPersistenceService persistence, DeviceDiscoveryCandidateStore candidateStore)
     {
         this.mapper = mapper;
         this.registry = registry;
         this.persistence = persistence;
+        this.candidateStore = candidateStore;
     }
 
     /** 按管理端筛选条件查询适配器及其运行状态。 */
@@ -68,6 +66,7 @@
     public TDeviceAdapter ensureManagedAdapter(TDeviceAdapter adapter)
     {
         normalizeAndValidate(adapter);
+        rejectDevelopmentSimulatorDatabaseConfig(adapter);
         AdapterCapabilities capabilities = parseCapabilities(adapter.getCapabilities());
         if (capabilities.control())
             throw error("当前里程碑只允许只读设备适配器", DeviceAdapterErrorCodes.INVALID_CONFIG);
@@ -85,6 +84,7 @@
     public int insert(TDeviceAdapter adapter)
     {
         normalizeAndValidate(adapter);
+        rejectDevelopmentSimulatorDatabaseConfig(adapter);
         if (mapper.selectDeviceAdapterByCode(adapter.getAdapterCode()) != null)
             throw error("适配器编码已存在", DeviceAdapterErrorCodes.DUPLICATE_CODE);
         adapter.setCapabilities(READ_ONLY_CAPABILITIES);
@@ -125,7 +125,7 @@
         return rows;
     }
 
-    /** 执行一次发现和实时观测,并事务性写入通道事实。 */
+    /** 执行一次发现和实时观测,将未登记设备写入 Redis 候选。 */
     @Override
     public AdapterDiscoveryResult discover(Long adapterId)
     {
@@ -135,18 +135,10 @@
         try
         {
             registry.synchronize(toRuntimeConfig(adapter));
-            DiscoveryObservation observation = registry.execute(adapter.getAdapterCode(), runtime -> {
-                List<DetectedChannel> channels = runtime.discoverChannels();
-                Set<ChannelIdentity> available = channels.stream()
-                        .filter(DetectedChannel::available)
-                        .map(DetectedChannel::identity)
-                        .collect(Collectors.toCollection(LinkedHashSet::new));
-                return new DiscoveryObservation(channels, available.isEmpty() ? Map.of() : runtime.inquire(available));
-            });
-            Instant observedAt = Instant.now();
-            AdapterDiscoveryResult result = persistence.persist(adapter, observation.channels(),
-                    observation.snapshots(), observedAt);
-            mapper.updateDiscoverySuccess(adapterId, observedAt);
+            AdapterDiscoveryResult result = persistence.discover(registry, new DiscoverySource(
+                    adapter.getAdapterCode(), adapter.getAdapterId(), adapter.getAdapterName(),
+                    adapter.getPollIntervalMs()));
+            mapper.updateDiscoverySuccess(adapterId, result.observedAtUtc());
             return result;
         }
         catch (DeviceAdapterException exception)
@@ -159,12 +151,28 @@
         }
     }
 
-    /** 查询指定适配器已发现且未删除的通道。 */
+    /** 查询指定适配器当前未过期的 Redis 发现通道。 */
     @Override
     public List<TDetectionChannel> selectChannels(Long adapterId)
     {
-        selectById(adapterId);
-        return mapper.selectDetectionChannelsByAdapterId(adapterId);
+        TDeviceAdapter adapter = selectById(adapterId);
+        return candidateStore.snapshot(Instant.now()).cabinets().stream()
+                .filter(item -> item.adapterCode().equals(adapter.getAdapterCode()))
+                .flatMap(item -> item.channels().stream().map(channel -> {
+                    TDetectionChannel value = new TDetectionChannel();
+                    value.setAdapterId(adapterId);
+                    value.setDevtype(channel.identity().devtype());
+                    value.setDevid(channel.identity().devid());
+                    value.setSubdevid(channel.identity().subdevid());
+                    value.setChlid(channel.identity().chlid());
+                    value.setDisplayNo(channel.identity().subdevid() + "-" + channel.identity().chlid());
+                    value.setOnlineStatus(channel.online() ? "ONLINE" : "OFFLINE");
+                    value.setWorkStatus(channel.workStatus().name());
+                    value.setLastSeenAtUtc(channel.observedAt());
+                    value.setStatus("0");
+                    return value;
+                }))
+                .toList();
     }
 
     /** 启动时恢复所有已启用适配器的运行时注册。 */
@@ -220,6 +228,12 @@
         }
     }
 
+    private void rejectDevelopmentSimulatorDatabaseConfig(TDeviceAdapter adapter)
+    {
+        if (BtsDevelopmentSimulator.ADAPTER_CODE.equals(adapter.getAdapterCode()))
+            throw error("dev-sim 由开发环境代码固定装配,不能写入数据库", DeviceAdapterErrorCodes.INVALID_CONFIG);
+    }
+
     private AdapterInstanceConfig toRuntimeConfig(TDeviceAdapter adapter)
     {
         return new AdapterInstanceConfig(adapter.getAdapterCode(), adapter.getAdapterType(), adapter.getHost(),
@@ -237,9 +251,4 @@
     }
 
     private ServiceException error(String message, int code) { return new ServiceException(message, code); }
-
-    private record DiscoveryObservation(List<DetectedChannel> channels,
-            Map<ChannelIdentity, RealtimeSnapshot> snapshots)
-    {
-    }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
index b9e9cdf..08582ef 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java
@@ -1,114 +1,97 @@
 package com.ruoyi.system.service.impl;
 
-import java.math.BigDecimal;
+import java.time.Duration;
 import java.time.Instant;
+import java.util.LinkedHashSet;
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
-import org.springframework.dao.DuplicateKeyException;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-import com.ruoyi.device.model.DetectedChannel;
+import java.util.Set;
+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.ChannelIdentity;
+import com.ruoyi.device.model.DetectedChannel;
 import com.ruoyi.device.model.DetectedDeviceMetadata;
+import com.ruoyi.device.model.DeviceWorkStatus;
 import com.ruoyi.device.model.RealtimeSnapshot;
-import com.ruoyi.system.domain.TDetectionCabinet;
-import com.ruoyi.system.domain.TDetectionChannel;
-import com.ruoyi.system.domain.TDeviceAdapter;
+import com.ruoyi.device.registry.DeviceAdapterRegistry;
 import com.ruoyi.system.domain.vo.AdapterDiscoveryResult;
-import com.ruoyi.system.mapper.TDeviceAdapterMapper;
+import org.springframework.stereotype.Service;
 
+/** 将适配器观测写入 Redis 候选存储,不创建正式 MySQL 设备或通道。 */
 @Service
 public class DeviceDiscoveryPersistenceService
 {
-    private final TDeviceAdapterMapper mapper;
+    private static final Duration MINIMUM_CANDIDATE_LIFETIME = Duration.ofSeconds(30);
+    private final DeviceDiscoveryCandidateStore candidateStore;
 
-    public DeviceDiscoveryPersistenceService(TDeviceAdapterMapper mapper)
+    public DeviceDiscoveryPersistenceService(DeviceDiscoveryCandidateStore candidateStore)
     {
-        this.mapper = mapper;
+        this.candidateStore = candidateStore;
     }
 
-    /** 在事务内 upsert 本轮发现和实时观测;未返回通道保持原记录不变。 */
-    @Transactional(rollbackFor = Exception.class)
-    public AdapterDiscoveryResult persist(TDeviceAdapter adapter, List<DetectedChannel> detected,
+    /** 执行设备发现和可用通道查询,并将本轮观测写入 Redis 候选。 */
+    public AdapterDiscoveryResult discover(DeviceAdapterRegistry registry, DiscoverySource source)
+    {
+        DiscoveryObservation observation = registry.execute(source.adapterCode(), runtime -> {
+            List<DetectedChannel> channels = runtime.discoverChannels();
+            Set<ChannelIdentity> available = channels.stream()
+                    .filter(DetectedChannel::available)
+                    .map(DetectedChannel::identity)
+                    .collect(java.util.stream.Collectors.toCollection(LinkedHashSet::new));
+            Map<ChannelIdentity, RealtimeSnapshot> snapshots = available.isEmpty()
+                    ? Map.of() : runtime.inquire(available);
+            return new DiscoveryObservation(channels, snapshots);
+        });
+        Instant observedAt = Instant.now();
+        return persist(source, observation.channels(), observation.snapshots(), observedAt);
+    }
+
+    /** upsert 本轮发现和实时状态;未返回设备由 expiresAt 控制淘汰。 */
+    private AdapterDiscoveryResult persist(DiscoverySource source, List<DetectedChannel> detected,
             Map<ChannelIdentity, RealtimeSnapshot> snapshots, Instant observedAt)
     {
-        Map<CabinetKey, List<DetectedChannel>> cabinets = detected.stream().collect(
+        Map<CabinetKey, List<DetectedChannel>> grouped = detected.stream().collect(
                 java.util.stream.Collectors.groupingBy(
                         item -> new CabinetKey(item.identity().devtype(), item.identity().devid()),
                         LinkedHashMap::new, java.util.stream.Collectors.toList()));
-
-        for (Map.Entry<CabinetKey, List<DetectedChannel>> entry : cabinets.entrySet())
-        {
-            Long cabinetId = ensureCabinet(adapter, entry.getKey(), entry.getValue(), observedAt);
-            for (DetectedChannel item : entry.getValue())
-                mapper.upsertDetectedChannel(toChannel(adapter.getAdapterId(), cabinetId, item,
-                        snapshots.get(item.identity()), observedAt));
-        }
-        return new AdapterDiscoveryResult(cabinets.size(), detected.size(), observedAt);
+        Duration lifetime = Duration.ofMillis(Math.max(MINIMUM_CANDIDATE_LIFETIME.toMillis(),
+                (long) source.pollIntervalMs() * 3));
+        List<CabinetCandidate> candidates = grouped.entrySet().stream()
+                .map(entry -> toCandidate(source, entry.getKey(), entry.getValue(), snapshots,
+                        observedAt, observedAt.plus(lifetime)))
+                .toList();
+        candidateStore.recordCabinets(candidates);
+        return new AdapterDiscoveryResult(grouped.size(), detected.size(), observedAt);
     }
 
-    private Long ensureCabinet(TDeviceAdapter adapter, CabinetKey key, List<DetectedChannel> channels,
-            Instant observedAt)
+    private CabinetCandidate toCandidate(DiscoverySource source, CabinetKey key, List<DetectedChannel> channels,
+            Map<ChannelIdentity, RealtimeSnapshot> snapshots, Instant observedAt, Instant expiresAt)
     {
-        Long cabinetId = mapper.selectCabinetIdByIdentity(adapter.getAdapterId(), key.devtype(), key.devid());
-        TDetectionCabinet cabinet = new TDetectionCabinet();
-        cabinet.setCabinetId(cabinetId);
-        cabinet.setAdapterId(adapter.getAdapterId());
-        cabinet.setCabinetCode(adapter.getAdapterCode() + "-" + key.devtype() + "-" + key.devid());
         DetectedDeviceMetadata metadata = channels.get(0).deviceMetadata();
-        cabinet.setCabinetName(metadata.name() == null
-                ? adapter.getAdapterName() + " " + key.devtype() + "-" + key.devid()
-                : metadata.name());
-        cabinet.setManufacturer(metadata.manufacturer());
-        cabinet.setModel(metadata.model());
-        cabinet.setMacAddress(metadata.macAddress());
-        cabinet.setCapacity(BigDecimal.ONE);
-        cabinet.setChannelCount(channels.size());
-        cabinet.setVendorDeviceType(key.devtype());
-        cabinet.setVendorDeviceId(key.devid());
-        cabinet.setIpAddress(channels.stream().map(DetectedChannel::serverIp).filter(value -> value != null && !value.isBlank())
-                .findFirst().orElse(null));
-        cabinet.setOnlineStatus(channels.stream().anyMatch(DetectedChannel::available) ? "ONLINE" : "OFFLINE");
-        cabinet.setLastSeenAtUtc(observedAt);
-        if (cabinetId == null)
-        {
-            try
-            {
-                mapper.insertDetectedCabinet(cabinet);
-                return cabinet.getCabinetId();
-            }
-            catch (DuplicateKeyException concurrentInsert)
-            {
-                cabinetId = mapper.selectCabinetIdByIdentity(adapter.getAdapterId(), key.devtype(), key.devid());
-                if (cabinetId == null) throw concurrentInsert;
-                cabinet.setCabinetId(cabinetId);
-            }
-        }
-        mapper.updateDetectedCabinet(cabinet);
-        return cabinetId;
+        String name = metadata.name() == null
+                ? source.adapterName() + " " + key.devtype() + "-" + key.devid()
+                : metadata.name();
+        String ip = channels.stream().map(DetectedChannel::serverIp)
+                .filter(value -> value != null && !value.isBlank()).findFirst().orElse(null);
+        List<ChannelCandidate> channelCandidates = channels.stream().map(item -> {
+            RealtimeSnapshot snapshot = snapshots.get(item.identity());
+            DeviceWorkStatus status = snapshot == null || snapshot.workStatus() == null
+                    ? DeviceWorkStatus.UNKNOWN : snapshot.workStatus();
+            return new ChannelCandidate(item.identity(), item.available(), status, observedAt);
+        }).toList();
+        return new CabinetCandidate(source.adapterCode(), source.adapterId(),
+                source.adapterCode() + "-" + key.devtype() + "-" + key.devid(),
+                key.devtype(), key.devid(), ip, metadata.macAddress(), name,
+                metadata.manufacturer(), metadata.model(), channelCandidates, observedAt, expiresAt);
     }
 
-    private TDetectionChannel toChannel(Long adapterId, Long cabinetId, DetectedChannel detected,
-            RealtimeSnapshot snapshot, Instant observedAt)
-    {
-        TDetectionChannel channel = new TDetectionChannel();
-        channel.setAdapterId(adapterId);
-        channel.setCabinetId(cabinetId);
-        channel.setDevtype(detected.identity().devtype());
-        channel.setDevid(detected.identity().devid());
-        channel.setSubdevid(detected.identity().subdevid());
-        channel.setChlid(detected.identity().chlid());
-        channel.setDisplayNo(detected.identity().subdevid() + "-" + detected.identity().chlid());
-        channel.setOnlineStatus(detected.available() ? "ONLINE" : "OFFLINE");
-        channel.setWorkStatus(snapshot == null || snapshot.workStatus() == null
-                ? "UNKNOWN" : snapshot.workStatus().name());
-        channel.setLastSeenAtUtc(observedAt);
-        channel.setStatus("0");
-        return channel;
-    }
+    /** 发现来源只携带候选标识和过期计算所需配置,不复用数据库实体。 */
+    public record DiscoverySource(String adapterCode, Long adapterId, String adapterName, int pollIntervalMs) { }
 
-    private record CabinetKey(int devtype, int devid)
-    {
-    }
+    private record DiscoveryObservation(List<DetectedChannel> channels,
+            Map<ChannelIdentity, RealtimeSnapshot> snapshots) { }
+
+    private record CabinetKey(int devtype, int devid) { }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TCollectorServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TCollectorServiceImpl.java
index a4b5ad7..25be25c 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TCollectorServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TCollectorServiceImpl.java
@@ -3,8 +3,10 @@
 import java.net.InetAddress;
 import java.util.Date;
 import java.util.List;
+import java.util.Set;
 import java.util.regex.Pattern;
-import org.springframework.beans.factory.annotation.Autowired;
+import java.time.Instant;
+import java.util.Map;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
 import com.ruoyi.common.exception.ServiceException;
@@ -12,6 +14,8 @@
 import com.ruoyi.system.domain.TCollector;
 import com.ruoyi.system.mapper.TCollectorMapper;
 import com.ruoyi.system.service.ITCollectorService;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CollectorCandidate;
 
 /**
  * 采集器 Service 实现。
@@ -24,14 +28,21 @@
     private static final String ENABLED = "0";
     private static final String DISABLED = "1";
     private static final String OFFLINE = "0";
+    private static final String ONLINE = "1";
     private static final String IDLE = "0";
     private static final String TESTING = "1";
     private static final Pattern IPV4_PATTERN = Pattern.compile(
             "^(25[0-5]|2[0-4]\\d|1?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|1?\\d?\\d)){3}$");
     private static final Pattern MAC_PATTERN = Pattern.compile("^[0-9A-F]{2}(:[0-9A-F]{2}){5}$");
 
-    @Autowired
-    private TCollectorMapper collectorMapper;
+    private final TCollectorMapper collectorMapper;
+    private final DeviceDiscoveryCandidateStore candidateStore;
+
+    public TCollectorServiceImpl(TCollectorMapper collectorMapper, DeviceDiscoveryCandidateStore candidateStore)
+    {
+        this.collectorMapper = collectorMapper;
+        this.candidateStore = candidateStore;
+    }
 
     /**
      * 查询未删除采集器,不存在时抛出业务异常。
@@ -48,7 +59,7 @@
         {
             throw new ServiceException("采集器不存在");
         }
-        return collector;
+        return applyRealtimeState(collector, candidatesByCode());
     }
 
     /**
@@ -64,18 +75,34 @@
         collector.setDevice(trimToNull(collector.getDevice()));
         collector.setBeginCreateTime(trimToNull(collector.getBeginCreateTime()));
         collector.setEndCreateTime(trimToNull(collector.getEndCreateTime()));
-        validateOptionalStatus(collector.getOnlineStatus(), "在线状态");
+        String requestedOnlineStatus = collector.getOnlineStatus();
+        validateOptionalStatus(requestedOnlineStatus, "在线状态");
+        collector.setOnlineStatus(null);
         validateOptionalStatus(collector.getTestStatus(), "测试状态");
         validateOptionalStatus(collector.getStatus(), "状态");
-        return collectorMapper.selectCollectorList(collector);
+        Map<String, CollectorCandidate> candidates = candidatesByCode();
+        return collectorMapper.selectCollectorList(collector).stream()
+                .map(item -> applyRealtimeState(item, candidates))
+                .filter(item -> StringUtils.isEmpty(requestedOnlineStatus)
+                        || requestedOnlineStatus.equals(item.getOnlineStatus()))
+                .toList();
     }
 
     /**
      * 新增采集器并初始化运行状态。
      */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public int insertCollector(TCollector collector)
     {
+        if (collector == null) throw new ServiceException("采集器信息不能为空");
+        collector.setCollectorCode(trimToNull(collector.getCollectorCode()));
+        CollectorCandidate candidate = candidateStore.findCollector(collector.getCollectorCode(), Instant.now())
+                .filter(CollectorCandidate::online)
+                .orElseThrow(() -> new ServiceException("请先选择在线的发现候选设备"));
+        collector.setIpAddress(candidate.ip());
+        collector.setMacAddress(candidate.mac());
+        collector.setChannelCount(candidate.channelCount());
         validateCollector(collector, false);
         collector.setOnlineStatus(OFFLINE);
         collector.setTestStatus(IDLE);
@@ -132,6 +159,13 @@
         }
         collector.setUpdateTime(new Date());
         return collectorMapper.updateCollectorStatus(collector);
+    }
+
+    /** 查询全部未删除采集器的设备编码,供发现候选排除已登记设备。 */
+    @Override
+    public Set<String> selectRegisteredCollectorCodes()
+    {
+        return Set.copyOf(collectorMapper.selectRegisteredCollectorCodes());
     }
 
     /**
@@ -309,4 +343,17 @@
     {
         return StringUtils.isEmpty(StringUtils.trim(value)) ? null : StringUtils.trim(value);
     }
+
+    private Map<String, CollectorCandidate> candidatesByCode()
+    {
+        return candidateStore.snapshot(Instant.now()).collectors().stream()
+                .collect(java.util.stream.Collectors.toMap(CollectorCandidate::deviceCode, item -> item));
+    }
+
+    private TCollector applyRealtimeState(TCollector collector, Map<String, CollectorCandidate> candidates)
+    {
+        CollectorCandidate candidate = candidates.get(collector.getCollectorCode());
+        collector.setOnlineStatus(candidate != null && candidate.online() ? ONLINE : OFFLINE);
+        return collector;
+    }
 }
diff --git a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java
index 16e3491..80929e8 100644
--- a/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java
+++ b/ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java
@@ -4,6 +4,9 @@
 import java.net.InetAddress;
 import java.util.List;
 import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.time.Instant;
 import org.springframework.dao.DuplicateKeyException;
 import org.springframework.stereotype.Service;
 import org.springframework.transaction.annotation.Transactional;
@@ -12,9 +15,13 @@
 import com.ruoyi.common.utils.StringUtils;
 import com.ruoyi.common.utils.ip.IpUtils;
 import com.ruoyi.system.domain.TDetectionCabinet;
+import com.ruoyi.system.domain.TDetectionChannel;
 import com.ruoyi.system.domain.vo.DiscoveredCabinetVo;
 import com.ruoyi.system.mapper.TDetectionCabinetMapper;
 import com.ruoyi.system.service.ITDetectionCabinetService;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore;
+import com.ruoyi.device.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
+import com.ruoyi.device.model.DeviceWorkStatus;
 
 /**
  * 检测柜 Service 业务层处理
@@ -27,19 +34,26 @@
     private static final String NORMAL = "0";
     private static final String DISABLED = "1";
     private static final String TESTING = "1";
-    private static final String ACTIVE = "ACTIVE";
-    private static final String DISCOVERED = "DISCOVERED";
+    private static final String ONLINE = "0";
+    private static final String OFFLINE = "1";
+    private static final Set<DeviceWorkStatus> ACTIVE_WORK_STATUSES = Set.of(DeviceWorkStatus.WORKING,
+            DeviceWorkStatus.PAUSED, DeviceWorkStatus.PROTECTED, DeviceWorkStatus.SYNCHRONIZING,
+            DeviceWorkStatus.LIT, DeviceWorkStatus.WAITING_TIMEOUT, DeviceWorkStatus.WAITING_START,
+            DeviceWorkStatus.LOCKED);
 
     private final TDetectionCabinetMapper detectionCabinetMapper;
+    private final DeviceDiscoveryCandidateStore candidateStore;
 
     /**
      * 构造检测柜业务服务。
      *
      * @param detectionCabinetMapper 检测柜数据访问对象
      */
-    public TDetectionCabinetServiceImpl(TDetectionCabinetMapper detectionCabinetMapper)
+    public TDetectionCabinetServiceImpl(TDetectionCabinetMapper detectionCabinetMapper,
+            DeviceDiscoveryCandidateStore candidateStore)
     {
         this.detectionCabinetMapper = detectionCabinetMapper;
+        this.candidateStore = candidateStore;
     }
 
     /**
@@ -56,7 +70,7 @@
         {
             throw new ServiceException("检测柜不存在或已删除");
         }
-        return detectionCabinet;
+        return applyRealtimeState(detectionCabinet, candidatesByCode());
     }
 
     /**
@@ -68,43 +82,46 @@
     @Override
     public List<TDetectionCabinet> selectDetectionCabinetList(TDetectionCabinet detectionCabinet)
     {
+        String requestedOnlineStatus = detectionCabinet.getOnlineStatus();
+        String requestedTestStatus = detectionCabinet.getTestStatus();
         if (StringUtils.isNotBlank(detectionCabinet.getMacAddress()))
         {
             detectionCabinet.setMacAddress(normalizeMacAddress(detectionCabinet.getMacAddress()));
         }
-        return detectionCabinetMapper.selectDetectionCabinetList(detectionCabinet);
+        detectionCabinet.setOnlineStatus(null);
+        detectionCabinet.setTestStatus(null);
+        Map<String, CabinetCandidate> candidates = candidatesByCode();
+        return detectionCabinetMapper.selectDetectionCabinetList(detectionCabinet).stream()
+                .map(item -> applyRealtimeState(item, candidates))
+                .filter(item -> StringUtils.isBlank(requestedOnlineStatus)
+                        || requestedOnlineStatus.equals(item.getOnlineStatus()))
+                .filter(item -> StringUtils.isBlank(requestedTestStatus)
+                        || requestedTestStatus.equals(item.getTestStatus()))
+                .toList();
     }
 
     /**
-     * 复用已持久化检测柜事实供原选择设备弹窗展示,不生成模拟设备。
-     * 测试占用状态由已持久化通道工作状态聚合;测试归属留待后续绑定闭环确认。
+     * 读取 Redis 在线候选供原选择设备弹窗展示,不生成模拟设备。
+     * 测试占用状态由实时通道工作状态聚合;测试归属留待后续绑定闭环确认。
      *
      * @return 可供选择的检测柜设备摘要
      */
     @Override
     public List<DiscoveredCabinetVo> selectDiscoveredCabinetList()
     {
-        return detectionCabinetMapper.selectDiscoveredCabinetList().stream()
-                .map(cabinet -> {
-                    boolean active = ACTIVE.equals(cabinet.getObservedWorkStatus());
+        Set<String> registeredCodes = Set.copyOf(detectionCabinetMapper.selectRegisteredCabinetCodes());
+        return candidateStore.snapshot(Instant.now()).cabinets().stream()
+                .filter(CabinetCandidate::online)
+                .filter(candidate -> !registeredCodes.contains(candidate.deviceCode()))
+                .map(candidate -> {
+                    boolean active = isActive(candidate);
                     return new DiscoveredCabinetVo(
-                            cabinet.getCabinetCode(),
-                            cabinet.getIpAddress(),
-                            cabinet.getMacAddress(),
-                            cabinet.getCabinetName(),
-                            cabinet.getManufacturer(),
-                            cabinet.getModel(),
-                            cabinet.getChannelCount(),
-                            isOnline(cabinet.getOnlineStatus()) ? "在线" : "离线",
+                            candidate.deviceCode(), candidate.ip(), candidate.mac(), candidate.name(),
+                            candidate.manufacturer(), candidate.model(), candidate.channels().size(), "在线",
                             active ? "测试中" : "空闲",
                             active ? "待确认" : "-");
                 })
                 .toList();
-    }
-
-    private boolean isOnline(String onlineStatus)
-    {
-        return NORMAL.equals(onlineStatus) || "ONLINE".equalsIgnoreCase(onlineStatus);
     }
 
     /**
@@ -114,26 +131,44 @@
      * @return 影响行数
      */
     @Override
+    @Transactional(rollbackFor = Exception.class)
     public int insertDetectionCabinet(TDetectionCabinet detectionCabinet)
     {
         normalizeBasicFields(detectionCabinet);
+        CabinetCandidate candidate = candidateStore.findCabinet(detectionCabinet.getCabinetCode(), Instant.now())
+                .filter(CabinetCandidate::online)
+                .orElseThrow(() -> new ServiceException("请先选择在线的发现候选设备"));
+        detectionCabinet.setAdapterId(candidate.adapterId());
+        detectionCabinet.setAdapterCode(candidate.adapterCode());
+        detectionCabinet.setVendorDeviceType(candidate.devtype());
+        detectionCabinet.setVendorDeviceId(candidate.devid());
+        detectionCabinet.setIpAddress(candidate.ip());
+        detectionCabinet.setMacAddress(candidate.mac() == null ? detectionCabinet.getMacAddress() : candidate.mac());
+        detectionCabinet.setChannelCount(candidate.channels().size());
         validateIpAddress(detectionCabinet.getIpAddress());
-        TDetectionCabinet candidate = detectionCabinetMapper.selectByCabinetCode(detectionCabinet.getCabinetCode());
-        if (candidate == null)
-        {
-            throw new ServiceException("请先选择发现候选设备");
-        }
-        if (!DISCOVERED.equals(candidate.getRegistrationStatus()))
-        {
-            throw new ServiceException("设备编码已存在");
-        }
-        detectionCabinet.setCabinetId(candidate.getCabinetId());
         validateUniqueFields(detectionCabinet);
+        detectionCabinet.setOnlineStatus("OFFLINE");
+        detectionCabinet.setLastSeenAtUtc(null);
         detectionCabinet.setStatus(NORMAL);
         try
         {
-            int rows = detectionCabinetMapper.registerDiscoveredCabinet(detectionCabinet);
-            if (rows == 0) throw new ServiceException("发现候选设备状态已变化,请重新选择");
+            int rows = detectionCabinetMapper.insertDetectionCabinet(detectionCabinet);
+            for (var channel : candidate.channels())
+            {
+                TDetectionChannel value = new TDetectionChannel();
+                value.setAdapterId(candidate.adapterId());
+                value.setAdapterCode(candidate.adapterCode());
+                value.setCabinetId(detectionCabinet.getCabinetId());
+                value.setDevtype(channel.identity().devtype());
+                value.setDevid(channel.identity().devid());
+                value.setSubdevid(channel.identity().subdevid());
+                value.setChlid(channel.identity().chlid());
+                value.setDisplayNo(channel.identity().subdevid() + "-" + channel.identity().chlid());
+                value.setOnlineStatus("UNKNOWN");
+                value.setWorkStatus("UNKNOWN");
+                value.setLastSeenAtUtc(null);
+                detectionCabinetMapper.insertDetectionChannel(value);
+            }
             return rows;
         }
         catch (DuplicateKeyException exception)
@@ -193,7 +228,7 @@
     }
 
     /**
-     * 批量逻辑删除检测柜;先完成全部目标记录校验,再执行删除,避免部分成功。
+     * 批量取消检测柜登记;先完成全部目标记录校验,再执行操作,避免部分成功。
      *
      * @param cabinetIds 检测柜主键数组
      * @param updateBy 更新人
@@ -215,6 +250,7 @@
         int rows = 0;
         for (Long cabinetId : cabinetIds)
         {
+            detectionCabinetMapper.deleteDetectionChannelsByCabinetId(cabinetId);
             rows += detectionCabinetMapper.deleteDetectionCabinetById(cabinetId, updateBy);
         }
         return rows;
@@ -331,6 +367,25 @@
         }
     }
 
+    private Map<String, CabinetCandidate> candidatesByCode()
+    {
+        return candidateStore.snapshot(Instant.now()).cabinets().stream()
+                .collect(java.util.stream.Collectors.toMap(CabinetCandidate::deviceCode, item -> item));
+    }
+
+    private TDetectionCabinet applyRealtimeState(TDetectionCabinet cabinet,
+            Map<String, CabinetCandidate> candidates)
+    {
+        CabinetCandidate candidate = candidates.get(cabinet.getCabinetCode());
+        cabinet.setOnlineStatus(candidate != null && candidate.online() ? ONLINE : OFFLINE);
+        return cabinet;
+    }
+
+    private boolean isActive(CabinetCandidate candidate)
+    {
+        return candidate.channels().stream().anyMatch(channel -> ACTIVE_WORK_STATUSES.contains(channel.workStatus()));
+    }
+
     private ServiceException buildDuplicateException(TDetectionCabinet detectionCabinet, DuplicateKeyException cause)
     {
         String message = checkCabinetCodeUnique(detectionCabinet) ? "MAC地址已存在" : "设备编码已存在";
diff --git a/ruoyi-system/src/main/resources/mapper/system/TCollectorMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TCollectorMapper.xml
index e0548ad..30b1af1 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TCollectorMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TCollectorMapper.xml
@@ -69,6 +69,12 @@
         order by create_time desc, collector_id desc
     </select>
 
+    <select id="selectRegisteredCollectorCodes" resultType="String">
+        select collector_code
+        from t_collector
+        where del_flag = '0'
+    </select>
+
     <select id="selectCollectorByCode" parameterType="String" resultMap="TCollectorResult">
         <include refid="selectCollectorVo" />
         where collector_code = #{collectorCode}
diff --git a/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml
index 07efe78..d7508de 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml
@@ -1,11 +1,12 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<!DOCTYPE mapper
-PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
+<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
 "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
 <mapper namespace="com.ruoyi.system.mapper.TDetectionCabinetMapper">
 
     <resultMap type="TDetectionCabinet" id="TDetectionCabinetResult">
         <id property="cabinetId" column="cabinet_id" />
+        <result property="adapterId" column="adapter_id" />
+        <result property="adapterCode" column="adapter_code" />
         <result property="cabinetCode" column="cabinet_code" />
         <result property="cabinetName" column="cabinet_name" />
         <result property="ipAddress" column="ip_address" />
@@ -27,127 +28,86 @@
         <result property="registrationStatus" column="registration_status" />
     </resultMap>
 
-    <resultMap type="TDetectionCabinet" id="DiscoveredCabinetResult">
-        <id property="cabinetId" column="cabinet_id" />
-        <result property="cabinetCode" column="cabinet_code" />
-        <result property="ipAddress" column="ip_address" />
-        <result property="macAddress" column="mac_address" />
-        <result property="cabinetName" column="cabinet_name" />
-        <result property="manufacturer" column="manufacturer" />
-        <result property="model" column="model" />
-        <result property="channelCount" column="channel_count" />
-        <result property="onlineStatus" column="online_status" />
-        <result property="observedWorkStatus" column="observed_work_status" />
-    </resultMap>
-
-    <sql id="activeCabinetChannelExists">
+    <sql id="activeCabinetTestExists">
         exists (
-            select 1 from t_detection_channel channel_fact
-            where channel_fact.cabinet_id = cabinet.cabinet_id
-              and channel_fact.del_flag = 0
-              and channel_fact.work_status in (
-                  'WORKING', 'PAUSED', 'PROTECTED', 'SYNCHRONIZING', 'LIT',
-                  'WAITING_TIMEOUT', 'WAITING_START', 'LOCKED'
-              )
+            select 1 from t_test_channel_binding binding
+            join t_detection_channel channel_fact on channel_fact.channel_id = binding.channel_id
+            join t_test test_fact on test_fact.test_id = binding.test_id
+            where channel_fact.cabinet_id = t_detection_cabinet.cabinet_id
+              and channel_fact.del_flag = 0 and test_fact.del_flag = 0
+              and test_fact.test_status in ('RUNNING', 'PAUSED', 'RUNNING_ABNORMAL')
         )
     </sql>
 
     <sql id="selectDetectionCabinetVo">
-        select cabinet_id, cabinet_code, cabinet_name, ip_address, mac_address,
+        select cabinet_id, adapter_id, adapter_code, cabinet_code, cabinet_name, ip_address, mac_address,
                manufacturer, model, capacity, channel_count,
                case when upper(online_status) = 'ONLINE' or online_status = '0' then '0' else '1' end as online_status,
-               case when <include refid="activeCabinetChannelExists"/> then '1' else '0' end as test_status,
-               case when <include refid="activeCabinetChannelExists"/> then '待确认' else null end as current_test,
+               case when <include refid="activeCabinetTestExists"/> then '1' else '0' end as test_status,
+               case when <include refid="activeCabinetTestExists"/> then '待确认' else null end as current_test,
                status, create_by, create_time, update_by, update_time, remark, del_flag, registration_status
-        from t_detection_cabinet cabinet
+        from t_detection_cabinet
     </sql>
 
     <select id="selectDetectionCabinetList" parameterType="TDetectionCabinet" resultMap="TDetectionCabinetResult">
         <include refid="selectDetectionCabinetVo"/>
         <where>
-            del_flag = '0'
-            and registration_status = 'REGISTERED'
+            del_flag = 0 and registration_status = 'REGISTERED'
             <if test="device != null and device != ''">
-                and (
-                    ip_address like concat('%', #{device}, '%')
+                and (ip_address like concat('%', #{device}, '%')
                     or cabinet_code like concat('%', #{device}, '%')
                     or mac_address like concat('%', #{device}, '%')
-                    or cabinet_name like concat('%', #{device}, '%')
-                )
+                    or cabinet_name like concat('%', #{device}, '%'))
             </if>
-            <if test="beginCreateTime != null">
-                and create_time &gt;= #{beginCreateTime}
-            </if>
-            <if test="endCreateTime != null">
-                and create_time &lt;= #{endCreateTime}
-            </if>
-            <if test="cabinetCode != null and cabinetCode != ''">
-                and cabinet_code like concat('%', #{cabinetCode}, '%')
-            </if>
-            <if test="cabinetName != null and cabinetName != ''">
-                and cabinet_name like concat('%', #{cabinetName}, '%')
-            </if>
-            <if test="ipAddress != null and ipAddress != ''">
-                and ip_address = #{ipAddress}
-            </if>
-            <if test="macAddress != null and macAddress != ''">
-                and mac_address = #{macAddress}
-            </if>
-            <if test="onlineStatus != null and onlineStatus != ''">
-                and (case when upper(online_status) = 'ONLINE' or online_status = '0' then '0' else '1' end) = #{onlineStatus}
-            </if>
-            <if test="testStatus != null and testStatus != ''">
-                <choose>
-                    <when test='testStatus.equals("1")'>
-                        and <include refid="activeCabinetChannelExists"/>
-                    </when>
-                    <otherwise>
-                        and not <include refid="activeCabinetChannelExists"/>
-                    </otherwise>
-                </choose>
-            </if>
-            <if test="status != null and status != ''">
-                and status = #{status}
-            </if>
+            <if test="beginCreateTime != null">and create_time &gt;= #{beginCreateTime}</if>
+            <if test="endCreateTime != null">and create_time &lt;= #{endCreateTime}</if>
+            <if test="cabinetCode != null and cabinetCode != ''">and cabinet_code like concat('%', #{cabinetCode}, '%')</if>
+            <if test="cabinetName != null and cabinetName != ''">and cabinet_name like concat('%', #{cabinetName}, '%')</if>
+            <if test="ipAddress != null and ipAddress != ''">and ip_address = #{ipAddress}</if>
+            <if test="macAddress != null and macAddress != ''">and mac_address = #{macAddress}</if>
+            <if test="status != null and status != ''">and status = #{status}</if>
         </where>
         order by create_time desc, cabinet_id desc
     </select>
 
-    <select id="selectDiscoveredCabinetList" resultMap="DiscoveredCabinetResult">
-        select cabinet.cabinet_id, cabinet.cabinet_code, cabinet.cabinet_name, cabinet.ip_address,
-               cabinet.mac_address, cabinet.manufacturer, cabinet.model, cabinet.channel_count,
-               cabinet.online_status,
-               case when <include refid="activeCabinetChannelExists"/>
-                    then 'ACTIVE' else 'IDLE' end as observed_work_status
-        from t_detection_cabinet cabinet
-        where cabinet.del_flag = 0 and cabinet.registration_status = 'DISCOVERED'
-        order by cabinet.last_seen_at_utc desc, cabinet.cabinet_id desc
+    <select id="selectRegisteredCabinetCodes" resultType="String">
+        select cabinet_code from t_detection_cabinet where del_flag = 0
     </select>
 
     <select id="selectDetectionCabinetById" parameterType="Long" resultMap="TDetectionCabinetResult">
         <include refid="selectDetectionCabinetVo"/>
-        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
+        where cabinet_id = #{cabinetId} and del_flag = 0 and registration_status = 'REGISTERED'
     </select>
 
     <select id="selectByCabinetCode" parameterType="String" resultMap="TDetectionCabinetResult">
         <include refid="selectDetectionCabinetVo"/>
-        where cabinet_code = #{cabinetCode} and del_flag = '0' limit 1
+        where cabinet_code = #{cabinetCode} and del_flag = 0 limit 1
     </select>
 
     <select id="selectByMacAddress" parameterType="String" resultMap="TDetectionCabinetResult">
         <include refid="selectDetectionCabinetVo"/>
-        where mac_address = #{macAddress} and del_flag = '0' limit 1
+        where mac_address = #{macAddress} and del_flag = 0 limit 1
     </select>
 
-    <update id="registerDiscoveredCabinet" parameterType="TDetectionCabinet">
-        update t_detection_cabinet
-        set cabinet_name = #{cabinetName}, ip_address = #{ipAddress}, mac_address = #{macAddress},
-            manufacturer = #{manufacturer}, model = #{model}, capacity = #{capacity},
-            channel_count = #{channelCount}, status = #{status}, registration_status = 'REGISTERED',
-            create_by = #{createBy}, create_time = sysdate(), update_by = #{createBy},
-            update_time = sysdate(), remark = #{remark}
-        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'DISCOVERED'
-    </update>
+    <insert id="insertDetectionCabinet" parameterType="TDetectionCabinet" useGeneratedKeys="true" keyProperty="cabinetId">
+        insert into t_detection_cabinet
+        (adapter_id, adapter_code, cabinet_code, cabinet_name, manufacturer, model, capacity, channel_count,
+         vendor_device_type, vendor_device_id, ip_address, mac_address, online_status, last_seen_at_utc,
+         registration_status, status, create_by, create_time, remark, del_flag)
+        values
+        (#{adapterId}, #{adapterCode}, #{cabinetCode}, #{cabinetName}, #{manufacturer}, #{model}, #{capacity},
+         #{channelCount}, #{vendorDeviceType}, #{vendorDeviceId}, #{ipAddress}, #{macAddress}, #{onlineStatus},
+         #{lastSeenAtUtc}, 'REGISTERED', #{status}, #{createBy}, sysdate(), #{remark}, 0)
+    </insert>
+
+    <insert id="insertDetectionChannel" parameterType="TDetectionChannel" useGeneratedKeys="true" keyProperty="channelId">
+        insert into t_detection_channel
+        (adapter_id, adapter_code, cabinet_id, devtype, devid, subdevid, chlid, display_no,
+         online_status, work_status, last_seen_at_utc, status, create_time, del_flag)
+        values
+        (#{adapterId}, #{adapterCode}, #{cabinetId}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{displayNo},
+         #{onlineStatus}, #{workStatus}, #{lastSeenAtUtc}, '0', sysdate(), 0)
+    </insert>
 
     <update id="updateDetectionCabinet" parameterType="TDetectionCabinet">
         update t_detection_cabinet
@@ -161,22 +121,25 @@
             <if test="capacity != null">capacity = #{capacity},</if>
             <if test="channelCount != null">channel_count = #{channelCount},</if>
             <if test="remark != null">remark = #{remark},</if>
-            update_by = #{updateBy},
-            update_time = sysdate()
+            update_by = #{updateBy}, update_time = sysdate()
         </set>
-        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
+        where cabinet_id = #{cabinetId} and del_flag = 0 and registration_status = 'REGISTERED'
     </update>
 
     <update id="updateDetectionCabinetStatus" parameterType="TDetectionCabinet">
-        update t_detection_cabinet
-        set status = #{status}, update_by = #{updateBy}, update_time = sysdate()
-        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
+        update t_detection_cabinet set status = #{status}, update_by = #{updateBy}, update_time = sysdate()
+        where cabinet_id = #{cabinetId} and del_flag = 0 and registration_status = 'REGISTERED'
+    </update>
+
+    <update id="deleteDetectionChannelsByCabinetId">
+        update t_detection_channel set del_flag = channel_id, update_time = sysdate()
+        where cabinet_id = #{cabinetId} and del_flag = 0
     </update>
 
     <update id="deleteDetectionCabinetById">
         update t_detection_cabinet
-        set del_flag = cast(cabinet_id as char), update_by = #{updateBy}, update_time = sysdate()
-        where cabinet_id = #{cabinetId} and del_flag = '0' and registration_status = 'REGISTERED'
+        set del_flag = cabinet_id, update_by = #{updateBy}, update_time = sysdate()
+        where cabinet_id = #{cabinetId} and del_flag = 0 and registration_status = 'REGISTERED'
     </update>
 
 </mapper>
diff --git a/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
index d4460b5..b092e59 100644
--- a/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
+++ b/ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml
@@ -26,21 +26,6 @@
         <result property="delFlag" column="del_flag"/>
     </resultMap>
 
-    <resultMap id="ChannelResult" type="TDetectionChannel">
-        <id property="channelId" column="channel_id"/>
-        <result property="adapterId" column="adapter_id"/>
-        <result property="cabinetId" column="cabinet_id"/>
-        <result property="devtype" column="devtype"/>
-        <result property="devid" column="devid"/>
-        <result property="subdevid" column="subdevid"/>
-        <result property="chlid" column="chlid"/>
-        <result property="displayNo" column="display_no"/>
-        <result property="onlineStatus" column="online_status"/>
-        <result property="workStatus" column="work_status"/>
-        <result property="lastSeenAtUtc" column="last_seen_at_utc"/>
-        <result property="status" column="status"/>
-    </resultMap>
-
     <sql id="adapterColumns">
         adapter_id, adapter_code, adapter_name, adapter_type, host, port, poll_interval_ms, timezone_id,
         capabilities, (json_extract(capabilities, '$.control') = false) read_only, status,
@@ -120,40 +105,4 @@
         where adapter_id=#{adapterId} and del_flag=0
     </update>
 
-    <select id="selectCabinetIdByIdentity" resultType="Long">
-        select cabinet_id from t_detection_cabinet
-        where adapter_id=#{adapterId} and vendor_device_type=#{devtype} and vendor_device_id=#{devid} and del_flag=0 limit 1
-    </select>
-
-    <insert id="insertDetectedCabinet" parameterType="TDetectionCabinet" useGeneratedKeys="true" keyProperty="cabinetId">
-        insert into t_detection_cabinet(adapter_id, cabinet_code, cabinet_name, manufacturer, model, capacity,
-            channel_count, vendor_device_type, vendor_device_id, ip_address, mac_address, online_status,
-            last_seen_at_utc, registration_status, status, create_by, create_time, del_flag)
-        values(#{adapterId}, #{cabinetCode}, #{cabinetName}, #{manufacturer}, #{model}, 1,
-            #{channelCount}, #{vendorDeviceType}, #{vendorDeviceId}, #{ipAddress}, #{macAddress},
-            #{onlineStatus}, #{lastSeenAtUtc}, 'DISCOVERED', '0', 'system', sysdate(), 0)
-    </insert>
-
-    <update id="updateDetectedCabinet" parameterType="TDetectionCabinet">
-        update t_detection_cabinet set channel_count=#{channelCount}, ip_address=#{ipAddress},
-            online_status=#{onlineStatus}, last_seen_at_utc=#{lastSeenAtUtc}, update_by='system', update_time=sysdate()
-        where cabinet_id=#{cabinetId} and del_flag=0
-    </update>
-
-    <insert id="upsertDetectedChannel" parameterType="TDetectionChannel">
-        insert into t_detection_channel(adapter_id, cabinet_id, devtype, devid, subdevid, chlid, display_no,
-            online_status, work_status, last_seen_at_utc, status, create_time, del_flag)
-        values(#{adapterId}, #{cabinetId}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{displayNo},
-            #{onlineStatus}, #{workStatus}, #{lastSeenAtUtc}, '0', sysdate(), 0)
-        on duplicate key update cabinet_id=values(cabinet_id), display_no=values(display_no),
-            online_status=values(online_status), work_status=values(work_status),
-            last_seen_at_utc=values(last_seen_at_utc), update_time=sysdate()
-    </insert>
-
-    <select id="selectDetectionChannelsByAdapterId" resultMap="ChannelResult">
-        select channel_id, adapter_id, cabinet_id, devtype, devid, subdevid, chlid, display_no,
-            online_status, work_status, last_seen_at_utc, status
-        from t_detection_channel where adapter_id=#{adapterId} and del_flag=0
-        order by devtype, devid, subdevid, chlid
-    </select>
 </mapper>

--
Gitblit v1.7.1