openapi: 3.0.3 info: title: 鹤仆云 · IoT 接入接口(internal) version: 1.8.1 description: | 本文件是 `J-00 接入接口规格 V1.7` 的机器可执行版。 **两者关系(不要理解成两份契约):** - **本文件管「长什么样」**:路径、方法、字段类型、必填、枚举、错误码——工具可执行、可校验、可生成代码。 - **J-00 管「为什么这样」**:业务语义、告警状态机、四条硬边界、幂等三档、断档与 0x04。这些写不进 YAML。 - 二者不一致时,以本文件为准,同时回改 J-00;但**业务语义的解释权仍在 J-00**。 **V1.8.1 变更(2026-11-12):** - `CommandResult.claimGeneration` 进 `required`、取消 `nullable`; `nodeId` 补必填语义(与 I-18 落库的 `claimed_node_id` 比对,不符按 `NODE_MISMATCH` 拒)。 - `RejectReason` 补命令侧四个原因码。 **V1.7.2 变更(2026-11-11):** - `I-18` 响应新增 `claimGeneration`(领取代次),`I-05` 新增同名字段用于回带。 用途:一条命令租约到期被回收重派后,第一个节点的**迟到回执**会覆盖第二次的流转且不报错。 回带代次后服务端能用 `WHERE claim_generation = ?` 挡住它(更新 0 行、计 `duplicated`)。 - **兼容性**:`I-18` 必返;`I-05` 不回带也不会被拒,但等于没有代次校验。**V1.8 起 `I-05` 必填。** **V1.7.1 变更(2026-11-10,需接入段确认后生效):** - `Event.rawRef` 由**可选改必填**,并取消「墓碑帧为 `null`」的例外。 墓碑帧同样是独立的一条 I-01 事件(`eventType=MSG_UNDELIVERED`),有自己的原始字节, 摘要填**承载该 `undelivered[]` 的网关上行帧**,多对一共享同一 `rawRef` 是合法的。 墓碑行身份是 `sensorId + seq`,事件幂等键是 `eventId`,都不依赖 `rawRef`,去重逻辑不受影响。 V1.7.1 之前已入库的历史墓碑行保持 `null`,业务层兼容。 **V1.7 变更(对齐《总体架构与 S 线一期实施规范 V1.0 定稿版》):** 1. `protocolVersion` 不再接受 `tcp-1.10`(旧口径已废止)。 2. `seq` 不再是「仅 G 线」——铠湾序列号、bignbiot 帧号等分别映射;没有才传 `null`。 3. `rawRef` 允许 **MD5(32 位)或 SHA-256(64 位)**,但 **I-01 与 I-12 必须同一算法**。 4. `eventType` 枚举外的新值 → 映射 `UNKNOWN` 并填 `payload.rawEventCode`,不静默丢弃。 5. `resultCode` 改为**标准字典值**(`0/1/2/3/4`),厂家原码进新增的可选字段 `rawResultCode`。 6. 命令状态七态英文(`PENDING/CLAIMED/SENT/ACKED/FAILED/TIMEOUT/CANCELLED`), **领取后是 `CLAIMED`,不是「已下发」**。 **五条不可协商的约束(J-00 §0.1 / §五):** 1. 全部接口由 **IoT 侧发起**,业务层一个出站请求都不发。 2. IoT 侧**不持有数据库账号**,不直连库。 3. IoT 侧**不判设备是否建档**——未建档设备照常上报,由业务层分流。 4. IoT 侧**不判业务策略**,只报亲眼看见的事实。 5. `deviceTime` 没有就传 `null`,**禁止用 `receivedAt` 顶替**。 **编号台账(J-00 §1.1):** 在役 `I-01`/`I-02`/`I-05`/`I-12`/`I-18`;二期预留 `I-17`。 废止永不复用:`I-03`、`I-04`、`I-06`~`I-11`、`I-13`~`I-16`。 servers: - url: https://{host}/internal/iot/v1 description: 内网地址,由部署时确定。**不走公网、不做域名备案**。 variables: host: default: 127.0.0.1:8080 description: 业务层内网入口 tags: - name: 上行 description: IoT → 业务层,推送设备数据 - name: 下行 description: IoT → 业务层,领取命令与回报结果 - name: 二期预留 description: 本期不实现 security: - mtls: [] internalToken: [] paths: /events/batch: post: tags: [上行] operationId: ingestEvents summary: I-01 设备事件批量上报 description: | 上报「能变成业务事件」的帧。心跳、鉴权帧、解析失败的未知帧**不从这里进**,走 I-12。 **限流**:单实例 200 条/批,1000 条/秒。 **幂等键**:`{sourceChannel}-{批内首条 eventId}-{条数}`。重推同一批必须算出同一个键。 **单条失败不整批回滚**:200 条里 1 条非法,其余 199 条照常入库。 parameters: - $ref: '#/components/parameters/InternalToken' - $ref: '#/components/parameters/Sign' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Nonce' - $ref: '#/components/parameters/IdempotencyKeyEvent' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/EventBatchRequest' responses: '200': description: 受理完成(含部分失败)。**HTTP 200 只表示受理,不表示处理完**。 content: application/json: schema: $ref: '#/components/schemas/IngestResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/ServerError' /device-status/batch: post: tags: [上行] operationId: ingestDeviceStatus summary: I-02 设备状态快照批量上报 description: | 并发上报「在线/电量/CSQ/最后报文时刻」。业务层按 `sensorId` **覆盖写**(谁最新谁覆盖), **不做时间排序**,也**不参与告警判定**——只作界面展示与离线判断的原料。 **限流**:500 条/批。 **幂等键**:`{sourceChannel}-{批内首条 sensorId}-{reportedAt}-{条数}`。 ⚠️ 注意:**I-02 报文里没有 `eventId`**,不能用它算幂等键。 parameters: - $ref: '#/components/parameters/InternalToken' - $ref: '#/components/parameters/Sign' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Nonce' - $ref: '#/components/parameters/IdempotencyKeyStatus' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/StatusBatchRequest' responses: '200': description: 受理完成 content: application/json: schema: $ref: '#/components/schemas/IngestResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/ServerError' /raw-frames: post: tags: [上行] operationId: ingestRawFrames summary: I-12 原始报文批量上报 description: | **全集的口子**——所有上行帧都从这里进一次,其中「能变成业务事件」的那部分另由 I-01 再报一次。 同一帧走两条链路不是重复:一条存档,一条产生业务后果。 **限流**:200 条/批。 **幂等键**:`{sourceChannel}-{批内首条 rawRef}-{条数}`。 ⚠️ 注意:**I-12 报文里没有 `eventId`**;`rawRef` 就是 `MD5(原始帧字节)`,重推同一帧必然算出同一个值。 parameters: - $ref: '#/components/parameters/InternalToken' - $ref: '#/components/parameters/Sign' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Nonce' - $ref: '#/components/parameters/IdempotencyKeyRaw' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/RawFrameBatchRequest' responses: '200': description: 受理完成 content: application/json: schema: $ref: '#/components/schemas/IngestResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '429': $ref: '#/components/responses/TooManyRequests' '500': $ref: '#/components/responses/ServerError' /commands/pending: get: tags: [下行] operationId: pullPendingCommands summary: I-18 长轮询领取命令 description: | ⚠️ **本接口编号是 I-18**(原 `I-04` 已废止、永不复用)。老文档里的 `I-04` 不要再实现。 **长轮询**:`wait` 是最长挂起秒数,不是轮询间隔。有新命令立即返回,不等满。 服务端用 `DeferredResult` 挂起,**不占线程**。 **`gatewayIds` 只传当前在线的接入端点**——离线端点的命令因此一条都不会被拉走, 天然实现「等上线再发」。IoT 不需要知道这个 id 代表网关还是单品。 **租约**:拉走不等于拿走。业务层返回时置 **`CLAIMED`**、`leaseExpireAt = now + 60s`; 租约内不回报会自动回 `PENDING`,`reclaimCount + 1`。 ⚠️ **领取这一步不能写「已下发」**——命令还在 IoT 手里没发出去。`CLAIMED`(领走)与 `SENT`(已发出)是两个状态,混成一个值,租约回收就判不出该不该重派。 **签名注意**:本接口是 GET,无 body —— `X-Sign` 里的 `sha256(body)` 取**空串的 hash**; 参与签名的 `path` **含 query**,`?nodeId=…&wait=30` 一并算进去。 parameters: - $ref: '#/components/parameters/InternalToken' - $ref: '#/components/parameters/Sign' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Nonce' - name: nodeId in: query required: true description: IoT 实例标识。多实例时业务层按它记租约。 schema: type: string maxLength: 64 - name: gatewayIds in: query required: true description: | 该实例**当前在线**的接入端点,逗号分隔,单次 ≤100。 **离线端点不要传**——传了等于让业务层以为能发。 schema: type: string example: OWN_GW_A,OWN_GW_B - name: wait in: query required: false description: 长轮询最长挂起秒数。0 = 立即返回。默认 30,上限 60。 schema: type: integer default: 30 minimum: 0 maximum: 60 - name: limit in: query required: false description: 单次最多返回条数。默认 50,上限 100。 schema: type: integer default: 50 minimum: 1 maximum: 100 responses: '200': description: 返回命令列表。无命令时 `commands` 为空数组(长轮询到期的情况)。 content: application/json: schema: $ref: '#/components/schemas/PendingResponse' '401': $ref: '#/components/responses/Unauthorized' '500': $ref: '#/components/responses/ServerError' /commands/result: post: tags: [下行] operationId: reportCommandResults summary: I-05 命令结果批量回报 description: | 回报命令执行结果。**`resultCode` 是标准字典值(`0` 成功/`1` 不支持/`2` 参数非法/ `3` 执行失败/`4` 设备忙),不是厂家原码**——厂家原始返回码请放 `rawResultCode`。 把原码直透到标准字段,业务层与前端就得按每个厂家各写一套判断。 **幂等键**:`{nodeId}-{批内首条 commandId}-{条数}`。 **五个 `result` 值的含义(不要合并):** - `ACKED` 设备有回应,看 `resultCode` 分流 - `SENT` 已发出,本协议无回执通道(S 线裸 TCP)——业务层置 `SENT` + 24h 超时兜底 - `TIMEOUT` 已发出,等不到回执 - `DEVICE_OFFLINE` **没有发出去**(连接不在) - `FAILED` IoT 自身发送失败(编码/写通道异常) parameters: - $ref: '#/components/parameters/InternalToken' - $ref: '#/components/parameters/Sign' - $ref: '#/components/parameters/Timestamp' - $ref: '#/components/parameters/Nonce' - $ref: '#/components/parameters/IdempotencyKeyResult' requestBody: required: true content: application/json: schema: $ref: '#/components/schemas/ResultBatchRequest' responses: '200': description: 受理完成 content: application/json: schema: $ref: '#/components/schemas/IngestResponse' '400': $ref: '#/components/responses/BadRequest' '401': $ref: '#/components/responses/Unauthorized' '500': $ref: '#/components/responses/ServerError' /commands/{commandId}/dump: post: tags: [二期预留] operationId: uploadDiagDump summary: 【二期预留,本期不实现】I-17 诊断包分片上传 description: | **本期零调用,不要实现。** 一期 `diag_dump` 只留命令字,实际不下发。 等 `set_ota` 与诊断包真正启用时再补。 编号说明:原 `I-13` 在系统设计 V1.4 曾指「网关台账快照」,该功能取消后号一并作废, 诊断包另起 `I-17`,不沿用旧号。 parameters: - name: commandId in: path required: true schema: type: string maxLength: 64 responses: '404': description: | 本期不实现:**这条路由没有注册**。 请求由 Spring 默认错误处理返回 404,`body` 是框架的错误结构, **不是本文档其他接口那种 `{code,msg,data}` 统一信封**,不要按信封解析。 留这一条只是占住 `I-17` 这个号,等二期真做时再补正式响应定义。 components: securitySchemes: mtls: type: mutualTLS description: 双向证书,90 天轮换。**没有它,其余四层都不必看**。 internalToken: type: apiKey in: header name: X-Internal-Token description: 固定长随机串,配置中心下发。 parameters: InternalToken: name: X-Internal-Token in: header required: true schema: type: string Sign: name: X-Sign in: header required: true description: | `HMAC-SHA256(token, method + "\n" + path + "\n" + timestamp + "\n" + nonce + "\n" + sha256(body))` - `path` **含 query**(GET 时尤其注意) - GET 无 body 时,`sha256(body)` 取**空串的 hash** `e3b0c442…b855`,不要取字面量 `null` schema: type: string Timestamp: name: X-Timestamp in: header required: true description: Unix 秒。与服务器偏差 > 300 秒直接拒。 schema: type: integer format: int64 Nonce: name: X-Nonce in: header required: true description: 随机串。Redis 留 600 秒做防重放。 schema: type: string maxLength: 64 IdempotencyKeyEvent: name: X-Idempotency-Key in: header required: true description: '`{sourceChannel}-{批内首条 eventId}-{条数}`' schema: type: string example: OWN_GW-3F7A9C21_10231-2 IdempotencyKeyStatus: name: X-Idempotency-Key in: header required: true description: '`{sourceChannel}-{批内首条 sensorId}-{reportedAt}-{条数}`。**不要用 eventId,I-02 没有这个字段**' schema: type: string example: HORN_CELL-868123456789012-2026-10-29T10:19:22+08:00-500 IdempotencyKeyRaw: name: X-Idempotency-Key in: header required: true description: '`{sourceChannel}-{批内首条 rawRef}-{条数}`。**不要用 eventId,I-12 没有这个字段**' schema: type: string example: MIR_CELL-9f2c1e7b8a4d6f03c5e9a1b7d4f8c2e6-200 IdempotencyKeyResult: name: X-Idempotency-Key in: header required: true description: '`{nodeId}-{批内首条 commandId}-{条数}`' schema: type: string example: iot-node-1-CMD-20261030-000123-50 responses: BadRequest: description: 请求体不符合本文件定义。**不重试**,字段问题重试也不会变。 content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' Unauthorized: description: mTLS 失败、token 错、签名不匹配、时间戳超窗、nonce 重放。 content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' TooManyRequests: description: 超出限流。**不重试**(重试还是被限),计入死信即可。 content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' ServerError: description: 服务端异常。**这是唯一需要重试的**,按 J-00 §1.6 的五档退避。 content: application/json: schema: $ref: '#/components/schemas/ErrorResponse' schemas: SourceChannel: type: string description: | 接入通道四值,**不是产品线**。同一户可以存在多个通道的设备。 它决定三件事:`dev_id` 生成规则、`ts_precision` 默认值、原文落哪张表。 ⚠️ **它是业务来源,不承担具体物理通路。** 哪套协议、哪个监听口进来,由接入侧的 `linkType` 描述(落 `adapter_in_raw`,不进本信封),两者分列。 例:S 线两套 TCP 同属 `MIR_CELL`,靠 `linkType` 区分,不能靠这个字段猜协议。 enum: [OWN_GW, HORN_HOST, HORN_CELL, MIR_CELL] TsSource: type: integer description: | 时源,**没有默认值,漏传直接拒收**(故意不设 default,让漏赋值当场暴露)。 `0` 设备原始 / `1` 厂家云 / `2` 平台接收 / `3` 网关打戳。 enum: [0, 1, 2, 3] EventType: type: string description: | 29 个值,按家族成对出现。**少一个「解除」,告警就永远不闭环。** 枚举外的新值**映射 `UNKNOWN` 并必填 `payload.rawEventCode`**——不拒收、不静默丢弃。 enum: # 成对:发生 / 解除 - 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 - TEMP_HUMIDITY - DEVICE_ONLINE - DEVICE_OFFLINE - LOW_BATTERY - HEARTBEAT - PRE_ALARM_0X04 - MSG_UNDELIVERED - UNKNOWN EventPayload: type: object description: 事件明细。已知字段逐个列;**厂商新增字段原样保留,不丢**(见 `additionalProperties`)。 required: [deviceTypeRaw] properties: deviceTypeRaw: type: integer description: | 设备原始类型码(G 线 `type` 字节、S 线 `DT`、H 线子设备类型)。 **任何帧都带,含心跳与 UNKNOWN。** 不要在这里做归一化——归一化由业务层按设备类型表做。 rawEventCode: type: integer description: 设备原始事件码。**`eventType` 落到 `UNKNOWN` 时必填**,其余情况建议带。 batteryMv: type: integer description: 电池电压,**毫伏整数**。不换算成百分比(换算式会变,在业务层)。 rssi: type: integer description: 信号强度 dBm。 csq: type: integer minimum: 0 maximum: 99 description: 信号质量 0–31,`99` = 未知。 additionalProperties: true Event: type: object required: - eventId - sensorId - sourceChannel - eventType - receivedAt - tsSource - protocolVersion - rawRef - payload properties: eventId: type: string maxLength: 96 description: | 事件级幂等键,**≤96 字符,不得含平台时间戳**。 算法见 J-00 §2.4 三档:有 `seq` 用 `{sensorId}_{seq}`;有厂商报文 ID 用它; 两者都没有用 `{sensorId}_{sha256(原始帧字节)前16位}`。 **hash 要对原始字节算**,不能对反序列化后再序列化的 JSON 算。 example: 3F7A9C21_10231 sensorId: type: string maxLength: 64 description: | 设备物理标识,**必须与 `device.sensor_id` 完全一致(含厂商前缀)**。 H/S 线必带 `HORN:`/`MIR:` 前缀;G 线 8 位 hex 不加前缀。 **这是全篇最容易出错的一个字段**——不一致会导致全场设备判成未建档。 example: 3F7A9C21 sourceChannel: $ref: '#/components/schemas/SourceChannel' eventType: $ref: '#/components/schemas/EventType' deviceTime: type: string format: date-time nullable: true description: | 设备侧时间,**没有就传 `null`**。 ⚠️ **禁止用 `receivedAt` 顶替**——填了等于伪造时源,界面会把平台收到的时刻当事情发生的时刻。 receivedAt: type: string format: date-time description: 平台接收时刻,ISO-8601 带时区。 example: '2026-10-29T10:19:22+08:00' tsSource: $ref: '#/components/schemas/TsSource' protocolVersion: type: string maxLength: 16 description: | 按**具体协议原件**填写,厂商改协议后要能回溯。 例:`gw-1.2`(契约 B)/`WTK-V3.0`(铠湾)/`bignbiot-3.0`/`nb-1.6`(NB&WIFI)。 **豪恩不得再写 `tcp-1.10`**——旧口径已废止。 example: gw-1.2 seq: type: integer format: int64 nullable: true description: | 协议序号。**G 线**取 433 帧 `count`(2 字节);**铠湾 V3.0** 取帧内序列号; **bignbiot** 取帧号(1 字节 1–255);其他协议取自带序号。 **没有才传 `null`,不得写成「S 线一律 null」**——S 线两套 TCP 都有序号。 payload: $ref: '#/components/schemas/EventPayload' rawRef: type: string pattern: '^([a-f0-9]{32}|[a-f0-9]{64})$' description: | 原始报文摘要,回指 I-12 的 `rawRef`。 **MD5(32 位小写 hex)与 SHA-256(64 位小写 hex)都接受**,按长度识别。 **约束只有一条:I-01 与 I-12 用同一种算法**,否则回指链断开。 摘要只用于关联与幂等,不作鉴权。 **必填(V1.7.1 起)。墓碑帧也必填**:填承载该 `undelivered[]` 的网关上行帧摘要。 一条网关报文里的 N 个 `undelivered` → N 条墓碑行共享同一 `rawRef`,多对一合法。 墓碑行身份是 `sensorId + seq`、事件幂等键是 `eventId`,都**不是** `rawRef`,故不影响去重。 V1.7.1 之前已入库的历史墓碑行为 `null`,业务层保持兼容。 example: 9f2c1e7b8a4d6f03c5e9a1b7d4f8c2e6 EventBatchRequest: type: object required: [sourceChannel, events] properties: sourceChannel: $ref: '#/components/schemas/SourceChannel' events: type: array maxItems: 200 items: $ref: '#/components/schemas/Event' DeviceStatus: type: object required: [sensorId, sourceChannel, online, lastSeenAt, protocolVersion, reportedAt] properties: sensorId: type: string maxLength: 64 sourceChannel: $ref: '#/components/schemas/SourceChannel' online: type: boolean description: | 只表示「最近是否有报文」,**不表示设备好坏**。 是否「离线告警」由业务层按阈值判定,IoT 不判。 lastSeenAt: type: string format: date-time description: 最后一次收到有效报文的时刻。 batteryMv: type: integer nullable: true description: 毫伏整数。 csq: type: integer nullable: true minimum: 0 maximum: 99 lastSeq: type: integer format: int64 nullable: true description: 仅 G 线,最后收到的 433 序号。 lostCnt: type: integer nullable: true minimum: 0 maximum: 255 description: 仅 G 线,窗口内未送达计数,`255` 表示饱和。 protocolVersion: type: string maxLength: 16 reportedAt: type: string format: date-time description: 快照生成时刻。**幂等键要用它**。 StatusBatchRequest: type: object required: [sourceChannel, statuses] properties: sourceChannel: $ref: '#/components/schemas/SourceChannel' statuses: type: array maxItems: 500 items: $ref: '#/components/schemas/DeviceStatus' RawFrame: type: object required: [rawRef, sourceChannel, receivedAt, rawFrame, protocolVersion] properties: rawRef: type: string pattern: '^[a-f0-9]{32}$' description: '`MD5(原始帧字节)`,32 位小写 hex。**它就是幂等键**,也是 I-01 里 `rawRef` 回指的目标。' sensorId: type: string maxLength: 64 nullable: true description: | 解析出设备标识时带上,规则同 I-01。**未建档设备也要带**——有值才追得回是哪个 SN。 实在解析不出可留空,原文照样落库。 sourceChannel: $ref: '#/components/schemas/SourceChannel' receivedAt: type: string format: date-time description: 平台收到这一帧的时刻。**不是设备时间。** rawFrame: type: string format: byte description: | **Base64,定死这一种表示。** 十六进制会把体积翻倍,而 200 条/批本来就是按字节数撑上限。 `rawRef` 对解码后的字节算——两种表示算出来一样,所以「写 hex 或 Base64 等于没定」。 写 hex 的实现方,MD5 永远对不上业务层。 frameBytes: type: integer nullable: true description: 解码后的字节数。用于自检——**对不上说明 Base64 编码有问题**。 gatewayDevId: type: string maxLength: 64 nullable: true description: 接入端点标识(网关 SN,或独立设备自身标识)。排障时回答「这帧从哪个口进来的」。 protocolVersion: type: string maxLength: 16 RawFrameBatchRequest: type: object required: [sourceChannel, frames] properties: sourceChannel: $ref: '#/components/schemas/SourceChannel' frames: type: array maxItems: 200 items: $ref: '#/components/schemas/RawFrame' DeliveryMode: type: string enum: [REALTIME, DEFERRED] description: | 由业务层在创建命令时定,IoT 不判。 `REALTIME` 实时命令(布撤防、重启):设备离线必须当场失败,不能「等上线再发」。 `DEFERRED` 可延迟命令(配置下发):失败可重排。 CmdType: type: string description: 下行命令字 C1–C8。**IoT 只负责编码下发,不解释语义。** enum: [set_mode, set_ack_policy, time_sync, set_report_cfg, del_sensor, reboot, diag_dump, set_ota] Command: type: object required: [commandId, gatewayId, targetId, cmdType, deliveryMode, leaseExpireAt, expireAt, retryCount] properties: commandId: type: string maxLength: 64 description: 命令唯一标识。**IoT 与网关都要按它去重**——租约回收可能导致同一条被下发两次。 example: CMD-20261030-000123 gatewayId: type: string maxLength: 64 description: | **「这条命令从哪个端点进去」,不是「网关 SN」。** 连网关的哨兵填网关 SN;独立设备填设备自身标识;H 线填厂家云节点标识。 IoT 拿它去自己的连接表里查,查不到报 `DEVICE_OFFLINE`。 targetId: type: string maxLength: 64 description: 目标设备标识。路由在业务层写指令时就算完了,IoT 不需要知道它与 `gatewayId` 的关系。 cmdType: $ref: '#/components/schemas/CmdType' deliveryMode: $ref: '#/components/schemas/DeliveryMode' params: type: object nullable: true additionalProperties: true leaseExpireAt: type: string format: date-time description: 租约到期时刻(拉走 + 60s)。到期不回报会自动回「待下发」,`reclaimCount + 1`。 expireAt: type: string format: date-time description: 命令最终有效期。过期即终态「已过期」。 retryCount: type: integer description: 设备侧已失败次数。**与 `reclaimCount` 分开记**——混成一个就分不清是设备坏了还是中间件坏了。 claimGeneration: type: integer minimum: 1 description: | **领取代次(V1.7.2 新增):这条命令被领走第几次。** 每次 `PENDING → CLAIMED` 时 +1。 IoT 必须原样保存,并在 I-05 里回带。用途:一条命令租约到期被回收重派后, 第一个节点的迟到回执会覆盖第二次的流转且不报错——回带代次后服务端能把它挡住。 不回带也不会被拒(兼容未升级的接入端),但等于没有代次校验。 example: 1 PendingResponse: type: object required: [nodeId, commands, hasMore] properties: nodeId: type: string commands: type: array items: $ref: '#/components/schemas/Command' hasMore: type: boolean description: 还有更多(本次达到 `limit` 上限)。IoT 可立即再拉一次。 CommandResult: type: object required: [commandId, result, claimGeneration] properties: commandId: type: string maxLength: 64 result: type: string enum: [ACKED, SENT, TIMEOUT, DEVICE_OFFLINE, FAILED] description: | - `ACKED` 设备有回应(看 `resultCode`) - `SENT` 已发出,本协议无回执通道 - `TIMEOUT` 已发出,等不到回执 - `DEVICE_OFFLINE` **没有发出去** - `FAILED` IoT 自身发送失败 resultCode: type: string enum: ['0', '1', '2', '3', '4'] nullable: true description: | **标准字典值,由 IoT 翻译后填,不是厂家原码。** `0` 成功/`1` 不支持/`2` 参数非法/`3` 执行失败/`4` 设备忙。 厂家原始返回码请放 `rawResultCode`——直透到标准字段,业务层与前端就得按每个厂家 各写一套判断,等于把适配器的活摊到全平台。 过渡期说明:服务端按字符串接收,**不因非标准值拒整批**(避免联调期因为一个码值 整批 400),非标准值会记日志并在 `rawResultCode` 为空时兜底采用。 rawResultCode: type: string maxLength: 32 nullable: true description: | 厂家原始返回码,**原样字符串,不解释**。 V1.7 新增的**可选**字段:不填也能跑(`null` 时业务层只看 `resultCode`)。 resultMsg: type: string maxLength: 256 nullable: true ackAt: type: string format: date-time nullable: true description: 收到回执的时刻。`SENT`/`DEVICE_OFFLINE`/`FAILED` 时为 `null`。 rawAck: type: string maxLength: 512 nullable: true description: 回执帧原样(hex)。排障用。 claimGeneration: type: integer minimum: 1 description: | 领取时 I-18 给的 `claimGeneration`,**原样回带。V1.8 起 required,不可为空。** 服务端用它当 `WHERE claim_generation = ?` 的条件,挡住旧代次的迟到回执 (更新 0 行、计 `duplicated`)。**缺失直接拒**,原因 `CLAIM_GENERATION_REQUIRED`—— 旧实现"缺了就拿库里当前值顶上",等于让代次校验自己失效,已取消。 与 `nodeId` 一起构成一次领取的唯一事实: `claim_generation` 回答"第几轮",`nodeId` 回答"这一轮是谁"。 example: 1 ResultBatchRequest: type: object required: [nodeId, results] properties: nodeId: type: string maxLength: 64 description: | **来源节点标识,必填。** 必须与 I-18 领取时落库的 `claimed_node_id` 一致, 否则整条回执按 `NODE_MISMATCH` 拒收;I-18 时未落节点的历史行按 `NODE_UNKNOWN` 拒收。 **作用边界**:它不是身份认证——值由 IoT 自报,同样可以伪造; "调用方是谁"由内网来源白名单 + 签名保证。这里校验的是**领取事实的一致性**: 这条结果是不是领走这一轮的那个节点回的。 results: type: array maxItems: 50 items: $ref: '#/components/schemas/CommandResult' RejectReason: type: string enum: [RATE_LIMITED, SCHEMA_INVALID, UNKNOWN_CHANNEL, SENSOR_ID_INVALID, COMMAND_NOT_FOUND, CLAIM_GENERATION_REQUIRED, NODE_MISMATCH, NODE_UNKNOWN] description: | 前四个用于 I-01/I-02/I-12(事件侧),后四个用于 I-05(命令回执侧): `COMMAND_NOT_FOUND` 命令号不存在;`CLAIM_GENERATION_REQUIRED` 缺 `claimGeneration`; `NODE_MISMATCH` 回执节点与领取节点不符;`NODE_UNKNOWN` 历史在途行没有领取节点可核。 **不要按原因做分支。** 收到 `rejectedDetail` 一律**不重试**,落死信 + 计数即可。 唯一例外是 `NODE_UNKNOWN`:那条命令会在 60 秒租约到期后被回收重派,重领后节点列即有值。 需要重试的只有整批失败(网络、5xx)。 RejectedItem: type: object required: [reason] properties: eventId: type: string nullable: true description: 事件类批次用 `eventId` 定位。 rawRef: type: string nullable: true description: 原文类批次用 `rawRef` 定位。 sensorId: type: string nullable: true description: 状态类批次用 `sensorId` 定位。 commandId: type: string nullable: true description: 结果类批次用 `commandId` 定位。 reason: $ref: '#/components/schemas/RejectReason' IngestResponse: type: object required: [code, accepted, rejected, duplicated, unregistered] description: | **HTTP 200 只表示受理,不表示处理完。** `unregistered` 是**计数字段,不是错误**——业务层已写进 `unregistered_device`, 接入侧不做任何动作:不重报、不换接口、不缓存。 properties: code: type: integer description: 0 = 受理完成。非 0 见 `message`。 example: 0 accepted: type: integer description: 成功入库条数。 example: 198 rejected: type: integer description: 拒收条数。 example: 2 duplicated: type: integer description: 命中幂等跳过的条数(重推)。 example: 3 unregistered: type: integer description: 未建档设备条数。**不是错误。** example: 5 rejectedDetail: type: array items: $ref: '#/components/schemas/RejectedItem' ErrorResponse: type: object required: [code, message] properties: code: type: integer description: | 错误码。**本文件暂不固定具体码值**——待与业务层错误码表统一后回填, 当前按 HTTP 状态码区段理解:400xx 请求非法、401xx 鉴权失败、429xx 限流。 message: type: string description: 人读的错误说明。**不要用它做分支判断**,用 `code`。