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