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),不做归一化以免整树摘要失效。
This commit is contained in:
aomin committed 2026-11-13 17:32:37 +08:00
commit a7605ef8b8
121 files changed
+23629

No files matched your search

@@ -0,0 +1,970 @@
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`。