界面显示号改为来源自报,并给出无歧义的来源定位
现场对照发现:甲方软件按 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 迁移契约)。
| | |
| | | private Integer subdevid; |
| | | /** 厂商通道号。 */ |
| | | private Integer chlid; |
| | | /** 来源自己的通道叫法(它软件里显示的通道号);只用于展示与登记,不参与通道身份。 */ |
| | | private Integer vendorChannelNo; |
| | | /** 来源协议序号,工步切换点可在同一绝对秒内出现多条。 */ |
| | | private Long recordSequence; |
| | | /** 来源行身份,重复推送由它幂等忽略。 */ |
| | |
| | | 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; } |
| | |
| | | /** |
| | | * 推式来源拓扑上报里的一条通道,附带它在落地表里最近的采样行时间。 |
| | | * 最近数据时间是一个观测事实,不是"在用"判定:登记哪些通道由操作员决定(ADR-0063)。 |
| | | * {@code vendorChannelNo} 是来源自己软件里显示的通道号,只用于界面与登记显示,不参与通道身份。 |
| | | */ |
| | | public class TDeviceIngestChannel implements Serializable |
| | | { |
| | |
| | | private Integer subdevid; |
| | | /** 厂商通道号。 */ |
| | | private Integer chlid; |
| | | /** 来源自己软件里显示的通道号(来源未提供时为空)。 */ |
| | | private Integer vendorChannelNo; |
| | | /** 该通道在落地表里最近一条采样行的时间;只作为观测事实,不判定通道是否在用。 */ |
| | | private Date lastDataAt; |
| | | |
| | |
| | | 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; } |
| | | } |
| | |
| | | @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); |
| | | |
| | |
| | | /** 心跳行按来源唯一,用 upsert 覆盖接收时间:它承载"取数程序还在推"这一条事实。 */ |
| | | int upsertHeartbeat(TDeviceIngestRecord record); |
| | | |
| | | /** |
| | | * 拓扑行是**声明**而不是设备事实,按来源通道唯一并刷新声明内容: |
| | | * 重复上报补上来源自己的通道号时不追加新行,也不需要把已声明过的通道清掉重来。 |
| | | * 设备数据行(SAMPLE)仍然只追加、不改写。 |
| | | */ |
| | | int upsertTopology(TDeviceIngestRecord record); |
| | | |
| | | /** 返回该来源行已落库的内容摘要(小写十六进制);没有这一行时为空。 */ |
| | | String selectIngestRecordHashByKey(@Param("adapterCode") String adapterCode, |
| | | @Param("payloadKind") String payloadKind, @Param("recordKey") String recordKey); |
| | |
| | | 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()); |
| | |
| | | .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); |
| | |
| | | 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(), |
| | |
| | | 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); |
| | |
| | | /** |
| | | * 拓扑上报:声明该来源当前有哪些通道。行里只有厂商自己的通道号,身份映射在本方法里完成。 |
| | | * 拓扑是"这个通道还在"的长期声明,重复上报被幂等忽略(不多占空间,也不改写既有行)。 |
| | | * |
| | | * <p>行里可以额外带 {@code channelNo}:来源自己软件里显示的通道号。 |
| | | * 它只用于界面显示与登记(操作员要拿它跟厂商软件对上),不参与通道身份,所以缺了也不影响接收。 |
| | | */ |
| | | private void storeTopology(String adapterCode, String timezoneId, JSONObject payload, Instant receivedAt, |
| | | Counters counters) |
| | |
| | | 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++; |
| | | } |
| | | } |
| | | |
| | |
| | | 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); |
| | | } |
| | |
| | | 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; |
| | | |
| | | /** |
| | |
| | | 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); |
| | |
| | | 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; |
| | |
| | | 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) |
| | | { |
| | |
| | | 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} |
| | |
| | | <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"/> |
| | |
| | | </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> |
| | |
| | | <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> |
| | | |
| | |
| | | <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}) |
| | |
| | | <!-- 心跳单独占一行并覆盖接收时间:它只表示"取数程序还在推",不是设备事实,没有保留历史的意义。 --> |
| | | <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"> |
| | |
| | | |
| | | <!-- 最近数据时间取该通道自己的采样行:没有新采样行就是没有,不按拓扑上报时间代替。 --> |
| | | <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 |
| | |
| | | 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) |
| | | { |
| | |
| | | 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)); |
| | |
| | | @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); |
| | | |
| | |
| | | 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()); |
| | | } |
| | | |
| | | /** 心跳缺失或过期即来源不可达:发现路径明确失败,业务侧据此把已登记设备置为离线并撤下旧快照。 */ |
| | |
| | | } |
| | | } |
| | | |
| | | /** 通道候选;{@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); |
| | | } |
| | | } |
| | | |
| | |
| | | * 发现结果中的一个通道。 |
| | | * {@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); |
| | | } |
| | | } |
| | |
| | | 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) |
| New file |
| | |
| | | -- 来源自己的通道叫法:来源在拓扑上报里可以带上它软件里显示的通道号/名称, |
| | | -- 界面与登记用它显示,操作员才能拿它跟厂商软件对上(ADR-0065 补记)。 |
| | | -- 只加一个可空列,不写入任何数据:不填的来源保持为空,界面回退到「单元-通道」。 |
| | | -- 它与通道身份无关——身份仍是设备的存储事实,所以这一列改了不需要迁移任何数据。 |
| | | ALTER TABLE t_device_ingest_record ADD COLUMN vendor_channel_no INT NULL AFTER chlid; |
| | |
| | | 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"); |
| | | |
| | |
| | | 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); |
| | |
| | | "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() |
| | | { |
| | |
| | | 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; |
| | |
| | | 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)); |
| | | } |
| | |
| | | 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)); |
| | | } |
| | |
| | | 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)); |
| | | } |
| | |
| | | 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(); |
| | |
| | | [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, |
| | |
| | | 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 |
| | | } |