安全(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(市场 / 开发者中心 / 审核 / 分润)
20 KiB
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 窗口分别启动:
窗口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 连接,会员明文密码不出客户端(防反编译泄露):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 |
统一约定:所有 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 引入工程
- 把插件目录
addon/iot/client/uvue/mqtt-debug/整个复制到 uni-app x 工程,例如放到pages/mqtt-debug/(index.uvue与MqttClient.uts必须同目录,页面用相对路径 import)。 pages.json注册页面:{ "path": "pages/mqtt-debug/index", "style": { "navigationBarTitleText": "MQTT 联调" } }MqttClient.uts是通用 MQTT 3.1.1 over WebSocket 客户端(无任何第三方依赖,基于uni.connectSocket二进制收发),可复用到业务页面。
7.2 联调步骤
- 后台「服务设置」确认
ws_port(默认 8083);Windows 开发机把ws_force设为「开启」、cluster_enabled设为「开启」(需本机 Redis)。 - 启动 Broker:Linux 一条命令
php think iot start -d;Windows 开两个窗口分别--tcp-only与--ws-only(见第 1 节提示)。 - 页面填写
ws://{局域网IP}:8083;账号填会员账号(会员须已绑定/被共享该设备)或认证账号(需 ACL 规则显式放行),密码对应;ClientID 留空自动生成。 - 点「连接服务器」,状态变「已连接」。
- 订阅
/devices/{uuid}/state(把{uuid}换成自己的设备):设备上报状态会实时滚入消息流。 - 发布
/devices/{uuid}/cmd:可下发开关指令,观察设备(或模拟器)响应。 - 订阅
/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 代码接入要点(业务页面直接使用)
// 方式二:会员账号 + 登录密码(内测/自用)
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)
// 方式三(推荐):一次性凭证 —— 每次连接/重连前先 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),
连接是成功的、但该主题不会来任何数据。业务页若不处理该回调,现象就是「连上了却收不到数据」,只能靠猜。
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 的紧凑版式起一字未动(只压「随位置/探头数线性增长」的那部分字节):
{
"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。
更早的三个版式对照:
// 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 字符串(版本号可能忘了升,键不会撒谎):
- 有
pos或positions键 → 新式(位置化数组);否则是 ≤1.4.0 的data[]; - 新式里再分:位置元素含
index/temp/temp_diff任一 → v1.5.0; 都没有 → v1.6.0 / v1.7.0(这两者只差键名:pos/prvspositions/probes); - 位置元素可能是 JSON
null→ 必须先判空再取字段,别直接访问属性; - 探头明细:
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 固件侧要点(易踩坑)
onConfigJson是全量覆盖语义:data里没有的位置一律停用,改完写 NVS 并回config_ack。PubSubClient回调的payload不是 NUL 结尾,必须memcpy到自有缓冲区再补'\0'。- 报文用
DynamicJsonDocument(768),不要用StaticJsonDocument(带探头明细会 NoMemory)。 - LWT 与告警分主题(
/statusvs/event),两者都 retained,混用会互相覆盖。 - 设备 ACL 是
auto:device:{uuid}(/devices/%u/#)覆盖全部子主题,改协议新增主题无需改 ACL。 - 会员侧 ACL 是生成时快照:新增自动规则后,老设备需自愈——App 每次换 ticket 时服务端会自动重建(
DeviceService::resyncMemberAcl+ShareService::resyncMember)。