Files
YwxAppThink/docs/guide/app_realtime_push.md
T
yangwx 18101cf11a 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(市场 / 开发者中心 / 审核 / 分润)
2026-10-22 00:53:39 +08:00

308 lines
20 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.
# App 实时推送接入指南(智慧生活)
> 面向 App 客户端的 MQTT 实时通知链路。REST API 负责查询与指令,MQTT 负责「设备状态 / 告警 / 场景 / OTA」的实时下行推送。绑定设备后,服务器自动为会员放行只读的 `/notify/{account}/#` 订阅。
## 1. 连接地址
| 协议 | 地址 | 说明 |
| --- | --- | --- |
| MQTT over TCP | `mqtt://{host}:1883` | 原生 MQTT 3.1.1 / 5.0 |
| MQTT over WebSocket | `ws://{host}:8083` | App(uni-app x)/ 浏览器 / 小程序首选,二进制帧,无子路径 |
端口以「服务设置」页(`/iot/backend/setting`)实际配置为准。
> **Windows 开发机联调**:Workerman 一个进程只能监听一个端口,需开**两个 cmd 窗口**分别启动:
> ```bash
> 窗口1: php think iot start --tcp-only # MQTT over TCP :1883
> 窗口2: php think iot start --ws-only # MQTT over WebSocket :8083
> ```
> 前提:后台「服务设置」开启「Windows强制WebSocket」(`ws_force=1`)与「启用集群桥接」(`cluster_enabled=1`,需本机 Redis 可连),两进程经 Redis Stream 互通;Linux 生产一条命令即可。
## 2. 连接认证(三种方式)
- **方式一:认证账号**:后台「服务设置」维护的 MQTT 专用账号,`username` / `password` 直接填。
- **方式二:会员账号 + 登录密码**(开启「允许会员登录」后):`username`=会员账号(`member_user.account`)、`password`=登录密码(bcrypt 校验)。适合内测/自用。
- **方式三:一次性凭证 ticket(对外分发推荐)**:App 登录后先调 REST 换 60s 短时效凭证,再以 `username`=会员账号、`password`=ticket 连接,**会员明文密码不出客户端**(防反编译泄露):
```http
POST /iot/api/client/mqttTicket # 需带 Bearer 登录 token
→ { "code": 0, "data": { "ticket": "48位hex", "account": "会员账号", "expire_in": 60, "expires_at": 1735... } }
```
- 每次建立连接 / 自动重连前都应**重新换取**(ticket 过期即失效,重连复用旧凭证会在 60s 后失败)。
- 服务端校验:ticket 存在 + 未过期 + 与连接用户名绑定一致,通过后按会员身份放行(等价 `reason=ticket`)。
- clientId 建议 `app-{account}-{随机串}`,避免与设备 clientId(=`uuid`)冲突。
- 密码/ticket 校验失败将收到 CONNACK 拒绝,需要重新获取凭据。
- 建议开启 Clean Session 并在订阅时 **不要** 使用持久会话;重连后 retain 消息会兜底补拉最新状态。
## 3. 订阅主题
统一订阅:`/notify/{account}/#`(account 为 URL 转义前的原始账号,含 `@` 等字符可直接订阅)
| 子主题 | 触发时机 | payload 摘要 |
| --- | --- | --- |
| `alert` | 告警写入(设备离线 / 事件上报 / OTA 完成失败) | `{"id":..,"type":"alert","level":1..3,"title":"..","content":"..","uuid":"..","time":..}` |
| `event` | 设备主动事件上报 | 同上(`type=event`),level 1提示 / 2警告 / 3严重 |
| `scene` | 场景动作 `notify` 执行 | `{"type":"scene","scene_id":..,"title":"..","message":"..","time":..}` |
| `ota` | OTA 进度(下载中/安装中) | `{"type":"progress","uuid":"..","state":"downloading","progress":42,"version":"1.2.0","time":..}` |
| `ota` | OTA 完成/失败 | `{"type":"alert","uuid":"..","level":1|3,"title":"..","content":"..","time":..}` |
统一约定:所有 notify payload 均为 JSON,含 `type` 字段区分场景;`time` 为 Unix 秒。
## 4. retain 兜底
- 服务器推送时 `retain=1`,最新一条告警/进度会保留在 Broker 中。
- App 断线重连后无需「补偿拉取」即可收到最近一条;历史列表仍通过 REST `alertList` / `deviceLogs` 拉取。
## 5. 端到端消息流
```
设备上报 /devices/{uuid}/state|event|ota/report
→ Broker 钩子
├─ DeviceLogService::log (in 留痕)
├─ SceneService::onMessagePublished (场景评估 → 动作入 outbox)
├─ AlertService::onDeviceEvent (event → 告警 + notify)
└─ OtaService::onReport (OTA → 告警 + notify)
→ outbox 消费器(0.5s) 发布
├─ /devices/{uuid}/cmd (场景下发指令)
└─ /notify/{account}/# (retain 实时通知)
→ App 订阅收到 → 本地弹窗 / 红点 / 进度条
```
## 6. 与 REST API 的分工
| 场景 | 用哪个 |
| --- | --- |
| 进入页面首屏 / 历史记录 / 分页 | REST(`myDevices`(**已内联 sensors/cfg_ver,首屏骨架一次到位**)、`deviceState`、`alertList`、`deviceLogs`、`otaList`) |
| 实时状态变化 / 告警弹窗 / OTA 进度 | MQTT notify 订阅 |
| 下发控制指令(开关 / 场景 / OTA) | REST(`sendCommand`、`runScene`、`publishOta`) |
建议 App 连接建立成功、收到 CONNACK 后再注册 REST 轮询兜底(如 30s 拉一次未读数),MQTT 断线时自动切换。
## 7. uni-app x(uvue)联调页面
插件自带一个可独立运行的 MQTT 联调页面,含「连接 / 订阅 / 发布 / 消息流」,App 端(Android/iOS)真机即可直连验证整条链路。
### 7.1 引入工程
1. 把插件目录 `addon/iot/client/uvue/mqtt-debug/` 整个复制到 uni-app x 工程,例如放到 `pages/mqtt-debug/`(`index.uvue` 与 `MqttClient.uts` 必须同目录,页面用相对路径 import)。
2. `pages.json` 注册页面:
```json
{ "path": "pages/mqtt-debug/index", "style": { "navigationBarTitleText": "MQTT 联调" } }
```
3. `MqttClient.uts` 是通用 MQTT 3.1.1 over WebSocket 客户端(无任何第三方依赖,基于 `uni.connectSocket` 二进制收发),可复用到业务页面。
### 7.2 联调步骤
1. 后台「服务设置」确认 `ws_port`(默认 8083);Windows 开发机把 `ws_force` 设为「开启」、`cluster_enabled` 设为「开启」(需本机 Redis)。
2. 启动 Broker:Linux 一条命令 `php think iot start -d`;Windows 开两个窗口分别 `--tcp-only` 与 `--ws-only`(见第 1 节提示)。
3. 页面填写 `ws://{局域网IP}:8083`;账号填会员账号(会员须已绑定/被共享该设备)或认证账号(需 ACL 规则显式放行),密码对应;ClientID 留空自动生成。
4. 点「连接服务器」,状态变「已连接」。
5. 订阅 `/devices/{uuid}/state`(把 `{uuid}` 换成自己的设备):设备上报状态会实时滚入消息流。
6. 发布 `/devices/{uuid}/cmd`:可下发开关指令,观察设备(或模拟器)响应。
7. 订阅 `/notify/{账号}/#`:设备事件 / 告警 / OTA 进度实时推送,验证「第 5 节」链路。
> **归属校验(必读)**:`/devices/{uuid}/...` 与 `/notify/{account}/...` 是私有主题,服务端按**设备绑定与共享关系**硬校验,
> 与「启用ACL」「ACL默认策略」无关。因此:
> - 只能订阅/发布**自己绑定的设备**,或被共享给自己的设备(`perm=1` 只读,仅可订阅 state/status/event/config/ack/ota/report;`perm=2` 可控,含 cmd/ota);
> - `/devices/+/state` 这类**跨设备通配符默认拒绝**(否则等于把全站设备暴露给一个人),确需监控时由管理员在「ACL规则」中为该账号显式放行;
> - 匿名连接(不带用户名)不享有任何设备权限,仅能完成 `system/device/register` 入网。
### 7.3 代码接入要点(业务页面直接使用)
```ts
// 方式二:会员账号 + 登录密码(内测/自用)
const client = new MqttClient({
url: 'ws://192.168.1.10:8083',
clientId: 'app-' + Date.now().toString(),
username: '会员账号', // allow_member_login=1 时用会员账号,否则用认证账号
password: '密码',
autoReconnect: true
})
client.onConnAck = (code: number) => { /* 0=成功 */ }
client.onMessage = (topic: string, payload: string) => { /* 解析 JSON 弹窗/红点 */ }
client.connect()
client.subscribe('/notify/' + account + '/#', 0)
```
```ts
// 方式三(推荐):一次性凭证 —— 每次连接/重连前先 HTTP 换取,密码不落客户端
async function connectMqtt() {
const resp = await request('/iot/api/client/mqttTicket', { method: 'POST' }) // 带 Bearer
const { ticket, account } = resp.data
const client = new MqttClient({
url: 'wss://mq.example.com:8083',
clientId: 'app-' + Date.now().toString(),
username: account, // ticket 绑定的会员账号
password: ticket, // 60s 一次性凭证
autoReconnect: false // 重连前必须重新换取
})
client.onClose = () => { /* 停 1s 后重新走本函数(重新换取),最多 N 次 */ }
client.onMessage = (topic: string, payload: string) => { /* 解析 JSON */ }
client.connect()
client.subscribe('/notify/' + account + '/#', 0)
}
```
实现参考:插件自带 `device-panel`(`addon/iot/client/uvue/device-panel/`)已按方式三实现完整「换凭证 → 连接 → 断线重连前重换」流程,可直接照搬。
**订阅被拒必须做成可见提示**(`onSubAck`):归属校验不通过时,服务端仍会回 SUBACK,只是结果码为 `0x80`(MQTT 3.1.1)/ `0x87` 等(MQTT5),
**连接是成功的、但该主题不会来任何数据**。业务页若不处理该回调,现象就是「连上了却收不到数据」,只能靠猜。
```ts
client.onSubAck = (topic: string, code: number) => {
if (code >= 128) {
showTip('无权订阅 ' + topic + ':设备未绑定到本账号或共享已撤销') // 必须让用户看到
} else {
// code = 服务端授予的 QoS(0/1/2)
}
}
```
- 收 iOS ATS 限制:线上必须使用 `wss://`(`ws_port` 同端口 TLS 部署,或经 Nginx 反代 wss)。
- 心跳由客户端自动维持(默认 60s 的 1/2),断线自动指数退避重连并恢复订阅。
## 8. 传感器配置与 state 报文(固件 v1.2.3+)
### 8.1 设计原则:配置与数值分离
| 数据 | 存放位置 | 频率 | 说明 |
|------|---------|------|------|
| 位置数 / 别名 / 探头数 / 上限 / 下限 / 温差阈值 | 服务端 `iot_sensor_cfg`(唯一真源) | 月级 | retained 下发设备 + App 经 HTTP 读同一份 |
| 当前温度 / 极差 / 探头有效性 | `/devices/{uuid}/state`(retained 影子) | 温变触发 + 120s 兜底 | 只传数值,不传设置 |
| 在线态 | `/devices/{uuid}/status`(retained,LWT) | 上下线 | `{"online":1,"pins":[3,4,10]}`(v1.7.0+ 上线时顺带报一次总线 GPIO 表);LWT 固定 `{"online":0}` |
| 配置回执 | `/devices/{uuid}/ack`(非 retained) | 收到配置时 | `{"type":"config_ack","success":true,"cv":7}`(≤1.6.2 固件回 `cfg_ver`,服务端两版都认) |
**为什么配置不放报文**:位置列表必须在设备离线时也能渲染(App 打开详情页不能空白),且 `alias/upper/lower/gap` 是月级静态数据,塞进秒级报文纯属浪费(旧报文里它占了近一半体积)。
### 8.2 App 侧流程
```
① GET /iot/api/client/deviceConfig?uuid=xxx → 画骨架(离线也有卡片,值显示 --)
② 订阅 /devices/{uuid}/state → 填数值(按 **数组下标** 对齐位置序号,不看顺序也不读 index)
③ 订阅 /devices/{uuid}/status → 覆盖在线态(低频上报后不能靠"多久没收到 state"判离线)
④ POST /iot/api/client/saveDeviceConfig → 改配置(全量覆盖语义),保存即 retained 下发
```
`deviceConfig` 返回:`{cfg_ver, sync_ver, sync_at, applied, pins:[3,4,10], sensors:[{index,alias,probes,upper,lower,gap,enabled}]}`
(位置序号键统一 `index`;服务端兼容旧键 `i`,但 App 新代码只读写 `index`)
`applied=true` 表示设备已回 `config_ack`(`sync_ver >= cfg_ver`);`false` 时显示「待设备生效」(设备离线,上线订阅瞬间即收到 retained 配置)。
`pins` = 总线 GPIO 表(下标 = 位置序号),来自设备上线时 `status` 报文里的 `pins`,服务端缓存后下发。
它是给 App **画骨架时就把 `GPIOx ·` 显示出来**用的(v1.7.0 起 state 不再逐条带 `pin`)。
### 8.3 state 报文(`/devices/{uuid}/state`,retained)
固件 **v1.7.0** 起键名压到 2 字母,并去掉两处冗余(顶层 `uuid`、每位置 `pin`);
结构自 **v1.6.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
]
}
```
> **为什么 state 里没有 `uuid` / `pin` 了**
> ① `uuid`:报文就发布在 `/devices/{uuid}/state` 上,App 订阅时用的就是这个 uuid ⇒ 省 ≈25B。
> ② `pin`:**编译期常量** `ONEWIRE_PINS[下标]`,设备不会变 ⇒ 改为上线时在 `status` 报一次,
> 服务端缓存进 `iot_device.pins`,App 从 `deviceConfig` 的 `pins` 取(每启用位置省 ≈8B)。
> ⚠️ 已烧录的 ≤1.6.2 固件仍会带这两个键 ⇒ **两条路都要留着**:
> `pin` 仍在 `normalizePositions()` 里解析(`ps.pin > 0` 就写进 `lastPins`),
> 老固件照旧能用;新固件则靠 `applyPins()` 从 `deviceConfig.pins` / `status.pins` 补。
各版式的解析统一在 `normalizePositions()` / `normalizeProbes()` 里归一化 ——
设备不升级也能继续用,反过来设备升到 v1.7.0 后 App 也不必同时改:
`iv`(旧名 `interval`;≤1.5.0 也曾叫 `iv`)与 `cv`(旧名 `cfg_ver`)一律「新键优先、旧键兜底」,
**不要再按版式分支取这两个键** —— 旧写法 `hasNew ? obj['interval'] : obj['iv']` 在 v1.7.0 下会取到 0。
| 字段 | 含义 | App 处理 |
|------|------|---------|
| `pos`(旧名 `positions`) | ⚠️ **数组下标就是位置序号**(0 起,= OneWire 总线 A/B/C);**已无 `index` 字段**;`null` = 该位置未启用 | **用数组下标匹配骨架**(不再读 `index`);`null` 元素直接跳过;`pos` 取不到时回落 `positions` |
| ~~`pin`~~ | 该总线实际 GPIO(固件 `ONEWIRE_PINS[下标]`);**纯属性,不是身份** | ⚠️ **v1.7.0 起 state 不再带**。仅展示(如 `GPIO4 ·`),**不要用它做匹配键**。App 用 `lastPins` 攒住,两个来源:`deviceConfig.pins`(v1.7.0+,见 `applyPins()`)与老固件 state 里的 `pin`;重拉配置后靠它补回 |
| `t` | 该位置温度(有效探头算术平均);`null` = 无有效读数 | 显示 2 位小数;`null` 显示「--(无有效读数)」,**不要显示 0** |
| `df` | 位置内极差(`max-min`,探头侧算好);`null` 同 `t` | 显示;`gap>0` 且 `df>=gap` 视为告警 |
| `pr[]`(旧名 `probes`) | 该总线上各探头;**数组下标 == 总线槽位**,尾部空槽已截(空总线 `[]`);元素是**二元组 `[身份, 温度]`** | 逐探头明细,统计 `x/y 有效`;`pr` 取不到时回落 `probes` |
| `pr[][0]` | 探头身份(ROM 短码,如 `"A3F2"`);`null` = 未识别 | 显示时带上(`A3F2 45.5`),便于跨时间追踪同一颗 |
| `pr[][1]` | 该槽温度;`null` = 无读数。**配合身份可区分两种故障** | 见下方说明 |
| `ts` | NTP Unix 秒;`0` = 未对时 | 历史时间轴优先用它,为 0 时用手机接收时间兜底 |
| `iv`(旧名 `interval`) | 本条数据覆盖的秒数(距上次上报) | **前向填充**:温度值有效期为 `[ts-iv, ts]`,误差上界 = 温变阈值 |
| `cv`(旧名 `cfg_ver`) | 设备当前生效的配置版本 | 与本地不一致 → 重新拉一次 `deviceConfig`(HTTP 字段名仍是 `cfg_ver`,别混) |
> **身份与温度解耦(v1.5.0 的关键修复)**:身份只要 ROM 枚举到就会上报,与读数成败无关。
> 于是「该槽没插器件」= `[null, null]`,「插了但读数失败(接触不良/供电)」=
> `["B71C", null]`,一眼可分。旧版 `ok[j]=false` 时 `ids[j]` 也被一并抹成空串,
> 这两种情况在报文上完全一样,只能连串口才能判断。
>
> 🚨 **`[null, 24]`(有读数、没身份)不是 App 解析问题**,是固件取不到 ROM 短码:
> ①该片序列号 6 字节全 0(克隆/翻新片,无唯一身份,只能按槽位区分);
> ②固件 < v1.6.1 且上电瞬间 `getAddress()` 失败过一次 —— 旧代码会把身份**永久锁死**为
> `null`(温度照常、重启也不恢复),v1.6.1 改为每采样周期自己搜一遍总线后已修。
> App 侧兜底:身份为空时显示槽位号 `P1/P2…`,不要显示空白(否则看着像 App 的 bug)。
>
> 另注:`t` 是位置级「有效探头均值」,**不要**用 `t == 0` 判无效——`0.00℃` 是
> DS18B20 的合法读数(冰水混合物),判无效只看是否为 `null`。
**更早的三个版式对照**:
```json
// v1.6.0:位置化数组(下标即位置序号)+ 二元组探头,键名写全
{ "uuid":"A1B2C3D4E5F6", "ts":1789000000, "ver":"1.6.0", "rssi":-57,
"interval":120, "cfg_ver":7,
"positions":[ {"pin":3,"t":45.63,"df":0.25,
"probes":[["A3F2",45.50],["B71C",null]]}, null ] }
// v1.5.0:键名写全、按 index 对齐
{ "positions":[ {"index":0,"pin":3,"temp":45.63,"temp_diff":0.25,
"probes":[{"id":"A3F2","temp":45.50},{"id":"B71C","temp":null}]} ] }
// ≤1.4.0:1~2 字母键 + v 标志 + 三个平行数组
{ "data":[ {"i":0,"t":45.63,"df":0.25,"v":true,
"vs":[45.50,45.75,0], "ok":[true,true,false], "ids":["A3F2","B71C",""]} ],
"rssi":-57,"ts":1789000000,"iv":120,"cfg_ver":7,"ver":"1.4.0" }
```
判定版式**只看键在不在,不看 `ver` 字符串**(版本号可能忘了升,键不会撒谎):
1. 有 `pos` 或 `positions` 键 → 新式(位置化数组);否则是 ≤1.4.0 的 `data[]`;
2. 新式里再分:位置元素含 `index` / `temp` / `temp_diff` 任一 → **v1.5.0**;
都没有 → **v1.6.0 / v1.7.0**(这两者只差键名:`pos`/`pr` vs `positions`/`probes`);
3. 位置元素可能是 JSON `null` → **必须先判空再取字段**,别直接访问属性;
4. 探头明细:`pr`(v1.7.0)与 `probes`(≤1.6.2)**同构**(都是二元组)→ `pr` 优先、`probes` 兜底。
⚠️ **`t` 是同名陷阱**:v1.6.0 的 `t` 与 ≤1.4.0 的 `t` 同名但语义不同 ——
旧版靠 `v` 标志判有效(`v=false` 时 `t` 照样带 `0`),新式靠 `null` 判。
所以解析顺序必须是「先判有没有 `v`」,否则会把「没读数」画成 `0.00℃`。
### 8.4 上报节奏(为什么可以低频又不丢实时性)
采样仍是 **10s**(告警判定实时性由它保证),上报改为 **温变触发 + 120s 兜底**:
① 任一有效探头 `|Δ| ≥ 0.3℃` ② 探头有效性掩码变化 ③ 告警状态翻转 ④ 距上次发送 ≥ 120s。
另:开机/重连、收到新配置后立即发一条。
实测流量:135MB/月 → ≈9.6MB/月(降 93%)。
### 8.5 历史数据(App 本地落库)
服务端不存历史。App 在收到 state 后按天分片写入本地存储:
- key:`iot_hist_{uuid}_{YYYYMMDD}`
- 行:`ts,i,t,df,okCount,total,valid`(`\n` 分隔,便于直接画曲线)
- 保留 7 天;同一 `ts+i` 会去重(retained 报文重连时会被重投)
- 容量:温变上报约 1000 条/天/位置,一行 ≈24B → 单日约 25KB,7 天可忽略
### 8.6 固件侧要点(易踩坑)
1. `onConfigJson` 是**全量覆盖**语义:`data` 里没有的位置一律停用,改完写 NVS 并回 `config_ack`。
2. `PubSubClient` 回调的 `payload` **不是 NUL 结尾**,必须 `memcpy` 到自有缓冲区再补 `'\0'`。
3. 报文用 `DynamicJsonDocument(768)`,不要用 `StaticJsonDocument`(带探头明细会 NoMemory)。
4. LWT 与告警**分主题**(`/status` vs `/event`),两者都 retained,混用会互相覆盖。
5. 设备 ACL 是 `auto:device:{uuid}`(`/devices/%u/#`)覆盖全部子主题,改协议新增主题无需改 ACL。
6. 会员侧 ACL 是**生成时快照**:新增自动规则后,老设备需自愈——App 每次换 ticket 时服务端会自动重建(`DeviceService::resyncMemberAcl` + `ShareService::resyncMember`)。