按周工交付的 SEC-Z-01 清理后源码建立 M-Z 主干,并按 Git 仓库惯例重排目录。 - aomin-gateway/ 后端工程 87 文件,原样保留,路径与构建命令未变 - demo/ 演示前端与设备模拟器 9 文件(原 02_前端与设备端),非正式三端 - docs/handover/ 交付与安全清理材料 6 份,内容未改 - docs/contract/ J-00 规格、OpenAPI、内部五接口、状态机、Mock 样例 - docs/reference/ 指纹与校验清单,含 84 个源码文件的 sha256 清单 目录重排只动位置不动内容:源码树 84 文件哈希与交付一致,逐文件自校验通过; 12 条 Flyway 迁移与交付包内镜像逐字节一致。 未纳入:迁移镜像目录(与工程内一套重复,改留指纹)、target/、证据 raw、 应急恢复脚本、评审用虚构台账数据。 安全:SEC-Z-01 15 处明文已清零,导入前重新扫描无新增命中,本仓库无历史污染。 行尾按交付字节原样保存(.gitattributes 设 * -text),不做归一化以免整树摘要失效。
79 KiB
J-00 接入接口规格
版本:V1.8 | 2026-11-11 | 编制:周工
V1.8.1 修订(2026-11-12,补命令节点事实)
上一版只把"回执必须带代次"落死,却漏了另一半:这一轮是谁领的从来没进过库。
claimed_node_id补上之后,一次领取的完整事实在device_command同一行可核:claimed_node_id + claim_generation + claimed_at + lease_expire_at + status。
# 改什么 为什么 1 §3.2 领取时持久化 claimed_node_id(V1.0.28)代次回答"第几轮",节点回答"这一轮是谁"。只有代次时,代次没变的命令,任何知道命令号的调用方都能改写它的流转 2 §3.3 顶层 nodeId必填,且必须与claimed_node_id一致,否则NODE_MISMATCH拒收回执来源必须和领取事实对得上。历史在途行没有节点值 → NODE_UNKNOWN拒收,不猜不伪造(租约到期重派后即有值)3 OpenAPI CommandResult.required加claimGeneration、取消nullable正文说必填、schema 还是 nullable,三端会按旧契约生成,必须同批改 作用边界(写在这里,免得下一轮按安全机制审):
nodeId是 IoT 自报的,能伪造。 它证明的是"领取事实一致",不是"调用方身份"——身份由内网来源白名单 + 签名保证。
V1.8 修订(2026-11-11,兑现 V1.7.2 的预告 + 一处降级)
# 改什么 为什么 1 §3.3 I-05 的 claimGeneration必填(V1.7.2 只说"建议")预告兑现。服务端已改为缺了直接拒( rejected+ 原因CLAIM_GENERATION_REQUIRED),不再"拿库里当前值顶上"——那个回退等于让代次校验自己失效2 §2.1 receivedAt降为参考值:落iot_event.received_at_remote,判据一律用业务层落库时自打的receive_time"平台收到这一帧的时刻"不该由外部填。旧实现直接采信,IoT 传个旧时间就能让一条真实告警在扫描里"永远不新鲜"。V1.0.27 起业务层自己打 NOW(3)形态说明(免得按集群口径再问一遍):本项目一期不是集群——同一个业务层只有一套, IoT 可以多实例但各自只领自己连的设备(I-18 的
gatewayIds由 IoT 报),MQTT 的 IoT 不会领 TCP 设备的命令。 代次防的只有一件事:同一条可延迟命令(deliveryMode=DEFERRED)租约到期回收重派后, 第一轮的迟到回执把第二轮的流转覆盖掉。实时命令(重启、布撤防)领一次就完,代次恒为 1。
V1.7.2 修订(2026-11-11,2 处)
# 改什么 为什么 1 §3.2 I-18 响应新增 claimGeneration(领取代次)一条命令可能被领走两次(租约 60 秒到期没回话 → 回收成 PENDING→ 被另一个节点领走)。第一个节点的回执迟到时,只按commandId落库会把第二次的流转覆盖掉,而且不报错——报表里表现为"同一条命令走了两条路",查不出原因2 §3.3 I-05 请求新增 claimGeneration回带只有回带,服务端才能用 WHERE claim_generation = ?挡住旧回执。不回带时服务端只能拿库里当前值自比,等于没有代次校验兼容性(V1.8 起生效):I-18 必返
claimGeneration;I-05 不回带会被拒——响应计rejected=1,rejectedDetail原因CLAIM_GENERATION_REQUIRED。带旧代次不拒但不采纳(更新 0 行,计duplicated,状态不被迟到回执改写)。 代码与用例:cmd_chain_test.py用例 16–19(I-18 返回 1 → 回收重派后 2 → 旧代次被挡 → 新代次生效)。
V1.7.1 修订(2026-11-10,1 处,需接入段确认后生效)
# 改什么 为什么 1 I-01 的 rawRef从「否」改为「是」,并取消「墓碑帧为null」的例外V1.7 当时设想墓碑行来自 undelivered[]数组的一项、没有独立帧。实际实现是 IoT 把它展开成一条独立 I-01 事件(eventType=MSG_UNDELIVERED)上报,因此它和普通事件一样有自己的原始字节,rawRef完全算得出来墓碑帧填哪个
rawRef:填承载这次undelivered[]的网关上行帧的摘要。一条网关报文里有 N 个undelivered,就是 N 条墓碑行共享同一个rawRef——多对一,合法。 墓碑行的身份是sensor_id + seq(MsgUndelivered按这两个去重),不是rawRef;iot_event的幂等键是eventId,也不是rawRef。所以共享rawRef不影响任何去重逻辑,只是把回指链补全。历史数据:V1.7.1 之前已入库的墓碑行为
null(无原始帧可追,不补造)。业务层对null保持兼容,不因缺rawRef拒收。 算法口径(V1.7.2 定稿):MD5(32 位小写 hex)与SHA-256(64 位小写 hex)都接受,按长度识别(^([a-f0-9]{32}|[a-f0-9]{64})$)。约束只有一条:同一条链上 I-01 与 I-12 用同一种,不能一边 MD5 一边 SHA-256。存储层两列已放宽到varchar(64) utf8mb4_bin,选哪种都能落库。 对应 WBS 单元:J-00-U1(SPEC) 上游依据:《奥敏平台总体架构与 S 线一期实施规范 V1.0 定稿版》(2026-11-04) §4.2 信封字段规则表(本文的上级口径,冲突时以定稿为准并回改本文);《守望系统设计文档 V1.7》§6.2–§6.13;《守望数据库设计 V1.10》iot_event/ingest_raw_log/adapter_in_raw/device/device_command/unregistered_device
V1.7 对齐定稿 V1.0:改 9 条、留 4 条差异、1 条提请修订定稿。接口集合、路径、字段名、批量大小一个没动;另有 3 处结构性补充(§2.7 差异表、§2.6 Base64 口径、§9 给陈工/黄工/谢工的事项)。
2026-11-06 补记(DB V1.10 到手后):四处差异的性质变了——核对后确认是**《数据库设计 V1.10》明确选了另一条路并写了理由**(
iot_event分区+p1900哨兵、link_quality_event全册不存在),不是我方实现走样。§2.7 已按此重写,责任归「定稿与 DB 待对齐」,接入层只跟 DB 走。同时 DB 依据由 V1.9 升至 V1.10,附录 B 的协议代号补上「≤16 字符」约束。
# 改什么 依据(定稿 V1.0 行号) 1 seq从「仅 G 线,H/S 线传 null」改为「铠湾序列号、bignbiot 帧号及其他协议序号分别映射;不得写成 S 线一律 null」§4.2 字段表 seq行2 rawRef加「新接口优先 SHA-256」;MD5 仍可用,32 位小写 hex 口径不变§4.2 字段表 rawRef行3 eventType从「枚举外不拒收」细化成「映射UNKNOWN并保留原始码,不静默拒收」§4.2 字段表 eventType行4 命令状态:领取后是 CLAIMED(已领取/租约中),不得写成「已下发」§4.2 末「命令模块」段;V1.0 定稿 L378 5 I-02 批量上限写明 500 条/批(I-01/I-12 仍 200) §4.2 接入层第 1 条 6 sourceChannel加注「不承担具体物理通路,与linkType分列」§4.2 字段表 sourceChannel行7 protocolVersion加「豪恩不得再写tcp-1.10」§4.2 字段表 protocolVersion行8 附录 B 协议清单重写:删「C 契约」残留,改按五个编解码插件列(kaiwan_v3/bignbiot/mqtt/aep/mc200) §4.2「协议编解码插件」段 9 I-05 的 resultCode不得把厂家原码直透到标准字段,另存rawResultCode/rawAck§4.2 末「命令下发采用拉取模型」段 保留现状、与定稿不一致的 4 处(按现行实现施工,已记入《提请修订定稿 V1.0》清单,见 §2.7):
差异 定稿要求 现行做法 为什么先不改 ① 墓碑帧落表 写 link_quality_event,标准业务时间字段禁哨兵落 iot_event,event_time='1900-01-01'、落p1900分区link_quality_event建表前不存在(定稿 §7.5 自列「11-12 随基线同步」);过渡期只能这样,否则数据无处可落② 0x04 告警帧落表 同上 落 iot_event,event_type=PRE_ALARM_0X04同上 ③ event_time可空§6.2「 event_ts可为空」NOT NULL且是分区键分区键不能可空(MySQL 硬约束);改可空=改分区策略,属库结构变更 ④ ingest_seq稳定接收顺序§6.2 要求业务层生成 无此列,用自增 id缺列,等加列 1 条提请修订定稿:定稿 §6.6 要求「二进制 Base64、JSON 保留原文,以 encoding 标识来源」。现行与陈工已核定为统一 Base64、不加 encoding 列(选 B),理由见 §2.6。提请把定稿该句改口径,本文不按定稿执行。
V1.6 的改动继续有效(I-04→I-18、
tsSource=3补空规则、幂等键表拆开重写)。
V1.6 修三处,接口集合、路径、字段、枚举、状态机一个没动。
# 修什么 依据 1 I-04→I-18(领任务),I-04废止、永不复用仇总 11-02 回复函 §二: I-04在陈工《系统设计 V1.4》§6.1 是「映射同步回执」,本轮按 J-00 收口后变成「领取命令」,属"旧编号复用于新含义"。与I-13→I-17同一处理方式2 §2.3 补「 tsSource=3且deviceTime为空」的取值规则同上 §二点名的遗留项。原表按字面执行会把 NULL 插进 event_time(NOT NULL 分区键),MySQL 直接报 13643 §1.2 幂等键表拆开重写(I-02/I-12 不再取 eventId)同上 §二。I-02(状态快照)与 I-12(原始报文)报文里没有 eventId字段,照 V1.5 实现算不出键11-03 勘误(v1.6 内修正,不升版):§1.5.1 原写
PRE_ALARM_0X04「要告警」、其下说明原写「前者代表设备在求救」,两处都错。0x04 是哨兵报「我丢了 N 条」的丢失计数,只通知平台运营/运维、不通知家属,用途是到达率指标(第一册 T12「30 天 ≤2%」/T10「≥98%」)。照原文实现,会给家属推一条既没有设备、也没有事件类型的消息。V1.5 的编号订正继续有效:二期预留的诊断包分片上传为
I-17(原I-13在系统设计 V1.4 曾指「网关台账快照」,故废止不复用)。
谁该读哪几章
| 读者 | 必读 | 可跳过 |
|---|---|---|
| 外包(IoT 中间件) | §〇、§一、§三、§四、§五、§六、§七、§八 | §二(它不落库)、§九 |
我方(业务层 /internal 服务端) |
全部,§二是设计与实现依据 | — |
| 陈工(小程序前端 + 库表) | §3.3、§3.4、§九 | §二 |
| 黄工(网关固件) | §3.5 | 其余 |
本文只写契约,不写实现。「报文长什么样、落到哪张表哪一列」在这里;「Controller 怎么拆、事务边界在哪」在各单元的详细设计里,不在这份文件。
〇、这份规格解决什么
中间件(我方,可外包)和业务层(Java)是两套代码、两个开发方,唯一的对话方式就是这几个接口。所以这份文件只写三件事:
- 调什么接口——路径、方向、鉴权、批量、重试;
- 传什么字段——含义、必填性、值从报文的哪里取;
- 落到哪张表哪一列——一条报文变几行、时间字段谁派生、未建档走哪条路。
0.1 四条硬边界(违反任何一条即返工)
| # | 边界 | 具体含义 |
|---|---|---|
| 1 | IoT 不连数据库 | 不持 DB 账号、无连接串。任何 JDBC/ORM/数据库驱动出现即打回 |
| 2 | IoT 不判业务 | 不判设备是否建档、不判该不该重试、不判设备支不支持这条命令。所有帧一律上报,所有结果如实回报 |
| 3 | 只允许 IoT 调业务层 | 单向。业务层不保存 IoT 的地址,不提供回调注册,永不反向调用 |
| 4 | IoT 只报它亲眼看见的事 | 连不上、发出去了、对端回了、回了什么码。业务语义(休眠、不支持、参数非法)由回执原始码或业务层判定 |
第 4 条是 V1.4 新增,也是最容易被违反的一条:让中间件判断它看不见的状态,外包只能靠猜,猜出来的就是假数据。假数据比没有数据更坏,因为它看起来是对的。
0.2 数据怎么走(上行)
┌──────────────────────────────────────────────┐
│ 设备侧(三条线形态不同,都只连中间件) │
│ │
│ G 线 433 子设备 ─433射频→ 4G 网关 ─MQTT→ │
│ S 线 NB / 4G 单品 ────裸 TCP 9902────→ │
│ H 线 厂家云 ───────HTTP 推送──────→ │
└───────────────────────┬──────────────────────┘
│ 三条线汇到同一个中间件
▼
┌──────────────────────────────────────────────┐
│ IoT 中间件(外包,不判身份 / 不判策略) │
│ J-04 帧解析 / J-05 信封组装 │
│ J-06 幂等键 / J-07 批量上传 / J-08 命令 │
└───────────────────────┬──────────────────────┘
│ 上行 POST 推数据 I-01 / I-02 / I-12
▼
┌──────────────────────────────────────────────┐
│ 业务层(Java) │
│ ① 一次 SELECT … WHERE sensor_id IN (…) │
│ ├ 命中 → 写 iot_event │
│ └ 未命中 → 写 unregistered_device │
│ ② EventTimeResolver 派生 event_time │
│ ③ 规则引擎 / 基线 / 派单 │
└──────────────────────────────────────────────┘
三条线都只连中间件,没有任何设备直连业务层。 这是物理事实,不是设计选择:433 子设备没有 IP;S 线单品只会说厂商私有二进制;H 线的厂家云只认一个推送入口。
还有一个容易画错的地方:4G 网关也属于「设备侧」,它连的是中间件,不是业务层。
0.3 指令怎么走(下行)
业务层:用户点【撤防】,目标 = 水浸 3F7A9C21
│
├ ① 查 device:3F7A9C21 的 gateway_dev_id = OWN_GW_A
│ → 路由在这一步算完,结果固化进指令行
│
└ ② INSERT device_command
dev_id = 3F7A9C21 ← 给谁(业务身份)
gateway_dev_id = OWN_GW_A ← 从哪个端点进去
│
▼
IoT:GET /commands/pending?gatewayIds=OWN_GW_A,…
│
├ 用 gateway_dev_id 在自己的连接表里找通道
│ 找不到 → DEVICE_OFFLINE,结束
├ 把 targetId 塞进下行报文载荷,发出去
└ 用 I-05 回报结果
IoT 不知道 3F7A9C21 是水浸还是门磁,也不知道它和 OWN_GW_A 是父子关系。 它眼里只有两个字符串:一个用来找连接,一个用来填载荷。
这就是「关系是业务数据,执行是机械动作」的分界。一旦 IoT 需要知道设备关系,它就得维护一份会过期的副本,两份状态必然对不上——这跟它当初不该缓存白名单是同一个理由。
0.4 谁算什么
| 判断 | 谁做 | 依据 |
|---|---|---|
| 这台设备建没建档 | 业务层 | 落库时本来就要 SELECT device,顺手一个 if |
| 这条命令该发给谁 | 业务层 | 写指令行时查 device.gateway_dev_id |
| 该不该重试 | 业务层 | delivery_mode + 结果 |
| 设备支不支持这条命令 | 业务层 | 能力表,入口就拦,不发出去 |
| 设备是不是休眠 | 业务层 | 回执码 + 供电方式,IoT 分不清休眠和坏 |
| 链路通不通 | IoT | 连接在不在、发没发出去、有没有回执 |
一、接口定义(外包照着写)
1.1 接口清单
上行(IoT 推数据)
| 编号 | 方法 | 路径 | 用途 | 批量上限 |
|---|---|---|---|---|
| I-01 | POST | /internal/iot/v1/events/batch |
设备事件(含墓碑帧) | 200 条/批 |
| I-02 | POST | /internal/iot/v1/device-status/batch |
技术状态快照(在线/电量/CSQ/lastSeq) | 500 条/批 |
| I-12 | POST | /internal/iot/v1/raw-frames |
原始报文批量留底(字段见 §1.9) | 200 条/批 |
下行(IoT 主动来领)
| 编号 | 方法 | 路径 | 用途 | 批量上限 |
|---|---|---|---|---|
| I-18 | GET | /internal/iot/v1/commands/pending |
长轮询领任务 | 50 条/次 |
| I-05 | POST | /internal/iot/v1/commands/result |
批量回报执行结果 | 50 条/批 |
五个接口,全部由 IoT 发起。 业务层一个出站请求都不发。
二期预留,本版不实现:
I-17 POST /commands/{id}/dump(诊断包分片上传)。一期diag_dump只留命令字、实际不下发,这个接口零调用。等set_ota和诊断包真正启用时再补。编号台账(V1.6)
编号 现状 含义 I-01在役 事件批量上报 I-02在役 设备状态快照 I-05在役 命令结果回报 I-12在役 原始报文上报 I-18在役 长轮询领命令(V1.6 起,原 I-04)I-17二期预留 诊断包分片上传 I-03、I-06~I-11、I-13~I-16、I-04废止 不得复用 两条废止理由要分开记,别混成一条:
I-13——在系统设计 V1.4 是「网关台账快照」,该功能取消后号一并作废,诊断包另起I-17。I-04——在系统设计 V1.4 是「映射同步回执」(POST /mapping/ack),本轮按 J-00 收口后该语义消失;但 J-00 自 V1.0 起I-04一直指「领任务」。同一个号在两份文件里指过两件事,按"旧编号不复用"原则一律废止,领任务改用I-18。宁可空号,也不让同一个编号在不同版本里指两件事。
1.2 鉴权:四层 + 幂等键
| 层 | 头 / 机制 | 值 |
|---|---|---|
| 传输 | mTLS | 双向证书,90 天轮换 |
| 身份 | X-Internal-Token |
固定长随机串,配置中心下发 |
| 完整性 | X-Sign |
HMAC-SHA256(token, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body)) |
| 防重放 | X-Timestamp / X-Nonce |
偏差 > 300 秒拒;nonce 在 Redis 留 600 秒 |
| 幂等 | X-Idempotency-Key |
24 小时内重复请求直接返回首次结果 |
幂等键算法定死一个,不要自创:
| 接口 | X-Idempotency-Key |
为什么取这个 |
|---|---|---|
| I-01 | {sourceChannel}-{批内首条 eventId}-{条数} |
I-01 报文里就有 eventId(§1.3),取它 |
| I-02 | {sourceChannel}-{批内首条 sensorId}-{reportedAt}-{条数} |
I-02 是状态快照,报文里没有 eventId(§1.8)。用「设备 + 快照时刻」定键:同一台设备同一时刻的快照重推,必是同一批 |
| I-12 | {sourceChannel}-{批内首条 rawRef}-{条数} |
I-12 是原始报文,报文里也没有 eventId(§1.9)。rawRef 就是 MD5(原始帧字节),重推同一帧必然算出同一个值 |
| I-05 | {nodeId}-{批内首条 commandId}-{条数} |
命令有 commandId(§3.3) |
I-18 |
不带 | 它是 GET 拉取,无 body、不改状态,重复拉取天然幂等(靠租约去重,见 §3.2) |
不得用批次号、时间戳、随机数。 重推同一批必须算出同一个键——用随机值等于每次重推都是一批新数据,幂等白做。
V1.6 修正:V1.5 把
I-01 / I-02 / I-12三个接口的幂等键并成一行「{批内首条 eventId}」,但 I-02 和 I-12 的报文里根本没有eventId这个字段。照 V1.5 实现,这两条链路算不出键,只能现编——一现编,重推就变成新数据,幂等整条失效。仇总 11-02 回复函 §二 点出的「I-02、I-12 幂等键不能取不存在的 eventId」即此。
三处容易做错的地方:
| # | 情形 | 正确做法 |
|---|---|---|
| 1 | I-18 是 GET,没有 body | 签名里的 sha256(body) 取空串的 hash(e3b0c442…b855),不要取字面量 null |
| 2 | I-18 参与签名的 path 含 query |
按发送时的原始路径算,?nodeId=…&wait=30 一并算进去。不带 query 会验签失败 |
| 3 | 重推 | 幂等键必须与首次一致(见上表) |
1.3 事件对象:逐字段
{
"sourceChannel": "OWN_GW",
"events": [
{
"eventId": "3F7A9C21_10231",
"sensorId": "3F7A9C21",
"sourceChannel": "OWN_GW",
"eventType": "DOOR_OPEN",
"deviceTime": null,
"receivedAt": "2026-10-29T10:19:22+08:00",
"tsSource": 3,
"protocolVersion": "gw-1.2",
"seq": 10231,
"payload": { "deviceTypeRaw": 1, "rawEventCode": 1, "batteryMv": 3720, "rssi": -72 },
"rawRef": "9f2c1e7b8a4d6f03c5e9a1b7d4f8c2e6"
}
]
}
| 字段 | 必填 | 说明 |
|---|---|---|
eventId |
是 | 事件级幂等键,≤96 字符,不得含平台时间戳。算法见 §2.4 |
sensorId |
是 | 设备物理标识,必须与 device.sensor_id 完全一致(含厂商前缀)。取值规则见 §1.4,这是全篇最容易出错的一个字段 |
sourceChannel |
是 | OWN_GW / HORN_HOST / HORN_CELL / MIR_CELL。它是业务来源,不承担具体物理通路——哪套协议、哪个监听口进来,由接入侧的 linkType 描述,两者分列,不合并成一个值 |
eventType |
是 | 枚举,取值见 §1.5。枚举外的新值映射 UNKNOWN 并保留原始码(写 payload.rawEventCode),不拒收、不静默丢弃。见 §1.5.5 |
deviceTime |
否 | 设备侧时间,没有就 null。禁止用 receivedAt 顶替——填了等于伪造时源 |
receivedAt |
是 | 平台接收时刻,ISO-8601 带时区 |
tsSource |
是,无默认 | 0 设备原始 / 1 厂家云 / 2 平台接收 / 3 网关打戳。漏赋值接口直接拒收 |
protocolVersion |
是,无默认 | 按具体协议原件填写(铠湾 V3.0/bignbiot 3.0/NB&WIFI V1.6/MQTT 对接协议版本等),厂商改协议后要能回溯。豪恩不得再写 tcp-1.10——那是旧口径,已废止 |
seq |
否 | 协议序号。G 线取 433 帧 count(2 字节);铠湾 V3.0 取帧内序列号;bignbiot 取帧号(1 字节 1–255);其他协议取自带序号。没有就是 null,但不得写成「S 线一律 null」——S 线两套 TCP 都有序号,丢了对不上账 |
payload |
否 | 事件明细。已知字段逐个列,厂商新增字段原样保留,不丢 |
payload.deviceTypeRaw |
是 | 设备原始类型码(G 线 type 字节、S 线 DT、H 线子设备类型)。任何帧都带,含心跳与 UNKNOWN |
payload.rawEventCode |
否 | 设备原始事件码。eventType 落到 UNKNOWN 时必填,其余情况建议带 |
rawRef |
是 (V1.7.1 起) |
原始报文摘要,回指 I-12 的 payload_md5。MD5 为 32 位小写 hex;新接口优先 SHA-256(64 位小写 hex)。摘要只用于关联与幂等,不作鉴权。墓碑帧同样必填:填承载该 undelivered[] 的网关上行帧摘要(一帧可对应多条墓碑行,多对一,合法);仅 V1.7.1 之前已入库的历史行为 null |
批量外层带 sourceChannel 是缺省值,内层优先——同机多通道部署时有用。
1.4 sensorId 归一化规则(★先看这节再写代码)
业务层用 sensor_id 做等值匹配。如果中间件传的标识和库里存的差一个前缀,结果是全场设备都被判成未建档,数据一条都进不了库,而且日志上一切"正常"。
所以前缀由适配器按通道拼,不靠猜:
sourceChannel |
前缀 | sensorId 写法 |
示例 |
|---|---|---|---|
OWN_GW |
无 | 433 帧 byte4–7,大写 8 位 hex | 3F7A9C21 |
HORN_HOST / HORN_CELL |
HORN: |
前缀 + 豪恩子设备 SN,原样不改大小写 | HORN:SN12345678 |
MIR_CELL |
MIR: |
前缀 + IMEI(15 位数字) | MIR:861234567890123 |
三条纪律:
- 不截断、不补零、不转小写。超 64 字符整帧拒收,不静默截断(截断会产生一个"看起来很对"的假设备)。
- 前缀只在适配器加一次。若厂商原始报文里已自带前缀,先剥掉再按上表重新加,避免
HORN:HORN:xxx。 - 上线前必做一次抽样比对:从
device表随机取 20 台在役设备,用其sensor_id反查适配器会生成什么值,逐个对齐。这一步省不得。
1.5 eventType 枚举
1.5.1 事件家族与枚举全表
29 个值,按家族成对出现。 少一个"解除",告警就永远不闭环——界面一直红着,规则引擎也判不出"已恢复"。
| 家族 | 发生 | 解除 / 停止 |
|---|---|---|
| 门磁 | DOOR_OPEN |
DOOR_CLOSE |
| 用水活动 | WATER_ACTIVITY |
WATER_ACTIVITY_STOP |
| 用电活动 | CURRENT_ACTIVITY |
CURRENT_ACTIVITY_STOP |
| 漏水 | WATER_LEAK |
WATER_LEAK_CLEAR |
| 燃气 | GAS_LEAK |
GAS_LEAK_CLEAR |
| 烟感 | SMOKE |
SMOKE_CLEAR |
| 跌倒 | FALL |
FALL_CLEAR |
| 呼救 | SOS |
SOS_CLEAR |
| 防拆 | TAMPER |
TAMPER_CLEAR |
| 电源 | POWER_CUT |
POWER_RESTORE |
单值事件:
| 值 | 语义 | 当前已知来源 |
|---|---|---|
VALVE_CLOSED |
关阀执行结果 | S |
TEMP_HUMIDITY |
温湿度采样 | S |
DEVICE_ONLINE / DEVICE_OFFLINE |
上下线 | G / S / H |
LOW_BATTERY |
低电量 | G / S / H |
HEARTBEAT |
心跳 | G / S / H |
PRE_ALARM_0X04 |
0x04 未送达告警帧(只通知平台运营/运维,不通知家属;分钟级) | G |
MSG_UNDELIVERED |
墓碑行(只算质量证据,不告警) | G |
UNKNOWN |
未知帧,原样转供排查 | 任意 |
「当前已知来源」这一列不作接收校验依据。 它只回答"截至本版本我们确认过哪些线会产生这个事件"。任何线上报任何合法枚举值一律接收——设备以后扩展出新能力是常态。
PRE_ALARM_0X04 与 MSG_UNDELIVERED 必须分开:前者是哨兵自己报「我丢了 N 条」(lost 字段是唯一来源,只通知平台,不通知家属);后者是网关按序号推算出的缺失帧记录(不告警)。混成一个值,要么丢掉设备侧上报的丢失计数,要么天天误报。
PRE_ALARM_0X04的用途是到达率指标,不是用户告警。 第一册 T12「到达率 30 天 ≤2%(排除is_backfill与离线)」与 T10「到达率 ≥98%」是同一个指标的两面;lost是必握手事件(水浸/SOS)丢失的唯一设备侧来源,非握手型(门磁/水振/电流)的丢失由网关 count 断档覆盖。所以它进运营/运维视图,不进家属通知。
1.5.2 命名规范
| 家族 | 格式 | 例 |
|---|---|---|
| 开关型 | XXX_OPEN / XXX_CLOSE |
DOOR_OPEN / DOOR_CLOSE |
| 报警型 | XXX / XXX_CLEAR |
WATER_LEAK / WATER_LEAK_CLEAR |
| 活动型 | XXX_ACTIVITY / XXX_ACTIVITY_STOP |
WATER_ACTIVITY / WATER_ACTIVITY_STOP |
| 通用 | 无主语 | HEARTBEAT / LOW_BATTERY / TAMPER |
DOOR_CLOSE 不用 DOOR_CLEAR——门磁的语义是"开关",不是"报警/解除"。
POWER_RESTORE 不用 POWER_CLEAR——来电是独立发生的正常事件,不是"某个异常解除了"。
1.5.3 G 线 (type, event) 完整映射矩阵
type 和 event 是两个独立维度,必须二维查表。
type |
event=1 触发 | event=2 恢复 | event=0 心跳 | event=3 低电 | event=4 未送达 |
|---|---|---|---|---|---|
| 1 门磁 | DOOR_OPEN |
DOOR_CLOSE |
HEARTBEAT |
LOW_BATTERY |
PRE_ALARM_0X04 |
| 2 水振 | WATER_ACTIVITY |
WATER_ACTIVITY_STOP |
同上 | 同上 | 同上 |
| 3 电流 | CURRENT_ACTIVITY |
CURRENT_ACTIVITY_STOP |
同上 | 同上 | 同上 |
| 4 水浸 | WATER_LEAK |
WATER_LEAK_CLEAR |
同上 | 同上 | 同上 |
| 5 SOS | SOS |
SOS_CLEAR |
同上 | 同上 | 同上 |
两条读法,别弄反:
event = 0 / 3 / 4(心跳、低电、未送达)与 type 无关,落同一个枚举值,所以HEARTBEAT不带主语;event = 1 / 2必须看 type。同是"触发",门磁是开门、水浸是漏水。
只按 type 查会把"不漏了"也映射成"漏水"——这是最容易犯的错误,因为一维写法更短。
1.5.4 deviceTypeRaw 为什么任何帧都要留
不是"0x04 特殊要留",而是所有帧都留,规则写成一条,代码里不用一堆 if。三条理由:
| 场景 | 没有它会怎样 |
|---|---|
| 野设备发帧 | sensorId 库里查不到,不知道是水浸求救还是门磁误报,只能当垃圾扔掉 |
| SN 复用/换绑 | 同一 sensorId 前后是不同设备,靠 type 变化才能发现绑错 |
| 排障 | 现场说"这设备没报警",原文里能看出是 type 配错了还是真没触发 |
1.5.5 遇到枚举外的新值(不拒收)
| 步 | 中间件做什么 |
|---|---|
| 1 | eventType 填 UNKNOWN |
| 2 | 原始事件码必填写入 payload.rawEventCode,完整帧照常进 I-12 |
| 3 | 本地计数 UNKNOWN 占比;同一 sourceChannel + rawEventCode 超过 50 次,产生一条自检告警提示补枚举 |
业务层收到 UNKNOWN 只落库、不进规则引擎、不告警。这样新协议第一天就能全量进库跑起来,补枚举只是把历史数据重新分类,不用重放报文。
1.6 批量、重试、限流
| 项 | 值 |
|---|---|
| 批量上限 | 事件 200 条/批;状态 500 条/批;原文 200 条/批(三档不同,别统一成一个数) |
| 攒批策略 | 满 200 条 或 200ms,先到先发 |
| 免攒批 | SOS、WATER_LEAK、PRE_ALARM_0X04、GAS_LEAK 立即单发,不等批量 |
| 超时 | 请求 10 秒 |
| 重试 | 1s / 5s / 30s / 2m / 10m,共 5 次;仍失败落本地死信并计数 |
| 单设备限流 | 同一 sourceChannel + sensorId 60 帧/分钟,超出丢弃并计 RATE_LIMITED |
| 单通道限流 | 同一 sourceChannel 5000 帧/分钟,超出丢弃并计 RATE_LIMITED |
关于限流:撤销白名单后,野设备也会走 I-01,所以要防的是帧量本身,不是设备身份。一台被拆下来反复上电的网关会持续发心跳,该拦的是频率。限流是初值,联调后按实测调,改配置不改代码。
限流丢弃必须计数,不能静默丢。
1.7 响应与错误
成功(含部分失败):
{
"code": 0,
"accepted": 198,
"rejected": 2,
"duplicated": 3,
"unregistered": 5,
"rejectedDetail": [
{ "eventId": "3F7A9C21_10230", "reason": "SCHEMA_INVALID" }
]
}
reason 只保留两个处置动作,不要按原因做分支:
reason |
含义 | 外包处置 |
|---|---|---|
RATE_LIMITED |
超出 §1.6 限流 | 计数,不重试(重试还是被限) |
SCHEMA_INVALID / UNKNOWN_CHANNEL / SENSOR_ID_INVALID |
字段缺失、通道不认识、标识非法 | 落死信,不重试(重试也不会变) |
也就是说:收到 rejectedDetail 一律不重试,只落死信 + 计数。 需要重试的只有整批失败(网络、5xx),按 §1.6 那五档退避。
unregistered 是计数字段,不是错误。 一批里 5 条设备未建档,业务层已经写进 unregistered_device,接入侧不做任何动作——不重报、不换接口、不缓存。
单条失败不整批回滚:一批 200 条里 1 条非法,其余 199 条照常入库。整批回滚会让接入侧重推全部,重推又撞去重键,最后一条都进不来。
1.8 I-02 状态快照字段
批量上限 500 条/批(I-01 与 I-12 是 200)。三档不同是有原因的:一条状态快照只有一个设备一行的量级,而一条事件要带 payload;统一成一个数会让状态上报被无谓拆批。
| 字段 | 必填 | 说明 |
|---|---|---|
sensorId |
是 | 同 §1.4 规则 |
sourceChannel |
是 | 同上 |
online |
是 | true / false。只表示"最近是否有报文",不表示设备好坏 |
lastSeenAt |
是 | 最后一次收到有效报文的时刻 |
batteryMv |
否 | 毫伏整数。电压不换算成百分比(换算式会变,在业务层) |
csq |
否 | 信号质量 0–31,99 = 未知 |
lastSeq |
否 | 仅 G 线,最后收到的 433 序号 |
lostCnt |
否 | 仅 G 线,窗口内未送达计数,255 表示饱和 |
protocolVersion |
是 | 同 I-01 |
reportedAt |
是 | 快照生成时刻 |
I-02 是并发上报的状态,业务层按 sensorId 覆盖写(谁最新谁覆盖),不做时间排序。它不参与告警判定,只用于界面展示与离线判断的原料。
1.9 I-12 原始报文字段
I-12 是「全集的口子」,不是 I-01 的附件。 所有上行帧都从这里进,其中「能变成业务事件」的那部分,另由 I-01 再报一次。同一帧走两条链路不是重复——一条存档,一条产生业务后果。
设备/厂商云 ──I-12──→ ingest_raw_log(G 线,30 天)/ adapter_in_raw(H·S 线,90 天)
└─I-01──→ iot_event(仅业务事件,25 个月)
为什么必须独立成接口,三条理由按重要性排:
| 层 | 问题 |
|---|---|
| 覆盖不全(决定性) | iot_event 只收「有 eventType 的业务事件」。心跳、鉴权帧、解析失败的未知帧、被限流丢弃的帧根本进不了 I-01,但原文必须留:没有心跳原文,「这台设备还在不在」没有证据;没有未知帧原文,新固件加个字段就永远查不出为什么解析失败 |
| 体积 | 原文表 30 天滚动 3600 万行。整帧塞进 I-01 要转义、要膨胀,能把请求体撑到几 MB,直接毁掉 100ms 的 ACK 目标 |
| 生命周期 | 事件表按月分区、留 25 个月;原文表 30 天删。两张表保留策略差一个数量级,同一条报文里就意味着只能同寿 |
1.9.1 报文
POST /internal/iot/v1/raw-frames
X-Idempotency-Key: OWN_GW-3F7A9C21_10231-2
Content-Encoding: gzip
{
"sourceChannel": "OWN_GW",
"frames": [
{
"rawRef": "9f2c1b7e5a0d4c88b3e6f1a2d5c9b047",
"sensorId": "3F7A9C21",
"sourceChannel": "OWN_GW",
"receivedAt": "2026-10-29T11:05:00.123+08:00",
"rawFrame": "AA0BQ0hJUA==",
"frameBytes": 42,
"gatewayDevId": "OWN_GW_3F7A9C21",
"protocolVersion": "gw-1.2"
}
]
}
1.9.2 逐字段
| 字段 | 必填 | 说明 |
|---|---|---|
rawRef |
是 | 原始帧字节的摘要。MD5 为 32 位小写 hex;新接口优先 SHA-256(64 位小写 hex)。它就是幂等键,也是 I-01 事件里 rawRef 回指的目标(对 ingest_raw_log.payload_md5)。两个接口必须用同一种算法,不能 I-01 用 MD5、I-12 用 SHA-256 |
sensorId |
否(建议带) | 解析出设备标识时带上,规则同 §1.4。未建档设备也要带——有值才追得回是哪个 SN;实在解析不出可留空,原文照样落库 |
sourceChannel |
是 | 决定落哪张表:OWN_GW → ingest_raw_log;HORN_HOST/HORN_CELL/MIR_CELL → adapter_in_raw(厂商原文,归一化之前) |
receivedAt |
是 | 平台收到这一帧的时刻,ISO-8601 带时区。不是设备时间,设备时间只走 I-01 的 deviceTime |
rawFrame |
是 | Base64 字符串,定死这一种表示。理由见 §2.6:十六进制把体积翻倍,而 200 条/批本来就是按字节数撑上限的;payload_md5 对解码后的字节算,两种表示算出来一样,体积差一倍。写「hex 或 Base64」等于没定,两边各选一种,MD5 就永远对不上 |
frameBytes |
否 | 解码后的字节数。用于核对——对不上说明 Base64 编码有问题,这是最省事的一道自检 |
gatewayDevId |
否 | 接入端点标识(网关 SN,或独立设备自身标识)。排障时用来回答「这帧从哪个口进来的」 |
protocolVersion |
是 | 本次解析用的协议版本号,同 I-01。厂商改协议后要能回溯是哪一版解析的 |
1.9.3 三条纪律
rawFrame必须是原始字节。 不允许「先解析成对象再序列化」——那样排障时对不上设备实际发的内容,payload_md5也算不稳,I-01 的rawRef回指就断了。- 解析失败、限流丢弃的帧也要上报。 I-12 的职责是「把所有看见的都留下」,判断谁该被业务处理是 I-01 的事。这里做过滤,等于把事故现场擦掉。
- 单帧失败不整批回滚。 一批 200 帧里 1 帧
rawRef重复(重推),按幂等跳过即可,其余 199 帧照常入库,并在响应里计duplicated。
响应结构与错误码同 §1.7(accepted / rejected / duplicated / rejectedDetail)。I-12 没有 unregistered 计数字段——未建档判定只发生在 I-01。
二、落库映射(外包可跳过;我方 /internal 服务端的设计依据)
2.1 字段 → iot_event 列
| 接口字段 | 表列 | 备注 |
|---|---|---|
eventId |
event_id |
唯一键 uk_event_id(event_id, event_time) |
sensorId |
sensor_id |
与 seq 组唯一键 uk_sensor_seq |
sourceChannel |
source_channel |
|
eventType |
event_type |
允许 UNKNOWN |
deviceTime |
device_time |
可空 |
tsSource |
ts_source |
NOT NULL 无默认,漏传直接拒收 |
receivedAt |
receive_time |
接口叫 receivedAt、库列叫 receive_time,故意的,不是笔误 |
seq |
seq |
仅 G 线 |
payload |
payload |
JSON 原样落;必含 deviceTypeRaw,UNKNOWN 时另必含 rawEventCode |
rawRef |
raw_ref |
回指 ingest_raw_log.payload_md5 |
protocolVersion |
protocol_version |
|
| — | dev_id |
业务层按 sensor_id 查 device 补,接入侧不传 |
| — | household_id |
业务层补,接入侧不传 |
| — | is_backfill |
墓碑/补传/回灌 = 1,其余 = 0 |
| — | lost_cnt |
仅心跳行:该窗口未送达计数 |
| — | processed |
0 待处理 / 1 已处理 |
| (派生) | event_time |
分区键,业务层派生,接入侧不传 |
2.2 一条事件变几行
| 情况 | 落几行 | 说明 |
|---|---|---|
| 普通事件 | 1 行 | is_backfill=0 |
| 0x04 提前告警帧 | 1 行 | event_type=PRE_ALARM_0X04、is_backfill=0;是丢失计数,只通知平台 |
| 墓碑帧 | 1 行 | event_type=MSG_UNDELIVERED、is_backfill=1、event_time='1900-01-01',落 p1900 分区 |
| 一次丢失(G 线) | 2 行 | 墓碑(seq=33)+ 告警帧(seq=34),两行都要写,按 sensor_id+seq 去重只告警一次 |
| 断网补传 / 建档回灌 | 各 1 行 | is_backfill=1,event_time 用报文里的真实时间 |
| 未建档设备的帧 | 不落 iot_event |
落 unregistered_device,见 §2.5 |
2.3 event_time 派生
event_time 是 iot_event 的分区键、NOT NULL——这是本节所有规则的硬约束。所以「没有可信设备时间」不能靠插 NULL 解决,只能落一个值、再把精度降下来。
| 情形 | event_time 落什么 |
device_time |
实际精度(取两者更差) |
|---|---|---|---|
tsSource ∈ {0,1} 且 deviceTime 有值 |
device_time |
原值 | 按 device.ts_precision,最高 A |
tsSource = 3 且 deviceTime 为 null |
receive_time |
NULL | 恒 C |
tsSource = 2(S 线裸 TCP 恒此值) |
receive_time |
恒 NULL | 按 device.ts_precision,恒 B 或 C |
墓碑帧(网关按 count 缺口补出,原始报文里走 undelivered[]) |
恒 1900-01-01 |
恒 NULL | 不适用 |
关于
undelivered[]的说明:那是设备/网关原始报文里的数组,属 IoT 侧与厂家协议之间的事,不是本接口(I-01/I-12)的字段。本接口只有events[],网关补出的墓碑帧在这里表现为eventType=MSG_UNDELIVERED的一条。两者不矛盾,读的时候别把两层的字段混成一件事。
tsSource=3 且 deviceTime 为空——本版新增的规则(仇总 11-02 回复函 §二 点名要关闭的一条):
tsSource=3 是「网关打戳」,指网关替设备打了个时间。但 433 上行帧 17 字节里没有时间字段,网关只能打自己收到帧的那一刻;若网关固件没带这个戳、或厂家没透传,deviceTime 就是 null。
处置三条:
event_time落receive_time,device_time保持 NULL。不能落 NULL(违反 NOT NULL),更不能落1900-01-01——那是墓碑帧的专用值,也是is_backfill=1的判据之一,拿来做「未知」会让墓碑行和未知时间行混成一堆,断档统计直接算错。- 精度强制降 C。判据是
device_time IS NULL AND ts_source = 3,由EventTimeResolver写入时判定,接入侧不参与。C 级按陈工《系统设计 V1.7》§6.11 的禁令使用:不进基线采样、不参加任何「时间间隔」类判定(活动画像、无活动告警、到点巡检全部排除)。 - 界面不得显示为发生时间。
device_time IS NULL的行,按device.ts_precision走 B 显示「约 HH:mm」,走 C 显示「时间未知」,C 级不得给出具体时分。
派生逻辑只在一处实现(EventTimeResolver),接入侧不传这个字段。
2.4 幂等:三档,按有无序号选
| 场景 | eventId 怎么算 |
兜底唯一键 |
|---|---|---|
有 seq(G 线) |
{sensorId}_{seq} |
uk_sensor_seq(sensor_id, seq, event_time) |
| 有厂商报文 ID | {sensorId}_{厂商msgId} |
uk_event_id |
| 两种都没有(H/S 线) | {sensorId}_{sha256(原始帧字节)前16位} |
uk_event_id(内容相同视为同一帧,正是要的语义) |
三档都必须满足一条:重推同一批,eventId 必须不变。 所以 hash 要对原始字节算,不能对"反序列化后再序列化"的 JSON 算——后者字段顺序、空格、数字格式都可能变,重推一次就多一条重复数据。
2.5 未建档设备:用现成的 unregistered_device
V1.4 更正:V1.3 这里编了 4 个不存在的列。V1.4 已有 unregistered_device 表,实际字段是:
| 列 | 用途 |
|---|---|
sensor_id |
报上来的标识 |
source_channel |
哪条线 |
first_seen_at / last_seen_at |
首见 / 末见 |
report_count |
累计帧数 |
last_payload |
最后一帧原文(deviceTypeRaw、rawEventCode 都在里面) |
status |
待认领 / 已认领 / 已停用 |
dev_id |
认领建档后回填 |
处理流程:
收到 I-01(200 条)
│
├ 一次 SELECT sensor_id, dev_id, household_id FROM device WHERE sensor_id IN (…200个…)
│
├ 命中 → 组 iot_event 批量 INSERT
│
└ 未命中 → upsert unregistered_device
同一 sensor_id 24h 内只保留一行,report_count 累加
last_payload 覆盖为最新一帧
→ 响应里计入 unregistered,不返回 rejectedDetail
三条要点:
- 查询是批量的(
IN (…)一次),不是 200 次单查。这是方案能成立的前提,写代码时别退化成循环。 last_payload必须存原始 JSON,认领时靠它判断"这是什么设备"。- 待认领记录不参与任何告警。唯一出口是"有人认领(扫码/人工)→ 建档 → 触发回灌 Job 把历史帧补进
iot_event(is_backfill=1)"。回灌不在本规格范围。
2.6 原文怎么带
| 表 | 谁写 | 从哪来 | 保留 |
|---|---|---|---|
ingest_raw_log |
业务层 | I-12 | 30 天,payload_md5 与事件的 raw_ref 对应 |
adapter_in_raw |
业务层 | I-12(H/S 线归一化前厂商原文) | 90 天 |
字段定义、rawFrame 为什么定死 Base64、哪些帧必须上报——见 §1.9,本节只说落库。
payload_md5 对解码后的原始字节算,与 rawFrame 用什么表示无关;但表示不统一,两边算出来就对不上。
统一 Base64,不加 encoding 列。 定稿 V1.0 曾要求「二进制存 Base64、JSON 保留原文、以 encoding 标识来源」;核对后定为一律 Base64 文本,理由三条:
payload/raw_payload是MEDIUMTEXT + utf8mb4,塞二进制在严格模式报Incorrect string value、非严格模式静默变成?——原文永久损坏还不报错。Base64 是唯一稳妥的存法。- 加
encoding列只是省一次FROM_BASE64(),却让每张表多一列、每个写入点多一个分支,而且那一列的值由写入方自己填,填错了照样读不出来。 - 排障代价可接受:
CONVERT(FROM_BASE64(payload) USING utf8mb4)一行就还原;二进制本来就要HEX(FROM_BASE64(...)),两种场景都要解码,不存在"多花一步"。
提请修订定稿 §6.6 该句口径(已列入《提请修订定稿 V1.0》清单),本文不按定稿执行。
2.7 与定稿 V1.0 的四处差异:分歧在定稿与 DB 之间,不在我方实现
定稿 V1.0 是本文的上级口径,但有四处它要求的做法与数据库设计 V1.10 明确写下的结构相反。这一节把三方的原话摆在一起,读的人不必再猜是哪一方漏了:
| # | 定稿 V1.0 要求 | DB V1.10 明确写的 | 本文怎么执行 | 分歧的性质 |
|---|---|---|---|---|
| ① | 墓碑帧写 link_quality_event,标准业务时间字段禁哨兵 |
墓碑行进 iot_event,event_time='1900-01-01',落 p1900 分区(§4.4 第 1721 行,且给了"分区键不能为 NULL""为什么选 1900 不选 1970"两段论证) |
按 DB:落 iot_event + p1900 |
DB 主动否决了定稿,不是遗漏——link_quality_event 全册 0 次出现 |
| ② | 0x04 告警帧也写 link_quality_event,「不占告警九态」 |
PRE_ALARM_0X04 是 iot_event.event_type 的正式取值(§4.4 第 1687 行注释、第 2030 行「要告警」),与墓碑靠 is_backfill/retry_cnt 区分 |
按 DB:落 iot_event |
同上 |
| ③ | event_ts 可为空 |
event_time DATETIME(3) NOT NULL COMMENT '分区键'(§4.4 第 1700 行) |
按 DB:NOT NULL |
MySQL 分区键不能可空。DB 选了"分区 + 哨兵",定稿选了"可空"——两者只能取一,取定稿就要放弃 iot_event 分区 |
| ④ | 业务层生成稳定接收顺序 ingest_seq |
全册无此列(0 次出现),§4.4 用自增 id 排序 |
按 DB:用 id |
纯缺列,加一列即可,不牵动分区 |
为什么本文这次写得比上一版硬:这四条此前被写成「我方按现行实现施工」,读起来像接入层为了省事没照定稿做。核对 DB V1.10 后可以确认,是数据库设计在 ①②③ 上明确选了另一条路并且写了理由,接入层只是跟着执行。真正的分歧在《定稿 V1.0》与《数据库设计 V1.10》之间,不在接口层。
因此本文的责任边界只有一条:DB 怎么定,我按它写;DB 改了,本文一节跟着改。接入层不对这四条自行选择。
关闭条件(按定稿让步 / 我方让步分开写):
| # | 若按定稿改,要动什么 | 代价 | 建议 |
|---|---|---|---|
| ① ② | 建 link_quality_event 表 + 把已有墓碑行迁过去 + I-01 增加一个落表分支 |
新表 + 一次数据迁移 + 代码分支;收益是"断档记录"与"业务事件"物理分开 | 可做;本文暂不采纳,仍按 DB V1.10 的墓碑行方案施工(见 §2.7) |
| ③ | 放弃 iot_event 分区 → 或改用 received_at 分区 |
4.4 亿行/年的主表放弃按事件时间分区,冷热分离与按月 DROP PARTITION 全部失效 |
建议不定稿让步,改为定稿修订:把"可空"改成"墓碑行用哨兵值" |
| ④ | 加 ingest_seq BIGINT + 回填历史 |
只加列,代价最小;但回填 4.4 亿行不现实,只能新行起算 | 二期做,本期用 id |
四处本文一律按 DB V1.10 施工——数据库结构是已执行的事实,契约必须与可执行的库一致。 是否单开
link_quality_event属二期表结构调整,不改本期接口集合与字段名。
三、指令下行
3.1 两类命令,判断权在业务层
分类在创建时就定死在 delivery_mode 上,不由 IoT 运行时判断:
REALTIME 实时 |
DEFERRED 可延迟 |
|
|---|---|---|
| 例 | 布防/撤防、重启 | 校时、上报配置、ACK 策略、删子设备 |
| 用户在等吗 | 在等,盯着屏幕 | 不等 |
| 晚到 20 分钟 | 有害(人已经出门了才撤防) | 无害 |
| 离线时 | 当场失败,明确告诉用户 | 挂起,等上线再发 |
| 失败后 | 终态,不重排 | 重排,有次数上限和 TTL |
3.2 I-18 拉取命令(长轮询 + 租约)
GET /internal/iot/v1/commands/pending
?nodeId=iot-node-1 IoT 实例标识
&gatewayIds=OWN_GW_A,OWN_GW_B 该实例当前在线的接入端点,逗号分隔,单次 ≤100
&wait=30 长轮询最长挂起秒数,0 = 立即返回
&limit=50 单次最多返回条数
| 参数 | 必填 | 说明 |
|---|---|---|
nodeId |
是 | IoT 实例标识。多实例时业务层按它记租约 |
gatewayIds |
是 | 只传当前在线的接入端点。离线端点的命令因此一条都不会被拉走,天然实现"等上线再发" |
wait |
否 | 默认 30。有新命令时立即返回,不等满 |
limit |
否 | 默认 50,上限 100 |
响应:
{
"nodeId": "iot-node-1",
"commands": [
{
"commandId": "CMD-20261030-000123",
"gatewayId": "OWN_GW_3F7A9C21",
"targetId": "3F7A9C21",
"cmdType": "set_mode",
"deliveryMode": "REALTIME",
"params": { "mode": "ARMED" },
"leaseExpireAt": "2026-10-30T08:46:00+08:00",
"expireAt": "2026-11-02T08:44:00+08:00",
"retryCount": 0,
"claimGeneration": 1
}
],
"hasMore": false
}
gatewayId 是「这条命令从哪个端点进去」,不是「网关 SN」。
| 设备形态 | gatewayId 填什么 |
IoT 怎么找通道 |
|---|---|---|
| 连网关的哨兵 | 网关 SN | MQTT session |
| 独立设备(无绑定关系) | 设备自身标识 | 裸 TCP 连接池 / 云 API |
| H 线(云代理) | 厂家云节点标识 | 云 API |
对 IoT 来说三种完全一样:拿 gatewayId 去自己的连接表里查,查到就发,查不到报 DEVICE_OFFLINE。它不需要知道这个 id 代表网关还是单品,也不需要知道 targetId 和它是什么关系——路由在业务层写指令时就算完了(见 §0.3)。
租约机制(拉走不等于拿走):
| 步 | 动作 |
|---|---|
| 1 | 业务层返回命令时置 CLAIMED(已领取/租约中),lease_expire_at = now + 60s。这一步不能写「已下发」——命令还在 IoT 手里没发出去,写「已下发」等于把「领走」和「发出」两件事的状态混成一个 |
| 2 | IoT 在租约内回报 → 按结果流转(§3.4) |
| 3 | 租约到期没回报 → 自动回 待下发,reclaim_count + 1,retry_count 不变 |
| 4 | reclaim_count 超过 5 → 说明这个 nodeId 反复失联,告警,人工看 |
两个计数器必须分开:retry_count 记"设备侧失败了几次",reclaim_count 记"IoT 拿走了却没回话几次"。混成一个,就分不清是设备坏了还是中间件坏了。
claimGeneration(领取代次,V1.7.2 新增):IoT 必须原样保存,并在 I-05 里回带。
它是"这条命令被领走第几次"。每次 PENDING → CLAIMED 时 +1。
为什么需要:一条命令可能被领走两次——第一次租约 60 秒到期没回话,被自动回收成 PENDING,然后被另一个节点领走。此时第一个节点的回执才迟到。如果只按 commandId 落库,这条旧回执会把第二次的流转覆盖掉,而且不报错、日志里也看不出来。
回带了才能拦住:服务端拿回带值当 WHERE claim_generation = ? 条件,旧代次更新 0 行,计入 duplicated。
V1.8 起不回带直接拒(CLAIM_GENERATION_REQUIRED)——不再"拿库里当前值顶上",那种回退等于让这道校验自己失效。
claimed_node_id(领取节点,V1.8.1 新增):I-18 落库,I-05 必读必比。
代次只回答"第几轮",不回答"这一轮是谁"。没有节点列时,一条代次没变的命令,
任何知道 commandId 的调用方都能改写它的流转状态。所以领取时刻必须把 nodeId 一并写进命令记录
(claimed_node_id + claimed_at,与 claim_generation 同一条 UPDATE),
I-05 的原子更新条件同时约束节点 + 代次 + 允许来源状态。
接口演进:V1.7.2 起 I-18 必返
claimGeneration;V1.8 起 I-05 必带(缺了拒收); V1.8.1 起 I-18 落claimed_node_id、I-05 必须带nodeId且与之一致(不符NODE_MISMATCH)。
3.3 I-05 回报结果:5 个值
POST /internal/iot/v1/commands/result
{
"nodeId": "iot-node-1",
"results": [
{
"commandId": "CMD-20261030-000123",
"result": "ACKED",
"resultCode": "0",
"resultMsg": "OK",
"ackAt": "2026-10-30T08:45:03+08:00",
"rawAck": "020A1F3C…",
"claimGeneration": 1
}
]
}
result |
含义 | IoT 怎么得出 | 业务层处置 |
|---|---|---|---|
ACKED |
设备有回应 | 收到回执帧 | 看 resultCode 分流 |
SENT |
已发出,本协议无回执通道 | 发完就返回 | 置 SENT,设 timeout_at = now+24h,到期无回执置 FAILED |
TIMEOUT |
已发出,等不到回执 | 超时 | 实时=失败;可延迟=重排 |
DEVICE_OFFLINE |
连接不在,没有发出去 | 发前查连接 | 实时=失败;可延迟=重排 |
FAILED |
IoT 自身发送失败 | 编码/写通道异常 | 同上 |
resultCode 是标准字典值,不是厂家原码。 定稿 V1.0 §4.2 末段:resultCode / channel_result 按统一字典填写,厂家原始结果码另存 rawResultCode/rawAck,不得把厂家原码直接透传到标准字段。
| 字段 | 谁填 | 内容 |
|---|---|---|
resultCode |
IoT 翻译后填 | 标准码:0 成功/1 不支持/2 参数非法/3 执行失败/4 设备忙 |
rawResultCode |
IoT 填(新增,可选) | 厂家原始返回码,原样字符串,不解释 |
rawAck |
IoT 填(可选) | 回执原始字节(Base64) |
claimGeneration |
必填(V1.8 起) | 领取时 I-18 给的值,原样回传。服务端用它挡住旧代次的迟到回执;不带直接拒(CLAIM_GENERATION_REQUIRED),不再退化为库里当前值 |
nodeId(顶层) |
必填(V1.8.1 起) | 来源节点标识,必须等于 I-18 领取时落库的 claimed_node_id。不符 → NODE_MISMATCH;历史在途行无节点值 → NODE_UNKNOWN。它不是身份认证(值可伪造),证明的是"领取事实一致" |
原因很直接:厂家原码各不一样(铠湾 08 是水浸、bignbiot 08 是白蚁这种坑在命令侧同样存在),直透到标准列以后,业务层与前端就得按每个厂家各写一套判断,等于把适配器的活摊到全平台。
rawResultCode是 V1.7 新增的可选字段,不填也能跑(null时业务层只看resultCode)。加它零成本:DTO 加一个String字段,Jackson 收不到就是null,老版本 IoT 不受影响。
3.3.1 参考实现(外包照抄这个分支就够了)
send(cmd):
conn = my_connections.get(cmd.gatewayId) # 自己的内存,不查任何设备表
if conn is null:
return DEVICE_OFFLINE # 没发
try:
frame = encode(cmd.type, cmd.targetId, cmd.params)
conn.write(frame)
except EncodeError | WriteError as e:
return FAILED(e) # 发了但发不出去
if not conn.supportAck(cmd.type): # S 线裸 TCP 无回执
return SENT
raw = conn.readAck(cmd.cmdSeq, timeout=5s)
if raw is null:
return TIMEOUT # 发了没回
return ACKED(code=parseCode(raw), msg=raw) # 设备回了,原样搬运
3.3.2 「设备不支持」「设备休眠」这两个状态在哪
它们没有消失,只是不由 IoT 填:
| 场景 | 谁发现 | 填在哪 |
|---|---|---|
| 这型号压根不支持下发 | 业务层入口 | 能力表里没标记 → 命令根本不生成,界面按钮置灰 |
| 设备老固件,真发过去才说不支持 | 设备回执 | ACKED + resultCode='1' |
| 电池设备在休眠 | 设备/网关回执,或干脆没回 | TIMEOUT,业务层按"电池设备 + 可延迟"翻译成「待唤醒」 |
业务层翻译表:
| IoT 回的 | 业务层再补什么 | 界面显示 |
|---|---|---|
ACKED code=0 |
— | 已生效 |
ACKED code=1 |
顺手把该型号的「支持下发」标记摘掉 | 设备不支持(以后不再发) |
ACKED code=2/3 |
— | 参数不合法 / 执行失败 |
TIMEOUT |
电池设备 → 待唤醒(不判失败) | 待送达 |
TIMEOUT |
插电设备 → 真失败 | 设备无响应 |
DEVICE_OFFLINE |
— | 离线,未发送 |
SENT |
— | 已送出(无回执,不做承诺) |
FAILED |
— | 通道异常 |
ACKED code=1 那一行有额外好处:能力表能自愈。老固件不支持时,第一次发过去就被降级,以后不再发——不需要谁去维护一张静态表。
3.4 业务层的命令状态机
七态,英文枚举(定稿 V1.0 §4.2 末段:PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED,由业务层维护)。CLAIMED 与 SENT 必须分开——「IoT 领走了」和「已经发出去了」是两件事,混成一个值,租约回收就判不出该不该重派。
| 状态 | 说明 | 进这个状态的时刻 |
|---|---|---|
PENDING |
待领取 | 创建命令 |
CLAIMED |
已领取/租约中(lease_expire_at 有效) |
IoT 通过 I-18 领走 |
SENT |
已发出(timeout_at 有效) |
I-05 回报 SENT(无回执协议) |
ACKED |
设备回执成功 | I-05 回报 ACKED + resultCode=0 |
FAILED |
失败(终态) | 实时类任意失败;SENT 超 24h 无回执 |
TIMEOUT |
已过期(终态) | 重排 10 次 或 过 expire_at(创建+72h) |
CANCELLED |
已取消(终态) | 用户撤单/设备已解绑 |
迁移状态:已完成(2026-11-10)。device_command.status 原先用中文六值(待下发/已下发/已生效/失败/超时/已取消),「领走」和「发出」被挤在「已下发」一个值里,IoT 侧分不清命令是"被拿走了"还是"真发出去了"。迁移脚本 V1.0.23__command_seven_states.sql 已刷完存量并扩了值域:
| 本文 | 迁移前库值 | 迁移后 |
|---|---|---|
PENDING |
待下发 | PENDING |
CLAIMED |
无此值 | CLAIMED(新增,租约中) |
SENT |
已下发 | SENT |
ACKED |
已生效 | ACKED |
FAILED |
失败 | FAILED |
TIMEOUT |
超时 | TIMEOUT |
CANCELLED |
已取消 | CANCELLED |
是值域迁移,不是加列:
varchar(16)装得下CANCELLED(9 字符),status上的索引不受影响。 迁移同时补了三列:claim_generation(租约代次,每次CLAIMED+1,回执比对不上就按过期丢弃)、raw_result_code(厂家原始结果码,原样存不解释)、raw_ack(回执原始字节)。 现状:库里status已全是英文七态,无中文残留;V1.0.23已进 Flyway 序列(checksum-1404977223)。
┌──────── I-18 拉取 ────────┐
▼ │
PENDING ────> CLAIMED ──I-05 SENT──> SENT ──ACKED code=0──> ACKED(终态)
▲ │ │
│ ├─ 租约到期(60s) ────┼──> 回 PENDING(reclaim+1)
│ │ │
│ ├─ 可延迟 + OFFLINE/TIMEOUT/FAILED
│ │ └──> 回 PENDING(retry+1,退避)
│ │ │
│ └─ 实时 + 任意失败 └─ timeout_at(24h) 到 ──> FAILED(终态)
│ └──> FAILED(终态,当场返回用户)
│
├── retry 超 10 次 或 过 expire_at(72h)──> TIMEOUT(终态)
└── 用户撤单/设备解绑 ──────────────────> CANCELLED(终态)
| 结果 | REALTIME 处置 |
DEFERRED 处置 |
|---|---|---|
ACKED code=0 |
ACKED |
ACKED |
ACKED code≠0 |
FAILED(终态),提示取 resultCode 语义 |
FAILED(终态),最多重排 3 次 |
SENT |
保持 SENT,timeout_at = now+24h;到期无回执置 FAILED |
同 |
TIMEOUT |
FAILED(终态),电池设备提示"待唤醒" |
回 PENDING,retry+1 |
DEVICE_OFFLINE |
FAILED(终态),提示"离线,命令未下发" |
回 PENDING,retry+1 |
FAILED |
FAILED(终态) |
回 PENDING,最多 3 次后终态 |
SENT 不是终态。 V1.3 写"成功待确认(终态),靠快照兜底"——终态就再也改不了了,兜底是空话。正确做法是留在 SENT 并设 24 小时超时,到期由 D-01-U4 扫描置 FAILED。
重排上限:10 次或 72 小时,先到哪个算哪个,置 TIMEOUT。不删除记录,留痕供界面显示"已过期未执行"。
两列两义,别混:expire_at = 创建 + 72h,整体 TTL,到了置 TIMEOUT;timeout_at = SENT + 24h,单次兜底,到了置 FAILED。扫描任务先判 expire_at 再判 timeout_at,同一次只判一个——两条都中时按 TTL 优先,否则同一行会被判两次、状态在两列之间来回跳。
实时命令的等待:接口同步等结果,最长 5 秒(见 §9.4)。超过 5 秒仍无回报,降级返回"指令已送出,结果稍后通知"。
3.5 契约 B 下行硬要求(给黄工,外包按此实现 MQTT 客户端)
| 项 | 要求 | 为什么 |
|---|---|---|
| QoS | 0 | QoS1 + 持久会话时,网关断线期间的 PUBLISH 会进离线队列,20 分钟后上线就执行了——而用户早已被告知"离线,未生效" |
| Retain | false |
命令是一次性动作,不能留在 broker 上给下一个连上来的设备重放 |
| 会话 | 下行命令主题不做离线消息排队 | 同上 |
| 在线判据 | 以 MQTT 连接存活为准,不以"PUBLISH 返回成功"为准 | 在有离线队列的配置下,PUBLISH 成功毫无意义 |
| 重复下发 | 网关侧按 commandId 去重 |
租约回收可能导致同一条命令被下发两次,set_mode 重放还算幂等,reboot 会重启两次 |
最后一条尤其重要:IoT 侧按 commandId 去重只能在单实例内存里做,跨实例要靠业务层的租约。所以网关固件也要能识别重复 commandId。
四、中间件不做什么(写进外包合同)
| 不做 | 为什么 |
|---|---|
| 不连数据库 | 拿不到 DB 账号;任何 JDBC/连接串/密码出现即打回 |
| 不判断设备是否建档 | 不持白名单、不缓存白名单、不查"设备在不在库里"。所有帧一律上报 |
| 不判命令该不该重试 | 只发一次、如实回报。重排还是终态由业务层状态机决定 |
| 不判设备支不支持命令 | 能力表在业务层,入口就拦了,发不出来的命令到不了 IoT |
| 不判设备供电方式 | 分不清休眠和坏。只报 TIMEOUT,怎么解读是业务层的事 |
| 不查设备状态 | 唯一的 GET 是 I-18「领任务」,不是"查这设备合不合法、在不在线"(见 §五) |
| 不判告警、不算基线、不派单 | 全在业务层,中间件只搬数据 |
| 不写业务表 | 落库在业务层,中间件只调接口 |
| 不改写时间 | deviceTime 没有就传 null,不许用接收时间顶替 |
| 不丢未知字段 | 厂商加字段是常态,payload 原样保留 |
| 不静默丢包 | 限流、解析失败、死信,全部计数并暴露指标 |
五、调用方向(合同硬条款)
| 条款 | 内容 |
|---|---|
| 方向 | 只允许 IoT 主动发起 HTTP 请求到业务层 |
| 上行 | 用 POST 推(I-01 / I-02 / I-12 / I-05) |
| 下行 | 用 GET 领(I-18),领到后自己下发,再 POST 回报 |
| 业务层不持有 | IoT 的任何地址、端口、域名、回调 URL |
| 业务层不提供 | 任何形式的"变更推送""回调注册""反向通知" |
| 原因 | IoT 部署位置不定(外包机房、客户内网、NAT 之后),没有固定公网入口 |
| 违反后果 | 一旦要求业务层能反向调用,外包就得暴露公网端口,/internal/** 那套 mTLS 等于白做 |
判断标准:如果某个方案需要业务层知道 IoT 在哪、需要 IoT 开一个端口等业务层敲门,那就是设计错误。
「拉」和「查」的区别,是这一节的全部要点:
| 拉(I-18,允许) | 查(禁止) | |
|---|---|---|
| 语义 | 有活给我干吗 | 这台设备合法吗 / 在线吗 |
| 返回 | 待办任务,拉走就归我 | 状态快照,用来做判断 |
| 判断发生在 | 业务层(给不给这个任务) | 中间件(要不要处理这帧) |
| 违反后果 | — | 中间件持有一份会过期的业务状态,两份数据必然对不上 |
六、外包开工依赖
这不是工期表,是依赖表。 缺哪一项就卡哪一段,其余照常推进。
| 要干的活 | 必须先有 | 谁给 | 缺了会怎样 |
|---|---|---|---|
G 线帧解析(J-04) |
第一册契约 B | 已有 | — |
S 线帧解析(J-04) |
铠湾 V3.0(WTK…END)+ bignbiot 3.0(0x0E00…0x7F)+ NB&WIFI V1.6 |
已有 | tcp-1.10 已废止,不得再用 |
信封组装(J-05)、幂等键(J-06)、攒批(J-07) |
本文 §1.3–§1.6 + 附录 A | 已有 | — |
H 线帧解析(J-03) |
豪恩云推送格式 | 豪恩 | 这条线写不了,只能留空壳 |
| 真跑一次 HTTP 请求 | 测试域名 + mTLS 证书 | 我方 | 代码能写完,但跑不通、没法验证 |
| 自测字段偏移对不对 | Mock 服务(固定返回 {"code":0}) |
我方 | 只能自己造数据,测不出偏移错 |
| 正式联调 | 业务层 /internal 部署完成(M 域) |
我方 | 停在"自测通过" |
命令下发(J-08) |
I-18 / I-05 两个接口 | 我方 | 上行能通,下行跑不起来 |
五个可以立刻开工的单元(纯本地逻辑,不依赖服务端):
J-04帧解析与 CRC —— S 线两套协议文档已到手,G 线契约 B 已定;J-05信封组装 —— 按本文 §1.3 / §1.4 / 附录 A;J-06幂等键生成 —— 按本文 §2.4;J-07攒批与重试 —— 按本文 §1.6;J-08命令下发与回执 —— 按本文 §三。
七、附录 A:适配器映射模板
每条协议一份 YAML,新增协议只加文件,不改本文正文。适配器读配置取值,代码里不写死任何字节偏移。
# adapter/own-gw.yaml
channel: OWN_GW
protocolVersion: gw-1.2
sensorIdPrefix: ""
# 17B 帧:前导4 + ID4 + type1 + 电量1 + event1 + count2 + 标志1 + lost1 + CRC2
fields:
sensorId: { from: frame, path: "hex(bytes[4:8])", upper: true }
eventId: { from: frame, path: "sensorId + '_' + seq" }
tsSource: { const: 3 }
deviceTime: { const: null }
seq: { from: frame, path: "uint16BE(bytes[11:13])" }
payload.deviceTypeRaw: { from: frame, path: "bytes[8]" }
payload.rawEventCode: { from: frame, path: "bytes[10]" }
payload.batteryMv: { from: frame, path: "bytes[9]", note: "单位换算见第一册 §4.3" }
# eventType 必须按 (type, event) 二维查表,不能只看 type
eventType:
by: [deviceTypeRaw, rawEventCode]
map:
"1/1": DOOR_OPEN
"1/2": DOOR_CLOSE
"2/1": WATER_ACTIVITY
"2/2": WATER_ACTIVITY_STOP
"3/1": CURRENT_ACTIVITY
"3/2": CURRENT_ACTIVITY_STOP
"4/1": WATER_LEAK
"4/2": WATER_LEAK_CLEAR
"5/1": SOS
"5/2": SOS_CLEAR
"*/0": HEARTBEAT
"*/3": LOW_BATTERY
"*/4": PRE_ALARM_0X04
fallback: UNKNOWN
unbatchable: [SOS, WATER_LEAK, PRE_ALARM_0X04]
# adapter/mir-nb.yaml
channel: MIR_CELL
protocolVersion: nb-1.6
sensorIdPrefix: "MIR:"
fields:
sensorId: { from: payload, path: "SN" }
eventId: { from: payload, path: "sensorId + '_' + sha256(rawFrame)[0:16]" }
tsSource: { const: 2 }
deviceTime: { const: null }
seq: { const: null }
payload.deviceTypeRaw: { from: payload, path: "DT" }
payload.rawEventCode: { from: payload, path: "AR" }
# 具体码值按 NB 协议 V1.6 的 DT + 报警位逐项填,模式同 own-gw.yaml
eventType:
by: [deviceTypeRaw, rawEventCode]
map:
# "<DT>/<报警位>": <eventType>
# 门磁、水浸、烟感、燃气、SOS 各配"发生/解除"两项
fallback: UNKNOWN
四条纪律:
fallback: UNKNOWN是默认配法,不是可选项。映射表没覆盖到的值不能抛异常、不能丢帧。eventType必须二维查表(设备类型 × 事件码)。- 每个适配器启动自检:加载 YAML → 校验必填字段齐全(
sensorId/tsSource/protocolVersion/eventType/payload.deviceTypeRaw/receivedAt)→ 缺任一则启动失败,不允许带病运行。 - 上线前跑样例帧:用协议文档里的真实样例帧跑一遍,断言每个必填字段都取到了值;取不到就报出字段名和偏移。
八、附录 B:当前已接入协议清单
按编解码插件列,不按"线"列。 定稿 V1.0 §4.2 定了五个插件,每个插件一个独立模块,输出同一个 J-00 事件信封。加协议=加一个插件,不改信封、不改接口。
| 插件 | 协议 | 供应商 | 设备型号 | 入口 | sourceChannel |
protocolVersion |
状态 |
|---|---|---|---|---|---|---|---|
kaiwan_v3_decoder |
铠湾智能无线终端通信协议 V3.0(帧头 0x57544B=WTK,CRC16-XMODEM) |
麦乐克 | 按 S 线选型 | 裸 TCP 24002 | MIR_CELL |
WTK-V3.0 |
可开工(端口待安全评审冻结) |
bignbiot_decoder |
麦乐克 物联网云平台设备协议 3.0(帧头 0x0E00,帧尾 0x7F,无 CRC) |
麦乐克 | MIR-WA301 等 | 裸 TCP 24003 | MIR_CELL |
bignbiot-3.0 |
可开工(文件名 3.0/正文到 2.0,待供应商确认) |
mqtt_decoder |
豪恩 MQTT 对接协议(JSON 负载,逐型号 topic 与字段) | 豪恩 | LH-608CT1/LH-302CT1/HO-01LCT1 | MQTT TLS 8883 | HORN_HOST |
按各型号协议文件版本 | 方案已定,待豪恩书面确认 Broker 地址可配 |
aep_adapter |
豪恩 NB 三款,经电信 AEP 平台推送(DT=Dmg/Hmt/Gas),按 NB&WIFI V1.6 解析 | 豪恩 | HO-032NB(II)/LH-336NB/JT-LH-806NB | HTTP 回调/订阅 | HORN_CELL |
nb-1.6 |
待 AEP 回调参数 |
mc200_callback_adapter |
MC200 平台 HTTP 推送(LwM2M 物模型,按物联网卡归属路由 OneNET/AEP) | MC200 | 按选型 | HTTP 回调 | HORN_CELL |
待定 | 阻塞(平台、物模型、回调均待确认) |
| (无插件) | 契约 B:自研 433 四件套 + 自研网关(17B 帧,前导 4 + ID 4 + type 1 + 电量 1 + event 1 + count 2 + 标志 1 + lost 1 + CRC 2) | 自研 | G 线四件套 | MQTT TLS 8883 | OWN_GW |
gw-1.2 |
可开工 |
protocol_version 的取值就是本表最后一列的「协议代号」,另有两条硬约束(DB V1.10 §1.5.11 第 3 条):
- ≤16 字符:列宽
VARCHAR(16),与iot_event.protocol_version一致;那列在 4.4 亿行/年的分区大表上,不能为加宽去MODIFY COLUMN。bignbiot-v3.0.2已 15 字符贴边,厂商再升一版就写不进去,所以用短代号bignbiot-3.0。 - 必须写协议代号,不写厂商产品名:
tcp-1.10之所以废止,就是它既是旧口径、又答不出"哪家的哪一版"。
已废止、不得恢复的旧口径:0x5D6C 豪恩 V1.10 帧头、0x7B/0x5B 跌倒 JSON 帧头、horn_decoder、fall_json_decoder、"C 契约"。tcp-1.10 这个 protocolVersion 值同废止——它是旧版把豪恩 TCP 与 S 线混在一起时的产物,现在每条线各有各的协议原件。
S 线两套 TCP 同属 MIR_CELL,靠 link_type(落 adapter_in_raw)区分,不靠 sourceChannel。别按 sourceChannel 猜协议:麦乐克两套帧格式完全不同,同一个 sourceChannel 底下压着两种解释(铠湾 08 是水浸、bignbiot 08 是白蚁)。
三个入口原则(定稿 §4.2 第 1 条):24002 / 24003 是我方规划端口,由部署与安全评审后冻结,不是全部设备的统一入口;未知协议走 I-12 留原文不拒收;非法帧留档+计数。
H 线与 MC200 阻塞不影响 G/S 开工——各插件互不依赖,共用的只有信封组装和五个接口。
九、给我方与配合方的事项
9.1 给陈工(库表与小程序侧)
| # | 事项 | 需要他做什么 |
|---|---|---|
| 1 | sensor_id 前缀规则写死(§1.4) |
确认 device.sensor_id 的存储值与 §1.4 完全一致。这一条不一致会导致全场设备判成未建档,是风险最高的一处 |
| 2 | eventType 扩到 29 个值(§1.5.1) |
规则引擎与告警配置要按新枚举配规则。重点是 8 个"解除/停止"事件,没有它们告警状态不闭环 |
| 3 | device_command 加 5 列 |
delivery_mode、reclaim_count、lease_expire_at、expire_at、dump_path。(retry_count 已有,不用加) |
| 4 | gateway_dev_id 改为一律非空 |
现在注释是"NULL 表示不经网关"。改为"命令的接入端点,独立设备填自身标识"。因为 uk_gw_seq(gateway_dev_id, cmd_seq) 是回执匹配键,NULL 不参与唯一约束,留空等于这一行逃出保护。 |
| 5 | 新增 I-18 / I-05 两个接口(§三) | 指令下行链路。I-18 长轮询(wait 最长 30s,用 DeferredResult 挂起)+ 租约发放;I-05 批量回报 |
| 6 | 实时命令改同步返回(§9.4) | 用户点【撤防】不要"异步 + 前端轮询",接口内挂起等 I-05,最长 5s |
| 7 | device_command.status 值域迁移:中文六值 → 英文七态(§3.4) |
原状态:待下发/已下发/已生效/失败/超时/已取消,缺 CLAIMED。已由 V1.0.23__command_seven_states.sql 刷为 PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED,并补 claim_generation / raw_result_code / raw_ack 三列。库里已无中文残留 |
| 8 | 索引 device_command(status, expire_at, timeout_at) |
D-01-U4 扫描是 status IN ('CLAIMED','SENT') AND 到期 < now,没有这个组合索引就是每轮全表扫 |
unregistered_device 表 V1.4 已有,不用新增(V1.3 的待确认项已关闭)。
9.2 给黄工(网关固件)
见 §3.5 那五条。其中第 1 条(QoS 0 + 禁离线队列)不确认,会出现"用户被告知失败、20 分钟后指令真执行了"——这是安全事故,不是体验问题。
另有一条比 8883 更前置的问题(V1.7 补):固件里的 Broker 地址(域名+端口)能不能改? 只能连豪恩云的话,附录 B 的 mqtt_decoder 整条作废,改走 AEP 或他们云的适配。这一条不成立,后面 TLS/证书全都白讨论。
9.3 给谢工(J 域实现方)
| # | 事项 | 依据 |
|---|---|---|
| 1 | 六个插件的边界:kaiwan_v3_decoder/bignbiot_decoder/mqtt_decoder/aep_adapter/mc200_callback_adapter+契约 B(自研网关) |
定稿 §4.2;附录 B |
| 2 | 输出同一个 J-00 信封,不许为某条线定制字段 | §1.3 |
| 3 | deviceTypeRaw 任何帧都带(含心跳与 UNKNOWN);eventType 枚举外映射 UNKNOWN 并填 rawEventCode |
§1.5.4/§1.5.5 |
| 4 | sourceChannel 按入口配置决定,不得根据到达报文自动改写;前缀由适配器按通道拼 |
§1.4;定稿 §4.2「通道路由纪律」 |
| 5 | resultCode 填标准字典,厂家原码进 rawResultCode/rawAck |
§3.3 |
| 6 | I-12 的 rawFrame 一律 Base64,payload_md5 对解码后字节算,与 I-01 的 rawRef 同一种算法 |
§1.9/§2.6 |
9.4 实时命令的同步等待(业务层实现,给陈工参考)
用户点【撤防】不应该看到"点了转圈、再轮询几秒、再弹窗"。
小程序 POST /api/v1/devices/{id}/commands(delivery_mode=REALTIME)
↓ 业务层写 device_command,长轮询立即返回
↓ IoT 判断端点在线与否(<100ms)
↓ 业务层收到 I-05 回报 → 唤醒挂起的请求
↓ 返回最终结果
小程序弹出「已撤防」或「网关离线,撤防未生效」
| 情形 | 耗时 | 返回 |
|---|---|---|
| 正常 | 200ms ~ 2s | 成功 / 失败,直接给结论 |
| 端点离线 | <200ms | 「网关离线,命令未下发」 |
| 设备无响应 | 5s | 「设备无响应」 |
| 5s 仍无回报 | 5s | 「指令已送出,结果稍后通知」(降级,前端再短轮询 1s×5) |
实现上用 DeferredResult 挂起请求等回调。JDK 21 虚拟线程让挂起 5 秒的成本接近零,不必改成异步接口 + 前端轮询——那会把一次点击拆成三跳。