From 147428971be43eed90a79f3849f15000d3855390 Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期一, 28 九月 2026 15:15:22 +0800
Subject: [PATCH] 实现新威 7.6 直连取数与逐行接收
---
battery-business/src/main/java/com/battery/business/service/impl/TDetectionCabinetServiceImpl.java | 51
battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCabinetVo.java | 12
battery-business/src/main/resources/mapper/battery/TTestMapper.xml | 6
battery-business/src/main/java/com/battery/business/service/impl/RoutingTestDataObservationAdapter.java | 130 ++
battery-device-neware/src/test/java/com/battery/device/neware/FakeVendorDatabase.java | 333 +++++
ruoyi-admin/src/test/java/com/battery/business/service/impl/VendorSampleReceptionServiceTest.java | 261 ++++
battery-device-neware/src/main/java/com/battery/device/neware/NonQueryStatementException.java | 12
ruoyi-admin/src/main/resources/db/migration-final/V22__vendor_source_instance_config.sql | 3
battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java | 140 ++
ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java | 22
battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java | 161 ++
battery-device-api/src/main/java/com/battery/device/api/registry/DeviceAdapterRegistry.java | 75 +
battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyStatementAudit.java | 45
battery-business/src/main/java/com/battery/business/domain/TDetectionCabinet.java | 7
ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java | 97 +
ruoyi-admin/src/test/java/com/battery/business/service/impl/TestDataReceptionServiceImplTest.java | 14
battery-business/src/main/java/com/battery/business/mapper/TTestMapper.java | 3
battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java | 27
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java | 12
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterRuntimeCoordinator.java | 7
ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java | 4
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java | 36
ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java | 9
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapterFactory.java | 78 +
battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java | 207 +++
.gitignore | 1
battery-business/src/main/resources/mapper/battery/TDeviceAdapterMapper.xml | 15
battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java | 47
battery-device-api/src/main/java/com/battery/device/api/registry/AdapterInstanceConfig.java | 21
battery-device-neware/pom.xml | 13
battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java | 7
pom.xml | 6
battery-device-neware/src/main/java/com/battery/device/neware/SingleConnectionVendorDataSource.java | 110 +
battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java | 43
ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java | 22
battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java | 8
ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java | 4
ruoyi-admin/pom.xml | 6
battery-business/src/main/java/com/battery/business/domain/TDeviceAdapter.java | 6
battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java | 237 ++++
battery-device-api/src/main/java/com/battery/device/api/model/DetectedChannel.java | 17
ruoyi-admin/src/test/java/com/battery/business/web/VendorSamplePersistenceDatabaseTest.java | 156 ++
/dev/null | 19
ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java | 33
battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java | 466 ++++++++
battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java | 28
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java | 25
battery-device-api/src/test/java/com/battery/device/api/registry/DeviceAdapterRegistryTest.java | 135 ++
ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingTestDataObservationAdapterTest.java | 163 ++
49 files changed, 3,234 insertions(+), 106 deletions(-)
diff --git a/.gitignore b/.gitignore
index aa757e8..dff4da9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,7 @@
/battery-business/target/
/battery-device-api/target/
/battery-device-bts/target/
+/battery-device-neware/target/
/battery-device-simulator/target/
/ruoyi-framework/target/
/ruoyi-generator/target/
diff --git a/battery-business/src/main/java/com/battery/business/domain/TDetectionCabinet.java b/battery-business/src/main/java/com/battery/business/domain/TDetectionCabinet.java
index 48ca93e..f784ed2 100644
--- a/battery-business/src/main/java/com/battery/business/domain/TDetectionCabinet.java
+++ b/battery-business/src/main/java/com/battery/business/domain/TDetectionCabinet.java
@@ -30,6 +30,9 @@
private Long adapterId;
private String adapterCode;
+ /** 数据来源名称,仅供展示,用于区分不同机器上的同名柜号;不落库。 */
+ private String adapterName;
+
private Integer vendorDeviceType;
private Integer vendorDeviceId;
private Instant lastSeenAtUtc;
@@ -107,7 +110,9 @@
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 void setAdapterCode(String value) { this.adapterCode = value; }
+ public String getAdapterName() { return adapterName; }
+ public void setAdapterName(String value) { adapterName = value; }
public Integer getVendorDeviceType() { return vendorDeviceType; }
public void setVendorDeviceType(Integer value) { vendorDeviceType = value; }
public Integer getVendorDeviceId() { return vendorDeviceId; }
diff --git a/battery-business/src/main/java/com/battery/business/domain/TDeviceAdapter.java b/battery-business/src/main/java/com/battery/business/domain/TDeviceAdapter.java
index e5c38fa..ed700db 100644
--- a/battery-business/src/main/java/com/battery/business/domain/TDeviceAdapter.java
+++ b/battery-business/src/main/java/com/battery/business/domain/TDeviceAdapter.java
@@ -49,6 +49,10 @@
private Boolean readOnly;
private String capabilities;
+
+ /** 来源形态配置(JSON),承载各存储形态互不相交的非敏感字段;连接口令不入库。 */
+ private String sourceConfig;
+
private String status;
/** 适配器连接状态,仅允许 ONLINE/OFFLINE。 */
private String connectionStatus;
@@ -78,6 +82,8 @@
public void setReadOnly(Boolean readOnly) { this.readOnly = readOnly; }
public String getCapabilities() { return capabilities; }
public void setCapabilities(String capabilities) { this.capabilities = capabilities; }
+ public String getSourceConfig() { return sourceConfig; }
+ public void setSourceConfig(String sourceConfig) { this.sourceConfig = sourceConfig; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getConnectionStatus() { return connectionStatus; }
diff --git a/battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCabinetVo.java b/battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCabinetVo.java
index 96d84de..ff8346e 100644
--- a/battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCabinetVo.java
+++ b/battery-business/src/main/java/com/battery/business/domain/vo/DiscoveredCabinetVo.java
@@ -1,6 +1,12 @@
package com.battery.business.domain.vo;
-/** 原检测柜选择弹窗使用的设备摘要。 */
+import java.time.Instant;
+
+/**
+ * 检测柜选择弹窗使用的设备摘要。
+ * {@code lastDataAt} 是该设备最近一次采样行的设备时间(来源里没有采样行时为空),
+ * 由操作员据此判断哪几台柜子现在真的在产出数据;登记与否仍由操作员决定。
+ */
public record DiscoveredCabinetVo(
String deviceCode,
String ip,
@@ -9,7 +15,8 @@
String manufacturer,
String model,
Integer channelCount,
- String onlineStatus)
+ String onlineStatus,
+ Instant lastDataAt)
{
public String getDeviceCode() { return deviceCode; }
public String getIp() { return ip; }
@@ -19,4 +26,5 @@
public String getModel() { return model; }
public Integer getChannelCount() { return channelCount; }
public String getOnlineStatus() { return onlineStatus; }
+ public Instant getLastDataAt() { return lastDataAt; }
}
diff --git a/battery-business/src/main/java/com/battery/business/mapper/TTestMapper.java b/battery-business/src/main/java/com/battery/business/mapper/TTestMapper.java
index 6414b6b..327206f 100644
--- a/battery-business/src/main/java/com/battery/business/mapper/TTestMapper.java
+++ b/battery-business/src/main/java/com/battery/business/mapper/TTestMapper.java
@@ -89,6 +89,9 @@
int insertRealtimeRecord(TTestRealtimeRecord record);
String selectRealtimeRecordContentHash(@Param("testId") Long testId, @Param("testBatteryId") Long testBatteryId,
@Param("captureSlotUtc") Date captureSlotUtc);
+ /** 按来源行身份查询已保存的实时记录摘要;逐行接收据此做幂等与冲突判定。 */
+ String selectRealtimeRecordHashBySourceRecord(@Param("testBatteryId") Long testBatteryId,
+ @Param("sourceIdentity") String sourceIdentity, @Param("sourceRecordId") String sourceRecordId);
int insertStepRecord(TTestStepRecord record);
int insertCycleRecord(TTestCycleRecord record);
String selectStepRecordContentHash(@Param("testBatteryId") Long testBatteryId,
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterRuntimeCoordinator.java b/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterRuntimeCoordinator.java
index d113dff..60d1746 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterRuntimeCoordinator.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterRuntimeCoordinator.java
@@ -2,14 +2,15 @@
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.event.EventListener;
-import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import com.battery.business.service.IDeviceAdapterService;
-/** 在单采集进程内恢复启用实例,并按各自周期执行只读发现。 */
+/**
+ * 在单采集进程内恢复启用实例,并按各自周期执行只读发现。
+ * 不限定 profile:真实来源的发现与接收要在非开发 profile 下运行。
+ */
@Component
-@Profile({ "dev-simulator", "bts" })
public class DeviceAdapterRuntimeCoordinator
{
private final IDeviceAdapterService service;
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java
index b42c954..175558e 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java
@@ -32,6 +32,10 @@
private static final String DISABLED = "1";
private static final String READ_ONLY_CAPABILITIES =
"{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}";
+ /** 适配器类型按存储形态划分;BTS 保留给开发模拟器与将来的协议路线(ADR-0065)。 */
+ private static final List<String> SUPPORTED_TYPES = List.of("BTS", "NEWARE_MYSQL");
+ /** 直连厂商存储的来源,其通道登记清单是长期事实,候选不按时间过期。 */
+ private static final List<String> PERSISTENT_CANDIDATE_TYPES = List.of("NEWARE_MYSQL");
private final TDeviceAdapterMapper mapper;
private final DeviceAdapterRegistry registry;
@@ -136,7 +140,7 @@
registry.synchronize(toRuntimeConfig(adapter));
AdapterDiscoveryResult result = persistence.discoverForReception(registry, new DiscoverySource(
adapter.getAdapterCode(), adapter.getAdapterId(), adapter.getAdapterName(),
- adapter.getPollIntervalMs()));
+ adapter.getPollIntervalMs(), PERSISTENT_CANDIDATE_TYPES.contains(adapter.getAdapterType())));
mapper.updateDiscoverySuccess(adapterId, result.observedAtUtc());
return result;
}
@@ -214,8 +218,9 @@
adapter.setAdapterType(StringUtils.trim(adapter.getAdapterType()).toUpperCase(Locale.ROOT));
adapter.setHost(StringUtils.trim(adapter.getHost()));
adapter.setTimezoneId(StringUtils.trim(adapter.getTimezoneId()));
+ adapter.setSourceConfig(StringUtils.trim(adapter.getSourceConfig()));
adapter.setRemark(StringUtils.trim(adapter.getRemark()));
- if (!"BTS".equals(adapter.getAdapterType()))
+ if (!SUPPORTED_TYPES.contains(adapter.getAdapterType()))
throw error("不支持的适配器类型", DeviceAdapterErrorCodes.UNSUPPORTED_TYPE);
if (!Boolean.TRUE.equals(adapter.getReadOnly()))
throw error("当前里程碑只允许只读设备适配器", DeviceAdapterErrorCodes.INVALID_CONFIG);
@@ -243,7 +248,21 @@
{
return new AdapterInstanceConfig(adapter.getAdapterCode(), adapter.getAdapterType(), adapter.getHost(),
adapter.getPort(), adapter.getPollIntervalMs(), adapter.getTimezoneId(),
- ENABLED.equals(adapter.getStatus()), parseCapabilities(adapter.getCapabilities()));
+ ENABLED.equals(adapter.getStatus()), parseCapabilities(adapter.getCapabilities()),
+ parseSourceConfig(adapter.getSourceConfig()));
+ }
+
+ /** 来源形态配置只承载非敏感项;连接口令由环境变量或启动参数提供,不入库。 */
+ private static java.util.Map<String, String> parseSourceConfig(String sourceConfig)
+ {
+ if (StringUtils.isBlank(sourceConfig)) return java.util.Map.of();
+ JSONObject value = JSON.parseObject(sourceConfig);
+ if (value == null) return java.util.Map.of();
+ java.util.Map<String, String> result = new java.util.LinkedHashMap<>();
+ value.forEach((key, item) -> {
+ if (item != null) result.put(String.valueOf(key), String.valueOf(item));
+ });
+ return result;
}
private AdapterCapabilities parseCapabilities(String capabilities)
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java b/battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java
index 2db8d34..1fe72af 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java
@@ -28,6 +28,11 @@
public class DeviceDiscoveryPersistenceService
{
private static final Duration MINIMUM_CANDIDATE_LIFETIME = Duration.ofSeconds(30);
+ /**
+ * 直连厂商存储的来源,其通道登记清单是长期存在的事实,没有"过期"的对应语义:
+ * 候选是否出现在界面上只由"已登记且未删除"决定,这个时间只是让候选一直有效。
+ */
+ static final Instant CANDIDATE_NEVER_EXPIRES = Instant.parse("9999-12-31T23:59:59Z");
private final DeviceDiscoveryCandidateStore candidateStore;
private final TDetectionCabinetMapper detectionCabinetMapper;
private final TTestMapper testMapper;
@@ -75,7 +80,8 @@
.map(item -> new CabinetCandidate(item.adapterCode(), item.adapterId(), item.deviceCode(),
item.devtype(), item.devid(), item.ip(), item.mac(), item.name(), item.manufacturer(), item.model(),
item.channels().stream().map(channel -> new ChannelCandidate(channel.identity(), false,
- DeviceWorkStatus.UNOBSERVABLE, failedAt)).toList(), failedAt, item.expiresAt()))
+ DeviceWorkStatus.UNOBSERVABLE, failedAt, null, channel.lastDataAt())).toList(),
+ failedAt, item.expiresAt(), item.lastDataAt()))
.toList());
detectionCabinetMapper.markRegisteredFactsUnobservable(adapterCode);
detectionCabinetMapper.markRegisteredCabinetsOffline(adapterCode);
@@ -108,9 +114,10 @@
LinkedHashMap::new, java.util.stream.Collectors.toList()));
Duration lifetime = Duration.ofMillis(Math.max(MINIMUM_CANDIDATE_LIFETIME.toMillis(),
(long) source.pollIntervalMs() * 3));
+ Instant expiresAt = source.persistentCandidates() ? CANDIDATE_NEVER_EXPIRES : observedAt.plus(lifetime);
List<CabinetCandidate> candidates = grouped.entrySet().stream()
.map(entry -> toCandidate(source, entry.getKey(), entry.getValue(), snapshots,
- observedAt, observedAt.plus(lifetime)))
+ observedAt, expiresAt))
.toList();
candidateStore.recordCabinets(candidates);
if (detected.isEmpty()) markUnavailable(source.adapterCode());
@@ -185,16 +192,27 @@
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, snapshots.get(item.identity()));
+ return new ChannelCandidate(item.identity(), item.available(), status, observedAt,
+ snapshots.get(item.identity()), item.lastDataAt());
}).toList();
+ Instant lastDataAt = channels.stream().map(DetectedChannel::lastDataAt)
+ .filter(java.util.Objects::nonNull).max(Instant::compareTo).orElse(null);
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);
+ metadata.manufacturer(), metadata.model(), channelCandidates, observedAt, expiresAt, lastDataAt);
}
/** 发现来源只携带候选标识和过期计算所需配置,不复用数据库实体。 */
- public record DiscoverySource(String adapterCode, Long adapterId, String adapterName, int pollIntervalMs) { }
+ public record DiscoverySource(String adapterCode, Long adapterId, String adapterName, int pollIntervalMs,
+ boolean persistentCandidates)
+ {
+ /** 兼容按轮询过期计算候选的既有来源。 */
+ public DiscoverySource(String adapterCode, Long adapterId, String adapterName, int pollIntervalMs)
+ {
+ this(adapterCode, adapterId, adapterName, pollIntervalMs, false);
+ }
+ }
private record DiscoveryObservation(List<DetectedChannel> channels,
Map<ChannelIdentity, RealtimeSnapshot> snapshots) { }
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/RoutingTestDataObservationAdapter.java b/battery-business/src/main/java/com/battery/business/service/impl/RoutingTestDataObservationAdapter.java
new file mode 100644
index 0000000..067c86b
--- /dev/null
+++ b/battery-business/src/main/java/com/battery/business/service/impl/RoutingTestDataObservationAdapter.java
@@ -0,0 +1,130 @@
+package com.battery.business.service.impl;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Service;
+import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.model.DetectedChannel;
+import com.battery.device.api.model.DeviceObservation;
+import com.battery.device.api.registry.DeviceAdapterRegistry;
+
+/**
+ * 把取数请求按通道身份里的适配器编码送到它所属的来源实例。
+ *
+ * <p>这是持久化与实时展示路径上唯一的取数入口:同一个进程里可以注册多个来源实例,
+ * 每个通道的请求只送到它自己的实例;实例缺失或未提供测试数据观测契约时明确失败,
+ * 不回退到其他实例。发现不走这里,它按实例在 {@link DeviceDiscoveryPersistenceService} 中执行。
+ *
+ * <p>声明为 {@code @Primary}:来源实例可能自己也实现了观测契约(开发模拟器就是这样,
+ * 它同时是 Spring Bean),业务侧注入的必须是这个按编码解析的入口,而不是某台设备的实例。
+ */
+@Service
+@Primary
+public class RoutingTestDataObservationAdapter implements TestDataObservationAdapter
+{
+ /** 聚合路由不是来源实例,这个编码只用于说明"按通道身份路由"。 */
+ public static final String ADAPTER_CODE = "by-source";
+
+ private final DeviceAdapterRegistry registry;
+
+ public RoutingTestDataObservationAdapter(DeviceAdapterRegistry registry)
+ {
+ this.registry = registry;
+ }
+
+ @Override
+ public String adapterCode()
+ {
+ return ADAPTER_CODE;
+ }
+
+ @Override
+ public AdapterCapabilities capabilities()
+ {
+ return AdapterCapabilities.observeOnly();
+ }
+
+ /** 聚合适配器不承载发现;发现按来源实例执行,避免在这条路上猜一个设备。 */
+ @Override
+ public List<DetectedChannel> discoverChannels()
+ {
+ throw new UnsupportedOperationException("设备发现按来源实例执行,不在聚合适配器上执行");
+ }
+
+ /** 一次观测里的通道可能来自多个来源实例,逐个按各自的编码请求,不能按第一个通道决定全部。 */
+ @Override
+ public Map<ChannelIdentity, DeviceObservation> observe(Set<ChannelIdentity> channels)
+ {
+ if (channels == null || channels.isEmpty()) return Map.of();
+ Map<ChannelIdentity, DeviceObservation> result = new java.util.LinkedHashMap<>();
+ for (Map.Entry<String, List<ChannelIdentity>> group : groupBySource(channels).entrySet())
+ {
+ Set<ChannelIdentity> groupChannels = new java.util.LinkedHashSet<>(group.getValue());
+ result.putAll(registry.executeTestData(group.getKey(), adapter -> adapter.observe(groupChannels)));
+ }
+ return Map.copyOf(result);
+ }
+
+ private static Map<String, List<ChannelIdentity>> groupBySource(Set<ChannelIdentity> channels)
+ {
+ Map<String, List<ChannelIdentity>> grouped = new java.util.LinkedHashMap<>();
+ for (ChannelIdentity channel : channels)
+ grouped.computeIfAbsent(channel.adapterCode(), ignored -> new java.util.ArrayList<>()).add(channel);
+ return grouped;
+ }
+
+ @Override
+ public CabinetRealtime latestCabinet(ChannelIdentity channel)
+ {
+ return registry.executeTestData(channel.adapterCode(), adapter -> adapter.latestCabinet(channel));
+ }
+
+ @Override
+ public CollectorRealtime latestCollector(String adapterCode, String collectorCode)
+ {
+ // 采集器测量属于某个来源实例的辅助测量,按该实例请求;实例缺失时明确失败。
+ // 来源编码为空说明测试电池连检测柜绑定都没有,此时来源未知:不猜也不回退,报无法请求。
+ if (adapterCode == null || adapterCode.isBlank()) return null;
+ return registry.executeTestData(adapterCode, adapter -> adapter.latestCollector(adapterCode, collectorCode));
+ }
+
+ @Override
+ public List<StepRecord> stepRecords(ChannelIdentity channel)
+ {
+ return registry.executeTestData(channel.adapterCode(), adapter -> adapter.stepRecords(channel));
+ }
+
+ @Override
+ public List<CycleRecord> cycleRecords(ChannelIdentity channel)
+ {
+ return registry.executeTestData(channel.adapterCode(), adapter -> adapter.cycleRecords(channel));
+ }
+
+ @Override
+ public long persistenceIntervalMillis(ChannelIdentity channel)
+ {
+ return registry.executeTestData(channel.adapterCode(), adapter -> adapter.persistenceIntervalMillis(channel));
+ }
+
+ @Override
+ public TestPlan testPlan(ChannelIdentity channel)
+ {
+ return registry.executeTestData(channel.adapterCode(), adapter -> adapter.testPlan(channel));
+ }
+
+ @Override
+ public SampleWatermark currentWatermark(ChannelIdentity channel)
+ {
+ return registry.executeTestData(channel.adapterCode(), adapter -> adapter.currentWatermark(channel));
+ }
+
+ @Override
+ public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark)
+ {
+ return registry.executeTestData(channel.adapterCode(), adapter -> adapter.samplesSince(channel, watermark));
+ }
+}
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/TDetectionCabinetServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/TDetectionCabinetServiceImpl.java
index de81e9c..9c485e1 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/TDetectionCabinetServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/TDetectionCabinetServiceImpl.java
@@ -18,6 +18,7 @@
import com.battery.business.domain.TDetectionChannel;
import com.battery.business.domain.vo.DiscoveredCabinetVo;
import com.battery.business.mapper.TDetectionCabinetMapper;
+import com.battery.business.service.IDeviceAdapterService;
import com.battery.business.service.ITDetectionCabinetService;
import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore;
import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
@@ -39,6 +40,7 @@
private final TDetectionCabinetMapper detectionCabinetMapper;
private final DeviceDiscoveryCandidateStore candidateStore;
+ private final IDeviceAdapterService adapterService;
/**
* 构造检测柜业务服务。
@@ -48,8 +50,42 @@
public TDetectionCabinetServiceImpl(TDetectionCabinetMapper detectionCabinetMapper,
DeviceDiscoveryCandidateStore candidateStore)
{
+ this(detectionCabinetMapper, candidateStore, null);
+ }
+
+ /**
+ * 构造检测柜业务服务。
+ *
+ * @param detectionCabinetMapper 检测柜数据访问对象
+ * @param candidateStore 发现候选存储
+ * @param adapterService 适配器实例服务;为空时列表不展示数据来源名称
+ */
+ @org.springframework.beans.factory.annotation.Autowired
+ public TDetectionCabinetServiceImpl(TDetectionCabinetMapper detectionCabinetMapper,
+ DeviceDiscoveryCandidateStore candidateStore,
+ @org.springframework.lang.Nullable IDeviceAdapterService adapterService)
+ {
this.detectionCabinetMapper = detectionCabinetMapper;
this.candidateStore = candidateStore;
+ this.adapterService = adapterService;
+ }
+
+ /** 数据来源名称按适配器编码解析;代码装配实例没有数据库记录时退回展示编码。 */
+ private Map<String, String> adapterNames()
+ {
+ if (adapterService == null) return Map.of();
+ return adapterService.selectList(new com.battery.business.domain.TDeviceAdapter()).stream()
+ .filter(item -> item.getAdapterCode() != null && item.getAdapterName() != null)
+ .collect(java.util.stream.Collectors.toMap(
+ com.battery.business.domain.TDeviceAdapter::getAdapterCode,
+ com.battery.business.domain.TDeviceAdapter::getAdapterName, (left, right) -> right));
+ }
+
+ /** 适配器编码可能缺失(历史登记),缺失时不查询来源名称。 */
+ private void applyAdapterName(TDetectionCabinet cabinet, Map<String, String> adapterNames)
+ {
+ if (cabinet == null || cabinet.getAdapterCode() == null) return;
+ cabinet.setAdapterName(adapterNames.get(cabinet.getAdapterCode()));
}
/**
@@ -66,6 +102,7 @@
{
throw new ServiceException("检测柜不存在或已删除");
}
+ applyAdapterName(detectionCabinet, adapterNames());
return applyRealtimeState(detectionCabinet, candidatesByCode());
}
@@ -87,8 +124,10 @@
detectionCabinet.setOnlineStatus(null);
detectionCabinet.setTestStatus(null);
Map<String, CabinetCandidate> candidates = candidatesByCode();
+ Map<String, String> adapterNames = adapterNames();
return detectionCabinetMapper.selectDetectionCabinetList(detectionCabinet).stream()
.map(item -> applyRealtimeState(item, candidates))
+ .peek(item -> applyAdapterName(item, adapterNames))
.filter(item -> StringUtils.isBlank(requestedOnlineStatus)
|| requestedOnlineStatus.equals(item.getOnlineStatus()))
.filter(item -> StringUtils.isBlank(requestedTestStatus)
@@ -97,8 +136,10 @@
}
/**
- * 读取未过期且未登记的候选,离线设备仍保留身份并可选择。
- * 发现只返回设备事实,不从通道工作状态推断系统测试关系。
+ * 读取未登记且未删除的候选,离线设备仍保留身份并可选择。
+ * 候选不按时间过期(厂商存储里的通道登记是长期事实),只按"已登记且未删除"排除;
+ * 每条附带最近数据时间并按它倒序,便于操作员判断哪几台真的在产出数据。
+ * 发现只返回设备事实,不从通道工作状态推断系统测试关系,也不自动判定"在用"。
*
* @return 可供选择的检测柜设备摘要
*/
@@ -110,7 +151,11 @@
.filter(candidate -> !registeredCodes.contains(candidate.deviceCode()))
.map(candidate -> new DiscoveredCabinetVo(
candidate.deviceCode(), candidate.ip(), candidate.mac(), candidate.name(),
- candidate.manufacturer(), candidate.model(), candidate.channels().size(), candidate.online() ? "在线" : "离线"))
+ candidate.manufacturer(), candidate.model(), candidate.channels().size(),
+ candidate.online() ? "在线" : "离线", candidate.lastDataAt()))
+ .sorted(java.util.Comparator.comparing(DiscoveredCabinetVo::lastDataAt,
+ java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))
+ .thenComparing(DiscoveredCabinetVo::deviceCode))
.toList();
}
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java
index 0ee9d4d..f9a7270 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/TTestLiveServiceImpl.java
@@ -191,6 +191,10 @@
private Map<String, Object> snapshot(ChannelCandidate channel)
{
Map<String, Object> value = new LinkedHashMap<>();
+ // 通道工作状态是独立设备事实:读库来源下不可观测,界面把它当正常取值展示而不是异常。
+ value.put("workStatus", channel.workStatus() == null ? null : channel.workStatus().name());
+ // 最近数据时间取来源里最近一次采样行的设备时间,与展示轮询节奏无关。
+ value.put("lastDataAt", channel.lastDataAt() == null ? null : channel.lastDataAt().toEpochMilli());
if (channel.latestSnapshot() == null) return value;
value.put("source", "检测柜");
value.put("observedAt", channel.latestSnapshot().receivedAt().toEpochMilli());
@@ -232,7 +236,8 @@
return new LiveSource("COLLECTOR", battery.getCollectorId(), battery.getBatteryNo(),
online ? "ONLINE" : "OFFLINE", Map.of());
}
- var collector = adapter.latestCollector(collectorCode);
+ var collector = adapter.latestCollector(
+ battery.getBinding() == null ? null : battery.getBinding().getAdapterCode(), collectorCode);
if (collector != null)
{
Map<String, Object> value = new LinkedHashMap<>();
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java
index 9ef74b0..0a2f7f3 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/TestDataReceptionServiceImpl.java
@@ -23,6 +23,7 @@
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;
@@ -47,6 +48,9 @@
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, SampleCursor> sampleCursors = new java.util.concurrent.ConcurrentHashMap<>();
+
@Autowired
public TestDataReceptionServiceImpl(TTestMapper mapper, ITTestService tests, @Nullable TestDataObservationAdapter adapter,
@@ -95,11 +99,127 @@
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;
+ // 来源以设备数据行为接收单位时,柜端事实逐行落库(同秒多行都保留),槽位只留给采集器端。
+ boolean rowBased = persistCabinetSamples(test, battery, binding);
+ saveRealtimeSlot(test, battery, binding, !rowBased);
+ }
+
+ /**
+ * 按来源水位逐行接收柜端事实。水位是"开始接收"或"继续接收"这一刻来源里的最新一行,
+ * 因此水位之前的数据行即使仍在来源里也不进本测试,暂停期间的空档也不会被补回来。
+ *
+ * @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)
+ {
+ // 开始接收与继续接收都会推进接收边界:边界变化即重新取水位,暂停期间的数据不补采。
+ 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
+ {
+ 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;
+ }
+ 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}),
+ * 这里不用服务器取数时间反推设备时间。
+ */
+ private void insertCabinetSample(TTest test, TTestBattery battery, TTestChannelBinding binding, CabinetRealtime row)
+ {
+ if (row.deviceTime() == null || row.serverReceivedAt() == null) return;
+ 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);
+ 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));
+ // 重复接收的同内容记录幂等忽略;同身份但内容不同时保留原事实,不覆盖。
+ 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);
+ }
+
+ private void saveRealtimeSlot(TTest test, TTestBattery battery, TTestChannelBinding binding, boolean includeCabinet)
+ {
+ // 柜端和采集器端分别判断成功;同一槽位只写一行,失败来源的身份、时间和指标保持为空。
Instant now = clock.instant(); long interval = persistenceInterval(binding); String key = test.getTestId() + ":" + battery.getTestBatteryId();
- CabinetRealtime cabinet = latestCabinet(binding);
+ 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);
@@ -192,8 +312,9 @@
}
}
- private CabinetRealtime latestCabinet(TTestChannelBinding b) { try { return adapter.latestCabinet(new ChannelIdentity(b.getAdapterCode(), b.getDevtype(), b.getDevid(), b.getSubdevid(), b.getChlid())); } catch (RuntimeException ex) { return null; } }
- private CollectorRealtime latestCollector(TTestBattery b) { if (b.getCollectorId() == null) return null; try { return adapter.latestCollector(mapper.selectCollectorCode(b.getCollectorId())); } catch (RuntimeException ex) { return null; } }
+ 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<>();
@@ -222,7 +343,7 @@
.ifPresent(sequence -> parts.add("collector:" + sequence));
return String.join(":", parts);
}
- private long persistenceInterval(TTestChannelBinding b) { try { return Math.max(100L, adapter.persistenceIntervalMillis(new ChannelIdentity(b.getAdapterCode(), b.getDevtype(), b.getDevid(), b.getSubdevid(), b.getChlid()))); } catch (RuntimeException ex) { return 60000L; } }
+ 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; }
@@ -241,4 +362,13 @@
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;
+ }
}
diff --git a/battery-business/src/main/resources/mapper/battery/TDeviceAdapterMapper.xml b/battery-business/src/main/resources/mapper/battery/TDeviceAdapterMapper.xml
index 5b75ff5..e041146 100644
--- a/battery-business/src/main/resources/mapper/battery/TDeviceAdapterMapper.xml
+++ b/battery-business/src/main/resources/mapper/battery/TDeviceAdapterMapper.xml
@@ -11,6 +11,7 @@
<result property="pollIntervalMs" column="poll_interval_ms"/>
<result property="timezoneId" column="timezone_id"/>
<result property="capabilities" column="capabilities"/>
+ <result property="sourceConfig" column="source_config"/>
<result property="readOnly" column="read_only"/>
<result property="status" column="status"/>
<result property="connectionStatus" column="connection_status"/>
@@ -28,7 +29,7 @@
<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,
+ capabilities, source_config, (json_extract(capabilities, '$.control') = false) read_only, status,
connection_status, last_error_code, last_error_message, last_attempt_at_utc, last_observed_at_utc,
create_by, create_time, update_by, update_time, remark, del_flag
</sql>
@@ -61,27 +62,29 @@
<!-- 应用装配层提供托管配置;持久化层仅负责按稳定编码幂等恢复。 -->
<insert id="upsertManagedDeviceAdapter" parameterType="TDeviceAdapter">
insert into t_device_adapter(adapter_code, adapter_name, adapter_type, host, port, poll_interval_ms,
- timezone_id, capabilities, status, connection_status, create_by, create_time, update_by, update_time,
+ timezone_id, capabilities, source_config, status, connection_status, create_by, create_time, update_by, update_time,
remark, del_flag)
values(#{adapterCode}, #{adapterName}, #{adapterType}, #{host}, #{port}, #{pollIntervalMs}, #{timezoneId},
- #{capabilities}, '0', 'OFFLINE', 'system', sysdate(), 'system', sysdate(), #{remark}, 0)
+ #{capabilities}, cast(#{sourceConfig} as json), '0', 'OFFLINE', 'system', sysdate(), 'system', sysdate(), #{remark}, 0)
on duplicate key update adapter_name=values(adapter_name), adapter_type=values(adapter_type),
host=values(host), port=values(port), poll_interval_ms=values(poll_interval_ms),
- timezone_id=values(timezone_id), capabilities=values(capabilities), status='0',
+ timezone_id=values(timezone_id), capabilities=values(capabilities), source_config=values(source_config),
+ status='0',
connection_status='OFFLINE', last_error_code=null, last_error_message=null,
update_by='system', update_time=sysdate(), remark=values(remark), del_flag=0
</insert>
<insert id="insertDeviceAdapter" parameterType="TDeviceAdapter" useGeneratedKeys="true" keyProperty="adapterId">
insert into t_device_adapter(adapter_code, adapter_name, adapter_type, host, port, poll_interval_ms,
- timezone_id, capabilities, status, connection_status, create_by, create_time, remark, del_flag)
+ timezone_id, capabilities, source_config, status, connection_status, create_by, create_time, remark, del_flag)
values(#{adapterCode}, #{adapterName}, #{adapterType}, #{host}, #{port}, #{pollIntervalMs},
- #{timezoneId}, #{capabilities}, #{status}, 'OFFLINE', #{createBy}, sysdate(), #{remark}, 0)
+ #{timezoneId}, #{capabilities}, cast(#{sourceConfig} as json), #{status}, 'OFFLINE', #{createBy}, sysdate(), #{remark}, 0)
</insert>
<update id="updateDeviceAdapter" parameterType="TDeviceAdapter">
update t_device_adapter set adapter_name=#{adapterName}, adapter_type=#{adapterType}, host=#{host}, port=#{port},
poll_interval_ms=#{pollIntervalMs}, timezone_id=#{timezoneId}, capabilities=#{capabilities},
+ source_config=cast(#{sourceConfig} as json),
connection_status='OFFLINE', last_error_code=null, last_error_message=null,
update_by=#{updateBy}, update_time=sysdate()
where adapter_id=#{adapterId} and del_flag=0
diff --git a/battery-business/src/main/resources/mapper/battery/TTestMapper.xml b/battery-business/src/main/resources/mapper/battery/TTestMapper.xml
index 00e2240..79bf3c5 100644
--- a/battery-business/src/main/resources/mapper/battery/TTestMapper.xml
+++ b/battery-business/src/main/resources/mapper/battery/TTestMapper.xml
@@ -382,6 +382,12 @@
where test_id = #{testId} and test_battery_id = #{testBatteryId} and capture_slot_utc = #{captureSlotUtc}
limit 1
</select>
+ <select id="selectRealtimeRecordHashBySourceRecord" resultType="string">
+ select lower(hex(content_sha256)) from t_test_realtime_record
+ where test_battery_id = #{testBatteryId} and source_identity = #{sourceIdentity}
+ and source_record_id = #{sourceRecordId}
+ limit 1
+ </select>
<insert id="insertStepRecord" parameterType="com.battery.business.domain.TTestStepRecord" useGeneratedKeys="true" keyProperty="stepRecordId">
insert ignore into t_test_step_record
(test_id, test_battery_id, binding_id, adapter_code, source_identity, source_record_id, dedupe_key, adapter_timezone_id,
diff --git a/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java b/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
index 3dd9033..ef44f5d 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/TestDataObservationAdapter.java
@@ -12,8 +12,12 @@
/** 返回指定检测柜通道最近一次实时观测;没有成功观测时返回空值。 */
CabinetRealtime latestCabinet(ChannelIdentity channel);
- /** 返回采集器独立实时快照;不得通过历史下载补采。 */
- CollectorRealtime latestCollector(String collectorCode);
+ /**
+ * 返回采集器独立实时快照;不得通过历史下载补采。
+ * 采集器测量按"同一来源的辅助测量"表达(ADR-0064),所以必须指名是哪个来源实例,
+ * 否则多实例下无法确定该向哪台机器请求。
+ */
+ CollectorRealtime latestCollector(String adapterCode, String collectorCode);
/** 返回设备直接提供的工步统计,不从实时采样推导。 */
List<StepRecord> stepRecords(ChannelIdentity channel);
@@ -27,14 +31,49 @@
/** 返回设备计划方案;设备未提供时保持为空,不推导计划。 */
default TestPlan testPlan(ChannelIdentity channel) { return null; }
+ /**
+ * 读取通道当前的接收水位。水位是来源里已存在的最新一行(设备绝对时间 + 协议序号),
+ * 用于把"开始接收"和"继续接收"切在这一时刻之后:水位之前的数据行即使仍在来源里也不进本测试。
+ *
+ * <p>返回空值表示该来源不按设备数据行接收(例如由系统轮询产生槽位的来源),此时不启用逐行接收。
+ * 设备时间为空的水位表示这一刻来源里没有任何可用行,之后出现的行全部属于本次接收。
+ * 来源不可达时必须抛出异常,不能返回空值——空值会被误当作"来源不按行接收"。
+ */
+ default SampleWatermark currentWatermark(ChannelIdentity channel) { return null; }
+
+ /**
+ * 返回严格晚于水位的设备数据行,按设备时间与协议序号升序。
+ * 同一绝对秒内的多行各自成立,调用方不得按秒合并。
+ * 水位为空表示调用方尚未取得水位,此时返回空列表:不把来源里的设备历史并入本次接收。
+ */
+ default List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark) { return List.of(); }
+
+ /** 接收水位:设备绝对时间与协议序号的二元组;序号在同一厂商测试内有效。 */
+ record SampleWatermark(Instant deviceTime, long sequenceId) { }
+
record TestPlan(int currentCycleNo, int currentStepNo, int plannedCycleCount, List<PlanRow> rows) { }
record PlanRow(String rowType, int stepNo, Integer cyclePosition, String stepName, double durationSeconds,
Double voltageV, String executionStatus, Integer cycleCount, Long recordCount) { }
- /** 柜端成功观测;相对时长使用设备提供的秒数,缺失不按采样时间推算。 */
+ /**
+ * 柜端成功观测;相对时长使用设备提供的秒数,缺失不按采样时间推算。
+ * {@code sourceRecordId} 是来源行的稳定身份(如厂商测试编号加协议序号),
+ * 供幂等与冲突判定使用;来源不提供时保持为空,调用方退化为按协议序号识别。
+ */
record CabinetRealtime(long sequenceId, Instant deviceTime, double currentA, double voltageV,
double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType,
- Instant serverReceivedAt, String deviceTimeRaw, String timezoneId, Long relativeTimeSeconds) { }
+ Instant serverReceivedAt, String deviceTimeRaw, String timezoneId, Long relativeTimeSeconds,
+ String sourceRecordId)
+ {
+ /** 兼容不携带来源行身份的既有适配器。 */
+ public CabinetRealtime(long sequenceId, Instant deviceTime, double currentA, double voltageV,
+ double capacityAh, double energyWh, int cycleNo, int stepNo, String stepName, String stepType,
+ Instant serverReceivedAt, String deviceTimeRaw, String timezoneId, Long relativeTimeSeconds)
+ {
+ this(sequenceId, deviceTime, currentA, voltageV, capacityAh, energyWh, cycleNo, stepNo, stepName,
+ stepType, serverReceivedAt, deviceTimeRaw, timezoneId, relativeTimeSeconds, null);
+ }
+ }
record CollectorRealtime(String collectorCode, boolean online, int sampleIntervalMs,
List<CollectorMeasurement> measurements, String timezoneId)
diff --git a/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java b/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java
index 54e5663..733965b 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java
@@ -43,22 +43,43 @@
}
}
+ /**
+ * 未登记的候选设备。{@code lastDataAt} 取该设备各通道最近的采样行时间,
+ * 供操作员判断哪几台设备现在真的在产出数据;它不改变候选的取舍,登记范围由操作员决定。
+ */
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)
+ List<ChannelCandidate> channels, Instant observedAt, Instant expiresAt, Instant lastDataAt)
{
public CabinetCandidate { channels = List.copyOf(channels); }
public boolean online() { return channels.stream().anyMatch(ChannelCandidate::online); }
+
+ /** 兼容不含最近数据时间的既有候选。 */
+ public 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)
+ {
+ this(adapterCode, adapterId, deviceCode, devtype, devid, ip, mac, name, manufacturer, model,
+ channels, observedAt, expiresAt, null);
+ }
}
+ /** 通道候选;{@code lastDataAt} 是该通道来源里最近一次采样行的设备时间。 */
record ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
- Instant observedAt, RealtimeSnapshot latestSnapshot)
+ Instant observedAt, RealtimeSnapshot latestSnapshot, Instant lastDataAt)
{
/** 兼容仅有状态的旧发现候选。 */
public ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
Instant observedAt)
{
- this(identity, online, workStatus, observedAt, null);
+ this(identity, online, workStatus, observedAt, null, null);
+ }
+
+ /** 兼容不含最近数据时间的既有候选。 */
+ public ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
+ Instant observedAt, RealtimeSnapshot latestSnapshot)
+ {
+ this(identity, online, workStatus, observedAt, latestSnapshot, null);
}
}
diff --git a/battery-device-api/src/main/java/com/battery/device/api/model/DetectedChannel.java b/battery-device-api/src/main/java/com/battery/device/api/model/DetectedChannel.java
index f244edb..d955ad3 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/model/DetectedChannel.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/model/DetectedChannel.java
@@ -1,10 +1,23 @@
package com.battery.device.api.model;
+import java.time.Instant;
+
+/**
+ * 发现结果中的一个通道。
+ * {@code lastDataAt} 是来源里最近一次采样行的设备时间;它是"最近是否有新采样行写入"这一条依据的取值,
+ * 与设备结束时间或通道水位线列无关。来源不提供该事实时保持为空。
+ */
public record DetectedChannel(ChannelIdentity identity, String serverIp, boolean available,
- DetectedDeviceMetadata deviceMetadata)
+ DetectedDeviceMetadata deviceMetadata, Instant lastDataAt)
{
public DetectedChannel(ChannelIdentity identity, String serverIp, boolean available)
{
- this(identity, serverIp, available, DetectedDeviceMetadata.empty());
+ this(identity, serverIp, available, DetectedDeviceMetadata.empty(), null);
+ }
+
+ public DetectedChannel(ChannelIdentity identity, String serverIp, boolean available,
+ DetectedDeviceMetadata deviceMetadata)
+ {
+ this(identity, serverIp, available, deviceMetadata, null);
}
}
diff --git a/battery-device-api/src/main/java/com/battery/device/api/registry/AdapterInstanceConfig.java b/battery-device-api/src/main/java/com/battery/device/api/registry/AdapterInstanceConfig.java
index 389645a..fc99987 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/registry/AdapterInstanceConfig.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/registry/AdapterInstanceConfig.java
@@ -2,6 +2,7 @@
import java.time.ZoneId;
import java.util.Locale;
+import java.util.Map;
import java.util.Objects;
import com.battery.device.api.model.AdapterCapabilities;
@@ -14,8 +15,12 @@
int pollIntervalMs,
String timezoneId,
boolean enabled,
- AdapterCapabilities capabilities)
+ AdapterCapabilities capabilities,
+ Map<String, String> sourceConfig)
{
+ /**
+ * 来源形态配置按存储形态互不相交,只承载非敏感项;连接口令由环境变量或启动参数提供,不下沉到数据库。
+ */
public AdapterInstanceConfig
{
adapterCode = requireText(adapterCode, "adapterCode");
@@ -23,6 +28,7 @@
host = requireText(host, "host");
timezoneId = requireText(timezoneId, "timezoneId");
capabilities = Objects.requireNonNull(capabilities, "capabilities");
+ sourceConfig = sourceConfig == null ? Map.of() : Map.copyOf(sourceConfig);
if (port < 1 || port > 65535) throw new IllegalArgumentException("port must be between 1 and 65535");
if (pollIntervalMs < 1) throw new IllegalArgumentException("pollIntervalMs must be positive");
ZoneId.of(timezoneId);
@@ -30,6 +36,19 @@
throw new IllegalArgumentException("MVP adapter must support discovery and remain read-only");
}
+ /** 兼容不含来源形态配置的既有装配。 */
+ public AdapterInstanceConfig(String adapterCode, String adapterType, String host, int port, int pollIntervalMs,
+ String timezoneId, boolean enabled, AdapterCapabilities capabilities)
+ {
+ this(adapterCode, adapterType, host, port, pollIntervalMs, timezoneId, enabled, capabilities, Map.of());
+ }
+
+ /** 读取来源形态的非敏感配置项;缺少该项时返回空值,是否必需由适配器决定。 */
+ public String sourceOption(String key)
+ {
+ return sourceConfig.get(key);
+ }
+
private static String requireText(String value, String field)
{
String normalized = Objects.requireNonNull(value, field).trim();
diff --git a/battery-device-api/src/main/java/com/battery/device/api/registry/DeviceAdapterRegistry.java b/battery-device-api/src/main/java/com/battery/device/api/registry/DeviceAdapterRegistry.java
index e2072aa..f2c51c5 100644
--- a/battery-device-api/src/main/java/com/battery/device/api/registry/DeviceAdapterRegistry.java
+++ b/battery-device-api/src/main/java/com/battery/device/api/registry/DeviceAdapterRegistry.java
@@ -8,6 +8,7 @@
import java.util.concurrent.locks.ReentrantLock;
import java.util.function.Function;
import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.TestDataObservationAdapter;
/** 管理启用设备适配器的运行时实例及其会话生命周期。 */
public final class DeviceAdapterRegistry implements AutoCloseable
@@ -34,18 +35,36 @@
return;
}
- RegisteredAdapter previous;
synchronized (this)
{
RegisteredAdapter current = adapters.get(config.adapterCode());
if (current != null && current.config.equals(config)) return;
-
- DeviceAdapterFactory factory = factories.get(normalizeType(config.adapterType()));
- if (factory == null)
+ if (!factories.containsKey(normalizeType(config.adapterType())))
throw new AdapterRegistryException("Unsupported device adapter type: " + config.adapterType());
+ }
- DeviceAdapter replacement = factory.create(config);
- previous = adapters.put(config.adapterCode(), new RegisteredAdapter(config, replacement));
+ DeviceAdapterFactory factory = factories.get(normalizeType(config.adapterType()));
+ register(config, factory.create(config));
+ }
+
+ /**
+ * 注册已构造的运行时实例。代码装配实例与数据库配置实例共用同一份实例表,
+ * 使发现与取数按同一适配器编码解析,避免同编码出现两个实例。
+ */
+ public void register(AdapterInstanceConfig config, DeviceAdapter adapter)
+ {
+ Objects.requireNonNull(config, "config");
+ Objects.requireNonNull(adapter, "adapter");
+ if (!config.enabled())
+ {
+ remove(config.adapterCode());
+ return;
+ }
+
+ RegisteredAdapter previous;
+ synchronized (this)
+ {
+ previous = adapters.put(config.adapterCode(), new RegisteredAdapter(config, adapter));
}
closeWhenIdle(previous);
}
@@ -54,14 +73,7 @@
public <T> T execute(String adapterCode, Function<DeviceAdapter, T> operation)
{
Objects.requireNonNull(operation, "operation");
- RegisteredAdapter registered;
- synchronized (this)
- {
- registered = adapters.get(adapterCode);
- if (registered == null)
- throw new AdapterRegistryException("Device adapter is not enabled: " + adapterCode);
- registered.operationLock.lock();
- }
+ RegisteredAdapter registered = acquire(adapterCode);
try
{
return operation.apply(registered.adapter);
@@ -72,6 +84,41 @@
}
}
+ /**
+ * 在实例级串行边界内按测试数据观测契约执行。
+ * 实例缺失或未提供该契约时明确失败,不回退到其他实例。
+ */
+ public <T> T executeTestData(String adapterCode, Function<TestDataObservationAdapter, T> operation)
+ {
+ Objects.requireNonNull(operation, "operation");
+ RegisteredAdapter registered = acquire(adapterCode);
+ try
+ {
+ if (!(registered.adapter instanceof TestDataObservationAdapter observation))
+ throw new AdapterRegistryException(
+ "Device adapter does not expose test data observation: " + adapterCode);
+ return operation.apply(observation);
+ }
+ finally
+ {
+ registered.operationLock.unlock();
+ }
+ }
+
+ /** 取用实例并持有其串行锁;调用方必须在 finally 中释放。 */
+ private RegisteredAdapter acquire(String adapterCode)
+ {
+ RegisteredAdapter registered;
+ synchronized (this)
+ {
+ registered = adapters.get(adapterCode);
+ if (registered == null)
+ throw new AdapterRegistryException("Device adapter is not enabled: " + adapterCode);
+ registered.operationLock.lock();
+ }
+ return registered;
+ }
+
public synchronized boolean contains(String adapterCode)
{
return adapters.containsKey(adapterCode);
diff --git a/battery-device-api/src/test/java/com/battery/device/api/registry/DeviceAdapterRegistryTest.java b/battery-device-api/src/test/java/com/battery/device/api/registry/DeviceAdapterRegistryTest.java
index 56e6c7e..05e1c91 100644
--- a/battery-device-api/src/test/java/com/battery/device/api/registry/DeviceAdapterRegistryTest.java
+++ b/battery-device-api/src/test/java/com/battery/device/api/registry/DeviceAdapterRegistryTest.java
@@ -1,5 +1,6 @@
package com.battery.device.api.registry;
+import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -15,6 +16,7 @@
import java.util.concurrent.atomic.AtomicBoolean;
import org.junit.jupiter.api.Test;
import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.api.model.AdapterCapabilities;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.DeviceControlCommand;
@@ -153,6 +155,139 @@
}
}
+ /**
+ * 多来源实例:取数按适配器编码解析到各自实例;实例缺失时明确失败,不回退到其他实例。
+ */
+ @Test
+ void routesTestDataObservationToTheInstanceNamedByTheChannelAndFailsWithoutFallback()
+ {
+ try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of(new ObservableFactory())))
+ {
+ registry.register(observableConfig("source-a", 1000), new ObservableAdapter("source-a", 1000L));
+ registry.register(observableConfig("source-b", 2000), new ObservableAdapter("source-b", 2000L));
+
+ assertEquals(1000L, registry.executeTestData("source-a",
+ adapter -> adapter.latestCabinet(channel("source-a")).sequenceId()).longValue());
+ assertEquals(2000L, registry.executeTestData("source-b",
+ adapter -> adapter.latestCabinet(channel("source-b")).sequenceId()).longValue());
+
+ // 实例缺失或未提供扩展观测契约时明确失败,不能落到别的实例上。
+ assertThrows(AdapterRegistryException.class,
+ () -> registry.executeTestData("source-c", adapter -> adapter.latestCabinet(channel("source-a"))));
+ assertThrows(AdapterRegistryException.class,
+ () -> registry.executeTestData("lab-bts", adapter -> adapter.adapterCode()));
+ }
+ }
+
+ /** 代码装配的实例与工厂实例共用同一份实例表,发现与取数因此按同一编码解析。 */
+ @Test
+ void codeAssembledInstanceIsVisibleToBothAccessors()
+ {
+ try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of()))
+ {
+ ObservableAdapter assembled = new ObservableAdapter("assembled", 42L);
+ registry.register(observableConfig("assembled", 3000), assembled);
+
+ assertSame(assembled, registry.execute("assembled", adapter -> adapter));
+ assertSame(assembled, registry.executeTestData("assembled", adapter -> adapter));
+ }
+ }
+
+ private static ChannelIdentity channel(String adapterCode)
+ {
+ return new ChannelIdentity(adapterCode, 22, 1, 1, 1);
+ }
+
+ private static AdapterInstanceConfig observableConfig(String adapterCode, int pollIntervalMs)
+ {
+ return new AdapterInstanceConfig(adapterCode, "NEWARE_MYSQL", "127.0.0.1", 3306, pollIntervalMs,
+ "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly());
+ }
+
+ private static final class ObservableFactory implements DeviceAdapterFactory
+ {
+ @Override
+ public String adapterType()
+ {
+ return "NEWARE_MYSQL";
+ }
+
+ @Override
+ public DeviceAdapter create(AdapterInstanceConfig config)
+ {
+ return new ObservableAdapter(config.adapterCode(), 0L);
+ }
+ }
+
+ /** 同时实现发现与测试数据观测契约的最小实例,用于验证按编码路由。 */
+ private static final class ObservableAdapter implements DeviceAdapter, TestDataObservationAdapter
+ {
+ private final String code;
+ private final long sequenceId;
+
+ private ObservableAdapter(String code, long sequenceId)
+ {
+ this.code = code;
+ this.sequenceId = sequenceId;
+ }
+
+ @Override
+ public String adapterCode()
+ {
+ return code;
+ }
+
+ @Override
+ public AdapterCapabilities capabilities()
+ {
+ return AdapterCapabilities.observeOnly();
+ }
+
+ @Override
+ public List<com.battery.device.api.model.DetectedChannel> discoverChannels()
+ {
+ return List.of();
+ }
+
+ @Override
+ public java.util.Map<ChannelIdentity, com.battery.device.api.model.RealtimeSnapshot> inquire(
+ java.util.Set<ChannelIdentity> channels)
+ {
+ return java.util.Map.of();
+ }
+
+ @Override
+ public TestDataObservationAdapter.CabinetRealtime latestCabinet(ChannelIdentity channel)
+ {
+ return new TestDataObservationAdapter.CabinetRealtime(sequenceId, java.time.Instant.EPOCH, 0, 0, 0, 0,
+ 0, 0, null, null, java.time.Instant.EPOCH, null, "Asia/Hong_Kong", null);
+ }
+
+ @Override
+ public TestDataObservationAdapter.CollectorRealtime latestCollector(String adapterCode, String collectorCode)
+ {
+ return null;
+ }
+
+ @Override
+ public List<TestDataObservationAdapter.StepRecord> stepRecords(ChannelIdentity channel)
+ {
+ return List.of();
+ }
+
+ @Override
+ public List<TestDataObservationAdapter.CycleRecord> cycleRecords(ChannelIdentity channel)
+ {
+ return List.of();
+ }
+
+ @Override
+ public long persistenceIntervalMillis(ChannelIdentity channel)
+ {
+ return 60000L;
+ }
+ }
+
private static AdapterInstanceConfig config(String host, boolean enabled)
{
return new AdapterInstanceConfig("lab-bts", "BTS", host, 502, 5000,
diff --git a/battery-device-neware/pom.xml b/battery-device-neware/pom.xml
new file mode 100644
index 0000000..c034c22
--- /dev/null
+++ b/battery-device-neware/pom.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
+ <modelVersion>4.0.0</modelVersion>
+ <parent><groupId>com.ruoyi</groupId><artifactId>ruoyi</artifactId><version>3.9.2</version></parent>
+ <groupId>com.battery</groupId><artifactId>battery-device-neware</artifactId>
+ <description>新威上位机数据库的只读直连适配器</description>
+ <dependencies>
+ <dependency><groupId>com.battery</groupId><artifactId>battery-device-api</artifactId></dependency>
+ <dependency><groupId>com.mysql</groupId><artifactId>mysql-connector-j</artifactId></dependency>
+ <dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-test</artifactId><scope>test</scope></dependency>
+ </dependencies>
+</project>
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java b/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java
new file mode 100644
index 0000000..99c6360
--- /dev/null
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapter.java
@@ -0,0 +1,466 @@
+package com.battery.device.neware;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeFormatterBuilder;
+import java.time.temporal.ChronoField;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.regex.Pattern;
+import javax.sql.DataSource;
+import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.DeviceAdapterException;
+import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.model.DetectedChannel;
+import com.battery.device.api.model.DetectedDeviceMetadata;
+import com.battery.device.api.model.DeviceConnectionStatus;
+import com.battery.device.api.model.DeviceWorkStatus;
+import com.battery.device.api.model.RealtimeSnapshot;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+
+/**
+ * 直连新威上位机 MySQL 库的只读适配器(存储形态 {@code NEWARE_MYSQL})。
+ *
+ * <p>现场确认的取数事实(见 {@code docs/commissioning/现场对接清单.md}):
+ * <ul>
+ * <li>发现候选来自 {@code channel} 通道登记表,主键 {@code (dev_uid, unit_id, chl_id)};
+ * 设备号 {@code dev_uid = devtype × 10000 + devid}。登记表不等于"当前在用",
+ * 所以只把"最近数据时间"作为事实交出,登记哪些候选由操作员决定。</li>
+ * <li>逐点数据在 {@code data_<设备六位>_<年>_<月>_<日>} 分表里,表名由 {@code test} 表的
+ * {@code main_first_table} / {@code main_second_table} 直接给出,不按日期推算。</li>
+ * <li>最新测试按 {@code start_time} 取,不能按 {@code test_id} 排序(实测不单调)。</li>
+ * <li>活跃与否只看有没有新的采样行;{@code end_time} 是否为空与 8.0 的通道水位线列都不作为依据。</li>
+ * <li>厂商固定每 60 秒写一行,实时展示节奏与数据产生节奏天生分离。</li>
+ * <li>辅助表族实测没有数据,采集器辅助测量只能走导入,因此 {@link #latestCollector} 保持为空。</li>
+ * </ul>
+ *
+ * <p>单位换算按实测公式:电压与电流除以 10000(原单位 0.1 mV / 0.1 mA),容量与能量除以 36000000
+ * (原单位 1e-4 C / 1e-4 J)。工步类型保留设备给出的数字,不按电流符号猜测方向;
+ * 内阻与温度的单位语义仍未确认,因此不对外暴露。
+ */
+public final class NewareMysqlAdapter implements DeviceAdapter, TestDataObservationAdapter
+{
+ public static final String ADAPTER_TYPE = "NEWARE_MYSQL";
+ /** 连接口令的默认环境变量名;也可由实例配置的 passwordEnv 指定。 */
+ public static final String DEFAULT_PASSWORD_ENV = "BATTERY_VENDOR_DB_PASSWORD";
+ /** 厂商实测写库周期;它同时是持久化周期,展示节奏不随之加快。 */
+ public static final long SAMPLE_INTERVAL_MS = 60000L;
+ private static final int SAMPLE_BATCH_LIMIT = 500;
+ private static final Pattern SAFE_TABLE_NAME = Pattern.compile("[A-Za-z0-9_]{1,64}");
+ /** 设备时间按适配器时区解释;来源列是秒精度 datetime,小数部分按需接受。 */
+ private static final DateTimeFormatter DEVICE_TIME = new DateTimeFormatterBuilder()
+ .appendPattern("yyyy-MM-dd HH:mm:ss")
+ .optionalStart().appendFraction(ChronoField.NANO_OF_SECOND, 1, 9, true).optionalEnd()
+ .toFormatter(Locale.ROOT);
+ /** 水位回写来源时使用秒精度文本,与来源列一致;同秒内的区分由协议序号承担。 */
+ private static final DateTimeFormatter DEVICE_TIME_TEXT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss",
+ Locale.ROOT);
+ private static final BigDecimal VOLTAGE_CURRENT_DIVISOR = BigDecimal.valueOf(10000);
+ private static final BigDecimal CAPACITY_ENERGY_DIVISOR = BigDecimal.valueOf(36000000);
+
+ private final AdapterInstanceConfig config;
+ private final DataSource dataSource;
+ private final Runnable connectionReset;
+ private final Clock clock;
+ private final ZoneId deviceZone;
+ /** 最近数据时间最快也要一分钟才可能变化,缓存使发现清单不必每轮重读全部通道。 */
+ private final long lastDataCacheMillis;
+ private final Map<String, CachedLastData> lastDataCache = new ConcurrentHashMap<>();
+
+ public NewareMysqlAdapter(AdapterInstanceConfig config, DataSource dataSource)
+ {
+ this(config, dataSource, () -> { }, Clock.systemUTC(), SAMPLE_INTERVAL_MS);
+ }
+
+ public NewareMysqlAdapter(AdapterInstanceConfig config, DataSource dataSource, Runnable connectionReset,
+ Clock clock, long lastDataCacheMillis)
+ {
+ this.config = config;
+ this.dataSource = dataSource;
+ this.connectionReset = connectionReset;
+ this.clock = clock;
+ this.deviceZone = ZoneId.of(config.timezoneId());
+ this.lastDataCacheMillis = Math.max(0, lastDataCacheMillis);
+ }
+
+ /** 由工厂使用:把驱动连接包上只读白名单,并在查询失败后作废连接。 */
+ public static NewareMysqlAdapter create(AdapterInstanceConfig config, SingleConnectionVendorDataSource source)
+ {
+ return new NewareMysqlAdapter(config, new ReadOnlyVendorDataSource(source, config.adapterCode()),
+ source::invalidate, Clock.systemUTC(), SAMPLE_INTERVAL_MS);
+ }
+
+ @Override
+ public String adapterCode()
+ {
+ return config.adapterCode();
+ }
+
+ @Override
+ public AdapterCapabilities capabilities()
+ {
+ return config.capabilities();
+ }
+
+ /** 发现候选来自厂商库的通道登记表;每条候选附带该通道最近的采样行时间。 */
+ @Override
+ public List<DetectedChannel> discoverChannels()
+ {
+ List<ChannelKey> registered = query("select dev_uid, unit_id, chl_id from channel "
+ + "order by dev_uid, unit_id, chl_id", this::readRegisteredChannels);
+ Map<Long, List<ChannelKey>> byDevice = new LinkedHashMap<>();
+ for (ChannelKey key : registered)
+ byDevice.computeIfAbsent(key.devUid(), ignored -> new ArrayList<>()).add(key);
+ List<DetectedChannel> channels = new ArrayList<>();
+ Instant observedAt = clock.instant();
+ for (Map.Entry<Long, List<ChannelKey>> entry : byDevice.entrySet())
+ {
+ Map<ChannelKey, VendorTest> latestTests = latestTests(entry.getKey());
+ for (ChannelKey key : entry.getValue())
+ {
+ VendorTest latest = latestTests.get(key);
+ Instant lastDataAt = latest == null ? null : cachedLastSampleAt(key, latest, observedAt);
+ channels.add(new DetectedChannel(identity(key), config.host(), true,
+ DetectedDeviceMetadata.empty(), lastDataAt));
+ }
+ }
+ return List.copyOf(channels);
+ }
+
+ /** 实时快照取自该通道最新测试的最新一行;没有可用行时不返回该通道,避免沿用过期观测。 */
+ @Override
+ public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
+ {
+ if (channels == null || channels.isEmpty()) return Map.of();
+ Map<ChannelIdentity, RealtimeSnapshot> result = new LinkedHashMap<>();
+ for (ChannelIdentity channel : channels)
+ {
+ CabinetRealtime latest = latestCabinet(channel);
+ if (latest == null) continue;
+ result.put(channel, snapshot(channel, latest));
+ }
+ return Map.copyOf(result);
+ }
+
+ @Override
+ public CabinetRealtime latestCabinet(ChannelIdentity channel)
+ {
+ ChannelKey key = channelKey(channel);
+ VendorTest latest = latestTest(key);
+ if (latest == null) return null;
+ SampleRow row = lastSample(key, latest);
+ return row == null ? null : cabinetRealtime(row);
+ }
+
+ /** 新威辅助表族实测没有数据,采集器辅助测量只能靠导入(ADR-0064)。 */
+ @Override
+ public CollectorRealtime latestCollector(String adapterCode, String collectorCode)
+ {
+ return null;
+ }
+
+ /** 工步层记录由本单之后的一张单实现,这里不返回从采样推导出来的工步。 */
+ @Override
+ public List<StepRecord> stepRecords(ChannelIdentity channel)
+ {
+ return List.of();
+ }
+
+ /** 循环汇总表不存在,聚合配方须先用真实数据核对,这里不返回推导值。 */
+ @Override
+ public List<CycleRecord> cycleRecords(ChannelIdentity channel)
+ {
+ return List.of();
+ }
+
+ /** 厂商固定 60 秒写库;持久化周期与之对齐,不做倍速。 */
+ @Override
+ public long persistenceIntervalMillis(ChannelIdentity channel)
+ {
+ return SAMPLE_INTERVAL_MS;
+ }
+
+ /** 水位是来源里已存在的最新一行:设备绝对时间与协议序号。 */
+ @Override
+ public SampleWatermark currentWatermark(ChannelIdentity channel)
+ {
+ ChannelKey key = channelKey(channel);
+ VendorTest latest = latestTest(key);
+ if (latest == null) return new SampleWatermark(null, 0L);
+ SampleRow row = lastSample(key, latest);
+ return row == null ? new SampleWatermark(null, 0L) : new SampleWatermark(row.deviceTime(), row.sequenceId());
+ }
+
+ /**
+ * 严格晚于水位的设备数据行。同一绝对秒内的多行各自成立(实测工步切换点会在同一秒写入多行),
+ * 所以按 {@code (test_atime, seq_id)} 过滤与排序,不按秒合并。
+ *
+ * <p>设备时间为空的水位表示边界时该通道在来源里没有任何行,之后出现的行全部属于本次接收。
+ * 厂商侧测试编号不参与边界判定,只写进来源行身份;接收边界是本系统的接收边界。
+ * 若测试期间分表滚动,两张表都会被读取,避免滚动点整段缺失。
+ */
+ @Override
+ public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark)
+ {
+ if (watermark == null) return List.of();
+ ChannelKey key = channelKey(channel);
+ VendorTest latest = latestTest(key);
+ if (latest == null) return List.of();
+ boolean bounded = watermark.deviceTime() != null;
+ String rawWatermark = bounded ? deviceTimeText(watermark.deviceTime()) : null;
+ List<SampleRow> rows = new ArrayList<>();
+ for (String table : latest.tables())
+ {
+ String predicate = bounded
+ ? "and (test_atime > ? or (test_atime = ? and seq_id > ?)) "
+ : "";
+ Object[] parameters = bounded
+ ? new Object[] { key.unitId(), key.chlId(), rawWatermark, rawWatermark, watermark.sequenceId() }
+ : new Object[] { key.unitId(), key.chlId() };
+ rows.addAll(query("select test_id, seq_id, cycle, step_id, step_type, test_time, test_vol, test_cur, "
+ + "test_capchg, test_engchg, test_capdchg, test_engdchg, test_atime from " + quote(table)
+ + " where unit_id = ? and chl_id = ? " + predicate
+ + "order by test_atime, seq_id limit " + SAMPLE_BATCH_LIMIT,
+ this::readSampleRows, parameters));
+ }
+ rows.sort(Comparator.comparing(SampleRow::deviceTime).thenComparingLong(SampleRow::sequenceId));
+ return rows.stream().map(this::cabinetRealtime).toList();
+ }
+
+ // ------------------------------------------------------------------ 来源读取
+
+ private List<ChannelKey> readRegisteredChannels(ResultSet rows) throws SQLException
+ {
+ List<ChannelKey> channels = new ArrayList<>();
+ while (rows.next())
+ channels.add(new ChannelKey(rows.getLong("dev_uid"), rows.getInt("unit_id"), rows.getInt("chl_id")));
+ return channels;
+ }
+
+ /** 按起始时间取每台设备每个通道的最新测试;test_id 实测不单调,不能用来排序。 */
+ private Map<ChannelKey, VendorTest> latestTests(long devUid)
+ {
+ return query("select unit_id, chl_id, test_id, main_first_table, main_second_table from test "
+ + "where dev_uid = ? and start_time = (select max(start_time) from test newer "
+ + "where newer.dev_uid = test.dev_uid and newer.unit_id = test.unit_id and newer.chl_id = test.chl_id) "
+ + "order by unit_id, chl_id, test_id", rows -> {
+ Map<ChannelKey, VendorTest> result = new LinkedHashMap<>();
+ while (rows.next())
+ {
+ ChannelKey key = new ChannelKey(devUid, rows.getInt("unit_id"), rows.getInt("chl_id"));
+ result.putIfAbsent(key, vendorTest(rows.getLong("test_id"),
+ rows.getString("main_first_table"), rows.getString("main_second_table")));
+ }
+ return result;
+ }, devUid);
+ }
+
+ private VendorTest latestTest(ChannelKey key)
+ {
+ List<VendorTest> tests = query("select test_id, main_first_table, main_second_table from test "
+ + "where dev_uid = ? and unit_id = ? and chl_id = ? order by start_time desc limit 1",
+ rows -> rows.next() ? List.of(vendorTest(rows.getLong("test_id"), rows.getString("main_first_table"),
+ rows.getString("main_second_table"))) : List.<VendorTest>of(),
+ key.devUid(), key.unitId(), key.chlId());
+ return tests.isEmpty() ? null : tests.get(0);
+ }
+
+ /** 测试数据可能跨分表滚动,两张表都要看;优先取第二张表,滚动后它就是最新的那张。 */
+ private SampleRow lastSample(ChannelKey key, VendorTest test)
+ {
+ List<String> tables = new ArrayList<>(test.tables());
+ Collections.reverse(tables);
+ for (String table : tables)
+ {
+ List<SampleRow> rows = query("select test_id, seq_id, cycle, step_id, step_type, test_time, test_vol, "
+ + "test_cur, test_capchg, test_engchg, test_capdchg, test_engdchg, test_atime from " + quote(table)
+ + " where unit_id = ? and chl_id = ? and test_id = ? order by seq_id desc limit 1",
+ this::readSampleRows, key.unitId(), key.chlId(), test.testId());
+ if (!rows.isEmpty()) return rows.get(0);
+ }
+ return null;
+ }
+
+ /** 最近数据时间来自采样行本身:没有新采样行就是没有,不看结束时间也不看通道水位线列。 */
+ private Instant cachedLastSampleAt(ChannelKey key, VendorTest test, Instant now)
+ {
+ String cacheKey = key.devUid() + ":" + key.unitId() + ":" + key.chlId() + ":" + test.testId();
+ CachedLastData cached = lastDataCache.get(cacheKey);
+ if (cached != null && now.isBefore(cached.expiresAt())) return cached.lastDataAt();
+ SampleRow row = lastSample(key, test);
+ Instant lastDataAt = row == null ? null : row.deviceTime();
+ if (lastDataCacheMillis > 0)
+ lastDataCache.put(cacheKey, new CachedLastData(lastDataAt, now.plusMillis(lastDataCacheMillis)));
+ return lastDataAt;
+ }
+
+ private List<SampleRow> readSampleRows(ResultSet rows) throws SQLException
+ {
+ List<SampleRow> result = new ArrayList<>();
+ while (rows.next())
+ {
+ String raw = rows.getString("test_atime");
+ LocalDateTime deviceTime = parseDeviceTime(raw);
+ result.add(new SampleRow(rows.getLong("test_id"), rows.getLong("seq_id"), rows.getInt("cycle"),
+ rows.getInt("step_id"), rows.getString("step_type"), rows.getLong("test_time"),
+ rows.getBigDecimal("test_vol"), rows.getBigDecimal("test_cur"), rows.getBigDecimal("test_capchg"),
+ rows.getBigDecimal("test_engchg"), rows.getBigDecimal("test_capdchg"),
+ rows.getBigDecimal("test_engdchg"), raw, deviceTime.atZone(deviceZone).toInstant()));
+ }
+ return result;
+ }
+
+ private CabinetRealtime cabinetRealtime(SampleRow row)
+ {
+ // 充放电容量按电流方向取对应的一组:设备界面对正在放电的通道显示放电容量。
+ boolean charging = row.current().signum() >= 0;
+ BigDecimal capacity = charging ? row.capacityCharge() : row.capacityDischarge();
+ BigDecimal energy = charging ? row.energyCharge() : row.energyDischarge();
+ return new CabinetRealtime(row.sequenceId(), row.deviceTime(), scaled(row.current(), VOLTAGE_CURRENT_DIVISOR),
+ scaled(row.voltage(), VOLTAGE_CURRENT_DIVISOR), scaled(capacity, CAPACITY_ENERGY_DIVISOR),
+ scaled(energy, CAPACITY_ENERGY_DIVISOR), row.cycle(), row.stepId(), null, row.stepType(),
+ clock.instant(), row.deviceTimeRaw(), deviceZone.getId(),
+ row.testTimeMillis() == null ? null : row.testTimeMillis() / 1000,
+ "neware:" + row.testId() + ":" + row.sequenceId());
+ }
+
+ private RealtimeSnapshot snapshot(ChannelIdentity channel, CabinetRealtime latest)
+ {
+ // 读库路线下通道工作状态不可观测(ADR-0063):不按数据行推断,也不沿用上一次状态。
+ return new RealtimeSnapshot(channel, DeviceWorkStatus.UNOBSERVABLE, latest.cycleNo(), latest.stepNo(),
+ latest.stepType(), null, BigDecimal.valueOf(latest.currentA()), BigDecimal.valueOf(latest.voltageV()),
+ BigDecimal.valueOf(latest.capacityAh()), BigDecimal.valueOf(latest.energyWh()), null,
+ latest.relativeTimeSeconds(), null, latest.serverReceivedAt(), Map.of(),
+ DeviceConnectionStatus.ONLINE, latest.deviceTime(), List.of(), null);
+ }
+
+ private static double scaled(BigDecimal value, BigDecimal divisor)
+ {
+ if (value == null) return 0d;
+ // 保留 12 位小数与业务列精度一致,避免在换算环节提前丢失设备原始分辨率。
+ return value.divide(divisor, 12, RoundingMode.HALF_UP).doubleValue();
+ }
+
+ private ChannelIdentity identity(ChannelKey key)
+ {
+ // 设备号编码 devtype × 10000 + devid;unit_id 是该柜内的单元,作为 subdevid 保留。
+ return new ChannelIdentity(config.adapterCode(), (int) (key.devUid() / 10000),
+ (int) (key.devUid() % 10000), key.unitId(), key.chlId());
+ }
+
+ private ChannelKey channelKey(ChannelIdentity channel)
+ {
+ if (!config.adapterCode().equals(channel.adapterCode()))
+ throw new DeviceAdapterException("通道身份不属于本实例:" + channel.adapterCode());
+ return new ChannelKey(channel.devtype() * 10000L + channel.devid(), channel.subdevid(), channel.chlid());
+ }
+
+ private static VendorTest vendorTest(long testId, String firstTable, String secondTable)
+ {
+ List<String> tables = new ArrayList<>(2);
+ if (isSafeTable(firstTable)) tables.add(firstTable.trim());
+ if (isSafeTable(secondTable) && !tables.contains(secondTable.trim())) tables.add(secondTable.trim());
+ return new VendorTest(testId, tables);
+ }
+
+ private static boolean isSafeTable(String table)
+ {
+ return table != null && SAFE_TABLE_NAME.matcher(table.trim()).matches();
+ }
+
+ private static String quote(String table)
+ {
+ // 表名来自厂商库自身的 test 记录,必须先按白名单校验再拼接,避免把库内容当 SQL。
+ if (!isSafeTable(table)) throw new DeviceAdapterException("来源表名不合法:" + table);
+ return "`" + table.trim() + "`";
+ }
+
+ private LocalDateTime parseDeviceTime(String raw)
+ {
+ if (raw == null || raw.isBlank())
+ throw new DeviceAdapterException("PARSE_FAILED", "设备时间为空,无法解释为设备事实", null);
+ String normalized = raw.trim().replace('T', ' ');
+ if (normalized.endsWith("Z")) normalized = normalized.substring(0, normalized.length() - 1).trim();
+ try
+ {
+ return LocalDateTime.parse(normalized, DEVICE_TIME);
+ }
+ catch (RuntimeException exception)
+ {
+ throw new DeviceAdapterException("PARSE_FAILED", "设备时间无法解释:" + raw, exception);
+ }
+ }
+
+ private String deviceTimeText(Instant instant)
+ {
+ return instant.atZone(deviceZone).toLocalDateTime().format(DEVICE_TIME_TEXT);
+ }
+
+ // ------------------------------------------------------------------ JDBC
+
+ /**
+ * 厂商数据源交出的是长期复用的一条连接,这里只关闭语句与结果集,不关闭连接本身;
+ * 查询失败时作废连接,下一轮取数重新建立。
+ */
+ private <T> T query(String sql, ResultSetReader<T> reader, Object... parameters)
+ {
+ try
+ {
+ Connection connection = dataSource.getConnection();
+ try (PreparedStatement statement = connection.prepareStatement(sql))
+ {
+ for (int index = 0; index < parameters.length; index++)
+ statement.setObject(index + 1, parameters[index]);
+ try (ResultSet rows = statement.executeQuery())
+ {
+ return reader.read(rows);
+ }
+ }
+ }
+ catch (NonQueryStatementException exception)
+ {
+ throw exception;
+ }
+ catch (SQLException exception)
+ {
+ connectionReset.run();
+ throw new DeviceAdapterException("CONNECTION_FAILED",
+ "厂商库读取失败:" + config.host() + ":" + config.port() + ",原因:" + exception.getMessage(),
+ exception);
+ }
+ }
+
+ private interface ResultSetReader<T>
+ {
+ T read(ResultSet rows) throws SQLException;
+ }
+
+ private record ChannelKey(long devUid, int unitId, int chlId) { }
+
+ private record VendorTest(long testId, List<String> tables) { }
+
+ private record CachedLastData(Instant lastDataAt, Instant expiresAt) { }
+
+ private record SampleRow(long testId, long sequenceId, int cycle, int stepId, String stepType,
+ Long testTimeMillis, BigDecimal voltage, BigDecimal current, BigDecimal capacityCharge,
+ BigDecimal energyCharge, BigDecimal capacityDischarge, BigDecimal energyDischarge,
+ String deviceTimeRaw, Instant deviceTime) { }
+}
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapterFactory.java b/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapterFactory.java
new file mode 100644
index 0000000..34da3c6
--- /dev/null
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/NewareMysqlAdapterFactory.java
@@ -0,0 +1,78 @@
+package com.battery.device.neware;
+
+import java.util.Map;
+import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.DeviceAdapterException;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+import com.battery.device.api.registry.DeviceAdapterFactory;
+
+/**
+ * 按存储形态 {@code NEWARE_MYSQL} 构造直连厂商库的只读适配器实例。
+ *
+ * <p>连接口令只从环境变量或启动参数读取(变量名由实例配置的 {@code passwordEnv} 指定,
+ * 缺省为 {@link NewareMysqlAdapter#DEFAULT_PASSWORD_ENV}),不进数据库也不进仓库。
+ * 账号固定为现场取得的服务端只读账号,全权限账号不进入配置。
+ */
+public final class NewareMysqlAdapterFactory implements DeviceAdapterFactory
+{
+ public static final String DATABASE_OPTION = "database";
+ public static final String USER_OPTION = "user";
+ public static final String PASSWORD_ENV_OPTION = "passwordEnv";
+ private static final String DEFAULT_DATABASE = "bts63";
+ private static final String READ_ONLY_USER = "visitor";
+
+ @Override
+ public String adapterType()
+ {
+ return NewareMysqlAdapter.ADAPTER_TYPE;
+ }
+
+ @Override
+ public DeviceAdapter create(AdapterInstanceConfig config)
+ {
+ String database = option(config.sourceConfig(), DATABASE_OPTION, DEFAULT_DATABASE);
+ String user = option(config.sourceConfig(), USER_OPTION, READ_ONLY_USER);
+ String password = credential(option(config.sourceConfig(), PASSWORD_ENV_OPTION,
+ NewareMysqlAdapter.DEFAULT_PASSWORD_ENV));
+ SingleConnectionVendorDataSource source = new SingleConnectionVendorDataSource(
+ jdbcUrl(config.host(), config.port(), database), user, password);
+ return NewareMysqlAdapter.create(config, source);
+ }
+
+ /** 只读账号口令;启动参数优先于环境变量,便于本机排查而不必改环境。 */
+ static String credential(String variableName)
+ {
+ if (variableName == null || variableName.isBlank())
+ throw new DeviceAdapterException("未配置厂商库口令的取值变量名");
+ String value = System.getenv(variableName);
+ if (value == null || value.isBlank()) value = System.getProperty(variableName);
+ if (value == null || value.isBlank())
+ throw new DeviceAdapterException("缺少厂商库连接口令:请设置环境变量 " + variableName);
+ return value;
+ }
+
+ /**
+ * MySQL 5.5 那台不支持任何会话级只读机制,因此 URL 中不得出现 transaction_read_only
+ * (ADR-0062);只读由服务端账号与语句白名单两层保证。
+ */
+ static String jdbcUrl(String host, int port, String database)
+ {
+ return "jdbc:mysql://" + host + ":" + port + "/" + database
+ + "?connectTimeout=5000&socketTimeout=10000&useSSL=false&allowPublicKeyRetrieval=true"
+ + "&zeroDateTimeBehavior=convertToNull&connectionTimeZone=Asia/Hong_Kong"
+ + "&forceConnectionTimeZoneToSession=false";
+ }
+
+ private static String option(Map<String, String> sourceConfig, String key, String fallback)
+ {
+ String value = sourceConfig.get(key);
+ return value == null || value.isBlank() ? fallback : value.trim();
+ }
+
+ /** 供装配层声明该形态的运行时能力:可发现、可观测实时值、只读。 */
+ public static AdapterCapabilities capabilities()
+ {
+ return AdapterCapabilities.observeOnly();
+ }
+}
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/NonQueryStatementException.java b/battery-device-neware/src/main/java/com/battery/device/neware/NonQueryStatementException.java
new file mode 100644
index 0000000..10e114a
--- /dev/null
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/NonQueryStatementException.java
@@ -0,0 +1,12 @@
+package com.battery.device.neware;
+
+/** 非查询语句在到达驱动之前被拒绝;抛出时不会产生任何服务端效果。 */
+public class NonQueryStatementException extends RuntimeException
+{
+ private static final long serialVersionUID = 1L;
+
+ public NonQueryStatementException(String message)
+ {
+ super(message);
+ }
+}
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyStatementAudit.java b/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyStatementAudit.java
new file mode 100644
index 0000000..51d69e0
--- /dev/null
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyStatementAudit.java
@@ -0,0 +1,45 @@
+package com.battery.device.neware;
+
+import java.util.List;
+import java.util.concurrent.CopyOnWriteArrayList;
+
+/**
+ * 厂商数据源上的语句审计:记录已转发到驱动的语句和被白名单挡下的语句。
+ *
+ * <p>它让"该数据源从未发出过非查询语句"成为可断言的事实,而不是纪律性约定;
+ * 断言对应的测试见 {@code ReadOnlyVendorDataSourceTest}。
+ */
+public final class ReadOnlyStatementAudit
+{
+ private final List<String> forwardedStatements = new CopyOnWriteArrayList<>();
+ private final List<String> rejectedStatements = new CopyOnWriteArrayList<>();
+
+ void recordForwarded(String statement)
+ {
+ forwardedStatements.add(abbreviate(statement));
+ }
+
+ void recordRejected(String statement)
+ {
+ rejectedStatements.add(abbreviate(statement));
+ }
+
+ /** 已被转发到驱动的语句。 */
+ public List<String> forwardedStatements()
+ {
+ return List.copyOf(forwardedStatements);
+ }
+
+ /** 被白名单挡下、从未到达驱动的语句。 */
+ public List<String> rejectedStatements()
+ {
+ return List.copyOf(rejectedStatements);
+ }
+
+ private static String abbreviate(String statement)
+ {
+ if (statement == null) return "";
+ String trimmed = statement.trim();
+ return trimmed.length() <= 200 ? trimmed : trimmed.substring(0, 200);
+ }
+}
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java b/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java
new file mode 100644
index 0000000..8562e73
--- /dev/null
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/ReadOnlyVendorDataSource.java
@@ -0,0 +1,237 @@
+package com.battery.device.neware;
+
+import java.io.PrintWriter;
+import java.lang.reflect.InvocationTargetException;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.sql.CallableStatement;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.sql.Statement;
+import java.util.List;
+import java.util.Locale;
+import java.util.logging.Logger;
+import javax.sql.DataSource;
+
+/**
+ * 厂商数据源的只读防线:在 JDBC 调用到达驱动之前按语句白名单放行。
+ *
+ * <p>只允许 {@code SELECT} / {@code SHOW} / {@code DESCRIBE}(含 {@code DESC});
+ * 其余语句(含存储过程调用)直接抛 {@link NonQueryStatementException},不转发给驱动,
+ * 因此在服务端不产生任何效果。被拒绝的语句记入 {@link ReadOnlyStatementAudit},
+ * 使"该数据源从未发出过非查询语句"可以被测试断言。
+ *
+ * <p>防线是结构性的,不是数学上的:任何绕过本数据源另开连接的代码仍能写。
+ * 服务端只读账号(见现场对接清单第七节)是第一道防线,本层是第二道。
+ */
+public final class ReadOnlyVendorDataSource implements DataSource
+{
+ private static final List<String> ALLOWED_PREFIXES = List.of("select", "show", "describe", "desc");
+
+ private final DataSource delegate;
+ private final String sourceLabel;
+ private final ReadOnlyStatementAudit audit = new ReadOnlyStatementAudit();
+
+ public ReadOnlyVendorDataSource(DataSource delegate, String sourceLabel)
+ {
+ if (delegate == null) throw new IllegalArgumentException("delegate data source must not be null");
+ this.delegate = delegate;
+ this.sourceLabel = sourceLabel == null || sourceLabel.isBlank() ? "vendor" : sourceLabel.trim();
+ }
+
+ /** 返回本数据源上的语句审计,用于断言从未发出过非查询语句。 */
+ public ReadOnlyStatementAudit audit()
+ {
+ return audit;
+ }
+
+ @Override
+ public Connection getConnection() throws SQLException
+ {
+ return guard(delegate.getConnection());
+ }
+
+ @Override
+ public Connection getConnection(String username, String password) throws SQLException
+ {
+ return guard(delegate.getConnection(username, password));
+ }
+
+ private Connection guard(Connection connection)
+ {
+ return (Connection) Proxy.newProxyInstance(connection.getClass().getClassLoader(),
+ new Class<?>[] { Connection.class }, (proxy, method, args) -> {
+ switch (method.getName())
+ {
+ case "prepareStatement" ->
+ {
+ String sql = (String) args[0];
+ requireQuery(sql);
+ return guardStatement((Statement) invoke(connection, method, args), sql, true);
+ }
+ case "prepareCall" -> throw new NonQueryStatementException(
+ "厂商数据源禁止存储过程调用:" + abbreviate((String) args[0]));
+ case "createStatement" ->
+ {
+ return guardStatement((Statement) invoke(connection, method, args), null, false);
+ }
+ default -> { }
+ }
+ return invoke(connection, method, args);
+ });
+ }
+
+ private Statement guardStatement(Statement statement, String preparedSql, boolean prepared)
+ {
+ Class<?> statementType = prepared ? PreparedStatement.class : Statement.class;
+ return (Statement) Proxy.newProxyInstance(statement.getClass().getClassLoader(),
+ new Class<?>[] { statementType }, (proxy, method, args) -> {
+ String sql = sqlArgument(method, args);
+ if (sql != null) requireQuery(sql);
+ return invoke(statement, method, args);
+ });
+ }
+
+ /**
+ * 取出本次调用携带的 SQL 文本:语句级方法带 SQL 时按该 SQL 判定,
+ * 预编译语句的无参执行按 prepare 时的 SQL 判定(已在 prepareStatement 中放行)。
+ */
+ private static String sqlArgument(Method method, Object[] args)
+ {
+ if (args == null || args.length == 0 || !(args[0] instanceof String sql)) return null;
+ return switch (method.getName())
+ {
+ case "execute", "executeUpdate", "executeLargeUpdate", "executeQuery", "addBatch", "executeBatch",
+ "executeLargeBatch" -> sql;
+ default -> null;
+ };
+ }
+
+ private void requireQuery(String sql)
+ {
+ if (isQuery(sql))
+ {
+ audit.recordForwarded(sql);
+ return;
+ }
+ audit.recordRejected(sql);
+ throw new NonQueryStatementException("厂商数据源只允许查询语句:" + abbreviate(sql));
+ }
+
+ /** 只按语句首关键字判定,避免用正则近似 SQL 语法。 */
+ static boolean isQuery(String sql)
+ {
+ if (sql == null) return false;
+ String normalized = stripLeadingComments(sql).toLowerCase(Locale.ROOT);
+ if (normalized.isEmpty()) return false;
+ for (String prefix : ALLOWED_PREFIXES)
+ {
+ if (!normalized.startsWith(prefix)) continue;
+ int boundary = prefix.length();
+ if (normalized.length() == boundary || Character.isWhitespace(normalized.charAt(boundary))
+ || normalized.charAt(boundary) == '(' || normalized.charAt(boundary) == '*'
+ || normalized.charAt(boundary) == '`')
+ return true;
+ }
+ return false;
+ }
+
+ private static String stripLeadingComments(String sql)
+ {
+ String remaining = sql.trim();
+ while (true)
+ {
+ if (remaining.startsWith("/*"))
+ {
+ int end = remaining.indexOf("*/");
+ if (end < 0) return remaining;
+ remaining = remaining.substring(end + 2).trim();
+ continue;
+ }
+ if (remaining.startsWith("--") || remaining.startsWith("#"))
+ {
+ int end = remaining.indexOf('\n');
+ if (end < 0) return "";
+ remaining = remaining.substring(end + 1).trim();
+ continue;
+ }
+ return remaining;
+ }
+ }
+
+ private static String abbreviate(String sql)
+ {
+ if (sql == null) return "";
+ String trimmed = sql.trim();
+ return trimmed.length() <= 200 ? trimmed : trimmed.substring(0, 200);
+ }
+
+ private static Object invoke(Object target, Method method, Object[] args) throws Throwable
+ {
+ try
+ {
+ return method.invoke(target, args);
+ }
+ catch (InvocationTargetException exception)
+ {
+ throw exception.getTargetException();
+ }
+ }
+
+ @Override
+ public PrintWriter getLogWriter() throws SQLException
+ {
+ return delegate.getLogWriter();
+ }
+
+ @Override
+ public void setLogWriter(PrintWriter out) throws SQLException
+ {
+ delegate.setLogWriter(out);
+ }
+
+ @Override
+ public void setLoginTimeout(int seconds) throws SQLException
+ {
+ delegate.setLoginTimeout(seconds);
+ }
+
+ @Override
+ public int getLoginTimeout() throws SQLException
+ {
+ return delegate.getLoginTimeout();
+ }
+
+ @Override
+ public Logger getParentLogger() throws SQLFeatureNotSupportedException
+ {
+ return delegate.getParentLogger();
+ }
+
+ @Override
+ public <T> T unwrap(Class<T> iface) throws SQLException
+ {
+ if (iface.isInstance(this)) return iface.cast(this);
+ throw new SQLFeatureNotSupportedException("厂商数据源不暴露未受约束的驱动对象");
+ }
+
+ @Override
+ public boolean isWrapperFor(Class<?> iface)
+ {
+ return iface.isInstance(this);
+ }
+
+ @Override
+ public String toString()
+ {
+ return "ReadOnlyVendorDataSource[" + sourceLabel + "]";
+ }
+
+ /** 便于测试与排查的来源说明。 */
+ public String sourceLabel()
+ {
+ return sourceLabel;
+ }
+}
diff --git a/battery-device-neware/src/main/java/com/battery/device/neware/SingleConnectionVendorDataSource.java b/battery-device-neware/src/main/java/com/battery/device/neware/SingleConnectionVendorDataSource.java
new file mode 100644
index 0000000..9c03559
--- /dev/null
+++ b/battery-device-neware/src/main/java/com/battery/device/neware/SingleConnectionVendorDataSource.java
@@ -0,0 +1,110 @@
+package com.battery.device.neware;
+
+import java.io.PrintWriter;
+import java.sql.Connection;
+import java.sql.DriverManager;
+import java.sql.SQLException;
+import java.sql.SQLFeatureNotSupportedException;
+import java.util.Properties;
+import java.util.logging.Logger;
+import javax.sql.DataSource;
+
+/**
+ * 由 DriverManager 直连厂商库的极简数据源:只被设备适配器使用,不注册进 Spring,
+ * 因此不参与任何业务事务,Druid 主数据源的回滚不会波及厂商库。
+ *
+ * <p>连接复用一条并在查询失败时作废,避免厂商服务端重启后长期持有失效连接;
+ * 厂商库实测按固定 60 秒写库,取数频率远低于连接复用带来的收益门槛。
+ */
+public final class SingleConnectionVendorDataSource implements DataSource
+{
+ private final String url;
+ private final Properties credentials;
+ private Connection connection;
+
+ public SingleConnectionVendorDataSource(String url, String user, String password)
+ {
+ if (url == null || url.isBlank()) throw new IllegalArgumentException("vendor jdbc url must not be blank");
+ this.url = url;
+ this.credentials = new Properties();
+ this.credentials.setProperty("user", user == null ? "" : user);
+ this.credentials.setProperty("password", password == null ? "" : password);
+ }
+
+ @Override
+ public synchronized Connection getConnection() throws SQLException
+ {
+ if (connection == null || connection.isClosed())
+ {
+ connection = DriverManager.getConnection(url, credentials);
+ }
+ return connection;
+ }
+
+ @Override
+ public Connection getConnection(String username, String password) throws SQLException
+ {
+ return DriverManager.getConnection(url, username, password);
+ }
+
+ /** 作废当前连接,使下一次取数重新建立;用于查询失败后的自愈。 */
+ public synchronized void invalidate()
+ {
+ if (connection == null) return;
+ try
+ {
+ connection.close();
+ }
+ catch (SQLException ignored)
+ {
+ // 连接已失效,无需处理。
+ }
+ finally
+ {
+ connection = null;
+ }
+ }
+
+ @Override
+ public PrintWriter getLogWriter() throws SQLException
+ {
+ return DriverManager.getLogWriter();
+ }
+
+ @Override
+ public void setLogWriter(PrintWriter out) throws SQLException
+ {
+ DriverManager.setLogWriter(out);
+ }
+
+ @Override
+ public void setLoginTimeout(int seconds) throws SQLException
+ {
+ DriverManager.setLoginTimeout(seconds);
+ }
+
+ @Override
+ public int getLoginTimeout() throws SQLException
+ {
+ return DriverManager.getLoginTimeout();
+ }
+
+ @Override
+ public Logger getParentLogger() throws SQLFeatureNotSupportedException
+ {
+ return Logger.getLogger("com.battery.device.neware");
+ }
+
+ @Override
+ public <T> T unwrap(Class<T> iface) throws SQLException
+ {
+ if (iface.isInstance(this)) return iface.cast(this);
+ throw new SQLFeatureNotSupportedException("不支持解包为 " + iface);
+ }
+
+ @Override
+ public boolean isWrapperFor(Class<?> iface)
+ {
+ return iface.isInstance(this);
+ }
+}
diff --git a/battery-device-neware/src/test/java/com/battery/device/neware/FakeVendorDatabase.java b/battery-device-neware/src/test/java/com/battery/device/neware/FakeVendorDatabase.java
new file mode 100644
index 0000000..62f1a60
--- /dev/null
+++ b/battery-device-neware/src/test/java/com/battery/device/neware/FakeVendorDatabase.java
@@ -0,0 +1,333 @@
+package com.battery.device.neware;
+
+import java.lang.reflect.InvocationHandler;
+import java.lang.reflect.Method;
+import java.lang.reflect.Proxy;
+import java.math.BigDecimal;
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.ResultSet;
+import java.sql.SQLException;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import javax.sql.DataSource;
+
+/**
+ * 可控的假厂商库:按新威 7.6 实测表结构({@code channel} / {@code test} / {@code data_*})回答适配器的查询,
+ * 并把每一条到达"驱动"的语句原样记录下来。
+ *
+ * <p>它只回答适配器实际使用的查询形态,因此测试断言的是外部行为(发现清单、最新测试、分表定位、
+ * 单位换算、水位过滤),不是 SQL 文本本身;记录下来的语句用于断言只读防线没有漏过非查询语句。
+ */
+final class FakeVendorDatabase
+{
+ record ChannelRow(long devUid, int unitId, int chlId) { }
+
+ record TestRow(long devUid, int unitId, int chlId, long testId, String startTime,
+ String firstTable, String secondTable) { }
+
+ record SampleRow(String table, long testId, int unitId, int chlId, long seqId, int cycle, int stepId,
+ String stepType, long testTimeMillis, int voltage, int current, int capacityCharge, int energyCharge,
+ int capacityDischarge, int energyDischarge, String testAtime) { }
+
+ private final List<String> statements = new java.util.concurrent.CopyOnWriteArrayList<>();
+ private final List<ChannelRow> channels = new ArrayList<>();
+ private final List<TestRow> tests = new ArrayList<>();
+ private final List<SampleRow> samples = new ArrayList<>();
+
+ FakeVendorDatabase registeredChannel(long devUid, int unitId, int chlId)
+ {
+ channels.add(new ChannelRow(devUid, unitId, chlId));
+ return this;
+ }
+
+ FakeVendorDatabase test(TestRow row)
+ {
+ tests.add(row);
+ return this;
+ }
+
+ FakeVendorDatabase sample(SampleRow row)
+ {
+ samples.add(row);
+ return this;
+ }
+
+ /** 到达"驱动"的语句原文,按调用顺序。 */
+ List<String> statements()
+ {
+ return List.copyOf(statements);
+ }
+
+ DataSource dataSource()
+ {
+ return new DataSource()
+ {
+ @Override
+ public Connection getConnection()
+ {
+ return connection();
+ }
+
+ @Override
+ public Connection getConnection(String username, String password)
+ {
+ return connection();
+ }
+
+ @Override
+ public java.io.PrintWriter getLogWriter()
+ {
+ return null;
+ }
+
+ @Override
+ public void setLogWriter(java.io.PrintWriter out)
+ {
+ }
+
+ @Override
+ public void setLoginTimeout(int seconds)
+ {
+ }
+
+ @Override
+ public int getLoginTimeout()
+ {
+ return 0;
+ }
+
+ @Override
+ public java.util.logging.Logger getParentLogger()
+ {
+ return java.util.logging.Logger.getLogger("fake-vendor");
+ }
+
+ @Override
+ public <T> T unwrap(Class<T> iface) throws SQLException
+ {
+ throw new SQLException("不支持解包");
+ }
+
+ @Override
+ public boolean isWrapperFor(Class<?> iface)
+ {
+ return false;
+ }
+ };
+ }
+
+ private Connection connection()
+ {
+ return (Connection) Proxy.newProxyInstance(getClass().getClassLoader(), new Class<?>[] { Connection.class },
+ (proxy, method, args) -> {
+ if ("prepareStatement".equals(method.getName())) return statement((String) args[0]);
+ if ("createStatement".equals(method.getName())) return plainStatement();
+ return defaultValue(method);
+ });
+ }
+
+ /** 语句级执行:SQL 在执行时才出现,因此在这里才记录;探测类语句不回数据。 */
+ private java.sql.Statement plainStatement()
+ {
+ return (java.sql.Statement) Proxy.newProxyInstance(getClass().getClassLoader(),
+ new Class<?>[] { java.sql.Statement.class }, (proxy, method, args) -> {
+ if ("executeQuery".equals(method.getName()) || "execute".equals(method.getName())
+ || "executeUpdate".equals(method.getName()))
+ {
+ statements.add((String) args[0]);
+ return resultSet(List.of());
+ }
+ return defaultValue(method);
+ });
+ }
+
+ private PreparedStatement statement(String sql)
+ {
+ statements.add(sql);
+ List<Object> parameters = new ArrayList<>();
+ return (PreparedStatement) Proxy.newProxyInstance(getClass().getClassLoader(),
+ new Class<?>[] { PreparedStatement.class }, (proxy, method, args) -> {
+ switch (method.getName())
+ {
+ case "setObject", "setString", "setLong", "setInt" ->
+ {
+ int index = (Integer) args[0];
+ while (parameters.size() < index) parameters.add(null);
+ parameters.set(index - 1, args[1]);
+ return null;
+ }
+ case "executeQuery" -> { return resultSet(resolve(sql, parameters)); }
+ case "close" -> { return null; }
+ default -> { return defaultValue(method); }
+ }
+ });
+ }
+
+ /** 按适配器使用的查询形态分派;参数顺序与适配器绑定顺序一致。 */
+ private List<Map<String, Object>> resolve(String sql, List<Object> parameters)
+ {
+ String normalized = sql.toLowerCase().replaceAll("\\s+", " ");
+ if (normalized.contains("from channel"))
+ return channels.stream().sorted(Comparator.comparingLong(ChannelRow::devUid)
+ .thenComparingInt(ChannelRow::unitId).thenComparingInt(ChannelRow::chlId))
+ .map(row -> {
+ Map<String, Object> value = new LinkedHashMap<>();
+ value.put("dev_uid", row.devUid());
+ value.put("unit_id", row.unitId());
+ value.put("chl_id", row.chlId());
+ return value;
+ })
+ .toList();
+ if (normalized.contains("from test") && normalized.contains("max(start_time)"))
+ return latestTests(longValue(parameters, 0));
+ if (normalized.contains("from test"))
+ return latestTestForChannel(longValue(parameters, 0), intValue(parameters, 1), intValue(parameters, 2));
+ if (normalized.contains("order by test_atime, seq_id limit"))
+ return samplesAfter(tableOf(sql), parameters);
+ return lastSampleOfTest(tableOf(sql), intValue(parameters, 0), intValue(parameters, 1),
+ longValue(parameters, 2));
+ }
+
+ private List<Map<String, Object>> latestTests(long devUid)
+ {
+ Map<String, TestRow> latest = new LinkedHashMap<>();
+ tests.stream().filter(row -> row.devUid() == devUid)
+ .sorted(Comparator.comparing(TestRow::startTime).reversed().thenComparingLong(TestRow::testId))
+ .forEach(row -> latest.putIfAbsent(row.unitId() + ":" + row.chlId(), row));
+ return latest.values().stream().sorted(Comparator.comparingInt(TestRow::unitId)
+ .thenComparingInt(TestRow::chlId)).map(this::testColumns).toList();
+ }
+
+ private List<Map<String, Object>> latestTestForChannel(long devUid, int unitId, int chlId)
+ {
+ return tests.stream().filter(row -> row.devUid() == devUid && row.unitId() == unitId && row.chlId() == chlId)
+ .sorted(Comparator.comparing(TestRow::startTime).reversed().thenComparingLong(TestRow::testId))
+ .limit(1).map(this::testColumns).toList();
+ }
+
+ private List<Map<String, Object>> lastSampleOfTest(String table, int unitId, int chlId, long testId)
+ {
+ return samples.stream().filter(row -> row.table().equals(table) && row.unitId() == unitId
+ && row.chlId() == chlId && row.testId() == testId)
+ .sorted(Comparator.comparingLong(SampleRow::seqId).reversed()).limit(1)
+ .map(this::sampleColumns).toList();
+ }
+
+ private List<Map<String, Object>> samplesAfter(String table, List<Object> parameters)
+ {
+ int unitId = intValue(parameters, 0), chlId = intValue(parameters, 1);
+ // 没有带时间的下界时(水位表示界时来源里没有行)返回该通道的全部行。
+ boolean bounded = parameters.size() >= 5;
+ String watermark = bounded ? String.valueOf(parameters.get(2)) : null;
+ long watermarkSeq = bounded ? longValue(parameters, 4) : 0L;
+ return samples.stream().filter(row -> row.table().equals(table) && row.unitId() == unitId
+ && row.chlId() == chlId
+ && (!bounded || after(row.testAtime(), row.seqId(), watermark, watermarkSeq)))
+ .sorted(Comparator.comparing(SampleRow::testAtime).thenComparingLong(SampleRow::seqId))
+ .map(this::sampleColumns).toList();
+ }
+
+ private static boolean after(String atime, long seqId, String watermark, long watermarkSeq)
+ {
+ int time = atime.compareTo(watermark);
+ return time > 0 || (time == 0 && seqId > watermarkSeq);
+ }
+
+ private static String tableOf(String sql)
+ {
+ java.util.regex.Matcher matcher = java.util.regex.Pattern.compile("from\\s+`([A-Za-z0-9_]+)`")
+ .matcher(sql.toLowerCase());
+ if (!matcher.find()) throw new IllegalStateException("假厂商库无法识别分表名:" + sql);
+ return matcher.group(1);
+ }
+
+ private Map<String, Object> testColumns(TestRow row)
+ {
+ Map<String, Object> value = new LinkedHashMap<>();
+ value.put("unit_id", row.unitId());
+ value.put("chl_id", row.chlId());
+ value.put("test_id", row.testId());
+ value.put("main_first_table", row.firstTable());
+ value.put("main_second_table", row.secondTable());
+ return value;
+ }
+
+ private Map<String, Object> sampleColumns(SampleRow row)
+ {
+ Map<String, Object> value = new LinkedHashMap<>();
+ value.put("test_id", row.testId());
+ value.put("seq_id", row.seqId());
+ value.put("cycle", row.cycle());
+ value.put("step_id", row.stepId());
+ value.put("step_type", row.stepType());
+ value.put("test_time", row.testTimeMillis());
+ value.put("test_vol", row.voltage());
+ value.put("test_cur", row.current());
+ value.put("test_capchg", row.capacityCharge());
+ value.put("test_engchg", row.energyCharge());
+ value.put("test_capdchg", row.capacityDischarge());
+ value.put("test_engdchg", row.energyDischarge());
+ value.put("test_atime", row.testAtime());
+ return value;
+ }
+
+ private static ResultSet resultSet(List<Map<String, Object>> rows)
+ {
+ List<String> labels = rows.isEmpty() ? List.of() : List.copyOf(rows.get(0).keySet());
+ int[] cursor = { -1 };
+ return (ResultSet) Proxy.newProxyInstance(FakeVendorDatabase.class.getClassLoader(),
+ new Class<?>[] { ResultSet.class }, (proxy, method, args) -> {
+ switch (method.getName())
+ {
+ case "next" -> { cursor[0]++; return cursor[0] < rows.size(); }
+ case "close" -> { return null; }
+ case "getLong", "getInt", "getString", "getBigDecimal" ->
+ {
+ Object value = value(rows.get(cursor[0]), labels, args[0]);
+ if (value == null) return null;
+ return switch (method.getName())
+ {
+ case "getLong" -> ((Number) value).longValue();
+ case "getInt" -> ((Number) value).intValue();
+ case "getString" -> String.valueOf(value);
+ default -> value instanceof BigDecimal decimal ? decimal
+ : BigDecimal.valueOf(((Number) value).longValue());
+ };
+ }
+ default -> { return defaultValue(method); }
+ }
+ });
+ }
+
+ private static Object value(Map<String, Object> row, List<String> labels, Object key)
+ {
+ if (key instanceof Integer index) return row.get(labels.get(index - 1));
+ return row.get(((String) key).toLowerCase());
+ }
+
+ private static int intValue(List<Object> parameters, int index)
+ {
+ return ((Number) parameters.get(index)).intValue();
+ }
+
+ private static long longValue(List<Object> parameters, int index)
+ {
+ return ((Number) parameters.get(index)).longValue();
+ }
+
+ private static Object defaultValue(Method method)
+ {
+ Class<?> type = method.getReturnType();
+ if (!type.isPrimitive() || type == void.class) return null;
+ if (type == boolean.class) return false;
+ if (type == long.class) return 0L;
+ if (type == double.class) return 0d;
+ if (type == float.class) return 0f;
+ if (type == char.class) return (char) 0;
+ return 0;
+ }
+}
diff --git a/battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java b/battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
new file mode 100644
index 0000000..d89ff23
--- /dev/null
+++ b/battery-device-neware/src/test/java/com/battery/device/neware/NewareMysqlAdapterContractTest.java
@@ -0,0 +1,207 @@
+package com.battery.device.neware;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
+import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.model.DetectedChannel;
+import com.battery.device.api.model.DeviceConnectionStatus;
+import com.battery.device.api.model.DeviceWorkStatus;
+import com.battery.device.api.model.RealtimeSnapshot;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+
+/**
+ * 直连新威库的适配器契约测试:用可控的假厂商库验证发现清单与最近数据时间、
+ * 最新测试按起始时间取、分表定位用测试记录给出的表名、活跃只看新采样行、单位换算、身份与水位。
+ */
+class NewareMysqlAdapterContractTest
+{
+ private static final String ADAPTER_CODE = "neware76";
+ private static final long DEV_UID = 210036L;
+ private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2);
+ private static final Clock CLOCK = Clock.fixed(Instant.parse("2026-09-28T02:00:00Z"), ZoneOffset.UTC);
+
+ private static final String ACTIVE_TABLE = "data_210036_2026_09_18";
+
+ private NewareMysqlAdapter adapter(FakeVendorDatabase database)
+ {
+ AdapterInstanceConfig config = new AdapterInstanceConfig(ADAPTER_CODE,
+ NewareMysqlAdapter.ADAPTER_TYPE, "26.1.2.3", 3306, 60000, "Asia/Hong_Kong", true,
+ AdapterCapabilities.observeOnly(), Map.of("database", "bts63", "user", "visitor"));
+ // 缓存关闭,让每个断言都读到假厂商库的当前内容。
+ return new NewareMysqlAdapter(config, database.dataSource(), () -> { }, CLOCK, 0);
+ }
+
+ /** 一台在用设备(dev_uid 210036)、一台只出现在历史数据里但不在登记表中的设备(240098)。 */
+ private FakeVendorDatabase activeDeviceDatabase()
+ {
+ return new FakeVendorDatabase()
+ .registeredChannel(DEV_UID, 1, 1)
+ .registeredChannel(DEV_UID, 1, 2)
+ .registeredChannel(DEV_UID, 2, 1)
+ .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 440L, "2026-09-18 09:00:00",
+ ACTIVE_TABLE, null))
+ // 已退役设备的历史数据:登记表里没有它,不得成为候选。
+ .test(new FakeVendorDatabase.TestRow(240098L, 1, 1, 900L, "2021-09-30 09:00:00",
+ "data_240098_2021_09_30", null))
+ .sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7435L, 3, 12, "2", 300000L,
+ 13733, -11889, 50350024, 1103331, 50350024, 1103331, "2026-09-28 09:54:56"))
+ .sample(new FakeVendorDatabase.SampleRow(ACTIVE_TABLE, 440L, 1, 2, 7436L, 3, 9, "4", 0L,
+ 13700, 0, 0, 0, 0, 0, "2026-09-28 09:54:56"));
+ }
+
+ @Test
+ void discoveryListsRegisteredChannelsWithLastDataTimeOnlyFromSampleRows()
+ {
+ List<DetectedChannel> channels = adapter(activeDeviceDatabase()).discoverChannels();
+ assertEquals(3, channels.size());
+ // 候选集合来自通道登记表:只出现在历史数据里的退役设备不出现。
+ assertTrue(channels.stream().allMatch(item -> item.identity().devid() == 36));
+ DetectedChannel active = channels.stream()
+ .filter(item -> item.identity().chlid() == 2).findFirst().orElseThrow();
+ assertEquals(new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2), active.identity());
+ assertTrue(active.available());
+ // 最近数据时间取该通道最新采样行的设备时间(按适配器时区解释):Asia/Hong_Kong 09:54:56 = 01:54:56Z。
+ assertEquals(Instant.parse("2026-09-28T01:54:56Z"), active.lastDataAt());
+ // 没有采样行的通道不报最近数据时间:活跃只看有没有新采样行写入。
+ DetectedChannel idle = channels.stream()
+ .filter(item -> item.identity().chlid() == 1 && item.identity().subdevid() == 1)
+ .findFirst().orElseThrow();
+ assertNull(idle.lastDataAt());
+ }
+
+ @Test
+ void latestTestIsPickedByStartTimeNotByTestId()
+ {
+ FakeVendorDatabase database = new FakeVendorDatabase()
+ .registeredChannel(DEV_UID, 1, 2)
+ // 测试编号更大的那条起始时间更早:按 test_id 排序会取错。
+ .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575907L, "2026-02-20 09:00:00",
+ "data_210036_2026_02_20", null))
+ .test(new FakeVendorDatabase.TestRow(DEV_UID, 1, 2, 2818575540L, "2026-09-28 09:00:00",
+ "data_210036_2026_09_28", null))
+ .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_02_20", 2818575907L, 1, 2, 10L, 1, 1, "1",
+ 1000L, 5000, 1000, 10, 10, 10, 10, "2026-02-20 09:00:01"))
+ .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_09_28", 2818575540L, 1, 2, 20L, 4, 7, "2",
+ 60000L, 31541, -12000, 700000, 900000, 800000, 1000000, "2026-09-28 09:01:00"));
+ CabinetRealtime latest = adapter(database).latestCabinet(CHANNEL);
+ assertEquals(20L, latest.sequenceId());
+ assertEquals(Instant.parse("2026-09-28T01:01:00Z"), latest.deviceTime());
+ }
+
+ @Test
+ void shardTableComesFromTheTestRecordInsteadOfTheDeviceTimeDate()
+ {
+ // 实测表名按建表日:9-28 启动的测试写进 9-18 的表。适配器必须用测试记录给出的表名。
+ CabinetRealtime latest = adapter(activeDeviceDatabase()).latestCabinet(CHANNEL);
+ assertEquals("2026-09-28 09:54:56", latest.deviceTimeRaw());
+ assertEquals(Instant.parse("2026-09-28T01:54:56Z"), latest.deviceTime());
+ assertEquals("Asia/Hong_Kong", latest.timezoneId());
+ assertEquals(CLOCK.instant(), latest.serverReceivedAt());
+ }
+
+ @Test
+ void sampledValuesAreConvertedWithTheFieldMeasuredFormula()
+ {
+ // 同秒两行的第一行承载本次实测的换算样本。
+ List<CabinetRealtime> rows = adapter(activeDeviceDatabase()).samplesSince(CHANNEL,
+ new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L));
+ CabinetRealtime latest = rows.get(0);
+ // test_vol 13733 → 1.3733 V;test_cur -11889 → -1.1889 A;放电容量与能量除以 36000000。
+ assertEquals(1.3733, latest.voltageV(), 1e-9);
+ assertEquals(-1.1889, latest.currentA(), 1e-9);
+ assertEquals(50350024d / 36000000d, latest.capacityAh(), 1e-9);
+ assertEquals(1103331d / 36000000d, latest.energyWh(), 1e-9);
+ // test_time 单位为毫秒,作为工步内相对时长输出秒。
+ assertEquals(300L, latest.relativeTimeSeconds());
+ assertEquals(3, latest.cycleNo());
+ assertEquals(12, latest.stepNo());
+ assertEquals("2", latest.stepType());
+ assertEquals("neware:440:7435", latest.sourceRecordId());
+ // 工步类型保留设备给出的数字,不按电流符号猜方向;同一秒的另一行靠工步与序号区分。
+ assertEquals(9, rows.get(1).stepNo());
+ }
+
+ @Test
+ void onlineFactIsSourceReachabilityAndChannelWorkStatusStaysUnobservable()
+ {
+ NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
+ Map<ChannelIdentity, RealtimeSnapshot> snapshots = adapter.inquire(Set.of(CHANNEL));
+ RealtimeSnapshot snapshot = snapshots.get(CHANNEL);
+ assertEquals(DeviceConnectionStatus.ONLINE, snapshot.connectionStatus());
+ // 读库路线下通道工作状态不可观测,不按数据行推断。
+ assertEquals(DeviceWorkStatus.UNOBSERVABLE, snapshot.workStatus());
+ assertFalse(adapter.capabilities().control());
+ // 辅助测量来自导入,不来自新威辅助表族。
+ assertNull(adapter.latestCollector(ADAPTER_CODE, "anything"));
+ assertEquals(NewareMysqlAdapter.SAMPLE_INTERVAL_MS, adapter.persistenceIntervalMillis(CHANNEL));
+ }
+
+ @Test
+ void samplesSinceKeepsEveryRowInTheSameSecondAndDropsRowsAtOrBeforeTheWatermark()
+ {
+ NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
+ SampleWatermark watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7434L);
+ List<CabinetRealtime> rows = adapter.samplesSince(CHANNEL, watermark);
+ // 同一绝对秒内的两行都保留,并且靠协议序号区分。
+ assertEquals(2, rows.size());
+ assertEquals(List.of(7435L, 7436L), rows.stream().map(CabinetRealtime::sequenceId).toList());
+ assertEquals(List.of("neware:440:7435", "neware:440:7436"),
+ rows.stream().map(CabinetRealtime::sourceRecordId).toList());
+ // 严格大于水位:等于水位的行不返回。
+ assertEquals(1, adapter.samplesSince(CHANNEL,
+ new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7435L)).size());
+ // 水位之后没有新行时返回空。
+ assertTrue(adapter.samplesSince(CHANNEL,
+ new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L)).isEmpty());
+ }
+
+ @Test
+ void samplesWithoutWatermarkReturnNothingSoDeviceHistoryNeverEntersTheTest()
+ {
+ NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
+ assertTrue(adapter.samplesSince(CHANNEL, null).isEmpty());
+ }
+
+ @Test
+ void emptyWatermarkMeansTheChannelHadNoRowsSoEveryLaterRowBelongsToTheReception()
+ {
+ // 边界时该通道在来源里没有任何行(例如通道尚未开始测试),之后出现的行全部属于本次接收。
+ List<CabinetRealtime> rows = adapter(activeDeviceDatabase())
+ .samplesSince(CHANNEL, new SampleWatermark(null, 0L));
+ assertEquals(2, rows.size());
+ }
+
+ @Test
+ void watermarkIsTheLatestRowOfTheLatestTest()
+ {
+ SampleWatermark watermark = adapter(activeDeviceDatabase()).currentWatermark(CHANNEL);
+ assertEquals(Instant.parse("2026-09-28T01:54:56Z"), watermark.deviceTime());
+ assertEquals(7436L, watermark.sequenceId());
+ // 通道没有测试记录时水位为空,接收路径不把设备历史并入本测试。
+ NewareMysqlAdapter empty = adapter(new FakeVendorDatabase().registeredChannel(DEV_UID, 1, 2));
+ assertNull(empty.currentWatermark(CHANNEL).deviceTime());
+ }
+
+ @Test
+ void rejectsChannelIdentitiesThatBelongToAnotherInstance()
+ {
+ NewareMysqlAdapter adapter = adapter(activeDeviceDatabase());
+ ChannelIdentity foreign = new ChannelIdentity("other-source", 21, 36, 1, 2);
+ org.junit.jupiter.api.Assertions.assertThrows(RuntimeException.class, () -> adapter.latestCabinet(foreign));
+ assertTrue(adapter.discoverChannels().stream()
+ .allMatch(item -> ADAPTER_CODE.equals(item.identity().adapterCode())));
+ }
+}
diff --git a/battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java b/battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java
new file mode 100644
index 0000000..9141429
--- /dev/null
+++ b/battery-device-neware/src/test/java/com/battery/device/neware/ReadOnlyVendorDataSourceTest.java
@@ -0,0 +1,161 @@
+package com.battery.device.neware;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.sql.Connection;
+import java.sql.PreparedStatement;
+import java.sql.Statement;
+import java.time.Clock;
+import java.time.Instant;
+import java.time.ZoneOffset;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import javax.sql.DataSource;
+import org.junit.jupiter.api.Test;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+
+/**
+ * 只读防线测试:非查询语句在到达驱动之前被拒绝,且该数据源在其生命周期内从未发出过非查询语句。
+ */
+class ReadOnlyVendorDataSourceTest
+{
+ private static final String ADAPTER_CODE = "neware76";
+ private static final ChannelIdentity CHANNEL = new ChannelIdentity(ADAPTER_CODE, 21, 36, 1, 2);
+
+ private ReadOnlyVendorDataSource dataSourceOver(FakeVendorDatabase database)
+ {
+ return new ReadOnlyVendorDataSource(database.dataSource(), ADAPTER_CODE);
+ }
+
+ @Test
+ void queryStatementsAreForwardedAndRecorded() throws Exception
+ {
+ ReadOnlyVendorDataSource dataSource = new ReadOnlyVendorDataSource(new FakeVendorDatabase().dataSource(),
+ ADAPTER_CODE);
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement("select 1 from channel"))
+ {
+ statement.executeQuery();
+ }
+ try (Connection connection = dataSource.getConnection();
+ Statement statement = connection.createStatement())
+ {
+ statement.executeQuery("show tables");
+ statement.executeQuery("describe `channel`");
+ }
+ assertTrue(dataSource.audit().rejectedStatements().isEmpty());
+ assertEquals(3, dataSource.audit().forwardedStatements().size());
+ }
+
+ @Test
+ void nonQueryStatementsAreRejectedBeforeTheyReachTheDriver() throws Exception
+ {
+ FakeVendorDatabase database = new FakeVendorDatabase();
+ ReadOnlyVendorDataSource dataSource = dataSourceOver(database);
+ List<String> nonQueries = List.of("update channel set chl_status = 1",
+ "insert into channel(dev_uid) values(210036)", "delete from channel",
+ "drop table data_210036_2026_09_18", "create index idx on channel(chl_status)",
+ "truncate table channel", "set global read_only = 0", "grant select on *.* to x",
+ "alter table channel add column x int", "call some_procedure()");
+ for (String sql : nonQueries)
+ {
+ assertThrows(NonQueryStatementException.class, () -> {
+ try (Connection connection = dataSource.getConnection();
+ PreparedStatement statement = connection.prepareStatement(sql))
+ {
+ statement.execute();
+ }
+ }, "非查询语句必须被拒绝:" + sql);
+ }
+ assertEquals(nonQueries.size(), dataSource.audit().rejectedStatements().size());
+ assertTrue(database.statements().isEmpty(), "被拒绝的语句不得到达驱动");
+ // 语句级执行同样被拦下,并且没有任何非查询语句到达驱动。
+ assertThrows(NonQueryStatementException.class, () -> {
+ try (Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement())
+ {
+ statement.executeUpdate("update channel set chl_status = 1");
+ }
+ });
+ assertThrows(NonQueryStatementException.class,
+ () -> dataSource.getConnection().prepareCall("{call anything()}"));
+ assertTrue(database.statements().isEmpty(), "被拒绝的语句不得到达驱动");
+ assertEquals(nonQueries.size() + 1, dataSource.audit().rejectedStatements().size());
+ }
+
+ /**
+ * 这条断言对应验收要求:整个适配器生命周期内,该数据源没有发出过任何非查询语句。
+ */
+ @Test
+ void adapterLifecycleNeverSendsANonQueryStatement()
+ {
+ FakeVendorDatabase database = new FakeVendorDatabase()
+ .registeredChannel(210036L, 1, 2)
+ .test(new FakeVendorDatabase.TestRow(210036L, 1, 2, 440L, "2026-09-18 09:00:00",
+ "data_210036_2026_09_18", null))
+ .sample(new FakeVendorDatabase.SampleRow("data_210036_2026_09_18", 440L, 1, 2, 7435L, 3, 12, "2",
+ 300000L, 13733, -11889, 0, 0, 50350024, 1103331, "2026-09-28 09:54:56"));
+ ReadOnlyVendorDataSource guarded = dataSourceOver(database);
+ AdapterInstanceConfig config = new AdapterInstanceConfig(ADAPTER_CODE, NewareMysqlAdapter.ADAPTER_TYPE,
+ "26.1.2.3", 3306, 60000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly(),
+ Map.of("database", "bts63"));
+ NewareMysqlAdapter adapter = new NewareMysqlAdapter(config, guarded, () -> { },
+ Clock.fixed(Instant.parse("2026-09-28T02:00:00Z"), ZoneOffset.UTC), 0);
+
+ adapter.discoverChannels();
+ adapter.inquire(Set.of(CHANNEL));
+ adapter.latestCabinet(CHANNEL);
+ adapter.currentWatermark(CHANNEL);
+ adapter.samplesSince(CHANNEL,
+ new com.battery.device.api.TestDataObservationAdapter.SampleWatermark(
+ Instant.parse("2026-09-28T01:00:00Z"), 1L));
+
+ assertTrue(guarded.audit().rejectedStatements().isEmpty(),
+ "适配器不应尝试任何非查询语句:" + guarded.audit().rejectedStatements());
+ assertTrue(!database.statements().isEmpty(), "适配器应当实际读取来源");
+ for (String statement : database.statements())
+ {
+ assertTrue(ReadOnlyVendorDataSource.isQuery(statement), "到达驱动的语句必须是查询:" + statement);
+ }
+ }
+
+ @Test
+ void statementClassificationKeepsQueriesAndRejectsEverythingElse()
+ {
+ assertTrue(ReadOnlyVendorDataSource.isQuery("select dev_uid from channel"));
+ assertTrue(ReadOnlyVendorDataSource.isQuery("\n SELECT 1"));
+ assertTrue(ReadOnlyVendorDataSource.isQuery("/* probe */ show grants"));
+ assertTrue(ReadOnlyVendorDataSource.isQuery("-- probe\n describe channel"));
+ assertTrue(ReadOnlyVendorDataSource.isQuery("desc `channel`"));
+ assertTrue(!ReadOnlyVendorDataSource.isQuery("update channel set chl_status = 0"));
+ assertTrue(!ReadOnlyVendorDataSource.isQuery(""));
+ // 以 select 开头但紧跟字母的标识符不是查询语句。
+ assertTrue(!ReadOnlyVendorDataSource.isQuery("selection_helper()"));
+ }
+
+ @Test
+ void rejectedStatementsNeverReachTheDriverEvenWhenTheApplicationKeepsTrying()
+ {
+ // 连续尝试写操作后,来源侧一条语句都没收到,说明拦截发生在驱动之前。
+ FakeVendorDatabase database = new FakeVendorDatabase();
+ ReadOnlyVendorDataSource dataSource = dataSourceOver(database);
+ for (String sql : List.of("update channel set chl_status = 1", "delete from channel",
+ "insert into channel(dev_uid) values(210036)"))
+ {
+ try
+ {
+ dataSource.getConnection().prepareStatement(sql);
+ }
+ catch (Exception ignored)
+ {
+ // 每条都被白名单挡下。
+ }
+ }
+ assertTrue(database.statements().isEmpty());
+ assertEquals(3, dataSource.audit().rejectedStatements().size());
+ }
+}
diff --git a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
index a7728ba..5fa4690 100644
--- a/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
+++ b/battery-device-simulator/src/main/java/com/battery/device/simulator/BtsDevelopmentSimulator.java
@@ -7,10 +7,14 @@
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
+import java.util.LinkedHashMap;
import java.util.List;
+import java.util.Map;
+import java.util.Set;
import javax.xml.stream.XMLInputFactory;
import javax.xml.stream.XMLStreamConstants;
import javax.xml.stream.XMLStreamReader;
+import com.battery.device.api.DeviceAdapter;
import com.battery.device.api.model.DeviceWorkStatus;
import com.battery.device.api.model.DeviceConnectionStatus;
import com.battery.device.api.model.DeviceIdentity;
@@ -19,6 +23,7 @@
import com.battery.device.api.model.DetectedDeviceMetadata;
import com.battery.device.api.model.ChannelIdentity;
import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.model.RealtimeSnapshot;
import com.battery.device.api.DeviceObservationAdapter;
import com.battery.device.api.TestDataObservationAdapter;
import com.battery.device.simulator.BtsSimulatorStateStore;
@@ -37,8 +42,12 @@
import com.battery.device.simulator.DevelopmentCollectorSimulation;
import com.battery.device.simulator.DevelopmentCollectorSimulation.CollectorRealtimeSnapshot;
-/** 提供由开发后端托管的临时只读 BTS 模拟端点。 */
-public final class BtsDevelopmentSimulator implements TestDataObservationAdapter
+/**
+ * 提供由开发后端托管的临时只读 BTS 模拟端点。
+ * 同时实现发现与测试数据观测契约:模拟器直接暴露自身状态,因此发现与取数按同一实例解析,
+ * 不再需要单独注入一个扩展观测 Bean。TCP 端点仍保留给协议层测试使用。
+ */
+public final class BtsDevelopmentSimulator implements DeviceAdapter, TestDataObservationAdapter
{
public static final String HOST = "127.0.0.1";
public static final int DEFAULT_PORT = 15020;
@@ -102,6 +111,34 @@
for (ChannelIdentity requested : channels) result.put(requested, observation(requested));
recordRequest("observe", channels.toString(), result.toString());
return java.util.Map.copyOf(result);
+ }
+
+ /** 直接提供模拟器实时快照;不可观测通道不出现在结果里,与 BTS 协议解析后的语义一致。 */
+ @Override
+ public synchronized Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
+ {
+ if (channels == null || channels.isEmpty()) return Map.of();
+ Map<ChannelIdentity, RealtimeSnapshot> result = new LinkedHashMap<>();
+ for (ChannelIdentity requested : channels)
+ {
+ if (!canObserve(requested)) continue;
+ CabinetRealtime measurement = cabinetMeasurement(requested);
+ if (measurement == null) continue;
+ DeviceObservation observed = observation(requested);
+ result.put(requested, new RealtimeSnapshot(requested, observed.workStatus(), measurement.cycleNo(),
+ measurement.stepNo(), measurement.stepType(), null, BigDecimal.valueOf(measurement.currentA()),
+ BigDecimal.valueOf(measurement.voltageV()), BigDecimal.valueOf(measurement.capacityAh()),
+ BigDecimal.valueOf(measurement.energyWh()), totalTimeSeconds(requested),
+ measurement.relativeTimeSeconds(), null, measurement.serverReceivedAt(), Map.of(),
+ DeviceConnectionStatus.ONLINE, measurement.deviceTime(), List.of(), null));
+ }
+ recordRequest("inquire", channels.toString(), result.toString());
+ return Map.copyOf(result);
+ }
+
+ private long totalTimeSeconds(ChannelIdentity identity)
+ {
+ return requireDevice(identity.devid()).channels().get(identity.chlid() - 1).configuration().totalTimeSeconds();
}
private DeviceObservation observation(ChannelIdentity requested)
@@ -368,7 +405,7 @@
/** 请求采集器当前配置值;软件不可访问或设备离线时返回空测量。 */
@Override
- public synchronized CollectorRealtime latestCollector(String collectorCode)
+ public synchronized CollectorRealtime latestCollector(String adapterCode, String collectorCode)
{
CollectorState collector = state.collectors().stream().filter(item -> item.deviceCode().equals(collectorCode))
.findFirst().orElse(null);
diff --git a/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java b/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
index 9407dfd..a35fb53 100644
--- a/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
+++ b/battery-device-simulator/src/test/java/com/battery/device/simulator/SimulatorObservationAdapterContractTest.java
@@ -16,7 +16,7 @@
{
simulator.updateChannel(1, 1, true, DeviceWorkStatus.PROTECTED);
simulator.updateCollectorChannelStatus(1, "S-01", DeviceWorkStatus.UNOBSERVABLE);
- var collector = simulator.latestCollector("dev-sim-collector-1");
+ var collector = simulator.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1");
assertTrue(collector.measurements().stream().noneMatch(item -> item.channelCode().equals("S-01")));
firstSequence = collector.measurements().get(0).sequenceId();
simulator.setSoftwareAccessible(false);
@@ -30,7 +30,7 @@
assertEquals(DeviceWorkStatus.PROTECTED, restored.snapshot().devices().get(0).channels().get(0).workStatus());
assertTrue(restored.snapshot().collectors().get(0).latestMeasurements().isEmpty());
restored.setSoftwareAccessible(true);
- assertTrue(restored.latestCollector("dev-sim-collector-1").measurements().get(0).sequenceId() > firstSequence);
+ assertTrue(restored.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1").measurements().get(0).sequenceId() > firstSequence);
assertEquals(DeviceWorkStatus.UNOBSERVABLE, restored.snapshot().collectors().get(0).channels().get(0).workStatus());
}
}
@@ -63,14 +63,14 @@
simulator.updateChannel(1, 1, true, DeviceWorkStatus.WORKING);
var first = simulator.latestCabinet(channel);
assertNotNull(first);
- assertFalse(simulator.latestCollector("dev-sim-collector-1").measurements().isEmpty());
+ assertFalse(simulator.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1").measurements().isEmpty());
simulator.setSoftwareAccessible(false);
assertEquals(channel, simulator.discoverChannels().get(0).identity());
assertTrue(simulator.snapshot().devices().get(0).online());
assertEquals(DeviceWorkStatus.UNOBSERVABLE,
simulator.observe(java.util.Set.of(channel)).get(channel).workStatus());
assertNull(simulator.latestCabinet(channel));
- assertTrue(simulator.latestCollector("dev-sim-collector-1").measurements().isEmpty());
+ assertTrue(simulator.latestCollector(BtsDevelopmentSimulator.ADAPTER_CODE, "dev-sim-collector-1").measurements().isEmpty());
simulator.setSoftwareAccessible(true);
var next = simulator.latestCabinet(channel);
assertTrue(next.sequenceId() > first.sequenceId());
diff --git a/pom.xml b/pom.xml
index f64766f..1913403 100644
--- a/pom.xml
+++ b/pom.xml
@@ -181,6 +181,11 @@
</dependency>
<dependency>
<groupId>com.battery</groupId>
+ <artifactId>battery-device-neware</artifactId>
+ <version>${ruoyi.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>com.battery</groupId>
<artifactId>battery-device-simulator</artifactId>
<version>${ruoyi.version}</version>
</dependency>
@@ -202,6 +207,7 @@
<module>battery-business</module>
<module>battery-device-api</module>
<module>battery-device-bts</module>
+ <module>battery-device-neware</module>
<module>ruoyi-quartz</module>
<module>ruoyi-generator</module>
<module>ruoyi-common</module>
diff --git a/ruoyi-admin/pom.xml b/ruoyi-admin/pom.xml
index 5209293..6973c13 100644
--- a/ruoyi-admin/pom.xml
+++ b/ruoyi-admin/pom.xml
@@ -81,6 +81,12 @@
<artifactId>battery-device-bts</artifactId>
</dependency>
+ <!-- 直连厂商库的只读适配器实现 -->
+ <dependency>
+ <groupId>com.battery</groupId>
+ <artifactId>battery-device-neware</artifactId>
+ </dependency>
+
<!-- 定时任务-->
<dependency>
<groupId>com.ruoyi</groupId>
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentRealtimeSnapshotScheduler.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentRealtimeSnapshotScheduler.java
deleted file mode 100644
index b9be79a..0000000
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentRealtimeSnapshotScheduler.java
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.battery.business.runtime;
-
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-import org.springframework.context.annotation.Profile;
-import com.battery.business.service.ITestDataReceptionService;
-
-/** 开发环境触发数据接收,由业务服务按测试接收状态和采样槽位保存成功观测。 */
-@Component
-@Profile("dev-simulator")
-public class DevelopmentRealtimeSnapshotScheduler
-{
- private final ITestDataReceptionService reception;
-
- public DevelopmentRealtimeSnapshotScheduler(ITestDataReceptionService reception) { this.reception = reception; }
-
- @Scheduled(fixedDelay = 100)
- public void capture() { reception.captureReceivedData(); }
-}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java
index ee12cab..53c5461 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/DevelopmentSimulatorConfig.java
@@ -21,10 +21,12 @@
@Bean
@Profile("dev-simulator")
public ApplicationRunner registerDevelopmentSimulatorAdapter(DeviceAdapterRegistry registry,
+ BtsDevelopmentSimulator simulator,
@Value("${battery.dev-simulator.port:15020}") int simulatorPort)
{
// 固定开发装配不进入 t_device_adapter,直接注册运行时实例。
- return arguments -> registry.synchronize(developmentSimulatorRuntimeConfig(simulatorPort));
+ // 注册模拟器对象本身,使发现与取数落在同一实例上,不再单独注入扩展观测 Bean。
+ return arguments -> registry.register(developmentSimulatorRuntimeConfig(simulatorPort), simulator);
}
private AdapterInstanceConfig developmentSimulatorRuntimeConfig(int simulatorPort)
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java
index 8cdd91f..b819ad6 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/DeviceAdapterRuntimeConfig.java
@@ -3,29 +3,27 @@
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
-import org.springframework.context.annotation.Profile;
import org.springframework.scheduling.annotation.EnableScheduling;
import com.battery.device.api.NoopDeviceAdapterFactory;
import com.battery.device.bts.BtsDeviceAdapterFactory;
import com.battery.device.api.registry.DeviceAdapterRegistry;
+import com.battery.device.neware.NewareMysqlAdapterFactory;
-/** 装配单进程设备适配器运行时。 */
+/**
+ * 装配单进程设备适配器运行时。
+ *
+ * <p>注册表按存储形态持有全部可用实现;实例按 {@code t_device_adapter} 配置或代码装配注册。
+ * 同一进程内可并存多个来源实例,取数按通道身份里的适配器编码解析到各自实例。
+ * 某个类型是否真的有实例可用只由配置决定,因此这里不再按 profile 收窄可注册的类型。
+ */
@Configuration
@EnableScheduling
public class DeviceAdapterRuntimeConfig
{
@Bean(destroyMethod = "close")
- @Profile("!dev-simulator & !bts")
- public DeviceAdapterRegistry noopDeviceAdapterRegistry()
- {
- return new DeviceAdapterRegistry(List.of(new NoopDeviceAdapterFactory()));
- }
-
- @Bean(destroyMethod = "close")
- @Profile({ "dev-simulator", "bts" })
public DeviceAdapterRegistry deviceAdapterRegistry()
{
- return new DeviceAdapterRegistry(List.of(new BtsDeviceAdapterFactory()));
+ return new DeviceAdapterRegistry(List.of(new NoopDeviceAdapterFactory(), new BtsDeviceAdapterFactory(),
+ new NewareMysqlAdapterFactory()));
}
-
}
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java
index 4357819..a21b4b2 100644
--- a/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java
@@ -129,7 +129,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)
@@ -141,7 +142,8 @@
identity.getIntValue("subdevid"), identity.getIntValue("chlid"));
RealtimeSnapshot latestSnapshot = object.getObject("latestSnapshot", RealtimeSnapshot.class);
return new ChannelCandidate(channelIdentity, object.getBooleanValue("online"),
- DeviceWorkStatus.valueOf(object.getString("workStatus")), requiredInstant(object, "observedAt"), latestSnapshot);
+ DeviceWorkStatus.valueOf(object.getString("workStatus")), requiredInstant(object, "observedAt"),
+ latestSnapshot, optionalInstant(object, "lastDataAt"));
}
private CollectorCandidate collectorCandidate(Object value)
@@ -161,6 +163,12 @@
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));
diff --git a/ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java b/ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java
new file mode 100644
index 0000000..f0766d2
--- /dev/null
+++ b/ruoyi-admin/src/main/java/com/battery/business/runtime/TestDataReceptionScheduler.java
@@ -0,0 +1,22 @@
+package com.battery.business.runtime;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+import com.battery.business.service.ITestDataReceptionService;
+
+/**
+ * 按固定周期触发数据接收,由业务服务按测试接收状态和采样来源保存成功观测。
+ *
+ * <p>它不限定 profile:真实来源的接收路径也必须在非开发 profile 下运行。
+ * 开发模拟器专属的调度(模拟器发现轮询、采集器状态同步)仍只在 dev-simulator 下额外启用。
+ */
+@Component
+public class TestDataReceptionScheduler
+{
+ private final ITestDataReceptionService reception;
+
+ public TestDataReceptionScheduler(ITestDataReceptionService reception) { this.reception = reception; }
+
+ @Scheduled(fixedDelay = 1000)
+ public void capture() { reception.captureReceivedData(); }
+}
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V22__vendor_source_instance_config.sql b/ruoyi-admin/src/main/resources/db/migration-final/V22__vendor_source_instance_config.sql
new file mode 100644
index 0000000..17ef70d
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V22__vendor_source_instance_config.sql
@@ -0,0 +1,3 @@
+-- Ticket01:适配器实例的类型按存储形态放宽到直连厂商库,来源形态配置改用 JSON 列承载(ADR-0065)。
+-- 连接口令不进库:source_config 只放 host、port、database、user、时区这类非敏感项。
+ALTER TABLE t_device_adapter ADD COLUMN source_config JSON NULL AFTER capabilities;
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java
index 932c945..99f5714 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/runtime/DeviceAdapterRuntimeConfigTest.java
@@ -8,19 +8,22 @@
import org.mockito.ArgumentCaptor;
import com.battery.device.api.registry.AdapterInstanceConfig;
import com.battery.device.api.registry.DeviceAdapterRegistry;
+import com.battery.device.simulator.BtsDevelopmentSimulator;
class DeviceAdapterRuntimeConfigTest
{
@Test
- void registersDevelopmentSimulatorBeforeApplicationReady() throws Exception
+ void registersDevelopmentSimulatorInstanceBeforeApplicationReady() throws Exception
{
DeviceAdapterRegistry registry = mock(DeviceAdapterRegistry.class);
- var runner = new DevelopmentSimulatorConfig().registerDevelopmentSimulatorAdapter(registry, 15021);
+ BtsDevelopmentSimulator simulator = mock(BtsDevelopmentSimulator.class);
+ var runner = new DevelopmentSimulatorConfig().registerDevelopmentSimulatorAdapter(registry, simulator, 15021);
runner.run(new DefaultApplicationArguments(new String[0]));
+ // 注册模拟器对象本身:发现与取数落在同一实例上,不再单独注入扩展观测 Bean。
ArgumentCaptor<AdapterInstanceConfig> adapter = ArgumentCaptor.forClass(AdapterInstanceConfig.class);
- verify(registry).synchronize(adapter.capture());
+ verify(registry).register(adapter.capture(), org.mockito.ArgumentMatchers.same(simulator));
assertEquals("dev-sim", adapter.getValue().adapterCode());
assertEquals("127.0.0.1", adapter.getValue().host());
assertEquals(15021, adapter.getValue().port());
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java
new file mode 100644
index 0000000..643bb07
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/runtime/VendorSourceInstanceMigrationContractTest.java
@@ -0,0 +1,97 @@
+package com.battery.business.runtime;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.UUID;
+import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
+import org.junit.jupiter.api.Test;
+import org.springframework.core.io.ClassPathResource;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.jdbc.datasource.init.ScriptUtils;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+import com.battery.device.api.registry.AdapterRegistryException;
+import com.battery.device.api.registry.DeviceAdapterRegistry;
+
+/**
+ * 适配器实例按存储形态放宽与来源形态配置列的迁移契约:
+ * 迁移把 {@code source_config} 加在能力列之后,运行库上该列确实存在且可空;
+ * 类型白名单放宽到直连厂商库,未知类型仍然拒绝。
+ */
+class VendorSourceInstanceMigrationContractTest
+{
+ @Test
+ void migrationAddsNullableSourceConfigColumnWithoutTouchingExistingRows() throws Exception
+ {
+ String path = "db/migration-final/V22__vendor_source_instance_config.sql";
+ String migration = resource(path);
+ assertTrue(migration.contains("ALTER TABLE t_device_adapter ADD COLUMN source_config JSON NULL"));
+ assertTrue(migration.contains("AFTER capabilities"));
+ // 迁移只加列:不改既有列、不写入任何数据,更不写入凭据。
+ assertFalse(migration.matches("(?s).*\\b(INSERT|UPDATE|DELETE|DROP)\\b.*"));
+ assertFalse(migration.toLowerCase().contains("password"));
+
+ var source = new UnpooledDataSource("org.h2.Driver",
+ "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
+ try (var connection = source.getConnection())
+ {
+ JdbcTemplate jdbc = new JdbcTemplate(source);
+ jdbc.execute("create table t_device_adapter(adapter_id bigint auto_increment primary key,"
+ + "adapter_code varchar(64), capabilities json)");
+ ScriptUtils.executeSqlScript(connection, new ClassPathResource(path));
+
+ // 来源形态配置只承载非敏感项;连接口令由环境变量提供,不入库。
+ jdbc.update("insert into t_device_adapter(adapter_code, capabilities, source_config) values(?,?,?)",
+ "neware76", "{\"control\":false}", "{\"database\":\"bts63\",\"user\":\"visitor\"}");
+ assertEquals(1, jdbc.queryForObject(
+ "select count(*) from t_device_adapter where source_config is not null", Integer.class));
+ // 代码装配实例(例如开发模拟器)保持为空。
+ jdbc.update("insert into t_device_adapter(adapter_code, capabilities) values(?,?)", "dev-sim",
+ "{\"control\":false}");
+ assertEquals(1, jdbc.queryForObject(
+ "select count(*) from t_device_adapter where source_config is null", Integer.class));
+ }
+ }
+
+ @Test
+ void sourceConfigCarriesOnlyNonSensitiveOptionsAndUnknownTypesStayRejected()
+ {
+ AdapterInstanceConfig config = new AdapterInstanceConfig("neware76", "NEWARE_MYSQL", "26.1.2.3", 3306,
+ 60000, "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly(),
+ Map.of("database", "bts63", "user", "visitor", "passwordEnv", "BATTERY_VENDOR_DB_PASSWORD"));
+
+ assertEquals("bts63", config.sourceOption("database"));
+ assertTrue(config.capabilities().discovery());
+ assertFalse(config.capabilities().control());
+
+ try (DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of()))
+ {
+ // 放宽不等于接受任何类型:未注册的存储形态仍然明确失败。
+ assertThrows(AdapterRegistryException.class,
+ () -> registry.synchronize(new AdapterInstanceConfig("mystery", "SOME_OTHER_VENDOR",
+ "127.0.0.1", 3306, 60000, "Asia/Hong_Kong", true,
+ AdapterCapabilities.observeOnly(), Map.of())));
+ }
+ }
+
+ private static String resource(String path)
+ {
+ try (InputStream stream = VendorSourceInstanceMigrationContractTest.class.getClassLoader()
+ .getResourceAsStream(path))
+ {
+ if (stream == null) throw new IllegalStateException("缺少迁移文件:" + path);
+ return new String(stream.readAllBytes(), StandardCharsets.UTF_8);
+ }
+ catch (java.io.IOException exception)
+ {
+ throw new IllegalStateException("无法读取迁移文件:" + path, exception);
+ }
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java
index dda94d2..a68bc03 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceServiceTest.java
@@ -76,4 +76,40 @@
assertEquals(1, candidateStore.snapshot(java.time.Instant.now()).cabinets().size());
verify(adapter).discoverChannels();
}
+
+ /**
+ * 直连厂商存储的来源:通道登记是长期事实,候选不按时间过期,
+ * 每条候选附带最近一次采样行的设备时间,供操作员判断哪几台真的在产出数据。
+ */
+ @Test
+ void vendorSourceCandidatesCarryLastDataTimeAndDoNotExpireByTime()
+ {
+ InMemoryDeviceDiscoveryCandidateStore candidateStore = new InMemoryDeviceDiscoveryCandidateStore();
+ TDetectionCabinetMapper cabinetMapper = Mockito.mock(TDetectionCabinetMapper.class);
+ TTestMapper testMapper = Mockito.mock(TTestMapper.class);
+ DeviceAdapterRegistry registry = Mockito.mock(DeviceAdapterRegistry.class);
+ DeviceAdapter adapter = Mockito.mock(DeviceAdapter.class);
+ ChannelIdentity identity = new ChannelIdentity("neware76", 21, 36, 1, 2);
+ java.time.Instant lastDataAt = java.time.Instant.parse("2026-09-28T01:54:56Z");
+ when(testMapper.hasReceivingTestForAdapter("neware76")).thenReturn(0);
+ when(adapter.discoverChannels()).thenReturn(List.of(new DetectedChannel(identity, "26.1.2.3", true,
+ com.battery.device.api.model.DetectedDeviceMetadata.empty(), lastDataAt)));
+ when(registry.execute(eq("neware76"), any())).thenAnswer(invocation -> {
+ @SuppressWarnings("unchecked")
+ Function<DeviceAdapter, Object> operation = (Function<DeviceAdapter, Object>) invocation.getArgument(1);
+ return operation.apply(adapter);
+ });
+
+ DeviceDiscoveryPersistenceService service = new DeviceDiscoveryPersistenceService(candidateStore,
+ cabinetMapper, testMapper);
+ service.discoverForReception(registry, new DeviceDiscoveryPersistenceService.DiscoverySource(
+ "neware76", 8L, "新威 7.6 那台", 60000, true));
+
+ var candidate = candidateStore.snapshot(java.time.Instant.now()).cabinets().get(0);
+ assertEquals(lastDataAt, candidate.lastDataAt());
+ assertEquals(lastDataAt, candidate.channels().get(0).lastDataAt());
+ // 一年后仍然是候选:候选只按"已登记且未删除"排除,不按时间过期。
+ assertEquals(1, candidateStore.snapshot(java.time.Instant.now().plusSeconds(365L * 24 * 3600))
+ .cabinets().size());
+ }
}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java
new file mode 100644
index 0000000..3b36194
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingAdapterWiringContractTest.java
@@ -0,0 +1,33 @@
+package com.battery.business.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.context.annotation.Primary;
+import org.springframework.stereotype.Service;
+import com.battery.device.api.TestDataObservationAdapter;
+
+/**
+ * 取数入口的装配契约:开发模拟器既是 Spring Bean,又实现了测试数据观测契约,
+ * 所以业务侧注入的那个 Bean 必须显式声明为首选,否则上下文里出现两个候选会启动失败。
+ * 这里断言的是"哪个 Bean 会被注入"这个结构性事实,而不是 Spring 内部行为。
+ */
+class RoutingAdapterWiringContractTest
+{
+ @Test
+ void routingAdapterIsThePreferredTestDataObservationBean()
+ {
+ assertTrue(TestDataObservationAdapter.class.isAssignableFrom(RoutingTestDataObservationAdapter.class));
+ assertTrue(RoutingTestDataObservationAdapter.class.isAnnotationPresent(Service.class),
+ "取数入口必须是可注入的 Bean");
+ assertTrue(RoutingTestDataObservationAdapter.class.isAnnotationPresent(Primary.class),
+ "来源实例自己也实现该契约时必须显式首选路由入口,否则注入出现两个候选");
+ }
+
+ @Test
+ void developmentSimulatorAlsoExposesTheContractWhichIsWhyPreferenceIsNeeded()
+ {
+ assertTrue(TestDataObservationAdapter.class
+ .isAssignableFrom(com.battery.device.simulator.BtsDevelopmentSimulator.class));
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingTestDataObservationAdapterTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingTestDataObservationAdapterTest.java
new file mode 100644
index 0000000..bf25dfc
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/RoutingTestDataObservationAdapterTest.java
@@ -0,0 +1,163 @@
+package com.battery.business.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import com.battery.device.api.DeviceAdapter;
+import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.model.DetectedChannel;
+import com.battery.device.api.model.RealtimeSnapshot;
+import com.battery.device.api.registry.AdapterInstanceConfig;
+import com.battery.device.api.registry.AdapterRegistryException;
+import com.battery.device.api.registry.DeviceAdapterRegistry;
+
+/**
+ * 多来源实例:每个通道的取数请求只送到它所属的实例;实例缺失时明确失败,不回退到其他实例。
+ */
+class RoutingTestDataObservationAdapterTest
+{
+ @Test
+ void sendsEachChannelRequestToTheInstanceNamedByItsAdapterCode()
+ {
+ DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of());
+ try
+ {
+ RoutingTestDataObservationAdapter router = new RoutingTestDataObservationAdapter(registry);
+ RecordingAdapter first = new RecordingAdapter("neware76", 1L);
+ RecordingAdapter second = new RecordingAdapter("neware80", 2L);
+ registry.register(config("neware76"), first);
+ registry.register(config("neware80"), second);
+
+ assertEquals(1L, router.latestCabinet(channel("neware76")).sequenceId());
+ assertEquals(2L, router.latestCabinet(channel("neware80")).sequenceId());
+ assertEquals(List.of("neware76"), first.calls());
+ assertEquals(List.of("neware80"), second.calls());
+ assertEquals(60000L, router.persistenceIntervalMillis(channel("neware76")));
+ assertEquals(70000L, router.persistenceIntervalMillis(channel("neware80")));
+ }
+ finally
+ {
+ registry.close();
+ }
+ }
+
+ @Test
+ void missingInstanceFailsExplicitlyInsteadOfFallingBackToAnotherInstance()
+ {
+ DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of());
+ try
+ {
+ RoutingTestDataObservationAdapter router = new RoutingTestDataObservationAdapter(registry);
+ RecordingAdapter only = new RecordingAdapter("neware76", 1L);
+ registry.register(config("neware76"), only);
+
+ // 实例缺失:明确失败,且没有落到已注册的实例上。
+ assertThrows(AdapterRegistryException.class, () -> router.latestCabinet(channel("neware80")));
+ assertThrows(AdapterRegistryException.class, () -> router.samplesSince(channel("neware80"), null));
+ assertThrows(AdapterRegistryException.class, () -> router.currentWatermark(channel("neware80")));
+ assertTrue(only.calls().isEmpty(), "缺失实例的请求不得落到其他实例");
+ }
+ finally
+ {
+ registry.close();
+ }
+ }
+
+ @Test
+ void aggregatedAdapterDoesNotPretendToDiscoverDevices()
+ {
+ DeviceAdapterRegistry registry = new DeviceAdapterRegistry(List.of());
+ try
+ {
+ RoutingTestDataObservationAdapter router = new RoutingTestDataObservationAdapter(registry);
+ assertThrows(UnsupportedOperationException.class, router::discoverChannels);
+ assertEquals(RoutingTestDataObservationAdapter.ADAPTER_CODE, router.adapterCode());
+ assertFalse(router.capabilities().control());
+ }
+ finally
+ {
+ registry.close();
+ }
+ }
+
+ private static ChannelIdentity channel(String adapterCode)
+ {
+ return new ChannelIdentity(adapterCode, 21, 36, 1, 2);
+ }
+
+ private static AdapterInstanceConfig config(String adapterCode)
+ {
+ return new AdapterInstanceConfig(adapterCode, "NEWARE_MYSQL", "127.0.0.1", 3306, 60000,
+ "Asia/Hong_Kong", true, AdapterCapabilities.observeOnly());
+ }
+
+ /** 记录被调用次数的实例替身;返回的序号用来区分请求落到了哪一个实例。 */
+ private static final class RecordingAdapter implements DeviceAdapter, TestDataObservationAdapter
+ {
+ private final String code;
+ private final long sequenceId;
+ private final List<String> calls = new ArrayList<>();
+
+ private RecordingAdapter(String code, long sequenceId)
+ {
+ this.code = code;
+ this.sequenceId = sequenceId;
+ }
+
+ private List<String> calls()
+ {
+ return List.copyOf(calls);
+ }
+
+ @Override public String adapterCode() { return code; }
+ @Override public AdapterCapabilities capabilities() { return AdapterCapabilities.observeOnly(); }
+ @Override public List<DetectedChannel> discoverChannels() { return List.of(); }
+ @Override public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels)
+ {
+ return Map.of();
+ }
+
+ @Override public CabinetRealtime latestCabinet(ChannelIdentity channel)
+ {
+ calls.add(code);
+ return new CabinetRealtime(sequenceId, Instant.EPOCH, 0, 0, 0, 0, 0, 0, null, null, Instant.EPOCH,
+ null, "Asia/Hong_Kong", null);
+ }
+
+ @Override public CollectorRealtime latestCollector(String adapterCode, String collectorCode)
+ {
+ return null;
+ }
+
+ @Override public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); }
+ @Override public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); }
+
+ @Override public long persistenceIntervalMillis(ChannelIdentity channel)
+ {
+ calls.add(code);
+ return "neware76".equals(code) ? 60000L : 70000L;
+ }
+
+ @Override public SampleWatermark currentWatermark(ChannelIdentity channel)
+ {
+ calls.add(code);
+ return new SampleWatermark(Instant.EPOCH, 1L);
+ }
+
+ @Override public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark)
+ {
+ calls.add(code);
+ return List.of();
+ }
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
index c9ad4a5..c216676 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TTestLiveServiceImplTest.java
@@ -66,7 +66,7 @@
for (Instant deviceTime : List.of(now.minusSeconds(1), now)) {
var measurement = new TestDataObservationAdapter.CollectorMeasurement("S-01", 1L, deviceTime, deviceTime.toString(),
now, "TEMPERATURE", new BigDecimal("38"), "℃");
- when(adapter.latestCollector("collector-7")).thenReturn(new TestDataObservationAdapter.CollectorRealtime(
+ when(adapter.latestCollector(org.mockito.ArgumentMatchers.nullable(String.class), org.mockito.ArgumentMatchers.eq("collector-7"))).thenReturn(new TestDataObservationAdapter.CollectorRealtime(
"collector-7", true, 1000, List.of(measurement), null));
assertEquals(deviceTime.equals(now) ? 1 : 0, service.current(10L).exceptions().size());
}
@@ -391,7 +391,7 @@
when(simulator.testPlan(any(ChannelIdentity.class))).thenReturn(plan);
if (mapper != null && test.getBatteries().get(0).getCollectorId() != null) {
when(mapper.selectCollectorCode(7L)).thenReturn("collector-7");
- when(simulator.latestCollector("collector-7")).thenReturn(
+ when(simulator.latestCollector(org.mockito.ArgumentMatchers.nullable(String.class), org.mockito.ArgumentMatchers.eq("collector-7"))).thenReturn(
new TestDataObservationAdapter.CollectorRealtime("collector-7", online, 1000, List.of(), null));
}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TestDataReceptionServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TestDataReceptionServiceImplTest.java
index 540bb5b..c6eb367 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TestDataReceptionServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TestDataReceptionServiceImplTest.java
@@ -59,13 +59,13 @@
Clock clock = mock(Clock.class);
when(clock.instant()).thenReturn(CAPTURED_AT);
var service = new TestDataReceptionServiceImpl(fixture.mapper, fixture.tests, fixture.simulator, clock);
- when(fixture.simulator.latestCollector("collector-9")).thenReturn(collectorSnapshot(true,
+ when(fixture.simulator.latestCollector(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq("collector-9"))).thenReturn(collectorSnapshot(true,
List.of(collector("S-01", 10L, "temperature", "25.1", "℃"))));
when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
service.captureReceivedData();
when(clock.instant()).thenReturn(CAPTURED_AT.plusSeconds(5));
- when(fixture.simulator.latestCollector("collector-9")).thenReturn(collectorSnapshot(true,
+ when(fixture.simulator.latestCollector(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq("collector-9"))).thenReturn(collectorSnapshot(true,
List.of(collector("S-01", 11L, "temperature", "38", "℃"))));
service.captureReceivedData();
@@ -87,7 +87,7 @@
test.getBatteries().get(0).setCollectorChannels(List.of(
mapping("PRIMARY_TEMPERATURE", "S-01"), mapping("SECONDARY_TEMPERATURE_2", "S-03"),
mapping("SECONDARY_TEMPERATURE_3", "S-05")));
- when(fixture.simulator.latestCollector("collector-9")).thenReturn(collectorSnapshot(true, List.of(
+ when(fixture.simulator.latestCollector(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq("collector-9"))).thenReturn(collectorSnapshot(true, List.of(
collector("S-01", 10L, "temperature", "38", "℃"),
collector("S-03", 10L, "temperature", "25.3", "℃"),
collector("S-05", 10L, "temperature", "25.5", "℃"))));
@@ -125,7 +125,7 @@
{
Fixture fixture = fixture();
when(fixture.simulator.latestCabinet(any(ChannelIdentity.class))).thenReturn(cabinetRecord());
- when(fixture.simulator.latestCollector("collector-9")).thenReturn(collectorSnapshot(true, List.of(
+ when(fixture.simulator.latestCollector(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq("collector-9"))).thenReturn(collectorSnapshot(true, List.of(
collector("S-01", 10L, "TEMPERATURE", "25.100", "℃"),
collector("S-02", 10L, "POSITIVE_POTENTIAL", "1.620", "V"),
collector("S-03", 10L, "NEGATIVE_POTENTIAL", "1.610", "V"),
@@ -159,7 +159,7 @@
{
Fixture fixture = fixture();
when(fixture.simulator.latestCabinet(any(ChannelIdentity.class))).thenThrow(new IllegalArgumentException("offline"));
- when(fixture.simulator.latestCollector("collector-9")).thenReturn(collectorSnapshot(true,
+ when(fixture.simulator.latestCollector(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq("collector-9"))).thenReturn(collectorSnapshot(true,
List.of(collector("S-01", 11L, "TEMPERATURE", "26.2", "℃"))));
when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
@@ -216,7 +216,7 @@
{
Fixture fixture = fixture();
when(fixture.simulator.latestCabinet(any(ChannelIdentity.class))).thenReturn(cabinetRecord());
- when(fixture.simulator.latestCollector("collector-9")).thenReturn(collectorSnapshot(false, List.of()));
+ when(fixture.simulator.latestCollector(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq("collector-9"))).thenReturn(collectorSnapshot(false, List.of()));
when(fixture.mapper.insertRealtimeRecord(any())).thenReturn(1);
fixture.service().captureReceivedData();
@@ -235,7 +235,7 @@
{
Fixture fixture = fixture();
when(fixture.simulator.latestCabinet(any(ChannelIdentity.class))).thenReturn(cabinetRecord());
- when(fixture.simulator.latestCollector("collector-9")).thenReturn(collectorSnapshot(false, List.of()));
+ when(fixture.simulator.latestCollector(org.mockito.ArgumentMatchers.anyString(), org.mockito.ArgumentMatchers.eq("collector-9"))).thenReturn(collectorSnapshot(false, List.of()));
when(fixture.mapper.selectRealtimeRecordContentHash(eq(1L), eq(4L), any(Date.class)))
.thenReturn("different-content");
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/VendorSampleReceptionServiceTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/VendorSampleReceptionServiceTest.java
new file mode 100644
index 0000000..8f54989
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/VendorSampleReceptionServiceTest.java
@@ -0,0 +1,261 @@
+package com.battery.business.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.ZoneId;
+import java.time.ZoneOffset;
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import com.battery.device.api.TestDataObservationAdapter;
+import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime;
+import com.battery.device.api.TestDataObservationAdapter.CollectorRealtime;
+import com.battery.device.api.TestDataObservationAdapter.CycleRecord;
+import com.battery.device.api.TestDataObservationAdapter.SampleWatermark;
+import com.battery.device.api.TestDataObservationAdapter.StepRecord;
+import com.battery.device.api.model.AdapterCapabilities;
+import com.battery.device.api.model.ChannelIdentity;
+import com.battery.device.api.model.DetectedChannel;
+import com.battery.device.api.model.DeviceObservation;
+import com.battery.business.domain.TTest;
+import com.battery.business.domain.TTestBattery;
+import com.battery.business.domain.TTestChannelBinding;
+import com.battery.business.domain.TTestRealtimeRecord;
+import com.battery.business.mapper.TTestMapper;
+import com.battery.business.service.ITTestService;
+
+/**
+ * 逐行接收的归属边界:水位由"开始接收"与"继续接收"这一刻来源里的最新一行决定,
+ * 只取严格晚于水位的数据行;同秒多行都保留;重复同内容幂等;同身份不同内容不覆盖。
+ */
+class VendorSampleReceptionServiceTest
+{
+ private static final Instant START = Instant.parse("2026-09-28T02:00:00Z");
+ private static final ChannelIdentity CHANNEL = new ChannelIdentity("neware76", 21, 36, 1, 2);
+ private static final String IDENTITY = "neware76:21:36:1:2";
+
+ @Test
+ void firstPollReadsTheWatermarkWithoutWritingDeviceHistory()
+ {
+ Fixture fixture = fixture();
+ fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
+
+ fixture.service.captureReceivedData();
+
+ assertEquals(1, fixture.adapter.watermarkReads);
+ assertTrue(fixture.adapter.sampleReads.isEmpty());
+ verify(fixture.mapper, never()).insertRealtimeRecord(any());
+ }
+
+ @Test
+ void everyRowStrictlyAfterTheWatermarkBecomesATestFactIncludingSameSecondRows()
+ {
+ Fixture fixture = fixture();
+ fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
+ fixture.service.captureReceivedData();
+
+ // 同秒两行都落库:槽位留空,来源行身份把它们区分开。
+ fixture.adapter.samples = List.of(sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"),
+ sample(7438L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"));
+ fixture.clock.advance(Duration.ofSeconds(60));
+ fixture.service.captureReceivedData();
+
+ ArgumentCaptor<TTestRealtimeRecord> rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+ verify(fixture.mapper, times(2)).insertRealtimeRecord(rows.capture());
+ assertEquals(List.of("neware:440:7437", "neware:440:7438"),
+ rows.getAllValues().stream().map(TTestRealtimeRecord::getSourceRecordId).toList());
+ assertTrue(rows.getAllValues().stream().allMatch(row -> row.getCaptureSlotUtc() == null));
+ assertEquals(Instant.parse("2026-09-28T01:55:56Z"),
+ rows.getAllValues().get(0).getCabinetDeviceTimeUtc().toInstant());
+ assertEquals("2026-09-28 09:55:56", rows.getAllValues().get(0).getCabinetDeviceTimeRaw());
+ assertEquals(IDENTITY, rows.getAllValues().get(0).getSourceIdentity());
+ }
+
+ @Test
+ void resumingReceptionReadsANewWatermarkSoThePauseGapIsNotBackfilled()
+ {
+ Fixture fixture = fixture();
+ fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
+ fixture.service.captureReceivedData();
+
+ fixture.adapter.samples = List.of(sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56"));
+ fixture.clock.advance(Duration.ofSeconds(60));
+ fixture.service.captureReceivedData();
+ assertEquals(List.of(new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L)),
+ fixture.adapter.sampleReads);
+
+ // 暂停后继续:接收边界推进,水位重新读取;暂停期间产生的行不再进本测试。
+ fixture.test.setReceptionBoundaryAtUtc(Date.from(START.plusSeconds(600)));
+ fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T02:05:56Z"), 7500L);
+ fixture.adapter.samples = List.of();
+ fixture.clock.advance(Duration.ofSeconds(60));
+ fixture.service.captureReceivedData();
+
+ assertEquals(2, fixture.adapter.watermarkReads);
+ // 取到新水位后,只追加新水位之后的行;暂停期间的空档不会被补回来。
+ fixture.clock.advance(Duration.ofSeconds(60));
+ fixture.service.captureReceivedData();
+ assertEquals(List.of(new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L),
+ new SampleWatermark(Instant.parse("2026-09-28T02:05:56Z"), 7500L)), fixture.adapter.sampleReads);
+ }
+
+ @Test
+ void repeatedIdenticalRowIsIgnoredAndConflictingContentIsNotOverwritten()
+ {
+ Fixture fixture = fixture();
+ fixture.adapter.watermark = new SampleWatermark(Instant.parse("2026-09-28T01:54:56Z"), 7436L);
+ fixture.service.captureReceivedData();
+
+ CabinetRealtime sample = sample(7437L, "2026-09-28T01:55:56Z", "2026-09-28 09:55:56");
+ fixture.adapter.samples = List.of(sample);
+ fixture.clock.advance(Duration.ofSeconds(60));
+ fixture.service.captureReceivedData();
+ ArgumentCaptor<TTestRealtimeRecord> first = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+ verify(fixture.mapper).insertRealtimeRecord(first.capture());
+
+ // 同身份同内容:幂等忽略,不再插入。
+ when(fixture.mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437"))
+ .thenReturn(HexFormat.of().formatHex(first.getValue().getContentSha256()));
+ fixture.adapter.watermark = new SampleWatermark(sample.deviceTime(), sample.sequenceId());
+ fixture.clock.advance(Duration.ofSeconds(60));
+ fixture.service.captureReceivedData();
+ verify(fixture.mapper, times(1)).insertRealtimeRecord(any());
+
+ // 同身份不同内容:保留原事实,拒绝覆盖。
+ when(fixture.mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437"))
+ .thenReturn("0000000000000000000000000000000000000000000000000000000000000000");
+ fixture.clock.advance(Duration.ofSeconds(60));
+ fixture.service.captureReceivedData();
+ verify(fixture.mapper, times(1)).insertRealtimeRecord(any());
+ }
+
+ @Test
+ void sourceWithoutWatermarkKeepsThePollingSlotPath()
+ {
+ Fixture fixture = fixture();
+ // 不提供水位的来源(例如开发模拟器)继续按轮询槽位保存柜端事实。
+ fixture.adapter.watermark = null;
+ fixture.adapter.latest = sample(9L, "2026-09-28T02:00:30Z", "2026-09-28 10:00:30");
+
+ fixture.service.captureReceivedData();
+
+ assertEquals(1, fixture.adapter.watermarkReads);
+ assertTrue(fixture.adapter.sampleReads.isEmpty());
+ ArgumentCaptor<TTestRealtimeRecord> rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class);
+ verify(fixture.mapper).insertRealtimeRecord(rows.capture());
+ assertTrue(rows.getValue().getCaptureSlotUtc() != null);
+ }
+
+ private static CabinetRealtime sample(long sequenceId, String deviceTime, String raw)
+ {
+ return new CabinetRealtime(sequenceId, Instant.parse(deviceTime), -1.1889, 1.3733, 1.398611, 0.030648,
+ 3, 12, null, "2", Instant.parse("2026-09-28T02:01:00Z"), raw, "Asia/Hong_Kong", 300L,
+ "neware:440:" + sequenceId);
+ }
+
+ private static Fixture fixture()
+ {
+ TTestMapper mapper = mock(TTestMapper.class);
+ ITTestService tests = mock(ITTestService.class);
+ AdaptiveAdapter adapter = new AdaptiveAdapter();
+ TTest test = receivingTest();
+ when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L));
+ when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test);
+ when(mapper.selectCollectorCode(9L)).thenReturn("collector-9");
+ when(tests.selectTestById(1L)).thenReturn(test);
+ TestClock clock = new TestClock(START);
+ return new Fixture(mapper, tests, adapter, test, clock,
+ new TestDataReceptionServiceImpl(mapper, tests, adapter, clock));
+ }
+
+ private static TTest receivingTest()
+ {
+ TTestChannelBinding binding = new TTestChannelBinding();
+ binding.setBindingId(2L);
+ binding.setChannelId(3L);
+ binding.setAdapterCode("neware76");
+ binding.setAdapterTimezoneId("Asia/Hong_Kong");
+ binding.setDevtype(21);
+ binding.setDevid(36);
+ binding.setSubdevid(1);
+ binding.setChlid(2);
+ TTestBattery battery = new TTestBattery();
+ battery.setTestBatteryId(4L);
+ battery.setCollectorId(9L);
+ battery.setBinding(binding);
+ battery.setCollectorChannels(List.of());
+ TTest test = new TTest();
+ test.setTestId(1L);
+ test.setTestStatus("RUNNING");
+ test.setReceptionStatus("RECEIVING");
+ test.setStartedAtUtc(Date.from(START));
+ test.setReceptionBoundaryAtUtc(Date.from(START));
+ test.setBatteries(List.of(battery));
+ return test;
+ }
+
+ private record Fixture(TTestMapper mapper, ITTestService tests, AdaptiveAdapter adapter, TTest test,
+ TestClock clock, TestDataReceptionServiceImpl service) { }
+
+ /** 只暴露水位与逐行读取的测试替身,并记录被问到的水位与逐行读取次数。 */
+ private static final class AdaptiveAdapter implements TestDataObservationAdapter
+ {
+ private SampleWatermark watermark;
+ private List<CabinetRealtime> samples = new ArrayList<>();
+ private CabinetRealtime latest;
+ private int watermarkReads;
+ private final List<SampleWatermark> sampleReads = new ArrayList<>();
+
+ @Override public String adapterCode() { return "neware76"; }
+ @Override public AdapterCapabilities capabilities() { return AdapterCapabilities.observeOnly(); }
+ @Override public List<DetectedChannel> discoverChannels() { return List.of(); }
+ @Override public Map<ChannelIdentity, DeviceObservation> observe(Set<ChannelIdentity> channels)
+ { return Map.of(); }
+ @Override public CabinetRealtime latestCabinet(ChannelIdentity channel) { return latest; }
+ @Override public CollectorRealtime latestCollector(String adapterCode, String collectorCode) { return null; }
+ @Override public List<StepRecord> stepRecords(ChannelIdentity channel) { return List.of(); }
+ @Override public List<CycleRecord> cycleRecords(ChannelIdentity channel) { return List.of(); }
+ @Override public long persistenceIntervalMillis(ChannelIdentity channel) { return 60000L; }
+
+ @Override public SampleWatermark currentWatermark(ChannelIdentity channel)
+ {
+ watermarkReads++;
+ return watermark;
+ }
+
+ @Override public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark since)
+ {
+ sampleReads.add(since);
+ return List.copyOf(samples);
+ }
+ }
+
+ /** 可推进的测试时钟:接收节奏与厂商写库节奏都按它推进。 */
+ private static final class TestClock extends Clock
+ {
+ private Instant now;
+
+ private TestClock(Instant now) { this.now = now; }
+
+ private void advance(Duration duration) { now = now.plus(duration); }
+
+ @Override public ZoneId getZone() { return ZoneOffset.UTC; }
+ @Override public Clock withZone(ZoneId zone) { return this; }
+ @Override public Instant instant() { return now; }
+ }
+}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/web/VendorSamplePersistenceDatabaseTest.java b/ruoyi-admin/src/test/java/com/battery/business/web/VendorSamplePersistenceDatabaseTest.java
new file mode 100644
index 0000000..d32e525
--- /dev/null
+++ b/ruoyi-admin/src/test/java/com/battery/business/web/VendorSamplePersistenceDatabaseTest.java
@@ -0,0 +1,156 @@
+package com.battery.business.web;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.math.BigDecimal;
+import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.time.Instant;
+import java.util.Date;
+import java.util.HexFormat;
+import java.util.Map;
+import java.util.UUID;
+import org.apache.ibatis.datasource.unpooled.UnpooledDataSource;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mybatis.spring.SqlSessionFactoryBean;
+import org.mybatis.spring.SqlSessionTemplate;
+import org.springframework.jdbc.core.JdbcTemplate;
+import com.battery.business.domain.TTestRealtimeRecord;
+import com.battery.business.mapper.TTestMapper;
+
+/**
+ * 逐行接收的落库语义(生产 Mapper + H2 内存库):
+ * 设备数据行不占系统轮询槽位,所以同一绝对秒内的多行可以各自成立;
+ * 来源行身份(厂商测试编号 + 协议序号)承担幂等与冲突判定。
+ */
+public class VendorSamplePersistenceDatabaseTest
+{
+ private static final String IDENTITY = "neware76:21:36:1:2";
+
+ private java.sql.Connection keepAlive;
+ private JdbcTemplate jdbc;
+ private TTestMapper mapper;
+
+ @BeforeEach void setUp() throws Exception
+ {
+ var source = new UnpooledDataSource("org.h2.Driver",
+ "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", "");
+ keepAlive = source.getConnection();
+ jdbc = new JdbcTemplate(source);
+ // H2 无 MySQL 的 HEX();测试中补一个等价别名,生产 SQL 仍在 MySQL 上验证。
+ jdbc.execute("create alias HEX for 'com.battery.business.web.VendorSamplePersistenceDatabaseTest.hexOf'");
+ jdbc.execute("create table t_test_realtime_record(record_id bigint auto_increment primary key,"
+ + "test_id bigint, test_battery_id bigint, binding_id bigint, adapter_code varchar(64),"
+ + "source_identity varchar(256) not null, source_record_id varchar(128),"
+ + "collector_id bigint, collector_channel_code varchar(100), collector_sequence_id bigint,"
+ + "collector_device_time_utc timestamp(3), collector_device_time_raw varchar(64),"
+ + "collector_adapter_timezone_id varchar(64),"
+ + "cabinet_devtype int, cabinet_devid int, cabinet_subdevid int, cabinet_chlid int,"
+ + "cabinet_sequence_id bigint, cabinet_device_time_utc timestamp(3), cabinet_device_time_raw varchar(64),"
+ + "cabinet_adapter_timezone_id varchar(64), cycle_no int, step_no int, step_type varchar(64),"
+ + "step_name varchar(191), voltage_v decimal(30,12), current_a decimal(30,12),"
+ + "capacity_ah decimal(30,12), energy_wh decimal(30,12), temperature decimal(30,12),"
+ + "positive_potential decimal(30,12), negative_potential decimal(30,12),"
+ + "extension_measurements_json varchar(2000), capture_slot_utc timestamp(3) null, "
+ + "server_received_at_utc timestamp(3) not null, content_sha256 binary(32) not null,"
+ // 轮询槽位唯一键允许多个空值:逐行接收因此可以把同一秒的多行都保存下来。
+ + "unique(test_id, test_battery_id, capture_slot_utc))");
+
+ SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
+ factory.setDataSource(source);
+ factory.setTypeAliasesPackage("com.battery.business.domain");
+ factory.setMapperLocations(new org.springframework.core.io.ClassPathResource("mapper/battery/TTestMapper.xml"));
+ mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TTestMapper.class);
+ }
+
+ @AfterEach void closeDatabase() throws Exception { if (keepAlive != null) keepAlive.close(); }
+
+ @Test void sameSecondRowsAreKeptSeparatelyAndRepeatIsRecognisedBySourceRowIdentity()
+ {
+ TTestRealtimeRecord first = row(7437L, "2026-09-28 09:55:56", "2026-09-28T01:55:56Z");
+ assertEquals(1, mapper.insertRealtimeRecord(first));
+
+ // 同一绝对秒的第二行:槽位为空,唯一键不冲突,两行都成立。
+ TTestRealtimeRecord second = row(7438L, "2026-09-28 09:55:56", "2026-09-28T01:55:56Z");
+ assertEquals(1, mapper.insertRealtimeRecord(second));
+
+ assertEquals(2, jdbc.queryForObject("select count(*) from t_test_realtime_record", Integer.class));
+ // 幂等与冲突按来源行身份判定:命中同身份即视为重复,由调用方比较内容摘要。
+ assertEquals(hex(first.getContentSha256()),
+ mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7437"));
+ assertNull(mapper.selectRealtimeRecordHashBySourceRecord(4L, IDENTITY, "neware:440:7439"));
+ assertTrue(!hex(first.getContentSha256()).equals(hex(second.getContentSha256())));
+ }
+
+ @Test void deviceRowKeepsVendorRawTimeServerReceiveTimeAndNoPollingSlot()
+ {
+ mapper.insertRealtimeRecord(row(7437L, "2026-09-28 09:55:56", "2026-09-28T01:55:56Z"));
+
+ Map<String, Object> stored = jdbc.queryForMap("select source_record_id, source_identity,"
+ + " cabinet_device_time_raw, cabinet_device_time_utc, server_received_at_utc, capture_slot_utc,"
+ + " cabinet_adapter_timezone_id, extension_measurements_json, voltage_v"
+ + " from t_test_realtime_record");
+ assertEquals("neware:440:7437", stored.get("source_record_id"));
+ assertEquals(IDENTITY, stored.get("source_identity"));
+ // 设备时间按适配器时区解释后以 UTC 保存,同时保留原始时间文本与服务器接收时间。
+ assertEquals("2026-09-28 09:55:56", stored.get("cabinet_device_time_raw"));
+ assertEquals("Asia/Hong_Kong", stored.get("cabinet_adapter_timezone_id"));
+ assertEquals(Instant.parse("2026-09-28T01:55:56Z"),
+ ((java.sql.Timestamp) stored.get("cabinet_device_time_utc")).toInstant());
+ assertEquals(Instant.parse("2026-09-28T02:01:00Z"),
+ ((java.sql.Timestamp) stored.get("server_received_at_utc")).toInstant());
+ assertNull(stored.get("capture_slot_utc"));
+ // H2 的 JSON 列会把 JSON 文本再引一层,这里只断言内容确实保存下来。
+ assertTrue(String.valueOf(stored.get("extension_measurements_json")).contains("relativeTimeSeconds"));
+ assertEquals(0, new BigDecimal("1.3733").compareTo((BigDecimal) stored.get("voltage_v")));
+ }
+
+ /** 供 H2 别名使用的 HEX 等价实现;H2 没有 MySQL 的 HEX()。 */
+ public static String hexOf(byte[] value)
+ {
+ return value == null ? null : HexFormat.of().formatHex(value);
+ }
+
+ private TTestRealtimeRecord row(long sequenceId, String raw, String deviceTimeUtc)
+ {
+ TTestRealtimeRecord row = new TTestRealtimeRecord();
+ row.setTestId(1L); row.setTestBatteryId(4L); row.setBindingId(2L); row.setAdapterCode("neware76");
+ row.setSourceIdentity(IDENTITY); row.setSourceRecordId("neware:440:" + sequenceId);
+ row.setCabinetDevtype(21); row.setCabinetDevid(36); row.setCabinetSubdevid(1); row.setCabinetChlid(2);
+ row.setCabinetSequenceId(sequenceId);
+ row.setCabinetDeviceTimeUtc(Date.from(Instant.parse(deviceTimeUtc)));
+ row.setCabinetDeviceTimeRaw(raw);
+ row.setCabinetAdapterTimezoneId("Asia/Hong_Kong");
+ row.setCaptureSlotUtc(null);
+ row.setServerReceivedAtUtc(Date.from(Instant.parse("2026-09-28T02:01:00Z")));
+ row.setCycleNo(3); row.setStepNo(12); row.setStepType("2");
+ row.setVoltageV(new BigDecimal("1.3733")); row.setCurrentA(new BigDecimal("-1.1889"));
+ row.setCapacityAh(new BigDecimal("1.398612")); row.setEnergyWh(new BigDecimal("0.030648"));
+ row.setExtensionMeasurementsJson("{\"relativeTimeSeconds\":300}");
+ // 内容摘要把协议序号算进去,使同秒不同行的内容彼此不同。
+ row.setContentSha256(sha256("neware:440:" + sequenceId));
+ return row;
+ }
+
+ private static String hex(byte[] value)
+ {
+ return HexFormat.of().formatHex(value);
+ }
+
+ private static byte[] sha256(String value)
+ {
+ try
+ {
+ return MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+ }
+ catch (Exception exception)
+ {
+ throw new IllegalStateException(exception);
+ }
+ }
+
+}
--
Gitblit v1.7.1