Files
hepu/docs/contract/6_J-00_接入接口规格_V1.8.md
aomin a7605ef8b8 chore(repo): 导入 M-Z 业务底座 V1.9 基线并建立仓库规范
按周工交付的 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),不做归一化以免整树摘要失效。
2026-11-13 17:32:37 +08:00

79 KiB
Raw Permalink Blame History

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 直接报 1364
3 §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)是两套代码、两个开发方,唯一的对话方式就是这几个接口。所以这份文件只写三件事:

  1. 调什么接口——路径、方向、鉴权、批量、重试;
  2. 传什么字段——含义、必填性、值从报文的哪里取;
  3. 落到哪张表哪一列——一条报文变几行、时间字段谁派生、未建档走哪条路。

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

三条纪律:

  1. 不截断、不补零、不转小写。超 64 字符整帧拒收,不静默截断(截断会产生一个"看起来很对"的假设备)。
  2. 前缀只在适配器加一次。若厂商原始报文里已自带前缀,先剥掉再按上表重新加,避免 HORN:HORN:xxx。
  3. 上线前必做一次抽样比对:从 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 三条纪律

  1. rawFrame 必须是原始字节。 不允许「先解析成对象再序列化」——那样排障时对不上设备实际发的内容,payload_md5 也算不稳,I-01 的 rawRef 回指就断了。
  2. 解析失败、限流丢弃的帧也要上报。 I-12 的职责是「把所有看见的都留下」,判断谁该被业务处理是 I-01 的事。这里做过滤,等于把事故现场擦掉。
  3. 单帧失败不整批回滚。 一批 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。

处置三条:

  1. event_time 落 receive_time,device_time 保持 NULL。不能落 NULL(违反 NOT NULL),更不能落 1900-01-01——那是墓碑帧的专用值,也是 is_backfill=1 的判据之一,拿来做「未知」会让墓碑行和未知时间行混成一堆,断档统计直接算错。
  2. 精度强制降 C。判据是 device_time IS NULL AND ts_source = 3,由 EventTimeResolver 写入时判定,接入侧不参与。C 级按陈工《系统设计 V1.7》§6.11 的禁令使用:不进基线采样、不参加任何「时间间隔」类判定(活动画像、无活动告警、到点巡检全部排除)。
  3. 界面不得显示为发生时间。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

三条要点:

  1. 查询是批量的(IN (…) 一次),不是 200 次单查。这是方案能成立的前提,写代码时别退化成循环。
  2. last_payload 必须存原始 JSON,认领时靠它判断"这是什么设备"。
  3. 待认领记录不参与任何告警。唯一出口是"有人认领(扫码/人工)→ 建档 → 触发回灌 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 文本,理由三条:

  1. payload / raw_payload 是 MEDIUMTEXT + utf8mb4,塞二进制在严格模式报 Incorrect string value、非严格模式静默变成 ?——原文永久损坏还不报错。Base64 是唯一稳妥的存法。
  2. 加 encoding 列只是省一次 FROM_BASE64(),却让每张表多一列、每个写入点多一个分支,而且那一列的值由写入方自己填,填错了照样读不出来。
  3. 排障代价可接受: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 两个接口 我方 上行能通,下行跑不起来

五个可以立刻开工的单元(纯本地逻辑,不依赖服务端):

  1. J-04 帧解析与 CRC —— S 线两套协议文档已到手,G 线契约 B 已定;
  2. J-05 信封组装 —— 按本文 §1.3 / §1.4 / 附录 A;
  3. J-06 幂等键生成 —— 按本文 §2.4;
  4. J-07 攒批与重试 —— 按本文 §1.6;
  5. 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

四条纪律:

  1. fallback: UNKNOWN 是默认配法,不是可选项。映射表没覆盖到的值不能抛异常、不能丢帧。
  2. eventType 必须二维查表(设备类型 × 事件码)。
  3. 每个适配器启动自检:加载 YAML → 校验必填字段齐全(sensorId / tsSource / protocolVersion / eventType / payload.deviceTypeRaw / receivedAt)→ 缺任一则启动失败,不允许带病运行。
  4. 上线前跑样例帧:用协议文档里的真实样例帧跑一遍,断言每个必填字段都取到了值;取不到就报出字段名和偏移。

八、附录 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) ✅ 已完成(2026-11-10) 原状态:待下发/已下发/已生效/失败/超时/已取消,缺 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 秒的成本接近零,不必改成异步接口 + 前端轮询——那会把一次点击拆成三跳。