Files
hepu/docs/contract/内部五接口定义.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

225 lines
10 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.
# 内部 IoT 五接口定义(实现侧)
> J-00 V1.8 定义了内部接口契约,本文是**我方实现的落地版**:路径、字段、幂等键、拒绝码,逐条对应代码。
> 五个接口,三类上行(I-01 / I-02 / I-12)+ 两类下行(I-18 / I-05)。**业务层不向 IoT 侧发起任何调用**,只有 IoT 调业务层。
>
> **编号一次说清**:I-01、I-02、I-12、I-18、I-05 就是这五个接口,与 J-00 V1.8 §三接口表逐字一致。
> 早期稿把下行两个写成 I-20/I-21,**该编号已作废**,本文与 `Mock样例.md`、`状态机.md` 均已统一。
>
> **与代码的一致性**:早期稿在这里登记过两条差距,两条都已修掉,本文与代码现在一致:
> ① 命令状态值域 —— `V1.0.23__command_seven_states.sql` 已把存量中文六值刷成英文七态(含 `CLAIMED`),代码读写全用英文值;
> ② `resultCode` —— `CommandService.standardCode()` 已把上报值收敛到标准码 `0`–`4`,厂家原码另存 `raw_result_code`,不再直透。
---
## 0. 公共约束(五个接口全适用)
| 项 | 约定 |
|---|---|
| 传输 | HTTP/1.1,`Content-Type: application/json`,**仅内网**(来源 IP 有代码校验,见文末附录) |
| 鉴权 | `X-Internal-Token` + `X-Timestamp`(±300s)+ `X-Nonce`(Redis 600s 不复用)+ `X-Sign`(HMAC-SHA256) |
| 签名原文 | `token\nmethod\npath\ntimestamp\nnonce\nsha256(body)` |
| 幂等 | `X-Idempotency-Key`,同一 key 在 TTL 内返回首次结果 |
| 限流 | 通道维度按批次、设备维度按条;超限 429 |
| 响应包封 | `{"code":0,"msg":"ok","data":{...}}`,业务失败用 `code!=0` |
| 校验顺序 | Token(最便宜)→ 时间戳 → nonce → 签名(最贵),前面挡掉的不付后面代价 |
> **当前配置** `GATEWAY_AUTH_ENABLED=false`(联调态,签名不校验)。机制完整实现于
> `security/SignatureVerifier` + `security/NonceStore` + `interceptor/AuthInterceptor`,
> 打开方式:环境变量 `GATEWAY_AUTH_ENABLED=true`,并同步配好 `GATEWAY_TOKEN`。
> **必须与 IoT 侧同批切换**,单边打开会导致全部请求 401。
---
## I-01 事件批量上报
```
POST /internal/iot/v1/events/batch
X-Idempotency-Key: <批次键>
```
**请求**
| 字段 | 类型 | 必填 | 说明 |
|---|---|---|---|
| `sourceChannel` | enum | ✅ | `OWN_GW` / `HORN_HOST` / `HORN_CELL` / `MIR_CELL` |
| `events[]` | array | ✅ | ≤200 条 |
**`events[]` 单条**(字段与 J-00 `Event` 逐项一致,详见 `04` 目录下字段差异表)
`eventId`(≤96,不含平台时间戳) / `sensorId`(≤64,含厂商前缀) / `sourceChannel` / `eventType` /
`deviceTime`(可 null) / `receivedAt`(ISO-8601 带时区) / `tsSource`(0–3) / `protocolVersion` /
`seq`(可 null) / `payload`(必须含 `deviceTypeRaw`) / `rawRef`(32 或 64 位小写 hex,可 null)
**响应**
```json
{"code":0,"msg":"ok","data":{
"accepted":1, "rejected":0, "duplicated":0, "unregistered":0, "rejectedDetail":[]}}
```
四个计数各有含义,**不能只看 accepted**:
| 字段 | 含义 |
|---|---|
| `accepted` | 本批收下的条数(含幂等命中,**不等于新建条数**) |
| `duplicated` | 事件级幂等命中(同 `event_id` 已存在,未重复落库) |
| `unregistered` | 设备未入库:**不建档**,写 `unregistered_device` 待后台确认 |
| `rejected` | 条目级校验失败条数,明细在 `rejectedDetail` |
**拒绝码**
| 码 | 触发 | HTTP |
|---|---|---|
| `SCHEMA_INVALID` | 必填缺失 / 长度超限 / `tsSource` 越界 / `rawRef` 格式错 / 缺 `deviceTypeRaw` | 200 内逐条拒 |
| `UNKNOWN_CHANNEL` | 通道不在 4 个枚举内(批级错 → 整批 400) | 400 |
| `SENSOR_ID_INVALID` | SN 超 64 位,或前缀与通道不符(如 `OWN_GW` 传 `HORN:xxx`) | 200 内逐条拒 |
---
## I-02 设备状态快照
```
POST /internal/iot/v1/device-status/batch
```
上报电量、信号、在线状态。字段:`sensorId` / `sourceChannel` / `batteryMv` / `csq` / `online` / `reportedAt`。
用于设备列表的「电量 / 信号 / 最近上报」,不产生告警。
---
## I-12 原始报文留档
```
POST /internal/iot/v1/raw-frames
```
把原始帧字节摘要与原文留档,供 I-01 的 `rawRef` 回指。
`rawRef` 与 I-01 **必须同一种算法**(MD5 32 位或 SHA-256 64 位),否则回指链断开。
`sensorId` 允许为空(实在解析不出时),原文照样落库。
---
## I-18 待下发命令拉取(长轮询)
```
GET /internal/iot/v1/commands/pending?nodeId=<节点ID>&wait=30
```
> **编号说明**:本节原写作 `I-20`,与 J-00 V1.7 §三接口表的 `I-18` 不一致,现已统一为 **I-18**(I-20/21 作废)。
> 参数名也按 J-00 用 `nodeId`,不是 `gwId`。
网关/单品长轮询取下行命令,返回按创建时间与优先级排序。`wait` 最长 30s(用 `DeferredResult` 挂起,不占线程)。
```json
{"code":0,"msg":"ok","data":{
"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
}],
"hasMore":false}}
```
**拉走不等于拿走**:业务层返回命令时同时置 `CLAIMED`、`lease_expire_at = now + 60s`。
60 秒没回报 → 回 `PENDING`,`reclaim_count + 1`(`retry_count` 不动)。
**命令类型 C1–C8**:`set_mode` / `set_ack_policy` / `time_sync` / `set_report_cfg` /
`del_sensor` / `reboot` / `diag_dump` / `set_ota`。全部带 `seq`。
---
## I-05 命令执行结果回执
```
POST /internal/iot/v1/commands/result
```
> **编号说明**:本节原写作 `I-21`,现已统一为 J-00 V1.7 的 **I-05**。
```json
{"code":0,"msg":"ok","data":{
"nodeId":"iot-node-1",
"results":[{
"commandId":"CMD-20261030-000123",
"result":"ACKED",
"resultCode":"0",
"resultMsg":"OK",
"ackAt":"2026-10-30T08:45:03+08:00",
"rawResultCode":"00",
"rawAck":"020A1F3C…"
}]}}
```
| 字段 | 必填 | 说明 |
|---|---|---|
| `commandId` | ✅ | I-18 返回的命令 ID |
| `result` | ✅ | `ACKED` / `SENT` / `TIMEOUT` / `DEVICE_OFFLINE` / `FAILED`(**五值,见 J-00 §3.3**) |
| `resultCode` | ✅ | **标准字典值**:`0` 成功/`1` 不支持/`2` 参数非法/`3` 执行失败/`4` 设备忙 |
| `resultMsg` | | 可读说明 |
| `ackAt` | | 设备侧回执时刻 |
| `rawResultCode` | | **厂家原始码,原样字符串,不解释**(V1.7 新增可选) |
| `rawAck` | | 回执原始字节(Base64,可选) |
**`result` 与 `resultCode` 的关系(写死,避免误读)**:
| `result` | 含义 | 业务层处置 |
|---|---|---|
| `ACKED` | **设备有回应**(收到回执帧),**不代表执行成功** | 看 `resultCode`:`0` → `ACKED`;非 `0` → `FAILED` |
| `SENT` | 已发出,本协议无回执通道 | 置 `SENT`,`timeout_at = now+24h` |
| `TIMEOUT` | 已发出,等不到回执 | 实时=失败;可延迟=重排 |
| `DEVICE_OFFLINE` | **没有发出去**(连接不在) | 实时=失败;可延迟=重排 |
| `FAILED` | IoT 自身发送失败 | 同上 |
> **`resultCode` 不得直透厂家原码**。厂家原码各不一样(铠湾 `08` 是水浸、bignbiot `08` 是白蚁,命令侧同样有这种坑),
> 直透到标准列以后,业务层和前端就得按每个厂家各写一套判断,等于把适配器的活摊到全平台。厂家原码只能进 `rawResultCode`。
回执落 `device_command.status`(七态见 `状态机.md` §二),后台命令记录页可见。
---
## 与 J-00 的关系
| 项 | 结论 |
|---|---|
| 字段命名 | 11 个字段与 J-00 `Event` schema **完全一致**,0 处改名 |
| 枚举 | `sourceChannel` 4 值、`tsSource` 4 值完全一致 |
| 接口编号 | **I-01 / I-02 / I-12 / I-18 / I-05**,与 J-00 §三逐字一致 |
| 事件类型 | J-00 29 个值全部接受;额外兼容 3 个别名(`SOS_PRESS` / `POWER_OFF` / `POWER_ON`),建议对外以 J-00 为准 |
| 命令状态 | 契约七态(J-00 §3.4),**代码同值**;存量中文六值已由 `V1.0.23` 刷平,库里无残留 |
| 命令结果码 | 契约 `resultCode` 标准码 `0`–`4`,**代码已按此收敛**(`standardCode()`),厂家原码落 `raw_result_code` 不参与判断 |
| 回执来源节点 | I-05 顶层 `nodeId` **必填**(V1.8.1),与领取时落库的 `claimed_node_id` 比对:不符 `NODE_MISMATCH`、历史在途行 `NODE_UNKNOWN`,均不采纳。原子 `WHERE` = 节点 + 代次 + 来源状态(`V1.0.28`) |
| 未实现 | `undelivered[]` 墓碑帧数组(表结构与写法已通,H/S 线不产生,仅 G 线用) |
字段级逐项对照见 `04_OpenAPI与内部IoT契约` 目录内说明,或 20261109 提交包中的《J-00 字段差异表》。
---
## 附:`/internal/**` 的两道闸(2026-11-11 补)
五个接口都挂 `/internal/**`,进业务逻辑之前有**两层**,顺序不能换:
| 顺序 | 闸 | 拦什么 | 开关 | 默认 |
|---|---|---|---|---|
| 1 | `InternalNetworkGuard` | 来源 IP 不在白名单 → 403 | `gateway.auth.network-guard-enabled` | **开** |
| 2 | `AuthInterceptor` | Token / 时间戳 / nonce / 签名 | `gateway.auth.enabled` | 关(联调态) |
**为什么要分两个开关**:签名要和 IoT 侧同批切(单边打开全部 401),所以联调期只能关着。
但签名关着的时候如果什么都没有,`/internal/**` 就是任何能连 8080 的人都能调——
文档写的"仅内网"变成一句空话,谁都能伪造一条水浸事件。所以网络闸独立于签名开关,**默认就开着**。
白名单默认值:`127.0.0.0/8`、`::1/128`、`10.0.0.0/8`、`172.16.0.0/12`、`192.168.0.0/16`。
覆盖本机自测、局域网演示、同机 Nginx 反代三种场景。要收紧就配 `gateway.auth.allowed-cidrs`,
配了之后以配置为准(默认值不再参与)。
只认 `getRemoteAddr()`,**不认 `X-Forwarded-For`**——后者是请求头,调用方随便写。
部署在 Nginx 后面时 `remoteAddr` 是 Nginx 那台机(本机或内网),仍在白名单内;
真正的公网边界由 Nginx 做,这一层兜的是"绕过 Nginx 直连 8080"。