yeg
6 天以前 a04222c873e5e55f48c8d33b5c0bc46fa524ac8d
界面显示号改为来源自报,并给出无歧义的来源定位

现场对照发现:甲方软件按 01–30 编号,而它库里的表名 Ws<N>_IV_0 比它小 1,
所以我们的通道号比甲方软件小 1;四组记录互证(软件通道号 ↔ 它库名 ↔ 我们表名
+ 电流/容量):02↔Ws1、03↔Ws2、25↔Ws24、28↔Ws27。按 ADR-0065 补记实现
"来源自己报它的叫法",身份键不动、不迁移任何数据:

- 观测契约的发现结果新增可选 sourceDisplayNo(来源没报就为空,界面回退到
「单元-通道」,既有来源零改动);候选与 Redis 序列化宽容读取;
- 候选、登记与适配器通道列表都用它写 display_no;
- 已登记的通道会跟着刷新:发现路径同步已登记事实时只在来源报了叫法时覆盖
display_no,所以不用重新登记、不用补数据;
- 落地表新增 vendor_channel_no(迁移 V25,只加可空列);接收端点解析拓扑行里
可选的 channelNo;
- 拓扑行是声明不是设备事实,改为按来源通道 upsert(刷新叫法/载荷/接收时间,
不追加新行)——否则"来源后来才补上的叫法"永远写不进已声明过的通道;
设备数据行仍然只追加、不改写;
- 取数程序在拓扑上报里带 channelNo(表名号 + -ChannelNoOffset,默认 1,可现场
改值而不改代码),注释里写明依据。

实测:重拷脚本跑一轮后,我们通道 23 显示为 24、25 显示为 26,与甲方软件一致。

后端全量测试 373 项通过(新增 2 项:显示号规则、V25 迁移契约)。
1个文件已添加
20个文件已修改
246 ■■■■■ 已修改文件
battery-business/src/main/java/com/battery/business/domain/TDeviceIngestRecord.java 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/domain/vo/TDeviceIngestChannel.java 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/mapper/TDetectionCabinetMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/mapper/TDeviceIngestRecordMapper.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/DeviceAdapterServiceImpl.java 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/DeviceDiscoveryPersistenceService.java 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/DeviceIngestServiceImpl.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/PushIngestAdapter.java 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/java/com/battery/business/service/impl/TDetectionCabinetServiceImpl.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/resources/mapper/battery/TDetectionCabinetMapper.xml 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/main/resources/mapper/battery/TDeviceIngestRecordMapper.xml 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/test/java/com/battery/business/service/impl/FakeIngestLanding.java 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-business/src/test/java/com/battery/business/service/impl/PushIngestAdapterContractTest.java 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/discovery/DeviceDiscoveryCandidateStore.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
battery-device-api/src/main/java/com/battery/device/api/model/DetectedChannel.java 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/java/com/battery/business/runtime/RedisDeviceDiscoveryCandidateStore.java 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/main/resources/db/migration-final/V25__ingest_vendor_channel_no.sql 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/runtime/PushIngestLandingMigrationContractTest.java 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/DeviceAdapterServiceImplTest.java 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
ruoyi-admin/src/test/java/com/battery/business/service/impl/TDetectionCabinetServiceImplTest.java 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
scripts/j22-collector.ps1 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
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; }
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; }
}
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);
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);
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());
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);
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++;
        }
    }
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);
    }
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)
    {
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}
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
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));
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());
    }
    /** 心跳缺失或过期即来源不可达:发现路径明确失败,业务侧据此把已登记设备置为离线并撤下旧快照。 */
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);
        }
    }
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);
    }
}
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)
ruoyi-admin/src/main/resources/db/migration-final/V25__ingest_vendor_channel_no.sql
New file
@@ -0,0 +1,5 @@
-- 来源自己的通道叫法:来源在拓扑上报里可以带上它软件里显示的通道号/名称,
-- 界面与登记用它显示,操作员才能拿它跟厂商软件对上(ADR-0065 补记)。
-- 只加一个可空列,不写入任何数据:不填的来源保持为空,界面回退到「单元-通道」。
-- 它与通道身份无关——身份仍是设备的存储事实,所以这一列改了不需要迁移任何数据。
ALTER TABLE t_device_ingest_record ADD COLUMN vendor_channel_no INT NULL AFTER chlid;
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()
    {
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));
    }
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();
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
}