Files
YwxAppThink/docs/guide/device_register_mqtt.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

275 lines
13 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.
# 设备注册(MQTT `system/device/register/+`)
设备通过 MQTT 主题向服务端注册入网,领取正式连接凭证(uuid/secret)与订阅主题清单;用户随后用**设备识别码**绑定设备获取设备信息与面板类型。
支持两种协议:
1. **MAC 即插即用注册(推荐)**:设备上电自动上报 `mac/type/model/ver`,服务端自动入网,无需后台预登记。
2. **华为式预登记注册(旧协议,兼容)**:设备出厂预置 **SN + activate_token**,后台登记后激活。
## 主题约定
| 主题 | 方向 | 说明 |
|------|------|------|
| `system/device/register/{sn}` | 设备 → 服务端 | 设备发布注册/激活请求 |
| `system/device/register/{sn}/resp` | 服务端 → 设备 | 注册回执(凭证 + 订阅主题 / 错误原因) |
ACL:上述主题**内置放行**(`Auth::checkAcl` 对 `system/device/register/{sn}` 发布与 `.../resp` 订阅始终放行,不依赖 ACL 开关),设备可在激活前匿名/以 SN 连接完成注册。
---
## MAC 即插即用注册(推荐,ESP32 等设备直连入网)
设备无需后台预登记:上电后把自身信息发布到注册主题,服务端**自动创建设备**并发放凭证。
### 主题
| 主题 | 方向 | 说明 |
|------|------|------|
| `system/device/register/{mac}` | 设备 → 服务端 | 注册请求(`{mac}` = 设备 MAC 去冒号小写,12 位 hex,如 `a0b1c2d3e4f5`) |
| `system/device/register/{mac}/resp` | 服务端 → 设备 | 注册回执(凭证 + 订阅主题 / 错误原因) |
ACL:上述主题**内置放行**,设备可在未激活前以任意 clientId(建议直接用 mac)连接并完成注册。
### 注册消息(设备上电 publish,建议 retained)
```json
{
"mac": "a0b1c2d3e4f5",
"type": "esp32c3_temp",
"model": "KF-C3-3KANG",
"ver": "1.0"
}
```
| 字段 | 值 | 作用 |
|------|------|------|
| `mac` | 设备 MAC 去冒号小写 | 唯一身份证,服务端靠它区分设备、做绑定(须与主题中的 mac 一致,否则拒绝) |
| `type` | `esp32c3_temp` | 设备类型,服务端落库为 `dev_type`,App 据此渲染面板(炕温控器/插座/灯…) |
| `model` | `KF-C3-3KANG` | 产品型号,后台管理区分硬件版本 |
| `ver` | `1.0` | 固件版本,服务端记录,用于版本检测/OTA 提示 |
> **retained 说明**:服务端收到 publish 即处理(不依赖 retained 标志)。retained 的作用是让**之后订阅**该主题的客户端也能看到设备注册信息。回执 `.../resp` **不会 retained**(防止 secret 泄露给后来的订阅者),设备若未收到回执应延时重发注册消息(例如 5s/30s/5min 退避)。
### 处理流程
```
① 设备联网:订阅 system/device/register/{mac}/resp
② 设备发布(retained):system/device/register/{mac},payload 含 mac/type/model/ver
③ 服务端:mac 首次出现 → 自动创建设备(sn=mac)并生成 uuid/secret,写入 iot_device
④ 服务端回执:{uuid, secret(仅首次), dev_type, model, ver, topics}
⑤ 设备保存凭证,改用 clientId=uuid / username=uuid / password=secret 连接
⑥ 设备按 topics 订阅 /devices/{uuid}/#,收发状态与命令
```
再次上报(设备重启 / 固件升级后):
- mac 已存在 → 幂等更新 `type/model/ver`,**不重置凭证**,回执不带 secret;
- 固件 `ver` 变化自动同步到设备记录,后台/OTA 据此判断是否需要推送新固件。
### 回执 payload(成功)
```json
{
"code": 0,
"message": "ok",
"data": {
"id": 1, "sn": "a0b1c2d3e4f5", "mac": "a0b1c2d3e4f5",
"uuid": "dev_8f3a...",
"dev_type": "esp32c3_temp", "model": "KF-C3-3KANG", "ver": "1.0",
"secret": "明文凭证(仅首次注册返回)",
"activated": true, "bound": false,
"topics": {
"state": "/devices/dev_8f3a.../state", "cmd": "/devices/dev_8f3a.../cmd",
"ota": "/devices/dev_8f3a.../ota", "ota_report": "/devices/dev_8f3a.../ota/report",
"event": "/devices/dev_8f3a.../event"
}
}
}
```
失败(`code=1`)示例:`{"code":1,"message":"payload mac 与主题不一致","data":null}`、`{"code":1,"message":"mac 格式非法(应为12位十六进制,去冒号小写)","data":null}`。
### 用户绑定(App 扫码/输 mac)
```
POST /iot/api/client/bindDevice (需登录)
body: { "sn": "a0b1c2d3e4f5", "title": "炕温控器" }
```
MAC 自动注册的设备**无配对码**(扫码即绑,先到先得);如需防抢绑,可在后台设备详情为该设备补设 `pair_code`,此后绑定须携带该码。
绑定成功返回 `mac / dev_type / ver` 与 `topics`:App 用 `dev_type` 查「设备类型 → 面板」映射表渲染对应控制面板,用 `ver` 与后台最新固件比对触发 OTA 提示。
---
## 华为式预登记注册(旧协议,兼容)
```
① 后台预登记设备:生成 sn + pair_code(贴纸)+ activate_token(出厂预置)
② 设备联网:订阅 system/device/register/{sn}/resp
③ 设备发布:system/device/register/{sn},payload 见下
④ 收到回执:uuid / secret / topics → 保存凭证
⑤ 使用凭证连接 MQTT:clientId=uuid,username=uuid,password=secret
⑥ 按 topics 订阅 /devices/{uuid}/# 收发状态与命令
```
### 请求 payload(②③)
```json
{
"token": "b5d3...32位hex激活令牌"
}
```
也兼容裸 token 字符串形式(直接以 token 作为 payload 发布)。
### 回执 payload(④)
成功(`code=0`):
```json
{
"code": 0,
"message": "ok",
"data": {
"id": 1,
"sn": "SN20260826",
"uuid": "dev_8f3a...",
"title": "智能灯泡",
"model": "LAMP-01",
"secret": "明文凭证(仅激活成功这一次返回)",
"activated": true,
"bound": false,
"topics": {
"state": "/devices/dev_8f3a.../state",
"cmd": "/devices/dev_8f3a.../cmd",
"ota": "/devices/dev_8f3a.../ota",
"ota_report": "/devices/dev_8f3a.../ota/report",
"event": "/devices/dev_8f3a.../event"
}
}
}
```
失败(`code=1`):`{ "code": 1, "message": "激活令牌无效或已失效", "data": null }`
幂等:已激活设备重复注册不报错,回执返回 `activated:true` 但 **不再下发 secret**(如需换凭证走 `resetSecret`)。
## 用户绑定设备识别码(App 侧)
设备激活后,用户通过 App 绑定:
```
POST /iot/api/client/bindDevice (需登录)
body: { "sn": "SN20260826", "pair_code": "123456", "title": "客厅灯" }
```
绑定成功返回(含订阅主题与设备信息):
```json
{
"code": 1,
"data": {
"id": 1, "sn": "SN20260826", "uuid": "dev_8f3a...",
"title": "客厅灯", "model": "LAMP-01", "room": "",
"bound": true, "activated": true, "secret": "",
"topics": { "state": "/devices/dev_8f3a.../state", "cmd": "/devices/dev_8f3a.../cmd", "ota": "/devices/dev_8f3a.../ota", "ota_report": "/devices/dev_8f3a.../ota/report", "event": "/devices/dev_8f3a.../event" }
}
}
```
> 注:插件 API 的 `Result::success` 用 `code=1` 表示成功(与框架 `code=0` 相反),解析时以 `message` 键为准。
绑定后 App 即可使用 `uuid` 连接 MQTT 推送(WebSocket `ws://{host}:8083`)或通过 REST 接口下发命令。
## 其他相关接口
| 接口 | 说明 |
|------|------|
| `POST /iot/api/deviceActivate` | HTTP 方式激活(`sn` + `token`),与 MQTT 注册等效,适合无 MQTT 客户端的设备 |
| `POST /iot/api/resetSecret` | 重置设备密钥(返回新明文,旧密钥作废) |
| `GET /iot/api/myDevices` | 我的设备列表(主人 + 共享,含在线状态/影子) |
| `POST /iot/api/sendCommand` | 向设备下发命令(写入 `/devices/{uuid}/cmd`) |
| `POST /iot/api/deviceLogs` | 设备消息流水 |
## 设备侧配网(ESP32 固件 `water_temp.ino`)
现场部署**不再需要**为了改 WiFi 重烧固件(OTA 又必须先联网,过去是死锁)。固件用 `WiFiManager` 自带配网页,**v1.6.2 起表单里只有 WiFi**(服务器与端口已写死在固件里,见下节):
1. 上电先用**已保存凭据**连网(`WIFI_CONNECT_S` = 15s),连上即正常跑,不会打扰用户;
2. 连不上(首次上电 / 换路由器 / 改密码)→ 自动开配网热点
`TempMonit-Esp32<MAC后4位>`(默认密码 `12345678`,见 `WIFI_AP_PASS`);
3. 手机连热点 → 浏览器打开 `http://192.168.4.1`(Android 会自动弹配网页)
→ 表单**只填 WiFi** → 保存 → 设备自动重启生效;
4. 热点 180s(`WIFI_PORTAL_S`)无人操作自动关闭,回后台继续重试 —— 要重新配网请断电重上电。
### 服务器地址:编译期写死 + 备用机自动切换(v1.6.2)
| 项 | 位置 | 说明 |
|---|---|---|
| 服务器表 | 固件常量区 `SRV_HOSTS[]` | 按 **主 → 备** 顺序填,只填域名或 IP(表尾空串/`nullptr` 即结束) |
| 端口 | `DEF_MQTT_PORT` = `1883` | **写死**,所有服务器共用(配网页已无端口输入框) |
| 切换阈值 | `SRV_FAILOVER_N` = `3` | 当前这台连续失败 3 次(5s/次 ⇒ 约 15s)切下一台,走到表尾回到第 0 台,永不卡死 |
| 切换条件 | 仅「网络层」失败 | `-2/-3/-4/3`(TCP 连不上/超时/被断/服务端不可用)才切;**CONNACK 1~5 是「服务器已应答、只是拒绝」(凭据/ACL 问题),换台毫无意义** |
默认表(按现场改这几行即可):
```c
const char* SRV_HOSTS[SRV_MAX] = {
"mqtt.ywxapp.cn", // 0 主:域名(正常走这条)
"211.149.215.200", // 1 备:同机 IP 直连 —— 免 DNS(解析失败/被劫持时仍能连上)
// "mqtt2.ywxapp.cn", // 2 备:真·第二台服务器(有独立机器时填它的域名或 IP)
};
```
- 串口:开机打印整张表(`[SRV] ... <- 当前`),心跳行带 `srv=`,切换时打
`[SRV] 连接连续失败:A → B(第 2/2 台)` —— 排障一眼看清在用哪台、切过没有。
- NVS 里的 `mqtt_host` / `mqtt_port`(≤v1.6.1 写过的)**一律不再读取**;遗留键不擦除(省一次 flash 写)。
- ⚠️ 换服务器 / 加备用机 = 改表后**重新编译烧录**;现场设备不需要任何配置,也就没有填错的可能。
- 注册流程同样参与切换(主服务器挂掉时,新设备也必须能注册上)。
**远程清空重配**(设备还连得上时最方便,App 用 `sendCommand` 把 payload 原样发下去即可):
```json
{"action": "wifi_reset"}
```
设备先回 `ack`,随后抹掉 WiFi 凭据并重启 → 开机连不上 → 自动进配网热点。
⚠️ **只清 WiFi 凭据**:`uuid` / `secret` / 传感器配置全部保留(服务器写死在固件里,不受影响),不需要重新注册。
### 清除身份 / 恢复出厂(固件 v1.3.3+,BOOT 键)
⚠️ **背景(重要)**:后台「删除设备」是**硬删** —— `DeviceService::remove()` 会清掉
`iot_device`、`iot_auth`、`iot_acl`、`iot_retain`、`iot_topic`、`iot_offline`、
分组关联 / 共享关系 / 传感器配置。**但设备端 NVS 里的 `uuid`/`secret` 不会被清**,于是:
> 设备拿旧凭据去连 → `iot_auth` 里已无此用户 → CONNACK 4/5 被拒;
> 而注册流程只在「`uuid` 为空」时才启动 → **设备永久爬不起来**。
> 现场表现:串口一直 `[MQTT] fail rc=4`,后台也永远不会再出现该设备。
固件 v1.3.3 起提供设备端「清除身份」入口,**现场无需拆机重烧**:
| 操作 | 清除内容 | 结果 |
|---|---|---|
| **按住 BOOT 键 3 秒后松手** | `uuid` / `secret` / 传感器配置 | 重启 → 自动重新注册,拿全新凭证;**WiFi 与服务器地址保留,不用重新配网** |
| **按住 BOOT 键 8 秒不松** | 上述 + WiFi 凭据(彻底恢复出厂) | 重启 → 进配网热点 `TempMonit-Esp32<MAC后4位>` |
- **键位**:ESP32-C3 = `GPIO9`,经典 ESP32 = `GPIO0`(改固件宏 `BTN_PIN`);固件里的
`ONEWIRE_PINS`(当前 `{3,4,10}`)不要与它撞脚。
- **上电时已按住** 也生效(恢复出厂的标准姿势),无 3s 内的时机要求。
- 串口日志以 `[BTN]` / `[RST]` 开头,会在按满 3s 时提示"现在松手=清身份 / 继续按住=全清"。
- **护栏**:开机按住超 15s(`BTN_ABNORMAL_MS`)判定为引脚异常并放弃清除;运行期必须先
观察到一次「松开」才允许触发(`armed` 标志),引脚恒低的板子不会被反复恢复出厂。
**远程等价命令**(设备仍在线时最方便,发到 `/devices/{uuid}/cmd`):
```json
{"action": "clear_identity"}
```
```json
{"action": "factory_reset"}
```
⚠️ 重新注册后拿到的是**全新 uuid**,后台会新增一条设备(`member_id=0` 未绑定),
需在 App / 后台**重新绑定**;旧 `iot_device_log` / `iot_alert` 中按旧 uuid 存的历史不会自动迁移。