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

20 KiB
Raw Blame History

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 引入工程

  1. 把插件目录 addon/iot/client/uvue/mqtt-debug/ 整个复制到 uni-app x 工程,例如放到 pages/mqtt-debug/(index.uvue 与 MqttClient.uts 必须同目录,页面用相对路径 import)。
  2. pages.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 代码接入要点(业务页面直接使用)

// 方式二:会员账号 + 登录密码(内测/自用)
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 字符串(版本号可能忘了升,键不会撒谎):

  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)。