实现 22 所推式来源闭环:接收端点、落地表与读落地表的适配器
按 ADR-0065 落地 PUSH_INGEST(22 所那台):只读取数程序把厂商原始行推到
POST /device-ingest/{adapterCode},后端解释后落进落地表 t_device_ingest_record,
适配器再从落地表读回统一观测,与直连来源共用同一契约、接缝形状不改。
- 接收端点:按来源实例区分,请求头携带该来源令牌(与厂商库口令共用
LocalCredentials 的取值顺序,不入库);凭据错、来源未启用、来源类型不对或
载荷不合法一律整批不落库,并按非 2xx 应答——取数程序只在 2xx 后推进水位。
- 落地表:一行 = 一条厂商原始行,按收到的键值原样保存原始行,另存解析出的
身份、设备时间、协议序号与测量列。幂等键为「来源定位 + Data_Point」
(22 所每个测试新建一个库,Data_Point 在每个测试内重新编号);
重复推送幂等忽略,同身份内容不同只报冲突、不覆盖。
- 适配器:候选来自拓扑上报并带各自最近数据时间,机器地址取实例 host
(登记检测柜时带进 IP 字段);心跳缺失或超过容忍窗口时发现明确失败,
沿用既有路径把已登记设备置为离线、撤下旧快照,离线不伪造测量值。
水位按 (设备时间, 协议序号) 只放行严格更晚的行。
- 取数程序 scripts/j22-collector.ps1(中文注释 + UTF-8 with BOM):只读
Windows 认证、固定 SELECT、不开入站端口、不传厂商库凭据;每条命令显式设
超时(连接串与命令都设)、无加锁读与长事务、增量过滤不排序(排序在客户端
做)、收到 2xx 才推进水位。
- 落地行未新增又查不到既有行时明确失败,而不是当作重复忽略:避免
insert ignore 把"转换失败被跳过"变成静默丢数后仍然回 2xx。
后端全量测试 371 项通过(battery-business 18、ruoyi-admin 273 含 4 跳过)。
| New file |
| | |
| | | package com.battery.business.domain; |
| | | |
| | | import java.io.Serializable; |
| | | import java.math.BigDecimal; |
| | | import java.util.Date; |
| | | |
| | | /** |
| | | * 推式来源落地表的一行(ADR-0065):厂商原始行按收到的键值原样保存在 {@code payloadJson}, |
| | | * 同时带上接收端点解析出的身份、时间、协议序号与测量列。 |
| | | * |
| | | * <p>解释只做一遍:厂商列名到这些字段的映射在接收端点里,适配器只把落地行投影成统一观测, |
| | | * 因此适配器里不出现任何厂商列名。{@code recordKey} 是来源行身份,重复推送由它幂等忽略。 |
| | | */ |
| | | public class TDeviceIngestRecord implements Serializable |
| | | { |
| | | /** 载荷类别:拓扑上报,声明该来源当前有哪些通道。 */ |
| | | public static final String KIND_TOPOLOGY = "TOPOLOGY"; |
| | | /** 载荷类别:逐点数据行。 */ |
| | | public static final String KIND_SAMPLE = "SAMPLE"; |
| | | /** 载荷类别:存活心跳,只刷新"取数程序还在推"这一条事实。 */ |
| | | public static final String KIND_HEARTBEAT = "HEARTBEAT"; |
| | | |
| | | private Long ingestId; |
| | | private String adapterCode; |
| | | private String payloadKind; |
| | | private Integer devtype; |
| | | private Integer devid; |
| | | private Integer subdevid; |
| | | private Integer chlid; |
| | | private Long recordSequence; |
| | | private String recordKey; |
| | | private String sourceRef; |
| | | private Date deviceTimeUtc; |
| | | private String deviceTimeRaw; |
| | | private String timezoneId; |
| | | private Integer cycleNo; |
| | | private Integer stepNo; |
| | | private BigDecimal relativeTimeSeconds; |
| | | private BigDecimal voltageV; |
| | | private BigDecimal currentA; |
| | | private BigDecimal chargeCapacityAh; |
| | | private BigDecimal dischargeCapacityAh; |
| | | private BigDecimal chargeEnergyWh; |
| | | private BigDecimal dischargeEnergyWh; |
| | | private String payloadJson; |
| | | private byte[] contentSha256; |
| | | private Date receivedAtUtc; |
| | | |
| | | public Long getIngestId() { return ingestId; } public void setIngestId(Long v) { ingestId = v; } |
| | | public String getAdapterCode() { return adapterCode; } public void setAdapterCode(String v) { adapterCode = v; } |
| | | public String getPayloadKind() { return payloadKind; } public void setPayloadKind(String v) { payloadKind = v; } |
| | | public Integer getDevtype() { return devtype; } public void setDevtype(Integer v) { devtype = v; } |
| | | 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 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; } |
| | | public Date getDeviceTimeUtc() { return deviceTimeUtc; } public void setDeviceTimeUtc(Date v) { deviceTimeUtc = v; } |
| | | public String getDeviceTimeRaw() { return deviceTimeRaw; } public void setDeviceTimeRaw(String v) { deviceTimeRaw = v; } |
| | | public String getTimezoneId() { return timezoneId; } public void setTimezoneId(String v) { timezoneId = v; } |
| | | public Integer getCycleNo() { return cycleNo; } public void setCycleNo(Integer v) { cycleNo = v; } |
| | | public Integer getStepNo() { return stepNo; } public void setStepNo(Integer v) { stepNo = v; } |
| | | public BigDecimal getRelativeTimeSeconds() { return relativeTimeSeconds; } |
| | | public void setRelativeTimeSeconds(BigDecimal v) { relativeTimeSeconds = v; } |
| | | public BigDecimal getVoltageV() { return voltageV; } public void setVoltageV(BigDecimal v) { voltageV = v; } |
| | | public BigDecimal getCurrentA() { return currentA; } public void setCurrentA(BigDecimal v) { currentA = v; } |
| | | public BigDecimal getChargeCapacityAh() { return chargeCapacityAh; } |
| | | public void setChargeCapacityAh(BigDecimal v) { chargeCapacityAh = v; } |
| | | public BigDecimal getDischargeCapacityAh() { return dischargeCapacityAh; } |
| | | public void setDischargeCapacityAh(BigDecimal v) { dischargeCapacityAh = v; } |
| | | public BigDecimal getChargeEnergyWh() { return chargeEnergyWh; } |
| | | public void setChargeEnergyWh(BigDecimal v) { chargeEnergyWh = v; } |
| | | public BigDecimal getDischargeEnergyWh() { return dischargeEnergyWh; } |
| | | public void setDischargeEnergyWh(BigDecimal v) { dischargeEnergyWh = v; } |
| | | public String getPayloadJson() { return payloadJson; } public void setPayloadJson(String v) { payloadJson = v; } |
| | | public byte[] getContentSha256() { return contentSha256; } public void setContentSha256(byte[] v) { contentSha256 = v; } |
| | | public Date getReceivedAtUtc() { return receivedAtUtc; } public void setReceivedAtUtc(Date v) { receivedAtUtc = v; } |
| | | } |
| New file |
| | |
| | | package com.battery.business.domain.vo; |
| | | |
| | | import java.io.Serializable; |
| | | import java.util.Date; |
| | | |
| | | /** |
| | | * 推式来源拓扑上报里的一条通道,附带它在落地表里最近的采样行时间。 |
| | | * 最近数据时间是一个观测事实,不是"在用"判定:登记哪些通道由操作员决定(ADR-0063)。 |
| | | */ |
| | | public class TDeviceIngestChannel implements Serializable |
| | | { |
| | | private Integer devtype; |
| | | private Integer devid; |
| | | private Integer subdevid; |
| | | private Integer chlid; |
| | | private Date lastDataAt; |
| | | |
| | | public Integer getDevtype() { return devtype; } public void setDevtype(Integer v) { devtype = v; } |
| | | 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 Date getLastDataAt() { return lastDataAt; } public void setLastDataAt(Date v) { lastDataAt = v; } |
| | | } |
| New file |
| | |
| | | package com.battery.business.mapper; |
| | | |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | import org.apache.ibatis.annotations.Param; |
| | | import com.battery.business.domain.TDeviceIngestRecord; |
| | | import com.battery.business.domain.vo.TDeviceIngestChannel; |
| | | |
| | | /** |
| | | * 推式来源落地表的读写:接收端点写入厂商原始行,适配器读回统一观测(ADR-0065)。 |
| | | * |
| | | * <p>写入一律用 insert ignore 与来源行身份唯一键:重复推送不改动既有事实, |
| | | * 由调用方比较内容摘要判定"同内容重复"与"同身份不同内容"。 |
| | | */ |
| | | public interface TDeviceIngestRecordMapper |
| | | { |
| | | int insertIngestRecord(TDeviceIngestRecord record); |
| | | |
| | | /** 心跳行按来源唯一,用 upsert 覆盖接收时间:它承载"取数程序还在推"这一条事实。 */ |
| | | int upsertHeartbeat(TDeviceIngestRecord record); |
| | | |
| | | /** 返回该来源行已落库的内容摘要(小写十六进制);没有这一行时为空。 */ |
| | | String selectIngestRecordHashByKey(@Param("adapterCode") String adapterCode, |
| | | @Param("payloadKind") String payloadKind, @Param("recordKey") String recordKey); |
| | | |
| | | /** 该来源最近一次收到推送的时间;从未收到推送时为空。 */ |
| | | Date selectHeartbeatAt(@Param("adapterCode") String adapterCode); |
| | | |
| | | /** 拓扑上报声明的通道清单,每条附带其最近一次采样行的设备时间。 */ |
| | | List<TDeviceIngestChannel> selectTopologyChannels(@Param("adapterCode") String adapterCode); |
| | | |
| | | /** 某通道最近一次采样行;没有采样行时为空。水位与实时观测都取它。 */ |
| | | TDeviceIngestRecord selectLatestSample(@Param("adapterCode") String adapterCode, |
| | | @Param("devtype") int devtype, @Param("devid") int devid, @Param("subdevid") int subdevid, |
| | | @Param("chlid") int chlid); |
| | | |
| | | /** 严格晚于 (设备时间, 协议序号) 的采样行,按二元组升序;水位为空表示不取数据。 */ |
| | | List<TDeviceIngestRecord> selectSamplesSince(@Param("adapterCode") String adapterCode, |
| | | @Param("devtype") int devtype, @Param("devid") int devid, @Param("subdevid") int subdevid, |
| | | @Param("chlid") int chlid, @Param("watermarkTime") Date watermarkTime, |
| | | @Param("watermarkSequence") long watermarkSequence, @Param("limit") int limit); |
| | | } |
| New file |
| | |
| | | package com.battery.business.service; |
| | | |
| | | /** |
| | | * 推式来源接收端点({@code POST /device-ingest/{adapterCode}})使用的稳定业务错误码。 |
| | | * 每个码都对应一个明确的 HTTP 状态,取数程序据此决定是否推进自己的读取位置: |
| | | * 只有 2xx 才算"对方收下了"(ADR-0065)。 |
| | | */ |
| | | public final class DeviceIngestErrorCodes |
| | | { |
| | | /** 来源凭据缺失或不匹配:拒绝并点名取值位置,不落任何数据。 */ |
| | | public static final int TOKEN_INVALID = 53101; |
| | | /** 来源未启用(类型不是推式来源的情形归 {@code DeviceAdapterErrorCodes.UNSUPPORTED_TYPE}):拒绝并说明状态。 */ |
| | | public static final int SOURCE_DISABLED = 53102; |
| | | /** 请求体不符合推式来源的载荷契约,或落地行写不进去:点名出错的行与字段。 */ |
| | | public static final int PAYLOAD_INVALID = 53103; |
| | | |
| | | private DeviceIngestErrorCodes() |
| | | { |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.service; |
| | | |
| | | /** |
| | | * 推式来源的接收端点业务:按来源实例校验凭据,把厂商原始行落进落地表(ADR-0065)。 |
| | | * |
| | | * <p>拒绝一律不留痕:凭据错误、来源未启用或载荷不合法时整批不落库, |
| | | * 取数程序因此不会推进自己的读取位置,重试也不会产生半批数据。 |
| | | */ |
| | | public interface IDeviceIngestService |
| | | { |
| | | /** |
| | | * 接收一批原始行。返回结果表示本批的落库情况,返回即代表"对方已收下"。 |
| | | * |
| | | * @param adapterCode 来源实例编码(端点路径) |
| | | * @param token 该来源的凭据(请求头) |
| | | * @param body 请求体原文:厂商原始行,不做任何换算 |
| | | */ |
| | | IngestResult ingest(String adapterCode, String token, String body); |
| | | |
| | | /** 本批落库结果:新增、幂等忽略与同身份内容冲突的条数。 */ |
| | | record IngestResult(int accepted, int ignored, int conflicts) { } |
| | | } |
| | |
| | | private static final String DISABLED = "1"; |
| | | private static final String READ_ONLY_CAPABILITIES = |
| | | "{\"discovery\":true,\"realtimeObservation\":true,\"historyDownload\":true,\"control\":false}"; |
| | | /** 适配器类型按存储形态划分;BTS 保留给开发模拟器与将来的协议路线(ADR-0065)。 */ |
| | | private static final List<String> SUPPORTED_TYPES = List.of("BTS", "NEWARE_MYSQL"); |
| | | /** 直连厂商存储的来源,其通道登记清单是长期事实,候选不按时间过期。 */ |
| | | private static final List<String> PERSISTENT_CANDIDATE_TYPES = List.of("NEWARE_MYSQL"); |
| | | /** |
| | | * 适配器类型按存储形态划分;BTS 保留给开发模拟器与将来的协议路线(ADR-0065)。 |
| | | * `NEWARE_MYSQL` 只能写字面量:battery-business 不依赖 battery-device-neware, |
| | | * 拿不到那边的常量;推式来源的实现就在本模块,所以用它的常量。 |
| | | */ |
| | | private static final List<String> SUPPORTED_TYPES = List.of("BTS", "NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE); |
| | | /** |
| | | * 通道清单长期存在的来源,候选不按时间过期:直连厂商存储的来源读的是厂商的登记表, |
| | | * 推式来源读的是取数程序上报的拓扑——两者都不是"轮询发现"的结果。 |
| | | */ |
| | | private static final List<String> PERSISTENT_CANDIDATE_TYPES = |
| | | List.of("NEWARE_MYSQL", PushIngestAdapter.ADAPTER_TYPE); |
| | | |
| | | private final TDeviceAdapterMapper mapper; |
| | | private final DeviceAdapterRegistry registry; |
| New file |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.math.RoundingMode; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.security.MessageDigest; |
| | | import java.time.Clock; |
| | | import java.time.Instant; |
| | | import java.util.Date; |
| | | import java.util.HexFormat; |
| | | import java.util.Map; |
| | | import java.util.TreeMap; |
| | | import java.util.function.Function; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.transaction.annotation.Transactional; |
| | | import com.alibaba.fastjson2.JSON; |
| | | import com.alibaba.fastjson2.JSONArray; |
| | | import com.alibaba.fastjson2.JSONObject; |
| | | import com.ruoyi.common.exception.ServiceException; |
| | | import com.ruoyi.common.utils.StringUtils; |
| | | import com.battery.device.api.credentials.LocalCredentials; |
| | | import com.battery.business.domain.TDeviceIngestRecord; |
| | | import com.battery.business.domain.TDeviceAdapter; |
| | | import com.battery.business.mapper.TDeviceAdapterMapper; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | | import com.battery.business.service.DeviceAdapterErrorCodes; |
| | | import com.battery.business.service.DeviceIngestErrorCodes; |
| | | import com.battery.business.service.IDeviceIngestService; |
| | | |
| | | /** |
| | | * 推式来源的接收端点实现(ADR-0065):校验来源凭据,把厂商原始行落进落地表。 |
| | | * |
| | | * <p>这里也是**唯一**解释厂商列名的地方:原始行里有 22 所 IV 表的列({@code record_time}、 |
| | | * {@code Data_Point}、{@code Voltage}、{@code eCurrent} 等),本类把它们映射成落地表的身份、时间、 |
| | | * 协议序号与测量列,同一份原始行原样存进 {@code payload_json}(键值不变,格式由 JSON 列规范化)。适配器因此只做投影、不认识厂商列名。 |
| | | * |
| | | * <p>三条判定口径: |
| | | * <ul> |
| | | * <li><b>凭据不入库</b>:端点令牌与厂商库口令共用同一套取值顺序(本地凭据文件 → 环境变量 → |
| | | * 启动参数),实例配置里只有变量名。令牌缺失或不匹配时拒绝,且不落任何数据。</li> |
| | | * <li><b>时间换算</b>:22 所的 {@code record_time} 是 Windows FILETIME(100 ns 刻度,自 1601-01-01 UTC), |
| | | * 直接换算出 UTC 时刻;原始字符串同时保留。厂商给的是绝对时间,不需要按时区再解释一次。</li> |
| | | * <li><b>幂等与冲突</b>:来源行身份是「库/表 + Data_Point」,重复推送被唯一键幂等忽略; |
| | | * 同身份但内容不同只记警告、不覆盖既有事实。</li> |
| | | * </ul> |
| | | */ |
| | | @Service |
| | | public class DeviceIngestServiceImpl implements IDeviceIngestService |
| | | { |
| | | private static final Logger log = LoggerFactory.getLogger(DeviceIngestServiceImpl.class); |
| | | /** 端点令牌的变量名默认值;实例配置用 {@code tokenEnv} 覆盖它。 */ |
| | | private static final String TOKEN_ENV_OPTION = "tokenEnv"; |
| | | private static final String DEFAULT_TOKEN_ENV = "BATTERY_INGEST_TOKEN"; |
| | | /** 心跳行按来源唯一,键名固定。 */ |
| | | private static final String HEARTBEAT_KEY = "heartbeat"; |
| | | /** 一批的上限:程序按批推,一次推来整表说明配置有问题,直接拒绝而不是把它读进内存。 */ |
| | | private static final int MAX_ROWS_PER_REQUEST = 2000; |
| | | /** Windows FILETIME 的起点(1601-01-01 UTC)与 Unix 纪元之间的秒数差。 */ |
| | | private static final long FILETIME_EPOCH_DIFFERENCE_SECONDS = 11644473600L; |
| | | |
| | | private final TDeviceAdapterMapper adapters; |
| | | private final TDeviceIngestRecordMapper records; |
| | | private final Clock clock; |
| | | /** 端点令牌的取值入口;测试注入固定值,生产走本地凭据文件 → 环境变量 → 启动参数。 */ |
| | | private final Function<String, String> credentials; |
| | | |
| | | @Autowired |
| | | public DeviceIngestServiceImpl(TDeviceAdapterMapper adapters, TDeviceIngestRecordMapper records) |
| | | { |
| | | this(adapters, records, Clock.systemUTC(), LocalCredentials::resolve); |
| | | } |
| | | |
| | | /** 允许测试固定接收时间与凭据取值,生产运行仍使用 UTC 系统时钟与本地凭据文件。 */ |
| | | public DeviceIngestServiceImpl(TDeviceAdapterMapper adapters, TDeviceIngestRecordMapper records, Clock clock, |
| | | Function<String, String> credentials) |
| | | { |
| | | this.adapters = adapters; |
| | | this.records = records; |
| | | this.clock = clock; |
| | | this.credentials = credentials; |
| | | } |
| | | |
| | | @Override |
| | | @Transactional(rollbackFor = Exception.class) |
| | | public IngestResult ingest(String adapterCode, String token, String body) |
| | | { |
| | | TDeviceAdapter adapter = requirePushSource(adapterCode); |
| | | requireToken(adapter, token); |
| | | JSONObject payload = parse(body); |
| | | String kind = payload.getString("kind"); |
| | | Instant receivedAt = clock.instant(); |
| | | Counters counters = new Counters(); |
| | | if (TDeviceIngestRecord.KIND_HEARTBEAT.equals(kind)) |
| | | { |
| | | // 心跳没有行:它只刷新"取数程序还在推"这一条事实。 |
| | | } |
| | | else if (TDeviceIngestRecord.KIND_TOPOLOGY.equals(kind)) |
| | | { |
| | | storeTopology(adapterCode, adapter.getTimezoneId(), payload, receivedAt, counters); |
| | | } |
| | | else if (TDeviceIngestRecord.KIND_SAMPLE.equals(kind)) |
| | | { |
| | | storeSamples(adapterCode, adapter.getTimezoneId(), payload, receivedAt, counters); |
| | | } |
| | | else |
| | | { |
| | | throw payloadInvalid("未知的载荷类别 kind:" + kind); |
| | | } |
| | | // 每一批成功接收都刷新心跳:收到采样行同样是"程序还在推"的证据,缺一次心跳不会误判离线。 |
| | | touchHeartbeat(adapterCode, receivedAt); |
| | | return new IngestResult(counters.accepted, counters.ignored, counters.conflicts); |
| | | } |
| | | |
| | | private TDeviceAdapter requirePushSource(String adapterCode) |
| | | { |
| | | TDeviceAdapter adapter = StringUtils.isBlank(adapterCode) ? null |
| | | : adapters.selectDeviceAdapterByCode(adapterCode); |
| | | // 来源不存在、类型不对与未启用都明确拒绝:这几种情况一律不落数据。 |
| | | if (adapter == null) throw new ServiceException("来源不存在:" + adapterCode, DeviceAdapterErrorCodes.NOT_FOUND); |
| | | if (!PushIngestAdapter.ADAPTER_TYPE.equalsIgnoreCase(String.valueOf(adapter.getAdapterType()))) |
| | | throw new ServiceException("该来源不是推式来源:" + adapterCode, DeviceAdapterErrorCodes.UNSUPPORTED_TYPE); |
| | | if (!"0".equals(adapter.getStatus())) |
| | | throw new ServiceException("来源未启用:" + adapterCode, DeviceIngestErrorCodes.SOURCE_DISABLED); |
| | | return adapter; |
| | | } |
| | | |
| | | /** 端点令牌与厂商库口令共用同一套取值顺序;配置缺失同样按凭据错误拒绝(失败关闭)。 */ |
| | | private void requireToken(TDeviceAdapter adapter, String token) |
| | | { |
| | | String key = option(adapter.getSourceConfig(), TOKEN_ENV_OPTION, DEFAULT_TOKEN_ENV); |
| | | String expected; |
| | | try |
| | | { |
| | | expected = credentials.apply(key); |
| | | } |
| | | catch (RuntimeException failure) |
| | | { |
| | | throw new ServiceException("来源凭据不可用:" + failure.getMessage(), DeviceIngestErrorCodes.TOKEN_INVALID); |
| | | } |
| | | if (StringUtils.isBlank(token) || !MessageDigest.isEqual( |
| | | expected.getBytes(StandardCharsets.UTF_8), token.trim().getBytes(StandardCharsets.UTF_8))) |
| | | throw new ServiceException("来源凭据不匹配:" + adapter.getAdapterCode(), |
| | | DeviceIngestErrorCodes.TOKEN_INVALID); |
| | | } |
| | | |
| | | /** |
| | | * 拓扑上报:声明该来源当前有哪些通道。行里只有厂商自己的通道号,身份映射在本方法里完成。 |
| | | * 拓扑是"这个通道还在"的长期声明,重复上报被幂等忽略(不多占空间,也不改写既有行)。 |
| | | */ |
| | | private void storeTopology(String adapterCode, String timezoneId, JSONObject payload, Instant receivedAt, |
| | | Counters counters) |
| | | { |
| | | JSONArray rows = rows(payload); |
| | | for (int index = 0; index < rows.size(); index++) |
| | | { |
| | | JSONObject row = rowAt(rows, index); |
| | | Integer channel = row.getInteger("channel"); |
| | | if (channel == null || channel < 1) throw payloadInvalid("第 " + index + " 行的 channel 不合法:" + row); |
| | | TDeviceIngestRecord value = base(adapterCode, timezoneId, TDeviceIngestRecord.KIND_TOPOLOGY, channel, |
| | | receivedAt); |
| | | value.setRecordKey("topology:" + channel); |
| | | value.setPayloadJson(JSON.toJSONString(row)); |
| | | store(value, counters); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 逐点数据行:一批来自同一张厂商表(同一测试同一通道),因此身份与来源定位取批次头上的声明。 |
| | | * 行本身原样保存,只额外解析出水位与投影需要的字段。 |
| | | */ |
| | | private void storeSamples(String adapterCode, String timezoneId, JSONObject payload, Instant receivedAt, |
| | | Counters counters) |
| | | { |
| | | Integer channel = payload.getInteger("channel"); |
| | | if (channel == null || channel < 1) throw payloadInvalid("批次缺少合法的 channel:" + payload); |
| | | String sourceRef = sourceRef(payload); |
| | | JSONArray rows = rows(payload); |
| | | for (int index = 0; index < rows.size(); index++) |
| | | { |
| | | JSONObject row = rowAt(rows, index); |
| | | TDeviceIngestRecord value = base(adapterCode, timezoneId, TDeviceIngestRecord.KIND_SAMPLE, channel, |
| | | receivedAt); |
| | | value.setSourceRef(sourceRef); |
| | | value.setRecordSequence(requiredSequence(row)); |
| | | value.setDeviceTimeRaw(requiredText(row, "record_time")); |
| | | value.setDeviceTimeUtc(Date.from(filetime(value.getDeviceTimeRaw()))); |
| | | value.setCycleNo(row.getInteger("Cycle_Index")); |
| | | value.setStepNo(row.getInteger("Step_Index")); |
| | | value.setRelativeTimeSeconds(row.getBigDecimal("Step_Time")); |
| | | value.setVoltageV(row.getBigDecimal("Voltage")); |
| | | value.setCurrentA(row.getBigDecimal("eCurrent")); |
| | | value.setChargeCapacityAh(row.getBigDecimal("ChargeCapacity")); |
| | | value.setDischargeCapacityAh(row.getBigDecimal("DischargeCapacity")); |
| | | value.setChargeEnergyWh(row.getBigDecimal("ChargeEnergy")); |
| | | value.setDischargeEnergyWh(row.getBigDecimal("DischargeEnergy")); |
| | | // 幂等键含来源定位:22 所每个测试新建一个库,Data_Point 在每个测试内重新编号,只看它会让两次测试撞键。 |
| | | value.setRecordKey(sourceRef + ":" + value.getRecordSequence()); |
| | | value.setPayloadJson(JSON.toJSONString(row)); |
| | | store(value, counters); |
| | | } |
| | | } |
| | | |
| | | // ------------------------------------------------------------------ 落地行 |
| | | |
| | | private TDeviceIngestRecord base(String adapterCode, String timezoneId, String kind, int channel, |
| | | Instant receivedAt) |
| | | { |
| | | TDeviceIngestRecord value = new TDeviceIngestRecord(); |
| | | value.setAdapterCode(adapterCode); |
| | | value.setPayloadKind(kind); |
| | | // 身份映射与适配器读落地行时用的是同一组常量:这个形态只在 PushIngestAdapter 里定义一次。 |
| | | value.setDevtype(PushIngestAdapter.DEVICE_TYPE); |
| | | value.setDevid(PushIngestAdapter.DEVICE_ID); |
| | | value.setSubdevid(PushIngestAdapter.SUB_DEVICE_ID); |
| | | value.setChlid(channel); |
| | | value.setRecordSequence(0L); |
| | | // 来源时区是"当时怎么解释这条来源"的一部分,跟着行落库:厂商给的是绝对时间, |
| | | // 换算出的是 UTC,时区只用于展示;实例时区以后改了,历史行仍保留当时的口径。 |
| | | value.setTimezoneId(timezoneId); |
| | | value.setReceivedAtUtc(Date.from(receivedAt)); |
| | | return value; |
| | | } |
| | | |
| | | /** |
| | | * 落一行并用来源行身份判幂等:撞键即视为重复推送,比较内容摘要后区分"同内容重复"与"同身份不同内容"。 |
| | | * 内容不同只记警告、不覆盖——既有那一行是已经收到的事实。 |
| | | */ |
| | | /** |
| | | * 落一行并用来源行身份判幂等:撞键即视为重复推送,比较内容摘要后区分"同内容重复"与"同身份不同内容"。 |
| | | * 内容不同只记警告、不覆盖——既有那一行是已经收到的事实。 |
| | | * |
| | | * <p>落库没有新增、又查不到这一行时**明确失败**,而不是当作重复忽略:`insert ignore` 把 |
| | | * 约束冲突与"转换失败被跳过"都变成"没插入",后者若不报错,取数程序会拿到 2xx 推进水位, |
| | | * 那条设备行就此消失。宁可让这一批重试,也不静默丢数据。 |
| | | */ |
| | | private void store(TDeviceIngestRecord value, Counters counters) |
| | | { |
| | | value.setContentSha256(canonicalHash(value.getPayloadJson())); |
| | | int inserted = records.insertIngestRecord(value); |
| | | if (inserted > 0) |
| | | { |
| | | counters.accepted++; |
| | | return; |
| | | } |
| | | String existing = records.selectIngestRecordHashByKey(value.getAdapterCode(), value.getPayloadKind(), |
| | | value.getRecordKey()); |
| | | if (existing == null) |
| | | throw payloadInvalid("落地行未能写入且查不到既有行,原因不明:" + value.getRecordKey()); |
| | | if (!existing.equalsIgnoreCase(HexFormat.of().formatHex(value.getContentSha256()))) |
| | | { |
| | | counters.conflicts++; |
| | | log.warn("落地记录内容冲突,拒绝覆盖 adapterCode={}, payloadKind={}, recordKey={}", |
| | | value.getAdapterCode(), value.getPayloadKind(), value.getRecordKey()); |
| | | return; |
| | | } |
| | | counters.ignored++; |
| | | } |
| | | |
| | | private void touchHeartbeat(String adapterCode, Instant receivedAt) |
| | | { |
| | | TDeviceIngestRecord heartbeat = base(adapterCode, null, TDeviceIngestRecord.KIND_HEARTBEAT, 0, receivedAt); |
| | | heartbeat.setRecordKey(HEARTBEAT_KEY); |
| | | heartbeat.setPayloadJson("{}"); |
| | | heartbeat.setContentSha256(canonicalHash(heartbeat.getPayloadJson())); |
| | | records.upsertHeartbeat(heartbeat); |
| | | } |
| | | |
| | | /** |
| | | * 内容摘要按规范化后的键值算:程序重复推同一行时字段顺序不同也算同一条, |
| | | * 但任何一个值的改动都会被识别为冲突。 |
| | | */ |
| | | private static byte[] canonicalHash(String payloadJson) |
| | | { |
| | | Map<String, Object> content = new TreeMap<>(JSON.parseObject(payloadJson)); |
| | | try |
| | | { |
| | | return MessageDigest.getInstance("SHA-256").digest(JSON.toJSONBytes(content)); |
| | | } |
| | | catch (Exception exception) |
| | | { |
| | | throw new IllegalStateException("无法计算落地记录内容摘要", exception); |
| | | } |
| | | } |
| | | |
| | | // ------------------------------------------------------------------ 请求解析 |
| | | |
| | | private JSONObject parse(String body) |
| | | { |
| | | if (StringUtils.isBlank(body)) throw payloadInvalid("请求体为空"); |
| | | JSONObject payload; |
| | | try |
| | | { |
| | | payload = JSON.parseObject(body); |
| | | } |
| | | catch (RuntimeException failure) |
| | | { |
| | | throw payloadInvalid("请求体不是合法的 JSON:" + failure.getMessage()); |
| | | } |
| | | if (payload == null) throw payloadInvalid("请求体不是 JSON 对象"); |
| | | return payload; |
| | | } |
| | | |
| | | private JSONArray rows(JSONObject payload) |
| | | { |
| | | JSONArray rows = payload.getJSONArray("rows"); |
| | | if (rows == null) throw payloadInvalid("请求体缺少 rows 数组"); |
| | | if (rows.size() > MAX_ROWS_PER_REQUEST) |
| | | throw payloadInvalid("一批最多 " + MAX_ROWS_PER_REQUEST + " 行,本批 " + rows.size() + " 行"); |
| | | return rows; |
| | | } |
| | | |
| | | private JSONObject rowAt(JSONArray rows, int index) |
| | | { |
| | | JSONObject row = rows.getJSONObject(index); |
| | | if (row == null) throw payloadInvalid("第 " + index + " 行不是 JSON 对象"); |
| | | return row; |
| | | } |
| | | |
| | | /** 来源定位(库/表)取批次头:幂等键需要它,回放与排查也需要它。 */ |
| | | private String sourceRef(JSONObject payload) |
| | | { |
| | | String database = payload.getString("database"); |
| | | String table = payload.getString("table"); |
| | | if (StringUtils.isBlank(database) || StringUtils.isBlank(table)) |
| | | throw payloadInvalid("逐点数据批缺少 database 或 table:无法确定来源行身份"); |
| | | return database.trim() + "/" + table.trim(); |
| | | } |
| | | |
| | | private long requiredSequence(JSONObject row) |
| | | { |
| | | Long sequence = row.getLong("Data_Point"); |
| | | if (sequence == null) throw payloadInvalid("行缺少 Data_Point:" + row); |
| | | return sequence; |
| | | } |
| | | |
| | | private String requiredText(JSONObject row, String key) |
| | | { |
| | | Object value = row.get(key); |
| | | if (value == null) throw payloadInvalid("行缺少 " + key + ":" + row); |
| | | String text = String.valueOf(value).trim(); |
| | | if (text.isEmpty()) throw payloadInvalid("行的 " + key + " 为空:" + row); |
| | | return text; |
| | | } |
| | | |
| | | /** |
| | | * Windows FILETIME(100 ns 刻度,自 1601-01-01 UTC)换算为 UTC 时刻。 |
| | | * 用毫秒精度换算:落地表与实时记录的时间列都是 {@code DATETIME(3)},多的精度存不下。 |
| | | */ |
| | | private static Instant filetime(String ticks) |
| | | { |
| | | BigDecimal value = new BigDecimal(ticks); |
| | | long epochMillis = value.divide(BigDecimal.valueOf(10000), 0, RoundingMode.DOWN).longValue() |
| | | - FILETIME_EPOCH_DIFFERENCE_SECONDS * 1000L; |
| | | return Instant.ofEpochMilli(epochMillis); |
| | | } |
| | | |
| | | private static String option(String sourceConfig, String key, String fallback) |
| | | { |
| | | if (StringUtils.isBlank(sourceConfig)) return fallback; |
| | | try |
| | | { |
| | | JSONObject config = JSON.parseObject(sourceConfig); |
| | | String value = config == null ? null : config.getString(key); |
| | | return StringUtils.isBlank(value) ? fallback : value.trim(); |
| | | } |
| | | catch (RuntimeException ignored) |
| | | { |
| | | return fallback; |
| | | } |
| | | } |
| | | |
| | | private static ServiceException payloadInvalid(String message) |
| | | { |
| | | return new ServiceException(message, DeviceIngestErrorCodes.PAYLOAD_INVALID); |
| | | } |
| | | |
| | | private static final class Counters |
| | | { |
| | | private int accepted; |
| | | private int ignored; |
| | | private int conflicts; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import java.math.BigDecimal; |
| | | import java.time.Clock; |
| | | import java.time.Duration; |
| | | import java.time.Instant; |
| | | import java.util.ArrayList; |
| | | import java.util.HashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.DeviceAdapterException; |
| | | import com.battery.device.api.TestDataObservationAdapter; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DetectedChannel; |
| | | import com.battery.device.api.model.DetectedDeviceMetadata; |
| | | import com.battery.device.api.model.DeviceConnectionStatus; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | | import com.battery.device.api.registry.AdapterInstanceConfig; |
| | | import com.battery.business.domain.TDeviceIngestRecord; |
| | | import com.battery.business.domain.vo.TDeviceIngestChannel; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | | |
| | | /** |
| | | * 推式来源的观测实现(存储形态 {@code PUSH_INGEST},ADR-0065):读本项目的落地表, |
| | | * 交出与直连来源完全相同的统一观测。 |
| | | * |
| | | * <p>这类来源的机器没有可用的网络协议(22 所:TCP 与命名管道都未启用、Windows 账号未获提供), |
| | | * 只能在它本机跑一个只读取数程序,把厂商原始行推到接收端点。程序本身很蠢——固定查询、原样推出、 |
| | | * 不开入站端口、不在网络上传递厂商库凭据;解释(身份映射、时间换算、幂等键取值)全部在后端完成, |
| | | * 所以这里只做投影:落地行的字段由接收端点解析好,本类不出现任何厂商列名。 |
| | | * |
| | | * <p>三条本形态特有的事实: |
| | | * <ul> |
| | | * <li><b>来源是否在线由心跳判定</b>:落地表在本地随时可读,因此"能否读到表"不能表示来源可达。 |
| | | * 取数程序每轮推送一次心跳,心跳缺失即认为来源不可达——发现路径明确失败, |
| | | * 已登记设备随之离线、旧快照被撤下(既有路径),页面不会显示伪造的实时值。</li> |
| | | * <li><b>最近数据时间只看采样行</b>:没有新采样行就是没有,不按拓扑上报或心跳时间代替(ADR-0063)。</li> |
| | | * <li><b>通道工作状态不可观测</b>:落地表里没有设备侧状态,报"无法观测"而不由数据行推断。</li> |
| | | * </ul> |
| | | */ |
| | | public final class PushIngestAdapter implements DeviceAdapter, TestDataObservationAdapter |
| | | { |
| | | /** 适配器类型按存储形态划分:这个形态读本项目的落地表(ADR-0065)。 */ |
| | | public static final String ADAPTER_TYPE = "PUSH_INGEST"; |
| | | /** |
| | | * 身份映射(后端完成):22 所的存储形态没有设备类型与单元维度,一台电脑就是登记意义上的一个柜, |
| | | * 通道号就是厂商自己的通道号(表名 {@code Ws<N>_IV_0} 里的 N)。厂商通道号进 {@code chlid}, |
| | | * {@code subdevid} 固定为 1,使界面通道号显示为「1-26」而不是「0-26」。 |
| | | * 若甲方要求按接口再分成几个柜,那是新增通道→接口映射的新决策,不在本单。 |
| | | * |
| | | * <p>接收端点写落地行时用的是同一组常量({@code DeviceIngestServiceImpl.base}), |
| | | * 使"身份映射只在这个形态里定义一次"成立。 |
| | | */ |
| | | public static final int DEVICE_TYPE = 0; |
| | | public static final int DEVICE_ID = 0; |
| | | public static final int SUB_DEVICE_ID = 1; |
| | | /** |
| | | * 落地表在本地,读它不产生厂商负载,所以按展示节奏取数(控制台 5 秒一轮); |
| | | * 只取水位之后的新行,取数更勤不会产生重复记录。厂商写库节奏仍是 60 秒,值不会来得更快。 |
| | | */ |
| | | private static final long PERSISTENCE_INTERVAL_MS = 5000L; |
| | | private static final int SAMPLE_BATCH_LIMIT = 500; |
| | | /** 心跳容忍窗口的下限:写库与推送都是 60 秒一轮,窗口至少覆盖三轮。 */ |
| | | private static final Duration MINIMUM_STALE_AFTER = Duration.ofSeconds(90); |
| | | |
| | | private final AdapterInstanceConfig config; |
| | | private final TDeviceIngestRecordMapper records; |
| | | private final Clock clock; |
| | | |
| | | public PushIngestAdapter(AdapterInstanceConfig config, TDeviceIngestRecordMapper records) |
| | | { |
| | | this(config, records, Clock.systemUTC()); |
| | | } |
| | | |
| | | public PushIngestAdapter(AdapterInstanceConfig config, TDeviceIngestRecordMapper records, Clock clock) |
| | | { |
| | | this.config = config; |
| | | this.records = records; |
| | | this.clock = clock; |
| | | } |
| | | |
| | | @Override |
| | | public String adapterCode() |
| | | { |
| | | return config.adapterCode(); |
| | | } |
| | | |
| | | @Override |
| | | public AdapterCapabilities capabilities() |
| | | { |
| | | return config.capabilities(); |
| | | } |
| | | |
| | | /** |
| | | * 候选来自取数程序的拓扑上报:每条候选附带落地表里该通道最近的采样行时间。 |
| | | * 不自动判定"在用",登记哪些通道由操作员决定(ADR-0063)。 |
| | | * |
| | | * <p>候选的机器地址取实例配置的 {@code host}:这个形态里它表示**取数程序所在那台机器**, |
| | | * 登记检测柜时会被带进 IP 字段(推送目标地址是取数程序自己的配置项,不在这边)。 |
| | | * |
| | | * <p>心跳缺失时明确失败(而不是返回空清单):来源不可达是一个可见的失败事实, |
| | | * 业务侧据此把已登记设备置为离线并撤下旧快照。 |
| | | */ |
| | | @Override |
| | | public List<DetectedChannel> discoverChannels() |
| | | { |
| | | requireFreshHeartbeat(); |
| | | List<DetectedChannel> channels = new ArrayList<>(); |
| | | List<TDeviceIngestChannel> reported = records.selectTopologyChannels(config.adapterCode()); |
| | | for (TDeviceIngestChannel channel : reported == null ? List.<TDeviceIngestChannel>of() : reported) |
| | | { |
| | | Instant lastDataAt = channel.getLastDataAt() == null ? null : channel.getLastDataAt().toInstant(); |
| | | channels.add(new DetectedChannel(identity(channel.getDevid(), channel.getSubdevid(), channel.getChlid()), |
| | | config.host(), true, DetectedDeviceMetadata.empty(), lastDataAt)); |
| | | } |
| | | return List.copyOf(channels); |
| | | } |
| | | |
| | | /** 实时快照取自该通道最近一次采样行;没有采样行时不返回该通道,避免沿用过期观测。 */ |
| | | @Override |
| | | public Map<ChannelIdentity, RealtimeSnapshot> inquire(Set<ChannelIdentity> channels) |
| | | { |
| | | if (channels == null || channels.isEmpty()) return Map.of(); |
| | | Map<ChannelIdentity, RealtimeSnapshot> result = new HashMap<>(); |
| | | for (ChannelIdentity channel : channels) |
| | | { |
| | | CabinetRealtime latest = latestCabinet(channel); |
| | | if (latest == null) continue; |
| | | result.put(channel, snapshot(channel, latest)); |
| | | } |
| | | return Map.copyOf(result); |
| | | } |
| | | |
| | | @Override |
| | | public CabinetRealtime latestCabinet(ChannelIdentity channel) |
| | | { |
| | | TDeviceIngestRecord row = latestSample(channel); |
| | | return row == null ? null : cabinetRealtime(row); |
| | | } |
| | | |
| | | /** |
| | | * 22 所的 IV 表里有温箱温度、压力与计数器,是现场唯一见到环境量的来源, |
| | | * 但类目语义与量纲尚未确认。按 ADR-0064 逐来源确认可得性,确认之前不交出测量值。 |
| | | */ |
| | | @Override |
| | | public CollectorRealtime latestCollector(String adapterCode, String collectorCode) |
| | | { |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 22 所没有独立的工步层:工步与循环号只在采样行的 {@code Step_Index} / {@code Cycle_Index} 里, |
| | | * 从采样推导工步是另一项决策(与"工步来自设备工步层"的既有口径冲突),不在本单范围,因此返回空。 |
| | | */ |
| | | @Override |
| | | public List<StepRecord> stepRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | } |
| | | |
| | | /** 同上:循环统计不从采样推导,本单不交出循环记录。 */ |
| | | @Override |
| | | public List<CycleRecord> cycleRecords(ChannelIdentity channel) |
| | | { |
| | | return List.of(); |
| | | } |
| | | |
| | | @Override |
| | | public long persistenceIntervalMillis(ChannelIdentity channel) |
| | | { |
| | | return PERSISTENCE_INTERVAL_MS; |
| | | } |
| | | |
| | | /** 水位是落地表里该通道已存在的最新一行:设备绝对时间与协议序号。 */ |
| | | @Override |
| | | public SampleWatermark currentWatermark(ChannelIdentity channel) |
| | | { |
| | | TDeviceIngestRecord row = latestSample(channel); |
| | | return row == null || row.getDeviceTimeUtc() == null |
| | | ? new SampleWatermark(null, 0L) |
| | | : new SampleWatermark(row.getDeviceTimeUtc().toInstant(), row.getRecordSequence()); |
| | | } |
| | | |
| | | /** |
| | | * 严格晚于水位的落地行。按 {@code (设备时间, 协议序号)} 过滤与排序:同一绝对秒内的多行各自成立, |
| | | * 不按秒合并。水位为空表示调用方尚未取得水位,返回空列表而不把来源里的历史并入本次接收。 |
| | | * |
| | | * <p>这里不判心跳:落地行是"我们已经收到并解释过的设备事实", |
| | | * 来源离线只影响发现与实时观测,不影响这些已经收到的行。 |
| | | */ |
| | | @Override |
| | | public List<CabinetRealtime> samplesSince(ChannelIdentity channel, SampleWatermark watermark) |
| | | { |
| | | if (watermark == null) return List.of(); |
| | | ChannelIdentity key = requireOwnChannel(channel); |
| | | java.util.Date watermarkTime = watermark.deviceTime() == null ? null : java.util.Date.from(watermark.deviceTime()); |
| | | List<TDeviceIngestRecord> rows = records.selectSamplesSince(config.adapterCode(), key.devtype(), key.devid(), |
| | | key.subdevid(), key.chlid(), watermarkTime, watermark.sequenceId(), SAMPLE_BATCH_LIMIT); |
| | | List<CabinetRealtime> samples = new ArrayList<>(); |
| | | for (TDeviceIngestRecord row : rows == null ? List.<TDeviceIngestRecord>of() : rows) samples.add(cabinetRealtime(row)); |
| | | return List.copyOf(samples); |
| | | } |
| | | |
| | | // ------------------------------------------------------------------ 投影与判定 |
| | | |
| | | /** 心跳缺失即来源不可达:取数程序停了、或它自己连不上厂商库。 */ |
| | | private void requireFreshHeartbeat() |
| | | { |
| | | java.util.Date receivedAt = records.selectHeartbeatAt(config.adapterCode()); |
| | | if (receivedAt == null) |
| | | throw new DeviceAdapterException("SOURCE_OFFLINE", |
| | | "尚未收到取数程序的推送:请确认程序已启动并指向 " + config.host() + ":" + config.port(), null); |
| | | Duration staleAfter = staleAfter(); |
| | | Duration silent = Duration.between(receivedAt.toInstant(), clock.instant()); |
| | | if (silent.compareTo(staleAfter) > 0) |
| | | throw new DeviceAdapterException("SOURCE_OFFLINE", "取数程序已停止推送:最近一次推送在 " |
| | | + silent.toSeconds() + " 秒前,超过容忍窗口 " + staleAfter.toSeconds() + " 秒", null); |
| | | } |
| | | |
| | | /** 容忍窗口按实例配置的轮询周期算:写库与推送都是这个节奏,连丢三轮即认为离线。 */ |
| | | private Duration staleAfter() |
| | | { |
| | | Duration window = Duration.ofMillis(config.pollIntervalMs()).multipliedBy(3); |
| | | return window.compareTo(MINIMUM_STALE_AFTER) < 0 ? MINIMUM_STALE_AFTER : window; |
| | | } |
| | | |
| | | private TDeviceIngestRecord latestSample(ChannelIdentity channel) |
| | | { |
| | | ChannelIdentity key = requireOwnChannel(channel); |
| | | return records.selectLatestSample(config.adapterCode(), key.devtype(), key.devid(), key.subdevid(), |
| | | key.chlid()); |
| | | } |
| | | |
| | | private ChannelIdentity requireOwnChannel(ChannelIdentity channel) |
| | | { |
| | | if (channel == null || !config.adapterCode().equals(channel.adapterCode())) |
| | | throw new DeviceAdapterException("通道身份不属于本实例:" |
| | | + (channel == null ? "空" : channel.adapterCode())); |
| | | return channel; |
| | | } |
| | | |
| | | private CabinetRealtime cabinetRealtime(TDeviceIngestRecord row) |
| | | { |
| | | // 充放电容量按电流方向取对应的一组:与直连来源同一口径(22 所的电流符号约定待现场对着上位机确认)。 |
| | | BigDecimal current = row.getCurrentA() == null ? BigDecimal.ZERO : row.getCurrentA(); |
| | | boolean charging = current.signum() >= 0; |
| | | BigDecimal capacity = charging ? row.getChargeCapacityAh() : row.getDischargeCapacityAh(); |
| | | BigDecimal energy = charging ? row.getChargeEnergyWh() : row.getDischargeEnergyWh(); |
| | | return new CabinetRealtime(row.getRecordSequence() == null ? 0L : row.getRecordSequence(), |
| | | row.getDeviceTimeUtc() == null ? null : row.getDeviceTimeUtc().toInstant(), |
| | | current.doubleValue(), decimal(row.getVoltageV()), decimal(capacity), decimal(energy), |
| | | row.getCycleNo() == null ? 0 : row.getCycleNo(), row.getStepNo() == null ? 0 : row.getStepNo(), |
| | | null, null, row.getReceivedAtUtc() == null ? null : row.getReceivedAtUtc().toInstant(), |
| | | row.getDeviceTimeRaw(), row.getTimezoneId(), |
| | | row.getRelativeTimeSeconds() == null ? null : row.getRelativeTimeSeconds().longValue(), |
| | | "push:" + row.getRecordKey()); |
| | | } |
| | | |
| | | private RealtimeSnapshot snapshot(ChannelIdentity channel, CabinetRealtime latest) |
| | | { |
| | | // 读落地表路线下通道工作状态不可观测(ADR-0063):不按数据行推断,也不沿用上一次状态。 |
| | | return new RealtimeSnapshot(channel, DeviceWorkStatus.UNOBSERVABLE, latest.cycleNo(), latest.stepNo(), |
| | | latest.stepType(), null, BigDecimal.valueOf(latest.currentA()), BigDecimal.valueOf(latest.voltageV()), |
| | | BigDecimal.valueOf(latest.capacityAh()), BigDecimal.valueOf(latest.energyWh()), null, |
| | | latest.relativeTimeSeconds(), null, latest.serverReceivedAt(), Map.of(), |
| | | DeviceConnectionStatus.ONLINE, latest.deviceTime(), List.of(), null); |
| | | } |
| | | |
| | | private ChannelIdentity identity(Integer devid, Integer subdevid, Integer chlid) |
| | | { |
| | | return new ChannelIdentity(config.adapterCode(), DEVICE_TYPE, |
| | | devid == null ? DEVICE_ID : devid, subdevid == null ? SUB_DEVICE_ID : subdevid, |
| | | chlid == null ? 0 : chlid); |
| | | } |
| | | |
| | | private static double decimal(BigDecimal value) |
| | | { |
| | | return value == null ? 0d : value.doubleValue(); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.registry.AdapterInstanceConfig; |
| | | import com.battery.device.api.registry.DeviceAdapterFactory; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | | |
| | | /** |
| | | * 按存储形态 {@code PUSH_INGEST} 构造读落地表的适配器实例(ADR-0065)。 |
| | | * |
| | | * <p>这个形态没有厂商连接:落地表就在业务库里,读写都走业务数据源与 Mapper, |
| | | * 所以工厂不需要凭据,装配不会因为"忘了放口令"失败。凭据(接收端点令牌)只在接收端点上校验。 |
| | | */ |
| | | public final class PushIngestAdapterFactory implements DeviceAdapterFactory |
| | | { |
| | | private final TDeviceIngestRecordMapper records; |
| | | |
| | | public PushIngestAdapterFactory(TDeviceIngestRecordMapper records) |
| | | { |
| | | this.records = records; |
| | | } |
| | | |
| | | @Override |
| | | public String adapterType() |
| | | { |
| | | return PushIngestAdapter.ADAPTER_TYPE; |
| | | } |
| | | |
| | | @Override |
| | | public DeviceAdapter create(AdapterInstanceConfig config) |
| | | { |
| | | return new PushIngestAdapter(config, records); |
| | | } |
| | | } |
| New file |
| | |
| | | <?xml version="1.0" encoding="UTF-8" ?> |
| | | <!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" |
| | | "http://mybatis.org/dtd/mybatis-3-mapper.dtd"> |
| | | <mapper namespace="com.battery.business.mapper.TDeviceIngestRecordMapper"> |
| | | <resultMap id="TDeviceIngestRecordResultMap" type="com.battery.business.domain.TDeviceIngestRecord"> |
| | | <id property="ingestId" column="ingest_id"/> |
| | | <result property="adapterCode" column="adapter_code"/> |
| | | <result property="payloadKind" column="payload_kind"/> |
| | | <result property="devtype" column="devtype"/> |
| | | <result property="devid" column="devid"/> |
| | | <result property="subdevid" column="subdevid"/> |
| | | <result property="chlid" column="chlid"/> |
| | | <result property="recordSequence" column="record_sequence"/> |
| | | <result property="recordKey" column="record_key"/> |
| | | <result property="sourceRef" column="source_ref"/> |
| | | <result property="deviceTimeUtc" column="device_time_utc"/> |
| | | <result property="deviceTimeRaw" column="device_time_raw"/> |
| | | <result property="timezoneId" column="timezone_id"/> |
| | | <result property="cycleNo" column="cycle_no"/> |
| | | <result property="stepNo" column="step_no"/> |
| | | <result property="relativeTimeSeconds" column="relative_time_seconds"/> |
| | | <result property="voltageV" column="voltage_v"/> |
| | | <result property="currentA" column="current_a"/> |
| | | <result property="chargeCapacityAh" column="charge_capacity_ah"/> |
| | | <result property="dischargeCapacityAh" column="discharge_capacity_ah"/> |
| | | <result property="chargeEnergyWh" column="charge_energy_wh"/> |
| | | <result property="dischargeEnergyWh" column="discharge_energy_wh"/> |
| | | <result property="payloadJson" column="payload_json"/> |
| | | <result property="contentSha256" column="content_sha256"/> |
| | | <result property="receivedAtUtc" column="received_at_utc"/> |
| | | </resultMap> |
| | | |
| | | <!-- 驼峰映射未开启(见 mybatis-config.xml),列名带下划线的查询一律显式映射。 --> |
| | | <resultMap id="TDeviceIngestChannelResultMap" type="com.battery.business.domain.vo.TDeviceIngestChannel"> |
| | | <result property="devtype" column="devtype"/> |
| | | <result property="devid" column="devid"/> |
| | | <result property="subdevid" column="subdevid"/> |
| | | <result property="chlid" column="chlid"/> |
| | | <result property="lastDataAt" column="last_data_at"/> |
| | | </resultMap> |
| | | |
| | | <!-- 落地行只追加:来源行身份(adapter_code + 类别 + record_key)撞键即视为重复推送,不改动既有事实。 --> |
| | | <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, |
| | | 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}, |
| | | #{recordKey}, #{sourceRef}, #{deviceTimeUtc}, #{deviceTimeRaw}, #{timezoneId}, #{cycleNo}, #{stepNo}, |
| | | #{relativeTimeSeconds}, #{voltageV}, #{currentA}, #{chargeCapacityAh}, #{dischargeCapacityAh}, |
| | | #{chargeEnergyWh}, #{dischargeEnergyWh}, cast(#{payloadJson} as json), #{contentSha256}, #{receivedAtUtc}) |
| | | </insert> |
| | | |
| | | <!-- 心跳单独占一行并覆盖接收时间:它只表示"取数程序还在推",不是设备事实,没有保留历史的意义。 --> |
| | | <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, |
| | | 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}, |
| | | #{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> |
| | | |
| | | <select id="selectIngestRecordHashByKey" resultType="string"> |
| | | select lower(hex(content_sha256)) from t_device_ingest_record |
| | | where adapter_code = #{adapterCode} and payload_kind = #{payloadKind} and record_key = #{recordKey} |
| | | limit 1 |
| | | </select> |
| | | |
| | | <select id="selectHeartbeatAt" resultType="java.util.Date"> |
| | | select max(received_at_utc) from t_device_ingest_record |
| | | where adapter_code = #{adapterCode} and payload_kind = 'HEARTBEAT' |
| | | </select> |
| | | |
| | | <!-- 最近数据时间取该通道自己的采样行:没有新采样行就是没有,不按拓扑上报时间代替。 --> |
| | | <select id="selectTopologyChannels" resultMap="TDeviceIngestChannelResultMap"> |
| | | select t.devtype, t.devid, t.subdevid, t.chlid, |
| | | (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 |
| | | and prior.subdevid = t.subdevid and prior.chlid = t.chlid) as last_data_at |
| | | from t_device_ingest_record t |
| | | where t.adapter_code = #{adapterCode} and t.payload_kind = 'TOPOLOGY' |
| | | order by t.devid, t.subdevid, t.chlid |
| | | </select> |
| | | |
| | | <select id="selectLatestSample" resultMap="TDeviceIngestRecordResultMap"> |
| | | select * from t_device_ingest_record |
| | | where adapter_code = #{adapterCode} and payload_kind = 'SAMPLE' |
| | | and devtype = #{devtype} and devid = #{devid} and subdevid = #{subdevid} and chlid = #{chlid} |
| | | order by device_time_utc desc, record_sequence desc |
| | | limit 1 |
| | | </select> |
| | | |
| | | <!-- 只取严格晚于水位的行;同秒多行按协议序号各自成立,不按秒合并。 --> |
| | | <select id="selectSamplesSince" resultMap="TDeviceIngestRecordResultMap"> |
| | | select * from t_device_ingest_record |
| | | where adapter_code = #{adapterCode} and payload_kind = 'SAMPLE' |
| | | and devtype = #{devtype} and devid = #{devid} and subdevid = #{subdevid} and chlid = #{chlid} |
| | | <if test="watermarkTime != null"> |
| | | and (device_time_utc > #{watermarkTime} |
| | | or (device_time_utc = #{watermarkTime} and record_sequence > #{watermarkSequence})) |
| | | </if> |
| | | order by device_time_utc, record_sequence |
| | | limit #{limit} |
| | | </select> |
| | | </mapper> |
| New file |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | | import java.time.Clock; |
| | | import java.time.Instant; |
| | | import java.time.ZoneOffset; |
| | | import java.util.Date; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.ruoyi.common.exception.ServiceException; |
| | | import com.battery.business.domain.TDeviceAdapter; |
| | | import com.battery.business.domain.TDeviceIngestRecord; |
| | | import com.battery.business.mapper.TDeviceAdapterMapper; |
| | | import com.battery.business.service.DeviceAdapterErrorCodes; |
| | | import com.battery.business.service.DeviceIngestErrorCodes; |
| | | import org.mockito.Mockito; |
| | | |
| | | /** |
| | | * 推式来源接收端点的判定口径:凭据、来源状态与载荷不合法时**整批不落库**(取数程序因此不推进水位), |
| | | * 合法批次里厂商原始行原样落地、身份与时间由后端解析、重复推送幂等、同身份不同内容不覆盖。 |
| | | */ |
| | | class DeviceIngestServiceTest |
| | | { |
| | | private static final String ADAPTER = "j22"; |
| | | private static final String TOKEN = "j22-token"; |
| | | /** 现场实测的一行:FILETIME 134349692000000000 等于 2026-09-27 07:53:20 UTC(北京 15:53)。 */ |
| | | private static final String SAMPLE_BATCH = """ |
| | | {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[ |
| | | {"record_time":"134349692000000000","Data_Point":420,"Schedule_Index":2818575540, |
| | | "Test_Time":600.0,"Step_Time":540.0,"Step_Index":4,"Cycle_Index":1, |
| | | "Voltage":1.26778,"eCurrent":0.5,"ChargeCapacity":0.12,"DischargeCapacity":0.0, |
| | | "ChargeEnergy":0.3,"DischargeEnergy":0.0,"IR":0.01,"Power":0.63,"Tempbox":25.4}]} |
| | | """; |
| | | |
| | | private final FakeIngestLanding landing = new FakeIngestLanding(); |
| | | private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class); |
| | | private final Clock clock = Clock.fixed(Instant.parse("2026-09-27T09:00:00Z"), ZoneOffset.UTC); |
| | | private final DeviceIngestServiceImpl service = |
| | | new DeviceIngestServiceImpl(adapters, landing, clock, key -> TOKEN); |
| | | |
| | | @Test |
| | | void unknownSourceIsRejectedWithoutWritingAnything() |
| | | { |
| | | var failure = assertThrows(ServiceException.class, () -> service.ingest("mystery", TOKEN, "{}")); |
| | | |
| | | assertEquals(DeviceAdapterErrorCodes.NOT_FOUND, failure.getCode()); |
| | | assertTrue(landing.rows().isEmpty()); |
| | | } |
| | | |
| | | @Test |
| | | void sourceThatIsNotAPushSourceOrIsDisabledIsRejectedWithoutWritingAnything() |
| | | { |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("NEWARE_MYSQL", "0")); |
| | | var wrongType = assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH)); |
| | | assertEquals(DeviceAdapterErrorCodes.UNSUPPORTED_TYPE, wrongType.getCode()); |
| | | |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "1")); |
| | | var disabled = assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH)); |
| | | assertEquals(DeviceIngestErrorCodes.SOURCE_DISABLED, disabled.getCode()); |
| | | |
| | | assertTrue(landing.rows().isEmpty()); |
| | | } |
| | | |
| | | /** 凭据不入库:实例配置里只有变量名,取值缺失同样按凭据错误拒绝(失败关闭)。 */ |
| | | @Test |
| | | void wrongMissingOrUnresolvableTokenIsRejectedWithoutWritingAnything() |
| | | { |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); |
| | | |
| | | assertEquals(DeviceIngestErrorCodes.TOKEN_INVALID, |
| | | assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, "wrong", SAMPLE_BATCH)).getCode()); |
| | | assertEquals(DeviceIngestErrorCodes.TOKEN_INVALID, |
| | | assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, null, SAMPLE_BATCH)).getCode()); |
| | | |
| | | DeviceIngestServiceImpl withoutCredential = new DeviceIngestServiceImpl(adapters, landing, clock, |
| | | key -> { throw new IllegalStateException("缺少来源凭据:" + key); }); |
| | | assertEquals(DeviceIngestErrorCodes.TOKEN_INVALID, |
| | | assertThrows(ServiceException.class, () -> withoutCredential.ingest(ADAPTER, TOKEN, SAMPLE_BATCH)) |
| | | .getCode()); |
| | | |
| | | assertTrue(landing.rows().isEmpty()); |
| | | } |
| | | |
| | | /** 请求体承载厂商原始行:不含已换算的数值,单位换算、时间换算、身份映射与幂等键都在后端完成。 */ |
| | | @Test |
| | | void rawVendorRowLandsWithResolvedIdentityTimeAndUnscaledMeasurements() |
| | | { |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); |
| | | |
| | | var result = service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH); |
| | | |
| | | assertEquals(1, result.accepted()); |
| | | TDeviceIngestRecord row = landing.rows().get(0); |
| | | // 身份映射:22 所没有设备类型与单元维度,一台电脑登记为一个柜,通道号就是厂商通道号。 |
| | | assertEquals(0, row.getDevtype()); |
| | | assertEquals(0, row.getDevid()); |
| | | assertEquals(1, row.getSubdevid()); |
| | | assertEquals(26, row.getChlid()); |
| | | // 时间换算:FILETIME → UTC,同时保留设备给出的原始时间文本。 |
| | | assertEquals(Instant.parse("2026-09-27T07:53:20Z"), row.getDeviceTimeUtc().toInstant()); |
| | | assertEquals("134349692000000000", row.getDeviceTimeRaw()); |
| | | // 幂等键:库/表 + Data_Point(22 所每个测试新建一个库,Data_Point 在每个测试内重新编号)。 |
| | | assertEquals(420L, row.getRecordSequence()); |
| | | assertEquals("DB_20260927_155209/Ws26_IV_0:420", row.getRecordKey()); |
| | | assertEquals("DB_20260927_155209/Ws26_IV_0", row.getSourceRef()); |
| | | // 测量值按物理单位直接落库,不做缩放:1.26778 V 就是 1.26778 V。 |
| | | assertEquals(0, new java.math.BigDecimal("1.26778").compareTo(row.getVoltageV())); |
| | | assertEquals(0, new java.math.BigDecimal("0.5").compareTo(row.getCurrentA())); |
| | | assertEquals(0, new java.math.BigDecimal("0.12").compareTo(row.getChargeCapacityAh())); |
| | | assertEquals(1, row.getCycleNo()); |
| | | assertEquals(4, row.getStepNo()); |
| | | assertEquals(0, new java.math.BigDecimal("540.0").compareTo(row.getRelativeTimeSeconds())); |
| | | // 原始行的键值原样保留,可供回放与核对。 |
| | | assertTrue(row.getPayloadJson().contains("Data_Point")); |
| | | assertTrue(row.getPayloadJson().contains("\"Schedule_Index\":2818575540")); |
| | | } |
| | | |
| | | @Test |
| | | void repeatedPushIsIdempotentAndConflictingContentIsReportedWithoutOverwriting() |
| | | { |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); |
| | | |
| | | assertEquals(1, service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH).accepted()); |
| | | // 同一批再推一次:同一条记录被幂等忽略,采样行仍是一行。 |
| | | var repeat = service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH); |
| | | assertEquals(0, repeat.accepted()); |
| | | assertEquals(1, repeat.ignored()); |
| | | assertEquals(1, landing.samples(ADAPTER, 0, 0, 1, 26).size()); |
| | | |
| | | // 同身份但内容不同:报冲突且不覆盖已有事实。 |
| | | var conflict = service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH.replace("1.26778", "1.99")); |
| | | assertEquals(1, conflict.conflicts()); |
| | | assertEquals(0, conflict.accepted()); |
| | | assertEquals(1, landing.samples(ADAPTER, 0, 0, 1, 26).size()); |
| | | assertEquals(0, new java.math.BigDecimal("1.26778") |
| | | .compareTo(landing.samples(ADAPTER, 0, 0, 1, 26).get(0).getVoltageV())); |
| | | } |
| | | |
| | | @Test |
| | | void topologyHeartbeatAndSamplesShareTheSameLandingTable() |
| | | { |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); |
| | | |
| | | service.ingest(ADAPTER, TOKEN, "{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26},{\"channel\":18}]}"); |
| | | // 心跳只刷新存活事实,不产生数据行。 |
| | | var heartbeat = service.ingest(ADAPTER, TOKEN, "{\"kind\":\"HEARTBEAT\"}"); |
| | | assertEquals(0, heartbeat.accepted()); |
| | | assertEquals(3, landing.rows().size()); |
| | | assertEquals(clock.instant(), landing.selectHeartbeatAt(ADAPTER).toInstant()); |
| | | |
| | | // 收到采样行同样是"程序还在推"的证据:接受请求就会刷新心跳,不依赖心跳消息本身。 |
| | | Date before = landing.selectHeartbeatAt(ADAPTER); |
| | | assertNotNull(before); |
| | | assertEquals(1, service.ingest(ADAPTER, TOKEN, SAMPLE_BATCH).accepted()); |
| | | assertEquals(4, landing.rows().size()); |
| | | assertEquals(before, landing.selectHeartbeatAt(ADAPTER)); |
| | | // 拓扑上报的通道清单是长期声明,重复上报幂等;采样行按事件逐行落库。 |
| | | assertEquals(2, landing.selectTopologyChannels(ADAPTER).size()); |
| | | assertEquals(1, landing.samples(ADAPTER, 0, 0, 1, 26).size()); |
| | | } |
| | | |
| | | @Test |
| | | void malformedBatchIsRejectedWithTheOffendingRowNamed() |
| | | { |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(adapter("PUSH_INGEST", "0")); |
| | | |
| | | for (String body : new String[] { |
| | | "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"channel\":26}", |
| | | "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"rows\":[]}", |
| | | "{\"kind\":\"SAMPLE\",\"channel\":26,\"rows\":[]}", |
| | | "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"channel\":26," |
| | | + "\"rows\":[{\"Voltage\":1.2}]}", |
| | | "{\"kind\":\"SAMPLE\",\"database\":\"DB_x\",\"table\":\"Ws26_IV_0\",\"channel\":26," |
| | | + "\"rows\":[{\"Data_Point\":1}]}", |
| | | "{\"kind\":\"SOMETHING_ELSE\"}", |
| | | "not-json" }) |
| | | { |
| | | var failure = assertThrows(ServiceException.class, () -> service.ingest(ADAPTER, TOKEN, body), body); |
| | | assertEquals(DeviceIngestErrorCodes.PAYLOAD_INVALID, failure.getCode(), body); |
| | | } |
| | | assertTrue(landing.rows().isEmpty()); |
| | | } |
| | | |
| | | private static TDeviceAdapter adapter(String type, String status) |
| | | { |
| | | TDeviceAdapter adapter = new TDeviceAdapter(); |
| | | adapter.setAdapterId(7L); |
| | | adapter.setAdapterCode(ADAPTER); |
| | | adapter.setAdapterName("22 所那台"); |
| | | adapter.setAdapterType(type); |
| | | adapter.setStatus(status); |
| | | adapter.setHost("127.0.0.1"); |
| | | adapter.setPort(8080); |
| | | adapter.setTimezoneId("Asia/Shanghai"); |
| | | adapter.setSourceConfig("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\"}"); |
| | | return adapter; |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.HexFormat; |
| | | import java.util.List; |
| | | import com.battery.business.domain.TDeviceIngestRecord; |
| | | import com.battery.business.domain.vo.TDeviceIngestChannel; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | | |
| | | /** |
| | | * 落地表的内存替身:按来源行身份判重、心跳按来源覆盖、水位按 (设备时间, 协议序号) 取严格更晚的行—— |
| | | * 与生产 Mapper 的 SQL 语义一致,使接收端点与适配器可以在没有数据库的情况下接起来测。 |
| | | */ |
| | | final class FakeIngestLanding implements TDeviceIngestRecordMapper |
| | | { |
| | | private final List<TDeviceIngestRecord> rows = new ArrayList<>(); |
| | | |
| | | @Override |
| | | public int insertIngestRecord(TDeviceIngestRecord record) |
| | | { |
| | | if (rows.stream().anyMatch(existing -> same(existing, record))) return 0; |
| | | rows.add(record); |
| | | return 1; |
| | | } |
| | | |
| | | @Override |
| | | public int upsertHeartbeat(TDeviceIngestRecord record) |
| | | { |
| | | rows.removeIf(existing -> same(existing, record)); |
| | | rows.add(record); |
| | | return 1; |
| | | } |
| | | |
| | | @Override |
| | | public String selectIngestRecordHashByKey(String adapterCode, String payloadKind, String recordKey) |
| | | { |
| | | return rows.stream().filter(row -> same(row, adapterCode, payloadKind, recordKey)).findFirst() |
| | | .map(row -> HexFormat.of().formatHex(row.getContentSha256())).orElse(null); |
| | | } |
| | | |
| | | @Override |
| | | public Date selectHeartbeatAt(String adapterCode) |
| | | { |
| | | return rows.stream() |
| | | .filter(row -> adapterCode.equals(row.getAdapterCode()) |
| | | && TDeviceIngestRecord.KIND_HEARTBEAT.equals(row.getPayloadKind())) |
| | | .map(TDeviceIngestRecord::getReceivedAtUtc).max(Date::compareTo).orElse(null); |
| | | } |
| | | |
| | | @Override |
| | | public List<TDeviceIngestChannel> selectTopologyChannels(String adapterCode) |
| | | { |
| | | List<TDeviceIngestChannel> channels = new ArrayList<>(); |
| | | for (TDeviceIngestRecord row : rows) |
| | | { |
| | | if (!adapterCode.equals(row.getAdapterCode()) |
| | | || !TDeviceIngestRecord.KIND_TOPOLOGY.equals(row.getPayloadKind())) continue; |
| | | TDeviceIngestChannel channel = new TDeviceIngestChannel(); |
| | | channel.setDevtype(row.getDevtype()); channel.setDevid(row.getDevid()); |
| | | channel.setSubdevid(row.getSubdevid()); channel.setChlid(row.getChlid()); |
| | | channel.setLastDataAt(rows.stream().filter(sample -> isSample(sample, adapterCode, row)) |
| | | .map(TDeviceIngestRecord::getDeviceTimeUtc).filter(java.util.Objects::nonNull) |
| | | .max(Date::compareTo).orElse(null)); |
| | | channels.add(channel); |
| | | } |
| | | channels.sort(java.util.Comparator.comparing(TDeviceIngestChannel::getDevid) |
| | | .thenComparing(TDeviceIngestChannel::getSubdevid).thenComparing(TDeviceIngestChannel::getChlid)); |
| | | return channels; |
| | | } |
| | | |
| | | @Override |
| | | public TDeviceIngestRecord selectLatestSample(String adapterCode, int devtype, int devid, int subdevid, |
| | | int chlid) |
| | | { |
| | | return samples(adapterCode, devtype, devid, subdevid, chlid).stream() |
| | | .max(java.util.Comparator.comparing(TDeviceIngestRecord::getDeviceTimeUtc) |
| | | .thenComparing(TDeviceIngestRecord::getRecordSequence)) |
| | | .orElse(null); |
| | | } |
| | | |
| | | @Override |
| | | public List<TDeviceIngestRecord> selectSamplesSince(String adapterCode, int devtype, int devid, int subdevid, |
| | | int chlid, Date watermarkTime, long watermarkSequence, int limit) |
| | | { |
| | | List<TDeviceIngestRecord> later = samples(adapterCode, devtype, devid, subdevid, chlid).stream() |
| | | .filter(row -> after(row, watermarkTime, watermarkSequence)) |
| | | .sorted(java.util.Comparator.comparing(TDeviceIngestRecord::getDeviceTimeUtc) |
| | | .thenComparing(TDeviceIngestRecord::getRecordSequence)) |
| | | .toList(); |
| | | return later.size() > limit ? later.subList(0, limit) : later; |
| | | } |
| | | |
| | | /** 全部落地行,供断言"拒绝时没有落任何数据"使用。 */ |
| | | List<TDeviceIngestRecord> rows() { return List.copyOf(rows); } |
| | | |
| | | List<TDeviceIngestRecord> samples(String adapterCode, int devtype, int devid, int subdevid, int chlid) |
| | | { |
| | | return rows.stream().filter(row -> adapterCode.equals(row.getAdapterCode()) |
| | | && TDeviceIngestRecord.KIND_SAMPLE.equals(row.getPayloadKind()) |
| | | && row.getDevtype() == devtype && row.getDevid() == devid && row.getSubdevid() == subdevid |
| | | && row.getChlid() == chlid).toList(); |
| | | } |
| | | |
| | | private static boolean isSample(TDeviceIngestRecord row, String adapterCode, TDeviceIngestRecord channel) |
| | | { |
| | | return adapterCode.equals(row.getAdapterCode()) && TDeviceIngestRecord.KIND_SAMPLE.equals(row.getPayloadKind()) |
| | | && row.getDevtype().equals(channel.getDevtype()) && row.getDevid().equals(channel.getDevid()) |
| | | && row.getSubdevid().equals(channel.getSubdevid()) && row.getChlid().equals(channel.getChlid()); |
| | | } |
| | | |
| | | private static boolean after(TDeviceIngestRecord row, Date watermarkTime, long watermarkSequence) |
| | | { |
| | | if (row.getDeviceTimeUtc() == null) return false; |
| | | if (watermarkTime == null) return true; |
| | | int byTime = row.getDeviceTimeUtc().compareTo(watermarkTime); |
| | | return byTime > 0 || (byTime == 0 && row.getRecordSequence() > watermarkSequence); |
| | | } |
| | | |
| | | private static boolean same(TDeviceIngestRecord row, TDeviceIngestRecord other) |
| | | { |
| | | return same(row, other.getAdapterCode(), other.getPayloadKind(), other.getRecordKey()); |
| | | } |
| | | |
| | | private static boolean same(TDeviceIngestRecord row, String adapterCode, String payloadKind, String recordKey) |
| | | { |
| | | return adapterCode.equals(row.getAdapterCode()) && payloadKind.equals(row.getPayloadKind()) |
| | | && recordKey.equals(row.getRecordKey()); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertThrows; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | | import java.time.Clock; |
| | | import java.time.Duration; |
| | | import java.time.Instant; |
| | | import java.time.ZoneOffset; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | import org.junit.jupiter.api.Test; |
| | | import com.battery.device.api.DeviceAdapterException; |
| | | import com.battery.device.api.TestDataObservationAdapter.CabinetRealtime; |
| | | import com.battery.device.api.TestDataObservationAdapter.SampleWatermark; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DetectedChannel; |
| | | import com.battery.device.api.model.DeviceWorkStatus; |
| | | import com.battery.device.api.model.RealtimeSnapshot; |
| | | import com.battery.device.api.registry.AdapterInstanceConfig; |
| | | import com.battery.business.domain.TDeviceAdapter; |
| | | import com.battery.business.mapper.TDeviceAdapterMapper; |
| | | import org.mockito.Mockito; |
| | | |
| | | /** |
| | | * 推式来源适配器的观测契约:落地行投影成统一观测(身份、时间、单位、幂等身份), |
| | | * 发现候选带最近数据时间且心跳缺失即明确失败,水位按 (设备时间, 协议序号) 取严格更晚的行。 |
| | | * 原始行由接收端点解析,所以这里只喂落地行,适配器里不应出现任何厂商列名。 |
| | | */ |
| | | class PushIngestAdapterContractTest |
| | | { |
| | | private static final String ADAPTER = "j22"; |
| | | private static final String TOKEN = "j22-token"; |
| | | /** 前两行同一个绝对秒(08:03:20 UTC)但协议序号不同;第三行晚一分钟。 */ |
| | | private static final String BATCH = """ |
| | | {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[ |
| | | {"record_time":"134349692000000000","Data_Point":420,"Step_Time":540.0,"Step_Index":4, |
| | | "Cycle_Index":1,"Voltage":1.26778,"eCurrent":0.5,"ChargeCapacity":0.12,"ChargeEnergy":0.3}, |
| | | {"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":4, |
| | | "Cycle_Index":1,"Voltage":1.2701,"eCurrent":0.5,"ChargeCapacity":0.121,"ChargeEnergy":0.31}, |
| | | {"record_time":"134349698000000000","Data_Point":422,"Step_Time":600.0,"Step_Index":5, |
| | | "Cycle_Index":1,"Voltage":1.2702,"eCurrent":-0.4,"DischargeCapacity":0.05,"DischargeEnergy":0.1}]} |
| | | """; |
| | | |
| | | private final FakeIngestLanding landing = new FakeIngestLanding(); |
| | | private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class); |
| | | private static final Instant NOW = Instant.parse("2026-09-27T09:00:00Z"); |
| | | |
| | | @Test |
| | | void topologyPushMakesChannelsCandidatesWithTheirOwnLatestDataTime() |
| | | { |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26},{\"channel\":18}]}"); |
| | | ingest(BATCH); |
| | | PushIngestAdapter adapter = adapter(NOW); |
| | | |
| | | List<DetectedChannel> channels = adapter.discoverChannels(); |
| | | |
| | | assertEquals(List.of(18, 26), channels.stream().map(channel -> channel.identity().chlid()).toList()); |
| | | DetectedChannel active = channels.stream().filter(channel -> channel.identity().chlid() == 26).findFirst() |
| | | .orElseThrow(); |
| | | // 最近数据时间来自该通道自己的采样行,不是拓扑上报时间。 |
| | | assertEquals(Instant.parse("2026-09-27T08:03:20Z"), active.lastDataAt()); |
| | | assertTrue(active.available()); |
| | | // 候选的机器地址取实例配置:登记时会被带进检测柜的 IP 字段。 |
| | | assertEquals("127.0.0.1", active.serverIp()); |
| | | // 登记范围由操作员决定:没有采样行的通道照样出现在候选里,只是没有最近数据时间。 |
| | | assertNull(channels.stream().filter(channel -> channel.identity().chlid() == 18).findFirst() |
| | | .orElseThrow().lastDataAt()); |
| | | } |
| | | |
| | | /** 心跳缺失或过期即来源不可达:发现路径明确失败,业务侧据此把已登记设备置为离线并撤下旧快照。 */ |
| | | @Test |
| | | void missingOrStaleHeartbeatFailsDiscoveryInsteadOfReturningStaleValues() |
| | | { |
| | | // 还没收到过任何推送:这是"取数程序没起来"这一条可见的失败,不是沉默的空清单。 |
| | | DeviceAdapterException missing = assertThrows(DeviceAdapterException.class, () -> adapter(NOW).discoverChannels()); |
| | | assertEquals("SOURCE_OFFLINE", missing.failureCode()); |
| | | |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); |
| | | // 收到推送即刷新心跳;容忍窗口是三个轮询周期,一轮没推不算离线。 |
| | | assertEquals(1, adapter(NOW.plus(Duration.ofSeconds(179))).discoverChannels().size()); |
| | | // 连丢三轮才算离线,且不必等候选过期。 |
| | | DeviceAdapterException stale = assertThrows(DeviceAdapterException.class, |
| | | () -> adapter(NOW.plus(Duration.ofSeconds(181))).discoverChannels()); |
| | | assertEquals("SOURCE_OFFLINE", stale.failureCode()); |
| | | } |
| | | |
| | | @Test |
| | | void samplesAreProjectedWithRawTimeUnscaledUnitsAndSourceRowIdentity() |
| | | { |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); |
| | | ingest(BATCH); |
| | | PushIngestAdapter adapter = adapter(NOW); |
| | | |
| | | CabinetRealtime latest = adapter.latestCabinet(channel()); |
| | | |
| | | // 时间按厂商格式换算为 UTC,同时保留原始值与来源时区;单位是物理单位,不做缩放。 |
| | | assertEquals(Instant.parse("2026-09-27T08:03:20Z"), latest.deviceTime()); |
| | | assertEquals("134349698000000000", latest.deviceTimeRaw()); |
| | | assertEquals("Asia/Shanghai", latest.timezoneId()); |
| | | assertEquals(1.2702, latest.voltageV(), 1e-9); |
| | | assertEquals(-0.4, latest.currentA(), 1e-9); |
| | | assertEquals(600L, latest.relativeTimeSeconds()); |
| | | assertEquals(1, latest.cycleNo()); |
| | | assertEquals(5, latest.stepNo()); |
| | | // 放电容量的取法按电流方向,与直连来源同一口径;来源行身份供幂等与冲突判定。 |
| | | assertEquals(0.05, latest.capacityAh(), 1e-9); |
| | | assertEquals("push:DB_20260927_155209/Ws26_IV_0:422", latest.sourceRecordId()); |
| | | |
| | | // 实时快照:通道工作状态在读落地表路线下不可观测(ADR-0063)。 |
| | | Map<ChannelIdentity, RealtimeSnapshot> snapshots = adapter.inquire(Set.of(channel())); |
| | | assertEquals(DeviceWorkStatus.UNOBSERVABLE, snapshots.get(channel()).workStatus()); |
| | | assertEquals(Instant.parse("2026-09-27T08:03:20Z"), snapshots.get(channel()).deviceTime()); |
| | | } |
| | | |
| | | @Test |
| | | void watermarkAndSamplesSinceKeepSameSecondRowsDistinctAndSkipNothingBeforeTheBoundary() |
| | | { |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); |
| | | ingest(BATCH); |
| | | PushIngestAdapter adapter = adapter(NOW); |
| | | |
| | | // 水位是落地表里该通道最新一行:设备绝对时间与协议序号。 |
| | | assertEquals(new SampleWatermark(Instant.parse("2026-09-27T08:03:20Z"), 422L), |
| | | adapter.currentWatermark(channel())); |
| | | |
| | | // 只取严格晚于水位的行:同一绝对秒内的多行靠协议序号各自成立。 |
| | | List<CabinetRealtime> rows = adapter.samplesSince(channel(), |
| | | new SampleWatermark(Instant.parse("2026-09-27T07:53:20Z"), 420L)); |
| | | assertEquals(List.of(421L, 422L), rows.stream().map(CabinetRealtime::sequenceId).toList()); |
| | | assertEquals(0, adapter.samplesSince(channel(), |
| | | new SampleWatermark(Instant.parse("2026-09-27T08:03:20Z"), 422L)).size()); |
| | | // 同一绝对秒内序号更大的行仍然成立:水位是二元组而不是秒。 |
| | | assertEquals(List.of(422L), adapter.samplesSince(channel(), |
| | | new SampleWatermark(Instant.parse("2026-09-27T08:03:20Z"), 421L)).stream() |
| | | .map(CabinetRealtime::sequenceId).toList()); |
| | | // 调用方尚未取得水位时不把来源里的历史并入本次接收。 |
| | | assertTrue(adapter.samplesSince(channel(), null).isEmpty()); |
| | | // 水位为空的二元组表示边界时刻来源里什么也没有:之后出现的行全部属于本次接收。 |
| | | assertEquals(3, adapter.samplesSince(channel(), new SampleWatermark(null, 0L)).size()); |
| | | } |
| | | |
| | | @Test |
| | | void emptySourceHasNoWatermarkAndNoRealtimeObservation() |
| | | { |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); |
| | | PushIngestAdapter adapter = adapter(NOW); |
| | | |
| | | assertEquals(new SampleWatermark(null, 0L), adapter.currentWatermark(channel())); |
| | | assertNull(adapter.latestCabinet(channel())); |
| | | assertTrue(adapter.inquire(Set.of(channel())).isEmpty()); |
| | | } |
| | | |
| | | /** 采集器辅助测量按 ADR-0064 逐来源确认;22 所环境量的列语义未确认,因此不交出测量值。 */ |
| | | @Test |
| | | void collectorAndStepCycleObservationsStayEmptyForThisStorageForm() |
| | | { |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); |
| | | ingest(BATCH); |
| | | PushIngestAdapter adapter = adapter(NOW); |
| | | |
| | | assertNull(adapter.latestCollector(ADAPTER, "collector-1")); |
| | | assertTrue(adapter.stepRecords(channel()).isEmpty()); |
| | | assertTrue(adapter.cycleRecords(channel()).isEmpty()); |
| | | // 落地表在本地,取数不产生厂商负载,所以按展示节奏取数;厂商写库节奏仍是 60 秒。 |
| | | assertEquals(5000L, adapter.persistenceIntervalMillis(channel())); |
| | | AdapterCapabilities capabilities = adapter.capabilities(); |
| | | assertTrue(capabilities.discovery() && capabilities.realtimeObservation() && !capabilities.control()); |
| | | } |
| | | |
| | | @Test |
| | | void channelIdentityFromAnotherInstanceFailsInsteadOfFallingBack() |
| | | { |
| | | PushIngestAdapter adapter = adapter(NOW); |
| | | |
| | | DeviceAdapterException failure = assertThrows(DeviceAdapterException.class, |
| | | () -> adapter.currentWatermark(new ChannelIdentity("neware76", 21, 36, 1, 2))); |
| | | assertTrue(failure.getMessage().contains("neware76")); |
| | | assertFalse(adapter.adapterCode().isEmpty()); |
| | | } |
| | | |
| | | private ChannelIdentity channel() |
| | | { |
| | | return new ChannelIdentity(ADAPTER, 0, 0, 1, 26); |
| | | } |
| | | |
| | | private PushIngestAdapter adapter(Instant now) |
| | | { |
| | | return new PushIngestAdapter(config(), landing, Clock.fixed(now, ZoneOffset.UTC)); |
| | | } |
| | | |
| | | private static AdapterInstanceConfig config() |
| | | { |
| | | return new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE, "127.0.0.1", 8080, 60000, |
| | | "Asia/Shanghai", true, AdapterCapabilities.observeOnly(), |
| | | Map.of("tokenEnv", "BATTERY_INGEST_TOKEN")); |
| | | } |
| | | |
| | | private void ingest(String body) |
| | | { |
| | | TDeviceAdapter source = new TDeviceAdapter(); |
| | | source.setAdapterCode(ADAPTER); |
| | | source.setAdapterType(PushIngestAdapter.ADAPTER_TYPE); |
| | | source.setStatus("0"); |
| | | source.setTimezoneId("Asia/Shanghai"); |
| | | Mockito.when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(source); |
| | | new DeviceIngestServiceImpl(adapters, landing, Clock.fixed(NOW, ZoneOffset.UTC), key -> TOKEN) |
| | | .ingest(ADAPTER, TOKEN, body); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.service.impl; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.never; |
| | | import static org.mockito.Mockito.times; |
| | | import static org.mockito.Mockito.verify; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.time.Clock; |
| | | import java.time.Duration; |
| | | import java.time.Instant; |
| | | import java.time.ZoneId; |
| | | import java.time.ZoneOffset; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.mockito.ArgumentCaptor; |
| | | import org.mockito.Mockito; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.registry.AdapterInstanceConfig; |
| | | import com.battery.business.domain.TDeviceAdapter; |
| | | import com.battery.business.domain.TDeviceIngestRecord; |
| | | import com.battery.business.domain.TTest; |
| | | import com.battery.business.domain.TTestBattery; |
| | | import com.battery.business.domain.TTestChannelBinding; |
| | | import com.battery.business.domain.TTestRealtimeRecord; |
| | | import com.battery.business.mapper.TDeviceAdapterMapper; |
| | | import com.battery.business.mapper.TTestMapper; |
| | | import com.battery.business.service.ITTestService; |
| | | |
| | | /** |
| | | * 推式来源接进业务接收路径端到端:取数程序推来的厂商原始行 → 落地表 → 适配器读回统一观测 → |
| | | * 接收中测试按水位逐行保存。 |
| | | * |
| | | * <p>用的是同一个观测契约,所以这里不需要任何推送专属的接收代码:落地行按事件落库、 |
| | | * 轮询槽位为空(同秒多行因此各自成立),来源行身份承担幂等与冲突判定。 |
| | | */ |
| | | class PushIngestReceptionContractTest |
| | | { |
| | | private static final String ADAPTER = "j22"; |
| | | private static final Instant START = Instant.parse("2026-09-27T08:00:00Z"); |
| | | /** 现场实测的一批:两行落在同一个绝对秒(08:03:20 UTC),协议序号不同。 */ |
| | | private static final String BATCH = """ |
| | | {"kind":"SAMPLE","database":"DB_20260927_155209","table":"Ws26_IV_0","channel":26,"rows":[ |
| | | {"record_time":"134349698000000000","Data_Point":421,"Step_Time":600.0,"Step_Index":4, |
| | | "Cycle_Index":1,"Voltage":1.2701,"eCurrent":0.5,"ChargeCapacity":0.121,"ChargeEnergy":0.31}, |
| | | {"record_time":"134349698000000000","Data_Point":422,"Step_Time":600.0,"Step_Index":4, |
| | | "Cycle_Index":1,"Voltage":1.2702,"eCurrent":0.5,"ChargeCapacity":0.122,"ChargeEnergy":0.32}]} |
| | | """; |
| | | |
| | | private final FakeIngestLanding landing = new FakeIngestLanding(); |
| | | private final TDeviceAdapterMapper adapters = Mockito.mock(TDeviceAdapterMapper.class); |
| | | private final TestClock clock = new TestClock(START); |
| | | |
| | | @Test |
| | | void pushedRawRowsBecomeTestFactsWithoutPollingSlots() |
| | | { |
| | | PushIngestAdapter adapter = ingestPushSource(); |
| | | TTestMapper mapper = mock(TTestMapper.class); |
| | | ITTestService tests = mock(ITTestService.class); |
| | | TTest test = receivingTest(); |
| | | when(mapper.selectReceivingTestIds()).thenReturn(List.of(1L)); |
| | | when(mapper.selectTestByIdForUpdate(1L)).thenReturn(test); |
| | | when(tests.selectTestById(1L)).thenReturn(test); |
| | | TestDataReceptionServiceImpl service = new TestDataReceptionServiceImpl(mapper, tests, adapter, clock); |
| | | |
| | | // 第一次接收只取水位:开始接收之前的行即使躺在落地表里也不进本测试。 |
| | | landing.insertIngestRecord(boundaryRow("DB_20260927_155209/Ws26_IV_0:420", 420L)); |
| | | service.captureReceivedData(); |
| | | verify(mapper, never()).insertRealtimeRecord(any()); |
| | | |
| | | // 之后推来的行逐行落库:同秒两行各自成立,因为槽位为空(推送记录按事件落库,不占轮询槽位)。 |
| | | ingest(BATCH); |
| | | clock.advance(Duration.ofSeconds(60)); |
| | | service.captureReceivedData(); |
| | | |
| | | ArgumentCaptor<TTestRealtimeRecord> rows = ArgumentCaptor.forClass(TTestRealtimeRecord.class); |
| | | verify(mapper, times(2)).insertRealtimeRecord(rows.capture()); |
| | | assertEquals(List.of("push:DB_20260927_155209/Ws26_IV_0:421", |
| | | "push:DB_20260927_155209/Ws26_IV_0:422"), |
| | | rows.getAllValues().stream().map(TTestRealtimeRecord::getSourceRecordId).toList()); |
| | | assertTrue(rows.getAllValues().stream().allMatch(row -> row.getCaptureSlotUtc() == null)); |
| | | assertEquals("j22:0:0:1:26", rows.getAllValues().get(0).getSourceIdentity()); |
| | | assertEquals(Instant.parse("2026-09-27T08:03:20Z"), |
| | | rows.getAllValues().get(0).getCabinetDeviceTimeUtc().toInstant()); |
| | | assertEquals("Asia/Shanghai", rows.getAllValues().get(0).getCabinetAdapterTimezoneId()); |
| | | // 测量值按物理单位直接使用,不做缩放。 |
| | | assertEquals(0, new java.math.BigDecimal("1.2701") |
| | | .compareTo(rows.getAllValues().get(0).getVoltageV())); |
| | | assertEquals(0, new java.math.BigDecimal("0.121") |
| | | .compareTo(rows.getAllValues().get(0).getCapacityAh())); |
| | | |
| | | // 同一批再推一次不会重复落库:来源行身份让接收幂等。 |
| | | ingest(BATCH); |
| | | clock.advance(Duration.ofSeconds(60)); |
| | | service.captureReceivedData(); |
| | | verify(mapper, times(2)).insertRealtimeRecord(any()); |
| | | } |
| | | |
| | | private PushIngestAdapter ingestPushSource() |
| | | { |
| | | TDeviceAdapter source = new TDeviceAdapter(); |
| | | source.setAdapterCode(ADAPTER); |
| | | source.setAdapterType(PushIngestAdapter.ADAPTER_TYPE); |
| | | source.setStatus("0"); |
| | | source.setTimezoneId("Asia/Shanghai"); |
| | | source.setSourceConfig("{\"tokenEnv\":\"BATTERY_INGEST_TOKEN\"}"); |
| | | when(adapters.selectDeviceAdapterByCode(ADAPTER)).thenReturn(source); |
| | | ingest("{\"kind\":\"TOPOLOGY\",\"rows\":[{\"channel\":26}]}"); |
| | | return new PushIngestAdapter(new AdapterInstanceConfig(ADAPTER, PushIngestAdapter.ADAPTER_TYPE, |
| | | "127.0.0.1", 8080, 60000, "Asia/Shanghai", true, AdapterCapabilities.observeOnly(), |
| | | java.util.Map.of("tokenEnv", "BATTERY_INGEST_TOKEN")), landing, clock); |
| | | } |
| | | |
| | | private void ingest(String body) |
| | | { |
| | | new DeviceIngestServiceImpl(adapters, landing, clock, key -> "token").ingest(ADAPTER, "token", body); |
| | | } |
| | | |
| | | /** 开始接收之前就躺在来源里的那一行:它只用来把水位定在边界时刻。 */ |
| | | private TDeviceIngestRecord boundaryRow(String recordKey, long sequence) |
| | | { |
| | | TDeviceIngestRecord value = new TDeviceIngestRecord(); |
| | | value.setAdapterCode(ADAPTER); |
| | | value.setPayloadKind(TDeviceIngestRecord.KIND_SAMPLE); |
| | | value.setDevtype(0); value.setDevid(0); value.setSubdevid(1); value.setChlid(26); |
| | | value.setRecordSequence(sequence); |
| | | value.setRecordKey(recordKey); |
| | | value.setSourceRef("DB_20260927_155209/Ws26_IV_0"); |
| | | value.setDeviceTimeUtc(Date.from(START.minusSeconds(60))); |
| | | value.setDeviceTimeRaw("134349691400000000"); |
| | | value.setTimezoneId("Asia/Shanghai"); |
| | | value.setPayloadJson("{}"); |
| | | value.setContentSha256(new byte[32]); |
| | | value.setReceivedAtUtc(Date.from(START.minusSeconds(60))); |
| | | return value; |
| | | } |
| | | |
| | | private static TTest receivingTest() |
| | | { |
| | | TTestChannelBinding binding = new TTestChannelBinding(); |
| | | binding.setBindingId(2L); |
| | | binding.setChannelId(3L); |
| | | binding.setAdapterCode(ADAPTER); |
| | | binding.setAdapterTimezoneId("Asia/Shanghai"); |
| | | binding.setDevtype(0); |
| | | binding.setDevid(0); |
| | | binding.setSubdevid(1); |
| | | binding.setChlid(26); |
| | | TTestBattery battery = new TTestBattery(); |
| | | battery.setTestBatteryId(4L); |
| | | battery.setBinding(binding); |
| | | battery.setCollectorChannels(List.of()); |
| | | TTest test = new TTest(); |
| | | test.setTestId(1L); |
| | | test.setTestStatus("RUNNING"); |
| | | test.setReceptionStatus("RECEIVING"); |
| | | test.setStartedAtUtc(Date.from(START)); |
| | | test.setReceptionBoundaryAtUtc(Date.from(START)); |
| | | test.setBatteries(List.of(battery)); |
| | | return test; |
| | | } |
| | | |
| | | /** 可推进的测试时钟:接收节奏与推送节奏都按它推进。 */ |
| | | private static final class TestClock extends Clock |
| | | { |
| | | private Instant now; |
| | | |
| | | private TestClock(Instant now) { this.now = now; } |
| | | |
| | | private void advance(Duration duration) { now = now.plus(duration); } |
| | | |
| | | @Override public ZoneId getZone() { return ZoneOffset.UTC; } |
| | | @Override public Clock withZone(ZoneId zone) { return this; } |
| | | @Override public Instant instant() { return now; } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.device.api.credentials; |
| | | |
| | | import java.io.IOException; |
| | | import java.io.InputStream; |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.util.Map; |
| | | import java.util.Properties; |
| | | import java.util.function.Function; |
| | | import com.battery.device.api.DeviceAdapterException; |
| | | |
| | | /** |
| | | * 来源凭据的取值顺序(ADR-0065):进程工作目录下的 {@code config/vendor-credentials.properties} |
| | | * → 同名环境变量 → 同名 JVM 启动参数。 |
| | | * |
| | | * <p>凭据不进数据库、不进仓库,也不打包;仓库只提交 {@code .example} 模板。 |
| | | * 文件优先是为了让部署只做"复制模板填一行"这一件事;保留环境变量与启动参数两条, |
| | | * 是因为交付形态可能只提供其中某一种。三者都没有时装配失败,错误信息同时点名文件路径与键名, |
| | | * 使"忘了放凭据"是可见的失败而不是沉默的缺失。 |
| | | * |
| | | * <p>直连厂商库的口令({@code NEWARE_MYSQL})与推式来源的接收端点令牌({@code PUSH_INGEST}) |
| | | * 都是来源凭据,因此共用同一个文件与同一套取值顺序。 |
| | | */ |
| | | public final class LocalCredentials |
| | | { |
| | | /** 相对进程工作目录定位,使开发(仓库根)与交付(应用目录)用同一个位置。 */ |
| | | public static final Path DEFAULT_FILE = Path.of("config", "vendor-credentials.properties"); |
| | | |
| | | private LocalCredentials() { } |
| | | |
| | | /** 按默认位置与进程环境取值;取不到时抛出 {@link DeviceAdapterException}。 */ |
| | | public static String resolve(String key) |
| | | { |
| | | return resolve(key, DEFAULT_FILE.toAbsolutePath(), System.getenv(), System::getProperty); |
| | | } |
| | | |
| | | /** |
| | | * 取值顺序:文件 → 环境变量 → 启动参数。文件里的空白值不算配置过, |
| | | * 否则一个空模板会把失败推到连接阶段。 |
| | | */ |
| | | public static String resolve(String key, Path credentialsFile, Map<String, String> environment, |
| | | Function<String, String> startupParameters) |
| | | { |
| | | if (key == null || key.isBlank()) throw new DeviceAdapterException("未配置来源凭据的取值名"); |
| | | String fromFile = fromFile(key, credentialsFile); |
| | | if (fromFile != null) return fromFile; |
| | | String fromEnvironment = environment == null ? null : environment.get(key); |
| | | if (fromEnvironment != null && !fromEnvironment.isBlank()) return fromEnvironment.trim(); |
| | | String fromParameter = startupParameters == null ? null : startupParameters.apply(key); |
| | | if (fromParameter != null && !fromParameter.isBlank()) return fromParameter.trim(); |
| | | throw new DeviceAdapterException("缺少来源凭据:" + credentialsFile |
| | | + " 中没有键 " + key + ",环境变量与启动参数 -D" + key + " 也都没有"); |
| | | } |
| | | |
| | | /** 凭据文件缺失不是错误:交付形态可能只用环境变量,所以这里只负责"读到了就返回"。 */ |
| | | private static String fromFile(String key, Path credentialsFile) |
| | | { |
| | | if (credentialsFile == null || !Files.isRegularFile(credentialsFile)) return null; |
| | | Properties properties = new Properties(); |
| | | try (InputStream input = Files.newInputStream(credentialsFile)) |
| | | { |
| | | properties.load(input); |
| | | } |
| | | catch (IOException exception) |
| | | { |
| | | throw new DeviceAdapterException("来源凭据文件无法读取:" + credentialsFile, exception); |
| | | } |
| | | String value = properties.getProperty(key); |
| | | return value == null || value.isBlank() ? null : value.trim(); |
| | | } |
| | | } |
| | |
| | | package com.battery.device.neware; |
| | | |
| | | import java.io.IOException; |
| | | import java.io.InputStream; |
| | | import java.nio.file.Files; |
| | | import java.nio.file.Path; |
| | | import java.util.List; |
| | | import java.util.Locale; |
| | | import java.util.Map; |
| | | import java.util.Properties; |
| | | import java.util.function.Function; |
| | | import com.battery.device.api.DeviceAdapter; |
| | | import com.battery.device.api.DeviceAdapterException; |
| | | import com.battery.device.api.credentials.LocalCredentials; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.registry.AdapterInstanceConfig; |
| | | import com.battery.device.api.registry.DeviceAdapterFactory; |
| | |
| | | /** 现场已知的全权限账号;写进交付配置就等于把误写风险带进现场。 */ |
| | | private static final List<String> FULL_PRIVILEGE_USERS = List.of("btsserver", "root"); |
| | | /** 凭据文件相对进程工作目录定位,使开发(仓库根)与交付(应用目录)用同一个位置。 */ |
| | | static final Path CREDENTIALS_FILE = Path.of("config", "vendor-credentials.properties"); |
| | | static final Path CREDENTIALS_FILE = LocalCredentials.DEFAULT_FILE; |
| | | |
| | | @Override |
| | | public String adapterType() |
| | |
| | | String user = readOnlyUser(config.sourceConfig()); |
| | | String key = option(config.sourceConfig(), PASSWORD_ENV_OPTION, |
| | | NewareMysqlAdapter.DEFAULT_PASSWORD_ENV); |
| | | String password = credential(key, CREDENTIALS_FILE.toAbsolutePath(), System.getenv(), |
| | | String password = LocalCredentials.resolve(key, CREDENTIALS_FILE.toAbsolutePath(), System.getenv(), |
| | | System::getProperty); |
| | | SingleConnectionVendorDataSource source = new SingleConnectionVendorDataSource( |
| | | jdbcUrl(config.host(), config.port(), database), user, password); |
| | |
| | | if (FULL_PRIVILEGE_USERS.contains(user.toLowerCase(Locale.ROOT))) |
| | | throw new DeviceAdapterException("厂商数据源只允许只读账号,拒绝全权限账号:" + user); |
| | | return user; |
| | | } |
| | | |
| | | /** |
| | | * 按「本地凭据文件 → 环境变量 → 启动参数」取值;键名由实例配置的 {@code passwordEnv} 指定。 |
| | | * 文件优先,是为了让部署只做"复制模板填一行"这一件事。 |
| | | */ |
| | | static String credential(String key, Path credentialsFile, Map<String, String> environment, |
| | | Function<String, String> startupParameters) |
| | | { |
| | | if (key == null || key.isBlank()) throw new DeviceAdapterException("未配置厂商库口令的取值名"); |
| | | String fromFile = credentialFromFile(key, credentialsFile); |
| | | if (fromFile != null) return fromFile; |
| | | String fromEnvironment = environment == null ? null : environment.get(key); |
| | | if (fromEnvironment != null && !fromEnvironment.isBlank()) return fromEnvironment.trim(); |
| | | String fromParameter = startupParameters == null ? null : startupParameters.apply(key); |
| | | if (fromParameter != null && !fromParameter.isBlank()) return fromParameter.trim(); |
| | | throw new DeviceAdapterException("缺少厂商库连接口令:" + credentialsFile |
| | | + " 中没有键 " + key + ",环境变量与启动参数 -D" + key + " 也都没有"); |
| | | } |
| | | |
| | | /** 凭据文件缺失不是错误:交付形态可能只用环境变量,所以这里只负责"读到了就返回"。 */ |
| | | private static String credentialFromFile(String key, Path credentialsFile) |
| | | { |
| | | if (credentialsFile == null || !Files.isRegularFile(credentialsFile)) return null; |
| | | Properties properties = new Properties(); |
| | | try (InputStream input = Files.newInputStream(credentialsFile)) |
| | | { |
| | | properties.load(input); |
| | | } |
| | | catch (IOException exception) |
| | | { |
| | | throw new DeviceAdapterException("厂商库凭据文件无法读取:" + credentialsFile, exception); |
| | | } |
| | | String value = properties.getProperty(key); |
| | | return value == null || value.isBlank() ? null : value.trim(); |
| | | } |
| | | |
| | | /** |
| | |
| | | import com.battery.device.api.TestDataObservationAdapter.CycleRecord; |
| | | import com.battery.device.api.TestDataObservationAdapter.SampleWatermark; |
| | | import com.battery.device.api.TestDataObservationAdapter.StepRecord; |
| | | import com.battery.device.api.credentials.LocalCredentials; |
| | | import com.battery.device.api.model.AdapterCapabilities; |
| | | import com.battery.device.api.model.ChannelIdentity; |
| | | import com.battery.device.api.model.DetectedChannel; |
| | |
| | | { |
| | | // 文件、环境变量、启动参数都在时取文件。 |
| | | java.nio.file.Files.writeString(file, key + "=from-file\n"); |
| | | assertEquals("from-file", NewareMysqlAdapterFactory.credential(key, file, |
| | | assertEquals("from-file", LocalCredentials.resolve(key, file, |
| | | Map.of(key, "from-env"), name -> "from-parameter")); |
| | | |
| | | // 没有文件时退到环境变量。 |
| | | java.nio.file.Files.delete(file); |
| | | assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file, |
| | | assertEquals("from-env", LocalCredentials.resolve(key, file, |
| | | Map.of(key, "from-env"), name -> "from-parameter")); |
| | | |
| | | // 前两者都没有时退到启动参数。 |
| | | assertEquals("from-parameter", NewareMysqlAdapterFactory.credential(key, file, |
| | | assertEquals("from-parameter", LocalCredentials.resolve(key, file, |
| | | Map.of(), name -> "from-parameter")); |
| | | |
| | | // 三者都没有:失败信息里同时出现文件路径与键名。 |
| | | var failure = org.junit.jupiter.api.Assertions.assertThrows( |
| | | com.battery.device.api.DeviceAdapterException.class, |
| | | () -> NewareMysqlAdapterFactory.credential(key, file, Map.of(), name -> null)); |
| | | () -> LocalCredentials.resolve(key, file, Map.of(), name -> null)); |
| | | assertTrue(failure.getMessage().contains(file.toString())); |
| | | assertTrue(failure.getMessage().contains(key)); |
| | | } |
| | |
| | | try |
| | | { |
| | | java.nio.file.Files.writeString(file, key + "=\n"); |
| | | assertEquals("from-env", NewareMysqlAdapterFactory.credential(key, file, Map.of(key, "from-env"), |
| | | assertEquals("from-env", LocalCredentials.resolve(key, file, Map.of(key, "from-env"), |
| | | name -> null)); |
| | | } |
| | | finally |
| | |
| | | import com.battery.device.bts.BtsDeviceAdapterFactory; |
| | | import com.battery.device.api.registry.DeviceAdapterRegistry; |
| | | import com.battery.device.neware.NewareMysqlAdapterFactory; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | | import com.battery.business.service.impl.PushIngestAdapterFactory; |
| | | |
| | | /** |
| | | * 装配单进程设备适配器运行时。 |
| | |
| | | * <p>注册表按存储形态持有全部可用实现;实例按 {@code t_device_adapter} 配置或代码装配注册。 |
| | | * 同一进程内可并存多个来源实例,取数按通道身份里的适配器编码解析到各自实例。 |
| | | * 某个类型是否真的有实例可用只由配置决定,因此这里不再按 profile 收窄可注册的类型。 |
| | | * |
| | | * <p>推式来源({@code PUSH_INGEST})读的是本项目的落地表,所以它的工厂拿业务 Mapper 而不是厂商连接。 |
| | | */ |
| | | @Configuration |
| | | @EnableScheduling |
| | | public class DeviceAdapterRuntimeConfig |
| | | { |
| | | @Bean(destroyMethod = "close") |
| | | public DeviceAdapterRegistry deviceAdapterRegistry() |
| | | public DeviceAdapterRegistry deviceAdapterRegistry(TDeviceIngestRecordMapper ingestRecords) |
| | | { |
| | | return new DeviceAdapterRegistry(List.of(new NoopDeviceAdapterFactory(), new BtsDeviceAdapterFactory(), |
| | | new NewareMysqlAdapterFactory())); |
| | | new NewareMysqlAdapterFactory(), new PushIngestAdapterFactory(ingestRecords))); |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.web.device; |
| | | |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.http.HttpStatus; |
| | | import org.springframework.http.ResponseEntity; |
| | | import org.springframework.web.bind.annotation.PathVariable; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestHeader; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | import com.ruoyi.common.annotation.Anonymous; |
| | | import com.ruoyi.common.exception.ServiceException; |
| | | import com.battery.business.service.DeviceAdapterErrorCodes; |
| | | import com.battery.business.service.DeviceIngestErrorCodes; |
| | | import com.battery.business.service.IDeviceIngestService; |
| | | import com.battery.business.service.IDeviceIngestService.IngestResult; |
| | | |
| | | /** |
| | | * 推式来源的接收端点(ADR-0065):甲方机器上的只读取数程序把厂商原始行推到这里。 |
| | | * |
| | | * <p>端点匿名放行但自己校验来源凭据(令牌随请求头来):取数程序不带登录态,也不能带。 |
| | | * 拒绝一律用非 2xx 状态码——程序只在收到 2xx 后才推进自己的读取位置, |
| | | * 所以"响应 200 但体内是错误码"是不能接受的:那会让程序把没落库的数据当成已送达。 |
| | | */ |
| | | @Anonymous |
| | | @RestController |
| | | @RequestMapping("/device-ingest") |
| | | public class DeviceIngestController |
| | | { |
| | | /** 来源凭据的请求头名;每个推式来源实例各有一个令牌。 */ |
| | | public static final String TOKEN_HEADER = "X-Device-Ingest-Token"; |
| | | |
| | | private final IDeviceIngestService ingest; |
| | | |
| | | @Autowired |
| | | public DeviceIngestController(IDeviceIngestService ingest) |
| | | { |
| | | this.ingest = ingest; |
| | | } |
| | | |
| | | @PostMapping("/{adapterCode}") |
| | | public ResponseEntity<?> receive(@PathVariable String adapterCode, |
| | | @RequestHeader(value = TOKEN_HEADER, required = false) String token, |
| | | @RequestBody(required = false) String body) |
| | | { |
| | | try |
| | | { |
| | | IngestResult result = ingest.ingest(adapterCode, token, body); |
| | | return ResponseEntity.ok(result); |
| | | } |
| | | catch (ServiceException failure) |
| | | { |
| | | return ResponseEntity.status(httpStatus(failure.getCode())) |
| | | .body(new IngestFailure(failure.getCode(), failure.getMessage())); |
| | | } |
| | | } |
| | | |
| | | /** 业务错误码到 HTTP 状态的映射:只影响取数程序"是否推进水位"这一个判断。 */ |
| | | private static HttpStatus httpStatus(Integer code) |
| | | { |
| | | if (code == null) return HttpStatus.INTERNAL_SERVER_ERROR; |
| | | return switch (code) |
| | | { |
| | | case DeviceAdapterErrorCodes.NOT_FOUND -> HttpStatus.NOT_FOUND; |
| | | case DeviceIngestErrorCodes.TOKEN_INVALID -> HttpStatus.UNAUTHORIZED; |
| | | case DeviceAdapterErrorCodes.UNSUPPORTED_TYPE, DeviceIngestErrorCodes.SOURCE_DISABLED -> HttpStatus.FORBIDDEN; |
| | | case DeviceIngestErrorCodes.PAYLOAD_INVALID -> HttpStatus.BAD_REQUEST; |
| | | default -> HttpStatus.INTERNAL_SERVER_ERROR; |
| | | }; |
| | | } |
| | | |
| | | /** 拒绝应答:取数程序据此记录原因,并保持自己的读取位置不动。 */ |
| | | public record IngestFailure(Integer code, String message) { } |
| | | } |
| New file |
| | |
| | | -- Ticket03:推式来源(PUSH_INGEST)的落地表,ADR-0065。 |
| | | -- 甲方机器上的只读取数程序把厂商原始行推给接收端点,行按收到的键值原样落在这里:不改列名、不增删字段、不做换算(JSON 列会规范化格式,但键值不变)。 |
| | | -- 解释只做一遍并全部落在后端:身份映射、时间换算、幂等键取值、测量列取值都在接收端点里完成, |
| | | -- 因此这张表既能本地回放开发与回归(不依赖那台机器在线),也是适配器读回统一观测的唯一来源—— |
| | | -- 适配器只做投影,不含任何厂商列名。 |
| | | -- 推送记录按事件落库:唯一键让同一条来源行重复推送被幂等忽略,同身份内容不同只报冲突、不覆盖。 |
| | | CREATE TABLE t_device_ingest_record ( |
| | | ingest_id BIGINT NOT NULL AUTO_INCREMENT, |
| | | adapter_code VARCHAR(64) NOT NULL, |
| | | -- 载荷类别:TOPOLOGY 拓扑上报(通道清单)、SAMPLE 逐点数据行、HEARTBEAT 存活心跳。 |
| | | payload_kind VARCHAR(16) NOT NULL, |
| | | devtype INT NOT NULL DEFAULT 0, |
| | | devid INT NOT NULL DEFAULT 0, |
| | | subdevid INT NOT NULL DEFAULT 0, |
| | | chlid INT NOT NULL DEFAULT 0, |
| | | -- 厂商协议序号(22 所为 Data_Point):水位与"同一绝对秒内多行"都靠它区分。 |
| | | record_sequence BIGINT NOT NULL DEFAULT 0, |
| | | -- 来源行身份(22 所为 库/表 + Data_Point);同身份只允许一行,重复推送因此天然幂等。 |
| | | record_key VARCHAR(191) NOT NULL, |
| | | -- 厂商来源定位(库/表),供回放与现场排查,不参与判定。 |
| | | source_ref VARCHAR(191) NULL, |
| | | device_time_utc DATETIME(3) NULL, |
| | | device_time_raw VARCHAR(64) NULL, |
| | | timezone_id VARCHAR(64) NULL, |
| | | cycle_no INT NULL, |
| | | step_no INT NULL, |
| | | relative_time_seconds DECIMAL(20,3) NULL, |
| | | -- 测量列按厂商给的物理单位原样落库(22 所不做缩放);缺列保持为空,不写零代替缺失。 |
| | | voltage_v DECIMAL(30,12) NULL, |
| | | current_a DECIMAL(30,12) NULL, |
| | | charge_capacity_ah DECIMAL(30,12) NULL, |
| | | discharge_capacity_ah DECIMAL(30,12) NULL, |
| | | charge_energy_wh DECIMAL(30,12) NULL, |
| | | discharge_energy_wh DECIMAL(30,12) NULL, |
| | | -- 厂商原始行,按收到的键值原样保留:回放与核对解释逻辑都靠它。 |
| | | payload_json JSON NOT NULL, |
| | | content_sha256 BINARY(32) NOT NULL, |
| | | received_at_utc DATETIME(3) NOT NULL, |
| | | PRIMARY KEY (ingest_id), |
| | | UNIQUE KEY uk_ingest_record (adapter_code, payload_kind, record_key), |
| | | -- 逐行接收按 (设备时间, 协议序号) 取严格晚于水位的数据行,索引列次序与它一致。 |
| | | KEY idx_ingest_channel (adapter_code, payload_kind, devtype, devid, subdevid, chlid, device_time_utc, record_sequence) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci COMMENT='推式来源落地表'; |
| | |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertInstanceOf; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.mockito.Mockito.mock; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.context.annotation.AnnotationConfigApplicationContext; |
| | | import com.battery.device.api.NoopDeviceAdapter; |
| | | import com.battery.device.api.registry.DeviceAdapterRegistry; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | | |
| | | class DeviceAdapterRuntimeNoopStartupTest |
| | | { |
| | |
| | | try (AnnotationConfigApplicationContext context = new AnnotationConfigApplicationContext()) |
| | | { |
| | | context.register(DeviceAdapterRuntimeConfig.class); |
| | | // 推式来源的工厂读本项目的落地表,因此需要它的 Mapper(真实应用里由 MyBatis 提供); |
| | | // 本用例断言的是"没有配置任何设备来源时仍能装配出无设备实现",所以这里给一个空替身。 |
| | | context.registerBean(TDeviceIngestRecordMapper.class, () -> mock(TDeviceIngestRecordMapper.class)); |
| | | context.refresh(); |
| | | DeviceAdapterRegistry registry = context.getBean(DeviceAdapterRegistry.class); |
| | | assertFalse(registry.contains("dev-sim")); |
| New file |
| | |
| | | package com.battery.business.runtime; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertFalse; |
| | | import static org.junit.jupiter.api.Assertions.assertNotNull; |
| | | import static org.junit.jupiter.api.Assertions.assertNull; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | |
| | | import java.io.InputStream; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.sql.Timestamp; |
| | | import java.time.Instant; |
| | | import java.util.Date; |
| | | import java.util.HexFormat; |
| | | import java.util.List; |
| | | import java.util.UUID; |
| | | import org.apache.ibatis.datasource.unpooled.UnpooledDataSource; |
| | | import org.junit.jupiter.api.AfterEach; |
| | | import org.junit.jupiter.api.BeforeEach; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.mybatis.spring.SqlSessionFactoryBean; |
| | | import org.mybatis.spring.SqlSessionTemplate; |
| | | import org.springframework.core.io.ClassPathResource; |
| | | import org.springframework.jdbc.core.JdbcTemplate; |
| | | import org.springframework.jdbc.datasource.init.ScriptUtils; |
| | | import com.battery.business.domain.TDeviceIngestRecord; |
| | | import com.battery.business.domain.vo.TDeviceIngestChannel; |
| | | import com.battery.business.mapper.TDeviceIngestRecordMapper; |
| | | |
| | | /** |
| | | * 推式来源落地表的迁移与落库语义(生产 Mapper + 迁移脚本 + H2 内存库): |
| | | * 来源行身份让重复推送幂等、同身份不同内容不覆盖;心跳行按来源覆盖接收时间; |
| | | * 拓扑上报的通道清单带上自己的最近数据时间;水位按 (设备时间, 协议序号) 取严格更晚的行。 |
| | | */ |
| | | public class PushIngestLandingMigrationContractTest |
| | | { |
| | | private static final String PATH = "db/migration-final/V24__push_ingest_landing_table.sql"; |
| | | private static final String ADAPTER = "j22"; |
| | | private static final Instant RECEIVED_AT = Instant.parse("2026-09-27T09:20:00Z"); |
| | | |
| | | private java.sql.Connection keepAlive; |
| | | private JdbcTemplate jdbc; |
| | | private TDeviceIngestRecordMapper mapper; |
| | | |
| | | @BeforeEach void setUp() throws Exception |
| | | { |
| | | var source = new UnpooledDataSource("org.h2.Driver", |
| | | "jdbc:h2:mem:" + UUID.randomUUID() + ";MODE=MySQL;DATABASE_TO_LOWER=TRUE", "sa", ""); |
| | | keepAlive = source.getConnection(); |
| | | jdbc = new JdbcTemplate(source); |
| | | // H2 无 MySQL 的 HEX();测试中补一个等价别名,生产 SQL 仍在 MySQL 上验证。 |
| | | jdbc.execute("create alias HEX for 'com.battery.business.runtime.PushIngestLandingMigrationContractTest.hexOf'"); |
| | | ScriptUtils.executeSqlScript(keepAlive, new ClassPathResource(PATH)); |
| | | |
| | | SqlSessionFactoryBean factory = new SqlSessionFactoryBean(); |
| | | factory.setDataSource(source); |
| | | factory.setTypeAliasesPackage("com.battery.business.domain"); |
| | | factory.setMapperLocations(new ClassPathResource("mapper/battery/TDeviceIngestRecordMapper.xml")); |
| | | mapper = new SqlSessionTemplate(factory.getObject()).getMapper(TDeviceIngestRecordMapper.class); |
| | | } |
| | | |
| | | @AfterEach void closeDatabase() throws Exception { if (keepAlive != null) keepAlive.close(); } |
| | | |
| | | @Test |
| | | void migrationCreatesTheLandingTableWithoutWritingAnyData() |
| | | { |
| | | String migration = resource(PATH); |
| | | // 迁移只建表:不导入演示数据、不改写任何既有表。 |
| | | assertFalse(migration.matches("(?s).*(INSERT|UPDATE|DELETE|DROP)\\b.*")); |
| | | assertTrue(migration.contains("UNIQUE KEY uk_ingest_record (adapter_code, payload_kind, record_key)")); |
| | | |
| | | assertEquals(0, jdbc.queryForObject("select count(*) from t_device_ingest_record", Integer.class)); |
| | | // 落地表保留厂商原始载荷,且测量列可以缺列(设备没给就是空,不写零代替缺失)。 |
| | | jdbc.update("insert into t_device_ingest_record(adapter_code, payload_kind, record_key, payload_json," |
| | | + " content_sha256, received_at_utc) values(?,?,?,?,?,?)", ADAPTER, "SAMPLE", "k", |
| | | "{\"Voltage\":1.2}", new byte[32], Timestamp.from(RECEIVED_AT)); |
| | | assertEquals(1, jdbc.queryForObject( |
| | | "select count(*) from t_device_ingest_record where voltage_v is null", Integer.class)); |
| | | } |
| | | |
| | | @Test |
| | | void repeatedPushIsIgnoredBySourceRowIdentityAndDifferentContentIsKept() |
| | | { |
| | | TDeviceIngestRecord first = sample("DB_20260927_155209/Ws26_IV_0:12", 12L, "{\"Voltage\":1.2}"); |
| | | assertEquals(1, mapper.insertIngestRecord(first)); |
| | | |
| | | // 同身份同内容:幂等忽略,库里仍是一行。 |
| | | TDeviceIngestRecord repeat = sample("DB_20260927_155209/Ws26_IV_0:12", 12L, "{\"Voltage\":1.2}"); |
| | | assertEquals(0, mapper.insertIngestRecord(repeat)); |
| | | assertEquals(1, jdbc.queryForObject("select count(*) from t_device_ingest_record", Integer.class)); |
| | | |
| | | // 同身份不同内容:内容摘要不同,调用方据此报冲突;既有那一行不被覆盖。 |
| | | TDeviceIngestRecord conflict = sample("DB_20260927_155209/Ws26_IV_0:12", 12L, "{\"Voltage\":1.3}"); |
| | | assertEquals(0, mapper.insertIngestRecord(conflict)); |
| | | assertEquals(HexFormat.of().formatHex(first.getContentSha256()), |
| | | mapper.selectIngestRecordHashByKey(ADAPTER, "SAMPLE", first.getRecordKey())); |
| | | // H2 的 JSON 列会再引一层,所以只断内容;既有那一行没有被冲突推送覆盖。 |
| | | assertTrue(jdbc.queryForObject("select payload_json from t_device_ingest_record", String.class) |
| | | .contains("Voltage")); |
| | | } |
| | | |
| | | @Test |
| | | void heartbeatRowIsUpsertedPerSourceSoAHeartbeatNeverGrows() |
| | | { |
| | | mapper.upsertHeartbeat(heartbeat(RECEIVED_AT)); |
| | | assertEquals(Timestamp.from(RECEIVED_AT).toInstant(), mapper.selectHeartbeatAt(ADAPTER).toInstant()); |
| | | |
| | | // 下一轮心跳只推进接收时间:它表示"取数程序还在推",不是设备事实,没有保留历史的意义。 |
| | | Instant later = RECEIVED_AT.plusSeconds(60); |
| | | mapper.upsertHeartbeat(heartbeat(later)); |
| | | assertEquals(Timestamp.from(later).toInstant(), mapper.selectHeartbeatAt(ADAPTER).toInstant()); |
| | | assertEquals(1, jdbc.queryForObject("select count(*) from t_device_ingest_record", Integer.class)); |
| | | // 从未收到推送的来源没有心跳事实。 |
| | | assertNull(mapper.selectHeartbeatAt("other")); |
| | | } |
| | | |
| | | @Test |
| | | void topologyChannelsCarryTheirOwnLatestSampleTimeAndNeverExpire() |
| | | { |
| | | mapper.insertIngestRecord(topology(26)); |
| | | mapper.insertIngestRecord(topology(18)); |
| | | // 18 号只有更早的采样行;26 号有两行。 |
| | | mapper.insertIngestRecord(sample(26, "DB_20260927_155209/Ws26_IV_0:11", 11L, "{\"Voltage\":1.2}", |
| | | Instant.parse("2026-09-27T07:52:20Z"))); |
| | | mapper.insertIngestRecord(sample(26, "DB_20260927_155209/Ws26_IV_0:12", 12L, "{\"Voltage\":1.3}", |
| | | Instant.parse("2026-09-27T07:53:20Z"))); |
| | | mapper.insertIngestRecord(sample(18, "DB_20260923_161840/Ws18_IV_0:5", 5L, "{\"Voltage\":1.1}", |
| | | Instant.parse("2026-09-23T08:18:40Z"))); |
| | | |
| | | List<TDeviceIngestChannel> channels = mapper.selectTopologyChannels(ADAPTER); |
| | | assertEquals(List.of(18, 26), channels.stream().map(TDeviceIngestChannel::getChlid).toList()); |
| | | assertEquals(Instant.parse("2026-09-23T08:18:40Z"), channels.get(0).getLastDataAt().toInstant()); |
| | | assertEquals(Instant.parse("2026-09-27T07:53:20Z"), channels.get(1).getLastDataAt().toInstant()); |
| | | // 没有采样行的通道不给"最近数据时间":这个事实是观测到的,不按上报时间代替。 |
| | | mapper.insertIngestRecord(topology(21)); |
| | | assertNull(mapper.selectTopologyChannels(ADAPTER).stream() |
| | | .filter(channel -> channel.getChlid() == 21).findFirst().orElseThrow().getLastDataAt()); |
| | | } |
| | | |
| | | @Test |
| | | void samplesSinceReturnsOnlyRowsStrictlyAfterTheWatermarkIncludingSameSecondRows() |
| | | { |
| | | TDeviceIngestRecord before = sample(26, "DB_20260927_155209/Ws26_IV_0:11", 11L, "{\"Voltage\":1.2}", |
| | | Instant.parse("2026-09-27T07:52:20Z")); |
| | | TDeviceIngestRecord sameSecond = sample(26, "DB_20260927_155209/Ws26_IV_0:12", 12L, "{\"Voltage\":1.3}", |
| | | Instant.parse("2026-09-27T07:53:20Z")); |
| | | TDeviceIngestRecord sameSecondLaterSequence = sample(26, "DB_20260927_155209/Ws26_IV_0:13", 13L, |
| | | "{\"Voltage\":1.4}", Instant.parse("2026-09-27T07:53:20Z")); |
| | | TDeviceIngestRecord after = sample(26, "DB_20260927_155209/Ws26_IV_0:14", 14L, "{\"Voltage\":1.5}", |
| | | Instant.parse("2026-09-27T07:54:20Z")); |
| | | for (TDeviceIngestRecord row : List.of(before, sameSecond, sameSecondLaterSequence, after)) |
| | | mapper.insertIngestRecord(row); |
| | | |
| | | // 水位落在一行上:那一行自己不算,严格更晚的才进本次接收。 |
| | | List<TDeviceIngestRecord> rows = mapper.selectSamplesSince(ADAPTER, 0, 0, 1, 26, |
| | | Date.from(sameSecond.getDeviceTimeUtc().toInstant()), 12L, 500); |
| | | assertEquals(List.of(13L, 14L), rows.stream().map(TDeviceIngestRecord::getRecordSequence).toList()); |
| | | |
| | | // 同一绝对秒内的多行各自成立:水位为空表示边界时刻什么也没有,之后的行全部属于本次接收。 |
| | | assertEquals(4, mapper.selectSamplesSince(ADAPTER, 0, 0, 1, 26, null, 0L, 500).size()); |
| | | // 其他通道与来源的行不混进来。 |
| | | assertTrue(mapper.selectSamplesSince("other", 0, 0, 1, 26, null, 0L, 500).isEmpty()); |
| | | } |
| | | |
| | | @Test |
| | | void latestSampleDrivesBothRealtimeObservationAndWatermark() |
| | | { |
| | | assertNull(mapper.selectLatestSample(ADAPTER, 0, 0, 1, 26)); |
| | | mapper.insertIngestRecord(sample(26, "DB_20260927_155209/Ws26_IV_0:12", 12L, "{\"Voltage\":1.3}", |
| | | Instant.parse("2026-09-27T07:53:20Z"))); |
| | | TDeviceIngestRecord latest = mapper.selectLatestSample(ADAPTER, 0, 0, 1, 26); |
| | | assertNotNull(latest); |
| | | assertEquals(12L, latest.getRecordSequence()); |
| | | assertEquals("2026-09-27 15:53:20", latest.getDeviceTimeRaw()); |
| | | assertEquals(Instant.parse("2026-09-27T07:53:20Z"), latest.getDeviceTimeUtc().toInstant()); |
| | | // 原始行逐字留在落地表里,回放与核对解释逻辑都靠它(H2 的 JSON 列会再引一层,所以只断内容)。 |
| | | assertTrue(latest.getPayloadJson().contains("Voltage")); |
| | | } |
| | | |
| | | /** 供 H2 别名使用的 HEX 等价实现;H2 没有 MySQL 的 HEX()。 */ |
| | | public static String hexOf(byte[] value) |
| | | { |
| | | return value == null ? null : HexFormat.of().formatHex(value); |
| | | } |
| | | |
| | | /** 内容摘要固定 32 字节:列是 BINARY(32),短值会被补齐,比较时对不上。 */ |
| | | private static byte[] digest(String content) |
| | | { |
| | | try |
| | | { |
| | | return java.security.MessageDigest.getInstance("SHA-256") |
| | | .digest(content.getBytes(StandardCharsets.UTF_8)); |
| | | } |
| | | catch (Exception exception) |
| | | { |
| | | throw new IllegalStateException(exception); |
| | | } |
| | | } |
| | | |
| | | private TDeviceIngestRecord sample(String recordKey, long sequence, String payload) |
| | | { |
| | | return sample(26, recordKey, sequence, payload, Instant.parse("2026-09-27T07:53:20Z")); |
| | | } |
| | | |
| | | private TDeviceIngestRecord sample(int channel, String recordKey, long sequence, String payload) |
| | | { |
| | | return sample(channel, recordKey, sequence, payload, Instant.parse("2026-09-27T07:53:20Z")); |
| | | } |
| | | |
| | | private TDeviceIngestRecord sample(int channel, String recordKey, long sequence, String payload, |
| | | Instant deviceTime) |
| | | { |
| | | TDeviceIngestRecord value = new TDeviceIngestRecord(); |
| | | value.setAdapterCode(ADAPTER); |
| | | value.setPayloadKind(TDeviceIngestRecord.KIND_SAMPLE); |
| | | value.setDevtype(0); value.setDevid(0); value.setSubdevid(1); value.setChlid(channel); |
| | | value.setRecordSequence(sequence); |
| | | value.setRecordKey(recordKey); |
| | | value.setSourceRef("DB_20260927_155209/Ws26_IV_0"); |
| | | value.setDeviceTimeUtc(Timestamp.from(deviceTime)); |
| | | value.setDeviceTimeRaw("2026-09-27 15:53:20"); |
| | | value.setTimezoneId("Asia/Shanghai"); |
| | | value.setPayloadJson(payload); |
| | | value.setContentSha256(digest(payload)); |
| | | value.setReceivedAtUtc(Timestamp.from(RECEIVED_AT)); |
| | | return value; |
| | | } |
| | | |
| | | private TDeviceIngestRecord topology(int channel) |
| | | { |
| | | TDeviceIngestRecord value = new TDeviceIngestRecord(); |
| | | value.setAdapterCode(ADAPTER); |
| | | value.setPayloadKind(TDeviceIngestRecord.KIND_TOPOLOGY); |
| | | value.setDevtype(0); value.setDevid(0); value.setSubdevid(1); value.setChlid(channel); |
| | | value.setRecordSequence(0L); |
| | | value.setRecordKey("topology:" + channel); |
| | | value.setPayloadJson("{\"channel\":" + channel + "}"); |
| | | value.setContentSha256(digest("topology:" + channel)); |
| | | value.setReceivedAtUtc(Timestamp.from(RECEIVED_AT)); |
| | | return value; |
| | | } |
| | | |
| | | private TDeviceIngestRecord heartbeat(Instant receivedAt) |
| | | { |
| | | TDeviceIngestRecord value = new TDeviceIngestRecord(); |
| | | value.setAdapterCode(ADAPTER); |
| | | value.setPayloadKind(TDeviceIngestRecord.KIND_HEARTBEAT); |
| | | value.setDevtype(0); value.setDevid(0); value.setSubdevid(1); value.setChlid(0); |
| | | value.setRecordSequence(0L); |
| | | value.setRecordKey("heartbeat"); |
| | | value.setPayloadJson("{}"); |
| | | value.setContentSha256(new byte[32]); |
| | | value.setReceivedAtUtc(Timestamp.from(receivedAt)); |
| | | return value; |
| | | } |
| | | |
| | | private static String resource(String path) |
| | | { |
| | | try (InputStream stream = PushIngestLandingMigrationContractTest.class.getClassLoader() |
| | | .getResourceAsStream(path)) |
| | | { |
| | | if (stream == null) throw new IllegalStateException("缺少迁移文件:" + path); |
| | | return new String(stream.readAllBytes(), StandardCharsets.UTF_8); |
| | | } |
| | | catch (java.io.IOException exception) |
| | | { |
| | | throw new IllegalStateException("无法读取迁移文件:" + path, exception); |
| | | } |
| | | } |
| | | } |
| New file |
| | |
| | | package com.battery.business.web.device; |
| | | |
| | | import static org.junit.jupiter.api.Assertions.assertEquals; |
| | | import static org.junit.jupiter.api.Assertions.assertTrue; |
| | | import static org.mockito.ArgumentMatchers.any; |
| | | import static org.mockito.Mockito.mock; |
| | | import static org.mockito.Mockito.when; |
| | | |
| | | import java.lang.reflect.Method; |
| | | import org.junit.jupiter.api.Test; |
| | | import org.springframework.test.web.servlet.MockMvc; |
| | | import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; |
| | | import org.springframework.test.web.servlet.result.MockMvcResultMatchers; |
| | | import org.springframework.test.web.servlet.setup.MockMvcBuilders; |
| | | import com.ruoyi.common.annotation.Anonymous; |
| | | import com.ruoyi.common.exception.ServiceException; |
| | | import com.battery.business.service.DeviceAdapterErrorCodes; |
| | | import com.battery.business.service.DeviceIngestErrorCodes; |
| | | import com.battery.business.service.IDeviceIngestService; |
| | | import com.battery.business.service.IDeviceIngestService.IngestResult; |
| | | |
| | | /** |
| | | * 接收端点的应答契约:拒绝一律用非 2xx 状态码——取数程序只在收到 2xx 后才推进自己的读取位置, |
| | | * 所以"响应 200 但体内是错误码"是不能接受的:那会让程序把没落库的数据当成已送达。 |
| | | */ |
| | | class DeviceIngestControllerTest |
| | | { |
| | | private final IDeviceIngestService ingest = mock(IDeviceIngestService.class); |
| | | private final MockMvc mvc = MockMvcBuilders.standaloneSetup(new DeviceIngestController(ingest)).build(); |
| | | |
| | | @Test |
| | | void acceptedBatchAnswersOkSoTheProgramMayAdvanceItsWatermark() throws Exception |
| | | { |
| | | when(ingest.ingest(any(), any(), any())).thenReturn(new IngestResult(2, 0, 0)); |
| | | |
| | | mvc.perform(MockMvcRequestBuilders.post("/device-ingest/j22") |
| | | .header(DeviceIngestController.TOKEN_HEADER, "token").content("{}")) |
| | | .andExpect(MockMvcResultMatchers.status().isOk()) |
| | | .andExpect(MockMvcResultMatchers.jsonPath("$.accepted").value(2)); |
| | | } |
| | | |
| | | @Test |
| | | void rejectionsAnswerWithNonOkStatusesSoTheProgramKeepsItsPosition() throws Exception |
| | | { |
| | | assertStatus(DeviceIngestErrorCodes.TOKEN_INVALID, 401); |
| | | assertStatus(DeviceIngestErrorCodes.SOURCE_DISABLED, 403); |
| | | assertStatus(DeviceAdapterErrorCodes.UNSUPPORTED_TYPE, 403); |
| | | assertStatus(DeviceIngestErrorCodes.PAYLOAD_INVALID, 400); |
| | | assertStatus(DeviceAdapterErrorCodes.NOT_FOUND, 404); |
| | | assertStatus(59999, 500); |
| | | } |
| | | |
| | | /** 端点匿名放行(取数程序没有登录态),凭据由请求头里的来源令牌自己校验。 */ |
| | | @Test |
| | | void endpointIsAnonymousAndAuthenticatesWithTheSourceTokenHeader() throws Exception |
| | | { |
| | | Method receive = DeviceIngestController.class.getMethod("receive", String.class, String.class, String.class); |
| | | assertTrue(DeviceIngestController.class.isAnnotationPresent(Anonymous.class)); |
| | | assertEquals("X-Device-Ingest-Token", DeviceIngestController.TOKEN_HEADER); |
| | | assertTrue(receive.getParameters()[1].toString().contains("token")); |
| | | } |
| | | |
| | | private void assertStatus(int code, int expected) throws Exception |
| | | { |
| | | // 用 doThrow/when 形式重新打桩:直接调 mock 方法会被上一次的桩立即抛异常。 |
| | | org.mockito.Mockito.doThrow(new ServiceException("拒绝", code)).when(ingest).ingest(any(), any(), any()); |
| | | |
| | | mvc.perform(MockMvcRequestBuilders.post("/device-ingest/j22").content("{}")) |
| | | .andExpect(MockMvcResultMatchers.status().is(expected)); |
| | | } |
| | | } |
| New file |
| | |
| | | #Requires -Version 5.1 |
| | | <# |
| | | 22 所检测柜数据的只读取数程序 —— 运行在 22 所那台机器上 |
| | | ========================================================================== |
| | | 它做的全部事情(甲方五分钟即可审完): |
| | | |
| | | 1. 用 Windows 认证连**本机** SQL Server(Integrated Security)。这个文件里没有任何口令, |
| | | 网络上也不传任何厂商库凭据——认证在那台机器内部完成。 |
| | | 2. 只执行固定的 SELECT。不写、不建、不改,不调用存储过程。 |
| | | 3. 把读到的行**原样**推到我们的接收端点(HTTP,令牌放在请求头里):不做单位换算、 |
| | | 不改列名、不做业务判断。解释(身份映射、时间换算、幂等键)全部在后端完成。 |
| | | 4. 只有收到 **2xx** 应答之后才推进自己对该表的读取位置。推送失败或没应答时, |
| | | 整批下一轮重推;端点按来源行身份幂等,重推不会产生重复记录。 |
| | | 5. 不开任何入站端口,只发起出站连接。 |
| | | |
| | | 查询纪律(SQL 为什么写成这样): |
| | | * 所有命令都经同一个 Invoke-Query,它给**每一条**命令设 CommandTimeout;连接串里设 |
| | | Connect Timeout。SQL Server 服务端没有查询超时——客户端不设就是无限, |
| | | 所以两处都必须显式设置,且代码里不存在绕过它的查询路径。 |
| | | * 不加锁读、不长事务:全文没有 FOR UPDATE / HOLDLOCK / XLOCK / UPDLOCK / TABLOCK, |
| | | 没有 BEGIN TRAN,也不读会长期持有的视图。每条语句即取即断。 |
| | | * 增量条件是 record_time > @since,且**刻意不排序**:对没有索引的列排序会让服务端 |
| | | 把整张表排序一遍,而那台机器是生产机。排序与截断都在本程序里做(客户端侧)。 |
| | | * 唯一一条 order by 落在 sys.databases 这个系统目录视图上,不碰任何测试表。 |
| | | * 活跃库探测读的是 sys.dm_db_index_usage_stats(目录视图,不是测试表;该实例只有 |
| | | 5131 个库、每库一张表,聚合代价有限)。现场可用 sp_help 复核: |
| | | 若发现这轮探测或增量查询的代价偏高,就调整这里的策略。 |
| | | |
| | | 发送的列:厂商 IV 表自己的列,原样。要改列清单只改这一处; |
| | | 后端负责一切解释。 |
| | | |
| | | 交付注意事项:本文件是 **UTF-8 with BOM**。Windows PowerShell 5.1 会把没有 BOM 的 |
| | | UTF-8 按 GBK 解码,中文注释会破坏脚本(报"字符串缺少终止符",或者静默什么都不做、 |
| | | 退出码却是 0)。**重新保存本文件时必须保留 BOM。** |
| | | 另有两条坑:多行脚本不能粘贴执行(一律跑 .ps1 文件);record_time 是 Int64, |
| | | 超过 JavaScript 安全整数范围,必须以字符串传输。 |
| | | #> |
| | | param( |
| | | # 推送目标,例如 http://<服务器>:8080/device-ingest/j22 |
| | | [Parameter(Mandatory = $true)][string]$Endpoint, |
| | | # 该来源的令牌;对应值放在后端进程工作目录的 config/vendor-credentials.properties(不入库) |
| | | [Parameter(Mandatory = $true)][string]$Token, |
| | | |
| | | [string]$SqlInstance = '.\SQLEXPRESS', |
| | | [int]$IntervalSec = 60, |
| | | # 只在第一次看到某张表时生效:从这里往前这么久开始取,而不是读整表历史—— |
| | | # 在生产库上读整表正是查询纪律要避免的 |
| | | [int]$LookbackMinutes = 10, |
| | | [int]$MaxRowsPerChannel = 500, |
| | | [int]$ConnectTimeoutSec = 10, |
| | | [int]$CommandTimeoutSec = 15, |
| | | [int]$HttpTimeoutSec = 30, |
| | | [string]$StateFile = (Join-Path $PSScriptRoot 'j22-collector-state.json'), |
| | | [string]$LogFile = (Join-Path $PSScriptRoot 'j22-collector.log'), |
| | | [switch]$Once |
| | | ) |
| | | |
| | | $ErrorActionPreference = 'Continue' |
| | | |
| | | # ---------------------------------------------------------------- 日志 |
| | | function Write-Log([string]$message) { |
| | | $line = (Get-Date -Format 'yyyy-MM-dd HH:mm:ss') + ' ' + $message |
| | | try { Add-Content -LiteralPath $LogFile -Value $line -Encoding UTF8 } catch { } |
| | | Write-Host $line |
| | | } |
| | | |
| | | # ---------------------------------------------------------------- 读取位置 |
| | | # 读取位置按「库里哪张表」记:"DB_<...>/Ws<N>_IV_0" -> 端点已确认的最后一条 record_time(FILETIME 刻度) |
| | | function Get-ReadPositions { |
| | | if (Test-Path -LiteralPath $StateFile) { |
| | | try { return Get-Content -LiteralPath $StateFile -Raw | ConvertFrom-Json } catch { } |
| | | } |
| | | return New-Object psobject |
| | | } |
| | | |
| | | function Save-ReadPositions($positions) { |
| | | try { $positions | ConvertTo-Json -Depth 4 | Set-Content -LiteralPath $StateFile -Encoding UTF8 } |
| | | catch { Write-Log ('读取位置未能落盘: ' + $_.Exception.Message) } |
| | | } |
| | | |
| | | # ---------------------------------------------------------------- 厂商库访问 |
| | | # 全程序唯一构造 SqlCommand 的地方,所以命令超时不可能被漏掉。所有查询都走这里。 |
| | | # 取值在 reader 打开期间拷出,不留 reader 本身。 |
| | | function Invoke-Query([System.Data.SqlClient.SqlConnection]$connection, [string]$sql, $parameters) { |
| | | $command = $connection.CreateCommand() |
| | | $command.CommandText = $sql |
| | | $command.CommandTimeout = $CommandTimeoutSec |
| | | if ($parameters) { |
| | | foreach ($name in $parameters.Keys) { [void]$command.Parameters.AddWithValue($name, $parameters[$name]) } |
| | | } |
| | | $reader = $command.ExecuteReader() |
| | | try { |
| | | $rows = New-Object System.Collections.ArrayList |
| | | while ($reader.Read()) { |
| | | $values = New-Object object[] $reader.FieldCount |
| | | [void]$reader.GetValues($values) |
| | | [void]$rows.Add($values) |
| | | } |
| | | return $rows |
| | | } |
| | | finally { $reader.Close() } |
| | | } |
| | | |
| | | function Open-VendorConnection { |
| | | $connection = New-Object System.Data.SqlClient.SqlConnection( |
| | | "Server=$SqlInstance;Database=master;Integrated Security=True;Connect Timeout=$ConnectTimeoutSec") |
| | | $connection.Open() |
| | | return $connection |
| | | } |
| | | |
| | | # 候选库:只要最近写入过的。DMV 读不到时退回按建库时间取最近 60 个。 |
| | | # 两条查询都失败就抛出——那说明这一轮**没有读到厂商库**,因此这一轮不许上报心跳。 |
| | | function Find-ActiveDatabases([System.Data.SqlClient.SqlConnection]$connection) { |
| | | $databases = New-Object System.Collections.ArrayList |
| | | $failure = $null |
| | | try { |
| | | $rows = Invoke-Query $connection @" |
| | | select DB_NAME(database_id) as db |
| | | from sys.dm_db_index_usage_stats |
| | | where database_id > 4 and DB_NAME(database_id) like 'DB[_]%' |
| | | group by database_id |
| | | having max(last_user_update) > dateadd(minute, -$LookbackMinutes, getdate()) |
| | | "@ $null |
| | | foreach ($row in $rows) { [void]$databases.Add([string]$row[0]) } |
| | | return $databases |
| | | } |
| | | catch { |
| | | $failure = $_.Exception.Message |
| | | Write-Log (' 活跃库探测失败,退回按建库时间取库列表: ' + $failure) |
| | | } |
| | | |
| | | try { |
| | | $rows = Invoke-Query $connection @" |
| | | select top 60 name from sys.databases where name like 'DB[_]%' order by create_date desc |
| | | "@ $null |
| | | foreach ($row in $rows) { [void]$databases.Add([string]$row[0]) } |
| | | return $databases |
| | | } |
| | | catch { |
| | | throw "vendor database not readable: $failure / " + $_.Exception.Message |
| | | } |
| | | } |
| | | |
| | | function Find-ChannelTables([System.Data.SqlClient.SqlConnection]$connection, [string]$database) { |
| | | $tables = New-Object System.Collections.ArrayList |
| | | $rows = Invoke-Query $connection "select name from [$database].sys.tables where name like 'Ws%[_]IV[_]0'" $null |
| | | foreach ($row in $rows) { [void]$tables.Add([string]$row[0]) } |
| | | return $tables |
| | | } |
| | | |
| | | # ---------------------------------------------------------------- 推送 |
| | | # 只有 2xx 才返回 $true:调用方据此决定是否推进读取位置,所以没被确认的批次整批下一轮重推 |
| | | function Send-Payload([string]$json) { |
| | | try { |
| | | Invoke-RestMethod -Uri $Endpoint -Method Post -Body $json ` |
| | | -ContentType 'application/json; charset=utf-8' ` |
| | | -Headers @{ 'X-Device-Ingest-Token' = $Token } ` |
| | | -TimeoutSec $HttpTimeoutSec | Out-Null |
| | | return $true |
| | | } |
| | | catch { |
| | | $status = '' |
| | | try { $status = [int]$_.Exception.Response.StatusCode } catch { } |
| | | Write-Log (" 推送失败(状态=$status): " + $_.Exception.Message) |
| | | return $false |
| | | } |
| | | } |
| | | |
| | | function Send-Samples([string]$database, [string]$table, [int]$channel, $rows) { |
| | | $payload = [pscustomobject]@{ |
| | | kind = 'SAMPLE' |
| | | database = $database |
| | | table = $table |
| | | channel = $channel |
| | | rows = $rows |
| | | } | ConvertTo-Json -Depth 6 -Compress |
| | | return Send-Payload $payload |
| | | } |
| | | |
| | | function Send-Topology($channels) { |
| | | if ($channels.Count -eq 0) { return $true } |
| | | $rows = @() |
| | | foreach ($channel in $channels) { $rows += [pscustomobject]@{ channel = $channel } } |
| | | $payload = [pscustomobject]@{ kind = 'TOPOLOGY'; rows = $rows } | ConvertTo-Json -Depth 4 -Compress |
| | | return Send-Payload $payload |
| | | } |
| | | |
| | | # 心跳只表示"程序还活着"。它在一轮成功读到厂商库之后才发, |
| | | # 所以厂商库连不上(或那台机器权限/网络有问题)时来源显示离线,而不是"绿色但没数据"。 |
| | | function Send-Heartbeat { |
| | | return Send-Payload '{"kind":"HEARTBEAT"}' |
| | | } |
| | | |
| | | # ---------------------------------------------------------------- 一轮 |
| | | function Invoke-Cycle([System.Data.SqlClient.SqlConnection]$connection) { |
| | | $positions = Get-ReadPositions |
| | | $seenChannels = New-Object System.Collections.ArrayList |
| | | $sentRows = 0 |
| | | |
| | | foreach ($database in (Find-ActiveDatabases $connection)) { |
| | | foreach ($table in (Find-ChannelTables $connection $database)) { |
| | | if ($table -notmatch '^Ws(\d+)_IV_0$') { continue } |
| | | $channel = [int]$Matches[1] |
| | | if (-not $seenChannels.Contains($channel)) { [void]$seenChannels.Add($channel) } |
| | | |
| | | $key = "$database/$table" |
| | | $since = 0L |
| | | if ($positions.PSObject.Properties.Name -contains $key) { $since = [long]$positions.$key } |
| | | else { |
| | | # 第一次看到这张表:从"现在往前 LookbackMinutes"开始,不读整表历史 |
| | | $since = [DateTime]::UtcNow.AddMinutes(-$LookbackMinutes).ToFileTimeUtc() |
| | | } |
| | | |
| | | $rows = @() |
| | | try { |
| | | $read = Invoke-Query $connection @" |
| | | select record_time, Data_Point, Schedule_Index, Step_Index, Cycle_Index, Step_Time, |
| | | Voltage, eCurrent, ChargeCapacity, DischargeCapacity, ChargeEnergy, DischargeEnergy |
| | | from [$database].dbo.[$table] |
| | | where record_time > @since |
| | | "@ @{ '@since' = $since } |
| | | foreach ($row in $read) { |
| | | # record_time 是 Int64,超过 JavaScript 安全整数范围:必须以字符串传输 |
| | | $rows += [pscustomobject]@{ |
| | | record_time = [string]$row[0] |
| | | Data_Point = [int]$row[1] |
| | | Schedule_Index = [string]$row[2] |
| | | Step_Index = [int]$row[3] |
| | | Cycle_Index = [int]$row[4] |
| | | Step_Time = [double]$row[5] |
| | | Voltage = [double]$row[6] |
| | | eCurrent = [double]$row[7] |
| | | ChargeCapacity = [double]$row[8] |
| | | DischargeCapacity = [double]$row[9] |
| | | ChargeEnergy = [double]$row[10] |
| | | DischargeEnergy = [double]$row[11] |
| | | } |
| | | } |
| | | } |
| | | catch { Write-Log (" 读取失败 [$key]: " + $_.Exception.Message); continue } |
| | | |
| | | if ($rows.Count -eq 0) { continue } |
| | | |
| | | # 排序与截断在这里做:服务端不做排序 |
| | | $rows = @($rows | Sort-Object { [long]$_.record_time } | Select-Object -First $MaxRowsPerChannel) |
| | | $highest = [long]$rows[-1].record_time |
| | | |
| | | if (-not (Send-Samples $database $table $channel $rows)) { continue } |
| | | $positions | Add-Member -NotePropertyName $key -NotePropertyValue $highest -Force |
| | | $sentRows += $rows.Count |
| | | Write-Log (" 已推 {0,4} 行 ch={1} {2}" -f $rows.Count, $channel, $key) |
| | | } |
| | | } |
| | | |
| | | # 拓扑:这台机器当前有数据表的通道。端点忽略重复声明,所以每轮都发没有副作用; |
| | | # 它同时是候选"最近数据时间"的来源之外的通道清单(最近数据时间取采样行)。 |
| | | [void](Send-Topology $seenChannels) |
| | | Save-ReadPositions $positions |
| | | return $sentRows |
| | | } |
| | | |
| | | # ---------------------------------------------------------------- 主循环 |
| | | Write-Log '===== 取数程序启动 =====' |
| | | Write-Log "端点 = $Endpoint" |
| | | Write-Log "间隔 = ${IntervalSec}s,回溯 = ${LookbackMinutes}min,实例 = $SqlInstance" |
| | | |
| | | $connection = $null |
| | | while ($true) { |
| | | try { |
| | | if ($null -eq $connection -or $connection.State -ne 'Open') { |
| | | $connection = Open-VendorConnection |
| | | Write-Log '已连上本机 SQL Server(只读,Windows 认证)' |
| | | } |
| | | $sent = Invoke-Cycle $connection |
| | | # 这一轮读到了厂商库:这是"来源还在"的证据 |
| | | if (Send-Heartbeat) { Write-Log "本轮结束,共推 $sent 行" } |
| | | } |
| | | catch { |
| | | Write-Log ('本轮出错: ' + $_.Exception.Message) |
| | | try { if ($connection) { $connection.Close() } } catch { } |
| | | $connection = $null |
| | | } |
| | | |
| | | if ($Once) { break } |
| | | Start-Sleep -Seconds $IntervalSec |
| | | } |