ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java
@@ -28,6 +28,7 @@
        this.redisTemplate = redisTemplate;
    }
    /** 按设备编码 upsert 本轮检测柜候选,本轮未返回的候选保留至逻辑过期。 */
    @Override
    public synchronized void recordCabinets(List<CabinetCandidate> candidates)
    {
@@ -35,48 +36,38 @@
        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()));
        save(new StoredState(CURRENT_VERSION, List.copyOf(merged.values())));
    }
    @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));
        save(new StoredState(CURRENT_VERSION, cabinets));
    }
    /**
     * 返回指定时刻尚未过期的发现候选,并顺带清掉已逻辑过期的条目。
     *
     * <p>是否“在用”由操作员判断,适配器不自动判定。</p>
     */
    @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);
        if (cabinets.size() != current.cabinets().size()) save(new StoredState(CURRENT_VERSION, cabinets));
        return new CandidateSnapshot(cabinets);
    }
    private StoredState load()
    {
        Object stored = redisTemplate.opsForValue().get(STATE_KEY);
        if (stored == null) return new StoredState(CURRENT_VERSION, List.of(), List.of());
        if (stored == null) return new StoredState(CURRENT_VERSION, List.of());
        try
        {
            // 通用 RedisTemplate 可能把泛型数组恢复为 JSONObject;在存储边界逐字段重建,禁止业务层强转。
@@ -85,10 +76,9 @@
            if (version == null || version != CURRENT_VERSION)
                throw new IllegalStateException("Unsupported device discovery state version: " + version);
            List<CabinetCandidate> cabinets = new ArrayList<>();
            // collectors 是模拟器时代写进同一 key 的历史字段:读到就忽略,不再恢复也不回写。
            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);
            return new StoredState(CURRENT_VERSION, cabinets);
        }
        catch (IllegalStateException exception)
        {
@@ -129,7 +119,8 @@
                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"));
                requiredInstant(object, "observedAt"), requiredInstant(object, "expiresAt"),
                optionalInstant(object, "lastDataAt"));
    }
    private ChannelCandidate channelCandidate(Object value)
@@ -140,18 +131,10 @@
                identity.getIntValue("devtype"), identity.getIntValue("devid"),
                identity.getIntValue("subdevid"), identity.getIntValue("chlid"));
        RealtimeSnapshot latestSnapshot = object.getObject("latestSnapshot", RealtimeSnapshot.class);
        // sourceDisplayNo 用宽容读法:升级前写进 Redis 的候选没有这个键,读成空即可,不该报错。
        return new ChannelCandidate(channelIdentity, object.getBooleanValue("online"),
                DeviceWorkStatus.valueOf(object.getString("workStatus")), requiredInstant(object, "observedAt"), latestSnapshot);
    }
    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"));
                DeviceWorkStatus.valueOf(object.getString("workStatus")), requiredInstant(object, "observedAt"),
                latestSnapshot, optionalInstant(object, "lastDataAt"), object.getString("sourceDisplayNo"));
    }
    private Instant requiredInstant(JSONObject object, String field)
@@ -161,18 +144,22 @@
        return value;
    }
    /** 最近数据时间只在厂商存储来源上存在,缺失是正常取值。 */
    private Instant optionalInstant(JSONObject object, String field)
    {
        return object.getObject(field, Instant.class);
    }
    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 record StoredState(int version, List<CabinetCandidate> cabinets)
    {
        private StoredState
        {
            cabinets = List.copyOf(cabinets);
            collectors = List.copyOf(collectors);
        }
    }
}