# 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 事件对象:逐字段 ```json { "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 响应与错误 成功(含部分失败): ```json { "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 报文 ```http POST /internal/iot/v1/raw-frames X-Idempotency-Key: OWN_GW-3F7A9C21_10231-2 Content-Encoding: gzip ``` ```json { "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 | 响应: ```json { "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 ``` ```json { "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 参考实现(外包照抄这个分支就够了) ```python 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,**新增协议只加文件,不改本文正文**。适配器读配置取值,代码里不写死任何字节偏移。 ```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] ``` ```yaml # 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: # "
/<报警位>": # 门磁、水浸、烟感、燃气、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 秒的成本接近零**,不必改成异步接口 + 前端轮询——那会把一次点击拆成三跳。 ---