yeg
2026-08-27 210663040bf65ee00b5f86545fdc31bd0356fddf
feat(device): persist simulator discovery in redis
1个文件已删除
16个文件已添加
33个文件已修改
2713 ■■■■■ 已修改文件
.gitignore 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorDiscoveryService.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/DeviceAdapterRuntimeConfig.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java 86 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/config/RedisDeviceDiscoveryCandidateStore.java 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorController.java 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/device/DeviceAdapterController.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/ruoyi/web/controller/system/TDetectionCabinetController.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V8__complete_collector_page_contract.sql 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/static/dev/bts-simulator.html 107 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/DeviceAdapterRuntimeConfigTest.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/FlywayMigrationContractTest.java 24 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImplTest.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImplTest.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TCollectorServiceImplTest.java 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImplTest.java 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/ruoyi/web/controller/device/BtsDevelopmentSimulatorControllerTest.java 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/bts/BtsDevelopmentSimulator.java 266 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/discovery/DeviceDiscoveryCandidateStore.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/discovery/InMemoryDeviceDiscoveryCandidateStore.java 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/main/java/com/ruoyi/device/simulator/InMemoryBtsSimulatorStateStore.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/contract/DiscoveryCandidateStoreContractTest.java 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-device/src/test/java/com/ruoyi/device/contract/SimulatorPublicContractTest.java 106 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionCabinet.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/TDetectionChannel.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/AdapterDiscoveryResult.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/domain/vo/DiscoveredCollectorVo.java 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TCollectorMapper.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDetectionCabinetMapper.java 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/mapper/TDeviceAdapterMapper.java 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/IDeviceAdapterService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/ITCollectorService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/ITDetectionCabinetService.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImpl.java 34 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceAdapterServiceImpl.java 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/DeviceDiscoveryPersistenceService.java 149 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TCollectorServiceImpl.java 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/TDetectionCabinetServiceImpl.java 127 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TCollectorMapper.xml 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TDetectionCabinetMapper.xml 155 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-system/src/main/resources/mapper/system/TDeviceAdapterMapper.xml 51 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.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/
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorDiscoveryService.java
New file
@@ -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();
    }
}
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizer.java
New file
@@ -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);
    }
}
ruoyi-admin/src/main/java/com/ruoyi/config/DevelopmentSimulatorDiscoveryScheduler.java
New file
@@ -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));
    }
}
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));
    }
}
ruoyi-admin/src/main/java/com/ruoyi/config/RedisBtsSimulatorStateStore.java
New file
@@ -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);
        }
    }
}
ruoyi-admin/src/main/java/com/ruoyi/config/RedisDeviceDiscoveryCandidateStore.java
New file
@@ -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);
        }
    }
}
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) { }
}
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) { }
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 操作结果
ruoyi-admin/src/main/resources/db/migration/V6__separate_discovery_from_registration.sql
New file
@@ -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);
ruoyi-admin/src/main/resources/db/migration/V7__complete_device_registration_boundary.sql
New file
@@ -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);
ruoyi-admin/src/main/resources/db/migration/V8__complete_collector_page_contract.sql
New file
@@ -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'));
ruoyi-admin/src/main/resources/db/migration/V9__move_discovery_candidates_to_redis.sql
New file
@@ -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;
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>
ruoyi-admin/src/test/java/com/ruoyi/config/DevelopmentCollectorStatusSynchronizerTest.java
New file
@@ -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());
        }
    }
}
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());
    }
}
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)
ruoyi-admin/src/test/java/com/ruoyi/config/RedisStateStoreTest.java
New file
@@ -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());
    }
}
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());
        }
    }
}
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;
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();
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());
    }
    /**
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");
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) { }
}
ruoyi-device/src/main/java/com/ruoyi/device/discovery/DeviceDiscoveryCandidateStore.java
New file
@@ -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) { }
}
ruoyi-device/src/main/java/com/ruoyi/device/discovery/InMemoryDeviceDiscoveryCandidateStore.java
New file
@@ -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()));
    }
}
ruoyi-device/src/main/java/com/ruoyi/device/registry/DeviceAdapterRegistry.java
@@ -24,7 +24,7 @@
        });
    }
    /** 按持久化配置注册、替换或移除运行时实例。 */
    /** 按数据库或代码配置注册、替换或移除运行时实例。 */
    public void synchronize(AdapterInstanceConfig config)
    {
        Objects.requireNonNull(config, "config");
ruoyi-device/src/main/java/com/ruoyi/device/simulator/BtsSimulatorStateStore.java
New file
@@ -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) { }
}
ruoyi-device/src/main/java/com/ruoyi/device/simulator/InMemoryBtsSimulatorStateStore.java
New file
@@ -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();
    }
}
ruoyi-device/src/test/java/com/ruoyi/device/contract/DiscoveryCandidateStoreContractTest.java
New file
@@ -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);
    }
}
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)));
    }
}
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; }
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; }
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)
{
}
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;
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);
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 更新人
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);
}
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);
    /** 启动时恢复所有已启用适配器的运行时实例。 */
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);
}
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 更新人
ruoyi-system/src/main/java/com/ruoyi/system/service/impl/CollectorDiscoveryServiceImpl.java
File was deleted
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)
    {
    }
}
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) { }
}
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;
    }
}
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地址已存在" : "设备编码已存在";
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}
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>
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>