Files
hepu/docs/contract/6_J-00_接入接口规格_V1.8.md
T
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

1199 lines
79 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` | **是**<br>(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:
# "<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 秒的成本接近零**,不必改成异步接口 + 前端轮询——那会把一次点击拆成三跳。
---