From a04222c873e5e55f48c8d33b5c0bc46fa524ac8d Mon Sep 17 00:00:00 2001
From: yeg <1347747024@qq.com>
Date: 星期四, 08 十月 2026 13:20:39 +0800
Subject: [PATCH] 界面显示号改为来源自报,并给出无歧义的来源定位
---
battery-business/src/main/java/com/battery/business/domain/TDeviceIngestRecord.java | 4
battery-business/src/main/java/com/battery/business/mapper/TDetectionCabinetMapper.java | 7 +
battery-business/src/main/java/com/battery/business/service/impl/TDetectionCabinetServiceImpl.java | 17 +++
battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java | 7 +
battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java | 17 +++
battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java | 9 +
battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java | 19 +++
ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java | 30 ++++++
battery-device-api/src/main/java/com/battery/device/api/model/DetectedChannel.java | 19 +++
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java | 3
scripts/j22-collector.ps1 | 12 ++
battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml | 37 ++++++-
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java | 7
battery-business/src/main/java/com/battery/business/domain/vo/TDeviceIngestChannel.java | 5 +
battery-business/src/main/java/com/battery/business/service/impl/DeviceIngestServiceImpl.java | 9 +
battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java | 6 +
battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java | 10 +
ruoyi-admin/src/main/resources/db/migration-final/V25__ingest_vendor_channel_no.sql | 5 +
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java | 2
battery-business/src/main/resources/mapper/battery/TDetectionCabinetMapper.xml | 1
ruoyi-admin/src/test/java/com/battery/business/service/impl/TDetectionCabinetServiceImplTest.java | 20 ++++
21 files changed, 218 insertions(+), 28 deletions(-)
diff --git a/battery-business/src/main/java/com/battery/business/domain/TDeviceIngestRecord.java b/battery-business/src/main/java/com/battery/business/domain/TDeviceIngestRecord.java
index b770c53..245dbe6 100644
--- a/battery-business/src/main/java/com/battery/business/domain/TDeviceIngestRecord.java
+++ b/battery-business/src/main/java/com/battery/business/domain/TDeviceIngestRecord.java
@@ -34,6 +34,8 @@
private Integer subdevid;
/** 厂商通道号。 */
private Integer chlid;
+ /** 来源自己的通道叫法(它软件里显示的通道号);只用于展示与登记,不参与通道身份。 */
+ private Integer vendorChannelNo;
/** 来源协议序号,工步切换点可在同一绝对秒内出现多条。 */
private Long recordSequence;
/** 来源行身份,重复推送由它幂等忽略。 */
@@ -78,6 +80,8 @@
public Integer getDevid() { return devid; } public void setDevid(Integer v) { devid = v; }
public Integer getSubdevid() { return subdevid; } public void setSubdevid(Integer v) { subdevid = v; }
public Integer getChlid() { return chlid; } public void setChlid(Integer v) { chlid = v; }
+ public Integer getVendorChannelNo() { return vendorChannelNo; }
+ public void setVendorChannelNo(Integer v) { vendorChannelNo = v; }
public Long getRecordSequence() { return recordSequence; } public void setRecordSequence(Long v) { recordSequence = v; }
public String getRecordKey() { return recordKey; } public void setRecordKey(String v) { recordKey = v; }
public String getSourceRef() { return sourceRef; } public void setSourceRef(String v) { sourceRef = v; }
diff --git a/battery-business/src/main/java/com/battery/business/domain/vo/TDeviceIngestChannel.java b/battery-business/src/main/java/com/battery/business/domain/vo/TDeviceIngestChannel.java
index 27d0f04..93177d2 100644
--- a/battery-business/src/main/java/com/battery/business/domain/vo/TDeviceIngestChannel.java
+++ b/battery-business/src/main/java/com/battery/business/domain/vo/TDeviceIngestChannel.java
@@ -6,6 +6,7 @@
/**
* 推式来源拓扑上报里的一条通道,附带它在落地表里最近的采样行时间。
* 最近数据时间是一个观测事实,不是"在用"判定:登记哪些通道由操作员决定(ADR-0063)。
+ * {@code vendorChannelNo} 是来源自己软件里显示的通道号,只用于界面与登记显示,不参与通道身份。
*/
public class TDeviceIngestChannel implements Serializable
{
@@ -17,6 +18,8 @@
private Integer subdevid;
/** 厂商通道号。 */
private Integer chlid;
+ /** 来源自己软件里显示的通道号(来源未提供时为空)。 */
+ private Integer vendorChannelNo;
/** 该通道在落地表里最近一条采样行的时间;只作为观测事实,不判定通道是否在用。 */
private Date lastDataAt;
@@ -24,5 +27,7 @@
public Integer getDevid() { return devid; } public void setDevid(Integer v) { devid = v; }
public Integer getSubdevid() { return subdevid; } public void setSubdevid(Integer v) { subdevid = v; }
public Integer getChlid() { return chlid; } public void setChlid(Integer v) { chlid = v; }
+ public Integer getVendorChannelNo() { return vendorChannelNo; }
+ public void setVendorChannelNo(Integer v) { vendorChannelNo = v; }
public Date getLastDataAt() { return lastDataAt; } public void setLastDataAt(Date v) { lastDataAt = v; }
}
diff --git a/battery-business/src/main/java/com/battery/business/mapper/TDetectionCabinetMapper.java b/battery-business/src/main/java/com/battery/business/mapper/TDetectionCabinetMapper.java
index 50d0a21..bbcbc2d 100644
--- a/battery-business/src/main/java/com/battery/business/mapper/TDetectionCabinetMapper.java
+++ b/battery-business/src/main/java/com/battery/business/mapper/TDetectionCabinetMapper.java
@@ -67,10 +67,15 @@
@Param("onlineStatus") String onlineStatus, @Param("lastSeenAtUtc") java.time.Instant lastSeenAtUtc,
@Param("updateLastSeen") boolean updateLastSeen);
- /** 按检测柜通道复合身份同步最近发现事实,不触碰软删除记录。 */
+ /**
+ * 按检测柜通道复合身份同步最近发现事实,不触碰软删除记录。
+ * {@code displayNo} 是来源自己报的通道叫法:只在该来源报了叫法时覆盖显示号
+ * (已登记通道因此会跟着来源刷新),为空则保持原样。
+ */
int updateRegisteredChannelObservation(@Param("adapterCode") String adapterCode,
@Param("devtype") int devtype, @Param("devid") int devid,
@Param("subdevid") int subdevid, @Param("chlid") int chlid,
+ @Param("displayNo") String displayNo,
@Param("onlineStatus") String onlineStatus, @Param("workStatus") String workStatus,
@Param("lastSeenAtUtc") java.time.Instant lastSeenAtUtc, @Param("updateLastSeen") boolean updateLastSeen);
diff --git a/battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java b/battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java
index 22a8a3d..c4c6745 100644
--- a/battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java
+++ b/battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java
@@ -19,6 +19,13 @@
/** 心跳行按来源唯一,用 upsert 覆盖接收时间:它承载"取数程序还在推"这一条事实。 */
int upsertHeartbeat(TDeviceIngestRecord record);
+ /**
+ * 拓扑行是**声明**而不是设备事实,按来源通道唯一并刷新声明内容:
+ * 重复上报补上来源自己的通道号时不追加新行,也不需要把已声明过的通道清掉重来。
+ * 设备数据行(SAMPLE)仍然只追加、不改写。
+ */
+ int upsertTopology(TDeviceIngestRecord record);
+
/** 返回该来源行已落库的内容摘要(小写十六进制);没有这一行时为空。 */
String selectIngestRecordHashByKey(@Param("adapterCode") String adapterCode,
@Param("payloadKind") String payloadKind, @Param("recordKey") String recordKey);
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 b74920b..242b8d5 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
@@ -182,7 +182,7 @@
value.setDevid(channel.identity().devid());
value.setSubdevid(channel.identity().subdevid());
value.setChlid(channel.identity().chlid());
- value.setDisplayNo(channel.identity().subdevid() + "-" + channel.identity().chlid());
+ value.setDisplayNo(TDetectionCabinetServiceImpl.channelDisplayNo(channel));
value.setOnlineStatus(channel.online() ? "ONLINE" : "OFFLINE");
value.setWorkStatus(channel.workStatus().name());
value.setLastSeenAtUtc(channel.observedAt());
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 1fe72af..ba05f42 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
@@ -80,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, null, channel.lastDataAt())).toList(),
+ DeviceWorkStatus.UNOBSERVABLE, failedAt, null, channel.lastDataAt(),
+ channel.sourceDisplayNo())).toList(),
failedAt, item.expiresAt(), item.lastDataAt()))
.toList());
detectionCabinetMapper.markRegisteredFactsUnobservable(adapterCode);
@@ -154,7 +155,10 @@
DeviceWorkStatus workStatus = snapshot == null || snapshot.workStatus() == null
? DeviceWorkStatus.UNKNOWN : snapshot.workStatus();
detectionCabinetMapper.updateRegisteredChannelObservation(source.adapterCode(), identity.devtype(),
- identity.devid(), identity.subdevid(), identity.chlid(), channel.available() ? "ONLINE" : "OFFLINE",
+ identity.devid(), identity.subdevid(), identity.chlid(),
+ // 来源自己报了叫法就跟着刷新显示号;没报就保持原样,不去改操作员已登记的显示
+ channel.sourceDisplayNo(),
+ channel.available() ? "ONLINE" : "OFFLINE",
workStatus.name(), observedAt, successfulObservation);
if (channel.available() && !successfulObservation)
detectionCabinetMapper.touchRegisteredChannelObservation(source.adapterCode(), identity.devtype(),
@@ -193,7 +197,7 @@
DeviceWorkStatus status = snapshot == null || snapshot.workStatus() == null
? DeviceWorkStatus.UNKNOWN : snapshot.workStatus();
return new ChannelCandidate(item.identity(), item.available(), status, observedAt,
- snapshots.get(item.identity()), item.lastDataAt());
+ snapshots.get(item.identity()), item.lastDataAt(), item.sourceDisplayNo());
}).toList();
Instant lastDataAt = channels.stream().map(DetectedChannel::lastDataAt)
.filter(java.util.Objects::nonNull).max(Instant::compareTo).orElse(null);
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/DeviceIngestServiceImpl.java b/battery-business/src/main/java/com/battery/business/service/impl/DeviceIngestServiceImpl.java
index bb44aeb..78083e4 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/DeviceIngestServiceImpl.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/DeviceIngestServiceImpl.java
@@ -165,6 +165,9 @@
/**
* 拓扑上报:声明该来源当前有哪些通道。行里只有厂商自己的通道号,身份映射在本方法里完成。
* 拓扑是"这个通道还在"的长期声明,重复上报被幂等忽略(不多占空间,也不改写既有行)。
+ *
+ * <p>行里可以额外带 {@code channelNo}:来源自己软件里显示的通道号。
+ * 它只用于界面显示与登记(操作员要拿它跟厂商软件对上),不参与通道身份,所以缺了也不影响接收。
*/
private void storeTopology(String adapterCode, String timezoneId, JSONObject payload, Instant receivedAt,
Counters counters)
@@ -177,9 +180,13 @@
if (channel == null || channel < 1) throw payloadInvalid("第 " + index + " 行的 channel 不合法:" + row);
TDeviceIngestRecord value = base(adapterCode, timezoneId, TDeviceIngestRecord.KIND_TOPOLOGY, channel,
receivedAt);
+ value.setVendorChannelNo(row.containsKey("channelNo") ? row.getInteger("channelNo") : null);
value.setRecordKey("topology:" + channel);
value.setPayloadJson(JSON.toJSONString(row));
- store(value, counters);
+ // 拓扑是声明不是设备事实:重复上报刷新声明内容(例如这次才带上的来源通道号),
+ // 不追加新行、也不按"内容不同即冲突"处理——声明本来就允许被刷新。
+ value.setContentSha256(canonicalHash(value.getPayloadJson()));
+ if (records.upsertTopology(value) > 0) counters.accepted++; else counters.ignored++;
}
}
diff --git a/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java b/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
index 7645d61..f988911 100644
--- a/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
+++ b/battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java
@@ -116,8 +116,12 @@
for (TDeviceIngestChannel channel : reported == null ? List.<TDeviceIngestChannel>of() : reported)
{
Instant lastDataAt = channel.getLastDataAt() == null ? null : channel.getLastDataAt().toInstant();
+ // 来源自己的通道叫法:22 所软件按 01–30 显示,而它库里的表名比它小 1,
+ // 取数程序按现场核对过的规则补回来(见现场清单);没报就留空,界面回退到「单元-通道」。
+ String sourceDisplayNo = channel.getVendorChannelNo() == null ? null
+ : String.valueOf(channel.getVendorChannelNo());
channels.add(new DetectedChannel(identity(channel.getDevid(), channel.getSubdevid(), channel.getChlid()),
- config.host(), true, DetectedDeviceMetadata.empty(), lastDataAt));
+ config.host(), true, DetectedDeviceMetadata.empty(), lastDataAt, sourceDisplayNo));
}
return List.copyOf(channels);
}
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 9c485e1..e875c1f 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
@@ -22,6 +22,7 @@
import com.battery.business.service.ITDetectionCabinetService;
import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore;
import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.CabinetCandidate;
+import com.battery.device.api.discovery.DeviceDiscoveryCandidateStore.ChannelCandidate;
import com.battery.device.api.model.DeviceWorkStatus;
/**
@@ -197,7 +198,7 @@
value.setDevid(channel.identity().devid());
value.setSubdevid(channel.identity().subdevid());
value.setChlid(channel.identity().chlid());
- value.setDisplayNo(channel.identity().subdevid() + "-" + channel.identity().chlid());
+ value.setDisplayNo(channelDisplayNo(channel));
value.setOnlineStatus(channel.online() ? "ONLINE" : "OFFLINE");
value.setWorkStatus(persistedWorkStatus(channel.workStatus(), channel.online()));
value.setLastSeenAtUtc(null);
@@ -209,7 +210,8 @@
candidate.online());
candidate.channels().forEach(channel -> detectionCabinetMapper.updateRegisteredChannelObservation(
candidate.adapterCode(), channel.identity().devtype(), channel.identity().devid(),
- channel.identity().subdevid(), channel.identity().chlid(), channel.online() ? "ONLINE" : "OFFLINE",
+ channel.identity().subdevid(), channel.identity().chlid(), channelDisplayNo(channel),
+ channel.online() ? "ONLINE" : "OFFLINE",
channel.online() ? persistedWorkStatus(channel.workStatus(), true) : "UNOBSERVABLE",
channel.observedAt(), channel.online()));
return rows;
@@ -432,6 +434,17 @@
return adapterCode + "-" + devtype + "-" + devid;
}
+ /**
+ * 通道在界面上的显示号:来源自己有叫法就用它(操作员拿它跟厂商软件对上),
+ * 否则回退到身份里的「单元-通道」。显示号只是展示,不参与通道身份。
+ */
+ static String channelDisplayNo(ChannelCandidate channel)
+ {
+ String declared = channel.sourceDisplayNo();
+ return declared == null || declared.isBlank()
+ ? channel.identity().subdevid() + "-" + channel.identity().chlid() : declared.trim();
+ }
+
/** 将设备观测状态转换为最终数据库允许的通道事实状态。 */
private String persistedWorkStatus(DeviceWorkStatus status, boolean online)
{
diff --git a/battery-business/src/main/resources/mapper/battery/TDetectionCabinetMapper.xml b/battery-business/src/main/resources/mapper/battery/TDetectionCabinetMapper.xml
index 37a69d6..7914a3e 100644
--- a/battery-business/src/main/resources/mapper/battery/TDetectionCabinetMapper.xml
+++ b/battery-business/src/main/resources/mapper/battery/TDetectionCabinetMapper.xml
@@ -138,6 +138,7 @@
when 'RUNNING' then 'RUNNING'
else 'UNOBSERVABLE' end,
update_time = sysdate()
+ <if test="displayNo != null">, display_no = #{displayNo}</if>
<if test="updateLastSeen">, last_seen_at_utc = #{lastSeenAtUtc}</if>
where adapter_code = #{adapterCode}
and devtype = #{devtype} and devid = #{devid}
diff --git a/battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml b/battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml
index b33e572..c305625 100644
--- a/battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml
+++ b/battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml
@@ -10,6 +10,7 @@
<result property="devid" column="devid"/>
<result property="subdevid" column="subdevid"/>
<result property="chlid" column="chlid"/>
+ <result property="vendorChannelNo" column="vendor_channel_no"/>
<result property="recordSequence" column="record_sequence"/>
<result property="recordKey" column="record_key"/>
<result property="sourceRef" column="source_ref"/>
@@ -31,7 +32,8 @@
</resultMap>
<!-- 落地行的显式列名,顺序与建表脚本一致;列集合不随运行期表结构变化。 -->
- <sql id="ingestColumns">ingest_id, adapter_code, payload_kind, devtype, devid, subdevid, chlid, record_sequence, record_key,
+ <sql id="ingestColumns">ingest_id, adapter_code, payload_kind, devtype, devid, subdevid, chlid, vendor_channel_no,
+ record_sequence, record_key,
source_ref, device_time_utc, device_time_raw, timezone_id, cycle_no, step_no, relative_time_seconds,
voltage_v, current_a, charge_capacity_ah, discharge_capacity_ah, charge_energy_wh, discharge_energy_wh,
payload_json, content_sha256, received_at_utc</sql>
@@ -42,6 +44,7 @@
<result property="devid" column="devid"/>
<result property="subdevid" column="subdevid"/>
<result property="chlid" column="chlid"/>
+ <result property="vendorChannelNo" column="vendor_channel_no"/>
<result property="lastDataAt" column="last_data_at"/>
</resultMap>
@@ -49,11 +52,13 @@
<insert id="insertIngestRecord" parameterType="com.battery.business.domain.TDeviceIngestRecord"
useGeneratedKeys="true" keyProperty="ingestId">
insert ignore into t_device_ingest_record
- (adapter_code, payload_kind, devtype, devid, subdevid, chlid, record_sequence, record_key, source_ref,
+ (adapter_code, payload_kind, devtype, devid, subdevid, chlid, vendor_channel_no, record_sequence, record_key,
+ source_ref,
device_time_utc, device_time_raw, timezone_id, cycle_no, step_no, relative_time_seconds,
voltage_v, current_a, charge_capacity_ah, discharge_capacity_ah, charge_energy_wh, discharge_energy_wh,
payload_json, content_sha256, received_at_utc)
- values (#{adapterCode}, #{payloadKind}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{recordSequence},
+ values (#{adapterCode}, #{payloadKind}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{vendorChannelNo},
+ #{recordSequence},
#{recordKey}, #{sourceRef}, #{deviceTimeUtc}, #{deviceTimeRaw}, #{timezoneId}, #{cycleNo}, #{stepNo},
#{relativeTimeSeconds}, #{voltageV}, #{currentA}, #{chargeCapacityAh}, #{dischargeCapacityAh},
#{chargeEnergyWh}, #{dischargeEnergyWh}, cast(#{payloadJson} as json), #{contentSha256}, #{receivedAtUtc})
@@ -62,15 +67,35 @@
<!-- 心跳单独占一行并覆盖接收时间:它只表示"取数程序还在推",不是设备事实,没有保留历史的意义。 -->
<insert id="upsertHeartbeat" parameterType="com.battery.business.domain.TDeviceIngestRecord">
insert into t_device_ingest_record
- (adapter_code, payload_kind, devtype, devid, subdevid, chlid, record_sequence, record_key, source_ref,
+ (adapter_code, payload_kind, devtype, devid, subdevid, chlid, vendor_channel_no, record_sequence, record_key,
+ source_ref,
device_time_utc, device_time_raw, timezone_id, cycle_no, step_no, relative_time_seconds,
voltage_v, current_a, charge_capacity_ah, discharge_capacity_ah, charge_energy_wh, discharge_energy_wh,
payload_json, content_sha256, received_at_utc)
- values (#{adapterCode}, #{payloadKind}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{recordSequence},
+ values (#{adapterCode}, #{payloadKind}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{vendorChannelNo},
+ #{recordSequence},
#{recordKey}, #{sourceRef}, #{deviceTimeUtc}, #{deviceTimeRaw}, #{timezoneId}, #{cycleNo}, #{stepNo},
#{relativeTimeSeconds}, #{voltageV}, #{currentA}, #{chargeCapacityAh}, #{dischargeCapacityAh},
#{chargeEnergyWh}, #{dischargeEnergyWh}, cast(#{payloadJson} as json), #{contentSha256}, #{receivedAtUtc})
on duplicate key update received_at_utc = values(received_at_utc)
+ </insert>
+
+ <!-- 拓扑行是声明不是设备事实:重复上报刷新声明内容(例如来源补上它自己的通道号),不追加新行。 -->
+ <insert id="upsertTopology" parameterType="com.battery.business.domain.TDeviceIngestRecord">
+ insert into t_device_ingest_record
+ (adapter_code, payload_kind, devtype, devid, subdevid, chlid, vendor_channel_no, record_sequence, record_key,
+ source_ref,
+ device_time_utc, device_time_raw, timezone_id, cycle_no, step_no, relative_time_seconds,
+ voltage_v, current_a, charge_capacity_ah, discharge_capacity_ah, charge_energy_wh, discharge_energy_wh,
+ payload_json, content_sha256, received_at_utc)
+ values (#{adapterCode}, #{payloadKind}, #{devtype}, #{devid}, #{subdevid}, #{chlid}, #{vendorChannelNo},
+ #{recordSequence},
+ #{recordKey}, #{sourceRef}, #{deviceTimeUtc}, #{deviceTimeRaw}, #{timezoneId}, #{cycleNo}, #{stepNo},
+ #{relativeTimeSeconds}, #{voltageV}, #{currentA}, #{chargeCapacityAh}, #{dischargeCapacityAh},
+ #{chargeEnergyWh}, #{dischargeEnergyWh}, cast(#{payloadJson} as json), #{contentSha256}, #{receivedAtUtc})
+ on duplicate key update vendor_channel_no = values(vendor_channel_no),
+ payload_json = values(payload_json), content_sha256 = values(content_sha256),
+ received_at_utc = values(received_at_utc)
</insert>
<select id="selectIngestRecordHashByKey" resultType="string">
@@ -86,7 +111,7 @@
<!-- 最近数据时间取该通道自己的采样行:没有新采样行就是没有,不按拓扑上报时间代替。 -->
<select id="selectTopologyChannels" resultMap="TDeviceIngestChannelResultMap">
- select t.devtype, t.devid, t.subdevid, t.chlid,
+ select t.devtype, t.devid, t.subdevid, t.chlid, t.vendor_channel_no,
(select max(prior.device_time_utc) from t_device_ingest_record prior
where prior.adapter_code = t.adapter_code and prior.payload_kind = 'SAMPLE'
and prior.devtype = t.devtype and prior.devid = t.devid
diff --git a/battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java b/battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java
index 8e8923e..1d175f7 100644
--- a/battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java
+++ b/battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java
@@ -32,6 +32,22 @@
return 1;
}
+ /** 拓扑是声明:撞键即刷新声明内容,不追加新行(与生产 SQL 的 upsert 语义一致)。 */
+ @Override
+ public int upsertTopology(TDeviceIngestRecord record)
+ {
+ int index = -1;
+ for (int i = 0; i < rows.size(); i++)
+ if (same(rows.get(i), record)) index = i;
+ if (index < 0)
+ {
+ rows.add(record);
+ return 1;
+ }
+ rows.set(index, record);
+ return 1;
+ }
+
@Override
public String selectIngestRecordHashByKey(String adapterCode, String payloadKind, String recordKey)
{
@@ -59,6 +75,7 @@
TDeviceIngestChannel channel = new TDeviceIngestChannel();
channel.setDevtype(row.getDevtype()); channel.setDevid(row.getDevid());
channel.setSubdevid(row.getSubdevid()); channel.setChlid(row.getChlid());
+ channel.setVendorChannelNo(row.getVendorChannelNo());
channel.setLastDataAt(rows.stream().filter(sample -> isSample(sample, adapterCode, row))
.map(TDeviceIngestRecord::getDeviceTimeUtc).filter(java.util.Objects::nonNull)
.max(Date::compareTo).orElse(null));
diff --git a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
index acee31c..25a2792 100644
--- a/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
+++ b/battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java
@@ -54,7 +54,8 @@
@Test
void topologyPushMakesChannelsCandidatesWithTheirOwnLatestDataTime()
{
- ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26},{\"channel\":18}]}");
+ // 拓扑行里可以带 channelNo:来源自己软件里显示的通道号(现场核对:22 所界面号 = 表名号 + 1)
+ ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26,\"channelNo\":27},{\"channel\":18}]}");
ingest(BATCH);
PushIngestAdapter adapter = adapter(NOW);
@@ -68,9 +69,15 @@
assertTrue(active.available());
// 候选的机器地址取实例配置:登记时会被带进检测柜的 IP 字段。
assertEquals("127.0.0.1", active.serverIp());
+ // 来源自己的通道叫法随候选一起交出(界面与登记用它显示),身份仍是表名里的号。
+ assertEquals("27", active.sourceDisplayNo());
+ assertEquals(26, active.identity().chlid());
// 登记范围由操作员决定:没有采样行的通道照样出现在候选里,只是没有最近数据时间。
assertNull(channels.stream().filter(channel -> channel.identity().chlid() == 18).findFirst()
.orElseThrow().lastDataAt());
+ // 来源没报叫法的通道保持为空,界面回退到「单元-通道」。
+ assertNull(channels.stream().filter(channel -> channel.identity().chlid() == 18).findFirst()
+ .orElseThrow().sourceDisplayNo());
}
/** 心跳缺失或过期即来源不可达:发现路径明确失败,业务侧据此把已登记设备置为离线并撤下旧快照。 */
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 733965b..7bb842c 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
@@ -64,22 +64,33 @@
}
}
- /** 通道候选;{@code lastDataAt} 是该通道来源里最近一次采样行的设备时间。 */
+ /**
+ * 通道候选;{@code lastDataAt} 是该通道来源里最近一次采样行的设备时间。
+ * {@code sourceDisplayNo} 是来源自己的通道叫法(见 {@code DetectedChannel}),
+ * 只用于界面与登记显示,不参与通道身份。
+ */
record ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
- Instant observedAt, RealtimeSnapshot latestSnapshot, Instant lastDataAt)
+ Instant observedAt, RealtimeSnapshot latestSnapshot, Instant lastDataAt, String sourceDisplayNo)
{
/** 兼容仅有状态的旧发现候选。 */
public ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
Instant observedAt)
{
- this(identity, online, workStatus, observedAt, null, null);
+ this(identity, online, workStatus, observedAt, null, null, null);
}
/** 兼容不含最近数据时间的既有候选。 */
public ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
Instant observedAt, RealtimeSnapshot latestSnapshot)
{
- this(identity, online, workStatus, observedAt, latestSnapshot, null);
+ this(identity, online, workStatus, observedAt, latestSnapshot, null, null);
+ }
+
+ /** 兼容不带来源通道叫法的既有候选。 */
+ public ChannelCandidate(ChannelIdentity identity, boolean online, DeviceWorkStatus workStatus,
+ Instant observedAt, RealtimeSnapshot latestSnapshot, Instant lastDataAt)
+ {
+ this(identity, online, workStatus, observedAt, latestSnapshot, lastDataAt, 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 d955ad3..62647b1 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
@@ -6,18 +6,31 @@
* 发现结果中的一个通道。
* {@code lastDataAt} 是来源里最近一次采样行的设备时间;它是"最近是否有新采样行写入"这一条依据的取值,
* 与设备结束时间或通道水位线列无关。来源不提供该事实时保持为空。
+ *
+ * <p>{@code sourceDisplayNo} 是**来源自己的叫法**(通常是它自己软件里显示的通道号,也可能是文本):
+ * 各厂商给通道编号的方式不同(新威按「单元-通道」,22 所按它界面上的 01–30),
+ * 由来源在发现结果里报出来,界面与登记就用它显示,操作员才能拿它跟厂商软件对上。
+ * 来源不提供时保持为空,界面回退到身份里的「单元-通道」。
+ * 它只是展示名,**不参与通道身份**(身份始终是设备的存储事实),所以改它不需要迁移任何数据。
*/
public record DetectedChannel(ChannelIdentity identity, String serverIp, boolean available,
- DetectedDeviceMetadata deviceMetadata, Instant lastDataAt)
+ DetectedDeviceMetadata deviceMetadata, Instant lastDataAt, String sourceDisplayNo)
{
public DetectedChannel(ChannelIdentity identity, String serverIp, boolean available)
{
- this(identity, serverIp, available, DetectedDeviceMetadata.empty(), null);
+ this(identity, serverIp, available, DetectedDeviceMetadata.empty(), null, null);
}
public DetectedChannel(ChannelIdentity identity, String serverIp, boolean available,
DetectedDeviceMetadata deviceMetadata)
{
- this(identity, serverIp, available, deviceMetadata, null);
+ this(identity, serverIp, available, deviceMetadata, null, null);
+ }
+
+ /** 兼容不带来源叫法的既有适配器。 */
+ public DetectedChannel(ChannelIdentity identity, String serverIp, boolean available,
+ DetectedDeviceMetadata deviceMetadata, Instant lastDataAt)
+ {
+ this(identity, serverIp, available, deviceMetadata, lastDataAt, null);
}
}
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 d6b3231..ac32c87 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
@@ -149,9 +149,10 @@
identity.getIntValue("devtype"), identity.getIntValue("devid"),
identity.getIntValue("subdevid"), identity.getIntValue("chlid"));
RealtimeSnapshot latestSnapshot = object.getObject("latestSnapshot", RealtimeSnapshot.class);
+ // sourceDisplayNo 用宽容读法:升级前写进 Redis 的候选没有这个键,读成空即可,不该报错。
return new ChannelCandidate(channelIdentity, object.getBooleanValue("online"),
DeviceWorkStatus.valueOf(object.getString("workStatus")), requiredInstant(object, "observedAt"),
- latestSnapshot, optionalInstant(object, "lastDataAt"));
+ latestSnapshot, optionalInstant(object, "lastDataAt"), object.getString("sourceDisplayNo"));
}
private CollectorCandidate collectorCandidate(Object value)
diff --git a/ruoyi-admin/src/main/resources/db/migration-final/V25__ingest_vendor_channel_no.sql b/ruoyi-admin/src/main/resources/db/migration-final/V25__ingest_vendor_channel_no.sql
new file mode 100644
index 0000000..18ab7e1
--- /dev/null
+++ b/ruoyi-admin/src/main/resources/db/migration-final/V25__ingest_vendor_channel_no.sql
@@ -0,0 +1,5 @@
+-- 来源自己的通道叫法:来源在拓扑上报里可以带上它软件里显示的通道号/名称,
+-- 界面与登记用它显示,操作员才能拿它跟厂商软件对上(ADR-0065 补记)。
+-- 只加一个可空列,不写入任何数据:不填的来源保持为空,界面回退到「单元-通道」。
+-- 它与通道身份无关——身份仍是设备的存储事实,所以这一列改了不需要迁移任何数据。
+ALTER TABLE t_device_ingest_record ADD COLUMN vendor_channel_no INT NULL AFTER chlid;
diff --git a/ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java b/ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java
index 8199694..fe58e3f 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java
@@ -35,6 +35,7 @@
public class PushIngestLandingMigrationContractTest
{
private static final String PATH = "db/migration-final/V24__push_ingest_landing_table.sql";
+ private static final String VENDOR_CHANNEL_NO_PATH = "db/migration-final/V25__ingest_vendor_channel_no.sql";
private static final String ADAPTER = "j22";
private static final Instant RECEIVED_AT = Instant.parse("2026-09-27T09:20:00Z");
@@ -50,7 +51,9 @@
jdbc = new JdbcTemplate(source);
// H2 无 MySQL 的 HEX();测试中补一个等价别名,生产 SQL 仍在 MySQL 上验证。
jdbc.execute("create alias HEX for 'com.battery.business.runtime.PushIngestLandingMigrationContractTest.hexOf'");
+ // 按顺序跑落地表相关的迁移,使内存库结构与生产库一致(V24 建表、V25 加来源通道号列)。
ScriptUtils.executeSqlScript(keepAlive, new ClassPathResource(PATH));
+ ScriptUtils.executeSqlScript(keepAlive, new ClassPathResource(VENDOR_CHANNEL_NO_PATH));
SqlSessionFactoryBean factory = new SqlSessionFactoryBean();
factory.setDataSource(source);
@@ -78,6 +81,33 @@
"select count(*) from t_device_ingest_record where voltage_v is null", Integer.class));
}
+ /**
+ * 来源自己的通道叫法那条迁移:只加一个可空列,不写入任何数据;
+ * 老行读出来是空(界面回退到「单元-通道」),新行可以带上来源软件里显示的通道号。
+ */
+ @Test
+ void vendorChannelNoMigrationOnlyAddsANullableColumn()
+ {
+ String migration = resource(VENDOR_CHANNEL_NO_PATH);
+ assertTrue(migration.contains("ADD COLUMN vendor_channel_no INT NULL"));
+ assertFalse(migration.matches("(?s).*(INSERT|UPDATE|DELETE|DROP)\\b.*"));
+
+ jdbc.update("insert into t_device_ingest_record(adapter_code, payload_kind, record_key, payload_json,"
+ + " content_sha256, received_at_utc) values(?,?,?,?,?,?)", ADAPTER, "TOPOLOGY", "topology:26",
+ "{\"channel\":26}", new byte[32], Timestamp.from(RECEIVED_AT));
+ // 迁移在 setUp 里已按顺序执行过(V24 建表、V25 加列),这里只核对列的行为。
+ assertNull(jdbc.queryForObject(
+ "select vendor_channel_no from t_device_ingest_record where record_key = 'topology:26'",
+ Integer.class));
+ jdbc.update("update t_device_ingest_record set vendor_channel_no = ? where record_key = ?", 27,
+ "topology:26");
+ assertEquals(27, jdbc.queryForObject(
+ "select vendor_channel_no from t_device_ingest_record where record_key = 'topology:26'",
+ Integer.class));
+ // 展示列不参与身份:唯一键仍只由来源行身份决定。
+ assertEquals(1, jdbc.queryForObject("select count(*) from t_device_ingest_record", Integer.class));
+ }
+
@Test
void repeatedPushIsIgnoredBySourceRowIdentityAndDifferentContentIsKept()
{
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java
index 2705a10..87f578d 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java
@@ -6,6 +6,7 @@
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
@@ -211,7 +212,7 @@
var channel = candidateStore.snapshot(Instant.now()).cabinets().get(0).channels().get(0);
assertEquals(DeviceWorkStatus.WORKING, channel.workStatus());
verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(2),
- eq(3), eq("ONLINE"), eq("WORKING"), any(), eq(true));
+ eq(3), isNull(), eq("ONLINE"), eq("WORKING"), any(), eq(true));
verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1),
eq("ONLINE"), any(), eq(true));
}
@@ -232,7 +233,7 @@
assertEquals(false, cabinet.online());
assertTrue(cabinet.channels().stream().noneMatch(channel -> channel.online()));
verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(1),
- eq(1), eq("OFFLINE"), eq("UNKNOWN"), any(), eq(false));
+ eq(1), isNull(), eq("OFFLINE"), eq("UNKNOWN"), any(), eq(false));
verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1),
eq("OFFLINE"), any(), eq(false));
}
@@ -249,7 +250,7 @@
service.discover(7L);
verify(detectionCabinetMapper).updateRegisteredChannelObservation(eq("lab-bts"), eq(22), eq(1), eq(1),
- eq(1), eq("ONLINE"), eq("UNKNOWN"), any(), eq(false));
+ eq(1), isNull(), eq("ONLINE"), eq("UNKNOWN"), any(), eq(false));
verify(detectionCabinetMapper).updateRegisteredCabinetObservation(eq("lab-bts"), eq(22), eq(1),
eq("ONLINE"), any(), eq(false));
}
diff --git a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TDetectionCabinetServiceImplTest.java b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TDetectionCabinetServiceImplTest.java
index 209a3dc..77ff911 100644
--- a/ruoyi-admin/src/test/java/com/battery/business/service/impl/TDetectionCabinetServiceImplTest.java
+++ b/ruoyi-admin/src/test/java/com/battery/business/service/impl/TDetectionCabinetServiceImplTest.java
@@ -193,6 +193,26 @@
return mapperXml.substring(start, end);
}
+ /**
+ * 通道显示号:来源自己报了叫法就用它(操作员拿它跟厂商软件对上),没报就回退到身份里的「单元-通道」。
+ * 显示号只是展示,不参与通道身份。
+ */
+ @Test
+ void channelDisplayNoPrefersTheSourceOwnLabelAndFallsBackToTheIdentity()
+ {
+ ChannelCandidate declared = new ChannelCandidate(new ChannelIdentity("j22", 0, 0, 1, 26), true,
+ DeviceWorkStatus.UNKNOWN, java.time.Instant.now(), null, null, "27");
+ assertEquals("27", TDetectionCabinetServiceImpl.channelDisplayNo(declared));
+
+ ChannelCandidate blank = new ChannelCandidate(new ChannelIdentity("j22", 0, 0, 1, 26), true,
+ DeviceWorkStatus.UNKNOWN, java.time.Instant.now(), null, null, " ");
+ assertEquals("1-26", TDetectionCabinetServiceImpl.channelDisplayNo(blank));
+
+ ChannelCandidate absent = new ChannelCandidate(new ChannelIdentity("neware76", 21, 3, 1, 4), true,
+ DeviceWorkStatus.UNKNOWN, java.time.Instant.now());
+ assertEquals("1-4", TDetectionCabinetServiceImpl.channelDisplayNo(absent));
+ }
+
private CabinetCandidate candidate(String deviceCode, boolean online, DeviceWorkStatus workStatus)
{
java.time.Instant observedAt = java.time.Instant.now();
diff --git a/scripts/j22-collector.ps1 b/scripts/j22-collector.ps1
index daa3d5e..2fc07d6 100644
--- a/scripts/j22-collector.ps1
+++ b/scripts/j22-collector.ps1
@@ -50,6 +50,13 @@
[int]$ConnectTimeoutSec = 10,
[int]$CommandTimeoutSec = 15,
[int]$HttpTimeoutSec = 30,
+ # 通道号偏移:把厂商自己软件里显示的通道号报给后端,让界面与登记能跟厂商软件对上。
+ # 现场核对过:甲方软件按 01–30 编号,而它库里的表名 Ws<N>_IV_0 比它小 1。
+ # 依据是四组记录互证(软件里的通道号 ↔ 它的库名 ↔ 我们的表名):
+ # 02 ↔ DB_20260921_165803 ↔ Ws1 ;03 ↔ DB_20260927_172118 ↔ Ws2
+ # 25 ↔ DB_20260927_155145 ↔ Ws24 ;28 ↔ DB_20261002_174234 ↔ Ws27
+ # 它只是展示名(不参与通道身份),所以若现场发现哪个通道对不上,改这个值即可,不用改代码、不影响数据。
+ [int]$ChannelNoOffset = 1,
# 下面两个留空即在正文里解析成"脚本同目录";不要写成 param 默认值:
# Windows PowerShell 的参数默认值不在脚本作用域里求值,$PSScriptRoot 在那里是空的(现场实测踩到)。
[string]$StateFile,
@@ -216,7 +223,10 @@
function Send-Topology($channels) {
if ($channels.Count -eq 0) { return $true }
$rows = @()
- foreach ($channel in $channels) { $rows += [pscustomobject]@{ channel = $channel } }
+ foreach ($channel in $channels) {
+ # channel 是身份(表名里的号);channelNo 是厂商软件里显示的号,只用于界面显示与登记
+ $rows += [pscustomobject]@{ channel = $channel; channelNo = ($channel + $ChannelNoOffset) }
+ }
$payload = [pscustomobject]@{ kind = 'TOPOLOGY'; rows = $rows } | ConvertTo-Json -Depth 4 -Compress
return Send-Payload $payload
}
--
Gitblit v1.7.1