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