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

+307
View File
@@ -0,0 +1,307 @@
# 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`)。