feat(device): persist simulator discovery in redis
1个文件已删除
16个文件已添加
33个文件已修改
| | |
| | | /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/ |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| New file |
| | |
| | | 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)); |
| | | } |
| | | } |
| | |
| | | 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 |
| | |
| | | } |
| | | |
| | | @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)); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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。 */ |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** 返回临时控制页;匿名放行后仍由本机拦截器限制来源。 */ |
| | |
| | | @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(); |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | /** 添加一台与检测柜分开配置的虚拟采集器。 */ |
| | | @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, |
| | |
| | | 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) { } |
| | | } |
| | |
| | | |
| | | @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) { } |
| | |
| | | } |
| | | |
| | | /** |
| | | * 保留原设备选择弹窗使用的分页接口,数据来自已持久化检测柜。 |
| | | * 保留原设备选择弹窗使用的分页接口,数据来自 Redis 发现候选。 |
| | | * |
| | | * @return 可供选择的检测柜分页数据 |
| | | */ |
| | |
| | | } |
| | | |
| | | /** |
| | | * 批量逻辑删除检测柜,测试中的检测柜不能删除。 |
| | | * 批量取消检测柜登记,测试中的检测柜不能操作。 |
| | | * |
| | | * @param cabinetIds 检测柜主键数组 |
| | | * @return 操作结果 |
| New file |
| | |
| | | -- 设备发现只形成候选事实;操作员保存后才进入正式检测柜列表。 |
| | | 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); |
| New file |
| | |
| | | -- 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); |
| New file |
| | |
| | | -- 补齐现有采集器管理页使用的运行展示字段;模拟器发现本身不写入正式采集器表。 |
| | | 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')); |
| New file |
| | |
| | | -- 正式拓扑保存稳定适配器编码;代码固定装配的 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; |
| | |
| | | <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; } |
| | |
| | | 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; } |
| | |
| | | .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; } |
| | |
| | | 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; } |
| | |
| | | </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> |
| | |
| | | <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> |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | 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}`; |
| | |
| | | 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); } |
| | |
| | | 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; |
| | | })); |
| | | } |
| | |
| | | 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 { |
| | |
| | | } 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> |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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) |
| New file |
| | |
| | | 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()); |
| | | } |
| | | } |
| | |
| | | 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()); |
| | | } |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | |
| | | 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"); |
| | |
| | | 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(); |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | 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 |
| | |
| | | } |
| | | |
| | | @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; |
| | |
| | | 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; |
| | |
| | | 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 单元测试。 |
| | |
| | | @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"); |
| | |
| | | |
| | | 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'")); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | |
| | | @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 |
| | |
| | | } |
| | | |
| | | @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(); |
| | |
| | | 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; |
| | | |
| | | /** |
| | | * 检测柜业务层单元测试,验证字段规范化、状态限制及唯一性校验等关键规则。 |
| | |
| | | @Mock |
| | | private TDetectionCabinetMapper detectionCabinetMapper; |
| | | |
| | | @InjectMocks |
| | | private TDetectionCabinetServiceImpl detectionCabinetService; |
| | | private InMemoryDeviceDiscoveryCandidateStore candidateStore; |
| | | |
| | | private TDetectionCabinet detectionCabinet; |
| | | |
| | |
| | | @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(" 一号检测柜 "); |
| | |
| | | @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()); |
| | |
| | | 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 |
| | |
| | | { |
| | | 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'")); |
| | | } |
| | | } |
| | | |
| | |
| | | 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}")); |
| | | } |
| | | } |
| | | |
| | |
| | | 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(); |
| | | |
| | |
| | | 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()); |
| | | } |
| | | |
| | | /** |
| | |
| | | () -> 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()); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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; |
| | | |
| | |
| | | { |
| | | 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)); |
| | |
| | | { |
| | | 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)); |
| | |
| | | |
| | | 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))); |
| | | } |
| | | } |
| | | |
| | |
| | | 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"); |
| | |
| | | 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; |
| | |
| | | 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); |
| | | } |
| | | |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** 修改开发设备的虚拟地址、通道数和在线状态。 */ |
| | |
| | | ? 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)); |
| | | } |
| | | |
| | | /** 修改指定开发设备的单个通道状态。 */ |
| | |
| | | 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) |
| | |
| | | |
| | | 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) |
| | |
| | | |
| | | 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()) |
| | | { |
| | |
| | | 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(); |
| | | } |
| | | |
| | |
| | | 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)); |
| | | |
| | |
| | | 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) |
| | |
| | | |
| | | 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) |
| | |
| | | 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 |
| | | { |
| | |
| | | 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) { } |
| | | |
| | | } |
| New file |
| | |
| | | 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) { } |
| | | } |
| New file |
| | |
| | | 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())); |
| | | } |
| | | } |
| | |
| | | }); |
| | | } |
| | | |
| | | /** 按持久化配置注册、替换或移除运行时实例。 */ |
| | | /** 按数据库或代码配置注册、替换或移除运行时实例。 */ |
| | | public void synchronize(AdapterInstanceConfig config) |
| | | { |
| | | Objects.requireNonNull(config, "config"); |
| New file |
| | |
| | | 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) { } |
| | | } |
| New file |
| | |
| | | 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(); |
| | | } |
| | | } |
| New file |
| | |
| | | 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); |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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 |
| | | { |
| | |
| | | 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))); |
| | | } |
| | | } |
| | |
| | | |
| | | /** 所属设备适配器实例。 */ |
| | | 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; |
| | | |
| | | /** 全局唯一的设备编码。 */ |
| | |
| | | |
| | | 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; } |
| | | |
| | |
| | | |
| | | private Long channelId; |
| | | private Long adapterId; |
| | | private String adapterCode; |
| | | private Long cabinetId; |
| | | private Integer devtype; |
| | | private Integer devid; |
| | |
| | | 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; } |
| | |
| | | |
| | | import java.time.Instant; |
| | | |
| | | /** 单次设备发现的持久化结果。 */ |
| | | /** 单次设备发现写入 Redis 候选后的结果。 */ |
| | | public record AdapterDiscoveryResult(int cabinetCount, int channelCount, Instant observedAtUtc) |
| | | { |
| | | } |
| | |
| | | /** MAC 地址 */ |
| | | private String mac; |
| | | |
| | | /** 模拟器提供的采集器名称 */ |
| | | private String name; |
| | | |
| | | /** 制造商 */ |
| | | private String manufacturer; |
| | | |
| | | /** 型号 */ |
| | | private String model; |
| | | |
| | | /** 通道数 */ |
| | | private Integer channelCount; |
| | | |
| | | /** 在线状态 */ |
| | | private String onlineStatus; |
| | | |
| | |
| | | { |
| | | } |
| | | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | /** 查询采集器列表。 */ |
| | | List<TCollector> selectCollectorList(TCollector collector); |
| | | |
| | | /** 查询全部未删除采集器的设备编码。 */ |
| | | List<String> selectRegisteredCollectorCodes(); |
| | | |
| | | /** 根据设备编码查询未删除采集器。 */ |
| | | TCollector selectCollectorByCode(String collectorCode); |
| | | |
| | |
| | | /** |
| | | * 查询设备选择弹窗需要的已发现检测柜事实。 |
| | | * |
| | | * @return 已发现且未删除的检测柜 |
| | | * @return 已发现、在线且未登记的检测柜 |
| | | */ |
| | | List<TDetectionCabinet> selectDiscoveredCabinetList(); |
| | | List<String> selectRegisteredCabinetCodes(); |
| | | |
| | | /** |
| | | * 根据设备编码查询未删除检测柜。 |
| | |
| | | * @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); |
| | | |
| | | /** |
| | | * 修改检测柜基础信息。 |
| | |
| | | int updateDetectionCabinetStatus(TDetectionCabinet detectionCabinet); |
| | | |
| | | /** |
| | | * 将检测柜标记为逻辑删除。 |
| | | * 取消检测柜登记,使仍在线的设备重新进入发现候选列表。 |
| | | * |
| | | * @param cabinetId 检测柜主键 |
| | | * @param updateBy 更新人 |
| | |
| | | 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 |
| | |
| | | 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); |
| | | } |
| | |
| | | /** 启用或停用设备适配器,并同步运行时生命周期。 */ |
| | | int changeStatus(Long adapterId, boolean enabled, String updateBy); |
| | | |
| | | /** 执行一次发现和实时观测,并持久化本轮设备事实。 */ |
| | | /** 执行一次发现和实时观测,并将本轮设备事实写入 Redis 候选。 */ |
| | | AdapterDiscoveryResult discover(Long adapterId); |
| | | |
| | | /** 查询指定适配器已发现且未被删除的通道。 */ |
| | | /** 查询指定适配器当前未过期的发现通道。 */ |
| | | List<TDetectionChannel> selectChannels(Long adapterId); |
| | | |
| | | /** 启动时恢复所有已启用适配器的运行时实例。 */ |
| | |
| | | package com.ruoyi.system.service; |
| | | |
| | | import java.util.List; |
| | | import java.util.Set; |
| | | import com.ruoyi.system.domain.TCollector; |
| | | |
| | | /** |
| | |
| | | /** 启用或禁用采集器。 */ |
| | | int updateCollectorStatus(TCollector collector); |
| | | |
| | | /** 查询全部未删除采集器的设备编码。 */ |
| | | Set<String> selectRegisteredCollectorCodes(); |
| | | |
| | | /** 批量逻辑删除采集器。 */ |
| | | int deleteCollectorByIds(Long[] collectorIds, String updateBy); |
| | | } |
| | |
| | | List<TDetectionCabinet> selectDetectionCabinetList(TDetectionCabinet detectionCabinet); |
| | | |
| | | /** |
| | | * 将已持久化的检测柜转换为原设备选择弹窗的数据合同。 |
| | | * 查询 Redis 中在线、未过期且尚未正式登记的检测柜候选。 |
| | | * |
| | | * @return 可供选择的检测柜设备摘要 |
| | | */ |
| | |
| | | int updateDetectionCabinetStatus(TDetectionCabinet detectionCabinet); |
| | | |
| | | /** |
| | | * 批量逻辑删除检测柜,测试中的检测柜不允许删除。 |
| | | * 批量取消检测柜登记,测试中的检测柜不允许操作。 |
| | | * |
| | | * @param cabinetIds 检测柜主键数组 |
| | | * @param updateBy 更新人 |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | 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 |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** 按管理端筛选条件查询适配器及其运行状态。 */ |
| | |
| | | public TDeviceAdapter ensureManagedAdapter(TDeviceAdapter adapter) |
| | | { |
| | | normalizeAndValidate(adapter); |
| | | rejectDevelopmentSimulatorDatabaseConfig(adapter); |
| | | AdapterCapabilities capabilities = parseCapabilities(adapter.getCapabilities()); |
| | | if (capabilities.control()) |
| | | throw error("当前里程碑只允许只读设备适配器", DeviceAdapterErrorCodes.INVALID_CONFIG); |
| | |
| | | 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); |
| | |
| | | return rows; |
| | | } |
| | | |
| | | /** 执行一次发现和实时观测,并事务性写入通道事实。 */ |
| | | /** 执行一次发现和实时观测,将未登记设备写入 Redis 候选。 */ |
| | | @Override |
| | | public AdapterDiscoveryResult discover(Long adapterId) |
| | | { |
| | |
| | | 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) |
| | |
| | | } |
| | | } |
| | | |
| | | /** 查询指定适配器已发现且未删除的通道。 */ |
| | | /** 查询指定适配器当前未过期的 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(); |
| | | } |
| | | |
| | | /** 启动时恢复所有已启用适配器的运行时注册。 */ |
| | |
| | | } |
| | | } |
| | | |
| | | 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(), |
| | |
| | | } |
| | | |
| | | private ServiceException error(String message, int code) { return new ServiceException(message, code); } |
| | | |
| | | private record DiscoveryObservation(List<DetectedChannel> channels, |
| | | Map<ChannelIdentity, RealtimeSnapshot> snapshots) |
| | | { |
| | | } |
| | | } |
| | |
| | | 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) { } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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 实现。 |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | | * 查询未删除采集器,不存在时抛出业务异常。 |
| | |
| | | { |
| | | throw new ServiceException("采集器不存在"); |
| | | } |
| | | return collector; |
| | | return applyRealtimeState(collector, candidatesByCode()); |
| | | } |
| | | |
| | | /** |
| | |
| | | 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); |
| | |
| | | } |
| | | collector.setUpdateTime(new Date()); |
| | | return collectorMapper.updateCollectorStatus(collector); |
| | | } |
| | | |
| | | /** 查询全部未删除采集器的设备编码,供发现候选排除已登记设备。 */ |
| | | @Override |
| | | public Set<String> selectRegisteredCollectorCodes() |
| | | { |
| | | return Set.copyOf(collectorMapper.selectRegisteredCollectorCodes()); |
| | | } |
| | | |
| | | /** |
| | |
| | | { |
| | | 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; |
| | | } |
| | | } |
| | |
| | | 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; |
| | |
| | | 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 业务层处理 |
| | |
| | | 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; |
| | | } |
| | | |
| | | /** |
| | |
| | | { |
| | | throw new ServiceException("检测柜不存在或已删除"); |
| | | } |
| | | return detectionCabinet; |
| | | return applyRealtimeState(detectionCabinet, candidatesByCode()); |
| | | } |
| | | |
| | | /** |
| | |
| | | @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); |
| | | } |
| | | |
| | | /** |
| | |
| | | * @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) |
| | |
| | | } |
| | | |
| | | /** |
| | | * 批量逻辑删除检测柜;先完成全部目标记录校验,再执行删除,避免部分成功。 |
| | | * 批量取消检测柜登记;先完成全部目标记录校验,再执行操作,避免部分成功。 |
| | | * |
| | | * @param cabinetIds 检测柜主键数组 |
| | | * @param updateBy 更新人 |
| | |
| | | int rows = 0; |
| | | for (Long cabinetId : cabinetIds) |
| | | { |
| | | detectionCabinetMapper.deleteDetectionChannelsByCabinetId(cabinetId); |
| | | rows += detectionCabinetMapper.deleteDetectionCabinetById(cabinetId, updateBy); |
| | | } |
| | | return rows; |
| | |
| | | } |
| | | } |
| | | |
| | | 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地址已存在" : "设备编码已存在"; |
| | |
| | | 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} |
| | |
| | | <?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" /> |
| | |
| | | <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 |
| | |
| | | <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> |
| | |
| | | <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, |
| | |
| | | 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> |