feat: 上线前安全加固与发布工程化;appmall 由插件迁移为独立应用

安全(P0)
- 发布包签名:新增 scripts/package_keygen.php、scripts/package_verify.php、
  ywxapp\service\PackageVerifier;FrameworkService::apply() 解压前强制验签
  (ywxapp.framework_package_verify,默认开启)
- JWT 收紧:config/jwt.php 移除公开默认密钥,JwtService::assertStrongSecret()
  校验非空 / 长度≥32 / 已知弱值;.env.production.example 键名由 JWT_SECRET_KEY 修正为 JWT_SECRET
- /api/v1/Article:免登录白名单收窄为只读接口,写操作字段白名单 + assertOwner() 越权校验
- 开发者令牌:查找接口不再回显明文、统一失败文案 + 限流
- TemplateInstaller:文件名白名单 + realpath 归一化 + 目录内断言,修复 Zip Slip;补 use ZipArchive
- 后台登录:接入验证码、失败锁定与登录限流(config/ratelimit.php)
- 缓存 / Session 驱动可由 .env 覆盖(CACHE_DRIVER / SESSION_TYPE),集群部署改 redis
- paycenter 支付日志开关改为 PAY_LOG,不再跟随 APP_DEBUG

工程化 / 交付
- 新增 CHANGELOG.md 与 .env.production.example;composer.lock 入库锁定依赖版本
- 插件离线安装 CLI 增加 --uid,后台上传安装透传 uid,付费插件授权校验可通过
- 授权拒绝文案区分「未携带 uid」与「未找到有效授权」,便于排障
- .gitignore 放行 .env.production.example / .env.addon.example / .gitattributes / public/.htaccess

结构
- appmall 由 addon/appmall 迁移为独立应用 app/appmall(市场 / 开发者中心 / 审核 / 分润)
This commit is contained in:
yangwx committed 2026-10-22 00:53:39 +08:00
1 parent 1d49e6f5ee
commit 18101cf11a
636 files changed
+58517 -9519

No files matched your search

+536
View File
@@ -0,0 +1,536 @@
# IoT 插件接口与 MQTT 主题参考
> 本文以**代码为准**(`addon/iot/route/app.php`、`controller/api/Client.php`、`controller/api/Mqtt.php`、`service/*.php`、`firmware/water_temp/water_temp.ino`)。
> 与 `device_register_mqtt.md` 冲突处,以本文为准(差异见文末「已发现的不一致」)。
---
## 一、通用约定
### 1.1 基址与鉴权
| 项 | 值 |
|---|---|
| App 接口前缀 | `https://{host}/iot/api/client/*`(接入层:绑定/共享/凭证/命令);时序·配置·告警在 `https://{host}/iot/api/monitor/*`(见 §2.4,旧 `client/*` 路径仍作兼容别名) |
| 设备 / 公开接口 | `https://{host}/iot/api/{deviceActivate,status,publish}` |
| 后台接口前缀 | `https://{host}/iot/backend/*` |
| App 鉴权 | Header `Authorization: Bearer {会员登录 token}`(框架 JWT,`ApiBase` 统一拦截,未登录 HTTP 401) |
| 后台鉴权 | `verifyAuth()` + `tokenParse()`,Bearer JWT |
| 免登录白名单 | 仅 `Client::deviceActivate`;`Mqtt` 控制器整个 `noNeedLogin = ['*']` |
### 1.2 响应格式(⚠️ 有两套,字段名不同)
**A. `Client.php`(App 全部接口)→ `apiSuccess()` / `apiError()`**
```json
{ "code": 0, "msg": "success", "data": {}, "timestamp": 1789000000 }
```
- `code=0` 成功 / `1` 业务失败 / `401` 未登录(HTTP 同为 401)
- 字段名是 **`msg`**,不是 `message`
- 携带 token 时附加 `tokens.access_token` / `tokens.access_expired` / `tokens.refresh_token`
**B. `Mqtt.php`(`status` / `publish`)→ `success()` / `error()`**
```json
{ "code": 0, "message": "success", "data": {}, "timestamp": 1789000000, "count": 12 }
```
- 字段名是 **`message`**,分页字段是 `count`
- 发布失败时 `error()` 之后**仍会继续往下执行**(`Mqtt::publish` 未 return),属已知小 bug
> ⚠️ `$this->result->setCount(n)->apiSuccess($list)` 这个组合**不会输出 `total`**:`apiSuccess()` 只认第三个参数 `$count`,而 `setCount()` 设的是 `$this->Total`(只有 HTML 版 `success()` 会用)。
> ⇒ App 端不要依赖这些接口的 `total`,条数从 `data` 数组长度取。
---
## 二、App / 会员 REST API(前缀 `/iot/api/client/`;时序·配置·告警 `/iot/api/monitor/*`)
### 2.1 设备发现与绑定
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| GET | `devices` | — | 我的 MQTT 连接记录(`iot_connection`,按 `username = 会员账号` 查,上限 200 条) |
| GET | `subscriptions` | — | 我的订阅主题列表,`data:{list,total}` |
| GET | `myDevices` | — | **我的设备**(主人 + 被共享,含 `role`、在线态、影子、**内联位置配置** `sensors/cfg_ver/sync_ver/applied`;已剔除 secret) |
| POST | `bindDevice` | `sn`(必填)、`pair_code`(设备设了配对码则必填)、`title`、`model`、`room` | 绑定;已激活设备不重置凭证;未激活且无配对码时绑定即激活 |
| POST | `unbindDevice` | `uuid`(必填) | 解绑,**会重置设备密钥** |
| POST | `shareDevice` | `device_id`、`account`(对方会员账号)、`perm`(1 只读 / 2 可控,默认 2) | 仅主人可操作 |
| POST | `revokeShare` | `device_id`、`member_id` | 撤销共享 |
| GET | `shareList` | `device_id` | 共享成员列表(仅主人) |
| POST | `resetSecret` | `uuid` | 重置密钥,新明文仅返回一次 |
> **三个标识符别混用**(各家接口用的不是一个东西):
>
> | 标识符 | 是什么 | 哪些接口用 |
> |---|---|---|
> | `sn` | `iot_device.sn` 列,**设备序列号** | `bindDevice`、`deviceActivate` |
> | `uuid` | `genUuid()` 生成,形如 `dev_` + 16 位 hex,设备 MQTT 连接用户名 | `unbindDevice`、`deviceState`、`deviceConfig`、`saveDeviceConfig`、`sendCommand`、`deviceLogs`、`publishOta`、所有 `/devices/{uuid}/…` 主题 |
> | `device_id` | `iot_device.id`(自增整数) | `shareDevice`、`revokeShare`、`groupAddDevice`、`groupRemoveDevice` |
>
> **`sn` 是不是就是 mac?** 看设备怎么入的库:
> - **即插即用注册**(当前固件走的路径,`registerByMac()`):插入时 `'sn' => $mac, 'mac' => $mac` → **`sn` 就等于纯 mac**(12 位小写 hex、去冒号)。
> - **后台预登记**(`register()`):`sn` 是手填的序列号(≤64 字符任意),真 MAC 存在独立的 `mac` 列 → 此时**必须传 `sn`**。
>
> ⚠️ `bind()` 只按 `sn` 列精确查,**没有 `mac` 兜底**(`register()` / `registerByMac()` 有 `where('mac', …)` 兜底)。
> ⚠️ 更危险:`bind()` 查不到时**直接新建一台设备**(`sn` 填你传的值、`member_id` 填你)——sn 传错不报错,会静默建出「空壳设备」,而真设备仍是 `member_id=0`(看起来绑定成功,实际设备永远不在线)。
>
> ⚠️ `myDevices` 的返回:主人侧是**整行减 `secret`**(含 `pair_code`、`activate_token`、`member_id`、`cfg_ver` 等);共享侧(`sharedToMe()`)是 12 字段白名单(`sn/uuid/title/model/room/role/perm/online/shadow/topics/…`,**无 `dev_type`/`ver`/`mac`**)→ App 统一渲染时要按缺失字段兜底。
>
> ✅ **列表已内联位置配置**(`DeviceService::attachSensorConfig()`,2026-09-14 起):每台设备(**含共享**)都带
> `sensors:[{index,alias,probes,upper,lower,gap,enabled}]` 与 `cfg_ver/sync_ver/applied`。字段与
> `deviceConfig` 接口**完全一致**(同一 `SensorConfigService::format()` 出口,见 `getAll()`)
> ⇒ App 拉一次设备列表即可画骨架(离线/首屏可用、骨架屏好做),省掉 `deviceConfig` 那次往返;
> 旧服务端不返回该字段(`sensors` 缺失或为空)时,App 回退到 `deviceConfig` 即可,新旧服务端双向兼容。
>
> ⚠️ 列表里的 `pins` 是 `iot_device.pins` 的 **CSV 字符串**(`"3,4,10"`),与 `deviceConfig` 下发的
> `pins`(数组)**不同型** —— 别把列表里的 `pins` 直接当数组用(App 端 `applyPins()` 只从
> `deviceConfig`/`/status` 取,就是为避开这个差异)。
>
> ⚠️ 实现固定 **2 条 SQL**(`whereIn(uuid)` 批量后按 uuid 分组),**不要**改成在循环里逐台
> `SensorConfigService::get()` —— 那是 N+1,设备一多就把列表接口拖垮。
### 2.2 连接凭证与指令
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| POST | `mqttTicket` | — | `{ticket(48 位 hex), account, expire_in:60, expires_at}`;顺带自愈 ACL(`resyncMemberAcl` + `resyncMember`) |
| POST | `sendCommand` | `uuid`、`payload`(JSON 字符串)、`qos`(0/1/2,默认 0) | 写入 `/devices/{uuid}/cmd`,`retain=0` |
| GET | `deviceState` | `uuid` | `{uuid, online, shadow}`(shadow = 最后一条 retained `state`) |
### 2.3 传感器配置
| 方法 | 路径 | 参数 | 返回 |
|---|---|---|---|
| GET | `deviceConfig` | `uuid` | `{cfg_ver, sync_ver, sync_at, applied, pins:[3,4,10], sensors:[{index,alias,probes,upper,lower,gap,enabled}], uuid}` |
| POST | `saveDeviceConfig` | `uuid`、`sensors`(数组或 JSON 字符串) | **全量覆盖**语义,保存即 retained 下发 |
>
> 🔑 **位置序号键统一为 `index`**(2026-09-15):`sensors[]` 元素 = `{index,alias,probes,upper,lower,gap,enabled}`。
> 服务端 `SensorConfigService::normalize()` 同时兼容旧键 `i` 与列名 `pos_index`(老 App 提交不会错位),
> 但**新代码一律只发 `index`**。
> ⚠️ 改这个键名必须同步三处消费方:`SensorPointService::capture()`(按位置取阈值复核)、App `device-panel`、
> 后台 `view/backend/admin/device.html` —— 少改一处,位置序号会**静默变 `0`**(不报错):
> 多个位置挤到同一序号,影子数据全填到位置 0,且保存时被「全量覆盖 + 同序号后写覆盖」合并成**一个位置**。
> ⚠️ 别与**下行 config 报文里的序号键**混为一谈:报文里它同样叫 `index`,而且**只有这一个键**
> (v1.7.0 的「index+i 双发」已于 2026-09-15 废除)。唯 `data[]`(≤1.4.0 老固件专用副本)仍用旧键 `i`,
> 改它等于让老设备一个位置都不启用(见 §5.2)。
- `applied = (sync_ver >= cfg_ver)`;`false` 表示设备尚未回 `config_ack`(离线,上线即生效)
- `saveDeviceConfig` 需 `PERM_CTRL`(只读共享会报「无权限修改该设备配置」)
- `pins`:**总线 GPIO 表,下标 = 位置序号**(对应 `iot_device.pins`)。取自设备上线时
`status` 报文里的 `pins`(v1.7.0+),服务端缓存后下发;设备从没上报过(≤1.6.2 老固件)
或从没上线过则为 `[]` —— 此时 App 靠 state 报文里的 `pin` 自行补齐显示
### 2.4 实时曲线与时序聚合(前缀 `/iot/api/monitor/*`)
> ⚠️ **三个接口返回结构互不相同**,这是 App 端最容易踩的坑:`sensorSeries` 回**原始稀疏点**,
> `sensorAvg` / `recentAvg` 回**聚合值**。拿 series 的键(`temp`/`interval`/`temp_diff`/`probes`/`alert_mask`)
> 去读聚合返回 ⇒ 键不存在,曲线**全是 `0` 和 `[]`**,而数据库里明明有数据(`ts` 两边都有,所以时间轴是对的)。
> 症状口诀:**「时间点对、温度全 0」= 读错键,不是没数据。**
| 方法 | 路径 | 参数 | 返回 `data` |
|---|---|---|---|
| GET | `sensorSeries` | `uuid`、`pos=0`、`from`、`to`、`limit=500`(钳 [1,2000])、`debug=1?` | `{uuid,pos,from,to,count,points}` |
| GET | `sensorAvg` | `uuid`、`pos=0`、`step=60`(钳 [60,86400])、`window=30`、`from`、`to`、`limit=720`、`debug=1?` | `{uuid,pos,step,from,to,count,points}` |
| GET | `recentAvg` | `uuid`、`window=10`(钳 [1,1440] 分钟)、`pos`(默认 `-1` = 全部位置) | `{uuid,window_min,from,to,samples_total,positions}` |
**① `sensorSeries` → `points[]`:原始稀疏点(细粒度,逐条上报)**
```json
{ "ts": 1789700400, "interval": 120, "temp": 26.5, "temp_diff": 0.2,
"probes": [ { "id": "A3F2", "temp": 26.5 } ], "alert_mask": 0 }
```
- `interval` = 本条覆盖秒数(曲线有效期 `[ts, ts+interval]`);`temp=null` = 该位置本刻**无读数**(不是 0℃)
- `probes[].id` = 探头 ROM 短码(未识别为 `null`);`alert_mask`:bit j = 槽位 j 越限、bit7 = 位置内温差超限
- 服务端**不展开**等间隔序列(温变上报 10~120s 一条,展开纯浪费带宽)⇒ 平滑曲线由客户端前向填充
**② `sensorAvg` → `points[]`:按 `step` 分桶(画趋势用这个)**
```json
{ "ts": 1789700400, "avg": 26.54, "min": 26.5, "max": 26.56, "samples": 86, "coverage_s": 1800 }
```
- 温度读 **`avg`**(时间加权 `SUM(t×秒)/SUM(秒)`,温变期比简单 AVG 准)—— **没有 `temp` 键**
- `ts` = 桶起点(UTC 对齐,App 按本地时区格式化);`coverage_s < step` = 桶内有断档,可画「数据缺口」
- 「昨天 11:00 → 现在,每 30 分钟一点」:`step=1800&from=1789614000&limit=2000`(不传 `from` 时用 `window=N` 分钟)
- **只回有数据的桶**,空档不补点(服务端不铺骨架、不补 0);想要连续骨架由客户端按 `step` 自己铺
**③ `recentAvg` → `positions[]`:每位置一条汇总值(首屏/排障用这个)**
```json
{ "index": 1, "alias": "位置B", "avg": 39.6, "min": 31.81, "max": 46.19,
"samples": 57, "coverage_s": 3480, "last_ts": 1789699572, "data_ok": true }
```
- 无样本时 `avg=null` 且 `data_ok=false`(前端显示「无数据」,**不要显示 0℃**)
**排障:`debug=1`(`sensorSeries` / `sensorAvg` 都支持)**
附加 `data.debug`:`database / table / sql / server_ts / uuid / pos / from / to / rows_uuid / rows_pos / rows_range / min_ts / max_ts / pos_min_ts / pos_max_ts / recent[3] / error` —— 用于定位「直接跑 SQL 有数据、接口却空 / 全 0」:
- `database`、`table` 与 SQL 客户端不一致 ⇒ 接口连错库 / 表前缀不对
- `rows_uuid=0` ⇒ 这个库里没有这台设备(你查的是另一个库/另一个站点)
- `rows_uuid>0` 但 `rows_range=0` ⇒ 时间条件不成立或时钟偏移(比 `server_ts` 与 DB 的 `UNIX_TIMESTAMP()`,并确认 `ts` 单位是秒不是毫秒)
- `error` 非空 ⇒ 原来被 `catch` 静默吞掉的异常原文(同时落 `runtime/log`:`[iot] sensorSeries/sensorAvg 查询失败`)
### 2.5 日志与告警
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `deviceLogs` | `uuid`、`page=1`、`size=20`(上限 100)、`direction=in\|out`(空 = 全部) |
| GET | `alertList` | `page`、`size`(上限 100)、`unread=0\|1` |
| POST | `readAlert` | `ids[]`(**不传或空数组 = 全部标已读**),返回 `{affected}` |
| GET | `alertUnread` | — → `{unread}` |
### 2.6 场景联动
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `sceneList` | — |
| GET | `sceneDetail` | `id` |
| POST | `saveScene` | `id?`、`title`、`icon`、`enabled`、`triggers`(JSON 串)、`actions`(JSON 串) |
| POST | `deleteScene` | `id` |
| POST | `toggleScene` | `id`、`enabled`(0/1) |
| POST | `runScene` | `id`(手动触发) |
| GET | `sceneLogs` | `id`、`page`、`size` |
```jsonc
// triggers[]
{ "type": "device_state|schedule|manual",
"topic": "/devices/+/state",
"condition_json": [{"field": "positions.0.temp", "op": "gt", "value": 60, "ref": "..."}],
"cron": "0 8 * * *",
"enabled": 1 }
// actions[]
{ "type": "send_cmd|http|notify",
"device_uuid": "...",
"payload_json": "...",
"topic": "..." }
```
### 2.7 设备分组
| 方法 | 路径 | 参数 |
|---|---|---|
| GET | `groupList` | — (含设备数 / 在线数) |
| POST | `saveGroup` | `id?`、`title`、`type`(room/custom)、`sort?` |
| POST | `deleteGroup` | `id` |
| POST | `groupAddDevice` | `group_id`、`device_id` |
| POST | `groupRemoveDevice` | `group_id`、`device_id` |
| GET | `groupDevices` | `group_id` |
### 2.8 OTA
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| GET | `otaList` | `uuid?`(不传 = 全部设备) | 返回 `{list:[{device, ota\|null}]}`,按 `model` 取最新启用固件 |
| POST | `publishOta` | `ota_id`、`uuid` | 需 `PERM_CTRL` |
---
## 三、设备侧 / 运维 API(公开)
| 方法 | 路径 | 参数 | 说明 |
|---|---|---|---|
| POST | `/iot/api/deviceActivate` | `sn`、`token` | 入云激活(免登录),幂等:已激活不再下发 secret |
| GET | `/iot/api/status` | — | `{online, total, messages, topics}` |
| POST | `/iot/api/publish` | `topic`、`payload`、`qos` | **免登录、无命名空间校验**,直接进出站队列 |
> ⚠️ **安全提醒**:路由 `api/publish` 指向的是 `Mqtt::publish`(`noNeedLogin=['*']`),它**没有** `topic` 前缀校验;
> 而带 `{account}/` 命名空间隔离的那版 `Client::publish` **未被路由绑定**。
> ⇒ 该接口目前任何人可往任意主题发消息。上线前建议改为只走 `client/publish` 或补鉴权。
---
## 四、MQTT 连接参数
| 项 | 值 |
|---|---|
| TCP | `mqtt://{host}:1883`(MQTT 3.1.1 / 5.0) |
| WebSocket | `ws://{host}:8083`(**无子路径、二进制帧**);iOS 线上必须 `wss://` |
| 端口来源 | 后台「服务设置」`/iot/backend/setting` 的 `tcp_port` / `ws_port` |
| 设备 clientId / username | 均为 `uuid`;password = `secret` 明文(库中存 bcrypt) |
| 注册阶段 clientId | 建议直接用 `mac`(未激活无凭证) |
| App clientId | 建议 `app-{account}-{随机串}`,避免与设备 `uuid` 撞名 |
### 认证三选一
| 方式 | username | password | 前提 |
|---|---|---|---|
| 认证账号 | 后台「认证」表账号 | 对应密码 | `iot_auth` 启用 |
| 会员账号 + 登录密码 | `member_user.account` | 会员登录密码(bcrypt 校验) | 后台开 `allow_member_login` |
| **一次性 ticket(推荐)** | `account`(`mqttTicket` 返回) | `ticket` | 每次连接 / 重连前重新换,TTL 60s |
- Clean Session 建议开启;重连不依赖持久会话,靠 retained 兜底
- 订阅被拒时服务端**仍回 SUBACK**,结果码 `0x80`(3.1.1)/ `0x87`(5.0)→ App 必须处理 `onSubAck`
---
## 五、设备私有主题 `/devices/{uuid}/…`
> 📄 **主题速查(功能 / 参数 / 谁发谁订 / 权限矩阵 / 排错)见 `iot_topics.md`** —— 本节只保留报文版式与历史沿革细节。
| 主题 | 方向 | QoS | retain | payload |
|---|---|---|---|---|
| `state` | 设备 → 服务端 | 0 | **1**(影子) | 见 5.1 |
| `status` | 设备 → 服务端 | 0 | **1** | 上线 `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 顺带报一次总线 GPIO 表);LWT 固定 `{"online":0}` |
| `config` | 服务端 → 设备 | 1 | **1** | 见 5.2 |
| `ack` | 设备 → 服务端 | 0 | 0 | `config_ack` 回执,见 5.3 |
| `cmd` | 服务端 / App → 设备 | 请求决定(默认 0) | 0 | `{"action":"..."}`,见 5.4 |
| `ota` | 服务端 → 设备 | 1 | 0 | 见 5.5 |
| `ota/report` | 设备 → 服务端 | 0 | 0 | 见 5.5 |
| `event` | 设备 → 服务端 | 0 | **1** | 见 5.6 |
### 5.1 `state`(retained 影子)
固件 **v1.7.0** 起键名压到 2 字母,并去掉两处冗余(顶层 `uuid`、每位置 `pin`);
结构沿用 **v1.6.0** 的**紧凑版式**
(全长报文 ≈533B → ≈370B(v1.6.0)→ ≈349B(v1.7.0 键名)→ ≈300B(v1.7.0 去冗余)):
```json
{ "ts": 1789000000, "ver": "1.7.0", "rssi": -57,
"iv": 120, "cv": 7,
"pos": [
{ "t": 45.63, "df": 0.25,
"pr": [ ["A3F2", 45.50], ["B71C", null] ] },
null
] }
```
> **两处冗余为什么能去掉**
> ① `uuid`:报文本来就发布在 `/devices/{uuid}/state` 上,「谁发的」主题里已经写着;
> 服务端 `SensorPointService::capture()` 本来就从主题正则取 uuid,App 订阅时也早知道 ⇒ 省 ≈25B。
> ② `pin`:它是**编译期常量** `ONEWIRE_PINS[下标]`,设备这辈子都不会变。改成设备**上线时**
> 在 `status` 报文里报一次(见上表),服务端缓存进 `iot_device.pins`、随 `deviceConfig`
> 下发 ⇒ 人眼对接线端子的信息一点没少,每启用位置省 ≈8B。
> ⚠️ **已烧录的 ≤1.6.2 固件仍会带这两个键**,服务端/App 一律「有则用、无则退」,
> 所以**不需要重烧、也不需要灰度窗口**。
> **键名沿革**(服务端 / App 一律「新键优先、旧键兜底」,**老固件不用重烧**):
> `iv` ← `interval`(≤1.5.0 也曾叫 `iv`)、`pos` ← `positions`、`pr` ← `probes`、`cv` ← `cfg_ver`。
> 只改键名,结构与语义一字未动。
| 字段 | 含义 |
|---|---|
| `pos`(旧名 `positions`) | ⚠️ **数组下标就是位置序号**(0 起,= OneWire 总线 A/B/C),**已没有 `index` 字段**;`null` = 该位置未启用(尾部占位会截掉,**中间的不截** —— 截了下标就前移) |
| ~~`pin`~~ | ⚠️ **v1.7.0 起 state 不再带此键**(改由设备上线时在 `status` 报一次 → 服务端缓存 → 经 `deviceConfig` 的 `pins` 下发)。它本来就是**纯属性、不是身份**:换板/改脚时它变、位置序号不变,所以别名/阈值/历史/告警都还能对上。≤1.6.2 老固件仍会带,解析照旧 |
| `t` | 该位置温度(有效探头均值);`null` = 无有效读数(`null` 显示 `--`,别显示 0) |
| `df` | 位置内极差(max − min);`null` 同 `t` |
| `pr[]`(旧名 `probes`) | 该总线各探头;**下标 == 总线槽位**,尾部空槽已截(空总线 `[]`)。元素是**二元组 `[身份, 温度]`** |
| `pr[][0]` | 探头身份(ROM 短码);`null` = 未识别。**ROM 枚举到就上报,与读数成败无关** |
| `pr[][1]` | 该槽温度;`null` = 无读数。配身份可区分「没插」与「插了读不到」 |
| `ts` | NTP Unix 秒,`0` = 未对时 |
| `iv`(旧名 `interval`) | 本条覆盖秒数(前向填充有效期 `[ts-iv, ts]`)。≤1.5.0 的固件也叫 `iv` |
| `cv`(旧名 `cfg_ver`) | 设备当前生效配置版本;与本地不一致就重拉 `deviceConfig`(HTTP 字段名仍叫 `cfg_ver`) |
> **压字节的账(v1.7.0 已做全)**:`pos`/`pr` 的字节数随「位置数 × 探头数」线性增长,是重点;
> `iv`/`cv` 每条只出现一次、收益最小,但既然这份报文的定位就是「压到最小」,索性一次压到底:
> ① 键名 `interval/positions/probes/cfg_ver` → `iv/pos/pr/cv`;② 去掉顶层 `uuid`;
> ③ 去掉每位置的 `pin`(改为上线时在 `status` 报一次)。
> 累计 549B → 300B(3 位置 2 探头),**其中高频部分压掉约 45%**。
> **探头身份(`probes[][0]`)怎么来的**(v1.6.1):DS18B20 的 64bit ROM = `[0]` 家族码(0x28)
> + `[1..6]` 48bit 序列号 + `[7]` CRC8,身份无信息量的两端不参与。短码取**序列号末 2 字节**
> (`addr[5..6]`)→ 4 位十六进制;若末 2 字节为 `0000`(克隆/翻新片常见:序列号高位恒 0),
> 依次退让到中 2 字节、前 2 字节,取第一个非 0 的 —— 这类片才有身份。
> **只有 6 字节序列号全 0** 才发 `null`:那种片各片 ROM 完全相同,任何切片都分不开,
> 报文上只能按槽位区分(固件串口会明确提示)。
> 🚨 **身份是 `null` 但温度有值(`[null, 24]`)= 取不到 ROM 短码,不是读数失败**:
> `[null, null]` 才是「该槽没插器件」。三种成因按概率排:
> ① 固件 **< v1.6.1** 且上电瞬间 `getAddress()` 失败过一次 —— 旧代码照样把计数记成枚举数,
> 「数量变了才重读 ROM」永久为假 ⇒ 身份永久 `null`(**温度正常、重启也不恢复**,v1.6.1 已修);
> ② 该片序列号 6 字节全 0(无唯一身份,只能按槽位区分);③ 该槽真的没插(此时温度也 `null`)。
> **更早的三个版式**(服务端与 App 一律按「键在不在」判版式,**不比较版本号字符串**):
> - **v1.6.0**:`positions[].{pin,t,df,probes[[id,temp]]}` —— 与 v1.7.0 完全同构,只是键名写全。
> - **v1.5.0**:`positions[].{index,pin,temp,temp_diff,probes[{id,temp}]}` —— 键名写全、按 `index` 对齐。
> 与 v1.6.0+ 靠**元素里有没有 `index`** 区分(v1.5 每个位置都写,v1.6 起彻底删掉)。
> - **≤1.4.0**:`data[].{i,t,df,v,vs[],ok[],ids[]}` + 顶层 `iv`。
> ⚠️ 旧版的 `t` 与 v1.6.0+ 的 `t` **同键名但语义不同**:旧版靠 `v` 标志判有效(`v=0` 时 `t` 照样带
> `0`),新式靠 `null` 判 —— 解析必须**先判有没有 `v`**,否则会把「没读数」存成 `0.00℃`。
> - 解析实现:服务端 `SensorPointService::capture()`;App `normalizePositions()` / `normalizeProbes()`;
> 场景条件路径 `SceneService::upgradeStatePath()` —— 它把 `data.` / `positions.` / `probes.` /
> `interval.` 这些历史词汇自动收敛到 `pos.` / `pr.` / `iv.`。⚠️ **改报文键名时必须同步这里**,
> 否则库里已存的场景条件会「取不到值 → 恒不成立」,且**不报错**(静默失效)。
**上报节奏**:采样 10s;触发上报 = 温变 ≥ 0.3℃ / 有效性掩码变化 / 告警翻转 / 距上次 ≥ 120s(另加开机、重连、收到新配置)。
### 5.2 `config`(服务端下发,retained,QoS1)
```json
{ "cv": 7,
"pos": [ {"index":0, "alias":"位置A", "pn":3, "upper":85, "lower":0, "gap":2} ] }
```
全量覆盖语义:`pos` 里没有的位置一律停用。设备写入 NVS 后回 ack。
`pos[]` **只含启用位置**(服务端 `get(uuid, true)`),所以元素里的 `index` 是必需的(见下)。
⚠️ 旧固件(≤1.7.1)收到本格式只回 ack `missing pos`:配置不生效,但**不会误报成功**,一眼可见。
> 🔑 **上下行同键名**(v1.7.2 起):下行 `cv` / `pos` / `pn` 与 §5.1 的 state 完全一致,抄报文不用换脑子。
> v1.7.0 曾为「已烧录的老固件不重烧也能收配置」叠了三层兼容:下行写长名 `positions`/`probes`、
> 复制一份旧式 `data[]`、标量新旧同发(`cfg_ver`/`probes` 与 `cv`/`pn` 同值双发)——
> 2026-09-15 **全部删除**。理由:现场只有两台自用设备、都会重烧固件,兼容收益为 0,而它
> 每多一个「同义键」就多一把备用钥匙:服务端读 `index`、固件读 `i`,改键名时只改一边就
> **静默拆锁**(当天现场事故:位置序号全变 0、数据全填位置 0、编辑一行改掉所有行)。
> 旧的「宁可多发 6B 标量也不复制数组」是为了绕开已烧录设备的 1024B ArduinoJson 解析池;
> 现在没有老设备,这个约束也就没有必要了。
> · ⚠️ `probes` 在**上行是探头明细数组、下行原是探头数(数字)** —— 下行改名 `pn` 后两义消失。
> 🔑 **下行必须有 `index`,上行反而没有** —— 这是唯一一处上下行"不对称",**不是漏改**:
> · 上行 state 的 `pos[]` 靠**数组下标**定位序号:未启用位置写 `null` 占位把下标撑住,
> 每个位置省 10B,而它是每 10s~120s 一条的高频报文,字节值钱;
> · 下行 `pos[]` **只含启用位置** ⇒ 下标会塌缩(位置 0 停用时,位置 2 排在报文下标 0 上)
> ⇒ 元素里必须带 `index`,固件也**不能用下标顶替**(见固件 `onConfigJson` 注释)。
> 🔴 升级顺序:**先烧固件 ≥1.7.2,再上线本改动。**
### 5.3 `ack`(设备 → 服务端,非 retained)
```json
{ "type":"config_ack", "success":true, "message":"...", "cv":7,
"mac":"a0b1c2d3e4f5", "uuid":"A1B2C3D4E5F6" }
```
`cv` 是唯一的版本键(v1.7.2 起;≤1.6.2 老固件回 `cfg_ver`,服务端已不再兜底)。
服务端 `SensorConfigService::onAck()` 据此记 `sync_ver`。
### 5.4 `cmd`(下发命令)
```json
{"action": "report"} // 立刻上报一次 state
{"action": "reboot"} // 重启(保留凭证)
{"action": "wifi_reset"} // 只清 WiFi 凭据 → 重启进配网页;凭证/配置保留
{"action": "clear_identity"} // 清 uuid/secret/配置 → 重启自动重注册;WiFi 保留(服务器写死在固件)
{"action": "factory_reset"} // 全清(含 WiFi)→ 进配网页
```
- 三个清除类动作设备会先回 `ack` 再重启(`delay(500)` 等 ack 出网)
- 未知 `action` 只打日志 `[CMD] unknown action=...`,不报错
- 前提:调用方对 `cmd` 有**发布权限**(主人 或 `perm=2` 共享者)
### 5.5 `ota` / `ota/report`
```json
// 下行 /devices/{uuid}/ota(固件实际只用 url + version,hash/size 被忽略)
{"version":"1.2.4", "url":"https://.../fw.bin", "hash":"sha256(64hex)", "size":123456}
// 上行 /devices/{uuid}/ota/report
{"state":"downloading|installing|done|fail", "progress":42, "version":"1.2.4", "error":"http 404"}
```
`done` → 写 level 1 告警;`fail` → level 3 告警;其它 → `push` 进度通知(不写告警表)。
### 5.6 `event`(retained 告警)
固件 **v1.6.0** 起为结构化告警(**v1.7.0** 起键名 `probes` → `pr`):
```json
{ "type": "alert",
"alerts": [
{ "alias": "位置A", "t": 92.5, "df": 0.3, "rs": "ov",
"pr": [ [0, "A3F2", 92.5] ] }, // 旧名 probes(≤1.6.2),服务端两版都认
null
] }
```
- `alerts` **数组下标 = 位置序号**(未告警的位置写 `null` 占位,尾部占位截掉)
- `rs` 是原因**短码**:`ov` = 探头越限 / `df` = 位置内温差超限。服务端
`AlertService::REASON_TEXT` 统一展开成中文 —— **改文案不用重烧固件**。
(v1.5.0 发的是中文字符串 `reason`,一条约 24B;≤1.4.0 发 `overunder` / `gap`)
- `alias` **保留写全**:它是「当时的叫法」的快照,告警文案要能独立成立,不依赖事后查配置
- `probes[]` 只列异常探头(稀疏),故每条是三元组 `[槽位, 身份, 温度]` —— 槽位必须显式带,不能靠下标推
- 旧版对应字段:`bad[]`(槽位下标)+ `bad_ids[]`(探头身份,与 `bad[]` 一一对应);
且 ≤1.4.0 的 `alerts` 里根本没有位置号 → 服务端只能记 `POS_NA`(255)
- `AlertService::onDeviceEvent()` 三版都认,逐位置落 `iot_alert`(含 `pos_index`)
采样后判定:新出现告警立即发,持续告警按 `ALERT_REPEAT_MS` 复报。
---
## 六、注册主题与通知主题
### 6.1 注册(MAC 即插即用)
| 主题 | 方向 | payload |
|---|---|---|
| `system/device/register/{mac}` | 设备 → 服务端 | `{"mac":"a0b1c2d3e4f5","type":"esp32c3_temp","model":"KF-C3-3KANG","ver":"1.0"}` |
| `system/device/register/{mac}/resp` | 服务端 → 设备 | `{"code":0,"message":"ok","data":{...}}` |
- **必须先订阅 resp 再 publish 注册**,否则错过回执(回执 QoS1、**非 retained**)
- mac 必须 12 位小写 hex(去冒号),且与主题段一致
- 首次注册回 `secret` 明文,之后幂等更新 `type/model/ver` 不再给 secret
- ACL:这两个主题**恒定放行**,不依赖 ACL 开关,可匿名连接完成注册
- 旧协议兼容:`system/device/register/{sn}` + payload `{"token":"..."}` 或裸 token
### 6.2 通知 `/notify/{account}/{子主题}`(QoS1,retain=1)
| 子主题 | 触发 | payload 摘要 |
|---|---|---|
| `alert` | 设备离线 / OTA 完成失败 | `{"id","type":"alert","level":1..3,"title","content","uuid","time"}` |
| `event` | 设备 `/event` 上报 | 同上,`type=event` |
| `scene` | 场景 `notify` 动作 | `{"id","type":"scene","level":2,"title","content","uuid","time"}`(`SceneService` → `AlertService::add($memberId,'scene',2,title,content)`;原先此处写的 `scene_id`/`message` 字段代码里并不存在,2026-09-15 校正) |
| `ota` | 升级进度 | `{"type":"progress","uuid","state","progress","version","time"}` |
| `ota` | 升级完成 / 失败 | `{"type":"alert","uuid","level","title","content","time"}` |
`time` 均为 Unix 秒。App 统一订阅 `/notify/{account}/#` 即可。
---
## 七、服务端消息流(Broker 钩子)
```text
设备 publish /devices/{uuid}/{state,event,ota/report,ack} 或 system/device/register/{mac}
→ Broker(QoS0/1 立即分发;QoS2 在 PUBREL)
├─ DeviceLogService::log 入库留痕(仅 /devices/*)
├─ SceneService::onMessagePublished 场景条件评估 → 动作入 outbox
├─ OtaService::onReport ota/report → 告警 / 进度通知
├─ AlertService::onDeviceEvent event → 告警 + notify
├─ SensorConfigService::onAck ack → 记 sync_ver
└─ handleDeviceRegister 注册 → 回执 dist 到 resp
→ outbox 消费器(0.5s)发布 cmd / notify
```
ACL 关键规则(`Auth::checkAcl` 判定顺序):
1. `$SYS` 只读
2. 注册主题放行
3. 超管放行
4. **私有主题硬归属**(`/devices/{uuid}`、`/notify/{account}`)
5. `acl_enabled` 开关
6. 首条命中规则
7. `acl_default` 兜底
私有主题只看 `iot_device.member_id` + `iot_share`:设备本体放行、主人全权、`perm=2` 全权、`perm=1` 仅可订阅 `state|status|event|config|ack|ota/report` 且**不能发 `cmd`**;`/devices/+/state` 这类跨设备通配符默认拒绝。
---
## 八、后台接口(`/iot/backend/*`,Bearer JWT)
| 分类 | 接口 |
|---|---|
| 概览 | `GET index`、`statsData` |
| 连接 / 消息 | `GET connections`、`connectionsTable`、`messages`、`messagesTable` |
| 发布 / 设置 | `GET|POST publish`、`GET|POST setting` |
| 认证 | `GET auth`、`authTable`、`POST saveAuth`、`deleteAuth` |
| ACL | `GET acl`、`aclTable`、`POST saveAcl`、`deleteAcl` |
| 设备 | `GET device`、`deviceTable`、`deviceLogs`、`deviceConfig`;`POST saveDevice`、`resetDeviceSecret`、`resetActivateToken`、`deleteDevice`、`saveDeviceConfig` |
| 规则 | `GET rule`、`ruleTable`;`POST saveRule`、`deleteRule` |
| 场景 | `GET scene`、`sceneDetail`、`sceneLogs`;`POST saveScene`、`deleteScene`、`toggleScene`、`runScene` |
| 分组 | `GET group`;`POST saveGroup`、`deleteGroup` |
| 告警 | `GET alertsPage`、`alerts`;`POST readAlertAll` |
| OTA | `GET ota`、`otaList`、`otaTable`;`POST saveOta`、`deleteOta`、`toggleOta`、`publishOta` |
> ⚠️ broker 配置**只在启动时读一次** → 后台改完「服务设置」必须重启 broker。
> ⚠️ 后台「删除设备」是**硬删**(清 `iot_device`/`iot_auth`/`iot_acl`/`iot_retain`)+ 设备端 NVS 仍留旧 uuid → 设备永久变砖,见 `device_register_mqtt.md` 的 BOOT 键重置章节。
---
## 九、已发现的不一致 / 待修正
1. `device_register_mqtt.md` 称「插件 API 的 `Result::success` 用 `code=1` 表示成功」——**错误**,`Result::success()` 内 `code` 硬编码为 `0`。
2. 同文档「用户绑定设备识别码」返回示例写 `"code": 1` 表示绑定成功——**反了**,实际 `code=0` 成功、`code=1` 失败;且 `Client` 系列字段名是 `msg` 而非 `message`。
3. 文档 / 代码注释里出现的 `/iot/api/myDevices`、`/iot/api/mqttTicket` 等(无 `client` 段)是历史写法;`route/app.php` 实际注册的是 `/iot/api/client/*`。
4. `setCount()` 与 `apiSuccess()` 不配套 → 这些接口的响应里没有 `total`。
5. `/iot/api/publish` 无鉴权、无 topic 校验,属安全隐患(见第三节)。