yeg
2026-09-29 b78295e8da96c3e5e76befeef9582ce5273dbb74
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
package com.battery.business.service.impl;
 
import java.math.BigDecimal;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Clock;
import java.time.Instant;
import java.util.Date;
import java.util.HexFormat;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import com.alibaba.fastjson2.JSON;
import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
import com.battery.device.api.TestDataObservationAdapter.CollectorMeasurement;
import com.battery.device.api.TestDataObservationAdapter.CollectorRealtime;
import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
import com.battery.device.api.TestDataObservationAdapter.StepRecord;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.business.domain.TTest;
import com.battery.business.domain.TTestBattery;
import com.battery.business.domain.TTestChannelBinding;
import com.battery.business.domain.TTestCycleRecord;
import com.battery.business.domain.TTestRealtimeRecord;
import com.battery.business.domain.TTestStepRecord;
import com.battery.business.mapper.TTestMapper;
import com.battery.business.service.ITTestService;
import com.battery.business.service.ITestDataReceptionService;
 
/** 接收期间将成功观测直接归属测试,并写入实时、工步和循环事实表。 */
@Service
public class TestDataReceptionServiceImpl implements ITestDataReceptionService
{
    private static final Logger log = LoggerFactory.getLogger(TestDataReceptionServiceImpl.class);
    private final TTestMapper mapper;
    private final ITTestService tests;
    private final TestDataObservationAdapter adapter;
    private final TestWarningExceptionService warningExceptions;
    private final Clock clock;
    private final Map<String, Instant> lastSnapshotAt = new java.util.concurrent.ConcurrentHashMap<>();
    private final Map<Long, Instant> lastRealtimeCheckAt = new java.util.concurrent.ConcurrentHashMap<>();
    /** 每个测试电池上次读取工步与循环的时刻;读取节奏对齐来源写库周期,不跟着调度节奏。 */
    private final Map<String, Instant> lastAggregateAt = new java.util.concurrent.ConcurrentHashMap<>();
    /** 每个测试电池各自的接收水位;只存在内存中,重启后按来源里的最新一行重新取水位。 */
    private final Map<String, SampleCursor> sampleCursors = new java.util.concurrent.ConcurrentHashMap<>();
 
 
    @Autowired
    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, @Nullable TestDataObservationAdapter adapter,
            TestWarningExceptionService warningExceptions)
    { this(mapper, tests, adapter, warningExceptions, Clock.systemUTC()); }
 
    /** 允许测试固定接收时间,生产运行仍使用 UTC 系统时钟。 */
    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, TestDataObservationAdapter adapter, Clock clock)
    { this(mapper, tests, adapter, new TestWarningExceptionService(mapper), clock); }
 
    public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, TestDataObservationAdapter adapter)
    { this(mapper, tests, adapter, new TestWarningExceptionService(mapper), Clock.systemUTC()); }
 
    private TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, TestDataObservationAdapter adapter,
            TestWarningExceptionService warningExceptions, Clock clock)
    { this.mapper = mapper; this.tests = tests; this.adapter = adapter; this.warningExceptions = warningExceptions; this.clock = clock; }
 
    /**
     * 接收调度入口:对全部接收中的测试判定实时超限与来源离线异常,并按各自节奏保存成功观测。
     *
     * <p>前置条件:测试处于“检测中 + 接收中”。预警判定按约 5 秒节奏,柜端逐行接收和工步、
     * 循环汇总按来源写库节奏限流;只有成功观测且可归属本测试的数据才写入事实表,不执行历史补采。</p>
     *
     * @throws org.springframework.dao.DataAccessException 任一写入失败时整个接收事务回滚,本轮不保存任何测试的数据
     */
    @Override @Transactional(rollbackFor = Exception.class)
    public void captureReceivedData()
    {
        for (TTest test : receivingTests()) {
            Instant now = clock.instant();
            Instant previous = lastRealtimeCheckAt.get(test.getTestId());
            // STEP 1: 按约 5 秒节奏判定当前来源离线事实与实时超限,预警不受采样入库周期限制。
            // 实时预警独立于持久化槽位,并与暂停、停止和删除共享测试事务锁。
            if (previous == null || !now.isBefore(previous.plusSeconds(5))) {
                warningExceptions.recordOfflineSources(test, Date.from(now));
                for (TTestBattery battery : batteries(test)) saveRealtime(test, battery);
                lastRealtimeCheckAt.put(test.getTestId(), now);
            }
            // STEP 2: 单独按来源写库节奏读取工步与循环汇总,避免每秒重查厂商库。
            for (TTestBattery battery : batteries(test)) saveDeviceAggregates(test, battery);
        }
    }
 
    private List<TTest> receivingTests()
    {
        List<Long> ids = mapper.selectReceivingTestIds(); if (ids == null) return List.of();
        return ids.stream().map(id -> {
            TTest locked = mapper.selectTestByIdForUpdate(id);
            if (locked == null || !"RUNNING".equals(locked.getTestStatus()) || !"RECEIVING".equals(locked.getReceptionStatus())) return null;
            TTest current = tests.selectTestById(id);
            return current != null && "RUNNING".equals(current.getTestStatus()) && "RECEIVING".equals(current.getReceptionStatus()) ? current : null;
        }).filter(java.util.Objects::nonNull).toList();
    }
    private List<TTestBattery> batteries(TTest test) { return test.getBatteries() == null ? List.of() : test.getBatteries(); }
 
    /**
     * 保存单个测试电池的一次实时观测:来源支持设备数据行时逐行落库,否则回落到轮询槽位。
     *
     * <p>前置条件:测试正在接收且绑定通道完整。柜端与采集器分别判断成功,失败来源的字段保持为空。</p>
     *
     * @param test   接收中的测试
     * @param battery 测试内的一个电池号及其通道绑定
     */
    private void saveRealtime(TTest test, TTestBattery battery)
    {
        if (adapter == null) return;
        TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
        // STEP 1: 优先按来源水位逐行接收柜端事实(同秒多行都保留),槽位只留给采集器端。
        boolean rowBased = persistCabinetSamples(test, battery, binding);
        // STEP 2: 来源不按设备数据行接收时,落到轮询槽位,保持原来的组合观测语义。
        saveRealtimeSlot(test, battery, binding, !rowBased);
    }
 
    /**
     * 按来源水位逐行接收柜端事实。水位是"开始接收"或"继续接收"这一刻来源里的最新一行,
     * 因此水位之前的数据行即使仍在来源里也不进本测试,暂停期间的空档也不会被补回来。
     *
     * <p>前置条件:调用方持有该测试电池的接收游标。读取节奏按来源写库周期限流;
     * 实例缺失、来源不可达或读取失败时明确失败并留痕,不回退到其他实例。</p>
     *
     * @param test    接收中的测试
     * @param battery 测试内的一个电池号
     * @param binding 该电池号绑定的检测柜通道
     * @return 该来源是否按设备数据行接收;来源不支持或被拒绝时返回 false,调用方回落到轮询槽位
     */
    private boolean persistCabinetSamples(TTest test, TTestBattery battery, TTestChannelBinding binding)
    {
        String key = test.getTestId() + ":" + battery.getTestBatteryId();
        Instant boundary = test.getReceptionBoundaryAtUtc() == null ? null : test.getReceptionBoundaryAtUtc().toInstant();
        SampleCursor cursor = sampleCursors.computeIfAbsent(key, ignored -> new SampleCursor());
        synchronized (cursor)
        {
            // STEP 1: 开始接收与继续接收都会推进接收边界,边界变化即重新取水位,暂停期间的数据不补采。
            if (!java.util.Objects.equals(cursor.boundary, boundary))
            {
                cursor.boundary = boundary; cursor.watermark = null; cursor.rowBased = null; cursor.sampledAt = null;
            }
            if (Boolean.FALSE.equals(cursor.rowBased)) return false;
            ChannelIdentity channel = channelIdentity(binding);
            Instant now = clock.instant();
            long interval = persistenceInterval(binding);
            // 厂商写库节奏是 60 秒,取数不必更快;展示节奏由控制台轮询单独承担。
            if (cursor.sampledAt != null && now.isBefore(cursor.sampledAt.plusMillis(interval)))
                return Boolean.TRUE.equals(cursor.rowBased);
            try
            {
                // STEP 2: 首次接触该边界时只记录水位,不把水位之前的历史行带入本测试。
                if (cursor.watermark == null)
                {
                    cursor.sampledAt = now;
                    SampleWatermark watermark = adapter.currentWatermark(channel);
                    if (watermark == null)
                    {
                        // 来源不按设备数据行接收(例如开发模拟器):柜端事实继续走轮询槽位。
                        cursor.rowBased = false;
                        return false;
                    }
                    cursor.watermark = watermark;
                    cursor.rowBased = true;
                    return true;
                }
                // STEP 3: 逐行写入水位之后的设备事实,每行成功后推进水位。
                List<CabinetRealtime> rows = adapter.samplesSince(channel, cursor.watermark);
                cursor.sampledAt = now;
                for (CabinetRealtime row : rows)
                {
                    insertCabinetSample(test, battery, binding, row);
                    cursor.watermark = new SampleWatermark(row.deviceTime(), row.sequenceId());
                }
                return true;
            }
            catch (RuntimeException exception)
            {
                // 实例缺失、来源不可达或读取失败:明确失败并留痕,下一轮重试;不回退到其他实例。
                log.warn("柜端逐行接收失败 testId={}, bindingId={}, adapterCode={}", test.getTestId(),
                        binding.getBindingId(), binding.getAdapterCode(), exception);
                cursor.sampledAt = now;
                return Boolean.TRUE.equals(cursor.rowBased);
            }
        }
    }
 
    /**
     * 把一行设备事实写成一条实时记录;来源行身份保证重复接收幂等、同身份不同内容报冲突。
     * 归属边界由水位在设备时间里判定(见 {@link #persistCabinetSamples}),
     * 这里不用服务器取数时间反推设备时间。
     *
     * @param test    接收中的测试
     * @param battery 测试内的一个电池号
     * @param binding 该电池号绑定的检测柜通道
     * @param row     来源返回的一行柜端事实;设备时间或服务器接收时间缺失时丢弃不写
     */
    private void insertCabinetSample(TTest test, TTestBattery battery, TTestChannelBinding binding, CabinetRealtime row)
    {
        if (row.deviceTime() == null || row.serverReceivedAt() == null) return;
        // STEP 1: 先用本次成功观测判定实时超限,异常不依赖采样入库周期。
        Date observedAt = Date.from(row.serverReceivedAt());
        evaluateCabinet(test, binding, "VOLTAGE", BigDecimal.valueOf(row.voltageV()), "V", observedAt);
        evaluateCabinet(test, binding, "CURRENT", BigDecimal.valueOf(row.currentA()), "A", observedAt);
        evaluateCabinet(test, binding, "CAPACITY", BigDecimal.valueOf(row.capacityAh()), "Ah", observedAt);
        evaluateCabinet(test, binding, "ENERGY", BigDecimal.valueOf(row.energyWh()), "Wh", observedAt);
        // STEP 2: 按来源身份组装记录,槽位留空以允许同秒多行各自成立。
        String identity = sourceIdentity(binding);
        String recordId = row.sourceRecordId() == null || row.sourceRecordId().isBlank()
                ? "cabinet:" + row.sequenceId() : row.sourceRecordId();
        TTestRealtimeRecord value = new TTestRealtimeRecord();
        value.setTestId(test.getTestId()); value.setTestBatteryId(battery.getTestBatteryId());
        value.setBindingId(binding.getBindingId()); value.setAdapterCode(binding.getAdapterCode());
        // 设备数据行不是系统轮询槽位,槽位留空:同秒多行因此可以各自成立(ADR-0051 的槽位可空机制)。
        value.setCaptureSlotUtc(null); value.setServerReceivedAtUtc(observedAt);
        value.setCabinetDevtype(binding.getDevtype()); value.setCabinetDevid(binding.getDevid());
        value.setCabinetSubdevid(binding.getSubdevid()); value.setCabinetChlid(binding.getChlid());
        value.setCabinetAdapterTimezoneId(row.timezoneId());
        value.setCabinetSequenceId(row.sequenceId());
        value.setCabinetDeviceTimeUtc(Date.from(row.deviceTime()));
        value.setCabinetDeviceTimeRaw(row.deviceTimeRaw());
        value.setCycleNo(row.cycleNo()); value.setStepNo(row.stepNo()); value.setStepType(row.stepType());
        value.setStepName(row.stepName());
        value.setVoltageV(BigDecimal.valueOf(row.voltageV())); value.setCurrentA(BigDecimal.valueOf(row.currentA()));
        value.setCapacityAh(BigDecimal.valueOf(row.capacityAh())); value.setEnergyWh(BigDecimal.valueOf(row.energyWh()));
        Map<String, Object> extension = new LinkedHashMap<>();
        if (row.relativeTimeSeconds() != null) extension.put("relativeTimeSeconds", row.relativeTimeSeconds());
        value.setExtensionMeasurementsJson(JSON.toJSONString(extension));
        value.setSourceIdentity(identity); value.setSourceRecordId(recordId);
        value.setContentSha256(hash(value));
        // STEP 3: 按来源行身份幂等;同内容重复接收忽略,同身份但内容不同时保留原事实并告警,不覆盖。
        String existing = mapper.selectRealtimeRecordHashBySourceRecord(battery.getTestBatteryId(), identity, recordId);
        if (existing != null)
        {
            if (!existing.equalsIgnoreCase(HexFormat.of().formatHex(value.getContentSha256())))
                log.warn("实时记录内容冲突,拒绝覆盖 testId={}, testBatteryId={}, sourceRecordId={}",
                        test.getTestId(), battery.getTestBatteryId(), recordId);
            return;
        }
        mapper.insertRealtimeRecord(value);
    }
 
    /**
     * 向轮询槽位写入一条实时采样记录,柜端与采集器端的成功观测在同一行合并保存。
     *
     * <p>前置条件:调用方已判定该来源不按设备数据行接收。槽位按持久化周期对齐,同一槽位只写一行;
     * 柜端和采集器端分别判断成功,失败来源的身份、时间和指标保持为空。</p>
     *
     * @param test          接收中的测试
     * @param battery       测试内的一个电池号
     * @param binding       该电池号绑定的检测柜通道
     * @param includeCabinet 本次是否把柜端事实合并进该槽位
     */
    private void saveRealtimeSlot(TTest test, TTestBattery battery, TTestChannelBinding binding, boolean includeCabinet)
    {
        // STEP 1: 柜端和采集器端分别判断成功,各取最近一次成功观测;同一槽位只写一行,失败来源的身份、时间和指标保持为空。
        Instant now = clock.instant(); long interval = persistenceInterval(binding); String key = test.getTestId() + ":" + battery.getTestBatteryId();
        CabinetRealtime cabinet = includeCabinet ? latestCabinet(binding) : null;
        if (cabinet != null && !accepted(test, cabinet.deviceTime(), cabinet.serverReceivedAt())) cabinet = null;
        CollectorRealtime collector = latestCollector(battery);
        Map<String, CollectorMeasurement> mapped = mappedCollectorMetrics(test, battery, collector);
        if (cabinet == null && (collector == null || !collector.online() || mapped.isEmpty())) return;
        // STEP 2: 槽位尚未到期或历史行被删除都不能阻断实时告警,当前成功测量先判定预警。
        if (cabinet != null) {
            Date observedAt = Date.from(cabinet.serverReceivedAt());
            evaluateCabinet(test, binding, "VOLTAGE", BigDecimal.valueOf(cabinet.voltageV()), "V", observedAt);
            evaluateCabinet(test, binding, "CURRENT", BigDecimal.valueOf(cabinet.currentA()), "A", observedAt);
            evaluateCabinet(test, binding, "CAPACITY", BigDecimal.valueOf(cabinet.capacityAh()), "Ah", observedAt);
            evaluateCabinet(test, binding, "ENERGY", BigDecimal.valueOf(cabinet.energyWh()), "Wh", observedAt);
        }
        for (CollectorMeasurement measurement : mapped.values().stream().distinct().toList()) {
            warningExceptions.evaluate(test, "COLLECTOR", battery.getCollectorId() + ":" + measurement.channelCode(),
                    measurement.metricCode(), measurement.value(), measurement.unit(), Date.from(measurement.serverReceivedAt()));
        }
        // STEP 3: 按持久化周期限流并对齐槽位;删除修订随数据库保留,重启后同一槽位也不能重新出现。
        Instant previous = lastSnapshotAt.get(key); if (previous != null && now.isBefore(previous.plusMillis(interval))) return;
        Instant collectorTime = mapped.values().stream().map(CollectorMeasurement::deviceTime).max(Instant::compareTo).orElse(null);
        Instant slot = Instant.ofEpochMilli((now.toEpochMilli() / interval) * interval);
        if (mapper.wasRealtimeSlotDeleted(test.getTestId(), battery.getTestBatteryId(), slot.toEpochMilli())) return;
        // STEP 4: 组装组合观测行,只写成功来源的字段。
        TTestRealtimeRecord row = new TTestRealtimeRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId());
        row.setAdapterCode(binding.getAdapterCode()); row.setCaptureSlotUtc(Date.from(slot)); row.setServerReceivedAtUtc(Date.from(now));
        if (cabinet != null) { row.setCabinetDevtype(binding.getDevtype()); row.setCabinetDevid(binding.getDevid()); row.setCabinetSubdevid(binding.getSubdevid()); row.setCabinetChlid(binding.getChlid()); row.setCabinetAdapterTimezoneId(cabinet.timezoneId()); }
        if (collector != null && collector.online() && !mapped.isEmpty()) { row.setCollectorId(battery.getCollectorId()); row.setCollectorAdapterTimezoneId(collector.timezoneId()); }
        row.setCabinetSequenceId(cabinet == null ? null : cabinet.sequenceId()); row.setCabinetDeviceTimeUtc(cabinet == null ? null : Date.from(cabinet.deviceTime())); row.setCabinetDeviceTimeRaw(cabinet == null ? null : cabinet.deviceTimeRaw());
        row.setCollectorSequenceId(mapped.values().stream().map(CollectorMeasurement::sequenceId).max(Long::compareTo).orElse(null)); row.setCollectorDeviceTimeUtc(collectorTime == null ? null : Date.from(collectorTime)); row.setCollectorDeviceTimeRaw(mapped.values().stream().filter(value -> value.deviceTime().equals(collectorTime)).map(CollectorMeasurement::deviceTimeRaw).findFirst().orElse(null));
        if (cabinet != null) { row.setCycleNo(cabinet.cycleNo()); row.setStepNo(cabinet.stepNo()); row.setStepType(cabinet.stepType()); row.setStepName(cabinet.stepName()); row.setVoltageV(BigDecimal.valueOf(cabinet.voltageV())); row.setCurrentA(BigDecimal.valueOf(cabinet.currentA())); row.setCapacityAh(BigDecimal.valueOf(cabinet.capacityAh())); row.setEnergyWh(BigDecimal.valueOf(cabinet.energyWh())); }
        row.setTemperature(metricValue(mapped, "TEMPERATURE")); row.setPositivePotential(metricValue(mapped, "POSITIVE_POTENTIAL")); row.setNegativePotential(metricValue(mapped, "NEGATIVE_POTENTIAL")); row.setCollectorChannelCode(mapped.values().stream().map(CollectorMeasurement::channelCode).findFirst().orElse(null));
        Map<String, Object> extension = new LinkedHashMap<>();
        // 工步内相对时长由成功柜端观测提供,不以轮询槽位或服务器时间推算。
        if (cabinet != null && cabinet.relativeTimeSeconds() != null) extension.put("relativeTimeSeconds", cabinet.relativeTimeSeconds());
        for (Map.Entry<String, CollectorMeasurement> e : mapped.entrySet()) if (!isFixedMetric(e.getKey()))
        {
            Map<String, Object> value = new LinkedHashMap<>(); value.put("value", e.getValue().value()); value.put("unit", e.getValue().unit()); value.put("channelCode", e.getValue().channelCode()); extension.put(e.getKey(), value);
        }
        row.setExtensionMeasurementsJson(JSON.toJSONString(extension)); row.setSourceIdentity(sourceIdentity(binding));
        row.setSourceRecordId(sourceRecordId(cabinet, mapped));
        row.setContentSha256(hash(row));
        // STEP 5: 同一槽位内容不相同报冲突并保留原事实;插入被忽略时再核对一次并发写入。
        String old = mapper.selectRealtimeRecordContentHash(test.getTestId(), battery.getTestBatteryId(), row.getCaptureSlotUtc());
        if (old != null) { if (!old.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256()))) log.warn("实时采样内容冲突,拒绝覆盖 testId={}, testBatteryId={}, captureSlotUtc={}", test.getTestId(), battery.getTestBatteryId(), slot); return; }
        int inserted = mapper.insertRealtimeRecord(row);
        if (inserted == 0)
        {
            String actual = mapper.selectRealtimeRecordContentHash(test.getTestId(), battery.getTestBatteryId(), row.getCaptureSlotUtc());
            if (actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())))
                log.warn("实时采样并发内容冲突,拒绝覆盖 testId={}, testBatteryId={}, captureSlotUtc={}", test.getTestId(), battery.getTestBatteryId(), slot);
        }
        lastSnapshotAt.put(key, now);
    }
 
    private void evaluateCabinet(TTest test, TTestChannelBinding binding, String metricCode, BigDecimal value, String unit, Date occurredAt)
    {
        if (value != null) warningExceptions.evaluate(test, "CABINET", sourceIdentity(binding), metricCode, value, unit, occurredAt);
    }
 
    /**
     * 保存单个测试电池的工步与循环汇总事实,来源编号或明确的时间组合保证重复观测幂等。
     *
     * <p>前置条件:测试正在接收且绑定通道完整。只接受来源返回的统计记录,不从实时采样推算;
     * 已按授权物理删除的汇总不会因为重复观测被写回。</p>
     *
     * @param test    接收中的测试
     * @param battery 测试内的一个电池号及其通道绑定
     */
    private void saveDeviceAggregates(TTest test, TTestBattery battery)
    {
        // STEP 1: 按来源写库节奏限流,避免接收调度每秒重查厂商库。
        // 工步、循环只接受来源返回的统计记录,并以来源编号或明确的时间组合执行幂等。
        if (adapter == null) return; TTestChannelBinding binding = battery.getBinding(); if (binding == null || binding.getDevid() == null || binding.getChlid() == null) return;
        String key = test.getTestId() + ":" + battery.getTestBatteryId();
        // 接收调度每秒跑一次,而来源按自己的写库节奏产出工步:不按来源节奏限流会每秒重查厂商库。
        Instant now = clock.instant(); Instant previous = lastAggregateAt.get(key);
        if (previous != null && now.isBefore(previous.plusMillis(persistenceInterval(binding)))) return;
        lastAggregateAt.put(key, now);
        // STEP 2: 读取来源的工步与循环汇总;来源不可达时本轮放弃,下一轮重试。
        ChannelIdentity channel = new ChannelIdentity(binding.getAdapterCode(), binding.getDevtype(), binding.getDevid(), binding.getSubdevid(), binding.getChlid());
        List<StepRecord> steps; List<CycleRecord> cycles; try { steps = adapter.stepRecords(channel); cycles = adapter.cycleRecords(channel); } catch (RuntimeException ex) { return; }
        String identity = sourceIdentity(binding);
        // STEP 3: 逐条写入可归属的实际工步,已删除的来源行跳过,内容不一致时保留原事实。
        for (StepRecord source : steps == null ? List.<StepRecord>of() : steps)
        {
            if (!accepted(test, source.endedAt(), source.serverReceivedAt())) continue;
            TTestStepRecord row = new TTestStepRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setAdapterTimezoneId(source.timezoneId()); row.setCycleNo(source.cycleNo()); row.setStepNo(source.stepNo()); row.setStepType(source.stepType()); row.setStepName(source.stepName()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setVoltageV(BigDecimal.valueOf(source.endVoltageV())); row.setCurrentA(BigDecimal.valueOf(source.endCurrentA())); row.setCapacityAh(decimal(source.capacityAh())); row.setEnergyWh(decimal(source.energyWh())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() + ":" + source.stepNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
            // 物理删除依靠已有修订追溯识别来源,重复观测不能把已删除汇总重新写回。
            row.setExtensionMeasurementsJson(JSON.toJSONString(source.measurements()));
            row.setContentSha256(hash(row));
            if (mapper.wasAggregateDeleted(test.getTestId(), "STEP", battery.getTestBatteryId(), identity, row.getDedupeKey())) continue;
            if (mapper.insertStepRecord(row) == 0)
            {
                String actual = mapper.selectStepRecordContentHash(row.getTestBatteryId(), row.getSourceIdentity(), row.getDedupeKey());
                if (actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())))
                    log.warn("工步记录内容冲突,拒绝覆盖 testId={}, dedupeKey={}", test.getTestId(), row.getDedupeKey());
            }
        }
        // STEP 4: 逐条写入可归属的循环汇总,来源类别随记录保存以便区分推导值与设备事实。
        for (CycleRecord source : cycles == null ? List.<CycleRecord>of() : cycles)
        {
            if (!accepted(test, source.endedAt(), source.serverReceivedAt())) continue;
            TTestCycleRecord row = new TTestCycleRecord(); row.setTestId(test.getTestId()); row.setTestBatteryId(battery.getTestBatteryId()); row.setBindingId(binding.getBindingId()); row.setAdapterCode(binding.getAdapterCode()); row.setSourceIdentity(identity); row.setAdapterTimezoneId(source.timezoneId()); row.setSourceRecordId(blankToNull(source.sourceRecordId())); row.setSourceKind(source.sourceKind()); row.setCycleNo(source.cycleNo()); row.setDeviceTimeUtc(Date.from(source.endedAt())); row.setDeviceTimeRaw(source.deviceTimeRaw()); row.setServerReceivedAtUtc(Date.from(source.serverReceivedAt())); row.setChargeCapacityAh(BigDecimal.valueOf(source.chargeCapacityAh())); row.setDischargeCapacityAh(BigDecimal.valueOf(source.dischargeCapacityAh())); row.setChargeEnergyWh(BigDecimal.valueOf(source.chargeEnergyWh())); row.setDischargeEnergyWh(BigDecimal.valueOf(source.dischargeEnergyWh())); row.setEfficiency(decimal(source.efficiency())); row.setDedupeKey(row.getSourceRecordId() == null ? "fallback:" + source.endedAt() + ":" + source.cycleNo() : "record:" + row.getSourceRecordId()); row.setContentSha256(hash(row));
            if (mapper.wasAggregateDeleted(test.getTestId(), "CYCLE", battery.getTestBatteryId(), identity, row.getDedupeKey())) continue;
            if (mapper.insertCycleRecord(row) == 0)
            {
                String actual = mapper.selectCycleRecordContentHash(row.getTestBatteryId(), row.getSourceIdentity(), row.getDedupeKey());
                if (actual != null && !actual.equalsIgnoreCase(HexFormat.of().formatHex(row.getContentSha256())))
                    log.warn("循环记录内容冲突,拒绝覆盖 testId={}, dedupeKey={}", test.getTestId(), row.getDedupeKey());
            }
        }
    }
 
    private CabinetRealtime latestCabinet(TTestChannelBinding b) { try { return adapter.latestCabinet(channelIdentity(b)); } catch (RuntimeException ex) { return null; } }
    private static ChannelIdentity channelIdentity(TTestChannelBinding b) { return new ChannelIdentity(b.getAdapterCode(), b.getDevtype(), b.getDevid(), b.getSubdevid(), b.getChlid()); }
    private CollectorRealtime latestCollector(TTestBattery b) { if (b.getCollectorId() == null || b.getBinding() == null) return null; try { return adapter.latestCollector(b.getBinding().getAdapterCode(), mapper.selectCollectorCode(b.getCollectorId())); } catch (RuntimeException ex) { return null; } }
    private Map<String, CollectorMeasurement> mappedCollectorMetrics(TTest test, TTestBattery battery, CollectorRealtime source)
    {
        Map<String, CollectorMeasurement> result = new LinkedHashMap<>();
        if (source == null || !source.online() || battery.getCollectorChannels() == null) return result;
        for (var binding : battery.getCollectorChannels()) for (var measurement : source.measurements()) {
            if (accepted(test, measurement.deviceTime(), measurement.serverReceivedAt())
                    && java.util.Objects.equals(binding.getChannelCode(), measurement.channelCode())
                    && TestWarningExceptionService.matchesCollectorMetric(binding.getMeasurementCode(), measurement.metricCode())) {
                // 绑定角色区分主、辅助测量;不能用设备通用指标名覆盖其他物理通道。
                result.put(binding.getMeasurementCode().toUpperCase(Locale.ROOT), measurement);
            }
        }
        return result;
    }
    private static boolean isFixedMetric(String code) { return List.of("TEMPERATURE", "POSITIVE_POTENTIAL", "NEGATIVE_POTENTIAL", "PRIMARY_TEMPERATURE", "PRIMARY_POSITIVE_POTENTIAL", "PRIMARY_NEGATIVE_POTENTIAL").contains(code); }
    private static BigDecimal metricValue(Map<String, CollectorMeasurement> values, String code) {
        var measurement = values.get("PRIMARY_" + code);
        if (measurement == null) measurement = values.get(code);
        return measurement == null ? null : measurement.value();
    }
    private static String sourceRecordId(CabinetRealtime cabinet, Map<String, CollectorMeasurement> measurements)
    {
        List<String> parts = new java.util.ArrayList<>();
        if (cabinet != null) parts.add("cabinet:" + cabinet.sequenceId());
        measurements.values().stream().map(CollectorMeasurement::sequenceId).max(Long::compareTo)
                .ifPresent(sequence -> parts.add("collector:" + sequence));
        return String.join(":", parts);
    }
    private long persistenceInterval(TTestChannelBinding b) { try { return Math.max(100L, adapter.persistenceIntervalMillis(channelIdentity(b))); } catch (RuntimeException ex) { return 60000L; } }
    private static String sourceIdentity(TTestChannelBinding b) { return String.join(":", String.valueOf(b.getAdapterCode()), String.valueOf(b.getDevtype()), String.valueOf(b.getDevid()), String.valueOf(b.getSubdevid()), String.valueOf(b.getChlid())); }
    private static String timezone(TTestChannelBinding b) { return b.getAdapterTimezoneId() == null || b.getAdapterTimezoneId().isBlank() ? "Asia/Hong_Kong" : b.getAdapterTimezoneId(); }
    private static String blankToNull(String value) { return value == null || value.isBlank() ? null : value; }
    /** 设备未提供的单项值保持为空:零是设备事实,缺失不能写成零。 */
    private static BigDecimal decimal(Double value) { return value == null ? null : BigDecimal.valueOf(value); }
    // 实时提醒与持久化共用归属边界,避免同一旧观测只在首页触发异常。
    static boolean accepted(TTest test, Instant deviceTime, Instant serverReceivedAt)
    {
        if (deviceTime != null && test.getStartedAtUtc() != null && deviceTime.isBefore(test.getStartedAtUtc().toInstant())) return false;
        return serverReceivedAt != null && (test.getReceptionBoundaryAtUtc() == null || !serverReceivedAt.isBefore(test.getReceptionBoundaryAtUtc().toInstant()));
    }
 
    private byte[] hash(Object value)
    {
        // 重复观测比较设备内容,不把服务器轮询时间作为内容变化。
        Map<String, Object> content = new java.util.TreeMap<>(JSON.parseObject(JSON.toJSONString(value)));
        content.remove("serverReceivedAtUtc");
        try { return MessageDigest.getInstance("SHA-256").digest(JSON.toJSONBytes(content)); }
        catch (Exception ex) { throw new IllegalStateException("无法计算测试数据内容摘要", ex); }
    }
 
    /** 单个测试电池的逐行接收状态:接收边界、来源水位、是否按行接收与上次取数时间。 */
    private static final class SampleCursor
    {
        private Instant boundary;
        private SampleWatermark watermark;
        private Boolean rowBased;
        private Instant sampledAt;
    }
}