yeg
2026-08-30 b985e6140e7f395ead7dc55e41e6b16d97f9f02b
ruoyi-device/src/main/java/com/ruoyi/device/simulator/DevelopmentCollectorSimulation.java
@@ -8,7 +8,6 @@
import java.time.format.DateTimeParseException;
import java.time.temporal.ChronoUnit;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@@ -26,7 +25,7 @@
    public static final int DEFAULT_SAMPLE_INTERVAL_MS = 1000;
    public static final int MIN_SAMPLE_INTERVAL_MS = 100;
    public static final int MAX_SAMPLE_INTERVAL_MS = 60000;
    public static final int MAX_HISTORY_PAGE_SIZE = 1000;
    public static final int MAX_HISTORY_PAGE_SIZE = BtsSimulatorStateStore.MAX_HISTORY_BATCH_SIZE;
    private static final int MAX_SAMPLES_PER_ADVANCE = 1000;
    private static final ZoneId DEVICE_ZONE = ZoneId.of("Asia/Hong_Kong");
    private static final DateTimeFormatter DEVICE_TIME_FORMATTER =
@@ -61,7 +60,7 @@
        List<CollectorRealtimeMeasurement> latestMeasurements = retainConfiguredMeasurements(
                current.latestMeasurements(), channels);
        return copy(current, ip, channelCount, online, current.sampleIntervalMs(), current.nextSequence(),
                lastSampledAt, channels, latestMeasurements, current.history(), current.historyFaults());
                lastSampledAt, channels, latestMeasurements, current.historyRecordCount(), current.historyFaults());
    }
    /** 更新采样频率和历史下载故障;测量原始历史保持只追加。 */
@@ -72,7 +71,7 @@
        validateFaults(historyFaults);
        return copy(current, current.ip(), current.channelCount(), current.online(), sampleIntervalMs,
                current.nextSequence(), current.lastSampledAt(), current.channels(), current.latestMeasurements(),
                current.history(), historyFaults);
                current.historyRecordCount(), historyFaults);
    }
    /** 替换一个演示通道的指标配置;动态添加项与固定指标共用指标编码合同。 */
@@ -87,19 +86,19 @@
                current.latestMeasurements(), channels);
        return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
                current.nextSequence(), current.lastSampledAt(), channels, latestMeasurements,
                current.history(), current.historyFaults());
                current.historyRecordCount(), current.historyFaults());
    }
    /** 按采集器自身频率推进采样;离线期间不生成记录,恢复后从下一序号继续。 */
    public static CollectorState advance(CollectorState current, Instant now)
    public static CollectorAdvance advance(CollectorState current, Instant now)
    {
        if (!current.online()) return current;
        if (!current.online()) return new CollectorAdvance(current, List.of());
        Instant sampleAt = current.lastSampledAt() == null
                ? now
                : current.lastSampledAt().plusMillis(current.sampleIntervalMs());
        if (sampleAt.isAfter(now)) return current;
        if (sampleAt.isAfter(now)) return new CollectorAdvance(current, List.of());
        List<CollectorMeasurementRecord> history = new ArrayList<>(current.history());
        List<CollectorMeasurementRecord> appendedRecords = new ArrayList<>();
        Map<String, CollectorRealtimeMeasurement> latest = new LinkedHashMap<>();
        for (CollectorRealtimeMeasurement measurement : current.latestMeasurements())
            latest.put(measurementKey(measurement.channelCode(), measurement.metricCode()), measurement);
@@ -116,7 +115,7 @@
                            channel.channelCode(), sequence, sampleAt,
                            DEVICE_TIME_FORMATTER.format(sampleAt.atZone(DEVICE_ZONE)), now,
                            metric.metricCode(), metric.value(), metric.unit());
                    history.add(record);
                    appendedRecords.add(record);
                    latest.put(measurementKey(record.channelCode(), record.metricCode()),
                            new CollectorRealtimeMeasurement(record.adapterCode(), record.collectorCode(),
                                    record.channelCode(), record.sequenceId(), record.deviceTime(),
@@ -129,9 +128,13 @@
            generated++;
            sampleAt = sampleAt.plusMillis(current.sampleIntervalMs());
        }
        return copy(current, current.ip(), current.channelCount(), current.online(), current.sampleIntervalMs(),
                sequence, lastSampledAt, current.channels(), List.copyOf(latest.values()), history,
        CollectorState next = copy(current, current.ip(), current.channelCount(), current.online(),
                current.sampleIntervalMs(),
                sequence, lastSampledAt, current.channels(), List.copyOf(latest.values()),
                Math.min(BtsSimulatorStateStore.MAX_HISTORY_RECORDS,
                        current.historyRecordCount() + appendedRecords.size()),
                current.historyFaults());
        return new CollectorAdvance(next, appendedRecords);
    }
    /** 返回独立保存的最新实时快照,不从原始历史反推当前状态。 */
@@ -142,21 +145,26 @@
    }
    /** 对只追加原始模拟历史应用可重复的下载故障视图,再按偏移量分页。 */
    public static CollectorHistoryPage history(CollectorState current, long offset, int limit)
    public static CollectorHistoryPage history(CollectorState current, BtsSimulatorStateStore stateStore,
            long offset, int limit)
    {
        if (offset < 0) throw new IllegalArgumentException("offset must not be negative");
        if (limit < 1 || limit > MAX_HISTORY_PAGE_SIZE)
            throw new IllegalArgumentException("limit must be between 1 and " + MAX_HISTORY_PAGE_SIZE);
        List<CollectorMeasurementRecord> download = faultedHistory(current);
        int from = (int) Math.min(offset, download.size());
        int to = Math.min(from + limit, download.size());
        return new CollectorHistoryPage(current.deviceCode(), offset, to, to < download.size(),
                download.size(), download.subList(from, to));
        CollectorHistoryFaults faults = current.historyFaults();
        if (faults.missingSequences().isEmpty() && faults.duplicateSequence() == null
                && faults.conflictingSequence() == null && !faults.outOfOrder())
        {
            BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage(
                    current.deviceCode(), offset, limit, false);
            return new CollectorHistoryPage(current.deviceCode(), offset, batch.nextOffset(), batch.hasMore(),
                    batch.total(), batch.records());
        }
        return faultedHistoryPage(current, stateStore, offset, limit);
    }
    /** 校验 Redis 恢复的采集器采样状态,拒绝损坏状态被静默替换。 */
    public static void validate(CollectorState state)
    public static void validate(CollectorState state, BtsSimulatorStateStore stateStore)
    {
        validateSampleInterval(state.sampleIntervalMs());
        if (state.nextSequence() < 1 || state.channels().size() != state.channelCount())
@@ -169,7 +177,7 @@
            validateMetrics(channel.metrics());
        }
        validateLatestMeasurements(state);
        validateHistory(state);
        validateHistory(state, stateStore);
        validateFaults(state.historyFaults());
    }
@@ -189,24 +197,50 @@
        }
    }
    private static void validateHistory(CollectorState state)
    private static void validateHistory(CollectorState state, BtsSimulatorStateStore stateStore)
    {
        long maximumSequence = 0;
        long previousSequence = 0;
        long recordCount = 0;
        long offset = 0;
        Set<String> identities = new java.util.HashSet<>();
        for (CollectorMeasurementRecord record : state.history())
        while (true)
        {
            String identity = record.sequenceId() + "\u0000" + record.channelCode() + "\u0000" + record.metricCode();
            if (!validMeasurement(state, record.adapterCode(), record.collectorCode(), record.channelCode(),
                    record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(),
                    record.metricCode(), record.value(), record.unit()) || !identities.add(identity)
                    || record.sequenceId() < previousSequence)
                throw new IllegalStateException("Invalid collector history state in Redis");
            maximumSequence = Math.max(maximumSequence, record.sequenceId());
            previousSequence = record.sequenceId();
            BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage(
                    state.deviceCode(), offset, MAX_HISTORY_PAGE_SIZE, false);
            if (batch.offset() != offset || batch.records().size() > MAX_HISTORY_PAGE_SIZE)
                throw new IllegalStateException("Invalid collector history page in Redis");
            for (CollectorMeasurementRecord record : batch.records())
            {
                if (record.sequenceId() < previousSequence)
                    throw new IllegalStateException("Invalid collector history state in Redis");
                if (record.sequenceId() != previousSequence)
                {
                    identities.clear();
                    previousSequence = record.sequenceId();
                }
                String identity = record.channelCode() + "\u0000" + record.metricCode();
                if (!validMeasurement(state, record.adapterCode(), record.collectorCode(), record.channelCode(),
                        record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(),
                        record.metricCode(), record.value(), record.unit()) || !identities.add(identity))
                    throw new IllegalStateException("Invalid collector history state in Redis");
                maximumSequence = Math.max(maximumSequence, record.sequenceId());
                recordCount++;
            }
            if (!batch.hasMore())
            {
                if (batch.total() != recordCount)
                    throw new IllegalStateException("Invalid collector history count in Redis");
                break;
            }
            if (batch.nextOffset() <= offset)
                throw new IllegalStateException("Invalid collector history page in Redis");
            offset = batch.nextOffset();
        }
        if (state.nextSequence() <= maximumSequence)
            throw new IllegalStateException("Invalid collector history sequence in Redis");
        if (state.historyRecordCount() != recordCount)
            throw new IllegalStateException("Invalid collector history count in Redis");
    }
    private static boolean validMeasurement(CollectorState state, String adapterCode, String collectorCode,
@@ -266,38 +300,61 @@
        return channelCode + "\u0000" + metricCode;
    }
    private static List<CollectorMeasurementRecord> faultedHistory(CollectorState current)
    private static CollectorHistoryPage faultedHistoryPage(CollectorState current,
            BtsSimulatorStateStore stateStore, long offset, int limit)
    {
        // 故障视图需返回精确 total,因此逐批扫描原始历史;正常下载会直接读取目标页。
        CollectorHistoryFaults faults = current.historyFaults();
        Set<Long> missing = Set.copyOf(faults.missingSequences());
        List<CollectorMeasurementRecord> records = new ArrayList<>();
        for (CollectorMeasurementRecord record : current.history())
        List<CollectorMeasurementRecord> page = new ArrayList<>(limit);
        long rawOffset = 0;
        long total = 0;
        while (true)
        {
            if (missing.contains(record.sequenceId())) continue;
            records.add(record);
            if (Long.valueOf(record.sequenceId()).equals(faults.duplicateSequence())) records.add(record);
            if (Long.valueOf(record.sequenceId()).equals(faults.conflictingSequence()))
                records.add(new CollectorMeasurementRecord(record.adapterCode(), record.collectorCode(),
                        record.channelCode(), record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(),
                        record.serverReceivedAt(), record.metricCode(),
                        record.value().add(new BigDecimal("0.001")), record.unit()));
            BtsSimulatorStateStore.CollectorHistoryBatch batch = stateStore.loadCollectorHistoryPage(
                    current.deviceCode(), rawOffset, MAX_HISTORY_PAGE_SIZE, faults.outOfOrder());
            for (CollectorMeasurementRecord record : batch.records())
            {
                if (missing.contains(record.sequenceId())) continue;
                total = appendFaultedRecord(page, record, total, offset, limit);
                if (Long.valueOf(record.sequenceId()).equals(faults.duplicateSequence()))
                    total = appendFaultedRecord(page, record, total, offset, limit);
                if (Long.valueOf(record.sequenceId()).equals(faults.conflictingSequence()))
                    total = appendFaultedRecord(page, conflictingRecord(record), total, offset, limit);
            }
            if (!batch.hasMore()) break;
            if (batch.nextOffset() <= rawOffset)
                throw new IllegalStateException("Invalid collector history page in Redis");
            rawOffset = batch.nextOffset();
        }
        if (faults.outOfOrder())
            records.sort(Comparator.comparingLong(CollectorMeasurementRecord::sequenceId).reversed()
                    .thenComparing(CollectorMeasurementRecord::channelCode)
                    .thenComparing(CollectorMeasurementRecord::metricCode));
        return records;
        long from = Math.min(offset, total);
        long nextOffset = from + page.size();
        return new CollectorHistoryPage(current.deviceCode(), offset, nextOffset, nextOffset < total, total, page);
    }
    private static long appendFaultedRecord(List<CollectorMeasurementRecord> page,
            CollectorMeasurementRecord record, long index, long offset, int limit)
    {
        if (index >= offset && page.size() < limit) page.add(record);
        return index + 1;
    }
    private static CollectorMeasurementRecord conflictingRecord(CollectorMeasurementRecord record)
    {
        return new CollectorMeasurementRecord(record.adapterCode(), record.collectorCode(), record.channelCode(),
                record.sequenceId(), record.deviceTime(), record.deviceTimeRaw(), record.serverReceivedAt(),
                record.metricCode(), record.value().add(new BigDecimal("0.001")), record.unit());
    }
    private static CollectorState copy(CollectorState current, String ip, int channelCount, boolean online,
            int sampleIntervalMs, long nextSequence, Instant lastSampledAt,
            List<CollectorChannelState> channels, List<CollectorRealtimeMeasurement> latestMeasurements,
            List<CollectorMeasurementRecord> history,
            long historyRecordCount,
            CollectorHistoryFaults faults)
    {
        return new CollectorState(current.collectorId(), current.deviceCode(), ip.trim(), current.mac(),
                current.name(), current.manufacturer(), current.model(), channelCount, online, sampleIntervalMs,
                nextSequence, lastSampledAt, channels, latestMeasurements, history, faults);
                nextSequence, lastSampledAt, channels, latestMeasurements, historyRecordCount, faults);
    }
    private static void validateSampleInterval(int sampleIntervalMs)
@@ -315,7 +372,9 @@
        for (CollectorMetricState metric : metrics)
        {
            if (metric.metricCode() == null || metric.metricCode().isBlank() || metric.value() == null
                    || metric.unit() == null || metric.unit().isBlank() || !codes.add(metric.metricCode().trim()))
                    || !metric.metricCode().equals(metric.metricCode().trim())
                    || metric.unit() == null || metric.unit().isBlank()
                    || !metric.unit().equals(metric.unit().trim()) || !codes.add(metric.metricCode()))
                throw new IllegalArgumentException("Collector metric code, value and unit must be valid and unique");
        }
    }
@@ -350,6 +409,11 @@
        public CollectorRealtimeSnapshot { measurements = List.copyOf(measurements); }
    }
    public record CollectorAdvance(CollectorState state, List<CollectorMeasurementRecord> appendedRecords)
    {
        public CollectorAdvance { appendedRecords = List.copyOf(appendedRecords); }
    }
    public record CollectorHistoryPage(String collectorCode, long offset, long nextOffset, boolean hasMore,
            long total, List<CollectorMeasurementRecord> records)
    {